Add manual invoice creation mode with new API & UI flow
Introduced "ModeB" for manual invoice creation in system config. Added backend endpoints and service logic for creating invoices from confirmed Shop POs, with validation and error handling. Updated DTOs to support optional CreatedByUserId. On the frontend, added configuration card to toggle modes, a dialog for manual invoice creation, and UI updates to support the new workflow. Enables admins to choose between automatic and manual invoice creation.
This commit is contained in:
+84
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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'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)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export { stock } from './stock'
|
||||
export { productReports, stockReports, dashboardReports } from './reports'
|
||||
export { shopPos, invoices, payments } from './sales'
|
||||
export { saleReturns, claims, supplierClaims } from './returns-claims'
|
||||
export { sysConfig, ITEM_TRACKING_KEYS, GRN_TAX_KEYS, grnTaxSettings, DEFAULT_GRN_TAX_SETTINGS } from './sys-config'
|
||||
export { sysConfig, ITEM_TRACKING_KEYS, GRN_TAX_KEYS, grnTaxSettings, DEFAULT_GRN_TAX_SETTINGS, invoiceCreationMode, INVOICE_CREATION_MODE_KEY, DEFAULT_INVOICE_CREATION_MODE } from './sys-config'
|
||||
export { bankAccounts } from './bank-accounts'
|
||||
export { chequeBooks, chequePages } from './cheques'
|
||||
export { receivedCheques } from './received-cheques'
|
||||
|
||||
@@ -48,6 +48,9 @@ export const shopPos = {
|
||||
get: (id: string) =>
|
||||
api.get<ShopPOResponse>(`/shop-pos/${id}`).then(r => r.data),
|
||||
|
||||
confirmedWithoutInvoice: (params?: { agentId?: string; routeId?: string; shopId?: string }) =>
|
||||
api.get<ShopPOResponse[]>('/shop-pos/confirmed-without-invoice', { params }).then(r => r.data),
|
||||
|
||||
create: (body: CreateShopPOBody) =>
|
||||
api.post<ShopPOResponse>('/shop-pos', body).then(r => r.data),
|
||||
|
||||
@@ -84,6 +87,9 @@ export const invoices = {
|
||||
get: (id: string) =>
|
||||
api.get<InvoiceResponse>(`/invoices/${id}`).then(r => r.data),
|
||||
|
||||
create: (shopPOId: string, createdByUserId?: string | null) =>
|
||||
api.post<InvoiceResponse>('/invoices', { shopPOId, createdByUserId }).then(r => r.data),
|
||||
|
||||
print: (id: string, printedByUserId?: string | null) =>
|
||||
api.post<InvoiceResponse>(`/invoices/${id}/print`, { printedByUserId }).then(r => r.data),
|
||||
|
||||
|
||||
@@ -72,3 +72,19 @@ export const grnTaxSettings = {
|
||||
.getAll()
|
||||
.then(all => parseGrnTaxSettings(Object.fromEntries(all.map(c => [c.key, c.value])))),
|
||||
}
|
||||
|
||||
// ─── Invoice creation mode (must match Distribution.Features.SysConfig.SysConfigService) ─────
|
||||
|
||||
export const INVOICE_CREATION_MODE_KEY = 'InvoiceCreationMode'
|
||||
|
||||
export type InvoiceCreationMode = 'ModeA' | 'ModeB'
|
||||
|
||||
export const DEFAULT_INVOICE_CREATION_MODE: InvoiceCreationMode = 'ModeB'
|
||||
|
||||
export const invoiceCreationMode = {
|
||||
get: () =>
|
||||
api.get<{ mode: InvoiceCreationMode }>('/sys-config/invoice-creation-mode').then(r => r.data.mode),
|
||||
|
||||
set: (mode: InvoiceCreationMode) =>
|
||||
api.put('/sys-config/invoice-creation-mode', { mode }).then(r => r.data),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user