diff --git a/src/pages/Reconciliation.tsx b/src/pages/Reconciliation.tsx index fd30ca0..6139d55 100644 --- a/src/pages/Reconciliation.tsx +++ b/src/pages/Reconciliation.tsx @@ -1,12 +1,15 @@ import { useState } from "react"; -import { GitMerge, CircleDashed, X } from "lucide-react"; +import { + ArrowRight, + CircleDashed, + GitMerge, + X, +} from "lucide-react"; import { toast } from "sonner"; import { useReconciliation } from "@/hooks/useReconciliation"; import { ApiError } from "@/lib/api"; import { Skeleton } from "@/components/ui/skeleton"; -import { EmptyState } from "@/components/ui/empty-state"; import { ErrorState } from "@/components/ui/error-state"; -import { PageHeader } from "@/components/PageHeader"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -16,43 +19,103 @@ import { cn } from "@/lib/utils"; * selected" to POST /api/reconciliation/match. The hook's invalidation * cascade refreshes the unmatched bucket + claims + remits + activity feeds * on success. + * + * Hybrid layout: dark editorial hero → torn-page fold → cream paper plane + * with the two pairing columns, a footer with the match action, and a + * "currently selected" annotation between them. */ export function ReconciliationPage() { const { unmatched, match } = useReconciliation(); const [selectedClaim, setSelectedClaim] = useState(null); const [selectedRemit, setSelectedRemit] = useState(null); + // ----------------------------------------------------------------- + // Stagger choreography + // ----------------------------------------------------------------- + const heroDelay = 0; + const foldDelay = 220; + const titleDelay = 320; + const kpiDelay = 460; + const columnsDelay = 600; + const actionDelay = 760; + + // ---------- Loading state ---------- if (unmatched.isLoading) { return ( -
- Manual pairing} - /> -
- - +
+
+
+
+
+ + Reconciliation + +
+

+ Manual{" "} + pairing. +

+
+
+
+
+ + +
); } + // ---------- Error state ---------- if (unmatched.isError) { const detail = unmatched.error instanceof Error ? unmatched.error.message : String(unmatched.error); return ( -
- Manual pairing} - /> - unmatched.refetch()} - /> +
+
+
+
+
+ + Reconciliation + +
+

+ Manual{" "} + pairing. +

+
+
+
+ unmatched.refetch()} + /> +
); } @@ -63,24 +126,6 @@ export function ReconciliationPage() { }; const empty = claims.length === 0 && remittances.length === 0; - if (empty) { - return ( -
- Manual pairing} - /> -
- } - eyebrow="Reconciliation · nothing pending" - message="Every claim and remittance is paired." - /> -
-
- ); - } - const handleMatch = async () => { if (!selectedClaim || !selectedRemit) return; try { @@ -104,117 +149,886 @@ export function ReconciliationPage() { setSelectedRemit(null); }; - return ( -
- Manual pairing} - subtitle="Pick one claim and one remittance, then match them." - /> + // ---------- Empty state ---------- + if (empty) { + return ( +
+
+
+ PAIRED +
+
+
+
+ + Reconciliation + +
+

+ Manual{" "} + pairing. +

+

+ Pick one claim and one remittance, then match them. The system + reconciles automatically when amounts and providers line up; + the ones that don't are dropped here for you to triage. +

+
+
-
- {/* Claims column */} -
-

- Unmatched claims - {claims.length} -

-
- {claims.map((c) => { - const active = selectedClaim === c.id; - return ( - - ); - })} + {/* Fold */} +
+
+
+
+
+ {Array.from({ length: 48 }, (_, i) => ( + + ))} +
+
+ + ↘ + + + Pair them by hand + + + ↙ +
- {/* Remits column */} -
-

- Unmatched remits - {remittances.length} -

-
- {remittances.map((r) => { - const active = selectedRemit === r.id; - return ( - - ); - })} +
+
\")", + backgroundSize: "160px 160px", + mixBlendMode: "multiply", + }} + /> +
+ +
+ Nothing pending. +
+
+ Every claim and remittance is paired. +
+ ); + } -
- - - {selectedClaim || selectedRemit ? ( - - {selectedClaim ? `Claim ${selectedClaim}` : "No claim"} - {" · "} - {selectedRemit ? `Remit ${selectedRemit}` : "No remit"} + {Array.from({ length: 48 }, (_, i) => ( + + ))} +
+
+ + ↘ - ) : null} + + Pair them by hand + + + ↙ + +
+
+ + {/* PAPER PLANE */} +
+ {/* Paper grain */} +
\")", + backgroundSize: "160px 160px", + mixBlendMode: "multiply", + }} + /> + + {/* Title block */} +
+
+
+
+
+ Reconciliation · Sheet 01 +
+

