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:
516
web/src/components/audit/AuditDetailPanel.tsx
Normal file
516
web/src/components/audit/AuditDetailPanel.tsx
Normal 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",
|
||||||
|
};
|
||||||
92
web/src/components/audit/AuditSummaryBar.tsx
Normal file
92
web/src/components/audit/AuditSummaryBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
web/src/components/audit/AuditTable.tsx
Normal file
136
web/src/components/audit/AuditTable.tsx
Normal 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)",
|
||||||
|
};
|
||||||
115
web/src/components/audit/AuditToolbar.tsx
Normal file
115
web/src/components/audit/AuditToolbar.tsx
Normal 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
112
web/src/hooks/useAudit.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -1,36 +1,135 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useState, useCallback } from "react";
|
||||||
import { get } from '../api/client';
|
import { useAudit } from "../hooks/useAudit";
|
||||||
import type { AuditCompletenessResponse } from '../api/types';
|
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() {
|
export function AuditPage() {
|
||||||
const [audit, setAudit] = useState<AuditCompletenessResponse | null>(null);
|
const { items, summary, loading, error, filters, updateFilters, reload } =
|
||||||
const [loading, setLoading] = useState(true);
|
useAudit();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [layout, setLayout] = useLocalStorage<"horizontal" | "vertical">(
|
||||||
|
"silo-audit-layout",
|
||||||
|
"horizontal",
|
||||||
|
);
|
||||||
|
const [pane, setPane] = useState<PaneMode>({ type: "none" });
|
||||||
|
|
||||||
useEffect(() => {
|
const handleSelect = useCallback((pn: string) => {
|
||||||
get<AuditCompletenessResponse>('/api/audit/completeness')
|
setPane({ type: "detail", partNumber: pn });
|
||||||
.then(setAudit)
|
|
||||||
.catch((e: Error) => setError(e.message))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (loading) return <p style={{ color: 'var(--ctp-subtext0)' }}>Loading audit data...</p>;
|
const handleClose = useCallback(() => {
|
||||||
if (error) return <p style={{ color: 'var(--ctp-red)' }}>Error: {error}</p>;
|
setPane({ type: "none" });
|
||||||
if (!audit) return null;
|
}, []);
|
||||||
|
|
||||||
|
const handleTierClick = useCallback(
|
||||||
|
(tier: string) => {
|
||||||
|
updateFilters({ tier });
|
||||||
|
},
|
||||||
|
[updateFilters],
|
||||||
|
);
|
||||||
|
|
||||||
|
const secondaryPane =
|
||||||
|
pane.type === "detail" ? (
|
||||||
|
<AuditDetailPanel
|
||||||
|
partNumber={pane.partNumber}
|
||||||
|
onClose={handleClose}
|
||||||
|
onSaved={reload}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div
|
||||||
<h2 style={{ marginBottom: '1rem' }}>Audit</h2>
|
style={{
|
||||||
<div style={{
|
display: "flex",
|
||||||
backgroundColor: 'var(--ctp-surface0)',
|
flexDirection: "column",
|
||||||
borderRadius: '0.75rem',
|
height: "calc(100vh - 80px)",
|
||||||
padding: '1.5rem',
|
paddingBottom: 28,
|
||||||
marginBottom: '1rem',
|
}}
|
||||||
}}>
|
>
|
||||||
<p>Total items: <strong>{audit.summary.total_items}</strong></p>
|
{error && (
|
||||||
<p>Average score: <strong>{(audit.summary.avg_score * 100).toFixed(1)}%</strong></p>
|
<div
|
||||||
<p>Manufactured without BOM: <strong>{audit.summary.manufactured_without_bom}</strong></p>
|
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>
|
||||||
</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",
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user