mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 04:33:11 +00:00
Compare commits
26 commits
90ffd85c3d
...
7f023c5a45
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f023c5a45 | ||
|
|
73c7094102 | ||
|
|
301fe44b0c | ||
|
|
53dcb6af9e | ||
|
|
6a6d519ed7 | ||
|
|
a554aa049e | ||
|
|
f36a07c99c | ||
|
|
810fa5d5b0 | ||
|
|
a27695b217 | ||
|
|
3cdb5e7d7b | ||
|
|
b55938104a | ||
|
|
727588ab52 | ||
|
|
a2d03f9461 | ||
|
|
18127c0f30 | ||
|
|
dc2565e3fe | ||
|
|
0b654bb07c | ||
|
|
3b7cf83332 | ||
|
|
f068beb45e | ||
|
|
06f70c193d | ||
|
|
7c38ebd579 | ||
|
|
383d5c3c6c | ||
|
|
9c19287172 | ||
|
|
d5cd0659a2 | ||
|
|
ca42d6e59f | ||
|
|
976cd908e3 | ||
|
|
297830ed35 |
65 changed files with 3074 additions and 255 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import { Mail } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { TeamMemberDetail } from "@/domain/team-members/types/team-member";
|
||||
import { useResendTeamMemberInvite } from "@/domain/team-members/use-cases/use-resend-team-member-invite";
|
||||
|
||||
/** Re-sends the registration invite to an active member who has not registered yet. */
|
||||
export function ResendInviteButton({ member }: { member: TeamMemberDetail }) {
|
||||
const resendInvite = useResendTeamMemberInvite();
|
||||
|
||||
if (!member.pendingRegistration || member.status !== "Active") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => resendInvite.mutate({ id: member.id, email: member.email })}
|
||||
loading={resendInvite.isPending}
|
||||
loadingText="Sending…"
|
||||
startIcon={<Mail size={15} aria-hidden="true" />}
|
||||
>
|
||||
Resend invite
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -37,6 +37,7 @@ import {
|
|||
import { getFormErrors } from "./team-member-permissions";
|
||||
import { TeamMemberFormFields, type TeamMemberForm } from "./add-team-member-dialog";
|
||||
import { canonicalRole, resolveFormColor } from "./team-member-form-options";
|
||||
import { ResendInviteButton } from "./resend-invite-button";
|
||||
import {
|
||||
useTeamMemberDetail,
|
||||
useUpdateTeamMember,
|
||||
|
|
@ -206,6 +207,7 @@ function MemberEditForm({
|
|||
}
|
||||
|
||||
function DrawerFooter({
|
||||
member,
|
||||
editing,
|
||||
form,
|
||||
setForm,
|
||||
|
|
@ -215,6 +217,7 @@ function DrawerFooter({
|
|||
onEdit,
|
||||
onSave,
|
||||
}: {
|
||||
member: TeamMemberDetail;
|
||||
editing: boolean;
|
||||
form: TeamMemberForm;
|
||||
setForm: Dispatch<SetStateAction<TeamMemberForm>>;
|
||||
|
|
@ -257,7 +260,8 @@ function DrawerFooter({
|
|||
</Stack>
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack direction="row" sx={{ justifyContent: "flex-end" }}>
|
||||
<Stack direction="row" sx={{ justifyContent: "flex-end", gap: 1.5 }}>
|
||||
<ResendInviteButton member={member} />
|
||||
<Button variant="contained" onClick={onEdit}>
|
||||
Edit
|
||||
</Button>
|
||||
|
|
@ -475,6 +479,7 @@ export function TeamMemberDetailDrawer({
|
|||
|
||||
{currentMember !== undefined && (
|
||||
<DrawerFooter
|
||||
member={currentMember}
|
||||
editing={editor.editing}
|
||||
form={editor.form}
|
||||
setForm={editor.setForm}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export const PERMISSION_LABELS: Record<string, string> = {
|
|||
};
|
||||
|
||||
export const ALL_PERMISSION_KEYS = PERMISSION_GROUPS.flatMap((group) => group.keys);
|
||||
export type PermissionKey = (typeof ALL_PERMISSION_KEYS)[number];
|
||||
const DISPATCHER_DEFAULTS = new Set([
|
||||
"createVendors",
|
||||
"editVendors",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,112 @@
|
|||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import {
|
||||
a4PageHeight,
|
||||
coverAllBlocks,
|
||||
currentPageAt,
|
||||
pageContentHeight,
|
||||
paginateBlocks,
|
||||
samePages,
|
||||
type PagePosition,
|
||||
} from "@/domain/work-orders/utils/completion-doc-pagination";
|
||||
|
||||
type PageLayout = {
|
||||
pages: number[][];
|
||||
/** Sheet height in px; undefined until the sheets have a measurable width. */
|
||||
pageHeight: number | undefined;
|
||||
};
|
||||
|
||||
const heightOf = (el: Element | null | undefined) => el?.getBoundingClientRect().height ?? 0;
|
||||
|
||||
function allOnOnePage(count: number): number[][] {
|
||||
return [Array.from({ length: count }, (_, i) => i)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Measures the rendered letterhead and blocks and splits them into A4 sheets.
|
||||
* Blocks are measured where they are rendered (every sheet has the same width),
|
||||
* and re-measured after every render and whenever anything observed resizes.
|
||||
*/
|
||||
export function useCompDocPagination(
|
||||
blockCount: number,
|
||||
onPageChange?: (position: PagePosition) => void,
|
||||
) {
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
const sheetsRef = useRef<HTMLDivElement>(null);
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
const blockRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const pageRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const [layout, setLayout] = useState<PageLayout>({ pages: [[]], pageHeight: undefined });
|
||||
const pages = coverAllBlocks(layout.pages, blockCount);
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const width = sheetsRef.current?.getBoundingClientRect().width ?? 0;
|
||||
const next: PageLayout =
|
||||
width > 0
|
||||
? {
|
||||
pages: paginateBlocks({
|
||||
headerHeight: heightOf(headerRef.current),
|
||||
blockHeights: Array.from({ length: blockCount }, (_, i) =>
|
||||
heightOf(blockRefs.current[i]),
|
||||
),
|
||||
pageHeight: pageContentHeight(width),
|
||||
}),
|
||||
pageHeight: a4PageHeight(width),
|
||||
}
|
||||
: { pages: allOnOnePage(blockCount), pageHeight: undefined };
|
||||
setLayout((prev) =>
|
||||
samePages(prev.pages, next.pages) && prev.pageHeight === next.pageHeight ? prev : next,
|
||||
);
|
||||
}, [blockCount]);
|
||||
|
||||
// Block content can change on any render (typing in the editor), so measure after each one.
|
||||
useLayoutEffect(() => {
|
||||
measure();
|
||||
});
|
||||
|
||||
const onPageChangeRef = useRef(onPageChange);
|
||||
const totalRef = useRef(pages.length);
|
||||
const lastReported = useRef<PagePosition | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
onPageChangeRef.current = onPageChange;
|
||||
totalRef.current = pages.length;
|
||||
});
|
||||
|
||||
const report = useCallback(() => {
|
||||
const total = totalRef.current;
|
||||
const tops = pageRefs.current.slice(0, total).map((el) => el?.offsetTop ?? 0);
|
||||
const current = Math.min(currentPageAt(tops, scrollerRef.current?.scrollTop ?? 0), total);
|
||||
const last = lastReported.current;
|
||||
if (last?.current === current && last.total === total) return;
|
||||
lastReported.current = { current, total };
|
||||
onPageChangeRef.current?.({ current, total });
|
||||
}, []);
|
||||
|
||||
// Any re-layout can move the sheet tops under the reader, even when the page count holds.
|
||||
useLayoutEffect(() => {
|
||||
report();
|
||||
});
|
||||
|
||||
// Width changes, late font/image loads and reflow do not re-render; observe them.
|
||||
const layoutKey = pages.map((page) => page.join(",")).join("|");
|
||||
useEffect(() => {
|
||||
const observer = new ResizeObserver(() => {
|
||||
measure();
|
||||
report();
|
||||
});
|
||||
[sheetsRef.current, headerRef.current, ...blockRefs.current.slice(0, blockCount)].forEach(
|
||||
(el) => el && observer.observe(el),
|
||||
);
|
||||
return () => observer.disconnect();
|
||||
}, [measure, report, blockCount, layoutKey]);
|
||||
|
||||
return {
|
||||
pages,
|
||||
pageHeight: layout.pageHeight,
|
||||
scrollerRef,
|
||||
sheetsRef,
|
||||
headerRef,
|
||||
blockRefs,
|
||||
pageRefs,
|
||||
onScroll: report,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { extractLocationLabel } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-helpers";
|
||||
import type { SitePatch } from "@/app/(protected)/workorders/_components/list/table/cells/site-dialog-types";
|
||||
import { normalizeAdditionalContacts } from "@/domain/work-orders/mappers/additional-contacts-mapper";
|
||||
import { resolveSiteCode } from "@/domain/work-orders/mappers/work-order-board-mapper";
|
||||
import type { WorkOrderAdditionalContact } from "@/domain/work-orders/types/work-order-additional-contact";
|
||||
import type { LocationOption } from "@/domain/work-orders/types/work-order";
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 ?? "",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { StatusCell } from "./cells/status-cell";
|
|||
import { UpliftCell } from "./cells/uplift-cell";
|
||||
import { VendorCell } from "./cells/vendor-cell";
|
||||
import { EMPTY_UPLIFT_SUMMARY } from "@/domain/work-orders/types/work-order-uplift";
|
||||
import { canOpenUpliftsDialog } from "@/domain/work-orders/utils/uplift-display-utils";
|
||||
import { isWorkOrderUpliftsReadOnly } from "@/domain/work-orders/utils/uplift-display-utils";
|
||||
import { applyVendorTableSave } from "@/domain/work-orders/utils/work-order-feedback-toasts";
|
||||
import { effectiveCompany } from "@/domain/work-orders/utils/effective-completion-snapshot";
|
||||
import type { WoTableRowHandlers } from "./wo-table-row";
|
||||
|
|
@ -132,12 +132,8 @@ export function WoTableRowServiceCells({
|
|||
<td style={{ padding: "0 14px" }} onClick={(e) => e.stopPropagation()}>
|
||||
<UpliftCell
|
||||
summary={row.upliftSummary ?? EMPTY_UPLIFT_SUMMARY}
|
||||
onOpen={
|
||||
canOpenUpliftsDialog(row.upliftSummary ?? EMPTY_UPLIFT_SUMMARY, row.status) &&
|
||||
handlers.onOpenUplifts
|
||||
? () => handlers.onOpenUplifts!(row)
|
||||
: undefined
|
||||
}
|
||||
onOpen={handlers.onOpenUplifts ? () => handlers.onOpenUplifts!(row) : undefined}
|
||||
readOnly={isWorkOrderUpliftsReadOnly(row.status)}
|
||||
/>
|
||||
</td>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ type PocContactFieldsProps = {
|
|||
showPrimaryErrors?: boolean;
|
||||
/** Labels the primary name/phone inputs with a persistent required asterisk. */
|
||||
primaryRequired?: boolean;
|
||||
/** Offers a Notes box on each additional contact; off where contacts save without per-contact notes. */
|
||||
additionalContactNotes?: boolean;
|
||||
};
|
||||
|
||||
function extraNameMissing(contact: WorkOrderAdditionalContact): boolean {
|
||||
|
|
@ -49,11 +51,13 @@ function updateContactAt(
|
|||
function AdditionalPocCard({
|
||||
contact,
|
||||
index,
|
||||
showNotes,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
contact: WorkOrderAdditionalContact;
|
||||
index: number;
|
||||
showNotes: boolean;
|
||||
onChange: (patch: Partial<WorkOrderAdditionalContact>) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
|
|
@ -115,14 +119,16 @@ function AdditionalPocCard({
|
|||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={contact.notes ?? ""}
|
||||
onChange={(event) => onChange({ notes: event.target.value })}
|
||||
placeholder="Notes…"
|
||||
rows={2}
|
||||
className={`${DLG_INPUT_CLS} resize-none`}
|
||||
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
|
||||
/>
|
||||
{showNotes && (
|
||||
<textarea
|
||||
value={contact.notes ?? ""}
|
||||
onChange={(event) => onChange({ notes: event.target.value })}
|
||||
placeholder="Notes…"
|
||||
rows={2}
|
||||
className={`${DLG_INPUT_CLS} resize-none`}
|
||||
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -139,6 +145,7 @@ export function PocContactFields({
|
|||
helperText,
|
||||
showPrimaryErrors = false,
|
||||
primaryRequired = false,
|
||||
additionalContactNotes = true,
|
||||
}: PocContactFieldsProps) {
|
||||
const fieldId = useId();
|
||||
const nameMissing = showPrimaryErrors && !pocName.trim();
|
||||
|
|
@ -214,6 +221,7 @@ export function PocContactFields({
|
|||
key={`additional-poc-${index}`}
|
||||
contact={contact}
|
||||
index={index}
|
||||
showNotes={additionalContactNotes}
|
||||
onChange={(patch) =>
|
||||
onAdditionalContactsChange(updateContactAt(additionalContacts, index, patch))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ type WorkOrderUpliftListItemProps = {
|
|||
uplift: WorkOrderUplift;
|
||||
readOnly: boolean;
|
||||
currentUserId: string | number | null | undefined;
|
||||
currentUserIsAdmin: boolean;
|
||||
pendingAction?: boolean;
|
||||
onCancel?: () => void;
|
||||
onRevoke?: () => void;
|
||||
|
|
@ -21,6 +22,7 @@ export function WorkOrderUpliftListItem({
|
|||
uplift,
|
||||
readOnly,
|
||||
currentUserId,
|
||||
currentUserIsAdmin,
|
||||
pendingAction = false,
|
||||
onCancel,
|
||||
onRevoke,
|
||||
|
|
@ -28,7 +30,9 @@ export function WorkOrderUpliftListItem({
|
|||
const pillStyle = getUpliftStatusPillStyle(uplift.status);
|
||||
const showCancel = !readOnly && uplift.status === "pending" && Boolean(onCancel);
|
||||
const showRevoke =
|
||||
!readOnly && canRevokeWorkOrderUplift(uplift, currentUserId) && Boolean(onRevoke);
|
||||
!readOnly &&
|
||||
canRevokeWorkOrderUplift(uplift, currentUserId, currentUserIsAdmin) &&
|
||||
Boolean(onRevoke);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-[var(--color-border)] p-3">
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { hasOpenWorkOrderUplift } from "@/domain/work-orders/utils/uplift-displa
|
|||
type WorkOrderUpliftsDialogContentProps = {
|
||||
readOnly: boolean;
|
||||
currentUserId: string | number | null | undefined;
|
||||
currentUserIsAdmin: boolean;
|
||||
readOnlyStatusLabel?: string;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
|
|
@ -28,6 +29,7 @@ type WorkOrderUpliftsDialogContentProps = {
|
|||
export function WorkOrderUpliftsDialogContent({
|
||||
readOnly,
|
||||
currentUserId,
|
||||
currentUserIsAdmin,
|
||||
readOnlyStatusLabel,
|
||||
isLoading,
|
||||
error,
|
||||
|
|
@ -77,6 +79,7 @@ export function WorkOrderUpliftsDialogContent({
|
|||
uplift={uplift}
|
||||
readOnly={readOnly}
|
||||
currentUserId={currentUserId}
|
||||
currentUserIsAdmin={currentUserIsAdmin}
|
||||
pendingAction={actionPending}
|
||||
onCancel={readOnly ? undefined : () => onCancelUplift(uplift.id)}
|
||||
onRevoke={readOnly ? undefined : () => onRevokeUplift(uplift.id)}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import {
|
|||
isWorkOrderUpliftsReadOnly,
|
||||
upliftRevokeRequiresReason,
|
||||
} from "@/domain/work-orders/utils/uplift-display-utils";
|
||||
import { isAdminUser } from "@/lib/auth/user-utils";
|
||||
import { useAuthContext } from "@/providers/auth-context";
|
||||
|
||||
type RevokeTarget = {
|
||||
|
|
@ -78,6 +79,7 @@ export function WorkOrderUpliftsDialog({ row, open, onClose }: WorkOrderUpliftsD
|
|||
<WorkOrderUpliftsDialogContent
|
||||
readOnly={readOnly}
|
||||
currentUserId={user?.id}
|
||||
currentUserIsAdmin={isAdminUser(user?.userRoles)}
|
||||
readOnlyStatusLabel={row?.status}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
|
|
|
|||
100
src/app/invite/_components/invite-code-step.tsx
Normal file
100
src/app/invite/_components/invite-code-step.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FormField } from "@/components/ui/form-field";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { TextField } from "@/components/ui/text-field";
|
||||
import {
|
||||
inviteCodeStepSchema,
|
||||
type InviteCodeStepValues,
|
||||
} from "@/domain/team-member-invite/schemas/invite-registration-schema";
|
||||
|
||||
import { InviteStepHeader } from "./invite-step-header";
|
||||
|
||||
export type InviteCodeStepProps = {
|
||||
email: string;
|
||||
isVerifying: boolean;
|
||||
isResending: boolean;
|
||||
/** Why the entered code was refused; shown under the field. */
|
||||
codeError: string | null;
|
||||
/** Why a new code could not be sent. */
|
||||
resendError: string | null;
|
||||
onVerify: (code: string) => void;
|
||||
onResend: () => void;
|
||||
onCodeChange: () => void;
|
||||
};
|
||||
|
||||
export function InviteCodeStep({
|
||||
email,
|
||||
isVerifying,
|
||||
isResending,
|
||||
codeError,
|
||||
resendError,
|
||||
onVerify,
|
||||
onResend,
|
||||
onCodeChange,
|
||||
}: InviteCodeStepProps) {
|
||||
const { register, handleSubmit, control } = useForm<InviteCodeStepValues>({
|
||||
resolver: zodResolver(inviteCodeStepSchema),
|
||||
defaultValues: { code: "" },
|
||||
});
|
||||
|
||||
const values = useWatch({ control });
|
||||
const canContinue = inviteCodeStepSchema.safeParse(values).success;
|
||||
const codeField = register("code", { onChange: onCodeChange });
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="form"
|
||||
aria-label="Confirm your email"
|
||||
onSubmit={handleSubmit((form) => onVerify(form.code))}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<InviteStepHeader
|
||||
step={2}
|
||||
title="Confirm your email"
|
||||
subtitle={`Enter the 6-digit code sent to ${email}`}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Confirmation code"
|
||||
htmlFor="invite-code"
|
||||
required
|
||||
appearance="auth"
|
||||
error={codeError !== null}
|
||||
helperText={codeError ?? undefined}
|
||||
>
|
||||
<TextField
|
||||
{...codeField}
|
||||
id="invite-code"
|
||||
placeholder="123456"
|
||||
autoComplete="one-time-code"
|
||||
error={codeError !== null}
|
||||
slotProps={{ htmlInput: { inputMode: "numeric", maxLength: 6 } }}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<Button type="button" variant="link" onClick={onResend} disabled={isResending}>
|
||||
Resend code
|
||||
</Button>
|
||||
<Text variant="error" when={resendError !== null}>
|
||||
{resendError}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
size="lg"
|
||||
disabled={!canContinue}
|
||||
loading={isVerifying}
|
||||
loadingText="Checking code…"
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
101
src/app/invite/_components/invite-password-step.tsx
Normal file
101
src/app/invite/_components/invite-password-step.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { useForm, useWatch } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Box } from "@mui/material";
|
||||
|
||||
import { PasswordChecklist } from "@/components/auth/password-checklist";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { FormField } from "@/components/ui/form-field";
|
||||
import { PasswordField } from "@/components/ui/password-field";
|
||||
import {
|
||||
invitePasswordStepSchema,
|
||||
type InvitePasswordStepValues,
|
||||
} from "@/domain/team-member-invite/schemas/invite-registration-schema";
|
||||
|
||||
import { InviteStepHeader } from "./invite-step-header";
|
||||
|
||||
export type InvitePasswordStepProps = {
|
||||
name: string;
|
||||
defaultPassword: string;
|
||||
isSubmitting: boolean;
|
||||
error: string | null;
|
||||
onContinue: (password: string) => void;
|
||||
};
|
||||
|
||||
export function InvitePasswordStep({
|
||||
name,
|
||||
defaultPassword,
|
||||
isSubmitting,
|
||||
error,
|
||||
onContinue,
|
||||
}: InvitePasswordStepProps) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useForm<InvitePasswordStepValues>({
|
||||
resolver: zodResolver(invitePasswordStepSchema),
|
||||
defaultValues: { password: defaultPassword, confirmPassword: defaultPassword },
|
||||
mode: "onTouched",
|
||||
});
|
||||
|
||||
const values = useWatch({ control });
|
||||
const canContinue = invitePasswordStepSchema.safeParse(values).success;
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="form"
|
||||
aria-label="Create your password"
|
||||
onSubmit={handleSubmit((form) => onContinue(form.password))}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<InviteStepHeader
|
||||
step={1}
|
||||
title="Create your password"
|
||||
subtitle={`Setting up the account for ${name}`}
|
||||
/>
|
||||
|
||||
<FormField label="Password" htmlFor="invite-password" required appearance="auth">
|
||||
<PasswordField
|
||||
{...register("password")}
|
||||
id="invite-password"
|
||||
autoComplete="new-password"
|
||||
slotProps={{ htmlInput: { "aria-describedby": "invite-password-rules" } }}
|
||||
/>
|
||||
<PasswordChecklist id="invite-password-rules" password={values.password ?? ""} />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="Confirm password"
|
||||
htmlFor="invite-confirm-password"
|
||||
required
|
||||
appearance="auth"
|
||||
error={Boolean(errors.confirmPassword)}
|
||||
helperText={errors.confirmPassword?.message}
|
||||
>
|
||||
<PasswordField
|
||||
{...register("confirmPassword")}
|
||||
id="invite-confirm-password"
|
||||
autoComplete="new-password"
|
||||
error={Boolean(errors.confirmPassword)}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<Text variant="error" when={error !== null}>
|
||||
{error}
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
size="lg"
|
||||
disabled={!canContinue}
|
||||
loading={isSubmitting}
|
||||
loadingText="Sending code…"
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
100
src/app/invite/_components/invite-review-step.tsx
Normal file
100
src/app/invite/_components/invite-review-step.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Box } from "@mui/material";
|
||||
import { CircleCheck } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FormField } from "@/components/ui/form-field";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { TextField } from "@/components/ui/text-field";
|
||||
import {
|
||||
inviteReviewStepSchema,
|
||||
type InviteReviewStepValues,
|
||||
} from "@/domain/team-member-invite/schemas/invite-registration-schema";
|
||||
import type { TeamMemberInviteDetails } from "@/domain/team-member-invite/types/team-member-invite";
|
||||
|
||||
import { InviteStepHeader } from "./invite-step-header";
|
||||
|
||||
export type InviteReviewStepProps = {
|
||||
details: TeamMemberInviteDetails;
|
||||
isFinishing: boolean;
|
||||
error: string | null;
|
||||
onFinish: (phone: string) => void;
|
||||
};
|
||||
|
||||
function ReadOnlyValue({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text variant="label" className="block">
|
||||
{label}
|
||||
</Text>
|
||||
<Text variant="body" className="m-0 text-(length:--text-base-sm)">
|
||||
{value}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function InviteReviewStep({ details, isFinishing, error, onFinish }: InviteReviewStepProps) {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<InviteReviewStepValues>({
|
||||
resolver: zodResolver(inviteReviewStepSchema),
|
||||
defaultValues: { phone: details.phone ?? "" },
|
||||
mode: "onTouched",
|
||||
});
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="form"
|
||||
aria-label="Review your info"
|
||||
onSubmit={handleSubmit((form) => onFinish(form.phone))}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<InviteStepHeader
|
||||
step={3}
|
||||
title="Review your info"
|
||||
subtitle="Name, role, and email are set by your admin — contact them to change these."
|
||||
/>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<ReadOnlyValue label="Name" value={details.name} />
|
||||
<ReadOnlyValue label="Role" value={details.role} />
|
||||
<ReadOnlyValue label="Email" value={details.email} />
|
||||
<FormField
|
||||
label="Phone"
|
||||
htmlFor="invite-phone"
|
||||
appearance="auth"
|
||||
error={Boolean(errors.phone)}
|
||||
helperText={errors.phone?.message}
|
||||
>
|
||||
<TextField
|
||||
{...register("phone")}
|
||||
id="invite-phone"
|
||||
type="tel"
|
||||
autoComplete="tel"
|
||||
placeholder="(000) 000-0000"
|
||||
error={Boolean(errors.phone)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<Text variant="error" when={error !== null}>
|
||||
{error}
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
fullWidth
|
||||
size="lg"
|
||||
loading={isFinishing}
|
||||
loadingText="Finishing…"
|
||||
startIcon={<CircleCheck size={15} aria-hidden="true" />}
|
||||
>
|
||||
Finish registration
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
46
src/app/invite/_components/invite-step-header.tsx
Normal file
46
src/app/invite/_components/invite-step-header.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { Text } from "@/components/ui/text";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const INVITE_STEP_COUNT = 3;
|
||||
|
||||
export type InviteStep = 1 | 2 | 3;
|
||||
|
||||
export type InviteStepHeaderProps = {
|
||||
step: InviteStep;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
};
|
||||
|
||||
export function InviteStepHeader({ step, title, subtitle }: InviteStepHeaderProps) {
|
||||
return (
|
||||
<div className="mb-5">
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label="Registration progress"
|
||||
aria-valuemin={1}
|
||||
aria-valuemax={INVITE_STEP_COUNT}
|
||||
aria-valuenow={step}
|
||||
className="mb-5 flex items-center gap-1.5"
|
||||
>
|
||||
{[1, 2, 3].map((bar) => (
|
||||
<span
|
||||
key={bar}
|
||||
className={cn(
|
||||
"h-[3px] flex-1 rounded-sm",
|
||||
bar <= step ? "bg-(--color-primary)" : "bg-(--color-border)",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<Text variant="caption" tone="muted" className="block">
|
||||
{`Step ${step} of ${INVITE_STEP_COUNT}`}
|
||||
</Text>
|
||||
<Text variant="title" className="m-0 mt-1! ![font-size:var(--text-lg)] !font-semibold">
|
||||
{title}
|
||||
</Text>
|
||||
<Text variant="description" tone="muted" className="mt-1!">
|
||||
{subtitle}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
src/app/invite/_components/invite-wizard.tsx
Normal file
137
src/app/invite/_components/invite-wizard.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import { useState } from "react";
|
||||
import { toast } from "react-toastify";
|
||||
|
||||
import { inviteErrorCode } from "@/domain/team-member-invite/api/team-member-invite-api";
|
||||
import type { TeamMemberInviteDetails } from "@/domain/team-member-invite/types/team-member-invite";
|
||||
import {
|
||||
useCompleteRegistration,
|
||||
useSendInviteCode,
|
||||
useVerifyInviteCode,
|
||||
} from "@/domain/team-member-invite/use-cases/use-team-member-invite";
|
||||
|
||||
import { InviteCodeStep } from "./invite-code-step";
|
||||
import { InvitePasswordStep } from "./invite-password-step";
|
||||
import { InviteReviewStep } from "./invite-review-step";
|
||||
import type { InviteStep } from "./invite-step-header";
|
||||
|
||||
export type InviteWizardProps = {
|
||||
token: string;
|
||||
details: TeamMemberInviteDetails;
|
||||
/** Called when the server reports the invite can no longer be used. */
|
||||
onInviteInvalid: () => void;
|
||||
};
|
||||
|
||||
export function InviteWizard({ token, details, onInviteInvalid }: InviteWizardProps) {
|
||||
const [step, setStep] = useState<InviteStep>(1);
|
||||
const [password, setPassword] = useState("");
|
||||
const [stepError, setStepError] = useState<string | null>(null);
|
||||
const [codeError, setCodeError] = useState<string | null>(null);
|
||||
const [resendError, setResendError] = useState<string | null>(null);
|
||||
const sendCode = useSendInviteCode();
|
||||
const verifyCode = useVerifyInviteCode();
|
||||
const complete = useCompleteRegistration();
|
||||
|
||||
/** Returns true when the failure ended the invite and the wizard should stop. */
|
||||
const endedInvite = (error: Error) => {
|
||||
if (inviteErrorCode(error) !== "invalid_invite") {
|
||||
return false;
|
||||
}
|
||||
onInviteInvalid();
|
||||
return true;
|
||||
};
|
||||
|
||||
const handlePasswordContinue = (nextPassword: string) => {
|
||||
setPassword(nextPassword);
|
||||
setStepError(null);
|
||||
sendCode.mutate(token, {
|
||||
onSuccess: () => setStep(2),
|
||||
onError: (error) => {
|
||||
if (endedInvite(error)) return;
|
||||
// A code sent moments ago is still valid, so the member can go on and enter it.
|
||||
if (inviteErrorCode(error) === "resend_too_soon") {
|
||||
setStep(2);
|
||||
return;
|
||||
}
|
||||
setStepError(error.message);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleResend = () => {
|
||||
setResendError(null);
|
||||
sendCode.mutate(token, {
|
||||
onSuccess: () => {
|
||||
setCodeError(null);
|
||||
toast.success(`Code resent to ${details.email}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
if (!endedInvite(error)) setResendError(error.message);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleVerify = (code: string) => {
|
||||
setCodeError(null);
|
||||
verifyCode.mutate(
|
||||
{ token, code },
|
||||
{
|
||||
onSuccess: () => setStep(3),
|
||||
onError: (error) => {
|
||||
if (!endedInvite(error)) setCodeError(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleFinish = (phone: string) => {
|
||||
setStepError(null);
|
||||
complete.mutate(
|
||||
{ token, password, phone },
|
||||
{
|
||||
onError: (error) => {
|
||||
if (endedInvite(error)) return;
|
||||
if (inviteErrorCode(error) === "password_rejected") {
|
||||
setStep(1);
|
||||
}
|
||||
setStepError(error.message);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
if (step === 1) {
|
||||
return (
|
||||
<InvitePasswordStep
|
||||
name={details.name}
|
||||
defaultPassword={password}
|
||||
isSubmitting={sendCode.isPending}
|
||||
error={stepError}
|
||||
onContinue={handlePasswordContinue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 2) {
|
||||
return (
|
||||
<InviteCodeStep
|
||||
email={details.email}
|
||||
isVerifying={verifyCode.isPending}
|
||||
isResending={sendCode.isPending}
|
||||
codeError={codeError}
|
||||
resendError={resendError}
|
||||
onVerify={handleVerify}
|
||||
onResend={handleResend}
|
||||
onCodeChange={() => setCodeError(null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InviteReviewStep
|
||||
details={details}
|
||||
isFinishing={complete.isPending}
|
||||
error={stepError}
|
||||
onFinish={handleFinish}
|
||||
/>
|
||||
);
|
||||
}
|
||||
87
src/app/invite/index.tsx
Normal file
87
src/app/invite/index.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { useState } from "react";
|
||||
import { Link, useLocation } from "react-router";
|
||||
import { Box, CircularProgress } from "@mui/material";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
|
||||
import { AuthBrandLogo } from "@/app/(auth)/_components/auth-brand-logo";
|
||||
import { AuthCardHeader } from "@/app/(auth)/_components/auth-card-header";
|
||||
import { AuthPageShell } from "@/app/(auth)/_components/auth-page-shell";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { inviteErrorCode } from "@/domain/team-member-invite/api/team-member-invite-api";
|
||||
import {
|
||||
INVALID_INVITE_MESSAGE,
|
||||
INVITE_UNREACHABLE_MESSAGE,
|
||||
} from "@/domain/team-member-invite/types/team-member-invite";
|
||||
import { useTeamMemberInviteDetails } from "@/domain/team-member-invite/use-cases/use-team-member-invite";
|
||||
|
||||
import { InviteWizard } from "./_components/invite-wizard";
|
||||
|
||||
/** The invite email links to `/invite#<token>`; a fragment never reaches a server or a Referer. */
|
||||
function readInviteToken(hash: string): string {
|
||||
return hash.replace(/^#/, "").trim();
|
||||
}
|
||||
|
||||
function InviteBody({ token }: { token: string }) {
|
||||
const details = useTeamMemberInviteDetails(token);
|
||||
const [inviteEnded, setInviteEnded] = useState(false);
|
||||
|
||||
if (token.length > 0 && details.isPending) {
|
||||
return (
|
||||
<Box className="flex justify-center py-8" role="status" aria-label="Loading invite">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Only the server's own `invalid_invite` answer means the link is dead. A request that never
|
||||
// got that answer (offline, timeout, 5xx) says nothing about the token, so offer a retry
|
||||
// instead of sending the member to their admin for a new invite.
|
||||
if (!inviteEnded && details.isError && inviteErrorCode(details.error) !== "invalid_invite") {
|
||||
return (
|
||||
<Box>
|
||||
<AuthCardHeader title="Couldn't load your invite" subtitle={INVITE_UNREACHABLE_MESSAGE} />
|
||||
<Button
|
||||
type="button"
|
||||
fullWidth
|
||||
size="lg"
|
||||
loading={details.isFetching}
|
||||
loadingText="Trying again…"
|
||||
onClick={() => void details.refetch()}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (inviteEnded || details.data === undefined) {
|
||||
return <AuthCardHeader title="Invite unavailable" subtitle={INVALID_INVITE_MESSAGE} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<InviteWizard
|
||||
token={token}
|
||||
details={details.data}
|
||||
onInviteInvalid={() => setInviteEnded(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InviteRegistrationPage() {
|
||||
const { hash } = useLocation();
|
||||
const token = readInviteToken(hash);
|
||||
|
||||
return (
|
||||
<AuthPageShell>
|
||||
<AuthBrandLogo className="mb-8" />
|
||||
<Link
|
||||
to="/login"
|
||||
className="mb-6 inline-flex items-center gap-1 text-(length:--text-xs) text-(--color-text-muted) no-underline hover:text-foreground"
|
||||
>
|
||||
<ChevronLeft size={13} aria-hidden="true" />
|
||||
Back to login
|
||||
</Link>
|
||||
<InviteBody token={token} />
|
||||
</AuthPageShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -40,12 +40,14 @@ export async function changePassword(data: ChangePasswordPayload): Promise<unkno
|
|||
return apiPost(API_PATHS.authentication.changePassword, data);
|
||||
}
|
||||
|
||||
// Email and code travel in the JSON body so they stay out of URLs, traces and proxy logs.
|
||||
export async function forgotPassword(email: string): Promise<unknown> {
|
||||
return apiPost(`${API_PATHS.authentication.forgetPassword}?Email=${encodeURIComponent(email)}`);
|
||||
return apiPost(API_PATHS.authentication.forgetPassword, { email });
|
||||
}
|
||||
|
||||
export async function verifyResetCode(code: string): Promise<unknown> {
|
||||
return apiPost(`${API_PATHS.authentication.verificationCode}?code=${encodeURIComponent(code)}`);
|
||||
// The server checks a code only against the email it was issued to.
|
||||
export async function verifyResetCode(email: string, code: string): Promise<unknown> {
|
||||
return apiPost(API_PATHS.authentication.verificationCode, { email, code });
|
||||
}
|
||||
|
||||
export async function resetPassword(data: ResetPasswordPayload): Promise<unknown> {
|
||||
|
|
|
|||
17
src/domain/auth/api/permissions-api.ts
Normal file
17
src/domain/auth/api/permissions-api.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { z } from "zod";
|
||||
import { apiGet } from "@/api/api";
|
||||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { handleApiResponse } from "@/api/handle-api-response";
|
||||
|
||||
export const UNEXPECTED_PERMISSIONS_RESPONSE =
|
||||
"The server sent an unexpected permissions response.";
|
||||
|
||||
const effectivePermissionsSchema = z.object({ permissions: z.array(z.string()) });
|
||||
|
||||
/** Keys the signed-in user holds after role defaults and their individual overrides. */
|
||||
export async function getMyPermissions(): Promise<string[]> {
|
||||
const data = await apiGet<unknown>(API_PATHS.teamMember.myPermissions);
|
||||
const result = effectivePermissionsSchema.safeParse(handleApiResponse<unknown>(data));
|
||||
if (!result.success) throw new Error(UNEXPECTED_PERMISSIONS_RESPONSE);
|
||||
return result.data.permissions;
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ import {
|
|||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type QueryClient,
|
||||
type UseMutationResult,
|
||||
type UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
|
|
@ -21,6 +22,13 @@ export function useSession(): UseQueryResult<AuthUser | null, Error> {
|
|||
});
|
||||
}
|
||||
|
||||
/** Makes a freshly stored session the signed-in user, after login or invite registration. */
|
||||
export function startSignedInSession(queryClient: QueryClient, user: AuthUser): void {
|
||||
stopQueryBroadcast(queryClient);
|
||||
queryClient.setQueryData(queryKeys.auth.session(), user);
|
||||
startQueryBroadcast(queryClient, user.id);
|
||||
}
|
||||
|
||||
export function useLogin(): UseMutationResult<AuthUser, Error, LoginCredentials> {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
|
@ -29,9 +37,7 @@ export function useLogin(): UseMutationResult<AuthUser, Error, LoginCredentials>
|
|||
mutationFn: (credentials: LoginCredentials) => authApi.login(credentials),
|
||||
meta: { suppressErrorToast: true },
|
||||
onSuccess: (user) => {
|
||||
stopQueryBroadcast(queryClient);
|
||||
queryClient.setQueryData(queryKeys.auth.session(), user);
|
||||
startQueryBroadcast(queryClient, user.id);
|
||||
startSignedInSession(queryClient, user);
|
||||
toast.success("Login successful!");
|
||||
navigate("/");
|
||||
},
|
||||
|
|
|
|||
32
src/domain/auth/use-cases/use-permissions.ts
Normal file
32
src/domain/auth/use-cases/use-permissions.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
|
||||
import type { PermissionKey } from "@/app/(protected)/team-members/_components/team-member-permissions";
|
||||
import { getMyPermissions } from "@/domain/auth/api/permissions-api";
|
||||
import { queryKeys } from "@/infra/query-key/query-key";
|
||||
import { useAuthContext } from "@/providers/auth-context";
|
||||
|
||||
/**
|
||||
* The signed-in user's effective permissions as the server computes them. Keyed by user so a
|
||||
* different sign-in never reads another person's answer, and refetched on every mount and window
|
||||
* focus so an override change applies on the next page load.
|
||||
*/
|
||||
export function usePermissions(): UseQueryResult<string[], Error> {
|
||||
const { user } = useAuthContext();
|
||||
const userId = user?.id ?? null;
|
||||
return useQuery({
|
||||
queryKey: queryKeys.auth.permissions(userId ?? ""),
|
||||
queryFn: getMyPermissions,
|
||||
enabled: userId !== null,
|
||||
staleTime: 0,
|
||||
refetchOnWindowFocus: true,
|
||||
meta: { suppressErrorToast: true },
|
||||
});
|
||||
}
|
||||
|
||||
/** Holds only for a successful server answer that lists the key; loading and errors deny. */
|
||||
export function hasPermission(query: UseQueryResult<string[], Error>, key: PermissionKey): boolean {
|
||||
return !query.isError && query.data?.includes(key) === true;
|
||||
}
|
||||
|
||||
export function useHasPermission(key: PermissionKey): boolean {
|
||||
return hasPermission(usePermissions(), key);
|
||||
}
|
||||
|
|
@ -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 },
|
||||
});
|
||||
}
|
||||
|
|
|
|||
59
src/domain/team-member-invite/api/team-member-invite-api.ts
Normal file
59
src/domain/team-member-invite/api/team-member-invite-api.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* Anonymous invite registration. The invite token travels only in request bodies,
|
||||
* never in a URL path or query, and the server scopes every call to that token.
|
||||
*/
|
||||
import { isHTTPError } from "ky";
|
||||
|
||||
import { apiPost, apiPostNoContent } from "@/api/api";
|
||||
import { API_PATHS } from "@/api/api-paths";
|
||||
import { handleApiResponse } from "@/api/handle-api-response";
|
||||
import type { AuthUser } from "@/domain/auth/types/auth-user";
|
||||
import type {
|
||||
CompleteRegistrationInput,
|
||||
TeamMemberInviteDetails,
|
||||
TeamMemberInviteErrorCode,
|
||||
} from "@/domain/team-member-invite/types/team-member-invite";
|
||||
import { getAuthUser, setAuthUser } from "@/lib/auth/auth-storage";
|
||||
|
||||
export const teamMemberInviteApi = {
|
||||
resolve: async (token: string): Promise<TeamMemberInviteDetails> => {
|
||||
const data = await apiPost<unknown>(API_PATHS.teamMemberInvite.resolve, { token });
|
||||
return handleApiResponse<TeamMemberInviteDetails>(data);
|
||||
},
|
||||
|
||||
sendCode: async (token: string): Promise<void> => {
|
||||
await apiPostNoContent(API_PATHS.teamMemberInvite.sendCode, { token });
|
||||
},
|
||||
|
||||
verifyCode: async ({ token, code }: { token: string; code: string }): Promise<void> => {
|
||||
await apiPostNoContent(API_PATHS.teamMemberInvite.verifyCode, { token, code });
|
||||
},
|
||||
|
||||
/** Finishes registration and stores the returned session exactly as login does. */
|
||||
complete: async (input: CompleteRegistrationInput): Promise<AuthUser> => {
|
||||
const data = await apiPost<unknown>(API_PATHS.teamMemberInvite.complete, {
|
||||
token: input.token,
|
||||
password: input.password,
|
||||
// An empty value clears the phone the admin entered.
|
||||
phone: input.phone.trim(),
|
||||
});
|
||||
const user = handleApiResponse<AuthUser>(data);
|
||||
setAuthUser(user);
|
||||
return getAuthUser() ?? user;
|
||||
},
|
||||
};
|
||||
|
||||
/** The server's failure code for an invite call, when the response carried one. */
|
||||
export function inviteErrorCode(error: unknown): TeamMemberInviteErrorCode | null {
|
||||
if (!isHTTPError(error)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data: unknown = error.data;
|
||||
if (typeof data === "object" && data !== null && "code" in data) {
|
||||
const code = (data as { code: unknown }).code;
|
||||
return typeof code === "string" ? (code as TeamMemberInviteErrorCode) : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { z } from "zod";
|
||||
|
||||
import { PASSWORDS_DO_NOT_MATCH_MESSAGE, passwordSchema } from "@/domain/auth/password-policy";
|
||||
|
||||
export const CODE_FORMAT_MESSAGE = "Enter the 6-digit code from your email";
|
||||
export const PHONE_FORMAT_MESSAGE = "Enter a valid phone number";
|
||||
|
||||
/** Mirrors the server's accepted phone characters and length. */
|
||||
const PHONE_PATTERN = /^[0-9+().\s-]{7,32}$/;
|
||||
|
||||
export const invitePasswordStepSchema = z
|
||||
.object({
|
||||
password: passwordSchema,
|
||||
confirmPassword: z.string(),
|
||||
})
|
||||
.refine((values) => values.password === values.confirmPassword, {
|
||||
message: PASSWORDS_DO_NOT_MATCH_MESSAGE,
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
|
||||
export type InvitePasswordStepValues = z.infer<typeof invitePasswordStepSchema>;
|
||||
|
||||
export const inviteCodeStepSchema = z.object({
|
||||
code: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(/^[0-9]{6}$/, CODE_FORMAT_MESSAGE),
|
||||
});
|
||||
|
||||
export type InviteCodeStepValues = z.infer<typeof inviteCodeStepSchema>;
|
||||
|
||||
export const inviteReviewStepSchema = z.object({
|
||||
phone: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((phone) => phone === "" || PHONE_PATTERN.test(phone), PHONE_FORMAT_MESSAGE),
|
||||
});
|
||||
|
||||
export type InviteReviewStepValues = z.infer<typeof inviteReviewStepSchema>;
|
||||
34
src/domain/team-member-invite/types/team-member-invite.ts
Normal file
34
src/domain/team-member-invite/types/team-member-invite.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/** The server's generic message for any invite that cannot be used; shown before any call too. */
|
||||
export const INVALID_INVITE_MESSAGE =
|
||||
"This invite link is invalid or has expired. Ask your admin to send a new invite.";
|
||||
|
||||
/** Shown when the invite could not be checked at all (offline, timeout, server error); the link may still be valid. */
|
||||
export const INVITE_UNREACHABLE_MESSAGE =
|
||||
"We couldn't reach the server to check your invite. Check your connection and try again.";
|
||||
|
||||
/** The read-only identity an admin set when inviting the member. */
|
||||
export interface TeamMemberInviteDetails {
|
||||
name: string;
|
||||
role: string;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
}
|
||||
|
||||
export interface CompleteRegistrationInput {
|
||||
token: string;
|
||||
password: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
/** Stable failure codes returned by the anonymous invite endpoints. */
|
||||
export type TeamMemberInviteErrorCode =
|
||||
| "invalid_invite"
|
||||
| "code_incorrect"
|
||||
| "code_expired"
|
||||
| "code_locked"
|
||||
| "resend_too_soon"
|
||||
| "resend_limit_reached"
|
||||
| "code_delivery_failed"
|
||||
| "email_not_confirmed"
|
||||
| "password_rejected"
|
||||
| "invalid_phone";
|
||||
|
|
@ -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 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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));
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
107
src/domain/work-orders/utils/completion-doc-pagination.ts
Normal file
107
src/domain/work-orders/utils/completion-doc-pagination.ts
Normal 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]))
|
||||
);
|
||||
}
|
||||
|
|
@ -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 !== "" &&
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 } },
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 },
|
||||
|
|
|
|||
425
src/test/app/invite/invite-registration-page.test.tsx
Normal file
425
src/test/app/invite/invite-registration-page.test.tsx
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
45
src/test/domain/auth/api/auth-api.test.ts
Normal file
45
src/test/domain/auth/api/auth-api.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
66
src/test/domain/team-members/use-create-team-member.test.tsx
Normal file
66
src/test/domain/team-members/use-create-team-member.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
72
src/test/domain/team-members/use-update-team-member.test.tsx
Normal file
72
src/test/domain/team-members/use-update-team-member.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue