From 2de9e5e4ad43a5b5245c3688d9e3549979b7c841 Mon Sep 17 00:00:00 2001
From: "Kalana B. Thilakarathna"
Date: Tue, 20 Oct 2026 13:38:43 +0530
Subject: [PATCH] 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.
---
.../Features/Invoice/InvoiceController.cs | 16 ++
Distribution/Features/Invoice/InvoiceDTOs.cs | 2 +-
.../Features/Invoice/InvoiceService.cs | 39 ++++-
.../Features/ShopPO/ShopPOController.cs | 10 ++
Distribution/Features/ShopPO/ShopPOService.cs | 7 +
.../invoice-creation-mode-card.tsx | 84 +++++++++++
.../app/(dashboard)/configuration/page.tsx | 5 +-
.../_components/create-invoice-dialog.tsx | 142 ++++++++++++++++++
.../orders/_components/orders-client.tsx | 20 ++-
distribution_fe/lib/api/index.ts | 2 +-
distribution_fe/lib/api/sales.ts | 6 +
distribution_fe/lib/api/sys-config.ts | 16 ++
12 files changed, 344 insertions(+), 5 deletions(-)
create mode 100644 distribution_fe/app/(dashboard)/configuration/_components/invoice-creation-mode-card.tsx
create mode 100644 distribution_fe/app/(dashboard)/sales/orders/_components/create-invoice-dialog.tsx
diff --git a/Distribution/Features/Invoice/InvoiceController.cs b/Distribution/Features/Invoice/InvoiceController.cs
index 4e7311c..b67b22b 100644
--- a/Distribution/Features/Invoice/InvoiceController.cs
+++ b/Distribution/Features/Invoice/InvoiceController.cs
@@ -36,6 +36,22 @@ public class InvoiceController(InvoiceService service, ICurrentUserService curre
return result is null ? NotFound() : Ok(result);
}
+ [HttpPost]
+ public async Task 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 Print(Guid id, [FromBody] PrintInvoiceRequest? request, CancellationToken ct)
{
diff --git a/Distribution/Features/Invoice/InvoiceDTOs.cs b/Distribution/Features/Invoice/InvoiceDTOs.cs
index d1178d6..51b4466 100644
--- a/Distribution/Features/Invoice/InvoiceDTOs.cs
+++ b/Distribution/Features/Invoice/InvoiceDTOs.cs
@@ -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? Adjustments = null, Guid? FinalizedByUserId = null);
public record PrintInvoiceRequest(Guid? PrintedByUserId = null);
diff --git a/Distribution/Features/Invoice/InvoiceService.cs b/Distribution/Features/Invoice/InvoiceService.cs
index dcc2891..85bdf03 100644
--- a/Distribution/Features/Invoice/InvoiceService.cs
+++ b/Distribution/Features/Invoice/InvoiceService.cs
@@ -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 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 PrintAsync(Guid id, Guid printedBy, CancellationToken ct = default)
{
var invoice = await repo.GetByIdAsync(id)
diff --git a/Distribution/Features/ShopPO/ShopPOController.cs b/Distribution/Features/ShopPO/ShopPOController.cs
index 45e643e..b185a58 100644
--- a/Distribution/Features/ShopPO/ShopPOController.cs
+++ b/Distribution/Features/ShopPO/ShopPOController.cs
@@ -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 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 Create(CreateShopPORequest request, CancellationToken ct)
{
diff --git a/Distribution/Features/ShopPO/ShopPOService.cs b/Distribution/Features/ShopPO/ShopPOService.cs
index 98a04a9..38ee9db 100644
--- a/Distribution/Features/ShopPO/ShopPOService.cs
+++ b/Distribution/Features/ShopPO/ShopPOService.cs
@@ -55,6 +55,13 @@ public class ShopPOService(
return ToResponse(po, users);
}
+ public async Task> 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 CreateAsync(CreateShopPORequest request, Guid raisedById, CancellationToken ct = default)
{
if (request.Items.Count == 0)
diff --git a/distribution_fe/app/(dashboard)/configuration/_components/invoice-creation-mode-card.tsx b/distribution_fe/app/(dashboard)/configuration/_components/invoice-creation-mode-card.tsx
new file mode 100644
index 0000000..1366ea3
--- /dev/null
+++ b/distribution_fe/app/(dashboard)/configuration/_components/invoice-creation-mode-card.tsx
@@ -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 = {
+ ModeA: 'Auto on confirm',
+ ModeB: 'Separate step',
+}
+
+const MODE_HINTS: Record = {
+ 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(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 Loading...
+
+ return (
+
+
+
+
+
+
+
+ Invoice Creation
+ When invoices are created for a Shop PO
+
+
+
+
+ v[0] && save(v[0] as InvoiceCreationMode)}
+ disabled={pending}
+ className="w-full"
+ >
+ {MODE_LABELS.ModeA}
+ {MODE_LABELS.ModeB}
+
+ {MODE_HINTS[mode]}
+
+
+ )
+}
diff --git a/distribution_fe/app/(dashboard)/configuration/page.tsx b/distribution_fe/app/(dashboard)/configuration/page.tsx
index fb8c58d..1833349 100644
--- a/distribution_fe/app/(dashboard)/configuration/page.tsx
+++ b/distribution_fe/app/(dashboard)/configuration/page.tsx
@@ -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() {
System-wide settings, grouped by area
- {/* 12-column grid: each card takes 6 columns so the two sit side by side; they stack on narrow screens. */}
@@ -19,6 +19,9 @@ export default function ConfigurationPage() {
+
+
+
)
diff --git a/distribution_fe/app/(dashboard)/sales/orders/_components/create-invoice-dialog.tsx b/distribution_fe/app/(dashboard)/sales/orders/_components/create-invoice-dialog.tsx
new file mode 100644
index 0000000..64f4ccf
--- /dev/null
+++ b/distribution_fe/app/(dashboard)/sales/orders/_components/create-invoice-dialog.tsx
@@ -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
+
+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([])
+ const [usersList, setUsersList] = useState([])
+ const [loading, setLoading] = useState(false)
+
+ const form = useForm({
+ 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 (
+
+ )
+}
diff --git a/distribution_fe/app/(dashboard)/sales/orders/_components/orders-client.tsx b/distribution_fe/app/(dashboard)/sales/orders/_components/orders-client.tsx
index 30242c5..6515b85 100644
--- a/distribution_fe/app/(dashboard)/sales/orders/_components/orders-client.tsx
+++ b/distribution_fe/app/(dashboard)/sales/orders/_components/orders-client.tsx
@@ -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(null)
+ const [createOpen, setCreateOpen] = useState(false)
+ const [mode, setMode] = useState(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
+ {mode === 'ModeB' && (
+
+ )}
+
+ setCreateOpen(false)}
+ onSuccess={() => fetchData(page, pageSize)}
+ />
>
)
}
diff --git a/distribution_fe/lib/api/index.ts b/distribution_fe/lib/api/index.ts
index 2b22eeb..cb264d8 100644
--- a/distribution_fe/lib/api/index.ts
+++ b/distribution_fe/lib/api/index.ts
@@ -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'
diff --git a/distribution_fe/lib/api/sales.ts b/distribution_fe/lib/api/sales.ts
index a9bdf93..31db419 100644
--- a/distribution_fe/lib/api/sales.ts
+++ b/distribution_fe/lib/api/sales.ts
@@ -48,6 +48,9 @@ export const shopPos = {
get: (id: string) =>
api.get(`/shop-pos/${id}`).then(r => r.data),
+ confirmedWithoutInvoice: (params?: { agentId?: string; routeId?: string; shopId?: string }) =>
+ api.get('/shop-pos/confirmed-without-invoice', { params }).then(r => r.data),
+
create: (body: CreateShopPOBody) =>
api.post('/shop-pos', body).then(r => r.data),
@@ -84,6 +87,9 @@ export const invoices = {
get: (id: string) =>
api.get(`/invoices/${id}`).then(r => r.data),
+ create: (shopPOId: string, createdByUserId?: string | null) =>
+ api.post('/invoices', { shopPOId, createdByUserId }).then(r => r.data),
+
print: (id: string, printedByUserId?: string | null) =>
api.post(`/invoices/${id}/print`, { printedByUserId }).then(r => r.data),
diff --git a/distribution_fe/lib/api/sys-config.ts b/distribution_fe/lib/api/sys-config.ts
index 6699096..c41cdb0 100644
--- a/distribution_fe/lib/api/sys-config.ts
+++ b/distribution_fe/lib/api/sys-config.ts
@@ -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),
+}