mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 05:43:12 +00:00
feat(uplifts): align Reject/Revoke Cancel and required asterisk [SH-120]
This commit is contained in:
parent
4a3d04ed01
commit
ba2e3e37c7
12 changed files with 573 additions and 43 deletions
|
|
@ -0,0 +1,3 @@
|
|||
export function formatUpliftMoney(value: number): string {
|
||||
return `$${value.toLocaleString("en-US", { maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
100
src/app/(protected)/uplifts/_components/reject-dialog.tsx
Normal file
100
src/app/(protected)/uplifts/_components/reject-dialog.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
|
||||
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
|
||||
import { UpliftReasonField } from "@/app/(protected)/uplifts/_components/uplift-reason-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DialogCancelButton } from "@/components/ui/dialog-cancel-button";
|
||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { guardDialogClose } from "@/lib/dialog-guard";
|
||||
|
||||
export type RejectDialogProps = {
|
||||
open: boolean;
|
||||
amount?: number | null;
|
||||
woNumber?: string | null;
|
||||
onClose: () => void;
|
||||
onConfirm: (reason: string) => void;
|
||||
isPending?: boolean;
|
||||
};
|
||||
|
||||
function rejectTitle(amount: number | null | undefined): string {
|
||||
if (amount != null && Number.isFinite(amount)) {
|
||||
return `Reject uplift of ${formatUpliftMoney(amount)}?`;
|
||||
}
|
||||
return "Reject uplift?";
|
||||
}
|
||||
|
||||
export function RejectDialog({
|
||||
open,
|
||||
amount,
|
||||
woNumber,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isPending = false,
|
||||
}: RejectDialogProps) {
|
||||
const [reason, setReason] = useState("");
|
||||
const canConfirm = reason.trim().length > 0 && !isPending;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setReason("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleClose = () => {
|
||||
if (isPending) return;
|
||||
setReason("");
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={guardDialogClose(isPending, handleClose)}
|
||||
maxWidth={false}
|
||||
slotProps={{
|
||||
...dialogBlurBackdropSlotProps,
|
||||
paper: {
|
||||
className: "w-full",
|
||||
style: { borderRadius: 12, maxWidth: 480 },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle
|
||||
className="p-6 pb-0"
|
||||
style={{ fontSize: 16, color: "var(--color-text-primary)" }}
|
||||
>
|
||||
{rejectTitle(amount)}
|
||||
</DialogTitle>
|
||||
<DialogContent className="px-6 pt-3">
|
||||
{Boolean(woNumber) && (
|
||||
<Text as="p" variant="description" className="mb-3">
|
||||
The dispatcher sees this reason on WO #{woNumber}.
|
||||
</Text>
|
||||
)}
|
||||
<UpliftReasonField
|
||||
id="reject-uplift-reason"
|
||||
label="Reason for rejection"
|
||||
required
|
||||
value={reason}
|
||||
disabled={isPending}
|
||||
placeholder="Explain why this uplift is not approved..."
|
||||
onChange={setReason}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions className="px-6 pb-6 pt-2">
|
||||
<DialogCancelButton onClick={handleClose} disabled={isPending} />
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="min-h-0 px-3 py-1 text-[12px]"
|
||||
disabled={!canConfirm}
|
||||
loading={isPending}
|
||||
loadingText="Rejecting…"
|
||||
onClick={() => onConfirm(reason.trim())}
|
||||
>
|
||||
Reject uplift
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
110
src/app/(protected)/uplifts/_components/revoke-dialog.tsx
Normal file
110
src/app/(protected)/uplifts/_components/revoke-dialog.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Dialog, DialogActions, DialogContent, DialogTitle } from "@mui/material";
|
||||
import { formatUpliftMoney } from "@/app/(protected)/uplifts/_components/format-uplift-money";
|
||||
import { UpliftReasonField } from "@/app/(protected)/uplifts/_components/uplift-reason-field";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DialogCancelButton } from "@/components/ui/dialog-cancel-button";
|
||||
import { dialogBlurBackdropSlotProps } from "@/components/ui/dialog-blur-backdrop";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { guardDialogClose } from "@/lib/dialog-guard";
|
||||
|
||||
export type RevokeDialogProps = {
|
||||
open: boolean;
|
||||
amount?: number | null;
|
||||
woNumber?: string | null;
|
||||
onClose: () => void;
|
||||
onConfirm: (reason: string) => void;
|
||||
isPending?: boolean;
|
||||
};
|
||||
|
||||
function revokeAmountLabel(amount: number | null | undefined): string {
|
||||
if (amount != null && Number.isFinite(amount)) {
|
||||
return formatUpliftMoney(amount);
|
||||
}
|
||||
return "this uplift";
|
||||
}
|
||||
|
||||
function revokeConfirmLabel(amount: number | null | undefined): string {
|
||||
if (amount != null && Number.isFinite(amount)) {
|
||||
return `Revoke ${formatUpliftMoney(amount)}`;
|
||||
}
|
||||
return "Revoke";
|
||||
}
|
||||
|
||||
export function RevokeDialog({
|
||||
open,
|
||||
amount,
|
||||
woNumber,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isPending = false,
|
||||
}: RevokeDialogProps) {
|
||||
const [reason, setReason] = useState("");
|
||||
const canConfirm = reason.trim().length > 0 && !isPending;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setReason("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleClose = () => {
|
||||
if (isPending) return;
|
||||
setReason("");
|
||||
onClose();
|
||||
};
|
||||
|
||||
const amountLabel = revokeAmountLabel(amount);
|
||||
const woSuffix = woNumber ? ` on WO ${woNumber}` : "";
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={guardDialogClose(isPending, handleClose)}
|
||||
maxWidth={false}
|
||||
slotProps={{
|
||||
...dialogBlurBackdropSlotProps,
|
||||
paper: {
|
||||
className: "w-full",
|
||||
style: { borderRadius: 12, maxWidth: 480 },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle
|
||||
className="p-6 pb-0"
|
||||
style={{ fontSize: 16, color: "var(--color-text-primary)" }}
|
||||
>
|
||||
Revoke this approval?
|
||||
</DialogTitle>
|
||||
<DialogContent className="px-6 pt-3">
|
||||
<Text as="p" variant="description" className="mb-3">
|
||||
The approved uplift of {amountLabel}
|
||||
{woSuffix} will be withdrawn. This does not recover money already spent — it records that
|
||||
the authorization was a mistake.
|
||||
</Text>
|
||||
<UpliftReasonField
|
||||
id="revoke-uplift-reason"
|
||||
label="Reason for revoking"
|
||||
required
|
||||
value={reason}
|
||||
disabled={isPending}
|
||||
placeholder="Explain why this approval is being withdrawn..."
|
||||
onChange={setReason}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions className="px-6 pb-6 pt-2">
|
||||
<DialogCancelButton onClick={handleClose} disabled={isPending} />
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="min-h-0 px-3 py-1 text-[12px]"
|
||||
disabled={!canConfirm}
|
||||
loading={isPending}
|
||||
loadingText="Revoking…"
|
||||
onClick={() => onConfirm(reason.trim())}
|
||||
>
|
||||
{revokeConfirmLabel(amount)}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import {
|
||||
DLG_INPUT_CLS,
|
||||
DLG_INPUT_STYLE,
|
||||
} from "@/app/(protected)/workorders/_components/wizard/wizard-input-styles";
|
||||
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
|
||||
|
||||
type UpliftReasonFieldProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
value: string;
|
||||
placeholder: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export function UpliftReasonField({
|
||||
id,
|
||||
label,
|
||||
value,
|
||||
placeholder,
|
||||
required = false,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: UpliftReasonFieldProps) {
|
||||
return (
|
||||
<div>
|
||||
<FieldLabel htmlFor={id} required={required}>
|
||||
{label}
|
||||
</FieldLabel>
|
||||
<textarea
|
||||
id={id}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
rows={3}
|
||||
autoFocus
|
||||
className={`${DLG_INPUT_CLS} resize-none`}
|
||||
style={{ ...DLG_INPUT_STYLE, fontFamily: "inherit" }}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@ import {
|
|||
import { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
|
||||
import { formatDateTime } from "@/lib/time-utils";
|
||||
import { toast } from "react-toastify";
|
||||
import { RejectDialog } from "@/app/(protected)/uplifts/_components/reject-dialog";
|
||||
|
||||
const STATUS_OPTIONS = ["", ...UPLIFT_STATUSES];
|
||||
|
||||
|
|
@ -297,9 +298,7 @@ function UpliftDecisionDialog({
|
|||
<DialogTitle>
|
||||
{action?.type === "approve"
|
||||
? `Approve uplift to ${formatMoney(action.item.requestedNTE)}?`
|
||||
: action?.type === "reject"
|
||||
? "Reject Uplift"
|
||||
: "Request Changes"}
|
||||
: "Request Changes"}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{action?.type === "approve" && (
|
||||
|
|
@ -354,9 +353,9 @@ export default function UpliftQueuePage() {
|
|||
|
||||
const rows = data?.items ?? [];
|
||||
const totalCount = data?.totalCount ?? 0;
|
||||
const noteRequired = upliftAction?.type === "reject" || upliftAction?.type === "request-changes";
|
||||
const decisionPending =
|
||||
approveUplift.isPending || rejectUplift.isPending || requestChangesUplift.isPending;
|
||||
const genericAction = upliftAction?.type === "reject" ? null : upliftAction;
|
||||
const noteRequired = genericAction?.type === "request-changes";
|
||||
const decisionPending = approveUplift.isPending || requestChangesUplift.isPending;
|
||||
|
||||
const handleStatusChange = (value: string) => {
|
||||
setStatus(value);
|
||||
|
|
@ -382,13 +381,26 @@ export default function UpliftQueuePage() {
|
|||
};
|
||||
if (upliftAction.type === "approve") {
|
||||
approveUplift.mutate({ id, note: note || undefined }, { onSuccess: onDone });
|
||||
} else if (upliftAction.type === "reject") {
|
||||
rejectUplift.mutate({ id, note: note.trim() }, { onSuccess: onDone });
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
if (upliftAction.type === "request-changes") {
|
||||
requestChangesUplift.mutate({ id, note: note.trim() }, { onSuccess: onDone });
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectConfirm = (reason: string) => {
|
||||
if (upliftAction?.type !== "reject") return;
|
||||
rejectUplift.mutate(
|
||||
{ id: upliftAction.item.id, note: reason },
|
||||
{
|
||||
onSuccess: () => {
|
||||
closeDialog();
|
||||
void refetch();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const downloadEvidence = async (item: UpliftQueueItem) => {
|
||||
try {
|
||||
await upliftsApi.downloadEvidence(item.id, item.evidenceFileName || "uplift-evidence");
|
||||
|
|
@ -440,8 +452,15 @@ export default function UpliftQueuePage() {
|
|||
rowsPerPage={pageSize}
|
||||
rowsPerPageOptions={[25]}
|
||||
/>
|
||||
<RejectDialog
|
||||
open={upliftAction?.type === "reject"}
|
||||
amount={upliftAction?.item.requestedNTE}
|
||||
onClose={closeDialog}
|
||||
onConfirm={handleRejectConfirm}
|
||||
isPending={rejectUplift.isPending}
|
||||
/>
|
||||
<UpliftDecisionDialog
|
||||
action={upliftAction}
|
||||
action={genericAction}
|
||||
note={note}
|
||||
noteRequired={noteRequired}
|
||||
decisionPending={decisionPending}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import {
|
|||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
|
||||
import { DialogCancelButton } from "@/components/ui/dialog-cancel-button";
|
||||
import type { UpliftRequest } from "@/domain/uplifts/types/uplift";
|
||||
import type {
|
||||
VendorPoDetail,
|
||||
|
|
@ -314,19 +316,21 @@ export function UpliftDecisionDialog({
|
|||
: "Request Changes"}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<FieldLabel htmlFor="vendor-po-uplift-note" required={noteRequired}>
|
||||
{noteRequired ? "Note" : "Optional note"}
|
||||
</FieldLabel>
|
||||
<TextField
|
||||
id="vendor-po-uplift-note"
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
className="mt-2"
|
||||
label={noteRequired ? "Note (required)" : "Optional note"}
|
||||
value={note}
|
||||
required={noteRequired}
|
||||
onChange={(e) => onNoteChange(e.target.value)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<DialogCancelButton onClick={onClose} disabled={decisionPending} />
|
||||
<Button
|
||||
variant="contained"
|
||||
color={action?.type === "approve" ? "success" : "primary"}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
import { useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import { Dialog, DialogActions, DialogContent, DialogTitle, TextField } from "@mui/material";
|
||||
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DialogCancelButton } from "@/components/ui/dialog-cancel-button";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { guardDialogClose } from "@/lib/dialog-guard";
|
||||
|
||||
type UpliftDecisionDialogProps = {
|
||||
|
|
@ -55,27 +51,27 @@ export function UpliftDecisionDialog({
|
|||
<DialogTitle>{titleFor(type, requestedNTE)}</DialogTitle>
|
||||
<DialogContent>
|
||||
{type === "approve" && (
|
||||
<Typography variant="body2" className="mt-1 mb-3">
|
||||
<Text as="p" variant="description" className="mt-1 mb-3">
|
||||
${Number(currentNTE ?? 0).toFixed(2)} → ${requestedNTE.toFixed(2)} (+$
|
||||
{(requestedNTE - Number(currentNTE ?? 0)).toFixed(2)})
|
||||
</Typography>
|
||||
</Text>
|
||||
)}
|
||||
<FieldLabel htmlFor="dispatch-uplift-note" required={noteRequired}>
|
||||
{noteRequired ? "Note" : "Optional note"}
|
||||
</FieldLabel>
|
||||
<TextField
|
||||
id="dispatch-uplift-note"
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
className="mt-2"
|
||||
label={noteRequired ? "Note (required)" : "Optional note"}
|
||||
required={noteRequired}
|
||||
value={note}
|
||||
onChange={(event) => setNote(event.target.value)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button variant="outline" onClick={handleClose} disabled={isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<DialogCancelButton onClick={handleClose} disabled={isPending} />
|
||||
<Button
|
||||
variant={type === "reject" ? "destructive" : "default"}
|
||||
disabled={noteRequired && !note.trim()}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,24 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
export function FieldLabel({ children }: { children: ReactNode }) {
|
||||
type FieldLabelProps = {
|
||||
children: ReactNode;
|
||||
required?: boolean;
|
||||
htmlFor?: string;
|
||||
};
|
||||
|
||||
export function FieldLabel({ children, required = false, htmlFor }: FieldLabelProps) {
|
||||
return (
|
||||
<label
|
||||
htmlFor={htmlFor}
|
||||
className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium"
|
||||
style={{ color: "var(--color-text-secondary)" }}
|
||||
>
|
||||
{children}
|
||||
{Boolean(required) && (
|
||||
<span aria-hidden style={{ color: "var(--destructive)" }}>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
TextField,
|
||||
} from "@mui/material";
|
||||
import { Dialog, DialogActions, DialogContent, DialogTitle, TextField } from "@mui/material";
|
||||
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DialogCancelButton } from "@/components/ui/dialog-cancel-button";
|
||||
import { Text } from "@/components/ui/text";
|
||||
import { guardDialogClose } from "@/lib/dialog-guard";
|
||||
|
||||
type RevokeUpliftDialogProps = {
|
||||
open: boolean;
|
||||
|
|
@ -38,6 +35,7 @@ export function RevokeUpliftDialog({
|
|||
}, [open]);
|
||||
|
||||
const handleClose = () => {
|
||||
if (pending) return;
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
|
@ -48,27 +46,29 @@ export function RevokeUpliftDialog({
|
|||
const reasonMissing = requireReason && reason.trim() === "";
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="xs" fullWidth>
|
||||
<Dialog open={open} onClose={guardDialogClose(pending, handleClose)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Text as="p" variant="description" className="mb-3">
|
||||
{description}
|
||||
</Text>
|
||||
<FieldLabel htmlFor="wo-revoke-uplift-reason" required={requireReason}>
|
||||
Reason
|
||||
</FieldLabel>
|
||||
<TextField
|
||||
label={requireReason ? "Reason (required)" : "Reason (optional)"}
|
||||
id="wo-revoke-uplift-reason"
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
required={requireReason}
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose}>Cancel</Button>
|
||||
<DialogCancelButton onClick={handleClose} disabled={pending} />
|
||||
<Button
|
||||
color="error"
|
||||
variant="contained"
|
||||
variant="destructive"
|
||||
className="min-h-0 px-3 py-1 text-[12px]"
|
||||
disabled={pending || reasonMissing}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
|
|
|
|||
20
src/components/ui/dialog-cancel-button.tsx
Normal file
20
src/components/ui/dialog-cancel-button.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { Button, type UiButtonProps } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Same tokens as VendorDialogFooter Cancel (`ghost` + this class). */
|
||||
export const VENDOR_DIALOG_CANCEL_CLASS = "min-h-0 px-2 py-1 text-[12px]";
|
||||
|
||||
type DialogCancelButtonProps = Omit<UiButtonProps, "variant">;
|
||||
|
||||
/** Cancel chrome shared with VendorDialog footer. */
|
||||
export function DialogCancelButton({
|
||||
className,
|
||||
children = "Cancel",
|
||||
...props
|
||||
}: DialogCancelButtonProps) {
|
||||
return (
|
||||
<Button variant="ghost" className={cn(VENDOR_DIALOG_CANCEL_CLASS, className)} {...props}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
141
src/test/app/(protected)/uplifts/reject-revoke-dialogs.test.tsx
Normal file
141
src/test/app/(protected)/uplifts/reject-revoke-dialogs.test.tsx
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import type { ComponentProps } from "react";
|
||||
import { fireEvent, screen, within } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { RejectDialog } from "@/app/(protected)/uplifts/_components/reject-dialog";
|
||||
import { RevokeDialog } from "@/app/(protected)/uplifts/_components/revoke-dialog";
|
||||
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
|
||||
import { VendorDialogFooter } from "@/app/(protected)/workorders/_components/list/table/cells/vendor-dialog-footer";
|
||||
import {
|
||||
DialogCancelButton,
|
||||
VENDOR_DIALOG_CANCEL_CLASS,
|
||||
} from "@/components/ui/dialog-cancel-button";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
const VENDOR_CANCEL_TOKENS = VENDOR_DIALOG_CANCEL_CLASS.split(" ");
|
||||
|
||||
function expectVendorCancelChrome(button: HTMLElement) {
|
||||
for (const token of VENDOR_CANCEL_TOKENS) {
|
||||
expect(button).toHaveClass(token);
|
||||
}
|
||||
expect(button).toHaveClass("text-foreground");
|
||||
}
|
||||
|
||||
function expectDestructiveAsterisk(labelText: string) {
|
||||
const label = screen.getByText(labelText).closest("label");
|
||||
expect(label).toBeTruthy();
|
||||
const star = within(label as HTMLElement).getByText("*");
|
||||
expect(star).toHaveAttribute("aria-hidden", "true");
|
||||
expect(star).toHaveStyle({ color: "var(--destructive)" });
|
||||
}
|
||||
|
||||
function renderReject(overrides: Partial<ComponentProps<typeof RejectDialog>> = {}) {
|
||||
const onClose = vi.fn();
|
||||
const onConfirm = vi.fn();
|
||||
renderWithProviders(
|
||||
<RejectDialog open amount={250} onClose={onClose} onConfirm={onConfirm} {...overrides} />,
|
||||
);
|
||||
return { onClose, onConfirm };
|
||||
}
|
||||
|
||||
function renderRevoke(overrides: Partial<ComponentProps<typeof RevokeDialog>> = {}) {
|
||||
const onClose = vi.fn();
|
||||
const onConfirm = vi.fn();
|
||||
renderWithProviders(
|
||||
<RevokeDialog open amount={500} onClose={onClose} onConfirm={onConfirm} {...overrides} />,
|
||||
);
|
||||
return { onClose, onConfirm };
|
||||
}
|
||||
|
||||
describe("FieldLabel required asterisk", () => {
|
||||
it("renders a destructive asterisk only when required", () => {
|
||||
const { rerender } = renderWithProviders(<FieldLabel>Site</FieldLabel>);
|
||||
expect(screen.getByText("Site").textContent).toBe("Site");
|
||||
expect(screen.queryByText("*")).not.toBeInTheDocument();
|
||||
|
||||
rerender(<FieldLabel required>Site</FieldLabel>);
|
||||
expectDestructiveAsterisk("Site");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DialogCancelButton VendorDialog tokens", () => {
|
||||
it("matches VendorDialogFooter Cancel classes (ghost + 12px compact padding)", () => {
|
||||
renderWithProviders(
|
||||
<>
|
||||
<VendorDialogFooter isPending={false} onCancel={() => undefined} onSave={() => undefined} />
|
||||
<DialogCancelButton />
|
||||
</>,
|
||||
);
|
||||
const [vendorCancel, sharedCancel] = screen.getAllByRole("button", { name: "Cancel" });
|
||||
expectVendorCancelChrome(vendorCancel);
|
||||
expectVendorCancelChrome(sharedCancel);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RejectDialog", () => {
|
||||
it("keeps confirm disabled until a non-whitespace reason is typed", () => {
|
||||
const { onConfirm } = renderReject();
|
||||
const confirm = screen.getByRole("button", { name: "Reject uplift" });
|
||||
expect(confirm).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/reason for rejection/i), {
|
||||
target: { value: " " },
|
||||
});
|
||||
expect(confirm).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/reason for rejection/i), {
|
||||
target: { value: "Over the NTE cap" },
|
||||
});
|
||||
expect(confirm).toBeEnabled();
|
||||
fireEvent.click(confirm);
|
||||
expect(onConfirm).toHaveBeenCalledWith("Over the NTE cap");
|
||||
});
|
||||
|
||||
it("closes on Cancel without confirming", () => {
|
||||
const { onClose, onConfirm } = renderReject();
|
||||
const cancel = screen.getByRole("button", { name: "Cancel" });
|
||||
expectVendorCancelChrome(cancel);
|
||||
fireEvent.click(cancel);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks the reason as required with an asterisk", () => {
|
||||
renderReject();
|
||||
expectDestructiveAsterisk("Reason for rejection");
|
||||
});
|
||||
|
||||
it("blocks cancel and escape while pending", () => {
|
||||
const { onClose } = renderReject({ isPending: true });
|
||||
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
|
||||
fireEvent.keyDown(document.body, { key: "Escape" });
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("RevokeDialog", () => {
|
||||
it("keeps confirm disabled until a reason is typed", () => {
|
||||
const { onConfirm } = renderRevoke();
|
||||
const confirm = screen.getByRole("button", { name: "Revoke $500" });
|
||||
expect(confirm).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/reason for revoking/i), {
|
||||
target: { value: "Approved in error" },
|
||||
});
|
||||
fireEvent.click(confirm);
|
||||
expect(onConfirm).toHaveBeenCalledWith("Approved in error");
|
||||
});
|
||||
|
||||
it("closes on Cancel without confirming", () => {
|
||||
const { onClose, onConfirm } = renderRevoke();
|
||||
const cancel = screen.getByRole("button", { name: "Cancel" });
|
||||
expectVendorCancelChrome(cancel);
|
||||
fireEvent.click(cancel);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks the reason as required with an asterisk", () => {
|
||||
renderRevoke();
|
||||
expectDestructiveAsterisk("Reason for revoking");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import { fireEvent, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { UpliftQueueItem } from "@/domain/uplifts/types/uplift";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
const rejectMutate = vi.fn();
|
||||
const approveMutate = vi.fn();
|
||||
const requestChangesMutate = vi.fn();
|
||||
const refetch = vi.fn();
|
||||
|
||||
const pendingItem: UpliftQueueItem = {
|
||||
id: 41,
|
||||
status: "Pending",
|
||||
currentNTE: 100,
|
||||
requestedNTE: 250,
|
||||
delta: 150,
|
||||
vendorReason: "Extra parts",
|
||||
requestedAt: "2026-01-15T10:00:00Z",
|
||||
requestedByVendorName: "Gateway",
|
||||
decidedAt: "",
|
||||
decidedByName: "",
|
||||
decisionNote: "",
|
||||
requiredTier: 1,
|
||||
canDecide: true,
|
||||
expiresAt: "",
|
||||
notificationStatus: "",
|
||||
notificationError: "",
|
||||
evidenceDocumentId: null,
|
||||
evidenceFileName: "",
|
||||
evidenceContentType: "",
|
||||
evidenceSizeBytes: null,
|
||||
dispatchNumber: "DSP-41",
|
||||
poNumber: "PO-41",
|
||||
vendorCompanyName: "Gateway Plumbing",
|
||||
workOrderId: 99,
|
||||
dispatchId: 7,
|
||||
};
|
||||
|
||||
vi.mock("@/domain/uplifts/use-cases/use-uplifts-queue", () => ({
|
||||
useUpliftsQueue: () => ({
|
||||
data: { items: [pendingItem], totalCount: 1, page: 1, pageSize: 25 },
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
refetch,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/domain/uplifts/use-cases/use-uplift-actions", () => ({
|
||||
useApproveUplift: () => ({ isPending: false, mutate: approveMutate }),
|
||||
useRejectUplift: () => ({ isPending: false, mutate: rejectMutate }),
|
||||
useRequestChangesUplift: () => ({ isPending: false, mutate: requestChangesMutate }),
|
||||
}));
|
||||
|
||||
describe("Uplift queue decision dialogs", () => {
|
||||
beforeEach(() => {
|
||||
rejectMutate.mockReset();
|
||||
approveMutate.mockReset();
|
||||
requestChangesMutate.mockReset();
|
||||
refetch.mockReset();
|
||||
});
|
||||
|
||||
it("opens RejectDialog from Reject and does not reject on Cancel", async () => {
|
||||
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
||||
renderWithProviders(<UpliftQueuePage />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Reject" }));
|
||||
expect(screen.getByRole("heading", { name: /reject uplift of \$250/i })).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(rejectMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps Approve on the generic confirm dialog", async () => {
|
||||
const { default: UpliftQueuePage } = await import("@/app/(protected)/uplifts/index");
|
||||
renderWithProviders(<UpliftQueuePage />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Approve" }));
|
||||
expect(screen.getByRole("button", { name: "Confirm" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Reject uplift" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue