Merge pull request 'fix: render project tags as strings, not objects' (#34) from fix-33-project-tags-render into main

Reviewed-on: #34
This commit was merged in pull request #34.
This commit is contained in:
2026-02-08 21:23:19 +00:00

View File

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