Merge pull request 'Dev' (#42) from dev into production

Reviewed-on: #42
This commit was merged in pull request #42.
This commit is contained in:
2026-09-23 08:38:21 +00:00
6 changed files with 172 additions and 44 deletions
@@ -29,12 +29,15 @@ public class InvoiceController(InvoiceService service, ICurrentUserService curre
} }
[HttpPost("{id:guid}/print")] [HttpPost("{id:guid}/print")]
public async Task<IActionResult> Print(Guid id, CancellationToken ct) public async Task<IActionResult> Print(Guid id, [FromBody] PrintInvoiceRequest? request, CancellationToken ct)
{ {
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); // 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?.PrintedByUserId;
if (userId is null) return BadRequest(new { error = "PrintedByUserId is required while auth is unavailable" });
try try
{ {
var result = await service.PrintAsync(id, userId, ct); var result = await service.PrintAsync(id, userId.Value, ct);
return Ok(result); return Ok(result);
} }
catch (KeyNotFoundException) { return NotFound(); } catch (KeyNotFoundException) { return NotFound(); }
@@ -44,10 +47,13 @@ public class InvoiceController(InvoiceService service, ICurrentUserService curre
[HttpPost("{id:guid}/finalize")] [HttpPost("{id:guid}/finalize")]
public async Task<IActionResult> Finalize(Guid id, [FromBody] FinalizeInvoiceRequest? request, CancellationToken ct) public async Task<IActionResult> Finalize(Guid id, [FromBody] FinalizeInvoiceRequest? request, CancellationToken ct)
{ {
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); // 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?.FinalizedByUserId;
if (userId is null) return BadRequest(new { error = "FinalizedByUserId is required while auth is unavailable" });
try try
{ {
var result = await service.FinalizeAsync(id, request ?? new FinalizeInvoiceRequest(), userId, ct); var result = await service.FinalizeAsync(id, request ?? new FinalizeInvoiceRequest(), userId.Value, ct);
return Ok(result); return Ok(result);
} }
catch (KeyNotFoundException) { return NotFound(); } catch (KeyNotFoundException) { return NotFound(); }
+2 -1
View File
@@ -4,7 +4,8 @@ namespace Distribution.Features.Invoice;
public record CreateInvoiceRequest(Guid ShopPOId); public record CreateInvoiceRequest(Guid ShopPOId);
public record InvoiceItemAdjustment(Guid ShopPOItemId, int AdjustedQuantity, string? Note = null); public record InvoiceItemAdjustment(Guid ShopPOItemId, int AdjustedQuantity, string? Note = null);
public record FinalizeInvoiceRequest(List<InvoiceItemAdjustment>? Adjustments = null); public record FinalizeInvoiceRequest(List<InvoiceItemAdjustment>? Adjustments = null, Guid? FinalizedByUserId = null);
public record PrintInvoiceRequest(Guid? PrintedByUserId = null);
public record InvoiceResponse( public record InvoiceResponse(
Guid Id, Guid Id,
@@ -12,6 +12,7 @@ import type { InvoiceResponse, InvoiceStatus, PaymentResponse, PaymentMethod, Pa
import { PaymentSheet } from '../../payments/_components/payment-sheet' import { PaymentSheet } from '../../payments/_components/payment-sheet'
import { PaymentViewSheet } from '../../payments/_components/payment-view-sheet' import { PaymentViewSheet } from '../../payments/_components/payment-view-sheet'
import { InvoiceActionDialog } from '../../orders/_components/invoice-action-dialog'
const INVOICE_STATUS_VARIANT: Record<InvoiceStatus, 'default' | 'secondary' | 'outline'> = { const INVOICE_STATUS_VARIANT: Record<InvoiceStatus, 'default' | 'secondary' | 'outline'> = {
Draft: 'secondary', Draft: 'secondary',
@@ -58,6 +59,8 @@ export default function InvoiceViewPage() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [paymentSheetOpen, setPaymentSheetOpen] = useState(false) const [paymentSheetOpen, setPaymentSheetOpen] = useState(false)
const [viewPayment, setViewPayment] = useState<PaymentResponse | null>(null) const [viewPayment, setViewPayment] = useState<PaymentResponse | null>(null)
const [actionOpen, setActionOpen] = useState(false)
const [actionMode, setActionMode] = useState<'print' | 'finalize'>('print')
const fetchInvoice = useCallback(async () => { const fetchInvoice = useCallback(async () => {
const inv = await invoicesApi.get(params.id) const inv = await invoicesApi.get(params.id)
@@ -86,26 +89,14 @@ export default function InvoiceViewPage() {
} }
}, [fetchInvoice, fetchPayments, router]) }, [fetchInvoice, fetchPayments, router])
async function handlePrint() { function handlePrint() {
if (!invoice) return setActionMode('print')
try { setActionOpen(true)
const updated = await invoicesApi.print(invoice.id)
setInvoice(updated)
toast.add({ title: 'Invoice printed', type: 'success' })
} catch (err) {
toast.add({ title: 'Failed to print invoice', description: getApiError(err), type: 'error' })
}
} }
async function handleFinalize() { function handleFinalize() {
if (!invoice) return setActionMode('finalize')
try { setActionOpen(true)
const updated = await invoicesApi.finalize(invoice.id)
setInvoice(updated)
toast.add({ title: 'Invoice finalized', type: 'success' })
} catch (err) {
toast.add({ title: 'Failed to finalize invoice', description: getApiError(err), type: 'error' })
}
} }
if (loading) { if (loading) {
@@ -240,6 +231,14 @@ export default function InvoiceViewPage() {
payment={viewPayment} payment={viewPayment}
invoiceName={`${invoice.storeName} — ${truncId(invoice.id)}`} invoiceName={`${invoice.storeName} — ${truncId(invoice.id)}`}
/> />
<InvoiceActionDialog
open={actionOpen}
onClose={() => setActionOpen(false)}
onSuccess={fetchInvoice}
invoice={invoice}
mode={actionMode}
/>
</div> </div>
) )
} }
@@ -0,0 +1,119 @@
'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, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { InvoiceResponse, UserResponse } from '@/types/api'
const schema = z.object({
userId: z.string().min(1, 'This is required'),
})
type FormValues = z.infer<typeof schema>
interface InvoiceActionDialogProps {
open: boolean
onClose: () => void
onSuccess: () => void
invoice: InvoiceResponse | null
mode: 'print' | 'finalize'
}
const COPY = {
print: { title: 'Mark Invoice as Printed', label: 'Printed By', action: 'Mark as Printed', success: 'Invoice marked as printed' },
finalize: { title: 'Finalize Invoice', label: 'Finalized By', action: 'Finalize Invoice', success: 'Invoice finalized' },
}
export function InvoiceActionDialog({ open, onClose, onSuccess, invoice, mode }: InvoiceActionDialogProps) {
const [usersList, setUsersList] = useState<UserResponse[]>([])
const copy = COPY[mode]
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { userId: '' },
})
useEffect(() => {
if (!open) return
form.reset({ userId: '' })
usersApi.list({ pageSize: 200 })
.then((res) => setUsersList(getPaginatedItems(res)))
.catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }))
}, [open])
if (!invoice) return null
async function onSubmit(values: FormValues) {
try {
if (mode === 'print') await invoicesApi.print(invoice!.id, values.userId)
else await invoicesApi.finalize(invoice!.id, values.userId)
toast.add({ title: copy.success, type: 'success' })
onSuccess()
onClose()
} catch (err) {
toast.add({ title: `Failed to ${mode} invoice`, description: getApiError(err), type: 'error' })
}
}
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) onClose() }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{copy.title}</DialogTitle>
<DialogDescription>{invoice.storeName} — Rs. {invoice.totalAmount.toLocaleString()}</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<FormField
control={form.control}
name="userId"
label={copy.label}
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>
<SelectItem value="" disabled>Select user...</SelectItem>
{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}>
{form.formState.isSubmitting ? 'Saving…' : copy.action}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -10,6 +10,7 @@ import { invoices as invoicesApi, getApiError, getPaginatedItems } from '@/lib/a
import type { InvoiceResponse, InvoiceStatus, PaymentStatus } from '@/types/api' import type { InvoiceResponse, InvoiceStatus, PaymentStatus } from '@/types/api'
import { OrderViewSheet } from './order-view-sheet' import { OrderViewSheet } from './order-view-sheet'
import { InvoiceActionDialog } from './invoice-action-dialog'
const fmtDate = (v: string) => const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
@@ -38,6 +39,8 @@ export function OrdersClient() {
const [viewOpen, setViewOpen] = useState(false) const [viewOpen, setViewOpen] = useState(false)
const [selectedInvoice, setSelectedInvoice] = useState<InvoiceResponse | null>(null) const [selectedInvoice, setSelectedInvoice] = useState<InvoiceResponse | null>(null)
const [actionOpen, setActionOpen] = useState(false)
const [actionMode, setActionMode] = useState<'print' | 'finalize'>('print')
const fetchData = useCallback(async (p: number, ps: number) => { const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true) setLoading(true)
@@ -61,24 +64,16 @@ export function OrdersClient() {
setViewOpen(true) setViewOpen(true)
} }
async function handlePrint(row: InvoiceResponse) { function handlePrint(row: InvoiceResponse) {
try { setSelectedInvoice(row)
await invoicesApi.print(row.id) setActionMode('print')
toast.add({ title: 'Invoice marked as printed', type: 'success' }) setActionOpen(true)
fetchData(page, pageSize)
} catch (err) {
toast.add({ title: 'Failed to print invoice', description: getApiError(err), type: 'error' })
}
} }
async function handleFinalize(row: InvoiceResponse) { function handleFinalize(row: InvoiceResponse) {
try { setSelectedInvoice(row)
await invoicesApi.finalize(row.id) setActionMode('finalize')
toast.add({ title: 'Invoice finalized', type: 'success' }) setActionOpen(true)
fetchData(page, pageSize)
} catch (err) {
toast.add({ title: 'Failed to finalize invoice', description: getApiError(err), type: 'error' })
}
} }
const tableConfig = useMemo<TableConfig<InvoiceResponse>>(() => ({ const tableConfig = useMemo<TableConfig<InvoiceResponse>>(() => ({
@@ -187,6 +182,14 @@ export function OrdersClient() {
onClose={() => setViewOpen(false)} onClose={() => setViewOpen(false)}
invoice={selectedInvoice} invoice={selectedInvoice}
/> />
<InvoiceActionDialog
open={actionOpen}
onClose={() => setActionOpen(false)}
onSuccess={() => fetchData(page, pageSize)}
invoice={selectedInvoice}
mode={actionMode}
/>
</> </>
) )
} }
+4 -4
View File
@@ -78,11 +78,11 @@ export const invoices = {
get: (id: string) => get: (id: string) =>
api.get<InvoiceResponse>(`/invoices/${id}`).then(r => r.data), api.get<InvoiceResponse>(`/invoices/${id}`).then(r => r.data),
print: (id: string) => print: (id: string, printedByUserId?: string | null) =>
api.post<InvoiceResponse>(`/invoices/${id}/print`).then(r => r.data), api.post<InvoiceResponse>(`/invoices/${id}/print`, { printedByUserId }).then(r => r.data),
finalize: (id: string) => finalize: (id: string, finalizedByUserId?: string | null) =>
api.post<InvoiceResponse>(`/invoices/${id}/finalize`).then(r => r.data), api.post<InvoiceResponse>(`/invoices/${id}/finalize`, { finalizedByUserId }).then(r => r.data),
} }
// ─── Payments ───────────────────────────────────────────────────────────────── // ─── Payments ─────────────────────────────────────────────────────────────────