mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 03:32:06 +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 { useUpliftsQueue } from "@/domain/uplifts/use-cases/use-uplifts-queue";
|
||||||
import { formatDateTime } from "@/lib/time-utils";
|
import { formatDateTime } from "@/lib/time-utils";
|
||||||
import { toast } from "react-toastify";
|
import { toast } from "react-toastify";
|
||||||
|
import { RejectDialog } from "@/app/(protected)/uplifts/_components/reject-dialog";
|
||||||
|
|
||||||
const STATUS_OPTIONS = ["", ...UPLIFT_STATUSES];
|
const STATUS_OPTIONS = ["", ...UPLIFT_STATUSES];
|
||||||
|
|
||||||
|
|
@ -297,9 +298,7 @@ function UpliftDecisionDialog({
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{action?.type === "approve"
|
{action?.type === "approve"
|
||||||
? `Approve uplift to ${formatMoney(action.item.requestedNTE)}?`
|
? `Approve uplift to ${formatMoney(action.item.requestedNTE)}?`
|
||||||
: action?.type === "reject"
|
: "Request Changes"}
|
||||||
? "Reject Uplift"
|
|
||||||
: "Request Changes"}
|
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
{action?.type === "approve" && (
|
{action?.type === "approve" && (
|
||||||
|
|
@ -354,9 +353,9 @@ export default function UpliftQueuePage() {
|
||||||
|
|
||||||
const rows = data?.items ?? [];
|
const rows = data?.items ?? [];
|
||||||
const totalCount = data?.totalCount ?? 0;
|
const totalCount = data?.totalCount ?? 0;
|
||||||
const noteRequired = upliftAction?.type === "reject" || upliftAction?.type === "request-changes";
|
const genericAction = upliftAction?.type === "reject" ? null : upliftAction;
|
||||||
const decisionPending =
|
const noteRequired = genericAction?.type === "request-changes";
|
||||||
approveUplift.isPending || rejectUplift.isPending || requestChangesUplift.isPending;
|
const decisionPending = approveUplift.isPending || requestChangesUplift.isPending;
|
||||||
|
|
||||||
const handleStatusChange = (value: string) => {
|
const handleStatusChange = (value: string) => {
|
||||||
setStatus(value);
|
setStatus(value);
|
||||||
|
|
@ -382,13 +381,26 @@ export default function UpliftQueuePage() {
|
||||||
};
|
};
|
||||||
if (upliftAction.type === "approve") {
|
if (upliftAction.type === "approve") {
|
||||||
approveUplift.mutate({ id, note: note || undefined }, { onSuccess: onDone });
|
approveUplift.mutate({ id, note: note || undefined }, { onSuccess: onDone });
|
||||||
} else if (upliftAction.type === "reject") {
|
return;
|
||||||
rejectUplift.mutate({ id, note: note.trim() }, { onSuccess: onDone });
|
}
|
||||||
} else {
|
if (upliftAction.type === "request-changes") {
|
||||||
requestChangesUplift.mutate({ id, note: note.trim() }, { onSuccess: onDone });
|
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) => {
|
const downloadEvidence = async (item: UpliftQueueItem) => {
|
||||||
try {
|
try {
|
||||||
await upliftsApi.downloadEvidence(item.id, item.evidenceFileName || "uplift-evidence");
|
await upliftsApi.downloadEvidence(item.id, item.evidenceFileName || "uplift-evidence");
|
||||||
|
|
@ -440,8 +452,15 @@ export default function UpliftQueuePage() {
|
||||||
rowsPerPage={pageSize}
|
rowsPerPage={pageSize}
|
||||||
rowsPerPageOptions={[25]}
|
rowsPerPageOptions={[25]}
|
||||||
/>
|
/>
|
||||||
|
<RejectDialog
|
||||||
|
open={upliftAction?.type === "reject"}
|
||||||
|
amount={upliftAction?.item.requestedNTE}
|
||||||
|
onClose={closeDialog}
|
||||||
|
onConfirm={handleRejectConfirm}
|
||||||
|
isPending={rejectUplift.isPending}
|
||||||
|
/>
|
||||||
<UpliftDecisionDialog
|
<UpliftDecisionDialog
|
||||||
action={upliftAction}
|
action={genericAction}
|
||||||
note={note}
|
note={note}
|
||||||
noteRequired={noteRequired}
|
noteRequired={noteRequired}
|
||||||
decisionPending={decisionPending}
|
decisionPending={decisionPending}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ import {
|
||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} 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 { UpliftRequest } from "@/domain/uplifts/types/uplift";
|
||||||
import type {
|
import type {
|
||||||
VendorPoDetail,
|
VendorPoDetail,
|
||||||
|
|
@ -314,19 +316,21 @@ export function UpliftDecisionDialog({
|
||||||
: "Request Changes"}
|
: "Request Changes"}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
<FieldLabel htmlFor="vendor-po-uplift-note" required={noteRequired}>
|
||||||
|
{noteRequired ? "Note" : "Optional note"}
|
||||||
|
</FieldLabel>
|
||||||
<TextField
|
<TextField
|
||||||
|
id="vendor-po-uplift-note"
|
||||||
fullWidth
|
fullWidth
|
||||||
multiline
|
multiline
|
||||||
minRows={2}
|
minRows={2}
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
label={noteRequired ? "Note (required)" : "Optional note"}
|
|
||||||
value={note}
|
value={note}
|
||||||
required={noteRequired}
|
|
||||||
onChange={(e) => onNoteChange(e.target.value)}
|
onChange={(e) => onNoteChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={onClose}>Cancel</Button>
|
<DialogCancelButton onClick={onClose} disabled={decisionPending} />
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color={action?.type === "approve" ? "success" : "primary"}
|
color={action?.type === "approve" ? "success" : "primary"}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,9 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import { Dialog, DialogActions, DialogContent, DialogTitle, TextField } from "@mui/material";
|
||||||
Dialog,
|
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
TextField,
|
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
import { Button } from "@/components/ui/button";
|
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";
|
import { guardDialogClose } from "@/lib/dialog-guard";
|
||||||
|
|
||||||
type UpliftDecisionDialogProps = {
|
type UpliftDecisionDialogProps = {
|
||||||
|
|
@ -55,27 +51,27 @@ export function UpliftDecisionDialog({
|
||||||
<DialogTitle>{titleFor(type, requestedNTE)}</DialogTitle>
|
<DialogTitle>{titleFor(type, requestedNTE)}</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
{type === "approve" && (
|
{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)} (+$
|
${Number(currentNTE ?? 0).toFixed(2)} → ${requestedNTE.toFixed(2)} (+$
|
||||||
{(requestedNTE - Number(currentNTE ?? 0)).toFixed(2)})
|
{(requestedNTE - Number(currentNTE ?? 0)).toFixed(2)})
|
||||||
</Typography>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
<FieldLabel htmlFor="dispatch-uplift-note" required={noteRequired}>
|
||||||
|
{noteRequired ? "Note" : "Optional note"}
|
||||||
|
</FieldLabel>
|
||||||
<TextField
|
<TextField
|
||||||
|
id="dispatch-uplift-note"
|
||||||
fullWidth
|
fullWidth
|
||||||
multiline
|
multiline
|
||||||
minRows={2}
|
minRows={2}
|
||||||
className="mt-2"
|
className="mt-2"
|
||||||
label={noteRequired ? "Note (required)" : "Optional note"}
|
|
||||||
required={noteRequired}
|
|
||||||
value={note}
|
value={note}
|
||||||
onChange={(event) => setNote(event.target.value)}
|
onChange={(event) => setNote(event.target.value)}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button variant="outline" onClick={handleClose} disabled={isPending}>
|
<DialogCancelButton onClick={handleClose} disabled={isPending} />
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
<Button
|
||||||
variant={type === "reject" ? "destructive" : "default"}
|
variant={type === "reject" ? "destructive" : "default"}
|
||||||
disabled={noteRequired && !note.trim()}
|
disabled={noteRequired && !note.trim()}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,24 @@
|
||||||
import type { ReactNode } from "react";
|
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 (
|
return (
|
||||||
<label
|
<label
|
||||||
|
htmlFor={htmlFor}
|
||||||
className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium"
|
className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium"
|
||||||
style={{ color: "var(--color-text-secondary)" }}
|
style={{ color: "var(--color-text-secondary)" }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
{Boolean(required) && (
|
||||||
|
<span aria-hidden style={{ color: "var(--destructive)" }}>
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import { Dialog, DialogActions, DialogContent, DialogTitle, TextField } from "@mui/material";
|
||||||
Button,
|
import { FieldLabel } from "@/app/(protected)/workorders/_components/list/table/cells/dialog-field-label";
|
||||||
Dialog,
|
import { Button } from "@/components/ui/button";
|
||||||
DialogActions,
|
import { DialogCancelButton } from "@/components/ui/dialog-cancel-button";
|
||||||
DialogContent,
|
|
||||||
DialogTitle,
|
|
||||||
TextField,
|
|
||||||
} from "@mui/material";
|
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
|
import { guardDialogClose } from "@/lib/dialog-guard";
|
||||||
|
|
||||||
type RevokeUpliftDialogProps = {
|
type RevokeUpliftDialogProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
|
@ -38,6 +35,7 @@ export function RevokeUpliftDialog({
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
if (pending) return;
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -48,27 +46,29 @@ export function RevokeUpliftDialog({
|
||||||
const reasonMissing = requireReason && reason.trim() === "";
|
const reasonMissing = requireReason && reason.trim() === "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onClose={handleClose} maxWidth="xs" fullWidth>
|
<Dialog open={open} onClose={guardDialogClose(pending, handleClose)} maxWidth="xs" fullWidth>
|
||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Text as="p" variant="description" className="mb-3">
|
<Text as="p" variant="description" className="mb-3">
|
||||||
{description}
|
{description}
|
||||||
</Text>
|
</Text>
|
||||||
|
<FieldLabel htmlFor="wo-revoke-uplift-reason" required={requireReason}>
|
||||||
|
Reason
|
||||||
|
</FieldLabel>
|
||||||
<TextField
|
<TextField
|
||||||
label={requireReason ? "Reason (required)" : "Reason (optional)"}
|
id="wo-revoke-uplift-reason"
|
||||||
fullWidth
|
fullWidth
|
||||||
multiline
|
multiline
|
||||||
minRows={2}
|
minRows={2}
|
||||||
required={requireReason}
|
|
||||||
value={reason}
|
value={reason}
|
||||||
onChange={(event) => setReason(event.target.value)}
|
onChange={(event) => setReason(event.target.value)}
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={handleClose}>Cancel</Button>
|
<DialogCancelButton onClick={handleClose} disabled={pending} />
|
||||||
<Button
|
<Button
|
||||||
color="error"
|
variant="destructive"
|
||||||
variant="contained"
|
className="min-h-0 px-3 py-1 text-[12px]"
|
||||||
disabled={pending || reasonMissing}
|
disabled={pending || reasonMissing}
|
||||||
onClick={handleConfirm}
|
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