Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions frontend/app/agencies/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"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<Agency>[] = [
{ 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",
},
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

defaultDisplayValue will call JSON.stringify on the profiles array, producing raw output so maybe add a render like this: (v) => Array.isArray(v) ? ${v.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 = Array.isArray(rawId) ? rawId[0] : rawId;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think Array.isArray branch is dead code so we can simplify it to:

Suggested change
const agencyId = Array.isArray(rawId) ? rawId[0] : rawId;
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 (
<div className="min-h-screen flex flex-col">
<header className="w-full bg-gradient-to-r from-blue-600 to-blue-800 text-white shadow-lg">
<div className="max-w-6xl mx-auto px-6 py-6 flex justify-between items-center">
<h1 className="text-2xl font-bold">Agency Details</h1>
<div className="flex items-center gap-2">
<Link
href="/agencies"
className="px-4 py-2 bg-white/20 rounded hover:bg-white/30 transition"
>
Back to Agencies
</Link>
<Link
href="/"
className="px-4 py-2 bg-white/20 rounded hover:bg-white/30 transition"
>
Home
</Link>
</div>
</div>
</header>

<main className="flex-1 max-w-6xl mx-auto px-6 py-8 w-full">
{isLoading && <p className="text-gray-600">Loading agency details...</p>}

{error && (
<div className="bg-red-50 border border-red-200 rounded p-4 text-red-800">
<p>Failed to load agency details.</p>
</div>
)}

{!isLoading && !error && !agency && (
<div className="bg-yellow-50 border border-yellow-200 rounded p-4 text-yellow-800">
<p>Agency not found.</p>
</div>
)}

{agency && (
<ResourceDetail
title={agency.name}
fields={agencyFields}
data={agency}
onDelete={handleDelete}
isDeleting={deleteAgency.isPending}
deleteTitle="Delete agency"
deleteMessage={`Are you sure you want to delete this agency (${agency.name})?`}
/>
)}

{deleteAgency.isError && (
<div className="mt-4 bg-red-50 border border-red-200 rounded p-4 text-red-800">
<p>Unable to delete agency. Please try again.</p>
</div>
)}
</main>
</div>
);
}
20 changes: 15 additions & 5 deletions frontend/app/agencies/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,21 @@ export default function AgenciesPage() {
key={agency.id}
className="bg-white rounded-lg shadow p-4 border border-gray-200"
>
<h2 className="font-semibold text-lg">{agency.name}</h2>
<p className="text-gray-600 text-sm">{agency.email}</p>
<p className="text-gray-500 text-sm">
{agency.city}, {agency.province}
</p>
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="font-semibold text-lg">{agency.name}</h2>
<p className="text-gray-600 text-sm">{agency.email}</p>
<p className="text-gray-500 text-sm">
{agency.city}, {agency.province}
</p>
</div>
<Link
href={`/agencies/${agency.id}`}
className="inline-flex items-center rounded bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-700 transition"
>
View details
</Link>
</div>
</li>
))}
</ul>
Expand Down
58 changes: 58 additions & 0 deletions frontend/components/ConfirmModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"use client";

interface ConfirmModalProps {
isOpen: boolean;
title: string;
message: string;
onConfirm: () => void;
onCancel: () => void;
isLoading?: boolean;
}

export default function ConfirmModal({
isOpen,
title,
message,
onConfirm,
onCancel,
isLoading = false,
}: ConfirmModalProps) {
if (!isOpen) {
return null;
}

return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"
role="dialog"
aria-modal="true"
aria-labelledby="confirm-modal-title"
>
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h2 id="confirm-modal-title" className="text-xl font-semibold text-gray-900">
{title}
</h2>
<p className="mt-2 text-sm text-gray-600">{message}</p>

<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={onCancel}
disabled={isLoading}
className="rounded border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-60"
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
disabled={isLoading}
className="rounded bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{isLoading ? "Deleting..." : "Confirm"}
</button>
</div>
</div>
</div>
);
}
94 changes: 94 additions & 0 deletions frontend/components/ResourceDetail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"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);
}

export default function ResourceDetail<T extends object>({
title,
fields,
data,
onDelete,
deleteTitle = "Confirm deletion",
deleteMessage = "Are you sure you want to delete this item?",
isDeleting = false,
}: ResourceDetailProps<T>) {
const [showConfirmModal, setShowConfirmModal] = useState(false);

const handleDeleteConfirm = async () => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If onDelete() rejects, setShowConfirmModal(false) is never reached so should there be error handling here?

if (!onDelete) {
return;
}

await onDelete();
setShowConfirmModal(false);
};

return (
<>
<section className="rounded-lg border border-gray-200 bg-white shadow">
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
<h2 className="text-xl font-semibold text-gray-900">
{title ?? "Resource Details"}
</h2>
{onDelete && (
<button
type="button"
onClick={() => setShowConfirmModal(true)}
disabled={isDeleting}
className="rounded bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-60"
>
Delete
</button>
)}
</div>

<dl className="grid grid-cols-1 gap-4 p-6 sm:grid-cols-2">
{fields.map((field) => {
const rawValue = data[field.key];
const renderedValue = field.render
? field.render(rawValue, data)
: defaultDisplayValue(rawValue, field.emptyValue);

return (
<div
key={String(field.key)}
className="rounded-md border border-gray-100 bg-gray-50 px-4 py-3"
>
<dt className="text-xs font-medium uppercase tracking-wide text-gray-500">
{field.label}
</dt>
<dd className="mt-1 break-words text-sm text-gray-900">{renderedValue}</dd>
</div>
);
})}
</dl>
</section>

<ConfirmModal
isOpen={showConfirmModal}
title={deleteTitle}
message={deleteMessage}
onConfirm={handleDeleteConfirm}
onCancel={() => setShowConfirmModal(false)}
isLoading={isDeleting}
/>
</>
);
}
37 changes: 37 additions & 0 deletions frontend/hooks/useApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Agency>(`/agencies/${agencyId}`);
return response.data;
},
enabled: Boolean(agencyId),
staleTime: 1000 * 60 * 5,
});
}

/**
* Fetch all donors
*/
Expand Down Expand Up @@ -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
*/
Expand Down
1 change: 1 addition & 0 deletions frontend/tsconfig.tsbuildinfo

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions frontend/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { ReactNode } from "react";

/**
* Common Types
*
Expand Down Expand Up @@ -175,3 +177,20 @@ export interface Referral {
created_at: string;
updated_at: string;
}

export interface ResourceDetailField<T extends object> {
key: keyof T;
label: string;
emptyValue?: string;
render?: (value: T[keyof T], data: T) => ReactNode;
}

export interface ResourceDetailProps<T extends object> {
title?: string;
fields: ResourceDetailField<T>[];
data: T;
onDelete?: () => Promise<void> | void;
deleteTitle?: string;
deleteMessage?: string;
isDeleting?: boolean;
}