Compare commits

...

34 commits

Author SHA1 Message Date
Adam Moussa
73e3362fcf
Merge pull request #237 from Sea-Haven-Industries/renovate/reconfigure
Some checks are pending
Frontend checks / Build and test (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
chore(renovate): widen the schedule, manage workflow actions, surface actionlint
2026-09-18 23:09:28 +00:00
Adam Moussa
e611f193bd
ci: run required checks on merge_group for the merge queue (#236)
governance and Build and test are the required checks on main. A merge queue
only counts checks that ran on the merge_group event, so the workflow now
triggers on it. The governance gate reads the merge group's own base SHA
because github.event.before is empty there.
2026-09-18 19:03:12 -04:00
bebd517290
chore(renovate): widen the schedule, manage workflow actions, surface actionlint
The org window (before 6am on Monday) has produced only one security PR in
this repository since the overlay landed, so the overlay now opens every
weekday morning. The github-actions manager is enabled so the digest-pinned
actions in the workflows follow the org's pinning and grouping rules. A regex
manager tracks the actionlint release installed by the governance job; it is
held for dashboard approval because the SHA256 pin next to it has to be
updated by hand.
2026-09-18 18:50:49 -04:00
Alexandre Brandizzi
7c470d389c
Merge pull request #231 from Sea-Haven-Industries/fix/ab/sh-380-completion-pdf-post-upload-error
fix(work-orders): stop the stale-version docStatus patch after a signed PDF upload (SH-380)
2026-09-18 19:47:47 -03:00
Alexandre Brandizzi
de7066d31b
Merge branch 'main' into fix/ab/sh-380-completion-pdf-post-upload-error 2026-09-18 19:33:59 -03:00
Alexandre Brandizzi
2c47b4a9ca
Merge pull request #212 from Sea-Haven-Industries/feat/ab/sh-304-services-registry-ui
SH-304: Add Services registry management UI
2026-09-18 19:33:33 -03:00
Alexandre Brandizzi
7fdf85fc47
Merge branch 'main' into feat/ab/sh-304-services-registry-ui 2026-09-18 19:21:21 -03:00
Alexandre Brandizzi
7eb7e4a730
Merge branch 'main' into fix/ab/sh-380-completion-pdf-post-upload-error 2026-09-18 19:21:18 -03:00
Alexandre Brandizzi
9bba6402e0
Merge pull request #230 from Sea-Haven-Industries/feat/ab/sh-382-extra-doc-view
fix(work-orders): view extra documents (SH-382)
2026-09-18 19:20:59 -03:00
Alexandre Brandizzi
7c1243eec3
Merge branch 'main' into feat/ab/sh-304-services-registry-ui 2026-09-18 19:09:49 -03:00
Alexandre Brandizzi
744d7af7bb
Merge branch 'main' into feat/ab/sh-382-extra-doc-view 2026-09-18 19:09:47 -03:00
Alexandre Brandizzi
e3afa182d6
Merge branch 'main' into fix/ab/sh-380-completion-pdf-post-upload-error 2026-09-18 19:09:44 -03:00
Alexandre Brandizzi
29caecd449
Merge branch 'main' into feat/ab/sh-304-services-registry-ui 2026-09-18 18:57:58 -03:00
Alexandre Brandizzi
e1b03dc151
Merge branch 'main' into feat/ab/sh-382-extra-doc-view 2026-09-18 18:57:56 -03:00
Alexandre Brandizzi
5766616cc0
Merge branch 'main' into fix/ab/sh-380-completion-pdf-post-upload-error 2026-09-18 18:57:53 -03:00
Alexandre Brandizzi
8d50c6f75e fix(work-orders): stop the stale-version docStatus patch after a signed PDF upload (SH-380)
The completion-doc upload endpoint sets DocStatus=Yes and bumps the work-order row
version in one transaction. Both signed-PDF upload paths then re-ran a "docStatus=Yes"
work-order patch using the pre-upload row version (the comp-doc dialog re-ran the whole
Generate flow; the slide-over posted docStatus directly). That patch always 409'd — and
in the dialog it also demanded a Date Work Completed — so a successful upload surfaced an
error to the user.

Both paths now stop after the upload settles: the mutation's onSuccess refreshes the
board and the UI reflects the generated state locally, with no second docStatus patch.
2026-09-18 16:59:24 -03:00
Alexandre Brandizzi
1a7a4ba58f fix(work-orders): open extra documents without credentialed CORS (SH-382)
Viewing a work-order extra document called the authorized content endpoint
with `credentials: "include"`. The API replies `Access-Control-Allow-Origin: *`,
and in credentialed mode the browser rejects a wildcard origin outright, so the
fetch threw, `openExtraDocContent` fell into its catch, closed the tab and
toasted "Unable to open this document." — QA CT-03.

The endpoint authenticates with the bearer token the ky beforeRequest hook
attaches, not cookies, so credentials mode was dead weight. List, upload and
delete never set it, which is why only viewing failed. Drop the option and
guard the request shape in a test (JSDOM cannot enforce CORS).
2026-09-18 16:35:01 -03:00
Alexandre Brandizzi
e780bafdd8
Merge branch 'dev' into feat/ab/sh-304-services-registry-ui 2026-09-18 11:59:13 -03:00
Codex Review Integration
28ff14d457 Merge remote-tracking branch 'origin/dev' into feat/ab/sh-304-services-registry-ui
# Conflicts:
#	src/test/lib/auth/user-utils.test.ts
2026-09-17 15:34:52 -03:00
Alexandre Brandizzi
9ed8099e71
Merge branch 'dev' into feat/ab/sh-304-services-registry-ui 2026-09-17 15:16:25 -03:00
Alexandre Brandizzi
361f6b65a2
Merge branch 'dev' into feat/ab/sh-304-services-registry-ui 2026-09-17 15:10:25 -03:00
Codex Review Integration
bbdf418ee3 refactor(services): extract useServiceEditor to satisfy maintainability gate
useServicesRegistryController exceeded the changed-file max-lines-per-function
budget (158 > 150). Move the add/edit form lifecycle into a useServiceEditor
sub-hook; the controller's public return shape and behavior are unchanged.
2026-09-17 14:55:05 -03:00
Alexandre Brandizzi
ff4ecb37e4
Merge branch 'dev' into feat/ab/sh-304-services-registry-ui 2026-09-17 14:48:16 -03:00
Alexandre Brandizzi
0302b2828f
Merge branch 'dev' into feat/ab/sh-304-services-registry-ui 2026-09-17 14:34:33 -03:00
Alexandre Brandizzi
3011cb1eda
Merge branch 'dev' into feat/ab/sh-304-services-registry-ui 2026-09-17 14:26:30 -03:00
Codex Review Integration
90a3c24846 fix(services): source Trade picker from canonical catalog (SH-304)
The Trade select was fed from the editable DropdownOptions "Trade"
category, whose legacy seed values ("Plumbing", "General", ...) are not
in the backend TradeCatalog. ServicesRegistryService.RequireCanonicalTrade
rejects any non-canonical trade with 400 TradeInvalid, so Add Service and
Save Changes failed for legacy names and an admin editing the Trade
dropdown could break service creation at any time.

Source the picker from the server-owned canonical Trade reference on
Vendor/facets (the same list the vendor roster form uses), key the icon
defaults to the canonical names, and keep the edited service's stored
trade selectable if it was later deactivated so editing never silently
blanks the field.
2026-09-17 14:14:48 -03:00
Codex Review Integration
7dd7ea497e Merge remote-tracking branch 'origin/dev' into feat/ab/sh-304-services-registry-ui
# Conflicts:
#	src/api/api-paths.ts
#	src/domain/services/api/services-api.ts
#	src/domain/services/mappers/service-mapper.ts
#	src/domain/services/types/service.ts
#	src/infra/query-key/query-key.ts
#	src/test/domain/services/api/services-api.test.ts
2026-09-17 13:24:08 -03:00
Codex Review Integration
f39328576e fix(services): replace status filter with active/inactive tabs (SH-304) 2026-09-17 02:01:08 -03:00
Codex Review Integration
2fb5d4a965 fix(services): satisfy registry maintainability gate 2026-09-16 22:50:09 -03:00
Codex Review Integration
9c360fc95b fix(services): address registry review findings 2026-09-16 22:30:32 -03:00
Codex Review Integration
1c66f8d60e fix(services): cache completion template options 2026-09-16 19:13:28 -03:00
Codex Review Integration
b449df97e6 fix(services): use completion document templates 2026-09-16 19:13:00 -03:00
Codex Review Integration
0b63d882ab refactor(services): split registry management view 2026-09-16 19:05:02 -03:00
Codex Review Integration
c9ab1f3598 feat(services): add registry management page 2026-09-16 18:42:31 -03:00
24 changed files with 1773 additions and 74 deletions

21
.github/renovate.json vendored
View file

@ -1,6 +1,7 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"enabledManagers": ["npm", "custom.regex", "terraform"],
"enabledManagers": ["npm", "custom.regex", "terraform", "github-actions"],
"schedule": ["before 6am every weekday"],
"minimumReleaseAge": "3 days",
"internalChecksFilter": "strict",
"customManagers": [
@ -14,6 +15,17 @@
"datasourceTemplate": "npm",
"depNameTemplate": "@playwright/test",
"versioningTemplate": "npm"
},
{
"customType": "regex",
"description": [
"actionlint release installed by the governance job; its SHA256 pin must be updated by hand, so this only surfaces the update on the dashboard"
],
"managerFilePatterns": ["/^\\.github/workflows/ci\\.ya?ml$/"],
"matchStrings": ["ACTIONLINT_VERSION: \"(?<currentValue>\\d+\\.\\d+\\.\\d+)\""],
"datasourceTemplate": "github-releases",
"depNameTemplate": "rhysd/actionlint",
"extractVersionTemplate": "^v(?<version>.*)$"
}
],
"packageRules": [
@ -36,6 +48,13 @@
"dependencyDashboardApproval": true,
"groupName": "playwright"
},
{
"description": [
"Do not open actionlint PRs until approved; the SHA256 pin in ci.yaml has to change with the version"
],
"matchPackageNames": ["rhysd/actionlint"],
"dependencyDashboardApproval": true
},
{
"description": ["Keep MUI packages together"],
"matchPackageNames": ["@mui/**"],

View file

@ -3,6 +3,9 @@ name: Frontend checks
on:
pull_request:
branches: [main, dev]
# The merge queue builds main plus the queued pull requests on a temporary
# branch and only counts checks that ran on the merge_group event.
merge_group:
push:
branches: [main]
workflow_dispatch: {}
@ -32,6 +35,7 @@ jobs:
#
# GOVERNANCE_BASE points the changed-file gate at the right diff:
# PR -> the PR target branch (origin/<base_ref>)
# merge group -> the group's own base (github.event.merge_group.base_sha)
# push-> the previous commit on the branch (github.event.before)
# manual -> main, for exact-head recovery runs
runs-on: ubuntu-latest
@ -46,10 +50,13 @@ jobs:
EVENT_NAME: ${{ github.event_name }}
EVENT_BEFORE: ${{ github.event.before }}
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }}
run: |
set -euo pipefail
if [[ "${EVENT_NAME}" == "pull_request" ]]; then
base="${PR_BASE_SHA}"
elif [[ "${EVENT_NAME}" == "merge_group" && -n "${MERGE_GROUP_BASE_SHA}" ]]; then
base="${MERGE_GROUP_BASE_SHA}"
elif [[ "${EVENT_NAME}" == "push" && -n "${EVENT_BEFORE}" && ! "${EVENT_BEFORE}" =~ ^0+$ ]]; then
base="${EVENT_BEFORE}"
else

View file

@ -31,6 +31,7 @@ export const API_PATHS = {
boardSearch: "workorders/board/search",
boardPatch: (id: string | number) => `workorders/${id}/board`,
boardDetail: (id: string | number) => `workorders/${id}/detail`,
completionTemplates: "workorders/completion-templates",
comments: (id: string | number) => `workorders/${id}/comments`,
commentItem: (workOrderId: string | number, commentId: string | number) =>
`workorders/${workOrderId}/comments/${commentId}`,
@ -167,6 +168,8 @@ export const API_PATHS = {
},
services: {
list: "services",
byId: (id: string | number) => `services/${id}`,
deactivate: (id: string | number) => `services/${id}/deactivate`,
},
user: {
list: "User",

View file

@ -0,0 +1,379 @@
import {
Autocomplete,
Box,
Button,
Checkbox,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Divider,
Drawer,
FormControl,
FormControlLabel,
FormHelperText,
InputLabel,
MenuItem,
Select,
Stack,
Switch,
TextField,
Typography,
} from "@mui/material";
import { Droplets, Hammer, Settings2, Wrench, Zap } from "lucide-react";
import {
SERVICE_WORK_ORDER_TYPES,
type CompletionDocTemplateOption,
type Service,
type ServiceWorkOrderType,
} from "@/domain/services/types/service";
import type { ServiceForm } from "@/domain/services/use-cases/use-services-registry-controller";
const ICONS = [
{ key: "wrench", label: "Wrench", Icon: Wrench },
{ key: "droplets", label: "Droplets", Icon: Droplets },
{ key: "zap", label: "Zap", Icon: Zap },
{ key: "hammer", label: "Hammer", Icon: Hammer },
{ key: "settings", label: "Settings", Icon: Settings2 },
] as const;
export function ServiceIcon({ iconKey, size = 18 }: { iconKey: string; size?: number }) {
const icon = ICONS.find((item) => item.key === iconKey) ?? ICONS[0];
const Icon = icon.Icon;
return <Icon size={size} aria-hidden="true" />;
}
type DetailsProps = {
service: Service | null;
open: boolean;
canManage: boolean;
canDeactivate: boolean;
onClose: () => void;
onEdit: (service: Service) => void;
onDeactivate: (service: Service) => void;
};
export function ServiceDetailsDrawer({
service,
open,
canManage,
canDeactivate,
onClose,
onEdit,
onDeactivate,
}: DetailsProps) {
return (
<Drawer anchor="right" open={open} onClose={onClose}>
{service !== null && (
<Box className="flex h-full w-[min(100vw,460px)] flex-col">
<Box className="flex items-start justify-between p-5">
<Box>
<Typography variant="h6" className="font-display font-bold">
{service.name}
</Typography>
<Typography variant="body2" color="text.secondary">
Service details
</Typography>
</Box>
<Chip size="small" label={service.isActive ? "Active" : "Inactive"} />
</Box>
<Divider />
<Box className="flex-1 overflow-y-auto p-5">
<Stack spacing={3}>
<Stack direction="row" spacing={1.5} sx={{ alignItems: "center" }}>
<Box className="flex size-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
<ServiceIcon iconKey={service.iconKey} size={24} />
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
Trade
</Typography>
<Typography variant="body1" className="font-semibold">
{service.trade}
</Typography>
</Box>
</Stack>
<Box>
<Typography variant="subtitle2" className="mb-1 font-semibold">
Work Order Types
</Typography>
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap", gap: 1 }}>
{service.supportedWorkOrderTypes.map((type) => (
<Chip key={type} size="small" label={type} variant="outlined" />
))}
</Stack>
</Box>
<Box>
<Typography variant="subtitle2" className="mb-1 font-semibold">
Completion Document
</Typography>
<Typography variant="body2">
{service.requiresCompletionDocument
? service.completionDocTemplate?.name || "No template linked yet"
: "Not required"}
</Typography>
</Box>
</Stack>
</Box>
<Divider />
<Stack direction="row" spacing={1} className="p-4" sx={{ justifyContent: "flex-end" }}>
{canManage && (
<Button variant="outlined" onClick={() => onEdit(service)}>
Edit
</Button>
)}
{canDeactivate && service.isActive && (
<Button color="warning" variant="contained" onClick={() => onDeactivate(service)}>
Deactivate
</Button>
)}
</Stack>
</Box>
)}
</Drawer>
);
}
type EditorProps = {
mode: "add" | "edit" | null;
form: ServiceForm;
formError: string;
trades: string[];
templates: CompletionDocTemplateOption[];
canChangeActiveState: boolean;
isSaving: boolean;
onClose: () => void;
onSave: () => void;
onUpdate: <K extends keyof ServiceForm>(key: K, value: ServiceForm[K]) => void;
onToggleType: (type: ServiceWorkOrderType) => void;
};
export function ServiceEditorDrawer({
mode,
form,
formError,
trades,
templates,
canChangeActiveState,
isSaving,
onClose,
onSave,
onUpdate,
onToggleType,
}: EditorProps) {
return (
<Drawer anchor="right" open={mode !== null} onClose={onClose}>
<Box className="flex h-full w-[min(100vw,520px)] flex-col">
<Box className="p-5">
<Typography variant="h6" className="font-display font-bold">
{mode === "add" ? "Add Service" : "Edit Service"}
</Typography>
<Typography variant="body2" color="text.secondary" className="mt-1">
Register a service and whether it needs a completion document.
</Typography>
</Box>
<Divider />
<Box className="flex-1 overflow-y-auto p-5">
<Stack spacing={2.5}>
{Boolean(formError) && <AlertMessage message={formError} />}
<ServiceEditorFields
mode={mode}
form={form}
trades={trades}
templates={templates}
canChangeActiveState={canChangeActiveState}
onUpdate={onUpdate}
onToggleType={onToggleType}
/>
</Stack>
</Box>
<Divider />
<Stack direction="row" spacing={1} className="p-4" sx={{ justifyContent: "flex-end" }}>
<Button onClick={onClose}>Cancel</Button>
<Button variant="contained" onClick={onSave} disabled={isSaving}>
{isSaving ? "Saving..." : mode === "add" ? "Add Service" : "Save Changes"}
</Button>
</Stack>
</Box>
</Drawer>
);
}
type EditorFieldsProps = Pick<
EditorProps,
"mode" | "form" | "trades" | "templates" | "canChangeActiveState" | "onUpdate" | "onToggleType"
>;
function ServiceEditorFields({
mode,
form,
trades,
templates,
canChangeActiveState,
onUpdate,
onToggleType,
}: EditorFieldsProps) {
return (
<>
<Typography variant="subtitle2" className="font-semibold">
Service Information
</Typography>
<TextField
label="Service Name"
value={form.name}
required
fullWidth
onChange={(event) => onUpdate("name", event.target.value)}
placeholder="e.g., HVAC Inspection"
/>
<FormControl fullWidth required>
<InputLabel id="service-trade-label">Trade</InputLabel>
<Select
labelId="service-trade-label"
label="Trade"
value={form.trade}
onChange={(event) => onUpdate("trade", event.target.value)}
>
{trades.map((trade) => (
<MenuItem key={trade} value={trade}>
{trade}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth required>
<InputLabel id="service-icon-label">Icon</InputLabel>
<Select
labelId="service-icon-label"
label="Icon"
value={form.iconKey}
onChange={(event) => onUpdate("iconKey", event.target.value)}
renderValue={(value) => {
const icon = ICONS.find((item) => item.key === value);
return icon ? (
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<icon.Icon size={18} />
<span>{icon.label}</span>
</Stack>
) : (
"Pick an icon"
);
}}
>
{ICONS.map(({ key, label, Icon }) => (
<MenuItem key={key} value={key}>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Icon size={18} />
<span>{label}</span>
</Stack>
</MenuItem>
))}
</Select>
<FormHelperText>Pre-selected from the Trade; you can change it.</FormHelperText>
</FormControl>
<Typography variant="subtitle2" className="font-semibold">
Supported Work Order Types
</Typography>
<Stack direction="row" sx={{ flexWrap: "wrap", gap: 1 }}>
{SERVICE_WORK_ORDER_TYPES.map((type) => (
<FormControlLabel
key={type}
control={
<Checkbox
checked={form.supportedWorkOrderTypes.includes(type)}
onChange={() => onToggleType(type)}
/>
}
label={type}
/>
))}
</Stack>
{mode === "edit" && canChangeActiveState && (
<FormControlLabel
control={
<Switch
checked={form.isActive ?? true}
onChange={(event) => onUpdate("isActive", event.target.checked)}
/>
}
label="Active"
/>
)}
<Divider />
<Typography variant="subtitle2" className="font-semibold">
Completion Document
</Typography>
<FormControlLabel
control={
<Switch
checked={form.requiresCompletionDocument}
onChange={(event) => {
onUpdate("requiresCompletionDocument", event.target.checked);
if (!event.target.checked) onUpdate("completionDocTemplateId", null);
}}
/>
}
label="Requires completion document"
/>
{form.requiresCompletionDocument && (
<Autocomplete
options={templates}
value={templates.find((template) => template.id === form.completionDocTemplateId) ?? null}
onChange={(_, template) => onUpdate("completionDocTemplateId", template?.id ?? null)}
getOptionLabel={(template) => template.name}
isOptionEqualToValue={(option, value) => String(option.id) === String(value.id)}
renderInput={(params) => (
<TextField {...params} label="Linked Template" placeholder="Select a template" />
)}
/>
)}
<Typography variant="caption" color="text.secondary">
Manage the document&apos;s own content on the Completion Document Templates page.
</Typography>
</>
);
}
function AlertMessage({ message }: { message: string }) {
return (
<Box className="rounded border border-red-200 bg-red-50 p-2 text-sm text-red-700">
{message}
</Box>
);
}
type DeactivateProps = {
service: Service | null;
open: boolean;
isPending: boolean;
onClose: () => void;
onConfirm: () => void;
};
export function DeactivateServiceDialog({
service,
open,
isPending,
onClose,
onConfirm,
}: DeactivateProps) {
return (
<Dialog open={open} onClose={onClose}>
<DialogTitle>Deactivate this service?</DialogTitle>
<DialogContent>
<DialogContentText>
{service?.name} will no longer be selectable for new work orders. Existing work orders
will keep showing this service.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Cancel</Button>
<Button color="warning" variant="contained" onClick={onConfirm} disabled={isPending}>
Deactivate
</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -0,0 +1,279 @@
import {
Alert,
Box,
Button,
CircularProgress,
Stack,
Tab,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Tabs,
TextField,
Typography,
} from "@mui/material";
import { Plus, Search } from "lucide-react";
import { SettingsNav } from "@/components/common/settings-nav";
import {
useServicesRegistryController,
type ServiceStatus,
} from "@/domain/services/use-cases/use-services-registry-controller";
import type { Service } from "@/domain/services/types/service";
import {
DeactivateServiceDialog,
ServiceDetailsDrawer,
ServiceEditorDrawer,
ServiceIcon,
} from "@/app/(protected)/settings/_components/services-registry-components";
function statusTabLabel(label: string, count: number) {
return (
<Box className="flex items-center gap-2">
<Box component="span">{label}</Box>
<Box
component="span"
className="min-w-5 rounded-full px-1.5 py-0.5 text-[10px] font-semibold leading-none"
sx={{ backgroundColor: "action.hover", color: "text.secondary" }}
>
{count}
</Box>
</Box>
);
}
export function ServicesRegistryView() {
const controller = useServicesRegistryController();
return (
<Box className="p-4 md:p-8">
<SettingsNav />
<Stack
direction={{ xs: "column", sm: "row" }}
spacing={2}
className="mb-5"
sx={{ justifyContent: "space-between", alignItems: { xs: "stretch", sm: "flex-start" } }}
>
<Box>
<Typography variant="h4" component="h1" className="font-display font-extrabold">
Services
</Typography>
<Typography variant="body2" color="text.secondary" className="mt-1">
{controller.services.length} service{controller.services.length === 1 ? "" : "s"} ·{" "}
{controller.activeCount} active
</Typography>
</Box>
{controller.canManage && (
<Button
variant="contained"
onClick={controller.openCreate}
startIcon={<Plus size={16} />}
>
Add Service
</Button>
)}
</Stack>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2} className="mb-4">
<TextField
size="small"
value={controller.search}
onChange={(event) => controller.setSearch(event.target.value)}
placeholder="Search service or trade..."
className="flex-1"
slotProps={{ htmlInput: { "aria-label": "Search services" } }}
/>
</Stack>
<Tabs
value={controller.status}
onChange={(_event, value: ServiceStatus) => controller.setStatus(value)}
aria-label="Service status"
className="mb-4"
sx={{
minHeight: 36,
borderBottom: (theme) => `1px solid ${theme.palette.divider}`,
"& .MuiTab-root": {
minHeight: 36,
px: 2,
py: 0,
fontSize: 13,
fontWeight: 500,
textTransform: "none",
},
"& .Mui-selected": { fontWeight: 600 },
"& .MuiTabs-indicator": { height: 2 },
}}
>
<Tab
value="active"
label={statusTabLabel("Active", controller.activeCount)}
aria-label={`Active (${controller.activeCount})`}
/>
<Tab
value="inactive"
label={statusTabLabel("Inactive", controller.inactiveCount)}
aria-label={`Inactive (${controller.inactiveCount})`}
/>
</Tabs>
{Boolean(controller.error) && (
<Alert severity="error">{controller.error?.message || "Failed to load services"}</Alert>
)}
{controller.isLoading ? (
<Box className="flex justify-center py-16">
<CircularProgress size={28} />
</Box>
) : controller.filteredServices.length === 0 ? (
<EmptyServices
search={Boolean(controller.search)}
status={controller.status}
onAdd={controller.canManage ? controller.openCreate : undefined}
/>
) : (
<ServicesTable services={controller.filteredServices} onSelect={controller.setSelected} />
)}
{!controller.isLoading && (
<Stack
direction="row"
className="mt-2"
sx={{
justifyContent: "space-between",
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
pt: 1,
}}
>
<Typography variant="caption" color="text.secondary">
{controller.filteredServices.length} {controller.status} service
{controller.filteredServices.length === 1 ? "" : "s"}
{controller.search.trim() ? " · filtered" : ""}
</Typography>
<Typography variant="caption" color="text.secondary">
{controller.activeCount} active · {controller.inactiveCount} inactive
</Typography>
</Stack>
)}
<ServiceDetailsDrawer
service={controller.selected}
open={controller.selected !== null && controller.editorMode === null}
canManage={controller.canManage}
canDeactivate={controller.canDeactivate}
onClose={() => controller.setSelected(null)}
onEdit={controller.openEdit}
onDeactivate={controller.setDeactivateTarget}
/>
<ServiceEditorDrawer
mode={controller.editorMode}
form={controller.form}
formError={controller.formError}
trades={controller.trades}
templates={controller.templates}
canChangeActiveState={controller.canDeactivate}
isSaving={controller.isSaving}
onClose={controller.closeEditor}
onSave={controller.save}
onUpdate={controller.updateForm}
onToggleType={controller.toggleType}
/>
<DeactivateServiceDialog
service={controller.deactivateTarget}
open={controller.deactivateTarget !== null}
isPending={controller.isDeactivating}
onClose={() => controller.setDeactivateTarget(null)}
onConfirm={controller.confirmDeactivate}
/>
</Box>
);
}
function ServicesTable({
services,
onSelect,
}: {
services: Service[];
onSelect: (service: Service) => void;
}) {
return (
<Box className="overflow-x-auto rounded-lg border border-border bg-background">
<Table aria-label="Services">
<TableHead>
<TableRow>
<TableCell>Service</TableCell>
<TableCell>Trade</TableCell>
<TableCell>Completion Document</TableCell>
<TableCell>Status</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{services.map((service) => (
<TableRow
hover
key={String(service.id)}
onClick={() => onSelect(service)}
sx={{ cursor: "pointer" }}
>
<TableCell>
<Stack direction="row" spacing={1.25} sx={{ alignItems: "center" }}>
<Box className="flex size-8 items-center justify-center rounded-md bg-primary/10 text-primary">
<ServiceIcon iconKey={service.iconKey} />
</Box>
<Box>
<Typography variant="body2" className="font-semibold">
{service.name}
</Typography>
<Typography variant="caption" color="text.secondary">
{service.supportedWorkOrderTypes.join(" · ")}
</Typography>
</Box>
</Stack>
</TableCell>
<TableCell>{service.trade}</TableCell>
<TableCell>
{service.requiresCompletionDocument
? service.completionDocTemplate?.name || "No template linked yet"
: "Not required"}
</TableCell>
<TableCell>{service.isActive ? "Active" : "Inactive"}</TableCell>
<TableCell align="right">›</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
);
}
function EmptyServices({
search,
status,
onAdd,
}: {
search: boolean;
status: ServiceStatus;
onAdd?: () => void;
}) {
return (
<Box className="flex flex-col items-center rounded-lg border border-dashed border-border bg-background px-4 py-16 text-center">
<Box sx={{ color: "text.secondary", mb: 2 }} data-testid="services-empty-icon">
<Search size={22} aria-hidden="true" />
</Box>
<Typography variant="body1" className="font-semibold">
{search ? "No services found" : `No ${status} services configured yet`}
</Typography>
<Typography variant="body2" color="text.secondary" className="mt-1">
{search
? "Try a different search, or add a new service."
: `There are no ${status} services matching this view.`}
</Typography>
{onAdd !== undefined && (
<Button variant="outlined" className="mt-4" onClick={onAdd}>
Add Service
</Button>
)}
</Box>
);
}

View file

@ -0,0 +1,5 @@
import { ServicesRegistryView } from "@/app/(protected)/settings/_components/services-registry-view";
export default function ServicesPage() {
return <ServicesRegistryView />;
}

View file

@ -97,12 +97,13 @@ export function CompDocDialog({
const rowVersion = boardDetail?.info.rowVersion || row.rowVersion;
const isDocumentsReadOnly = isWorkOrderDocumentsReadOnly(boardDetail?.info.status ?? row.status);
// Uploading a signed PDF finalizes through the same Generate flow, so the
// completedDate/address prerequisites still persist before docStatus Yes.
// The upload endpoint finalizes the document (DocStatus=Yes) and bumps the row version in
// one transaction, so uploading a signed PDF only refreshes state and the UI — a follow-up
// Generate patch would carry the pre-upload version and 409 after a successful upload.
const upload = useCompDocPdfUpload({
workOrderId: row.id,
rowVersion,
onUploaded: dialog.handleGenerate,
onUploaded: dialog.markSignedPdfUploaded,
});
// A signed PDF on file means the document exists even if docState has not caught up.

View file

@ -20,7 +20,6 @@ import {
isWorkOrderFullyLocked,
} from "@/domain/work-orders/utils/work-order-lock";
import { isWorkOrderDocumentsReadOnly } from "@/domain/work-orders/utils/work-order-media-limits";
import { todayIso } from "@/domain/work-orders/utils/wizard-date-utils";
import { useAuthContext } from "@/providers/auth-context";
import { useSlideOverEditState } from "./use-slide-over-edit-state";
import { useSlideOverMediaState } from "./use-slide-over-media-state";
@ -37,22 +36,17 @@ type UseWorkOrderSlideOverArgs = {
saving?: boolean;
};
type CompletionUploadMutate = (
input: { file: File; workOrderVersion: string },
options: { onSuccess: () => Promise<void> | void },
) => void;
type CompletionUploadMutate = (input: { file: File; workOrderVersion: string }) => void;
function uploadSlideOverCompletionPdf({
file,
row,
infoSource,
onSave,
mutate,
}: {
file: File;
row: WorkOrderTableRow | null;
infoSource: WorkOrderTableRow | null | undefined;
onSave: UseWorkOrderSlideOverArgs["onSave"];
mutate: CompletionUploadMutate;
}) {
const version = infoSource?.rowVersion;
@ -60,27 +54,11 @@ function uploadSlideOverCompletionPdf({
toast.error("Missing work order version. Refresh and try again.");
return;
}
mutate(
{ file, workOrderVersion: version },
{
onSuccess: async () => {
const patch: WorkOrderTablePatch = { docStatus: "Yes" };
if (!infoSource.completedDate) {
patch.completedDate = todayIso();
}
try {
await new Promise<void>((resolve, reject) => {
onSave(row.id, patch, {
onSuccess: () => resolve(),
onError: (error) => reject(error),
});
});
} catch {
toast.error("Uploaded PDF but failed to update document status. Refresh and retry.");
}
},
},
);
// The upload transitions the completion document to Yes and bumps the work-order row
// version in the same server transaction; the mutation's onSuccess refreshes the board.
// A client docStatus patch here would carry the pre-upload version and 409 after a
// successful upload, so it is intentionally omitted.
mutate({ file, workOrderVersion: version });
}
function resolveSlideOverSource(
@ -167,7 +145,6 @@ export function useWorkOrderSlideOver({
file,
row,
infoSource,
onSave,
mutate: uploadCompletionMutation.mutate,
});
};

View file

@ -162,11 +162,22 @@ export function createCompDocDialogActions({
printDoc(buildDocHtml(row, { notes, generatedAt }, docFields));
}
// Uploading a signed PDF finalizes the document server-side: the endpoint sets DocStatus=Yes
// and bumps the work-order row version in one transaction. Reflect that in the UI and refresh
// authoritative state, but do not re-run the Generate persist — that patch would carry the
// pre-upload row version and 409, surfacing an error right after a successful upload.
function markSignedPdfUploaded() {
setGeneratedAt(fmtDocNow());
setDocState("generated");
refreshAuthoritativeState();
}
return {
handleMarkNotRequired,
handleUndo,
handleSaveDraft,
handleGenerate,
handleDownloadPdf,
markSignedPdfUploaded,
};
}

View file

@ -2,6 +2,7 @@ import { NavLink } from "react-router";
import { Stack } from "@mui/material";
const SETTINGS_LINKS = [
{ path: "/settings/services", label: "Services" },
{ path: "/settings/dropdowns", label: "Dropdown Options" },
{ path: "/settings/task-templates", label: "Task Templates" },
] as const;

View file

@ -1,18 +1,48 @@
import { apiGet } from "@/api/api";
import { apiGet, apiPost, apiPostNoContent, apiPut } from "@/api/api";
import { API_PATHS } from "@/api/api-paths";
import { handleApiResponse } from "@/api/handle-api-response";
import { mapServices } from "@/domain/services/mappers/service-mapper";
import type { Service } from "@/domain/services/types/service";
import {
mapCompletionDocTemplates,
mapService,
mapServices,
} from "@/domain/services/mappers/service-mapper";
import type {
CompletionDocTemplateOption,
Service,
ServiceInput,
} from "@/domain/services/types/service";
import { WORK_ORDER_TYPE_API } from "@/domain/work-orders/types/work-order-board";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
const SERVICE_WORK_ORDER_TYPE_API = {
PM: 2,
Emergency: 3,
Reactive: 6,
} as const;
const TYPE_TO_API: Record<Exclude<WOType, "Overdue">, number> = {
PM: WORK_ORDER_TYPE_API.PM,
Reactive: WORK_ORDER_TYPE_API.Reactive,
Emergency: WORK_ORDER_TYPE_API.Emergency,
};
function toApiInput(input: ServiceInput) {
return {
...input,
supportedWorkOrderTypes: input.supportedWorkOrderTypes.map(
(type) => SERVICE_WORK_ORDER_TYPE_API[type],
),
};
}
export const servicesApi = {
getAll: async (isActive?: boolean): Promise<Service[]> => {
const data = await apiGet<unknown>(API_PATHS.services.list, {
searchParams: isActive === undefined ? undefined : { isActive },
});
return mapServices(handleApiResponse(data));
},
getActive: async (): Promise<Service[]> => {
const data = await apiGet<unknown>(API_PATHS.services.list, {
searchParams: { isActive: true },
@ -27,4 +57,23 @@ export const servicesApi = {
});
return mapServices(handleApiResponse(data));
},
getCompletionTemplates: async (): Promise<CompletionDocTemplateOption[]> => {
const data = await apiGet<unknown>(API_PATHS.workOrder.completionTemplates);
return mapCompletionDocTemplates(handleApiResponse(data));
},
create: async (input: ServiceInput): Promise<Service> => {
const data = await apiPost<unknown>(API_PATHS.services.list, toApiInput(input));
return mapService(handleApiResponse(data));
},
update: async (id: string | number, input: ServiceInput): Promise<Service> => {
const data = await apiPut<unknown>(API_PATHS.services.byId(id), toApiInput(input));
return mapService(handleApiResponse(data));
},
deactivate: async (id: string | number): Promise<void> => {
await apiPostNoContent(API_PATHS.services.deactivate(id));
},
};

View file

@ -1,62 +1,77 @@
import type { Service } from "@/domain/services/types/service";
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
import type {
CompletionDocTemplateOption,
LinkedServiceTemplate,
Service,
ServiceWorkOrderType,
} from "@/domain/services/types/service";
function asRecord(raw: unknown): Record<string, unknown> {
return typeof raw === "object" && raw !== null ? (raw as Record<string, unknown>) : {};
}
function readId(item: Record<string, unknown>): string | number {
const value = item.id ?? item.Id;
return typeof value === "string" || typeof value === "number" ? value : "";
}
function readString(item: Record<string, unknown>, ...keys: string[]): string {
function readString(record: Record<string, unknown>, ...keys: string[]): string {
for (const key of keys) {
const value = item[key];
const value = record[key];
if (typeof value === "string") return value.trim();
if (typeof value === "number") return String(value);
}
return "";
}
function readBool(item: Record<string, unknown>, ...keys: string[]): boolean {
function readId(record: Record<string, unknown>): string | number {
const value = record.id ?? record.Id;
return typeof value === "string" || typeof value === "number" ? value : "";
}
function readBool(record: Record<string, unknown>, ...keys: string[]): boolean {
for (const key of keys) {
const value = item[key];
const value = record[key];
if (typeof value === "boolean") return value;
}
return false;
}
function mapWorkOrderType(value: unknown): WOType | null {
function mapWorkOrderType(value: unknown): ServiceWorkOrderType | null {
if (typeof value === "number") {
return value === 2 ? "PM" : value === 3 ? "Emergency" : value === 6 ? "Reactive" : null;
}
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
return normalized === "pm" || normalized === "preventivemaintenance"
? "PM"
: normalized === "emergency"
? "Emergency"
: normalized === "reactive"
? "Reactive"
: null;
}
return null;
if (typeof value !== "string") return null;
const normalized = value.trim().toLowerCase();
return normalized === "pm" || normalized === "preventivemaintenance"
? "PM"
: normalized === "emergency"
? "Emergency"
: normalized === "reactive"
? "Reactive"
: null;
}
function mapTemplate(raw: unknown): LinkedServiceTemplate | null {
if (!raw) return null;
const record = asRecord(raw);
const id = readId(record);
return id === "" ? null : { id, name: readString(record, "name", "Name") };
}
export function mapService(raw: unknown): Service {
const item = asRecord(raw);
const types = item.supportedWorkOrderTypes ?? item.SupportedWorkOrderTypes;
const record = asRecord(raw);
const types = record.supportedWorkOrderTypes ?? record.SupportedWorkOrderTypes;
return {
id: readId(item),
name: readString(item, "name", "Name"),
trade: readString(item, "trade", "Trade"),
iconKey: readString(item, "iconKey", "IconKey"),
id: readId(record),
name: readString(record, "name", "Name"),
trade: readString(record, "trade", "Trade"),
iconKey: readString(record, "iconKey", "IconKey"),
requiresCompletionDocument: readBool(
item,
record,
"requiresCompletionDocument",
"RequiresCompletionDocument",
),
isActive: readBool(item, "isActive", "IsActive"),
completionDocTemplate:
mapTemplate(record.completionDocTemplate ?? record.CompletionDocTemplate) ??
mapTemplate({
id: record.completionDocTemplateId ?? record.CompletionDocTemplateId,
}),
isActive: readBool(record, "isActive", "IsActive"),
supportedWorkOrderTypes: Array.isArray(types)
? types.flatMap((value) => {
const mapped = mapWorkOrderType(value);
@ -67,5 +82,34 @@ export function mapService(raw: unknown): Service {
}
export function mapServices(raw: unknown): Service[] {
return Array.isArray(raw) ? raw.map(mapService).filter((service) => service.id !== "") : [];
const items = asRecord(raw);
const payload = Array.isArray(raw)
? raw
: (items.items ?? items.Items ?? items.data ?? items.Data);
return Array.isArray(payload)
? payload.map(mapService).filter((service) => service.id !== "")
: [];
}
export function mapCompletionDocTemplates(raw: unknown): CompletionDocTemplateOption[] {
const payload = Array.isArray(raw)
? raw
: (() => {
const record = asRecord(raw);
return record.items ?? record.Items ?? record.data ?? record.Data;
})();
return Array.isArray(payload)
? payload.flatMap((item) => {
const record = asRecord(item);
const id = readId(record);
return id === ""
? []
: [
{
id,
name: readString(record, "name", "Name"),
} satisfies CompletionDocTemplateOption,
];
})
: [];
}

View file

@ -1,4 +1,13 @@
import type { WOType } from "@/domain/work-orders/types/work-order-wizard";
export const SERVICE_WORK_ORDER_TYPES = ["PM", "Reactive", "Emergency"] as const;
export type ServiceWorkOrderType = (typeof SERVICE_WORK_ORDER_TYPES)[number];
export interface LinkedServiceTemplate {
id: string | number;
name: string;
}
export type CompletionDocTemplateOption = LinkedServiceTemplate;
export interface Service {
id: string | number;
@ -6,6 +15,17 @@ export interface Service {
trade: string;
iconKey: string;
requiresCompletionDocument: boolean;
completionDocTemplate: LinkedServiceTemplate | null;
isActive: boolean;
supportedWorkOrderTypes: WOType[];
supportedWorkOrderTypes: ServiceWorkOrderType[];
}
export interface ServiceInput {
name: string;
trade: string;
iconKey: string;
requiresCompletionDocument: boolean;
completionDocTemplateId: number | null;
supportedWorkOrderTypes: ServiceWorkOrderType[];
isActive?: boolean;
}

View file

@ -0,0 +1,283 @@
import { useEffect, useMemo, useState } from "react";
import { useAuthContext } from "@/providers/auth-context";
import { hasUserRole, isAdminUser } from "@/lib/auth/user-utils";
import { useVendorFacets } from "@/domain/vendors/use-cases/use-vendor-facets";
import type { Service, ServiceInput, ServiceWorkOrderType } from "@/domain/services/types/service";
import {
useCreateService,
useCompletionDocTemplates,
useDeactivateService,
useServices,
useUpdateService,
} from "@/domain/services/use-cases/use-services";
export type ServiceStatus = "active" | "inactive";
export type ServiceForm = Omit<ServiceInput, "completionDocTemplateId"> & {
completionDocTemplateId: string | number | null;
};
export const EMPTY_SERVICE_FORM: ServiceForm = {
name: "",
trade: "",
iconKey: "",
requiresCompletionDocument: false,
completionDocTemplateId: null,
supportedWorkOrderTypes: ["PM", "Reactive", "Emergency"],
};
// Keyed to the canonical TradeCatalog names the backend accepts (SH-247), not
// the legacy DropdownOptions seed. A trade with no explicit default falls back
// to "wrench" where this map is read.
export const DEFAULT_ICON_BY_TRADE: Record<string, string> = {
HVAC: "droplets",
"Plumbing & Water Systems": "droplets",
Electrical: "zap",
"General Building & Handyman": "hammer",
};
export function buildServiceInput(
form: ServiceForm,
includeActiveState = false,
): {
input: ServiceInput | null;
error: string;
} {
const name = form.name.trim();
if (!name || !form.trade || !form.iconKey) {
return { input: null, error: "Service Name, Trade, and Icon are required." };
}
if (!form.supportedWorkOrderTypes.length) {
return { input: null, error: "Select at least one Work Order Type." };
}
if (form.requiresCompletionDocument && form.completionDocTemplateId == null) {
return {
input: null,
error: "Select a completion document template when the document is required.",
};
}
const templateId = form.completionDocTemplateId;
const { isActive, ...editableFields } = form;
return {
input: {
...editableFields,
name,
completionDocTemplateId: templateId == null || templateId === "" ? null : Number(templateId),
...(includeActiveState && isActive !== undefined ? { isActive } : {}),
},
error: "",
};
}
function emptyForm(): ServiceForm {
return {
...EMPTY_SERVICE_FORM,
supportedWorkOrderTypes: [...EMPTY_SERVICE_FORM.supportedWorkOrderTypes],
};
}
function formFromService(service: Service): ServiceForm {
return {
name: service.name,
trade: service.trade,
iconKey: service.iconKey,
requiresCompletionDocument: service.requiresCompletionDocument,
completionDocTemplateId: service.completionDocTemplate?.id ?? null,
supportedWorkOrderTypes: [...service.supportedWorkOrderTypes],
isActive: service.isActive,
};
}
// Owns the add/edit form lifecycle so the top-level controller stays within the
// changed-file maintainability budget. Mutations are passed in so `isSaving` and
// `closeEditor` share the exact instances the controller reports on.
function useServiceEditor(
canonicalTrades: string[],
canDeactivate: boolean,
createService: ReturnType<typeof useCreateService>,
updateService: ReturnType<typeof useUpdateService>,
) {
const [selected, setSelected] = useState<Service | null>(null);
const [editorMode, setEditorMode] = useState<"add" | "edit" | null>(null);
const [form, setForm] = useState<ServiceForm>(emptyForm);
const [iconTouched, setIconTouched] = useState(false);
const [formError, setFormError] = useState("");
// Keep the trade stored on the service being edited selectable even if it was
// later deactivated in the canonical Trade catalog, so editing never silently
// blanks the field and forces an unrelated trade change.
const trades = useMemo(() => {
if (form.trade && !canonicalTrades.includes(form.trade)) {
return [form.trade, ...canonicalTrades];
}
return canonicalTrades;
}, [canonicalTrades, form.trade]);
useEffect(() => {
if (!iconTouched && form.trade) {
setForm((current) => ({
...current,
iconKey: DEFAULT_ICON_BY_TRADE[current.trade] ?? "wrench",
}));
}
}, [form.trade, iconTouched]);
const openCreate = () => {
setSelected(null);
setForm(emptyForm());
setIconTouched(false);
setFormError("");
setEditorMode("add");
};
const openEdit = (service: Service) => {
setSelected(service);
setForm(formFromService(service));
setIconTouched(true);
setFormError("");
setEditorMode("edit");
};
const closeEditor = () => {
if (!createService.isPending && !updateService.isPending) setEditorMode(null);
};
const updateForm = <K extends keyof ServiceForm>(key: K, value: ServiceForm[K]) => {
if (key === "trade") setIconTouched(false);
setFormError("");
setForm((current) => ({ ...current, [key]: value }));
};
const toggleType = (type: ServiceWorkOrderType) => {
setForm((current) => {
const supported = current.supportedWorkOrderTypes.includes(type)
? current.supportedWorkOrderTypes.filter((item) => item !== type)
: [...current.supportedWorkOrderTypes, type];
return { ...current, supportedWorkOrderTypes: supported };
});
};
const save = () => {
const validation = buildServiceInput(form, canDeactivate);
if (!validation.input) {
setFormError(validation.error);
return;
}
const input = validation.input;
if (editorMode === "add") {
createService.mutate(input, {
onSuccess: (created) => {
setEditorMode(null);
setSelected(created);
},
});
} else if (editorMode === "edit" && selected) {
updateService.mutate(
{ id: selected.id, input },
{
onSuccess: (updated) => {
setEditorMode(null);
setSelected(updated);
},
},
);
}
};
return {
selected,
setSelected,
editorMode,
form,
formError,
trades,
openCreate,
openEdit,
closeEditor,
updateForm,
toggleType,
save,
};
}
export function useServicesRegistryController() {
const { user } = useAuthContext();
const canManage = hasUserRole(user?.userRoles, "scheduler") || isAdminUser(user?.userRoles);
const canDeactivate = isAdminUser(user?.userRoles);
const [status, setStatus] = useState<ServiceStatus>("active");
const [search, setSearch] = useState("");
const [deactivateTarget, setDeactivateTarget] = useState<Service | null>(null);
const servicesQuery = useServices();
// The backend rejects any Trade outside the canonical TradeCatalog (400
// TradeInvalid), so the picker is sourced from the server-owned canonical
// Trade reference exposed on Vendor/facets — the same list the vendor roster
// form uses — never the editable DropdownOptions "Trade" category, whose
// legacy seed values ("Plumbing", "General", ...) would fail on save.
const facetsQuery = useVendorFacets();
const canonicalTrades = useMemo(() => facetsQuery.data?.trades ?? [], [facetsQuery.data]);
const { data: templates = [] } = useCompletionDocTemplates();
const createService = useCreateService();
const updateService = useUpdateService();
const deactivateService = useDeactivateService();
const editor = useServiceEditor(canonicalTrades, canDeactivate, createService, updateService);
const services = useMemo(() => servicesQuery.data ?? [], [servicesQuery.data]);
const activeCount = services.filter((service) => service.isActive).length;
const inactiveCount = services.length - activeCount;
const filteredServices = useMemo(() => {
const needle = search.trim().toLowerCase();
return services
.filter((service) => (status === "active" ? service.isActive : !service.isActive))
.filter(
(service) =>
!needle ||
service.name.toLowerCase().includes(needle) ||
service.trade.toLowerCase().includes(needle),
);
}, [search, services, status]);
const confirmDeactivate = () => {
if (!deactivateTarget) return;
deactivateService.mutate(deactivateTarget.id, {
onSuccess: () => {
setDeactivateTarget(null);
editor.setSelected(null);
},
});
};
return {
canManage,
canDeactivate,
status,
setStatus,
search,
setSearch,
selected: editor.selected,
setSelected: editor.setSelected,
editorMode: editor.editorMode,
form: editor.form,
formError: editor.formError,
trades: editor.trades,
templates,
filteredServices,
services,
activeCount,
inactiveCount,
isLoading: servicesQuery.isLoading,
error: servicesQuery.error,
openCreate: editor.openCreate,
openEdit: editor.openEdit,
closeEditor: editor.closeEditor,
updateForm: editor.updateForm,
toggleType: editor.toggleType,
save: editor.save,
deactivateTarget,
setDeactivateTarget,
confirmDeactivate,
isSaving: createService.isPending || updateService.isPending,
isDeactivating: deactivateService.isPending,
};
}

View file

@ -0,0 +1,62 @@
import { useMutation, useQuery, useQueryClient, type UseQueryResult } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { servicesApi } from "@/domain/services/api/services-api";
import type {
CompletionDocTemplateOption,
Service,
ServiceInput,
} from "@/domain/services/types/service";
import { queryKeys } from "@/infra/query-key/query-key";
export function useServices(isActive?: boolean): UseQueryResult<Service[], Error> {
return useQuery({
queryKey: queryKeys.services.list(isActive),
queryFn: () => servicesApi.getAll(isActive),
staleTime: 60 * 1000,
});
}
export function useCompletionDocTemplates(): UseQueryResult<CompletionDocTemplateOption[], Error> {
return useQuery({
queryKey: queryKeys.services.completionTemplates(),
queryFn: servicesApi.getCompletionTemplates,
staleTime: 5 * 60 * 1000,
});
}
export function useCreateService() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: ServiceInput) => servicesApi.create(input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.services.all });
toast.success("Service added");
},
onError: (error: Error) => toast.error(error.message || "Failed to add service"),
});
}
export function useUpdateService() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, input }: { id: string | number; input: ServiceInput }) =>
servicesApi.update(id, input),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.services.all });
toast.success("Service updated");
},
onError: (error: Error) => toast.error(error.message || "Failed to update service"),
});
}
export function useDeactivateService() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string | number) => servicesApi.deactivate(id),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.services.all });
toast.success("Service deactivated");
},
onError: (error: Error) => toast.error(error.message || "Failed to deactivate service"),
});
}

