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 (
upload.mutate(fs)} /> {upload.isPending &&

Uploading & validating…

} {upload.isError &&

{(upload.error as Error).message}

} {skipped.length > 0 && (

{skipped.length} file(s) skipped as duplicates — nothing was double-counted

    {skipped.map((s) => (
  • {s.filename} — {s.reason}
  • ))}
)}
{!files?.length ? (
No files yet. Drag the month's Amazon transaction files above.
) : ( {files.map((f) => ( ))}
FileSheetSize RowsDatesMkt Status
{f.filename}
{f.message &&
{f.message}
}
{f.data_sheet ?? "—"} {bytes(f.size_bytes)} {f.imported_rows ? int(f.imported_rows) : "—"} {f.min_date ? `${date(f.min_date)}…${date(f.max_date)}` : "—"} {f.marketplace ?? "—"}
)}

Column mapping & duplicates

Headers are auto-matched to the internal schema by normalized name (not position): the raw date/time · settlement id · type · account type · total 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 replaces it; a file whose content is already uploaded (even under another name) is skipped — a month can never count a file twice.

{files?.length ?? 0} file(s) · {hasInvalid ? fix invalid files before processing : "ready"}

{run.isError &&

{(run.error as Error).message}

}
); }