Merge remote-tracking branch 'origin/main' into feat/ab/sh-385-invite-registration

# Conflicts:
#	src/api/api-paths.ts
This commit is contained in:
Alexandre Brandizzi 2026-09-25 17:46:17 -03:00
commit 0b654bb07c
149 changed files with 6167 additions and 897 deletions

View file

@ -26,7 +26,7 @@
| 4 | dashboard | must-have | 1 | `pages/Dashboard.js` | `/`, `/dashboard` | 253 | auth |
| 5 | settings/dropdowns | must-have | 1 | `pages/settings/` (DropdownOptions) | `/settings/dropdowns` | 866\* | auth |
| 6 | accounts | must-have | 2 | `pages/accounts/` | `/accounts`, `/accounts/new`, `/accounts/edit/:id` | 745 | auth |
| 7 | locations | must-have | 2 | `pages/locations/` | `/locations`, `/locations/new`, `/locations/edit/:id` | 1.1k | auth, accounts |
| 7 | locations | must-have | 2 | `pages/locations/` | `/locations` (Add Site modal, detail panel) | 1.1k | auth, accounts |
| 8 | employees | must-have | 2 | `pages/employees/` | `/employees`, `/employees/new`, `/employees/edit/:id` | 1.9k | auth |
| 9 | vendors | should-have | 3 | `pages/vendors/` | `/vendors`, `/vendors/new`, `/vendors/edit/:id` | 610 | auth, work-orders (full) |
| 10 | vendor-pos | should-have | 3 | `pages/vendor-pos/` | `/vendor-pos`, `/vendor-pos/:id` | 1k | auth, work-orders (full) |

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 202 KiB

View file

@ -33,6 +33,9 @@ export const API_PATHS = {
pocPatch: (id: string | number) => `workorders/${id}/poc`,
boardDetail: (id: string | number) => `workorders/${id}/detail`,
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`,
commentItem: (workOrderId: string | number, commentId: string | number) =>
`workorders/${workOrderId}/comments/${commentId}`,
@ -84,6 +87,7 @@ export const API_PATHS = {
create: "team-members",
detail: "team-members",
invite: (id: string | number) => `team-members/${encodeURIComponent(String(id))}/invite`,
myPermissions: "team-members/me/permissions",
},
teamMemberInvite: {
resolve: "team-member-invites/resolve",

View file

@ -6,6 +6,7 @@ import { ProtectedRoute } from "@/components/auth/protected-route";
import { AppSidebar } from "@/components/layout/app-sidebar";
import { getSidebarWidth } from "@/config/menu";
import { AppTopbar } from "@/components/layout/app-topbar";
import { SlaAtRiskBanner } from "@/components/notifications/sla-at-risk-banner";
import { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider";
export default function AdminShellLayout() {
@ -40,6 +41,7 @@ export default function AdminShellLayout() {
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
>
<AppTopbar onToggleNav={handleToggleNav} />
<SlaAtRiskBanner />
<Box
component="main"

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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)}`,
};
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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,
};
}

View file

@ -0,0 +1,17 @@
import { hasPermission, usePermissions } from "@/domain/auth/use-cases/use-permissions";
export interface CompletionTemplatePermissions {
canCreate: boolean;
canEdit: boolean;
canDelete: boolean;
}
/** Template actions follow the signed-in user's effective permissions, never their role alone. */
export function useCompletionTemplatePermissions(): CompletionTemplatePermissions {
const permissions = usePermissions();
return {
canCreate: hasPermission(permissions, "createCompletionDocTemplates"),
canEdit: hasPermission(permissions, "editCompletionDocTemplates"),
canDelete: hasPermission(permissions, "deleteCompletionDocTemplates"),
};
}

View 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>
);
}

View file

@ -0,0 +1,94 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Typography,
} from "@mui/material";
import { DIALOG_BLUR_BACKDROP_SX } from "@/components/ui/dialog-blur-backdrop";
import {
locationDefaultValues,
locationSchema,
type LocationFormValues,
} from "@/domain/locations/schemas/location-schema";
import { useCreateLocation } from "@/domain/locations/use-cases/use-create-location";
import {
DUPLICATE_SITE_CODE_MESSAGE,
isDuplicateSiteCodeError,
} from "@/domain/locations/utils/site-code-conflict";
import { SiteFormFields } from "./site-form-fields";
interface AddSiteDialogProps {
open: boolean;
onClose: () => void;
}
/** Add Site modal: closes on success; a duplicate Site Code stays open with an inline error. */
export function AddSiteDialog({ open, onClose }: AddSiteDialogProps) {
const createLocation = useCreateLocation();
const form = useForm<LocationFormValues>({
resolver: zodResolver(locationSchema),
defaultValues: locationDefaultValues,
});
const close = () => {
form.reset(locationDefaultValues);
createLocation.reset();
onClose();
};
const onSubmit = (values: LocationFormValues) => {
createLocation.mutate(values, {
onSuccess: close,
onError: (error) => {
if (isDuplicateSiteCodeError(error)) {
form.setError("name", { type: "server", message: DUPLICATE_SITE_CODE_MESSAGE });
}
},
});
};
return (
<Dialog
open={open}
onClose={() => {
if (!createLocation.isPending) {
close();
}
}}
fullWidth
maxWidth="sm"
aria-labelledby="add-site-title"
slotProps={{ backdrop: { sx: DIALOG_BLUR_BACKDROP_SX } }}
>
<Box component="form" noValidate onSubmit={form.handleSubmit(onSubmit)}>
<DialogTitle id="add-site-title">
Add Site
<Typography
variant="body2"
component="span"
className="block"
sx={{ color: "text.secondary" }}
>
Create a new client site record.
</Typography>
</DialogTitle>
<DialogContent dividers>
<SiteFormFields form={form} />
</DialogContent>
<DialogActions>
<Button variant="text" onClick={close} disabled={createLocation.isPending}>
Cancel
</Button>
<Button type="submit" variant="contained" disabled={createLocation.isPending}>
{createLocation.isPending ? "Adding..." : "Add Site"}
</Button>
</DialogActions>
</Box>
</Dialog>
);
}

View file

@ -0,0 +1,64 @@
import { useMemo, useState } from "react";
import { Controller, type Control, type FieldError } from "react-hook-form";
import { Autocomplete, TextField } from "@mui/material";
import type { AccountListItem } from "@/domain/accounts/types/account";
import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list";
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
import { useDebounce } from "@/hooks/use-debounce";
type ClientOption = Pick<AccountListItem, "id" | "name">;
interface ClientSelectProps {
control: Control<LocationFormValues>;
error?: FieldError;
/** Stored client, shown even when it is not in the first page of search results. */
initialClient?: ClientOption;
}
/** Searchable client (account) picker bound to `accountId`. */
export function ClientSelect({ control, error, initialClient }: ClientSelectProps) {
const [search, setSearch] = useState("");
const debouncedSearch = useDebounce(search, 300);
const { data, isLoading } = useAccountsList({ search: debouncedSearch, page: 1, pageSize: 50 });
const options = useMemo<ClientOption[]>(() => {
const items: ClientOption[] = data?.items ?? [];
const hasInitial =
initialClient !== undefined &&
items.some((item) => String(item.id) === String(initialClient.id));
return initialClient === undefined || hasInitial ? items : [initialClient, ...items];
}, [data?.items, initialClient]);
return (
<Controller
name="accountId"
control={control}
render={({ field }) => (
<Autocomplete<ClientOption>
options={options}
loading={isLoading}
getOptionLabel={(option) => option.name || String(option.id)}
isOptionEqualToValue={(option, value) => String(option.id) === String(value.id)}
value={options.find((option) => String(option.id) === field.value) ?? null}
onChange={(_event, option) => field.onChange(option ? String(option.id) : "")}
onBlur={field.onBlur}
onInputChange={(_event, value, reason) => {
if (reason === "input") {
setSearch(value);
}
}}
renderInput={(params) => (
<TextField
{...params}
label="Client"
required
placeholder="Search clients..."
error={Boolean(error)}
helperText={error?.message}
/>
)}
/>
)}
/>
);
}

View file

@ -202,7 +202,7 @@ export function LocationContactsFieldArray({
<Box ref={rootRef}>
<Stack direction="row" sx={{ alignItems: "center", justifyContent: "space-between" }}>
<Typography variant="subtitle2" className="font-semibold uppercase">
Contacts
Points of contact
</Typography>
<Button
size="small"
@ -210,7 +210,7 @@ export function LocationContactsFieldArray({
startIcon={<AddIcon fontSize="small" />}
onClick={() => append({ name: "", phone: "" })}
>
Add Contact
Add point of contact
</Button>
</Stack>
<Stack spacing={1.5} className="mt-2">

View file

@ -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 Location</DialogTitle>
<DialogContent>
<DialogContentText>
Are you sure you want to delete &quot;{target?.name}&quot;? This action 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>
);
}

View file

@ -1,143 +0,0 @@
import { Controller, type Control, type FieldErrors, type UseFormRegister } from "react-hook-form";
import {
Autocomplete,
FormControl,
InputLabel,
MenuItem,
Select,
Stack,
TextField,
Typography,
} from "@mui/material";
import type { AccountListItem } from "@/domain/accounts/types/account";
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
import { LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location";
import { LocationContactsFieldArray } from "./location-contacts-field-array";
import { StateSelect } from "./state-select";
interface SharedFieldsProps {
control: Control<LocationFormValues>;
errors: FieldErrors<LocationFormValues>;
register: UseFormRegister<LocationFormValues>;
}
interface LocationDetailsFieldsProps extends SharedFieldsProps {
accountOptions: AccountListItem[];
isLoadingAccounts: boolean;
onAccountSearchChange: (value: string) => void;
}
export function LocationDetailsFields({
accountOptions,
control,
errors,
isLoadingAccounts,
onAccountSearchChange,
register,
}: LocationDetailsFieldsProps) {
return (
<Stack spacing={2}>
<Typography variant="subtitle1" className="font-semibold">
Location Information
</Typography>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("name")}
label="Name"
required
error={Boolean(errors.name)}
helperText={errors.name?.message}
fullWidth
/>
<Controller
name="accountId"
control={control}
render={({ field }) => (
<Autocomplete
options={accountOptions}
loading={isLoadingAccounts}
getOptionLabel={(option: AccountListItem) => option.name || String(option.id)}
isOptionEqualToValue={(option, value) => String(option.id) === String(value.id)}
value={accountOptions.find((account) => String(account.id) === field.value) ?? null}
onChange={(_event, option) => field.onChange(option ? String(option.id) : "")}
onInputChange={(_event, value, reason) => {
if (reason === "input") {
onAccountSearchChange(value);
}
}}
renderInput={(params) => (
<TextField {...params} label="Account" placeholder="Search accounts..." />
)}
className="flex-1"
/>
)}
/>
</Stack>
<TextField
{...register("address")}
label="Address"
required
error={Boolean(errors.address)}
helperText={errors.address?.message}
fullWidth
/>
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("city")}
label="City"
required
error={Boolean(errors.city)}
helperText={errors.city?.message}
fullWidth
/>
<StateSelect control={control} error={errors.state} />
<TextField
{...register("zipCode")}
label="Zip Code"
required
error={Boolean(errors.zipCode)}
helperText={errors.zipCode?.message}
fullWidth
/>
</Stack>
</Stack>
);
}
export function LocationContactFields({ control, errors, register }: SharedFieldsProps) {
return (
<Stack spacing={2}>
<Typography variant="subtitle1" className="font-semibold">
Contact Information
</Typography>
<LocationContactsFieldArray control={control} errors={errors} register={register} />
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
<TextField
{...register("contactEmail")}
label="Contact Email"
type="email"
required
error={Boolean(errors.contactEmail)}
helperText={errors.contactEmail?.message}
fullWidth
/>
<Controller
name="status"
control={control}
render={({ field }) => (
<FormControl fullWidth required error={Boolean(errors.status)}>
<InputLabel id="status-label">Status</InputLabel>
<Select {...field} labelId="status-label" label="Status" value={field.value}>
{LOCATION_STATUS_OPTIONS.map((status) => (
<MenuItem key={status} value={status}>
{status}
</MenuItem>
))}
</Select>
</FormControl>
)}
/>
</Stack>
</Stack>
);
}

View file

@ -1,141 +0,0 @@
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useNavigate, useParams } from "react-router";
import { Alert, Box, Button, CircularProgress, Paper, Stack, Typography } from "@mui/material";
import { useAccountsList } from "@/domain/accounts/use-cases/use-accounts-list";
import { normalizeStateCode } from "@/data/areas";
import {
locationDefaultValues,
locationSchema,
type LocationFormValues,
} from "@/domain/locations/schemas/location-schema";
import { useCreateLocation } from "@/domain/locations/use-cases/use-create-location";
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
import { useUpdateLocation } from "@/domain/locations/use-cases/use-update-location";
import { useDebounce } from "@/hooks/use-debounce";
import { LocationContactFields, LocationDetailsFields } from "./location-form-fields";
export default function LocationFormPage() {
const { id } = useParams();
const navigate = useNavigate();
const isEdit = Boolean(id);
const [accountSearch, setAccountSearch] = useState("");
const debouncedAccountSearch = useDebounce(accountSearch, 300);
const { data: location, isLoading, error } = useLocationDetail(id);
const { data: accountsData, isLoading: isLoadingAccounts } = useAccountsList({
search: debouncedAccountSearch,
page: 1,
pageSize: 50,
});
const createLocation = useCreateLocation();
const updateLocation = useUpdateLocation();
const accountOptions = useMemo(() => accountsData?.items ?? [], [accountsData?.items]);
const {
register,
handleSubmit,
reset,
control,
formState: { errors },
} = useForm<LocationFormValues>({
resolver: zodResolver(locationSchema),
defaultValues: locationDefaultValues,
});
useEffect(() => {
if (location && isEdit) {
reset({
name: location.name ?? "",
accountId: location.accountId != null ? String(location.accountId) : "",
address: location.address ?? "",
city: location.city ?? "",
state: normalizeStateCode(location.state) ?? "",
zipCode: location.zipCode ?? "",
contacts:
location.contacts && location.contacts.length > 0
? location.contacts
: [{ name: location.contact ?? "", phone: location.phone ?? "" }],
contactEmail: location.contactEmail ?? "",
status: (location.status as LocationFormValues["status"]) || "Active",
});
}
}, [location, isEdit, reset]);
const isSaving = createLocation.isPending || updateLocation.isPending;
const onSubmit = (values: LocationFormValues) => {
if (isEdit && id) {
updateLocation.mutate({ id, values });
return;
}
createLocation.mutate(values);
};
if (isEdit && isLoading) {
return (
<Box className="flex justify-center p-10">
<CircularProgress />
</Box>
);
}
return (
<Box className="mx-auto max-w-4xl p-4">
<Paper variant="outlined" className="p-6">
<Stack spacing={4}>
<Stack
direction="row"
sx={{
justifyContent: "space-between",
alignItems: "center",
}}
>
<Typography variant="h5" component="h1">
{isEdit ? "Edit Location" : "Create a Location"}
</Typography>
<Button variant="text" onClick={() => navigate("/locations")}>
Back to list
</Button>
</Stack>
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load location"}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-6">
<LocationDetailsFields
accountOptions={accountOptions}
control={control}
errors={errors}
isLoadingAccounts={isLoadingAccounts}
onAccountSearchChange={setAccountSearch}
register={register}
/>
<LocationContactFields control={control} errors={errors} register={register} />
<Stack
direction="row"
spacing={2}
sx={{
justifyContent: "flex-end",
}}
>
<Button variant="outlined" onClick={() => navigate("/locations")} disabled={isSaving}>
Cancel
</Button>
<Button type="submit" variant="contained" disabled={isSaving}>
{isSaving ? "Saving..." : isEdit ? "Update Location" : "Create Location"}
</Button>
</Stack>
</Box>
</Stack>
</Paper>
</Box>
);
}

View file

@ -1,9 +1,6 @@
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
import {
Button,
CircularProgress,
IconButton,
Paper,
Table,
TableBody,
@ -11,7 +8,6 @@ import {
TableContainer,
TableHead,
TableRow,
Tooltip,
Typography,
} from "@mui/material";
import type { LocationListItem } from "@/domain/locations/types/location";
@ -20,84 +16,82 @@ import { StateAreaCell } from "./state-area-cell";
interface LocationsTableProps {
isLoading: boolean;
onDelete: (row: LocationListItem) => void;
onEdit: (row: LocationListItem) => void;
onOpen: (row: LocationListItem) => void;
rows: LocationListItem[];
selectedId?: string | null;
}
export function LocationsTable({ isLoading, onDelete, onEdit, rows }: LocationsTableProps) {
const COLUMN_COUNT = 7;
export function LocationsTable({
isLoading,
onOpen,
rows,
selectedId = null,
}: LocationsTableProps) {
return (
<TableContainer component={Paper} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="font-semibold">Location Name</TableCell>
<TableCell className="font-semibold">Address</TableCell>
<TableCell className="font-semibold">Site Code</TableCell>
<TableCell className="font-semibold">Client</TableCell>
<TableCell className="font-semibold">Street Address</TableCell>
<TableCell className="font-semibold">City</TableCell>
<TableCell className="font-semibold">State</TableCell>
<TableCell className="font-semibold">Phone</TableCell>
<TableCell className="font-semibold">Contact</TableCell>
<TableCell className="font-semibold">Status</TableCell>
<TableCell align="right" className="font-semibold">
Actions
</TableCell>
<TableCell className="font-semibold">POC Name</TableCell>
<TableCell className="font-semibold">POC Phone</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading ? (
{isLoading && (
<TableRow>
<TableCell colSpan={8} align="center" className="py-10">
<TableCell colSpan={COLUMN_COUNT} align="center" className="py-10">
<CircularProgress size={28} />
</TableCell>
</TableRow>
) : rows.length === 0 ? (
)}
{!isLoading && rows.length === 0 && (
<TableRow>
<TableCell colSpan={8} align="center" className="py-10">
<Typography sx={{ color: "text.secondary" }}>No locations found</Typography>
<TableCell colSpan={COLUMN_COUNT} align="center" className="py-10">
<Typography sx={{ color: "text.secondary" }}>No sites found</Typography>
</TableCell>
</TableRow>
) : (
)}
{!isLoading &&
rows.map((row) => (
<TableRow key={String(row.id)} hover>
<TableRow
key={String(row.id)}
hover
selected={String(row.id) === selectedId}
onClick={() => onOpen(row)}
className="cursor-pointer"
>
<TableCell>
<Button
variant="text"
size="small"
className="font-semibold"
onClick={() => onEdit(row)}
onClick={(event) => {
event.stopPropagation();
onOpen(row);
}}
>
{row.name || "—"}
</Button>
</TableCell>
<TableCell>{row.clientName || "—"}</TableCell>
<TableCell>{row.address || "—"}</TableCell>
<TableCell>{row.city || "—"}</TableCell>
<TableCell>
<StateAreaCell state={row.state} />
</TableCell>
<TableCell>{row.phone || "—"}</TableCell>
<TableCell>
<LocationsContactsCell row={row} />
</TableCell>
<TableCell>{row.status || "—"}</TableCell>
<TableCell align="right">
<Tooltip title="Edit">
<IconButton size="small" aria-label="Edit location" onClick={() => onEdit(row)}>
<EditOutlinedIcon fontSize="small" />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<IconButton
size="small"
aria-label="Delete location"
onClick={() => onDelete(row)}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
</Tooltip>
</TableCell>
<TableCell>{row.phone || "—"}</TableCell>
</TableRow>
))
)}
))}
</TableBody>
</Table>
</TableContainer>

View 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>
);
}

View file

@ -0,0 +1,68 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Box, Button, Stack } from "@mui/material";
import {
locationSchema,
type LocationFormValues,
} from "@/domain/locations/schemas/location-schema";
import type { Location } from "@/domain/locations/types/location";
import { useUpdateLocation } from "@/domain/locations/use-cases/use-update-location";
import { SiteFormFields } from "./site-form-fields";
import { toSiteFormValues } from "./site-form-values";
interface SiteDetailEditProps {
siteId: string;
location: Location;
/** Back to view mode: after Cancel (no API call) or after a successful save. */
onDone: () => void;
}
/** Edit mode of the site panel; Site Code is read-only, Save needs a valid, changed form. */
export function SiteDetailEdit({ siteId, location, onDone }: SiteDetailEditProps) {
const updateLocation = useUpdateLocation();
const form = useForm<LocationFormValues>({
resolver: zodResolver(locationSchema),
defaultValues: toSiteFormValues(location),
mode: "onChange",
});
const { isDirty, isValid } = form.formState;
const initialClient =
location.accountId != null && location.clientName
? { id: location.accountId, name: location.clientName }
: undefined;
const onSubmit = (values: LocationFormValues) => {
updateLocation.mutate({ id: siteId, values }, { onSuccess: onDone });
};
return (
<Box
component="form"
noValidate
onSubmit={form.handleSubmit(onSubmit)}
className="flex min-h-0 flex-1 flex-col"
aria-label="Edit site"
>
<Box className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
<SiteFormFields form={form} siteCodeReadOnly initialClient={initialClient} />
</Box>
<Stack
direction="row"
spacing={1}
className="border-t px-4 py-3"
sx={{ justifyContent: "flex-end", borderColor: "divider" }}
>
<Button variant="text" onClick={onDone} disabled={updateLocation.isPending}>
Cancel
</Button>
<Button
type="submit"
variant="contained"
disabled={!isDirty || !isValid || updateLocation.isPending}
>
{updateLocation.isPending ? "Saving..." : "Save changes"}
</Button>
</Stack>
</Box>
);
}

View file

@ -0,0 +1,132 @@
import { useContext, useEffect, useState } from "react";
import { Alert, Box, Button, CircularProgress, Drawer, Stack, Typography } from "@mui/material";
import { IconCloseButton } from "@/components/ui/icon-close-button";
import type { Location } from "@/domain/locations/types/location";
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 { SiteDetailView } from "./site-detail-view";
export const SITE_PANEL_WIDTH = 420;
interface SiteDetailPanelProps {
/** Selected site id; `null` closes the panel. */
siteId: string | null;
onClose: () => void;
}
function PanelHeader({ location, onClose }: { location?: Location; onClose: () => void }) {
return (
<Stack
direction="row"
className="border-b px-4 py-3"
sx={{ alignItems: "flex-start", justifyContent: "space-between", borderColor: "divider" }}
>
<Box>
<Typography variant="h6" component="h2">
{location?.name || "Site"}
</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{location?.clientName || " "}
</Typography>
</Box>
<IconCloseButton label="Close site details" onClick={onClose} />
</Stack>
);
}
function PanelBody({ siteId, onClose }: { siteId: string; onClose: () => void }) {
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);
return (
<>
<PanelHeader location={location} onClose={onClose} />
{isLoading && (
<Box className="flex justify-center p-8">
<CircularProgress size={28} />
</Box>
)}
{Boolean(error) && (
<Alert severity="error" className="m-4">
{error instanceof Error ? error.message : "Failed to load site"}
</Alert>
)}
{location !== undefined && mode === "edit" && (
<SiteDetailEdit siteId={siteId} location={location} onDone={() => setMode("view")} />
)}
{location !== undefined && mode === "view" && (
<>
<Box className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
<SiteDetailView location={location} />
</Box>
<Stack
direction="row"
spacing={1}
className="border-t px-4 py-3"
sx={{ justifyContent: "flex-end", borderColor: "divider" }}
>
{canDelete && (
<Button color="error" onClick={() => setIsDeleteOpen(true)} className="mr-auto">
Delete
</Button>
)}
<Button variant="contained" onClick={() => setMode("edit")}>
Edit
</Button>
</Stack>
<SiteDeleteDialog
open={isDeleteOpen}
siteId={siteId}
siteCode={location.name ?? ""}
onClose={() => setIsDeleteOpen(false)}
onDeleted={onClose}
/>
</>
)}
</>
);
}
/**
* Non-modal site panel: the table stays interactive, so clicking another row swaps the
* content in place. Escape closes it unless a popup inside already handled the key.
*/
export function SiteDetailPanel({ siteId, onClose }: SiteDetailPanelProps) {
const open = siteId !== null;
useEffect(() => {
if (!open) {
return undefined;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && !event.defaultPrevented) {
onClose();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, onClose]);
return (
<Drawer
anchor="right"
variant="persistent"
open={open}
slotProps={{
paper: {
role: "region",
"aria-label": "Site details",
className: "flex h-full flex-col border-l shadow-lg",
sx: { width: SITE_PANEL_WIDTH, maxWidth: "100vw" },
},
}}
>
{siteId !== null && <PanelBody key={siteId} siteId={siteId} onClose={onClose} />}
</Drawer>
);
}

View file

@ -0,0 +1,76 @@
import type { ReactNode } from "react";
import { Link, Stack, Typography } from "@mui/material";
import { getAreaForState } from "@/data/areas";
import type { Location } from "@/domain/locations/types/location";
import { buildSiteMapsUrl } from "@/domain/locations/utils/site-maps";
import { SiteMapPreview } from "./site-map-preview";
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<Stack spacing={0.25}>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{label}
</Typography>
<Typography variant="body2" component="div">
{children}
</Typography>
</Stack>
);
}
function formatState(state?: string): string {
const area = getAreaForState(state);
const code = state?.trim() ?? "";
if (!code) {
return "—";
}
return area === null ? code : `${code} · Area: ${area}`;
}
/** Read-only site details, in the prototype order. */
export function SiteDetailView({ location }: { location: Location }) {
const contacts = location.contacts ?? [];
return (
<Stack spacing={2}>
<Field label="Site Code">{location.name || "—"}</Field>
<Field label="Client">{location.clientName || "—"}</Field>
<Field label="Street Address">
{Boolean(location.address) && (
<Link
href={buildSiteMapsUrl(location)}
target="_blank"
rel="noopener noreferrer"
underline="hover"
>
{location.address}
</Link>
)}
{!location.address && "—"}
</Field>
<Stack direction="row" spacing={2}>
<Field label="City">{location.city || "—"}</Field>
<Field label="State">{formatState(location.state)}</Field>
</Stack>
<SiteMapPreview
address={location.address}
city={location.city}
state={location.state}
zipCode={location.zipCode}
/>
<Field label="Site Phone">{location.sitePhone || "—"}</Field>
<Field label="Site Email">{location.contactEmail || "—"}</Field>
<Field label="Points of contact">
{contacts.length === 0 && "—"}
<Stack component="ul" spacing={0.5} className="m-0 list-none p-0">
{contacts.map((contact, index) => (
<li key={contact.id ?? `${contact.name}-${index}`}>
{index === 0 ? "Main" : `Contact ${index + 1}`}: {contact.name}
{Boolean(contact.phone) && ` · ${contact.phone}`}
</li>
))}
</Stack>
</Field>
</Stack>
);
}

View file

@ -0,0 +1,111 @@
import { Controller, useController, useWatch, type UseFormReturn } from "react-hook-form";
import { Stack, TextField } from "@mui/material";
import { AddressAutocompleteField } from "@/components/common/address-autocomplete-field";
import { normalizeStateCode } from "@/data/areas";
import type { AccountListItem } from "@/domain/accounts/types/account";
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
import { ClientSelect } from "./client-select";
import { LocationContactsFieldArray } from "./location-contacts-field-array";
import { SiteMapPreview } from "./site-map-preview";
import { StateSelect } from "./state-select";
interface SiteFormFieldsProps {
form: UseFormReturn<LocationFormValues>;
/** Edit mode: Site Code is immutable after creation. */
siteCodeReadOnly?: boolean;
initialClient?: Pick<AccountListItem, "id" | "name">;
}
/** Shared Add Site / edit-mode fields, in the prototype order. */
export function SiteFormFields({
form,
siteCodeReadOnly = false,
initialClient,
}: SiteFormFieldsProps) {
const {
control,
register,
formState: { errors },
} = form;
const [address, city, state] = useWatch({ control, name: ["address", "city", "state"] });
const { field: addressField } = useController({ control, name: "address" });
const { field: cityField } = useController({ control, name: "city" });
const { field: stateField } = useController({ control, name: "state" });
return (
<Stack spacing={2}>
<Controller
name="name"
control={control}
render={({ field }) => (
<TextField
{...field}
onChange={(event) => field.onChange(event.target.value.toUpperCase())}
label="Site Code"
placeholder="e.g. AB12"
required={!siteCodeReadOnly}
disabled={siteCodeReadOnly}
error={Boolean(errors.name)}
helperText={
errors.name?.message ?? (siteCodeReadOnly ? "Site Code can't be changed" : undefined)
}
slotProps={{ htmlInput: { maxLength: 50, readOnly: siteCodeReadOnly } }}
fullWidth
/>
)}
/>
<ClientSelect control={control} error={errors.accountId} initialClient={initialClient} />
<AddressAutocompleteField
value={addressField.value}
onInputChange={addressField.onChange}
onSelect={(parts) => {
// One pick fills all three, each through its own controller so the
// form marks them dirty and revalidates (edit mode's Save depends on it).
addressField.onChange(parts.street);
cityField.onChange(parts.city);
stateField.onChange(normalizeStateCode(parts.state) ?? "");
}}
label="Street Address"
required
requiredMarker
error={Boolean(errors.address)}
helperText={
errors.address?.message ?? "Pick a suggestion to auto-fill City/State, or type your own."
}
/>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
{...cityField}
label="City"
required
error={Boolean(errors.city)}
helperText={errors.city?.message}
fullWidth
/>
<StateSelect control={control} error={errors.state} />
</Stack>
<SiteMapPreview address={address} city={city} state={state} />
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<TextField
{...register("sitePhone")}
label="Site Phone (optional)"
placeholder="(000) 000-0000"
error={Boolean(errors.sitePhone)}
helperText={errors.sitePhone?.message}
fullWidth
slotProps={{ htmlInput: { maxLength: 20 } }}
/>
<TextField
{...register("contactEmail")}
label="Site Email (optional)"
placeholder="site@company.com"
type="email"
error={Boolean(errors.contactEmail)}
helperText={errors.contactEmail?.message}
fullWidth
/>
</Stack>
<LocationContactsFieldArray control={control} errors={errors} register={register} />
</Stack>
);
}

