Finance-Accounts/ar-aging-app/frontend/src/pages/closing/Upload.tsx

108 lines
5.7 KiB
TypeScript

import { useNavigate } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Trash2, Play, FileSpreadsheet, CopyX } from "lucide-react";
import { api } from "../../api/client";
import { bytes, date, int } from "../../lib/format";
import { FileDrop, Section, StatusBadge, Spinner } from "../../components/ui";
import HeaderMapping from "../../components/HeaderMapping";
import { useClosing } from "../Closing";
export default function Upload() {
const { id, session, locked } = useClosing();
const qc = useQueryClient();
const nav = useNavigate();
const busy = session.status === "processing" || session.status === "exporting";
const { data: files } = useQuery({ queryKey: ["files", id], queryFn: () => api.listFiles(id) });
const invalidate = () => {
qc.invalidateQueries({ queryKey: ["files", id] });
qc.invalidateQueries({ queryKey: ["session", id] });
};
const upload = useMutation({ mutationFn: (fs: File[]) => api.uploadFiles(id, fs), onSuccess: invalidate });
const remove = useMutation({ mutationFn: (fid: number) => api.deleteFile(id, fid), onSuccess: invalidate });
const run = useMutation({
mutationFn: () => api.process(id),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["session", id] }); nav(`/closing/${id}`); },
});
const skipped = upload.data?.skipped ?? [];
const hasInvalid = files?.some((f) => f.status === "invalid");
const dates = (files ?? []).flatMap((f) => [f.min_date, f.max_date]).filter(Boolean) as string[];
const cover = dates.length ? `${dates.reduce((a, b) => (a < b ? a : b))}${dates.reduce((a, b) => (a > b ? a : b))}` : "—";
return (
<div className="space-y-6">
<FileDrop disabled={busy || locked || upload.isPending} onFiles={(fs) => upload.mutate(fs)} />
{upload.isPending && <p className="text-sm text-subink flex items-center gap-2"><Spinner /> Uploading & validating</p>}
{upload.isError && <p className="text-sm text-bad">{(upload.error as Error).message}</p>}
{skipped.length > 0 && (
<div className="card border-warn/30 bg-warnbg/40 p-3 text-sm space-y-1">
<p className="font-semibold text-ink flex items-center gap-2">
<CopyX size={15} className="text-warn" />
{skipped.length} file(s) skipped as duplicates nothing was double-counted
</p>
<ul className="text-xs text-subink pl-6 list-disc">
{skipped.map((s) => (
<li key={s.filename}><b>{s.filename}</b> {s.reason}</li>
))}
</ul>
</div>
)}
<Section title="Uploaded files" subtitle={`Detected date coverage: ${cover}`}>
{!files?.length ? (
<div className="p-6 text-sm text-subink">No files yet. Drag the month's Amazon transaction files above.</div>
) : (
<table className="w-full">
<thead><tr>
<th className="th">File</th><th className="th">Sheet</th><th className="th text-right">Size</th>
<th className="th text-right">Rows</th><th className="th">Dates</th><th className="th">Mkt</th>
<th className="th">Status</th><th className="th"></th>
</tr></thead>
<tbody>
{files.map((f) => (
<tr key={f.id}>
<td className="td font-medium"><div className="flex items-center gap-2"><FileSpreadsheet size={15} className="text-primary" />{f.filename}</div>
{f.message && <div className="text-xs text-bad mt-0.5">{f.message}</div>}</td>
<td className="td text-xs text-subink">{f.data_sheet ?? "—"}</td>
<td className="td text-right num">{bytes(f.size_bytes)}</td>
<td className="td text-right num">{f.imported_rows ? int(f.imported_rows) : "—"}</td>
<td className="td num text-xs">{f.min_date ? `${date(f.min_date)}${date(f.max_date)}` : "—"}</td>
<td className="td text-xs">{f.marketplace ?? "—"}</td>
<td className="td"><StatusBadge status={f.status} /></td>
<td className="td text-right">
<button className="p-1.5 rounded hover:bg-badbg text-subink hover:text-bad" disabled={busy || locked}
onClick={() => remove.mutate(f.id)}><Trash2 size={15} /></button>
</td>
</tr>
))}
</tbody>
</table>
)}
</Section>
<div className="card p-4 text-xs text-subink">
<p className="font-semibold text-ink mb-1">Column mapping & duplicates</p>
Headers are auto-matched to the internal schema by normalized name (not position): the raw
<span className="num"> date/time · settlement id · type · account type · total </span> columns are
required. The pivot sheet in each file is ignored automatically. Files failing validation are flagged above.
Re-uploading a file with the same name <b>replaces</b> it; a file whose content is already
uploaded (even under another name) is skipped a month can never count a file twice.
</div>
<div className="flex items-center justify-between">
<p className="text-sm text-subink">{files?.length ?? 0} file(s) · {hasInvalid ? <span className="text-bad">fix invalid files before processing</span> : "ready"}</p>
<button className="btn-primary" disabled={!files?.length || hasInvalid || busy || locked || run.isPending}
onClick={() => run.mutate()}>
<Play size={16} /> {run.isPending ? "Starting…" : "Run processing"}
</button>
</div>
{run.isError && <p className="text-sm text-bad">{(run.error as Error).message}</p>}
<HeaderMapping id={id} />
</div>
);
}