diff --git a/web/src/components/audit/AuditDetailPanel.tsx b/web/src/components/audit/AuditDetailPanel.tsx new file mode 100644 index 0000000..8f35940 --- /dev/null +++ b/web/src/components/audit/AuditDetailPanel.tsx @@ -0,0 +1,516 @@ +import { useEffect, useState, useCallback, useRef } from "react"; +import { get, put } from "../../api/client"; +import type { + AuditItemResult, + AuditFieldResult, + Item, +} from "../../api/types"; + +const tierColors: Record = { + critical: "var(--ctp-red)", + low: "var(--ctp-peach)", + partial: "var(--ctp-yellow)", + good: "var(--ctp-green)", + complete: "var(--ctp-teal)", +}; + +// Item-level fields saved via PUT /api/items/{pn}. +const itemFields = new Set([ + "description", + "sourcing_type", + "sourcing_link", + "standard_cost", + "long_description", +]); + +// Fields to group under "Procurement". +const procurementFields = new Set([ + "sourcing_link", + "standard_cost", + "long_description", + "manufacturer", + "manufacturer_pn", + "supplier", + "supplier_pn", + "lead_time_days", + "minimum_order_qty", + "lifecycle_status", +]); + +// Fields to group under "Required". +const requiredFields = new Set(["description", "sourcing_type"]); + +interface AuditDetailPanelProps { + partNumber: string; + onClose: () => void; + onSaved: () => void; +} + +export function AuditDetailPanel({ + partNumber, + onClose, + onSaved, +}: AuditDetailPanelProps) { + const [audit, setAudit] = useState(null); + const [item, setItem] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [edits, setEdits] = useState>({}); + const debounceRef = useRef>(undefined); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [auditData, itemData] = await Promise.all([ + get( + `/api/audit/completeness/${encodeURIComponent(partNumber)}`, + ), + get(`/api/items/${encodeURIComponent(partNumber)}`), + ]); + setAudit(auditData); + setItem(itemData); + setEdits({}); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load"); + } finally { + setLoading(false); + } + }, [partNumber]); + + useEffect(() => { + void fetchData(); + }, [fetchData]); + + const handleFieldChange = useCallback( + (key: string, value: string) => { + setEdits((prev) => ({ ...prev, [key]: value })); + }, + [], + ); + + const saveChanges = useCallback(async () => { + if (!item || Object.keys(edits).length === 0) return; + + setSaving(true); + setError(null); + try { + // Split edits into item-level and property-level. + const itemUpdate: Record = {}; + const propUpdate: Record = {}; + + 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; + } + } else { + // Attempt number coercion for property fields. + const num = parseFloat(value); + propUpdate[key] = !isNaN(num) && String(num) === value.trim() + ? num + : value || undefined; + } + } + + // Merge property changes with existing properties. + const currentProps = item.properties ?? {}; + const hasProps = Object.keys(propUpdate).length > 0; + const payload: Record = { + ...itemUpdate, + ...(hasProps + ? { properties: { ...currentProps, ...propUpdate }, comment: "Audit field update" } + : {}), + }; + + await put(`/api/items/${encodeURIComponent(partNumber)}`, payload); + setEdits({}); + await fetchData(); + onSaved(); + } catch (e) { + setError(e instanceof Error ? e.message : "Save failed"); + } finally { + setSaving(false); + } + }, [item, edits, partNumber, fetchData, onSaved]); + + // Debounced auto-save on field blur. + const handleBlur = useCallback(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + void saveChanges(); + }, 500); + }, [saveChanges]); + + // Clean up debounce on unmount. + useEffect(() => { + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, []); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + if (debounceRef.current) clearTimeout(debounceRef.current); + void saveChanges(); + } + }, + [saveChanges], + ); + + if (loading) { + return ( +
+ Loading... +
+ ); + } + + if (!audit || !item) { + return ( +
+ {error ?? "Item not found"} +
+ ); + } + + const fields = audit.fields ?? []; + const color = tierColors[audit.tier] ?? "var(--ctp-subtext0)"; + + // Group fields. + const required = fields.filter((f) => requiredFields.has(f.key)); + const procurement = fields.filter( + (f) => procurementFields.has(f.key) && !requiredFields.has(f.key), + ); + const categorySpecific = fields.filter( + (f) => + !requiredFields.has(f.key) && + !procurementFields.has(f.key) && + f.source !== "computed", + ); + const computed = fields.filter((f) => f.source === "computed"); + + return ( +
+ {/* Header */} +
+ + {audit.part_number} + + + {(audit.score * 100).toFixed(0)}% + + {saving && ( + + Saving... + + )} +
+ +
+ + {/* Score progress bar */} +
+
+
+ + {error && ( +
+ {error} +
+ )} + + {/* Description */} +
+ {audit.description} + {audit.category_name && ( + + {audit.category_name} + + )} +
+ + {/* Scrollable field groups */} +
+ {required.length > 0 && ( + + )} + {procurement.length > 0 && ( + + )} + {categorySpecific.length > 0 && ( + + )} + {computed.length > 0 && ( + + )} +
+
+ ); +} + +// --- FieldGroup sub-component --- + +interface FieldGroupProps { + title: string; + fields: AuditFieldResult[]; + edits: Record; + onChange: (key: string, value: string) => void; + onBlur: () => void; + onKeyDown: (e: React.KeyboardEvent) => void; + readOnly?: boolean; +} + +function FieldGroup({ + title, + fields, + edits, + onChange, + onBlur, + onKeyDown, + readOnly, +}: FieldGroupProps) { + return ( +
+
+ {title} +
+ {fields.map((field) => ( + + ))} +
+ ); +} + +// --- FieldRow sub-component --- + +interface FieldRowProps { + field: AuditFieldResult; + editValue?: string; + onChange: (key: string, value: string) => void; + onBlur: () => void; + onKeyDown: (e: React.KeyboardEvent) => void; + readOnly?: boolean; +} + +function FieldRow({ + field, + editValue, + onChange, + onBlur, + onKeyDown, + readOnly, +}: FieldRowProps) { + const displayValue = + editValue !== undefined + ? editValue + : field.value != null + ? String(field.value) + : ""; + + const borderColor = field.filled + ? "var(--ctp-green)" + : "var(--ctp-red)"; + + const label = field.key + .replace(/_/g, " ") + .replace(/\b\w/g, (c) => c.toUpperCase()); + + return ( +
+
+ {label} + {field.weight >= 3 && ( + + * + + )} +
+ {readOnly || field.source === "computed" ? ( +
+ {field.key === "has_bom" + ? field.filled + ? "Yes" + : "No" + : displayValue || "---"} +
+ ) : ( + onChange(field.key, e.target.value)} + onBlur={onBlur} + onKeyDown={onKeyDown} + placeholder="---" + style={{ + flex: 1, + padding: "0.2rem 0.4rem", + fontSize: "0.8rem", + border: "1px solid var(--ctp-surface1)", + borderRadius: "0.3rem", + backgroundColor: "var(--ctp-surface0)", + color: "var(--ctp-text)", + outline: "none", + }} + /> + )} +
+ ); +} + +const closeBtnStyle: React.CSSProperties = { + padding: "0.2rem 0.5rem", + fontSize: "0.8rem", + border: "none", + borderRadius: "0.3rem", + backgroundColor: "var(--ctp-surface1)", + color: "var(--ctp-subtext1)", + cursor: "pointer", +}; diff --git a/web/src/components/audit/AuditSummaryBar.tsx b/web/src/components/audit/AuditSummaryBar.tsx new file mode 100644 index 0000000..93021d6 --- /dev/null +++ b/web/src/components/audit/AuditSummaryBar.tsx @@ -0,0 +1,92 @@ +import type { AuditSummary } from "../../api/types"; + +const tierConfig = [ + { key: "critical", label: "Critical", color: "var(--ctp-red)" }, + { key: "low", label: "Low", color: "var(--ctp-peach)" }, + { key: "partial", label: "Partial", color: "var(--ctp-yellow)" }, + { key: "good", label: "Good", color: "var(--ctp-green)" }, + { key: "complete", label: "Complete", color: "var(--ctp-teal)" }, +] as const; + +interface AuditSummaryBarProps { + summary: AuditSummary; + activeTier: string; + onTierClick: (tier: string) => void; +} + +export function AuditSummaryBar({ + summary, + activeTier, + onTierClick, +}: AuditSummaryBarProps) { + const total = summary.total_items || 1; + + return ( +
+ {/* Stacked bar */} +
+ {tierConfig.map(({ key, label, color }) => { + const count = summary.by_tier[key] ?? 0; + if (count === 0) return null; + const pct = (count / total) * 100; + const isActive = activeTier === key; + return ( +
onTierClick(activeTier === key ? "" : key)} + title={`${label}: ${count}`} + style={{ + width: `${pct}%`, + minWidth: count > 0 ? 28 : 0, + backgroundColor: color, + opacity: !activeTier || isActive ? 1 : 0.35, + display: "flex", + alignItems: "center", + justifyContent: "center", + fontSize: "0.7rem", + fontWeight: 600, + color: "var(--ctp-crust)", + transition: "opacity 0.2s", + outline: isActive ? "2px solid var(--ctp-text)" : "none", + outlineOffset: -2, + }} + > + {pct > 8 ? `${label} ${count}` : count} +
+ ); + })} +
+ + {/* Stats line */} +
+ + {summary.total_items} items + + + Avg score: {(summary.avg_score * 100).toFixed(1)}% + + {summary.manufactured_without_bom > 0 && ( + + {summary.manufactured_without_bom} manufactured without BOM + + )} +
+
+ ); +} diff --git a/web/src/components/audit/AuditTable.tsx b/web/src/components/audit/AuditTable.tsx new file mode 100644 index 0000000..e75c4c2 --- /dev/null +++ b/web/src/components/audit/AuditTable.tsx @@ -0,0 +1,136 @@ +import type { AuditItemResult } from "../../api/types"; + +const tierColors: Record = { + critical: "var(--ctp-red)", + low: "var(--ctp-peach)", + partial: "var(--ctp-yellow)", + good: "var(--ctp-green)", + complete: "var(--ctp-teal)", +}; + +interface AuditTableProps { + items: AuditItemResult[]; + loading: boolean; + selectedPN: string | null; + onSelect: (pn: string) => void; +} + +export function AuditTable({ + items, + loading, + selectedPN, + onSelect, +}: AuditTableProps) { + if (loading) { + return ( +
+ Loading audit data... +
+ ); + } + + if (items.length === 0) { + return ( +
+ No items found +
+ ); + } + + return ( +
+ + + + {["Score", "Part Number", "Description", "Category", "Sourcing", "Missing"].map( + (h) => ( + + ), + )} + + + + {items.map((item) => { + const color = tierColors[item.tier] ?? "var(--ctp-subtext0)"; + const isSelected = selectedPN === item.part_number; + return ( + onSelect(item.part_number)} + style={{ + cursor: "pointer", + backgroundColor: isSelected + ? "var(--ctp-surface1)" + : "transparent", + transition: "background-color 0.15s", + }} + onMouseEnter={(e) => { + if (!isSelected) + e.currentTarget.style.backgroundColor = "var(--ctp-surface0)"; + }} + onMouseLeave={(e) => { + if (!isSelected) + e.currentTarget.style.backgroundColor = "transparent"; + }} + > + + + + + + + + ); + })} + +
+ {h} +
+ + {(item.score * 100).toFixed(0)}% + + + {item.part_number} + + {item.description} + {item.category_name || item.category}{item.sourcing_type} + {item.missing.length} +
+
+ ); +} + +const thStyle: React.CSSProperties = { + textAlign: "left", + padding: "0.5rem 0.75rem", + borderBottom: "1px solid var(--ctp-surface1)", + color: "var(--ctp-subtext0)", + fontWeight: 500, + position: "sticky", + top: 0, + backgroundColor: "var(--ctp-base)", + zIndex: 1, +}; + +const tdStyle: React.CSSProperties = { + padding: "0.4rem 0.75rem", + borderBottom: "1px solid var(--ctp-surface0)", + color: "var(--ctp-text)", +}; diff --git a/web/src/components/audit/AuditToolbar.tsx b/web/src/components/audit/AuditToolbar.tsx new file mode 100644 index 0000000..c83e851 --- /dev/null +++ b/web/src/components/audit/AuditToolbar.tsx @@ -0,0 +1,115 @@ +import { useEffect, useState } from "react"; +import { get } from "../../api/client"; +import type { Project, AuditSummary } from "../../api/types"; +import type { AuditFilters } from "../../hooks/useAudit"; + +interface AuditToolbarProps { + filters: AuditFilters; + onFilterChange: (partial: Partial) => void; + summary: AuditSummary; + layout: "horizontal" | "vertical"; + onLayoutChange: (layout: "horizontal" | "vertical") => void; +} + +export function AuditToolbar({ + filters, + onFilterChange, + summary, + layout, + onLayoutChange, +}: AuditToolbarProps) { + const [projects, setProjects] = useState([]); + + useEffect(() => { + get("/api/projects") + .then(setProjects) + .catch(() => {}); + }, []); + + const categoryKeys = Object.keys(summary.by_category).sort(); + + return ( +
+ + + + + + +
+ + +
+ ); +} + +const selectStyle: React.CSSProperties = { + padding: "0.35rem 0.5rem", + fontSize: "0.8rem", + borderRadius: "0.4rem", + border: "1px solid var(--ctp-surface1)", + backgroundColor: "var(--ctp-surface0)", + color: "var(--ctp-text)", +}; + +const btnStyle: React.CSSProperties = { + padding: "0.35rem 0.6rem", + fontSize: "0.8rem", + borderRadius: "0.4rem", + border: "none", + backgroundColor: "var(--ctp-surface1)", + color: "var(--ctp-subtext1)", + cursor: "pointer", + fontWeight: 600, +}; diff --git a/web/src/hooks/useAudit.ts b/web/src/hooks/useAudit.ts new file mode 100644 index 0000000..dc3f85b --- /dev/null +++ b/web/src/hooks/useAudit.ts @@ -0,0 +1,112 @@ +import { useState, useEffect, useCallback } from "react"; +import { get } from "../api/client"; +import type { + AuditCompletenessResponse, + AuditItemResult, + AuditSummary, +} from "../api/types"; + +export interface AuditFilters { + project: string; + category: string; + tier: string; + sort: string; + page: number; + pageSize: number; +} + +const defaultFilters: AuditFilters = { + project: "", + category: "", + tier: "", + sort: "score_asc", + page: 1, + pageSize: 100, +}; + +const emptySummary: AuditSummary = { + total_items: 0, + avg_score: 0, + manufactured_without_bom: 0, + by_tier: {}, + by_category: {}, +}; + +// Map tier name to min_score/max_score API params. +function tierToScoreRange(tier: string): { + min_score?: number; + max_score?: number; +} { + switch (tier) { + case "critical": + return { max_score: 0.2499 }; + case "low": + return { min_score: 0.25, max_score: 0.4999 }; + case "partial": + return { min_score: 0.5, max_score: 0.7499 }; + case "good": + return { min_score: 0.75, max_score: 0.9999 }; + case "complete": + return { min_score: 1.0 }; + default: + return {}; + } +} + +export function useAudit() { + const [items, setItems] = useState([]); + const [summary, setSummary] = useState(emptySummary); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [filters, setFilters] = useState(defaultFilters); + + const fetchAudit = useCallback(async (f: AuditFilters) => { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams(); + if (f.project) params.set("project", f.project); + if (f.category) params.set("category", f.category); + if (f.sort) params.set("sort", f.sort); + params.set("limit", String(f.pageSize)); + params.set("offset", String((f.page - 1) * f.pageSize)); + + if (f.tier) { + const range = tierToScoreRange(f.tier); + if (range.min_score !== undefined) + params.set("min_score", String(range.min_score)); + if (range.max_score !== undefined) + params.set("max_score", String(range.max_score)); + } + + const qs = params.toString(); + const resp = await get( + `/api/audit/completeness${qs ? `?${qs}` : ""}`, + ); + setItems(resp.items); + setSummary(resp.summary); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load audit data"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void fetchAudit(filters); + }, [filters, fetchAudit]); + + const updateFilters = useCallback((partial: Partial) => { + setFilters((prev) => { + const next = { ...prev, ...partial }; + if (!("page" in partial)) next.page = 1; + return next; + }); + }, []); + + const reload = useCallback(() => { + void fetchAudit(filters); + }, [filters, fetchAudit]); + + return { items, summary, loading, error, filters, updateFilters, reload }; +} diff --git a/web/src/pages/AuditPage.tsx b/web/src/pages/AuditPage.tsx index f8c0339..9df9003 100644 --- a/web/src/pages/AuditPage.tsx +++ b/web/src/pages/AuditPage.tsx @@ -1,36 +1,135 @@ -import { useEffect, useState } from 'react'; -import { get } from '../api/client'; -import type { AuditCompletenessResponse } from '../api/types'; +import { useState, useCallback } from "react"; +import { useAudit } from "../hooks/useAudit"; +import { useLocalStorage } from "../hooks/useLocalStorage"; +import { AuditSummaryBar } from "../components/audit/AuditSummaryBar"; +import { AuditToolbar } from "../components/audit/AuditToolbar"; +import { AuditTable } from "../components/audit/AuditTable"; +import { AuditDetailPanel } from "../components/audit/AuditDetailPanel"; +import { SplitPanel } from "../components/items/SplitPanel"; + +type PaneMode = { type: "none" } | { type: "detail"; partNumber: string }; export function AuditPage() { - const [audit, setAudit] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const { items, summary, loading, error, filters, updateFilters, reload } = + useAudit(); + const [layout, setLayout] = useLocalStorage<"horizontal" | "vertical">( + "silo-audit-layout", + "horizontal", + ); + const [pane, setPane] = useState({ type: "none" }); - useEffect(() => { - get('/api/audit/completeness') - .then(setAudit) - .catch((e: Error) => setError(e.message)) - .finally(() => setLoading(false)); + const handleSelect = useCallback((pn: string) => { + setPane({ type: "detail", partNumber: pn }); }, []); - if (loading) return

Loading audit data...

; - if (error) return

Error: {error}

; - if (!audit) return null; + const handleClose = useCallback(() => { + setPane({ type: "none" }); + }, []); + + const handleTierClick = useCallback( + (tier: string) => { + updateFilters({ tier }); + }, + [updateFilters], + ); + + const secondaryPane = + pane.type === "detail" ? ( + + ) : null; return ( -
-

Audit

-
-

Total items: {audit.summary.total_items}

-

Average score: {(audit.summary.avg_score * 100).toFixed(1)}%

-

Manufactured without BOM: {audit.summary.manufactured_without_bom}

+
+ {error && ( +
+ Error: {error} +
+ )} + + + + + + + } + secondary={secondaryPane} + storageKey="silo-audit-split" + /> + + {/* Pagination */} +
+ + + Page {filters.page} ยท {items.length} items + +
); } + +const pageBtnStyle: React.CSSProperties = { + padding: "0.25rem 0.6rem", + fontSize: "0.8rem", + border: "none", + borderRadius: "0.3rem", + backgroundColor: "var(--ctp-surface0)", + color: "var(--ctp-text)", + cursor: "pointer", +};