Merge remote-tracking branch 'origin/main' into feat/ab/sh-335-wo-site-dialog
# Conflicts: # src/domain/locations/api/locations-api.ts # src/domain/locations/types/location.ts
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 198 KiB After Width: | Height: | Size: 202 KiB |
|
|
@ -33,6 +33,9 @@ export const API_PATHS = {
|
||||||
pocPatch: (id: string | number) => `workorders/${id}/poc`,
|
pocPatch: (id: string | number) => `workorders/${id}/poc`,
|
||||||
boardDetail: (id: string | number) => `workorders/${id}/detail`,
|
boardDetail: (id: string | number) => `workorders/${id}/detail`,
|
||||||
completionTemplates: "workorders/completion-templates",
|
completionTemplates: "workorders/completion-templates",
|
||||||
|
completionTemplateById: (id: string | number) => `workorders/completion-templates/${id}`,
|
||||||
|
completionTemplateLinkedWorkOrders: (id: string | number) =>
|
||||||
|
`workorders/completion-templates/${id}/linked-work-orders`,
|
||||||
comments: (id: string | number) => `workorders/${id}/comments`,
|
comments: (id: string | number) => `workorders/${id}/comments`,
|
||||||
commentItem: (workOrderId: string | number, commentId: string | number) =>
|
commentItem: (workOrderId: string | number, commentId: string | number) =>
|
||||||
`workorders/${workOrderId}/comments/${commentId}`,
|
`workorders/${workOrderId}/comments/${commentId}`,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export function CompletionTemplateAddLink({
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
onClick: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="self-start"
|
||||||
|
style={{ fontSize: 12, fontWeight: 500, color: "var(--color-primary)", textAlign: "left" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
function initials(name: string): string {
|
||||||
|
return name
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((part) => part[0] ?? "")
|
||||||
|
.join("")
|
||||||
|
.slice(0, 2)
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompletionTemplateCreatedBy({ name }: { name: string | null }) {
|
||||||
|
if (name === null) {
|
||||||
|
return (
|
||||||
|
<Text variant="caption" tone="muted" sx={{ fontSize: 13 }}>
|
||||||
|
—
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="flex shrink-0 items-center justify-center rounded-full"
|
||||||
|
style={{
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: 600,
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
color: "var(--color-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{initials(name)}
|
||||||
|
</span>
|
||||||
|
<Text variant="caption" sx={{ fontSize: 13 }}>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { COMPLETION_TEMPLATE_COPY } from "@/domain/completion-templates/constants/completion-template-constants";
|
||||||
|
import type { CompletionTemplate } from "@/domain/completion-templates/types/completion-template";
|
||||||
|
import { descriptionLines } from "@/domain/completion-templates/utils/description-lines";
|
||||||
|
import { formatTemplateDate } from "@/domain/completion-templates/utils/format-template-date";
|
||||||
|
import { CompletionTemplateCreatedBy } from "./completion-template-created-by";
|
||||||
|
import {
|
||||||
|
AutoPopulatedFields,
|
||||||
|
FieldLabel,
|
||||||
|
SectionTitle,
|
||||||
|
StandardSafetyBullets,
|
||||||
|
} from "./completion-template-sections";
|
||||||
|
|
||||||
|
function ReadOnlyField({ label, children }: { label: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Text
|
||||||
|
variant="label"
|
||||||
|
tone="muted"
|
||||||
|
sx={{ fontSize: 12, fontWeight: 500, display: "block", marginBottom: "6px" }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProcedureList({ template }: { template: CompletionTemplate }) {
|
||||||
|
if (template.procedures.length === 0) {
|
||||||
|
return (
|
||||||
|
<Text variant="caption" tone="muted" sx={{ fontSize: 13 }}>
|
||||||
|
{COMPLETION_TEMPLATE_COPY.noProcedureDetail}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<ol className="m-0 list-none p-0">
|
||||||
|
{template.procedures.map((procedure, index) => (
|
||||||
|
<li key={index} style={{ marginBottom: 12 }}>
|
||||||
|
<Text variant="label" sx={{ fontSize: 13, fontWeight: 600, display: "block", mb: 0.5 }}>
|
||||||
|
{procedure.name || `Procedure ${index + 1}`}
|
||||||
|
</Text>
|
||||||
|
{descriptionLines(procedure.description).map((line, lineIndex) => (
|
||||||
|
<Text key={lineIndex} variant="body" sx={{ fontSize: 13, lineHeight: 1.5, my: 0.5 }}>
|
||||||
|
{line}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything a template holds, read-only. */
|
||||||
|
export function CompletionTemplateDetail({ template }: { template: CompletionTemplate }) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3.5">
|
||||||
|
<SectionTitle>Completion Document</SectionTitle>
|
||||||
|
<ReadOnlyField label="Document Title">
|
||||||
|
<Text variant="body" sx={{ fontSize: 13 }}>
|
||||||
|
{template.name || "—"}
|
||||||
|
</Text>
|
||||||
|
</ReadOnlyField>
|
||||||
|
<AutoPopulatedFields />
|
||||||
|
<div>
|
||||||
|
<FieldLabel>Safety Notes</FieldLabel>
|
||||||
|
<StandardSafetyBullets extra={template.extraSafetyNote} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel hint="(optional)">Procedures</FieldLabel>
|
||||||
|
<ProcedureList template={template} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<ReadOnlyField label="Last Updated">
|
||||||
|
<Text variant="body" sx={{ fontSize: 13 }}>
|
||||||
|
{formatTemplateDate(template.lastUpdatedAt)}
|
||||||
|
</Text>
|
||||||
|
</ReadOnlyField>
|
||||||
|
<ReadOnlyField label="Created By">
|
||||||
|
<CompletionTemplateCreatedBy name={template.createdByName} />
|
||||||
|
</ReadOnlyField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
import { FileText, Pencil, Plus, Save, Trash2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { DialogCancelButton } from "@/components/ui/dialog-cancel-button";
|
||||||
|
import { IconCloseButton } from "@/components/ui/icon-close-button";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { COMPLETION_TEMPLATE_COPY } from "@/domain/completion-templates/constants/completion-template-constants";
|
||||||
|
import type { CompletionTemplatePermissions } from "./use-completion-template-permissions";
|
||||||
|
import type { CompletionTemplateDialogMode } from "./use-completion-template-dialog";
|
||||||
|
|
||||||
|
export function CompletionTemplateDialogHeader({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
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">
|
||||||
|
<Text
|
||||||
|
as="div"
|
||||||
|
variant="heading"
|
||||||
|
className="truncate"
|
||||||
|
sx={{ fontSize: 16, fontWeight: 700, lineHeight: 1.1 }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<Text as="div" variant="caption" tone="muted" className="truncate" sx={{ fontSize: 13 }}>
|
||||||
|
{subtitle}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<IconCloseButton onClick={onClose} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type FooterProps = {
|
||||||
|
mode: CompletionTemplateDialogMode;
|
||||||
|
permissions: CompletionTemplatePermissions;
|
||||||
|
isSaving: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSubmit: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompletionTemplateDialogFooter({
|
||||||
|
mode,
|
||||||
|
permissions,
|
||||||
|
isSaving,
|
||||||
|
onCancel,
|
||||||
|
onSubmit,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: FooterProps) {
|
||||||
|
const footerStyle = {
|
||||||
|
padding: "12px 20px",
|
||||||
|
borderTop: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-bg-surface)",
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
if (mode !== "view") {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-end gap-3" style={footerStyle}>
|
||||||
|
<DialogCancelButton onClick={onCancel} disabled={isSaving} />
|
||||||
|
<Button onClick={onSubmit} disabled={isSaving} className="gap-1.5">
|
||||||
|
{mode === "create" ? <Plus size={13} /> : <Save size={13} />}
|
||||||
|
{mode === "create"
|
||||||
|
? COMPLETION_TEMPLATE_COPY.addTemplate
|
||||||
|
: COMPLETION_TEMPLATE_COPY.saveChanges}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-3 ${permissions.canDelete ? "justify-between" : "justify-end"}`}
|
||||||
|
style={footerStyle}
|
||||||
|
>
|
||||||
|
{permissions.canDelete && (
|
||||||
|
<Button variant="ghost" onClick={onDelete} className="gap-1.5 text-destructive">
|
||||||
|
<Trash2 size={13} /> Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{permissions.canEdit && (
|
||||||
|
<Button onClick={onEdit} className="gap-1.5">
|
||||||
|
<Pencil size={13} /> Edit
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
import type { CompletionTemplate } from "@/domain/completion-templates/types/completion-template";
|
||||||
|
import { formatTemplateDate } from "@/domain/completion-templates/utils/format-template-date";
|
||||||
|
import type { CompletionTemplateDialogMode } from "./use-completion-template-dialog";
|
||||||
|
|
||||||
|
export function dialogHeading(
|
||||||
|
template: CompletionTemplate | null,
|
||||||
|
mode: CompletionTemplateDialogMode,
|
||||||
|
): { title: string; subtitle: string } {
|
||||||
|
if (template === null) {
|
||||||
|
return {
|
||||||
|
title: "New Completion Document Template",
|
||||||
|
subtitle: "Completion Document Template",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (mode === "edit") return { title: template.name, subtitle: "Editing template" };
|
||||||
|
const createdBy = template.createdByName ?? "—";
|
||||||
|
return {
|
||||||
|
title: template.name,
|
||||||
|
subtitle: `Created by ${createdBy} · Updated ${formatTemplateDate(template.lastUpdatedAt)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { Dialog, DialogTitle } from "@mui/material";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||||
|
import type { CompletionTemplate } from "@/domain/completion-templates/types/completion-template";
|
||||||
|
import { CompletionTemplateDetail } from "./completion-template-detail";
|
||||||
|
import {
|
||||||
|
CompletionTemplateDialogFooter,
|
||||||
|
CompletionTemplateDialogHeader,
|
||||||
|
} from "./completion-template-dialog-chrome";
|
||||||
|
import { dialogHeading } from "./completion-template-dialog-heading";
|
||||||
|
import { CompletionTemplateForm } from "./completion-template-form";
|
||||||
|
import { CompletionTemplatePreview } from "./completion-template-preview";
|
||||||
|
import { DeleteCompletionTemplateDialog } from "./delete-completion-template-dialog";
|
||||||
|
import {
|
||||||
|
useCompletionTemplateDialog,
|
||||||
|
type CompletionTemplateDialogMode,
|
||||||
|
} from "./use-completion-template-dialog";
|
||||||
|
import type { CompletionTemplatePermissions } from "./use-completion-template-permissions";
|
||||||
|
|
||||||
|
type CompletionTemplateDialogProps = {
|
||||||
|
/** `null` adds a new template. */
|
||||||
|
template: CompletionTemplate | null;
|
||||||
|
initialMode: CompletionTemplateDialogMode;
|
||||||
|
permissions: CompletionTemplatePermissions;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: (template: CompletionTemplate) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** View, add or edit one template beside its document preview. */
|
||||||
|
export function CompletionTemplateDialog({
|
||||||
|
template,
|
||||||
|
initialMode,
|
||||||
|
permissions,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: CompletionTemplateDialogProps) {
|
||||||
|
const dialog = useCompletionTemplateDialog({ template, initialMode, onClose, onSaved });
|
||||||
|
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||||
|
const heading = dialogHeading(template, dialog.mode);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open
|
||||||
|
onClose={onClose}
|
||||||
|
maxWidth={false}
|
||||||
|
aria-labelledby="completion-template-dialog-title"
|
||||||
|
slotProps={{
|
||||||
|
...dialogBlurBackdropSlotProps,
|
||||||
|
paper: {
|
||||||
|
className: "flex max-h-[85vh] flex-col overflow-hidden border border-border p-0",
|
||||||
|
style: {
|
||||||
|
borderRadius: 12,
|
||||||
|
width: "min(1040px, calc(100vw - 32px))",
|
||||||
|
maxWidth: "none",
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTitle id="completion-template-dialog-title" className="sr-only">
|
||||||
|
{heading.title}
|
||||||
|
</DialogTitle>
|
||||||
|
<CompletionTemplateDialogHeader
|
||||||
|
title={heading.title}
|
||||||
|
subtitle={heading.subtitle}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
<div className="flex min-h-0 flex-1 flex-row">
|
||||||
|
<CompletionTemplatePreview values={dialog.previewValues} isEditing={dialog.isEditing} />
|
||||||
|
<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 }}>
|
||||||
|
{dialog.isEditing ? (
|
||||||
|
<CompletionTemplateForm form={dialog.form} />
|
||||||
|
) : (
|
||||||
|
template !== null && <CompletionTemplateDetail template={template} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CompletionTemplateDialogFooter
|
||||||
|
mode={dialog.mode}
|
||||||
|
permissions={permissions}
|
||||||
|
isSaving={dialog.isSaving}
|
||||||
|
onCancel={dialog.cancel}
|
||||||
|
onSubmit={() => void dialog.submit()}
|
||||||
|
onEdit={dialog.startEditing}
|
||||||
|
onDelete={() => setConfirmingDelete(true)}
|
||||||
|
/>
|
||||||
|
{template !== null && permissions.canDelete && (
|
||||||
|
<DeleteCompletionTemplateDialog
|
||||||
|
template={template}
|
||||||
|
open={confirmingDelete}
|
||||||
|
onClose={() => setConfirmingDelete(false)}
|
||||||
|
onDeleted={onClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||||
|
import { FormField } from "@/components/ui/form-field";
|
||||||
|
import { TextField } from "@/components/ui/text-field";
|
||||||
|
import type { CompletionTemplateFormValues } from "@/domain/completion-templates/schemas/completion-template-schema";
|
||||||
|
import { CompletionTemplateProceduresEditor } from "./completion-template-procedures-editor";
|
||||||
|
import { CompletionTemplateSafetyNoteField } from "./completion-template-safety-note-field";
|
||||||
|
import {
|
||||||
|
AutoPopulatedFields,
|
||||||
|
FieldLabel,
|
||||||
|
SectionTitle,
|
||||||
|
StandardSafetyBullets,
|
||||||
|
} from "./completion-template-sections";
|
||||||
|
|
||||||
|
type CompletionTemplateFormProps = {
|
||||||
|
form: UseFormReturn<CompletionTemplateFormValues>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function TitleField({ form }: CompletionTemplateFormProps) {
|
||||||
|
return (
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<FormField
|
||||||
|
label="Document Title"
|
||||||
|
htmlFor="completion-template-title"
|
||||||
|
required
|
||||||
|
error={fieldState.error !== undefined}
|
||||||
|
helperText={fieldState.error?.message}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
id="completion-template-title"
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
inputRef={field.ref}
|
||||||
|
placeholder="Completion document title"
|
||||||
|
error={fieldState.error !== undefined}
|
||||||
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The editable template: title, fixed references, the optional note and procedures. */
|
||||||
|
export function CompletionTemplateForm({ form }: CompletionTemplateFormProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3.5">
|
||||||
|
<SectionTitle>Completion Document</SectionTitle>
|
||||||
|
<TitleField form={form} />
|
||||||
|
<AutoPopulatedFields />
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<FieldLabel>Safety Notes</FieldLabel>
|
||||||
|
<StandardSafetyBullets />
|
||||||
|
<CompletionTemplateSafetyNoteField form={form} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<FieldLabel hint="(optional)">Procedures</FieldLabel>
|
||||||
|
<CompletionTemplateProceduresEditor form={form} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { FileText } from "lucide-react";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { CompDocDocumentFrame } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-document-frame";
|
||||||
|
import { CompDocPreviewHeader } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-header";
|
||||||
|
import { CompDocProcedureHeading } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-procedure-heading";
|
||||||
|
import { CompDocSafetyBullets } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-safety-bullets";
|
||||||
|
import {
|
||||||
|
AUTO_POPULATED_FIELDS,
|
||||||
|
COMPLETION_TEMPLATE_COPY,
|
||||||
|
} from "@/domain/completion-templates/constants/completion-template-constants";
|
||||||
|
import type { CompletionTemplateProcedure } from "@/domain/completion-templates/types/completion-template";
|
||||||
|
import { descriptionLines } from "@/domain/completion-templates/utils/description-lines";
|
||||||
|
|
||||||
|
export interface CompletionTemplatePreviewValues {
|
||||||
|
name: string;
|
||||||
|
extraSafetyNote: string | null;
|
||||||
|
procedures: CompletionTemplateProcedure[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type CompletionTemplatePreviewProps = {
|
||||||
|
values: CompletionTemplatePreviewValues;
|
||||||
|
isEditing: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function PreviewPill({ isEditing }: { isEditing: boolean }) {
|
||||||
|
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)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileText size={13} />
|
||||||
|
{isEditing ? "Live preview" : "Document preview"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewTitle({ name }: { name: string }) {
|
||||||
|
const title = name.trim();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text
|
||||||
|
as="h2"
|
||||||
|
variant="title"
|
||||||
|
family="body"
|
||||||
|
sx={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.02em",
|
||||||
|
color: title ? "#161E38" : "#9CA3AF",
|
||||||
|
margin: "16px 0 4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title ? title.toUpperCase() : COMPLETION_TEMPLATE_COPY.untitledDocument}
|
||||||
|
</Text>
|
||||||
|
<Text variant="body" sx={{ fontSize: 12, color: "#58595B", margin: "2px 0 12px" }}>
|
||||||
|
{AUTO_POPULATED_FIELDS.join(" · ")} {COMPLETION_TEMPLATE_COPY.autoFilledSuffix}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PreviewProcedures({ procedures }: { procedures: CompletionTemplateProcedure[] }) {
|
||||||
|
if (procedures.length === 0) {
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
variant="body"
|
||||||
|
sx={{ fontSize: 12.5, color: "#9CA3AF", margin: "16px 0 0", fontStyle: "italic" }}
|
||||||
|
>
|
||||||
|
{COMPLETION_TEMPLATE_COPY.noProcedurePreview}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{procedures.map((procedure, index) => (
|
||||||
|
<section key={index} aria-label={`Preview procedure ${index + 1}`}>
|
||||||
|
<CompDocProcedureHeading>
|
||||||
|
{procedure.name.trim() || `Procedure ${index + 1}`}
|
||||||
|
</CompDocProcedureHeading>
|
||||||
|
{descriptionLines(procedure.description).map((line, lineIndex) => (
|
||||||
|
<Text
|
||||||
|
key={lineIndex}
|
||||||
|
variant="body"
|
||||||
|
sx={{ fontSize: 13, lineHeight: 1.5, color: "#161E38", margin: "4px 0" }}
|
||||||
|
>
|
||||||
|
{line}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The template rendered on the same page, header and safety bullets as a work order's document. */
|
||||||
|
export function CompletionTemplatePreview({ values, isEditing }: CompletionTemplatePreviewProps) {
|
||||||
|
const note = values.extraSafetyNote?.trim() ?? "";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex min-h-0 flex-col"
|
||||||
|
style={{
|
||||||
|
width: "58%",
|
||||||
|
borderRight: "1px solid var(--color-border)",
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PreviewPill isEditing={isEditing} />
|
||||||
|
<div
|
||||||
|
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
|
||||||
|
style={{ padding: "0 20px 20px" }}
|
||||||
|
data-testid="completion-template-preview"
|
||||||
|
>
|
||||||
|
<CompDocDocumentFrame>
|
||||||
|
<CompDocPreviewHeader />
|
||||||
|
<PreviewTitle name={values.name} />
|
||||||
|
<CompDocSafetyBullets extra={note === "" ? [] : [note]} />
|
||||||
|
<PreviewProcedures procedures={values.procedures} />
|
||||||
|
</CompDocDocumentFrame>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { TextField } from "@/components/ui/text-field";
|
||||||
|
import { PROCEDURE_NAME_MAX_LENGTH } from "@/domain/completion-templates/constants/completion-template-constants";
|
||||||
|
import type { CompletionTemplateFormValues } from "@/domain/completion-templates/schemas/completion-template-schema";
|
||||||
|
import { CompletionTemplateAddLink } from "./completion-template-add-link";
|
||||||
|
import { CompletionTemplateRemoveButton } from "./completion-template-remove-button";
|
||||||
|
|
||||||
|
type ProceduresEditorProps = {
|
||||||
|
form: UseFormReturn<CompletionTemplateFormValues>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DESCRIPTION_PLACEHOLDER =
|
||||||
|
"Describe the procedure — steps, sub-items, whatever the source document has. One item per line works well.";
|
||||||
|
|
||||||
|
function ProcedureFields({ form, index }: ProceduresEditorProps & { index: number }) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2.5">
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name={`procedures.${index}.name`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
inputRef={field.ref}
|
||||||
|
placeholder="Procedure name (e.g. Backflow Procedure)"
|
||||||
|
slotProps={{
|
||||||
|
htmlInput: {
|
||||||
|
"aria-label": `Procedure ${index + 1} name`,
|
||||||
|
maxLength: PROCEDURE_NAME_MAX_LENGTH,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name={`procedures.${index}.description`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
value={field.value}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
inputRef={field.ref}
|
||||||
|
multiline
|
||||||
|
minRows={5}
|
||||||
|
maxRows={8}
|
||||||
|
placeholder={DESCRIPTION_PLACEHOLDER}
|
||||||
|
slotProps={{ htmlInput: { "aria-label": `Procedure ${index + 1} description` } }}
|
||||||
|
sx={{ "& .MuiOutlinedInput-root": { height: "auto" } }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Procedures print in the order they are listed here. */
|
||||||
|
export function CompletionTemplateProceduresEditor({ form }: ProceduresEditorProps) {
|
||||||
|
const { fields, append, remove } = useFieldArray({ control: form.control, name: "procedures" });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{fields.map((field, index) => (
|
||||||
|
<fieldset
|
||||||
|
key={field.id}
|
||||||
|
aria-label={`Procedure ${index + 1}`}
|
||||||
|
className="m-0 min-w-0"
|
||||||
|
style={{
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: "var(--radius-md)",
|
||||||
|
border: "1px solid var(--color-border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between" style={{ marginBottom: 8 }}>
|
||||||
|
<Text
|
||||||
|
variant="caption"
|
||||||
|
tone="muted"
|
||||||
|
sx={{
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: 600,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.04em",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Procedure {index + 1}
|
||||||
|
</Text>
|
||||||
|
<CompletionTemplateRemoveButton
|
||||||
|
label={`Remove procedure ${index + 1}`}
|
||||||
|
onClick={() => remove(index)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ProcedureFields form={form} index={index} />
|
||||||
|
</fieldset>
|
||||||
|
))}
|
||||||
|
<CompletionTemplateAddLink onClick={() => append({ name: "", description: "" })}>
|
||||||
|
+ Add procedure
|
||||||
|
</CompletionTemplateAddLink>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { X } from "lucide-react";
|
||||||
|
|
||||||
|
export function CompletionTemplateRemoveButton({
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
className="shrink-0"
|
||||||
|
style={{ color: "var(--color-text-muted)" }}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import { Controller, useWatch, type UseFormReturn } from "react-hook-form";
|
||||||
|
import { TextField } from "@/components/ui/text-field";
|
||||||
|
import { EXTRA_SAFETY_NOTE_MAX_LENGTH } from "@/domain/completion-templates/constants/completion-template-constants";
|
||||||
|
import type { CompletionTemplateFormValues } from "@/domain/completion-templates/schemas/completion-template-schema";
|
||||||
|
import { CompletionTemplateAddLink } from "./completion-template-add-link";
|
||||||
|
import { CompletionTemplateRemoveButton } from "./completion-template-remove-button";
|
||||||
|
|
||||||
|
type SafetyNoteFieldProps = {
|
||||||
|
form: UseFormReturn<CompletionTemplateFormValues>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** At most one template-specific note, printed after the standard bullets. */
|
||||||
|
export function CompletionTemplateSafetyNoteField({ form }: SafetyNoteFieldProps) {
|
||||||
|
const note = useWatch({ control: form.control, name: "extraSafetyNote" });
|
||||||
|
const setNote = (value: string | null) =>
|
||||||
|
form.setValue("extraSafetyNote", value, { shouldDirty: true });
|
||||||
|
|
||||||
|
if (note === null) {
|
||||||
|
return (
|
||||||
|
<CompletionTemplateAddLink onClick={() => setNote("")}>
|
||||||
|
+ Add safety note
|
||||||
|
</CompletionTemplateAddLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="extraSafetyNote"
|
||||||
|
render={({ field }) => (
|
||||||
|
<TextField
|
||||||
|
multiline
|
||||||
|
minRows={3}
|
||||||
|
maxRows={8}
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={field.onChange}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
inputRef={field.ref}
|
||||||
|
placeholder="Type safety note…"
|
||||||
|
slotProps={{
|
||||||
|
htmlInput: {
|
||||||
|
"aria-label": "Extra safety note",
|
||||||
|
maxLength: EXTRA_SAFETY_NOTE_MAX_LENGTH,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
sx={{ "& .MuiOutlinedInput-root": { height: "auto" } }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div style={{ marginTop: 6 }}>
|
||||||
|
<CompletionTemplateRemoveButton label="Remove safety note" onClick={() => setNote(null)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { AUTO_POPULATED_FIELDS } from "@/domain/completion-templates/constants/completion-template-constants";
|
||||||
|
import { SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
|
||||||
|
|
||||||
|
export function SectionTitle({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Text as="div" variant="heading" family="body" sx={{ fontSize: 16, fontWeight: 600 }}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldLabel({ children, hint }: { children: ReactNode; hint?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-baseline gap-1.5" style={{ marginBottom: 4 }}>
|
||||||
|
<Text
|
||||||
|
variant="caption"
|
||||||
|
tone="muted"
|
||||||
|
sx={{ fontSize: 11, fontWeight: 500, textTransform: "uppercase", letterSpacing: "0.06em" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
{hint !== undefined && (
|
||||||
|
<Text variant="caption" tone="muted" sx={{ fontSize: 11 }}>
|
||||||
|
{hint}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AutoPopulatedFields() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<FieldLabel>Auto-populated fields</FieldLabel>
|
||||||
|
<ul className="m-0 flex list-none flex-wrap items-center gap-1.5 p-0">
|
||||||
|
{AUTO_POPULATED_FIELDS.map((field) => (
|
||||||
|
<li
|
||||||
|
key={field}
|
||||||
|
className="inline-flex items-center"
|
||||||
|
style={{
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
color: "var(--color-text-muted)",
|
||||||
|
fontSize: 12,
|
||||||
|
padding: "2px 8px",
|
||||||
|
borderRadius: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{field}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The standard bullets every generated document prints; never editable per template. */
|
||||||
|
export function StandardSafetyBullets({ extra }: { extra?: string | null }) {
|
||||||
|
return (
|
||||||
|
<ul aria-label="Safety notes" style={{ margin: "0 0 8px", paddingLeft: 18 }}>
|
||||||
|
{SAFETY_BULLETS.map((bullet) => (
|
||||||
|
<li
|
||||||
|
key={bullet}
|
||||||
|
style={{ fontSize: 12.5, color: "var(--color-text-muted)", margin: "2px 0" }}
|
||||||
|
>
|
||||||
|
{bullet}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{Boolean(extra) && (
|
||||||
|
<li style={{ fontSize: 12.5, margin: "2px 0", whiteSpace: "pre-wrap" }}>{extra}</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,162 @@
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableRow } from "@mui/material";
|
||||||
|
import { Eye, FileText, Pencil, Search } from "lucide-react";
|
||||||
|
import type { MouseEvent } from "react";
|
||||||
|
import { TableLoadingRow } from "@/components/ui/table-state-rows";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { COMPLETION_TEMPLATE_COPY } from "@/domain/completion-templates/constants/completion-template-constants";
|
||||||
|
import type { CompletionTemplate } from "@/domain/completion-templates/types/completion-template";
|
||||||
|
import { formatTemplateDate } from "@/domain/completion-templates/utils/format-template-date";
|
||||||
|
import { CompletionTemplateCreatedBy } from "./completion-template-created-by";
|
||||||
|
|
||||||
|
const HEAD_CELL_SX = {
|
||||||
|
fontSize: 10.5,
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "0.06em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
padding: "11px 14px",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const BODY_CELL_SX = { fontSize: 13, padding: "8px 12px" } as const;
|
||||||
|
|
||||||
|
type CompletionTemplatesTableProps = {
|
||||||
|
templates: CompletionTemplate[];
|
||||||
|
isLoading: boolean;
|
||||||
|
/** The load failed: the page shows the error, so there is no search result to explain. */
|
||||||
|
hasError: boolean;
|
||||||
|
canEdit: boolean;
|
||||||
|
onView: (template: CompletionTemplate) => void;
|
||||||
|
onEdit: (template: CompletionTemplate) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function RowAction({
|
||||||
|
label,
|
||||||
|
icon: Icon,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
icon: typeof Eye;
|
||||||
|
onClick: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
|
onClick={(event: MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onClick();
|
||||||
|
}}
|
||||||
|
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-primary"
|
||||||
|
>
|
||||||
|
<Icon size={15} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyRow() {
|
||||||
|
return (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} sx={{ padding: 0, borderBottom: 0 }}>
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center justify-center gap-2 text-center"
|
||||||
|
style={{ padding: "64px 16px" }}
|
||||||
|
>
|
||||||
|
<Search size={22} aria-hidden style={{ color: "var(--color-text-muted)" }} />
|
||||||
|
<Text variant="body" sx={{ fontSize: 14, fontWeight: 600 }}>
|
||||||
|
{COMPLETION_TEMPLATE_COPY.emptySearchTitle}
|
||||||
|
</Text>
|
||||||
|
<Text variant="description" tone="muted" sx={{ fontSize: 12.5 }}>
|
||||||
|
{COMPLETION_TEMPLATE_COPY.emptySearchHint}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TemplateRow({
|
||||||
|
template,
|
||||||
|
canEdit,
|
||||||
|
onView,
|
||||||
|
onEdit,
|
||||||
|
}: Omit<CompletionTemplatesTableProps, "templates" | "isLoading" | "hasError"> & {
|
||||||
|
template: CompletionTemplate;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TableRow hover onClick={() => onView(template)} sx={{ cursor: "pointer", height: 52 }}>
|
||||||
|
<TableCell sx={BODY_CELL_SX}>
|
||||||
|
<span className="inline-flex items-center gap-2.5">
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className="inline-flex shrink-0 items-center justify-center rounded-full"
|
||||||
|
style={{
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
background: "var(--color-bg-muted)",
|
||||||
|
color: "var(--color-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileText size={14} />
|
||||||
|
</span>
|
||||||
|
{template.name}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ ...BODY_CELL_SX, color: "text.secondary" }}>
|
||||||
|
{formatTemplateDate(template.lastUpdatedAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={BODY_CELL_SX}>
|
||||||
|
<CompletionTemplateCreatedBy name={template.createdByName} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={BODY_CELL_SX}>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<RowAction label="View details" icon={Eye} onClick={() => onView(template)} />
|
||||||
|
{canEdit && <RowAction label="Edit" icon={Pencil} onClick={() => onEdit(template)} />}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CompletionTemplatesTable({
|
||||||
|
templates,
|
||||||
|
isLoading,
|
||||||
|
hasError,
|
||||||
|
canEdit,
|
||||||
|
onView,
|
||||||
|
onEdit,
|
||||||
|
}: CompletionTemplatesTableProps) {
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
stickyHeader
|
||||||
|
size="small"
|
||||||
|
aria-label="Completion document templates"
|
||||||
|
sx={{ minWidth: 700 }}
|
||||||
|
>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell sx={{ ...HEAD_CELL_SX, width: 360 }}>Document Title</TableCell>
|
||||||
|
<TableCell sx={{ ...HEAD_CELL_SX, width: 140 }}>Last Updated</TableCell>
|
||||||
|
<TableCell sx={{ ...HEAD_CELL_SX, width: 180 }}>Created By</TableCell>
|
||||||
|
<TableCell sx={{ ...HEAD_CELL_SX, width: 90 }}>
|
||||||
|
<span className="sr-only">Actions</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading && <TableLoadingRow colSpan={4} />}
|
||||||
|
{!isLoading && !hasError && templates.length === 0 && <EmptyRow />}
|
||||||
|
{templates.map((template) => (
|
||||||
|
<TemplateRow
|
||||||
|
key={template.id}
|
||||||
|
template={template}
|
||||||
|
canEdit={canEdit}
|
||||||
|
onView={onView}
|
||||||
|
onEdit={onEdit}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { DialogCancelButton } from "@/components/ui/dialog-cancel-button";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type {
|
||||||
|
CompletionTemplate,
|
||||||
|
LinkedWorkOrders,
|
||||||
|
} from "@/domain/completion-templates/types/completion-template";
|
||||||
|
import { useDeleteCompletionTemplate } from "@/domain/completion-templates/use-cases/use-completion-template-mutations";
|
||||||
|
import { useLinkedWorkOrders } from "@/domain/completion-templates/use-cases/use-completion-templates";
|
||||||
|
import { buildWorkOrderIdsHref } from "@/domain/work-orders/utils/work-order-ids-filter";
|
||||||
|
|
||||||
|
type DeleteCompletionTemplateDialogProps = {
|
||||||
|
template: CompletionTemplate;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onDeleted: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function deleteMessage(name: string, linked: LinkedWorkOrders): string {
|
||||||
|
if (linked.count === 0) return `This will permanently remove "${name}". This cannot be undone.`;
|
||||||
|
const plural = linked.count === 1 ? "" : "s";
|
||||||
|
return (
|
||||||
|
`This will permanently remove "${name}". It's still linked to ${linked.count} open work ` +
|
||||||
|
`order${plural} through its Service — deleting it won't change that Service, but new ` +
|
||||||
|
"documents generated for those work orders will fall back to a generic procedure until " +
|
||||||
|
"reassigned. This cannot be undone."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkedWorkOrdersMessage({
|
||||||
|
name,
|
||||||
|
linked,
|
||||||
|
}: {
|
||||||
|
name: string;
|
||||||
|
linked: ReturnType<typeof useLinkedWorkOrders>;
|
||||||
|
}) {
|
||||||
|
if (linked.isPending) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2" role="status">
|
||||||
|
<CircularProgress size={16} />
|
||||||
|
<Text variant="description" tone="muted">
|
||||||
|
Checking linked work orders…
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (linked.isError) {
|
||||||
|
return (
|
||||||
|
<Text variant="error">
|
||||||
|
Couldn't check which work orders use this template. Close this and try again.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Text variant="description">{deleteMessage(name, linked.data)}</Text>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deleting always asks first, and names the open work orders that still depend on the template. */
|
||||||
|
export function DeleteCompletionTemplateDialog({
|
||||||
|
template,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onDeleted,
|
||||||
|
}: DeleteCompletionTemplateDialogProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const linked = useLinkedWorkOrders(open ? template.id : null);
|
||||||
|
const remove = useDeleteCompletionTemplate();
|
||||||
|
const linkedIds = linked.data?.workOrderIds ?? [];
|
||||||
|
|
||||||
|
function confirmDelete() {
|
||||||
|
remove.mutate(template.id, { onSuccess: onDeleted });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||||
|
<DialogTitle>Delete this template?</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<LinkedWorkOrdersMessage name={template.name} linked={linked} />
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<DialogCancelButton onClick={onClose} />
|
||||||
|
{linkedIds.length > 0 && (
|
||||||
|
<Button variant="outline" onClick={() => void navigate(buildWorkOrderIdsHref(linkedIds))}>
|
||||||
|
View linked work orders
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={confirmDelete}
|
||||||
|
disabled={!linked.isSuccess || remove.isPending}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useForm, useWatch } from "react-hook-form";
|
||||||
|
import { COMPLETION_TEMPLATE_COPY } from "@/domain/completion-templates/constants/completion-template-constants";
|
||||||
|
import {
|
||||||
|
toCompletionTemplateFormValues,
|
||||||
|
toCompletionTemplateInput,
|
||||||
|
} from "@/domain/completion-templates/mappers/completion-template-mapper";
|
||||||
|
import {
|
||||||
|
completionTemplateFormSchema,
|
||||||
|
type CompletionTemplateFormValues,
|
||||||
|
} from "@/domain/completion-templates/schemas/completion-template-schema";
|
||||||
|
import type { CompletionTemplate } from "@/domain/completion-templates/types/completion-template";
|
||||||
|
import {
|
||||||
|
isTitleRequiredError,
|
||||||
|
useCreateCompletionTemplate,
|
||||||
|
useUpdateCompletionTemplate,
|
||||||
|
} from "@/domain/completion-templates/use-cases/use-completion-template-mutations";
|
||||||
|
import type { CompletionTemplatePreviewValues } from "./completion-template-preview";
|
||||||
|
|
||||||
|
export type CompletionTemplateDialogMode = "create" | "view" | "edit";
|
||||||
|
|
||||||
|
type UseCompletionTemplateDialogOptions = {
|
||||||
|
template: CompletionTemplate | null;
|
||||||
|
initialMode: CompletionTemplateDialogMode;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: (template: CompletionTemplate) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function usePreviewValues(
|
||||||
|
control: ReturnType<typeof useForm<CompletionTemplateFormValues>>["control"],
|
||||||
|
): CompletionTemplatePreviewValues {
|
||||||
|
const watched = useWatch({ control });
|
||||||
|
return {
|
||||||
|
name: watched.name ?? "",
|
||||||
|
extraSafetyNote: watched.extraSafetyNote ?? null,
|
||||||
|
procedures: (watched.procedures ?? []).map((procedure) => ({
|
||||||
|
name: procedure?.name ?? "",
|
||||||
|
description: procedure?.description ?? "",
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCompletionTemplateDialog({
|
||||||
|
template,
|
||||||
|
initialMode,
|
||||||
|
onClose,
|
||||||
|
onSaved,
|
||||||
|
}: UseCompletionTemplateDialogOptions) {
|
||||||
|
const [mode, setMode] = useState<CompletionTemplateDialogMode>(
|
||||||
|
template === null ? "create" : initialMode,
|
||||||
|
);
|
||||||
|
const form = useForm<CompletionTemplateFormValues>({
|
||||||
|
resolver: zodResolver(completionTemplateFormSchema),
|
||||||
|
defaultValues: toCompletionTemplateFormValues(template),
|
||||||
|
});
|
||||||
|
const previewValues = usePreviewValues(form.control);
|
||||||
|
const create = useCreateCompletionTemplate();
|
||||||
|
const update = useUpdateCompletionTemplate();
|
||||||
|
|
||||||
|
function showServerTitleError(error: Error) {
|
||||||
|
if (!isTitleRequiredError(error)) return;
|
||||||
|
form.setError(
|
||||||
|
"name",
|
||||||
|
{ message: COMPLETION_TEMPLATE_COPY.titleRequired },
|
||||||
|
{ shouldFocus: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const submit = form.handleSubmit((values) => {
|
||||||
|
const input = toCompletionTemplateInput(values);
|
||||||
|
if (template === null) {
|
||||||
|
create.mutate(input, { onSuccess: onClose, onError: showServerTitleError });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
update.mutate(
|
||||||
|
{ template, input },
|
||||||
|
{
|
||||||
|
onSuccess: (saved) => {
|
||||||
|
form.reset(toCompletionTemplateFormValues(saved));
|
||||||
|
setMode("view");
|
||||||
|
onSaved(saved);
|
||||||
|
},
|
||||||
|
onError: showServerTitleError,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Discards unsaved edits: back to the template as it was, or closed when creating. */
|
||||||
|
function cancel() {
|
||||||
|
if (template === null) {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
form.reset(toCompletionTemplateFormValues(template));
|
||||||
|
setMode("view");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode,
|
||||||
|
isEditing: mode !== "view",
|
||||||
|
form,
|
||||||
|
previewValues,
|
||||||
|
submit,
|
||||||
|
cancel,
|
||||||
|
startEditing: () => setMode("edit"),
|
||||||
|
isSaving: create.isPending || update.isPending,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { defaultsForRole } from "@/app/(protected)/team-members/_components/team-member-permissions";
|
||||||
|
import type { TeamMemberRole } from "@/domain/team-members/types/team-member";
|
||||||
|
import { useAuthContext } from "@/providers/auth-context";
|
||||||
|
|
||||||
|
const TEAM_ROLES: readonly TeamMemberRole[] = ["Admin", "Scheduler", "Dispatcher"];
|
||||||
|
|
||||||
|
export interface CompletionTemplatePermissions {
|
||||||
|
canCreate: boolean;
|
||||||
|
canEdit: boolean;
|
||||||
|
canDelete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function teamRoles(userRoles: string | null | undefined): TeamMemberRole[] {
|
||||||
|
const names = (userRoles ?? "").split(",").map((role) => role.trim().toLowerCase());
|
||||||
|
return TEAM_ROLES.filter((role) => names.includes(role.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The signed-in user's session carries roles, not per-user permission overrides, so each key
|
||||||
|
* resolves to the role default the Team Members screen and the server policy start from.
|
||||||
|
*/
|
||||||
|
export function completionTemplatePermissions(
|
||||||
|
userRoles: string | null | undefined,
|
||||||
|
): CompletionTemplatePermissions {
|
||||||
|
const granted = new Set(teamRoles(userRoles).flatMap((role) => [...defaultsForRole(role)]));
|
||||||
|
return {
|
||||||
|
canCreate: granted.has("createCompletionDocTemplates"),
|
||||||
|
canEdit: granted.has("editCompletionDocTemplates"),
|
||||||
|
canDelete: granted.has("deleteCompletionDocTemplates"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCompletionTemplatePermissions(): CompletionTemplatePermissions {
|
||||||
|
const { user } = useAuthContext();
|
||||||
|
return completionTemplatePermissions(user?.userRoles);
|
||||||
|
}
|
||||||
99
src/app/(protected)/completion-docs/index.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { Box } from "@mui/material";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { PageHeader } from "@/components/ui/page-header";
|
||||||
|
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
||||||
|
import { SearchInput } from "@/components/ui/search-input";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import {
|
||||||
|
COMPLETION_TEMPLATE_COPY,
|
||||||
|
templateCountLabel,
|
||||||
|
} from "@/domain/completion-templates/constants/completion-template-constants";
|
||||||
|
import type { CompletionTemplate } from "@/domain/completion-templates/types/completion-template";
|
||||||
|
import { useCompletionTemplates } from "@/domain/completion-templates/use-cases/use-completion-templates";
|
||||||
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
|
import { CompletionTemplateDialog } from "./_components/completion-template-dialog";
|
||||||
|
import { CompletionTemplatesTable } from "./_components/completion-templates-table";
|
||||||
|
import type { CompletionTemplateDialogMode } from "./_components/use-completion-template-dialog";
|
||||||
|
import { useCompletionTemplatePermissions } from "./_components/use-completion-template-permissions";
|
||||||
|
|
||||||
|
type OpenDialog = { template: CompletionTemplate | null; mode: CompletionTemplateDialogMode };
|
||||||
|
|
||||||
|
export default function CompletionDocsPage() {
|
||||||
|
const permissions = useCompletionTemplatePermissions();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const debouncedSearch = useDebounce(search, 250);
|
||||||
|
const all = useCompletionTemplates();
|
||||||
|
const filtered = useCompletionTemplates(debouncedSearch);
|
||||||
|
const [dialog, setDialog] = useState<OpenDialog | null>(null);
|
||||||
|
const templates = filtered.data ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box className="flex h-full flex-col overflow-hidden p-2.5">
|
||||||
|
<PageHeader
|
||||||
|
surface
|
||||||
|
title={COMPLETION_TEMPLATE_COPY.pageTitle}
|
||||||
|
subtitle={templateCountLabel(all.data?.length ?? 0)}
|
||||||
|
actions={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SearchInput
|
||||||
|
value={search}
|
||||||
|
onChange={setSearch}
|
||||||
|
placeholder={COMPLETION_TEMPLATE_COPY.searchPlaceholder}
|
||||||
|
aria-label="Search document title"
|
||||||
|
className="w-56"
|
||||||
|
/>
|
||||||
|
{permissions.canCreate && (
|
||||||
|
<Button
|
||||||
|
onClick={() => setDialog({ template: null, mode: "create" })}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
<Plus size={13} /> {COMPLETION_TEMPLATE_COPY.addTemplate}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{filtered.error !== null && (
|
||||||
|
<QueryErrorPanel
|
||||||
|
className="mt-3 shrink-0"
|
||||||
|
compact
|
||||||
|
message={filtered.error.message || "Failed to load templates."}
|
||||||
|
onRetry={() => void filtered.refetch()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="mt-4 min-h-0 flex-1 overflow-auto rounded-lg border border-border"
|
||||||
|
style={{ background: "var(--card)" }}
|
||||||
|
>
|
||||||
|
<CompletionTemplatesTable
|
||||||
|
templates={templates}
|
||||||
|
isLoading={filtered.isPending}
|
||||||
|
hasError={filtered.error !== null}
|
||||||
|
canEdit={permissions.canEdit}
|
||||||
|
onView={(template) => setDialog({ template, mode: "view" })}
|
||||||
|
onEdit={(template) => setDialog({ template, mode: "edit" })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 px-4 py-2">
|
||||||
|
<Text variant="caption" tone="muted" sx={{ fontSize: 11 }}>
|
||||||
|
{templateCountLabel(templates.length)}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dialog !== null && (
|
||||||
|
<CompletionTemplateDialog
|
||||||
|
key={dialog.template?.id ?? "new"}
|
||||||
|
template={dialog.template}
|
||||||
|
initialMode={dialog.mode}
|
||||||
|
permissions={permissions}
|
||||||
|
onClose={() => setDialog(null)}
|
||||||
|
onSaved={(template) => setDialog({ template, mode: "view" })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogContentText,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@mui/material";
|
|
||||||
import type { LocationListItem } from "@/domain/locations/types/location";
|
|
||||||
|
|
||||||
interface LocationDeleteDialogProps {
|
|
||||||
isPending: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onConfirm: () => void;
|
|
||||||
target: LocationListItem | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LocationDeleteDialog({
|
|
||||||
isPending,
|
|
||||||
onClose,
|
|
||||||
onConfirm,
|
|
||||||
target,
|
|
||||||
}: LocationDeleteDialogProps) {
|
|
||||||
return (
|
|
||||||
<Dialog open={Boolean(target)} onClose={onClose}>
|
|
||||||
<DialogTitle>Delete this site?</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogContentText>
|
|
||||||
This will permanently remove {target?.name} from Sites. This cannot be undone.
|
|
||||||
</DialogContentText>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={onClose} disabled={isPending}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button color="error" variant="contained" onClick={onConfirm} disabled={isPending}>
|
|
||||||
{isPending ? "Deleting..." : "Delete"}
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
IconButton,
|
|
||||||
Paper,
|
Paper,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -10,7 +8,6 @@ import {
|
||||||
TableContainer,
|
TableContainer,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableRow,
|
TableRow,
|
||||||
Tooltip,
|
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import type { LocationListItem } from "@/domain/locations/types/location";
|
import type { LocationListItem } from "@/domain/locations/types/location";
|
||||||
|
|
@ -19,17 +16,15 @@ import { StateAreaCell } from "./state-area-cell";
|
||||||
|
|
||||||
interface LocationsTableProps {
|
interface LocationsTableProps {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
onDelete: (row: LocationListItem) => void;
|
|
||||||
onOpen: (row: LocationListItem) => void;
|
onOpen: (row: LocationListItem) => void;
|
||||||
rows: LocationListItem[];
|
rows: LocationListItem[];
|
||||||
selectedId?: string | null;
|
selectedId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLUMN_COUNT = 8;
|
const COLUMN_COUNT = 7;
|
||||||
|
|
||||||
export function LocationsTable({
|
export function LocationsTable({
|
||||||
isLoading,
|
isLoading,
|
||||||
onDelete,
|
|
||||||
onOpen,
|
onOpen,
|
||||||
rows,
|
rows,
|
||||||
selectedId = null,
|
selectedId = null,
|
||||||
|
|
@ -46,9 +41,6 @@ export function LocationsTable({
|
||||||
<TableCell className="font-semibold">State</TableCell>
|
<TableCell className="font-semibold">State</TableCell>
|
||||||
<TableCell className="font-semibold">POC Name</TableCell>
|
<TableCell className="font-semibold">POC Name</TableCell>
|
||||||
<TableCell className="font-semibold">POC Phone</TableCell>
|
<TableCell className="font-semibold">POC Phone</TableCell>
|
||||||
<TableCell align="right" className="font-semibold">
|
|
||||||
<span className="sr-only">Actions</span>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|
@ -98,20 +90,6 @@ export function LocationsTable({
|
||||||
<LocationsContactsCell row={row} />
|
<LocationsContactsCell row={row} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{row.phone || "—"}</TableCell>
|
<TableCell>{row.phone || "—"}</TableCell>
|
||||||
<TableCell align="right">
|
|
||||||
<Tooltip title="Delete">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
aria-label={`Delete site ${row.name}`}
|
|
||||||
onClick={(event) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
onDelete(row);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DeleteOutlineIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|
|
||||||
110
src/app/(protected)/locations/_components/site-delete-dialog.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@mui/material";
|
||||||
|
import { DIALOG_BLUR_BACKDROP_SX } from "@/components/ui/dialog-blur-backdrop";
|
||||||
|
import { useDeleteLocation } from "@/domain/locations/use-cases/use-delete-location";
|
||||||
|
import { useSiteOpenWorkOrders } from "@/domain/locations/use-cases/use-site-open-work-orders";
|
||||||
|
import { buildSiteOpenWorkOrdersHref } from "@/domain/work-orders/utils/dashboard-drilldown-params";
|
||||||
|
import { buildWorkOrderIdsHref } from "@/domain/work-orders/utils/work-order-ids-filter";
|
||||||
|
|
||||||
|
interface SiteDeleteDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
siteId: string;
|
||||||
|
siteCode: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onDeleted: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CHECK_FAILED_MESSAGE =
|
||||||
|
"Couldn't check this site for open work orders. Try again before deleting it.";
|
||||||
|
|
||||||
|
function describeDeletion(siteCode: string, openCount: number): string {
|
||||||
|
if (openCount === 0) {
|
||||||
|
return `This will permanently remove ${siteCode} from Sites. This cannot be undone.`;
|
||||||
|
}
|
||||||
|
const noun = openCount === 1 ? "work order" : "work orders";
|
||||||
|
return `This will permanently remove ${siteCode} from Sites. It still has ${openCount} open ${noun} — deleting the site won't cancel them automatically. This cannot be undone.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete confirmation; warns with the open work-order count and links to those work orders. */
|
||||||
|
export function SiteDeleteDialog({
|
||||||
|
open,
|
||||||
|
siteId,
|
||||||
|
siteCode,
|
||||||
|
onClose,
|
||||||
|
onDeleted,
|
||||||
|
}: SiteDeleteDialogProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const deleteLocation = useDeleteLocation();
|
||||||
|
const {
|
||||||
|
data: openWorkOrders,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
isFetching,
|
||||||
|
refetch,
|
||||||
|
} = useSiteOpenWorkOrders(siteId, open);
|
||||||
|
const openCount = openWorkOrders?.count ?? 0;
|
||||||
|
// Without a current count the warning cannot be trusted, so Delete waits for a successful check.
|
||||||
|
// `isFetching` covers reopening: the count cached from the last open is stale until the refetch lands.
|
||||||
|
const countUnknown = isLoading || isFetching || isError;
|
||||||
|
// The server caps the id list; when it doesn't cover the stated count, open the site's whole board.
|
||||||
|
const openIds = openWorkOrders?.workOrderIds ?? [];
|
||||||
|
const openWorkOrdersHref =
|
||||||
|
openIds.length > 0 && openIds.length >= openCount
|
||||||
|
? buildWorkOrderIdsHref(openIds)
|
||||||
|
: buildSiteOpenWorkOrdersHref(siteId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onClose={() => {
|
||||||
|
if (!deleteLocation.isPending) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-labelledby="site-delete-title"
|
||||||
|
slotProps={{ backdrop: { sx: DIALOG_BLUR_BACKDROP_SX } }}
|
||||||
|
>
|
||||||
|
<DialogTitle id="site-delete-title">Delete this site?</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{isFetching && !isError && (
|
||||||
|
<CircularProgress size={20} aria-label="Checking open work orders" />
|
||||||
|
)}
|
||||||
|
{isError && <DialogContentText role="alert">{CHECK_FAILED_MESSAGE}</DialogContentText>}
|
||||||
|
{!countUnknown && (
|
||||||
|
<DialogContentText>{describeDeletion(siteCode, openCount)}</DialogContentText>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button variant="text" onClick={onClose} disabled={deleteLocation.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
{isError && (
|
||||||
|
<Button variant="outlined" disabled={isFetching} onClick={() => void refetch()}>
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!countUnknown && openCount > 0 && (
|
||||||
|
<Button variant="outlined" onClick={() => navigate(openWorkOrdersHref)}>
|
||||||
|
View open work orders
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="error"
|
||||||
|
disabled={countUnknown || deleteLocation.isPending}
|
||||||
|
onClick={() => deleteLocation.mutate(siteId, { onSuccess: onDeleted })}
|
||||||
|
>
|
||||||
|
{deleteLocation.isPending ? "Deleting..." : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useContext, useEffect, useState } from "react";
|
||||||
import { Alert, Box, Button, CircularProgress, Drawer, Stack, Typography } from "@mui/material";
|
import { Alert, Box, Button, CircularProgress, Drawer, Stack, Typography } from "@mui/material";
|
||||||
import { IconCloseButton } from "@/components/ui/icon-close-button";
|
import { IconCloseButton } from "@/components/ui/icon-close-button";
|
||||||
import type { Location } from "@/domain/locations/types/location";
|
import type { Location } from "@/domain/locations/types/location";
|
||||||
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||||
|
import { canDeleteSites } from "@/lib/auth/user-utils";
|
||||||
|
import { AuthContext } from "@/providers/auth-context";
|
||||||
|
import { SiteDeleteDialog } from "./site-delete-dialog";
|
||||||
import { SiteDetailEdit } from "./site-detail-edit";
|
import { SiteDetailEdit } from "./site-detail-edit";
|
||||||
import { SiteDetailView } from "./site-detail-view";
|
import { SiteDetailView } from "./site-detail-view";
|
||||||
|
|
||||||
|
|
@ -36,6 +39,8 @@ function PanelHeader({ location, onClose }: { location?: Location; onClose: () =
|
||||||
|
|
||||||
function PanelBody({ siteId, onClose }: { siteId: string; onClose: () => void }) {
|
function PanelBody({ siteId, onClose }: { siteId: string; onClose: () => void }) {
|
||||||
const [mode, setMode] = useState<"view" | "edit">("view");
|
const [mode, setMode] = useState<"view" | "edit">("view");
|
||||||
|
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||||
|
const canDelete = canDeleteSites(useContext(AuthContext)?.user?.userRoles);
|
||||||
const { data: location, isLoading, error } = useLocationDetail(siteId);
|
const { data: location, isLoading, error } = useLocationDetail(siteId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -65,10 +70,22 @@ function PanelBody({ siteId, onClose }: { siteId: string; onClose: () => void })
|
||||||
className="border-t px-4 py-3"
|
className="border-t px-4 py-3"
|
||||||
sx={{ justifyContent: "flex-end", borderColor: "divider" }}
|
sx={{ justifyContent: "flex-end", borderColor: "divider" }}
|
||||||
>
|
>
|
||||||
|
{canDelete && (
|
||||||
|
<Button color="error" onClick={() => setIsDeleteOpen(true)} className="mr-auto">
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="contained" onClick={() => setMode("edit")}>
|
<Button variant="contained" onClick={() => setMode("edit")}>
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
<SiteDeleteDialog
|
||||||
|
open={isDeleteOpen}
|
||||||
|
siteId={siteId}
|
||||||
|
siteCode={location.name ?? ""}
|
||||||
|
onClose={() => setIsDeleteOpen(false)}
|
||||||
|
onDeleted={onClose}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,11 @@ import {
|
||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import type { LocationListItem } from "@/domain/locations/types/location";
|
|
||||||
import { useDeleteLocation } from "@/domain/locations/use-cases/use-delete-location";
|
|
||||||
import { useLocationsList } from "@/domain/locations/use-cases/use-locations-list";
|
import { useLocationsList } from "@/domain/locations/use-cases/use-locations-list";
|
||||||
import { useDebounce } from "@/hooks/use-debounce";
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
import { expandAreasToStateCodes, type AreaZone } from "@/data/areas";
|
import { expandAreasToStateCodes, type AreaZone } from "@/data/areas";
|
||||||
import { AddSiteDialog } from "./_components/add-site-dialog";
|
import { AddSiteDialog } from "./_components/add-site-dialog";
|
||||||
import { AreaFacet } from "./_components/area-facet";
|
import { AreaFacet } from "./_components/area-facet";
|
||||||
import { LocationDeleteDialog } from "./_components/location-delete-dialog";
|
|
||||||
import { LocationsTable } from "./_components/locations-table";
|
import { LocationsTable } from "./_components/locations-table";
|
||||||
import { SITE_PANEL_WIDTH, SiteDetailPanel } from "./_components/site-detail-panel";
|
import { SITE_PANEL_WIDTH, SiteDetailPanel } from "./_components/site-detail-panel";
|
||||||
|
|
||||||
|
|
@ -25,12 +22,10 @@ export default function LocationsListPage() {
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [pageSize, setPageSize] = useState(12);
|
const [pageSize, setPageSize] = useState(12);
|
||||||
const [selectedAreas, setSelectedAreas] = useState<AreaZone[]>([]);
|
const [selectedAreas, setSelectedAreas] = useState<AreaZone[]>([]);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<LocationListItem | null>(null);
|
|
||||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
const debouncedSearch = useDebounce(search, 300);
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
const deleteLocation = useDeleteLocation();
|
|
||||||
const closePanel = useCallback(() => setSelectedId(null), []);
|
const closePanel = useCallback(() => setSelectedId(null), []);
|
||||||
|
|
||||||
const stateCodes = useMemo(() => expandAreasToStateCodes(selectedAreas), [selectedAreas]);
|
const stateCodes = useMemo(() => expandAreasToStateCodes(selectedAreas), [selectedAreas]);
|
||||||
|
|
@ -50,20 +45,6 @@ export default function LocationsListPage() {
|
||||||
const rows = data?.items ?? [];
|
const rows = data?.items ?? [];
|
||||||
const totalCount = data?.totalCount ?? 0;
|
const totalCount = data?.totalCount ?? 0;
|
||||||
|
|
||||||
const handleDeleteConfirm = () => {
|
|
||||||
if (!deleteTarget) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deletedId = String(deleteTarget.id);
|
|
||||||
deleteLocation.mutate(deleteTarget.id, {
|
|
||||||
onSuccess: () => {
|
|
||||||
setDeleteTarget(null);
|
|
||||||
setSelectedId((current) => (current === deletedId ? null : current));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
className="flex flex-col gap-4 p-4"
|
className="flex flex-col gap-4 p-4"
|
||||||
|
|
@ -120,7 +101,6 @@ export default function LocationsListPage() {
|
||||||
rows={rows}
|
rows={rows}
|
||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
onOpen={(row) => setSelectedId(String(row.id))}
|
onOpen={(row) => setSelectedId(String(row.id))}
|
||||||
onDelete={setDeleteTarget}
|
|
||||||
/>
|
/>
|
||||||
<TablePagination
|
<TablePagination
|
||||||
component="div"
|
component="div"
|
||||||
|
|
@ -136,12 +116,6 @@ export default function LocationsListPage() {
|
||||||
/>
|
/>
|
||||||
<AddSiteDialog open={isAddOpen} onClose={() => setIsAddOpen(false)} />
|
<AddSiteDialog open={isAddOpen} onClose={() => setIsAddOpen(false)} />
|
||||||
<SiteDetailPanel siteId={selectedId} onClose={closePanel} />
|
<SiteDetailPanel siteId={selectedId} onClose={closePanel} />
|
||||||
<LocationDeleteDialog
|
|
||||||
target={deleteTarget}
|
|
||||||
isPending={deleteLocation.isPending}
|
|
||||||
onClose={() => setDeleteTarget(null)}
|
|
||||||
onConfirm={handleDeleteConfirm}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
export const UPLIFT_APPROVAL_TABS = ["pending", "approved", "rejected"] as const;
|
||||||
|
export type UpliftApprovalTab = (typeof UPLIFT_APPROVAL_TABS)[number];
|
||||||
|
|
||||||
|
export function isUpliftApprovalTab(value: string): value is UpliftApprovalTab {
|
||||||
|
return (UPLIFT_APPROVAL_TABS as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PENDING_HEADER_CELLS = [
|
||||||
|
"Work Order",
|
||||||
|
"Service / Vendor",
|
||||||
|
"Amount",
|
||||||
|
"Approved on WO",
|
||||||
|
"Notes",
|
||||||
|
"Attachments",
|
||||||
|
"Requested By",
|
||||||
|
"Waiting",
|
||||||
|
"Actions",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const APPROVED_HEADER_CELLS = [
|
||||||
|
"Work Order",
|
||||||
|
"Service / Vendor",
|
||||||
|
"Amount",
|
||||||
|
"Approved on WO",
|
||||||
|
"Notes",
|
||||||
|
"Attachments",
|
||||||
|
"Requested By",
|
||||||
|
"Approved By",
|
||||||
|
"Approved At",
|
||||||
|
"Actions",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// A rejection is terminal, so the archive ends with the decision and its reason
|
||||||
|
// instead of an Actions column.
|
||||||
|
const REJECTED_HEADER_CELLS = [
|
||||||
|
"Work Order",
|
||||||
|
"Service / Vendor",
|
||||||
|
"Amount",
|
||||||
|
"Approved on WO",
|
||||||
|
"Notes",
|
||||||
|
"Attachments",
|
||||||
|
"Requested By",
|
||||||
|
"Rejected By",
|
||||||
|
"Rejected At",
|
||||||
|
"Rejection Reason",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const UPLIFT_APPROVAL_HEADER_CELLS: Record<UpliftApprovalTab, readonly string[]> = {
|
||||||
|
pending: PENDING_HEADER_CELLS,
|
||||||
|
approved: APPROVED_HEADER_CELLS,
|
||||||
|
rejected: REJECTED_HEADER_CELLS,
|
||||||
|
};
|
||||||
|
|
@ -16,14 +16,20 @@ import {
|
||||||
formatUpliftMoney,
|
formatUpliftMoney,
|
||||||
isGrantedAmount,
|
isGrantedAmount,
|
||||||
} from "@/app/(protected)/uplifts/_components/format-uplift-money";
|
} from "@/app/(protected)/uplifts/_components/format-uplift-money";
|
||||||
|
import {
|
||||||
|
UPLIFT_APPROVAL_HEADER_CELLS,
|
||||||
|
type UpliftApprovalTab,
|
||||||
|
} from "@/app/(protected)/uplifts/_components/uplift-approval-tabs";
|
||||||
|
import {
|
||||||
|
RejectedEmptyState,
|
||||||
|
RejectedRowCells,
|
||||||
|
} from "@/app/(protected)/uplifts/_components/uplift-rejected-cells";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
|
import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
|
||||||
import { upliftAmount } from "@/domain/uplifts/utils/uplift-amount";
|
import { upliftAmount } from "@/domain/uplifts/utils/uplift-amount";
|
||||||
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
||||||
import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
|
import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
|
||||||
|
|
||||||
export type UpliftApprovalTab = "pending" | "approved";
|
|
||||||
|
|
||||||
const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked.";
|
const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked.";
|
||||||
const ADMIN_ONLY_REVOKE_TOOLTIP = "Only admins can revoke uplifts";
|
const ADMIN_ONLY_REVOKE_TOOLTIP = "Only admins can revoke uplifts";
|
||||||
|
|
||||||
|
|
@ -236,65 +242,84 @@ function UpliftApprovalRow({
|
||||||
<AttachmentsCell row={row} onOpenAttachment={onOpenAttachment} />
|
<AttachmentsCell row={row} onOpenAttachment={onOpenAttachment} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{row.requestedByVendorName || "—"}</TableCell>
|
<TableCell>{row.requestedByVendorName || "—"}</TableCell>
|
||||||
{tab === "pending" ? (
|
{tab === "pending" && (
|
||||||
<TableCell>
|
<>
|
||||||
<WaitingCell row={row} />
|
<TableCell>
|
||||||
</TableCell>
|
<WaitingCell row={row} />
|
||||||
) : (
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<PendingRowActions
|
||||||
|
row={row}
|
||||||
|
onApprove={onApprove}
|
||||||
|
onReject={onReject}
|
||||||
|
isDecisionPending={isDecisionPending}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{tab === "approved" && (
|
||||||
<>
|
<>
|
||||||
<TableCell>{row.decidedByName || "—"}</TableCell>
|
<TableCell>{row.decidedByName || "—"}</TableCell>
|
||||||
<TableCell>{row.decidedAt ? formatDateTime(row.decidedAt) : "—"}</TableCell>
|
<TableCell>{row.decidedAt ? formatDateTime(row.decidedAt) : "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<ApprovedRowActions
|
||||||
|
row={row}
|
||||||
|
canRevoke={canRevoke}
|
||||||
|
onRevoke={onRevoke}
|
||||||
|
isDecisionPending={isDecisionPending}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<TableCell>
|
{tab === "rejected" && <RejectedRowCells row={row} />}
|
||||||
{tab === "pending" ? (
|
</TableRow>
|
||||||
<PendingRowActions
|
);
|
||||||
row={row}
|
}
|
||||||
onApprove={onApprove}
|
|
||||||
onReject={onReject}
|
function UpliftApprovalsEmptyState({ tab }: { tab: UpliftApprovalTab }) {
|
||||||
isDecisionPending={isDecisionPending}
|
if (tab === "rejected") return <RejectedEmptyState />;
|
||||||
/>
|
if (tab === "approved") {
|
||||||
) : (
|
return (
|
||||||
<ApprovedRowActions
|
<Box>
|
||||||
row={row}
|
<Text variant="description">No approved uplifts yet</Text>
|
||||||
canRevoke={canRevoke}
|
<Text variant="caption" tone="muted">
|
||||||
onRevoke={onRevoke}
|
Uplifts you approve are archived here for reference.
|
||||||
isDecisionPending={isDecisionPending}
|
</Text>
|
||||||
/>
|
</Box>
|
||||||
)}
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Text variant="description" tone="muted">
|
||||||
|
No uplifts waiting for approval.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failed queue is not an empty one: the page's error alert explains it, so the
|
||||||
|
// empty-state copy must not also claim the list has nothing in it.
|
||||||
|
function UpliftApprovalsEmptyRow({
|
||||||
|
tab,
|
||||||
|
colSpan,
|
||||||
|
hasError,
|
||||||
|
}: {
|
||||||
|
tab: UpliftApprovalTab;
|
||||||
|
colSpan: number;
|
||||||
|
hasError: boolean;
|
||||||
|
}) {
|
||||||
|
if (hasError) return null;
|
||||||
|
return (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={colSpan} align="center" className="py-10">
|
||||||
|
<UpliftApprovalsEmptyState tab={tab} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const PENDING_HEADER_CELLS = [
|
|
||||||
"Work Order",
|
|
||||||
"Service / Vendor",
|
|
||||||
"Amount",
|
|
||||||
"Approved on WO",
|
|
||||||
"Notes",
|
|
||||||
"Attachments",
|
|
||||||
"Requested By",
|
|
||||||
"Waiting",
|
|
||||||
"Actions",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
const APPROVED_HEADER_CELLS = [
|
|
||||||
"Work Order",
|
|
||||||
"Service / Vendor",
|
|
||||||
"Amount",
|
|
||||||
"Approved on WO",
|
|
||||||
"Notes",
|
|
||||||
"Attachments",
|
|
||||||
"Requested By",
|
|
||||||
"Approved By",
|
|
||||||
"Approved At",
|
|
||||||
"Actions",
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export function UpliftApprovalsTable({
|
export function UpliftApprovalsTable({
|
||||||
tab,
|
tab,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
hasError,
|
||||||
rows,
|
rows,
|
||||||
canRevoke,
|
canRevoke,
|
||||||
onOpenDetail,
|
onOpenDetail,
|
||||||
|
|
@ -306,6 +331,7 @@ export function UpliftApprovalsTable({
|
||||||
}: {
|
}: {
|
||||||
tab: UpliftApprovalTab;
|
tab: UpliftApprovalTab;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
hasError: boolean;
|
||||||
rows: UpliftQueueItem[];
|
rows: UpliftQueueItem[];
|
||||||
canRevoke: boolean;
|
canRevoke: boolean;
|
||||||
onOpenDetail: (row: UpliftQueueItem) => void;
|
onOpenDetail: (row: UpliftQueueItem) => void;
|
||||||
|
|
@ -315,7 +341,7 @@ export function UpliftApprovalsTable({
|
||||||
onOpenAttachment: (row: UpliftQueueItem) => void;
|
onOpenAttachment: (row: UpliftQueueItem) => void;
|
||||||
isDecisionPending: boolean;
|
isDecisionPending: boolean;
|
||||||
}) {
|
}) {
|
||||||
const headerCells = tab === "pending" ? PENDING_HEADER_CELLS : APPROVED_HEADER_CELLS;
|
const headerCells = UPLIFT_APPROVAL_HEADER_CELLS[tab];
|
||||||
return (
|
return (
|
||||||
<TableContainer component={Paper} variant="outlined">
|
<TableContainer component={Paper} variant="outlined">
|
||||||
<Table size="small" aria-label={`${tab} uplift requests`}>
|
<Table size="small" aria-label={`${tab} uplift requests`}>
|
||||||
|
|
@ -336,22 +362,7 @@ export function UpliftApprovalsTable({
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<TableRow>
|
<UpliftApprovalsEmptyRow tab={tab} colSpan={headerCells.length} hasError={hasError} />
|
||||||
<TableCell colSpan={headerCells.length} align="center" className="py-10">
|
|
||||||
{tab === "pending" ? (
|
|
||||||
<Text variant="description" tone="muted">
|
|
||||||
No uplifts waiting for approval.
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Box>
|
|
||||||
<Text variant="description">No approved uplifts yet</Text>
|
|
||||||
<Text variant="caption" tone="muted">
|
|
||||||
Uplifts you approve are archived here for reference.
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
) : (
|
||||||
rows.map((row) => (
|
rows.map((row) => (
|
||||||
<UpliftApprovalRow
|
<UpliftApprovalRow
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import HighlightOffOutlinedIcon from "@mui/icons-material/HighlightOffOutlined";
|
||||||
|
import { Box, TableCell } from "@mui/material";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
||||||
|
import { formatDateTime } from "@/lib/time-utils";
|
||||||
|
|
||||||
|
export function RejectedRowCells({ row }: { row: UpliftQueueItem }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TableCell>{row.decidedByName || "—"}</TableCell>
|
||||||
|
<TableCell>{row.decidedAt ? formatDateTime(row.decidedAt) : "—"}</TableCell>
|
||||||
|
<TableCell sx={{ maxWidth: 260 }}>
|
||||||
|
<Text variant="caption" sx={{ whiteSpace: "pre-wrap" }}>
|
||||||
|
{row.decisionNote || "—"}
|
||||||
|
</Text>
|
||||||
|
</TableCell>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RejectedEmptyState() {
|
||||||
|
return (
|
||||||
|
<Box className="flex flex-col items-center gap-2">
|
||||||
|
<HighlightOffOutlinedIcon color="disabled" aria-hidden />
|
||||||
|
<Text variant="description">No rejected uplifts.</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert, Box, Chip, Stack, Tab, Tabs, TablePagination } from "@mui/material";
|
import { Alert, Box, Chip, Stack, Tab, Tabs, TablePagination } from "@mui/material";
|
||||||
import {
|
import {
|
||||||
UpliftApprovalsTable,
|
UPLIFT_APPROVAL_TABS,
|
||||||
|
isUpliftApprovalTab,
|
||||||
type UpliftApprovalTab,
|
type UpliftApprovalTab,
|
||||||
} from "@/app/(protected)/uplifts/_components/uplift-approvals-table";
|
} from "@/app/(protected)/uplifts/_components/uplift-approval-tabs";
|
||||||
|
import { UpliftApprovalsTable } from "@/app/(protected)/uplifts/_components/uplift-approvals-table";
|
||||||
import { UpliftDecisionDialogs } from "@/app/(protected)/uplifts/_components/uplift-decision-dialogs";
|
import { UpliftDecisionDialogs } from "@/app/(protected)/uplifts/_components/uplift-decision-dialogs";
|
||||||
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
|
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
|
||||||
import {
|
import {
|
||||||
|
|
@ -16,6 +18,7 @@ import { useAuthContext } from "@/providers/auth-context";
|
||||||
import { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller";
|
import { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller";
|
||||||
import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
|
import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { UpliftsQueueResult } from "@/domain/uplifts/types/uplift";
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
|
@ -39,58 +42,71 @@ function UpliftApprovalsHeader({ pendingExposureTotal }: { pendingExposureTotal:
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TAB_LABELS: Record<UpliftApprovalTab, string> = {
|
||||||
|
pending: "Pending",
|
||||||
|
approved: "Approved",
|
||||||
|
rejected: "Rejected",
|
||||||
|
};
|
||||||
|
|
||||||
|
// A tab's total is only known once its queue has loaded at least once; until then the
|
||||||
|
// pill and pager must not claim a count (a loading or failed queue is not an empty one).
|
||||||
|
// A failed refetch keeps the last loaded page, so its total stays usable.
|
||||||
|
function knownTotal(query: { data?: UpliftsQueueResult | undefined }): number | null {
|
||||||
|
return query.data ? query.data.totalCount : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabCountPill({ tab, count }: { tab: UpliftApprovalTab; count: number | null }) {
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
size="small"
|
||||||
|
label={count ?? "—"}
|
||||||
|
aria-label={count == null ? `${tab} uplift count unavailable` : `${count} ${tab} uplifts`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function UpliftApprovalsTabs({
|
function UpliftApprovalsTabs({
|
||||||
tab,
|
tab,
|
||||||
pendingCount,
|
counts,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
tab: UpliftApprovalTab;
|
tab: UpliftApprovalTab;
|
||||||
pendingCount: number;
|
counts: Record<UpliftApprovalTab, number | null>;
|
||||||
onChange: (_event: unknown, value: string) => void;
|
onChange: (_event: unknown, value: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Tabs value={tab} onChange={onChange}>
|
<Tabs value={tab} onChange={onChange}>
|
||||||
<Tab
|
{UPLIFT_APPROVAL_TABS.map((value) => (
|
||||||
value="pending"
|
<Tab
|
||||||
label={
|
key={value}
|
||||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
value={value}
|
||||||
<span>Pending</span>
|
label={
|
||||||
<Chip
|
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||||
size="small"
|
<span>{TAB_LABELS[value]}</span>
|
||||||
label={pendingCount}
|
<TabCountPill tab={value} count={counts[value]} />
|
||||||
aria-label={`${pendingCount} pending uplifts`}
|
</Stack>
|
||||||
/>
|
}
|
||||||
</Stack>
|
/>
|
||||||
}
|
))}
|
||||||
/>
|
|
||||||
<Tab value="approved" label="Approved" />
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function UpliftApprovalsPagination({
|
function UpliftApprovalsPagination({
|
||||||
tab,
|
|
||||||
count,
|
count,
|
||||||
pendingPage,
|
page,
|
||||||
approvedPage,
|
onPageChange,
|
||||||
onPendingPageChange,
|
|
||||||
onApprovedPageChange,
|
|
||||||
}: {
|
}: {
|
||||||
tab: UpliftApprovalTab;
|
|
||||||
count: number;
|
count: number;
|
||||||
pendingPage: number;
|
page: number;
|
||||||
approvedPage: number;
|
onPageChange: (page: number) => void;
|
||||||
onPendingPageChange: (page: number) => void;
|
|
||||||
onApprovedPageChange: (page: number) => void;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<TablePagination
|
<TablePagination
|
||||||
component="div"
|
component="div"
|
||||||
count={count}
|
count={count}
|
||||||
page={tab === "pending" ? pendingPage : approvedPage}
|
page={page}
|
||||||
onPageChange={(_event, nextPage) =>
|
onPageChange={(_event, nextPage) => onPageChange(nextPage)}
|
||||||
tab === "pending" ? onPendingPageChange(nextPage) : onApprovedPageChange(nextPage)
|
|
||||||
}
|
|
||||||
rowsPerPage={PAGE_SIZE}
|
rowsPerPage={PAGE_SIZE}
|
||||||
rowsPerPageOptions={[PAGE_SIZE]}
|
rowsPerPageOptions={[PAGE_SIZE]}
|
||||||
/>
|
/>
|
||||||
|
|
@ -108,22 +124,31 @@ function UpliftQueueError({ error }: { error: unknown }) {
|
||||||
|
|
||||||
export default function UpliftQueuePage() {
|
export default function UpliftQueuePage() {
|
||||||
const [tab, setTab] = useState<UpliftApprovalTab>("pending");
|
const [tab, setTab] = useState<UpliftApprovalTab>("pending");
|
||||||
const [pendingPage, setPendingPage] = useState(0);
|
const [pages, setPages] = useState<Record<UpliftApprovalTab, number>>({
|
||||||
const [approvedPage, setApprovedPage] = useState(0);
|
pending: 0,
|
||||||
|
approved: 0,
|
||||||
|
rejected: 0,
|
||||||
|
});
|
||||||
|
|
||||||
const { user } = useAuthContext();
|
const { user } = useAuthContext();
|
||||||
const canRevokeUplifts = isAdminUser(user?.userRoles);
|
const canRevokeUplifts = isAdminUser(user?.userRoles);
|
||||||
|
|
||||||
const pendingQuery = useUpliftsQueue({
|
const pendingQuery = useUpliftsQueue({
|
||||||
status: "Pending",
|
status: "Pending",
|
||||||
page: pendingPage + 1,
|
page: pages.pending + 1,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
});
|
});
|
||||||
const approvedQuery = useUpliftsQueue({
|
const approvedQuery = useUpliftsQueue({
|
||||||
status: "Approved",
|
status: "Approved",
|
||||||
page: approvedPage + 1,
|
page: pages.approved + 1,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
});
|
});
|
||||||
|
const rejectedQuery = useUpliftsQueue({
|
||||||
|
status: "Rejected",
|
||||||
|
page: pages.rejected + 1,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
});
|
||||||
|
const queries = { pending: pendingQuery, approved: approvedQuery, rejected: rejectedQuery };
|
||||||
|
|
||||||
const controller = useUpliftApprovalController();
|
const controller = useUpliftApprovalController();
|
||||||
const {
|
const {
|
||||||
|
|
@ -138,13 +163,18 @@ export default function UpliftQueuePage() {
|
||||||
openDetail,
|
openDetail,
|
||||||
} = controller;
|
} = controller;
|
||||||
|
|
||||||
const activeQuery = tab === "pending" ? pendingQuery : approvedQuery;
|
const activeQuery = queries[tab];
|
||||||
const rows = activeQuery.data?.items ?? [];
|
const rows = activeQuery.data?.items ?? [];
|
||||||
const pendingCount = pendingQuery.data?.totalCount ?? 0;
|
const counts = {
|
||||||
|
pending: knownTotal(pendingQuery),
|
||||||
|
approved: knownTotal(approvedQuery),
|
||||||
|
rejected: knownTotal(rejectedQuery),
|
||||||
|
};
|
||||||
|
const activeTotal = counts[tab];
|
||||||
const pendingExposureTotal = pendingQuery.data?.pendingExposureTotal ?? null;
|
const pendingExposureTotal = pendingQuery.data?.pendingExposureTotal ?? null;
|
||||||
|
|
||||||
const handleTabChange = (_event: unknown, value: string) => {
|
const handleTabChange = (_event: unknown, value: string) => {
|
||||||
if (value === "pending" || value === "approved") {
|
if (isUpliftApprovalTab(value)) {
|
||||||
setTab(value);
|
setTab(value);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -152,11 +182,12 @@ export default function UpliftQueuePage() {
|
||||||
return (
|
return (
|
||||||
<Box className="flex flex-col gap-4 p-4">
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
<UpliftApprovalsHeader pendingExposureTotal={pendingExposureTotal} />
|
<UpliftApprovalsHeader pendingExposureTotal={pendingExposureTotal} />
|
||||||
<UpliftApprovalsTabs tab={tab} pendingCount={pendingCount} onChange={handleTabChange} />
|
<UpliftApprovalsTabs tab={tab} counts={counts} onChange={handleTabChange} />
|
||||||
<UpliftQueueError error={activeQuery.error} />
|
<UpliftQueueError error={activeQuery.error} />
|
||||||
<UpliftApprovalsTable
|
<UpliftApprovalsTable
|
||||||
tab={tab}
|
tab={tab}
|
||||||
isLoading={activeQuery.isLoading}
|
isLoading={activeQuery.isLoading}
|
||||||
|
hasError={Boolean(activeQuery.error)}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
canRevoke={canRevokeUplifts}
|
canRevoke={canRevokeUplifts}
|
||||||
onOpenDetail={openDetail}
|
onOpenDetail={openDetail}
|
||||||
|
|
@ -166,14 +197,13 @@ export default function UpliftQueuePage() {
|
||||||
onOpenAttachment={openUpliftEvidence}
|
onOpenAttachment={openUpliftEvidence}
|
||||||
isDecisionPending={approvePending || rejectPending || revokePending}
|
isDecisionPending={approvePending || rejectPending || revokePending}
|
||||||
/>
|
/>
|
||||||
<UpliftApprovalsPagination
|
{activeTotal != null && (
|
||||||
tab={tab}
|
<UpliftApprovalsPagination
|
||||||
count={activeQuery.data?.totalCount ?? 0}
|
count={activeTotal}
|
||||||
pendingPage={pendingPage}
|
page={pages[tab]}
|
||||||
approvedPage={approvedPage}
|
onPageChange={(page) => setPages((current) => ({ ...current, [tab]: page }))}
|
||||||
onPendingPageChange={setPendingPage}
|
/>
|
||||||
onApprovedPageChange={setApprovedPage}
|
)}
|
||||||
/>
|
|
||||||
{detailItem != null && (
|
{detailItem != null && (
|
||||||
<UpliftDetailModal
|
<UpliftDetailModal
|
||||||
item={detailItem}
|
item={detailItem}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
type CompDocDocumentFrameProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
/** Greyed-out page, as for a document marked not required. */
|
||||||
|
dimmed?: boolean;
|
||||||
|
/** Rendered over the page, outside the dimmed layer. */
|
||||||
|
overlay?: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The printed page: white sheet between the top and bottom brand bands. */
|
||||||
|
export function CompDocDocumentFrame({
|
||||||
|
children,
|
||||||
|
dimmed = false,
|
||||||
|
overlay,
|
||||||
|
}: CompDocDocumentFrameProps) {
|
||||||
|
return (
|
||||||
|
<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: dimmed ? 0.5 : 1,
|
||||||
|
filter: dimmed ? "grayscale(0.4)" : "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 12,
|
||||||
|
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ padding: "0 28px 28px" }}>{children}</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 24,
|
||||||
|
background: "linear-gradient(100deg,#1C75BC,#27AAE1)",
|
||||||
|
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{overlay}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
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 { procedureFor, SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
|
import { procedureFor } from "@/domain/work-orders/utils/completion-doc-procedures";
|
||||||
|
import { CompDocProcedureHeading } from "./comp-doc-procedure-heading";
|
||||||
import { CompDocProcedureList } from "./comp-doc-procedure-list";
|
import { CompDocProcedureList } from "./comp-doc-procedure-list";
|
||||||
|
import { CompDocSafetyBullets } from "./comp-doc-safety-bullets";
|
||||||
|
|
||||||
type CompDocPreviewProcedureProps = {
|
type CompDocPreviewProcedureProps = {
|
||||||
row: WorkOrderTableRow;
|
row: WorkOrderTableRow;
|
||||||
|
|
@ -13,34 +15,9 @@ export function CompDocPreviewProcedure({ row, notes }: CompDocPreviewProcedureP
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ul style={{ listStyle: "disc", paddingLeft: 18, margin: "8px 0 0" }}>
|
<CompDocSafetyBullets />
|
||||||
{SAFETY_BULLETS.map((b, i) => (
|
|
||||||
<li
|
|
||||||
key={i}
|
|
||||||
style={{
|
|
||||||
fontSize: 12.5,
|
|
||||||
lineHeight: 1.5,
|
|
||||||
margin: "5px 0",
|
|
||||||
color: "#161E38",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{b}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<Text
|
<CompDocProcedureHeading>{proc.heading}</CompDocProcedureHeading>
|
||||||
as="h3"
|
|
||||||
variant="heading"
|
|
||||||
sx={{
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "#161E38",
|
|
||||||
margin: "16px 0 4px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{proc.heading}
|
|
||||||
</Text>
|
|
||||||
<Text
|
<Text
|
||||||
variant="body"
|
variant="body"
|
||||||
sx={{
|
sx={{
|
||||||
|
|
@ -56,18 +33,7 @@ export function CompDocPreviewProcedure({ row, notes }: CompDocPreviewProcedureP
|
||||||
|
|
||||||
{notes.length > 0 && (
|
{notes.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Text
|
<CompDocProcedureHeading>PM Comments</CompDocProcedureHeading>
|
||||||
as="h3"
|
|
||||||
variant="heading"
|
|
||||||
sx={{
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 700,
|
|
||||||
color: "#161E38",
|
|
||||||
margin: "16px 0 4px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
PM Comments
|
|
||||||
</Text>
|
|
||||||
<Text
|
<Text
|
||||||
variant="body"
|
variant="body"
|
||||||
sx={{
|
sx={{
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
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 { CompDocDocumentFrame } from "./comp-doc-document-frame";
|
||||||
import { CompDocNotRequiredStamp } from "./comp-doc-not-required-stamp";
|
import { CompDocNotRequiredStamp } from "./comp-doc-not-required-stamp";
|
||||||
import { CompDocPreviewBadge } from "./comp-doc-preview-badge";
|
import { CompDocPreviewBadge } from "./comp-doc-preview-badge";
|
||||||
import { CompDocPreviewHeader } from "./comp-doc-preview-header";
|
import { CompDocPreviewHeader } from "./comp-doc-preview-header";
|
||||||
|
|
@ -38,51 +39,19 @@ export function CompDocPreview({
|
||||||
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
|
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
|
||||||
style={{ padding: "0 20px 20px" }}
|
style={{ padding: "0 20px 20px" }}
|
||||||
>
|
>
|
||||||
<div
|
<CompDocDocumentFrame
|
||||||
className="relative break-words"
|
dimmed={isNotRequired}
|
||||||
style={{
|
overlay={isNotRequired && <CompDocNotRequiredStamp />}
|
||||||
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
|
<CompDocPreviewHeader />
|
||||||
style={{
|
<CompDocPreviewSummary
|
||||||
opacity: isNotRequired ? 0.5 : 1,
|
row={row}
|
||||||
filter: isNotRequired ? "grayscale(0.4)" : "none",
|
site={site}
|
||||||
}}
|
generatedAt={generatedAt}
|
||||||
>
|
isGenerated={isGenerated}
|
||||||
<div
|
/>
|
||||||
style={{
|
<CompDocPreviewProcedure row={row} notes={notes} />
|
||||||
height: 12,
|
</CompDocDocumentFrame>
|
||||||
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div style={{ padding: "0 28px 28px" }}>
|
|
||||||
<CompDocPreviewHeader />
|
|
||||||
<CompDocPreviewSummary
|
|
||||||
row={row}
|
|
||||||
site={site}
|
|
||||||
generatedAt={generatedAt}
|
|
||||||
isGenerated={isGenerated}
|
|
||||||
/>
|
|
||||||
<CompDocPreviewProcedure row={row} notes={notes} />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: 24,
|
|
||||||
background: "linear-gradient(100deg,#1C75BC,#27AAE1)",
|
|
||||||
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isNotRequired && <CompDocNotRequiredStamp />}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
|
||||||
|
export function CompDocProcedureHeading({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
as="h3"
|
||||||
|
variant="heading"
|
||||||
|
sx={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#161E38",
|
||||||
|
margin: "16px 0 4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
|
||||||
|
|
||||||
|
type CompDocSafetyBulletsProps = {
|
||||||
|
/** Template-specific notes printed after the standard bullets. */
|
||||||
|
extra?: readonly string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CompDocSafetyBullets({ extra = [] }: CompDocSafetyBulletsProps) {
|
||||||
|
return (
|
||||||
|
<ul style={{ listStyle: "disc", paddingLeft: 18, margin: "8px 0 0" }}>
|
||||||
|
{[...SAFETY_BULLETS, ...extra].map((b, i) => (
|
||||||
|
<li
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
fontSize: 12.5,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
margin: "5px 0",
|
||||||
|
color: "#161E38",
|
||||||
|
whiteSpace: i < SAFETY_BULLETS.length ? undefined : "pre-wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{b}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -63,7 +63,10 @@ export const navGroups: NavGroup[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "DATA MANAGEMENT",
|
title: "DATA MANAGEMENT",
|
||||||
items: [{ label: "Services", icon: Wrench, path: "/settings/services" }],
|
items: [
|
||||||
|
{ label: "Services", icon: Wrench, path: "/settings/services" },
|
||||||
|
{ label: "Completion Docs", icon: FileText, path: "/completion-docs" },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "TEAM",
|
title: "TEAM",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import type { z } from "zod";
|
||||||
|
import { apiDeleteNoContent, apiGet, apiPost, apiPut } from "@/api/api";
|
||||||
|
import { API_PATHS } from "@/api/api-paths";
|
||||||
|
import { handleApiResponse } from "@/api/handle-api-response";
|
||||||
|
import { mapCompletionTemplate } from "@/domain/completion-templates/mappers/completion-template-mapper";
|
||||||
|
import {
|
||||||
|
completionTemplateDtoSchema,
|
||||||
|
completionTemplateListDtoSchema,
|
||||||
|
linkedWorkOrdersDtoSchema,
|
||||||
|
} from "@/domain/completion-templates/schemas/completion-template-schema";
|
||||||
|
import type {
|
||||||
|
CompletionTemplate,
|
||||||
|
CompletionTemplateInput,
|
||||||
|
CompletionTemplateLegacyFields,
|
||||||
|
LinkedWorkOrders,
|
||||||
|
} from "@/domain/completion-templates/types/completion-template";
|
||||||
|
|
||||||
|
export const UNEXPECTED_TEMPLATE_RESPONSE = "The server sent an unexpected response.";
|
||||||
|
|
||||||
|
function parse<T>(schema: z.ZodType<T>, data: unknown): T {
|
||||||
|
const result = schema.safeParse(handleApiResponse<unknown>(data));
|
||||||
|
if (!result.success) throw new Error(UNEXPECTED_TEMPLATE_RESPONSE);
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBody(input: CompletionTemplateInput, legacy?: CompletionTemplateLegacyFields) {
|
||||||
|
return { ...legacy, ...input };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const completionTemplatesApi = {
|
||||||
|
list: async (search: string): Promise<CompletionTemplate[]> => {
|
||||||
|
const term = search.trim();
|
||||||
|
const data = await apiGet<unknown>(API_PATHS.workOrder.completionTemplates, {
|
||||||
|
searchParams: term === "" ? undefined : { search: term },
|
||||||
|
});
|
||||||
|
return parse(completionTemplateListDtoSchema, data).map(mapCompletionTemplate);
|
||||||
|
},
|
||||||
|
|
||||||
|
create: async (input: CompletionTemplateInput): Promise<CompletionTemplate> => {
|
||||||
|
const data = await apiPost<unknown>(API_PATHS.workOrder.completionTemplates, toBody(input));
|
||||||
|
return mapCompletionTemplate(parse(completionTemplateDtoSchema, data));
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Replaces the whole procedure list and keeps the legacy fields the template already had. */
|
||||||
|
update: async (
|
||||||
|
template: CompletionTemplate,
|
||||||
|
input: CompletionTemplateInput,
|
||||||
|
): Promise<CompletionTemplate> => {
|
||||||
|
const data = await apiPut<unknown>(
|
||||||
|
API_PATHS.workOrder.completionTemplateById(template.id),
|
||||||
|
toBody(input, template.legacy),
|
||||||
|
);
|
||||||
|
return mapCompletionTemplate(parse(completionTemplateDtoSchema, data));
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: number): Promise<void> => {
|
||||||
|
await apiDeleteNoContent(API_PATHS.workOrder.completionTemplateById(id));
|
||||||
|
},
|
||||||
|
|
||||||
|
linkedWorkOrders: async (id: number): Promise<LinkedWorkOrders> => {
|
||||||
|
const data = await apiGet<unknown>(API_PATHS.workOrder.completionTemplateLinkedWorkOrders(id));
|
||||||
|
return parse(linkedWorkOrdersDtoSchema, data);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
/** Filled in on every generated document; shown read-only so authors don't retype them. */
|
||||||
|
export const AUTO_POPULATED_FIELDS = ["WO#", "Site Code", "Address", "Service Type"] as const;
|
||||||
|
|
||||||
|
/** Server limits; the inputs stop there instead of failing the save. */
|
||||||
|
export const PROCEDURE_NAME_MAX_LENGTH = 200;
|
||||||
|
export const EXTRA_SAFETY_NOTE_MAX_LENGTH = 2000;
|
||||||
|
|
||||||
|
export const COMPLETION_TEMPLATE_COPY = {
|
||||||
|
pageTitle: "Completion Docs",
|
||||||
|
searchPlaceholder: "Search document title…",
|
||||||
|
addTemplate: "Add Template",
|
||||||
|
saveChanges: "Save changes",
|
||||||
|
titleRequired: "Document Title is required",
|
||||||
|
noProcedureDetail: "No procedure defined yet",
|
||||||
|
noProcedurePreview: "No procedure added yet.",
|
||||||
|
untitledDocument: "UNTITLED COMPLETION DOCUMENT",
|
||||||
|
autoFilledSuffix: "— auto-filled per work order",
|
||||||
|
emptySearchTitle: "No templates found",
|
||||||
|
emptySearchHint: "Try a different search, or add a new template.",
|
||||||
|
added: "Template added",
|
||||||
|
updated: "Template updated",
|
||||||
|
deleted: "Template deleted",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function templateCountLabel(count: number): string {
|
||||||
|
return `${count} template${count === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
import type {
|
||||||
|
CompletionTemplateDto,
|
||||||
|
CompletionTemplateFormValues,
|
||||||
|
} from "@/domain/completion-templates/schemas/completion-template-schema";
|
||||||
|
import type {
|
||||||
|
CompletionTemplate,
|
||||||
|
CompletionTemplateInput,
|
||||||
|
} from "@/domain/completion-templates/types/completion-template";
|
||||||
|
|
||||||
|
export function mapCompletionTemplate(dto: CompletionTemplateDto): CompletionTemplate {
|
||||||
|
const createdAt = dto.createdAt ?? "";
|
||||||
|
const procedures = [...(dto.procedures ?? [])]
|
||||||
|
.map((procedure, index) => ({ procedure, order: procedure.sortOrder ?? index }))
|
||||||
|
.sort((a, b) => a.order - b.order)
|
||||||
|
.map(({ procedure }) => ({
|
||||||
|
name: procedure.name ?? "",
|
||||||
|
description: procedure.description ?? "",
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: dto.id,
|
||||||
|
name: dto.name ?? "",
|
||||||
|
extraSafetyNote: dto.extraSafetyNote?.trim() ? dto.extraSafetyNote : null,
|
||||||
|
procedures,
|
||||||
|
createdAt,
|
||||||
|
lastUpdatedAt: dto.updatedAt ?? createdAt,
|
||||||
|
createdByName: dto.createdByName?.trim() ? dto.createdByName : null,
|
||||||
|
legacy: {
|
||||||
|
serviceKey: dto.serviceKey ?? "",
|
||||||
|
workOrderType: dto.workOrderType ?? null,
|
||||||
|
templateUrl: dto.templateUrl ?? "",
|
||||||
|
isActive: dto.isActive ?? true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toCompletionTemplateFormValues(
|
||||||
|
template: CompletionTemplate | null,
|
||||||
|
): CompletionTemplateFormValues {
|
||||||
|
if (!template) return { name: "", extraSafetyNote: null, procedures: [] };
|
||||||
|
return {
|
||||||
|
name: template.name,
|
||||||
|
extraSafetyNote: template.extraSafetyNote,
|
||||||
|
procedures: template.procedures.map((procedure) => ({ ...procedure })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Trims every field and drops procedures the author left entirely blank. */
|
||||||
|
export function toCompletionTemplateInput(
|
||||||
|
values: CompletionTemplateFormValues,
|
||||||
|
): CompletionTemplateInput {
|
||||||
|
const note = values.extraSafetyNote?.trim() ?? "";
|
||||||
|
return {
|
||||||
|
name: values.name.trim(),
|
||||||
|
extraSafetyNote: note === "" ? null : note,
|
||||||
|
procedures: values.procedures
|
||||||
|
.map((procedure) => ({
|
||||||
|
name: procedure.name.trim(),
|
||||||
|
description: procedure.description.trim(),
|
||||||
|
}))
|
||||||
|
.filter((procedure) => procedure.name !== "" || procedure.description !== ""),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
import { COMPLETION_TEMPLATE_COPY } from "@/domain/completion-templates/constants/completion-template-constants";
|
||||||
|
|
||||||
|
const procedureDtoSchema = z.object({
|
||||||
|
name: z.string().nullish(),
|
||||||
|
description: z.string().nullish(),
|
||||||
|
sortOrder: z.number().nullish(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const completionTemplateDtoSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
name: z.string().nullish(),
|
||||||
|
extraSafetyNote: z.string().nullish(),
|
||||||
|
procedures: z.array(procedureDtoSchema).nullish(),
|
||||||
|
createdAt: z.string().nullish(),
|
||||||
|
updatedAt: z.string().nullish(),
|
||||||
|
createdByName: z.string().nullish(),
|
||||||
|
serviceKey: z.string().nullish(),
|
||||||
|
workOrderType: z.number().nullish(),
|
||||||
|
templateUrl: z.string().nullish(),
|
||||||
|
isActive: z.boolean().nullish(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const completionTemplateListDtoSchema = z.array(completionTemplateDtoSchema);
|
||||||
|
|
||||||
|
export const linkedWorkOrdersDtoSchema = z.object({
|
||||||
|
count: z.number(),
|
||||||
|
workOrderIds: z.array(z.number()),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CompletionTemplateDto = z.infer<typeof completionTemplateDtoSchema>;
|
||||||
|
|
||||||
|
const procedureFormSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const completionTemplateFormSchema = z.object({
|
||||||
|
name: z.string().trim().min(1, COMPLETION_TEMPLATE_COPY.titleRequired),
|
||||||
|
/** `null` until the author adds the optional note. */
|
||||||
|
extraSafetyNote: z.string().nullable(),
|
||||||
|
procedures: z.array(procedureFormSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type CompletionTemplateFormValues = z.infer<typeof completionTemplateFormSchema>;
|
||||||
38
src/domain/completion-templates/types/completion-template.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
export interface CompletionTemplateProcedure {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fields the Services registry still reads; an edit sends them back unchanged. */
|
||||||
|
export interface CompletionTemplateLegacyFields {
|
||||||
|
serviceKey: string;
|
||||||
|
workOrderType: number | null;
|
||||||
|
templateUrl: string;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompletionTemplate {
|
||||||
|
id: number;
|
||||||
|
/** The Document Title. */
|
||||||
|
name: string;
|
||||||
|
extraSafetyNote: string | null;
|
||||||
|
/** In print order. */
|
||||||
|
procedures: CompletionTemplateProcedure[];
|
||||||
|
createdAt: string;
|
||||||
|
/** `updatedAt ?? createdAt`: what the table shows as Last Updated. */
|
||||||
|
lastUpdatedAt: string;
|
||||||
|
createdByName: string | null;
|
||||||
|
legacy: CompletionTemplateLegacyFields;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CompletionTemplateInput {
|
||||||
|
name: string;
|
||||||
|
extraSafetyNote: string | null;
|
||||||
|
procedures: CompletionTemplateProcedure[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open work orders whose completion document would be generated from a template. */
|
||||||
|
export interface LinkedWorkOrders {
|
||||||
|
count: number;
|
||||||
|
workOrderIds: number[];
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
import {
|
||||||
|
useMutation,
|
||||||
|
useQueryClient,
|
||||||
|
type QueryClient,
|
||||||
|
type UseMutationResult,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import { isHTTPError } from "ky";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { completionTemplatesApi } from "@/domain/completion-templates/api/completion-templates-api";
|
||||||
|
import { COMPLETION_TEMPLATE_COPY } from "@/domain/completion-templates/constants/completion-template-constants";
|
||||||
|
import type {
|
||||||
|
CompletionTemplate,
|
||||||
|
CompletionTemplateInput,
|
||||||
|
} from "@/domain/completion-templates/types/completion-template";
|
||||||
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
|
interface UpdateCompletionTemplateVariables {
|
||||||
|
template: CompletionTemplate;
|
||||||
|
input: CompletionTemplateInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The server's blank-title rejection, which the form shows on the title field instead. */
|
||||||
|
export function isTitleRequiredError(error: Error): boolean {
|
||||||
|
if (!isHTTPError(error) || error.response.status !== 422) return false;
|
||||||
|
const body: unknown = error.data;
|
||||||
|
return (
|
||||||
|
typeof body === "object" && body !== null && "code" in body && body.code === "TitleRequired"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateTemplates(queryClient: QueryClient) {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.completionTemplates.all });
|
||||||
|
// The Services registry lists template names and clears links to a deleted template.
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.services.all });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toastFailure(error: Error, fallback: string) {
|
||||||
|
if (isTitleRequiredError(error)) return;
|
||||||
|
toast.error(error.message || fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateCompletionTemplate(): UseMutationResult<
|
||||||
|
CompletionTemplate,
|
||||||
|
Error,
|
||||||
|
CompletionTemplateInput
|
||||||
|
> {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: CompletionTemplateInput) => completionTemplatesApi.create(input),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateTemplates(queryClient);
|
||||||
|
toast.success(COMPLETION_TEMPLATE_COPY.added);
|
||||||
|
},
|
||||||
|
onError: (error: Error) => toastFailure(error, "Failed to add template"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateCompletionTemplate(): UseMutationResult<
|
||||||
|
CompletionTemplate,
|
||||||
|
Error,
|
||||||
|
UpdateCompletionTemplateVariables
|
||||||
|
> {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ template, input }: UpdateCompletionTemplateVariables) =>
|
||||||
|
completionTemplatesApi.update(template, input),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateTemplates(queryClient);
|
||||||
|
toast.success(COMPLETION_TEMPLATE_COPY.updated);
|
||||||
|
},
|
||||||
|
onError: (error: Error) => toastFailure(error, "Failed to update template"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteCompletionTemplate(): UseMutationResult<void, Error, number> {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: number) => completionTemplatesApi.delete(id),
|
||||||
|
onSuccess: () => {
|
||||||
|
invalidateTemplates(queryClient);
|
||||||
|
toast.success(COMPLETION_TEMPLATE_COPY.deleted);
|
||||||
|
},
|
||||||
|
onError: (error: Error) => toastFailure(error, "Failed to delete template"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||||
|
import { completionTemplatesApi } from "@/domain/completion-templates/api/completion-templates-api";
|
||||||
|
import type {
|
||||||
|
CompletionTemplate,
|
||||||
|
LinkedWorkOrders,
|
||||||
|
} from "@/domain/completion-templates/types/completion-template";
|
||||||
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
|
/** `search` is a partial, case-insensitive Document Title match done by the server. */
|
||||||
|
export function useCompletionTemplates(search = ""): UseQueryResult<CompletionTemplate[], Error> {
|
||||||
|
const term = search.trim();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.completionTemplates.list(term),
|
||||||
|
queryFn: () => completionTemplatesApi.list(term),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Always refetched when asked for: the count gates a destructive action. */
|
||||||
|
export function useLinkedWorkOrders(
|
||||||
|
templateId: number | null,
|
||||||
|
): UseQueryResult<LinkedWorkOrders, Error> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.completionTemplates.linkedWorkOrders(templateId ?? 0),
|
||||||
|
queryFn: () => completionTemplatesApi.linkedWorkOrders(templateId ?? 0),
|
||||||
|
enabled: templateId !== null,
|
||||||
|
staleTime: 0,
|
||||||
|
refetchOnMount: "always",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
/** A procedure description prints one paragraph per non-blank line. */
|
||||||
|
export function descriptionLines(description: string): string[] {
|
||||||
|
return description
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
/** "Jun 1, 2026"; an empty or unparseable value shows as "—". */
|
||||||
|
export function formatTemplateDate(value: string): string {
|
||||||
|
const date = new Date(value);
|
||||||
|
if (value === "" || Number.isNaN(date.getTime())) return "—";
|
||||||
|
return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||||
|
}
|
||||||
|
|
@ -7,11 +7,13 @@ import {
|
||||||
mapLocationListItems,
|
mapLocationListItems,
|
||||||
mapLocationSites,
|
mapLocationSites,
|
||||||
mapLocationToBackend,
|
mapLocationToBackend,
|
||||||
|
mapSiteOpenWorkOrders,
|
||||||
} from "@/domain/locations/mappers/location-mapper";
|
} from "@/domain/locations/mappers/location-mapper";
|
||||||
import type {
|
import type {
|
||||||
Location,
|
Location,
|
||||||
LocationSite,
|
LocationSite,
|
||||||
SiteContactInfoInput,
|
SiteContactInfoInput,
|
||||||
|
SiteOpenWorkOrders,
|
||||||
LocationsListParams,
|
LocationsListParams,
|
||||||
LocationsListResult,
|
LocationsListResult,
|
||||||
} from "@/domain/locations/types/location";
|
} from "@/domain/locations/types/location";
|
||||||
|
|
@ -84,4 +86,9 @@ export const locationsApi = {
|
||||||
delete: async (id: string | number): Promise<void> => {
|
delete: async (id: string | number): Promise<void> => {
|
||||||
await apiDelete<unknown>(`${API_PATHS.rest.locations}/${id}`);
|
await apiDelete<unknown>(`${API_PATHS.rest.locations}/${id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getOpenWorkOrders: async (id: string | number): Promise<SiteOpenWorkOrders> => {
|
||||||
|
const data = await apiGet<unknown>(`${API_PATHS.rest.locations}/${id}/open-work-orders`);
|
||||||
|
return mapSiteOpenWorkOrders(handleApiResponse(data));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type {
|
||||||
LocationContact,
|
LocationContact,
|
||||||
LocationListItem,
|
LocationListItem,
|
||||||
LocationSite,
|
LocationSite,
|
||||||
|
SiteOpenWorkOrders,
|
||||||
} from "@/domain/locations/types/location";
|
} from "@/domain/locations/types/location";
|
||||||
|
|
||||||
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
||||||
|
|
@ -255,3 +256,16 @@ export function mapLocationToBackend(location: unknown): Record<string, unknown>
|
||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function mapSiteOpenWorkOrders(raw: unknown): SiteOpenWorkOrders {
|
||||||
|
const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
|
||||||
|
const rawIds = item.workOrderIds ?? item.WorkOrderIds;
|
||||||
|
const workOrderIds = Array.isArray(rawIds)
|
||||||
|
? rawIds.filter((id): id is number => typeof id === "number" && Number.isInteger(id) && id > 0)
|
||||||
|
: [];
|
||||||
|
const count = item.count ?? item.Count;
|
||||||
|
return {
|
||||||
|
count: typeof count === "number" && count >= 0 ? count : workOrderIds.length,
|
||||||
|
workOrderIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -75,3 +75,9 @@ export interface SiteContactInfoInput {
|
||||||
contacts: LocationContact[];
|
contacts: LocationContact[];
|
||||||
notes: string;
|
notes: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** GET locations/{id}/open-work-orders: work orders not Completed or Canceled (ids capped). */
|
||||||
|
export interface SiteOpenWorkOrders {
|
||||||
|
count: number;
|
||||||
|
workOrderIds: number[];
|
||||||
|
}
|
||||||
|
|
|
||||||
17
src/domain/locations/use-cases/use-site-open-work-orders.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||||
|
import { locationsApi } from "@/domain/locations/api/locations-api";
|
||||||
|
import type { SiteOpenWorkOrders } from "@/domain/locations/types/location";
|
||||||
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
|
/** Open (not Completed/Canceled) work orders of a site; fetched only while `enabled`. */
|
||||||
|
export function useSiteOpenWorkOrders(
|
||||||
|
siteId: string,
|
||||||
|
enabled: boolean,
|
||||||
|
): UseQueryResult<SiteOpenWorkOrders, Error> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: queryKeys.locations.openWorkOrders(siteId),
|
||||||
|
queryFn: () => locationsApi.getOpenWorkOrders(siteId),
|
||||||
|
enabled,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -10,5 +10,8 @@ export function useUpliftsQueue(
|
||||||
queryKey: queryKeys.uplifts.queue(params as Record<string, unknown>),
|
queryKey: queryKeys.uplifts.queue(params as Record<string, unknown>),
|
||||||
queryFn: () => upliftsApi.list(params),
|
queryFn: () => upliftsApi.list(params),
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
|
// The Uplift Approvals page reports a failed queue inline, so the global toast
|
||||||
|
// would announce the same failure twice.
|
||||||
|
meta: { suppressErrorToast: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,18 @@ function parseUpliftStatuses(raw: string | null): UpliftFilterStatus[] {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Open = every lifecycle status except Completed (Canceled is not a board status). */
|
||||||
|
const OPEN_STATUSES = ALL_WIZARD_STATUSES.filter((status) => status !== "Completed");
|
||||||
|
|
||||||
|
/** Work Orders filtered to one site's open work orders; `sites` takes the location id. */
|
||||||
|
export function buildSiteOpenWorkOrdersHref(siteId: string | number): string {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
sites: String(siteId),
|
||||||
|
statuses: OPEN_STATUSES.join(","),
|
||||||
|
});
|
||||||
|
return `/workorders?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseDashboardDrilldownParams(
|
export function parseDashboardDrilldownParams(
|
||||||
params: URLSearchParams,
|
params: URLSearchParams,
|
||||||
): WorkOrderAdvancedFilters | null {
|
): WorkOrderAdvancedFilters | null {
|
||||||
|
|
@ -79,6 +91,7 @@ export function parseDashboardDrilldownParams(
|
||||||
const types = parseTypes(params.get("types"));
|
const types = parseTypes(params.get("types"));
|
||||||
const dispatchers = parseList(params.get("dispatchers"));
|
const dispatchers = parseList(params.get("dispatchers"));
|
||||||
const regions = parseRegions(params.get("regions"));
|
const regions = parseRegions(params.get("regions"));
|
||||||
|
const sites = [...new Set(parseList(params.get("sites")))];
|
||||||
const upliftStatuses = parseUpliftStatuses(params.get("upliftStatuses"));
|
const upliftStatuses = parseUpliftStatuses(params.get("upliftStatuses"));
|
||||||
const hasUplift = params.get("hasUplift") === "1";
|
const hasUplift = params.get("hasUplift") === "1";
|
||||||
|
|
||||||
|
|
@ -90,6 +103,7 @@ export function parseDashboardDrilldownParams(
|
||||||
types.length,
|
types.length,
|
||||||
dispatchers.length,
|
dispatchers.length,
|
||||||
regions.length,
|
regions.length,
|
||||||
|
sites.length,
|
||||||
upliftStatuses.length,
|
upliftStatuses.length,
|
||||||
hasUplift,
|
hasUplift,
|
||||||
].some(Boolean);
|
].some(Boolean);
|
||||||
|
|
@ -122,6 +136,9 @@ export function parseDashboardDrilldownParams(
|
||||||
if (regions.length > 0) {
|
if (regions.length > 0) {
|
||||||
filters.regions = new Set(regions);
|
filters.regions = new Set(regions);
|
||||||
}
|
}
|
||||||
|
if (sites.length > 0) {
|
||||||
|
filters.sites = new Set(sites);
|
||||||
|
}
|
||||||
if (upliftStatuses.length > 0) {
|
if (upliftStatuses.length > 0) {
|
||||||
filters.upliftStatuses = new Set(upliftStatuses);
|
filters.upliftStatuses = new Set(upliftStatuses);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,12 @@ export const queryKeys = {
|
||||||
byWorkOrderType: (workOrderType: string) =>
|
byWorkOrderType: (workOrderType: string) =>
|
||||||
[...queryKeys.services.all, "byWorkOrderType", workOrderType] as const,
|
[...queryKeys.services.all, "byWorkOrderType", workOrderType] as const,
|
||||||
},
|
},
|
||||||
|
completionTemplates: {
|
||||||
|
all: ["completionTemplates"] as const,
|
||||||
|
list: (search: string) => [...queryKeys.completionTemplates.all, "list", search] as const,
|
||||||
|
linkedWorkOrders: (id: number) =>
|
||||||
|
[...queryKeys.completionTemplates.all, "linkedWorkOrders", id] as const,
|
||||||
|
},
|
||||||
locations: {
|
locations: {
|
||||||
all: ["locations"] as const,
|
all: ["locations"] as const,
|
||||||
list: (params?: Record<string, unknown>) =>
|
list: (params?: Record<string, unknown>) =>
|
||||||
|
|
@ -88,6 +94,8 @@ export const queryKeys = {
|
||||||
detail: (id: string | number) => [...queryKeys.locations.all, "detail", id] as const,
|
detail: (id: string | number) => [...queryKeys.locations.all, "detail", id] as const,
|
||||||
dropdown: () => [...queryKeys.locations.all, "dropdown"] as const,
|
dropdown: () => [...queryKeys.locations.all, "dropdown"] as const,
|
||||||
sites: (search = "") => [...queryKeys.locations.all, "sites", search] as const,
|
sites: (search = "") => [...queryKeys.locations.all, "sites", search] as const,
|
||||||
|
openWorkOrders: (id: string | number) =>
|
||||||
|
[...queryKeys.locations.all, "open-work-orders", id] as const,
|
||||||
},
|
},
|
||||||
vendors: {
|
vendors: {
|
||||||
all: ["vendors"] as const,
|
all: ["vendors"] as const,
|
||||||
|
|
|
||||||
|
|
@ -34,3 +34,11 @@ export function canViewAllDispatchersOnDashboard(userRoles: string | null | unde
|
||||||
const roles = userRoles.split(",").map((role) => role.trim().toLowerCase());
|
const roles = userRoles.split(",").map((role) => role.trim().toLowerCase());
|
||||||
return roles.includes("admin") || roles.includes("scheduler");
|
return roles.includes("admin") || roles.includes("scheduler");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Site deletion is limited to Admin and Scheduler (the server enforces the DeleteSites team
|
||||||
|
* permission; this only hides the control from roles that would get a 403).
|
||||||
|
*/
|
||||||
|
export function canDeleteSites(userRoles: string | null | undefined): boolean {
|
||||||
|
return hasUserRole(userRoles, "admin") || hasUserRole(userRoles, "scheduler");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import type { Mock } from "vitest";
|
||||||
|
import type { CompletionTemplateDto } from "@/domain/completion-templates/schemas/completion-template-schema";
|
||||||
|
|
||||||
|
export interface ApiMocks {
|
||||||
|
apiGet: Mock;
|
||||||
|
apiPost: Mock;
|
||||||
|
apiPut: Mock;
|
||||||
|
apiDeleteNoContent: Mock;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WriteBody {
|
||||||
|
name: string;
|
||||||
|
extraSafetyNote: string | null;
|
||||||
|
procedures: { name: string; description: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function templateDto(overrides: Partial<CompletionTemplateDto> = {}): CompletionTemplateDto {
|
||||||
|
return {
|
||||||
|
id: 12,
|
||||||
|
name: "Backflow Test Report",
|
||||||
|
extraSafetyNote: null,
|
||||||
|
procedures: [],
|
||||||
|
createdAt: "2026-09-01T13:00:00Z",
|
||||||
|
updatedAt: null,
|
||||||
|
createdByName: "Jane Doe",
|
||||||
|
serviceKey: "backflow",
|
||||||
|
workOrderType: 2,
|
||||||
|
templateUrl: "https://files.example/backflow.pdf",
|
||||||
|
isActive: true,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BACKFLOW = templateDto({
|
||||||
|
id: 12,
|
||||||
|
name: "Backflow Test Report",
|
||||||
|
extraSafetyNote: "Wear gloves",
|
||||||
|
procedures: [
|
||||||
|
{ name: "Shut-off", description: "Close valve", sortOrder: 1 },
|
||||||
|
{ name: "Backflow Procedure", description: "line 1\nline 2", sortOrder: 0 },
|
||||||
|
],
|
||||||
|
updatedAt: "2026-09-25T14:00:00Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SCRUBBER = templateDto({
|
||||||
|
id: 13,
|
||||||
|
name: "Scrubber Pit Augering",
|
||||||
|
createdByName: null,
|
||||||
|
createdAt: "2026-06-01T10:00:00Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
function savedDto(id: number, body: WriteBody, base?: CompletionTemplateDto) {
|
||||||
|
return templateDto({
|
||||||
|
...base,
|
||||||
|
id,
|
||||||
|
name: body.name,
|
||||||
|
extraSafetyNote: body.extraSafetyNote,
|
||||||
|
procedures: body.procedures.map((procedure, index) => ({ ...procedure, sortOrder: index })),
|
||||||
|
updatedAt: "2026-09-25T15:00:00Z",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Answers the template endpoints the way the backend contract describes. */
|
||||||
|
export function serveTemplates(
|
||||||
|
api: ApiMocks,
|
||||||
|
templates: CompletionTemplateDto[],
|
||||||
|
linked: { count: number; workOrderIds: number[] } = { count: 0, workOrderIds: [] },
|
||||||
|
) {
|
||||||
|
api.apiGet.mockImplementation((url: string, options?: { searchParams?: { search?: string } }) => {
|
||||||
|
if (url.endsWith("/linked-work-orders")) return Promise.resolve(linked);
|
||||||
|
const term = options?.searchParams?.search?.toLowerCase() ?? "";
|
||||||
|
return Promise.resolve(
|
||||||
|
templates.filter((template) => (template.name ?? "").toLowerCase().includes(term)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
api.apiPost.mockImplementation((_url: string, body: WriteBody) =>
|
||||||
|
Promise.resolve(savedDto(99, body)),
|
||||||
|
);
|
||||||
|
api.apiPut.mockImplementation((url: string, body: WriteBody) => {
|
||||||
|
const id = Number(url.split("/").pop());
|
||||||
|
return Promise.resolve(
|
||||||
|
savedDto(
|
||||||
|
id,
|
||||||
|
body,
|
||||||
|
templates.find((template) => template.id === id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
api.apiDeleteNoContent.mockResolvedValue(undefined);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
import { screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import CompletionDocsPage from "@/app/(protected)/completion-docs/index";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
import { BACKFLOW, SCRUBBER, serveTemplates, templateDto } from "./completion-docs-fixtures";
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({
|
||||||
|
apiGet: vi.fn(),
|
||||||
|
apiPost: vi.fn(),
|
||||||
|
apiPut: vi.fn(),
|
||||||
|
apiDeleteNoContent: vi.fn(),
|
||||||
|
}));
|
||||||
|
const auth = vi.hoisted(() => ({ userRoles: "Admin" }));
|
||||||
|
|
||||||
|
vi.mock("@/api/api", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("@/api/api")>()),
|
||||||
|
...api,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/providers/auth-context", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("@/providers/auth-context")>()),
|
||||||
|
useAuthContext: () => ({ user: { id: "u1", userRoles: auth.userRoles } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// delay: null skips per-keystroke timer yields that only slow typing-heavy tests down.
|
||||||
|
let user = userEvent.setup({ delay: null });
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
return renderWithProviders(<CompletionDocsPage />, {
|
||||||
|
route: "/completion-docs",
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openTemplate(name: string) {
|
||||||
|
await user.click(await screen.findByRole("row", { name: new RegExp(name) }));
|
||||||
|
return screen.findByRole("dialog");
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
user = userEvent.setup({ delay: null });
|
||||||
|
auth.userRoles = "Admin";
|
||||||
|
serveTemplates(api, [BACKFLOW, SCRUBBER]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Completion Docs page", { timeout: 15_000 }, () => {
|
||||||
|
it("lists every template with its Document Title, Last Updated and Created By", async () => {
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const backflow = await screen.findByRole("row", { name: /Backflow Test Report/ });
|
||||||
|
const table = screen.getByRole("table", { name: "Completion document templates" });
|
||||||
|
const headers = within(table)
|
||||||
|
.getAllByRole("columnheader")
|
||||||
|
.map((cell) => cell.textContent);
|
||||||
|
expect(headers.slice(0, 3)).toEqual(["Document Title", "Last Updated", "Created By"]);
|
||||||
|
|
||||||
|
expect(within(backflow).getByText("Sep 25, 2026")).toBeInTheDocument();
|
||||||
|
expect(within(backflow).getByText("Jane Doe")).toBeInTheDocument();
|
||||||
|
const scrubber = within(table).getByRole("row", { name: /Scrubber Pit Augering/ });
|
||||||
|
// Never updated: Last Updated falls back to the creation date; no creator shows a dash.
|
||||||
|
expect(within(scrubber).getByText("Jun 1, 2026")).toBeInTheDocument();
|
||||||
|
expect(within(scrubber).getByText("—")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("heading", { name: "Completion Docs" })).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText("2 templates")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by Document Title as the user types and explains an empty result", async () => {
|
||||||
|
renderPage();
|
||||||
|
await screen.findByRole("row", { name: /Scrubber Pit Augering/ });
|
||||||
|
const search = screen.getByRole("searchbox", { name: "Search document title" });
|
||||||
|
|
||||||
|
await user.type(search, "backf");
|
||||||
|
expect(
|
||||||
|
await screen.findByText("1 template", { selector: "span" }, { timeout: 2000 }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("row", { name: /Scrubber Pit Augering/ })).not.toBeInTheDocument();
|
||||||
|
expect(api.apiGet).toHaveBeenCalledWith("workorders/completion-templates", {
|
||||||
|
searchParams: { search: "backf" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await user.clear(search);
|
||||||
|
await user.type(search, "no such document");
|
||||||
|
expect(
|
||||||
|
await screen.findByText("No templates found", {}, { timeout: 2000 }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Try a different search, or add a new template.")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("row", { name: /Backflow Test Report/ })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a failed load without the empty-search message, including after a failed Retry", async () => {
|
||||||
|
api.apiGet.mockRejectedValue(new Error("Templates are unavailable"));
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
const retry = await screen.findByRole("button", { name: "Retry" });
|
||||||
|
expect(screen.getByText("Templates are unavailable")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("No templates found")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
const callsBeforeRetry = api.apiGet.mock.calls.length;
|
||||||
|
await user.click(retry);
|
||||||
|
await waitFor(() => expect(api.apiGet.mock.calls.length).toBeGreaterThan(callsBeforeRetry));
|
||||||
|
expect(await screen.findByText("Templates are unavailable")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("No templates found")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens a read-only detail panel from a row with every part of the template", async () => {
|
||||||
|
renderPage();
|
||||||
|
const dialog = await openTemplate("Backflow Test Report");
|
||||||
|
|
||||||
|
expect(within(dialog).getByText("Document preview")).toBeInTheDocument();
|
||||||
|
for (const field of ["WO#", "Site Code", "Address", "Service Type"]) {
|
||||||
|
expect(within(dialog).getByText(field)).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
const safety = within(dialog).getByRole("list", { name: "Safety notes" });
|
||||||
|
expect(within(safety).getAllByRole("listitem")).toHaveLength(4);
|
||||||
|
expect(within(safety).getByText("Wear gloves")).toBeInTheDocument();
|
||||||
|
expect(within(dialog).queryByRole("textbox")).not.toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
within(dialog).getByText("Created by Jane Doe · Updated Sep 25, 2026"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows that a template without procedures has none defined yet", async () => {
|
||||||
|
renderPage();
|
||||||
|
const dialog = await openTemplate("Scrubber Pit Augering");
|
||||||
|
|
||||||
|
expect(within(dialog).getByText("No procedure defined yet")).toBeInTheDocument();
|
||||||
|
expect(within(dialog).getByText("No procedure added yet.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives an Admin every template action", async () => {
|
||||||
|
renderPage();
|
||||||
|
const row = await screen.findByRole("row", { name: /Backflow Test Report/ });
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /Add Template/ })).toBeInTheDocument();
|
||||||
|
expect(within(row).getByRole("button", { name: "Edit" })).toBeInTheDocument();
|
||||||
|
const dialog = await openTemplate("Backflow Test Report");
|
||||||
|
expect(within(dialog).getByRole("button", { name: /Edit/ })).toBeInTheDocument();
|
||||||
|
expect(within(dialog).getByRole("button", { name: /Delete/ })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides delete from a Scheduler, who may still add and edit", async () => {
|
||||||
|
auth.userRoles = "Scheduler";
|
||||||
|
renderPage();
|
||||||
|
const row = await screen.findByRole("row", { name: /Backflow Test Report/ });
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /Add Template/ })).toBeInTheDocument();
|
||||||
|
expect(within(row).getByRole("button", { name: "Edit" })).toBeInTheDocument();
|
||||||
|
const dialog = await openTemplate("Backflow Test Report");
|
||||||
|
expect(within(dialog).getByRole("button", { name: /Edit/ })).toBeInTheDocument();
|
||||||
|
expect(within(dialog).queryByRole("button", { name: /Delete/ })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides add, edit and delete entirely from a user without those permissions", async () => {
|
||||||
|
auth.userRoles = "Dispatcher";
|
||||||
|
serveTemplates(api, [templateDto()]);
|
||||||
|
renderPage();
|
||||||
|
const row = await screen.findByRole("row", { name: /Backflow Test Report/ });
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: /Add Template/ })).not.toBeInTheDocument();
|
||||||
|
expect(within(row).queryByRole("button", { name: "Edit" })).not.toBeInTheDocument();
|
||||||
|
expect(within(row).getByRole("button", { name: "View details" })).toBeInTheDocument();
|
||||||
|
const dialog = await openTemplate("Backflow Test Report");
|
||||||
|
expect(within(dialog).queryByRole("button", { name: /Edit/ })).not.toBeInTheDocument();
|
||||||
|
expect(within(dialog).queryByRole("button", { name: /Delete/ })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,309 @@
|
||||||
|
import { screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { HTTPError } from "ky";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import CompletionDocsPage from "@/app/(protected)/completion-docs/index";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
import { BACKFLOW, SCRUBBER, serveTemplates } from "./completion-docs-fixtures";
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({
|
||||||
|
apiGet: vi.fn(),
|
||||||
|
apiPost: vi.fn(),
|
||||||
|
apiPut: vi.fn(),
|
||||||
|
apiDeleteNoContent: vi.fn(),
|
||||||
|
}));
|
||||||
|
const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
||||||
|
const navigate = vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("@/api/api", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("@/api/api")>()),
|
||||||
|
...api,
|
||||||
|
}));
|
||||||
|
vi.mock("react-toastify", () => ({ toast }));
|
||||||
|
vi.mock("react-router", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("react-router")>()),
|
||||||
|
useNavigate: () => navigate,
|
||||||
|
}));
|
||||||
|
vi.mock("@/providers/auth-context", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("@/providers/auth-context")>()),
|
||||||
|
useAuthContext: () => ({ user: { id: "u1", userRoles: "Admin" } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// delay: null skips per-keystroke timer yields that only slow typing-heavy tests down.
|
||||||
|
let user = userEvent.setup({ delay: null });
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
return renderWithProviders(<CompletionDocsPage />, {
|
||||||
|
route: "/completion-docs",
|
||||||
|
withAuth: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openNewTemplate() {
|
||||||
|
renderPage();
|
||||||
|
await screen.findByRole("row", { name: /Backflow Test Report/ });
|
||||||
|
await user.click(screen.getByRole("button", { name: /Add Template/ }));
|
||||||
|
return screen.findByRole("dialog");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openTemplate(name: string) {
|
||||||
|
renderPage();
|
||||||
|
await user.click(await screen.findByRole("row", { name: new RegExp(name) }));
|
||||||
|
return screen.findByRole("dialog");
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewHeadings(dialog: HTMLElement) {
|
||||||
|
const preview = within(dialog).getByTestId("completion-template-preview");
|
||||||
|
return within(preview)
|
||||||
|
.getAllByRole("heading", { level: 3 })
|
||||||
|
.map((heading) => heading.textContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A 422 as the API layer delivers it: ky's parsed body on `data`, the mapped text on `message`. */
|
||||||
|
function unprocessable(body: { code: string; message: string }, message = body.message) {
|
||||||
|
const error = new HTTPError(
|
||||||
|
new Response(null, { status: 422 }),
|
||||||
|
new Request("http://api/workorders/completion-templates"),
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
error.data = body;
|
||||||
|
error.message = message;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addProcedure(dialog: HTMLElement, index: number, name: string) {
|
||||||
|
await user.click(within(dialog).getByRole("button", { name: "+ Add procedure" }));
|
||||||
|
await user.type(within(dialog).getByLabelText(`Procedure ${index} name`), name);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
user = userEvent.setup({ delay: null });
|
||||||
|
vi.clearAllMocks();
|
||||||
|
serveTemplates(api, [BACKFLOW, SCRUBBER], { count: 0, workOrderIds: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Add Template", { timeout: 15_000 }, () => {
|
||||||
|
it("blocks saving without a Document Title and says why", async () => {
|
||||||
|
const dialog = await openNewTemplate();
|
||||||
|
|
||||||
|
await user.click(within(dialog).getByRole("button", { name: /Add Template/ }));
|
||||||
|
|
||||||
|
expect(await within(dialog).findByText("Document Title is required")).toBeInTheDocument();
|
||||||
|
expect(within(dialog).getByRole("textbox", { name: /Document Title/ })).toHaveFocus();
|
||||||
|
expect(api.apiPost).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the fixed fields read-only and lets the author add then remove one safety note", async () => {
|
||||||
|
const dialog = await openNewTemplate();
|
||||||
|
|
||||||
|
const safety = within(dialog).getByRole("list", { name: "Safety notes" });
|
||||||
|
expect(within(safety).getAllByRole("listitem")).toHaveLength(3);
|
||||||
|
expect(within(dialog).getByText("WO#")).toBeInTheDocument();
|
||||||
|
expect(within(dialog).getAllByRole("textbox")).toHaveLength(1);
|
||||||
|
|
||||||
|
await user.click(within(dialog).getByRole("button", { name: "+ Add safety note" }));
|
||||||
|
expect(within(dialog).queryByRole("button", { name: "+ Add safety note" })).toBeNull();
|
||||||
|
await user.type(within(dialog).getByLabelText("Extra safety note"), "Mind the pit");
|
||||||
|
expect(within(dialog).getByTestId("completion-template-preview")).toHaveTextContent(
|
||||||
|
"Mind the pit",
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(within(dialog).getByRole("button", { name: "Remove safety note" }));
|
||||||
|
expect(within(dialog).queryByLabelText("Extra safety note")).toBeNull();
|
||||||
|
expect(within(dialog).getByTestId("completion-template-preview")).not.toHaveTextContent(
|
||||||
|
"Mind the pit",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds and removes procedures and previews them in editor order", async () => {
|
||||||
|
const dialog = await openNewTemplate();
|
||||||
|
expect(within(dialog).getByText("No procedure added yet.")).toBeInTheDocument();
|
||||||
|
|
||||||
|
await addProcedure(dialog, 1, "Isolate");
|
||||||
|
await addProcedure(dialog, 2, "Inspect");
|
||||||
|
await addProcedure(dialog, 3, "Restore");
|
||||||
|
expect(previewHeadings(dialog)).toEqual(["Isolate", "Inspect", "Restore"]);
|
||||||
|
|
||||||
|
await user.click(within(dialog).getByRole("button", { name: "Remove procedure 2" }));
|
||||||
|
expect(previewHeadings(dialog)).toEqual(["Isolate", "Restore"]);
|
||||||
|
expect(within(dialog).getByLabelText("Procedure 2 name")).toHaveValue("Restore");
|
||||||
|
|
||||||
|
await user.click(within(dialog).getByRole("button", { name: "+ Add procedure" }));
|
||||||
|
expect(previewHeadings(dialog)).toEqual(["Isolate", "Restore", "Procedure 3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves the title, note and procedures in order, then confirms with a toast", async () => {
|
||||||
|
const dialog = await openNewTemplate();
|
||||||
|
await user.type(
|
||||||
|
within(dialog).getByRole("textbox", { name: /Document Title/ }),
|
||||||
|
" PRV Check ",
|
||||||
|
);
|
||||||
|
await user.click(within(dialog).getByRole("button", { name: "+ Add safety note" }));
|
||||||
|
await user.type(within(dialog).getByLabelText("Extra safety note"), "Gloves");
|
||||||
|
await addProcedure(dialog, 1, "Open");
|
||||||
|
await user.type(within(dialog).getByLabelText("Procedure 1 description"), "a{enter}b");
|
||||||
|
await user.click(within(dialog).getByRole("button", { name: "+ Add procedure" }));
|
||||||
|
await addProcedure(dialog, 3, "Close");
|
||||||
|
|
||||||
|
await user.click(within(dialog).getByRole("button", { name: /Add Template/ }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Template added"));
|
||||||
|
expect(api.apiPost).toHaveBeenCalledWith("workorders/completion-templates", {
|
||||||
|
name: "PRV Check",
|
||||||
|
extraSafetyNote: "Gloves",
|
||||||
|
procedures: [
|
||||||
|
{ name: "Open", description: "a\nb" },
|
||||||
|
{ name: "Close", description: "" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the server's blank-title rejection on the title field", async () => {
|
||||||
|
api.apiPost.mockRejectedValue(
|
||||||
|
unprocessable({ code: "TitleRequired", message: "Document Title is required." }),
|
||||||
|
);
|
||||||
|
const dialog = await openNewTemplate();
|
||||||
|
await user.type(within(dialog).getByRole("textbox", { name: /Document Title/ }), "X");
|
||||||
|
|
||||||
|
await user.click(within(dialog).getByRole("button", { name: /Add Template/ }));
|
||||||
|
|
||||||
|
expect(await within(dialog).findByText("Document Title is required")).toBeInTheDocument();
|
||||||
|
expect(toast.error).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports any other rejected value in a toast and keeps the form open", async () => {
|
||||||
|
const message = "Procedure name must be 200 characters or fewer.";
|
||||||
|
api.apiPost.mockRejectedValue(unprocessable({ code: "InvalidValue", message }, message));
|
||||||
|
const dialog = await openNewTemplate();
|
||||||
|
await user.type(within(dialog).getByRole("textbox", { name: /Document Title/ }), "X");
|
||||||
|
|
||||||
|
await user.click(within(dialog).getByRole("button", { name: /Add Template/ }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(message));
|
||||||
|
expect(within(dialog).queryByText("Document Title is required")).toBeNull();
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Edit template", { timeout: 15_000 }, () => {
|
||||||
|
it("Cancel discards unsaved changes and returns to the original read-only values", async () => {
|
||||||
|
const dialog = await openTemplate("Backflow Test Report");
|
||||||
|
await user.click(within(dialog).getByRole("button", { name: /Edit/ }));
|
||||||
|
expect(within(dialog).getByText("Live preview")).toBeInTheDocument();
|
||||||
|
const title = within(dialog).getByRole("textbox", { name: /Document Title/ });
|
||||||
|
await user.clear(title);
|
||||||
|
await user.type(title, "Renamed");
|
||||||
|
await user.click(within(dialog).getByRole("button", { name: "Remove procedure 1" }));
|
||||||
|
|
||||||
|
await user.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||||
|
|
||||||
|
expect(within(dialog).queryByRole("textbox")).not.toBeInTheDocument();
|
||||||
|
expect(within(dialog).getAllByText("Backflow Test Report").length).toBeGreaterThan(0);
|
||||||
|
expect(within(dialog).queryByText("Renamed")).not.toBeInTheDocument();
|
||||||
|
expect(previewHeadings(dialog)).toEqual(["Backflow Procedure", "Shut-off"]);
|
||||||
|
expect(api.apiPut).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await user.click(within(dialog).getByRole("button", { name: /Edit/ }));
|
||||||
|
expect(within(dialog).getByRole("textbox", { name: /Document Title/ })).toHaveValue(
|
||||||
|
"Backflow Test Report",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves an edit, keeps the legacy fields and confirms with a toast", async () => {
|
||||||
|
const dialog = await openTemplate("Backflow Test Report");
|
||||||
|
await user.click(within(dialog).getByRole("button", { name: /Edit/ }));
|
||||||
|
await user.type(within(dialog).getByRole("textbox", { name: /Document Title/ }), " v2");
|
||||||
|
|
||||||
|
await user.click(within(dialog).getByRole("button", { name: /Save changes/ }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Template updated"));
|
||||||
|
expect(api.apiPut).toHaveBeenCalledWith("workorders/completion-templates/12", {
|
||||||
|
serviceKey: "backflow",
|
||||||
|
workOrderType: 2,
|
||||||
|
templateUrl: "https://files.example/backflow.pdf",
|
||||||
|
isActive: true,
|
||||||
|
name: "Backflow Test Report v2",
|
||||||
|
extraSafetyNote: "Wear gloves",
|
||||||
|
procedures: [
|
||||||
|
{ name: "Backflow Procedure", description: "line 1\nline 2" },
|
||||||
|
{ name: "Shut-off", description: "Close valve" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await within(dialog).findByText("Created by Jane Doe · Updated Sep 25, 2026"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[
|
||||||
|
"Escape",
|
||||||
|
async () => {
|
||||||
|
// The disabled Save button drops focus; Escape is pressed from inside the form.
|
||||||
|
screen.getByRole("textbox", { name: /Document Title/ }).focus();
|
||||||
|
await user.keyboard("{Escape}");
|
||||||
|
},
|
||||||
|
],
|
||||||
|
["the Close button", () => user.click(screen.getByRole("button", { name: "Close" }))],
|
||||||
|
])("stays closed when dismissed with %s while a save is in flight", async (_, dismiss) => {
|
||||||
|
let finishSave: () => void = () => {};
|
||||||
|
const respond = api.apiPut.getMockImplementation()!;
|
||||||
|
api.apiPut.mockImplementation(
|
||||||
|
(...args: unknown[]) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
finishSave = () => resolve(respond(...args));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const dialog = await openTemplate("Backflow Test Report");
|
||||||
|
await user.click(within(dialog).getByRole("button", { name: /Edit/ }));
|
||||||
|
await user.type(within(dialog).getByRole("textbox", { name: /Document Title/ }), " v2");
|
||||||
|
await user.click(within(dialog).getByRole("button", { name: /Save changes/ }));
|
||||||
|
await waitFor(() => expect(api.apiPut).toHaveBeenCalled());
|
||||||
|
|
||||||
|
await dismiss();
|
||||||
|
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||||
|
finishSave();
|
||||||
|
|
||||||
|
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Template updated"));
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Delete template", { timeout: 15_000 }, () => {
|
||||||
|
it("states how many open work orders depend on it and links to exactly those", async () => {
|
||||||
|
serveTemplates(api, [BACKFLOW, SCRUBBER], { count: 2, workOrderIds: [101, 205] });
|
||||||
|
const dialog = await openTemplate("Backflow Test Report");
|
||||||
|
|
||||||
|
await user.click(within(dialog).getByRole("button", { name: /Delete/ }));
|
||||||
|
|
||||||
|
const confirm = await screen.findByRole("dialog", { name: "Delete this template?" });
|
||||||
|
expect(
|
||||||
|
await within(confirm).findByText(
|
||||||
|
/It's still linked to 2 open work orders through its Service/,
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(api.apiDeleteNoContent).not.toHaveBeenCalled();
|
||||||
|
await user.click(within(confirm).getByRole("button", { name: "View linked work orders" }));
|
||||||
|
expect(navigate).toHaveBeenCalledWith("/workorders?ids=101,205");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still asks first when nothing depends on it, then deletes with a toast", async () => {
|
||||||
|
const dialog = await openTemplate("Scrubber Pit Augering");
|
||||||
|
|
||||||
|
await user.click(within(dialog).getByRole("button", { name: /Delete/ }));
|
||||||
|
|
||||||
|
const confirm = await screen.findByRole("dialog", { name: "Delete this template?" });
|
||||||
|
expect(
|
||||||
|
await within(confirm).findByText(
|
||||||
|
'This will permanently remove "Scrubber Pit Augering". This cannot be undone.',
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(within(confirm).queryByRole("button", { name: "View linked work orders" })).toBeNull();
|
||||||
|
|
||||||
|
await user.click(within(confirm).getByRole("button", { name: "Delete" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(toast.success).toHaveBeenCalledWith("Template deleted"));
|
||||||
|
expect(api.apiDeleteNoContent).toHaveBeenCalledWith("workorders/completion-templates/13");
|
||||||
|
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||||
|
});
|
||||||
|
});
|
||||||
286
src/test/app/(protected)/locations/site-delete.test.tsx
Normal file
|
|
@ -0,0 +1,286 @@
|
||||||
|
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { HTTPError } from "ky";
|
||||||
|
import { Route, Routes, useLocation } from "react-router";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import LocationsListPage from "@/app/(protected)/locations/index";
|
||||||
|
import { normalizeApiRequestError } from "@/api/api-error";
|
||||||
|
import type { Location, LocationListItem } from "@/domain/locations/types/location";
|
||||||
|
import { AuthContext, type AuthContextValue } from "@/providers/auth-context";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
const getList = vi.fn();
|
||||||
|
const getById = vi.fn();
|
||||||
|
const getOpenWorkOrders = vi.fn();
|
||||||
|
const deleteSite = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@/domain/locations/api/locations-api", () => ({
|
||||||
|
locationsApi: {
|
||||||
|
getList: (...args: unknown[]) => getList(...args),
|
||||||
|
getById: (...args: unknown[]) => getById(...args),
|
||||||
|
getOpenWorkOrders: (...args: unknown[]) => getOpenWorkOrders(...args),
|
||||||
|
delete: (...args: unknown[]) => deleteSite(...args),
|
||||||
|
update: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/accounts/use-cases/use-accounts-list", () => ({
|
||||||
|
useAccountsList: () => ({
|
||||||
|
data: { items: [], totalCount: 0, page: 1, pageSize: 50 },
|
||||||
|
isLoading: false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/use-debounce", () => ({
|
||||||
|
useDebounce: (value: string) => value,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const site: Location = {
|
||||||
|
id: 1,
|
||||||
|
name: "DAL1",
|
||||||
|
clientName: "Acme Logistics",
|
||||||
|
accountId: 7,
|
||||||
|
address: "3811 Distribution Dr",
|
||||||
|
city: "Dallas",
|
||||||
|
state: "TX",
|
||||||
|
contacts: [{ id: 31, name: "Jane", phone: "(421) 433-0032" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const row: LocationListItem = {
|
||||||
|
id: 1,
|
||||||
|
name: "DAL1",
|
||||||
|
clientName: "Acme Logistics",
|
||||||
|
sitePhone: "",
|
||||||
|
address: "3811 Distribution Dr",
|
||||||
|
city: "Dallas",
|
||||||
|
state: "TX",
|
||||||
|
zipCode: "",
|
||||||
|
phone: "(421) 433-0032",
|
||||||
|
contact: "Jane",
|
||||||
|
contactEmail: "",
|
||||||
|
status: "Active",
|
||||||
|
accountId: 7,
|
||||||
|
contacts: site.contacts,
|
||||||
|
};
|
||||||
|
|
||||||
|
function WorkOrdersProbe() {
|
||||||
|
const location = useLocation();
|
||||||
|
return <div data-testid="work-orders-url">{`${location.pathname}${location.search}`}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAs(userRoles: string) {
|
||||||
|
const auth = { user: { userRoles } } as unknown as AuthContextValue;
|
||||||
|
return renderWithProviders(
|
||||||
|
<AuthContext.Provider value={auth}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/locations" element={<LocationsListPage />} />
|
||||||
|
<Route path="/workorders" element={<WorkOrdersProbe />} />
|
||||||
|
</Routes>
|
||||||
|
</AuthContext.Provider>,
|
||||||
|
{ withAuth: false, route: "/locations" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPanel(): Promise<HTMLElement> {
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "DAL1" }));
|
||||||
|
const panel = screen.getByRole("region", { name: "Site details" });
|
||||||
|
await within(panel).findByRole("button", { name: "Edit" });
|
||||||
|
return panel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function forbidden(): HTTPError {
|
||||||
|
const body = { status: "Error", message: "You are not allowed to delete sites." };
|
||||||
|
const error = new HTTPError(
|
||||||
|
new Response(JSON.stringify(body), { status: 403 }),
|
||||||
|
new Request("https://api.example.test/api/locations/1"),
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
error.data = body;
|
||||||
|
return normalizeApiRequestError(error) as HTTPError;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Delete a site from the detail panel", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
for (const mock of [getList, getById, getOpenWorkOrders, deleteSite]) {
|
||||||
|
mock.mockReset();
|
||||||
|
}
|
||||||
|
getList.mockResolvedValue({ items: [row], totalCount: 1, page: 1, pageSize: 12 });
|
||||||
|
getById.mockResolvedValue(site);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["Admin", "Scheduler"])("offers Delete in the view footer to %s", async (role) => {
|
||||||
|
renderAs(role);
|
||||||
|
const panel = await openPanel();
|
||||||
|
|
||||||
|
expect(within(panel).getByRole("button", { name: "Delete" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Delete from a Dispatcher and removes the old row delete control", async () => {
|
||||||
|
renderAs("Dispatcher");
|
||||||
|
const panel = await openPanel();
|
||||||
|
|
||||||
|
expect(within(panel).queryByRole("button", { name: "Delete" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: /delete site/i })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warns with the open work-order count and links to exactly those work orders", async () => {
|
||||||
|
getOpenWorkOrders.mockResolvedValue({ count: 3, workOrderIds: [101, 205, 318] });
|
||||||
|
renderAs("Scheduler");
|
||||||
|
const panel = await openPanel();
|
||||||
|
|
||||||
|
fireEvent.click(within(panel).getByRole("button", { name: "Delete" }));
|
||||||
|
const dialog = await screen.findByRole("dialog", { name: "Delete this site?" });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await within(dialog).findByText(
|
||||||
|
"This will permanently remove DAL1 from Sites. It still has 3 open work orders — deleting the site won't cancel them automatically. This cannot be undone.",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(getOpenWorkOrders).toHaveBeenCalledWith("1");
|
||||||
|
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "View open work orders" }));
|
||||||
|
|
||||||
|
expect(await screen.findByTestId("work-orders-url")).toHaveTextContent(
|
||||||
|
"/workorders?ids=101,205,318",
|
||||||
|
);
|
||||||
|
expect(deleteSite).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["more open work orders than the capped id list", { count: 240, workOrderIds: [101, 205] }],
|
||||||
|
["an open count with no ids", { count: 4, workOrderIds: [] }],
|
||||||
|
])("links to the site's open work-order board when there are %s", async (_label, result) => {
|
||||||
|
getOpenWorkOrders.mockResolvedValue(result);
|
||||||
|
renderAs("Admin");
|
||||||
|
const panel = await openPanel();
|
||||||
|
|
||||||
|
fireEvent.click(within(panel).getByRole("button", { name: "Delete" }));
|
||||||
|
const dialog = await screen.findByRole("dialog", { name: "Delete this site?" });
|
||||||
|
await within(dialog).findByText(new RegExp(`It still has ${result.count} open work orders`));
|
||||||
|
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "View open work orders" }));
|
||||||
|
|
||||||
|
const url = new URL(
|
||||||
|
(await screen.findByTestId("work-orders-url")).textContent ?? "",
|
||||||
|
"https://app.test",
|
||||||
|
);
|
||||||
|
expect(url.pathname).toBe("/workorders");
|
||||||
|
expect(url.searchParams.get("ids")).toBeNull();
|
||||||
|
expect(url.searchParams.get("sites")).toBe("1");
|
||||||
|
expect(url.searchParams.get("statuses")?.split(",")).toEqual([
|
||||||
|
"Incomplete",
|
||||||
|
"Pending",
|
||||||
|
"Scheduled",
|
||||||
|
"En Route",
|
||||||
|
"On Site",
|
||||||
|
"In Progress",
|
||||||
|
"Rescheduled",
|
||||||
|
"Pending Quote",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("confirms without a warning when nothing is open, deletes and closes the panel", async () => {
|
||||||
|
getOpenWorkOrders.mockResolvedValue({ count: 0, workOrderIds: [] });
|
||||||
|
deleteSite.mockResolvedValue(undefined);
|
||||||
|
renderAs("Admin");
|
||||||
|
const panel = await openPanel();
|
||||||
|
|
||||||
|
fireEvent.click(within(panel).getByRole("button", { name: "Delete" }));
|
||||||
|
const dialog = await screen.findByRole("dialog", { name: "Delete this site?" });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await within(dialog).findByText(
|
||||||
|
"This will permanently remove DAL1 from Sites. This cannot be undone.",
|
||||||
|
),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
within(dialog).queryByRole("button", { name: "View open work orders" }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(deleteSite).toHaveBeenCalledWith("1"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByRole("heading", { name: "DAL1" })).not.toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks Delete when the open work-order check fails, until a retry succeeds", async () => {
|
||||||
|
getOpenWorkOrders.mockRejectedValueOnce(new Error("Server error"));
|
||||||
|
getOpenWorkOrders.mockResolvedValueOnce({ count: 2, workOrderIds: [101, 205] });
|
||||||
|
renderAs("Admin");
|
||||||
|
const panel = await openPanel();
|
||||||
|
|
||||||
|
fireEvent.click(within(panel).getByRole("button", { name: "Delete" }));
|
||||||
|
const dialog = await screen.findByRole("dialog", { name: "Delete this site?" });
|
||||||
|
|
||||||
|
expect(await within(dialog).findByRole("alert")).toHaveTextContent(
|
||||||
|
"Couldn't check this site for open work orders. Try again before deleting it.",
|
||||||
|
);
|
||||||
|
expect(within(dialog).getByRole("button", { name: "Delete" })).toBeDisabled();
|
||||||
|
expect(within(dialog).queryByText(/This cannot be undone/)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Try again" }));
|
||||||
|
|
||||||
|
expect(await within(dialog).findByText(/It still has 2 open work orders/)).toBeInTheDocument();
|
||||||
|
expect(within(dialog).getByRole("button", { name: "Delete" })).toBeEnabled();
|
||||||
|
expect(within(dialog).getByRole("button", { name: "View open work orders" })).toBeEnabled();
|
||||||
|
expect(deleteSite).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds Delete on reopen until the fresh open work-order count replaces the cached one", async () => {
|
||||||
|
let resolveRecheck: (value: { count: number; workOrderIds: number[] }) => void = () => {};
|
||||||
|
getOpenWorkOrders.mockResolvedValueOnce({ count: 0, workOrderIds: [] });
|
||||||
|
getOpenWorkOrders.mockReturnValueOnce(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveRecheck = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderAs("Admin");
|
||||||
|
const panel = await openPanel();
|
||||||
|
|
||||||
|
fireEvent.click(within(panel).getByRole("button", { name: "Delete" }));
|
||||||
|
let dialog = await screen.findByRole("dialog", { name: "Delete this site?" });
|
||||||
|
await within(dialog).findByText(
|
||||||
|
"This will permanently remove DAL1 from Sites. This cannot be undone.",
|
||||||
|
);
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByRole("dialog", { name: "Delete this site?" })).not.toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(within(panel).getByRole("button", { name: "Delete" }));
|
||||||
|
dialog = await screen.findByRole("dialog", { name: "Delete this site?" });
|
||||||
|
await waitFor(() => expect(getOpenWorkOrders).toHaveBeenCalledTimes(2));
|
||||||
|
|
||||||
|
expect(within(dialog).getByRole("button", { name: "Delete" })).toBeDisabled();
|
||||||
|
expect(within(dialog).queryByText(/This cannot be undone/)).not.toBeInTheDocument();
|
||||||
|
expect(within(dialog).getByLabelText("Checking open work orders")).toBeInTheDocument();
|
||||||
|
|
||||||
|
resolveRecheck({ count: 1, workOrderIds: [101] });
|
||||||
|
|
||||||
|
expect(await within(dialog).findByText(/It still has 1 open work order —/)).toBeInTheDocument();
|
||||||
|
expect(within(dialog).getByRole("button", { name: "Delete" })).toBeEnabled();
|
||||||
|
expect(deleteSite).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the site and shows the server's refusal when the API answers 403", async () => {
|
||||||
|
getOpenWorkOrders.mockResolvedValue({ count: 0, workOrderIds: [] });
|
||||||
|
deleteSite.mockRejectedValue(forbidden());
|
||||||
|
const toastError = vi.spyOn(toast, "error");
|
||||||
|
renderAs("Admin");
|
||||||
|
const panel = await openPanel();
|
||||||
|
|
||||||
|
fireEvent.click(within(panel).getByRole("button", { name: "Delete" }));
|
||||||
|
const dialog = await screen.findByRole("dialog", { name: "Delete this site?" });
|
||||||
|
await within(dialog).findByText(/This cannot be undone/);
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(toastError).toHaveBeenCalledWith("You do not have permission to perform this action."),
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("dialog", { name: "Delete this site?" })).toBeInTheDocument();
|
||||||
|
expect(within(panel).getByRole("heading", { name: "DAL1", hidden: true })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -235,17 +235,4 @@ describe("Sites page detail panel", () => {
|
||||||
expect(screen.queryByRole("heading", { name: "DAL1" })).not.toBeInTheDocument(),
|
expect(screen.queryByRole("heading", { name: "DAL1" })).not.toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
it("row delete asks to confirm removing the site without opening the panel", async () => {
|
|
||||||
renderWithProviders(<LocationsListPage />, { withAuth: false });
|
|
||||||
|
|
||||||
fireEvent.click(await screen.findByRole("button", { name: "Delete site DAL1" }));
|
|
||||||
|
|
||||||
const confirm = await screen.findByRole("dialog", { name: "Delete this site?" });
|
|
||||||
expect(
|
|
||||||
within(confirm).getByText(
|
|
||||||
"This will permanently remove DAL1 from Sites. This cannot be undone.",
|
|
||||||
),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole("heading", { name: "DAL1" })).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ describe("Uplift Approvals approved tab", () => {
|
||||||
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
||||||
renderWithProviders(<UpliftQueuePage />);
|
renderWithProviders(<UpliftQueuePage />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
|
fireEvent.click(screen.getByRole("tab", { name: /^Approved/ }));
|
||||||
expect(screen.getByRole("columnheader", { name: "Approved By" })).toBeInTheDocument();
|
expect(screen.getByRole("columnheader", { name: "Approved By" })).toBeInTheDocument();
|
||||||
expect(screen.getByRole("columnheader", { name: "Approved At" })).toBeInTheDocument();
|
expect(screen.getByRole("columnheader", { name: "Approved At" })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("columnheader", { name: "Waiting" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("columnheader", { name: "Waiting" })).not.toBeInTheDocument();
|
||||||
|
|
@ -125,7 +125,7 @@ describe("Uplift Approvals approved tab", () => {
|
||||||
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
||||||
renderWithProviders(<UpliftQueuePage />);
|
renderWithProviders(<UpliftQueuePage />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
|
fireEvent.click(screen.getByRole("tab", { name: /^Approved/ }));
|
||||||
const revoke = await screen.findByRole("button", { name: "Revoke" });
|
const revoke = await screen.findByRole("button", { name: "Revoke" });
|
||||||
expect(revoke).toBeEnabled();
|
expect(revoke).toBeEnabled();
|
||||||
fireEvent.click(revoke);
|
fireEvent.click(revoke);
|
||||||
|
|
@ -149,7 +149,7 @@ describe("Uplift Approvals approved tab", () => {
|
||||||
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
||||||
renderWithProviders(<UpliftQueuePage />);
|
renderWithProviders(<UpliftQueuePage />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
|
fireEvent.click(screen.getByRole("tab", { name: /^Approved/ }));
|
||||||
const revoke = await screen.findByRole("button", { name: "Revoke" });
|
const revoke = await screen.findByRole("button", { name: "Revoke" });
|
||||||
expect(revoke).toBeDisabled();
|
expect(revoke).toBeDisabled();
|
||||||
fireEvent.mouseOver(revoke);
|
fireEvent.mouseOver(revoke);
|
||||||
|
|
@ -172,7 +172,7 @@ describe("Uplift Approvals approved tab", () => {
|
||||||
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
||||||
renderWithProviders(<UpliftQueuePage />);
|
renderWithProviders(<UpliftQueuePage />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
|
fireEvent.click(screen.getByRole("tab", { name: /^Approved/ }));
|
||||||
expect(await screen.findByText("No approved uplifts yet")).toBeInTheDocument();
|
expect(await screen.findByText("No approved uplifts yet")).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("Uplifts you approve are archived here for reference."),
|
screen.getByText("Uplifts you approve are archived here for reference."),
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,7 @@ describe("Uplift Approvals shows one amount per request", () => {
|
||||||
queueState.data = queueOf(approvedItem);
|
queueState.data = queueOf(approvedItem);
|
||||||
await renderQueue();
|
await renderQueue();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "Approved" }));
|
fireEvent.click(screen.getByRole("tab", { name: /^Approved/ }));
|
||||||
expect(amountCell()).toHaveTextContent(UPLIFT);
|
expect(amountCell()).toHaveTextContent(UPLIFT);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Revoke" }));
|
fireEvent.click(screen.getByRole("button", { name: "Revoke" }));
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,237 @@
|
||||||
|
import { fireEvent, screen, within } from "@testing-library/react";
|
||||||
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type {
|
||||||
|
UpliftQueueItem,
|
||||||
|
UpliftsQueueParams,
|
||||||
|
UpliftsQueueResult,
|
||||||
|
} from "@/domain/uplifts/types/uplift";
|
||||||
|
import { formatDateTime } from "@/lib/time-utils";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
const queueState = vi.hoisted(() => ({
|
||||||
|
byStatus: {} as Record<string, UpliftsQueueResult | undefined>,
|
||||||
|
errorByStatus: {} as Record<string, Error | undefined>,
|
||||||
|
requestedStatuses: [] as Array<string | undefined>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const baseItem: UpliftQueueItem = {
|
||||||
|
id: 1,
|
||||||
|
status: "Pending",
|
||||||
|
currentNTE: 500,
|
||||||
|
requestedNTE: 750,
|
||||||
|
delta: 250,
|
||||||
|
vendorReason: "Second hydro-jetting pass on the adjoining line",
|
||||||
|
requestedAt: "2026-06-21T10:05:00Z",
|
||||||
|
requestedByVendorName: "Alyssa Dispatcher",
|
||||||
|
decidedAt: "",
|
||||||
|
decidedByName: "",
|
||||||
|
decisionNote: "",
|
||||||
|
requiredTier: 1,
|
||||||
|
canDecide: true,
|
||||||
|
expiresAt: "",
|
||||||
|
notificationStatus: "",
|
||||||
|
notificationError: "",
|
||||||
|
evidenceDocumentId: "doc-9",
|
||||||
|
evidenceFileName: "quote.pdf",
|
||||||
|
evidenceContentType: "application/pdf",
|
||||||
|
evidenceSizeBytes: 1024,
|
||||||
|
dispatchNumber: "DSP-1",
|
||||||
|
poNumber: "PO-1",
|
||||||
|
vendorCompanyName: "Drain Cleaning Bros",
|
||||||
|
workOrderId: 10,
|
||||||
|
dispatchId: 20,
|
||||||
|
woNumber: "WO-25603",
|
||||||
|
site: "CHI2",
|
||||||
|
serviceName: "Drains",
|
||||||
|
technicianName: "",
|
||||||
|
workOrderDispatcherName: "",
|
||||||
|
workOrderScheduledDate: "",
|
||||||
|
attachmentCount: 1,
|
||||||
|
approvedOnWoAuto: null,
|
||||||
|
approvedOnWoAdmin: null,
|
||||||
|
approvedOnWoTotal: null,
|
||||||
|
workOrderClosed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const newestRejection: UpliftQueueItem = {
|
||||||
|
...baseItem,
|
||||||
|
id: 31,
|
||||||
|
status: "Rejected",
|
||||||
|
woNumber: "WO-31",
|
||||||
|
decidedAt: "2026-06-21T16:40:00Z",
|
||||||
|
decidedByName: "Adam Moussa",
|
||||||
|
decisionNote: "Outside this work order's scope — raise a separate reactive WO.",
|
||||||
|
canDecide: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const olderRejection: UpliftQueueItem = {
|
||||||
|
...baseItem,
|
||||||
|
id: 30,
|
||||||
|
status: "Rejected",
|
||||||
|
woNumber: "WO-30",
|
||||||
|
requestedByVendorName: "Derek Dispatcher",
|
||||||
|
decidedAt: "2026-05-26T15:05:00Z",
|
||||||
|
decidedByName: "Grace Admin",
|
||||||
|
decisionNote: "Quote is above market for this scope.",
|
||||||
|
canDecide: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingItem: UpliftQueueItem = { ...baseItem, id: 40, woNumber: "WO-PENDING" };
|
||||||
|
const approvedItem: UpliftQueueItem = {
|
||||||
|
...baseItem,
|
||||||
|
id: 50,
|
||||||
|
status: "Approved",
|
||||||
|
woNumber: "WO-APPROVED",
|
||||||
|
decidedAt: "2026-06-01T09:00:00Z",
|
||||||
|
decidedByName: "Adam Moussa",
|
||||||
|
};
|
||||||
|
|
||||||
|
function page(items: UpliftQueueItem[], totalCount = items.length): UpliftsQueueResult {
|
||||||
|
return { items, totalCount, page: 1, pageSize: 25, pendingExposureTotal: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({
|
||||||
|
useUpliftsQueue: (params: UpliftsQueueParams) => {
|
||||||
|
queueState.requestedStatuses.push(params.status);
|
||||||
|
const data = queueState.byStatus[params.status ?? ""];
|
||||||
|
const error = queueState.errorByStatus[params.status ?? ""] ?? null;
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
isLoading: data === undefined && error === null,
|
||||||
|
isFetching: false,
|
||||||
|
error,
|
||||||
|
refetch: vi.fn(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/uplifts/use-cases/use-uplift-actions", () => ({
|
||||||
|
useApproveUplift: () => ({ isPending: false, mutate: vi.fn() }),
|
||||||
|
useRejectUplift: () => ({ isPending: false, mutate: vi.fn() }),
|
||||||
|
useRevokeUplift: () => ({ isPending: false, mutate: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/providers/auth-context", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<object>()),
|
||||||
|
useAuthContext: () => ({ user: { userRoles: "Admin" } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/domain/uplifts/use-cases/use-uplifts-can-approve", () => ({
|
||||||
|
useUpliftsCanApprove: () => ({ data: true }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
async function openRejectedTab() {
|
||||||
|
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
||||||
|
renderWithProviders(<UpliftQueuePage />);
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: /^Rejected/ }));
|
||||||
|
return screen.getByRole("table", { name: "rejected uplift requests" });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Uplift Approvals rejected tab", () => {
|
||||||
|
// Load the page module once up front so the first test's budget is not spent on a
|
||||||
|
// cold import of the whole route.
|
||||||
|
beforeAll(async () => {
|
||||||
|
await import("@/app/(protected)/uplifts/index");
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
queueState.requestedStatuses = [];
|
||||||
|
queueState.errorByStatus = {};
|
||||||
|
queueState.byStatus = {
|
||||||
|
Pending: page([pendingItem]),
|
||||||
|
Approved: page([approvedItem]),
|
||||||
|
Rejected: page([newestRejection, olderRejection], 12),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the rejected total in the tab count pill next to the other tabs' pills", async () => {
|
||||||
|
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
||||||
|
renderWithProviders(<UpliftQueuePage />);
|
||||||
|
|
||||||
|
const rejectedTab = screen.getByRole("tab", { name: /^Rejected/ });
|
||||||
|
expect(within(rejectedTab).getByLabelText("12 rejected uplifts")).toHaveTextContent("12");
|
||||||
|
expect(screen.getByLabelText("1 pending uplifts")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("1 approved uplifts")).toBeInTheDocument();
|
||||||
|
expect(queueState.requestedStatuses).toContain("Rejected");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never claims a count for a queue that is still loading or failed to load", async () => {
|
||||||
|
queueState.byStatus.Rejected = undefined;
|
||||||
|
queueState.errorByStatus.Rejected = new Error("Forbidden");
|
||||||
|
queueState.byStatus.Approved = undefined;
|
||||||
|
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
||||||
|
renderWithProviders(<UpliftQueuePage />);
|
||||||
|
|
||||||
|
const rejectedPill = screen.getByLabelText("rejected uplift count unavailable");
|
||||||
|
expect(rejectedPill).toHaveTextContent("—");
|
||||||
|
expect(screen.getByLabelText("approved uplift count unavailable")).toHaveTextContent("—");
|
||||||
|
expect(screen.queryByLabelText("0 rejected uplifts")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText("0 approved uplifts")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("1 pending uplifts")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the count and pager for kept rows when a rejected refetch fails", async () => {
|
||||||
|
queueState.errorByStatus.Rejected = new Error("Failed to load uplift queue");
|
||||||
|
const table = await openRejectedTab();
|
||||||
|
|
||||||
|
expect(screen.getByRole("alert")).toHaveTextContent("Failed to load uplift queue");
|
||||||
|
expect(within(table).getAllByRole("row")).toHaveLength(3);
|
||||||
|
expect(screen.getByLabelText("12 rejected uplifts")).toHaveTextContent("12");
|
||||||
|
expect(screen.getByText(/of 12/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the load error instead of an empty archive when the rejected queue fails", async () => {
|
||||||
|
queueState.byStatus.Rejected = undefined;
|
||||||
|
queueState.errorByStatus.Rejected = new Error("Failed to load uplift queue");
|
||||||
|
const table = await openRejectedTab();
|
||||||
|
|
||||||
|
expect(screen.getByRole("alert")).toHaveTextContent("Failed to load uplift queue");
|
||||||
|
expect(within(table).queryByText("No rejected uplifts.")).not.toBeInTheDocument();
|
||||||
|
expect(within(table).queryByTestId("HighlightOffOutlinedIcon")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/of 0/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists only rejected uplifts, in the server's most-recently-rejected order", async () => {
|
||||||
|
const table = await openRejectedTab();
|
||||||
|
|
||||||
|
const dataRows = within(table).getAllByRole("row").slice(1);
|
||||||
|
expect(dataRows).toHaveLength(2);
|
||||||
|
expect(dataRows[0]).toHaveTextContent("WO-31");
|
||||||
|
expect(dataRows[1]).toHaveTextContent("WO-30");
|
||||||
|
expect(within(table).queryByText("WO-PENDING")).not.toBeInTheDocument();
|
||||||
|
expect(within(table).queryByText("WO-APPROVED")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows who requested, who rejected, when, and the rejection reason inline", async () => {
|
||||||
|
const table = await openRejectedTab();
|
||||||
|
|
||||||
|
for (const header of ["Requested By", "Rejected By", "Rejected At", "Rejection Reason"]) {
|
||||||
|
expect(within(table).getByRole("columnheader", { name: header })).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
const [, firstRow] = within(table).getAllByRole("row");
|
||||||
|
expect(firstRow).toHaveTextContent("Alyssa Dispatcher");
|
||||||
|
expect(firstRow).toHaveTextContent("Adam Moussa");
|
||||||
|
expect(firstRow).toHaveTextContent(formatDateTime(newestRejection.decidedAt));
|
||||||
|
expect(
|
||||||
|
within(firstRow).getByText("Outside this work order's scope — raise a separate reactive WO."),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers no action on a rejected uplift", async () => {
|
||||||
|
const table = await openRejectedTab();
|
||||||
|
|
||||||
|
expect(within(table).queryByRole("columnheader", { name: "Actions" })).not.toBeInTheDocument();
|
||||||
|
for (const name of [/^Approve/, /^Reject$/, /^Revoke/]) {
|
||||||
|
expect(within(table).queryByRole("button", { name })).not.toBeInTheDocument();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an icon and the empty copy when nothing has been rejected", async () => {
|
||||||
|
queueState.byStatus.Rejected = page([]);
|
||||||
|
const table = await openRejectedTab();
|
||||||
|
|
||||||
|
expect(within(table).getByText("No rejected uplifts.")).toBeInTheDocument();
|
||||||
|
expect(within(table).getByTestId("HighlightOffOutlinedIcon")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("0 rejected uplifts")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -32,6 +32,15 @@ describe("navGroups", () => {
|
||||||
expect(dataManagement?.items.map((item) => item.path)).toContain("/settings/services");
|
expect(dataManagement?.items.map((item) => item.path)).toContain("/settings/services");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lists Completion Docs after Services in the DATA MANAGEMENT group", () => {
|
||||||
|
const dataManagement = navGroups.find((group) => group.title === "DATA MANAGEMENT");
|
||||||
|
expect(dataManagement?.items.map((item) => item.label)).toEqual([
|
||||||
|
"Services",
|
||||||
|
"Completion Docs",
|
||||||
|
]);
|
||||||
|
expect(getActiveNavPath("/completion-docs")).toBe("/completion-docs");
|
||||||
|
});
|
||||||
|
|
||||||
it("does not include the approvals group by default", () => {
|
it("does not include the approvals group by default", () => {
|
||||||
expect(navGroups.some((group) => group.title === "APPROVALS")).toBe(false);
|
expect(navGroups.some((group) => group.title === "APPROVALS")).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { completionTemplatePermissions } from "@/app/(protected)/completion-docs/_components/use-completion-template-permissions";
|
||||||
|
import {
|
||||||
|
mapCompletionTemplate,
|
||||||
|
toCompletionTemplateInput,
|
||||||
|
} from "@/domain/completion-templates/mappers/completion-template-mapper";
|
||||||
|
import { completionTemplateDtoSchema } from "@/domain/completion-templates/schemas/completion-template-schema";
|
||||||
|
|
||||||
|
describe("mapCompletionTemplate", () => {
|
||||||
|
it("orders procedures by sortOrder and shows createdAt when never updated", () => {
|
||||||
|
const template = mapCompletionTemplate(
|
||||||
|
completionTemplateDtoSchema.parse({
|
||||||
|
id: 4,
|
||||||
|
name: "PRV",
|
||||||
|
extraSafetyNote: " ",
|
||||||
|
procedures: [
|
||||||
|
{ id: 2, name: "Second", description: "b", sortOrder: 1 },
|
||||||
|
{ id: 1, name: "First", description: "a", sortOrder: 0 },
|
||||||
|
],
|
||||||
|
createdAt: "2026-09-01T00:00:00Z",
|
||||||
|
updatedAt: null,
|
||||||
|
createdByName: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(template.procedures.map((procedure) => procedure.name)).toEqual(["First", "Second"]);
|
||||||
|
expect(template.lastUpdatedAt).toBe("2026-09-01T00:00:00Z");
|
||||||
|
expect(template.extraSafetyNote).toBeNull();
|
||||||
|
expect(template.createdByName).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a response that is not a template", () => {
|
||||||
|
expect(completionTemplateDtoSchema.safeParse({ name: "no id" }).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("toCompletionTemplateInput", () => {
|
||||||
|
it("trims fields, clears a blank note and drops procedures left entirely blank", () => {
|
||||||
|
expect(
|
||||||
|
toCompletionTemplateInput({
|
||||||
|
name: " Title ",
|
||||||
|
extraSafetyNote: " ",
|
||||||
|
procedures: [
|
||||||
|
{ name: " ", description: "" },
|
||||||
|
{ name: "", description: " steps " },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
name: "Title",
|
||||||
|
extraSafetyNote: null,
|
||||||
|
procedures: [{ name: "", description: "steps" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("completionTemplatePermissions", () => {
|
||||||
|
it.each([
|
||||||
|
["Admin", { canCreate: true, canEdit: true, canDelete: true }],
|
||||||
|
["Scheduler", { canCreate: true, canEdit: true, canDelete: false }],
|
||||||
|
["Dispatcher", { canCreate: false, canEdit: false, canDelete: false }],
|
||||||
|
["dispatcher, admin", { canCreate: true, canEdit: true, canDelete: true }],
|
||||||
|
["Vendor", { canCreate: false, canEdit: false, canDelete: false }],
|
||||||
|
[undefined, { canCreate: false, canEdit: false, canDelete: false }],
|
||||||
|
])("resolves %s to the role defaults", (roles, expected) => {
|
||||||
|
expect(completionTemplatePermissions(roles)).toEqual(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
mapLocation,
|
mapLocation,
|
||||||
mapLocationListItem,
|
mapLocationListItem,
|
||||||
mapLocationToBackend,
|
mapLocationToBackend,
|
||||||
|
mapSiteOpenWorkOrders,
|
||||||
} from "@/domain/locations/mappers/location-mapper";
|
} from "@/domain/locations/mappers/location-mapper";
|
||||||
|
|
||||||
describe("location contacts hydration", () => {
|
describe("location contacts hydration", () => {
|
||||||
|
|
@ -147,3 +148,16 @@ describe("mapLocation site fields", () => {
|
||||||
expect(row).toMatchObject({ clientName: "Acme", sitePhone: "(214) 555-0100" });
|
expect(row).toMatchObject({ clientName: "Acme", sitePhone: "(214) 555-0100" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("mapSiteOpenWorkOrders", () => {
|
||||||
|
it("keeps the server count and drops invalid ids", () => {
|
||||||
|
expect(mapSiteOpenWorkOrders({ count: 240, workOrderIds: [101, "x", -3, 205] })).toEqual({
|
||||||
|
count: 240,
|
||||||
|
workOrderIds: [101, 205],
|
||||||
|
});
|
||||||
|
expect(mapSiteOpenWorkOrders({ Count: 0, WorkOrderIds: [] })).toEqual({
|
||||||
|
count: 0,
|
||||||
|
workOrderIds: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
40
src/test/domain/uplifts/use-cases/use-uplifts-queue.test.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { upliftsApi } from "@/domain/uplifts/api/uplifts-api";
|
||||||
|
import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
|
||||||
|
import { createAppQueryClient } from "@/lib/query/query-client";
|
||||||
|
|
||||||
|
const toastMocks = vi.hoisted(() => ({ error: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock("react-toastify", () => ({
|
||||||
|
toast: { error: toastMocks.error, success: vi.fn() },
|
||||||
|
}));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
toastMocks.error.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useUpliftsQueue", () => {
|
||||||
|
it("does not show the global error toast, since the approvals page reports the failure inline", async () => {
|
||||||
|
vi.spyOn(upliftsApi, "list").mockRejectedValue(new Error("Failed to load uplift queue"));
|
||||||
|
// The real app query client runs the QueryCache's global onError handler, which
|
||||||
|
// toasts unless the query opts out.
|
||||||
|
const queryClient = createAppQueryClient();
|
||||||
|
queryClient.setDefaultOptions({ queries: { retry: false } });
|
||||||
|
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => useUpliftsQueue({ status: "Rejected", page: 1, pageSize: 25 }),
|
||||||
|
{
|
||||||
|
wrapper: ({ children }: { children: ReactNode }) => (
|
||||||
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.error).toBeInstanceOf(Error));
|
||||||
|
expect(toastMocks.error).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { parseDashboardDrilldownParams } from "@/domain/work-orders/utils/dashboard-drilldown-params";
|
import { buildResolvedBoardSearchParams } from "@/app/(protected)/workorders/_hooks/build-resolved-board-search-params";
|
||||||
|
import {
|
||||||
|
buildSiteOpenWorkOrdersHref,
|
||||||
|
parseDashboardDrilldownParams,
|
||||||
|
} from "@/domain/work-orders/utils/dashboard-drilldown-params";
|
||||||
import {
|
import {
|
||||||
avetaPendingDrilldownSearch,
|
avetaPendingDrilldownSearch,
|
||||||
unassignedDrilldownSearch,
|
unassignedDrilldownSearch,
|
||||||
|
|
@ -102,6 +106,39 @@ describe("parseDashboardDrilldownParams", () => {
|
||||||
expect(cleared.dispatchers.size).toBe(0);
|
expect(cleared.dispatchers.size).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("turns the site open work-order link into that site's open, all-weeks board search", () => {
|
||||||
|
const href = buildSiteOpenWorkOrdersHref(12);
|
||||||
|
const filters = parseDashboardDrilldownParams(new URL(href, "https://app.test").searchParams);
|
||||||
|
|
||||||
|
expect(filters?.sites).toEqual(new Set(["12"]));
|
||||||
|
expect(filters?.workOrderIds.size).toBe(0);
|
||||||
|
const params = buildResolvedBoardSearchParams({
|
||||||
|
advApplied: filters,
|
||||||
|
search: "",
|
||||||
|
page: 0,
|
||||||
|
pageSize: 200,
|
||||||
|
sortBy: "",
|
||||||
|
sortDir: "",
|
||||||
|
locations: [{ id: 12, name: "DAL1", siteCode: "DAL1" }],
|
||||||
|
vendors: [],
|
||||||
|
});
|
||||||
|
expect(params).toMatchObject({
|
||||||
|
sites: ["DAL1"],
|
||||||
|
statuses: [...NON_TERMINAL_LIFECYCLE_STATUSES],
|
||||||
|
datePreset: "Custom",
|
||||||
|
dateFrom: "2000-01-01",
|
||||||
|
dateTo: "2099-12-31",
|
||||||
|
includeDateless: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps an exact id set ahead of a site filter", () => {
|
||||||
|
const filters = parseDashboardDrilldownParams(new URLSearchParams({ ids: "4,9", sites: "12" }));
|
||||||
|
|
||||||
|
expect(filters?.workOrderIds).toEqual(new Set([4, 9]));
|
||||||
|
expect(filters?.sites.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects partial or malformed date ranges", () => {
|
it("rejects partial or malformed date ranges", () => {
|
||||||
expect(
|
expect(
|
||||||
parseDashboardDrilldownParams(
|
parseDashboardDrilldownParams(
|
||||||
|
|
|
||||||