From 736157467cff9b2285b2c7b85f28cb459e924eb1 Mon Sep 17 00:00:00 2001 From: npal Date: Fri, 25 Sep 2026 10:53:23 -0500 Subject: [PATCH 1/2] refactor: move vendor-portal dispatch actions and 2 pages onto proper data hooks Two groups of components were calling the API directly instead of using this codebase's standard React Query hooks, with no shared caching and duplicated submit/error-handling boilerplate. - checklist-section.tsx, comments-section.tsx, signoff-section.tsx, uplift-requests-section.tsx: replace direct vendorPortalApi calls and hand-rolled submitting/error state with mutation hooks in the new use-vendor-portal-dispatch-actions.ts. uplift-requests-section keeps its existing multi-step evidence-upload orchestration intact and only swaps the 3 raw API calls for mutateAsync, since restructuring that flow around declarative mutation callbacks carried more risk than value. Caught and fixed a real bug along the way: two components were passing a prop callback directly as a mutation's onSuccess, which would have forwarded React Query's extra callback arguments into it. - followups/index.tsx, vendors/preferences.tsx: split from a single file doing state, API calls, and rendering into a hook (owns data and handlers) plus small presentational components, matching the task-templates refactor pattern. Added a missing centralized query key (vendorOperations.sitePreferences) that preferences.tsx was building as a raw inline array. - use-list-page-filters.ts: small shared hook for search/page/pageSize state, used by the new followups hook. 29 new/updated tests across all touched files; none of these had coverage for their mutation/error paths before. --- .../_components/delete-follow-up-dialog.tsx | 39 +++ .../_components/followups-filter-bar.tsx | 51 ++++ .../followups/_components/followups-table.tsx | 133 +++++++++ .../_components/use-followups-list-page.ts | 54 ++++ src/app/(protected)/followups/index.tsx | 269 +++--------------- .../add-vendor-preference-controls.tsx | 47 +++ .../vendors/_components/preference-row.tsx | 46 +++ .../use-vendor-preferences-editor.ts | 120 ++++++++ src/app/(protected)/vendors/preferences.tsx | 222 ++------------- .../_components/checklist-section.tsx | 29 +- .../dispatch/_components/comments-section.tsx | 39 ++- .../dispatch/_components/signoff-section.tsx | 51 ++-- .../_components/uplift-requests-section.tsx | 24 +- .../use-vendor-portal-dispatch-actions.ts | 91 ++++++ src/hooks/use-list-page-filters.ts | 32 +++ src/infra/query-key/query-key.ts | 5 + .../followups/followups-list-page.test.tsx | 98 +++++++ .../vendors/vendor-preferences-page.test.tsx | 90 ++++++ src/test/app/v/checklist-section.test.tsx | 70 +++++ src/test/app/v/comments-section.test.tsx | 73 +++++ src/test/app/v/signoff-section.test.tsx | 62 +++- src/test/hooks/use-list-page-filters.test.ts | 52 ++++ 22 files changed, 1199 insertions(+), 498 deletions(-) create mode 100644 src/app/(protected)/followups/_components/delete-follow-up-dialog.tsx create mode 100644 src/app/(protected)/followups/_components/followups-filter-bar.tsx create mode 100644 src/app/(protected)/followups/_components/followups-table.tsx create mode 100644 src/app/(protected)/followups/_components/use-followups-list-page.ts create mode 100644 src/app/(protected)/vendors/_components/add-vendor-preference-controls.tsx create mode 100644 src/app/(protected)/vendors/_components/preference-row.tsx create mode 100644 src/app/(protected)/vendors/_components/use-vendor-preferences-editor.ts create mode 100644 src/domain/vendor-portal/use-cases/use-vendor-portal-dispatch-actions.ts create mode 100644 src/hooks/use-list-page-filters.ts create mode 100644 src/test/app/(protected)/followups/followups-list-page.test.tsx create mode 100644 src/test/app/(protected)/vendors/vendor-preferences-page.test.tsx create mode 100644 src/test/app/v/checklist-section.test.tsx create mode 100644 src/test/app/v/comments-section.test.tsx create mode 100644 src/test/hooks/use-list-page-filters.test.ts 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? + + + + + + + ); +} 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 + + + + + ); +} 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) => ( + + + + + + {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 - - - - + {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) => ( - - - - - - {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? - - - - - -
); } 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" + /> + + + + ); +} 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" - /> - - - - ); -} - -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() { /> ))} - )} diff --git a/src/app/v/[token]/dispatch/_components/checklist-section.tsx b/src/app/v/[token]/dispatch/_components/checklist-section.tsx index 01ac7520..850c2b6d 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,14 @@ 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."); - } + updateItem.mutate( + { itemId: item.id, isCompleted: !item.isCompleted }, + { onSuccess: (updated) => onItemUpdated(updated) }, + ); }; if (!items.length) { @@ -43,8 +34,8 @@ export function ChecklistSection({ return (
- - {errorMessage} + + Unable to update the checklist. Please try again. {items.map((item) => (
@@ -52,7 +43,7 @@ export function ChecklistSection({ type="checkbox" id={`cl-${item.id}`} checked={item.isCompleted} - onChange={() => void toggle(item)} + onChange={() => toggle(item)} disabled={locked} />
))} - - {errorMessage} + + Unable to post your comment. Please try again. -
void submit(event)} style={{ marginTop: 12 }}> +