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.

- -
-
-

Audit Logs

- Persisted in DB -
-
-
- - 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"} +
+
+ + +
-
-
+ )} + > + {events.map((event) => (
@@ -191,33 +220,8 @@ export default function AuditPage() {
) : null} -
-
-
- Page {page} · Showing {events.length} record{events.length === 1 ? "" : "s"} -
-
- - -
-
-
+ + ); } 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() {

- -
@@ -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"} +
+
+ + +
-
-
+ )} + > + {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"} -
-
- - -
-
-
+ +
- -
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} +
+ +
+
+
+ ); +} 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.

- @@ -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"} +
+
+ + +
-
- -
+ )} + > + {rows.map((row) => (
@@ -253,31 +279,8 @@ export default function FinesLedgerPage() {
) : null} -
-
-
- Page {page} · Showing {rows.length} payment record{rows.length === 1 ? "" : "s"} -
-
- - -
-
-
+ + ); } 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} +
+ + +
+
+
+ + + ); +} 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 /> - 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.

- 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 (
@@ -441,8 +480,12 @@ export default function BorrowingsPage() {

Borrowings, Returns, Fines

Issue and return books fast while keeping user and inventory records accurate.

- @@ -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() { />
-
@@ -724,8 +771,13 @@ export default function BorrowingsPage() { testId="return-loan-id" />
-
diff --git a/frontend/app/settings/page.tsx b/frontend/app/settings/page.tsx index 121d427..b4d3a13 100755 --- a/frontend/app/settings/page.tsx +++ b/frontend/app/settings/page.tsx @@ -56,6 +56,7 @@ export default function SettingsPage() { max_loan_days: "21", fine_per_day: "2.0", }); + const settingsBusy = loadingAction !== null; const loadPolicy = async () => { try { @@ -164,6 +165,7 @@ export default function SettingsPage() { importer: (input: File) => Promise<{ imported: number; skipped: number; errors: Array }>, setFile: (file: File | null) => void ) => { + if (settingsBusy) return; if (!file) { showToast({ type: "error", title: `Select a ${entity} CSV/XLSX file first` }); return; @@ -190,6 +192,7 @@ export default function SettingsPage() { }; const runSeed = async () => { + if (settingsBusy) return; setLoadingAction("seed"); try { const result = await seedData(); @@ -211,6 +214,7 @@ export default function SettingsPage() { const savePolicy = async (event: React.FormEvent) => { event.preventDefault(); + if (settingsBusy) return; setLoadingAction("policy"); try { const saved = await updatePolicy({ @@ -267,6 +271,7 @@ export default function SettingsPage() { type="checkbox" data-testid="policy-enforce-limits" checked={policyForm.enforce_limits} + disabled={settingsBusy} onChange={(event) => setPolicyForm({ ...policyForm, enforce_limits: event.target.checked }) } @@ -281,6 +286,7 @@ export default function SettingsPage() { max={50} data-testid="policy-max-active-loans" value={policyForm.max_active_loans_per_user} + disabled={settingsBusy} onChange={(event) => setPolicyForm({ ...policyForm, @@ -298,6 +304,7 @@ export default function SettingsPage() { max={365} data-testid="policy-max-loan-days" value={policyForm.max_loan_days} + disabled={settingsBusy} onChange={(event) => setPolicyForm({ ...policyForm, max_loan_days: event.target.value }) } @@ -313,13 +320,14 @@ export default function SettingsPage() { step="0.5" data-testid="policy-fine-per-day" value={policyForm.fine_per_day} + disabled={settingsBusy} onChange={(event) => setPolicyForm({ ...policyForm, fine_per_day: event.target.value }) } required /> - @@ -336,7 +344,7 @@ export default function SettingsPage() { Bundled Books + Users + Loans

Load curated Indian catalog, user profiles, and borrowing history.

- @@ -406,7 +414,7 @@ export default function SettingsPage() { className="ghost" data-testid={`import-run-${row.entity}`} onClick={() => runImport(row.entity, row.file, row.importer, row.setFile)} - disabled={loadingAction === row.entity} + disabled={settingsBusy} > {loadingAction === row.entity ? "Importing..." : "Import"} 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() {

- -
@@ -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"} +
+
+ + +
-
-
+ )} + > + {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"} -
-
- - -
-
-
+ +
- -
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} +
+ + +
+
+
+ ); + } + 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,