mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-06 18:12:05 +00:00
Merge branch 'dev' into feature/wo-board-completed-date-column
This commit is contained in:
commit
449ec647b0
105 changed files with 6506 additions and 653 deletions
|
|
@ -2,16 +2,5 @@
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"purpose": "Frozen grandfathered debt inventory for the frontend governance checks. New entries and cap increases fail the governance comparison. Existing caps may only decrease and entries must be removed when compliant.",
|
"purpose": "Frozen grandfathered debt inventory for the frontend governance checks. New entries and cap increases fail the governance comparison. Existing caps may only decrease and entries must be removed when compliant.",
|
||||||
"maxFileLines": 500,
|
"maxFileLines": 500,
|
||||||
"godfileDebt": [
|
"godfileDebt": []
|
||||||
{
|
|
||||||
"path": "src/app/(protected)/workorders/[id].tsx",
|
|
||||||
"maxLines": 797,
|
|
||||||
"reason": "Work-order detail route page; legacy godfile targeted for decomposition into focused state components."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"path": "src/app/(protected)/vendors/index.tsx",
|
|
||||||
"maxLines": 612,
|
|
||||||
"reason": "Vendors list route; legacy page with mixed query/filter/table state pending extraction."
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
import { Ban, Save, Sparkles, Upload } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-button-sx";
|
||||||
|
|
||||||
|
type CompDocDialogFooterDraftActionsProps = {
|
||||||
|
rowVersion?: string | number | null;
|
||||||
|
isUploading: boolean;
|
||||||
|
pdfInputRef: RefObject<HTMLInputElement | null>;
|
||||||
|
onMarkNotRequired: () => void;
|
||||||
|
onSaveDraft: () => void;
|
||||||
|
onGenerate: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocDialogFooterDraftActions({
|
||||||
|
rowVersion,
|
||||||
|
isUploading,
|
||||||
|
pdfInputRef,
|
||||||
|
onMarkNotRequired,
|
||||||
|
onSaveDraft,
|
||||||
|
onGenerate,
|
||||||
|
}: CompDocDialogFooterDraftActionsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="default"
|
||||||
|
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||||
|
sx={COMP_DOC_FOOTER_BTN_SX}
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
onClick={onMarkNotRequired}
|
||||||
|
>
|
||||||
|
<Ban size={14} /> Mark as not required
|
||||||
|
</Button>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||||
|
sx={COMP_DOC_FOOTER_BTN_SX}
|
||||||
|
onClick={onSaveDraft}
|
||||||
|
>
|
||||||
|
<Save size={14} /> Save draft
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||||
|
sx={COMP_DOC_FOOTER_BTN_SX}
|
||||||
|
disabled={!rowVersion}
|
||||||
|
loading={isUploading}
|
||||||
|
loadingText="Uploading…"
|
||||||
|
onClick={() => pdfInputRef.current?.click()}
|
||||||
|
startIcon={!isUploading ? <Upload size={14} /> : undefined}
|
||||||
|
>
|
||||||
|
Upload PDF
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="default"
|
||||||
|
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||||
|
sx={COMP_DOC_FOOTER_BTN_SX}
|
||||||
|
style={{ background: "var(--color-primary)", color: "#fff" }}
|
||||||
|
onClick={onGenerate}
|
||||||
|
>
|
||||||
|
<Sparkles size={14} /> Generate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
import { CheckCircle2, Download, Upload } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-button-sx";
|
||||||
|
|
||||||
|
type CompDocDialogFooterGeneratedActionsProps = {
|
||||||
|
generatedAt: string;
|
||||||
|
signOffAttachment?: string | null;
|
||||||
|
isDocumentsReadOnly: boolean;
|
||||||
|
rowVersion?: string | number | null;
|
||||||
|
isUploading: boolean;
|
||||||
|
pdfInputRef: RefObject<HTMLInputElement | null>;
|
||||||
|
onDownloadPdf: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocDialogFooterGeneratedActions({
|
||||||
|
generatedAt,
|
||||||
|
signOffAttachment,
|
||||||
|
isDocumentsReadOnly,
|
||||||
|
rowVersion,
|
||||||
|
isUploading,
|
||||||
|
pdfInputRef,
|
||||||
|
onDownloadPdf,
|
||||||
|
}: CompDocDialogFooterGeneratedActionsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 whitespace-nowrap"
|
||||||
|
style={{ fontSize: 12, color: "var(--color-success)" }}
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={14} />{" "}
|
||||||
|
{signOffAttachment ? "Document on file" : `Generated ${generatedAt}`}
|
||||||
|
</span>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{!isDocumentsReadOnly && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||||
|
sx={COMP_DOC_FOOTER_BTN_SX}
|
||||||
|
disabled={!rowVersion}
|
||||||
|
loading={isUploading}
|
||||||
|
loadingText="Uploading…"
|
||||||
|
onClick={() => pdfInputRef.current?.click()}
|
||||||
|
startIcon={!isUploading ? <Upload size={14} /> : undefined}
|
||||||
|
>
|
||||||
|
Replace PDF
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="default"
|
||||||
|
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||||
|
sx={COMP_DOC_FOOTER_BTN_SX}
|
||||||
|
style={{ background: "var(--color-primary)", color: "#fff" }}
|
||||||
|
onClick={onDownloadPdf}
|
||||||
|
>
|
||||||
|
<Download size={14} /> Download PDF
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { Ban, Sparkles, Undo2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { COMP_DOC_FOOTER_BTN_SX } from "./comp-doc-footer-button-sx";
|
||||||
|
|
||||||
|
type CompDocDialogFooterNotRequiredActionsProps = {
|
||||||
|
isDocumentsReadOnly: boolean;
|
||||||
|
onUndo: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocDialogFooterNotRequiredActions({
|
||||||
|
isDocumentsReadOnly,
|
||||||
|
onUndo,
|
||||||
|
}: CompDocDialogFooterNotRequiredActionsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 whitespace-nowrap"
|
||||||
|
style={{ fontSize: 12, color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
<Ban size={14} style={{ color: "var(--warning)" }} /> Marked{" "}
|
||||||
|
<b style={{ fontWeight: 600 }}>not required</b> for this work order
|
||||||
|
</span>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{!isDocumentsReadOnly && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||||
|
sx={COMP_DOC_FOOTER_BTN_SX}
|
||||||
|
onClick={onUndo}
|
||||||
|
>
|
||||||
|
<Undo2 size={14} /> Undo
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="default"
|
||||||
|
disabled
|
||||||
|
title="Marked not required — undo to generate"
|
||||||
|
className="gap-1.5 whitespace-nowrap text-[12px]"
|
||||||
|
sx={COMP_DOC_FOOTER_BTN_SX}
|
||||||
|
style={{
|
||||||
|
background: "var(--color-primary)",
|
||||||
|
color: "#fff",
|
||||||
|
opacity: 0.5,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Sparkles size={14} /> Generate
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,27 +1,48 @@
|
||||||
|
import type { RefObject } from "react";
|
||||||
import type { DocState } from "@/domain/work-orders/types/completion-doc";
|
import type { DocState } from "@/domain/work-orders/types/completion-doc";
|
||||||
import { CompDocDialogFooterDraft } from "./comp-doc-dialog-footer-draft";
|
import { CompDocDialogFooterDraftActions } from "./comp-doc-dialog-footer-draft-actions";
|
||||||
import { CompDocDialogFooterGenerated } from "./comp-doc-dialog-footer-generated";
|
import { CompDocDialogFooterGeneratedActions } from "./comp-doc-dialog-footer-generated-actions";
|
||||||
import { CompDocDialogFooterNotRequired } from "./comp-doc-dialog-footer-not-required";
|
import { CompDocDialogFooterNotRequiredActions } from "./comp-doc-dialog-footer-not-required-actions";
|
||||||
|
|
||||||
type CompDocDialogFooterProps = {
|
type CompDocDialogFooterProps = {
|
||||||
docState: DocState;
|
docState: DocState;
|
||||||
|
isDocumentsReadOnly: boolean;
|
||||||
|
rowVersion?: string | number | null;
|
||||||
|
isUploading: boolean;
|
||||||
|
signOffAttachment?: string | null;
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
|
pdfInputRef: RefObject<HTMLInputElement | null>;
|
||||||
onMarkNotRequired: () => void;
|
onMarkNotRequired: () => void;
|
||||||
onUndo: () => void;
|
|
||||||
onSaveDraft: () => void;
|
onSaveDraft: () => void;
|
||||||
onGenerate: () => void;
|
onGenerate: () => void;
|
||||||
|
onUndo: () => void;
|
||||||
onDownloadPdf: () => void;
|
onDownloadPdf: () => void;
|
||||||
|
onUploadPdf: (files: FileList | null) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CompDocDialogFooter({
|
export function CompDocDialogFooter({
|
||||||
docState,
|
docState,
|
||||||
|
isDocumentsReadOnly,
|
||||||
|
rowVersion,
|
||||||
|
isUploading,
|
||||||
|
signOffAttachment,
|
||||||
generatedAt,
|
generatedAt,
|
||||||
|
pdfInputRef,
|
||||||
onMarkNotRequired,
|
onMarkNotRequired,
|
||||||
onUndo,
|
|
||||||
onSaveDraft,
|
onSaveDraft,
|
||||||
onGenerate,
|
onGenerate,
|
||||||
|
onUndo,
|
||||||
onDownloadPdf,
|
onDownloadPdf,
|
||||||
|
onUploadPdf,
|
||||||
}: CompDocDialogFooterProps) {
|
}: CompDocDialogFooterProps) {
|
||||||
|
// Exclusive footer branches (prototype: one docState at a time). A signed PDF
|
||||||
|
// on file implies Generated even if local docState has not caught up yet — never
|
||||||
|
// render Draft actions alongside Generated.
|
||||||
|
const showNotRequired = docState === "not-required";
|
||||||
|
const showGenerated =
|
||||||
|
!showNotRequired && (docState === "generated" || Boolean(signOffAttachment));
|
||||||
|
const showDraft = !showNotRequired && !showGenerated && docState === "draft";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex shrink-0 items-center justify-between gap-3"
|
className="flex shrink-0 items-center justify-between gap-3"
|
||||||
|
|
@ -31,17 +52,43 @@ export function CompDocDialogFooter({
|
||||||
background: "var(--color-bg-surface)",
|
background: "var(--color-bg-surface)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{docState === "draft" && (
|
<input
|
||||||
<CompDocDialogFooterDraft
|
ref={pdfInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf,.pdf"
|
||||||
|
hidden
|
||||||
|
onChange={(event) => {
|
||||||
|
onUploadPdf(event.target.files);
|
||||||
|
event.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{Boolean(showDraft && !isDocumentsReadOnly) && (
|
||||||
|
<CompDocDialogFooterDraftActions
|
||||||
|
rowVersion={rowVersion}
|
||||||
|
isUploading={isUploading}
|
||||||
|
pdfInputRef={pdfInputRef}
|
||||||
onMarkNotRequired={onMarkNotRequired}
|
onMarkNotRequired={onMarkNotRequired}
|
||||||
onSaveDraft={onSaveDraft}
|
onSaveDraft={onSaveDraft}
|
||||||
onGenerate={onGenerate}
|
onGenerate={onGenerate}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{docState === "generated" && (
|
{showGenerated && (
|
||||||
<CompDocDialogFooterGenerated generatedAt={generatedAt} onDownloadPdf={onDownloadPdf} />
|
<CompDocDialogFooterGeneratedActions
|
||||||
|
generatedAt={generatedAt}
|
||||||
|
signOffAttachment={signOffAttachment}
|
||||||
|
isDocumentsReadOnly={isDocumentsReadOnly}
|
||||||
|
rowVersion={rowVersion}
|
||||||
|
isUploading={isUploading}
|
||||||
|
pdfInputRef={pdfInputRef}
|
||||||
|
onDownloadPdf={onDownloadPdf}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showNotRequired && (
|
||||||
|
<CompDocDialogFooterNotRequiredActions
|
||||||
|
isDocumentsReadOnly={isDocumentsReadOnly}
|
||||||
|
onUndo={onUndo}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{docState === "not-required" && <CompDocDialogFooterNotRequired onUndo={onUndo} />}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,13 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
|
||||||
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
|
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
|
||||||
|
|
||||||
type CompDocDialogHeaderProps = {
|
type CompDocDialogHeaderProps = {
|
||||||
row: WorkOrderTableRow;
|
info: WorkOrderTableRow;
|
||||||
cfg: CompletionDocStateConfig;
|
cfg: CompletionDocStateConfig;
|
||||||
|
isUploading: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CompDocDialogHeader({ row, cfg, onClose }: CompDocDialogHeaderProps) {
|
export function CompDocDialogHeader({ info, cfg, isUploading, onClose }: CompDocDialogHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex shrink-0 items-center justify-between"
|
className="flex shrink-0 items-center justify-between"
|
||||||
|
|
@ -44,10 +45,10 @@ export function CompDocDialogHeader({ row, cfg, onClose }: CompDocDialogHeaderPr
|
||||||
>
|
>
|
||||||
WO{" "}
|
WO{" "}
|
||||||
<span className="font-mono" style={{ color: "var(--color-primary)", fontWeight: 600 }}>
|
<span className="font-mono" style={{ color: "var(--color-primary)", fontWeight: 600 }}>
|
||||||
#{row.woNumber}
|
#{info.woNumber}
|
||||||
</span>
|
</span>
|
||||||
{" · "}
|
{" · "}
|
||||||
{serviceTitle(row)}
|
{serviceTitle(info)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -77,8 +78,9 @@ export function CompDocDialogHeader({ row, cfg, onClose }: CompDocDialogHeaderPr
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
disabled={isUploading}
|
||||||
aria-label="Close"
|
aria-label="Close"
|
||||||
className="flex items-center justify-center rounded-md transition-colors hover:bg-[var(--color-bg-muted)]"
|
className="flex items-center justify-center rounded-md transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-40"
|
||||||
style={{
|
style={{
|
||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import { Download, FileText } from "lucide-react";
|
||||||
|
|
||||||
|
type CompDocDialogResourceLinksProps = {
|
||||||
|
templateUrl?: string | null;
|
||||||
|
signOffAttachment?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocDialogResourceLinks({
|
||||||
|
templateUrl,
|
||||||
|
signOffAttachment,
|
||||||
|
}: CompDocDialogResourceLinksProps) {
|
||||||
|
if (!templateUrl && !signOffAttachment) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 flex-wrap items-center gap-3 px-5 py-2"
|
||||||
|
style={{
|
||||||
|
borderBottom: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Boolean(templateUrl) && (
|
||||||
|
<a
|
||||||
|
href={templateUrl ?? undefined}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
||||||
|
style={{ color: "var(--color-primary)" }}
|
||||||
|
>
|
||||||
|
<Download size={13} />
|
||||||
|
Download blank template
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{Boolean(signOffAttachment) && (
|
||||||
|
<a
|
||||||
|
href={signOffAttachment ?? undefined}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
||||||
|
style={{ color: "var(--color-primary)" }}
|
||||||
|
>
|
||||||
|
<FileText size={13} />
|
||||||
|
Open uploaded PDF
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,11 +4,12 @@ import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdro
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
|
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
|
||||||
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 { useCompDocDialog } from "@/app/(protected)/workorders/_hooks/use-comp-doc-dialog";
|
|
||||||
import { CompDocDialogFooter } from "./comp-doc-dialog-footer";
|
import { CompDocDialogFooter } from "./comp-doc-dialog-footer";
|
||||||
import { CompDocDialogHeader } from "./comp-doc-dialog-header";
|
import { CompDocDialogHeader } from "./comp-doc-dialog-header";
|
||||||
|
import { CompDocDialogResourceLinks } from "./comp-doc-dialog-resource-links";
|
||||||
import { CompDocPreview } from "./comp-doc-preview";
|
import { CompDocPreview } from "./comp-doc-preview";
|
||||||
import { CompDocSidebar } from "./comp-doc-sidebar";
|
import { CompDocSidebar } from "./comp-doc-sidebar";
|
||||||
|
import { useCompDocDialog } from "./use-comp-doc-dialog";
|
||||||
|
|
||||||
type CompDocDialogProps = {
|
type CompDocDialogProps = {
|
||||||
row: WorkOrderTableRow;
|
row: WorkOrderTableRow;
|
||||||
|
|
@ -27,32 +28,19 @@ export function CompDocDialog({
|
||||||
onSave,
|
onSave,
|
||||||
onDateCompletedChange,
|
onDateCompletedChange,
|
||||||
}: CompDocDialogProps) {
|
}: CompDocDialogProps) {
|
||||||
const {
|
const dialog = useCompDocDialog({
|
||||||
site,
|
row,
|
||||||
notes,
|
open,
|
||||||
setNotes,
|
data,
|
||||||
docState,
|
onSave,
|
||||||
generatedAt,
|
onDateCompletedChange,
|
||||||
editAddress,
|
});
|
||||||
setEditAddress,
|
const handleClose = dialog.isUploading ? undefined : () => onOpenChange(false);
|
||||||
editDateCompleted,
|
|
||||||
setEditDateCompleted,
|
|
||||||
amazonRep,
|
|
||||||
seaRep,
|
|
||||||
cfg,
|
|
||||||
isNotRequired,
|
|
||||||
isGenerated,
|
|
||||||
handleMarkNotRequired,
|
|
||||||
handleUndo,
|
|
||||||
handleSaveDraft,
|
|
||||||
handleGenerate,
|
|
||||||
handleDownloadPdf,
|
|
||||||
} = useCompDocDialog({ row, open, data, onSave, onDateCompletedChange });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
onClose={() => onOpenChange(false)}
|
onClose={handleClose}
|
||||||
maxWidth={false}
|
maxWidth={false}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
...dialogBlurBackdropSlotProps,
|
...dialogBlurBackdropSlotProps,
|
||||||
|
|
@ -78,48 +66,64 @@ export function CompDocDialog({
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogTitle className="sr-only">Completion Document — WO #{row.woNumber}</DialogTitle>
|
<DialogTitle className="sr-only">
|
||||||
|
Completion Document — WO #{dialog.info.woNumber}
|
||||||
|
</DialogTitle>
|
||||||
<Text className="sr-only" variant="description">
|
<Text className="sr-only" variant="description">
|
||||||
Generate, save or mark not-required the completion document for this work order.
|
Generate, save or mark not-required the completion document for this work order.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<CompDocDialogHeader row={row} cfg={cfg} onClose={() => onOpenChange(false)} />
|
<CompDocDialogHeader
|
||||||
|
info={dialog.info}
|
||||||
|
cfg={dialog.cfg}
|
||||||
|
isUploading={dialog.isUploading}
|
||||||
|
onClose={() => handleClose?.()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CompDocDialogResourceLinks
|
||||||
|
templateUrl={dialog.templateUrl}
|
||||||
|
signOffAttachment={dialog.signOffAttachment}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-row">
|
<div className="flex min-h-0 flex-1 flex-row">
|
||||||
<CompDocPreview
|
<CompDocPreview
|
||||||
row={row}
|
row={dialog.info}
|
||||||
site={site}
|
site={dialog.site}
|
||||||
notes={notes}
|
notes={dialog.notes}
|
||||||
generatedAt={generatedAt}
|
generatedAt={dialog.generatedAt}
|
||||||
isGenerated={isGenerated}
|
isGenerated={dialog.isGenerated}
|
||||||
isNotRequired={isNotRequired}
|
isNotRequired={dialog.isNotRequired}
|
||||||
/>
|
/>
|
||||||
<CompDocSidebar
|
<CompDocSidebar
|
||||||
row={row}
|
row={dialog.info}
|
||||||
site={site}
|
site={dialog.site}
|
||||||
docState={docState}
|
docState={dialog.docState}
|
||||||
generatedAt={generatedAt}
|
generatedAt={dialog.generatedAt}
|
||||||
notes={notes}
|
notes={dialog.notes}
|
||||||
editAddress={editAddress}
|
editDateCompleted={dialog.editDateCompleted}
|
||||||
onAddressChange={setEditAddress}
|
amazonRep={dialog.amazonRep}
|
||||||
editDateCompleted={editDateCompleted}
|
seaRep={dialog.seaRep}
|
||||||
amazonRep={amazonRep}
|
isGenerated={dialog.isGenerated}
|
||||||
seaRep={seaRep}
|
onNotesChange={dialog.setNotes}
|
||||||
isGenerated={isGenerated}
|
onDateCompletedChange={dialog.setEditDateCompleted}
|
||||||
onNotesChange={setNotes}
|
|
||||||
onDateCompletedChange={setEditDateCompleted}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CompDocDialogFooter
|
<CompDocDialogFooter
|
||||||
docState={docState}
|
docState={dialog.docState}
|
||||||
generatedAt={generatedAt}
|
isDocumentsReadOnly={dialog.isDocumentsReadOnly}
|
||||||
onMarkNotRequired={handleMarkNotRequired}
|
rowVersion={dialog.rowVersion}
|
||||||
onUndo={handleUndo}
|
isUploading={dialog.isUploading}
|
||||||
onSaveDraft={handleSaveDraft}
|
signOffAttachment={dialog.signOffAttachment}
|
||||||
onGenerate={handleGenerate}
|
generatedAt={dialog.generatedAt}
|
||||||
onDownloadPdf={handleDownloadPdf}
|
pdfInputRef={dialog.pdfInputRef}
|
||||||
|
onMarkNotRequired={dialog.handleMarkNotRequired}
|
||||||
|
onSaveDraft={dialog.handleSaveDraft}
|
||||||
|
onGenerate={dialog.handleGenerate}
|
||||||
|
onUndo={dialog.handleUndo}
|
||||||
|
onDownloadPdf={dialog.handleDownloadPdf}
|
||||||
|
onUploadPdf={dialog.handleUploadPdf}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
const COMP_DOC_INPUT_CLS = "w-full rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-1";
|
||||||
|
|
||||||
|
const COMP_DOC_INPUT_STYLE: CSSProperties = {
|
||||||
|
fontSize: 13,
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
};
|
||||||
|
|
||||||
|
type CompDocEditRowProps = {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
type?: string;
|
||||||
|
readOnly?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocEditRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
type = "text",
|
||||||
|
readOnly = false,
|
||||||
|
}: CompDocEditRowProps) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: "8px 0", borderBottom: "1px solid var(--color-border)" }}>
|
||||||
|
<div style={{ fontSize: 11.5, color: "var(--muted-foreground)", marginBottom: 4 }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
readOnly={readOnly}
|
||||||
|
className={COMP_DOC_INPUT_CLS}
|
||||||
|
style={{
|
||||||
|
...COMP_DOC_INPUT_STYLE,
|
||||||
|
...(readOnly
|
||||||
|
? {
|
||||||
|
opacity: 0.6,
|
||||||
|
cursor: "default",
|
||||||
|
pointerEvents: "none" as const,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
export const COMP_DOC_FOOTER_BTN_SX = {
|
||||||
|
minHeight: 28,
|
||||||
|
height: 28,
|
||||||
|
fontSize: 12,
|
||||||
|
px: 1.25,
|
||||||
|
py: 0.5,
|
||||||
|
gap: 0.75,
|
||||||
|
} as const;
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { Building2, Mail, MapPin, Phone, Printer } from "lucide-react";
|
||||||
|
import { SEAHAVEN_HEADER } from "@/domain/work-orders/utils/completion-doc-html";
|
||||||
|
import { CompDocCoLine } from "./comp-doc-co-line";
|
||||||
|
|
||||||
|
export function CompDocPreviewCompanyHeader() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="flex flex-wrap items-start justify-between gap-y-3"
|
||||||
|
style={{ padding: "20px 0 12px" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<img
|
||||||
|
src="/seahaven-logo.svg"
|
||||||
|
alt="Seahaven Industries"
|
||||||
|
style={{ height: 34, width: "auto", display: "block" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<CompDocCoLine icon={Building2} text={SEAHAVEN_HEADER.name} bold />
|
||||||
|
<CompDocCoLine icon={MapPin} text={SEAHAVEN_HEADER.address} />
|
||||||
|
<CompDocCoLine icon={Phone} text={SEAHAVEN_HEADER.phone} />
|
||||||
|
<CompDocCoLine icon={Printer} text={SEAHAVEN_HEADER.fax} />
|
||||||
|
<CompDocCoLine icon={Mail} text={SEAHAVEN_HEADER.email} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 3,
|
||||||
|
borderRadius: 2,
|
||||||
|
background: "linear-gradient(90deg,#27AAE1,#1C75BC)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { CompDocPreviewCompanyHeader } from "./comp-doc-preview-company-header";
|
||||||
|
import { CompDocPreviewProcedureNotes } from "./comp-doc-preview-procedure-notes";
|
||||||
|
import { CompDocPreviewServiceInfo } from "./comp-doc-preview-service-info";
|
||||||
|
import { CompDocPreviewVendorSafety } from "./comp-doc-preview-vendor-safety";
|
||||||
|
|
||||||
|
type CompDocPreviewDocumentProps = {
|
||||||
|
row: WorkOrderTableRow;
|
||||||
|
site: CompDocSiteInfo;
|
||||||
|
notes: string;
|
||||||
|
generatedAt: string;
|
||||||
|
isGenerated: boolean;
|
||||||
|
isNotRequired: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocPreviewDocument({
|
||||||
|
row,
|
||||||
|
site,
|
||||||
|
notes,
|
||||||
|
generatedAt,
|
||||||
|
isGenerated,
|
||||||
|
isNotRequired,
|
||||||
|
}: CompDocPreviewDocumentProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
opacity: isNotRequired ? 0.5 : 1,
|
||||||
|
filter: isNotRequired ? "grayscale(0.4)" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 12,
|
||||||
|
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ padding: "0 28px 28px" }}>
|
||||||
|
<CompDocPreviewCompanyHeader />
|
||||||
|
<CompDocPreviewServiceInfo
|
||||||
|
row={row}
|
||||||
|
site={site}
|
||||||
|
isGenerated={isGenerated}
|
||||||
|
generatedAt={generatedAt}
|
||||||
|
/>
|
||||||
|
<CompDocPreviewVendorSafety row={row} />
|
||||||
|
<CompDocPreviewProcedureNotes pm={row.pm} notes={notes} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 24,
|
||||||
|
background: "linear-gradient(100deg,#1C75BC,#27AAE1)",
|
||||||
|
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { Ban } from "lucide-react";
|
||||||
|
|
||||||
|
export function CompDocPreviewNotRequiredStamp() {
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-start justify-center">
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center gap-2"
|
||||||
|
style={{
|
||||||
|
marginTop: "30%",
|
||||||
|
transform: "rotate(-15deg)",
|
||||||
|
border: "3px solid var(--warning)",
|
||||||
|
color: "var(--warning)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "8px 18px",
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: 22,
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
background: "rgba(255,255,255,0.65)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ban size={20} /> NOT REQUIRED
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { procedureFor } from "@/domain/work-orders/utils/completion-doc-procedures";
|
||||||
|
import { CompDocProcedureList } from "./comp-doc-procedure-list";
|
||||||
|
|
||||||
|
type CompDocPreviewProcedureNotesProps = {
|
||||||
|
pm: string;
|
||||||
|
notes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocPreviewProcedureNotes({ pm, notes }: CompDocPreviewProcedureNotesProps) {
|
||||||
|
const proc = procedureFor(pm);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text
|
||||||
|
as="h3"
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#161E38",
|
||||||
|
margin: "16px 0 4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{proc.heading}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
style={{
|
||||||
|
fontSize: 12.5,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: "#58595B",
|
||||||
|
margin: "0 0 8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{proc.intro}
|
||||||
|
</Text>
|
||||||
|
<CompDocProcedureList proc={proc} />
|
||||||
|
|
||||||
|
{Boolean(notes) && (
|
||||||
|
<>
|
||||||
|
<Text
|
||||||
|
as="h3"
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#161E38",
|
||||||
|
margin: "16px 0 4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
PM Comments
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
style={{
|
||||||
|
fontSize: 12.5,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: "#161E38",
|
||||||
|
margin: 0,
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{notes}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
import { CheckCircle2 } from "lucide-react";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
|
||||||
|
|
||||||
|
type CompDocPreviewServiceInfoProps = {
|
||||||
|
row: WorkOrderTableRow;
|
||||||
|
site: CompDocSiteInfo;
|
||||||
|
isGenerated: boolean;
|
||||||
|
generatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocPreviewServiceInfo({
|
||||||
|
row,
|
||||||
|
site,
|
||||||
|
isGenerated,
|
||||||
|
generatedAt,
|
||||||
|
}: CompDocPreviewServiceInfoProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isGenerated && (
|
||||||
|
<div
|
||||||
|
className="inline-flex items-center gap-1.5"
|
||||||
|
style={{
|
||||||
|
marginTop: 16,
|
||||||
|
padding: "4px 10px",
|
||||||
|
borderRadius: 9999,
|
||||||
|
background: "var(--doc-yes-bg)",
|
||||||
|
color: "var(--color-success)",
|
||||||
|
fontSize: 11.5,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={13} /> Generated · {generatedAt} · v1
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text
|
||||||
|
as="h2"
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.02em",
|
||||||
|
color: "#161E38",
|
||||||
|
margin: "16px 0 4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{serviceTitle(row).toUpperCase()}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
className="font-mono"
|
||||||
|
style={{
|
||||||
|
fontSize: 12.5,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#1C75BC",
|
||||||
|
margin: "2px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
WO# {row.woNumber}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 14 }}>
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "#161E38",
|
||||||
|
margin: "2px 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Site ID — {row.site}
|
||||||
|
</Text>
|
||||||
|
<Text as="p" style={{ fontSize: 13, color: "#161E38", margin: "2px 0" }}>
|
||||||
|
Amazon.com Services LLC {row.site}
|
||||||
|
</Text>
|
||||||
|
<Text as="p" style={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>
|
||||||
|
{site.street},
|
||||||
|
</Text>
|
||||||
|
<Text as="p" style={{ fontSize: 13, color: "#58595B", margin: "2px 0" }}>
|
||||||
|
{site.label}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { CheckCircle2, FileText } from "lucide-react";
|
||||||
|
|
||||||
|
type CompDocPreviewStatusBadgeProps = {
|
||||||
|
isGenerated: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocPreviewStatusBadge({ isGenerated }: CompDocPreviewStatusBadgeProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex shrink-0 items-center justify-center" style={{ padding: "12px 0" }}>
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5"
|
||||||
|
style={{
|
||||||
|
height: 28,
|
||||||
|
padding: "0 12px",
|
||||||
|
borderRadius: 9999,
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isGenerated ? (
|
||||||
|
<CheckCircle2 size={13} style={{ color: "var(--color-success)" }} />
|
||||||
|
) : (
|
||||||
|
<FileText size={13} />
|
||||||
|
)}
|
||||||
|
{isGenerated ? "Generated document" : "Preview — not yet generated"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
|
||||||
|
|
||||||
|
type CompDocPreviewVendorSafetyProps = {
|
||||||
|
row: WorkOrderTableRow;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocPreviewVendorSafety({ row }: CompDocPreviewVendorSafetyProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text as="p" style={{ fontSize: 13, color: "#161E38", margin: "14px 0 6px" }}>
|
||||||
|
<span style={{ fontWeight: 700 }}>Vendor / Technician: </span>
|
||||||
|
{row.tech || "—"}
|
||||||
|
{row.company ? ` · ${row.company}` : ""}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<ul style={{ listStyle: "disc", paddingLeft: 18, margin: "8px 0 0" }}>
|
||||||
|
{SAFETY_BULLETS.map((b, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
fontSize: 12.5,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
margin: "5px 0",
|
||||||
|
color: "#161E38",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{b}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
|
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
|
||||||
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 { CompDocNotRequiredStamp } from "./comp-doc-not-required-stamp";
|
import { CompDocPreviewDocument } from "./comp-doc-preview-document";
|
||||||
import { CompDocPreviewBadge } from "./comp-doc-preview-badge";
|
import { CompDocPreviewNotRequiredStamp } from "./comp-doc-preview-not-required-stamp";
|
||||||
import { CompDocPreviewHeader } from "./comp-doc-preview-header";
|
import { CompDocPreviewStatusBadge } from "./comp-doc-preview-status-badge";
|
||||||
import { CompDocPreviewProcedure } from "./comp-doc-preview-procedure";
|
|
||||||
import { CompDocPreviewSummary } from "./comp-doc-preview-summary";
|
|
||||||
|
|
||||||
type CompDocPreviewProps = {
|
type CompDocPreviewProps = {
|
||||||
row: WorkOrderTableRow;
|
row: WorkOrderTableRow;
|
||||||
|
|
@ -32,7 +30,7 @@ export function CompDocPreview({
|
||||||
background: "var(--color-bg-muted)",
|
background: "var(--color-bg-muted)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CompDocPreviewBadge isGenerated={isGenerated} />
|
<CompDocPreviewStatusBadge isGenerated={isGenerated} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
|
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
|
||||||
|
|
@ -50,38 +48,16 @@ export function CompDocPreview({
|
||||||
wordBreak: "break-word",
|
wordBreak: "break-word",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<CompDocPreviewDocument
|
||||||
style={{
|
row={row}
|
||||||
opacity: isNotRequired ? 0.5 : 1,
|
site={site}
|
||||||
filter: isNotRequired ? "grayscale(0.4)" : "none",
|
notes={notes}
|
||||||
}}
|
generatedAt={generatedAt}
|
||||||
>
|
isGenerated={isGenerated}
|
||||||
<div
|
isNotRequired={isNotRequired}
|
||||||
style={{
|
/>
|
||||||
height: 12,
|
|
||||||
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div style={{ padding: "0 28px 28px" }}>
|
|
||||||
<CompDocPreviewHeader />
|
|
||||||
<CompDocPreviewSummary
|
|
||||||
row={row}
|
|
||||||
site={site}
|
|
||||||
generatedAt={generatedAt}
|
|
||||||
isGenerated={isGenerated}
|
|
||||||
/>
|
|
||||||
<CompDocPreviewProcedure row={row} notes={notes} />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: 24,
|
|
||||||
background: "linear-gradient(100deg,#1C75BC,#27AAE1)",
|
|
||||||
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isNotRequired && <CompDocNotRequiredStamp />}
|
{isNotRequired && <CompDocPreviewNotRequiredStamp />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,29 +7,26 @@ type CompDocProcedureListProps = {
|
||||||
export function CompDocProcedureList({ proc }: CompDocProcedureListProps) {
|
export function CompDocProcedureList({ proc }: CompDocProcedureListProps) {
|
||||||
return (
|
return (
|
||||||
<ol style={{ listStyle: "decimal", paddingLeft: 22, margin: 0 }}>
|
<ol style={{ listStyle: "decimal", paddingLeft: 22, margin: 0 }}>
|
||||||
{proc.steps.map((st, i) => {
|
{proc.steps.map((st, i) => (
|
||||||
const subSteps = st.sub ?? [];
|
<li key={i} style={{ margin: "6px 0", fontSize: 13, lineHeight: 1.5 }}>
|
||||||
return (
|
{st.text}
|
||||||
<li key={i} style={{ margin: "6px 0", fontSize: 13, lineHeight: 1.5 }}>
|
{st.sub != null && (
|
||||||
{st.text}
|
<ol
|
||||||
{subSteps.length > 0 && (
|
style={{
|
||||||
<ol
|
listStyle: "lower-alpha",
|
||||||
style={{
|
paddingLeft: 22,
|
||||||
listStyle: "lower-alpha",
|
margin: "4px 0 0",
|
||||||
paddingLeft: 22,
|
}}
|
||||||
margin: "4px 0 0",
|
>
|
||||||
}}
|
{st.sub.map((x, j) => (
|
||||||
>
|
<li key={j} style={{ margin: "2px 0" }}>
|
||||||
{subSteps.map((x, j) => (
|
{x}
|
||||||
<li key={j} style={{ margin: "2px 0" }}>
|
</li>
|
||||||
{x}
|
))}
|
||||||
</li>
|
</ol>
|
||||||
))}
|
)}
|
||||||
</ol>
|
</li>
|
||||||
)}
|
))}
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ol>
|
</ol>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
|
import { Building2, MapPin, Users, Wrench } from "lucide-react";
|
||||||
import type { CompDocSiteInfo, DocState } from "@/domain/work-orders/types/completion-doc";
|
import type { CompDocSiteInfo, DocState } from "@/domain/work-orders/types/completion-doc";
|
||||||
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 { CompDocDetailsSection } from "./comp-doc-details-section";
|
import {
|
||||||
import { CompDocNotesField } from "./comp-doc-notes-field";
|
DOC_STATE_CFG,
|
||||||
import { CompDocStatusCard } from "./comp-doc-status-card";
|
docStatusCardDescription,
|
||||||
|
} from "@/domain/work-orders/utils/completion-doc-state";
|
||||||
|
import { CompDocDetailRow } from "./comp-doc-detail-row";
|
||||||
|
import { CompDocEditRow } from "./comp-doc-edit-row";
|
||||||
|
|
||||||
type CompDocSidebarProps = {
|
type CompDocSidebarProps = {
|
||||||
row: WorkOrderTableRow;
|
row: WorkOrderTableRow;
|
||||||
|
|
@ -10,8 +14,6 @@ type CompDocSidebarProps = {
|
||||||
docState: DocState;
|
docState: DocState;
|
||||||
generatedAt: string;
|
generatedAt: string;
|
||||||
notes: string;
|
notes: string;
|
||||||
editAddress: string;
|
|
||||||
onAddressChange: (value: string) => void;
|
|
||||||
editDateCompleted: string;
|
editDateCompleted: string;
|
||||||
amazonRep: string;
|
amazonRep: string;
|
||||||
seaRep: string;
|
seaRep: string;
|
||||||
|
|
@ -26,8 +28,6 @@ export function CompDocSidebar({
|
||||||
docState,
|
docState,
|
||||||
generatedAt,
|
generatedAt,
|
||||||
notes,
|
notes,
|
||||||
editAddress,
|
|
||||||
onAddressChange,
|
|
||||||
editDateCompleted,
|
editDateCompleted,
|
||||||
amazonRep,
|
amazonRep,
|
||||||
seaRep,
|
seaRep,
|
||||||
|
|
@ -35,25 +35,139 @@ export function CompDocSidebar({
|
||||||
onNotesChange,
|
onNotesChange,
|
||||||
onDateCompletedChange,
|
onDateCompletedChange,
|
||||||
}: CompDocSidebarProps) {
|
}: CompDocSidebarProps) {
|
||||||
|
const displayState: DocState =
|
||||||
|
isGenerated && docState !== "not-required" ? "generated" : docState;
|
||||||
|
const cfg = DOC_STATE_CFG[displayState];
|
||||||
|
const StateIcon = cfg.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex min-h-0 flex-col"
|
className="flex min-h-0 flex-col"
|
||||||
style={{ width: "42%", background: "var(--color-bg-surface)" }}
|
style={{ width: "42%", background: "var(--color-bg-surface)" }}
|
||||||
>
|
>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto" style={{ padding: 20 }}>
|
<div className="min-h-0 flex-1 overflow-y-auto" style={{ padding: 20 }}>
|
||||||
<CompDocStatusCard docState={docState} generatedAt={generatedAt} />
|
<div
|
||||||
<CompDocDetailsSection
|
className="flex items-start gap-3"
|
||||||
row={row}
|
style={{
|
||||||
site={site}
|
padding: 14,
|
||||||
editAddress={editAddress}
|
borderRadius: 10,
|
||||||
onAddressChange={onAddressChange}
|
background: cfg.cardBg,
|
||||||
editDateCompleted={editDateCompleted}
|
border: `1px solid ${cfg.cardBorder}`,
|
||||||
onDateCompletedChange={onDateCompletedChange}
|
}}
|
||||||
amazonRep={amazonRep}
|
>
|
||||||
seaRep={seaRep}
|
<div
|
||||||
isGenerated={isGenerated}
|
className="flex shrink-0 items-center justify-center"
|
||||||
/>
|
style={{
|
||||||
<CompDocNotesField notes={notes} onNotesChange={onNotesChange} isGenerated={isGenerated} />
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: 8,
|
||||||
|
background: cfg.cardIconBg,
|
||||||
|
border: `1px solid ${cfg.cardBorder}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<StateIcon size={17} style={{ color: cfg.iconColor }} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 13.5,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cfg.cardTitle}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontSize: 12.5,
|
||||||
|
lineHeight: 1.45,
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
marginTop: 3,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{docStatusCardDescription(displayState, generatedAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 20 }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 600,
|
||||||
|
letterSpacing: "0.1em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Work Order Details
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
<CompDocDetailRow icon={Building2} label="WO #" value={`#${row.woNumber}`} />
|
||||||
|
<CompDocDetailRow
|
||||||
|
icon={MapPin}
|
||||||
|
label="Site Code"
|
||||||
|
value={`${row.site} · Amazon.com Services LLC`}
|
||||||
|
/>
|
||||||
|
<CompDocDetailRow icon={MapPin} label="Address" value={site.fullAddress} />
|
||||||
|
<CompDocEditRow
|
||||||
|
label="Date Work Completed"
|
||||||
|
value={editDateCompleted}
|
||||||
|
onChange={onDateCompletedChange}
|
||||||
|
type="date"
|
||||||
|
readOnly={isGenerated}
|
||||||
|
/>
|
||||||
|
<CompDocDetailRow icon={Users} label="Amazon Representative" value={amazonRep || "—"} />
|
||||||
|
<CompDocDetailRow
|
||||||
|
icon={Wrench}
|
||||||
|
label="Seahaven Representative"
|
||||||
|
value={seaRep || "—"}
|
||||||
|
last
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 18 }}>
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
PM Comments{" "}
|
||||||
|
<span style={{ fontWeight: 400, color: "var(--muted-foreground)" }}>
|
||||||
|
— optional, included in the document
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => onNotesChange(e.target.value)}
|
||||||
|
readOnly={isGenerated}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Add findings, parts used, access notes, or anything the site POC should see…"
|
||||||
|
className="mt-2 w-full resize-none"
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: "10px 12px",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
outline: "none",
|
||||||
|
...(isGenerated
|
||||||
|
? {
|
||||||
|
opacity: 0.6,
|
||||||
|
cursor: "default",
|
||||||
|
pointerEvents: "none" as const,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { useEffect, useRef, type Dispatch, type SetStateAction } from "react";
|
||||||
|
import type { CompDocData, DocState } from "@/domain/work-orders/types/completion-doc";
|
||||||
|
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { docStateOf } from "@/domain/work-orders/utils/completion-doc-state";
|
||||||
|
|
||||||
|
type UseCompDocDialogSeedArgs = {
|
||||||
|
open: boolean;
|
||||||
|
infoId: string | number;
|
||||||
|
infoDocStatus: CompletionDocStatus;
|
||||||
|
infoCompletedDate?: string | null;
|
||||||
|
completionDocStatus?: CompletionDocStatus | null;
|
||||||
|
data?: CompDocData;
|
||||||
|
setNotes: Dispatch<SetStateAction<string>>;
|
||||||
|
setDocState: Dispatch<SetStateAction<DocState>>;
|
||||||
|
setGeneratedAt: Dispatch<SetStateAction<string>>;
|
||||||
|
setEditDateCompleted: Dispatch<SetStateAction<string>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Prototype seeds once per open/WO session; detail refetches must not wipe edits. */
|
||||||
|
export function useCompDocDialogSeed({
|
||||||
|
open,
|
||||||
|
infoId,
|
||||||
|
infoDocStatus,
|
||||||
|
infoCompletedDate,
|
||||||
|
completionDocStatus,
|
||||||
|
data,
|
||||||
|
setNotes,
|
||||||
|
setDocState,
|
||||||
|
setGeneratedAt,
|
||||||
|
setEditDateCompleted,
|
||||||
|
}: UseCompDocDialogSeedArgs) {
|
||||||
|
const seedKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
seedKeyRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = String(infoId);
|
||||||
|
if (seedKeyRef.current === key) return;
|
||||||
|
seedKeyRef.current = key;
|
||||||
|
setNotes(data?.notes ?? "");
|
||||||
|
setDocState(docStateOf(completionDocStatus || infoDocStatus));
|
||||||
|
setGeneratedAt(data?.generatedAt ?? "");
|
||||||
|
setEditDateCompleted(infoCompletedDate || "");
|
||||||
|
}, [
|
||||||
|
open,
|
||||||
|
infoId,
|
||||||
|
infoDocStatus,
|
||||||
|
infoCompletedDate,
|
||||||
|
completionDocStatus,
|
||||||
|
data?.notes,
|
||||||
|
data?.generatedAt,
|
||||||
|
setNotes,
|
||||||
|
setDocState,
|
||||||
|
setGeneratedAt,
|
||||||
|
setEditDateCompleted,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
import { useMemo, useRef, useState } from "react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||||
|
import type {
|
||||||
|
CompDocData,
|
||||||
|
CompDocSaveStatus,
|
||||||
|
DocFields,
|
||||||
|
DocState,
|
||||||
|
} from "@/domain/work-orders/types/completion-doc";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
|
import { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||||
|
import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site";
|
||||||
|
import { DOC_STATE_CFG } from "@/domain/work-orders/utils/completion-doc-state";
|
||||||
|
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||||
|
import { useCompDocDialogSeed } from "./use-comp-doc-dialog-seed";
|
||||||
|
import { useCompDocNotRequiredActions } from "./use-comp-doc-not-required-actions";
|
||||||
|
import { useCompDocPdfActions } from "./use-comp-doc-pdf-actions";
|
||||||
|
import { useCompDocPersistFlow } from "./use-comp-doc-persist-flow";
|
||||||
|
|
||||||
|
type UseCompDocDialogArgs = {
|
||||||
|
row: WorkOrderTableRow;
|
||||||
|
open: boolean;
|
||||||
|
data?: CompDocData;
|
||||||
|
onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
|
||||||
|
onDateCompletedChange?: (date: string) => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function notifyDraftSaved() {
|
||||||
|
toast(
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
style={{
|
||||||
|
background: "var(--foreground)",
|
||||||
|
color: "#fff",
|
||||||
|
padding: "10px 18px",
|
||||||
|
borderRadius: 9999,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
boxShadow: "0 10px 30px -10px rgba(0,0,0,.45)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Draft saved
|
||||||
|
</div>,
|
||||||
|
{
|
||||||
|
position: "bottom-center",
|
||||||
|
autoClose: 3000,
|
||||||
|
hideProgressBar: true,
|
||||||
|
closeButton: false,
|
||||||
|
icon: false,
|
||||||
|
className: "!min-h-0 !bg-transparent !p-0 !shadow-none",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCompDocDialog({
|
||||||
|
row,
|
||||||
|
open,
|
||||||
|
data,
|
||||||
|
onSave,
|
||||||
|
onDateCompletedChange,
|
||||||
|
}: UseCompDocDialogArgs) {
|
||||||
|
const locId = String(row.locationId || "");
|
||||||
|
const { data: locationDetail } = useLocationDetail(locId || undefined);
|
||||||
|
const { data: boardDetail } = useWorkOrderBoardDetail(row.id, open);
|
||||||
|
const uploadCompletionMutation = useUploadCompletionDoc(row.id);
|
||||||
|
const pdfInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [isFinalizing, setIsFinalizing] = useState(false);
|
||||||
|
|
||||||
|
const info = boardDetail?.info ?? row;
|
||||||
|
const completion = boardDetail?.completion;
|
||||||
|
const templateUrl = completion?.template?.templateUrl;
|
||||||
|
const signOffAttachment = completion?.signOffAttachment;
|
||||||
|
const rowVersion = info.rowVersion || row.rowVersion;
|
||||||
|
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(info.status);
|
||||||
|
const site = useMemo(() => resolveCompDocSite(info, locationDetail), [info, locationDetail]);
|
||||||
|
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [docState, setDocState] = useState<DocState>("draft");
|
||||||
|
const [generatedAt, setGeneratedAt] = useState("");
|
||||||
|
const [editDateCompleted, setEditDateCompleted] = useState("");
|
||||||
|
|
||||||
|
useCompDocDialogSeed({
|
||||||
|
open,
|
||||||
|
infoId: info.id,
|
||||||
|
infoDocStatus: info.docStatus,
|
||||||
|
infoCompletedDate: info.completedDate,
|
||||||
|
completionDocStatus: completion?.docStatus,
|
||||||
|
data,
|
||||||
|
setNotes,
|
||||||
|
setDocState,
|
||||||
|
setGeneratedAt,
|
||||||
|
setEditDateCompleted,
|
||||||
|
});
|
||||||
|
|
||||||
|
const amazonRep = info.pocName || "";
|
||||||
|
const seaRep = info.tech || "";
|
||||||
|
const cfg = DOC_STATE_CFG[docState];
|
||||||
|
const isNotRequired = docState === "not-required";
|
||||||
|
const isGenerated = docState === "generated" || Boolean(signOffAttachment);
|
||||||
|
|
||||||
|
const docFields: DocFields = {
|
||||||
|
street: site.street,
|
||||||
|
city: site.city,
|
||||||
|
state: site.state,
|
||||||
|
zip: site.zip,
|
||||||
|
dateCompleted: editDateCompleted,
|
||||||
|
amazonRep,
|
||||||
|
seaRep,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { persist, syncDateIfChanged, refreshAuthoritativeState, handleSaveDraft, handleGenerate } =
|
||||||
|
useCompDocPersistFlow({
|
||||||
|
workOrderId: row.id,
|
||||||
|
notes,
|
||||||
|
generatedAt,
|
||||||
|
editDateCompleted,
|
||||||
|
currentCompletedDate: info.completedDate || "",
|
||||||
|
onSave,
|
||||||
|
onDateCompletedChange,
|
||||||
|
onGenerated: (ts) => {
|
||||||
|
setGeneratedAt(ts);
|
||||||
|
setDocState("generated");
|
||||||
|
},
|
||||||
|
onDraftSaved: notifyDraftSaved,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleMarkNotRequired, handleUndo } = useCompDocNotRequiredActions({
|
||||||
|
docState,
|
||||||
|
setDocState,
|
||||||
|
persist,
|
||||||
|
refreshAuthoritativeState,
|
||||||
|
setIsFinalizing,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { handleDownloadPdf, handleUploadPdf } = useCompDocPdfActions({
|
||||||
|
info,
|
||||||
|
signOffAttachment,
|
||||||
|
rowVersion,
|
||||||
|
notes,
|
||||||
|
generatedAt,
|
||||||
|
docFields,
|
||||||
|
uploadCompletionMutation,
|
||||||
|
persist,
|
||||||
|
syncDateIfChanged,
|
||||||
|
setGeneratedAt,
|
||||||
|
markGenerated: () => setDocState("generated"),
|
||||||
|
onPersistFailure: refreshAuthoritativeState,
|
||||||
|
onFinalizingChange: setIsFinalizing,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
info,
|
||||||
|
templateUrl,
|
||||||
|
signOffAttachment,
|
||||||
|
rowVersion,
|
||||||
|
isDocumentsReadOnly,
|
||||||
|
site,
|
||||||
|
notes,
|
||||||
|
setNotes,
|
||||||
|
docState,
|
||||||
|
generatedAt,
|
||||||
|
editDateCompleted,
|
||||||
|
setEditDateCompleted,
|
||||||
|
amazonRep,
|
||||||
|
seaRep,
|
||||||
|
cfg,
|
||||||
|
isNotRequired,
|
||||||
|
isGenerated,
|
||||||
|
isUploading: uploadCompletionMutation.isPending || isFinalizing,
|
||||||
|
pdfInputRef,
|
||||||
|
handleMarkNotRequired,
|
||||||
|
handleUndo,
|
||||||
|
handleSaveDraft,
|
||||||
|
handleGenerate,
|
||||||
|
handleDownloadPdf,
|
||||||
|
handleUploadPdf,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import type {
|
||||||
|
CompDocData,
|
||||||
|
CompDocSaveStatus,
|
||||||
|
DocState,
|
||||||
|
} from "@/domain/work-orders/types/completion-doc";
|
||||||
|
|
||||||
|
type PersistFn = (data: Partial<CompDocData>, status: CompDocSaveStatus) => void | Promise<void>;
|
||||||
|
|
||||||
|
type UseCompDocNotRequiredActionsArgs = {
|
||||||
|
docState: DocState;
|
||||||
|
setDocState: (state: DocState) => void;
|
||||||
|
persist: PersistFn;
|
||||||
|
refreshAuthoritativeState: () => void;
|
||||||
|
setIsFinalizing: (value: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCompDocNotRequiredActions({
|
||||||
|
docState,
|
||||||
|
setDocState,
|
||||||
|
persist,
|
||||||
|
refreshAuthoritativeState,
|
||||||
|
setIsFinalizing,
|
||||||
|
}: UseCompDocNotRequiredActionsArgs) {
|
||||||
|
async function handleMarkNotRequired() {
|
||||||
|
const previous = docState;
|
||||||
|
setDocState("not-required");
|
||||||
|
setIsFinalizing(true);
|
||||||
|
try {
|
||||||
|
await persist({}, "NN");
|
||||||
|
} catch (error) {
|
||||||
|
setDocState(previous);
|
||||||
|
refreshAuthoritativeState();
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Failed to mark document as not required",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsFinalizing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUndo() {
|
||||||
|
const previous = docState;
|
||||||
|
setDocState("draft");
|
||||||
|
setIsFinalizing(true);
|
||||||
|
try {
|
||||||
|
await persist({}, "No");
|
||||||
|
} catch (error) {
|
||||||
|
setDocState(previous);
|
||||||
|
refreshAuthoritativeState();
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to undo not-required");
|
||||||
|
} finally {
|
||||||
|
setIsFinalizing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { handleMarkNotRequired, handleUndo };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import type {
|
||||||
|
CompDocData,
|
||||||
|
CompDocSaveStatus,
|
||||||
|
DocFields,
|
||||||
|
} from "@/domain/work-orders/types/completion-doc";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import type { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||||
|
import { buildDocHtml, printDoc } from "@/domain/work-orders/utils/completion-doc-html";
|
||||||
|
import { fmtDocNow } from "@/domain/work-orders/utils/completion-doc-state";
|
||||||
|
import {
|
||||||
|
formatMediaSizeLimit,
|
||||||
|
isWorkOrderFileTooLarge,
|
||||||
|
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||||
|
|
||||||
|
type UseCompDocPdfActionsArgs = {
|
||||||
|
info: WorkOrderTableRow;
|
||||||
|
signOffAttachment?: string | null;
|
||||||
|
rowVersion?: string;
|
||||||
|
notes: string;
|
||||||
|
generatedAt: string;
|
||||||
|
docFields: DocFields;
|
||||||
|
uploadCompletionMutation: ReturnType<typeof useUploadCompletionDoc>;
|
||||||
|
persist: (extra: Partial<CompDocData>, status: CompDocSaveStatus) => Promise<void>;
|
||||||
|
syncDateIfChanged: () => Promise<void>;
|
||||||
|
setGeneratedAt: (value: string) => void;
|
||||||
|
markGenerated: () => void;
|
||||||
|
onPersistFailure?: () => void;
|
||||||
|
onFinalizingChange?: (finalizing: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCompDocPdfActions({
|
||||||
|
info,
|
||||||
|
signOffAttachment,
|
||||||
|
rowVersion,
|
||||||
|
notes,
|
||||||
|
generatedAt,
|
||||||
|
docFields,
|
||||||
|
uploadCompletionMutation,
|
||||||
|
persist,
|
||||||
|
syncDateIfChanged,
|
||||||
|
setGeneratedAt,
|
||||||
|
markGenerated,
|
||||||
|
onPersistFailure,
|
||||||
|
onFinalizingChange,
|
||||||
|
}: UseCompDocPdfActionsArgs) {
|
||||||
|
function handleDownloadPdf() {
|
||||||
|
if (signOffAttachment) {
|
||||||
|
window.open(signOffAttachment, "_blank", "noopener,noreferrer");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
printDoc(buildDocHtml(info, { notes, generatedAt }, docFields));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUploadPdf(files: FileList | null) {
|
||||||
|
const file = files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (isWorkOrderFileTooLarge(file)) {
|
||||||
|
toast.error(`File exceeds the ${formatMediaSizeLimit()} limit.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
|
||||||
|
toast.error("Please upload a PDF file.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!rowVersion) {
|
||||||
|
toast.error("Missing work order version. Refresh and try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploadCompletionMutation.mutate(
|
||||||
|
{ file, workOrderVersion: rowVersion },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
// Keep close disabled across the gap between upload settle and docStatus persist.
|
||||||
|
onFinalizingChange?.(true);
|
||||||
|
void (async () => {
|
||||||
|
const ts = fmtDocNow();
|
||||||
|
try {
|
||||||
|
await syncDateIfChanged();
|
||||||
|
await persist({ generatedAt: ts }, "Yes");
|
||||||
|
setGeneratedAt(ts);
|
||||||
|
markGenerated();
|
||||||
|
} catch (error) {
|
||||||
|
onPersistFailure?.();
|
||||||
|
toast.error(
|
||||||
|
error instanceof Error ? error.message : "Failed to finalize completion document",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
onFinalizingChange?.(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { handleDownloadPdf, handleUploadPdf };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
|
||||||
|
import { fmtDocNow } from "@/domain/work-orders/utils/completion-doc-state";
|
||||||
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
|
type UseCompDocPersistFlowArgs = {
|
||||||
|
workOrderId: string | number;
|
||||||
|
notes: string;
|
||||||
|
generatedAt: string;
|
||||||
|
editDateCompleted: string;
|
||||||
|
currentCompletedDate: string;
|
||||||
|
onSave: (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
|
||||||
|
onDateCompletedChange?: (date: string) => void | Promise<void>;
|
||||||
|
onGenerated: (generatedAt: string) => void;
|
||||||
|
onDraftSaved: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCompDocPersistFlow({
|
||||||
|
workOrderId,
|
||||||
|
notes,
|
||||||
|
generatedAt,
|
||||||
|
editDateCompleted,
|
||||||
|
currentCompletedDate,
|
||||||
|
onSave,
|
||||||
|
onDateCompletedChange,
|
||||||
|
onGenerated,
|
||||||
|
onDraftSaved,
|
||||||
|
}: UseCompDocPersistFlowArgs) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const refreshAuthoritativeState = () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all });
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.workOrders.boardDetail(workOrderId),
|
||||||
|
});
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.workOrders.detail(workOrderId),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const persist = async (extra: Partial<CompDocData>, status: CompDocSaveStatus) => {
|
||||||
|
await onSave({ notes, generatedAt, ...extra }, status);
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncDateIfChanged = async () => {
|
||||||
|
if (editDateCompleted !== currentCompletedDate) {
|
||||||
|
await onDateCompletedChange?.(editDateCompleted);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handleSaveDraft() {
|
||||||
|
try {
|
||||||
|
// Prerequisites first; docStatus last.
|
||||||
|
await syncDateIfChanged();
|
||||||
|
await persist({}, "No");
|
||||||
|
onDraftSaved();
|
||||||
|
} catch (error) {
|
||||||
|
refreshAuthoritativeState();
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to save draft");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGenerate() {
|
||||||
|
const ts = fmtDocNow();
|
||||||
|
try {
|
||||||
|
// Prerequisites first; only mark Generated after completedDate persists.
|
||||||
|
await syncDateIfChanged();
|
||||||
|
await persist({ generatedAt: ts }, "Yes");
|
||||||
|
onGenerated(ts);
|
||||||
|
} catch (error) {
|
||||||
|
refreshAuthoritativeState();
|
||||||
|
toast.error(error instanceof Error ? error.message : "Failed to generate document");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
persist,
|
||||||
|
syncDateIfChanged,
|
||||||
|
refreshAuthoritativeState,
|
||||||
|
handleSaveDraft,
|
||||||
|
handleGenerate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Box, Typography } from "@mui/material";
|
||||||
|
import type { WorkOrderAuditEntry } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { formatDateTime } from "@/lib/time-utils";
|
||||||
|
|
||||||
|
type AuditTimelineProps = {
|
||||||
|
entries: WorkOrderAuditEntry[];
|
||||||
|
emptyMessage?: string;
|
||||||
|
maxHeightClass?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AuditTimeline({
|
||||||
|
entries,
|
||||||
|
emptyMessage = "No changes recorded.",
|
||||||
|
maxHeightClass = "max-h-72",
|
||||||
|
}: AuditTimelineProps) {
|
||||||
|
const sorted = useMemo(
|
||||||
|
() =>
|
||||||
|
[...entries].sort(
|
||||||
|
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
|
||||||
|
),
|
||||||
|
[entries],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!sorted.length) {
|
||||||
|
return <Typography color="text.secondary">{emptyMessage}</Typography>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className={`${maxHeightClass} space-y-3 overflow-y-auto`}>
|
||||||
|
{sorted.map((entry) => (
|
||||||
|
<Box key={String(entry.id)} className="rounded border border-border p-3">
|
||||||
|
<Box className="mb-1 flex justify-between gap-2">
|
||||||
|
<Typography variant="caption" className="font-semibold">
|
||||||
|
{entry.userName || "System"}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{entry.createdAt ? formatDateTime(entry.createdAt) : ""}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2">
|
||||||
|
{entry.action ? (
|
||||||
|
entry.action
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<strong>{entry.fieldName}</strong> {entry.oldValue} → {entry.newValue}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
{Boolean(entry.action && entry.fieldName) && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color="text.secondary"
|
||||||
|
className="mt-1"
|
||||||
|
sx={{ display: "block" }}
|
||||||
|
>
|
||||||
|
{entry.fieldName}
|
||||||
|
{entry.oldValue || entry.newValue
|
||||||
|
? `: ${entry.oldValue || "—"} → ${entry.newValue || "—"}`
|
||||||
|
: ""}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
import { Settings } from "lucide-react";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { WorkOrderAuditEntry, WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||||
|
import {
|
||||||
|
getAvatarColorFromName,
|
||||||
|
getDispatcherAvatarColor,
|
||||||
|
getInitialsFromName,
|
||||||
|
} from "@/domain/work-orders/utils/dispatcher-avatar-color";
|
||||||
|
import { formatBoardAuditAction } from "@/domain/work-orders/utils/format-board-audit-action";
|
||||||
|
|
||||||
|
function isSystemEntry(entry: WorkOrderAuditEntry): boolean {
|
||||||
|
if (entry.type === "system") return true;
|
||||||
|
if (entry.type === "manual") return false;
|
||||||
|
const name = entry.userName?.trim();
|
||||||
|
return !name || name.toLowerCase() === "system";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatClock(date: Date): string {
|
||||||
|
return date.toLocaleTimeString("en-US", {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual (image): `· 9:48 AM`
|
||||||
|
* System (image): `05/13 · 8:00 AM`
|
||||||
|
* Prefer API display strings that already include · / AM|PM.
|
||||||
|
*/
|
||||||
|
function formatAuditTime(value: string, isSystem: boolean): string {
|
||||||
|
if (!value) return "";
|
||||||
|
if (/[·•]/.test(value) || /\b(AM|PM)\b/i.test(value)) return value;
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
|
||||||
|
const timePart = formatClock(date);
|
||||||
|
if (isSystem) {
|
||||||
|
const datePart = date.toLocaleDateString("en-US", {
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
});
|
||||||
|
return `${datePart} · ${timePart}`;
|
||||||
|
}
|
||||||
|
return `· ${timePart}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SlideOverAuditListProps = {
|
||||||
|
entries: WorkOrderAuditEntry[];
|
||||||
|
users?: WorkOrderUser[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverAuditList({ entries, users = [] }: SlideOverAuditListProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
className="mb-3 text-[10px] font-medium uppercase"
|
||||||
|
style={{ letterSpacing: "0.06em", color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
Audit Log
|
||||||
|
</Text>
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
className="py-4 text-center text-[12px]"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
No changes recorded
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
entries.map((entry, index) => {
|
||||||
|
const isSys = isSystemEntry(entry);
|
||||||
|
const dispatcher = users.find(
|
||||||
|
(user) =>
|
||||||
|
entry.dispatcherId != null && String(user.id) === String(entry.dispatcherId),
|
||||||
|
);
|
||||||
|
const name =
|
||||||
|
dispatcher?.name ||
|
||||||
|
entry.userName?.trim() ||
|
||||||
|
(entry.dispatcherId ? String(entry.dispatcherId) : "Unknown");
|
||||||
|
const color = dispatcher
|
||||||
|
? getDispatcherAvatarColor(dispatcher)
|
||||||
|
: getAvatarColorFromName(name);
|
||||||
|
const initials = dispatcher
|
||||||
|
? getInitialsFromName(dispatcher.name)
|
||||||
|
: getInitialsFromName(name);
|
||||||
|
const action = formatBoardAuditAction(entry);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={String(entry.id) || index}
|
||||||
|
className="flex items-start gap-2.5"
|
||||||
|
style={
|
||||||
|
isSys
|
||||||
|
? {
|
||||||
|
background: "#F8FAFC",
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "10px",
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
background: "transparent",
|
||||||
|
padding: "10px 0",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isSys ? (
|
||||||
|
<div
|
||||||
|
className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded"
|
||||||
|
style={{ background: "#E2E8F0" }}
|
||||||
|
>
|
||||||
|
<Settings size={10} style={{ color: "#64748B" }} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[8px] font-bold"
|
||||||
|
style={
|
||||||
|
dispatcher || name !== "Unknown"
|
||||||
|
? { background: `${color}25`, color }
|
||||||
|
: {
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{isSys ? (
|
||||||
|
<Text as="p" className="text-[12px] leading-snug" style={{ color: "#374151" }}>
|
||||||
|
<span style={{ fontStyle: "italic", color: "#6B7280" }}>System</span>
|
||||||
|
<span style={{ color: "#6B7280" }}> — </span>
|
||||||
|
{action}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text as="p" className="text-[12px] leading-snug">
|
||||||
|
<span className="font-semibold" style={{ color: "#1E293B" }}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
<span style={{ color: "#D1D5DB" }}> · </span>
|
||||||
|
<span style={{ color: "#374151" }}>{action}</span>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
className="mt-0.5 text-[10px]"
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-mono)",
|
||||||
|
color: "#9CA3AF",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatAuditTime(entry.createdAt, isSys)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { AlertTriangle, Ban, Check, Pencil } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
type SlideOverFooterProps = {
|
||||||
|
isCancelled: boolean;
|
||||||
|
editing: boolean;
|
||||||
|
isDirty: boolean;
|
||||||
|
showUnsaved: boolean;
|
||||||
|
saving?: boolean;
|
||||||
|
onCancelWO: () => void;
|
||||||
|
onCancelEdit: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onDiscard: () => void;
|
||||||
|
onKeepEditing: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverFooter({
|
||||||
|
isCancelled,
|
||||||
|
editing,
|
||||||
|
isDirty,
|
||||||
|
showUnsaved,
|
||||||
|
saving,
|
||||||
|
onCancelWO,
|
||||||
|
onCancelEdit,
|
||||||
|
onClose,
|
||||||
|
onEdit,
|
||||||
|
onSave,
|
||||||
|
onDiscard,
|
||||||
|
onKeepEditing,
|
||||||
|
}: SlideOverFooterProps) {
|
||||||
|
if (showUnsaved) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-center justify-between gap-2 px-5 py-3"
|
||||||
|
style={{
|
||||||
|
borderTop: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="flex items-center gap-1.5 text-[12px]"
|
||||||
|
style={{ color: "var(--color-warning)" }}
|
||||||
|
>
|
||||||
|
<AlertTriangle size={13} />
|
||||||
|
You have unsaved changes.
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" className="text-[12px]" onClick={onDiscard}>
|
||||||
|
Discard
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
className="gap-1.5 text-[12px]"
|
||||||
|
onClick={onSave}
|
||||||
|
loading={saving}
|
||||||
|
>
|
||||||
|
<Check size={13} />
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" className="text-[12px]" onClick={onKeepEditing}>
|
||||||
|
Keep editing
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCancelled) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-center gap-2 px-4 py-2.5"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
borderTop: "1px solid var(--color-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Ban size={14} style={{ color: "var(--muted-foreground)" }} />
|
||||||
|
<Text as="p" className="text-[12px]" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
This work order has been cancelled and is read-only.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-center justify-between gap-3 px-5 py-3"
|
||||||
|
style={{
|
||||||
|
borderTop: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancelWO}
|
||||||
|
className="flex items-center gap-1.5 text-[12px] font-medium transition-opacity hover:opacity-80"
|
||||||
|
style={{ color: "var(--destructive)" }}
|
||||||
|
>
|
||||||
|
<AlertTriangle size={13} />
|
||||||
|
Cancel WO
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={editing ? onCancelEdit : onClose}
|
||||||
|
disabled={saving}
|
||||||
|
className="text-[12px] transition-colors"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
e.currentTarget.style.color = "var(--color-text-primary)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.color = "var(--color-text-muted)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{editing ? (
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
className="gap-1.5 text-[12px]"
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={!isDirty}
|
||||||
|
loading={saving}
|
||||||
|
>
|
||||||
|
<Check size={13} />
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="default" className="gap-1.5 text-[12px]" onClick={onEdit}>
|
||||||
|
<Pencil size={13} />
|
||||||
|
Edit WO
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { CircularProgress } from "@mui/material";
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
import { WoTableStatusLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-status-label";
|
||||||
|
import { WoTableTypeLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
|
type SlideOverHeaderProps = {
|
||||||
|
row: WorkOrderTableRow;
|
||||||
|
isCancelled: boolean;
|
||||||
|
isFetching?: boolean;
|
||||||
|
closeDisabled?: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverHeader({
|
||||||
|
row,
|
||||||
|
isCancelled,
|
||||||
|
isFetching = false,
|
||||||
|
closeDisabled = false,
|
||||||
|
onClose,
|
||||||
|
}: SlideOverHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-center justify-between gap-3 px-5 py-3.5"
|
||||||
|
style={{ borderBottom: "1px solid var(--color-border)" }}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||||
|
<Text
|
||||||
|
as="h2"
|
||||||
|
className="text-[15px] font-semibold leading-none"
|
||||||
|
style={{ color: "var(--color-text-primary)" }}
|
||||||
|
>
|
||||||
|
#{row.woNumber || "—"}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{isCancelled && (
|
||||||
|
<span
|
||||||
|
className="rounded-full px-2 py-0.5 text-[12px] font-medium"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancelled
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Boolean(row.site) && (
|
||||||
|
<span
|
||||||
|
className="rounded px-1.5 py-0.5 font-mono text-[11px]"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
color: "var(--color-text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.site}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{row.isPastDue ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 text-[11px] font-medium leading-none"
|
||||||
|
style={{
|
||||||
|
background: "var(--status-pastdue-bg)",
|
||||||
|
color: "var(--status-pastdue-text)",
|
||||||
|
borderRadius: 4,
|
||||||
|
padding: "3px 8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-[#BE123C]" />
|
||||||
|
Past Due
|
||||||
|
</span>
|
||||||
|
) : row.status ? (
|
||||||
|
<WoTableStatusLabel status={row.status} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{Boolean(row.type) && <WoTableTypeLabel type={row.type as WOType} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{isFetching && <CircularProgress size={14} aria-label="Refreshing details" />}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Close"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={closeDisabled}
|
||||||
|
className="shrink-0 rounded p-1.5 transition-colors disabled:opacity-40"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (!closeDisabled) {
|
||||||
|
e.currentTarget.style.background = "var(--color-bg-muted)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.background = "transparent";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type SlideOverInfoRowProps = {
|
||||||
|
label: string;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverInfoRow({ label, children }: SlideOverInfoRowProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 py-1.5"
|
||||||
|
style={{ borderBottom: "1px solid var(--color-bg-muted)" }}
|
||||||
|
>
|
||||||
|
<span className="w-28 shrink-0 text-[13px]" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1 text-[12.5px]" style={{ color: "var(--color-text-primary)" }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { SlideOverAuditList } from "@/app/(protected)/workorders/_components/detail/slide-over-audit-list";
|
||||||
|
import type { WorkOrderAuditEntry, WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
|
type SlideOverAuditTabProps = {
|
||||||
|
entries: WorkOrderAuditEntry[];
|
||||||
|
users?: WorkOrderUser[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverAuditTab({ entries, users }: SlideOverAuditTabProps) {
|
||||||
|
return <SlideOverAuditList entries={entries} users={users} />;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,265 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Pencil } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { WorkOrderComment } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { isOwnWorkOrderComment } from "@/domain/work-orders/utils/comment-ownership";
|
||||||
|
import {
|
||||||
|
getAvatarColorFromName,
|
||||||
|
getInitialsFromName,
|
||||||
|
} from "@/domain/work-orders/utils/dispatcher-avatar-color";
|
||||||
|
|
||||||
|
function formatCommentTime(value: string): string {
|
||||||
|
if (!value) return "";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const textareaStyle = {
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
type SlideOverCommentItemProps = {
|
||||||
|
comment: WorkOrderComment;
|
||||||
|
canEdit: boolean;
|
||||||
|
isEditing: boolean;
|
||||||
|
draftText: string;
|
||||||
|
isUpdatePending: boolean;
|
||||||
|
onStartEdit: () => void;
|
||||||
|
onDraftChange: (value: string) => void;
|
||||||
|
onCancelEdit: () => void;
|
||||||
|
onSaveEdit: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function SlideOverCommentItem({
|
||||||
|
comment,
|
||||||
|
canEdit,
|
||||||
|
isEditing,
|
||||||
|
draftText,
|
||||||
|
isUpdatePending,
|
||||||
|
onStartEdit,
|
||||||
|
onDraftChange,
|
||||||
|
onCancelEdit,
|
||||||
|
onSaveEdit,
|
||||||
|
}: SlideOverCommentItemProps) {
|
||||||
|
const author = comment.userName || comment.commenter || "Unknown";
|
||||||
|
const color = getAvatarColorFromName(author);
|
||||||
|
const initials = getInitialsFromName(author);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2.5 py-2.5">
|
||||||
|
<span
|
||||||
|
className="mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-[9px] font-semibold"
|
||||||
|
style={{ background: `${color}25`, color }}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center justify-between gap-1.5">
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="truncate text-[12px] font-semibold"
|
||||||
|
style={{ color: "var(--color-text-primary)" }}
|
||||||
|
>
|
||||||
|
{author}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-[10px]" style={{ color: "var(--color-text-muted)" }}>
|
||||||
|
{formatCommentTime(comment.createdDate)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{canEdit && !isEditing && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Edit comment"
|
||||||
|
aria-label="Edit comment"
|
||||||
|
onClick={onStartEdit}
|
||||||
|
className="shrink-0 rounded p-0.5 transition-opacity hover:opacity-70"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
<Pencil size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="mt-1.5 space-y-2">
|
||||||
|
<textarea
|
||||||
|
value={draftText}
|
||||||
|
onChange={(event) => onDraftChange(event.target.value)}
|
||||||
|
rows={3}
|
||||||
|
disabled={isUpdatePending}
|
||||||
|
className="w-full resize-none rounded-lg px-3 py-2 text-[12px] focus:outline-none focus:ring-1"
|
||||||
|
style={textareaStyle}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="default"
|
||||||
|
className="h-8 text-[12px]"
|
||||||
|
disabled={isUpdatePending}
|
||||||
|
onClick={onCancelEdit}
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="default"
|
||||||
|
className="h-8 text-[12px]"
|
||||||
|
disabled={!draftText.trim() || isUpdatePending}
|
||||||
|
loading={isUpdatePending}
|
||||||
|
onClick={onSaveEdit}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
className="mt-0.5 text-[12px] leading-snug"
|
||||||
|
style={{ color: "var(--color-text-secondary)" }}
|
||||||
|
>
|
||||||
|
{comment.commenttext}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SlideOverCommentsTabProps = {
|
||||||
|
comments: WorkOrderComment[];
|
||||||
|
isReadOnly: boolean;
|
||||||
|
isPending: boolean;
|
||||||
|
currentUserId?: string | number;
|
||||||
|
isUpdatePending?: boolean;
|
||||||
|
onSubmit: (text: string, options?: { onSuccess?: () => void }) => void;
|
||||||
|
onUpdateComment?: (
|
||||||
|
commentId: string | number,
|
||||||
|
text: string,
|
||||||
|
options?: { onSuccess?: () => void },
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverCommentsTab({
|
||||||
|
comments,
|
||||||
|
isReadOnly,
|
||||||
|
isPending,
|
||||||
|
currentUserId,
|
||||||
|
isUpdatePending = false,
|
||||||
|
onSubmit,
|
||||||
|
onUpdateComment,
|
||||||
|
}: SlideOverCommentsTabProps) {
|
||||||
|
const [commentText, setCommentText] = useState("");
|
||||||
|
const [editingCommentId, setEditingCommentId] = useState<string | number | null>(null);
|
||||||
|
const [draftText, setDraftText] = useState("");
|
||||||
|
|
||||||
|
const handlePost = () => {
|
||||||
|
const text = commentText.trim();
|
||||||
|
if (!text || isReadOnly || isPending) return;
|
||||||
|
onSubmit(text, { onSuccess: () => setCommentText("") });
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEdit = (comment: WorkOrderComment) => {
|
||||||
|
setEditingCommentId(comment.id);
|
||||||
|
setDraftText(comment.commenttext);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelEdit = () => {
|
||||||
|
setEditingCommentId(null);
|
||||||
|
setDraftText("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = () => {
|
||||||
|
if (editingCommentId == null || !onUpdateComment) return;
|
||||||
|
const text = draftText.trim();
|
||||||
|
if (!text || isUpdatePending) return;
|
||||||
|
onUpdateComment(editingCommentId, text, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setEditingCommentId(null);
|
||||||
|
setDraftText("");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{!isReadOnly && (
|
||||||
|
<>
|
||||||
|
<textarea
|
||||||
|
value={commentText}
|
||||||
|
onChange={(event) => setCommentText(event.target.value)}
|
||||||
|
placeholder="Add comment…"
|
||||||
|
rows={3}
|
||||||
|
disabled={isPending}
|
||||||
|
className="w-full resize-none rounded-lg px-3 py-2 text-[12px] focus:outline-none focus:ring-1"
|
||||||
|
style={textareaStyle}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
size="default"
|
||||||
|
className="h-8 text-[12px]"
|
||||||
|
disabled={!commentText.trim() || isPending}
|
||||||
|
onClick={handlePost}
|
||||||
|
loading={isPending}
|
||||||
|
>
|
||||||
|
Post
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
borderTop: "1px solid var(--color-bg-muted)",
|
||||||
|
pointerEvents: isReadOnly ? "none" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{comments.length === 0 ? (
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
className="py-6 text-center text-[12px]"
|
||||||
|
style={{ color: "var(--muted-foreground)" }}
|
||||||
|
>
|
||||||
|
No comments yet
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
comments.map((comment) => {
|
||||||
|
const canEdit =
|
||||||
|
!isReadOnly &&
|
||||||
|
Boolean(onUpdateComment) &&
|
||||||
|
isOwnWorkOrderComment(comment, currentUserId);
|
||||||
|
const isEditing = editingCommentId === comment.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={String(comment.id)}
|
||||||
|
style={{ borderBottom: "1px solid var(--color-bg-muted)" }}
|
||||||
|
>
|
||||||
|
<SlideOverCommentItem
|
||||||
|
comment={comment}
|
||||||
|
canEdit={canEdit}
|
||||||
|
isEditing={isEditing}
|
||||||
|
draftText={isEditing ? draftText : comment.commenttext}
|
||||||
|
isUpdatePending={isUpdatePending && isEditing}
|
||||||
|
onStartEdit={() => startEdit(comment)}
|
||||||
|
onDraftChange={setDraftText}
|
||||||
|
onCancelEdit={cancelEdit}
|
||||||
|
onSaveEdit={saveEdit}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
import { Ban, CheckCircle2, FileText, Upload } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
type SlideOverCompletionActionsProps = {
|
||||||
|
docStatus: CompletionDocStatus;
|
||||||
|
rowVersion?: string | number | null;
|
||||||
|
completionPending: boolean;
|
||||||
|
hasUploadHandler: boolean;
|
||||||
|
pdfInputRef: RefObject<HTMLInputElement | null>;
|
||||||
|
onPdfSelected: (files: FileList | null) => void;
|
||||||
|
onOpenCompletionDoc: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function DocStatusButtonContent({ docStatus }: { docStatus: CompletionDocStatus }) {
|
||||||
|
if (docStatus === "Yes") {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 size={13} />
|
||||||
|
View / Edit Document
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (docStatus === "NN") {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Ban size={13} />
|
||||||
|
Document Not Required — Review
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FileText size={13} />
|
||||||
|
Open completion document
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SlideOverCompletionActions({
|
||||||
|
docStatus,
|
||||||
|
rowVersion,
|
||||||
|
completionPending,
|
||||||
|
hasUploadHandler,
|
||||||
|
pdfInputRef,
|
||||||
|
onPdfSelected,
|
||||||
|
onOpenCompletionDoc,
|
||||||
|
}: SlideOverCompletionActionsProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{hasUploadHandler && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full gap-1.5 text-[12px]"
|
||||||
|
disabled={!rowVersion}
|
||||||
|
loading={completionPending}
|
||||||
|
loadingText="Uploading…"
|
||||||
|
onClick={() => pdfInputRef.current?.click()}
|
||||||
|
startIcon={!completionPending ? <Upload size={13} /> : undefined}
|
||||||
|
>
|
||||||
|
Upload signed PDF
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={pdfInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf,.pdf"
|
||||||
|
hidden
|
||||||
|
onChange={(event) => {
|
||||||
|
onPdfSelected(event.target.files);
|
||||||
|
event.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!rowVersion && (
|
||||||
|
<Text as="p" style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||||
|
Waiting for work order version before upload is available.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full gap-1.5 text-[12px]"
|
||||||
|
onClick={onOpenCompletionDoc}
|
||||||
|
>
|
||||||
|
<DocStatusButtonContent docStatus={docStatus} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { Image as ImageIcon } from "lucide-react";
|
||||||
|
import {
|
||||||
|
MediaUploader,
|
||||||
|
type MediaUploadRequest,
|
||||||
|
} from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||||
|
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||||
|
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
|
|
||||||
|
type SlideOverCompletionMediaProps = {
|
||||||
|
isReadOnly: boolean;
|
||||||
|
media: MediaFile[];
|
||||||
|
mediaPending: boolean;
|
||||||
|
onUploadMedia?: (requests: MediaUploadRequest[]) => void;
|
||||||
|
onCategorizeMedia?: (id: string, category: UploadableMediaCategory) => void;
|
||||||
|
onRemoveMedia?: (id: string) => void;
|
||||||
|
onRetryMedia?: (localId: string) => void;
|
||||||
|
onCancelMedia?: (localId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverCompletionMedia({
|
||||||
|
isReadOnly,
|
||||||
|
media,
|
||||||
|
mediaPending,
|
||||||
|
onUploadMedia,
|
||||||
|
onCategorizeMedia,
|
||||||
|
onRemoveMedia,
|
||||||
|
onRetryMedia,
|
||||||
|
onCancelMedia,
|
||||||
|
}: SlideOverCompletionMediaProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<ImageIcon size={14} style={{ color: "var(--foreground)" }} />
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--foreground)" }}>
|
||||||
|
Photos & Videos
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={isReadOnly ? { opacity: 0.6, pointerEvents: "none" } : undefined}>
|
||||||
|
<MediaUploader
|
||||||
|
uploads={media}
|
||||||
|
onUpload={onUploadMedia}
|
||||||
|
onCategorize={onCategorizeMedia}
|
||||||
|
onRemove={onRemoveMedia}
|
||||||
|
onRetry={onRetryMedia}
|
||||||
|
onCancel={onCancelMedia}
|
||||||
|
readOnly={isReadOnly}
|
||||||
|
pending={mediaPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { CheckCircle2, FileText } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
type SlideOverCompletionReadonlyStatusProps = {
|
||||||
|
docStatus: CompletionDocStatus;
|
||||||
|
onOpenCompletionDoc: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverCompletionReadonlyStatus({
|
||||||
|
docStatus,
|
||||||
|
onOpenCompletionDoc,
|
||||||
|
}: SlideOverCompletionReadonlyStatusProps) {
|
||||||
|
if (docStatus === "Yes") {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full gap-1.5 text-[12px]"
|
||||||
|
onClick={onOpenCompletionDoc}
|
||||||
|
>
|
||||||
|
<CheckCircle2 size={13} />
|
||||||
|
View Document
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
style={{
|
||||||
|
padding: "10px 12px",
|
||||||
|
borderRadius: 8,
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileText size={14} className="shrink-0" style={{ color: "var(--color-text-muted)" }} />
|
||||||
|
<span style={{ fontSize: 12, color: "var(--muted-foreground)" }}>
|
||||||
|
{docStatus === "NN"
|
||||||
|
? "No completion document was required for this work order."
|
||||||
|
: "No completion document was generated."}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import type { WorkOrderBoardCompletion } from "@/domain/work-orders/types/work-order-board-detail";
|
||||||
|
|
||||||
|
type SlideOverCompletionSignoffsProps = {
|
||||||
|
completion?: WorkOrderBoardCompletion;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverCompletionSignoffs({ completion }: SlideOverCompletionSignoffsProps) {
|
||||||
|
if (!completion || completion.dispatchSignoffs.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1 pt-2">
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Dispatch sign-offs
|
||||||
|
</span>
|
||||||
|
{completion.dispatchSignoffs.map((signoff, index) => (
|
||||||
|
<div
|
||||||
|
key={`${signoff.dispatchId ?? "d"}-${index}`}
|
||||||
|
style={{ fontSize: 12, color: "var(--color-text-secondary)" }}
|
||||||
|
>
|
||||||
|
{signoff.signoffType || "Sign-off"}
|
||||||
|
{signoff.name ? ` — ${signoff.name}` : ""}
|
||||||
|
{signoff.signedAt ? ` · ${signoff.signedAt}` : ""}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { useRef } from "react";
|
||||||
|
import { Download, FileText } from "lucide-react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||||
|
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||||
|
import type { WorkOrderBoardCompletion } from "@/domain/work-orders/types/work-order-board-detail";
|
||||||
|
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import {
|
||||||
|
formatMediaSizeLimit,
|
||||||
|
isWorkOrderFileTooLarge,
|
||||||
|
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||||
|
import { SlideOverCompletionActions } from "./slide-over-completion-actions";
|
||||||
|
import { SlideOverCompletionMedia } from "./slide-over-completion-media";
|
||||||
|
import { SlideOverCompletionReadonlyStatus } from "./slide-over-completion-readonly-status";
|
||||||
|
import { SlideOverCompletionSignoffs } from "./slide-over-completion-signoffs";
|
||||||
|
|
||||||
|
function isMediaWO(type: WOType | ""): boolean {
|
||||||
|
return type === "Emergency" || type === "Reactive";
|
||||||
|
}
|
||||||
|
|
||||||
|
type SlideOverCompletionTabProps = {
|
||||||
|
row: WorkOrderTableRow;
|
||||||
|
isReadOnly: boolean;
|
||||||
|
completion?: WorkOrderBoardCompletion;
|
||||||
|
media: MediaFile[];
|
||||||
|
mediaPending?: boolean;
|
||||||
|
completionPending?: boolean;
|
||||||
|
onUploadMedia?: (requests: MediaUploadRequest[]) => void;
|
||||||
|
onCategorizeMedia?: (id: string, category: UploadableMediaCategory) => void;
|
||||||
|
onRemoveMedia?: (id: string) => void;
|
||||||
|
onRetryMedia?: (localId: string) => void;
|
||||||
|
onCancelMedia?: (localId: string) => void;
|
||||||
|
onUploadCompletionPdf?: (file: File) => void;
|
||||||
|
onOpenCompletionDoc: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverCompletionTab({
|
||||||
|
row,
|
||||||
|
isReadOnly,
|
||||||
|
completion,
|
||||||
|
media,
|
||||||
|
mediaPending = false,
|
||||||
|
completionPending = false,
|
||||||
|
onUploadMedia,
|
||||||
|
onCategorizeMedia,
|
||||||
|
onRemoveMedia,
|
||||||
|
onRetryMedia,
|
||||||
|
onCancelMedia,
|
||||||
|
onUploadCompletionPdf,
|
||||||
|
onOpenCompletionDoc,
|
||||||
|
}: SlideOverCompletionTabProps) {
|
||||||
|
const pdfInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const docStatus = completion?.docStatus || row.docStatus;
|
||||||
|
const templateUrl = completion?.template?.templateUrl;
|
||||||
|
const signOffAttachment = completion?.signOffAttachment;
|
||||||
|
|
||||||
|
if (isMediaWO(row.type)) {
|
||||||
|
return (
|
||||||
|
<SlideOverCompletionMedia
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
|
media={media}
|
||||||
|
mediaPending={mediaPending}
|
||||||
|
onUploadMedia={onUploadMedia}
|
||||||
|
onCategorizeMedia={onCategorizeMedia}
|
||||||
|
onRemoveMedia={onRemoveMedia}
|
||||||
|
onRetryMedia={onRetryMedia}
|
||||||
|
onCancelMedia={onCancelMedia}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePdfSelected = (files: FileList | null) => {
|
||||||
|
const file = files?.[0];
|
||||||
|
if (!file || !onUploadCompletionPdf) return;
|
||||||
|
if (isWorkOrderFileTooLarge(file)) {
|
||||||
|
toast.error(`File exceeds the ${formatMediaSizeLimit()} limit.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (file.type !== "application/pdf" && !file.name.toLowerCase().endsWith(".pdf")) {
|
||||||
|
toast.error("Please upload a PDF file.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onUploadCompletionPdf(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<FileText size={14} style={{ color: "var(--foreground)" }} />
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--foreground)" }}>
|
||||||
|
Completion Document
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Boolean(templateUrl) && (
|
||||||
|
<a
|
||||||
|
href={templateUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
||||||
|
style={{ color: "var(--color-primary)" }}
|
||||||
|
>
|
||||||
|
<Download size={13} />
|
||||||
|
Download blank template
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Boolean(signOffAttachment) && (
|
||||||
|
<a
|
||||||
|
href={signOffAttachment}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
||||||
|
style={{ color: "var(--color-primary)" }}
|
||||||
|
>
|
||||||
|
<FileText size={13} />
|
||||||
|
View uploaded document
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isReadOnly ? (
|
||||||
|
<SlideOverCompletionReadonlyStatus
|
||||||
|
docStatus={docStatus}
|
||||||
|
onOpenCompletionDoc={onOpenCompletionDoc}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SlideOverCompletionActions
|
||||||
|
docStatus={docStatus}
|
||||||
|
rowVersion={row.rowVersion}
|
||||||
|
completionPending={completionPending}
|
||||||
|
hasUploadHandler={Boolean(onUploadCompletionPdf)}
|
||||||
|
pdfInputRef={pdfInputRef}
|
||||||
|
onPdfSelected={handlePdfSelected}
|
||||||
|
onOpenCompletionDoc={onOpenCompletionDoc}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SlideOverCompletionSignoffs completion={completion} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
import { Image as ImageIcon } from "lucide-react";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
|
||||||
|
function isMediaWO(type: WOType | ""): boolean {
|
||||||
|
return type === "Emergency" || type === "Reactive";
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOC_BADGE_STYLE: Record<"Yes" | "No" | "NN", { bg: string; text: string }> = {
|
||||||
|
Yes: { bg: "#F0FDF4", text: "#166534" },
|
||||||
|
No: { bg: "#FFF1F2", text: "#BE123C" },
|
||||||
|
NN: { bg: "#F3F4F6", text: "#6B7280" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DocBadge({ row }: { row: WorkOrderTableRow }) {
|
||||||
|
if (isMediaWO(row.type)) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold"
|
||||||
|
style={{ color: "var(--color-primary)" }}
|
||||||
|
>
|
||||||
|
<ImageIcon size={14} />
|
||||||
|
{row.mediaCount > 0 ? `${row.mediaCount} file${row.mediaCount !== 1 ? "s" : ""}` : "Media"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = row.docStatus === "Yes" ? "Yes" : row.docStatus === "NN" ? "NN" : "No";
|
||||||
|
const style = DOC_BADGE_STYLE[key];
|
||||||
|
const label = key === "NN" ? "NN" : key;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center px-1.5 py-0.5 text-[11px] font-medium leading-none"
|
||||||
|
style={{
|
||||||
|
background: style.bg,
|
||||||
|
color: style.text,
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
import { useRef } from "react";
|
||||||
|
import {
|
||||||
|
File as FileIcon,
|
||||||
|
FileText,
|
||||||
|
Film,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Paperclip,
|
||||||
|
Upload,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
|
import {
|
||||||
|
canDeleteWorkOrderMedia,
|
||||||
|
formatMediaSizeLimit,
|
||||||
|
isWorkOrderFileTooLarge,
|
||||||
|
isWorkOrderMediaTypeAllowed,
|
||||||
|
workOrderMediaAcceptAttribute,
|
||||||
|
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||||
|
|
||||||
|
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
|
||||||
|
image: ImageIcon,
|
||||||
|
video: Film,
|
||||||
|
pdf: FileText,
|
||||||
|
doc: FileIcon,
|
||||||
|
other: Paperclip,
|
||||||
|
};
|
||||||
|
|
||||||
|
type SlideOverExtrasTabProps = {
|
||||||
|
uploads: MediaFile[];
|
||||||
|
isReadOnly: boolean;
|
||||||
|
pending?: boolean;
|
||||||
|
onUpload: (files: File[]) => void;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverExtrasTab({
|
||||||
|
uploads,
|
||||||
|
isReadOnly,
|
||||||
|
pending = false,
|
||||||
|
onUpload,
|
||||||
|
onRemove,
|
||||||
|
}: SlideOverExtrasTabProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const addFiles = (files: FileList | null) => {
|
||||||
|
if (isReadOnly || pending || !files || files.length === 0) return;
|
||||||
|
const accepted: File[] = [];
|
||||||
|
for (const file of Array.from(files)) {
|
||||||
|
if (!isWorkOrderMediaTypeAllowed(file)) {
|
||||||
|
toast.error(`"${file.name}" is not a supported type (JPG, PNG, MP4, MOV).`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isWorkOrderFileTooLarge(file)) {
|
||||||
|
toast.error(`"${file.name}" exceeds the ${formatMediaSizeLimit()} limit.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
accepted.push(file);
|
||||||
|
}
|
||||||
|
if (accepted.length > 0) {
|
||||||
|
onUpload(accepted);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<ImageIcon size={14} style={{ color: "var(--foreground)" }} />
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--foreground)" }}>
|
||||||
|
Extra Documents
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isReadOnly && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
onDragOver={(event) => event.preventDefault()}
|
||||||
|
onDrop={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
addFiles(event.dataTransfer.files);
|
||||||
|
}}
|
||||||
|
className="flex w-full flex-col items-center justify-center gap-1.5 transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-60"
|
||||||
|
style={{
|
||||||
|
minHeight: 100,
|
||||||
|
border: "1px dashed var(--color-border)",
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Upload size={20} style={{ color: "var(--color-text-muted)" }} />
|
||||||
|
<span
|
||||||
|
style={{ fontSize: 13, color: "var(--color-text-muted)" }}
|
||||||
|
aria-busy={pending || undefined}
|
||||||
|
>
|
||||||
|
{pending ? "Uploading…" : "Upload documents, photos or videos"}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 11, color: "var(--color-text-muted)" }}>
|
||||||
|
Accepted: JPG, PNG, MP4, MOV · Max {formatMediaSizeLimit()} per file
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={workOrderMediaAcceptAttribute()}
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(event) => {
|
||||||
|
addFiles(event.target.files);
|
||||||
|
event.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{uploads.length === 0 ? (
|
||||||
|
<Text as="p" style={{ fontSize: 12, color: "var(--color-text-muted)" }}>
|
||||||
|
No extra documents yet.
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{uploads.map((file) => {
|
||||||
|
const ItemIcon = FILETYPE_ICON[file.fileType];
|
||||||
|
const showDelete = !isReadOnly && canDeleteWorkOrderMedia(file);
|
||||||
|
return (
|
||||||
|
<div key={file.id} className="flex items-center gap-2 py-1">
|
||||||
|
<ItemIcon
|
||||||
|
size={12}
|
||||||
|
className="shrink-0"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
/>
|
||||||
|
<a
|
||||||
|
href={file.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="min-w-0 flex-1 truncate hover:underline"
|
||||||
|
style={{ fontSize: 12, color: "var(--foreground)" }}
|
||||||
|
>
|
||||||
|
{file.name}
|
||||||
|
</a>
|
||||||
|
{showDelete && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="Remove"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => onRemove(file.id)}
|
||||||
|
className="rounded p-0.5 transition-opacity hover:opacity-70 disabled:opacity-50"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color";
|
||||||
|
|
||||||
|
type SlideOverInfoTabAssignedFieldProps = {
|
||||||
|
dispatcher?: WorkOrderUser;
|
||||||
|
dispatcherName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverInfoTabAssignedField({
|
||||||
|
dispatcher,
|
||||||
|
dispatcherName,
|
||||||
|
}: SlideOverInfoTabAssignedFieldProps) {
|
||||||
|
if (!dispatcher && !dispatcherName) {
|
||||||
|
return <span style={{ color: "var(--color-text-muted)" }}>—</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[12.5px]">
|
||||||
|
{dispatcher != null && (
|
||||||
|
<span
|
||||||
|
className="h-2 w-2 shrink-0 rounded-full"
|
||||||
|
style={{ background: getDispatcherAvatarColor(dispatcher) }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{dispatcher?.name || dispatcherName}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
import { SlideOverInfoRow } from "@/app/(protected)/workorders/_components/detail/slide-over-info-row";
|
||||||
|
import { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
|
||||||
|
import { WoTableTypeLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-type-label";
|
||||||
|
import type { VendorDropdownItem, WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import { formatApptRange, parseApptTime } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
import { DocBadge } from "./slide-over-doc-badge";
|
||||||
|
import { SlideOverInfoTabAssignedField } from "./slide-over-info-tab-assigned-field";
|
||||||
|
import { SlideOverInfoTabStatusField } from "./slide-over-info-tab-status-field";
|
||||||
|
import { SlideOverInfoTabVendorField } from "./slide-over-info-tab-vendor-field";
|
||||||
|
|
||||||
|
type SlideOverInfoTabDisplayViewProps = {
|
||||||
|
source: WorkOrderTableRow;
|
||||||
|
isCancelled: boolean;
|
||||||
|
dispatcher?: WorkOrderUser;
|
||||||
|
vendors: VendorDropdownItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverInfoTabDisplayView({
|
||||||
|
source,
|
||||||
|
isCancelled,
|
||||||
|
dispatcher,
|
||||||
|
vendors,
|
||||||
|
}: SlideOverInfoTabDisplayViewProps) {
|
||||||
|
const appt = parseApptTime(source.apptTime);
|
||||||
|
const apptDisplay = appt.start ? formatApptRange(appt.start, appt.end) : source.apptTime || "—";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={isCancelled ? { opacity: 0.6, pointerEvents: "none" } : undefined}>
|
||||||
|
<SlideOverInfoRow label="WO #">
|
||||||
|
<span className="font-mono text-[12.5px] font-semibold">{source.woNumber || "—"}</span>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Site">
|
||||||
|
<span className="font-mono text-[12.5px] font-bold" style={{ color: "var(--foreground)" }}>
|
||||||
|
{source.site || "—"}
|
||||||
|
</span>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Type of WO">
|
||||||
|
{source.type ? (
|
||||||
|
<WoTableTypeLabel type={source.type as WOType} />
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "var(--color-text-muted)" }}>—</span>
|
||||||
|
)}
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Assigned To">
|
||||||
|
<SlideOverInfoTabAssignedField
|
||||||
|
dispatcher={dispatcher}
|
||||||
|
dispatcherName={source.dispatcherName}
|
||||||
|
/>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Schedule On">
|
||||||
|
<span className="text-[12.5px]">
|
||||||
|
{source.scheduleWeekOnly && source.targetWeek
|
||||||
|
? `Week of ${source.targetWeek}`
|
||||||
|
: source.scheduledOn || "—"}
|
||||||
|
</span>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Due Date">
|
||||||
|
<span className="text-[12.5px]">{source.dueDate || "—"}</span>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Appt Time">
|
||||||
|
<span className="text-[12.5px]">{apptDisplay}</span>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Service">
|
||||||
|
{source.pm ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[12.5px]">
|
||||||
|
<PmTypeIcon pmType={source.pm} size={14} />
|
||||||
|
{source.pm}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: "var(--color-text-muted)" }}>—</span>
|
||||||
|
)}
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Vendor">
|
||||||
|
<SlideOverInfoTabVendorField source={source} vendors={vendors} />
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Status">
|
||||||
|
<SlideOverInfoTabStatusField source={source} />
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Comp Doc">
|
||||||
|
<DocBadge row={source} />
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="POC">
|
||||||
|
<span className="text-[12.5px]" style={{ color: "var(--color-text-secondary)" }}>
|
||||||
|
{source.pocName || "—"}
|
||||||
|
{source.pocPhone ? ` · ${source.pocPhone}` : ""}
|
||||||
|
</span>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
import { AssignedToCell } from "@/app/(protected)/workorders/_components/list/table/cells/assigned-to-cell";
|
||||||
|
import { ApptTimeCell } from "@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell";
|
||||||
|
import { DateCell } from "@/app/(protected)/workorders/_components/list/table/cells/date-cell";
|
||||||
|
import { EditableWONumberCell } from "@/app/(protected)/workorders/_components/list/table/cells/editable-wo-number-cell";
|
||||||
|
import { PMTypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell";
|
||||||
|
import { SiteCell } from "@/app/(protected)/workorders/_components/list/table/cells/site-cell";
|
||||||
|
import { StatusCell } from "@/app/(protected)/workorders/_components/list/table/cells/status-cell";
|
||||||
|
import { TypeCell } from "@/app/(protected)/workorders/_components/list/table/cells/type-cell";
|
||||||
|
import { VendorCell } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-cell";
|
||||||
|
import { SlideOverInfoRow } from "@/app/(protected)/workorders/_components/detail/slide-over-info-row";
|
||||||
|
import type {
|
||||||
|
LocationOption,
|
||||||
|
VendorDropdownItem,
|
||||||
|
WorkOrderUser,
|
||||||
|
} from "@/domain/work-orders/types/work-order";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
import { DocBadge } from "./slide-over-doc-badge";
|
||||||
|
|
||||||
|
type SlideOverInfoTabEditViewProps = {
|
||||||
|
draft: WorkOrderTableRow;
|
||||||
|
users: WorkOrderUser[];
|
||||||
|
sites: LocationOption[];
|
||||||
|
vendors: VendorDropdownItem[];
|
||||||
|
pmTypes: string[];
|
||||||
|
allOrders: WorkOrderTableRow[];
|
||||||
|
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
|
||||||
|
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverInfoTabEditView({
|
||||||
|
draft,
|
||||||
|
users,
|
||||||
|
sites,
|
||||||
|
vendors,
|
||||||
|
pmTypes,
|
||||||
|
allOrders,
|
||||||
|
onDraftChange,
|
||||||
|
onDuplicateFound,
|
||||||
|
}: SlideOverInfoTabEditViewProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<SlideOverInfoRow label="WO #">
|
||||||
|
<EditableWONumberCell
|
||||||
|
value={draft.woNumber}
|
||||||
|
rescheduleCount={draft.rescheduleCount}
|
||||||
|
carriedOver={draft.carriedOver}
|
||||||
|
currentId={draft.id}
|
||||||
|
allOrders={allOrders}
|
||||||
|
onSave={(woNumber) => onDraftChange({ woNumber })}
|
||||||
|
onDuplicateFound={onDuplicateFound}
|
||||||
|
/>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Site">
|
||||||
|
<SiteCell
|
||||||
|
value={draft.site}
|
||||||
|
location={draft.location}
|
||||||
|
locationId={draft.locationId}
|
||||||
|
pocName={draft.pocName}
|
||||||
|
pocPhone={draft.pocPhone}
|
||||||
|
pocNotes={draft.pocNotes}
|
||||||
|
sites={sites}
|
||||||
|
onSave={(patch) =>
|
||||||
|
onDraftChange({
|
||||||
|
site: patch.site,
|
||||||
|
location: patch.location,
|
||||||
|
locationId: patch.locationId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Type of WO">
|
||||||
|
<TypeCell value={draft.type} onSave={(type) => onDraftChange({ type })} />
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Assigned To">
|
||||||
|
<AssignedToCell
|
||||||
|
dispatcherId={draft.dispatcherId}
|
||||||
|
users={users}
|
||||||
|
onSave={(id, name) => onDraftChange({ dispatcherId: id, dispatcherName: name })}
|
||||||
|
/>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Schedule On">
|
||||||
|
<DateCell
|
||||||
|
value={draft.scheduledOn}
|
||||||
|
allowWeekMode
|
||||||
|
targetWeek={draft.targetWeek}
|
||||||
|
defaultMode={draft.scheduleWeekOnly ? "week" : "date"}
|
||||||
|
onSave={(scheduledOn) =>
|
||||||
|
onDraftChange({ scheduledOn, scheduleWeekOnly: false, isPastDue: false })
|
||||||
|
}
|
||||||
|
onSaveWeek={(weekStart) =>
|
||||||
|
onDraftChange({
|
||||||
|
scheduledOn: "",
|
||||||
|
targetWeek: weekStart,
|
||||||
|
scheduleWeekOnly: true,
|
||||||
|
isPastDue: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Due Date">
|
||||||
|
<DateCell value={draft.dueDate} onSave={(dueDate) => onDraftChange({ dueDate })} />
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Appt Time">
|
||||||
|
<ApptTimeCell
|
||||||
|
apptTime={draft.apptTime}
|
||||||
|
onSave={(apptTime) => onDraftChange({ apptTime })}
|
||||||
|
/>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Service">
|
||||||
|
<PMTypeCell
|
||||||
|
value={draft.pm}
|
||||||
|
pmTypes={pmTypes}
|
||||||
|
serviceNotes={draft.serviceNotes}
|
||||||
|
extraServices={draft.extraServices}
|
||||||
|
woNumber={draft.woNumber}
|
||||||
|
onSave={(pm) => onDraftChange({ pm })}
|
||||||
|
onSaveDetails={(details) =>
|
||||||
|
onDraftChange({
|
||||||
|
serviceNotes: details.serviceNotes,
|
||||||
|
extraServices: details.extraServices,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Vendor">
|
||||||
|
<VendorCell
|
||||||
|
company={draft.company}
|
||||||
|
tech={draft.tech}
|
||||||
|
techPhone={draft.techPhone}
|
||||||
|
vendors={vendors}
|
||||||
|
onSave={(patch) =>
|
||||||
|
onDraftChange({
|
||||||
|
vendorId: patch.vendorId,
|
||||||
|
company: patch.company,
|
||||||
|
tech: patch.tech,
|
||||||
|
techPhone: patch.techPhone,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Status">
|
||||||
|
<StatusCell
|
||||||
|
wo={draft}
|
||||||
|
onChangeStatus={(status: WizardWOStatus) => onDraftChange({ status })}
|
||||||
|
onOpenSlideOver={() => {}}
|
||||||
|
/>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="Comp Doc">
|
||||||
|
<DocBadge row={draft} />
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
<SlideOverInfoRow label="POC">
|
||||||
|
<span className="text-[13px]" style={{ color: "var(--color-text-secondary)" }}>
|
||||||
|
{draft.pocName || "—"}
|
||||||
|
{draft.pocPhone ? ` · ${draft.pocPhone}` : ""}
|
||||||
|
</span>
|
||||||
|
</SlideOverInfoRow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
import { WoTableStatusLabel } from "@/app/(protected)/workorders/_components/list/table/cells/wo-table-status-label";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
type SlideOverInfoTabStatusFieldProps = {
|
||||||
|
source: WorkOrderTableRow;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverInfoTabStatusField({ source }: SlideOverInfoTabStatusFieldProps) {
|
||||||
|
if (source.isPastDue) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 text-[12.5px] font-semibold"
|
||||||
|
style={{ color: "var(--status-pastdue-text)" }}
|
||||||
|
>
|
||||||
|
<span className="h-2 w-2 shrink-0 rounded-full bg-[#BE123C]" />
|
||||||
|
Past Due
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.status) {
|
||||||
|
return <WoTableStatusLabel status={source.status} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span style={{ color: "var(--color-text-muted)" }}>—</span>;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { VendorDialog } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog";
|
||||||
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
type SlideOverInfoTabVendorFieldProps = {
|
||||||
|
source: WorkOrderTableRow;
|
||||||
|
vendors: VendorDropdownItem[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverInfoTabVendorField({ source, vendors }: SlideOverInfoTabVendorFieldProps) {
|
||||||
|
const [vendorDlg, setVendorDlg] = useState(false);
|
||||||
|
|
||||||
|
if (!source.tech && !source.company) {
|
||||||
|
return <span style={{ color: "var(--color-text-muted)" }}>—</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVendorDlg(true)}
|
||||||
|
className="text-left transition-opacity hover:opacity-80"
|
||||||
|
>
|
||||||
|
<div className="text-[13px] font-semibold" style={{ color: "var(--foreground)" }}>
|
||||||
|
{source.tech || source.company}
|
||||||
|
</div>
|
||||||
|
{Boolean(source.tech && source.company) && (
|
||||||
|
<div className="text-[11px] font-normal" style={{ color: "var(--muted-foreground)" }}>
|
||||||
|
{source.company}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<VendorDialog
|
||||||
|
open={vendorDlg}
|
||||||
|
onOpenChange={setVendorDlg}
|
||||||
|
company={source.company}
|
||||||
|
tech={source.tech}
|
||||||
|
techPhone={source.techPhone}
|
||||||
|
vendors={vendors}
|
||||||
|
onSave={() => setVendorDlg(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import type {
|
||||||
|
LocationOption,
|
||||||
|
VendorDropdownItem,
|
||||||
|
WorkOrderUser,
|
||||||
|
} from "@/domain/work-orders/types/work-order";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { SlideOverInfoTabDisplayView } from "./slide-over-info-tab-display-view";
|
||||||
|
import { SlideOverInfoTabEditView } from "./slide-over-info-tab-edit-view";
|
||||||
|
|
||||||
|
type SlideOverInfoTabProps = {
|
||||||
|
row: WorkOrderTableRow;
|
||||||
|
draft: WorkOrderTableRow;
|
||||||
|
editing: boolean;
|
||||||
|
isCancelled: boolean;
|
||||||
|
users: WorkOrderUser[];
|
||||||
|
sites: LocationOption[];
|
||||||
|
vendors: VendorDropdownItem[];
|
||||||
|
pmTypes: string[];
|
||||||
|
allOrders: WorkOrderTableRow[];
|
||||||
|
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
|
||||||
|
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverInfoTab({
|
||||||
|
row,
|
||||||
|
draft,
|
||||||
|
editing,
|
||||||
|
isCancelled,
|
||||||
|
users,
|
||||||
|
sites,
|
||||||
|
vendors,
|
||||||
|
pmTypes,
|
||||||
|
allOrders,
|
||||||
|
onDraftChange,
|
||||||
|
onDuplicateFound,
|
||||||
|
}: SlideOverInfoTabProps) {
|
||||||
|
const isEditing = editing && !isCancelled;
|
||||||
|
const source = isEditing ? draft : row;
|
||||||
|
const dispatcher = users.find((u) => String(u.id) === source.dispatcherId);
|
||||||
|
|
||||||
|
if (isEditing) {
|
||||||
|
return (
|
||||||
|
<SlideOverInfoTabEditView
|
||||||
|
draft={draft}
|
||||||
|
users={users}
|
||||||
|
sites={sites}
|
||||||
|
vendors={vendors}
|
||||||
|
pmTypes={pmTypes}
|
||||||
|
allOrders={allOrders}
|
||||||
|
onDraftChange={onDraftChange}
|
||||||
|
onDuplicateFound={onDuplicateFound}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SlideOverInfoTabDisplayView
|
||||||
|
source={source}
|
||||||
|
isCancelled={isCancelled}
|
||||||
|
dispatcher={dispatcher}
|
||||||
|
vendors={vendors}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import type {
|
||||||
|
WorkOrderTablePatch,
|
||||||
|
WorkOrderTableRow,
|
||||||
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import {
|
||||||
|
buildSlideOverPatch,
|
||||||
|
isSlideOverDraftDirty,
|
||||||
|
} from "@/domain/work-orders/utils/slide-over-draft";
|
||||||
|
import type { SlideOverSaveOptions } from "./work-order-slide-over";
|
||||||
|
import type { SlideOverTab } from "./use-work-order-slide-over";
|
||||||
|
|
||||||
|
type UseSlideOverEditStateArgs = {
|
||||||
|
row: WorkOrderTableRow | null;
|
||||||
|
editMode: boolean;
|
||||||
|
infoSource: WorkOrderTableRow | null | undefined;
|
||||||
|
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
closeDisabled: boolean;
|
||||||
|
setTab: (tab: SlideOverTab) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useSlideOverEditState({
|
||||||
|
row,
|
||||||
|
editMode,
|
||||||
|
infoSource,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
closeDisabled,
|
||||||
|
setTab,
|
||||||
|
}: UseSlideOverEditStateArgs) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [draft, setDraft] = useState<WorkOrderTableRow | null>(null);
|
||||||
|
/** Last confirmed baseline for dirty/patch — not overwritten by optimistic parent row. */
|
||||||
|
const [baseline, setBaseline] = useState<WorkOrderTableRow | null>(null);
|
||||||
|
const [showUnsaved, setShowUnsaved] = useState(false);
|
||||||
|
const [unsavedIntent, setUnsavedIntent] = useState<"close" | "cancel-edit" | null>(null);
|
||||||
|
|
||||||
|
// Reset draft/baseline only when the opened WO identity or editMode intent changes —
|
||||||
|
// not on every optimistic parent-row field update during a failed/in-flight save.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!row) {
|
||||||
|
setDraft(null);
|
||||||
|
setBaseline(null);
|
||||||
|
setEditing(false);
|
||||||
|
setShowUnsaved(false);
|
||||||
|
setUnsavedIntent(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDraft(row);
|
||||||
|
setBaseline(row);
|
||||||
|
setEditing(editMode && (row.status as string) !== "Canceled");
|
||||||
|
setTab("info");
|
||||||
|
setShowUnsaved(false);
|
||||||
|
setUnsavedIntent(null);
|
||||||
|
// Intentionally identity-only: depending on `row` would absorb optimistic patches.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- row.id / editMode gate
|
||||||
|
}, [row?.id, editMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!infoSource) return;
|
||||||
|
if (!editing) {
|
||||||
|
setDraft(infoSource);
|
||||||
|
setBaseline(infoSource);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// While editing, freeze baseline so optimistic parent-row patches cannot clear dirty.
|
||||||
|
setDraft((prev) => {
|
||||||
|
if (prev && baseline && isSlideOverDraftDirty(prev, baseline)) return prev;
|
||||||
|
return infoSource;
|
||||||
|
});
|
||||||
|
}, [infoSource, editing, baseline]);
|
||||||
|
|
||||||
|
const activeDraft = draft ?? infoSource;
|
||||||
|
const dirtyBaseline = baseline ?? infoSource;
|
||||||
|
const isDirty = useMemo(() => {
|
||||||
|
if (!dirtyBaseline || !activeDraft || !editing) return false;
|
||||||
|
return isSlideOverDraftDirty(activeDraft, dirtyBaseline);
|
||||||
|
}, [activeDraft, dirtyBaseline, editing]);
|
||||||
|
|
||||||
|
const handleDraftChange = (patch: Partial<WorkOrderTableRow>) => {
|
||||||
|
setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
|
||||||
|
};
|
||||||
|
|
||||||
|
const commit = () => {
|
||||||
|
if (!row || !activeDraft || !dirtyBaseline) return;
|
||||||
|
const patch = buildSlideOverPatch(activeDraft, dirtyBaseline);
|
||||||
|
if (Object.keys(patch).length === 0) {
|
||||||
|
setEditing(false);
|
||||||
|
setShowUnsaved(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSave(row.id, patch, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setBaseline(activeDraft);
|
||||||
|
setEditing(false);
|
||||||
|
setShowUnsaved(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const discard = () => {
|
||||||
|
if (!dirtyBaseline) return;
|
||||||
|
setDraft(dirtyBaseline);
|
||||||
|
setEditing(false);
|
||||||
|
setShowUnsaved(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestClose = () => {
|
||||||
|
if (closeDisabled) return;
|
||||||
|
if (editing && isDirty) {
|
||||||
|
setUnsavedIntent("close");
|
||||||
|
setShowUnsaved(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
if (isDirty) {
|
||||||
|
setUnsavedIntent("cancel-edit");
|
||||||
|
setShowUnsaved(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dirtyBaseline) setDraft(dirtyBaseline);
|
||||||
|
setEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDiscard = () => {
|
||||||
|
const intent = unsavedIntent;
|
||||||
|
discard();
|
||||||
|
setUnsavedIntent(null);
|
||||||
|
// Close only when Discard was opened from panel close — never from cancel-edit.
|
||||||
|
// Skip when uploads block close (closeDisabled).
|
||||||
|
if (intent === "close" && !closeDisabled) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEditing = () => {
|
||||||
|
if (!infoSource) return;
|
||||||
|
setDraft(infoSource);
|
||||||
|
setBaseline(infoSource);
|
||||||
|
setEditing(true);
|
||||||
|
setTab("info");
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
editing,
|
||||||
|
activeDraft,
|
||||||
|
isDirty,
|
||||||
|
showUnsaved,
|
||||||
|
setShowUnsaved,
|
||||||
|
handleDraftChange,
|
||||||
|
commit,
|
||||||
|
discard,
|
||||||
|
handleDiscard,
|
||||||
|
requestClose,
|
||||||
|
handleCancelEdit,
|
||||||
|
startEditing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||||
|
import { useMediaUploadSession } from "@/app/(protected)/workorders/_hooks/use-media-upload-session";
|
||||||
|
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||||
|
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||||
|
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
|
||||||
|
import type {
|
||||||
|
useDeleteWorkOrderMedia,
|
||||||
|
useUpdateWorkOrderMediaCategory,
|
||||||
|
useUploadWorkOrderMedia,
|
||||||
|
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||||
|
|
||||||
|
const COMPLETION_MEDIA_PURPOSES = new Set(["Before", "After", "Completion"]);
|
||||||
|
|
||||||
|
type UseSlideOverMediaStateArgs = {
|
||||||
|
detailForRow?: WorkOrderBoardDetail;
|
||||||
|
uploadMediaMutation: ReturnType<typeof useUploadWorkOrderMedia>;
|
||||||
|
deleteMediaMutation: ReturnType<typeof useDeleteWorkOrderMedia>;
|
||||||
|
categorizeMediaMutation: ReturnType<typeof useUpdateWorkOrderMediaCategory>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useSlideOverMediaState({
|
||||||
|
detailForRow,
|
||||||
|
uploadMediaMutation,
|
||||||
|
deleteMediaMutation,
|
||||||
|
categorizeMediaMutation,
|
||||||
|
}: UseSlideOverMediaStateArgs) {
|
||||||
|
const [mediaBatchPending, setMediaBatchPending] = useState(false);
|
||||||
|
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
|
||||||
|
useMediaUploadSession(detailForRow, (input) => uploadMediaMutation.mutateAsync(input));
|
||||||
|
|
||||||
|
const completionMedia = useMemo(
|
||||||
|
() =>
|
||||||
|
uploads.filter(
|
||||||
|
(item) =>
|
||||||
|
COMPLETION_MEDIA_PURPOSES.has(item.purpose ?? "") ||
|
||||||
|
item.uploadStatus === "uploading" ||
|
||||||
|
item.uploadStatus === "failed",
|
||||||
|
),
|
||||||
|
[uploads],
|
||||||
|
);
|
||||||
|
|
||||||
|
const extrasUploads = useMemo(
|
||||||
|
() =>
|
||||||
|
boardMediaItemsToMediaFiles(
|
||||||
|
(detailForRow?.media ?? []).filter((item) => item.category === "Extra"),
|
||||||
|
),
|
||||||
|
[detailForRow?.media],
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasLocalUploading = uploads.some((item) => item.uploadStatus === "uploading");
|
||||||
|
const mediaPending =
|
||||||
|
mediaBatchPending ||
|
||||||
|
hasLocalUploading ||
|
||||||
|
uploadMediaMutation.isPending ||
|
||||||
|
deleteMediaMutation.isPending ||
|
||||||
|
categorizeMediaMutation.isPending;
|
||||||
|
|
||||||
|
const workOrderVersion = detailForRow?.info.rowVersion?.trim() ?? "";
|
||||||
|
|
||||||
|
const uploadMediaRequests = (requests: MediaUploadRequest[]) => {
|
||||||
|
startUploads(requests);
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadExtraFiles = async (files: File[]) => {
|
||||||
|
setMediaBatchPending(true);
|
||||||
|
try {
|
||||||
|
for (const file of files) {
|
||||||
|
await uploadMediaMutation.mutateAsync({ file, category: "Extra" });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setMediaBatchPending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const categorizeMedia = (mediaId: string, category: UploadableMediaCategory) => {
|
||||||
|
if (!workOrderVersion) return;
|
||||||
|
categorizeMediaMutation.mutate({ mediaId, category, workOrderVersion });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeMedia = (id: string) => {
|
||||||
|
if (isLocal(id)) {
|
||||||
|
removeLocal(id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!workOrderVersion) return;
|
||||||
|
deleteMediaMutation.mutate({ mediaId: id, workOrderVersion });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
completionMedia,
|
||||||
|
extrasUploads,
|
||||||
|
mediaPending,
|
||||||
|
uploadMediaRequests,
|
||||||
|
uploadExtraFiles,
|
||||||
|
categorizeMedia,
|
||||||
|
retryUpload,
|
||||||
|
cancelUpload,
|
||||||
|
removeMedia,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import type {
|
||||||
|
WorkOrderTablePatch,
|
||||||
|
WorkOrderTableRow,
|
||||||
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
|
import {
|
||||||
|
useAddWorkOrderComment,
|
||||||
|
useDeleteWorkOrderMedia,
|
||||||
|
useUpdateWorkOrderComment,
|
||||||
|
useUpdateWorkOrderMediaCategory,
|
||||||
|
useUploadCompletionDoc,
|
||||||
|
useUploadWorkOrderMedia,
|
||||||
|
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||||
|
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||||
|
import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
|
||||||
|
import { useAuthContext } from "@/providers/auth-context";
|
||||||
|
import { useSlideOverEditState } from "./use-slide-over-edit-state";
|
||||||
|
import { useSlideOverMediaState } from "./use-slide-over-media-state";
|
||||||
|
import type { SlideOverSaveOptions } from "./work-order-slide-over";
|
||||||
|
|
||||||
|
export type SlideOverTab = "info" | "comments" | "audit" | "completion" | "extras";
|
||||||
|
|
||||||
|
type UseWorkOrderSlideOverArgs = {
|
||||||
|
row: WorkOrderTableRow | null;
|
||||||
|
editMode: boolean;
|
||||||
|
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
saving?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useWorkOrderSlideOver({
|
||||||
|
row,
|
||||||
|
editMode,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
saving,
|
||||||
|
}: UseWorkOrderSlideOverArgs) {
|
||||||
|
const { user } = useAuthContext();
|
||||||
|
const workOrderId = row?.id;
|
||||||
|
const {
|
||||||
|
data: detail,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
} = useWorkOrderBoardDetail(workOrderId, Boolean(row));
|
||||||
|
const commentMutation = useAddWorkOrderComment(workOrderId ?? "");
|
||||||
|
const updateCommentMutation = useUpdateWorkOrderComment(workOrderId ?? "");
|
||||||
|
const uploadMediaMutation = useUploadWorkOrderMedia(workOrderId ?? "");
|
||||||
|
const categorizeMediaMutation = useUpdateWorkOrderMediaCategory(workOrderId ?? "");
|
||||||
|
const deleteMediaMutation = useDeleteWorkOrderMedia(workOrderId ?? "");
|
||||||
|
const uploadCompletionMutation = useUploadCompletionDoc(workOrderId ?? "");
|
||||||
|
|
||||||
|
const [tab, setTab] = useState<SlideOverTab>("info");
|
||||||
|
|
||||||
|
// Prefer row until detail for this id arrives — avoids cross-WO bleed while loading.
|
||||||
|
const detailForRow =
|
||||||
|
detail && row && String(detail.info.id) === String(row.id) ? detail : undefined;
|
||||||
|
const infoSource = detailForRow?.info ?? row;
|
||||||
|
const isCancelled = (infoSource?.status as string) === "Canceled";
|
||||||
|
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(infoSource?.status);
|
||||||
|
|
||||||
|
const {
|
||||||
|
completionMedia,
|
||||||
|
extrasUploads,
|
||||||
|
mediaPending,
|
||||||
|
uploadMediaRequests,
|
||||||
|
uploadExtraFiles,
|
||||||
|
categorizeMedia,
|
||||||
|
retryUpload,
|
||||||
|
cancelUpload,
|
||||||
|
removeMedia,
|
||||||
|
} = useSlideOverMediaState({
|
||||||
|
detailForRow,
|
||||||
|
uploadMediaMutation,
|
||||||
|
deleteMediaMutation,
|
||||||
|
categorizeMediaMutation,
|
||||||
|
});
|
||||||
|
const completionPending = uploadCompletionMutation.isPending;
|
||||||
|
const closeDisabled = Boolean(saving || mediaPending || completionPending);
|
||||||
|
|
||||||
|
const editState = useSlideOverEditState({
|
||||||
|
row,
|
||||||
|
editMode,
|
||||||
|
infoSource,
|
||||||
|
onSave,
|
||||||
|
onClose,
|
||||||
|
closeDisabled,
|
||||||
|
setTab,
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadCompletionPdf = (file: File) => {
|
||||||
|
const version = infoSource?.rowVersion;
|
||||||
|
if (!version || !row || !infoSource) {
|
||||||
|
toast.error("Missing work order version. Refresh and try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploadCompletionMutation.mutate(
|
||||||
|
{ file, workOrderVersion: version },
|
||||||
|
{
|
||||||
|
onSuccess: async () => {
|
||||||
|
// Mirror CompDoc finalize: ensure completedDate then mark docStatus Yes.
|
||||||
|
const patch: WorkOrderTablePatch = { docStatus: "Yes" };
|
||||||
|
if (!infoSource.completedDate) {
|
||||||
|
patch.completedDate = todayIso();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
onSave(row.id, patch, {
|
||||||
|
onSuccess: () => resolve(),
|
||||||
|
onError: (error) => reject(error),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
toast.error("Uploaded PDF but failed to update document status. Refresh and retry.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const showDetailSkeleton = isLoading && !detailForRow;
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : "Failed to load work order details.";
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
detail,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
commentMutation,
|
||||||
|
updateCommentMutation,
|
||||||
|
tab,
|
||||||
|
setTab,
|
||||||
|
detailForRow,
|
||||||
|
infoSource,
|
||||||
|
isCancelled,
|
||||||
|
isDocumentsReadOnly,
|
||||||
|
completionMedia,
|
||||||
|
extrasUploads,
|
||||||
|
mediaPending,
|
||||||
|
completionPending,
|
||||||
|
closeDisabled,
|
||||||
|
uploadMediaRequests,
|
||||||
|
uploadExtraFiles,
|
||||||
|
categorizeMedia,
|
||||||
|
retryUpload,
|
||||||
|
cancelUpload,
|
||||||
|
removeMedia,
|
||||||
|
deleteMediaMutation,
|
||||||
|
uploadCompletionPdf,
|
||||||
|
showDetailSkeleton,
|
||||||
|
errorMessage,
|
||||||
|
...editState,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Box, Paper, Tab, Tabs, TextField, Typography } from "@mui/material";
|
||||||
|
import { CommentTimeline } from "@/components/common/comment-timeline";
|
||||||
|
import { mapWorkOrderCommentsToTimeline } from "@/components/common/comment-timeline-mappers";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { AuditTimeline } from "./audit-timeline";
|
||||||
|
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
|
type ActivityTab = "customer" | "internal" | "audit";
|
||||||
|
|
||||||
|
type WorkOrderActivitySectionProps = {
|
||||||
|
wo: WorkOrderDetail;
|
||||||
|
onCommentSubmit: (
|
||||||
|
text: string,
|
||||||
|
commentType: "customer" | "internal",
|
||||||
|
options?: { onSuccess?: () => void },
|
||||||
|
) => void;
|
||||||
|
isCommentPending: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkOrderActivitySection({
|
||||||
|
wo,
|
||||||
|
onCommentSubmit,
|
||||||
|
isCommentPending,
|
||||||
|
}: WorkOrderActivitySectionProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<ActivityTab>("customer");
|
||||||
|
const [commentText, setCommentText] = useState("");
|
||||||
|
|
||||||
|
const customerComments = useMemo(
|
||||||
|
() => (wo.comments ?? []).filter((comment) => comment.commentType === "customer"),
|
||||||
|
[wo.comments],
|
||||||
|
);
|
||||||
|
const internalComments = useMemo(
|
||||||
|
() => (wo.comments ?? []).filter((comment) => comment.commentType === "internal"),
|
||||||
|
[wo.comments],
|
||||||
|
);
|
||||||
|
const auditLog = wo.auditLog ?? [];
|
||||||
|
|
||||||
|
const handleCommentSubmit = (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!commentText.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onCommentSubmit(commentText.trim(), activeTab === "customer" ? "customer" : "internal", {
|
||||||
|
onSuccess: () => setCommentText(""),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper variant="outlined" className="p-4">
|
||||||
|
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||||
|
Activity
|
||||||
|
</Typography>
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onChange={(_event, value: ActivityTab) => setActiveTab(value)}
|
||||||
|
className="mb-3"
|
||||||
|
>
|
||||||
|
<Tab value="customer" label={`Customer (${customerComments.length})`} />
|
||||||
|
<Tab value="internal" label={`Internal (${internalComments.length})`} />
|
||||||
|
<Tab value="audit" label={`Audit Log (${auditLog.length})`} />
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{activeTab === "customer" && (
|
||||||
|
<CommentTimeline
|
||||||
|
items={mapWorkOrderCommentsToTimeline(customerComments)}
|
||||||
|
emptyMessage="No comments yet."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === "internal" && (
|
||||||
|
<CommentTimeline
|
||||||
|
items={mapWorkOrderCommentsToTimeline(internalComments)}
|
||||||
|
emptyMessage="No comments yet."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{activeTab === "audit" && <AuditTimeline entries={auditLog} />}
|
||||||
|
|
||||||
|
{activeTab === "internal" && (
|
||||||
|
<Box component="form" onSubmit={handleCommentSubmit} className="mt-4 flex flex-col gap-2">
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
rows={2}
|
||||||
|
value={commentText}
|
||||||
|
onChange={(event) => setCommentText(event.target.value)}
|
||||||
|
placeholder="Type a comment..."
|
||||||
|
disabled={isCommentPending}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="self-end"
|
||||||
|
disabled={!commentText.trim()}
|
||||||
|
loading={isCommentPending}
|
||||||
|
loadingText="Sending…"
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { Button, Menu, MenuItem } from "@mui/material";
|
||||||
|
import type { WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
|
||||||
|
|
||||||
|
type WorkOrderAssigneeMenuProps = {
|
||||||
|
assignedTo: string;
|
||||||
|
users: WorkOrderUser[];
|
||||||
|
disabled?: boolean;
|
||||||
|
onAssign: (userId: string | number, userName: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkOrderAssigneeMenu({
|
||||||
|
assignedTo,
|
||||||
|
users,
|
||||||
|
disabled = false,
|
||||||
|
onAssign,
|
||||||
|
}: WorkOrderAssigneeMenuProps) {
|
||||||
|
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-busy={disabled || undefined}
|
||||||
|
onClick={(event) => setAnchor(event.currentTarget)}
|
||||||
|
>
|
||||||
|
{disabled ? "Updating…" : assignedTo || "Unassigned"}
|
||||||
|
</Button>
|
||||||
|
<Menu anchorEl={anchorEl} open={open} onClose={close}>
|
||||||
|
<MenuItem
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => {
|
||||||
|
close();
|
||||||
|
onAssign("", "");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Unassigned
|
||||||
|
</MenuItem>
|
||||||
|
{users.map((user) => (
|
||||||
|
<MenuItem
|
||||||
|
key={String(user.id)}
|
||||||
|
selected={assignedTo === user.name}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => {
|
||||||
|
close();
|
||||||
|
onAssign(user.id, user.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { Accordion, AccordionDetails, AccordionSummary, Typography } from "@mui/material";
|
||||||
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
|
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
|
type WorkOrderDescriptionAccordionProps = {
|
||||||
|
wo: WorkOrderDetail;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkOrderDescriptionAccordion({ wo }: WorkOrderDescriptionAccordionProps) {
|
||||||
|
return (
|
||||||
|
<Accordion>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
|
<Typography sx={{ fontWeight: 600 }}>Description</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<Typography>{wo.description || "No description provided."}</Typography>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import type { WorkOrderDetail, WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { WorkOrderActivitySection } from "./work-order-activity-section";
|
||||||
|
import { WorkOrderDescriptionAccordion } from "./work-order-description-accordion";
|
||||||
|
import { WorkOrderDetailsCard } from "./work-order-details-card";
|
||||||
|
import { WorkOrderDispatchesAccordion } from "./work-order-dispatches-accordion";
|
||||||
|
import { WorkOrderSimpleAccordions } from "./work-order-simple-accordions";
|
||||||
|
|
||||||
|
type WorkOrderDetailsSectionProps = {
|
||||||
|
wo: WorkOrderDetail;
|
||||||
|
users: WorkOrderUser[];
|
||||||
|
onAssign: (userId: string | number, userName: string) => void;
|
||||||
|
isAssignPending?: boolean;
|
||||||
|
onDispatchClick: (dispatchId: string | number) => void;
|
||||||
|
onCommentSubmit: (
|
||||||
|
text: string,
|
||||||
|
commentType: "customer" | "internal",
|
||||||
|
options?: { onSuccess?: () => void },
|
||||||
|
) => void;
|
||||||
|
isCommentPending: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkOrderDetailsSection({
|
||||||
|
wo,
|
||||||
|
users,
|
||||||
|
onAssign,
|
||||||
|
isAssignPending = false,
|
||||||
|
onDispatchClick,
|
||||||
|
onCommentSubmit,
|
||||||
|
isCommentPending,
|
||||||
|
}: WorkOrderDetailsSectionProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<WorkOrderDetailsCard
|
||||||
|
wo={wo}
|
||||||
|
users={users}
|
||||||
|
onAssign={onAssign}
|
||||||
|
isAssignPending={isAssignPending}
|
||||||
|
/>
|
||||||
|
<WorkOrderSimpleAccordions wo={wo} />
|
||||||
|
<WorkOrderDispatchesAccordion wo={wo} onDispatchClick={onDispatchClick} />
|
||||||
|
<WorkOrderDescriptionAccordion wo={wo} />
|
||||||
|
<WorkOrderActivitySection
|
||||||
|
wo={wo}
|
||||||
|
onCommentSubmit={onCommentSubmit}
|
||||||
|
isCommentPending={isCommentPending}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { Box, Paper, Typography } from "@mui/material";
|
||||||
|
import { DetailField, DetailFieldGrid } from "@/components/common/detail-field";
|
||||||
|
import { WorkOrderAssigneeMenu } from "./work-order-assignee-menu";
|
||||||
|
import type { WorkOrderDetail, WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
|
type WorkOrderDetailsCardProps = {
|
||||||
|
wo: WorkOrderDetail;
|
||||||
|
users: WorkOrderUser[];
|
||||||
|
onAssign: (userId: string | number, userName: string) => void;
|
||||||
|
isAssignPending?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkOrderDetailsCard({
|
||||||
|
wo,
|
||||||
|
users,
|
||||||
|
onAssign,
|
||||||
|
isAssignPending = false,
|
||||||
|
}: WorkOrderDetailsCardProps) {
|
||||||
|
return (
|
||||||
|
<Paper variant="outlined" className="p-4">
|
||||||
|
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
||||||
|
Details
|
||||||
|
</Typography>
|
||||||
|
<DetailFieldGrid>
|
||||||
|
<DetailField label="Internal WO" value={wo.internalWONumber} />
|
||||||
|
<DetailField label="Customer WO" value={wo.workOrderNumber || "—"} />
|
||||||
|
<DetailField label="Priority" value={wo.priority || "—"} />
|
||||||
|
<DetailField label="Location" value={wo.location || "—"} />
|
||||||
|
<Box>
|
||||||
|
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
|
||||||
|
Assigned To
|
||||||
|
</Typography>
|
||||||
|
<WorkOrderAssigneeMenu
|
||||||
|
assignedTo={wo.assignedTo}
|
||||||
|
users={users}
|
||||||
|
disabled={isAssignPending}
|
||||||
|
onAssign={onAssign}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<DetailField label="Source" value={wo.source || "—"} />
|
||||||
|
<DetailField label="TT" value={wo.tt || "—"} />
|
||||||
|
</DetailFieldGrid>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionDetails,
|
||||||
|
AccordionSummary,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
|
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
|
||||||
|
|
||||||
|
type WorkOrderDispatchesAccordionProps = {
|
||||||
|
wo: WorkOrderDetail;
|
||||||
|
onDispatchClick: (dispatchId: string | number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkOrderDispatchesAccordion({
|
||||||
|
wo,
|
||||||
|
onDispatchClick,
|
||||||
|
}: WorkOrderDispatchesAccordionProps) {
|
||||||
|
return (
|
||||||
|
<Accordion defaultExpanded={Boolean(wo.dispatches.length)}>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
|
<Typography sx={{ fontWeight: 600 }}>Dispatches ({wo.dispatches.length})</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
{wo.dispatches.length ? (
|
||||||
|
<TableContainer>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Dispatch #</TableCell>
|
||||||
|
<TableCell>Vendor</TableCell>
|
||||||
|
<TableCell>NTE</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell>Date</TableCell>
|
||||||
|
<TableCell>Wait</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{wo.dispatches.map((dispatch) => {
|
||||||
|
const color = waitTimeColor(dispatch.dispatchedAt);
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={String(dispatch.id)}
|
||||||
|
hover
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => onDispatchClick(dispatch.id)}
|
||||||
|
>
|
||||||
|
<TableCell>{dispatch.dispatchNumber || dispatch.poNumber}</TableCell>
|
||||||
|
<TableCell>{dispatch.vendorName}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{dispatch.nteAmount != null ? `$${dispatch.nteAmount.toFixed(2)}` : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{dispatch.status}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{dispatch.dispatchedAt ? formatDateTime(dispatch.dispatchedAt) : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{dispatch.status === "Sent" ? (
|
||||||
|
<Typography variant="caption" className={getWaitTimeTextClass(color)}>
|
||||||
|
{timeSince(dispatch.dispatchedAt)}
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
) : (
|
||||||
|
<Typography color="text.secondary">No dispatches yet.</Typography>
|
||||||
|
)}
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
import { Accordion, AccordionDetails, AccordionSummary, Box, Typography } from "@mui/material";
|
||||||
|
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
||||||
|
import { DetailField, DetailFieldGrid } from "@/components/common/detail-field";
|
||||||
|
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { formatDateTime } from "@/lib/time-utils";
|
||||||
|
|
||||||
|
type WorkOrderSimpleAccordionsProps = {
|
||||||
|
wo: WorkOrderDetail;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkOrderSimpleAccordions({ wo }: WorkOrderSimpleAccordionsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Accordion>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
|
<Typography sx={{ fontWeight: 600 }}>Classification</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<DetailFieldGrid>
|
||||||
|
<DetailField label="Problem" value={wo.problem || "—"} />
|
||||||
|
<DetailField label="Trade" value={wo.trade || "—"} />
|
||||||
|
<DetailField label="Sub-Trade" value={wo.subTrade || "—"} />
|
||||||
|
</DetailFieldGrid>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
|
<Typography sx={{ fontWeight: 600 }}>Schedule</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<DetailField
|
||||||
|
label="Created"
|
||||||
|
value={wo.createdDateFull ? formatDateTime(wo.createdDateFull) : "—"}
|
||||||
|
/>
|
||||||
|
<DetailField label="Due Date" value={wo.dueDate || "—"} />
|
||||||
|
<DetailField label="Scheduled" value={wo.scheduledDate || "—"} />
|
||||||
|
<DetailField label="Completed" value={wo.completedDate || "—"} />
|
||||||
|
</Box>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Accordion>
|
||||||
|
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||||
|
<Typography sx={{ fontWeight: 600 }}>Financial</Typography>
|
||||||
|
</AccordionSummary>
|
||||||
|
<AccordionDetails>
|
||||||
|
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<DetailField
|
||||||
|
label="Vendor NTE"
|
||||||
|
value={wo.vendorNTE ? `$${parseFloat(wo.vendorNTE).toFixed(2)}` : "—"}
|
||||||
|
/>
|
||||||
|
<DetailField label="Customer PO" value={wo.po || "—"} />
|
||||||
|
<DetailField label="Customer NTE" value="Not connected" />
|
||||||
|
</Box>
|
||||||
|
</AccordionDetails>
|
||||||
|
</Accordion>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
import type { SlideOverTab } from "./use-work-order-slide-over";
|
||||||
|
|
||||||
|
const SLIDE_OVER_TABS: { id: SlideOverTab; label: string }[] = [
|
||||||
|
{ id: "info", label: "Info" },
|
||||||
|
{ id: "comments", label: "Comments" },
|
||||||
|
{ id: "audit", label: "Audit Log" },
|
||||||
|
{ id: "completion", label: "Completion Doc" },
|
||||||
|
{ id: "extras", label: "Extra Docs" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type WorkOrderSlideOverTabNavProps = {
|
||||||
|
tab: SlideOverTab;
|
||||||
|
saving?: boolean;
|
||||||
|
onTabChange: (tab: SlideOverTab) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkOrderSlideOverTabNav({
|
||||||
|
tab,
|
||||||
|
saving,
|
||||||
|
onTabChange,
|
||||||
|
}: WorkOrderSlideOverTabNavProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="mt-2 flex shrink-0 gap-4 overflow-x-auto px-4"
|
||||||
|
style={{ borderBottom: "1px solid var(--color-border)" }}
|
||||||
|
role="tablist"
|
||||||
|
aria-label="Work order sections"
|
||||||
|
>
|
||||||
|
{SLIDE_OVER_TABS.map((item) => {
|
||||||
|
const active = tab === item.id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={active}
|
||||||
|
disabled={Boolean(saving)}
|
||||||
|
onClick={() => onTabChange(item.id)}
|
||||||
|
className="shrink-0 border-b-2 pb-2.5 text-[12px] transition-colors disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
borderBottomColor: active ? "var(--color-primary)" : "transparent",
|
||||||
|
color: active ? "var(--color-primary)" : "var(--color-text-muted)",
|
||||||
|
fontWeight: active ? 500 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
||||||
|
import { SlideOverTabSkeleton } from "@/app/(protected)/workorders/_components/loading/slide-over-tab-skeleton";
|
||||||
|
|
||||||
|
type SlideOverTabSkeletonVariant = "comments" | "audit" | "completion" | "extras";
|
||||||
|
|
||||||
|
type WorkOrderSlideOverTabPanelProps = {
|
||||||
|
variant: SlideOverTabSkeletonVariant;
|
||||||
|
showSkeleton: boolean;
|
||||||
|
hasError: boolean;
|
||||||
|
errorMessage: string;
|
||||||
|
onRetry: () => void;
|
||||||
|
showContent: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkOrderSlideOverTabPanel({
|
||||||
|
variant,
|
||||||
|
showSkeleton,
|
||||||
|
hasError,
|
||||||
|
errorMessage,
|
||||||
|
onRetry,
|
||||||
|
showContent,
|
||||||
|
children,
|
||||||
|
}: WorkOrderSlideOverTabPanelProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showSkeleton && <SlideOverTabSkeleton variant={variant} />}
|
||||||
|
{hasError && <QueryErrorPanel compact message={errorMessage} onRetry={onRetry} />}
|
||||||
|
{showContent && children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,156 @@
|
||||||
|
import { Box } from "@mui/material";
|
||||||
|
import { SlideOverAuditTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-audit-tab";
|
||||||
|
import { SlideOverCommentsTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab";
|
||||||
|
import { SlideOverCompletionTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-completion-tab";
|
||||||
|
import { SlideOverExtrasTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-extras-tab";
|
||||||
|
import { SlideOverInfoTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab";
|
||||||
|
import type {
|
||||||
|
LocationOption,
|
||||||
|
VendorDropdownItem,
|
||||||
|
WorkOrderUser,
|
||||||
|
} from "@/domain/work-orders/types/work-order";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import type { useWorkOrderSlideOver, SlideOverTab } from "./use-work-order-slide-over";
|
||||||
|
import { WorkOrderSlideOverTabPanel } from "./work-order-slide-over-tab-panel";
|
||||||
|
|
||||||
|
type SlideOverState = ReturnType<typeof useWorkOrderSlideOver>;
|
||||||
|
|
||||||
|
type WorkOrderSlideOverTabsProps = {
|
||||||
|
tab: SlideOverTab;
|
||||||
|
state: SlideOverState;
|
||||||
|
saving?: boolean;
|
||||||
|
users: WorkOrderUser[];
|
||||||
|
sites: LocationOption[];
|
||||||
|
vendors: VendorDropdownItem[];
|
||||||
|
pmTypes: string[];
|
||||||
|
allOrders: WorkOrderTableRow[];
|
||||||
|
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||||
|
onOpenCompletionDoc: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkOrderSlideOverTabs({
|
||||||
|
tab,
|
||||||
|
state,
|
||||||
|
saving,
|
||||||
|
users,
|
||||||
|
sites,
|
||||||
|
vendors,
|
||||||
|
pmTypes,
|
||||||
|
allOrders,
|
||||||
|
onDuplicateFound,
|
||||||
|
onOpenCompletionDoc,
|
||||||
|
}: WorkOrderSlideOverTabsProps) {
|
||||||
|
const hasError = Boolean(state.error) && !state.detailForRow;
|
||||||
|
const showContent = Boolean(state.detailForRow || !state.isLoading);
|
||||||
|
const { infoSource, activeDraft } = state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="flex-1 overflow-y-auto p-5">
|
||||||
|
{tab === "info" && infoSource != null && activeDraft != null && (
|
||||||
|
<SlideOverInfoTab
|
||||||
|
row={infoSource}
|
||||||
|
draft={activeDraft}
|
||||||
|
editing={state.editing && !saving}
|
||||||
|
isCancelled={state.isCancelled}
|
||||||
|
users={users}
|
||||||
|
sites={sites}
|
||||||
|
vendors={vendors}
|
||||||
|
pmTypes={pmTypes}
|
||||||
|
allOrders={allOrders}
|
||||||
|
onDraftChange={state.handleDraftChange}
|
||||||
|
onDuplicateFound={onDuplicateFound}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "comments" && (
|
||||||
|
<WorkOrderSlideOverTabPanel
|
||||||
|
variant="comments"
|
||||||
|
showSkeleton={state.showDetailSkeleton}
|
||||||
|
hasError={hasError}
|
||||||
|
errorMessage={state.errorMessage}
|
||||||
|
onRetry={() => void state.refetch()}
|
||||||
|
showContent={state.detailForRow != null}
|
||||||
|
>
|
||||||
|
{state.detailForRow != null && (
|
||||||
|
<SlideOverCommentsTab
|
||||||
|
comments={state.detailForRow.comments}
|
||||||
|
isReadOnly={state.isDocumentsReadOnly}
|
||||||
|
isPending={state.commentMutation.isPending}
|
||||||
|
currentUserId={state.user?.id}
|
||||||
|
isUpdatePending={state.updateCommentMutation.isPending}
|
||||||
|
onSubmit={(text, options) => state.commentMutation.mutate({ text }, options)}
|
||||||
|
onUpdateComment={(commentId, text, options) =>
|
||||||
|
state.updateCommentMutation.mutate({ commentId, text }, options)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</WorkOrderSlideOverTabPanel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "audit" && (
|
||||||
|
<WorkOrderSlideOverTabPanel
|
||||||
|
variant="audit"
|
||||||
|
showSkeleton={state.showDetailSkeleton}
|
||||||
|
hasError={hasError}
|
||||||
|
errorMessage={state.errorMessage}
|
||||||
|
onRetry={() => void state.refetch()}
|
||||||
|
showContent={state.detailForRow != null}
|
||||||
|
>
|
||||||
|
{state.detailForRow != null && (
|
||||||
|
<SlideOverAuditTab entries={state.detailForRow.audit} users={users} />
|
||||||
|
)}
|
||||||
|
</WorkOrderSlideOverTabPanel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "completion" && state.infoSource != null && (
|
||||||
|
<WorkOrderSlideOverTabPanel
|
||||||
|
variant="completion"
|
||||||
|
showSkeleton={state.showDetailSkeleton}
|
||||||
|
hasError={hasError}
|
||||||
|
errorMessage={state.errorMessage}
|
||||||
|
onRetry={() => void state.refetch()}
|
||||||
|
showContent={showContent}
|
||||||
|
>
|
||||||
|
<SlideOverCompletionTab
|
||||||
|
row={state.infoSource}
|
||||||
|
isReadOnly={state.isDocumentsReadOnly}
|
||||||
|
completion={state.detailForRow?.completion}
|
||||||
|
media={state.completionMedia}
|
||||||
|
mediaPending={state.mediaPending}
|
||||||
|
completionPending={state.completionPending}
|
||||||
|
onUploadMedia={(requests) => {
|
||||||
|
state.uploadMediaRequests(requests);
|
||||||
|
}}
|
||||||
|
onCategorizeMedia={state.categorizeMedia}
|
||||||
|
onRemoveMedia={state.removeMedia}
|
||||||
|
onRetryMedia={state.retryUpload}
|
||||||
|
onCancelMedia={state.cancelUpload}
|
||||||
|
onUploadCompletionPdf={state.uploadCompletionPdf}
|
||||||
|
onOpenCompletionDoc={onOpenCompletionDoc}
|
||||||
|
/>
|
||||||
|
</WorkOrderSlideOverTabPanel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "extras" && (
|
||||||
|
<WorkOrderSlideOverTabPanel
|
||||||
|
variant="extras"
|
||||||
|
showSkeleton={state.showDetailSkeleton}
|
||||||
|
hasError={hasError}
|
||||||
|
errorMessage={state.errorMessage}
|
||||||
|
onRetry={() => void state.refetch()}
|
||||||
|
showContent={showContent}
|
||||||
|
>
|
||||||
|
<SlideOverExtrasTab
|
||||||
|
uploads={state.extrasUploads}
|
||||||
|
isReadOnly={state.isDocumentsReadOnly}
|
||||||
|
pending={state.mediaPending}
|
||||||
|
onUpload={(files) => {
|
||||||
|
void state.uploadExtraFiles(files);
|
||||||
|
}}
|
||||||
|
onRemove={state.removeMedia}
|
||||||
|
/>
|
||||||
|
</WorkOrderSlideOverTabPanel>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
import { Box } from "@mui/material";
|
||||||
|
import { SideSheet } from "@/components/ui/side-sheet";
|
||||||
|
import { SlideOverHeader } from "@/app/(protected)/workorders/_components/detail/slide-over-header";
|
||||||
|
import { SlideOverFooter } from "@/app/(protected)/workorders/_components/detail/slide-over-footer";
|
||||||
|
import type {
|
||||||
|
LocationOption,
|
||||||
|
VendorDropdownItem,
|
||||||
|
WorkOrderUser,
|
||||||
|
} from "@/domain/work-orders/types/work-order";
|
||||||
|
import type {
|
||||||
|
WorkOrderTablePatch,
|
||||||
|
WorkOrderTableRow,
|
||||||
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { useWorkOrderSlideOver } from "./use-work-order-slide-over";
|
||||||
|
import { WorkOrderSlideOverTabNav } from "./work-order-slide-over-tab-nav";
|
||||||
|
import { WorkOrderSlideOverTabs } from "./work-order-slide-over-tabs";
|
||||||
|
|
||||||
|
export type SlideOverSaveOptions = {
|
||||||
|
onSuccess?: () => void;
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WorkOrderSlideOverProps = {
|
||||||
|
row: WorkOrderTableRow | null;
|
||||||
|
editMode: boolean;
|
||||||
|
users: WorkOrderUser[];
|
||||||
|
sites: LocationOption[];
|
||||||
|
vendors: VendorDropdownItem[];
|
||||||
|
pmTypes: string[];
|
||||||
|
allOrders: WorkOrderTableRow[];
|
||||||
|
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
|
||||||
|
onOpenCompletionDoc: (row: WorkOrderTableRow) => void;
|
||||||
|
onCancelWO: (row: WorkOrderTableRow) => void;
|
||||||
|
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
saving?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkOrderSlideOver({
|
||||||
|
row,
|
||||||
|
editMode,
|
||||||
|
users,
|
||||||
|
sites,
|
||||||
|
vendors,
|
||||||
|
pmTypes,
|
||||||
|
allOrders,
|
||||||
|
onSave,
|
||||||
|
onOpenCompletionDoc,
|
||||||
|
onCancelWO,
|
||||||
|
onDuplicateFound,
|
||||||
|
onClose,
|
||||||
|
saving,
|
||||||
|
}: WorkOrderSlideOverProps) {
|
||||||
|
const state = useWorkOrderSlideOver({ row, editMode, onSave, onClose, saving });
|
||||||
|
const { infoSource, activeDraft } = state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SideSheet
|
||||||
|
open={Boolean(row)}
|
||||||
|
onClose={state.requestClose}
|
||||||
|
closeDisabled={state.closeDisabled}
|
||||||
|
widthClass="w-[480px] max-w-[480px]"
|
||||||
|
aria-label="Work order details"
|
||||||
|
>
|
||||||
|
{row != null && infoSource != null && activeDraft != null && (
|
||||||
|
<Box className="flex h-full flex-col overflow-hidden">
|
||||||
|
<SlideOverHeader
|
||||||
|
row={infoSource}
|
||||||
|
isCancelled={state.isCancelled}
|
||||||
|
isFetching={state.isFetching && !state.isLoading}
|
||||||
|
closeDisabled={state.closeDisabled}
|
||||||
|
onClose={state.requestClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<WorkOrderSlideOverTabNav tab={state.tab} saving={saving} onTabChange={state.setTab} />
|
||||||
|
|
||||||
|
<WorkOrderSlideOverTabs
|
||||||
|
tab={state.tab}
|
||||||
|
state={state}
|
||||||
|
saving={saving}
|
||||||
|
users={users}
|
||||||
|
sites={sites}
|
||||||
|
vendors={vendors}
|
||||||
|
pmTypes={pmTypes}
|
||||||
|
allOrders={allOrders}
|
||||||
|
onDuplicateFound={onDuplicateFound}
|
||||||
|
onOpenCompletionDoc={() => onOpenCompletionDoc(infoSource)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SlideOverFooter
|
||||||
|
isCancelled={state.isCancelled}
|
||||||
|
editing={state.editing}
|
||||||
|
isDirty={state.isDirty}
|
||||||
|
showUnsaved={state.showUnsaved}
|
||||||
|
saving={saving}
|
||||||
|
onCancelWO={() => onCancelWO(infoSource)}
|
||||||
|
onCancelEdit={state.handleCancelEdit}
|
||||||
|
onClose={state.requestClose}
|
||||||
|
onEdit={state.startEditing}
|
||||||
|
onSave={state.commit}
|
||||||
|
onDiscard={state.handleDiscard}
|
||||||
|
onKeepEditing={() => state.setShowUnsaved(false)}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</SideSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { Menu, MenuItem } from "@mui/material";
|
||||||
|
import { WorkOrderStatusBadge } from "@/components/domain/status-badge";
|
||||||
|
import { MenuAnchorButton } from "@/components/ui/menu-anchor-button";
|
||||||
|
import { WORK_ORDER_STATUS_OPTIONS } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { useMenuAnchor } from "@/hooks/use-menu-anchor";
|
||||||
|
|
||||||
|
type WorkOrderStatusMenuProps = {
|
||||||
|
status: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onStatusChange: (status: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkOrderStatusMenu({
|
||||||
|
status,
|
||||||
|
disabled = false,
|
||||||
|
onStatusChange,
|
||||||
|
}: WorkOrderStatusMenuProps) {
|
||||||
|
const { anchorEl, open, setAnchor, close } = useMenuAnchor();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MenuAnchorButton
|
||||||
|
onAnchor={setAnchor}
|
||||||
|
open={open}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label="Change status"
|
||||||
|
aria-busy={disabled || undefined}
|
||||||
|
>
|
||||||
|
<WorkOrderStatusBadge status={status} />
|
||||||
|
</MenuAnchorButton>
|
||||||
|
<Menu anchorEl={anchorEl} open={open} onClose={close}>
|
||||||
|
{WORK_ORDER_STATUS_OPTIONS.map((option) => (
|
||||||
|
<MenuItem
|
||||||
|
key={option}
|
||||||
|
selected={option === status}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => {
|
||||||
|
close();
|
||||||
|
if (option !== status) {
|
||||||
|
onStatusChange(option);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -12,12 +12,15 @@ export function buildInlineDraftCreateInput(
|
||||||
const location = sites.find((s) => String(s.id) === String(draft.locationId));
|
const location = sites.find((s) => String(s.id) === String(draft.locationId));
|
||||||
const locationName = draft.location || draft.site || location?.name || "";
|
const locationName = draft.location || draft.site || location?.name || "";
|
||||||
|
|
||||||
|
const hasWeekTarget = Boolean(draft.targetWeek?.trim());
|
||||||
|
const scheduleWeekOnly = draft.scheduleWeekOnly && (hasWeekTarget || Boolean(draft.scheduledOn));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
locationId: String(draft.locationId || ""),
|
locationId: String(draft.locationId || ""),
|
||||||
locationName,
|
locationName,
|
||||||
siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }),
|
siteCode: location ? resolveSiteCode(location) : resolveSiteCode({ name: locationName }),
|
||||||
woNumber: draft.woNumber,
|
woNumber: draft.woNumber,
|
||||||
type: draft.type,
|
type: draft.type || "PM",
|
||||||
assigneeId: draft.dispatcherId,
|
assigneeId: draft.dispatcherId,
|
||||||
assigneeName: assignee?.name ?? "",
|
assigneeName: assignee?.name ?? "",
|
||||||
scheduledOn: draft.scheduledOn,
|
scheduledOn: draft.scheduledOn,
|
||||||
|
|
@ -35,7 +38,7 @@ export function buildInlineDraftCreateInput(
|
||||||
pocName: draft.pocName,
|
pocName: draft.pocName,
|
||||||
pocPhone: draft.pocPhone,
|
pocPhone: draft.pocPhone,
|
||||||
pocNotes: draft.pocNotes,
|
pocNotes: draft.pocNotes,
|
||||||
scheduleWeekOnly: draft.scheduleWeekOnly,
|
scheduleWeekOnly,
|
||||||
targetWeek: draft.targetWeek || "",
|
targetWeek: draft.targetWeek || "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ type DateCellProps = {
|
||||||
targetWeek?: string;
|
targetWeek?: string;
|
||||||
onSaveWeek?: (weekStart: string) => void;
|
onSaveWeek?: (weekStart: string) => void;
|
||||||
defaultMode?: "week" | "date";
|
defaultMode?: "week" | "date";
|
||||||
|
/** When set, shows Clear in the popover so schedule can be removed (e.g. day-group → Unscheduled). */
|
||||||
|
onClear?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function DateCell({
|
export function DateCell({
|
||||||
|
|
@ -26,6 +28,7 @@ export function DateCell({
|
||||||
targetWeek = "",
|
targetWeek = "",
|
||||||
onSaveWeek,
|
onSaveWeek,
|
||||||
defaultMode,
|
defaultMode,
|
||||||
|
onClear,
|
||||||
}: DateCellProps) {
|
}: DateCellProps) {
|
||||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
|
||||||
const [cellMode, setCellMode] = useState<"week" | "date">(
|
const [cellMode, setCellMode] = useState<"week" | "date">(
|
||||||
|
|
@ -132,6 +135,21 @@ export function DateCell({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{Boolean(onClear) && (
|
||||||
|
<div className="mt-2.5 flex justify-end border-t border-border pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onClear?.();
|
||||||
|
setAnchorEl(null);
|
||||||
|
}}
|
||||||
|
className="text-[11.5px] font-medium transition-colors"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
× Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Popover>
|
</Popover>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ type SiteCellProps = {
|
||||||
pocNotes?: string;
|
pocNotes?: string;
|
||||||
sites: LocationOption[];
|
sites: LocationOption[];
|
||||||
q?: string;
|
q?: string;
|
||||||
|
/** Inline create: editable POC + Site/POC required to confirm the modal. */
|
||||||
|
createMode?: boolean;
|
||||||
onSave: (patch: SitePatch) => void;
|
onSave: (patch: SitePatch) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -31,6 +33,7 @@ export function SiteCell({
|
||||||
pocNotes,
|
pocNotes,
|
||||||
sites,
|
sites,
|
||||||
q,
|
q,
|
||||||
|
createMode = false,
|
||||||
onSave,
|
onSave,
|
||||||
}: SiteCellProps) {
|
}: SiteCellProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
@ -70,7 +73,15 @@ export function SiteCell({
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[12.5px] text-[var(--color-text-muted)]">—</span>
|
<span className="text-[12.5px] text-[var(--color-text-muted)]">
|
||||||
|
{createMode ? (
|
||||||
|
<>
|
||||||
|
Site <span style={{ color: "var(--destructive)" }}>*</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
<HoverChevron />
|
<HoverChevron />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -83,6 +94,7 @@ export function SiteCell({
|
||||||
pocPhone={pocPhone}
|
pocPhone={pocPhone}
|
||||||
pocNotes={pocNotes}
|
pocNotes={pocNotes}
|
||||||
sites={sites}
|
sites={sites}
|
||||||
|
createMode={createMode}
|
||||||
onSave={onSave}
|
onSave={onSave}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -9,37 +9,77 @@ type SiteDialogPocFieldsProps = {
|
||||||
pocName: string;
|
pocName: string;
|
||||||
pocPhone: string;
|
pocPhone: string;
|
||||||
pocNotes: string;
|
pocNotes: string;
|
||||||
|
editable?: boolean;
|
||||||
|
showErrors?: boolean;
|
||||||
|
onPocNameChange?: (value: string) => void;
|
||||||
|
onPocPhoneChange?: (value: string) => void;
|
||||||
|
onPocNotesChange?: (value: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SiteDialogPocFields({ pocName, pocPhone, pocNotes }: SiteDialogPocFieldsProps) {
|
export function SiteDialogPocFields({
|
||||||
|
pocName,
|
||||||
|
pocPhone,
|
||||||
|
pocNotes,
|
||||||
|
editable = false,
|
||||||
|
showErrors = false,
|
||||||
|
onPocNameChange,
|
||||||
|
onPocPhoneChange,
|
||||||
|
onPocNotesChange,
|
||||||
|
}: SiteDialogPocFieldsProps) {
|
||||||
|
const nameMissing = showErrors && !pocName.trim();
|
||||||
|
const phoneMissing = showErrors && !pocPhone.trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pt-1">
|
<div className="pt-1">
|
||||||
<FieldLabel>Point of Contact</FieldLabel>
|
<FieldLabel>Point of Contact</FieldLabel>
|
||||||
<Text as="p" className="mb-2 text-[11px]" style={{ color: "var(--color-text-muted)" }}>
|
<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.
|
{editable
|
||||||
|
? "Auto-filled from records — edit if outdated"
|
||||||
|
: "Shown from site/work order records. Board edit saves site code only."}
|
||||||
</Text>
|
</Text>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<input
|
<div>
|
||||||
value={pocName}
|
<input
|
||||||
readOnly
|
value={pocName}
|
||||||
placeholder="POC name"
|
readOnly={!editable}
|
||||||
className={DLG_INPUT_CLS}
|
placeholder="POC name"
|
||||||
style={{ ...DLG_INPUT_STYLE, opacity: 0.85 }}
|
className={DLG_INPUT_CLS}
|
||||||
/>
|
style={{
|
||||||
<input
|
...DLG_INPUT_STYLE,
|
||||||
value={pocPhone}
|
opacity: editable ? 1 : 0.85,
|
||||||
readOnly
|
borderColor: nameMissing ? "var(--destructive)" : undefined,
|
||||||
placeholder="POC phone"
|
}}
|
||||||
className={DLG_INPUT_CLS}
|
onChange={(e) => onPocNameChange?.(e.target.value)}
|
||||||
style={{ ...DLG_INPUT_STYLE, opacity: 0.85 }}
|
/>
|
||||||
/>
|
<Text as="p" variant="error" when={nameMissing} className="mt-1 text-[11px]">
|
||||||
|
Contact name is required
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
value={pocPhone}
|
||||||
|
readOnly={!editable}
|
||||||
|
placeholder="POC phone"
|
||||||
|
className={DLG_INPUT_CLS}
|
||||||
|
style={{
|
||||||
|
...DLG_INPUT_STYLE,
|
||||||
|
opacity: editable ? 1 : 0.85,
|
||||||
|
borderColor: phoneMissing ? "var(--destructive)" : undefined,
|
||||||
|
}}
|
||||||
|
onChange={(e) => onPocPhoneChange?.(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Text as="p" variant="error" when={phoneMissing} className="mt-1 text-[11px]">
|
||||||
|
Contact phone is required
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
value={pocNotes}
|
value={pocNotes}
|
||||||
readOnly
|
readOnly={!editable}
|
||||||
placeholder="Notes…"
|
placeholder="Notes…"
|
||||||
rows={2}
|
rows={2}
|
||||||
className={`${DLG_INPUT_CLS} resize-none`}
|
className={`${DLG_INPUT_CLS} resize-none`}
|
||||||
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit", opacity: 0.85 }}
|
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit", opacity: editable ? 1 : 0.85 }}
|
||||||
|
onChange={(e) => onPocNotesChange?.(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,20 @@
|
||||||
import { useEffect, useState, type MouseEvent } from "react";
|
import type { MouseEvent } from "react";
|
||||||
import { MapPin } 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 { 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 { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
|
|
||||||
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
|
||||||
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 { 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 { 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";
|
import { SiteDialogPocFields } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-poc-fields";
|
||||||
|
import {
|
||||||
|
useSiteDialogState,
|
||||||
|
type SitePatch,
|
||||||
|
} from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state";
|
||||||
|
|
||||||
export type SitePatch = {
|
export type { SitePatch };
|
||||||
site: string;
|
|
||||||
location: string;
|
|
||||||
locationId: string | number;
|
|
||||||
pocName: string;
|
|
||||||
pocPhone: string;
|
|
||||||
pocNotes: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type SiteDialogProps = {
|
type SiteDialogProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -35,6 +25,7 @@ type SiteDialogProps = {
|
||||||
pocPhone?: string;
|
pocPhone?: string;
|
||||||
pocNotes?: string;
|
pocNotes?: string;
|
||||||
sites: LocationOption[];
|
sites: LocationOption[];
|
||||||
|
createMode?: boolean;
|
||||||
onSave: (patch: SitePatch) => void;
|
onSave: (patch: SitePatch) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -47,62 +38,26 @@ export function SiteDialog({
|
||||||
pocPhone = "",
|
pocPhone = "",
|
||||||
pocNotes = "",
|
pocNotes = "",
|
||||||
sites,
|
sites,
|
||||||
|
createMode = false,
|
||||||
onSave,
|
onSave,
|
||||||
}: SiteDialogProps) {
|
}: SiteDialogProps) {
|
||||||
const [code, setCode] = useState(value);
|
const s = useSiteDialogState({
|
||||||
const [locId, setLocId] = useState(() => resolveLocationId(locationId, value, sites));
|
open,
|
||||||
const [pn, setPn] = useState(pocName);
|
onOpenChange,
|
||||||
const [pp, setPp] = useState(pocPhone);
|
value,
|
||||||
const [notes, setNotes] = useState(pocNotes);
|
locationId,
|
||||||
const [pocFilledFor, setPocFilledFor] = useState("");
|
pocName,
|
||||||
|
pocPhone,
|
||||||
const {
|
pocNotes,
|
||||||
data: locationDetail,
|
sites,
|
||||||
isLoading: locationDetailLoading,
|
createMode,
|
||||||
isError: locationDetailError,
|
onSave,
|
||||||
} = useLocationDetail(open && locId ? locId : undefined);
|
});
|
||||||
|
|
||||||
const selected = sites.find((s) => String(s.id) === locId);
|
|
||||||
const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const resolved = resolveLocationId(locationId, value, sites);
|
|
||||||
setCode(value);
|
|
||||||
setLocId(resolved);
|
|
||||||
setPn(pocName);
|
|
||||||
setPp(pocPhone);
|
|
||||||
setNotes(pocNotes);
|
|
||||||
// Preserve existing WO POC; allow one auto-fill from site detail when POC is empty.
|
|
||||||
setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : "");
|
|
||||||
}, [open, value, locationId, pocName, pocPhone, pocNotes, sites]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open || !locId || !locationDetail || pocFilledFor === locId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setPn(locationDetail.contact ?? "");
|
|
||||||
setPp(locationDetail.phone ?? "");
|
|
||||||
setPocFilledFor(locId);
|
|
||||||
}, [open, locId, locationDetail, pocFilledFor]);
|
|
||||||
|
|
||||||
const handlePick = (id: string) => {
|
|
||||||
const next = sites.find((s) => String(s.id) === id);
|
|
||||||
setLocId(id);
|
|
||||||
if (next) {
|
|
||||||
setCode(resolveSiteCode(next));
|
|
||||||
}
|
|
||||||
if (id !== pocFilledFor) {
|
|
||||||
setPocFilledFor("");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
onClose={() => onOpenChange(false)}
|
onClose={s.close}
|
||||||
maxWidth={false}
|
maxWidth={false}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
...dialogBlurBackdropSlotProps,
|
...dialogBlurBackdropSlotProps,
|
||||||
|
|
@ -125,7 +80,7 @@ export function SiteDialog({
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel>Site</FieldLabel>
|
<FieldLabel>Site</FieldLabel>
|
||||||
<WizardFieldSelect
|
<WizardFieldSelect
|
||||||
value={locId}
|
value={s.locId}
|
||||||
placeholder="Select site …"
|
placeholder="Select site …"
|
||||||
icon={
|
icon={
|
||||||
<MapPin
|
<MapPin
|
||||||
|
|
@ -139,45 +94,45 @@ export function SiteDialog({
|
||||||
key: String(site.id),
|
key: String(site.id),
|
||||||
label: site.name,
|
label: site.name,
|
||||||
}))}
|
}))}
|
||||||
onPick={handlePick}
|
onPick={s.handlePick}
|
||||||
/>
|
/>
|
||||||
|
<Text
|
||||||
|
as="p"
|
||||||
|
variant="error"
|
||||||
|
when={Boolean(createMode && s.showErrors && s.siteMissing)}
|
||||||
|
className="mt-1 text-[11px]"
|
||||||
|
>
|
||||||
|
Site is required
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{Boolean(locId) && (
|
{Boolean(s.locId) && (
|
||||||
<SiteDialogDetailPanel
|
<SiteDialogDetailPanel
|
||||||
locationDetail={locationDetail}
|
locationDetail={s.locationDetail}
|
||||||
locationDetailLoading={locationDetailLoading}
|
locationDetailLoading={s.locationDetailLoading}
|
||||||
locationDetailError={locationDetailError}
|
locationDetailError={s.locationDetailError}
|
||||||
addressPreview={addressPreview}
|
addressPreview={s.addressPreview}
|
||||||
fallbackLabel={selected?.name || code || "—"}
|
fallbackLabel={s.selected?.name || s.code || "—"}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SiteDialogPocFields pocName={pn} pocPhone={pp} pocNotes={notes} />
|
<SiteDialogPocFields
|
||||||
|
pocName={s.pn}
|
||||||
|
pocPhone={s.pp}
|
||||||
|
pocNotes={s.notes}
|
||||||
|
editable={createMode}
|
||||||
|
showErrors={createMode && s.showErrors}
|
||||||
|
onPocNameChange={s.setPn}
|
||||||
|
onPocPhoneChange={s.setPp}
|
||||||
|
onPocNotesChange={s.setNotes}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center justify-end gap-2">
|
<div className="mt-4 flex items-center justify-end gap-2">
|
||||||
<Button
|
<Button variant="ghost" className="min-h-0 px-2 py-1 text-[12px]" onClick={s.close}>
|
||||||
variant="ghost"
|
|
||||||
className="min-h-0 px-2 py-1 text-[12px]"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button className="min-h-0 px-3 py-1 text-[12px]" onClick={s.attemptSave}>
|
||||||
className="min-h-0 px-3 py-1 text-[12px]"
|
|
||||||
onClick={() => {
|
|
||||||
onSave({
|
|
||||||
site: code || (selected ? resolveSiteCode(selected) : ""),
|
|
||||||
location: selected ? extractLocationLabel(selected) : "",
|
|
||||||
locationId: locId,
|
|
||||||
pocName: pn,
|
|
||||||
pocPhone: pp,
|
|
||||||
pocNotes: notes,
|
|
||||||
});
|
|
||||||
onOpenChange(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
|
||||||
|
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||||
|
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||||
|
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||||
|
import {
|
||||||
|
extractLocationLabel,
|
||||||
|
resolveLocationId,
|
||||||
|
} from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
|
||||||
|
|
||||||
|
export type SitePatch = {
|
||||||
|
site: string;
|
||||||
|
location: string;
|
||||||
|
locationId: string | number;
|
||||||
|
pocName: string;
|
||||||
|
pocPhone: string;
|
||||||
|
pocNotes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UseSiteDialogStateArgs = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
value: string;
|
||||||
|
locationId: string | number;
|
||||||
|
pocName: string;
|
||||||
|
pocPhone: string;
|
||||||
|
pocNotes: string;
|
||||||
|
sites: LocationOption[];
|
||||||
|
createMode: boolean;
|
||||||
|
onSave: (patch: SitePatch) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useSiteDialogState({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
value,
|
||||||
|
locationId,
|
||||||
|
pocName,
|
||||||
|
pocPhone,
|
||||||
|
pocNotes,
|
||||||
|
sites,
|
||||||
|
createMode,
|
||||||
|
onSave,
|
||||||
|
}: UseSiteDialogStateArgs) {
|
||||||
|
const [code, setCode] = useState(value);
|
||||||
|
const [locId, setLocId] = useState(() => resolveLocationId(locationId, value, sites));
|
||||||
|
const [pn, setPn] = useState(pocName);
|
||||||
|
const [pp, setPp] = useState(pocPhone);
|
||||||
|
const [notes, setNotes] = useState(pocNotes);
|
||||||
|
const [pocFilledFor, setPocFilledFor] = useState("");
|
||||||
|
const [showErrors, setShowErrors] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: locationDetail,
|
||||||
|
isLoading: locationDetailLoading,
|
||||||
|
isError: locationDetailError,
|
||||||
|
} = useLocationDetail(open && locId ? locId : undefined);
|
||||||
|
|
||||||
|
const selected = sites.find((s) => String(s.id) === locId);
|
||||||
|
const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
|
||||||
|
const siteMissing = !locId;
|
||||||
|
const pocMissing = !pn.trim() || !pp.trim();
|
||||||
|
const canConfirm = !siteMissing && (!createMode || !pocMissing);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resolved = resolveLocationId(locationId, value, sites);
|
||||||
|
setCode(value);
|
||||||
|
setLocId(resolved);
|
||||||
|
setPn(pocName);
|
||||||
|
setPp(pocPhone);
|
||||||
|
setNotes(pocNotes);
|
||||||
|
setShowErrors(false);
|
||||||
|
setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : "");
|
||||||
|
}, [open, value, locationId, pocName, pocPhone, pocNotes, sites]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !locId || !locationDetail || pocFilledFor === locId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only autofill empty fields so typed createMode POC edits are not overwritten.
|
||||||
|
setPn((prev) => (prev.trim() ? prev : (locationDetail.contact ?? "")));
|
||||||
|
setPp((prev) => (prev.trim() ? prev : (locationDetail.phone ?? "")));
|
||||||
|
setPocFilledFor(locId);
|
||||||
|
}, [open, locId, locationDetail, pocFilledFor]);
|
||||||
|
|
||||||
|
const handlePick = (id: string) => {
|
||||||
|
const next = sites.find((s) => String(s.id) === id);
|
||||||
|
const siteChanged = id !== locId;
|
||||||
|
setLocId(id);
|
||||||
|
if (next) {
|
||||||
|
setCode(resolveSiteCode(next));
|
||||||
|
}
|
||||||
|
if (siteChanged) {
|
||||||
|
setPn("");
|
||||||
|
setPp("");
|
||||||
|
setPocFilledFor("");
|
||||||
|
} else if (id !== pocFilledFor) {
|
||||||
|
setPocFilledFor("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const attemptSave = () => {
|
||||||
|
if (createMode && !canConfirm) {
|
||||||
|
setShowErrors(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSave({
|
||||||
|
site: code || (selected ? resolveSiteCode(selected) : ""),
|
||||||
|
location: selected ? extractLocationLabel(selected) : "",
|
||||||
|
locationId: locId,
|
||||||
|
pocName: pn,
|
||||||
|
pocPhone: pp,
|
||||||
|
pocNotes: notes,
|
||||||
|
});
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
locId,
|
||||||
|
pn,
|
||||||
|
pp,
|
||||||
|
notes,
|
||||||
|
showErrors,
|
||||||
|
locationDetail,
|
||||||
|
locationDetailLoading,
|
||||||
|
locationDetailError,
|
||||||
|
selected,
|
||||||
|
addressPreview,
|
||||||
|
siteMissing,
|
||||||
|
canConfirm,
|
||||||
|
code,
|
||||||
|
handlePick,
|
||||||
|
attemptSave,
|
||||||
|
setPn,
|
||||||
|
setPp,
|
||||||
|
setNotes,
|
||||||
|
close: () => onOpenChange(false),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
type InlineCreateFieldProps = {
|
||||||
|
showError?: boolean;
|
||||||
|
error?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Bordered create-cell wrapper; error ring + helper reveal after a failed Save attempt. */
|
||||||
|
export function InlineCreateField({ showError = false, error, children }: InlineCreateFieldProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full min-w-0 flex-col gap-1">
|
||||||
|
<div
|
||||||
|
className="flex w-full min-w-0 items-center"
|
||||||
|
style={{
|
||||||
|
minHeight: 34,
|
||||||
|
borderRadius: 6,
|
||||||
|
padding: "0 8px",
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
border: `1px solid ${showError ? "var(--destructive)" : "var(--color-border)"}`,
|
||||||
|
boxShadow: showError ? "0 0 0 1px var(--destructive)" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<Text as="p" variant="error" when={Boolean(showError && error)} className="m-0 text-[11px]">
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||||
|
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
|
||||||
|
import { SiteCell } from "./cells/site-cell";
|
||||||
|
import { InlineCreateField } from "./inline-create-field";
|
||||||
|
|
||||||
|
type WoTableInlineIdentityCellsProps = {
|
||||||
|
draft: WorkOrderTableDraft;
|
||||||
|
sites: LocationOption[];
|
||||||
|
sitePlace?: { city: string; state: string };
|
||||||
|
allRows: { id: string | number; woNumber: string }[];
|
||||||
|
siteCellRef: RefObject<HTMLTableCellElement | null>;
|
||||||
|
woCellRef: RefObject<HTMLTableCellElement | null>;
|
||||||
|
siteShowError: boolean;
|
||||||
|
siteError: string;
|
||||||
|
woShowError: boolean;
|
||||||
|
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WoTableInlineIdentityCells({
|
||||||
|
draft,
|
||||||
|
sites,
|
||||||
|
sitePlace,
|
||||||
|
allRows,
|
||||||
|
siteCellRef,
|
||||||
|
woCellRef,
|
||||||
|
siteShowError,
|
||||||
|
siteError,
|
||||||
|
woShowError,
|
||||||
|
onPatch,
|
||||||
|
}: WoTableInlineIdentityCellsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<td
|
||||||
|
ref={siteCellRef}
|
||||||
|
className="group/cell"
|
||||||
|
style={{ padding: "8px 6px 0", verticalAlign: "top" }}
|
||||||
|
>
|
||||||
|
<InlineCreateField showError={siteShowError} error={siteError}>
|
||||||
|
<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}
|
||||||
|
createMode
|
||||||
|
onSave={(p) =>
|
||||||
|
onPatch({
|
||||||
|
site: p.site,
|
||||||
|
location: p.location,
|
||||||
|
locationId: p.locationId,
|
||||||
|
pocName: p.pocName,
|
||||||
|
pocPhone: p.pocPhone,
|
||||||
|
pocNotes: p.pocNotes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</InlineCreateField>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td
|
||||||
|
ref={woCellRef}
|
||||||
|
style={{ padding: "8px 6px 0", verticalAlign: "top", whiteSpace: "nowrap" }}
|
||||||
|
>
|
||||||
|
<InlineCreateField showError={woShowError} error="Work order number is required">
|
||||||
|
<EditableWONumberCell
|
||||||
|
value={draft.woNumber}
|
||||||
|
rescheduleCount={0}
|
||||||
|
currentId="draft"
|
||||||
|
allOrders={allRows as never[]}
|
||||||
|
onSave={(v) => onPatch({ woNumber: v })}
|
||||||
|
/>
|
||||||
|
</InlineCreateField>
|
||||||
|
</td>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import { Check, X } from "lucide-react";
|
||||||
type WoTableInlineRowActionsProps = {
|
type WoTableInlineRowActionsProps = {
|
||||||
canSave: boolean;
|
canSave: boolean;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
|
saveHint: string;
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
};
|
};
|
||||||
|
|
@ -11,6 +12,7 @@ type WoTableInlineRowActionsProps = {
|
||||||
export function WoTableInlineRowActions({
|
export function WoTableInlineRowActions({
|
||||||
canSave,
|
canSave,
|
||||||
saving,
|
saving,
|
||||||
|
saveHint,
|
||||||
onSave,
|
onSave,
|
||||||
onCancel,
|
onCancel,
|
||||||
}: WoTableInlineRowActionsProps) {
|
}: WoTableInlineRowActionsProps) {
|
||||||
|
|
@ -19,11 +21,14 @@ export function WoTableInlineRowActions({
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onSave}
|
onClick={onSave}
|
||||||
disabled={!canSave}
|
disabled={saving}
|
||||||
title={saving ? "Saving…" : canSave ? "Save" : "Schedule On is required"}
|
title={saveHint}
|
||||||
aria-label={saving ? "Saving work order" : "Save work order"}
|
aria-label={saving ? "Saving work order" : "Save work order"}
|
||||||
className="rounded p-1 transition-colors disabled:opacity-30"
|
className="rounded p-1 transition-colors disabled:opacity-30"
|
||||||
style={{ color: "var(--color-primary)" }}
|
style={{
|
||||||
|
color: "var(--color-primary)",
|
||||||
|
...(canSave || saving ? {} : { opacity: 0.5, cursor: "not-allowed" }),
|
||||||
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (canSave) {
|
if (canSave) {
|
||||||
(e.currentTarget as HTMLButtonElement).style.background = "var(--color-primary-tint)";
|
(e.currentTarget as HTMLButtonElement).style.background = "var(--color-primary-tint)";
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { RefObject } from "react";
|
||||||
import type {
|
import type {
|
||||||
LocationOption,
|
LocationOption,
|
||||||
VendorDropdownItem,
|
VendorDropdownItem,
|
||||||
|
|
@ -8,13 +9,12 @@ import { AssignedToCell } from "./cells/assigned-to-cell";
|
||||||
import { ApptTimeCell } from "./cells/appt-time-cell";
|
import { ApptTimeCell } from "./cells/appt-time-cell";
|
||||||
import { DateCell } from "./cells/date-cell";
|
import { DateCell } from "./cells/date-cell";
|
||||||
import { DueDateCell } from "./cells/due-date-cell";
|
import { DueDateCell } from "./cells/due-date-cell";
|
||||||
import { EditableWONumberCell } from "./cells/editable-wo-number-cell";
|
|
||||||
import { PMTypeCell } from "./cells/pm-type-cell";
|
import { PMTypeCell } from "./cells/pm-type-cell";
|
||||||
import { SiteCell } from "./cells/site-cell";
|
|
||||||
import { StatusCell } from "./cells/status-cell";
|
import { StatusCell } from "./cells/status-cell";
|
||||||
import { TypeCell } from "./cells/type-cell";
|
import { TypeCell } from "./cells/type-cell";
|
||||||
import { VendorCell } from "./cells/vendor-cell";
|
import { VendorCell } from "./cells/vendor-cell";
|
||||||
import { toInlineDraftRow } from "./to-inline-draft-row";
|
import { toInlineDraftRow } from "./to-inline-draft-row";
|
||||||
|
import { WoTableInlineIdentityCells } from "./wo-table-inline-identity-cells";
|
||||||
|
|
||||||
type WoTableInlineRowCellsProps = {
|
type WoTableInlineRowCellsProps = {
|
||||||
draft: WorkOrderTableDraft;
|
draft: WorkOrderTableDraft;
|
||||||
|
|
@ -25,6 +25,11 @@ type WoTableInlineRowCellsProps = {
|
||||||
vendors: VendorDropdownItem[];
|
vendors: VendorDropdownItem[];
|
||||||
pmTypes: string[];
|
pmTypes: string[];
|
||||||
allRows: { id: string | number; woNumber: string }[];
|
allRows: { id: string | number; woNumber: string }[];
|
||||||
|
siteCellRef: RefObject<HTMLTableCellElement | null>;
|
||||||
|
woCellRef: RefObject<HTMLTableCellElement | null>;
|
||||||
|
siteShowError: boolean;
|
||||||
|
siteError: string;
|
||||||
|
woShowError: boolean;
|
||||||
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
|
onPatch: (patch: Partial<WorkOrderTableDraft>) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -45,45 +50,30 @@ export function WoTableInlineRowCells({
|
||||||
vendors,
|
vendors,
|
||||||
pmTypes,
|
pmTypes,
|
||||||
allRows,
|
allRows,
|
||||||
|
siteCellRef,
|
||||||
|
woCellRef,
|
||||||
|
siteShowError,
|
||||||
|
siteError,
|
||||||
|
woShowError,
|
||||||
onPatch,
|
onPatch,
|
||||||
}: WoTableInlineRowCellsProps) {
|
}: WoTableInlineRowCellsProps) {
|
||||||
const draftRow = toInlineDraftRow(draft);
|
const draftRow = toInlineDraftRow(draft);
|
||||||
|
const hasSchedule = Boolean(draft.scheduledOn) || Boolean(draft.targetWeek);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<td className="group/cell" style={{ padding: "0 14px" }}>
|
<WoTableInlineIdentityCells
|
||||||
<SiteCell
|
draft={draft}
|
||||||
value={draft.site}
|
sites={sites}
|
||||||
location={draft.location}
|
sitePlace={sitePlace}
|
||||||
locationId={draft.locationId}
|
allRows={allRows}
|
||||||
city={sitePlace?.city}
|
siteCellRef={siteCellRef}
|
||||||
state={sitePlace?.state}
|
woCellRef={woCellRef}
|
||||||
pocName={draft.pocName}
|
siteShowError={siteShowError}
|
||||||
pocPhone={draft.pocPhone}
|
siteError={siteError}
|
||||||
pocNotes={draft.pocNotes}
|
woShowError={woShowError}
|
||||||
sites={sites}
|
onPatch={onPatch}
|
||||||
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" }}>
|
<td className="group/cell" style={{ padding: "0 14px" }}>
|
||||||
<TypeCell value={draft.type} onSave={(t) => onPatch({ type: t })} />
|
<TypeCell value={draft.type} onSave={(t) => onPatch({ type: t })} />
|
||||||
|
|
@ -106,11 +96,15 @@ export function WoTableInlineRowCells({
|
||||||
allowWeekMode
|
allowWeekMode
|
||||||
targetWeek={draft.targetWeek}
|
targetWeek={draft.targetWeek}
|
||||||
defaultMode={weekDefault ? "week" : "date"}
|
defaultMode={weekDefault ? "week" : "date"}
|
||||||
highlight={!draft.scheduledOn && !draft.targetWeek}
|
|
||||||
onSave={(date) => onPatch({ scheduledOn: date, scheduleWeekOnly: false, targetWeek: "" })}
|
onSave={(date) => onPatch({ scheduledOn: date, scheduleWeekOnly: false, targetWeek: "" })}
|
||||||
onSaveWeek={(week) =>
|
onSaveWeek={(week) =>
|
||||||
onPatch({ scheduledOn: "", targetWeek: week, scheduleWeekOnly: true })
|
onPatch({ scheduledOn: "", targetWeek: week, scheduleWeekOnly: true })
|
||||||
}
|
}
|
||||||
|
onClear={
|
||||||
|
hasSchedule
|
||||||
|
? () => onPatch({ scheduledOn: "", targetWeek: "", scheduleWeekOnly: false })
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useRef, useState, type RefObject } from "react";
|
||||||
import { Flag } from "lucide-react";
|
import { Flag } from "lucide-react";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -5,10 +6,14 @@ import type {
|
||||||
VendorDropdownItem,
|
VendorDropdownItem,
|
||||||
WorkOrderUser,
|
WorkOrderUser,
|
||||||
} from "@/domain/work-orders/types/work-order";
|
} from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableDraft } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
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 {
|
||||||
|
draftHasScheduleDate,
|
||||||
|
getInlineCreateMissing,
|
||||||
|
inlineCreateSaveHint,
|
||||||
|
inlineCreateSiteError,
|
||||||
|
} from "@/domain/work-orders/utils/get-inline-create-missing";
|
||||||
|
|
||||||
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 { WoTableInlineRowCells } from "./wo-table-inline-row-cells";
|
||||||
|
|
@ -16,72 +21,86 @@ import { WoTableInlineRowActions } from "./wo-table-inline-row-actions";
|
||||||
|
|
||||||
type WoTableInlineRowProps = {
|
type WoTableInlineRowProps = {
|
||||||
draft: WorkOrderTableDraft;
|
draft: WorkOrderTableDraft;
|
||||||
|
|
||||||
draftDayKey: string;
|
draftDayKey: string;
|
||||||
|
|
||||||
users: WorkOrderUser[];
|
users: WorkOrderUser[];
|
||||||
|
|
||||||
sites: LocationOption[];
|
sites: LocationOption[];
|
||||||
|
|
||||||
sitePlace?: { city: string; state: string };
|
sitePlace?: { city: string; state: string };
|
||||||
|
|
||||||
vendors: VendorDropdownItem[];
|
vendors: VendorDropdownItem[];
|
||||||
|
|
||||||
pmTypes: string[];
|
pmTypes: string[];
|
||||||
|
|
||||||
allRows: { id: string | number; woNumber: string }[];
|
allRows: { id: string | number; woNumber: string }[];
|
||||||
|
|
||||||
saving?: boolean;
|
saving?: boolean;
|
||||||
|
|
||||||
onChange: (draft: WorkOrderTableDraft) => void;
|
onChange: (draft: WorkOrderTableDraft) => void;
|
||||||
|
|
||||||
onSave: () => void;
|
onSave: () => void;
|
||||||
|
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PRE_SCHEDULE = new Set(["Incomplete", "Pending", ""]);
|
||||||
|
|
||||||
|
function withScheduleStatusSync(next: WorkOrderTableDraft): WorkOrderTableDraft {
|
||||||
|
const dated = draftHasScheduleDate(next);
|
||||||
|
if (dated && PRE_SCHEDULE.has(next.status)) {
|
||||||
|
return { ...next, status: "Scheduled" };
|
||||||
|
}
|
||||||
|
if (!dated && next.status === "Scheduled") {
|
||||||
|
return { ...next, status: "Pending" };
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
export function WoTableInlineRow({
|
export function WoTableInlineRow({
|
||||||
draft,
|
draft,
|
||||||
|
|
||||||
draftDayKey,
|
draftDayKey,
|
||||||
|
|
||||||
users,
|
users,
|
||||||
|
|
||||||
sites,
|
sites,
|
||||||
|
|
||||||
sitePlace,
|
sitePlace,
|
||||||
|
|
||||||
vendors,
|
vendors,
|
||||||
|
|
||||||
pmTypes,
|
pmTypes,
|
||||||
|
|
||||||
allRows,
|
allRows,
|
||||||
|
|
||||||
saving = false,
|
saving = false,
|
||||||
|
|
||||||
onChange,
|
onChange,
|
||||||
|
|
||||||
onSave,
|
onSave,
|
||||||
|
|
||||||
onCancel,
|
onCancel,
|
||||||
}: WoTableInlineRowProps) {
|
}: WoTableInlineRowProps) {
|
||||||
const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY;
|
const weekDefault = draftDayKey === UNSCHEDULED_DAY_KEY;
|
||||||
|
const missing = getInlineCreateMissing(draft);
|
||||||
|
const canSave = !saving && missing.length === 0;
|
||||||
|
const [showErrors, setShowErrors] = useState(false);
|
||||||
|
const siteCellRef = useRef<HTMLTableCellElement>(null);
|
||||||
|
const woCellRef = useRef<HTMLTableCellElement>(null);
|
||||||
|
|
||||||
const canSave =
|
const siteCellMissing = missing.some((f) => f === "site" || f === "pocName" || f === "pocPhone");
|
||||||
!saving && (weekDefault ? !!draft.targetWeek || !!draft.scheduledOn : !!draft.scheduledOn);
|
const siteShowError = showErrors && siteCellMissing;
|
||||||
|
const woShowError = showErrors && missing.includes("woNumber");
|
||||||
|
|
||||||
const patch = (p: Partial<WorkOrderTableDraft>) => onChange({ ...draft, ...p });
|
const patch = (p: Partial<WorkOrderTableDraft>) =>
|
||||||
|
onChange(withScheduleStatusSync({ ...draft, ...p }));
|
||||||
|
|
||||||
|
const focusCell = (r: RefObject<HTMLTableCellElement | null>) =>
|
||||||
|
r.current?.querySelector<HTMLElement>("input, button, [tabindex]")?.focus();
|
||||||
|
|
||||||
|
const attemptSave = () => {
|
||||||
|
if (saving) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (canSave) {
|
||||||
|
onSave();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowErrors(true);
|
||||||
|
if (siteCellMissing) {
|
||||||
|
focusCell(siteCellRef);
|
||||||
|
} else if (missing.includes("woNumber")) {
|
||||||
|
focusCell(woCellRef);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
aria-busy={saving || undefined}
|
aria-busy={saving || undefined}
|
||||||
style={{
|
style={{
|
||||||
height: WO_TABLE_ROW_HEIGHT,
|
height: WO_TABLE_ROW_HEIGHT,
|
||||||
|
|
||||||
borderBottom: "1px solid var(--color-border)",
|
borderBottom: "1px solid var(--color-border)",
|
||||||
|
|
||||||
borderLeft: "2px solid var(--color-primary)",
|
borderLeft: "2px solid var(--color-primary)",
|
||||||
|
|
||||||
background: "var(--color-primary-tint)",
|
background: "var(--color-primary-tint)",
|
||||||
opacity: saving ? 0.7 : 1,
|
opacity: saving ? 0.7 : 1,
|
||||||
pointerEvents: saving ? "none" : undefined,
|
pointerEvents: saving ? "none" : undefined,
|
||||||
|
|
@ -102,6 +121,11 @@ export function WoTableInlineRow({
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
pmTypes={pmTypes}
|
pmTypes={pmTypes}
|
||||||
allRows={allRows}
|
allRows={allRows}
|
||||||
|
siteCellRef={siteCellRef}
|
||||||
|
woCellRef={woCellRef}
|
||||||
|
siteShowError={siteShowError}
|
||||||
|
siteError={inlineCreateSiteError(missing)}
|
||||||
|
woShowError={woShowError}
|
||||||
onPatch={patch}
|
onPatch={patch}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -109,7 +133,8 @@ export function WoTableInlineRow({
|
||||||
<WoTableInlineRowActions
|
<WoTableInlineRowActions
|
||||||
canSave={canSave}
|
canSave={canSave}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
onSave={onSave}
|
saveHint={saving ? "Saving…" : inlineCreateSaveHint(missing)}
|
||||||
|
onSave={attemptSave}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
type WorkOrderCancelDialogProps = {
|
||||||
|
row: WorkOrderTableRow | null;
|
||||||
|
isPending: boolean;
|
||||||
|
onDialogClose: (() => void) | undefined;
|
||||||
|
onKeepWO: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkOrderCancelDialog({
|
||||||
|
row,
|
||||||
|
isPending,
|
||||||
|
onDialogClose,
|
||||||
|
onKeepWO,
|
||||||
|
onConfirm,
|
||||||
|
}: WorkOrderCancelDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={Boolean(row)} onClose={onDialogClose} slotProps={dialogBlurBackdropSlotProps}>
|
||||||
|
<DialogTitle>Cancel work order?</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
Cancel WO #{row?.woNumber}? The record will be kept with status Canceled.
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button variant="outline" onClick={onKeepWO} disabled={isPending}>
|
||||||
|
Keep WO
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={onConfirm}
|
||||||
|
loading={isPending}
|
||||||
|
loadingText="Canceling…"
|
||||||
|
>
|
||||||
|
Cancel WO
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -38,10 +38,8 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
|
||||||
const categorizeMutation = useUpdateWorkOrderMediaCategory(row.id);
|
const categorizeMutation = useUpdateWorkOrderMediaCategory(row.id);
|
||||||
const deleteMutation = useDeleteWorkOrderMedia(row.id);
|
const deleteMutation = useDeleteWorkOrderMedia(row.id);
|
||||||
const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
|
const isReadOnly = isWorkOrderDocumentsReadOnly(detail?.info.status ?? row.status);
|
||||||
const { uploads, startUploads, retryUpload, removeLocal, isLocal } = useMediaUploadSession(
|
const { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal } =
|
||||||
detail,
|
useMediaUploadSession(detail, (input) => uploadMutation.mutateAsync(input));
|
||||||
(input) => uploadMutation.mutateAsync(input),
|
|
||||||
);
|
|
||||||
|
|
||||||
const pending =
|
const pending =
|
||||||
uploadMutation.isPending || deleteMutation.isPending || categorizeMutation.isPending;
|
uploadMutation.isPending || deleteMutation.isPending || categorizeMutation.isPending;
|
||||||
|
|
@ -109,7 +107,7 @@ export function MediaUploadModal({ row, open, onOpenChange }: MediaUploadModalPr
|
||||||
categorizeMutation.mutate({ mediaId: id, category, workOrderVersion });
|
categorizeMutation.mutate({ mediaId: id, category, workOrderVersion });
|
||||||
}}
|
}}
|
||||||
onRetry={retryUpload}
|
onRetry={retryUpload}
|
||||||
onCancel={removeLocal}
|
onCancel={cancelUpload}
|
||||||
readOnly={isReadOnly}
|
readOnly={isReadOnly}
|
||||||
pending={pending}
|
pending={pending}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useRef, useState } from "react";
|
import { useRef } from "react";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
|
|
@ -15,17 +15,16 @@ import { MediaUploaderGrid } from "./media-uploader-grid";
|
||||||
export type MediaUploadRequest = {
|
export type MediaUploadRequest = {
|
||||||
localId: string;
|
localId: string;
|
||||||
file: File;
|
file: File;
|
||||||
signal: AbortSignal;
|
|
||||||
onProgress: (percent: number) => void;
|
onProgress: (percent: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MediaUploaderProps = {
|
type MediaUploaderProps = {
|
||||||
uploads: MediaFile[];
|
uploads: MediaFile[];
|
||||||
/** Upload-then-categorize: files only — category assigned after persist. */
|
/** Upload-then-categorize: files only ÔÇö category assigned after persist. */
|
||||||
onUpload?: (requests: MediaUploadRequest[]) => void;
|
onUpload?: (requests: MediaUploadRequest[]) => void;
|
||||||
onRemove?: (id: string) => void;
|
onRemove?: (id: string) => void;
|
||||||
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
onCategorize?: (id: string, category: UploadableMediaCategory) => void;
|
||||||
onRetry?: (localId: string, signal: AbortSignal) => void;
|
onRetry?: (localId: string) => void;
|
||||||
onCancel?: (localId: string) => void;
|
onCancel?: (localId: string) => void;
|
||||||
/** Legacy local-only mode (table modal fallback). */
|
/** Legacy local-only mode (table modal fallback). */
|
||||||
onChange?: (next: MediaFile[]) => void;
|
onChange?: (next: MediaFile[]) => void;
|
||||||
|
|
@ -45,7 +44,6 @@ export function MediaUploader({
|
||||||
pending = false,
|
pending = false,
|
||||||
}: MediaUploaderProps) {
|
}: MediaUploaderProps) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [controllers, setControllers] = useState<Record<string, AbortController>>({});
|
|
||||||
|
|
||||||
const addFiles = (files: FileList | null) => {
|
const addFiles = (files: FileList | null) => {
|
||||||
if (readOnly || pending || !files || files.length === 0) return;
|
if (readOnly || pending || !files || files.length === 0) return;
|
||||||
|
|
@ -65,19 +63,11 @@ export function MediaUploader({
|
||||||
if (accepted.length === 0) return;
|
if (accepted.length === 0) return;
|
||||||
|
|
||||||
if (onUpload) {
|
if (onUpload) {
|
||||||
const nextControllers: Record<string, AbortController> = { ...controllers };
|
const requests: MediaUploadRequest[] = accepted.map((file) => ({
|
||||||
const requests: MediaUploadRequest[] = accepted.map((file) => {
|
localId: `local-${crypto.randomUUID()}`,
|
||||||
const localId = `local-${crypto.randomUUID()}`;
|
file,
|
||||||
const controller = new AbortController();
|
onProgress: () => undefined,
|
||||||
nextControllers[localId] = controller;
|
}));
|
||||||
return {
|
|
||||||
localId,
|
|
||||||
file,
|
|
||||||
signal: controller.signal,
|
|
||||||
onProgress: () => undefined,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
setControllers(nextControllers);
|
|
||||||
onUpload(requests);
|
onUpload(requests);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -98,18 +88,6 @@ export function MediaUploader({
|
||||||
onChange?.(uploads.filter((u) => u.id !== id));
|
onChange?.(uploads.filter((u) => u.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const cancel = (id: string) => {
|
|
||||||
controllers[id]?.abort();
|
|
||||||
onCancel?.(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const retry = (id: string) => {
|
|
||||||
controllers[id]?.abort();
|
|
||||||
const controller = new AbortController();
|
|
||||||
setControllers((prev) => ({ ...prev, [id]: controller }));
|
|
||||||
onRetry?.(id, controller.signal);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
|
|
@ -121,8 +99,8 @@ export function MediaUploader({
|
||||||
pending={pending}
|
pending={pending}
|
||||||
onRemove={remove}
|
onRemove={remove}
|
||||||
onCategorize={onCategorize}
|
onCategorize={onCategorize}
|
||||||
onRetry={onRetry ? retry : undefined}
|
onRetry={onRetry}
|
||||||
onCancel={cancel}
|
onCancel={onCancel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
import type { MediaUploadRequest } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
||||||
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||||
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
|
|
@ -13,13 +13,13 @@ type UploadMutate = (input: {
|
||||||
onProgress?: (percent: number) => void;
|
onProgress?: (percent: number) => void;
|
||||||
}) => Promise<unknown>;
|
}) => Promise<unknown>;
|
||||||
|
|
||||||
function revokeBlobUrl(url: string | undefined) {
|
type SessionUploadRequest = {
|
||||||
if (url?.startsWith("blob:")) {
|
localId: string;
|
||||||
URL.revokeObjectURL(url);
|
file: File;
|
||||||
}
|
signal: AbortSignal;
|
||||||
}
|
};
|
||||||
|
|
||||||
function toLocalUpload(request: MediaUploadRequest): LocalUpload {
|
function toLocalUpload(request: SessionUploadRequest): LocalUpload {
|
||||||
const classified = classifyUpload(request.file);
|
const classified = classifyUpload(request.file);
|
||||||
return {
|
return {
|
||||||
id: request.localId,
|
id: request.localId,
|
||||||
|
|
@ -35,15 +35,44 @@ function toLocalUpload(request: MediaUploadRequest): LocalUpload {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSameAttempt(item: LocalUpload, request: MediaUploadRequest) {
|
function isSameAttempt(item: LocalUpload, request: SessionUploadRequest) {
|
||||||
return item.id === request.localId && item.signal === request.signal;
|
return item.id === request.localId && item.signal === request.signal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function revokeBlobUrl(url: string) {
|
||||||
|
if (url.startsWith("blob:")) {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useMediaUploadSession(
|
export function useMediaUploadSession(
|
||||||
detail: WorkOrderBoardDetail | undefined,
|
detail: WorkOrderBoardDetail | undefined,
|
||||||
uploadMutateAsync: UploadMutate,
|
uploadMutateAsync: UploadMutate,
|
||||||
) {
|
) {
|
||||||
const [localUploads, setLocalUploads] = useState<LocalUpload[]>([]);
|
const [localUploads, setLocalUploads] = useState<LocalUpload[]>([]);
|
||||||
|
const controllersRef = useRef(new Map<string, AbortController>());
|
||||||
|
const workOrderId = detail?.info.id;
|
||||||
|
const previousWorkOrderIdRef = useRef(workOrderId);
|
||||||
|
|
||||||
|
// Drop local tiles and abort in-flight uploads when the bound WO changes.
|
||||||
|
// Skip the first bind (undefined  id) so an early upload is not cleared.
|
||||||
|
useEffect(() => {
|
||||||
|
if (previousWorkOrderIdRef.current === workOrderId) return;
|
||||||
|
const hadPrevious = previousWorkOrderIdRef.current != null;
|
||||||
|
previousWorkOrderIdRef.current = workOrderId;
|
||||||
|
if (!hadPrevious) return;
|
||||||
|
|
||||||
|
for (const controller of controllersRef.current.values()) {
|
||||||
|
controller.abort();
|
||||||
|
}
|
||||||
|
controllersRef.current.clear();
|
||||||
|
setLocalUploads((prev) => {
|
||||||
|
for (const item of prev) {
|
||||||
|
revokeBlobUrl(item.url);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
}, [workOrderId]);
|
||||||
|
|
||||||
const serverUploads = useMemo(
|
const serverUploads = useMemo(
|
||||||
() => boardMediaItemsToMediaFiles(detail?.media ?? []),
|
() => boardMediaItemsToMediaFiles(detail?.media ?? []),
|
||||||
|
|
@ -58,15 +87,34 @@ export function useMediaUploadSession(
|
||||||
return [...serverUploads, ...pendingLocals.filter((item) => !serverIds.has(item.id))];
|
return [...serverUploads, ...pendingLocals.filter((item) => !serverIds.has(item.id))];
|
||||||
}, [serverUploads, localUploads]);
|
}, [serverUploads, localUploads]);
|
||||||
|
|
||||||
|
function clearController(localId: string) {
|
||||||
|
controllersRef.current.delete(localId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceController(localId: string): AbortController {
|
||||||
|
controllersRef.current.get(localId)?.abort();
|
||||||
|
const controller = new AbortController();
|
||||||
|
controllersRef.current.set(localId, controller);
|
||||||
|
return controller;
|
||||||
|
}
|
||||||
|
|
||||||
function startUploads(requests: MediaUploadRequest[]) {
|
function startUploads(requests: MediaUploadRequest[]) {
|
||||||
|
const sessionRequests: SessionUploadRequest[] = requests.map((request) => ({
|
||||||
|
localId: request.localId,
|
||||||
|
file: request.file,
|
||||||
|
signal: replaceController(request.localId).signal,
|
||||||
|
}));
|
||||||
|
|
||||||
setLocalUploads((prev) => {
|
setLocalUploads((prev) => {
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
for (const request of requests) {
|
for (const request of sessionRequests) {
|
||||||
const entry = toLocalUpload(request);
|
const entry = toLocalUpload(request);
|
||||||
const index = next.findIndex((item) => item.id === request.localId);
|
const index = next.findIndex((item) => item.id === request.localId);
|
||||||
if (index >= 0) {
|
if (index >= 0) {
|
||||||
const previous = next[index];
|
const previous = next[index];
|
||||||
revokeBlobUrl(previous?.url);
|
if (previous) {
|
||||||
|
revokeBlobUrl(previous.url);
|
||||||
|
}
|
||||||
next[index] = entry;
|
next[index] = entry;
|
||||||
} else {
|
} else {
|
||||||
next.push(entry);
|
next.push(entry);
|
||||||
|
|
@ -75,7 +123,7 @@ export function useMediaUploadSession(
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const request of requests) {
|
for (const request of sessionRequests) {
|
||||||
void uploadMutateAsync({
|
void uploadMutateAsync({
|
||||||
file: request.file,
|
file: request.file,
|
||||||
signal: request.signal,
|
signal: request.signal,
|
||||||
|
|
@ -88,25 +136,16 @@ export function useMediaUploadSession(
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setLocalUploads((prev) => {
|
clearController(request.localId);
|
||||||
const dropped = prev.filter((item) => isSameAttempt(item, request));
|
setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
|
||||||
for (const item of dropped) {
|
|
||||||
revokeBlobUrl(item.url);
|
|
||||||
}
|
|
||||||
return prev.filter((item) => !isSameAttempt(item, request));
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.catch((error: Error) => {
|
.catch((error: Error) => {
|
||||||
if (error.name === "AbortError") {
|
if (error.name === "AbortError") {
|
||||||
setLocalUploads((prev) => {
|
clearController(request.localId);
|
||||||
const dropped = prev.filter((item) => isSameAttempt(item, request));
|
setLocalUploads((prev) => prev.filter((item) => !isSameAttempt(item, request)));
|
||||||
for (const item of dropped) {
|
|
||||||
revokeBlobUrl(item.url);
|
|
||||||
}
|
|
||||||
return prev.filter((item) => !isSameAttempt(item, request));
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Keep controller entry only while retryable; replaceController on retry.
|
||||||
setLocalUploads((prev) =>
|
setLocalUploads((prev) =>
|
||||||
prev.map((item) =>
|
prev.map((item) =>
|
||||||
isSameAttempt(item, request)
|
isSameAttempt(item, request)
|
||||||
|
|
@ -123,32 +162,38 @@ export function useMediaUploadSession(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function retryUpload(localId: string, signal: AbortSignal) {
|
function retryUpload(localId: string) {
|
||||||
const target = localUploads.find((item) => item.id === localId);
|
const target = localUploads.find((item) => item.id === localId);
|
||||||
if (!target?.sourceFile) return;
|
if (!target?.sourceFile) return;
|
||||||
startUploads([
|
startUploads([
|
||||||
{
|
{
|
||||||
localId,
|
localId,
|
||||||
file: target.sourceFile,
|
file: target.sourceFile,
|
||||||
signal,
|
|
||||||
onProgress: () => undefined,
|
onProgress: () => undefined,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeLocal(id: string) {
|
function cancelUpload(localId: string) {
|
||||||
|
controllersRef.current.get(localId)?.abort();
|
||||||
|
clearController(localId);
|
||||||
setLocalUploads((prev) => {
|
setLocalUploads((prev) => {
|
||||||
const dropped = prev.filter((item) => item.id === id);
|
const target = prev.find((item) => item.id === localId);
|
||||||
for (const item of dropped) {
|
if (target) {
|
||||||
revokeBlobUrl(item.url);
|
revokeBlobUrl(target.url);
|
||||||
}
|
}
|
||||||
return prev.filter((item) => item.id !== id);
|
return prev.filter((item) => item.id !== localId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeLocal(id: string) {
|
||||||
|
clearController(id);
|
||||||
|
setLocalUploads((prev) => prev.filter((item) => item.id !== id));
|
||||||
|
}
|
||||||
|
|
||||||
function isLocal(id: string) {
|
function isLocal(id: string) {
|
||||||
return localUploads.some((item) => item.id === id);
|
return localUploads.some((item) => item.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { uploads, startUploads, retryUpload, removeLocal, isLocal };
|
return { uploads, startUploads, retryUpload, cancelUpload, removeLocal, isLocal };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
||||||
|
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
import {
|
||||||
|
useWorkOrderLocations,
|
||||||
|
useWorkOrderVendors,
|
||||||
|
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
|
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
||||||
|
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
||||||
|
import { guardDialogClose } from "@/lib/dialog-guard";
|
||||||
|
import { useWorkOrdersListFilters } from "./use-work-orders-list-filters";
|
||||||
|
import { useWorkOrdersTableData } from "./use-work-orders-table-data";
|
||||||
|
|
||||||
|
export function useWorkOrdersListPage() {
|
||||||
|
const [wizardOpen, setWizardOpen] = useState(false);
|
||||||
|
const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null);
|
||||||
|
const [slideOverEdit, setSlideOverEdit] = useState(false);
|
||||||
|
const [confirmCancel, setConfirmCancel] = useState<WorkOrderTableRow | null>(null);
|
||||||
|
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
|
||||||
|
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
|
||||||
|
const [completionDocs, setCompletionDocs] = useState<Record<string, CompDocData>>({});
|
||||||
|
const filters = useWorkOrdersListFilters();
|
||||||
|
const tableData = useWorkOrdersTableData(filters);
|
||||||
|
const {
|
||||||
|
patchField,
|
||||||
|
patchFieldAsync,
|
||||||
|
isPending: isPatchPending,
|
||||||
|
} = useWorkOrderTableMutations({
|
||||||
|
onPatch: tableData.patchRow,
|
||||||
|
clearPatch: tableData.clearRowPatch,
|
||||||
|
getRow: (id) => tableData.rows.find((row) => String(row.id) === String(id)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: users = [], isLoading: usersLoading } = useWorkOrderUsers();
|
||||||
|
const { data: locations = [] } = useWorkOrderLocations();
|
||||||
|
const { data: vendors = [] } = useWorkOrderVendors();
|
||||||
|
const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem");
|
||||||
|
|
||||||
|
const pmTypes = useMemo(
|
||||||
|
() => problemOptions.map((option) => option.value).filter(Boolean),
|
||||||
|
[problemOptions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const vendorTechs = useMemo(
|
||||||
|
() => [...new Set(vendors.map((vendor) => vendor.companyName).filter(Boolean))],
|
||||||
|
[vendors],
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeSlideOverRow = useMemo(() => {
|
||||||
|
if (!slideOverRow) return null;
|
||||||
|
return tableData.rows.find((row) => row.id === slideOverRow.id) ?? slideOverRow;
|
||||||
|
}, [slideOverRow, tableData.rows]);
|
||||||
|
|
||||||
|
const activeDocRow = useMemo(() => {
|
||||||
|
if (!docRow) return null;
|
||||||
|
return tableData.rows.find((row) => row.id === docRow.id) ?? docRow;
|
||||||
|
}, [docRow, tableData.rows]);
|
||||||
|
|
||||||
|
const activeMediaRow = useMemo(() => {
|
||||||
|
if (!mediaRow) return null;
|
||||||
|
return tableData.rows.find((row) => row.id === mediaRow.id) ?? mediaRow;
|
||||||
|
}, [mediaRow, tableData.rows]);
|
||||||
|
|
||||||
|
const handleOpenSlideOver = (row: WorkOrderTableRow, edit = false) => {
|
||||||
|
setSlideOverRow(row);
|
||||||
|
setSlideOverEdit(edit);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseSlideOver = () => {
|
||||||
|
setSlideOverRow(null);
|
||||||
|
setSlideOverEdit(false);
|
||||||
|
tableData.setSelectedId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmCancel = () => {
|
||||||
|
if (!confirmCancel) return;
|
||||||
|
patchField(confirmCancel.id, { status: "Canceled" });
|
||||||
|
setConfirmCancel(null);
|
||||||
|
handleCloseSlideOver();
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelDialogClose = guardDialogClose(isPatchPending, () => setConfirmCancel(null));
|
||||||
|
|
||||||
|
const saveCompletionDoc = async (
|
||||||
|
row: WorkOrderTableRow,
|
||||||
|
data: CompDocData,
|
||||||
|
status: CompDocSaveStatus,
|
||||||
|
) => {
|
||||||
|
// Persist PM comments + generatedAt in session state (no dedicated BE field yet)
|
||||||
|
// before patching board docStatus — Save draft / Generate / NN all share this path.
|
||||||
|
const nextData: CompDocData = {
|
||||||
|
notes: data.notes,
|
||||||
|
generatedAt: data.generatedAt,
|
||||||
|
};
|
||||||
|
setCompletionDocs((prev) => ({ ...prev, [String(row.id)]: nextData }));
|
||||||
|
await patchFieldAsync(row.id, { docStatus: status });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
wizardOpen,
|
||||||
|
setWizardOpen,
|
||||||
|
slideOverEdit,
|
||||||
|
confirmCancel,
|
||||||
|
setConfirmCancel,
|
||||||
|
docRow,
|
||||||
|
setDocRow,
|
||||||
|
mediaRow,
|
||||||
|
setMediaRow,
|
||||||
|
completionDocs,
|
||||||
|
filters,
|
||||||
|
tableData,
|
||||||
|
patchField,
|
||||||
|
patchFieldAsync,
|
||||||
|
isPatchPending,
|
||||||
|
users,
|
||||||
|
usersLoading,
|
||||||
|
locations,
|
||||||
|
vendors,
|
||||||
|
pmTypes,
|
||||||
|
vendorTechs,
|
||||||
|
activeSlideOverRow,
|
||||||
|
activeDocRow,
|
||||||
|
activeMediaRow,
|
||||||
|
handleOpenSlideOver,
|
||||||
|
handleCloseSlideOver,
|
||||||
|
handleConfirmCancel,
|
||||||
|
cancelDialogClose,
|
||||||
|
saveCompletionDoc,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -49,12 +49,16 @@ export function useWorkOrdersTableUiState(weekMonday: string) {
|
||||||
const startDayDraft = useCallback(
|
const startDayDraft = useCallback(
|
||||||
(key: string, scheduledIso?: string) => {
|
(key: string, scheduledIso?: string) => {
|
||||||
setDraftDayKey(key);
|
setDraftDayKey(key);
|
||||||
|
// Unscheduled "+ Add WO" starts with no schedule (SH-109); day groups prefill the day.
|
||||||
|
if (key === UNSCHEDULED_DAY_KEY) {
|
||||||
|
setDraft({ ...EMPTY_TABLE_DRAFT });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const scheduledOn = resolveDraftScheduledOn(key, weekMonday, scheduledIso);
|
const scheduledOn = resolveDraftScheduledOn(key, weekMonday, scheduledIso);
|
||||||
setDraft({
|
setDraft({
|
||||||
...EMPTY_TABLE_DRAFT,
|
...EMPTY_TABLE_DRAFT,
|
||||||
scheduledOn,
|
scheduledOn,
|
||||||
scheduleWeekOnly: key === UNSCHEDULED_DAY_KEY,
|
status: scheduledOn ? "Scheduled" : "Incomplete",
|
||||||
targetWeek: key === UNSCHEDULED_DAY_KEY ? weekMonday : "",
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[weekMonday],
|
[weekMonday],
|
||||||
|
|
|
||||||
|
|
@ -1,69 +1,24 @@
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import { Box } from "@mui/material";
|
import { Box } from "@mui/material";
|
||||||
import { useNavigate } from "react-router";
|
|
||||||
import { PageHeader } from "@/components/ui/page-header";
|
import { PageHeader } from "@/components/ui/page-header";
|
||||||
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
||||||
|
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
|
||||||
|
import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
|
||||||
|
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
|
||||||
|
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
|
||||||
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
|
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
|
||||||
import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar";
|
import { WorkOrdersFilterBar } from "@/app/(protected)/workorders/_components/list/filter-bar/work-orders-filter-bar";
|
||||||
import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header";
|
import { WorkOrdersListHeader } from "@/app/(protected)/workorders/_components/list/work-orders-list-header";
|
||||||
import { WorkOrdersListOverlays } from "@/app/(protected)/workorders/_components/list/work-orders-list-overlays";
|
import { WorkOrderCancelDialog } from "@/app/(protected)/workorders/_components/list/work-order-cancel-dialog";
|
||||||
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
|
import { WoTable } from "@/app/(protected)/workorders/_components/list/table/wo-table";
|
||||||
import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
|
||||||
import { useWorkOrdersTableData } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
|
import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
|
||||||
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
|
||||||
import {
|
|
||||||
useWorkOrderLocations,
|
|
||||||
useWorkOrderVendors,
|
|
||||||
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
|
||||||
import { useWorkOrderUsers } from "@/domain/work-orders/use-cases/use-work-orders-list";
|
|
||||||
import type { CompDocData } from "@/domain/work-orders/types/completion-doc";
|
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
|
||||||
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
|
||||||
import { hasCompletionDocService } from "@/domain/work-orders/utils/completion-doc-service-gate";
|
|
||||||
import { useAuthContext } from "@/providers/auth-context";
|
import { useAuthContext } from "@/providers/auth-context";
|
||||||
|
|
||||||
function resolveActiveRow(
|
|
||||||
selected: WorkOrderTableRow | null,
|
|
||||||
rows: WorkOrderTableRow[],
|
|
||||||
): WorkOrderTableRow | null {
|
|
||||||
if (!selected) return null;
|
|
||||||
return rows.find((row) => row.id === selected.id) ?? selected;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function WorkOrdersListPage() {
|
export default function WorkOrdersListPage() {
|
||||||
const navigate = useNavigate();
|
|
||||||
const { user } = useAuthContext();
|
const { user } = useAuthContext();
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
const page = useWorkOrdersListPage();
|
||||||
const [docRow, setDocRow] = useState<WorkOrderTableRow | null>(null);
|
const { filters, tableData, activeDocRow } = page;
|
||||||
const [mediaRow, setMediaRow] = useState<WorkOrderTableRow | null>(null);
|
|
||||||
const [completionDocs, setCompletionDocs] = useState<Record<string, CompDocData>>({});
|
|
||||||
const filters = useWorkOrdersListFilters();
|
|
||||||
const tableData = useWorkOrdersTableData(filters);
|
|
||||||
const { patchField, patchFieldAsync } = useWorkOrderTableMutations({
|
|
||||||
onPatch: tableData.patchRow,
|
|
||||||
clearPatch: tableData.clearRowPatch,
|
|
||||||
getRow: (id) => tableData.rows.find((row) => String(row.id) === String(id)),
|
|
||||||
});
|
|
||||||
const { data: users = [], isLoading: usersLoading } = useWorkOrderUsers();
|
|
||||||
const { data: locations = [] } = useWorkOrderLocations();
|
|
||||||
const { data: vendors = [] } = useWorkOrderVendors();
|
|
||||||
const { data: problemOptions = [] } = useDropdownOptionsByCategory("Problem");
|
|
||||||
const pmTypes = useMemo(
|
|
||||||
() => problemOptions.map((option) => option.value).filter(Boolean),
|
|
||||||
[problemOptions],
|
|
||||||
);
|
|
||||||
const vendorTechs = useMemo(
|
|
||||||
() => [...new Set(vendors.map((vendor) => vendor.companyName).filter(Boolean))],
|
|
||||||
[vendors],
|
|
||||||
);
|
|
||||||
const activeDocRow = useMemo(
|
|
||||||
() => resolveActiveRow(docRow, tableData.rows),
|
|
||||||
[docRow, tableData.rows],
|
|
||||||
);
|
|
||||||
const activeMediaRow = useMemo(
|
|
||||||
() => resolveActiveRow(mediaRow, tableData.rows),
|
|
||||||
[mediaRow, tableData.rows],
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex h-full flex-col overflow-hidden p-2.5">
|
<Box className="flex h-full flex-col overflow-hidden p-2.5">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
|
|
@ -75,7 +30,7 @@ export default function WorkOrdersListPage() {
|
||||||
search={filters.search}
|
search={filters.search}
|
||||||
onSearchChange={filters.handleSearchChange}
|
onSearchChange={filters.handleSearchChange}
|
||||||
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
|
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
|
||||||
onNewWorkOrder={() => setWizardOpen(true)}
|
onNewWorkOrder={() => page.setWizardOpen(true)}
|
||||||
activeFilterCount={filters.activeFilterCount}
|
activeFilterCount={filters.activeFilterCount}
|
||||||
isFetching={tableData.isFetching && !tableData.isLoading}
|
isFetching={tableData.isFetching && !tableData.isLoading}
|
||||||
/>
|
/>
|
||||||
|
|
@ -89,8 +44,8 @@ export default function WorkOrdersListPage() {
|
||||||
onDispatcherChange={filters.setDispatcherIds}
|
onDispatcherChange={filters.setDispatcherIds}
|
||||||
typeFilter={filters.typeFilter}
|
typeFilter={filters.typeFilter}
|
||||||
onTypeChange={filters.setTypeFilter}
|
onTypeChange={filters.setTypeFilter}
|
||||||
users={users}
|
users={page.users}
|
||||||
usersLoading={usersLoading}
|
usersLoading={page.usersLoading}
|
||||||
currentUserId={user?.id}
|
currentUserId={user?.id}
|
||||||
filteredCount={tableData.totalCount}
|
filteredCount={tableData.totalCount}
|
||||||
scopeTotalCount={tableData.scopeTotalCount}
|
scopeTotalCount={tableData.scopeTotalCount}
|
||||||
|
|
@ -100,8 +55,8 @@ export default function WorkOrdersListPage() {
|
||||||
<WorkOrdersAdvancedFiltersBanner
|
<WorkOrdersAdvancedFiltersBanner
|
||||||
applied={filters.advApplied}
|
applied={filters.advApplied}
|
||||||
totalCount={tableData.totalCount}
|
totalCount={tableData.totalCount}
|
||||||
sites={locations}
|
sites={page.locations}
|
||||||
users={users}
|
users={page.users}
|
||||||
onClearChip={filters.clearAdvancedFilterChip}
|
onClearChip={filters.clearAdvancedFilterChip}
|
||||||
onClearAll={filters.clearAdvancedFilters}
|
onClearAll={filters.clearAdvancedFilters}
|
||||||
/>
|
/>
|
||||||
|
|
@ -122,58 +77,82 @@ export default function WorkOrdersListPage() {
|
||||||
|
|
||||||
<WoTable
|
<WoTable
|
||||||
tableData={tableData}
|
tableData={tableData}
|
||||||
users={users}
|
users={page.users}
|
||||||
sites={locations}
|
sites={page.locations}
|
||||||
vendors={vendors}
|
vendors={page.vendors}
|
||||||
pmTypes={pmTypes}
|
pmTypes={page.pmTypes}
|
||||||
weekMonday={filters.weekMonday}
|
weekMonday={filters.weekMonday}
|
||||||
onWeekChange={filters.setWeekMonday}
|
onWeekChange={filters.setWeekMonday}
|
||||||
onOpenSlideOver={(row, edit) => {
|
onOpenSlideOver={page.handleOpenSlideOver}
|
||||||
tableData.setSelectedId(row.id);
|
onOpenCompDoc={page.setDocRow}
|
||||||
navigate(edit ? `/workorders/edit/${row.id}` : `/workorders/${row.id}`);
|
onOpenMedia={page.setMediaRow}
|
||||||
}}
|
|
||||||
onOpenCompDoc={(row) => {
|
|
||||||
if (!hasCompletionDocService(row.pm)) return;
|
|
||||||
setDocRow(row);
|
|
||||||
}}
|
|
||||||
onOpenMedia={setMediaRow}
|
|
||||||
onClearSearch={() => filters.handleSearchChange("")}
|
onClearSearch={() => filters.handleSearchChange("")}
|
||||||
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
|
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
|
||||||
onPatch={patchField}
|
onPatch={page.patchField}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WorkOrdersListOverlays
|
<NewWoWizard open={page.wizardOpen} onOpenChange={page.setWizardOpen} users={page.users} />
|
||||||
wizardOpen={wizardOpen}
|
|
||||||
onWizardOpenChange={setWizardOpen}
|
<WorkOrdersAdvancedSearchSheet
|
||||||
users={users}
|
open={filters.advOpen}
|
||||||
sites={locations}
|
onOpenChange={filters.setAdvOpen}
|
||||||
pmTypes={pmTypes}
|
initial={filters.advInitial}
|
||||||
vendorTechs={vendorTechs}
|
onApply={filters.applyAdvancedFilters}
|
||||||
advOpen={filters.advOpen}
|
onClearAll={filters.clearAdvancedFilters}
|
||||||
onAdvOpenChange={filters.setAdvOpen}
|
sites={page.locations}
|
||||||
advInitial={filters.advInitial}
|
users={page.users}
|
||||||
onApplyAdvanced={filters.applyAdvancedFilters}
|
pmTypes={page.pmTypes}
|
||||||
onClearAdvanced={filters.clearAdvancedFilters}
|
vendorTechs={page.vendorTechs}
|
||||||
activeDocRow={activeDocRow}
|
|
||||||
onDocOpenChange={(open) => {
|
|
||||||
if (!open) setDocRow(null);
|
|
||||||
}}
|
|
||||||
completionDocs={completionDocs}
|
|
||||||
onSaveCompDoc={async (data, status) => {
|
|
||||||
if (!activeDocRow) return;
|
|
||||||
// Persist board docStatus first so local generatedAt/notes never outrun the server.
|
|
||||||
await patchFieldAsync(activeDocRow.id, { docStatus: status });
|
|
||||||
setCompletionDocs((prev) => ({ ...prev, [String(activeDocRow.id)]: data }));
|
|
||||||
}}
|
|
||||||
onDateCompletedChange={async (date) => {
|
|
||||||
if (!activeDocRow) return;
|
|
||||||
await patchFieldAsync(activeDocRow.id, { completedDate: date });
|
|
||||||
}}
|
|
||||||
activeMediaRow={activeMediaRow}
|
|
||||||
onMediaOpenChange={(open) => {
|
|
||||||
if (!open) setMediaRow(null);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<WorkOrderSlideOver
|
||||||
|
row={page.activeSlideOverRow}
|
||||||
|
editMode={page.slideOverEdit}
|
||||||
|
users={page.users}
|
||||||
|
sites={page.locations}
|
||||||
|
vendors={page.vendors}
|
||||||
|
pmTypes={page.pmTypes}
|
||||||
|
allOrders={tableData.rows}
|
||||||
|
onSave={(id, patch, options) => page.patchField(id, patch, options)}
|
||||||
|
onOpenCompletionDoc={page.setDocRow}
|
||||||
|
onCancelWO={page.setConfirmCancel}
|
||||||
|
onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)}
|
||||||
|
saving={page.isPatchPending}
|
||||||
|
onClose={page.handleCloseSlideOver}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<WorkOrderCancelDialog
|
||||||
|
row={page.confirmCancel}
|
||||||
|
isPending={page.isPatchPending}
|
||||||
|
onDialogClose={page.cancelDialogClose}
|
||||||
|
onKeepWO={() => page.setConfirmCancel(null)}
|
||||||
|
onConfirm={page.handleConfirmCancel}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{page.activeMediaRow != null && (
|
||||||
|
<MediaUploadModal
|
||||||
|
row={page.activeMediaRow}
|
||||||
|
open={Boolean(page.activeMediaRow)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) page.setMediaRow(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeDocRow != null && (
|
||||||
|
<CompDocDialog
|
||||||
|
row={activeDocRow}
|
||||||
|
open={Boolean(activeDocRow)}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) page.setDocRow(null);
|
||||||
|
}}
|
||||||
|
data={page.completionDocs[String(activeDocRow.id)]}
|
||||||
|
onSave={(data, status) => page.saveCompletionDoc(activeDocRow, data, status)}
|
||||||
|
onDateCompletedChange={async (date) => {
|
||||||
|
await page.patchFieldAsync(activeDocRow.id, { completedDate: date });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,11 @@ export function assignWizardScheduleFields(
|
||||||
const isRange = draft.schedMode === "range";
|
const isRange = draft.schedMode === "range";
|
||||||
|
|
||||||
if (!isRange && draft.scheduleWeekOnly) {
|
if (!isRange && draft.scheduleWeekOnly) {
|
||||||
payload.scheduleWeekOnly = true;
|
|
||||||
|
|
||||||
const week = draft.targetWeek?.trim() || (draft.scheduledOn ? mondayOf(draft.scheduledOn) : "");
|
const week = draft.targetWeek?.trim() || (draft.scheduledOn ? mondayOf(draft.scheduledOn) : "");
|
||||||
|
|
||||||
|
// Only emit week-mode flags when a concrete week exists (SH-109: undated create).
|
||||||
if (week) {
|
if (week) {
|
||||||
|
payload.scheduleWeekOnly = true;
|
||||||
payload.targetWeek = week;
|
payload.targetWeek = week;
|
||||||
}
|
}
|
||||||
} else if (!isRange && draft.scheduledOn) {
|
} else if (!isRange && draft.scheduledOn) {
|
||||||
|
|
|
||||||
|
|
@ -259,10 +259,7 @@ export function boardMediaItemsToMediaFiles(items: WorkOrderBoardMediaItem[]): M
|
||||||
fileType: classifyFileName(item.fileName, item.url),
|
fileType: classifyFileName(item.fileName, item.url),
|
||||||
url: item.url,
|
url: item.url,
|
||||||
category: boardCategoryToMediaCategory(item.category),
|
category: boardCategoryToMediaCategory(item.category),
|
||||||
purpose:
|
purpose: item.category,
|
||||||
item.category === "Before" || item.category === "After" || item.category === "Extra"
|
|
||||||
? item.category
|
|
||||||
: null,
|
|
||||||
isLegacy: item.isLegacy || Number(item.id) <= 0,
|
isLegacy: item.isLegacy || Number(item.id) <= 0,
|
||||||
uploadStatus: "done" as const,
|
uploadStatus: "done" as const,
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,8 @@ export interface MediaFile {
|
||||||
fileType: FileCategory;
|
fileType: FileCategory;
|
||||||
url: string;
|
url: string;
|
||||||
category: MediaCategory;
|
category: MediaCategory;
|
||||||
/** Board purpose label (Before/After/Extra). Null/undefined = needs categorization. */
|
/** Board purpose label (Before/After/Extra/Completion). Null/undefined = needs categorization. */
|
||||||
purpose?: "Before" | "After" | "Extra" | null;
|
purpose?: "Before" | "After" | "Extra" | "Completion" | null;
|
||||||
/** True when media is a legacy Before/After/Completion column (not deletable). */
|
/** True when media is a legacy Before/After/Completion column (not deletable). */
|
||||||
isLegacy?: boolean;
|
isLegacy?: boolean;
|
||||||
/** 0–100 while uploading; undefined when idle/complete. */
|
/** 0–100 while uploading; undefined when idle/complete. */
|
||||||
|
|
|
||||||
|
|
@ -146,8 +146,21 @@ export function useWorkOrderTableMutations(
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const patchField = (id: string | number, patch: WorkOrderTablePatch) => {
|
const patchField = (
|
||||||
patchMutation.mutate({ id, patch });
|
id: string | number,
|
||||||
|
patch: WorkOrderTablePatch,
|
||||||
|
options?: {
|
||||||
|
onSuccess?: () => void;
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
patchMutation.mutate(
|
||||||
|
{ id, patch },
|
||||||
|
{
|
||||||
|
onSuccess: () => options?.onSuccess?.(),
|
||||||
|
onError: (error) => options?.onError?.(error),
|
||||||
|
},
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const patchFieldAsync = (id: string | number, patch: WorkOrderTablePatch) =>
|
const patchFieldAsync = (id: string | number, patch: WorkOrderTablePatch) =>
|
||||||
|
|
|
||||||
62
src/domain/work-orders/utils/get-inline-create-missing.ts
Normal file
62
src/domain/work-orders/utils/get-inline-create-missing.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
/** Required fields for board inline create (Schedule On is optional — SH-109). */
|
||||||
|
export type InlineCreateReqField = "site" | "woNumber" | "pocName" | "pocPhone";
|
||||||
|
|
||||||
|
export const INLINE_CREATE_REQ_LABELS: Record<InlineCreateReqField, string> = {
|
||||||
|
site: "Site",
|
||||||
|
woNumber: "WO #",
|
||||||
|
pocName: "POC name",
|
||||||
|
pocPhone: "POC phone",
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InlineCreateDraftFields = {
|
||||||
|
site?: string;
|
||||||
|
locationId?: string | number;
|
||||||
|
woNumber?: string;
|
||||||
|
pocName?: string;
|
||||||
|
pocPhone?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getInlineCreateMissing(d: InlineCreateDraftFields): InlineCreateReqField[] {
|
||||||
|
const miss: InlineCreateReqField[] = [];
|
||||||
|
const hasSite = Boolean(d.site?.trim()) || Boolean(String(d.locationId ?? "").trim());
|
||||||
|
if (!hasSite) {
|
||||||
|
miss.push("site");
|
||||||
|
}
|
||||||
|
if (!d.woNumber?.trim()) {
|
||||||
|
miss.push("woNumber");
|
||||||
|
}
|
||||||
|
if (!d.pocName?.trim()) {
|
||||||
|
miss.push("pocName");
|
||||||
|
}
|
||||||
|
if (!d.pocPhone?.trim()) {
|
||||||
|
miss.push("pocPhone");
|
||||||
|
}
|
||||||
|
return miss;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tooltip for Save: "Missing: Site, WO #" or "Save" when ready. */
|
||||||
|
export function inlineCreateSaveHint(miss: InlineCreateReqField[]): string {
|
||||||
|
if (miss.length === 0) {
|
||||||
|
return "Save";
|
||||||
|
}
|
||||||
|
return `Missing: ${miss.map((m) => INLINE_CREATE_REQ_LABELS[m]).join(", ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inlineCreateSiteError(miss: InlineCreateReqField[]): string {
|
||||||
|
if (miss.includes("site")) {
|
||||||
|
return "Site is required";
|
||||||
|
}
|
||||||
|
return "Contact name and phone are required";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Concrete calendar day/range — week-only does not count (design hasScheduleDate). */
|
||||||
|
export function draftHasScheduleDate(d: {
|
||||||
|
scheduledOn?: string;
|
||||||
|
scheduleWeekOnly?: boolean;
|
||||||
|
schedMode?: string;
|
||||||
|
}): boolean {
|
||||||
|
if (d.scheduleWeekOnly || d.schedMode === "week") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Boolean(d.scheduledOn?.trim());
|
||||||
|
}
|
||||||
24
src/domain/work-orders/utils/run-sequential-uploads.ts
Normal file
24
src/domain/work-orders/utils/run-sequential-uploads.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
/**
|
||||||
|
* Runs file uploads one-by-one while holding an aggregate pending flag true
|
||||||
|
* until every file has settled (success or failure). Avoids relying on a single
|
||||||
|
* TanStack Mutation `isPending`, which can flip false when a later small file
|
||||||
|
* finishes before an earlier large one.
|
||||||
|
*/
|
||||||
|
export async function runSequentialUploads<TFile>(
|
||||||
|
files: readonly TFile[],
|
||||||
|
upload: (file: TFile) => Promise<unknown>,
|
||||||
|
setPending: (pending: boolean) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
if (files.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPending(true);
|
||||||
|
try {
|
||||||
|
for (const file of files) {
|
||||||
|
await upload(file);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,8 +24,7 @@ export const SLIDE_OVER_EDIT_KEYS = [
|
||||||
"serviceNotes",
|
"serviceNotes",
|
||||||
"extraServices",
|
"extraServices",
|
||||||
"vendorId",
|
"vendorId",
|
||||||
"company",
|
// company/tech are display companions; board persists vendor via vendorId only
|
||||||
"tech",
|
|
||||||
"status",
|
"status",
|
||||||
] as const satisfies ReadonlyArray<keyof WorkOrderTableRow>;
|
] as const satisfies ReadonlyArray<keyof WorkOrderTableRow>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildInlineDraftCreateInput } from "@/app/(protected)/workorders/_components/list/table/build-inline-draft-create-input";
|
||||||
|
import { mapWizardDraftToBoardCreatePayload } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||||
|
import { EMPTY_TABLE_DRAFT } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
describe("buildInlineDraftCreateInput", () => {
|
||||||
|
it("defaults type to PM and omits schedule flags when undated", () => {
|
||||||
|
const draft = {
|
||||||
|
...EMPTY_TABLE_DRAFT,
|
||||||
|
site: "BK5",
|
||||||
|
locationId: "9",
|
||||||
|
woNumber: "25001",
|
||||||
|
pocName: "Jane",
|
||||||
|
pocPhone: "555-0100",
|
||||||
|
scheduleWeekOnly: true,
|
||||||
|
targetWeek: "",
|
||||||
|
scheduledOn: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const input = buildInlineDraftCreateInput(draft, [], [{ id: 9, name: "BK5 · Dallas, TX" }]);
|
||||||
|
|
||||||
|
expect(input.type).toBe("PM");
|
||||||
|
expect(input.scheduleWeekOnly).toBe(false);
|
||||||
|
expect(input.scheduledOn).toBe("");
|
||||||
|
expect(input.targetWeek).toBe("");
|
||||||
|
|
||||||
|
const payload = mapWizardDraftToBoardCreatePayload(input);
|
||||||
|
expect(payload.scheduledDate).toBeUndefined();
|
||||||
|
expect(payload.targetWeek).toBeUndefined();
|
||||||
|
expect(payload.scheduleWeekOnly).toBeUndefined();
|
||||||
|
expect(payload.woNumber).toBe("25001");
|
||||||
|
expect(payload.pocName).toBe("Jane");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps week-only schedule when targetWeek is set", () => {
|
||||||
|
const draft = {
|
||||||
|
...EMPTY_TABLE_DRAFT,
|
||||||
|
site: "BK5",
|
||||||
|
locationId: "9",
|
||||||
|
woNumber: "25002",
|
||||||
|
type: "Reactive" as const,
|
||||||
|
pocName: "Jane",
|
||||||
|
pocPhone: "555-0100",
|
||||||
|
scheduleWeekOnly: true,
|
||||||
|
targetWeek: "2026-06-22",
|
||||||
|
};
|
||||||
|
|
||||||
|
const input = buildInlineDraftCreateInput(draft, [], [{ id: 9, name: "BK5 · Dallas, TX" }]);
|
||||||
|
const payload = mapWizardDraftToBoardCreatePayload(input);
|
||||||
|
|
||||||
|
expect(payload.scheduleWeekOnly).toBe(true);
|
||||||
|
expect(payload.targetWeek).toBe("2026-06-22");
|
||||||
|
expect(payload.scheduledDate).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { toast } from "react-toastify";
|
|
||||||
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
|
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
|
@ -9,6 +8,14 @@ vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
|
||||||
useLocationDetail: () => ({ data: undefined }),
|
useLocationDetail: () => ({ data: undefined }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
||||||
|
useWorkOrderBoardDetail: () => ({ data: undefined, isLoading: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
|
||||||
|
useUploadCompletionDoc: () => ({ mutate: vi.fn(), isPending: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("react-toastify", () => ({
|
vi.mock("react-toastify", () => ({
|
||||||
toast: { error: vi.fn(), success: vi.fn() },
|
toast: { error: vi.fn(), success: vi.fn() },
|
||||||
}));
|
}));
|
||||||
|
|
@ -74,7 +81,6 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
onSave.mockReset();
|
onSave.mockReset();
|
||||||
onDateCompletedChange.mockReset();
|
onDateCompletedChange.mockReset();
|
||||||
vi.mocked(toast.error).mockReset();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function renderDialog(row: WorkOrderTableRow) {
|
function renderDialog(row: WorkOrderTableRow) {
|
||||||
|
|
@ -90,12 +96,12 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => {
|
||||||
row={row}
|
row={row}
|
||||||
open
|
open
|
||||||
onOpenChange={vi.fn()}
|
onOpenChange={vi.fn()}
|
||||||
onSave={(data, status) => {
|
onSave={async (data, status) => {
|
||||||
onSave(data, status);
|
await onSave(data, status);
|
||||||
patches.push({ docStatus: status });
|
patches.push({ docStatus: status });
|
||||||
}}
|
}}
|
||||||
onDateCompletedChange={(date) => {
|
onDateCompletedChange={async (date) => {
|
||||||
onDateCompletedChange(date);
|
await onDateCompletedChange(date);
|
||||||
patches.push({ completedDate: date });
|
patches.push({ completedDate: date });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -129,7 +135,7 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => {
|
||||||
expect(row.status).toBe("Scheduled");
|
expect(row.status).toBe("Scheduled");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Generate emits completedDate before doc Yes without auto-setting lifecycle Completed", async () => {
|
it("Generate emits completedDate and doc Yes without auto-setting lifecycle Completed", async () => {
|
||||||
const row = baseRow({ completedDate: "2026-07-01" });
|
const row = baseRow({ completedDate: "2026-07-01" });
|
||||||
const { patches } = renderDialog(row);
|
const { patches } = renderDialog(row);
|
||||||
|
|
||||||
|
|
@ -150,41 +156,10 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => {
|
||||||
expect(row.status).toBe("Scheduled");
|
expect(row.status).toBe("Scheduled");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Generate keeps Draft and skips docStatus Yes when Date Work Completed is blank", async () => {
|
|
||||||
const row = baseRow({ completedDate: "" });
|
|
||||||
renderDialog(row);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /^generate$/i }));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(toast.error).toHaveBeenCalledWith(
|
|
||||||
"Date Work Completed is required to generate the document.",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
expect(onDateCompletedChange).not.toHaveBeenCalled();
|
|
||||||
expect(onSave).not.toHaveBeenCalled();
|
|
||||||
expect(screen.getByRole("button", { name: /^generate$/i })).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Generate keeps Draft and skips docStatus Yes when completedDate patch fails", async () => {
|
it("Generate keeps Draft and skips docStatus Yes when completedDate patch fails", async () => {
|
||||||
const row = baseRow({ completedDate: "2026-07-01" });
|
|
||||||
const client = new QueryClient({
|
|
||||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
||||||
});
|
|
||||||
const invalidateSpy = vi.spyOn(client, "invalidateQueries");
|
|
||||||
onDateCompletedChange.mockRejectedValueOnce(new Error("conflict"));
|
onDateCompletedChange.mockRejectedValueOnce(new Error("conflict"));
|
||||||
|
const row = baseRow({ completedDate: "2026-07-01" });
|
||||||
render(
|
renderDialog(row);
|
||||||
<QueryClientProvider client={client}>
|
|
||||||
<CompDocDialog
|
|
||||||
row={row}
|
|
||||||
open
|
|
||||||
onOpenChange={vi.fn()}
|
|
||||||
onSave={onSave}
|
|
||||||
onDateCompletedChange={onDateCompletedChange}
|
|
||||||
/>
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
changeDateCompleted("2026-07-20");
|
changeDateCompleted("2026-07-20");
|
||||||
fireEvent.click(screen.getByRole("button", { name: /^generate$/i }));
|
fireEvent.click(screen.getByRole("button", { name: /^generate$/i }));
|
||||||
|
|
@ -194,8 +169,61 @@ describe("CompDocDialog Save Draft / Generate completion contract", () => {
|
||||||
});
|
});
|
||||||
expect(onSave).not.toHaveBeenCalled();
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
expect(screen.getByRole("button", { name: /^generate$/i })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /^generate$/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Generate never writes docStatus Yes after a successful status-capable save when date fails first", async () => {
|
||||||
|
// Regression for the prior order (status then date): if date fails, Yes must not have been sent.
|
||||||
|
// With prerequisites-first ordering, date failure short-circuits before onSave("Yes").
|
||||||
|
const callOrder: string[] = [];
|
||||||
|
onDateCompletedChange.mockImplementation(async () => {
|
||||||
|
callOrder.push("date");
|
||||||
|
throw new Error("completedDate conflict");
|
||||||
|
});
|
||||||
|
onSave.mockImplementation(async () => {
|
||||||
|
callOrder.push("status");
|
||||||
|
});
|
||||||
|
|
||||||
|
const row = baseRow({ completedDate: "2026-07-01" });
|
||||||
|
renderDialog(row);
|
||||||
|
changeDateCompleted("2026-07-20");
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /^generate$/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(invalidateSpy).toHaveBeenCalled();
|
expect(onDateCompletedChange).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
expect(callOrder).toEqual(["date"]);
|
||||||
|
expect(onSave).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Save draft persists PM comments notes through onSave", async () => {
|
||||||
|
const row = baseRow();
|
||||||
|
renderDialog(row);
|
||||||
|
|
||||||
|
const notes = screen.getByPlaceholderText(/add findings/i);
|
||||||
|
fireEvent.change(notes, { target: { value: "No issues noted" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /save draft/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSave).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ notes: "No issues noted" }),
|
||||||
|
"No",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Mark as not required rolls back UI when persist fails", async () => {
|
||||||
|
onSave.mockRejectedValueOnce(new Error("persist failed"));
|
||||||
|
const row = baseRow();
|
||||||
|
renderDialog(row);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /mark as not required/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSave).toHaveBeenCalledWith(expect.any(Object), "NN");
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole("button", { name: /mark as not required/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.queryByRole("button", { name: /^undo$/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { createRef } from "react";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { CompDocDialogFooter } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog-footer";
|
||||||
|
|
||||||
|
describe("CompDocDialogFooter exclusive states", () => {
|
||||||
|
const pdfInputRef = createRef<HTMLInputElement | null>();
|
||||||
|
const handlers = {
|
||||||
|
onMarkNotRequired: vi.fn(),
|
||||||
|
onSaveDraft: vi.fn(),
|
||||||
|
onGenerate: vi.fn(),
|
||||||
|
onUndo: vi.fn(),
|
||||||
|
onDownloadPdf: vi.fn(),
|
||||||
|
onUploadPdf: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
it("does not render draft actions when a signed PDF is on file while docState is still draft", () => {
|
||||||
|
render(
|
||||||
|
<CompDocDialogFooter
|
||||||
|
docState="draft"
|
||||||
|
isDocumentsReadOnly={false}
|
||||||
|
rowVersion="1"
|
||||||
|
isUploading={false}
|
||||||
|
signOffAttachment="https://example.com/doc.pdf"
|
||||||
|
generatedAt=""
|
||||||
|
pdfInputRef={pdfInputRef}
|
||||||
|
{...handlers}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: /save draft/i })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: /mark as not required/i })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/document on file/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders only draft actions for a clean draft without attachment", () => {
|
||||||
|
render(
|
||||||
|
<CompDocDialogFooter
|
||||||
|
docState="draft"
|
||||||
|
isDocumentsReadOnly={false}
|
||||||
|
rowVersion="1"
|
||||||
|
isUploading={false}
|
||||||
|
signOffAttachment={null}
|
||||||
|
generatedAt=""
|
||||||
|
pdfInputRef={pdfInputRef}
|
||||||
|
{...handlers}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /save draft/i })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/document on file/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -11,7 +11,7 @@ const categorizeMutate = vi.fn();
|
||||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
||||||
useWorkOrderBoardDetail: () => ({
|
useWorkOrderBoardDetail: () => ({
|
||||||
data: {
|
data: {
|
||||||
info: { status: "Scheduled", rowVersion: "DETAIL-VER==" },
|
info: { status: "Scheduled" },
|
||||||
media: [
|
media: [
|
||||||
{
|
{
|
||||||
id: "11",
|
id: "11",
|
||||||
|
|
@ -80,7 +80,7 @@ function baseRow(): WorkOrderTableRow {
|
||||||
title: "Emergency",
|
title: "Emergency",
|
||||||
priority: "Sev 1",
|
priority: "Sev 1",
|
||||||
primaryDispatchId: null,
|
primaryDispatchId: null,
|
||||||
rowVersion: "ROW-VER==",
|
rowVersion: "",
|
||||||
dispatchRowVersion: "",
|
dispatchRowVersion: "",
|
||||||
flagColor: null,
|
flagColor: null,
|
||||||
};
|
};
|
||||||
|
|
@ -111,32 +111,4 @@ describe("MediaUploadModal API wiring", () => {
|
||||||
expect(screen.getByText(/categorize after upload/i)).toBeInTheDocument();
|
expect(screen.getByText(/categorize after upload/i)).toBeInTheDocument();
|
||||||
expect(screen.getByLabelText(/category for before\.jpg/i)).toBeInTheDocument();
|
expect(screen.getByLabelText(/category for before\.jpg/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("categorizes with workOrderVersion from detail.info.rowVersion", async () => {
|
|
||||||
const client = new QueryClient({
|
|
||||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
||||||
});
|
|
||||||
|
|
||||||
const { fireEvent } = await import("@testing-library/react");
|
|
||||||
|
|
||||||
render(
|
|
||||||
<QueryClientProvider client={client}>
|
|
||||||
<MediaUploadModal row={baseRow()} open onOpenChange={vi.fn()} />
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByLabelText(/category for before\.jpg/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText(/category for before\.jpg/i), {
|
|
||||||
target: { value: "After" },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(categorizeMutate).toHaveBeenCalledWith({
|
|
||||||
mediaId: "11",
|
|
||||||
category: "After",
|
|
||||||
workOrderVersion: "DETAIL-VER==",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ describe("useMediaUploadSession retry / cancel", () => {
|
||||||
.mockImplementation(() => new Promise(() => undefined));
|
.mockImplementation(() => new Promise(() => undefined));
|
||||||
|
|
||||||
const { result } = renderHook(() => useMediaUploadSession(undefined, uploadMutateAsync));
|
const { result } = renderHook(() => useMediaUploadSession(undefined, uploadMutateAsync));
|
||||||
const firstController = new AbortController();
|
|
||||||
const localId = "local-1";
|
const localId = "local-1";
|
||||||
const file = makeFile();
|
const file = makeFile();
|
||||||
|
|
||||||
|
|
@ -29,7 +28,6 @@ describe("useMediaUploadSession retry / cancel", () => {
|
||||||
{
|
{
|
||||||
localId,
|
localId,
|
||||||
file,
|
file,
|
||||||
signal: firstController.signal,
|
|
||||||
onProgress: () => undefined,
|
onProgress: () => undefined,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
@ -39,6 +37,8 @@ describe("useMediaUploadSession retry / cancel", () => {
|
||||||
expect(result.current.uploads).toHaveLength(1);
|
expect(result.current.uploads).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const firstSignal = uploadMutateAsync.mock.calls[0]?.[0]?.signal as AbortSignal;
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
rejectFirst(new Error("network"));
|
rejectFirst(new Error("network"));
|
||||||
});
|
});
|
||||||
|
|
@ -47,9 +47,8 @@ describe("useMediaUploadSession retry / cancel", () => {
|
||||||
expect(result.current.uploads[0]?.uploadStatus).toBe("failed");
|
expect(result.current.uploads[0]?.uploadStatus).toBe("failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
const retryController = new AbortController();
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.retryUpload(localId, retryController.signal);
|
result.current.retryUpload(localId);
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|
@ -58,11 +57,15 @@ describe("useMediaUploadSession retry / cancel", () => {
|
||||||
expect(result.current.uploads[0]?.uploadStatus).toBe("uploading");
|
expect(result.current.uploads[0]?.uploadStatus).toBe("uploading");
|
||||||
});
|
});
|
||||||
expect(uploadMutateAsync).toHaveBeenCalledTimes(2);
|
expect(uploadMutateAsync).toHaveBeenCalledTimes(2);
|
||||||
expect(uploadMutateAsync.mock.calls[1]?.[0]?.signal).toBe(retryController.signal);
|
const retrySignal = uploadMutateAsync.mock.calls[1]?.[0]?.signal as AbortSignal;
|
||||||
|
expect(retrySignal).toBeInstanceOf(AbortSignal);
|
||||||
|
expect(retrySignal).not.toBe(firstSignal);
|
||||||
|
expect(retrySignal.aborted).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cancel after retry aborts the replacement upload and removes the local row", async () => {
|
it("fail → retry → cancel aborts the replacement upload and records no success", async () => {
|
||||||
let rejectFirst!: (error: Error) => void;
|
let rejectFirst!: (error: Error) => void;
|
||||||
|
let resolveRetry!: (value: unknown) => void;
|
||||||
const uploadMutateAsync = vi
|
const uploadMutateAsync = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockImplementationOnce(
|
.mockImplementationOnce(
|
||||||
|
|
@ -73,7 +76,8 @@ describe("useMediaUploadSession retry / cancel", () => {
|
||||||
)
|
)
|
||||||
.mockImplementation(
|
.mockImplementation(
|
||||||
({ signal }: { signal?: AbortSignal }) =>
|
({ signal }: { signal?: AbortSignal }) =>
|
||||||
new Promise((_resolve, reject) => {
|
new Promise((resolve, reject) => {
|
||||||
|
resolveRetry = resolve;
|
||||||
signal?.addEventListener("abort", () => {
|
signal?.addEventListener("abort", () => {
|
||||||
const error = new Error("aborted");
|
const error = new Error("aborted");
|
||||||
error.name = "AbortError";
|
error.name = "AbortError";
|
||||||
|
|
@ -91,7 +95,6 @@ describe("useMediaUploadSession retry / cancel", () => {
|
||||||
{
|
{
|
||||||
localId,
|
localId,
|
||||||
file,
|
file,
|
||||||
signal: new AbortController().signal,
|
|
||||||
onProgress: () => undefined,
|
onProgress: () => undefined,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
@ -105,25 +108,99 @@ describe("useMediaUploadSession retry / cancel", () => {
|
||||||
expect(result.current.uploads[0]?.uploadStatus).toBe("failed");
|
expect(result.current.uploads[0]?.uploadStatus).toBe("failed");
|
||||||
});
|
});
|
||||||
|
|
||||||
const retryController = new AbortController();
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.retryUpload(localId, retryController.signal);
|
result.current.retryUpload(localId);
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.uploads[0]?.uploadStatus).toBe("uploading");
|
expect(result.current.uploads[0]?.uploadStatus).toBe("uploading");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const retrySignal = uploadMutateAsync.mock.calls[1]?.[0]?.signal as AbortSignal;
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
// Mirrors MediaUploader.cancel: abort the mapped controller, then removeLocal.
|
result.current.cancelUpload(localId);
|
||||||
retryController.abort();
|
|
||||||
result.current.removeLocal(localId);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.uploads).toHaveLength(0);
|
expect(result.current.uploads).toHaveLength(0);
|
||||||
});
|
});
|
||||||
expect(retryController.signal.aborted).toBe(true);
|
expect(retrySignal.aborted).toBe(true);
|
||||||
expect(uploadMutateAsync.mock.calls[1]?.[0]?.signal).toBe(retryController.signal);
|
// Cancelling must not leave a successful settle path.
|
||||||
|
expect(() => resolveRetry({})).not.toThrow();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.uploads).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears local uploads and aborts in-flight work when the bound WO changes", async () => {
|
||||||
|
const uploadMutateAsync = vi.fn().mockImplementation(
|
||||||
|
({ signal }: { signal?: AbortSignal }) =>
|
||||||
|
new Promise((_resolve, reject) => {
|
||||||
|
signal?.addEventListener("abort", () => {
|
||||||
|
const error = new Error("aborted");
|
||||||
|
error.name = "AbortError";
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const detailA = {
|
||||||
|
info: { id: 1 },
|
||||||
|
media: [],
|
||||||
|
comments: [],
|
||||||
|
audit: [],
|
||||||
|
completion: {
|
||||||
|
docStatus: "",
|
||||||
|
template: null,
|
||||||
|
signOffName: "",
|
||||||
|
signOffAttachment: "",
|
||||||
|
signOffSignature: "",
|
||||||
|
dispatchSignoffs: [],
|
||||||
|
},
|
||||||
|
} as unknown as Parameters<typeof useMediaUploadSession>[0];
|
||||||
|
|
||||||
|
const detailB = {
|
||||||
|
info: { id: 2 },
|
||||||
|
media: [],
|
||||||
|
comments: [],
|
||||||
|
audit: [],
|
||||||
|
completion: {
|
||||||
|
docStatus: "",
|
||||||
|
template: null,
|
||||||
|
signOffName: "",
|
||||||
|
signOffAttachment: "",
|
||||||
|
signOffSignature: "",
|
||||||
|
dispatchSignoffs: [],
|
||||||
|
},
|
||||||
|
} as unknown as Parameters<typeof useMediaUploadSession>[0];
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ detail }) => useMediaUploadSession(detail, uploadMutateAsync),
|
||||||
|
{ initialProps: { detail: detailA } },
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.startUploads([
|
||||||
|
{
|
||||||
|
localId: "local-wo-a",
|
||||||
|
file: makeFile("a.jpg"),
|
||||||
|
onProgress: () => undefined,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.uploads).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstSignal = uploadMutateAsync.mock.calls[0]?.[0]?.signal as AbortSignal;
|
||||||
|
|
||||||
|
rerender({ detail: detailB });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.uploads).toHaveLength(0);
|
||||||
|
});
|
||||||
|
expect(firstSignal.aborted).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { useSiteDialogState } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-state";
|
||||||
|
import type { Location } from "@/domain/locations/types/location";
|
||||||
|
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
|
const detailById = new Map<string, Location>();
|
||||||
|
|
||||||
|
vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
|
||||||
|
useLocationDetail: (id: string | undefined) => ({
|
||||||
|
data: id && detailById.has(id) ? detailById.get(id) : undefined,
|
||||||
|
isLoading: Boolean(id && !detailById.has(id)),
|
||||||
|
isError: false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const SITES: LocationOption[] = [
|
||||||
|
{ id: "loc-1", name: "BK5 · Dallas", siteCode: "BK5", city: "Dallas", state: "TX" },
|
||||||
|
{ id: "loc-2", name: "SEA1 · Seattle", siteCode: "SEA1", city: "Seattle", state: "WA" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function locationDetail(id: string, contact: string, phone: string): Location {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
contact,
|
||||||
|
phone,
|
||||||
|
address: "100 Main St",
|
||||||
|
city: "Dallas",
|
||||||
|
state: "TX",
|
||||||
|
zipCode: "75001",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSiteDialogState() {
|
||||||
|
return renderHook(() =>
|
||||||
|
useSiteDialogState({
|
||||||
|
open: true,
|
||||||
|
onOpenChange: vi.fn(),
|
||||||
|
value: "",
|
||||||
|
locationId: "",
|
||||||
|
pocName: "",
|
||||||
|
pocPhone: "",
|
||||||
|
pocNotes: "",
|
||||||
|
sites: SITES,
|
||||||
|
createMode: true,
|
||||||
|
onSave: vi.fn(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useSiteDialogState POC autofill", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
detailById.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves POC typed before deferred location detail resolves", () => {
|
||||||
|
const { result, rerender } = renderSiteDialogState();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handlePick("loc-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setPn("Dispatcher Typed");
|
||||||
|
result.current.setPp("999-000-1111");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.pn).toBe("Dispatcher Typed");
|
||||||
|
expect(result.current.pp).toBe("999-000-1111");
|
||||||
|
|
||||||
|
detailById.set("loc-1", locationDetail("loc-1", "Site Contact", "214-555-0111"));
|
||||||
|
act(() => {
|
||||||
|
rerender();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.pn).toBe("Dispatcher Typed");
|
||||||
|
expect(result.current.pp).toBe("999-000-1111");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears prior POC on site switch then autofills from the new site detail", () => {
|
||||||
|
const { result, rerender } = renderSiteDialogState();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handlePick("loc-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
detailById.set("loc-1", locationDetail("loc-1", "Contact A", "111-111-1111"));
|
||||||
|
act(() => {
|
||||||
|
rerender();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.pn).toBe("Contact A");
|
||||||
|
expect(result.current.pp).toBe("111-111-1111");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handlePick("loc-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.locId).toBe("loc-2");
|
||||||
|
expect(result.current.pn).toBe("");
|
||||||
|
expect(result.current.pp).toBe("");
|
||||||
|
|
||||||
|
detailById.set("loc-2", locationDetail("loc-2", "Contact B", "222-222-2222"));
|
||||||
|
act(() => {
|
||||||
|
rerender();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.pn).toBe("Contact B");
|
||||||
|
expect(result.current.pp).toBe("222-222-2222");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,142 @@
|
||||||
|
import { renderHook } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { useSlideOverMediaState } from "@/app/(protected)/workorders/_components/detail/use-slide-over-media-state";
|
||||||
|
import type { WorkOrderBoardDetail } from "@/domain/work-orders/types/work-order-board-detail";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
function baseInfo(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||||
|
return {
|
||||||
|
id: 100,
|
||||||
|
woNumber: "100",
|
||||||
|
site: "SITE",
|
||||||
|
type: "Emergency",
|
||||||
|
dispatcherId: "d1",
|
||||||
|
dispatcherName: "Alex",
|
||||||
|
scheduledOn: "2026-07-16",
|
||||||
|
completedDate: "",
|
||||||
|
targetWeek: "",
|
||||||
|
dayGroup: "2026-07-16",
|
||||||
|
dayLabel: "Thu",
|
||||||
|
location: "Main",
|
||||||
|
locationId: 5,
|
||||||
|
pm: "",
|
||||||
|
serviceNotes: "",
|
||||||
|
extraServices: [],
|
||||||
|
vendorId: "v1",
|
||||||
|
company: "Acme",
|
||||||
|
tech: "Sam",
|
||||||
|
techPhone: "",
|
||||||
|
apptStart: "",
|
||||||
|
apptEnd: "",
|
||||||
|
apptTime: "",
|
||||||
|
status: "Incomplete",
|
||||||
|
docStatus: "",
|
||||||
|
pocName: "",
|
||||||
|
pocPhone: "",
|
||||||
|
pocNotes: "",
|
||||||
|
dueDate: "",
|
||||||
|
rescheduleCount: 0,
|
||||||
|
carriedOver: 0,
|
||||||
|
originalWeek: "",
|
||||||
|
originalDate: "",
|
||||||
|
isPastDue: false,
|
||||||
|
scheduleWeekOnly: false,
|
||||||
|
mediaCount: 0,
|
||||||
|
title: "",
|
||||||
|
priority: "",
|
||||||
|
primaryDispatchId: null,
|
||||||
|
rowVersion: "1",
|
||||||
|
dispatchRowVersion: "",
|
||||||
|
flagColor: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailWithMedia(
|
||||||
|
media: WorkOrderBoardDetail["media"],
|
||||||
|
infoOverrides: Partial<WorkOrderTableRow> = {},
|
||||||
|
): WorkOrderBoardDetail {
|
||||||
|
return {
|
||||||
|
info: baseInfo(infoOverrides),
|
||||||
|
completion: {
|
||||||
|
docStatus: "",
|
||||||
|
template: null,
|
||||||
|
signOffName: "",
|
||||||
|
signOffAttachment: "",
|
||||||
|
signOffSignature: "",
|
||||||
|
dispatchSignoffs: [],
|
||||||
|
},
|
||||||
|
comments: [],
|
||||||
|
audit: [],
|
||||||
|
media,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function idleMutation() {
|
||||||
|
return {
|
||||||
|
mutate: vi.fn(),
|
||||||
|
mutateAsync: vi.fn().mockResolvedValue({}),
|
||||||
|
isPending: false,
|
||||||
|
} as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useSlideOverMediaState completion filter", () => {
|
||||||
|
it("keeps Before/After/Completion after refetch mapped purpose (not file category)", () => {
|
||||||
|
const detail = detailWithMedia([
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
category: "Before",
|
||||||
|
url: "https://cdn.example.com/before.jpg",
|
||||||
|
fileName: "before.jpg",
|
||||||
|
uploadedAt: "2026-07-15T18:00:00.0000000Z",
|
||||||
|
isLegacy: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
category: "After",
|
||||||
|
url: "https://cdn.example.com/after.jpg",
|
||||||
|
fileName: "after.jpg",
|
||||||
|
uploadedAt: "2026-07-15T18:00:00.0000000Z",
|
||||||
|
isLegacy: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
category: "Completion",
|
||||||
|
url: "https://cdn.example.com/completion.pdf",
|
||||||
|
fileName: "completion.pdf",
|
||||||
|
uploadedAt: "2026-07-15T18:00:00.0000000Z",
|
||||||
|
isLegacy: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
category: "Extra",
|
||||||
|
url: "https://cdn.example.com/extra.pdf",
|
||||||
|
fileName: "extra.pdf",
|
||||||
|
uploadedAt: "2026-07-15T18:00:00.0000000Z",
|
||||||
|
isLegacy: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useSlideOverMediaState({
|
||||||
|
detailForRow: detail,
|
||||||
|
uploadMediaMutation: idleMutation(),
|
||||||
|
deleteMediaMutation: idleMutation(),
|
||||||
|
categorizeMediaMutation: idleMutation(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.completionMedia.map((item) => item.purpose)).toEqual([
|
||||||
|
"Before",
|
||||||
|
"After",
|
||||||
|
"Completion",
|
||||||
|
]);
|
||||||
|
expect(
|
||||||
|
result.current.completionMedia.every((item) =>
|
||||||
|
["Photo", "Video", "Report", "Other"].includes(item.category),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(result.current.extrasUploads).toHaveLength(1);
|
||||||
|
expect(result.current.extrasUploads[0]?.purpose).toBe("Extra");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -17,6 +17,7 @@ const unscheduledRow: WorkOrderTableRow = {
|
||||||
dispatcherId: "u1",
|
dispatcherId: "u1",
|
||||||
dispatcherName: "Test Dispatcher",
|
dispatcherName: "Test Dispatcher",
|
||||||
scheduledOn: "",
|
scheduledOn: "",
|
||||||
|
completedDate: "",
|
||||||
targetWeek: "",
|
targetWeek: "",
|
||||||
dayGroup: "",
|
dayGroup: "",
|
||||||
dayLabel: "",
|
dayLabel: "",
|
||||||
|
|
@ -38,7 +39,6 @@ const unscheduledRow: WorkOrderTableRow = {
|
||||||
pocPhone: "",
|
pocPhone: "",
|
||||||
pocNotes: "",
|
pocNotes: "",
|
||||||
dueDate: "2026-07-15",
|
dueDate: "2026-07-15",
|
||||||
completedDate: "",
|
|
||||||
rescheduleCount: 0,
|
rescheduleCount: 0,
|
||||||
carriedOver: 0,
|
carriedOver: 0,
|
||||||
originalWeek: "",
|
originalWeek: "",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
|
||||||
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
|
vi.mock("@/providers/auth-context", () => ({
|
||||||
|
useAuthContext: () => ({ user: { id: "u1" } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
||||||
|
useWorkOrderBoardDetail: () => ({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: true,
|
||||||
|
isFetching: true,
|
||||||
|
error: null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
|
||||||
|
useAddWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }),
|
||||||
|
useUpdateWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }),
|
||||||
|
useUploadWorkOrderMedia: () => ({ mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false }),
|
||||||
|
useUpdateWorkOrderMediaCategory: () => ({ mutate: vi.fn(), isPending: false }),
|
||||||
|
useDeleteWorkOrderMedia: () => ({ mutate: vi.fn(), isPending: false }),
|
||||||
|
useUploadCompletionDoc: () => ({ mutate: vi.fn(), isPending: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab", () => ({
|
||||||
|
SlideOverInfoTab: ({
|
||||||
|
draft,
|
||||||
|
onDraftChange,
|
||||||
|
}: {
|
||||||
|
draft: WorkOrderTableRow;
|
||||||
|
onDraftChange: (patch: Partial<WorkOrderTableRow>) => void;
|
||||||
|
}) => (
|
||||||
|
<label>
|
||||||
|
WO number
|
||||||
|
<input
|
||||||
|
aria-label="WO number"
|
||||||
|
value={draft.woNumber}
|
||||||
|
onChange={(event) => onDraftChange({ woNumber: event.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("react-toastify", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
|
||||||
|
|
||||||
|
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
|
||||||
|
return {
|
||||||
|
id: 42,
|
||||||
|
woNumber: "WO-42",
|
||||||
|
site: "SEA1",
|
||||||
|
type: "PM",
|
||||||
|
dispatcherId: "u1",
|
||||||
|
dispatcherName: "Dispatcher",
|
||||||
|
scheduledOn: "2026-07-20",
|
||||||
|
completedDate: "",
|
||||||
|
targetWeek: "",
|
||||||
|
dayGroup: "07/20",
|
||||||
|
dayLabel: "Mon",
|
||||||
|
location: "Seattle",
|
||||||
|
locationId: "loc-1",
|
||||||
|
pm: "HVAC",
|
||||||
|
serviceNotes: "",
|
||||||
|
extraServices: [],
|
||||||
|
company: "Vendor Co",
|
||||||
|
tech: "Tech",
|
||||||
|
techPhone: "",
|
||||||
|
vendorId: "v1",
|
||||||
|
apptStart: "",
|
||||||
|
apptEnd: "",
|
||||||
|
apptTime: "",
|
||||||
|
status: "Scheduled",
|
||||||
|
docStatus: "No",
|
||||||
|
pocName: "",
|
||||||
|
pocPhone: "",
|
||||||
|
pocNotes: "",
|
||||||
|
dueDate: "",
|
||||||
|
rescheduleCount: 0,
|
||||||
|
carriedOver: 0,
|
||||||
|
originalWeek: "",
|
||||||
|
originalDate: "",
|
||||||
|
isPastDue: false,
|
||||||
|
scheduleWeekOnly: false,
|
||||||
|
mediaCount: 0,
|
||||||
|
title: "PM — SEA1",
|
||||||
|
priority: "Sev 3",
|
||||||
|
primaryDispatchId: null,
|
||||||
|
rowVersion: "v1",
|
||||||
|
dispatchRowVersion: "",
|
||||||
|
flagColor: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("WorkOrderSlideOver failed-save retry (no detail yet)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps draft dirty and Save enabled after optimistic parent row + failed save", async () => {
|
||||||
|
const initial = baseRow();
|
||||||
|
let currentRow = initial;
|
||||||
|
const onSave = vi.fn(
|
||||||
|
(
|
||||||
|
_id: string | number,
|
||||||
|
patch: Partial<WorkOrderTableRow>,
|
||||||
|
options?: { onSuccess?: () => void; onError?: (error: Error) => void },
|
||||||
|
) => {
|
||||||
|
currentRow = { ...currentRow, ...patch };
|
||||||
|
rerenderWithRow(currentRow);
|
||||||
|
currentRow = initial;
|
||||||
|
rerenderWithRow(currentRow);
|
||||||
|
options?.onError?.(new Error("save failed"));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let rerenderWithRow = (_row: WorkOrderTableRow) => {
|
||||||
|
/* assigned after first render */
|
||||||
|
};
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<WorkOrderSlideOver
|
||||||
|
row={currentRow}
|
||||||
|
editMode
|
||||||
|
users={[{ id: "u1", name: "Dispatcher" }]}
|
||||||
|
sites={[{ id: "loc-1", name: "Seattle" }]}
|
||||||
|
vendors={[]}
|
||||||
|
pmTypes={["HVAC"]}
|
||||||
|
allOrders={[currentRow]}
|
||||||
|
onSave={onSave}
|
||||||
|
onOpenCompletionDoc={vi.fn()}
|
||||||
|
onCancelWO={vi.fn()}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
rerenderWithRow = (row) => {
|
||||||
|
view.rerender(
|
||||||
|
<WorkOrderSlideOver
|
||||||
|
row={row}
|
||||||
|
editMode
|
||||||
|
users={[{ id: "u1", name: "Dispatcher" }]}
|
||||||
|
sites={[{ id: "loc-1", name: "Seattle" }]}
|
||||||
|
vendors={[]}
|
||||||
|
pmTypes={["HVAC"]}
|
||||||
|
allOrders={[row]}
|
||||||
|
onSave={onSave}
|
||||||
|
onOpenCompletionDoc={vi.fn()}
|
||||||
|
onCancelWO={vi.fn()}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("WO number"), { target: { value: "WO-99" } });
|
||||||
|
expect(screen.getByDisplayValue("WO-99")).toBeInTheDocument();
|
||||||
|
|
||||||
|
const saveButton = screen.getByRole("button", { name: /^save$/i });
|
||||||
|
expect(saveButton).not.toBeDisabled();
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalled();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByDisplayValue("WO-99")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /^save$/i })).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -81,6 +81,13 @@ vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
||||||
useWorkOrderVendors: () => ({
|
useWorkOrderVendors: () => ({
|
||||||
data: [{ companyName: "Vendor Co", email: "real@vendor.com" }],
|
data: [{ companyName: "Vendor Co", email: "real@vendor.com" }],
|
||||||
}),
|
}),
|
||||||
|
useWorkOrderBoardDetail: () => ({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: false,
|
||||||
|
isFetching: false,
|
||||||
|
error: null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({
|
vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,13 @@ vi.mock("@/domain/work-orders/use-cases/use-work-orders-list", () => ({
|
||||||
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
|
||||||
useWorkOrderLocations: () => ({ data: [] }),
|
useWorkOrderLocations: () => ({ data: [] }),
|
||||||
useWorkOrderVendors: () => ({ data: [] }),
|
useWorkOrderVendors: () => ({ data: [] }),
|
||||||
|
useWorkOrderBoardDetail: () => ({
|
||||||
|
data: undefined,
|
||||||
|
isLoading: false,
|
||||||
|
isFetching: false,
|
||||||
|
error: null,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({
|
vi.mock("@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category", () => ({
|
||||||
|
|
@ -198,7 +205,7 @@ describe("WorkOrdersListHeader slice affordances", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("WorkOrdersListPage board-core public interface", () => {
|
describe("WorkOrdersListPage board-core public interface", () => {
|
||||||
it("opens New WO wizard on click, enforces step validation, closes, and View navigates to detail", async () => {
|
it("opens New WO wizard on click, enforces step validation, closes, and View opens the slide-over", async () => {
|
||||||
navigate.mockReset();
|
navigate.mockReset();
|
||||||
|
|
||||||
renderWithProviders(<WorkOrdersListPage />, { route: "/workorders", withAuth: false });
|
renderWithProviders(<WorkOrdersListPage />, { route: "/workorders", withAuth: false });
|
||||||
|
|
@ -217,7 +224,10 @@ describe("WorkOrdersListPage board-core public interface", () => {
|
||||||
await waitForElementToBeRemoved(() => screen.queryByRole("dialog"));
|
await waitForElementToBeRemoved(() => screen.queryByRole("dialog"));
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /view canceled row/i }));
|
fireEvent.click(screen.getByRole("button", { name: /view canceled row/i }));
|
||||||
expect(navigate).toHaveBeenCalledWith("/workorders/77");
|
|
||||||
|
expect(await screen.findByLabelText("Work order details")).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText(/WO-77/i).length).toBeGreaterThan(0);
|
||||||
|
expect(navigate).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -172,13 +172,40 @@ describe("mapWorkOrderBoardDetail", () => {
|
||||||
name: "before.jpg",
|
name: "before.jpg",
|
||||||
url: "https://cdn.example.com/before.jpg",
|
url: "https://cdn.example.com/before.jpg",
|
||||||
category: "Photo",
|
category: "Photo",
|
||||||
|
purpose: "Before",
|
||||||
fileType: "image",
|
fileType: "image",
|
||||||
isLegacy: false,
|
isLegacy: false,
|
||||||
});
|
});
|
||||||
expect(files[1]?.category).toBe("Other");
|
expect(files[1]?.category).toBe("Other");
|
||||||
|
expect(files[1]?.purpose).toBe("Extra");
|
||||||
expect(files[1]?.fileType).toBe("pdf");
|
expect(files[1]?.fileType).toBe("pdf");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("maps After and Completion board categories to purpose for completion UI", () => {
|
||||||
|
const after = mapBoardDetailMediaItem({
|
||||||
|
id: 20,
|
||||||
|
category: 2,
|
||||||
|
url: "https://cdn.example.com/after.jpg",
|
||||||
|
fileName: "after.jpg",
|
||||||
|
});
|
||||||
|
const completion = mapBoardDetailMediaItem({
|
||||||
|
id: 21,
|
||||||
|
category: 4,
|
||||||
|
url: "https://cdn.example.com/completion.pdf",
|
||||||
|
fileName: "completion.pdf",
|
||||||
|
});
|
||||||
|
const files = boardMediaItemsToMediaFiles([after, completion]);
|
||||||
|
expect(files[0]).toMatchObject({
|
||||||
|
category: "Photo",
|
||||||
|
purpose: "After",
|
||||||
|
});
|
||||||
|
expect(files[1]).toMatchObject({
|
||||||
|
category: "Report",
|
||||||
|
purpose: "Completion",
|
||||||
|
fileType: "pdf",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("maps numeric category and marks negative ids as legacy", () => {
|
it("maps numeric category and marks negative ids as legacy", () => {
|
||||||
const legacy = mapBoardDetailMediaItem({
|
const legacy = mapBoardDetailMediaItem({
|
||||||
id: -1,
|
id: -1,
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,21 @@ describe("mapWizardDraftToBoardCreatePayload", () => {
|
||||||
expect(payload.scheduledDate).toBeUndefined();
|
expect(payload.scheduledDate).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("omits week-mode flags when scheduleWeekOnly is set without a week", () => {
|
||||||
|
const payload = mapWizardDraftToBoardCreatePayload({
|
||||||
|
...EMPTY_WIZARD_DRAFT,
|
||||||
|
type: "PM",
|
||||||
|
siteCode: "BK5",
|
||||||
|
scheduleWeekOnly: true,
|
||||||
|
scheduledOn: "",
|
||||||
|
targetWeek: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(payload.scheduleWeekOnly).toBeUndefined();
|
||||||
|
expect(payload.targetWeek).toBeUndefined();
|
||||||
|
expect(payload.scheduledDate).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("omits appointment fields when vendor is missing but still sends techPhone and vendorNotes", () => {
|
it("omits appointment fields when vendor is missing but still sends techPhone and vendorNotes", () => {
|
||||||
const payload = mapWizardDraftToBoardCreatePayload({
|
const payload = mapWizardDraftToBoardCreatePayload({
|
||||||
...EMPTY_WIZARD_DRAFT,
|
...EMPTY_WIZARD_DRAFT,
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,10 @@ import type { ReactNode } from "react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { act, renderHook, waitFor } from "@testing-library/react";
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import type { WorkOrderDetail } from "@/domain/work-orders/types/work-order";
|
import type {
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
CompletionDocStatus,
|
||||||
|
WorkOrderTableRow,
|
||||||
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
||||||
const getById = vi.fn();
|
const getById = vi.fn();
|
||||||
const update = vi.fn();
|
const update = vi.fn();
|
||||||
|
|
@ -34,34 +36,18 @@ function makeWrapper() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE_DETAIL: WorkOrderDetail = {
|
type ServerState = {
|
||||||
id: 42,
|
status: string;
|
||||||
internalWONumber: "WO-42",
|
scheduledDate: string;
|
||||||
workOrderNumber: "WO-42",
|
completedDate: string;
|
||||||
title: "PM — SEA1",
|
docStatus: CompletionDocStatus;
|
||||||
location: "Seattle",
|
};
|
||||||
locationId: "loc-1",
|
|
||||||
locationZip: "98101",
|
const BASE_SERVER: ServerState = {
|
||||||
assignedTo: "Dispatcher",
|
|
||||||
dueDate: "",
|
|
||||||
createdDate: "2026-06-01",
|
|
||||||
createdDateFull: "2026-06-01T00:00:00Z",
|
|
||||||
description: "",
|
|
||||||
priority: "Sev 3",
|
|
||||||
status: "Open",
|
status: "Open",
|
||||||
po: "",
|
|
||||||
tt: "",
|
|
||||||
problem: "HVAC",
|
|
||||||
trade: "",
|
|
||||||
subTrade: "",
|
|
||||||
vendorNTE: "",
|
|
||||||
scheduledDate: "2026-06-01",
|
scheduledDate: "2026-06-01",
|
||||||
completedDate: "",
|
completedDate: "",
|
||||||
docStatus: "No",
|
docStatus: "No",
|
||||||
source: "PM",
|
|
||||||
comments: [],
|
|
||||||
auditLog: [],
|
|
||||||
dispatches: [],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const BASE_ROW = {
|
const BASE_ROW = {
|
||||||
|
|
@ -110,7 +96,7 @@ const BASE_ROW = {
|
||||||
} as WorkOrderTableRow;
|
} as WorkOrderTableRow;
|
||||||
|
|
||||||
describe("useWorkOrderTableMutations completion-doc persistence", () => {
|
describe("useWorkOrderTableMutations completion-doc persistence", () => {
|
||||||
let serverState: WorkOrderDetail;
|
let serverState: ServerState;
|
||||||
let events: string[];
|
let events: string[];
|
||||||
let rowState: WorkOrderTableRow;
|
let rowState: WorkOrderTableRow;
|
||||||
|
|
||||||
|
|
@ -118,7 +104,7 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => {
|
||||||
getById.mockReset();
|
getById.mockReset();
|
||||||
update.mockReset();
|
update.mockReset();
|
||||||
patchBoardField.mockReset();
|
patchBoardField.mockReset();
|
||||||
serverState = { ...BASE_DETAIL };
|
serverState = { ...BASE_SERVER };
|
||||||
rowState = { ...BASE_ROW };
|
rowState = { ...BASE_ROW };
|
||||||
events = [];
|
events = [];
|
||||||
|
|
||||||
|
|
@ -131,7 +117,7 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => {
|
||||||
await delay(5);
|
await delay(5);
|
||||||
if (payload.field === "docStatus" && typeof payload.value === "string") {
|
if (payload.field === "docStatus" && typeof payload.value === "string") {
|
||||||
const docStatus =
|
const docStatus =
|
||||||
payload.value === "NotNeeded" ? "NN" : (payload.value as WorkOrderDetail["docStatus"]);
|
payload.value === "NotNeeded" ? "NN" : (payload.value as CompletionDocStatus);
|
||||||
serverState = { ...serverState, docStatus };
|
serverState = { ...serverState, docStatus };
|
||||||
rowState = {
|
rowState = {
|
||||||
...rowState,
|
...rowState,
|
||||||
|
|
@ -217,7 +203,7 @@ describe("useWorkOrderTableMutations completion-doc persistence", () => {
|
||||||
|
|
||||||
expect(serverState.docStatus).toBe("Yes");
|
expect(serverState.docStatus).toBe("Yes");
|
||||||
expect(serverState.completedDate).toBe("2026-07-20");
|
expect(serverState.completedDate).toBe("2026-07-20");
|
||||||
expect(serverState.scheduledDate).toBe(BASE_DETAIL.scheduledDate);
|
expect(serverState.scheduledDate).toBe(BASE_SERVER.scheduledDate);
|
||||||
expect(serverState.status).toBe(BASE_DETAIL.status);
|
expect(serverState.status).toBe(BASE_SERVER.status);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
draftHasScheduleDate,
|
||||||
|
getInlineCreateMissing,
|
||||||
|
inlineCreateSaveHint,
|
||||||
|
inlineCreateSiteError,
|
||||||
|
} from "@/domain/work-orders/utils/get-inline-create-missing";
|
||||||
|
|
||||||
|
describe("getInlineCreateMissing", () => {
|
||||||
|
it("returns empty when Site, WO#, and POC are filled even without schedule", () => {
|
||||||
|
expect(
|
||||||
|
getInlineCreateMissing({
|
||||||
|
site: "BK5",
|
||||||
|
locationId: "12",
|
||||||
|
woNumber: "25001",
|
||||||
|
pocName: "Jane",
|
||||||
|
pocPhone: "555-0100",
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts locationId alone as site presence", () => {
|
||||||
|
expect(
|
||||||
|
getInlineCreateMissing({
|
||||||
|
site: "",
|
||||||
|
locationId: 42,
|
||||||
|
woNumber: "25001",
|
||||||
|
pocName: "Jane",
|
||||||
|
pocPhone: "555-0100",
|
||||||
|
}),
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists missing POC fields without requiring schedule", () => {
|
||||||
|
expect(
|
||||||
|
getInlineCreateMissing({
|
||||||
|
site: "BK5",
|
||||||
|
woNumber: "25001",
|
||||||
|
pocName: "",
|
||||||
|
pocPhone: "",
|
||||||
|
}),
|
||||||
|
).toEqual(["pocName", "pocPhone"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists site and woNumber when empty", () => {
|
||||||
|
expect(getInlineCreateMissing({})).toEqual(["site", "woNumber", "pocName", "pocPhone"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("inlineCreateSaveHint", () => {
|
||||||
|
it("returns Save when ready", () => {
|
||||||
|
expect(inlineCreateSaveHint([])).toBe("Save");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists missing labels", () => {
|
||||||
|
expect(inlineCreateSaveHint(["site", "woNumber"])).toBe("Missing: Site, WO #");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("inlineCreateSiteError", () => {
|
||||||
|
it("prefers site-required message when site is missing", () => {
|
||||||
|
expect(inlineCreateSiteError(["site", "pocName"])).toBe("Site is required");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces contact message when only POC is missing", () => {
|
||||||
|
expect(inlineCreateSiteError(["pocName", "pocPhone"])).toBe(
|
||||||
|
"Contact name and phone are required",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("draftHasScheduleDate", () => {
|
||||||
|
it("is true for a concrete day", () => {
|
||||||
|
expect(draftHasScheduleDate({ scheduledOn: "2026-06-25" })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is false for week-only", () => {
|
||||||
|
expect(
|
||||||
|
draftHasScheduleDate({ scheduledOn: "", scheduleWeekOnly: true, schedMode: "week" }),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue