diff --git a/.gitignore b/.gitignore index be0f0ec..7fe2169 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,9 @@ var/ # Logs *.log +# TypeScript incremental build +*.tsbuildinfo + # Testing .pytest_cache .coverage diff --git a/frontend/app/agencies/[id]/page.tsx b/frontend/app/agencies/[id]/page.tsx new file mode 100644 index 0000000..beb79e6 --- /dev/null +++ b/frontend/app/agencies/[id]/page.tsx @@ -0,0 +1,113 @@ +"use client"; + +import Link from "next/link"; +import { useParams, useRouter } from "next/navigation"; +import ResourceDetail from "@/components/ResourceDetail"; +import { useAgency, useDeleteAgency } from "@/hooks/useApi"; +import type { Agency, ResourceDetailField } from "@/types"; + +const agencyFields: ResourceDetailField[] = [ + { key: "id", label: "ID" }, + { key: "name", label: "Name" }, + { key: "email", label: "Email" }, + { key: "phone", label: "Phone" }, + { key: "address", label: "Address" }, + { key: "city", label: "City" }, + { key: "province", label: "Province" }, + { key: "description", label: "Description", emptyValue: "No description" }, + { key: "status", label: "Status", emptyValue: "Unknown" }, + { key: "require_pre_payment", label: "Require Pre-payment" }, + { + key: "billing_profiles", + label: "Billing Profiles", + emptyValue: "None", + render: (value) => Array.isArray(value) ? `${value.length} profile(s)` : "-", + }, + { + key: "created_at", + label: "Created", + render: (value) => new Date(String(value)).toLocaleString(), + }, + { + key: "updated_at", + label: "Updated", + render: (value) => new Date(String(value)).toLocaleString(), + }, +]; + +export default function AgencyDetailPage() { + const params = useParams<{ id: string }>(); + const router = useRouter(); + const rawId = params?.id; + const agencyId = params?.id; + + const { data: agency, isLoading, error } = useAgency(agencyId); + const deleteAgency = useDeleteAgency(); + + const handleDelete = async () => { + if (!agencyId) { + return; + } + + await deleteAgency.mutateAsync(agencyId); + router.push("/agencies"); + }; + + return ( +
+
+
+

Agency Details

+
+ + Back to Agencies + + + Home + +
+
+
+ +
+ {isLoading &&

Loading agency details...

} + + {error && ( +
+

Failed to load agency details.

+
+ )} + + {!isLoading && !error && !agency && ( +
+

Agency not found.

+
+ )} + + {agency && ( + + )} + + {deleteAgency.isError && ( +
+

Unable to delete agency. Please try again.

+
+ )} +
+
+ ); +} diff --git a/frontend/app/agencies/page.tsx b/frontend/app/agencies/page.tsx index a28d285..eaf3f18 100644 --- a/frontend/app/agencies/page.tsx +++ b/frontend/app/agencies/page.tsx @@ -45,11 +45,21 @@ export default function AgenciesPage() { key={agency.id} className="bg-white rounded-lg shadow p-4 border border-gray-200" > -

{agency.name}

-

{agency.email}

-

- {agency.city}, {agency.province} -

+
+
+

{agency.name}

+

{agency.email}

+

+ {agency.city}, {agency.province} +

+
+ + View details + +
))} diff --git a/frontend/components/ConfirmModal.tsx b/frontend/components/ConfirmModal.tsx new file mode 100644 index 0000000..b63336b --- /dev/null +++ b/frontend/components/ConfirmModal.tsx @@ -0,0 +1,56 @@ +"use client"; +import type { ConfirmModalProps } from "@/types"; + +export default function ConfirmModal({ + isOpen, + title, + message, + errorMessage, + onConfirm, + onCancel, + isLoading = false, +}: ConfirmModalProps) { + if (!isOpen) { + return null; + } + + return ( +
+
+

+ {title} +

+

{message}

+ {errorMessage && ( +
+ {errorMessage} +
+ )} + +
+ + +
+
+
+ ); +} diff --git a/frontend/components/ResourceDetail.tsx b/frontend/components/ResourceDetail.tsx new file mode 100644 index 0000000..6e8f408 --- /dev/null +++ b/frontend/components/ResourceDetail.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useState } from "react"; +import ConfirmModal from "@/components/ConfirmModal"; +import type { ResourceDetailProps } from "@/types"; + +function defaultDisplayValue(value: unknown, emptyValue = "-") { + if (value === null || value === undefined || value === "") { + return emptyValue; + } + + if (typeof value === "boolean") { + return value ? "Yes" : "No"; + } + + if (Array.isArray(value) || typeof value === "object") { + return JSON.stringify(value); + } + + return String(value); +} + +function extractErrorMessage(error: unknown): string | null { + if (!error) { + return null; + } + + if (typeof error === "string") { + return error.trim() ? error : null; + } + + if (error instanceof Error) { + return error.message?.trim() ? error.message : null; + } + + if (typeof error === "object") { + const maybeAxiosDetail = (error as { response?: { data?: { detail?: unknown } } }) + ?.response?.data?.detail; + if (typeof maybeAxiosDetail === "string" && maybeAxiosDetail.trim()) { + return maybeAxiosDetail; + } + + const maybeMessage = (error as { message?: unknown })?.message; + if (typeof maybeMessage === "string" && maybeMessage.trim()) { + return maybeMessage; + } + } + + return null; +} + +export default function ResourceDetail({ + title, + fields, + data, + onDelete, + deleteTitle = "Confirm deletion", + deleteMessage = "Are you sure you want to delete this item?", + isDeleting = false, +}: ResourceDetailProps) { + const [showConfirmModal, setShowConfirmModal] = useState(false); + const [confirmError, setConfirmError] = useState(null); + + const handleDeleteConfirm = async () => { + if (!onDelete) { + return; + } + + setConfirmError(null); + try { + await onDelete(); + setShowConfirmModal(false); + } catch (error) { + setConfirmError( + extractErrorMessage(error) ?? "Unable to delete. Please try again." + ); + } + }; + + return ( + <> +
+
+

+ {title ?? "Resource Details"} +

+ {onDelete && ( + + )} +
+ +
+ {fields.map((field) => { + const rawValue = data[field.key]; + const renderedValue = field.render + ? field.render(rawValue, data) + : defaultDisplayValue(rawValue, field.emptyValue); + + return ( +
+
+ {field.label} +
+
{renderedValue}
+
+ ); + })} +
+
+ + { + setConfirmError(null); + setShowConfirmModal(false); + }} + isLoading={isDeleting} + /> + + ); +} diff --git a/frontend/hooks/useApi.ts b/frontend/hooks/useApi.ts index d99fff7..b721b01 100644 --- a/frontend/hooks/useApi.ts +++ b/frontend/hooks/useApi.ts @@ -34,6 +34,24 @@ export function useAgencies() { }); } +/** + * Fetch a single agency by ID + */ +export function useAgency(agencyId?: string) { + return useQuery({ + queryKey: ["agencies", agencyId], + queryFn: async () => { + if (!agencyId) { + throw new Error("Agency ID is required"); + } + const response = await apiClient.get(`/agencies/${agencyId}`); + return response.data; + }, + enabled: Boolean(agencyId), + staleTime: 1000 * 60 * 5, + }); +} + /** * Fetch all donors */ @@ -69,6 +87,25 @@ export function useCreateAgency() { }); } +/** + * Delete an agency by ID + * + * Invalidates agencies cache and agency detail cache on success. + */ +export function useDeleteAgency() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (agencyId: string) => { + await apiClient.delete(`/agencies/${agencyId}`); + }, + onSuccess: (_, agencyId) => { + queryClient.invalidateQueries({ queryKey: ["agencies"] }); + queryClient.invalidateQueries({ queryKey: ["agencies", agencyId] }); + }, + }); +} + /** * Fetch all clients */ diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 1157d6c..deff400 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -13,6 +13,7 @@ "isolatedModules": true, "jsx": "preserve", "incremental": true, + "tsBuildInfoFile": ".next/cache/tsconfig.tsbuildinfo", "plugins": [ { "name": "next" diff --git a/frontend/types/index.ts b/frontend/types/index.ts index ad87425..7bf540b 100644 --- a/frontend/types/index.ts +++ b/frontend/types/index.ts @@ -1,3 +1,5 @@ +import type { ReactNode } from "react"; + /** * Common Types * @@ -175,3 +177,37 @@ export interface Referral { created_at: string; updated_at: string; } + + +/* +Resource Detail Types +*/ +export interface ConfirmModalProps { + isOpen: boolean; + title: string; + message: string; + onConfirm: () => void; + onCancel: () => void; + isLoading?: boolean; + errorMessage?: string | null; +} + +export interface ResourceDetailField< + T extends object, + K extends keyof T = keyof T, +> { + key: K; + label: string; + emptyValue?: string; + render?: (value: T[K], data: T) => ReactNode; +} + +export interface ResourceDetailProps { + title?: string; + fields: ResourceDetailField[]; + data: T; + onDelete?: () => Promise | void; + deleteTitle?: string; + deleteMessage?: string; + isDeleting?: boolean; +}