diff --git a/distribution_fe/app/(dashboard)/sales/invoices/[id]/edit/page.tsx b/distribution_fe/app/(dashboard)/sales/invoices/[id]/edit/page.tsx new file mode 100644 index 0000000..05ea17b --- /dev/null +++ b/distribution_fe/app/(dashboard)/sales/invoices/[id]/edit/page.tsx @@ -0,0 +1,342 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { useParams, useRouter } from 'next/navigation' +import { HugeiconsIcon } from '@hugeicons/react' +import { Add01Icon, ArrowLeft01Icon, Delete02Icon, Undo02Icon } from '@hugeicons/core-free-icons' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { InvoiceTemplate } from '@/components/invoice/invoice-template' +import { toast } from '@/components/ui/toast' +import { + invoices as invoicesApi, + users as usersApi, + items as itemsApi, + priceLists as priceListsApi, + getApiError, + getPaginatedItems, +} from '@/lib/api/index' +import type { InvoiceItemResponse, InvoiceResponse, ItemSummaryResponse, UserResponse } from '@/types/api' + +interface AddedLine { + key: string + itemId: string + name: string + qty: string + price: string +} + +const NEW_PREFIX = 'new:' +const isNew = (item: InvoiceItemResponse) => item.id.startsWith(NEW_PREFIX) +const addedKey = (item: InvoiceItemResponse) => item.id.slice(NEW_PREFIX.length) + +// Full-page editor for a Draft invoice, on the invoice template: change quantity/price, delete (strike) +// lines and add items that were not on the PO. Each save is recorded as a new invoice version. +export default function InvoiceEditPage() { + const router = useRouter() + const params = useParams<{ id: string }>() + const invoiceId = params.id + const backToInvoice = () => router.push(`/sales/invoices/${invoiceId}`) + + const [full, setFull] = useState(null) + const [loading, setLoading] = useState(true) + const [qty, setQty] = useState>({}) + const [price, setPrice] = useState>({}) + const [removed, setRemoved] = useState>({}) + const [added, setAdded] = useState([]) + const [catalogue, setCatalogue] = useState([]) + const [pickItemId, setPickItemId] = useState('') + const [pickQty, setPickQty] = useState('1') + const [pickPrice, setPickPrice] = useState('') + const [userId, setUserId] = useState('') + const [usersList, setUsersList] = useState([]) + const [saving, setSaving] = useState(false) + + useEffect(() => { + invoicesApi.get(invoiceId) + .then((inv) => { + if (inv.status !== 'Draft') { + toast.add({ title: 'Only Draft invoices can be edited', type: 'error' }) + router.replace(`/sales/invoices/${invoiceId}`) + return + } + setFull(inv) + setQty(Object.fromEntries(inv.items.map((i) => [i.id, String(i.quantity)]))) + setPrice(Object.fromEntries(inv.items.map((i) => [i.id, String(i.unitPrice)]))) + setRemoved(Object.fromEntries(inv.items.map((i) => [i.id, i.isRemoved]))) + }) + .catch((err) => { + toast.add({ title: 'Failed to load invoice', description: getApiError(err), type: 'error' }) + router.push('/sales/orders') + }) + .finally(() => setLoading(false)) + usersApi.list() + .then(setUsersList) + .catch((err) => toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' })) + itemsApi.list({ pageSize: 200 }) + .then((res) => setCatalogue(getPaginatedItems(res))) + .catch((err) => toast.add({ title: 'Failed to load items', description: getApiError(err), type: 'error' })) + }, [invoiceId, router]) + + // The template renders this copy so line amounts and the total follow what is typed. + const live = useMemo(() => { + if (!full) return null + const existing = full.items.map((i) => ({ + ...i, + quantity: Number(qty[i.id]) || 0, + unitPrice: Number(price[i.id]) || 0, + isRemoved: !!removed[i.id], + })) + const extra: InvoiceItemResponse[] = added.map((a) => ({ + id: NEW_PREFIX + a.key, + shopPOItemId: null, + itemId: a.itemId, + itemName: a.name, + orderedQuantity: null, + quantity: Number(a.qty) || 0, + unitPrice: Number(a.price) || 0, + isRemoved: false, + })) + const items = [...existing, ...extra] + const totalAmount = items.filter((i) => !i.isRemoved).reduce((s, i) => s + i.quantity * i.unitPrice, 0) + return { ...full, items, totalAmount } + }, [full, qty, price, removed, added]) + + // Items not already on the invoice (a deleted line can be restored with Undo instead). + const availableItems = catalogue.filter((c) => !live?.items.some((i) => i.itemId === c.id)) + + // Picking an item fills in its price: the store's price list, falling back to the item's default price. + async function pickItem(itemId: string) { + setPickItemId(itemId) + const item = catalogue.find((c) => c.id === itemId) + if (!item || !full) return + setPickPrice(String(item.salePrice)) + try { + const res = await priceListsApi.resolve({ + itemId, + storeId: full.storeId, + saleDate: new Date().toISOString().slice(0, 10), + }) + setPickPrice(String(res.price)) + } catch { + // No price list entry for this store — keep the default price. + } + } + + // The line currently typed into the add row, or null when nothing valid is picked. + function pendingLine(): AddedLine | null { + const item = catalogue.find((c) => c.id === pickItemId) + if (!item || !(Number(pickQty) > 0) || pickPrice === '' || Number(pickPrice) < 0) return null + return { key: crypto.randomUUID(), itemId: item.id, name: item.name, qty: pickQty, price: pickPrice } + } + + function addItem() { + const line = pendingLine() + if (!line) { + toast.add({ title: 'Pick an item and enter a quantity above zero', type: 'error' }) + return + } + setAdded((a) => [...a, line]) + setPickItemId('') + setPickQty('1') + setPickPrice('') + } + + const changeSummary = useMemo(() => { + if (!full) return [] + const edited = full.items.filter( + (i) => !removed[i.id] && (Number(qty[i.id]) !== i.quantity || Number(price[i.id]) !== i.unitPrice), + ).length + const struck = full.items.filter((i) => !!removed[i.id] !== i.isRemoved).length + return [ + added.length && `${added.length} added`, + struck && `${struck} removed/restored`, + edited && `${edited} edited`, + ].filter(Boolean) as string[] + }, [full, qty, price, removed, added]) + + const dirty = changeSummary.length > 0 + + // Warn before the tab is closed/reloaded with unsaved edits. + useEffect(() => { + if (!dirty) return + const warn = (e: BeforeUnloadEvent) => e.preventDefault() + window.addEventListener('beforeunload', warn) + return () => window.removeEventListener('beforeunload', warn) + }, [dirty]) + + async function save() { + if (!full) return + if (!userId) { + toast.add({ title: 'Select who is editing', type: 'error' }) + return + } + // An item picked in the add row but not yet added is included, so it is not silently lost. + const pending = pendingLine() + const newLines = pending ? [...added, pending] : added + setSaving(true) + try { + await invoicesApi.updateItems( + full.id, + [ + ...full.items.map((i) => ({ + invoiceItemId: i.id, + quantity: Number(qty[i.id]), + unitPrice: Number(price[i.id]), + isRemoved: !!removed[i.id], + })), + ...newLines.map((a) => ({ itemId: a.itemId, quantity: Number(a.qty), unitPrice: Number(a.price) })), + ], + userId, + ) + toast.add({ title: 'Invoice updated', type: 'success' }) + // Navigating away right after a successful save; clear the unsaved-changes guard first. + setAdded([]) + setQty(Object.fromEntries(full.items.map((i) => [i.id, String(i.quantity)]))) + setPrice(Object.fromEntries(full.items.map((i) => [i.id, String(i.unitPrice)]))) + setRemoved(Object.fromEntries(full.items.map((i) => [i.id, i.isRemoved]))) + backToInvoice() + } catch (err) { + toast.add({ title: 'Failed to update invoice', description: getApiError(err), type: 'error' }) + } finally { + setSaving(false) + } + } + + return ( +
+
+
+ +
+
+

