Merge pull request #37 from Sea-Haven-Industries/feature/wo-domain-utils-mocks
Some checks are pending
CI / ci (push) Waiting to run
Deploy / deploy (push) Waiting to run

feat(work-orders): add board utils and mock data
This commit is contained in:
Alexandre Brandizzi 2026-07-21 14:10:45 -03:00 • committed by GitHub
commit 8813cb2e75
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 391 additions and 0 deletions

View file

@ -0,0 +1,119 @@
import type { CompDocData, DocFields } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { procedureFor, SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
export const SEAHAVEN_HEADER = {
name: "Seahaven Industries, Inc.",
address: "710 Koehler Avenue, Ronkonkoma, NY 11779",
phone: "(631) 776-5102",
fax: "(631) 677-3934",
email: "work-orders@seahavenind.com",
};
function esc(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
export function buildDocHtml(
row: Pick<WorkOrderTableRow, "woNumber" | "site" | "pm" | "tech" | "company">,
data: CompDocData,
fields: DocFields,
): string {
const cityStateZip =
[fields.city, fields.state].filter(Boolean).join(", ") + (fields.zip ? ` ${fields.zip}` : "");
const proc = procedureFor(row.pm);
const completedBlock = `<h3 style="margin:16px 0 4px;">Completion Details:</h3>
<p style="margin:2px 0;"><b>Date Work Completed:</b> ${esc(fields.dateCompleted || "—")}</p>
<p style="margin:2px 0;"><b>Amazon Representative:</b> ${esc(fields.amazonRep || "—")}</p>
<p style="margin:2px 0;"><b>Seahaven Representative:</b> ${esc(fields.seaRep || "—")}</p>`;
const procHtml = proc.steps
.map((st) => {
const sub = st.sub
? `<ol type="a" style="margin:4px 0 0 0;padding-left:22px;">${st.sub.map((x) => `<li style="margin:2px 0;">${esc(x)}</li>`).join("")}</ol>`
: "";
return `<li style="margin:6px 0;">${esc(st.text)}${sub}</li>`;
})
.join("");
const safety = SAFETY_BULLETS.map((b) => `<li style="margin:4px 0;">${esc(b)}</li>`).join("");
const notesBlock = data.notes
? `<h3 style="margin:16px 0 4px;">PM Comments:</h3><p style="margin:0;">${esc(data.notes)}</p>`
: "";
return `<!doctype html><html><head><meta charset="utf-8"><title>WO ${esc(row.woNumber)} — Completion</title>
<style>
@page { margin: 28px; }
* { box-sizing: border-box; }
body { font-family: 'DM Sans', system-ui, sans-serif; color: #161E38; font-size: 13px; line-height: 1.5; margin: 0; }
.band-top { height: 14px; background: linear-gradient(100deg,#27AAE1,#1C75BC); }
.band-bottom { height: 26px; margin-top: 28px; background: linear-gradient(100deg,#1C75BC,#27AAE1); clip-path: polygon(0 40%,100% 0,100% 100%,0 100%); }
.wrap { padding: 0 36px; }
.head { display: flex; justify-content: space-between; align-items: flex-start; padding: 20px 0 12px; }
.brand { font-weight: 800; font-size: 22px; letter-spacing: .04em; color: #1C75BC; }
.brand small { display:block; font-size: 9px; letter-spacing: .22em; color: #58595B; font-weight:600; }
.co { text-align: right; font-size: 12px; color: #161E38; }
.co b { color:#161E38; }
.rule { height: 3px; background: linear-gradient(90deg,#27AAE1,#1C75BC); border-radius: 2px; }
h1 { font-size: 14px; letter-spacing: .04em; margin: 18px 0 4px; }
.wo { color: #1C75BC; font-weight: 700; }
.muted { color: #58595B; }
ol { padding-left: 22px; }
</style></head>
<body>
<div class="band-top"></div>
<div class="wrap">
<div class="head">
<div class="brand">SEAHAVEN<small>INDUSTRIES</small></div>
<div class="co">
<div><b>${esc(SEAHAVEN_HEADER.name)}</b></div>
<div>${esc(SEAHAVEN_HEADER.address)}</div>
<div>${esc(SEAHAVEN_HEADER.phone)}</div>
<div>${esc(SEAHAVEN_HEADER.fax)}</div>
<div>${esc(SEAHAVEN_HEADER.email)}</div>
</div>
</div>
<div class="rule"></div>
<h1>${esc(serviceTitle(row).toUpperCase())}</h1>
<p style="margin:2px 0;" class="wo">WO# ${esc(row.woNumber)}</p>
<p style="margin:14px 0 0;">Site ID — ${esc(row.site)}</p>
<p style="margin:2px 0;">Amazon.com Services LLC ${esc(row.site)}</p>
<p style="margin:2px 0;" class="muted">${esc(fields.street)},</p>
<p style="margin:2px 0;" class="muted">${esc(cityStateZip)}</p>
<p style="margin:14px 0 4px;"><b>Vendor / Technician:</b> ${esc(row.tech || "—")}${row.company ? ` &middot; ${esc(row.company)}` : ""}</p>
<ul style="padding-left:20px;">${safety}</ul>
${completedBlock}
<h3 style="margin:16px 0 4px;">${esc(proc.heading)}:</h3>
<p style="margin:0 0 8px;">${esc(proc.intro)}</p>
<ol>${procHtml}</ol>
${notesBlock}
</div>
<div class="band-bottom"></div>
</body></html>`;
}
export function printDoc(html: string): void {
const iframe = document.createElement("iframe");
iframe.style.position = "fixed";
iframe.style.right = "0";
iframe.style.bottom = "0";
iframe.style.width = "0";
iframe.style.height = "0";
iframe.style.border = "0";
document.body.appendChild(iframe);
const idoc = iframe.contentWindow?.document;
if (!idoc) {
document.body.removeChild(iframe);
return;
}
idoc.open();
idoc.write(html);
idoc.close();
const win = iframe.contentWindow!;
setTimeout(() => {
win.focus();
win.print();
setTimeout(() => {
if (iframe.parentNode) document.body.removeChild(iframe);
}, 1200);
}, 300);
}

View file

@ -0,0 +1,99 @@
export interface ProcStep {
text: string;
sub?: string[];
}
export interface Procedure {
heading: string;
intro: string;
steps: ProcStep[];
}
export const SAFETY_BULLETS = [
"Use required PPE (gloves, goggles, hearing protection, arc flash equip. if needed).",
"Always follow OSHA, LOTO, FTHA, NFPA, and Amazon Policies before placing hands on equipment.",
'If any other issues are found not part of this PM\'s tasks please create a new "Work from PM" work order to make the correction.',
];
const BACKFLOW_PROCEDURE: Procedure = {
heading: "Backflow Procedure",
intro:
"Each backflow should be tested and if it passes inspection, the documentation must be submitted to the local municipality (i.e., city or county). No backflow PM is sufficient without at least testing the backflow.",
steps: [
{
text: "Contact base building coordinator/site POC, schedule backflow testing",
},
{ text: "Inform Facility Manager of scheduled test date" },
{
text: "Meet with base building coordinator/site POC upon arrival at site, confirming backflows will now be tested",
},
{
text: "Visually inspect backflows and take photos of units",
sub: [
"Document backflow location in facility",
'Document size and type of backflow (i.e., 1" RPZ backflow)',
],
},
{
text: "Perform backflow test",
sub: [
"If backflow test fails, disassemble backflow as needed and determine reason for failure",
"Make repairs to backflow",
"Retest backflow and ensure appropriate pressure",
],
},
{
text: "File backflow test and certification paperwork with site POC and local municipality",
},
{
text: 'If any failed backflow cannot be repaired immediately, notify the site POC and schedule tentative follow-up date for return, ensure a new PO for the follow up work. Issue a new "Work from PM" work order.',
sub: [
"Ensure equipment is functioning appropriately and safely before returning to operations",
"Ensure equipment and work area is clean prior to leaving the work site",
"If any deficiencies are discovered and unable to repair immediately, schedule follow up date with vendor and issue a new PO for the follow-up work. Issue a new \"work from PM' work order.",
"Provide adequate lead time for parts/scheduling",
],
},
],
};
const GENERIC_PROCEDURE: Procedure = {
heading: "Service Procedure",
intro:
"Perform the scheduled preventive maintenance per the manufacturer specification and Amazon site requirements. Document all findings and submit the completion paperwork to the site POC.",
steps: [
{
text: "Contact base building coordinator/site POC and schedule the service visit",
},
{ text: "Inform Facility Manager of the scheduled date" },
{
text: "Visually inspect the equipment and take photos of units",
sub: [
"Document equipment location in facility",
"Document make, model and condition of the asset",
],
},
{
text: "Perform the required preventive maintenance tasks for this asset",
sub: [
"If a deficiency is found, determine the corrective action and parts required",
"Make repairs where possible and re-test to confirm normal operation",
],
},
{
text: "Ensure equipment is functioning appropriately and the work area is clean prior to leaving the site",
},
{
text: "File the completion paperwork with the site POC and Seahaven",
sub: [
'If any deficiencies are discovered and unable to repair immediately, schedule a follow-up date with vendor and issue a new PO. Issue a new "Work from PM" work order.',
],
},
],
};
export function procedureFor(pm: string): Procedure {
return pm === "Backflow Preventers" || pm === "Domestic Backflow"
? BACKFLOW_PROCEDURE
: GENERIC_PROCEDURE;
}

View file

@ -0,0 +1,31 @@
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
const SERVICE_PHRASE: Record<string, string> = {
"Scrubber Pit": "Scrubber Pit Augering",
"Backflow Preventers": "Backflow Preventer Testing",
"Domestic Backflow": "Domestic Backflow Testing",
"Tank / Water Heater": "Tank / Water Heater Inspection",
"Pressure Reducing Valve": "Pressure Reducing Valve Service",
"Pumping Systems": "Pumping System Maintenance",
"HVAC Inspection": "HVAC Inspection",
Electrical: "Electrical Inspection",
Plumbing: "Plumbing Service",
"Door / Hardware": "Door / Hardware Service",
"Floor Drain Backing Up": "Floor Drain Augering",
"Parking Lot Lighting": "Parking Lot Lighting Service",
"Mens Restroom Camera Line": "Restroom Camera Line Service",
"Assess Window Leaks": "Window Leak Assessment",
};
const FREQ_CYCLE = ["13 Week", "26 Week", "52 Week"];
function hashNum(s: string, mod: number): number {
let h = 0;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0;
return h % mod;
}
export function serviceTitle(row: Pick<WorkOrderTableRow, "pm" | "woNumber">): string {
const phrase = SERVICE_PHRASE[row.pm] ?? row.pm;
return `Amer-Building ${phrase} — ${FREQ_CYCLE[hashNum(row.woNumber, FREQ_CYCLE.length)]}`;
}

View file

@ -0,0 +1,31 @@
import type { Location } from "@/domain/locations/types/location";
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
function buildLabel(city?: string, state?: string): string {
return [city, state].filter(Boolean).join(", ");
}
export function resolveCompDocSite(
row: Pick<WorkOrderTableRow, "site" | "location">,
location?: Location | null,
): CompDocSiteInfo {
if (location?.address || location?.city) {
const city = location.city ?? "";
const state = location.state ?? "";
const zip = location.zipCode ?? "";
const label = buildLabel(city, state);
const street = location.address ?? "";
const fullAddress = [street, label, zip].filter(Boolean).join(", ") || "—";
return { street, city, state, zip, label, fullAddress };
}
const locationParts = row.location.split(",").map((s) => s.trim());
const city = locationParts[0] ?? "";
const state = locationParts[1] ?? "";
const label = buildLabel(city, state);
const street = row.location && !label ? row.location : "";
const fullAddress = [street, label].filter(Boolean).join(", ") || "—";
return { street, city, state, zip: "", label, fullAddress };
}

View file

@ -0,0 +1,80 @@
import { CheckCircle2, FileText, XCircle } from "lucide-react";
import type { CompletionDocStateConfig, DocState } from "@/domain/work-orders/types/completion-doc";
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
const MONTH_NAMES = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
function pad2(n: number): string {
return String(n).padStart(2, "0");
}
export function docStateOf(status: CompletionDocStatus): DocState {
return status === "Yes" ? "generated" : status === "NN" ? "not-required" : "draft";
}
export function fmtDocNow(): string {
const d = new Date();
let h = d.getHours();
const ap = h >= 12 ? "PM" : "AM";
h = h % 12 || 12;
return `${MONTH_NAMES[d.getMonth()].slice(0, 3)} ${d.getDate()}, ${d.getFullYear()} · ${h}:${pad2(d.getMinutes())} ${ap}`;
}
export const DOC_STATE_CFG: Record<DocState, CompletionDocStateConfig> = {
draft: {
pillLabel: "Draft — not generated",
pillBg: "var(--color-bg-muted)",
pillText: "var(--muted-foreground)",
cardBg: "var(--color-bg-muted)",
cardBorder: "var(--color-border)",
cardIconBg: "var(--color-bg-surface)",
iconColor: "var(--muted-foreground)",
cardTitle: "Draft — not generated",
icon: FileText,
},
generated: {
pillLabel: "Generated",
pillBg: "var(--doc-yes-bg)",
pillText: "var(--color-success)",
cardBg: "var(--doc-yes-bg)",
cardBorder: "#bbf7d0",
cardIconBg: "var(--color-bg-surface)",
iconColor: "var(--color-success)",
cardTitle: "Generated",
icon: CheckCircle2,
},
"not-required": {
pillLabel: "Not required",
pillBg: "var(--indicator-co-1-bg)",
pillText: "var(--warning)",
cardBg: "var(--indicator-co-1-bg)",
cardBorder: "#fde68a",
cardIconBg: "var(--color-bg-surface)",
iconColor: "var(--warning)",
cardTitle: "Not required",
icon: XCircle,
},
};
export function docStatusCardDescription(state: DocState, generatedAt: string): string {
if (state === "draft") {
return "This document hasn't been generated yet. Add any extra details, then generate it — or mark it as not required.";
}
if (state === "generated") {
return `Ready to download or share. Generated ${generatedAt}.`;
}
return "No document is required for this work order. You can undo this anytime.";
}

View file

@ -0,0 +1,31 @@
import type {
FileCategory,
MediaCategory,
MediaFile,
} from "@/domain/work-orders/types/work-order-media";
let mediaIdCounter = 0;
export function classifyUpload(f: File): {
fileType: FileCategory;
category: MediaCategory;
} {
const t = f.type;
if (t.startsWith("image/")) return { fileType: "image", category: "Photo" };
if (t.startsWith("video/")) return { fileType: "video", category: "Video" };
if (t === "application/pdf" || /\.pdf$/i.test(f.name)) {
return { fileType: "pdf", category: "Report" };
}
return { fileType: "other", category: "Other" };
}
export function makeMediaFile(f: File): MediaFile {
const { fileType, category } = classifyUpload(f);
return {
id: `m-${Date.now()}-${mediaIdCounter++}`,
name: f.name,
fileType,
url: URL.createObjectURL(f),
category,
};
}