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