import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'; import { kalmanSmoothGps } from '@/lib/dispatchShared'; import { parseDoormileTimestamp } from '@/lib/doormileTimestamp'; import * as api from '@/api/doormile'; import { OpenToast, messageOf } from '@/api/doormile/notify'; /** * React Query hooks over the Doormile Express admin API. * * Every page reads through here rather than calling `@/api/doormile` directly, * so a resource has exactly one cache key across the app and a write on one * page invalidates the list another page is showing. The keys are declared once * in `KEYS` for the same reason — a hand-written key that drifts by a character * is a cache that silently never invalidates. */ export const KEYS = { dashboard: ['doormile', 'dashboard'], profile: ['doormile', 'profile'], reports: (from, to, tenantid, locationid, hubid) => ['doormile', 'reports', from, to, tenantid, locationid, hubid], locationsSummary: (tenantid, locationid, from, to) => ['doormile', 'locations-summary', tenantid, locationid, from, to], appUsers: ['doormile', 'app-users'], partners: ['doormile', 'partners'], partner: (id) => ['doormile', 'partner', id], tenants: ['doormile', 'tenants'], tenant: (id) => ['doormile', 'tenant', id], tenantLocations: (tenantId) => ['doormile', 'tenant-locations', tenantId], tenantCustomers: ['doormile', 'tenant-customers'], tenantCustomer: (id) => ['doormile', 'tenant-customer', id], customers: ['doormile', 'customers'], hubs: ['doormile', 'hubs'], hub: (id) => ['doormile', 'hub', id], vehicles: ['doormile', 'vehicles'], vehicle: (id) => ['doormile', 'vehicle', id], milers: ['doormile', 'milers'], miler: (id) => ['doormile', 'miler', id], milerSummary: (hubid, from, to) => ['doormile', 'miler-summary', hubid, from, to], milerLogs: (id, from, to) => ['doormile', 'miler-logs', id, from, to], milerActivity: (id, from, to) => ['doormile', 'miler-activity', id, from, to], riderSummaryCounts: ['doormile', 'rider-summary-counts'], bookings: (pageno, pagesize) => ['doormile', 'bookings', pageno, pagesize], booking: (id) => ['doormile', 'booking', id], bookingTrack: (id) => ['doormile', 'booking-track', id], consignments: ['doormile', 'consignments'], consignment: (id) => ['doormile', 'consignment', id], consignmentLogs: (id) => ['doormile', 'consignment-logs', id], deliveries: (status, from, to, pageSize) => ['doormile', 'deliveries', status, from, to, pageSize], deliveryCounts: ['doormile', 'delivery-counts'], tripsheets: ['doormile', 'tripsheets'], tripsheet: (id) => ['doormile', 'tripsheet', id], pricing: ['doormile', 'pricing'], doormilePricing: ['doormile', 'doormile-pricing'], exceptions: ['doormile', 'exceptions'], exception: (id) => ['doormile', 'exception', id], competitorBranches: (page, limit) => ['doormile', 'competitor-branches', page, limit], carrierPricing: (page, limit) => ['doormile', 'carrier-pricing', page, limit], appLocations: ['doormile', 'app-locations'], // AI agent registry. One prefix, so a write invalidates agents, skills, // tools and the audit together — a skill toggle changes an agent's counts. aiRegistry: ['doormile', 'ai'], aiAgents: ['doormile', 'ai', 'agents'], aiSkills: ['doormile', 'ai', 'skills'], aiTools: ['doormile', 'ai', 'tools'], aiAudit: ['doormile', 'ai', 'audit'], }; /* ── Shared helpers ───────────────────────────────────────────────────────── */ /** * A mutation that toasts on both outcomes and invalidates the keys it touched. * * The Doormile API answers `{ success: false, message }` with HTTP 200 on some * write paths, so "resolved" is not the same as "worked" — a mutation that only * watched for a thrown error would report a refused write as a success. */ function useDoormileMutation({ mutationFn, invalidates = [], successMessage, onDone }) { const queryClient = useQueryClient(); return useMutation({ mutationFn, onSuccess: (result) => { if (result && result.success === false) { OpenToast(result.message || 'The change was refused', 'warning', 4000); return; } invalidates.forEach((key) => queryClient.invalidateQueries({ queryKey: key })); if (successMessage) OpenToast(successMessage, 'success'); onDone?.(result); }, onError: (err) => OpenToast(messageOf(err), 'error', 4000), }); } /** Every key a booking write can invalidate — orders, deliveries and counts. */ const BOOKING_KEYS = [ ['doormile', 'bookings'], ['doormile', 'deliveries'], ['doormile', 'delivery-counts'], ]; /* ── Dashboard, reports, profile ──────────────────────────────────────────── */ export const useDashboard = (options) => useQuery({ queryKey: KEYS.dashboard, queryFn: api.getDashboard, staleTime: 30_000, ...options }); export const useReports = (from, to, tenantid, locationid, hubid, options) => useQuery({ queryKey: KEYS.reports(from, to, tenantid, locationid, hubid), queryFn: () => api.getReports(from, to, tenantid, locationid, hubid), staleTime: 30_000, ...options, }); export const useLocationsSummary = (tenantid, locationid, from, to, options) => useQuery({ queryKey: KEYS.locationsSummary(tenantid, locationid, from, to), queryFn: () => api.getLocationsSummary(tenantid, locationid, from, to), staleTime: 30_000, ...options, }); export const useProfile = (options) => useQuery({ queryKey: KEYS.profile, queryFn: api.getusers, staleTime: 60_000, ...options }); export const useUpdatePassword = () => useDoormileMutation({ mutationFn: ({ current_password, new_password }) => api.updateProfilePassword(current_password, new_password), successMessage: 'Password updated', }); /* ── App users ────────────────────────────────────────────────────────────── */ export const useAppUsers = (options) => useQuery({ queryKey: KEYS.appUsers, queryFn: api.getAppUsers, staleTime: 30_000, ...options }); export const useCreateAppUser = () => useDoormileMutation({ mutationFn: api.createAppUser, invalidates: [KEYS.appUsers], successMessage: 'User created' }); export const useUpdateAppUser = () => useDoormileMutation({ mutationFn: ({ id, data }) => api.updateAppUser(id, data), invalidates: [KEYS.appUsers], successMessage: 'User updated', }); export const useDeleteAppUser = () => useDoormileMutation({ mutationFn: api.deleteAppUser, invalidates: [KEYS.appUsers], successMessage: 'User removed' }); /* ── Partners (fleet suppliers) ───────────────────────────────────────────── */ export const usePartners = (options) => useQuery({ queryKey: KEYS.partners, queryFn: api.getPartners, staleTime: 60_000, ...options }); export const useCreatePartner = () => useDoormileMutation({ mutationFn: api.createPartner, invalidates: [KEYS.partners], successMessage: 'Partner created' }); export const useUpdatePartner = () => useDoormileMutation({ mutationFn: ({ id, data }) => api.updatePartner(id, data), invalidates: [KEYS.partners], successMessage: 'Partner updated', }); export const useDeletePartner = () => useDoormileMutation({ mutationFn: api.deletePartner, invalidates: [KEYS.partners], successMessage: 'Partner removed' }); /* ── Tenants (client companies) ───────────────────────────────────────────── */ export const useTenants = (options) => useQuery({ queryKey: KEYS.tenants, queryFn: api.getalltenants, staleTime: 60_000, ...options }); export const useTenant = (id, options) => useQuery({ queryKey: KEYS.tenant(id), queryFn: () => api.getAdminTenant(id), enabled: !!id, ...options }); export const useCreateTenant = () => useDoormileMutation({ mutationFn: api.createAdminTenant, invalidates: [KEYS.tenants], successMessage: 'Client created' }); export const useOnboardedClients = (options) => useQuery({ queryKey: [...KEYS.tenants, 'onboarded'], queryFn: api.getOnboardedClients, staleTime: 30_000, ...options }); export const useOnboardingCities = (options) => useQuery({ queryKey: [...KEYS.tenants, 'onboarding-cities'], queryFn: api.getOnboardingCities, staleTime: 300_000, ...options }); export const useUpdateOnboardedClient = () => useDoormileMutation({ mutationFn: ({ authid, data }) => api.updateOnboardedClient(authid, data), invalidates: [KEYS.tenants], successMessage: 'Client updated', }); export const useDeleteOnboardedClient = () => useDoormileMutation({ mutationFn: (authid) => api.deleteOnboardedClient(authid), invalidates: [KEYS.tenants], successMessage: 'Client login removed', }); // No toasts: the page shows its own success panel and inline errors. export const useOnboardClient = () => { const queryClient = useQueryClient(); return useMutation({ mutationFn: api.onboardClient, onSuccess: (result) => { if (result?.success !== false) queryClient.invalidateQueries({ queryKey: KEYS.tenants }); }, }); }; export const useUpdateTenant = () => useDoormileMutation({ mutationFn: ({ id, data }) => api.updateAdminTenant(id, data), invalidates: [KEYS.tenants], successMessage: 'Client updated', }); export const useDeleteTenant = () => useDoormileMutation({ mutationFn: api.deleteAdminTenant, invalidates: [KEYS.tenants], successMessage: 'Client removed' }); export const useTenantLocations = (tenantId, options) => useQuery({ queryKey: KEYS.tenantLocations(tenantId), queryFn: () => api.gettenantlocations(tenantId), enabled: !!tenantId, staleTime: 60_000, ...options, }); export const useCreateTenantLocation = () => useDoormileMutation({ mutationFn: ({ tenantId, data }) => api.createTenantLocation(tenantId, data), invalidates: [KEYS.tenants], successMessage: 'Branch created', }); export const useUpdateTenantLocation = () => useDoormileMutation({ mutationFn: ({ locationId, data }) => api.updateTenantLocation(locationId, data), invalidates: [KEYS.tenants], successMessage: 'Branch updated', }); /* ── Customers ────────────────────────────────────────────────────────────── */ export const useCustomers = (options) => useQuery({ queryKey: KEYS.customers, queryFn: api.getAdminCustomers, staleTime: 60_000, ...options }); export const useCreateCustomer = () => useDoormileMutation({ mutationFn: api.createAdminCustomer, invalidates: [KEYS.customers], successMessage: 'Customer created' }); export const useUpdateCustomer = () => useDoormileMutation({ mutationFn: ({ id, data }) => api.updateAdminCustomer(id, data), invalidates: [KEYS.customers], successMessage: 'Customer updated', }); export const useTenantCustomers = (options) => useQuery({ queryKey: KEYS.tenantCustomers, queryFn: api.getTenantCustomers, staleTime: 60_000, ...options }); export const useCreateTenantCustomer = () => useDoormileMutation({ mutationFn: api.createTenantCustomer, invalidates: [KEYS.tenantCustomers], successMessage: 'Customer created', }); export const useUpdateTenantCustomer = () => useDoormileMutation({ mutationFn: ({ id, data }) => api.updateTenantCustomer(id, data), invalidates: [KEYS.tenantCustomers], successMessage: 'Customer updated', }); export const useDeleteTenantCustomer = () => useDoormileMutation({ mutationFn: api.deleteTenantCustomer, invalidates: [KEYS.tenantCustomers], successMessage: 'Customer removed', }); /* ── Hubs ─────────────────────────────────────────────────────────────────── */ export const useHubs = (options) => useQuery({ queryKey: KEYS.hubs, queryFn: api.getHubs, staleTime: 60_000, ...options }); export const useHub = (id, options) => useQuery({ queryKey: KEYS.hub(id), queryFn: () => api.getHub(id), enabled: !!id, ...options }); /** * The facility list used to decide which end of a booking is a hub. * * Lives here rather than in each page so every screen classifies against the * SAME facilities. A page that built its own list from hubs alone would call * every client's kitchen a customer pickup, and the same order would then carry * different labels on Orders and on Dispatch. * * `tenantIds` is sorted into the cache key so the same set of tenants in a * different order is one cache entry, not two. */ export const useFlowAnchors = (tenantIds = [], options) => { const ids = [...new Set((tenantIds || []).filter((id) => id != null).map(String))].sort(); return useQuery({ queryKey: ['doormile', 'flowAnchors', ids.join(',')], queryFn: () => api.fetchFlowAnchors(ids), staleTime: 5 * 60_000, ...options, }); }; export const useCreateHub = () => useDoormileMutation({ mutationFn: api.createHub, invalidates: [KEYS.hubs], successMessage: 'Hub created' }); export const useUpdateHub = () => useDoormileMutation({ mutationFn: ({ id, data }) => api.updateHub(id, data), invalidates: [KEYS.hubs], successMessage: 'Hub updated', }); export const useDeleteHub = () => useDoormileMutation({ mutationFn: api.deleteHub, invalidates: [KEYS.hubs], successMessage: 'Hub removed' }); /** * The zone picker. There is no zones resource on this API — `applocationid` * only exists as a field on a hub — so the list is derived from the distinct * cities across `GET /admin/hubs`. */ export const useAppLocations = (options) => useQuery({ queryKey: KEYS.appLocations, queryFn: api.fetchAppLocations, staleTime: 300_000, ...options }); /* ── Vehicles ─────────────────────────────────────────────────────────────── */ export const useVehicles = (options) => useQuery({ queryKey: KEYS.vehicles, queryFn: api.getVehicles, staleTime: 60_000, ...options }); export const useCreateVehicle = () => useDoormileMutation({ mutationFn: api.createVehicle, invalidates: [KEYS.vehicles], successMessage: 'Vehicle created' }); export const useUpdateVehicle = () => useDoormileMutation({ mutationFn: ({ id, data }) => api.updateVehicle(id, data), invalidates: [KEYS.vehicles], successMessage: 'Vehicle updated', }); export const useDeleteVehicle = () => useDoormileMutation({ mutationFn: api.deleteVehicle, invalidates: [KEYS.vehicles], successMessage: 'Vehicle removed' }); /* ── Milers (riders) ──────────────────────────────────────────────────────── */ export const useMilers = (options) => useQuery({ queryKey: KEYS.milers, queryFn: api.getallriders, staleTime: 30_000, ...options }); export const useRiders = useMilers; export const useMiler = (id, options) => useQuery({ queryKey: KEYS.miler(id), queryFn: () => api.getMiler(id), enabled: !!id, ...options }); /** Riders shaped for a dropdown — `label` is "name | phone". */ export const useRiderOptions = (options) => useQuery({ queryKey: [...KEYS.milers, 'options'], queryFn: api.fetchRidersList, staleTime: 30_000, ...options }); /** Total / active / available / on-delivery counts for the Riders KPI strip. */ export const useRiderSummaryCounts = (options) => useQuery({ queryKey: KEYS.riderSummaryCounts, queryFn: api.getallridersummary, staleTime: 30_000, ...options }); export const useMilerSummary = (hubid, from, to, options) => useQuery({ queryKey: KEYS.milerSummary(hubid, from, to), queryFn: () => api.getMilerSummary(hubid, from, to), staleTime: 30_000, ...options, }); export const useMilerLogs = (id, from, to, limit, options) => useQuery({ queryKey: KEYS.milerLogs(id, from, to), queryFn: () => api.getMilerLogs(id, from, to, limit), enabled: !!id, ...options, }); export const useMilerActivity = (id, from, to, options) => useQuery({ queryKey: KEYS.milerActivity(id, from, to), queryFn: () => api.getMilerActivity(id, from, to), enabled: !!id, ...options, }); export const useCreateMiler = () => useDoormileMutation({ mutationFn: api.createMiler, invalidates: [KEYS.milers], successMessage: 'Rider created' }); export const useUpdateMiler = () => useDoormileMutation({ mutationFn: ({ id, data }) => api.updateMiler(id, data), invalidates: [KEYS.milers], successMessage: 'Rider updated', }); export const useBlockMiler = () => useDoormileMutation({ mutationFn: ({ id, data }) => api.blockMiler(id, data), invalidates: [KEYS.milers], successMessage: 'Rider updated', }); export const useAssignMilerVehicle = () => useDoormileMutation({ mutationFn: ({ id, data }) => api.assignMilerVehicle(id, data), invalidates: [KEYS.milers, KEYS.vehicles], successMessage: 'Vehicle assigned', }); /* Notify takes a miler PROFILE id, not a userid and not an FCM token — the server looks the device token up itself. */ export const useNotifyMiler = () => useDoormileMutation({ mutationFn: ({ milerProfileId, title, message }) => api.notifyRider(milerProfileId, title, message), successMessage: 'Rider notified', }); /* ── Bookings (orders) ────────────────────────────────────────────────────── */ /** One page of bookings, with the envelope's `total` kept for the pager. */ export const useBookingsPage = (pageno, pagesize, options) => useQuery({ queryKey: KEYS.bookings(pageno, pagesize), queryFn: () => api.getBookingsPage(pageno, pagesize), placeholderData: (previous) => previous, staleTime: 15_000, ...options, }); export const useBooking = (id, options) => useQuery({ queryKey: KEYS.booking(id), queryFn: () => api.getorderdetails(id), enabled: !!id, ...options }); export const useBookingTrack = (id, options) => useQuery({ queryKey: KEYS.bookingTrack(id), queryFn: () => api.getBookingTrack(id), enabled: !!id, ...options }); export const useCreateBooking = () => useDoormileMutation({ mutationFn: api.createExpressBooking, invalidates: BOOKING_KEYS, successMessage: 'Order created' }); export const useCreateBookingsBulk = () => useDoormileMutation({ mutationFn: api.createExpressBookingBulk, invalidates: BOOKING_KEYS }); /* assign-miler wants the miler's `userid` — a different identity space than the `milerprofileid` every other /admin/milers route keys on. */ export const useAssignMilerToBooking = () => useDoormileMutation({ mutationFn: ({ id, mileruserid }) => api.assignMilerToBooking(id, { mileruserid }), invalidates: BOOKING_KEYS, successMessage: 'Rider assigned', }); export const useBatchAssignBookings = () => useDoormileMutation({ mutationFn: ({ bookingIds, maxPerRider }) => api.batchAssignBookings(bookingIds, maxPerRider), invalidates: BOOKING_KEYS, }); export const useAssignVehicleToBooking = () => useDoormileMutation({ mutationFn: ({ id, data }) => api.assignVehicleToBooking(id, data), invalidates: BOOKING_KEYS, successMessage: 'Vehicle assigned', }); export const useUpdateBookingStatus = () => useDoormileMutation({ mutationFn: ({ id, data }) => api.updateBookingStatus(id, data), invalidates: BOOKING_KEYS, successMessage: 'Status updated', }); export const useCancelBooking = () => useDoormileMutation({ mutationFn: ({ id, reason }) => api.cancelBooking(id, { reason }), invalidates: BOOKING_KEYS, successMessage: 'Order cancelled', }); export const useBulkCancelBookings = () => useDoormileMutation({ mutationFn: api.bulkCancelBookings, invalidates: BOOKING_KEYS, successMessage: 'Orders cancelled', }); /* ── Deliveries ───────────────────────────────────────────────────────────── */ /** * The Deliveries table — bookings that have moved past merely being created. * * Built from `/admin/bookings` rather than `/admin/consignments`, which has no * documented response schema; see `fetchDeliveries` for the full reasoning and * for which columns are deliberately left blank instead of invented. */ export const useDeliveries = ({ page = 1, pageSize = 50, status = '', from = '', to = '' } = {}, options) => useQuery({ queryKey: [...KEYS.deliveries(status, from, to, pageSize), page], queryFn: () => api.fetchDeliveries({ pageParam: page, queryKey: ['deliveries', '', '', status, from, to, pageSize] }), placeholderData: (previous) => previous, staleTime: 15_000, ...options, }); export const useDeliveryCounts = (options) => useQuery({ queryKey: KEYS.deliveryCounts, queryFn: api.fetchCountAPI, staleTime: 15_000, ...options }); export const useChangeDeliveryRider = () => useDoormileMutation({ mutationFn: ({ rider, row }) => api.changeRiderAPI(rider, row), invalidates: BOOKING_KEYS, successMessage: 'Rider changed', }); /* Refuses statuses the consignment enum cannot express, with the reason — see `updateDeliveryAPI`. Those come back as `{ success: false, message }`, which `useDoormileMutation` surfaces as a warning rather than a false success. */ export const useUpdateDeliveryStatus = () => useDoormileMutation({ mutationFn: api.updateDeliveryAPI, invalidates: BOOKING_KEYS, successMessage: 'Status updated' }); export const useCancelDelivery = () => useDoormileMutation({ mutationFn: ({ row, reason }) => api.cancelDeliveryAPI(row, reason), invalidates: BOOKING_KEYS, successMessage: 'Delivery cancelled', }); /* ── Consignments ─────────────────────────────────────────────────────────── */ export const useConsignments = (options) => useQuery({ queryKey: KEYS.consignments, queryFn: api.getConsignments, staleTime: 30_000, ...options }); export const useConsignment = (id, options) => useQuery({ queryKey: KEYS.consignment(id), queryFn: () => api.getConsignment(id), enabled: !!id, ...options }); export const useConsignmentLogs = (id, options) => useQuery({ queryKey: KEYS.consignmentLogs(id), queryFn: () => api.getConsignmentLogs(id), enabled: !!id, ...options }); /** * The GPS trail for each of a rider's deliveries, as parallel queries. * * This is what the dispatch board's plan-vs-actual comparison is built on: * `GET /admin/consignments/:id/logs` is the confirmed per-consignment trail, * and the comparison is per stop, not per rider — so one query per delivery is * the shape, not a single call. * * Pings come back in no guaranteed order and carry raw GPS jitter, so each * trail is sorted chronologically and run through the Kalman/RTS smoother * before it reaches a renderer. Without the sort a polyline zig-zags between * consecutive points; without the smoother it wanders at every stop. * * A `deliveryid` on this board has never been confirmed to be a consignment id. * If it is really a booking id the call 404s and the trail is simply empty — * degraded, not broken, which is why the error is swallowed per query. */ export const useDeliveryTracks = (deliveryIds = [], enabled = true) => useQueries({ queries: deliveryIds.map((deliveryid) => ({ queryKey: KEYS.consignmentLogs(deliveryid), queryFn: async () => { let rows = []; try { rows = (await api.getConsignmentLogs(deliveryid)) || []; if (!Array.isArray(rows)) rows = []; } catch { rows = []; } const sorted = rows .map((row) => { const at = row?.logdate ? parseDoormileTimestamp(row.logdate) : null; return { lat: parseFloat(row?.latitude ?? row?.lat), lng: parseFloat(row?.longitude ?? row?.lng ?? row?.lon), logdate: row?.logdate, _ts: at && at.isValid() ? at.valueOf() : Number.MAX_SAFE_INTEGER, }; }) .filter((point) => Number.isFinite(point.lat) && Number.isFinite(point.lng)) .sort((a, b) => a._ts - b._ts); /* The smoother needs `_ts` to compute per-step dt, so it runs before the timestamp is stripped on the way out. */ return kalmanSmoothGps(sorted).map(({ _ts, ...point }) => point); }, enabled: enabled && deliveryid != null, staleTime: 5 * 60 * 1000, refetchOnWindowFocus: false, retry: 1, })), }); /** * A rider's most recent periodic log — position, status, battery. * * Polled while the rider-info view is open, and only then: this is a live * snapshot, and polling it from a background tab burns requests for a panel * nobody is looking at. */ export const useRiderPeriodicLog = (milerProfileId, options) => useQuery({ queryKey: ['doormile', 'rider-periodic-log', milerProfileId], queryFn: () => api.getRiderPeriodicLogs(milerProfileId), enabled: !!milerProfileId, ...options, }); /* ── Tripsheets ───────────────────────────────────────────────────────────── */ export const useTripsheets = (options) => useQuery({ queryKey: KEYS.tripsheets, queryFn: api.getTripsheets, staleTime: 30_000, ...options }); export const useTripsheet = (id, options) => useQuery({ queryKey: KEYS.tripsheet(id), queryFn: () => api.getTripsheet(id), enabled: !!id, ...options }); export const useCreateTripsheet = () => useDoormileMutation({ mutationFn: api.createTripsheet, invalidates: [KEYS.tripsheets], successMessage: 'Tripsheet created' }); export const useAddTripsheetItem = () => useDoormileMutation({ mutationFn: ({ id, consignmentid }) => api.addTripsheetItem(id, consignmentid), invalidates: [KEYS.tripsheets], successMessage: 'Consignment added', }); export const useRemoveTripsheetItem = () => useDoormileMutation({ mutationFn: ({ id, itemId }) => api.removeTripsheetItem(id, itemId), invalidates: [KEYS.tripsheets], successMessage: 'Consignment removed', }); export const useDispatchTripsheet = () => useDoormileMutation({ mutationFn: api.dispatchTripsheet, invalidates: [KEYS.tripsheets], successMessage: 'Tripsheet dispatched', }); export const useArriveTripsheet = () => useDoormileMutation({ mutationFn: api.arriveTripsheet, invalidates: [KEYS.tripsheets], successMessage: 'Tripsheet marked arrived', }); /* ── Pricing ──────────────────────────────────────────────────────────────── */ /* Two separate resources, deliberately: `/admin/pricing` is per-tenant client pricing; `/admin/doormile-pricing` is Doormile's own internal banding and has no tenant concept at all. */ export const usePricing = (options) => useQuery({ queryKey: KEYS.pricing, queryFn: api.getallpricing, staleTime: 60_000, ...options }); export const useCreatePricing = () => useDoormileMutation({ mutationFn: api.createAdminPricing, invalidates: [KEYS.pricing], successMessage: 'Pricing rule created' }); export const useUpdatePricing = () => useDoormileMutation({ mutationFn: ({ id, data }) => api.updateAdminPricing(id, data), invalidates: [KEYS.pricing], successMessage: 'Pricing rule updated', }); export const useDeletePricing = () => useDoormileMutation({ mutationFn: api.deleteAdminPricing, invalidates: [KEYS.pricing], successMessage: 'Pricing rule removed' }); export const useSimulatePricing = () => useDoormileMutation({ mutationFn: api.simulatePricing }); export const useDoormilePricing = (options) => useQuery({ queryKey: KEYS.doormilePricing, queryFn: api.getDoormilePricing, staleTime: 60_000, ...options }); export const useCreateDoormilePricing = () => useDoormileMutation({ mutationFn: api.createDoormilePricing, invalidates: [KEYS.doormilePricing], successMessage: 'Band created', }); export const useUpdateDoormilePricing = () => useDoormileMutation({ mutationFn: ({ id, data }) => api.updateDoormilePricing(id, data), invalidates: [KEYS.doormilePricing], successMessage: 'Band updated', }); export const useDeleteDoormilePricing = () => useDoormileMutation({ mutationFn: api.deleteDoormilePricing, invalidates: [KEYS.doormilePricing], successMessage: 'Band removed', }); /* ── Exceptions ───────────────────────────────────────────────────────────── */ export const useExceptions = (options) => useQuery({ queryKey: KEYS.exceptions, queryFn: api.getExceptions, staleTime: 30_000, ...options }); export const useException = (id, options) => useQuery({ queryKey: KEYS.exception(id), queryFn: () => api.getException(id), enabled: !!id, ...options }); export const useCreateException = () => useDoormileMutation({ mutationFn: api.createException, invalidates: [KEYS.exceptions], successMessage: 'Exception raised' }); export const useUpdateExceptionStatus = () => useDoormileMutation({ mutationFn: ({ id, status, resolution }) => api.updateExceptionStatus(id, status, resolution), invalidates: [KEYS.exceptions], successMessage: 'Exception updated', }); /* ── Competitive intelligence ─────────────────────────────────────────────── */ /* These two resources paginate on page/limit, not the pageno/pagesize convention used everywhere else, and return the whole envelope so the page can build its own pager. A single fetch is NOT the full set. */ export const useCompetitorBranches = (page = 1, limit = 100, options) => useQuery({ queryKey: KEYS.competitorBranches(page, limit), queryFn: () => api.getCompetitorBranches(page, limit), placeholderData: (previous) => previous, staleTime: 60_000, ...options, }); export const useCreateCompetitorBranch = () => useDoormileMutation({ mutationFn: api.createCompetitorBranch, invalidates: [['doormile', 'competitor-branches']], successMessage: 'Branch added', }); export const useUpdateCompetitorBranch = () => useDoormileMutation({ mutationFn: ({ id, data }) => api.updateCompetitorBranch(id, data), invalidates: [['doormile', 'competitor-branches']], successMessage: 'Branch updated', }); export const useDeleteCompetitorBranch = () => useDoormileMutation({ mutationFn: api.deleteCompetitorBranch, invalidates: [['doormile', 'competitor-branches']], successMessage: 'Branch removed', }); export const useCarrierPricing = (page = 1, limit = 100, options) => useQuery({ queryKey: KEYS.carrierPricing(page, limit), queryFn: () => api.getCarrierPricing(page, limit), placeholderData: (previous) => previous, staleTime: 60_000, ...options, }); export const useCreateCarrierPricing = () => useDoormileMutation({ mutationFn: api.createCarrierPricing, invalidates: [['doormile', 'carrier-pricing']], successMessage: 'Carrier rate added', }); export const useUpdateCarrierPricing = () => useDoormileMutation({ mutationFn: ({ id, data }) => api.updateCarrierPricing(id, data), invalidates: [['doormile', 'carrier-pricing']], successMessage: 'Carrier rate updated', }); export const useDeleteCarrierPricing = () => useDoormileMutation({ mutationFn: api.deleteCarrierPricing, invalidates: [['doormile', 'carrier-pricing']], successMessage: 'Carrier rate removed', }); /* ── Reports ──────────────────────────────────────────────────────────────── */ export const useReportSummary = (appId, tenantid, locationid, startdate, enddate, options) => useQuery({ queryKey: ['doormile', 'report-summary', appId, tenantid, locationid, startdate, enddate], queryFn: () => api.getreportsummary({ queryKey: [appId, tenantid, locationid, startdate, enddate] }), staleTime: 30_000, ...options, }); export const useReportLocationSummary = (appId, tenantid, locationid, startdate, enddate, options) => useQuery({ queryKey: ['doormile', 'report-location-summary', appId, tenantid, locationid, startdate, enddate], queryFn: () => api.getreportlocationsummary({ queryKey: [appId, tenantid, locationid, startdate, enddate] }), staleTime: 30_000, ...options, }); export const useRidersSummary = (appId, startdate, enddate, options) => useQuery({ queryKey: ['doormile', 'riders-summary', appId, startdate, enddate], queryFn: () => api.fetchRidersSummary({ queryKey: ['ridersSummary', appId, startdate, enddate] }), staleTime: 30_000, ...options, }); /** Live rider positions for the dispatch map. Polled, so it never toasts. */ export const useRiderLogs = (appId, options) => useQuery({ queryKey: ['doormile', 'rider-logs', appId], queryFn: () => api.fetchRidersLogs({ queryKey: [appId] }), ...options, }); export const useOrderPercentages = (startdate, enddate, options) => useQuery({ queryKey: ['doormile', 'order-percentages', startdate, enddate], queryFn: () => api.fetchPercentageData({ queryKey: ['pct', '', startdate, enddate] }), staleTime: 30_000, ...options, }); /* ── AI agent registry (Settings → Skills & Tools) ────────────────────────── */ export const useAiAgents = (options) => useQuery({ queryKey: KEYS.aiAgents, queryFn: api.getAiAgents, staleTime: 30_000, ...options }); export const useAiSkills = (options) => useQuery({ queryKey: KEYS.aiSkills, queryFn: api.getAiSkills, staleTime: 30_000, ...options }); export const useAiTools = (options) => useQuery({ queryKey: KEYS.aiTools, queryFn: api.getAiTools, staleTime: 5 * 60_000, ...options }); export const useUpdateAiSkill = () => useDoormileMutation({ mutationFn: ({ id, patch }) => api.updateAiSkill(id, patch), invalidates: [KEYS.aiRegistry], successMessage: 'Skill updated', }); export const useCreateAiSkill = () => useDoormileMutation({ mutationFn: (skill) => api.createAiSkill(skill), invalidates: [KEYS.aiRegistry], successMessage: 'Skill registered', }); export const useUpdateAiAgent = () => useDoormileMutation({ mutationFn: ({ id, patch }) => api.updateAiAgent(id, patch), invalidates: [KEYS.aiRegistry], successMessage: 'Agent updated', }); // Read-only, and it changes as agents work: refetched every minute while open. // The banner's source of truth; re-checked every 30 s so it turns green on its // own once AI_engine starts reading the registry. export const useAiStatus = (options) => useQuery({ queryKey: [...KEYS.aiRegistry, 'status'], queryFn: api.getAiStatus, staleTime: 15_000, refetchInterval: 30_000, retry: false, ...options, }); export const useAiInsights = (days = 7, options) => useQuery({ queryKey: [...KEYS.aiRegistry, 'insights', days], queryFn: () => api.getAiInsights(days), staleTime: 30_000, refetchInterval: 60_000, ...options, }); // A playground run. No toasts and no invalidation: it changes nothing, and the // Test tab shows its own result or error inline. export const useRunAiPlayground = () => useMutation({ mutationFn: api.runAiPlayground }); export const useAiDecisions = (params = {}, options) => useQuery({ queryKey: [...KEYS.aiRegistry, 'decisions', params], queryFn: () => api.getAiDecisions(params), staleTime: 30_000, ...options, });