View file

@ -0,0 +1,24 @@
import { normalizeStateCode } from "@/data/areas";
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
import type { Location } from "@/domain/locations/types/location";
/** Edit-mode defaults from a site; zip and title are carried so a save keeps them. */
export function toSiteFormValues(location: Location): LocationFormValues {
const contacts =
location.contacts && location.contacts.length > 0
? location.contacts
: [{ name: location.contact ?? "", phone: location.phone ?? "" }];
return {
name: location.name ?? "",
accountId: location.accountId != null ? String(location.accountId) : "",
address: location.address ?? "",
city: location.city ?? "",
state: normalizeStateCode(location.state) ?? "",
sitePhone: location.sitePhone ?? "",
contactEmail: location.contactEmail ?? "",
contacts,
zipCode: location.zipCode ?? "",
title: location.title ?? "",
};
}

View file

@ -0,0 +1,39 @@
import { Box, Typography } from "@mui/material";
import type { Location } from "@/domain/locations/types/location";
import { buildSiteMapEmbedUrl, hasCompleteSiteAddress } from "@/domain/locations/utils/site-maps";
type SiteMapPreviewProps = Pick<Location, "address" | "city" | "state" | "zipCode">;
/** Read-only key-less Google Maps embed; a placeholder until Street, City and State are set. */
export function SiteMapPreview(props: SiteMapPreviewProps) {
const complete = hasCompleteSiteAddress(props);
return (
<Box>
<Typography variant="caption" sx={{ color: "text.secondary" }}>
Location preview
</Typography>
{complete && (
<Box
component="iframe"
title="Site location map"
src={buildSiteMapEmbedUrl(props)}
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
className="mt-1 block w-full rounded border-0"
sx={{ height: 140 }}
/>
)}
{!complete && (
<Box
className="mt-1 flex items-center justify-center rounded border border-dashed"
sx={{ height: 140, borderColor: "divider" }}
>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
Enter an address to preview the location
</Typography>
</Box>
)}
</Box>
);
}

View file

@ -1 +0,0 @@
export { default } from "../_components/location-form";

View file

@ -1,5 +1,4 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { useCallback, useMemo, useState } from "react";
import {
Alert,
Box,
@ -10,25 +9,24 @@ import {
TextField,
Typography,
} 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 { useDebounce } from "@/hooks/use-debounce";
import { expandAreasToStateCodes, type AreaZone } from "@/data/areas";
import { AddSiteDialog } from "./_components/add-site-dialog";
import { AreaFacet } from "./_components/area-facet";
import { LocationDeleteDialog } from "./_components/location-delete-dialog";
import { LocationsTable } from "./_components/locations-table";
import { SITE_PANEL_WIDTH, SiteDetailPanel } from "./_components/site-detail-panel";
export default function LocationsListPage() {
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(12);
const [selectedAreas, setSelectedAreas] = useState<AreaZone[]>([]);
const [deleteTarget, setDeleteTarget] = useState<LocationListItem | null>(null);
const [isAddOpen, setIsAddOpen] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const debouncedSearch = useDebounce(search, 300);
const deleteLocation = useDeleteLocation();
const closePanel = useCallback(() => setSelectedId(null), []);
const stateCodes = useMemo(() => expandAreasToStateCodes(selectedAreas), [selectedAreas]);
@ -47,18 +45,11 @@ export default function LocationsListPage() {
const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0;
const handleDeleteConfirm = () => {
if (!deleteTarget) {
return;
}
deleteLocation.mutate(deleteTarget.id, {
onSuccess: () => setDeleteTarget(null),
});
};
return (
<Box className="flex flex-col gap-4 p-4">
<Box
className="flex flex-col gap-4 p-4"
sx={{ pr: { md: selectedId === null ? 2 : `${SITE_PANEL_WIDTH + 16}px` } }}
>
<Stack
direction={{ xs: "column", md: "row" }}
spacing={2}
@ -67,34 +58,17 @@ export default function LocationsListPage() {
justifyContent: "space-between",
}}
>
<Stack
direction="row"
spacing={2}
sx={{
alignItems: "baseline",
}}
>
<Stack direction="row" spacing={2} sx={{ alignItems: "baseline" }}>
<Typography variant="h5" component="h1">
Locations
Sites
</Typography>
<Typography
variant="body2"
sx={{
color: "text.secondary",
}}
>
<Typography variant="body2" sx={{ color: "text.secondary" }}>
{totalCount} total
</Typography>
{isFetching && !isLoading && <CircularProgress size={16} />}
</Stack>
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
sx={{
alignItems: "center",
}}
>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} sx={{ alignItems: "center" }}>
<AreaFacet
value={selectedAreas}
onChange={(zones) => {
@ -109,24 +83,24 @@ export default function LocationsListPage() {
setSearch(event.target.value);
setPage(0);
}}
placeholder="Search locations..."
placeholder="Search sites..."
className="min-w-[220px]"
/>
<Button variant="contained" onClick={() => navigate("/locations/new")}>
+ New Location
<Button variant="contained" onClick={() => setIsAddOpen(true)}>
Add Site
</Button>
</Stack>
</Stack>
{Boolean(error) && (
<Alert severity="error">
{error instanceof Error ? error.message : "Failed to load locations"}
{error instanceof Error ? error.message : "Failed to load sites"}
</Alert>
)}
<LocationsTable
isLoading={isLoading}
rows={rows}
onEdit={(row) => navigate(`/locations/edit/${row.id}`)}
onDelete={setDeleteTarget}
selectedId={selectedId}
onOpen={(row) => setSelectedId(String(row.id))}
/>
<TablePagination
component="div"
@ -140,12 +114,8 @@ export default function LocationsListPage() {
}}
rowsPerPageOptions={[12, 24, 48]}
/>
<LocationDeleteDialog
target={deleteTarget}
isPending={deleteLocation.isPending}
onClose={() => setDeleteTarget(null)}
onConfirm={handleDeleteConfirm}
/>
<AddSiteDialog open={isAddOpen} onClose={() => setIsAddOpen(false)} />
<SiteDetailPanel siteId={selectedId} onClose={closePanel} />
</Box>
);
}

View file

@ -1 +0,0 @@
export { default } from "./_components/location-form";

View file

