2026-10-16 09:31:45 -06:00
|
|
|
import QRCode from "qrcode";
|
|
|
|
|
import { useEffect, useRef, useState, type FormEvent } from "react";
|
|
|
|
|
import { assetsApi } from "../api/assets";
|
|
|
|
|
import { packetsApi } from "../api/packets";
|
|
|
|
|
import type { BomLine, Guarded, PacketRead, WorkOrder } from "../api/types";
|
|
|
|
|
import { errorText, fmtTime } from "../lib/format";
|
|
|
|
|
import { useLoad } from "../lib/useLoad";
|
2026-10-16 17:13:31 -06:00
|
|
|
import { MtrViewer } from "../warehouse/MtrViewer";
|
|
|
|
|
import { PullTable, ShortChip, type Write } from "../warehouse/PullTable";
|
|
|
|
|
import { ShortSheet } from "../warehouse/ShortSheet";
|
2026-10-16 09:31:45 -06:00
|
|
|
|
|
|
|
|
export type Mutate = (
|
|
|
|
|
fn: (g: Guarded) => Promise<PacketRead | null | undefined>,
|
|
|
|
|
opts?: { sign?: string | undefined; ok?: string | undefined },
|
|
|
|
|
) => Promise<boolean>;
|
|
|
|
|
|
|
|
|
|
const show = (v: unknown) => (v === null || v === undefined || v === "" ? "-" : String(v));
|
|
|
|
|
|
|
|
|
|
/* ---------- Materials ---------- */
|
|
|
|
|
|
|
|
|
|
interface LineDraft {
|
|
|
|
|
qty: string;
|
|
|
|
|
part_id: string;
|
|
|
|
|
description: string;
|
|
|
|
|
material: string;
|
|
|
|
|
length: string;
|
|
|
|
|
dwg_ref: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const toDraft = (b: BomLine): LineDraft => ({
|
|
|
|
|
qty: b.qty === null || b.qty === undefined ? "" : String(b.qty),
|
|
|
|
|
part_id: b.part_id ?? "",
|
|
|
|
|
description: b.description ?? "",
|
|
|
|
|
material: b.material ?? "",
|
|
|
|
|
length: b.length ?? "",
|
|
|
|
|
dwg_ref: b.dwg_ref ?? "",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const EMPTY_LINE: LineDraft = { qty: "", part_id: "", description: "", material: "", length: "", dwg_ref: "" };
|
|
|
|
|
|
2026-10-16 17:13:31 -06:00
|
|
|
/**
|
|
|
|
|
* The BOM. Clerical may edit the lines of a hand draft (PUT /bom, full replace). The warehouse gets the pull table
|
|
|
|
|
* while the packet is with it. Anyone who may receive a short gets Receive on the line.
|
|
|
|
|
*/
|
|
|
|
|
export function MaterialsTab({
|
|
|
|
|
p,
|
|
|
|
|
mutate,
|
|
|
|
|
busy,
|
|
|
|
|
write,
|
|
|
|
|
reload,
|
|
|
|
|
}: {
|
|
|
|
|
p: PacketRead;
|
|
|
|
|
mutate: Mutate;
|
|
|
|
|
busy: boolean;
|
|
|
|
|
write?: Write | undefined;
|
|
|
|
|
reload?: (() => Promise<void>) | undefined;
|
|
|
|
|
}) {
|
2026-10-16 09:31:45 -06:00
|
|
|
const editable = p.status === "draft" && p.source_kind === "hand" && p.can.includes("edit_bom");
|
|
|
|
|
const [lines, setLines] = useState<LineDraft[] | null>(null);
|
2026-10-16 17:13:31 -06:00
|
|
|
const [receiveFor, setReceiveFor] = useState<number | null>(null);
|
|
|
|
|
const [viewFor, setViewFor] = useState<number | null>(null);
|
|
|
|
|
const canReceive = p.can.includes("wh_receive_short") || p.can.includes("edit_bom");
|
2026-10-16 09:31:45 -06:00
|
|
|
const set = (i: number, k: keyof LineDraft, v: string) => setLines((rows) => (rows ?? []).map((r, j) => (j === i ? { ...r, [k]: v } : r)));
|
|
|
|
|
|
|
|
|
|
const save = async (e: FormEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
if (!lines) return;
|
|
|
|
|
const bom: BomLine[] = lines
|
|
|
|
|
.filter((l) => l.part_id.trim() || l.description.trim())
|
|
|
|
|
.map((l, i) => ({
|
|
|
|
|
item: i + 1,
|
|
|
|
|
qty: l.qty.trim() === "" ? null : Number(l.qty),
|
|
|
|
|
part_id: l.part_id.trim() || null,
|
|
|
|
|
description: l.description.trim() || null,
|
|
|
|
|
material: l.material.trim() || null,
|
|
|
|
|
length: l.length.trim() || null,
|
|
|
|
|
dwg_ref: l.dwg_ref.trim() || null,
|
|
|
|
|
}));
|
|
|
|
|
const ok = await mutate((g) => packetsApi.putBom(p.id, p.version, bom, g), { ok: "BOM saved" });
|
|
|
|
|
if (ok) setLines(null);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (lines) {
|
|
|
|
|
return (
|
|
|
|
|
<form className="stack" onSubmit={save} noValidate>
|
|
|
|
|
<div className="tw">
|
|
|
|
|
<table className="edit">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>Item</th>
|
|
|
|
|
<th className="num">Qty</th>
|
|
|
|
|
<th>Part ID</th>
|
|
|
|
|
<th>Description</th>
|
|
|
|
|
<th>Material</th>
|
|
|
|
|
<th>Length</th>
|
|
|
|
|
<th>Dwg ref</th>
|
|
|
|
|
<th />
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{lines.map((l, i) => (
|
|
|
|
|
<tr key={i}>
|
|
|
|
|
<td className="mono">{i + 1}</td>
|
|
|
|
|
<td>
|
|
|
|
|
<input className="in sm num" aria-label={`Line ${i + 1} quantity`} inputMode="decimal" value={l.qty} onChange={(e) => set(i, "qty", e.target.value)} />
|
|
|
|
|
</td>
|
|
|
|
|
<td>
|
|
|
|
|
<input className="in sm mono" aria-label={`Line ${i + 1} part ID`} maxLength={60} value={l.part_id} onChange={(e) => set(i, "part_id", e.target.value)} />
|
|
|
|
|
</td>
|
|
|
|
|
<td>
|
|
|
|
|
<input className="in sm" aria-label={`Line ${i + 1} description`} maxLength={240} value={l.description} onChange={(e) => set(i, "description", e.target.value)} />
|
|
|
|
|
</td>
|
|
|
|
|
<td>
|
|
|
|
|
<input className="in sm" aria-label={`Line ${i + 1} material`} maxLength={120} value={l.material} onChange={(e) => set(i, "material", e.target.value)} />
|
|
|
|
|
</td>
|
|
|
|
|
<td>
|
|
|
|
|
<input className="in sm" aria-label={`Line ${i + 1} length`} maxLength={40} value={l.length} onChange={(e) => set(i, "length", e.target.value)} />
|
|
|
|
|
</td>
|
|
|
|
|
<td>
|
|
|
|
|
<input className="in sm" aria-label={`Line ${i + 1} drawing reference`} maxLength={60} value={l.dwg_ref} onChange={(e) => set(i, "dwg_ref", e.target.value)} />
|
|
|
|
|
</td>
|
|
|
|
|
<td>
|
|
|
|
|
<button className="btn sm ghost" type="button" aria-label={`Remove line ${i + 1}`} onClick={() => setLines((rows) => (rows ?? []).filter((_, j) => j !== i))}>
|
|
|
|
|
Remove
|
|
|
|
|
</button>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="sheet-acts start">
|
|
|
|
|
<button className="btn sm" type="button" onClick={() => setLines((rows) => [...(rows ?? []), { ...EMPTY_LINE }])}>
|
|
|
|
|
Add line
|
|
|
|
|
</button>
|
|
|
|
|
<span className="grow" />
|
|
|
|
|
<button className="btn ghost" type="button" onClick={() => setLines(null)}>
|
|
|
|
|
Cancel
|
|
|
|
|
</button>
|
|
|
|
|
<button className="btn pri" type="submit" disabled={busy}>
|
|
|
|
|
Save BOM
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-16 17:13:31 -06:00
|
|
|
if (p.status === "warehouse" && p.can.includes("edit_bom") && write && reload) {
|
|
|
|
|
return <PullTable p={p} write={write} reload={reload} busy={busy} />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const receiving = receiveFor === null ? undefined : p.bom.find((b) => b.item === receiveFor);
|
|
|
|
|
const viewed = viewFor === null ? undefined : p.bom.find((b) => b.item === viewFor);
|
|
|
|
|
const receive = async (item: number, body: Parameters<typeof packetsApi.receiveShort>[2]): Promise<string | null> => {
|
|
|
|
|
if (!write) return "Receiving is not available here.";
|
|
|
|
|
const r = await write((g) => packetsApi.receiveShort(p.id, item, body, g));
|
|
|
|
|
if (r.ok || r.err === undefined) return null;
|
|
|
|
|
return errorText(r.err);
|
|
|
|
|
};
|
|
|
|
|
|
2026-10-16 09:31:45 -06:00
|
|
|
return (
|
|
|
|
|
<div className="stack">
|
|
|
|
|
{editable ? (
|
|
|
|
|
<div className="sec-h flush">
|
|
|
|
|
<span className="small muted">This packet was made by hand. Clerical can change its lines while it is a draft.</span>
|
|
|
|
|
<span className="grow" />
|
|
|
|
|
<button className="btn sm" type="button" onClick={() => setLines(p.bom.length ? p.bom.map(toDraft) : [{ ...EMPTY_LINE }])}>
|
|
|
|
|
Edit lines
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
{p.bom.length ? (
|
|
|
|
|
<div className="tw">
|
|
|
|
|
<table>
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>Item</th>
|
|
|
|
|
<th className="num">Qty</th>
|
|
|
|
|
<th>Part ID</th>
|
|
|
|
|
<th>Description</th>
|
|
|
|
|
<th>Material</th>
|
|
|
|
|
<th>Length</th>
|
|
|
|
|
<th>Heat</th>
|
|
|
|
|
<th>Pulled</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{p.bom.map((b) => (
|
|
|
|
|
<tr key={b.id ?? b.item} className={b.na ? "muted" : undefined}>
|
|
|
|
|
<td>
|
|
|
|
|
<span className="balloon">{b.item}</span>
|
|
|
|
|
</td>
|
|
|
|
|
<td className="num">{show(b.qty)}</td>
|
|
|
|
|
<td className="mono small">{show(b.part_id)}</td>
|
|
|
|
|
<td>
|
|
|
|
|
{show(b.description)}
|
|
|
|
|
{b.dwg_ref ? <div className="sub">Dwg ref {b.dwg_ref}</div> : null}
|
|
|
|
|
</td>
|
|
|
|
|
<td className="small">{show(b.material)}</td>
|
|
|
|
|
<td className="small">{show(b.length)}</td>
|
2026-10-16 17:13:31 -06:00
|
|
|
<td className="mono small">
|
|
|
|
|
{show(b.heat)}
|
|
|
|
|
{b.mtr_asset_id ? (
|
|
|
|
|
<>
|
|
|
|
|
{" "}
|
|
|
|
|
<button type="button" className="lnk small" onClick={() => setViewFor(b.item)}>
|
|
|
|
|
MTR
|
|
|
|
|
</button>
|
|
|
|
|
</>
|
|
|
|
|
) : null}
|
|
|
|
|
</td>
|
2026-10-16 09:31:45 -06:00
|
|
|
<td>
|
2026-10-16 17:13:31 -06:00
|
|
|
{b.na && !b.short ? (
|
2026-10-16 09:31:45 -06:00
|
|
|
<span className="pill st-filed">N/A</span>
|
|
|
|
|
) : b.short ? (
|
2026-10-16 17:13:31 -06:00
|
|
|
<div className="stack tight">
|
|
|
|
|
<ShortChip s={b.short} />
|
|
|
|
|
{canReceive && write ? (
|
|
|
|
|
<button className="btn sm pri" type="button" disabled={busy} onClick={() => setReceiveFor(b.item)}>
|
|
|
|
|
Receive
|
|
|
|
|
</button>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
2026-10-16 09:31:45 -06:00
|
|
|
) : b.pulled ? (
|
|
|
|
|
<span className="pill st-ok">Pulled</span>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="muted small">Not yet</span>
|
|
|
|
|
)}
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="empty">No BOM lines on this packet.</div>
|
|
|
|
|
)}
|
2026-10-16 17:13:31 -06:00
|
|
|
{receiving ? (
|
|
|
|
|
<ShortSheet mode="receive" line={receiving} onClose={() => setReceiveFor(null)} onReceive={(body) => receive(receiving.item, body)} />
|
|
|
|
|
) : null}
|
|
|
|
|
{viewed?.mtr_asset_id ? (
|
|
|
|
|
<MtrViewer assetId={viewed.mtr_asset_id} title={`MTR · item ${viewed.item}${viewed.heat ? ` · ${viewed.heat}` : ""}`} onClose={() => setViewFor(null)} />
|
|
|
|
|
) : null}
|
2026-10-16 09:31:45 -06:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------- Work orders ---------- */
|
|
|
|
|
|
|
|
|
|
function woKey(w: Pick<WorkOrder, "wo_base" | "lot" | "split" | "sub">): string {
|
|
|
|
|
return `${w.wo_base}/${w.lot}/${w.split}/${w.sub}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Qr({ text }: { text: string }) {
|
|
|
|
|
const ref = useRef<HTMLCanvasElement>(null);
|
|
|
|
|
const [failed, setFailed] = useState(false);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const c = ref.current;
|
|
|
|
|
if (!c) return;
|
|
|
|
|
let ok = true;
|
|
|
|
|
try {
|
|
|
|
|
QRCode.toCanvas(c, text, { width: 128, margin: 1, errorCorrectionLevel: "M" }).catch(() => {
|
|
|
|
|
if (ok) setFailed(true);
|
|
|
|
|
});
|
|
|
|
|
} catch {
|
|
|
|
|
setFailed(true);
|
|
|
|
|
}
|
|
|
|
|
return () => {
|
|
|
|
|
ok = false;
|
|
|
|
|
};
|
|
|
|
|
}, [text]);
|
|
|
|
|
return (
|
|
|
|
|
<div className="code">
|
|
|
|
|
{failed ? <span className="small muted">No QR</span> : <canvas ref={ref} role="img" aria-label={`QR code ${text}`} />}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** The work orders, and one QR code per work order key. */
|
|
|
|
|
export function WorkOrdersTab({ p }: { p: PacketRead }) {
|
|
|
|
|
if (!p.work_orders.length) return <div className="empty">No work orders on this packet.</div>;
|
|
|
|
|
return (
|
|
|
|
|
<div className="stack">
|
|
|
|
|
<div className="tw">
|
|
|
|
|
<table>
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>WO</th>
|
|
|
|
|
<th>Lot</th>
|
|
|
|
|
<th>Split</th>
|
|
|
|
|
<th>Sub</th>
|
|
|
|
|
<th>Seq</th>
|
|
|
|
|
<th>Operation</th>
|
|
|
|
|
<th className="num">Qty</th>
|
|
|
|
|
<th className="num">Target hrs</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{p.work_orders.map((w) => (
|
|
|
|
|
<tr key={w.id ?? woKey(w)} className={w.gone_at ? "muted" : undefined}>
|
|
|
|
|
<td className="mono">{w.wo_base}</td>
|
|
|
|
|
<td className="mono">{w.lot}</td>
|
|
|
|
|
<td className="mono">{w.split}</td>
|
|
|
|
|
<td className="mono">{w.sub}</td>
|
|
|
|
|
<td className="mono">{show(w.seq)}</td>
|
|
|
|
|
<td>{show(w.op)}</td>
|
|
|
|
|
<td className="num">{show(w.qty)}</td>
|
|
|
|
|
<td className="num">{show(w.target_hrs)}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="qrs">
|
|
|
|
|
{p.work_orders.map((w) => {
|
|
|
|
|
const k = woKey(w);
|
|
|
|
|
return (
|
|
|
|
|
<div key={w.id ?? k} className="qr">
|
|
|
|
|
<Qr text={k} />
|
|
|
|
|
<div>
|
|
|
|
|
<div className="wo">{w.wo_base}</div>
|
|
|
|
|
<div className="mono small">{k}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------- Documents ---------- */
|
|
|
|
|
|
|
|
|
|
const DOC_KINDS: ReadonlyArray<[string, string]> = [
|
|
|
|
|
["mtr", "Material test report"],
|
|
|
|
|
["nde", "NDE report"],
|
|
|
|
|
["procedure", "Procedure (WPS / PQR)"],
|
|
|
|
|
["photo", "Photo"],
|
|
|
|
|
["other", "Other"],
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const kindLabel = (k: string) => DOC_KINDS.find(([v]) => v === k)?.[1] ?? k;
|
|
|
|
|
|
|
|
|
|
/** Documents attached to the packet. Clerical and Q.C. add and remove them. */
|
|
|
|
|
export function DocumentsTab({ p, mutate, busy }: { p: PacketRead; mutate: Mutate; busy: boolean }) {
|
|
|
|
|
const canAdd = p.can.includes("doc_add");
|
|
|
|
|
const canRemove = p.can.includes("doc_remove");
|
|
|
|
|
const fileRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
const [kind, setKind] = useState("mtr");
|
|
|
|
|
const [title, setTitle] = useState("");
|
|
|
|
|
const [file, setFile] = useState<File | null>(null);
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
|
const [uploading, setUploading] = useState(false);
|
|
|
|
|
const [confirm, setConfirm] = useState<number | null>(null);
|
|
|
|
|
|
|
|
|
|
const add = async (e: FormEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
setError(null);
|
|
|
|
|
if (!file) {
|
|
|
|
|
setError("Choose a file.");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
setUploading(true);
|
|
|
|
|
try {
|
2026-10-16 17:13:31 -06:00
|
|
|
const asset = await assetsApi.upload(file, { packet_id: p.id, purpose: "document" });
|
2026-10-16 09:31:45 -06:00
|
|
|
const ok = await mutate((g) => packetsApi.addDoc(p.id, { asset_id: asset.id, kind, title: title.trim() || file.name, ...g }), { ok: "Document added" });
|
|
|
|
|
if (ok) {
|
|
|
|
|
setTitle("");
|
|
|
|
|
setFile(null);
|
|
|
|
|
if (fileRef.current) fileRef.current.value = "";
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
setError(errorText(err));
|
|
|
|
|
} finally {
|
|
|
|
|
setUploading(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="stack">
|
|
|
|
|
{p.docs.length ? (
|
|
|
|
|
<div className="tw">
|
|
|
|
|
<table>
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>Title</th>
|
|
|
|
|
<th>Kind</th>
|
|
|
|
|
<th>Added</th>
|
|
|
|
|
<th>By</th>
|
|
|
|
|
<th />
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{p.docs.map((d) => (
|
|
|
|
|
<tr key={d.id}>
|
|
|
|
|
<td>
|
|
|
|
|
<a className="lnk" href={d.url} target="_blank" rel="noopener noreferrer">
|
|
|
|
|
{d.title || "Untitled"}
|
|
|
|
|
</a>
|
|
|
|
|
</td>
|
|
|
|
|
<td className="small">{kindLabel(d.kind)}</td>
|
|
|
|
|
<td className="small mono">{fmtTime(d.added_at)}</td>
|
|
|
|
|
<td className="small">{d.added_by?.display_name ?? "-"}</td>
|
|
|
|
|
<td className="nowrap">
|
|
|
|
|
{canRemove ? (
|
|
|
|
|
confirm === d.id ? (
|
|
|
|
|
<>
|
|
|
|
|
<button
|
|
|
|
|
className="btn sm rejb"
|
|
|
|
|
type="button"
|
|
|
|
|
disabled={busy}
|
|
|
|
|
onClick={() => void mutate((g) => packetsApi.removeDoc(p.id, d.id, g), { ok: "Document removed" }).then(() => setConfirm(null))}
|
|
|
|
|
>
|
|
|
|
|
Confirm remove
|
|
|
|
|
</button>{" "}
|
|
|
|
|
<button className="btn sm ghost" type="button" onClick={() => setConfirm(null)}>
|
|
|
|
|
Cancel
|
|
|
|
|
</button>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<button className="btn sm ghost" type="button" onClick={() => setConfirm(d.id)}>
|
|
|
|
|
Remove
|
|
|
|
|
</button>
|
|
|
|
|
)
|
|
|
|
|
) : null}
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="empty slim">No documents on this packet.</div>
|
|
|
|
|
)}
|
|
|
|
|
{canAdd ? (
|
|
|
|
|
<form className="panel stack" onSubmit={add} noValidate aria-labelledby="docadd-h">
|
|
|
|
|
<h3 id="docadd-h">Add a document</h3>
|
|
|
|
|
<div className="frow">
|
|
|
|
|
<label className="f">
|
|
|
|
|
File (PDF, PNG or JPEG)
|
|
|
|
|
<input
|
|
|
|
|
ref={fileRef}
|
|
|
|
|
className="in"
|
|
|
|
|
type="file"
|
|
|
|
|
accept="application/pdf,image/png,image/jpeg"
|
|
|
|
|
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
|
|
|
|
/>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="f">
|
|
|
|
|
Kind
|
|
|
|
|
<select className="in" value={kind} onChange={(e) => setKind(e.target.value)}>
|
|
|
|
|
{DOC_KINDS.map(([v, l]) => (
|
|
|
|
|
<option key={v} value={v}>
|
|
|
|
|
{l}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="f">
|
|
|
|
|
Title
|
|
|
|
|
<input className="in" value={title} maxLength={160} onChange={(e) => setTitle(e.target.value)} />
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
{error ? (
|
|
|
|
|
<div className="callout warn flush" role="alert">
|
|
|
|
|
{error}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
<div>
|
|
|
|
|
<button className="btn pri" type="submit" disabled={busy || uploading}>
|
|
|
|
|
{uploading ? "Uploading" : "Add document"}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ---------- History ---------- */
|
|
|
|
|
|
|
|
|
|
/** GET /log, newest first. IT edits and signatures are marked. */
|
|
|
|
|
export function HistoryTab({ p }: { p: PacketRead }) {
|
|
|
|
|
const [load] = useLoad((s) => packetsApi.log(p.id, { limit: 300 }, s), `${p.id}:${p.version}`);
|
|
|
|
|
if (load.state === "loading") return <div className="empty slim">Loading</div>;
|
|
|
|
|
if (load.state === "error") return <div className="callout warn">{load.message}</div>;
|
|
|
|
|
const rows = load.data;
|
|
|
|
|
if (!rows.length) return <div className="empty slim">Nothing recorded yet.</div>;
|
|
|
|
|
return (
|
|
|
|
|
<div className="tw">
|
|
|
|
|
<table>
|
|
|
|
|
<thead>
|
|
|
|
|
<tr>
|
|
|
|
|
<th>When</th>
|
|
|
|
|
<th>Who</th>
|
|
|
|
|
<th>What</th>
|
|
|
|
|
<th>Note</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>
|
|
|
|
|
{rows.map((r) => (
|
|
|
|
|
<tr key={r.id}>
|
|
|
|
|
<td className="small mono nowrap">{fmtTime(r.at)}</td>
|
|
|
|
|
<td className="small">
|
|
|
|
|
{r.by?.display_name ?? "System"}
|
|
|
|
|
{r.role ? <div className="sub">{r.role}</div> : null}
|
|
|
|
|
</td>
|
|
|
|
|
<td>
|
|
|
|
|
{r.action}
|
|
|
|
|
{r.signed ? <span className="pill st-ok">Signed</span> : null}
|
|
|
|
|
{r.it_reason ? <span className="pill st-warehouse">IT edit</span> : null}
|
|
|
|
|
</td>
|
|
|
|
|
<td className="small">
|
|
|
|
|
{r.note ?? ""}
|
|
|
|
|
{r.it_reason ? <div className="sub">IT reason: {r.it_reason}</div> : null}
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|