mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 15:09:02 +00:00
refactor(work-orders): pass board-api governance maintainability gate
Split oversized API/mapper/UI modules and extract focused helpers so the exact head satisfies godfile and complexity ratchets without relaxing thresholds.
This commit is contained in:
parent
e1ea198319
commit
6e99dc0af2
50 changed files with 3017 additions and 2259 deletions
|
|
@ -12,11 +12,6 @@
|
||||||
"path": "src/app/(protected)/vendors/index.tsx",
|
"path": "src/app/(protected)/vendors/index.tsx",
|
||||||
"maxLines": 612,
|
"maxLines": 612,
|
||||||
"reason": "Vendors list route; legacy page with mixed query/filter/table state pending extraction."
|
"reason": "Vendors list route; legacy page with mixed query/filter/table state pending extraction."
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/domain/work-orders/mappers/work-order-mapper.ts",
|
|
||||||
"maxLines": 572,
|
|
||||||
"reason": "API<->domain mapper; long but cohesive mapping logic, pending split by responsibility."
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -164,11 +164,13 @@ function maintainabilityGate(files) {
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
return { skipped: true, reason: "no changed governed TS/TSX files" };
|
return { skipped: true, reason: "no changed governed TS/TSX files" };
|
||||||
}
|
}
|
||||||
const eslintBin = path.join(ROOT, "node_modules", ".bin", "eslint");
|
// Invoke eslint via node so Windows (no shebang exec) and Unix both work.
|
||||||
|
const eslintJs = path.join(ROOT, "node_modules", "eslint", "bin", "eslint.js");
|
||||||
const ruleArgs = MAINTAINABILITY_RULES.flatMap((rule) => ["--rule", rule]);
|
const ruleArgs = MAINTAINABILITY_RULES.flatMap((rule) => ["--rule", rule]);
|
||||||
const result = spawnSync(
|
const result = spawnSync(
|
||||||
eslintBin,
|
process.execPath,
|
||||||
[
|
[
|
||||||
|
eslintJs,
|
||||||
...files,
|
...files,
|
||||||
...ruleArgs,
|
...ruleArgs,
|
||||||
"--max-warnings=0",
|
"--max-warnings=0",
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { readItems, readPage, readPageSize, readTotalCount } from "@/api/paginated-response-fields";
|
||||||
|
|
||||||
export interface PaginatedResult<T> {
|
export interface PaginatedResult<T> {
|
||||||
items: T[];
|
items: T[];
|
||||||
totalCount: number;
|
totalCount: number;
|
||||||
|
|
@ -41,30 +43,13 @@ export function handlePaginatedResponse<T>(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isRecord(data)) {
|
if (isRecord(data)) {
|
||||||
const nested = data.data ?? data.Data ?? data.value;
|
const items = readItems<T>(data);
|
||||||
const itemsFromPaged =
|
|
||||||
(Array.isArray(data.items) ? (data.items as T[]) : undefined) ??
|
|
||||||
(Array.isArray(data.Items) ? (data.Items as T[]) : undefined);
|
|
||||||
const items = itemsFromPaged ?? (Array.isArray(nested) ? (nested as T[]) : []);
|
|
||||||
|
|
||||||
const totalCount =
|
|
||||||
(typeof data.totalCount === "number" ? data.totalCount : undefined) ??
|
|
||||||
(typeof data.TotalCount === "number" ? data.TotalCount : undefined) ??
|
|
||||||
(typeof data.Count === "number" ? data.Count : undefined) ??
|
|
||||||
items.length;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items,
|
items,
|
||||||
totalCount,
|
totalCount: readTotalCount(data, items.length),
|
||||||
page:
|
page: readPage(data, page),
|
||||||
(typeof data.page === "number" ? data.page : undefined) ??
|
pageSize: readPageSize(data, pageSize),
|
||||||
(typeof data.pageNumber === "number" ? data.pageNumber : undefined) ??
|
|
||||||
(typeof data.PageNumber === "number" ? data.PageNumber : undefined) ??
|
|
||||||
page,
|
|
||||||
pageSize:
|
|
||||||
(typeof data.pageSize === "number" ? data.pageSize : undefined) ??
|
|
||||||
(typeof data.PageSize === "number" ? data.PageSize : undefined) ??
|
|
||||||
pageSize,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
36
src/api/paginated-response-fields.ts
Normal file
36
src/api/paginated-response-fields.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
/** Field readers for paginated API payloads that tolerate PascalCase/camelCase and nested envelopes. */
|
||||||
|
|
||||||
|
export function readItems<T>(record: Record<string, unknown>): T[] {
|
||||||
|
const nested = record.data ?? record.Data ?? record.value;
|
||||||
|
const itemsFromPaged =
|
||||||
|
(Array.isArray(record.items) ? (record.items as T[]) : undefined) ??
|
||||||
|
(Array.isArray(record.Items) ? (record.Items as T[]) : undefined);
|
||||||
|
|
||||||
|
return itemsFromPaged ?? (Array.isArray(nested) ? (nested as T[]) : []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readTotalCount(record: Record<string, unknown>, fallback: number): number {
|
||||||
|
return (
|
||||||
|
(typeof record.totalCount === "number" ? record.totalCount : undefined) ??
|
||||||
|
(typeof record.TotalCount === "number" ? record.TotalCount : undefined) ??
|
||||||
|
(typeof record.Count === "number" ? record.Count : undefined) ??
|
||||||
|
fallback
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readPage(record: Record<string, unknown>, fallback: number): number {
|
||||||
|
return (
|
||||||
|
(typeof record.page === "number" ? record.page : undefined) ??
|
||||||
|
(typeof record.pageNumber === "number" ? record.pageNumber : undefined) ??
|
||||||
|
(typeof record.PageNumber === "number" ? record.PageNumber : undefined) ??
|
||||||
|
fallback
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readPageSize(record: Record<string, unknown>, fallback: number): number {
|
||||||
|
return (
|
||||||
|
(typeof record.pageSize === "number" ? record.pageSize : undefined) ??
|
||||||
|
(typeof record.PageSize === "number" ? record.PageSize : undefined) ??
|
||||||
|
fallback
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||||
|
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||||
|
|
||||||
|
export function buildInlineDraftCreateInput(
|
||||||
|
draft: WorkOrderTableDraft,
|
||||||
|
users: WorkOrderUser[],
|
||||||
|
sites: LocationOption[],
|
||||||
|
): WorkOrderWizardDraft {
|
||||||
|
const assignee = users.find((u) => String(u.id) === draft.dispatcherId);
|
||||||
|
const location = sites.find((s) => String(s.id) === String(draft.locationId));
|
||||||
|
const locationName = draft.location || draft.site || location?.name || "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
locationId: String(draft.locationId || ""),
|
||||||
|
locationName,
|
||||||
|
siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }),
|
||||||
|
woNumber: draft.woNumber,
|
||||||
|
type: draft.type,
|
||||||
|
assigneeId: draft.dispatcherId,
|
||||||
|
assigneeName: assignee?.name ?? "",
|
||||||
|
scheduledOn: draft.scheduledOn,
|
||||||
|
dueDate: draft.dueDate,
|
||||||
|
pm: draft.pm,
|
||||||
|
extraServices: draft.extraServices ?? [],
|
||||||
|
pmNote: draft.serviceNotes ?? "",
|
||||||
|
vendorId: draft.vendorId || "",
|
||||||
|
vendorName: draft.company,
|
||||||
|
techPhone: draft.techPhone,
|
||||||
|
vendorNotes: "",
|
||||||
|
apptStart: draft.apptStart,
|
||||||
|
apptEnd: draft.apptEnd,
|
||||||
|
status: draft.status || "Incomplete",
|
||||||
|
pocName: draft.pocName,
|
||||||
|
pocPhone: draft.pocPhone,
|
||||||
|
pocNotes: draft.pocNotes,
|
||||||
|
scheduleWeekOnly: draft.scheduleWeekOnly,
|
||||||
|
targetWeek: draft.targetWeek || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function FieldLabel({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium"
|
||||||
|
style={{ color: "var(--color-text-secondary)" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Check, Plus } from "lucide-react";
|
|
||||||
import { Popover } from "@mui/material";
|
|
||||||
import {
|
import {
|
||||||
ServiceNotesDialog,
|
ServiceNotesDialog,
|
||||||
type ServiceNotesDetails,
|
type ServiceNotesDetails,
|
||||||
|
|
@ -8,6 +6,8 @@ import {
|
||||||
import { HighlightText } from "@/app/(protected)/workorders/_components/list/table/cells/highlight-text";
|
import { HighlightText } from "@/app/(protected)/workorders/_components/list/table/cells/highlight-text";
|
||||||
import { HoverChevron } from "@/app/(protected)/workorders/_components/list/table/cells/hover-chevron";
|
import { HoverChevron } from "@/app/(protected)/workorders/_components/list/table/cells/hover-chevron";
|
||||||
import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
|
import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
|
||||||
|
import { PmTypeDetailsTrigger } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-details-trigger";
|
||||||
|
import { PmTypePickerPopover } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-picker-popover";
|
||||||
import {
|
import {
|
||||||
hasServiceNotesDetails,
|
hasServiceNotesDetails,
|
||||||
truncateServiceNotesPreview,
|
truncateServiceNotesPreview,
|
||||||
|
|
@ -46,6 +46,11 @@ export function PMTypeCell({
|
||||||
const hasDetails = hasServiceNotesDetails(serviceNotes, extras);
|
const hasDetails = hasServiceNotesDetails(serviceNotes, extras);
|
||||||
const notePreview = serviceNotes?.trim() ? truncateServiceNotesPreview(serviceNotes) : "";
|
const notePreview = serviceNotes?.trim() ? truncateServiceNotesPreview(serviceNotes) : "";
|
||||||
|
|
||||||
|
const closePopover = () => {
|
||||||
|
setAnchorEl(null);
|
||||||
|
setSearch("");
|
||||||
|
};
|
||||||
|
|
||||||
const modal =
|
const modal =
|
||||||
onSaveDetails != null ? (
|
onSaveDetails != null ? (
|
||||||
<ServiceNotesDialog
|
<ServiceNotesDialog
|
||||||
|
|
@ -63,56 +68,13 @@ export function PMTypeCell({
|
||||||
if (hasDetails && onSaveDetails) {
|
if (hasDetails && onSaveDetails) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<PmTypeDetailsTrigger
|
||||||
type="button"
|
value={value}
|
||||||
onClick={(e) => {
|
q={q}
|
||||||
e.stopPropagation();
|
notePreview={notePreview}
|
||||||
setModalOpen(true);
|
extrasCount={extras.length}
|
||||||
}}
|
onOpenDetails={() => setModalOpen(true)}
|
||||||
className="flex w-full min-w-0 flex-col items-start text-left leading-tight transition-opacity hover:opacity-80"
|
/>
|
||||||
>
|
|
||||||
<span className="flex w-full min-w-0 items-center gap-1.5">
|
|
||||||
<PmTypeIcon
|
|
||||||
pmType={value}
|
|
||||||
size={12}
|
|
||||||
className="shrink-0"
|
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="min-w-0 truncate"
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
color: value ? "var(--color-text-primary)" : "var(--color-text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{value ? <HighlightText text={value} q={q} /> : "Select …"}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
{notePreview ? (
|
|
||||||
<span
|
|
||||||
className="w-full min-w-0 truncate"
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
fontStyle: "italic",
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
paddingLeft: 18,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{notePreview}
|
|
||||||
</span>
|
|
||||||
) : extras.length > 0 ? (
|
|
||||||
<span
|
|
||||||
className="w-full min-w-0 truncate"
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
paddingLeft: 18,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
+{extras.length} more
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</button>
|
|
||||||
{modal}
|
{modal}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
@ -150,71 +112,23 @@ export function PMTypeCell({
|
||||||
<HoverChevron />
|
<HoverChevron />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Popover
|
<PmTypePickerPopover
|
||||||
open={Boolean(anchorEl)}
|
|
||||||
anchorEl={anchorEl}
|
anchorEl={anchorEl}
|
||||||
onClose={() => {
|
search={search}
|
||||||
setAnchorEl(null);
|
filtered={filtered}
|
||||||
setSearch("");
|
value={value}
|
||||||
|
showAddDetails={Boolean(onSaveDetails)}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
onClose={closePopover}
|
||||||
|
onPick={(pm) => {
|
||||||
|
onSave(pm);
|
||||||
|
closePopover();
|
||||||
}}
|
}}
|
||||||
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
onAddDetails={() => {
|
||||||
onClick={(e) => e.stopPropagation()}
|
closePopover();
|
||||||
slotProps={{ paper: { className: "w-[244px] rounded-lg border border-border p-1.5" } }}
|
setModalOpen(true);
|
||||||
>
|
}}
|
||||||
<div className="mb-1.5 border-b border-border px-1 pb-1.5">
|
/>
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
placeholder="Search PM type…"
|
|
||||||
className="w-full rounded border border-border bg-[var(--color-bg-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)] focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-h-52 overflow-y-auto">
|
|
||||||
{Boolean(onSaveDetails) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setAnchorEl(null);
|
|
||||||
setSearch("");
|
|
||||||
setModalOpen(true);
|
|
||||||
}}
|
|
||||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[12px] font-medium transition-colors"
|
|
||||||
style={{ color: "var(--color-primary)" }}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
e.currentTarget.style.background = "var(--color-primary-tint)";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
e.currentTarget.style.background = "transparent";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus size={12} className="shrink-0" /> Add service and notes
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{filtered.map((pm) => (
|
|
||||||
<button
|
|
||||||
key={pm}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
onSave(pm);
|
|
||||||
setAnchorEl(null);
|
|
||||||
setSearch("");
|
|
||||||
}}
|
|
||||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
|
|
||||||
>
|
|
||||||
<PmTypeIcon pmType={pm} size={12} style={{ color: "var(--color-text-muted)" }} />
|
|
||||||
<span
|
|
||||||
className="flex-1 truncate text-left text-xs"
|
|
||||||
style={{ color: "var(--color-text-primary)" }}
|
|
||||||
>
|
|
||||||
{pm}
|
|
||||||
</span>
|
|
||||||
{value === pm && <Check size={10} className="text-[var(--color-primary)]" />}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Popover>
|
|
||||||
{modal}
|
{modal}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { HighlightText } from "@/app/(protected)/workorders/_components/list/table/cells/highlight-text";
|
||||||
|
import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
|
||||||
|
|
||||||
|
type PmTypeDetailsTriggerProps = {
|
||||||
|
value: string;
|
||||||
|
q?: string;
|
||||||
|
notePreview: string;
|
||||||
|
extrasCount: number;
|
||||||
|
onOpenDetails: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PmTypeDetailsTrigger({
|
||||||
|
value,
|
||||||
|
q,
|
||||||
|
notePreview,
|
||||||
|
extrasCount,
|
||||||
|
onOpenDetails,
|
||||||
|
}: PmTypeDetailsTriggerProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onOpenDetails();
|
||||||
|
}}
|
||||||
|
className="flex w-full min-w-0 flex-col items-start text-left leading-tight transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
|
<span className="flex w-full min-w-0 items-center gap-1.5">
|
||||||
|
<PmTypeIcon
|
||||||
|
pmType={value}
|
||||||
|
size={12}
|
||||||
|
className="shrink-0"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="min-w-0 truncate"
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: value ? "var(--color-text-primary)" : "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value ? <HighlightText text={value} q={q} /> : "Select …"}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{notePreview ? (
|
||||||
|
<span
|
||||||
|
className="w-full min-w-0 truncate"
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
fontStyle: "italic",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
paddingLeft: 18,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{notePreview}
|
||||||
|
</span>
|
||||||
|
) : extrasCount > 0 ? (
|
||||||
|
<span
|
||||||
|
className="w-full min-w-0 truncate"
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
paddingLeft: 18,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
+{extrasCount} more
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { Check, Plus } from "lucide-react";
|
||||||
|
import { Popover } from "@mui/material";
|
||||||
|
import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
|
||||||
|
|
||||||
|
type PmTypePickerPopoverProps = {
|
||||||
|
anchorEl: HTMLElement | null;
|
||||||
|
search: string;
|
||||||
|
filtered: string[];
|
||||||
|
value: string;
|
||||||
|
showAddDetails: boolean;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onPick: (pm: string) => void;
|
||||||
|
onAddDetails: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function PmTypePickerPopover({
|
||||||
|
anchorEl,
|
||||||
|
search,
|
||||||
|
filtered,
|
||||||
|
value,
|
||||||
|
showAddDetails,
|
||||||
|
onSearchChange,
|
||||||
|
onClose,
|
||||||
|
onPick,
|
||||||
|
onAddDetails,
|
||||||
|
}: PmTypePickerPopoverProps) {
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
open={Boolean(anchorEl)}
|
||||||
|
anchorEl={anchorEl}
|
||||||
|
onClose={onClose}
|
||||||
|
anchorOrigin={{ vertical: "bottom", horizontal: "left" }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
slotProps={{ paper: { className: "w-[244px] rounded-lg border border-border p-1.5" } }}
|
||||||
|
>
|
||||||
|
<div className="mb-1.5 border-b border-border px-1 pb-1.5">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
placeholder="Search PM type…"
|
||||||
|
className="w-full rounded border border-border bg-[var(--color-bg-surface)] px-2 py-1 text-xs text-[var(--color-text-primary)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-52 overflow-y-auto">
|
||||||
|
{showAddDetails && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAddDetails}
|
||||||
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-[12px] font-medium transition-colors"
|
||||||
|
style={{ color: "var(--color-primary)" }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.background = "var(--color-primary-tint)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus size={12} className="shrink-0" /> Add service and notes
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{filtered.map((pm) => (
|
||||||
|
<button
|
||||||
|
key={pm}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onPick(pm)}
|
||||||
|
className="flex w-full items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-[#F9FAFB]"
|
||||||
|
>
|
||||||
|
<PmTypeIcon pmType={pm} size={12} style={{ color: "var(--color-text-muted)" }} />
|
||||||
|
<span
|
||||||
|
className="flex-1 truncate text-left text-xs"
|
||||||
|
style={{ color: "var(--color-text-primary)" }}
|
||||||
|
>
|
||||||
|
{pm}
|
||||||
|
</span>
|
||||||
|
{value === pm && <Check size={10} className="text-[var(--color-primary)]" />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { ExternalLink, Mail, MapPin, Phone } from "lucide-react";
|
||||||
|
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { Location } from "@/domain/locations/types/location";
|
||||||
|
import { buildMapsUrl } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
|
||||||
|
|
||||||
|
type SiteDialogDetailPanelProps = {
|
||||||
|
locationDetail: Location | undefined;
|
||||||
|
locationDetailLoading: boolean;
|
||||||
|
locationDetailError: boolean;
|
||||||
|
addressPreview: string;
|
||||||
|
fallbackLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function SiteDialogSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5" aria-busy="true" role="status" aria-label="Loading site details">
|
||||||
|
<span
|
||||||
|
className="block animate-pulse rounded"
|
||||||
|
style={{ background: "var(--color-bg-muted)", width: "75%", height: 12 }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="block animate-pulse rounded"
|
||||||
|
style={{ background: "var(--color-bg-muted)", width: "50%", height: 12 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SiteDialogDetailPanel({
|
||||||
|
locationDetail,
|
||||||
|
locationDetailLoading,
|
||||||
|
locationDetailError,
|
||||||
|
addressPreview,
|
||||||
|
fallbackLabel,
|
||||||
|
}: SiteDialogDetailPanelProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="space-y-1.5 rounded-lg p-3"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{locationDetail ? (
|
||||||
|
<>
|
||||||
|
<Detail icon={MapPin} text={addressPreview || fallbackLabel} />
|
||||||
|
{Boolean(locationDetail.phone) && (
|
||||||
|
<Detail icon={Phone} text={locationDetail.phone ?? ""} />
|
||||||
|
)}
|
||||||
|
{Boolean(locationDetail.contactEmail) && (
|
||||||
|
<Detail icon={Mail} text={locationDetail.contactEmail ?? ""} />
|
||||||
|
)}
|
||||||
|
{Boolean(addressPreview) && (
|
||||||
|
<a
|
||||||
|
href={buildMapsUrl(locationDetail)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
||||||
|
style={{ color: "var(--color-primary)" }}
|
||||||
|
>
|
||||||
|
<ExternalLink size={12} /> Open in Google Maps
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Detail icon={MapPin} text={fallbackLabel} />
|
||||||
|
{locationDetailLoading && <SiteDialogSkeleton />}
|
||||||
|
{locationDetailError && (
|
||||||
|
<Text as="p" style={{ fontSize: 12, color: "var(--muted-foreground)" }}>
|
||||||
|
Could not load full site details. City and site code above are still available.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import type { Location } from "@/domain/locations/types/location";
|
||||||
|
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
|
||||||
|
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||||
|
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
|
export function extractLocationLabel(site: LocationOption): string {
|
||||||
|
const place = [site.city, site.state].filter(Boolean).join(", ");
|
||||||
|
if (place) {
|
||||||
|
return place;
|
||||||
|
}
|
||||||
|
const name = site.name;
|
||||||
|
for (const sep of [" · ", " — ", " - "]) {
|
||||||
|
const idx = name.indexOf(sep);
|
||||||
|
if (idx > 0) {
|
||||||
|
return name.slice(idx + sep.length).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Board rows often have siteCode but no locationId — resolve via sites list. */
|
||||||
|
export function resolveLocationId(
|
||||||
|
locationId: string | number,
|
||||||
|
siteCode: string,
|
||||||
|
sites: LocationOption[],
|
||||||
|
): string {
|
||||||
|
const idStr = locationId !== "" && locationId != null ? String(locationId) : "";
|
||||||
|
if (idStr && sites.some((s) => String(s.id) === idStr)) {
|
||||||
|
return idStr;
|
||||||
|
}
|
||||||
|
if (idStr && sites.length === 0) {
|
||||||
|
return idStr;
|
||||||
|
}
|
||||||
|
const code = siteCode.trim().toLowerCase();
|
||||||
|
if (!code) {
|
||||||
|
return idStr;
|
||||||
|
}
|
||||||
|
const byCode = sites.find((s) => resolveSiteCode(s).toLowerCase() === code);
|
||||||
|
return byCode ? String(byCode.id) : idStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMapsUrl(location: Location): string {
|
||||||
|
const query = formatLocationAddressPreview(location);
|
||||||
|
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
import {
|
||||||
|
DLG_INPUT_CLS,
|
||||||
|
DLG_INPUT_STYLE,
|
||||||
|
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
|
||||||
|
|
||||||
|
type SiteDialogPocFieldsProps = {
|
||||||
|
pocName: string;
|
||||||
|
pocPhone: string;
|
||||||
|
pocNotes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SiteDialogPocFields({ pocName, pocPhone, pocNotes }: SiteDialogPocFieldsProps) {
|
||||||
|
return (
|
||||||
|
<div className="pt-1">
|
||||||
|
<FieldLabel>Point of Contact</FieldLabel>
|
||||||
|
<Text as="p" className="mb-2 text-[11px]" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
Shown from site/work order records. Board edit saves site code only.
|
||||||
|
</Text>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<input
|
||||||
|
value={pocName}
|
||||||
|
readOnly
|
||||||
|
placeholder="POC name"
|
||||||
|
className={DLG_INPUT_CLS}
|
||||||
|
style={{ ...DLG_INPUT_STYLE, opacity: 0.85 }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={pocPhone}
|
||||||
|
readOnly
|
||||||
|
placeholder="POC phone"
|
||||||
|
className={DLG_INPUT_CLS}
|
||||||
|
style={{ ...DLG_INPUT_STYLE, opacity: 0.85 }}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
value={pocNotes}
|
||||||
|
readOnly
|
||||||
|
placeholder="Notes…"
|
||||||
|
rows={2}
|
||||||
|
className={`${DLG_INPUT_CLS} resize-none`}
|
||||||
|
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit", opacity: 0.85 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,20 +1,21 @@
|
||||||
import { useEffect, useState, type MouseEvent, type ReactNode } from "react";
|
import { useEffect, useState, type MouseEvent } from "react";
|
||||||
import { ExternalLink, Mail, MapPin, Phone } from "lucide-react";
|
import { MapPin } from "lucide-react";
|
||||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||||
import {
|
|
||||||
DLG_INPUT_CLS,
|
|
||||||
DLG_INPUT_STYLE,
|
|
||||||
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
|
||||||
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||||
import type { Location } from "@/domain/locations/types/location";
|
|
||||||
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
|
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
|
||||||
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||||
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
|
||||||
|
import {
|
||||||
|
extractLocationLabel,
|
||||||
|
resolveLocationId,
|
||||||
|
} from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
|
||||||
|
import { SiteDialogDetailPanel } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-detail-panel";
|
||||||
|
import { SiteDialogPocFields } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields";
|
||||||
|
|
||||||
export type SitePatch = {
|
export type SitePatch = {
|
||||||
site: string;
|
site: string;
|
||||||
|
|
@ -37,58 +38,6 @@ type SiteDialogProps = {
|
||||||
onSave: (patch: SitePatch) => void;
|
onSave: (patch: SitePatch) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function FieldLabel({ children }: { children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium"
|
|
||||||
style={{ color: "var(--color-text-secondary)" }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractLocationLabel(site: LocationOption): string {
|
|
||||||
const place = [site.city, site.state].filter(Boolean).join(", ");
|
|
||||||
if (place) {
|
|
||||||
return place;
|
|
||||||
}
|
|
||||||
const name = site.name;
|
|
||||||
for (const sep of [" · ", " — ", " - "]) {
|
|
||||||
const idx = name.indexOf(sep);
|
|
||||||
if (idx > 0) {
|
|
||||||
return name.slice(idx + sep.length).trim();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return name;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Board rows often have siteCode but no locationId — resolve via sites list. */
|
|
||||||
function resolveLocationId(
|
|
||||||
locationId: string | number,
|
|
||||||
siteCode: string,
|
|
||||||
sites: LocationOption[],
|
|
||||||
): string {
|
|
||||||
const idStr = locationId !== "" && locationId != null ? String(locationId) : "";
|
|
||||||
if (idStr && sites.some((s) => String(s.id) === idStr)) {
|
|
||||||
return idStr;
|
|
||||||
}
|
|
||||||
if (idStr && sites.length === 0) {
|
|
||||||
return idStr;
|
|
||||||
}
|
|
||||||
const code = siteCode.trim().toLowerCase();
|
|
||||||
if (!code) {
|
|
||||||
return idStr;
|
|
||||||
}
|
|
||||||
const byCode = sites.find((s) => resolveSiteCode(s).toLowerCase() === code);
|
|
||||||
return byCode ? String(byCode.id) : idStr;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildMapsUrl(location: Location): string {
|
|
||||||
const query = formatLocationAddressPreview(location);
|
|
||||||
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SiteDialog({
|
export function SiteDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
|
@ -195,95 +144,16 @@ export function SiteDialog({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{Boolean(locId) && (
|
{Boolean(locId) && (
|
||||||
<div
|
<SiteDialogDetailPanel
|
||||||
className="space-y-1.5 rounded-lg p-3"
|
locationDetail={locationDetail}
|
||||||
style={{
|
locationDetailLoading={locationDetailLoading}
|
||||||
background: "var(--color-bg-muted)",
|
locationDetailError={locationDetailError}
|
||||||
border: "1px solid var(--color-border)",
|
addressPreview={addressPreview}
|
||||||
}}
|
fallbackLabel={selected?.name || code || "—"}
|
||||||
>
|
/>
|
||||||
{locationDetail ? (
|
|
||||||
<>
|
|
||||||
<Detail icon={MapPin} text={addressPreview || selected?.name || code || "—"} />
|
|
||||||
{Boolean(locationDetail.phone) && (
|
|
||||||
<Detail icon={Phone} text={locationDetail.phone ?? ""} />
|
|
||||||
)}
|
|
||||||
{Boolean(locationDetail.contactEmail) && (
|
|
||||||
<Detail icon={Mail} text={locationDetail.contactEmail ?? ""} />
|
|
||||||
)}
|
|
||||||
{Boolean(addressPreview) && (
|
|
||||||
<a
|
|
||||||
href={buildMapsUrl(locationDetail)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
|
||||||
style={{ color: "var(--color-primary)" }}
|
|
||||||
>
|
|
||||||
<ExternalLink size={12} /> Open in Google Maps
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Detail icon={MapPin} text={selected?.name || code || "—"} />
|
|
||||||
{locationDetailLoading && (
|
|
||||||
<div
|
|
||||||
className="space-y-1.5"
|
|
||||||
aria-busy="true"
|
|
||||||
role="status"
|
|
||||||
aria-label="Loading site details"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="block animate-pulse rounded"
|
|
||||||
style={{ background: "var(--color-bg-muted)", width: "75%", height: 12 }}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className="block animate-pulse rounded"
|
|
||||||
style={{ background: "var(--color-bg-muted)", width: "50%", height: 12 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{locationDetailError && (
|
|
||||||
<Text as="p" style={{ fontSize: 12, color: "var(--muted-foreground)" }}>
|
|
||||||
Could not load full site details. City and site code above are still
|
|
||||||
available.
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="pt-1">
|
<SiteDialogPocFields pocName={pn} pocPhone={pp} pocNotes={notes} />
|
||||||
<FieldLabel>Point of Contact</FieldLabel>
|
|
||||||
<Text as="p" className="mb-2 text-[11px]" style={{ color: "var(--color-text-muted)" }}>
|
|
||||||
Shown from site/work order records. Board edit saves site code only.
|
|
||||||
</Text>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<input
|
|
||||||
value={pn}
|
|
||||||
readOnly
|
|
||||||
placeholder="POC name"
|
|
||||||
className={DLG_INPUT_CLS}
|
|
||||||
style={{ ...DLG_INPUT_STYLE, opacity: 0.85 }}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={pp}
|
|
||||||
readOnly
|
|
||||||
placeholder="POC phone"
|
|
||||||
className={DLG_INPUT_CLS}
|
|
||||||
style={{ ...DLG_INPUT_STYLE, opacity: 0.85 }}
|
|
||||||
/>
|
|
||||||
<textarea
|
|
||||||
value={notes}
|
|
||||||
readOnly
|
|
||||||
placeholder="Notes…"
|
|
||||||
rows={2}
|
|
||||||
className={`${DLG_INPUT_CLS} resize-none`}
|
|
||||||
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit", opacity: 0.85 }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-end gap-2">
|
<div className="mt-4 flex items-center justify-end gap-2">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { ExternalLink, Mail, User, Wrench } from "lucide-react";
|
||||||
|
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||||
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { formatVendorDistance } from "@/domain/work-orders/utils/vendor-dropdown-label";
|
||||||
|
|
||||||
|
type VendorDialogCompanyCardProps = {
|
||||||
|
company: string;
|
||||||
|
vendor: VendorDropdownItem | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function VendorDialogCompanyCard({ company, vendor }: VendorDialogCompanyCardProps) {
|
||||||
|
const distance = formatVendorDistance(vendor?.distanceMiles);
|
||||||
|
const email = vendor?.email?.trim() || "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="space-y-1.5 rounded-lg p-3"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--foreground)" }}>
|
||||||
|
{company || "—"}
|
||||||
|
</div>
|
||||||
|
{Boolean(distance) && (
|
||||||
|
<span className="shrink-0 text-[11px]" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{distance}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{Boolean(vendor?.contactName) && <Detail icon={User} text={vendor?.contactName ?? ""} />}
|
||||||
|
{Boolean(vendor?.tradeSpecialties) && (
|
||||||
|
<Detail icon={Wrench} text={vendor?.tradeSpecialties ?? ""} />
|
||||||
|
)}
|
||||||
|
{Boolean(email) && <Detail icon={Mail} text={email} />}
|
||||||
|
{vendor != null && Boolean(vendor.address) && (
|
||||||
|
<a
|
||||||
|
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(vendor.address)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
||||||
|
style={{ color: "var(--color-primary)" }}
|
||||||
|
>
|
||||||
|
<ExternalLink size={12} /> Open in Google Maps
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { formatVendorTechnicianLabel } from "@/domain/work-orders/utils/vendor-dropdown-label";
|
||||||
|
|
||||||
|
export type TechnicianOption = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
company: string;
|
||||||
|
phone: string;
|
||||||
|
vendor?: VendorDropdownItem;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildTechnicianOptions(
|
||||||
|
vendors: VendorDropdownItem[],
|
||||||
|
tech: string,
|
||||||
|
company: string,
|
||||||
|
): TechnicianOption[] {
|
||||||
|
const options = new Map<string, TechnicianOption>();
|
||||||
|
|
||||||
|
const add = (option: TechnicianOption) => {
|
||||||
|
if (!option.key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
options.set(option.key, option);
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const vendor of vendors) {
|
||||||
|
const contact = vendor.contactName.trim() || vendor.companyName.trim();
|
||||||
|
add({
|
||||||
|
key: String(vendor.id),
|
||||||
|
label: formatVendorTechnicianLabel(vendor),
|
||||||
|
name: contact,
|
||||||
|
company: vendor.companyName,
|
||||||
|
phone: "",
|
||||||
|
vendor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tech) {
|
||||||
|
const vendor =
|
||||||
|
vendors.find((v) => v.contactName === tech && v.companyName === company) ??
|
||||||
|
vendors.find((v) => v.contactName === tech) ??
|
||||||
|
vendors.find((v) => v.companyName === company);
|
||||||
|
const key = vendor ? String(vendor.id) : tech;
|
||||||
|
add({
|
||||||
|
key,
|
||||||
|
label: company && company !== tech ? `${tech} · ${company}` : tech,
|
||||||
|
name: tech,
|
||||||
|
company: company || tech,
|
||||||
|
phone: "",
|
||||||
|
vendor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(options.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function phoneForOption(name: string, options: TechnicianOption[], fallback = ""): string {
|
||||||
|
return options.find((o) => o.name === name)?.phone ?? fallback;
|
||||||
|
}
|
||||||
|
|
@ -1,20 +1,21 @@
|
||||||
import { useEffect, useMemo, useState, type MouseEvent, type ReactNode } from "react";
|
import { useEffect, useMemo, useState, type MouseEvent } from "react";
|
||||||
import { ExternalLink, Mail, User, Wrench } from "lucide-react";
|
import { Wrench } from "lucide-react";
|
||||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||||
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
import { WizardFieldSelect } from "@/app/(protected)/workorders/_components/wizard/wizard-field-select";
|
||||||
import {
|
import {
|
||||||
DLG_INPUT_CLS,
|
DLG_INPUT_CLS,
|
||||||
DLG_INPUT_STYLE,
|
DLG_INPUT_STYLE,
|
||||||
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
||||||
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
|
||||||
import {
|
import {
|
||||||
formatVendorDistance,
|
buildTechnicianOptions,
|
||||||
formatVendorTechnicianLabel,
|
phoneForOption,
|
||||||
} from "@/domain/work-orders/utils/vendor-dropdown-label";
|
} from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-options";
|
||||||
|
import { VendorDialogCompanyCard } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-company-card";
|
||||||
|
|
||||||
export type VendorPatch = {
|
export type VendorPatch = {
|
||||||
vendorId: string;
|
vendorId: string;
|
||||||
|
|
@ -33,75 +34,6 @@ type VendorDialogProps = {
|
||||||
onSave: (patch: VendorPatch) => void;
|
onSave: (patch: VendorPatch) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TechnicianOption = {
|
|
||||||
key: string;
|
|
||||||
label: string;
|
|
||||||
name: string;
|
|
||||||
company: string;
|
|
||||||
phone: string;
|
|
||||||
vendor?: VendorDropdownItem;
|
|
||||||
};
|
|
||||||
|
|
||||||
function FieldLabel({ children }: { children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<label
|
|
||||||
className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium"
|
|
||||||
style={{ color: "var(--color-text-secondary)" }}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTechnicianOptions(
|
|
||||||
vendors: VendorDropdownItem[],
|
|
||||||
tech: string,
|
|
||||||
company: string,
|
|
||||||
): TechnicianOption[] {
|
|
||||||
const options = new Map<string, TechnicianOption>();
|
|
||||||
|
|
||||||
const add = (option: TechnicianOption) => {
|
|
||||||
if (!option.key) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
options.set(option.key, option);
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const vendor of vendors) {
|
|
||||||
const contact = vendor.contactName.trim() || vendor.companyName.trim();
|
|
||||||
add({
|
|
||||||
key: String(vendor.id),
|
|
||||||
label: formatVendorTechnicianLabel(vendor),
|
|
||||||
name: contact,
|
|
||||||
company: vendor.companyName,
|
|
||||||
phone: "",
|
|
||||||
vendor,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tech) {
|
|
||||||
const vendor =
|
|
||||||
vendors.find((v) => v.contactName === tech && v.companyName === company) ??
|
|
||||||
vendors.find((v) => v.contactName === tech) ??
|
|
||||||
vendors.find((v) => v.companyName === company);
|
|
||||||
const key = vendor ? String(vendor.id) : tech;
|
|
||||||
add({
|
|
||||||
key,
|
|
||||||
label: company && company !== tech ? `${tech} · ${company}` : tech,
|
|
||||||
name: tech,
|
|
||||||
company: company || tech,
|
|
||||||
phone: "",
|
|
||||||
vendor,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(options.values());
|
|
||||||
}
|
|
||||||
|
|
||||||
function phoneForOption(name: string, options: TechnicianOption[], fallback = ""): string {
|
|
||||||
return options.find((o) => o.name === name)?.phone ?? fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VendorDialog({
|
export function VendorDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
|
@ -134,8 +66,6 @@ export function VendorDialog({
|
||||||
const selectedKey = selected?.key ?? "";
|
const selectedKey = selected?.key ?? "";
|
||||||
const resolvedCompany = t ? (selected?.company ?? company) : "";
|
const resolvedCompany = t ? (selected?.company ?? company) : "";
|
||||||
const selectedVendor = selected?.vendor ?? vendors.find((v) => v.companyName === resolvedCompany);
|
const selectedVendor = selected?.vendor ?? vendors.find((v) => v.companyName === resolvedCompany);
|
||||||
const selectedDistance = formatVendorDistance(selectedVendor?.distanceMiles);
|
|
||||||
const companyEmail = selectedVendor?.email?.trim() || "";
|
|
||||||
|
|
||||||
const handlePick = (key: string) => {
|
const handlePick = (key: string) => {
|
||||||
const option = technicianOptions.find((o) => o.key === key);
|
const option = technicianOptions.find((o) => o.key === key);
|
||||||
|
|
@ -204,45 +134,7 @@ export function VendorDialog({
|
||||||
{Boolean(t) && (
|
{Boolean(t) && (
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel>Company</FieldLabel>
|
<FieldLabel>Company</FieldLabel>
|
||||||
<div
|
<VendorDialogCompanyCard company={resolvedCompany} vendor={selectedVendor} />
|
||||||
className="space-y-1.5 rounded-lg p-3"
|
|
||||||
style={{
|
|
||||||
background: "var(--color-bg-muted)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 500, color: "var(--foreground)" }}>
|
|
||||||
{resolvedCompany || "—"}
|
|
||||||
</div>
|
|
||||||
{Boolean(selectedDistance) && (
|
|
||||||
<span
|
|
||||||
className="shrink-0 text-[11px]"
|
|
||||||
style={{ color: "var(--muted-foreground)" }}
|
|
||||||
>
|
|
||||||
{selectedDistance}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{Boolean(selectedVendor?.contactName) && (
|
|
||||||
<Detail icon={User} text={selectedVendor?.contactName ?? ""} />
|
|
||||||
)}
|
|
||||||
{Boolean(selectedVendor?.tradeSpecialties) && (
|
|
||||||
<Detail icon={Wrench} text={selectedVendor?.tradeSpecialties ?? ""} />
|
|
||||||
)}
|
|
||||||
{Boolean(companyEmail) && <Detail icon={Mail} text={companyEmail} />}
|
|
||||||
{selectedVendor != null && Boolean(selectedVendor.address) && (
|
|
||||||
<a
|
|
||||||
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(selectedVendor.address)}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
|
||||||
style={{ color: "var(--color-primary)" }}
|
|
||||||
>
|
|
||||||
<ExternalLink size={12} /> Open in Google Maps
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
/** Minimal row shape needed by StatusCell while a new work order is being drafted inline. */
|
||||||
|
export function toInlineDraftRow(draft: WorkOrderTableDraft) {
|
||||||
|
return {
|
||||||
|
id: "draft",
|
||||||
|
woNumber: draft.woNumber,
|
||||||
|
status: draft.status,
|
||||||
|
isPastDue: false,
|
||||||
|
type: draft.type,
|
||||||
|
rescheduleCount: 0,
|
||||||
|
carriedOver: 0,
|
||||||
|
docStatus: "" as const,
|
||||||
|
mediaCount: 0,
|
||||||
|
site: draft.site,
|
||||||
|
location: draft.location,
|
||||||
|
dispatcherId: draft.dispatcherId,
|
||||||
|
scheduledOn: draft.scheduledOn,
|
||||||
|
dueDate: draft.dueDate,
|
||||||
|
pm: draft.pm,
|
||||||
|
serviceNotes: draft.serviceNotes,
|
||||||
|
extraServices: draft.extraServices,
|
||||||
|
company: draft.company,
|
||||||
|
tech: draft.tech,
|
||||||
|
targetWeek: draft.targetWeek,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import type {
|
||||||
|
WorkOrderTablePatch,
|
||||||
|
WorkOrderTableRow,
|
||||||
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { applySchedulePatch } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||||
|
import { shouldNotifyCrossWeek } from "@/domain/work-orders/utils/should-notify-cross-week";
|
||||||
|
import { mondayOf } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
||||||
|
import { rescheduleNotify, weekMoveNotify } from "./reschedule-notify";
|
||||||
|
import type { WoTableRowHandlers } from "./wo-table-row";
|
||||||
|
|
||||||
|
type UseWoTableRowHandlersOptions = {
|
||||||
|
tableData: ReturnTypeOfUseWorkOrdersTableData;
|
||||||
|
weekMonday: string;
|
||||||
|
onWeekChange: (mondayIso: string) => void;
|
||||||
|
onOpenSlideOver: (row: WorkOrderTableRow, edit?: boolean) => void;
|
||||||
|
onOpenCompDoc?: (row: WorkOrderTableRow) => void;
|
||||||
|
onOpenMedia: (row: WorkOrderTableRow) => void;
|
||||||
|
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
|
||||||
|
onScheduleHighlight: (id: string | number | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useWoTableRowHandlers({
|
||||||
|
tableData,
|
||||||
|
weekMonday,
|
||||||
|
onWeekChange,
|
||||||
|
onOpenSlideOver,
|
||||||
|
onOpenCompDoc,
|
||||||
|
onOpenMedia,
|
||||||
|
onPatch,
|
||||||
|
onScheduleHighlight,
|
||||||
|
}: UseWoTableRowHandlersOptions): WoTableRowHandlers {
|
||||||
|
const {
|
||||||
|
dragState: dragStateRef,
|
||||||
|
setDragViz,
|
||||||
|
reorderRows,
|
||||||
|
endDrag,
|
||||||
|
setUnschedOpen,
|
||||||
|
triggerFlash,
|
||||||
|
} = tableData;
|
||||||
|
|
||||||
|
return useMemo(
|
||||||
|
() => ({
|
||||||
|
onRowClick: (row: WorkOrderTableRow) => {
|
||||||
|
tableData.setSelectedId(row.id);
|
||||||
|
onOpenSlideOver(row, false);
|
||||||
|
},
|
||||||
|
onView: (row: WorkOrderTableRow) => {
|
||||||
|
tableData.setSelectedId(row.id);
|
||||||
|
onOpenSlideOver(row, false);
|
||||||
|
},
|
||||||
|
onEdit: (row: WorkOrderTableRow) => {
|
||||||
|
tableData.setSelectedId(row.id);
|
||||||
|
onOpenSlideOver(row, true);
|
||||||
|
},
|
||||||
|
onOpenCompDoc: onOpenCompDoc
|
||||||
|
? (row: WorkOrderTableRow) => {
|
||||||
|
tableData.setSelectedId(row.id);
|
||||||
|
onOpenCompDoc(row);
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
onOpenMedia: (row: WorkOrderTableRow) => {
|
||||||
|
tableData.setSelectedId(row.id);
|
||||||
|
onOpenMedia(row);
|
||||||
|
},
|
||||||
|
onPatch: (id: string | number, patch: Partial<WorkOrderTableRow>) => {
|
||||||
|
onPatch(id, patch);
|
||||||
|
},
|
||||||
|
onScheduleDate: (id: string | number, date: string) => {
|
||||||
|
onPatch(id, applySchedulePatch(date, false));
|
||||||
|
if (shouldNotifyCrossWeek(date, weekMonday)) {
|
||||||
|
const targetMonday = mondayOf(date);
|
||||||
|
rescheduleNotify(date, {
|
||||||
|
onNavigate: () => {
|
||||||
|
onWeekChange(targetMonday);
|
||||||
|
setTimeout(() => triggerFlash(id), 180);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onScheduleWeek: (id: string | number, week: string) => {
|
||||||
|
onPatch(id, {
|
||||||
|
scheduledOn: "",
|
||||||
|
targetWeek: week,
|
||||||
|
scheduleWeekOnly: true,
|
||||||
|
dayGroup: "",
|
||||||
|
dayLabel: "",
|
||||||
|
});
|
||||||
|
setUnschedOpen(true);
|
||||||
|
triggerFlash(id);
|
||||||
|
weekMoveNotify(week, {
|
||||||
|
onNavigate: () => {
|
||||||
|
setUnschedOpen(true);
|
||||||
|
setTimeout(() => triggerFlash(id), 180);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onDuplicateFound: (existing: WorkOrderTableRow) => {
|
||||||
|
tableData.setSelectedId(existing.id);
|
||||||
|
onOpenSlideOver(existing, false);
|
||||||
|
},
|
||||||
|
onOpenSlideOver: (row: WorkOrderTableRow) => onOpenSlideOver(row, false),
|
||||||
|
onPastDueAttempt: (id: string | number) => onScheduleHighlight(id),
|
||||||
|
onReorder: reorderRows,
|
||||||
|
onDragStart: (id: string | number) => {
|
||||||
|
dragStateRef.current = { dragId: id, overId: null };
|
||||||
|
setDragViz({ dragId: id, overId: null });
|
||||||
|
},
|
||||||
|
onDragOver: (id: string | number) => {
|
||||||
|
const did = dragStateRef.current.dragId;
|
||||||
|
if (did && did !== id && dragStateRef.current.overId !== id) {
|
||||||
|
dragStateRef.current.overId = id;
|
||||||
|
setDragViz((v) => ({ ...v, overId: id }));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDrop: () => {
|
||||||
|
const { dragId, overId } = dragStateRef.current;
|
||||||
|
if (dragId && overId) {
|
||||||
|
reorderRows(dragId, overId);
|
||||||
|
}
|
||||||
|
endDrag();
|
||||||
|
},
|
||||||
|
onDragEnd: endDrag,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
tableData,
|
||||||
|
dragStateRef,
|
||||||
|
setDragViz,
|
||||||
|
reorderRows,
|
||||||
|
endDrag,
|
||||||
|
setUnschedOpen,
|
||||||
|
triggerFlash,
|
||||||
|
weekMonday,
|
||||||
|
onWeekChange,
|
||||||
|
onOpenSlideOver,
|
||||||
|
onOpenCompDoc,
|
||||||
|
onOpenMedia,
|
||||||
|
onPatch,
|
||||||
|
onScheduleHighlight,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { Fragment, type ReactNode } from "react";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
||||||
|
import { WoTableSectionHeader } from "./wo-table-section-header";
|
||||||
|
|
||||||
|
type WoTableBodyProps = {
|
||||||
|
tableData: ReturnTypeOfUseWorkOrdersTableData;
|
||||||
|
renderRow: (row: WorkOrderTableRow, forceScheduleHighlight?: boolean) => ReactNode;
|
||||||
|
renderInlineRow: (dayKey: string) => ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WoTableBody({ tableData, renderRow, renderInlineRow }: WoTableBodyProps) {
|
||||||
|
if (tableData.advActive) {
|
||||||
|
return <tbody>{tableData.advResults.map((row) => renderRow(row))}</tbody>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const showUnscheduled =
|
||||||
|
tableData.unscheduled.length > 0 || tableData.draftDayKey === UNSCHEDULED_DAY_KEY;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tbody>
|
||||||
|
{showUnscheduled && (
|
||||||
|
<>
|
||||||
|
<WoTableSectionHeader
|
||||||
|
variant="unscheduled"
|
||||||
|
label="Unscheduled"
|
||||||
|
count={tableData.unscheduled.length}
|
||||||
|
collapsed={!tableData.unschedOpen}
|
||||||
|
onToggleCollapse={() => tableData.setUnschedOpen((v) => !v)}
|
||||||
|
onAddWo={() => tableData.startDayDraft(UNSCHEDULED_DAY_KEY)}
|
||||||
|
/>
|
||||||
|
{tableData.unschedOpen && (
|
||||||
|
<>
|
||||||
|
{renderInlineRow(UNSCHEDULED_DAY_KEY)}
|
||||||
|
{tableData.unscheduled.map((row) => renderRow(row, true))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{tableData.displayGroups.map((group) => (
|
||||||
|
<Fragment key={group.key}>
|
||||||
|
<WoTableSectionHeader
|
||||||
|
variant="day"
|
||||||
|
label={group.label}
|
||||||
|
count={group.items.length}
|
||||||
|
isToday={group.key === tableData.todayKey}
|
||||||
|
onAddWo={() => tableData.startDayDraft(group.key, group.date)}
|
||||||
|
/>
|
||||||
|
{renderInlineRow(group.key)}
|
||||||
|
{group.items.map((row) => renderRow(row))}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { CircularProgress } from "@mui/material";
|
||||||
|
import { Check, X } from "lucide-react";
|
||||||
|
|
||||||
|
type WoTableInlineRowActionsProps = {
|
||||||
|
canSave: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
onSave: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WoTableInlineRowActions({
|
||||||
|
canSave,
|
||||||
|
saving,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
}: WoTableInlineRowActionsProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={!canSave}
|
||||||
|
title={saving ? "Saving…" : canSave ? "Save" : "Schedule On is required"}
|
||||||
|
aria-label={saving ? "Saving work order" : "Save work order"}
|
||||||
|
className="rounded p-1 transition-colors disabled:opacity-30"
|
||||||
|
style={{ color: "var(--color-primary)" }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (canSave) {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "var(--color-primary-tint)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving ? <CircularProgress size={13} color="inherit" /> : <Check size={13} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={saving}
|
||||||
|
title="Cancel"
|
||||||
|
aria-label="Cancel draft"
|
||||||
|
className="rounded p-1 transition-colors disabled:opacity-30"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "var(--color-bg-muted)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,172 @@
|
||||||
|
import type {
|
||||||
|
LocationOption,
|
||||||
|
VendorDropdownItem,
|
||||||
|
WorkOrderUser,
|
||||||
|
} from "@/domain/work-orders/types/work-order";
|
||||||
|
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { AssignedToCell } from "./cells/assigned-to-cell";
|
||||||
|
import { ApptTimeCell } from "./cells/appt-time-cell";
|
||||||
|
import { DateCell } from "./cells/date-cell";
|
||||||
|
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
|
||||||
|
import { PMTypeCell } from "./cells/pm-type-cell";
|
||||||
|
import { SiteCell } from "./cells/site-cell";
|
||||||
|
import { StatusCell } from "./cells/status-cell";
|
||||||
|
import { TypeCell } from "./cells/type-cell";
|
||||||
|
import { VendorCell } from "./cells/vendor-cell";
|
||||||
|
import { toInlineDraftRow } from "./to-inline-draft-row";
|
||||||
|
|
||||||
|
type WoTableInlineRowCellsProps = {
|
||||||
|
draft: WorkOrderTableDraft;
|
||||||
|
weekDefault: boolean;
|
||||||
|
users: WorkOrderUser[];
|
||||||
|
sites: LocationOption[];
|
||||||
|
sitePlace?: { city: string; state: string };
|
||||||
|
vendors: VendorDropdownItem[];
|
||||||
|
pmTypes: string[];
|
||||||
|
allRows: { id: string | number; woNumber: string }[];
|
||||||
|
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WoTableInlineRowCells({
|
||||||
|
draft,
|
||||||
|
weekDefault,
|
||||||
|
users,
|
||||||
|
sites,
|
||||||
|
sitePlace,
|
||||||
|
vendors,
|
||||||
|
pmTypes,
|
||||||
|
allRows,
|
||||||
|
onPatch,
|
||||||
|
}: WoTableInlineRowCellsProps) {
|
||||||
|
const draftRow = toInlineDraftRow(draft);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
|
<SiteCell
|
||||||
|
value={draft.site}
|
||||||
|
location={draft.location}
|
||||||
|
locationId={draft.locationId}
|
||||||
|
city={sitePlace?.city}
|
||||||
|
state={sitePlace?.state}
|
||||||
|
pocName={draft.pocName}
|
||||||
|
pocPhone={draft.pocPhone}
|
||||||
|
pocNotes={draft.pocNotes}
|
||||||
|
sites={sites}
|
||||||
|
onSave={(p) =>
|
||||||
|
onPatch({
|
||||||
|
site: p.site,
|
||||||
|
location: p.location,
|
||||||
|
locationId: p.locationId,
|
||||||
|
pocName: p.pocName,
|
||||||
|
pocPhone: p.pocPhone,
|
||||||
|
pocNotes: p.pocNotes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td style={{ padding: "0 14px" }}>
|
||||||
|
<EditableWONumberCell
|
||||||
|
value={draft.woNumber}
|
||||||
|
rescheduleCount={0}
|
||||||
|
currentId="draft"
|
||||||
|
allOrders={allRows as never[]}
|
||||||
|
onSave={(v) => onPatch({ woNumber: v })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
|
<TypeCell value={draft.type} onSave={(t) => onPatch({ type: t })} />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
|
<AssignedToCell
|
||||||
|
dispatcherId={draft.dispatcherId}
|
||||||
|
users={users}
|
||||||
|
highlight={!draft.dispatcherId}
|
||||||
|
onSave={(id) => {
|
||||||
|
onPatch({ dispatcherId: id, status: draft.status || "Incomplete" });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
|
<DateCell
|
||||||
|
value={draft.scheduledOn}
|
||||||
|
allowWeekMode
|
||||||
|
targetWeek={draft.targetWeek}
|
||||||
|
defaultMode={weekDefault ? "week" : "date"}
|
||||||
|
highlight={!draft.scheduledOn && !draft.targetWeek}
|
||||||
|
onSave={(date) => onPatch({ scheduledOn: date, scheduleWeekOnly: false, targetWeek: "" })}
|
||||||
|
onSaveWeek={(week) =>
|
||||||
|
onPatch({ scheduledOn: "", targetWeek: week, scheduleWeekOnly: true })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
|
<DateCell value={draft.dueDate} onSave={(date) => onPatch({ dueDate: date })} />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
|
<PMTypeCell
|
||||||
|
value={draft.pm}
|
||||||
|
pmTypes={pmTypes}
|
||||||
|
serviceNotes={draft.serviceNotes}
|
||||||
|
extraServices={draft.extraServices}
|
||||||
|
woNumber={draft.woNumber}
|
||||||
|
onSave={(pm) => onPatch({ pm })}
|
||||||
|
onSaveDetails={(d) =>
|
||||||
|
onPatch({
|
||||||
|
pm: d.pm,
|
||||||
|
extraServices: d.extraServices,
|
||||||
|
serviceNotes: d.serviceNotes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
|
<VendorCell
|
||||||
|
company={draft.company}
|
||||||
|
tech={draft.tech}
|
||||||
|
techPhone={draft.techPhone}
|
||||||
|
vendors={vendors}
|
||||||
|
onSave={(p) =>
|
||||||
|
onPatch({
|
||||||
|
vendorId: p.vendorId,
|
||||||
|
company: p.company,
|
||||||
|
tech: p.tech,
|
||||||
|
techPhone: p.techPhone,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
|
<ApptTimeCell
|
||||||
|
apptTime={
|
||||||
|
draft.apptStart ? `${draft.apptStart}${draft.apptEnd ? ` – ${draft.apptEnd}` : ""}` : ""
|
||||||
|
}
|
||||||
|
onSave={(v) => {
|
||||||
|
const [start, end] = v.split(" – ");
|
||||||
|
onPatch({ apptStart: start ?? "", apptEnd: end ?? "" });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
|
<StatusCell
|
||||||
|
wo={draftRow as never}
|
||||||
|
onChangeStatus={(status) => onPatch({ status })}
|
||||||
|
onOpenSlideOver={() => {}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td style={{ padding: "0 14px" }}>
|
||||||
|
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>—</span>
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { CircularProgress } from "@mui/material";
|
import { Flag } from "lucide-react";
|
||||||
import { Check, Flag, X } from "lucide-react";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
LocationOption,
|
LocationOption,
|
||||||
|
|
@ -11,25 +10,9 @@ import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-
|
||||||
|
|
||||||
import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row";
|
import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
import { AssignedToCell } from "./cells/assigned-to-cell";
|
|
||||||
|
|
||||||
import { ApptTimeCell } from "./cells/appt-time-cell";
|
|
||||||
|
|
||||||
import { DateCell } from "./cells/date-cell";
|
|
||||||
|
|
||||||
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
|
|
||||||
|
|
||||||
import { PMTypeCell } from "./cells/pm-type-cell";
|
|
||||||
|
|
||||||
import { SiteCell } from "./cells/site-cell";
|
|
||||||
|
|
||||||
import { StatusCell } from "./cells/status-cell";
|
|
||||||
|
|
||||||
import { TypeCell } from "./cells/type-cell";
|
|
||||||
|
|
||||||
import { VendorCell } from "./cells/vendor-cell";
|
|
||||||
|
|
||||||
import { WO_TABLE_ROW_HEIGHT } from "./wo-table-cols";
|
import { WO_TABLE_ROW_HEIGHT } from "./wo-table-cols";
|
||||||
|
import { WoTableInlineRowCells } from "./wo-table-inline-row-cells";
|
||||||
|
import { WoTableInlineRowActions } from "./wo-table-inline-row-actions";
|
||||||
|
|
||||||
type WoTableInlineRowProps = {
|
type WoTableInlineRowProps = {
|
||||||
draft: WorkOrderTableDraft;
|
draft: WorkOrderTableDraft;
|
||||||
|
|
@ -89,48 +72,6 @@ export function WoTableInlineRow({
|
||||||
|
|
||||||
const patch = (p: Partial<WorkOrderTableDraft>) => onChange({ ...draft, ...p });
|
const patch = (p: Partial<WorkOrderTableDraft>) => onChange({ ...draft, ...p });
|
||||||
|
|
||||||
const draftRow = {
|
|
||||||
id: "draft",
|
|
||||||
|
|
||||||
woNumber: draft.woNumber,
|
|
||||||
|
|
||||||
status: draft.status,
|
|
||||||
|
|
||||||
isPastDue: false,
|
|
||||||
|
|
||||||
type: draft.type,
|
|
||||||
|
|
||||||
rescheduleCount: 0,
|
|
||||||
|
|
||||||
carriedOver: 0,
|
|
||||||
|
|
||||||
docStatus: "" as const,
|
|
||||||
|
|
||||||
mediaCount: 0,
|
|
||||||
|
|
||||||
site: draft.site,
|
|
||||||
|
|
||||||
location: draft.location,
|
|
||||||
|
|
||||||
dispatcherId: draft.dispatcherId,
|
|
||||||
|
|
||||||
scheduledOn: draft.scheduledOn,
|
|
||||||
|
|
||||||
dueDate: draft.dueDate,
|
|
||||||
|
|
||||||
pm: draft.pm,
|
|
||||||
|
|
||||||
serviceNotes: draft.serviceNotes,
|
|
||||||
|
|
||||||
extraServices: draft.extraServices,
|
|
||||||
|
|
||||||
company: draft.company,
|
|
||||||
|
|
||||||
tech: draft.tech,
|
|
||||||
|
|
||||||
targetWeek: draft.targetWeek,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
aria-busy={saving || undefined}
|
aria-busy={saving || undefined}
|
||||||
|
|
@ -152,179 +93,25 @@ export function WoTableInlineRow({
|
||||||
<Flag size={14} strokeWidth={1.75} style={{ color: "var(--color-border)" }} />
|
<Flag size={14} strokeWidth={1.75} style={{ color: "var(--color-border)" }} />
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
<WoTableInlineRowCells
|
||||||
<SiteCell
|
draft={draft}
|
||||||
value={draft.site}
|
weekDefault={weekDefault}
|
||||||
location={draft.location}
|
users={users}
|
||||||
locationId={draft.locationId}
|
sites={sites}
|
||||||
city={sitePlace?.city}
|
sitePlace={sitePlace}
|
||||||
state={sitePlace?.state}
|
vendors={vendors}
|
||||||
pocName={draft.pocName}
|
pmTypes={pmTypes}
|
||||||
pocPhone={draft.pocPhone}
|
allRows={allRows}
|
||||||
pocNotes={draft.pocNotes}
|
onPatch={patch}
|
||||||
sites={sites}
|
/>
|
||||||
onSave={(p) =>
|
|
||||||
patch({
|
|
||||||
site: p.site,
|
|
||||||
|
|
||||||
location: p.location,
|
|
||||||
|
|
||||||
locationId: p.locationId,
|
|
||||||
|
|
||||||
pocName: p.pocName,
|
|
||||||
|
|
||||||
pocPhone: p.pocPhone,
|
|
||||||
|
|
||||||
pocNotes: p.pocNotes,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td style={{ padding: "0 14px" }}>
|
|
||||||
<EditableWONumberCell
|
|
||||||
value={draft.woNumber}
|
|
||||||
rescheduleCount={0}
|
|
||||||
currentId="draft"
|
|
||||||
allOrders={allRows as never[]}
|
|
||||||
onSave={(v) => patch({ woNumber: v })}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
|
||||||
<TypeCell value={draft.type} onSave={(t) => patch({ type: t })} />
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
|
||||||
<AssignedToCell
|
|
||||||
dispatcherId={draft.dispatcherId}
|
|
||||||
users={users}
|
|
||||||
highlight={!draft.dispatcherId}
|
|
||||||
onSave={(id) => {
|
|
||||||
patch({ dispatcherId: id, status: draft.status || "Incomplete" });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
|
||||||
<DateCell
|
|
||||||
value={draft.scheduledOn}
|
|
||||||
allowWeekMode
|
|
||||||
targetWeek={draft.targetWeek}
|
|
||||||
defaultMode={weekDefault ? "week" : "date"}
|
|
||||||
highlight={!draft.scheduledOn && !draft.targetWeek}
|
|
||||||
onSave={(date) => patch({ scheduledOn: date, scheduleWeekOnly: false, targetWeek: "" })}
|
|
||||||
onSaveWeek={(week) =>
|
|
||||||
patch({ scheduledOn: "", targetWeek: week, scheduleWeekOnly: true })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
|
||||||
<DateCell value={draft.dueDate} onSave={(date) => patch({ dueDate: date })} />
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
|
||||||
<PMTypeCell
|
|
||||||
value={draft.pm}
|
|
||||||
pmTypes={pmTypes}
|
|
||||||
serviceNotes={draft.serviceNotes}
|
|
||||||
extraServices={draft.extraServices}
|
|
||||||
woNumber={draft.woNumber}
|
|
||||||
onSave={(pm) => patch({ pm })}
|
|
||||||
onSaveDetails={(d) =>
|
|
||||||
patch({
|
|
||||||
pm: d.pm,
|
|
||||||
extraServices: d.extraServices,
|
|
||||||
serviceNotes: d.serviceNotes,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
|
||||||
<VendorCell
|
|
||||||
company={draft.company}
|
|
||||||
tech={draft.tech}
|
|
||||||
techPhone={draft.techPhone}
|
|
||||||
vendors={vendors}
|
|
||||||
onSave={(p) =>
|
|
||||||
patch({
|
|
||||||
vendorId: p.vendorId,
|
|
||||||
company: p.company,
|
|
||||||
tech: p.tech,
|
|
||||||
techPhone: p.techPhone,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
|
||||||
<ApptTimeCell
|
|
||||||
apptTime={
|
|
||||||
draft.apptStart ? `${draft.apptStart}${draft.apptEnd ? ` – ${draft.apptEnd}` : ""}` : ""
|
|
||||||
}
|
|
||||||
onSave={(v) => {
|
|
||||||
const [start, end] = v.split(" – ");
|
|
||||||
|
|
||||||
patch({ apptStart: start ?? "", apptEnd: end ?? "" });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
|
||||||
<StatusCell
|
|
||||||
wo={draftRow as never}
|
|
||||||
onChangeStatus={(status) => patch({ status })}
|
|
||||||
onOpenSlideOver={() => {}}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td style={{ padding: "0 14px" }}>
|
|
||||||
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>—</span>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td style={{ padding: "0 4px" }}>
|
<td style={{ padding: "0 4px" }}>
|
||||||
<div className="flex items-center gap-0.5">
|
<WoTableInlineRowActions
|
||||||
<button
|
canSave={canSave}
|
||||||
type="button"
|
saving={saving}
|
||||||
onClick={onSave}
|
onSave={onSave}
|
||||||
disabled={!canSave}
|
onCancel={onCancel}
|
||||||
title={saving ? "Saving…" : canSave ? "Save" : "Schedule On is required"}
|
/>
|
||||||
aria-label={saving ? "Saving work order" : "Save work order"}
|
|
||||||
className="rounded p-1 transition-colors disabled:opacity-30"
|
|
||||||
style={{ color: "var(--color-primary)" }}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (canSave) {
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.background =
|
|
||||||
"var(--color-primary-tint)";
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{saving ? <CircularProgress size={13} color="inherit" /> : <Check size={13} />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onCancel}
|
|
||||||
disabled={saving}
|
|
||||||
title="Cancel"
|
|
||||||
aria-label="Cancel draft"
|
|
||||||
className="rounded p-1 transition-colors disabled:opacity-30"
|
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.background = "var(--color-bg-muted)";
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X size={13} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,123 @@
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import type { LocationOption, WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { AssignedToCell } from "./cells/assigned-to-cell";
|
||||||
|
import { DateCell } from "./cells/date-cell";
|
||||||
|
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
|
||||||
|
import { SiteCell } from "./cells/site-cell";
|
||||||
|
import { TypeCell } from "./cells/type-cell";
|
||||||
|
import type { WoTableRowHandlers } from "./wo-table-row";
|
||||||
|
|
||||||
|
type WoTableRowCellsProps = {
|
||||||
|
row: WorkOrderTableRow;
|
||||||
|
allRows: WorkOrderTableRow[];
|
||||||
|
users: WorkOrderUser[];
|
||||||
|
sites: LocationOption[];
|
||||||
|
sitePlace?: { city: string; state: string };
|
||||||
|
search?: string;
|
||||||
|
forceScheduleHighlight?: boolean;
|
||||||
|
mutationCellStyle?: CSSProperties;
|
||||||
|
handlers: WoTableRowHandlers;
|
||||||
|
onPatchRow: (patch: Partial<WorkOrderTableRow>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WoTableRowCells({
|
||||||
|
row,
|
||||||
|
allRows,
|
||||||
|
users,
|
||||||
|
sites,
|
||||||
|
sitePlace,
|
||||||
|
search,
|
||||||
|
forceScheduleHighlight,
|
||||||
|
mutationCellStyle,
|
||||||
|
handlers,
|
||||||
|
onPatchRow,
|
||||||
|
}: WoTableRowCellsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<td
|
||||||
|
className="group/cell"
|
||||||
|
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<SiteCell
|
||||||
|
value={row.site}
|
||||||
|
location={row.location}
|
||||||
|
locationId={row.locationId}
|
||||||
|
city={sitePlace?.city}
|
||||||
|
state={sitePlace?.state}
|
||||||
|
pocName={row.pocName}
|
||||||
|
pocPhone={row.pocPhone}
|
||||||
|
pocNotes={row.pocNotes}
|
||||||
|
sites={sites}
|
||||||
|
q={search}
|
||||||
|
onSave={(p) =>
|
||||||
|
onPatchRow({
|
||||||
|
site: p.site,
|
||||||
|
location: p.location,
|
||||||
|
locationId: p.locationId,
|
||||||
|
pocName: p.pocName,
|
||||||
|
pocPhone: p.pocPhone,
|
||||||
|
pocNotes: p.pocNotes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "0 14px", whiteSpace: "nowrap", ...mutationCellStyle }}>
|
||||||
|
<EditableWONumberCell
|
||||||
|
value={row.woNumber}
|
||||||
|
rescheduleCount={row.rescheduleCount}
|
||||||
|
carriedOver={row.carriedOver}
|
||||||
|
q={search}
|
||||||
|
currentId={row.id}
|
||||||
|
allOrders={allRows}
|
||||||
|
onSave={(v) => onPatchRow({ woNumber: v })}
|
||||||
|
onDuplicateFound={handlers.onDuplicateFound}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="group/cell"
|
||||||
|
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<TypeCell value={row.type} q={search} onSave={(t) => onPatchRow({ type: t })} />
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="group/cell"
|
||||||
|
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<AssignedToCell
|
||||||
|
dispatcherId={row.dispatcherId}
|
||||||
|
users={users}
|
||||||
|
highlight={!row.dispatcherId}
|
||||||
|
onSave={(id, name) =>
|
||||||
|
handlers.onPatch(row.id, { dispatcherId: id, dispatcherName: name })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="group/cell"
|
||||||
|
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<DateCell
|
||||||
|
value={row.scheduledOn}
|
||||||
|
q={search}
|
||||||
|
highlight={forceScheduleHighlight || row.isPastDue}
|
||||||
|
allowWeekMode
|
||||||
|
targetWeek={row.targetWeek}
|
||||||
|
onSave={(date) => handlers.onScheduleDate(row.id, date)}
|
||||||
|
onSaveWeek={(week) => handlers.onScheduleWeek(row.id, week)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="group/cell"
|
||||||
|
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<DateCell value={row.dueDate} q={search} onSave={(date) => onPatchRow({ dueDate: date })} />
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { ApptTimeCell } from "./cells/appt-time-cell";
|
||||||
|
import { CompDocCell } from "./cells/comp-doc-cell";
|
||||||
|
import { PMTypeCell } from "./cells/pm-type-cell";
|
||||||
|
import { StatusCell } from "./cells/status-cell";
|
||||||
|
import { VendorCell } from "./cells/vendor-cell";
|
||||||
|
import type { WoTableRowHandlers } from "./wo-table-row";
|
||||||
|
|
||||||
|
type WoTableRowServiceCellsProps = {
|
||||||
|
row: WorkOrderTableRow;
|
||||||
|
vendors: VendorDropdownItem[];
|
||||||
|
pmTypes: string[];
|
||||||
|
search?: string;
|
||||||
|
mutationCellStyle?: React.CSSProperties;
|
||||||
|
handlers: WoTableRowHandlers;
|
||||||
|
onPatchRow: (patch: Partial<WorkOrderTableRow>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WoTableRowServiceCells({
|
||||||
|
row,
|
||||||
|
vendors,
|
||||||
|
pmTypes,
|
||||||
|
search,
|
||||||
|
mutationCellStyle,
|
||||||
|
handlers,
|
||||||
|
onPatchRow,
|
||||||
|
}: WoTableRowServiceCellsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<td
|
||||||
|
className="group/cell"
|
||||||
|
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<PMTypeCell
|
||||||
|
value={row.pm}
|
||||||
|
pmTypes={pmTypes}
|
||||||
|
q={search}
|
||||||
|
serviceNotes={row.serviceNotes}
|
||||||
|
extraServices={row.extraServices}
|
||||||
|
woNumber={row.woNumber}
|
||||||
|
onSave={(pm) => onPatchRow({ pm })}
|
||||||
|
onSaveDetails={(d) =>
|
||||||
|
onPatchRow({
|
||||||
|
pm: d.pm,
|
||||||
|
extraServices: d.extraServices,
|
||||||
|
serviceNotes: d.serviceNotes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="group/cell"
|
||||||
|
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<VendorCell
|
||||||
|
company={row.company}
|
||||||
|
tech={row.tech}
|
||||||
|
techPhone={row.techPhone}
|
||||||
|
vendors={vendors}
|
||||||
|
q={search}
|
||||||
|
onSave={(p) =>
|
||||||
|
onPatchRow({
|
||||||
|
vendorId: p.vendorId,
|
||||||
|
company: p.company,
|
||||||
|
tech: p.tech,
|
||||||
|
techPhone: p.techPhone,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="group/cell"
|
||||||
|
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<ApptTimeCell apptTime={row.apptTime} onSave={(v) => onPatchRow({ apptTime: v })} />
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className="group/cell"
|
||||||
|
style={{ padding: "0 14px", ...mutationCellStyle }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<StatusCell
|
||||||
|
wo={row}
|
||||||
|
onChangeStatus={(status) => onPatchRow({ status })}
|
||||||
|
onOpenSlideOver={() => handlers.onOpenSlideOver(row)}
|
||||||
|
onPastDueAttempt={() => handlers.onPastDueAttempt?.(row.id)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<CompDocCell
|
||||||
|
status={row.docStatus}
|
||||||
|
woType={row.type}
|
||||||
|
mediaCount={row.mediaCount}
|
||||||
|
onOpen={handlers.onOpenCompDoc ? () => handlers.onOpenCompDoc!(row) : undefined}
|
||||||
|
onOpenMedia={() => handlers.onOpenMedia(row)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { WO_TABLE_ROW_HEIGHT } from "./wo-table-cols";
|
||||||
|
|
||||||
|
export const MUTATION_CELL_STYLE: CSSProperties = { pointerEvents: "none" };
|
||||||
|
|
||||||
|
export function resolveFirstCellShadow(row: WorkOrderTableRow): string {
|
||||||
|
if (row.type === "Emergency") {
|
||||||
|
return "inset 3px 0 0 var(--destructive)";
|
||||||
|
}
|
||||||
|
if (row.isPastDue) {
|
||||||
|
return "inset 3px 0 0 var(--warning)";
|
||||||
|
}
|
||||||
|
return "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
type WoTableRowStyleInput = {
|
||||||
|
isDragging: boolean;
|
||||||
|
isDropTarget: boolean;
|
||||||
|
isFlashing: boolean;
|
||||||
|
isSelected: boolean;
|
||||||
|
isCanceled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveWoTableRowStyle({
|
||||||
|
isDragging,
|
||||||
|
isDropTarget,
|
||||||
|
isFlashing,
|
||||||
|
isSelected,
|
||||||
|
isCanceled,
|
||||||
|
}: WoTableRowStyleInput): CSSProperties {
|
||||||
|
return {
|
||||||
|
height: WO_TABLE_ROW_HEIGHT,
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
borderTop: isDropTarget ? "2px solid var(--color-primary)" : undefined,
|
||||||
|
opacity: isCanceled ? 0.45 : isDragging ? 0.4 : 1,
|
||||||
|
background: isFlashing || isSelected ? "var(--color-primary-tint)" : "var(--card)",
|
||||||
|
outline: isFlashing ? "2px solid var(--color-primary)" : "none",
|
||||||
|
outlineOffset: isFlashing ? "-2px" : undefined,
|
||||||
|
borderRadius: isFlashing ? 4 : undefined,
|
||||||
|
transition: "background 1.8s ease-out, outline 1.8s ease-out",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -6,19 +6,15 @@ import type {
|
||||||
} from "@/domain/work-orders/types/work-order";
|
} from "@/domain/work-orders/types/work-order";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
|
import type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
|
||||||
import { AssignedToCell } from "./cells/assigned-to-cell";
|
|
||||||
import { ApptTimeCell } from "./cells/appt-time-cell";
|
|
||||||
import { CompDocCell } from "./cells/comp-doc-cell";
|
|
||||||
import { DateCell } from "./cells/date-cell";
|
|
||||||
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
|
|
||||||
import { FlagCell } from "./cells/flag-cell";
|
import { FlagCell } from "./cells/flag-cell";
|
||||||
import { PMTypeCell } from "./cells/pm-type-cell";
|
|
||||||
import { SiteCell } from "./cells/site-cell";
|
|
||||||
import { StatusCell } from "./cells/status-cell";
|
|
||||||
import { TypeCell } from "./cells/type-cell";
|
|
||||||
import { VendorCell } from "./cells/vendor-cell";
|
|
||||||
import { WO_TABLE_ROW_HEIGHT } from "./wo-table-cols";
|
|
||||||
import { WoTableRowActions } from "./wo-table-row-actions";
|
import { WoTableRowActions } from "./wo-table-row-actions";
|
||||||
|
import { WoTableRowCells } from "./wo-table-row-cells";
|
||||||
|
import { WoTableRowServiceCells } from "./wo-table-row-service-cells";
|
||||||
|
import {
|
||||||
|
MUTATION_CELL_STYLE,
|
||||||
|
resolveFirstCellShadow,
|
||||||
|
resolveWoTableRowStyle,
|
||||||
|
} from "./wo-table-row-style";
|
||||||
|
|
||||||
export type WoTableRowHandlers = {
|
export type WoTableRowHandlers = {
|
||||||
onRowClick: (row: WorkOrderTableRow) => void;
|
onRowClick: (row: WorkOrderTableRow) => void;
|
||||||
|
|
@ -74,20 +70,13 @@ export function WoTableRow({
|
||||||
const isDropTarget = dragViz.overId === row.id && dragViz.dragId !== row.id;
|
const isDropTarget = dragViz.overId === row.id && dragViz.dragId !== row.id;
|
||||||
const isCanceled = (row.status as string) === "Canceled";
|
const isCanceled = (row.status as string) === "Canceled";
|
||||||
|
|
||||||
let firstCellShadow = "none";
|
|
||||||
if (row.type === "Emergency") {
|
|
||||||
firstCellShadow = "inset 3px 0 0 var(--destructive)";
|
|
||||||
} else if (row.isPastDue) {
|
|
||||||
firstCellShadow = "inset 3px 0 0 var(--warning)";
|
|
||||||
}
|
|
||||||
|
|
||||||
const patchRow = (patch: Partial<WorkOrderTableRow>) => {
|
const patchRow = (patch: Partial<WorkOrderTableRow>) => {
|
||||||
if (isCanceled) return;
|
if (isCanceled) return;
|
||||||
handlers.onPatch(row.id, patch);
|
handlers.onPatch(row.id, patch);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Block mutation controls on canceled rows while keeping row/View clickable. */
|
/** Block mutation controls on canceled rows while keeping row/View clickable. */
|
||||||
const mutationCellStyle = isCanceled ? ({ pointerEvents: "none" } as const) : undefined;
|
const mutationCellStyle = isCanceled ? MUTATION_CELL_STYLE : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
|
|
@ -111,17 +100,13 @@ export function WoTableRow({
|
||||||
}}
|
}}
|
||||||
onDragEnd={handlers.onDragEnd}
|
onDragEnd={handlers.onDragEnd}
|
||||||
className="group/row cursor-pointer transition-colors"
|
className="group/row cursor-pointer transition-colors"
|
||||||
style={{
|
style={resolveWoTableRowStyle({
|
||||||
height: WO_TABLE_ROW_HEIGHT,
|
isDragging,
|
||||||
borderBottom: "1px solid var(--border)",
|
isDropTarget,
|
||||||
borderTop: isDropTarget ? "2px solid var(--color-primary)" : undefined,
|
isFlashing,
|
||||||
opacity: isCanceled ? 0.45 : isDragging ? 0.4 : 1,
|
isSelected,
|
||||||
background: isFlashing || isSelected ? "var(--color-primary-tint)" : "var(--card)",
|
isCanceled,
|
||||||
outline: isFlashing ? "2px solid var(--color-primary)" : "none",
|
})}
|
||||||
outlineOffset: isFlashing ? "-2px" : undefined,
|
|
||||||
borderRadius: isFlashing ? 4 : undefined,
|
|
||||||
transition: "background 1.8s ease-out, outline 1.8s ease-out",
|
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (!isSelected && !isFlashing) {
|
if (!isSelected && !isFlashing) {
|
||||||
(e.currentTarget as HTMLTableRowElement).style.background = "var(--color-primary-tint)";
|
(e.currentTarget as HTMLTableRowElement).style.background = "var(--color-primary-tint)";
|
||||||
|
|
@ -134,7 +119,11 @@ export function WoTableRow({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<td
|
<td
|
||||||
style={{ padding: "0 0 0 6px", boxShadow: firstCellShadow, ...mutationCellStyle }}
|
style={{
|
||||||
|
padding: "0 0 0 6px",
|
||||||
|
boxShadow: resolveFirstCellShadow(row),
|
||||||
|
...mutationCellStyle,
|
||||||
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
|
|
@ -157,160 +146,27 @@ export function WoTableRow({
|
||||||
onChange={(c) => handlers.onPatch(row.id, { flagColor: c })}
|
onChange={(c) => handlers.onPatch(row.id, { flagColor: c })}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<WoTableRowCells
|
||||||
className="group/cell"
|
row={row}
|
||||||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
allRows={allRows}
|
||||||
onClick={(e) => e.stopPropagation()}
|
users={users}
|
||||||
>
|
sites={sites}
|
||||||
<SiteCell
|
sitePlace={sitePlace}
|
||||||
value={row.site}
|
search={search}
|
||||||
location={row.location}
|
forceScheduleHighlight={forceScheduleHighlight}
|
||||||
locationId={row.locationId}
|
mutationCellStyle={mutationCellStyle}
|
||||||
city={sitePlace?.city}
|
handlers={handlers}
|
||||||
state={sitePlace?.state}
|
onPatchRow={patchRow}
|
||||||
pocName={row.pocName}
|
/>
|
||||||
pocPhone={row.pocPhone}
|
<WoTableRowServiceCells
|
||||||
pocNotes={row.pocNotes}
|
row={row}
|
||||||
sites={sites}
|
vendors={vendors}
|
||||||
q={search}
|
pmTypes={pmTypes}
|
||||||
onSave={(p) =>
|
search={search}
|
||||||
patchRow({
|
mutationCellStyle={mutationCellStyle}
|
||||||
site: p.site,
|
handlers={handlers}
|
||||||
location: p.location,
|
onPatchRow={patchRow}
|
||||||
locationId: p.locationId,
|
/>
|
||||||
pocName: p.pocName,
|
|
||||||
pocPhone: p.pocPhone,
|
|
||||||
pocNotes: p.pocNotes,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: "0 14px", whiteSpace: "nowrap", ...mutationCellStyle }}>
|
|
||||||
<EditableWONumberCell
|
|
||||||
value={row.woNumber}
|
|
||||||
rescheduleCount={row.rescheduleCount}
|
|
||||||
carriedOver={row.carriedOver}
|
|
||||||
q={search}
|
|
||||||
currentId={row.id}
|
|
||||||
allOrders={allRows}
|
|
||||||
onSave={(v) => patchRow({ woNumber: v })}
|
|
||||||
onDuplicateFound={handlers.onDuplicateFound}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className="group/cell"
|
|
||||||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<TypeCell value={row.type} q={search} onSave={(t) => patchRow({ type: t })} />
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className="group/cell"
|
|
||||||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<AssignedToCell
|
|
||||||
dispatcherId={row.dispatcherId}
|
|
||||||
users={users}
|
|
||||||
highlight={!row.dispatcherId}
|
|
||||||
onSave={(id, name) =>
|
|
||||||
handlers.onPatch(row.id, { dispatcherId: id, dispatcherName: name })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className="group/cell"
|
|
||||||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<DateCell
|
|
||||||
value={row.scheduledOn}
|
|
||||||
q={search}
|
|
||||||
highlight={forceScheduleHighlight || row.isPastDue}
|
|
||||||
allowWeekMode
|
|
||||||
targetWeek={row.targetWeek}
|
|
||||||
onSave={(date) => handlers.onScheduleDate(row.id, date)}
|
|
||||||
onSaveWeek={(week) => handlers.onScheduleWeek(row.id, week)}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className="group/cell"
|
|
||||||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<DateCell value={row.dueDate} q={search} onSave={(date) => patchRow({ dueDate: date })} />
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className="group/cell"
|
|
||||||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<PMTypeCell
|
|
||||||
value={row.pm}
|
|
||||||
pmTypes={pmTypes}
|
|
||||||
q={search}
|
|
||||||
serviceNotes={row.serviceNotes}
|
|
||||||
extraServices={row.extraServices}
|
|
||||||
woNumber={row.woNumber}
|
|
||||||
onSave={(pm) => patchRow({ pm })}
|
|
||||||
onSaveDetails={(d) =>
|
|
||||||
patchRow({
|
|
||||||
pm: d.pm,
|
|
||||||
extraServices: d.extraServices,
|
|
||||||
serviceNotes: d.serviceNotes,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className="group/cell"
|
|
||||||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<VendorCell
|
|
||||||
company={row.company}
|
|
||||||
tech={row.tech}
|
|
||||||
techPhone={row.techPhone}
|
|
||||||
vendors={vendors}
|
|
||||||
q={search}
|
|
||||||
onSave={(p) =>
|
|
||||||
patchRow({
|
|
||||||
vendorId: p.vendorId,
|
|
||||||
company: p.company,
|
|
||||||
tech: p.tech,
|
|
||||||
techPhone: p.techPhone,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className="group/cell"
|
|
||||||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<ApptTimeCell apptTime={row.apptTime} onSave={(v) => patchRow({ apptTime: v })} />
|
|
||||||
</td>
|
|
||||||
<td
|
|
||||||
className="group/cell"
|
|
||||||
style={{ padding: "0 14px", ...mutationCellStyle }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<StatusCell
|
|
||||||
wo={row}
|
|
||||||
onChangeStatus={(status) => patchRow({ status })}
|
|
||||||
onOpenSlideOver={() => handlers.onOpenSlideOver(row)}
|
|
||||||
onPastDueAttempt={() => handlers.onPastDueAttempt?.(row.id)}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>
|
|
||||||
<CompDocCell
|
|
||||||
status={row.docStatus}
|
|
||||||
woType={row.type}
|
|
||||||
mediaCount={row.mediaCount}
|
|
||||||
onOpen={handlers.onOpenCompDoc ? () => handlers.onOpenCompDoc!(row) : undefined}
|
|
||||||
onOpenMedia={() => handlers.onOpenMedia(row)}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: "0 8px" }} onClick={(e) => e.stopPropagation()}>
|
<td style={{ padding: "0 8px" }} onClick={(e) => e.stopPropagation()}>
|
||||||
<WoTableRowActions
|
<WoTableRowActions
|
||||||
onView={() => handlers.onView(row)}
|
onView={() => handlers.onView(row)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { SearchX } from "lucide-react";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
const CARD_STYLE: React.CSSProperties = {
|
||||||
|
background: "var(--card)",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: 8,
|
||||||
|
boxShadow: "0 1px 3px rgba(38,34,98,.05)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WoTableShell({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-0 flex-1 overflow-hidden px-2 py-4">
|
||||||
|
<div className="relative h-full overflow-auto" style={CARD_STYLE}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type WoTableEmptyStateProps = {
|
||||||
|
message: string;
|
||||||
|
actionLabel: string;
|
||||||
|
onAction?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WoTableEmptyState({ message, actionLabel, onAction }: WoTableEmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex h-full flex-col items-center justify-center text-center"
|
||||||
|
style={{ gap: 14, padding: 48 }}
|
||||||
|
>
|
||||||
|
<SearchX size={34} style={{ color: "var(--color-text-muted)" }} />
|
||||||
|
<Text as="p" style={{ fontSize: 14, color: "var(--muted-foreground)" }}>
|
||||||
|
{message}
|
||||||
|
</Text>
|
||||||
|
{Boolean(onAction) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAction}
|
||||||
|
className="transition-opacity hover:opacity-80"
|
||||||
|
style={{ fontSize: 13, fontWeight: 500, color: "var(--color-accent)" }}
|
||||||
|
>
|
||||||
|
{actionLabel}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import { Fragment, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Text } from "@/components/ui/text";
|
|
||||||
import { SearchX } from "lucide-react";
|
|
||||||
import { useLocationSites } from "@/domain/locations/use-cases/use-location-sites";
|
import { useLocationSites } from "@/domain/locations/use-cases/use-location-sites";
|
||||||
import type {
|
import type {
|
||||||
LocationOption,
|
LocationOption,
|
||||||
|
|
@ -11,29 +9,19 @@ import type {
|
||||||
WorkOrderTablePatch,
|
WorkOrderTablePatch,
|
||||||
WorkOrderTableRow,
|
WorkOrderTableRow,
|
||||||
} from "@/domain/work-orders/types/work-order-table-row";
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import { UNSCHEDULED_DAY_KEY } from "@/domain/work-orders/types/work-order-table-row";
|
|
||||||
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
|
||||||
import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
|
import { useCreateWorkOrderFromWizard } from "@/domain/work-orders/use-cases/use-create-work-order-from-wizard";
|
||||||
import { applySchedulePatch } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
|
||||||
import { shouldNotifyCrossWeek } from "@/domain/work-orders/utils/should-notify-cross-week";
|
|
||||||
import { mondayOf } from "@/domain/work-orders/utils/wizard-date-utils";
|
|
||||||
import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton";
|
import { WoTableSkeleton } from "@/app/(protected)/workorders/_components/loading/wo-table-skeleton";
|
||||||
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
import type { ReturnTypeOfUseWorkOrdersTableData } from "./wo-table-types";
|
||||||
import { rescheduleNotify, weekMoveNotify } from "./reschedule-notify";
|
import { buildInlineDraftCreateInput } from "./build-inline-draft-create-input";
|
||||||
import { buildSitePlaceLookups, resolveSitePlace } from "./site-place-lookup";
|
import { buildSitePlaceLookups, resolveSitePlace } from "./site-place-lookup";
|
||||||
|
import { useWoTableRowHandlers } from "./use-wo-table-row-handlers";
|
||||||
|
import { WoTableBody } from "./wo-table-body";
|
||||||
import { WoTableHead } from "./wo-table-head";
|
import { WoTableHead } from "./wo-table-head";
|
||||||
import { WoTableInlineRow } from "./wo-table-inline-row";
|
import { WoTableInlineRow } from "./wo-table-inline-row";
|
||||||
import { WoTableRow } from "./wo-table-row";
|
import { WoTableRow } from "./wo-table-row";
|
||||||
import { WoTableSectionHeader } from "./wo-table-section-header";
|
import { WoTableEmptyState, WoTableShell } from "./wo-table-shell";
|
||||||
import { WO_TABLE_MIN_WIDTH } from "./wo-table-cols";
|
import { WO_TABLE_MIN_WIDTH } from "./wo-table-cols";
|
||||||
|
|
||||||
const CARD_STYLE: React.CSSProperties = {
|
|
||||||
background: "var(--card)",
|
|
||||||
border: "1px solid var(--border)",
|
|
||||||
borderRadius: 8,
|
|
||||||
boxShadow: "0 1px 3px rgba(38,34,98,.05)",
|
|
||||||
};
|
|
||||||
|
|
||||||
type WoTableProps = {
|
type WoTableProps = {
|
||||||
tableData: ReturnTypeOfUseWorkOrdersTableData;
|
tableData: ReturnTypeOfUseWorkOrdersTableData;
|
||||||
users: WorkOrderUser[];
|
users: WorkOrderUser[];
|
||||||
|
|
@ -51,48 +39,6 @@ type WoTableProps = {
|
||||||
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
|
onPatch: (id: string | number, patch: WorkOrderTablePatch) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function WoTableShell({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="min-h-0 flex-1 overflow-hidden px-2 py-4">
|
|
||||||
<div className="relative h-full overflow-auto" style={CARD_STYLE}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function WoTableEmptyState({
|
|
||||||
message,
|
|
||||||
actionLabel,
|
|
||||||
onAction,
|
|
||||||
}: {
|
|
||||||
message: string;
|
|
||||||
actionLabel: string;
|
|
||||||
onAction?: () => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex h-full flex-col items-center justify-center text-center"
|
|
||||||
style={{ gap: 14, padding: 48 }}
|
|
||||||
>
|
|
||||||
<SearchX size={34} style={{ color: "var(--color-text-muted)" }} />
|
|
||||||
<Text as="p" style={{ fontSize: 14, color: "var(--muted-foreground)" }}>
|
|
||||||
{message}
|
|
||||||
</Text>
|
|
||||||
{Boolean(onAction) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onAction}
|
|
||||||
className="transition-opacity hover:opacity-80"
|
|
||||||
style={{ fontSize: 13, fontWeight: 500, color: "var(--color-accent)" }}
|
|
||||||
>
|
|
||||||
{actionLabel}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WoTable({
|
export function WoTable({
|
||||||
tableData,
|
tableData,
|
||||||
users,
|
users,
|
||||||
|
|
@ -114,114 +60,17 @@ export function WoTable({
|
||||||
const createMutation = useCreateWorkOrderFromWizard(() => {
|
const createMutation = useCreateWorkOrderFromWizard(() => {
|
||||||
tableData.cancelDraft();
|
tableData.cancelDraft();
|
||||||
});
|
});
|
||||||
const {
|
|
||||||
dragState: dragStateRef,
|
|
||||||
setDragViz,
|
|
||||||
reorderRows,
|
|
||||||
endDrag,
|
|
||||||
setUnschedOpen,
|
|
||||||
triggerFlash,
|
|
||||||
} = tableData;
|
|
||||||
|
|
||||||
const handlers = useMemo(
|
const handlers = useWoTableRowHandlers({
|
||||||
() => ({
|
tableData,
|
||||||
onRowClick: (row: WorkOrderTableRow) => {
|
weekMonday,
|
||||||
tableData.setSelectedId(row.id);
|
onWeekChange,
|
||||||
onOpenSlideOver(row, false);
|
onOpenSlideOver,
|
||||||
},
|
onOpenCompDoc,
|
||||||
onView: (row: WorkOrderTableRow) => {
|
onOpenMedia,
|
||||||
tableData.setSelectedId(row.id);
|
onPatch,
|
||||||
onOpenSlideOver(row, false);
|
onScheduleHighlight: setScheduleHighlightId,
|
||||||
},
|
});
|
||||||
onEdit: (row: WorkOrderTableRow) => {
|
|
||||||
tableData.setSelectedId(row.id);
|
|
||||||
onOpenSlideOver(row, true);
|
|
||||||
},
|
|
||||||
onOpenCompDoc: onOpenCompDoc
|
|
||||||
? (row: WorkOrderTableRow) => {
|
|
||||||
tableData.setSelectedId(row.id);
|
|
||||||
onOpenCompDoc(row);
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
onOpenMedia: (row: WorkOrderTableRow) => {
|
|
||||||
tableData.setSelectedId(row.id);
|
|
||||||
onOpenMedia(row);
|
|
||||||
},
|
|
||||||
onPatch: (id: string | number, patch: Partial<WorkOrderTableRow>) => {
|
|
||||||
onPatch(id, patch);
|
|
||||||
},
|
|
||||||
onScheduleDate: (id: string | number, date: string) => {
|
|
||||||
onPatch(id, applySchedulePatch(date, false));
|
|
||||||
if (shouldNotifyCrossWeek(date, weekMonday)) {
|
|
||||||
const targetMonday = mondayOf(date);
|
|
||||||
rescheduleNotify(date, {
|
|
||||||
onNavigate: () => {
|
|
||||||
onWeekChange(targetMonday);
|
|
||||||
setTimeout(() => triggerFlash(id), 180);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onScheduleWeek: (id: string | number, week: string) => {
|
|
||||||
onPatch(id, {
|
|
||||||
scheduledOn: "",
|
|
||||||
targetWeek: week,
|
|
||||||
scheduleWeekOnly: true,
|
|
||||||
dayGroup: "",
|
|
||||||
dayLabel: "",
|
|
||||||
});
|
|
||||||
setUnschedOpen(true);
|
|
||||||
triggerFlash(id);
|
|
||||||
weekMoveNotify(week, {
|
|
||||||
onNavigate: () => {
|
|
||||||
setUnschedOpen(true);
|
|
||||||
setTimeout(() => triggerFlash(id), 180);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onDuplicateFound: (existing: WorkOrderTableRow) => {
|
|
||||||
tableData.setSelectedId(existing.id);
|
|
||||||
onOpenSlideOver(existing, false);
|
|
||||||
},
|
|
||||||
onOpenSlideOver: (row: WorkOrderTableRow) => onOpenSlideOver(row, false),
|
|
||||||
onPastDueAttempt: (id: string | number) => setScheduleHighlightId(id),
|
|
||||||
onReorder: reorderRows,
|
|
||||||
onDragStart: (id: string | number) => {
|
|
||||||
dragStateRef.current = { dragId: id, overId: null };
|
|
||||||
setDragViz({ dragId: id, overId: null });
|
|
||||||
},
|
|
||||||
onDragOver: (id: string | number) => {
|
|
||||||
const did = dragStateRef.current.dragId;
|
|
||||||
if (did && did !== id && dragStateRef.current.overId !== id) {
|
|
||||||
dragStateRef.current.overId = id;
|
|
||||||
setDragViz((v) => ({ ...v, overId: id }));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDrop: () => {
|
|
||||||
const { dragId, overId } = dragStateRef.current;
|
|
||||||
if (dragId && overId) {
|
|
||||||
reorderRows(dragId, overId);
|
|
||||||
}
|
|
||||||
endDrag();
|
|
||||||
},
|
|
||||||
onDragEnd: endDrag,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
tableData,
|
|
||||||
dragStateRef,
|
|
||||||
setDragViz,
|
|
||||||
reorderRows,
|
|
||||||
endDrag,
|
|
||||||
setUnschedOpen,
|
|
||||||
triggerFlash,
|
|
||||||
weekMonday,
|
|
||||||
onWeekChange,
|
|
||||||
onOpenSlideOver,
|
|
||||||
onOpenCompDoc,
|
|
||||||
onOpenMedia,
|
|
||||||
onPatch,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
|
const renderRow = (row: WorkOrderTableRow, forceScheduleHighlight = false) => (
|
||||||
<WoTableRow
|
<WoTableRow
|
||||||
|
|
@ -242,38 +91,29 @@ export function WoTable({
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSaveDraft = () => {
|
const renderInlineRow = (dayKey: string) =>
|
||||||
const d = tableData.draft;
|
tableData.draftDayKey === dayKey && (
|
||||||
const assignee = users.find((u) => String(u.id) === d.dispatcherId);
|
<WoTableInlineRow
|
||||||
const location = sites.find((s) => String(s.id) === String(d.locationId));
|
draft={tableData.draft}
|
||||||
const locationName = d.location || d.site || location?.name || "";
|
draftDayKey={tableData.draftDayKey}
|
||||||
createMutation.mutate({
|
users={users}
|
||||||
locationId: String(d.locationId || ""),
|
sites={sites}
|
||||||
locationName,
|
sitePlace={resolveSitePlace(
|
||||||
siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }),
|
tableData.draft.locationId,
|
||||||
woNumber: d.woNumber,
|
tableData.draft.site,
|
||||||
type: d.type,
|
sitePlaceLookups,
|
||||||
assigneeId: d.dispatcherId,
|
)}
|
||||||
assigneeName: assignee?.name ?? "",
|
vendors={vendors}
|
||||||
scheduledOn: d.scheduledOn,
|
pmTypes={pmTypes}
|
||||||
dueDate: d.dueDate,
|
allRows={tableData.rows}
|
||||||
pm: d.pm,
|
saving={createMutation.isPending}
|
||||||
extraServices: d.extraServices ?? [],
|
onChange={tableData.setDraft}
|
||||||
pmNote: d.serviceNotes ?? "",
|
onSave={() =>
|
||||||
vendorId: d.vendorId || "",
|
createMutation.mutate(buildInlineDraftCreateInput(tableData.draft, users, sites))
|
||||||
vendorName: d.company,
|
}
|
||||||
techPhone: d.techPhone,
|
onCancel={tableData.cancelDraft}
|
||||||
vendorNotes: "",
|
/>
|
||||||
apptStart: d.apptStart,
|
);
|
||||||
apptEnd: d.apptEnd,
|
|
||||||
status: d.status || "Incomplete",
|
|
||||||
pocName: d.pocName,
|
|
||||||
pocPhone: d.pocPhone,
|
|
||||||
pocNotes: d.pocNotes,
|
|
||||||
scheduleWeekOnly: d.scheduleWeekOnly,
|
|
||||||
targetWeek: d.targetWeek || "",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (tableData.isLoading) {
|
if (tableData.isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -319,84 +159,11 @@ export function WoTable({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<WoTableHead />
|
<WoTableHead />
|
||||||
<tbody>
|
<WoTableBody
|
||||||
{tableData.advActive ? (
|
tableData={tableData}
|
||||||
tableData.advResults.map((row) => renderRow(row))
|
renderRow={renderRow}
|
||||||
) : (
|
renderInlineRow={renderInlineRow}
|
||||||
<>
|
/>
|
||||||
{(tableData.unscheduled.length > 0 ||
|
|
||||||
tableData.draftDayKey === UNSCHEDULED_DAY_KEY) && (
|
|
||||||
<>
|
|
||||||
<WoTableSectionHeader
|
|
||||||
variant="unscheduled"
|
|
||||||
label="Unscheduled"
|
|
||||||
count={tableData.unscheduled.length}
|
|
||||||
collapsed={!tableData.unschedOpen}
|
|
||||||
onToggleCollapse={() => tableData.setUnschedOpen((v) => !v)}
|
|
||||||
onAddWo={() => tableData.startDayDraft(UNSCHEDULED_DAY_KEY)}
|
|
||||||
/>
|
|
||||||
{tableData.unschedOpen && (
|
|
||||||
<>
|
|
||||||
{tableData.draftDayKey === UNSCHEDULED_DAY_KEY && (
|
|
||||||
<WoTableInlineRow
|
|
||||||
draft={tableData.draft}
|
|
||||||
draftDayKey={tableData.draftDayKey}
|
|
||||||
users={users}
|
|
||||||
sites={sites}
|
|
||||||
sitePlace={resolveSitePlace(
|
|
||||||
tableData.draft.locationId,
|
|
||||||
tableData.draft.site,
|
|
||||||
sitePlaceLookups,
|
|
||||||
)}
|
|
||||||
vendors={vendors}
|
|
||||||
pmTypes={pmTypes}
|
|
||||||
allRows={tableData.rows}
|
|
||||||
saving={createMutation.isPending}
|
|
||||||
onChange={tableData.setDraft}
|
|
||||||
onSave={handleSaveDraft}
|
|
||||||
onCancel={tableData.cancelDraft}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{tableData.unscheduled.map((row) => renderRow(row, true))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{tableData.displayGroups.map((group) => (
|
|
||||||
<Fragment key={group.key}>
|
|
||||||
<WoTableSectionHeader
|
|
||||||
variant="day"
|
|
||||||
label={group.label}
|
|
||||||
count={group.items.length}
|
|
||||||
isToday={group.key === tableData.todayKey}
|
|
||||||
onAddWo={() => tableData.startDayDraft(group.key, group.date)}
|
|
||||||
/>
|
|
||||||
{tableData.draftDayKey === group.key && (
|
|
||||||
<WoTableInlineRow
|
|
||||||
draft={tableData.draft}
|
|
||||||
draftDayKey={tableData.draftDayKey}
|
|
||||||
users={users}
|
|
||||||
sites={sites}
|
|
||||||
sitePlace={resolveSitePlace(
|
|
||||||
tableData.draft.locationId,
|
|
||||||
tableData.draft.site,
|
|
||||||
sitePlaceLookups,
|
|
||||||
)}
|
|
||||||
vendors={vendors}
|
|
||||||
pmTypes={pmTypes}
|
|
||||||
allRows={tableData.rows}
|
|
||||||
saving={createMutation.isPending}
|
|
||||||
onChange={tableData.setDraft}
|
|
||||||
onSave={handleSaveDraft}
|
|
||||||
onCancel={tableData.cancelDraft}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{group.items.map((row) => renderRow(row))}
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
</table>
|
||||||
</WoTableShell>
|
</WoTableShell>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||||
|
import type {
|
||||||
|
LocationOption,
|
||||||
|
VendorDropdownItem,
|
||||||
|
} from "@/domain/work-orders/types/work-order-common";
|
||||||
|
import type { WorkOrderBoardSearchQueryParams } from "@/domain/work-orders/types/work-order-board";
|
||||||
|
import {
|
||||||
|
BOARD_SEARCH_MAX_PAGE_SIZE,
|
||||||
|
toBoardSearchQueryParams,
|
||||||
|
} from "@/domain/work-orders/utils/board-query-params";
|
||||||
|
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||||
|
|
||||||
|
export type ResolvedBoardSearchParamsInput = {
|
||||||
|
advApplied: WorkOrderAdvancedFilters | null;
|
||||||
|
search: string;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
sortBy: string;
|
||||||
|
sortDir: string;
|
||||||
|
locations: LocationOption[];
|
||||||
|
vendors: VendorDropdownItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveSiteCodes(sites: Set<string>, locations: LocationOption[]): Set<string> {
|
||||||
|
const siteCodes = new Set<string>();
|
||||||
|
for (const siteKey of sites) {
|
||||||
|
const location = locations.find((item) => String(item.id) === siteKey);
|
||||||
|
if (location) {
|
||||||
|
const code = resolveSiteCode({ siteCode: location.siteCode, name: location.name });
|
||||||
|
if (code) {
|
||||||
|
siteCodes.add(code);
|
||||||
|
}
|
||||||
|
} else if (siteKey.trim()) {
|
||||||
|
siteCodes.add(siteKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return siteCodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildVendorIdsByCompanyName(vendors: VendorDropdownItem[]): Map<string, number> {
|
||||||
|
const vendorIdsByCompanyName = new Map<string, number>();
|
||||||
|
for (const vendor of vendors) {
|
||||||
|
const id = Number(vendor.id);
|
||||||
|
if (Number.isFinite(id) && vendor.companyName) {
|
||||||
|
vendorIdsByCompanyName.set(vendor.companyName, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return vendorIdsByCompanyName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolves advanced-filter site keys/vendor names to API-ready board/search query params. */
|
||||||
|
export function buildResolvedBoardSearchParams(
|
||||||
|
input: ResolvedBoardSearchParamsInput,
|
||||||
|
): WorkOrderBoardSearchQueryParams | null {
|
||||||
|
const { advApplied } = input;
|
||||||
|
if (!advApplied) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const siteCodes = resolveSiteCodes(advApplied.sites, input.locations);
|
||||||
|
const vendorIdsByCompanyName = buildVendorIdsByCompanyName(input.vendors);
|
||||||
|
|
||||||
|
return toBoardSearchQueryParams({
|
||||||
|
filters: { ...advApplied, sites: siteCodes },
|
||||||
|
search: input.search,
|
||||||
|
page: input.page,
|
||||||
|
pageSize: Math.min(input.pageSize || BOARD_SEARCH_MAX_PAGE_SIZE, BOARD_SEARCH_MAX_PAGE_SIZE),
|
||||||
|
sortBy: input.sortBy || "scheduledDate",
|
||||||
|
sortDir: input.sortDir || "asc",
|
||||||
|
vendorIdsByCompanyName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
||||||
|
import { buildResolvedBoardSearchParams } from "@/app/(protected)/workorders/_hooks/build-resolved-board-search-params";
|
||||||
|
import {
|
||||||
|
useWorkOrdersBoard,
|
||||||
|
useWorkOrdersBoardSearch,
|
||||||
|
} from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||||
|
import {
|
||||||
|
useWorkOrderLocations,
|
||||||
|
useWorkOrderVendors,
|
||||||
|
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
|
import type { WorkOrderBoardListResult } from "@/domain/work-orders/types/work-order-board";
|
||||||
|
import type { UseQueryResult } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
|
||||||
|
|
||||||
|
export type WorkOrdersBoardSource = {
|
||||||
|
advActive: boolean;
|
||||||
|
data: WorkOrderBoardListResult | undefined;
|
||||||
|
isLoading: boolean;
|
||||||
|
isFetching: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
refetch: UseQueryResult<WorkOrderBoardListResult, Error>["refetch"];
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Picks between the weekly board query and the advanced-filters search query. */
|
||||||
|
export function useWorkOrdersBoardSource(filters: FiltersReturn): WorkOrdersBoardSource {
|
||||||
|
const advActive = filters.advApplied !== null;
|
||||||
|
const { data: locations = [] } = useWorkOrderLocations();
|
||||||
|
const { data: vendors = [] } = useWorkOrderVendors();
|
||||||
|
|
||||||
|
const resolvedSearchParams = useMemo(
|
||||||
|
() =>
|
||||||
|
buildResolvedBoardSearchParams({
|
||||||
|
advApplied: filters.advApplied,
|
||||||
|
search: filters.search,
|
||||||
|
page: filters.page,
|
||||||
|
pageSize: filters.pageSize,
|
||||||
|
sortBy: filters.sortBy,
|
||||||
|
sortDir: filters.sortDir,
|
||||||
|
locations,
|
||||||
|
vendors,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
filters.advApplied,
|
||||||
|
filters.search,
|
||||||
|
filters.page,
|
||||||
|
filters.pageSize,
|
||||||
|
filters.sortBy,
|
||||||
|
filters.sortDir,
|
||||||
|
locations,
|
||||||
|
vendors,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: boardData,
|
||||||
|
isLoading: boardLoading,
|
||||||
|
isFetching: boardFetching,
|
||||||
|
error: boardError,
|
||||||
|
refetch: refetchBoard,
|
||||||
|
} = useWorkOrdersBoard(filters.boardParams, !advActive);
|
||||||
|
const {
|
||||||
|
data: searchData,
|
||||||
|
isLoading: searchLoading,
|
||||||
|
isFetching: searchFetching,
|
||||||
|
error: searchError,
|
||||||
|
refetch: refetchSearch,
|
||||||
|
} = useWorkOrdersBoardSearch(resolvedSearchParams ?? { datePreset: "ThisWeek" }, advActive);
|
||||||
|
|
||||||
|
return {
|
||||||
|
advActive,
|
||||||
|
data: advActive ? searchData : boardData,
|
||||||
|
isLoading: advActive ? searchLoading : boardLoading,
|
||||||
|
isFetching: advActive ? searchFetching : boardFetching,
|
||||||
|
error: advActive ? searchError : boardError,
|
||||||
|
refetch: advActive ? refetchSearch : refetchBoard,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,164 +1,32 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
import type { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
||||||
import {
|
import { useWorkOrdersBoardSource } from "@/app/(protected)/workorders/_hooks/use-work-orders-board-source";
|
||||||
useWorkOrdersBoard,
|
import { useWorkOrdersTableLocalState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
|
||||||
useWorkOrdersBoardSearch,
|
|
||||||
} from "@/domain/work-orders/use-cases/use-work-orders-list";
|
|
||||||
import {
|
|
||||||
useWorkOrderLocations,
|
|
||||||
useWorkOrderVendors,
|
|
||||||
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
|
||||||
import {
|
|
||||||
EMPTY_TABLE_DRAFT,
|
|
||||||
UNSCHEDULED_DAY_KEY,
|
|
||||||
type WorkOrderTableDraft,
|
|
||||||
type WorkOrderTableRow,
|
|
||||||
} from "@/domain/work-orders/types/work-order-table-row";
|
|
||||||
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
import { currentWeekMonday } from "@/domain/work-orders/utils/list-week-utils";
|
||||||
import { buildWeekSkeleton, deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
import { buildWeekSkeleton, deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||||
import {
|
import {
|
||||||
buildDayGroups,
|
buildDayGroups,
|
||||||
enrichTableRow,
|
|
||||||
filterByBarAndSearch,
|
filterByBarAndSearch,
|
||||||
splitScheduledUnscheduled,
|
splitScheduledUnscheduled,
|
||||||
} from "@/domain/work-orders/utils/table-filter-utils";
|
} from "@/domain/work-orders/utils/table-filter-utils";
|
||||||
import {
|
|
||||||
BOARD_SEARCH_MAX_PAGE_SIZE,
|
export type { DragVizState } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-local-state";
|
||||||
toBoardSearchQueryParams,
|
|
||||||
} from "@/domain/work-orders/utils/board-query-params";
|
|
||||||
import {
|
|
||||||
applyRowOrderByDay,
|
|
||||||
loadBoardRowOrder,
|
|
||||||
mergeDayOrder,
|
|
||||||
removeIdFromRowOrder,
|
|
||||||
reorderIdsInList,
|
|
||||||
saveBoardRowOrder,
|
|
||||||
type BoardRowOrderByDay,
|
|
||||||
} from "@/domain/work-orders/utils/board-row-order-storage";
|
|
||||||
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
|
||||||
import type { WorkOrderBoardSearchQueryParams } from "@/domain/work-orders/types/work-order-board";
|
|
||||||
|
|
||||||
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
|
type FiltersReturn = ReturnType<typeof useWorkOrdersListFilters>;
|
||||||
|
|
||||||
export type DragVizState = {
|
|
||||||
dragId: string | number | null;
|
|
||||||
overId: string | number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useWorkOrdersTableData(filters: FiltersReturn) {
|
export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
const advActive = filters.advApplied !== null;
|
const { advActive, data, isLoading, isFetching, error, refetch } =
|
||||||
const { data: locations = [] } = useWorkOrderLocations();
|
useWorkOrdersBoardSource(filters);
|
||||||
const { data: vendors = [] } = useWorkOrderVendors();
|
|
||||||
|
|
||||||
const resolvedSearchParams = useMemo<WorkOrderBoardSearchQueryParams | null>(() => {
|
const local = useWorkOrdersTableLocalState({
|
||||||
if (!filters.advApplied) {
|
items: data?.items ?? [],
|
||||||
return null;
|
weekMonday: filters.weekMonday,
|
||||||
}
|
advActive,
|
||||||
|
isFetching,
|
||||||
|
});
|
||||||
|
const { displayRows } = local;
|
||||||
|
|
||||||
const siteCodes = new Set<string>();
|
const advResults = useMemo(() => (advActive ? displayRows : []), [advActive, displayRows]);
|
||||||
for (const siteKey of filters.advApplied.sites) {
|
|
||||||
const location = locations.find((item) => String(item.id) === siteKey);
|
|
||||||
if (location) {
|
|
||||||
const code = resolveSiteCode({
|
|
||||||
siteCode: location.siteCode,
|
|
||||||
name: location.name,
|
|
||||||
});
|
|
||||||
if (code) {
|
|
||||||
siteCodes.add(code);
|
|
||||||
}
|
|
||||||
} else if (siteKey.trim()) {
|
|
||||||
siteCodes.add(siteKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const vendorIdsByCompanyName = new Map<string, number>();
|
|
||||||
for (const vendor of vendors) {
|
|
||||||
const id = Number(vendor.id);
|
|
||||||
if (Number.isFinite(id) && vendor.companyName) {
|
|
||||||
vendorIdsByCompanyName.set(vendor.companyName, id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return toBoardSearchQueryParams({
|
|
||||||
filters: {
|
|
||||||
...filters.advApplied,
|
|
||||||
sites: siteCodes,
|
|
||||||
},
|
|
||||||
search: filters.search,
|
|
||||||
page: filters.page,
|
|
||||||
pageSize: Math.min(
|
|
||||||
filters.pageSize || BOARD_SEARCH_MAX_PAGE_SIZE,
|
|
||||||
BOARD_SEARCH_MAX_PAGE_SIZE,
|
|
||||||
),
|
|
||||||
sortBy: filters.sortBy || "scheduledDate",
|
|
||||||
sortDir: filters.sortDir || "asc",
|
|
||||||
vendorIdsByCompanyName,
|
|
||||||
});
|
|
||||||
}, [
|
|
||||||
filters.advApplied,
|
|
||||||
filters.search,
|
|
||||||
filters.page,
|
|
||||||
filters.pageSize,
|
|
||||||
filters.sortBy,
|
|
||||||
filters.sortDir,
|
|
||||||
locations,
|
|
||||||
vendors,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const {
|
|
||||||
data: boardData,
|
|
||||||
isLoading: boardLoading,
|
|
||||||
isFetching: boardFetching,
|
|
||||||
error: boardError,
|
|
||||||
refetch: refetchBoard,
|
|
||||||
} = useWorkOrdersBoard(filters.boardParams, !advActive);
|
|
||||||
const {
|
|
||||||
data: searchData,
|
|
||||||
isLoading: searchLoading,
|
|
||||||
isFetching: searchFetching,
|
|
||||||
error: searchError,
|
|
||||||
refetch: refetchSearch,
|
|
||||||
} = useWorkOrdersBoardSearch(resolvedSearchParams ?? { datePreset: "ThisWeek" }, advActive);
|
|
||||||
|
|
||||||
const data = advActive ? searchData : boardData;
|
|
||||||
const isLoading = advActive ? searchLoading : boardLoading;
|
|
||||||
const isFetching = advActive ? searchFetching : boardFetching;
|
|
||||||
const error = advActive ? searchError : boardError;
|
|
||||||
const refetch = advActive ? refetchSearch : refetchBoard;
|
|
||||||
|
|
||||||
const [patches, setPatches] = useState<Record<string, Partial<WorkOrderTableRow>>>({});
|
|
||||||
const [localRows, setLocalRows] = useState<WorkOrderTableRow[]>([]);
|
|
||||||
const [rowOrderByDay, setRowOrderByDay] = useState<BoardRowOrderByDay>({});
|
|
||||||
const [draftDayKey, setDraftDayKey] = useState<string | null>(null);
|
|
||||||
const [draft, setDraft] = useState<WorkOrderTableDraft>(EMPTY_TABLE_DRAFT);
|
|
||||||
const [unschedOpen, setUnschedOpen] = useState(true);
|
|
||||||
const [selectedId, setSelectedId] = useState<string | number | null>(null);
|
|
||||||
const [flashId, setFlashId] = useState<string | number | null>(null);
|
|
||||||
const [dragViz, setDragViz] = useState<DragVizState>({ dragId: null, overId: null });
|
|
||||||
const dragState = useRef<DragVizState>({ dragId: null, overId: null });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setRowOrderByDay(loadBoardRowOrder(filters.weekMonday));
|
|
||||||
}, [filters.weekMonday]);
|
|
||||||
|
|
||||||
const apiRows = useMemo(
|
|
||||||
() => (data?.items ?? []).map((row) => enrichTableRow({ ...row, ...patches[String(row.id)] })),
|
|
||||||
[data?.items, patches],
|
|
||||||
);
|
|
||||||
|
|
||||||
const displayRows = useMemo(() => {
|
|
||||||
const apiIds = new Set(apiRows.map((r) => String(r.id)));
|
|
||||||
const extra = localRows.filter((r) => !apiIds.has(String(r.id)));
|
|
||||||
const merged = [...apiRows, ...extra.map(enrichTableRow)];
|
|
||||||
return applyRowOrderByDay(merged, rowOrderByDay);
|
|
||||||
}, [apiRows, localRows, rowOrderByDay]);
|
|
||||||
|
|
||||||
const advResults = useMemo(() => {
|
|
||||||
if (!advActive) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
return displayRows;
|
|
||||||
}, [advActive, displayRows]);
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (advActive) {
|
if (advActive) {
|
||||||
|
|
@ -170,12 +38,10 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
return displayRows;
|
return displayRows;
|
||||||
}, [displayRows, filters.barFilters, filters.search, advActive]);
|
}, [displayRows, filters.barFilters, filters.search, advActive]);
|
||||||
|
|
||||||
const { scheduled, unscheduled } = useMemo(() => {
|
const { scheduled, unscheduled } = useMemo(
|
||||||
if (advActive) {
|
() => (advActive ? splitScheduledUnscheduled(advResults) : splitScheduledUnscheduled(filtered)),
|
||||||
return splitScheduledUnscheduled(advResults);
|
[advActive, advResults, filtered],
|
||||||
}
|
);
|
||||||
return splitScheduledUnscheduled(filtered);
|
|
||||||
}, [advActive, advResults, filtered]);
|
|
||||||
|
|
||||||
const dayGroups = useMemo(() => buildDayGroups(scheduled), [scheduled]);
|
const dayGroups = useMemo(() => buildDayGroups(scheduled), [scheduled]);
|
||||||
const weekSkeleton = useMemo(() => buildWeekSkeleton(filters.weekMonday), [filters.weekMonday]);
|
const weekSkeleton = useMemo(() => buildWeekSkeleton(filters.weekMonday), [filters.weekMonday]);
|
||||||
|
|
@ -192,161 +58,8 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const selectedRow = useMemo(
|
const selectedRow = useMemo(
|
||||||
() => displayRows.find((row) => String(row.id) === String(selectedId)) ?? null,
|
() => displayRows.find((row) => String(row.id) === String(local.selectedId)) ?? null,
|
||||||
[displayRows, selectedId],
|
[displayRows, local.selectedId],
|
||||||
);
|
|
||||||
|
|
||||||
const clearRowPatch = useCallback((id: string | number) => {
|
|
||||||
const key = String(id);
|
|
||||||
setPatches((prev) => {
|
|
||||||
if (!(key in prev)) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
const next = { ...prev };
|
|
||||||
delete next[key];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const wasFetchingRef = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (wasFetchingRef.current && !isFetching) {
|
|
||||||
// Prune optimistic overlays once the invalidated board refetch lands.
|
|
||||||
setPatches({});
|
|
||||||
}
|
|
||||||
wasFetchingRef.current = isFetching;
|
|
||||||
}, [isFetching]);
|
|
||||||
|
|
||||||
const patchRow = useCallback(
|
|
||||||
(id: string | number, patch: Partial<WorkOrderTableRow>) => {
|
|
||||||
const key = String(id);
|
|
||||||
const previousRow =
|
|
||||||
displayRows.find((row) => String(row.id) === key) ??
|
|
||||||
localRows.find((row) => String(row.id) === key);
|
|
||||||
const previousDayGroup = previousRow?.dayGroup;
|
|
||||||
|
|
||||||
setPatches((prev) => {
|
|
||||||
const merged = { ...prev[key], ...patch };
|
|
||||||
if (patch.scheduledOn !== undefined) {
|
|
||||||
const dayFields = deriveDayFields(patch.scheduledOn);
|
|
||||||
merged.dayGroup = dayFields.dayGroup;
|
|
||||||
merged.dayLabel = dayFields.dayLabel;
|
|
||||||
}
|
|
||||||
return { ...prev, [key]: merged };
|
|
||||||
});
|
|
||||||
setLocalRows((prev) =>
|
|
||||||
prev.map((row) => (String(row.id) === key ? enrichTableRow({ ...row, ...patch }) : row)),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (patch.scheduledOn === undefined || advActive) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextDayGroup = deriveDayFields(patch.scheduledOn).dayGroup;
|
|
||||||
if (!previousDayGroup || previousDayGroup === nextDayGroup) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setRowOrderByDay((prev) => {
|
|
||||||
const next = removeIdFromRowOrder(prev, key);
|
|
||||||
if (next === prev) {
|
|
||||||
return prev;
|
|
||||||
}
|
|
||||||
saveBoardRowOrder(filters.weekMonday, next);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[advActive, displayRows, filters.weekMonday, localRows],
|
|
||||||
);
|
|
||||||
|
|
||||||
const reorderRows = useCallback(
|
|
||||||
(dragId: string | number, overId: string | number) => {
|
|
||||||
const dragKey = String(dragId);
|
|
||||||
const overKey = String(overId);
|
|
||||||
const dragRow = displayRows.find((r) => String(r.id) === dragKey);
|
|
||||||
const overRow = displayRows.find((r) => String(r.id) === overKey);
|
|
||||||
if (!dragRow || !overRow || dragRow.dayGroup !== overRow.dayGroup) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dayGroup = dragRow.dayGroup;
|
|
||||||
const dayIds = displayRows
|
|
||||||
.filter((row) => row.dayGroup === dayGroup)
|
|
||||||
.map((row) => String(row.id));
|
|
||||||
const reorderedVisible = reorderIdsInList(dayIds, dragKey, overKey);
|
|
||||||
if (!reorderedVisible) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setRowOrderByDay((prev) => {
|
|
||||||
const nextDayIds = mergeDayOrder(prev[dayGroup], reorderedVisible);
|
|
||||||
const next = { ...prev, [dayGroup]: nextDayIds };
|
|
||||||
if (!advActive) {
|
|
||||||
saveBoardRowOrder(filters.weekMonday, next);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[advActive, displayRows, filters.weekMonday],
|
|
||||||
);
|
|
||||||
|
|
||||||
const endDrag = useCallback(() => {
|
|
||||||
dragState.current = { dragId: null, overId: null };
|
|
||||||
setDragViz({ dragId: null, overId: null });
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const startDayDraft = useCallback(
|
|
||||||
(key: string, scheduledIso?: string) => {
|
|
||||||
setDraftDayKey(key);
|
|
||||||
const scheduledOn =
|
|
||||||
key === UNSCHEDULED_DAY_KEY
|
|
||||||
? ""
|
|
||||||
: scheduledIso && /^\d{4}-\d{2}-\d{2}$/.test(scheduledIso)
|
|
||||||
? scheduledIso
|
|
||||||
: (() => {
|
|
||||||
const base = new Date(`${filters.weekMonday}T12:00:00`);
|
|
||||||
for (let i = 0; i < 7; i += 1) {
|
|
||||||
const d = new Date(base);
|
|
||||||
d.setDate(base.getDate() + i);
|
|
||||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
||||||
const dd = String(d.getDate()).padStart(2, "0");
|
|
||||||
if (`${mm}/${dd}` === key) {
|
|
||||||
return `${d.getFullYear()}-${mm}-${dd}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
})();
|
|
||||||
setDraft({
|
|
||||||
...EMPTY_TABLE_DRAFT,
|
|
||||||
scheduledOn,
|
|
||||||
scheduleWeekOnly: key === UNSCHEDULED_DAY_KEY,
|
|
||||||
targetWeek: key === UNSCHEDULED_DAY_KEY ? filters.weekMonday : "",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[filters.weekMonday],
|
|
||||||
);
|
|
||||||
|
|
||||||
const cancelDraft = useCallback(() => {
|
|
||||||
setDraftDayKey(null);
|
|
||||||
setDraft(EMPTY_TABLE_DRAFT);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const triggerFlash = useCallback((id: string | number) => {
|
|
||||||
setFlashId(id);
|
|
||||||
setTimeout(() => {
|
|
||||||
document
|
|
||||||
.getElementById(`wo-row-${id}`)
|
|
||||||
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
||||||
}, 80);
|
|
||||||
setTimeout(() => setFlashId(null), 2500);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const addRow = useCallback(
|
|
||||||
(row: WorkOrderTableRow) => {
|
|
||||||
setLocalRows((prev) => [...prev, enrichTableRow(row)]);
|
|
||||||
triggerFlash(row.id);
|
|
||||||
},
|
|
||||||
[triggerFlash],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -363,26 +76,26 @@ export function useWorkOrdersTableData(filters: FiltersReturn) {
|
||||||
noSearchResults,
|
noSearchResults,
|
||||||
isEmptyWeek,
|
isEmptyWeek,
|
||||||
todayKey,
|
todayKey,
|
||||||
draftDayKey,
|
draftDayKey: local.draftDayKey,
|
||||||
draft,
|
draft: local.draft,
|
||||||
setDraft,
|
setDraft: local.setDraft,
|
||||||
unschedOpen,
|
unschedOpen: local.unschedOpen,
|
||||||
setUnschedOpen,
|
setUnschedOpen: local.setUnschedOpen,
|
||||||
selectedId,
|
selectedId: local.selectedId,
|
||||||
setSelectedId,
|
setSelectedId: local.setSelectedId,
|
||||||
selectedRow,
|
selectedRow,
|
||||||
flashId,
|
flashId: local.flashId,
|
||||||
triggerFlash,
|
triggerFlash: local.triggerFlash,
|
||||||
dragViz,
|
dragViz: local.dragViz,
|
||||||
setDragViz,
|
setDragViz: local.setDragViz,
|
||||||
dragState,
|
dragState: local.dragState,
|
||||||
patchRow,
|
patchRow: local.patchRow,
|
||||||
clearRowPatch,
|
clearRowPatch: local.clearRowPatch,
|
||||||
reorderRows,
|
reorderRows: local.reorderRows,
|
||||||
endDrag,
|
endDrag: local.endDrag,
|
||||||
startDayDraft,
|
startDayDraft: local.startDayDraft,
|
||||||
cancelDraft,
|
cancelDraft: local.cancelDraft,
|
||||||
addRow,
|
addRow: local.addRow,
|
||||||
scopeTotalCount: data?.totalCount ?? 0,
|
scopeTotalCount: data?.totalCount ?? 0,
|
||||||
totalCount: advActive
|
totalCount: advActive
|
||||||
? (data?.totalCount ?? advResults.length)
|
? (data?.totalCount ?? advResults.length)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,172 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||||
|
import { enrichTableRow } from "@/domain/work-orders/utils/table-filter-utils";
|
||||||
|
import {
|
||||||
|
applyRowOrderByDay,
|
||||||
|
loadBoardRowOrder,
|
||||||
|
mergeDayOrder,
|
||||||
|
removeIdFromRowOrder,
|
||||||
|
reorderIdsInList,
|
||||||
|
saveBoardRowOrder,
|
||||||
|
type BoardRowOrderByDay,
|
||||||
|
} from "@/domain/work-orders/utils/board-row-order-storage";
|
||||||
|
import {
|
||||||
|
useWorkOrdersTableUiState,
|
||||||
|
type DragVizState,
|
||||||
|
} from "@/app/(protected)/workorders/_hooks/use-work-orders-table-ui-state";
|
||||||
|
|
||||||
|
export type { DragVizState };
|
||||||
|
|
||||||
|
export type UseWorkOrdersTableLocalStateParams = {
|
||||||
|
items: WorkOrderTableRow[];
|
||||||
|
weekMonday: string;
|
||||||
|
advActive: boolean;
|
||||||
|
isFetching: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function usePrunePatchesOnFetchSettle(
|
||||||
|
isFetching: boolean,
|
||||||
|
setPatches: React.Dispatch<React.SetStateAction<Record<string, Partial<WorkOrderTableRow>>>>,
|
||||||
|
) {
|
||||||
|
const wasFetchingRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (wasFetchingRef.current && !isFetching) {
|
||||||
|
setPatches({});
|
||||||
|
}
|
||||||
|
wasFetchingRef.current = isFetching;
|
||||||
|
}, [isFetching, setPatches]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Owns board local overlays (patches/added rows), row ordering, draft, selection and drag/flash UI state. */
|
||||||
|
export function useWorkOrdersTableLocalState({
|
||||||
|
items,
|
||||||
|
weekMonday,
|
||||||
|
advActive,
|
||||||
|
isFetching,
|
||||||
|
}: UseWorkOrdersTableLocalStateParams) {
|
||||||
|
const [patches, setPatches] = useState<Record<string, Partial<WorkOrderTableRow>>>({});
|
||||||
|
const [localRows, setLocalRows] = useState<WorkOrderTableRow[]>([]);
|
||||||
|
const [rowOrderByDay, setRowOrderByDay] = useState<BoardRowOrderByDay>({});
|
||||||
|
const ui = useWorkOrdersTableUiState(weekMonday);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setRowOrderByDay(loadBoardRowOrder(weekMonday));
|
||||||
|
}, [weekMonday]);
|
||||||
|
|
||||||
|
const apiRows = useMemo(
|
||||||
|
() => items.map((row) => enrichTableRow({ ...row, ...patches[String(row.id)] })),
|
||||||
|
[items, patches],
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayRows = useMemo(() => {
|
||||||
|
const apiIds = new Set(apiRows.map((r) => String(r.id)));
|
||||||
|
const extra = localRows.filter((r) => !apiIds.has(String(r.id)));
|
||||||
|
return applyRowOrderByDay([...apiRows, ...extra.map(enrichTableRow)], rowOrderByDay);
|
||||||
|
}, [apiRows, localRows, rowOrderByDay]);
|
||||||
|
|
||||||
|
const clearRowPatch = useCallback((id: string | number) => {
|
||||||
|
const key = String(id);
|
||||||
|
setPatches((prev) => {
|
||||||
|
if (!(key in prev)) return prev;
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[key];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
usePrunePatchesOnFetchSettle(isFetching, setPatches);
|
||||||
|
|
||||||
|
const patchRow = useCallback(
|
||||||
|
(id: string | number, patch: Partial<WorkOrderTableRow>) => {
|
||||||
|
const key = String(id);
|
||||||
|
const previousRow =
|
||||||
|
displayRows.find((row) => String(row.id) === key) ??
|
||||||
|
localRows.find((row) => String(row.id) === key);
|
||||||
|
const previousDayGroup = previousRow?.dayGroup;
|
||||||
|
|
||||||
|
setPatches((prev) => {
|
||||||
|
const merged = { ...prev[key], ...patch };
|
||||||
|
if (patch.scheduledOn !== undefined) {
|
||||||
|
const dayFields = deriveDayFields(patch.scheduledOn);
|
||||||
|
merged.dayGroup = dayFields.dayGroup;
|
||||||
|
merged.dayLabel = dayFields.dayLabel;
|
||||||
|
}
|
||||||
|
return { ...prev, [key]: merged };
|
||||||
|
});
|
||||||
|
setLocalRows((prev) =>
|
||||||
|
prev.map((row) => (String(row.id) === key ? enrichTableRow({ ...row, ...patch }) : row)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (patch.scheduledOn === undefined || advActive) return;
|
||||||
|
|
||||||
|
const nextDayGroup = deriveDayFields(patch.scheduledOn).dayGroup;
|
||||||
|
if (!previousDayGroup || previousDayGroup === nextDayGroup) return;
|
||||||
|
|
||||||
|
setRowOrderByDay((prev) => {
|
||||||
|
const next = removeIdFromRowOrder(prev, key);
|
||||||
|
if (next === prev) return prev;
|
||||||
|
saveBoardRowOrder(weekMonday, next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[advActive, displayRows, weekMonday, localRows],
|
||||||
|
);
|
||||||
|
|
||||||
|
const reorderRows = useCallback(
|
||||||
|
(dragId: string | number, overId: string | number) => {
|
||||||
|
const dragKey = String(dragId);
|
||||||
|
const overKey = String(overId);
|
||||||
|
const dragRow = displayRows.find((r) => String(r.id) === dragKey);
|
||||||
|
const overRow = displayRows.find((r) => String(r.id) === overKey);
|
||||||
|
if (!dragRow || !overRow || dragRow.dayGroup !== overRow.dayGroup) return;
|
||||||
|
|
||||||
|
const dayGroup = dragRow.dayGroup;
|
||||||
|
const dayIds = displayRows
|
||||||
|
.filter((row) => row.dayGroup === dayGroup)
|
||||||
|
.map((row) => String(row.id));
|
||||||
|
const reorderedVisible = reorderIdsInList(dayIds, dragKey, overKey);
|
||||||
|
if (!reorderedVisible) return;
|
||||||
|
|
||||||
|
setRowOrderByDay((prev) => {
|
||||||
|
const next = { ...prev, [dayGroup]: mergeDayOrder(prev[dayGroup], reorderedVisible) };
|
||||||
|
if (!advActive) saveBoardRowOrder(weekMonday, next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[advActive, displayRows, weekMonday],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { triggerFlash } = ui;
|
||||||
|
const addRow = useCallback(
|
||||||
|
(row: WorkOrderTableRow) => {
|
||||||
|
setLocalRows((prev) => [...prev, enrichTableRow(row)]);
|
||||||
|
triggerFlash(row.id);
|
||||||
|
},
|
||||||
|
[triggerFlash],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
displayRows,
|
||||||
|
localRows,
|
||||||
|
patchRow,
|
||||||
|
clearRowPatch,
|
||||||
|
reorderRows,
|
||||||
|
endDrag: ui.endDrag,
|
||||||
|
draftDayKey: ui.draftDayKey,
|
||||||
|
draft: ui.draft,
|
||||||
|
setDraft: ui.setDraft,
|
||||||
|
startDayDraft: ui.startDayDraft,
|
||||||
|
cancelDraft: ui.cancelDraft,
|
||||||
|
unschedOpen: ui.unschedOpen,
|
||||||
|
setUnschedOpen: ui.setUnschedOpen,
|
||||||
|
selectedId: ui.selectedId,
|
||||||
|
setSelectedId: ui.setSelectedId,
|
||||||
|
flashId: ui.flashId,
|
||||||
|
triggerFlash: ui.triggerFlash,
|
||||||
|
dragViz: ui.dragViz,
|
||||||
|
setDragViz: ui.setDragViz,
|
||||||
|
dragState: ui.dragState,
|
||||||
|
addRow,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
EMPTY_TABLE_DRAFT,
|
||||||
|
UNSCHEDULED_DAY_KEY,
|
||||||
|
type WorkOrderTableDraft,
|
||||||
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
export type DragVizState = {
|
||||||
|
dragId: string | number | null;
|
||||||
|
overId: string | number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveDraftScheduledOn(key: string, weekMonday: string, scheduledIso?: string): string {
|
||||||
|
if (key === UNSCHEDULED_DAY_KEY) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (scheduledIso && /^\d{4}-\d{2}-\d{2}$/.test(scheduledIso)) {
|
||||||
|
return scheduledIso;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = new Date(`${weekMonday}T12:00:00`);
|
||||||
|
for (let i = 0; i < 7; i += 1) {
|
||||||
|
const d = new Date(base);
|
||||||
|
d.setDate(base.getDate() + i);
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const dd = String(d.getDate()).padStart(2, "0");
|
||||||
|
if (`${mm}/${dd}` === key) {
|
||||||
|
return `${d.getFullYear()}-${mm}-${dd}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draft, selection, unsched accordion, drag viz and flash scroll helpers. */
|
||||||
|
export function useWorkOrdersTableUiState(weekMonday: string) {
|
||||||
|
const [draftDayKey, setDraftDayKey] = useState<string | null>(null);
|
||||||
|
const [draft, setDraft] = useState<WorkOrderTableDraft>(EMPTY_TABLE_DRAFT);
|
||||||
|
const [unschedOpen, setUnschedOpen] = useState(true);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | number | null>(null);
|
||||||
|
const [flashId, setFlashId] = useState<string | number | null>(null);
|
||||||
|
const [dragViz, setDragViz] = useState<DragVizState>({ dragId: null, overId: null });
|
||||||
|
const dragState = useRef<DragVizState>({ dragId: null, overId: null });
|
||||||
|
|
||||||
|
const endDrag = useCallback(() => {
|
||||||
|
dragState.current = { dragId: null, overId: null };
|
||||||
|
setDragViz({ dragId: null, overId: null });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startDayDraft = useCallback(
|
||||||
|
(key: string, scheduledIso?: string) => {
|
||||||
|
setDraftDayKey(key);
|
||||||
|
const scheduledOn = resolveDraftScheduledOn(key, weekMonday, scheduledIso);
|
||||||
|
setDraft({
|
||||||
|
...EMPTY_TABLE_DRAFT,
|
||||||
|
scheduledOn,
|
||||||
|
scheduleWeekOnly: key === UNSCHEDULED_DAY_KEY,
|
||||||
|
targetWeek: key === UNSCHEDULED_DAY_KEY ? weekMonday : "",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[weekMonday],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelDraft = useCallback(() => {
|
||||||
|
setDraftDayKey(null);
|
||||||
|
setDraft(EMPTY_TABLE_DRAFT);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const triggerFlash = useCallback((id: string | number) => {
|
||||||
|
setFlashId(id);
|
||||||
|
setTimeout(() => {
|
||||||
|
document
|
||||||
|
.getElementById(`wo-row-${id}`)
|
||||||
|
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}, 80);
|
||||||
|
setTimeout(() => setFlashId(null), 2500);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
draftDayKey,
|
||||||
|
draft,
|
||||||
|
setDraft,
|
||||||
|
startDayDraft,
|
||||||
|
cancelDraft,
|
||||||
|
unschedOpen,
|
||||||
|
setUnschedOpen,
|
||||||
|
selectedId,
|
||||||
|
setSelectedId,
|
||||||
|
flashId,
|
||||||
|
triggerFlash,
|
||||||
|
dragViz,
|
||||||
|
setDragViz,
|
||||||
|
dragState,
|
||||||
|
endDrag,
|
||||||
|
};
|
||||||
|
}
|
||||||
169
src/domain/work-orders/api/work-order-board-documents-api.ts
Normal file
169
src/domain/work-orders/api/work-order-board-documents-api.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
import { API_PATHS } from "@/api/api-paths";
|
||||||
|
import { ApiError, mapHttpStatusToMessage } from "@/api/api-error";
|
||||||
|
import { api, apiGet, apiPost } from "@/api/api";
|
||||||
|
import { handleApiResponse } from "@/api/handle-api-response";
|
||||||
|
import {
|
||||||
|
BoardPatchConflictError,
|
||||||
|
BoardPatchValidationError,
|
||||||
|
} from "@/domain/work-orders/errors/board-patch-errors";
|
||||||
|
import {
|
||||||
|
buildCompletionDocFormData,
|
||||||
|
buildMediaFormData,
|
||||||
|
mapBoardDetailComment,
|
||||||
|
mapBoardDetailMediaItem,
|
||||||
|
mapCompletion,
|
||||||
|
type UploadableMediaCategory,
|
||||||
|
} from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||||
|
import type {
|
||||||
|
WorkOrderBoardCompletion,
|
||||||
|
WorkOrderBoardMediaItem,
|
||||||
|
} from "@/domain/work-orders/types/work-order-board-detail";
|
||||||
|
import type { WorkOrderComment } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
|
async function readResponseJson(response: Response): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
return await response.json();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function throwDocumentHttpError(response: Response, data: unknown): never {
|
||||||
|
const record = typeof data === "object" && data !== null ? (data as Record<string, unknown>) : {};
|
||||||
|
|
||||||
|
if (response.status === 409) {
|
||||||
|
throw new BoardPatchConflictError(
|
||||||
|
typeof record.message === "string"
|
||||||
|
? record.message
|
||||||
|
: "The record was modified by another user. Refresh and retry.",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 422) {
|
||||||
|
throw new BoardPatchValidationError(
|
||||||
|
typeof record.code === "string" ? record.code : "ValidationError",
|
||||||
|
typeof record.message === "string" ? record.message : mapHttpStatusToMessage(422, data),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new ApiError(mapHttpStatusToMessage(response.status, data), response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const workOrderBoardDocumentsApi = {
|
||||||
|
listComments: async (workOrderId: string | number): Promise<WorkOrderComment[]> => {
|
||||||
|
const data = await apiGet<unknown>(API_PATHS.workOrder.comments(workOrderId));
|
||||||
|
const items = handleApiResponse<unknown[]>(data);
|
||||||
|
return Array.isArray(items) ? items.map(mapBoardDetailComment) : [];
|
||||||
|
},
|
||||||
|
|
||||||
|
createComment: async (workOrderId: string | number, text: string): Promise<WorkOrderComment> => {
|
||||||
|
const data = await apiPost<unknown>(API_PATHS.workOrder.comments(workOrderId), { text });
|
||||||
|
return mapBoardDetailComment(handleApiResponse(data));
|
||||||
|
},
|
||||||
|
|
||||||
|
updateComment: async (
|
||||||
|
workOrderId: string | number,
|
||||||
|
commentId: string | number,
|
||||||
|
text: string,
|
||||||
|
): Promise<WorkOrderComment> => {
|
||||||
|
const response = await api.patch(API_PATHS.workOrder.commentItem(workOrderId, commentId), {
|
||||||
|
json: { text },
|
||||||
|
throwHttpErrors: false,
|
||||||
|
});
|
||||||
|
const data = await readResponseJson(response);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
if (response.status === 204 || data == null) {
|
||||||
|
return mapBoardDetailComment({ id: commentId, text });
|
||||||
|
}
|
||||||
|
return mapBoardDetailComment(handleApiResponse(data) ?? data);
|
||||||
|
}
|
||||||
|
|
||||||
|
throwDocumentHttpError(response, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** @deprecated Prefer createComment — modern board endpoint. */
|
||||||
|
addComment: async (
|
||||||
|
workOrderId: string | number,
|
||||||
|
text: string,
|
||||||
|
_commentType?: string,
|
||||||
|
): Promise<WorkOrderComment> => {
|
||||||
|
return workOrderBoardDocumentsApi.createComment(workOrderId, text);
|
||||||
|
},
|
||||||
|
|
||||||
|
listMedia: async (workOrderId: string | number): Promise<WorkOrderBoardMediaItem[]> => {
|
||||||
|
const data = await apiGet<unknown>(API_PATHS.workOrder.media(workOrderId));
|
||||||
|
const items = handleApiResponse<unknown[]>(data);
|
||||||
|
return Array.isArray(items) ? items.map(mapBoardDetailMediaItem) : [];
|
||||||
|
},
|
||||||
|
|
||||||
|
uploadMedia: async (
|
||||||
|
workOrderId: string | number,
|
||||||
|
file: File,
|
||||||
|
category: UploadableMediaCategory,
|
||||||
|
): Promise<WorkOrderBoardMediaItem> => {
|
||||||
|
const formData = buildMediaFormData(file, category);
|
||||||
|
const response = await api.post(API_PATHS.workOrder.media(workOrderId), {
|
||||||
|
body: formData,
|
||||||
|
throwHttpErrors: false,
|
||||||
|
});
|
||||||
|
const data = await readResponseJson(response);
|
||||||
|
if (response.ok) {
|
||||||
|
return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
|
||||||
|
}
|
||||||
|
throwDocumentHttpError(response, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteMedia: async (workOrderId: string | number, mediaId: string | number): Promise<void> => {
|
||||||
|
const numericId = typeof mediaId === "number" ? mediaId : Number(mediaId);
|
||||||
|
if (!Number.isFinite(numericId) || numericId <= 0) {
|
||||||
|
throw new BoardPatchValidationError(
|
||||||
|
"InvalidMedia",
|
||||||
|
"Legacy media cannot be deleted via this endpoint.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await api.delete(API_PATHS.workOrder.mediaItem(workOrderId, numericId), {
|
||||||
|
throwHttpErrors: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await readResponseJson(response);
|
||||||
|
if (response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throwDocumentHttpError(response, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
uploadCompletionDoc: async (
|
||||||
|
workOrderId: string | number,
|
||||||
|
params: {
|
||||||
|
file: File;
|
||||||
|
workOrderVersion: string;
|
||||||
|
signOffName?: string;
|
||||||
|
signOffSignature?: string;
|
||||||
|
},
|
||||||
|
): Promise<WorkOrderBoardCompletion> => {
|
||||||
|
if (!params.workOrderVersion) {
|
||||||
|
throw new BoardPatchValidationError(
|
||||||
|
"WorkOrderVersionRequired",
|
||||||
|
"workOrderVersion is required to upload a completion document.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = buildCompletionDocFormData(params);
|
||||||
|
const response = await api.post(API_PATHS.workOrder.completionDoc(workOrderId), {
|
||||||
|
body: formData,
|
||||||
|
throwHttpErrors: false,
|
||||||
|
});
|
||||||
|
const data = await readResponseJson(response);
|
||||||
|
if (response.ok) {
|
||||||
|
return mapCompletion(handleApiResponse(data) ?? data);
|
||||||
|
}
|
||||||
|
throwDocumentHttpError(response, data);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
BoardPatchConflictError,
|
BoardPatchConflictError,
|
||||||
BoardPatchValidationError,
|
BoardPatchValidationError,
|
||||||
} from "@/domain/work-orders/errors/board-patch-errors";
|
} from "@/domain/work-orders/errors/board-patch-errors";
|
||||||
|
import { workOrderBoardDocumentsApi } from "@/domain/work-orders/api/work-order-board-documents-api";
|
||||||
import {
|
import {
|
||||||
mapDispatchComment,
|
mapDispatchComment,
|
||||||
mapDispatchDetail,
|
mapDispatchDetail,
|
||||||
|
|
@ -23,15 +24,7 @@ import {
|
||||||
mapWorkOrderTableRows,
|
mapWorkOrderTableRows,
|
||||||
} from "@/domain/work-orders/mappers/work-order-mapper";
|
} from "@/domain/work-orders/mappers/work-order-mapper";
|
||||||
import { mapWizardDraftToBoardCreatePayload } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
import { mapWizardDraftToBoardCreatePayload } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||||
import {
|
import { mapWorkOrderBoardDetail } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||||
buildCompletionDocFormData,
|
|
||||||
buildMediaFormData,
|
|
||||||
mapBoardDetailComment,
|
|
||||||
mapBoardDetailMediaItem,
|
|
||||||
mapCompletion,
|
|
||||||
mapWorkOrderBoardDetail,
|
|
||||||
type UploadableMediaCategory,
|
|
||||||
} from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
|
||||||
import type { WorkOrderFormSchemaValues } from "@/domain/work-orders/schemas/work-order-schema";
|
import type { WorkOrderFormSchemaValues } from "@/domain/work-orders/schemas/work-order-schema";
|
||||||
import type {
|
import type {
|
||||||
DispatchDetail,
|
DispatchDetail,
|
||||||
|
|
@ -42,7 +35,6 @@ import type {
|
||||||
UpdateDispatchPayload,
|
UpdateDispatchPayload,
|
||||||
WorkOrderVendorUpdatePayload,
|
WorkOrderVendorUpdatePayload,
|
||||||
VendorDropdownItem,
|
VendorDropdownItem,
|
||||||
WorkOrderComment,
|
|
||||||
WorkOrderDetail,
|
WorkOrderDetail,
|
||||||
WorkOrderFormValues,
|
WorkOrderFormValues,
|
||||||
WorkOrderUser,
|
WorkOrderUser,
|
||||||
|
|
@ -55,11 +47,7 @@ import type {
|
||||||
WorkOrderBoardResponseApi,
|
WorkOrderBoardResponseApi,
|
||||||
WorkOrderBoardSearchQueryParams,
|
WorkOrderBoardSearchQueryParams,
|
||||||
} from "@/domain/work-orders/types/work-order-board";
|
} from "@/domain/work-orders/types/work-order-board";
|
||||||
import type {
|
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
|
||||||
WorkOrderBoardCompletion,
|
|
||||||
WorkOrderBoardDetail,
|
|
||||||
WorkOrderBoardMediaItem,
|
|
||||||
} from "@/domain/work-orders/types/work-order-board-detail";
|
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import {
|
import {
|
||||||
DISPATCH_LOCATION_WO_PAGE_SIZE,
|
DISPATCH_LOCATION_WO_PAGE_SIZE,
|
||||||
|
|
@ -68,36 +56,6 @@ import {
|
||||||
} from "@/domain/work-orders/types/work-order-list";
|
} from "@/domain/work-orders/types/work-order-list";
|
||||||
import { buildBoardSearchParamsRecord } from "@/domain/work-orders/utils/board-query-params";
|
import { buildBoardSearchParamsRecord } from "@/domain/work-orders/utils/board-query-params";
|
||||||
|
|
||||||
async function readResponseJson(response: Response): Promise<unknown> {
|
|
||||||
try {
|
|
||||||
return await response.json();
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function throwDocumentHttpError(response: Response, data: unknown): never {
|
|
||||||
const record = typeof data === "object" && data !== null ? (data as Record<string, unknown>) : {};
|
|
||||||
|
|
||||||
if (response.status === 409) {
|
|
||||||
throw new BoardPatchConflictError(
|
|
||||||
typeof record.message === "string"
|
|
||||||
? record.message
|
|
||||||
: "The record was modified by another user. Refresh and retry.",
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status === 422) {
|
|
||||||
throw new BoardPatchValidationError(
|
|
||||||
typeof record.code === "string" ? record.code : "ValidationError",
|
|
||||||
typeof record.message === "string" ? record.message : mapHttpStatusToMessage(422, data),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new ApiError(mapHttpStatusToMessage(response.status, data), response.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSearchParams(params: WorkOrdersListParams): Record<string, string | number> {
|
function buildSearchParams(params: WorkOrdersListParams): Record<string, string | number> {
|
||||||
const searchParams: Record<string, string | number> = {
|
const searchParams: Record<string, string | number> = {
|
||||||
search: params.search ?? "",
|
search: params.search ?? "",
|
||||||
|
|
@ -168,6 +126,8 @@ function mapUser(raw: unknown): WorkOrderUser {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const workOrdersApi = {
|
export const workOrdersApi = {
|
||||||
|
...workOrderBoardDocumentsApi,
|
||||||
|
|
||||||
getList: async (params: WorkOrdersListParams = {}): Promise<WorkOrdersListResult> => {
|
getList: async (params: WorkOrdersListParams = {}): Promise<WorkOrdersListResult> => {
|
||||||
const searchParams = buildSearchParams(params);
|
const searchParams = buildSearchParams(params);
|
||||||
const data = await apiGet<unknown>(API_PATHS.workOrder.getList, { searchParams });
|
const data = await apiGet<unknown>(API_PATHS.workOrder.getList, { searchParams });
|
||||||
|
|
@ -353,122 +313,6 @@ export const workOrdersApi = {
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
listComments: async (workOrderId: string | number): Promise<WorkOrderComment[]> => {
|
|
||||||
const data = await apiGet<unknown>(API_PATHS.workOrder.comments(workOrderId));
|
|
||||||
const items = handleApiResponse<unknown[]>(data);
|
|
||||||
return Array.isArray(items) ? items.map(mapBoardDetailComment) : [];
|
|
||||||
},
|
|
||||||
|
|
||||||
createComment: async (workOrderId: string | number, text: string): Promise<WorkOrderComment> => {
|
|
||||||
const data = await apiPost<unknown>(API_PATHS.workOrder.comments(workOrderId), { text });
|
|
||||||
return mapBoardDetailComment(handleApiResponse(data));
|
|
||||||
},
|
|
||||||
|
|
||||||
updateComment: async (
|
|
||||||
workOrderId: string | number,
|
|
||||||
commentId: string | number,
|
|
||||||
text: string,
|
|
||||||
): Promise<WorkOrderComment> => {
|
|
||||||
const response = await api.patch(API_PATHS.workOrder.commentItem(workOrderId, commentId), {
|
|
||||||
json: { text },
|
|
||||||
throwHttpErrors: false,
|
|
||||||
});
|
|
||||||
const data = await readResponseJson(response);
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
if (response.status === 204 || data == null) {
|
|
||||||
return mapBoardDetailComment({ id: commentId, text });
|
|
||||||
}
|
|
||||||
return mapBoardDetailComment(handleApiResponse(data) ?? data);
|
|
||||||
}
|
|
||||||
|
|
||||||
throwDocumentHttpError(response, data);
|
|
||||||
},
|
|
||||||
|
|
||||||
/** @deprecated Prefer createComment — modern board endpoint. */
|
|
||||||
addComment: async (
|
|
||||||
workOrderId: string | number,
|
|
||||||
text: string,
|
|
||||||
_commentType?: string,
|
|
||||||
): Promise<WorkOrderComment> => {
|
|
||||||
return workOrdersApi.createComment(workOrderId, text);
|
|
||||||
},
|
|
||||||
|
|
||||||
listMedia: async (workOrderId: string | number): Promise<WorkOrderBoardMediaItem[]> => {
|
|
||||||
const data = await apiGet<unknown>(API_PATHS.workOrder.media(workOrderId));
|
|
||||||
const items = handleApiResponse<unknown[]>(data);
|
|
||||||
return Array.isArray(items) ? items.map(mapBoardDetailMediaItem) : [];
|
|
||||||
},
|
|
||||||
|
|
||||||
uploadMedia: async (
|
|
||||||
workOrderId: string | number,
|
|
||||||
file: File,
|
|
||||||
category: UploadableMediaCategory,
|
|
||||||
): Promise<WorkOrderBoardMediaItem> => {
|
|
||||||
const formData = buildMediaFormData(file, category);
|
|
||||||
const response = await api.post(API_PATHS.workOrder.media(workOrderId), {
|
|
||||||
body: formData,
|
|
||||||
throwHttpErrors: false,
|
|
||||||
});
|
|
||||||
const data = await readResponseJson(response);
|
|
||||||
if (response.ok) {
|
|
||||||
return mapBoardDetailMediaItem(handleApiResponse(data) ?? data);
|
|
||||||
}
|
|
||||||
throwDocumentHttpError(response, data);
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteMedia: async (workOrderId: string | number, mediaId: string | number): Promise<void> => {
|
|
||||||
const numericId = typeof mediaId === "number" ? mediaId : Number(mediaId);
|
|
||||||
if (!Number.isFinite(numericId) || numericId <= 0) {
|
|
||||||
throw new BoardPatchValidationError(
|
|
||||||
"InvalidMedia",
|
|
||||||
"Legacy media cannot be deleted via this endpoint.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await api.delete(API_PATHS.workOrder.mediaItem(workOrderId, numericId), {
|
|
||||||
throwHttpErrors: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status === 204) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await readResponseJson(response);
|
|
||||||
if (response.ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throwDocumentHttpError(response, data);
|
|
||||||
},
|
|
||||||
|
|
||||||
uploadCompletionDoc: async (
|
|
||||||
workOrderId: string | number,
|
|
||||||
params: {
|
|
||||||
file: File;
|
|
||||||
workOrderVersion: string;
|
|
||||||
signOffName?: string;
|
|
||||||
signOffSignature?: string;
|
|
||||||
},
|
|
||||||
): Promise<WorkOrderBoardCompletion> => {
|
|
||||||
if (!params.workOrderVersion) {
|
|
||||||
throw new BoardPatchValidationError(
|
|
||||||
"WorkOrderVersionRequired",
|
|
||||||
"workOrderVersion is required to upload a completion document.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData = buildCompletionDocFormData(params);
|
|
||||||
const response = await api.post(API_PATHS.workOrder.completionDoc(workOrderId), {
|
|
||||||
body: formData,
|
|
||||||
throwHttpErrors: false,
|
|
||||||
});
|
|
||||||
const data = await readResponseJson(response);
|
|
||||||
if (response.ok) {
|
|
||||||
return mapCompletion(handleApiResponse(data) ?? data);
|
|
||||||
}
|
|
||||||
throwDocumentHttpError(response, data);
|
|
||||||
},
|
|
||||||
|
|
||||||
changeStatus: async (id: string | number, status: string): Promise<void> => {
|
changeStatus: async (id: string | number, status: string): Promise<void> => {
|
||||||
await apiPost<unknown>(API_PATHS.workOrder.changeStatus, undefined, {
|
await apiPost<unknown>(API_PATHS.workOrder.changeStatus, undefined, {
|
||||||
searchParams: { id, status },
|
searchParams: { id, status },
|
||||||
|
|
@ -572,17 +416,14 @@ export const workOrdersApi = {
|
||||||
|
|
||||||
addDispatchSignoff: async (
|
addDispatchSignoff: async (
|
||||||
dispatchId: string | number,
|
dispatchId: string | number,
|
||||||
signoffType: string,
|
options: { signoffType: string; name: string; signature: string; signatureMethod: string },
|
||||||
name: string,
|
|
||||||
signature: string,
|
|
||||||
signatureMethod: string,
|
|
||||||
): Promise<DispatchDetail["signoffs"][number]> => {
|
): Promise<DispatchDetail["signoffs"][number]> => {
|
||||||
const data = await apiPost<unknown>(API_PATHS.workOrder.addDispatchSignoff, {
|
const data = await apiPost<unknown>(API_PATHS.workOrder.addDispatchSignoff, {
|
||||||
DispatchId: dispatchId,
|
DispatchId: dispatchId,
|
||||||
SignoffType: signoffType,
|
SignoffType: options.signoffType,
|
||||||
Name: name,
|
Name: options.name,
|
||||||
Signature: signature,
|
Signature: options.signature,
|
||||||
SignatureMethod: signatureMethod,
|
SignatureMethod: options.signatureMethod,
|
||||||
});
|
});
|
||||||
return mapDispatchSignoff(handleApiResponse(data));
|
return mapDispatchSignoff(handleApiResponse(data));
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/work-order-board";
|
||||||
|
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import { mondayOf } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
|
||||||
|
/** Mutates `payload` with `dueDate` and day/week schedule fields (range mode has no schedule fields). */
|
||||||
|
export function assignWizardScheduleFields(
|
||||||
|
payload: WorkOrderBoardCreatePayload,
|
||||||
|
draft: WorkOrderWizardDraft,
|
||||||
|
): void {
|
||||||
|
if (draft.dueDate) {
|
||||||
|
payload.dueDate = draft.dueDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRange = draft.schedMode === "range";
|
||||||
|
|
||||||
|
if (!isRange && draft.scheduleWeekOnly) {
|
||||||
|
payload.scheduleWeekOnly = true;
|
||||||
|
|
||||||
|
const week = draft.targetWeek?.trim() || (draft.scheduledOn ? mondayOf(draft.scheduledOn) : "");
|
||||||
|
|
||||||
|
if (week) {
|
||||||
|
payload.targetWeek = week;
|
||||||
|
}
|
||||||
|
} else if (!isRange && draft.scheduledOn) {
|
||||||
|
payload.scheduledDate = draft.scheduledOn;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/work-order-board";
|
||||||
|
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
|
function trimOrUndefined(value: string | undefined): string | undefined {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
|
||||||
|
return trimmed ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExtraServices(
|
||||||
|
primary: string | undefined,
|
||||||
|
extras: string[],
|
||||||
|
): string[] | undefined {
|
||||||
|
if (extras.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
const primaryKey = primary?.toLowerCase() ?? "";
|
||||||
|
|
||||||
|
const out: string[] = [];
|
||||||
|
|
||||||
|
for (const raw of extras) {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
|
||||||
|
if (!trimmed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = trimmed.toLowerCase();
|
||||||
|
|
||||||
|
if (key === primaryKey || seen.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.add(key);
|
||||||
|
|
||||||
|
out.push(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.length > 0 ? out : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mutates `payload` with primary/extra services, service notes, POC, tech phone, and vendor notes. */
|
||||||
|
export function assignWizardServicePocFields(
|
||||||
|
payload: WorkOrderBoardCreatePayload,
|
||||||
|
draft: WorkOrderWizardDraft,
|
||||||
|
): void {
|
||||||
|
const primaryService = trimOrUndefined(draft.pm);
|
||||||
|
|
||||||
|
if (primaryService) {
|
||||||
|
payload.primaryService = primaryService;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extraServices = normalizeExtraServices(primaryService, draft.extraServices);
|
||||||
|
|
||||||
|
if (extraServices) {
|
||||||
|
payload.extraServices = extraServices;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceNotes = trimOrUndefined(draft.pmNote);
|
||||||
|
|
||||||
|
if (serviceNotes) {
|
||||||
|
payload.serviceNotes = serviceNotes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pocName = trimOrUndefined(draft.pocName);
|
||||||
|
|
||||||
|
if (pocName) {
|
||||||
|
payload.pocName = pocName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pocPhone = trimOrUndefined(draft.pocPhone);
|
||||||
|
|
||||||
|
if (pocPhone) {
|
||||||
|
payload.pocPhone = pocPhone;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pocNotes = trimOrUndefined(draft.pocNotes);
|
||||||
|
|
||||||
|
if (pocNotes) {
|
||||||
|
payload.pocNotes = pocNotes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const techPhone = trimOrUndefined(draft.techPhone);
|
||||||
|
|
||||||
|
if (techPhone) {
|
||||||
|
payload.techPhone = techPhone;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vendorNotes = trimOrUndefined(draft.vendorNotes);
|
||||||
|
|
||||||
|
if (vendorNotes) {
|
||||||
|
payload.vendorNotes = vendorNotes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/work-order-board";
|
||||||
|
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import { formatApptTimeValue } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
|
||||||
|
/** Mutates `payload` with `vendorId` and, when applicable, single-day appt date/time. */
|
||||||
|
export function assignWizardVendorApptFields(
|
||||||
|
payload: WorkOrderBoardCreatePayload,
|
||||||
|
draft: WorkOrderWizardDraft,
|
||||||
|
): void {
|
||||||
|
const vendorId = draft.vendorId.trim() ? Number(draft.vendorId) : NaN;
|
||||||
|
const hasVendor = Number.isFinite(vendorId);
|
||||||
|
|
||||||
|
if (hasVendor) {
|
||||||
|
payload.vendorId = vendorId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apptTime = formatApptTimeValue(draft.apptStart, draft.apptEnd);
|
||||||
|
const isRange = draft.schedMode === "range";
|
||||||
|
|
||||||
|
if (hasVendor && (draft.scheduledOn || apptTime) && !draft.scheduleWeekOnly && !isRange) {
|
||||||
|
if (draft.scheduledOn) {
|
||||||
|
payload.apptDate = draft.scheduledOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apptTime) {
|
||||||
|
payload.apptTime = apptTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/domain/work-orders/mappers/expand-scalar-board-ops.ts
Normal file
49
src/domain/work-orders/mappers/expand-scalar-board-ops.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import type { WorkOrderBoardEditableField } from "@/domain/work-orders/types/work-order-board";
|
||||||
|
import type { WorkOrderTablePatch } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
export type BoardFieldOp = {
|
||||||
|
field: WorkOrderBoardEditableField;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function mapDocStatusToApi(value: string): string {
|
||||||
|
if (value === "NN") {
|
||||||
|
return "NotNeeded";
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScalarBoardOpRule = {
|
||||||
|
key: keyof WorkOrderTablePatch;
|
||||||
|
field: WorkOrderBoardEditableField;
|
||||||
|
toValue: (value: unknown) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** One row per scalar patch field, in emission order. */
|
||||||
|
const SCALAR_BOARD_OP_RULES: ScalarBoardOpRule[] = [
|
||||||
|
{ key: "woNumber", field: "woNumber", toValue: (value) => value as string },
|
||||||
|
{ key: "type", field: "workOrderType", toValue: (value) => value as string },
|
||||||
|
{ key: "site", field: "siteCode", toValue: (value) => value as string },
|
||||||
|
{ key: "status", field: "lifecycleStatus", toValue: (value) => value as string },
|
||||||
|
{ key: "dispatcherId", field: "assignTo", toValue: (value) => value as string },
|
||||||
|
{ key: "dueDate", field: "dueDate", toValue: (value) => value as string },
|
||||||
|
{ key: "vendorId", field: "vendorId", toValue: (value) => String(value) },
|
||||||
|
{ key: "apptTime", field: "apptTime", toValue: (value) => value as string },
|
||||||
|
{ key: "docStatus", field: "docStatus", toValue: (value) => mapDocStatusToApi(value as string) },
|
||||||
|
{ key: "pm", field: "pm", toValue: (value) => value as string },
|
||||||
|
{ key: "extraServices", field: "extraServices", toValue: (value) => JSON.stringify(value) },
|
||||||
|
{ key: "serviceNotes", field: "serviceNotes", toValue: (value) => value as string },
|
||||||
|
{ key: "flagColor", field: "flagColor", toValue: (value) => (value as string | null) ?? "" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function expandScalarBoardOps(patch: WorkOrderTablePatch): BoardFieldOp[] {
|
||||||
|
const ops: BoardFieldOp[] = [];
|
||||||
|
|
||||||
|
for (const rule of SCALAR_BOARD_OP_RULES) {
|
||||||
|
const value = patch[rule.key];
|
||||||
|
if (value === undefined) continue;
|
||||||
|
ops.push({ field: rule.field, value: rule.toValue(value) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return ops;
|
||||||
|
}
|
||||||
42
src/domain/work-orders/mappers/expand-schedule-board-ops.ts
Normal file
42
src/domain/work-orders/mappers/expand-schedule-board-ops.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import type { BoardFieldOp } from "@/domain/work-orders/mappers/expand-scalar-board-ops";
|
||||||
|
import type { WorkOrderTablePatch } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
function hasScheduleRelatedPatch(patch: WorkOrderTablePatch): boolean {
|
||||||
|
return (
|
||||||
|
patch.scheduledOn !== undefined ||
|
||||||
|
patch.targetWeek !== undefined ||
|
||||||
|
patch.scheduleWeekOnly !== undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Schedule/week-only ops in the stable order the board API expects. */
|
||||||
|
export function expandScheduleBoardOps(patch: WorkOrderTablePatch): BoardFieldOp[] {
|
||||||
|
if (!hasScheduleRelatedPatch(patch)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const ops: BoardFieldOp[] = [];
|
||||||
|
|
||||||
|
if (patch.scheduleWeekOnly === true) {
|
||||||
|
ops.push({ field: "scheduleWeekOnly", value: "true" });
|
||||||
|
if (patch.targetWeek !== undefined) {
|
||||||
|
ops.push({ field: "targetWeek", value: patch.targetWeek });
|
||||||
|
}
|
||||||
|
if (patch.scheduledOn !== undefined) {
|
||||||
|
ops.push({ field: "scheduledDate", value: patch.scheduledOn });
|
||||||
|
}
|
||||||
|
return ops;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patch.scheduleWeekOnly === false) {
|
||||||
|
ops.push({ field: "scheduleWeekOnly", value: "false" });
|
||||||
|
}
|
||||||
|
if (patch.scheduledOn !== undefined) {
|
||||||
|
ops.push({ field: "scheduledDate", value: patch.scheduledOn });
|
||||||
|
}
|
||||||
|
if (patch.targetWeek !== undefined) {
|
||||||
|
ops.push({ field: "targetWeek", value: patch.targetWeek });
|
||||||
|
}
|
||||||
|
|
||||||
|
return ops;
|
||||||
|
}
|
||||||
|
|
@ -7,11 +7,11 @@ import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board
|
||||||
|
|
||||||
import type { WOType, WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
import type { WOType, WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
import {
|
import { fmtDateRange } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
fmtDateRange,
|
|
||||||
formatApptTimeValue,
|
import { assignWizardScheduleFields } from "@/domain/work-orders/mappers/assign-wizard-schedule-fields";
|
||||||
mondayOf,
|
import { assignWizardVendorApptFields } from "@/domain/work-orders/mappers/assign-wizard-vendor-appt-fields";
|
||||||
} from "@/domain/work-orders/utils/wizard-date-utils";
|
import { assignWizardServicePocFields } from "@/domain/work-orders/mappers/assign-wizard-service-poc-fields";
|
||||||
|
|
||||||
const WO_TYPE_TO_API: Record<WOType, WorkOrderTypeApi> = {
|
const WO_TYPE_TO_API: Record<WOType, WorkOrderTypeApi> = {
|
||||||
PM: WORK_ORDER_TYPE_API.PM,
|
PM: WORK_ORDER_TYPE_API.PM,
|
||||||
|
|
@ -77,47 +77,6 @@ export function buildBoardDescription(draft: WorkOrderWizardDraft): string {
|
||||||
return parts.join("\n");
|
return parts.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
function trimOrUndefined(value: string | undefined): string | undefined {
|
|
||||||
const trimmed = value?.trim();
|
|
||||||
|
|
||||||
return trimmed ? trimmed : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeExtraServices(
|
|
||||||
primary: string | undefined,
|
|
||||||
extras: string[],
|
|
||||||
): string[] | undefined {
|
|
||||||
if (extras.length === 0) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const seen = new Set<string>();
|
|
||||||
|
|
||||||
const primaryKey = primary?.toLowerCase() ?? "";
|
|
||||||
|
|
||||||
const out: string[] = [];
|
|
||||||
|
|
||||||
for (const raw of extras) {
|
|
||||||
const trimmed = raw.trim();
|
|
||||||
|
|
||||||
if (!trimmed) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = trimmed.toLowerCase();
|
|
||||||
|
|
||||||
if (key === primaryKey || seen.has(key)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
seen.add(key);
|
|
||||||
|
|
||||||
out.push(trimmed);
|
|
||||||
}
|
|
||||||
|
|
||||||
return out.length > 0 ? out : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapWizardDraftToBoardCreatePayload(
|
export function mapWizardDraftToBoardCreatePayload(
|
||||||
draft: WorkOrderWizardDraft,
|
draft: WorkOrderWizardDraft,
|
||||||
): WorkOrderBoardCreatePayload {
|
): WorkOrderBoardCreatePayload {
|
||||||
|
|
@ -147,91 +106,9 @@ export function mapWizardDraftToBoardCreatePayload(
|
||||||
payload.assignTo = draft.assigneeId.trim();
|
payload.assignTo = draft.assigneeId.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (draft.dueDate) {
|
assignWizardScheduleFields(payload, draft);
|
||||||
payload.dueDate = draft.dueDate;
|
assignWizardVendorApptFields(payload, draft);
|
||||||
}
|
assignWizardServicePocFields(payload, draft);
|
||||||
|
|
||||||
const isRange = draft.schedMode === "range";
|
|
||||||
|
|
||||||
if (!isRange && draft.scheduleWeekOnly) {
|
|
||||||
payload.scheduleWeekOnly = true;
|
|
||||||
|
|
||||||
const week = draft.targetWeek?.trim() || (draft.scheduledOn ? mondayOf(draft.scheduledOn) : "");
|
|
||||||
|
|
||||||
if (week) {
|
|
||||||
payload.targetWeek = week;
|
|
||||||
}
|
|
||||||
} else if (!isRange && draft.scheduledOn) {
|
|
||||||
payload.scheduledDate = draft.scheduledOn;
|
|
||||||
}
|
|
||||||
|
|
||||||
const vendorId = draft.vendorId.trim() ? Number(draft.vendorId) : NaN;
|
|
||||||
|
|
||||||
const hasVendor = Number.isFinite(vendorId);
|
|
||||||
|
|
||||||
if (hasVendor) {
|
|
||||||
payload.vendorId = vendorId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const apptTime = formatApptTimeValue(draft.apptStart, draft.apptEnd);
|
|
||||||
|
|
||||||
if (hasVendor && (draft.scheduledOn || apptTime) && !draft.scheduleWeekOnly && !isRange) {
|
|
||||||
if (draft.scheduledOn) {
|
|
||||||
payload.apptDate = draft.scheduledOn;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (apptTime) {
|
|
||||||
payload.apptTime = apptTime;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const primaryService = trimOrUndefined(draft.pm);
|
|
||||||
|
|
||||||
if (primaryService) {
|
|
||||||
payload.primaryService = primaryService;
|
|
||||||
}
|
|
||||||
|
|
||||||
const extraServices = normalizeExtraServices(primaryService, draft.extraServices);
|
|
||||||
|
|
||||||
if (extraServices) {
|
|
||||||
payload.extraServices = extraServices;
|
|
||||||
}
|
|
||||||
|
|
||||||
const serviceNotes = trimOrUndefined(draft.pmNote);
|
|
||||||
|
|
||||||
if (serviceNotes) {
|
|
||||||
payload.serviceNotes = serviceNotes;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pocName = trimOrUndefined(draft.pocName);
|
|
||||||
|
|
||||||
if (pocName) {
|
|
||||||
payload.pocName = pocName;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pocPhone = trimOrUndefined(draft.pocPhone);
|
|
||||||
|
|
||||||
if (pocPhone) {
|
|
||||||
payload.pocPhone = pocPhone;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pocNotes = trimOrUndefined(draft.pocNotes);
|
|
||||||
|
|
||||||
if (pocNotes) {
|
|
||||||
payload.pocNotes = pocNotes;
|
|
||||||
}
|
|
||||||
|
|
||||||
const techPhone = trimOrUndefined(draft.techPhone);
|
|
||||||
|
|
||||||
if (techPhone) {
|
|
||||||
payload.techPhone = techPhone;
|
|
||||||
}
|
|
||||||
|
|
||||||
const vendorNotes = trimOrUndefined(draft.vendorNotes);
|
|
||||||
|
|
||||||
if (vendorNotes) {
|
|
||||||
payload.vendorNotes = vendorNotes;
|
|
||||||
}
|
|
||||||
|
|
||||||
const description = buildBoardDescription(draft);
|
const description = buildBoardDescription(draft);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,15 @@ import type {
|
||||||
WorkOrderTablePatch,
|
WorkOrderTablePatch,
|
||||||
WorkOrderTableRow,
|
WorkOrderTableRow,
|
||||||
} from "@/domain/work-orders/types/work-order-table-row";
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import {
|
||||||
|
expandScalarBoardOps,
|
||||||
|
mapDocStatusToApi,
|
||||||
|
type BoardFieldOp,
|
||||||
|
} from "@/domain/work-orders/mappers/expand-scalar-board-ops";
|
||||||
|
import { expandScheduleBoardOps } from "@/domain/work-orders/mappers/expand-schedule-board-ops";
|
||||||
|
|
||||||
export type BoardFieldOp = {
|
export type { BoardFieldOp };
|
||||||
field: WorkOrderBoardEditableField;
|
export { mapDocStatusToApi };
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DISPATCH_FIELDS = new Set<WorkOrderBoardEditableField>(["vendorId", "apptDate", "apptTime"]);
|
const DISPATCH_FIELDS = new Set<WorkOrderBoardEditableField>(["vendorId", "apptDate", "apptTime"]);
|
||||||
|
|
||||||
|
|
@ -38,13 +42,6 @@ const LOCAL_ONLY_KEYS = new Set<keyof WorkOrderTablePatch>([
|
||||||
"originalDate",
|
"originalDate",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function mapDocStatusToApi(value: string): string {
|
|
||||||
if (value === "NN") {
|
|
||||||
return "NotNeeded";
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isLocalOnlyTablePatch(patch: WorkOrderTablePatch): boolean {
|
export function isLocalOnlyTablePatch(patch: WorkOrderTablePatch): boolean {
|
||||||
const keys = Object.keys(patch) as (keyof WorkOrderTablePatch)[];
|
const keys = Object.keys(patch) as (keyof WorkOrderTablePatch)[];
|
||||||
return keys.length > 0 && keys.every((key) => LOCAL_ONLY_KEYS.has(key));
|
return keys.length > 0 && keys.every((key) => LOCAL_ONLY_KEYS.has(key));
|
||||||
|
|
@ -59,76 +56,7 @@ export function expandTablePatchToBoardFieldOps(patch: WorkOrderTablePatch): Boa
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ops: BoardFieldOp[] = [];
|
return [...expandScheduleBoardOps(patch), ...expandScalarBoardOps(patch)];
|
||||||
|
|
||||||
const hasScheduleRelated =
|
|
||||||
patch.scheduledOn !== undefined ||
|
|
||||||
patch.targetWeek !== undefined ||
|
|
||||||
patch.scheduleWeekOnly !== undefined;
|
|
||||||
|
|
||||||
if (hasScheduleRelated) {
|
|
||||||
if (patch.scheduleWeekOnly === true) {
|
|
||||||
ops.push({ field: "scheduleWeekOnly", value: "true" });
|
|
||||||
if (patch.targetWeek !== undefined) {
|
|
||||||
ops.push({ field: "targetWeek", value: patch.targetWeek });
|
|
||||||
}
|
|
||||||
if (patch.scheduledOn !== undefined) {
|
|
||||||
ops.push({ field: "scheduledDate", value: patch.scheduledOn });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (patch.scheduleWeekOnly === false) {
|
|
||||||
ops.push({ field: "scheduleWeekOnly", value: "false" });
|
|
||||||
}
|
|
||||||
if (patch.scheduledOn !== undefined) {
|
|
||||||
ops.push({ field: "scheduledDate", value: patch.scheduledOn });
|
|
||||||
}
|
|
||||||
if (patch.targetWeek !== undefined) {
|
|
||||||
ops.push({ field: "targetWeek", value: patch.targetWeek });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (patch.woNumber !== undefined) {
|
|
||||||
ops.push({ field: "woNumber", value: patch.woNumber });
|
|
||||||
}
|
|
||||||
if (patch.type !== undefined) {
|
|
||||||
ops.push({ field: "workOrderType", value: patch.type });
|
|
||||||
}
|
|
||||||
if (patch.site !== undefined) {
|
|
||||||
ops.push({ field: "siteCode", value: patch.site });
|
|
||||||
}
|
|
||||||
if (patch.status !== undefined) {
|
|
||||||
ops.push({ field: "lifecycleStatus", value: patch.status });
|
|
||||||
}
|
|
||||||
if (patch.dispatcherId !== undefined) {
|
|
||||||
ops.push({ field: "assignTo", value: patch.dispatcherId });
|
|
||||||
}
|
|
||||||
if (patch.dueDate !== undefined) {
|
|
||||||
ops.push({ field: "dueDate", value: patch.dueDate });
|
|
||||||
}
|
|
||||||
if (patch.vendorId !== undefined) {
|
|
||||||
ops.push({ field: "vendorId", value: String(patch.vendorId) });
|
|
||||||
}
|
|
||||||
if (patch.apptTime !== undefined) {
|
|
||||||
ops.push({ field: "apptTime", value: patch.apptTime });
|
|
||||||
}
|
|
||||||
if (patch.docStatus !== undefined) {
|
|
||||||
ops.push({ field: "docStatus", value: mapDocStatusToApi(patch.docStatus) });
|
|
||||||
}
|
|
||||||
if (patch.pm !== undefined) {
|
|
||||||
ops.push({ field: "pm", value: patch.pm });
|
|
||||||
}
|
|
||||||
if (patch.extraServices !== undefined) {
|
|
||||||
ops.push({ field: "extraServices", value: JSON.stringify(patch.extraServices) });
|
|
||||||
}
|
|
||||||
if (patch.serviceNotes !== undefined) {
|
|
||||||
ops.push({ field: "serviceNotes", value: patch.serviceNotes });
|
|
||||||
}
|
|
||||||
if (patch.flagColor !== undefined) {
|
|
||||||
ops.push({ field: "flagColor", value: patch.flagColor ?? "" });
|
|
||||||
}
|
|
||||||
|
|
||||||
return ops;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isDispatchBoardField(field: WorkOrderBoardEditableField): boolean {
|
export function isDispatchBoardField(field: WorkOrderBoardEditableField): boolean {
|
||||||
|
|
|
||||||
169
src/domain/work-orders/mappers/work-order-dispatch-mapper.ts
Normal file
169
src/domain/work-orders/mappers/work-order-dispatch-mapper.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
import type {
|
||||||
|
DispatchChecklistItem,
|
||||||
|
DispatchComment,
|
||||||
|
DispatchDetail,
|
||||||
|
DispatchSignoff,
|
||||||
|
DispatchToVendorPayload,
|
||||||
|
DispatchWorkOrderRef,
|
||||||
|
UpdateDispatchPayload,
|
||||||
|
} from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
|
function asRecord(raw: unknown): Record<string, unknown> {
|
||||||
|
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readId(record: Record<string, unknown>): string | number {
|
||||||
|
const value = record.id ?? record.Id;
|
||||||
|
if (typeof value === "string" || typeof value === "number") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumber(record: Record<string, unknown>, ...keys: string[]): number | null {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && value !== "") {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapDispatchChecklistItem(raw: unknown): DispatchChecklistItem {
|
||||||
|
const item = asRecord(raw);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: readId(item),
|
||||||
|
itemText: readString(item, "itemText", "ItemText"),
|
||||||
|
isCompleted: readBool(item, "isCompleted", "IsCompleted"),
|
||||||
|
completedBy: readString(item, "completedBy", "CompletedBy") || null,
|
||||||
|
completedAt: readString(item, "completedAt", "CompletedAt") || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapDispatchSignoff(raw: unknown): DispatchSignoff {
|
||||||
|
const item = asRecord(raw);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: readId(item),
|
||||||
|
signoffType: readString(item, "signoffType", "SignoffType"),
|
||||||
|
name: readString(item, "name", "Name"),
|
||||||
|
signature: readString(item, "signature", "Signature"),
|
||||||
|
signatureMethod: readString(item, "signatureMethod", "SignatureMethod"),
|
||||||
|
signedAt: readString(item, "signedAt", "SignedAt"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapDispatchComment(raw: unknown): DispatchComment {
|
||||||
|
const item = asRecord(raw);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: readId(item),
|
||||||
|
createdDate: readString(item, "createdDate", "CreatedDate"),
|
||||||
|
commenttext: readString(item, "commenttext", "Commenttext", "text", "Text"),
|
||||||
|
userName: readString(item, "userName", "UserName"),
|
||||||
|
commenter: readString(item, "commenter", "Commenter") || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapDispatchWorkOrderRef(raw: unknown): DispatchWorkOrderRef {
|
||||||
|
const item = asRecord(raw);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: readId(item),
|
||||||
|
woNumber: readString(item, "woNumber", "WoNumber", "WONumber", "number", "Number"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapDispatchDetail(raw: unknown): DispatchDetail {
|
||||||
|
const item = asRecord(raw);
|
||||||
|
const checklistItems = item.checklistItems ?? item.ChecklistItems;
|
||||||
|
const signoffs = item.signoffs ?? item.Signoffs;
|
||||||
|
const comments = item.comments ?? item.Comments;
|
||||||
|
const workOrders = item.workOrders ?? item.WorkOrders;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: readId(item),
|
||||||
|
dispatchNumber: readString(item, "dispatchNumber", "DispatchNumber"),
|
||||||
|
poNumber: readString(item, "poNumber", "PoNumber", "PONumber"),
|
||||||
|
vendorName: readString(item, "vendorName", "VendorName"),
|
||||||
|
vendorEmail: readString(item, "vendorEmail", "VendorEmail"),
|
||||||
|
woNumber: readString(item, "woNumber", "WoNumber"),
|
||||||
|
status: readString(item, "status", "Status"),
|
||||||
|
nteAmount: readNumber(item, "nteAmount", "NTEAmount"),
|
||||||
|
scheduledDate: readString(item, "scheduledDate", "ScheduledDate"),
|
||||||
|
estimatedArrivalAt: readString(item, "estimatedArrivalAt", "EstimatedArrivalAt"),
|
||||||
|
etaManualOverride: readBool(item, "etaManualOverride", "EtaManualOverride"),
|
||||||
|
completedDate: readString(item, "completedDate", "CompletedDate"),
|
||||||
|
description: readString(item, "description", "Description"),
|
||||||
|
dispatchedAt: readString(item, "dispatchedAt", "DispatchedAt"),
|
||||||
|
acknowledgedAt: readString(item, "acknowledgedAt", "AcknowledgedAt"),
|
||||||
|
verifiedAt: readString(item, "verifiedAt", "VerifiedAt"),
|
||||||
|
verifiedBy: readString(item, "verifiedBy", "VerifiedBy"),
|
||||||
|
checklistItems: Array.isArray(checklistItems)
|
||||||
|
? checklistItems.map(mapDispatchChecklistItem)
|
||||||
|
: [],
|
||||||
|
signoffs: Array.isArray(signoffs) ? signoffs.map(mapDispatchSignoff) : [],
|
||||||
|
comments: Array.isArray(comments) ? comments.map(mapDispatchComment) : [],
|
||||||
|
workOrders: Array.isArray(workOrders) ? workOrders.map(mapDispatchWorkOrderRef) : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapDispatchToVendorPayload(
|
||||||
|
payload: DispatchToVendorPayload,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
WorkOrderIds: payload.workOrderIds,
|
||||||
|
VendorIds: payload.vendorIds,
|
||||||
|
NTEAmount: payload.nteAmount,
|
||||||
|
ScheduledDate: payload.scheduledDate,
|
||||||
|
EstimatedArrivalAt: payload.estimatedArrivalAt,
|
||||||
|
EtaManualOverride: payload.etaManualOverride ?? false,
|
||||||
|
Description: payload.description,
|
||||||
|
TaskListTemplateId: payload.taskListTemplateId,
|
||||||
|
CustomChecklistItems: payload.customChecklistItems,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapUpdateDispatchPayload(payload: UpdateDispatchPayload): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
Id: payload.id,
|
||||||
|
Status: payload.status,
|
||||||
|
NTEAmount: payload.nteAmount,
|
||||||
|
ScheduledDate: payload.scheduledDate,
|
||||||
|
EstimatedArrivalAt: payload.estimatedArrivalAt,
|
||||||
|
EtaManualOverride: payload.etaManualOverride ?? false,
|
||||||
|
CompletedDate: payload.completedDate,
|
||||||
|
Description: payload.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,21 +1,8 @@
|
||||||
import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper";
|
|
||||||
import { parseSiteCodeFromLocationName } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
import { parseSiteCodeFromLocationName } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||||
import {
|
|
||||||
DOC_STATUS_API,
|
|
||||||
LIFECYCLE_STATUS_API_TO_FE,
|
|
||||||
WORK_ORDER_TYPE_API_TO_FE,
|
|
||||||
} from "@/domain/work-orders/types/work-order-board";
|
|
||||||
import type {
|
import type {
|
||||||
DispatchChecklistItem,
|
|
||||||
DispatchComment,
|
|
||||||
DispatchDetail,
|
|
||||||
DispatchSignoff,
|
|
||||||
DispatchToVendorPayload,
|
|
||||||
DispatchWorkOrderRef,
|
|
||||||
EmployeeOption,
|
EmployeeOption,
|
||||||
LocationOption,
|
LocationOption,
|
||||||
TaskListTemplate,
|
TaskListTemplate,
|
||||||
UpdateDispatchPayload,
|
|
||||||
WorkOrderAuditEntry,
|
WorkOrderAuditEntry,
|
||||||
WorkOrderComment,
|
WorkOrderComment,
|
||||||
WorkOrderDetail,
|
WorkOrderDetail,
|
||||||
|
|
@ -23,13 +10,6 @@ import type {
|
||||||
WorkOrderFormValues,
|
WorkOrderFormValues,
|
||||||
WorkOrderListItem,
|
WorkOrderListItem,
|
||||||
} from "@/domain/work-orders/types/work-order";
|
} from "@/domain/work-orders/types/work-order";
|
||||||
import type {
|
|
||||||
CompletionDocStatus,
|
|
||||||
WorkOrderTableRow,
|
|
||||||
} from "@/domain/work-orders/types/work-order-table-row";
|
|
||||||
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
|
||||||
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
|
||||||
import { formatApptRange } from "@/domain/work-orders/utils/wizard-date-utils";
|
|
||||||
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
|
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
|
||||||
import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order-common";
|
import type { WorkOrderStatus } from "@/domain/work-orders/types/work-order-common";
|
||||||
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order-common";
|
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order-common";
|
||||||
|
|
@ -91,32 +71,6 @@ function readNumber(record: Record<string, unknown>, ...keys: string[]): number
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readStringArray(record: Record<string, unknown>, ...keys: string[]): string[] {
|
|
||||||
for (const key of keys) {
|
|
||||||
const value = record[key];
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
return value
|
|
||||||
.filter((item): item is string => typeof item === "string")
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
if (typeof value === "string" && value.trim()) {
|
|
||||||
try {
|
|
||||||
const parsed: unknown = JSON.parse(value);
|
|
||||||
if (Array.isArray(parsed)) {
|
|
||||||
return parsed
|
|
||||||
.filter((item): item is string => typeof item === "string")
|
|
||||||
.map((item) => item.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore invalid JSON
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function readIdList(raw: unknown): number[] | undefined {
|
function readIdList(raw: unknown): number[] | undefined {
|
||||||
if (!Array.isArray(raw)) {
|
if (!Array.isArray(raw)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|
@ -226,16 +180,6 @@ function appendIdList(formData: FormData, key: string, ids: number[] | undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function readRowVersion(record: Record<string, unknown>, ...keys: string[]): string {
|
|
||||||
for (const key of keys) {
|
|
||||||
const value = record[key];
|
|
||||||
if (typeof value === "string" && value.length > 0) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
|
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const value = record[key];
|
const value = record[key];
|
||||||
|
|
@ -267,186 +211,7 @@ export function mapWorkOrderListItems(rawItems: unknown[]): WorkOrderListItem[]
|
||||||
return rawItems.map(mapWorkOrderListItem);
|
return rawItems.map(mapWorkOrderListItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
const WO_TYPES_SET = new Set(["PM", "Reactive", "Emergency", "Add-On", "Overdue"]);
|
export { mapWorkOrderTableRow, mapWorkOrderTableRows } from "./work-order-table-row-mapper";
|
||||||
|
|
||||||
function readWoType(record: Record<string, unknown>): WOType | "" {
|
|
||||||
const numeric =
|
|
||||||
readNumber(record, "workOrderType", "WorkOrderType") ?? readNumber(record, "type", "Type");
|
|
||||||
if (numeric != null && WORK_ORDER_TYPE_API_TO_FE[numeric]) {
|
|
||||||
return WORK_ORDER_TYPE_API_TO_FE[numeric] as WOType;
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw = readString(
|
|
||||||
record,
|
|
||||||
"workOrderType",
|
|
||||||
"WorkOrderType",
|
|
||||||
"type",
|
|
||||||
"Type",
|
|
||||||
"woType",
|
|
||||||
"WoType",
|
|
||||||
"source",
|
|
||||||
"Source",
|
|
||||||
);
|
|
||||||
if (WO_TYPES_SET.has(raw)) {
|
|
||||||
return raw as WOType;
|
|
||||||
}
|
|
||||||
if (raw === "AddOn") {
|
|
||||||
return "Add-On";
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function readDocStatus(record: Record<string, unknown>): CompletionDocStatus {
|
|
||||||
const numeric = readNumber(record, "docStatus", "DocStatus");
|
|
||||||
if (numeric === DOC_STATUS_API.Yes) return "Yes";
|
|
||||||
if (numeric === DOC_STATUS_API.No) return "No";
|
|
||||||
if (numeric === DOC_STATUS_API.NotNeeded) return "NN";
|
|
||||||
|
|
||||||
const raw = readString(record, "docStatus", "DocStatus", "completionDoc", "CompletionDoc");
|
|
||||||
if (raw === "Yes" || raw === "No" || raw === "NN") {
|
|
||||||
return raw;
|
|
||||||
}
|
|
||||||
if (raw === "NotNeeded") {
|
|
||||||
return "NN";
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function readBoardStatus(record: Record<string, unknown>): ReturnType<typeof mapApiStatusToWizard> {
|
|
||||||
const label = readString(record, "lifecycleStatusLabel", "LifecycleStatusLabel");
|
|
||||||
if (label) {
|
|
||||||
return mapApiStatusToWizard(label);
|
|
||||||
}
|
|
||||||
|
|
||||||
const lifecycle = readNumber(record, "lifecycleStatus", "LifecycleStatus");
|
|
||||||
if (lifecycle != null && LIFECYCLE_STATUS_API_TO_FE[lifecycle]) {
|
|
||||||
return mapApiStatusToWizard(LIFECYCLE_STATUS_API_TO_FE[lifecycle]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const legacy = readString(record, "legacyStatus", "LegacyStatus", "status", "Status");
|
|
||||||
return mapApiStatusToWizard(legacy);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
|
||||||
const item = asRecord(raw);
|
|
||||||
const scheduledOn = toDateInputValue(
|
|
||||||
readString(item, "scheduledOn", "scheduledDate", "ScheduledDate", "ScheduledOn"),
|
|
||||||
);
|
|
||||||
// FE day groups use MM/DD keys (week skeleton); ignore BE weekday labels like "monday".
|
|
||||||
const { dayGroup, dayLabel } = deriveDayFields(scheduledOn);
|
|
||||||
const woNumber = readString(
|
|
||||||
item,
|
|
||||||
"woNumber",
|
|
||||||
"internalWONumber",
|
|
||||||
"InternalWONumber",
|
|
||||||
"number",
|
|
||||||
"workOrderNumber",
|
|
||||||
"WorkerOrderNumber",
|
|
||||||
);
|
|
||||||
const site = readString(
|
|
||||||
item,
|
|
||||||
"siteCode",
|
|
||||||
"SiteCode",
|
|
||||||
"site",
|
|
||||||
"Site",
|
|
||||||
"locationCode",
|
|
||||||
"LocationCode",
|
|
||||||
);
|
|
||||||
const location = readString(item, "location", "Location", "locationName", "LocationName");
|
|
||||||
const dispatcherId = readString(
|
|
||||||
item,
|
|
||||||
"dispatcherId",
|
|
||||||
"DispatcherId",
|
|
||||||
"assigneeId",
|
|
||||||
"AssigneeId",
|
|
||||||
"employeeId",
|
|
||||||
"EmployeeId",
|
|
||||||
);
|
|
||||||
const dispatcherName = readString(
|
|
||||||
item,
|
|
||||||
"dispatcherName",
|
|
||||||
"DispatcherName",
|
|
||||||
"assignedTo",
|
|
||||||
"AssignedTo",
|
|
||||||
"employeeName",
|
|
||||||
"EmployeeName",
|
|
||||||
"assignee",
|
|
||||||
"Assignee",
|
|
||||||
);
|
|
||||||
const apptStart = readString(item, "apptStart", "ApptStart", "appointmentStart");
|
|
||||||
const apptEnd = readString(item, "apptEnd", "ApptEnd", "appointmentEnd");
|
|
||||||
const apptTime =
|
|
||||||
readString(item, "apptTime", "ApptTime") ||
|
|
||||||
(apptStart ? formatApptRange(apptStart, apptEnd) : "");
|
|
||||||
const locationIdRaw = item.locationId ?? item.LocationId;
|
|
||||||
const vendorIdNum = readNumber(item, "vendorId", "VendorId");
|
|
||||||
const primaryDispatchId = readNumber(item, "primaryDispatchId", "PrimaryDispatchId");
|
|
||||||
|
|
||||||
const row: WorkOrderTableRow = {
|
|
||||||
id: readId(item),
|
|
||||||
woNumber,
|
|
||||||
site: site || location.slice(0, 8),
|
|
||||||
type: readWoType(item),
|
|
||||||
dispatcherId,
|
|
||||||
dispatcherName,
|
|
||||||
scheduledOn,
|
|
||||||
targetWeek: toDateInputValue(readString(item, "targetWeek", "TargetWeek")),
|
|
||||||
dayGroup,
|
|
||||||
dayLabel,
|
|
||||||
location,
|
|
||||||
locationId:
|
|
||||||
typeof locationIdRaw === "string" || typeof locationIdRaw === "number" ? locationIdRaw : "",
|
|
||||||
pm: readString(item, "pm", "PM", "problem", "Problem", "service", "Service", "trade", "Trade"),
|
|
||||||
serviceNotes: readString(item, "serviceNotes", "ServiceNotes", "pmNote", "PmNote"),
|
|
||||||
extraServices: readStringArray(item, "extraServices", "ExtraServices"),
|
|
||||||
vendorId: vendorIdNum != null ? String(vendorIdNum) : "",
|
|
||||||
company: readString(item, "company", "Company", "vendorName", "VendorName"),
|
|
||||||
tech: readString(item, "techName", "TechName", "tech", "Tech", "technician", "Technician"),
|
|
||||||
techPhone: readString(item, "techPhone", "TechPhone"),
|
|
||||||
apptStart,
|
|
||||||
apptEnd,
|
|
||||||
apptTime,
|
|
||||||
status: readBoardStatus(item),
|
|
||||||
docStatus: readDocStatus(item),
|
|
||||||
pocName: readString(item, "pocName", "PocName", "POCName"),
|
|
||||||
pocPhone: readString(item, "pocPhone", "PocPhone", "POCPhone"),
|
|
||||||
pocNotes: readString(item, "pocNotes", "PocNotes", "POCNotes"),
|
|
||||||
dueDate: toDateInputValue(readString(item, "dueDate", "DueDate")),
|
|
||||||
rescheduleCount: readNumber(item, "rescheduleCount", "RescheduleCount") ?? 0,
|
|
||||||
carriedOver: readNumber(item, "carriedOver", "CarriedOver") ?? 0,
|
|
||||||
originalWeek: readString(item, "originalWeek", "OriginalWeek"),
|
|
||||||
originalDate: toDateInputValue(readString(item, "originalDate", "OriginalDate")),
|
|
||||||
isPastDue: readBool(item, "isPastDue", "IsPastDue"),
|
|
||||||
scheduleWeekOnly: readBool(item, "scheduleWeekOnly", "ScheduleWeekOnly"),
|
|
||||||
mediaCount: readNumber(item, "mediaCount", "MediaCount") ?? 0,
|
|
||||||
title: readString(item, "title", "Title", "workerOrderTitle", "WorkerOrderTitle"),
|
|
||||||
priority: readString(item, "priority", "Priority"),
|
|
||||||
primaryDispatchId,
|
|
||||||
rowVersion: readRowVersion(item, "rowVersion", "RowVersion"),
|
|
||||||
dispatchRowVersion: readRowVersion(item, "dispatchRowVersion", "DispatchRowVersion"),
|
|
||||||
flagColor: readFlagColor(item),
|
|
||||||
};
|
|
||||||
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readFlagColor(record: Record<string, unknown>): string | null {
|
|
||||||
for (const key of ["flagColor", "FlagColor"]) {
|
|
||||||
const value = record[key];
|
|
||||||
if (value == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (typeof value === "string") {
|
|
||||||
const trimmed = value.trim();
|
|
||||||
return trimmed || null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapWorkOrderTableRows(rawItems: unknown[]): WorkOrderTableRow[] {
|
|
||||||
return rawItems.map(mapWorkOrderTableRow);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapWorkOrderComment(raw: unknown): WorkOrderComment {
|
export function mapWorkOrderComment(raw: unknown): WorkOrderComment {
|
||||||
const item = asRecord(raw);
|
const item = asRecord(raw);
|
||||||
|
|
@ -681,114 +446,15 @@ export function mapTaskListTemplates(rawItems: unknown[]): TaskListTemplate[] {
|
||||||
return rawItems.map(mapTaskListTemplate);
|
return rawItems.map(mapTaskListTemplate);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapDispatchChecklistItem(raw: unknown): DispatchChecklistItem {
|
export {
|
||||||
const item = asRecord(raw);
|
mapDispatchChecklistItem,
|
||||||
|
mapDispatchComment,
|
||||||
return {
|
mapDispatchDetail,
|
||||||
id: readId(item),
|
mapDispatchSignoff,
|
||||||
itemText: readString(item, "itemText", "ItemText"),
|
mapDispatchToVendorPayload,
|
||||||
isCompleted: readBool(item, "isCompleted", "IsCompleted"),
|
mapDispatchWorkOrderRef,
|
||||||
completedBy: readString(item, "completedBy", "CompletedBy") || null,
|
mapUpdateDispatchPayload,
|
||||||
completedAt: readString(item, "completedAt", "CompletedAt") || null,
|
} from "./work-order-dispatch-mapper";
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapDispatchSignoff(raw: unknown): DispatchSignoff {
|
|
||||||
const item = asRecord(raw);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: readId(item),
|
|
||||||
signoffType: readString(item, "signoffType", "SignoffType"),
|
|
||||||
name: readString(item, "name", "Name"),
|
|
||||||
signature: readString(item, "signature", "Signature"),
|
|
||||||
signatureMethod: readString(item, "signatureMethod", "SignatureMethod"),
|
|
||||||
signedAt: readString(item, "signedAt", "SignedAt"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapDispatchComment(raw: unknown): DispatchComment {
|
|
||||||
const item = asRecord(raw);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: readId(item),
|
|
||||||
createdDate: readString(item, "createdDate", "CreatedDate"),
|
|
||||||
commenttext: readString(item, "commenttext", "Commenttext", "text", "Text"),
|
|
||||||
userName: readString(item, "userName", "UserName"),
|
|
||||||
commenter: readString(item, "commenter", "Commenter") || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapDispatchWorkOrderRef(raw: unknown): DispatchWorkOrderRef {
|
|
||||||
const item = asRecord(raw);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: readId(item),
|
|
||||||
woNumber: readString(item, "woNumber", "WoNumber", "WONumber", "number", "Number"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapDispatchDetail(raw: unknown): DispatchDetail {
|
|
||||||
const item = asRecord(raw);
|
|
||||||
const checklistItems = item.checklistItems ?? item.ChecklistItems;
|
|
||||||
const signoffs = item.signoffs ?? item.Signoffs;
|
|
||||||
const comments = item.comments ?? item.Comments;
|
|
||||||
const workOrders = item.workOrders ?? item.WorkOrders;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: readId(item),
|
|
||||||
dispatchNumber: readString(item, "dispatchNumber", "DispatchNumber"),
|
|
||||||
poNumber: readString(item, "poNumber", "PoNumber", "PONumber"),
|
|
||||||
vendorName: readString(item, "vendorName", "VendorName"),
|
|
||||||
vendorEmail: readString(item, "vendorEmail", "VendorEmail"),
|
|
||||||
woNumber: readString(item, "woNumber", "WoNumber"),
|
|
||||||
status: readString(item, "status", "Status"),
|
|
||||||
nteAmount: readNumber(item, "nteAmount", "NTEAmount"),
|
|
||||||
scheduledDate: readString(item, "scheduledDate", "ScheduledDate"),
|
|
||||||
estimatedArrivalAt: readString(item, "estimatedArrivalAt", "EstimatedArrivalAt"),
|
|
||||||
etaManualOverride: readBool(item, "etaManualOverride", "EtaManualOverride"),
|
|
||||||
completedDate: readString(item, "completedDate", "CompletedDate"),
|
|
||||||
description: readString(item, "description", "Description"),
|
|
||||||
dispatchedAt: readString(item, "dispatchedAt", "DispatchedAt"),
|
|
||||||
acknowledgedAt: readString(item, "acknowledgedAt", "AcknowledgedAt"),
|
|
||||||
verifiedAt: readString(item, "verifiedAt", "VerifiedAt"),
|
|
||||||
verifiedBy: readString(item, "verifiedBy", "VerifiedBy"),
|
|
||||||
checklistItems: Array.isArray(checklistItems)
|
|
||||||
? checklistItems.map(mapDispatchChecklistItem)
|
|
||||||
: [],
|
|
||||||
signoffs: Array.isArray(signoffs) ? signoffs.map(mapDispatchSignoff) : [],
|
|
||||||
comments: Array.isArray(comments) ? comments.map(mapDispatchComment) : [],
|
|
||||||
workOrders: Array.isArray(workOrders) ? workOrders.map(mapDispatchWorkOrderRef) : [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapDispatchToVendorPayload(
|
|
||||||
payload: DispatchToVendorPayload,
|
|
||||||
): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
WorkOrderIds: payload.workOrderIds,
|
|
||||||
VendorIds: payload.vendorIds,
|
|
||||||
NTEAmount: payload.nteAmount,
|
|
||||||
ScheduledDate: payload.scheduledDate,
|
|
||||||
EstimatedArrivalAt: payload.estimatedArrivalAt,
|
|
||||||
EtaManualOverride: payload.etaManualOverride ?? false,
|
|
||||||
Description: payload.description,
|
|
||||||
TaskListTemplateId: payload.taskListTemplateId,
|
|
||||||
CustomChecklistItems: payload.customChecklistItems,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapUpdateDispatchPayload(payload: UpdateDispatchPayload): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
Id: payload.id,
|
|
||||||
Status: payload.status,
|
|
||||||
NTEAmount: payload.nteAmount,
|
|
||||||
ScheduledDate: payload.scheduledDate,
|
|
||||||
EstimatedArrivalAt: payload.estimatedArrivalAt,
|
|
||||||
EtaManualOverride: payload.etaManualOverride ?? false,
|
|
||||||
CompletedDate: payload.completedDate,
|
|
||||||
Description: payload.description,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mapUpliftRequest(raw: unknown): UpliftRequest {
|
export function mapUpliftRequest(raw: unknown): UpliftRequest {
|
||||||
const item = asRecord(raw);
|
const item = asRecord(raw);
|
||||||
|
|
|
||||||
282
src/domain/work-orders/mappers/work-order-table-row-mapper.ts
Normal file
282
src/domain/work-orders/mappers/work-order-table-row-mapper.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
import { mapApiStatusToWizard } from "@/domain/work-orders/mappers/wizard-draft-mapper";
|
||||||
|
import {
|
||||||
|
DOC_STATUS_API,
|
||||||
|
LIFECYCLE_STATUS_API_TO_FE,
|
||||||
|
WORK_ORDER_TYPE_API_TO_FE,
|
||||||
|
} from "@/domain/work-orders/types/work-order-board";
|
||||||
|
import type {
|
||||||
|
CompletionDocStatus,
|
||||||
|
WorkOrderTableRow,
|
||||||
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import { deriveDayFields } from "@/domain/work-orders/utils/table-day-utils";
|
||||||
|
import { formatApptRange } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
import { toDateInputValue } from "@/lib/time-utils";
|
||||||
|
|
||||||
|
function asRecord(raw: unknown): Record<string, unknown> {
|
||||||
|
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readId(record: Record<string, unknown>): string | number {
|
||||||
|
const value = record.id ?? record.Id;
|
||||||
|
if (typeof value === "string" || typeof value === "number") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readNumber(record: Record<string, unknown>, ...keys: string[]): number | null {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && value !== "") {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isNaN(parsed)) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStringArray(record: Record<string, unknown>, ...keys: string[]): string[] {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.filter((item): item is string => typeof item === "string")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
if (typeof value === "string" && value.trim()) {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(value);
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed
|
||||||
|
.filter((item): item is string => typeof item === "string")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore invalid JSON
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRowVersion(record: Record<string, unknown>, ...keys: string[]): string {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = record[key];
|
||||||
|
if (typeof value === "string" && value.length > 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const WO_TYPES_SET = new Set(["PM", "Reactive", "Emergency", "Add-On", "Overdue"]);
|
||||||
|
|
||||||
|
function readWoType(record: Record<string, unknown>): WOType | "" {
|
||||||
|
const numeric =
|
||||||
|
readNumber(record, "workOrderType", "WorkOrderType") ?? readNumber(record, "type", "Type");
|
||||||
|
if (numeric != null && WORK_ORDER_TYPE_API_TO_FE[numeric]) {
|
||||||
|
return WORK_ORDER_TYPE_API_TO_FE[numeric] as WOType;
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = readString(
|
||||||
|
record,
|
||||||
|
"workOrderType",
|
||||||
|
"WorkOrderType",
|
||||||
|
"type",
|
||||||
|
"Type",
|
||||||
|
"woType",
|
||||||
|
"WoType",
|
||||||
|
"source",
|
||||||
|
"Source",
|
||||||
|
);
|
||||||
|
if (WO_TYPES_SET.has(raw)) {
|
||||||
|
return raw as WOType;
|
||||||
|
}
|
||||||
|
if (raw === "AddOn") {
|
||||||
|
return "Add-On";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readDocStatus(record: Record<string, unknown>): CompletionDocStatus {
|
||||||
|
const numeric = readNumber(record, "docStatus", "DocStatus");
|
||||||
|
if (numeric === DOC_STATUS_API.Yes) return "Yes";
|
||||||
|
if (numeric === DOC_STATUS_API.No) return "No";
|
||||||
|
if (numeric === DOC_STATUS_API.NotNeeded) return "NN";
|
||||||
|
|
||||||
|
const raw = readString(record, "docStatus", "DocStatus", "completionDoc", "CompletionDoc");
|
||||||
|
if (raw === "Yes" || raw === "No" || raw === "NN") {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
if (raw === "NotNeeded") {
|
||||||
|
return "NN";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBoardStatus(record: Record<string, unknown>): ReturnType<typeof mapApiStatusToWizard> {
|
||||||
|
const label = readString(record, "lifecycleStatusLabel", "LifecycleStatusLabel");
|
||||||
|
if (label) {
|
||||||
|
return mapApiStatusToWizard(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lifecycle = readNumber(record, "lifecycleStatus", "LifecycleStatus");
|
||||||
|
if (lifecycle != null && LIFECYCLE_STATUS_API_TO_FE[lifecycle]) {
|
||||||
|
return mapApiStatusToWizard(LIFECYCLE_STATUS_API_TO_FE[lifecycle]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const legacy = readString(record, "legacyStatus", "LegacyStatus", "status", "Status");
|
||||||
|
return mapApiStatusToWizard(legacy);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFlagColor(record: Record<string, unknown>): string | null {
|
||||||
|
for (const key of ["flagColor", "FlagColor"]) {
|
||||||
|
const value = record[key];
|
||||||
|
if (value == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapWorkOrderTableRow(raw: unknown): WorkOrderTableRow {
|
||||||
|
const item = asRecord(raw);
|
||||||
|
const scheduledOn = toDateInputValue(
|
||||||
|
readString(item, "scheduledOn", "scheduledDate", "ScheduledDate", "ScheduledOn"),
|
||||||
|
);
|
||||||
|
// FE day groups use MM/DD keys (week skeleton); ignore BE weekday labels like "monday".
|
||||||
|
const { dayGroup, dayLabel } = deriveDayFields(scheduledOn);
|
||||||
|
const woNumber = readString(
|
||||||
|
item,
|
||||||
|
"woNumber",
|
||||||
|
"internalWONumber",
|
||||||
|
"InternalWONumber",
|
||||||
|
"number",
|
||||||
|
"workOrderNumber",
|
||||||
|
"WorkerOrderNumber",
|
||||||
|
);
|
||||||
|
const site = readString(
|
||||||
|
item,
|
||||||
|
"siteCode",
|
||||||
|
"SiteCode",
|
||||||
|
"site",
|
||||||
|
"Site",
|
||||||
|
"locationCode",
|
||||||
|
"LocationCode",
|
||||||
|
);
|
||||||
|
const location = readString(item, "location", "Location", "locationName", "LocationName");
|
||||||
|
const dispatcherId = readString(
|
||||||
|
item,
|
||||||
|
"dispatcherId",
|
||||||
|
"DispatcherId",
|
||||||
|
"assigneeId",
|
||||||
|
"AssigneeId",
|
||||||
|
"employeeId",
|
||||||
|
"EmployeeId",
|
||||||
|
);
|
||||||
|
const dispatcherName = readString(
|
||||||
|
item,
|
||||||
|
"dispatcherName",
|
||||||
|
"DispatcherName",
|
||||||
|
"assignedTo",
|
||||||
|
"AssignedTo",
|
||||||
|
"employeeName",
|
||||||
|
"EmployeeName",
|
||||||
|
"assignee",
|
||||||
|
"Assignee",
|
||||||
|
);
|
||||||
|
const apptStart = readString(item, "apptStart", "ApptStart", "appointmentStart");
|
||||||
|
const apptEnd = readString(item, "apptEnd", "ApptEnd", "appointmentEnd");
|
||||||
|
const apptTime =
|
||||||
|
readString(item, "apptTime", "ApptTime") ||
|
||||||
|
(apptStart ? formatApptRange(apptStart, apptEnd) : "");
|
||||||
|
const locationIdRaw = item.locationId ?? item.LocationId;
|
||||||
|
const vendorIdNum = readNumber(item, "vendorId", "VendorId");
|
||||||
|
const primaryDispatchId = readNumber(item, "primaryDispatchId", "PrimaryDispatchId");
|
||||||
|
|
||||||
|
const row: WorkOrderTableRow = {
|
||||||
|
id: readId(item),
|
||||||
|
woNumber,
|
||||||
|
site: site || location.slice(0, 8),
|
||||||
|
type: readWoType(item),
|
||||||
|
dispatcherId,
|
||||||
|
dispatcherName,
|
||||||
|
scheduledOn,
|
||||||
|
targetWeek: toDateInputValue(readString(item, "targetWeek", "TargetWeek")),
|
||||||
|
dayGroup,
|
||||||
|
dayLabel,
|
||||||
|
location,
|
||||||
|
locationId:
|
||||||
|
typeof locationIdRaw === "string" || typeof locationIdRaw === "number" ? locationIdRaw : "",
|
||||||
|
pm: readString(item, "pm", "PM", "problem", "Problem", "service", "Service", "trade", "Trade"),
|
||||||
|
serviceNotes: readString(item, "serviceNotes", "ServiceNotes", "pmNote", "PmNote"),
|
||||||
|
extraServices: readStringArray(item, "extraServices", "ExtraServices"),
|
||||||
|
vendorId: vendorIdNum != null ? String(vendorIdNum) : "",
|
||||||
|
company: readString(item, "company", "Company", "vendorName", "VendorName"),
|
||||||
|
tech: readString(item, "techName", "TechName", "tech", "Tech", "technician", "Technician"),
|
||||||
|
techPhone: readString(item, "techPhone", "TechPhone"),
|
||||||
|
apptStart,
|
||||||
|
apptEnd,
|
||||||
|
apptTime,
|
||||||
|
status: readBoardStatus(item),
|
||||||
|
docStatus: readDocStatus(item),
|
||||||
|
pocName: readString(item, "pocName", "PocName", "POCName"),
|
||||||
|
pocPhone: readString(item, "pocPhone", "PocPhone", "POCPhone"),
|
||||||
|
pocNotes: readString(item, "pocNotes", "PocNotes", "POCNotes"),
|
||||||
|
dueDate: toDateInputValue(readString(item, "dueDate", "DueDate")),
|
||||||
|
rescheduleCount: readNumber(item, "rescheduleCount", "RescheduleCount") ?? 0,
|
||||||
|
carriedOver: readNumber(item, "carriedOver", "CarriedOver") ?? 0,
|
||||||
|
originalWeek: readString(item, "originalWeek", "OriginalWeek"),
|
||||||
|
originalDate: toDateInputValue(readString(item, "originalDate", "OriginalDate")),
|
||||||
|
isPastDue: readBool(item, "isPastDue", "IsPastDue"),
|
||||||
|
scheduleWeekOnly: readBool(item, "scheduleWeekOnly", "ScheduleWeekOnly"),
|
||||||
|
mediaCount: readNumber(item, "mediaCount", "MediaCount") ?? 0,
|
||||||
|
title: readString(item, "title", "Title", "workerOrderTitle", "WorkerOrderTitle"),
|
||||||
|
priority: readString(item, "priority", "Priority"),
|
||||||
|
primaryDispatchId,
|
||||||
|
rowVersion: readRowVersion(item, "rowVersion", "RowVersion"),
|
||||||
|
dispatchRowVersion: readRowVersion(item, "dispatchRowVersion", "DispatchRowVersion"),
|
||||||
|
flagColor: readFlagColor(item),
|
||||||
|
};
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapWorkOrderTableRows(rawItems: unknown[]): WorkOrderTableRow[] {
|
||||||
|
return rawItems.map(mapWorkOrderTableRow);
|
||||||
|
}
|
||||||
|
|
@ -376,8 +376,8 @@ export function useAddDispatchSignoff(
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: ({ signoffType, name, signature, signatureMethod }: AddDispatchSignoffInput) =>
|
mutationFn: (input: AddDispatchSignoffInput) =>
|
||||||
workOrdersApi.addDispatchSignoff(dispatchId, signoffType, name, signature, signatureMethod),
|
workOrdersApi.addDispatchSignoff(dispatchId, input),
|
||||||
onSuccess: (_data, variables) => {
|
onSuccess: (_data, variables) => {
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.dispatch(dispatchId) });
|
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.dispatch(dispatchId) });
|
||||||
toast.success(`${variables.signoffType} sign-off collected`);
|
toast.success(`${variables.signoffType} sign-off collected`);
|
||||||
|
|
|
||||||
57
src/domain/work-orders/utils/advanced-filter-predicates.ts
Normal file
57
src/domain/work-orders/utils/advanced-filter-predicates.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import type { WorkOrderAdvancedFilters } from "@/domain/work-orders/types/work-order-advanced-filters";
|
||||||
|
import { ASSIGNEE_FILTER_UNASSIGNED } from "@/domain/work-orders/types/work-order-list";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { dateRangeFor } from "@/domain/work-orders/utils/advanced-filters-utils";
|
||||||
|
|
||||||
|
type AdvancedFilterPredicate = (
|
||||||
|
row: WorkOrderTableRow,
|
||||||
|
filters: WorkOrderAdvancedFilters,
|
||||||
|
) => boolean;
|
||||||
|
|
||||||
|
const matchesDateRange: AdvancedFilterPredicate = (row, filters) => {
|
||||||
|
const [from, to] = dateRangeFor(filters);
|
||||||
|
return !row.scheduledOn || (row.scheduledOn >= from && row.scheduledOn <= to);
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchesSite: AdvancedFilterPredicate = (row, filters) =>
|
||||||
|
!filters.sites.size || filters.sites.has(row.site);
|
||||||
|
|
||||||
|
const matchesType: AdvancedFilterPredicate = (row, filters) => {
|
||||||
|
if (!filters.types.size) return true;
|
||||||
|
const overdueMatch = filters.types.has("Overdue") && row.isPastDue;
|
||||||
|
const typeMatch = filters.types.has(row.type as never);
|
||||||
|
return overdueMatch || typeMatch;
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchesDispatcher: AdvancedFilterPredicate = (row, filters) => {
|
||||||
|
if (!filters.dispatchers.size) return true;
|
||||||
|
const unassignedMatch = filters.dispatchers.has(ASSIGNEE_FILTER_UNASSIGNED) && !row.dispatcherId;
|
||||||
|
const idMatch = !!row.dispatcherId && filters.dispatchers.has(row.dispatcherId);
|
||||||
|
return unassignedMatch || idMatch;
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchesStatus: AdvancedFilterPredicate = (row, filters) =>
|
||||||
|
!filters.statuses.size || (!!row.status && filters.statuses.has(row.status as never));
|
||||||
|
|
||||||
|
const matchesPmType: AdvancedFilterPredicate = (row, filters) =>
|
||||||
|
!filters.pmTypes.size || filters.pmTypes.has(row.pm);
|
||||||
|
|
||||||
|
const matchesVendorTech: AdvancedFilterPredicate = (row, filters) =>
|
||||||
|
!filters.vendorTechs.size ||
|
||||||
|
filters.vendorTechs.has(row.tech) ||
|
||||||
|
filters.vendorTechs.has(row.company);
|
||||||
|
|
||||||
|
const matchesDoc: AdvancedFilterPredicate = (row, filters) =>
|
||||||
|
!filters.docs.size || filters.docs.has(row.docStatus as never);
|
||||||
|
|
||||||
|
/** One predicate per filter dimension; a row matches when every predicate is true. */
|
||||||
|
export const ADVANCED_FILTER_PREDICATES: AdvancedFilterPredicate[] = [
|
||||||
|
matchesDateRange,
|
||||||
|
matchesSite,
|
||||||
|
matchesType,
|
||||||
|
matchesDispatcher,
|
||||||
|
matchesStatus,
|
||||||
|
matchesPmType,
|
||||||
|
matchesVendorTech,
|
||||||
|
matchesDoc,
|
||||||
|
];
|
||||||
109
src/domain/work-orders/utils/board-audit-action-rules.ts
Normal file
109
src/domain/work-orders/utils/board-audit-action-rules.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order-detail";
|
||||||
|
|
||||||
|
export type AuditActionSource = Pick<
|
||||||
|
WorkOrderAuditEntry,
|
||||||
|
"action" | "fieldName" | "oldValue" | "newValue"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type FormattedAuditValue = {
|
||||||
|
text: string;
|
||||||
|
/** Per-item quotes already applied; omit outer quotes in sentence templates. */
|
||||||
|
isQuotedList: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function wrapAuditValue(value: FormattedAuditValue): string {
|
||||||
|
if (!value.text) return value.text;
|
||||||
|
return value.isQuotedList ? value.text : `"${value.text}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AuditActionRuleContext = {
|
||||||
|
entry: AuditActionSource;
|
||||||
|
fieldKey: string;
|
||||||
|
actionKey: string;
|
||||||
|
oldValue: FormattedAuditValue;
|
||||||
|
newValue: FormattedAuditValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuditActionRule = (ctx: AuditActionRuleContext) => string | null;
|
||||||
|
|
||||||
|
function tryStatusRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null {
|
||||||
|
const matches =
|
||||||
|
fieldKey.includes("status") || fieldKey === "lifecyclestatus" || actionKey.includes("status");
|
||||||
|
if (!matches || !newValue.text) return null;
|
||||||
|
|
||||||
|
return `Changed status to ${wrapAuditValue(newValue)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryApptRule({
|
||||||
|
fieldKey,
|
||||||
|
actionKey,
|
||||||
|
oldValue,
|
||||||
|
newValue,
|
||||||
|
}: AuditActionRuleContext): string | null {
|
||||||
|
const matches =
|
||||||
|
fieldKey.includes("appt") || fieldKey.includes("appointment") || actionKey.includes("appt");
|
||||||
|
if (!matches) return null;
|
||||||
|
|
||||||
|
if (oldValue.text && newValue.text) {
|
||||||
|
return `Changed appt time from ${wrapAuditValue(oldValue)} to ${wrapAuditValue(newValue)}`;
|
||||||
|
}
|
||||||
|
if (newValue.text) return `Changed appt time to ${wrapAuditValue(newValue)}`;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryScheduleRule({ fieldKey, actionKey, newValue }: AuditActionRuleContext): string | null {
|
||||||
|
const matches =
|
||||||
|
fieldKey.includes("scheduled") ||
|
||||||
|
fieldKey === "schedule" ||
|
||||||
|
fieldKey.includes("scheduledate") ||
|
||||||
|
fieldKey.includes("duedate") ||
|
||||||
|
actionKey === "rescheduled" ||
|
||||||
|
actionKey.includes("reschedule");
|
||||||
|
if (!matches) return null;
|
||||||
|
|
||||||
|
if (newValue.text) return `Changed schedule to ${wrapAuditValue(newValue)}`;
|
||||||
|
|
||||||
|
return "Rescheduled work order";
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryCarriedOverRule({ fieldKey, actionKey, entry }: AuditActionRuleContext): string | null {
|
||||||
|
const matches =
|
||||||
|
fieldKey.includes("carriedover") ||
|
||||||
|
actionKey === "weekrolled" ||
|
||||||
|
actionKey.includes("carryover");
|
||||||
|
if (!matches) return null;
|
||||||
|
|
||||||
|
const increment = Number(entry.newValue?.trim() ?? "");
|
||||||
|
if (Number.isFinite(increment) && increment > 0) {
|
||||||
|
return `Carried over to the next week (increment #${increment})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "Carried over to the next week";
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryPastDueRule({ fieldKey, actionKey }: AuditActionRuleContext): string | null {
|
||||||
|
const matches =
|
||||||
|
fieldKey.includes("pastdue") || actionKey.includes("pastdue") || actionKey === "markedpastdue";
|
||||||
|
if (!matches) return null;
|
||||||
|
|
||||||
|
return "WO marked as Past Due. Scheduled date has passed without completion.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryCreatedRule({ fieldKey, actionKey }: AuditActionRuleContext): string | null {
|
||||||
|
const matches =
|
||||||
|
fieldKey === "created" || actionKey === "workordercreated" || actionKey === "created";
|
||||||
|
if (!matches) return null;
|
||||||
|
|
||||||
|
return "Work order created in the system.";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applied in order; the first non-null result wins. */
|
||||||
|
export const BOARD_AUDIT_ACTION_RULES: AuditActionRule[] = [
|
||||||
|
tryStatusRule,
|
||||||
|
tryApptRule,
|
||||||
|
tryScheduleRule,
|
||||||
|
tryCarriedOverRule,
|
||||||
|
tryPastDueRule,
|
||||||
|
tryCreatedRule,
|
||||||
|
];
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order-detail";
|
import {
|
||||||
|
BOARD_AUDIT_ACTION_RULES,
|
||||||
type AuditActionSource = Pick<
|
wrapAuditValue,
|
||||||
WorkOrderAuditEntry,
|
type AuditActionSource,
|
||||||
"action" | "fieldName" | "oldValue" | "newValue"
|
type FormattedAuditValue,
|
||||||
>;
|
} from "@/domain/work-orders/utils/board-audit-action-rules";
|
||||||
|
|
||||||
function looksLikeHumanSentence(action: string): boolean {
|
function looksLikeHumanSentence(action: string): boolean {
|
||||||
return /\s/.test(action);
|
return /\s/.test(action);
|
||||||
|
|
@ -45,17 +45,6 @@ function formatJsonArrayDisplayValue(raw: string): string | null {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type FormattedAuditValue = {
|
|
||||||
text: string;
|
|
||||||
/** Per-item quotes already applied; omit outer quotes in sentence templates. */
|
|
||||||
isQuotedList: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function wrapAuditValue(value: FormattedAuditValue): string {
|
|
||||||
if (!value.text) return value.text;
|
|
||||||
return value.isQuotedList ? value.text : `"${value.text}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Parse ISO / date-only / pipe-separated API values → unique mm/dd/yyyy. */
|
/** Parse ISO / date-only / pipe-separated API values → unique mm/dd/yyyy. */
|
||||||
function formatAuditDisplayValue(raw: string): FormattedAuditValue {
|
function formatAuditDisplayValue(raw: string): FormattedAuditValue {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
|
|
@ -111,57 +100,22 @@ export function formatBoardAuditAction(entry: AuditActionSource): string {
|
||||||
const fieldKey = normalizeKey(fieldName);
|
const fieldKey = normalizeKey(fieldName);
|
||||||
const actionKey = normalizeKey(action);
|
const actionKey = normalizeKey(action);
|
||||||
|
|
||||||
if (
|
const ruleContext = { entry, fieldKey, actionKey, oldValue, newValue };
|
||||||
fieldKey.includes("status") ||
|
for (const rule of BOARD_AUDIT_ACTION_RULES) {
|
||||||
fieldKey === "lifecyclestatus" ||
|
const result = rule(ruleContext);
|
||||||
actionKey.includes("status")
|
if (result !== null) return result;
|
||||||
) {
|
|
||||||
if (newValue.text) return `Changed status to ${wrapAuditValue(newValue)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fieldKey.includes("appt") || fieldKey.includes("appointment") || actionKey.includes("appt")) {
|
return formatGenericAuditFallback({ action, fieldName, oldValue, newValue });
|
||||||
if (oldValue.text && newValue.text) {
|
}
|
||||||
return `Changed appt time from ${wrapAuditValue(oldValue)} to ${wrapAuditValue(newValue)}`;
|
|
||||||
}
|
|
||||||
if (newValue.text) return `Changed appt time to ${wrapAuditValue(newValue)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
fieldKey.includes("scheduled") ||
|
|
||||||
fieldKey === "schedule" ||
|
|
||||||
fieldKey.includes("scheduledate") ||
|
|
||||||
fieldKey.includes("duedate") ||
|
|
||||||
actionKey === "rescheduled" ||
|
|
||||||
actionKey.includes("reschedule")
|
|
||||||
) {
|
|
||||||
if (newValue.text) return `Changed schedule to ${wrapAuditValue(newValue)}`;
|
|
||||||
return "Rescheduled work order";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
fieldKey.includes("carriedover") ||
|
|
||||||
actionKey === "weekrolled" ||
|
|
||||||
actionKey.includes("carryover")
|
|
||||||
) {
|
|
||||||
const increment = Number(entry.newValue?.trim() ?? "");
|
|
||||||
if (Number.isFinite(increment) && increment > 0) {
|
|
||||||
return `Carried over to the next week (increment #${increment})`;
|
|
||||||
}
|
|
||||||
return "Carried over to the next week";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
fieldKey.includes("pastdue") ||
|
|
||||||
actionKey.includes("pastdue") ||
|
|
||||||
actionKey === "markedpastdue"
|
|
||||||
) {
|
|
||||||
return "WO marked as Past Due. Scheduled date has passed without completion.";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fieldKey === "created" || actionKey === "workordercreated" || actionKey === "created") {
|
|
||||||
return "Work order created in the system.";
|
|
||||||
}
|
|
||||||
|
|
||||||
|
function formatGenericAuditFallback(args: {
|
||||||
|
action: string;
|
||||||
|
fieldName: string;
|
||||||
|
oldValue: FormattedAuditValue;
|
||||||
|
newValue: FormattedAuditValue;
|
||||||
|
}): string {
|
||||||
|
const { action, fieldName, oldValue, newValue } = args;
|
||||||
if (!action && !fieldName && !oldValue.text && !newValue.text) {
|
if (!action && !fieldName && !oldValue.text && !newValue.text) {
|
||||||
return "Work order created in the system.";
|
return "Work order created in the system.";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import type {
|
||||||
WorkOrderDayGroup,
|
WorkOrderDayGroup,
|
||||||
WorkOrderTableRow,
|
WorkOrderTableRow,
|
||||||
} from "@/domain/work-orders/types/work-order-table-row";
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import { dateRangeFor } from "@/domain/work-orders/utils/advanced-filters-utils";
|
import { ADVANCED_FILTER_PREDICATES } from "@/domain/work-orders/utils/advanced-filter-predicates";
|
||||||
import { isDateInWeek } from "@/domain/work-orders/utils/table-day-utils";
|
import { isDateInWeek } from "@/domain/work-orders/utils/table-day-utils";
|
||||||
import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
|
import { weekRange } from "@/domain/work-orders/utils/list-week-utils";
|
||||||
|
|
||||||
|
|
@ -35,45 +35,7 @@ export function matchesAdvancedFilters(
|
||||||
row: WorkOrderTableRow,
|
row: WorkOrderTableRow,
|
||||||
filters: WorkOrderAdvancedFilters,
|
filters: WorkOrderAdvancedFilters,
|
||||||
): boolean {
|
): boolean {
|
||||||
const [from, to] = dateRangeFor(filters);
|
return ADVANCED_FILTER_PREDICATES.every((predicate) => predicate(row, filters));
|
||||||
if (row.scheduledOn && (row.scheduledOn < from || row.scheduledOn > to)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (filters.sites.size && !filters.sites.has(row.site)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (filters.types.size) {
|
|
||||||
const overdueMatch = filters.types.has("Overdue") && row.isPastDue;
|
|
||||||
const typeMatch = filters.types.has(row.type as never);
|
|
||||||
if (!overdueMatch && !typeMatch) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (filters.dispatchers.size) {
|
|
||||||
const unassignedMatch =
|
|
||||||
filters.dispatchers.has(ASSIGNEE_FILTER_UNASSIGNED) && !row.dispatcherId;
|
|
||||||
const idMatch = !!row.dispatcherId && filters.dispatchers.has(row.dispatcherId);
|
|
||||||
if (!unassignedMatch && !idMatch) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (filters.statuses.size && (!row.status || !filters.statuses.has(row.status as never))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (filters.pmTypes.size && !filters.pmTypes.has(row.pm)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
filters.vendorTechs.size &&
|
|
||||||
!filters.vendorTechs.has(row.tech) &&
|
|
||||||
!filters.vendorTechs.has(row.company)
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (filters.docs.size && !filters.docs.has(row.docStatus as never)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowMatchesSearch(row: WorkOrderTableRow, query: string): boolean {
|
function rowMatchesSearch(row: WorkOrderTableRow, query: string): boolean {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue