Compare commits

...

26 commits

Author SHA1 Message Date
Alexandre Brandizzi
7f023c5a45
Merge 0aecdf1b10 into 73c7094102 2026-09-28 20:50:33 -03:00
Alexandre Brandizzi
73c7094102
Merge pull request #275 from Sea-Haven-Industries/feat/ab/sh-335-wo-site-dialog
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
Work Order Site dialog saves contacts and notes to the site
2026-09-28 23:45:53 +00:00
Alexandre Brandizzi
301fe44b0c fix(workorders): Site dialog loads legacy site contacts and locks fields while saving
A site saved before the contacts list existed maps with no contacts, only
the legacy contact/phone fields. The site-record sync read the contacts
list alone, so the dialog blanked the POC, blocked a notes-only Save until
the contact was retyped, and when the work order had no POC the legacy
autofill made the diff report a contact edit nobody made. The legacy
contact is now the site's main contact for display, baseline and request.

Save sends the values it had when clicked, so the dialog's fields are now
disabled while the site update is in flight instead of accepting edits
that neither save would include.

The site-update mutation already toasts its own failure, so it sets
meta.suppressErrorToast and the global mutation toast no longer repeats it.
2026-09-25 19:52:03 -03:00
Alexandre Brandizzi
53dcb6af9e fix(workorders): Site dialog hides per-contact notes when it edits the site record
Site contacts carry only a name and a phone (LocationContact, and the
Sites page contact rows), so PATCH contact-info has nowhere to put an
additional contact's note. The Notes box on each additional contact card
let a dispatcher type one under copy saying contacts are saved to the
site record, but the note only reached this work order's copy and no
other work order at the site ever saw it.

The additional-contact Notes box is now hidden while the dialog edits the
site record. It stays where contacts are saved to the work order only:
inline create, the wizard, and an existing work order whose site record
failed to load.
2026-09-25 19:36:15 -03:00
Alexandre Brandizzi
6a6d519ed7
Merge pull request #276 from Sea-Haven-Industries/fix/ab/sh-403-reset-code-hardening
Some checks failed
Frontend checks / static (push) Has been cancelled
Frontend checks / build (push) Has been cancelled
Frontend checks / unit (push) Has been cancelled
Frontend checks / Visual regression (push) Has been cancelled
Frontend checks / browser-smoke (push) Has been cancelled
Frontend checks / governance (push) Has been cancelled
Deploy Web / Resolve target (push) Has been cancelled
Frontend checks / ci-complete (push) Has been cancelled
Deploy Web / Deploy SPA to (push) Has been cancelled
fix(auth): send reset email and code in the request body
2026-09-25 22:21:20 +00:00
Alexandre Brandizzi
a554aa049e
Merge pull request #282 from Sea-Haven-Industries/fix/ab/sh-408-has-uplift-live-status
fix(work-orders): keep uplift history reachable on closed work orders
2026-09-25 22:21:17 +00:00
Alexandre Brandizzi
f36a07c99c fix(workorders): Site dialog clears the previous site's contact on a site switch
Picking another site on an existing work order kept the old site's POC
name, phone and notes in the fields. When the new site's record failed
to load, the dialog never synced it, Save stayed enabled, and the work
order pointing at the new site was saved with the previous site's
contact and notes as its override.

A site switch now empties the POC name and phone for every work order,
and the notes for existing ones, so the fields hold either the new
site's record or what the dispatcher types for it.
2026-09-25 19:17:24 -03:00
Alexandre Brandizzi
810fa5d5b0
Merge pull request #283 from Sea-Haven-Industries/fix/ab/sh-406-no-revoke-auto-approved
Hide Revoke from admins on auto-approved uplifts
2026-09-25 22:17:01 +00:00
Alexandre Brandizzi
a27695b217
Merge pull request #274 from Sea-Haven-Industries/feat/ab/sh-385-invite-registration
feat(team-members): invite registration wizard and resend invite (SH-385)
2026-09-25 22:16:36 +00:00
Alexandre Brandizzi
3cdb5e7d7b
Merge pull request #281 from Sea-Haven-Industries/feat/ab/sh-317-a4-pagination
feat(comp-doc): A4 multi-page completion document preview
2026-09-25 22:16:32 +00:00
Alexandre Brandizzi
b55938104a fix(workorders): Site dialog sends only complete contacts without site ids to the work order
The Site dialog loads extra contacts from the site record with their site
contact ids so the site request can update rows in place. buildSiteDialogPatch
copied those rows verbatim into the work-order patch, so onSave received
siteContactId (and half-filled rows) even though the type documents that the
id is never sent on work-order patches. The wire serializer already stripped
both, but the patch handed to the board did not.

Run the patch's extra contacts through normalizeAdditionalContacts, the same
helper the work-order serializer uses, so the patch carries only complete
name/phone/notes rows. An emptied list still clears the work order's copy.
The site request keeps its ids because it is built from the form fields.
2026-09-25 19:04:32 -03:00
Alexandre Brandizzi
727588ab52 fix(comp-doc): keep the page pill current on re-layout and stamp NOT REQUIRED on the sheet
The page being read is re-derived after every render and every observed
resize, not only when the page count changes. The NOT REQUIRED stamp is
rendered inside the first sheet, above its greyed-out content, instead of
floating over the scroll panel.
2026-09-25 18:49:13 -03:00
Alexandre Brandizzi
a2d03f9461 fix(work-orders): keep uplift history reachable on closed work orders
The Uplift column hid its trigger on a Completed or Canceled work order
with no live uplift, so once cancelled/revoked uplifts stop counting as
"has uplift", their read-only history had no way in. The cell now always
opens the uplifts dialog; closed work orders show a dash instead of Manage.
2026-09-25 18:14:53 -03:00
Alexandre Brandizzi
18127c0f30 fix(uplifts): hide Revoke from admins on auto-approved uplifts
Admins may revoke only admin-approved uplifts. The Work Order Uplifts
dialog showed Revoke to whoever requested an auto-approved uplift, so an
admin requester saw it and was sent into the optional-reason revoke flow.
2026-09-25 18:14:06 -03:00
Alexandre Brandizzi
dc2565e3fe feat(comp-doc): paginate the completion document preview into A4 sheets
Both the template preview and a work order's completion document render
through the shared frame, which now splits the letterhead and blocks into
A4-sized sheets with the brand bars on every sheet and shows Page X of Y
in the preview pill.
2026-09-25 18:03:33 -03:00
Alexandre Brandizzi
0b654bb07c Merge remote-tracking branch 'origin/main' into feat/ab/sh-385-invite-registration
# Conflicts:
#	src/api/api-paths.ts
2026-09-25 17:46:17 -03:00
Alexandre Brandizzi
3b7cf83332 fix(team-members): offer a retry when the invite cannot be checked
The invite page showed "invalid or has expired, ask your admin for a new
invite" for any failed resolve call, including offline, timeouts and 5xx.
A member on a flaky connection was sent to their admin for a resend, which
revokes a token that was still valid.

Only the server's invalid_invite answer now ends on "Invite unavailable".
Any other failure shows "Couldn't load your invite" with a Try again
button that re-runs resolve, without a global error toast. Regression
tests cover a network rejection and a 500 followed by a successful retry.
2026-09-25 17:45:08 -03:00
Alexandre Brandizzi
f068beb45e
Merge pull request #277 from Sea-Haven-Industries/fix/ab/sh-402-effective-permissions
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
fix(completion-docs): show template actions from the user's effective permissions
2026-09-25 20:31:22 +00:00
Alexandre Brandizzi
06f70c193d Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	src/test/app/(protected)/completion-docs/completion-docs-page.test.tsx
#	src/test/app/(protected)/completion-docs/completion-template-dialog.test.tsx
2026-09-25 17:26:10 -03:00
Alexandre Brandizzi
7c38ebd579 Merge commit 'd281cbd663b7b4106f104cb40207e4d5d1271387' into HEAD 2026-09-25 16:51:25 -03:00
Alexandre Brandizzi
383d5c3c6c feat(team-members): warn when the invite to a corrected email could not be sent 2026-09-25 12:54:31 -03:00
Alexandre Brandizzi
9c19287172 test(completion-docs): keep the dialog closed when a save finishes after closing 2026-09-25 12:53:43 -03:00
Alexandre Brandizzi
d5cd0659a2 fix(completion-docs): gate template actions on the user's effective permissions
Add Template, Edit and Delete now follow the permissions the server
computes for the signed-in user, role defaults plus their individual
overrides, instead of role defaults alone. The actions stay hidden while
the permissions load or when they fail to load.
2026-09-25 12:34:01 -03:00
Alexandre Brandizzi
ca42d6e59f fix(team-members): cover mid-wizard invite loss and accept empty invite responses 2026-09-25 12:33:37 -03:00
Alexandre Brandizzi
976cd908e3 feat(team-members): invite registration wizard and resend invite (SH-385) 2026-09-25 12:26:42 -03:00
Alexandre Brandizzi
297830ed35 fix(auth): send reset email and code in the request body
The verify call now carries the email the code was issued to, which the
API requires, and neither call puts the email or code in the URL.
2026-09-25 12:22:55 -03:00
65 changed files with 3074 additions and 255 deletions

View file

