import { useEffect, useMemo, useState } from "react"; import { format, parseISO, subDays } from "date-fns"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { DatePicker } from "@/components/ui/date-picker"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Field, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { PaginationNavButtons } from "@/components/ui/pagination"; import { Spinner } from "@/components/ui/spinner"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; /** * BankStatementGenerateDialog — WealthX DS (L4 Template) * * Dialog for generating a new bank statement PDF from connected bank accounts. * Shown when the user clicks "Generate" in the Bank Statement tab of * `OpportunityDetailsDrawer`. * * Internal state: * - Statement name (Input) * - Range preset: 90 / 180 / 365 days or Custom (ToggleGroup + DatePickers) * - Applicant type: primary / secondary (ToggleGroup, matching Statement period) * - Selected bank account IDs (Table with Checkboxes, paginated at 5/page) * * Data handed in via props (consumer owns fetching): * - `bankAccounts` — pre-filtered for the current `applicantType` * - `isLoadingAccounts` — spinner while the consumer re-fetches after type change * * Layer: L4 Template * * Data source: `useAggregatedBankAccounts` in backoffice (not included here) */ // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type BankStatementRangePreset = 90 | 180 | 365 | "custom"; export interface BankStatementAccount { id: string; /** Display name for the account (e.g. "Everyday Savings"). */ name: string; /** BSB / account number string. */ accountNo?: string; /** Bank / institution name. */ institutionName?: string; /** URL to institution logo image. */ institutionLogo?: string; /** ISO date string of when the account was last synced. */ lastUpdated?: string; } export interface BankStatementGeneratePayload { statementName: string; rangePreset: BankStatementRangePreset; /** ISO date string (YYYY-MM-DD). Set for all presets as well as custom. */ fromDate: string; /** ISO date string (YYYY-MM-DD). Set for all presets as well as custom. */ toDate: string; applicantType: "primary" | "secondary"; selectedAccountIds: string[]; } export interface BankStatementGenerateDialogProps { open: boolean; onClose: () => void; /** * Called when the user clicks Generate (only if form is valid). * Consumer is responsible for the actual API call. */ onSubmit: (payload: BankStatementGeneratePayload) => void; /** * Called when the user changes the applicant type so the consumer can * re-fetch / re-filter bank accounts for the new type. */ onApplicantTypeChange?: (type: "primary" | "secondary") => void; /** * Bank accounts pre-filtered for the currently selected applicant type. * Pass an empty array while loading. */ bankAccounts: BankStatementAccount[]; /** Enable the "Co-applicant" option. When false it renders disabled, not hidden. */ hasCoApplicant?: boolean; /** Show a loading spinner in the accounts table while the consumer re-fetches. */ isLoadingAccounts?: boolean; /** Show a loading indicator on the Generate button while the API call is in flight. */ isLoading?: boolean; className?: string; } // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const APPLICANT_TYPE_LABELS: Record<"primary" | "secondary", string> = { primary: "Main applicant", secondary: "Co-applicant", }; const ACCOUNTS_PAGE_SIZE = 5; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function toIsoDate(date: Date): string { return date.toISOString().slice(0, 10); } function presetToDateRange(preset: number): { from: string; to: string } { const today = new Date(); return { from: toIsoDate(subDays(today, preset - 1)), to: toIsoDate(today), }; } // --------------------------------------------------------------------------- // BankStatementGenerateDialog // --------------------------------------------------------------------------- export function BankStatementGenerateDialog({ open, onClose, onSubmit, onApplicantTypeChange, bankAccounts, hasCoApplicant = false, isLoadingAccounts = false, isLoading = false, className, }: BankStatementGenerateDialogProps) { const [statementName, setStatementName] = useState("Bank Statement 1"); const [rangePreset, setRangePreset] = useState(90); // Initialise immediately so the Period column never shows "—" on first open. const [fromDate, setFromDate] = useState( () => presetToDateRange(90).from, ); const [toDate, setToDate] = useState(() => presetToDateRange(90).to); const [applicantType, setApplicantType] = useState< "primary" | "secondary" | "" >(""); const [selectedAccountIds, setSelectedAccountIds] = useState([]); const [accountPage, setAccountPage] = useState(1); // Reset form whenever the dialog opens. useEffect(() => { if (!open) return; const timer = setTimeout(() => { const { from, to } = presetToDateRange(90); setStatementName("Bank Statement 1"); setRangePreset(90); setFromDate(from); setToDate(to); setApplicantType(""); setSelectedAccountIds([]); setAccountPage(1); }, 0); return () => clearTimeout(timer); }, [open]); // ── Applicant type ─────────────────────────────────────────────────────── const handleApplicantTypeChange = (type: "primary" | "secondary") => { setApplicantType(type); setSelectedAccountIds([]); setAccountPage(1); onApplicantTypeChange?.(type); }; // ToggleGroup emits an empty array when the active item is clicked again; // the applicant is not an optional choice, so that deselect is ignored. const handleApplicantChange = (val: string[]) => { if (val.length === 0) return; handleApplicantTypeChange(val[0] as "primary" | "secondary"); }; // ── Date range preset ──────────────────────────────────────────────────── const handlePresetChange = (val: string[]) => { if (val.length === 0) return; const raw = val[0]; const preset: BankStatementRangePreset = raw === "custom" ? "custom" : (Number(raw) as 90 | 180 | 365); setRangePreset(preset); if (preset !== "custom") { const { from, to } = presetToDateRange(preset); setFromDate(from); setToDate(to); } }; // ── Account selection ──────────────────────────────────────────────────── const areAllSelected = bankAccounts.length > 0 && selectedAccountIds.length === bankAccounts.length; const isSomeSelected = selectedAccountIds.length > 0 && !areAllSelected; const handleToggleAccount = (id: string) => { setSelectedAccountIds((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id], ); }; const handleToggleAll = () => { setSelectedAccountIds(areAllSelected ? [] : bankAccounts.map((a) => a.id)); }; // ── Accounts pagination ────────────────────────────────────────────────── const accountPageCount = Math.ceil(bankAccounts.length / ACCOUNTS_PAGE_SIZE); const pagedAccounts = bankAccounts.slice( (accountPage - 1) * ACCOUNTS_PAGE_SIZE, accountPage * ACCOUNTS_PAGE_SIZE, ); // ── Period label for the accounts table ───────────────────────────────── const periodLabel = useMemo(() => { if (fromDate && toDate) { return `${format(parseISO(fromDate), "dd MMM yyyy")} – ${format(parseISO(toDate), "dd MMM yyyy")}`; } return "—"; }, [fromDate, toDate]); // ── Submit guard ───────────────────────────────────────────────────────── const isSubmitDisabled = !statementName.trim() || !applicantType || (rangePreset === "custom" && (!fromDate || !toDate)) || selectedAccountIds.length === 0; const handleSubmit = () => { if (isSubmitDisabled || !applicantType) return; onSubmit({ statementName, rangePreset, fromDate, toDate, applicantType, selectedAccountIds, }); }; // ── Render ─────────────────────────────────────────────────────────────── return ( !o && onClose()}> Generate Bank Statement
{/* Statement name */} Name setStatementName(e.target.value)} placeholder="Bank Statement 1" /> {/* Date range preset */}
Statement period 3 Months 6 Months 12 Months Custom
{/* Custom date pickers */} {rangePreset === "custom" && (
setFromDate(date ? toIsoDate(date) : "")} placeholder="From Date" className="flex-1" /> setToDate(date ? toIsoDate(date) : "")} placeholder="To Date" className="flex-1" />
)} {/* Applicant type */}
Statement for {APPLICANT_TYPE_LABELS.primary} {APPLICANT_TYPE_LABELS.secondary}
{/* Bank accounts table */} {applicantType && (
{isLoadingAccounts ? (
) : bankAccounts.length === 0 ? (

No bank accounts found for the selected applicant

) : ( <> Account Name Account Number Period Last Updated {pagedAccounts.map((account) => { const isSelected = selectedAccountIds.includes( account.id, ); return ( handleToggleAccount(account.id) } aria-label={`Select ${account.name}`} />
{account.institutionLogo && ( {account.institutionName )} {account.name || account.institutionName || "—"}
{account.accountNo ?? "—"} {periodLabel} {account.lastUpdated ? format( parseISO(account.lastUpdated), "dd MMM yyyy", ) : "—"}
); })}
{accountPageCount > 1 && (
{(accountPage - 1) * ACCOUNTS_PAGE_SIZE + 1}– {Math.min( accountPage * ACCOUNTS_PAGE_SIZE, bankAccounts.length, )}{" "} of {bankAccounts.length} accounts 1} hasNext={accountPage < accountPageCount} onFirst={() => setAccountPage(1)} onPrev={() => setAccountPage((p) => p - 1)} onNext={() => setAccountPage((p) => p + 1)} onLast={() => setAccountPage(accountPageCount)} />
)} )}
)}
); }