1034 lines
47 KiB
TypeScript
1034 lines
47 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { AppShell } from "../../components/app-shell";
|
|
import { apiFetch } from "@/lib/api";
|
|
|
|
type TransactionRow = {
|
|
viewRef: string;
|
|
name?: string;
|
|
description?: string;
|
|
amount: string;
|
|
category?: string | null;
|
|
note?: string | null;
|
|
attribution?: "mine" | "yours" | "ours";
|
|
split?: {
|
|
mode: "none" | "equal" | "custom";
|
|
minePercent: number;
|
|
yoursPercent: number;
|
|
mineAmount: number;
|
|
yoursAmount: number;
|
|
};
|
|
status?: string;
|
|
hidden?: boolean;
|
|
commentCount?: number;
|
|
date: string;
|
|
};
|
|
|
|
type TransactionComment = {
|
|
id: string;
|
|
body: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
author: {
|
|
displayName: string;
|
|
email?: string | null;
|
|
};
|
|
};
|
|
|
|
type Account = {
|
|
viewRef: string;
|
|
institutionName: string;
|
|
accountType: string;
|
|
mask?: string | null;
|
|
};
|
|
|
|
type ImportResult = {
|
|
imported: number;
|
|
skipped: number;
|
|
total?: number;
|
|
errors?: string[];
|
|
};
|
|
|
|
type ImportBatchResult = {
|
|
totalFiles: number;
|
|
processedFiles: number;
|
|
failedFiles: number;
|
|
imported: number;
|
|
skipped: number;
|
|
total: number;
|
|
results: Array<{
|
|
fileName: string;
|
|
imported: number;
|
|
skipped: number;
|
|
total: number;
|
|
error?: string;
|
|
}>;
|
|
};
|
|
|
|
type CsvMapping = {
|
|
date: string;
|
|
description: string;
|
|
amount: string;
|
|
category?: string;
|
|
notes?: string;
|
|
amountMultiplier?: 1 | -1;
|
|
};
|
|
|
|
type CsvPreview = {
|
|
fileName: string;
|
|
headerSignature: string;
|
|
headers: string[];
|
|
rowCount: number;
|
|
mapping: Partial<CsvMapping>;
|
|
remembered: boolean;
|
|
};
|
|
|
|
type CashflowRow = {
|
|
month: string;
|
|
income: string;
|
|
expense: string;
|
|
net: string;
|
|
};
|
|
|
|
type MerchantInsight = {
|
|
merchant: string;
|
|
total: string;
|
|
count: number;
|
|
};
|
|
|
|
export default function TransactionsPage() {
|
|
const [rows, setRows] = useState<TransactionRow[]>([]);
|
|
const [status, setStatus] = useState("Loading transactions...");
|
|
const [summary, setSummary] = useState<{
|
|
total: string; count: number; income?: string; expense?: string; net?: string;
|
|
} | null>(null);
|
|
const [cashflow, setCashflow] = useState<CashflowRow[]>([]);
|
|
const [merchants, setMerchants] = useState<MerchantInsight[]>([]);
|
|
const [datePreset, setDatePreset] = useState("this_month");
|
|
const [showFilters, setShowFilters] = useState(false);
|
|
const [accounts, setAccounts] = useState<Account[]>([]);
|
|
const [autoSync, setAutoSync] = useState(true);
|
|
const [isSyncing, setIsSyncing] = useState(false);
|
|
const [showManual, setShowManual] = useState(false);
|
|
const [showImport, setShowImport] = useState(false);
|
|
const [importStatus, setImportStatus] = useState("");
|
|
const [importLoading, setImportLoading] = useState(false);
|
|
const [csvFiles, setCsvFiles] = useState<File[]>([]);
|
|
const [csvPreview, setCsvPreview] = useState<CsvPreview | null>(null);
|
|
const [csvMapping, setCsvMapping] = useState<CsvMapping>({
|
|
date: "",
|
|
description: "",
|
|
amount: "",
|
|
amountMultiplier: 1,
|
|
});
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const [manualForm, setManualForm] = useState({
|
|
accountRef: "",
|
|
date: new Date().toISOString().slice(0, 10),
|
|
description: "",
|
|
amount: "",
|
|
category: "",
|
|
note: "",
|
|
attribution: "mine" as "mine" | "yours" | "ours",
|
|
splitMode: "none" as "none" | "equal" | "custom",
|
|
splitMinePercent: "50",
|
|
splitYoursPercent: "50",
|
|
});
|
|
const [editingRef, setEditingRef] = useState<string | null>(null);
|
|
const [commentRef, setCommentRef] = useState<string | null>(null);
|
|
const [comments, setComments] = useState<TransactionComment[]>([]);
|
|
const [commentDraft, setCommentDraft] = useState("");
|
|
const [commentStatus, setCommentStatus] = useState("");
|
|
const [editForm, setEditForm] = useState({
|
|
category: "",
|
|
note: "",
|
|
attribution: "mine" as "mine" | "yours" | "ours",
|
|
splitMode: "none" as "none" | "equal" | "custom",
|
|
splitMinePercent: "50",
|
|
splitYoursPercent: "50",
|
|
hidden: false,
|
|
});
|
|
const [filters, setFilters] = useState({
|
|
startDate: "", endDate: "", minAmount: "", maxAmount: "",
|
|
category: "", source: "", search: "", includeHidden: false,
|
|
});
|
|
|
|
const applyPreset = (preset: string) => {
|
|
setDatePreset(preset);
|
|
if (preset === "custom") return;
|
|
const now = new Date();
|
|
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
let start = new Date(end);
|
|
if (preset === "this_month") {
|
|
start = new Date(end.getFullYear(), end.getMonth(), 1);
|
|
} else if (preset === "last_month") {
|
|
start = new Date(end.getFullYear(), end.getMonth() - 1, 1);
|
|
end.setDate(0);
|
|
} else if (preset === "last_6_months") {
|
|
start = new Date(end.getFullYear(), end.getMonth() - 5, 1);
|
|
} else if (preset === "last_year") {
|
|
start = new Date(end.getFullYear() - 1, 0, 1);
|
|
end.setMonth(11, 31);
|
|
}
|
|
const fmt = (d: Date) => d.toISOString().slice(0, 10);
|
|
setFilters((prev) => ({ ...prev, startDate: fmt(start), endDate: fmt(end) }));
|
|
};
|
|
|
|
const buildQuery = () => {
|
|
const params = new URLSearchParams();
|
|
if (filters.startDate) params.set("start_date", filters.startDate);
|
|
if (filters.endDate) params.set("end_date", filters.endDate);
|
|
if (filters.minAmount) params.set("min_amount", filters.minAmount);
|
|
if (filters.maxAmount) params.set("max_amount", filters.maxAmount);
|
|
if (filters.category) params.set("category", filters.category);
|
|
if (filters.source) params.set("source", filters.source);
|
|
if (filters.search) params.set("search", filters.search);
|
|
if (filters.includeHidden) params.set("include_hidden", "true");
|
|
return params.toString() ? `?${params.toString()}` : "";
|
|
};
|
|
|
|
const load = async () => {
|
|
const query = buildQuery();
|
|
const res = await apiFetch<{ transactions: TransactionRow[]; total: number }>(`/api/transactions${query}`);
|
|
if (res.error) {
|
|
setStatus(res.error.message ?? "Unable to load transactions.");
|
|
return;
|
|
}
|
|
const txs = res.data?.transactions ?? [];
|
|
setRows(txs);
|
|
setStatus(txs.length ? "" : "No transactions yet.");
|
|
};
|
|
|
|
const loadAccounts = async () => {
|
|
const res = await apiFetch<{ accounts: Account[]; total: number }>("/api/accounts");
|
|
if (!res.error) setAccounts(res.data?.accounts ?? []);
|
|
};
|
|
|
|
const loadSummary = async () => {
|
|
const query = buildQuery();
|
|
const res = await apiFetch<{ total: string; count: number }>(`/api/transactions/summary${query}`);
|
|
if (!res.error) setSummary(res.data);
|
|
};
|
|
|
|
const loadInsights = async () => {
|
|
const [cashflowRes, merchantsRes] = await Promise.all([
|
|
apiFetch<CashflowRow[]>("/api/transactions/cashflow?months=6"),
|
|
apiFetch<MerchantInsight[]>("/api/transactions/merchants?limit=6"),
|
|
]);
|
|
if (!cashflowRes.error) setCashflow(cashflowRes.data ?? []);
|
|
if (!merchantsRes.error) setMerchants(merchantsRes.data ?? []);
|
|
};
|
|
|
|
useEffect(() => {
|
|
applyPreset("this_month");
|
|
load();
|
|
loadSummary();
|
|
loadInsights();
|
|
loadAccounts();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!autoSync) return;
|
|
const id = setInterval(() => { onSync(); }, 5 * 60 * 1000);
|
|
return () => clearInterval(id);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [autoSync, filters.startDate, filters.endDate]);
|
|
|
|
const onSync = async () => {
|
|
if (isSyncing) return;
|
|
setIsSyncing(true);
|
|
setStatus("Syncing transactions...");
|
|
const res = await apiFetch<unknown>("/api/transactions/sync", {
|
|
method: "POST",
|
|
body: JSON.stringify({ startDate: filters.startDate || undefined, endDate: filters.endDate || undefined }),
|
|
});
|
|
if (res.error) {
|
|
setStatus(res.error.message ?? "Sync failed.");
|
|
setIsSyncing(false);
|
|
return;
|
|
}
|
|
setStatus("Sync complete.");
|
|
await load();
|
|
await loadSummary();
|
|
await loadInsights();
|
|
setIsSyncing(false);
|
|
};
|
|
|
|
const onPreviewCsv = async (files: File[]) => {
|
|
const csvFiles = files.filter((file) => file.name.toLowerCase().endsWith(".csv"));
|
|
if (!csvFiles.length) {
|
|
setImportStatus("Select one or more CSV files.");
|
|
return;
|
|
}
|
|
|
|
setCsvFiles(csvFiles);
|
|
setCsvPreview(null);
|
|
setImportLoading(true);
|
|
setImportStatus(`Reading ${csvFiles[0].name}...`);
|
|
|
|
const formData = new FormData();
|
|
formData.append("file", csvFiles[0]);
|
|
try {
|
|
const payload = await apiFetch<CsvPreview>("/api/transactions/import/preview", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
if (payload.error) {
|
|
setImportStatus(payload.error?.message ?? "Preview failed.");
|
|
setImportLoading(false);
|
|
return;
|
|
}
|
|
const preview = payload.data;
|
|
setCsvPreview(preview);
|
|
setCsvMapping({
|
|
date: preview.mapping.date ?? "",
|
|
description: preview.mapping.description ?? "",
|
|
amount: preview.mapping.amount ?? "",
|
|
category: preview.mapping.category,
|
|
notes: preview.mapping.notes,
|
|
amountMultiplier: preview.mapping.amountMultiplier === -1 ? -1 : 1,
|
|
});
|
|
setImportStatus(
|
|
`${csvFiles.length} CSV file${csvFiles.length === 1 ? "" : "s"} selected. ${preview.remembered ? "Using remembered mapping." : "Review the column mapping before import."}`
|
|
);
|
|
} catch {
|
|
setImportStatus("Preview failed. Please try again.");
|
|
}
|
|
setImportLoading(false);
|
|
};
|
|
|
|
const onImportCsv = async () => {
|
|
const selectedFiles = csvFiles.filter((file) => file.name.toLowerCase().endsWith(".csv"));
|
|
if (!selectedFiles.length) {
|
|
setImportStatus("Select one or more CSV files.");
|
|
return;
|
|
}
|
|
if (!csvMapping.date || !csvMapping.description || !csvMapping.amount) {
|
|
setImportStatus("Map date, description, and amount columns before importing.");
|
|
return;
|
|
}
|
|
|
|
setImportLoading(true);
|
|
setImportStatus(`Uploading ${selectedFiles.length} file${selectedFiles.length === 1 ? "" : "s"}...`);
|
|
const formData = new FormData();
|
|
for (const file of selectedFiles) {
|
|
formData.append("files", file);
|
|
}
|
|
formData.append("mapping", JSON.stringify(csvMapping));
|
|
try {
|
|
const payload = await apiFetch<ImportBatchResult>("/api/transactions/import/batch", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
if (payload.error) {
|
|
setImportStatus(payload.error?.message ?? "Import failed.");
|
|
setImportLoading(false);
|
|
return;
|
|
}
|
|
const r = payload.data;
|
|
const failed = r.failedFiles
|
|
? ` ${r.failedFiles} file${r.failedFiles === 1 ? "" : "s"} failed.`
|
|
: "";
|
|
const failedNames = r.results
|
|
.filter((result) => result.error)
|
|
.map((result) => `${result.fileName}: ${result.error}`)
|
|
.join(" ");
|
|
setImportStatus(
|
|
`Processed ${r.processedFiles}/${r.totalFiles} file${r.totalFiles === 1 ? "" : "s"}. Imported ${r.imported} transaction${r.imported === 1 ? "" : "s"}, skipped ${r.skipped} duplicate${r.skipped === 1 ? "" : "s"}.${failed}${failedNames ? ` ${failedNames}` : ""}`
|
|
);
|
|
setCsvFiles([]);
|
|
setCsvPreview(null);
|
|
await load();
|
|
await loadSummary();
|
|
await loadInsights();
|
|
} catch {
|
|
setImportStatus("Import failed. Please try again.");
|
|
}
|
|
setImportLoading(false);
|
|
};
|
|
|
|
const formatAmount = (value: string) => {
|
|
const numeric = Number.parseFloat(value.replace(/[^0-9.-]/g, ""));
|
|
if (Number.isNaN(numeric)) return { display: value, tone: "text-foreground" };
|
|
return {
|
|
display: numeric < 0 ? `-$${Math.abs(numeric).toFixed(2)}` : `$${numeric.toFixed(2)}`,
|
|
tone: numeric < 0 ? "text-foreground" : "text-primary font-bold",
|
|
};
|
|
};
|
|
|
|
const splitPayload = (mode: "none" | "equal" | "custom", mine: string, yours: string) => {
|
|
if (mode === "custom") {
|
|
return {
|
|
splitMode: mode,
|
|
splitMinePercent: Number.parseFloat(mine),
|
|
splitYoursPercent: Number.parseFloat(yours),
|
|
};
|
|
}
|
|
return { splitMode: mode };
|
|
};
|
|
|
|
const splitLabel = (row: TransactionRow) => {
|
|
if (!row.split || row.split.mode === "none") return "No split";
|
|
if (row.split.mode === "equal") return "50/50";
|
|
return `${row.split.minePercent}/${row.split.yoursPercent}`;
|
|
};
|
|
|
|
const onManualCreate = async (event: React.FormEvent) => {
|
|
event.preventDefault();
|
|
const amount = Number.parseFloat(manualForm.amount);
|
|
if (Number.isNaN(amount)) { setStatus("Invalid amount."); return; }
|
|
setStatus("Saving manual transaction...");
|
|
const res = await apiFetch<unknown>("/api/transactions/manual", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
accountId: manualForm.accountRef || undefined,
|
|
date: manualForm.date,
|
|
description: manualForm.description,
|
|
amount,
|
|
category: manualForm.category || undefined,
|
|
note: manualForm.note || undefined,
|
|
attribution: manualForm.attribution,
|
|
...splitPayload(manualForm.splitMode, manualForm.splitMinePercent, manualForm.splitYoursPercent),
|
|
}),
|
|
});
|
|
if (res.error) { setStatus(res.error.message ?? "Unable to save transaction."); return; }
|
|
setManualForm((prev) => ({ ...prev, description: "", amount: "", category: "", note: "", attribution: "mine", splitMode: "none", splitMinePercent: "50", splitYoursPercent: "50" }));
|
|
setShowManual(false);
|
|
setStatus("Manual transaction saved.");
|
|
await load();
|
|
await loadSummary();
|
|
await loadInsights();
|
|
};
|
|
|
|
const startEdit = (row: TransactionRow) => {
|
|
setEditingRef(row.viewRef);
|
|
setEditForm({
|
|
category: row.category ?? "",
|
|
note: row.note ?? "",
|
|
attribution: row.attribution ?? "mine",
|
|
splitMode: row.split?.mode ?? "none",
|
|
splitMinePercent: String(row.split?.minePercent ?? 50),
|
|
splitYoursPercent: String(row.split?.yoursPercent ?? 50),
|
|
hidden: Boolean(row.hidden),
|
|
});
|
|
};
|
|
|
|
const openComments = async (row: TransactionRow) => {
|
|
setCommentRef(row.viewRef);
|
|
setComments([]);
|
|
setCommentDraft("");
|
|
setCommentStatus("Loading comments...");
|
|
const res = await apiFetch<TransactionComment[]>(`/api/transactions/${row.viewRef}/comments`);
|
|
if (res.error) {
|
|
setCommentStatus(res.error.message ?? "Unable to load comments.");
|
|
return;
|
|
}
|
|
setComments(res.data ?? []);
|
|
setCommentStatus("");
|
|
};
|
|
|
|
const createComment = async () => {
|
|
if (!commentRef || !commentDraft.trim()) return;
|
|
setCommentStatus("Posting comment...");
|
|
const res = await apiFetch<TransactionComment>(`/api/transactions/${commentRef}/comments`, {
|
|
method: "POST",
|
|
body: JSON.stringify({ body: commentDraft }),
|
|
});
|
|
if (res.error) {
|
|
setCommentStatus(res.error.message ?? "Unable to post comment.");
|
|
return;
|
|
}
|
|
if (res.data) setComments((prev) => [...prev, res.data]);
|
|
setCommentDraft("");
|
|
setCommentStatus("");
|
|
await load();
|
|
};
|
|
|
|
const saveEdit = async () => {
|
|
if (!editingRef) return;
|
|
setStatus("Saving edits...");
|
|
const res = await apiFetch<unknown>(`/api/transactions/${editingRef}/derived`, {
|
|
method: "PATCH",
|
|
body: JSON.stringify({
|
|
userCategory: editForm.category || undefined,
|
|
userNotes: editForm.note || undefined,
|
|
attribution: editForm.attribution,
|
|
...splitPayload(editForm.splitMode, editForm.splitMinePercent, editForm.splitYoursPercent),
|
|
isHidden: editForm.hidden,
|
|
}),
|
|
});
|
|
if (res.error) { setStatus(res.error.message ?? "Unable to save edits."); return; }
|
|
setEditingRef(null);
|
|
setStatus("Transaction updated.");
|
|
await load();
|
|
await loadSummary();
|
|
await loadInsights();
|
|
};
|
|
|
|
const inputCls = "mt-2 w-full rounded-md border border-border bg-background/50 px-3 py-2 text-sm text-foreground focus:border-primary focus:ring-primary focus:outline-none";
|
|
const labelCls = "text-xs font-semibold text-muted-foreground uppercase tracking-wider";
|
|
const selectedCommentRow = rows.find((row) => row.viewRef === commentRef) ?? null;
|
|
|
|
return (
|
|
<AppShell title="Transactions" subtitle="View, sync, and categorize your transactions.">
|
|
{/* Action bar */}
|
|
<div className="flex flex-wrap items-center gap-2 text-sm mb-6">
|
|
<span className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-secondary/50 border border-border text-muted-foreground">
|
|
<span className="h-2 w-2 rounded-full bg-primary" />
|
|
{datePreset === "custom" ? "Custom range" : datePreset.replace(/_/g, " ")}
|
|
</span>
|
|
|
|
<button onClick={onSync} className="ml-auto px-3 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-bold hover:bg-primary/90 transition-colors">
|
|
{isSyncing ? "Syncing..." : "Sync"}
|
|
</button>
|
|
<button onClick={() => setAutoSync((prev) => !prev)} className={`px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${autoSync ? "bg-primary/10 border-primary/30 text-primary" : "bg-background border-border text-foreground hover:bg-secondary"}`}>
|
|
Auto {autoSync ? "On" : "Off"}
|
|
</button>
|
|
<button onClick={() => setShowManual((prev) => !prev)} className="px-3 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors">
|
|
{showManual ? "Hide manual" : "Add manual"}
|
|
</button>
|
|
<button onClick={() => { setShowImport((prev) => !prev); setImportStatus(""); }} className="px-3 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors">
|
|
{showImport ? "Hide import" : "Import CSV"}
|
|
</button>
|
|
<Link href={`/exports${buildQuery()}`} className="px-3 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors">
|
|
Export
|
|
</Link>
|
|
<button onClick={() => setShowFilters((prev) => !prev)} className="px-3 py-2 rounded-lg bg-background border border-border text-foreground text-sm font-medium hover:bg-secondary transition-colors">
|
|
{showFilters ? "Hide filters" : "Filters"}
|
|
</button>
|
|
</div>
|
|
|
|
{/* CSV Import panel */}
|
|
{showImport && (
|
|
<div className="mb-6 glass-panel rounded-xl p-5 shadow-sm">
|
|
<p className="text-sm font-bold text-foreground mb-1">Import CSV</p>
|
|
<p className="text-xs text-muted-foreground mb-4">
|
|
Supports Chase, Bank of America, Wells Fargo, and generic CSV formats. Duplicate transactions are skipped automatically.
|
|
</p>
|
|
<div
|
|
className="border-2 border-dashed border-border rounded-xl p-8 text-center cursor-pointer hover:border-primary/50 transition-colors"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
onDragOver={(e) => e.preventDefault()}
|
|
onDrop={(e) => {
|
|
e.preventDefault();
|
|
onPreviewCsv(Array.from(e.dataTransfer.files));
|
|
}}
|
|
>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept=".csv"
|
|
multiple
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
const files = Array.from(e.target.files ?? []);
|
|
if (files.length) onPreviewCsv(files);
|
|
e.currentTarget.value = "";
|
|
}}
|
|
/>
|
|
<svg className="mx-auto h-8 w-8 text-muted-foreground mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
|
</svg>
|
|
{importLoading ? (
|
|
<p className="text-sm text-muted-foreground">Uploading...</p>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground">Drop CSV files here or <span className="text-primary font-medium">click to browse</span></p>
|
|
)}
|
|
</div>
|
|
{csvPreview && (
|
|
<div className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
|
<div className="rounded-xl border border-border bg-background/40 p-4">
|
|
<div className="mb-3 flex items-center justify-between gap-3">
|
|
<p className="text-sm font-semibold text-foreground">Column mapping</p>
|
|
<span className="rounded-full bg-secondary px-2 py-1 text-[11px] font-medium text-muted-foreground">
|
|
{csvPreview.remembered ? "Remembered" : "New"}
|
|
</span>
|
|
</div>
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
{[
|
|
["date", "Date"],
|
|
["description", "Description"],
|
|
["amount", "Amount"],
|
|
["category", "Category"],
|
|
["notes", "Notes"],
|
|
].map(([key, label]) => (
|
|
<label key={key} className={labelCls}>
|
|
{label}
|
|
<select
|
|
value={(csvMapping[key as keyof CsvMapping] as string | undefined) ?? ""}
|
|
onChange={(e) => setCsvMapping((prev) => ({ ...prev, [key]: e.target.value || undefined }))}
|
|
className={inputCls}
|
|
>
|
|
<option value="">Not mapped</option>
|
|
{csvPreview.headers.map((header) => (
|
|
<option key={header} value={header}>{header}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
))}
|
|
<label className={labelCls}>
|
|
Amount sign
|
|
<select
|
|
value={csvMapping.amountMultiplier ?? 1}
|
|
onChange={(e) => setCsvMapping((prev) => ({ ...prev, amountMultiplier: e.target.value === "-1" ? -1 : 1 }))}
|
|
className={inputCls}
|
|
>
|
|
<option value={1}>Keep file values</option>
|
|
<option value={-1}>Flip income/expense signs</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onImportCsv}
|
|
disabled={importLoading}
|
|
className="mt-4 w-full rounded-lg bg-primary px-4 py-2 text-sm font-bold text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-60"
|
|
>
|
|
{importLoading ? "Importing..." : `Import ${csvFiles.length} file${csvFiles.length === 1 ? "" : "s"}`}
|
|
</button>
|
|
</div>
|
|
<div className="overflow-hidden rounded-xl border border-border bg-background/40">
|
|
<div className="border-b border-border px-4 py-3">
|
|
<p className="text-sm font-semibold text-foreground">Columns: {csvPreview.fileName}</p>
|
|
<p className="mt-1 text-xs text-muted-foreground">{csvPreview.rowCount} detected row{csvPreview.rowCount === 1 ? "" : "s"}</p>
|
|
</div>
|
|
<div className="max-h-72 overflow-auto">
|
|
<table className="w-full text-left text-xs">
|
|
<thead className="bg-secondary/30 text-muted-foreground">
|
|
<tr>
|
|
<th className="whitespace-nowrap px-3 py-2 font-semibold">Column</th>
|
|
<th className="whitespace-nowrap px-3 py-2 font-semibold">Mapped as</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{csvPreview.headers.map((header) => {
|
|
const mapped = Object.entries(csvMapping).find(([, value]) => value === header)?.[0];
|
|
return (
|
|
<tr key={header} className="border-t border-border text-foreground">
|
|
<td className="max-w-48 truncate px-3 py-2">{header}</td>
|
|
<td className="px-3 py-2 capitalize text-muted-foreground">{mapped ?? "Not mapped"}</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{importStatus && (
|
|
<p className="mt-3 text-sm text-muted-foreground">{importStatus}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Manual transaction form */}
|
|
{showManual && (
|
|
<div className="mb-6 glass-panel rounded-xl p-5 shadow-sm">
|
|
<p className="text-sm font-bold text-foreground mb-4">Add Manual Transaction</p>
|
|
<form onSubmit={onManualCreate} className="grid gap-3 md:grid-cols-3">
|
|
<div>
|
|
<label className={labelCls}>Date</label>
|
|
<input type="date" value={manualForm.date} onChange={(e) => setManualForm((p) => ({ ...p, date: e.target.value }))} className={inputCls} required />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Description</label>
|
|
<input type="text" value={manualForm.description} onChange={(e) => setManualForm((p) => ({ ...p, description: e.target.value }))} className={inputCls} required />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Amount (negative = expense)</label>
|
|
<input type="number" step="0.01" value={manualForm.amount} onChange={(e) => setManualForm((p) => ({ ...p, amount: e.target.value }))} className={inputCls} required placeholder="-42.50" />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Category</label>
|
|
<input type="text" value={manualForm.category} onChange={(e) => setManualForm((p) => ({ ...p, category: e.target.value }))} className={inputCls} />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Account</label>
|
|
<select value={manualForm.accountRef} onChange={(e) => setManualForm((p) => ({ ...p, accountRef: e.target.value }))} className={inputCls}>
|
|
<option value="">— No account —</option>
|
|
{accounts.map((a) => (
|
|
<option key={a.viewRef} value={a.viewRef}>{a.institutionName} {a.mask ? `••${a.mask}` : ""}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Note</label>
|
|
<input type="text" value={manualForm.note} onChange={(e) => setManualForm((p) => ({ ...p, note: e.target.value }))} className={inputCls} />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Attribution</label>
|
|
<select value={manualForm.attribution} onChange={(e) => setManualForm((p) => ({ ...p, attribution: e.target.value as "mine" | "yours" | "ours" }))} className={inputCls}>
|
|
<option value="mine">Mine</option>
|
|
<option value="yours">Yours</option>
|
|
<option value="ours">Ours</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Split</label>
|
|
<select value={manualForm.splitMode} onChange={(e) => setManualForm((p) => ({ ...p, splitMode: e.target.value as "none" | "equal" | "custom" }))} className={inputCls}>
|
|
<option value="none">No split</option>
|
|
<option value="equal">50/50</option>
|
|
<option value="custom">Custom</option>
|
|
</select>
|
|
</div>
|
|
{manualForm.splitMode === "custom" && (
|
|
<>
|
|
<div>
|
|
<label className={labelCls}>Mine %</label>
|
|
<input type="number" min="0" max="100" step="0.01" value={manualForm.splitMinePercent} onChange={(e) => setManualForm((p) => ({ ...p, splitMinePercent: e.target.value }))} className={inputCls} />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Yours %</label>
|
|
<input type="number" min="0" max="100" step="0.01" value={manualForm.splitYoursPercent} onChange={(e) => setManualForm((p) => ({ ...p, splitYoursPercent: e.target.value }))} className={inputCls} />
|
|
</div>
|
|
</>
|
|
)}
|
|
<div className="md:col-span-3 flex justify-end gap-2">
|
|
<button type="button" onClick={() => setShowManual(false)} className="px-4 py-2 rounded-lg border border-border text-sm text-foreground hover:bg-secondary">Cancel</button>
|
|
<button type="submit" className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-bold hover:bg-primary/90">Save</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
{/* Filters */}
|
|
{showFilters && (
|
|
<div className="mb-6 glass-panel rounded-xl p-5 shadow-sm">
|
|
<div className="grid gap-4 md:grid-cols-3">
|
|
<div>
|
|
<label className={labelCls}>Date range</label>
|
|
<select value={datePreset} onChange={(e) => applyPreset(e.target.value)} className={inputCls}>
|
|
<option value="this_month">This month</option>
|
|
<option value="last_month">Last month</option>
|
|
<option value="last_6_months">Last 6 months</option>
|
|
<option value="last_year">Last year</option>
|
|
<option value="custom">Custom</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Start date</label>
|
|
<input type="date" value={filters.startDate} onChange={(e) => setFilters((p) => ({ ...p, startDate: e.target.value }))} className={inputCls} disabled={datePreset !== "custom"} />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>End date</label>
|
|
<input type="date" value={filters.endDate} onChange={(e) => setFilters((p) => ({ ...p, endDate: e.target.value }))} className={inputCls} disabled={datePreset !== "custom"} />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Search</label>
|
|
<input type="text" value={filters.search} onChange={(e) => setFilters((p) => ({ ...p, search: e.target.value }))} className={inputCls} placeholder="Description..." />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Category</label>
|
|
<input type="text" value={filters.category} onChange={(e) => setFilters((p) => ({ ...p, category: e.target.value }))} className={inputCls} />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Source</label>
|
|
<input type="text" value={filters.source} onChange={(e) => setFilters((p) => ({ ...p, source: e.target.value }))} className={inputCls} placeholder="plaid, manual, csv..." />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Min amount</label>
|
|
<input type="number" step="0.01" value={filters.minAmount} onChange={(e) => setFilters((p) => ({ ...p, minAmount: e.target.value }))} className={inputCls} />
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>Max amount</label>
|
|
<input type="number" step="0.01" value={filters.maxAmount} onChange={(e) => setFilters((p) => ({ ...p, maxAmount: e.target.value }))} className={inputCls} />
|
|
</div>
|
|
<div className="flex items-end gap-2">
|
|
<label className="flex items-center gap-2 text-sm text-foreground cursor-pointer">
|
|
<input type="checkbox" checked={filters.includeHidden} onChange={(e) => setFilters((p) => ({ ...p, includeHidden: e.target.checked }))} className="rounded border-border text-primary focus:ring-primary" />
|
|
Include hidden
|
|
</label>
|
|
</div>
|
|
<div className="flex items-end">
|
|
<button onClick={() => { load(); loadSummary(); loadInsights(); }} className="w-full px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-bold hover:bg-primary/90">Apply</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Summary cards */}
|
|
{summary && (
|
|
<div className="mb-6 grid gap-3 md:grid-cols-3">
|
|
{[
|
|
{ label: "Total", value: `$${Math.abs(Number.parseFloat(summary.total ?? "0")).toFixed(2)}`, sub: `${summary.count} transactions` },
|
|
{ label: "Income", value: `+$${Math.abs(Number.parseFloat(summary.income ?? "0")).toFixed(2)}`, sub: "Credits" },
|
|
{ label: "Expenses", value: `-$${Math.abs(Number.parseFloat(summary.expense ?? "0")).toFixed(2)}`, sub: "Debits" },
|
|
].map((c) => (
|
|
<div key={c.label} className="glass-panel rounded-xl p-4 shadow-sm">
|
|
<p className="text-xs uppercase tracking-[0.2em] text-muted-foreground font-semibold">{c.label}</p>
|
|
<p className="mt-2 text-xl font-bold text-foreground">{c.value}</p>
|
|
<p className="text-xs text-muted-foreground">{c.sub}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{(cashflow.length > 0 || merchants.length > 0) && (
|
|
<div className="mb-6 grid gap-4 lg:grid-cols-2">
|
|
{cashflow.length > 0 && (
|
|
<div className="glass-panel rounded-xl p-4 shadow-sm">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<p className="text-sm font-bold text-foreground">Cashflow</p>
|
|
<span className="text-xs text-muted-foreground">Last 6 months</span>
|
|
</div>
|
|
<div className="space-y-3">
|
|
{cashflow.map((item) => {
|
|
const income = Number.parseFloat(item.income);
|
|
const expense = Number.parseFloat(item.expense);
|
|
const max = Math.max(income, expense, 1);
|
|
return (
|
|
<div key={item.month} className="grid grid-cols-[5rem_1fr_4.5rem] items-center gap-3 text-xs">
|
|
<span className="font-medium text-muted-foreground">{item.month}</span>
|
|
<div className="space-y-1">
|
|
<div className="h-2 rounded-full bg-secondary">
|
|
<div className="h-2 rounded-full bg-primary" style={{ width: `${Math.min((income / max) * 100, 100)}%` }} />
|
|
</div>
|
|
<div className="h-2 rounded-full bg-secondary">
|
|
<div className="h-2 rounded-full bg-foreground/70" style={{ width: `${Math.min((expense / max) * 100, 100)}%` }} />
|
|
</div>
|
|
</div>
|
|
<span className={Number.parseFloat(item.net) >= 0 ? "text-primary font-semibold" : "text-foreground font-semibold"}>
|
|
${Number.parseFloat(item.net).toFixed(0)}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{merchants.length > 0 && (
|
|
<div className="glass-panel rounded-xl p-4 shadow-sm">
|
|
<div className="mb-4 flex items-center justify-between">
|
|
<p className="text-sm font-bold text-foreground">Top merchants</p>
|
|
<span className="text-xs text-muted-foreground">By spend</span>
|
|
</div>
|
|
<div className="space-y-3">
|
|
{merchants.map((merchant) => (
|
|
<div key={merchant.merchant} className="flex items-center justify-between gap-3 rounded-lg border border-border bg-background/40 px-3 py-2">
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm font-semibold text-foreground">{merchant.merchant}</p>
|
|
<p className="text-xs text-muted-foreground">{merchant.count} transactions</p>
|
|
</div>
|
|
<p className="text-sm font-bold text-foreground">${Number.parseFloat(merchant.total).toFixed(2)}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Transaction table */}
|
|
<div className="glass-panel rounded-2xl shadow-sm overflow-hidden">
|
|
{status && (
|
|
<div className="px-6 py-3 bg-secondary/30 border-b border-border text-sm text-muted-foreground">{status}</div>
|
|
)}
|
|
{selectedCommentRow && (
|
|
<div className="border-b border-border bg-secondary/10 p-5">
|
|
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
|
<div>
|
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Transaction chat</p>
|
|
<h2 className="mt-1 text-base font-bold text-foreground">{selectedCommentRow.description ?? selectedCommentRow.name ?? "Transaction"}</h2>
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
|
{new Date(selectedCommentRow.date).toLocaleDateString()} · {formatAmount(selectedCommentRow.amount).display}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setCommentRef(null);
|
|
setComments([]);
|
|
setCommentDraft("");
|
|
setCommentStatus("");
|
|
}}
|
|
className="rounded border border-border px-3 py-1 text-xs font-semibold text-foreground hover:bg-secondary"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
<div className="mt-4 grid gap-4 lg:grid-cols-[1fr_320px]">
|
|
<div className="max-h-64 space-y-3 overflow-y-auto rounded-lg border border-border bg-background/40 p-3">
|
|
{comments.map((comment) => (
|
|
<div key={comment.id} className="rounded-lg border border-border bg-background p-3">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<p className="text-sm font-semibold text-foreground">{comment.author.displayName}</p>
|
|
<p className="text-xs text-muted-foreground">{new Date(comment.createdAt).toLocaleString()}</p>
|
|
</div>
|
|
<p className="mt-2 whitespace-pre-wrap text-sm leading-6 text-muted-foreground">{comment.body}</p>
|
|
</div>
|
|
))}
|
|
{!comments.length && !commentStatus && (
|
|
<p className="text-sm text-muted-foreground">No comments yet.</p>
|
|
)}
|
|
{commentStatus && <p className="text-sm text-muted-foreground">{commentStatus}</p>}
|
|
</div>
|
|
<div>
|
|
<textarea
|
|
value={commentDraft}
|
|
onChange={(event) => setCommentDraft(event.target.value)}
|
|
maxLength={1000}
|
|
placeholder="Add a note for this transaction"
|
|
className="h-32 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:border-primary focus:outline-none"
|
|
/>
|
|
<div className="mt-2 flex items-center justify-between gap-3">
|
|
<span className="text-xs text-muted-foreground">{commentDraft.length}/1000</span>
|
|
<button
|
|
type="button"
|
|
onClick={createComment}
|
|
disabled={!commentDraft.trim()}
|
|
className="rounded bg-primary px-4 py-2 text-xs font-bold text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
|
>
|
|
Post comment
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-xs text-muted-foreground">
|
|
<thead className="text-[0.65rem] uppercase tracking-[0.18em] font-semibold bg-secondary/20">
|
|
<tr>
|
|
<th className="px-4 py-3">Date</th>
|
|
<th className="px-4 py-3">Description</th>
|
|
<th className="px-4 py-3">Category</th>
|
|
<th className="px-4 py-3">Attribution</th>
|
|
<th className="px-4 py-3">Split</th>
|
|
<th className="px-4 py-3 text-right">Amount</th>
|
|
<th className="px-4 py-3 text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((row) =>
|
|
editingRef === row.viewRef ? (
|
|
<tr key={row.viewRef} className="border-b border-border bg-secondary/10">
|
|
<td className="px-4 py-3" colSpan={4}>
|
|
<div className="flex flex-wrap gap-2">
|
|
<input
|
|
type="text"
|
|
value={editForm.category}
|
|
onChange={(e) => setEditForm((p) => ({ ...p, category: e.target.value }))}
|
|
placeholder="Category"
|
|
className="w-28 rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={editForm.note}
|
|
onChange={(e) => setEditForm((p) => ({ ...p, note: e.target.value }))}
|
|
placeholder="Note"
|
|
className="flex-1 rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
|
|
/>
|
|
<select
|
|
value={editForm.attribution}
|
|
onChange={(e) => setEditForm((p) => ({ ...p, attribution: e.target.value as "mine" | "yours" | "ours" }))}
|
|
className="rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
|
|
>
|
|
<option value="mine">Mine</option>
|
|
<option value="yours">Yours</option>
|
|
<option value="ours">Ours</option>
|
|
</select>
|
|
<select
|
|
value={editForm.splitMode}
|
|
onChange={(e) => setEditForm((p) => ({ ...p, splitMode: e.target.value as "none" | "equal" | "custom" }))}
|
|
className="rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
|
|
>
|
|
<option value="none">No split</option>
|
|
<option value="equal">50/50</option>
|
|
<option value="custom">Custom</option>
|
|
</select>
|
|
{editForm.splitMode === "custom" && (
|
|
<>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
max="100"
|
|
step="0.01"
|
|
value={editForm.splitMinePercent}
|
|
onChange={(e) => setEditForm((p) => ({ ...p, splitMinePercent: e.target.value }))}
|
|
placeholder="Mine %"
|
|
className="w-20 rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
|
|
/>
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
max="100"
|
|
step="0.01"
|
|
value={editForm.splitYoursPercent}
|
|
onChange={(e) => setEditForm((p) => ({ ...p, splitYoursPercent: e.target.value }))}
|
|
placeholder="Yours %"
|
|
className="w-20 rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:border-primary focus:outline-none"
|
|
/>
|
|
</>
|
|
)}
|
|
<label className="flex items-center gap-1 text-xs text-foreground">
|
|
<input type="checkbox" checked={editForm.hidden} onChange={(e) => setEditForm((p) => ({ ...p, hidden: e.target.checked }))} />
|
|
Hide
|
|
</label>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3" colSpan={3}>
|
|
<div className="flex gap-2 justify-end">
|
|
<button onClick={saveEdit} className="rounded bg-primary px-3 py-1 text-[11px] font-bold text-primary-foreground hover:bg-primary/90">Save</button>
|
|
<button onClick={() => setEditingRef(null)} className="rounded border border-border px-3 py-1 text-[11px] text-foreground hover:bg-secondary">Cancel</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
<tr key={row.viewRef} className={`border-b border-border hover:bg-secondary/20 transition-colors ${row.hidden ? "opacity-50" : ""}`}>
|
|
<td className="px-4 py-3 font-medium whitespace-nowrap">
|
|
{new Date(row.date).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}
|
|
</td>
|
|
<td className="px-4 py-3 text-foreground font-medium max-w-[200px] truncate">
|
|
{row.description ?? row.name ?? "—"}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
{row.category ? (
|
|
<span className="inline-flex rounded-full bg-secondary px-2 py-0.5 text-xs font-medium text-foreground">{row.category}</span>
|
|
) : (
|
|
<span className="text-muted-foreground">—</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className="inline-flex rounded-full border border-border bg-background px-2 py-0.5 text-xs font-medium capitalize text-foreground">
|
|
{row.attribution ?? "mine"}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className="inline-flex rounded-full border border-border bg-background px-2 py-0.5 text-xs font-medium text-foreground">
|
|
{splitLabel(row)}
|
|
</span>
|
|
</td>
|
|
<td className={`px-4 py-3 text-right font-bold ${formatAmount(row.amount).tone}`}>
|
|
{formatAmount(row.amount).display}
|
|
</td>
|
|
<td className="px-4 py-3 text-right">
|
|
<div className="flex justify-end gap-3">
|
|
<button onClick={() => openComments(row)} className="text-xs text-primary hover:underline">
|
|
Comments{row.commentCount ? ` (${row.commentCount})` : ""}
|
|
</button>
|
|
<button onClick={() => startEdit(row)} className="text-xs text-primary hover:underline">Edit</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)
|
|
)}
|
|
{!rows.length && !status && (
|
|
<tr>
|
|
<td colSpan={7} className="px-4 py-12 text-center text-sm text-muted-foreground">
|
|
No transactions found. Try adjusting your filters or sync your accounts.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</AppShell>
|
|
);
|
|
}
|