108 lines
5.7 KiB
TypeScript
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>
|
|
);
|
|
}
|