Edit Draft Invoice

+ {full && {full.status}} +
+

+ Change quantities and prices, delete lines, or add items. Quantity on a PO line cannot exceed the + ordered quantity. Every save creates a new version. +

+
+
+
+ + {loading || !live ? ( +

Loading invoice…

+ ) : ( + <> + ( + a.key === addedKey(item))?.qty ?? '' : qty[item.id] ?? ''} + onChange={(e) => isNew(item) + ? setAdded((a) => a.map((l) => l.key === addedKey(item) ? { ...l, qty: e.target.value } : l)) + : setQty((q) => ({ ...q, [item.id]: e.target.value }))} + /> + )} + renderUnitPrice={(item) => ( + a.key === addedKey(item))?.price ?? '' : price[item.id] ?? ''} + onChange={(e) => isNew(item) + ? setAdded((a) => a.map((l) => l.key === addedKey(item) ? { ...l, price: e.target.value } : l)) + : setPrice((p) => ({ ...p, [item.id]: e.target.value }))} + /> + )} + renderActions={(item) => ( + + )} + footerRow={ + // Cells line up with the table columns: Item · Qty · Unit Price · Amount · action. + { if (e.key === 'Enter') { e.preventDefault(); addItem() } }}> + + + + + setPickQty(e.target.value)} + /> + + + setPickPrice(e.target.value)} + /> + + + {pickItemId ? ((Number(pickQty) || 0) * (Number(pickPrice) || 0)).toLocaleString('en-US', { minimumFractionDigits: 2 }) : ''} + + + + + + } + /> + +
+
+ +
+

+ {dirty ? `Unsaved: ${changeSummary.join(', ')}` : 'No changes yet'} +

+
+ + +
+
+ + )} +
+ ) +} diff --git a/distribution_fe/app/(dashboard)/sales/invoices/[id]/page.tsx b/distribution_fe/app/(dashboard)/sales/invoices/[id]/page.tsx index aac3dae..6e4f4fc 100644 --- a/distribution_fe/app/(dashboard)/sales/invoices/[id]/page.tsx +++ b/distribution_fe/app/(dashboard)/sales/invoices/[id]/page.tsx @@ -8,12 +8,19 @@ import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { toast } from '@/components/ui/toast' import { invoices as invoicesApi, payments as paymentsApi, getApiError } from '@/lib/api/index' -import type { InvoiceResponse, InvoiceStatus, PaymentResponse, PaymentMethod, PaymentStatus } from '@/types/api' +import type { InvoiceResponse, InvoiceStatus, InvoiceVersionResponse, PaymentResponse, PaymentMethod, PaymentStatus } from '@/types/api' import { PaymentSheet } from '../../payments/_components/payment-sheet' import { PaymentViewSheet } from '../../payments/_components/payment-view-sheet' import { InvoiceActionDialog } from '../../orders/_components/invoice-action-dialog' +const STAGE_LABEL: Record = { + Created: 'Created', + DraftEdited: 'Draft edited', + Printed: 'Printed', + Finalized: 'Finalized', +} + const INVOICE_STATUS_VARIANT: Record = { Draft: 'secondary', Printed: 'outline', @@ -61,10 +68,12 @@ export default function InvoiceViewPage() { const [viewPayment, setViewPayment] = useState(null) const [actionOpen, setActionOpen] = useState(false) const [actionMode, setActionMode] = useState<'print' | 'finalize'>('print') + const [versions, setVersions] = useState([]) const fetchInvoice = useCallback(async () => { - const inv = await invoicesApi.get(params.id) + const [inv, vers] = await Promise.all([invoicesApi.get(params.id), invoicesApi.versions(params.id)]) setInvoice(inv) + setVersions(vers) return inv }, [params.id]) @@ -139,11 +148,21 @@ export default function InvoiceViewPage() {
+ {invoice.status === 'Draft' && ( + + )} {invoice.status === 'Draft' && ( )} + {invoice.status !== 'Draft' && ( + + )} {invoice.status === 'Printed' && (
+
+
+

Items

+

Ordered quantity is the original PO; quantity is what is billed now.

+
+
+ + + + + + + + + + + + {invoice.items.length === 0 ? ( + + + + ) : ( + invoice.items.map((i) => ( + + + + + + + + )) + )} + +
ItemOrderedBilledUnit priceLine total
No items.
+ {i.itemName} + {i.orderedQuantity === null && Added} + {i.isRemoved && Removed} + {i.orderedQuantity ?? '—'} + {i.quantity} + {fmtAmount(i.unitPrice)}{i.isRemoved ? '—' : fmtAmount(i.quantity * i.unitPrice)}
+
+
+ +
+
+

Version history

+

+ Quantities after each change, so you can compare the original, printed and finalized copies. +

+
+
+ + + + + + + + + + + + {versions.map((v) => ( + + + + + + + + ))} + +
VersionStageWhenQuantitiesTotal
v{v.versionNumber}{STAGE_LABEL[v.stage]}{fmtDateTime(v.createdAt)} + {v.items.map((i) => (i.isRemoved ? `${i.itemName} (removed)` : `${i.itemName} × ${i.quantity}`)).join(', ') || '—'} + {fmtAmount(v.totalAmount)}
+
+
+

Payments