Add region management dialog for targets, budgets, tax rules & agencies
RegionManageSheet lets a manager set a region's target/budget for a period, save a tax rule config, map a RegionalAgency user to the region, and view its route/store hierarchy and dashboard stats. Wired into the regions table as a new row action. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,276 @@
|
|||||||
|
'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 {
|
||||||
|
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 { regions as regionsApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||||
|
import type {
|
||||||
|
RegionResponse,
|
||||||
|
UserResponse,
|
||||||
|
RegionDashboardResponse,
|
||||||
|
RegionHierarchyResponse,
|
||||||
|
} from '@/types/api'
|
||||||
|
|
||||||
|
interface RegionManageSheetProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
region: RegionResponse | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentPeriod() {
|
||||||
|
const now = new Date()
|
||||||
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RegionManageSheet({ open, onClose, region }: RegionManageSheetProps) {
|
||||||
|
const [period, setPeriod] = useState(currentPeriod())
|
||||||
|
const [targetValue, setTargetValue] = useState('')
|
||||||
|
const [budgetAmount, setBudgetAmount] = useState('')
|
||||||
|
const [ruleConfig, setRuleConfig] = useState('')
|
||||||
|
const [agencyUserId, setAgencyUserId] = useState('')
|
||||||
|
const [agencyUsers, setAgencyUsers] = useState<UserResponse[]>([])
|
||||||
|
const [dashboard, setDashboard] = useState<RegionDashboardResponse | null>(null)
|
||||||
|
const [hierarchy, setHierarchy] = useState<RegionHierarchyResponse | null>(null)
|
||||||
|
const [saving, setSaving] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !region) return
|
||||||
|
|
||||||
|
setPeriod(currentPeriod())
|
||||||
|
setTargetValue('')
|
||||||
|
setBudgetAmount('')
|
||||||
|
setRuleConfig('')
|
||||||
|
setAgencyUserId('')
|
||||||
|
|
||||||
|
usersApi.list({ page: 1, pageSize: 200 })
|
||||||
|
.then((res) => setAgencyUsers(getPaginatedItems(res).filter((u) => u.roleName === 'RegionalAgency')))
|
||||||
|
.catch(() => toast.add({ title: 'Failed to load agency users', type: 'error' }))
|
||||||
|
|
||||||
|
regionsApi.hierarchy(region.id)
|
||||||
|
.then(setHierarchy)
|
||||||
|
.catch(() => toast.add({ title: 'Failed to load hierarchy', type: 'error' }))
|
||||||
|
}, [open, region])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !region) return
|
||||||
|
regionsApi.dashboard(region.id, period)
|
||||||
|
.then(setDashboard)
|
||||||
|
.catch(() => toast.add({ title: 'Failed to load dashboard', type: 'error' }))
|
||||||
|
}, [open, region, period])
|
||||||
|
|
||||||
|
if (!region) return null
|
||||||
|
|
||||||
|
async function handleSetTarget() {
|
||||||
|
const value = parseFloat(targetValue)
|
||||||
|
if (Number.isNaN(value)) return
|
||||||
|
setSaving('target')
|
||||||
|
try {
|
||||||
|
await regionsApi.setTarget(region!.id, period, value)
|
||||||
|
toast.add({ title: 'Target updated', type: 'success' })
|
||||||
|
regionsApi.dashboard(region!.id, period).then(setDashboard)
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to set target', description: getApiError(err), type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setSaving(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSetBudget() {
|
||||||
|
const value = parseFloat(budgetAmount)
|
||||||
|
if (Number.isNaN(value)) return
|
||||||
|
setSaving('budget')
|
||||||
|
try {
|
||||||
|
await regionsApi.setBudget(region!.id, period, value)
|
||||||
|
toast.add({ title: 'Budget updated', type: 'success' })
|
||||||
|
regionsApi.dashboard(region!.id, period).then(setDashboard)
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to set budget', description: getApiError(err), type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setSaving(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSetTaxRule() {
|
||||||
|
if (!ruleConfig.trim()) return
|
||||||
|
setSaving('tax-rule')
|
||||||
|
try {
|
||||||
|
await regionsApi.setTaxRule(region!.id, ruleConfig)
|
||||||
|
toast.add({ title: 'Tax rule updated', type: 'success' })
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to set tax rule', description: getApiError(err), type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setSaving(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMapAgency() {
|
||||||
|
if (!agencyUserId) return
|
||||||
|
setSaving('agency')
|
||||||
|
try {
|
||||||
|
await regionsApi.mapAgency(region!.id, agencyUserId)
|
||||||
|
toast.add({ title: 'Agency mapped to region', type: 'success' })
|
||||||
|
setAgencyUserId('')
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to map agency', description: getApiError(err), type: 'error' })
|
||||||
|
} finally {
|
||||||
|
setSaving(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
|
<DialogContent className="overflow-y-auto sm:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Manage {region.name}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Targets, budgets, tax rules, agency mapping, and dashboard for this region.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="period">Period</Label>
|
||||||
|
<Input
|
||||||
|
id="period"
|
||||||
|
type="month"
|
||||||
|
value={period}
|
||||||
|
onChange={(e) => setPeriod(e.target.value)}
|
||||||
|
className="w-40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Regional Target</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="e.g. 500000"
|
||||||
|
value={targetValue}
|
||||||
|
onChange={(e) => setTargetValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button type="button" onClick={handleSetTarget} disabled={saving === 'target'}>
|
||||||
|
{saving === 'target' ? 'Saving…' : 'Set Target'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{dashboard?.targetValue != null && (
|
||||||
|
<p className="text-xs text-muted-foreground">Current: {dashboard.targetValue}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Regional Budget</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="e.g. 200000"
|
||||||
|
value={budgetAmount}
|
||||||
|
onChange={(e) => setBudgetAmount(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button type="button" onClick={handleSetBudget} disabled={saving === 'budget'}>
|
||||||
|
{saving === 'budget' ? 'Saving…' : 'Set Budget'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{dashboard?.budgetAmount != null && (
|
||||||
|
<p className="text-xs text-muted-foreground">Current: {dashboard.budgetAmount}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Tax Rule Config (JSON)</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder='e.g. {"rate": 0.15}'
|
||||||
|
value={ruleConfig}
|
||||||
|
onChange={(e) => setRuleConfig(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button type="button" onClick={handleSetTaxRule} disabled={saving === 'tax-rule'}>
|
||||||
|
{saving === 'tax-rule' ? 'Saving…' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Map Regional Agency</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Select value={agencyUserId} onValueChange={(v) => setAgencyUserId(v ?? '')}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select an agency user">
|
||||||
|
{agencyUsers.find((u) => u.id === agencyUserId)?.fullName}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{agencyUsers.map((u) => (
|
||||||
|
<SelectItem key={u.id} value={u.id}>{u.fullName}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button type="button" onClick={handleMapAgency} disabled={saving === 'agency'}>
|
||||||
|
{saving === 'agency' ? 'Saving…' : 'Map'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{agencyUsers.length === 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">No users with the RegionalAgency role found.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Hierarchy</Label>
|
||||||
|
{hierarchy && hierarchy.routes.length > 0 ? (
|
||||||
|
<ul className="text-sm space-y-1">
|
||||||
|
{hierarchy.routes.map((r) => (
|
||||||
|
<li key={r.routeId}>
|
||||||
|
{r.routeName} — {r.storeIds.length} store{r.storeIds.length === 1 ? '' : 's'}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">No routes under this region yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Dashboard ({period})</Label>
|
||||||
|
{dashboard ? (
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div>Orders: {dashboard.orderCount}</div>
|
||||||
|
<div>Actual Sales: {dashboard.actualSales}</div>
|
||||||
|
<div>Target: {dashboard.targetValue ?? '—'}</div>
|
||||||
|
<div>Budget: {dashboard.budgetAmount ?? '—'}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">Loading dashboard…</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import { regions as regionsApi, getApiError, getPaginatedItems } from '@/lib/api
|
|||||||
import type { RegionResponse } from '@/types/api'
|
import type { RegionResponse } from '@/types/api'
|
||||||
|
|
||||||
import { RegionSheet } from './region-sheet'
|
import { RegionSheet } from './region-sheet'
|
||||||
|
import { RegionManageSheet } from './region-manage-sheet'
|
||||||
|
|
||||||
export function RegionsClient() {
|
export function RegionsClient() {
|
||||||
const [data, setData] = useState<RegionResponse[]>([])
|
const [data, setData] = useState<RegionResponse[]>([])
|
||||||
@@ -24,6 +25,9 @@ export function RegionsClient() {
|
|||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
const [editing, setEditing] = useState<RegionResponse | null>(null)
|
const [editing, setEditing] = useState<RegionResponse | null>(null)
|
||||||
|
|
||||||
|
const [manageOpen, setManageOpen] = useState(false)
|
||||||
|
const [managing, setManaging] = useState<RegionResponse | null>(null)
|
||||||
|
|
||||||
const fetchData = useCallback(async (p: number, ps: number) => {
|
const fetchData = useCallback(async (p: number, ps: number) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
@@ -51,6 +55,11 @@ export function RegionsClient() {
|
|||||||
setSheetOpen(true)
|
setSheetOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openManage(region: RegionResponse) {
|
||||||
|
setManaging(region)
|
||||||
|
setManageOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleActive(region: RegionResponse) {
|
async function toggleActive(region: RegionResponse) {
|
||||||
try {
|
try {
|
||||||
await regionsApi.update(region.id, { isActive: !region.isActive })
|
await regionsApi.update(region.id, { isActive: !region.isActive })
|
||||||
@@ -103,6 +112,11 @@ export function RegionsClient() {
|
|||||||
],
|
],
|
||||||
rowActions: {
|
rowActions: {
|
||||||
actions: () => [
|
actions: () => [
|
||||||
|
{
|
||||||
|
action: 'confirm',
|
||||||
|
label: 'Manage targets, budgets & agencies',
|
||||||
|
onClick: openManage,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
action: 'edit',
|
action: 'edit',
|
||||||
label: 'Edit region',
|
label: 'Edit region',
|
||||||
@@ -122,7 +136,7 @@ export function RegionsClient() {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}), [pageSize, openEdit, toggleActive])
|
}), [pageSize, openEdit, openManage, toggleActive])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -156,6 +170,12 @@ export function RegionsClient() {
|
|||||||
region={editing}
|
region={editing}
|
||||||
onSuccess={() => fetchData(page, pageSize)}
|
onSuccess={() => fetchData(page, pageSize)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<RegionManageSheet
|
||||||
|
open={manageOpen}
|
||||||
|
onClose={() => setManageOpen(false)}
|
||||||
|
region={managing}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user