Files
silo/web/src/components/items/MainTab.tsx
Forbes 07c4aa1c28 fix(web): align spacing values to style guide grid (#71)
- Replace 0.3rem padding/margin/gap with 0.25rem (xs)
- Replace 0.2rem margins with 0.25rem (xs)
- Replace 0.1rem padding with 0.15rem (badge spec)
- Replace 0.6rem margins/padding with 0.5rem (sm)
- Fix borderRadius 0.3rem to 0.375rem (6px per style guide)
- Preserve style-guide-specified values: 0.35rem button gap, 0.4rem cell padding, 0.45rem input padding
2026-02-13 14:37:40 -06:00

313 lines
9.2 KiB
TypeScript

import { useState, useEffect } from "react";
import { X } from "lucide-react";
import { get, post, del } from "../../api/client";
import type { Item, Project, Revision } from "../../api/types";
interface MainTabProps {
item: Item;
onReload: () => void;
isEditor: boolean;
}
function formatDate(s: string) {
if (!s) return "—";
return new Date(s).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function formatFileSize(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`;
return `${(bytes / 1073741824).toFixed(1)} GB`;
}
export function MainTab({ item, onReload, isEditor }: MainTabProps) {
const [itemProjects, setItemProjects] = useState<Project[]>([]);
const [allProjects, setAllProjects] = useState<Project[]>([]);
const [latestRev, setLatestRev] = useState<Revision | null>(null);
const [addProject, setAddProject] = useState("");
useEffect(() => {
get<Project[]>(
`/api/items/${encodeURIComponent(item.part_number)}/projects`,
)
.then(setItemProjects)
.catch(() => setItemProjects([]));
get<Project[]>("/api/projects")
.then(setAllProjects)
.catch(() => {});
get<Revision[]>(
`/api/items/${encodeURIComponent(item.part_number)}/revisions`,
)
.then((revs) => {
if (revs.length > 0) setLatestRev(revs[revs.length - 1]!);
})
.catch(() => {});
}, [item.part_number]);
const handleAddProject = async () => {
if (!addProject) return;
try {
await post(
`/api/items/${encodeURIComponent(item.part_number)}/projects`,
{ projects: [addProject] },
);
const proj = allProjects.find((p) => p.code === addProject);
if (proj) setItemProjects((prev) => [...prev, proj]);
setAddProject("");
onReload();
} catch (e) {
alert(e instanceof Error ? e.message : "Failed to add project");
}
};
const handleRemoveProject = async (code: string) => {
try {
await del(
`/api/items/${encodeURIComponent(item.part_number)}/projects/${encodeURIComponent(code)}`,
);
setItemProjects((prev) => prev.filter((p) => p.code !== code));
onReload();
} catch (e) {
alert(e instanceof Error ? e.message : "Failed to remove project");
}
};
const row = (label: string, value: React.ReactNode) => (
<div
style={{
display: "flex",
gap: "1rem",
padding: "0.25rem 0",
fontSize: "var(--font-body)",
}}
>
<span style={{ width: 120, flexShrink: 0, color: "var(--ctp-subtext0)" }}>
{label}
</span>
<span style={{ color: "var(--ctp-text)" }}>{value}</span>
</div>
);
return (
<div>
{row(
"Part Number",
<span
style={{
fontFamily: "'JetBrains Mono', monospace",
color: "var(--ctp-peach)",
}}
>
{item.part_number}
</span>,
)}
{row("Description", item.description)}
{row("Type", item.item_type)}
{row("Sourcing", item.sourcing_type || "—")}
{item.properties?.sourcing_link != null &&
row(
"Source Link",
<a
href={String(item.properties.sourcing_link)}
target="_blank"
rel="noreferrer"
>
{String(item.properties.sourcing_link)}
</a>,
)}
{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))}
{item.long_description && (
<div
style={{
marginTop: "0.75rem",
padding: "0.5rem",
backgroundColor: "var(--ctp-surface0)",
borderRadius: "0.4rem",
fontSize: "var(--font-body)",
}}
>
<div
style={{
color: "var(--ctp-subtext0)",
fontSize: "0.75rem",
marginBottom: "0.25rem",
}}
>
Long Description
</div>
<div style={{ whiteSpace: "pre-wrap" }}>{item.long_description}</div>
</div>
)}
{/* Project Tags */}
<div style={{ marginTop: "0.75rem" }}>
<div
style={{
color: "var(--ctp-subtext0)",
fontSize: "0.75rem",
marginBottom: "0.25rem",
}}
>
Projects
</div>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "0.25rem",
alignItems: "center",
}}
>
{itemProjects.map((proj) => (
<span
key={proj.code}
style={{
display: "inline-flex",
alignItems: "center",
gap: "0.25rem",
padding: "0.15rem 0.5rem",
borderRadius: "1rem",
backgroundColor: "rgba(203,166,247,0.15)",
color: "var(--ctp-mauve)",
fontSize: "0.75rem",
}}
>
{proj.code}
{isEditor && (
<button
onClick={() => void handleRemoveProject(proj.code)}
style={{
background: "none",
border: "none",
color: "var(--ctp-overlay0)",
cursor: "pointer",
padding: 0,
display: "inline-flex",
}}
>
<X size={14} />
</button>
)}
</span>
))}
{isEditor && (
<>
<select
value={addProject}
onChange={(e) => setAddProject(e.target.value)}
style={{
padding: "0.15rem 0.25rem",
fontSize: "0.75rem",
backgroundColor: "var(--ctp-surface0)",
border: "1px solid var(--ctp-surface1)",
borderRadius: "0.375rem",
color: "var(--ctp-text)",
}}
>
<option value="">+</option>
{allProjects
.filter((p) => !itemProjects.some((ip) => ip.code === p.code))
.map((p) => (
<option key={p.code} value={p.code}>
{p.code}
</option>
))}
</select>
{addProject && (
<button
onClick={() => void handleAddProject()}
style={{
padding: "0.15rem 0.4rem",
fontSize: "var(--font-sm)",
border: "none",
backgroundColor: "var(--ctp-mauve)",
color: "var(--ctp-crust)",
borderRadius: "0.375rem",
cursor: "pointer",
}}
>
Add
</button>
)}
</>
)}
</div>
</div>
{/* File Info */}
{latestRev?.file_key && (
<div
style={{
marginTop: "0.75rem",
padding: "0.5rem",
backgroundColor: "var(--ctp-surface0)",
borderRadius: "0.4rem",
}}
>
<div
style={{
color: "var(--ctp-subtext0)",
fontSize: "0.75rem",
marginBottom: "0.25rem",
}}
>
File Attachment (Rev {latestRev.revision_number})
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: "0.75rem",
fontSize: "var(--font-body)",
}}
>
{latestRev.file_size != null && (
<span>{formatFileSize(latestRev.file_size)}</span>
)}
{latestRev.file_checksum && (
<span
title={latestRev.file_checksum}
style={{
color: "var(--ctp-overlay1)",
fontFamily: "monospace",
fontSize: "0.75rem",
}}
>
SHA256: {latestRev.file_checksum.substring(0, 12)}...
</span>
)}
<button
onClick={() => {
window.location.href = `/api/items/${encodeURIComponent(item.part_number)}/file/${latestRev.revision_number}`;
}}
style={{
padding: "0.25rem 0.5rem",
fontSize: "var(--font-table)",
border: "none",
backgroundColor: "var(--ctp-surface1)",
color: "var(--ctp-text)",
borderRadius: "0.375rem",
cursor: "pointer",
}}
>
Download
</button>
</div>
</div>
)}
</div>
);
}