Compare commits
No commits in common. "414e584b36cebd106c6381a4b2da94de21ff9060" and "c0e484275dbcc512ef36715aad403f144e4710b4" have entirely different histories.
414e584b36
...
c0e484275d
|
Before Width: | Height: | Size: 149 KiB After Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 125 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 149 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 155 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 123 KiB After Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 124 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 202 KiB After Width: | Height: | Size: 195 KiB |
|
|
@ -33,9 +33,6 @@ export const API_PATHS = {
|
||||||
pocPatch: (id: string | number) => `workorders/${id}/poc`,
|
pocPatch: (id: string | number) => `workorders/${id}/poc`,
|
||||||
boardDetail: (id: string | number) => `workorders/${id}/detail`,
|
boardDetail: (id: string | number) => `workorders/${id}/detail`,
|
||||||
completionTemplates: "workorders/completion-templates",
|
completionTemplates: "workorders/completion-templates",
|
||||||
completionTemplateById: (id: string | number) => `workorders/completion-templates/${id}`,
|
|
||||||
completionTemplateLinkedWorkOrders: (id: string | number) =>
|
|
||||||
`workorders/completion-templates/${id}/linked-work-orders`,
|
|
||||||
comments: (id: string | number) => `workorders/${id}/comments`,
|
comments: (id: string | number) => `workorders/${id}/comments`,
|
||||||
commentItem: (workOrderId: string | number, commentId: string | number) =>
|
commentItem: (workOrderId: string | number, commentId: string | number) =>
|
||||||
`workorders/${workOrderId}/comments/${commentId}`,
|
`workorders/${workOrderId}/comments/${commentId}`,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { ProtectedRoute } from "@/components/auth/protected-route";
|
||||||
import { AppSidebar } from "@/components/layout/app-sidebar";
|
import { AppSidebar } from "@/components/layout/app-sidebar";
|
||||||
import { getSidebarWidth } from "@/config/menu";
|
import { getSidebarWidth } from "@/config/menu";
|
||||||
import { AppTopbar } from "@/components/layout/app-topbar";
|
import { AppTopbar } from "@/components/layout/app-topbar";
|
||||||
import { SlaAtRiskBanner } from "@/components/notifications/sla-at-risk-banner";
|
|
||||||
import { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider";
|
import { NotificationSessionProvider } from "@/domain/notifications/session/notification-session-provider";
|
||||||
|
|
||||||
export default function AdminShellLayout() {
|
export default function AdminShellLayout() {
|
||||||
|
|
@ -41,7 +40,6 @@ export default function AdminShellLayout() {
|
||||||
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
|
sx={{ width: `calc(100% - ${sidebarWidth}px)` }}
|
||||||
>
|
>
|
||||||
<AppTopbar onToggleNav={handleToggleNav} />
|
<AppTopbar onToggleNav={handleToggleNav} />
|
||||||
<SlaAtRiskBanner />
|
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
component="main"
|
component="main"
|
||||||
|
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
import { Text } from "@/components/ui/text";
|
|
||||||
import type { AuthUser } from "@/domain/auth/types/auth-user";
|
|
||||||
|
|
||||||
function ReadOnlyValue({ label, value }: { label: string; value: string | undefined }) {
|
|
||||||
return (
|
|
||||||
<div className="grid gap-1.5">
|
|
||||||
<Text variant="label" tone="muted" className="text-[12px]!">
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
<Text variant="body" className="m-0 text-[14px]!">
|
|
||||||
{value || "—"}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AccountSummaryCard({ user }: { user: AuthUser }) {
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
aria-label="Account details"
|
|
||||||
className="grid gap-4 rounded-lg border border-(--color-border) bg-(--color-bg-surface) p-5"
|
|
||||||
>
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
|
||||||
<ReadOnlyValue label="Name" value={user.fullname} />
|
|
||||||
<ReadOnlyValue label="Role" value={user.userRoles} />
|
|
||||||
</div>
|
|
||||||
<ReadOnlyValue label="Email" value={user.email} />
|
|
||||||
<Text variant="caption" tone="muted" className="text-[11.5px]!">
|
|
||||||
Contact your Admin to change your name, role, or email.
|
|
||||||
</Text>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,132 +0,0 @@
|
||||||
import { useForm, useWatch } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { Box } from "@mui/material";
|
|
||||||
import { KeyRound } from "lucide-react";
|
|
||||||
|
|
||||||
import { PasswordChecklist } from "@/components/auth/password-checklist";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { FormField } from "@/components/ui/form-field";
|
|
||||||
import { PasswordField } from "@/components/ui/password-field";
|
|
||||||
import { Text } from "@/components/ui/text";
|
|
||||||
import {
|
|
||||||
CURRENT_PASSWORD_INCORRECT_MESSAGE,
|
|
||||||
changePasswordSchema,
|
|
||||||
type ChangePasswordFormValues,
|
|
||||||
} from "@/domain/auth/schemas/change-password-schema";
|
|
||||||
import { useChangePassword } from "@/domain/auth/use-cases/use-change-password";
|
|
||||||
|
|
||||||
const EMPTY_VALUES: ChangePasswordFormValues = {
|
|
||||||
currentPassword: "",
|
|
||||||
newPassword: "",
|
|
||||||
confirmPassword: "",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ChangePasswordSection() {
|
|
||||||
const changePassword = useChangePassword();
|
|
||||||
const {
|
|
||||||
register,
|
|
||||||
handleSubmit,
|
|
||||||
control,
|
|
||||||
reset,
|
|
||||||
setError,
|
|
||||||
formState: { errors },
|
|
||||||
} = useForm<ChangePasswordFormValues>({
|
|
||||||
resolver: zodResolver(changePasswordSchema),
|
|
||||||
defaultValues: EMPTY_VALUES,
|
|
||||||
mode: "onTouched",
|
|
||||||
});
|
|
||||||
|
|
||||||
const values = useWatch({ control });
|
|
||||||
const newPassword = values.newPassword ?? "";
|
|
||||||
const canSubmit = changePasswordSchema.safeParse(values).success;
|
|
||||||
const serverError = changePassword.error?.message;
|
|
||||||
const showServerError =
|
|
||||||
Boolean(serverError) && serverError !== CURRENT_PASSWORD_INCORRECT_MESSAGE;
|
|
||||||
|
|
||||||
const onSubmit = (form: ChangePasswordFormValues) => {
|
|
||||||
changePassword.mutate(
|
|
||||||
{
|
|
||||||
Currentpassword: form.currentPassword,
|
|
||||||
Newpassword: form.newPassword,
|
|
||||||
Confirmpassword: form.confirmPassword,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
onSuccess: () => reset(EMPTY_VALUES),
|
|
||||||
onError: (error) => {
|
|
||||||
if (error.message === CURRENT_PASSWORD_INCORRECT_MESSAGE) {
|
|
||||||
setError("currentPassword", { message: CURRENT_PASSWORD_INCORRECT_MESSAGE });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
component="form"
|
|
||||||
aria-labelledby="change-password-heading"
|
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
|
||||||
className="grid gap-4 rounded-lg border border-(--color-border) bg-(--color-bg-surface) p-5"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<KeyRound size={15} aria-hidden="true" className="text-primary" />
|
|
||||||
<Text id="change-password-heading" variant="heading" className="m-0 text-[15px]!">
|
|
||||||
Change Password
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
label="Current password"
|
|
||||||
htmlFor="account-current-password"
|
|
||||||
required
|
|
||||||
error={Boolean(errors.currentPassword)}
|
|
||||||
helperText={errors.currentPassword?.message}
|
|
||||||
>
|
|
||||||
<PasswordField
|
|
||||||
{...register("currentPassword")}
|
|
||||||
id="account-current-password"
|
|
||||||
autoComplete="current-password"
|
|
||||||
error={Boolean(errors.currentPassword)}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField label="New password" htmlFor="account-new-password" required>
|
|
||||||
<PasswordField
|
|
||||||
{...register("newPassword")}
|
|
||||||
id="account-new-password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
slotProps={{ htmlInput: { "aria-describedby": "account-password-rules" } }}
|
|
||||||
/>
|
|
||||||
<PasswordChecklist id="account-password-rules" password={newPassword} />
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
label="Confirm new password"
|
|
||||||
htmlFor="account-confirm-password"
|
|
||||||
required
|
|
||||||
error={Boolean(errors.confirmPassword)}
|
|
||||||
helperText={errors.confirmPassword?.message}
|
|
||||||
>
|
|
||||||
<PasswordField
|
|
||||||
{...register("confirmPassword")}
|
|
||||||
id="account-confirm-password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
error={Boolean(errors.confirmPassword)}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
|
|
||||||
{showServerError && <Text variant="error">{serverError}</Text>}
|
|
||||||
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={!canSubmit}
|
|
||||||
loading={changePassword.isPending}
|
|
||||||
loadingText="Updating…"
|
|
||||||
>
|
|
||||||
Update password
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
import { Box } from "@mui/material";
|
|
||||||
|
|
||||||
import { AccountSummaryCard } from "@/app/(protected)/account/_components/account-summary-card";
|
|
||||||
import { ChangePasswordSection } from "@/app/(protected)/account/_components/change-password-section";
|
|
||||||
import { PageHeader } from "@/components/ui/page-header";
|
|
||||||
import { useAuthContext } from "@/providers/auth-context";
|
|
||||||
|
|
||||||
export default function MyAccountPage() {
|
|
||||||
const { user } = useAuthContext();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box className="flex flex-col gap-2 p-2.5">
|
|
||||||
<PageHeader
|
|
||||||
surface
|
|
||||||
title="My Account"
|
|
||||||
subtitle="Review your account and manage your password"
|
|
||||||
/>
|
|
||||||
<Box className="grid max-w-md gap-5 px-5 pb-5">
|
|
||||||
{user != null && <AccountSummaryCard user={user} />}
|
|
||||||
<ChangePasswordSection />
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
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)}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
import { FileText } from "lucide-react";
|
|
||||||
import { Text } from "@/components/ui/text";
|
|
||||||
import { CompDocDocumentFrame } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-document-frame";
|
|
||||||
import { CompDocPreviewHeader } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-header";
|
|
||||||
import { CompDocProcedureHeading } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-procedure-heading";
|
|
||||||
import { CompDocSafetyBullets } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-safety-bullets";
|
|
||||||
import {
|
|
||||||
AUTO_POPULATED_FIELDS,
|
|
||||||
COMPLETION_TEMPLATE_COPY,
|
|
||||||
} from "@/domain/completion-templates/constants/completion-template-constants";
|
|
||||||
import type { CompletionTemplateProcedure } from "@/domain/completion-templates/types/completion-template";
|
|
||||||
import { descriptionLines } from "@/domain/completion-templates/utils/description-lines";
|
|
||||||
|
|
||||||
export interface CompletionTemplatePreviewValues {
|
|
||||||
name: string;
|
|
||||||
extraSafetyNote: string | null;
|
|
||||||
procedures: CompletionTemplateProcedure[];
|
|
||||||
}
|
|
||||||
|
|
||||||
type CompletionTemplatePreviewProps = {
|
|
||||||
values: CompletionTemplatePreviewValues;
|
|
||||||
isEditing: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function PreviewPill({ isEditing }: { isEditing: boolean }) {
|
|
||||||
return (
|
|
||||||
<div className="flex shrink-0 items-center justify-center" style={{ padding: "12px 0" }}>
|
|
||||||
<span
|
|
||||||
className="inline-flex items-center gap-1.5"
|
|
||||||
style={{
|
|
||||||
height: 28,
|
|
||||||
padding: "0 12px",
|
|
||||||
borderRadius: 9999,
|
|
||||||
background: "var(--color-bg-surface)",
|
|
||||||
border: "1px solid var(--color-border)",
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: "var(--muted-foreground)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FileText size={13} />
|
|
||||||
{isEditing ? "Live preview" : "Document preview"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PreviewTitle({ name }: { name: string }) {
|
|
||||||
const title = name.trim();
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Text
|
|
||||||
as="h2"
|
|
||||||
variant="title"
|
|
||||||
family="body"
|
|
||||||
sx={{
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: 700,
|
|
||||||
letterSpacing: "0.02em",
|
|
||||||
color: title ? "#161E38" : "#9CA3AF",
|
|
||||||
margin: "16px 0 4px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{title ? title.toUpperCase() : COMPLETION_TEMPLATE_COPY.untitledDocument}
|
|
||||||
</Text>
|
|
||||||
<Text variant="body" sx={{ fontSize: 12, color: "#58595B", margin: "2px 0 12px" }}>
|
|
||||||
{AUTO_POPULATED_FIELDS.join(" · ")} {COMPLETION_TEMPLATE_COPY.autoFilledSuffix}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PreviewProcedures({ procedures }: { procedures: CompletionTemplateProcedure[] }) {
|
|
||||||
if (procedures.length === 0) {
|
|
||||||
return (
|
|
||||||
<Text
|
|
||||||
variant="body"
|
|
||||||
sx={{ fontSize: 12.5, color: "#9CA3AF", margin: "16px 0 0", fontStyle: "italic" }}
|
|
||||||
>
|
|
||||||
{COMPLETION_TEMPLATE_COPY.noProcedurePreview}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{procedures.map((procedure, index) => (
|
|
||||||
<section key={index} aria-label={`Preview procedure ${index + 1}`}>
|
|
||||||
<CompDocProcedureHeading>
|
|
||||||
{procedure.name.trim() || `Procedure ${index + 1}`}
|
|
||||||
</CompDocProcedureHeading>
|
|
||||||
{descriptionLines(procedure.description).map((line, lineIndex) => (
|
|
||||||
<Text
|
|
||||||
key={lineIndex}
|
|
||||||
variant="body"
|
|
||||||
sx={{ fontSize: 13, lineHeight: 1.5, color: "#161E38", margin: "4px 0" }}
|
|
||||||
>
|
|
||||||
{line}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The template rendered on the same page, header and safety bullets as a work order's document. */
|
|
||||||
export function CompletionTemplatePreview({ values, isEditing }: CompletionTemplatePreviewProps) {
|
|
||||||
const note = values.extraSafetyNote?.trim() ?? "";
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="flex min-h-0 flex-col"
|
|
||||||
style={{
|
|
||||||
width: "58%",
|
|
||||||
borderRight: "1px solid var(--color-border)",
|
|
||||||
background: "var(--color-bg-muted)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PreviewPill isEditing={isEditing} />
|
|
||||||
<div
|
|
||||||
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
|
|
||||||
style={{ padding: "0 20px 20px" }}
|
|
||||||
data-testid="completion-template-preview"
|
|
||||||
>
|
|
||||||
<CompDocDocumentFrame>
|
|
||||||
<CompDocPreviewHeader />
|
|
||||||
<PreviewTitle name={values.name} />
|
|
||||||
<CompDocSafetyBullets extra={note === "" ? [] : [note]} />
|
|
||||||
<PreviewProcedures procedures={values.procedures} />
|
|
||||||
</CompDocDocumentFrame>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,162 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
import { defaultsForRole } from "@/app/(protected)/team-members/_components/team-member-permissions";
|
|
||||||
import type { TeamMemberRole } from "@/domain/team-members/types/team-member";
|
|
||||||
import { useAuthContext } from "@/providers/auth-context";
|
|
||||||
|
|
||||||
const TEAM_ROLES: readonly TeamMemberRole[] = ["Admin", "Scheduler", "Dispatcher"];
|
|
||||||
|
|
||||||
export interface CompletionTemplatePermissions {
|
|
||||||
canCreate: boolean;
|
|
||||||
canEdit: boolean;
|
|
||||||
canDelete: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function teamRoles(userRoles: string | null | undefined): TeamMemberRole[] {
|
|
||||||
const names = (userRoles ?? "").split(",").map((role) => role.trim().toLowerCase());
|
|
||||||
return TEAM_ROLES.filter((role) => names.includes(role.toLowerCase()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The signed-in user's session carries roles, not per-user permission overrides, so each key
|
|
||||||
* resolves to the role default the Team Members screen and the server policy start from.
|
|
||||||
*/
|
|
||||||
export function completionTemplatePermissions(
|
|
||||||
userRoles: string | null | undefined,
|
|
||||||
): CompletionTemplatePermissions {
|
|
||||||
const granted = new Set(teamRoles(userRoles).flatMap((role) => [...defaultsForRole(role)]));
|
|
||||||
return {
|
|
||||||
canCreate: granted.has("createCompletionDocTemplates"),
|
|
||||||
canEdit: granted.has("editCompletionDocTemplates"),
|
|
||||||
canDelete: granted.has("deleteCompletionDocTemplates"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCompletionTemplatePermissions(): CompletionTemplatePermissions {
|
|
||||||
const { user } = useAuthContext();
|
|
||||||
return completionTemplatePermissions(user?.userRoles);
|
|
||||||
}
|
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
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,6 +1,8 @@
|
||||||
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
CircularProgress,
|
CircularProgress,
|
||||||
|
IconButton,
|
||||||
Paper,
|
Paper,
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
|
|
@ -8,6 +10,7 @@ import {
|
||||||
TableContainer,
|
TableContainer,
|
||||||
TableHead,
|
TableHead,
|
||||||
TableRow,
|
TableRow,
|
||||||
|
Tooltip,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
import type { LocationListItem } from "@/domain/locations/types/location";
|
import type { LocationListItem } from "@/domain/locations/types/location";
|
||||||
|
|
@ -16,15 +19,17 @@ import { StateAreaCell } from "./state-area-cell";
|
||||||
|
|
||||||
interface LocationsTableProps {
|
interface LocationsTableProps {
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
onDelete: (row: LocationListItem) => void;
|
||||||
onOpen: (row: LocationListItem) => void;
|
onOpen: (row: LocationListItem) => void;
|
||||||
rows: LocationListItem[];
|
rows: LocationListItem[];
|
||||||
selectedId?: string | null;
|
selectedId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLUMN_COUNT = 7;
|
const COLUMN_COUNT = 8;
|
||||||
|
|
||||||
export function LocationsTable({
|
export function LocationsTable({
|
||||||
isLoading,
|
isLoading,
|
||||||
|
onDelete,
|
||||||
onOpen,
|
onOpen,
|
||||||
rows,
|
rows,
|
||||||
selectedId = null,
|
selectedId = null,
|
||||||
|
|
@ -41,6 +46,9 @@ export function LocationsTable({
|
||||||
<TableCell className="font-semibold">State</TableCell>
|
<TableCell className="font-semibold">State</TableCell>
|
||||||
<TableCell className="font-semibold">POC Name</TableCell>
|
<TableCell className="font-semibold">POC Name</TableCell>
|
||||||
<TableCell className="font-semibold">POC Phone</TableCell>
|
<TableCell className="font-semibold">POC Phone</TableCell>
|
||||||
|
<TableCell align="right" className="font-semibold">
|
||||||
|
<span className="sr-only">Actions</span>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|
@ -90,6 +98,20 @@ export function LocationsTable({
|
||||||
<LocationsContactsCell row={row} />
|
<LocationsContactsCell row={row} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{row.phone || "—"}</TableCell>
|
<TableCell>{row.phone || "—"}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Tooltip title="Delete">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
aria-label={`Delete site ${row.name}`}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onDelete(row);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DeleteOutlineIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
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,11 +1,8 @@
|
||||||
import { useContext, useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Alert, Box, Button, CircularProgress, Drawer, Stack, Typography } from "@mui/material";
|
import { Alert, Box, Button, CircularProgress, Drawer, Stack, Typography } from "@mui/material";
|
||||||
import { IconCloseButton } from "@/components/ui/icon-close-button";
|
import { IconCloseButton } from "@/components/ui/icon-close-button";
|
||||||
import type { Location } from "@/domain/locations/types/location";
|
import type { Location } from "@/domain/locations/types/location";
|
||||||
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||||
import { canDeleteSites } from "@/lib/auth/user-utils";
|
|
||||||
import { AuthContext } from "@/providers/auth-context";
|
|
||||||
import { SiteDeleteDialog } from "./site-delete-dialog";
|
|
||||||
import { SiteDetailEdit } from "./site-detail-edit";
|
import { SiteDetailEdit } from "./site-detail-edit";
|
||||||
import { SiteDetailView } from "./site-detail-view";
|
import { SiteDetailView } from "./site-detail-view";
|
||||||
|
|
||||||
|
|
@ -39,8 +36,6 @@ function PanelHeader({ location, onClose }: { location?: Location; onClose: () =
|
||||||
|
|
||||||
function PanelBody({ siteId, onClose }: { siteId: string; onClose: () => void }) {
|
function PanelBody({ siteId, onClose }: { siteId: string; onClose: () => void }) {
|
||||||
const [mode, setMode] = useState<"view" | "edit">("view");
|
const [mode, setMode] = useState<"view" | "edit">("view");
|
||||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
|
||||||
const canDelete = canDeleteSites(useContext(AuthContext)?.user?.userRoles);
|
|
||||||
const { data: location, isLoading, error } = useLocationDetail(siteId);
|
const { data: location, isLoading, error } = useLocationDetail(siteId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -70,22 +65,10 @@ function PanelBody({ siteId, onClose }: { siteId: string; onClose: () => void })
|
||||||
className="border-t px-4 py-3"
|
className="border-t px-4 py-3"
|
||||||
sx={{ justifyContent: "flex-end", borderColor: "divider" }}
|
sx={{ justifyContent: "flex-end", borderColor: "divider" }}
|
||||||
>
|
>
|
||||||
{canDelete && (
|
|
||||||
<Button color="error" onClick={() => setIsDeleteOpen(true)} className="mr-auto">
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button variant="contained" onClick={() => setMode("edit")}>
|
<Button variant="contained" onClick={() => setMode("edit")}>
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
<SiteDeleteDialog
|
|
||||||
open={isDeleteOpen}
|
|
||||||
siteId={siteId}
|
|
||||||
siteCode={location.name ?? ""}
|
|
||||||
onClose={() => setIsDeleteOpen(false)}
|
|
||||||
onDeleted={onClose}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import { Controller, useController, useWatch, type UseFormReturn } from "react-hook-form";
|
import { Controller, useWatch, type UseFormReturn } from "react-hook-form";
|
||||||
import { Stack, TextField } from "@mui/material";
|
import { Stack, TextField } from "@mui/material";
|
||||||
import { AddressAutocompleteField } from "@/components/common/address-autocomplete-field";
|
|
||||||
import { normalizeStateCode } from "@/data/areas";
|
|
||||||
import type { AccountListItem } from "@/domain/accounts/types/account";
|
import type { AccountListItem } from "@/domain/accounts/types/account";
|
||||||
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
|
import type { LocationFormValues } from "@/domain/locations/schemas/location-schema";
|
||||||
import { ClientSelect } from "./client-select";
|
import { ClientSelect } from "./client-select";
|
||||||
|
|
@ -28,9 +26,6 @@ export function SiteFormFields({
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = form;
|
} = form;
|
||||||
const [address, city, state] = useWatch({ control, name: ["address", "city", "state"] });
|
const [address, city, state] = useWatch({ control, name: ["address", "city", "state"] });
|
||||||
const { field: addressField } = useController({ control, name: "address" });
|
|
||||||
const { field: cityField } = useController({ control, name: "city" });
|
|
||||||
const { field: stateField } = useController({ control, name: "state" });
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack spacing={2}>
|
<Stack spacing={2}>
|
||||||
|
|
@ -55,27 +50,18 @@ export function SiteFormFields({
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<ClientSelect control={control} error={errors.accountId} initialClient={initialClient} />
|
<ClientSelect control={control} error={errors.accountId} initialClient={initialClient} />
|
||||||
<AddressAutocompleteField
|
<TextField
|
||||||
value={addressField.value}
|
{...register("address")}
|
||||||
onInputChange={addressField.onChange}
|
|
||||||
onSelect={(parts) => {
|
|
||||||
// One pick fills all three, each through its own controller so the
|
|
||||||
// form marks them dirty and revalidates (edit mode's Save depends on it).
|
|
||||||
addressField.onChange(parts.street);
|
|
||||||
cityField.onChange(parts.city);
|
|
||||||
stateField.onChange(normalizeStateCode(parts.state) ?? "");
|
|
||||||
}}
|
|
||||||
label="Street Address"
|
label="Street Address"
|
||||||
|
placeholder="Street address"
|
||||||
required
|
required
|
||||||
requiredMarker
|
|
||||||
error={Boolean(errors.address)}
|
error={Boolean(errors.address)}
|
||||||
helperText={
|
helperText={errors.address?.message}
|
||||||
errors.address?.message ?? "Pick a suggestion to auto-fill City/State, or type your own."
|
fullWidth
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
||||||
<TextField
|
<TextField
|
||||||
{...cityField}
|
{...register("city")}
|
||||||
label="City"
|
label="City"
|
||||||
required
|
required
|
||||||
error={Boolean(errors.city)}
|
error={Boolean(errors.city)}
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,14 @@ import {
|
||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import type { LocationListItem } from "@/domain/locations/types/location";
|
||||||
|
import { useDeleteLocation } from "@/domain/locations/use-cases/use-delete-location";
|
||||||
import { useLocationsList } from "@/domain/locations/use-cases/use-locations-list";
|
import { useLocationsList } from "@/domain/locations/use-cases/use-locations-list";
|
||||||
import { useDebounce } from "@/hooks/use-debounce";
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
import { expandAreasToStateCodes, type AreaZone } from "@/data/areas";
|
import { expandAreasToStateCodes, type AreaZone } from "@/data/areas";
|
||||||
import { AddSiteDialog } from "./_components/add-site-dialog";
|
import { AddSiteDialog } from "./_components/add-site-dialog";
|
||||||
import { AreaFacet } from "./_components/area-facet";
|
import { AreaFacet } from "./_components/area-facet";
|
||||||
|
import { LocationDeleteDialog } from "./_components/location-delete-dialog";
|
||||||
import { LocationsTable } from "./_components/locations-table";
|
import { LocationsTable } from "./_components/locations-table";
|
||||||
import { SITE_PANEL_WIDTH, SiteDetailPanel } from "./_components/site-detail-panel";
|
import { SITE_PANEL_WIDTH, SiteDetailPanel } from "./_components/site-detail-panel";
|
||||||
|
|
||||||
|
|
@ -22,10 +25,12 @@ export default function LocationsListPage() {
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
const [pageSize, setPageSize] = useState(12);
|
const [pageSize, setPageSize] = useState(12);
|
||||||
const [selectedAreas, setSelectedAreas] = useState<AreaZone[]>([]);
|
const [selectedAreas, setSelectedAreas] = useState<AreaZone[]>([]);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<LocationListItem | null>(null);
|
||||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
|
||||||
const debouncedSearch = useDebounce(search, 300);
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
|
const deleteLocation = useDeleteLocation();
|
||||||
const closePanel = useCallback(() => setSelectedId(null), []);
|
const closePanel = useCallback(() => setSelectedId(null), []);
|
||||||
|
|
||||||
const stateCodes = useMemo(() => expandAreasToStateCodes(selectedAreas), [selectedAreas]);
|
const stateCodes = useMemo(() => expandAreasToStateCodes(selectedAreas), [selectedAreas]);
|
||||||
|
|
@ -45,6 +50,20 @@ export default function LocationsListPage() {
|
||||||
const rows = data?.items ?? [];
|
const rows = data?.items ?? [];
|
||||||
const totalCount = data?.totalCount ?? 0;
|
const totalCount = data?.totalCount ?? 0;
|
||||||
|
|
||||||
|
const handleDeleteConfirm = () => {
|
||||||
|
if (!deleteTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedId = String(deleteTarget.id);
|
||||||
|
deleteLocation.mutate(deleteTarget.id, {
|
||||||
|
onSuccess: () => {
|
||||||
|
setDeleteTarget(null);
|
||||||
|
setSelectedId((current) => (current === deletedId ? null : current));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
className="flex flex-col gap-4 p-4"
|
className="flex flex-col gap-4 p-4"
|
||||||
|
|
@ -101,6 +120,7 @@ export default function LocationsListPage() {
|
||||||
rows={rows}
|
rows={rows}
|
||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
onOpen={(row) => setSelectedId(String(row.id))}
|
onOpen={(row) => setSelectedId(String(row.id))}
|
||||||
|
onDelete={setDeleteTarget}
|
||||||
/>
|
/>
|
||||||
<TablePagination
|
<TablePagination
|
||||||
component="div"
|
component="div"
|
||||||
|
|
@ -116,6 +136,12 @@ export default function LocationsListPage() {
|
||||||
/>
|
/>
|
||||||
<AddSiteDialog open={isAddOpen} onClose={() => setIsAddOpen(false)} />
|
<AddSiteDialog open={isAddOpen} onClose={() => setIsAddOpen(false)} />
|
||||||
<SiteDetailPanel siteId={selectedId} onClose={closePanel} />
|
<SiteDetailPanel siteId={selectedId} onClose={closePanel} />
|
||||||
|
<LocationDeleteDialog
|
||||||
|
target={deleteTarget}
|
||||||
|
isPending={deleteLocation.isPending}
|
||||||
|
onClose={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={handleDeleteConfirm}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
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,20 +16,14 @@ import {
|
||||||
formatUpliftMoney,
|
formatUpliftMoney,
|
||||||
isGrantedAmount,
|
isGrantedAmount,
|
||||||
} from "@/app/(protected)/uplifts/_components/format-uplift-money";
|
} from "@/app/(protected)/uplifts/_components/format-uplift-money";
|
||||||
import {
|
|
||||||
UPLIFT_APPROVAL_HEADER_CELLS,
|
|
||||||
type UpliftApprovalTab,
|
|
||||||
} from "@/app/(protected)/uplifts/_components/uplift-approval-tabs";
|
|
||||||
import {
|
|
||||||
RejectedEmptyState,
|
|
||||||
RejectedRowCells,
|
|
||||||
} from "@/app/(protected)/uplifts/_components/uplift-rejected-cells";
|
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
|
import { useUpliftsCanApprove } from "@/domain/uplifts/use-cases/use-uplifts-can-approve";
|
||||||
import { upliftAmount } from "@/domain/uplifts/utils/uplift-amount";
|
import { upliftAmount } from "@/domain/uplifts/utils/uplift-amount";
|
||||||
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
||||||
import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
|
import { formatDateTime, getWaitTimeTextClass, timeSince, waitTimeColor } from "@/lib/time-utils";
|
||||||
|
|
||||||
|
export type UpliftApprovalTab = "pending" | "approved";
|
||||||
|
|
||||||
const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked.";
|
const CLOSED_WO_TOOLTIP = "This work order is closed. Uplifts can no longer be revoked.";
|
||||||
const ADMIN_ONLY_REVOKE_TOOLTIP = "Only admins can revoke uplifts";
|
const ADMIN_ONLY_REVOKE_TOOLTIP = "Only admins can revoke uplifts";
|
||||||
|
|
||||||
|
|
@ -242,84 +236,65 @@ function UpliftApprovalRow({
|
||||||
<AttachmentsCell row={row} onOpenAttachment={onOpenAttachment} />
|
<AttachmentsCell row={row} onOpenAttachment={onOpenAttachment} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{row.requestedByVendorName || "—"}</TableCell>
|
<TableCell>{row.requestedByVendorName || "—"}</TableCell>
|
||||||
{tab === "pending" && (
|
{tab === "pending" ? (
|
||||||
<>
|
<TableCell>
|
||||||
<TableCell>
|
<WaitingCell row={row} />
|
||||||
<WaitingCell row={row} />
|
</TableCell>
|
||||||
</TableCell>
|
) : (
|
||||||
<TableCell>
|
|
||||||
<PendingRowActions
|
|
||||||
row={row}
|
|
||||||
onApprove={onApprove}
|
|
||||||
onReject={onReject}
|
|
||||||
isDecisionPending={isDecisionPending}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{tab === "approved" && (
|
|
||||||
<>
|
<>
|
||||||
<TableCell>{row.decidedByName || "—"}</TableCell>
|
<TableCell>{row.decidedByName || "—"}</TableCell>
|
||||||
<TableCell>{row.decidedAt ? formatDateTime(row.decidedAt) : "—"}</TableCell>
|
<TableCell>{row.decidedAt ? formatDateTime(row.decidedAt) : "—"}</TableCell>
|
||||||
<TableCell>
|
|
||||||
<ApprovedRowActions
|
|
||||||
row={row}
|
|
||||||
canRevoke={canRevoke}
|
|
||||||
onRevoke={onRevoke}
|
|
||||||
isDecisionPending={isDecisionPending}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{tab === "rejected" && <RejectedRowCells row={row} />}
|
<TableCell>
|
||||||
</TableRow>
|
{tab === "pending" ? (
|
||||||
);
|
<PendingRowActions
|
||||||
}
|
row={row}
|
||||||
|
onApprove={onApprove}
|
||||||
function UpliftApprovalsEmptyState({ tab }: { tab: UpliftApprovalTab }) {
|
onReject={onReject}
|
||||||
if (tab === "rejected") return <RejectedEmptyState />;
|
isDecisionPending={isDecisionPending}
|
||||||
if (tab === "approved") {
|
/>
|
||||||
return (
|
) : (
|
||||||
<Box>
|
<ApprovedRowActions
|
||||||
<Text variant="description">No approved uplifts yet</Text>
|
row={row}
|
||||||
<Text variant="caption" tone="muted">
|
canRevoke={canRevoke}
|
||||||
Uplifts you approve are archived here for reference.
|
onRevoke={onRevoke}
|
||||||
</Text>
|
isDecisionPending={isDecisionPending}
|
||||||
</Box>
|
/>
|
||||||
);
|
)}
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Text variant="description" tone="muted">
|
|
||||||
No uplifts waiting for approval.
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A failed queue is not an empty one: the page's error alert explains it, so the
|
|
||||||
// empty-state copy must not also claim the list has nothing in it.
|
|
||||||
function UpliftApprovalsEmptyRow({
|
|
||||||
tab,
|
|
||||||
colSpan,
|
|
||||||
hasError,
|
|
||||||
}: {
|
|
||||||
tab: UpliftApprovalTab;
|
|
||||||
colSpan: number;
|
|
||||||
hasError: boolean;
|
|
||||||
}) {
|
|
||||||
if (hasError) return null;
|
|
||||||
return (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={colSpan} align="center" className="py-10">
|
|
||||||
<UpliftApprovalsEmptyState tab={tab} />
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PENDING_HEADER_CELLS = [
|
||||||
|
"Work Order",
|
||||||
|
"Service / Vendor",
|
||||||
|
"Amount",
|
||||||
|
"Approved on WO",
|
||||||
|
"Notes",
|
||||||
|
"Attachments",
|
||||||
|
"Requested By",
|
||||||
|
"Waiting",
|
||||||
|
"Actions",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const APPROVED_HEADER_CELLS = [
|
||||||
|
"Work Order",
|
||||||
|
"Service / Vendor",
|
||||||
|
"Amount",
|
||||||
|
"Approved on WO",
|
||||||
|
"Notes",
|
||||||
|
"Attachments",
|
||||||
|
"Requested By",
|
||||||
|
"Approved By",
|
||||||
|
"Approved At",
|
||||||
|
"Actions",
|
||||||
|
] as const;
|
||||||
|
|
||||||
export function UpliftApprovalsTable({
|
export function UpliftApprovalsTable({
|
||||||
tab,
|
tab,
|
||||||
isLoading,
|
isLoading,
|
||||||
hasError,
|
|
||||||
rows,
|
rows,
|
||||||
canRevoke,
|
canRevoke,
|
||||||
onOpenDetail,
|
onOpenDetail,
|
||||||
|
|
@ -331,7 +306,6 @@ export function UpliftApprovalsTable({
|
||||||
}: {
|
}: {
|
||||||
tab: UpliftApprovalTab;
|
tab: UpliftApprovalTab;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
hasError: boolean;
|
|
||||||
rows: UpliftQueueItem[];
|
rows: UpliftQueueItem[];
|
||||||
canRevoke: boolean;
|
canRevoke: boolean;
|
||||||
onOpenDetail: (row: UpliftQueueItem) => void;
|
onOpenDetail: (row: UpliftQueueItem) => void;
|
||||||
|
|
@ -341,7 +315,7 @@ export function UpliftApprovalsTable({
|
||||||
onOpenAttachment: (row: UpliftQueueItem) => void;
|
onOpenAttachment: (row: UpliftQueueItem) => void;
|
||||||
isDecisionPending: boolean;
|
isDecisionPending: boolean;
|
||||||
}) {
|
}) {
|
||||||
const headerCells = UPLIFT_APPROVAL_HEADER_CELLS[tab];
|
const headerCells = tab === "pending" ? PENDING_HEADER_CELLS : APPROVED_HEADER_CELLS;
|
||||||
return (
|
return (
|
||||||
<TableContainer component={Paper} variant="outlined">
|
<TableContainer component={Paper} variant="outlined">
|
||||||
<Table size="small" aria-label={`${tab} uplift requests`}>
|
<Table size="small" aria-label={`${tab} uplift requests`}>
|
||||||
|
|
@ -362,7 +336,22 @@ export function UpliftApprovalsTable({
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<UpliftApprovalsEmptyRow tab={tab} colSpan={headerCells.length} hasError={hasError} />
|
<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>
|
||||||
) : (
|
) : (
|
||||||
rows.map((row) => (
|
rows.map((row) => (
|
||||||
<UpliftApprovalRow
|
<UpliftApprovalRow
|
||||||
|
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
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,11 +1,9 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Alert, Box, Chip, Stack, Tab, Tabs, TablePagination } from "@mui/material";
|
import { Alert, Box, Chip, Stack, Tab, Tabs, TablePagination } from "@mui/material";
|
||||||
import {
|
import {
|
||||||
UPLIFT_APPROVAL_TABS,
|
UpliftApprovalsTable,
|
||||||
isUpliftApprovalTab,
|
|
||||||
type UpliftApprovalTab,
|
type UpliftApprovalTab,
|
||||||
} from "@/app/(protected)/uplifts/_components/uplift-approval-tabs";
|
} from "@/app/(protected)/uplifts/_components/uplift-approvals-table";
|
||||||
import { UpliftApprovalsTable } from "@/app/(protected)/uplifts/_components/uplift-approvals-table";
|
|
||||||
import { UpliftDecisionDialogs } from "@/app/(protected)/uplifts/_components/uplift-decision-dialogs";
|
import { UpliftDecisionDialogs } from "@/app/(protected)/uplifts/_components/uplift-decision-dialogs";
|
||||||
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
|
import { UpliftDetailModal } from "@/app/(protected)/uplifts/_components/uplift-detail-modal";
|
||||||
import {
|
import {
|
||||||
|
|
@ -18,7 +16,6 @@ import { useAuthContext } from "@/providers/auth-context";
|
||||||
import { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller";
|
import { useUpliftApprovalController } from "@/domain/uplifts/use-cases/use-uplift-approval-controller";
|
||||||
import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
|
import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import type { UpliftsQueueResult } from "@/domain/uplifts/types/uplift";
|
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
|
@ -42,71 +39,58 @@ function UpliftApprovalsHeader({ pendingExposureTotal }: { pendingExposureTotal:
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAB_LABELS: Record<UpliftApprovalTab, string> = {
|
|
||||||
pending: "Pending",
|
|
||||||
approved: "Approved",
|
|
||||||
rejected: "Rejected",
|
|
||||||
};
|
|
||||||
|
|
||||||
// A tab's total is only known once its queue has loaded at least once; until then the
|
|
||||||
// pill and pager must not claim a count (a loading or failed queue is not an empty one).
|
|
||||||
// A failed refetch keeps the last loaded page, so its total stays usable.
|
|
||||||
function knownTotal(query: { data?: UpliftsQueueResult | undefined }): number | null {
|
|
||||||
return query.data ? query.data.totalCount : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TabCountPill({ tab, count }: { tab: UpliftApprovalTab; count: number | null }) {
|
|
||||||
return (
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
label={count ?? "—"}
|
|
||||||
aria-label={count == null ? `${tab} uplift count unavailable` : `${count} ${tab} uplifts`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function UpliftApprovalsTabs({
|
function UpliftApprovalsTabs({
|
||||||
tab,
|
tab,
|
||||||
counts,
|
pendingCount,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
tab: UpliftApprovalTab;
|
tab: UpliftApprovalTab;
|
||||||
counts: Record<UpliftApprovalTab, number | null>;
|
pendingCount: number;
|
||||||
onChange: (_event: unknown, value: string) => void;
|
onChange: (_event: unknown, value: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Tabs value={tab} onChange={onChange}>
|
<Tabs value={tab} onChange={onChange}>
|
||||||
{UPLIFT_APPROVAL_TABS.map((value) => (
|
<Tab
|
||||||
<Tab
|
value="pending"
|
||||||
key={value}
|
label={
|
||||||
value={value}
|
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||||
label={
|
<span>Pending</span>
|
||||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
<Chip
|
||||||
<span>{TAB_LABELS[value]}</span>
|
size="small"
|
||||||
<TabCountPill tab={value} count={counts[value]} />
|
label={pendingCount}
|
||||||
</Stack>
|
aria-label={`${pendingCount} pending uplifts`}
|
||||||
}
|
/>
|
||||||
/>
|
</Stack>
|
||||||
))}
|
}
|
||||||
|
/>
|
||||||
|
<Tab value="approved" label="Approved" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function UpliftApprovalsPagination({
|
function UpliftApprovalsPagination({
|
||||||
|
tab,
|
||||||
count,
|
count,
|
||||||
page,
|
pendingPage,
|
||||||
onPageChange,
|
approvedPage,
|
||||||
|
onPendingPageChange,
|
||||||
|
onApprovedPageChange,
|
||||||
}: {
|
}: {
|
||||||
|
tab: UpliftApprovalTab;
|
||||||
count: number;
|
count: number;
|
||||||
page: number;
|
pendingPage: number;
|
||||||
onPageChange: (page: number) => void;
|
approvedPage: number;
|
||||||
|
onPendingPageChange: (page: number) => void;
|
||||||
|
onApprovedPageChange: (page: number) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<TablePagination
|
<TablePagination
|
||||||
component="div"
|
component="div"
|
||||||
count={count}
|
count={count}
|
||||||
page={page}
|
page={tab === "pending" ? pendingPage : approvedPage}
|
||||||
onPageChange={(_event, nextPage) => onPageChange(nextPage)}
|
onPageChange={(_event, nextPage) =>
|
||||||
|
tab === "pending" ? onPendingPageChange(nextPage) : onApprovedPageChange(nextPage)
|
||||||
|
}
|
||||||
rowsPerPage={PAGE_SIZE}
|
rowsPerPage={PAGE_SIZE}
|
||||||
rowsPerPageOptions={[PAGE_SIZE]}
|
rowsPerPageOptions={[PAGE_SIZE]}
|
||||||
/>
|
/>
|
||||||
|
|
@ -124,31 +108,22 @@ function UpliftQueueError({ error }: { error: unknown }) {
|
||||||
|
|
||||||
export default function UpliftQueuePage() {
|
export default function UpliftQueuePage() {
|
||||||
const [tab, setTab] = useState<UpliftApprovalTab>("pending");
|
const [tab, setTab] = useState<UpliftApprovalTab>("pending");
|
||||||
const [pages, setPages] = useState<Record<UpliftApprovalTab, number>>({
|
const [pendingPage, setPendingPage] = useState(0);
|
||||||
pending: 0,
|
const [approvedPage, setApprovedPage] = useState(0);
|
||||||
approved: 0,
|
|
||||||
rejected: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { user } = useAuthContext();
|
const { user } = useAuthContext();
|
||||||
const canRevokeUplifts = isAdminUser(user?.userRoles);
|
const canRevokeUplifts = isAdminUser(user?.userRoles);
|
||||||
|
|
||||||
const pendingQuery = useUpliftsQueue({
|
const pendingQuery = useUpliftsQueue({
|
||||||
status: "Pending",
|
status: "Pending",
|
||||||
page: pages.pending + 1,
|
page: pendingPage + 1,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
});
|
});
|
||||||
const approvedQuery = useUpliftsQueue({
|
const approvedQuery = useUpliftsQueue({
|
||||||
status: "Approved",
|
status: "Approved",
|
||||||
page: pages.approved + 1,
|
page: approvedPage + 1,
|
||||||
pageSize: PAGE_SIZE,
|
pageSize: PAGE_SIZE,
|
||||||
});
|
});
|
||||||
const rejectedQuery = useUpliftsQueue({
|
|
||||||
status: "Rejected",
|
|
||||||
page: pages.rejected + 1,
|
|
||||||
pageSize: PAGE_SIZE,
|
|
||||||
});
|
|
||||||
const queries = { pending: pendingQuery, approved: approvedQuery, rejected: rejectedQuery };
|
|
||||||
|
|
||||||
const controller = useUpliftApprovalController();
|
const controller = useUpliftApprovalController();
|
||||||
const {
|
const {
|
||||||
|
|
@ -163,18 +138,13 @@ export default function UpliftQueuePage() {
|
||||||
openDetail,
|
openDetail,
|
||||||
} = controller;
|
} = controller;
|
||||||
|
|
||||||
const activeQuery = queries[tab];
|
const activeQuery = tab === "pending" ? pendingQuery : approvedQuery;
|
||||||
const rows = activeQuery.data?.items ?? [];
|
const rows = activeQuery.data?.items ?? [];
|
||||||
const counts = {
|
const pendingCount = pendingQuery.data?.totalCount ?? 0;
|
||||||
pending: knownTotal(pendingQuery),
|
|
||||||
approved: knownTotal(approvedQuery),
|
|
||||||
rejected: knownTotal(rejectedQuery),
|
|
||||||
};
|
|
||||||
const activeTotal = counts[tab];
|
|
||||||
const pendingExposureTotal = pendingQuery.data?.pendingExposureTotal ?? null;
|
const pendingExposureTotal = pendingQuery.data?.pendingExposureTotal ?? null;
|
||||||
|
|
||||||
const handleTabChange = (_event: unknown, value: string) => {
|
const handleTabChange = (_event: unknown, value: string) => {
|
||||||
if (isUpliftApprovalTab(value)) {
|
if (value === "pending" || value === "approved") {
|
||||||
setTab(value);
|
setTab(value);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -182,12 +152,11 @@ export default function UpliftQueuePage() {
|
||||||
return (
|
return (
|
||||||
<Box className="flex flex-col gap-4 p-4">
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
<UpliftApprovalsHeader pendingExposureTotal={pendingExposureTotal} />
|
<UpliftApprovalsHeader pendingExposureTotal={pendingExposureTotal} />
|
||||||
<UpliftApprovalsTabs tab={tab} counts={counts} onChange={handleTabChange} />
|
<UpliftApprovalsTabs tab={tab} pendingCount={pendingCount} onChange={handleTabChange} />
|
||||||
<UpliftQueueError error={activeQuery.error} />
|
<UpliftQueueError error={activeQuery.error} />
|
||||||
<UpliftApprovalsTable
|
<UpliftApprovalsTable
|
||||||
tab={tab}
|
tab={tab}
|
||||||
isLoading={activeQuery.isLoading}
|
isLoading={activeQuery.isLoading}
|
||||||
hasError={Boolean(activeQuery.error)}
|
|
||||||
rows={rows}
|
rows={rows}
|
||||||
canRevoke={canRevokeUplifts}
|
canRevoke={canRevokeUplifts}
|
||||||
onOpenDetail={openDetail}
|
onOpenDetail={openDetail}
|
||||||
|
|
@ -197,13 +166,14 @@ export default function UpliftQueuePage() {
|
||||||
onOpenAttachment={openUpliftEvidence}
|
onOpenAttachment={openUpliftEvidence}
|
||||||
isDecisionPending={approvePending || rejectPending || revokePending}
|
isDecisionPending={approvePending || rejectPending || revokePending}
|
||||||
/>
|
/>
|
||||||
{activeTotal != null && (
|
<UpliftApprovalsPagination
|
||||||
<UpliftApprovalsPagination
|
tab={tab}
|
||||||
count={activeTotal}
|
count={activeQuery.data?.totalCount ?? 0}
|
||||||
page={pages[tab]}
|
pendingPage={pendingPage}
|
||||||
onPageChange={(page) => setPages((current) => ({ ...current, [tab]: page }))}
|
approvedPage={approvedPage}
|
||||||
/>
|
onPendingPageChange={setPendingPage}
|
||||||
)}
|
onApprovedPageChange={setApprovedPage}
|
||||||
|
/>
|
||||||
{detailItem != null && (
|
{detailItem != null && (
|
||||||
<UpliftDetailModal
|
<UpliftDetailModal
|
||||||
item={detailItem}
|
item={detailItem}
|
||||||
|
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
type CompDocDocumentFrameProps = {
|
|
||||||
children: ReactNode;
|
|
||||||
/** Greyed-out page, as for a document marked not required. */
|
|
||||||
dimmed?: boolean;
|
|
||||||
/** Rendered over the page, outside the dimmed layer. */
|
|
||||||
overlay?: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
/** The printed page: white sheet between the top and bottom brand bands. */
|
|
||||||
export function CompDocDocumentFrame({
|
|
||||||
children,
|
|
||||||
dimmed = false,
|
|
||||||
overlay,
|
|
||||||
}: CompDocDocumentFrameProps) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="relative break-words"
|
|
||||||
style={{
|
|
||||||
background: "#ffffff",
|
|
||||||
borderRadius: 8,
|
|
||||||
boxShadow: "0 1px 3px rgba(0,0,0,.08), 0 12px 32px -16px rgba(0,0,0,.25)",
|
|
||||||
overflow: "hidden",
|
|
||||||
fontFamily: "var(--font-sans)",
|
|
||||||
overflowWrap: "break-word",
|
|
||||||
wordBreak: "break-word",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
opacity: dimmed ? 0.5 : 1,
|
|
||||||
filter: dimmed ? "grayscale(0.4)" : "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: 12,
|
|
||||||
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div style={{ padding: "0 28px 28px" }}>{children}</div>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: 24,
|
|
||||||
background: "linear-gradient(100deg,#1C75BC,#27AAE1)",
|
|
||||||
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{overlay}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import { procedureFor } from "@/domain/work-orders/utils/completion-doc-procedures";
|
import { procedureFor, SAFETY_BULLETS } from "@/domain/work-orders/utils/completion-doc-procedures";
|
||||||
import { CompDocProcedureHeading } from "./comp-doc-procedure-heading";
|
|
||||||
import { CompDocProcedureList } from "./comp-doc-procedure-list";
|
import { CompDocProcedureList } from "./comp-doc-procedure-list";
|
||||||
import { CompDocSafetyBullets } from "./comp-doc-safety-bullets";
|
|
||||||
|
|
||||||
type CompDocPreviewProcedureProps = {
|
type CompDocPreviewProcedureProps = {
|
||||||
row: WorkOrderTableRow;
|
row: WorkOrderTableRow;
|
||||||
|
|
@ -15,9 +13,34 @@ export function CompDocPreviewProcedure({ row, notes }: CompDocPreviewProcedureP
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CompDocSafetyBullets />
|
<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>
|
||||||
|
|
||||||
<CompDocProcedureHeading>{proc.heading}</CompDocProcedureHeading>
|
<Text
|
||||||
|
as="h3"
|
||||||
|
variant="heading"
|
||||||
|
sx={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#161E38",
|
||||||
|
margin: "16px 0 4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{proc.heading}
|
||||||
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
variant="body"
|
variant="body"
|
||||||
sx={{
|
sx={{
|
||||||
|
|
@ -33,7 +56,18 @@ export function CompDocPreviewProcedure({ row, notes }: CompDocPreviewProcedureP
|
||||||
|
|
||||||
{notes.length > 0 && (
|
{notes.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<CompDocProcedureHeading>PM Comments</CompDocProcedureHeading>
|
<Text
|
||||||
|
as="h3"
|
||||||
|
variant="heading"
|
||||||
|
sx={{
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: "#161E38",
|
||||||
|
margin: "16px 0 4px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
PM Comments
|
||||||
|
</Text>
|
||||||
<Text
|
<Text
|
||||||
variant="body"
|
variant="body"
|
||||||
sx={{
|
sx={{
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
|
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
|
||||||
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
import { CompDocDocumentFrame } from "./comp-doc-document-frame";
|
|
||||||
import { CompDocNotRequiredStamp } from "./comp-doc-not-required-stamp";
|
import { CompDocNotRequiredStamp } from "./comp-doc-not-required-stamp";
|
||||||
import { CompDocPreviewBadge } from "./comp-doc-preview-badge";
|
import { CompDocPreviewBadge } from "./comp-doc-preview-badge";
|
||||||
import { CompDocPreviewHeader } from "./comp-doc-preview-header";
|
import { CompDocPreviewHeader } from "./comp-doc-preview-header";
|
||||||
|
|
@ -39,19 +38,51 @@ export function CompDocPreview({
|
||||||
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
|
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
|
||||||
style={{ padding: "0 20px 20px" }}
|
style={{ padding: "0 20px 20px" }}
|
||||||
>
|
>
|
||||||
<CompDocDocumentFrame
|
<div
|
||||||
dimmed={isNotRequired}
|
className="relative break-words"
|
||||||
overlay={isNotRequired && <CompDocNotRequiredStamp />}
|
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",
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<CompDocPreviewHeader />
|
<div
|
||||||
<CompDocPreviewSummary
|
style={{
|
||||||
row={row}
|
opacity: isNotRequired ? 0.5 : 1,
|
||||||
site={site}
|
filter: isNotRequired ? "grayscale(0.4)" : "none",
|
||||||
generatedAt={generatedAt}
|
}}
|
||||||
isGenerated={isGenerated}
|
>
|
||||||
/>
|
<div
|
||||||
<CompDocPreviewProcedure row={row} notes={notes} />
|
style={{
|
||||||
</CompDocDocumentFrame>
|
height: 12,
|
||||||
|
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ padding: "0 28px 28px" }}>
|
||||||
|
<CompDocPreviewHeader />
|
||||||
|
<CompDocPreviewSummary
|
||||||
|
row={row}
|
||||||
|
site={site}
|
||||||
|
generatedAt={generatedAt}
|
||||||
|
isGenerated={isGenerated}
|
||||||
|
/>
|
||||||
|
<CompDocPreviewProcedure row={row} notes={notes} />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: 24,
|
||||||
|
background: "linear-gradient(100deg,#1C75BC,#27AAE1)",
|
||||||
|
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isNotRequired && <CompDocNotRequiredStamp />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -109,11 +109,7 @@ export function AdvancedSearchFilterSections({
|
||||||
/>
|
/>
|
||||||
</AdvSection>
|
</AdvSection>
|
||||||
|
|
||||||
<AdvSection
|
<AdvSection icon={CheckCircle2} label="Status" count={draft.statuses.size}>
|
||||||
icon={CheckCircle2}
|
|
||||||
label="Status"
|
|
||||||
count={draft.statuses.size + (draft.pastDue ? 1 : 0)}
|
|
||||||
>
|
|
||||||
{selectAllRow("statuses", [...ALL_WIZARD_STATUSES], draft.statuses.size)}
|
{selectAllRow("statuses", [...ALL_WIZARD_STATUSES], draft.statuses.size)}
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{ALL_WIZARD_STATUSES.map((status) => (
|
{ALL_WIZARD_STATUSES.map((status) => (
|
||||||
|
|
@ -126,14 +122,6 @@ export function AdvancedSearchFilterSections({
|
||||||
onClick={() => toggleIn("statuses", status)}
|
onClick={() => toggleIn("statuses", status)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<AdvStatusPill
|
|
||||||
label="Past Due"
|
|
||||||
dotColor="var(--status-pastdue-text)"
|
|
||||||
selected={draft.pastDue}
|
|
||||||
selectedBg="var(--status-pastdue-bg)"
|
|
||||||
selectedText="var(--status-pastdue-text)"
|
|
||||||
onClick={() => setDraft((prev) => ({ ...prev, pastDue: !prev.pastDue }))}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</AdvSection>
|
</AdvSection>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,82 +0,0 @@
|
||||||
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,11 +1,15 @@
|
||||||
import { useEffect, useMemo } from "react";
|
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 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 { 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 { resolveLocationId } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
|
||||||
import { useSiteRecordSync } from "@/app/(protected)/workorders/_components/list/table/cells/use-site-record-sync";
|
import {
|
||||||
|
useSiteRecordSync,
|
||||||
|
workOrderPocAfterSiteSave,
|
||||||
|
} 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 { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
import { useLocationDetail } from "@/domain/locations/use-cases/use-location-detail";
|
||||||
import { formatLocationAddressPreview } from "@/domain/locations/mappers/location-mapper";
|
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 { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
|
||||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||||
import type { WorkOrderFrozenSite } from "@/domain/work-orders/types/work-order-table-row";
|
import type { WorkOrderFrozenSite } from "@/domain/work-orders/types/work-order-table-row";
|
||||||
|
|
@ -28,20 +32,6 @@ type UseSiteDialogStateArgs = {
|
||||||
onSave: (patch: SitePatch) => void;
|
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({
|
export function useSiteDialogState({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
|
@ -68,51 +58,96 @@ export function useSiteDialogState({
|
||||||
sites,
|
sites,
|
||||||
createMode,
|
createMode,
|
||||||
});
|
});
|
||||||
const { locId, pocFilledFor, setPn, setPp, setPocFilledFor } = fields;
|
const { locId, pocFilledFor, pn, pp, setPn, setPp, setPocFilledFor, siteMissing } = fields;
|
||||||
const live = frozenSite == null;
|
const {
|
||||||
const liveDetail = useLocationDetail(live && open && locId ? locId : undefined);
|
data: liveLocationDetail,
|
||||||
|
isLoading: liveLocationDetailLoading,
|
||||||
|
isError: liveLocationDetailError,
|
||||||
|
} = useLocationDetail(frozenSite == null && open && locId ? locId : undefined);
|
||||||
const frozenLocationDetail = useMemo(
|
const frozenLocationDetail = useMemo(
|
||||||
() => (frozenSite == null ? undefined : frozenSiteDetail(frozenSite)),
|
() =>
|
||||||
|
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],
|
[frozenSite],
|
||||||
);
|
);
|
||||||
const locationDetail = frozenLocationDetail ?? liveDetail.data;
|
const locationDetail = frozenLocationDetail ?? liveLocationDetail;
|
||||||
const locationDetailLoading = live && liveDetail.isLoading;
|
const locationDetailLoading = frozenSite == null && liveLocationDetailLoading;
|
||||||
const locationDetailError = live && liveDetail.isError;
|
const locationDetailError = frozenSite == null && liveLocationDetailError;
|
||||||
|
|
||||||
const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
|
const addressPreview = locationDetail ? formatLocationAddressPreview(locationDetail) : "";
|
||||||
// An existing, editable work order edits the site record itself (contacts and notes).
|
// An existing, editable work order edits the site record itself (contacts and notes).
|
||||||
const editsSiteRecord = !createMode && !viewOnly && live;
|
const editsSiteRecord = !createMode && !viewOnly && frozenSite == null;
|
||||||
const siteRecord = useSiteRecordSync({
|
const siteRecord = useSiteRecordSync({
|
||||||
enabled: editsSiteRecord,
|
enabled: editsSiteRecord,
|
||||||
open,
|
open,
|
||||||
locId,
|
locId,
|
||||||
locationDetail: liveDetail.data,
|
locationDetail: liveLocationDetail,
|
||||||
locationDetailFetching: liveDetail.isFetching,
|
locationDetailError: liveLocationDetailError,
|
||||||
locationDetailError: liveDetail.isError,
|
|
||||||
fields,
|
fields,
|
||||||
});
|
});
|
||||||
// The site failed to load (or the user typed before it did), so Save writes this work order only.
|
// The site failed to load (or the user typed before it did), so Save writes this work order only.
|
||||||
const siteRecordUnavailable =
|
const siteRecordUnavailable =
|
||||||
editsSiteRecord && Boolean(locId) && !siteRecord.synced && !siteRecord.loading;
|
editsSiteRecord && Boolean(locId) && !siteRecord.synced && !siteRecord.loading;
|
||||||
|
const updateSite = useUpdateSiteContactInfo();
|
||||||
|
const pocMissing = !pn.trim() || !pp.trim();
|
||||||
const requiresPoc = createMode || editsSiteRecord;
|
const requiresPoc = createMode || editsSiteRecord;
|
||||||
const save = useSiteDialogSave({
|
const canConfirm = !siteMissing && (!requiresPoc || !pocMissing);
|
||||||
fields,
|
|
||||||
siteRecord,
|
|
||||||
original: { pocName, pocPhone, pocNotes, locationId, value, sites },
|
|
||||||
createMode,
|
|
||||||
requiresPoc,
|
|
||||||
onSave,
|
|
||||||
onOpenChange,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!live || !open || !locId || !locationDetail || pocFilledFor === locId) {
|
if (frozenSite != null || !open || !locId || !locationDetail || pocFilledFor === locId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setPn((prev) => (prev.trim() ? prev : (locationDetail.contact ?? "")));
|
setPn((prev) => (prev.trim() ? prev : (locationDetail.contact ?? "")));
|
||||||
setPp((prev) => (prev.trim() ? prev : (locationDetail.phone ?? "")));
|
setPp((prev) => (prev.trim() ? prev : (locationDetail.phone ?? "")));
|
||||||
setPocFilledFor(locId);
|
setPocFilledFor(locId);
|
||||||
}, [open, locId, locationDetail, pocFilledFor, setPn, setPp, setPocFilledFor, live]);
|
}, [open, locId, locationDetail, pocFilledFor, setPn, setPp, setPocFilledFor, frozenSite]);
|
||||||
|
|
||||||
|
const saveWorkOrder = () => {
|
||||||
|
const poc = workOrderPocAfterSiteSave(
|
||||||
|
siteRecord,
|
||||||
|
{ pocName, pocPhone, pocNotes },
|
||||||
|
{ pocName: fields.pn, pocPhone: fields.pp, pocNotes: fields.notes },
|
||||||
|
fields.locId !== resolveLocationId(locationId, value, 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: locId, ...siteRecord.request }, { onSuccess: saveWorkOrder });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save is actionable only once something changed; inline create keeps its confirm step.
|
||||||
|
const saveDisabled = updateSite.isPending || siteRecord.loading || (!createMode && !fields.dirty);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
locId: fields.locId,
|
locId: fields.locId,
|
||||||
|
|
@ -129,9 +164,9 @@ export function useSiteDialogState({
|
||||||
showErrors: fields.showErrors,
|
showErrors: fields.showErrors,
|
||||||
siteMissing: fields.siteMissing,
|
siteMissing: fields.siteMissing,
|
||||||
handlePick: fields.handlePick,
|
handlePick: fields.handlePick,
|
||||||
attemptSave: save.attemptSave,
|
attemptSave,
|
||||||
saveDisabled: save.saveDisabled,
|
saveDisabled,
|
||||||
saving: save.saving,
|
saving: updateSite.isPending,
|
||||||
siteRecordLoading: siteRecord.loading,
|
siteRecordLoading: siteRecord.loading,
|
||||||
editsSiteRecord,
|
editsSiteRecord,
|
||||||
siteRecordUnavailable,
|
siteRecordUnavailable,
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,6 @@ type UseSiteRecordSyncArgs = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
locId: string;
|
locId: string;
|
||||||
locationDetail: Location | undefined;
|
locationDetail: Location | undefined;
|
||||||
/** A request for the site is in flight; cached data may predate the latest save. */
|
|
||||||
locationDetailFetching: boolean;
|
|
||||||
locationDetailError: boolean;
|
locationDetailError: boolean;
|
||||||
fields: SiteRecordFields;
|
fields: SiteRecordFields;
|
||||||
};
|
};
|
||||||
|
|
@ -32,10 +30,6 @@ export type WorkOrderPoc = { pocName: string; pocPhone: string; pocNotes: string
|
||||||
|
|
||||||
type SiteRecordEdits = { primary: boolean; notes: boolean };
|
type SiteRecordEdits = { primary: boolean; notes: boolean };
|
||||||
|
|
||||||
function isCompleteContact(contact: WorkOrderAdditionalContact): boolean {
|
|
||||||
return contact.name.trim() !== "" && contact.phone.trim() !== "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function toRequest(
|
function toRequest(
|
||||||
primaryId: number | undefined,
|
primaryId: number | undefined,
|
||||||
fields: Pick<SiteRecordFields, "pn" | "pp" | "notes" | "extraContacts">,
|
fields: Pick<SiteRecordFields, "pn" | "pp" | "notes" | "extraContacts">,
|
||||||
|
|
@ -43,8 +37,7 @@ function toRequest(
|
||||||
return {
|
return {
|
||||||
contacts: [
|
contacts: [
|
||||||
{ ...(primaryId === undefined ? {} : { id: primaryId }), name: fields.pn, phone: fields.pp },
|
{ ...(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.map((contact) => ({
|
||||||
...fields.extraContacts.filter(isCompleteContact).map((contact) => ({
|
|
||||||
...(contact.siteContactId === undefined ? {} : { id: contact.siteContactId }),
|
...(contact.siteContactId === undefined ? {} : { id: contact.siteContactId }),
|
||||||
name: contact.name,
|
name: contact.name,
|
||||||
phone: contact.phone,
|
phone: contact.phone,
|
||||||
|
|
@ -99,22 +92,6 @@ export function workOrderPocAfterSiteSave(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The site record's contacts and notes, in the dialog's field shape. */
|
|
||||||
function siteRecordValues(location: Location) {
|
|
||||||
const [main, ...others] = location.contacts ?? [];
|
|
||||||
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
|
* 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.
|
* changed, so Save can write them back to the site record.
|
||||||
|
|
@ -124,7 +101,6 @@ export function useSiteRecordSync({
|
||||||
open,
|
open,
|
||||||
locId,
|
locId,
|
||||||
locationDetail,
|
locationDetail,
|
||||||
locationDetailFetching,
|
|
||||||
locationDetailError,
|
locationDetailError,
|
||||||
fields,
|
fields,
|
||||||
}: UseSiteRecordSyncArgs) {
|
}: UseSiteRecordSyncArgs) {
|
||||||
|
|
@ -133,34 +109,43 @@ export function useSiteRecordSync({
|
||||||
const [baseline, setBaseline] = useState<SiteContactInfoInput | null>(null);
|
const [baseline, setBaseline] = useState<SiteContactInfoInput | null>(null);
|
||||||
const { userEdited, resetVersion, setPn, setPp, setNotes, setExtraContacts } = fields;
|
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(() => {
|
useEffect(() => {
|
||||||
setSyncedFor("");
|
setSyncedFor("");
|
||||||
}, [open, resetVersion, locId]);
|
}, [open, resetVersion]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const detailMatches = locationDetail !== undefined && String(locationDetail.id) === locId;
|
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
|
// 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.
|
// 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) {
|
if (!enabled || !open || !locId || !detailMatches || syncedFor === locId || userEdited) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const site = siteRecordValues(locationDetail);
|
const [main, ...others] = locationDetail.contacts ?? [];
|
||||||
setPn(site.pn);
|
const extras = others.map((contact) => ({
|
||||||
setPp(site.pp);
|
name: contact.name,
|
||||||
setExtraContacts(site.extraContacts);
|
phone: contact.phone,
|
||||||
setNotes(site.notes);
|
...(contact.id === undefined ? {} : { siteContactId: contact.id }),
|
||||||
setPrimaryId(site.primaryId);
|
}));
|
||||||
setBaseline(toRequest(site.primaryId, site));
|
const notes = locationDetail.notes ?? "";
|
||||||
|
setPn(main?.name ?? "");
|
||||||
|
setPp(main?.phone ?? "");
|
||||||
|
setExtraContacts(extras);
|
||||||
|
setNotes(notes);
|
||||||
|
setPrimaryId(main?.id);
|
||||||
|
setBaseline(
|
||||||
|
toRequest(main?.id, {
|
||||||
|
pn: main?.name ?? "",
|
||||||
|
pp: main?.phone ?? "",
|
||||||
|
notes,
|
||||||
|
extraContacts: extras,
|
||||||
|
}),
|
||||||
|
);
|
||||||
setSyncedFor(locId);
|
setSyncedFor(locId);
|
||||||
}, [
|
}, [
|
||||||
enabled,
|
enabled,
|
||||||
open,
|
open,
|
||||||
locId,
|
locId,
|
||||||
locationDetail,
|
locationDetail,
|
||||||
locationDetailFetching,
|
|
||||||
syncedFor,
|
syncedFor,
|
||||||
userEdited,
|
userEdited,
|
||||||
setPn,
|
setPn,
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
import type { WorkOrderBoardQueryParams } from "@/domain/work-orders/types/work-order-board";
|
import type { WorkOrderBoardQueryParams } from "@/domain/work-orders/types/work-order-board";
|
||||||
import {
|
import {
|
||||||
clearAdvancedFilterGroup,
|
clearAdvancedFilterGroup,
|
||||||
|
cloneAdvancedFilters,
|
||||||
countAdvancedFilters,
|
countAdvancedFilters,
|
||||||
getInitialAdvancedFilters,
|
getInitialAdvancedFilters,
|
||||||
resolveAppliedAdvancedFilters,
|
|
||||||
} from "@/domain/work-orders/utils/advanced-filters-utils";
|
} from "@/domain/work-orders/utils/advanced-filters-utils";
|
||||||
import { toBoardQueryParams } from "@/domain/work-orders/utils/board-query-params";
|
import { toBoardQueryParams } from "@/domain/work-orders/utils/board-query-params";
|
||||||
import { asDayPageSize, DEFAULT_DAY_PAGE_SIZE } from "@/domain/work-orders/utils/day-pagination";
|
import { asDayPageSize, DEFAULT_DAY_PAGE_SIZE } from "@/domain/work-orders/utils/day-pagination";
|
||||||
|
|
@ -90,7 +90,7 @@ export function useWorkOrdersListFilters() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyAdvancedFilters = (filters: WorkOrderAdvancedFilters) => {
|
const applyAdvancedFilters = (filters: WorkOrderAdvancedFilters) => {
|
||||||
setAdvApplied(resolveAppliedAdvancedFilters(filters));
|
setAdvApplied(cloneAdvancedFilters(filters));
|
||||||
setPage(0);
|
setPage(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
import { useState } from "react";
|
|
||||||
import { Circle, CircleCheck } from "lucide-react";
|
|
||||||
|
|
||||||
import { Text } from "@/components/ui/text";
|
|
||||||
import { evaluatePasswordRules, type PasswordRuleResult } from "@/domain/auth/password-policy";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export type PasswordChecklistProps = {
|
|
||||||
password: string;
|
|
||||||
id?: string;
|
|
||||||
className?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function describeChanges(previous: PasswordRuleResult[], next: PasswordRuleResult[]): string {
|
|
||||||
return next
|
|
||||||
.filter((rule, index) => rule.met !== previous[index]?.met)
|
|
||||||
.map((rule) => `${rule.label}: ${rule.met ? "requirement met" : "requirement not met"}`)
|
|
||||||
.join(". ");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Live, per-rule feedback for the shared password policy. A polite status region
|
|
||||||
* announces only the rules whose state changed, so screen-reader users hear
|
|
||||||
* progress while typing without the whole list being re-read on each keystroke.
|
|
||||||
*/
|
|
||||||
export function PasswordChecklist({ password, id, className }: PasswordChecklistProps) {
|
|
||||||
const results = evaluatePasswordRules(password);
|
|
||||||
const signature = results.map((rule) => (rule.met ? "1" : "0")).join("");
|
|
||||||
const [previous, setPrevious] = useState({ signature, results });
|
|
||||||
const [announcement, setAnnouncement] = useState("");
|
|
||||||
|
|
||||||
if (previous.signature !== signature) {
|
|
||||||
setPrevious({ signature, results });
|
|
||||||
setAnnouncement(describeChanges(previous.results, results));
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ul
|
|
||||||
id={id}
|
|
||||||
aria-label="Password requirements"
|
|
||||||
className={cn("m-0 mt-2 grid list-none gap-1 p-0", className)}
|
|
||||||
>
|
|
||||||
{results.map(({ id: ruleId, label, met }) => (
|
|
||||||
<li key={ruleId} data-rule={ruleId} data-met={met} className="flex items-center gap-1.5">
|
|
||||||
{met ? (
|
|
||||||
<CircleCheck size={13} aria-hidden="true" className="text-(--color-success)" />
|
|
||||||
) : (
|
|
||||||
<Circle size={13} aria-hidden="true" className="text-(--color-text-muted)" />
|
|
||||||
)}
|
|
||||||
<Text variant="caption" tone={met ? "success" : "muted"}>
|
|
||||||
{label}
|
|
||||||
</Text>
|
|
||||||
<span className="sr-only">{met ? "requirement met" : "requirement not met"}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<Text variant="feedback" className="sr-only">
|
|
||||||
{announcement}
|
|
||||||
</Text>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -13,11 +13,6 @@ type AddressAutocompleteFieldProps = {
|
||||||
label?: string;
|
label?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
/**
|
|
||||||
* Show MUI's required asterisk on the label. Sites marks required fields that
|
|
||||||
* way; Vendors spells "(required)" in the label and asserts no asterisk.
|
|
||||||
*/
|
|
||||||
requiredMarker?: boolean;
|
|
||||||
error?: boolean;
|
error?: boolean;
|
||||||
helperText?: string;
|
helperText?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
|
@ -40,7 +35,6 @@ export function AddressAutocompleteField({
|
||||||
label = "Street Address",
|
label = "Street Address",
|
||||||
placeholder = "Start typing the street address…",
|
placeholder = "Start typing the street address…",
|
||||||
required = false,
|
required = false,
|
||||||
requiredMarker = false,
|
|
||||||
error = false,
|
error = false,
|
||||||
helperText,
|
helperText,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
|
@ -80,9 +74,9 @@ export function AddressAutocompleteField({
|
||||||
error={error}
|
error={error}
|
||||||
helperText={helperText}
|
helperText={helperText}
|
||||||
fullWidth
|
fullWidth
|
||||||
required={requiredMarker}
|
// Required is set on the input itself, never via MUI's `required`
|
||||||
// Required is set on the input itself; MUI's `required` prop only
|
// prop: this codebase spells the requirement in the label text and
|
||||||
// adds the label asterisk, which Vendors must not render.
|
// asserts that no generated asterisk renders anywhere.
|
||||||
slotProps={{
|
slotProps={{
|
||||||
...params.slotProps,
|
...params.slotProps,
|
||||||
htmlInput: { ...params.slotProps?.htmlInput, required },
|
htmlInput: { ...params.slotProps?.htmlInput, required },
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,11 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Link, useLocation } from "react-router";
|
import { Link, useLocation } from "react-router";
|
||||||
import { Box, Drawer } from "@mui/material";
|
import { Box, Drawer } from "@mui/material";
|
||||||
import { CircleUserRound } from "lucide-react";
|
|
||||||
|
|
||||||
import { BrandLockup, BrandMark } from "@/components/common/brand-lockup";
|
import { BrandLockup, BrandMark } from "@/components/common/brand-lockup";
|
||||||
import { useNavGroups } from "@/components/layout/use-nav-groups";
|
import { useNavGroups } from "@/components/layout/use-nav-groups";
|
||||||
import { getActiveNavPath, getSidebarWidth, type NavItemDef } from "@/config/menu";
|
import { getActiveNavPath, getSidebarWidth, type NavItemDef } from "@/config/menu";
|
||||||
|
|
||||||
const ACCOUNT_PATH = "/account";
|
|
||||||
|
|
||||||
function getNavItemAppearance(isActive: boolean, isHover: boolean) {
|
function getNavItemAppearance(isActive: boolean, isHover: boolean) {
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -225,21 +222,6 @@ export function AppSidebar({ collapsed, mobile = false, open = true, onClose }:
|
||||||
</Box>
|
</Box>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box
|
|
||||||
component="nav"
|
|
||||||
aria-label="Account navigation"
|
|
||||||
sx={{ borderTop: "1px solid var(--color-sidebar-border)", p: "8px" }}
|
|
||||||
>
|
|
||||||
<NavItem
|
|
||||||
label="My Account"
|
|
||||||
icon={CircleUserRound}
|
|
||||||
path={ACCOUNT_PATH}
|
|
||||||
collapsed={collapsed}
|
|
||||||
active={pathname === ACCOUNT_PATH}
|
|
||||||
onNavigate={mobile ? onClose : undefined}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { isDismissable } from "@/domain/notifications/utils/visible-notification-sections";
|
|
||||||
import type { NotificationCenter } from "./use-notification-center";
|
import type { NotificationCenter } from "./use-notification-center";
|
||||||
|
|
||||||
type NotificationFeedActionsProps = {
|
type NotificationFeedActionsProps = {
|
||||||
|
|
@ -7,8 +6,7 @@ type NotificationFeedActionsProps = {
|
||||||
|
|
||||||
export function NotificationFeedActions({ center }: NotificationFeedActionsProps) {
|
export function NotificationFeedActions({ center }: NotificationFeedActionsProps) {
|
||||||
const hasUnread = center.unreadIds.size > 0;
|
const hasUnread = center.unreadIds.size > 0;
|
||||||
// Rows that need acknowledging are never cleared, so they alone do not enable "Clear all".
|
const hasItems = center.sections.length > 0;
|
||||||
const hasItems = center.sections.some((section) => section.items.some(isDismissable));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -79,11 +79,6 @@ export function NotificationFeedList({ center }: NotificationFeedListProps) {
|
||||||
unread={center.unreadIds.has(item.id)}
|
unread={center.unreadIds.has(item.id)}
|
||||||
onOpen={center.open}
|
onOpen={center.open}
|
||||||
onDismiss={center.dismiss}
|
onDismiss={center.dismiss}
|
||||||
onAcknowledge={center.acknowledge}
|
|
||||||
acknowledging={
|
|
||||||
item.target.workOrderId != null &&
|
|
||||||
item.target.workOrderId === center.acknowledgingWorkOrderId
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
||||||
|
|
@ -53,37 +53,8 @@ type NotificationRowProps = {
|
||||||
unread: boolean;
|
unread: boolean;
|
||||||
onOpen: (item: NotificationItem, url?: string) => void;
|
onOpen: (item: NotificationItem, url?: string) => void;
|
||||||
onDismiss: (item: NotificationItem) => void;
|
onDismiss: (item: NotificationItem) => void;
|
||||||
onAcknowledge?: (item: NotificationItem) => void;
|
|
||||||
acknowledging?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** A missed response deadline cannot be dismissed; acknowledging it is the only way out of the feed. */
|
|
||||||
function AcknowledgeButton({
|
|
||||||
item,
|
|
||||||
onAcknowledge,
|
|
||||||
acknowledging = false,
|
|
||||||
}: Pick<NotificationRowProps, "item" | "onAcknowledge" | "acknowledging">) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onAcknowledge?.(item)}
|
|
||||||
disabled={acknowledging}
|
|
||||||
title="Acknowledge"
|
|
||||||
aria-label="Acknowledge SLA breach"
|
|
||||||
className="shrink-0 cursor-pointer rounded-md border transition-opacity disabled:cursor-not-allowed disabled:opacity-50"
|
|
||||||
style={{
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 600,
|
|
||||||
padding: "2px 8px",
|
|
||||||
color: "var(--destructive)",
|
|
||||||
borderColor: "var(--destructive)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{acknowledging ? "Acknowledging…" : "Acknowledge"}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A vendor conflict spans several work orders, so each one stays reachable from the row. */
|
/** A vendor conflict spans several work orders, so each one stays reachable from the row. */
|
||||||
function WorkOrderLinks({ item, onOpen }: Pick<NotificationRowProps, "item" | "onOpen">) {
|
function WorkOrderLinks({ item, onOpen }: Pick<NotificationRowProps, "item" | "onOpen">) {
|
||||||
if (item.reason !== "vendor-conflict" || item.workOrders.length === 0) {
|
if (item.reason !== "vendor-conflict" || item.workOrders.length === 0) {
|
||||||
|
|
@ -113,14 +84,7 @@ function WorkOrderLinks({ item, onOpen }: Pick<NotificationRowProps, "item" | "o
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NotificationRow({
|
export function NotificationRow({ item, unread, onOpen, onDismiss }: NotificationRowProps) {
|
||||||
item,
|
|
||||||
unread,
|
|
||||||
onOpen,
|
|
||||||
onDismiss,
|
|
||||||
onAcknowledge,
|
|
||||||
acknowledging,
|
|
||||||
}: NotificationRowProps) {
|
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
className="group flex items-start gap-2.5 transition-colors hover:bg-[var(--color-bg-muted)]"
|
className="group flex items-start gap-2.5 transition-colors hover:bg-[var(--color-bg-muted)]"
|
||||||
|
|
@ -172,13 +136,6 @@ export function NotificationRow({
|
||||||
<X size={13} aria-hidden />
|
<X size={13} aria-hidden />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{item.rowType === "acknowledge" && (
|
|
||||||
<AcknowledgeButton
|
|
||||||
item={item}
|
|
||||||
onAcknowledge={onAcknowledge}
|
|
||||||
acknowledging={acknowledging}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,90 +0,0 @@
|
||||||
import { useEffect, useMemo } from "react";
|
|
||||||
import { AlertTriangle, ChevronRight, X } from "lucide-react";
|
|
||||||
import { useNavigate } from "react-router";
|
|
||||||
import { toast } from "react-toastify";
|
|
||||||
import type { SlaAtRiskWorkOrder } from "@/domain/notifications/types/notification-feed";
|
|
||||||
import { useNotificationSession } from "@/domain/notifications/session/notification-session-context";
|
|
||||||
import { useNotificationFeed } from "@/domain/notifications/use-cases/use-notification-feed";
|
|
||||||
import { slaBannerText, slaHalfwayToastText } from "@/domain/notifications/utils/sla-alert-copy";
|
|
||||||
import { buildWorkOrderIdsHref } from "@/domain/work-orders/utils/work-order-ids-filter";
|
|
||||||
|
|
||||||
const NO_WORK_ORDERS: SlaAtRiskWorkOrder[] = [];
|
|
||||||
|
|
||||||
/** Toasts each at-risk work order once per session, the first time the feed reports it. */
|
|
||||||
function useSlaHalfwayToasts(atRisk: SlaAtRiskWorkOrder[]) {
|
|
||||||
const { claimSlaToasts } = useNotificationSession();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const claimed = new Set(claimSlaToasts(atRisk.map((workOrder) => workOrder.id)));
|
|
||||||
atRisk
|
|
||||||
.filter((workOrder) => claimed.has(workOrder.id))
|
|
||||||
.forEach((workOrder) =>
|
|
||||||
toast.warn(slaHalfwayToastText(workOrder), { toastId: `sla-at-risk-${workOrder.id}` }),
|
|
||||||
);
|
|
||||||
}, [atRisk, claimSlaToasts]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One line at the top of every page while any of the user's Reactive/Emergency work orders is past
|
|
||||||
* half of its response window. Clicking it opens Work Orders on exactly those work orders; dismissing
|
|
||||||
* hides them for this session, and a work order that becomes at risk later brings the banner back.
|
|
||||||
*/
|
|
||||||
export function SlaAtRiskBanner() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { data } = useNotificationFeed();
|
|
||||||
const { slaBannerDismissedIds, dismissSlaBanner } = useNotificationSession();
|
|
||||||
const atRisk = data?.slaAtRisk ?? NO_WORK_ORDERS;
|
|
||||||
useSlaHalfwayToasts(atRisk);
|
|
||||||
|
|
||||||
const shown = useMemo(
|
|
||||||
() => atRisk.filter((workOrder) => !slaBannerDismissedIds.has(workOrder.id)),
|
|
||||||
[atRisk, slaBannerDismissedIds],
|
|
||||||
);
|
|
||||||
if (shown.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ids = shown.map((workOrder) => workOrder.id);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="region"
|
|
||||||
aria-label="Work orders at risk of missing their response deadline"
|
|
||||||
className="flex shrink-0 items-center"
|
|
||||||
style={{
|
|
||||||
gap: 10,
|
|
||||||
padding: "6px 16px",
|
|
||||||
background: "var(--destructive)",
|
|
||||||
color: "var(--destructive-foreground)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => navigate(buildWorkOrderIdsHref(ids))}
|
|
||||||
title="View in Work Orders"
|
|
||||||
className="flex min-w-0 flex-1 cursor-pointer items-center text-left"
|
|
||||||
style={{ color: "inherit" }}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="inline-flex min-w-0 items-center transition-colors hover:bg-white/20"
|
|
||||||
style={{ gap: 8, padding: "3px 8px", marginLeft: -8, borderRadius: 4 }}
|
|
||||||
>
|
|
||||||
<AlertTriangle size={14} className="shrink-0" aria-hidden />
|
|
||||||
<span className="truncate" style={{ fontSize: 12.5, fontWeight: 600 }}>
|
|
||||||
{slaBannerText(shown)}
|
|
||||||
</span>
|
|
||||||
<ChevronRight size={14} className="shrink-0" aria-hidden />
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => dismissSlaBanner(ids)}
|
|
||||||
title="Dismiss"
|
|
||||||
aria-label="Dismiss SLA alert"
|
|
||||||
className="flex shrink-0 cursor-pointer items-center justify-center rounded transition-colors hover:bg-white/20"
|
|
||||||
style={{ width: 22, height: 22, color: "inherit" }}
|
|
||||||
>
|
|
||||||
<X size={14} aria-hidden />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,6 @@ import { useMemo } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import type { NotificationItem } from "@/domain/notifications/types/notification-feed";
|
import type { NotificationItem } from "@/domain/notifications/types/notification-feed";
|
||||||
import { useNotificationSession } from "@/domain/notifications/session/notification-session-context";
|
import { useNotificationSession } from "@/domain/notifications/session/notification-session-context";
|
||||||
import { useAcknowledgeSlaBreach } from "@/domain/notifications/use-cases/use-acknowledge-sla-breach";
|
|
||||||
import { useNotificationFeed } from "@/domain/notifications/use-cases/use-notification-feed";
|
import { useNotificationFeed } from "@/domain/notifications/use-cases/use-notification-feed";
|
||||||
import { notificationTargetUrl } from "@/domain/notifications/utils/notification-target-url";
|
import { notificationTargetUrl } from "@/domain/notifications/utils/notification-target-url";
|
||||||
import {
|
import {
|
||||||
|
|
@ -27,13 +26,6 @@ export function useNotificationCenter(onNavigate?: () => void) {
|
||||||
[items, readIds],
|
[items, readIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
const acknowledgeBreach = useAcknowledgeSlaBreach();
|
|
||||||
const acknowledge = (item: NotificationItem) => {
|
|
||||||
if (item.rowType !== "acknowledge" || item.target.workOrderId == null) return;
|
|
||||||
acknowledgeBreach.mutate(item.target.workOrderId);
|
|
||||||
};
|
|
||||||
const acknowledgingWorkOrderId = acknowledgeBreach.isPending ? acknowledgeBreach.variables : null;
|
|
||||||
|
|
||||||
const open = (item: NotificationItem, url = notificationTargetUrl(item, user?.id)) => {
|
const open = (item: NotificationItem, url = notificationTargetUrl(item, user?.id)) => {
|
||||||
markRead([item.id]);
|
markRead([item.id]);
|
||||||
if (url == null) return;
|
if (url == null) return;
|
||||||
|
|
@ -49,8 +41,6 @@ export function useNotificationCenter(onNavigate?: () => void) {
|
||||||
error: feed.error,
|
error: feed.error,
|
||||||
open,
|
open,
|
||||||
dismiss: (item: NotificationItem) => dismiss([item.id]),
|
dismiss: (item: NotificationItem) => dismiss([item.id]),
|
||||||
acknowledge,
|
|
||||||
acknowledgingWorkOrderId,
|
|
||||||
dismissAll: () => dismiss(items.filter(isDismissable).map((item) => item.id)),
|
dismissAll: () => dismiss(items.filter(isDismissable).map((item) => item.id)),
|
||||||
markAllRead: () => markRead(items.map((item) => item.id)),
|
markAllRead: () => markRead(items.map((item) => item.id)),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -63,10 +63,7 @@ export const navGroups: NavGroup[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "DATA MANAGEMENT",
|
title: "DATA MANAGEMENT",
|
||||||
items: [
|
items: [{ label: "Services", icon: Wrench, path: "/settings/services" }],
|
||||||
{ label: "Services", icon: Wrench, path: "/settings/services" },
|
|
||||||
{ label: "Completion Docs", icon: FileText, path: "/completion-docs" },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "TEAM",
|
title: "TEAM",
|
||||||
|
|
|
||||||
|
|
@ -1,53 +0,0 @@
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The single password rule for every surface that sets a password. It mirrors the
|
|
||||||
* server's Identity options: the character classes are ASCII, matching how the
|
|
||||||
* server classifies uppercase letters, digits and non-alphanumeric characters.
|
|
||||||
*/
|
|
||||||
export const PASSWORD_MIN_LENGTH = 6;
|
|
||||||
|
|
||||||
export type PasswordRuleId = "length" | "uppercase" | "number" | "special";
|
|
||||||
|
|
||||||
export type PasswordRule = {
|
|
||||||
id: PasswordRuleId;
|
|
||||||
label: string;
|
|
||||||
test: (password: string) => boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PasswordRuleResult = {
|
|
||||||
id: PasswordRuleId;
|
|
||||||
label: string;
|
|
||||||
met: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PASSWORD_RULES: readonly PasswordRule[] = [
|
|
||||||
{
|
|
||||||
id: "length",
|
|
||||||
label: `At least ${PASSWORD_MIN_LENGTH} characters`,
|
|
||||||
test: (password) => password.length >= PASSWORD_MIN_LENGTH,
|
|
||||||
},
|
|
||||||
{ id: "uppercase", label: "One uppercase letter", test: (password) => /[A-Z]/.test(password) },
|
|
||||||
{ id: "number", label: "One number", test: (password) => /[0-9]/.test(password) },
|
|
||||||
{
|
|
||||||
id: "special",
|
|
||||||
label: "One special character",
|
|
||||||
test: (password) => /[^A-Za-z0-9]/.test(password),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const PASSWORD_REQUIREMENTS_MESSAGE = "Password must meet every requirement below";
|
|
||||||
|
|
||||||
export const PASSWORDS_DO_NOT_MATCH_MESSAGE = "Passwords don't match";
|
|
||||||
|
|
||||||
export function evaluatePasswordRules(password: string): PasswordRuleResult[] {
|
|
||||||
return PASSWORD_RULES.map(({ id, label, test }) => ({ id, label, met: test(password) }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function meetsPasswordPolicy(password: string): boolean {
|
|
||||||
return PASSWORD_RULES.every((rule) => rule.test(password));
|
|
||||||
}
|
|
||||||
|
|
||||||
export const passwordSchema = z.string().refine(meetsPasswordPolicy, {
|
|
||||||
message: PASSWORD_REQUIREMENTS_MESSAGE,
|
|
||||||
});
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { PASSWORDS_DO_NOT_MATCH_MESSAGE, passwordSchema } from "@/domain/auth/password-policy";
|
|
||||||
|
|
||||||
export const CURRENT_PASSWORD_INCORRECT_MESSAGE = "Current password is incorrect";
|
|
||||||
|
|
||||||
export const changePasswordSchema = z
|
|
||||||
.object({
|
|
||||||
currentPassword: z.string().min(1, "Current password is required"),
|
|
||||||
newPassword: passwordSchema,
|
|
||||||
confirmPassword: z.string(),
|
|
||||||
})
|
|
||||||
.refine((values) => values.newPassword === values.confirmPassword, {
|
|
||||||
path: ["confirmPassword"],
|
|
||||||
message: PASSWORDS_DO_NOT_MATCH_MESSAGE,
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ChangePasswordFormValues = z.infer<typeof changePasswordSchema>;
|
|
||||||
|
|
@ -3,13 +3,14 @@ import { toast } from "react-toastify";
|
||||||
import * as authApi from "@/domain/auth/api/auth-api";
|
import * as authApi from "@/domain/auth/api/auth-api";
|
||||||
import type { ChangePasswordPayload } from "@/domain/auth/types/auth-user";
|
import type { ChangePasswordPayload } from "@/domain/auth/types/auth-user";
|
||||||
|
|
||||||
/** Errors are rendered inline by the form that initiated the change. */
|
|
||||||
export function useChangePassword(): UseMutationResult<unknown, Error, ChangePasswordPayload> {
|
export function useChangePassword(): UseMutationResult<unknown, Error, ChangePasswordPayload> {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: ChangePasswordPayload) => authApi.changePassword(data),
|
mutationFn: (data: ChangePasswordPayload) => authApi.changePassword(data),
|
||||||
meta: { suppressErrorToast: true },
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success("Password updated");
|
toast.success("Password changed successfully!");
|
||||||
|
},
|
||||||
|
onError: (error: Error) => {
|
||||||
|
toast.error(error.message || "Failed to change password");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
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);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
/** 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"}`;
|
|
||||||
}
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
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 !== ""),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,45 +0,0 @@
|
||||||
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>;
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
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[];
|
|
||||||
}
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
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"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
/** 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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
/** "Jun 1, 2026"; an empty or unparseable value shows as "—". */
|
|
||||||
export function formatTemplateDate(value: string): string {
|
|
||||||
const date = new Date(value);
|
|
||||||
if (value === "" || Number.isNaN(date.getTime())) return "—";
|
|
||||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
@ -7,13 +7,11 @@ import {
|
||||||
mapLocationListItems,
|
mapLocationListItems,
|
||||||
mapLocationSites,
|
mapLocationSites,
|
||||||
mapLocationToBackend,
|
mapLocationToBackend,
|
||||||
mapSiteOpenWorkOrders,
|
|
||||||
} from "@/domain/locations/mappers/location-mapper";
|
} from "@/domain/locations/mappers/location-mapper";
|
||||||
import type {
|
import type {
|
||||||
Location,
|
Location,
|
||||||
LocationSite,
|
LocationSite,
|
||||||
SiteContactInfoInput,
|
SiteContactInfoInput,
|
||||||
SiteOpenWorkOrders,
|
|
||||||
LocationsListParams,
|
LocationsListParams,
|
||||||
LocationsListResult,
|
LocationsListResult,
|
||||||
} from "@/domain/locations/types/location";
|
} from "@/domain/locations/types/location";
|
||||||
|
|
@ -86,9 +84,4 @@ export const locationsApi = {
|
||||||
delete: async (id: string | number): Promise<void> => {
|
delete: async (id: string | number): Promise<void> => {
|
||||||
await apiDelete<unknown>(`${API_PATHS.rest.locations}/${id}`);
|
await apiDelete<unknown>(`${API_PATHS.rest.locations}/${id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
getOpenWorkOrders: async (id: string | number): Promise<SiteOpenWorkOrders> => {
|
|
||||||
const data = await apiGet<unknown>(`${API_PATHS.rest.locations}/${id}/open-work-orders`);
|
|
||||||
return mapSiteOpenWorkOrders(handleApiResponse(data));
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import type {
|
||||||
LocationContact,
|
LocationContact,
|
||||||
LocationListItem,
|
LocationListItem,
|
||||||
LocationSite,
|
LocationSite,
|
||||||
SiteOpenWorkOrders,
|
|
||||||
} from "@/domain/locations/types/location";
|
} from "@/domain/locations/types/location";
|
||||||
|
|
||||||
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
function readString(record: Record<string, unknown>, ...keys: string[]): string {
|
||||||
|
|
@ -256,16 +255,3 @@ export function mapLocationToBackend(location: unknown): Record<string, unknown>
|
||||||
|
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapSiteOpenWorkOrders(raw: unknown): SiteOpenWorkOrders {
|
|
||||||
const item = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
|
|
||||||
const rawIds = item.workOrderIds ?? item.WorkOrderIds;
|
|
||||||
const workOrderIds = Array.isArray(rawIds)
|
|
||||||
? rawIds.filter((id): id is number => typeof id === "number" && Number.isInteger(id) && id > 0)
|
|
||||||
: [];
|
|
||||||
const count = item.count ?? item.Count;
|
|
||||||
return {
|
|
||||||
count: typeof count === "number" && count >= 0 ? count : workOrderIds.length,
|
|
||||||
workOrderIds,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -75,9 +75,3 @@ export interface SiteContactInfoInput {
|
||||||
contacts: LocationContact[];
|
contacts: LocationContact[];
|
||||||
notes: string;
|
notes: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** GET locations/{id}/open-work-orders: work orders not Completed or Canceled (ids capped). */
|
|
||||||
export interface SiteOpenWorkOrders {
|
|
||||||
count: number;
|
|
||||||
workOrderIds: number[];
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { apiGet, apiPostNoContent } from "@/api/api";
|
import { apiGet } from "@/api/api";
|
||||||
import {
|
import {
|
||||||
notificationFeedSchema,
|
notificationFeedSchema,
|
||||||
type NotificationFeed,
|
type NotificationFeed,
|
||||||
|
|
@ -7,7 +7,4 @@ import {
|
||||||
export const notificationsApi = {
|
export const notificationsApi = {
|
||||||
feed: async (): Promise<NotificationFeed> =>
|
feed: async (): Promise<NotificationFeed> =>
|
||||||
notificationFeedSchema.parse(await apiGet<unknown>("notifications")),
|
notificationFeedSchema.parse(await apiGet<unknown>("notifications")),
|
||||||
/** Records who acknowledged the missed response deadline in the work order's audit history. */
|
|
||||||
acknowledgeSlaBreach: (workOrderId: number): Promise<void> =>
|
|
||||||
apiPostNoContent(`notifications/sla/${workOrderId}/acknowledge`),
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,6 @@ export type NotificationSession = {
|
||||||
readIds: ReadonlySet<string>;
|
readIds: ReadonlySet<string>;
|
||||||
dismiss: (ids: string[]) => void;
|
dismiss: (ids: string[]) => void;
|
||||||
markRead: (ids: string[]) => void;
|
markRead: (ids: string[]) => void;
|
||||||
/** At-risk work orders hidden from the SLA banner for this session. */
|
|
||||||
slaBannerDismissedIds: ReadonlySet<number>;
|
|
||||||
dismissSlaBanner: (workOrderIds: number[]) => void;
|
|
||||||
/** Returns the ids not toasted yet this session and marks them toasted, so each toast fires once. */
|
|
||||||
claimSlaToasts: (workOrderIds: number[]) => number[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NotificationSessionContext = createContext<NotificationSession | null>(null);
|
export const NotificationSessionContext = createContext<NotificationSession | null>(null);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
|
import { useCallback, useMemo, useState, type ReactNode } from "react";
|
||||||
import { NotificationSessionContext } from "./notification-session-context";
|
import { NotificationSessionContext } from "./notification-session-context";
|
||||||
|
|
||||||
function addAll<T>(current: ReadonlySet<T>, ids: T[]): ReadonlySet<T> {
|
function addAll(current: ReadonlySet<string>, ids: string[]): ReadonlySet<string> {
|
||||||
if (ids.every((id) => current.has(id))) {
|
if (ids.every((id) => current.has(id))) {
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|
@ -17,11 +17,6 @@ function addAll<T>(current: ReadonlySet<T>, ids: T[]): ReadonlySet<T> {
|
||||||
export function NotificationSessionProvider({ children }: { children: ReactNode }) {
|
export function NotificationSessionProvider({ children }: { children: ReactNode }) {
|
||||||
const [dismissedIds, setDismissedIds] = useState<ReadonlySet<string>>(() => new Set());
|
const [dismissedIds, setDismissedIds] = useState<ReadonlySet<string>>(() => new Set());
|
||||||
const [readIds, setReadIds] = useState<ReadonlySet<string>>(() => new Set());
|
const [readIds, setReadIds] = useState<ReadonlySet<string>>(() => new Set());
|
||||||
const [slaBannerDismissedIds, setSlaBannerDismissedIds] = useState<ReadonlySet<number>>(
|
|
||||||
() => new Set(),
|
|
||||||
);
|
|
||||||
// A ref, not state: claiming must be synchronous so a re-run effect can never toast twice.
|
|
||||||
const toastedSlaIds = useRef(new Set<number>());
|
|
||||||
|
|
||||||
const dismiss = useCallback((ids: string[]) => {
|
const dismiss = useCallback((ids: string[]) => {
|
||||||
setDismissedIds((current) => addAll(current, ids));
|
setDismissedIds((current) => addAll(current, ids));
|
||||||
|
|
@ -29,34 +24,10 @@ export function NotificationSessionProvider({ children }: { children: ReactNode
|
||||||
const markRead = useCallback((ids: string[]) => {
|
const markRead = useCallback((ids: string[]) => {
|
||||||
setReadIds((current) => addAll(current, ids));
|
setReadIds((current) => addAll(current, ids));
|
||||||
}, []);
|
}, []);
|
||||||
const dismissSlaBanner = useCallback((workOrderIds: number[]) => {
|
|
||||||
setSlaBannerDismissedIds((current) => addAll(current, workOrderIds));
|
|
||||||
}, []);
|
|
||||||
const claimSlaToasts = useCallback((workOrderIds: number[]) => {
|
|
||||||
const claimed = workOrderIds.filter((id) => !toastedSlaIds.current.has(id));
|
|
||||||
claimed.forEach((id) => toastedSlaIds.current.add(id));
|
|
||||||
return claimed;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({ dismissedIds, readIds, dismiss, markRead }),
|
||||||
dismissedIds,
|
[dismissedIds, readIds, dismiss, markRead],
|
||||||
readIds,
|
|
||||||
dismiss,
|
|
||||||
markRead,
|
|
||||||
slaBannerDismissedIds,
|
|
||||||
dismissSlaBanner,
|
|
||||||
claimSlaToasts,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
dismissedIds,
|
|
||||||
readIds,
|
|
||||||
dismiss,
|
|
||||||
markRead,
|
|
||||||
slaBannerDismissedIds,
|
|
||||||
dismissSlaBanner,
|
|
||||||
claimSlaToasts,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -50,21 +50,9 @@ const sectionSchema = z.object({
|
||||||
items: z.array(itemSchema),
|
items: z.array(itemSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** A work order past half of its SEV response window, with the server-computed clock. */
|
|
||||||
const slaAtRiskSchema = z.object({
|
|
||||||
id: z.number(),
|
|
||||||
number: z.string().nullish(),
|
|
||||||
severity: z.number(),
|
|
||||||
startedAt: z.string(),
|
|
||||||
deadlineAt: z.string(),
|
|
||||||
percentElapsed: z.number(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const notificationFeedSchema = z.object({
|
export const notificationFeedSchema = z.object({
|
||||||
generatedAt: z.string(),
|
generatedAt: z.string(),
|
||||||
sections: z.array(sectionSchema),
|
sections: z.array(sectionSchema),
|
||||||
// Defaulted so a feed from a server without SLA alerts still parses.
|
|
||||||
slaAtRisk: z.array(slaAtRiskSchema).default([]),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type NotificationSeverity = z.infer<typeof severitySchema>;
|
export type NotificationSeverity = z.infer<typeof severitySchema>;
|
||||||
|
|
@ -72,4 +60,3 @@ export type NotificationTarget = z.infer<typeof targetSchema>;
|
||||||
export type NotificationItem = z.infer<typeof itemSchema>;
|
export type NotificationItem = z.infer<typeof itemSchema>;
|
||||||
export type NotificationSection = z.infer<typeof sectionSchema>;
|
export type NotificationSection = z.infer<typeof sectionSchema>;
|
||||||
export type NotificationFeed = z.infer<typeof notificationFeedSchema>;
|
export type NotificationFeed = z.infer<typeof notificationFeedSchema>;
|
||||||
export type SlaAtRiskWorkOrder = z.infer<typeof slaAtRiskSchema>;
|
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { notificationsApi } from "@/domain/notifications/api/notifications-api";
|
|
||||||
import type {
|
|
||||||
NotificationFeed,
|
|
||||||
NotificationItem,
|
|
||||||
} from "@/domain/notifications/types/notification-feed";
|
|
||||||
import { queryKeys } from "@/infra/query-key/query-key";
|
|
||||||
|
|
||||||
function isBreachRowFor(workOrderId: number) {
|
|
||||||
return (item: NotificationItem) =>
|
|
||||||
item.reason === "sla" &&
|
|
||||||
item.rowType === "acknowledge" &&
|
|
||||||
item.target.workOrderId === workOrderId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The feed without the acknowledged work order's breach row, until the next poll confirms it. */
|
|
||||||
export function withoutSlaBreach(feed: NotificationFeed, workOrderId: number): NotificationFeed {
|
|
||||||
const matches = isBreachRowFor(workOrderId);
|
|
||||||
return {
|
|
||||||
...feed,
|
|
||||||
sections: feed.sections.map((section) => {
|
|
||||||
const removed = section.items.filter(matches);
|
|
||||||
if (removed.length === 0) return section;
|
|
||||||
return {
|
|
||||||
...section,
|
|
||||||
items: section.items.filter((item) => !matches(item)),
|
|
||||||
count: Math.max(section.count - removed.reduce((total, item) => total + item.count, 0), 0),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAcknowledgeSlaBreach() {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (workOrderId: number) => notificationsApi.acknowledgeSlaBreach(workOrderId),
|
|
||||||
onSuccess: (_result, workOrderId) => {
|
|
||||||
queryClient.setQueryData<NotificationFeed>(queryKeys.notifications.feed(), (feed) =>
|
|
||||||
feed ? withoutSlaBreach(feed, workOrderId) : feed,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onSettled: () => queryClient.invalidateQueries({ queryKey: queryKeys.notifications.feed() }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
import type { SlaAtRiskWorkOrder } from "@/domain/notifications/types/notification-feed";
|
|
||||||
|
|
||||||
function workOrderLabel(workOrder: Pick<SlaAtRiskWorkOrder, "id" | "number">): string {
|
|
||||||
return workOrder.number != null && workOrder.number !== ""
|
|
||||||
? `WO #${workOrder.number}`
|
|
||||||
: `WO ${workOrder.id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One line whatever the count, as the top-of-app banner shows it. */
|
|
||||||
export function slaBannerText(workOrders: Pick<SlaAtRiskWorkOrder, "id" | "number">[]): string {
|
|
||||||
if (workOrders.length === 1) {
|
|
||||||
return `${workOrderLabel(workOrders[0])} is at risk of missing its response deadline`;
|
|
||||||
}
|
|
||||||
return `${workOrders.length} work orders are at risk of missing their response deadline`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The one-time toast when a work order is first seen past half of its response window. */
|
|
||||||
export function slaHalfwayToastText(workOrder: Pick<SlaAtRiskWorkOrder, "id" | "number">): string {
|
|
||||||
return `${workOrderLabel(workOrder)} is at 50% of its SLA response window`;
|
|
||||||
}
|
|
||||||
|
|
@ -20,13 +20,10 @@ const SERVICE_WORK_ORDER_TYPE_API = {
|
||||||
Reactive: 6,
|
Reactive: 6,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// The registry is keyed on PM, Reactive and Emergency; Overdue is PM work the
|
const TYPE_TO_API: Record<Exclude<WOType, "Overdue">, number> = {
|
||||||
// client never confirmed, so it lists the PM catalog.
|
|
||||||
const TYPE_TO_API: Record<WOType, number> = {
|
|
||||||
PM: WORK_ORDER_TYPE_API.PM,
|
PM: WORK_ORDER_TYPE_API.PM,
|
||||||
Reactive: WORK_ORDER_TYPE_API.Reactive,
|
Reactive: WORK_ORDER_TYPE_API.Reactive,
|
||||||
Emergency: WORK_ORDER_TYPE_API.Emergency,
|
Emergency: WORK_ORDER_TYPE_API.Emergency,
|
||||||
Overdue: WORK_ORDER_TYPE_API.PM,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function toApiInput(input: ServiceInput) {
|
function toApiInput(input: ServiceInput) {
|
||||||
|
|
@ -54,6 +51,7 @@ export const servicesApi = {
|
||||||
},
|
},
|
||||||
|
|
||||||
getActiveForWorkOrderType: async (workOrderType: WOType): Promise<Service[]> => {
|
getActiveForWorkOrderType: async (workOrderType: WOType): Promise<Service[]> => {
|
||||||
|
if (workOrderType === "Overdue") return [];
|
||||||
const data = await apiGet<unknown>(API_PATHS.services.list, {
|
const data = await apiGet<unknown>(API_PATHS.services.list, {
|
||||||
searchParams: { isActive: true, workOrderType: TYPE_TO_API[workOrderType] },
|
searchParams: { isActive: true, workOrderType: TYPE_TO_API[workOrderType] },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ export function useServicesForWorkOrderType(
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: queryKeys.services.byWorkOrderType(effectiveType),
|
queryKey: queryKeys.services.byWorkOrderType(effectiveType),
|
||||||
queryFn: () => servicesApi.getActiveForWorkOrderType(effectiveType),
|
queryFn: () => servicesApi.getActiveForWorkOrderType(effectiveType),
|
||||||
|
enabled: effectiveType !== "Overdue",
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,5 @@ export function useUpliftsQueue(
|
||||||
queryKey: queryKeys.uplifts.queue(params as Record<string, unknown>),
|
queryKey: queryKeys.uplifts.queue(params as Record<string, unknown>),
|
||||||
queryFn: () => upliftsApi.list(params),
|
queryFn: () => upliftsApi.list(params),
|
||||||
placeholderData: keepPreviousData,
|
placeholderData: keepPreviousData,
|
||||||
// The Uplift Approvals page reports a failed queue inline, so the global toast
|
|
||||||
// would announce the same failure twice.
|
|
||||||
meta: { suppressErrorToast: true },
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/work-order-board";
|
import type { WorkOrderBoardCreatePayload } from "@/domain/work-orders/types/work-order-board";
|
||||||
import { typeNeedsSeverity } from "@/domain/work-orders/types/work-order-severity";
|
|
||||||
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
import type { WorkOrderWizardDraft } from "@/domain/work-orders/types/work-order-wizard";
|
||||||
import { normalizeAdditionalContacts } from "@/domain/work-orders/mappers/additional-contacts-mapper";
|
import { normalizeAdditionalContacts } from "@/domain/work-orders/mappers/additional-contacts-mapper";
|
||||||
|
|
||||||
|
|
@ -65,7 +64,7 @@ export function assignWizardServicePocFields(
|
||||||
payload.vendorNotes = vendorNotes;
|
payload.vendorNotes = vendorNotes;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (draft.severity != null && typeNeedsSeverity(draft.type)) {
|
if (draft.severity != null) {
|
||||||
payload.severity = draft.severity;
|
payload.severity = draft.severity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ const WO_TYPE_TO_API: Record<WOType, WorkOrderTypeApi> = {
|
||||||
|
|
||||||
Reactive: WORK_ORDER_TYPE_API.Reactive,
|
Reactive: WORK_ORDER_TYPE_API.Reactive,
|
||||||
|
|
||||||
Overdue: WORK_ORDER_TYPE_API.Overdue,
|
Overdue: WORK_ORDER_TYPE_API.Other,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Leading token of a location label, e.g. `BK5` from `BK5 · Dallas` or `CHI-1042` from `CHI-1042 — Chicago`. */
|
/** Leading token of a location label, e.g. `BK5` from `BK5 · Dallas` or `CHI-1042` from `CHI-1042 — Chicago`. */
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,6 @@ export interface WorkOrderAdvancedFilters {
|
||||||
dispatchers: Set<string>;
|
dispatchers: Set<string>;
|
||||||
regions: Set<string>;
|
regions: Set<string>;
|
||||||
statuses: Set<WizardWOStatus>;
|
statuses: Set<WizardWOStatus>;
|
||||||
/** Past Due overlay (any type); narrows the other filters, independent of the Overdue type. */
|
|
||||||
pastDue: boolean;
|
|
||||||
rescheduled: boolean;
|
rescheduled: boolean;
|
||||||
carriedOver: boolean;
|
carriedOver: boolean;
|
||||||
addOn: boolean;
|
addOn: boolean;
|
||||||
|
|
@ -48,8 +46,6 @@ export interface WorkOrderAdvancedFilters {
|
||||||
internalOnly: boolean;
|
internalOnly: boolean;
|
||||||
hasUplift: boolean;
|
hasUplift: boolean;
|
||||||
upliftStatuses: Set<UpliftFilterStatus>;
|
upliftStatuses: Set<UpliftFilterStatus>;
|
||||||
/** Exact work orders to show; when non-empty the server ignores every other filter. */
|
|
||||||
workOrderIds: Set<number>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
|
export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
|
||||||
|
|
@ -63,7 +59,6 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
|
||||||
dispatchers: new Set(),
|
dispatchers: new Set(),
|
||||||
regions: new Set(),
|
regions: new Set(),
|
||||||
statuses: new Set(),
|
statuses: new Set(),
|
||||||
pastDue: false,
|
|
||||||
rescheduled: false,
|
rescheduled: false,
|
||||||
carriedOver: false,
|
carriedOver: false,
|
||||||
addOn: false,
|
addOn: false,
|
||||||
|
|
@ -75,7 +70,6 @@ export const EMPTY_ADVANCED_FILTERS: WorkOrderAdvancedFilters = {
|
||||||
internalOnly: false,
|
internalOnly: false,
|
||||||
hasUplift: false,
|
hasUplift: false,
|
||||||
upliftStatuses: new Set(),
|
upliftStatuses: new Set(),
|
||||||
workOrderIds: new Set(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DATE_RANGE_OPTIONS: { key: DateRangeKey; label: string }[] = [
|
export const DATE_RANGE_OPTIONS: { key: DateRangeKey; label: string }[] = [
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ export const WORK_ORDER_TYPE_API = {
|
||||||
Inspection: 5,
|
Inspection: 5,
|
||||||
Reactive: 6,
|
Reactive: 6,
|
||||||
AddOn: 7,
|
AddOn: 7,
|
||||||
Overdue: 8,
|
|
||||||
Other: 99,
|
Other: 99,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|
@ -65,7 +64,6 @@ export const WORK_ORDER_TYPE_API_TO_FE: Record<number, WOType | ""> = {
|
||||||
[WORK_ORDER_TYPE_API.PM]: "PM",
|
[WORK_ORDER_TYPE_API.PM]: "PM",
|
||||||
[WORK_ORDER_TYPE_API.Reactive]: "Reactive",
|
[WORK_ORDER_TYPE_API.Reactive]: "Reactive",
|
||||||
[WORK_ORDER_TYPE_API.Emergency]: "Emergency",
|
[WORK_ORDER_TYPE_API.Emergency]: "Emergency",
|
||||||
[WORK_ORDER_TYPE_API.Overdue]: "Overdue",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const LIFECYCLE_STATUS_API_TO_FE: Record<number, WizardWOStatus | "Canceled"> = {
|
export const LIFECYCLE_STATUS_API_TO_FE: Record<number, WizardWOStatus | "Canceled"> = {
|
||||||
|
|
@ -328,8 +326,6 @@ export interface WorkOrderBoardSearchQueryParams {
|
||||||
hasUplift?: boolean;
|
hasUplift?: boolean;
|
||||||
upliftStatuses?: string[];
|
upliftStatuses?: string[];
|
||||||
myWorkOrders?: boolean;
|
myWorkOrders?: boolean;
|
||||||
/** Comma-separated work-order ids; when present the server returns exactly those, ignoring other filters. */
|
|
||||||
ids?: string;
|
|
||||||
sortBy?: "scheduledDate" | "woNumber" | "dueDate" | string;
|
sortBy?: "scheduledDate" | "woNumber" | "dueDate" | string;
|
||||||
sortDir?: "asc" | "desc" | string;
|
sortDir?: "asc" | "desc" | string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -192,15 +192,6 @@ export function appendStatusPmVendorDocChips(
|
||||||
clear: (current) => ({ ...current, statuses: new Set() }),
|
clear: (current) => ({ ...current, statuses: new Set() }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (filters.pastDue) {
|
|
||||||
chips.push({
|
|
||||||
key: "pastdue",
|
|
||||||
group: "Status",
|
|
||||||
value: "Past Due",
|
|
||||||
clear: (current) => ({ ...current, pastDue: false }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
pushSetChip({
|
pushSetChip({
|
||||||
chips,
|
chips,
|
||||||
key: "pm",
|
key: "pm",
|
||||||
|
|
@ -315,17 +306,3 @@ export function appendUpliftChip(chips: AdvancedFilterChip[], filters: WorkOrder
|
||||||
clear: clearUplift,
|
clear: clearUplift,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function appendWorkOrderIdsChip(
|
|
||||||
chips: AdvancedFilterChip[],
|
|
||||||
filters: WorkOrderAdvancedFilters,
|
|
||||||
) {
|
|
||||||
const count = filters.workOrderIds?.size ?? 0;
|
|
||||||
if (count === 0) return;
|
|
||||||
chips.push({
|
|
||||||
key: "workOrderIds",
|
|
||||||
group: "Work orders",
|
|
||||||
value: `Showing ${count} selected work order${count === 1 ? "" : "s"}`,
|
|
||||||
clear: (current) => ({ ...current, workOrderIds: new Set<number>() }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -45,10 +45,12 @@ const matchesDateRange: AdvancedFilterPredicate = (row, filters) => {
|
||||||
const matchesSite: AdvancedFilterPredicate = (row, filters) =>
|
const matchesSite: AdvancedFilterPredicate = (row, filters) =>
|
||||||
!filters.sites.size || filters.sites.has(row.site);
|
!filters.sites.size || filters.sites.has(row.site);
|
||||||
|
|
||||||
const matchesType: AdvancedFilterPredicate = (row, filters) =>
|
const matchesType: AdvancedFilterPredicate = (row, filters) => {
|
||||||
!filters.types.size || filters.types.has(row.type as never);
|
if (!filters.types.size) return true;
|
||||||
|
const overdueMatch = filters.types.has("Overdue") && row.isPastDue;
|
||||||
const matchesPastDue: AdvancedFilterPredicate = (row, filters) => !filters.pastDue || row.isPastDue;
|
const typeMatch = filters.types.has(row.type as never);
|
||||||
|
return overdueMatch || typeMatch;
|
||||||
|
};
|
||||||
|
|
||||||
const matchesDispatcher: AdvancedFilterPredicate = (row, filters) => {
|
const matchesDispatcher: AdvancedFilterPredicate = (row, filters) => {
|
||||||
if (!filters.dispatchers.size) return true;
|
if (!filters.dispatchers.size) return true;
|
||||||
|
|
@ -93,7 +95,6 @@ export const ADVANCED_FILTER_PREDICATES: AdvancedFilterPredicate[] = [
|
||||||
matchesType,
|
matchesType,
|
||||||
matchesDispatcher,
|
matchesDispatcher,
|
||||||
matchesStatus,
|
matchesStatus,
|
||||||
matchesPastDue,
|
|
||||||
matchesPmType,
|
matchesPmType,
|
||||||
matchesVendorTech,
|
matchesVendorTech,
|
||||||
matchesDoc,
|
matchesDoc,
|
||||||
|
|
|
||||||