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 ( + { if (!v) onClose() }}> + + + Create Invoice + Generate an invoice from a confirmed Shop PO that doesn't have one yet. + + +
+ + ( + + )} + /> + + ( + + )} + /> + + + + + + +
+
+
+ ) +} 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), +}