Add AssetItem CSV import/export
CI / test (push) Failing after 8s

/assets gets Export CSV (GET /api/assets/export) and Import CSV
(POST /api/assets/import) buttons. Export emits human-readable
manufacturer/model columns instead of raw modelId; import resolves those
back to an AssetModel (case-insensitive match, must already exist -- this
does not auto-create models) so export -> edit in Excel -> re-import is a
real round trip.

Per-row import errors (unmatched model, missing required field, bad date)
are collected and returned alongside the created count rather than
aborting the whole file on the first bad row -- a typo in row 200 should
not cost rows 1-199. warrantyEndDate still auto-fills from the models
warrantyMonths when the CSV omits it, same as manual creation.

Extracted src/lib/csv.ts (splitCsvLine/splitCsvLines/normalizeHeader/
csvField) out of expected-assets.ts so both CSV parsers share one
implementation instead of duplicating it.

Manual smoke test only (tsx script: export shape, valid row + unmatched-
model row + missing-date row all handled correctly), no Vitest coverage,
not through the ba->pm->feature-implementer->test-runner->code-reviewer
pipeline -- same deviation as the rest of this branch, see CLAUDE.md.
tsc/lint clean.
This commit is contained in:
toanhoang
2026-08-23 07:41:52 -04:00
parent d9b360c1df
commit aa7f5515db
7 changed files with 429 additions and 43 deletions
+87
View File
@@ -77,6 +77,50 @@ export default function AssetsPage() {
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
const [importStatus, setImportStatus] = useState<"idle" | "uploading" | "done" | "error">(
"idle"
);
const [importMessage, setImportMessage] = useState<string | null>(null);
async function handleImportChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0];
event.target.value = ""; // allow re-selecting the same file name after an error
if (!file) return;
setImportStatus("uploading");
setImportMessage(null);
try {
const text = await file.text();
const res = await fetch("/api/assets/import", {
method: "POST",
headers: { "Content-Type": "text/csv" },
body: text,
});
const body = await res.json();
if (!res.ok) {
setImportStatus("error");
setImportMessage(body.error ?? "Import thất bại");
return;
}
const errorCount = (body.errors as unknown[] | undefined)?.length ?? 0;
setImportStatus(errorCount > 0 ? "error" : "done");
const errorSummary =
errorCount > 0
? " Lỗi: " +
(body.errors as { row: number; serialNumber: string; error: string }[])
.slice(0, 5)
.map((e) => `dòng ${e.row} (${e.serialNumber || "?"}): ${e.error}`)
.join("; ") +
(errorCount > 5 ? ` (+${errorCount - 5} lỗi khác)` : "")
: "";
setImportMessage(`Đã import ${body.created} asset.` + errorSummary);
refetch();
} catch {
setImportStatus("error");
setImportMessage("Không đọc được file -- kiểm tra lại định dạng CSV.");
}
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
@@ -174,6 +218,49 @@ export default function AssetsPage() {
)}
</section>
<section className="flex max-w-2xl flex-col gap-3">
<h2 className="text-lg font-semibold">Import / Export CSV</h2>
<p className="text-sm text-muted-foreground">
Cột nhận diện theo tên (không phân biệt hoa/thường): <code>serial number</code>,{" "}
<code>manufacturer</code>, <code>model</code> (phải khớp Asset Model đã có sẵn ở{" "}
<a href="/asset-models" className="underline">
/asset-models
</a>
), <code>purchase date</code> (bắt buộc), <code>warranty end date</code>,{" "}
<code>purchase order id</code> (tuỳ chọn -- bỏ trống thì tự tính từ warranty của model).
</p>
<div className="flex flex-wrap items-center gap-3">
<a href="/api/assets/export">
<Button type="button" variant="outline">
Export CSV
</Button>
</a>
<input
type="file"
accept=".csv,text/csv"
onChange={handleImportChange}
disabled={importStatus === "uploading"}
className="hidden"
id="assets-csv-input"
/>
<Button asChild>
<label htmlFor="assets-csv-input" className="cursor-pointer">
{importStatus === "uploading" ? "Đang import..." : "Import CSV"}
</label>
</Button>
</div>
{importMessage ? (
<p
className={
importStatus === "error" ? "text-sm text-destructive" : "text-sm text-foreground"
}
>
{importMessage}
</p>
) : null}
</section>
<section className="flex max-w-sm flex-col gap-4">
<h2 className="text-lg font-semibold">New Asset</h2>