- 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
350 lines
11 KiB
TypeScript
350 lines
11 KiB
TypeScript
import { useState, useEffect } from "react";
|
|
import { Download } from "lucide-react";
|
|
import { get, post } from "../../api/client";
|
|
import type { Revision, RevisionComparison } from "../../api/types";
|
|
|
|
interface RevisionsTabProps {
|
|
partNumber: string;
|
|
isEditor: boolean;
|
|
}
|
|
|
|
const statusColors: Record<string, string> = {
|
|
draft: "var(--ctp-overlay1)",
|
|
review: "var(--ctp-yellow)",
|
|
released: "var(--ctp-green)",
|
|
obsolete: "var(--ctp-red)",
|
|
};
|
|
|
|
function formatDate(s: string) {
|
|
if (!s) return "";
|
|
return new Date(s).toLocaleDateString("en-US", {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
});
|
|
}
|
|
|
|
export function RevisionsTab({ partNumber, isEditor }: RevisionsTabProps) {
|
|
const [revisions, setRevisions] = useState<Revision[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [compareFrom, setCompareFrom] = useState("");
|
|
const [compareTo, setCompareTo] = useState("");
|
|
const [comparison, setComparison] = useState<RevisionComparison | null>(null);
|
|
|
|
const load = () => {
|
|
setLoading(true);
|
|
get<Revision[]>(`/api/items/${encodeURIComponent(partNumber)}/revisions`)
|
|
.then((r) => {
|
|
setRevisions(r);
|
|
setLoading(false);
|
|
})
|
|
.catch(() => setLoading(false));
|
|
};
|
|
|
|
useEffect(load, [partNumber]);
|
|
|
|
const handleCompare = async () => {
|
|
if (!compareFrom || !compareTo) return;
|
|
try {
|
|
const result = await get<RevisionComparison>(
|
|
`/api/items/${encodeURIComponent(partNumber)}/revisions/compare?from=${compareFrom}&to=${compareTo}`,
|
|
);
|
|
setComparison(result);
|
|
} catch (e) {
|
|
alert(e instanceof Error ? e.message : "Compare failed");
|
|
}
|
|
};
|
|
|
|
const handleStatusChange = async (rev: number, status: string) => {
|
|
try {
|
|
await fetch(
|
|
`/api/items/${encodeURIComponent(partNumber)}/revisions/${rev}`,
|
|
{
|
|
method: "PATCH",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify({ status }),
|
|
},
|
|
);
|
|
load();
|
|
} catch (e) {
|
|
alert(e instanceof Error ? e.message : "Status update failed");
|
|
}
|
|
};
|
|
|
|
const handleRollback = async (rev: number) => {
|
|
if (
|
|
!confirm(
|
|
`Rollback to revision ${rev}? This creates a new revision with data from rev ${rev}.`,
|
|
)
|
|
)
|
|
return;
|
|
const comment = prompt("Rollback comment:") ?? `Rollback to rev ${rev}`;
|
|
try {
|
|
await post(
|
|
`/api/items/${encodeURIComponent(partNumber)}/revisions/${rev}/rollback`,
|
|
{ comment },
|
|
);
|
|
load();
|
|
} catch (e) {
|
|
alert(e instanceof Error ? e.message : "Rollback failed");
|
|
}
|
|
};
|
|
|
|
if (loading)
|
|
return (
|
|
<div style={{ color: "var(--ctp-subtext0)" }}>Loading revisions...</div>
|
|
);
|
|
|
|
const selectStyle: React.CSSProperties = {
|
|
padding: "0.25rem 0.4rem",
|
|
fontSize: "var(--font-table)",
|
|
backgroundColor: "var(--ctp-surface0)",
|
|
border: "1px solid var(--ctp-surface1)",
|
|
borderRadius: "0.375rem",
|
|
color: "var(--ctp-text)",
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
{/* Compare controls */}
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
gap: "0.5rem",
|
|
alignItems: "center",
|
|
marginBottom: "0.75rem",
|
|
}}
|
|
>
|
|
<select
|
|
value={compareFrom}
|
|
onChange={(e) => setCompareFrom(e.target.value)}
|
|
style={selectStyle}
|
|
>
|
|
<option value="">From rev...</option>
|
|
{revisions.map((r) => (
|
|
<option key={r.id} value={r.revision_number}>
|
|
Rev {r.revision_number}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<select
|
|
value={compareTo}
|
|
onChange={(e) => setCompareTo(e.target.value)}
|
|
style={selectStyle}
|
|
>
|
|
<option value="">To rev...</option>
|
|
{revisions.map((r) => (
|
|
<option key={r.id} value={r.revision_number}>
|
|
Rev {r.revision_number}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
onClick={() => void handleCompare()}
|
|
disabled={!compareFrom || !compareTo}
|
|
style={{
|
|
padding: "0.25rem 0.5rem",
|
|
fontSize: "var(--font-table)",
|
|
border: "none",
|
|
borderRadius: "0.375rem",
|
|
backgroundColor: "var(--ctp-mauve)",
|
|
color: "var(--ctp-crust)",
|
|
cursor: "pointer",
|
|
opacity: !compareFrom || !compareTo ? 0.5 : 1,
|
|
}}
|
|
>
|
|
Compare
|
|
</button>
|
|
</div>
|
|
|
|
{/* Compare results */}
|
|
{comparison && (
|
|
<div
|
|
style={{
|
|
padding: "0.5rem",
|
|
backgroundColor: "var(--ctp-surface0)",
|
|
borderRadius: "0.4rem",
|
|
fontSize: "var(--font-table)",
|
|
marginBottom: "0.75rem",
|
|
fontFamily: "'JetBrains Mono', monospace",
|
|
}}
|
|
>
|
|
{comparison.status_changed && (
|
|
<div>
|
|
Status:{" "}
|
|
<span style={{ color: "var(--ctp-red)" }}>
|
|
{comparison.status_changed.from}
|
|
</span>{" "}
|
|
→{" "}
|
|
<span style={{ color: "var(--ctp-green)" }}>
|
|
{comparison.status_changed.to}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{comparison.file_changed && (
|
|
<div style={{ color: "var(--ctp-yellow)" }}>File changed</div>
|
|
)}
|
|
{Object.entries(comparison.added).map(([k, v]) => (
|
|
<div key={k} style={{ color: "var(--ctp-green)" }}>
|
|
+ {k}: {String(v)}
|
|
</div>
|
|
))}
|
|
{Object.entries(comparison.removed).map(([k, v]) => (
|
|
<div key={k} style={{ color: "var(--ctp-red)" }}>
|
|
- {k}: {String(v)}
|
|
</div>
|
|
))}
|
|
{Object.entries(comparison.changed).map(([k, c]) => (
|
|
<div key={k} style={{ color: "var(--ctp-yellow)" }}>
|
|
~ {k}: {String(c.from)} → {String(c.to)}
|
|
</div>
|
|
))}
|
|
{!comparison.status_changed &&
|
|
!comparison.file_changed &&
|
|
Object.keys(comparison.added).length === 0 &&
|
|
Object.keys(comparison.removed).length === 0 &&
|
|
Object.keys(comparison.changed).length === 0 && (
|
|
<div style={{ color: "var(--ctp-subtext0)" }}>No differences</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Revisions table */}
|
|
<table
|
|
style={{
|
|
width: "100%",
|
|
borderCollapse: "collapse",
|
|
fontSize: "var(--font-table)",
|
|
}}
|
|
>
|
|
<thead>
|
|
<tr>
|
|
<th style={thStyle}>Rev</th>
|
|
<th style={thStyle}>Status</th>
|
|
<th style={thStyle}>Created</th>
|
|
<th style={thStyle}>By</th>
|
|
<th style={thStyle}>Comment</th>
|
|
<th style={thStyle}>File</th>
|
|
{isEditor && <th style={thStyle}>Actions</th>}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{revisions.map((rev, idx) => (
|
|
<tr
|
|
key={rev.id}
|
|
style={{
|
|
backgroundColor:
|
|
idx % 2 === 0 ? "var(--ctp-base)" : "var(--ctp-surface0)",
|
|
}}
|
|
>
|
|
<td style={tdStyle}>{rev.revision_number}</td>
|
|
<td style={tdStyle}>
|
|
{isEditor ? (
|
|
<select
|
|
value={rev.status}
|
|
onChange={(e) =>
|
|
void handleStatusChange(
|
|
rev.revision_number,
|
|
e.target.value,
|
|
)
|
|
}
|
|
style={{
|
|
padding: "0.15rem 0.25rem",
|
|
fontSize: "0.75rem",
|
|
border: "none",
|
|
borderRadius: "0.375rem",
|
|
backgroundColor: "transparent",
|
|
color: statusColors[rev.status] ?? "var(--ctp-text)",
|
|
cursor: "pointer",
|
|
}}
|
|
>
|
|
<option value="draft">draft</option>
|
|
<option value="review">review</option>
|
|
<option value="released">released</option>
|
|
<option value="obsolete">obsolete</option>
|
|
</select>
|
|
) : (
|
|
<span
|
|
style={{
|
|
color: statusColors[rev.status] ?? "var(--ctp-text)",
|
|
}}
|
|
>
|
|
{rev.status}
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td style={tdStyle}>{formatDate(rev.created_at)}</td>
|
|
<td style={tdStyle}>{rev.created_by ?? "—"}</td>
|
|
<td
|
|
style={{
|
|
...tdStyle,
|
|
maxWidth: 150,
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
}}
|
|
>
|
|
{rev.comment ?? ""}
|
|
</td>
|
|
<td style={tdStyle}>
|
|
{rev.file_key ? (
|
|
<button
|
|
onClick={() => {
|
|
window.location.href = `/api/items/${encodeURIComponent(partNumber)}/file/${rev.revision_number}`;
|
|
}}
|
|
style={{
|
|
background: "none",
|
|
border: "none",
|
|
color: "var(--ctp-sapphire)",
|
|
cursor: "pointer",
|
|
display: "inline-flex",
|
|
}}
|
|
>
|
|
<Download size={14} />
|
|
</button>
|
|
) : (
|
|
"—"
|
|
)}
|
|
</td>
|
|
{isEditor && (
|
|
<td style={tdStyle}>
|
|
<button
|
|
onClick={() => void handleRollback(rev.revision_number)}
|
|
style={{
|
|
background: "none",
|
|
border: "none",
|
|
color: "var(--ctp-peach)",
|
|
cursor: "pointer",
|
|
fontSize: "0.75rem",
|
|
}}
|
|
title="Rollback to this revision"
|
|
>
|
|
Rollback
|
|
</button>
|
|
</td>
|
|
)}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const thStyle: React.CSSProperties = {
|
|
padding: "0.25rem 0.5rem",
|
|
textAlign: "left",
|
|
borderBottom: "1px solid var(--ctp-surface1)",
|
|
color: "var(--ctp-subtext1)",
|
|
fontWeight: 600,
|
|
fontSize: "var(--font-sm)",
|
|
textTransform: "uppercase",
|
|
letterSpacing: "0.05em",
|
|
};
|
|
|
|
const tdStyle: React.CSSProperties = {
|
|
padding: "0.25rem 0.5rem",
|
|
borderBottom: "1px solid var(--ctp-surface0)",
|
|
whiteSpace: "nowrap",
|
|
};
|