View file

@ -216,10 +216,14 @@ export const workOrderBoardDocumentsApi = {
workOrderId: string | number,
mediaId: string | number,
): Promise<Blob> => {
// No `credentials: "include"`: this endpoint authenticates with the bearer token the ky
// beforeRequest hook attaches, not cookies. In credentialed mode the browser rejects the
// API's `Access-Control-Allow-Origin: *` outright, so the fetch would throw and the document
// would never open.
const response = await apiRequestRaw(
"get",
API_PATHS.workOrder.mediaContent(workOrderId, mediaId),
{ credentials: "include", throwHttpErrors: false },
{ throwHttpErrors: false },
"workOrderBoardDocumentsApi.getMediaContent",
);

View file

@ -75,6 +75,8 @@ export const queryKeys = {
},
services: {
all: ["services"] as const,
list: (isActive?: boolean) => [...queryKeys.services.all, "list", isActive ?? "all"] as const,
completionTemplates: () => [...queryKeys.services.all, "completionTemplates"] as const,
active: () => [...queryKeys.services.all, "active"] as const,
byWorkOrderType: (workOrderType: string) =>
[...queryKeys.services.all, "byWorkOrderType", workOrderType] as const,

View file

@ -6,13 +6,18 @@ export function getPrimaryUserRole(
}
export function isAdminUser(userRoles: string | null | undefined): boolean {
return hasUserRole(userRoles, "admin");
}
export function hasUserRole(userRoles: string | null | undefined, expectedRole: string): boolean {
if (!userRoles) {
return false;
}
const normalizedExpectedRole = expectedRole.trim().toLowerCase();
return userRoles
.split(",")
.map((role) => role.trim().toLowerCase())
.includes("admin");
.includes(normalizedExpectedRole);
}
/**

View file

@ -0,0 +1,153 @@
import { screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ServicesRegistryView } from "@/app/(protected)/settings/_components/services-registry-view";
import type { Service } from "@/domain/services/types/service";
import { renderWithProviders } from "@/test/test-utils";
const useServices = vi.fn();
const mutate = vi.fn();
vi.mock("@/providers/auth-context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/providers/auth-context")>();
return {
...actual,
useAuthContext: () => ({ user: { id: "u1", userRoles: "admin,scheduler" } }),
};
});
vi.mock("@/domain/services/use-cases/use-services", () => ({
useServices: (...args: unknown[]) => useServices(...args),
useCompletionDocTemplates: () => ({ data: [] }),
useCreateService: () => ({ mutate, isPending: false }),
useUpdateService: () => ({ mutate, isPending: false }),
useDeactivateService: () => ({ mutate, isPending: false }),
}));
vi.mock("@/domain/vendors/use-cases/use-vendor-facets", () => ({
useVendorFacets: () => ({
data: {
companies: [],
trades: ["Plumbing & Water Systems", "Electrical", "HVAC", "General Building & Handyman"],
areas: [],
locations: [],
jobBuckets: [],
},
}),
}));
const activeHvac: Service = {
id: 1,
name: "HVAC Repair",
trade: "HVAC",
iconKey: "zap",
requiresCompletionDocument: false,
completionDocTemplate: null,
isActive: true,
supportedWorkOrderTypes: ["PM", "Reactive"],
};
const activeDrain: Service = {
...activeHvac,
id: 2,
name: "Drain Cleaning",
trade: "Plumbing",
};
const inactiveFire: Service = {
...activeHvac,
id: 3,
name: "Fire Suppression",
trade: "Fire Protection",
isActive: false,
};
function setup(services: Service[] = [activeHvac, activeDrain, inactiveFire]) {
useServices.mockReturnValue({
data: services,
isLoading: false,
error: null,
});
return renderWithProviders(<ServicesRegistryView />, { route: "/settings/services" });
}
describe("ServicesRegistryView", () => {
it("renders Active and Inactive tabs with live counts from the unfiltered list", () => {
setup();
expect(useServices).toHaveBeenCalledWith();
expect(screen.getByRole("tab", { name: "Active (2)" })).toHaveAttribute(
"aria-selected",
"true",
);
expect(screen.getByRole("tab", { name: "Inactive (1)" })).toHaveAttribute(
"aria-selected",
"false",
);
expect(screen.getByText("3 services · 2 active")).toBeInTheDocument();
expect(screen.getByRole("row", { name: /HVAC Repair/ })).toBeInTheDocument();
expect(screen.queryByRole("row", { name: /Fire Suppression/ })).not.toBeInTheDocument();
});
it("switches to the Inactive tab and shows only inactive services", async () => {
setup();
await userEvent.click(screen.getByRole("tab", { name: "Inactive (1)" }));
expect(screen.getByRole("row", { name: /Fire Suppression/ })).toBeInTheDocument();
expect(screen.queryByRole("row", { name: /HVAC Repair/ })).not.toBeInTheDocument();
expect(screen.getByText("1 inactive service")).toBeInTheDocument();
expect(screen.getByText("2 active · 1 inactive")).toBeInTheDocument();
});
it("applies the search within the selected tab and reports the filtered footer count", async () => {
setup();
await userEvent.type(screen.getByRole("textbox", { name: "Search services" }), "drain");
expect(screen.getByRole("row", { name: /Drain Cleaning/ })).toBeInTheDocument();
expect(screen.queryByRole("row", { name: /HVAC Repair/ })).not.toBeInTheDocument();
expect(screen.getByText("1 active service · filtered")).toBeInTheDocument();
expect(screen.getByText("2 active · 1 inactive")).toBeInTheDocument();
});
it("shows an icon, message, and try-a-different-search hint when a tab and search combination is empty", async () => {
setup();
await userEvent.click(screen.getByRole("tab", { name: "Inactive (1)" }));
await userEvent.type(screen.getByRole("textbox", { name: "Search services" }), "hvac");
expect(screen.getByTestId("services-empty-icon")).toBeInTheDocument();
expect(screen.getByText("No services found")).toBeInTheDocument();
expect(screen.getByText("Try a different search, or add a new service.")).toBeInTheDocument();
expect(screen.queryByRole("row", { name: /Fire Suppression/ })).not.toBeInTheDocument();
});
it("offers only canonical trades and sends the selected canonical trade to the create payload", async () => {
setup();
await userEvent.click(screen.getByRole("button", { name: "Add Service" }));
await userEvent.type(screen.getByRole("textbox", { name: "Service Name" }), "Panel Swap");
await userEvent.click(screen.getByRole("combobox", { name: "Trade" }));
// Canonical names are offered; legacy DropdownOptions seeds ("Plumbing") are not.
expect(screen.getByRole("option", { name: "Plumbing & Water Systems" })).toBeInTheDocument();
expect(screen.queryByRole("option", { name: "Plumbing" })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("option", { name: "Electrical" }));
const saveButtons = screen.getAllByRole("button", { name: "Add Service" });
await userEvent.click(saveButtons[saveButtons.length - 1]);
expect(mutate).toHaveBeenCalledTimes(1);
expect(mutate.mock.calls[0][0]).toMatchObject({ trade: "Electrical" });
});
it("summarizes the filtered count and the overall split in the footer without a search", () => {
setup();
const footer = screen.getByText("2 active services").closest("div");
expect(footer).not.toBeNull();
expect(within(footer as HTMLElement).getByText("2 active · 1 inactive")).toBeInTheDocument();
expect(screen.queryByText(/filtered/)).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,153 @@
import { fireEvent, render, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { toast } from "react-toastify";
import { CompDocDialog } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-dialog";
import type { CompDocData, CompDocSaveStatus } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
type SaveHandler = (data: CompDocData, status: CompDocSaveStatus) => void | Promise<void>;
vi.mock("@/domain/locations/use-cases/use-location-detail", () => ({
useLocationDetail: () => ({ data: undefined }),
}));
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderBoardDetail: () => ({ data: undefined, isLoading: false }),
}));
// A completion-doc upload that settles successfully. The backend has already set
// DocStatus=Yes and bumped the work-order row version inside the same transaction,
// so any client follow-up patch would carry a stale version.
vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
useUploadCompletionDoc: () => ({
mutate: (_vars: unknown, options?: { onSuccess?: () => void | Promise<void> }) => {
void options?.onSuccess?.();
},
isPending: false,
}),
}));
vi.mock("react-toastify", () => ({
toast: Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn() }),
}));
vi.mock("@/app/(protected)/workorders/_components/comp-doc/comp-doc-draft-toast", () => ({
showCompDocDraftSavedToast: vi.fn(),
}));
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
const defaults: WorkOrderTableRow = {
id: 42,
woNumber: "WO-42",
site: "SEA1",
type: "PM",
severity: null,
dispatcherId: "u1",
dispatcherName: "Dispatcher",
scheduledOn: "2026-06-01",
completedDate: "2026-07-01",
targetWeek: "",
dayGroup: "",
dayLabel: "",
location: "Seattle",
locationId: "mock-loc-1",
pm: "HVAC",
serviceNotes: "",
extraServices: [],
vendorId: "",
company: "Vendor Co",
tech: "Tech One",
techPhone: "",
apptStart: "",
apptEnd: "",
apptTime: "",
status: "Scheduled",
docStatus: "No",
pocName: "Amazon Rep",
pocPhone: "",
pocNotes: "",
additionalContacts: [],
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
originalWeek: "",
originalDate: "",
isPastDue: false,
scheduleWeekOnly: false,
mediaCount: 0,
title: "PM — SEA1",
priority: "Sev 3",
primaryDispatchId: null,
rowVersion: "v1",
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
upliftSummary: { hasUplift: false, pendingCount: 0 },
};
return { ...defaults, ...overrides };
}
describe("CompDocDialog signed PDF upload (SH-380)", () => {
beforeEach(() => {
vi.mocked(toast.error).mockReset();
});
function uploadSignedPdf(onSave: SaveHandler) {
const onDateCompletedChange = vi.fn().mockResolvedValue(undefined);
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
render(
<QueryClientProvider client={client}>
<CompDocDialog
row={baseRow()}
open
onOpenChange={vi.fn()}
onSave={onSave}
onDateCompletedChange={onDateCompletedChange}
/>
</QueryClientProvider>,
);
const fileInput = document.querySelector('input[type="file"]');
expect(fileInput).not.toBeNull();
fireEvent.change(fileInput!, {
target: {
files: [
new File([new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d])], "signed.pdf", {
type: "application/pdf",
}),
],
},
});
return { onSave, onDateCompletedChange };
}
it("does not re-run the docStatus persist after a successful upload", async () => {
// The server already marked the document generated; a client docStatus patch here would
// carry the pre-upload row version and 409. It must not run.
const onSave: SaveHandler = vi.fn(() =>
Promise.reject(new Error("The record was modified by another user. Refresh and retry.")),
);
uploadSignedPdf(onSave);
await waitFor(() => {
expect(vi.mocked(toast.error)).not.toHaveBeenCalled();
});
expect(onSave).not.toHaveBeenCalled();
});
it("surfaces no error toast to the user when the signed PDF upload settles", async () => {
const onSave: SaveHandler = vi.fn(() => Promise.reject(new Error("conflict")));
const { onDateCompletedChange } = uploadSignedPdf(onSave);
await waitFor(() => {
expect(vi.mocked(toast.error)).not.toHaveBeenCalled();
});
expect(onDateCompletedChange).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,145 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { toast } from "react-toastify";
import { WorkOrderSlideOver } from "@/app/(protected)/workorders/_components/detail/work-order-slide-over";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
vi.mock("@/providers/auth-context", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/providers/auth-context")>();
return { ...actual, useAuthContext: () => ({ user: { id: "u1" } }) };
});
vi.mock("@/domain/work-orders/use-cases/use-work-order-detail", () => ({
useWorkOrderBoardDetail: () => ({
data: undefined,
isLoading: false,
isFetching: false,
error: null,
refetch: vi.fn(),
}),
}));
// A completion-doc upload that settles successfully. The mock forwards any onSuccess the
// caller passes; the fix means the slide-over passes none (no follow-up docStatus patch).
vi.mock("@/domain/work-orders/use-cases/use-work-order-mutations", () => ({
useAddWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }),
useUpdateWorkOrderComment: () => ({ mutate: vi.fn(), isPending: false }),
useUploadWorkOrderMedia: () => ({ mutate: vi.fn(), mutateAsync: vi.fn(), isPending: false }),
useUpdateWorkOrderMediaCategory: () => ({ mutate: vi.fn(), isPending: false }),
useDeleteWorkOrderMedia: () => ({ mutate: vi.fn(), isPending: false }),
useUploadCompletionDoc: () => ({
mutate: (_vars: unknown, options?: { onSuccess?: () => void | Promise<void> }) => {
void options?.onSuccess?.();
},
isPending: false,
}),
}));
vi.mock("react-toastify", () => ({ toast: { error: vi.fn(), success: vi.fn() } }));
function baseRow(overrides: Partial<WorkOrderTableRow> = {}): WorkOrderTableRow {
return {
id: 42,
woNumber: "WO-42",
site: "SEA1",
type: "PM",
severity: null,
dispatcherId: "u1",
dispatcherName: "Dispatcher",
scheduledOn: "2026-07-20",
completedDate: "",
targetWeek: "",
dayGroup: "07/20",
dayLabel: "Mon",
location: "Seattle",
locationId: "loc-1",
pm: "HVAC",
serviceNotes: "",
extraServices: [],
company: "Vendor Co",
tech: "Tech",
techPhone: "",
vendorId: "v1",
apptStart: "",
apptEnd: "",
apptTime: "",
status: "Scheduled",
docStatus: "No",
pocName: "",
pocPhone: "",
pocNotes: "",
additionalContacts: [],
dueDate: "",
rescheduleCount: 0,
carriedOver: 0,
originalWeek: "",
originalDate: "",
isPastDue: false,
scheduleWeekOnly: false,
mediaCount: 0,
title: "PM — SEA1",
priority: "Sev 3",
primaryDispatchId: null,
rowVersion: "v1",
dispatchRowVersion: "",
flagColor: null,
isAddOn: false,
upliftSummary: { hasUplift: false, pendingCount: 0 },
...overrides,
};
}
describe("WorkOrderSlideOver signed PDF upload (SH-380)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("does not issue a docStatus patch or surface an error after a successful upload", async () => {
const row = baseRow();
// A follow-up docStatus patch would carry the pre-upload version and reject; assert none runs.
const onSave = vi.fn(
(
_id: string | number,
_patch: Partial<WorkOrderTableRow>,
options?: { onSuccess?: () => void; onError?: (error: Error) => void },
) => {
options?.onError?.(new Error("The record was modified by another user."));
},
);
render(
<WorkOrderSlideOver
row={row}
editMode={false}
users={[{ id: "u1", name: "Dispatcher" }]}
sites={[{ id: "loc-1", name: "Seattle" }]}
vendors={[]}
pmTypes={["HVAC"]}
allOrders={[row]}
onSave={onSave}
onOpenCompletionDoc={vi.fn()}
onCancelWO={vi.fn()}
onClose={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole("tab", { name: /completion doc/i }));
const pdfInput = document.querySelector('input[accept="application/pdf,.pdf"]');
expect(pdfInput).not.toBeNull();
fireEvent.change(pdfInput!, {
target: {
files: [
new File([new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d])], "signed.pdf", {
type: "application/pdf",
}),
],
},
});
await waitFor(() => {
expect(vi.mocked(toast.error)).not.toHaveBeenCalled();
});
expect(onSave).not.toHaveBeenCalled();
});
});

View file

@ -1,10 +1,17 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { API_PATHS } from "@/api/api-paths";
import type { ServiceInput } from "@/domain/services/types/service";
const apiGet = vi.fn();
const apiPost = vi.fn();
const apiPostNoContent = vi.fn();
const apiPut = vi.fn();
vi.mock("@/api/api", () => ({
apiGet: (...args: unknown[]) => apiGet(...args),
apiPost: (...args: unknown[]) => apiPost(...args),
apiPostNoContent: (...args: unknown[]) => apiPostNoContent(...args),
apiPut: (...args: unknown[]) => apiPut(...args),
}));
import { servicesApi } from "@/domain/services/api/services-api";
@ -12,6 +19,38 @@ import { servicesApi } from "@/domain/services/api/services-api";
describe("servicesApi", () => {
beforeEach(() => {
apiGet.mockReset();
apiPost.mockReset();
apiPostNoContent.mockReset();
apiPut.mockReset();
});
it("loads active services and maps the linked completion template", async () => {
apiGet.mockResolvedValue({
items: [
{
Id: 7,
Name: "HVAC inspection",
Trade: "HVAC",
IconKey: "droplets",
RequiresCompletionDocument: true,
CompletionDocTemplate: { Id: 4, Name: "HVAC checklist" },
IsActive: true,
SupportedWorkOrderTypes: [2, 6],
},
],
});
const services = await servicesApi.getAll(true);
expect(apiGet).toHaveBeenCalledWith(API_PATHS.services.list, {
searchParams: { isActive: true },
});
expect(services[0]).toMatchObject({
id: 7,
name: "HVAC inspection",
completionDocTemplate: { id: 4, name: "HVAC checklist" },
supportedWorkOrderTypes: ["PM", "Reactive"],
});
});
it("loads active services filtered by the selected work-order type", async () => {
@ -45,4 +84,49 @@ describe("servicesApi", () => {
expect(apiGet).not.toHaveBeenCalled();
expect(services).toEqual([]);
});
it("creates, updates, and deactivates through the registry routes", async () => {
const input: ServiceInput = {
name: "Boiler inspection",
trade: "HVAC",
iconKey: "droplets",
requiresCompletionDocument: false,
completionDocTemplateId: null,
supportedWorkOrderTypes: ["PM"],
};
apiPost.mockResolvedValue({ id: 8, ...input, isActive: true, supportedWorkOrderTypes: [2] });
apiPut.mockResolvedValue({
id: 8,
...input,
completionDocTemplateId: 12,
isActive: true,
supportedWorkOrderTypes: [2],
});
apiPostNoContent.mockResolvedValue(undefined);
await servicesApi.create(input);
const updated = await servicesApi.update(8, input);
await servicesApi.deactivate(8);
expect(apiPost).toHaveBeenNthCalledWith(1, API_PATHS.services.list, {
...input,
supportedWorkOrderTypes: [2],
});
expect(apiPostNoContent).toHaveBeenCalledWith(API_PATHS.services.deactivate(8));
expect(apiPut).toHaveBeenCalledWith(API_PATHS.services.byId(8), {
...input,
supportedWorkOrderTypes: [2],
});
expect(updated.completionDocTemplate).toEqual({ id: 12, name: "" });
});
it("loads active Completion Document Template options from the completion route", async () => {
apiGet.mockResolvedValue([{ Id: 4, Name: "HVAC checklist", IsActive: true }]);
const templates = await servicesApi.getCompletionTemplates();
expect(apiGet).toHaveBeenCalledWith(API_PATHS.workOrder.completionTemplates);
expect(templates).toEqual([{ id: 4, name: "HVAC checklist" }]);
});
});

View file

@ -1022,8 +1022,14 @@ describe("workOrdersApi.getMediaContent", () => {
expect(result).toBe(blob);
expect(apiGetFn).toHaveBeenCalledWith(
API_PATHS.workOrder.mediaContent(10, 12),
expect.objectContaining({ credentials: "include", throwHttpErrors: false }),
expect.objectContaining({ throwHttpErrors: false }),
);
// Regression guard (SH-382): a credentialed cross-origin fetch is rejected by the browser
// against the API's `Access-Control-Allow-Origin: *`, so the content request must not opt
// into credentials mode — otherwise the document never opens. JSDOM cannot enforce CORS, so
// this asserts the request shape rather than reproducing the block.
const [, options] = apiGetFn.mock.calls[0] as [string, Record<string, unknown>];
expect(options).not.toHaveProperty("credentials");
});
it("throws ApiError on 404 without inventing a filename", async () => {

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { canViewAllDispatchersOnDashboard, isAdminUser } from "@/lib/auth/user-utils";
import { canViewAllDispatchersOnDashboard, hasUserRole, isAdminUser } from "@/lib/auth/user-utils";
describe("isAdminUser", () => {
it("returns true when Admin appears in roles", () => {
@ -14,6 +14,13 @@ describe("isAdminUser", () => {
});
});
describe("hasUserRole", () => {
it("finds a role regardless of its position", () => {
expect(hasUserRole("Dispatcher,Scheduler", "Scheduler")).toBe(true);
expect(hasUserRole("Dispatcher", "Scheduler")).toBe(false);
});
});
describe("canViewAllDispatchersOnDashboard", () => {
it("grants the dashboard picker to Admin and Scheduler", () => {
expect(canViewAllDispatchersOnDashboard("Admin")).toBe(true);