Compare commits
61 commits
3ae130aa08
...
b4035d0916
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4035d0916 | ||
|
|
73c7094102 | ||
|
|
301fe44b0c | ||
|
|
53dcb6af9e | ||
|
|
6a6d519ed7 | ||
|
|
a554aa049e | ||
|
|
f36a07c99c | ||
|
|
810fa5d5b0 | ||
|
|
a27695b217 | ||
|
|
3cdb5e7d7b | ||
|
|
b55938104a | ||
|
|
727588ab52 | ||
|
|
a2d03f9461 | ||
|
|
18127c0f30 | ||
|
|
dc2565e3fe | ||
|
|
0b654bb07c | ||
|
|
3b7cf83332 | ||
|
|
414e584b36 | ||
|
|
f068beb45e | ||
|
|
06f70c193d | ||
|
|
e8cdb9814a | ||
|
|
8e02031152 | ||
|
|
7bde8aacf2 | ||
|
|
2b338b36c6 | ||
|
|
2281c7c82d | ||
|
|
85a45e9fc0 | ||
|
|
090c977659 | ||
|
|
62664894bd | ||
|
|
35714a917d | ||
|
|
d866cf1b87 | ||
|
|
a031239259 | ||
|
|
7c38ebd579 | ||
|
|
b53d91b1da | ||
|
|
3ed45547db | ||
|
|
d281cbd663 | ||
|
|
70501693c8 | ||
|
|
c0e484275d | ||
|
|
70e52937aa | ||
|
|
7efc9e8914 | ||
|
|
383d5c3c6c | ||
|
|
9c19287172 | ||
|
|
ad500753e1 | ||
|
|
d5cd0659a2 | ||
|
|
ca42d6e59f | ||
|
|
cccdeceb10 | ||
|
|
976cd908e3 | ||
|
|
bea3540409 | ||
|
|
9bb0416346 | ||
|
|
3ab9c9a65b | ||
|
|
297830ed35 | ||
|
|
7eaf6bca18 | ||
|
|
c16b7e03e4 | ||
|
|
5bcc0f7081 | ||
|
|
ffc4a9da68 | ||
|
|
fac0d1885c | ||
|
|
b6226ef1e6 | ||
|
|
c1361dbbf3 | ||
|
|
6d22d039ef | ||
|
|
0a64f2dec7 | ||
|
|
50b97a96d0 | ||
|
|
3c1736aa85 |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 150 KiB |
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 153 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 157 KiB After Width: | Height: | Size: 161 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 198 KiB After Width: | Height: | Size: 202 KiB |
46
package-lock.json
generated
|
|
@ -22,9 +22,9 @@
|
|||
"@mui/icons-material": "9.2.0",
|
||||
"@mui/material": "9.2.0",
|
||||
"@sentry/react": "10.75.0",
|
||||
"@tanstack/query-broadcast-client-experimental": "5.103.1",
|
||||
"@tanstack/react-query": "5.103.1",
|
||||
"@tanstack/react-query-devtools": "5.103.1",
|
||||
"@tanstack/query-broadcast-client-experimental": "5.103.2",
|
||||
"@tanstack/react-query": "5.103.2",
|
||||
"@tanstack/react-query-devtools": "5.103.2",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
|
|
@ -3016,12 +3016,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-broadcast-client-experimental": {
|
||||
"version": "5.103.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-broadcast-client-experimental/-/query-broadcast-client-experimental-5.103.1.tgz",
|
||||
"integrity": "sha512-PJPupLlVjL3YMd7A9HRcEgYnH4dptAKrH2egsGFk4PCx/sPYaVhnEoHXCGwxQMl2qKnxvQWNBlmCz04BGIwwaw==",
|
||||
"version": "5.103.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-broadcast-client-experimental/-/query-broadcast-client-experimental-5.103.2.tgz",
|
||||
"integrity": "sha512-cVitY7ohr77S7EsL2Qkl8AbvPLTwa3R7vb9K4lpmEaDCwozXCahu2G3r/k1QpVQliKqqKAugh5I0H1QiVawgGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.103.1",
|
||||
"@tanstack/query-core": "5.103.2",
|
||||
"broadcast-channel": "^7.0.0"
|
||||
},
|
||||
"funding": {
|
||||
|
|
@ -3030,9 +3030,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.103.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.103.1.tgz",
|
||||
"integrity": "sha512-rms8HqTGp6zA00dM+cUQ2eBcgzNJefuu5CAMB37i/6MiGT1zulPOytCFu2a0qjLqVR2n1jENPj9woqFQNuCzWA==",
|
||||
"version": "5.103.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.103.2.tgz",
|
||||
"integrity": "sha512-I8DkFXls5jXLqtm8+QpOhEmG07hIblhSZFzafg9wHsMSEvMzULy9hK17wU1T/ahfhMbtITJhbxutwwCoihkR7A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
|
|
@ -3040,9 +3040,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-devtools": {
|
||||
"version": "5.103.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.103.1.tgz",
|
||||
"integrity": "sha512-xMOcnULRPEjww0A/0RnweNmcZ5hesLjTekqf8SHxf+BJH729qz6sUu5TsOWFo7J4fu3OR50MeuJUsM3BN78Kfw==",
|
||||
"version": "5.103.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.103.2.tgz",
|
||||
"integrity": "sha512-QW+uVtT7dZcc9KMmKQ846Yw25h7WPhM1cfCYYaSkkmPqkK39g+Cj1tcAt+Ege+6rBVFQvNyYln0FgAAHcYGSdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "^0.13.4",
|
||||
|
|
@ -3050,7 +3050,7 @@
|
|||
"@solid-primitives/resize-observer": "^2.0.26",
|
||||
"@solid-primitives/storage": "^1.3.11",
|
||||
"@tanstack/match-sorter-utils": "^8.19.4",
|
||||
"@tanstack/query-core": "5.103.1",
|
||||
"@tanstack/query-core": "5.103.2",
|
||||
"clsx": "^2.1.1",
|
||||
"goober": "^2.1.16",
|
||||
"solid-js": "^1.9.7",
|
||||
|
|
@ -3063,12 +3063,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.103.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.103.1.tgz",
|
||||
"integrity": "sha512-rmAPPApNEK17VXRJhtE1rja53n23VV5w30C0saCgJhhX+EpdUnVqMGV/s5nnzV43X75KTHKzuoiuxSzGTBIkag==",
|
||||
"version": "5.103.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.103.2.tgz",
|
||||
"integrity": "sha512-B+fWiYZBc+0uUD5zDZAeLw9dKj7XEsdnuu6zRZ+no6LNKpeE3P3bJ+N6HINjcIlWR6fW3vUoTneEaGa2V6ehqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.103.1"
|
||||
"@tanstack/query-core": "5.103.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
|
|
@ -3079,19 +3079,19 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query-devtools": {
|
||||
"version": "5.103.1",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.103.1.tgz",
|
||||
"integrity": "sha512-l6OzwPbDCnuim8w9McFs3dC/rdXTMZBIBFMVrXzNuhUEzWheldubOu+nKRDtPf+1nLRuwavb3We+nNJq/0vV6w==",
|
||||
"version": "5.103.2",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.103.2.tgz",
|
||||
"integrity": "sha512-NV2AwKeS4UoyAJFcU0CNsoABKVf3A9KMX2InuII2LPUW7z6U296mbQITURAoMwDAxC2AMFCbXlMEPCgRGveQhw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-devtools": "5.103.1"
|
||||
"@tanstack/query-devtools": "5.103.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/tannerlinsley"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tanstack/react-query": "^5.103.1",
|
||||
"@tanstack/react-query": "^5.103.2",
|
||||
"@types/react": "^18 || ^19",
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,9 +62,9 @@
|
|||
"@mui/icons-material": "9.2.0",
|
||||
"@mui/material": "9.2.0",
|
||||
"@sentry/react": "10.75.0",
|
||||
"@tanstack/query-broadcast-client-experimental": "5.103.1",
|
||||
"@tanstack/react-query": "5.103.1",
|
||||
"@tanstack/react-query-devtools": "5.103.1",
|
||||
"@tanstack/query-broadcast-client-experimental": "5.103.2",
|
||||
"@tanstack/react-query": "5.103.2",
|
||||
"@tanstack/react-query-devtools": "5.103.2",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
@ -83,6 +86,14 @@ export const API_PATHS = {
|
|||
getList: "User",
|
||||
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",
|
||||
sendCode: "team-member-invites/send-code",
|
||||
verifyCode: "team-member-invites/verify-code",
|
||||
complete: "team-member-invites/complete",
|
||||
},
|
||||
contact: {
|
||||
getList: "Contact/GetContactList",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
export function CompletionTemplateAddLink({
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="self-start"
|
||||
style={{ fontSize: 12, fontWeight: 500, color: "var(--color-primary)", textAlign: "left" }}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { Text } from "@/components/ui/text";
|
||||
|
||||
function initials(name: string): string {
|
||||
return name
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map((part) => part[0] ?? "")
|
||||
.join("")
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
export function CompletionTemplateCreatedBy({ name }: { name: string | null }) {
|
||||
if (name === null) {
|
||||
return (
|
||||
<Text variant="caption" tone="muted" sx={{ fontSize: 13 }}>
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex shrink-0 items-center justify-center rounded-full"
|
||||
style={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
fontSize: 9,
|
||||
fontWeight: 600,
|
||||
background: "var(--color-bg-muted)",
|
||||
color: "var(--color-primary)",
|
||||
}}
|
||||
>
|
||||
{initials(name)}
|
||||
</span>
|
||||
<Text variant="caption" sx={{ fontSize: 13 }}>
|
||||
{name}
|
||||
</Text>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { COMPLETION_TEMPLATE_COPY } from "@/domain/completion-templates/constants/completion-template-constants";
|
||||
import type { CompletionTemplate } from "@/domain/completion-templates/types/completion-template";
|
||||
import { descriptionLines } from "@/domain/completion-templates/utils/description-lines";
|
||||
import { formatTemplateDate } from "@/domain/completion-templates/utils/format-template-date";
|
||||
import { CompletionTemplateCreatedBy } from "./completion-template-created-by";
|
||||
import {
|
||||
AutoPopulatedFields,
|
||||
FieldLabel,
|
||||
SectionTitle,
|
||||
StandardSafetyBullets,
|
||||
} from "./completion-template-sections";
|
||||
|
||||
function ReadOnlyField({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<Text
|
||||
variant="label"
|
||||
tone="muted"
|
||||
sx={{ fontSize: 12, fontWeight: 500, display: "block", marginBottom: "6px" }}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProcedureList({ template }: { template: CompletionTemplate }) {
|
||||
if (template.procedures.length === 0) {
|
||||
return (
|
||||
<Text variant="caption" tone="muted" sx={{ fontSize: 13 }}>
|
||||
{COMPLETION_TEMPLATE_COPY.noProcedureDetail}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ol className="m-0 list-none p-0">
|
||||
{template.procedures.map((procedure, index) => (
|
||||
<li key={index} style={{ marginBottom: 12 }}>
|
||||
<Text variant="label" sx={{ fontSize: 13, fontWeight: 600, display: "block", mb: 0.5 }}>
|
||||
{procedure.name || `Procedure ${index + 1}`}
|
||||
</Text>
|
||||
{descriptionLines(procedure.description).map((line, lineIndex) => (
|
||||
<Text key={lineIndex} variant="body" sx={{ fontSize: 13, lineHeight: 1.5, my: 0.5 }}>
|
||||
{line}
|
||||
</Text>
|
||||
))}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
/** Everything a template holds, read-only. */
|
||||
export function CompletionTemplateDetail({ template }: { template: CompletionTemplate }) {
|
||||
return (
|
||||
<div className="grid gap-3.5">
|
||||
<SectionTitle>Completion Document</SectionTitle>
|
||||
<ReadOnlyField label="Document Title">
|
||||
<Text variant="body" sx={{ fontSize: 13 }}>
|
||||
{template.name || "—"}
|
||||
</Text>
|
||||
</ReadOnlyField>
|
||||
<AutoPopulatedFields />
|
||||
<div>
|
||||
<FieldLabel>Safety Notes</FieldLabel>
|
||||
<StandardSafetyBullets extra={template.extraSafetyNote} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel hint="(optional)">Procedures</FieldLabel>
|
||||
<ProcedureList template={template} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<ReadOnlyField label="Last Updated">
|
||||
<Text variant="body" sx={{ fontSize: 13 }}>
|
||||
{formatTemplateDate(template.lastUpdatedAt)}
|
||||
</Text>
|
||||
</ReadOnlyField>
|
||||
<ReadOnlyField label="Created By">
|
||||
<CompletionTemplateCreatedBy name={template.createdByName} />
|
||||
</ReadOnlyField>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import { FileText, Pencil, Plus, Save, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DialogCancelButton } from "@/components/ui/dialog-cancel-button";
|
||||
import { IconCloseButton } from "@/components/ui/icon-close-button";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { COMPLETION_TEMPLATE_COPY } from "@/domain/completion-templates/constants/completion-template-constants";
|
||||
import type { CompletionTemplatePermissions } from "./use-completion-template-permissions";
|
||||
import type { CompletionTemplateDialogMode } from "./use-completion-template-dialog";
|
||||
|
||||
export function CompletionTemplateDialogHeader({
|
||||
title,
|
||||
subtitle,
|
||||
onClose,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-between"
|
||||
style={{
|
||||
height: 60,
|
||||
padding: "0 20px",
|
||||
borderBottom: "1px solid var(--color-border)",
|
||||
background: "var(--color-bg-surface)",
|
||||
}}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<FileText size={18} className="shrink-0" style={{ color: "var(--color-primary)" }} />
|
||||
<div className="min-w-0">
|
||||
<Text
|
||||
as="div"
|
||||
variant="heading"
|
||||
className="truncate"
|
||||
sx={{ fontSize: 16, fontWeight: 700, lineHeight: 1.1 }}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
<Text as="div" variant="caption" tone="muted" className="truncate" sx={{ fontSize: 13 }}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<IconCloseButton onClick={onClose} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type FooterProps = {
|
||||
mode: CompletionTemplateDialogMode;
|
||||
permissions: CompletionTemplatePermissions;
|
||||
isSaving: boolean;
|
||||
onCancel: () => void;
|
||||
onSubmit: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
export function CompletionTemplateDialogFooter({
|
||||
mode,
|
||||
permissions,
|
||||
isSaving,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: FooterProps) {
|
||||
const footerStyle = {
|
||||
padding: "12px 20px",
|
||||
borderTop: "1px solid var(--color-border)",
|
||||
background: "var(--color-bg-surface)",
|
||||
flexShrink: 0,
|
||||
};
|
||||
if (mode !== "view") {
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-3" style={footerStyle}>
|
||||
<DialogCancelButton onClick={onCancel} disabled={isSaving} />
|
||||
<Button onClick={onSubmit} disabled={isSaving} className="gap-1.5">
|
||||
{mode === "create" ? <Plus size={13} /> : <Save size={13} />}
|
||||
{mode === "create"
|
||||
? COMPLETION_TEMPLATE_COPY.addTemplate
|
||||
: COMPLETION_TEMPLATE_COPY.saveChanges}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-3 ${permissions.canDelete ? "justify-between" : "justify-end"}`}
|
||||
style={footerStyle}
|
||||
>
|
||||
{permissions.canDelete && (
|
||||
<Button variant="ghost" onClick={onDelete} className="gap-1.5 text-destructive">
|
||||
<Trash2 size={13} /> Delete
|
||||
</Button>
|
||||
)}
|
||||
{permissions.canEdit && (
|
||||
<Button onClick={onEdit} className="gap-1.5">
|
||||
<Pencil size={13} /> Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import type { CompletionTemplate } from "@/domain/completion-templates/types/completion-template";
|
||||
import { formatTemplateDate } from "@/domain/completion-templates/utils/format-template-date";
|
||||
import type { CompletionTemplateDialogMode } from "./use-completion-template-dialog";
|
||||
|
||||
export function dialogHeading(
|
||||
template: CompletionTemplate | null,
|
||||
mode: CompletionTemplateDialogMode,
|
||||
): { title: string; subtitle: string } {
|
||||
if (template === null) {
|
||||
return {
|
||||
title: "New Completion Document Template",
|
||||
subtitle: "Completion Document Template",
|
||||
};
|
||||
}
|
||||
if (mode === "edit") return { title: template.name, subtitle: "Editing template" };
|
||||
const createdBy = template.createdByName ?? "—";
|
||||
return {
|
||||
title: template.name,
|
||||
subtitle: `Created by ${createdBy} · Updated ${formatTemplateDate(template.lastUpdatedAt)}`,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import { Dialog, DialogTitle } from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
import type { CompletionTemplate } from "@/domain/completion-templates/types/completion-template";
|
||||
import { CompletionTemplateDetail } from "./completion-template-detail";
|
||||
import {
|
||||
CompletionTemplateDialogFooter,
|
||||
CompletionTemplateDialogHeader,
|
||||
} from "./completion-template-dialog-chrome";
|
||||
import { dialogHeading } from "./completion-template-dialog-heading";
|
||||
import { CompletionTemplateForm } from "./completion-template-form";
|
||||
import { CompletionTemplatePreview } from "./completion-template-preview";
|
||||
import { DeleteCompletionTemplateDialog } from "./delete-completion-template-dialog";
|
||||
import {
|
||||
useCompletionTemplateDialog,
|
||||
type CompletionTemplateDialogMode,
|
||||
} from "./use-completion-template-dialog";
|
||||
import type { CompletionTemplatePermissions } from "./use-completion-template-permissions";
|
||||
|
||||
type CompletionTemplateDialogProps = {
|
||||
/** `null` adds a new template. */
|
||||
template: CompletionTemplate | null;
|
||||
initialMode: CompletionTemplateDialogMode;
|
||||
permissions: CompletionTemplatePermissions;
|
||||
onClose: () => void;
|
||||
onSaved: (template: CompletionTemplate) => void;
|
||||
};
|
||||
|
||||
/** View, add or edit one template beside its document preview. */
|
||||
export function CompletionTemplateDialog({
|
||||
template,
|
||||
initialMode,
|
||||
permissions,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: CompletionTemplateDialogProps) {
|
||||
const dialog = useCompletionTemplateDialog({ template, initialMode, onClose, onSaved });
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
const heading = dialogHeading(template, dialog.mode);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onClose={onClose}
|
||||
maxWidth={false}
|
||||
aria-labelledby="completion-template-dialog-title"
|
||||
slotProps={{
|
||||
...dialogBlurBackdropSlotProps,
|
||||
paper: {
|
||||
className: "flex max-h-[85vh] flex-col overflow-hidden border border-border p-0",
|
||||
style: {
|
||||
borderRadius: 12,
|
||||
width: "min(1040px, calc(100vw - 32px))",
|
||||
maxWidth: "none",
|
||||
background: "var(--color-bg-muted)",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle id="completion-template-dialog-title" className="sr-only">
|
||||
{heading.title}
|
||||
</DialogTitle>
|
||||
<CompletionTemplateDialogHeader
|
||||
title={heading.title}
|
||||
subtitle={heading.subtitle}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1 flex-row">
|
||||
<CompletionTemplatePreview values={dialog.previewValues} isEditing={dialog.isEditing} />
|
||||
<div
|
||||
className="flex min-h-0 flex-col"
|
||||
style={{ width: "42%", background: "var(--color-bg-surface)" }}
|
||||
>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto" style={{ padding: 20 }}>
|
||||
{dialog.isEditing ? (
|
||||
<CompletionTemplateForm form={dialog.form} />
|
||||
) : (
|
||||
template !== null && <CompletionTemplateDetail template={template} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CompletionTemplateDialogFooter
|
||||
mode={dialog.mode}
|
||||
permissions={permissions}
|
||||
isSaving={dialog.isSaving}
|
||||
onCancel={dialog.cancel}
|
||||
onSubmit={() => void dialog.submit()}
|
||||
onEdit={dialog.startEditing}
|
||||
onDelete={() => setConfirmingDelete(true)}
|
||||
/>
|
||||
{template !== null && permissions.canDelete && (
|
||||
<DeleteCompletionTemplateDialog
|
||||
template={template}
|
||||
open={confirmingDelete}
|
||||
onClose={() => setConfirmingDelete(false)}
|
||||
onDeleted={onClose}
|
||||
/>
|
||||
)}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { FormField } from "@/components/ui/form-field";
|
||||
import { TextField } from "@/components/ui/text-field";
|
||||
import type { CompletionTemplateFormValues } from "@/domain/completion-templates/schemas/completion-template-schema";
|
||||
import { CompletionTemplateProceduresEditor } from "./completion-template-procedures-editor";
|
||||
import { CompletionTemplateSafetyNoteField } from "./completion-template-safety-note-field";
|
||||
import {
|
||||
AutoPopulatedFields,
|
||||
FieldLabel,
|
||||
SectionTitle,
|
||||
StandardSafetyBullets,
|
||||
} from "./completion-template-sections";
|
||||
|
||||
type CompletionTemplateFormProps = {
|
||||
form: UseFormReturn<CompletionTemplateFormValues>;
|
||||
};
|
||||
|
||||
function TitleField({ form }: CompletionTemplateFormProps) {
|
||||
return (
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field, fieldState }) => (
|
||||
<FormField
|
||||
label="Document Title"
|
||||
htmlFor="completion-template-title"
|
||||
required
|
||||
error={fieldState.error !== undefined}
|
||||
helperText={fieldState.error?.message}
|
||||
>
|
||||
<TextField
|
||||
id="completion-template-title"
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
inputRef={field.ref}
|
||||
placeholder="Completion document title"
|
||||
error={fieldState.error !== undefined}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The editable template: title, fixed references, the optional note and procedures. */
|
||||
export function CompletionTemplateForm({ form }: CompletionTemplateFormProps) {
|
||||
return (
|
||||
<div className="grid gap-3.5">
|
||||
<SectionTitle>Completion Document</SectionTitle>
|
||||
<TitleField form={form} />
|
||||
<AutoPopulatedFields />
|
||||
<div className="grid gap-1">
|
||||
<FieldLabel>Safety Notes</FieldLabel>
|
||||
<StandardSafetyBullets />
|
||||
<CompletionTemplateSafetyNoteField form={form} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel hint="(optional)">Procedures</FieldLabel>
|
||||
<CompletionTemplateProceduresEditor form={form} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import { FileText } from "lucide-react";
|
||||
import { useState, type ReactNode } from "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";
|
||||
import {
|
||||
pageIndicatorLabel,
|
||||
type PagePosition,
|
||||
} from "@/domain/work-orders/utils/completion-doc-pagination";
|
||||
|
||||
export interface CompletionTemplatePreviewValues {
|
||||
name: string;
|
||||
extraSafetyNote: string | null;
|
||||
procedures: CompletionTemplateProcedure[];
|
||||
}
|
||||
|
||||
type CompletionTemplatePreviewProps = {
|
||||
values: CompletionTemplatePreviewValues;
|
||||
isEditing: boolean;
|
||||
};
|
||||
|
||||
function PreviewPill({ isEditing, page }: { isEditing: boolean; page: PagePosition }) {
|
||||
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"}
|
||||
{pageIndicatorLabel(page)}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** One block per procedure (each kept whole on a page), or the empty-state line. */
|
||||
function previewSections(procedures: CompletionTemplateProcedure[]): ReactNode[] {
|
||||
if (procedures.length === 0) {
|
||||
return [
|
||||
<Text
|
||||
key="empty"
|
||||
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 [page, setPage] = useState<PagePosition>({ current: 1, total: 1 });
|
||||
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} page={page} />
|
||||
<div className="flex min-h-0 flex-1 flex-col" data-testid="completion-template-preview">
|
||||
<CompDocDocumentFrame
|
||||
onPageChange={setPage}
|
||||
header={
|
||||
<>
|
||||
<CompDocPreviewHeader />
|
||||
<PreviewTitle name={values.name} />
|
||||
<CompDocSafetyBullets extra={note === "" ? [] : [note]} />
|
||||
</>
|
||||
}
|
||||
sections={previewSections(values.procedures)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import { Controller, useFieldArray, type UseFormReturn } from "react-hook-form";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { TextField } from "@/components/ui/text-field";
|
||||
import { PROCEDURE_NAME_MAX_LENGTH } from "@/domain/completion-templates/constants/completion-template-constants";
|
||||
import type { CompletionTemplateFormValues } from "@/domain/completion-templates/schemas/completion-template-schema";
|
||||
import { CompletionTemplateAddLink } from "./completion-template-add-link";
|
||||
import { CompletionTemplateRemoveButton } from "./completion-template-remove-button";
|
||||
|
||||
type ProceduresEditorProps = {
|
||||
form: UseFormReturn<CompletionTemplateFormValues>;
|
||||
};
|
||||
|
||||
const DESCRIPTION_PLACEHOLDER =
|
||||
"Describe the procedure — steps, sub-items, whatever the source document has. One item per line works well.";
|
||||
|
||||
function ProcedureFields({ form, index }: ProceduresEditorProps & { index: number }) {
|
||||
return (
|
||||
<div className="grid gap-2.5">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name={`procedures.${index}.name`}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
inputRef={field.ref}
|
||||
placeholder="Procedure name (e.g. Backflow Procedure)"
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
"aria-label": `Procedure ${index + 1} name`,
|
||||
maxLength: PROCEDURE_NAME_MAX_LENGTH,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name={`procedures.${index}.description`}
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
inputRef={field.ref}
|
||||
multiline
|
||||
minRows={5}
|
||||
maxRows={8}
|
||||
placeholder={DESCRIPTION_PLACEHOLDER}
|
||||
slotProps={{ htmlInput: { "aria-label": `Procedure ${index + 1} description` } }}
|
||||
sx={{ "& .MuiOutlinedInput-root": { height: "auto" } }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Procedures print in the order they are listed here. */
|
||||
export function CompletionTemplateProceduresEditor({ form }: ProceduresEditorProps) {
|
||||
const { fields, append, remove } = useFieldArray({ control: form.control, name: "procedures" });
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{fields.map((field, index) => (
|
||||
<fieldset
|
||||
key={field.id}
|
||||
aria-label={`Procedure ${index + 1}`}
|
||||
className="m-0 min-w-0"
|
||||
style={{
|
||||
padding: 12,
|
||||
borderRadius: "var(--radius-md)",
|
||||
border: "1px solid var(--color-border)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: 8 }}>
|
||||
<Text
|
||||
variant="caption"
|
||||
tone="muted"
|
||||
sx={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.04em",
|
||||
}}
|
||||
>
|
||||
Procedure {index + 1}
|
||||
</Text>
|
||||
<CompletionTemplateRemoveButton
|
||||
label={`Remove procedure ${index + 1}`}
|
||||
onClick={() => remove(index)}
|
||||
/>
|
||||
</div>
|
||||
<ProcedureFields form={form} index={index} />
|
||||
</fieldset>
|
||||
))}
|
||||
<CompletionTemplateAddLink onClick={() => append({ name: "", description: "" })}>
|
||||
+ Add procedure
|
||||
</CompletionTemplateAddLink>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { X } from "lucide-react";
|
||||
|
||||
export function CompletionTemplateRemoveButton({
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
className="shrink-0"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import { Controller, useWatch, type UseFormReturn } from "react-hook-form";
|
||||
import { TextField } from "@/components/ui/text-field";
|
||||
import { EXTRA_SAFETY_NOTE_MAX_LENGTH } from "@/domain/completion-templates/constants/completion-template-constants";
|
||||
import type { CompletionTemplateFormValues } from "@/domain/completion-templates/schemas/completion-template-schema";
|
||||
import { CompletionTemplateAddLink } from "./completion-template-add-link";
|
||||
import { CompletionTemplateRemoveButton } from "./completion-template-remove-button";
|
||||
|
||||
type SafetyNoteFieldProps = {
|
||||
form: UseFormReturn<CompletionTemplateFormValues>;
|
||||
};
|
||||
|
||||
/** At most one template-specific note, printed after the standard bullets. */
|
||||
export function CompletionTemplateSafetyNoteField({ form }: SafetyNoteFieldProps) {
|
||||
const note = useWatch({ control: form.control, name: "extraSafetyNote" });
|
||||
const setNote = (value: string | null) =>
|
||||
form.setValue("extraSafetyNote", value, { shouldDirty: true });
|
||||
|
||||
if (note === null) {
|
||||
return (
|
||||
<CompletionTemplateAddLink onClick={() => setNote("")}>
|
||||
+ Add safety note
|
||||
</CompletionTemplateAddLink>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex items-start gap-2">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="extraSafetyNote"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
multiline
|
||||
minRows={3}
|
||||
maxRows={8}
|
||||
value={field.value ?? ""}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
inputRef={field.ref}
|
||||
placeholder="Type safety note…"
|
||||
slotProps={{
|
||||
htmlInput: {
|
||||
"aria-label": "Extra safety note",
|
||||
maxLength: EXTRA_SAFETY_NOTE_MAX_LENGTH,
|
||||
},
|
||||
}}
|
||||
sx={{ "& .MuiOutlinedInput-root": { height: "auto" } }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<CompletionTemplateRemoveButton label="Remove safety note" onClick={() => setNote(null)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { AUTO_POPULATED_FIELDS } from "@/domain/completion-templates/constants/completion-template-constants";
|
||||
import { SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
|
||||
|
||||
export function SectionTitle({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Text as="div" variant="heading" family="body" sx={{ fontSize: 16, fontWeight: 600 }}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export function FieldLabel({ children, hint }: { children: ReactNode; hint?: string }) {
|
||||
return (
|
||||
<div className="flex items-baseline gap-1.5" style={{ marginBottom: 4 }}>
|
||||
<Text
|
||||
variant="caption"
|
||||
tone="muted"
|
||||
sx={{ fontSize: 11, fontWeight: 500, textTransform: "uppercase", letterSpacing: "0.06em" }}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
{hint !== undefined && (
|
||||
<Text variant="caption" tone="muted" sx={{ fontSize: 11 }}>
|
||||
{hint}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AutoPopulatedFields() {
|
||||
return (
|
||||
<div>
|
||||
<FieldLabel>Auto-populated fields</FieldLabel>
|
||||
<ul className="m-0 flex list-none flex-wrap items-center gap-1.5 p-0">
|
||||
{AUTO_POPULATED_FIELDS.map((field) => (
|
||||
<li
|
||||
key={field}
|
||||
className="inline-flex items-center"
|
||||
style={{
|
||||
background: "var(--color-bg-muted)",
|
||||
color: "var(--color-text-muted)",
|
||||
fontSize: 12,
|
||||
padding: "2px 8px",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{field}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The standard bullets every generated document prints; never editable per template. */
|
||||
export function StandardSafetyBullets({ extra }: { extra?: string | null }) {
|
||||
return (
|
||||
<ul aria-label="Safety notes" style={{ margin: "0 0 8px", paddingLeft: 18 }}>
|
||||
{SAFETY_BULLETS.map((bullet) => (
|
||||
<li
|
||||
key={bullet}
|
||||
style={{ fontSize: 12.5, color: "var(--color-text-muted)", margin: "2px 0" }}
|
||||
>
|
||||
{bullet}
|
||||
</li>
|
||||
))}
|
||||
{Boolean(extra) && (
|
||||
<li style={{ fontSize: 12.5, margin: "2px 0", whiteSpace: "pre-wrap" }}>{extra}</li>
|
||||
)}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
import { Table, TableBody, TableCell, TableHead, TableRow } from "@mui/material";
|
||||
import { Eye, FileText, Pencil, Search } from "lucide-react";
|
||||
import type { MouseEvent } from "react";
|
||||
import { TableLoadingRow } from "@/components/ui/table-state-rows";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { COMPLETION_TEMPLATE_COPY } from "@/domain/completion-templates/constants/completion-template-constants";
|
||||
import type { CompletionTemplate } from "@/domain/completion-templates/types/completion-template";
|
||||
import { formatTemplateDate } from "@/domain/completion-templates/utils/format-template-date";
|
||||
import { CompletionTemplateCreatedBy } from "./completion-template-created-by";
|
||||
|
||||
const HEAD_CELL_SX = {
|
||||
fontSize: 10.5,
|
||||
fontWeight: 700,
|
||||
letterSpacing: "0.06em",
|
||||
textTransform: "uppercase",
|
||||
background: "var(--color-bg-muted)",
|
||||
padding: "11px 14px",
|
||||
whiteSpace: "nowrap",
|
||||
} as const;
|
||||
|
||||
const BODY_CELL_SX = { fontSize: 13, padding: "8px 12px" } as const;
|
||||
|
||||
type CompletionTemplatesTableProps = {
|
||||
templates: CompletionTemplate[];
|
||||
isLoading: boolean;
|
||||
/** The load failed: the page shows the error, so there is no search result to explain. */
|
||||
hasError: boolean;
|
||||
canEdit: boolean;
|
||||
onView: (template: CompletionTemplate) => void;
|
||||
onEdit: (template: CompletionTemplate) => void;
|
||||
};
|
||||
|
||||
function RowAction({
|
||||
label,
|
||||
icon: Icon,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
icon: typeof Eye;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
onClick={(event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
className="flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-primary"
|
||||
>
|
||||
<Icon size={15} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyRow() {
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} sx={{ padding: 0, borderBottom: 0 }}>
|
||||
<div
|
||||
className="flex flex-col items-center justify-center gap-2 text-center"
|
||||
style={{ padding: "64px 16px" }}
|
||||
>
|
||||
<Search size={22} aria-hidden style={{ color: "var(--color-text-muted)" }} />
|
||||
<Text variant="body" sx={{ fontSize: 14, fontWeight: 600 }}>
|
||||
{COMPLETION_TEMPLATE_COPY.emptySearchTitle}
|
||||
</Text>
|
||||
<Text variant="description" tone="muted" sx={{ fontSize: 12.5 }}>
|
||||
{COMPLETION_TEMPLATE_COPY.emptySearchHint}
|
||||
</Text>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateRow({
|
||||
template,
|
||||
canEdit,
|
||||
onView,
|
||||
onEdit,
|
||||
}: Omit<CompletionTemplatesTableProps, "templates" | "isLoading" | "hasError"> & {
|
||||
template: CompletionTemplate;
|
||||
}) {
|
||||
return (
|
||||
<TableRow hover onClick={() => onView(template)} sx={{ cursor: "pointer", height: 52 }}>
|
||||
<TableCell sx={BODY_CELL_SX}>
|
||||
<span className="inline-flex items-center gap-2.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-flex shrink-0 items-center justify-center rounded-full"
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
background: "var(--color-bg-muted)",
|
||||
color: "var(--color-primary)",
|
||||
}}
|
||||
>
|
||||
<FileText size={14} />
|
||||
</span>
|
||||
{template.name}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell sx={{ ...BODY_CELL_SX, color: "text.secondary" }}>
|
||||
{formatTemplateDate(template.lastUpdatedAt)}
|
||||
</TableCell>
|
||||
<TableCell sx={BODY_CELL_SX}>
|
||||
<CompletionTemplateCreatedBy name={template.createdByName} />
|
||||
</TableCell>
|
||||
<TableCell sx={BODY_CELL_SX}>
|
||||
<span className="flex items-center gap-1">
|
||||
<RowAction label="View details" icon={Eye} onClick={() => onView(template)} />
|
||||
{canEdit && <RowAction label="Edit" icon={Pencil} onClick={() => onEdit(template)} />}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompletionTemplatesTable({
|
||||
templates,
|
||||
isLoading,
|
||||
hasError,
|
||||
canEdit,
|
||||
onView,
|
||||
onEdit,
|
||||
}: CompletionTemplatesTableProps) {
|
||||
return (
|
||||
<Table
|
||||
stickyHeader
|
||||
size="small"
|
||||
aria-label="Completion document templates"
|
||||
sx={{ minWidth: 700 }}
|
||||
>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ ...HEAD_CELL_SX, width: 360 }}>Document Title</TableCell>
|
||||
<TableCell sx={{ ...HEAD_CELL_SX, width: 140 }}>Last Updated</TableCell>
|
||||
<TableCell sx={{ ...HEAD_CELL_SX, width: 180 }}>Created By</TableCell>
|
||||
<TableCell sx={{ ...HEAD_CELL_SX, width: 90 }}>
|
||||
<span className="sr-only">Actions</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading && <TableLoadingRow colSpan={4} />}
|
||||
{!isLoading && !hasError && templates.length === 0 && <EmptyRow />}
|
||||
{templates.map((template) => (
|
||||
<TemplateRow
|
||||
key={template.id}
|
||||
template={template}
|
||||
canEdit={canEdit}
|
||||
onView={onView}
|
||||
onEdit={onEdit}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import { CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
|
||||
import { useNavigate } from "react-router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DialogCancelButton } from "@/components/ui/dialog-cancel-button";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type {
|
||||
CompletionTemplate,
|
||||
LinkedWorkOrders,
|
||||
} from "@/domain/completion-templates/types/completion-template";
|
||||
import { useDeleteCompletionTemplate } from "@/domain/completion-templates/use-cases/use-completion-template-mutations";
|
||||
import { useLinkedWorkOrders } from "@/domain/completion-templates/use-cases/use-completion-templates";
|
||||
import { buildWorkOrderIdsHref } from "@/domain/work-orders/utils/work-order-ids-filter";
|
||||
|
||||
type DeleteCompletionTemplateDialogProps = {
|
||||
template: CompletionTemplate;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onDeleted: () => void;
|
||||
};
|
||||
|
||||
function deleteMessage(name: string, linked: LinkedWorkOrders): string {
|
||||
if (linked.count === 0) return `This will permanently remove "${name}". This cannot be undone.`;
|
||||
const plural = linked.count === 1 ? "" : "s";
|
||||
return (
|
||||
`This will permanently remove "${name}". It's still linked to ${linked.count} open work ` +
|
||||
`order${plural} through its Service — deleting it won't change that Service, but new ` +
|
||||
"documents generated for those work orders will fall back to a generic procedure until " +
|
||||
"reassigned. This cannot be undone."
|
||||
);
|
||||
}
|
||||
|
||||
function LinkedWorkOrdersMessage({
|
||||
name,
|
||||
linked,
|
||||
}: {
|
||||
name: string;
|
||||
linked: ReturnType<typeof useLinkedWorkOrders>;
|
||||
}) {
|
||||
if (linked.isPending) {
|
||||
return (
|
||||
<div className="flex items-center gap-2" role="status">
|
||||
<CircularProgress size={16} />
|
||||
<Text variant="description" tone="muted">
|
||||
Checking linked work orders…
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (linked.isError) {
|
||||
return (
|
||||
<Text variant="error">
|
||||
Couldn't check which work orders use this template. Close this and try again.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return <Text variant="description">{deleteMessage(name, linked.data)}</Text>;
|
||||
}
|
||||
|
||||
/** Deleting always asks first, and names the open work orders that still depend on the template. */
|
||||
export function DeleteCompletionTemplateDialog({
|
||||
template,
|
||||
open,
|
||||
onClose,
|
||||
onDeleted,
|
||||
}: DeleteCompletionTemplateDialogProps) {
|
||||
const navigate = useNavigate();
|
||||
const linked = useLinkedWorkOrders(open ? template.id : null);
|
||||
const remove = useDeleteCompletionTemplate();
|
||||
const linkedIds = linked.data?.workOrderIds ?? [];
|
||||
|
||||
function confirmDelete() {
|
||||
remove.mutate(template.id, { onSuccess: onDeleted });
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Delete this template?</DialogTitle>
|
||||
<DialogContent>
|
||||
<LinkedWorkOrdersMessage name={template.name} linked={linked} />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<DialogCancelButton onClick={onClose} />
|
||||
{linkedIds.length > 0 && (
|
||||
<Button variant="outline" onClick={() => void navigate(buildWorkOrderIdsHref(linkedIds))}>
|
||||
View linked work orders
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={confirmDelete}
|
||||
disabled={!linked.isSuccess || remove.isPending}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useState } from "react";
|
||||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { COMPLETION_TEMPLATE_COPY } from "@/domain/completion-templates/constants/completion-template-constants";
|
||||
import {
|
||||
toCompletionTemplateFormValues,
|
||||
toCompletionTemplateInput,
|
||||
} from "@/domain/completion-templates/mappers/completion-template-mapper";
|
||||
import {
|
||||
completionTemplateFormSchema,
|
||||
type CompletionTemplateFormValues,
|
||||
} from "@/domain/completion-templates/schemas/completion-template-schema";
|
||||
import type { CompletionTemplate } from "@/domain/completion-templates/types/completion-template";
|
||||
import {
|
||||
isTitleRequiredError,
|
||||
useCreateCompletionTemplate,
|
||||
useUpdateCompletionTemplate,
|
||||
} from "@/domain/completion-templates/use-cases/use-completion-template-mutations";
|
||||
import type { CompletionTemplatePreviewValues } from "./completion-template-preview";
|
||||
|
||||
export type CompletionTemplateDialogMode = "create" | "view" | "edit";
|
||||
|
||||
type UseCompletionTemplateDialogOptions = {
|
||||
template: CompletionTemplate | null;
|
||||
initialMode: CompletionTemplateDialogMode;
|
||||
onClose: () => void;
|
||||
onSaved: (template: CompletionTemplate) => void;
|
||||
};
|
||||
|
||||
function usePreviewValues(
|
||||
control: ReturnType<typeof useForm<CompletionTemplateFormValues>>["control"],
|
||||
): CompletionTemplatePreviewValues {
|
||||
const watched = useWatch({ control });
|
||||
return {
|
||||
name: watched.name ?? "",
|
||||
extraSafetyNote: watched.extraSafetyNote ?? null,
|
||||
procedures: (watched.procedures ?? []).map((procedure) => ({
|
||||
name: procedure?.name ?? "",
|
||||
description: procedure?.description ?? "",
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function useCompletionTemplateDialog({
|
||||
template,
|
||||
initialMode,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: UseCompletionTemplateDialogOptions) {
|
||||
const [mode, setMode] = useState<CompletionTemplateDialogMode>(
|
||||
template === null ? "create" : initialMode,
|
||||
);
|
||||
const form = useForm<CompletionTemplateFormValues>({
|
||||
resolver: zodResolver(completionTemplateFormSchema),
|
||||
defaultValues: toCompletionTemplateFormValues(template),
|
||||
});
|
||||
const previewValues = usePreviewValues(form.control);
|
||||
const create = useCreateCompletionTemplate();
|
||||
const update = useUpdateCompletionTemplate();
|
||||
|
||||
function showServerTitleError(error: Error) {
|
||||
if (!isTitleRequiredError(error)) return;
|
||||
form.setError(
|
||||
"name",
|
||||
{ message: COMPLETION_TEMPLATE_COPY.titleRequired },
|
||||
{ shouldFocus: true },
|
||||
);
|
||||
}
|
||||
|
||||
const submit = form.handleSubmit((values) => {
|
||||
const input = toCompletionTemplateInput(values);
|
||||
if (template === null) {
|
||||
create.mutate(input, { onSuccess: onClose, onError: showServerTitleError });
|
||||
return;
|
||||
}
|
||||
update.mutate(
|
||||
{ template, input },
|
||||
{
|
||||
onSuccess: (saved) => {
|
||||
form.reset(toCompletionTemplateFormValues(saved));
|
||||
setMode("view");
|
||||
onSaved(saved);
|
||||
},
|
||||
onError: showServerTitleError,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/** Discards unsaved edits: back to the template as it was, or closed when creating. */
|
||||
function cancel() {
|
||||
if (template === null) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
form.reset(toCompletionTemplateFormValues(template));
|
||||
setMode("view");
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
isEditing: mode !== "view",
|
||||
form,
|
||||
previewValues,
|
||||
submit,
|
||||
cancel,
|
||||
startEditing: () => setMode("edit"),
|
||||
isSaving: create.isPending || update.isPending,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,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"),
|
||||
};
|
||||
}
|
||||
99
src/app/(protected)/completion-docs/index.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { Box } from "@mui/material";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHeader } from "@/components/ui/page-header";
|
||||
import { QueryErrorPanel } from "@/components/ui/query-error-panel";
|
||||
import { SearchInput } from "@/components/ui/search-input";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import {
|
||||
COMPLETION_TEMPLATE_COPY,
|
||||
templateCountLabel,
|
||||
} from "@/domain/completion-templates/constants/completion-template-constants";
|
||||
import type { CompletionTemplate } from "@/domain/completion-templates/types/completion-template";
|
||||
import { useCompletionTemplates } from "@/domain/completion-templates/use-cases/use-completion-templates";
|
||||
import { useDebounce } from "@/hooks/use-debounce";
|
||||
import { CompletionTemplateDialog } from "./_components/completion-template-dialog";
|
||||
import { CompletionTemplatesTable } from "./_components/completion-templates-table";
|
||||
import type { CompletionTemplateDialogMode } from "./_components/use-completion-template-dialog";
|
||||
import { useCompletionTemplatePermissions } from "./_components/use-completion-template-permissions";
|
||||
|
||||
type OpenDialog = { template: CompletionTemplate | null; mode: CompletionTemplateDialogMode };
|
||||
|
||||
export default function CompletionDocsPage() {
|
||||
const permissions = useCompletionTemplatePermissions();
|
||||
const [search, setSearch] = useState("");
|
||||
const debouncedSearch = useDebounce(search, 250);
|
||||
const all = useCompletionTemplates();
|
||||
const filtered = useCompletionTemplates(debouncedSearch);
|
||||
const [dialog, setDialog] = useState<OpenDialog | null>(null);
|
||||
const templates = filtered.data ?? [];
|
||||
|
||||
return (
|
||||
<Box className="flex h-full flex-col overflow-hidden p-2.5">
|
||||
<PageHeader
|
||||
surface
|
||||
title={COMPLETION_TEMPLATE_COPY.pageTitle}
|
||||
subtitle={templateCountLabel(all.data?.length ?? 0)}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder={COMPLETION_TEMPLATE_COPY.searchPlaceholder}
|
||||
aria-label="Search document title"
|
||||
className="w-56"
|
||||
/>
|
||||
{permissions.canCreate && (
|
||||
<Button
|
||||
onClick={() => setDialog({ template: null, mode: "create" })}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Plus size={13} /> {COMPLETION_TEMPLATE_COPY.addTemplate}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{filtered.error !== null && (
|
||||
<QueryErrorPanel
|
||||
className="mt-3 shrink-0"
|
||||
compact
|
||||
message={filtered.error.message || "Failed to load templates."}
|
||||
onRetry={() => void filtered.refetch()}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="mt-4 min-h-0 flex-1 overflow-auto rounded-lg border border-border"
|
||||
style={{ background: "var(--card)" }}
|
||||
>
|
||||
<CompletionTemplatesTable
|
||||
templates={templates}
|
||||
isLoading={filtered.isPending}
|
||||
hasError={filtered.error !== null}
|
||||
canEdit={permissions.canEdit}
|
||||
onView={(template) => setDialog({ template, mode: "view" })}
|
||||
onEdit={(template) => setDialog({ template, mode: "edit" })}
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 px-4 py-2">
|
||||
<Text variant="caption" tone="muted" sx={{ fontSize: 11 }}>
|
||||
{templateCountLabel(templates.length)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{dialog !== null && (
|
||||
<CompletionTemplateDialog
|
||||
key={dialog.template?.id ?? "new"}
|
||||
template={dialog.template}
|
||||
initialMode={dialog.mode}
|
||||
permissions={permissions}
|
||||
onClose={() => setDialog(null)}
|
||||
onSaved={(template) => setDialog({ template, mode: "view" })}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
} from "@mui/material";
|
||||
import type { LocationListItem } from "@/domain/locations/types/location";
|
||||
|
||||
interface LocationDeleteDialogProps {
|
||||
isPending: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
target: LocationListItem | null;
|
||||
}
|
||||
|
||||
export function LocationDeleteDialog({
|
||||
isPending,
|
||||
onClose,
|
||||
onConfirm,
|
||||
target,
|
||||
}: LocationDeleteDialogProps) {
|
||||
return (
|
||||
<Dialog open={Boolean(target)} onClose={onClose}>
|
||||
<DialogTitle>Delete this site?</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
This will permanently remove {target?.name} from Sites. This cannot be undone.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="error" variant="contained" onClick={onConfirm} disabled={isPending}>
|
||||
{isPending ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import {
|
||||
Button,
|
||||
CircularProgress,
|
||||
IconButton,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -10,7 +8,6 @@ import {
|
|||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import type { LocationListItem } from "@/domain/locations/types/location";
|
||||
|
|
@ -19,17 +16,15 @@ import { StateAreaCell } from "./state-area-cell";
|
|||
|
||||
interface LocationsTableProps {
|
||||
isLoading: boolean;
|
||||
onDelete: (row: LocationListItem) => void;
|
||||
onOpen: (row: LocationListItem) => void;
|
||||
rows: LocationListItem[];
|
||||
selectedId?: string | null;
|
||||
}
|
||||
|
||||
const COLUMN_COUNT = 8;
|
||||
const COLUMN_COUNT = 7;
|
||||
|
||||
export function LocationsTable({
|
||||
isLoading,
|
||||
onDelete,
|
||||
onOpen,
|
||||
rows,
|
||||
selectedId = null,
|
||||
|
|
@ -46,9 +41,6 @@ export function LocationsTable({
|
|||
<TableCell className="font-semibold">State</TableCell>
|
||||
<TableCell className="font-semibold">POC Name</TableCell>
|
||||
<TableCell className="font-semibold">POC Phone</TableCell>
|
||||
<TableCell align="right" className="font-semibold">
|
||||
<span className="sr-only">Actions</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
|
|
@ -98,20 +90,6 @@ export function LocationsTable({
|
|||
<LocationsContactsCell row={row} />
|
||||
</TableCell>
|
||||
<TableCell>{row.phone || "—"}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Delete">
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={`Delete site ${row.name}`}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete(row);
|
||||
}}
|
||||
>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
|
|
|
|||
110
src/app/(protected)/locations/_components/site-delete-dialog.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { useNavigate } from "react-router";
|
||||
import {
|
||||
Button,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
} from "@mui/material";
|
||||
import { DIALOG_BLUR_BACKDROP_SX } from "@/components/ui/dialog-blur-backdrop";
|
||||
import { useDeleteLocation } from "@/domain/locations/use-cases/use-delete-location";
|
||||
import { useSiteOpenWorkOrders } from "@/domain/locations/use-cases/use-site-open-work-orders";
|
||||
import { buildSiteOpenWorkOrdersHref } from "@/domain/work-orders/utils/dashboard-drilldown-params";
|
||||
import { buildWorkOrderIdsHref } from "@/domain/work-orders/utils/work-order-ids-filter";
|
||||
|
||||
interface SiteDeleteDialogProps {
|
||||
open: boolean;
|
||||
siteId: string;
|
||||
siteCode: string;
|
||||
onClose: () => void;
|
||||
onDeleted: () => void;
|
||||
}
|
||||
|
||||
const CHECK_FAILED_MESSAGE =
|
||||
"Couldn't check this site for open work orders. Try again before deleting it.";
|
||||
|
||||
function describeDeletion(siteCode: string, openCount: number): string {
|
||||
if (openCount === 0) {
|
||||
return `This will permanently remove ${siteCode} from Sites. This cannot be undone.`;
|
||||
}
|
||||
const noun = openCount === 1 ? "work order" : "work orders";
|
||||
return `This will permanently remove ${siteCode} from Sites. It still has ${openCount} open ${noun} — deleting the site won't cancel them automatically. This cannot be undone.`;
|
||||
}
|
||||
|
||||
/** Delete confirmation; warns with the open work-order count and links to those work orders. */
|
||||
export function SiteDeleteDialog({
|
||||
open,
|
||||
siteId,
|
||||
siteCode,
|
||||
onClose,
|
||||
onDeleted,
|
||||
}: SiteDeleteDialogProps) {
|
||||
const navigate = useNavigate();
|
||||
const deleteLocation = useDeleteLocation();
|
||||
const {
|
||||
data: openWorkOrders,
|
||||
isLoading,
|
||||
isError,
|
||||
isFetching,
|
||||
refetch,
|
||||
} = useSiteOpenWorkOrders(siteId, open);
|
||||
const openCount = openWorkOrders?.count ?? 0;
|
||||
// Without a current count the warning cannot be trusted, so Delete waits for a successful check.
|
||||
// `isFetching` covers reopening: the count cached from the last open is stale until the refetch lands.
|
||||
const countUnknown = isLoading || isFetching || isError;
|
||||
// The server caps the id list; when it doesn't cover the stated count, open the site's whole board.
|
||||
const openIds = openWorkOrders?.workOrderIds ?? [];
|
||||
const openWorkOrdersHref =
|
||||
openIds.length > 0 && openIds.length >= openCount
|
||||
? buildWorkOrderIdsHref(openIds)
|
||||
: buildSiteOpenWorkOrdersHref(siteId);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => {
|
||||
if (!deleteLocation.isPending) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
aria-labelledby="site-delete-title"
|
||||
slotProps={{ backdrop: { sx: DIALOG_BLUR_BACKDROP_SX } }}
|
||||
>
|
||||
<DialogTitle id="site-delete-title">Delete this site?</DialogTitle>
|
||||
<DialogContent>
|
||||
{isFetching && !isError && (
|
||||
<CircularProgress size={20} aria-label="Checking open work orders" />
|
||||
)}
|
||||
{isError && <DialogContentText role="alert">{CHECK_FAILED_MESSAGE}</DialogContentText>}
|
||||
{!countUnknown && (
|
||||
<DialogContentText>{describeDeletion(siteCode, openCount)}</DialogContentText>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button variant="text" onClick={onClose} disabled={deleteLocation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
{isError && (
|
||||
<Button variant="outlined" disabled={isFetching} onClick={() => void refetch()}>
|
||||
Try again
|
||||
</Button>
|
||||
)}
|
||||
{!countUnknown && openCount > 0 && (
|
||||
<Button variant="outlined" onClick={() => navigate(openWorkOrdersHref)}>
|
||||
View open work orders
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="contained"
|
||||
color="error"
|
||||
disabled={countUnknown || deleteLocation.isPending}
|
||||
onClick={() => deleteLocation.mutate(siteId, { onSuccess: onDeleted })}
|
||||
>
|
||||
{deleteLocation.isPending ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { Alert, Box, Button, CircularProgress, Drawer, Stack, Typography } from "@mui/material";
|
||||
import { 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";
|
||||
|
||||
|
|
@ -36,6 +39,8 @@ function PanelHeader({ location, onClose }: { location?: Location; onClose: () =
|
|||
|
||||
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 (
|
||||
|
|
@ -65,10 +70,22 @@ function PanelBody({ siteId, onClose }: { siteId: string; onClose: () => void })
|
|||
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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -9,14 +9,11 @@ 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";
|
||||
|
||||
|
|
@ -25,12 +22,10 @@ export default function LocationsListPage() {
|
|||
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]);
|
||||
|
|
@ -50,20 +45,6 @@ export default function LocationsListPage() {
|
|||
const rows = data?.items ?? [];
|
||||
const totalCount = data?.totalCount ?? 0;
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
if (!deleteTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deletedId = String(deleteTarget.id);
|
||||
deleteLocation.mutate(deleteTarget.id, {
|
||||
onSuccess: () => {
|
||||
setDeleteTarget(null);
|
||||
setSelectedId((current) => (current === deletedId ? null : current));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
className="flex flex-col gap-4 p-4"
|
||||
|
|
@ -120,7 +101,6 @@ export default function LocationsListPage() {
|
|||
rows={rows}
|
||||
selectedId={selectedId}
|
||||
onOpen={(row) => setSelectedId(String(row.id))}
|
||||
onDelete={setDeleteTarget}
|
||||
/>
|
||||
<TablePagination
|
||||
component="div"
|
||||
|
|
@ -136,12 +116,6 @@ export default function LocationsListPage() {
|
|||
/>
|
||||
<AddSiteDialog open={isAddOpen} onClose={() => setIsAddOpen(false)} />
|
||||
<SiteDetailPanel siteId={selectedId} onClose={closePanel} />
|
||||
<LocationDeleteDialog
|
||||
target={deleteTarget}
|
||||
isPending={deleteLocation.isPending}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import { Mail } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { TeamMemberDetail } from "@/domain/team-members/types/team-member";
|
||||
import { useResendTeamMemberInvite } from "@/domain/team-members/use-cases/use-resend-team-member-invite";
|
||||
|
||||
/** Re-sends the registration invite to an active member who has not registered yet. */
|
||||
export function ResendInviteButton({ member }: { member: TeamMemberDetail }) {
|
||||
const resendInvite = useResendTeamMemberInvite();
|
||||
|
||||
if (!member.pendingRegistration || member.status !== "Active") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => resendInvite.mutate({ id: member.id, email: member.email })}
|
||||
loading={resendInvite.isPending}
|
||||
loadingText="Sending…"
|
||||
startIcon={<Mail size={15} aria-hidden="true" />}
|
||||
>
|
||||
Resend invite
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -37,6 +37,7 @@ import {
|
|||
import { getFormErrors } from "./team-member-permissions";
|
||||
import { TeamMemberFormFields, type TeamMemberForm } from "./add-team-member-dialog";
|
||||
import { canonicalRole, resolveFormColor } from "./team-member-form-options";
|
||||
import { ResendInviteButton } from "./resend-invite-button";
|
||||
import {
|
||||
useTeamMemberDetail,
|
||||
useUpdateTeamMember,
|
||||
|
|
@ -206,6 +207,7 @@ function MemberEditForm({
|
|||
}
|
||||
|
||||
function DrawerFooter({
|
||||
member,
|
||||
editing,
|
||||
form,
|
||||
setForm,
|
||||
|
|
@ -215,6 +217,7 @@ function DrawerFooter({
|
|||
onEdit,
|
||||
onSave,
|
||||
}: {
|
||||
member: TeamMemberDetail;
|
||||
editing: boolean;
|
||||
form: TeamMemberForm;
|
||||
setForm: Dispatch<SetStateAction<TeamMemberForm>>;
|
||||
|
|
@ -257,7 +260,8 @@ function DrawerFooter({
|
|||
</Stack>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack direction="row" sx={{ justifyContent: "flex-end" }}>
|
||||
<Stack direction="row" sx={{ justifyContent: "flex-end", gap: 1.5 }}>
|
||||
<ResendInviteButton member={member} />
|
||||
<Button variant="contained" onClick={onEdit}>
|
||||
Edit
|
||||
</Button>
|
||||
|
|
@ -475,6 +479,7 @@ export function TeamMemberDetailDrawer({
|
|||
|
||||
{currentMember !== undefined && (
|
||||
<DrawerFooter
|
||||
member={currentMember}
|
||||
editing={editor.editing}
|
||||
form={editor.form}
|
||||
setForm={editor.setForm}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
export const UPLIFT_APPROVAL_TABS = ["pending", "approved", "rejected"] as const;
|
||||
export type UpliftApprovalTab = (typeof UPLIFT_APPROVAL_TABS)[number];
|
||||
|
||||
export function isUpliftApprovalTab(value: string): value is UpliftApprovalTab {
|
||||
return (UPLIFT_APPROVAL_TABS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
const PENDING_HEADER_CELLS = [
|
||||
"Work Order",
|
||||
"Service / Vendor",
|
||||
"Amount",
|
||||
"Approved on WO",
|
||||
"Notes",
|
||||
"Attachments",
|
||||
"Requested By",
|
||||
"Waiting",
|
||||
"Actions",
|
||||
] as const;
|
||||
|
||||
const APPROVED_HEADER_CELLS = [
|
||||
"Work Order",
|
||||
"Service / Vendor",
|
||||
"Amount",
|
||||
"Approved on WO",
|
||||
"Notes",
|
||||
"Attachments",
|
||||
"Requested By",
|
||||
"Approved By",
|
||||
"Approved At",
|
||||
"Actions",
|
||||
] as const;
|
||||
|
||||
// A rejection is terminal, so the archive ends with the decision and its reason
|
||||
// instead of an Actions column.
|
||||
const REJECTED_HEADER_CELLS = [
|
||||
"Work Order",
|
||||
"Service / Vendor",
|
||||
"Amount",
|
||||
"Approved on WO",
|
||||
"Notes",
|
||||
"Attachments",
|
||||
"Requested By",
|
||||
"Rejected By",
|
||||
"Rejected At",
|
||||
"Rejection Reason",
|
||||
] as const;
|
||||
|
||||
export const UPLIFT_APPROVAL_HEADER_CELLS: Record<UpliftApprovalTab, readonly string[]> = {
|
||||
pending: PENDING_HEADER_CELLS,
|
||||
approved: APPROVED_HEADER_CELLS,
|
||||
rejected: REJECTED_HEADER_CELLS,
|
||||
};
|
||||
|
|
@ -16,14 +16,20 @@ import {
|
|||
formatUpliftMoney,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
import HighlightOffOutlinedIcon from "@mui/icons-material/HighlightOffOutlined";
|
||||
import { Box, TableCell } from "@mui/material";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
||||
import { formatDateTime } from "@/lib/time-utils";
|
||||
|
||||
export function RejectedRowCells({ row }: { row: UpliftQueueItem }) {
|
||||
return (
|
||||
<>
|
||||
<TableCell>{row.decidedByName || "—"}</TableCell>
|
||||
<TableCell>{row.decidedAt ? formatDateTime(row.decidedAt) : "—"}</TableCell>
|
||||
<TableCell sx={{ maxWidth: 260 }}>
|
||||
<Text variant="caption" sx={{ whiteSpace: "pre-wrap" }}>
|
||||
{row.decisionNote || "—"}
|
||||
</Text>
|
||||
</TableCell>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function RejectedEmptyState() {
|
||||
return (
|
||||
<Box className="flex flex-col items-center gap-2">
|
||||
<HighlightOffOutlinedIcon color="disabled" aria-hidden />
|
||||
<Text variant="description">No rejected uplifts.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
import { useState } from "react";
|
||||
import { 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}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
import type { ReactNode } from "react";
|
||||
import {
|
||||
PAGE_BOTTOM_BAR_PX,
|
||||
PAGE_BOTTOM_PADDING_PX,
|
||||
PAGE_GAP_PX,
|
||||
PAGE_TOP_BAR_PX,
|
||||
type PagePosition,
|
||||
} from "@/domain/work-orders/utils/completion-doc-pagination";
|
||||
import { useCompDocPagination } from "./use-comp-doc-pagination";
|
||||
|
||||
type CompDocDocumentFrameProps = {
|
||||
/** Letterhead and opening content, printed once at the top of page 1. */
|
||||
header: ReactNode;
|
||||
/** Blocks (procedures, comments) laid out across pages; never split. */
|
||||
sections: readonly ReactNode[];
|
||||
/** Greyed-out pages, as for a document marked not required. */
|
||||
dimmed?: boolean;
|
||||
/** Stamped over the first sheet, outside the dimmed layer. */
|
||||
overlay?: ReactNode;
|
||||
/** Page being read and page count, updated on layout and on scroll. */
|
||||
onPageChange?: (position: PagePosition) => void;
|
||||
};
|
||||
|
||||
/** Contains child margins so a block's measured height is its full height. */
|
||||
const flowRoot = { display: "flow-root" } as const;
|
||||
|
||||
/**
|
||||
* The printed document: A4 sheets stacked in a scrollable column, the letterhead
|
||||
* on the first sheet only and the brand bars at the top and bottom of every sheet.
|
||||
*/
|
||||
export function CompDocDocumentFrame({
|
||||
header,
|
||||
sections,
|
||||
dimmed = false,
|
||||
overlay,
|
||||
onPageChange,
|
||||
}: CompDocDocumentFrameProps) {
|
||||
const { pages, pageHeight, scrollerRef, sheetsRef, headerRef, blockRefs, pageRefs, onScroll } =
|
||||
useCompDocPagination(sections.length, onPageChange);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
onScroll={onScroll}
|
||||
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
|
||||
style={{ padding: "0 20px 20px", scrollbarGutter: "stable" }}
|
||||
data-testid="comp-doc-document"
|
||||
>
|
||||
<div ref={sheetsRef} className="relative">
|
||||
{pages.map((blockIndices, pageIndex) => (
|
||||
<div
|
||||
key={pageIndex}
|
||||
ref={(el) => {
|
||||
pageRefs.current[pageIndex] = el;
|
||||
}}
|
||||
data-testid="comp-doc-page"
|
||||
className="relative flex flex-col break-words"
|
||||
style={{
|
||||
marginTop: pageIndex > 0 ? PAGE_GAP_PX : 0,
|
||||
minHeight: pageHeight,
|
||||
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
|
||||
className="flex flex-1 flex-col"
|
||||
style={{
|
||||
opacity: dimmed ? 0.5 : 1,
|
||||
filter: dimmed ? "grayscale(0.4)" : "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-testid="comp-doc-page-top-bar"
|
||||
style={{
|
||||
height: PAGE_TOP_BAR_PX,
|
||||
flexShrink: 0,
|
||||
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
|
||||
}}
|
||||
/>
|
||||
<div style={{ padding: `0 28px ${PAGE_BOTTOM_PADDING_PX}px` }}>
|
||||
{pageIndex === 0 && (
|
||||
<div ref={headerRef} style={flowRoot}>
|
||||
{header}
|
||||
</div>
|
||||
)}
|
||||
{blockIndices.map((blockIndex) => (
|
||||
<div
|
||||
key={blockIndex}
|
||||
ref={(el) => {
|
||||
blockRefs.current[blockIndex] = el;
|
||||
}}
|
||||
style={flowRoot}
|
||||
>
|
||||
{sections[blockIndex]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
data-testid="comp-doc-page-bottom-bar"
|
||||
style={{
|
||||
height: PAGE_BOTTOM_BAR_PX,
|
||||
flexShrink: 0,
|
||||
marginTop: "auto",
|
||||
background: "linear-gradient(100deg,#1C75BC,#27AAE1)",
|
||||
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{pageIndex === 0 && overlay}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,15 @@
|
|||
import { CheckCircle2, FileText } from "lucide-react";
|
||||
import {
|
||||
pageIndicatorLabel,
|
||||
type PagePosition,
|
||||
} from "@/domain/work-orders/utils/completion-doc-pagination";
|
||||
|
||||
type CompDocPreviewBadgeProps = {
|
||||
isGenerated: boolean;
|
||||
page: PagePosition;
|
||||
};
|
||||
|
||||
export function CompDocPreviewBadge({ isGenerated }: CompDocPreviewBadgeProps) {
|
||||
export function CompDocPreviewBadge({ isGenerated, page }: CompDocPreviewBadgeProps) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center justify-center" style={{ padding: "12px 0" }}>
|
||||
<span
|
||||
|
|
@ -26,6 +31,7 @@ export function CompDocPreviewBadge({ isGenerated }: CompDocPreviewBadgeProps) {
|
|||
<FileText size={13} />
|
||||
)}
|
||||
{isGenerated ? "Generated document" : "Preview — not yet generated"}
|
||||
{pageIndicatorLabel(page)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,46 +1,20 @@
|
|||
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";
|
||||
|
||||
type CompDocPreviewProcedureProps = {
|
||||
row: WorkOrderTableRow;
|
||||
notes: string;
|
||||
};
|
||||
|
||||
export function CompDocPreviewProcedure({ row, notes }: CompDocPreviewProcedureProps) {
|
||||
/** The work order's procedure: heading, intro and steps, kept together on one page. */
|
||||
export function CompDocPreviewProcedure({ row }: CompDocPreviewProcedureProps) {
|
||||
const proc = procedureFor(row.pm);
|
||||
|
||||
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>
|
||||
|
||||
<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={{
|
||||
|
|
@ -53,35 +27,27 @@ export function CompDocPreviewProcedure({ row, notes }: CompDocPreviewProcedureP
|
|||
{proc.intro}
|
||||
</Text>
|
||||
<CompDocProcedureList proc={proc} />
|
||||
|
||||
{notes.length > 0 && (
|
||||
<>
|
||||
<Text
|
||||
as="h3"
|
||||
variant="heading"
|
||||
sx={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "#161E38",
|
||||
margin: "16px 0 4px",
|
||||
}}
|
||||
>
|
||||
PM Comments
|
||||
</Text>
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.5,
|
||||
color: "#161E38",
|
||||
margin: 0,
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{notes}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** The PM's free-text comments printed after the procedure. */
|
||||
export function CompDocPreviewComments({ notes }: { notes: string }) {
|
||||
return (
|
||||
<>
|
||||
<CompDocProcedureHeading>PM Comments</CompDocProcedureHeading>
|
||||
<Text
|
||||
variant="body"
|
||||
sx={{
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.5,
|
||||
color: "#161E38",
|
||||
margin: 0,
|
||||
whiteSpace: "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{notes}
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,14 @@
|
|||
import { useState } from "react";
|
||||
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
|
||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||
import type { PagePosition } from "@/domain/work-orders/utils/completion-doc-pagination";
|
||||
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";
|
||||
import { CompDocPreviewProcedure } from "./comp-doc-preview-procedure";
|
||||
import { CompDocPreviewComments, CompDocPreviewProcedure } from "./comp-doc-preview-procedure";
|
||||
import { CompDocPreviewSummary } from "./comp-doc-preview-summary";
|
||||
import { CompDocSafetyBullets } from "./comp-doc-safety-bullets";
|
||||
|
||||
type CompDocPreviewProps = {
|
||||
row: WorkOrderTableRow;
|
||||
|
|
@ -23,6 +27,10 @@ export function CompDocPreview({
|
|||
isGenerated,
|
||||
isNotRequired,
|
||||
}: CompDocPreviewProps) {
|
||||
const [page, setPage] = useState<PagePosition>({ current: 1, total: 1 });
|
||||
const sections = [<CompDocPreviewProcedure key="procedure" row={row} />];
|
||||
if (notes.length > 0) sections.push(<CompDocPreviewComments key="notes" notes={notes} />);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-0 flex-col"
|
||||
|
|
@ -32,58 +40,26 @@ export function CompDocPreview({
|
|||
background: "var(--color-bg-muted)",
|
||||
}}
|
||||
>
|
||||
<CompDocPreviewBadge isGenerated={isGenerated} />
|
||||
<CompDocPreviewBadge isGenerated={isGenerated} page={page} />
|
||||
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
|
||||
style={{ padding: "0 20px 20px" }}
|
||||
>
|
||||
<div
|
||||
className="relative break-words"
|
||||
style={{
|
||||
background: "#ffffff",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,.08), 0 12px 32px -16px rgba(0,0,0,.25)",
|
||||
overflow: "hidden",
|
||||
fontFamily: "var(--font-sans)",
|
||||
overflowWrap: "break-word",
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
opacity: isNotRequired ? 0.5 : 1,
|
||||
filter: isNotRequired ? "grayscale(0.4)" : "none",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: 12,
|
||||
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
|
||||
}}
|
||||
<CompDocDocumentFrame
|
||||
dimmed={isNotRequired}
|
||||
overlay={isNotRequired && <CompDocNotRequiredStamp />}
|
||||
onPageChange={setPage}
|
||||
header={
|
||||
<>
|
||||
<CompDocPreviewHeader />
|
||||
<CompDocPreviewSummary
|
||||
row={row}
|
||||
site={site}
|
||||
generatedAt={generatedAt}
|
||||
isGenerated={isGenerated}
|
||||
/>
|
||||
<div style={{ padding: "0 28px 28px" }}>
|
||||
<CompDocPreviewHeader />
|
||||
<CompDocPreviewSummary
|
||||
row={row}
|
||||
site={site}
|
||||
generatedAt={generatedAt}
|
||||
isGenerated={isGenerated}
|
||||
/>
|
||||
<CompDocPreviewProcedure row={row} notes={notes} />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: 24,
|
||||
background: "linear-gradient(100deg,#1C75BC,#27AAE1)",
|
||||
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isNotRequired && <CompDocNotRequiredStamp />}
|
||||
</div>
|
||||
</div>
|
||||
<CompDocSafetyBullets />
|
||||
</>
|
||||
}
|
||||
sections={sections}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { Text } from "@/components/ui/text";
|
||||
|
||||
export function CompDocProcedureHeading({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Text
|
||||
as="h3"
|
||||
variant="heading"
|
||||
sx={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: "#161E38",
|
||||
margin: "16px 0 4px",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
|
||||
|
||||
type CompDocSafetyBulletsProps = {
|
||||
/** Template-specific notes printed after the standard bullets. */
|
||||
extra?: readonly string[];
|
||||
};
|
||||
|
||||
export function CompDocSafetyBullets({ extra = [] }: CompDocSafetyBulletsProps) {
|
||||
return (
|
||||
<ul style={{ listStyle: "disc", paddingLeft: 18, margin: "8px 0 0" }}>
|
||||
{[...SAFETY_BULLETS, ...extra].map((b, i) => (
|
||||
<li
|
||||
key={i}
|
||||
style={{
|
||||
fontSize: 12.5,
|
||||
lineHeight: 1.5,
|
||||
margin: "5px 0",
|
||||
color: "#161E38",
|
||||
whiteSpace: i < SAFETY_BULLETS.length ? undefined : "pre-wrap",
|
||||
}}
|
||||
>
|
||||
{b}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import {
|
||||
a4PageHeight,
|
||||
coverAllBlocks,
|
||||
currentPageAt,
|
||||
pageContentHeight,
|
||||
paginateBlocks,
|
||||
samePages,
|
||||
type PagePosition,
|
||||
} from "@/domain/work-orders/utils/completion-doc-pagination";
|
||||
|
||||
type PageLayout = {
|
||||
pages: number[][];
|
||||
/** Sheet height in px; undefined until the sheets have a measurable width. */
|
||||
pageHeight: number | undefined;
|
||||
};
|
||||
|
||||
const heightOf = (el: Element | null | undefined) => el?.getBoundingClientRect().height ?? 0;
|
||||
|
||||
function allOnOnePage(count: number): number[][] {
|
||||
return [Array.from({ length: count }, (_, i) => i)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures the rendered letterhead and blocks and splits them into A4 sheets.
|
||||
* Blocks are measured where they are rendered (every sheet has the same width),
|
||||
* and re-measured after every render and whenever anything observed resizes.
|
||||
*/
|
||||
export function useCompDocPagination(
|
||||
blockCount: number,
|
||||
onPageChange?: (position: PagePosition) => void,
|
||||
) {
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
const sheetsRef = useRef<HTMLDivElement>(null);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const blockRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const pageRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const [layout, setLayout] = useState<PageLayout>({ pages: [[]], pageHeight: undefined });
|
||||
const pages = coverAllBlocks(layout.pages, blockCount);
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const width = sheetsRef.current?.getBoundingClientRect().width ?? 0;
|
||||
const next: PageLayout =
|
||||
width > 0
|
||||
? {
|
||||
pages: paginateBlocks({
|
||||
headerHeight: heightOf(headerRef.current),
|
||||
blockHeights: Array.from({ length: blockCount }, (_, i) =>
|
||||
heightOf(blockRefs.current[i]),
|
||||
),
|
||||
pageHeight: pageContentHeight(width),
|
||||
}),
|
||||
pageHeight: a4PageHeight(width),
|
||||
}
|
||||
: { pages: allOnOnePage(blockCount), pageHeight: undefined };
|
||||
setLayout((prev) =>
|
||||
samePages(prev.pages, next.pages) && prev.pageHeight === next.pageHeight ? prev : next,
|
||||
);
|
||||
}, [blockCount]);
|
||||
|
||||
// Block content can change on any render (typing in the editor), so measure after each one.
|
||||
useLayoutEffect(() => {
|
||||
measure();
|
||||
});
|
||||
|
||||
const onPageChangeRef = useRef(onPageChange);
|
||||
const totalRef = useRef(pages.length);
|
||||
const lastReported = useRef<PagePosition | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
onPageChangeRef.current = onPageChange;
|
||||
totalRef.current = pages.length;
|
||||
});
|
||||
|
||||
const report = useCallback(() => {
|
||||
const total = totalRef.current;
|
||||
const tops = pageRefs.current.slice(0, total).map((el) => el?.offsetTop ?? 0);
|
||||
const current = Math.min(currentPageAt(tops, scrollerRef.current?.scrollTop ?? 0), total);
|
||||
const last = lastReported.current;
|
||||
if (last?.current === current && last.total === total) return;
|
||||
lastReported.current = { current, total };
|
||||
onPageChangeRef.current?.({ current, total });
|
||||
}, []);
|
||||
|
||||
// Any re-layout can move the sheet tops under the reader, even when the page count holds.
|
||||
useLayoutEffect(() => {
|
||||
report();
|
||||
});
|
||||
|
||||
// Width changes, late font/image loads and reflow do not re-render; observe them.
|
||||
const layoutKey = pages.map((page) => page.join(",")).join("|");
|
||||
useEffect(() => {
|
||||
const observer = new ResizeObserver(() => {
|
||||
measure();
|
||||
report();
|
||||
});
|
||||
[sheetsRef.current, headerRef.current, ...blockRefs.current.slice(0, blockCount)].forEach(
|
||||
(el) => el && observer.observe(el),
|
||||
);
|
||||
return () => observer.disconnect();
|
||||
}, [measure, report, blockCount, layoutKey]);
|
||||
|
||||
return {
|
||||
pages,
|
||||
pageHeight: layout.pageHeight,
|
||||
scrollerRef,
|
||||
sheetsRef,
|
||||
headerRef,
|
||||
blockRefs,
|
||||
pageRefs,
|
||||
onScroll: report,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { extractLocationLabel } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
|
||||
import type { SitePatch } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-types";
|
||||
import { normalizeAdditionalContacts } from "@/domain/work-orders/mappers/additional-contacts-mapper";
|
||||
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||
import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
|
||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||
|
|
@ -14,6 +15,8 @@ type BuildSiteDialogPatchArgs = {
|
|||
extraContacts: WorkOrderAdditionalContact[];
|
||||
contactsDirty: boolean;
|
||||
baselineHadContacts: boolean;
|
||||
/** The dialog saved these contacts and notes to the site record, so the work order follows it. */
|
||||
followsSiteRecord?: boolean;
|
||||
};
|
||||
|
||||
export function buildSiteDialogPatch({
|
||||
|
|
@ -26,6 +29,7 @@ export function buildSiteDialogPatch({
|
|||
extraContacts,
|
||||
contactsDirty,
|
||||
baselineHadContacts,
|
||||
followsSiteRecord = false,
|
||||
}: BuildSiteDialogPatchArgs): SitePatch {
|
||||
const patch: SitePatch = {
|
||||
site: code || (selected ? resolveSiteCode(selected) : ""),
|
||||
|
|
@ -35,8 +39,11 @@ export function buildSiteDialogPatch({
|
|||
pocPhone,
|
||||
pocNotes,
|
||||
};
|
||||
if (contactsDirty || baselineHadContacts) {
|
||||
patch.additionalContacts = extraContacts;
|
||||
// Following the site record, the dialog's extra contacts are the site's: copy them only when edited.
|
||||
const sendContacts = followsSiteRecord ? contactsDirty : contactsDirty || baselineHadContacts;
|
||||
if (sendContacts) {
|
||||
// The work order keeps only complete name/phone rows; site contact ids stay on the site request.
|
||||
patch.additionalContacts = normalizeAdditionalContacts(extraContacts) ?? [];
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { ExternalLink, Mail, MapPin, Phone } from "lucide-react";
|
||||
import { Mail, MapPin, Phone } from "lucide-react";
|
||||
import { SiteMapPreview } from "@/app/(protected)/locations/_components/site-map-preview";
|
||||
import { Detail } from "@/app/(protected)/workorders/_components/wizard/wizard-labels";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import type { Location } from "@/domain/locations/types/location";
|
||||
|
|
@ -34,6 +35,9 @@ export function SiteDialogDetailPanel({
|
|||
addressPreview,
|
||||
fallbackLabel,
|
||||
}: SiteDialogDetailPanelProps) {
|
||||
// The site's own phone; a frozen snapshot carries it as `phone`.
|
||||
const sitePhone = locationDetail?.sitePhone ?? locationDetail?.phone ?? "";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="space-y-1.5 rounded-lg p-3"
|
||||
|
|
@ -44,24 +48,29 @@ export function SiteDialogDetailPanel({
|
|||
>
|
||||
{locationDetail ? (
|
||||
<>
|
||||
<Detail icon={MapPin} text={addressPreview || fallbackLabel} />
|
||||
{Boolean(locationDetail.phone) && (
|
||||
<Detail icon={Phone} text={locationDetail.phone ?? ""} />
|
||||
)}
|
||||
{Boolean(locationDetail.contactEmail) && (
|
||||
<Detail icon={Mail} text={locationDetail.contactEmail ?? ""} />
|
||||
)}
|
||||
{Boolean(addressPreview) && (
|
||||
<a
|
||||
href={buildMapsUrl(locationDetail)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-[12px] hover:underline"
|
||||
className="flex items-center gap-1.5 text-[12px] hover:underline"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
>
|
||||
<ExternalLink size={12} /> Open in Google Maps
|
||||
<MapPin size={12} className="shrink-0" aria-hidden />
|
||||
<span className="truncate">{addressPreview}</span>
|
||||
</a>
|
||||
)}
|
||||
{!addressPreview && <Detail icon={MapPin} text={fallbackLabel} />}
|
||||
{Boolean(sitePhone) && <Detail icon={Phone} text={sitePhone} />}
|
||||
{Boolean(locationDetail.contactEmail) && (
|
||||
<Detail icon={Mail} text={locationDetail.contactEmail ?? ""} />
|
||||
)}
|
||||
<SiteMapPreview
|
||||
address={locationDetail.address}
|
||||
city={locationDetail.city}
|
||||
state={locationDetail.state}
|
||||
zipCode={locationDetail.zipCode}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -4,9 +4,17 @@ type SiteDialogFooterProps = {
|
|||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
viewOnly?: boolean;
|
||||
saveDisabled?: boolean;
|
||||
saving?: boolean;
|
||||
};
|
||||
|
||||
export function SiteDialogFooter({ onClose, onConfirm, viewOnly = false }: SiteDialogFooterProps) {
|
||||
export function SiteDialogFooter({
|
||||
onClose,
|
||||
onConfirm,
|
||||
viewOnly = false,
|
||||
saveDisabled = false,
|
||||
saving = false,
|
||||
}: SiteDialogFooterProps) {
|
||||
if (viewOnly) {
|
||||
return (
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
|
|
@ -22,7 +30,13 @@ export function SiteDialogFooter({ onClose, onConfirm, viewOnly = false }: SiteD
|
|||
<Button variant="ghost" className="min-h-0 px-2 py-1 text-[12px]" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button className="min-h-0 px-3 py-1 text-[12px]" onClick={onConfirm}>
|
||||
<Button
|
||||
className="min-h-0 px-3 py-1 text-[12px]"
|
||||
disabled={saveDisabled}
|
||||
loading={saving}
|
||||
loadingText="Saving…"
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -8,12 +8,25 @@ type SiteDialogPocSectionProps = {
|
|||
additionalContacts: WorkOrderAdditionalContact[];
|
||||
showPrimaryErrors?: boolean;
|
||||
primaryRequired?: boolean;
|
||||
/** Edits go to the site record rather than only this work order. */
|
||||
editsSiteRecord?: boolean;
|
||||
/** The site record did not load, so Save writes this work order only. */
|
||||
siteRecordUnavailable?: boolean;
|
||||
onPocNameChange: (value: string) => void;
|
||||
onPocPhoneChange: (value: string) => void;
|
||||
onPocNotesChange: (value: string) => void;
|
||||
onAdditionalContactsChange: (contacts: WorkOrderAdditionalContact[]) => void;
|
||||
};
|
||||
|
||||
function helperText(editsSiteRecord: boolean, siteRecordUnavailable: boolean): string {
|
||||
if (!editsSiteRecord) {
|
||||
return "Auto-filled from site records when empty. Additional contacts persist on save.";
|
||||
}
|
||||
return siteRecordUnavailable
|
||||
? "Site record unavailable — changes apply to this work order only."
|
||||
: "Contacts and notes are saved to the site record and apply to all its work orders.";
|
||||
}
|
||||
|
||||
export function SiteDialogPocSection({
|
||||
pocName,
|
||||
pocPhone,
|
||||
|
|
@ -21,6 +34,8 @@ export function SiteDialogPocSection({
|
|||
additionalContacts,
|
||||
showPrimaryErrors = false,
|
||||
primaryRequired = false,
|
||||
editsSiteRecord = false,
|
||||
siteRecordUnavailable = false,
|
||||
onPocNameChange,
|
||||
onPocPhoneChange,
|
||||
onPocNotesChange,
|
||||
|
|
@ -38,7 +53,9 @@ export function SiteDialogPocSection({
|
|||
onPocPhoneChange={onPocPhoneChange}
|
||||
onPocNotesChange={onPocNotesChange}
|
||||
onAdditionalContactsChange={onAdditionalContactsChange}
|
||||
helperText="Auto-filled from site records when empty. Additional contacts persist on save."
|
||||
helperText={helperText(editsSiteRecord, siteRecordUnavailable)}
|
||||
// Site contacts carry only a name and phone, so a per-contact note could never reach the site.
|
||||
additionalContactNotes={!editsSiteRecord || siteRecordUnavailable}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ export function SiteDialog({
|
|||
additionalContacts,
|
||||
sites,
|
||||
createMode,
|
||||
viewOnly,
|
||||
frozenSite,
|
||||
onSave,
|
||||
});
|
||||
|
|
@ -84,9 +85,10 @@ export function SiteDialog({
|
|||
Select a site and edit its point of contact
|
||||
</Text>
|
||||
|
||||
<fieldset disabled={viewOnly} className="mt-3 space-y-3 border-0 p-0">
|
||||
{/* Locked while saving: Save sends the values it had when clicked, so later edits would be lost. */}
|
||||
<fieldset disabled={viewOnly || s.saving} className="mt-3 space-y-3 border-0 p-0">
|
||||
<div>
|
||||
<FieldLabel required={createMode}>Site</FieldLabel>
|
||||
<FieldLabel required={s.requiresPoc}>Site</FieldLabel>
|
||||
<WizardFieldSelect
|
||||
value={s.locId}
|
||||
placeholder="Select site …"
|
||||
|
|
@ -107,7 +109,7 @@ export function SiteDialog({
|
|||
<Text
|
||||
as="p"
|
||||
variant="error"
|
||||
when={Boolean(createMode && s.showErrors && s.siteMissing)}
|
||||
when={Boolean(s.requiresPoc && s.showErrors && s.siteMissing)}
|
||||
className="mt-1 text-[11px]"
|
||||
>
|
||||
Site is required
|
||||
|
|
@ -124,21 +126,36 @@ export function SiteDialog({
|
|||
/>
|
||||
)}
|
||||
|
||||
<SiteDialogPocSection
|
||||
pocName={s.pn}
|
||||
pocPhone={s.pp}
|
||||
pocNotes={s.notes}
|
||||
additionalContacts={s.extraContacts}
|
||||
showPrimaryErrors={createMode && s.showErrors}
|
||||
primaryRequired={createMode}
|
||||
onPocNameChange={s.setPn}
|
||||
onPocPhoneChange={s.setPp}
|
||||
onPocNotesChange={s.setNotes}
|
||||
onAdditionalContactsChange={s.setExtraContacts}
|
||||
/>
|
||||
{/* Locked until the site record loads, so its values never replace typed input. */}
|
||||
<fieldset
|
||||
disabled={s.siteRecordLoading}
|
||||
aria-busy={s.siteRecordLoading || undefined}
|
||||
className="m-0 border-0 p-0"
|
||||
>
|
||||
<SiteDialogPocSection
|
||||
pocName={s.pn}
|
||||
pocPhone={s.pp}
|
||||
pocNotes={s.notes}
|
||||
additionalContacts={s.extraContacts}
|
||||
showPrimaryErrors={s.requiresPoc && s.showErrors}
|
||||
primaryRequired={s.requiresPoc}
|
||||
editsSiteRecord={s.editsSiteRecord}
|
||||
siteRecordUnavailable={s.siteRecordUnavailable}
|
||||
onPocNameChange={s.setPn}
|
||||
onPocPhoneChange={s.setPp}
|
||||
onPocNotesChange={s.setNotes}
|
||||
onAdditionalContactsChange={s.setExtraContacts}
|
||||
/>
|
||||
</fieldset>
|
||||
</fieldset>
|
||||
|
||||
<SiteDialogFooter viewOnly={viewOnly} onClose={s.close} onConfirm={s.attemptSave} />
|
||||
<SiteDialogFooter
|
||||
viewOnly={viewOnly}
|
||||
saveDisabled={s.saveDisabled}
|
||||
saving={s.saving}
|
||||
onClose={s.close}
|
||||
onConfirm={s.attemptSave}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@ import {
|
|||
type UpliftCellProps = {
|
||||
summary: UpliftSummary;
|
||||
onOpen?: () => void;
|
||||
/** Closed work orders show a dash (not "Manage") that still opens the read-only history. */
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
export function UpliftCell({ summary, onOpen }: UpliftCellProps) {
|
||||
export function UpliftCell({ summary, onOpen, readOnly = false }: UpliftCellProps) {
|
||||
const label = getUpliftCellLabel(summary);
|
||||
|
||||
if (!onOpen) {
|
||||
|
|
@ -30,7 +32,7 @@ export function UpliftCell({ summary, onOpen }: UpliftCellProps) {
|
|||
e.stopPropagation();
|
||||
onOpen();
|
||||
}}
|
||||
title={label ? "View uplifts" : "Manage uplifts"}
|
||||
title={label || readOnly ? "View uplifts" : "Manage uplifts"}
|
||||
className="flex w-full items-center justify-start transition-opacity hover:opacity-80"
|
||||
>
|
||||
{label && pillStyle ? (
|
||||
|
|
@ -40,6 +42,8 @@ export function UpliftCell({ summary, onOpen }: UpliftCellProps) {
|
|||
>
|
||||
{label}
|
||||
</span>
|
||||
) : readOnly ? (
|
||||
<span className="text-[11px] text-[var(--color-text-muted)]">—</span>
|
||||
) : (
|
||||
<span className="text-[11px] font-semibold text-[var(--color-primary)]">Manage</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,20 @@ type UseSiteDialogFormFieldsArgs = {
|
|||
createMode: boolean;
|
||||
};
|
||||
|
||||
function sameContacts(a: WorkOrderAdditionalContact[], b: WorkOrderAdditionalContact[]): boolean {
|
||||
return a === b || JSON.stringify(a) === JSON.stringify(b);
|
||||
}
|
||||
|
||||
/** Keeps one array identity while the contacts are unchanged, so a fresh `[]` does not reset. */
|
||||
function useStableContacts(contacts: WorkOrderAdditionalContact[]): WorkOrderAdditionalContact[] {
|
||||
const [stable, setStable] = useState(contacts);
|
||||
if (!sameContacts(stable, contacts)) {
|
||||
setStable(contacts);
|
||||
return contacts;
|
||||
}
|
||||
return stable;
|
||||
}
|
||||
|
||||
export function useSiteDialogFormFields({
|
||||
open,
|
||||
value,
|
||||
|
|
@ -23,10 +37,11 @@ export function useSiteDialogFormFields({
|
|||
pocName,
|
||||
pocPhone,
|
||||
pocNotes,
|
||||
additionalContacts,
|
||||
additionalContacts: incomingContacts,
|
||||
sites,
|
||||
createMode,
|
||||
}: UseSiteDialogFormFieldsArgs) {
|
||||
const additionalContacts = useStableContacts(incomingContacts);
|
||||
const [code, setCode] = useState(value);
|
||||
const [locId, setLocId] = useState(() => resolveLocationId(locationId, value, sites));
|
||||
const [pn, setPn] = useState(pocName);
|
||||
|
|
@ -36,15 +51,20 @@ export function useSiteDialogFormFields({
|
|||
const [pocFilledFor, setPocFilledFor] = useState("");
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [contactsDirty, setContactsDirty] = useState(false);
|
||||
// The user typed into the contact fields since the last reset or site switch.
|
||||
const [userEdited, setUserEdited] = useState(false);
|
||||
const [baselineHadContacts, setBaselineHadContacts] = useState(
|
||||
() => additionalContacts.length > 0,
|
||||
);
|
||||
const [showErrors, setShowErrors] = useState(false);
|
||||
// Bumped whenever the fields are reset from the row, so site-record sync can re-apply.
|
||||
const [resetVersion, setResetVersion] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setDirty(false);
|
||||
setContactsDirty(false);
|
||||
setUserEdited(false);
|
||||
setShowErrors(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
|
@ -62,6 +82,7 @@ export function useSiteDialogFormFields({
|
|||
setExtraContacts(additionalContacts);
|
||||
setBaselineHadContacts(additionalContacts.length > 0);
|
||||
setPocFilledFor(pocName.trim() || pocPhone.trim() ? resolved : "");
|
||||
setResetVersion((version) => version + 1);
|
||||
}, [open, dirty, value, locationId, pocName, pocPhone, pocNotes, additionalContacts, sites]);
|
||||
|
||||
const handlePick = (id: string) => {
|
||||
|
|
@ -74,10 +95,13 @@ export function useSiteDialogFormFields({
|
|||
if (siteChanged) {
|
||||
setExtraContacts([]);
|
||||
setContactsDirty(true);
|
||||
setUserEdited(false);
|
||||
setDirty(true);
|
||||
if (createMode) {
|
||||
setPn("");
|
||||
setPp("");
|
||||
// The previous site's contact never carries over, even when the new site fails to load.
|
||||
setPn("");
|
||||
setPp("");
|
||||
if (!createMode) {
|
||||
setNotes("");
|
||||
}
|
||||
setPocFilledFor("");
|
||||
} else if (id !== pocFilledFor) {
|
||||
|
|
@ -87,6 +111,7 @@ export function useSiteDialogFormFields({
|
|||
|
||||
const markPocDirty = (apply: () => void) => {
|
||||
setDirty(true);
|
||||
setUserEdited(true);
|
||||
apply();
|
||||
};
|
||||
|
||||
|
|
@ -100,6 +125,8 @@ export function useSiteDialogFormFields({
|
|||
pocFilledFor,
|
||||
dirty,
|
||||
contactsDirty,
|
||||
userEdited,
|
||||
resetVersion,
|
||||
baselineHadContacts,
|
||||
showErrors,
|
||||
selected: sites.find((s) => String(s.id) === locId),
|
||||
|
|
@ -108,6 +135,8 @@ export function useSiteDialogFormFields({
|
|||
setPn,
|
||||
setPp,
|
||||
setShowErrors,
|
||||
setNotes,
|
||||
setExtraContacts,
|
||||
markDirty: () => setDirty(true),
|
||||
handlePick,
|
||||
setPnDirty: (next: string) => markPocDirty(() => setPn(next)),
|
||||
|
|
@ -115,6 +144,7 @@ export function useSiteDialogFormFields({
|
|||
setNotesDirty: (next: string) => markPocDirty(() => setNotes(next)),
|
||||
setExtraContactsDirty: (contacts: WorkOrderAdditionalContact[]) => {
|
||||
setDirty(true);
|
||||
setUserEdited(true);
|
||||
setContactsDirty(true);
|
||||
setExtraContacts(contacts);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,82 @@
|
|||
import { buildSiteDialogPatch } from "@/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch";
|
||||
import type { SitePatch } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-types";
|
||||
import { resolveLocationId } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
|
||||
import type { useSiteDialogFormFields } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields";
|
||||
import {
|
||||
workOrderPocAfterSiteSave,
|
||||
type useSiteRecordSync,
|
||||
type WorkOrderPoc,
|
||||
} from "@/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync";
|
||||
import { useUpdateSiteContactInfo } from "@/domain/locations/use-cases/use-update-site-contact-info";
|
||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||
|
||||
type UseSiteDialogSaveArgs = {
|
||||
fields: ReturnType<typeof useSiteDialogFormFields>;
|
||||
siteRecord: ReturnType<typeof useSiteRecordSync>;
|
||||
/** The work order's values when the dialog opened. */
|
||||
original: WorkOrderPoc & { locationId: string | number; value: string; sites: LocationOption[] };
|
||||
createMode: boolean;
|
||||
/** The first contact is required before Save (inline create, or editing the site record). */
|
||||
requiresPoc: boolean;
|
||||
onSave: (patch: SitePatch) => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The Site dialog's Save: writes changed contacts and notes to the site record first when the
|
||||
* dialog edits it, then patches the work order and closes.
|
||||
*/
|
||||
export function useSiteDialogSave({
|
||||
fields,
|
||||
siteRecord,
|
||||
original,
|
||||
createMode,
|
||||
requiresPoc,
|
||||
onSave,
|
||||
onOpenChange,
|
||||
}: UseSiteDialogSaveArgs) {
|
||||
const updateSite = useUpdateSiteContactInfo();
|
||||
const pocMissing = !fields.pn.trim() || !fields.pp.trim();
|
||||
const canConfirm = !fields.siteMissing && (!requiresPoc || !pocMissing);
|
||||
|
||||
const saveWorkOrder = () => {
|
||||
const poc = workOrderPocAfterSiteSave(
|
||||
siteRecord,
|
||||
original,
|
||||
{ pocName: fields.pn, pocPhone: fields.pp, pocNotes: fields.notes },
|
||||
fields.locId !== resolveLocationId(original.locationId, original.value, original.sites),
|
||||
);
|
||||
onSave(
|
||||
buildSiteDialogPatch({
|
||||
code: fields.code,
|
||||
locId: fields.locId,
|
||||
selected: fields.selected,
|
||||
...poc,
|
||||
extraContacts: fields.extraContacts,
|
||||
contactsDirty: fields.contactsDirty,
|
||||
baselineHadContacts: fields.baselineHadContacts,
|
||||
followsSiteRecord: siteRecord.synced,
|
||||
}),
|
||||
);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const attemptSave = () => {
|
||||
if (requiresPoc && !canConfirm) {
|
||||
fields.setShowErrors(true);
|
||||
return;
|
||||
}
|
||||
if (!siteRecord.siteChanged) {
|
||||
saveWorkOrder();
|
||||
return;
|
||||
}
|
||||
updateSite.mutate({ id: fields.locId, ...siteRecord.request }, { onSuccess: saveWorkOrder });
|
||||
};
|
||||
|
||||
return {
|
||||
attemptSave,
|
||||
// Save is actionable only once something changed; inline create keeps its confirm step.
|
||||
saveDisabled: updateSite.isPending || siteRecord.loading || (!createMode && !fields.dirty),
|
||||
saving: updateSite.isPending,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,9 +1,11 @@
|
|||
import { useEffect, useMemo } from "react";
|
||||
import { buildSiteDialogPatch } from "@/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch";
|
||||
import type { SitePatch } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-types";
|
||||
import { useSiteDialogFormFields } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-form-fields";
|
||||
import { useSiteDialogSave } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-dialog-save";
|
||||
import { useSiteRecordSync } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync";
|
||||
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
|
||||
import type { Location } from "@/domain/locations/types/location";
|
||||
import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
|
||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||
import type { WorkOrderFrozenSite } from "@/domain/work-orders/types/work-order-table-row";
|
||||
|
|
@ -21,10 +23,25 @@ type UseSiteDialogStateArgs = {
|
|||
additionalContacts: WorkOrderAdditionalContact[];
|
||||
sites: LocationOption[];
|
||||
createMode: boolean;
|
||||
viewOnly?: boolean;
|
||||
frozenSite?: WorkOrderFrozenSite;
|
||||
onSave: (patch: SitePatch) => void;
|
||||
};
|
||||
|
||||
/** A completed work order's frozen snapshot, shaped like the live site detail. */
|
||||
function frozenSiteDetail(frozenSite: WorkOrderFrozenSite): Location {
|
||||
return {
|
||||
name: frozenSite.label,
|
||||
address: frozenSite.address,
|
||||
city: frozenSite.city,
|
||||
state: frozenSite.state,
|
||||
zipCode: frozenSite.zip,
|
||||
phone: frozenSite.phone,
|
||||
contact: undefined,
|
||||
contactEmail: frozenSite.email,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSiteDialogState({
|
||||
open,
|
||||
onOpenChange,
|
||||
|
|
@ -36,6 +53,7 @@ export function useSiteDialogState({
|
|||
additionalContacts = [],
|
||||
sites,
|
||||
createMode,
|
||||
viewOnly = false,
|
||||
frozenSite,
|
||||
onSave,
|
||||
}: UseSiteDialogStateArgs) {
|
||||
|
|
@ -50,65 +68,51 @@ export function useSiteDialogState({
|
|||
sites,
|
||||
createMode,
|
||||
});
|
||||
const { locId, pocFilledFor, pn, pp, setPn, setPp, setPocFilledFor, siteMissing } = fields;
|
||||
const {
|
||||
data: liveLocationDetail,
|
||||
isLoading: liveLocationDetailLoading,
|
||||
isError: liveLocationDetailError,
|
||||
} = useLocationDetail(frozenSite == null && open && locId ? locId : undefined);
|
||||
const { locId, pocFilledFor, setPn, setPp, setPocFilledFor } = fields;
|
||||
const live = frozenSite == null;
|
||||
const liveDetail = useLocationDetail(live && open && locId ? locId : undefined);
|
||||
const frozenLocationDetail = useMemo(
|
||||
() =>
|
||||
frozenSite == null
|
||||
? undefined
|
||||
: {
|
||||
name: frozenSite.label,
|
||||
address: frozenSite.address,
|
||||
city: frozenSite.city,
|
||||
state: frozenSite.state,
|
||||
zipCode: frozenSite.zip,
|
||||
phone: frozenSite.phone,
|
||||
contact: undefined,
|
||||
contactEmail: frozenSite.email,
|
||||
},
|
||||
() => (frozenSite == null ? undefined : frozenSiteDetail(frozenSite)),
|
||||
[frozenSite],
|
||||
);
|
||||
const locationDetail = frozenLocationDetail ?? liveLocationDetail;
|
||||
const locationDetailLoading = frozenSite == null && liveLocationDetailLoading;
|
||||
const locationDetailError = frozenSite == null && liveLocationDetailError;
|
||||
const locationDetail = frozenLocationDetail ?? liveDetail.data;
|
||||
const locationDetailLoading = live && liveDetail.isLoading;
|
||||
const locationDetailError = live && liveDetail.isError;
|
||||
|
||||
const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
|
||||
const pocMissing = !pn.trim() || !pp.trim();
|
||||
const canConfirm = !siteMissing && (!createMode || !pocMissing);
|
||||
// An existing, editable work order edits the site record itself (contacts and notes).
|
||||
const editsSiteRecord = !createMode && !viewOnly && live;
|
||||
const siteRecord = useSiteRecordSync({
|
||||
enabled: editsSiteRecord,
|
||||
open,
|
||||
locId,
|
||||
locationDetail: liveDetail.data,
|
||||
locationDetailFetching: liveDetail.isFetching,
|
||||
locationDetailError: liveDetail.isError,
|
||||
fields,
|
||||
});
|
||||
// The site failed to load (or the user typed before it did), so Save writes this work order only.
|
||||
const siteRecordUnavailable =
|
||||
editsSiteRecord && Boolean(locId) && !siteRecord.synced && !siteRecord.loading;
|
||||
const requiresPoc = createMode || editsSiteRecord;
|
||||
const save = useSiteDialogSave({
|
||||
fields,
|
||||
siteRecord,
|
||||
original: { pocName, pocPhone, pocNotes, locationId, value, sites },
|
||||
createMode,
|
||||
requiresPoc,
|
||||
onSave,
|
||||
onOpenChange,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (frozenSite != null || !open || !locId || !locationDetail || pocFilledFor === locId) {
|
||||
if (!live || !open || !locId || !locationDetail || pocFilledFor === locId) {
|
||||
return;
|
||||
}
|
||||
setPn((prev) => (prev.trim() ? prev : (locationDetail.contact ?? "")));
|
||||
setPp((prev) => (prev.trim() ? prev : (locationDetail.phone ?? "")));
|
||||
setPocFilledFor(locId);
|
||||
}, [open, locId, locationDetail, pocFilledFor, setPn, setPp, setPocFilledFor, frozenSite]);
|
||||
|
||||
const attemptSave = () => {
|
||||
if (createMode && !canConfirm) {
|
||||
fields.setShowErrors(true);
|
||||
return;
|
||||
}
|
||||
onSave(
|
||||
buildSiteDialogPatch({
|
||||
code: fields.code,
|
||||
locId: fields.locId,
|
||||
selected: fields.selected,
|
||||
pocName: fields.pn,
|
||||
pocPhone: fields.pp,
|
||||
pocNotes: fields.notes,
|
||||
extraContacts: fields.extraContacts,
|
||||
contactsDirty: fields.contactsDirty,
|
||||
baselineHadContacts: fields.baselineHadContacts,
|
||||
}),
|
||||
);
|
||||
onOpenChange(false);
|
||||
};
|
||||
}, [open, locId, locationDetail, pocFilledFor, setPn, setPp, setPocFilledFor, live]);
|
||||
|
||||
return {
|
||||
locId: fields.locId,
|
||||
|
|
@ -125,7 +129,13 @@ export function useSiteDialogState({
|
|||
showErrors: fields.showErrors,
|
||||
siteMissing: fields.siteMissing,
|
||||
handlePick: fields.handlePick,
|
||||
attemptSave,
|
||||
attemptSave: save.attemptSave,
|
||||
saveDisabled: save.saveDisabled,
|
||||
saving: save.saving,
|
||||
siteRecordLoading: siteRecord.loading,
|
||||
editsSiteRecord,
|
||||
siteRecordUnavailable,
|
||||
requiresPoc,
|
||||
setPn: fields.setPnDirty,
|
||||
setPp: fields.setPpDirty,
|
||||
setNotes: fields.setNotesDirty,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,201 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import type {
|
||||
Location,
|
||||
LocationContact,
|
||||
SiteContactInfoInput,
|
||||
} from "@/domain/locations/types/location";
|
||||
import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
|
||||
|
||||
type SiteRecordFields = {
|
||||
pn: string;
|
||||
pp: string;
|
||||
notes: string;
|
||||
extraContacts: WorkOrderAdditionalContact[];
|
||||
/** The user typed into the contact fields since the last reset or site switch. */
|
||||
userEdited: boolean;
|
||||
resetVersion: number;
|
||||
setPn: (value: string) => void;
|
||||
setPp: (value: string) => void;
|
||||
setNotes: (value: string) => void;
|
||||
setExtraContacts: (contacts: WorkOrderAdditionalContact[]) => void;
|
||||
};
|
||||
|
||||
type UseSiteRecordSyncArgs = {
|
||||
/** Existing, editable work order: the dialog edits the site record itself. */
|
||||
enabled: boolean;
|
||||
open: boolean;
|
||||
locId: string;
|
||||
locationDetail: Location | undefined;
|
||||
/** A request for the site is in flight; cached data may predate the latest save. */
|
||||
locationDetailFetching: boolean;
|
||||
locationDetailError: boolean;
|
||||
fields: SiteRecordFields;
|
||||
};
|
||||
|
||||
export type WorkOrderPoc = { pocName: string; pocPhone: string; pocNotes: string };
|
||||
|
||||
type SiteRecordEdits = { primary: boolean; notes: boolean };
|
||||
|
||||
function isCompleteContact(contact: WorkOrderAdditionalContact): boolean {
|
||||
return contact.name.trim() !== "" && contact.phone.trim() !== "";
|
||||
}
|
||||
|
||||
function toRequest(
|
||||
primaryId: number | undefined,
|
||||
fields: Pick<SiteRecordFields, "pn" | "pp" | "notes" | "extraContacts">,
|
||||
): SiteContactInfoInput {
|
||||
return {
|
||||
contacts: [
|
||||
{ ...(primaryId === undefined ? {} : { id: primaryId }), name: fields.pn, phone: fields.pp },
|
||||
// A half-filled extra is dropped here, the same as the work order's own copy drops it.
|
||||
...fields.extraContacts.filter(isCompleteContact).map((contact) => ({
|
||||
...(contact.siteContactId === undefined ? {} : { id: contact.siteContactId }),
|
||||
name: contact.name,
|
||||
phone: contact.phone,
|
||||
})),
|
||||
],
|
||||
notes: fields.notes,
|
||||
};
|
||||
}
|
||||
|
||||
function fingerprint(request: SiteContactInfoInput): string {
|
||||
return JSON.stringify({
|
||||
contacts: request.contacts.map((contact) => [contact.name.trim(), contact.phone.trim()]),
|
||||
notes: request.notes.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
function diffEdits(
|
||||
baseline: SiteContactInfoInput | null,
|
||||
request: SiteContactInfoInput,
|
||||
): SiteRecordEdits {
|
||||
if (baseline === null) {
|
||||
return { primary: false, notes: false };
|
||||
}
|
||||
const before = baseline.contacts[0];
|
||||
const after = request.contacts[0];
|
||||
return {
|
||||
primary: before.name.trim() !== after.name.trim() || before.phone.trim() !== after.phone.trim(),
|
||||
notes: baseline.notes.trim() !== request.notes.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The work order's own POC values once the site record owns them: a field follows the site only
|
||||
* when the user edited it (or picked another site); untouched fields keep the work order's values.
|
||||
*/
|
||||
export function workOrderPocAfterSiteSave(
|
||||
siteRecord: { synced: boolean; edits: SiteRecordEdits },
|
||||
original: WorkOrderPoc,
|
||||
current: WorkOrderPoc,
|
||||
siteSwitched: boolean,
|
||||
): WorkOrderPoc {
|
||||
if (!siteRecord.synced) {
|
||||
return current;
|
||||
}
|
||||
const primary = siteSwitched || siteRecord.edits.primary;
|
||||
const notes = siteSwitched || siteRecord.edits.notes;
|
||||
return {
|
||||
pocName: primary ? current.pocName : original.pocName,
|
||||
pocPhone: primary ? current.pocPhone : original.pocPhone,
|
||||
// Notes live on the site record; an empty override lets the site's notes show through.
|
||||
pocNotes: notes ? "" : original.pocNotes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The site's contacts in order. A site saved before the contacts list existed has none; its
|
||||
* contact lives only in the legacy `contact`/`phone` fields, so that one is the main contact.
|
||||
*/
|
||||
function siteContacts(location: Location): LocationContact[] {
|
||||
if (location.contacts && location.contacts.length > 0) {
|
||||
return location.contacts;
|
||||
}
|
||||
const name = location.contact ?? "";
|
||||
const phone = location.phone ?? "";
|
||||
return name.trim() || phone.trim() ? [{ name, phone }] : [];
|
||||
}
|
||||
|
||||
/** The site record's contacts and notes, in the dialog's field shape. */
|
||||
function siteRecordValues(location: Location) {
|
||||
const [main, ...others] = siteContacts(location);
|
||||
return {
|
||||
primaryId: main?.id,
|
||||
pn: main?.name ?? "",
|
||||
pp: main?.phone ?? "",
|
||||
notes: location.notes ?? "",
|
||||
extraContacts: others.map((contact) => ({
|
||||
name: contact.name,
|
||||
phone: contact.phone,
|
||||
...(contact.id === undefined ? {} : { siteContactId: contact.id }),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the selected site's contacts and notes into the Site dialog and reports what the user
|
||||
* changed, so Save can write them back to the site record.
|
||||
*/
|
||||
export function useSiteRecordSync({
|
||||
enabled,
|
||||
open,
|
||||
locId,
|
||||
locationDetail,
|
||||
locationDetailFetching,
|
||||
locationDetailError,
|
||||
fields,
|
||||
}: UseSiteRecordSyncArgs) {
|
||||
const [syncedFor, setSyncedFor] = useState("");
|
||||
const [primaryId, setPrimaryId] = useState<number | undefined>(undefined);
|
||||
const [baseline, setBaseline] = useState<SiteContactInfoInput | null>(null);
|
||||
const { userEdited, resetVersion, setPn, setPp, setNotes, setExtraContacts } = fields;
|
||||
|
||||
// Picking another site clears its contacts, so coming back to a site must load it again.
|
||||
useEffect(() => {
|
||||
setSyncedFor("");
|
||||
}, [open, resetVersion, locId]);
|
||||
|
||||
useEffect(() => {
|
||||
const detailMatches = locationDetail !== undefined && String(locationDetail.id) === locId;
|
||||
// Cached site data can predate a save made since, so only a settled request is synced.
|
||||
const detailCurrent = detailMatches && !locationDetailFetching;
|
||||
// Input typed while the site was unavailable (a failed load) is never replaced by a late
|
||||
// response: the dialog stays on the work order's values and saves them to the work order only.
|
||||
if (!enabled || !open || !locId || !detailCurrent || syncedFor === locId || userEdited) {
|
||||
return;
|
||||
}
|
||||
const site = siteRecordValues(locationDetail);
|
||||
setPn(site.pn);
|
||||
setPp(site.pp);
|
||||
setExtraContacts(site.extraContacts);
|
||||
setNotes(site.notes);
|
||||
setPrimaryId(site.primaryId);
|
||||
setBaseline(toRequest(site.primaryId, site));
|
||||
setSyncedFor(locId);
|
||||
}, [
|
||||
enabled,
|
||||
open,
|
||||
locId,
|
||||
locationDetail,
|
||||
locationDetailFetching,
|
||||
syncedFor,
|
||||
userEdited,
|
||||
setPn,
|
||||
setPp,
|
||||
setNotes,
|
||||
setExtraContacts,
|
||||
]);
|
||||
|
||||
const request = toRequest(primaryId, fields);
|
||||
const synced = enabled && syncedFor !== "" && syncedFor === locId;
|
||||
|
||||
return {
|
||||
synced,
|
||||
// Until the site loads the fields do not reflect it; a failed load falls back to the work order,
|
||||
// which it keeps once the user has typed, even if a later refetch succeeds.
|
||||
loading: enabled && Boolean(locId) && !synced && !locationDetailError && !userEdited,
|
||||
siteChanged: synced && baseline !== null && fingerprint(request) !== fingerprint(baseline),
|
||||
edits: diffEdits(synced ? baseline : null, request),
|
||||
request,
|
||||
};
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ import { StatusCell } from "./cells/status-cell";
|
|||
import { UpliftCell } from "./cells/uplift-cell";
|
||||
import { VendorCell } from "./cells/vendor-cell";
|
||||
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
|
||||
import { canOpenUpliftsDialog } from "@/domain/work-orders/utils/uplift-display-utils";
|
||||
import { isWorkOrderUpliftsReadOnly } from "@/domain/work-orders/utils/uplift-display-utils";
|
||||
import { applyVendorTableSave } from "@/domain/work-orders/utils/work-order-feedback-toasts";
|
||||
import { effectiveCompany } from "@/domain/work-orders/utils/effective-completion-snapshot";
|
||||
import type { WoTableRowHandlers } from "./wo-table-row";
|
||||
|
|
@ -132,12 +132,8 @@ export function WoTableRowServiceCells({
|
|||
<td style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>
|
||||
<UpliftCell
|
||||
summary={row.upliftSummary ?? EMPTY_UPLIFT_SUMMARY}
|
||||
onOpen={
|
||||
canOpenUpliftsDialog(row.upliftSummary ?? EMPTY_UPLIFT_SUMMARY, row.status) &&
|
||||
handlers.onOpenUplifts
|
||||
? () => handlers.onOpenUplifts!(row)
|
||||
: undefined
|
||||
}
|
||||
onOpen={handlers.onOpenUplifts ? () => handlers.onOpenUplifts!(row) : undefined}
|
||||
readOnly={isWorkOrderUpliftsReadOnly(row.status)}
|
||||
/>
|
||||
</td>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ type PocContactFieldsProps = {
|
|||
showPrimaryErrors?: boolean;
|
||||
/** Labels the primary name/phone inputs with a persistent required asterisk. */
|
||||
primaryRequired?: boolean;
|
||||
/** Offers a Notes box on each additional contact; off where contacts save without per-contact notes. */
|
||||
additionalContactNotes?: boolean;
|
||||
};
|
||||
|
||||
function extraNameMissing(contact: WorkOrderAdditionalContact): boolean {
|
||||
|
|
@ -49,11 +51,13 @@ function updateContactAt(
|
|||
function AdditionalPocCard({
|
||||
contact,
|
||||
index,
|
||||
showNotes,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
contact: WorkOrderAdditionalContact;
|
||||
index: number;
|
||||
showNotes: boolean;
|
||||
onChange: (patch: Partial<WorkOrderAdditionalContact>) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
|
|
@ -115,14 +119,16 @@ function AdditionalPocCard({
|
|||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={contact.notes ?? ""}
|
||||
onChange={(event) => onChange({ notes: event.target.value })}
|
||||
placeholder="Notes…"
|
||||
rows={2}
|
||||
className={`${DLG_INPUT_CLS} resize-none`}
|
||||
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
|
||||
/>
|
||||
{showNotes && (
|
||||
<textarea
|
||||
value={contact.notes ?? ""}
|
||||
onChange={(event) => onChange({ notes: event.target.value })}
|
||||
placeholder="Notes…"
|
||||
rows={2}
|
||||
className={`${DLG_INPUT_CLS} resize-none`}
|
||||
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -139,6 +145,7 @@ export function PocContactFields({
|
|||
helperText,
|
||||
showPrimaryErrors = false,
|
||||
primaryRequired = false,
|
||||
additionalContactNotes = true,
|
||||
}: PocContactFieldsProps) {
|
||||
const fieldId = useId();
|
||||
const nameMissing = showPrimaryErrors && !pocName.trim();
|
||||
|
|
@ -214,6 +221,7 @@ export function PocContactFields({
|
|||
key={`additional-poc-${index}`}
|
||||
contact={contact}
|
||||
index={index}
|
||||
showNotes={additionalContactNotes}
|
||||
onChange={(patch) =>
|
||||
onAdditionalContactsChange(updateContactAt(additionalContacts, index, patch))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ type WorkOrderUpliftListItemProps = {
|
|||
uplift: WorkOrderUplift;
|
||||
readOnly: boolean;
|
||||
currentUserId: string | number | null | undefined;
|
||||
currentUserIsAdmin: boolean;
|
||||
pendingAction?: boolean;
|
||||
onCancel?: () => void;
|
||||
onRevoke?: () => void;
|
||||
|
|
@ -21,6 +22,7 @@ export function WorkOrderUpliftListItem({
|
|||
uplift,
|
||||
readOnly,
|
||||
currentUserId,
|
||||
currentUserIsAdmin,
|
||||
pendingAction = false,
|
||||
onCancel,
|
||||
onRevoke,
|
||||
|
|
@ -28,7 +30,9 @@ export function WorkOrderUpliftListItem({
|
|||
const pillStyle = getUpliftStatusPillStyle(uplift.status);
|
||||
const showCancel = !readOnly && uplift.status === "pending" && Boolean(onCancel);
|
||||
const showRevoke =
|
||||
!readOnly && canRevokeWorkOrderUplift(uplift, currentUserId) && Boolean(onRevoke);
|
||||
!readOnly &&
|
||||
canRevokeWorkOrderUplift(uplift, currentUserId, currentUserIsAdmin) &&
|
||||
Boolean(onRevoke);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--color-border)] p-3">
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { hasOpenWorkOrderUplift } from "@/domain/work-orders/utils/uplift-displa
|
|||
type WorkOrderUpliftsDialogContentProps = {
|
||||
readOnly: boolean;
|
||||
currentUserId: string | number | null | undefined;
|
||||
currentUserIsAdmin: boolean;
|
||||
readOnlyStatusLabel?: string;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
|
|
@ -28,6 +29,7 @@ type WorkOrderUpliftsDialogContentProps = {
|
|||
export function WorkOrderUpliftsDialogContent({
|
||||
readOnly,
|
||||
currentUserId,
|
||||
currentUserIsAdmin,
|
||||
readOnlyStatusLabel,
|
||||
isLoading,
|
||||
error,
|
||||
|
|
@ -77,6 +79,7 @@ export function WorkOrderUpliftsDialogContent({
|
|||
uplift={uplift}
|
||||
readOnly={readOnly}
|
||||
currentUserId={currentUserId}
|
||||
currentUserIsAdmin={currentUserIsAdmin}
|
||||
pendingAction={actionPending}
|
||||
onCancel={readOnly ? undefined : () => onCancelUplift(uplift.id)}
|
||||
onRevoke={readOnly ? undefined : () => onRevokeUplift(uplift.id)}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
isWorkOrderUpliftsReadOnly,
|
||||
upliftRevokeRequiresReason,
|
||||
} from "@/domain/work-orders/utils/uplift-display-utils";
|
||||
import { isAdminUser } from "@/lib/auth/user-utils";
|
||||
import { useAuthContext } from "@/providers/auth-context";
|
||||
|
||||
type RevokeTarget = {
|
||||
|
|
@ -78,6 +79,7 @@ export function WorkOrderUpliftsDialog({ row, open, onClose }: WorkOrderUpliftsD
|
|||
<WorkOrderUpliftsDialogContent
|
||||
readOnly={readOnly}
|
||||
currentUserId={user?.id}
|
||||
currentUserIsAdmin={isAdminUser(user?.userRoles)}
|
||||
readOnlyStatusLabel={row?.status}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
|
|
|
|||
100
src/app/invite/_components/invite-code-step.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FormField } from "@/components/ui/form-field";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { TextField } from "@/components/ui/text-field";
|
||||
import {
|
||||
inviteCodeStepSchema,
|
||||
type InviteCodeStepValues,
|
||||
} from "@/domain/team-member-invite/schemas/invite-registration-schema";
|
||||
|
||||
import { InviteStepHeader } from "./invite-step-header";
|
||||
|
||||
export type InviteCodeStepProps = {
|
||||
email: string;
|
||||
isVerifying: boolean;
|
||||
isResending: boolean;
|
||||
/** Why the entered code was refused; shown under the field. */
|
||||
codeError: string | null;
|
||||
/** Why a new code could not be sent. */
|
||||
resendError: string | null;
|
||||
onVerify: (code: string) => void;
|
||||
onResend: () => void;
|
||||
onCodeChange: () => void;
|
||||
};
|
||||
|
||||
export function InviteCodeStep({
|
||||
email,
|
||||
isVerifying,
|
||||
isResending,
|
||||
codeError,
|
||||
resendError,
|
||||
onVerify,
|
||||
onResend,
|
||||
onCodeChange,
|
||||
}: InviteCodeStepProps) {
|
||||
const { register, handleSubmit, control } = useForm<InviteCodeStepValues>({
|
||||
resolver: zodResolver(inviteCodeStepSchema),
|
||||
defaultValues: { code: "" },
|
||||
});
|
||||
|
||||
const values = useWatch({ control });
|
||||
const canContinue = inviteCodeStepSchema.safeParse(values).success;
|
||||
const codeField = register("code", { onChange: onCodeChange });
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="form"
|
||||
aria-label="Confirm your email"
|
||||
onSubmit={handleSubmit((form) => onVerify(form.code))}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<InviteStepHeader
|
||||
step={2}
|
||||
title="Confirm your email"
|
||||
subtitle={`Enter the 6-digit code sent to ${email}`}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Confirmation code"
|
||||
htmlFor="invite-code"
|
||||
required
|
||||
appearance="auth"
|
||||
error={codeError !== null}
|
||||
helperText={codeError ?? undefined}
|
||||
>
|
||||
<TextField
|
||||
{...codeField}
|
||||
id="invite-code"
|
||||
placeholder="123456"
|
||||
autoComplete="one-time-code"
|
||||
error={codeError !== null}
|
||||
slotProps={{ htmlInput: { inputMode: "numeric", maxLength: 6 } }}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<Button type="button" variant="link" onClick={onResend} disabled={isResending}>
|
||||
Resend code
|
||||
</Button>
|
||||
<Text variant="error" when={resendError !== null}>
|
||||
{resendError}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
size="lg"
|
||||
disabled={!canContinue}
|
||||
loading={isVerifying}
|
||||
loadingText="Checking code…"
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
101
src/app/invite/_components/invite-password-step.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
import { PasswordChecklist } from "@/components/auth/password-checklist";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { FormField } from "@/components/ui/form-field";
|
||||
import { PasswordField } from "@/components/ui/password-field";
|
||||
import {
|
||||
invitePasswordStepSchema,
|
||||
type InvitePasswordStepValues,
|
||||
} from "@/domain/team-member-invite/schemas/invite-registration-schema";
|
||||
|
||||
import { InviteStepHeader } from "./invite-step-header";
|
||||
|
||||
export type InvitePasswordStepProps = {
|
||||
name: string;
|
||||
defaultPassword: string;
|
||||
isSubmitting: boolean;
|
||||
error: string | null;
|
||||
onContinue: (password: string) => void;
|
||||
};
|
||||
|
||||
export function InvitePasswordStep({
|
||||
name,
|
||||
defaultPassword,
|
||||
isSubmitting,
|
||||
error,
|
||||
onContinue,
|
||||
}: InvitePasswordStepProps) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useForm<InvitePasswordStepValues>({
|
||||
resolver: zodResolver(invitePasswordStepSchema),
|
||||
defaultValues: { password: defaultPassword, confirmPassword: defaultPassword },
|
||||
mode: "onTouched",
|
||||
});
|
||||
|
||||
const values = useWatch({ control });
|
||||
const canContinue = invitePasswordStepSchema.safeParse(values).success;
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="form"
|
||||
aria-label="Create your password"
|
||||
onSubmit={handleSubmit((form) => onContinue(form.password))}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<InviteStepHeader
|
||||
step={1}
|
||||
title="Create your password"
|
||||
subtitle={`Setting up the account for ${name}`}
|
||||
/>
|
||||
|
||||
<FormField label="Password" htmlFor="invite-password" required appearance="auth">
|
||||
<PasswordField
|
||||
{...register("password")}
|
||||
id="invite-password"
|
||||
autoComplete="new-password"
|
||||
slotProps={{ htmlInput: { "aria-describedby": "invite-password-rules" } }}
|
||||
/>
|
||||
<PasswordChecklist id="invite-password-rules" password={values.password ?? ""} />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Confirm password"
|
||||
htmlFor="invite-confirm-password"
|
||||
required
|
||||
appearance="auth"
|
||||
error={Boolean(errors.confirmPassword)}
|
||||
helperText={errors.confirmPassword?.message}
|
||||
>
|
||||
<PasswordField
|
||||
{...register("confirmPassword")}
|
||||
id="invite-confirm-password"
|
||||
autoComplete="new-password"
|
||||
error={Boolean(errors.confirmPassword)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<Text variant="error" when={error !== null}>
|
||||
{error}
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
size="lg"
|
||||
disabled={!canContinue}
|
||||
loading={isSubmitting}
|
||||
loadingText="Sending code…"
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
100
src/app/invite/_components/invite-review-step.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Box } from "@mui/material";
|
||||
import { CircleCheck } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FormField } from "@/components/ui/form-field";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { TextField } from "@/components/ui/text-field";
|
||||
import {
|
||||
inviteReviewStepSchema,
|
||||
type InviteReviewStepValues,
|
||||
} from "@/domain/team-member-invite/schemas/invite-registration-schema";
|
||||
import type { TeamMemberInviteDetails } from "@/domain/team-member-invite/types/team-member-invite";
|
||||
|
||||
import { InviteStepHeader } from "./invite-step-header";
|
||||
|
||||
export type InviteReviewStepProps = {
|
||||
details: TeamMemberInviteDetails;
|
||||
isFinishing: boolean;
|
||||
error: string | null;
|
||||
onFinish: (phone: string) => void;
|
||||
};
|
||||
|
||||
function ReadOnlyValue({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text variant="label" className="block">
|
||||
{label}
|
||||
</Text>
|
||||
<Text variant="body" className="m-0 text-(length:--text-base-sm)">
|
||||
{value}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InviteReviewStep({ details, isFinishing, error, onFinish }: InviteReviewStepProps) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<InviteReviewStepValues>({
|
||||
resolver: zodResolver(inviteReviewStepSchema),
|
||||
defaultValues: { phone: details.phone ?? "" },
|
||||
mode: "onTouched",
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="form"
|
||||
aria-label="Review your info"
|
||||
onSubmit={handleSubmit((form) => onFinish(form.phone))}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<InviteStepHeader
|
||||
step={3}
|
||||
title="Review your info"
|
||||
subtitle="Name, role, and email are set by your admin — contact them to change these."
|
||||
/>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<ReadOnlyValue label="Name" value={details.name} />
|
||||
<ReadOnlyValue label="Role" value={details.role} />
|
||||
<ReadOnlyValue label="Email" value={details.email} />
|
||||
<FormField
|
||||
label="Phone"
|
||||
htmlFor="invite-phone"
|
||||
appearance="auth"
|
||||
error={Boolean(errors.phone)}
|
||||
helperText={errors.phone?.message}
|
||||
>
|
||||
<TextField
|
||||
{...register("phone")}
|
||||
id="invite-phone"
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
placeholder="(000) 000-0000"
|
||||
error={Boolean(errors.phone)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<Text variant="error" when={error !== null}>
|
||||
{error}
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
size="lg"
|
||||
loading={isFinishing}
|
||||
loadingText="Finishing…"
|
||||
startIcon={<CircleCheck size={15} aria-hidden="true" />}
|
||||
>
|
||||
Finish registration
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
46
src/app/invite/_components/invite-step-header.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { Text } from "@/components/ui/text";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const INVITE_STEP_COUNT = 3;
|
||||
|
||||
export type InviteStep = 1 | 2 | 3;
|
||||
|
||||
export type InviteStepHeaderProps = {
|
||||
step: InviteStep;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
|
||||
export function InviteStepHeader({ step, title, subtitle }: InviteStepHeaderProps) {
|
||||
return (
|
||||
<div className="mb-5">
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label="Registration progress"
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={INVITE_STEP_COUNT}
|
||||
aria-valuenow={step}
|
||||
className="mb-5 flex items-center gap-1.5"
|
||||
>
|
||||
{[1, 2, 3].map((bar) => (
|
||||
<span
|
||||
key={bar}
|
||||
className={cn(
|
||||
"h-[3px] flex-1 rounded-sm",
|
||||
bar <= step ? "bg-(--color-primary)" : "bg-(--color-border)",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Text variant="caption" tone="muted" className="block">
|
||||
{`Step ${step} of ${INVITE_STEP_COUNT}`}
|
||||
</Text>
|
||||
<Text variant="title" className="m-0 mt-1! ![font-size:var(--text-lg)] !font-semibold">
|
||||
{title}
|
||||
</Text>
|
||||
<Text variant="description" tone="muted" className="mt-1!">
|
||||
{subtitle}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
src/app/invite/_components/invite-wizard.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import { inviteErrorCode } from "@/domain/team-member-invite/api/team-member-invite-api";
|
||||
import type { TeamMemberInviteDetails } from "@/domain/team-member-invite/types/team-member-invite";
|
||||
import {
|
||||
useCompleteRegistration,
|
||||
useSendInviteCode,
|
||||
useVerifyInviteCode,
|
||||
} from "@/domain/team-member-invite/use-cases/use-team-member-invite";
|
||||
|
||||
import { InviteCodeStep } from "./invite-code-step";
|
||||
import { InvitePasswordStep } from "./invite-password-step";
|
||||
import { InviteReviewStep } from "./invite-review-step";
|
||||
import type { InviteStep } from "./invite-step-header";
|
||||
|
||||
export type InviteWizardProps = {
|
||||
token: string;
|
||||
details: TeamMemberInviteDetails;
|
||||
/** Called when the server reports the invite can no longer be used. */
|
||||
onInviteInvalid: () => void;
|
||||
};
|
||||
|
||||
export function InviteWizard({ token, details, onInviteInvalid }: InviteWizardProps) {
|
||||
const [step, setStep] = useState<InviteStep>(1);
|
||||
const [password, setPassword] = useState("");
|
||||
const [stepError, setStepError] = useState<string | null>(null);
|
||||
const [codeError, setCodeError] = useState<string | null>(null);
|
||||
const [resendError, setResendError] = useState<string | null>(null);
|
||||
const sendCode = useSendInviteCode();
|
||||
const verifyCode = useVerifyInviteCode();
|
||||
const complete = useCompleteRegistration();
|
||||
|
||||
/** Returns true when the failure ended the invite and the wizard should stop. */
|
||||
const endedInvite = (error: Error) => {
|
||||
if (inviteErrorCode(error) !== "invalid_invite") {
|
||||
return false;
|
||||
}
|
||||
onInviteInvalid();
|
||||
return true;
|
||||
};
|
||||
|
||||
const handlePasswordContinue = (nextPassword: string) => {
|
||||
setPassword(nextPassword);
|
||||
setStepError(null);
|
||||
sendCode.mutate(token, {
|
||||
onSuccess: () => setStep(2),
|
||||
onError: (error) => {
|
||||
if (endedInvite(error)) return;
|
||||
// A code sent moments ago is still valid, so the member can go on and enter it.
|
||||
if (inviteErrorCode(error) === "resend_too_soon") {
|
||||
setStep(2);
|
||||
return;
|
||||
}
|
||||
setStepError(error.message);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleResend = () => {
|
||||
setResendError(null);
|
||||
sendCode.mutate(token, {
|
||||
onSuccess: () => {
|
||||
setCodeError(null);
|
||||
toast.success(`Code resent to ${details.email}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
if (!endedInvite(error)) setResendError(error.message);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleVerify = (code: string) => {
|
||||
setCodeError(null);
|
||||
verifyCode.mutate(
|
||||
{ token, code },
|
||||
{
|
||||
onSuccess: () => setStep(3),
|
||||
onError: (error) => {
|
||||
if (!endedInvite(error)) setCodeError(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleFinish = (phone: string) => {
|
||||
setStepError(null);
|
||||
complete.mutate(
|
||||
{ token, password, phone },
|
||||
{
|
||||
onError: (error) => {
|
||||
if (endedInvite(error)) return;
|
||||
if (inviteErrorCode(error) === "password_rejected") {
|
||||
setStep(1);
|
||||
}
|
||||
setStepError(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
if (step === 1) {
|
||||
return (
|
||||
<InvitePasswordStep
|
||||
name={details.name}
|
||||
defaultPassword={password}
|
||||
isSubmitting={sendCode.isPending}
|
||||
error={stepError}
|
||||
onContinue={handlePasswordContinue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 2) {
|
||||
return (
|
||||
<InviteCodeStep
|
||||
email={details.email}
|
||||
isVerifying={verifyCode.isPending}
|
||||
isResending={sendCode.isPending}
|
||||
codeError={codeError}
|
||||
resendError={resendError}
|
||||
onVerify={handleVerify}
|
||||
onResend={handleResend}
|
||||
onCodeChange={() => setCodeError(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InviteReviewStep
|
||||
details={details}
|
||||
isFinishing={complete.isPending}
|
||||
error={stepError}
|
||||
onFinish={handleFinish}
|
||||
/>
|
||||
);
|
||||
}
|
||||
87
src/app/invite/index.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { useState } from "react";
|
||||
import { Link, useLocation } from "react-router";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
|
||||
import { AuthBrandLogo } from "@/app/(auth)/_components/auth-brand-logo";
|
||||
import { AuthCardHeader } from "@/app/(auth)/_components/auth-card-header";
|
||||
import { AuthPageShell } from "@/app/(auth)/_components/auth-page-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { inviteErrorCode } from "@/domain/team-member-invite/api/team-member-invite-api";
|
||||
import {
|
||||
INVALID_INVITE_MESSAGE,
|
||||
INVITE_UNREACHABLE_MESSAGE,
|
||||
} from "@/domain/team-member-invite/types/team-member-invite";
|
||||
import { useTeamMemberInviteDetails } from "@/domain/team-member-invite/use-cases/use-team-member-invite";
|
||||
|
||||
import { InviteWizard } from "./_components/invite-wizard";
|
||||
|
||||
/** The invite email links to `/invite#<token>`; a fragment never reaches a server or a Referer. */
|
||||
function readInviteToken(hash: string): string {
|
||||
return hash.replace(/^#/, "").trim();
|
||||
}
|
||||
|
||||
function InviteBody({ token }: { token: string }) {
|
||||
const details = useTeamMemberInviteDetails(token);
|
||||
const [inviteEnded, setInviteEnded] = useState(false);
|
||||
|
||||
if (token.length > 0 && details.isPending) {
|
||||
return (
|
||||
<Box className="flex justify-center py-8" role="status" aria-label="Loading invite">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Only the server's own `invalid_invite` answer means the link is dead. A request that never
|
||||
// got that answer (offline, timeout, 5xx) says nothing about the token, so offer a retry
|
||||
// instead of sending the member to their admin for a new invite.
|
||||
if (!inviteEnded && details.isError && inviteErrorCode(details.error) !== "invalid_invite") {
|
||||
return (
|
||||
<Box>
|
||||
<AuthCardHeader title="Couldn't load your invite" subtitle={INVITE_UNREACHABLE_MESSAGE} />
|
||||
<Button
|
||||
type="button"
|
||||
fullWidth
|
||||
size="lg"
|
||||
loading={details.isFetching}
|
||||
loadingText="Trying again…"
|
||||
onClick={() => void details.refetch()}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (inviteEnded || details.data === undefined) {
|
||||
return <AuthCardHeader title="Invite unavailable" subtitle={INVALID_INVITE_MESSAGE} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<InviteWizard
|
||||
token={token}
|
||||
details={details.data}
|
||||
onInviteInvalid={() => setInviteEnded(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InviteRegistrationPage() {
|
||||
const { hash } = useLocation();
|
||||
const token = readInviteToken(hash);
|
||||
|
||||
return (
|
||||
<AuthPageShell>
|
||||
<AuthBrandLogo className="mb-8" />
|
||||
<Link
|
||||
to="/login"
|
||||
className="mb-6 inline-flex items-center gap-1 text-(length:--text-xs) text-(--color-text-muted) no-underline hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft size={13} aria-hidden="true" />
|
||||
Back to login
|
||||
</Link>
|
||||
<InviteBody token={token} />
|
||||
</AuthPageShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -40,12 +40,14 @@ export async function changePassword(data: ChangePasswordPayload): Promise<unkno
|
|||
return apiPost(API_PATHS.authentication.changePassword, data);
|
||||
}
|
||||
|
||||
// Email and code travel in the JSON body so they stay out of URLs, traces and proxy logs.
|
||||
export async function forgotPassword(email: string): Promise<unknown> {
|
||||
return apiPost(`${API_PATHS.authentication.forgetPassword}?Email=${encodeURIComponent(email)}`);
|
||||
return apiPost(API_PATHS.authentication.forgetPassword, { email });
|
||||
}
|
||||
|
||||
export async function verifyResetCode(code: string): Promise<unknown> {
|
||||
return apiPost(`${API_PATHS.authentication.verificationCode}?code=${encodeURIComponent(code)}`);
|
||||
// The server checks a code only against the email it was issued to.
|
||||
export async function verifyResetCode(email: string, code: string): Promise<unknown> {
|
||||
return apiPost(API_PATHS.authentication.verificationCode, { email, code });
|
||||
}
|
||||
|
||||
export async function resetPassword(data: ResetPasswordPayload): Promise<unknown> {
|
||||
|
|
|
|||
17
src/domain/auth/api/permissions-api.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import {
|
|||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type QueryClient,
|
||||
type UseMutationResult,
|
||||
type UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
|
|
@ -21,6 +22,13 @@ export function useSession(): UseQueryResult<AuthUser | null, Error> {
|
|||
});
|
||||
}
|
||||
|
||||
/** Makes a freshly stored session the signed-in user, after login or invite registration. */
|
||||
export function startSignedInSession(queryClient: QueryClient, user: AuthUser): void {
|
||||
stopQueryBroadcast(queryClient);
|
||||
queryClient.setQueryData(queryKeys.auth.session(), user);
|
||||
startQueryBroadcast(queryClient, user.id);
|
||||
}
|
||||
|
||||
export function useLogin(): UseMutationResult<AuthUser, Error, LoginCredentials> {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
|
@ -29,9 +37,7 @@ export function useLogin(): UseMutationResult<AuthUser, Error, LoginCredentials>
|
|||
mutationFn: (credentials: LoginCredentials) => authApi.login(credentials),
|
||||
meta: { suppressErrorToast: true },
|
||||
onSuccess: (user) => {
|
||||
stopQueryBroadcast(queryClient);
|
||||
queryClient.setQueryData(queryKeys.auth.session(), user);
|
||||
startQueryBroadcast(queryClient, user.id);
|
||||
startSignedInSession(queryClient, user);
|
||||
toast.success("Login successful!");
|
||||
navigate("/");
|
||||
},
|
||||
|
|
|
|||
32
src/domain/auth/use-cases/use-permissions.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import type { z } from "zod";
|
||||
import { apiDeleteNoContent, apiGet, apiPost, apiPut } from "@/api/api";
|
||||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { handleApiResponse } from "@/api/handle-api-response";
|
||||
import { mapCompletionTemplate } from "@/domain/completion-templates/mappers/completion-template-mapper";
|
||||
import {
|
||||
completionTemplateDtoSchema,
|
||||
completionTemplateListDtoSchema,
|
||||
linkedWorkOrdersDtoSchema,
|
||||
} from "@/domain/completion-templates/schemas/completion-template-schema";
|
||||
import type {
|
||||
CompletionTemplate,
|
||||
CompletionTemplateInput,
|
||||
CompletionTemplateLegacyFields,
|
||||
LinkedWorkOrders,
|
||||
} from "@/domain/completion-templates/types/completion-template";
|
||||
|
||||
export const UNEXPECTED_TEMPLATE_RESPONSE = "The server sent an unexpected response.";
|
||||
|
||||
function parse<T>(schema: z.ZodType<T>, data: unknown): T {
|
||||
const result = schema.safeParse(handleApiResponse<unknown>(data));
|
||||
if (!result.success) throw new Error(UNEXPECTED_TEMPLATE_RESPONSE);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
function toBody(input: CompletionTemplateInput, legacy?: CompletionTemplateLegacyFields) {
|
||||
return { ...legacy, ...input };
|
||||
}
|
||||
|
||||
export const completionTemplatesApi = {
|
||||
list: async (search: string): Promise<CompletionTemplate[]> => {
|
||||
const term = search.trim();
|
||||
const data = await apiGet<unknown>(API_PATHS.workOrder.completionTemplates, {
|
||||
searchParams: term === "" ? undefined : { search: term },
|
||||
});
|
||||
return parse(completionTemplateListDtoSchema, data).map(mapCompletionTemplate);
|
||||
},
|
||||
|
||||
create: async (input: CompletionTemplateInput): Promise<CompletionTemplate> => {
|
||||
const data = await apiPost<unknown>(API_PATHS.workOrder.completionTemplates, toBody(input));
|
||||
return mapCompletionTemplate(parse(completionTemplateDtoSchema, data));
|
||||
},
|
||||
|
||||
/** Replaces the whole procedure list and keeps the legacy fields the template already had. */
|
||||
update: async (
|
||||
template: CompletionTemplate,
|
||||
input: CompletionTemplateInput,
|
||||
): Promise<CompletionTemplate> => {
|
||||
const data = await apiPut<unknown>(
|
||||
API_PATHS.workOrder.completionTemplateById(template.id),
|
||||
toBody(input, template.legacy),
|
||||
);
|
||||
return mapCompletionTemplate(parse(completionTemplateDtoSchema, data));
|
||||
},
|
||||
|
||||
delete: async (id: number): Promise<void> => {
|
||||
await apiDeleteNoContent(API_PATHS.workOrder.completionTemplateById(id));
|
||||
},
|
||||
|
||||
linkedWorkOrders: async (id: number): Promise<LinkedWorkOrders> => {
|
||||
const data = await apiGet<unknown>(API_PATHS.workOrder.completionTemplateLinkedWorkOrders(id));
|
||||
return parse(linkedWorkOrdersDtoSchema, data);
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
/** Filled in on every generated document; shown read-only so authors don't retype them. */
|
||||
export const AUTO_POPULATED_FIELDS = ["WO#", "Site Code", "Address", "Service Type"] as const;
|
||||
|
||||
/** Server limits; the inputs stop there instead of failing the save. */
|
||||
export const PROCEDURE_NAME_MAX_LENGTH = 200;
|
||||
export const EXTRA_SAFETY_NOTE_MAX_LENGTH = 2000;
|
||||
|
||||
export const COMPLETION_TEMPLATE_COPY = {
|
||||
pageTitle: "Completion Docs",
|
||||
searchPlaceholder: "Search document title…",
|
||||
addTemplate: "Add Template",
|
||||
saveChanges: "Save changes",
|
||||
titleRequired: "Document Title is required",
|
||||
noProcedureDetail: "No procedure defined yet",
|
||||
noProcedurePreview: "No procedure added yet.",
|
||||
untitledDocument: "UNTITLED COMPLETION DOCUMENT",
|
||||
autoFilledSuffix: "— auto-filled per work order",
|
||||
emptySearchTitle: "No templates found",
|
||||
emptySearchHint: "Try a different search, or add a new template.",
|
||||
added: "Template added",
|
||||
updated: "Template updated",
|
||||
deleted: "Template deleted",
|
||||
} as const;
|
||||
|
||||
export function templateCountLabel(count: number): string {
|
||||
return `${count} template${count === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import type {
|
||||
CompletionTemplateDto,
|
||||
CompletionTemplateFormValues,
|
||||
} from "@/domain/completion-templates/schemas/completion-template-schema";
|
||||
import type {
|
||||
CompletionTemplate,
|
||||
CompletionTemplateInput,
|
||||
} from "@/domain/completion-templates/types/completion-template";
|
||||
|
||||
export function mapCompletionTemplate(dto: CompletionTemplateDto): CompletionTemplate {
|
||||
const createdAt = dto.createdAt ?? "";
|
||||
const procedures = [...(dto.procedures ?? [])]
|
||||
.map((procedure, index) => ({ procedure, order: procedure.sortOrder ?? index }))
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map(({ procedure }) => ({
|
||||
name: procedure.name ?? "",
|
||||
description: procedure.description ?? "",
|
||||
}));
|
||||
|
||||
return {
|
||||
id: dto.id,
|
||||
name: dto.name ?? "",
|
||||
extraSafetyNote: dto.extraSafetyNote?.trim() ? dto.extraSafetyNote : null,
|
||||
procedures,
|
||||
createdAt,
|
||||
lastUpdatedAt: dto.updatedAt ?? createdAt,
|
||||
createdByName: dto.createdByName?.trim() ? dto.createdByName : null,
|
||||
legacy: {
|
||||
serviceKey: dto.serviceKey ?? "",
|
||||
workOrderType: dto.workOrderType ?? null,
|
||||
templateUrl: dto.templateUrl ?? "",
|
||||
isActive: dto.isActive ?? true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function toCompletionTemplateFormValues(
|
||||
template: CompletionTemplate | null,
|
||||
): CompletionTemplateFormValues {
|
||||
if (!template) return { name: "", extraSafetyNote: null, procedures: [] };
|
||||
return {
|
||||
name: template.name,
|
||||
extraSafetyNote: template.extraSafetyNote,
|
||||
procedures: template.procedures.map((procedure) => ({ ...procedure })),
|
||||
};
|
||||
}
|
||||
|
||||
/** Trims every field and drops procedures the author left entirely blank. */
|
||||
export function toCompletionTemplateInput(
|
||||
values: CompletionTemplateFormValues,
|
||||
): CompletionTemplateInput {
|
||||
const note = values.extraSafetyNote?.trim() ?? "";
|
||||
return {
|
||||
name: values.name.trim(),
|
||||
extraSafetyNote: note === "" ? null : note,
|
||||
procedures: values.procedures
|
||||
.map((procedure) => ({
|
||||
name: procedure.name.trim(),
|
||||
description: procedure.description.trim(),
|
||||
}))
|
||||
.filter((procedure) => procedure.name !== "" || procedure.description !== ""),
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { z } from "zod";
|
||||
import { COMPLETION_TEMPLATE_COPY } from "@/domain/completion-templates/constants/completion-template-constants";
|
||||
|
||||
const procedureDtoSchema = z.object({
|
||||
name: z.string().nullish(),
|
||||
description: z.string().nullish(),
|
||||
sortOrder: z.number().nullish(),
|
||||
});
|
||||
|
||||
export const completionTemplateDtoSchema = z.object({
|
||||
id: z.number(),
|
||||
name: z.string().nullish(),
|
||||
extraSafetyNote: z.string().nullish(),
|
||||
procedures: z.array(procedureDtoSchema).nullish(),
|
||||
createdAt: z.string().nullish(),
|
||||
updatedAt: z.string().nullish(),
|
||||
createdByName: z.string().nullish(),
|
||||
serviceKey: z.string().nullish(),
|
||||
workOrderType: z.number().nullish(),
|
||||
templateUrl: z.string().nullish(),
|
||||
isActive: z.boolean().nullish(),
|
||||
});
|
||||
|
||||
export const completionTemplateListDtoSchema = z.array(completionTemplateDtoSchema);
|
||||
|
||||
export const linkedWorkOrdersDtoSchema = z.object({
|
||||
count: z.number(),
|
||||
workOrderIds: z.array(z.number()),
|
||||
});
|
||||
|
||||
export type CompletionTemplateDto = z.infer<typeof completionTemplateDtoSchema>;
|
||||
|
||||
const procedureFormSchema = z.object({
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
});
|
||||
|
||||
export const completionTemplateFormSchema = z.object({
|
||||
name: z.string().trim().min(1, COMPLETION_TEMPLATE_COPY.titleRequired),
|
||||
/** `null` until the author adds the optional note. */
|
||||
extraSafetyNote: z.string().nullable(),
|
||||
procedures: z.array(procedureFormSchema),
|
||||
});
|
||||
|
||||
export type CompletionTemplateFormValues = z.infer<typeof completionTemplateFormSchema>;
|
||||
38
src/domain/completion-templates/types/completion-template.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
export interface CompletionTemplateProcedure {
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** Fields the Services registry still reads; an edit sends them back unchanged. */
|
||||
export interface CompletionTemplateLegacyFields {
|
||||
serviceKey: string;
|
||||
workOrderType: number | null;
|
||||
templateUrl: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface CompletionTemplate {
|
||||
id: number;
|
||||
/** The Document Title. */
|
||||
name: string;
|
||||
extraSafetyNote: string | null;
|
||||
/** In print order. */
|
||||
procedures: CompletionTemplateProcedure[];
|
||||
createdAt: string;
|
||||
/** `updatedAt ?? createdAt`: what the table shows as Last Updated. */
|
||||
lastUpdatedAt: string;
|
||||
createdByName: string | null;
|
||||
legacy: CompletionTemplateLegacyFields;
|
||||
}
|
||||
|
||||
export interface CompletionTemplateInput {
|
||||
name: string;
|
||||
extraSafetyNote: string | null;
|
||||
procedures: CompletionTemplateProcedure[];
|
||||
}
|
||||
|
||||
/** Open work orders whose completion document would be generated from a template. */
|
||||
export interface LinkedWorkOrders {
|
||||
count: number;
|
||||
workOrderIds: number[];
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import {
|
||||
useMutation,
|
||||
useQueryClient,
|
||||
type QueryClient,
|
||||
type UseMutationResult,
|
||||
} from "@tanstack/react-query";
|
||||
import { isHTTPError } from "ky";
|
||||
import { toast } from "react-toastify";
|
||||
import { completionTemplatesApi } from "@/domain/completion-templates/api/completion-templates-api";
|
||||
import { COMPLETION_TEMPLATE_COPY } from "@/domain/completion-templates/constants/completion-template-constants";
|
||||
import type {
|
||||
CompletionTemplate,
|
||||
CompletionTemplateInput,
|
||||
} from "@/domain/completion-templates/types/completion-template";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
interface UpdateCompletionTemplateVariables {
|
||||
template: CompletionTemplate;
|
||||
input: CompletionTemplateInput;
|
||||
}
|
||||
|
||||
/** The server's blank-title rejection, which the form shows on the title field instead. */
|
||||
export function isTitleRequiredError(error: Error): boolean {
|
||||
if (!isHTTPError(error) || error.response.status !== 422) return false;
|
||||
const body: unknown = error.data;
|
||||
return (
|
||||
typeof body === "object" && body !== null && "code" in body && body.code === "TitleRequired"
|
||||
);
|
||||
}
|
||||
|
||||
function invalidateTemplates(queryClient: QueryClient) {
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.completionTemplates.all });
|
||||
// The Services registry lists template names and clears links to a deleted template.
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.services.all });
|
||||
}
|
||||
|
||||
function toastFailure(error: Error, fallback: string) {
|
||||
if (isTitleRequiredError(error)) return;
|
||||
toast.error(error.message || fallback);
|
||||
}
|
||||
|
||||
export function useCreateCompletionTemplate(): UseMutationResult<
|
||||
CompletionTemplate,
|
||||
Error,
|
||||
CompletionTemplateInput
|
||||
> {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: CompletionTemplateInput) => completionTemplatesApi.create(input),
|
||||
onSuccess: () => {
|
||||
invalidateTemplates(queryClient);
|
||||
toast.success(COMPLETION_TEMPLATE_COPY.added);
|
||||
},
|
||||
onError: (error: Error) => toastFailure(error, "Failed to add template"),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCompletionTemplate(): UseMutationResult<
|
||||
CompletionTemplate,
|
||||
Error,
|
||||
UpdateCompletionTemplateVariables
|
||||
> {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ template, input }: UpdateCompletionTemplateVariables) =>
|
||||
completionTemplatesApi.update(template, input),
|
||||
onSuccess: () => {
|
||||
invalidateTemplates(queryClient);
|
||||
toast.success(COMPLETION_TEMPLATE_COPY.updated);
|
||||
},
|
||||
onError: (error: Error) => toastFailure(error, "Failed to update template"),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCompletionTemplate(): UseMutationResult<void, Error, number> {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => completionTemplatesApi.delete(id),
|
||||
onSuccess: () => {
|
||||
invalidateTemplates(queryClient);
|
||||
toast.success(COMPLETION_TEMPLATE_COPY.deleted);
|
||||
},
|
||||
onError: (error: Error) => toastFailure(error, "Failed to delete template"),
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { keepPreviousData, useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import { completionTemplatesApi } from "@/domain/completion-templates/api/completion-templates-api";
|
||||
import type {
|
||||
CompletionTemplate,
|
||||
LinkedWorkOrders,
|
||||
} from "@/domain/completion-templates/types/completion-template";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
/** `search` is a partial, case-insensitive Document Title match done by the server. */
|
||||
export function useCompletionTemplates(search = ""): UseQueryResult<CompletionTemplate[], Error> {
|
||||
const term = search.trim();
|
||||
return useQuery({
|
||||
queryKey: queryKeys.completionTemplates.list(term),
|
||||
queryFn: () => completionTemplatesApi.list(term),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
/** Always refetched when asked for: the count gates a destructive action. */
|
||||
export function useLinkedWorkOrders(
|
||||
templateId: number | null,
|
||||
): UseQueryResult<LinkedWorkOrders, Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.completionTemplates.linkedWorkOrders(templateId ?? 0),
|
||||
queryFn: () => completionTemplatesApi.linkedWorkOrders(templateId ?? 0),
|
||||
enabled: templateId !== null,
|
||||
staleTime: 0,
|
||||
refetchOnMount: "always",
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
/** A procedure description prints one paragraph per non-blank line. */
|
||||
export function descriptionLines(description: string): string[] {
|
||||
return description
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/** "Jun 1, 2026"; an empty or unparseable value shows as "—". */
|
||||
export function formatTemplateDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (value === "" || Number.isNaN(date.getTime())) return "—";
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
}
|
||||
|
|
@ -1,15 +1,19 @@
|
|||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from "@/api/api";
|
||||
import { apiDelete, apiGet, apiPatch, apiPost, apiPut } from "@/api/api";
|
||||
import { handleApiResponse, handlePaginatedResponse } from "@/api/handle-api-response";
|
||||
import {
|
||||
mapLocation,
|
||||
mapContactsToBackend,
|
||||
mapLocationListItems,
|
||||
mapLocationSites,
|
||||
mapLocationToBackend,
|
||||
mapSiteOpenWorkOrders,
|
||||
} from "@/domain/locations/mappers/location-mapper";
|
||||
import type {
|
||||
Location,
|
||||
LocationSite,
|
||||
SiteContactInfoInput,
|
||||
SiteOpenWorkOrders,
|
||||
LocationsListParams,
|
||||
LocationsListResult,
|
||||
} from "@/domain/locations/types/location";
|
||||
|
|
@ -71,7 +75,20 @@ export const locationsApi = {
|
|||
return mapLocation(handleApiResponse(data));
|
||||
},
|
||||
|
||||
/** Saves the site's ordered contacts and notes (work-order Site dialog). */
|
||||
updateContactInfo: async (id: string | number, input: SiteContactInfoInput): Promise<void> => {
|
||||
await apiPatch<unknown>(`${API_PATHS.rest.locations}/${id}/contact-info`, {
|
||||
contacts: mapContactsToBackend(input.contacts),
|
||||
notes: input.notes,
|
||||
});
|
||||
},
|
||||
|
||||
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));
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type {
|
|||
LocationContact,
|
||||
LocationListItem,
|
||||
LocationSite,
|
||||
SiteOpenWorkOrders,
|
||||
} from "@/domain/locations/types/location";
|
||||
|
||||
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
||||
|
|
@ -255,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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,3 +69,15 @@ export interface LocationsListResult {
|
|||
page: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
/** PATCH locations/{id}/contact-info: ordered site contacts (first is Main) and site notes. */
|
||||
export interface SiteContactInfoInput {
|
||||
contacts: LocationContact[];
|
||||
notes: string;
|
||||
}
|
||||
|
||||
/** GET locations/{id}/open-work-orders: work orders not Completed or Canceled (ids capped). */
|
||||
export interface SiteOpenWorkOrders {
|
||||
count: number;
|
||||
workOrderIds: number[];
|
||||
}
|
||||
|
|
|
|||
17
src/domain/locations/use-cases/use-site-open-work-orders.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import { locationsApi } from "@/domain/locations/api/locations-api";
|
||||
import type { SiteOpenWorkOrders } from "@/domain/locations/types/location";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
/** Open (not Completed/Canceled) work orders of a site; fetched only while `enabled`. */
|
||||
export function useSiteOpenWorkOrders(
|
||||
siteId: string,
|
||||
enabled: boolean,
|
||||
): UseQueryResult<SiteOpenWorkOrders, Error> {
|
||||
return useQuery({
|
||||
queryKey: queryKeys.locations.openWorkOrders(siteId),
|
||||
queryFn: () => locationsApi.getOpenWorkOrders(siteId),
|
||||
enabled,
|
||||
staleTime: 0,
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { useMutation, useQueryClient, type UseMutationResult } from "@tanstack/react-query";
|
||||
import { toast } from "react-toastify";
|
||||
import { locationsApi } from "@/domain/locations/api/locations-api";
|
||||
import type { SiteContactInfoInput } from "@/domain/locations/types/location";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
|
||||
interface UpdateSiteContactInfoInput extends SiteContactInfoInput {
|
||||
id: string | number;
|
||||
}
|
||||
|
||||
/** Writes contacts and notes to the site record, then refetches sites and work orders. */
|
||||
export function useUpdateSiteContactInfo(): UseMutationResult<
|
||||
void,
|
||||
Error,
|
||||
UpdateSiteContactInfoInput
|
||||
> {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, contacts, notes }: UpdateSiteContactInfoInput) =>
|
||||
locationsApi.updateContactInfo(id, { contacts, notes }),
|
||||
onSuccess: async () => {
|
||||
// Other work orders at this site show its contacts and notes too.
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.locations.all }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.workOrders.all }),
|
||||
]);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to update the site");
|
||||
},
|
||||
// The toast above is this failure's only message; the global mutation toast would repeat it.
|
||||
meta: { suppressErrorToast: true },
|
||||
});
|
||||
}
|
||||
59
src/domain/team-member-invite/api/team-member-invite-api.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* Anonymous invite registration. The invite token travels only in request bodies,
|
||||
* never in a URL path or query, and the server scopes every call to that token.
|
||||
*/
|
||||
import { isHTTPError } from "ky";
|
||||
|
||||
import { apiPost, apiPostNoContent } from "@/api/api";
|
||||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { handleApiResponse } from "@/api/handle-api-response";
|
||||
import type { AuthUser } from "@/domain/auth/types/auth-user";
|
||||
import type {
|
||||
CompleteRegistrationInput,
|
||||
TeamMemberInviteDetails,
|
||||
TeamMemberInviteErrorCode,
|
||||
} from "@/domain/team-member-invite/types/team-member-invite";
|
||||
import { getAuthUser, setAuthUser } from "@/lib/auth/auth-storage";
|
||||
|
||||
export const teamMemberInviteApi = {
|
||||
resolve: async (token: string): Promise<TeamMemberInviteDetails> => {
|
||||
const data = await apiPost<unknown>(API_PATHS.teamMemberInvite.resolve, { token });
|
||||
return handleApiResponse<TeamMemberInviteDetails>(data);
|
||||
},
|
||||
|
||||
sendCode: async (token: string): Promise<void> => {
|
||||
await apiPostNoContent(API_PATHS.teamMemberInvite.sendCode, { token });
|
||||
},
|
||||
|
||||
verifyCode: async ({ token, code }: { token: string; code: string }): Promise<void> => {
|
||||
await apiPostNoContent(API_PATHS.teamMemberInvite.verifyCode, { token, code });
|
||||
},
|
||||
|
||||
/** Finishes registration and stores the returned session exactly as login does. */
|
||||
complete: async (input: CompleteRegistrationInput): Promise<AuthUser> => {
|
||||
const data = await apiPost<unknown>(API_PATHS.teamMemberInvite.complete, {
|
||||
token: input.token,
|
||||
password: input.password,
|
||||
// An empty value clears the phone the admin entered.
|
||||
phone: input.phone.trim(),
|
||||
});
|
||||
const user = handleApiResponse<AuthUser>(data);
|
||||
setAuthUser(user);
|
||||
return getAuthUser() ?? user;
|
||||
},
|
||||
};
|
||||
|
||||
/** The server's failure code for an invite call, when the response carried one. */
|
||||
export function inviteErrorCode(error: unknown): TeamMemberInviteErrorCode | null {
|
||||
if (!isHTTPError(error)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data: unknown = error.data;
|
||||
if (typeof data === "object" && data !== null && "code" in data) {
|
||||
const code = (data as { code: unknown }).code;
|
||||
return typeof code === "string" ? (code as TeamMemberInviteErrorCode) : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { PASSWORDS_DO_NOT_MATCH_MESSAGE, passwordSchema } from "@/domain/auth/password-policy";
|
||||
|
||||
export const CODE_FORMAT_MESSAGE = "Enter the 6-digit code from your email";
|
||||
export const PHONE_FORMAT_MESSAGE = "Enter a valid phone number";
|
||||
|
||||
/** Mirrors the server's accepted phone characters and length. */
|
||||
const PHONE_PATTERN = /^[0-9+().\s-]{7,32}$/;
|
||||
|
||||
export const invitePasswordStepSchema = z
|
||||
.object({
|
||||
password: passwordSchema,
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((values) => values.password === values.confirmPassword, {
|
||||
message: PASSWORDS_DO_NOT_MATCH_MESSAGE,
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
|
||||
export type InvitePasswordStepValues = z.infer<typeof invitePasswordStepSchema>;
|
||||
|
||||
export const inviteCodeStepSchema = z.object({
|
||||
code: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^[0-9]{6}$/, CODE_FORMAT_MESSAGE),
|
||||
});
|
||||
|
||||
export type InviteCodeStepValues = z.infer<typeof inviteCodeStepSchema>;
|
||||
|
||||
export const inviteReviewStepSchema = z.object({
|
||||
phone: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((phone) => phone === "" || PHONE_PATTERN.test(phone), PHONE_FORMAT_MESSAGE),
|
||||
});
|
||||
|
||||
export type InviteReviewStepValues = z.infer<typeof inviteReviewStepSchema>;
|
||||
34
src/domain/team-member-invite/types/team-member-invite.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/** The server's generic message for any invite that cannot be used; shown before any call too. */
|
||||
export const INVALID_INVITE_MESSAGE =
|
||||
"This invite link is invalid or has expired. Ask your admin to send a new invite.";
|
||||
|
||||
/** Shown when the invite could not be checked at all (offline, timeout, server error); the link may still be valid. */
|
||||
export const INVITE_UNREACHABLE_MESSAGE =
|
||||
"We couldn't reach the server to check your invite. Check your connection and try again.";
|
||||
|
||||
/** The read-only identity an admin set when inviting the member. */
|
||||
export interface TeamMemberInviteDetails {
|
||||
name: string;
|
||||
role: string;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
}
|
||||
|
||||
export interface CompleteRegistrationInput {
|
||||
token: string;
|
||||
password: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
/** Stable failure codes returned by the anonymous invite endpoints. */
|
||||
export type TeamMemberInviteErrorCode =
|
||||
| "invalid_invite"
|
||||
| "code_incorrect"
|
||||
| "code_expired"
|
||||
| "code_locked"
|
||||
| "resend_too_soon"
|
||||
| "resend_limit_reached"
|
||||
| "code_delivery_failed"
|
||||
| "email_not_confirmed"
|
||||
| "password_rejected"
|
||||
| "invalid_phone";
|
||||