diff --git a/src/app/(protected)/followups/_components/delete-follow-up-dialog.tsx b/src/app/(protected)/followups/_components/delete-follow-up-dialog.tsx new file mode 100644 index 00000000..9b5935f9 --- /dev/null +++ b/src/app/(protected)/followups/_components/delete-follow-up-dialog.tsx @@ -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 ( + + Delete Follow-up + + Are you sure you want to delete this follow-up? + + + + Cancel + + + Delete + + + + ); +} diff --git a/src/app/(protected)/followups/_components/followups-filter-bar.tsx b/src/app/(protected)/followups/_components/followups-filter-bar.tsx new file mode 100644 index 00000000..21cf2e8f --- /dev/null +++ b/src/app/(protected)/followups/_components/followups-filter-bar.tsx @@ -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 ( + + onSearchChange(e.target.value)} + placeholder="Search follow-ups..." + className="min-w-sidebar" + /> + + Status + onStatusChange(e.target.value)}> + {STATUS_OPTIONS.map((s) => ( + + {s || "All"} + + ))} + + + navigate("/followups/new")}> + + New Follow-up + + + ); +} diff --git a/src/app/(protected)/followups/_components/followups-table.tsx b/src/app/(protected)/followups/_components/followups-table.tsx new file mode 100644 index 00000000..1ee88cfc --- /dev/null +++ b/src/app/(protected)/followups/_components/followups-table.tsx @@ -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 ( + <> + + + + + Reason + Schedule + Assigned To + Account + Location + WO # + Status + + Actions + + + + + {isLoading ? ( + + + + + + ) : rows.length === 0 ? ( + + + No follow-ups found + + + ) : ( + rows.map((row) => ( + + + navigate(`/followups/edit/${row.id}`)} + > + {row.reason || "—"} + + + + {toDateInputValue(row.scheduleDate) || "—"} + {row.scheduleStartTime ? ` ${row.scheduleStartTime.slice(0, 5)}` : ""} + + {row.assignedTo || "—"} + {row.accountName || "—"} + {row.locationName || "—"} + {row.internalWONumber || row.workOrderNumber || "—"} + + + + + + navigate(`/followups/edit/${row.id}`)} + > + + + + + onDelete(row)}> + + + + + + )) + )} + + + + onPageChange(nextPage)} + rowsPerPage={pageSize} + onRowsPerPageChange={(e) => onPageSizeChange(Number(e.target.value))} + rowsPerPageOptions={[12, 24, 48]} + /> + > + ); +} diff --git a/src/app/(protected)/followups/_components/use-followups-list-page.ts b/src/app/(protected)/followups/_components/use-followups-list-page.ts new file mode 100644 index 00000000..9fd2984d --- /dev/null +++ b/src/app/(protected)/followups/_components/use-followups-list-page.ts @@ -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(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, + }; +} diff --git a/src/app/(protected)/followups/index.tsx b/src/app/(protected)/followups/index.tsx index 30980fc0..fe8905b4 100644 --- a/src/app/(protected)/followups/index.tsx +++ b/src/app/(protected)/followups/index.tsx @@ -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(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 ( @@ -69,187 +35,44 @@ export default function FollowUpsListPage() { justifyContent: "space-between", }} > - + Follow-ups - + {totalCount} total {isFetching && !isLoading && } - - { - setSearch(e.target.value); - setPage(0); - }} - placeholder="Search follow-ups..." - className="min-w-sidebar" - /> - - Status - { - setStatus(e.target.value); - setPage(0); - }} - > - {STATUS_OPTIONS.map((s) => ( - - {s || "All"} - - ))} - - - navigate("/followups/new")}> - + New Follow-up - - + {Boolean(error) && ( {error instanceof Error ? error.message : "Failed to load follow-ups"} )} - - - - - Reason - Schedule - Assigned To - Account - Location - WO # - Status - - Actions - - - - - {isLoading ? ( - - - - - - ) : rows.length === 0 ? ( - - - - No follow-ups found - - - - ) : ( - rows.map((row) => ( - - - navigate(`/followups/edit/${row.id}`)} - > - {row.reason || "—"} - - - - {toDateInputValue(row.scheduleDate) || "—"} - {row.scheduleStartTime ? ` ${row.scheduleStartTime.slice(0, 5)}` : ""} - - {row.assignedTo || "—"} - {row.accountName || "—"} - {row.locationName || "—"} - {row.internalWONumber || row.workOrderNumber || "—"} - - - - - - navigate(`/followups/edit/${row.id}`)} - > - - - - - setDeleteTarget(row)}> - - - - - - )) - )} - - - - 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} + /> + setDeleteTarget(null)} + onConfirm={confirmDelete} /> - setDeleteTarget(null)}> - Delete Follow-up - - Are you sure you want to delete this follow-up? - - - setDeleteTarget(null)} disabled={deleteFollowUp.isPending}> - Cancel - - { - if (!deleteTarget) return; - deleteFollowUp.mutate(deleteTarget.id, { - onSuccess: () => setDeleteTarget(null), - }); - }} - > - Delete - - - ); } diff --git a/src/app/(protected)/vendors/_components/add-vendor-preference-controls.tsx b/src/app/(protected)/vendors/_components/add-vendor-preference-controls.tsx new file mode 100644 index 00000000..1e778761 --- /dev/null +++ b/src/app/(protected)/vendors/_components/add-vendor-preference-controls.tsx @@ -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 ( + + + onTradeChange(event.target.value)} + /> + + `${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) => } + className="flex-1" + /> + + Add + + + + ); +} diff --git a/src/app/(protected)/vendors/_components/preference-row.tsx b/src/app/(protected)/vendors/_components/preference-row.tsx new file mode 100644 index 00000000..2feafc25 --- /dev/null +++ b/src/app/(protected)/vendors/_components/preference-row.tsx @@ -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 ( + + + + {index + 1}. {item.vendorName} {item.trade ? `· ${item.trade}` : "· All trades"} + + {item.isStale && ( + + Stale preference + + )} + onMove(index, -1)} + disabled={index === 0} + > + + + onMove(index, 1)} + disabled={index === total - 1} + > + + + onRemove(index)}> + + + + + ); +} diff --git a/src/app/(protected)/vendors/_components/use-vendor-preferences-editor.ts b/src/app/(protected)/vendors/_components/use-vendor-preferences-editor.ts new file mode 100644 index 00000000..125e79d9 --- /dev/null +++ b/src/app/(protected)/vendors/_components/use-vendor-preferences-editor.ts @@ -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(""); + const [draft, setDraft] = useState([]); + const [isDirty, setIsDirty] = useState(false); + const [trade, setTrade] = useState(""); + const [vendorId, setVendorId] = useState(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, + }; +} diff --git a/src/app/(protected)/vendors/preferences.tsx b/src/app/(protected)/vendors/preferences.tsx index fdb93618..c8ad2f8c 100644 --- a/src/app/(protected)/vendors/preferences.tsx +++ b/src/app/(protected)/vendors/preferences.tsx @@ -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 ( - - - onTradeChange(event.target.value)} - /> - - `${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) => } - className="flex-1" - /> - - Add - - - - ); -} - -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 ( - - - - {index + 1}. {item.vendorName} {item.trade ? `· ${item.trade}` : "· All trades"} - - {item.isStale && ( - - Stale preference - - )} - onMove(index, -1)} - disabled={index === 0} - > - - - onMove(index, 1)} - disabled={index === total - 1} - > - - - onRemove(index)}> - - - - - ); -} +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(""); - const [draft, setDraft] = useState([]); - const [isDirty, setIsDirty] = useState(false); - const [trade, setTrade] = useState(""); - const [vendorId, setVendorId] = useState(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 ( @@ -244,8 +70,8 @@ export default function VendorPreferencesPage() { /> ))} - save.mutate()} disabled={save.isPending}> - {save.isPending ? "Saving…" : "Save preferences"} + + {isSaving ? "Saving…" : "Save preferences"} > )} diff --git a/src/app/v/[token]/dispatch/_components/checklist-section.tsx b/src/app/v/[token]/dispatch/_components/checklist-section.tsx index 01ac7520..4c1acb2e 100644 --- a/src/app/v/[token]/dispatch/_components/checklist-section.tsx +++ b/src/app/v/[token]/dispatch/_components/checklist-section.tsx @@ -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 ( - - {errorMessage} + + Unable to update the checklist. Please try again. {items.map((item) => ( @@ -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} /> diff --git a/src/app/v/[token]/dispatch/_components/comments-section.tsx b/src/app/v/[token]/dispatch/_components/comments-section.tsx index 1b2abf4c..e764cba5 100644 --- a/src/app/v/[token]/dispatch/_components/comments-section.tsx +++ b/src/app/v/[token]/dispatch/_components/comments-section.tsx @@ -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 {comment.commenttext} ))} - - {errorMessage} + + Unable to post your comment. Please try again. - void submit(event)} style={{ marginTop: 12 }}> + { setText(event.target.value); - setErrorMessage(""); + if (addComment.isError) addComment.reset(); }} /> - {submitting ? "Posting…" : "Post Comment"} + {addComment.isPending ? "Posting…" : "Post Comment"} diff --git a/src/app/v/[token]/dispatch/_components/signoff-section.tsx b/src/app/v/[token]/dispatch/_components/signoff-section.tsx index 298e1737..ca2f2092 100644 --- a/src/app/v/[token]/dispatch/_components/signoff-section.tsx +++ b/src/app/v/[token]/dispatch/_components/signoff-section.tsx @@ -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 Signoff is unavailable for this dispatch.; } - 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 ( - void submit(event)}> + {nameLabel} { setName(event.target.value); - setErrorMessage(""); + clearErrors(); }} /> @@ -111,7 +110,7 @@ export function SignoffSection({ { setDrawnSig(signature); - setErrorMessage(""); + clearErrors(); }} /> @@ -123,7 +122,7 @@ export function SignoffSection({ value={typedSig} onChange={(event) => { setTypedSig(event.target.value); - setErrorMessage(""); + clearErrors(); }} /> @@ -132,8 +131,8 @@ export function SignoffSection({ {errorMessage} - - {submitting ? "Signing…" : "Submit Signoff"} + + {addSignoff.isPending ? "Signing…" : "Submit Signoff"} diff --git a/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx b/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx index 625bdda9..cae9f5b9 100644 --- a/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx +++ b/src/app/v/[token]/dispatch/_components/uplift-requests-section.tsx @@ -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(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(); diff --git a/src/domain/vendor-portal/use-cases/use-vendor-portal-dispatch-actions.ts b/src/domain/vendor-portal/use-cases/use-vendor-portal-dispatch-actions.ts new file mode 100644 index 00000000..1d21a8a9 --- /dev/null +++ b/src/domain/vendor-portal/use-cases/use-vendor-portal-dispatch-actions.ts @@ -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 { + return useMutation({ + mutationFn: (commentText: string) => vendorPortalApi.addComment(token, dispatchId, commentText), + }); +} + +export function useRequestVendorUplift( + token: string, + dispatchId: number, +): UseMutationResult { + return useMutation({ + mutationFn: (payload: VendorUpliftRequestBody) => + vendorPortalApi.requestUplift(token, dispatchId, payload), + }); +} + +export function useWithdrawVendorUplift( + token: string, + dispatchId: number, +): UseMutationResult { + 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 { + return useMutation({ + mutationFn: ({ requestId, payload }: ReviseUpliftInput) => + vendorPortalApi.reviseUplift(token, dispatchId, requestId, payload), + }); +} diff --git a/src/hooks/use-list-page-filters.ts b/src/hooks/use-list-page-filters.ts new file mode 100644 index 00000000..0d5ef515 --- /dev/null +++ b/src/hooks/use-list-page-filters.ts @@ -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 }; +} diff --git a/src/infra/query-key/query-key.ts b/src/infra/query-key/query-key.ts index 35be49f4..7e62f6c1 100644 --- a/src/infra/query-key/query-key.ts +++ b/src/infra/query-key/query-key.ts @@ -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; diff --git a/src/test/app/(protected)/followups/followups-list-page.test.tsx b/src/test/app/(protected)/followups/followups-list-page.test.tsx new file mode 100644 index 00000000..1b04bb7b --- /dev/null +++ b/src/test/app/(protected)/followups/followups-list-page.test.tsx @@ -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(, { 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)); + }); +}); diff --git a/src/test/app/(protected)/vendors/vendor-preferences-page.test.tsx b/src/test/app/(protected)/vendors/vendor-preferences-page.test.tsx new file mode 100644 index 00000000..99634ece --- /dev/null +++ b/src/test/app/(protected)/vendors/vendor-preferences-page.test.tsx @@ -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(, { 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(); + }); +}); diff --git a/src/test/app/v/checklist-section.test.tsx b/src/test/app/v/checklist-section.test.tsx new file mode 100644 index 00000000..e0e1013e --- /dev/null +++ b/src/test/app/v/checklist-section.test.tsx @@ -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> = {}, + onItemUpdated = vi.fn(), +) { + return { + onItemUpdated, + ...renderWithProviders( + , + { 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]), + ); + }); +}); diff --git a/src/test/app/v/comments-section.test.tsx b/src/test/app/v/comments-section.test.tsx new file mode 100644 index 00000000..5ef0c60f --- /dev/null +++ b/src/test/app/v/comments-section.test.tsx @@ -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( + , + { 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(); + }); +}); diff --git a/src/test/app/v/signoff-section.test.tsx b/src/test/app/v/signoff-section.test.tsx index b83b0b7e..3c2b8b25 100644 --- a/src/test/app/v/signoff-section.test.tsx +++ b/src/test/app/v/signoff-section.test.tsx @@ -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> = {}) { - return render( + return renderWithProviders( , + { 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(); + }); }); diff --git a/src/test/hooks/use-list-page-filters.test.ts b/src/test/hooks/use-list-page-filters.test.ts new file mode 100644 index 00000000..b14d3266 --- /dev/null +++ b/src/test/hooks/use-list-page-filters.test.ts @@ -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); + }); +});