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),
+}