refactor: move sourcing_link and standard_cost from item columns to revision properties
- Add migration 013 to copy sourcing_link/standard_cost values into current revision properties JSONB and drop the columns from items table - Remove SourcingLink/StandardCost from Go Item struct and all DB queries (items.go, audit_queries.go, projects.go) - Remove from API request/response structs and handlers - Update CSV/ODS/BOM export/import to read these from revision properties - Update audit handlers to score as regular property fields - Remove from frontend Item type and hardcoded form fields - MainTab now reads sourcing_link/standard_cost from item.properties - CreateItemPane/EditItemPane no longer have dedicated fields for these; they will be rendered as schema-driven property fields
This commit is contained in:
@@ -16,9 +16,7 @@ export interface Item {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
sourcing_type: string;
|
||||
sourcing_link?: string;
|
||||
long_description?: string;
|
||||
standard_cost?: number;
|
||||
file_count: number;
|
||||
files_total_size: number;
|
||||
properties?: Record<string, unknown>;
|
||||
@@ -170,9 +168,7 @@ export interface CreateItemRequest {
|
||||
projects?: string[];
|
||||
properties?: Record<string, unknown>;
|
||||
sourcing_type?: string;
|
||||
sourcing_link?: string;
|
||||
long_description?: string;
|
||||
standard_cost?: number;
|
||||
}
|
||||
|
||||
export interface UpdateItemRequest {
|
||||
@@ -182,9 +178,7 @@ export interface UpdateItemRequest {
|
||||
properties?: Record<string, unknown>;
|
||||
comment?: string;
|
||||
sourcing_type?: string;
|
||||
sourcing_link?: string;
|
||||
long_description?: string;
|
||||
standard_cost?: number;
|
||||
}
|
||||
|
||||
export interface CreateRevisionRequest {
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { get, put } from "../../api/client";
|
||||
import type {
|
||||
AuditItemResult,
|
||||
AuditFieldResult,
|
||||
Item,
|
||||
} from "../../api/types";
|
||||
import type { AuditItemResult, AuditFieldResult, Item } from "../../api/types";
|
||||
|
||||
const tierColors: Record<string, string> = {
|
||||
critical: "var(--ctp-red)",
|
||||
@@ -18,8 +14,6 @@ const tierColors: Record<string, string> = {
|
||||
const itemFields = new Set([
|
||||
"description",
|
||||
"sourcing_type",
|
||||
"sourcing_link",
|
||||
"standard_cost",
|
||||
"long_description",
|
||||
]);
|
||||
|
||||
@@ -83,12 +77,9 @@ export function AuditDetailPanel({
|
||||
void fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
(key: string, value: string) => {
|
||||
setEdits((prev) => ({ ...prev, [key]: value }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handleFieldChange = useCallback((key: string, value: string) => {
|
||||
setEdits((prev) => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
const saveChanges = useCallback(async () => {
|
||||
if (!item || Object.keys(edits).length === 0) return;
|
||||
@@ -102,18 +93,14 @@ export function AuditDetailPanel({
|
||||
|
||||
for (const [key, value] of Object.entries(edits)) {
|
||||
if (itemFields.has(key)) {
|
||||
if (key === "standard_cost") {
|
||||
const num = parseFloat(value);
|
||||
itemUpdate[key] = isNaN(num) ? undefined : num;
|
||||
} else {
|
||||
itemUpdate[key] = value || undefined;
|
||||
}
|
||||
itemUpdate[key] = value || undefined;
|
||||
} else {
|
||||
// Attempt number coercion for property fields.
|
||||
const num = parseFloat(value);
|
||||
propUpdate[key] = !isNaN(num) && String(num) === value.trim()
|
||||
? num
|
||||
: value || undefined;
|
||||
propUpdate[key] =
|
||||
!isNaN(num) && String(num) === value.trim()
|
||||
? num
|
||||
: value || undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +110,10 @@ export function AuditDetailPanel({
|
||||
const payload: Record<string, unknown> = {
|
||||
...itemUpdate,
|
||||
...(hasProps
|
||||
? { properties: { ...currentProps, ...propUpdate }, comment: "Audit field update" }
|
||||
? {
|
||||
properties: { ...currentProps, ...propUpdate },
|
||||
comment: "Audit field update",
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
@@ -423,9 +413,7 @@ function FieldRow({
|
||||
? String(field.value)
|
||||
: "";
|
||||
|
||||
const borderColor = field.filled
|
||||
? "var(--ctp-green)"
|
||||
: "var(--ctp-red)";
|
||||
const borderColor = field.filled ? "var(--ctp-green)" : "var(--ctp-red)";
|
||||
|
||||
const label = field.key
|
||||
.replace(/_/g, " ")
|
||||
@@ -469,9 +457,7 @@ function FieldRow({
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: "0.8rem",
|
||||
color: field.filled
|
||||
? "var(--ctp-text)"
|
||||
: "var(--ctp-subtext0)",
|
||||
color: field.filled ? "var(--ctp-text)" : "var(--ctp-subtext0)",
|
||||
fontStyle: field.filled ? "normal" : "italic",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -26,9 +26,7 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
const [category, setCategory] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [sourcingType, setSourcingType] = useState("manufactured");
|
||||
const [sourcingLink, setSourcingLink] = useState("");
|
||||
const [longDescription, setLongDescription] = useState("");
|
||||
const [standardCost, setStandardCost] = useState("");
|
||||
const [selectedProjects, setSelectedProjects] = useState<string[]>([]);
|
||||
const [catProps, setCatProps] = useState<Record<string, string>>({});
|
||||
const [catPropDefs, setCatPropDefs] = useState<
|
||||
@@ -173,9 +171,7 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
projects: selectedProjects.length > 0 ? selectedProjects : undefined,
|
||||
properties: Object.keys(properties).length > 0 ? properties : undefined,
|
||||
sourcing_type: sourcingType || undefined,
|
||||
sourcing_link: sourcingLink || undefined,
|
||||
long_description: longDescription || undefined,
|
||||
standard_cost: standardCost ? Number(standardCost) : undefined,
|
||||
});
|
||||
|
||||
const pn = result.part_number;
|
||||
@@ -309,26 +305,6 @@ export function CreateItemPane({ onCreated, onCancel }: CreateItemPaneProps) {
|
||||
<option value="purchased">Purchased</option>
|
||||
</select>
|
||||
</FormGroup>
|
||||
<FormGroup label="Standard Cost">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={standardCost}
|
||||
onChange={(e) => setStandardCost(e.target.value)}
|
||||
style={inputStyle}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</FormGroup>
|
||||
<div style={{ gridColumn: "1 / -1" }}>
|
||||
<FormGroup label="Sourcing Link">
|
||||
<input
|
||||
value={sourcingLink}
|
||||
onChange={(e) => setSourcingLink(e.target.value)}
|
||||
style={inputStyle}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details section */}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { get, put } from '../../api/client';
|
||||
import type { Item } from '../../api/types';
|
||||
import { useState, useEffect } from "react";
|
||||
import { get, put } from "../../api/client";
|
||||
import type { Item } from "../../api/types";
|
||||
|
||||
interface EditItemPaneProps {
|
||||
partNumber: string;
|
||||
@@ -8,17 +8,19 @@ interface EditItemPaneProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function EditItemPane({ partNumber, onSaved, onCancel }: EditItemPaneProps) {
|
||||
export function EditItemPane({
|
||||
partNumber,
|
||||
onSaved,
|
||||
onCancel,
|
||||
}: EditItemPaneProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pn, setPN] = useState('');
|
||||
const [itemType, setItemType] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [sourcingType, setSourcingType] = useState('');
|
||||
const [sourcingLink, setSourcingLink] = useState('');
|
||||
const [longDescription, setLongDescription] = useState('');
|
||||
const [standardCost, setStandardCost] = useState('');
|
||||
const [pn, setPN] = useState("");
|
||||
const [itemType, setItemType] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [sourcingType, setSourcingType] = useState("");
|
||||
const [longDescription, setLongDescription] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
@@ -27,12 +29,10 @@ export function EditItemPane({ partNumber, onSaved, onCancel }: EditItemPaneProp
|
||||
setPN(item.part_number);
|
||||
setItemType(item.item_type);
|
||||
setDescription(item.description);
|
||||
setSourcingType(item.sourcing_type ?? '');
|
||||
setSourcingLink(item.sourcing_link ?? '');
|
||||
setLongDescription(item.long_description ?? '');
|
||||
setStandardCost(item.standard_cost != null ? String(item.standard_cost) : '');
|
||||
setSourcingType(item.sourcing_type ?? "");
|
||||
setLongDescription(item.long_description ?? "");
|
||||
})
|
||||
.catch(() => setError('Failed to load item'))
|
||||
.catch(() => setError("Failed to load item"))
|
||||
.finally(() => setLoading(false));
|
||||
}, [partNumber]);
|
||||
|
||||
@@ -45,54 +45,97 @@ export function EditItemPane({ partNumber, onSaved, onCancel }: EditItemPaneProp
|
||||
item_type: itemType || undefined,
|
||||
description: description || undefined,
|
||||
sourcing_type: sourcingType || undefined,
|
||||
sourcing_link: sourcingLink || undefined,
|
||||
long_description: longDescription || undefined,
|
||||
standard_cost: standardCost ? Number(standardCost) : undefined,
|
||||
});
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to save item');
|
||||
setError(e instanceof Error ? e.message : "Failed to save item");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div style={{ padding: '1rem', color: 'var(--ctp-subtext0)' }}>Loading...</div>;
|
||||
if (loading)
|
||||
return (
|
||||
<div style={{ padding: "1rem", color: "var(--ctp-subtext0)" }}>
|
||||
Loading...
|
||||
</div>
|
||||
);
|
||||
|
||||
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-blue)', fontWeight: 600, fontSize: '0.9rem' }}>Edit {partNumber}</span>
|
||||
<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-blue)",
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9rem",
|
||||
}}
|
||||
>
|
||||
Edit {partNumber}
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button onClick={() => void handleSave()} disabled={saving} style={{
|
||||
padding: '0.3rem 0.75rem', fontSize: '0.8rem', border: 'none', borderRadius: '0.3rem',
|
||||
backgroundColor: 'var(--ctp-blue)', color: 'var(--ctp-crust)', cursor: 'pointer',
|
||||
opacity: saving ? 0.6 : 1,
|
||||
}}>
|
||||
{saving ? 'Saving...' : 'Save'}
|
||||
<button
|
||||
onClick={() => void handleSave()}
|
||||
disabled={saving}
|
||||
style={{
|
||||
padding: "0.3rem 0.75rem",
|
||||
fontSize: "0.8rem",
|
||||
border: "none",
|
||||
borderRadius: "0.3rem",
|
||||
backgroundColor: "var(--ctp-blue)",
|
||||
color: "var(--ctp-crust)",
|
||||
cursor: "pointer",
|
||||
opacity: saving ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
<button onClick={onCancel} style={headerBtnStyle}>
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={onCancel} style={headerBtnStyle}>Cancel</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '0.75rem' }}>
|
||||
<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.3rem', marginBottom: '0.5rem', fontSize: '0.85rem' }}>
|
||||
<div
|
||||
style={{
|
||||
color: "var(--ctp-red)",
|
||||
backgroundColor: "rgba(243,139,168,0.1)",
|
||||
padding: "0.5rem",
|
||||
borderRadius: "0.3rem",
|
||||
marginBottom: "0.5rem",
|
||||
fontSize: "0.85rem",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormGroup label="Part Number">
|
||||
<input value={pn} onChange={(e) => setPN(e.target.value)} style={inputStyle} />
|
||||
<input
|
||||
value={pn}
|
||||
onChange={(e) => setPN(e.target.value)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="Type">
|
||||
<select value={itemType} onChange={(e) => setItemType(e.target.value)} style={inputStyle}>
|
||||
<select
|
||||
value={itemType}
|
||||
onChange={(e) => setItemType(e.target.value)}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="part">Part</option>
|
||||
<option value="assembly">Assembly</option>
|
||||
<option value="document">Document</option>
|
||||
@@ -101,11 +144,19 @@ export function EditItemPane({ partNumber, onSaved, onCancel }: EditItemPaneProp
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="Description">
|
||||
<input value={description} onChange={(e) => setDescription(e.target.value)} style={inputStyle} />
|
||||
<input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="Sourcing Type">
|
||||
<select value={sourcingType} onChange={(e) => setSourcingType(e.target.value)} style={inputStyle}>
|
||||
<select
|
||||
value={sourcingType}
|
||||
onChange={(e) => setSourcingType(e.target.value)}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="">—</option>
|
||||
<option value="manufactured">Manufactured</option>
|
||||
<option value="purchased">Purchased</option>
|
||||
@@ -113,38 +164,57 @@ export function EditItemPane({ partNumber, onSaved, onCancel }: EditItemPaneProp
|
||||
</select>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="Sourcing Link">
|
||||
<input value={sourcingLink} onChange={(e) => setSourcingLink(e.target.value)} style={inputStyle} placeholder="URL" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="Standard Cost">
|
||||
<input type="number" step="0.01" value={standardCost} onChange={(e) => setStandardCost(e.target.value)} style={inputStyle} placeholder="0.00" />
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup label="Long Description">
|
||||
<textarea value={longDescription} onChange={(e) => setLongDescription(e.target.value)} style={{ ...inputStyle, minHeight: 80, resize: 'vertical' }} />
|
||||
<textarea
|
||||
value={longDescription}
|
||||
onChange={(e) => setLongDescription(e.target.value)}
|
||||
style={{ ...inputStyle, minHeight: 80, resize: "vertical" }}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FormGroup({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
function FormGroup({
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ marginBottom: '0.6rem' }}>
|
||||
<label style={{ display: 'block', fontSize: '0.75rem', color: 'var(--ctp-subtext0)', marginBottom: '0.2rem' }}>{label}</label>
|
||||
<div style={{ marginBottom: "0.6rem" }}>
|
||||
<label
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: "0.75rem",
|
||||
color: "var(--ctp-subtext0)",
|
||||
marginBottom: "0.2rem",
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
width: '100%', padding: '0.35rem 0.5rem', fontSize: '0.85rem',
|
||||
backgroundColor: 'var(--ctp-base)', border: '1px solid var(--ctp-surface1)',
|
||||
borderRadius: '0.3rem', color: 'var(--ctp-text)',
|
||||
width: "100%",
|
||||
padding: "0.35rem 0.5rem",
|
||||
fontSize: "0.85rem",
|
||||
backgroundColor: "var(--ctp-base)",
|
||||
border: "1px solid var(--ctp-surface1)",
|
||||
borderRadius: "0.3rem",
|
||||
color: "var(--ctp-text)",
|
||||
};
|
||||
|
||||
const headerBtnStyle: React.CSSProperties = {
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--ctp-subtext1)', fontSize: '0.8rem', padding: '0.2rem 0.4rem',
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--ctp-subtext1)",
|
||||
fontSize: "0.8rem",
|
||||
padding: "0.2rem 0.4rem",
|
||||
};
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import type { CSVImportResult } from '../../api/types';
|
||||
import { useState, useRef } from "react";
|
||||
import type { CSVImportResult } from "../../api/types";
|
||||
|
||||
interface ImportItemsPaneProps {
|
||||
onImported: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ImportItemsPane({ onImported, onCancel }: ImportItemsPaneProps) {
|
||||
export function ImportItemsPane({
|
||||
onImported,
|
||||
onCancel,
|
||||
}: ImportItemsPaneProps) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [skipExisting, setSkipExisting] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
@@ -21,19 +24,22 @@ export function ImportItemsPane({ onImported, onCancel }: ImportItemsPaneProps)
|
||||
setError(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (dryRun) formData.append('dry_run', 'true');
|
||||
if (skipExisting) formData.append('skip_existing', 'true');
|
||||
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',
|
||||
const res = await fetch("/api/items/import", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
body: formData,
|
||||
});
|
||||
const data = await res.json() as CSVImportResult;
|
||||
const data = (await res.json()) as CSVImportResult;
|
||||
if (!res.ok) {
|
||||
setError((data as unknown as { message?: string }).message ?? `HTTP ${res.status}`);
|
||||
setError(
|
||||
(data as unknown as { message?: string }).message ??
|
||||
`HTTP ${res.status}`,
|
||||
);
|
||||
} else {
|
||||
setResult(data);
|
||||
if (dryRun) {
|
||||
@@ -43,48 +49,85 @@ export function ImportItemsPane({ onImported, onCancel }: ImportItemsPaneProps)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Import failed');
|
||||
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: '0.9rem' }}>Import Items (CSV)</span>
|
||||
<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: "0.9rem",
|
||||
}}
|
||||
>
|
||||
Import Items (CSV)
|
||||
</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button onClick={onCancel} style={headerBtnStyle}>Cancel</button>
|
||||
<button onClick={onCancel} style={headerBtnStyle}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: '0.75rem' }}>
|
||||
<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.3rem', marginBottom: '0.5rem', fontSize: '0.85rem' }}>
|
||||
<div
|
||||
style={{
|
||||
color: "var(--ctp-red)",
|
||||
backgroundColor: "rgba(243,139,168,0.1)",
|
||||
padding: "0.5rem",
|
||||
borderRadius: "0.3rem",
|
||||
marginBottom: "0.5rem",
|
||||
fontSize: "0.85rem",
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
<div style={{ fontSize: '0.8rem', 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, sourcing_link, long_description, standard_cost, + property columns</p>
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.8rem",
|
||||
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: '0.8rem' }}
|
||||
style={{ color: "var(--ctp-sapphire)", fontSize: "0.8rem" }}
|
||||
>
|
||||
Download CSV template
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* File input */}
|
||||
<div style={{ marginBottom: '0.75rem' }}>
|
||||
<div style={{ marginBottom: "0.75rem" }}>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
@@ -94,76 +137,144 @@ export function ImportItemsPane({ onImported, onCancel }: ImportItemsPaneProps)
|
||||
setResult(null);
|
||||
setValidated(false);
|
||||
}}
|
||||
style={{ display: 'none' }}
|
||||
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: '0.85rem',
|
||||
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: "0.85rem",
|
||||
}}
|
||||
>
|
||||
{file ? file.name : 'Choose CSV file...'}
|
||||
{file ? file.name : "Choose CSV file..."}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', fontSize: '0.85rem', color: 'var(--ctp-subtext1)', marginBottom: '0.75rem' }}>
|
||||
<input type="checkbox" checked={skipExisting} onChange={(e) => setSkipExisting(e.target.checked)} />
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.4rem",
|
||||
fontSize: "0.85rem",
|
||||
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' }}>
|
||||
<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.85rem', border: 'none', borderRadius: '0.3rem',
|
||||
backgroundColor: 'var(--ctp-yellow)', color: 'var(--ctp-crust)', cursor: 'pointer',
|
||||
opacity: (!file || importing) ? 0.5 : 1,
|
||||
padding: "0.4rem 0.75rem",
|
||||
fontSize: "0.85rem",
|
||||
border: "none",
|
||||
borderRadius: "0.3rem",
|
||||
backgroundColor: "var(--ctp-yellow)",
|
||||
color: "var(--ctp-crust)",
|
||||
cursor: "pointer",
|
||||
opacity: !file || importing ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{importing ? 'Validating...' : 'Validate (Dry Run)'}
|
||||
{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.85rem', border: 'none', borderRadius: '0.3rem',
|
||||
backgroundColor: 'var(--ctp-green)', color: 'var(--ctp-crust)', cursor: 'pointer',
|
||||
opacity: (importing || (result?.error_count ?? 0) > 0) ? 0.5 : 1,
|
||||
padding: "0.4rem 0.75rem",
|
||||
fontSize: "0.85rem",
|
||||
border: "none",
|
||||
borderRadius: "0.3rem",
|
||||
backgroundColor: "var(--ctp-green)",
|
||||
color: "var(--ctp-crust)",
|
||||
cursor: "pointer",
|
||||
opacity: importing || (result?.error_count ?? 0) > 0 ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{importing ? 'Importing...' : 'Import Now'}
|
||||
{importing ? "Importing..." : "Import Now"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<div style={{ padding: '0.5rem', backgroundColor: 'var(--ctp-surface0)', borderRadius: '0.4rem', fontSize: '0.8rem' }}>
|
||||
<p>Total rows: <strong>{result.total_rows}</strong></p>
|
||||
<p>Success: <strong style={{ color: 'var(--ctp-green)' }}>{result.success_count}</strong></p>
|
||||
<div
|
||||
style={{
|
||||
padding: "0.5rem",
|
||||
backgroundColor: "var(--ctp-surface0)",
|
||||
borderRadius: "0.4rem",
|
||||
fontSize: "0.8rem",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<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' }}>
|
||||
<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.1rem 0' }}>
|
||||
Row {err.row}{err.field ? ` [${err.field}]` : ''}: {err.message}
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
color: "var(--ctp-red)",
|
||||
fontSize: "0.75rem",
|
||||
padding: "0.1rem 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
|
||||
style={{
|
||||
marginTop: "0.5rem",
|
||||
color: "var(--ctp-green)",
|
||||
fontSize: "0.75rem",
|
||||
}}
|
||||
>
|
||||
Created: {result.created_items.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -174,6 +285,10 @@ export function ImportItemsPane({ onImported, onCancel }: ImportItemsPaneProps)
|
||||
}
|
||||
|
||||
const headerBtnStyle: React.CSSProperties = {
|
||||
background: 'none', border: 'none', cursor: 'pointer',
|
||||
color: 'var(--ctp-subtext1)', fontSize: '0.8rem', padding: '0.2rem 0.4rem',
|
||||
background: "none",
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
color: "var(--ctp-subtext1)",
|
||||
fontSize: "0.8rem",
|
||||
padding: "0.2rem 0.4rem",
|
||||
};
|
||||
|
||||
@@ -110,15 +110,19 @@ export function MainTab({ item, onReload, isEditor }: MainTabProps) {
|
||||
{row("Description", item.description)}
|
||||
{row("Type", item.item_type)}
|
||||
{row("Sourcing", item.sourcing_type || "—")}
|
||||
{item.sourcing_link &&
|
||||
{item.properties?.sourcing_link != null &&
|
||||
row(
|
||||
"Source Link",
|
||||
<a href={item.sourcing_link} target="_blank" rel="noreferrer">
|
||||
{item.sourcing_link}
|
||||
<a
|
||||
href={String(item.properties.sourcing_link)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{String(item.properties.sourcing_link)}
|
||||
</a>,
|
||||
)}
|
||||
{item.standard_cost != null &&
|
||||
row("Std Cost", `$${item.standard_cost.toFixed(2)}`)}
|
||||
{item.properties?.standard_cost != null &&
|
||||
row("Std Cost", `$${Number(item.properties.standard_cost).toFixed(2)}`)}
|
||||
{row("Revision", `Rev ${item.current_revision}`)}
|
||||
{row("Created", formatDate(item.created_at))}
|
||||
{row("Updated", formatDate(item.updated_at))}
|
||||
|
||||
Reference in New Issue
Block a user