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:
@@ -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(); }
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ─────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user