mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
refactor(work-orders): split slide-over and CompDoc for governance
Extract focused hooks and sibling components so CompDoc, slide-over, media uploader, and list page meet maintainability ratchets.
This commit is contained in:
parent
ad30eb4a4d
commit
68b85940fe
53 changed files with 3222 additions and 2085 deletions
|
|
@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
import type { DocState } from "@/domain/work-orders/types/completion-doc";
|
||||||
|
import { CompDocDialogFooterDraftActions } from "./comp-doc-dialog-footer-draft-actions";
|
||||||
|
import { CompDocDialogFooterGeneratedActions } from "./comp-doc-dialog-footer-generated-actions";
|
||||||
|
import { CompDocDialogFooterNotRequiredActions } from "./comp-doc-dialog-footer-not-required-actions";
|
||||||
|
|
||||||
|
type CompDocDialogFooterProps = {
|
||||||
|
docState: DocState;
|
||||||
|
isDocumentsReadOnly: boolean;
|
||||||
|
rowVersion?: string | number | null;
|
||||||
|
isUploading: boolean;
|
||||||
|
signOffAttachment?: string | null;
|
||||||
|
generatedAt: string;
|
||||||
|
pdfInputRef: RefObject<HTMLInputElement | null>;
|
||||||
|
onMarkNotRequired: () => void;
|
||||||
|
onSaveDraft: () => void;
|
||||||
|
onGenerate: () => void;
|
||||||
|
onUndo: () => void;
|
||||||
|
onDownloadPdf: () => void;
|
||||||
|
onUploadPdf: (files: FileList | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocDialogFooter({
|
||||||
|
docState,
|
||||||
|
isDocumentsReadOnly,
|
||||||
|
rowVersion,
|
||||||
|
isUploading,
|
||||||
|
signOffAttachment,
|
||||||
|
generatedAt,
|
||||||
|
pdfInputRef,
|
||||||
|
onMarkNotRequired,
|
||||||
|
onSaveDraft,
|
||||||
|
onGenerate,
|
||||||
|
onUndo,
|
||||||
|
onDownloadPdf,
|
||||||
|
onUploadPdf,
|
||||||
|
}: CompDocDialogFooterProps) {
|
||||||
|
const showGenerated =
|
||||||
|
(docState === "generated" || Boolean(signOffAttachment)) && docState !== "not-required";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-center justify-between gap-3"
|
||||||
|
style={{
|
||||||
|
padding: "14px 20px",
|
||||||
|
borderTop: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={pdfInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf,.pdf"
|
||||||
|
hidden
|
||||||
|
onChange={(event) => {
|
||||||
|
onUploadPdf(event.target.files);
|
||||||
|
event.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{docState === "draft" && !isDocumentsReadOnly && (
|
||||||
|
<CompDocDialogFooterDraftActions
|
||||||
|
rowVersion={rowVersion}
|
||||||
|
isUploading={isUploading}
|
||||||
|
pdfInputRef={pdfInputRef}
|
||||||
|
onMarkNotRequired={onMarkNotRequired}
|
||||||
|
onSaveDraft={onSaveDraft}
|
||||||
|
onGenerate={onGenerate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{showGenerated && (
|
||||||
|
<CompDocDialogFooterGeneratedActions
|
||||||
|
generatedAt={generatedAt}
|
||||||
|
signOffAttachment={signOffAttachment}
|
||||||
|
isDocumentsReadOnly={isDocumentsReadOnly}
|
||||||
|
rowVersion={rowVersion}
|
||||||
|
isUploading={isUploading}
|
||||||
|
pdfInputRef={pdfInputRef}
|
||||||
|
onDownloadPdf={onDownloadPdf}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{docState === "not-required" && (
|
||||||
|
<CompDocDialogFooterNotRequiredActions
|
||||||
|
isDocumentsReadOnly={isDocumentsReadOnly}
|
||||||
|
onUndo={onUndo}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
import { FileText, X } from "lucide-react";
|
||||||
|
import type { CompletionDocStateConfig } 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 CompDocDialogHeaderProps = {
|
||||||
|
info: WorkOrderTableRow;
|
||||||
|
cfg: CompletionDocStateConfig;
|
||||||
|
isUploading: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocDialogHeader({ info, cfg, isUploading, onClose }: CompDocDialogHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex shrink-0 items-center justify-between"
|
||||||
|
style={{
|
||||||
|
height: 60,
|
||||||
|
padding: "0 20px",
|
||||||
|
borderBottom: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 items-center gap-2.5">
|
||||||
|
<FileText size={18} className="shrink-0" style={{ color: "var(--color-primary)" }} />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: "var(--font-display)",
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
lineHeight: 1.1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Completion Document
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="truncate"
|
||||||
|
style={{
|
||||||
|
fontSize: 13,
|
||||||
|
color: "var(--muted-foreground)",
|
||||||
|
marginTop: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
WO{" "}
|
||||||
|
<span className="font-mono" style={{ color: "var(--color-primary)", fontWeight: 600 }}>
|
||||||
|
#{info.woNumber}
|
||||||
|
</span>
|
||||||
|
{" · "}
|
||||||
|
{serviceTitle(info)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5"
|
||||||
|
style={{
|
||||||
|
height: 26,
|
||||||
|
padding: "0 10px",
|
||||||
|
borderRadius: 9999,
|
||||||
|
background: cfg.pillBg,
|
||||||
|
color: cfg.pillText,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: 9999,
|
||||||
|
background: cfg.pillText,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{cfg.pillLabel}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isUploading}
|
||||||
|
aria-label="Close"
|
||||||
|
className="flex items-center justify-center rounded-md transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-40"
|
||||||
|
style={{
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,55 +1,16 @@
|
||||||
import { useEffect, useMemo, useRef, useState, type MouseEvent } from "react";
|
import type { MouseEvent } from "react";
|
||||||
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
import { Dialog, DialogContent, DialogTitle } from "@mui/material";
|
||||||
import {
|
|
||||||
Ban,
|
|
||||||
CheckCircle2,
|
|
||||||
Download,
|
|
||||||
FileText,
|
|
||||||
Save,
|
|
||||||
Sparkles,
|
|
||||||
Undo2,
|
|
||||||
Upload,
|
|
||||||
X,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { toast } from "react-toastify";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
|
||||||
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 type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
|
import type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
import { CompDocDialogFooter } from "./comp-doc-dialog-footer";
|
||||||
import { useUploadCompletionDoc } from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
import { CompDocDialogHeader } from "./comp-doc-dialog-header";
|
||||||
import { buildDocHtml, printDoc } from "@/domain/work-orders/utils/completion-doc-html";
|
import { CompDocDialogResourceLinks } from "./comp-doc-dialog-resource-links";
|
||||||
import { resolveCompDocSite } from "@/domain/work-orders/utils/completion-doc-site";
|
|
||||||
import {
|
|
||||||
DOC_STATE_CFG,
|
|
||||||
docStateOf,
|
|
||||||
fmtDocNow,
|
|
||||||
} from "@/domain/work-orders/utils/completion-doc-state";
|
|
||||||
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
|
|
||||||
import {
|
|
||||||
formatMediaSizeLimit,
|
|
||||||
isWorkOrderDocumentsReadOnly,
|
|
||||||
isWorkOrderFileTooLarge,
|
|
||||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
|
||||||
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";
|
||||||
const FOOTER_BTN_SX = {
|
|
||||||
minHeight: 28,
|
|
||||||
height: 28,
|
|
||||||
fontSize: 12,
|
|
||||||
px: 1.25,
|
|
||||||
py: 0.5,
|
|
||||||
gap: 0.75,
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
type CompDocDialogProps = {
|
type CompDocDialogProps = {
|
||||||
row: WorkOrderTableRow;
|
row: WorkOrderTableRow;
|
||||||
|
|
@ -70,155 +31,15 @@ export function CompDocDialog({
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
onDateCompletedChange,
|
onDateCompletedChange,
|
||||||
}: CompDocDialogProps) {
|
}: CompDocDialogProps) {
|
||||||
const locId = String(row.locationId || "");
|
const dialog = useCompDocDialog({
|
||||||
const { data: locationDetail } = useLocationDetail(locId || undefined);
|
row,
|
||||||
const { data: boardDetail } = useWorkOrderBoardDetail(row.id, open);
|
|
||||||
const uploadCompletionMutation = useUploadCompletionDoc(row.id);
|
|
||||||
const isUploading = uploadCompletionMutation.isPending;
|
|
||||||
const pdfInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
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("");
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
setNotes(data?.notes ?? "");
|
|
||||||
setDocState(docStateOf(completion?.docStatus || info.docStatus));
|
|
||||||
setGeneratedAt(data?.generatedAt ?? "");
|
|
||||||
setEditDateCompleted(info.scheduledOn || "");
|
|
||||||
}, [
|
|
||||||
open,
|
open,
|
||||||
info.id,
|
data,
|
||||||
info.docStatus,
|
onSave,
|
||||||
info.scheduledOn,
|
onStatusChange,
|
||||||
completion?.docStatus,
|
onDateCompletedChange,
|
||||||
data?.notes,
|
});
|
||||||
data?.generatedAt,
|
const handleClose = dialog.isUploading ? undefined : () => onOpenChange(false);
|
||||||
]);
|
|
||||||
|
|
||||||
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 = (extra: Partial<CompDocData>, status: CompDocSaveStatus) => {
|
|
||||||
onSave({ notes, generatedAt, ...extra }, status);
|
|
||||||
};
|
|
||||||
|
|
||||||
function handleMarkNotRequired() {
|
|
||||||
setDocState("not-required");
|
|
||||||
persist({}, "NN");
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleUndo() {
|
|
||||||
setDocState("draft");
|
|
||||||
persist({}, "No");
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSaveDraft() {
|
|
||||||
persist({}, "No");
|
|
||||||
if (editDateCompleted !== info.scheduledOn) {
|
|
||||||
onDateCompletedChange?.(editDateCompleted);
|
|
||||||
}
|
|
||||||
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)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CheckCircle2 size={16} /> Draft saved
|
|
||||||
</div>,
|
|
||||||
{
|
|
||||||
position: "bottom-center",
|
|
||||||
autoClose: 3000,
|
|
||||||
hideProgressBar: true,
|
|
||||||
closeButton: false,
|
|
||||||
icon: false,
|
|
||||||
className: "!min-h-0 !bg-transparent !p-0 !shadow-none",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleGenerate() {
|
|
||||||
const ts = fmtDocNow();
|
|
||||||
setGeneratedAt(ts);
|
|
||||||
setDocState("generated");
|
|
||||||
persist({ generatedAt: ts }, "Yes");
|
|
||||||
if (editDateCompleted !== info.scheduledOn) {
|
|
||||||
onDateCompletedChange?.(editDateCompleted);
|
|
||||||
}
|
|
||||||
onStatusChange?.("Completed");
|
|
||||||
}
|
|
||||||
|
|
||||||
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: () => {
|
|
||||||
const ts = fmtDocNow();
|
|
||||||
setGeneratedAt(ts);
|
|
||||||
setDocState("generated");
|
|
||||||
persist({ generatedAt: ts }, "Yes");
|
|
||||||
if (editDateCompleted !== info.scheduledOn) {
|
|
||||||
onDateCompletedChange?.(editDateCompleted);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleClose = isUploading ? undefined : () => onOpenChange(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
|
|
@ -249,300 +70,65 @@ export function CompDocDialog({
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogTitle className="sr-only">Completion Document — WO #{info.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">
|
||||||
<div
|
<CompDocDialogHeader
|
||||||
className="flex shrink-0 items-center justify-between"
|
info={dialog.info}
|
||||||
style={{
|
cfg={dialog.cfg}
|
||||||
height: 60,
|
isUploading={dialog.isUploading}
|
||||||
padding: "0 20px",
|
onClose={() => handleClose?.()}
|
||||||
borderBottom: "1px solid var(--color-border)",
|
/>
|
||||||
background: "var(--color-bg-surface)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 items-center gap-2.5">
|
|
||||||
<FileText size={18} className="shrink-0" style={{ color: "var(--color-primary)" }} />
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
fontFamily: "var(--font-display)",
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
lineHeight: 1.1,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Completion Document
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="truncate"
|
|
||||||
style={{
|
|
||||||
fontSize: 13,
|
|
||||||
color: "var(--muted-foreground)",
|
|
||||||
marginTop: 2,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
WO{" "}
|
|
||||||
<span
|
|
||||||
className="font-mono"
|
|
||||||
style={{ color: "var(--color-primary)", fontWeight: 600 }}
|
|
||||||
>
|
|
||||||
#{info.woNumber}
|
|
||||||
</span>
|
|
||||||
{" · "}
|
|
||||||
{serviceTitle(info)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center gap-1.5"
|
|
||||||
style={{
|
|
||||||
height: 26,
|
|
||||||
padding: "0 10px",
|
|
||||||
borderRadius: 9999,
|
|
||||||
background: cfg.pillBg,
|
|
||||||
color: cfg.pillText,
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 600,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
width: 6,
|
|
||||||
height: 6,
|
|
||||||
borderRadius: 9999,
|
|
||||||
background: cfg.pillText,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{cfg.pillLabel}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
if (!isUploading) onOpenChange(false);
|
|
||||||
}}
|
|
||||||
disabled={isUploading}
|
|
||||||
aria-label="Close"
|
|
||||||
className="flex items-center justify-center rounded-md transition-colors hover:bg-[var(--color-bg-muted)] disabled:opacity-40"
|
|
||||||
style={{
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{Boolean(templateUrl || signOffAttachment) && (
|
<CompDocDialogResourceLinks
|
||||||
<div
|
templateUrl={dialog.templateUrl}
|
||||||
className="flex shrink-0 flex-wrap items-center gap-3 px-5 py-2"
|
signOffAttachment={dialog.signOffAttachment}
|
||||||
style={{
|
/>
|
||||||
borderBottom: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-bg-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{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} />
|
|
||||||
Open uploaded PDF
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-row">
|
<div className="flex min-h-0 flex-1 flex-row">
|
||||||
<CompDocPreview
|
<CompDocPreview
|
||||||
row={info}
|
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={info}
|
row={dialog.info}
|
||||||
site={site}
|
site={dialog.site}
|
||||||
docState={docState}
|
docState={dialog.docState}
|
||||||
generatedAt={generatedAt}
|
generatedAt={dialog.generatedAt}
|
||||||
notes={notes}
|
notes={dialog.notes}
|
||||||
editDateCompleted={editDateCompleted}
|
editDateCompleted={dialog.editDateCompleted}
|
||||||
amazonRep={amazonRep}
|
amazonRep={dialog.amazonRep}
|
||||||
seaRep={seaRep}
|
seaRep={dialog.seaRep}
|
||||||
isGenerated={isGenerated}
|
isGenerated={dialog.isGenerated}
|
||||||
onNotesChange={setNotes}
|
onNotesChange={dialog.setNotes}
|
||||||
onDateCompletedChange={setEditDateCompleted}
|
onDateCompletedChange={dialog.setEditDateCompleted}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<CompDocDialogFooter
|
||||||
className="flex shrink-0 items-center justify-between gap-3"
|
docState={dialog.docState}
|
||||||
style={{
|
isDocumentsReadOnly={dialog.isDocumentsReadOnly}
|
||||||
padding: "14px 20px",
|
rowVersion={dialog.rowVersion}
|
||||||
borderTop: "1px solid var(--color-border)",
|
isUploading={dialog.isUploading}
|
||||||
background: "var(--color-bg-surface)",
|
signOffAttachment={dialog.signOffAttachment}
|
||||||
}}
|
generatedAt={dialog.generatedAt}
|
||||||
>
|
pdfInputRef={dialog.pdfInputRef}
|
||||||
<input
|
onMarkNotRequired={dialog.handleMarkNotRequired}
|
||||||
ref={pdfInputRef}
|
onSaveDraft={dialog.handleSaveDraft}
|
||||||
type="file"
|
onGenerate={dialog.handleGenerate}
|
||||||
accept="application/pdf,.pdf"
|
onUndo={dialog.handleUndo}
|
||||||
hidden
|
onDownloadPdf={dialog.handleDownloadPdf}
|
||||||
onChange={(event) => {
|
onUploadPdf={dialog.handleUploadPdf}
|
||||||
handleUploadPdf(event.target.files);
|
/>
|
||||||
event.target.value = "";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{docState === "draft" && !isDocumentsReadOnly && (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="default"
|
|
||||||
className="gap-1.5 whitespace-nowrap text-[12px]"
|
|
||||||
sx={FOOTER_BTN_SX}
|
|
||||||
style={{ color: "var(--muted-foreground)" }}
|
|
||||||
onClick={handleMarkNotRequired}
|
|
||||||
>
|
|
||||||
<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={FOOTER_BTN_SX}
|
|
||||||
onClick={handleSaveDraft}
|
|
||||||
>
|
|
||||||
<Save size={14} /> Save draft
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="default"
|
|
||||||
className="gap-1.5 whitespace-nowrap text-[12px]"
|
|
||||||
sx={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={FOOTER_BTN_SX}
|
|
||||||
style={{ background: "var(--color-primary)", color: "#fff" }}
|
|
||||||
onClick={handleGenerate}
|
|
||||||
>
|
|
||||||
<Sparkles size={14} /> Generate
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{(docState === "generated" || Boolean(signOffAttachment)) &&
|
|
||||||
docState !== "not-required" && (
|
|
||||||
<>
|
|
||||||
<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={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={FOOTER_BTN_SX}
|
|
||||||
style={{ background: "var(--color-primary)", color: "#fff" }}
|
|
||||||
onClick={handleDownloadPdf}
|
|
||||||
>
|
|
||||||
<Download size={14} /> Download PDF
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{docState === "not-required" && (
|
|
||||||
<>
|
|
||||||
<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={FOOTER_BTN_SX}
|
|
||||||
onClick={handleUndo}
|
|
||||||
>
|
|
||||||
<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={FOOTER_BTN_SX}
|
|
||||||
style={{
|
|
||||||
background: "var(--color-primary)",
|
|
||||||
color: "#fff",
|
|
||||||
opacity: 0.5,
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Sparkles size={14} /> Generate
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
|
|
@ -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,12 +1,8 @@
|
||||||
import { Ban, Building2, CheckCircle2, FileText, Mail, MapPin, Phone, Printer } from "lucide-react";
|
|
||||||
import { Text } from "@/components/ui/text";
|
|
||||||
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 { SEAHAVEN_HEADER } from "@/domain/work-orders/utils/completion-doc-html";
|
import { CompDocPreviewDocument } from "./comp-doc-preview-document";
|
||||||
import { procedureFor, SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
|
import { CompDocPreviewNotRequiredStamp } from "./comp-doc-preview-not-required-stamp";
|
||||||
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
|
import { CompDocPreviewStatusBadge } from "./comp-doc-preview-status-badge";
|
||||||
import { CompDocCoLine } from "./comp-doc-co-line";
|
|
||||||
import { CompDocProcedureList } from "./comp-doc-procedure-list";
|
|
||||||
|
|
||||||
type CompDocPreviewProps = {
|
type CompDocPreviewProps = {
|
||||||
row: WorkOrderTableRow;
|
row: WorkOrderTableRow;
|
||||||
|
|
@ -25,8 +21,6 @@ export function CompDocPreview({
|
||||||
isGenerated,
|
isGenerated,
|
||||||
isNotRequired,
|
isNotRequired,
|
||||||
}: CompDocPreviewProps) {
|
}: CompDocPreviewProps) {
|
||||||
const proc = procedureFor(row.pm);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex min-h-0 flex-col"
|
className="flex min-h-0 flex-col"
|
||||||
|
|
@ -36,28 +30,7 @@ export function CompDocPreview({
|
||||||
background: "var(--color-bg-muted)",
|
background: "var(--color-bg-muted)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex shrink-0 items-center justify-center" style={{ padding: "12px 0" }}>
|
<CompDocPreviewStatusBadge isGenerated={isGenerated} />
|
||||||
<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>
|
|
||||||
|
|
||||||
<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"
|
||||||
|
|
@ -75,216 +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" }}>
|
|
||||||
<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)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isGenerated && (
|
{isNotRequired && <CompDocPreviewNotRequiredStamp />}
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: 24,
|
|
||||||
background: "linear-gradient(100deg,#1C75BC,#27AAE1)",
|
|
||||||
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isNotRequired && (
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import type { CSSProperties } from "react";
|
|
||||||
import { Building2, MapPin, Users, Wrench } from "lucide-react";
|
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";
|
||||||
|
|
@ -7,15 +6,7 @@ import {
|
||||||
docStatusCardDescription,
|
docStatusCardDescription,
|
||||||
} from "@/domain/work-orders/utils/completion-doc-state";
|
} from "@/domain/work-orders/utils/completion-doc-state";
|
||||||
import { CompDocDetailRow } from "./comp-doc-detail-row";
|
import { CompDocDetailRow } from "./comp-doc-detail-row";
|
||||||
|
import { CompDocEditRow } from "./comp-doc-edit-row";
|
||||||
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 CompDocSidebarProps = {
|
type CompDocSidebarProps = {
|
||||||
row: WorkOrderTableRow;
|
row: WorkOrderTableRow;
|
||||||
|
|
@ -47,38 +38,6 @@ export function CompDocSidebar({
|
||||||
const cfg = DOC_STATE_CFG[docState];
|
const cfg = DOC_STATE_CFG[docState];
|
||||||
const StateIcon = cfg.icon;
|
const StateIcon = cfg.icon;
|
||||||
|
|
||||||
const editRow = (
|
|
||||||
label: string,
|
|
||||||
value: string,
|
|
||||||
onChange: (v: string) => void,
|
|
||||||
type = "text",
|
|
||||||
placeholder = "",
|
|
||||||
) => (
|
|
||||||
<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={isGenerated}
|
|
||||||
placeholder={placeholder}
|
|
||||||
className={COMP_DOC_INPUT_CLS}
|
|
||||||
style={{
|
|
||||||
...COMP_DOC_INPUT_STYLE,
|
|
||||||
...(isGenerated
|
|
||||||
? {
|
|
||||||
opacity: 0.6,
|
|
||||||
cursor: "default",
|
|
||||||
pointerEvents: "none" as const,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex min-h-0 flex-col"
|
className="flex min-h-0 flex-col"
|
||||||
|
|
@ -150,7 +109,13 @@ export function CompDocSidebar({
|
||||||
value={`${row.site} · Amazon.com Services LLC`}
|
value={`${row.site} · Amazon.com Services LLC`}
|
||||||
/>
|
/>
|
||||||
<CompDocDetailRow icon={MapPin} label="Address" value={site.fullAddress} />
|
<CompDocDetailRow icon={MapPin} label="Address" value={site.fullAddress} />
|
||||||
{editRow("Date Work Completed", editDateCompleted, onDateCompletedChange, "date")}
|
<CompDocEditRow
|
||||||
|
label="Date Work Completed"
|
||||||
|
value={editDateCompleted}
|
||||||
|
onChange={onDateCompletedChange}
|
||||||
|
type="date"
|
||||||
|
readOnly={isGenerated}
|
||||||
|
/>
|
||||||
<CompDocDetailRow icon={Users} label="Amazon Representative" value={amazonRep || "—"} />
|
<CompDocDetailRow icon={Users} label="Amazon Representative" value={amazonRep || "—"} />
|
||||||
<CompDocDetailRow
|
<CompDocDetailRow
|
||||||
icon={Wrench}
|
icon={Wrench}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,196 @@
|
||||||
|
import { useEffect, 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 type { WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
|
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,
|
||||||
|
docStateOf,
|
||||||
|
fmtDocNow,
|
||||||
|
} from "@/domain/work-orders/utils/completion-doc-state";
|
||||||
|
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||||
|
import { useCompDocPdfActions } from "./use-comp-doc-pdf-actions";
|
||||||
|
|
||||||
|
type UseCompDocDialogArgs = {
|
||||||
|
row: WorkOrderTableRow;
|
||||||
|
open: boolean;
|
||||||
|
data?: CompDocData;
|
||||||
|
onSave: (data: CompDocData, status: CompDocSaveStatus) => void;
|
||||||
|
onStatusChange?: (status: WizardWOStatus) => void;
|
||||||
|
onDateCompletedChange?: (date: string) => 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,
|
||||||
|
onStatusChange,
|
||||||
|
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 isUploading = uploadCompletionMutation.isPending;
|
||||||
|
const pdfInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
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("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setNotes(data?.notes ?? "");
|
||||||
|
setDocState(docStateOf(completion?.docStatus || info.docStatus));
|
||||||
|
setGeneratedAt(data?.generatedAt ?? "");
|
||||||
|
setEditDateCompleted(info.scheduledOn || "");
|
||||||
|
}, [
|
||||||
|
open,
|
||||||
|
info.id,
|
||||||
|
info.docStatus,
|
||||||
|
info.scheduledOn,
|
||||||
|
completion?.docStatus,
|
||||||
|
data?.notes,
|
||||||
|
data?.generatedAt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
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 = (extra: Partial<CompDocData>, status: CompDocSaveStatus) => {
|
||||||
|
onSave({ notes, generatedAt, ...extra }, status);
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncDateIfChanged = () => {
|
||||||
|
if (editDateCompleted !== info.scheduledOn) {
|
||||||
|
onDateCompletedChange?.(editDateCompleted);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleMarkNotRequired() {
|
||||||
|
setDocState("not-required");
|
||||||
|
persist({}, "NN");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUndo() {
|
||||||
|
setDocState("draft");
|
||||||
|
persist({}, "No");
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaveDraft() {
|
||||||
|
persist({}, "No");
|
||||||
|
syncDateIfChanged();
|
||||||
|
notifyDraftSaved();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleGenerate() {
|
||||||
|
const ts = fmtDocNow();
|
||||||
|
setGeneratedAt(ts);
|
||||||
|
setDocState("generated");
|
||||||
|
persist({ generatedAt: ts }, "Yes");
|
||||||
|
syncDateIfChanged();
|
||||||
|
onStatusChange?.("Completed");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { handleDownloadPdf, handleUploadPdf } = useCompDocPdfActions({
|
||||||
|
info,
|
||||||
|
signOffAttachment,
|
||||||
|
rowVersion,
|
||||||
|
notes,
|
||||||
|
generatedAt,
|
||||||
|
docFields,
|
||||||
|
uploadCompletionMutation,
|
||||||
|
persist,
|
||||||
|
syncDateIfChanged,
|
||||||
|
setGeneratedAt,
|
||||||
|
markGenerated: () => setDocState("generated"),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
info,
|
||||||
|
templateUrl,
|
||||||
|
signOffAttachment,
|
||||||
|
rowVersion,
|
||||||
|
isDocumentsReadOnly,
|
||||||
|
site,
|
||||||
|
notes,
|
||||||
|
setNotes,
|
||||||
|
docState,
|
||||||
|
generatedAt,
|
||||||
|
editDateCompleted,
|
||||||
|
setEditDateCompleted,
|
||||||
|
amazonRep,
|
||||||
|
seaRep,
|
||||||
|
cfg,
|
||||||
|
isNotRequired,
|
||||||
|
isGenerated,
|
||||||
|
isUploading,
|
||||||
|
pdfInputRef,
|
||||||
|
handleMarkNotRequired,
|
||||||
|
handleUndo,
|
||||||
|
handleSaveDraft,
|
||||||
|
handleGenerate,
|
||||||
|
handleDownloadPdf,
|
||||||
|
handleUploadPdf,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
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) => void;
|
||||||
|
syncDateIfChanged: () => void;
|
||||||
|
setGeneratedAt: (value: string) => void;
|
||||||
|
markGenerated: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCompDocPdfActions({
|
||||||
|
info,
|
||||||
|
signOffAttachment,
|
||||||
|
rowVersion,
|
||||||
|
notes,
|
||||||
|
generatedAt,
|
||||||
|
docFields,
|
||||||
|
uploadCompletionMutation,
|
||||||
|
persist,
|
||||||
|
syncDateIfChanged,
|
||||||
|
setGeneratedAt,
|
||||||
|
markGenerated,
|
||||||
|
}: 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: () => {
|
||||||
|
const ts = fmtDocNow();
|
||||||
|
setGeneratedAt(ts);
|
||||||
|
markGenerated();
|
||||||
|
persist({ generatedAt: ts }, "Yes");
|
||||||
|
syncDateIfChanged();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { handleDownloadPdf, handleUploadPdf };
|
||||||
|
}
|
||||||
|
|
@ -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,40 @@
|
||||||
|
import { Image as ImageIcon } from "lucide-react";
|
||||||
|
import { MediaUploader } 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?: (files: File[], category: UploadableMediaCategory) => void;
|
||||||
|
onRemoveMedia?: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SlideOverCompletionMedia({
|
||||||
|
isReadOnly,
|
||||||
|
media,
|
||||||
|
mediaPending,
|
||||||
|
onUploadMedia,
|
||||||
|
onRemoveMedia,
|
||||||
|
}: 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}
|
||||||
|
onRemove={onRemoveMedia}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { Ban, CheckCircle2, Download, FileText, Image as ImageIcon, Upload } from "lucide-react";
|
import { Download, FileText } from "lucide-react";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Text } from "@/components/ui/text";
|
|
||||||
import { MediaUploader } from "@/app/(protected)/workorders/_components/media/media-uploader";
|
|
||||||
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 { WorkOrderBoardCompletion } from "@/domain/work-orders/types/work-order-board-detail";
|
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 { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
|
|
@ -13,6 +10,10 @@ import {
|
||||||
formatMediaSizeLimit,
|
formatMediaSizeLimit,
|
||||||
isWorkOrderFileTooLarge,
|
isWorkOrderFileTooLarge,
|
||||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
} 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 {
|
function isMediaWO(type: WOType | ""): boolean {
|
||||||
return type === "Emergency" || type === "Reactive";
|
return type === "Emergency" || type === "Reactive";
|
||||||
|
|
@ -50,23 +51,13 @@ export function SlideOverCompletionTab({
|
||||||
|
|
||||||
if (isMediaWO(row.type)) {
|
if (isMediaWO(row.type)) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<SlideOverCompletionMedia
|
||||||
<div className="flex items-center gap-1.5">
|
isReadOnly={isReadOnly}
|
||||||
<ImageIcon size={14} style={{ color: "var(--foreground)" }} />
|
media={media}
|
||||||
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--foreground)" }}>
|
mediaPending={mediaPending}
|
||||||
Photos & Videos
|
onUploadMedia={onUploadMedia}
|
||||||
</span>
|
onRemoveMedia={onRemoveMedia}
|
||||||
</div>
|
/>
|
||||||
<div style={isReadOnly ? { opacity: 0.6, pointerEvents: "none" } : undefined}>
|
|
||||||
<MediaUploader
|
|
||||||
uploads={media}
|
|
||||||
onUpload={onUploadMedia}
|
|
||||||
onRemove={onRemoveMedia}
|
|
||||||
readOnly={isReadOnly}
|
|
||||||
pending={mediaPending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,115 +111,23 @@ export function SlideOverCompletionTab({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isReadOnly ? (
|
{isReadOnly ? (
|
||||||
docStatus === "Yes" ? (
|
<SlideOverCompletionReadonlyStatus
|
||||||
<Button
|
docStatus={docStatus}
|
||||||
variant="outline"
|
onOpenCompletionDoc={onOpenCompletionDoc}
|
||||||
className="w-full gap-1.5 text-[12px]"
|
/>
|
||||||
onClick={onOpenCompletionDoc}
|
|
||||||
>
|
|
||||||
<CheckCircle2 size={13} />
|
|
||||||
View Document
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
)
|
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<SlideOverCompletionActions
|
||||||
{Boolean(onUploadCompletionPdf) && (
|
docStatus={docStatus}
|
||||||
<>
|
rowVersion={row.rowVersion}
|
||||||
<Button
|
completionPending={completionPending}
|
||||||
variant="outline"
|
hasUploadHandler={Boolean(onUploadCompletionPdf)}
|
||||||
className="w-full gap-1.5 text-[12px]"
|
pdfInputRef={pdfInputRef}
|
||||||
disabled={!row.rowVersion}
|
onPdfSelected={handlePdfSelected}
|
||||||
loading={completionPending}
|
onOpenCompletionDoc={onOpenCompletionDoc}
|
||||||
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) => {
|
|
||||||
handlePdfSelected(event.target.files);
|
|
||||||
event.target.value = "";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{!row.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}
|
|
||||||
>
|
|
||||||
{docStatus === "Yes" ? (
|
|
||||||
<>
|
|
||||||
<CheckCircle2 size={13} />
|
|
||||||
View / Edit Document
|
|
||||||
</>
|
|
||||||
) : docStatus === "NN" ? (
|
|
||||||
<>
|
|
||||||
<Ban size={13} />
|
|
||||||
Document Not Required — Review
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<FileText size={13} />
|
|
||||||
Open completion document
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{completion != null && completion.dispatchSignoffs.length > 0 && (
|
<SlideOverCompletionSignoffs completion={completion} />
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</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,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)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,69 +1,11 @@
|
||||||
import { useState } from "react";
|
|
||||||
import { Image as ImageIcon } from "lucide-react";
|
|
||||||
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 { VendorDialog } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog";
|
|
||||||
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 { PmTypeIcon } from "@/app/(protected)/workorders/_components/list/table/pm-type-icons";
|
|
||||||
import { SlideOverInfoRow } from "@/app/(protected)/workorders/_components/detail/slide-over-info-row";
|
|
||||||
import type {
|
import type {
|
||||||
LocationOption,
|
LocationOption,
|
||||||
VendorDropdownItem,
|
VendorDropdownItem,
|
||||||
WorkOrderUser,
|
WorkOrderUser,
|
||||||
} from "@/domain/work-orders/types/work-order";
|
} from "@/domain/work-orders/types/work-order";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import type { WOType, WizardWOStatus } from "@/domain/work-orders/types/work-order-wizard";
|
import { SlideOverInfoTabDisplayView } from "./slide-over-info-tab-display-view";
|
||||||
import { getDispatcherAvatarColor } from "@/domain/work-orders/utils/dispatcher-avatar-color";
|
import { SlideOverInfoTabEditView } from "./slide-over-info-tab-edit-view";
|
||||||
import { formatApptRange, parseApptTime } from "@/domain/work-orders/utils/wizard-date-utils";
|
|
||||||
|
|
||||||
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" },
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type SlideOverInfoTabProps = {
|
type SlideOverInfoTabProps = {
|
||||||
row: WorkOrderTableRow;
|
row: WorkOrderTableRow;
|
||||||
|
|
@ -92,242 +34,31 @@ export function SlideOverInfoTab({
|
||||||
onDraftChange,
|
onDraftChange,
|
||||||
onDuplicateFound,
|
onDuplicateFound,
|
||||||
}: SlideOverInfoTabProps) {
|
}: SlideOverInfoTabProps) {
|
||||||
const [vendorDlg, setVendorDlg] = useState(false);
|
const isEditing = editing && !isCancelled;
|
||||||
const source = editing && !isCancelled ? draft : row;
|
const source = isEditing ? draft : row;
|
||||||
const appt = parseApptTime(source.apptTime);
|
|
||||||
const apptDisplay = appt.start ? formatApptRange(appt.start, appt.end) : source.apptTime || "—";
|
|
||||||
const dispatcher = users.find((u) => String(u.id) === source.dispatcherId);
|
const dispatcher = users.find((u) => String(u.id) === source.dispatcherId);
|
||||||
|
|
||||||
if (editing && !isCancelled) {
|
if (isEditing) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<SlideOverInfoTabEditView
|
||||||
<SlideOverInfoRow label="WO #">
|
draft={draft}
|
||||||
<EditableWONumberCell
|
users={users}
|
||||||
value={draft.woNumber}
|
sites={sites}
|
||||||
rescheduleCount={draft.rescheduleCount}
|
vendors={vendors}
|
||||||
carriedOver={draft.carriedOver}
|
pmTypes={pmTypes}
|
||||||
currentId={draft.id}
|
allOrders={allOrders}
|
||||||
allOrders={allOrders}
|
onDraftChange={onDraftChange}
|
||||||
onSave={(woNumber) => onDraftChange({ woNumber })}
|
onDuplicateFound={onDuplicateFound}
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={isCancelled ? { opacity: 0.6, pointerEvents: "none" } : undefined}>
|
<SlideOverInfoTabDisplayView
|
||||||
<SlideOverInfoRow label="WO #">
|
source={source}
|
||||||
<span className="font-mono text-[12.5px] font-semibold">{source.woNumber || "—"}</span>
|
isCancelled={isCancelled}
|
||||||
</SlideOverInfoRow>
|
dispatcher={dispatcher}
|
||||||
<SlideOverInfoRow label="Site">
|
vendors={vendors}
|
||||||
<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">
|
|
||||||
{dispatcher || source.dispatcherName ? (
|
|
||||||
<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 || source.dispatcherName}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span style={{ color: "var(--color-text-muted)" }}>—</span>
|
|
||||||
)}
|
|
||||||
</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">
|
|
||||||
{source.tech || source.company ? (
|
|
||||||
<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>
|
|
||||||
) : (
|
|
||||||
<span style={{ color: "var(--color-text-muted)" }}>—</span>
|
|
||||||
)}
|
|
||||||
<VendorDialog
|
|
||||||
open={vendorDlg}
|
|
||||||
onOpenChange={setVendorDlg}
|
|
||||||
company={source.company}
|
|
||||||
tech={source.tech}
|
|
||||||
techPhone={source.techPhone}
|
|
||||||
vendors={vendors}
|
|
||||||
onSave={() => setVendorDlg(false)}
|
|
||||||
/>
|
|
||||||
</SlideOverInfoRow>
|
|
||||||
<SlideOverInfoRow label="Status">
|
|
||||||
{source.isPastDue ? (
|
|
||||||
<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>
|
|
||||||
) : source.status ? (
|
|
||||||
<WoTableStatusLabel status={source.status} />
|
|
||||||
) : (
|
|
||||||
<span style={{ color: "var(--color-text-muted)" }}>—</span>
|
|
||||||
)}
|
|
||||||
</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,145 @@
|
||||||
|
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);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDraft(row);
|
||||||
|
setBaseline(row);
|
||||||
|
setEditing(editMode && (row.status as string) !== "Canceled");
|
||||||
|
setTab("info");
|
||||||
|
setShowUnsaved(false);
|
||||||
|
// 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) {
|
||||||
|
setShowUnsaved(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
if (isDirty) {
|
||||||
|
setShowUnsaved(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dirtyBaseline) setDraft(dirtyBaseline);
|
||||||
|
setEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startEditing = () => {
|
||||||
|
if (!infoSource) return;
|
||||||
|
setDraft(infoSource);
|
||||||
|
setBaseline(infoSource);
|
||||||
|
setEditing(true);
|
||||||
|
setTab("info");
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
editing,
|
||||||
|
activeDraft,
|
||||||
|
isDirty,
|
||||||
|
showUnsaved,
|
||||||
|
setShowUnsaved,
|
||||||
|
handleDraftChange,
|
||||||
|
commit,
|
||||||
|
discard,
|
||||||
|
requestClose,
|
||||||
|
handleCancelEdit,
|
||||||
|
startEditing,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
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 { runSequentialUploads } from "@/domain/work-orders/utils/run-sequential-uploads";
|
||||||
|
import type {
|
||||||
|
useDeleteWorkOrderMedia,
|
||||||
|
useUploadWorkOrderMedia,
|
||||||
|
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||||
|
|
||||||
|
const COMPLETION_MEDIA_CATEGORIES = new Set(["Before", "After", "Completion"]);
|
||||||
|
|
||||||
|
type UseSlideOverMediaStateArgs = {
|
||||||
|
detailForRow?: WorkOrderBoardDetail;
|
||||||
|
uploadMediaMutation: ReturnType<typeof useUploadWorkOrderMedia>;
|
||||||
|
deleteMediaMutation: ReturnType<typeof useDeleteWorkOrderMedia>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useSlideOverMediaState({
|
||||||
|
detailForRow,
|
||||||
|
uploadMediaMutation,
|
||||||
|
deleteMediaMutation,
|
||||||
|
}: UseSlideOverMediaStateArgs) {
|
||||||
|
const [mediaBatchPending, setMediaBatchPending] = useState(false);
|
||||||
|
|
||||||
|
const completionMedia = useMemo(
|
||||||
|
() =>
|
||||||
|
boardMediaItemsToMediaFiles(
|
||||||
|
(detailForRow?.media ?? []).filter((item) =>
|
||||||
|
COMPLETION_MEDIA_CATEGORIES.has(item.category),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
[detailForRow?.media],
|
||||||
|
);
|
||||||
|
|
||||||
|
const extrasUploads = useMemo(
|
||||||
|
() =>
|
||||||
|
boardMediaItemsToMediaFiles(
|
||||||
|
(detailForRow?.media ?? []).filter((item) => item.category === "Extra"),
|
||||||
|
),
|
||||||
|
[detailForRow?.media],
|
||||||
|
);
|
||||||
|
|
||||||
|
const mediaPending =
|
||||||
|
mediaBatchPending || uploadMediaMutation.isPending || deleteMediaMutation.isPending;
|
||||||
|
|
||||||
|
const uploadMediaFiles = async (files: File[], category: UploadableMediaCategory) => {
|
||||||
|
await runSequentialUploads(
|
||||||
|
files,
|
||||||
|
(file) => uploadMediaMutation.mutateAsync({ file, category }),
|
||||||
|
setMediaBatchPending,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { completionMedia, extrasUploads, mediaPending, uploadMediaFiles };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
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,
|
||||||
|
useUploadCompletionDoc,
|
||||||
|
useUploadWorkOrderMedia,
|
||||||
|
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
||||||
|
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||||
|
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 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, uploadMediaFiles } = useSlideOverMediaState(
|
||||||
|
{
|
||||||
|
detailForRow,
|
||||||
|
uploadMediaMutation,
|
||||||
|
deleteMediaMutation,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
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) {
|
||||||
|
toast.error("Missing work order version. Refresh and try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploadCompletionMutation.mutate({ file, workOrderVersion: version });
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
uploadMediaFiles,
|
||||||
|
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,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,32 +1,9 @@
|
||||||
import { useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionDetails,
|
|
||||||
AccordionSummary,
|
|
||||||
Box,
|
|
||||||
Paper,
|
|
||||||
Tab,
|
|
||||||
Tabs,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableContainer,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
TextField,
|
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
|
|
||||||
import { CommentTimeline } from "@/components/common/comment-timeline";
|
|
||||||
import { mapWorkOrderCommentsToTimeline } from "@/components/common/comment-timeline-mappers";
|
|
||||||
import { DetailField, DetailFieldGrid } from "@/components/common/detail-field";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { AuditTimeline } from "./audit-timeline";
|
|
||||||
import { WorkOrderAssigneeMenu } from "./work-order-assignee-menu";
|
|
||||||
import type { WorkOrderDetail, WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
import type { WorkOrderDetail, WorkOrderUser } from "@/domain/work-orders/types/work-order";
|
||||||
import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
|
import { WorkOrderActivitySection } from "./work-order-activity-section";
|
||||||
|
import { WorkOrderDescriptionAccordion } from "./work-order-description-accordion";
|
||||||
type ActivityTab = "customer" | "internal" | "audit";
|
import { WorkOrderDetailsCard } from "./work-order-details-card";
|
||||||
|
import { WorkOrderDispatchesAccordion } from "./work-order-dispatches-accordion";
|
||||||
|
import { WorkOrderSimpleAccordions } from "./work-order-simple-accordions";
|
||||||
|
|
||||||
type WorkOrderDetailsSectionProps = {
|
type WorkOrderDetailsSectionProps = {
|
||||||
wo: WorkOrderDetail;
|
wo: WorkOrderDetail;
|
||||||
|
|
@ -51,219 +28,22 @@ export function WorkOrderDetailsSection({
|
||||||
onCommentSubmit,
|
onCommentSubmit,
|
||||||
isCommentPending,
|
isCommentPending,
|
||||||
}: WorkOrderDetailsSectionProps) {
|
}: WorkOrderDetailsSectionProps) {
|
||||||
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Paper variant="outlined" className="p-4">
|
<WorkOrderDetailsCard
|
||||||
<Typography variant="subtitle1" className="mb-3 font-semibold">
|
wo={wo}
|
||||||
Details
|
users={users}
|
||||||
</Typography>
|
onAssign={onAssign}
|
||||||
<DetailFieldGrid>
|
isAssignPending={isAssignPending}
|
||||||
<DetailField label="Internal WO" value={wo.internalWONumber} />
|
/>
|
||||||
<DetailField label="Customer WO" value={wo.workOrderNumber || "—"} />
|
<WorkOrderSimpleAccordions wo={wo} />
|
||||||
<DetailField label="Priority" value={wo.priority || "—"} />
|
<WorkOrderDispatchesAccordion wo={wo} onDispatchClick={onDispatchClick} />
|
||||||
<DetailField label="Location" value={wo.location || "—"} />
|
<WorkOrderDescriptionAccordion wo={wo} />
|
||||||
<Box>
|
<WorkOrderActivitySection
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
|
wo={wo}
|
||||||
Assigned To
|
onCommentSubmit={onCommentSubmit}
|
||||||
</Typography>
|
isCommentPending={isCommentPending}
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<Accordion>
|
|
||||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
|
||||||
<Typography sx={{ fontWeight: 600 }}>Description</Typography>
|
|
||||||
</AccordionSummary>
|
|
||||||
<AccordionDetails>
|
|
||||||
<Typography>{wo.description || "No description provided."}</Typography>
|
|
||||||
</AccordionDetails>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
<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,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,154 @@
|
||||||
|
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 { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||||
|
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.isCancelled}
|
||||||
|
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={(files: File[], category: UploadableMediaCategory) => {
|
||||||
|
void state.uploadMediaFiles(files, category);
|
||||||
|
}}
|
||||||
|
onRemoveMedia={(id) => state.deleteMediaMutation.mutate(id)}
|
||||||
|
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.uploadMediaFiles(files, "Extra");
|
||||||
|
}}
|
||||||
|
onRemove={(id) => state.deleteMediaMutation.mutate(id)}
|
||||||
|
/>
|
||||||
|
</WorkOrderSlideOverTabPanel>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,16 +1,7 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { Box } from "@mui/material";
|
import { Box } from "@mui/material";
|
||||||
import { toast } from "react-toastify";
|
|
||||||
import { SideSheet } from "@/components/ui/side-sheet";
|
import { SideSheet } from "@/components/ui/side-sheet";
|
||||||
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
|
||||||
import { SlideOverHeader } from "@/app/(protected)/workorders/_components/detail/slide-over-header";
|
import { SlideOverHeader } from "@/app/(protected)/workorders/_components/detail/slide-over-header";
|
||||||
import { SlideOverFooter } from "@/app/(protected)/workorders/_components/detail/slide-over-footer";
|
import { SlideOverFooter } from "@/app/(protected)/workorders/_components/detail/slide-over-footer";
|
||||||
import { SlideOverInfoTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-info-tab";
|
|
||||||
import { SlideOverCommentsTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-comments-tab";
|
|
||||||
import { SlideOverAuditTab } from "@/app/(protected)/workorders/_components/detail/tabs/slide-over-audit-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 { SlideOverTabSkeleton } from "@/app/(protected)/workorders/_components/loading/slide-over-tab-skeleton";
|
|
||||||
import type {
|
import type {
|
||||||
LocationOption,
|
LocationOption,
|
||||||
VendorDropdownItem,
|
VendorDropdownItem,
|
||||||
|
|
@ -20,33 +11,9 @@ import type {
|
||||||
WorkOrderTablePatch,
|
WorkOrderTablePatch,
|
||||||
WorkOrderTableRow,
|
WorkOrderTableRow,
|
||||||
} from "@/domain/work-orders/types/work-order-table-row";
|
} from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import { boardMediaItemsToMediaFiles } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
import { useWorkOrderSlideOver } from "./use-work-order-slide-over";
|
||||||
import { useWorkOrderBoardDetail } from "@/domain/work-orders/use-cases/use-work-order-detail";
|
import { WorkOrderSlideOverTabNav } from "./work-order-slide-over-tab-nav";
|
||||||
import {
|
import { WorkOrderSlideOverTabs } from "./work-order-slide-over-tabs";
|
||||||
useAddWorkOrderComment,
|
|
||||||
useDeleteWorkOrderMedia,
|
|
||||||
useUpdateWorkOrderComment,
|
|
||||||
useUploadCompletionDoc,
|
|
||||||
useUploadWorkOrderMedia,
|
|
||||||
} from "@/domain/work-orders/use-cases/use-work-order-mutations";
|
|
||||||
import {
|
|
||||||
buildSlideOverPatch,
|
|
||||||
isSlideOverDraftDirty,
|
|
||||||
} from "@/domain/work-orders/utils/slide-over-draft";
|
|
||||||
import { runSequentialUploads } from "@/domain/work-orders/utils/run-sequential-uploads";
|
|
||||||
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
|
|
||||||
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
|
||||||
import { useAuthContext } from "@/providers/auth-context";
|
|
||||||
|
|
||||||
type SlideOverTab = "info" | "comments" | "audit" | "completion" | "extras";
|
|
||||||
|
|
||||||
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" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export type SlideOverSaveOptions = {
|
export type SlideOverSaveOptions = {
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
|
|
@ -84,166 +51,14 @@ export function WorkOrderSlideOver({
|
||||||
onClose,
|
onClose,
|
||||||
saving,
|
saving,
|
||||||
}: WorkOrderSlideOverProps) {
|
}: WorkOrderSlideOverProps) {
|
||||||
const { user } = useAuthContext();
|
const state = useWorkOrderSlideOver({ row, editMode, onSave, onClose, saving });
|
||||||
const workOrderId = row?.id;
|
const { infoSource, activeDraft } = state;
|
||||||
const {
|
|
||||||
data: detail,
|
|
||||||
isLoading,
|
|
||||||
isFetching,
|
|
||||||
error,
|
|
||||||
refetch,
|
|
||||||
} = useWorkOrderBoardDetail(workOrderId, Boolean(row));
|
|
||||||
const commentMutation = useAddWorkOrderComment(workOrderId ?? "");
|
|
||||||
const updateCommentMutation = useUpdateWorkOrderComment(workOrderId ?? "");
|
|
||||||
const uploadMediaMutation = useUploadWorkOrderMedia(workOrderId ?? "");
|
|
||||||
const deleteMediaMutation = useDeleteWorkOrderMedia(workOrderId ?? "");
|
|
||||||
const uploadCompletionMutation = useUploadCompletionDoc(workOrderId ?? "");
|
|
||||||
|
|
||||||
const [tab, setTab] = useState<SlideOverTab>("info");
|
|
||||||
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);
|
|
||||||
/** Tracks multi-file upload batches; mutation.isPending alone can flip false early. */
|
|
||||||
const [mediaBatchPending, setMediaBatchPending] = useState(false);
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDraft(row);
|
|
||||||
setBaseline(row);
|
|
||||||
setEditing(editMode && (row.status as string) !== "Canceled");
|
|
||||||
setTab("info");
|
|
||||||
setShowUnsaved(false);
|
|
||||||
// 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 completionMedia = useMemo(
|
|
||||||
() =>
|
|
||||||
boardMediaItemsToMediaFiles(
|
|
||||||
(detailForRow?.media ?? []).filter(
|
|
||||||
(item) =>
|
|
||||||
item.category === "Before" ||
|
|
||||||
item.category === "After" ||
|
|
||||||
item.category === "Completion",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
[detailForRow?.media],
|
|
||||||
);
|
|
||||||
|
|
||||||
const extrasUploads = useMemo(
|
|
||||||
() =>
|
|
||||||
boardMediaItemsToMediaFiles(
|
|
||||||
(detailForRow?.media ?? []).filter((item) => item.category === "Extra"),
|
|
||||||
),
|
|
||||||
[detailForRow?.media],
|
|
||||||
);
|
|
||||||
|
|
||||||
const mediaPending =
|
|
||||||
mediaBatchPending || uploadMediaMutation.isPending || deleteMediaMutation.isPending;
|
|
||||||
const completionPending = uploadCompletionMutation.isPending;
|
|
||||||
const closeDisabled = Boolean(saving || mediaPending || completionPending);
|
|
||||||
|
|
||||||
const handleDraftChange = (patch: Partial<WorkOrderTableRow>) => {
|
|
||||||
setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
|
|
||||||
};
|
|
||||||
|
|
||||||
const uploadMediaFiles = async (files: File[], category: UploadableMediaCategory) => {
|
|
||||||
await runSequentialUploads(
|
|
||||||
files,
|
|
||||||
(file) => uploadMediaMutation.mutateAsync({ file, category }),
|
|
||||||
setMediaBatchPending,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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) {
|
|
||||||
setShowUnsaved(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCancelEdit = () => {
|
|
||||||
if (isDirty) {
|
|
||||||
setShowUnsaved(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (dirtyBaseline) setDraft(dirtyBaseline);
|
|
||||||
setEditing(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const showDetailSkeleton = isLoading && !detailForRow;
|
|
||||||
const errorMessage =
|
|
||||||
error instanceof Error ? error.message : "Failed to load work order details.";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SideSheet
|
<SideSheet
|
||||||
open={Boolean(row)}
|
open={Boolean(row)}
|
||||||
onClose={requestClose}
|
onClose={state.requestClose}
|
||||||
closeDisabled={closeDisabled}
|
closeDisabled={state.closeDisabled}
|
||||||
widthClass="w-[480px] max-w-[480px]"
|
widthClass="w-[480px] max-w-[480px]"
|
||||||
aria-label="Work order details"
|
aria-label="Work order details"
|
||||||
>
|
>
|
||||||
|
|
@ -251,170 +66,43 @@ export function WorkOrderSlideOver({
|
||||||
<Box className="flex h-full flex-col overflow-hidden">
|
<Box className="flex h-full flex-col overflow-hidden">
|
||||||
<SlideOverHeader
|
<SlideOverHeader
|
||||||
row={infoSource}
|
row={infoSource}
|
||||||
isCancelled={isCancelled}
|
isCancelled={state.isCancelled}
|
||||||
isFetching={isFetching && !isLoading}
|
isFetching={state.isFetching && !state.isLoading}
|
||||||
closeDisabled={closeDisabled}
|
closeDisabled={state.closeDisabled}
|
||||||
onClose={requestClose}
|
onClose={state.requestClose}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<WorkOrderSlideOverTabNav tab={state.tab} saving={saving} onTabChange={state.setTab} />
|
||||||
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={() => setTab(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>
|
|
||||||
|
|
||||||
<Box className="flex-1 overflow-y-auto p-5">
|
<WorkOrderSlideOverTabs
|
||||||
{tab === "info" && (
|
tab={state.tab}
|
||||||
<SlideOverInfoTab
|
state={state}
|
||||||
row={infoSource}
|
saving={saving}
|
||||||
draft={activeDraft}
|
users={users}
|
||||||
editing={editing && !saving}
|
sites={sites}
|
||||||
isCancelled={isCancelled}
|
vendors={vendors}
|
||||||
users={users}
|
pmTypes={pmTypes}
|
||||||
sites={sites}
|
allOrders={allOrders}
|
||||||
vendors={vendors}
|
onDuplicateFound={onDuplicateFound}
|
||||||
pmTypes={pmTypes}
|
onOpenCompletionDoc={() => onOpenCompletionDoc(infoSource)}
|
||||||
allOrders={allOrders}
|
/>
|
||||||
onDraftChange={handleDraftChange}
|
|
||||||
onDuplicateFound={onDuplicateFound}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "comments" && (
|
|
||||||
<>
|
|
||||||
{showDetailSkeleton && <SlideOverTabSkeleton variant="comments" />}
|
|
||||||
{Boolean(error) && !detailForRow && (
|
|
||||||
<QueryErrorPanel compact message={errorMessage} onRetry={() => void refetch()} />
|
|
||||||
)}
|
|
||||||
{detailForRow != null && (
|
|
||||||
<SlideOverCommentsTab
|
|
||||||
comments={detailForRow.comments}
|
|
||||||
isReadOnly={isCancelled}
|
|
||||||
isPending={commentMutation.isPending}
|
|
||||||
currentUserId={user?.id}
|
|
||||||
isUpdatePending={updateCommentMutation.isPending}
|
|
||||||
onSubmit={(text, options) => commentMutation.mutate({ text }, options)}
|
|
||||||
onUpdateComment={(commentId, text, options) =>
|
|
||||||
updateCommentMutation.mutate({ commentId, text }, options)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "audit" && (
|
|
||||||
<>
|
|
||||||
{showDetailSkeleton && <SlideOverTabSkeleton variant="audit" />}
|
|
||||||
{Boolean(error) && !detailForRow && (
|
|
||||||
<QueryErrorPanel compact message={errorMessage} onRetry={() => void refetch()} />
|
|
||||||
)}
|
|
||||||
{detailForRow != null && (
|
|
||||||
<SlideOverAuditTab entries={detailForRow.audit} users={users} />
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "completion" && (
|
|
||||||
<>
|
|
||||||
{showDetailSkeleton && <SlideOverTabSkeleton variant="completion" />}
|
|
||||||
{Boolean(error) && !detailForRow && (
|
|
||||||
<QueryErrorPanel compact message={errorMessage} onRetry={() => void refetch()} />
|
|
||||||
)}
|
|
||||||
{Boolean(detailForRow || !isLoading) && (
|
|
||||||
<SlideOverCompletionTab
|
|
||||||
row={infoSource}
|
|
||||||
isReadOnly={isDocumentsReadOnly}
|
|
||||||
completion={detailForRow?.completion}
|
|
||||||
media={completionMedia}
|
|
||||||
mediaPending={mediaPending}
|
|
||||||
completionPending={completionPending}
|
|
||||||
onUploadMedia={(files, category) => {
|
|
||||||
void uploadMediaFiles(files, category);
|
|
||||||
}}
|
|
||||||
onRemoveMedia={(id) => deleteMediaMutation.mutate(id)}
|
|
||||||
onUploadCompletionPdf={(file) => {
|
|
||||||
const version = infoSource.rowVersion;
|
|
||||||
if (!version) {
|
|
||||||
toast.error("Missing work order version. Refresh and try again.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
uploadCompletionMutation.mutate({
|
|
||||||
file,
|
|
||||||
workOrderVersion: version,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onOpenCompletionDoc={() => onOpenCompletionDoc(infoSource)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === "extras" && (
|
|
||||||
<>
|
|
||||||
{showDetailSkeleton && <SlideOverTabSkeleton variant="extras" />}
|
|
||||||
{Boolean(error) && !detailForRow && (
|
|
||||||
<QueryErrorPanel compact message={errorMessage} onRetry={() => void refetch()} />
|
|
||||||
)}
|
|
||||||
{Boolean(detailForRow || !isLoading) && (
|
|
||||||
<SlideOverExtrasTab
|
|
||||||
uploads={extrasUploads}
|
|
||||||
isReadOnly={isDocumentsReadOnly}
|
|
||||||
pending={mediaPending}
|
|
||||||
onUpload={(files) => {
|
|
||||||
void uploadMediaFiles(files, "Extra");
|
|
||||||
}}
|
|
||||||
onRemove={(id) => deleteMediaMutation.mutate(id)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<SlideOverFooter
|
<SlideOverFooter
|
||||||
isCancelled={isCancelled}
|
isCancelled={state.isCancelled}
|
||||||
editing={editing}
|
editing={state.editing}
|
||||||
isDirty={isDirty}
|
isDirty={state.isDirty}
|
||||||
showUnsaved={showUnsaved}
|
showUnsaved={state.showUnsaved}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
onCancelWO={() => onCancelWO(infoSource)}
|
onCancelWO={() => onCancelWO(infoSource)}
|
||||||
onCancelEdit={handleCancelEdit}
|
onCancelEdit={state.handleCancelEdit}
|
||||||
onClose={requestClose}
|
onClose={state.requestClose}
|
||||||
onEdit={() => {
|
onEdit={state.startEditing}
|
||||||
const source = infoSource;
|
onSave={state.commit}
|
||||||
setDraft(source);
|
|
||||||
setBaseline(source);
|
|
||||||
setEditing(true);
|
|
||||||
setTab("info");
|
|
||||||
}}
|
|
||||||
onSave={commit}
|
|
||||||
onDiscard={() => {
|
onDiscard={() => {
|
||||||
discard();
|
state.discard();
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
onKeepEditing={() => setShowUnsaved(false)}
|
onKeepEditing={() => state.setShowUnsaved(false)}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
import { Upload } from "lucide-react";
|
||||||
|
import type { UploadableMediaCategory } from "@/domain/work-orders/mappers/work-order-board-detail-mapper";
|
||||||
|
import { formatMediaSizeLimit } from "@/domain/work-orders/utils/work-order-media-limits";
|
||||||
|
|
||||||
|
const PHOTO_CATEGORIES: UploadableMediaCategory[] = ["Before", "After"];
|
||||||
|
|
||||||
|
type MediaUploaderDropzoneProps = {
|
||||||
|
inputRef: RefObject<HTMLInputElement | null>;
|
||||||
|
category: UploadableMediaCategory;
|
||||||
|
onCategoryChange: (category: UploadableMediaCategory) => void;
|
||||||
|
showCategoryPicker: boolean;
|
||||||
|
pending: boolean;
|
||||||
|
onFilesSelected: (files: FileList | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MediaUploaderDropzone({
|
||||||
|
inputRef,
|
||||||
|
category,
|
||||||
|
onCategoryChange,
|
||||||
|
showCategoryPicker,
|
||||||
|
pending,
|
||||||
|
onFilesSelected,
|
||||||
|
}: MediaUploaderDropzoneProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showCategoryPicker && (
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<label
|
||||||
|
className="text-[11px]"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
htmlFor="media-upload-category"
|
||||||
|
>
|
||||||
|
Category
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="media-upload-category"
|
||||||
|
value={category}
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(event) => onCategoryChange(event.target.value as UploadableMediaCategory)}
|
||||||
|
className="rounded border px-2 py-1 text-[11px]"
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--color-border)",
|
||||||
|
color: "var(--color-text-primary)",
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{PHOTO_CATEGORIES.map((value) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{value}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onFilesSelected(e.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: 120,
|
||||||
|
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)" }}>
|
||||||
|
{pending ? "Uploading…" : "Drag files here or click to browse"}
|
||||||
|
</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="image/*,video/*"
|
||||||
|
multiple
|
||||||
|
hidden
|
||||||
|
disabled={pending}
|
||||||
|
onChange={(e) => {
|
||||||
|
onFilesSelected(e.target.files);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
import {
|
||||||
|
File as FileIcon,
|
||||||
|
FileText,
|
||||||
|
Film,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Paperclip,
|
||||||
|
Play,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
|
import { canDeleteWorkOrderMedia } 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 MediaUploaderGridItemProps = {
|
||||||
|
file: MediaFile;
|
||||||
|
readOnly: boolean;
|
||||||
|
pending: boolean;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MediaUploaderGridItem({
|
||||||
|
file,
|
||||||
|
readOnly,
|
||||||
|
pending,
|
||||||
|
onRemove,
|
||||||
|
}: MediaUploaderGridItemProps) {
|
||||||
|
const TypeIcon = FILETYPE_ICON[file.fileType];
|
||||||
|
const showDelete = !readOnly && canDeleteWorkOrderMedia(file);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="overflow-hidden rounded-lg border"
|
||||||
|
style={{ borderColor: "var(--color-border)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
style={{
|
||||||
|
aspectRatio: "1 / 1",
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{file.fileType === "image" ? (
|
||||||
|
<img
|
||||||
|
src={file.url}
|
||||||
|
alt={file.name}
|
||||||
|
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||||
|
/>
|
||||||
|
) : file.fileType === "video" ? (
|
||||||
|
<>
|
||||||
|
<video src={file.url} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
||||||
|
<span className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<Play size={18} style={{ color: "#fff" }} />
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<TypeIcon size={18} style={{ color: "var(--color-text-muted)" }} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{showDelete && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRemove(file.id)}
|
||||||
|
title="Remove"
|
||||||
|
disabled={pending}
|
||||||
|
className="absolute flex items-center justify-center transition-opacity hover:opacity-80 disabled:opacity-50"
|
||||||
|
style={{
|
||||||
|
top: 4,
|
||||||
|
right: 4,
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
borderRadius: 4,
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X size={11} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="px-1 py-1">
|
||||||
|
<div className="truncate" style={{ fontSize: 10, color: "var(--color-text-muted)" }}>
|
||||||
|
{file.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
|
import { MediaUploaderGridItem } from "./media-uploader-grid-item";
|
||||||
|
|
||||||
|
type MediaUploaderGridProps = {
|
||||||
|
uploads: MediaFile[];
|
||||||
|
readOnly: boolean;
|
||||||
|
pending: boolean;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MediaUploaderGrid({
|
||||||
|
uploads,
|
||||||
|
readOnly,
|
||||||
|
pending,
|
||||||
|
onRemove,
|
||||||
|
}: MediaUploaderGridProps) {
|
||||||
|
if (uploads.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||||
|
{uploads.map((file) => (
|
||||||
|
<MediaUploaderGridItem
|
||||||
|
key={file.id}
|
||||||
|
file={file}
|
||||||
|
readOnly={readOnly}
|
||||||
|
pending={pending}
|
||||||
|
onRemove={onRemove}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,33 +1,15 @@
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import {
|
|
||||||
File as FileIcon,
|
|
||||||
FileText,
|
|
||||||
Film,
|
|
||||||
Image as ImageIcon,
|
|
||||||
Paperclip,
|
|
||||||
Play,
|
|
||||||
Upload,
|
|
||||||
X,
|
|
||||||
} from "lucide-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 { FileCategory, MediaFile } from "@/domain/work-orders/types/work-order-media";
|
import type { MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||||
import {
|
import {
|
||||||
canDeleteWorkOrderMedia,
|
canDeleteWorkOrderMedia,
|
||||||
formatMediaSizeLimit,
|
formatMediaSizeLimit,
|
||||||
isWorkOrderFileTooLarge,
|
isWorkOrderFileTooLarge,
|
||||||
} from "@/domain/work-orders/utils/work-order-media-limits";
|
} from "@/domain/work-orders/utils/work-order-media-limits";
|
||||||
import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils";
|
import { makeMediaFile } from "@/domain/work-orders/utils/work-order-media-utils";
|
||||||
|
import { MediaUploaderDropzone } from "./media-uploader-dropzone";
|
||||||
const FILETYPE_ICON: Record<FileCategory, typeof ImageIcon> = {
|
import { MediaUploaderGrid } from "./media-uploader-grid";
|
||||||
image: ImageIcon,
|
|
||||||
video: Film,
|
|
||||||
pdf: FileText,
|
|
||||||
doc: FileIcon,
|
|
||||||
other: Paperclip,
|
|
||||||
};
|
|
||||||
|
|
||||||
const PHOTO_CATEGORIES: UploadableMediaCategory[] = ["Before", "After"];
|
|
||||||
|
|
||||||
type MediaUploaderProps = {
|
type MediaUploaderProps = {
|
||||||
uploads: MediaFile[];
|
uploads: MediaFile[];
|
||||||
|
|
@ -88,148 +70,21 @@ export function MediaUploader({
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{!readOnly && (
|
{!readOnly && (
|
||||||
<>
|
<MediaUploaderDropzone
|
||||||
{Boolean(onUpload) && (
|
inputRef={inputRef}
|
||||||
<div className="mb-2 flex items-center gap-2">
|
category={category}
|
||||||
<label
|
onCategoryChange={setCategory}
|
||||||
className="text-[11px]"
|
showCategoryPicker={Boolean(onUpload)}
|
||||||
style={{ color: "var(--color-text-muted)" }}
|
pending={pending}
|
||||||
htmlFor="media-upload-category"
|
onFilesSelected={addFiles}
|
||||||
>
|
/>
|
||||||
Category
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="media-upload-category"
|
|
||||||
value={category}
|
|
||||||
disabled={pending}
|
|
||||||
onChange={(event) => setCategory(event.target.value as UploadableMediaCategory)}
|
|
||||||
className="rounded border px-2 py-1 text-[11px]"
|
|
||||||
style={{
|
|
||||||
borderColor: "var(--color-border)",
|
|
||||||
color: "var(--color-text-primary)",
|
|
||||||
background: "var(--color-bg-surface)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{PHOTO_CATEGORIES.map((value) => (
|
|
||||||
<option key={value} value={value}>
|
|
||||||
{value}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={pending}
|
|
||||||
onClick={() => inputRef.current?.click()}
|
|
||||||
onDragOver={(e) => e.preventDefault()}
|
|
||||||
onDrop={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
addFiles(e.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: 120,
|
|
||||||
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)" }}>
|
|
||||||
{pending ? "Uploading…" : "Drag files here or click to browse"}
|
|
||||||
</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="image/*,video/*"
|
|
||||||
multiple
|
|
||||||
hidden
|
|
||||||
disabled={pending}
|
|
||||||
onChange={(e) => {
|
|
||||||
addFiles(e.target.files);
|
|
||||||
e.target.value = "";
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{uploads.length > 0 && (
|
|
||||||
<div className="mt-3 grid grid-cols-3 gap-2">
|
|
||||||
{uploads.map((u) => {
|
|
||||||
const TypeIcon = FILETYPE_ICON[u.fileType];
|
|
||||||
const showDelete = !readOnly && canDeleteWorkOrderMedia(u);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={u.id}
|
|
||||||
className="overflow-hidden rounded-lg border"
|
|
||||||
style={{ borderColor: "var(--color-border)" }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="relative"
|
|
||||||
style={{
|
|
||||||
aspectRatio: "1 / 1",
|
|
||||||
background: "var(--color-bg-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{u.fileType === "image" ? (
|
|
||||||
<img
|
|
||||||
src={u.url}
|
|
||||||
alt={u.name}
|
|
||||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
|
||||||
/>
|
|
||||||
) : u.fileType === "video" ? (
|
|
||||||
<>
|
|
||||||
<video
|
|
||||||
src={u.url}
|
|
||||||
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
|
||||||
/>
|
|
||||||
<span className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<Play size={18} style={{ color: "#fff" }} />
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<TypeIcon size={18} style={{ color: "var(--color-text-muted)" }} />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{showDelete && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => remove(u.id)}
|
|
||||||
title="Remove"
|
|
||||||
disabled={pending}
|
|
||||||
className="absolute flex items-center justify-center transition-opacity hover:opacity-80 disabled:opacity-50"
|
|
||||||
style={{
|
|
||||||
top: 4,
|
|
||||||
right: 4,
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
borderRadius: 4,
|
|
||||||
background: "var(--color-bg-surface)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
color: "var(--color-text-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X size={11} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="px-1 py-1">
|
|
||||||
<div
|
|
||||||
className="truncate"
|
|
||||||
style={{ fontSize: 10, color: "var(--color-text-muted)" }}
|
|
||||||
>
|
|
||||||
{u.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
<MediaUploaderGrid
|
||||||
|
uploads={uploads}
|
||||||
|
readOnly={readOnly}
|
||||||
|
pending={pending}
|
||||||
|
onRemove={remove}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
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, 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 = (
|
||||||
|
row: WorkOrderTableRow,
|
||||||
|
data: CompDocData,
|
||||||
|
status: CompDocSaveStatus,
|
||||||
|
) => {
|
||||||
|
setCompletionDocs((prev) => ({ ...prev, [String(row.id)]: data }));
|
||||||
|
patchField(row.id, { docStatus: status });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
wizardOpen,
|
||||||
|
setWizardOpen,
|
||||||
|
slideOverEdit,
|
||||||
|
confirmCancel,
|
||||||
|
setConfirmCancel,
|
||||||
|
docRow,
|
||||||
|
setDocRow,
|
||||||
|
mediaRow,
|
||||||
|
setMediaRow,
|
||||||
|
completionDocs,
|
||||||
|
filters,
|
||||||
|
tableData,
|
||||||
|
patchField,
|
||||||
|
isPatchPending,
|
||||||
|
users,
|
||||||
|
usersLoading,
|
||||||
|
locations,
|
||||||
|
vendors,
|
||||||
|
pmTypes,
|
||||||
|
vendorTechs,
|
||||||
|
activeSlideOverRow,
|
||||||
|
activeDocRow,
|
||||||
|
activeMediaRow,
|
||||||
|
handleOpenSlideOver,
|
||||||
|
handleCloseSlideOver,
|
||||||
|
handleConfirmCancel,
|
||||||
|
cancelDialogClose,
|
||||||
|
saveCompletionDoc,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,4 @@
|
||||||
import { useMemo, useState } from "react";
|
import { Box } from "@mui/material";
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogContentText,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@mui/material";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
|
||||||
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 { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
|
||||||
|
|
@ -18,91 +8,16 @@ import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_com
|
||||||
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 { 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 { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
|
import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new-wo-wizard";
|
||||||
import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
import { useWorkOrdersListPage } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-page";
|
||||||
import { useWorkOrdersTableData } from "@/app/(protected)/workorders/_hooks/use-work-orders-table-data";
|
|
||||||
import { useDropdownOptionsByCategory } from "@/domain/settings/dropdown-options/use-cases/use-dropdown-options-by-category";
|
|
||||||
import type { CompDocData } from "@/domain/work-orders/types/completion-doc";
|
|
||||||
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 { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
|
||||||
import { useWorkOrderTableMutations } from "@/domain/work-orders/use-cases/use-work-order-table-mutations";
|
|
||||||
import { guardDialogClose } from "@/lib/dialog-guard";
|
|
||||||
import { useAuthContext } from "@/providers/auth-context";
|
import { useAuthContext } from "@/providers/auth-context";
|
||||||
|
|
||||||
export default function WorkOrdersListPage() {
|
export default function WorkOrdersListPage() {
|
||||||
const { user } = useAuthContext();
|
const { user } = useAuthContext();
|
||||||
const [wizardOpen, setWizardOpen] = useState(false);
|
const page = useWorkOrdersListPage();
|
||||||
const [slideOverRow, setSlideOverRow] = useState<WorkOrderTableRow | null>(null);
|
const { filters, tableData, activeDocRow } = page;
|
||||||
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, 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 totalCount = tableData.totalCount;
|
|
||||||
const scopeTotalCount = tableData.scopeTotalCount;
|
|
||||||
|
|
||||||
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));
|
|
||||||
|
|
||||||
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">
|
||||||
|
|
@ -115,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}
|
||||||
/>
|
/>
|
||||||
|
|
@ -129,19 +44,19 @@ 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={totalCount}
|
filteredCount={tableData.totalCount}
|
||||||
scopeTotalCount={scopeTotalCount}
|
scopeTotalCount={tableData.scopeTotalCount}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{filters.advApplied != null && (
|
{filters.advApplied != null && (
|
||||||
<WorkOrdersAdvancedFiltersBanner
|
<WorkOrdersAdvancedFiltersBanner
|
||||||
applied={filters.advApplied}
|
applied={filters.advApplied}
|
||||||
totalCount={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}
|
||||||
/>
|
/>
|
||||||
|
|
@ -162,21 +77,21 @@ 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={handleOpenSlideOver}
|
onOpenSlideOver={page.handleOpenSlideOver}
|
||||||
onOpenCompDoc={setDocRow}
|
onOpenCompDoc={page.setDocRow}
|
||||||
onOpenMedia={setMediaRow}
|
onOpenMedia={page.setMediaRow}
|
||||||
onClearSearch={() => filters.handleSearchChange("")}
|
onClearSearch={() => filters.handleSearchChange("")}
|
||||||
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
|
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
|
||||||
onPatch={patchField}
|
onPatch={page.patchField}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NewWoWizard open={wizardOpen} onOpenChange={setWizardOpen} users={users} />
|
<NewWoWizard open={page.wizardOpen} onOpenChange={page.setWizardOpen} users={page.users} />
|
||||||
|
|
||||||
<WorkOrdersAdvancedSearchSheet
|
<WorkOrdersAdvancedSearchSheet
|
||||||
open={filters.advOpen}
|
open={filters.advOpen}
|
||||||
|
|
@ -184,64 +99,42 @@ export default function WorkOrdersListPage() {
|
||||||
initial={filters.advInitial}
|
initial={filters.advInitial}
|
||||||
onApply={filters.applyAdvancedFilters}
|
onApply={filters.applyAdvancedFilters}
|
||||||
onClearAll={filters.clearAdvancedFilters}
|
onClearAll={filters.clearAdvancedFilters}
|
||||||
sites={locations}
|
sites={page.locations}
|
||||||
users={users}
|
users={page.users}
|
||||||
pmTypes={pmTypes}
|
pmTypes={page.pmTypes}
|
||||||
vendorTechs={vendorTechs}
|
vendorTechs={page.vendorTechs}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<WorkOrderSlideOver
|
<WorkOrderSlideOver
|
||||||
row={activeSlideOverRow}
|
row={page.activeSlideOverRow}
|
||||||
editMode={slideOverEdit}
|
editMode={page.slideOverEdit}
|
||||||
users={users}
|
users={page.users}
|
||||||
sites={locations}
|
sites={page.locations}
|
||||||
vendors={vendors}
|
vendors={page.vendors}
|
||||||
pmTypes={pmTypes}
|
pmTypes={page.pmTypes}
|
||||||
allOrders={tableData.rows}
|
allOrders={tableData.rows}
|
||||||
onSave={(id, patch, options) => patchField(id, patch, options)}
|
onSave={(id, patch, options) => page.patchField(id, patch, options)}
|
||||||
onOpenCompletionDoc={setDocRow}
|
onOpenCompletionDoc={page.setDocRow}
|
||||||
onCancelWO={setConfirmCancel}
|
onCancelWO={page.setConfirmCancel}
|
||||||
onDuplicateFound={(existing) => handleOpenSlideOver(existing, false)}
|
onDuplicateFound={(existing) => page.handleOpenSlideOver(existing, false)}
|
||||||
saving={isPatchPending}
|
saving={page.isPatchPending}
|
||||||
onClose={handleCloseSlideOver}
|
onClose={page.handleCloseSlideOver}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Dialog
|
<WorkOrderCancelDialog
|
||||||
open={Boolean(confirmCancel)}
|
row={page.confirmCancel}
|
||||||
onClose={cancelDialogClose}
|
isPending={page.isPatchPending}
|
||||||
slotProps={dialogBlurBackdropSlotProps}
|
onDialogClose={page.cancelDialogClose}
|
||||||
>
|
onKeepWO={() => page.setConfirmCancel(null)}
|
||||||
<DialogTitle>Cancel work order?</DialogTitle>
|
onConfirm={page.handleConfirmCancel}
|
||||||
<DialogContent>
|
/>
|
||||||
<DialogContentText>
|
|
||||||
Cancel WO #{confirmCancel?.woNumber}? The record will be kept with status Canceled.
|
|
||||||
</DialogContentText>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setConfirmCancel(null)}
|
|
||||||
disabled={isPatchPending}
|
|
||||||
>
|
|
||||||
Keep WO
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={handleConfirmCancel}
|
|
||||||
loading={isPatchPending}
|
|
||||||
loadingText="Canceling…"
|
|
||||||
>
|
|
||||||
Cancel WO
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{activeMediaRow != null && (
|
{page.activeMediaRow != null && (
|
||||||
<MediaUploadModal
|
<MediaUploadModal
|
||||||
row={activeMediaRow}
|
row={page.activeMediaRow}
|
||||||
open={!!activeMediaRow}
|
open={!!page.activeMediaRow}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) setMediaRow(null);
|
if (!open) page.setMediaRow(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -251,15 +144,12 @@ export default function WorkOrdersListPage() {
|
||||||
row={activeDocRow}
|
row={activeDocRow}
|
||||||
open={!!activeDocRow}
|
open={!!activeDocRow}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) setDocRow(null);
|
if (!open) page.setDocRow(null);
|
||||||
}}
|
}}
|
||||||
data={completionDocs[String(activeDocRow.id)]}
|
data={page.completionDocs[String(activeDocRow.id)]}
|
||||||
onSave={(data, status) => {
|
onSave={(data, status) => page.saveCompletionDoc(activeDocRow, data, status)}
|
||||||
setCompletionDocs((prev) => ({ ...prev, [String(activeDocRow.id)]: data }));
|
onStatusChange={(status) => page.patchField(activeDocRow.id, { status })}
|
||||||
patchField(activeDocRow.id, { docStatus: status });
|
onDateCompletedChange={(date) => page.patchField(activeDocRow.id, { scheduledOn: date })}
|
||||||
}}
|
|
||||||
onStatusChange={(status) => patchField(activeDocRow.id, { status })}
|
|
||||||
onDateCompletedChange={(date) => patchField(activeDocRow.id, { scheduledOn: date })}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ function invalidateBoardDetail(
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
|
void queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.detail(workOrderId) });
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AddWorkOrderCommentInput {
|
export interface AddWorkOrderCommentInput {
|
||||||
text: string;
|
text: string;
|
||||||
commentType?: "customer" | "internal";
|
commentType?: "customer" | "internal";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue