Merge dev into feat-notification
CI/CD - ERP Distribution / Build & Deploy Backend (pull_request) Failing after 12m21s
CI/CD - ERP Distribution / Build & Deploy Frontend (pull_request) Failing after 16m5s

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-10-20 17:19:51 +05:30
28 changed files with 7310 additions and 88 deletions
@@ -0,0 +1,84 @@
'use client'
import { useEffect, useState } from 'react'
import { HugeiconsIcon } from '@hugeicons/react'
import { ReceiptDollarIcon } from '@hugeicons/core-free-icons'
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { FieldDescription } from '@/components/ui/field'
import { toast } from '@/components/ui/toast'
import {
invoiceCreationMode as invoiceCreationModeApi,
DEFAULT_INVOICE_CREATION_MODE,
getApiError,
} from '@/lib/api/index'
import type { InvoiceCreationMode } from '@/lib/api/sys-config'
const MODE_LABELS: Record<InvoiceCreationMode, string> = {
ModeA: 'Auto on confirm',
ModeB: 'Separate step',
}
const MODE_HINTS: Record<InvoiceCreationMode, string> = {
ModeA: 'Confirming a Shop PO immediately creates a draft invoice.',
ModeB: 'Confirmation and invoice creation are two distinct actions.',
}
export function InvoiceCreationModeCard() {
const [loading, setLoading] = useState(true)
const [mode, setMode] = useState<InvoiceCreationMode>(DEFAULT_INVOICE_CREATION_MODE)
const [pending, setPending] = useState(false)
useEffect(() => {
invoiceCreationModeApi.get()
.then(setMode)
.catch(err => toast.add({ title: 'Failed to load invoice creation mode', description: getApiError(err), type: 'error' }))
.finally(() => setLoading(false))
}, [])
async function save(next: InvoiceCreationMode) {
if (next === mode) return
const previous = mode
setMode(next)
setPending(true)
try {
await invoiceCreationModeApi.set(next)
} catch (err) {
setMode(previous)
toast.add({ title: 'Failed to update invoice creation mode', description: getApiError(err), type: 'error' })
} finally {
setPending(false)
}
}
if (loading) return <p className="text-sm text-muted-foreground">Loading...</p>
return (
<Card className="h-full gap-3 rounded-3xl border-border/60 py-4">
<CardHeader className="border-b border-border/60 pb-3">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-muted-foreground">
<HugeiconsIcon icon={ReceiptDollarIcon} size={16} />
</div>
<div>
<CardTitle className="text-base">Invoice Creation</CardTitle>
<CardDescription className="text-xs">When invoices are created for a Shop PO</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-2.5">
<ToggleGroup
variant="outline"
value={[mode]}
onValueChange={(v: string[]) => v[0] && save(v[0] as InvoiceCreationMode)}
disabled={pending}
className="w-full"
>
<ToggleGroupItem value="ModeA" className="flex-1">{MODE_LABELS.ModeA}</ToggleGroupItem>
<ToggleGroupItem value="ModeB" className="flex-1">{MODE_LABELS.ModeB}</ToggleGroupItem>
</ToggleGroup>
<FieldDescription className="text-xs leading-relaxed">{MODE_HINTS[mode]}</FieldDescription>
</CardContent>
</Card>
)
}
@@ -2,6 +2,7 @@
import { ProductCapabilitiesCard } from './_components/product-capabilities-card'
import { GrnTaxCard } from './_components/grn-tax-card'
import { InvoiceCreationModeCard } from './_components/invoice-creation-mode-card'
export default function ConfigurationPage() {
return (
@@ -11,7 +12,6 @@ export default function ConfigurationPage() {
<p className="text-sm text-muted-foreground mt-0.5">System-wide settings, grouped by area</p>
</div>
{/* 12-column grid: each card takes 6 columns so the two sit side by side; they stack on narrow screens. */}
<div className="grid grid-cols-12 items-stretch gap-6">
<div className="col-span-12 flex lg:col-span-6 [&>*]:w-full">
<ProductCapabilitiesCard />
@@ -19,6 +19,9 @@ export default function ConfigurationPage() {
<div className="col-span-12 flex lg:col-span-6 [&>*]:w-full">
<GrnTaxCard />
</div>
<div className="col-span-12 flex lg:col-span-6 [&>*]:w-full">
<InvoiceCreationModeCard />
</div>
</div>
</div>
)
@@ -24,9 +24,12 @@ import {
items as itemsApi,
priceLists as priceListsApi,
users as usersApi,
invoiceCreationMode as invoiceCreationModeApi,
DEFAULT_INVOICE_CREATION_MODE,
getApiError,
getPaginatedItems,
} from '@/lib/api/index'
import type { InvoiceCreationMode } from '@/lib/api/sys-config'
import type { ItemSummaryResponse, ShopPOResponse, UserResponse } from '@/types/api'
const lineItemSchema = z.object({
@@ -56,6 +59,11 @@ export default function EditShopPOPage() {
const [usersList, setUsersList] = useState<UserResponse[]>([])
const [confirmedByUserId, setConfirmedByUserId] = useState('')
const [confirming, setConfirming] = useState(false)
const [mode, setMode] = useState<InvoiceCreationMode>(DEFAULT_INVOICE_CREATION_MODE)
useEffect(() => {
invoiceCreationModeApi.get().then(setMode).catch(() => {})
}, [])
const form = useForm<FormValues>({
resolver: zodResolver(schema),
@@ -98,9 +106,14 @@ export default function EditShopPOPage() {
if (!po || !confirmedByUserId) return
setConfirming(true)
try {
const updated = await shopPosApi.confirm(po.id, confirmedByUserId)
const updated = mode === 'ModeA'
? await shopPosApi.confirmAndCreateInvoice(po.id, confirmedByUserId)
: await shopPosApi.confirm(po.id, confirmedByUserId)
setPo(updated)
toast.add({ title: 'Shop PO confirmed', type: 'success' })
toast.add({
title: mode === 'ModeA' ? 'Shop PO confirmed and invoice created' : 'Shop PO confirmed',
type: 'success',
})
} catch (err) {
toast.add({ title: 'Failed to confirm Shop PO', description: getApiError(err), type: 'error' })
} finally {
@@ -21,7 +21,15 @@ import {
} from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
import { toast } from '@/components/ui/toast'
import { shopPos as shopPosApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import {
shopPos as shopPosApi,
users as usersApi,
invoiceCreationMode as invoiceCreationModeApi,
DEFAULT_INVOICE_CREATION_MODE,
getApiError,
getPaginatedItems,
} from '@/lib/api/index'
import type { InvoiceCreationMode } from '@/lib/api/sys-config'
import type { ShopPOResponse, UserResponse } from '@/types/api'
const schema = z.object({
@@ -39,6 +47,7 @@ interface ConfirmShopPODialogProps {
export function ConfirmShopPODialog({ open, onClose, onSuccess, po }: ConfirmShopPODialogProps) {
const [usersList, setUsersList] = useState<UserResponse[]>([])
const [mode, setMode] = useState<InvoiceCreationMode>(DEFAULT_INVOICE_CREATION_MODE)
const form = useForm<FormValues>({
resolver: zodResolver(schema),
@@ -51,14 +60,20 @@ export function ConfirmShopPODialog({ open, onClose, onSuccess, po }: ConfirmSho
usersApi.list()
.then((res) => setUsersList(res))
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
invoiceCreationModeApi.get().then(setMode).catch(() => {})
}, [open])
if (!po) return null
async function onSubmit(values: FormValues) {
try {
await shopPosApi.confirm(po!.id, values.confirmedByUserId)
toast.add({ title: 'Shop PO confirmed', type: 'success' })
if (mode === 'ModeA') {
await shopPosApi.confirmAndCreateInvoice(po!.id, values.confirmedByUserId)
toast.add({ title: 'Shop PO confirmed and invoice created', type: 'success' })
} else {
await shopPosApi.confirm(po!.id, values.confirmedByUserId)
toast.add({ title: 'Shop PO confirmed', type: 'success' })
}
onSuccess()
onClose()
} catch (err) {
@@ -71,7 +86,10 @@ export function ConfirmShopPODialog({ open, onClose, onSuccess, po }: ConfirmSho
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Confirm Shop PO</DialogTitle>
<DialogDescription>{po.storeName} — Rs. {po.totalAmount.toLocaleString()}</DialogDescription>
<DialogDescription>
{po.storeName} — Rs. {po.totalAmount.toLocaleString()}
{mode === 'ModeA' && ' · a draft invoice will be created'}
</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
@@ -0,0 +1,142 @@
'use client'
import { useEffect, useState } from 'react'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
import { toast } from '@/components/ui/toast'
import { invoices as invoicesApi, shopPos as shopPosApi, users as usersApi, getApiError } from '@/lib/api/index'
import type { ShopPOResponse, UserResponse } from '@/types/api'
const schema = z.object({
shopPOId: z.string().min(1, 'Shop PO is required'),
userId: z.string().min(1, 'User is required'),
})
type FormValues = z.infer<typeof schema>
interface CreateInvoiceDialogProps {
open: boolean
onClose: () => void
onSuccess: () => void
}
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
const truncId = (id: string) => id.substring(0, 8)
export function CreateInvoiceDialog({ open, onClose, onSuccess }: CreateInvoiceDialogProps) {
const [pos, setPos] = useState<ShopPOResponse[]>([])
const [usersList, setUsersList] = useState<UserResponse[]>([])
const [loading, setLoading] = useState(false)
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { shopPOId: '', userId: '' },
})
useEffect(() => {
if (!open) return
form.reset({ shopPOId: '', userId: '' })
setLoading(true)
Promise.all([
shopPosApi.confirmedWithoutInvoice(),
usersApi.list(),
])
.then(([posRes, usersRes]) => {
setPos(posRes)
setUsersList(usersRes)
})
.catch((err) => toast.add({ title: 'Failed to load data', description: getApiError(err), type: 'error' }))
.finally(() => setLoading(false))
}, [open])
async function onSubmit(values: FormValues) {
try {
await invoicesApi.create(values.shopPOId, values.userId)
toast.add({ title: 'Invoice created', type: 'success' })
onSuccess()
onClose()
} catch (err) {
toast.add({ title: 'Failed to create invoice', description: getApiError(err), type: 'error' })
}
}
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Create Invoice</DialogTitle>
<DialogDescription>Generate an invoice from a confirmed Shop PO that doesn&apos;t have one yet.</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<FormField
control={form.control}
name="shopPOId"
label="Shop PO"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange} disabled={loading || pos.length === 0}>
<SelectTrigger>
<SelectValue placeholder={loading ? 'Loading…' : pos.length === 0 ? 'No eligible Shop POs' : 'Select Shop PO...'} />
</SelectTrigger>
<SelectContent>
{pos.map((po) => (
<SelectItem key={po.id} value={po.id}>
{po.storeName} — {fmtAmount(po.totalAmount)} [{truncId(po.id)}]
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name="userId"
label="Created By"
description="Temporary — required until auth resolves the logged-in user automatically."
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select user..." />
</SelectTrigger>
<SelectContent>
{usersList.map((user) => (
<SelectItem key={user.id} value={user.id}>{user.fullName}</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</FieldGroup>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={form.formState.isSubmitting || pos.length === 0}>
{form.formState.isSubmitting ? 'Saving…' : 'Create Invoice'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -5,12 +5,15 @@ import { useState, useEffect, useCallback, useMemo } from 'react'
import { DataTable } from '@/components/shared/data-table'
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 { invoices as invoicesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import { invoices as invoicesApi, invoiceCreationMode as invoiceCreationModeApi, DEFAULT_INVOICE_CREATION_MODE, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { InvoiceCreationMode } from '@/lib/api/sys-config'
import type { InvoiceResponse, InvoiceStatus, PaymentStatus } from '@/types/api'
import { OrderViewSheet } from './order-view-sheet'
import { InvoiceActionDialog } from './invoice-action-dialog'
import { CreateInvoiceDialog } from './create-invoice-dialog'
import { PaymentSheet } from '../../payments/_components/payment-sheet'
const fmtDate = (v: string) =>
@@ -44,6 +47,12 @@ export function OrdersClient() {
const [actionMode, setActionMode] = useState<'print' | 'finalize'>('print')
const [paymentOpen, setPaymentOpen] = useState(false)
const [paymentInvoice, setPaymentInvoice] = useState<InvoiceResponse | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [mode, setMode] = useState<InvoiceCreationMode>(DEFAULT_INVOICE_CREATION_MODE)
useEffect(() => {
invoiceCreationModeApi.get().then(setMode).catch(() => {})
}, [])
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
@@ -178,6 +187,9 @@ export function OrdersClient() {
Billing documents generated from confirmed Shop POs
</p>
</div>
{mode === 'ModeB' && (
<Button onClick={() => setCreateOpen(true)}>Create Invoice</Button>
)}
</div>
<DataTable
@@ -212,6 +224,12 @@ export function OrdersClient() {
invoices={paymentInvoice ? [paymentInvoice] : []}
defaultInvoiceId={paymentInvoice?.id}
/>
<CreateInvoiceDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={() => fetchData(page, pageSize)}
/>
</>
)
}
@@ -23,8 +23,8 @@ import {
} from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
import { toast } from '@/components/ui/toast'
import { payments as paymentsApi, bankAccounts as bankAccountsApi, getApiError } from '@/lib/api/index'
import type { InvoiceResponse } from '@/types/api'
import { payments as paymentsApi, bankAccounts as bankAccountsApi, stores as storesApi, getApiError } from '@/lib/api/index'
import type { InvoiceResponse, OutstandingBalanceResponse } from '@/types/api'
import type { BankAccountResponse } from '@/types/payments'
const schema = z
@@ -67,6 +67,7 @@ const truncId = (id: string) => id.substring(0, 8)
export function PaymentSheet({ open, onClose, onSuccess, invoices, defaultInvoiceId }: PaymentSheetProps) {
const [accounts, setAccounts] = useState<BankAccountResponse[]>([])
const [credit, setCredit] = useState<OutstandingBalanceResponse | null>(null)
const form = useForm<FormValues>({
resolver: zodResolver(schema),
@@ -84,6 +85,16 @@ export function PaymentSheet({ open, onClose, onSuccess, invoices, defaultInvoic
})
const method = form.watch('method')
const invoiceId = form.watch('invoiceId')
const amount = form.watch('amount')
const selectedInvoice = invoices.find((inv) => inv.id === invoiceId)
const selectedStoreId = selectedInvoice?.storeId
const remaining = selectedInvoice ? selectedInvoice.totalAmount - selectedInvoice.paidAmount : 0
useEffect(() => {
if (!open || !selectedInvoice) return
form.setValue('amount', Number((selectedInvoice.totalAmount - selectedInvoice.paidAmount).toFixed(2)))
}, [open, selectedInvoice?.id])
useEffect(() => {
if (!open) return
@@ -101,10 +112,39 @@ export function PaymentSheet({ open, onClose, onSuccess, invoices, defaultInvoic
bankAccountsApi.list()
.then(list => setAccounts(list.filter(a => a.isActive !== false)))
.catch(() => {})
setCredit(null)
}, [open, defaultInvoiceId])
useEffect(() => {
if (!open || method !== 'Credit' || !selectedStoreId) {
setCredit(null)
return
}
let cancelled = false
storesApi.outstandingBalance(selectedStoreId)
.then((data) => { if (!cancelled) setCredit(data) })
.catch(() => { if (!cancelled) setCredit(null) })
return () => { cancelled = true }
}, [open, method, selectedStoreId])
async function onSubmit(values: FormValues) {
const { invoiceId, method, bankAccountId, chequeNumber, chequeDate, drawerBankName, accountHolderName, ...rest } = values
if (selectedInvoice && values.amount > remaining) {
toast.add({
title: 'Amount exceeds outstanding balance',
description: `Outstanding: Rs. ${remaining.toLocaleString()}`,
type: 'error',
})
return
}
if (method === 'Credit' && credit && values.amount > credit.availableCredit) {
toast.add({
title: 'Credit limit exceeded',
description: `Available credit: Rs. ${credit.availableCredit.toLocaleString()}`,
type: 'error',
})
return
}
try {
await paymentsApi.create(invoiceId, {
...rest,
@@ -161,6 +201,7 @@ export function PaymentSheet({ open, onClose, onSuccess, invoices, defaultInvoic
name="amount"
label="Amount"
required
description={selectedInvoice ? `Outstanding: ${fmtAmount(remaining)}` : undefined}
render={({ field, invalid }) => (
<Input
id="amount"
@@ -172,7 +213,7 @@ export function PaymentSheet({ open, onClose, onSuccess, invoices, defaultInvoic
onChange={(e) => field.onChange(Number(e.target.value))}
onBlur={field.onBlur}
placeholder="0.00"
aria-invalid={invalid}
aria-invalid={invalid || (selectedInvoice ? amount > remaining : false)}
/>
)}
/>
@@ -196,6 +237,36 @@ export function PaymentSheet({ open, onClose, onSuccess, invoices, defaultInvoic
)}
/>
{method === 'Credit' && selectedStoreId && (
<div className="rounded-md border bg-muted/40 p-3 text-sm">
{credit ? (
<div className="flex flex-col gap-1">
<div className="flex justify-between">
<span className="text-muted-foreground">Credit limit</span>
<span>{fmtAmount(credit.creditLimit)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Currently used</span>
<span>{fmtAmount(credit.outstandingBalance)}</span>
</div>
<div className="flex justify-between font-medium">
<span>Available</span>
<span className={amount > credit.availableCredit ? 'text-destructive' : ''}>
{fmtAmount(credit.availableCredit)}
</span>
</div>
{amount > credit.availableCredit && (
<p className="text-destructive text-xs mt-1">
Amount exceeds available credit.
</p>
)}
</div>
) : (
<span className="text-muted-foreground">Loading credit info…</span>
)}
</div>
)}
{method === 'Cash' && (
<FormField
control={form.control}