Files
silo/web/src/components/items/ImportItemsPane.tsx
Forbes 07c4aa1c28 fix(web): align spacing values to style guide grid (#71)
- Replace 0.3rem padding/margin/gap with 0.25rem (xs)
- Replace 0.2rem margins with 0.25rem (xs)
- Replace 0.1rem padding with 0.15rem (badge spec)
- Replace 0.6rem margins/padding with 0.5rem (sm)
- Fix borderRadius 0.3rem to 0.375rem (6px per style guide)
- Preserve style-guide-specified values: 0.35rem button gap, 0.4rem cell padding, 0.45rem input padding
2026-02-13 14:37:40 -06:00

302 lines
8.7 KiB
TypeScript

import { useState, useRef } from "react";
import type { CSVImportResult } from "../../api/types";
interface ImportItemsPaneProps {
onImported: () => void;
onCancel: () => void;
}
export function ImportItemsPane({
onImported,
onCancel,
}: ImportItemsPaneProps) {
const [file, setFile] = useState<File | null>(null);
const [skipExisting, setSkipExisting] = useState(false);
const [importing, setImporting] = useState(false);
const [result, setResult] = useState<CSVImportResult | null>(null);
const [validated, setValidated] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
const doImport = async (dryRun: boolean) => {
if (!file) return;
setImporting(true);
setError(null);
const formData = new FormData();
formData.append("file", file);
if (dryRun) formData.append("dry_run", "true");
if (skipExisting) formData.append("skip_existing", "true");
try {
const res = await fetch("/api/items/import", {
method: "POST",
credentials: "include",
body: formData,
});
const data = (await res.json()) as CSVImportResult;
if (!res.ok) {
setError(
(data as unknown as { message?: string }).message ??
`HTTP ${res.status}`,
);
} else {
setResult(data);
if (dryRun) {
setValidated(true);
} else {
onImported();
}
}
} catch (e) {
setError(e instanceof Error ? e.message : "Import failed");
} finally {
setImporting(false);
}
};
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
padding: "0.5rem 0.75rem",
borderBottom: "1px solid var(--ctp-surface1)",
backgroundColor: "var(--ctp-mantle)",
flexShrink: 0,
}}
>
<span
style={{
color: "var(--ctp-yellow)",
fontWeight: 600,
fontSize: "var(--font-body)",
}}
>
Import Items (CSV)
</span>
<span style={{ flex: 1 }} />
<button onClick={onCancel} style={headerBtnStyle}>
Cancel
</button>
</div>
<div style={{ flex: 1, overflow: "auto", padding: "0.75rem" }}>
{error && (
<div
style={{
color: "var(--ctp-red)",
backgroundColor: "rgba(243,139,168,0.1)",
padding: "0.5rem",
borderRadius: "0.375rem",
marginBottom: "0.5rem",
fontSize: "var(--font-body)",
}}
>
{error}
</div>
)}
{/* Instructions */}
<div
style={{
fontSize: "var(--font-table)",
color: "var(--ctp-subtext0)",
marginBottom: "0.75rem",
}}
>
<p style={{ marginBottom: "0.25rem" }}>
Upload a CSV file with items to import.
</p>
<p>
Required column:{" "}
<strong style={{ color: "var(--ctp-text)" }}>category</strong>
</p>
<p>
Optional: description, projects, sourcing_type, long_description, +
property columns (including sourcing_link, standard_cost)
</p>
<a
href="/api/items/template.csv"
style={{
color: "var(--ctp-sapphire)",
fontSize: "var(--font-table)",
}}
>
Download CSV template
</a>
</div>
{/* File input */}
<div style={{ marginBottom: "0.75rem" }}>
<input
ref={fileRef}
type="file"
accept=".csv"
onChange={(e) => {
setFile(e.target.files?.[0] ?? null);
setResult(null);
setValidated(false);
}}
style={{ display: "none" }}
/>
<button
onClick={() => fileRef.current?.click()}
style={{
padding: "0.75rem 1.5rem",
border: "2px dashed var(--ctp-surface2)",
borderRadius: "0.5rem",
backgroundColor: "var(--ctp-surface0)",
color: "var(--ctp-subtext1)",
cursor: "pointer",
width: "100%",
fontSize: "var(--font-body)",
}}
>
{file ? file.name : "Choose CSV file..."}
</button>
</div>
{/* Options */}
<label
style={{
display: "flex",
alignItems: "center",
gap: "0.4rem",
fontSize: "var(--font-body)",
color: "var(--ctp-subtext1)",
marginBottom: "0.75rem",
}}
>
<input
type="checkbox"
checked={skipExisting}
onChange={(e) => setSkipExisting(e.target.checked)}
/>
Skip existing items
</label>
{/* Actions */}
<div
style={{ display: "flex", gap: "0.5rem", marginBottom: "0.75rem" }}
>
{!validated ? (
<button
onClick={() => void doImport(true)}
disabled={!file || importing}
style={{
padding: "0.4rem 0.75rem",
fontSize: "0.75rem",
fontWeight: 500,
border: "none",
borderRadius: "0.375rem",
backgroundColor: "var(--ctp-yellow)",
color: "var(--ctp-crust)",
cursor: "pointer",
opacity: !file || importing ? 0.5 : 1,
}}
>
{importing ? "Validating..." : "Validate (Dry Run)"}
</button>
) : (
<button
onClick={() => void doImport(false)}
disabled={importing || (result?.error_count ?? 0) > 0}
style={{
padding: "0.4rem 0.75rem",
fontSize: "0.75rem",
fontWeight: 500,
border: "none",
borderRadius: "0.375rem",
backgroundColor: "var(--ctp-green)",
color: "var(--ctp-crust)",
cursor: "pointer",
opacity: importing || (result?.error_count ?? 0) > 0 ? 0.5 : 1,
}}
>
{importing ? "Importing..." : "Import Now"}
</button>
)}
</div>
{/* Results */}
{result && (
<div
style={{
padding: "0.5rem",
backgroundColor: "var(--ctp-surface0)",
borderRadius: "0.4rem",
fontSize: "var(--font-table)",
}}
>
<p>
Total rows: <strong>{result.total_rows}</strong>
</p>
<p>
Success:{" "}
<strong style={{ color: "var(--ctp-green)" }}>
{result.success_count}
</strong>
</p>
{result.error_count > 0 && (
<p>
Errors:{" "}
<strong style={{ color: "var(--ctp-red)" }}>
{result.error_count}
</strong>
</p>
)}
{result.errors && result.errors.length > 0 && (
<div
style={{
marginTop: "0.5rem",
maxHeight: 200,
overflow: "auto",
}}
>
{result.errors.map((err, i) => (
<div
key={i}
style={{
color: "var(--ctp-red)",
fontSize: "0.75rem",
padding: "0.15rem 0",
}}
>
Row {err.row}
{err.field ? ` [${err.field}]` : ""}: {err.message}
</div>
))}
</div>
)}
{result.created_items && result.created_items.length > 0 && (
<div
style={{
marginTop: "0.5rem",
color: "var(--ctp-green)",
fontSize: "0.75rem",
}}
>
Created: {result.created_items.join(", ")}
</div>
)}
</div>
)}
</div>
</div>
);
}
const headerBtnStyle: React.CSSProperties = {
background: "none",
border: "none",
cursor: "pointer",
color: "var(--ctp-subtext1)",
fontSize: "0.75rem",
fontWeight: 500,
padding: "0.25rem 0.4rem",
borderRadius: "0.375rem",
};