@ -47,6 +47,7 @@ export const PERMISSION_LABELS: Record<string, string> = {
};
export const ALL_PERMISSION_KEYS = PERMISSION_GROUPS.flatMap((group) => group.keys);
export type PermissionKey = (typeof ALL_PERMISSION_KEYS)[number];
const DISPATCHER_DEFAULTS = new Set([
"createVendors",
"editVendors",

View file

@ -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,
};

View file

@ -16,14 +16,20 @@ import {
formatUpliftMoney,
isGrantedAmount,
} 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 { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
import { upliftAmount } from "@/domain/uplifts/utils/uplift-amount";
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
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 ADMIN_ONLY_REVOKE_TOOLTIP = "Only admins can revoke uplifts";
@ -236,65 +242,84 @@ function UpliftApprovalRow({
<AttachmentsCell row={row} onOpenAttachment={onOpenAttachment} />
</TableCell>
<TableCell>{row.requestedByVendorName || "—"}</TableCell>
{tab === "pending" ? (
<TableCell>
<WaitingCell row={row} />
</TableCell>
) : (
{tab === "pending" && (
<>
<TableCell>
<WaitingCell row={row} />
</TableCell>
<TableCell>
<PendingRowActions
row={row}
onApprove={onApprove}
onReject={onReject}
isDecisionPending={isDecisionPending}
/>
</TableCell>
</>
)}
{tab === "approved" && (
<>
<TableCell>{row.decidedByName || "—"}</TableCell>
<TableCell>{row.decidedAt ? formatDateTime(row.decidedAt) : "—"}</TableCell>
<TableCell>
<ApprovedRowActions
row={row}
canRevoke={canRevoke}
onRevoke={onRevoke}
isDecisionPending={isDecisionPending}
/>
</TableCell>
</>
)}
<TableCell>
{tab === "pending" ? (
<PendingRowActions
row={row}
onApprove={onApprove}
onReject={onReject}
isDecisionPending={isDecisionPending}
/>
) : (
<ApprovedRowActions
row={row}
canRevoke={canRevoke}
onRevoke={onRevoke}
isDecisionPending={isDecisionPending}
/>
)}
{tab === "rejected" && <RejectedRowCells row={row} />}
</TableRow>
);
}
function UpliftApprovalsEmptyState({ tab }: { tab: UpliftApprovalTab }) {
if (tab === "rejected") return <RejectedEmptyState />;
if (tab === "approved") {
return (
<Box>
<Text variant="description">No approved uplifts yet</Text>
<Text variant="caption" tone="muted">
Uplifts you approve are archived here for reference.
</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>
</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({
tab,
isLoading,
hasError,
rows,
canRevoke,
onOpenDetail,
@ -306,6 +331,7 @@ export function UpliftApprovalsTable({
}: {
tab: UpliftApprovalTab;
isLoading: boolean;
hasError: boolean;
rows: UpliftQueueItem[];
canRevoke: boolean;
onOpenDetail: (row: UpliftQueueItem) => void;
@ -315,7 +341,7 @@ export function UpliftApprovalsTable({
onOpenAttachment: (row: UpliftQueueItem) => void;
isDecisionPending: boolean;
}) {
const headerCells = tab === "pending" ? PENDING_HEADER_CELLS : APPROVED_HEADER_CELLS;
const headerCells = UPLIFT_APPROVAL_HEADER_CELLS[tab];
return (
<TableContainer component={Paper} variant="outlined">
<Table size="small" aria-label={`${tab} uplift requests`}>
@ -336,22 +362,7 @@ export function UpliftApprovalsTable({
</TableCell>
</TableRow>
) : rows.length === 0 ? (
<TableRow>
<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>
<UpliftApprovalsEmptyRow tab={tab} colSpan={headerCells.length} hasError={hasError} />
) : (
rows.map((row) => (
<UpliftApprovalRow

View file

@ -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>
);
}

View file

@ -1,9 +1,11 @@
import { useState } from "react";
import { Alert, Box, Chip, Stack, Tab, Tabs, TablePagination } from "@mui/material";
import {
UpliftApprovalsTable,
UPLIFT_APPROVAL_TABS,
isUpliftApprovalTab,
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 { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
import {
@ -16,6 +18,7 @@ import { useAuthContext } from "@/providers/auth-context";
import { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller";
import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
import { Text } from "@/components/ui/text";
import type { UpliftsQueueResult } from "@/domain/uplifts/types/uplift";
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({
tab,
pendingCount,
counts,
onChange,
}: {
tab: UpliftApprovalTab;
pendingCount: number;
counts: Record<UpliftApprovalTab, number | null>;
onChange: (_event: unknown, value: string) => void;
}) {
return (
<Tabs value={tab} onChange={onChange}>
<Tab
value="pending"
label={
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<span>Pending</span>
<Chip
size="small"
label={pendingCount}
aria-label={`${pendingCount} pending uplifts`}
/>
</Stack>
}
/>
<Tab value="approved" label="Approved" />
{UPLIFT_APPROVAL_TABS.map((value) => (
<Tab
key={value}
value={value}
label={
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<span>{TAB_LABELS[value]}</span>
<TabCountPill tab={value} count={counts[value]} />
</Stack>
}
/>
))}
</Tabs>
);
}
function UpliftApprovalsPagination({
tab,
count,
pendingPage,
approvedPage,
onPendingPageChange,
onApprovedPageChange,
page,
onPageChange,
}: {
tab: UpliftApprovalTab;
count: number;
pendingPage: number;
approvedPage: number;
onPendingPageChange: (page: number) => void;
onApprovedPageChange: (page: number) => void;
page: number;
onPageChange: (page: number) => void;
}) {
return (
<TablePagination
component="div"
count={count}
page={tab === "pending" ? pendingPage : approvedPage}
onPageChange={(_event, nextPage) =>
tab === "pending" ? onPendingPageChange(nextPage) : onApprovedPageChange(nextPage)
}
page={page}
onPageChange={(_event, nextPage) => onPageChange(nextPage)}
rowsPerPage={PAGE_SIZE}
rowsPerPageOptions={[PAGE_SIZE]}
/>
@ -108,22 +124,31 @@ function UpliftQueueError({ error }: { error: unknown }) {
export default function UpliftQueuePage() {
const [tab, setTab] = useState<UpliftApprovalTab>("pending");
const [pendingPage, setPendingPage] = useState(0);
const [approvedPage, setApprovedPage] = useState(0);
const [pages, setPages] = useState<Record<UpliftApprovalTab, number>>({
pending: 0,
approved: 0,
rejected: 0,
});
const { user } = useAuthContext();
const canRevokeUplifts = isAdminUser(user?.userRoles);
const pendingQuery = useUpliftsQueue({
status: "Pending",
page: pendingPage + 1,
page: pages.pending + 1,
pageSize: PAGE_SIZE,
});
const approvedQuery = useUpliftsQueue({
status: "Approved",
page: approvedPage + 1,
page: pages.approved + 1,
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 {
@ -138,13 +163,18 @@ export default function UpliftQueuePage() {
openDetail,
} = controller;
const activeQuery = tab === "pending" ? pendingQuery : approvedQuery;
const activeQuery = queries[tab];
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 handleTabChange = (_event: unknown, value: string) => {
if (value === "pending" || value === "approved") {
if (isUpliftApprovalTab(value)) {
setTab(value);
}
};
@ -152,11 +182,12 @@ export default function UpliftQueuePage() {
return (
<Box className="flex flex-col gap-4 p-4">
<UpliftApprovalsHeader pendingExposureTotal={pendingExposureTotal} />
<UpliftApprovalsTabs tab={tab} pendingCount={pendingCount} onChange={handleTabChange} />
<UpliftApprovalsTabs tab={tab} counts={counts} onChange={handleTabChange} />
<UpliftQueueError error={activeQuery.error} />
<UpliftApprovalsTable
tab={tab}
isLoading={activeQuery.isLoading}
hasError={Boolean(activeQuery.error)}
rows={rows}
canRevoke={canRevokeUplifts}
onOpenDetail={openDetail}
@ -166,14 +197,13 @@ export default function UpliftQueuePage() {
onOpenAttachment={openUpliftEvidence}
isDecisionPending={approvePending || rejectPending || revokePending}
/>
<UpliftApprovalsPagination
tab={tab}
count={activeQuery.data?.totalCount ?? 0}
pendingPage={pendingPage}
approvedPage={approvedPage}
onPendingPageChange={setPendingPage}
onApprovedPageChange={setApprovedPage}
/>
{activeTotal != null && (
<UpliftApprovalsPagination
count={activeTotal}
page={pages[tab]}
onPageChange={(page) => setPages((current) => ({ ...current, [tab]: page }))}
/>
)}
{detailItem != null && (
<UpliftDetailModal
item={detailItem}

View file

@ -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>
);
}

View file

@ -1,7 +1,9 @@
import { Text } from "@/components/ui/text";
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 { CompDocSafetyBullets } from "./comp-doc-safety-bullets";
type CompDocPreviewProcedureProps = {
row: WorkOrderTableRow;
@ -13,34 +15,9 @@ export function CompDocPreviewProcedure({ row, notes }: CompDocPreviewProcedureP
return (
<>
<ul style={{ listStyle: "disc", paddingLeft: 18, margin: "8px 0 0" }}>
{SAFETY_BULLETS.map((b, i) => (
<li
key={i}
style={{
fontSize: 12.5,
lineHeight: 1.5,
margin: "5px 0",
color: "#161E38",
}}
>
{b}
</li>
))}
</ul>
<CompDocSafetyBullets />
<Text
as="h3"
variant="heading"
sx={{
fontSize: 13,
fontWeight: 700,
color: "#161E38",
margin: "16px 0 4px",
}}
>
{proc.heading}
</Text>
<CompDocProcedureHeading>{proc.heading}</CompDocProcedureHeading>
<Text
variant="body"
sx={{
@ -56,18 +33,7 @@ export function CompDocPreviewProcedure({ row, notes }: CompDocPreviewProcedureP
{notes.length > 0 && (
<>
<Text
as="h3"
variant="heading"
sx={{
fontSize: 13,
fontWeight: 700,
color: "#161E38",
margin: "16px 0 4px",
}}
>
PM Comments
</Text>
<CompDocProcedureHeading>PM Comments</CompDocProcedureHeading>
<Text
variant="body"
sx={{

View file

@ -1,5 +1,6 @@
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
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 { CompDocPreviewBadge } from "./comp-doc-preview-badge";
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"
style={{ padding: "0 20px 20px" }}
>
<div
className="relative break-words"
style={{
background: "#ffffff",
borderRadius: 8,
boxShadow: "0 1px 3px rgba(0,0,0,.08), 0 12px 32px -16px rgba(0,0,0,.25)",
overflow: "hidden",
fontFamily: "var(--font-sans)",
overflowWrap: "break-word",
wordBreak: "break-word",
}}
<CompDocDocumentFrame
dimmed={isNotRequired}
overlay={isNotRequired && <CompDocNotRequiredStamp />}
>
<div
style={{
opacity: isNotRequired ? 0.5 : 1,
filter: isNotRequired ? "grayscale(0.4)" : "none",
}}
>
<div
style={{
height: 12,
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
}}
/>
<div style={{ padding: "0 28px 28px" }}>
<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>
<CompDocPreviewHeader />
<CompDocPreviewSummary
row={row}
site={site}
generatedAt={generatedAt}
isGenerated={isGenerated}
/>
<CompDocPreviewProcedure row={row} notes={notes} />
</CompDocDocumentFrame>
</div>
</div>
);

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -109,7 +109,11 @@ export function AdvancedSearchFilterSections({
/>
</AdvSection>
<AdvSection icon={CheckCircle2} label="Status" count={draft.statuses.size}>
<AdvSection
icon={CheckCircle2}
label="Status"
count={draft.statuses.size + (draft.pastDue ? 1 : 0)}
>
{selectAllRow("statuses", [...ALL_WIZARD_STATUSES], draft.statuses.size)}
<div className="flex flex-wrap gap-1.5">
{ALL_WIZARD_STATUSES.map((status) => (
@ -122,6 +126,14 @@ export function AdvancedSearchFilterSections({
onClick={() => toggleIn("statuses", status)}
/>
))}
<AdvStatusPill
label="Past Due"
dotColor="var(--status-pastdue-text)"
selected={draft.pastDue}
selectedBg="var(--status-pastdue-bg)"
selectedText="var(--status-pastdue-text)"
onClick={() => setDraft((prev) => ({ ...prev, pastDue: !prev.pastDue }))}
/>
</div>
</AdvSection>

View file

@ -1,5 +1,5 @@
import type { Location } from "@/domain/locations/types/location";
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
import { buildSiteMapsUrl } from "@/domain/locations/utils/site-maps";
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
@ -40,6 +40,5 @@ export function resolveLocationId(
}
export function buildMapsUrl(location: Location): string {
const query = formatLocationAddressPreview(location);
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`;
return buildSiteMapsUrl(location);
}

View file

@ -3,13 +3,9 @@ import { Text } from "@/components/ui/text";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
import type { Location } from "@/domain/locations/types/location";
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
import { buildSiteMapsUrl } from "@/domain/locations/utils/site-maps";
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
function buildMapsUrl(location: Location): string {
const query = formatLocationAddressPreview(location);
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`;
}
type WizardStepLocationSiteDetailsProps = {
selectedLoc: LocationOption | undefined;
locationName: string;
@ -58,7 +54,7 @@ export function WizardStepLocationSiteDetails({
)}
{Boolean(addressPreview) && (
<a
href={buildMapsUrl(locationDetail)}
href={buildSiteMapsUrl(locationDetail)}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"

View file

@ -6,9 +6,9 @@ import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import type { WorkOrderBoardQueryParams } from "@/domain/work-orders/types/work-order-board";
import {
clearAdvancedFilterGroup,
cloneAdvancedFilters,
countAdvancedFilters,
getInitialAdvancedFilters,
resolveAppliedAdvancedFilters,
} from "@/domain/work-orders/utils/advanced-filters-utils";
import { toBoardQueryParams } from "@/domain/work-orders/utils/board-query-params";
import { asDayPageSize, DEFAULT_DAY_PAGE_SIZE } from "@/domain/work-orders/utils/day-pagination";
@ -90,7 +90,7 @@ export function useWorkOrdersListFilters() {
};
const applyAdvancedFilters = (filters: WorkOrderAdvancedFilters) => {
setAdvApplied(cloneAdvancedFilters(filters));
setAdvApplied(resolveAppliedAdvancedFilters(filters));
setPage(0);
};

View file

@ -13,6 +13,11 @@ type AddressAutocompleteFieldProps = {
label?: string;
placeholder?: string;
required?: boolean;
/**
* Show MUI's required asterisk on the label. Sites marks required fields that
* way; Vendors spells "(required)" in the label and asserts no asterisk.
*/
requiredMarker?: boolean;
error?: boolean;
helperText?: string;
disabled?: boolean;
@ -35,6 +40,7 @@ export function AddressAutocompleteField({
label = "Street Address",
placeholder = "Start typing the street address…",
required = false,
requiredMarker = false,
error = false,
helperText,
disabled = false,
@ -74,9 +80,9 @@ export function AddressAutocompleteField({
error={error}
helperText={helperText}
fullWidth
// Required is set on the input itself, never via MUI's `required`
// prop: this codebase spells the requirement in the label text and
// asserts that no generated asterisk renders anywhere.
required={requiredMarker}
// Required is set on the input itself; MUI's `required` prop only
// adds the label asterisk, which Vendors must not render.
slotProps={{
...params.slotProps,
htmlInput: { ...params.slotProps?.htmlInput, required },

View file

@ -1,3 +1,4 @@
import { isDismissable } from "@/domain/notifications/utils/visible-notification-sections";
import type { NotificationCenter } from "./use-notification-center";
type NotificationFeedActionsProps = {
@ -6,7 +7,8 @@ type NotificationFeedActionsProps = {
export function NotificationFeedActions({ center }: NotificationFeedActionsProps) {
const hasUnread = center.unreadIds.size > 0;
const hasItems = center.sections.length > 0;
// Rows that need acknowledging are never cleared, so they alone do not enable "Clear all".
const hasItems = center.sections.some((section) => section.items.some(isDismissable));
return (
<>

View file

@ -79,6 +79,11 @@ export function NotificationFeedList({ center }: NotificationFeedListProps) {
unread={center.unreadIds.has(item.id)}
onOpen={center.open}
onDismiss={center.dismiss}
onAcknowledge={center.acknowledge}
acknowledging={
item.target.workOrderId != null &&
item.target.workOrderId === center.acknowledgingWorkOrderId
}
/>
))}
</ul>

View file

@ -53,8 +53,37 @@ type NotificationRowProps = {
unread: boolean;
onOpen: (item: NotificationItem, url?: string) => void;
onDismiss: (item: NotificationItem) => void;
onAcknowledge?: (item: NotificationItem) => void;
acknowledging?: boolean;
};
/** A missed response deadline cannot be dismissed; acknowledging it is the only way out of the feed. */
function AcknowledgeButton({
item,
onAcknowledge,
acknowledging = false,
}: Pick<NotificationRowProps, "item" | "onAcknowledge" | "acknowledging">) {
return (
<button
type="button"
onClick={() => onAcknowledge?.(item)}
disabled={acknowledging}
title="Acknowledge"
aria-label="Acknowledge SLA breach"
className="shrink-0 cursor-pointer rounded-md border transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
style={{
fontSize: 11,
fontWeight: 600,
padding: "2px 8px",
color: "var(--destructive)",
borderColor: "var(--destructive)",
}}
>
{acknowledging ? "Acknowledging…" : "Acknowledge"}
</button>
);
}
/** A vendor conflict spans several work orders, so each one stays reachable from the row. */
function WorkOrderLinks({ item, onOpen }: Pick<NotificationRowProps, "item" | "onOpen">) {
if (item.reason !== "vendor-conflict" || item.workOrders.length === 0) {
@ -84,7 +113,14 @@ function WorkOrderLinks({ item, onOpen }: Pick<NotificationRowProps, "item" | "o
);
}
export function NotificationRow({ item, unread, onOpen, onDismiss }: NotificationRowProps) {
export function NotificationRow({
item,
unread,
onOpen,
onDismiss,
onAcknowledge,
acknowledging,
}: NotificationRowProps) {
return (
<li
className="group flex items-start gap-2.5 transition-colors hover:bg-[var(--color-bg-muted)]"
@ -136,6 +172,13 @@ export function NotificationRow({ item, unread, onOpen, onDismiss }: Notificatio
<X size={13} aria-hidden />
</button>
)}
{item.rowType === "acknowledge" && (
<AcknowledgeButton
item={item}
onAcknowledge={onAcknowledge}
acknowledging={acknowledging}
/>
)}
</li>
);
}

View file

@ -0,0 +1,90 @@
import { useEffect, useMemo } from "react";
import { AlertTriangle, ChevronRight, X } from "lucide-react";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import type { SlaAtRiskWorkOrder } from "@/domain/notifications/types/notification-feed";
import { useNotificationSession } from "@/domain/notifications/session/notification-session-context";
import { useNotificationFeed } from "@/domain/notifications/use-cases/use-notification-feed";
import { slaBannerText, slaHalfwayToastText } from "@/domain/notifications/utils/sla-alert-copy";
import { buildWorkOrderIdsHref } from "@/domain/work-orders/utils/work-order-ids-filter";
const NO_WORK_ORDERS: SlaAtRiskWorkOrder[] = [];
/** Toasts each at-risk work order once per session, the first time the feed reports it. */
function useSlaHalfwayToasts(atRisk: SlaAtRiskWorkOrder[]) {
const { claimSlaToasts } = useNotificationSession();
useEffect(() => {
const claimed = new Set(claimSlaToasts(atRisk.map((workOrder) => workOrder.id)));
atRisk
.filter((workOrder) => claimed.has(workOrder.id))
.forEach((workOrder) =>
toast.warn(slaHalfwayToastText(workOrder), { toastId: `sla-at-risk-${workOrder.id}` }),
);
}, [atRisk, claimSlaToasts]);
}
/**
* One line at the top of every page while any of the user's Reactive/Emergency work orders is past
* half of its response window. Clicking it opens Work Orders on exactly those work orders; dismissing
* hides them for this session, and a work order that becomes at risk later brings the banner back.
*/
export function SlaAtRiskBanner() {
const navigate = useNavigate();
const { data } = useNotificationFeed();
const { slaBannerDismissedIds, dismissSlaBanner } = useNotificationSession();
const atRisk = data?.slaAtRisk ?? NO_WORK_ORDERS;
useSlaHalfwayToasts(atRisk);
const shown = useMemo(
() => atRisk.filter((workOrder) => !slaBannerDismissedIds.has(workOrder.id)),
[atRisk, slaBannerDismissedIds],
);
if (shown.length === 0) {
return null;
}
const ids = shown.map((workOrder) => workOrder.id);
return (
<div
role="region"
aria-label="Work orders at risk of missing their response deadline"
className="flex shrink-0 items-center"
style={{
gap: 10,
padding: "6px 16px",
background: "var(--destructive)",
color: "var(--destructive-foreground)",
}}
>
<button
type="button"
onClick={() => navigate(buildWorkOrderIdsHref(ids))}
title="View in Work Orders"
className="flex min-w-0 flex-1 cursor-pointer items-center text-left"
style={{ color: "inherit" }}
>
<span
className="inline-flex min-w-0 items-center transition-colors hover:bg-white/20"
style={{ gap: 8, padding: "3px 8px", marginLeft: -8, borderRadius: 4 }}
>
<AlertTriangle size={14} className="shrink-0" aria-hidden />
<span className="truncate" style={{ fontSize: 12.5, fontWeight: 600 }}>
{slaBannerText(shown)}
</span>
<ChevronRight size={14} className="shrink-0" aria-hidden />
</span>
</button>
<button
type="button"
onClick={() => dismissSlaBanner(ids)}
title="Dismiss"
aria-label="Dismiss SLA alert"
className="flex shrink-0 cursor-pointer items-center justify-center rounded transition-colors hover:bg-white/20"
style={{ width: 22, height: 22, color: "inherit" }}
>
<X size={14} aria-hidden />
</button>
</div>
);
}

View file

@ -2,6 +2,7 @@ import { useMemo } from "react";
import { useNavigate } from "react-router";
import type { NotificationItem } from "@/domain/notifications/types/notification-feed";
import { useNotificationSession } from "@/domain/notifications/session/notification-session-context";
import { useAcknowledgeSlaBreach } from "@/domain/notifications/use-cases/use-acknowledge-sla-breach";
import { useNotificationFeed } from "@/domain/notifications/use-cases/use-notification-feed";
import { notificationTargetUrl } from "@/domain/notifications/utils/notification-target-url";
import {
@ -26,6 +27,13 @@ export function useNotificationCenter(onNavigate?: () => void) {
[items, readIds],
);
const acknowledgeBreach = useAcknowledgeSlaBreach();
const acknowledge = (item: NotificationItem) => {
if (item.rowType !== "acknowledge" || item.target.workOrderId == null) return;
acknowledgeBreach.mutate(item.target.workOrderId);
};
const acknowledgingWorkOrderId = acknowledgeBreach.isPending ? acknowledgeBreach.variables : null;
const open = (item: NotificationItem, url = notificationTargetUrl(item, user?.id)) => {
markRead([item.id]);
if (url == null) return;
@ -41,6 +49,8 @@ export function useNotificationCenter(onNavigate?: () => void) {
error: feed.error,
open,
dismiss: (item: NotificationItem) => dismiss([item.id]),
acknowledge,
acknowledgingWorkOrderId,
dismissAll: () => dismiss(items.filter(isDismissable).map((item) => item.id)),
markAllRead: () => markRead(items.map((item) => item.id)),
};

View file

@ -63,7 +63,10 @@ export const navGroups: NavGroup[] = [
},
{
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",

View file

@ -0,0 +1,17 @@
import { z } from "zod";
import { apiGet } from "@/api/api";
import { API_PATHS } from "@/api/api-paths";
import { handleApiResponse } from "@/api/handle-api-response";
export const UNEXPECTED_PERMISSIONS_RESPONSE =
"The server sent an unexpected permissions response.";
const effectivePermissionsSchema = z.object({ permissions: z.array(z.string()) });
/** Keys the signed-in user holds after role defaults and their individual overrides. */
export async function getMyPermissions(): Promise<string[]> {
const data = await apiGet<unknown>(API_PATHS.teamMember.myPermissions);
const result = effectivePermissionsSchema.safeParse(handleApiResponse<unknown>(data));
if (!result.success) throw new Error(UNEXPECTED_PERMISSIONS_RESPONSE);
return result.data.permissions;
}

View file

@ -0,0 +1,32 @@
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
import type { PermissionKey } from "@/app/(protected)/team-members/_components/team-member-permissions";
import { getMyPermissions } from "@/domain/auth/api/permissions-api";
import { queryKeys } from "@/infra/query-key/query-key";
import { useAuthContext } from "@/providers/auth-context";
/**
* The signed-in user's effective permissions as the server computes them. Keyed by user so a
* different sign-in never reads another person's answer, and refetched on every mount and window
* focus so an override change applies on the next page load.
*/
export function usePermissions(): UseQueryResult<string[], Error> {
const { user } = useAuthContext();
const userId = user?.id ?? null;
return useQuery({
queryKey: queryKeys.auth.permissions(userId ?? ""),
queryFn: getMyPermissions,
enabled: userId !== null,
staleTime: 0,
refetchOnWindowFocus: true,
meta: { suppressErrorToast: true },
});
}
/** Holds only for a successful server answer that lists the key; loading and errors deny. */
export function hasPermission(query: UseQueryResult<string[], Error>, key: PermissionKey): boolean {
return !query.isError && query.data?.includes(key) === true;
}
export function useHasPermission(key: PermissionKey): boolean {
return hasPermission(usePermissions(), key);
}

View file

@ -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);
},
};

View file

@ -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"}`;
}

View file

@ -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 !== ""),
};
}

View file

@ -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>;

View 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[];
}

View file

@ -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"),
});
}

View file

@ -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",
});
}

View file

@ -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);
}

View file

@ -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" });
}

View file

@ -6,10 +6,12 @@ import {
mapLocationListItems,
mapLocationSites,
mapLocationToBackend,
mapSiteOpenWorkOrders,
} from "@/domain/locations/mappers/location-mapper";
import type {
Location,
LocationSite,
SiteOpenWorkOrders,
LocationsListParams,
LocationsListResult,
} from "@/domain/locations/types/location";
@ -74,4 +76,9 @@ export const locationsApi = {
delete: async (id: string | number): Promise<void> => {
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));
},
};

View file

@ -3,6 +3,7 @@ import type {
LocationContact,
LocationListItem,
LocationSite,
SiteOpenWorkOrders,
} from "@/domain/locations/types/location";
function readString(record: Record<string, unknown>, ...keys: string[]): string {
@ -18,6 +19,14 @@ function readString(record: Record<string, unknown>, ...keys: string[]): string
return "";
}
/** Like readString, but an empty value becomes undefined. */
function readOptionalString(
record: Record<string, unknown>,
...keys: string[]
): string | undefined {
return readString(record, ...keys) || undefined;
}
function readId(record: Record<string, unknown>): string | number {
const value = record.id ?? record.Id;
if (typeof value === "string" || typeof value === "number") {
@ -123,6 +132,8 @@ export function mapLocationListItem(raw: unknown): LocationListItem {
return {
id: readId(item),
name: readString(item, "name", "Name"),
clientName: readString(item, "clientName", "ClientName", "accountName", "AccountName"),
sitePhone: readString(item, "phone", "Phone"),
address: readString(item, "address", "Address"),
city: readString(item, "city", "City"),
state: readString(item, "state", "State"),
@ -154,21 +165,30 @@ export function mapLocation(raw: unknown): Location {
const contacts = mapLocationContacts(item.contacts ?? item.Contacts);
const mainContact = contacts[0];
const legacyPhone = readString(item, "phone", "Phone") || undefined;
const legacyContact =
readString(item, "contact", "Contact", "contactPerson", "ContactPerson") || undefined;
const legacyPhone = readOptionalString(item, "phone", "Phone");
const legacyContact = readOptionalString(
item,
"contact",
"Contact",
"contactPerson",
"ContactPerson",
);
return {
id: readId(item) || undefined,
name: readString(item, "name", "Name") || undefined,
address: address || undefined,
city: readString(item, "city", "City") || undefined,
state: readString(item, "state", "State") || undefined,
zipCode: readString(item, "zipCode", "ZipCode", "Zip") || undefined,
name: readOptionalString(item, "name", "Name"),
title: readOptionalString(item, "title", "Title"),
clientName: readOptionalString(item, "clientName", "ClientName", "accountName", "AccountName"),
sitePhone: legacyPhone,
notes: readOptionalString(item, "notes", "Notes"),
address,
city: readOptionalString(item, "city", "City"),
state: readOptionalString(item, "state", "State"),
zipCode: readOptionalString(item, "zipCode", "ZipCode", "Zip"),
phone: mainContact ? mainContact.phone : legacyPhone,
contact: mainContact ? mainContact.name : legacyContact,
contactEmail: readString(item, "contactEmail", "ContactEmail") || undefined,
status: readString(item, "status", "Status") || undefined,
contactEmail: readOptionalString(item, "contactEmail", "ContactEmail"),
status: readOptionalString(item, "status", "Status"),
accountId: readOptionalId(item) || undefined,
contacts: contacts.length > 0 ? contacts : undefined,
};
@ -196,6 +216,10 @@ export function mapContactsToBackend(raw: unknown): LocationContact[] {
.filter((contact) => contact.name !== "" || contact.phone !== "");
}
/**
* Create/update payload. `phone` is the Site Phone (independent of the contacts); `status`,
* `title` and `notes` are sent only when present so an update keeps the stored values.
*/
export function mapLocationToBackend(location: unknown): Record<string, unknown> {
const item = (typeof location === "object" && location !== null ? location : {}) as Record<
string,
@ -206,19 +230,25 @@ export function mapLocationToBackend(location: unknown): Record<string, unknown>
const mainContact = contacts[0];
const payload: Record<string, unknown> = {
name: item.name ?? "",
name: typeof item.name === "string" ? item.name.trim() : "",
address: item.address ?? "",
city: item.city ?? "",
state: item.state ?? "",
zipCode: item.zipCode ?? "",
phone: mainContact ? mainContact.phone : (item.phone ?? ""),
contact: mainContact ? mainContact.name : (item.contact ?? ""),
contactEmail: item.contactEmail ?? "",
status: item.status ?? "Active",
phone: typeof item.sitePhone === "string" ? item.sitePhone.trim() : "",
// A caller without a contacts list (e.g. address-only sync) keeps the stored legacy name.
contact: mainContact ? mainContact.name : readString(item, "contact").trim(),
contactEmail: typeof item.contactEmail === "string" ? item.contactEmail.trim() : "",
accountId: item.accountId ?? "",
contacts,
};
for (const key of ["status", "title", "notes"] as const) {
if (typeof item[key] === "string") {
payload[key] = item[key];
}
}
const id = item.id ?? item.Id;
if (typeof id === "string" || typeof id === "number") {
payload.id = id;
@ -226,3 +256,16 @@ export function mapLocationToBackend(location: unknown): Record<string, unknown>
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,
};
}

View file

@ -1,29 +1,43 @@
import { z } from "zod";
import { US_STATE_CODES } from "@/data/areas";
import { LOCATION_STATUS_OPTIONS } from "@/domain/locations/types/location";
export const locationContactSchema = z.object({
id: z.number().int().positive().optional(),
name: z
.string()
.trim()
.min(1, "Contact person is required")
.max(100, "Contact person cannot exceed 100 characters"),
phone: z.string().trim().min(1, "Phone is required").max(20, "Phone cannot exceed 20 characters"),
.min(1, "Contact name is required")
.max(100, "Contact name cannot exceed 100 characters"),
phone: z
.string()
.trim()
.min(1, "Contact phone is required")
.max(20, "Contact phone cannot exceed 20 characters"),
});
/** Add Site / Site detail edit form. `zipCode` and `title` are carried through, not edited. */
export const locationSchema = z.object({
name: z.string().min(1, "Name is required"),
accountId: z.string().optional(),
address: z.string().min(1, "Address is required"),
city: z.string().min(1, "City is required"),
name: z
.string()
.trim()
.min(1, "Site Code is required")
.max(50, "Site Code cannot exceed 50 characters"),
accountId: z.string().min(1, "Client is required"),
address: z.string().trim().min(1, "Street Address is required"),
city: z.string().trim().min(1, "City is required"),
state: z
.union([z.enum(US_STATE_CODES), z.literal("")])
.refine((state): boolean => state !== "", { message: "State is required" }),
zipCode: z.string().min(1, "Zip code is required"),
sitePhone: z.string().trim().max(20, "Site Phone cannot exceed 20 characters"),
contactEmail: z
.string()
.trim()
.refine((value) => value === "" || z.email().safeParse(value).success, {
message: "Invalid email address",
}),
contacts: z.array(locationContactSchema).min(1, "At least one contact is required"),
contactEmail: z.string().min(1, "Contact email is required").email("Invalid email address"),
status: z.enum(LOCATION_STATUS_OPTIONS, { message: "Status is required" }),
zipCode: z.string(),
title: z.string(),
});
export type LocationFormValues = z.infer<typeof locationSchema>;
@ -35,8 +49,9 @@ export const locationDefaultValues: LocationFormValues = {
address: "",
city: "",
state: "",
zipCode: "",
contacts: [{ name: "", phone: "" }],
sitePhone: "",
contactEmail: "",
status: "Active",
contacts: [{ name: "", phone: "" }],
zipCode: "",
title: "",
};

View file

@ -10,6 +10,10 @@ export interface LocationContact {
export interface LocationListItem {
id: string | number;
name: string;
/** Client (account) display name. */
clientName: string;
/** Site Phone, independent of the contacts. */
sitePhone: string;
address: string;
city: string;
state: string;
@ -26,6 +30,10 @@ export interface LocationListItem {
export interface Location {
id?: string | number;
name?: string;
title?: string;
clientName?: string;
sitePhone?: string;
notes?: string;
address?: string;
city?: string;
state?: string;
@ -62,4 +70,8 @@ export interface LocationsListResult {
pageSize: number;
}
export const LOCATION_STATUS_OPTIONS = ["Active", "Inactive", "Closed"] as const;
/** GET locations/{id}/open-work-orders: work orders not Completed or Canceled (ids capped). */
export interface SiteOpenWorkOrders {
count: number;
workOrderIds: number[];
}

View file

@ -1,24 +1,27 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import { locationsApi } from "@/domain/locations/api/locations-api";
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
import type { Location } from "@/domain/locations/types/location";
import { isDuplicateSiteCodeError } from "@/domain/locations/utils/site-code-conflict";
import { queryKeys } from "@/infra/query-key/query-key";
export function useCreateLocation(): UseMutationResult<Location, Error, LocationFormValues> {
const navigate = useNavigate();
/** Creates a site. A duplicate Site Code is left to the caller to show inline (no toast). */
export function useCreateLocation(): UseMutationResult<void, Error, LocationFormValues> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (values: LocationFormValues) => locationsApi.create(values),
mutationFn: async (values: LocationFormValues) => {
await locationsApi.create({ ...values, status: "Active" });
},
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all });
toast.success("Location created successfully!");
navigate("/locations");
toast.success("Site added");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to create location");
if (isDuplicateSiteCodeError(error)) {
return;
}
toast.error(error.message || "Failed to add site");
},
});
}

View file

@ -10,10 +10,10 @@ export function useDeleteLocation(): UseMutationResult<void, Error, string | num
mutationFn: (id: string | number) => locationsApi.delete(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all });
toast.success("Location deleted successfully!");
toast.success("Site deleted");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to delete location");
toast.error(error.message || "Failed to delete site");
},
});
}

View 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,
});
}

View file

@ -1,9 +1,7 @@
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import { locationsApi } from "@/domain/locations/api/locations-api";
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
import type { Location } from "@/domain/locations/types/location";
import { queryKeys } from "@/infra/query-key/query-key";
interface UpdateLocationInput {
@ -11,20 +9,25 @@ interface UpdateLocationInput {
values: LocationFormValues;
}
export function useUpdateLocation(): UseMutationResult<Location, Error, UpdateLocationInput> {
const navigate = useNavigate();
/** Saves a site. The response carries no site, so the list and detail are refetched. */
export function useUpdateLocation(): UseMutationResult<void, Error, UpdateLocationInput> {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, values }: UpdateLocationInput) => locationsApi.update(id, values),
onSuccess: (_data, variables) => {
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.all });
void queryClient.invalidateQueries({ queryKey: queryKeys.locations.detail(variables.id) });
toast.success("Location updated successfully!");
navigate("/locations");
mutationFn: async ({ id, values }: UpdateLocationInput) => {
await locationsApi.update(id, values);
},
onSuccess: async (_data, variables) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.locations.all }),
queryClient.invalidateQueries({
queryKey: queryKeys.locations.detail(String(variables.id)),
}),
]);
toast.success("Site updated");
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update location");
toast.error(error.message || "Failed to update site");
},
});
}

View file

@ -0,0 +1,14 @@
import { isHTTPError } from "ky";
export const DUPLICATE_SITE_CODE_MESSAGE = "This site code already exists.";
/** The API answers a duplicate Site Code (same client, any case) with 409 DuplicateSiteCode. */
export function isDuplicateSiteCodeError(error: unknown): boolean {
if (!isHTTPError(error)) {
return false;
}
const data = error.data;
const code =
data && typeof data === "object" ? (data as Record<string, unknown>).code : undefined;
return error.response.status === 409 || code === "DuplicateSiteCode";
}

View file

@ -0,0 +1,23 @@
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
import type { Location } from "@/domain/locations/types/location";
type SiteAddress = Pick<Location, "address" | "city" | "state" | "zipCode">;
/** Google Maps search link for a site address; opens in a new tab. */
export function buildSiteMapsUrl(location: SiteAddress): string {
const query = formatLocationAddressPreview(location);
return `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`;
}
/** True once Street, City and State are all filled, so a map preview can render. */
export function hasCompleteSiteAddress(location: SiteAddress): boolean {
return [location.address, location.city, location.state].every(
(part) => (part?.trim() ?? "") !== "",
);
}
/** Key-less Google Maps embed URL for the read-only preview. */
export function buildSiteMapEmbedUrl(location: SiteAddress): string {
const query = formatLocationAddressPreview(location);
return `https://www.google.com/maps?q=${encodeURIComponent(query)}&output=embed`;
}

View file

@ -1,4 +1,4 @@
import { apiGet } from "@/api/api";
import { apiGet, apiPostNoContent } from "@/api/api";
import {
notificationFeedSchema,
type NotificationFeed,
@ -7,4 +7,7 @@ import {
export const notificationsApi = {
feed: async (): Promise<NotificationFeed> =>
notificationFeedSchema.parse(await apiGet<unknown>("notifications")),
/** Records who acknowledged the missed response deadline in the work order's audit history. */
acknowledgeSlaBreach: (workOrderId: number): Promise<void> =>
apiPostNoContent(`notifications/sla/${workOrderId}/acknowledge`),
};

View file

@ -5,6 +5,11 @@ export type NotificationSession = {
readIds: ReadonlySet<string>;
dismiss: (ids: string[]) => void;
markRead: (ids: string[]) => void;
/** At-risk work orders hidden from the SLA banner for this session. */
slaBannerDismissedIds: ReadonlySet<number>;
dismissSlaBanner: (workOrderIds: number[]) => void;
/** Returns the ids not toasted yet this session and marks them toasted, so each toast fires once. */
claimSlaToasts: (workOrderIds: number[]) => number[];
};
export const NotificationSessionContext = createContext<NotificationSession | null>(null);

View file

@ -1,7 +1,7 @@
import { useCallback, useMemo, useState, type ReactNode } from "react";
import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
import { NotificationSessionContext } from "./notification-session-context";
function addAll(current: ReadonlySet<string>, ids: string[]): ReadonlySet<string> {
function addAll<T>(current: ReadonlySet<T>, ids: T[]): ReadonlySet<T> {
if (ids.every((id) => current.has(id))) {
return current;
}
@ -17,6 +17,11 @@ function addAll(current: ReadonlySet<string>, ids: string[]): ReadonlySet<string
export function NotificationSessionProvider({ children }: { children: ReactNode }) {
const [dismissedIds, setDismissedIds] = useState<ReadonlySet<string>>(() => new Set());
const [readIds, setReadIds] = useState<ReadonlySet<string>>(() => new Set());
const [slaBannerDismissedIds, setSlaBannerDismissedIds] = useState<ReadonlySet<number>>(
() => new Set(),
);
// A ref, not state: claiming must be synchronous so a re-run effect can never toast twice.
const toastedSlaIds = useRef(new Set<number>());
const dismiss = useCallback((ids: string[]) => {
setDismissedIds((current) => addAll(current, ids));
@ -24,10 +29,34 @@ export function NotificationSessionProvider({ children }: { children: ReactNode
const markRead = useCallback((ids: string[]) => {
setReadIds((current) => addAll(current, ids));
}, []);
const dismissSlaBanner = useCallback((workOrderIds: number[]) => {
setSlaBannerDismissedIds((current) => addAll(current, workOrderIds));
}, []);
const claimSlaToasts = useCallback((workOrderIds: number[]) => {
const claimed = workOrderIds.filter((id) => !toastedSlaIds.current.has(id));
claimed.forEach((id) => toastedSlaIds.current.add(id));
return claimed;
}, []);
const value = useMemo(
() => ({ dismissedIds, readIds, dismiss, markRead }),
[dismissedIds, readIds, dismiss, markRead],
() => ({
dismissedIds,
readIds,
dismiss,
markRead,
slaBannerDismissedIds,
dismissSlaBanner,
claimSlaToasts,
}),
[
dismissedIds,
readIds,
dismiss,
markRead,
slaBannerDismissedIds,
dismissSlaBanner,
claimSlaToasts,
],
);
return (

Some files were not shown because too many files have changed in this diff Show more