Add manual invoice creation mode with new API & UI flow #78

Merged
kalana_Thilakarathna merged 1 commits from dev into production 2026-10-20 13:39:13 +05:30
12 changed files with 344 additions and 5 deletions
@@ -36,6 +36,22 @@ public class InvoiceController(InvoiceService service, ICurrentUserService curre
return result is null ? NotFound() : Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateInvoiceRequest request, CancellationToken ct)
{
// TEMPORARY: currentUser.UserId is always unset due to the broken auth middleware
// (testbug.md #28) — fall back to the explicitly supplied user until that's fixed.
var userId = currentUser.UserId ?? request.CreatedByUserId;
if (userId is null) return BadRequest(new { error = "CreatedByUserId is required while auth is unavailable" });
try
{
var result = await service.CreateAsync(request, userId.Value, ct);
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
}
catch (KeyNotFoundException) { return NotFound(); }
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
}
[HttpPost("{id:guid}/print")]
public async Task<IActionResult> Print(Guid id, [FromBody] PrintInvoiceRequest? request, CancellationToken ct)
{
+1 -1
View File
@@ -2,7 +2,7 @@
namespace Distribution.Features.Invoice;
public record CreateInvoiceRequest(Guid ShopPOId);
public record CreateInvoiceRequest(Guid ShopPOId, Guid? CreatedByUserId = null);
public record InvoiceItemAdjustment(Guid ShopPOItemId, int AdjustedQuantity, string? Note = null);
public record FinalizeInvoiceRequest(List<InvoiceItemAdjustment>? Adjustments = null, Guid? FinalizedByUserId = null);
public record PrintInvoiceRequest(Guid? PrintedByUserId = null);
@@ -1,4 +1,5 @@
using Distribution.Features.Stock;
using Distribution.Features.ShopPO;
using Distribution.Features.Stock;
using Distribution.Infra.Authorization;
using Distribution.Infra.UoW;
using Distribution.Models;
@@ -7,6 +8,7 @@ namespace Distribution.Features.Invoice;
public class InvoiceService(
IInvoiceRepository repo,
IShopPORepository shopPoRepo,
IUnitOfWork uow,
ICurrentUserService currentUser)
{
@@ -42,6 +44,41 @@ public class InvoiceService(
return new OutstandingPaymentsResponse(count, outstanding);
}
public async Task<InvoiceResponse> CreateAsync(CreateInvoiceRequest request, Guid createdBy, CancellationToken ct = default)
{
var po = await shopPoRepo.GetByIdAsync(request.ShopPOId)
?? throw new KeyNotFoundException("Shop PO not found");
if (po.ConfirmedAt is null)
throw new InvalidOperationException("Shop PO must be confirmed before an invoice can be created");
if (po.Status == ShopPOStatus.Cancelled)
throw new InvalidOperationException("Cannot create an invoice for a cancelled Shop PO");
if (po.InvoiceId is not null)
throw new InvalidOperationException("An invoice already exists for this Shop PO");
var invoice = new Models.Invoice
{
FK_ShopPOId = po.Id,
Status = InvoiceStatus.Draft,
PaymentStatus = PaymentStatus.Pending,
CreatedBy = createdBy,
TotalAmount = po.TotalAmount
};
await uow.BeginAsync(ct);
try
{
await repo.AddAsync(invoice);
po.InvoiceId = invoice.Id;
shopPoRepo.Update(po);
await uow.CommitAsync(ct);
}
catch { await uow.RollbackAsync(ct); throw; }
var saved = await repo.GetByIdAsync(invoice.Id);
return ToResponse(saved!);
}
public async Task<InvoiceResponse> PrintAsync(Guid id, Guid printedBy, CancellationToken ct = default)
{
var invoice = await repo.GetByIdAsync(id)
@@ -30,6 +30,16 @@ public class ShopPOController(ShopPOService service, ICurrentUserService current
return result is null ? NotFound() : Ok(result);
}
[HttpGet("confirmed-without-invoice")]
public async Task<IActionResult> GetConfirmedWithoutInvoice(
[FromQuery] Guid? agentId,
[FromQuery] Guid? routeId,
[FromQuery] Guid? shopId)
{
var result = await service.GetConfirmedWithoutInvoiceAsync(agentId, routeId, shopId);
return Ok(result);
}
[HttpPost]
public async Task<IActionResult> Create(CreateShopPORequest request, CancellationToken ct)
{
@@ -55,6 +55,13 @@ public class ShopPOService(
return ToResponse(po, users);
}
public async Task<List<ShopPOResponse>> GetConfirmedWithoutInvoiceAsync(Guid? agentId, Guid? routeId, Guid? shopId)
{
var items = await repo.GetConfirmedWithoutInvoiceAsync(agentId, routeId, shopId);
var users = await repo.GetUserInfoAsync(items.SelectMany(UserIds));
return items.Select(p => ToResponse(p, users)).ToList();
}
public async Task<ShopPOResponse> CreateAsync(CreateShopPORequest request, Guid raisedById, CancellationToken ct = default)
{
if (request.Items.Count == 0)
@@ -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&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)}
/>
</>
)
}
+1 -1
View File
@@ -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'
+6
View File
@@ -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),
+16
View File
@@ -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),
}