feat(web): implement Component Audit UI with inline editing

Build the full Audit page replacing the minimal stub with:

Phase 1 - Audit Overview:
- useAudit hook with server-side filtering by project, category,
  tier (mapped to min_score/max_score), sort, and pagination
- AuditSummaryBar: horizontal stacked bar with tier counts, colored
  segments (critical/low/partial/good/complete), clickable to filter
- AuditToolbar: project and category dropdowns, sort selector,
  layout toggle
- AuditTable: sortable table with score badge (colored by tier),
  part number, description, category, sourcing type, missing count
- SplitPanel integration (reused from items) with persistent layout

Phase 2 - Inline Edit Panel:
- AuditDetailPanel: split-panel detail view with field-by-field
  breakdown from GET /api/audit/completeness/{pn}
- Fields grouped into Required, Procurement, Category Properties,
  and Computed sections
- Color-coded left border per field: green if filled, red if empty
- Critical fields (weight >= 3) marked with asterisk
- Inline editing with debounced auto-save on blur (500ms)
- Item-level fields saved via PUT /api/items/{pn}
- Property fields saved via PUT with merged properties + new revision
- Score progress bar updates live after each save
- Computed fields (has_bom) shown read-only

All components use inline CSS with Catppuccin Mocha theme variables,
matching the existing frontend patterns.

Closes #5
This commit is contained in:
Forbes
2026-02-07 08:48:50 -06:00
parent d08b178466
commit cf02ce4231
6 changed files with 1095 additions and 25 deletions

View File

