mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 08:03:13 +00:00
Merge f2ba62fe79 into 6a6d519ed7
This commit is contained in:
commit
e0390a9ace
22 changed files with 1226 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 { useNavigate } from "react-router";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
|
||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import {
|
||||
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", ""];
|
||||
import { Alert, Box, CircularProgress, Stack, Typography } from "@mui/material";
|
||||
import { DeleteFollowUpDialog } from "./_components/delete-follow-up-dialog";
|
||||
import { FollowUpsFilterBar } from "./_components/followups-filter-bar";
|
||||
import { FollowUpsTable } from "./_components/followups-table";
|
||||
import { useFollowUpsListPage } from "./_components/use-followups-list-page";
|
||||
|
||||
export default function FollowUpsListPage() {
|
||||
const navigate = useNavigate();
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(12);
|
||||
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 rows = data?.items ?? [];
|
||||
const totalCount = data?.totalCount ?? 0;
|
||||
const {
|
||||
search,
|
||||
setSearch,
|
||||
status,
|
||||
setStatus,
|
||||
page,
|
||||
setPage,
|
||||
pageSize,
|
||||
setPageSize,
|
||||
deleteTarget,
|
||||
setDeleteTarget,
|
||||
rows,
|
||||
totalCount,
|
||||
isLoading,
|
||||
isFetching,
|
||||
error,
|
||||
isDeleting,
|
||||
confirmDelete,
|
||||
} = useFollowUpsListPage();
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col gap-4 p-4">
|
||||
|
|
@ -69,187 +35,44 @@ export default function FollowUpsListPage() {
|
|||
justifyContent: "space-between",
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction="row"
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: "baseline",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={2} sx={{ alignItems: "baseline" }}>
|
||||
<Typography variant="h5" component="h1">
|
||||
Follow-ups
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
sx={{
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{totalCount} total
|
||||
</Typography>
|
||||
{isFetching && !isLoading && <CircularProgress size={16} />}
|
||||
</Stack>
|
||||
|
||||
<Stack
|
||||
direction={{ xs: "column", sm: "row" }}
|
||||
spacing={2}
|
||||
sx={{
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<FollowUpsFilterBar
|
||||
search={search}
|
||||
status={status}
|
||||
onSearchChange={setSearch}
|
||||
onStatusChange={setStatus}
|
||||
/>
|
||||
</Stack>
|
||||
{Boolean(error) && (
|
||||
<Alert severity="error">
|
||||
{error instanceof Error ? error.message : "Failed to load follow-ups"}
|
||||
</Alert>
|
||||
)}
|
||||
<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={() => setDeleteTarget(row)}>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={totalCount}
|
||||
<FollowUpsTable
|
||||
rows={rows}
|
||||
totalCount={totalCount}
|
||||
isLoading={isLoading}
|
||||
page={page}
|
||||
onPageChange={(_e, nextPage) => setPage(nextPage)}
|
||||
rowsPerPage={pageSize}
|
||||
onRowsPerPageChange={(e) => {
|
||||
setPageSize(Number(e.target.value));
|
||||
setPage(0);
|
||||
}}
|
||||
rowsPerPageOptions={[12, 24, 48]}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
onDelete={setDeleteTarget}
|
||||
/>
|
||||
<DeleteFollowUpDialog
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
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 { 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 { Autocomplete, Box, Button, Paper, Stack, TextField } from "@mui/material";
|
||||
import { Text } from "@/components/ui/text";
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
import { AddVendorPreferenceControls } from "./_components/add-vendor-preference-controls";
|
||||
import { PreferenceRow } from "./_components/preference-row";
|
||||
import { useVendorPreferencesEditor } from "./_components/use-vendor-preferences-editor";
|
||||
|
||||
export default function VendorPreferencesPage() {
|
||||
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 = ["vendor-operations", "site-preferences", 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);
|
||||
};
|
||||
const {
|
||||
locationId,
|
||||
selectLocation,
|
||||
locations,
|
||||
draft,
|
||||
trade,
|
||||
setTrade,
|
||||
vendorId,
|
||||
setVendorId,
|
||||
vendors,
|
||||
vendorOptions,
|
||||
error,
|
||||
isSaving,
|
||||
save,
|
||||
move,
|
||||
add,
|
||||
remove,
|
||||
} = useVendorPreferencesEditor();
|
||||
|
||||
return (
|
||||
<Box className="flex flex-col gap-4 p-4">
|
||||
|
|
@ -244,8 +70,8 @@ export default function VendorPreferencesPage() {
|
|||
/>
|
||||
))}
|
||||
</Stack>
|
||||
<Button variant="contained" onClick={() => save.mutate()} disabled={save.isPending}>
|
||||
{save.isPending ? "Saving…" : "Save preferences"}
|
||||
<Button variant="contained" onClick={save} disabled={isSaving}>
|
||||
{isSaving ? "Saving…" : "Save preferences"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { useState } from "react";
|
||||
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 type { VendorPortalChecklistItem } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
import { useUpdateVendorPortalChecklistItem } from "@/domain/vendor-portal/use-cases/use-vendor-portal-dispatch-actions";
|
||||
|
||||
type ChecklistSectionProps = {
|
||||
token: string;
|
||||
|
|
@ -19,22 +18,16 @@ export function ChecklistSection({
|
|||
locked,
|
||||
onItemUpdated,
|
||||
}: ChecklistSectionProps) {
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const updateItem = useUpdateVendorPortalChecklistItem(token, dispatchId);
|
||||
|
||||
const toggle = async (item: VendorPortalChecklistItem) => {
|
||||
const toggle = (item: VendorPortalChecklistItem) => {
|
||||
if (locked) return;
|
||||
setErrorMessage("");
|
||||
try {
|
||||
const updated = await vendorPortalApi.updateChecklistItem(
|
||||
token,
|
||||
dispatchId,
|
||||
item.id,
|
||||
!item.isCompleted,
|
||||
);
|
||||
onItemUpdated(updated);
|
||||
} catch {
|
||||
setErrorMessage("Unable to update the checklist. Please try again.");
|
||||
}
|
||||
// mutateAsync resolves per call, unlike mutate's onSuccess which only fires for the
|
||||
// latest call on this shared observer — needed so two quick toggles both report back.
|
||||
void updateItem
|
||||
.mutateAsync({ itemId: item.id, isCompleted: !item.isCompleted })
|
||||
.then((updated) => onItemUpdated(updated))
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
if (!items.length) {
|
||||
|
|
@ -43,8 +36,8 @@ export function ChecklistSection({
|
|||
|
||||
return (
|
||||
<div>
|
||||
<Text variant="error" when={Boolean(errorMessage)}>
|
||||
{errorMessage}
|
||||
<Text variant="error" when={updateItem.isError}>
|
||||
Unable to update the checklist. Please try again.
|
||||
</Text>
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className={`vp-checklist-item ${item.isCompleted ? "done" : ""}`}>
|
||||
|
|
@ -52,7 +45,7 @@ export function ChecklistSection({
|
|||
type="checkbox"
|
||||
id={`cl-${item.id}`}
|
||||
checked={item.isCompleted}
|
||||
onChange={() => void toggle(item)}
|
||||
onChange={() => toggle(item)}
|
||||
disabled={locked}
|
||||
/>
|
||||
<label htmlFor={`cl-${item.id}`} style={{ flex: 1 }}>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useState } from "react";
|
||||
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 type { VendorPortalComment } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
import { useAddVendorPortalComment } from "@/domain/vendor-portal/use-cases/use-vendor-portal-dispatch-actions";
|
||||
|
||||
type CommentsSectionProps = {
|
||||
token: string;
|
||||
|
|
@ -13,23 +13,18 @@ type CommentsSectionProps = {
|
|||
|
||||
export function CommentsSection({ token, dispatchId, comments, onAdded }: CommentsSectionProps) {
|
||||
const [text, setText] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const addComment = useAddVendorPortalComment(token, dispatchId);
|
||||
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!text.trim()) return;
|
||||
setErrorMessage("");
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const added = await vendorPortalApi.addComment(token, dispatchId, text.trim());
|
||||
onAdded(added);
|
||||
setText("");
|
||||
} catch {
|
||||
setErrorMessage("Unable to post your comment. Please try again.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
addComment.mutate(trimmed, {
|
||||
onSuccess: (added) => {
|
||||
onAdded(added);
|
||||
setText("");
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -43,26 +38,26 @@ export function CommentsSection({ token, dispatchId, comments, onAdded }: Commen
|
|||
<div>{comment.commenttext}</div>
|
||||
</div>
|
||||
))}
|
||||
<Text variant="error" when={Boolean(errorMessage)}>
|
||||
{errorMessage}
|
||||
<Text variant="error" when={addComment.isError}>
|
||||
Unable to post your comment. Please try again.
|
||||
</Text>
|
||||
<form onSubmit={(event) => void submit(event)} style={{ marginTop: 12 }}>
|
||||
<form onSubmit={submit} style={{ marginTop: 12 }}>
|
||||
<textarea
|
||||
className="vp-textarea"
|
||||
placeholder="Add a comment for the dispatcher…"
|
||||
value={text}
|
||||
onChange={(event) => {
|
||||
setText(event.target.value);
|
||||
setErrorMessage("");
|
||||
if (addComment.isError) addComment.reset();
|
||||
}}
|
||||
/>
|
||||
<div className="vp-actions">
|
||||
<button
|
||||
type="submit"
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { useState } from "react";
|
||||
import { Text } from "@/components/ui/text";
|
||||
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 type { VendorPortalSignoff } from "@/domain/vendor-portal/types/vendor-portal";
|
||||
import { useAddVendorPortalSignoff } from "@/domain/vendor-portal/use-cases/use-vendor-portal-dispatch-actions";
|
||||
|
||||
type SignoffSectionProps = {
|
||||
token: string;
|
||||
|
|
@ -28,8 +28,8 @@ export function SignoffSection({
|
|||
const [method, setMethod] = useState<"drawn" | "typed">("drawn");
|
||||
const [typedSig, setTypedSig] = useState("");
|
||||
const [drawnSig, setDrawnSig] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [validationError, setValidationError] = useState("");
|
||||
const addSignoff = useAddVendorPortalSignoff(token, dispatchId);
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
const submit = async (event: React.FormEvent) => {
|
||||
const clearErrors = () => {
|
||||
setValidationError("");
|
||||
if (addSignoff.isError) addSignoff.reset();
|
||||
};
|
||||
|
||||
const submit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const signature = method === "drawn" ? drawnSig : typedSig;
|
||||
if (!name.trim() || !signature) {
|
||||
setErrorMessage("Name and signature are required.");
|
||||
setValidationError("Name and signature are required.");
|
||||
return;
|
||||
}
|
||||
setErrorMessage("");
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await vendorPortalApi.addSignoff(token, dispatchId, {
|
||||
name: name.trim(),
|
||||
signature,
|
||||
signatureMethod: method,
|
||||
signoffType,
|
||||
});
|
||||
onAdded();
|
||||
} catch {
|
||||
setErrorMessage("Unable to submit the signoff. Please try again.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
setValidationError("");
|
||||
addSignoff.mutate(
|
||||
{ name: name.trim(), signature, signatureMethod: method, signoffType },
|
||||
{ onSuccess: () => onAdded() },
|
||||
);
|
||||
};
|
||||
|
||||
const errorMessage =
|
||||
validationError ||
|
||||
(addSignoff.isError ? "Unable to submit the signoff. Please try again." : "");
|
||||
|
||||
return (
|
||||
<form onSubmit={(event) => void submit(event)}>
|
||||
<form onSubmit={submit}>
|
||||
<div className="vp-row">
|
||||
<strong>{nameLabel}</strong>
|
||||
<input
|
||||
|
|
@ -89,7 +88,7 @@ export function SignoffSection({
|
|||
value={name}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value);
|
||||
setErrorMessage("");
|
||||
clearErrors();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -111,7 +110,7 @@ export function SignoffSection({
|
|||
<SignaturePad
|
||||
onChange={(signature) => {
|
||||
setDrawnSig(signature);
|
||||
setErrorMessage("");
|
||||
clearErrors();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -123,7 +122,7 @@ export function SignoffSection({
|
|||
value={typedSig}
|
||||
onChange={(event) => {
|
||||
setTypedSig(event.target.value);
|
||||
setErrorMessage("");
|
||||
clearErrors();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -132,8 +131,8 @@ export function SignoffSection({
|
|||
{errorMessage}
|
||||
</Text>
|
||||
<div className="vp-actions">
|
||||
<button type="submit" className="vp-btn vp-btn-success" disabled={submitting}>
|
||||
{submitting ? "Signing…" : "Submit Signoff"}
|
||||
<button type="submit" className="vp-btn vp-btn-success" disabled={addSignoff.isPending}>
|
||||
{addSignoff.isPending ? "Signing…" : "Submit Signoff"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,16 @@
|
|||
import { useCallback, useState } from "react";
|
||||
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 type {
|
||||
VendorPortalDocumentStatus,
|
||||
VendorPortalMediaCounts,
|
||||
VendorPortalUpliftRequest,
|
||||
} 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 type { UpliftFormValues } from "@/domain/vendor-portal/lib/uplift-validation";
|
||||
import { vendorMediaUsage } from "@/domain/vendor-portal/lib/document-validation";
|
||||
|
|
@ -56,6 +60,9 @@ function useVendorUpliftActions({
|
|||
const [infoMessage, setInfoMessage] = useState("");
|
||||
const [requestError, setRequestError] = useState("");
|
||||
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(
|
||||
async (file: File): Promise<{ documentId: number; scan: EvidenceScanState }> => {
|
||||
|
|
@ -88,7 +95,7 @@ function useVendorUpliftActions({
|
|||
}
|
||||
evidenceDocumentId = result.documentId;
|
||||
}
|
||||
const outcome = await vendorPortalApi.requestUplift(token, dispatchId, {
|
||||
const outcome = await requestUplift.mutateAsync({
|
||||
requestedNTE: values.amount,
|
||||
reason: values.reason,
|
||||
requestKey: createUpliftRequestKey(),
|
||||
|
|
@ -112,7 +119,7 @@ function useVendorUpliftActions({
|
|||
setRequestError("");
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await vendorPortalApi.withdrawUplift(token, dispatchId, request.id);
|
||||
await withdrawUplift.mutateAsync(request.id);
|
||||
await onChanged();
|
||||
} catch {
|
||||
setRequestError("Unable to withdraw the uplift request. Please try again.");
|
||||
|
|
@ -137,10 +144,13 @@ function useVendorUpliftActions({
|
|||
}
|
||||
evidenceDocumentId = result.documentId;
|
||||
}
|
||||
await vendorPortalApi.reviseUplift(token, dispatchId, request.id, {
|
||||
requestedNTE: values.amount,
|
||||
reason: values.reason,
|
||||
evidenceDocumentId,
|
||||
await reviseUplift.mutateAsync({
|
||||
requestId: request.id,
|
||||
payload: {
|
||||
requestedNTE: values.amount,
|
||||
reason: values.reason,
|
||||
evidenceDocumentId,
|
||||
},
|
||||
});
|
||||
evidence.reset();
|
||||
await onChanged();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
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),
|
||||
});
|
||||
}
|
||||
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddVendorPortalComment(
|
||||
token: string,
|
||||
dispatchId: number,
|
||||
): UseMutationResult<VendorPortalComment, Error, string> {
|
||||
return useMutation({
|
||||
mutationFn: (commentText: string) => vendorPortalApi.addComment(token, dispatchId, commentText),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRequestVendorUplift(
|
||||
token: string,
|
||||
dispatchId: number,
|
||||
): UseMutationResult<VendorUpliftRequestOutcome, Error, VendorUpliftRequestBody> {
|
||||
return useMutation({
|
||||
mutationFn: (payload: VendorUpliftRequestBody) =>
|
||||
vendorPortalApi.requestUplift(token, dispatchId, payload),
|
||||
});
|
||||
}
|
||||
|
||||
export function useWithdrawVendorUplift(
|
||||
token: string,
|
||||
dispatchId: number,
|
||||
): UseMutationResult<void, Error, string | number> {
|
||||
return useMutation({
|
||||
mutationFn: (requestId: string | number) =>
|
||||
vendorPortalApi.withdrawUplift(token, dispatchId, requestId),
|
||||
});
|
||||
}
|
||||
|
||||
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),
|
||||
});
|
||||
}
|
||||
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) =>
|
||||
[...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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
95
src/test/app/v/checklist-section.test.tsx
Normal file
95
src/test/app/v/checklist-section.test.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
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]),
|
||||
);
|
||||
});
|
||||
});
|
||||
73
src/test/app/v/comments-section.test.tsx
Normal file
73
src/test/app/v/comments-section.test.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
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 { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function renderSection(comments: VendorPortalComment[] = [], onAdded = vi.fn()) {
|
||||
return {
|
||||
onAdded,
|
||||
...renderWithProviders(
|
||||
<CommentsSection token="portal-token" dispatchId={9} comments={comments} onAdded={onAdded} />,
|
||||
{ withAuth: false },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,9 +1,15 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SignoffSection } from "@/app/v/[token]/dispatch/_components/signoff-section";
|
||||
import { vendorPortalApi } from "@/domain/vendor-portal/api/vendor-portal-api";
|
||||
import { renderWithProviders } from "@/test/test-utils";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function renderSection(overrides: Partial<React.ComponentProps<typeof SignoffSection>> = {}) {
|
||||
return render(
|
||||
return renderWithProviders(
|
||||
<SignoffSection
|
||||
token="portal-token"
|
||||
dispatchId={9}
|
||||
|
|
@ -13,6 +19,7 @@ function renderSection(overrides: Partial<React.ComponentProps<typeof SignoffSec
|
|||
onAdded={vi.fn()}
|
||||
{...overrides}
|
||||
/>,
|
||||
{ withAuth: false },
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -43,4 +50,53 @@ describe("SignoffSection", () => {
|
|||
expect(screen.queryByText(/Signoff is unavailable/i)).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 });
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Typed" }));
|
||||
const [nameInput, signatureInput] = screen.getAllByRole("textbox");
|
||||
fireEvent.change(nameInput, { target: { value: "Jane Doe" } });
|
||||
fireEvent.change(signatureInput, { target: { value: "Jane Doe" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit Signoff" }));
|
||||
|
||||
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();
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Typed" }));
|
||||
const [nameInput, signatureInput] = screen.getAllByRole("textbox");
|
||||
fireEvent.change(nameInput, { target: { value: "Jane Doe" } });
|
||||
fireEvent.change(signatureInput, { target: { value: "Jane Doe" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit Signoff" }));
|
||||
|
||||
expect(
|
||||
await screen.findByText("Unable to submit the signoff. Please try again."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
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