- 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
302 lines
8.7 KiB
TypeScript
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",
|
|
};
|