Skip to content

Commit 723f438

Browse files
committed
frontend: donation request review page (HAFB)
Implement the admin donation-request review screen and the shared components it needs. The database is not provisioned, so the page reads from a local Zustand store seeded from a fixture; field names and actions mirror the review-schema PR so wiring to the API later is a fetch swap. New shared components (common/): - ui/alert — cva callout (info/warning/destructive); ServiceAreaNotice now builds on it - ui/collapsible, ui/copy-button, ui/toggle-group — thin base-ui wrappers - data-display/PhotoStrip + PhotoLightboxDialog — thumbnail row and full-size viewer with prev/next + thumbnail rail - layout/AdminHeader — logo + search + avatar top bar - InformationBlock gains valueAction (copy button beside a value) - status-labels gains ScheduledBadge Flow (app/donation-request/[id]): - Donor Information card with read + inline edit (Yes/No toggles), collapsible - Item cards reusing the existing approve/reject dialogs, with the rejection reason shown once rejected and a photo strip that opens the lightbox - Schedule / Edit pickup dialog (native date input per design; the calendar popover is not designed), scheduled-pickup card, and confirm-date dialog. Editing a confirmed pickup's date clears the confirmation, matching the dialog warning and the backend rule. - Header badge derived from item + pickup state (pending / partially reviewed / reviewed + Schedule button / scheduled) Tests: reviewStatus derivation, DonationItemCard, PhotoLightboxDialog (18 new; suite 125 passing). Gallery entries added for the new shared pieces. type-check, lint, prettier clean. Two design gaps handled: frame 1 has no visible entry into donor-edit mode, so an edit affordance sits beside the collapse chevron; frame 8's "2/4 Approved" label is driven by the real approved/total counts.
1 parent af57aef commit 723f438

30 files changed

Lines changed: 2035 additions & 26 deletions

