- 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
313 lines
9.2 KiB
TypeScript
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>
|
|
);
|
|
}
|