Phase 5: Supervisor and Stall screens, weld map, heat table, clock and CO verification
The shop at parity with the old build. Supervisor screen (department chips, CO headline, numbers, Change orders
on hold, station grid with clocks and CO tags, boards) and Stall screen (station picker, hero, My packets,
Submitted). Shop steps on the packet page: assign with station, welder picker and stamp; pull back; Start, Pause,
Resume; Submit with a PIN signature and the missing steps as links; review with Send to Q.C., Return to station
and Skip Q.C. route; a lead's read-only review. Weld map (pdf.js viewer with pan, pinch, zoom, full screen; marks
with leader lines; weld sheet; weld record with field CAS; carried marks). Heat map (heat table placed, dragged,
sized, removed; legacy amber tags; borrowed heat map read-only). Checklist tab with templates from settings.
VOID stamps on voided pages. Change orders: CO-n labels, supervisor verify with signature, approval copies the
heat table to the new sheet and the BOM merge keeps the shop's values. Server clock of record with VISUAL ticket
hours beside it. Presence per packet over SSE. Migration 0006 (bfx, bfy, carried_from, checklist_templates).
New endpoints /api/shop/board, /api/shop/stations, /api/shop/stall, /api/departments/{code}/people.
Tests: policy oracle for every shop action, role and status; clock arithmetic; concurrent inserts; CO apply;
frontend geometry, heat table, clock, checklist, weld sheet, screens; seven Playwright scenarios. Docs updated.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fmtClock, fmtHours, liveElapsed } from "./clock";
|
||||
|
||||
describe("clock", () => {
|
||||
it("fmtClock pads and counts hours", () => {
|
||||
expect(fmtClock(0)).toBe("00:00:00");
|
||||
expect(fmtClock(61_000)).toBe("00:01:01");
|
||||
expect(fmtClock(5_400_999)).toBe("01:30:00");
|
||||
expect(fmtClock(-5)).toBe("00:00:00");
|
||||
expect(fmtClock(360_000_000)).toBe("100:00:00");
|
||||
});
|
||||
|
||||
it("fmtHours has one decimal", () => {
|
||||
expect(fmtHours(5_400_000)).toBe("1.5 h");
|
||||
expect(fmtHours(0)).toBe("0.0 h");
|
||||
});
|
||||
|
||||
it("liveElapsed adds the time since the fetch only while running", () => {
|
||||
const fetched = 1_000_000;
|
||||
expect(liveElapsed({ elapsed_ms: 60_000, running: false, running_since: null }, fetched, fetched + 5000)).toBe(60_000);
|
||||
expect(liveElapsed({ elapsed_ms: 60_000, running: true, running_since: "2026-10-16T08:00:00Z" }, fetched, fetched + 5000)).toBe(65_000);
|
||||
expect(liveElapsed({ elapsed_ms: 60_000, running_since: "2026-10-16T08:00:00Z" }, fetched, fetched + 2000)).toBe(62_000);
|
||||
// A client clock behind the fetch time never takes time away.
|
||||
expect(liveElapsed({ elapsed_ms: 60_000, running: true }, fetched, fetched - 9000)).toBe(60_000);
|
||||
expect(liveElapsed(null, fetched)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
// The station clock on screen. The server is the clock of record: every stored value comes from the read model.
|
||||
// The client only adds the time since the fetch while the clock runs, for the ticking display.
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface ClockLike {
|
||||
elapsed_ms: number;
|
||||
running?: boolean | undefined;
|
||||
running_since?: string | null | undefined;
|
||||
}
|
||||
|
||||
/** HH:MM:SS. Hours keep counting past 99. */
|
||||
export function fmtClock(ms: number): string {
|
||||
const t = Math.max(0, Math.floor((Number(ms) || 0) / 1000));
|
||||
const h = Math.floor(t / 3600);
|
||||
const m = Math.floor((t % 3600) / 60);
|
||||
const s = t % 60;
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Hours with one decimal: "1.5 h". */
|
||||
export function fmtHours(ms: number): string {
|
||||
return `${((Number(ms) || 0) / 3_600_000).toFixed(1)} h`;
|
||||
}
|
||||
|
||||
export function isRunning(c: ClockLike | null | undefined): boolean {
|
||||
if (!c) return false;
|
||||
return c.running ?? !!c.running_since;
|
||||
}
|
||||
|
||||
/**
|
||||
* The elapsed time to show now. `fetchedAt` is the client time when the packet arrived; the server's elapsed_ms
|
||||
* was live at that moment. While running, the time since the fetch is added. Never below the server's value.
|
||||
*/
|
||||
export function liveElapsed(c: ClockLike | null | undefined, fetchedAt: number, now: number = Date.now()): number {
|
||||
if (!c) return 0;
|
||||
const base = Math.max(0, Number(c.elapsed_ms) || 0);
|
||||
if (!isRunning(c)) return base;
|
||||
return base + Math.max(0, now - fetchedAt);
|
||||
}
|
||||
|
||||
/** Re-renders every second while `running`. Returns the current time. */
|
||||
export function useTicker(running: boolean, everyMs = 1000): number {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (!running) return;
|
||||
setNow(Date.now());
|
||||
const t = window.setInterval(() => setNow(Date.now()), everyMs);
|
||||
return () => window.clearInterval(t);
|
||||
}, [running, everyMs]);
|
||||
return now;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { arrowRotation, bpos, clamp01, fitView, markRadius, toFraction, toPixels, zoomAt, type View } from "./geometry";
|
||||
|
||||
describe("geometry", () => {
|
||||
it("clamp01 keeps a margin", () => {
|
||||
expect(clamp01(-1)).toBe(0.01);
|
||||
expect(clamp01(2)).toBe(0.99);
|
||||
expect(clamp01(0.5)).toBe(0.5);
|
||||
});
|
||||
|
||||
it("bpos defaults right and up, left near the right edge, down near the top", () => {
|
||||
const mid = bpos({ fx: 0.5, fy: 0.5 });
|
||||
expect(mid.bfx).toBeCloseTo(0.55);
|
||||
expect(mid.bfy).toBeCloseTo(0.43);
|
||||
const right = bpos({ fx: 0.9, fy: 0.5 });
|
||||
expect(right.bfx).toBeCloseTo(0.85);
|
||||
const top = bpos({ fx: 0.5, fy: 0.05 });
|
||||
expect(top.bfy).toBeCloseTo(0.12);
|
||||
const corner = bpos({ fx: 0.99, fy: 0.0 });
|
||||
expect(corner.bfx).toBeCloseTo(0.94);
|
||||
expect(corner.bfy).toBeCloseTo(0.07);
|
||||
expect(bpos({ fx: 0.995, fy: 0.5 }).bfx).toBeLessThan(0.99);
|
||||
});
|
||||
|
||||
it("bpos keeps a stored label", () => {
|
||||
expect(bpos({ fx: 0.5, fy: 0.5, bfx: 0.2, bfy: 0.3 })).toEqual({ bfx: 0.2, bfy: 0.3 });
|
||||
});
|
||||
|
||||
it("fractions to pixels and back on a few transforms", () => {
|
||||
const frame = { left: 100, top: 50 };
|
||||
const views: View[] = [
|
||||
{ x: 0, y: 0, z: 1, w: 1000, h: 700 },
|
||||
{ x: 40, y: -20, z: 0.5, w: 4200, h: 2970 },
|
||||
{ x: -300, y: 120, z: 2.75, w: 2000, h: 1400 },
|
||||
];
|
||||
for (const v of views) {
|
||||
for (const [fx, fy] of [
|
||||
[0, 0],
|
||||
[0.25, 0.75],
|
||||
[1, 1],
|
||||
] as const) {
|
||||
const { px, py } = toPixels(fx, fy, v);
|
||||
const back = toFraction(px + frame.left, py + frame.top, frame, v);
|
||||
expect(back.fx).toBeCloseTo(fx, 9);
|
||||
expect(back.fy).toBeCloseTo(fy, 9);
|
||||
}
|
||||
}
|
||||
expect(toPixels(0.5, 0.5, views[0]!)).toEqual({ px: 500, py: 350 });
|
||||
});
|
||||
|
||||
it("arrowRotation points from the bubble to the anchor", () => {
|
||||
expect(arrowRotation(0.5, 0.5, 0.4, 0.5, 1.5)).toBeCloseTo(0);
|
||||
expect(arrowRotation(0.4, 0.5, 0.5, 0.5, 1.5)).toBeCloseTo(180);
|
||||
expect(arrowRotation(0.5, 0.6, 0.5, 0.5, 1.5)).toBeCloseTo(90);
|
||||
});
|
||||
|
||||
it("markRadius has a floor", () => {
|
||||
expect(markRadius(1000)).toBe(18);
|
||||
expect(markRadius(4200)).toBe(28);
|
||||
});
|
||||
|
||||
it("fitView centres and zoomAt keeps the point under the cursor", () => {
|
||||
const v = fitView(800, 600, 1600, 800);
|
||||
expect(v.z).toBe(0.5);
|
||||
expect(v.y).toBe(100);
|
||||
const z = zoomAt(v, 2, 400, 300, 0.1);
|
||||
const before = toFraction(400, 300, { left: 0, top: 0 }, v);
|
||||
const after = toFraction(400, 300, { left: 0, top: 0 }, z);
|
||||
expect(after.fx).toBeCloseTo(before.fx);
|
||||
expect(after.fy).toBeCloseTo(before.fy);
|
||||
expect(zoomAt(v, 100, 0, 0, 0.1).z).toBe(6);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
// Mark positions on a drawing page. Every position is a fraction of the page (0..1), so a mark stays on its
|
||||
// joint whatever the zoom or the bitmap size. A port of the old build's clamp01, bpos, arrowRot and toNorm.
|
||||
|
||||
/** The viewer transform: the stage is translated by (x, y) and scaled by z. w and h are the page size at z = 1. */
|
||||
export interface View {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/** A point on the page, as fractions. */
|
||||
export interface Frac {
|
||||
fx: number;
|
||||
fy: number;
|
||||
}
|
||||
|
||||
/** Marks keep a small margin from the page edge. */
|
||||
export function clamp01(v: number): number {
|
||||
return Math.max(0.01, Math.min(0.99, v));
|
||||
}
|
||||
|
||||
/** Plain 0..1 clamp, for drops and table corners. */
|
||||
export function clamp(v: number, lo = 0, hi = 1): number {
|
||||
return Math.max(lo, Math.min(hi, v));
|
||||
}
|
||||
|
||||
export interface Placed {
|
||||
fx: number | null;
|
||||
fy: number | null;
|
||||
bfx?: number | null | undefined;
|
||||
bfy?: number | null | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the label (bubble) sits. A stored bfx, bfy wins. Otherwise the old default: a little right of the arrow
|
||||
* point (left near the right edge) and a little above it (below near the top edge).
|
||||
*/
|
||||
export function bpos(m: Placed): { bfx: number; bfy: number } {
|
||||
const fx = m.fx ?? 0.5;
|
||||
const fy = m.fy ?? 0.5;
|
||||
return {
|
||||
bfx: m.bfx ?? clamp01(fx + (fx > 0.85 ? -0.05 : 0.05)),
|
||||
bfy: m.bfy ?? clamp01(fy + (fy < 0.12 ? 0.07 : -0.07)),
|
||||
};
|
||||
}
|
||||
|
||||
/** A client point to page fractions. `frame` is the viewer's bounding box. Not clamped. */
|
||||
export function toFraction(clientX: number, clientY: number, frame: { left: number; top: number }, view: View): Frac {
|
||||
if (!view.w || !view.h || !view.z) return { fx: NaN, fy: NaN };
|
||||
return {
|
||||
fx: (clientX - frame.left - view.x) / view.z / view.w,
|
||||
fy: (clientY - frame.top - view.y) / view.z / view.h,
|
||||
};
|
||||
}
|
||||
|
||||
/** Page fractions to pixels inside the viewer frame. The inverse of toFraction. */
|
||||
export function toPixels(fx: number, fy: number, view: View): { px: number; py: number } {
|
||||
return { px: view.x + fx * view.w * view.z, py: view.y + fy * view.h * view.z };
|
||||
}
|
||||
|
||||
/** True when the fraction lies on the page. */
|
||||
export function onPage(f: Frac): boolean {
|
||||
return f.fx >= 0 && f.fx <= 1 && f.fy >= 0 && f.fy <= 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* The arrowhead's rotation in degrees: it points from the bubble (bx, by) to the anchor (ax, ay).
|
||||
* The fractions are scaled by the page aspect (width / height) so the angle is right on a non-square page.
|
||||
*/
|
||||
export function arrowRotation(ax: number, ay: number, bx: number, by: number, aspect: number): number {
|
||||
return (Math.atan2(ay - by, (ax - bx) * (aspect || 1.5)) * 180) / Math.PI;
|
||||
}
|
||||
|
||||
/** The weld circle radius in page pixels for the printed raster. */
|
||||
export function markRadius(width: number): number {
|
||||
return Math.max(18, width / 150);
|
||||
}
|
||||
|
||||
/** The fit transform: the whole page in the frame, centred. */
|
||||
export function fitView(frameW: number, frameH: number, w: number, h: number): View {
|
||||
const z = Math.min(frameW / w, frameH / h) || 1;
|
||||
return { x: (frameW - w * z) / 2, y: (frameH - h * z) / 2, z, w, h };
|
||||
}
|
||||
|
||||
/** Zoom by factor f around the frame point (cx, cy), clamped between minZ and maxZ. */
|
||||
export function zoomAt(view: View, f: number, cx: number, cy: number, minZ: number, maxZ = 6): View {
|
||||
const nz = Math.max(minZ, Math.min(maxZ, view.z * f));
|
||||
const k = nz / view.z;
|
||||
return { ...view, x: cx - (cx - view.x) * k, y: cy - (cy - view.y) * k, z: nz };
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { clampCorner, fontPx, heatGroups, heatRows, stepSize } from "./heatTable";
|
||||
|
||||
const rows = (n: number) => Array.from({ length: n }, (_, i) => i + 1);
|
||||
|
||||
describe("heatTable", () => {
|
||||
it("heatRows shows N/A and leaves a missing heat empty", () => {
|
||||
expect(heatRows([{ item: 1, heat: "H123" }, { item: 2, na: true }, { item: 3 }])).toEqual([
|
||||
{ item: "1", heat: "H123", na: false },
|
||||
{ item: "2", heat: "N/A", na: true },
|
||||
{ item: "3", heat: "", na: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it("groups at most 12 rows, evenly", () => {
|
||||
expect(heatGroups(rows(12)).map((g) => g.length)).toEqual([12]);
|
||||
expect(heatGroups(rows(13)).map((g) => g.length)).toEqual([7, 6]);
|
||||
expect(heatGroups(rows(24)).map((g) => g.length)).toEqual([12, 12]);
|
||||
expect(heatGroups(rows(25)).map((g) => g.length)).toEqual([9, 9, 7]);
|
||||
expect(heatGroups([])).toEqual([[]]);
|
||||
});
|
||||
|
||||
it("stepSize moves 0.15 and clamps", () => {
|
||||
expect(stepSize(1, 1)).toBe(1.15);
|
||||
expect(stepSize(1.15, 1)).toBe(1.3);
|
||||
expect(stepSize(1, -1)).toBe(0.85);
|
||||
expect(stepSize(0.6, -1)).toBe(0.6);
|
||||
expect(stepSize(0.7, -1)).toBe(0.6);
|
||||
expect(stepSize(2.1, 1)).toBe(2.1);
|
||||
expect(stepSize(2.0, 1)).toBe(2.1);
|
||||
});
|
||||
|
||||
it("fontPx follows the page width and size", () => {
|
||||
expect(fontPx(4000, 1)).toBe(30);
|
||||
expect(fontPx(4000, 2)).toBe(60);
|
||||
});
|
||||
|
||||
it("clampCorner keeps the corner on the page", () => {
|
||||
expect(clampCorner(-0.2, 1.5)).toEqual({ x: 0, y: 0.97 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
// The heat table: ITEM | HEAT NO., placed on a drawing sheet. A port of the old build's heattable.js.
|
||||
// The table always shows the live BOM heats, so a heat fixed in Materials updates the drawing with no re-placing.
|
||||
|
||||
export const HT_ROWS = 12;
|
||||
export const HT_FONT = 0.0075;
|
||||
export const HT_MIN = 0.6;
|
||||
export const HT_MAX = 2.1;
|
||||
export const HT_STEP = 0.15;
|
||||
export const HEAT_COLOR = "#b07800";
|
||||
export const HEAT_MISSING = "#b00020";
|
||||
export const HEAT_NA = "#6b5a3a";
|
||||
/** The red dash a missing heat shows. */
|
||||
export const NO_HEAT = "—";
|
||||
|
||||
export interface HeatRow {
|
||||
item: string;
|
||||
/** "N/A" on an N/A line, "" when the heat is missing. */
|
||||
heat: string;
|
||||
na: boolean;
|
||||
}
|
||||
|
||||
export interface HeatBomLine {
|
||||
item: number | string;
|
||||
heat?: string | null | undefined;
|
||||
na?: boolean | null | undefined;
|
||||
}
|
||||
|
||||
export function heatRows(bom: readonly HeatBomLine[]): HeatRow[] {
|
||||
return bom.map((b) => ({ item: String(b.item), heat: b.heat || (b.na ? "N/A" : ""), na: !!b.na }));
|
||||
}
|
||||
|
||||
/** At most 12 rows per group, the groups as even as they can be. Always at least one (maybe empty) group. */
|
||||
export function heatGroups<T>(rows: readonly T[]): T[][] {
|
||||
const n = rows.length;
|
||||
const per = n > HT_ROWS ? Math.ceil(n / Math.ceil(n / HT_ROWS)) : HT_ROWS;
|
||||
const out: T[][] = [];
|
||||
for (let i = 0; i < n; i += per) out.push(rows.slice(i, i + per));
|
||||
return out.length ? out : [[]];
|
||||
}
|
||||
|
||||
/** The table's font size in page pixels. */
|
||||
export function fontPx(pageWidth: number, s: number): number {
|
||||
return pageWidth * HT_FONT * s;
|
||||
}
|
||||
|
||||
/** One A- (dir -1) or A+ (dir 1) step, clamped to 0.6..2.1 and rounded to 2 decimals. */
|
||||
export function stepSize(s: number, dir: number): number {
|
||||
const v = Math.max(HT_MIN, Math.min(HT_MAX, (s || 1) + HT_STEP * Math.sign(dir)));
|
||||
return Math.round(v * 100) / 100;
|
||||
}
|
||||
|
||||
/** The table's top-left corner stays on the page. */
|
||||
export function clampCorner(x: number, y: number): { x: number; y: number } {
|
||||
return { x: Math.max(0, Math.min(0.97, x)), y: Math.max(0, Math.min(0.97, y)) };
|
||||
}
|
||||
|
||||
/** Draws the table into a page raster (print, full-screen copy). A port of htDrawCanvas. */
|
||||
export function drawHeatTable(
|
||||
c: CanvasRenderingContext2D,
|
||||
placement: { x: number; y: number; s: number },
|
||||
rows: readonly HeatRow[],
|
||||
W: number,
|
||||
H: number,
|
||||
): void {
|
||||
const fs = fontPx(W, placement.s || 1);
|
||||
const padX = fs * 0.5;
|
||||
const padY = fs * 0.2;
|
||||
const rowH = fs * 1.15 + padY * 2;
|
||||
const bw = Math.max(1.5, fs * 0.08);
|
||||
const col = HEAT_COLOR;
|
||||
const groups = heatGroups(rows);
|
||||
c.save();
|
||||
c.textBaseline = "middle";
|
||||
const thF = `bold ${fs * 0.8}px Arial`;
|
||||
const tdF = `bold ${fs}px Arial`;
|
||||
const monoF = `bold ${fs}px "Courier New", monospace`;
|
||||
const widths = groups.map((g) => {
|
||||
c.font = thF;
|
||||
let wi = c.measureText("ITEM").width;
|
||||
let wh = c.measureText("HEAT NO.").width;
|
||||
c.font = tdF;
|
||||
for (const r of g) wi = Math.max(wi, c.measureText(r.item).width);
|
||||
c.font = monoF;
|
||||
for (const r of g) wh = Math.max(wh, c.measureText(r.heat || NO_HEAT).width);
|
||||
return [wi + padX * 2, wh + padX * 2] as const;
|
||||
});
|
||||
const totalW = widths.reduce((a, [i, h]) => a + i + h, 0);
|
||||
const capH = rowH;
|
||||
const maxRows = Math.max(...groups.map((g) => g.length));
|
||||
const totalH = capH + rowH * (maxRows + 1);
|
||||
const x0 = placement.x * W;
|
||||
const y0 = placement.y * H;
|
||||
c.fillStyle = "#fff";
|
||||
c.fillRect(x0, y0, totalW, totalH);
|
||||
c.fillStyle = col;
|
||||
c.fillRect(x0, y0, totalW, capH);
|
||||
c.fillStyle = "#fff";
|
||||
c.font = `bold ${fs * 0.85}px Arial`;
|
||||
c.textAlign = "center";
|
||||
c.fillText("HEAT NUMBERS", x0 + totalW / 2, y0 + capH / 2);
|
||||
let gx = x0;
|
||||
groups.forEach((g, gi) => {
|
||||
const [wi, wh] = widths[gi] ?? [0, 0];
|
||||
let y = y0 + capH;
|
||||
c.fillStyle = "#f3e2b8";
|
||||
c.fillRect(gx, y, wi + wh, rowH);
|
||||
c.fillStyle = "#3d2a00";
|
||||
c.font = thF;
|
||||
c.textAlign = "center";
|
||||
c.fillText("ITEM", gx + wi / 2, y + rowH / 2);
|
||||
c.fillText("HEAT NO.", gx + wi + wh / 2, y + rowH / 2);
|
||||
c.strokeStyle = col;
|
||||
c.lineWidth = bw;
|
||||
c.strokeRect(gx, y, wi, rowH);
|
||||
c.strokeRect(gx + wi, y, wh, rowH);
|
||||
y += rowH;
|
||||
for (const r of g) {
|
||||
c.font = tdF;
|
||||
c.fillStyle = "#2b1d00";
|
||||
c.textAlign = "center";
|
||||
c.fillText(r.item, gx + wi / 2, y + rowH / 2);
|
||||
c.font = monoF;
|
||||
c.textAlign = "left";
|
||||
c.fillStyle = r.heat ? (r.na ? HEAT_NA : "#2b1d00") : HEAT_MISSING;
|
||||
c.fillText(r.heat || NO_HEAT, gx + wi + padX, y + rowH / 2);
|
||||
c.strokeRect(gx, y, wi, rowH);
|
||||
c.strokeRect(gx + wi, y, wh, rowH);
|
||||
y += rowH;
|
||||
}
|
||||
gx += wi + wh;
|
||||
});
|
||||
c.lineWidth = bw * 2;
|
||||
c.strokeStyle = col;
|
||||
c.strokeRect(x0, y0, totalW, totalH);
|
||||
c.restore();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { voidText } from "./voidStamp";
|
||||
|
||||
describe("voidText", () => {
|
||||
it("names the CO, the new sheet and the date", () => {
|
||||
const at = new Date(2026, 9, 16, 12).toISOString();
|
||||
expect(voidText({ void: { co_id: 9, co_no: 2, at, new_n: 1001, new: { n: 1001, dwg: "CI-DWG-1", rev: "B" } } })).toBe(
|
||||
"VOID - superseded by CO-2 (CI-DWG-1 Rev B) 10/16/2026",
|
||||
);
|
||||
});
|
||||
|
||||
it("finds the new sheet in the pages when the read model gives only new_n", () => {
|
||||
const at = new Date(2026, 0, 5, 12).toISOString();
|
||||
expect(voidText({ void: { co_id: 9, co_no: 3, at, new_n: 4 } }, [{ n: 4, dwg: null, rev: null }])).toBe(
|
||||
"VOID - superseded by CO-3 (new sheet Rev ?) 1/5/2026",
|
||||
);
|
||||
});
|
||||
|
||||
it("is empty for a live page", () => {
|
||||
expect(voidText({ void: null })).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
// The VOID stamp on a superseded drawing sheet. A port of the old build's voidText and stampVoidCanvas.
|
||||
import type { PacketPage } from "../api/types";
|
||||
|
||||
/** "10/16/2026", the shop's date format. */
|
||||
function usDate(iso: string | null | undefined): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
return `${d.getMonth() + 1}/${d.getDate()}/${d.getFullYear()}`;
|
||||
}
|
||||
|
||||
/** `VOID - superseded by CO-2 (dwg Rev B) 10/16/2026`. Empty for a live page. */
|
||||
export function voidText(page: Pick<PacketPage, "void">, pages: ReadonlyArray<Pick<PacketPage, "n" | "dwg" | "rev">> = []): string {
|
||||
const v = page.void;
|
||||
if (!v) return "";
|
||||
const co = v.co_no !== undefined && v.co_no !== null ? `CO-${v.co_no}` : "a change order";
|
||||
const nw = v.new ?? (v.new_n !== null ? pages.find((x) => x.n === v.new_n) : undefined) ?? null;
|
||||
const sheet = nw ? ` (${nw.dwg || "new sheet"} Rev ${nw.rev || "?"})` : "";
|
||||
const day = usDate(v.at);
|
||||
return `VOID - superseded by ${co}${sheet}${day ? ` ${day}` : ""}`;
|
||||
}
|
||||
|
||||
/** Stamps a page raster: a big diagonal VOID at low opacity, the text under it, a red border. */
|
||||
export function stampVoid(canvas: HTMLCanvasElement, text: string): HTMLCanvasElement {
|
||||
const c = canvas.getContext("2d");
|
||||
if (!c) return canvas;
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
c.save();
|
||||
c.translate(w / 2, h / 2);
|
||||
c.rotate(-Math.atan2(h, w) * 0.85);
|
||||
c.textAlign = "center";
|
||||
c.textBaseline = "middle";
|
||||
c.font = `bold ${Math.round(w / 5.5)}px Arial`;
|
||||
c.lineWidth = Math.max(4, w / 260);
|
||||
c.strokeStyle = "rgba(200,0,0,.55)";
|
||||
c.fillStyle = "rgba(220,0,0,.3)";
|
||||
c.fillText("VOID", 0, 0);
|
||||
c.strokeText("VOID", 0, 0);
|
||||
c.font = `bold ${Math.round(w / 55)}px Arial`;
|
||||
c.fillStyle = "rgba(200,0,0,.8)";
|
||||
c.fillText(text, 0, w / 9);
|
||||
c.restore();
|
||||
const lw = Math.max(6, w / 200);
|
||||
c.strokeStyle = "rgba(200,0,0,.7)";
|
||||
c.lineWidth = lw;
|
||||
c.strokeRect(lw / 2, lw / 2, w - lw, h - lw);
|
||||
return canvas;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// The weld vocabulary from the old build (app.js WELD_TYPES, PROCESSES). The server checks the same lists.
|
||||
import type { PacketRead, Weld } from "../api/types";
|
||||
|
||||
export const WELD_TYPES = ["Fillet", "Groove", "Fill", "Seal", "Tack"] as const;
|
||||
export const PROCESSES = ["GMAW", "FCAW", "SMAW", "GTAW", "SAW"] as const;
|
||||
export const DEFAULT_SIZE = '5/16"';
|
||||
export const DEFAULT_TYPE = "Fillet";
|
||||
export const DEFAULT_PROCESS = "GMAW";
|
||||
export const WELD_COLOR = "#c2185b";
|
||||
|
||||
/** "W4". */
|
||||
export function weldId(w: Pick<Weld, "weld_no">): string {
|
||||
return `W${w.weld_no}`;
|
||||
}
|
||||
|
||||
/** The process of the newest weld logged in the open stage, else GMAW. */
|
||||
export function lastProcess(p: Pick<PacketRead, "welds" | "stage">): string {
|
||||
const stageId = p.stage?.id;
|
||||
const mine = p.welds.filter((w) => w.process && !w.carried && (stageId === undefined || w.stage_id == null || w.stage_id === stageId));
|
||||
const last = [...mine].sort((a, b) => b.weld_no - a.weld_no)[0];
|
||||
return last?.process ?? DEFAULT_PROCESS;
|
||||
}
|
||||
|
||||
/** `Fillet 5/16" · items 1 + 2`. */
|
||||
export function weldSummary(w: Pick<Weld, "type" | "size" | "items">): string {
|
||||
const desc = [w.type, w.size].filter(Boolean).join(" ");
|
||||
const items = w.items.length ? `items ${w.items.join(" + ")}` : "";
|
||||
return [desc, items].filter(Boolean).join(" · ");
|
||||
}
|
||||
|
||||
/** The packet's work order bases, once each, with the largest lot quantity for the piece list. */
|
||||
export function woChoices(p: Pick<PacketRead, "work_orders">): Array<{ base: string; qty: number }> {
|
||||
const out = new Map<string, number>();
|
||||
for (const w of p.work_orders) {
|
||||
if (w.gone_at) continue;
|
||||
const q = Math.max(1, Math.floor(Number(w.qty) || 1));
|
||||
out.set(w.wo_base, Math.max(out.get(w.wo_base) ?? 1, q));
|
||||
}
|
||||
return [...out.entries()].map(([base, qty]) => ({ base, qty }));
|
||||
}
|
||||
Reference in New Issue
Block a user