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,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)}
/>
</>
)
}