mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
Add ACMCC app source, Supabase backend, and project config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,597 @@
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import { accounting } from "@/lib/accountingClient";
|
||||
import { useCompanyId } from "./lib/useCompanyId";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
|
||||
import { ArrowLeft, CheckCircle2, AlertTriangle, FileDown, Search, Loader2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { money, fmtDate } from "./lib/format";
|
||||
import { renderReconciliationPdf, type ReconReportData } from "./lib/reconciliationPdf";
|
||||
|
||||
type Tx = {
|
||||
id: string;
|
||||
date: string;
|
||||
description: string;
|
||||
reference: string | null;
|
||||
amount: number;
|
||||
type: "debit" | "credit";
|
||||
cleared: boolean;
|
||||
reconciliation_id: string | null;
|
||||
};
|
||||
|
||||
export default function AccountingReconcileDetailPage() {
|
||||
const { accountId = "" } = useParams();
|
||||
const { companyId, loading: companyLoading, error: companyError, associationId, associationName } = useCompanyId();
|
||||
const { user } = useAuth();
|
||||
const cid = companyId ?? "";
|
||||
const cur = "USD";
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [setupOpen, setSetupOpen] = useState(false);
|
||||
const [setup, setSetup] = useState({
|
||||
statement_end_date: new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }),
|
||||
statement_balance: 0,
|
||||
});
|
||||
const [active, setActive] = useState<{
|
||||
statement_end_date: string;
|
||||
statement_balance: number;
|
||||
opening_balance: number;
|
||||
} | null>(null);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState<"all" | "deposits" | "withdrawals">("all");
|
||||
const [checked, setChecked] = useState<Set<string>>(new Set());
|
||||
|
||||
const [successOpen, setSuccessOpen] = useState(false);
|
||||
const [successData, setSuccessData] = useState<ReconReportData | null>(null);
|
||||
const [adjOpen, setAdjOpen] = useState(false);
|
||||
const [adjAccount, setAdjAccount] = useState("");
|
||||
const [adjNote, setAdjNote] = useState("Bank reconciliation adjustment");
|
||||
|
||||
const { data: account } = useQuery({
|
||||
queryKey: ["account", accountId],
|
||||
enabled: !!accountId,
|
||||
queryFn: async () =>
|
||||
(await accounting.from("accounts").select("*").eq("id", accountId).single()).data,
|
||||
});
|
||||
|
||||
const { data: history = [] } = useQuery({
|
||||
queryKey: ["recon-history", accountId],
|
||||
enabled: !!accountId,
|
||||
queryFn: async () =>
|
||||
(
|
||||
await accounting
|
||||
.from("reconciliations")
|
||||
.select("*")
|
||||
.eq("account_id", accountId)
|
||||
.order("statement_end_date", { ascending: false })
|
||||
).data ?? [],
|
||||
});
|
||||
|
||||
const lastCompleted = (history as any[]).find((h: any) => h.status === "completed");
|
||||
const openingBalance = active?.opening_balance ?? Number(lastCompleted?.statement_balance ?? 0);
|
||||
|
||||
const { data: allAccounts = [] } = useQuery({
|
||||
queryKey: ["accounts", cid],
|
||||
enabled: !!cid,
|
||||
queryFn: async () =>
|
||||
(await accounting.from("accounts").select("id,name,type").eq("company_id", cid).order("name")).data ?? [],
|
||||
});
|
||||
|
||||
const { data: txs = [] } = useQuery({
|
||||
queryKey: ["recon-txs", accountId, active?.statement_end_date],
|
||||
enabled: !!accountId && !!active,
|
||||
queryFn: async () => {
|
||||
const { data } = await accounting
|
||||
.from("transactions")
|
||||
.select("id,date,description,reference,amount,type,cleared,reconciliation_id")
|
||||
.eq("account_id", accountId)
|
||||
.is("reconciliation_id", null)
|
||||
.lte("date", active!.statement_end_date)
|
||||
.order("date");
|
||||
return (data ?? []) as Tx[];
|
||||
},
|
||||
});
|
||||
|
||||
// Preload checked from already-cleared rows
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
setChecked(new Set((txs as Tx[]).filter((t) => t.cleared).map((t) => t.id)));
|
||||
}
|
||||
}, [active, txs]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const s = search.toLowerCase().trim();
|
||||
return (txs as Tx[]).filter((t) => {
|
||||
if (filter === "deposits" && t.type !== "credit") return false;
|
||||
if (filter === "withdrawals" && t.type !== "debit") return false;
|
||||
if (s && !`${t.description} ${t.reference ?? ""}`.toLowerCase().includes(s)) return false;
|
||||
return true;
|
||||
});
|
||||
}, [txs, search, filter]);
|
||||
|
||||
const clearedDeposits = useMemo(
|
||||
() => (txs as Tx[]).filter((t) => checked.has(t.id) && t.type === "credit")
|
||||
.reduce((s, t) => s + Number(t.amount), 0),
|
||||
[txs, checked],
|
||||
);
|
||||
const clearedWithdrawals = useMemo(
|
||||
() => (txs as Tx[]).filter((t) => checked.has(t.id) && t.type === "debit")
|
||||
.reduce((s, t) => s + Number(t.amount), 0),
|
||||
[txs, checked],
|
||||
);
|
||||
const clearedBalance = openingBalance + clearedDeposits - clearedWithdrawals;
|
||||
const difference = Number(active?.statement_balance ?? 0) - clearedBalance;
|
||||
const balanced = Math.abs(difference) < 0.005;
|
||||
|
||||
const startReconciling = () => {
|
||||
if (!setup.statement_end_date) return toast.error("Statement date required");
|
||||
setActive({
|
||||
statement_end_date: setup.statement_end_date,
|
||||
statement_balance: Number(setup.statement_balance) || 0,
|
||||
opening_balance: Number(lastCompleted?.statement_balance ?? 0),
|
||||
});
|
||||
setSetupOpen(false);
|
||||
};
|
||||
|
||||
const toggleAll = (on: boolean) => {
|
||||
if (on) setChecked(new Set(filtered.map((t) => t.id)));
|
||||
else setChecked(new Set());
|
||||
};
|
||||
|
||||
const saveForLater = async () => {
|
||||
if (!active) return;
|
||||
const ids = Array.from(checked);
|
||||
if (ids.length === 0) return toast.error("No transactions selected");
|
||||
const { error } = await accounting.from("transactions").update({ cleared: true }).in("id", ids);
|
||||
if (error) return toast.error(error.message);
|
||||
toast.success("Progress saved");
|
||||
qc.invalidateQueries({ queryKey: ["recon-txs", accountId] });
|
||||
};
|
||||
|
||||
const finish = async () => {
|
||||
if (!active || !balanced) return;
|
||||
if (!cid) return toast.error("No company selected");
|
||||
const ids = Array.from(checked);
|
||||
|
||||
const { data: rec, error: recErr } = await accounting
|
||||
.from("reconciliations")
|
||||
.insert({
|
||||
company_id: cid,
|
||||
account_id: accountId,
|
||||
statement_end_date: active.statement_end_date,
|
||||
statement_balance: active.statement_balance,
|
||||
opening_balance: active.opening_balance,
|
||||
cleared_deposits: clearedDeposits,
|
||||
cleared_withdrawals: clearedWithdrawals,
|
||||
status: "completed",
|
||||
completed_at: new Date().toISOString(),
|
||||
completed_by: user?.id ?? null,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (recErr || !rec) return toast.error(recErr?.message ?? "Failed to save reconciliation");
|
||||
|
||||
if (ids.length > 0) {
|
||||
const { error: txErr } = await accounting
|
||||
.from("transactions")
|
||||
.update({ cleared: true, reconciliation_id: rec.id })
|
||||
.in("id", ids);
|
||||
if (txErr) {
|
||||
toast.error(`Reconciliation saved but transaction update failed: ${txErr.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const deposits = (txs as Tx[]).filter((t) => checked.has(t.id) && t.type === "credit")
|
||||
.map((t) => ({ date: t.date, description: t.description, amount: Number(t.amount) }));
|
||||
const withdrawals = (txs as Tx[]).filter((t) => checked.has(t.id) && t.type === "debit")
|
||||
.map((t) => ({ date: t.date, description: t.description, amount: Number(t.amount) }));
|
||||
|
||||
setSuccessData({
|
||||
companyName: associationName ?? "Company",
|
||||
accountName: (account as any)?.name ?? "Account",
|
||||
statementEndDate: active.statement_end_date,
|
||||
openingBalance: active.opening_balance,
|
||||
statementBalance: active.statement_balance,
|
||||
difference: 0,
|
||||
deposits,
|
||||
withdrawals,
|
||||
preparedBy: user?.email ?? "—",
|
||||
currency: cur,
|
||||
completedAt: new Date().toISOString(),
|
||||
});
|
||||
setSuccessOpen(true);
|
||||
setActive(null);
|
||||
setChecked(new Set());
|
||||
qc.invalidateQueries({ queryKey: ["recon-history", accountId] });
|
||||
qc.invalidateQueries({ queryKey: ["recon-last", cid] });
|
||||
qc.invalidateQueries({ queryKey: ["recon-txs", accountId] });
|
||||
};
|
||||
|
||||
const postAdjustment = async () => {
|
||||
if (!active || !adjAccount) return toast.error("Pick an account");
|
||||
const adjAmount = difference; // sign: positive if statement > cleared
|
||||
const type: "credit" | "debit" = adjAmount >= 0 ? "credit" : "debit";
|
||||
const { data, error } = await accounting
|
||||
.from("transactions")
|
||||
.insert({
|
||||
company_id: cid,
|
||||
account_id: accountId,
|
||||
date: active.statement_end_date,
|
||||
description: adjNote || "Reconciliation adjustment",
|
||||
amount: Math.abs(adjAmount),
|
||||
type,
|
||||
cleared: true,
|
||||
reference: "ADJ",
|
||||
category: "Adjustment",
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
if (error || !data) return toast.error(error?.message ?? "Failed");
|
||||
|
||||
// Offset entry to chosen account
|
||||
await accounting.from("transactions").insert({
|
||||
company_id: cid,
|
||||
account_id: adjAccount,
|
||||
date: active.statement_end_date,
|
||||
description: adjNote || "Reconciliation adjustment",
|
||||
amount: Math.abs(adjAmount),
|
||||
type: type === "credit" ? "debit" : "credit",
|
||||
reference: "ADJ",
|
||||
category: "Adjustment",
|
||||
});
|
||||
|
||||
setAdjOpen(false);
|
||||
toast.success("Adjustment posted");
|
||||
qc.invalidateQueries({ queryKey: ["recon-txs", accountId] });
|
||||
// auto-check the new adjustment
|
||||
setChecked((prev) => new Set(prev).add(data.id));
|
||||
};
|
||||
|
||||
if (!associationId) return <p className="text-sm text-muted-foreground">Select an association.</p>;
|
||||
if (companyLoading) return <div className="flex justify-center py-12"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>;
|
||||
if (companyError || !companyId) return <p className="text-sm text-muted-foreground text-center py-12">{companyError || "Accounting setup is not ready."}</p>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/dashboard/accounting/reconciliation"><ArrowLeft className="h-4 w-4 mr-1" />Bank Reconciliation</Link>
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">{(account as any)?.name ?? "Account"}</h1>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Current balance {money((account as any)?.balance, cur)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!active && (
|
||||
<Button onClick={() => {
|
||||
setSetup({
|
||||
statement_end_date: new Date().toLocaleDateString("en-CA", { timeZone: "America/New_York" }),
|
||||
statement_balance: 0,
|
||||
});
|
||||
setSetupOpen(true);
|
||||
}}>Reconcile</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{active ? (
|
||||
<div className="grid gap-4 lg:grid-cols-[1fr_360px]">
|
||||
{/* Left panel */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<CardTitle className="text-base mr-auto">Statement Transactions</CardTitle>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-8 h-9 w-56"
|
||||
placeholder="Search…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Select value={filter} onValueChange={(v) => setFilter(v as any)}>
|
||||
<SelectTrigger className="h-9 w-40"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="deposits">Deposits only</SelectItem>
|
||||
<SelectItem value="withdrawals">Withdrawals only</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8">
|
||||
<Checkbox
|
||||
checked={filtered.length > 0 && filtered.every((t) => checked.has(t.id))}
|
||||
onCheckedChange={(v) => toggleAll(!!v)}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead>Date</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Ref #</TableHead>
|
||||
<TableHead className="text-right">Deposit</TableHead>
|
||||
<TableHead className="text-right">Withdrawal</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filtered.map((t) => {
|
||||
const on = checked.has(t.id);
|
||||
return (
|
||||
<TableRow
|
||||
key={t.id}
|
||||
className={on ? "bg-emerald-50/60 border-l-4 border-l-emerald-500" : ""}
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={on}
|
||||
onCheckedChange={(v) => {
|
||||
setChecked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (v) next.add(t.id); else next.delete(t.id);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{fmtDate(t.date)}</TableCell>
|
||||
<TableCell className="max-w-[280px] truncate">{t.description}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{t.reference ?? "—"}</TableCell>
|
||||
<TableCell className="text-right text-emerald-700">
|
||||
{t.type === "credit" ? money(t.amount, cur) : ""}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-red-700">
|
||||
{t.type === "debit" ? money(t.amount, cur) : ""}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{filtered.length === 0 && (
|
||||
<TableRow><TableCell colSpan={6} className="text-center text-muted-foreground py-8">
|
||||
No unreconciled transactions in this period.
|
||||
</TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Right panel */}
|
||||
<div className="space-y-4 lg:sticky lg:top-4 lg:self-start">
|
||||
<Card>
|
||||
<CardHeader className="pb-3"><CardTitle className="text-base">Summary</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<div>
|
||||
<Label className="text-xs">Statement ending balance</Label>
|
||||
<Input
|
||||
type="number" step="0.01"
|
||||
value={active.statement_balance}
|
||||
onChange={(e) => setActive({ ...active, statement_balance: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs"><span className="text-muted-foreground">Opening balance</span><span>{money(openingBalance, cur)}</span></div>
|
||||
<div className="flex justify-between text-xs"><span className="text-muted-foreground">+ Cleared deposits ({(txs as Tx[]).filter(t => checked.has(t.id) && t.type === "credit").length})</span><span className="text-emerald-700">+{money(clearedDeposits, cur)}</span></div>
|
||||
<div className="flex justify-between text-xs"><span className="text-muted-foreground">− Cleared withdrawals ({(txs as Tx[]).filter(t => checked.has(t.id) && t.type === "debit").length})</span><span className="text-red-700">−{money(clearedWithdrawals, cur)}</span></div>
|
||||
<div className="flex justify-between border-t pt-2 font-medium text-sm"><span>Cleared balance</span><span>{money(clearedBalance, cur)}</span></div>
|
||||
<div className="flex justify-between text-xs text-muted-foreground"><span>Statement balance</span><span>{money(active.statement_balance, cur)}</span></div>
|
||||
|
||||
<div className="rounded-md border p-3 text-center">
|
||||
<div className="text-xs text-muted-foreground mb-1">Difference</div>
|
||||
<div className={`text-2xl font-bold flex items-center justify-center gap-2 ${balanced ? "text-emerald-600" : "text-red-600"}`}>
|
||||
{balanced ? <CheckCircle2 className="h-6 w-6" /> : <AlertTriangle className="h-6 w-6" />}
|
||||
{money(difference, cur)}
|
||||
</div>
|
||||
{!balanced && (
|
||||
<button
|
||||
onClick={() => setAdjOpen(true)}
|
||||
className="mt-2 text-xs text-primary hover:underline"
|
||||
>
|
||||
Create adjustment entry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
<Button disabled={!balanced} onClick={finish}>Finish Reconciling</Button>
|
||||
<Button variant="outline" onClick={saveForLater}>Save for Later</Button>
|
||||
<Button variant="ghost" onClick={() => { setActive(null); setChecked(new Set()); }}>Cancel</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Tabs defaultValue="overview">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="history">History</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview">
|
||||
<Card>
|
||||
<CardContent className="py-10 text-center space-y-2">
|
||||
<p className="text-muted-foreground">
|
||||
{lastCompleted
|
||||
? <>Last reconciled on <strong>{fmtDate(lastCompleted.statement_end_date)}</strong> at <strong>{money(lastCompleted.statement_balance, cur)}</strong>.</>
|
||||
: "This account has never been reconciled."}
|
||||
</p>
|
||||
<Button onClick={() => setSetupOpen(true)}>Start Reconciling</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
<TabsContent value="history">
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<Table>
|
||||
<TableHeader><TableRow>
|
||||
<TableHead>Period (Statement End)</TableHead>
|
||||
<TableHead>Statement Balance</TableHead>
|
||||
<TableHead>Reconciled Date</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow></TableHeader>
|
||||
<TableBody>
|
||||
{(history as any[]).map((h: any) => (
|
||||
<TableRow key={h.id}>
|
||||
<TableCell>{fmtDate(h.statement_end_date)}</TableCell>
|
||||
<TableCell>{money(h.statement_balance, cur)}</TableCell>
|
||||
<TableCell>{h.completed_at ? fmtDate(h.completed_at) : "—"}</TableCell>
|
||||
<TableCell>
|
||||
{h.status === "completed"
|
||||
? <Badge className="bg-emerald-600">Completed</Badge>
|
||||
: <Badge variant="secondary">In progress</Badge>}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button size="sm" variant="ghost" onClick={async () => {
|
||||
const { data: rows } = await accounting
|
||||
.from("transactions")
|
||||
.select("date,description,amount,type")
|
||||
.eq("reconciliation_id", h.id);
|
||||
const deposits = (rows ?? []).filter((r: any) => r.type === "credit")
|
||||
.map((r: any) => ({ date: r.date, description: r.description, amount: Number(r.amount) }));
|
||||
const withdrawals = (rows ?? []).filter((r: any) => r.type === "debit")
|
||||
.map((r: any) => ({ date: r.date, description: r.description, amount: Number(r.amount) }));
|
||||
renderReconciliationPdf({
|
||||
companyName: associationName ?? "Company",
|
||||
accountName: (account as any)?.name ?? "Account",
|
||||
statementEndDate: h.statement_end_date,
|
||||
openingBalance: Number(h.opening_balance),
|
||||
statementBalance: Number(h.statement_balance),
|
||||
difference: 0,
|
||||
deposits, withdrawals,
|
||||
preparedBy: user?.email ?? "—",
|
||||
currency: cur,
|
||||
completedAt: h.completed_at ?? new Date().toISOString(),
|
||||
});
|
||||
}}>
|
||||
<FileDown className="h-4 w-4 mr-1" />View Report
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{(history as any[]).length === 0 && (
|
||||
<TableRow><TableCell colSpan={5} className="text-center text-muted-foreground py-8">No reconciliations yet.</TableCell></TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
{/* Setup modal */}
|
||||
<Dialog open={setupOpen} onOpenChange={setSetupOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Start Reconciliation</DialogTitle>
|
||||
<DialogDescription>Enter the details from your bank statement.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Statement end date</Label>
|
||||
<Input type="date" value={setup.statement_end_date}
|
||||
onChange={(e) => setSetup({ ...setup, statement_end_date: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Ending statement balance</Label>
|
||||
<Input type="number" step="0.01" value={setup.statement_balance}
|
||||
onChange={(e) => setSetup({ ...setup, statement_balance: Number(e.target.value) })} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Opening balance</Label>
|
||||
<Input readOnly value={Number(lastCompleted?.statement_balance ?? 0).toFixed(2)} />
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Pulled from last reconciliation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setSetupOpen(false)}>Cancel</Button>
|
||||
<Button onClick={startReconciling}>Start Reconciling</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Adjustment modal */}
|
||||
<Dialog open={adjOpen} onOpenChange={setAdjOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Adjustment Entry</DialogTitle>
|
||||
<DialogDescription>
|
||||
Post a journal entry of {money(difference, cur)} to balance the reconciliation.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>Offset account (suspense / bank charges)</Label>
|
||||
<Select value={adjAccount} onValueChange={setAdjAccount}>
|
||||
<SelectTrigger><SelectValue placeholder="Select account" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(allAccounts as any[])
|
||||
.filter((a: any) => a.id !== accountId)
|
||||
.map((a: any) => (
|
||||
<SelectItem key={a.id} value={a.id}>{a.name} ({a.type})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Note</Label>
|
||||
<Input value={adjNote} onChange={(e) => setAdjNote(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setAdjOpen(false)}>Cancel</Button>
|
||||
<Button onClick={postAdjustment}>Post Adjustment</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Success modal */}
|
||||
<Dialog open={successOpen} onOpenChange={setSuccessOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-600" /> Reconciliation Complete
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{successData && (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Period reconciled</span><span>{fmtDate(successData.statementEndDate)}</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Statement balance</span><span>{money(successData.statementBalance, successData.currency)}</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Cleared transactions</span><span>{successData.deposits.length + successData.withdrawals.length}</span></div>
|
||||
<div className="flex justify-between"><span className="text-muted-foreground">Date completed</span><span>{fmtDate(successData.completedAt)}</span></div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSuccessOpen(false)}>Close</Button>
|
||||
<Button onClick={() => successData && renderReconciliationPdf(successData)}>
|
||||
<FileDown className="h-4 w-4 mr-1" />Export PDF
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user