Add manual invoice creation mode with new API & UI flow #78
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
|
||||
+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