Dev #18
@@ -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<UserResponse[]>([])
|
||||
const [ownerUserId, setOwnerUserId] = useState('')
|
||||
const [allStores, setAllStores] = useState<StoreResponse[]>([])
|
||||
const [checkedStoreIds, setCheckedStoreIds] = useState<Set<string>>(new Set())
|
||||
const [storeFilter, setStoreFilter] = useState('')
|
||||
const [saving, setSaving] = useState<string | null>(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 (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage {route.routeName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Assign a sales rep owner and the stores on this route.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Route Owner (Sales Rep)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select value={ownerUserId} onValueChange={(v) => setOwnerUserId(v ?? '')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a sales rep">
|
||||
{salesReps.find((u) => u.id === ownerUserId)?.fullName}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{salesReps.map((u) => (
|
||||
<SelectItem key={u.id} value={u.id}>{u.fullName}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button type="button" onClick={handleAssignOwner} disabled={saving === 'owner'}>
|
||||
{saving === 'owner' ? 'Saving…' : 'Assign'}
|
||||
</Button>
|
||||
</div>
|
||||
{salesReps.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground">No users with the SalesRep role found.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Stores on this Route</Label>
|
||||
<Input
|
||||
placeholder="Filter stores…"
|
||||
value={storeFilter}
|
||||
onChange={(e) => setStoreFilter(e.target.value)}
|
||||
/>
|
||||
<div className="flex flex-col gap-1 max-h-64 overflow-y-auto rounded-md border p-2">
|
||||
{filteredStores.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground p-2">No stores match.</p>
|
||||
)}
|
||||
{filteredStores.map((s) => (
|
||||
<label key={s.id} className="flex items-center gap-2 py-1 px-1 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={checkedStoreIds.has(s.id)}
|
||||
onCheckedChange={() => toggleStore(s.id)}
|
||||
/>
|
||||
{s.storeName}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<Button type="button" onClick={handleSaveStores} disabled={saving === 'stores'} className="self-start">
|
||||
{saving === 'stores' ? 'Saving…' : 'Save Assignment'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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<RouteResponse[]>([])
|
||||
@@ -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<Record<string, string>>({})
|
||||
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<RouteResponse | null>(null)
|
||||
const [manageOpen, setManageOpen] = useState(false)
|
||||
const [managing, setManaging] = useState<RouteResponse | null>(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<string, string> = {}
|
||||
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) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{row.ownerUserId ? userNameById[row.ownerUserId] ?? 'Unknown' : 'Unassigned'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
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)}
|
||||
/>
|
||||
|
||||
<RouteManageSheet
|
||||
open={manageOpen}
|
||||
onClose={() => setManageOpen(false)}
|
||||
route={managing}
|
||||
onSuccess={() => fetchData(page, pageSize)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<GeolocationPosition> {
|
||||
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<RouteResponse[]>([])
|
||||
const [selectedRouteId, setSelectedRouteId] = useState('')
|
||||
const [storeList, setStoreList] = useState<StoreResponse[]>([])
|
||||
const [loadingStores, setLoadingStores] = useState(false)
|
||||
const [visitState, setVisitState] = useState<Record<string, VisitState>>({})
|
||||
const [deviationByStore, setDeviationByStore] = useState<Record<string, boolean>>({})
|
||||
const [busyStoreId, setBusyStoreId] = useState<string | null>(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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Sales Visits</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Check in and out of scheduled store visits along a route
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 max-w-sm">
|
||||
<Label>Route</Label>
|
||||
<Select value={selectedRouteId} onValueChange={(v) => setSelectedRouteId(v ?? '')}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a route">
|
||||
{routeList.find((r) => r.id === selectedRouteId)?.routeName}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{routeList.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>{r.routeName}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{loadingStores && <p className="text-sm text-muted-foreground">Loading stores…</p>}
|
||||
|
||||
{!loadingStores && selectedRouteId && storeList.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No active stores on this route.</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{storeList.map((store) => {
|
||||
const state = visitState[store.id] ?? 'idle'
|
||||
const isBusy = busyStoreId === store.id
|
||||
return (
|
||||
<div key={store.id} className="flex items-center justify-between rounded-md border p-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{store.storeName}</span>
|
||||
<span className="text-xs text-muted-foreground">{store.storeAddress}</span>
|
||||
{deviationByStore[store.id] && state !== 'idle' && (
|
||||
<Badge variant="destructive" className="w-fit mt-1">Location mismatch</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{state === 'checked-out' && <Badge variant="secondary">Completed</Badge>}
|
||||
{state !== 'checked-out' && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={state === 'checked-in' ? 'outline' : 'default'}
|
||||
disabled={isBusy || state === 'checked-in'}
|
||||
onClick={() => handleCheckIn(store)}
|
||||
>
|
||||
{isBusy && state === 'idle' ? 'Checking in…' : state === 'checked-in' ? 'Checked In' : 'Check In'}
|
||||
</Button>
|
||||
)}
|
||||
{state === 'checked-in' && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={isBusy}
|
||||
onClick={() => handleCheckOut(store)}
|
||||
>
|
||||
{isBusy ? 'Checking out…' : 'Check Out'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SalesVisitsClient } from './_components/sales-visits-client'
|
||||
|
||||
export default function SalesVisitsPage() {
|
||||
return <SalesVisitsClient />
|
||||
}
|
||||
@@ -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<StoreVisitHistoryItem[] | null>(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 (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Visit History — {store.storeName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Check-ins and check-outs logged against this store.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{visits === null ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : visits.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No visits logged yet.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{visits.map((v) => (
|
||||
<li key={v.visitId} className="flex items-center justify-between rounded-md border p-2 text-sm">
|
||||
<span>
|
||||
{new Date(v.visitDate).toLocaleString('en-GB', {
|
||||
day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
<Badge variant={v.outcome === 'Completed' ? 'default' : 'secondary'}>
|
||||
{v.outcome}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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<StoreResponse | null>(null)
|
||||
const [historyOpen, setHistoryOpen] = useState(false)
|
||||
const [viewingHistory, setViewingHistory] = useState<StoreResponse | null>(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)}
|
||||
/>
|
||||
|
||||
<StoreVisitHistorySheet
|
||||
open={historyOpen}
|
||||
onClose={() => setHistoryOpen(false)}
|
||||
store={viewingHistory}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -51,6 +51,7 @@ const MOCK_PERMISSIONS: Record<Role, string[]> = {
|
||||
'routes.view',
|
||||
'rides.view',
|
||||
'driver_profiles.view',
|
||||
'sales_visits.checkin',
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -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<RouteResponse>(`/routes/${id}/stores`, { storeIds }).then(r => r.data),
|
||||
|
||||
assignOwner: (id: string, salesRepId: string) =>
|
||||
api.put<RouteResponse>(`/routes/${id}/owner`, { salesRepId }).then(r => r.data),
|
||||
|
||||
checkIn: (id: string, body: { storeId: string; latitude: number; longitude: number }) =>
|
||||
api.post<RouteVisitResponse>(`/routes/${id}/visits/check-in`, body).then(r => r.data),
|
||||
|
||||
checkOut: (id: string, body: { storeId: string }) =>
|
||||
api.post<RouteVisitResponse>(`/routes/${id}/visits/check-out`, body).then(r => r.data),
|
||||
}
|
||||
|
||||
// ─── Vehicles ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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<StoreResponse>(`/stores/${id}/kyc-status`, { kycStatus }).then(r => r.data),
|
||||
|
||||
visitHistory: (id: string) =>
|
||||
api.get<StoreVisitHistoryItem[]>(`/stores/${id}/visit-history`).then(r => r.data),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user