mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-10-07 12:49:00 +00:00
Merge pull request #278 from Sea-Haven-Industries/refactor/react-architecture-cleanup
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
Some checks are pending
Frontend checks / static (push) Waiting to run
Frontend checks / build (push) Waiting to run
Frontend checks / unit (push) Waiting to run
Frontend checks / Visual regression (push) Waiting to run
Frontend checks / browser-smoke (push) Waiting to run
Frontend checks / governance (push) Waiting to run
Frontend checks / ci-complete (push) Blocked by required conditions
Deploy Web / Resolve target (push) Waiting to run
Deploy Web / Deploy SPA to (push) Blocked by required conditions
refactor: move vendor-portal dispatch actions and 2 pages onto proper data hooks
This commit is contained in:
commit
88307764c5
23 changed files with 1431 additions and 498 deletions
|
|
@ -0,0 +1,39 @@
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogContentText,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@mui/material";
|
||||||
|
|
||||||
|
interface DeleteFollowUpDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
isDeleting: boolean;
|
||||||
|
onCancel: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteFollowUpDialog({
|
||||||
|
open,
|
||||||
|
isDeleting,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
}: DeleteFollowUpDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onClose={onCancel}>
|
||||||
|
<DialogTitle>Delete Follow-up</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>Are you sure you want to delete this follow-up?</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onCancel} disabled={isDeleting}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button color="error" variant="contained" disabled={isDeleting} onClick={onConfirm}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { Button, FormControl, InputLabel, MenuItem, Select, Stack, TextField } from "@mui/material";
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = ["Open", "Completed", "Cancelled", ""];
|
||||||
|
|
||||||
|
interface FollowUpsFilterBarProps {
|
||||||
|
search: string;
|
||||||
|
status: string;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
onStatusChange: (value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FollowUpsFilterBar({
|
||||||
|
search,
|
||||||
|
status,
|
||||||
|
onSearchChange,
|
||||||
|
onStatusChange,
|
||||||
|
}: FollowUpsFilterBarProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
direction={{ xs: "column", sm: "row" }}
|
||||||
|
spacing={2}
|
||||||
|
sx={{
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
placeholder="Search follow-ups..."
|
||||||
|
className="min-w-sidebar"
|
||||||
|
/>
|
||||||
|
<FormControl size="small" className="min-w-[140px]">
|
||||||
|
<InputLabel>Status</InputLabel>
|
||||||
|
<Select label="Status" value={status} onChange={(e) => onStatusChange(e.target.value)}>
|
||||||
|
{STATUS_OPTIONS.map((s) => (
|
||||||
|
<MenuItem key={s || "all"} value={s}>
|
||||||
|
{s || "All"}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<Button variant="contained" onClick={() => navigate("/followups/new")}>
|
||||||
|
+ New Follow-up
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
133
src/app/(protected)/followups/_components/followups-table.tsx
Normal file
133
src/app/(protected)/followups/_components/followups-table.tsx
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||||
|
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
IconButton,
|
||||||
|
Paper,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TablePagination,
|
||||||
|
TableRow,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from "@mui/material";
|
||||||
|
import type { FollowUpListItem } from "@/domain/follow-ups/types/follow-up";
|
||||||
|
import { toDateInputValue } from "@/lib/time-utils";
|
||||||
|
|
||||||
|
interface FollowUpsTableProps {
|
||||||
|
rows: FollowUpListItem[];
|
||||||
|
totalCount: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onPageSizeChange: (pageSize: number) => void;
|
||||||
|
onDelete: (row: FollowUpListItem) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FollowUpsTable({
|
||||||
|
rows,
|
||||||
|
totalCount,
|
||||||
|
isLoading,
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
onDelete,
|
||||||
|
}: FollowUpsTableProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TableContainer component={Paper} variant="outlined">
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="font-semibold">Reason</TableCell>
|
||||||
|
<TableCell className="font-semibold">Schedule</TableCell>
|
||||||
|
<TableCell className="font-semibold">Assigned To</TableCell>
|
||||||
|
<TableCell className="font-semibold">Account</TableCell>
|
||||||
|
<TableCell className="font-semibold">Location</TableCell>
|
||||||
|
<TableCell className="font-semibold">WO #</TableCell>
|
||||||
|
<TableCell className="font-semibold">Status</TableCell>
|
||||||
|
<TableCell align="right" className="font-semibold">
|
||||||
|
Actions
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} align="center" className="py-10">
|
||||||
|
<CircularProgress size={28} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} align="center" className="py-10">
|
||||||
|
<Typography sx={{ color: "text.secondary" }}>No follow-ups found</Typography>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
rows.map((row) => (
|
||||||
|
<TableRow key={String(row.id)} hover>
|
||||||
|
<TableCell>
|
||||||
|
<Button
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
className="font-semibold"
|
||||||
|
onClick={() => navigate(`/followups/edit/${row.id}`)}
|
||||||
|
>
|
||||||
|
{row.reason || "—"}
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{toDateInputValue(row.scheduleDate) || "—"}
|
||||||
|
{row.scheduleStartTime ? ` ${row.scheduleStartTime.slice(0, 5)}` : ""}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{row.assignedTo || "—"}</TableCell>
|
||||||
|
<TableCell>{row.accountName || "—"}</TableCell>
|
||||||
|
<TableCell>{row.locationName || "—"}</TableCell>
|
||||||
|
<TableCell>{row.internalWONumber || row.workOrderNumber || "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip size="small" label={row.status || "—"} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Tooltip title="Edit">
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={() => navigate(`/followups/edit/${row.id}`)}
|
||||||
|
>
|
||||||
|
<EditOutlinedIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Delete">
|
||||||
|
<IconButton size="small" onClick={() => onDelete(row)}>
|
||||||
|
<DeleteOutlineIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
<TablePagination
|
||||||
|
component="div"
|
||||||
|
count={totalCount}
|
||||||
|
page={page}
|
||||||
|
onPageChange={(_e, nextPage) => onPageChange(nextPage)}
|
||||||
|
rowsPerPage={pageSize}
|
||||||
|
onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))}
|
||||||
|
rowsPerPageOptions={[12, 24, 48]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import type { FollowUpListItem } from "@/domain/follow-ups/types/follow-up";
|
||||||
|
import { useDeleteFollowUp } from "@/domain/follow-ups/use-cases/use-delete-follow-up";
|
||||||
|
import { useFollowUpsList } from "@/domain/follow-ups/use-cases/use-follow-ups-list";
|
||||||
|
import { useDebounce } from "@/hooks/use-debounce";
|
||||||
|
import { useListPageFilters } from "@/hooks/use-list-page-filters";
|
||||||
|
|
||||||
|
export function useFollowUpsListPage() {
|
||||||
|
const { search, page, pageSize, setSearch, setPage, setPageSize } = useListPageFilters();
|
||||||
|
const [status, setStatus] = useState("Open");
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<FollowUpListItem | null>(null);
|
||||||
|
|
||||||
|
const debouncedSearch = useDebounce(search, 300);
|
||||||
|
const deleteFollowUp = useDeleteFollowUp();
|
||||||
|
|
||||||
|
const listParams = useMemo(
|
||||||
|
() => ({ search: debouncedSearch, page: page + 1, pageSize, status }),
|
||||||
|
[debouncedSearch, page, pageSize, status],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isFetching, error } = useFollowUpsList(listParams);
|
||||||
|
|
||||||
|
const setStatusFilter = (value: string) => {
|
||||||
|
setStatus(value);
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
deleteFollowUp.mutate(deleteTarget.id, {
|
||||||
|
onSuccess: () => setDeleteTarget(null),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
status,
|
||||||
|
setStatus: setStatusFilter,
|
||||||
|
page,
|
||||||
|
setPage,
|
||||||
|
pageSize,
|
||||||
|
setPageSize,
|
||||||
|
deleteTarget,
|
||||||
|
setDeleteTarget,
|
||||||
|
rows: data?.items ?? [],
|
||||||
|
totalCount: data?.totalCount ?? 0,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
error,
|
||||||
|
isDeleting: deleteFollowUp.isPending,
|
||||||
|
confirmDelete,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,63 +1,29 @@
|
||||||
import { useMemo, useState } from "react";
|
import { Alert, Box, CircularProgress, Stack, Typography } from "@mui/material";
|
||||||
import { useNavigate } from "react-router";
|
import { DeleteFollowUpDialog } from "./_components/delete-follow-up-dialog";
|
||||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
import { FollowUpsFilterBar } from "./_components/followups-filter-bar";
|
||||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
import { FollowUpsTable } from "./_components/followups-table";
|
||||||
import {
|
import { useFollowUpsListPage } from "./_components/use-followups-list-page";
|
||||||
Alert,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
Chip,
|
|
||||||
CircularProgress,
|
|
||||||
Dialog,
|
|
||||||
DialogActions,
|
|
||||||
DialogContent,
|
|
||||||
DialogContentText,
|
|
||||||
DialogTitle,
|
|
||||||
FormControl,
|
|
||||||
IconButton,
|
|
||||||
InputLabel,
|
|
||||||
MenuItem,
|
|
||||||
Paper,
|
|
||||||
Select,
|
|
||||||
Stack,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableContainer,
|
|
||||||
TableHead,
|
|
||||||
TablePagination,
|
|
||||||
TableRow,
|
|
||||||
TextField,
|
|
||||||
Tooltip,
|
|
||||||
Typography,
|
|
||||||
} from "@mui/material";
|
|
||||||
import type { FollowUpListItem } from "@/domain/follow-ups/types/follow-up";
|
|
||||||
import { useDeleteFollowUp } from "@/domain/follow-ups/use-cases/use-delete-follow-up";
|
|
||||||
import { useFollowUpsList } from "@/domain/follow-ups/use-cases/use-follow-ups-list";
|
|
||||||
import { useDebounce } from "@/hooks/use-debounce";
|
|
||||||
import { toDateInputValue } from "@/lib/time-utils";
|
|
||||||
|
|
||||||
const STATUS_OPTIONS = ["Open", "Completed", "Cancelled", ""];
|
|
||||||
|
|
||||||
export default function FollowUpsListPage() {
|
export default function FollowUpsListPage() {
|
||||||
const navigate = useNavigate();
|
const {
|
||||||
const [search, setSearch] = useState("");
|
search,
|
||||||
const [page, setPage] = useState(0);
|
setSearch,
|
||||||
const [pageSize, setPageSize] = useState(12);
|
status,
|
||||||
const [status, setStatus] = useState("Open");
|
setStatus,
|
||||||
const [deleteTarget, setDeleteTarget] = useState<FollowUpListItem | null>(null);
|
page,
|
||||||
|
setPage,
|
||||||
const debouncedSearch = useDebounce(search, 300);
|
pageSize,
|
||||||
const deleteFollowUp = useDeleteFollowUp();
|
setPageSize,
|
||||||
|
deleteTarget,
|
||||||
const listParams = useMemo(
|
setDeleteTarget,
|
||||||
() => ({ search: debouncedSearch, page: page + 1, pageSize, status }),
|
rows,
|
||||||
[debouncedSearch, page, pageSize, status],
|
totalCount,
|
||||||
);
|
isLoading,
|
||||||
|
isFetching,
|
||||||
const { data, isLoading, isFetching, error } = useFollowUpsList(listParams);
|
error,
|
||||||
const rows = data?.items ?? [];
|
isDeleting,
|
||||||
const totalCount = data?.totalCount ?? 0;
|
confirmDelete,
|
||||||
|
} = useFollowUpsListPage();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex flex-col gap-4 p-4">
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
|
|
@ -69,187 +35,44 @@ export default function FollowUpsListPage() {
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack
|
<Stack direction="row" spacing={2} sx={{ alignItems: "baseline" }}>
|
||||||
direction="row"
|
|
||||||
spacing={2}
|
|
||||||
sx={{
|
|
||||||
alignItems: "baseline",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="h5" component="h1">
|
<Typography variant="h5" component="h1">
|
||||||
Follow-ups
|
Follow-ups
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
variant="body2"
|
|
||||||
sx={{
|
|
||||||
color: "text.secondary",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{totalCount} total
|
{totalCount} total
|
||||||
</Typography>
|
</Typography>
|
||||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack
|
<FollowUpsFilterBar
|
||||||
direction={{ xs: "column", sm: "row" }}
|
search={search}
|
||||||
spacing={2}
|
status={status}
|
||||||
sx={{
|
onSearchChange={setSearch}
|
||||||
alignItems: "center",
|
onStatusChange={setStatus}
|
||||||
}}
|
/>
|
||||||
>
|
|
||||||
<TextField
|
|
||||||
size="small"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => {
|
|
||||||
setSearch(e.target.value);
|
|
||||||
setPage(0);
|
|
||||||
}}
|
|
||||||
placeholder="Search follow-ups..."
|
|
||||||
className="min-w-sidebar"
|
|
||||||
/>
|
|
||||||
<FormControl size="small" className="min-w-[140px]">
|
|
||||||
<InputLabel>Status</InputLabel>
|
|
||||||
<Select
|
|
||||||
label="Status"
|
|
||||||
value={status}
|
|
||||||
onChange={(e) => {
|
|
||||||
setStatus(e.target.value);
|
|
||||||
setPage(0);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{STATUS_OPTIONS.map((s) => (
|
|
||||||
<MenuItem key={s || "all"} value={s}>
|
|
||||||
{s || "All"}
|
|
||||||
</MenuItem>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
<Button variant="contained" onClick={() => navigate("/followups/new")}>
|
|
||||||
+ New Follow-up
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
{Boolean(error) && (
|
{Boolean(error) && (
|
||||||
<Alert severity="error">
|
<Alert severity="error">
|
||||||
{error instanceof Error ? error.message : "Failed to load follow-ups"}
|
{error instanceof Error ? error.message : "Failed to load follow-ups"}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<TableContainer component={Paper} variant="outlined">
|
<FollowUpsTable
|
||||||
<Table size="small">
|
rows={rows}
|
||||||
<TableHead>
|
totalCount={totalCount}
|
||||||
<TableRow>
|
isLoading={isLoading}
|
||||||
<TableCell className="font-semibold">Reason</TableCell>
|
|
||||||
<TableCell className="font-semibold">Schedule</TableCell>
|
|
||||||
<TableCell className="font-semibold">Assigned To</TableCell>
|
|
||||||
<TableCell className="font-semibold">Account</TableCell>
|
|
||||||
<TableCell className="font-semibold">Location</TableCell>
|
|
||||||
<TableCell className="font-semibold">WO #</TableCell>
|
|
||||||
<TableCell className="font-semibold">Status</TableCell>
|
|
||||||
<TableCell align="right" className="font-semibold">
|
|
||||||
Actions
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{isLoading ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={8} align="center" className="py-10">
|
|
||||||
<CircularProgress size={28} />
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : rows.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={8} align="center" className="py-10">
|
|
||||||
<Typography
|
|
||||||
sx={{
|
|
||||||
color: "text.secondary",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
No follow-ups found
|
|
||||||
</Typography>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
rows.map((row) => (
|
|
||||||
<TableRow key={String(row.id)} hover>
|
|
||||||
<TableCell>
|
|
||||||
<Button
|
|
||||||
variant="text"
|
|
||||||
size="small"
|
|
||||||
className="font-semibold"
|
|
||||||
onClick={() => navigate(`/followups/edit/${row.id}`)}
|
|
||||||
>
|
|
||||||
{row.reason || "—"}
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{toDateInputValue(row.scheduleDate) || "—"}
|
|
||||||
{row.scheduleStartTime ? ` ${row.scheduleStartTime.slice(0, 5)}` : ""}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{row.assignedTo || "—"}</TableCell>
|
|
||||||
<TableCell>{row.accountName || "—"}</TableCell>
|
|
||||||
<TableCell>{row.locationName || "—"}</TableCell>
|
|
||||||
<TableCell>{row.internalWONumber || row.workOrderNumber || "—"}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Chip size="small" label={row.status || "—"} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell align="right">
|
|
||||||
<Tooltip title="Edit">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={() => navigate(`/followups/edit/${row.id}`)}
|
|
||||||
>
|
|
||||||
<EditOutlinedIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title="Delete">
|
|
||||||
<IconButton size="small" onClick={() => setDeleteTarget(row)}>
|
|
||||||
<DeleteOutlineIcon fontSize="small" />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
<TablePagination
|
|
||||||
component="div"
|
|
||||||
count={totalCount}
|
|
||||||
page={page}
|
page={page}
|
||||||
onPageChange={(_e, nextPage) => setPage(nextPage)}
|
pageSize={pageSize}
|
||||||
rowsPerPage={pageSize}
|
onPageChange={setPage}
|
||||||
onRowsPerPageChange={(e) => {
|
onPageSizeChange={setPageSize}
|
||||||
setPageSize(Number(e.target.value));
|
onDelete={setDeleteTarget}
|
||||||
setPage(0);
|
/>
|
||||||
}}
|
<DeleteFollowUpDialog
|
||||||
rowsPerPageOptions={[12, 24, 48]}
|
open={Boolean(deleteTarget)}
|
||||||
|
isDeleting={isDeleting}
|
||||||
|
onCancel={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
/>
|
/>
|
||||||
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)}>
|
|
||||||
<DialogTitle>Delete Follow-up</DialogTitle>
|
|
||||||
<DialogContent>
|
|
||||||
<DialogContentText>Are you sure you want to delete this follow-up?</DialogContentText>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button onClick={() => setDeleteTarget(null)} disabled={deleteFollowUp.isPending}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
color="error"
|
|
||||||
variant="contained"
|
|
||||||
disabled={deleteFollowUp.isPending}
|
|
||||||
onClick={() => {
|
|
||||||
if (!deleteTarget) return;
|
|
||||||
deleteFollowUp.mutate(deleteTarget.id, {
|
|
||||||
onSuccess: () => setDeleteTarget(null),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
47
src/app/(protected)/vendors/_components/add-vendor-preference-controls.tsx
vendored
Normal file
47
src/app/(protected)/vendors/_components/add-vendor-preference-controls.tsx
vendored
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
import { Autocomplete, Button, Paper, Stack, TextField } from "@mui/material";
|
||||||
|
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
|
||||||
|
interface AddVendorPreferenceControlsProps {
|
||||||
|
trade: string;
|
||||||
|
onTradeChange: (value: string) => void;
|
||||||
|
vendorOptions: VendorDropdownItem[];
|
||||||
|
vendors: VendorDropdownItem[];
|
||||||
|
vendorId: number | null;
|
||||||
|
onVendorChange: (vendorId: number | null) => void;
|
||||||
|
onAdd: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddVendorPreferenceControls({
|
||||||
|
trade,
|
||||||
|
onTradeChange,
|
||||||
|
vendorOptions,
|
||||||
|
vendors,
|
||||||
|
vendorId,
|
||||||
|
onVendorChange,
|
||||||
|
onAdd,
|
||||||
|
}: AddVendorPreferenceControlsProps) {
|
||||||
|
return (
|
||||||
|
<Paper variant="outlined" className="p-4">
|
||||||
|
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
||||||
|
<TextField
|
||||||
|
label="Trade scope (optional)"
|
||||||
|
value={trade}
|
||||||
|
onChange={(event) => onTradeChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Autocomplete
|
||||||
|
options={vendorOptions}
|
||||||
|
getOptionLabel={(option) =>
|
||||||
|
`${option.companyName}${option.tradeSpecialties ? ` · ${option.tradeSpecialties}` : ""}`
|
||||||
|
}
|
||||||
|
value={vendors.find((vendor) => Number(vendor.id) === vendorId) ?? null}
|
||||||
|
onChange={(_event, option) => onVendorChange(option ? Number(option.id) : null)}
|
||||||
|
renderInput={(params) => <TextField {...params} label="Vendor / technician" />}
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<Button variant="contained" onClick={onAdd} disabled={vendorId == null}>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
src/app/(protected)/vendors/_components/preference-row.tsx
vendored
Normal file
46
src/app/(protected)/vendors/_components/preference-row.tsx
vendored
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { Alert, IconButton, Paper, Stack } from "@mui/material";
|
||||||
|
import { ArrowDown, ArrowUp, Trash2 } from "lucide-react";
|
||||||
|
import { Text } from "@/components/ui/text";
|
||||||
|
import type { SiteVendorPreference } from "@/domain/vendor-operations/types/vendor-operations";
|
||||||
|
|
||||||
|
interface PreferenceRowProps {
|
||||||
|
item: SiteVendorPreference;
|
||||||
|
index: number;
|
||||||
|
total: number;
|
||||||
|
onMove: (index: number, offset: number) => void;
|
||||||
|
onRemove: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PreferenceRow({ item, index, total, onMove, onRemove }: PreferenceRowProps) {
|
||||||
|
return (
|
||||||
|
<Paper variant="outlined" className="p-3">
|
||||||
|
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
||||||
|
<Text className="flex-1">
|
||||||
|
{index + 1}. {item.vendorName} {item.trade ? `· ${item.trade}` : "· All trades"}
|
||||||
|
</Text>
|
||||||
|
{item.isStale && (
|
||||||
|
<Alert severity="warning">
|
||||||
|
<Text as="span">Stale preference</Text>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<IconButton
|
||||||
|
aria-label={`Move ${item.vendorName} up`}
|
||||||
|
onClick={() => onMove(index, -1)}
|
||||||
|
disabled={index === 0}
|
||||||
|
>
|
||||||
|
<ArrowUp size={18} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
aria-label={`Move ${item.vendorName} down`}
|
||||||
|
onClick={() => onMove(index, 1)}
|
||||||
|
disabled={index === total - 1}
|
||||||
|
>
|
||||||
|
<ArrowDown size={18} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton aria-label={`Remove ${item.vendorName}`} onClick={() => onRemove(index)}>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</IconButton>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
src/app/(protected)/vendors/_components/use-vendor-preferences-editor.ts
vendored
Normal file
120
src/app/(protected)/vendors/_components/use-vendor-preferences-editor.ts
vendored
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "react-toastify";
|
||||||
|
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api";
|
||||||
|
import type { SiteVendorPreference } from "@/domain/vendor-operations/types/vendor-operations";
|
||||||
|
import {
|
||||||
|
useWorkOrderLocations,
|
||||||
|
useWorkOrderVendors,
|
||||||
|
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
||||||
|
import { queryKeys } from "@/infra/query-key/query-key";
|
||||||
|
|
||||||
|
const EMPTY_PREFERENCES: SiteVendorPreference[] = [];
|
||||||
|
|
||||||
|
export function useVendorPreferencesEditor() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [locationId, setLocationId] = useState<string | number>("");
|
||||||
|
const [draft, setDraft] = useState<SiteVendorPreference[]>([]);
|
||||||
|
const [isDirty, setIsDirty] = useState(false);
|
||||||
|
const [trade, setTrade] = useState("");
|
||||||
|
const [vendorId, setVendorId] = useState<number | null>(null);
|
||||||
|
const { data: locations = [] } = useWorkOrderLocations();
|
||||||
|
const { data: vendors = [] } = useWorkOrderVendors(trade || undefined);
|
||||||
|
const queryKey = queryKeys.vendorOperations.sitePreferences(locationId);
|
||||||
|
const { data: serverPreferences = EMPTY_PREFERENCES, error } = useQuery({
|
||||||
|
queryKey,
|
||||||
|
queryFn: () => vendorOperationsApi.sitePreferences(locationId),
|
||||||
|
enabled: locationId !== "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// A background refetch must not clobber in-flight edits, so only adopt server
|
||||||
|
// data while the draft is clean (initial load, site change, or after a save).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isDirty) {
|
||||||
|
setDraft(serverPreferences);
|
||||||
|
}
|
||||||
|
}, [isDirty, serverPreferences]);
|
||||||
|
|
||||||
|
const vendorOptions = useMemo(() => {
|
||||||
|
const selectedVendorIds = new Set(draft.map((preference) => preference.vendorId));
|
||||||
|
return vendors.filter((vendor) => !selectedVendorIds.has(Number(vendor.id)));
|
||||||
|
}, [draft, vendors]);
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
vendorOperationsApi.replaceSitePreferences(
|
||||||
|
locationId,
|
||||||
|
draft.map((item, sortOrder) => ({ vendorId: item.vendorId, trade: item.trade, sortOrder })),
|
||||||
|
),
|
||||||
|
onMutate: async () => {
|
||||||
|
await queryClient.cancelQueries({ queryKey });
|
||||||
|
},
|
||||||
|
onSuccess: (savedPreferences) => {
|
||||||
|
queryClient.setQueryData(queryKey, savedPreferences);
|
||||||
|
setDraft(savedPreferences);
|
||||||
|
setIsDirty(false);
|
||||||
|
toast.success("Preferred vendors updated");
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Unable to save preferred vendors. Please try again."),
|
||||||
|
});
|
||||||
|
|
||||||
|
const move = (index: number, offset: number) => {
|
||||||
|
setDraft((current) => {
|
||||||
|
const target = index + offset;
|
||||||
|
if (target < 0 || target >= current.length) return current;
|
||||||
|
const next = [...current];
|
||||||
|
[next[index], next[target]] = [next[target]!, next[index]!];
|
||||||
|
return next.map((item, sortOrder) => ({ ...item, sortOrder }));
|
||||||
|
});
|
||||||
|
setIsDirty(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const add = () => {
|
||||||
|
const vendor = vendors.find((item) => Number(item.id) === vendorId);
|
||||||
|
if (!vendor || locationId === "") return;
|
||||||
|
setDraft((current) => [
|
||||||
|
...current,
|
||||||
|
{
|
||||||
|
id: -Date.now(),
|
||||||
|
locationId: Number(locationId),
|
||||||
|
vendorId: Number(vendor.id),
|
||||||
|
vendorName: vendor.companyName,
|
||||||
|
trade: trade.trim() || undefined,
|
||||||
|
sortOrder: current.length,
|
||||||
|
isStale: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
setVendorId(null);
|
||||||
|
setIsDirty(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (index: number) => {
|
||||||
|
setDraft((current) => current.filter((_, row) => row !== index));
|
||||||
|
setIsDirty(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectLocation = (nextLocationId: string | number) => {
|
||||||
|
setLocationId(nextLocationId);
|
||||||
|
setDraft(EMPTY_PREFERENCES);
|
||||||
|
setIsDirty(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
locationId,
|
||||||
|
selectLocation,
|
||||||
|
locations,
|
||||||
|
draft,
|
||||||
|
trade,
|
||||||
|
setTrade,
|
||||||
|
vendorId,
|
||||||
|
setVendorId,
|
||||||
|
vendors,
|
||||||
|
vendorOptions,
|
||||||
|
error,
|
||||||
|
isSaving: save.isPending,
|
||||||
|
save: () => save.mutate(),
|
||||||
|
move,
|
||||||
|
add,
|
||||||
|
remove,
|
||||||
|
};
|
||||||
|
}
|
||||||
222
src/app/(protected)/vendors/preferences.tsx
vendored
222
src/app/(protected)/vendors/preferences.tsx
vendored
|
|
@ -1,202 +1,28 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { Autocomplete, Box, Button, Paper, Stack, TextField } from "@mui/material";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
Alert,
|
|
||||||
Autocomplete,
|
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
IconButton,
|
|
||||||
Paper,
|
|
||||||
Stack,
|
|
||||||
TextField,
|
|
||||||
} from "@mui/material";
|
|
||||||
import { ArrowDown, ArrowUp, Trash2 } from "lucide-react";
|
|
||||||
import { toast } from "react-toastify";
|
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api";
|
import { AddVendorPreferenceControls } from "./_components/add-vendor-preference-controls";
|
||||||
import type { SiteVendorPreference } from "@/domain/vendor-operations/types/vendor-operations";
|
import { PreferenceRow } from "./_components/preference-row";
|
||||||
import {
|
import { useVendorPreferencesEditor } from "./_components/use-vendor-preferences-editor";
|
||||||
useWorkOrderLocations,
|
|
||||||
useWorkOrderVendors,
|
|
||||||
} from "@/domain/work-orders/use-cases/use-work-order-detail";
|
|
||||||
import type { VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
|
||||||
|
|
||||||
const EMPTY_PREFERENCES: SiteVendorPreference[] = [];
|
|
||||||
|
|
||||||
type AddVendorPreferenceControlsProps = {
|
|
||||||
trade: string;
|
|
||||||
onTradeChange: (value: string) => void;
|
|
||||||
vendorOptions: VendorDropdownItem[];
|
|
||||||
vendors: VendorDropdownItem[];
|
|
||||||
vendorId: number | null;
|
|
||||||
onVendorChange: (vendorId: number | null) => void;
|
|
||||||
onAdd: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function AddVendorPreferenceControls({
|
|
||||||
trade,
|
|
||||||
onTradeChange,
|
|
||||||
vendorOptions,
|
|
||||||
vendors,
|
|
||||||
vendorId,
|
|
||||||
onVendorChange,
|
|
||||||
onAdd,
|
|
||||||
}: AddVendorPreferenceControlsProps) {
|
|
||||||
return (
|
|
||||||
<Paper variant="outlined" className="p-4">
|
|
||||||
<Stack direction={{ xs: "column", md: "row" }} spacing={2}>
|
|
||||||
<TextField
|
|
||||||
label="Trade scope (optional)"
|
|
||||||
value={trade}
|
|
||||||
onChange={(event) => onTradeChange(event.target.value)}
|
|
||||||
/>
|
|
||||||
<Autocomplete
|
|
||||||
options={vendorOptions}
|
|
||||||
getOptionLabel={(option) =>
|
|
||||||
`${option.companyName}${option.tradeSpecialties ? ` · ${option.tradeSpecialties}` : ""}`
|
|
||||||
}
|
|
||||||
value={vendors.find((vendor) => Number(vendor.id) === vendorId) ?? null}
|
|
||||||
onChange={(_event, option) => onVendorChange(option ? Number(option.id) : null)}
|
|
||||||
renderInput={(params) => <TextField {...params} label="Vendor / technician" />}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<Button variant="contained" onClick={onAdd} disabled={vendorId == null}>
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type PreferenceRowProps = {
|
|
||||||
item: SiteVendorPreference;
|
|
||||||
index: number;
|
|
||||||
total: number;
|
|
||||||
onMove: (index: number, offset: number) => void;
|
|
||||||
onRemove: (index: number) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function PreferenceRow({ item, index, total, onMove, onRemove }: PreferenceRowProps) {
|
|
||||||
return (
|
|
||||||
<Paper variant="outlined" className="p-3">
|
|
||||||
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
|
|
||||||
<Text className="flex-1">
|
|
||||||
{index + 1}. {item.vendorName} {item.trade ? `· ${item.trade}` : "· All trades"}
|
|
||||||
</Text>
|
|
||||||
{item.isStale && (
|
|
||||||
<Alert severity="warning">
|
|
||||||
<Text as="span">Stale preference</Text>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
<IconButton
|
|
||||||
aria-label={`Move ${item.vendorName} up`}
|
|
||||||
onClick={() => onMove(index, -1)}
|
|
||||||
disabled={index === 0}
|
|
||||||
>
|
|
||||||
<ArrowUp size={18} />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton
|
|
||||||
aria-label={`Move ${item.vendorName} down`}
|
|
||||||
onClick={() => onMove(index, 1)}
|
|
||||||
disabled={index === total - 1}
|
|
||||||
>
|
|
||||||
<ArrowDown size={18} />
|
|
||||||
</IconButton>
|
|
||||||
<IconButton aria-label={`Remove ${item.vendorName}`} onClick={() => onRemove(index)}>
|
|
||||||
<Trash2 size={18} />
|
|
||||||
</IconButton>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VendorPreferencesPage() {
|
export default function VendorPreferencesPage() {
|
||||||
const queryClient = useQueryClient();
|
const {
|
||||||
const [locationId, setLocationId] = useState<string | number>("");
|
locationId,
|
||||||
const [draft, setDraft] = useState<SiteVendorPreference[]>([]);
|
selectLocation,
|
||||||
const [isDirty, setIsDirty] = useState(false);
|
locations,
|
||||||
const [trade, setTrade] = useState("");
|
draft,
|
||||||
const [vendorId, setVendorId] = useState<number | null>(null);
|
trade,
|
||||||
const { data: locations = [] } = useWorkOrderLocations();
|
setTrade,
|
||||||
const { data: vendors = [] } = useWorkOrderVendors(trade || undefined);
|
vendorId,
|
||||||
const queryKey = ["vendor-operations", "site-preferences", locationId];
|
setVendorId,
|
||||||
const { data: serverPreferences = EMPTY_PREFERENCES, error } = useQuery({
|
vendors,
|
||||||
queryKey,
|
vendorOptions,
|
||||||
queryFn: () => vendorOperationsApi.sitePreferences(locationId),
|
error,
|
||||||
enabled: locationId !== "",
|
isSaving,
|
||||||
});
|
save,
|
||||||
|
move,
|
||||||
// A background refetch must not clobber in-flight edits, so only adopt server
|
add,
|
||||||
// data while the draft is clean (initial load, site change, or after a save).
|
remove,
|
||||||
useEffect(() => {
|
} = useVendorPreferencesEditor();
|
||||||
if (!isDirty) {
|
|
||||||
setDraft(serverPreferences);
|
|
||||||
}
|
|
||||||
}, [isDirty, serverPreferences]);
|
|
||||||
|
|
||||||
const vendorOptions = useMemo(() => {
|
|
||||||
const selectedVendorIds = new Set(draft.map((preference) => preference.vendorId));
|
|
||||||
return vendors.filter((vendor) => !selectedVendorIds.has(Number(vendor.id)));
|
|
||||||
}, [draft, vendors]);
|
|
||||||
|
|
||||||
const save = useMutation({
|
|
||||||
mutationFn: () =>
|
|
||||||
vendorOperationsApi.replaceSitePreferences(
|
|
||||||
locationId,
|
|
||||||
draft.map((item, sortOrder) => ({ vendorId: item.vendorId, trade: item.trade, sortOrder })),
|
|
||||||
),
|
|
||||||
onMutate: async () => {
|
|
||||||
await queryClient.cancelQueries({ queryKey });
|
|
||||||
},
|
|
||||||
onSuccess: (savedPreferences) => {
|
|
||||||
queryClient.setQueryData(queryKey, savedPreferences);
|
|
||||||
setDraft(savedPreferences);
|
|
||||||
setIsDirty(false);
|
|
||||||
toast.success("Preferred vendors updated");
|
|
||||||
},
|
|
||||||
onError: () => toast.error("Unable to save preferred vendors. Please try again."),
|
|
||||||
});
|
|
||||||
|
|
||||||
const move = (index: number, offset: number) => {
|
|
||||||
setDraft((current) => {
|
|
||||||
const target = index + offset;
|
|
||||||
if (target < 0 || target >= current.length) return current;
|
|
||||||
const next = [...current];
|
|
||||||
[next[index], next[target]] = [next[target]!, next[index]!];
|
|
||||||
return next.map((item, sortOrder) => ({ ...item, sortOrder }));
|
|
||||||
});
|
|
||||||
setIsDirty(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const add = () => {
|
|
||||||
const vendor = vendors.find((item) => Number(item.id) === vendorId);
|
|
||||||
if (!vendor || locationId === "") return;
|
|
||||||
setDraft((current) => [
|
|
||||||
...current,
|
|
||||||
{
|
|
||||||
id: -Date.now(),
|
|
||||||
locationId: Number(locationId),
|
|
||||||
vendorId: Number(vendor.id),
|
|
||||||
vendorName: vendor.companyName,
|
|
||||||
trade: trade.trim() || undefined,
|
|
||||||
sortOrder: current.length,
|
|
||||||
isStale: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
setVendorId(null);
|
|
||||||
setIsDirty(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const remove = (index: number) => {
|
|
||||||
setDraft((current) => current.filter((_, row) => row !== index));
|
|
||||||
setIsDirty(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectLocation = (nextLocationId: string | number) => {
|
|
||||||
setLocationId(nextLocationId);
|
|
||||||
setDraft(EMPTY_PREFERENCES);
|
|
||||||
setIsDirty(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box className="flex flex-col gap-4 p-4">
|
<Box className="flex flex-col gap-4 p-4">
|
||||||
|
|
@ -244,8 +70,8 @@ export default function VendorPreferencesPage() {
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
<Button variant="contained" onClick={() => save.mutate()} disabled={save.isPending}>
|
<Button variant="contained" onClick={save} disabled={isSaving}>
|
||||||
{save.isPending ? "Saving…" : "Save preferences"}
|
{isSaving ? "Saving…" : "Save preferences"}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
|
||||||
import { formatVendorPortalDateTime } from "@/domain/vendor-portal/lib/status-helpers";
|
import { formatVendorPortalDateTime } from "@/domain/vendor-portal/lib/status-helpers";
|
||||||
import type { VendorPortalChecklistItem } from "@/domain/vendor-portal/types/vendor-portal";
|
import type { VendorPortalChecklistItem } from "@/domain/vendor-portal/types/vendor-portal";
|
||||||
|
import { useUpdateVendorPortalChecklistItem } from "@/domain/vendor-portal/use-cases/use-vendor-portal-dispatch-actions";
|
||||||
|
|
||||||
type ChecklistSectionProps = {
|
type ChecklistSectionProps = {
|
||||||
token: string;
|
token: string;
|
||||||
|
|
@ -19,22 +19,20 @@ export function ChecklistSection({
|
||||||
locked,
|
locked,
|
||||||
onItemUpdated,
|
onItemUpdated,
|
||||||
}: ChecklistSectionProps) {
|
}: ChecklistSectionProps) {
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const updateItem = useUpdateVendorPortalChecklistItem(token, dispatchId);
|
||||||
|
// updateItem.isError only reflects the latest call on the shared mutation observer, so it
|
||||||
|
// can't tell an earlier failed toggle from a later one that succeeded. Track it locally.
|
||||||
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
const toggle = async (item: VendorPortalChecklistItem) => {
|
const toggle = (item: VendorPortalChecklistItem) => {
|
||||||
if (locked) return;
|
if (locked) return;
|
||||||
setErrorMessage("");
|
setFailed(false);
|
||||||
try {
|
// mutateAsync resolves per call, unlike mutate's onSuccess which only fires for the
|
||||||
const updated = await vendorPortalApi.updateChecklistItem(
|
// latest call on this shared observer — needed so two quick toggles both report back.
|
||||||
token,
|
void updateItem
|
||||||
dispatchId,
|
.mutateAsync({ itemId: item.id, isCompleted: !item.isCompleted })
|
||||||
item.id,
|
.then((updated) => onItemUpdated(updated))
|
||||||
!item.isCompleted,
|
.catch(() => setFailed(true));
|
||||||
);
|
|
||||||
onItemUpdated(updated);
|
|
||||||
} catch {
|
|
||||||
setErrorMessage("Unable to update the checklist. Please try again.");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
|
|
@ -43,8 +41,8 @@ export function ChecklistSection({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Text variant="error" when={Boolean(errorMessage)}>
|
<Text variant="error" when={failed}>
|
||||||
{errorMessage}
|
Unable to update the checklist. Please try again.
|
||||||
</Text>
|
</Text>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<div key={item.id} className={`vp-checklist-item ${item.isCompleted ? "done" : ""}`}>
|
<div key={item.id} className={`vp-checklist-item ${item.isCompleted ? "done" : ""}`}>
|
||||||
|
|
@ -52,7 +50,7 @@ export function ChecklistSection({
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id={`cl-${item.id}`}
|
id={`cl-${item.id}`}
|
||||||
checked={item.isCompleted}
|
checked={item.isCompleted}
|
||||||
onChange={() => void toggle(item)}
|
onChange={() => toggle(item)}
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
/>
|
/>
|
||||||
<label htmlFor={`cl-${item.id}`} style={{ flex: 1 }}>
|
<label htmlFor={`cl-${item.id}`} style={{ flex: 1 }}>
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
|
||||||
import { formatVendorPortalDateTime } from "@/domain/vendor-portal/lib/status-helpers";
|
import { formatVendorPortalDateTime } from "@/domain/vendor-portal/lib/status-helpers";
|
||||||
import type { VendorPortalComment } from "@/domain/vendor-portal/types/vendor-portal";
|
import type { VendorPortalComment } from "@/domain/vendor-portal/types/vendor-portal";
|
||||||
|
import { useAddVendorPortalComment } from "@/domain/vendor-portal/use-cases/use-vendor-portal-dispatch-actions";
|
||||||
|
|
||||||
type CommentsSectionProps = {
|
type CommentsSectionProps = {
|
||||||
token: string;
|
token: string;
|
||||||
|
|
@ -13,23 +13,18 @@ type CommentsSectionProps = {
|
||||||
|
|
||||||
export function CommentsSection({ token, dispatchId, comments, onAdded }: CommentsSectionProps) {
|
export function CommentsSection({ token, dispatchId, comments, onAdded }: CommentsSectionProps) {
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const addComment = useAddVendorPortalComment(token, dispatchId);
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
|
||||||
|
|
||||||
const submit = async (event: React.FormEvent) => {
|
const submit = (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!text.trim()) return;
|
const trimmed = text.trim();
|
||||||
setErrorMessage("");
|
if (!trimmed) return;
|
||||||
setSubmitting(true);
|
addComment.mutate(trimmed, {
|
||||||
try {
|
onSuccess: (added) => {
|
||||||
const added = await vendorPortalApi.addComment(token, dispatchId, text.trim());
|
onAdded(added);
|
||||||
onAdded(added);
|
setText("");
|
||||||
setText("");
|
},
|
||||||
} catch {
|
});
|
||||||
setErrorMessage("Unable to post your comment. Please try again.");
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -43,26 +38,26 @@ export function CommentsSection({ token, dispatchId, comments, onAdded }: Commen
|
||||||
<div>{comment.commenttext}</div>
|
<div>{comment.commenttext}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Text variant="error" when={Boolean(errorMessage)}>
|
<Text variant="error" when={addComment.isError}>
|
||||||
{errorMessage}
|
Unable to post your comment. Please try again.
|
||||||
</Text>
|
</Text>
|
||||||
<form onSubmit={(event) => void submit(event)} style={{ marginTop: 12 }}>
|
<form onSubmit={submit} style={{ marginTop: 12 }}>
|
||||||
<textarea
|
<textarea
|
||||||
className="vp-textarea"
|
className="vp-textarea"
|
||||||
placeholder="Add a comment for the dispatcher…"
|
placeholder="Add a comment for the dispatcher…"
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setText(event.target.value);
|
setText(event.target.value);
|
||||||
setErrorMessage("");
|
if (addComment.isError) addComment.reset();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="vp-actions">
|
<div className="vp-actions">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="vp-btn vp-btn-primary"
|
className="vp-btn vp-btn-primary"
|
||||||
disabled={submitting || !text.trim()}
|
disabled={addComment.isPending || !text.trim()}
|
||||||
>
|
>
|
||||||
{submitting ? "Posting…" : "Post Comment"}
|
{addComment.isPending ? "Posting…" : "Post Comment"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Text } from "@/components/ui/text";
|
import { Text } from "@/components/ui/text";
|
||||||
import { SignaturePad } from "@/app/v/_components/signature-pad";
|
import { SignaturePad } from "@/app/v/_components/signature-pad";
|
||||||
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
|
||||||
import { formatVendorPortalDateTime } from "@/domain/vendor-portal/lib/status-helpers";
|
import { formatVendorPortalDateTime } from "@/domain/vendor-portal/lib/status-helpers";
|
||||||
import type { VendorPortalSignoff } from "@/domain/vendor-portal/types/vendor-portal";
|
import type { VendorPortalSignoff } from "@/domain/vendor-portal/types/vendor-portal";
|
||||||
|
import { useAddVendorPortalSignoff } from "@/domain/vendor-portal/use-cases/use-vendor-portal-dispatch-actions";
|
||||||
|
|
||||||
type SignoffSectionProps = {
|
type SignoffSectionProps = {
|
||||||
token: string;
|
token: string;
|
||||||
|
|
@ -28,8 +28,8 @@ export function SignoffSection({
|
||||||
const [method, setMethod] = useState<"drawn" | "typed">("drawn");
|
const [method, setMethod] = useState<"drawn" | "typed">("drawn");
|
||||||
const [typedSig, setTypedSig] = useState("");
|
const [typedSig, setTypedSig] = useState("");
|
||||||
const [drawnSig, setDrawnSig] = useState("");
|
const [drawnSig, setDrawnSig] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [validationError, setValidationError] = useState("");
|
||||||
const [errorMessage, setErrorMessage] = useState("");
|
const addSignoff = useAddVendorPortalSignoff(token, dispatchId);
|
||||||
|
|
||||||
const existingSignoff = existing.find((signoff) => signoff.signoffType === signoffType);
|
const existingSignoff = existing.find((signoff) => signoff.signoffType === signoffType);
|
||||||
|
|
||||||
|
|
@ -56,32 +56,31 @@ export function SignoffSection({
|
||||||
return <Text variant="feedback">Signoff is unavailable for this dispatch.</Text>;
|
return <Text variant="feedback">Signoff is unavailable for this dispatch.</Text>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const submit = async (event: React.FormEvent) => {
|
const clearErrors = () => {
|
||||||
|
setValidationError("");
|
||||||
|
if (addSignoff.isError) addSignoff.reset();
|
||||||
|
};
|
||||||
|
|
||||||
|
const submit = (event: React.FormEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const signature = method === "drawn" ? drawnSig : typedSig;
|
const signature = method === "drawn" ? drawnSig : typedSig;
|
||||||
if (!name.trim() || !signature) {
|
if (!name.trim() || !signature) {
|
||||||
setErrorMessage("Name and signature are required.");
|
setValidationError("Name and signature are required.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setErrorMessage("");
|
setValidationError("");
|
||||||
setSubmitting(true);
|
addSignoff.mutate(
|
||||||
try {
|
{ name: name.trim(), signature, signatureMethod: method, signoffType },
|
||||||
await vendorPortalApi.addSignoff(token, dispatchId, {
|
{ onSuccess: () => onAdded() },
|
||||||
name: name.trim(),
|
);
|
||||||
signature,
|
|
||||||
signatureMethod: method,
|
|
||||||
signoffType,
|
|
||||||
});
|
|
||||||
onAdded();
|
|
||||||
} catch {
|
|
||||||
setErrorMessage("Unable to submit the signoff. Please try again.");
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const errorMessage =
|
||||||
|
validationError ||
|
||||||
|
(addSignoff.isError ? "Unable to submit the signoff. Please try again." : "");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={(event) => void submit(event)}>
|
<form onSubmit={submit}>
|
||||||
<div className="vp-row">
|
<div className="vp-row">
|
||||||
<strong>{nameLabel}</strong>
|
<strong>{nameLabel}</strong>
|
||||||
<input
|
<input
|
||||||
|
|
@ -89,7 +88,7 @@ export function SignoffSection({
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setName(event.target.value);
|
setName(event.target.value);
|
||||||
setErrorMessage("");
|
clearErrors();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -111,7 +110,7 @@ export function SignoffSection({
|
||||||
<SignaturePad
|
<SignaturePad
|
||||||
onChange={(signature) => {
|
onChange={(signature) => {
|
||||||
setDrawnSig(signature);
|
setDrawnSig(signature);
|
||||||
setErrorMessage("");
|
clearErrors();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -123,7 +122,7 @@ export function SignoffSection({
|
||||||
value={typedSig}
|
value={typedSig}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
setTypedSig(event.target.value);
|
setTypedSig(event.target.value);
|
||||||
setErrorMessage("");
|
clearErrors();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -132,8 +131,8 @@ export function SignoffSection({
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
</Text>
|
</Text>
|
||||||
<div className="vp-actions">
|
<div className="vp-actions">
|
||||||
<button type="submit" className="vp-btn vp-btn-success" disabled={submitting}>
|
<button type="submit" className="vp-btn vp-btn-success" disabled={addSignoff.isPending}>
|
||||||
{submitting ? "Signing…" : "Submit Signoff"}
|
{addSignoff.isPending ? "Signing…" : "Submit Signoff"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { VendorPortalFeedback } from "@/app/v/_components/vendor-portal-feedback";
|
import { VendorPortalFeedback } from "@/app/v/_components/vendor-portal-feedback";
|
||||||
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
|
||||||
import { createUpliftRequestKey } from "@/domain/vendor-portal/lib/request-key";
|
import { createUpliftRequestKey } from "@/domain/vendor-portal/lib/request-key";
|
||||||
import type {
|
import type {
|
||||||
VendorPortalDocumentStatus,
|
VendorPortalDocumentStatus,
|
||||||
VendorPortalMediaCounts,
|
VendorPortalMediaCounts,
|
||||||
VendorPortalUpliftRequest,
|
VendorPortalUpliftRequest,
|
||||||
} from "@/domain/vendor-portal/types/vendor-portal";
|
} from "@/domain/vendor-portal/types/vendor-portal";
|
||||||
|
import {
|
||||||
|
useReviseVendorUplift,
|
||||||
|
useRequestVendorUplift,
|
||||||
|
useWithdrawVendorUplift,
|
||||||
|
} from "@/domain/vendor-portal/use-cases/use-vendor-portal-dispatch-actions";
|
||||||
import { UpliftRequestForm } from "@/app/v/[token]/dispatch/_components/uplift-request-form";
|
import { UpliftRequestForm } from "@/app/v/[token]/dispatch/_components/uplift-request-form";
|
||||||
import type { UpliftFormValues } from "@/domain/vendor-portal/lib/uplift-validation";
|
import type { UpliftFormValues } from "@/domain/vendor-portal/lib/uplift-validation";
|
||||||
import { vendorMediaUsage } from "@/domain/vendor-portal/lib/document-validation";
|
import { vendorMediaUsage } from "@/domain/vendor-portal/lib/document-validation";
|
||||||
|
|
@ -56,6 +60,9 @@ function useVendorUpliftActions({
|
||||||
const [infoMessage, setInfoMessage] = useState("");
|
const [infoMessage, setInfoMessage] = useState("");
|
||||||
const [requestError, setRequestError] = useState("");
|
const [requestError, setRequestError] = useState("");
|
||||||
const [errorRequestId, setErrorRequestId] = useState<string | number | null>(null);
|
const [errorRequestId, setErrorRequestId] = useState<string | number | null>(null);
|
||||||
|
const requestUplift = useRequestVendorUplift(token, dispatchId);
|
||||||
|
const withdrawUplift = useWithdrawVendorUplift(token, dispatchId);
|
||||||
|
const reviseUplift = useReviseVendorUplift(token, dispatchId);
|
||||||
|
|
||||||
const uploadAndScan = useCallback(
|
const uploadAndScan = useCallback(
|
||||||
async (file: File): Promise<{ documentId: number; scan: EvidenceScanState }> => {
|
async (file: File): Promise<{ documentId: number; scan: EvidenceScanState }> => {
|
||||||
|
|
@ -88,7 +95,7 @@ function useVendorUpliftActions({
|
||||||
}
|
}
|
||||||
evidenceDocumentId = result.documentId;
|
evidenceDocumentId = result.documentId;
|
||||||
}
|
}
|
||||||
const outcome = await vendorPortalApi.requestUplift(token, dispatchId, {
|
const outcome = await requestUplift.mutateAsync({
|
||||||
requestedNTE: values.amount,
|
requestedNTE: values.amount,
|
||||||
reason: values.reason,
|
reason: values.reason,
|
||||||
requestKey: createUpliftRequestKey(),
|
requestKey: createUpliftRequestKey(),
|
||||||
|
|
@ -112,7 +119,7 @@ function useVendorUpliftActions({
|
||||||
setRequestError("");
|
setRequestError("");
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await vendorPortalApi.withdrawUplift(token, dispatchId, request.id);
|
await withdrawUplift.mutateAsync(request.id);
|
||||||
await onChanged();
|
await onChanged();
|
||||||
} catch {
|
} catch {
|
||||||
setRequestError("Unable to withdraw the uplift request. Please try again.");
|
setRequestError("Unable to withdraw the uplift request. Please try again.");
|
||||||
|
|
@ -137,10 +144,13 @@ function useVendorUpliftActions({
|
||||||
}
|
}
|
||||||
evidenceDocumentId = result.documentId;
|
evidenceDocumentId = result.documentId;
|
||||||
}
|
}
|
||||||
await vendorPortalApi.reviseUplift(token, dispatchId, request.id, {
|
await reviseUplift.mutateAsync({
|
||||||
requestedNTE: values.amount,
|
requestId: request.id,
|
||||||
reason: values.reason,
|
payload: {
|
||||||
evidenceDocumentId,
|
requestedNTE: values.amount,
|
||||||
|
reason: values.reason,
|
||||||
|
evidenceDocumentId,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
evidence.reset();
|
evidence.reset();
|
||||||
await onChanged();
|
await onChanged();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { useMutation, type UseMutationResult } from "@tanstack/react-query";
|
||||||
|
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||||
|
import type {
|
||||||
|
VendorPortalComment,
|
||||||
|
VendorPortalDispatchDetail,
|
||||||
|
VendorPortalSignoff,
|
||||||
|
VendorUpliftRequestBody,
|
||||||
|
VendorUpliftRequestOutcome,
|
||||||
|
VendorUpliftReviseBody,
|
||||||
|
} from "@/domain/vendor-portal/types/vendor-portal";
|
||||||
|
|
||||||
|
interface UpdateChecklistItemInput {
|
||||||
|
itemId: string | number;
|
||||||
|
isCompleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateVendorPortalChecklistItem(
|
||||||
|
token: string,
|
||||||
|
dispatchId: number,
|
||||||
|
): UseMutationResult<
|
||||||
|
VendorPortalDispatchDetail["checklist"][number],
|
||||||
|
Error,
|
||||||
|
UpdateChecklistItemInput
|
||||||
|
> {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ itemId, isCompleted }: UpdateChecklistItemInput) =>
|
||||||
|
vendorPortalApi.updateChecklistItem(token, dispatchId, itemId, isCompleted),
|
||||||
|
// Each caller already renders its own inline failure copy.
|
||||||
|
meta: { suppressErrorToast: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddVendorPortalSignoff(
|
||||||
|
token: string,
|
||||||
|
dispatchId: number,
|
||||||
|
): UseMutationResult<
|
||||||
|
VendorPortalSignoff,
|
||||||
|
Error,
|
||||||
|
{ name: string; signature: string; signatureMethod: string; signoffType: string }
|
||||||
|
> {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: {
|
||||||
|
name: string;
|
||||||
|
signature: string;
|
||||||
|
signatureMethod: string;
|
||||||
|
signoffType: string;
|
||||||
|
}) => vendorPortalApi.addSignoff(token, dispatchId, payload),
|
||||||
|
meta: { suppressErrorToast: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddVendorPortalComment(
|
||||||
|
token: string,
|
||||||
|
dispatchId: number,
|
||||||
|
): UseMutationResult<VendorPortalComment, Error, string> {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (commentText: string) => vendorPortalApi.addComment(token, dispatchId, commentText),
|
||||||
|
meta: { suppressErrorToast: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRequestVendorUplift(
|
||||||
|
token: string,
|
||||||
|
dispatchId: number,
|
||||||
|
): UseMutationResult<VendorUpliftRequestOutcome, Error, VendorUpliftRequestBody> {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: VendorUpliftRequestBody) =>
|
||||||
|
vendorPortalApi.requestUplift(token, dispatchId, payload),
|
||||||
|
meta: { suppressErrorToast: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWithdrawVendorUplift(
|
||||||
|
token: string,
|
||||||
|
dispatchId: number,
|
||||||
|
): UseMutationResult<void, Error, string | number> {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (requestId: string | number) =>
|
||||||
|
vendorPortalApi.withdrawUplift(token, dispatchId, requestId),
|
||||||
|
meta: { suppressErrorToast: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviseUpliftInput {
|
||||||
|
requestId: string | number;
|
||||||
|
payload: VendorUpliftReviseBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReviseVendorUplift(
|
||||||
|
token: string,
|
||||||
|
dispatchId: number,
|
||||||
|
): UseMutationResult<void, Error, ReviseUpliftInput> {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ requestId, payload }: ReviseUpliftInput) =>
|
||||||
|
vendorPortalApi.reviseUplift(token, dispatchId, requestId, payload),
|
||||||
|
meta: { suppressErrorToast: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
32
src/hooks/use-list-page-filters.ts
Normal file
32
src/hooks/use-list-page-filters.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export interface UseListPageFiltersResult {
|
||||||
|
search: string;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
setSearch: (value: string) => void;
|
||||||
|
setPage: (value: number) => void;
|
||||||
|
setPageSize: (value: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The search/page/pageSize state shared by every paginated list page, with the
|
||||||
|
* page reset that must happen whenever search or pageSize changes.
|
||||||
|
*/
|
||||||
|
export function useListPageFilters(defaultPageSize = 12): UseListPageFiltersResult {
|
||||||
|
const [search, setSearchValue] = useState("");
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [pageSize, setPageSizeValue] = useState(defaultPageSize);
|
||||||
|
|
||||||
|
const setSearch = (value: string) => {
|
||||||
|
setSearchValue(value);
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPageSize = (value: number) => {
|
||||||
|
setPageSizeValue(value);
|
||||||
|
setPage(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { search, page, pageSize, setSearch, setPage, setPageSize };
|
||||||
|
}
|
||||||
|
|
@ -173,4 +173,9 @@ export const queryKeys = {
|
||||||
dispatch: (token: string, id: string | number) =>
|
dispatch: (token: string, id: string | number) =>
|
||||||
[...queryKeys.vendorPortal.all, "dispatch", token, id] as const,
|
[...queryKeys.vendorPortal.all, "dispatch", token, id] as const,
|
||||||
},
|
},
|
||||||
|
vendorOperations: {
|
||||||
|
all: ["vendorOperations"] as const,
|
||||||
|
sitePreferences: (locationId: string | number) =>
|
||||||
|
[...queryKeys.vendorOperations.all, "sitePreferences", locationId] as const,
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import FollowUpsListPage from "@/app/(protected)/followups";
|
||||||
|
import { followUpsApi } from "@/domain/follow-ups/api/follow-ups-api";
|
||||||
|
import type { FollowUpListItem } from "@/domain/follow-ups/types/follow-up";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
vi.mock("react-toastify", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||||
|
|
||||||
|
const followUp: FollowUpListItem = {
|
||||||
|
id: 1,
|
||||||
|
reason: "Follow up on parts",
|
||||||
|
scheduleDate: "2026-01-15",
|
||||||
|
scheduleStartTime: "09:00:00",
|
||||||
|
employeeId: 1,
|
||||||
|
workOrderId: 5,
|
||||||
|
accountId: 1,
|
||||||
|
locationId: 1,
|
||||||
|
accountName: "Acme Co",
|
||||||
|
assignedTo: "Jamie",
|
||||||
|
locationName: "Main Site",
|
||||||
|
workOrderNumber: "WO-5",
|
||||||
|
internalWONumber: "SH-5",
|
||||||
|
status: "Open",
|
||||||
|
createdDate: "2026-01-01",
|
||||||
|
createdByName: "Alex",
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderPage(items: FollowUpListItem[] = [followUp]) {
|
||||||
|
vi.spyOn(followUpsApi, "getList").mockResolvedValue({
|
||||||
|
items,
|
||||||
|
totalCount: items.length,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 12,
|
||||||
|
});
|
||||||
|
return renderWithProviders(<FollowUpsListPage />, { withAuth: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("FollowUpsListPage", () => {
|
||||||
|
it("renders follow-ups from the list query", async () => {
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText("Follow up on parts")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("1 total")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an empty state when there are no results", async () => {
|
||||||
|
renderPage([]);
|
||||||
|
|
||||||
|
expect(await screen.findByText("No follow-ups found")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-queries with the search term and resets to page 1", async () => {
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText("Follow up on parts");
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("Search follow-ups..."), {
|
||||||
|
target: { value: "parts" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(followUpsApi.getList).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ search: "parts", page: 1 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-queries when the status filter changes", async () => {
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText("Follow up on parts");
|
||||||
|
|
||||||
|
const [statusSelect] = screen.getAllByRole("combobox");
|
||||||
|
fireEvent.mouseDown(statusSelect);
|
||||||
|
fireEvent.click(await screen.findByRole("option", { name: "Completed" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(followUpsApi.getList).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ status: "Completed", page: 1 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes a follow-up after confirming in the dialog", async () => {
|
||||||
|
vi.spyOn(followUpsApi, "delete").mockResolvedValue(undefined);
|
||||||
|
renderPage();
|
||||||
|
await screen.findByText("Follow up on parts");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||||
|
const dialog = await screen.findByRole("dialog");
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(followUpsApi.delete).toHaveBeenCalledWith(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
90
src/test/app/(protected)/vendors/vendor-preferences-page.test.tsx
vendored
Normal file
90
src/test/app/(protected)/vendors/vendor-preferences-page.test.tsx
vendored
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import VendorPreferencesPage from "@/app/(protected)/vendors/preferences";
|
||||||
|
import { vendorOperationsApi } from "@/domain/vendor-operations/api/vendor-operations-api";
|
||||||
|
import type { SiteVendorPreference } from "@/domain/vendor-operations/types/vendor-operations";
|
||||||
|
import { workOrdersApi } from "@/domain/work-orders/api/work-orders-api";
|
||||||
|
import type { LocationOption, VendorDropdownItem } from "@/domain/work-orders/types/work-order";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
vi.mock("react-toastify", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||||
|
|
||||||
|
const location: LocationOption = { id: 1, name: "Main Site" };
|
||||||
|
const vendor: VendorDropdownItem = {
|
||||||
|
id: 10,
|
||||||
|
companyName: "Acme HVAC",
|
||||||
|
contactName: "Pat",
|
||||||
|
tradeSpecialties: "HVAC",
|
||||||
|
address: "123 Main St",
|
||||||
|
distanceMiles: 5,
|
||||||
|
};
|
||||||
|
const existingPreference: SiteVendorPreference = {
|
||||||
|
id: 1,
|
||||||
|
locationId: 1,
|
||||||
|
vendorId: 10,
|
||||||
|
vendorName: "Acme HVAC",
|
||||||
|
trade: undefined,
|
||||||
|
sortOrder: 0,
|
||||||
|
isStale: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderPage(preferences: SiteVendorPreference[] = []) {
|
||||||
|
vi.spyOn(workOrdersApi, "fetchLocations").mockResolvedValue([location]);
|
||||||
|
vi.spyOn(workOrdersApi, "fetchVendorsDropdown").mockResolvedValue([vendor]);
|
||||||
|
vi.spyOn(vendorOperationsApi, "sitePreferences").mockResolvedValue(preferences);
|
||||||
|
return renderWithProviders(<VendorPreferencesPage />, { withAuth: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectSite() {
|
||||||
|
fireEvent.mouseDown(screen.getByLabelText("Site"));
|
||||||
|
fireEvent.click(await screen.findByRole("option", { name: "Main Site" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("VendorPreferencesPage", () => {
|
||||||
|
it("shows nothing to add/save until a site is selected", () => {
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(screen.queryByRole("button", { name: "Add" })).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: "Save preferences" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds a vendor preference after selecting a site and vendor", async () => {
|
||||||
|
renderPage();
|
||||||
|
await selectSite();
|
||||||
|
|
||||||
|
fireEvent.mouseDown(screen.getByLabelText("Vendor / technician"));
|
||||||
|
fireEvent.click(await screen.findByText("Acme HVAC · HVAC"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Add" }));
|
||||||
|
|
||||||
|
expect(await screen.findByText(/Acme HVAC/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves preferences through replaceSitePreferences", async () => {
|
||||||
|
vi.spyOn(vendorOperationsApi, "replaceSitePreferences").mockResolvedValue([existingPreference]);
|
||||||
|
renderPage([existingPreference]);
|
||||||
|
await selectSite();
|
||||||
|
|
||||||
|
expect(await screen.findByText(/Acme HVAC/)).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save preferences" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(vendorOperationsApi.replaceSitePreferences).toHaveBeenCalledWith(1, [
|
||||||
|
{ vendorId: 10, trade: undefined, sortOrder: 0 },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes a preference from the draft", async () => {
|
||||||
|
renderPage([existingPreference]);
|
||||||
|
await selectSite();
|
||||||
|
|
||||||
|
expect(await screen.findByText(/Acme HVAC/)).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Remove Acme HVAC" }));
|
||||||
|
|
||||||
|
expect(screen.queryByText(/Acme HVAC/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
131
src/test/app/v/checklist-section.test.tsx
Normal file
131
src/test/app/v/checklist-section.test.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ChecklistSection } from "@/app/v/[token]/dispatch/_components/checklist-section";
|
||||||
|
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||||
|
import type { VendorPortalChecklistItem } from "@/domain/vendor-portal/types/vendor-portal";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
const item: VendorPortalChecklistItem = { id: 1, itemText: "Check filters", isCompleted: false };
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderSection(
|
||||||
|
overrides: Partial<React.ComponentProps<typeof ChecklistSection>> = {},
|
||||||
|
onItemUpdated = vi.fn(),
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
onItemUpdated,
|
||||||
|
...renderWithProviders(
|
||||||
|
<ChecklistSection
|
||||||
|
token="portal-token"
|
||||||
|
dispatchId={9}
|
||||||
|
items={[item]}
|
||||||
|
locked={false}
|
||||||
|
onItemUpdated={onItemUpdated}
|
||||||
|
{...overrides}
|
||||||
|
/>,
|
||||||
|
{ withAuth: false },
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ChecklistSection", () => {
|
||||||
|
it("shows a placeholder when there are no items", () => {
|
||||||
|
renderSection({ items: [] });
|
||||||
|
expect(screen.getByText("No checklist items for this dispatch.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toggles an item and calls onItemUpdated with the server response", async () => {
|
||||||
|
const updated: VendorPortalChecklistItem = { ...item, isCompleted: true };
|
||||||
|
vi.spyOn(vendorPortalApi, "updateChecklistItem").mockResolvedValue(updated);
|
||||||
|
const { onItemUpdated } = renderSection();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("checkbox"));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onItemUpdated).toHaveBeenCalledWith(updated));
|
||||||
|
expect(vendorPortalApi.updateChecklistItem).toHaveBeenCalledWith("portal-token", 9, 1, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when locked", () => {
|
||||||
|
const updateChecklistItem = vi.spyOn(vendorPortalApi, "updateChecklistItem");
|
||||||
|
renderSection({ locked: true });
|
||||||
|
|
||||||
|
expect(screen.getByRole("checkbox")).toBeDisabled();
|
||||||
|
fireEvent.click(screen.getByRole("checkbox"));
|
||||||
|
expect(updateChecklistItem).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an error message when the update fails", async () => {
|
||||||
|
vi.spyOn(vendorPortalApi, "updateChecklistItem").mockRejectedValue(new Error("boom"));
|
||||||
|
renderSection();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("checkbox"));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Unable to update the checklist. Please try again."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports every successful toggle when two items are checked before the first responds", async () => {
|
||||||
|
const a: VendorPortalChecklistItem = { id: 1, itemText: "Check filters", isCompleted: false };
|
||||||
|
const b: VendorPortalChecklistItem = { id: 2, itemText: "Check belts", isCompleted: false };
|
||||||
|
const resolvers: Array<() => void> = [];
|
||||||
|
vi.spyOn(vendorPortalApi, "updateChecklistItem").mockImplementation(
|
||||||
|
(_token, _dispatchId, itemId) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolvers.push(() => resolve({ ...(itemId === 1 ? a : b), isCompleted: true }));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const onItemUpdated = vi.fn();
|
||||||
|
renderSection({ items: [a, b] }, onItemUpdated);
|
||||||
|
|
||||||
|
const [boxA, boxB] = screen.getAllByRole("checkbox");
|
||||||
|
fireEvent.click(boxA);
|
||||||
|
fireEvent.click(boxB);
|
||||||
|
|
||||||
|
await waitFor(() => expect(resolvers).toHaveLength(2));
|
||||||
|
resolvers.forEach((resolve) => resolve());
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onItemUpdated.mock.calls.map(([updated]) => updated.id)).toEqual([1, 2]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the inline error visible when one of two in-flight toggles fails and the other succeeds", async () => {
|
||||||
|
const a: VendorPortalChecklistItem = { id: 1, itemText: "Check filters", isCompleted: false };
|
||||||
|
const b: VendorPortalChecklistItem = { id: 2, itemText: "Check belts", isCompleted: false };
|
||||||
|
let rejectA: (error: Error) => void = () => {};
|
||||||
|
let resolveB: () => void = () => {};
|
||||||
|
let aRequested = false;
|
||||||
|
let bRequested = false;
|
||||||
|
vi.spyOn(vendorPortalApi, "updateChecklistItem").mockImplementation(
|
||||||
|
(_token, _dispatchId, itemId) =>
|
||||||
|
itemId === 1
|
||||||
|
? new Promise((_resolve, reject) => {
|
||||||
|
rejectA = reject;
|
||||||
|
aRequested = true;
|
||||||
|
})
|
||||||
|
: new Promise((resolve) => {
|
||||||
|
resolveB = () => resolve({ ...b, isCompleted: true });
|
||||||
|
bRequested = true;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const onItemUpdated = vi.fn();
|
||||||
|
renderSection({ items: [a, b] }, onItemUpdated);
|
||||||
|
|
||||||
|
const [boxA, boxB] = screen.getAllByRole("checkbox");
|
||||||
|
fireEvent.click(boxA);
|
||||||
|
fireEvent.click(boxB);
|
||||||
|
await waitFor(() => expect(aRequested && bRequested).toBe(true));
|
||||||
|
|
||||||
|
rejectA(new Error("boom"));
|
||||||
|
resolveB();
|
||||||
|
|
||||||
|
await waitFor(() => expect(onItemUpdated).toHaveBeenCalledWith({ ...b, isCompleted: true }));
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Unable to update the checklist. Please try again."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
104
src/test/app/v/comments-section.test.tsx
Normal file
104
src/test/app/v/comments-section.test.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { CommentsSection } from "@/app/v/[token]/dispatch/_components/comments-section";
|
||||||
|
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||||
|
import type { VendorPortalComment } from "@/domain/vendor-portal/types/vendor-portal";
|
||||||
|
import { createAppQueryClient } from "@/lib/query/query-client";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
const toastMocks = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
||||||
|
vi.mock("react-toastify", () => ({ toast: toastMocks }));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
toastMocks.success.mockReset();
|
||||||
|
toastMocks.error.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderSection(comments: VendorPortalComment[] = [], onAdded = vi.fn()) {
|
||||||
|
return {
|
||||||
|
onAdded,
|
||||||
|
...renderWithProviders(
|
||||||
|
<CommentsSection token="portal-token" dispatchId={9} comments={comments} onAdded={onAdded} />,
|
||||||
|
{ withAuth: false },
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The app's real query client, so the global mutation error toast is live. */
|
||||||
|
function renderSectionWithAppQueryClient(onAdded = vi.fn()) {
|
||||||
|
return {
|
||||||
|
onAdded,
|
||||||
|
...renderWithProviders(
|
||||||
|
<CommentsSection token="portal-token" dispatchId={9} comments={[]} onAdded={onAdded} />,
|
||||||
|
{ withAuth: false, queryClient: createAppQueryClient() },
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("CommentsSection", () => {
|
||||||
|
it("shows a placeholder when there are no comments yet", () => {
|
||||||
|
renderSection();
|
||||||
|
expect(screen.getByText("No comments yet.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("posts a comment, calls onAdded, and clears the textarea", async () => {
|
||||||
|
const added: VendorPortalComment = { id: 1, commenter: "Me", commenttext: "Looks good" };
|
||||||
|
vi.spyOn(vendorPortalApi, "addComment").mockResolvedValue(added);
|
||||||
|
const { onAdded } = renderSection();
|
||||||
|
|
||||||
|
const textarea = screen.getByPlaceholderText("Add a comment for the dispatcher…");
|
||||||
|
fireEvent.change(textarea, { target: { value: "Looks good" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Post Comment" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onAdded).toHaveBeenCalledWith(added));
|
||||||
|
expect(vendorPortalApi.addComment).toHaveBeenCalledWith("portal-token", 9, "Looks good");
|
||||||
|
expect(textarea).toHaveValue("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not submit a blank or whitespace-only comment", () => {
|
||||||
|
const addComment = vi.spyOn(vendorPortalApi, "addComment");
|
||||||
|
renderSection();
|
||||||
|
|
||||||
|
const button = screen.getByRole("button", { name: "Post Comment" });
|
||||||
|
expect(button).toBeDisabled();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("Add a comment for the dispatcher…"), {
|
||||||
|
target: { value: " " },
|
||||||
|
});
|
||||||
|
expect(button).toBeDisabled();
|
||||||
|
expect(addComment).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an error message when posting fails, and clears it once the user edits again", async () => {
|
||||||
|
vi.spyOn(vendorPortalApi, "addComment").mockRejectedValue(new Error("network down"));
|
||||||
|
renderSection();
|
||||||
|
|
||||||
|
const textarea = screen.getByPlaceholderText("Add a comment for the dispatcher…");
|
||||||
|
fireEvent.change(textarea, { target: { value: "Looks good" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Post Comment" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Unable to post your comment. Please try again."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.change(textarea, { target: { value: "Looks good, retrying" } });
|
||||||
|
expect(
|
||||||
|
screen.queryByText("Unable to post your comment. Please try again."),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows only the inline error on a failed post, not a duplicate global toast", async () => {
|
||||||
|
vi.spyOn(vendorPortalApi, "addComment").mockRejectedValue(new Error("network down"));
|
||||||
|
renderSectionWithAppQueryClient();
|
||||||
|
|
||||||
|
const textarea = screen.getByPlaceholderText("Add a comment for the dispatcher…");
|
||||||
|
fireEvent.change(textarea, { target: { value: "Looks good" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Post Comment" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Unable to post your comment. Please try again."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(toastMocks.error).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,9 +1,24 @@
|
||||||
import { render, screen } from "@testing-library/react";
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { SignoffSection } from "@/app/v/[token]/dispatch/_components/signoff-section";
|
import { SignoffSection } from "@/app/v/[token]/dispatch/_components/signoff-section";
|
||||||
|
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||||
|
import { createAppQueryClient } from "@/lib/query/query-client";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
function renderSection(overrides: Partial<React.ComponentProps<typeof SignoffSection>> = {}) {
|
const toastMocks = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
||||||
return render(
|
vi.mock("react-toastify", () => ({ toast: toastMocks }));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
toastMocks.success.mockReset();
|
||||||
|
toastMocks.error.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderSection(
|
||||||
|
overrides: Partial<React.ComponentProps<typeof SignoffSection>> = {},
|
||||||
|
options: { useAppQueryClient?: boolean } = {},
|
||||||
|
) {
|
||||||
|
return renderWithProviders(
|
||||||
<SignoffSection
|
<SignoffSection
|
||||||
token="portal-token"
|
token="portal-token"
|
||||||
dispatchId={9}
|
dispatchId={9}
|
||||||
|
|
@ -13,9 +28,21 @@ function renderSection(overrides: Partial<React.ComponentProps<typeof SignoffSec
|
||||||
onAdded={vi.fn()}
|
onAdded={vi.fn()}
|
||||||
{...overrides}
|
{...overrides}
|
||||||
/>,
|
/>,
|
||||||
|
{
|
||||||
|
withAuth: false,
|
||||||
|
...(options.useAppQueryClient ? { queryClient: createAppQueryClient() } : {}),
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function submitTypedSignoff(name = "Jane Doe") {
|
||||||
|
fireEvent.click(screen.getByRole("radio", { name: "Typed" }));
|
||||||
|
const [nameInput, signatureInput] = screen.getAllByRole("textbox");
|
||||||
|
fireEvent.change(nameInput, { target: { value: name } });
|
||||||
|
fireEvent.change(signatureInput, { target: { value: name } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Submit Signoff" }));
|
||||||
|
}
|
||||||
|
|
||||||
describe("SignoffSection", () => {
|
describe("SignoffSection", () => {
|
||||||
it("shows status-neutral locked copy when submission is locked", () => {
|
it("shows status-neutral locked copy when submission is locked", () => {
|
||||||
renderSection({ submissionLocked: true });
|
renderSection({ submissionLocked: true });
|
||||||
|
|
@ -43,4 +70,57 @@ describe("SignoffSection", () => {
|
||||||
expect(screen.queryByText(/Signoff is unavailable/i)).not.toBeInTheDocument();
|
expect(screen.queryByText(/Signoff is unavailable/i)).not.toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: "Submit Signoff" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Submit Signoff" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("requires a name and signature before submitting", () => {
|
||||||
|
const addSignoff = vi.spyOn(vendorPortalApi, "addSignoff");
|
||||||
|
renderSection();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Submit Signoff" }));
|
||||||
|
|
||||||
|
expect(screen.getByText("Name and signature are required.")).toBeInTheDocument();
|
||||||
|
expect(addSignoff).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits a typed signoff and calls onAdded", async () => {
|
||||||
|
vi.spyOn(vendorPortalApi, "addSignoff").mockResolvedValue({
|
||||||
|
signoffType: "customer",
|
||||||
|
name: "Jane Doe",
|
||||||
|
signatureMethod: "typed",
|
||||||
|
});
|
||||||
|
const onAdded = vi.fn();
|
||||||
|
renderSection({ onAdded });
|
||||||
|
|
||||||
|
submitTypedSignoff();
|
||||||
|
|
||||||
|
await waitFor(() => expect(onAdded).toHaveBeenCalled());
|
||||||
|
expect(vendorPortalApi.addSignoff).toHaveBeenCalledWith("portal-token", 9, {
|
||||||
|
name: "Jane Doe",
|
||||||
|
signature: "Jane Doe",
|
||||||
|
signatureMethod: "typed",
|
||||||
|
signoffType: "customer",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an error message when the submission fails", async () => {
|
||||||
|
vi.spyOn(vendorPortalApi, "addSignoff").mockRejectedValue(new Error("boom"));
|
||||||
|
renderSection();
|
||||||
|
|
||||||
|
submitTypedSignoff();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Unable to submit the signoff. Please try again."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows only the inline error on a failed submit, not a duplicate global toast", async () => {
|
||||||
|
vi.spyOn(vendorPortalApi, "addSignoff").mockRejectedValue(new Error("boom"));
|
||||||
|
renderSection({}, { useAppQueryClient: true });
|
||||||
|
|
||||||
|
submitTypedSignoff();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Unable to submit the signoff. Please try again."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(toastMocks.error).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
102
src/test/app/v/uplift-requests-section.test.tsx
Normal file
102
src/test/app/v/uplift-requests-section.test.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { UpliftRequestsSection } from "@/app/v/[token]/dispatch/_components/uplift-requests-section";
|
||||||
|
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||||
|
import type { VendorPortalUpliftRequest } from "@/domain/vendor-portal/types/vendor-portal";
|
||||||
|
import { createAppQueryClient } from "@/lib/query/query-client";
|
||||||
|
import { renderWithProviders } from "@/test/test-utils";
|
||||||
|
|
||||||
|
const toastMocks = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
|
||||||
|
vi.mock("react-toastify", () => ({ toast: toastMocks }));
|
||||||
|
|
||||||
|
const pendingRequest: VendorPortalUpliftRequest = {
|
||||||
|
id: 501,
|
||||||
|
status: "Pending",
|
||||||
|
requestedNTE: 750,
|
||||||
|
currentNTE: 500,
|
||||||
|
raisedByVendor: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
toastMocks.success.mockReset();
|
||||||
|
toastMocks.error.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderSection(
|
||||||
|
overrides: Partial<React.ComponentProps<typeof UpliftRequestsSection>> = {},
|
||||||
|
options: { useAppQueryClient?: boolean } = {},
|
||||||
|
) {
|
||||||
|
return renderWithProviders(
|
||||||
|
<UpliftRequestsSection
|
||||||
|
token="portal-token"
|
||||||
|
dispatchId={9}
|
||||||
|
currentNte={500}
|
||||||
|
upliftRequests={[pendingRequest]}
|
||||||
|
locked={false}
|
||||||
|
onChanged={vi.fn().mockResolvedValue(undefined)}
|
||||||
|
pollDocument={vi.fn()}
|
||||||
|
{...overrides}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
withAuth: false,
|
||||||
|
...(options.useAppQueryClient ? { queryClient: createAppQueryClient() } : {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("UpliftRequestsSection withdraw", () => {
|
||||||
|
it("withdraws a pending request and refreshes the dispatch", async () => {
|
||||||
|
vi.spyOn(vendorPortalApi, "withdrawUplift").mockResolvedValue(undefined);
|
||||||
|
const onChanged = vi.fn().mockResolvedValue(undefined);
|
||||||
|
renderSection({ onChanged });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Withdraw request" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(vendorPortalApi.withdrawUplift).toHaveBeenCalledWith(
|
||||||
|
"portal-token",
|
||||||
|
9,
|
||||||
|
pendingRequest.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(onChanged).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing when the confirm dialog is dismissed", () => {
|
||||||
|
const withdrawUplift = vi.spyOn(vendorPortalApi, "withdrawUplift");
|
||||||
|
vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||||
|
renderSection();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Withdraw request" }));
|
||||||
|
|
||||||
|
expect(withdrawUplift).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an inline error when withdrawing fails", async () => {
|
||||||
|
vi.spyOn(vendorPortalApi, "withdrawUplift").mockRejectedValue(new Error("boom"));
|
||||||
|
renderSection();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Withdraw request" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Unable to withdraw the uplift request. Please try again."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows only the inline error on a failed withdraw, not a duplicate global toast", async () => {
|
||||||
|
vi.spyOn(vendorPortalApi, "withdrawUplift").mockRejectedValue(new Error("boom"));
|
||||||
|
renderSection({}, { useAppQueryClient: true });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Withdraw request" }));
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Unable to withdraw the uplift request. Please try again."),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(toastMocks.error).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
52
src/test/hooks/use-list-page-filters.test.ts
Normal file
52
src/test/hooks/use-list-page-filters.test.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { useListPageFilters } from "@/hooks/use-list-page-filters";
|
||||||
|
|
||||||
|
describe("useListPageFilters", () => {
|
||||||
|
it("starts with empty search, page 0, and the default page size", () => {
|
||||||
|
const { result } = renderHook(() => useListPageFilters());
|
||||||
|
|
||||||
|
expect(result.current.search).toBe("");
|
||||||
|
expect(result.current.page).toBe(0);
|
||||||
|
expect(result.current.pageSize).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a custom default page size", () => {
|
||||||
|
const { result } = renderHook(() => useListPageFilters(24));
|
||||||
|
|
||||||
|
expect(result.current.pageSize).toBe(24);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets the page to 0 when search changes", () => {
|
||||||
|
const { result } = renderHook(() => useListPageFilters());
|
||||||
|
|
||||||
|
act(() => result.current.setPage(3));
|
||||||
|
expect(result.current.page).toBe(3);
|
||||||
|
|
||||||
|
act(() => result.current.setSearch("pump"));
|
||||||
|
expect(result.current.search).toBe("pump");
|
||||||
|
expect(result.current.page).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets the page to 0 when pageSize changes", () => {
|
||||||
|
const { result } = renderHook(() => useListPageFilters());
|
||||||
|
|
||||||
|
act(() => result.current.setPage(2));
|
||||||
|
expect(result.current.page).toBe(2);
|
||||||
|
|
||||||
|
act(() => result.current.setPageSize(48));
|
||||||
|
expect(result.current.pageSize).toBe(48);
|
||||||
|
expect(result.current.page).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setPage does not reset search or pageSize", () => {
|
||||||
|
const { result } = renderHook(() => useListPageFilters());
|
||||||
|
|
||||||
|
act(() => result.current.setSearch("hvac"));
|
||||||
|
act(() => result.current.setPage(1));
|
||||||
|
|
||||||
|
expect(result.current.page).toBe(1);
|
||||||
|
expect(result.current.search).toBe("hvac");
|
||||||
|
expect(result.current.pageSize).toBe(12);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue