mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 16:19:01 +00:00
fix(work-orders): preserve slide-over draft and gate media close
This commit is contained in:
parent
0682b47678
commit
475862003c
14 changed files with 929 additions and 37 deletions
|
|
@ -0,0 +1,19 @@
|
|||
import type { ElementType } from "react";
|
||||
|
||||
type CompDocCoLineProps = {
|
||||
icon: ElementType;
|
||||
text: string;
|
||||
bold?: boolean;
|
||||
};
|
||||
|
||||
export function CompDocCoLine({ icon: Icon, text, bold }: CompDocCoLineProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-end gap-1.5 break-words"
|
||||
style={{ fontSize: 11, color: "#161E38", overflowWrap: "break-word" }}
|
||||
>
|
||||
<span style={{ fontWeight: bold ? 600 : 400 }}>{text}</span>
|
||||
<Icon size={12} className="shrink-0" style={{ color: "#1C75BC" }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import type { ElementType } from "react";
|
||||
|
||||
type CompDocDetailRowProps = {
|
||||
icon: ElementType;
|
||||
label: string;
|
||||
value: string;
|
||||
last?: boolean;
|
||||
};
|
||||
|
||||
export function CompDocDetailRow({ icon: Icon, label, value, last }: CompDocDetailRowProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-start gap-2.5"
|
||||
style={{
|
||||
padding: "10px 0",
|
||||
borderBottom: last ? "none" : "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<Icon
|
||||
size={15}
|
||||
className="shrink-0"
|
||||
style={{ color: "var(--muted-foreground)", marginTop: 2 }}
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div style={{ fontSize: 11.5, color: "var(--muted-foreground)" }}>{label}</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: "var(--color-text-primary)",
|
||||
marginTop: 1,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,292 @@
|
|||
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 { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import { SEAHAVEN_HEADER } from "@/domain/work-orders/utils/completion-doc-html";
|
||||
import { procedureFor, SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
|
||||
import { serviceTitle } from "@/domain/work-orders/utils/completion-doc-service-title";
|
||||
import { CompDocCoLine } from "./comp-doc-co-line";
|
||||
import { CompDocProcedureList } from "./comp-doc-procedure-list";
|
||||
|
||||
type CompDocPreviewProps = {
|
||||
row: WorkOrderTableRow;
|
||||
site: CompDocSiteInfo;
|
||||
notes: string;
|
||||
generatedAt: string;
|
||||
isGenerated: boolean;
|
||||
isNotRequired: boolean;
|
||||
};
|
||||
|
||||
export function CompDocPreview({
|
||||
row,
|
||||
site,
|
||||
notes,
|
||||
generatedAt,
|
||||
isGenerated,
|
||||
isNotRequired,
|
||||
}: CompDocPreviewProps) {
|
||||
const proc = procedureFor(row.pm);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-0 flex-col"
|
||||
style={{
|
||||
width: "58%",
|
||||
borderRight: "1px solid var(--color-border)",
|
||||
background: "var(--color-bg-muted)",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
|
||||
style={{ padding: "0 20px 20px" }}
|
||||
>
|
||||
<div
|
||||
className="relative break-words"
|
||||
style={{
|
||||
background: "#ffffff",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,.08), 0 12px 32px -16px rgba(0,0,0,.25)",
|
||||
overflow: "hidden",
|
||||
fontFamily: "var(--font-sans)",
|
||||
overflowWrap: "break-word",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
<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" }}>
|
||||
<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 && (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import type { Procedure } from "@/domain/work-orders/utils/completion-doc-procedures";
|
||||
|
||||
type CompDocProcedureListProps = {
|
||||
proc: Procedure;
|
||||
};
|
||||
|
||||
export function CompDocProcedureList({ proc }: CompDocProcedureListProps) {
|
||||
return (
|
||||
<ol style={{ listStyle: "decimal", paddingLeft: 22, margin: 0 }}>
|
||||
{proc.steps.map((st, i) => (
|
||||
<li key={i} style={{ margin: "6px 0", fontSize: 13, lineHeight: 1.5 }}>
|
||||
{st.text}
|
||||
{st.sub != null && (
|
||||
<ol
|
||||
style={{
|
||||
listStyle: "lower-alpha",
|
||||
paddingLeft: 22,
|
||||
margin: "4px 0 0",
|
||||
}}
|
||||
>
|
||||
{st.sub.map((x, j) => (
|
||||
<li key={j} style={{ margin: "2px 0" }}>
|
||||
{x}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
import type { CSSProperties } from "react";
|
||||
import { Building2, MapPin, Users, Wrench } from "lucide-react";
|
||||
import type { CompDocSiteInfo, DocState } from "@/domain/work-orders/types/completion-doc";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import {
|
||||
DOC_STATE_CFG,
|
||||
docStatusCardDescription,
|
||||
} from "@/domain/work-orders/utils/completion-doc-state";
|
||||
import { CompDocDetailRow } from "./comp-doc-detail-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 = {
|
||||
row: WorkOrderTableRow;
|
||||
site: CompDocSiteInfo;
|
||||
docState: DocState;
|
||||
generatedAt: string;
|
||||
notes: string;
|
||||
editDateCompleted: string;
|
||||
amazonRep: string;
|
||||
seaRep: string;
|
||||
isGenerated: boolean;
|
||||
onNotesChange: (value: string) => void;
|
||||
onDateCompletedChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export function CompDocSidebar({
|
||||
row,
|
||||
site,
|
||||
docState,
|
||||
generatedAt,
|
||||
notes,
|
||||
editDateCompleted,
|
||||
amazonRep,
|
||||
seaRep,
|
||||
isGenerated,
|
||||
onNotesChange,
|
||||
onDateCompletedChange,
|
||||
}: CompDocSidebarProps) {
|
||||
const cfg = DOC_STATE_CFG[docState];
|
||||
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 (
|
||||
<div
|
||||
className="flex min-h-0 flex-col"
|
||||
style={{ width: "42%", background: "var(--color-bg-surface)" }}
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto" style={{ padding: 20 }}>
|
||||
<div
|
||||
className="flex items-start gap-3"
|
||||
style={{
|
||||
padding: 14,
|
||||
borderRadius: 10,
|
||||
background: cfg.cardBg,
|
||||
border: `1px solid ${cfg.cardBorder}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-center"
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 8,
|
||||
background: cfg.cardIconBg,
|
||||
border: `1px solid ${cfg.cardBorder}`,
|
||||
}}
|
||||
>
|
||||
<StateIcon size={17} style={{ color: cfg.iconColor }} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13.5,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-primary)",
|
||||
}}
|
||||
>
|
||||
{cfg.cardTitle}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.45,
|
||||
color: "var(--muted-foreground)",
|
||||
marginTop: 3,
|
||||
}}
|
||||
>
|
||||
{docStatusCardDescription(docState, generatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 20 }}>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: 10,
|
||||
fontWeight: 600,
|
||||
letterSpacing: "0.1em",
|
||||
textTransform: "uppercase",
|
||||
color: "var(--muted-foreground)",
|
||||
}}
|
||||
>
|
||||
Work Order Details
|
||||
</div>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<CompDocDetailRow icon={Building2} label="WO #" value={`#${row.woNumber}`} />
|
||||
<CompDocDetailRow
|
||||
icon={MapPin}
|
||||
label="Site Code"
|
||||
value={`${row.site} · Amazon.com Services LLC`}
|
||||
/>
|
||||
<CompDocDetailRow icon={MapPin} label="Address" value={site.fullAddress} />
|
||||
{editRow("Date Work Completed", editDateCompleted, onDateCompletedChange, "date")}
|
||||
<CompDocDetailRow icon={Users} label="Amazon Representative" value={amazonRep || "—"} />
|
||||
<CompDocDetailRow
|
||||
icon={Wrench}
|
||||
label="Seahaven Representative"
|
||||
value={seaRep || "—"}
|
||||
last
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 18 }}>
|
||||
<label
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
color: "var(--color-text-primary)",
|
||||
}}
|
||||
>
|
||||
PM Comments{" "}
|
||||
<span style={{ fontWeight: 400, color: "var(--muted-foreground)" }}>
|
||||
— optional, included in the document
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => onNotesChange(e.target.value)}
|
||||
readOnly={isGenerated}
|
||||
rows={3}
|
||||
placeholder="Add findings, parts used, access notes, or anything the site POC should see…"
|
||||
className="mt-2 w-full resize-none"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontFamily: "var(--font-sans)",
|
||||
color: "var(--color-text-primary)",
|
||||
background: "var(--color-bg-surface)",
|
||||
border: "1px solid var(--color-border)",
|
||||
borderRadius: 8,
|
||||
padding: "10px 12px",
|
||||
lineHeight: 1.5,
|
||||
outline: "none",
|
||||
...(isGenerated
|
||||
? {
|
||||
opacity: 0.6,
|
||||
cursor: "default",
|
||||
pointerEvents: "none" as const,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -48,7 +48,12 @@ export function AuditTimeline({
|
|||
)}
|
||||
</Typography>
|
||||
{Boolean(entry.action && entry.fieldName) && (
|
||||
<Typography variant="caption" color="text.secondary" display="block" className="mt-1">
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
className="mt-1"
|
||||
sx={{ display: "block" }}
|
||||
>
|
||||
{entry.fieldName}
|
||||
{entry.oldValue || entry.newValue
|
||||
? `: ${entry.oldValue || "—"} → ${entry.newValue || "—"}`
|
||||
|
|
|
|||
|
|
@ -204,7 +204,7 @@ export function SlideOverCompletionTab({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{Boolean(completion && completion.dispatchSignoffs.length > 0) && (
|
||||
{completion != null && completion.dispatchSignoffs.length > 0 && (
|
||||
<div className="space-y-1 pt-2">
|
||||
<span
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ export function WorkOrderDetailsSection({
|
|||
<DetailField label="Priority" value={wo.priority || "—"} />
|
||||
<DetailField label="Location" value={wo.location || "—"} />
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" display="block">
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
|
||||
Assigned To
|
||||
</Typography>
|
||||
<WorkOrderAssigneeMenu
|
||||
|
|
@ -103,7 +103,7 @@ export function WorkOrderDetailsSection({
|
|||
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography fontWeight={600}>Classification</Typography>
|
||||
<Typography sx={{ fontWeight: 600 }}>Classification</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<DetailFieldGrid>
|
||||
|
|
@ -116,7 +116,7 @@ export function WorkOrderDetailsSection({
|
|||
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography fontWeight={600}>Schedule</Typography>
|
||||
<Typography sx={{ fontWeight: 600 }}>Schedule</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
|
|
@ -133,7 +133,7 @@ export function WorkOrderDetailsSection({
|
|||
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography fontWeight={600}>Financial</Typography>
|
||||
<Typography sx={{ fontWeight: 600 }}>Financial</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Box className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
|
|
@ -149,7 +149,7 @@ export function WorkOrderDetailsSection({
|
|||
|
||||
<Accordion defaultExpanded={Boolean(wo.dispatches.length)}>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography fontWeight={600}>Dispatches ({wo.dispatches.length})</Typography>
|
||||
<Typography sx={{ fontWeight: 600 }}>Dispatches ({wo.dispatches.length})</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
{wo.dispatches.length ? (
|
||||
|
|
@ -207,7 +207,7 @@ export function WorkOrderDetailsSection({
|
|||
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography fontWeight={600}>Description</Typography>
|
||||
<Typography sx={{ fontWeight: 600 }}>Description</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Typography>{wo.description || "No description provided."}</Typography>
|
||||
|
|
|
|||
|
|
@ -33,7 +33,9 @@ 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";
|
||||
|
|
@ -46,6 +48,11 @@ const SLIDE_OVER_TABS: { id: SlideOverTab; label: string }[] = [
|
|||
{ id: "extras", label: "Extra Docs" },
|
||||
];
|
||||
|
||||
export type SlideOverSaveOptions = {
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
};
|
||||
|
||||
type WorkOrderSlideOverProps = {
|
||||
row: WorkOrderTableRow | null;
|
||||
editMode: boolean;
|
||||
|
|
@ -54,7 +61,7 @@ type WorkOrderSlideOverProps = {
|
|||
vendors: VendorDropdownItem[];
|
||||
pmTypes: string[];
|
||||
allOrders: WorkOrderTableRow[];
|
||||
onSave: (id: string | number, patch: WorkOrderTablePatch) => void;
|
||||
onSave: (id: string | number, patch: WorkOrderTablePatch, options?: SlideOverSaveOptions) => void;
|
||||
onOpenCompletionDoc: (row: WorkOrderTableRow) => void;
|
||||
onCancelWO: (row: WorkOrderTableRow) => void;
|
||||
onDuplicateFound?: (existing: WorkOrderTableRow) => void;
|
||||
|
|
@ -96,6 +103,8 @@ export function WorkOrderSlideOver({
|
|||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = 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 =
|
||||
|
|
@ -147,7 +156,8 @@ export function WorkOrderSlideOver({
|
|||
[detailForRow?.media],
|
||||
);
|
||||
|
||||
const mediaPending = uploadMediaMutation.isPending || deleteMediaMutation.isPending;
|
||||
const mediaPending =
|
||||
mediaBatchPending || uploadMediaMutation.isPending || deleteMediaMutation.isPending;
|
||||
const completionPending = uploadCompletionMutation.isPending;
|
||||
const closeDisabled = Boolean(saving || mediaPending || completionPending);
|
||||
|
||||
|
|
@ -155,14 +165,28 @@ export function WorkOrderSlideOver({
|
|||
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 || !infoSource) return;
|
||||
const patch = buildSlideOverPatch(activeDraft, infoSource);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
onSave(row.id, patch);
|
||||
if (Object.keys(patch).length === 0) {
|
||||
setEditing(false);
|
||||
setShowUnsaved(false);
|
||||
return;
|
||||
}
|
||||
setEditing(false);
|
||||
setShowUnsaved(false);
|
||||
onSave(row.id, patch, {
|
||||
onSuccess: () => {
|
||||
setEditing(false);
|
||||
setShowUnsaved(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const discard = () => {
|
||||
|
|
@ -307,9 +331,7 @@ export function WorkOrderSlideOver({
|
|||
mediaPending={mediaPending}
|
||||
completionPending={completionPending}
|
||||
onUploadMedia={(files, category) => {
|
||||
for (const file of files) {
|
||||
uploadMediaMutation.mutate({ file, category });
|
||||
}
|
||||
void uploadMediaFiles(files, category);
|
||||
}}
|
||||
onRemoveMedia={(id) => deleteMediaMutation.mutate(id)}
|
||||
onUploadCompletionPdf={(file) => {
|
||||
|
|
@ -341,9 +363,7 @@ export function WorkOrderSlideOver({
|
|||
isReadOnly={isDocumentsReadOnly}
|
||||
pending={mediaPending}
|
||||
onUpload={(files) => {
|
||||
for (const file of files) {
|
||||
uploadMediaMutation.mutate({ file, category: "Extra" });
|
||||
}
|
||||
void uploadMediaFiles(files, "Extra");
|
||||
}}
|
||||
onRemove={(id) => deleteMediaMutation.mutate(id)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,18 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import { Box } from "@mui/material";
|
||||
import { useNavigate } from "react-router";
|
||||
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 { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
||||
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
|
||||
import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
|
||||
import { MediaUploadModal } from "@/app/(protected)/workorders/_components/media/media-upload-modal";
|
||||
import { WorkOrdersAdvancedSearchSheet } from "@/app/(protected)/workorders/_components/list/advanced-search/work-orders-advanced-search-sheet";
|
||||
import { WorkOrdersAdvancedFiltersBanner } from "@/app/(protected)/workorders/_components/list/work-orders-advanced-filters-banner";
|
||||
|
|
@ -13,25 +23,29 @@ import { NewWoWizard } from "@/app/(protected)/workorders/_components/wizard/new
|
|||
import { useWorkOrdersListFilters } from "@/app/(protected)/workorders/_hooks/use-work-orders-list-filters";
|
||||
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 { EMPTY_MEDIA, type MediaFile } from "@/domain/work-orders/types/work-order-media";
|
||||
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";
|
||||
|
||||
export default function WorkOrdersListPage() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuthContext();
|
||||
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 [mediaFiles, setMediaFiles] = useState<Record<string, MediaFile[]>>({});
|
||||
const [completionDocs, setCompletionDocs] = useState<Record<string, CompDocData>>({});
|
||||
const filters = useWorkOrdersListFilters();
|
||||
const tableData = useWorkOrdersTableData(filters);
|
||||
const { patchField } = useWorkOrderTableMutations({
|
||||
const { patchField, isPending: isPatchPending } = useWorkOrderTableMutations({
|
||||
onPatch: tableData.patchRow,
|
||||
clearPatch: tableData.clearRowPatch,
|
||||
getRow: (id) => tableData.rows.find((row) => String(row.id) === String(id)),
|
||||
|
|
@ -55,11 +69,41 @@ export default function WorkOrdersListPage() {
|
|||
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 (
|
||||
<Box className="flex h-full flex-col overflow-hidden p-2.5">
|
||||
<PageHeader
|
||||
|
|
@ -124,16 +168,16 @@ export default function WorkOrdersListPage() {
|
|||
pmTypes={pmTypes}
|
||||
weekMonday={filters.weekMonday}
|
||||
onWeekChange={filters.setWeekMonday}
|
||||
onOpenSlideOver={(row, edit) => {
|
||||
tableData.setSelectedId(row.id);
|
||||
navigate(edit ? `/workorders/edit/${row.id}` : `/workorders/${row.id}`);
|
||||
}}
|
||||
onOpenSlideOver={handleOpenSlideOver}
|
||||
onOpenCompDoc={setDocRow}
|
||||
onOpenMedia={setMediaRow}
|
||||
onClearSearch={() => filters.handleSearchChange("")}
|
||||
onOpenAdvancedFilters={() => filters.setAdvOpen(true)}
|
||||
onPatch={patchField}
|
||||
/>
|
||||
|
||||
<NewWoWizard open={wizardOpen} onOpenChange={setWizardOpen} users={users} />
|
||||
|
||||
<WorkOrdersAdvancedSearchSheet
|
||||
open={filters.advOpen}
|
||||
onOpenChange={filters.setAdvOpen}
|
||||
|
|
@ -146,7 +190,51 @@ export default function WorkOrdersListPage() {
|
|||
vendorTechs={vendorTechs}
|
||||
/>
|
||||
|
||||
<NewWoWizard open={wizardOpen} onOpenChange={setWizardOpen} users={users} />
|
||||
<WorkOrderSlideOver
|
||||
row={activeSlideOverRow}
|
||||
editMode={slideOverEdit}
|
||||
users={users}
|
||||
sites={locations}
|
||||
vendors={vendors}
|
||||
pmTypes={pmTypes}
|
||||
allOrders={tableData.rows}
|
||||
onSave={(id, patch, options) => patchField(id, patch, options)}
|
||||
onOpenCompletionDoc={setDocRow}
|
||||
onCancelWO={setConfirmCancel}
|
||||
onDuplicateFound={(existing) => handleOpenSlideOver(existing, false)}
|
||||
saving={isPatchPending}
|
||||
onClose={handleCloseSlideOver}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={Boolean(confirmCancel)}
|
||||
onClose={cancelDialogClose}
|
||||
slotProps={dialogBlurBackdropSlotProps}
|
||||
>
|
||||
<DialogTitle>Cancel work order?</DialogTitle>
|
||||
<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 && (
|
||||
<MediaUploadModal
|
||||
|
|
@ -155,11 +243,23 @@ export default function WorkOrdersListPage() {
|
|||
onOpenChange={(open) => {
|
||||
if (!open) setMediaRow(null);
|
||||
}}
|
||||
uploads={mediaFiles[String(activeMediaRow.id)] ?? EMPTY_MEDIA}
|
||||
onChange={(files) => {
|
||||
setMediaFiles((prev) => ({ ...prev, [String(activeMediaRow.id)]: files }));
|
||||
patchField(activeMediaRow.id, { mediaCount: files.length });
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeDocRow != null && (
|
||||
<CompDocDialog
|
||||
row={activeDocRow}
|
||||
open={!!activeDocRow}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDocRow(null);
|
||||
}}
|
||||
data={completionDocs[String(activeDocRow.id)]}
|
||||
onSave={(data, status) => {
|
||||
setCompletionDocs((prev) => ({ ...prev, [String(activeDocRow.id)]: data }));
|
||||
patchField(activeDocRow.id, { docStatus: status });
|
||||
}}
|
||||
onStatusChange={(status) => patchField(activeDocRow.id, { status })}
|
||||
onDateCompletedChange={(date) => patchField(activeDocRow.id, { scheduledOn: date })}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -123,8 +123,21 @@ export function useWorkOrderTableMutations(
|
|||
},
|
||||
});
|
||||
|
||||
const patchField = (id: string | number, patch: WorkOrderTablePatch) => {
|
||||
patchMutation.mutate({ id, patch });
|
||||
const patchField = (
|
||||
id: string | number,
|
||||
patch: WorkOrderTablePatch,
|
||||
options?: {
|
||||
onSuccess?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
},
|
||||
) => {
|
||||
patchMutation.mutate(
|
||||
{ id, patch },
|
||||
{
|
||||
onSuccess: () => options?.onSuccess?.(),
|
||||
onError: (error) => options?.onError?.(error),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
|
|||
24
src/domain/work-orders/utils/run-sequential-uploads.ts
Normal file
24
src/domain/work-orders/utils/run-sequential-uploads.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* Runs file uploads one-by-one while holding an aggregate pending flag true
|
||||
* until every file has settled (success or failure). Avoids relying on a single
|
||||
* TanStack Mutation `isPending`, which can flip false when a later small file
|
||||
* finishes before an earlier large one.
|
||||
*/
|
||||
export async function runSequentialUploads<TFile>(
|
||||
files: readonly TFile[],
|
||||
upload: (file: TFile) => Promise<unknown>,
|
||||
setPending: (pending: boolean) => void,
|
||||
): Promise<void> {
|
||||
if (files.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPending(true);
|
||||
try {
|
||||
for (const file of files) {
|
||||
await upload(file);
|
||||
}
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { runSequentialUploads } from "@/domain/work-orders/utils/run-sequential-uploads";
|
||||
|
||||
describe("runSequentialUploads", () => {
|
||||
it("keeps pending true across the full batch; second upload starts only after the first settles", async () => {
|
||||
const pendingLog: boolean[] = [];
|
||||
const setPending = (pending: boolean) => {
|
||||
pendingLog.push(pending);
|
||||
};
|
||||
|
||||
let resolveFirst: (() => void) | undefined;
|
||||
let resolveSecond: (() => void) | undefined;
|
||||
|
||||
const first = new Promise<void>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
const second = new Promise<void>((resolve) => {
|
||||
resolveSecond = resolve;
|
||||
});
|
||||
|
||||
const upload = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => first)
|
||||
.mockImplementationOnce(() => second);
|
||||
|
||||
const done = runSequentialUploads(["large.bin", "small.bin"], upload, setPending);
|
||||
|
||||
expect(pendingLog).toEqual([true]);
|
||||
expect(upload).toHaveBeenCalledTimes(1);
|
||||
expect(upload).toHaveBeenCalledWith("large.bin");
|
||||
|
||||
// First file still in flight — pending must stay true.
|
||||
await Promise.resolve();
|
||||
expect(pendingLog).toEqual([true]);
|
||||
expect(upload).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveFirst?.();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(upload).toHaveBeenCalledTimes(2);
|
||||
expect(upload).toHaveBeenNthCalledWith(2, "small.bin");
|
||||
expect(pendingLog).toEqual([true]);
|
||||
|
||||
resolveSecond?.();
|
||||
await done;
|
||||
|
||||
expect(pendingLog).toEqual([true, false]);
|
||||
});
|
||||
|
||||
it("clears pending when a middle upload rejects", async () => {
|
||||
const setPending = vi.fn();
|
||||
const upload = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error("upload failed"));
|
||||
|
||||
await expect(runSequentialUploads(["a", "b", "c"], upload, setPending)).rejects.toThrow(
|
||||
"upload failed",
|
||||
);
|
||||
|
||||
expect(setPending.mock.calls.map((call) => call[0])).toEqual([true, false]);
|
||||
expect(upload).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* Mirrors WorkOrderSlideOver commit: exit edit mode only after a successful save
|
||||
* (or when there is nothing to persist).
|
||||
*/
|
||||
function commitSlideOverEdit(input: {
|
||||
patchKeys: number;
|
||||
onSave: (options: { onSuccess?: () => void }) => void;
|
||||
exitEdit: () => void;
|
||||
clearUnsaved: () => void;
|
||||
}) {
|
||||
if (input.patchKeys === 0) {
|
||||
input.exitEdit();
|
||||
input.clearUnsaved();
|
||||
return;
|
||||
}
|
||||
|
||||
input.onSave({
|
||||
onSuccess: () => {
|
||||
input.exitEdit();
|
||||
input.clearUnsaved();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("slide-over commit save contract", () => {
|
||||
it("exits edit mode immediately when there is no patch", () => {
|
||||
const exitEdit = vi.fn();
|
||||
const clearUnsaved = vi.fn();
|
||||
const onSave = vi.fn();
|
||||
|
||||
commitSlideOverEdit({ patchKeys: 0, onSave, exitEdit, clearUnsaved });
|
||||
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
expect(exitEdit).toHaveBeenCalledOnce();
|
||||
expect(clearUnsaved).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps edit mode when save fails (onSuccess never runs)", () => {
|
||||
const exitEdit = vi.fn();
|
||||
const clearUnsaved = vi.fn();
|
||||
|
||||
commitSlideOverEdit({
|
||||
patchKeys: 1,
|
||||
onSave: () => {
|
||||
/* rejected — do not call onSuccess */
|
||||
},
|
||||
exitEdit,
|
||||
clearUnsaved,
|
||||
});
|
||||
|
||||
expect(exitEdit).not.toHaveBeenCalled();
|
||||
expect(clearUnsaved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("exits edit mode only from onSuccess after a rejected-then-retry path would not", () => {
|
||||
const exitEdit = vi.fn();
|
||||
const clearUnsaved = vi.fn();
|
||||
let success: (() => void) | undefined;
|
||||
|
||||
commitSlideOverEdit({
|
||||
patchKeys: 2,
|
||||
onSave: (options) => {
|
||||
success = options.onSuccess;
|
||||
},
|
||||
exitEdit,
|
||||
clearUnsaved,
|
||||
});
|
||||
|
||||
expect(exitEdit).not.toHaveBeenCalled();
|
||||
success?.();
|
||||
expect(exitEdit).toHaveBeenCalledOnce();
|
||||
expect(clearUnsaved).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue