add sales visits ui

This commit is contained in:
2026-08-31 13:00:33 +05:30
parent 5c4e1b590a
commit da9caa855a
11 changed files with 518 additions and 3 deletions
@@ -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 />
}