From 77d23df1b1d7de0feb7b4aa79ec05208ce2e7823 Mon Sep 17 00:00:00 2001
From: Shrey Baheti
Date: Mon, 16 Feb 2026 14:22:24 +0530
Subject: [PATCH] Harden frontend validation, action locks, and error handling
---
frontend/app/audit/page.tsx | 176 ++++++++--------
frontend/app/catalog/page.tsx | 251 +++++++++++++----------
frontend/app/error.tsx | 33 +++
frontend/app/fines/page.tsx | 149 +++++++-------
frontend/app/global-error.tsx | 40 ++++
frontend/app/globals.css | 36 ++++
frontend/app/layout.tsx | 5 +-
frontend/app/login/page.tsx | 14 +-
frontend/app/member/page.tsx | 2 +-
frontend/app/page.tsx | 68 +++++-
frontend/app/users/page.tsx | 204 ++++++++++--------
frontend/components/AppErrorBoundary.tsx | 68 ++++++
frontend/components/ListViewCard.tsx | 45 ++++
frontend/lib/api-paths.ts | 23 +++
frontend/lib/api.ts | 67 +++---
15 files changed, 784 insertions(+), 397 deletions(-)
create mode 100644 frontend/app/error.tsx
create mode 100644 frontend/app/global-error.tsx
create mode 100644 frontend/components/AppErrorBoundary.tsx
create mode 100644 frontend/components/ListViewCard.tsx
create mode 100644 frontend/lib/api-paths.ts
diff --git a/frontend/app/audit/page.tsx b/frontend/app/audit/page.tsx
index 3e27cb2..c0bc7b4 100644
--- a/frontend/app/audit/page.tsx
+++ b/frontend/app/audit/page.tsx
@@ -2,6 +2,7 @@
import { useEffect, useMemo, useState } from "react";
+import ListViewCard, { ListGrid } from "../../components/ListViewCard";
import SearchableSelect from "../../components/SearchableSelect";
import { useToast } from "../../components/ToastProvider";
import { queryAuditLogs } from "../../lib/api";
@@ -96,70 +97,98 @@ export default function AuditPage() {
Audit Timeline
Database-backed request audit log for all mutating API operations.
-
-
-
-
- setSearch(event.target.value)}
- placeholder="Path, method, role, status, actor, entity id"
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
+
Persisted in DB}
+ filters={(
+ <>
+
+
+ setSearch(event.target.value)}
+ placeholder="Path, method, role, status, actor, entity id"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+ footer={(
+
+
+ Page {page} · Showing {events.length} record{events.length === 1 ? "" : "s"}
+
+
+ setPage((current) => Math.max(1, current - 1))}
+ disabled={page === 1 || loading}
+ data-testid="audit-prev-page"
+ >
+ Previous
+
+ setPage((current) => current + 1)}
+ disabled={!hasNextPage || loading}
+ data-testid="audit-next-page"
+ >
+ Next
+
+
-
-
+ )}
+ >
+
{events.map((event) => (
@@ -191,33 +220,8 @@ export default function AuditPage() {
) : null}
-
-
-
- Page {page} · Showing {events.length} record{events.length === 1 ? "" : "s"}
-
-
- setPage((current) => Math.max(1, current - 1))}
- disabled={page === 1 || loading}
- data-testid="audit-prev-page"
- >
- Previous
-
- setPage((current) => current + 1)}
- disabled={!hasNextPage || loading}
- data-testid="audit-next-page"
- >
- Next
-
-
-
-
+
+
);
}
diff --git a/frontend/app/catalog/page.tsx b/frontend/app/catalog/page.tsx
index 41dd41d..6f6668e 100644
--- a/frontend/app/catalog/page.tsx
+++ b/frontend/app/catalog/page.tsx
@@ -2,6 +2,7 @@
import { useEffect, useMemo, useState } from "react";
import ActionModal from "../../components/ActionModal";
+import ListViewCard, { ListGrid } from "../../components/ListViewCard";
import SearchableSelect from "../../components/SearchableSelect";
import { useToast } from "../../components/ToastProvider";
import { createBook, deleteBook, getBooks, queryBooks, updateBook } from "../../lib/api";
@@ -35,6 +36,8 @@ export default function CatalogPage() {
const [modalMode, setModalMode] = useState
(null);
const [activeBookId, setActiveBookId] = useState(null);
const [bookForm, setBookForm] = useState(initialBookForm);
+ const [bookSubmitting, setBookSubmitting] = useState(false);
+ const [deletingBookId, setDeletingBookId] = useState(null);
const sortConfigMap: Record = {
title_asc: { sort_by: "title", sort_order: "asc" },
@@ -187,6 +190,17 @@ export default function CatalogPage() {
const handleBookSubmit = async (event: React.FormEvent) => {
event.preventDefault();
+ if (bookSubmitting) return;
+ const trimmedTitle = bookForm.title.trim();
+ const trimmedAuthor = bookForm.author.trim();
+ if (!trimmedTitle || !trimmedAuthor) {
+ showToast({
+ type: "error",
+ title: "Title and author are required",
+ description: "Whitespace-only values are not allowed.",
+ });
+ return;
+ }
const copies = Number(bookForm.copies_total);
if (!Number.isFinite(copies) || copies < 1) {
showToast({ type: "error", title: "Total copies must be 1 or higher" });
@@ -207,12 +221,13 @@ export default function CatalogPage() {
return;
}
+ setBookSubmitting(true);
try {
if (modalMode === "edit") {
if (!activeBookId) return;
await updateBook(activeBookId, {
- title: bookForm.title.trim(),
- author: bookForm.author.trim(),
+ title: trimmedTitle,
+ author: trimmedAuthor,
subject: bookForm.subject.trim() || null,
rack_number: bookForm.rack_number.trim() || null,
isbn: bookForm.isbn.trim() || null,
@@ -223,6 +238,8 @@ export default function CatalogPage() {
} else {
await createBook({
...bookForm,
+ title: trimmedTitle,
+ author: trimmedAuthor,
subject: bookForm.subject.trim() || undefined,
rack_number: bookForm.rack_number.trim() || undefined,
isbn: bookForm.isbn.trim() || undefined,
@@ -242,12 +259,16 @@ export default function CatalogPage() {
title: modalMode === "edit" ? "Unable to update book" : "Unable to create book",
description: err.message || "Request failed",
});
+ } finally {
+ setBookSubmitting(false);
}
};
const handleDeleteBook = async (book: any) => {
+ if (bookSubmitting || deletingBookId !== null) return;
const ok = window.confirm(`Delete "${book.title}"?`);
if (!ok) return;
+ setDeletingBookId(book.id);
try {
await deleteBook(book.id);
if (activeBookId === book.id) {
@@ -261,6 +282,8 @@ export default function CatalogPage() {
title: "Unable to delete book",
description: err.message || "Request failed",
});
+ } finally {
+ setDeletingBookId(null);
}
};
@@ -282,11 +305,20 @@ export default function CatalogPage() {
-
+
Add Book
- refresh(true)}>
- Refresh
+ refresh(true)}
+ disabled={loading || bookSubmitting || deletingBookId !== null}
+ >
+ {loading ? "Refreshing..." : "Refresh"}
@@ -310,78 +342,106 @@ export default function CatalogPage() {
-
-
-
Catalog Index
- Searchable
-
-
-
-
- setSearch(event.target.value)}
- placeholder="Title, author, subject, rack, ISBN, Book ID"
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
Searchable}
+ filters={(
+ <>
+
+
+ setSearch(event.target.value)}
+ placeholder="Title, author, subject, rack, ISBN, Book ID"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+ footer={(
+
+
+ Page {page} · Showing {books.length} record{books.length === 1 ? "" : "s"}
+
+
+ setPage((current) => Math.max(1, current - 1))}
+ disabled={page === 1 || loading}
+ data-testid="catalog-prev-page"
+ >
+ Previous
+
+ setPage((current) => current + 1)}
+ disabled={!hasNextPage || loading}
+ data-testid="catalog-next-page"
+ >
+ Next
+
+
-
-
+ )}
+ >
+
{books.map((book) => (
openEditModal(book)}
+ disabled={bookSubmitting || deletingBookId !== null}
>
Edit
@@ -432,8 +493,9 @@ export default function CatalogPage() {
className="danger small"
type="button"
onClick={() => handleDeleteBook(book)}
+ disabled={bookSubmitting || deletingBookId !== null}
>
- Delete
+ {deletingBookId === book.id ? "Deleting..." : "Delete"}
@@ -452,33 +514,8 @@ export default function CatalogPage() {
)}
-
-
-
- Page {page} · Showing {books.length} record{books.length === 1 ? "" : "s"}
-
-
- setPage((current) => Math.max(1, current - 1))}
- disabled={page === 1 || loading}
- data-testid="catalog-prev-page"
- >
- Previous
-
- setPage((current) => current + 1)}
- disabled={!hasNextPage || loading}
- data-testid="catalog-next-page"
- >
- Next
-
-
-
-
+
+
-
+
Cancel
-
- {modalMode === "edit" ? "Save Changes" : "Add Book"}
+
+ {bookSubmitting ? "Saving..." : modalMode === "edit" ? "Save Changes" : "Add Book"}
diff --git a/frontend/app/error.tsx b/frontend/app/error.tsx
new file mode 100644
index 0000000..d6225a6
--- /dev/null
+++ b/frontend/app/error.tsx
@@ -0,0 +1,33 @@
+"use client";
+
+import { useEffect } from "react";
+
+export default function AppRouteError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ useEffect(() => {
+ console.error("Route error boundary", error);
+ }, [error]);
+
+ return (
+
+
+ Error
+ Unable to render this page
+
+ An unexpected issue occurred while loading this route.
+
+ {error?.message ? {error.message}
: null}
+
+
+ Try Again
+
+
+
+
+ );
+}
diff --git a/frontend/app/fines/page.tsx b/frontend/app/fines/page.tsx
index 6d9f168..1724351 100644
--- a/frontend/app/fines/page.tsx
+++ b/frontend/app/fines/page.tsx
@@ -2,6 +2,7 @@
import { useEffect, useMemo, useState } from "react";
+import ListViewCard, { ListGrid } from "../../components/ListViewCard";
import SearchableSelect from "../../components/SearchableSelect";
import { useToast } from "../../components/ToastProvider";
import { FinePaymentLedgerItem, queryFinePayments } from "../../lib/api";
@@ -130,8 +131,8 @@ export default function FinesLedgerPage() {
Payment ledger for collected fines with user, loan, and catalog traceability.
- loadPage(true)}>
- Refresh
+ loadPage(true)} disabled={loading}>
+ {loading ? "Refreshing..." : "Refresh"}
@@ -154,53 +155,78 @@ export default function FinesLedgerPage() {
-
-
-
Collection Register
- Auditable
-
-
-
-
- setSearch(event.target.value)}
- placeholder="Payment ID, loan, user name/email/phone, title, ISBN, reference"
- />
-
-
-
-
-
-
-
-
-
-
+
Auditable}
+ filters={(
+ <>
+
+
+ setSearch(event.target.value)}
+ placeholder="Payment ID, loan, user name/email/phone, title, ISBN, reference"
+ />
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+ footer={(
+
+
+ Page {page} · Showing {rows.length} payment record{rows.length === 1 ? "" : "s"}
+
+
+ setPage((current) => Math.max(1, current - 1))}
+ disabled={page === 1 || loading}
+ >
+ Previous
+
+ setPage((current) => current + 1)}
+ disabled={!hasNextPage || loading}
+ >
+ Next
+
+
-
-
-
+ )}
+ >
+
{rows.map((row) => (
@@ -253,31 +279,8 @@ export default function FinesLedgerPage() {
) : null}
-
-
-
- Page {page} · Showing {rows.length} payment record{rows.length === 1 ? "" : "s"}
-
-
- setPage((current) => Math.max(1, current - 1))}
- disabled={page === 1 || loading}
- >
- Previous
-
- setPage((current) => current + 1)}
- disabled={!hasNextPage || loading}
- >
- Next
-
-
-
-
+
+
);
}
diff --git a/frontend/app/global-error.tsx b/frontend/app/global-error.tsx
new file mode 100644
index 0000000..6a6b568
--- /dev/null
+++ b/frontend/app/global-error.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import "./globals.css";
+
+export default function GlobalError({
+ error,
+ reset,
+}: {
+ error: Error & { digest?: string };
+ reset: () => void;
+}) {
+ return (
+
+
+
+
+ Critical Error
+ Application failed to load
+
+ A global rendering error occurred.
+
+ {error?.message ? {error.message}
: null}
+
+
+ Retry
+
+ window.location.reload()}
+ >
+ Reload
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/app/globals.css b/frontend/app/globals.css
index 16d8f6e..0e87823 100644
--- a/frontend/app/globals.css
+++ b/frontend/app/globals.css
@@ -181,6 +181,13 @@ a {
padding: 24px;
}
+.app-error-shell {
+ min-height: 100vh;
+ display: grid;
+ place-items: center;
+ padding: 24px;
+}
+
.auth-card {
width: min(480px, 100%);
background: var(--surface);
@@ -192,6 +199,17 @@ a {
gap: 14px;
}
+.app-error-card {
+ width: min(560px, 100%);
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 18px;
+ box-shadow: var(--shadow);
+ padding: 24px;
+ display: grid;
+ gap: 14px;
+}
+
.badge {
display: inline-flex;
align-items: center;
@@ -277,6 +295,14 @@ p.lede {
transform: translateY(-1px);
}
+.action-tile:disabled,
+.action-tile:disabled:hover {
+ background: linear-gradient(135deg, #fff, #fff8ee);
+ border-color: rgba(14, 107, 78, 0.22);
+ box-shadow: 0 6px 14px rgba(19, 15, 10, 0.08);
+ transform: none;
+}
+
.action-tile:focus-visible {
outline: 2px solid rgba(14, 107, 78, 0.55);
outline-offset: 2px;
@@ -947,6 +973,16 @@ button:hover {
background: var(--accent-dark);
}
+button:disabled {
+ cursor: not-allowed;
+ opacity: 0.62;
+ transform: none;
+}
+
+button:disabled:hover {
+ transform: none;
+}
+
button.secondary {
background: transparent;
color: var(--accent-dark);
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
index 90725bf..2c9d7c1 100644
--- a/frontend/app/layout.tsx
+++ b/frontend/app/layout.tsx
@@ -1,5 +1,6 @@
import "./globals.css";
import AppShell from "../components/AppShell";
+import AppErrorBoundary from "../components/AppErrorBoundary";
export const metadata = {
title: "Neighborhood Library Service",
@@ -10,7 +11,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
return (
-
{children}
+
+ {children}
+
);
diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx
index 5cc7cf3..81b0963 100644
--- a/frontend/app/login/page.tsx
+++ b/frontend/app/login/page.tsx
@@ -17,10 +17,17 @@ export default function LoginPage() {
const onSubmit = async (event: FormEvent) => {
event.preventDefault();
+ if (submitting || bootstrapping) return;
+ const trimmedEmail = email.trim();
+ const trimmedPassword = password.trim();
+ if (!trimmedEmail || !trimmedPassword) {
+ setError("Enter a valid email and password. Whitespace-only values are not allowed.");
+ return;
+ }
setSubmitting(true);
setError(null);
try {
- const result = await login({ email: email.trim(), password });
+ const result = await login({ email: trimmedEmail, password });
setStoredToken(result.access_token);
setStoredUser(result.user);
router.replace("/");
@@ -32,6 +39,7 @@ export default function LoginPage() {
};
const onBootstrap = async () => {
+ if (submitting || bootstrapping) return;
const trimmedName = name.trim();
const trimmedEmail = email.trim();
const trimmedPassword = password.trim();
@@ -99,14 +107,14 @@ export default function LoginPage() {
required
/>
-
+
{submitting ? "Signing in..." : "Sign In"}
{bootstrapping ? "Creating..." : "Bootstrap Admin (First Run)"}
diff --git a/frontend/app/member/page.tsx b/frontend/app/member/page.tsx
index 9c1e5e9..4267cf0 100644
--- a/frontend/app/member/page.tsx
+++ b/frontend/app/member/page.tsx
@@ -92,7 +92,7 @@ export default function MemberDashboardPage() {
My Borrowings & Fines
Track your active loans, returned books, due dates, and fine totals.
-
+
{loading ? "Refreshing..." : "Refresh"}
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index fc094f4..11de0ac 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -68,6 +68,8 @@ export default function BorrowingsPage() {
payment_mode: "upi",
reference: "",
});
+ const [borrowLoading, setBorrowLoading] = useState(false);
+ const [returnLoading, setReturnLoading] = useState(false);
const [fineLoading, setFineLoading] = useState(false);
const [quickUserForm, setQuickUserForm] = useState(initialQuickUser);
const [userActionLoading, setUserActionLoading] = useState(false);
@@ -77,6 +79,7 @@ export default function BorrowingsPage() {
const [registerPageSize, setRegisterPageSize] = useState(10);
const [registerHasNext, setRegisterHasNext] = useState(false);
const [registerLoading, setRegisterLoading] = useState(false);
+ const [refreshing, setRefreshing] = useState(false);
const [activeModal, setActiveModal] = useState(null);
const loanSortConfig: Record = {
@@ -136,6 +139,8 @@ export default function BorrowingsPage() {
};
const refresh = async (showSuccess = false) => {
+ if (refreshing) return;
+ setRefreshing(true);
try {
const [booksData, usersData, loansData] = await Promise.all([getBooks(), getUsers(), getLoans()]);
setBooks(booksData);
@@ -151,6 +156,8 @@ export default function BorrowingsPage() {
title: "Unable to load borrowings",
description: err.message || "Request failed",
});
+ } finally {
+ setRefreshing(false);
}
};
@@ -288,6 +295,8 @@ export default function BorrowingsPage() {
const handleBorrow = async (event: React.FormEvent) => {
event.preventDefault();
+ if (borrowLoading) return;
+ setBorrowLoading(true);
try {
await borrowBook({
book_id: Number(borrowForm.book_id),
@@ -308,11 +317,15 @@ export default function BorrowingsPage() {
title: "Borrowing failed",
description: err.message || "Request failed",
});
+ } finally {
+ setBorrowLoading(false);
}
};
const handleReturn = async (event: React.FormEvent) => {
event.preventDefault();
+ if (returnLoading) return;
+ setReturnLoading(true);
try {
await returnBook(Number(returnForm.loan_id));
setReturnForm({ loan_id: "" });
@@ -329,11 +342,14 @@ export default function BorrowingsPage() {
title: "Return failed",
description: err.message || "Request failed",
});
+ } finally {
+ setReturnLoading(false);
}
};
const handleCollectFine = async (event: React.FormEvent) => {
event.preventDefault();
+ if (fineLoading) return;
setFineLoading(true);
try {
await createLoanFinePayment(Number(fineForm.loan_id), {
@@ -362,10 +378,20 @@ export default function BorrowingsPage() {
const handleCreateQuickUser = async (event: React.FormEvent) => {
event.preventDefault();
+ if (userActionLoading) return;
+ const trimmedName = quickUserForm.name.trim();
+ if (!trimmedName) {
+ showToast({
+ type: "error",
+ title: "Name is required",
+ description: "Whitespace-only values are not allowed.",
+ });
+ return;
+ }
setUserActionLoading(true);
try {
const payload: any = {
- name: quickUserForm.name.trim(),
+ name: trimmedName,
email: quickUserForm.email.trim() || null,
phone: quickUserForm.phone.trim() || null,
role: quickUserForm.role,
@@ -398,6 +424,7 @@ export default function BorrowingsPage() {
};
const handleUpdateQuickUser = async () => {
+ if (userActionLoading) return;
if (!quickUserForm.id) {
showToast({
type: "info",
@@ -406,10 +433,19 @@ export default function BorrowingsPage() {
});
return;
}
+ const trimmedName = quickUserForm.name.trim();
+ if (!trimmedName) {
+ showToast({
+ type: "error",
+ title: "Name is required",
+ description: "Whitespace-only values are not allowed.",
+ });
+ return;
+ }
setUserActionLoading(true);
try {
const payload: any = {
- name: quickUserForm.name.trim(),
+ name: trimmedName,
email: quickUserForm.email.trim() || null,
phone: quickUserForm.phone.trim() || null,
role: quickUserForm.role,
@@ -433,6 +469,9 @@ export default function BorrowingsPage() {
}
};
+ const modalActionLoading = borrowLoading || returnLoading || fineLoading || userActionLoading;
+ const pageActionLoading = modalActionLoading || refreshing;
+
return (
- refresh(true)}>
- Refresh
+ refresh(true)}
+ disabled={registerLoading || pageActionLoading}
+ >
+ {registerLoading || refreshing ? "Refreshing..." : "Refresh"}
@@ -475,6 +518,7 @@ export default function BorrowingsPage() {
type="button"
className="action-tile"
onClick={() => setActiveModal("borrow")}
+ disabled={pageActionLoading}
data-testid="borrow-open-modal"
>
Issue Book
@@ -485,6 +529,7 @@ export default function BorrowingsPage() {
type="button"
className="action-tile"
onClick={() => setActiveModal("return")}
+ disabled={pageActionLoading}
data-testid="return-open-modal"
>
Accept Return
@@ -495,6 +540,7 @@ export default function BorrowingsPage() {
type="button"
className="action-tile"
onClick={() => setActiveModal("fine")}
+ disabled={pageActionLoading}
data-testid="fine-open-modal"
>
Fine Collection
@@ -505,6 +551,7 @@ export default function BorrowingsPage() {
type="button"
className="action-tile"
onClick={() => setActiveModal("quick_user")}
+ disabled={pageActionLoading}
data-testid="quick-user-open-modal"
>
Quick User Desk
@@ -699,8 +746,8 @@ export default function BorrowingsPage() {
/>
-
- Record Borrowing
+
+ {borrowLoading ? "Recording..." : "Record Borrowing"}
@@ -724,8 +771,13 @@ export default function BorrowingsPage() {
testId="return-loan-id"
/>
-
- Record Return
+
+ {returnLoading ? "Recording..." : "Record Return"}
diff --git a/frontend/app/users/page.tsx b/frontend/app/users/page.tsx
index 37ebfd5..5280ed4 100644
--- a/frontend/app/users/page.tsx
+++ b/frontend/app/users/page.tsx
@@ -2,6 +2,7 @@
import { useEffect, useMemo, useState } from "react";
import ActionModal from "../../components/ActionModal";
+import ListViewCard, { ListGrid } from "../../components/ListViewCard";
import SearchableSelect from "../../components/SearchableSelect";
import { useToast } from "../../components/ToastProvider";
import { createUser, deleteUser, getUsers, queryUsers, updateUser } from "../../lib/api";
@@ -30,6 +31,8 @@ export default function UsersPage() {
const [modalMode, setModalMode] = useState(null);
const [activeUserId, setActiveUserId] = useState(null);
const [userForm, setUserForm] = useState(initialUserForm);
+ const [userSubmitting, setUserSubmitting] = useState(false);
+ const [deletingUserId, setDeletingUserId] = useState(null);
const sortConfigMap: Record = {
name_asc: { sort_by: "name", sort_order: "asc" },
@@ -141,11 +144,22 @@ export default function UsersPage() {
const handleUserSubmit = async (event: React.FormEvent) => {
event.preventDefault();
+ if (userSubmitting) return;
+ const trimmedName = userForm.name.trim();
+ if (!trimmedName) {
+ showToast({
+ type: "error",
+ title: "Name is required",
+ description: "Whitespace-only values are not allowed.",
+ });
+ return;
+ }
+ setUserSubmitting(true);
try {
if (modalMode === "edit") {
if (!activeUserId) return;
await updateUser(activeUserId, {
- name: userForm.name.trim(),
+ name: trimmedName,
email: userForm.email.trim() || null,
phone: userForm.phone.trim() || null,
role: userForm.role,
@@ -153,7 +167,7 @@ export default function UsersPage() {
showToast({ type: "success", title: "User updated successfully" });
} else {
await createUser({
- name: userForm.name.trim(),
+ name: trimmedName,
email: userForm.email.trim() || undefined,
phone: userForm.phone.trim() || undefined,
role: userForm.role,
@@ -170,12 +184,16 @@ export default function UsersPage() {
title: modalMode === "edit" ? "Unable to update user" : "Unable to create user",
description: err.message || "Request failed",
});
+ } finally {
+ setUserSubmitting(false);
}
};
const handleDeleteUser = async (user: any) => {
+ if (userSubmitting || deletingUserId !== null) return;
const ok = window.confirm(`Delete user "${user.name}"?`);
if (!ok) return;
+ setDeletingUserId(user.id);
try {
await deleteUser(user.id);
if (activeUserId === user.id) {
@@ -189,6 +207,8 @@ export default function UsersPage() {
title: "Unable to delete user",
description: err.message || "Request failed",
});
+ } finally {
+ setDeletingUserId(null);
}
};
@@ -210,11 +230,20 @@ export default function UsersPage() {
-
+
Add User
- refresh(true)}>
- Refresh
+ refresh(true)}
+ disabled={loading || userSubmitting || deletingUserId !== null}
+ >
+ {loading ? "Refreshing..." : "Refresh"}
@@ -238,56 +267,84 @@ export default function UsersPage() {
-
-
-
User Directory
- Searchable
-
-
-
-
- setSearch(event.target.value)}
- placeholder="Name, email, phone, user ID"
- />
-
-
-
-
-
-
-
-
-
-
+
Searchable}
+ filters={(
+ <>
+
+
+ setSearch(event.target.value)}
+ placeholder="Name, email, phone, user ID"
+ />
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+ footer={(
+
+
+ Page {page} · Showing {users.length} record{users.length === 1 ? "" : "s"}
+
+
+ setPage((current) => Math.max(1, current - 1))}
+ disabled={page === 1 || loading}
+ data-testid="users-prev-page"
+ >
+ Previous
+
+ setPage((current) => current + 1)}
+ disabled={!hasNextPage || loading}
+ data-testid="users-next-page"
+ >
+ Next
+
+
-
-
+ )}
+ >
+
{users.map((user) => (
openEditModal(user)}
+ disabled={userSubmitting || deletingUserId !== null}
>
Edit
@@ -327,8 +385,9 @@ export default function UsersPage() {
className="danger small"
type="button"
onClick={() => handleDeleteUser(user)}
+ disabled={userSubmitting || deletingUserId !== null}
>
- Delete
+ {deletingUserId === user.id ? "Deleting..." : "Delete"}
@@ -345,33 +404,8 @@ export default function UsersPage() {
)}
-
-
-
- Page {page} · Showing {users.length} record{users.length === 1 ? "" : "s"}
-
-
- setPage((current) => Math.max(1, current - 1))}
- disabled={page === 1 || loading}
- data-testid="users-prev-page"
- >
- Previous
-
- setPage((current) => current + 1)}
- disabled={!hasNextPage || loading}
- data-testid="users-next-page"
- >
- Next
-
-
-
-
+
+
-
+
Cancel
-
- {modalMode === "edit" ? "Save Changes" : "Add User"}
+
+ {userSubmitting ? "Saving..." : modalMode === "edit" ? "Save Changes" : "Add User"}
diff --git a/frontend/components/AppErrorBoundary.tsx b/frontend/components/AppErrorBoundary.tsx
new file mode 100644
index 0000000..f2232b8
--- /dev/null
+++ b/frontend/components/AppErrorBoundary.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import { ErrorInfo } from "react";
+import React from "react";
+
+type AppErrorBoundaryProps = {
+ children: React.ReactNode;
+};
+
+type AppErrorBoundaryState = {
+ hasError: boolean;
+ message: string;
+};
+
+export default class AppErrorBoundary extends React.Component<
+ AppErrorBoundaryProps,
+ AppErrorBoundaryState
+> {
+ state: AppErrorBoundaryState = {
+ hasError: false,
+ message: "",
+ };
+
+ static getDerivedStateFromError(error: Error): AppErrorBoundaryState {
+ return {
+ hasError: true,
+ message: error.message || "Unexpected UI error",
+ };
+ }
+
+ componentDidCatch(error: Error, info: ErrorInfo) {
+ console.error("AppErrorBoundary caught an error", error, info);
+ }
+
+ handleRetry = () => {
+ this.setState({ hasError: false, message: "" });
+ };
+
+ render() {
+ if (this.state.hasError) {
+ return (
+
+
+ Error
+ Something went wrong
+
+ The page crashed unexpectedly. Retry the view or reload the application.
+
+ {this.state.message ? {this.state.message}
: null}
+
+
+ Retry
+
+ window.location.reload()}
+ >
+ Reload
+
+
+
+
+ );
+ }
+ return this.props.children;
+ }
+}
diff --git a/frontend/components/ListViewCard.tsx b/frontend/components/ListViewCard.tsx
new file mode 100644
index 0000000..1ecc42a
--- /dev/null
+++ b/frontend/components/ListViewCard.tsx
@@ -0,0 +1,45 @@
+type ListViewCardProps = {
+ title: string;
+ headerRight?: React.ReactNode;
+ filters?: React.ReactNode;
+ footer?: React.ReactNode;
+ children: React.ReactNode;
+ className?: string;
+ testId?: string;
+};
+
+type ListGridProps = {
+ children: React.ReactNode;
+ className?: string;
+};
+
+function withClass(base: string, value?: string) {
+ if (!value) return base;
+ return `${base} ${value}`;
+}
+
+export function ListGrid({ children, className }: ListGridProps) {
+ return {children}
;
+}
+
+export default function ListViewCard({
+ title,
+ headerRight,
+ filters,
+ footer,
+ children,
+ className,
+ testId,
+}: ListViewCardProps) {
+ return (
+
+
+
{title}
+ {headerRight || null}
+
+ {filters ? {filters}
: null}
+ {children}
+ {footer || null}
+
+ );
+}
diff --git a/frontend/lib/api-paths.ts b/frontend/lib/api-paths.ts
new file mode 100644
index 0000000..c2263a5
--- /dev/null
+++ b/frontend/lib/api-paths.ts
@@ -0,0 +1,23 @@
+export const API_PATHS = {
+ books: "/books",
+ book: (bookId: number) => `/books/${bookId}`,
+ users: "/users",
+ user: (userId: number) => `/users/${userId}`,
+ loans: "/loans",
+ loan: (loanId: number) => `/loans/${loanId}`,
+ borrowLoan: "/loans/borrow",
+ returnLoan: (loanId: number) => `/loans/${loanId}/return`,
+ loanFineSummary: (loanId: number) => `/loans/${loanId}/fine-summary`,
+ loanFinePayments: (loanId: number) => `/loans/${loanId}/fine-payments`,
+ finePayments: "/fine-payments",
+ seed: "/seed",
+ importBooks: "/imports/books",
+ importUsers: "/imports/users",
+ importLoans: "/imports/loans",
+ policy: "/settings/policy",
+ auditLogs: "/audit/logs",
+ authLogin: "/auth/login",
+ authMe: "/auth/me",
+ myLoans: "/users/me/loans",
+ myFinePayments: "/users/me/fine-payments",
+} as const;
diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts
index 2b43b72..b51ee4c 100644
--- a/frontend/lib/api.ts
+++ b/frontend/lib/api.ts
@@ -1,4 +1,5 @@
import { clearAuth, getStoredToken } from "./auth";
+import { API_PATHS } from "./api-paths";
type RequestOptions = RequestInit & {
auth?: boolean;
@@ -269,31 +270,31 @@ export async function queryBooks(params?: PageQuery & {
appendQueryValue(query, "skip", params?.skip);
appendQueryValue(query, "limit", params?.limit);
const suffix = query.toString() ? `?${query.toString()}` : "";
- return request(`/books${suffix}`, { method: "GET" });
+ return request(`${API_PATHS.books}${suffix}`, { method: "GET" });
}
export async function getBooks(q?: string) {
const query = new URLSearchParams();
if (q?.trim()) query.set("q", q.trim());
- return requestAllPages("/books", query, { method: "GET", pageSize: 200 });
+ return requestAllPages(API_PATHS.books, query, { method: "GET", pageSize: 200 });
}
export async function createBook(payload: any) {
- return request("/books", {
+ return request(API_PATHS.books, {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function updateBook(bookId: number, payload: any) {
- return request(`/books/${bookId}`, {
+ return request(API_PATHS.book(bookId), {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export async function deleteBook(bookId: number) {
- return request(`/books/${bookId}`, {
+ return request(API_PATHS.book(bookId), {
method: "DELETE"
});
}
@@ -301,7 +302,7 @@ export async function deleteBook(bookId: number) {
export async function getUsers(q?: string) {
const query = new URLSearchParams();
if (q?.trim()) query.set("q", q.trim());
- return requestAllPages("/users", query, { method: "GET", pageSize: 200 });
+ return requestAllPages(API_PATHS.users, query, { method: "GET", pageSize: 200 });
}
export async function queryUsers(params?: PageQuery & {
@@ -318,31 +319,31 @@ export async function queryUsers(params?: PageQuery & {
appendQueryValue(query, "skip", params?.skip);
appendQueryValue(query, "limit", params?.limit);
const suffix = query.toString() ? `?${query.toString()}` : "";
- return request(`/users${suffix}`, { method: "GET" });
+ return request(`${API_PATHS.users}${suffix}`, { method: "GET" });
}
export async function createUser(payload: any) {
- return request("/users", {
+ return request(API_PATHS.users, {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function updateUser(userId: number, payload: any) {
- return request(`/users/${userId}`, {
+ return request(API_PATHS.user(userId), {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export async function deleteUser(userId: number) {
- return request(`/users/${userId}`, {
+ return request(API_PATHS.user(userId), {
method: "DELETE"
});
}
export async function getLoans() {
- return requestAllPages("/loans", undefined, { method: "GET", pageSize: 200 });
+ return requestAllPages(API_PATHS.loans, undefined, { method: "GET", pageSize: 200 });
}
export async function queryLoans(params?: PageQuery & {
@@ -365,62 +366,62 @@ export async function queryLoans(params?: PageQuery & {
appendQueryValue(query, "skip", params?.skip);
appendQueryValue(query, "limit", params?.limit);
const suffix = query.toString() ? `?${query.toString()}` : "";
- return request(`/loans${suffix}`, { method: "GET" });
+ return request(`${API_PATHS.loans}${suffix}`, { method: "GET" });
}
export async function borrowBook(payload: any) {
- return request("/loans/borrow", {
+ return request(API_PATHS.borrowLoan, {
method: "POST",
body: JSON.stringify(payload)
});
}
export async function returnBook(loanId: number) {
- return request(`/loans/${loanId}/return`, { method: "POST" });
+ return request(API_PATHS.returnLoan(loanId), { method: "POST" });
}
export async function updateLoan(loanId: number, payload: { extend_days: number }) {
- return request(`/loans/${loanId}`, {
+ return request(API_PATHS.loan(loanId), {
method: "PATCH",
body: JSON.stringify(payload)
});
}
export async function deleteLoan(loanId: number) {
- return request(`/loans/${loanId}`, {
+ return request(API_PATHS.loan(loanId), {
method: "DELETE"
});
}
export async function seedData() {
- return request<{ status: string; message?: string; counts?: Record }>("/seed", {
+ return request<{ status: string; message?: string; counts?: Record }>(API_PATHS.seed, {
method: "POST"
});
}
export async function importBooksFile(file: File) {
return upload<{ entity: string; imported: number; skipped: number; errors: Array }>(
- "/imports/books",
+ API_PATHS.importBooks,
file
);
}
export async function importUsersFile(file: File) {
return upload<{ entity: string; imported: number; skipped: number; errors: Array }>(
- "/imports/users",
+ API_PATHS.importUsers,
file
);
}
export async function importLoansFile(file: File) {
return upload<{ entity: string; imported: number; skipped: number; errors: Array }>(
- "/imports/loans",
+ API_PATHS.importLoans,
file
);
}
export async function getPolicy() {
- return request("/settings/policy", { method: "GET" });
+ return request(API_PATHS.policy, { method: "GET" });
}
export async function updatePolicy(payload: {
@@ -429,7 +430,7 @@ export async function updatePolicy(payload: {
max_loan_days: number;
fine_per_day: number;
}) {
- return request("/settings/policy", {
+ return request(API_PATHS.policy, {
method: "PUT",
body: JSON.stringify(payload),
});
@@ -448,7 +449,7 @@ export async function getAuditLogs(params?: {
appendQueryValues(query, "entity", params?.entity);
if (typeof params?.status_code === "number") query.set("status_code", String(params.status_code));
const maxItems = typeof params?.limit === "number" ? params.limit : undefined;
- return requestAllPages("/audit/logs", query, {
+ return requestAllPages(API_PATHS.auditLogs, query, {
method: "GET",
pageSize: 200,
maxItems,
@@ -473,12 +474,12 @@ export async function queryAuditLogs(params?: PageQuery & {
appendQueryValue(query, "skip", params?.skip);
appendQueryValue(query, "limit", params?.limit);
const suffix = query.toString() ? `?${query.toString()}` : "";
- return request(`/audit/logs${suffix}`, { method: "GET" });
+ return request(`${API_PATHS.auditLogs}${suffix}`, { method: "GET" });
}
export async function login(payload: { email: string; password: string }) {
return request<{ access_token: string; user: any; expires_in: number; token_type: string }>(
- "/auth/login",
+ API_PATHS.authLogin,
{
method: "POST",
body: JSON.stringify(payload),
@@ -488,30 +489,30 @@ export async function login(payload: { email: string; password: string }) {
}
export async function getMe() {
- return request("/auth/me", { method: "GET" });
+ return request(API_PATHS.authMe, { method: "GET" });
}
export async function getMyLoans() {
- return request("/users/me/loans", { method: "GET" });
+ return request(API_PATHS.myLoans, { method: "GET" });
}
export async function getMyFinePayments() {
- return request("/users/me/fine-payments", { method: "GET" });
+ return request(API_PATHS.myFinePayments, { method: "GET" });
}
export async function getLoanFineSummary(loanId: number) {
- return request(`/loans/${loanId}/fine-summary`, { method: "GET" });
+ return request(API_PATHS.loanFineSummary(loanId), { method: "GET" });
}
export async function getLoanFinePayments(loanId: number) {
- return request(`/loans/${loanId}/fine-payments`, { method: "GET" });
+ return request(API_PATHS.loanFinePayments(loanId), { method: "GET" });
}
export async function createLoanFinePayment(
loanId: number,
payload: { amount: number; payment_mode: string; reference?: string | null; notes?: string | null }
) {
- return request(`/loans/${loanId}/fine-payments`, {
+ return request(API_PATHS.loanFinePayments(loanId), {
method: "POST",
body: JSON.stringify(payload),
});
@@ -539,7 +540,7 @@ export async function queryFinePayments(params?: PageQuery & {
appendQueryValue(query, "skip", params?.skip);
appendQueryValue(query, "limit", params?.limit);
const suffix = query.toString() ? `?${query.toString()}` : "";
- return request(`/fine-payments${suffix}`, { method: "GET" });
+ return request(`${API_PATHS.finePayments}${suffix}`, { method: "GET" });
}
export async function bootstrapAdmin(payload: {
@@ -548,7 +549,7 @@ export async function bootstrapAdmin(payload: {
role: string;
password: string;
}) {
- return request("/users", {
+ return request(API_PATHS.users, {
method: "POST",
body: JSON.stringify(payload),
auth: false,