@@ -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<string, string> = {
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<AuditItemResult | null>(null);
const [item, setItem] = useState<Item | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [edits, setEdits] = useState<Record<string, string>>({});
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [auditData, itemData] = await Promise.all([
get<AuditItemResult>(
`/api/audit/completeness/${encodeURIComponent(partNumber)}`,
),
get<Item>(`/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<string, unknown> = {};
const propUpdate: Record<string, unknown> = {};
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<string, unknown> = {
...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 (
<div style={{ padding: "2rem", color: "var(--ctp-subtext0)" }}>
Loading...
</div>
);
}
if (!audit || !item) {
return (
<div style={{ padding: "2rem", color: "var(--ctp-red)" }}>
{error ?? "Item not found"}
</div>
);
}
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 (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100%",
backgroundColor: "var(--ctp-base)",
}}
>
{/* Header */}
<div
style={{
padding: "0.75rem 1rem",
borderBottom: "1px solid var(--ctp-surface1)",
display: "flex",
alignItems: "center",
gap: "0.75rem",
flexShrink: 0,
}}
>
<span
style={{
fontFamily: "'JetBrains Mono', monospace",
color: "var(--ctp-peach)",
fontWeight: 600,
fontSize: "1rem",
}}
>
{audit.part_number}
</span>
<span
style={{
display: "inline-block",
padding: "0.15rem 0.5rem",
borderRadius: "1rem",
fontSize: "0.75rem",
fontWeight: 600,
backgroundColor: color,
color: "var(--ctp-crust)",
}}
>
{(audit.score * 100).toFixed(0)}%
</span>
{saving && (
<span style={{ fontSize: "0.75rem", color: "var(--ctp-blue)" }}>
Saving...
</span>
)}
<div style={{ flex: 1 }} />
<button onClick={onClose} style={closeBtnStyle} title="Close">
X
</button>
</div>
{/* Score progress bar */}
<div
style={{
height: 6,
backgroundColor: "var(--ctp-surface0)",
flexShrink: 0,
}}
>
<div
style={{
height: "100%",
width: `${Math.min(audit.score * 100, 100)}%`,
backgroundColor: color,
transition: "width 0.3s, background-color 0.3s",
borderRadius: "0 3px 3px 0",
}}
/>
</div>
{error && (
<div
style={{
padding: "0.5rem 1rem",
color: "var(--ctp-red)",
fontSize: "0.8rem",
}}
>
{error}
</div>
)}
{/* Description */}
<div
style={{
padding: "0.5rem 1rem",
fontSize: "0.85rem",
color: "var(--ctp-subtext1)",
borderBottom: "1px solid var(--ctp-surface0)",
flexShrink: 0,
}}
>
{audit.description}
{audit.category_name && (
<span style={{ marginLeft: "0.75rem", color: "var(--ctp-subtext0)" }}>
{audit.category_name}
</span>
)}
</div>
{/* Scrollable field groups */}
<div style={{ overflow: "auto", flex: 1, padding: "0.5rem 0" }}>
{required.length > 0 && (
<FieldGroup
title="Required"
fields={required}
edits={edits}
onChange={handleFieldChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
/>
)}
{procurement.length > 0 && (
<FieldGroup
title="Procurement"
fields={procurement}
edits={edits}
onChange={handleFieldChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
/>
)}
{categorySpecific.length > 0 && (
<FieldGroup
title={`${audit.category_name || audit.category} Properties`}
fields={categorySpecific}
edits={edits}
onChange={handleFieldChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
/>
)}
{computed.length > 0 && (
<FieldGroup
title="Computed"
fields={computed}
edits={edits}
onChange={handleFieldChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
readOnly
/>
)}
</div>
</div>
);
}
// --- FieldGroup sub-component ---
interface FieldGroupProps {
title: string;
fields: AuditFieldResult[];
edits: Record<string, string>;
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 (
<div style={{ marginBottom: "0.75rem" }}>
<div
style={{
padding: "0.3rem 1rem",
fontSize: "0.7rem",
fontWeight: 600,
textTransform: "uppercase",
letterSpacing: "0.05em",
color: "var(--ctp-subtext0)",
backgroundColor: "var(--ctp-mantle)",
}}
>
{title}
</div>
{fields.map((field) => (
<FieldRow
key={field.key}
field={field}
editValue={edits[field.key]}
onChange={onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
readOnly={readOnly}
/>
))}
</div>
);
}
// --- 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 (
<div
style={{
display: "flex",
alignItems: "center",
padding: "0.3rem 1rem",
borderLeft: `3px solid ${borderColor}`,
marginLeft: "0.5rem",
gap: "0.5rem",
}}
>
<div
style={{
width: 140,
flexShrink: 0,
fontSize: "0.78rem",
color: "var(--ctp-subtext1)",
}}
title={`Weight: ${field.weight}`}
>
{label}
{field.weight >= 3 && (
<span
style={{
marginLeft: 4,
color: "var(--ctp-red)",
fontSize: "0.65rem",
}}
>
*
</span>
)}
</div>
{readOnly || field.source === "computed" ? (
<div
style={{
flex: 1,
fontSize: "0.8rem",
color: field.filled
? "var(--ctp-text)"
: "var(--ctp-subtext0)",
fontStyle: field.filled ? "normal" : "italic",
}}
>
{field.key === "has_bom"
? field.filled
? "Yes"
: "No"
: displayValue || "---"}
</div>
) : (
<input
type="text"
value={displayValue}
onChange={(e) => 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",
}}
/>
)}
</div>
);
}
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",
};

View File

@@ -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 (
<div style={{ marginBottom: "0.75rem" }}>
{/* Stacked bar */}
<div
style={{
display: "flex",
height: 32,
borderRadius: "0.5rem",
overflow: "hidden",
cursor: "pointer",
}}
>
{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 (
<div
key={key}
onClick={() => 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}
</div>
);
})}
</div>
{/* Stats line */}
<div
style={{
display: "flex",
gap: "1.5rem",
marginTop: "0.4rem",
fontSize: "0.8rem",
color: "var(--ctp-subtext0)",
}}
>
<span>
{summary.total_items} items
</span>
<span>
Avg score: {(summary.avg_score * 100).toFixed(1)}%
</span>
{summary.manufactured_without_bom > 0 && (
<span style={{ color: "var(--ctp-red)" }}>
{summary.manufactured_without_bom} manufactured without BOM
</span>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,136 @@
import type { AuditItemResult } from "../../api/types";
const tierColors: Record<string, string> = {
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 (
<div style={{ padding: "2rem", color: "var(--ctp-subtext0)", textAlign: "center" }}>
Loading audit data...
</div>
);
}
if (items.length === 0) {
return (
<div style={{ padding: "2rem", color: "var(--ctp-subtext0)", textAlign: "center" }}>
No items found
</div>
);
}
return (
<div style={{ overflow: "auto", flex: 1 }}>
<table style={{ width: "100%", borderCollapse: "collapse", fontSize: "0.8rem" }}>
<thead>
<tr>
{["Score", "Part Number", "Description", "Category", "Sourcing", "Missing"].map(
(h) => (
<th key={h} style={thStyle}>
{h}
</th>
),
)}
</tr>
</thead>
<tbody>
{items.map((item) => {
const color = tierColors[item.tier] ?? "var(--ctp-subtext0)";
const isSelected = selectedPN === item.part_number;
return (
<tr
key={item.part_number}
onClick={() => 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";
}}
>
<td style={tdStyle}>
<span
style={{
display: "inline-block",
padding: "0.15rem 0.5rem",
borderRadius: "1rem",
fontSize: "0.75rem",
fontWeight: 600,
backgroundColor: color,
opacity: 0.9,
color: "var(--ctp-crust)",
}}
>
{(item.score * 100).toFixed(0)}%
</span>
</td>
<td
style={{
...tdStyle,
fontFamily: "'JetBrains Mono', monospace",
color: "var(--ctp-peach)",
}}
>
{item.part_number}
</td>
<td style={{ ...tdStyle, maxWidth: 300, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{item.description}
</td>
<td style={tdStyle}>{item.category_name || item.category}</td>
<td style={tdStyle}>{item.sourcing_type}</td>
<td style={{ ...tdStyle, textAlign: "center" }}>
{item.missing.length}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
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)",
};

View File

@@ -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<AuditFilters>) => void;
summary: AuditSummary;
layout: "horizontal" | "vertical";
onLayoutChange: (layout: "horizontal" | "vertical") => void;
}
export function AuditToolbar({
filters,
onFilterChange,
summary,
layout,
onLayoutChange,
}: AuditToolbarProps) {
const [projects, setProjects] = useState<Project[]>([]);
useEffect(() => {
get<Project[]>("/api/projects")
.then(setProjects)
.catch(() => {});
}, []);
const categoryKeys = Object.keys(summary.by_category).sort();
return (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.5rem",
alignItems: "center",
marginBottom: "0.5rem",
}}
>
<select
value={filters.project}
onChange={(e) => onFilterChange({ project: e.target.value })}
style={selectStyle}
title="Filter by project"
>
<option value="">All Projects</option>
{projects.map((p) => (
<option key={p.code} value={p.code}>
{p.code}
{p.name ? ` - ${p.name}` : ""}
</option>
))}
</select>
<select
value={filters.category}
onChange={(e) => onFilterChange({ category: e.target.value })}
style={selectStyle}
title="Filter by category"
>
<option value="">All Categories</option>
{categoryKeys.map((cat) => (
<option key={cat} value={cat}>
{cat} ({summary.by_category[cat]?.count ?? 0})
</option>
))}
</select>
<select
value={filters.sort}
onChange={(e) => onFilterChange({ sort: e.target.value })}
style={selectStyle}
title="Sort order"
>
<option value="score_asc">Score (low first)</option>
<option value="score_desc">Score (high first)</option>
<option value="part_number">Part Number</option>
<option value="updated_at">Recently Updated</option>
</select>
<div style={{ flex: 1 }} />
<button
onClick={() =>
onLayoutChange(layout === "horizontal" ? "vertical" : "horizontal")
}
style={btnStyle}
title={`Switch to ${layout === "horizontal" ? "vertical" : "horizontal"} layout`}
>
{layout === "horizontal" ? "H" : "V"}
</button>
</div>
);
}
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,
};

112
web/src/hooks/useAudit.ts Normal file
View File

@@ -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<AuditItemResult[]>([]);
const [summary, setSummary] = useState<AuditSummary>(emptySummary);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [filters, setFilters] = useState<AuditFilters>(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<AuditCompletenessResponse>(
`/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<AuditFilters>) => {
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 };
}

View File

@@ -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<AuditCompletenessResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { items, summary, loading, error, filters, updateFilters, reload } =
useAudit();
const [layout, setLayout] = useLocalStorage<"horizontal" | "vertical">(
"silo-audit-layout",
"horizontal",
);
const [pane, setPane] = useState<PaneMode>({ type: "none" });
useEffect(() => {
get<AuditCompletenessResponse>('/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 <p style={{ color: 'var(--ctp-subtext0)' }}>Loading audit data...</p>;
if (error) return <p style={{ color: 'var(--ctp-red)' }}>Error: {error}</p>;
if (!audit) return null;
const handleClose = useCallback(() => {
setPane({ type: "none" });
}, []);
const handleTierClick = useCallback(
(tier: string) => {
updateFilters({ tier });
},
[updateFilters],
);
const secondaryPane =
pane.type === "detail" ? (
<AuditDetailPanel
partNumber={pane.partNumber}
onClose={handleClose}
onSaved={reload}
/>
) : null;
return (
<div>
<h2 style={{ marginBottom: '1rem' }}>Audit</h2>
<div style={{
backgroundColor: 'var(--ctp-surface0)',
borderRadius: '0.75rem',
padding: '1.5rem',
marginBottom: '1rem',
}}>
<p>Total items: <strong>{audit.summary.total_items}</strong></p>
<p>Average score: <strong>{(audit.summary.avg_score * 100).toFixed(1)}%</strong></p>
<p>Manufactured without BOM: <strong>{audit.summary.manufactured_without_bom}</strong></p>
<div
style={{
display: "flex",
flexDirection: "column",
height: "calc(100vh - 80px)",
paddingBottom: 28,
}}
>
{error && (
<div
style={{
color: "var(--ctp-red)",
padding: "0.5rem",
fontSize: "0.85rem",
}}
>
Error: {error}
</div>
)}
<AuditSummaryBar
summary={summary}
activeTier={filters.tier}
onTierClick={handleTierClick}
/>
<AuditToolbar
filters={filters}
onFilterChange={updateFilters}
summary={summary}
layout={layout}
onLayoutChange={setLayout}
/>
<SplitPanel
layout={layout}
primary={
<AuditTable
items={items}
loading={loading}
selectedPN={pane.type === "detail" ? pane.partNumber : null}
onSelect={handleSelect}
/>
}
secondary={secondaryPane}
storageKey="silo-audit-split"
/>
{/* Pagination */}
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
gap: "0.75rem",
padding: "0.4rem",
flexShrink: 0,
}}
>
<button
onClick={() => updateFilters({ page: Math.max(1, filters.page - 1) })}
disabled={filters.page <= 1}
style={pageBtnStyle}
>
Prev
</button>
<span style={{ fontSize: "0.8rem", color: "var(--ctp-subtext0)" }}>
Page {filters.page} · {items.length} items
</span>
<button
onClick={() => updateFilters({ page: filters.page + 1 })}
disabled={items.length < filters.pageSize}
style={pageBtnStyle}
>
Next
</button>
</div>
</div>
);
}
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",
};