From da9caa855a2a32cd9e3e39200ff9e71ad920da8d Mon Sep 17 00:00:00 2001 From: DeepnaPooja Date: Mon, 31 Aug 2026 13:00:33 +0530 Subject: [PATCH] add sales visits ui --- .../routes/_components/route-manage-sheet.tsx | 170 +++++++++++++++++ .../routes/_components/routes-client.tsx | 46 ++++- .../_components/sales-visits-client.tsx | 173 ++++++++++++++++++ .../app/(dashboard)/sales-visits/page.tsx | 5 + .../_components/store-visit-history-sheet.tsx | 69 +++++++ .../stores/_components/stores-client.tsx | 21 ++- distribution_fe/config/nav.ts | 1 + distribution_fe/contexts/auth-context.tsx | 1 + distribution_fe/lib/api/master.ts | 13 ++ distribution_fe/lib/api/stores.ts | 4 + distribution_fe/types/api.ts | 18 ++ 11 files changed, 518 insertions(+), 3 deletions(-) create mode 100644 distribution_fe/app/(dashboard)/network/routes/_components/route-manage-sheet.tsx create mode 100644 distribution_fe/app/(dashboard)/sales-visits/_components/sales-visits-client.tsx create mode 100644 distribution_fe/app/(dashboard)/sales-visits/page.tsx create mode 100644 distribution_fe/app/(dashboard)/stores/_components/store-visit-history-sheet.tsx diff --git a/distribution_fe/app/(dashboard)/network/routes/_components/route-manage-sheet.tsx b/distribution_fe/app/(dashboard)/network/routes/_components/route-manage-sheet.tsx new file mode 100644 index 0000000..990aaff --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/routes/_components/route-manage-sheet.tsx @@ -0,0 +1,170 @@ +'use client' + +import { useEffect, useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Checkbox } from '@/components/ui/checkbox' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog' +import { Separator } from '@/components/ui/separator' +import { toast } from '@/components/ui/toast' +import { routes as routesApi, stores as storesApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { RouteResponse, UserResponse, StoreResponse } from '@/types/api' + +interface RouteManageSheetProps { + open: boolean + onClose: () => void + route: RouteResponse | null + onSuccess: () => void +} + +export function RouteManageSheet({ open, onClose, route, onSuccess }: RouteManageSheetProps) { + const [salesReps, setSalesReps] = useState([]) + const [ownerUserId, setOwnerUserId] = useState('') + const [allStores, setAllStores] = useState([]) + const [checkedStoreIds, setCheckedStoreIds] = useState>(new Set()) + const [storeFilter, setStoreFilter] = useState('') + const [saving, setSaving] = useState(null) + + useEffect(() => { + if (!open || !route) return + + setOwnerUserId(route.ownerUserId ?? '') + setStoreFilter('') + + usersApi.list({ page: 1, pageSize: 200 }) + .then((res) => setSalesReps(getPaginatedItems(res).filter((u) => u.roleName === 'SalesRep'))) + .catch(() => toast.add({ title: 'Failed to load sales reps', type: 'error' })) + + storesApi.list({ page: 1, pageSize: 200 }) + .then((res) => { + const items = getPaginatedItems(res) + setAllStores(items) + setCheckedStoreIds(new Set(items.filter((s) => s.routeId === route.id).map((s) => s.id))) + }) + .catch(() => toast.add({ title: 'Failed to load stores', type: 'error' })) + }, [open, route]) + + if (!route) return null + + async function handleAssignOwner() { + if (!ownerUserId) return + setSaving('owner') + try { + await routesApi.assignOwner(route!.id, ownerUserId) + toast.add({ title: 'Route owner assigned', type: 'success' }) + onSuccess() + } catch (err) { + toast.add({ title: 'Failed to assign owner', description: getApiError(err), type: 'error' }) + } finally { + setSaving(null) + } + } + + async function handleSaveStores() { + setSaving('stores') + try { + await routesApi.assignStores(route!.id, Array.from(checkedStoreIds)) + toast.add({ title: 'Store assignment saved', type: 'success' }) + onSuccess() + } catch (err) { + toast.add({ title: 'Failed to assign stores', description: getApiError(err), type: 'error' }) + } finally { + setSaving(null) + } + } + + function toggleStore(storeId: string) { + setCheckedStoreIds((prev) => { + const next = new Set(prev) + if (next.has(storeId)) next.delete(storeId) + else next.add(storeId) + return next + }) + } + + const filteredStores = allStores.filter((s) => + s.storeName.toLowerCase().includes(storeFilter.toLowerCase()) + ) + + return ( + !v && onClose()}> + + + Manage {route.routeName} + + Assign a sales rep owner and the stores on this route. + + + +
+
+ +
+ + +
+ {salesReps.length === 0 && ( +

No users with the SalesRep role found.

+ )} +
+ + + +
+ + setStoreFilter(e.target.value)} + /> +
+ {filteredStores.length === 0 && ( +

No stores match.

+ )} + {filteredStores.map((s) => ( + + ))} +
+ +
+
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/network/routes/_components/routes-client.tsx b/distribution_fe/app/(dashboard)/network/routes/_components/routes-client.tsx index 942d587..f531b91 100644 --- a/distribution_fe/app/(dashboard)/network/routes/_components/routes-client.tsx +++ b/distribution_fe/app/(dashboard)/network/routes/_components/routes-client.tsx @@ -9,10 +9,11 @@ import type { TableConfig } from '@/components/shared/data-table' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/toast' -import { routes as routesApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import { routes as routesApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index' import type { RouteResponse } from '@/types/api' import { RouteSheet } from './route-sheet' +import { RouteManageSheet } from './route-manage-sheet' export function RoutesClient() { const [data, setData] = useState([]) @@ -20,9 +21,12 @@ export function RoutesClient() { const [total, setTotal] = useState(0) const [page, setPage] = useState(1) const [pageSize, setPageSize] = useState(20) + const [userNameById, setUserNameById] = useState>({}) const [sheetOpen, setSheetOpen] = useState(false) const [editing, setEditing] = useState(null) + const [manageOpen, setManageOpen] = useState(false) + const [managing, setManaging] = useState(null) const fetchData = useCallback(async (p: number, ps: number) => { setLoading(true) @@ -41,6 +45,16 @@ export function RoutesClient() { fetchData(page, pageSize) }, [fetchData, page, pageSize]) + useEffect(() => { + usersApi.list({ page: 1, pageSize: 200 }) + .then((res) => { + const map: Record = {} + for (const u of getPaginatedItems(res)) map[u.id] = u.fullName + setUserNameById(map) + }) + .catch(() => toast.add({ title: 'Failed to load users', type: 'error' })) + }, []) + function openCreate() { setEditing(null) setSheetOpen(true) @@ -51,6 +65,11 @@ export function RoutesClient() { setSheetOpen(true) } + function openManage(route: RouteResponse) { + setManaging(route) + setManageOpen(true) + } + async function toggleActive(route: RouteResponse) { try { await routesApi.update(route.id, { isActive: !route.isActive }) @@ -95,6 +114,17 @@ export function RoutesClient() { fields: [{ key: 'length', transform: (v) => `${v} km` }], width: 100, }, + { + key: 'owner', + title: 'Owner', + fields: [{ key: 'ownerUserId' }], + minWidth: 140, + cellRender: (row) => ( + + {row.ownerUserId ? userNameById[row.ownerUserId] ?? 'Unknown' : 'Unassigned'} + + ), + }, { key: 'status', title: 'Status', @@ -125,6 +155,11 @@ export function RoutesClient() { label: 'Edit route', onClick: openEdit, }, + { + action: 'confirm', + label: 'Manage owner & stores', + onClick: openManage, + }, { action: 'deactivate', label: 'Deactivate route', @@ -139,7 +174,7 @@ export function RoutesClient() { }, ], }, - }), [pageSize, openEdit, toggleActive]) + }), [pageSize, userNameById, openEdit, openManage, toggleActive]) return ( <> @@ -173,6 +208,13 @@ export function RoutesClient() { route={editing} onSuccess={() => fetchData(page, pageSize)} /> + + setManageOpen(false)} + route={managing} + onSuccess={() => fetchData(page, pageSize)} + /> ) } diff --git a/distribution_fe/app/(dashboard)/sales-visits/_components/sales-visits-client.tsx b/distribution_fe/app/(dashboard)/sales-visits/_components/sales-visits-client.tsx new file mode 100644 index 0000000..b4ad638 --- /dev/null +++ b/distribution_fe/app/(dashboard)/sales-visits/_components/sales-visits-client.tsx @@ -0,0 +1,173 @@ +'use client' + +import { useEffect, useState } from 'react' + +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { toast } from '@/components/ui/toast' +import { routes as routesApi, stores as storesApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { RouteResponse, StoreResponse, RouteVisitResponse } from '@/types/api' + +type VisitState = 'idle' | 'checked-in' | 'checked-out' + +function getCurrentPosition(): Promise { + return new Promise((resolve, reject) => { + if (!('geolocation' in navigator)) { + reject(new Error('Geolocation is not supported by this browser')) + return + } + navigator.geolocation.getCurrentPosition(resolve, reject, { enableHighAccuracy: true, timeout: 10000 }) + }) +} + +export function SalesVisitsClient() { + const [routeList, setRouteList] = useState([]) + const [selectedRouteId, setSelectedRouteId] = useState('') + const [storeList, setStoreList] = useState([]) + const [loadingStores, setLoadingStores] = useState(false) + const [visitState, setVisitState] = useState>({}) + const [deviationByStore, setDeviationByStore] = useState>({}) + const [busyStoreId, setBusyStoreId] = useState(null) + + useEffect(() => { + routesApi.list({ page: 1, pageSize: 200 }) + .then((res) => setRouteList(getPaginatedItems(res).filter((r) => r.isActive))) + .catch(() => toast.add({ title: 'Failed to load routes', type: 'error' })) + }, []) + + useEffect(() => { + if (!selectedRouteId) { + setStoreList([]) + return + } + setLoadingStores(true) + storesApi.list({ routeId: selectedRouteId, page: 1, pageSize: 200 }) + .then((res) => { + setStoreList(getPaginatedItems(res).filter((s) => s.isActive)) + setVisitState({}) + setDeviationByStore({}) + }) + .catch(() => toast.add({ title: 'Failed to load stores', type: 'error' })) + .finally(() => setLoadingStores(false)) + }, [selectedRouteId]) + + async function handleCheckIn(store: StoreResponse) { + setBusyStoreId(store.id) + try { + const position = await getCurrentPosition() + const result: RouteVisitResponse = await routesApi.checkIn(selectedRouteId, { + storeId: store.id, + latitude: position.coords.latitude, + longitude: position.coords.longitude, + }) + setVisitState((prev) => ({ ...prev, [store.id]: 'checked-in' })) + setDeviationByStore((prev) => ({ ...prev, [store.id]: result.isDeviationFlagged })) + toast.add({ + title: 'Checked in', + description: result.isDeviationFlagged ? 'Location does not match the store’s registered address.' : undefined, + type: result.isDeviationFlagged ? 'error' : 'success', + }) + } catch (err) { + const message = err instanceof Error && !getApiError(err) ? err.message : getApiError(err) + toast.add({ title: 'Check-in failed', description: message, type: 'error' }) + } finally { + setBusyStoreId(null) + } + } + + async function handleCheckOut(store: StoreResponse) { + setBusyStoreId(store.id) + try { + await routesApi.checkOut(selectedRouteId, { storeId: store.id }) + setVisitState((prev) => ({ ...prev, [store.id]: 'checked-out' })) + toast.add({ title: 'Checked out', type: 'success' }) + } catch (err) { + toast.add({ title: 'Check-out failed', description: getApiError(err), type: 'error' }) + } finally { + setBusyStoreId(null) + } + } + + return ( +
+
+

Sales Visits

+

+ Check in and out of scheduled store visits along a route +

+
+ +
+ + +
+ + {loadingStores &&

Loading stores…

} + + {!loadingStores && selectedRouteId && storeList.length === 0 && ( +

No active stores on this route.

+ )} + +
+ {storeList.map((store) => { + const state = visitState[store.id] ?? 'idle' + const isBusy = busyStoreId === store.id + return ( +
+
+ {store.storeName} + {store.storeAddress} + {deviationByStore[store.id] && state !== 'idle' && ( + Location mismatch + )} +
+
+ {state === 'checked-out' && Completed} + {state !== 'checked-out' && ( + + )} + {state === 'checked-in' && ( + + )} +
+
+ ) + })} +
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/sales-visits/page.tsx b/distribution_fe/app/(dashboard)/sales-visits/page.tsx new file mode 100644 index 0000000..4cb4a13 --- /dev/null +++ b/distribution_fe/app/(dashboard)/sales-visits/page.tsx @@ -0,0 +1,5 @@ +import { SalesVisitsClient } from './_components/sales-visits-client' + +export default function SalesVisitsPage() { + return +} diff --git a/distribution_fe/app/(dashboard)/stores/_components/store-visit-history-sheet.tsx b/distribution_fe/app/(dashboard)/stores/_components/store-visit-history-sheet.tsx new file mode 100644 index 0000000..12a9678 --- /dev/null +++ b/distribution_fe/app/(dashboard)/stores/_components/store-visit-history-sheet.tsx @@ -0,0 +1,69 @@ +'use client' + +import { useEffect, useState } from 'react' + +import { Badge } from '@/components/ui/badge' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog' +import { toast } from '@/components/ui/toast' +import { stores as storesApi } from '@/lib/api/index' +import type { StoreResponse, StoreVisitHistoryItem } from '@/types/api' + +interface StoreVisitHistorySheetProps { + open: boolean + onClose: () => void + store: StoreResponse | null +} + +export function StoreVisitHistorySheet({ open, onClose, store }: StoreVisitHistorySheetProps) { + const [visits, setVisits] = useState(null) + + useEffect(() => { + if (!open || !store) return + setVisits(null) + storesApi.visitHistory(store.id) + .then(setVisits) + .catch(() => toast.add({ title: 'Failed to load visit history', type: 'error' })) + }, [open, store]) + + if (!store) return null + + return ( + !v && onClose()}> + + + Visit History — {store.storeName} + + Check-ins and check-outs logged against this store. + + + + {visits === null ? ( +

Loading…

+ ) : visits.length === 0 ? ( +

No visits logged yet.

+ ) : ( +
    + {visits.map((v) => ( +
  • + + {new Date(v.visitDate).toLocaleString('en-GB', { + day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', + })} + + + {v.outcome} + +
  • + ))} +
+ )} +
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/stores/_components/stores-client.tsx b/distribution_fe/app/(dashboard)/stores/_components/stores-client.tsx index 017399f..e4f7094 100644 --- a/distribution_fe/app/(dashboard)/stores/_components/stores-client.tsx +++ b/distribution_fe/app/(dashboard)/stores/_components/stores-client.tsx @@ -13,6 +13,7 @@ import { stores as storesApi, getApiError, getPaginatedItems } from '@/lib/api/i import type { StoreResponse, KycStatus } from '@/types/api' import { StoreSheet } from './store-sheet' +import { StoreVisitHistorySheet } from './store-visit-history-sheet' function kycBadgeVariant(status: KycStatus): 'default' | 'secondary' | 'destructive' { if (status === 'Verified') return 'default' @@ -29,6 +30,8 @@ export function StoresClient() { const [sheetOpen, setSheetOpen] = useState(false) const [editing, setEditing] = useState(null) + const [historyOpen, setHistoryOpen] = useState(false) + const [viewingHistory, setViewingHistory] = useState(null) const fetchData = useCallback(async (p: number, ps: number) => { setLoading(true) @@ -57,6 +60,11 @@ export function StoresClient() { setSheetOpen(true) } + function openHistory(store: StoreResponse) { + setViewingHistory(store) + setHistoryOpen(true) + } + async function toggleActive(store: StoreResponse) { try { await storesApi.update(store.id, { isActive: !store.isActive }) @@ -125,6 +133,11 @@ export function StoresClient() { label: 'Edit store', onClick: openEdit, }, + { + action: 'confirm', + label: 'View visit history', + onClick: openHistory, + }, { action: 'deactivate', label: 'Deactivate store', @@ -139,7 +152,7 @@ export function StoresClient() { }, ], }, - }), [pageSize, openEdit, toggleActive]) + }), [pageSize, openEdit, openHistory, toggleActive]) return ( <> @@ -173,6 +186,12 @@ export function StoresClient() { store={editing} onSuccess={() => fetchData(page, pageSize)} /> + + setHistoryOpen(false)} + store={viewingHistory} + /> ) } diff --git a/distribution_fe/config/nav.ts b/distribution_fe/config/nav.ts index 787bd83..bbdd53a 100644 --- a/distribution_fe/config/nav.ts +++ b/distribution_fe/config/nav.ts @@ -73,6 +73,7 @@ export const NAV_GROUPS: NavGroup[] = [ icon: Store01Icon, items: [ { title: 'Stores', href: '/stores', icon: Store01Icon, description: 'Retail store accounts, credit limits, and KYC status', permission: 'stores.view' }, + { title: 'Sales Visits', href: '/sales-visits', icon: Location01Icon, description: 'Check in and out of scheduled store visits', permission: 'sales_visits.checkin' }, ], }, { diff --git a/distribution_fe/contexts/auth-context.tsx b/distribution_fe/contexts/auth-context.tsx index f17fdf6..9728cb3 100644 --- a/distribution_fe/contexts/auth-context.tsx +++ b/distribution_fe/contexts/auth-context.tsx @@ -51,6 +51,7 @@ const MOCK_PERMISSIONS: Record = { 'routes.view', 'rides.view', 'driver_profiles.view', + 'sales_visits.checkin', ], } diff --git a/distribution_fe/lib/api/master.ts b/distribution_fe/lib/api/master.ts index 5fec4f2..ce18073 100644 --- a/distribution_fe/lib/api/master.ts +++ b/distribution_fe/lib/api/master.ts @@ -9,6 +9,7 @@ import type { RegionHierarchyResponse, RegionDashboardResponse, RouteResponse, + RouteVisitResponse, VehicleResponse, WarehouseResponse, VehicleType, @@ -148,6 +149,18 @@ export const routes = { remove: (id: string) => api.delete(`/routes/${id}`), + + assignStores: (id: string, storeIds: string[]) => + api.post(`/routes/${id}/stores`, { storeIds }).then(r => r.data), + + assignOwner: (id: string, salesRepId: string) => + api.put(`/routes/${id}/owner`, { salesRepId }).then(r => r.data), + + checkIn: (id: string, body: { storeId: string; latitude: number; longitude: number }) => + api.post(`/routes/${id}/visits/check-in`, body).then(r => r.data), + + checkOut: (id: string, body: { storeId: string }) => + api.post(`/routes/${id}/visits/check-out`, body).then(r => r.data), } // ─── Vehicles ───────────────────────────────────────────────────────────────── diff --git a/distribution_fe/lib/api/stores.ts b/distribution_fe/lib/api/stores.ts index 7804ad8..4188802 100644 --- a/distribution_fe/lib/api/stores.ts +++ b/distribution_fe/lib/api/stores.ts @@ -5,6 +5,7 @@ import type { KycDocumentResponse, OutstandingBalanceResponse, StoreDashboardResponse, + StoreVisitHistoryItem, KycStatus, } from '@/types/api' @@ -58,4 +59,7 @@ export const stores = { updateKycStatus: (id: string, kycStatus: KycStatus) => api.patch(`/stores/${id}/kyc-status`, { kycStatus }).then(r => r.data), + + visitHistory: (id: string) => + api.get(`/stores/${id}/visit-history`).then(r => r.data), } diff --git a/distribution_fe/types/api.ts b/distribution_fe/types/api.ts index 8f6a317..b12bf4b 100644 --- a/distribution_fe/types/api.ts +++ b/distribution_fe/types/api.ts @@ -81,10 +81,28 @@ export interface RouteResponse { startPoint: string endPoint: string length: number + ownerUserId: string | null isActive: boolean createdAt: string } +export interface RouteVisitResponse { + id: string + routeId: string + storeId: string + userId: string + checkInAt: string + isDeviationFlagged: boolean + checkOutAt: string | null +} + +export interface StoreVisitHistoryItem { + visitId: string + visitDate: string + repUserId: string + outcome: string +} + export interface RegionalTargetResponse { regionId: string period: string