This commit is contained in:
npalseahaven 2026-09-28 23:32:10 +00:00 • committed by GitHub
commit e0390a9ace
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 1226 additions and 498 deletions

View file

@ -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>
);
}

View file

@ -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>
);
}

View 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]}
/>
</>
);
}

View file

@ -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,
};
}

View file

@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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,
};
}

View file

@ -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>
</>
)}

View file

@ -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 }}>

View file

@ -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>

View file

@ -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>

View file

@ -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();

View file

@ -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),
});
}

View 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 };
}

View file

@ -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;

View file

@ -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));
});
});

View 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();
});
});

View 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]),
);
});
});

View 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();
});
});

View file

@ -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();
});
});

View 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);
});
});