diff --git a/Backend/ERPCore/Controllers/VendorsController.cs b/Backend/ERPCore/Controllers/VendorsController.cs index 29f3175..05199c7 100644 --- a/Backend/ERPCore/Controllers/VendorsController.cs +++ b/Backend/ERPCore/Controllers/VendorsController.cs @@ -65,4 +65,15 @@ public sealed class VendorsController : ApiControllerBase await _vendors.SetStatusAsync(vendorId, request.Status, ct); return NoContent(); } + + [HttpDelete("{vendorId:int}")] + [Authorize(Policy = NavPolicies.Prefix + "vendors" + NavPolicies.ActionSeparator + NavPolicies.DeleteAction)] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Delete(int vendorId, CancellationToken ct) + { + await _vendors.DeleteAsync(vendorId, ct); + return NoContent(); + } } diff --git a/Backend/ERPCore/Dtos/Procurement/PurchaseInvoiceDtos.cs b/Backend/ERPCore/Dtos/Procurement/PurchaseInvoiceDtos.cs index 483b8db..69e293e 100644 --- a/Backend/ERPCore/Dtos/Procurement/PurchaseInvoiceDtos.cs +++ b/Backend/ERPCore/Dtos/Procurement/PurchaseInvoiceDtos.cs @@ -31,6 +31,8 @@ public sealed class CreatePurchaseInvoicePaymentRequest [Range(0.01, double.MaxValue)] public decimal Amount { get; set; } [Required] public long GlBankAccountId { get; set; } [StringLength(100)] public string? Reference { get; set; } + /// When the vendor was paid; also the GL entry date. Omitted ⇒ today. Never in the future. + public DateOnly? PaymentDate { get; set; } } public sealed class CreatePurchaseInvoiceRequest diff --git a/Backend/ERPCore/Services/Interfaces/IVendorService.cs b/Backend/ERPCore/Services/Interfaces/IVendorService.cs index bc78b41..75a3077 100644 --- a/Backend/ERPCore/Services/Interfaces/IVendorService.cs +++ b/Backend/ERPCore/Services/Interfaces/IVendorService.cs @@ -13,4 +13,5 @@ public interface IVendorService Task> CreateAsync(CreateVendorRequest request, CancellationToken ct = default); Task> UpdateAsync(int vendorId, UpdateVendorRequest request, uint expectedRowVersion, CancellationToken ct = default); Task SetStatusAsync(int vendorId, EntityStatus status, CancellationToken ct = default); + Task DeleteAsync(int vendorId, CancellationToken ct = default); } diff --git a/Backend/ERPCore/Services/PurchaseInvoicePaymentService.cs b/Backend/ERPCore/Services/PurchaseInvoicePaymentService.cs index 66aec8c..52ff97f 100644 --- a/Backend/ERPCore/Services/PurchaseInvoicePaymentService.cs +++ b/Backend/ERPCore/Services/PurchaseInvoicePaymentService.cs @@ -59,6 +59,7 @@ public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentServi var actor = _currentUser.AuditUserId; var now = DateTime.UtcNow; + var (paymentDay, paymentDate) = ResolvePaymentDate(request.PaymentDate, now); var payment = await _uow.ExecuteInTransactionAsync(async token => { @@ -82,7 +83,7 @@ public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentServi { PurchaseInvoiceId = invoice.PurchaseInvoiceId, Amount = request.Amount, - PaymentDate = now, + PaymentDate = paymentDate, GlBankAccountId = account.AccountId, BankAccountName = account.AccountName, Reference = request.Reference, @@ -101,6 +102,21 @@ public sealed class PurchaseInvoicePaymentService : IPurchaseInvoicePaymentServi return Map(payment); } + /// + /// The payment's calendar day (also the GL entry date) and the timestamp stored on it — same + /// rule as . Omitted ⇒ today. One day of slack on + /// "future": the client sends its local date, which runs ahead of UTC for users east of it. + /// Today keeps the actual time of entry; a back-dated payment is stamped at that day's start. + /// + private static (DateOnly Day, DateTime Stamp) ResolvePaymentDate(DateOnly? requested, DateTime now) + { + var today = DateOnly.FromDateTime(now); + var day = requested ?? today; + if (day > today.AddDays(1)) + throw new DomainException(ErrorCodes.Validation, "Payment date cannot be in the future.", 400); + return (day, day >= today ? now : day.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)); + } + public async Task> ListAsync(int purchaseInvoiceId, CancellationToken ct = default) { if (!await _invoices.Query().AnyAsync(i => i.PurchaseInvoiceId == purchaseInvoiceId, ct)) diff --git a/Backend/ERPCore/Services/RequisitionService.cs b/Backend/ERPCore/Services/RequisitionService.cs index 36b096c..06dfc5a 100644 --- a/Backend/ERPCore/Services/RequisitionService.cs +++ b/Backend/ERPCore/Services/RequisitionService.cs @@ -66,7 +66,11 @@ public sealed class RequisitionService : IRequisitionService { await EnsureItemsExistAsync(request.Lines.Select(l => l.ItemId), ct); var actor = _currentUser.AuditUserId; - var branchId = await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct); + // Branch is optional on a requisition: an unset one falls back to the default branch + // rather than being rejected (this also covers reorder-alert requisitions, which carry none). + var branchId = request.BranchId > 0 + ? await _branchConfig.ResolveBranchIdAsync(request.BranchId, ct) + : await _branchConfig.ResolveDefaultBranchIdAsync(ct); var req = await _uow.ExecuteInTransactionAsync(async token => { diff --git a/Backend/ERPCore/Services/VendorService.cs b/Backend/ERPCore/Services/VendorService.cs index 238865a..ca17045 100644 --- a/Backend/ERPCore/Services/VendorService.cs +++ b/Backend/ERPCore/Services/VendorService.cs @@ -120,6 +120,26 @@ public sealed class VendorService : IVendorService await _uow.SaveChangesAsync(ct); } + /// + /// Hard delete. Every FK to vendors (POs, GRNs, invoices, returns, quotations, item + /// default vendor) is Restrict, so a vendor in use is refused with 409 — deactivate it instead. + /// + public async Task DeleteAsync(int vendorId, CancellationToken ct = default) + { + var vendor = await _vendors.GetByIdAsync(vendorId, ct) + ?? throw new NotFoundException($"Vendor {vendorId} was not found."); + + _vendors.Remove(vendor); + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateException) + { + throw new DomainException(ErrorCodes.MasterInUse, "This vendor is used by purchase documents or items and cannot be deleted. Deactivate it instead.", 409); + } + } + private static VendorDto Map(Vendor v) => new( v.VendorId, v.Code, v.Name, v.Terms, v.TaxReg, v.Currency, v.Status, v.BranchId, v.CreatedAt, v.UpdatedAt); } diff --git a/Backend/ERPCore/appsettings.Development.json b/Backend/ERPCore/appsettings.Development.json index c80290e..7ff3bb5 100644 --- a/Backend/ERPCore/appsettings.Development.json +++ b/Backend/ERPCore/appsettings.Development.json @@ -10,5 +10,8 @@ }, "AuthHex": { "BaseUrl": "http://localhost:5011" + }, + "GeneralLedgerService": { + "BaseUrl": "http://localhost:5200/api/v1/" } } diff --git a/Frontend/erp-system/app/apple-icon.png b/Frontend/erp-system/app/apple-icon.png new file mode 100644 index 0000000..b6caa7b Binary files /dev/null and b/Frontend/erp-system/app/apple-icon.png differ diff --git a/Frontend/erp-system/app/dashboard/customers/page.tsx b/Frontend/erp-system/app/dashboard/customers/page.tsx index 235ea61..6478a7a 100644 --- a/Frontend/erp-system/app/dashboard/customers/page.tsx +++ b/Frontend/erp-system/app/dashboard/customers/page.tsx @@ -33,6 +33,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Skeleton } from "@/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { toast } from "@/components/ui/toast" +import { EditCustomerDialog } from "@/components/customers/EditCustomerDialog" type StatusFilter = EntityStatus | "All" type TypeFilter = CustomerType | "All" @@ -63,6 +64,10 @@ export default function CustomersPage() { const [newCustomerType, setNewCustomerType] = useState("B2C") const [phone, setPhone] = useState("") const [email, setEmail] = useState("") + const [addressLine1, setAddressLine1] = useState("") + const [addressLine2, setAddressLine2] = useState("") + const [city, setCity] = useState("") + const [country, setCountry] = useState("") const [creditLimit, setCreditLimit] = useState("") const [creditDays, setCreditDays] = useState("") const [errors, setErrors] = useState>({}) @@ -72,6 +77,8 @@ export default function CustomersPage() { // customer's own code (mirrors the vendor create dialog). const [allCustomerCodes, setAllCustomerCodes] = useState([]) const [actionPendingId, setActionPendingId] = useState(null) + // The customer whose edit popup is open. + const [editingId, setEditingId] = useState(null) const [showMoreLoading, setShowMoreLoading] = useState(false) const generatedCode = name.trim() ? generateCustomerCode(name, allCustomerCodes) : "" @@ -135,6 +142,10 @@ export default function CustomersPage() { setNewCustomerType("B2C") setPhone("") setEmail("") + setAddressLine1("") + setAddressLine2("") + setCity("") + setCountry("") setCreditLimit("") setCreditDays("") setBranchFilterId(null) @@ -156,6 +167,10 @@ export default function CustomersPage() { name, phone: phone || null, email: email || null, + addressLine1: addressLine1 || null, + addressLine2: addressLine2 || null, + city: city || null, + country: country || null, creditLimit: Number(creditLimit) || 0, creditDays: Number(creditDays) || 0, branchId: effectiveBranchId ?? 0, @@ -187,6 +202,17 @@ export default function CustomersPage() { } } + /** + * Row click → profile page. React bubbles events out of portals along the component tree, + * so a click on the delete confirmation (portaled to ) still reaches the row; only + * navigate for events whose DOM target really is inside the row and not an action cell. + */ + function openRow(e: React.MouseEvent | React.KeyboardEvent, customerId: number) { + const target = e.target as HTMLElement + if (!e.currentTarget.contains(target) || target.closest("[data-no-row-nav]")) return + router.push(`/dashboard/customers/${customerId}`) + } + const hasFilters = query.length > 0 || status !== "All" || customerType !== "All" return ( @@ -213,7 +239,7 @@ export default function CustomersPage() { - Name + Name setName(e.target.value)} placeholder="Nimal Perera" aria-invalid={!!errors.name} /> @@ -243,6 +269,24 @@ export default function CustomersPage() { setEmail(e.target.value)} placeholder="customer@example.com" /> + + Address line 1 (optional) + setAddressLine1(e.target.value)} placeholder="No. 12, Galle Road" /> + + + Address line 2 (optional) + setAddressLine2(e.target.value)} placeholder="Wellawatte" /> + +
+ + City (optional) + setCity(e.target.value)} placeholder="Colombo" /> + + + Country (optional) + setCountry(e.target.value)} placeholder="Sri Lanka" /> + +
Credit limit (LKR) @@ -255,7 +299,7 @@ export default function CustomersPage() {
{showBranchPicker && ( - Branch + Branch value={branchFilterId} onValueChange={(v) => setBranchFilterId(v)}> @@ -357,12 +401,9 @@ export default function CustomersPage() { key={c.customerId} role="link" tabIndex={0} - onClick={(e) => { - if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return - router.push(`/dashboard/customers/${c.customerId}`) - }} + onClick={(e) => openRow(e, c.customerId)} onKeyDown={(e) => { - if (e.key === "Enter") router.push(`/dashboard/customers/${c.customerId}`) + if (e.key === "Enter") openRow(e, c.customerId) }} className="flex flex-col gap-3 rounded-xl border bg-card p-4 shadow-sm" > @@ -398,14 +439,15 @@ export default function CustomersPage() {
- setEditingId(c.customerId)} > - + { - if ((e.target as HTMLElement).closest("[data-no-row-nav]")) return - router.push(`/dashboard/customers/${c.customerId}`) - }} + onClick={(e) => openRow(e, c.customerId)} > @@ -497,14 +536,15 @@ export default function CustomersPage() { > - setEditingId(c.customerId)} > - + )} + + {editingId !== null && ( + setEditingId(null)} + onSaved={(saved) => { + // The code is editable here — swap it so later creates de-dupe against the new one. + const oldCode = customers?.find((c) => c.customerId === saved.customerId)?.customerCode + setAllCustomerCodes((codes) => [...codes.filter((c) => c !== oldCode), saved.customerCode]) + setEditingId(null) + load() + }} + /> + )}
) } diff --git a/Frontend/erp-system/app/dashboard/page.tsx b/Frontend/erp-system/app/dashboard/page.tsx index 9e22166..f00c9e9 100644 --- a/Frontend/erp-system/app/dashboard/page.tsx +++ b/Frontend/erp-system/app/dashboard/page.tsx @@ -24,6 +24,7 @@ import { DashboardStats } from "@/types/dashboard" import { LedgerEntry } from "@/types/stock" import { Warehouse } from "@/types/master-data" import { useAuth } from "@/components/auth/AuthProvider" +import { useIsMobile } from "@/hooks/use-mobile" import { StatCard } from "@/components/ui/stat-card" import { Skeleton } from "@/components/ui/skeleton" import { Badge } from "@/components/ui/badge" @@ -33,6 +34,8 @@ import BarChart from "@/components/ui/bar-chart" import LineChart from "@/components/ui/line-chart" const TREND_DAYS = 14 +/** Fewest bar slots the valuation chart's axis spans, so a couple of bars sit at the start. */ +const CHART_MIN_SLOTS = 5 function isoDate(d: Date) { return d.toISOString().slice(0, 10) @@ -114,6 +117,13 @@ export default function DashboardPage() { } }, [trend]) + // Same fixed-axis padding as the product page's chart: with fewer than CHART_MIN_SLOTS + // warehouses, Chart.js would center the bars across the full width. Trailing blank slots keep + // the bars at the start. Skipped on mobile, where the bars should stretch to fill the card. + const isMobile = useIsMobile() + const valuationRows = stats?.stockValuationByWarehouse ?? [] + const valuationPad = isMobile ? 0 : Math.max(0, CHART_MIN_SLOTS - valuationRows.length) + const loaded = stats && movements && trend && warehouses return ( @@ -172,16 +182,18 @@ export default function DashboardPage() { (stats.stockValuationByWarehouse ?? []).length > 0 ? (
warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}` - )} + labels={[ + ...valuationRows.map((w) => warehousesById.get(w.warehouseId)?.code ?? `#${w.warehouseId}`), + ...Array(valuationPad).fill(""), + ]} datasets={[ { label: "Stock Value (LKR)", - data: stats.stockValuationByWarehouse.map((w) => w.total), + data: [...valuationRows.map((w) => w.total), ...Array(valuationPad).fill(null)], backgroundColor: "#E11D48", }, ]} + barPercentage={0.6} />
) : ( diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/[id]/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/[id]/page.tsx index d834e37..1bc88b2 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/[id]/page.tsx @@ -294,7 +294,13 @@ export default function PurchaseInvoiceDetailPage({ params }: { params: Promise< - + router.push("/dashboard/receiving/grn")} + /> ) } diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/page.tsx index b4c4e91..1998a65 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-invoices/page.tsx @@ -433,7 +433,13 @@ export default function PurchaseInvoicesPage() { )} - + router.push("/dashboard/receiving/grn")} + /> ) } diff --git a/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx b/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx index 22ee546..896c151 100644 --- a/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/procurement/purchase-returns/new/page.tsx @@ -9,6 +9,8 @@ import { purchaseReturnsApi } from "@/lib/api/purchase-returns" import { grnsApi } from "@/lib/api/grns" import { reasonCodesApi } from "@/lib/api/reason-codes" import { itemsApi } from "@/lib/api/items" +import { vendorsApi } from "@/lib/api/vendors" +import { warehousesApi } from "@/lib/api/warehouses" import { errorMessage } from "@/lib/error-map" import { validateReturnLine } from "@/lib/validations/procurement" import { cn } from "@/lib/utils" @@ -23,11 +25,17 @@ import { Label } from "@/components/ui/label" import { Checkbox } from "@/components/ui/checkbox" import { FieldError } from "@/components/ui/field" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Combobox } from "@/components/ui/combobox" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Skeleton } from "@/components/ui/skeleton" import { toast } from "@/components/ui/toast" import { HoldStatusBadge } from "@/components/receiving/status-badges" +// The stock checkbox (16px, faint border) and qty box are easy to miss on this form, so both are +// enlarged and given a solid border here. +const LINE_CHECKBOX = "size-5 shrink-0 border-2 border-muted-foreground/60" +const RETURN_QTY_INPUT = "h-11 border-2 border-muted-foreground/40 bg-background text-base font-medium md:text-base" + interface LineState { selected: boolean qty: string @@ -42,6 +50,8 @@ function NewPurchaseReturnContent() { const [grns, setGrns] = useState(null) const [items, setItems] = useState([]) const [reasonCodes, setReasonCodes] = useState([]) + const [vendorNames, setVendorNames] = useState>(new Map()) + const [warehouseNames, setWarehouseNames] = useState>(new Map()) const [loadError, setLoadError] = useState(null) const [grnId, setGrnId] = useState(presetGrnId) @@ -62,8 +72,18 @@ function NewPurchaseReturnContent() { setReasonCodes(rc.items) }) .catch((err) => setLoadError(errorMessage(err))) + + // Names only make the GRN picker searchable by vendor/warehouse — if these fail the + // labels fall back to "#id", so they don't block the form. + vendorsApi.list({ pageSize: 200 }).then((r) => setVendorNames(new Map(r.items.map((v) => [v.vendorId, v.name])))).catch(() => {}) + warehousesApi.list({ pageSize: 200 }).then((r) => setWarehouseNames(new Map(r.items.map((w) => [w.warehouseId, w.name])))).catch(() => {}) }, []) + const grnOptions = (grns ?? []).map((g) => ({ + value: g.grnId, + label: `${g.docNo} — ${vendorNames.get(g.vendorId) ?? `Vendor #${g.vendorId}`}, ${warehouseNames.get(g.warehouseId) ?? `Warehouse #${g.warehouseId}`}`, + })) + const selectedGrn = grns?.find((g) => g.grnId === grnId) ?? null useEffect(() => { @@ -87,14 +107,18 @@ function NewPurchaseReturnContent() { } function toggleLine(line: GrnLine) { - setLineState((prev) => ({ - ...prev, - [line.grnLineId]: { selected: !prev[line.grnLineId]?.selected, qty: prev[line.grnLineId]?.qty || String(line.qty) }, - })) + setLineState((prev) => { + const selected = !prev[line.grnLineId]?.selected + // Ticking pre-fills the full received qty; unticking clears it, so a qty is only ever + // shown on lines that will actually be returned. + return { ...prev, [line.grnLineId]: { selected, qty: selected ? prev[line.grnLineId]?.qty || String(line.qty) : "" } } + }) } + // Typing a quantity ticks the line (and clearing it / entering 0 unticks it), so the qty box + // can stay editable instead of sitting greyed-out until the checkbox is found and ticked. function setQty(grnLineId: number, qty: string) { - setLineState((prev) => ({ ...prev, [grnLineId]: { ...prev[grnLineId], qty } })) + setLineState((prev) => ({ ...prev, [grnLineId]: { ...prev[grnLineId], qty, selected: Number(qty) > 0 } })) } async function handleSubmit() { @@ -173,18 +197,15 @@ function NewPurchaseReturnContent() {
- value={grnId} onValueChange={setGrnId} disabled={!!presetGrnId}> - - - - - {(grns ?? []).map((g) => ( - - {g.docNo} — Vendor #{g.vendorId}, Warehouse #{g.warehouseId} - - ))} - - + + items={grnOptions} + value={grnId} + onValueChange={setGrnId} + disabled={!!presetGrnId} + placeholder="Search GRN — number, vendor or warehouse…" + emptyText="No confirmed GRNs match." + className="h-12 text-base" + />
@@ -218,9 +239,12 @@ function NewPurchaseReturnContent() { const state = lineState[line.grnLineId] ?? { selected: false, qty: "" } const errors = lineErrors[line.grnLineId] ?? {} return ( -
-
@@ -509,6 +530,22 @@ export default function VendorsPage() { )} )} + + {editingId !== null && ( + setEditingId(null)} + onSaved={(saved) => { + // A renamed vendor gets a regenerated code — swap it so later creates de-dupe correctly. + const oldCode = vendors?.find((v) => v.vendorId === saved.vendorId)?.code + setAllVendorCodes((codes) => [...codes.filter((c) => c !== oldCode), saved.code]) + setEditingId(null) + load() + }} + /> + )}
) } diff --git a/Frontend/erp-system/app/dashboard/warehouse/[id]/page.tsx b/Frontend/erp-system/app/dashboard/warehouse/[id]/page.tsx index 09919bd..e92dce7 100644 --- a/Frontend/erp-system/app/dashboard/warehouse/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/warehouse/[id]/page.tsx @@ -10,6 +10,7 @@ import { itemsApi } from "@/lib/api/items" import { errorMessage, fieldErrors } from "@/lib/error-map" import { Bin, ItemListItem, Warehouse } from "@/types/master-data" import { LedgerEntry, OnHand } from "@/types/stock" +import { useIsMobile } from "@/hooks/use-mobile" import { useAuth } from "@/components/auth/AuthProvider" import { Button } from "@/components/ui/button" @@ -31,6 +32,8 @@ import BarChart from "@/components/ui/bar-chart" /** Charts stay legible with a lot of items only by capping to the top N by on-hand qty. */ const CHART_ROW_LIMIT = 15 +/** Fewest bar slots the charts' axis spans, so a couple of bars sit at the start (see padSlots). */ +const CHART_MIN_SLOTS = 5 const PAGE_SIZE_OPTIONS = [5, 10, 15, 20] @@ -120,8 +123,18 @@ export default function WarehouseDetailPage() { const stockRows = useMemo(() => (stock ?? []).slice().sort((a, b) => b.onHand - a.onHand), [stock]) const totalOnHand = stockRows.reduce((sum, r) => sum + r.onHand, 0) + // Same fixed-axis padding as the product page's chart: with fewer than CHART_MIN_SLOTS bars, + // Chart.js would center them across the full width. Trailing blank slots keep the bars at the + // start. Skipped on mobile, where the bars should stretch to fill the narrower card instead. + const isMobile = useIsMobile() + const padSlots = (count: number) => (isMobile ? 0 : Math.max(0, CHART_MIN_SLOTS - count)) + const chartRows = stockRows.slice(0, CHART_ROW_LIMIT) - const chartLabels = chartRows.map((r) => itemsById.get(r.itemId)?.sku ?? `#${r.itemId}`) + const chartPad = padSlots(chartRows.length) + const chartLabels = [ + ...chartRows.map((r) => itemsById.get(r.itemId)?.sku ?? `#${r.itemId}`), + ...Array(chartPad).fill(""), + ] const movementByItem = useMemo(() => { const map = new Map() @@ -137,12 +150,24 @@ export default function WarehouseDetailPage() { }, [ledger]) const movementChartRows = movementByItem.slice(0, CHART_ROW_LIMIT) - const movementChartLabels = movementChartRows.map((r) => itemsById.get(r.itemId)?.sku ?? `#${r.itemId}`) + const movementChartPad = padSlots(movementChartRows.length) + const movementChartLabels = [ + ...movementChartRows.map((r) => itemsById.get(r.itemId)?.sku ?? `#${r.itemId}`), + ...Array(movementChartPad).fill(""), + ] const pagedBins = bins?.slice((binsPage - 1) * binsPageSize, binsPage * binsPageSize) ?? [] const pagedStockRows = stockRows.slice((stockPage - 1) * stockPageSize, stockPage * stockPageSize) const pagedMovementRows = movementByItem.slice((movementPage - 1) * movementPageSize, movementPage * movementPageSize) + /** Closes the new-bin dialog and clears whatever was typed, so the next open starts blank. */ + function closeCreate() { + setOpen(false) + setCode("") + setBinType("") + setErrors({}) + } + async function handleCreate() { const nextErrors: Record = {} if (!code.trim()) nextErrors.code = "Bin code is required" @@ -153,10 +178,7 @@ export default function WarehouseDetailPage() { try { const bin = await warehousesApi.createBin(warehouseId, { code, binType: binType || null }) toast.success("Bin created", bin.code) - setOpen(false) - setCode("") - setBinType("") - setErrors({}) + closeCreate() loadBins() } catch (err) { const fe = fieldErrors(err) @@ -198,7 +220,7 @@ export default function WarehouseDetailPage() { )}
- + (v ? setOpen(true) : closeCreate())}> @@ -214,8 +236,8 @@ export default function WarehouseDetailPage() { - Code - setCode(e.target.value)} placeholder="A-01-01" aria-invalid={!!errors.code} /> + Code + { setCode(e.target.value); setErrors({}) }} placeholder="A-01-01" aria-invalid={!!errors.code} /> @@ -224,7 +246,7 @@ export default function WarehouseDetailPage() {
-
@@ -451,9 +482,18 @@ export default function WarehouseDetailPage() { r.in), backgroundColor: "rgba(34,197,94,0.6)" }, - { label: "Out", data: movementChartRows.map((r) => r.out), backgroundColor: "rgba(239,68,68,0.6)" }, + { + label: "In", + data: [...movementChartRows.map((r) => r.in), ...Array(movementChartPad).fill(null)], + backgroundColor: "rgba(34,197,94,0.6)", + }, + { + label: "Out", + data: [...movementChartRows.map((r) => r.out), ...Array(movementChartPad).fill(null)], + backgroundColor: "rgba(239,68,68,0.6)", + }, ]} + barPercentage={0.6} /> diff --git a/Frontend/erp-system/app/dashboard/warehouse/page.tsx b/Frontend/erp-system/app/dashboard/warehouse/page.tsx index a9eef3c..721bd5c 100644 --- a/Frontend/erp-system/app/dashboard/warehouse/page.tsx +++ b/Frontend/erp-system/app/dashboard/warehouse/page.tsx @@ -126,10 +126,30 @@ export default function WarehousesPage() { return () => clearTimeout(t) }, [search]) + /** State updater that drops one field's error once the user starts editing that field. */ + function dropError(key: string) { + return (prev: Record) => { + if (!prev[key]) return prev + const next = { ...prev } + delete next[key] + return next + } + } + function binCount(warehouseId: number) { return (bins ?? []).filter((b) => b.warehouseId === warehouseId).length } + /** Closes the create dialog and clears whatever was typed, so the next open starts blank. */ + function closeCreate() { + setOpen(false) + setName("") + setAddress("") + setPhone("") + setBranchId(0) + setErrors({}) + } + async function handleCreate() { const nextErrors: Record = {} if (!name.trim()) nextErrors.name = "Warehouse name is required" @@ -141,12 +161,7 @@ export default function WarehousesPage() { try { const warehouse = await warehousesApi.create({ code: generatedCode, name, address: address || null, phone: phone || null, branchId }) toast.success("Warehouse created", `${warehouse.code} — ${warehouse.name}`) - setOpen(false) - setName("") - setAddress("") - setPhone("") - setBranchId(0) - setErrors({}) + closeCreate() load() } catch (err) { // A 409 here means another creation raced ours for the same generated code — the @@ -219,7 +234,7 @@ export default function WarehousesPage() {

Multi-warehouse master data with per-warehouse bin/location structure (FR-WH-01, FR-MD-07).

- + (v ? setOpen(true) : closeCreate())}> @@ -235,8 +250,8 @@ export default function WarehousesPage() { - Name - setName(e.target.value)} placeholder="Main Warehouse - Negombo" aria-invalid={!!errors.name} /> + Name + { setName(e.target.value); setErrors(dropError("name")) }} placeholder="Main Warehouse - Negombo" aria-invalid={!!errors.name} /> @@ -253,8 +268,8 @@ export default function WarehousesPage() { {warehouseBranchingEnabled && ( - Branch - { setBranchId(v ? Number(v) : 0); setErrors(dropError("branchId")) }}> {branches.map((b) => {b.name})} @@ -263,7 +278,7 @@ export default function WarehousesPage() { )}
- + +
+ +
+ ) +} diff --git a/Frontend/erp-system/components/invoice-template/InvoicePrintKit.tsx b/Frontend/erp-system/components/invoice-template/InvoicePrintKit.tsx new file mode 100644 index 0000000..443a2b4 --- /dev/null +++ b/Frontend/erp-system/components/invoice-template/InvoicePrintKit.tsx @@ -0,0 +1,286 @@ +"use client" + +import { ReactNode, useRef, useState, useSyncExternalStore } from "react" +import { createPortal } from "react-dom" +import { Download, Loader2, Printer, Share2 } from "lucide-react" + +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" + +/* + * Shared plumbing for the printable invoice previews (sales and purchase): paper sizes, the + * sheet's building blocks, PDF export, and the preview frame with its Paper size / Share / + * Download / Print bar. Each invoice type only supplies its own A4 and receipt layouts. + */ + +// Deep-red accent from the reference layout — used for the heading underlines and header rule. +export const ACCENT = "#8b1c24" + +export type PaperSize = "A4" | "80mm" | "58mm" +export type ReceiptWidth = Exclude + +const PAPER_SIZES: { value: PaperSize; label: string; hint: string }[] = [ + { value: "A4", label: "A4", hint: "210 × 297 mm" }, + { value: "80mm", label: "80mm receipt", hint: "Thermal roll" }, + { value: "58mm", label: "58mm receipt", hint: "Small thermal roll" }, +] + +/** Sets the printer's page box — the browser print dialog picks this up as the paper size. */ +const PAGE_RULES: Record = { + A4: "@page { size: A4; margin: 12mm; }", + "80mm": "@page { size: 80mm auto; margin: 3mm; }", + "58mm": "@page { size: 58mm auto; margin: 2mm; }", +} + +/** Receipt sheet sizing per roll — 58mm gets tighter padding and a smaller font to fit ~32 chars a line. */ +export const RECEIPT_SHEET_CLASS: Record = { + "80mm": "w-[80mm] px-[4mm] py-[5mm] text-[11px]", + "58mm": "w-[58mm] px-[2.5mm] py-[4mm] text-[9.5px]", +} + +/** Screen chrome for the on-screen A4 page; in print the @page margin does the spacing. */ +export const A4_SHEET_CLASS = + "invoice-sheet mx-auto min-h-[297mm] w-[210mm] shrink-0 bg-white p-[12mm] text-sm text-neutral-800 shadow-md ring-1 ring-foreground/10 print:min-h-0 print:w-auto print:shadow-none print:ring-0" + +/** Screen chrome for a receipt roll; combine with RECEIPT_SHEET_CLASS[width]. */ +export const RECEIPT_SHEET_BASE_CLASS = + "invoice-sheet mx-auto shrink-0 bg-white leading-snug text-neutral-900 shadow-md ring-1 ring-foreground/10 print:w-auto print:p-0 print:shadow-none print:ring-0" + +export const printColorExact = { printColorAdjust: "exact", WebkitPrintColorAdjust: "exact" } as const + +export function money(n: number) { + return `Rs. ${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` +} + +export function qty(n: number) { + return n.toLocaleString(undefined, { maximumFractionDigits: 3 }) +} + +/** dd/MM/yyyy, as on the reference invoice. */ +export function formatDate(value: string | Date) { + const d = new Date(value) + return `${String(d.getDate()).padStart(2, "0")}/${String(d.getMonth() + 1).padStart(2, "0")}/${d.getFullYear()}` +} + +/** Section heading with the short accent underline. */ +export function SectionTitle({ children }: { children: ReactNode }) { + return ( +
+

{children}

+
+
+ ) +} + +/** Dashed divider used between receipt sections. */ +export function ReceiptRule() { + return
+} + +export function ReceiptRow({ label, value, bold }: { label: string; value: string; bold?: boolean }) { + return ( +
+ {label} + {value} +
+ ) +} + +/** Terms + footer note lines from the invoice template, blank lines dropped. */ +export function templateNotes(termsAndConditions?: string | null, footerNote?: string | null): string[] { + return [...(termsAndConditions ?? "").split("\n"), ...(footerNote ?? "").split("\n")].map((l) => l.trim()).filter(Boolean) +} + +const subscribeNoop = () => () => {} + +/** True only on the client — the print copy is portalled into document.body. */ +function useIsClient() { + return useSyncExternalStore(subscribeNoop, () => true, () => false) +} + +/** Paper width in mm — the PDF page is this wide; A4 is also 297mm tall (longer invoices add pages). */ +const PAPER_WIDTH_MM: Record = { A4: 210, "80mm": 80, "58mm": 58 } +const A4_HEIGHT_MM = 297 + +/** + * Renders the on-screen sheet to a PDF that looks exactly like the preview: the sheet is captured + * as an image (html-to-image) and laid onto pages of the chosen paper size (jsPDF). A4 is split + * across as many 297mm pages as needed; a receipt is one page as long as the receipt itself. + * Both libraries are loaded on demand. + */ +async function buildInvoicePdf(sheet: HTMLElement, size: PaperSize): Promise { + const [{ toPng }, { jsPDF }] = await Promise.all([import("html-to-image"), import("jspdf")]) + // The sheet's drop shadow / ring are screen decoration only. + const dataUrl = await toPng(sheet, { + pixelRatio: 2, + backgroundColor: "#ffffff", + style: { boxShadow: "none", margin: "0" }, + // A logo the browser can't read back (cross-origin, no CORS) becomes a blank instead of failing the whole PDF. + imagePlaceholder: "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", + }) + const img = new Image() + img.src = dataUrl + await img.decode() + + const widthMm = PAPER_WIDTH_MM[size] + const imgHeightMm = (img.height * widthMm) / img.width + if (size !== "A4") { + const pdf = new jsPDF({ unit: "mm", format: [widthMm, imgHeightMm] }) + pdf.addImage(dataUrl, "PNG", 0, 0, widthMm, imgHeightMm) + return pdf.output("blob") + } + const pdf = new jsPDF({ unit: "mm", format: "a4" }) + // Same image on every page, shifted up one page-height each time; the page box crops the rest. + for (let offset = 0, page = 0; offset < imgHeightMm - 0.5; offset += A4_HEIGHT_MM, page++) { + if (page > 0) pdf.addPage() + pdf.addImage(dataUrl, "PNG", 0, -offset, widthMm, imgHeightMm) + } + return pdf.output("blob") +} + +function saveBlob(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = filename + a.click() + setTimeout(() => URL.revokeObjectURL(url), 1000) +} + +/** + * Invoice preview + paper-size bar underneath + Share / Download / Print. Printing uses a copy of + * the sheet portalled straight into (`.invoice-print-root`, see globals.css) so the printout + * is just the invoice, whether this is rendered in a page or inside a dialog. + */ +export function InvoicePreviewFrame({ + renderSheet, + error, + docNo, + shareTitle, + sharePath, + className, +}: { + /** The invoice laid out for a paper size, or null while it is still loading. */ + renderSheet: ((size: PaperSize) => ReactNode) | null + error: string | null + /** Used for the PDF file name. */ + docNo: string + shareTitle: string + /** App path of a page showing this invoice — shared/copied where the PDF itself can't be. */ + sharePath: string + className?: string +}) { + const [paperSize, setPaperSize] = useState("A4") + const isClient = useIsClient() + const previewRef = useRef(null) + const [busy, setBusy] = useState<"download" | "share" | null>(null) + + const fileName = `${docNo}${paperSize === "A4" ? "" : `-${paperSize}`}.pdf` + + async function makePdf(): Promise { + const sheet = previewRef.current?.querySelector(".invoice-sheet") + if (!sheet) throw new Error("The invoice is still loading.") + return buildInvoicePdf(sheet, paperSize) + } + + async function handleDownload() { + setBusy("download") + try { + saveBlob(await makePdf(), fileName) + } catch (err) { + toast.error("Could not create the PDF", errorMessage(err)) + } finally { + setBusy(null) + } + } + + // Shares the PDF itself where the device supports sharing files (phones, most desktop Chrome/Edge); + // otherwise shares — or, failing that, copies — a link to this invoice's page. + async function handleShare() { + const link = `${window.location.origin}${sharePath}` + setBusy("share") + try { + const file = new File([await makePdf()], fileName, { type: "application/pdf" }) + if (navigator.canShare?.({ files: [file] })) { + await navigator.share({ files: [file], title: shareTitle, text: shareTitle }) + } else if (navigator.share) { + await navigator.share({ title: shareTitle, text: shareTitle, url: link }) + } else { + await navigator.clipboard.writeText(link) + toast.success("Link copied", "Sharing files isn't supported here — the invoice link is on your clipboard.") + } + } catch (err) { + // Closing the share sheet isn't an error. + if (!(err instanceof DOMException && err.name === "AbortError")) toast.error("Could not share the invoice", errorMessage(err)) + } finally { + setBusy(null) + } + } + + if (error && !renderSheet) { + return
{error}
+ } + + return ( +
+ {/* Preview window — scrolls so the page keeps its true size. */} +
+ {renderSheet ? renderSheet(paperSize) : } +
+ + {/* Paper size selection, under the invoice. */} +
+
+ + + value={paperSize} + onValueChange={(v) => v && setPaperSize(v)} + items={PAPER_SIZES.map((s) => ({ value: s.value, label: s.label }))} + > + + + + + {PAPER_SIZES.map((s) => ( + + + {s.label} + {s.hint} + + + ))} + + +
+
+ + + +
+
+ + {isClient && + renderSheet && + createPortal( +
+ + {renderSheet(paperSize)} +
, + document.body + )} +
+ ) +} diff --git a/Frontend/erp-system/components/procurement/PurchaseInvoicePaymentDialog.tsx b/Frontend/erp-system/components/procurement/PurchaseInvoicePaymentDialog.tsx index fcef274..f623de8 100644 --- a/Frontend/erp-system/components/procurement/PurchaseInvoicePaymentDialog.tsx +++ b/Frontend/erp-system/components/procurement/PurchaseInvoicePaymentDialog.tsx @@ -13,8 +13,8 @@ import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" import { Input } from "@/components/ui/input" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { toast } from "@/components/ui/toast" +import { localToday, PaymentLine, PaymentLinesEditor, round2, validatePaymentLines } from "@/components/sales/PaymentLinesEditor" interface PurchaseInvoicePaymentDialogProps { // PurchaseInvoiceSummary rather than the full PurchaseInvoice — every field this dialog @@ -24,23 +24,34 @@ interface PurchaseInvoicePaymentDialogProps { invoice: PurchaseInvoiceSummary | null open: boolean onOpenChange: (open: boolean) => void - /** `newStatus` is always "Posted" — paying only ever succeeds against a posted invoice, whether - * it started that way or this dialog auto-posted it first. */ + /** Called once per recorded payment line. `newStatus` is always "Posted" — paying only ever + * succeeds against a posted invoice, whether it started that way or this dialog auto-posted it. */ onPaid: (payment: PurchaseInvoicePayment, newStatus: PurchaseInvoiceStatus) => void + /** Called once, after the dialog closes, when this submit cleared the whole balance. */ + onFullyPaid?: () => void } -/** Record a vendor payment against a Purchase Invoice's balance — full or partial, into an - * existing GL cash/bank account. A Draft invoice is posted first (the backend only accepts - * payments against a Posted invoice) so this doubles as "confirm and pay in one step" from a - * list row; an already-Posted invoice skips straight to payment. Modeled on - * SalesInvoicePaymentDialog's "pick an account, submit" shape. */ -export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid }: PurchaseInvoicePaymentDialogProps) { +/** Record a vendor payment against a Purchase Invoice's balance — full or partial, split across + * one or more payment lines (e.g. part cash, part bank), each from an existing GL cash/bank + * account. Same shape as SalesInvoicePaymentDialog: each line is posted as its own payment (the + * backend already allows several installments). A Draft invoice is posted first (the backend only + * accepts payments against a Posted invoice) so this doubles as "confirm and pay in one step" from + * a list row. If a line fails part-way, the lines already recorded are removed from the form and + * the rest stay for a retry. */ +export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid, onFullyPaid }: PurchaseInvoicePaymentDialogProps) { const [accounts, setAccounts] = useState(null) - const [glBankAccountId, setGlBankAccountId] = useState("") - const [amount, setAmount] = useState("") + const [lines, setLines] = useState([]) const [reference, setReference] = useState("") + const [paymentDate, setPaymentDate] = useState(localToday) const [errors, setErrors] = useState>({}) const [submitting, setSubmitting] = useState(false) + // What this dialog has already done to the invoice in this session — the `invoice` prop isn't + // guaranteed to be refreshed by the parent between a partial failure and the retry. + const [postedHere, setPostedHere] = useState(false) + const [paidHere, setPaidHere] = useState(0) + // Balance as it stood when the dialog opened. The parent pages apply each `onPaid` to the + // invoice they pass in, so subtracting `paidHere` from the live prop would count it twice. + const [openingBalance, setOpeningBalance] = useState(0) // Reset the form fields for a fresh open (or a different invoice) during render — same // "adjust state while rendering" pattern as SalesInvoicePaymentDialog, not inside an effect. @@ -48,10 +59,13 @@ export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPa const [resetFor, setResetFor] = useState(null) if (openKey !== null && resetFor !== openKey) { setResetFor(openKey) - setAmount(invoice!.totals.balanceAmount.toFixed(2)) - setGlBankAccountId("") + setLines([{ key: 0, glBankAccountId: "", amount: invoice!.totals.balanceAmount.toFixed(2) }]) setReference("") + setPaymentDate(localToday()) setErrors({}) + setPostedHere(false) + setPaidHere(0) + setOpeningBalance(invoice!.totals.balanceAmount) } useEffect(() => { @@ -61,98 +75,120 @@ export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPa if (!invoice) return null + const isDraft = invoice.status === "Draft" && !postedHere + const balance = round2(openingBalance - paidHere) + async function submit() { if (!invoice) return - const nextErrors: Record = {} - const amountNum = Number(amount) - if (!glBankAccountId) nextErrors.glBankAccountId = "Select an account to pay from" - if (!amount || Number.isNaN(amountNum) || amountNum <= 0) nextErrors.amount = "Enter a valid amount" - else if (amountNum > invoice.totals.balanceAmount) nextErrors.amount = `Cannot exceed the balance (${formatAmount(invoice.totals.balanceAmount)})` + const nextErrors = validatePaymentLines(lines, balance) + if (!paymentDate) nextErrors.paymentDate = "Select the payment date" + else if (paymentDate > localToday()) nextErrors.paymentDate = "Payment date cannot be in the future" setErrors(nextErrors) if (Object.keys(nextErrors).length > 0) return setSubmitting(true) - try { - if (invoice.status === "Draft") { - try { - await purchaseInvoicesApi.post(invoice.purchaseInvoiceId) - } catch (err) { - toast.error("Could not confirm invoice", errorMessage(err)) - return - } + if (isDraft) { + try { + await purchaseInvoicesApi.post(invoice.purchaseInvoiceId) + setPostedHere(true) + } catch (err) { + toast.error("Could not confirm invoice", errorMessage(err)) + setSubmitting(false) + return } + } - const payment = await purchaseInvoicesApi.pay(invoice.purchaseInvoiceId, { - amount: amountNum, - glBankAccountId: Number(glBankAccountId), - reference: reference || undefined, - }) - const fullyPaid = amountNum >= invoice.totals.balanceAmount + // One payment per line, in order. On a failure, stop: drop the recorded lines from the form and + // keep the rest so the user can fix and retry without double-paying. + const recorded: PurchaseInvoicePayment[] = [] + let failure: unknown = null + for (const line of lines) { + try { + const payment = await purchaseInvoicesApi.pay(invoice.purchaseInvoiceId, { + amount: Number(line.amount), + glBankAccountId: Number(line.glBankAccountId), + reference: reference || undefined, + paymentDate, + }) + recorded.push(payment) + onPaid(payment, "Posted") + } catch (err) { + failure = err + break + } + } + setSubmitting(false) + + const recordedTotal = round2(recorded.reduce((sum, p) => sum + p.amount, 0)) + if (failure === null) { + const journals = recorded.map((p) => p.glJournalNo ?? "—").join(", ") + const fullyPaid = recordedTotal >= balance toast.success( invoice.status === "Draft" ? "Invoice confirmed and paid" : "Payment recorded", - `${formatAmount(amountNum)} posted to the ledger (${payment.glJournalNo ?? "—"}) — ${fullyPaid ? "fully paid" : "partially paid"}.` + `${formatAmount(recordedTotal)} posted to the ledger (${journals}) — ${fullyPaid ? "fully paid" : "partially paid"}.` ) - onPaid(payment, "Posted") onOpenChange(false) - } catch (err) { - toast.error( - invoice.status === "Draft" ? "Invoice was confirmed, but the payment could not be recorded" : "Could not record payment", - errorMessage(err) - ) - } finally { - setSubmitting(false) + if (fullyPaid) onFullyPaid?.() + return } + + setPaidHere((p) => round2(p + recordedTotal)) + setLines((prev) => prev.slice(recorded.length)) + toast.error( + recorded.length > 0 + ? `${recorded.length} of ${lines.length} payment lines recorded (${formatAmount(recordedTotal)}); the rest could not be` + : invoice.status === "Draft" + ? "Invoice was confirmed, but the payment could not be recorded" + : "Could not record payment", + errorMessage(failure) + ) } return ( - + - {invoice.status === "Draft" ? "Confirm & pay" : "Pay"} invoice {invoice.docNo} + {isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.docNo}

- {invoice.status === "Draft" && "This invoice is still a draft — submitting will confirm it, then record the payment. "} - Balance due: {formatAmount(invoice.totals.balanceAmount)} + {isDraft && "This invoice is still a draft — submitting will confirm it, then record the payment. "} + Balance due: {formatAmount(balance)}

- - Pay from - value={glBankAccountId} onValueChange={(v) => setGlBankAccountId(v ?? "")}> - - - - - {(accounts ?? []).map((a) => ( - - {a.accountName} ({a.accountType}) - - ))} - - - - +
+ + Invoice no. + + - - Amount - setAmount(e.target.value)} - aria-invalid={!!errors.amount} - /> - - + + Payment date + setPaymentDate(e.target.value)} + aria-invalid={!!errors.paymentDate} + /> + + +
Reference (optional) setReference(e.target.value)} /> + +
@@ -160,11 +196,7 @@ export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPa Cancel
diff --git a/Frontend/erp-system/components/procurement/PurchaseInvoicePrint.tsx b/Frontend/erp-system/components/procurement/PurchaseInvoicePrint.tsx new file mode 100644 index 0000000..1bfb559 --- /dev/null +++ b/Frontend/erp-system/components/procurement/PurchaseInvoicePrint.tsx @@ -0,0 +1,386 @@ +"use client" + +import { ReactNode, useEffect, useState } from "react" + +import { purchaseInvoicesApi } from "@/lib/api/purchase-invoices" +import { vendorsApi } from "@/lib/api/vendors" +import { companyProfileApi } from "@/lib/api/company-profile" +import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config" +import { errorMessage } from "@/lib/error-map" +import { cn } from "@/lib/utils" +import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog" +import { + A4_SHEET_CLASS, + ACCENT, + formatDate, + InvoicePreviewFrame, + money, + PaperSize, + printColorExact, + RECEIPT_SHEET_BASE_CLASS, + RECEIPT_SHEET_CLASS, + ReceiptRow, + ReceiptRule, + ReceiptWidth, + SectionTitle, + templateNotes, +} from "@/components/invoice-template/InvoicePrintKit" +import { CompanyProfile } from "@/types/company-profile" +import { InvoiceTemplateConfig } from "@/types/invoice-template" +import { Vendor } from "@/types/master-data" +import { PurchaseInvoice, PurchaseInvoicePayment } from "@/types/purchase-invoices" + +/* + * Printable purchase invoice (vendor bill) in the same three paper sizes as the sales invoice — + * A4, 80mm and 58mm receipt — sharing its preview frame, PDF export and print plumbing. The + * lines are the GRNs the bill covers; "From" is the vendor and "Bill To" is our company. + */ + +/** Everything both layouts render, resolved once. */ +interface PurchaseInvoiceDoc { + invoice: PurchaseInvoice + vendor: Vendor | null + payments: PurchaseInvoicePayment[] + company: CompanyProfile | null + template: InvoiceTemplateConfig | null + notes: string[] + showLogo: boolean +} + +function A4Sheet({ invoice, vendor, payments, company, template, notes, showLogo }: PurchaseInvoiceDoc) { + const t = invoice.totals + return ( +
+ {/* Title + meta */} +
+
+

PURCHASE INVOICE

+
+
Invoice No.
+
{invoice.docNo}
+
Vendor Invoice No.
+
{invoice.vendorInvoiceNo}
+
Invoice Date
+
{formatDate(invoice.vendorInvoiceDate)}
+ {invoice.status !== "Posted" && ( + <> +
Status
+
{invoice.status.toUpperCase()}
+ + )} +
+
+ {showLogo && ( + // eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed + {company!.legalName} + )} +
+ + {/* Header rule: heavy dark bar over a thin accent line */} +
+
+
+
+ + {/* From (vendor) / Bill To (us) */} +
+
+ From +
+

{invoice.vendorSnapshotName}

+ {vendor?.code &&

Vendor code: {vendor.code}

} +
+ {invoice.vendorSnapshotTaxNo &&

Tax No: {invoice.vendorSnapshotTaxNo}

} + {vendor?.terms &&

Terms: {vendor.terms}

} +
+
+
+ +
+ Bill To + {company ? ( +
+

{company.tradeName || company.legalName}

+

{[company.addressLine1, company.addressLine2].filter(Boolean).join(", ")}

+

{[company.city, company.country].filter(Boolean).join(", ")}

+
+ {company.phone &&

{company.phone}

} + {company.email &&

{company.email}

} + {template?.showTaxRegNo && company.taxRegistrationNo &&

Tax Reg No: {company.taxRegistrationNo}

} +
+
+ ) : ( +

Company details aren't set up yet (Settings → Company Profile).

+ )} +
+
+ + {/* GRN lines */} +
+ Goods Received + + + + + + + + + + + + {invoice.lines.map((line) => ( + + + + + + + + ))} + +
GRNReceivedSubtotalVATAmount
{line.grnDocNo}{formatDate(line.grnPostedAt)}{money(line.subtotal)}{line.vatTotal > 0 ? money(line.vatTotal) : "—"}{money(line.lineTotal)}
+
+ + {/* Notes / totals */} +
+
+ {notes.length > 0 && ( + <> + Notes / Terms +
    + {notes.map((note, i) => ( +
  • {note}
  • + ))} +
+ + )} +
+ +
+
+
Subtotal{money(t.subtotal)}
+ {t.vatTotal > 0 &&
VAT{money(t.vatTotal)}
} +
+
+ Total + {money(t.grandTotal)} +
+ {t.paidAmount > 0 && ( +
+
Paid{money(t.paidAmount)}
+
Balance Due{money(t.balanceAmount)}
+
+ )} +
+
+ + {/* Payments */} +
+ Payments + {payments.length > 0 ? ( +
    + {payments.map((p) => ( +
  • + + {formatDate(p.paymentDate)} · {p.bankAccountName} + {p.reference && · Ref {p.reference}} + + {money(p.amount)} +
  • + ))} +
+ ) : ( +

No payment recorded yet.

+ )} +
+
+ ) +} + +/** 80mm / 58mm thermal layout: one centred column, no fills (thermal heads print black only). */ +function ReceiptSheet({ invoice, payments, company, template, notes, showLogo, width }: PurchaseInvoiceDoc & { width: ReceiptWidth }) { + const t = invoice.totals + return ( +
+
+ {showLogo && ( + // eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed + {company!.legalName} + )} + {company && ( + <> +

{company.tradeName || company.legalName}

+

{[company.addressLine1, company.addressLine2, company.city].filter(Boolean).join(", ")}

+ {company.phone &&

Tel: {company.phone}

} + {template?.showTaxRegNo && company.taxRegistrationNo &&

Tax Reg: {company.taxRegistrationNo}

} + + )} +

PURCHASE INVOICE

+ {invoice.status !== "Posted" &&

*** {invoice.status.toUpperCase()} ***

} +
+ + + + + + + + +
+ GRN + Amount +
+ +
+ {invoice.lines.map((line) => ( +
+

{line.grnDocNo}

+
+ {formatDate(line.grnPostedAt)} + {money(line.lineTotal)} +
+ {line.vatTotal > 0 &&

incl. VAT {money(line.vatTotal)}

} +
+ ))} +
+ + + + {t.vatTotal > 0 && } +
+ +
+ {t.paidAmount > 0 && ( + <> + + + + )} + + {payments.length > 0 && ( + <> + +

Payment

+ {payments.map((p) => ( + + ))} + + )} + + {notes.length > 0 && ( + <> + + {notes.map((note, i) =>

{note}

)} + + )} +
+ ) +} + +/** Loads the invoice plus the optional decoration around it (vendor, payments, company, template). + * Only the invoice is required — any other failure just leaves that block out. */ +function usePurchaseInvoiceDoc(purchaseInvoiceId: number, refreshKey = 0) { + const [doc, setDoc] = useState(null) + const [loadError, setLoadError] = useState(null) + const error = Number.isFinite(purchaseInvoiceId) ? loadError : `Invalid purchase invoice id '${purchaseInvoiceId}'.` + + useEffect(() => { + if (!Number.isFinite(purchaseInvoiceId)) return + let cancelled = false + purchaseInvoicesApi + .get(purchaseInvoiceId) + .then(async ({ data: invoice }) => { + const [vendorRes, payments, companyRes, templateRes] = await Promise.all([ + vendorsApi.get(invoice.vendorId).catch(() => null), + invoice.status === "Posted" ? purchaseInvoicesApi.listPayments(purchaseInvoiceId).catch(() => []) : Promise.resolve([]), + companyProfileApi.get().catch(() => ({ data: null, etag: null })), + invoiceTemplateConfigApi.get().catch(() => ({ data: null, etag: null })), + ]) + if (cancelled) return + const company = companyRes.data + const template = templateRes.data + setDoc({ + invoice, + vendor: vendorRes?.data ?? null, + payments, + company, + template, + notes: templateNotes(template?.termsAndConditions, template?.footerNote), + showLogo: template?.logoPlacement !== "None" && !!company?.logoUrl, + }) + }) + .catch((err) => { + if (!cancelled) setLoadError(errorMessage(err)) + }) + return () => { + cancelled = true + } + // refreshKey: bumped by the caller after a payment so the sheet shows the new state. + }, [purchaseInvoiceId, refreshKey]) + + return { doc, error } +} + +function Sheet({ size, doc }: { size: PaperSize; doc: PurchaseInvoiceDoc }) { + return size === "A4" ? : +} + +/** Purchase invoice preview + paper-size bar underneath + Share / Download / Print. */ +export function PurchaseInvoicePrintPreview({ + purchaseInvoiceId, + className, + refreshKey, +}: { + purchaseInvoiceId: number + className?: string + refreshKey?: number +}) { + const { doc, error } = usePurchaseInvoiceDoc(purchaseInvoiceId, refreshKey) + return ( + : null} + error={error} + docNo={doc?.invoice.docNo ?? `purchase-invoice-${purchaseInvoiceId}`} + shareTitle={`Purchase invoice ${doc?.invoice.docNo ?? ""}`.trim()} + sharePath={`/dashboard/procurement/purchase-invoices/${purchaseInvoiceId}`} + className={className} + /> + ) +} + +/** Popup form of the print preview — same frame as SalesInvoicePrintDialog. Opens on A4 every time. */ +export function PurchaseInvoicePrintDialog({ + purchaseInvoiceId, + invoiceNo, + open, + onOpenChange, + actions, + refreshKey, +}: { + purchaseInvoiceId: number | null + invoiceNo?: string + open: boolean + onOpenChange: (open: boolean) => void + /** Optional buttons shown in the header (e.g. Record payment). */ + actions?: ReactNode + /** Bump to reload the invoice (after a payment, …). */ + refreshKey?: number +}) { + return ( + + +
+
+ Purchase invoice{invoiceNo ? ` ${invoiceNo}` : ""} + Preview, pick a paper size below, then print. +
+ {actions &&
{actions}
} +
+ {purchaseInvoiceId !== null && ( + + )} +
+
+ ) +} diff --git a/Frontend/erp-system/components/products/BrandPanel.tsx b/Frontend/erp-system/components/products/BrandPanel.tsx index 82df0b3..7a888ce 100644 --- a/Frontend/erp-system/components/products/BrandPanel.tsx +++ b/Frontend/erp-system/components/products/BrandPanel.tsx @@ -49,7 +49,10 @@ export function BrandPanel() { async function handleAdd() { const nextErrors = validateBrandName(name) setErrors(nextErrors) - if (Object.keys(nextErrors).length > 0) return + if (Object.keys(nextErrors).length > 0) { + toast.error("Could not create brand", Object.values(nextErrors)[0]) + return + } setSubmitting(true) try { @@ -91,7 +94,10 @@ export function BrandPanel() { if (!editing) return const nextErrors = validateBrandName(editName) setEditErrors(nextErrors) - if (Object.keys(nextErrors).length > 0) return + if (Object.keys(nextErrors).length > 0) { + toast.error("Could not update brand", Object.values(nextErrors)[0]) + return + } setEditSubmitting(true) try { @@ -116,7 +122,10 @@ export function BrandPanel() {
setName(e.target.value)} + onChange={(e) => { + setName(e.target.value) + setErrors({}) + }} placeholder="New brand name, e.g. Bosch" className="h-12 flex-1 text-base" aria-label="New brand name" @@ -178,11 +187,14 @@ export function BrandPanel() { - Name + Name setEditName(e.target.value)} + onChange={(e) => { + setEditName(e.target.value) + setEditErrors({}) + }} aria-invalid={!!editErrors.name} /> diff --git a/Frontend/erp-system/components/products/CategoryPanel.tsx b/Frontend/erp-system/components/products/CategoryPanel.tsx index 14a618a..90c402a 100644 --- a/Frontend/erp-system/components/products/CategoryPanel.tsx +++ b/Frontend/erp-system/components/products/CategoryPanel.tsx @@ -23,6 +23,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Skeleton } from "@/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { toast } from "@/components/ui/toast" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" type SortOrder = "asc" | "desc" type SortKey = "categoryId" | "name" | "status" | "createdAt" @@ -145,7 +146,10 @@ export function CategoryPanel() { async function handleSubmit() { const nextErrors = validateCategoryName(name) setErrors(nextErrors) - if (Object.keys(nextErrors).length > 0) return + if (Object.keys(nextErrors).length > 0) { + toast.error(editing ? "Could not update category" : "Could not create category", Object.values(nextErrors)[0]) + return + } setSubmitting(true) try { @@ -219,8 +223,8 @@ export function CategoryPanel() { - Name - setName(e.target.value)} placeholder="Fasteners" aria-invalid={!!errors.name} /> + Name + { setName(e.target.value); setErrors({}) }} placeholder="Fasteners" aria-invalid={!!errors.name} /> @@ -385,39 +389,45 @@ export function CategoryPanel() { {/* Subcategories are their own resource now, not a nested tree. Hidden while the feature is switched off in Product Configuration. */} {config?.subcategoriesEnabled && ( - - - + + + + + )} - + + + {/* Deactivate, not delete: no DELETE exists for any master (FR-MD-08). */} - - } - > - {c.status === "Active" ? : } - + + + } + > + {c.status === "Active" ? : } + + + + {label} + + ) +} + function SortableHeader({ label, active, diff --git a/Frontend/erp-system/components/products/UomPanel.tsx b/Frontend/erp-system/components/products/UomPanel.tsx index 23ec659..84c22e3 100644 --- a/Frontend/erp-system/components/products/UomPanel.tsx +++ b/Frontend/erp-system/components/products/UomPanel.tsx @@ -53,7 +53,10 @@ export function UomPanel() { async function handleAdd() { const nextErrors = validateUomName(name) setErrors(nextErrors) - if (Object.keys(nextErrors).length > 0) return + if (Object.keys(nextErrors).length > 0) { + toast.error("Could not create UOM", Object.values(nextErrors)[0]) + return + } setSubmitting(true) try { @@ -76,7 +79,10 @@ export function UomPanel() {
setName(e.target.value)} + onChange={(e) => { + setName(e.target.value) + setErrors({}) + }} placeholder="New UOM name, e.g. Box-12" className="h-12 flex-1 text-base" aria-label="New UOM name" diff --git a/Frontend/erp-system/components/sales/SalesInvoicePrint.tsx b/Frontend/erp-system/components/sales/SalesInvoicePrint.tsx index d877630..aea8d05 100644 --- a/Frontend/erp-system/components/sales/SalesInvoicePrint.tsx +++ b/Frontend/erp-system/components/sales/SalesInvoicePrint.tsx @@ -1,8 +1,6 @@ "use client" -import { ReactNode, useEffect, useRef, useState, useSyncExternalStore } from "react" -import { createPortal } from "react-dom" -import { Download, Loader2, Printer, Share2 } from "lucide-react" +import { ReactNode, useEffect, useState } from "react" import { salesApi } from "@/lib/api/sales" import { customersApi } from "@/lib/api/customers" @@ -10,55 +8,29 @@ import { companyProfileApi } from "@/lib/api/company-profile" import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config" import { errorMessage } from "@/lib/error-map" import { cn } from "@/lib/utils" -import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { Skeleton } from "@/components/ui/skeleton" -import { toast } from "@/components/ui/toast" +import { + A4_SHEET_CLASS, + ACCENT, + formatDate, + InvoicePreviewFrame, + money, + PaperSize, + printColorExact, + qty, + RECEIPT_SHEET_BASE_CLASS, + RECEIPT_SHEET_CLASS, + ReceiptRow, + ReceiptRule, + ReceiptWidth, + SectionTitle, + templateNotes, +} from "@/components/invoice-template/InvoicePrintKit" import { CompanyProfile } from "@/types/company-profile" import { Customer } from "@/types/customers" import { InvoiceTemplateConfig } from "@/types/invoice-template" import { SalesInvoice, SalesInvoicePayment } from "@/types/sales" -// Deep-red accent from the reference layout — used for the heading underlines and header rule. -const ACCENT = "#8b1c24" - -type PaperSize = "A4" | "80mm" | "58mm" -type ReceiptWidth = Exclude - -const PAPER_SIZES: { value: PaperSize; label: string; hint: string }[] = [ - { value: "A4", label: "A4", hint: "210 × 297 mm" }, - { value: "80mm", label: "80mm receipt", hint: "Thermal roll" }, - { value: "58mm", label: "58mm receipt", hint: "Small thermal roll" }, -] - -/** Sets the printer's page box — the browser print dialog picks this up as the paper size. */ -const PAGE_RULES: Record = { - A4: "@page { size: A4; margin: 12mm; }", - "80mm": "@page { size: 80mm auto; margin: 3mm; }", - "58mm": "@page { size: 58mm auto; margin: 2mm; }", -} - -/** Receipt sheet sizing per roll — 58mm gets tighter padding and a smaller font to fit ~32 chars a line. */ -const RECEIPT_SHEET_CLASS: Record = { - "80mm": "w-[80mm] px-[4mm] py-[5mm] text-[11px]", - "58mm": "w-[58mm] px-[2.5mm] py-[4mm] text-[9.5px]", -} - -function money(n: number) { - return `Rs. ${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` -} - -function qty(n: number) { - return n.toLocaleString(undefined, { maximumFractionDigits: 3 }) -} - -/** dd/MM/yyyy, as on the reference invoice. */ -function formatDate(value: string | Date) { - const d = new Date(value) - return `${String(d.getDate()).padStart(2, "0")}/${String(d.getMonth() + 1).padStart(2, "0")}/${d.getFullYear()}` -} - /** Everything both layouts render, resolved once. */ interface InvoiceDoc { invoice: SalesInvoice @@ -72,26 +44,11 @@ interface InvoiceDoc { showBank: boolean } -const printColorExact = { printColorAdjust: "exact", WebkitPrintColorAdjust: "exact" } as const - -/** Section heading with the short accent underline. */ -function SectionTitle({ children }: { children: React.ReactNode }) { - return ( -
-

{children}

-
-
- ) -} - function A4Sheet({ invoice, customer, payments, company, template, dueDate, notes, showLogo, showBank }: InvoiceDoc) { const t = invoice.totals return ( // On screen: a true 210 × 297 mm page. In print the @page margin does the spacing. -
+
{/* Title + meta */}
@@ -262,31 +219,11 @@ function A4Sheet({ invoice, customer, payments, company, template, dueDate, note ) } -/** Dashed divider used between receipt sections. */ -function ReceiptRule() { - return
-} - -function ReceiptRow({ label, value, bold }: { label: string; value: string; bold?: boolean }) { - return ( -
- {label} - {value} -
- ) -} - /** 80mm / 58mm thermal layout: one centred column, no fills (thermal heads print black only). */ function ReceiptSheet({ invoice, customer, payments, company, template, dueDate, notes, showLogo, width }: InvoiceDoc & { width: ReceiptWidth }) { const t = invoice.totals return ( -
+
{showLogo && ( // eslint-disable-next-line @next/next/no-img-element -- print view, no next/image optimization needed @@ -405,9 +342,7 @@ function useInvoiceDoc(invoiceId: number, refreshKey = 0) { company, template, dueDate, - notes: [...(template?.termsAndConditions ?? "").split("\n"), ...(template?.footerNote ?? "").split("\n")] - .map((l) => l.trim()) - .filter(Boolean), + notes: templateNotes(template?.termsAndConditions, template?.footerNote), showLogo: template?.logoPlacement !== "None" && !!company?.logoUrl, showBank: !!template?.showBankDetails && !!company && !!(company.bankName || company.accountNumber), }) @@ -424,186 +359,22 @@ function useInvoiceDoc(invoiceId: number, refreshKey = 0) { return { doc, error } } -const subscribeNoop = () => () => {} - -/** True only on the client — the print copy is portalled into document.body. */ -function useIsClient() { - return useSyncExternalStore(subscribeNoop, () => true, () => false) -} - -/** Paper width in mm — the PDF page is this wide; A4 is also 297mm tall (longer invoices add pages). */ -const PAPER_WIDTH_MM: Record = { A4: 210, "80mm": 80, "58mm": 58 } -const A4_HEIGHT_MM = 297 - -/** - * Renders the on-screen sheet to a PDF that looks exactly like the preview: the sheet is captured - * as an image (html-to-image) and laid onto pages of the chosen paper size (jsPDF). A4 is split - * across as many 297mm pages as needed; a receipt is one page as long as the receipt itself. - * Both libraries are loaded on demand. - */ -async function buildInvoicePdf(sheet: HTMLElement, size: PaperSize): Promise { - const [{ toPng }, { jsPDF }] = await Promise.all([import("html-to-image"), import("jspdf")]) - // The sheet's drop shadow / ring are screen decoration only. - const dataUrl = await toPng(sheet, { - pixelRatio: 2, - backgroundColor: "#ffffff", - style: { boxShadow: "none", margin: "0" }, - // A logo the browser can't read back (cross-origin, no CORS) becomes a blank instead of failing the whole PDF. - imagePlaceholder: "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7", - }) - const img = new Image() - img.src = dataUrl - await img.decode() - - const widthMm = PAPER_WIDTH_MM[size] - const imgHeightMm = (img.height * widthMm) / img.width - if (size !== "A4") { - const pdf = new jsPDF({ unit: "mm", format: [widthMm, imgHeightMm] }) - pdf.addImage(dataUrl, "PNG", 0, 0, widthMm, imgHeightMm) - return pdf.output("blob") - } - const pdf = new jsPDF({ unit: "mm", format: "a4" }) - // Same image on every page, shifted up one page-height each time; the page box crops the rest. - for (let offset = 0, page = 0; offset < imgHeightMm - 0.5; offset += A4_HEIGHT_MM, page++) { - if (page > 0) pdf.addPage() - pdf.addImage(dataUrl, "PNG", 0, -offset, widthMm, imgHeightMm) - } - return pdf.output("blob") -} - -function saveBlob(blob: Blob, filename: string) { - const url = URL.createObjectURL(blob) - const a = document.createElement("a") - a.href = url - a.download = filename - a.click() - setTimeout(() => URL.revokeObjectURL(url), 1000) -} - function Sheet({ size, doc }: { size: PaperSize; doc: InvoiceDoc }) { return size === "A4" ? : } -/** - * Invoice preview + paper-size bar underneath + Print. Printing uses a copy of the sheet - * portalled straight into (`.invoice-print-root`, see globals.css) so the printout is - * just the invoice, whether this is rendered in a page or inside a dialog. - */ +/** Invoice preview + paper-size bar underneath + Share / Download / Print (see InvoicePreviewFrame). */ export function SalesInvoicePrintPreview({ invoiceId, className, refreshKey }: { invoiceId: number; className?: string; refreshKey?: number }) { const { doc, error } = useInvoiceDoc(invoiceId, refreshKey) - const [paperSize, setPaperSize] = useState("A4") - const isClient = useIsClient() - const previewRef = useRef(null) - const [busy, setBusy] = useState<"download" | "share" | null>(null) - - const fileName = `${doc?.invoice.invoiceNo ?? `invoice-${invoiceId}`}${paperSize === "A4" ? "" : `-${paperSize}`}.pdf` - - async function makePdf(): Promise { - const sheet = previewRef.current?.querySelector(".invoice-sheet") - if (!sheet) throw new Error("The invoice is still loading.") - return buildInvoicePdf(sheet, paperSize) - } - - async function handleDownload() { - setBusy("download") - try { - saveBlob(await makePdf(), fileName) - } catch (err) { - toast.error("Could not create the PDF", errorMessage(err)) - } finally { - setBusy(null) - } - } - - // Shares the PDF itself where the device supports sharing files (phones, most desktop Chrome/Edge); - // otherwise shares — or, failing that, copies — a link to this invoice's print page. - async function handleShare() { - const title = `Invoice ${doc?.invoice.invoiceNo ?? ""}`.trim() - const link = `${window.location.origin}/dashboard/sales/invoices/${invoiceId}/print` - setBusy("share") - try { - const file = new File([await makePdf()], fileName, { type: "application/pdf" }) - if (navigator.canShare?.({ files: [file] })) { - await navigator.share({ files: [file], title, text: title }) - } else if (navigator.share) { - await navigator.share({ title, text: title, url: link }) - } else { - await navigator.clipboard.writeText(link) - toast.success("Link copied", "Sharing files isn't supported here — the invoice link is on your clipboard.") - } - } catch (err) { - // Closing the share sheet isn't an error. - if (!(err instanceof DOMException && err.name === "AbortError")) toast.error("Could not share the invoice", errorMessage(err)) - } finally { - setBusy(null) - } - } - - if (error && !doc) { - return
{error}
- } - return ( -
- {/* Preview window — scrolls so the page keeps its true size. */} -
- {doc ? ( - - ) : ( - - )} -
- - {/* Paper size selection, under the invoice. */} -
-
- - - value={paperSize} - onValueChange={(v) => v && setPaperSize(v)} - items={PAPER_SIZES.map((s) => ({ value: s.value, label: s.label }))} - > - - - - - {PAPER_SIZES.map((s) => ( - - - {s.label} - {s.hint} - - - ))} - - -
-
- - - -
-
- - {isClient && - doc && - createPortal( -
- - -
, - document.body - )} -
+ : null} + error={error} + docNo={doc?.invoice.invoiceNo ?? `invoice-${invoiceId}`} + shareTitle={`Invoice ${doc?.invoice.invoiceNo ?? ""}`.trim()} + sharePath={`/dashboard/sales/invoices/${invoiceId}/print`} + className={className} + /> ) } diff --git a/Frontend/erp-system/components/ui/bar-chart.tsx b/Frontend/erp-system/components/ui/bar-chart.tsx index 92232bd..84806b6 100644 --- a/Frontend/erp-system/components/ui/bar-chart.tsx +++ b/Frontend/erp-system/components/ui/bar-chart.tsx @@ -35,7 +35,8 @@ type Props = { labels: string[] datasets: Array<{ label?: string - data: number[] + /** `null` leaves an empty slot — used to pad the axis so a few bars sit at the start. */ + data: (number | null)[] backgroundColor?: string | string[] }> /** Container height in px — defaults to the original 260 so existing callers are unaffected. */ diff --git a/Frontend/erp-system/components/ui/label.tsx b/Frontend/erp-system/components/ui/label.tsx index 74da65c..36a64c3 100644 --- a/Frontend/erp-system/components/ui/label.tsx +++ b/Frontend/erp-system/components/ui/label.tsx @@ -4,7 +4,13 @@ import * as React from "react" import { cn } from "@/lib/utils" -function Label({ className, ...props }: React.ComponentProps<"label">) { +/** `required` appends a red asterisk to mark a mandatory field. */ +function Label({ + className, + required, + children, + ...props +}: React.ComponentProps<"label"> & { required?: boolean }) { return ( ) } diff --git a/Frontend/erp-system/components/vendors/EditVendorDialog.tsx b/Frontend/erp-system/components/vendors/EditVendorDialog.tsx new file mode 100644 index 0000000..dd0e058 --- /dev/null +++ b/Frontend/erp-system/components/vendors/EditVendorDialog.tsx @@ -0,0 +1,185 @@ +"use client" + +import { useEffect, useState } from "react" + +import { vendorsApi } from "@/lib/api/vendors" +import { errorMessage, fieldErrors } from "@/lib/error-map" +import { generateVendorCode } from "@/lib/vendor-code" +import { Vendor } from "@/types/master-data" + +import { Button } from "@/components/ui/button" +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Skeleton } from "@/components/ui/skeleton" +import { toast } from "@/components/ui/toast" + +/** + * Edit popup for one vendor, opened from the vendors list. Mount it with `key={vendorId}` + * so each open starts clean. The list rows carry no ETag, so the vendor is re-read here + * for a fresh If-Match before anything can be saved. + */ +export function EditVendorDialog({ + vendorId, + existingCodes, + onClose, + onSaved, +}: { + vendorId: number + /** Every loaded vendor code, to de-dupe the regenerated code against (this vendor's own is skipped). */ + existingCodes: string[] + onClose: () => void + onSaved: (vendor: Vendor) => void +}) { + const [vendor, setVendor] = useState(null) + const [etag, setEtag] = useState(null) + const [loadError, setLoadError] = useState(null) + + const [code, setCode] = useState("") + const [name, setName] = useState("") + const [terms, setTerms] = useState("") + const [taxReg, setTaxReg] = useState("") + const [currency, setCurrency] = useState("") + const [errors, setErrors] = useState>({}) + const [saving, setSaving] = useState(false) + + function applyVendor(data: Vendor, tag: string | null) { + setVendor(data) + setEtag(tag) + setCode(data.code) + setName(data.name) + setTerms(data.terms ?? "") + setTaxReg(data.taxReg ?? "") + setCurrency(data.currency) + } + + function reload() { + return vendorsApi.get(vendorId).then(({ data, etag: tag }) => applyVendor(data, tag)) + } + + useEffect(() => { + reload().catch((err) => setLoadError(errorMessage(err))) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [vendorId]) + + /** Drops fields' errors once the user starts editing them. */ + function clearErrors(...keys: string[]) { + setErrors((prev) => { + if (!keys.some((k) => prev[k])) return prev + const next = { ...prev } + for (const k of keys) delete next[k] + return next + }) + } + + function handleNameChange(value: string) { + setName(value) + const otherCodes = existingCodes.filter((c) => c !== vendor?.code) + setCode(value.trim() ? generateVendorCode(value, otherCodes) : "") + // The code is regenerated from the name, so its error is stale too. + clearErrors("name", "code") + } + + async function handleSave() { + const nextErrors: Record = {} + if (!code.trim()) nextErrors.code = "Vendor code is required" + if (!name.trim()) nextErrors.name = "Vendor name is required" + if (!currency.trim()) nextErrors.currency = "Currency is required" + setErrors(nextErrors) + if (Object.keys(nextErrors).length > 0) { + toast.error("Could not save vendor", Object.values(nextErrors)[0]) + return + } + if (!vendor || !etag) return + + setSaving(true) + try { + const result = await vendorsApi.update( + vendorId, + { code, name, terms: terms || null, taxReg: taxReg || null, currency, branchId: vendor.branchId }, + etag + ) + toast.success("Vendor saved", `${result.data.code} — ${result.data.name}`) + onSaved(result.data) + } catch (err) { + if ((err as { code?: string })?.code === "CONCURRENCY_CONFLICT") { + // Someone else saved first: show their version so the user can redo the edit on top of it. + toast.error("Vendor was changed by someone else", "The latest details have been loaded — make your edit again.") + await reload().catch(() => {}) + return + } + const fe = fieldErrors(err) + if (fe?.code) setErrors({ code: fe.code }) + toast.error("Could not save vendor", errorMessage(err)) + } finally { + setSaving(false) + } + } + + // Laid out to match the "New vendor" dialog (app/dashboard/vendors/page.tsx). + return ( + !open && onClose()}> + + + Edit vendor + Update this supplier record. Its code is regenerated from the name. + + + {loadError ? ( +
{loadError}
+ ) : !vendor ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : ( + + + Name + handleNameChange(e.target.value)} placeholder="Lanka Steel Traders (Pvt) Ltd" aria-invalid={!!errors.name} /> + + + + Code (auto-generated) + + + + + Payment terms (optional) + setTerms(e.target.value)} placeholder="NET30" /> + + + Tax registration (optional) + setTaxReg(e.target.value)} placeholder="134567890-7000" /> + + + Currency + { + setCurrency(e.target.value) + clearErrors("currency") + }} + placeholder="LKR" + maxLength={3} + aria-invalid={!!errors.currency} + /> + + + + )} + +
+ + +
+
+
+ ) +} diff --git a/Frontend/erp-system/lib/api/vendors.ts b/Frontend/erp-system/lib/api/vendors.ts index ab6961b..f83e303 100644 --- a/Frontend/erp-system/lib/api/vendors.ts +++ b/Frontend/erp-system/lib/api/vendors.ts @@ -1,6 +1,6 @@ // One typed client method per vendor (supplier) endpoint (docs/11-BACKEND-PHASE1.md -// §2.4, FR-MD-06). ETag/If-Match on update, PATCH status for deactivate (masters are -// deactivated, not hard-deleted, FR-MD-08). +// §2.4, FR-MD-06). ETag/If-Match on update, PATCH status for deactivate, DELETE for a +// hard delete (409 MASTER_IN_USE while any purchase document or item references it). import { apiRequest, apiRequestWithETag, buildQuery } from "@/lib/api-client" import { ApiResult, EntityStatus, PagedResponse } from "@/types/common" import { CreateVendorRequest, UpdateVendorRequest, Vendor } from "@/types/master-data" @@ -33,4 +33,8 @@ export const vendorsApi = { updateStatus(vendorId: number, status: EntityStatus): Promise { return apiRequest(`/vendors/${vendorId}/status`, { method: "PATCH", body: { status } }) }, + + delete(vendorId: number): Promise { + return apiRequest(`/vendors/${vendorId}`, { method: "DELETE" }) + }, } diff --git a/Frontend/erp-system/types/purchase-invoices.ts b/Frontend/erp-system/types/purchase-invoices.ts index 4c56ddb..d6a57ba 100644 --- a/Frontend/erp-system/types/purchase-invoices.ts +++ b/Frontend/erp-system/types/purchase-invoices.ts @@ -64,6 +64,8 @@ export interface CreatePurchaseInvoicePaymentRequest { amount: number glBankAccountId: number reference?: string | null + /** `YYYY-MM-DD`; when the vendor was paid (also the GL entry date). Omitted ⇒ today; never future. */ + paymentDate?: string } export interface CreatePurchaseInvoiceRequest {