+ Pair them. +

+
+ One claim, one remit. The system learns from every pair you + confirm — make them count. +
+
+
+
+ In the queue +
+
+ {claims.length + remittances.length} +
+
+ {claims.length} claim{claims.length === 1 ? "" : "s"} ·{" "} + {remittances.length} remit + {remittances.length === 1 ? "" : "s"} +
+
+
+
+ + {/* KPI strip — § 01 Vital signs */} +
+
+ + § 01 + +
+ + Vital signs + +
+
+ + + +
+
+ + {/* Two-column pairing surface — § 02 The match */} +
+
+ + § 02 + +
+ + The match + +
+ +
+
+
+
+ The match +
+

+ Pick one of each. +

+
+
+ Tap a row on the left, tap a row on the right, then send the + pair. +
+
+ +
+ {/* Claims column */} + + {claims.map((c) => { + const active = selectedClaim === c.id; + return ( + + ); + })} + + + {/* Center bridge — a vertical "→" between the two columns */} +
+
+
+ {selectedClaim && selectedRemit ? ( + + ) : ( + + )} +
+
+
+ + {/* Remits column */} + + {remittances.map((r) => { + const active = selectedRemit === r.id; + return ( + + ); + })} + +
+
+
+ + {/* Action footer — § 03 The action */} +
+
+ + § 03 + +
+ + The action + +
+
+
+ {selectedClaim && selectedRemit ? ( + <> + Ready to pair{" "} + + {selectedClaim} + {" "} + with{" "} + + {selectedRemit} + + . + + ) : selectedClaim ? ( + <>Now pick a remit on the right. + ) : selectedRemit ? ( + <>Now pick a claim on the left. + ) : ( + <>Pick one from each side to begin. + )} +
+
+ + +
+
+
+ + {/* Footer */} +
+ End of sheet 01 + Cyclone · Reconciliation + + {claims.length + remittances.length}{" "} + {claims.length + remittances.length === 1 ? "row" : "rows"} pending + +
+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Helper components +// --------------------------------------------------------------------------- + +function KpiTile({ + label, + value, + tone, +}: { + label: string; + value: number; + tone: "blue" | "amber" | "ink"; +}) { + const colorMap = { + blue: "hsl(212 100% 45%)", + amber: "hsl(36 92% 50%)", + ink: "hsl(var(--surface-ink))", + } as const; + const tintMap = { + blue: "hsl(212 85% 95%)", + amber: "hsl(36 82% 92%)", + ink: "hsl(36 22% 92%)", + } as const; + return ( +
+
+
+
+ {label} +
+
+ +
+
+
+ {value} +
+
+ ); +} + +function PairColumn({ + eyebrow, + count, + tone, + empty, + emptyMessage, + children, +}: { + eyebrow: string; + count: number; + tone: "blue" | "amber"; + empty: boolean; + emptyMessage: string; + children: React.ReactNode; +}) { + const accent = + tone === "blue" ? "hsl(212 100% 45%)" : "hsl(36 92% 50%)"; + const tint = + tone === "blue" ? "hsl(212 85% 95%)" : "hsl(36 82% 92%)"; + return ( +
+
+
+
+ + {eyebrow} + +
+ + {count} + +
+
+ {empty ? ( +
+ {emptyMessage} +
+ ) : ( + children + )}
);