@ -86,6 +86,14 @@ export const API_PATHS = {
getList: "User",
create: "team-members",
detail: "team-members",
invite: (id: string | number) => `team-members/${encodeURIComponent(String(id))}/invite`,
myPermissions: "team-members/me/permissions",
},
teamMemberInvite: {
resolve: "team-member-invites/resolve",
sendCode: "team-member-invites/send-code",
verifyCode: "team-member-invites/verify-code",
complete: "team-member-invites/complete",
},
contact: {
getList: "Contact/GetContactList",

View file

@ -1,4 +1,5 @@
import { FileText } from "lucide-react";
import { useState, type ReactNode } from "react";
import { Text } from "@/components/ui/text";
import { CompDocDocumentFrame } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-document-frame";
import { CompDocPreviewHeader } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-preview-header";
@ -10,6 +11,10 @@ import {
} from "@/domain/completion-templates/constants/completion-template-constants";
import type { CompletionTemplateProcedure } from "@/domain/completion-templates/types/completion-template";
import { descriptionLines } from "@/domain/completion-templates/utils/description-lines";
import {
pageIndicatorLabel,
type PagePosition,
} from "@/domain/work-orders/utils/completion-doc-pagination";
export interface CompletionTemplatePreviewValues {
name: string;
@ -22,7 +27,7 @@ type CompletionTemplatePreviewProps = {
isEditing: boolean;
};
function PreviewPill({ isEditing }: { isEditing: boolean }) {
function PreviewPill({ isEditing, page }: { isEditing: boolean; page: PagePosition }) {
return (
<div className="flex shrink-0 items-center justify-center" style={{ padding: "12px 0" }}>
<span
@ -40,6 +45,7 @@ function PreviewPill({ isEditing }: { isEditing: boolean }) {
>
<FileText size={13} />
{isEditing ? "Live preview" : "Document preview"}
{pageIndicatorLabel(page)}
</span>
</div>
);
@ -70,41 +76,40 @@ function PreviewTitle({ name }: { name: string }) {
);
}
function PreviewProcedures({ procedures }: { procedures: CompletionTemplateProcedure[] }) {
/** One block per procedure (each kept whole on a page), or the empty-state line. */
function previewSections(procedures: CompletionTemplateProcedure[]): ReactNode[] {
if (procedures.length === 0) {
return (
return [
<Text
key="empty"
variant="body"
sx={{ fontSize: 12.5, color: "#9CA3AF", margin: "16px 0 0", fontStyle: "italic" }}
>
{COMPLETION_TEMPLATE_COPY.noProcedurePreview}
</Text>
);
</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>
return procedures.map((procedure, index) => (
<section key={index} aria-label={`Preview procedure ${index + 1}`}>
<CompDocProcedureHeading>
{procedure.name.trim() || `Procedure ${index + 1}`}
</CompDocProcedureHeading>
{descriptionLines(procedure.description).map((line, lineIndex) => (
<Text
key={lineIndex}
variant="body"
sx={{ fontSize: 13, lineHeight: 1.5, color: "#161E38", margin: "4px 0" }}
>
{line}
</Text>
))}
</>
);
</section>
));
}
/** The template rendered on the same page, header and safety bullets as a work order's document. */
export function CompletionTemplatePreview({ values, isEditing }: CompletionTemplatePreviewProps) {
const [page, setPage] = useState<PagePosition>({ current: 1, total: 1 });
const note = values.extraSafetyNote?.trim() ?? "";
return (
<div
@ -115,18 +120,19 @@ export function CompletionTemplatePreview({ values, isEditing }: CompletionTempl
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>
<PreviewPill isEditing={isEditing} page={page} />
<div className="flex min-h-0 flex-1 flex-col" data-testid="completion-template-preview">
<CompDocDocumentFrame
onPageChange={setPage}
header={
<>
<CompDocPreviewHeader />
<PreviewTitle name={values.name} />
<CompDocSafetyBullets extra={note === "" ? [] : [note]} />
</>
}
sections={previewSections(values.procedures)}
/>
</div>
</div>
);

View file

@ -1,8 +1,4 @@
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"];
import { hasPermission, usePermissions } from "@/domain/auth/use-cases/use-permissions";
export interface CompletionTemplatePermissions {
canCreate: boolean;
@ -10,27 +6,12 @@ export interface CompletionTemplatePermissions {
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)]));
/** Template actions follow the signed-in user's effective permissions, never their role alone. */
export function useCompletionTemplatePermissions(): CompletionTemplatePermissions {
const permissions = usePermissions();
return {
canCreate: granted.has("createCompletionDocTemplates"),
canEdit: granted.has("editCompletionDocTemplates"),
canDelete: granted.has("deleteCompletionDocTemplates"),
canCreate: hasPermission(permissions, "createCompletionDocTemplates"),
canEdit: hasPermission(permissions, "editCompletionDocTemplates"),
canDelete: hasPermission(permissions, "deleteCompletionDocTemplates"),
};
}
export function useCompletionTemplatePermissions(): CompletionTemplatePermissions {
const { user } = useAuthContext();
return completionTemplatePermissions(user?.userRoles);
}

View file

@ -0,0 +1,26 @@
import { Mail } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { TeamMemberDetail } from "@/domain/team-members/types/team-member";
import { useResendTeamMemberInvite } from "@/domain/team-members/use-cases/use-resend-team-member-invite";
/** Re-sends the registration invite to an active member who has not registered yet. */
export function ResendInviteButton({ member }: { member: TeamMemberDetail }) {
const resendInvite = useResendTeamMemberInvite();
if (!member.pendingRegistration || member.status !== "Active") {
return null;
}
return (
<Button
variant="outline"
onClick={() => resendInvite.mutate({ id: member.id, email: member.email })}
loading={resendInvite.isPending}
loadingText="Sending…"
startIcon={<Mail size={15} aria-hidden="true" />}
>
Resend invite
</Button>
);
}

View file

@ -37,6 +37,7 @@ import {
import { getFormErrors } from "./team-member-permissions";
import { TeamMemberFormFields, type TeamMemberForm } from "./add-team-member-dialog";
import { canonicalRole, resolveFormColor } from "./team-member-form-options";
import { ResendInviteButton } from "./resend-invite-button";
import {
useTeamMemberDetail,
useUpdateTeamMember,
@ -206,6 +207,7 @@ function MemberEditForm({
}
function DrawerFooter({
member,
editing,
form,
setForm,
@ -215,6 +217,7 @@ function DrawerFooter({
onEdit,
onSave,
}: {
member: TeamMemberDetail;
editing: boolean;
form: TeamMemberForm;
setForm: Dispatch<SetStateAction<TeamMemberForm>>;
@ -257,7 +260,8 @@ function DrawerFooter({
</Stack>
</Stack>
) : (
<Stack direction="row" sx={{ justifyContent: "flex-end" }}>
<Stack direction="row" sx={{ justifyContent: "flex-end", gap: 1.5 }}>
<ResendInviteButton member={member} />
<Button variant="contained" onClick={onEdit}>
Edit
</Button>
@ -475,6 +479,7 @@ export function TeamMemberDetailDrawer({
{currentMember !== undefined && (
<DrawerFooter
member={currentMember}
editing={editor.editing}
form={editor.form}
setForm={editor.setForm}

View file

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

View file

@ -1,55 +1,121 @@
import type { ReactNode } from "react";
import {
PAGE_BOTTOM_BAR_PX,
PAGE_BOTTOM_PADDING_PX,
PAGE_GAP_PX,
PAGE_TOP_BAR_PX,
type PagePosition,
} from "@/domain/work-orders/utils/completion-doc-pagination";
import { useCompDocPagination } from "./use-comp-doc-pagination";
type CompDocDocumentFrameProps = {
children: ReactNode;
/** Greyed-out page, as for a document marked not required. */
/** Letterhead and opening content, printed once at the top of page 1. */
header: ReactNode;
/** Blocks (procedures, comments) laid out across pages; never split. */
sections: readonly ReactNode[];
/** Greyed-out pages, as for a document marked not required. */
dimmed?: boolean;
/** Rendered over the page, outside the dimmed layer. */
/** Stamped over the first sheet, outside the dimmed layer. */
overlay?: ReactNode;
/** Page being read and page count, updated on layout and on scroll. */
onPageChange?: (position: PagePosition) => void;
};
/** The printed page: white sheet between the top and bottom brand bands. */
/** Contains child margins so a block's measured height is its full height. */
const flowRoot = { display: "flow-root" } as const;
/**
* The printed document: A4 sheets stacked in a scrollable column, the letterhead
* on the first sheet only and the brand bars at the top and bottom of every sheet.
*/
export function CompDocDocumentFrame({
children,
header,
sections,
dimmed = false,
overlay,
onPageChange,
}: CompDocDocumentFrameProps) {
const { pages, pageHeight, scrollerRef, sheetsRef, headerRef, blockRefs, pageRefs, onScroll } =
useCompDocPagination(sections.length, onPageChange);
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",
}}
ref={scrollerRef}
onScroll={onScroll}
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
style={{ padding: "0 20px 20px", scrollbarGutter: "stable" }}
data-testid="comp-doc-document"
>
<div
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>
<div ref={sheetsRef} className="relative">
{pages.map((blockIndices, pageIndex) => (
<div
key={pageIndex}
ref={(el) => {
pageRefs.current[pageIndex] = el;
}}
data-testid="comp-doc-page"
className="relative flex flex-col break-words"
style={{
marginTop: pageIndex > 0 ? PAGE_GAP_PX : 0,
minHeight: pageHeight,
background: "#ffffff",
borderRadius: 8,
boxShadow: "0 1px 3px rgba(0,0,0,.08), 0 12px 32px -16px rgba(0,0,0,.25)",
overflow: "hidden",
fontFamily: "var(--font-sans)",
overflowWrap: "break-word",
wordBreak: "break-word",
}}
>
<div
className="flex flex-1 flex-col"
style={{
opacity: dimmed ? 0.5 : 1,
filter: dimmed ? "grayscale(0.4)" : "none",
}}
>
<div
data-testid="comp-doc-page-top-bar"
style={{
height: PAGE_TOP_BAR_PX,
flexShrink: 0,
background: "linear-gradient(100deg,#27AAE1,#1C75BC)",
}}
/>
<div style={{ padding: `0 28px ${PAGE_BOTTOM_PADDING_PX}px` }}>
{pageIndex === 0 && (
<div ref={headerRef} style={flowRoot}>
{header}
</div>
)}
{blockIndices.map((blockIndex) => (
<div
key={blockIndex}
ref={(el) => {
blockRefs.current[blockIndex] = el;
}}
style={flowRoot}
>
{sections[blockIndex]}
</div>
))}
</div>
<div
data-testid="comp-doc-page-bottom-bar"
style={{
height: PAGE_BOTTOM_BAR_PX,
flexShrink: 0,
marginTop: "auto",
background: "linear-gradient(100deg,#1C75BC,#27AAE1)",
clipPath: "polygon(0 45%,100% 0,100% 100%,0 100%)",
}}
/>
</div>
{overlay}
{pageIndex === 0 && overlay}
</div>
))}
</div>
</div>
);
}

View file

@ -1,10 +1,15 @@
import { CheckCircle2, FileText } from "lucide-react";
import {
pageIndicatorLabel,
type PagePosition,
} from "@/domain/work-orders/utils/completion-doc-pagination";
type CompDocPreviewBadgeProps = {
isGenerated: boolean;
page: PagePosition;
};
export function CompDocPreviewBadge({ isGenerated }: CompDocPreviewBadgeProps) {
export function CompDocPreviewBadge({ isGenerated, page }: CompDocPreviewBadgeProps) {
return (
<div className="flex shrink-0 items-center justify-center" style={{ padding: "12px 0" }}>
<span
@ -26,6 +31,7 @@ export function CompDocPreviewBadge({ isGenerated }: CompDocPreviewBadgeProps) {
<FileText size={13} />
)}
{isGenerated ? "Generated document" : "Preview — not yet generated"}
{pageIndicatorLabel(page)}
</span>
</div>
);

View file

@ -3,20 +3,17 @@ import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-ta
import { procedureFor } from "@/domain/work-orders/utils/completion-doc-procedures";
import { CompDocProcedureHeading } from "./comp-doc-procedure-heading";
import { CompDocProcedureList } from "./comp-doc-procedure-list";
import { CompDocSafetyBullets } from "./comp-doc-safety-bullets";
type CompDocPreviewProcedureProps = {
row: WorkOrderTableRow;
notes: string;
};
export function CompDocPreviewProcedure({ row, notes }: CompDocPreviewProcedureProps) {
/** The work order's procedure: heading, intro and steps, kept together on one page. */
export function CompDocPreviewProcedure({ row }: CompDocPreviewProcedureProps) {
const proc = procedureFor(row.pm);
return (
<>
<CompDocSafetyBullets />
<CompDocProcedureHeading>{proc.heading}</CompDocProcedureHeading>
<Text
variant="body"
@ -30,24 +27,27 @@ export function CompDocPreviewProcedure({ row, notes }: CompDocPreviewProcedureP
{proc.intro}
</Text>
<CompDocProcedureList proc={proc} />
{notes.length > 0 && (
<>
<CompDocProcedureHeading>PM Comments</CompDocProcedureHeading>
<Text
variant="body"
sx={{
fontSize: 12.5,
lineHeight: 1.5,
color: "#161E38",
margin: 0,
whiteSpace: "pre-wrap",
}}
>
{notes}
</Text>
</>
)}
</>
);
}
/** The PM's free-text comments printed after the procedure. */
export function CompDocPreviewComments({ notes }: { notes: string }) {
return (
<>
<CompDocProcedureHeading>PM Comments</CompDocProcedureHeading>
<Text
variant="body"
sx={{
fontSize: 12.5,
lineHeight: 1.5,
color: "#161E38",
margin: 0,
whiteSpace: "pre-wrap",
}}
>
{notes}
</Text>
</>
);
}

View file

@ -1,11 +1,14 @@
import { useState } from "react";
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import type { PagePosition } from "@/domain/work-orders/utils/completion-doc-pagination";
import { CompDocDocumentFrame } from "./comp-doc-document-frame";
import { CompDocNotRequiredStamp } from "./comp-doc-not-required-stamp";
import { CompDocPreviewBadge } from "./comp-doc-preview-badge";
import { CompDocPreviewHeader } from "./comp-doc-preview-header";
import { CompDocPreviewProcedure } from "./comp-doc-preview-procedure";
import { CompDocPreviewComments, CompDocPreviewProcedure } from "./comp-doc-preview-procedure";
import { CompDocPreviewSummary } from "./comp-doc-preview-summary";
import { CompDocSafetyBullets } from "./comp-doc-safety-bullets";
type CompDocPreviewProps = {
row: WorkOrderTableRow;
@ -24,6 +27,10 @@ export function CompDocPreview({
isGenerated,
isNotRequired,
}: CompDocPreviewProps) {
const [page, setPage] = useState<PagePosition>({ current: 1, total: 1 });
const sections = [<CompDocPreviewProcedure key="procedure" row={row} />];
if (notes.length > 0) sections.push(<CompDocPreviewComments key="notes" notes={notes} />);
return (
<div
className="flex min-h-0 flex-col"
@ -33,26 +40,26 @@ export function CompDocPreview({
background: "var(--color-bg-muted)",
}}
>
<CompDocPreviewBadge isGenerated={isGenerated} />
<CompDocPreviewBadge isGenerated={isGenerated} page={page} />
<div
className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
style={{ padding: "0 20px 20px" }}
>
<CompDocDocumentFrame
dimmed={isNotRequired}
overlay={isNotRequired && <CompDocNotRequiredStamp />}
>
<CompDocPreviewHeader />
<CompDocPreviewSummary
row={row}
site={site}
generatedAt={generatedAt}
isGenerated={isGenerated}
/>
<CompDocPreviewProcedure row={row} notes={notes} />
</CompDocDocumentFrame>
</div>
<CompDocDocumentFrame
dimmed={isNotRequired}
overlay={isNotRequired && <CompDocNotRequiredStamp />}
onPageChange={setPage}
header={
<>
<CompDocPreviewHeader />
<CompDocPreviewSummary
row={row}
site={site}
generatedAt={generatedAt}
isGenerated={isGenerated}
/>
<CompDocSafetyBullets />
</>
}
sections={sections}
/>
</div>
);
}

View file

@ -0,0 +1,112 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import {
a4PageHeight,
coverAllBlocks,
currentPageAt,
pageContentHeight,
paginateBlocks,
samePages,
type PagePosition,
} from "@/domain/work-orders/utils/completion-doc-pagination";
type PageLayout = {
pages: number[][];
/** Sheet height in px; undefined until the sheets have a measurable width. */
pageHeight: number | undefined;
};
const heightOf = (el: Element | null | undefined) => el?.getBoundingClientRect().height ?? 0;
function allOnOnePage(count: number): number[][] {
return [Array.from({ length: count }, (_, i) => i)];
}
/**
* Measures the rendered letterhead and blocks and splits them into A4 sheets.
* Blocks are measured where they are rendered (every sheet has the same width),
* and re-measured after every render and whenever anything observed resizes.
*/
export function useCompDocPagination(
blockCount: number,
onPageChange?: (position: PagePosition) => void,
) {
const scrollerRef = useRef<HTMLDivElement>(null);
const sheetsRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const blockRefs = useRef<(HTMLDivElement | null)[]>([]);
const pageRefs = useRef<(HTMLDivElement | null)[]>([]);
const [layout, setLayout] = useState<PageLayout>({ pages: [[]], pageHeight: undefined });
const pages = coverAllBlocks(layout.pages, blockCount);
const measure = useCallback(() => {
const width = sheetsRef.current?.getBoundingClientRect().width ?? 0;
const next: PageLayout =
width > 0
? {
pages: paginateBlocks({
headerHeight: heightOf(headerRef.current),
blockHeights: Array.from({ length: blockCount }, (_, i) =>
heightOf(blockRefs.current[i]),
),
pageHeight: pageContentHeight(width),
}),
pageHeight: a4PageHeight(width),
}
: { pages: allOnOnePage(blockCount), pageHeight: undefined };
setLayout((prev) =>
samePages(prev.pages, next.pages) && prev.pageHeight === next.pageHeight ? prev : next,
);
}, [blockCount]);
// Block content can change on any render (typing in the editor), so measure after each one.
useLayoutEffect(() => {
measure();
});
const onPageChangeRef = useRef(onPageChange);
const totalRef = useRef(pages.length);
const lastReported = useRef<PagePosition | null>(null);
useLayoutEffect(() => {
onPageChangeRef.current = onPageChange;
totalRef.current = pages.length;
});
const report = useCallback(() => {
const total = totalRef.current;
const tops = pageRefs.current.slice(0, total).map((el) => el?.offsetTop ?? 0);
const current = Math.min(currentPageAt(tops, scrollerRef.current?.scrollTop ?? 0), total);
const last = lastReported.current;
if (last?.current === current && last.total === total) return;
lastReported.current = { current, total };
onPageChangeRef.current?.({ current, total });
}, []);
// Any re-layout can move the sheet tops under the reader, even when the page count holds.
useLayoutEffect(() => {
report();
});
// Width changes, late font/image loads and reflow do not re-render; observe them.
const layoutKey = pages.map((page) => page.join(",")).join("|");
useEffect(() => {
const observer = new ResizeObserver(() => {
measure();
report();
});
[sheetsRef.current, headerRef.current, ...blockRefs.current.slice(0, blockCount)].forEach(
(el) => el && observer.observe(el),
);
return () => observer.disconnect();
}, [measure, report, blockCount, layoutKey]);
return {
pages,
pageHeight: layout.pageHeight,
scrollerRef,
sheetsRef,
headerRef,
blockRefs,
pageRefs,
onScroll: report,
};
}

View file

@ -1,5 +1,6 @@
import { extractLocationLabel } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
import type { SitePatch } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-types";
import { normalizeAdditionalContacts } from "@/domain/work-orders/mappers/additional-contacts-mapper";
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
import type { LocationOption } from "@/domain/work-orders/types/work-order";
@ -41,7 +42,8 @@ export function buildSiteDialogPatch({
// Following the site record, the dialog's extra contacts are the site's: copy them only when edited.
const sendContacts = followsSiteRecord ? contactsDirty : contactsDirty || baselineHadContacts;
if (sendContacts) {
patch.additionalContacts = extraContacts;
// The work order keeps only complete name/phone rows; site contact ids stay on the site request.
patch.additionalContacts = normalizeAdditionalContacts(extraContacts) ?? [];
}
return patch;
}

View file

@ -54,6 +54,8 @@ export function SiteDialogPocSection({
onPocNotesChange={onPocNotesChange}
onAdditionalContactsChange={onAdditionalContactsChange}
helperText={helperText(editsSiteRecord, siteRecordUnavailable)}
// Site contacts carry only a name and phone, so a per-contact note could never reach the site.
additionalContactNotes={!editsSiteRecord || siteRecordUnavailable}
/>
);
}

View file

@ -85,7 +85,8 @@ export function SiteDialog({
Select a site and edit its point of contact
</Text>
<fieldset disabled={viewOnly} className="mt-3 space-y-3 border-0 p-0">
{/* Locked while saving: Save sends the values it had when clicked, so later edits would be lost. */}
<fieldset disabled={viewOnly || s.saving} className="mt-3 space-y-3 border-0 p-0">
<div>
<FieldLabel required={s.requiresPoc}>Site</FieldLabel>
<WizardFieldSelect

View file

@ -7,9 +7,11 @@ import {
type UpliftCellProps = {
summary: UpliftSummary;
onOpen?: () => void;
/** Closed work orders show a dash (not "Manage") that still opens the read-only history. */
readOnly?: boolean;
};
export function UpliftCell({ summary, onOpen }: UpliftCellProps) {
export function UpliftCell({ summary, onOpen, readOnly = false }: UpliftCellProps) {
const label = getUpliftCellLabel(summary);
if (!onOpen) {
@ -30,7 +32,7 @@ export function UpliftCell({ summary, onOpen }: UpliftCellProps) {
e.stopPropagation();
onOpen();
}}
title={label ? "View uplifts" : "Manage uplifts"}
title={label || readOnly ? "View uplifts" : "Manage uplifts"}
className="flex w-full items-center justify-start transition-opacity hover:opacity-80"
>
{label && pillStyle ? (
@ -40,6 +42,8 @@ export function UpliftCell({ summary, onOpen }: UpliftCellProps) {
>
{label}
</span>
) : readOnly ? (
<span className="text-[11px] text-[var(--color-text-muted)]">—</span>
) : (
<span className="text-[11px] font-semibold text-[var(--color-primary)]">Manage</span>
)}

View file

@ -97,9 +97,11 @@ export function useSiteDialogFormFields({
setContactsDirty(true);
setUserEdited(false);
setDirty(true);
if (createMode) {
setPn("");
setPp("");
// The previous site's contact never carries over, even when the new site fails to load.
setPn("");
setPp("");
if (!createMode) {
setNotes("");
}
setPocFilledFor("");
} else if (id !== pocFilledFor) {

View file

@ -1,5 +1,9 @@
import { useEffect, useState } from "react";
import type { Location, SiteContactInfoInput } from "@/domain/locations/types/location";
import type {
Location,
LocationContact,
SiteContactInfoInput,
} from "@/domain/locations/types/location";
import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
type SiteRecordFields = {
@ -99,9 +103,22 @@ export function workOrderPocAfterSiteSave(
};
}
/**
* The site's contacts in order. A site saved before the contacts list existed has none; its
* contact lives only in the legacy `contact`/`phone` fields, so that one is the main contact.
*/
function siteContacts(location: Location): LocationContact[] {
if (location.contacts && location.contacts.length > 0) {
return location.contacts;
}
const name = location.contact ?? "";
const phone = location.phone ?? "";
return name.trim() || phone.trim() ? [{ name, phone }] : [];
}
/** The site record's contacts and notes, in the dialog's field shape. */
function siteRecordValues(location: Location) {
const [main, ...others] = location.contacts ?? [];
const [main, ...others] = siteContacts(location);
return {
primaryId: main?.id,
pn: main?.name ?? "",

View file

@ -9,7 +9,7 @@ import { StatusCell } from "./cells/status-cell";
import { UpliftCell } from "./cells/uplift-cell";
import { VendorCell } from "./cells/vendor-cell";
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
import { canOpenUpliftsDialog } from "@/domain/work-orders/utils/uplift-display-utils";
import { isWorkOrderUpliftsReadOnly } from "@/domain/work-orders/utils/uplift-display-utils";
import { applyVendorTableSave } from "@/domain/work-orders/utils/work-order-feedback-toasts";
import { effectiveCompany } from "@/domain/work-orders/utils/effective-completion-snapshot";
import type { WoTableRowHandlers } from "./wo-table-row";
@ -132,12 +132,8 @@ export function WoTableRowServiceCells({
<td style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>
<UpliftCell
summary={row.upliftSummary ?? EMPTY_UPLIFT_SUMMARY}
onOpen={
canOpenUpliftsDialog(row.upliftSummary ?? EMPTY_UPLIFT_SUMMARY, row.status) &&
handlers.onOpenUplifts
? () => handlers.onOpenUplifts!(row)
: undefined
}
onOpen={handlers.onOpenUplifts ? () => handlers.onOpenUplifts!(row) : undefined}
readOnly={isWorkOrderUpliftsReadOnly(row.status)}
/>
</td>
</>

View file

@ -26,6 +26,8 @@ type PocContactFieldsProps = {
showPrimaryErrors?: boolean;
/** Labels the primary name/phone inputs with a persistent required asterisk. */
primaryRequired?: boolean;
/** Offers a Notes box on each additional contact; off where contacts save without per-contact notes. */
additionalContactNotes?: boolean;
};
function extraNameMissing(contact: WorkOrderAdditionalContact): boolean {
@ -49,11 +51,13 @@ function updateContactAt(
function AdditionalPocCard({
contact,
index,
showNotes,
onChange,
onRemove,
}: {
contact: WorkOrderAdditionalContact;
index: number;
showNotes: boolean;
onChange: (patch: Partial<WorkOrderAdditionalContact>) => void;
onRemove: () => void;
}) {
@ -115,14 +119,16 @@ function AdditionalPocCard({
</Text>
</div>
</div>
<textarea
value={contact.notes ?? ""}
onChange={(event) => onChange({ notes: event.target.value })}
placeholder="Notes…"
rows={2}
className={`${DLG_INPUT_CLS} resize-none`}
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
/>
{showNotes && (
<textarea
value={contact.notes ?? ""}
onChange={(event) => onChange({ notes: event.target.value })}
placeholder="Notes…"
rows={2}
className={`${DLG_INPUT_CLS} resize-none`}
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
/>
)}
</div>
);
}
@ -139,6 +145,7 @@ export function PocContactFields({
helperText,
showPrimaryErrors = false,
primaryRequired = false,
additionalContactNotes = true,
}: PocContactFieldsProps) {
const fieldId = useId();
const nameMissing = showPrimaryErrors && !pocName.trim();
@ -214,6 +221,7 @@ export function PocContactFields({
key={`additional-poc-${index}`}
contact={contact}
index={index}
showNotes={additionalContactNotes}
onChange={(patch) =>
onAdditionalContactsChange(updateContactAt(additionalContacts, index, patch))
}

View file

@ -12,6 +12,7 @@ type WorkOrderUpliftListItemProps = {
uplift: WorkOrderUplift;
readOnly: boolean;
currentUserId: string | number | null | undefined;
currentUserIsAdmin: boolean;
pendingAction?: boolean;
onCancel?: () => void;
onRevoke?: () => void;
@ -21,6 +22,7 @@ export function WorkOrderUpliftListItem({
uplift,
readOnly,
currentUserId,
currentUserIsAdmin,
pendingAction = false,
onCancel,
onRevoke,
@ -28,7 +30,9 @@ export function WorkOrderUpliftListItem({
const pillStyle = getUpliftStatusPillStyle(uplift.status);
const showCancel = !readOnly && uplift.status === "pending" && Boolean(onCancel);
const showRevoke =
!readOnly && canRevokeWorkOrderUplift(uplift, currentUserId) && Boolean(onRevoke);
!readOnly &&
canRevokeWorkOrderUplift(uplift, currentUserId, currentUserIsAdmin) &&
Boolean(onRevoke);
return (
<div className="rounded-lg border border-[var(--color-border)] p-3">

View file

@ -12,6 +12,7 @@ import { hasOpenWorkOrderUplift } from "@/domain/work-orders/utils/uplift-displa
type WorkOrderUpliftsDialogContentProps = {
readOnly: boolean;
currentUserId: string | number | null | undefined;
currentUserIsAdmin: boolean;
readOnlyStatusLabel?: string;
isLoading: boolean;
error: Error | null;
@ -28,6 +29,7 @@ type WorkOrderUpliftsDialogContentProps = {
export function WorkOrderUpliftsDialogContent({
readOnly,
currentUserId,
currentUserIsAdmin,
readOnlyStatusLabel,
isLoading,
error,
@ -77,6 +79,7 @@ export function WorkOrderUpliftsDialogContent({
uplift={uplift}
readOnly={readOnly}
currentUserId={currentUserId}
currentUserIsAdmin={currentUserIsAdmin}
pendingAction={actionPending}
onCancel={readOnly ? undefined : () => onCancelUplift(uplift.id)}
onRevoke={readOnly ? undefined : () => onRevokeUplift(uplift.id)}

View file

@ -15,6 +15,7 @@ import {
isWorkOrderUpliftsReadOnly,
upliftRevokeRequiresReason,
} from "@/domain/work-orders/utils/uplift-display-utils";
import { isAdminUser } from "@/lib/auth/user-utils";
import { useAuthContext } from "@/providers/auth-context";
type RevokeTarget = {
@ -78,6 +79,7 @@ export function WorkOrderUpliftsDialog({ row, open, onClose }: WorkOrderUpliftsD
<WorkOrderUpliftsDialogContent
readOnly={readOnly}
currentUserId={user?.id}
currentUserIsAdmin={isAdminUser(user?.userRoles)}
readOnlyStatusLabel={row?.status}
isLoading={isLoading}
error={error}

View file

@ -0,0 +1,100 @@
import { useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Box } from "@mui/material";
import { Button } from "@/components/ui/button";
import { FormField } from "@/components/ui/form-field";
import { Text } from "@/components/ui/text";
import { TextField } from "@/components/ui/text-field";
import {
inviteCodeStepSchema,
type InviteCodeStepValues,
} from "@/domain/team-member-invite/schemas/invite-registration-schema";
import { InviteStepHeader } from "./invite-step-header";
export type InviteCodeStepProps = {
email: string;
isVerifying: boolean;
isResending: boolean;
/** Why the entered code was refused; shown under the field. */
codeError: string | null;
/** Why a new code could not be sent. */
resendError: string | null;
onVerify: (code: string) => void;
onResend: () => void;
onCodeChange: () => void;
};
export function InviteCodeStep({
email,
isVerifying,
isResending,
codeError,
resendError,
onVerify,
onResend,
onCodeChange,
}: InviteCodeStepProps) {
const { register, handleSubmit, control } = useForm<InviteCodeStepValues>({
resolver: zodResolver(inviteCodeStepSchema),
defaultValues: { code: "" },
});
const values = useWatch({ control });
const canContinue = inviteCodeStepSchema.safeParse(values).success;
const codeField = register("code", { onChange: onCodeChange });
return (
<Box
component="form"
aria-label="Confirm your email"
onSubmit={handleSubmit((form) => onVerify(form.code))}
className="flex flex-col gap-4"
>
<InviteStepHeader
step={2}
title="Confirm your email"
subtitle={`Enter the 6-digit code sent to ${email}`}
/>
<FormField
label="Confirmation code"
htmlFor="invite-code"
required
appearance="auth"
error={codeError !== null}
helperText={codeError ?? undefined}
>
<TextField
{...codeField}
id="invite-code"
placeholder="123456"
autoComplete="one-time-code"
error={codeError !== null}
slotProps={{ htmlInput: { inputMode: "numeric", maxLength: 6 } }}
/>
</FormField>
<div className="flex flex-col items-start gap-1">
<Button type="button" variant="link" onClick={onResend} disabled={isResending}>
Resend code
</Button>
<Text variant="error" when={resendError !== null}>
{resendError}
</Text>
</div>
<Button
type="submit"
fullWidth
size="lg"
disabled={!canContinue}
loading={isVerifying}
loadingText="Checking code…"
>
Continue
</Button>
</Box>
);
}

View file

@ -0,0 +1,101 @@
import { useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Box } from "@mui/material";
import { PasswordChecklist } from "@/components/auth/password-checklist";
import { Button } from "@/components/ui/button";
import { Text } from "@/components/ui/text";
import { FormField } from "@/components/ui/form-field";
import { PasswordField } from "@/components/ui/password-field";
import {
invitePasswordStepSchema,
type InvitePasswordStepValues,
} from "@/domain/team-member-invite/schemas/invite-registration-schema";
import { InviteStepHeader } from "./invite-step-header";
export type InvitePasswordStepProps = {
name: string;
defaultPassword: string;
isSubmitting: boolean;
error: string | null;
onContinue: (password: string) => void;
};
export function InvitePasswordStep({
name,
defaultPassword,
isSubmitting,
error,
onContinue,
}: InvitePasswordStepProps) {
const {
register,
handleSubmit,
control,
formState: { errors },
} = useForm<InvitePasswordStepValues>({
resolver: zodResolver(invitePasswordStepSchema),
defaultValues: { password: defaultPassword, confirmPassword: defaultPassword },
mode: "onTouched",
});
const values = useWatch({ control });
const canContinue = invitePasswordStepSchema.safeParse(values).success;
return (
<Box
component="form"
aria-label="Create your password"
onSubmit={handleSubmit((form) => onContinue(form.password))}
className="flex flex-col gap-4"
>
<InviteStepHeader
step={1}
title="Create your password"
subtitle={`Setting up the account for ${name}`}
/>
<FormField label="Password" htmlFor="invite-password" required appearance="auth">
<PasswordField
{...register("password")}
id="invite-password"
autoComplete="new-password"
slotProps={{ htmlInput: { "aria-describedby": "invite-password-rules" } }}
/>
<PasswordChecklist id="invite-password-rules" password={values.password ?? ""} />
</FormField>
<FormField
label="Confirm password"
htmlFor="invite-confirm-password"
required
appearance="auth"
error={Boolean(errors.confirmPassword)}
helperText={errors.confirmPassword?.message}
>
<PasswordField
{...register("confirmPassword")}
id="invite-confirm-password"
autoComplete="new-password"
error={Boolean(errors.confirmPassword)}
/>
</FormField>
<Text variant="error" when={error !== null}>
{error}
</Text>
<Button
type="submit"
fullWidth
size="lg"
disabled={!canContinue}
loading={isSubmitting}
loadingText="Sending code…"
>
Continue
</Button>
</Box>
);
}

View file

@ -0,0 +1,100 @@
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Box } from "@mui/material";
import { CircleCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { FormField } from "@/components/ui/form-field";
import { Text } from "@/components/ui/text";
import { TextField } from "@/components/ui/text-field";
import {
inviteReviewStepSchema,
type InviteReviewStepValues,
} from "@/domain/team-member-invite/schemas/invite-registration-schema";
import type { TeamMemberInviteDetails } from "@/domain/team-member-invite/types/team-member-invite";
import { InviteStepHeader } from "./invite-step-header";
export type InviteReviewStepProps = {
details: TeamMemberInviteDetails;
isFinishing: boolean;
error: string | null;
onFinish: (phone: string) => void;
};
function ReadOnlyValue({ label, value }: { label: string; value: string }) {
return (
<div>
<Text variant="label" className="block">
{label}
</Text>
<Text variant="body" className="m-0 text-(length:--text-base-sm)">
{value}
</Text>
</div>
);
}
export function InviteReviewStep({ details, isFinishing, error, onFinish }: InviteReviewStepProps) {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<InviteReviewStepValues>({
resolver: zodResolver(inviteReviewStepSchema),
defaultValues: { phone: details.phone ?? "" },
mode: "onTouched",
});
return (
<Box
component="form"
aria-label="Review your info"
onSubmit={handleSubmit((form) => onFinish(form.phone))}
className="flex flex-col gap-4"
>
<InviteStepHeader
step={3}
title="Review your info"
subtitle="Name, role, and email are set by your admin — contact them to change these."
/>
<div className="grid gap-3">
<ReadOnlyValue label="Name" value={details.name} />
<ReadOnlyValue label="Role" value={details.role} />
<ReadOnlyValue label="Email" value={details.email} />
<FormField
label="Phone"
htmlFor="invite-phone"
appearance="auth"
error={Boolean(errors.phone)}
helperText={errors.phone?.message}
>
<TextField
{...register("phone")}
id="invite-phone"
type="tel"
autoComplete="tel"
placeholder="(000) 000-0000"
error={Boolean(errors.phone)}
/>
</FormField>
</div>
<Text variant="error" when={error !== null}>
{error}
</Text>
<Button
type="submit"
fullWidth
size="lg"
loading={isFinishing}
loadingText="Finishing…"
startIcon={<CircleCheck size={15} aria-hidden="true" />}
>
Finish registration
</Button>
</Box>
);
}

View file

@ -0,0 +1,46 @@
import { Text } from "@/components/ui/text";
import { cn } from "@/lib/utils";
export const INVITE_STEP_COUNT = 3;
export type InviteStep = 1 | 2 | 3;
export type InviteStepHeaderProps = {
step: InviteStep;
title: string;
subtitle: string;
};
export function InviteStepHeader({ step, title, subtitle }: InviteStepHeaderProps) {
return (
<div className="mb-5">
<div
role="progressbar"
aria-label="Registration progress"
aria-valuemin={1}
aria-valuemax={INVITE_STEP_COUNT}
aria-valuenow={step}
className="mb-5 flex items-center gap-1.5"
>
{[1, 2, 3].map((bar) => (
<span
key={bar}
className={cn(
"h-[3px] flex-1 rounded-sm",
bar <= step ? "bg-(--color-primary)" : "bg-(--color-border)",
)}
/>
))}
</div>
<Text variant="caption" tone="muted" className="block">
{`Step ${step} of ${INVITE_STEP_COUNT}`}
</Text>
<Text variant="title" className="m-0 mt-1! ![font-size:var(--text-lg)] !font-semibold">
{title}
</Text>
<Text variant="description" tone="muted" className="mt-1!">
{subtitle}
</Text>
</div>
);
}

View file

@ -0,0 +1,137 @@
import { useState } from "react";
import { toast } from "react-toastify";
import { inviteErrorCode } from "@/domain/team-member-invite/api/team-member-invite-api";
import type { TeamMemberInviteDetails } from "@/domain/team-member-invite/types/team-member-invite";
import {
useCompleteRegistration,
useSendInviteCode,
useVerifyInviteCode,
} from "@/domain/team-member-invite/use-cases/use-team-member-invite";
import { InviteCodeStep } from "./invite-code-step";
import { InvitePasswordStep } from "./invite-password-step";
import { InviteReviewStep } from "./invite-review-step";
import type { InviteStep } from "./invite-step-header";
export type InviteWizardProps = {
token: string;
details: TeamMemberInviteDetails;
/** Called when the server reports the invite can no longer be used. */
onInviteInvalid: () => void;
};
export function InviteWizard({ token, details, onInviteInvalid }: InviteWizardProps) {
const [step, setStep] = useState<InviteStep>(1);
const [password, setPassword] = useState("");
const [stepError, setStepError] = useState<string | null>(null);
const [codeError, setCodeError] = useState<string | null>(null);
const [resendError, setResendError] = useState<string | null>(null);
const sendCode = useSendInviteCode();
const verifyCode = useVerifyInviteCode();
const complete = useCompleteRegistration();
/** Returns true when the failure ended the invite and the wizard should stop. */
const endedInvite = (error: Error) => {
if (inviteErrorCode(error) !== "invalid_invite") {
return false;
}
onInviteInvalid();
return true;
};
const handlePasswordContinue = (nextPassword: string) => {
setPassword(nextPassword);
setStepError(null);
sendCode.mutate(token, {
onSuccess: () => setStep(2),
onError: (error) => {
if (endedInvite(error)) return;
// A code sent moments ago is still valid, so the member can go on and enter it.
if (inviteErrorCode(error) === "resend_too_soon") {
setStep(2);
return;
}
setStepError(error.message);
},
});
};
const handleResend = () => {
setResendError(null);
sendCode.mutate(token, {
onSuccess: () => {
setCodeError(null);
toast.success(`Code resent to ${details.email}`);
},
onError: (error) => {
if (!endedInvite(error)) setResendError(error.message);
},
});
};
const handleVerify = (code: string) => {
setCodeError(null);
verifyCode.mutate(
{ token, code },
{
onSuccess: () => setStep(3),
onError: (error) => {
if (!endedInvite(error)) setCodeError(error.message);
},
},
);
};
const handleFinish = (phone: string) => {
setStepError(null);
complete.mutate(
{ token, password, phone },
{
onError: (error) => {
if (endedInvite(error)) return;
if (inviteErrorCode(error) === "password_rejected") {
setStep(1);
}
setStepError(error.message);
},
},
);
};
if (step === 1) {
return (
<InvitePasswordStep
name={details.name}
defaultPassword={password}
isSubmitting={sendCode.isPending}
error={stepError}
onContinue={handlePasswordContinue}
/>
);
}
if (step === 2) {
return (
<InviteCodeStep
email={details.email}
isVerifying={verifyCode.isPending}
isResending={sendCode.isPending}
codeError={codeError}
resendError={resendError}
onVerify={handleVerify}
onResend={handleResend}
onCodeChange={() => setCodeError(null)}
/>
);
}
return (
<InviteReviewStep
details={details}
isFinishing={complete.isPending}
error={stepError}
onFinish={handleFinish}
/>
);
}

87
src/app/invite/index.tsx Normal file
View file

@ -0,0 +1,87 @@
import { useState } from "react";
import { Link, useLocation } from "react-router";
import { Box, CircularProgress } from "@mui/material";
import { ChevronLeft } from "lucide-react";
import { AuthBrandLogo } from "@/app/(auth)/_components/auth-brand-logo";
import { AuthCardHeader } from "@/app/(auth)/_components/auth-card-header";
import { AuthPageShell } from "@/app/(auth)/_components/auth-page-shell";
import { Button } from "@/components/ui/button";
import { inviteErrorCode } from "@/domain/team-member-invite/api/team-member-invite-api";
import {
INVALID_INVITE_MESSAGE,
INVITE_UNREACHABLE_MESSAGE,
} from "@/domain/team-member-invite/types/team-member-invite";
import { useTeamMemberInviteDetails } from "@/domain/team-member-invite/use-cases/use-team-member-invite";
import { InviteWizard } from "./_components/invite-wizard";
/** The invite email links to `/invite#<token>`; a fragment never reaches a server or a Referer. */
function readInviteToken(hash: string): string {
return hash.replace(/^#/, "").trim();
}
function InviteBody({ token }: { token: string }) {
const details = useTeamMemberInviteDetails(token);
const [inviteEnded, setInviteEnded] = useState(false);
if (token.length > 0 && details.isPending) {
return (
<Box className="flex justify-center py-8" role="status" aria-label="Loading invite">
<CircularProgress />
</Box>
);
}
// Only the server's own `invalid_invite` answer means the link is dead. A request that never
// got that answer (offline, timeout, 5xx) says nothing about the token, so offer a retry
// instead of sending the member to their admin for a new invite.
if (!inviteEnded && details.isError && inviteErrorCode(details.error) !== "invalid_invite") {
return (
<Box>
<AuthCardHeader title="Couldn't load your invite" subtitle={INVITE_UNREACHABLE_MESSAGE} />
<Button
type="button"
fullWidth
size="lg"
loading={details.isFetching}
loadingText="Trying again…"
onClick={() => void details.refetch()}
>
Try again
</Button>
</Box>
);
}
if (inviteEnded || details.data === undefined) {
return <AuthCardHeader title="Invite unavailable" subtitle={INVALID_INVITE_MESSAGE} />;
}
return (
<InviteWizard
token={token}
details={details.data}
onInviteInvalid={() => setInviteEnded(true)}
/>
);
}
export default function InviteRegistrationPage() {
const { hash } = useLocation();
const token = readInviteToken(hash);
return (
<AuthPageShell>
<AuthBrandLogo className="mb-8" />
<Link
to="/login"
className="mb-6 inline-flex items-center gap-1 text-(length:--text-xs) text-(--color-text-muted) no-underline hover:text-foreground"
>
<ChevronLeft size={13} aria-hidden="true" />
Back to login
</Link>
<InviteBody token={token} />
</AuthPageShell>
);
}

View file

@ -40,12 +40,14 @@ export async function changePassword(data: ChangePasswordPayload): Promise<unkno
return apiPost(API_PATHS.authentication.changePassword, data);
}
// Email and code travel in the JSON body so they stay out of URLs, traces and proxy logs.
export async function forgotPassword(email: string): Promise<unknown> {
return apiPost(`${API_PATHS.authentication.forgetPassword}?Email=${encodeURIComponent(email)}`);
return apiPost(API_PATHS.authentication.forgetPassword, { email });
}
export async function verifyResetCode(code: string): Promise<unknown> {
return apiPost(`${API_PATHS.authentication.verificationCode}?code=${encodeURIComponent(code)}`);
// The server checks a code only against the email it was issued to.
export async function verifyResetCode(email: string, code: string): Promise<unknown> {
return apiPost(API_PATHS.authentication.verificationCode, { email, code });
}
export async function resetPassword(data: ResetPasswordPayload): Promise<unknown> {

View file

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

View file

@ -2,6 +2,7 @@ import {
useMutation,
useQuery,
useQueryClient,
type QueryClient,
type UseMutationResult,
type UseQueryResult,
} from "@tanstack/react-query";
@ -21,6 +22,13 @@ export function useSession(): UseQueryResult<AuthUser | null, Error> {
});
}
/** Makes a freshly stored session the signed-in user, after login or invite registration. */
export function startSignedInSession(queryClient: QueryClient, user: AuthUser): void {
stopQueryBroadcast(queryClient);
queryClient.setQueryData(queryKeys.auth.session(), user);
startQueryBroadcast(queryClient, user.id);
}
export function useLogin(): UseMutationResult<AuthUser, Error, LoginCredentials> {
const navigate = useNavigate();
const queryClient = useQueryClient();
@ -29,9 +37,7 @@ export function useLogin(): UseMutationResult<AuthUser, Error, LoginCredentials>
mutationFn: (credentials: LoginCredentials) => authApi.login(credentials),
meta: { suppressErrorToast: true },
onSuccess: (user) => {
stopQueryBroadcast(queryClient);
queryClient.setQueryData(queryKeys.auth.session(), user);
startQueryBroadcast(queryClient, user.id);
startSignedInSession(queryClient, user);
toast.success("Login successful!");
navigate("/");
},

View file

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

View file

@ -29,5 +29,7 @@ export function useUpdateSiteContactInfo(): UseMutationResult<
onError: (error: Error) => {
toast.error(error.message || "Failed to update the site");
},
// The toast above is this failure's only message; the global mutation toast would repeat it.
meta: { suppressErrorToast: true },
});
}

View file

@ -0,0 +1,59 @@
/**
* Anonymous invite registration. The invite token travels only in request bodies,
* never in a URL path or query, and the server scopes every call to that token.
*/
import { isHTTPError } from "ky";
import { apiPost, apiPostNoContent } from "@/api/api";
import { API_PATHS } from "@/api/api-paths";
import { handleApiResponse } from "@/api/handle-api-response";
import type { AuthUser } from "@/domain/auth/types/auth-user";
import type {
CompleteRegistrationInput,
TeamMemberInviteDetails,
TeamMemberInviteErrorCode,
} from "@/domain/team-member-invite/types/team-member-invite";
import { getAuthUser, setAuthUser } from "@/lib/auth/auth-storage";
export const teamMemberInviteApi = {
resolve: async (token: string): Promise<TeamMemberInviteDetails> => {
const data = await apiPost<unknown>(API_PATHS.teamMemberInvite.resolve, { token });
return handleApiResponse<TeamMemberInviteDetails>(data);
},
sendCode: async (token: string): Promise<void> => {
await apiPostNoContent(API_PATHS.teamMemberInvite.sendCode, { token });
},
verifyCode: async ({ token, code }: { token: string; code: string }): Promise<void> => {
await apiPostNoContent(API_PATHS.teamMemberInvite.verifyCode, { token, code });
},
/** Finishes registration and stores the returned session exactly as login does. */
complete: async (input: CompleteRegistrationInput): Promise<AuthUser> => {
const data = await apiPost<unknown>(API_PATHS.teamMemberInvite.complete, {
token: input.token,
password: input.password,
// An empty value clears the phone the admin entered.
phone: input.phone.trim(),
});
const user = handleApiResponse<AuthUser>(data);
setAuthUser(user);
return getAuthUser() ?? user;
},
};
/** The server's failure code for an invite call, when the response carried one. */
export function inviteErrorCode(error: unknown): TeamMemberInviteErrorCode | null {
if (!isHTTPError(error)) {
return null;
}
const data: unknown = error.data;
if (typeof data === "object" && data !== null && "code" in data) {
const code = (data as { code: unknown }).code;
return typeof code === "string" ? (code as TeamMemberInviteErrorCode) : null;
}
return null;
}

View file

@ -0,0 +1,39 @@
import { z } from "zod";
import { PASSWORDS_DO_NOT_MATCH_MESSAGE, passwordSchema } from "@/domain/auth/password-policy";
export const CODE_FORMAT_MESSAGE = "Enter the 6-digit code from your email";
export const PHONE_FORMAT_MESSAGE = "Enter a valid phone number";
/** Mirrors the server's accepted phone characters and length. */
const PHONE_PATTERN = /^[0-9+().\s-]{7,32}$/;
export const invitePasswordStepSchema = z
.object({
password: passwordSchema,
confirmPassword: z.string(),
})
.refine((values) => values.password === values.confirmPassword, {
message: PASSWORDS_DO_NOT_MATCH_MESSAGE,
path: ["confirmPassword"],
});
export type InvitePasswordStepValues = z.infer<typeof invitePasswordStepSchema>;
export const inviteCodeStepSchema = z.object({
code: z
.string()
.trim()
.regex(/^[0-9]{6}$/, CODE_FORMAT_MESSAGE),
});
export type InviteCodeStepValues = z.infer<typeof inviteCodeStepSchema>;
export const inviteReviewStepSchema = z.object({
phone: z
.string()
.trim()
.refine((phone) => phone === "" || PHONE_PATTERN.test(phone), PHONE_FORMAT_MESSAGE),
});
export type InviteReviewStepValues = z.infer<typeof inviteReviewStepSchema>;

View file

@ -0,0 +1,34 @@
/** The server's generic message for any invite that cannot be used; shown before any call too. */
export const INVALID_INVITE_MESSAGE =
"This invite link is invalid or has expired. Ask your admin to send a new invite.";
/** Shown when the invite could not be checked at all (offline, timeout, server error); the link may still be valid. */
export const INVITE_UNREACHABLE_MESSAGE =
"We couldn't reach the server to check your invite. Check your connection and try again.";
/** The read-only identity an admin set when inviting the member. */
export interface TeamMemberInviteDetails {
name: string;
role: string;
email: string;
phone: string | null;
}
export interface CompleteRegistrationInput {
token: string;
password: string;
phone: string;
}
/** Stable failure codes returned by the anonymous invite endpoints. */
export type TeamMemberInviteErrorCode =
| "invalid_invite"
| "code_incorrect"
| "code_expired"
| "code_locked"
| "resend_too_soon"
| "resend_limit_reached"
| "code_delivery_failed"
| "email_not_confirmed"
| "password_rejected"
| "invalid_phone";

View file

@ -0,0 +1,74 @@
import {
useMutation,
useQuery,
useQueryClient,
type UseMutationResult,
type UseQueryResult,
} from "@tanstack/react-query";
import { useNavigate } from "react-router";
import { toast } from "react-toastify";
import type { AuthUser } from "@/domain/auth/types/auth-user";
import { startSignedInSession } from "@/domain/auth/use-cases/use-login";
import { queryKeys } from "@/infra/query-key/query-key";
import { teamMemberInviteApi } from "@/domain/team-member-invite/api/team-member-invite-api";
import type {
CompleteRegistrationInput,
TeamMemberInviteDetails,
} from "@/domain/team-member-invite/types/team-member-invite";
export const REGISTRATION_COMPLETE_MESSAGE = "Registration complete — welcome to Seahaven";
/** Invite errors are shown inline on the wizard step, never as a global toast. */
const INLINE_ERRORS = { suppressErrorToast: true } as const;
export function useTeamMemberInviteDetails(
token: string,
): UseQueryResult<TeamMemberInviteDetails, Error> {
return useQuery({
queryKey: queryKeys.teamMemberInvite.details(token),
queryFn: () => teamMemberInviteApi.resolve(token),
enabled: token.length > 0,
retry: false,
staleTime: Infinity,
gcTime: 0,
meta: INLINE_ERRORS,
});
}
export function useSendInviteCode(): UseMutationResult<void, Error, string> {
return useMutation({
mutationFn: teamMemberInviteApi.sendCode,
meta: INLINE_ERRORS,
});
}
export function useVerifyInviteCode(): UseMutationResult<
void,
Error,
{ token: string; code: string }
> {
return useMutation({
mutationFn: teamMemberInviteApi.verifyCode,
meta: INLINE_ERRORS,
});
}
export function useCompleteRegistration(): UseMutationResult<
AuthUser,
Error,
CompleteRegistrationInput
> {
const navigate = useNavigate();
const queryClient = useQueryClient();
return useMutation({
mutationFn: teamMemberInviteApi.complete,
meta: INLINE_ERRORS,
onSuccess: (user) => {
startSignedInSession(queryClient, user);
toast.success(REGISTRATION_COMPLETE_MESSAGE);
navigate("/", { replace: true });
},
});
}

View file

@ -1,4 +1,4 @@
import { apiGet, apiPost, apiPut } 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 {
@ -39,4 +39,9 @@ export const teamMembersApi = {
);
return mapTeamMemberDetail(handleApiResponse<unknown>(data));
},
/** Emails a fresh invite link and revokes every earlier one for this pending member. */
resendInvite: async (id: string | number): Promise<void> => {
await apiPostNoContent(API_PATHS.teamMember.invite(id));
},
};

View file

@ -88,9 +88,12 @@ export function mapTeamMemberListItems(rawItems: unknown[]): TeamMemberListItem[
export function mapTeamMemberDetail(raw: unknown): TeamMemberDetail {
const record = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
const listItem = mapTeamMemberListItem(record);
// Present only on an update that re-sent a pending member's invite.
const inviteEmailSent = record.inviteEmailSent ?? record.InviteEmailSent;
return {
...listItem,
serviceAreas: readStringArray(record, "serviceAreas", "ServiceAreas"),
permissions: readPermissions(record),
...(typeof inviteEmailSent === "boolean" && { inviteEmailSent }),
};
}

View file

@ -32,6 +32,8 @@ export interface TeamMemberCreated {
phone: string;
serviceAreas: string[];
pendingRegistration: boolean;
/** Present on create only: false means the member exists but the invite email failed. */
inviteEmailSent?: boolean;
}
export type TeamPermissionState = 0 | 1 | 2;
@ -45,6 +47,8 @@ export interface TeamPermissionValue {
export interface TeamMemberDetail extends TeamMemberListItem {
serviceAreas: string[];
permissions: TeamPermissionValue[];
/** Set only after an update that changed a pending member's email and re-sent their invite. */
inviteEmailSent?: boolean;
}
export interface UpdateTeamMemberInput {

View file

@ -7,6 +7,9 @@ import type {
} from "@/domain/team-members/types/team-member";
import { queryKeys } from "@/infra/query-key/query-key";
export const INVITE_EMAIL_FAILED_MESSAGE =
"Team member created, but the invite email couldn't be sent. Open their details and use Resend invite.";
export function useCreateTeamMember(): UseMutationResult<
TeamMemberCreated,
Error,
@ -16,8 +19,12 @@ export function useCreateTeamMember(): UseMutationResult<
return useMutation({
mutationFn: teamMembersApi.create,
onSuccess: () => {
onSuccess: (created) => {
void queryClient.invalidateQueries({ queryKey: queryKeys.teamMembers.all });
if (created.inviteEmailSent === false) {
toast.warning(INVITE_EMAIL_FAILED_MESSAGE);
return;
}
toast.success("Team member created with pending registration.");
},
onError: (error: Error) => {

View file

@ -0,0 +1,19 @@
import { useMutation, type UseMutationResult } from "@tanstack/react-query";
import { toast } from "react-toastify";
import { teamMembersApi } from "@/domain/team-members/api/team-members-api";
export type ResendInviteInput = {
id: string | number;
email: string;
};
/** Failures surface through the global mutation error toast with the server's message. */
export function useResendTeamMemberInvite(): UseMutationResult<void, Error, ResendInviteInput> {
return useMutation({
mutationFn: ({ id }: ResendInviteInput) => teamMembersApi.resendInvite(id),
onSuccess: (_data, { email }) => {
toast.success(`Invite sent to ${email}`);
},
});
}

View file

@ -23,6 +23,15 @@ export function useTeamMemberDetail(
});
}
export const UPDATED_INVITE_EMAIL_FAILED_MESSAGE =
"Team member updated, but the invite to their new email couldn't be sent. Use Resend invite.";
function updatedMessage(member: TeamMemberDetail): string {
return member.inviteEmailSent === true
? `Team member updated. A new invite was sent to ${member.email}.`
: "Team member updated.";
}
interface UpdateVariables {
id: string | number;
input: UpdateTeamMemberInput;
@ -36,7 +45,11 @@ export function useUpdateTeamMember(): UseMutationResult<TeamMemberDetail, Error
onSuccess: (member) => {
void queryClient.invalidateQueries({ queryKey: queryKeys.teamMembers.all });
void queryClient.setQueryData(queryKeys.teamMembers.detail(member.id), member);
toast.success("Team member updated.");
if (member.inviteEmailSent === false) {
toast.warning(UPDATED_INVITE_EMAIL_FAILED_MESSAGE);
return;
}
toast.success(updatedMessage(member));
},
onError: (error: Error) => {
toast.error(error.message || "Failed to update team member");

View file

@ -0,0 +1,107 @@
/** A4 portrait: sheet height is its width × 297 / 210. */
export const A4_HEIGHT_RATIO = 297 / 210;
/** Brand-accent bar at the top of every sheet. */
export const PAGE_TOP_BAR_PX = 12;
/** Brand-accent bar at the bottom of every sheet. */
export const PAGE_BOTTOM_BAR_PX = 24;
/** Blank space kept between the last block and the bottom bar. */
export const PAGE_BOTTOM_PADDING_PX = 28;
/** Vertical gap between stacked sheets. */
export const PAGE_GAP_PX = 20;
export type PagePosition = { current: number; total: number };
/** Sheet height for a sheet of the given width. */
export function a4PageHeight(width: number): number {
return width * A4_HEIGHT_RATIO;
}
/** Height a sheet of the given width leaves for the letterhead and blocks. */
export function pageContentHeight(width: number): number {
return a4PageHeight(width) - PAGE_TOP_BAR_PX - PAGE_BOTTOM_BAR_PX - PAGE_BOTTOM_PADDING_PX;
}
type PaginateInput = {
/** Height of the letterhead, printed once at the top of the first page. */
headerHeight: number;
/** Heights of the blocks (procedures, comments) in document order. */
blockHeights: readonly number[];
/** Room a sheet has for content between its top bar and bottom padding. */
pageHeight: number;
};
/**
* Assigns blocks to pages, in order. A block that does not fit in what is left
* of the current page starts the next page, unless the current page holds
* nothing yet: a block taller than a whole page then stays on its own page and
* that page grows to show it. Blocks are never split, dropped or reordered.
* Returns block indices per page; there is always at least one page.
*/
export function paginateBlocks({
headerHeight,
blockHeights,
pageHeight,
}: PaginateInput): number[][] {
const pages: number[][] = [[]];
let remaining = pageHeight - headerHeight;
let pageHasContent = headerHeight > 0;
blockHeights.forEach((height, index) => {
if (pageHasContent && height > remaining) {
pages.push([]);
remaining = pageHeight;
}
pages[pages.length - 1].push(index);
remaining -= height;
pageHasContent = true;
});
return pages;
}
/**
* Makes a (possibly stale) page layout cover exactly blocks 0..count-1: indices
* that no longer exist are dropped and new ones join the last page, so a block
* added since the last measurement is still rendered (and then measured).
*/
export function coverAllBlocks(pages: readonly number[][], count: number): number[][] {
const seen = new Set<number>();
const covered = pages.map((page) =>
page.filter((index) => {
if (index >= count || seen.has(index)) return false;
seen.add(index);
return true;
}),
);
const result = covered.filter((page, i) => i === 0 || page.length > 0);
if (result.length === 0) result.push([]);
for (let index = 0; index < count; index += 1) {
if (!seen.has(index)) result[result.length - 1].push(index);
}
return result;
}
/**
* The page being read: the last sheet whose top edge (less half the gap
* between sheets) has scrolled to the top of the viewport.
*/
export function currentPageAt(pageTops: readonly number[], scrollTop: number): number {
let current = 1;
pageTops.forEach((top, index) => {
if (top - PAGE_GAP_PX / 2 <= scrollTop) current = index + 1;
});
return current;
}
/** The pill suffix, shown only once the document spans more than one page. */
export function pageIndicatorLabel({ current, total }: PagePosition): string {
return total > 1 ? ` · Page ${current} of ${total}` : "";
}
/** Page layouts compare equal when every page holds the same blocks. */
export function samePages(a: readonly number[][], b: readonly number[][]): boolean {
return (
a.length === b.length &&
a.every((page, i) => page.length === b[i].length && page.every((x, j) => x === b[i][j]))
);
}

View file

@ -74,24 +74,19 @@ export function isWorkOrderUpliftsReadOnly(status: WorkOrderTableRow["status"]):
return status === "Completed" || status === "Canceled";
}
export function canOpenUpliftsDialog(
summary: UpliftSummary,
status: WorkOrderTableRow["status"],
): boolean {
if (summary.hasUplift || summary.pendingCount > 0) {
return true;
}
return !isWorkOrderUpliftsReadOnly(status);
}
export function canRevokeWorkOrderUplift(
uplift: Pick<WorkOrderUplift, "status" | "requestedById">,
currentUserId: string | number | null | undefined,
currentUserIsAdmin: boolean,
): boolean {
if (uplift.status !== "auto_approved") {
// SH-214/SH-212: admin-approved revoke lives on Uplift Approvals, not the WO dialog.
return false;
}
if (currentUserIsAdmin) {
// Admins revoke only admin-approved uplifts; an auto-approval is the requester's to revoke.
return false;
}
return (
currentUserId != null &&
currentUserId !== "" &&

View file

@ -10,6 +10,8 @@ export const queryKeys = {
auth: {
all: ["auth"] as const,
session: () => [...queryKeys.auth.all, "session"] as const,
permissions: (userId: string | number) =>
[...queryKeys.auth.all, "permissions", userId] as const,
},
workOrders: {
all: ["workOrders"] as const,
@ -49,6 +51,9 @@ export const queryKeys = {
[...queryKeys.employees.all, "list", params ?? {}] as const,
detail: (id: string | number) => [...queryKeys.employees.all, "detail", id] as const,
},
teamMemberInvite: {
details: (token: string) => ["teamMemberInvite", "details", token] as const,
},
teamMembers: {
all: ["teamMembers"] as const,
list: () => [...queryKeys.teamMembers.all, "list"] as const,

View file

@ -60,13 +60,39 @@ function savedDto(id: number, body: WriteBody, base?: CompletionTemplateDto) {
});
}
/** Answers the template endpoints the way the backend contract describes. */
export const MY_PERMISSIONS_URL = "team-members/me/permissions";
export const TEMPLATE_PERMISSIONS = [
"createCompletionDocTemplates",
"editCompletionDocTemplates",
"deleteCompletionDocTemplates",
];
/** What the effective-permissions endpoint does: answer with keys, fail, or never settle. */
export type PermissionsAnswer = string[] | Error | "pending";
export const permissionsAnswer: { current: PermissionsAnswer } = {
current: TEMPLATE_PERMISSIONS,
};
function answerPermissions(): Promise<unknown> {
const answer = permissionsAnswer.current;
if (answer === "pending") return new Promise(() => undefined);
if (answer instanceof Error) return Promise.reject(answer);
return Promise.resolve({ permissions: answer });
}
/**
* Answers the template endpoints the way the backend contract describes, and the signed-in
* user's effective permissions with `permissionsAnswer.current`.
*/
export function serveTemplates(
api: ApiMocks,
templates: CompletionTemplateDto[],
linked: { count: number; workOrderIds: number[] } = { count: 0, workOrderIds: [] },
) {
api.apiGet.mockImplementation((url: string, options?: { searchParams?: { search?: string } }) => {
if (url === MY_PERMISSIONS_URL) return answerPermissions();
if (url.endsWith("/linked-work-orders")) return Promise.resolve(linked);
const term = options?.searchParams?.search?.toLowerCase() ?? "";
return Promise.resolve(

View file

@ -1,9 +1,18 @@
import { screen, waitFor, within } from "@testing-library/react";
import { focusManager, QueryClient } from "@tanstack/react-query";
import { act, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import CompletionDocsPage from "@/app/(protected)/completion-docs/index";
import { renderWithProviders } from "@/test/test-utils";
import { BACKFLOW, SCRUBBER, serveTemplates, templateDto } from "./completion-docs-fixtures";
import {
BACKFLOW,
MY_PERMISSIONS_URL,
permissionsAnswer,
SCRUBBER,
serveTemplates,
TEMPLATE_PERMISSIONS,
templateDto,
} from "./completion-docs-fixtures";
const api = vi.hoisted(() => ({
apiGet: vi.fn(),
@ -11,7 +20,7 @@ const api = vi.hoisted(() => ({
apiPut: vi.fn(),
apiDeleteNoContent: vi.fn(),
}));
const auth = vi.hoisted(() => ({ userRoles: "Admin" }));
const auth = vi.hoisted(() => ({ id: "u1", userRoles: "Admin" }));
vi.mock("@/api/api", async (importOriginal) => ({
...(await importOriginal<typeof import("@/api/api")>()),
@ -20,7 +29,7 @@ vi.mock("@/api/api", async (importOriginal) => ({
vi.mock("@/providers/auth-context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/providers/auth-context")>()),
useAuthContext: () => ({ user: { id: "u1", userRoles: auth.userRoles } }),
useAuthContext: () => ({ user: { id: auth.id, userRoles: auth.userRoles } }),
}));
// delay: null skips per-keystroke timer yields that only slow typing-heavy tests down.
@ -38,12 +47,24 @@ async function openTemplate(name: string) {
return screen.findByRole("dialog");
}
/** Lets the effective-permissions request finish (or stay pending) before asserting absence. */
async function permissionsRequested() {
await waitFor(() => expect(api.apiGet).toHaveBeenCalledWith(MY_PERMISSIONS_URL));
await act(() => new Promise((resolve) => setTimeout(resolve, 0)));
}
beforeEach(() => {
user = userEvent.setup({ delay: null });
auth.id = "u1";
auth.userRoles = "Admin";
permissionsAnswer.current = TEMPLATE_PERMISSIONS;
serveTemplates(api, [BACKFLOW, SCRUBBER]);
});
afterEach(() => {
focusManager.setFocused(undefined);
});
describe("Completion Docs page", { timeout: 15_000 }, () => {
it("lists every template with its Document Title, Last Updated and Created By", async () => {
renderPage();
@ -127,41 +148,150 @@ describe("Completion Docs page", { timeout: 15_000 }, () => {
expect(within(dialog).getByText("No procedure defined yet")).toBeInTheDocument();
expect(within(dialog).getByText("No procedure added yet.")).toBeInTheDocument();
});
it("gives an Admin every template action", async () => {
renderPage();
const row = await screen.findByRole("row", { name: /Backflow Test Report/ });
expect(screen.getByRole("button", { name: /Add Template/ })).toBeInTheDocument();
expect(within(row).getByRole("button", { name: "Edit" })).toBeInTheDocument();
const dialog = await openTemplate("Backflow Test Report");
expect(within(dialog).getByRole("button", { name: /Edit/ })).toBeInTheDocument();
expect(within(dialog).getByRole("button", { name: /Delete/ })).toBeInTheDocument();
});
it("hides delete from a Scheduler, who may still add and edit", async () => {
auth.userRoles = "Scheduler";
renderPage();
const row = await screen.findByRole("row", { name: /Backflow Test Report/ });
expect(screen.getByRole("button", { name: /Add Template/ })).toBeInTheDocument();
expect(within(row).getByRole("button", { name: "Edit" })).toBeInTheDocument();
const dialog = await openTemplate("Backflow Test Report");
expect(within(dialog).getByRole("button", { name: /Edit/ })).toBeInTheDocument();
expect(within(dialog).queryByRole("button", { name: /Delete/ })).not.toBeInTheDocument();
});
it("hides add, edit and delete entirely from a user without those permissions", async () => {
auth.userRoles = "Dispatcher";
serveTemplates(api, [templateDto()]);
renderPage();
const row = await screen.findByRole("row", { name: /Backflow Test Report/ });
expect(screen.queryByRole("button", { name: /Add Template/ })).not.toBeInTheDocument();
expect(within(row).queryByRole("button", { name: "Edit" })).not.toBeInTheDocument();
expect(within(row).getByRole("button", { name: "View details" })).toBeInTheDocument();
const dialog = await openTemplate("Backflow Test Report");
expect(within(dialog).queryByRole("button", { name: /Edit/ })).not.toBeInTheDocument();
expect(within(dialog).queryByRole("button", { name: /Delete/ })).not.toBeInTheDocument();
});
});
describe(
"Completion Docs actions follow the server's effective permissions",
{ timeout: 15_000 },
() => {
async function expectEveryAction() {
const row = await screen.findByRole("row", { name: /Backflow Test Report/ });
expect(await screen.findByRole("button", { name: /Add Template/ })).toBeInTheDocument();
expect(within(row).getByRole("button", { name: "Edit" })).toBeInTheDocument();
const dialog = await openTemplate("Backflow Test Report");
expect(within(dialog).getByRole("button", { name: /Edit/ })).toBeInTheDocument();
expect(within(dialog).getByRole("button", { name: /Delete/ })).toBeInTheDocument();
}
async function expectNoAction() {
const row = await screen.findByRole("row", { name: /Backflow Test Report/ });
await permissionsRequested();
expect(screen.queryByRole("button", { name: /Add Template/ })).not.toBeInTheDocument();
expect(within(row).queryByRole("button", { name: "Edit" })).not.toBeInTheDocument();
expect(within(row).getByRole("button", { name: "View details" })).toBeInTheDocument();
const dialog = await openTemplate("Backflow Test Report");
expect(within(dialog).queryByRole("button", { name: /Edit/ })).not.toBeInTheDocument();
expect(within(dialog).queryByRole("button", { name: /Delete/ })).not.toBeInTheDocument();
}
it("gives every template action to a user the server grants all three", async () => {
renderPage();
await expectEveryAction();
expect(api.apiGet).toHaveBeenCalledWith(MY_PERMISSIONS_URL);
});
it("shows every action to a Dispatcher whose overrides grant them", async () => {
auth.userRoles = "Dispatcher";
renderPage();
await expectEveryAction();
});
it("hides Delete when the effective permissions omit it, even for an Admin", async () => {
permissionsAnswer.current = ["createCompletionDocTemplates", "editCompletionDocTemplates"];
renderPage();
const row = await screen.findByRole("row", { name: /Backflow Test Report/ });
expect(await screen.findByRole("button", { name: /Add Template/ })).toBeInTheDocument();
expect(within(row).getByRole("button", { name: "Edit" })).toBeInTheDocument();
const dialog = await openTemplate("Backflow Test Report");
expect(within(dialog).getByRole("button", { name: /Edit/ })).toBeInTheDocument();
expect(within(dialog).queryByRole("button", { name: /Delete/ })).not.toBeInTheDocument();
});
it("hides Edit from a Scheduler whose edit permission was revoked", async () => {
auth.userRoles = "Scheduler";
permissionsAnswer.current = ["createCompletionDocTemplates"];
renderPage();
const row = await screen.findByRole("row", { name: /Backflow Test Report/ });
expect(await screen.findByRole("button", { name: /Add Template/ })).toBeInTheDocument();
expect(within(row).queryByRole("button", { name: "Edit" })).not.toBeInTheDocument();
const dialog = await openTemplate("Backflow Test Report");
expect(within(dialog).queryByRole("button", { name: /Edit/ })).not.toBeInTheDocument();
expect(within(dialog).queryByRole("button", { name: /Delete/ })).not.toBeInTheDocument();
});
it("hides add, edit and delete entirely from a user without those permissions", async () => {
auth.userRoles = "Dispatcher";
permissionsAnswer.current = ["createWorkOrders"];
serveTemplates(api, [templateDto()]);
renderPage();
await expectNoAction();
});
it("hides every action while the permissions are still loading", async () => {
permissionsAnswer.current = "pending";
renderPage();
await expectNoAction();
});
it("hides every action when the permissions fail to load, and still lists templates", async () => {
permissionsAnswer.current = new Error("Network down");
renderPage();
expect(await screen.findByRole("row", { name: /Scrubber Pit Augering/ })).toBeInTheDocument();
await expectNoAction();
});
it("hides the actions again when a later permissions refresh fails", async () => {
renderPage();
expect(await screen.findByRole("button", { name: /Add Template/ })).toBeInTheDocument();
permissionsAnswer.current = new Error("Network down");
act(() => {
focusManager.setFocused(false);
focusManager.setFocused(true);
});
await waitFor(() =>
expect(screen.queryByRole("button", { name: /Add Template/ })).not.toBeInTheDocument(),
);
});
it("picks up a changed override when the window regains focus", async () => {
permissionsAnswer.current = [];
renderPage();
await screen.findByRole("row", { name: /Backflow Test Report/ });
await permissionsRequested();
expect(screen.queryByRole("button", { name: /Add Template/ })).not.toBeInTheDocument();
permissionsAnswer.current = ["createCompletionDocTemplates"];
act(() => {
focusManager.setFocused(false);
focusManager.setFocused(true);
});
expect(await screen.findByRole("button", { name: /Add Template/ })).toBeInTheDocument();
});
it("never reuses one signed-in user's permissions for the next", async () => {
// Keeps the first answer cached after unmount, so only the per-user key can keep it out.
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: Infinity } },
});
const first = renderWithProviders(<CompletionDocsPage />, {
route: "/completion-docs",
withAuth: false,
queryClient,
});
expect(await screen.findByRole("button", { name: /Add Template/ })).toBeInTheDocument();
first.unmount();
auth.id = "u2";
permissionsAnswer.current = "pending";
renderWithProviders(<CompletionDocsPage />, {
route: "/completion-docs",
withAuth: false,
queryClient,
});
await screen.findByRole("row", { name: /Backflow Test Report/ });
await permissionsRequested();
expect(screen.queryByRole("button", { name: /Add Template/ })).not.toBeInTheDocument();
});
},
);

View file

@ -42,7 +42,7 @@ function renderPage() {
async function openNewTemplate() {
renderPage();
await screen.findByRole("row", { name: /Backflow Test Report/ });
await user.click(screen.getByRole("button", { name: /Add Template/ }));
await user.click(await screen.findByRole("button", { name: /Add Template/ }));
return screen.findByRole("dialog");
}

View file

@ -0,0 +1,61 @@
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { toast } from "react-toastify";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ResendInviteButton } from "@/app/(protected)/team-members/_components/resend-invite-button";
import { teamMembersApi } from "@/domain/team-members/api/team-members-api";
import type { TeamMemberDetail } from "@/domain/team-members/types/team-member";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("react-toastify", () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
vi.mock("@/domain/team-members/api/team-members-api", () => ({
teamMembersApi: { resendInvite: vi.fn() },
}));
function member(overrides: Partial<TeamMemberDetail> = {}): TeamMemberDetail {
return {
id: "user-7",
name: "Taylor Reed",
role: "Dispatcher",
color: "#0D9488",
email: "taylor@example.com",
phone: "555-0100",
status: "Active",
pendingRegistration: true,
serviceAreas: ["East"],
permissions: [],
...overrides,
} as TeamMemberDetail;
}
describe("ResendInviteButton", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("re-sends the invite for a pending member and confirms where it went", async () => {
vi.mocked(teamMembersApi.resendInvite).mockResolvedValue(undefined);
const user = userEvent.setup();
renderWithProviders(<ResendInviteButton member={member()} />, { withAuth: false });
await user.click(screen.getByRole("button", { name: "Resend invite" }));
await waitFor(() =>
expect(vi.mocked(toast.success)).toHaveBeenCalledWith("Invite sent to taylor@example.com"),
);
expect(teamMembersApi.resendInvite).toHaveBeenCalledWith("user-7");
});
it.each([
["a registered member", { pendingRegistration: false }],
["a deactivated pending member", { status: "Inactive" as const }],
])("is not offered for %s", (_label, overrides) => {
renderWithProviders(<ResendInviteButton member={member(overrides)} />, { withAuth: false });
expect(screen.queryByRole("button", { name: "Resend invite" })).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { buildSiteDialogPatch } from "@/app/(protected)/workorders/_components/list/table/cells/build-site-dialog-patch";
const base = {
code: "S-100",
locId: "1",
selected: undefined,
pocName: "Jane",
pocPhone: "(421) 433-1111",
pocNotes: "",
contactsDirty: true,
baselineHadContacts: true,
followsSiteRecord: true,
};
describe("buildSiteDialogPatch", () => {
it("copies site-loaded extra contacts to the work order without their site contact ids", () => {
const patch = buildSiteDialogPatch({
...base,
extraContacts: [{ name: "Bob", phone: "(421) 433-7777", siteContactId: 32 }],
});
expect(patch.additionalContacts).toEqual([{ name: "Bob", phone: "(421) 433-7777" }]);
});
it("drops half-filled and empty extra contacts from the work-order patch", () => {
const patch = buildSiteDialogPatch({
...base,
extraContacts: [
{ name: "Bob", phone: "(421) 433-7777", notes: "Night shift", siteContactId: 32 },
{ name: "Half", phone: "", siteContactId: 33 },
{ name: "", phone: "(421) 433-0000" },
{ name: "", phone: "" },
],
});
expect(patch.additionalContacts).toEqual([
{ name: "Bob", phone: "(421) 433-7777", notes: "Night shift" },
]);
});
it("still clears the work order's copy when every extra contact was removed", () => {
const patch = buildSiteDialogPatch({ ...base, extraContacts: [] });
expect(patch.additionalContacts).toEqual([]);
});
it("leaves additional contacts off the patch when they were not edited", () => {
const patch = buildSiteDialogPatch({
...base,
contactsDirty: false,
extraContacts: [{ name: "Bob", phone: "(421) 433-7777", siteContactId: 32 }],
});
expect(patch).not.toHaveProperty("additionalContacts");
});
});

View file

@ -0,0 +1,275 @@
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CompletionTemplatePreview } from "@/app/(protected)/completion-docs/_components/completion-template-preview";
import { CompDocPreview } from "@/app/(protected)/workorders/_components/comp-doc/comp-doc-preview";
import type { CompDocSiteInfo } from "@/domain/work-orders/types/completion-doc";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
/*
* jsdom has no layout, so the geometry the paginator reads is stubbed:
* sheets are 420px wide (A4 → 594px tall, 530px of content room), the
* letterhead block is 200px and every other block is 300px. Page 1 then holds
* one block and every later page holds one block as well.
*/
const SHEET_WIDTH = 420;
const LETTERHEAD_HEIGHT = 200;
const BLOCK_HEIGHT = 300;
const SHEET_STRIDE = 614;
let sheetStride = SHEET_STRIDE;
function isLetterhead(el: HTMLElement) {
return el.querySelector('img[alt="Seahaven Industries"]') !== null;
}
function stubRect(this: HTMLElement): DOMRect {
const height =
this.style.display === "flow-root"
? isLetterhead(this)
? LETTERHEAD_HEIGHT
: BLOCK_HEIGHT
: 0;
return { width: SHEET_WIDTH, height } as DOMRect;
}
const offsetTopDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetTop");
beforeEach(() => {
sheetStride = SHEET_STRIDE;
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(stubRect);
Object.defineProperty(HTMLElement.prototype, "offsetTop", {
configurable: true,
get(this: HTMLElement) {
if (this.dataset.testid !== "comp-doc-page") return 0;
return Array.from(this.parentElement?.children ?? []).indexOf(this) * sheetStride;
},
});
});
afterEach(() => {
vi.restoreAllMocks();
if (offsetTopDescriptor) {
Object.defineProperty(HTMLElement.prototype, "offsetTop", offsetTopDescriptor);
}
});
function sheets() {
return screen.getAllByTestId("comp-doc-page");
}
function expectEverySheetFramed() {
for (const sheet of sheets()) {
expect(within(sheet).getAllByTestId("comp-doc-page-top-bar")).toHaveLength(1);
expect(within(sheet).getAllByTestId("comp-doc-page-bottom-bar")).toHaveLength(1);
// A4 portrait: 420 × 297 / 210.
expect(sheet.style.minHeight).toBe("594px");
}
}
function expectLetterheadOnlyOnFirstSheet() {
const [first, ...rest] = sheets();
expect(within(first).getByAltText("Seahaven Industries")).toBeInTheDocument();
for (const sheet of rest) {
expect(within(sheet).queryByAltText("Seahaven Industries")).not.toBeInTheDocument();
}
}
function scrollDocumentTo(scrollTop: number) {
const scroller = screen.getByTestId("comp-doc-document");
act(() => {
scroller.scrollTop = scrollTop;
fireEvent.scroll(scroller);
});
}
describe("Completion template preview pagination", () => {
const procedures = [1, 2, 3].map((n) => ({
name: `Step group ${n}`,
description: `Do part ${n} of the work`,
}));
it("lays the template out on separate A4 sheets with the letterhead once and bars on each", () => {
render(
<CompletionTemplatePreview
values={{ name: "Backflow test", extraSafetyNote: null, procedures }}
isEditing={false}
/>,
);
expect(sheets()).toHaveLength(3);
expectEverySheetFramed();
expectLetterheadOnlyOnFirstSheet();
procedures.forEach((procedure, index) => {
expect(within(sheets()[index]).getByText(procedure.name)).toBeInTheDocument();
});
expect(screen.getByText(/Document preview/)).toHaveTextContent(
"Document preview · Page 1 of 3",
);
});
it("updates Page X of Y as the reader scrolls through the sheets", () => {
render(
<CompletionTemplatePreview
values={{ name: "Backflow test", extraSafetyNote: null, procedures }}
isEditing
/>,
);
scrollDocumentTo(SHEET_STRIDE + 5);
expect(screen.getByText(/Live preview/)).toHaveTextContent("Live preview · Page 2 of 3");
scrollDocumentTo(2 * SHEET_STRIDE + 5);
expect(screen.getByText(/Live preview/)).toHaveTextContent("Live preview · Page 3 of 3");
scrollDocumentTo(0);
expect(screen.getByText(/Live preview/)).toHaveTextContent("Live preview · Page 1 of 3");
});
it("renders a short template as one full A4 sheet without a page count", () => {
render(
<CompletionTemplatePreview
values={{ name: "Short", extraSafetyNote: null, procedures: procedures.slice(0, 1) }}
isEditing={false}
/>,
);
expect(sheets()).toHaveLength(1);
expectEverySheetFramed();
expect(screen.getByText(/Document preview/)).toHaveTextContent(/^Document preview$/);
});
it("adds a sheet when a procedure is added while editing", () => {
const { rerender } = render(
<CompletionTemplatePreview
values={{ name: "Grows", extraSafetyNote: null, procedures: procedures.slice(0, 2) }}
isEditing
/>,
);
expect(sheets()).toHaveLength(2);
rerender(
<CompletionTemplatePreview
values={{ name: "Grows", extraSafetyNote: null, procedures }}
isEditing
/>,
);
expect(sheets()).toHaveLength(3);
expect(within(sheets()[2]).getByText("Step group 3")).toBeInTheDocument();
expect(screen.getByText(/Live preview/)).toHaveTextContent("Live preview · Page 1 of 3");
});
it("re-reads the page being read when an edit moves the sheets but keeps the page count", () => {
const { rerender } = render(
<CompletionTemplatePreview
values={{ name: "Edits", extraSafetyNote: null, procedures }}
isEditing
/>,
);
scrollDocumentTo(SHEET_STRIDE + 5);
expect(screen.getByText(/Live preview/)).toHaveTextContent("Live preview · Page 2 of 3");
// The edit makes sheet 1 taller: sheet 2 now starts below the reader's position.
sheetStride = 800;
rerender(
<CompletionTemplatePreview
values={{
name: "Edits",
extraSafetyNote: null,
procedures: [{ ...procedures[0], description: "Longer text" }, ...procedures.slice(1)],
}}
isEditing
/>,
);
expect(sheets()).toHaveLength(3);
expect(screen.getByText(/Live preview/)).toHaveTextContent("Live preview · Page 1 of 3");
});
});
const site: CompDocSiteInfo = {
code: "SEA1",
street: "1 Main St",
city: "Seattle",
state: "WA",
zip: "98101",
label: "Seattle, WA",
fullAddress: "1 Main St, Seattle, WA 98101",
tech: "Tech One",
company: "Vendor Co",
};
const row = {
id: 42,
woNumber: "WO-42",
site: "SEA1",
pm: "HVAC",
company: "Vendor Co",
tech: "Tech One",
scheduledOn: "2026-06-01",
} as WorkOrderTableRow;
describe("Work order completion document preview pagination", () => {
it("pages the procedure and PM comments through the shared A4 frame", () => {
render(
<CompDocPreview
row={row}
site={site}
notes="Replaced the filter on unit 3."
generatedAt=""
isGenerated={false}
isNotRequired={false}
/>,
);
expect(sheets()).toHaveLength(2);
expectEverySheetFramed();
expectLetterheadOnlyOnFirstSheet();
expect(within(sheets()[1]).getByText("PM Comments")).toBeInTheDocument();
expect(screen.getByText(/Preview — not yet generated/)).toHaveTextContent(
"Preview — not yet generated · Page 1 of 2",
);
scrollDocumentTo(SHEET_STRIDE);
expect(screen.getByText(/Preview — not yet generated/)).toHaveTextContent(
"Preview — not yet generated · Page 2 of 2",
);
});
it("stamps NOT REQUIRED on the first sheet, above the greyed-out content", () => {
render(
<CompDocPreview
row={row}
site={site}
notes="Replaced the filter on unit 3."
generatedAt=""
isGenerated={false}
isNotRequired
/>,
);
const [first, second] = sheets();
const stamp = within(first).getByText(/NOT REQUIRED/);
expect(stamp.closest('[style*="opacity: 0.5"]')).toBeNull();
expect(within(first).getByTestId("comp-doc-page-top-bar").parentElement).toHaveStyle({
opacity: "0.5",
});
expect(within(second).queryByText(/NOT REQUIRED/)).not.toBeInTheDocument();
});
it("keeps a document without comments on a single sheet", () => {
render(
<CompDocPreview
row={row}
site={site}
notes=""
generatedAt=""
isGenerated={false}
isNotRequired={false}
/>,
);
expect(sheets()).toHaveLength(1);
expectEverySheetFramed();
expect(screen.queryByText("PM Comments")).not.toBeInTheDocument();
});
});

View file

@ -133,6 +133,41 @@ describe("Work order Site dialog keeps the site record in sync", () => {
});
});
it("does not carry the previous site's contact onto a site whose record failed to load", async () => {
// The legacy contact fields too, so nothing can refill the cleared fields from DAL1.
const dallasWithContact: Location = { ...dallas, contact: "Jane", phone: "(421) 433-0032" };
getById.mockImplementation((id: string) =>
String(id) === "1"
? Promise.resolve(dallasWithContact)
: Promise.reject(new Error("Network error")),
);
const { onSave } = renderDialog();
expect(await screen.findByDisplayValue("Jane")).toBeInTheDocument();
pickSite("CHI2");
expect(await screen.findByText(SITE_UNAVAILABLE_COPY)).toBeInTheDocument();
expect(primaryField("POC name")).toHaveValue("");
expect(primaryField("POC phone")).toHaveValue("");
expect(primaryField("Notes…")).toHaveValue("");
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
expect(await screen.findByText(/name is required/i)).toBeInTheDocument();
expect(onSave).not.toHaveBeenCalled();
fireEvent.change(primaryField("POC name"), { target: { value: "Carl" } });
fireEvent.change(primaryField("POC phone"), { target: { value: "(312) 555-0101" } });
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
expect(onSave.mock.calls[0][0]).toMatchObject({
locationId: "2",
pocName: "Carl",
pocPhone: "(312) 555-0101",
pocNotes: "",
additionalContacts: [],
});
expect(updateContactInfo).not.toHaveBeenCalled();
});
it("waits for the refetch instead of syncing a cached site record, so a save never reverts it", async () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },

View file

@ -1,12 +1,22 @@
import type { QueryClient } from "@tanstack/react-query";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { SiteDialog } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog";
import type { Location } from "@/domain/locations/types/location";
import { queryKeys } from "@/infra/query-key/query-key";
import { createAppQueryClient } from "@/lib/query/query-client";
import { renderWithProviders } from "@/test/test-utils";
const getById = vi.fn();
const updateContactInfo = vi.fn();
const toastMocks = vi.hoisted(() => ({
error: vi.fn(),
success: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
}));
vi.mock("react-toastify", () => ({ toast: toastMocks }));
vi.mock("@/domain/locations/api/locations-api", () => ({
locationsApi: {
@ -38,7 +48,10 @@ const SITE_RECORD_COPY =
"Contacts and notes are saved to the site record and apply to all its work orders.";
const SITE_UNAVAILABLE_COPY = "Site record unavailable — changes apply to this work order only.";
function renderDialog(overrides: Partial<Parameters<typeof SiteDialog>[0]> = {}) {
function renderDialog(
overrides: Partial<Parameters<typeof SiteDialog>[0]> = {},
options: { queryClient?: QueryClient } = {},
) {
const onSave = vi.fn();
const onOpenChange = vi.fn();
const { queryClient } = renderWithProviders(
@ -54,7 +67,7 @@ function renderDialog(overrides: Partial<Parameters<typeof SiteDialog>[0]> = {})
onSave={onSave}
{...overrides}
/>,
{ withAuth: false },
{ withAuth: false, ...options },
);
return { onSave, onOpenChange, queryClient };
}
@ -72,6 +85,7 @@ describe("Work order Site dialog edits the site record", () => {
beforeEach(() => {
getById.mockReset();
updateContactInfo.mockReset();
toastMocks.error.mockReset();
getById.mockResolvedValue(dallas);
});
@ -101,6 +115,26 @@ describe("Work order Site dialog edits the site record", () => {
expect(screen.queryByText(SITE_UNAVAILABLE_COPY)).not.toBeInTheDocument();
});
it("offers no per-contact notes on additional contacts, since site contacts cannot store them", async () => {
renderDialog();
await screen.findByDisplayValue("Bob");
// Only the site notes box: Bob's card has none.
expect(screen.getAllByPlaceholderText("Notes…")).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: /add point of contact/i }));
expect(screen.getAllByPlaceholderText("POC name")).toHaveLength(3);
expect(screen.getAllByPlaceholderText("Notes…")).toHaveLength(1);
});
it("keeps per-contact notes when the site record is unavailable and Save writes the work order only", async () => {
getById.mockRejectedValue(new Error("Network error"));
renderDialog();
await screen.findByText(SITE_UNAVAILABLE_COPY);
fireEvent.click(screen.getByRole("button", { name: /add point of contact/i }));
expect(screen.getAllByPlaceholderText("Notes…")).toHaveLength(2);
});
it("locks the contact fields until the site loads, so typed input is never replaced", async () => {
let resolveSite: (site: Location) => void = () => {};
getById.mockReturnValue(
@ -201,7 +235,7 @@ describe("Work order Site dialog edits the site record", () => {
pocName: "Jane",
pocPhone: "(421) 433-9999",
pocNotes: "",
additionalContacts: [{ name: "Bob", phone: "(421) 433-7777", siteContactId: 32 }],
additionalContacts: [{ name: "Bob", phone: "(421) 433-7777" }],
}),
);
expect(onOpenChange).toHaveBeenCalledWith(false);
@ -263,6 +297,96 @@ describe("Work order Site dialog edits the site record", () => {
expect(onOpenChange).not.toHaveBeenCalled();
});
it("shows one error toast when the site update fails, not the global one as well", async () => {
updateContactInfo.mockRejectedValue(new Error("Forbidden"));
renderDialog({}, { queryClient: createAppQueryClient() });
await screen.findByDisplayValue("Jane");
fireEvent.change(primaryField("Notes…"), { target: { value: "Gate code 9999" } });
fireEvent.click(saveButton());
await waitFor(() => expect(saveButton()).toBeEnabled());
expect(toastMocks.error).toHaveBeenCalledTimes(1);
expect(toastMocks.error).toHaveBeenCalledWith("Forbidden");
});
it("locks the fields while the site update is in flight, so no edit is lost", async () => {
let finishSiteUpdate: () => void = () => {};
updateContactInfo.mockReturnValue(
new Promise<void>((resolve) => {
finishSiteUpdate = resolve;
}),
);
const { onSave } = renderDialog();
await screen.findByDisplayValue("Jane");
fireEvent.change(primaryField("Notes…"), { target: { value: "Gate code 9999" } });
fireEvent.click(saveButton());
await screen.findByRole("button", { name: /saving/i });
expect(primaryField("POC name")).toBeDisabled();
expect(primaryField("POC phone")).toBeDisabled();
expect(primaryField("Notes…")).toBeDisabled();
expect(screen.getByRole("button", { name: /add point of contact/i })).toBeDisabled();
finishSiteUpdate();
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
expect(onSave.mock.calls[0][0]).toMatchObject({ pocNotes: "" });
});
describe("on a site saved before the contacts list existed", () => {
const legacyDallas: Location = {
...dallas,
phone: "(421) 433-0050",
contact: "Legacy Lee",
contacts: undefined,
};
beforeEach(() => {
getById.mockResolvedValue(legacyDallas);
});
it("shows the site's legacy contact and saves a notes-only edit without retyping it", async () => {
updateContactInfo.mockResolvedValue(undefined);
const { onSave } = renderDialog();
expect(await screen.findByDisplayValue("Legacy Lee")).toBeInTheDocument();
expect(primaryField("POC phone")).toHaveValue("(421) 433-0050");
expect(saveButton()).toBeDisabled();
fireEvent.change(primaryField("Notes…"), { target: { value: "Gate code 9999" } });
fireEvent.click(saveButton());
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
expect(screen.queryByText(/name is required/i)).not.toBeInTheDocument();
// The legacy contact becomes the site's first contact row, unchanged.
expect(updateContactInfo).toHaveBeenCalledWith("1", {
contacts: [{ name: "Legacy Lee", phone: "(421) 433-0050" }],
notes: "Gate code 9999",
});
// Only the notes were edited, so the work order keeps its own contact override.
expect(onSave.mock.calls[0][0]).toMatchObject({
pocName: "Someone Else",
pocPhone: "(000) 000-0000",
pocNotes: "",
});
});
it("does not report a contact edit the user never made when the work order had no POC", async () => {
updateContactInfo.mockResolvedValue(undefined);
const { onSave } = renderDialog({ pocName: "", pocPhone: "" });
expect(await screen.findByDisplayValue("Legacy Lee")).toBeInTheDocument();
expect(saveButton()).toBeDisabled();
fireEvent.change(primaryField("Notes…"), { target: { value: "Gate code 9999" } });
fireEvent.click(saveButton());
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
expect(onSave.mock.calls[0][0]).toMatchObject({ pocName: "", pocPhone: "", pocNotes: "" });
});
});
it("requires the first contact before saving", async () => {
const { onSave } = renderDialog();
await screen.findByDisplayValue("Jane");
@ -305,6 +429,10 @@ describe("Work order Site dialog edits the site record", () => {
await waitFor(() => expect(getById).toHaveBeenCalled());
fireEvent.change(primaryField("Notes…"), { target: { value: "Bring ladder" } });
// Work-order-only contacts keep their own notes.
fireEvent.click(screen.getByRole("button", { name: /add point of contact/i }));
expect(screen.getAllByPlaceholderText("Notes…")).toHaveLength(2);
fireEvent.click(screen.getByRole("button", { name: /remove point of contact 2/i }));
fireEvent.click(saveButton());
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));

View file

@ -0,0 +1,81 @@
import { fireEvent, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { WoTableRowServiceCells } from "@/app/(protected)/workorders/_components/list/table/wo-table-row-service-cells";
import type { WoTableRowHandlers } from "@/app/(protected)/workorders/_components/list/table/wo-table-row";
import type { WorkOrderTableRow } from "@/domain/work-orders/types/work-order-table-row";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/pm-type-cell", () => ({
PMTypeCell: () => null,
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/appt-time-cell", () => ({
ApptTimeCell: () => null,
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/status-cell", () => ({
StatusCell: () => null,
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/comp-doc-cell", () => ({
CompDocCell: () => null,
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/date-cell", () => ({
DateCell: () => null,
}));
vi.mock("@/app/(protected)/workorders/_components/list/table/cells/vendor-cell", () => ({
VendorCell: () => null,
}));
function renderCells(row: WorkOrderTableRow, onOpenUplifts = vi.fn()) {
const handlers = { onOpenUplifts } as unknown as WoTableRowHandlers;
renderWithProviders(
<table>
<tbody>
<tr>
<WoTableRowServiceCells
row={row}
vendors={[]}
pmTypes={[]}
handlers={handlers}
onPatchRow={vi.fn()}
/>
</tr>
</tbody>
</table>,
{ withAuth: false },
);
return onOpenUplifts;
}
describe("WoTableRowServiceCells uplift column", () => {
// A closed work order whose uplifts were all cancelled or revoked has no live uplift,
// but its read-only uplift history must stay one click away.
it.each(["Completed", "Canceled"] as const)(
"opens the read-only uplift history from a dash on a %s work order with no live uplift",
(status) => {
const row = {
id: 7,
status,
upliftSummary: { hasUplift: false, pendingCount: 0 },
} as WorkOrderTableRow;
const onOpenUplifts = renderCells(row);
const trigger = screen.getByRole("button", { name: "—" });
expect(screen.queryByText("Manage")).not.toBeInTheDocument();
fireEvent.click(trigger);
expect(onOpenUplifts).toHaveBeenCalledWith(row);
},
);
it("offers Manage on an open work order with no live uplift", () => {
const row = {
id: 8,
status: "Scheduled",
upliftSummary: { hasUplift: false, pendingCount: 0 },
} as WorkOrderTableRow;
const onOpenUplifts = renderCells(row);
fireEvent.click(screen.getByRole("button", { name: "Manage" }));
expect(onOpenUplifts).toHaveBeenCalledWith(row);
});
});

View file

@ -22,6 +22,12 @@ const pendingUplift: WorkOrderUplift = {
const mockState = vi.hoisted(() => ({
uplifts: [] as WorkOrderUplift[],
authUser: { id: "u1", userRoles: "Dispatcher" },
}));
vi.mock("@/providers/auth-context", async (importOriginal) => ({
...(await importOriginal<typeof import("@/providers/auth-context")>()),
useAuthContext: () => ({ user: mockState.authUser }),
}));
const baseRow: WorkOrderTableRow = {
@ -145,6 +151,31 @@ describe("WorkOrderUpliftsDialog affordances", () => {
expect(screen.queryByRole("button", { name: /create uplift/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /cancel pending/i })).not.toBeInTheDocument();
});
it("shows no Revoke to an admin on the auto-approved uplift they requested", () => {
mockState.authUser = { id: "admin-1", userRoles: "Admin" };
mockState.uplifts = [{ ...pendingUplift, status: "auto_approved", requestedById: "admin-1" }];
renderWithProviders(<WorkOrderUpliftsDialog row={baseRow} open onClose={vi.fn()} />, {
withAuth: true,
});
expect(screen.getByText("Auto")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /revoke/i })).not.toBeInTheDocument();
});
it("shows Revoke to the dispatcher on the auto-approved uplift they requested", () => {
mockState.authUser = { id: "dispatcher-1", userRoles: "Dispatcher" };
mockState.uplifts = [
{ ...pendingUplift, status: "auto_approved", requestedById: "dispatcher-1" },
];
renderWithProviders(<WorkOrderUpliftsDialog row={baseRow} open onClose={vi.fn()} />, {
withAuth: true,
});
expect(screen.getByRole("button", { name: /revoke/i })).toBeInTheDocument();
});
});
describe("WorkOrderUpliftListItem revoke affordances", () => {
@ -154,6 +185,7 @@ describe("WorkOrderUpliftListItem revoke affordances", () => {
uplift={{ ...pendingUplift, status: "approved" }}
readOnly={false}
currentUserId="admin-1"
currentUserIsAdmin
onRevoke={vi.fn()}
/>,
{ withAuth: false },
@ -168,6 +200,7 @@ describe("WorkOrderUpliftListItem revoke affordances", () => {
uplift={{ ...pendingUplift, status: "auto_approved", requestedById: "dispatcher-1" }}
readOnly={false}
currentUserId="dispatcher-1"
currentUserIsAdmin={false}
onRevoke={vi.fn()}
/>,
{ withAuth: false },

View file

@ -0,0 +1,425 @@
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { toast } from "react-toastify";
import { Route, Routes } from "react-router";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import InviteRegistrationPage from "@/app/invite";
import { getAuthUser } from "@/lib/auth/auth-storage";
import { renderWithProviders } from "@/test/test-utils";
vi.mock("react-toastify", () => ({
toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() },
}));
const TOKEN = "Zm9yLXRlc3RzLW9ubHktbm90LWEtcmVhbC10b2tlbg";
const CODE = "246810";
const DETAILS = {
name: "Taylor Reed",
role: "Dispatcher",
email: "taylor@example.com",
phone: "555-0100",
};
const SESSION = {
token: "session-jwt",
expiration: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
email: DETAILS.email,
userRoles: "Dispatcher",
phoneNumber: "555-123-4567",
fullname: "Taylor Reed",
id: "user-7",
};
type Call = { path: string; url: string; body: Record<string, unknown> };
type ServerOptions = {
sendCodeTooSoon?: boolean;
resendTooSoon?: boolean;
/** The admin re-sent the invite after the code went out, revoking this token. */
revokedBeforeVerify?: boolean;
rejectPasswordAtFinish?: boolean;
/** How the first `resolve` call fails before the server answers normally. */
resolveFailsOnce?: "network" | "server-error";
};
const INVALID_INVITE_BODY = {
code: "invalid_invite",
message: "This invite link is invalid or has expired. Ask your admin to send a new invite.",
};
const PASSWORD_REJECTED_MESSAGE = "Choose a different password";
function json(status: number, body: unknown) {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
});
}
/** A fake of the four anonymous invite endpoints, scoped to one valid token. */
function installInviteServer({
sendCodeTooSoon = false,
resendTooSoon = false,
revokedBeforeVerify = false,
rejectPasswordAtFinish = false,
resolveFailsOnce,
}: ServerOptions = {}) {
const calls: Call[] = [];
let sends = 0;
let resolves = 0;
vi.spyOn(globalThis, "fetch").mockImplementation(async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init);
const path = new URL(request.url).pathname.replace(/^.*\/team-member-invites\//, "");
const body = (await request.json()) as Record<string, unknown>;
calls.push({ path, url: request.url, body });
if (path === "resolve" && resolveFailsOnce && ++resolves === 1) {
if (resolveFailsOnce === "network") {
throw new TypeError("Failed to fetch");
}
return json(500, { message: "Internal Server Error" });
}
if (body.token !== TOKEN || (revokedBeforeVerify && path === "verify-code")) {
return json(400, INVALID_INVITE_BODY);
}
switch (path) {
case "resolve":
return json(200, DETAILS);
case "send-code": {
sends += 1;
const refuse = sends === 1 ? sendCodeTooSoon : resendTooSoon;
return refuse
? json(429, {
code: "resend_too_soon",
message: "Please wait a moment before requesting another code",
retryAfterSeconds: 42,
})
: json(200, { message: "Code sent" });
}
case "verify-code":
return body.code === CODE
? json(200, { message: "Email confirmed" })
: json(400, {
code: "code_incorrect",
message: "Incorrect code — check your email and try again",
});
case "complete":
return rejectPasswordAtFinish
? json(400, { code: "password_rejected", message: PASSWORD_REJECTED_MESSAGE })
: json(200, SESSION);
default:
return json(404, {});
}
});
return calls;
}
function renderInvite(hash: string) {
return renderWithProviders(
<Routes>
<Route path="/invite" element={<InviteRegistrationPage />} />
<Route path="/" element={<div>Dashboard home</div>} />
<Route path="/login" element={<div>Login page</div>} />
</Routes>,
{ route: `/invite${hash}` },
);
}
function input(label: RegExp) {
return screen.getByLabelText(label, { selector: "input" });
}
function ruleStates() {
const list = screen.getByRole("list", { name: "Password requirements" });
return Object.fromEntries(
within(list)
.getAllByRole("listitem")
.map((item) => [item.getAttribute("data-rule"), item.getAttribute("data-met")]),
);
}
async function completePasswordStep(user: ReturnType<typeof userEvent.setup>) {
await screen.findByRole("heading", { name: "Create your password" });
await user.type(input(/^password/i), "Abc1!x");
await user.type(input(/^confirm password/i), "Abc1!x");
await user.click(screen.getByRole("button", { name: "Continue" }));
await screen.findByRole("heading", { name: "Confirm your email" });
}
async function completeCodeStep(user: ReturnType<typeof userEvent.setup>) {
await user.type(input(/^confirmation code/i), CODE);
await user.click(screen.getByRole("button", { name: "Continue" }));
await screen.findByRole("heading", { name: "Review your info" });
}
describe("Invite registration", { timeout: 15_000 }, () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("shows the generic unavailable message for an unknown token", async () => {
const calls = installInviteServer();
renderInvite("#not-a-real-token");
expect(await screen.findByRole("heading", { name: "Invite unavailable" })).toBeInTheDocument();
expect(
screen.getByText(
"This invite link is invalid or has expired. Ask your admin to send a new invite.",
),
).toBeInTheDocument();
expect(calls.map((call) => call.path)).toEqual(["resolve"]);
expect(screen.getByRole("link", { name: "Back to login" })).toHaveAttribute("href", "/login");
});
it.each(["network", "server-error"] as const)(
"offers a retry instead of the dead-invite message when resolve fails (%s)",
async (failure) => {
const calls = installInviteServer({ resolveFailsOnce: failure });
const user = userEvent.setup();
renderInvite(`#${TOKEN}`);
expect(
await screen.findByRole("heading", { name: "Couldn't load your invite" }),
).toBeInTheDocument();
expect(
screen.getByText(
"We couldn't reach the server to check your invite. Check your connection and try again.",
),
).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Invite unavailable" })).not.toBeInTheDocument();
expect(
screen.queryByText(
"This invite link is invalid or has expired. Ask your admin to send a new invite.",
),
).not.toBeInTheDocument();
expect(vi.mocked(toast.error)).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Try again" }));
expect(
await screen.findByRole("heading", { name: "Create your password" }),
).toBeInTheDocument();
expect(calls.map((call) => call.path)).toEqual(["resolve", "resolve"]);
},
);
it("does not call the server when the link carries no token", async () => {
const calls = installInviteServer();
renderInvite("");
expect(await screen.findByRole("heading", { name: "Invite unavailable" })).toBeInTheDocument();
expect(calls).toHaveLength(0);
});
it("step 1 rejects a password missing a rule, shows the live checklist, and requires a match", async () => {
installInviteServer();
const user = userEvent.setup();
renderInvite(`#${TOKEN}`);
expect(await screen.findByText("Step 1 of 3")).toBeInTheDocument();
expect(screen.getByText("Setting up the account for Taylor Reed")).toBeInTheDocument();
const continueButton = screen.getByRole("button", { name: "Continue" });
await user.type(input(/^password/i), "abcdefgh");
await user.type(input(/^confirm password/i), "abcdefgh");
expect(ruleStates()).toEqual({
length: "true",
uppercase: "false",
number: "false",
special: "false",
});
expect(continueButton).toBeDisabled();
await user.clear(input(/^password/i));
await user.type(input(/^password/i), "Abc1!x");
expect(ruleStates()).toEqual({
length: "true",
uppercase: "true",
number: "true",
special: "true",
});
await user.click(input(/^confirm password/i));
await user.tab();
expect(await screen.findByText("Passwords don't match")).toBeInTheDocument();
expect(continueButton).toBeDisabled();
await user.clear(input(/^confirm password/i));
await user.type(input(/^confirm password/i), "Abc1!x");
expect(continueButton).toBeEnabled();
});
it("step 2 shows a clear error for a wrong code, resends on request, and advances on the right code", async () => {
const calls = installInviteServer();
const user = userEvent.setup();
renderInvite(`#${TOKEN}`);
await completePasswordStep(user);
expect(screen.getByText("Step 2 of 3")).toBeInTheDocument();
expect(screen.getByText(`Enter the 6-digit code sent to ${DETAILS.email}`)).toBeInTheDocument();
const continueButton = screen.getByRole("button", { name: "Continue" });
await user.type(input(/^confirmation code/i), "123");
expect(continueButton).toBeDisabled();
await user.type(input(/^confirmation code/i), "456");
await user.click(continueButton);
expect(
await screen.findByText("Incorrect code — check your email and try again"),
).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Confirm your email" })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Resend code" }));
await waitFor(() =>
expect(vi.mocked(toast.success)).toHaveBeenCalledWith(`Code resent to ${DETAILS.email}`),
);
expect(calls.filter((call) => call.path === "send-code")).toHaveLength(2);
await user.clear(input(/^confirmation code/i));
await completeCodeStep(user);
expect(screen.getByText("Step 3 of 3")).toBeInTheDocument();
});
it("shows the cooldown message when a resend is refused", async () => {
installInviteServer({ resendTooSoon: true });
const user = userEvent.setup();
renderInvite(`#${TOKEN}`);
await completePasswordStep(user);
await user.click(screen.getByRole("button", { name: "Resend code" }));
expect(
await screen.findByText("Please wait a moment before requesting another code"),
).toBeInTheDocument();
expect(vi.mocked(toast.success)).not.toHaveBeenCalled();
});
it("still advances to step 2 when a code was already sent moments ago", async () => {
installInviteServer({ sendCodeTooSoon: true });
const user = userEvent.setup();
renderInvite(`#${TOKEN}`);
await completePasswordStep(user);
expect(screen.getByText("Step 2 of 3")).toBeInTheDocument();
});
it("step 3 keeps name, role and email read-only, lets the member edit phone, and finishing signs them in", async () => {
const calls = installInviteServer();
const user = userEvent.setup();
renderInvite(`#${TOKEN}`);
await completePasswordStep(user);
await completeCodeStep(user);
for (const value of [DETAILS.name, DETAILS.role, DETAILS.email]) {
expect(screen.getByText(value)).toBeInTheDocument();
expect(screen.queryByDisplayValue(value)).not.toBeInTheDocument();
}
expect(screen.getAllByRole("textbox")).toHaveLength(1);
const phone = input(/^phone/i);
expect(phone).toHaveValue(DETAILS.phone);
expect(phone).toHaveAttribute("type", "tel");
await user.clear(phone);
await user.type(phone, "555-123-4567");
await user.click(screen.getByRole("button", { name: /finish registration/i }));
expect(await screen.findByText("Dashboard home")).toBeInTheDocument();
expect(vi.mocked(toast.success)).toHaveBeenCalledWith(
"Registration complete — welcome to Seahaven",
);
expect(getAuthUser()).toMatchObject({ token: "session-jwt", id: "user-7" });
expect(calls.find((call) => call.path === "complete")?.body).toEqual({
token: TOKEN,
password: "Abc1!x",
phone: "555-123-4567",
});
});
it("step 3 blocks finishing with an invalid phone and never calls the server", async () => {
const calls = installInviteServer();
const user = userEvent.setup();
renderInvite(`#${TOKEN}`);
await completePasswordStep(user);
await completeCodeStep(user);
await user.clear(input(/^phone/i));
await user.type(input(/^phone/i), "call me");
await user.click(screen.getByRole("button", { name: /finish registration/i }));
expect(await screen.findByText("Enter a valid phone number")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Review your info" })).toBeInTheDocument();
expect(calls.some((call) => call.path === "complete")).toBe(false);
});
it("sends an empty phone when the member clears it", async () => {
const calls = installInviteServer();
const user = userEvent.setup();
renderInvite(`#${TOKEN}`);
await completePasswordStep(user);
await completeCodeStep(user);
await user.clear(input(/^phone/i));
await user.click(screen.getByRole("button", { name: /finish registration/i }));
await screen.findByText("Dashboard home");
expect(calls.find((call) => call.path === "complete")?.body.phone).toBe("");
});
it("ends the wizard with the generic message when the invite is revoked mid-way", async () => {
installInviteServer({ revokedBeforeVerify: true });
const user = userEvent.setup();
renderInvite(`#${TOKEN}`);
await completePasswordStep(user);
await user.type(input(/^confirmation code/i), CODE);
await user.click(screen.getByRole("button", { name: "Continue" }));
expect(await screen.findByRole("heading", { name: "Invite unavailable" })).toBeInTheDocument();
expect(screen.getByText(INVALID_INVITE_BODY.message)).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Confirm your email" })).not.toBeInTheDocument();
});
it("returns to step 1 with the server's message when the password is rejected at finish", async () => {
installInviteServer({ rejectPasswordAtFinish: true });
const user = userEvent.setup();
renderInvite(`#${TOKEN}`);
await completePasswordStep(user);
await completeCodeStep(user);
await user.click(screen.getByRole("button", { name: /finish registration/i }));
expect(
await screen.findByRole("heading", { name: "Create your password" }),
).toBeInTheDocument();
expect(screen.getByText(PASSWORD_REJECTED_MESSAGE)).toBeInTheDocument();
expect(getAuthUser()).toBeNull();
});
it("sends the invite token only in request bodies, never in a URL", async () => {
const calls = installInviteServer();
const user = userEvent.setup();
renderInvite(`#${TOKEN}`);
await completePasswordStep(user);
await completeCodeStep(user);
await user.click(screen.getByRole("button", { name: /finish registration/i }));
await screen.findByText("Dashboard home");
expect(calls.map((call) => call.path)).toEqual([
"resolve",
"send-code",
"verify-code",
"complete",
]);
for (const call of calls) {
expect(call.url).not.toContain(TOKEN);
expect(call.body.token).toBe(TOKEN);
}
});
});

View file

@ -0,0 +1,45 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const apiPost = vi.fn();
vi.mock("@/api/api", () => ({
apiPost: (...args: unknown[]) => apiPost(...args),
}));
import { forgotPassword, resetPassword, verifyResetCode } from "@/domain/auth/api/auth-api";
describe("password reset API", () => {
beforeEach(() => {
apiPost.mockReset();
apiPost.mockResolvedValue({ status: "Success ", message: "ok" });
});
it("sends the email in the request body, never in the URL", async () => {
await forgotPassword("alice@example.com");
expect(apiPost).toHaveBeenCalledWith("Authentication/ForgetPassword", {
email: "alice@example.com",
});
expect(String(apiPost.mock.calls[0][0])).not.toContain("?");
});
it("verifies a code together with the email it was issued to", async () => {
await verifyResetCode("alice@example.com", "123456");
expect(apiPost).toHaveBeenCalledWith("Authentication/VerificationCode", {
email: "alice@example.com",
code: "123456",
});
expect(String(apiPost.mock.calls[0][0])).not.toContain("123456");
});
it("resets with email, code and the new password in the body", async () => {
await resetPassword({ Email: "alice@example.com", Code: "123456", Password: "New@67890" });
expect(apiPost).toHaveBeenCalledWith("Authentication/ResetPassword", {
Email: "alice@example.com",
Code: "123456",
Password: "New@67890",
});
});
});

View file

@ -1,5 +1,4 @@
import { describe, expect, it } from "vitest";
import { completionTemplatePermissions } from "@/app/(protected)/completion-docs/_components/use-completion-template-permissions";
import {
mapCompletionTemplate,
toCompletionTemplateInput,
@ -52,16 +51,3 @@ describe("toCompletionTemplateInput", () => {
});
});
});
describe("completionTemplatePermissions", () => {
it.each([
["Admin", { canCreate: true, canEdit: true, canDelete: true }],
["Scheduler", { canCreate: true, canEdit: true, canDelete: false }],
["Dispatcher", { canCreate: false, canEdit: false, canDelete: false }],
["dispatcher, admin", { canCreate: true, canEdit: true, canDelete: true }],
["Vendor", { canCreate: false, canEdit: false, canDelete: false }],
[undefined, { canCreate: false, canEdit: false, canDelete: false }],
])("resolves %s to the role defaults", (roles, expected) => {
expect(completionTemplatePermissions(roles)).toEqual(expected);
});
});

View file

@ -0,0 +1,21 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { teamMemberInviteApi } from "@/domain/team-member-invite/api/team-member-invite-api";
import { teamMembersApi } from "@/domain/team-members/api/team-members-api";
/** The server has already acted when it answers, so an empty success body must still succeed. */
describe("invite calls that return nothing", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it.each([200, 204])("treat an empty %i response as success", async (status) => {
vi.spyOn(globalThis, "fetch").mockImplementation(async () => new Response(null, { status }));
await expect(teamMembersApi.resendInvite("user-7")).resolves.toBeUndefined();
await expect(teamMemberInviteApi.sendCode("token")).resolves.toBeUndefined();
await expect(
teamMemberInviteApi.verifyCode({ token: "token", code: "246810" }),
).resolves.toBeUndefined();
});
});

View file

@ -54,4 +54,10 @@ describe("mapTeamMemberDetail", () => {
],
});
});
it("keeps the invite email result only when the update reported one", () => {
expect(mapTeamMemberDetail({ id: "u1", inviteEmailSent: false }).inviteEmailSent).toBe(false);
expect(mapTeamMemberDetail({ Id: "u1", InviteEmailSent: true }).inviteEmailSent).toBe(true);
expect(mapTeamMemberDetail({ id: "u1" })).not.toHaveProperty("inviteEmailSent");
});
});

View file

@ -0,0 +1,66 @@
import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import { toast } from "react-toastify";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { teamMembersApi } from "@/domain/team-members/api/team-members-api";
import type {
CreateTeamMemberInput,
TeamMemberCreated,
} from "@/domain/team-members/types/team-member";
import {
INVITE_EMAIL_FAILED_MESSAGE,
useCreateTeamMember,
} from "@/domain/team-members/use-cases/use-create-team-member";
vi.mock("react-toastify", () => ({
toast: { success: vi.fn(), warning: vi.fn(), error: vi.fn() },
}));
vi.mock("@/domain/team-members/api/team-members-api", () => ({
teamMembersApi: { create: vi.fn() },
}));
const CREATED: TeamMemberCreated = {
id: "user-7",
name: "Taylor Reed",
role: "Dispatcher",
color: "#0D9488",
email: "taylor@example.com",
phone: "555-0100",
serviceAreas: ["East"],
pendingRegistration: true,
};
function wrapper({ children }: { children: ReactNode }) {
const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
async function create(created: TeamMemberCreated) {
vi.mocked(teamMembersApi.create).mockResolvedValue(created);
const { result } = renderHook(() => useCreateTeamMember(), { wrapper });
act(() => result.current.mutate({} as CreateTeamMemberInput));
await waitFor(() => expect(result.current.isSuccess).toBe(true));
}
describe("useCreateTeamMember", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("confirms the pending member when the invite email went out", async () => {
await create({ ...CREATED, inviteEmailSent: true });
expect(toast.success).toHaveBeenCalledWith("Team member created with pending registration.");
expect(toast.warning).not.toHaveBeenCalled();
});
it("warns the admin to re-send when the invite email could not be sent", async () => {
await create({ ...CREATED, inviteEmailSent: false });
expect(toast.warning).toHaveBeenCalledWith(INVITE_EMAIL_FAILED_MESSAGE);
expect(toast.success).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,72 @@
import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import { toast } from "react-toastify";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { teamMembersApi } from "@/domain/team-members/api/team-members-api";
import type {
TeamMemberDetail,
UpdateTeamMemberInput,
} from "@/domain/team-members/types/team-member";
import {
UPDATED_INVITE_EMAIL_FAILED_MESSAGE,
useUpdateTeamMember,
} from "@/domain/team-members/use-cases/use-team-member-detail";
vi.mock("react-toastify", () => ({
toast: { success: vi.fn(), warning: vi.fn(), error: vi.fn() },
}));
vi.mock("@/domain/team-members/api/team-members-api", () => ({
teamMembersApi: { update: vi.fn() },
}));
const UPDATED = {
id: "user-7",
name: "Taylor Reed",
email: "taylor.reed@example.com",
pendingRegistration: true,
serviceAreas: ["East"],
permissions: [],
} as unknown as TeamMemberDetail;
function wrapper({ children }: { children: ReactNode }) {
const client = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
}
async function update(updated: TeamMemberDetail) {
vi.mocked(teamMembersApi.update).mockResolvedValue(updated);
const { result } = renderHook(() => useUpdateTeamMember(), { wrapper });
act(() => result.current.mutate({ id: "user-7", input: {} as UpdateTeamMemberInput }));
await waitFor(() => expect(result.current.isSuccess).toBe(true));
}
describe("useUpdateTeamMember", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("confirms an ordinary update", async () => {
await update(UPDATED);
expect(toast.success).toHaveBeenCalledWith("Team member updated.");
expect(toast.warning).not.toHaveBeenCalled();
});
it("tells the admin a new invite went to the corrected email", async () => {
await update({ ...UPDATED, inviteEmailSent: true });
expect(toast.success).toHaveBeenCalledWith(
"Team member updated. A new invite was sent to taylor.reed@example.com.",
);
});
it("warns the admin to re-send when the new invite could not be emailed", async () => {
await update({ ...UPDATED, inviteEmailSent: false });
expect(toast.warning).toHaveBeenCalledWith(UPDATED_INVITE_EMAIL_FAILED_MESSAGE);
expect(toast.success).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,111 @@
import { describe, expect, it } from "vitest";
import {
a4PageHeight,
coverAllBlocks,
currentPageAt,
pageContentHeight,
pageIndicatorLabel,
paginateBlocks,
samePages,
} from "@/domain/work-orders/utils/completion-doc-pagination";
describe("paginateBlocks", () => {
it("keeps a short document on one page", () => {
expect(
paginateBlocks({ headerHeight: 200, blockHeights: [100, 150], pageHeight: 800 }),
).toEqual([[0, 1]]);
});
it("returns one empty page when there are no blocks", () => {
expect(paginateBlocks({ headerHeight: 200, blockHeights: [], pageHeight: 800 })).toEqual([[]]);
});
it("moves a block that no longer fits onto the next page, in order", () => {
expect(
paginateBlocks({ headerHeight: 300, blockHeights: [400, 200, 500, 300], pageHeight: 800 }),
).toEqual([[0], [1, 2], [3]]);
});
it("charges the letterhead to the first page only", () => {
// Page 1 has 800 - 500 = 300 left; later pages have the full 800.
expect(
paginateBlocks({ headerHeight: 500, blockHeights: [300, 400, 400], pageHeight: 800 }),
).toEqual([[0], [1, 2]]);
});
it("fills a page exactly without spilling", () => {
expect(
paginateBlocks({ headerHeight: 0, blockHeights: [400, 400, 1], pageHeight: 800 }),
).toEqual([[0, 1], [2]]);
});
it("gives a block taller than a page its own page instead of splitting or hiding it", () => {
expect(
paginateBlocks({ headerHeight: 200, blockHeights: [100, 2000, 100], pageHeight: 800 }),
).toEqual([[0], [1], [2]]);
});
it("starts the first block on page 2 when the letterhead alone fills page 1", () => {
expect(paginateBlocks({ headerHeight: 900, blockHeights: [100], pageHeight: 800 })).toEqual([
[],
[0],
]);
});
it("keeps an oversized first block on page 1 when there is no letterhead", () => {
expect(paginateBlocks({ headerHeight: 0, blockHeights: [2000, 100], pageHeight: 800 })).toEqual(
[[0], [1]],
);
});
});
describe("coverAllBlocks", () => {
it("adds blocks created since the last measurement to the last page", () => {
expect(coverAllBlocks([[0], [1]], 4)).toEqual([[0], [1, 2, 3]]);
});
it("drops blocks that were removed and the pages they leave empty", () => {
expect(coverAllBlocks([[0], [1, 2], [3]], 2)).toEqual([[0], [1]]);
});
it("always keeps the first page, even with no blocks", () => {
expect(coverAllBlocks([[0, 1]], 0)).toEqual([[]]);
expect(coverAllBlocks([], 2)).toEqual([[0, 1]]);
});
});
describe("page geometry", () => {
it("sizes a sheet as A4 portrait and reserves the bars and bottom padding", () => {
expect(a4PageHeight(210)).toBeCloseTo(297);
expect(pageContentHeight(420)).toBeCloseTo(594 - 12 - 24 - 28);
});
});
describe("currentPageAt", () => {
const tops = [0, 900, 1800];
it("reads page 1 at the top of the document", () => {
expect(currentPageAt(tops, 0)).toBe(1);
});
it("switches page once the next sheet reaches the top of the viewport", () => {
expect(currentPageAt(tops, 880)).toBe(1);
expect(currentPageAt(tops, 890)).toBe(2);
expect(currentPageAt(tops, 2500)).toBe(3);
});
});
describe("pageIndicatorLabel", () => {
it("shows Page X of Y only for multi-page documents", () => {
expect(pageIndicatorLabel({ current: 1, total: 1 })).toBe("");
expect(pageIndicatorLabel({ current: 2, total: 3 })).toBe(" · Page 2 of 3");
});
});
describe("samePages", () => {
it("compares page layouts by content", () => {
expect(samePages([[0], [1, 2]], [[0], [1, 2]])).toBe(true);
expect(samePages([[0], [1, 2]], [[0, 1], [2]])).toBe(false);
expect(samePages([[0]], [[0], []])).toBe(false);
});
});

View file

@ -12,6 +12,7 @@ describe("canRevokeWorkOrderUplift", () => {
canRevokeWorkOrderUplift(
{ status: "auto_approved", requestedById: "dispatcher-1" },
"dispatcher-1",
false,
),
).toBe(true);
});
@ -21,6 +22,7 @@ describe("canRevokeWorkOrderUplift", () => {
canRevokeWorkOrderUplift(
{ status: "auto_approved", requestedById: "other-dispatcher" },
"dispatcher-1",
false,
),
).toBe(false);
});
@ -30,28 +32,34 @@ describe("canRevokeWorkOrderUplift", () => {
canRevokeWorkOrderUplift(
{ status: "auto_approved", requestedById: "dispatcher-1" },
"admin-user",
true,
),
).toBe(false);
});
it("allows admin to revoke their own auto-approved uplift as the requester", () => {
it("rejects admin revoking an auto-approved uplift even as its requester", () => {
expect(
canRevokeWorkOrderUplift(
{ status: "auto_approved", requestedById: "admin-user" },
"admin-user",
true,
),
).toBe(true);
).toBe(false);
});
it("rejects approved revoke on the Work Order surface (SH-214)", () => {
const uplift = { status: "approved" as const, requestedById: "dispatcher-1" };
expect(canRevokeWorkOrderUplift(uplift, "dispatcher-1")).toBe(false);
expect(canRevokeWorkOrderUplift(uplift, "admin-user")).toBe(false);
expect(canRevokeWorkOrderUplift(uplift, "dispatcher-1", false)).toBe(false);
expect(canRevokeWorkOrderUplift(uplift, "admin-user", true)).toBe(false);
});
it("rejects revoke for other statuses", () => {
expect(canRevokeWorkOrderUplift({ status: "pending", requestedById: "u1" }, "u1")).toBe(false);
expect(canRevokeWorkOrderUplift({ status: "rejected", requestedById: "u1" }, "u1")).toBe(false);
expect(canRevokeWorkOrderUplift({ status: "pending", requestedById: "u1" }, "u1", false)).toBe(
false,
);
expect(canRevokeWorkOrderUplift({ status: "rejected", requestedById: "u1" }, "u1", false)).toBe(
false,
);
});
});