add sales visits ui
This commit is contained in:
@@ -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 />
|
||||
}
|
||||
Reference in New Issue
Block a user