frontend/app/component-gallery/page.tsx

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,11 @@ import {
2020
SearchBar,
2121
DataTable,
2222
DataTableColumnHeader,
23+
PhotoStrip,
24+
PhotoLightboxDialog,
2325
} from "@/common/components/data-display";
2426
import {
27+
Alert,
2528
Avatar,
2629
AvatarFallback,
2730
AvatarImage,
@@ -36,6 +39,10 @@ import {
3639
CardHeader,
3740
CardTitle,
3841
Checkbox,
42+
Collapsible,
43+
CollapsiblePanel,
44+
CollapsibleTrigger,
45+
CopyButton,
3946
Dialog,
4047
DialogBody,
4148
DialogClose,
@@ -72,6 +79,8 @@ import {
7279
TabsList,
7380
TabsTrigger,
7481
Textarea,
82+
ToggleGroup,
83+
ToggleGroupItem,
7584
Tooltip,
7685
TooltipContent,
7786
TooltipProvider,
@@ -92,6 +101,7 @@ import {
92101
ReviewedBadge,
93102
PendingReviewBadge,
94103
PartiallyReviewedBadge,
104+
ScheduledBadge,
95105
ApprovalsLabel,
96106
ApprovedBadge,
97107
RejectedBadge,
@@ -200,6 +210,7 @@ function StatusLabelsDemo() {
200210
<ReviewedBadge />
201211
<PendingReviewBadge />
202212
<PartiallyReviewedBadge />
213+
<ScheduledBadge date="Mar 14" />
203214
</div>
204215
<div className="flex flex-wrap gap-sm">
205216
<ApprovalsLabel approved={0} total={4} />
@@ -480,7 +491,10 @@ const SAMPLE_DONATION_ITEM: DonationItem = {
480491

481492
function DonationItemPreviewDemo() {
482493
return (
483-
<DonationItemPreview {...SAMPLE_DONATION_ITEM} className="w-full max-w-md" />
494+
<DonationItemPreview
495+
{...SAMPLE_DONATION_ITEM}
496+
className="w-full max-w-md"
497+
/>
484498
);
485499
}
486500

@@ -1204,18 +1218,97 @@ function DataTableDemo() {
12041218
);
12051219
}
12061220

1221+
function AlertDemo() {
1222+
return (
1223+
<div className="flex w-full max-w-md flex-col gap-sm">
1224+
<Alert variant="info">This is an informational notice.</Alert>
1225+
<Alert variant="warning">
1226+
Selecting a new date will require new confirmation.
1227+
</Alert>
1228+
<Alert variant="destructive">Something went wrong.</Alert>
1229+
</div>
1230+
);
1231+
}
1232+
1233+
function CopyButtonDemo() {
1234+
return (
1235+
<div className="flex items-center gap-sm text-paragraph-small text-muted-foreground">
1236+
katiesun@uwblueprint.org
1237+
<CopyButton value="katiesun@uwblueprint.org" label="Copy email" />
1238+
</div>
1239+
);
1240+
}
1241+
1242+
function CollapsibleDemo() {
1243+
return (
1244+
<Collapsible
1245+
defaultOpen
1246+
className="w-full max-w-md rounded-lg border border-border p-md"
1247+
>
1248+
<CollapsibleTrigger render={<Button variant="ghost" size="sm" />}>
1249+
Toggle details
1250+
</CollapsibleTrigger>
1251+
<CollapsiblePanel>
1252+
<p className="pt-sm text-paragraph-small text-muted-foreground">
1253+
Hidden content revealed by the trigger.
1254+
</p>
1255+
</CollapsiblePanel>
1256+
</Collapsible>
1257+
);
1258+
}
1259+
1260+
function ToggleGroupDemo() {
1261+
const [value, setValue] = useState<string[]>(["no"]);
1262+
return (
1263+
<ToggleGroup value={value} onValueChange={setValue} aria-label="Yes or no">
1264+
<ToggleGroupItem value="yes">Yes</ToggleGroupItem>
1265+
<ToggleGroupItem value="no">No</ToggleGroupItem>
1266+
</ToggleGroup>
1267+
);
1268+
}
1269+
1270+
const SAMPLE_PHOTOS = Array.from({ length: 5 }, (_, i) => ({
1271+
url: `https://picsum.photos/seed/gallery-${i}/240/240`,
1272+
alt: `Sample photo ${i + 1}`,
1273+
}));
1274+
1275+
function PhotoStripDemo() {
1276+
const [open, setOpen] = useState(false);
1277+
const [index, setIndex] = useState(0);
1278+
return (
1279+
<div className="w-full max-w-md">
1280+
<PhotoStrip
1281+
photos={SAMPLE_PHOTOS}
1282+
onView={(i) => {
1283+
setIndex(i);
1284+
setOpen(true);
1285+
}}
1286+
/>
1287+
<PhotoLightboxDialog
1288+
open={open}
1289+
onOpenChange={setOpen}
1290+
photos={SAMPLE_PHOTOS}
1291+
initialIndex={index}
1292+
/>
1293+
</div>
1294+
);
1295+
}
1296+
12071297
// ─── registry ─────────────────────────────────────────────────────────────────
12081298
// To add a base component: add an entry to BASE_COMPONENTS.
12091299
// To add a composed component: add an entry to COMPOSED_COMPONENTS.
12101300

12111301
const BASE_COMPONENTS: { name: string; Demo: () => ReactNode }[] = [
1302+
{ name: "Alert", Demo: AlertDemo },
12121303
{ name: "Avatar", Demo: AvatarDemo },
12131304
{ name: "Badge", Demo: BadgeDemo },
12141305
{ name: "StatusLabels", Demo: StatusLabelsDemo },
12151306
{ name: "Breadcrumb", Demo: BreadcrumbDemo },
12161307
{ name: "Button", Demo: ButtonDemo },
12171308
{ name: "Card", Demo: CardDemo },
12181309
{ name: "Checkbox", Demo: CheckboxSectionDemo },
1310+
{ name: "Collapsible", Demo: CollapsibleDemo },
1311+
{ name: "CopyButton", Demo: CopyButtonDemo },
12191312
{ name: "Dialog", Demo: DialogDemo },
12201313
{ name: "Dropdown", Demo: DropdownMenuDemo },
12211314
{ name: "Input", Demo: InputDemo },
@@ -1229,6 +1322,7 @@ const BASE_COMPONENTS: { name: string; Demo: () => ReactNode }[] = [
12291322
{ name: "Tabs", Demo: TabsDemo },
12301323
{ name: "Furniture category tabs", Demo: FurnitureCategoryTabsDemo },
12311324
{ name: "Textarea", Demo: TextareaDemo },
1325+
{ name: "ToggleGroup", Demo: ToggleGroupDemo },
12321326
{ name: "Tooltip", Demo: TooltipDemo },
12331327
];
12341328

@@ -1244,6 +1338,7 @@ const COMPOSED_COMPONENTS: { name: string; Demo: () => ReactNode }[] = [
12441338
{ name: "Form breadcrumb", Demo: FormBreadcrumbDemo },
12451339
{ name: "DataTable", Demo: DataTableDemo },
12461340
{ name: "BigToggleButton", Demo: BigToggleButtonDemo },
1341+
{ name: "PhotoStrip + Lightbox", Demo: PhotoStripDemo },
12471342
];
12481343

12491344
// ─── page ─────────────────────────────────────────────────────────────────────
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"use client";
2+
3+
import { useRouter } from "next/navigation";
4+
import { useState } from "react";
5+
6+
import { SidebarAppShell } from "@/common/components/ui/sidebar-app-shell";
7+
import { AdminSidebar } from "@/common/components/ui/admin-sidebar";
8+
import { AdminHeader } from "@/common/components/layout";
9+
import { Button } from "@/common/components/ui/button";
10+
import { useDonationRequestStore } from "@/app/donation-request/stores/donationRequestStore";
11+
import {
12+
ConfirmPickupDateDialog,
13+
DonationItemCard,
14+
DonationRequestHeader,
15+
DonorInformationCard,
16+
SchedulePickupDialog,
17+
ScheduledPickupCard,
18+
countApproved,
19+
deriveReviewStatus,
20+
} from "@/app/donation-request/components";
21+
22+
export default function DonationRequestPage() {
23+
const router = useRouter();
24+
const [search, setSearch] = useState("");
25+
const [scheduleOpen, setScheduleOpen] = useState(false);
26+
const [scheduleMode, setScheduleMode] = useState<"schedule" | "edit">(
27+
"schedule"
28+
);
29+
const [confirmOpen, setConfirmOpen] = useState(false);
30+
31+
const request = useDonationRequestStore((state) => state.request);
32+
const approveItem = useDonationRequestStore((state) => state.approveItem);
33+
const rejectItem = useDonationRequestStore((state) => state.rejectItem);
34+
const updateDonor = useDonationRequestStore((state) => state.updateDonor);
35+
const schedulePickup = useDonationRequestStore(
36+
(state) => state.schedulePickup
37+
);
38+
const updatePickup = useDonationRequestStore((state) => state.updatePickup);
39+
const confirmPickup = useDonationRequestStore((state) => state.confirmPickup);
40+
41+
const reviewStatus = deriveReviewStatus(request);
42+
const approvedCount = countApproved(request);
43+
const { pickup } = request;
44+
45+
const openSchedule = () => {
46+
setScheduleMode("schedule");
47+
setScheduleOpen(true);
48+
};
49+
const openEdit = () => {
50+
setScheduleMode("edit");
51+
setScheduleOpen(true);
52+
};
53+
54+
return (
55+
<SidebarAppShell sidebar={<AdminSidebar activeItem="donation-requests" />}>
56+
<div className="flex min-h-svh flex-col">
57+
<AdminHeader
58+
search={search}
59+
onSearchChange={setSearch}
60+
userInitials="WX"
61+
/>
62+
63+
<main className="mx-auto flex w-full max-w-[1174px] flex-1 flex-col gap-2xl px-2xl py-lg">
64+
<DonationRequestHeader
65+
request={request}
66+
reviewStatus={reviewStatus}
67+
approvedCount={approvedCount}
68+
totalItems={request.items.length}
69+
onSchedulePickup={openSchedule}
70+
/>
71+
72+
<DonorInformationCard donor={request.donor} onSave={updateDonor} />
73+
74+
{pickup?.scheduled_date && (
75+
<ScheduledPickupCard
76+
pickup={pickup}
77+
onEdit={openEdit}
78+
onConfirm={() => setConfirmOpen(true)}
79+
/>
80+
)}
81+
82+
<section className="flex flex-col gap-lg">
83+
<h2 className="text-heading-3 font-semibold text-foreground">
84+
{request.items.length} Items Donated
85+
</h2>
86+
{request.items.map((item) => (
87+
<DonationItemCard
88+
key={item.id}
89+
item={item}
90+
onApprove={approveItem}
91+
onReject={rejectItem}
92+
/>
93+
))}
94+
</section>
95+
</main>
96+
97+
<nav className="mt-auto flex w-full justify-end border-t border-border px-2xl py-lg">
98+
<Button variant="outline" onClick={() => router.back()}>
99+
Back
100+
</Button>
101+
</nav>
102+
</div>
103+
104+
<SchedulePickupDialog
105+
open={scheduleOpen}
106+
onOpenChange={setScheduleOpen}
107+
mode={scheduleMode}
108+
isConfirmed={Boolean(pickup?.confirmed_at)}
109+
defaultDate={pickup?.scheduled_date ?? ""}
110+
defaultNote={pickup?.note ?? ""}
111+
onSubmit={(date, note) =>
112+
scheduleMode === "edit"
113+
? updatePickup(date, note)
114+
: schedulePickup(date, note)
115+
}
116+
/>
117+
118+
{pickup?.scheduled_date && (
119+
<ConfirmPickupDateDialog
120+
open={confirmOpen}
121+
onOpenChange={setConfirmOpen}
122+
date={pickup.scheduled_date}
123+
onConfirm={confirmPickup}
124+
/>
125+
)}
126+
</SidebarAppShell>
127+
);
128+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
"use client";
2+
3+
import {
4+
Dialog,
5+
DialogBody,
6+
DialogClose,
7+
DialogContent,
8+
DialogFooter,
9+
DialogHeader,
10+
DialogTitle,
11+
DialogDescription,
12+
} from "@/common/components/ui/dialog";
13+
import { Button } from "@/common/components/ui/button";
14+
import { InformationBlock } from "@/common/components/data-display";
15+
import { formatDate } from "@/common/utils/DateUtils";
16+
17+
interface ConfirmPickupDateDialogProps {
18+
open: boolean;
19+
onOpenChange: (open: boolean) => void;
20+
/** ISO date being confirmed. */
21+
date: string;
22+
onConfirm: () => void;
23+
}
24+
25+
/**
26+
* Final confirmation before notifying the donor of the scheduled pickup date.
27+
*/
28+
function ConfirmPickupDateDialog({
29+
open,
30+
onOpenChange,
31+
date,
32+
onConfirm,
33+
}: ConfirmPickupDateDialogProps) {
34+
return (
35+
<Dialog open={open} onOpenChange={onOpenChange}>
36+
<DialogContent>
37+
<DialogHeader>
38+
<DialogTitle>Confirm Pickup Date</DialogTitle>
39+
<DialogDescription>
40+
A confirmation email will be sent to the donor with the chosen
41+
pickup date.
42+
</DialogDescription>
43+
</DialogHeader>
44+
<DialogBody>
45+
<InformationBlock label="Selected Date" value={formatDate(date)} />
46+
</DialogBody>
47+
<DialogFooter>
48+
<DialogClose render={<Button variant="outline" />}>
49+
Cancel
50+
</DialogClose>
51+
<DialogClose render={<Button onClick={onConfirm} />}>
52+
Confirm and Send
53+
</DialogClose>
54+
</DialogFooter>
55+
</DialogContent>
56+
</Dialog>
57+
);
58+
}
59+
60+
export { ConfirmPickupDateDialog };

0 commit comments

Comments
 (0)