/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:
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user