Update sales pages
This commit is contained in:
+78
-4
@@ -8,9 +8,18 @@ import { DataTable } from '@/components/shared/data-table'
|
|||||||
import type { TableConfig } from '@/components/shared/data-table'
|
import type { TableConfig } from '@/components/shared/data-table'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
import { toast } from '@/components/ui/toast'
|
import { toast } from '@/components/ui/toast'
|
||||||
import { loadingSheets as loadingSheetsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
import { loadingSheets as loadingSheetsApi, rides as ridesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
||||||
import type { LoadingSheetResponse, LoadingSheetStatus } from '@/types/api'
|
import type { LoadingSheetResponse, LoadingSheetStatus, RideResponse } from '@/types/api'
|
||||||
|
|
||||||
|
const ALL = '__all__'
|
||||||
|
|
||||||
import { LoadingSheetViewSheet } from './loading-sheet-view-sheet'
|
import { LoadingSheetViewSheet } from './loading-sheet-view-sheet'
|
||||||
import { LoadingSheetCreateDialog } from './loading-sheet-create-dialog'
|
import { LoadingSheetCreateDialog } from './loading-sheet-create-dialog'
|
||||||
@@ -36,10 +45,25 @@ export function LoadingSheetsClient() {
|
|||||||
const [viewOpen, setViewOpen] = useState(false)
|
const [viewOpen, setViewOpen] = useState(false)
|
||||||
const [selectedSheet, setSelectedSheet] = useState<LoadingSheetResponse | null>(null)
|
const [selectedSheet, setSelectedSheet] = useState<LoadingSheetResponse | null>(null)
|
||||||
|
|
||||||
|
const [ridesList, setRidesList] = useState<RideResponse[]>([])
|
||||||
|
const [rideId, setRideId] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<LoadingSheetStatus | ''>('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ridesApi.list({ pageSize: 200 })
|
||||||
|
.then((res) => setRidesList(getPaginatedItems(res)))
|
||||||
|
.catch((err) => toast.add({ title: 'Failed to load rides', description: getApiError(err), type: 'error' }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const fetchData = useCallback(async (p: number, ps: number) => {
|
const fetchData = useCallback(async (p: number, ps: number) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await loadingSheetsApi.list({ page: p, pageSize: ps })
|
const res = await loadingSheetsApi.list({
|
||||||
|
page: p,
|
||||||
|
pageSize: ps,
|
||||||
|
rideId: rideId || undefined,
|
||||||
|
status: statusFilter || undefined,
|
||||||
|
})
|
||||||
setData(getPaginatedItems(res))
|
setData(getPaginatedItems(res))
|
||||||
setTotal(res.total)
|
setTotal(res.total)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -47,12 +71,19 @@ export function LoadingSheetsClient() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [rideId, statusFilter])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData(page, pageSize)
|
fetchData(page, pageSize)
|
||||||
}, [fetchData, page, pageSize])
|
}, [fetchData, page, pageSize])
|
||||||
|
|
||||||
|
const hasFilters = !!rideId || !!statusFilter
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
setRideId('')
|
||||||
|
setStatusFilter('')
|
||||||
|
}
|
||||||
|
|
||||||
function handleView(row: LoadingSheetResponse) {
|
function handleView(row: LoadingSheetResponse) {
|
||||||
setSelectedSheet(row)
|
setSelectedSheet(row)
|
||||||
setViewOpen(true)
|
setViewOpen(true)
|
||||||
@@ -159,6 +190,49 @@ export function LoadingSheetsClient() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-muted-foreground">Ride</label>
|
||||||
|
<Select
|
||||||
|
value={rideId || ALL}
|
||||||
|
onValueChange={(v) => { setRideId(v === ALL ? '' : v); setPage(1) }}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-52">
|
||||||
|
<SelectValue placeholder="All rides" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ALL}>All rides</SelectItem>
|
||||||
|
{ridesList.map((r) => (
|
||||||
|
<SelectItem key={r.id} value={r.id}>{r.vehicleNumberPlate} — {r.driverFullName}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-muted-foreground">Status</label>
|
||||||
|
<Select
|
||||||
|
value={statusFilter || ALL}
|
||||||
|
onValueChange={(v) => { setStatusFilter(v === ALL ? '' : v as LoadingSheetStatus); setPage(1) }}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue placeholder="All statuses" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ALL}>All statuses</SelectItem>
|
||||||
|
<SelectItem value="Planned">Planned</SelectItem>
|
||||||
|
<SelectItem value="Confirmed">Confirmed</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasFilters && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => { clearFilters(); setPage(1) }}>
|
||||||
|
Clear filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
config={tableConfig}
|
config={tableConfig}
|
||||||
data={data}
|
data={data}
|
||||||
|
|||||||
@@ -0,0 +1,216 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
|
import { ArrowLeft01Icon } from '@hugeicons/core-free-icons'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { shopPos as shopPosApi, getApiError } from '@/lib/api/index'
|
||||||
|
import type { ShopPOResponse, ShopPOStatus } from '@/types/api'
|
||||||
|
|
||||||
|
const PO_STATUS_VARIANT: Record<ShopPOStatus, 'default' | 'secondary' | 'destructive'> = {
|
||||||
|
Pending: 'secondary',
|
||||||
|
PartiallyFulfilled: 'secondary',
|
||||||
|
Fulfilled: 'default',
|
||||||
|
Cancelled: 'destructive',
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
|
||||||
|
const truncId = (id: string) => id.substring(0, 8)
|
||||||
|
|
||||||
|
const fmtDateTime = (v: string | null) =>
|
||||||
|
v
|
||||||
|
? new Date(v).toLocaleString('en-GB', {
|
||||||
|
day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||||
|
})
|
||||||
|
: '—'
|
||||||
|
|
||||||
|
function Stat({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
|
<span className="text-sm font-medium text-foreground">{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ShopPOViewPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const params = useParams<{ id: string }>()
|
||||||
|
const [po, setPo] = useState<ShopPOResponse | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
shopPosApi
|
||||||
|
.get(params.id)
|
||||||
|
.then((res) => {
|
||||||
|
if (!cancelled) setPo(res)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.add({ title: 'Failed to load Shop PO', description: getApiError(err), type: 'error' })
|
||||||
|
router.push('/sales')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [params.id, router])
|
||||||
|
|
||||||
|
async function handleConfirm() {
|
||||||
|
if (!po) return
|
||||||
|
try {
|
||||||
|
const updated = await shopPosApi.confirm(po.id)
|
||||||
|
setPo(updated)
|
||||||
|
toast.add({ title: 'Shop PO confirmed', type: 'success' })
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to confirm Shop PO', description: getApiError(err), type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCancel() {
|
||||||
|
if (!po) return
|
||||||
|
try {
|
||||||
|
const updated = await shopPosApi.cancel(po.id)
|
||||||
|
setPo(updated)
|
||||||
|
toast.add({ title: 'Shop PO cancelled', type: 'success' })
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to cancel Shop PO', description: getApiError(err), type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => router.push('/sales')}>
|
||||||
|
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Loading Shop PO…</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!po) return null
|
||||||
|
|
||||||
|
const activeItems = po.items.filter((i) => i.isActive)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => router.push('/sales')}>
|
||||||
|
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">
|
||||||
|
Shop PO <span className="font-mono text-lg text-muted-foreground">{truncId(po.id)}</span>
|
||||||
|
</h1>
|
||||||
|
<Badge variant={PO_STATUS_VARIANT[po.status]}>{po.status}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{po.storeName}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!po.confirmedAt && po.status !== 'Cancelled' && (
|
||||||
|
<Button type="button" onClick={handleConfirm}>
|
||||||
|
Confirm PO
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{po.status !== 'Cancelled' && po.status !== 'Fulfilled' && (
|
||||||
|
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||||
|
Cancel PO
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 rounded-2xl border p-4 sm:grid-cols-4">
|
||||||
|
<Stat
|
||||||
|
label="Invoice"
|
||||||
|
value={
|
||||||
|
po.invoiceId ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="link"
|
||||||
|
className="h-auto p-0 font-mono text-sm"
|
||||||
|
onClick={() => router.push(`/sales/invoices/${po.invoiceId}`)}
|
||||||
|
>
|
||||||
|
{truncId(po.invoiceId)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
'Not raised yet'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Stat label="Created" value={fmtDateTime(po.createdAt)} />
|
||||||
|
<Stat label="Confirmed" value={fmtDateTime(po.confirmedAt)} />
|
||||||
|
<Stat label="Fulfilled" value={fmtDateTime(po.fulfilledAt)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border p-6">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-sm font-medium text-foreground">Items</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{activeItems.length} item{activeItems.length === 1 ? '' : 's'} on this PO.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-xl border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/50">
|
||||||
|
<tr>
|
||||||
|
<th className="min-w-[220px] px-3 py-2 text-left font-medium">Item</th>
|
||||||
|
<th className="w-28 px-3 py-2 text-right font-medium">Quantity</th>
|
||||||
|
<th className="w-28 px-3 py-2 text-right font-medium">Fulfilled</th>
|
||||||
|
<th className="w-32 px-3 py-2 text-right font-medium">Unit Price</th>
|
||||||
|
<th className="w-32 px-3 py-2 text-right font-medium">Line Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{activeItems.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-3 py-4 text-center text-muted-foreground">
|
||||||
|
No items on this PO.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
activeItems.map((item) => (
|
||||||
|
<tr key={item.id} className="border-t">
|
||||||
|
<td className="px-3 py-2">{item.itemName}</td>
|
||||||
|
<td className="px-3 py-2 text-right">{item.quantity}</td>
|
||||||
|
<td className="px-3 py-2 text-right">{item.quantityFulfilled}</td>
|
||||||
|
<td className="px-3 py-2 text-right">{fmtAmount(item.unitPrice)}</td>
|
||||||
|
<td className="px-3 py-2 text-right font-medium">{fmtAmount(item.quantity * item.unitPrice)}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
{activeItems.length > 0 && (
|
||||||
|
<tfoot>
|
||||||
|
<tr className="border-t bg-muted/30">
|
||||||
|
<td colSpan={4} className="px-3 py-3 text-right text-sm font-medium text-foreground">
|
||||||
|
Total
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-3 text-right text-base font-semibold text-foreground">
|
||||||
|
{fmtAmount(po.totalAmount)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
)}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import { useRouter } from 'next/navigation'
|
||||||
import { HugeiconsIcon } from '@hugeicons/react'
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
import { Add01Icon } from '@hugeicons/core-free-icons'
|
import { Add01Icon } from '@hugeicons/core-free-icons'
|
||||||
|
|
||||||
@@ -8,12 +9,26 @@ import { DataTable } from '@/components/shared/data-table'
|
|||||||
import type { TableConfig } from '@/components/shared/data-table'
|
import type { TableConfig } from '@/components/shared/data-table'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select'
|
||||||
import { toast } from '@/components/ui/toast'
|
import { toast } from '@/components/ui/toast'
|
||||||
import { shopPos as shopPosApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
import {
|
||||||
import type { ShopPOResponse, ShopPOStatus } from '@/types/api'
|
shopPos as shopPosApi,
|
||||||
|
users as usersApi,
|
||||||
|
routes as routesApi,
|
||||||
|
stores as storesApi,
|
||||||
|
getApiError,
|
||||||
|
getPaginatedItems,
|
||||||
|
} from '@/lib/api/index'
|
||||||
|
import type { ShopPOResponse, ShopPOStatus, UserResponse, RouteResponse, StoreResponse } from '@/types/api'
|
||||||
|
|
||||||
import { ShopPOViewSheet } from './shop-po-view-sheet'
|
const ALL = '__all__'
|
||||||
import { ShopPOCreateSheet } from './shop-po-create-sheet'
|
type ConfirmedFilter = 'all' | 'confirmed' | 'unconfirmed'
|
||||||
|
|
||||||
const fmtDate = (v: string) =>
|
const fmtDate = (v: string) =>
|
||||||
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||||
@@ -29,19 +44,47 @@ const PO_STATUS_VARIANT: Record<ShopPOStatus, 'default' | 'secondary' | 'destruc
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SalesClient() {
|
export function SalesClient() {
|
||||||
|
const router = useRouter()
|
||||||
const [data, setData] = useState<ShopPOResponse[]>([])
|
const [data, setData] = useState<ShopPOResponse[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [pageSize, setPageSize] = useState(20)
|
const [pageSize, setPageSize] = useState(20)
|
||||||
const [viewOpen, setViewOpen] = useState(false)
|
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [agentsList, setAgentsList] = useState<UserResponse[]>([])
|
||||||
const [selectedPO, setSelectedPO] = useState<ShopPOResponse | null>(null)
|
const [routesList, setRoutesList] = useState<RouteResponse[]>([])
|
||||||
|
const [storesList, setStoresList] = useState<StoreResponse[]>([])
|
||||||
|
|
||||||
|
const [agentId, setAgentId] = useState('')
|
||||||
|
const [routeId, setRouteId] = useState('')
|
||||||
|
const [shopId, setShopId] = useState('')
|
||||||
|
const [confirmedFilter, setConfirmedFilter] = useState<ConfirmedFilter>('all')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
usersApi.list({ pageSize: 200 }),
|
||||||
|
routesApi.list({ pageSize: 200 }),
|
||||||
|
storesApi.list({ pageSize: 200 }),
|
||||||
|
])
|
||||||
|
.then(([u, r, s]) => {
|
||||||
|
setAgentsList(getPaginatedItems(u))
|
||||||
|
setRoutesList(getPaginatedItems(r))
|
||||||
|
setStoresList(getPaginatedItems(s))
|
||||||
|
})
|
||||||
|
.catch((err) => toast.add({ title: 'Failed to load filter options', description: getApiError(err), type: 'error' }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
const fetchData = useCallback(async (p: number, ps: number) => {
|
const fetchData = useCallback(async (p: number, ps: number) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await shopPosApi.list({ page: p, pageSize: ps })
|
const res = await shopPosApi.list({
|
||||||
|
page: p,
|
||||||
|
pageSize: ps,
|
||||||
|
agentId: agentId || undefined,
|
||||||
|
routeId: routeId || undefined,
|
||||||
|
shopId: shopId || undefined,
|
||||||
|
confirmed: confirmedFilter === 'all' ? undefined : confirmedFilter === 'confirmed',
|
||||||
|
})
|
||||||
setData(getPaginatedItems(res))
|
setData(getPaginatedItems(res))
|
||||||
setTotal(res.total)
|
setTotal(res.total)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -49,15 +92,23 @@ export function SalesClient() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [agentId, routeId, shopId, confirmedFilter])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchData(page, pageSize)
|
fetchData(page, pageSize)
|
||||||
}, [fetchData, page, pageSize])
|
}, [fetchData, page, pageSize])
|
||||||
|
|
||||||
|
const hasFilters = !!agentId || !!routeId || !!shopId || confirmedFilter !== 'all'
|
||||||
|
|
||||||
|
function clearFilters() {
|
||||||
|
setAgentId('')
|
||||||
|
setRouteId('')
|
||||||
|
setShopId('')
|
||||||
|
setConfirmedFilter('all')
|
||||||
|
}
|
||||||
|
|
||||||
function handleView(row: ShopPOResponse) {
|
function handleView(row: ShopPOResponse) {
|
||||||
setSelectedPO(row)
|
router.push(`/sales/${row.id}`)
|
||||||
setViewOpen(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleConfirm(row: ShopPOResponse) {
|
async function handleConfirm(row: ShopPOResponse) {
|
||||||
@@ -170,12 +221,91 @@ export function SalesClient() {
|
|||||||
Purchase orders raised by agents on behalf of stores
|
Purchase orders raised by agents on behalf of stores
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setCreateOpen(true)}>
|
<Button onClick={() => router.push('/sales/new')}>
|
||||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||||
New PO
|
New PO
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-muted-foreground">Agent</label>
|
||||||
|
<Select
|
||||||
|
value={agentId || ALL}
|
||||||
|
onValueChange={(v) => { setAgentId(v === ALL ? '' : v); setPage(1) }}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-44">
|
||||||
|
<SelectValue placeholder="All agents" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ALL}>All agents</SelectItem>
|
||||||
|
{agentsList.map((u) => (
|
||||||
|
<SelectItem key={u.id} value={u.id}>{u.fullName}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-muted-foreground">Route</label>
|
||||||
|
<Select
|
||||||
|
value={routeId || ALL}
|
||||||
|
onValueChange={(v) => { setRouteId(v === ALL ? '' : v); setPage(1) }}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-44">
|
||||||
|
<SelectValue placeholder="All routes" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ALL}>All routes</SelectItem>
|
||||||
|
{routesList.map((r) => (
|
||||||
|
<SelectItem key={r.id} value={r.id}>{r.routeName}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-muted-foreground">Store</label>
|
||||||
|
<Select
|
||||||
|
value={shopId || ALL}
|
||||||
|
onValueChange={(v) => { setShopId(v === ALL ? '' : v); setPage(1) }}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-44">
|
||||||
|
<SelectValue placeholder="All stores" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={ALL}>All stores</SelectItem>
|
||||||
|
{storesList.map((s) => (
|
||||||
|
<SelectItem key={s.id} value={s.id}>{s.storeName}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<label className="text-xs text-muted-foreground">Confirmation</label>
|
||||||
|
<Select
|
||||||
|
value={confirmedFilter}
|
||||||
|
onValueChange={(v) => { setConfirmedFilter(v as ConfirmedFilter); setPage(1) }}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-40">
|
||||||
|
<SelectValue placeholder="All" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All</SelectItem>
|
||||||
|
<SelectItem value="confirmed">Confirmed</SelectItem>
|
||||||
|
<SelectItem value="unconfirmed">Unconfirmed</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasFilters && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => { clearFilters(); setPage(1) }}>
|
||||||
|
Clear filters
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
config={tableConfig}
|
config={tableConfig}
|
||||||
data={data}
|
data={data}
|
||||||
@@ -186,18 +316,6 @@ export function SalesClient() {
|
|||||||
setPageSize(p.pageSize)
|
setPageSize(p.pageSize)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ShopPOCreateSheet
|
|
||||||
open={createOpen}
|
|
||||||
onClose={() => setCreateOpen(false)}
|
|
||||||
onSuccess={() => fetchData(page, pageSize)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ShopPOViewSheet
|
|
||||||
open={viewOpen}
|
|
||||||
onClose={() => setViewOpen(false)}
|
|
||||||
po={selectedPO}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,217 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { useForm, zodResolver, z } from '@/lib/form'
|
|
||||||
import { FormField } from '@/components/shared/form-field'
|
|
||||||
import { Input } from '@/components/ui/input'
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { FieldGroup } from '@/components/ui/field'
|
|
||||||
import { toast } from '@/components/ui/toast'
|
|
||||||
import { shopPos as shopPosApi, stores as storesApi, items as itemsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
|
|
||||||
import type { StoreResponse, ItemSummaryResponse } from '@/types/api'
|
|
||||||
|
|
||||||
const itemLineSchema = z.object({
|
|
||||||
itemId: z.string().min(1, 'Item required'),
|
|
||||||
quantity: z.number().int().min(1, 'Min 1'),
|
|
||||||
unitPrice: z.number().min(0, 'Price must be 0 or more'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
storeId: z.string().min(1, 'Store is required'),
|
|
||||||
items: z.array(itemLineSchema).min(1, 'At least one item is required'),
|
|
||||||
})
|
|
||||||
|
|
||||||
type FormValues = z.infer<typeof schema>
|
|
||||||
|
|
||||||
interface ShopPOCreateSheetProps {
|
|
||||||
open: boolean
|
|
||||||
onClose: () => void
|
|
||||||
onSuccess: () => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const emptyLine = { itemId: '', quantity: 1, unitPrice: 0 }
|
|
||||||
|
|
||||||
export function ShopPOCreateSheet({ open, onClose, onSuccess }: ShopPOCreateSheetProps) {
|
|
||||||
const [storesList, setStoresList] = useState<StoreResponse[]>([])
|
|
||||||
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
|
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
|
||||||
resolver: zodResolver(schema),
|
|
||||||
defaultValues: { storeId: '', items: [emptyLine] },
|
|
||||||
})
|
|
||||||
|
|
||||||
const watchedItems = form.watch('items')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return
|
|
||||||
form.reset({ storeId: '', items: [emptyLine] })
|
|
||||||
Promise.all([
|
|
||||||
storesApi.list({ pageSize: 200 }),
|
|
||||||
itemsApi.list({ pageSize: 200 }),
|
|
||||||
])
|
|
||||||
.then(([s, i]) => {
|
|
||||||
setStoresList(getPaginatedItems(s))
|
|
||||||
setItemsList(getPaginatedItems(i))
|
|
||||||
})
|
|
||||||
.catch((err) => toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }))
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
function addLine() {
|
|
||||||
form.setValue('items', [...watchedItems, emptyLine])
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeLine(idx: number) {
|
|
||||||
form.setValue('items', watchedItems.filter((_, i) => i !== idx))
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onSubmit(values: FormValues) {
|
|
||||||
try {
|
|
||||||
await shopPosApi.create(values)
|
|
||||||
toast.add({ title: 'Shop PO created', type: 'success' })
|
|
||||||
onSuccess()
|
|
||||||
onClose()
|
|
||||||
} catch (err) {
|
|
||||||
toast.add({ title: 'Failed to create Shop PO', description: getApiError(err), type: 'error' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
|
||||||
<DialogContent className="overflow-y-auto sm:max-w-2xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>New Shop PO</DialogTitle>
|
|
||||||
<DialogDescription>Raise a purchase order on behalf of a store.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
|
||||||
<FieldGroup>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="storeId"
|
|
||||||
label="Store"
|
|
||||||
required
|
|
||||||
render={({ field }) => (
|
|
||||||
<Select value={field.value as string} onValueChange={field.onChange}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select store..." />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="" disabled>Select store...</SelectItem>
|
|
||||||
{storesList.map((s) => (
|
|
||||||
<SelectItem key={s.id} value={s.id}>
|
|
||||||
{s.storeName} — {s.ownerName}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</FieldGroup>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<p className="text-sm font-medium">Line Items</p>
|
|
||||||
<Button type="button" variant="outline" size="sm" onClick={addLine}>
|
|
||||||
+ Add Item
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{watchedItems.map((_, idx) => (
|
|
||||||
<div key={idx} className="grid grid-cols-[1fr_80px_100px_36px] gap-2 items-end">
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name={`items.${idx}.itemId`}
|
|
||||||
label={idx === 0 ? 'Item' : undefined}
|
|
||||||
render={({ field }) => (
|
|
||||||
<Select value={field.value as string} onValueChange={field.onChange}>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select item..." />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="" disabled>Select item...</SelectItem>
|
|
||||||
{itemsList.map((item) => (
|
|
||||||
<SelectItem key={item.id} value={item.id}>
|
|
||||||
{item.name} ({item.sku})
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name={`items.${idx}.quantity`}
|
|
||||||
label={idx === 0 ? 'Qty' : undefined}
|
|
||||||
render={({ field, invalid }) => (
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={Number(field.value as number)}
|
|
||||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
aria-invalid={invalid}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name={`items.${idx}.unitPrice`}
|
|
||||||
label={idx === 0 ? 'Unit Price' : undefined}
|
|
||||||
render={({ field, invalid }) => (
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
min={0}
|
|
||||||
value={Number(field.value as number)}
|
|
||||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
|
||||||
onBlur={field.onBlur}
|
|
||||||
aria-invalid={invalid}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className={idx === 0 ? 'mt-6' : ''}>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => removeLine(idx)}
|
|
||||||
disabled={watchedItems.length === 1}
|
|
||||||
className="h-9 w-9 p-0 text-muted-foreground hover:text-destructive"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
|
||||||
{form.formState.isSubmitting ? 'Creating…' : 'Create PO'}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { Separator } from '@/components/ui/separator'
|
|
||||||
import type { ShopPOResponse, ShopPOStatus } from '@/types/api'
|
|
||||||
|
|
||||||
const PO_STATUS_VARIANT: Record<ShopPOStatus, 'default' | 'secondary' | 'destructive'> = {
|
|
||||||
Pending: 'secondary',
|
|
||||||
PartiallyFulfilled: 'secondary',
|
|
||||||
Fulfilled: 'default',
|
|
||||||
Cancelled: 'destructive',
|
|
||||||
}
|
|
||||||
|
|
||||||
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
|
|
||||||
const truncId = (id: string) => id.substring(0, 8)
|
|
||||||
|
|
||||||
const fmtDateTime = (v: string | null) =>
|
|
||||||
v
|
|
||||||
? new Date(v).toLocaleString('en-GB', {
|
|
||||||
day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
|
||||||
})
|
|
||||||
: '—'
|
|
||||||
|
|
||||||
interface ShopPOViewSheetProps {
|
|
||||||
open: boolean
|
|
||||||
onClose: () => void
|
|
||||||
po: ShopPOResponse | null
|
|
||||||
}
|
|
||||||
|
|
||||||
function Row({ label, value }: { label: string; value: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between py-2 text-sm border-b last:border-0">
|
|
||||||
<span className="text-muted-foreground">{label}</span>
|
|
||||||
<span className="font-medium text-right">{value}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ShopPOViewSheet({ open, onClose, po }: ShopPOViewSheetProps) {
|
|
||||||
if (!po) return null
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
|
||||||
<DialogContent className="overflow-y-auto sm:max-w-2xl">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Shop PO Details</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<Row label="PO ID" value={<span className="font-mono">{truncId(po.id)}</span>} />
|
|
||||||
<Row label="Store" value={po.storeName} />
|
|
||||||
<Row label="Status" value={<Badge variant={PO_STATUS_VARIANT[po.status]}>{po.status}</Badge>} />
|
|
||||||
<Row label="Total Amount" value={fmtAmount(po.totalAmount)} />
|
|
||||||
<Row label="Invoice" value={po.invoiceId ? <span className="font-mono">{truncId(po.invoiceId)}</span> : 'No invoice yet'} />
|
|
||||||
<Row label="Confirmed At" value={fmtDateTime(po.confirmedAt)} />
|
|
||||||
<Row label="Fulfilled At" value={fmtDateTime(po.fulfilledAt)} />
|
|
||||||
<Row label="Created At" value={fmtDateTime(po.createdAt)} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium mb-2">Line Items ({po.items.filter(i => i.isActive).length})</p>
|
|
||||||
<div className="overflow-x-auto rounded-md border">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead className="bg-muted/50">
|
|
||||||
<tr>
|
|
||||||
<th className="text-left font-medium px-3 py-2">Item</th>
|
|
||||||
<th className="text-right font-medium px-3 py-2">Qty</th>
|
|
||||||
<th className="text-right font-medium px-3 py-2">Fulfilled</th>
|
|
||||||
<th className="text-right font-medium px-3 py-2">Unit Price</th>
|
|
||||||
<th className="text-right font-medium px-3 py-2">Line Total</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{po.items.filter(i => i.isActive).length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="text-center text-muted-foreground px-3 py-4">
|
|
||||||
No items on this PO.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
po.items.filter(i => i.isActive).map((item) => (
|
|
||||||
<tr key={item.id} className="border-t">
|
|
||||||
<td className="px-3 py-2">{item.itemName}</td>
|
|
||||||
<td className="text-right px-3 py-2">{item.quantity}</td>
|
|
||||||
<td className="text-right px-3 py-2">{item.quantityFulfilled}</td>
|
|
||||||
<td className="text-right px-3 py-2">{fmtAmount(item.unitPrice)}</td>
|
|
||||||
<td className="text-right px-3 py-2 font-medium">{fmtAmount(item.quantity * item.unitPrice)}</td>
|
|
||||||
</tr>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
|
import { HugeiconsIcon } from '@hugeicons/react'
|
||||||
|
import { ArrowLeft01Icon, Add01Icon } from '@hugeicons/core-free-icons'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { invoices as invoicesApi, payments as paymentsApi, getApiError } from '@/lib/api/index'
|
||||||
|
import type { InvoiceResponse, InvoiceStatus, PaymentResponse, PaymentMethod, PaymentStatus } from '@/types/api'
|
||||||
|
|
||||||
|
import { PaymentSheet } from '../../payments/_components/payment-sheet'
|
||||||
|
import { PaymentViewSheet } from '../../payments/_components/payment-view-sheet'
|
||||||
|
|
||||||
|
const INVOICE_STATUS_VARIANT: Record<InvoiceStatus, 'default' | 'secondary' | 'outline'> = {
|
||||||
|
Draft: 'secondary',
|
||||||
|
Printed: 'outline',
|
||||||
|
Finalized: 'default',
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAYMENT_STATUS_VARIANT: Record<PaymentStatus, 'default' | 'secondary' | 'destructive'> = {
|
||||||
|
Pending: 'destructive',
|
||||||
|
PartiallyPaid: 'secondary',
|
||||||
|
Paid: 'default',
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAYMENT_METHOD_VARIANT: Record<PaymentMethod, 'default' | 'secondary' | 'outline'> = {
|
||||||
|
Cash: 'default',
|
||||||
|
Cheque: 'outline',
|
||||||
|
Credit: 'secondary',
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
|
||||||
|
const truncId = (id: string) => id.substring(0, 8)
|
||||||
|
|
||||||
|
const fmtDateTime = (v: string | null) =>
|
||||||
|
v
|
||||||
|
? new Date(v).toLocaleString('en-GB', {
|
||||||
|
day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||||
|
})
|
||||||
|
: '—'
|
||||||
|
|
||||||
|
function Stat({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-xs text-muted-foreground">{label}</span>
|
||||||
|
<span className="text-sm font-medium text-foreground">{value}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InvoiceViewPage() {
|
||||||
|
const router = useRouter()
|
||||||
|
const params = useParams<{ id: string }>()
|
||||||
|
const [invoice, setInvoice] = useState<InvoiceResponse | null>(null)
|
||||||
|
const [payments, setPayments] = useState<PaymentResponse[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [paymentSheetOpen, setPaymentSheetOpen] = useState(false)
|
||||||
|
const [viewPayment, setViewPayment] = useState<PaymentResponse | null>(null)
|
||||||
|
|
||||||
|
const fetchInvoice = useCallback(async () => {
|
||||||
|
const inv = await invoicesApi.get(params.id)
|
||||||
|
setInvoice(inv)
|
||||||
|
return inv
|
||||||
|
}, [params.id])
|
||||||
|
|
||||||
|
const fetchPayments = useCallback(async () => {
|
||||||
|
const list = await paymentsApi.listByInvoice(params.id)
|
||||||
|
setPayments(list)
|
||||||
|
}, [params.id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
setLoading(true)
|
||||||
|
Promise.all([fetchInvoice(), fetchPayments()])
|
||||||
|
.catch((err) => {
|
||||||
|
toast.add({ title: 'Failed to load invoice', description: getApiError(err), type: 'error' })
|
||||||
|
router.push('/sales')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [fetchInvoice, fetchPayments, router])
|
||||||
|
|
||||||
|
async function handlePrint() {
|
||||||
|
if (!invoice) return
|
||||||
|
try {
|
||||||
|
const updated = await invoicesApi.print(invoice.id)
|
||||||
|
setInvoice(updated)
|
||||||
|
toast.add({ title: 'Invoice printed', type: 'success' })
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to print invoice', description: getApiError(err), type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFinalize() {
|
||||||
|
if (!invoice) return
|
||||||
|
try {
|
||||||
|
const updated = await invoicesApi.finalize(invoice.id)
|
||||||
|
setInvoice(updated)
|
||||||
|
toast.add({ title: 'Invoice finalized', type: 'success' })
|
||||||
|
} catch (err) {
|
||||||
|
toast.add({ title: 'Failed to finalize invoice', description: getApiError(err), type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => router.push('/sales')}>
|
||||||
|
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Loading invoice…</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!invoice) return null
|
||||||
|
|
||||||
|
const paidAmount = payments.reduce((sum, p) => sum + p.amount, 0)
|
||||||
|
const balance = invoice.totalAmount - paidAmount
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={() => router.push(`/sales/${invoice.shopPOId}`)}>
|
||||||
|
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
|
||||||
|
Back to PO
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground">
|
||||||
|
Invoice <span className="font-mono text-lg text-muted-foreground">{truncId(invoice.id)}</span>
|
||||||
|
</h1>
|
||||||
|
<Badge variant={INVOICE_STATUS_VARIANT[invoice.status]}>{invoice.status}</Badge>
|
||||||
|
<Badge variant={PAYMENT_STATUS_VARIANT[invoice.paymentStatus]}>{invoice.paymentStatus}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">{invoice.storeName}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{invoice.status === 'Draft' && (
|
||||||
|
<Button type="button" onClick={handlePrint}>
|
||||||
|
Print Invoice
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{invoice.status === 'Printed' && (
|
||||||
|
<Button type="button" onClick={handleFinalize}>
|
||||||
|
Finalize Invoice
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{invoice.paymentStatus !== 'Paid' && (
|
||||||
|
<Button type="button" variant="outline" onClick={() => setPaymentSheetOpen(true)}>
|
||||||
|
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||||
|
Record Payment
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 rounded-2xl border p-4 sm:grid-cols-4">
|
||||||
|
<Stat label="Total" value={fmtAmount(invoice.totalAmount)} />
|
||||||
|
<Stat label="Paid" value={fmtAmount(paidAmount)} />
|
||||||
|
<Stat label="Balance" value={fmtAmount(balance)} />
|
||||||
|
<Stat label="Created" value={fmtDateTime(invoice.createdAt)} />
|
||||||
|
<Stat label="Printed" value={fmtDateTime(invoice.printedAt)} />
|
||||||
|
<Stat label="Finalized" value={fmtDateTime(invoice.finalizedAt)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border p-6">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-sm font-medium text-foreground">Payments</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{payments.length} payment{payments.length === 1 ? '' : 's'} recorded against this invoice.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-xl border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted/50">
|
||||||
|
<tr>
|
||||||
|
<th className="min-w-[120px] px-3 py-2 text-left font-medium">Payment ID</th>
|
||||||
|
<th className="w-32 px-3 py-2 text-right font-medium">Amount</th>
|
||||||
|
<th className="w-28 px-3 py-2 text-left font-medium">Method</th>
|
||||||
|
<th className="w-36 px-3 py-2 text-left font-medium">Collected At</th>
|
||||||
|
<th className="w-10 px-3 py-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{payments.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-3 py-4 text-center text-muted-foreground">
|
||||||
|
No payments recorded yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
payments.map((payment) => (
|
||||||
|
<tr key={payment.id} className="border-t">
|
||||||
|
<td className="px-3 py-2 font-mono">{truncId(payment.id)}</td>
|
||||||
|
<td className="px-3 py-2 text-right font-medium">{fmtAmount(payment.amount)}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Badge variant={PAYMENT_METHOD_VARIANT[payment.method]}>{payment.method}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">{fmtDateTime(payment.collectedAt)}</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
<Button type="button" variant="ghost" size="sm" onClick={() => setViewPayment(payment)}>
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PaymentSheet
|
||||||
|
open={paymentSheetOpen}
|
||||||
|
onClose={() => setPaymentSheetOpen(false)}
|
||||||
|
onSuccess={() => {
|
||||||
|
fetchPayments()
|
||||||
|
fetchInvoice()
|
||||||
|
}}
|
||||||
|
invoices={[invoice]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PaymentViewSheet
|
||||||
|
open={!!viewPayment}
|
||||||
|
onClose={() => setViewPayment(null)}
|
||||||
|
payment={viewPayment}
|
||||||
|
invoiceName={`${invoice.storeName} — ${truncId(invoice.id)}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user