diff --git a/Backend/ERPCore/Controllers/SalesInvoicesController.cs b/Backend/ERPCore/Controllers/SalesInvoicesController.cs index 50c947e..b462a60 100644 --- a/Backend/ERPCore/Controllers/SalesInvoicesController.cs +++ b/Backend/ERPCore/Controllers/SalesInvoicesController.cs @@ -95,6 +95,22 @@ public sealed class SalesInvoicesController : ApiControllerBase CancellationToken ct) => Ok(await _invoices.ListAsync(query, status, customerId, warehouseId, branchId, from, to, createdBy, paid, ct)); + // Totals for the list's stat cards: same filters as List, but over every match rather than one page. + [HttpGet("totals")] + [ProducesResponseType(typeof(SalesInvoiceListTotalsDto), StatusCodes.Status200OK)] + public async Task> Totals( + [FromQuery] string? q, + [FromQuery] SalesInvoiceStatus? status, + [FromQuery] int? customerId, + [FromQuery] int? warehouseId, + [FromQuery] int? branchId, + [FromQuery] DateOnly? from, + [FromQuery] DateOnly? to, + [FromQuery] int? createdBy, + [FromQuery] bool? paid, + CancellationToken ct) + => Ok(await _invoices.TotalsAsync(q, status, customerId, warehouseId, branchId, from, to, createdBy, paid, ct)); + [HttpGet("{salesInvoiceId:int}")] [ProducesResponseType(typeof(SalesInvoiceDto), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] diff --git a/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs index 77cd30d..8b5ac94 100644 --- a/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs +++ b/Backend/ERPCore/Dtos/Sales/SalesInvoiceDtos.cs @@ -24,6 +24,9 @@ public sealed record SalesInvoiceDto( DateTime? UpdatedAt, string? GlJournalNo, DateTime? GlPostedAt, SalesInvoiceTotalsDto Totals, IReadOnlyList Lines); +/// Totals over every invoice matching the list filters (GET sales-invoices/totals) — not just one page. +public sealed record SalesInvoiceListTotalsDto(int Count, decimal NetTotal, decimal PaidTotal, decimal BalanceTotal, int UnpaidCount, int OverdueCount); + public sealed record SalesInvoiceSummaryDto( int SalesInvoiceId, string InvoiceNo, DateTime InvoiceDate, int CustomerId, string CustomerSnapshotName, int WarehouseId, int BranchId, SalesInvoiceType InvoiceType, diff --git a/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs b/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs index f9fd12d..b22d624 100644 --- a/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs +++ b/Backend/ERPCore/Dtos/Warehouses/WarehouseDtos.cs @@ -10,12 +10,20 @@ public sealed record WarehouseDto(int WarehouseId, string Code, string Name, str /// Bin/location resource (docs/11 §2.5). public sealed record BinDto(int BinId, int WarehouseId, string Code, string? BinType); +/// Sri Lankan phone rule for master data — mirrors the frontend's `lib/validations/phone.ts`: +/// 10 digits starting with 0, or +94 followed by 9 digits; spaces and hyphens allowed between digits. +public static class PhoneRules +{ + public const string Pattern = @"^(?:0(?:[\s-]*\d){9}|\+94(?:[\s-]*\d){9})[\s-]*$"; + public const string Message = "Enter a valid Sri Lankan phone number: 10 digits starting with 0 (0771234567) or +94 followed by 9 digits (+94771234567)."; +} + public sealed class CreateWarehouseRequest { [Required, StringLength(50)] public string Code { get; set; } = string.Empty; [Required, StringLength(200)] public string Name { get; set; } = string.Empty; [StringLength(300)] public string? Address { get; set; } - [StringLength(30)] public string? Phone { get; set; } + [StringLength(30), RegularExpression(PhoneRules.Pattern, ErrorMessage = PhoneRules.Message)] public string? Phone { get; set; } [Required] public int BranchId { get; set; } } @@ -24,7 +32,7 @@ public sealed class UpdateWarehouseRequest [Required, StringLength(50)] public string Code { get; set; } = string.Empty; [Required, StringLength(200)] public string Name { get; set; } = string.Empty; [StringLength(300)] public string? Address { get; set; } - [StringLength(30)] public string? Phone { get; set; } + [StringLength(30), RegularExpression(PhoneRules.Pattern, ErrorMessage = PhoneRules.Message)] public string? Phone { get; set; } [Required] public int BranchId { get; set; } } diff --git a/Backend/ERPCore/Services/Interfaces/ISalesInvoiceService.cs b/Backend/ERPCore/Services/Interfaces/ISalesInvoiceService.cs index 8bbca39..0bfe92d 100644 --- a/Backend/ERPCore/Services/Interfaces/ISalesInvoiceService.cs +++ b/Backend/ERPCore/Services/Interfaces/ISalesInvoiceService.cs @@ -8,6 +8,8 @@ namespace ERPCore.Services.Interfaces; public interface ISalesInvoiceService { Task> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default); + /// Count and amount totals over every invoice matching the same filters as . + Task TotalsAsync(string? q, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default); Task?> GetAsync(int salesInvoiceId, CancellationToken ct = default); Task CheckPostingAsync(int salesInvoiceId, CancellationToken ct = default); Task> CreateAsync(CreateSalesInvoiceRequest request, CancellationToken ct = default); diff --git a/Backend/ERPCore/Services/SalesConfigService.cs b/Backend/ERPCore/Services/SalesConfigService.cs index cd906bf..002ad03 100644 --- a/Backend/ERPCore/Services/SalesConfigService.cs +++ b/Backend/ERPCore/Services/SalesConfigService.cs @@ -41,13 +41,37 @@ public sealed class SalesConfigService : ISalesConfigService public async Task> GetAsync(CancellationToken ct = default) { - var config = await _config.Query().AsNoTracking().Include(c => c.CashCustomer).Include(c => c.DefaultWarehouse) - .FirstOrDefaultAsync(c => c.ConfigId == SalesConfig.SingletonId, ct) - ?? throw new NotFoundException("Sales configuration has not been seeded."); + var config = await LoadAsync(ct); + + // "Allow cash customer" can be on with no customer linked — the flag was set outside + // UpdateAsync (seed, SQL, an older build). Every sales screen then hides the cash customer + // because there's no id to offer, so link (or create) CUST-CASH here instead. + if (config.AllowCashCustomer && config.CashCustomerId is null) + { + var tracked = await _config.GetByIdAsync(SalesConfig.SingletonId, ct); + if (tracked is not null && tracked.CashCustomerId is null) + { + tracked.CashCustomerId = await ResolveCashCustomerIdAsync(ct); + try + { + await _uow.SaveChangesAsync(ct); + } + catch (DbUpdateConcurrencyException) + { + // A concurrent save got there first — whatever it stored is re-read below. + } + } + config = await LoadAsync(ct); + } return new ETagged(Map(config), config.RowVersion); } + private async Task LoadAsync(CancellationToken ct) => + await _config.Query().AsNoTracking().Include(c => c.CashCustomer).Include(c => c.DefaultWarehouse) + .FirstOrDefaultAsync(c => c.ConfigId == SalesConfig.SingletonId, ct) + ?? throw new NotFoundException("Sales configuration has not been seeded."); + public async Task> UpdateAsync( UpdateSalesConfigRequest request, uint expectedRowVersion, CancellationToken ct = default) { diff --git a/Backend/ERPCore/Services/SalesInvoiceService.cs b/Backend/ERPCore/Services/SalesInvoiceService.cs index 23c73b7..067ca89 100644 --- a/Backend/ERPCore/Services/SalesInvoiceService.cs +++ b/Backend/ERPCore/Services/SalesInvoiceService.cs @@ -53,10 +53,44 @@ public sealed class SalesInvoiceService : ISalesInvoiceService public async Task> ListAsync(PageQuery query, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default) { - IQueryable q = _invoices.Query().AsNoTracking().Include(x => x.Lines); - if (!string.IsNullOrWhiteSpace(query.Q)) + var q = Filter(_invoices.Query().AsNoTracking().Include(x => x.Lines), query.Q, status, customerId, warehouseId, branchId, from, to, createdBy, paid); + + var total = await q.CountAsync(ct); + var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct); + + return PagedResponse.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total); + } + + public async Task TotalsAsync(string? q, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from = null, DateOnly? to = null, int? createdBy = null, bool? paid = null, CancellationToken ct = default) + { + var filtered = Filter(_invoices.Query().AsNoTracking(), q, status, customerId, warehouseId, branchId, from, to, createdBy, paid); + // Overdue = due date (InvoiceDate) before today with money still owed — the list's red "Due" rule. + var today = DateTime.UtcNow.Date; + + var totals = await filtered + .GroupBy(_ => 1) + .Select(g => new + { + Count = g.Count(), + Net = g.Sum(x => x.GrandTotal), + Paid = g.Sum(x => x.PaidAmount), + Balance = g.Sum(x => x.BalanceAmount > 0 ? x.BalanceAmount : 0), + Unpaid = g.Count(x => x.BalanceAmount > 0), + Overdue = g.Count(x => x.BalanceAmount > 0 && x.InvoiceDate < today), + }) + .FirstOrDefaultAsync(ct); + + return totals is null + ? new SalesInvoiceListTotalsDto(0, 0m, 0m, 0m, 0, 0) + : new SalesInvoiceListTotalsDto(totals.Count, totals.Net, totals.Paid, totals.Balance, totals.Unpaid, totals.Overdue); + } + + /// The list's filters, shared with so the totals always cover exactly the listed rows. + private static IQueryable Filter(IQueryable q, string? search, SalesInvoiceStatus? status, int? customerId, int? warehouseId, int? branchId, DateOnly? from, DateOnly? to, int? createdBy, bool? paid) + { + if (!string.IsNullOrWhiteSpace(search)) { - var term = query.Q.Trim(); + var term = search.Trim(); q = q.Where(x => EF.Functions.Like(x.InvoiceNo, $"%{term}%") || EF.Functions.Like(x.CustomerSnapshotName, $"%{term}%")); } if (status is not null) q = q.Where(x => x.Status == status); @@ -72,11 +106,7 @@ public sealed class SalesInvoiceService : ISalesInvoiceService // paid=true: nothing left to pay; paid=false: a balance is still due. if (paid == true) q = q.Where(x => x.BalanceAmount <= 0); if (paid == false) q = q.Where(x => x.BalanceAmount > 0); - - var total = await q.CountAsync(ct); - var rows = await q.OrderByDescending(x => x.SalesInvoiceId).Skip(query.Skip).Take(query.PageSize).ToListAsync(ct); - - return PagedResponse.Create(rows.Select(MapSummary).ToList(), query.Page, query.PageSize, total); + return q; } public async Task?> GetAsync(int salesInvoiceId, CancellationToken ct = default) diff --git a/Frontend/erp-system/app/dashboard/accounts/expense-setup/page.tsx b/Frontend/erp-system/app/dashboard/accounts/expense-setup/page.tsx index bbb0877..b87775e 100644 --- a/Frontend/erp-system/app/dashboard/accounts/expense-setup/page.tsx +++ b/Frontend/erp-system/app/dashboard/accounts/expense-setup/page.tsx @@ -20,15 +20,12 @@ import { Switch } from "@/components/ui/switch" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { toast } from "@/components/ui/toast" -/** What the edit dialog is editing — a category (no GL code) or a title. */ -type Editing = { kind: "category"; row: ExpenseCategory } | { kind: "title"; row: ExpenseTitle } | null - export default function ExpenseSetupPage() { const [categories, setCategories] = useState(null) const [error, setError] = useState(null) const [selectedId, setSelectedId] = useState(null) const [addTitleOpen, setAddTitleOpen] = useState(false) - const [editing, setEditing] = useState(null) + const [editing, setEditing] = useState(null) function load() { expenseSetupApi @@ -45,10 +42,10 @@ export default function ExpenseSetupPage() { const selected = categories?.find((c) => c.expenseCategoryId === selectedId) ?? null return ( -
+
-

Expense Setup

-

+

Expense Setup

+

The four expense categories are fixed; add your own titles under them. Built-in rows can be deactivated but not renamed.

@@ -58,19 +55,22 @@ export default function ExpenseSetupPage() { {categories && (
-
-
- - -
+
{selected.code === "SALARIES" && ( @@ -106,7 +100,36 @@ export default function ExpenseSetupPage() { {selected.titles.length === 0 ? (

No titles yet — add the first one.

) : ( -
+ <> + {/* Phones: one card per title — tap anywhere on it to edit. */} +
    + {selected.titles.map((t) => ( +
  • + +
  • + ))} +
+
@@ -128,7 +151,7 @@ export default function ExpenseSetupPage() { {t.glExpenseAccountCode} {t.status === "Active" ? "Active" : } - @@ -138,6 +161,7 @@ export default function ExpenseSetupPage() {
+ )}
)} @@ -179,7 +203,7 @@ function InactiveBadge() { ) } -function EditDialog({ editing, onClose, onSaved }: { editing: Editing; onClose: () => void; onSaved: () => void }) { +function EditDialog({ editing, onClose, onSaved }: { editing: ExpenseTitle | null; onClose: () => void; onSaved: () => void }) { const [name, setName] = useState("") const [glCode, setGlCode] = useState("") const [active, setActive] = useState(true) @@ -187,33 +211,29 @@ function EditDialog({ editing, onClose, onSaved }: { editing: Editing; onClose: const [saving, setSaving] = useState(false) // Reset whenever a different row is opened — "adjust state while rendering", as in the payment dialogs. - const key = editing ? `${editing.kind}-${editing.kind === "category" ? editing.row.expenseCategoryId : editing.row.expenseTitleId}` : null - const [resetFor, setResetFor] = useState(null) + const key = editing?.expenseTitleId ?? null + const [resetFor, setResetFor] = useState(null) if (key !== resetFor) { setResetFor(key) if (editing) { - setName(editing.row.name) - setGlCode(editing.kind === "title" ? editing.row.glExpenseAccountCode : "") - setActive(editing.row.status === "Active") + setName(editing.name) + setGlCode(editing.glExpenseAccountCode) + setActive(editing.status === "Active") setError(null) } } if (!editing) return null - const isSystem = editing.row.isSystem + const isSystem = editing.isSystem async function submit() { if (!editing) return if (!name.trim()) return setError("Enter a name") - if (editing.kind === "title" && !glCode.trim()) return setError("Enter the GL expense account code") + if (!glCode.trim()) return setError("Enter the GL expense account code") const status: EntityStatus = active ? "Active" : "Inactive" setSaving(true) try { - if (editing.kind === "category") { - await expenseSetupApi.updateCategory(editing.row.expenseCategoryId, { name: name.trim(), status }) - } else { - await expenseSetupApi.updateTitle(editing.row.expenseTitleId, { name: name.trim(), glExpenseAccountCode: glCode.trim(), status }) - } + await expenseSetupApi.updateTitle(editing.expenseTitleId, { name: name.trim(), glExpenseAccountCode: glCode.trim(), status }) toast.success("Saved", name.trim()) onSaved() onClose() @@ -228,7 +248,7 @@ function EditDialog({ editing, onClose, onSaved }: { editing: Editing; onClose: !open && onClose()}> - Edit {editing.kind === "category" ? "category" : "title"} + Edit title @@ -236,13 +256,11 @@ function EditDialog({ editing, onClose, onSaved }: { editing: Editing; onClose: setName(e.target.value)} /> {isSystem && Built-in names can't be changed.} - {editing.kind === "title" && ( - - GL expense account code - setGlCode(e.target.value)} /> - Used for expenses posted from now on; already-posted expenses keep their account. - - )} + + GL expense account code + setGlCode(e.target.value)} /> + Used for expenses posted from now on; already-posted expenses keep their account. + Active — offered on new expenses @@ -250,10 +268,10 @@ function EditDialog({ editing, onClose, onSaved }: { editing: Editing; onClose: {error &&

{error}

}
- - diff --git a/Frontend/erp-system/app/dashboard/customers/page.tsx b/Frontend/erp-system/app/dashboard/customers/page.tsx index 9bea575..1d5e5da 100644 --- a/Frontend/erp-system/app/dashboard/customers/page.tsx +++ b/Frontend/erp-system/app/dashboard/customers/page.tsx @@ -236,11 +236,13 @@ export default function CustomersPage() { } /> - + New customer Its code is generated from the name — everything else can be filled in later from its profile. + {/* Only the fields scroll — title and Create/Cancel stay visible on a phone. */} +
Name @@ -263,10 +265,10 @@ export default function CustomersPage() { -
+
Phone (optional) - setPhone(e.target.value)} placeholder="0771234567" /> + setPhone(e.target.value)} placeholder="0771234567" /> Email (optional) @@ -281,7 +283,7 @@ export default function CustomersPage() { Address line 2 (optional) setAddressLine2(e.target.value)} placeholder="Wellawatte" /> -
+
City (optional) setCity(e.target.value)} placeholder="Colombo" /> @@ -291,7 +293,7 @@ export default function CustomersPage() { setCountry(e.target.value)} placeholder="Sri Lanka" />
-
+
Credit limit (LKR) setCreditLimit(e.target.value)} /> @@ -318,11 +320,12 @@ export default function CustomersPage() { )} -
-
+
+ -
diff --git a/Frontend/erp-system/app/dashboard/hrm/page.tsx b/Frontend/erp-system/app/dashboard/hrm/page.tsx new file mode 100644 index 0000000..d26b5f4 --- /dev/null +++ b/Frontend/erp-system/app/dashboard/hrm/page.tsx @@ -0,0 +1,93 @@ +"use client" + +import Link from "next/link" +import { Banknote, CalendarCheck, CalendarClock, IdCard, SlidersHorizontal, Users } from "lucide-react" + +import { cn } from "@/lib/utils" +import { useAuth } from "@/components/auth/AuthProvider" +import { buttonVariants } from "@/components/ui/button" + +// Same entries (and permission codes) as the HRM group in the sidebar. +const sections = [ + { + title: "Employees", + description: "Employee records, departments and designations.", + href: "/dashboard/hrm/employees", + code: "hrm.employees", + icon: Users, + }, + { + title: "Attendance", + description: "Daily attendance and time records.", + href: "/dashboard/hrm/attendance", + code: "hrm.attendance", + icon: CalendarCheck, + }, + { + title: "Leave", + description: "Leave requests, approvals and balances.", + href: "/dashboard/hrm/leave", + code: "hrm.leave", + icon: CalendarClock, + }, + { + title: "Payroll", + description: "Salary runs and payslips.", + href: "/dashboard/hrm/payroll", + code: "hrm.payroll", + icon: Banknote, + }, + { + title: "Settings", + description: "HRM masters and configuration.", + href: "/dashboard/hrm/settings", + code: "hrm.settings", + icon: SlidersHorizontal, + }, +] + +/** HRM overview — what the breadcrumb's "HRM" opens; one card per HRM section the user can see. */ +export default function HrmHubPage() { + const { navCodes, isAdmin, loading } = useAuth() + // Admins see everything; otherwise the same nav codes that decide the sidebar's HRM entries. + const visible = loading ? [] : sections.filter((s) => isAdmin || navCodes.includes(s.code)) + + return ( +
+
+
+ + HRM +
+

HRM

+

Employees, attendance, leave and payroll in one place.

+
+ + {!loading && visible.length === 0 && ( +

+ You don't have access to any HRM sections. +

+ )} + +
+ {visible.map((section) => { + const Icon = section.icon + return ( + +
+ +
+

{section.title}

+

{section.description}

+
Open
+ + ) + })} +
+
+ ) +} diff --git a/Frontend/erp-system/app/dashboard/page.tsx b/Frontend/erp-system/app/dashboard/page.tsx index 33625f4..547da52 100644 --- a/Frontend/erp-system/app/dashboard/page.tsx +++ b/Frontend/erp-system/app/dashboard/page.tsx @@ -34,6 +34,7 @@ import BarChart from "@/components/ui/bar-chart" import LineChart from "@/components/ui/line-chart" const TREND_DAYS = 14 +const MOBILE_TREND_DAYS = 5 /** Fewest bar slots the valuation chart's axis spans, so a couple of bars sit at the start. */ const CHART_MIN_SLOTS = 5 @@ -112,6 +113,16 @@ export default function DashboardPage() { // 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() + // Phones show only the most recent MOBILE_TREND_DAYS days — 14 points are unreadable at that width. + const trendDays = isMobile ? MOBILE_TREND_DAYS : TREND_DAYS + const shownTrend = useMemo( + () => ({ + labels: movementTrend.labels.slice(-trendDays), + inData: movementTrend.inData.slice(-trendDays), + outData: movementTrend.outData.slice(-trendDays), + }), + [movementTrend, trendDays] + ) const valuationRows = stats?.stockValuationByWarehouse ?? [] const valuationPad = isMobile ? 0 : Math.max(0, CHART_MIN_SLOTS - valuationRows.length) @@ -130,26 +141,28 @@ export default function DashboardPage() {
)} -
+ {/* Three across until there is room for six — at six-up the cards get too narrow and switch to the + compact stacked layout, so they would not match the stat cards on every other screen. */} +
{loaded ? ( <> - - + + - - + + - - + + - - + + - - + + - - + + ) : ( @@ -199,23 +212,23 @@ export default function DashboardPage() {

- Stock Movement Trend (last {TREND_DAYS} days) + Stock Movement Trend (last {trendDays} days)

{loaded ? ( - movementTrend.inData.some((v) => v > 0) || movementTrend.outData.some((v) => v > 0) ? ( + shownTrend.inData.some((v) => v > 0) || shownTrend.outData.some((v) => v > 0) ? (
) : ( -

No stock movement in the last {TREND_DAYS} days.

+

No stock movement in the last {trendDays} days.

) ) : ( !error && 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 8de035d..66aa90a 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 @@ -230,7 +230,7 @@ function NewPurchaseReturnContent() { <>
- + items={grnOptions} value={grnId} diff --git a/Frontend/erp-system/app/dashboard/products/new/page.tsx b/Frontend/erp-system/app/dashboard/products/new/page.tsx index ad38b2d..c501021 100644 --- a/Frontend/erp-system/app/dashboard/products/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/new/page.tsx @@ -63,6 +63,8 @@ import { ITEM_IMAGE_MAX_COUNT, itemImageProblem, } from "@/components/products/ItemImagesPanel" +import { AddUomDialog } from "@/components/products/AddUomDialog" +import { AddBrandDialog } from "@/components/products/AddBrandDialog" function skuSegment(text: string, maxLen: number): string { const cleaned = text.trim().toUpperCase().replace(/[^A-Z0-9]/g, "") @@ -118,6 +120,8 @@ export default function NewItemPage() { const [uoms, setUoms] = useState<{ uomId: number; name: string }[]>([]) /** Defaults to the first UOM once loaded; null only means none exist yet. */ const [baseUomId, setBaseUomId] = useState(null) + const [addUomOpen, setAddUomOpen] = useState(false) + const [addBrandOpen, setAddBrandOpen] = useState(false) // Kept as a raw string: an empty box means "no content size", which is different from 0. const [contentQty, setContentQty] = useState("") const [contentUnit, setContentUnit] = useState(null) @@ -216,7 +220,11 @@ export default function NewItemPage() { setBaseUomId(uoms.items[0]?.uomId ?? null) setWarehouses(wh.items) }) - .catch((err) => setLoadError(errorMessage(err))) + .catch((err) => { + const message = errorMessage(err) + setLoadError(message) + toast.error("Could not load the form", message) + }) // Optional extra: a failure here just means only the default periods are offered. warrantyPeriodsApi .list({ status: "Active" }) @@ -350,6 +358,7 @@ export default function NewItemPage() { const parsed = parseWarrantyInput(customPeriodAmount, customPeriodUnit, customWarrantyMonths) if (parsed.error !== undefined) { setCustomPeriodError(parsed.error) + toast.error("Invalid warranty period", parsed.error) return } setItemOnlyWarrantyMonths(parsed.months) @@ -375,24 +384,30 @@ export default function NewItemPage() { * in the form's Measurement unit (500 ml, 1 L) and each one becomes a variant with its own * content size — the same path as a measurable Item Variant. Name defaults to "Size". */ + /** Inline message under the custom-variant box, mirrored as a toast. */ + function failCustom(message: string) { + setCustomError(message) + toast.error("Could not add custom variant", message) + } + function createCustomVariant() { const name = customName.trim() || (customIsSize ? "Size" : "") const invalid = validateItemTypeName(name).name if (invalid) { - setCustomError(invalid) + failCustom(invalid) return } if ((itemTypes ?? []).some((t) => t.name.toLowerCase() === name.toLowerCase())) { - setCustomError(`"${name}" already exists — tick it above instead.`) + failCustom(`"${name}" already exists — tick it above instead.`) return } if (customIsSize && !measurementUnit) { - setCustomError("Pick a Measurement unit at the top first — size values are entered in it.") + failCustom("Pick a Measurement unit at the top first — size values are entered in it.") return } // Only one dimension may supply the content size, or a variant would have two. if (customIsSize && measurableCheckedCount > 0) { - setCustomError("Only one size variant can be used at a time — untick the other one first.") + failCustom("Only one size variant can be used at a time — untick the other one first.") return } const local: ItemType = { @@ -413,6 +428,10 @@ export default function NewItemPage() { * duplicate pair never reaches state, so nothing downstream has to defend against one. */ function addValue(itemTypeId: number) { + const failValue = (message: string) => { + setValueErrors((prev) => ({ ...prev, [itemTypeId]: message })) + toast.error("Could not add value", message) + } const measurable = (itemTypes ?? []).find((t) => t.itemTypeId === itemTypeId)?.isMeasurable ?? false if (!measurable) { @@ -435,11 +454,11 @@ export default function NewItemPage() { const qty = Number(raw) if (!raw || Number.isNaN(qty) || qty <= 0) { - setValueErrors((prev) => ({ ...prev, [itemTypeId]: "Enter a size greater than 0" })) + failValue("Enter a size greater than 0") return } if (!unit) { - setValueErrors((prev) => ({ ...prev, [itemTypeId]: "Select a unit" })) + failValue("Select a unit") return } @@ -448,7 +467,7 @@ export default function NewItemPage() { const key = measureKey(qty, unit) const existing = valuesByCategory[itemTypeId] ?? [] if (existing.some((v) => isMeasured(v) && measureKey(v.qty, v.unit) === key)) { - setValueErrors((prev) => ({ ...prev, [itemTypeId]: "That size is already in the list" })) + failValue("That size is already in the list") return } @@ -708,13 +727,44 @@ export default function NewItemPage() { const loading = !categories || !brands || !itemTypes || !config + /** One price cell, shared by the mobile variant cards and the desktop table. Flat ("") is the validated one. */ + function priceInput(variantKey: string, kind: "min" | "max" | "flat", className?: string) { + const stateKey = kind === "flat" ? variantKey : `${variantKey}::${kind}` + const invalid = kind === "flat" && !!priceErrors[variantKey] + return ( + { + const value = e.target.value + setPricesByKey((prev) => ({ ...prev, [stateKey]: value })) + if (kind === "flat") + setPriceErrors((prev) => { + if (!prev[variantKey]) return prev + const next = { ...prev } + delete next[variantKey] + return next + }) + }} + placeholder="0.00" + aria-label={`${kind === "flat" ? "Flat" : kind === "min" ? "Min" : "Max"} price`} + aria-invalid={invalid} + className={cn("h-10 text-base", className)} + /> + ) + } + + /** Mobile-first section shell: tighter padding on phones, roomier from `sm` up. */ + const sectionClass = "flex flex-col gap-4 rounded-xl border p-4 sm:p-5" + return ( -
-
-
-

New Item

-

Category, subcategory, brand, and item variants (FR-MD-01).

-
+
+
+

New Item

+

Category, subcategory, brand, and item variants (FR-MD-01).

{loadError && ( @@ -726,17 +776,19 @@ export default function NewItemPage() { {!loading && baseUomId === null && (
No unit of measure exists yet. Items need a base UOM —{" "} - - create one first - + .
)} {!loading && ( <> +
+

Basic details

-
+
+
+ + value={brandId} + onValueChange={setBrandId} + items={(brands ?? []).map((b) => ({ label: b.name, value: b.brandId }))} + > + + + + + {(brands ?? []).map((b) => ( + + {b.name} + + ))} + + + +
+
+ )} +
+
+ +
+

Units & stock

+
+
+ +
- value={brandId} - onValueChange={setBrandId} - items={(brands ?? []).map((b) => ({ label: b.name, value: b.brandId }))} + value={baseUomId} + onValueChange={setBaseUomId} + items={uoms.map((u) => ({ label: u.name, value: u.uomId }))} > - - + + - {(brands ?? []).map((b) => ( - - {b.name} + {uoms.map((u) => ( + + {u.name} ))} +
- )} -
- -
- { - setLowStockAt(e.target.value) - clearErrors("lowStockAt", "reorderQty") - }} - placeholder="Alert when stock ≤" - aria-label="Alert when stock is at or below" - aria-invalid={!!errors.lowStockAt} - className="h-12! text-base" - /> - { - setReorderQty(e.target.value) - clearErrors("reorderQty") - }} - placeholder="Reorder qty" - aria-label="Reorder quantity" - aria-invalid={!!errors.reorderQty} - className="h-12! text-base" - /> -
- -

- {lowStockAt.trim() - ? `When stock at ${warehouseId ? (warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? "the warehouse") : "any warehouse"} drops to ${lowStockAt} or less, a low-stock alert is sent and the item shows on Reorder Alerts.` - : "When stock drops to this count or less, you get a low-stock alert. Leave blank for no alert."} -

-
-
- - - value={warehouseId} - onValueChange={setWarehouseId} - items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))} - > - - - - - {warehouses.map((w) => ( - - {w.code} — {w.name} - - ))} - - -

Where the low-stock alert watches stock. None picked = every warehouse.

-
-
- - - value={baseUomId} - onValueChange={setBaseUomId} - items={uoms.map((u) => ({ label: u.name, value: u.uomId }))} - > - - - - - {uoms.map((u) => ( - - {u.name} - - ))} - -
{/* The unit size variants are measured in. Picked once here; every size value added below (custom size variant or a measurement Item Variant) starts in this unit. */} @@ -1026,11 +1042,88 @@ export default function NewItemPage() {

+
+ +
+
+

Low stock alert

+

Optional — get notified when stock runs low.

+
+
+
+ + {/* Stays side by side even on phones: two short numbers read as one rule. */} +
+ { + setLowStockAt(e.target.value) + clearErrors("lowStockAt", "reorderQty") + }} + placeholder="Alert at" + aria-label="Alert when stock is at or below" + aria-invalid={!!errors.lowStockAt} + className="h-12! text-base" + /> + { + setReorderQty(e.target.value) + clearErrors("reorderQty") + }} + placeholder="Reorder qty" + aria-label="Reorder quantity" + aria-invalid={!!errors.reorderQty} + className="h-12! text-base" + /> +
+ +

+ {lowStockAt.trim() + ? `When stock at ${warehouseId ? (warehouses.find((w) => w.warehouseId === warehouseId)?.code ?? "the warehouse") : "any warehouse"} drops to ${lowStockAt} or less, a low-stock alert is sent and the item shows on Reorder Alerts.` + : "When stock drops to this count or less, you get a low-stock alert. Leave blank for no alert."} +

+
+
+ + + value={warehouseId} + onValueChange={setWarehouseId} + items={warehouses.map((w) => ({ label: `${w.code} — ${w.name}`, value: w.warehouseId }))} + > + + + + + {warehouses.map((w) => ( + + {w.code} — {w.name} + + ))} + + +

Where the low-stock alert watches stock. None picked = every warehouse.

+
+
+
{(config?.sellingPricesEnabled || config?.warrantyPeriodsEnabled) && ( -
+
{config?.sellingPricesEnabled && ( -
+

Variant selling prices

@@ -1054,8 +1147,8 @@ export default function NewItemPage() { {priceMode === "fixed" && ( -

-
+
+
{ const raw = defaultFlatPrice.trim() if (!raw) return @@ -1096,7 +1190,7 @@ export default function NewItemPage() { )} {config?.warrantyPeriodsEnabled && ( -
+

Warranty

@@ -1140,7 +1234,7 @@ export default function NewItemPage() { value: m, }))} > - + @@ -1253,7 +1347,7 @@ export default function NewItemPage() { )} {config?.itemImagesEnabled && ( -

+

Images (optional) @@ -1275,7 +1369,7 @@ export default function NewItemPage() { type="button" onClick={() => removePendingImage(p.url)} aria-label={`Remove ${p.file.name}`} - className="absolute top-1.5 right-1.5 flex size-6 items-center justify-center rounded-md bg-background/90 text-muted-foreground shadow-sm hover:text-destructive" + className="absolute top-1.5 right-1.5 flex size-8 items-center sm:size-6 justify-center rounded-md bg-background/90 text-muted-foreground shadow-sm hover:text-destructive" > @@ -1309,7 +1403,7 @@ export default function NewItemPage() { {/* itemTypesEnabled is advisory — the server cannot enforce it (items hold no item-type reference), so this section IS the enforcement. */} {config?.itemTypesEnabled && ( -
+

Item variants

@@ -1329,7 +1423,7 @@ export default function NewItemPage() { )}

-
+
{(itemTypes ?? []).map((t) => { const checked = checkedItemTypeIds.includes(t.itemTypeId) // Only one dimension may supply the content size, or a variant would have two. @@ -1339,7 +1433,7 @@ export default function NewItemPage() { key={t.itemTypeId} title={blocked ? "Only one measurement dimension can be used at a time." : undefined} className={cn( - "flex min-w-32 items-center gap-2.5 rounded-lg border px-3 py-2", + "flex min-h-11 min-w-32 items-center gap-2.5 rounded-lg border px-3 py-2", blocked ? "opacity-50" : "hover:bg-muted/50" )} > @@ -1362,7 +1456,7 @@ export default function NewItemPage() { })} {config?.customVariantsEnabled && !customOpen && ( - @@ -1414,11 +1508,11 @@ export default function NewItemPage() { /> {measurementUnit ? `Size variant (in ${UNIT_LABEL[measurementUnit]})` : "Size variant — pick a Measurement unit above"} -
- -
@@ -1490,9 +1584,15 @@ export default function NewItemPage() { )} -
) : ( @@ -1509,9 +1609,15 @@ export default function NewItemPage() { placeholder={t.name} className="h-11 text-base" /> -
)} @@ -1525,7 +1631,7 @@ export default function NewItemPage() { + + + ))} + + +
+ )}
)} @@ -1657,16 +1769,40 @@ export default function NewItemPage() {
{submitError}
)} -
- + {/* Sticky so the primary action stays in thumb reach on a long form; -mx/-mb bleed to the card edge. */} +
+ {variants.length > 0 && ( +

+ {variants.length} variant{variants.length === 1 ? "" : "s"} will be created +

+ )} + Cancel -
)} + + { + setUoms((prev) => [...prev, { uomId: uom.uomId, name: uom.name }]) + setBaseUomId(uom.uomId) + setSubmitError(null) + }} + /> + { + setBrands((prev) => [...(prev ?? []), brand]) + setBrandId(brand.brandId) + }} + />
) } diff --git a/Frontend/erp-system/app/dashboard/products/page.tsx b/Frontend/erp-system/app/dashboard/products/page.tsx index 1a64d67..25335bc 100644 --- a/Frontend/erp-system/app/dashboard/products/page.tsx +++ b/Frontend/erp-system/app/dashboard/products/page.tsx @@ -33,6 +33,7 @@ import { Brand, Category, ItemOverview, ProductConfig, StockNature, SubCategory, import { Badge } from "@/components/ui/badge" import { Button, buttonVariants } from "@/components/ui/button" +import { toast } from "@/components/ui/toast" import { Checkbox } from "@/components/ui/checkbox" import { Field, FieldLabel } from "@/components/ui/field" import { FilterBar } from "@/components/ui/filter-bar" @@ -187,7 +188,9 @@ export default function ItemsPage() { if (!append) setSelected(new Set()) }) .catch((err) => { - if (!cancelled) setError(errorMessage(err)) + if (cancelled) return + setError(errorMessage(err)) + toast.error("Could not load items", errorMessage(err)) }) .finally(() => { if (!cancelled) setLoadingMore(false) @@ -344,19 +347,30 @@ export default function ItemsPage() {

Items

Stock value, last purchase and sales activity at a glance — click a row for its full profile.

-
- + {/* New Item is the page's primary action: first and full-width on phones, last (rightmost) and + emphasised on desktop; the secondary actions share a row beneath it on mobile. */} +
+ + + New Item + + - Category/Brand/UOM + + Masters + Category/Brand/UOM + - - - New Item -
@@ -475,7 +489,7 @@ export default function ItemsPage() {

{hasFilters ? "No items match your search/filter." : "No items yet."}

{!hasFilters && ( - + New Item diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx index 6fc438a..88eceff 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/[id]/page.tsx @@ -23,14 +23,15 @@ import { Skeleton } from "@/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { toast } from "@/components/ui/toast" import { GrnStatusBadge, HoldStatusBadge } from "@/components/receiving/status-badges" +import { SerialRanges } from "@/components/receiving/SerialRanges" import { PurchaseInvoiceStatusBadge } from "@/components/procurement/status-badges" import { PurchaseInvoicePrintDialog } from "@/components/procurement/PurchaseInvoicePrint" import { PurchaseInvoicePaymentDialog } from "@/components/procurement/PurchaseInvoicePaymentDialog" -/** Batch (with expiry) and captured serial numbers of one GRN line — serials collapse behind a toggle. */ +/** Batch (with expiry), serial and warranty numbers of one GRN line — numbers shown as From – To ranges. */ function LineTrackingInfo({ line }: { line: GrnLine }) { const serials = line.serials ?? [] - if (!line.batchNo && serials.length === 0) return null + if (!line.batchNo && serials.length === 0 && line.warrantyNumbers.length === 0) return null return (
{line.batchNo && ( @@ -39,18 +40,8 @@ function LineTrackingInfo({ line }: { line: GrnLine }) { {line.batchExpiryDate && <> · expires {line.batchExpiryDate}}

)} - {serials.length > 0 && ( -
- - {serials.length} serial number{serials.length === 1 ? "" : "s"} - -
    - {serials.map((s) => ( -
  • {s.serialNo}
  • - ))} -
-
- )} + s.serialNo)} /> + w.warrantyNo)} />
) } @@ -112,7 +103,13 @@ export default function GrnDetailPage() { useEffect(() => { if (!Number.isFinite(grnId)) return - grnsApi.get(grnId).then(setGrn).catch((err) => setError(errorMessage(err))) + grnsApi + .get(grnId) + .then(setGrn) + .catch((err) => { + setError(errorMessage(err)) + toast.error("Could not load GRN", errorMessage(err)) + }) Promise.all([itemsApi.list({ pageSize: 200 }), uomsApi.list()]) .then(([it, uo]) => { setItems(it.items) @@ -258,11 +255,6 @@ export default function GrnDetailPage() {

{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}

{item?.warranty === "Warranty" && Warranty}
- {line.warrantyNumbers.length > 0 && ( -

- {line.warrantyNumbers.length} warranty number{line.warrantyNumbers.length === 1 ? "" : "s"} captured -

- )}
@@ -378,14 +370,6 @@ export default function GrnDetailPage() { {item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`} {item?.warranty === "Warranty" && Warranty}
- {line.warrantyNumbers.length > 0 && ( -

`${w.warrantyNo} (${w.warrantyPeriodMonths}mo)`).join(", ")} - > - {line.warrantyNumbers.length} warranty number{line.warrantyNumbers.length === 1 ? "" : "s"} captured -

- )} {baseUomLabel(items, uoms, line.itemId)} diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx index 724e46c..1d1579a 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/new/page.tsx @@ -289,7 +289,10 @@ export default function NewGrnPage() { setVendors(ve.items) setPurchaseOrders(po.items.filter((p) => p.status === "Approved" || p.status === "PartiallyReceived")) }) - .catch((err) => setLoadError(errorMessage(err))) + .catch((err) => { + setLoadError(errorMessage(err)) + toast.error("Could not load the GRN form", errorMessage(err)) + }) }, []) useEffect(() => { @@ -318,6 +321,27 @@ export default function NewGrnPage() { const effectiveBranchId = branchingEnabled ? (isAdmin ? (activeBranchId ?? branchFilterId) : myBranchId) : null const visibleWarehouses = warehouses ?? [] + // Each keeps its inline message (it marks the field) and mirrors it as a toast so no error goes unseen. + function failHeader(message: string) { + setHeaderError(message) + toast.error("Check the GRN details", message) + } + + function failScan(message: string) { + setScanError(message) + toast.error("Scan failed", message) + } + + function failSubmit(message: string) { + setSubmitError(message) + toast.error("Could not create GRN", message) + } + + function failWarranty(lineKey: string, message: string) { + setWarrantyAddErrors((prev) => ({ ...prev, [lineKey]: message })) + toast.error("Could not fill warranty numbers", message) + } + function switchMode(next: Mode) { setMode(next) setPoId(null) @@ -342,7 +366,7 @@ export default function NewGrnPage() { const po: PurchaseOrder = await purchaseOrdersApi.get(nextPoId) const openLines = po.lines.filter((l) => l.qtyReceived < l.qty) if (openLines.length === 0) { - setHeaderError("This purchase order has no open (unreceived) lines.") + failHeader("This purchase order has no open (unreceived) lines.") setLines([]) return } @@ -369,7 +393,7 @@ export default function NewGrnPage() { }) ) } catch (err) { - setHeaderError(errorMessage(err)) + failHeader(errorMessage(err)) } finally { setPoLoading(false) } @@ -403,9 +427,9 @@ export default function NewGrnPage() { addItemLine(result.itemId, scannedBatchNo) } catch (err) { if (err instanceof ApiError && err.status === 404) { - setScanError(`No item or lot found for "${code}"`) + failScan(`No item or lot found for "${code}"`) } else { - setScanError(errorMessage(err)) + failScan(errorMessage(err)) } } finally { setScanning(false) @@ -526,7 +550,7 @@ export default function NewGrnPage() { function replaceWarrantyNumbers(lineKey: string, units: number) { const range = warrantyRangeInputs[lineKey] if (!range?.from.trim() || !range?.adding.trim()) { - setWarrantyAddErrors((prev) => ({ ...prev, [lineKey]: "Enter both a from number and an adding number" })) + failWarranty(lineKey, "Enter both a from number and an adding number") return } // "From" can be typed short too (e.g. "101") — it splices onto the line's current unit-1 @@ -536,14 +560,11 @@ export default function NewGrnPage() { const to = applyAddingNumber(from, range.adding) const seq = buildWarrantyRange(from, to) if (!seq) { - setWarrantyAddErrors((prev) => ({ ...prev, [lineKey]: "The computed number must share From's prefix and not come before it" })) + failWarranty(lineKey, "The computed number must share From's prefix and not come before it") return } if (seq.length > units) { - setWarrantyAddErrors((prev) => ({ - ...prev, - [lineKey]: `From/adding number spans ${seq.length} number(s) but this line only has ${units} unit(s)`, - })) + failWarranty(lineKey, `From/adding number spans ${seq.length} number(s) but this line only has ${units} unit(s)`) return } setWarrantyAddErrors((prev) => { @@ -636,23 +657,23 @@ export default function NewGrnPage() { poId: mode === "po" ? poId : null, }) if (!header.success) { - setHeaderError(header.error.issues[0]?.message ?? "Check the header fields.") + failHeader(header.error.issues[0]?.message ?? "Check the header fields.") return } if (mode === "direct" && !vendorId) { - setHeaderError("Select a vendor for a direct receipt.") + failHeader("Select a vendor for a direct receipt.") return } if (mode === "po" && !poId) { - setHeaderError("Select a purchase order.") + failHeader("Select a purchase order.") return } if (branchingEnabled && !effectiveBranchId) { - setHeaderError("Select a branch for this GRN.") + failHeader("Select a branch for this GRN.") return } if (lines.length === 0) { - setSubmitError("Add at least one line.") + failSubmit("Add at least one line.") return } @@ -680,6 +701,12 @@ export default function NewGrnPage() { ) setLineTab(onlyWarrantyErrors ? "warranty" : "lines") setSubmitError("Fix the highlighted lines before submitting.") + // The toast names the first problem so it's useful even when that line is scrolled out of view. + const messages = [...new Set(Object.values(nextLineErrors).flatMap((errs) => Object.values(errs)))] + toast.error( + "Fix the highlighted lines before submitting", + messages.length === 1 ? messages[0] : `${messages[0]} (and ${messages.length - 1} more)`, + ) return } diff --git a/Frontend/erp-system/app/dashboard/receiving/grn/page.tsx b/Frontend/erp-system/app/dashboard/receiving/grn/page.tsx index 7b886e4..84ff24c 100644 --- a/Frontend/erp-system/app/dashboard/receiving/grn/page.tsx +++ b/Frontend/erp-system/app/dashboard/receiving/grn/page.tsx @@ -16,6 +16,7 @@ import { cn } from "@/lib/utils" import { useAuth } from "@/components/auth/AuthProvider" import { useDashboardStats } from "@/hooks/use-dashboard-stats" import { Button, buttonVariants } from "@/components/ui/button" +import { toast } from "@/components/ui/toast" import { Input } from "@/components/ui/input" import { KpiRow } from "@/components/ui/kpi-row" import { PaginationFooter } from "@/components/ui/pagination-footer" @@ -97,7 +98,10 @@ export default function GrnListPage() { setGrns(res.items) setPagination(res.pagination) }) - .catch((err) => setError(errorMessage(err))) + .catch((err) => { + setError(errorMessage(err)) + toast.error("Could not load GRNs", errorMessage(err)) + }) .finally(() => setShowMoreLoading(false)) } @@ -114,7 +118,7 @@ export default function GrnListPage() {
-

Goods Receipt Notes

+

Goods Received Notes

Receive goods against a purchase order, or record a direct receipt.

diff --git a/Frontend/erp-system/app/dashboard/sales/day-end-report/page.tsx b/Frontend/erp-system/app/dashboard/sales/day-end-report/page.tsx index 6f665a0..7408972 100644 --- a/Frontend/erp-system/app/dashboard/sales/day-end-report/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/day-end-report/page.tsx @@ -22,6 +22,7 @@ import { errorMessage } from "@/lib/error-map" import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format" import { downloadDailyIncomePdf, downloadDayEndReportPdf } from "@/lib/pdf/day-end-report-pdf" import { cn } from "@/lib/utils" +import { useClientPagination } from "@/hooks/use-client-pagination" import { maskAccountNumber } from "@/types/bank-deposits" import { DayEndDailyReport, DayEndReport, DayEndReportDay, DayEndReportDeposit } from "@/types/sales" @@ -30,6 +31,7 @@ import { DayEndIncomeChart } from "@/components/sales/DayEndIncomeChart" import { Button, buttonVariants } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" +import { PaginationFooter } from "@/components/ui/pagination-footer" import { Skeleton } from "@/components/ui/skeleton" import { StatCard } from "@/components/ui/stat-card" import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table" @@ -71,6 +73,20 @@ function ProfitCell({ value, bold = false }: { value: number; bold?: boolean }) ) } +/** Label-over-value pair used by the phone cards that stand in for the tables below `md`. */ +function KV({ label, children, className }: { label: string; children: React.ReactNode; className?: string }) { + return ( +
+ {label} + {children} +
+ ) +} + +/** Phone card shell; `total` gives the summary card at the end of a list its tinted look. */ +const mobileCard = (total = false) => + cn("flex flex-col gap-2 rounded-lg border p-3 text-sm", total && "border-primary/30 bg-primary/5 font-semibold") + /** The day's result as a short statement: sales − cost = gross profit − expenses = net profit or loss. */ function ProfitStatement({ day }: { day: DayEndReportDay }) { const gross = profitOrLoss(day.grossProfit) @@ -112,6 +128,7 @@ function ProfitStatement({ day }: { day: DayEndReportDay }) { * banked, and what is still in hand — plus the deposits themselves. The figures come from the Bank Deposit module. */ function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; deposits: DayEndReportDeposit[]; date: string }) { + const depositPage = useClientPagination(deposits, date) const remaining = day.closingCashInHand ?? 0 return (
@@ -125,21 +142,15 @@ function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; depo -
0 ? "bg-warning/10 ring-warning/30" : "bg-card ring-border" - )} - > -
-

Remaining cash in hand

- 0 ? "text-warning" : "text-muted-foreground")} aria-hidden /> -
-

0 ? "text-warning" : "text-foreground")}> - {formatAmount(remaining)} -

-

{remaining > 0 ? "Not yet deposited" : "All cash banked"}

-
+ 0 ? "Not yet deposited" : "All cash banked"} + className={remaining > 0 ? "bg-warning/10 ring-warning/30" : undefined} + iconClassName={remaining > 0 ? "bg-warning/15 text-warning" : undefined} + valueClassName={remaining > 0 ? "text-warning" : undefined} + />

Net cash = cash sales + cash collected on invoices − expenses paid in cash. Only cash is deposited; card and transfers go @@ -149,6 +160,28 @@ function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; depo {deposits.length === 0 ? (

No bank deposits for {formatReportDate(date)}.

) : ( + <> +
    + {depositPage.pageRows.map((d) => ( +
  • +
    + {d.docNo} + {formatAmount(d.amount)} +
    +

    + {[d.bankAccountName, d.bankName, maskAccountNumber(d.accountNumber)].filter(Boolean).join(" · ")} +

    +

    + Slip {d.depositSlipNo ?? "—"} · {formatReportDate(d.depositDate)} +

    +
  • + ))} +
  • + Total deposited + {formatAmount(day.cashDeposited ?? 0)} +
  • +
+
@@ -160,7 +193,7 @@ function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; depo - {deposits.map((d) => ( + {depositPage.pageRows.map((d) => ( {d.docNo} @@ -184,6 +217,11 @@ function CashInHandSection({ day, deposits, date }: { day: DayEndReportDay; depo
+
+ {depositPage.pagination.totalItems > 10 && ( + + )} + )}
) @@ -272,6 +310,10 @@ export default function DayEndReportPage() { const sales = day?.data?.sales ?? [] const expenses = day?.data?.expenses ?? [] const deposits = day?.data?.deposits ?? [] + // Paged in the browser (the report returns whole lists); a new date / range starts on page 1. + const salesPage = useClientPagination(sales, date) + const expensePage = useClientPagination(expenses, date) + const dayPage = useClientPagination(range?.data ? [...range.data.days].reverse() : [], `${from}|${to}`) return (
@@ -305,7 +347,7 @@ export default function DayEndReportPage() { {!s && !day?.error && (
- {Array.from({ length: 10 }).map((_, i) => )} + {Array.from({ length: 10 }).map((_, i) => )}
)} @@ -325,27 +367,15 @@ export default function DayEndReportPage() { -
= 0 ? "bg-success/10 ring-success/30" : "bg-destructive/10 ring-destructive/30" - )} - > -
-

Income

- {s.income >= 0 - ? - : } -
-

= 0 ? "text-success" : "text-destructive")}> - {formatSignedAmount(s.income)} -

- {s.income < 0 ? ( -

Loss — expenses are more than sales

- ) : ( -

Sales − expenses

- )} -
+ = 0 ? TrendingUp : TrendingDown} + hint={s.income < 0 ? "Loss — expenses are more than sales" : "Sales − expenses"} + className={s.income >= 0 ? "bg-success/10 ring-success/30" : "bg-destructive/10 ring-destructive/30"} + iconClassName={s.income >= 0 ? "bg-success/15 text-success" : "bg-destructive/15 text-destructive"} + valueClassName={s.income >= 0 ? "text-success" : "text-destructive"} + />

{s.mixedSalesCount} paid partly cash, partly card · {s.otherSalesCount} by bank transfer/cheque @@ -361,7 +391,48 @@ export default function DayEndReportPage() { {sales.length === 0 ? (

No posted sales on {formatReportDate(date)}.

) : ( -
+ <> +
    + {salesPage.pageRows.map((row) => ( +
  • +
    + + {row.docType === "Invoice" ? ( + {row.docNo} + ) : ( + {row.docNo} + )} + + {new Date(row.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} · {row.docType} · {row.customerName} + + + + {formatAmount(row.total)} + {row.paidBy} + +
    +
    + {formatAmount(row.paid)} + {formatAmount(row.due, true)} + {formatAmount(row.cost)} + {formatAmount(row.grossProfit)} +
    +
  • + ))} +
  • +
    + Total + {formatAmount(s.totalSales)} +
    +
    + {formatAmount(s.paidAmount)} + {formatAmount(s.dueAmount)} + {formatAmount(s.costOfGoodsSold)} + {formatAmount(s.grossProfit)} +
    +
  • +
+
@@ -378,7 +449,7 @@ export default function DayEndReportPage() { - {sales.map((row) => ( + {salesPage.pageRows.map((row) => ( {new Date(row.at).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} @@ -415,6 +486,10 @@ export default function DayEndReportPage() {
+ {salesPage.pagination.totalItems > 10 && ( + + )} + )}
@@ -423,6 +498,29 @@ export default function DayEndReportPage() { {expenses.length === 0 ? (

No posted expenses on {formatReportDate(date)}.

) : ( + <> +
    + {expensePage.pageRows.map((e) => ( +
  • +
    + + {e.docNo} + {e.categoryName} · {e.titleName} + {e.payee && Payee: {e.payee}} + + + {formatAmount(e.amount)} + Paid {formatAmount(e.paidAmount)} + +
    +
  • + ))} +
  • + Total + {formatAmount(s.expenseTotal)} +
  • +
+
@@ -435,7 +533,7 @@ export default function DayEndReportPage() { - {expenses.map((e) => ( + {expensePage.pageRows.map((e) => ( {e.docNo} @@ -456,6 +554,11 @@ export default function DayEndReportPage() {
+
+ {expensePage.pagination.totalItems > 10 && ( + + )} + )}
@@ -471,16 +574,16 @@ export default function DayEndReportPage() {

Day-wise income

Income = sales − expenses, per day (up to {MAX_RANGE_DAYS} days).

-
+
- setFrom(e.target.value)} className="w-44" /> + setFrom(e.target.value)} className="w-full sm:w-44" />
- setTo(e.target.value)} className="w-44" /> + setTo(e.target.value)} className="w-full sm:w-44" />
- @@ -493,7 +596,7 @@ export default function DayEndReportPage() { {!rangeError && range?.data && ( <> -
+
- + {/* Its own framed block with extra space above, so the chart is clearly apart from the cards. */} +
+ +
-
+ {/* Phones: one card per day (newest first), tap to show that day above. */} +
    + {dayPage.pageRows.map((d) => { + const p = profitOrLoss(d.netProfit) + return ( +
  • + +
  • + ) + })} +
  • +
    + Total · {range.data.totals.salesCount} sales + + {profitOrLoss(range.data.totals.netProfit).label} {profitOrLoss(range.data.totals.netProfit).amount} + +
    +
    + {formatAmount(range.data.totals.totalSales)} + {formatAmount(range.data.totals.expenseTotal)} + {formatSignedAmount(range.data.totals.income)} + {formatAmount(range.data.totals.cashDeposited ?? 0)} + {formatAmount(range.data.totals.closingCashInHand ?? 0)} +
    +
  • +
+ +
@@ -531,7 +689,7 @@ export default function DayEndReportPage() { - {[...range.data.days].reverse().map((d) => ( + {dayPage.pageRows.map((d) => ( d.date && setDate(d.date)} title="Show this day above"> {formatReportDate(d.date)} {formatAmount(d.totalSales)} @@ -568,6 +726,9 @@ export default function DayEndReportPage() {
+ {dayPage.pagination.totalItems > 10 && ( + + )} )} diff --git a/Frontend/erp-system/app/dashboard/sales/day-end/page.tsx b/Frontend/erp-system/app/dashboard/sales/day-end/page.tsx index 5c698f4..81b1fcb 100644 --- a/Frontend/erp-system/app/dashboard/sales/day-end/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/day-end/page.tsx @@ -2,17 +2,17 @@ import { useEffect, useState } from "react" import Link from "next/link" -import { AlertTriangle, ArrowLeft, CalendarDays, CheckCircle2, CircleDollarSign, FileText, Lock, ReceiptText } from "lucide-react" +import { AlertTriangle, ArrowLeft, CalendarDays, CheckCircle2, CircleDollarSign, FileText, Hourglass, Wallet } from "lucide-react" import { salesApi } from "@/lib/api/sales" import { salesConfigApi } from "@/lib/api/sales-config" -import { salesDayEndApi } from "@/lib/api/sales-day-end" import { usersApi } from "@/lib/api/users" import { errorMessage } from "@/lib/error-map" +import { formatAmount } from "@/lib/format" import { cn } from "@/lib/utils" import { PaginationMeta } from "@/types/common" import { ManagedUser } from "@/types/users" -import { SalesDayEnd, SalesDayEndInvoiceSummary, SalesDayEndPreview, SalesInvoiceSummary } from "@/types/sales" +import { SalesInvoiceListTotals, SalesInvoiceSummary } from "@/types/sales" import { SalesInvoiceBulkPaymentDialog } from "@/components/sales/SalesInvoiceBulkPaymentDialog" import { SalesInvoicePaymentDialog } from "@/components/sales/SalesInvoicePaymentDialog" @@ -21,16 +21,11 @@ import { SalesInvoiceStatusBadge, SalesPaymentStatusBadge } from "@/components/s import { Button, buttonVariants } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" import { PaginationFooter } from "@/components/ui/pagination-footer" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Skeleton } from "@/components/ui/skeleton" +import { StatCard } from "@/components/ui/stat-card" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" -import { toast } from "@/components/ui/toast" - -function todayIso(): string { - return new Date().toISOString().slice(0, 10) -} type SalesDateRange = "All" | "Today" | "7d" | "30d" @@ -77,24 +72,14 @@ const paymentFilters: { value: PaymentFilter; label: string }[] = [ { value: "Paid", label: "Fully paid" }, ] -export default function SalesDayEndPage() { +export default function ApprovedInvoicesPage() { const [cashiers, setCashiers] = useState([]) - const [cashierUserId, setCashierUserId] = useState(null) - const [businessDate, setBusinessDate] = useState(todayIso()) - const [preview, setPreview] = useState(null) - const [previewLoading, setPreviewLoading] = useState(false) - const [previewError, setPreviewError] = useState(null) - - const [report, setReport] = useState(null) - const [closing, setClosing] = useState(false) - - // Approved (Posted) sales invoices, shown as a table with its own filters (independent of the - // business date above): a cashier (null follows the cashier picked above, "All" shows every + // Approved (Posted) sales invoices with their own filters: a cashier ("All" shows every // cashier's sales) and a date range. - const [salesCashier, setSalesCashier] = useState(null) - const effectiveSalesCashier = salesCashier ?? cashierUserId - const [salesDateRange, setSalesDateRange] = useState("Today") + const [salesCashier, setSalesCashier] = useState("All") + const effectiveSalesCashier = salesCashier + const [salesDateRange, setSalesDateRange] = useState("All") const { from: salesFrom, to: salesTo } = salesDateBounds(salesDateRange) const [salesPayment, setSalesPayment] = useState("All") const [salesSearchInput, setSalesSearchInput] = useState("") @@ -135,30 +120,10 @@ export default function SalesDayEndPage() { useEffect(() => { usersApi .list({ pageSize: 200 }) - .then((res) => { - setCashiers(res.items) - setCashierUserId((prev) => prev ?? res.items[0]?.userId ?? null) - }) + .then((res) => setCashiers(res.items)) .catch(() => {}) }, []) - function loadPreview() { - if (!cashierUserId) return - setReport(null) - setPreviewError(null) - setPreviewLoading(true) - salesDayEndApi - .preview(cashierUserId, businessDate) - .then(setPreview) - .catch((err) => setPreviewError(errorMessage(err))) - .finally(() => setPreviewLoading(false)) - } - - useEffect(() => { - loadPreview() - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [cashierUserId, businessDate]) - // Debounced search; a new term starts again from page 1. useEffect(() => { const timeout = setTimeout(() => { @@ -170,7 +135,6 @@ export default function SalesDayEndPage() { }, [salesSearchInput]) useEffect(() => { - if (effectiveSalesCashier === null) return salesApi .listInvoices({ page: salesPage, @@ -186,6 +150,41 @@ export default function SalesDayEndPage() { .catch((err) => setSalesResult({ key: salesKey, rows: null, pagination: null, error: errorMessage(err) })) }, [salesKey, effectiveSalesCashier, salesFrom, salesTo, salesPayment, salesQuery, salesPage, salesPageSize]) + // Stat cards: the same filters as the table, totalled over every match (not one page). Keyed + // like the table so a change of dropdown — or a payment — refreshes them. + const totalsKey = [effectiveSalesCashier, salesFrom, salesTo, salesPayment, salesQuery, salesReload].join("|") + const [totalsResult, setTotalsResult] = useState<{ key: string; data: SalesInvoiceListTotals | null } | null>(null) + const totals = totalsResult?.key === totalsKey ? totalsResult.data : undefined + // If the totals endpoint can't be reached (e.g. an older backend), the cards still show — summed + // from the loaded page and labelled as such — rather than disappearing. + const pageTotals: SalesInvoiceListTotals | null = sales + ? { + count: salesPagination?.totalItems ?? sales.length, + netTotal: sales.reduce((sum, r) => sum + r.totals.grandTotal, 0), + paidTotal: sales.reduce((sum, r) => sum + r.totals.paidAmount, 0), + balanceTotal: sales.reduce((sum, r) => sum + Math.max(0, r.totals.balanceAmount), 0), + unpaidCount: sales.filter((r) => r.totals.balanceAmount > 0).length, + overdueCount: sales.filter(isOverdue).length, + } + : null + const pageOnly = totals === null + const cardTotals = pageOnly ? (pageTotals ?? undefined) : totals + + useEffect(() => { + salesApi + .invoiceTotals({ + status: "Posted", + createdBy: effectiveSalesCashier === "All" ? undefined : effectiveSalesCashier, + from: salesFrom, + to: salesTo, + paid: salesPayment === "All" ? undefined : salesPayment === "Paid", + q: salesQuery || undefined, + }) + .then((data) => setTotalsResult({ key: totalsKey, data })) + // Cards are a summary — a failure blanks them (the table reports its own errors). + .catch(() => setTotalsResult({ key: totalsKey, data: null })) + }, [totalsKey, effectiveSalesCashier, salesFrom, salesTo, salesPayment, salesQuery]) + /** Re-read the table (after a payment); ticks are cleared since balances changed. */ function reloadSales() { setSelected(new Map()) @@ -198,12 +197,6 @@ export default function SalesDayEndPage() { setSelected(new Map()) } - function changeCashier(userId: number | null) { - setCashierUserId(userId) - setSalesCashier(null) - resetSalesView() - } - function changeSalesCashier(value: number | "All") { setSalesCashier(value) resetSalesView() @@ -224,23 +217,6 @@ export default function SalesDayEndPage() { resetSalesView() } - async function handleClose() { - if (!cashierUserId) return - setClosing(true) - try { - const result = await salesDayEndApi.close({ cashierUserId, businessDate }) - setReport(result) - toast.success("Day closed", `${result.docNo} — ${result.slipCount} slip(s), ${result.bundleCount} bundle(s), ${result.grandTotal.toFixed(2)} total.`) - loadPreview() - } catch (err) { - toast.error("Could not close the day", errorMessage(err)) - } finally { - setClosing(false) - } - } - - const cashierName = cashiers.find((c) => c.userId === cashierUserId)?.displayName - const selectedCustomerId = selected.size > 0 ? selected.values().next().value!.customerId : null /** Null when the row can be ticked for a combined payment, else why not (the checkbox's tooltip). */ @@ -262,220 +238,28 @@ export default function SalesDayEndPage() { } return ( -
-
- +
+
+
-

Day End

-

- Close a cashier's business day — locks their posted sales slips and bundle sales, and posts one consolidated journal entry to the ledger. +

Approved Invoices

+

+ Approved sales invoices — view them and take payments, one at a time or several of one customer together.

-
-
- - value={cashierUserId} onValueChange={changeCashier}> - - - - - {cashiers.map((c) => ( - - {c.displayName} - - ))} - - -
-
- - setBusinessDate(e.target.value)} - className="h-12 text-base" - /> -
-
- - {previewLoading && } - - {previewError && ( -
{previewError}
- )} - - {!previewLoading && preview && !report && ( -
- {preview.alreadyClosed ? ( -
- -
-

{cashierName ?? `Cashier ${cashierUserId}`} already closed {businessDate}.

-

{preview.slipCount} slip(s), {preview.bundleCount} bundle(s), {preview.grandTotal.toFixed(2)} total.

-
-
- ) : preview.draftSlipsBlockingClose.length > 0 || preview.draftBundleSalesBlockingClose.length > 0 ? ( -
- -
-

Still Draft — post or cancel these before closing.

-
    - {preview.draftSlipsBlockingClose.map((s) => ( -
  • - {s.slipNo} — {s.grandTotal.toFixed(2)} -
  • - ))} - {preview.draftBundleSalesBlockingClose.map((b) => ( -
  • - {b.bundleNo} — {b.grandTotal.toFixed(2)} -
  • - ))} -
-
-
- ) : ( -

- {preview.slipCount === 0 && preview.bundleCount === 0 - ? preview.invoices.invoiceCount > 0 - ? `No sales slips or bundle sales for this cashier on this date — only ${preview.invoices.invoiceCount} sales invoice(s), which don't close through Day End (see below).` - : "No sales slips or bundle sales for this cashier on this date." - : `${preview.slipCount} posted slip(s), ${preview.bundleCount} posted bundle sale(s) ready to close.`} -

- )} - -
- - - - -
- -
- - -
- - - - {!preview.alreadyClosed && ( -
- -
- )} - - {preview.alreadyClosed && ( -
- -
- )} -
- )} - - {report && ( -
-
-
-

{report.docNo}

-

- {cashierName ?? `Cashier ${report.cashierUserId}`} — {report.businessDate} — closed {new Date(report.closedAt).toLocaleString()} -

-
- {report.glJournalNo && ( -
- GL journal {report.glJournalNo} posted {report.glPostedAt ? new Date(report.glPostedAt).toLocaleString() : ""} -
- )} -
- -
- - - - - - -
- -
- - -
- -
-

Item breakdown

- {report.itemBreakdown.length === 0 ? ( -

No lines.

- ) : ( - <> - {/* Mobile: stacked rows. */} -
- {report.itemBreakdown.map((line) => ( -
- {line.sku} — {line.name} - - {line.qty} × {line.revenue.toFixed(2)} - -
- ))} -
- - {/* Desktop: table. */} -
- - - - Item - Qty - Revenue - - - - {report.itemBreakdown.map((line) => ( - - {line.sku} — {line.name} - {line.qty} - {line.revenue.toFixed(2)} - - ))} - -
-
- - )} -
- - {report.slipNumbers.length > 0 && ( -
-

Slips included

-

{report.slipNumbers.join(", ")}

-
- )} - - {report.bundleNumbers.length > 0 && ( -
-

Bundle sales included

-

{report.bundleNumbers.join(", ")}

-
- )} - - -
- )} + c.userId === salesCashier)?.displayName ?? "Cashier"), + salesDateRanges.find((r) => r.value === salesDateRange)?.label ?? "", + paymentFilters.find((p) => p.value === salesPayment)?.label ?? "", + ].filter(Boolean).join(" · ") + (salesQuery ? ` · “${salesQuery}”` : "")} + /> 0 - const netTotal = visible.reduce((sum, r) => sum + r.totals.grandTotal, 0) - const paidTotal = visible.reduce((sum, r) => sum + r.totals.paidAmount, 0) - const balanceTotal = visible.reduce((sum, r) => sum + r.totals.balanceAmount, 0) - return ( -
+

Sales

Approved sales invoices — filter by cashier, date, payment status or search.

-
+ {/* Phones: search on top, cashier full-width, then date + payment side by side. */} +
+ onSearchChange(e.target.value)} + placeholder="Search invoice or customer" + className="col-span-2 h-11 text-base sm:col-span-1 sm:h-10 sm:text-sm lg:order-last" + /> +
value={cashierFilter} onValueChange={(v) => { if (v !== null) onCashierFilterChange(v) }} items={[{ label: "All cashiers", value: "All" as const }, ...cashiers.map((c) => ({ label: c.displayName, value: c.userId }))]} > - + @@ -661,12 +449,13 @@ function SalesTable({ ))} +
value={dateRange} - onValueChange={(v) => onDateRangeChange(v ?? "Today")} + onValueChange={(v) => onDateRangeChange(v ?? "All")} items={salesDateRanges.map((r) => ({ label: r.label, value: r.value }))} > - + @@ -681,7 +470,7 @@ function SalesTable({ onValueChange={(v) => onPaymentFilterChange(v ?? "All")} items={paymentFilters.map((p) => ({ label: p.label, value: p.value }))} > - + @@ -690,12 +479,6 @@ function SalesTable({ ))} - onSearchChange(e.target.value)} - placeholder="Search invoice or customer" - className="h-10 text-sm" - />
{error &&
{error}
} @@ -709,17 +492,18 @@ function SalesTable({ )} {selected.size > 0 && ( -
+ // Sticky on phones so "Pay selected" stays reachable while ticking rows further down. +

{selected.size} invoice{selected.size === 1 ? "" : "s"} selected for{" "} {selected.values().next().value!.customerSnapshotName} — balance{" "} {selectedBalance.toFixed(2)}

-
- - @@ -729,48 +513,66 @@ function SalesTable({ {!error && rows !== null && visible.length > 0 && ( <> - {/* Mobile: stacked rows. */} -
+ {/* Phones and tablets (below lg): a card per sale — two per row from sm. Details on top, amounts + actions on a row of their own so + nothing is squeezed. Tapping the details opens the invoice. */} +
{visible.map((row) => ( -
- {renderSelectBox(row)} - - {renderActions(row)} + +
+
+
+ Net + Paid + Due + {row.totals.grandTotal.toFixed(2)} + {row.totals.paidAmount.toFixed(2)} + 0 && "font-semibold text-destructive")}> + {row.totals.balanceAmount.toFixed(2)} + +
+ {renderActions(row)} +
))}
- {/* Desktop: table. */} -
- + {/* Desktop (lg+): table. Date and Paid only appear from xl, where there is room for every column. */} +
+
Invoice Customer {showCashier && Cashier} - Date + Date Due Net - Paid + Paid Remaining Status Actions @@ -790,16 +592,16 @@ function SalesTable({ {renderSelectBox(row)} {row.invoiceNo} - {row.customerSnapshotName} + {row.customerSnapshotName} {showCashier && ( {cashierNameById.get(row.createdBy) ?? `User ${row.createdBy}`} )} - {new Date(row.createdAt).toLocaleDateString()} + {new Date(row.createdAt).toLocaleDateString()} {new Date(row.invoiceDate).toLocaleDateString()} {row.totals.grandTotal.toFixed(2)} - {row.totals.paidAmount.toFixed(2)} + {row.totals.paidAmount.toFixed(2)} )} - - {/* Invoices counts every match; the amounts are for the rows on this page. */} -
- - - - -
)} ) } -// Sales Invoices aren't part of the cashier close (they post their own GL on Confirm and have -// no CashierUserId/DayEndId) — this is a read-only summary so the screen isn't a wall of zeros -// for cashiers who only use "New Sale", not Sales Slips/Bundle Sales. -function InvoiceSummaryPanel({ invoices }: { invoices: SalesDayEndInvoiceSummary }) { - if (invoices.invoiceCount === 0) return null - return ( -
-
-

Sales invoices (not part of this close)

-

- Invoices post their own ledger entry when confirmed — shown here for visibility only. -

-
-
- - - - - -
-

{invoices.invoiceNumbers.join(", ")}

-
- ) -} +/** Shared card shell — tighter padding on phones, roomier from `sm` up. */ +const PANEL = "rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)] sm:p-5" + +/** + * Top-of-page totals for whatever the dropdowns/search currently select — across every matching + * invoice, not just the visible page (`pageOnly` marks the fallback when that isn't available). + * `undefined` = loading. Same StatCard as the other screens' KPI rows. + */ +function InvoiceTotalsCards({ + totals, scope, pageOnly, +}: { totals: SalesInvoiceListTotals | null | undefined; scope: string; pageOnly: boolean }) { + if (totals === undefined) { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) + } + if (totals === null) return null -function Stat({ label, value, emphasize, isCount }: { label: string; value: number; emphasize?: boolean; isCount?: boolean }) { return ( -
- {label} - - {isCount ? value : value.toFixed(2)} - +
+

+ Totals for: {scope} + {pageOnly && — amounts cover this page only} +

+
+ + + + 0 ? "bg-warning/10 ring-warning/30" : undefined} + iconClassName={totals.balanceTotal > 0 ? "bg-warning/15 text-warning" : undefined} + /> + 0 && "bg-destructive/10 ring-destructive/30")} + iconClassName={totals.overdueCount > 0 ? "bg-destructive/15 text-destructive" : undefined} + /> +
) } diff --git a/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx b/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx index 3a20a9e..7651484 100644 --- a/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/invoices/[id]/page.tsx @@ -15,6 +15,7 @@ import { companyProfileApi } from "@/lib/api/company-profile" import { invoiceTemplateConfigApi } from "@/lib/api/invoice-template-config" import { salesConfigApi } from "@/lib/api/sales-config" import { errorMessage } from "@/lib/error-map" +import { formatSerialRanges, toSerialRanges } from "@/lib/serial-ranges" import { cn } from "@/lib/utils" import { getSuggestedUnitPrice, parsePriceInput, roundPrice } from "@/lib/sales-line-utils" import { Customer } from "@/types/customers" @@ -638,8 +639,12 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
{line.description}
SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}
{(line.serials ?? []).length > 0 && ( -
- S/N {(line.serials ?? []).map((s) => s.serialNo).join(", ")} + // From – To runs, not one entry per unit (full list on hover). +
s.serialNo).join(", ")} + > + S/N {formatSerialRanges(toSerialRanges((line.serials ?? []).map((s) => s.serialNo)), 3)}
)} diff --git a/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx b/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx index 9153a57..0164c58 100644 --- a/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/invoices/page.tsx @@ -154,7 +154,8 @@ export default function SalesInvoicesPage() { }, [query, dateFrom, dateTo, activeBranchId, customerId, warehouseId, rowsPerPage]) useEffect(() => { - customersApi.list({ pageSize: 200 }).then((r) => setCustomers(r.items)).catch(() => setCustomers([])) + // Includes the cash customer so its (walk-in) invoices can be filtered too. + customersApi.list({ pageSize: 200, includeCashCustomer: true }).then((r) => setCustomers(r.items)).catch(() => setCustomers([])) warehousesApi.list({ pageSize: 200 }).then((r) => setWarehouses(r.items)).catch(() => setWarehouses([])) }, []) diff --git a/Frontend/erp-system/app/dashboard/sales/page.tsx b/Frontend/erp-system/app/dashboard/sales/page.tsx index 10deb94..2e78887 100644 --- a/Frontend/erp-system/app/dashboard/sales/page.tsx +++ b/Frontend/erp-system/app/dashboard/sales/page.tsx @@ -24,8 +24,8 @@ const sections = [ icon: PackageX, }, { - title: "Day End", - description: "Close a cashier's day and post it to the ledger.", + title: "Approved Invoices", + description: "Approved sales invoices — view them and take payments.", href: "/dashboard/sales/day-end", icon: Lock, }, diff --git a/Frontend/erp-system/app/dashboard/stock/serials/page.tsx b/Frontend/erp-system/app/dashboard/stock/serials/page.tsx index b57d6cb..d4b93e4 100644 --- a/Frontend/erp-system/app/dashboard/stock/serials/page.tsx +++ b/Frontend/erp-system/app/dashboard/stock/serials/page.tsx @@ -33,7 +33,7 @@ const STATUS_TONE: Partial> = { /** Friendly names for the document-type prefixes a serial movement references. */ const DOC_LABEL: Record = { - GRN: "Goods receipt", + GRN: "Goods received", SI: "Sales invoice", SSL: "Sales slip", BND: "Bundle sale", diff --git a/Frontend/erp-system/app/dashboard/vendors/[id]/page.tsx b/Frontend/erp-system/app/dashboard/vendors/[id]/page.tsx index 24c8ee1..80b8989 100644 --- a/Frontend/erp-system/app/dashboard/vendors/[id]/page.tsx +++ b/Frontend/erp-system/app/dashboard/vendors/[id]/page.tsx @@ -14,6 +14,7 @@ import { Vendor } from "@/types/master-data" import { GrnSummary } from "@/types/grn" import { Button, buttonVariants } from "@/components/ui/button" +import { StatCard } from "@/components/ui/stat-card" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" @@ -27,21 +28,6 @@ import BarChart from "@/components/ui/bar-chart" const GRN_PAGE_SIZE_OPTIONS = [5, 10, 15, 20] -/** Small tinted stat chip for the profile header — value + label + icon. */ -function StatChip({ icon: Icon, label, value }: { icon: typeof Truck; label: string; value: string }) { - return ( -
-
- -
-
-

{value}

-

{label}

-
-
- ) -} - export default function VendorDetailPage() { const params = useParams<{ id: string }>() const vendorId = Number(params.id) @@ -293,11 +279,13 @@ export default function VendorDetailPage() {
-
- - - + + + diff --git a/Frontend/erp-system/app/dashboard/warehouse/page.tsx b/Frontend/erp-system/app/dashboard/warehouse/page.tsx index 721bd5c..f3d2aba 100644 --- a/Frontend/erp-system/app/dashboard/warehouse/page.tsx +++ b/Frontend/erp-system/app/dashboard/warehouse/page.tsx @@ -33,6 +33,7 @@ import { PaginationFooter } from "@/components/ui/pagination-footer" import { Skeleton } from "@/components/ui/skeleton" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { toast } from "@/components/ui/toast" +import { limitPhoneInput, phoneError } from "@/lib/validations/phone" /** First word of the name, uppercased and stripped to alphanumerics — falls back to "WH" * so an empty/punctuation-only name still yields a usable base. */ @@ -153,13 +154,18 @@ export default function WarehousesPage() { async function handleCreate() { const nextErrors: Record = {} if (!name.trim()) nextErrors.name = "Warehouse name is required" + const phoneProblem = phoneError(phone) + if (phoneProblem) nextErrors.phone = phoneProblem if (warehouseBranchingEnabled && !branchId) nextErrors.branchId = "Branch is required" setErrors(nextErrors) - if (Object.keys(nextErrors).length > 0) return + if (Object.keys(nextErrors).length > 0) { + toast.error("Could not create warehouse", Object.values(nextErrors)[0]) + return + } setSubmitting(true) try { - const warehouse = await warehousesApi.create({ code: generatedCode, name, address: address || null, phone: phone || null, branchId }) + const warehouse = await warehousesApi.create({ code: generatedCode, name, address: address || null, phone: phone.trim() || null, branchId }) toast.success("Warehouse created", `${warehouse.code} — ${warehouse.name}`) closeCreate() load() @@ -187,13 +193,18 @@ export default function WarehousesPage() { const nextErrors: Record = {} if (!editCode.trim()) nextErrors.code = "Code is required" if (!editName.trim()) nextErrors.name = "Warehouse name is required" + const phoneProblem = phoneError(editPhone) + if (phoneProblem) nextErrors.phone = phoneProblem if (warehouseBranchingEnabled && !editBranchId) nextErrors.branchId = "Branch is required" setEditErrors(nextErrors) - if (Object.keys(nextErrors).length > 0) return + if (Object.keys(nextErrors).length > 0) { + toast.error("Could not update warehouse", Object.values(nextErrors)[0]) + return + } setEditSubmitting(true) try { - const warehouse = await warehousesApi.update(editTarget.warehouseId, { code: editCode, name: editName, address: editAddress || null, phone: editPhone || null, branchId: editBranchId }) + const warehouse = await warehousesApi.update(editTarget.warehouseId, { code: editCode, name: editName, address: editAddress || null, phone: editPhone.trim() || null, branchId: editBranchId }) toast.success("Warehouse updated", `${warehouse.code} — ${warehouse.name}`) setEditTarget(null) load() @@ -262,9 +273,20 @@ export default function WarehousesPage() { Address (optional) setAddress(e.target.value)} placeholder="Street, city…" /> - + Phone (optional) - setPhone(e.target.value)} placeholder="+94 …" /> + { setPhone(limitPhoneInput(e.target.value)); setErrors(dropError("phone")) }} + onBlur={() => { const p = phoneError(phone); if (p) setErrors((prev) => ({ ...prev, phone: p })) }} + placeholder="0771234567 or +94771234567" + aria-invalid={!!errors.phone} + /> + {warehouseBranchingEnabled && ( @@ -556,9 +578,20 @@ export default function WarehousesPage() { Address (optional) setEditAddress(e.target.value)} placeholder="Street, city…" /> - + Phone (optional) - setEditPhone(e.target.value)} placeholder="+94 …" /> + { setEditPhone(limitPhoneInput(e.target.value)); setEditErrors(dropError("phone")) }} + onBlur={() => { const p = phoneError(editPhone); if (p) setEditErrors((prev) => ({ ...prev, phone: p })) }} + placeholder="0771234567 or +94771234567" + aria-invalid={!!editErrors.phone} + /> + {warehouseBranchingEnabled && ( diff --git a/Frontend/erp-system/components/Layouts/AppSidebar.tsx b/Frontend/erp-system/components/Layouts/AppSidebar.tsx index 879ef52..8caae37 100644 --- a/Frontend/erp-system/components/Layouts/AppSidebar.tsx +++ b/Frontend/erp-system/components/Layouts/AppSidebar.tsx @@ -46,7 +46,6 @@ import { Receipt, ReceiptText, Scale, - ScanLine, ScrollText, Settings, ShieldCheck, @@ -62,12 +61,8 @@ import { type LucideIcon, } from "lucide-react" -import { approvalsApi } from "@/lib/api/approvals" import { cn } from "@/lib/utils" import { useAuth } from "@/components/auth/AuthProvider" -import { useTrackingSettings } from "@/hooks/use-tracking-settings" - -const SERIALS_HREF = "/dashboard/stock/serials" // `code` must match the seeded NavItem/SubNavItem codes in ERPCore // (Infra/Persistence/Configurations/NavItemConfiguration.cs / SubNavItemConfiguration.cs) @@ -109,8 +104,6 @@ const navItems: { { title: "Price Adjustments", code: "products.price-adjustments", href: "/dashboard/products/price-adjustments", icon: Tag }, ], }, - // Open to everyone signed in: the inbox only lists what the user may approve and their own requests. - { title: "Approvals", code: "approvals", href: "/dashboard/approvals", icon: ClipboardCheck }, { title: "Procurement", code: "procurement", @@ -159,9 +152,6 @@ const navItems: { children: [ { title: "Stock Ledger", code: "stock.ledger", href: "/dashboard/stock/ledger", icon: ScrollText }, { title: "Stock Enquiry", code: "stock.enquiry", href: "/dashboard/stock/enquiry", icon: PackageSearch }, - // Serial register and batch expiry are enquiries — they reuse stock.enquiry's permission code. - { title: "Serials", code: "stock.enquiry", href: "/dashboard/stock/serials", icon: ScanLine }, - { title: "Batch Expiry", code: "stock.enquiry", href: "/dashboard/stock/batches", icon: CalendarClock }, { title: "Valuation", code: "stock.valuation", href: "/dashboard/stock/valuation", icon: BadgeDollarSign }, { title: "Transfers", code: "stock.transfers", href: "/dashboard/stock/transfers", icon: ArrowLeftRight }, { title: "Adjustments", code: "stock.adjustments", href: "/dashboard/stock/adjustments", icon: SlidersHorizontal }, @@ -250,7 +240,6 @@ function SidebarContent({ onClose, pathname, isMobile, - approvalCount, }: { items: typeof navItems collapsed: boolean @@ -258,8 +247,6 @@ function SidebarContent({ onClose?: () => void pathname: string isMobile: boolean - /** Requests the user can approve — the badge on Approvals. */ - approvalCount: number }) { const iconOnly = !isMobile && collapsed @@ -381,11 +368,6 @@ function SidebarContent({ className={cn("size-3.5 shrink-0", isActive ? "text-primary" : "text-muted-foreground")} /> {!iconOnly && {item.title}} - {item.code === "approvals" && approvalCount > 0 && ( - - {approvalCount} - - )} )}
@@ -453,8 +435,6 @@ export function AppSidebar() { const [collapsed, setCollapsed] = useState(false) const [mobileOpen, setMobileOpen] = useState(false) const { navCodes, loading } = useAuth() - // The Serials register is hidden while serial numbers are switched off in Product Configuration. - const { serialNumbersEnabled } = useTrackingSettings() // While /auth/me hasn't resolved yet, show nothing rather than briefly // flashing the full menu to a restricted role. Once resolved, a nav item @@ -468,16 +448,8 @@ export function AppSidebar() { // seed grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility // stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce // anything server-side. - // "approvals" is not a permission at all — the inbox is for everyone (see its nav entry). - const bypassCodes = new Set(["procurement", "sales", "sales-returns", "hrm", "production", "stock", "approvals"]) + const bypassCodes = new Set(["procurement", "sales", "sales-returns", "hrm", "production", "stock"]) - // Requests waiting for this user — the Approvals badge. Refreshed on navigation, which is when - // someone has most likely just approved or submitted something. - const [approvalCount, setApprovalCount] = useState(0) - useEffect(() => { - if (loading) return - approvalsApi.pending().then((p) => setApprovalCount(p.actionableCount)).catch(() => setApprovalCount(0)) - }, [loading, pathname]) const visibleItems = loading ? [] : navItems @@ -492,7 +464,7 @@ export function AppSidebar() { children: (bypassCodes.has(item.code) ? item.children : item.children?.filter((c) => navCodes.includes(c.code) || c.altCodes?.some((ac) => navCodes.includes(ac))) - )?.filter((c) => !(serialNumbersEnabled === false && c.href === SERIALS_HREF)), + ), })) // Close mobile menu on route change @@ -517,7 +489,6 @@ export function AppSidebar() { onCollapse={() => setCollapsed((v) => !v)} pathname={pathname} isMobile={false} - approvalCount={approvalCount} /> @@ -554,7 +525,6 @@ export function AppSidebar() { onClose={() => setMobileOpen(false)} pathname={pathname} isMobile={true} - approvalCount={approvalCount} /> diff --git a/Frontend/erp-system/components/Layouts/Breadcrumbs.tsx b/Frontend/erp-system/components/Layouts/Breadcrumbs.tsx index 5a1e9a6..40a4d8a 100644 --- a/Frontend/erp-system/components/Layouts/Breadcrumbs.tsx +++ b/Frontend/erp-system/components/Layouts/Breadcrumbs.tsx @@ -20,7 +20,7 @@ const SEGMENT_LABELS: Record = { vendors: "Vendors", warehouse: "Warehouses", receiving: "Receiving", - grn: "Goods Receipt Notes", + grn: "Goods Received Notes", stock: "Stock", enquiry: "Enquiry", ledger: "Ledger", @@ -40,9 +40,11 @@ const SEGMENT_LABELS: Record = { "purchase-invoices": "Purchase Invoices", "purchase-returns": "Purchase Returns", production: "Production", + hrm: "HRM", templates: "Templates", runs: "Runs", invoices: "Sales", + "day-end": "Approved Invoices", } function labelFor(segment: string): string { @@ -51,6 +53,17 @@ function labelFor(segment: string): string { return segment.charAt(0).toUpperCase() + segment.slice(1) } +/** + * Grouping paths with no page of their own (no page.tsx), mapped to where their menu lands — the + * same `landingHref`s as the sidebar. Linking the raw path would 404. (HRM has its own hub page now.) + */ +const GROUP_LANDING: Record = { + "/dashboard/production": "/dashboard/production/runs", + "/dashboard/receiving": "/dashboard/receiving/grn", + "/dashboard/sales/bundles": "/dashboard/sales/bundles/templates", + "/dashboard/help/tickets": "/dashboard/help", +} + const linkClass = "truncate transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm" @@ -61,10 +74,10 @@ export function Breadcrumbs() { // Nothing to show above the dashboard root itself. if (segments.length <= 1) return null - const crumbs = segments.map((segment, i) => ({ - href: "/" + segments.slice(0, i + 1).join("/"), - label: labelFor(segment), - })) + const crumbs = segments.map((segment, i) => { + const path = "/" + segments.slice(0, i + 1).join("/") + return { key: path, href: GROUP_LANDING[path] ?? path, label: labelFor(segment) } + }) return ( @@ -72,7 +85,7 @@ export function Breadcrumbs() { {crumbs.map((crumb, i) => { const isLast = i === crumbs.length - 1 return ( - + {isLast ? ( {crumb.label} diff --git a/Frontend/erp-system/components/Layouts/Header.tsx b/Frontend/erp-system/components/Layouts/Header.tsx index f82e9da..e7b9a1b 100644 --- a/Frontend/erp-system/components/Layouts/Header.tsx +++ b/Frontend/erp-system/components/Layouts/Header.tsx @@ -108,10 +108,12 @@ function titleFromPath(pathname: string) { if (pathname === "/dashboard/sales/invoices/new") return "New Sale" if (pathname === "/dashboard/sales/warranties") return "Warranties" if (pathname === "/dashboard/sales/day-end-report") return "Day-End Report" + if (pathname === "/dashboard/sales/day-end") return "Approved Invoices" + if (pathname === "/dashboard/hrm") return "HRM" - if (pathname === "/dashboard/receiving/grn") return "Goods Receipt Notes" - if (pathname === "/dashboard/receiving/grn/new") return "Create Goods Receipt Note" - if (/^\/dashboard\/receiving\/grn\/[^/]+$/.test(pathname)) return "Goods Receipt Note" + if (pathname === "/dashboard/receiving/grn") return "Goods Received Notes" + if (pathname === "/dashboard/receiving/grn/new") return "Create Goods Received Note" + if (/^\/dashboard\/receiving\/grn\/[^/]+$/.test(pathname)) return "Goods Received Note" if (LEDGER_TITLES[pathname]) return LEDGER_TITLES[pathname] @@ -336,7 +338,8 @@ export function Header() { - + {/* Open below the pill (not over it), left edges lined up; at least 14rem wide, or the pill's width if that is wider. */} + All Branches {branches.map((b) => ( diff --git a/Frontend/erp-system/components/accounts/ExpenseForm.tsx b/Frontend/erp-system/components/accounts/ExpenseForm.tsx index 0961597..b881d37 100644 --- a/Frontend/erp-system/components/accounts/ExpenseForm.tsx +++ b/Frontend/erp-system/components/accounts/ExpenseForm.tsx @@ -425,10 +425,10 @@ export function AddTitleDialog({ {error &&

{error}

} - - diff --git a/Frontend/erp-system/components/accounts/ExpensePaymentDialog.tsx b/Frontend/erp-system/components/accounts/ExpensePaymentDialog.tsx index defa3ad..b737bd8 100644 --- a/Frontend/erp-system/components/accounts/ExpensePaymentDialog.tsx +++ b/Frontend/erp-system/components/accounts/ExpensePaymentDialog.tsx @@ -139,11 +139,14 @@ export function ExpensePaymentDialog({ expense, open, onOpenChange, onDone }: Ex return ( - + - {isDraft ? "Post & pay" : "Pay"} expense {expense.docNo} + {isDraft ? "Post & pay" : "Pay"} expense {expense.docNo} + {/* Header and footer stay put; only the form scrolls, so Pay is always reachable on a phone. */} +
+

{isDraft && "This expense is still a draft — it will be posted and paid in one step. "} Pay the full balance or change the amount to pay part of it now.{" "} @@ -204,7 +207,7 @@ export function ExpensePaymentDialog({ expense, open, onOpenChange, onDone }: Ex

{accountsError && ( -
+
Could not load cash/bank accounts from the ledger: {accountsError}
+ - - diff --git a/Frontend/erp-system/components/procurement/PurchaseInvoicePaymentDialog.tsx b/Frontend/erp-system/components/procurement/PurchaseInvoicePaymentDialog.tsx index f623de8..04dd0a2 100644 --- a/Frontend/erp-system/components/procurement/PurchaseInvoicePaymentDialog.tsx +++ b/Frontend/erp-system/components/procurement/PurchaseInvoicePaymentDialog.tsx @@ -146,11 +146,14 @@ export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPa return ( - + - {isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.docNo} + {isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.docNo} + {/* Header and footer stay put; only the form scrolls, so Pay is always reachable on a phone. */} +
+

{isDraft && "This invoice is still a draft — submitting will confirm it, then record the payment. "} Balance due: {formatAmount(balance)} @@ -191,11 +194,13 @@ export function PurchaseInvoicePaymentDialog({ invoice, open, onOpenChange, onPa +

+ - - diff --git a/Frontend/erp-system/components/products/AddBrandDialog.tsx b/Frontend/erp-system/components/products/AddBrandDialog.tsx new file mode 100644 index 0000000..894cfcf --- /dev/null +++ b/Frontend/erp-system/components/products/AddBrandDialog.tsx @@ -0,0 +1,117 @@ +"use client" + +import { useState } from "react" + +import { brandsApi } from "@/lib/api/brands" +import { errorMessage } from "@/lib/error-map" +import { validateBrandName } from "@/lib/validations/master-data" +import { Brand } from "@/types/master-data" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { FieldError } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { toast } from "@/components/ui/toast" + +/** + * Creates a brand from a popup — used by the Brand tab and inline from New Item. Same + * validation and endpoint as before; the caller receives the created row. + */ +export function AddBrandDialog({ + open, + onOpenChange, + onCreated, + description = "The new brand is selected for this item and offered on other items too.", +}: { + open: boolean + onOpenChange: (open: boolean) => void + onCreated: (brand: Brand) => void + description?: string +}) { + const [name, setName] = useState("") + const [error, setError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + function close(next: boolean) { + if (submitting) return + if (!next) { + setName("") + setError(null) + } + onOpenChange(next) + } + + async function handleSave() { + const invalid = validateBrandName(name).name + if (invalid) { + setError(invalid) + toast.error("Could not create brand", invalid) + return + } + setSubmitting(true) + try { + const { data: brand } = await brandsApi.create({ name: name.trim() }) + toast.success("Brand created", brand.name) + onCreated(brand) + setName("") + setError(null) + onOpenChange(false) + } catch (err) { + setError(errorMessage(err)) + toast.error("Could not create brand", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + return ( + + + + Add brand + {description} + +
+ + { + setName(e.target.value) + setError(null) + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + handleSave() + } + }} + placeholder="e.g. Bosch" + aria-invalid={!!error} + className="h-12 text-base" + /> + +
+ + + + +
+
+ ) +} diff --git a/Frontend/erp-system/components/products/AddUomDialog.tsx b/Frontend/erp-system/components/products/AddUomDialog.tsx new file mode 100644 index 0000000..68549fa --- /dev/null +++ b/Frontend/erp-system/components/products/AddUomDialog.tsx @@ -0,0 +1,117 @@ +"use client" + +import { useState } from "react" + +import { uomsApi } from "@/lib/api/uoms" +import { errorMessage, fieldErrors } from "@/lib/error-map" +import { validateUomName } from "@/lib/validations/master-data" +import { Uom } from "@/types/master-data" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { FieldError } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { toast } from "@/components/ui/toast" + +/** + * Creates a UOM without leaving the current form (e.g. New Item). Same validation and + * endpoint as the UOM tab; the caller receives the created row to add and select it. + */ +export function AddUomDialog({ + open, + onOpenChange, + onCreated, + description = "The new UOM is selected for this item and offered on other items too.", +}: { + open: boolean + onOpenChange: (open: boolean) => void + onCreated: (uom: Uom) => void + description?: string +}) { + const [name, setName] = useState("") + const [error, setError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + function close(next: boolean) { + if (submitting) return + if (!next) { + setName("") + setError(null) + } + onOpenChange(next) + } + + async function handleSave() { + const invalid = validateUomName(name).name + if (invalid) { + setError(invalid) + toast.error("Could not create UOM", invalid) + return + } + setSubmitting(true) + try { + const uom = await uomsApi.create({ name: name.trim() }) + toast.success("UOM created", uom.name) + onCreated(uom) + setName("") + setError(null) + onOpenChange(false) + } catch (err) { + setError(fieldErrors(err)?.name ?? errorMessage(err)) + toast.error("Could not create UOM", errorMessage(err)) + } finally { + setSubmitting(false) + } + } + + return ( + + + + Add unit of measure + {description} + +
+ + { + setName(e.target.value) + setError(null) + }} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault() + handleSave() + } + }} + placeholder="e.g. Box-12" + aria-invalid={!!error} + className="h-12 text-base" + /> + +
+ + + + +
+
+ ) +} diff --git a/Frontend/erp-system/components/products/BrandPanel.tsx b/Frontend/erp-system/components/products/BrandPanel.tsx index 7a888ce..aab02af 100644 --- a/Frontend/erp-system/components/products/BrandPanel.tsx +++ b/Frontend/erp-system/components/products/BrandPanel.tsx @@ -8,6 +8,7 @@ import { errorMessage } from "@/lib/error-map" import { validateBrandName } from "@/lib/validations/master-data" import { Brand } from "@/types/master-data" +import { AddBrandDialog } from "@/components/products/AddBrandDialog" import { Checkbox } from "@/components/ui/checkbox" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -25,9 +26,7 @@ export function BrandPanel() { const [brands, setBrands] = useState(null) const [error, setError] = useState(null) - const [name, setName] = useState("") - const [errors, setErrors] = useState>({}) - const [submitting, setSubmitting] = useState(false) + const [addOpen, setAddOpen] = useState(false) const [togglingId, setTogglingId] = useState(null) const [editOpen, setEditOpen] = useState(false) @@ -41,34 +40,14 @@ export function BrandPanel() { brandsApi .list({ sort: "name", page: 1, pageSize: ALL_ITEMS_PAGE_SIZE }) .then((res) => setBrands(res.items)) - .catch((err) => setError(errorMessage(err))) + .catch((err) => { + setError(errorMessage(err)) + toast.error("Could not load brands", errorMessage(err)) + }) } useEffect(load, []) - async function handleAdd() { - const nextErrors = validateBrandName(name) - setErrors(nextErrors) - if (Object.keys(nextErrors).length > 0) { - toast.error("Could not create brand", Object.values(nextErrors)[0]) - return - } - - setSubmitting(true) - try { - const result = await brandsApi.create({ name }) - toast.success("Brand created", result.data.name) - setName("") - setErrors({}) - load() - } catch (err) { - setErrors({ name: errorMessage(err) }) - toast.error("Could not create brand", errorMessage(err)) - } finally { - setSubmitting(false) - } - } - /** Masters are deactivated, never deleted — the API has no DELETE (FR-MD-08). */ async function handleToggleActive(brand: Brand) { const next = brand.status === "Active" ? "Inactive" : "Active" @@ -119,25 +98,18 @@ export function BrandPanel() { return (
-
- { - setName(e.target.value) - setErrors({}) - }} - placeholder="New brand name, e.g. Bosch" - className="h-12 flex-1 text-base" - aria-label="New brand name" - aria-invalid={!!errors.name} - onKeyDown={(e) => e.key === "Enter" && handleAdd()} - /> -
- {errors.name &&

{errors.name}

} + {error && (
{error}
diff --git a/Frontend/erp-system/components/products/UomPanel.tsx b/Frontend/erp-system/components/products/UomPanel.tsx index 84c22e3..07a9061 100644 --- a/Frontend/erp-system/components/products/UomPanel.tsx +++ b/Frontend/erp-system/components/products/UomPanel.tsx @@ -4,13 +4,12 @@ import { useEffect, useState } from "react" import { Plus, Ruler } from "lucide-react" import { uomsApi } from "@/lib/api/uoms" -import { errorMessage, fieldErrors } from "@/lib/error-map" -import { validateUomName } from "@/lib/validations/master-data" +import { errorMessage } from "@/lib/error-map" import { Uom } from "@/types/master-data" +import { AddUomDialog } from "@/components/products/AddUomDialog" import { Checkbox } from "@/components/ui/checkbox" import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" import { Skeleton } from "@/components/ui/skeleton" import { toast } from "@/components/ui/toast" @@ -22,16 +21,17 @@ export function UomPanel() { const [uoms, setUoms] = useState(null) const [error, setError] = useState(null) - const [name, setName] = useState("") - const [errors, setErrors] = useState>({}) - const [submitting, setSubmitting] = useState(false) + const [addOpen, setAddOpen] = useState(false) const [togglingId, setTogglingId] = useState(null) function load() { uomsApi .list({ page: 1, pageSize: ALL_ITEMS_PAGE_SIZE }) .then((res) => setUoms(res.items)) - .catch((err) => setError(errorMessage(err))) + .catch((err) => { + setError(errorMessage(err)) + toast.error("Could not load UOMs", errorMessage(err)) + }) } useEffect(load, []) @@ -50,51 +50,20 @@ export function UomPanel() { } } - async function handleAdd() { - const nextErrors = validateUomName(name) - setErrors(nextErrors) - if (Object.keys(nextErrors).length > 0) { - toast.error("Could not create UOM", Object.values(nextErrors)[0]) - return - } - - setSubmitting(true) - try { - const uom = await uomsApi.create({ name }) - toast.success("UOM created", uom.name) - setName("") - setErrors({}) - load() - } catch (err) { - const fe = fieldErrors(err) - if (fe?.name) setErrors({ name: fe.name }) - toast.error("Could not create UOM", errorMessage(err)) - } finally { - setSubmitting(false) - } - } - return (
-
- { - 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" - aria-invalid={!!errors.name} - onKeyDown={(e) => e.key === "Enter" && handleAdd()} - /> -
- {errors.name &&

{errors.name}

} + {error && (
{error}
diff --git a/Frontend/erp-system/components/receiving/SerialRanges.tsx b/Frontend/erp-system/components/receiving/SerialRanges.tsx new file mode 100644 index 0000000..e9fc918 --- /dev/null +++ b/Frontend/erp-system/components/receiving/SerialRanges.tsx @@ -0,0 +1,38 @@ +"use client" + +import { formatSerialRanges, toSerialRanges } from "@/lib/serial-ranges" + +/** + * Serial / warranty numbers shown as From – To ranges instead of one row per unit. A single + * run reads inline ("10 serial numbers: SN-001 – SN-010"); several runs expand behind a toggle. + */ +export function SerialRanges({ label, values }: { label: string; values: string[] }) { + const ranges = toSerialRanges(values) + if (ranges.length === 0) return null + const total = ranges.reduce((n, r) => n + r.count, 0) + const heading = `${total} ${label}${total === 1 ? "" : "s"}` + + if (ranges.length <= 2) { + return ( +

+ {heading}: {formatSerialRanges(ranges)} +

+ ) + } + + return ( +
+ + {heading} in {ranges.length} ranges: {formatSerialRanges(ranges)} + +
    + {ranges.map((r) => ( +
  • + {r.count === 1 ? r.from : `${r.from} – ${r.to}`} + ×{r.count} +
  • + ))} +
+
+ ) +} diff --git a/Frontend/erp-system/components/sales/DayEndIncomeChart.tsx b/Frontend/erp-system/components/sales/DayEndIncomeChart.tsx index 1a99124..08a5be6 100644 --- a/Frontend/erp-system/components/sales/DayEndIncomeChart.tsx +++ b/Frontend/erp-system/components/sales/DayEndIncomeChart.tsx @@ -1,14 +1,41 @@ "use client" -import { CategoryScale, Chart as ChartJS, Legend, LinearScale, LineElement, PointElement, Tooltip } from "chart.js" +import { CategoryScale, Chart as ChartJS, Legend, LinearScale, LineElement, PointElement, Tooltip, type Plugin } from "chart.js" import { useTheme } from "next-themes" import { Line } from "react-chartjs-2" +import { useIsMobile } from "@/hooks/use-mobile" import { formatAmount, formatReportDate, formatSignedAmount, profitOrLoss } from "@/lib/format" import { DayEndReportDay } from "@/types/sales" ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Legend) +/** Phones plot only the most recent days — four lines over a long range are unreadable at that width. */ +const MOBILE_POINTS = 5 + +/** Space between the legend (the coloured dots) and the plot. Chart.js has no option for it, so the + * legend box is made taller after it measures itself — the extra height sits below its items. */ +const LEGEND_GAP_PX = 16 +const legendGap: Plugin<"line"> = { + id: "legendGap", + beforeInit(chart) { + const legend = chart.legend as (typeof chart.legend & { fit: () => void }) | undefined + if (!legend) return + const fit = legend.fit.bind(legend) + legend.fit = function () { + fit() + this.height += LEGEND_GAP_PX + } + }, +} + +/** An en space (U+2002) put before each legend label — widens the fixed dot-to-text gap. */ +const LEGEND_TEXT_GAP = String.fromCharCode(0x2002) + +/** Short axis amounts for phones: 1,250,000 → 1.3M, 45,000 → 45K. Tooltips keep the full figure. */ +const compactAmount = (v: number) => + new Intl.NumberFormat(undefined, { notation: "compact", maximumFractionDigits: 1 }).format(v) + // Categorical slots 1-4 of the validated chart palette, stepped per mode (not auto-flipped). const SERIES = { light: { income: "#2a78d6", sales: "#eb6834", expenses: "#1baf7a", profit: "#eda100", grid: "rgba(0,0,0,0.08)", text: "#5f5e58" }, @@ -17,9 +44,12 @@ const SERIES = { /** Day-wise income and profit/loss lines (with sales and expenses beside them) — one shared amount axis, a shared * index tooltip as the crosshair, legend on top. The day-wise table below the chart is its table view. */ -export function DayEndIncomeChart({ days }: { days: DayEndReportDay[] }) { +export function DayEndIncomeChart({ days: allDays }: { days: DayEndReportDay[] }) { const { resolvedTheme } = useTheme() + const isMobile = useIsMobile() const c = resolvedTheme === "dark" ? SERIES.dark : SERIES.light + const days = isMobile ? allDays.slice(-MOBILE_POINTS) : allDays + const trimmed = days.length < allDays.length const labels = days.map((d) => formatReportDate(d.date)) const line = (label: string, data: number[], color: string, width: number) => ({ @@ -27,50 +57,84 @@ export function DayEndIncomeChart({ days }: { days: DayEndReportDay[] }) { data, borderColor: color, backgroundColor: color, - borderWidth: width, + borderWidth: isMobile ? Math.max(1.5, width - 0.5) : width, pointRadius: days.length > 45 ? 0 : 3, pointHoverRadius: 5, tension: 0.25, fill: false, }) + const tickFont = { size: isMobile ? 10 : 12 } + return ( -
- d.income), c.income, 2.5), - line("Sales", days.map((d) => d.totalSales), c.sales, 2), - line("Expenses", days.map((d) => d.expenseTotal), c.expenses, 2), - line("Profit / loss", days.map((d) => d.netProfit), c.profit, 2.5), - ], - }} - options={{ - responsive: true, - maintainAspectRatio: false, - interaction: { mode: "index", intersect: false }, - plugins: { - legend: { position: "top", align: "start", labels: { color: c.text, usePointStyle: true, boxWidth: 8 } }, - tooltip: { - callbacks: { - label: (ctx) => { - const y = ctx.parsed.y ?? 0 - if (ctx.dataset.label === "Income") - return y < 0 ? `Income: ${formatSignedAmount(y)} (loss)` : `Income: ${formatSignedAmount(y)}` - if (ctx.dataset.label !== "Profit / loss") return `${ctx.dataset.label}: ${formatAmount(y)}` - const p = profitOrLoss(y) - return `${p.label}: ${p.amount}` +
+ {trimmed && ( +

+ Showing the last {days.length} days — the table below lists every day. +

+ )} +
+ d.income), c.income, 2.5), + line("Sales", days.map((d) => d.totalSales), c.sales, 2), + line("Expenses", days.map((d) => d.expenseTotal), c.expenses, 2), + line("Profit / loss", days.map((d) => d.netProfit), c.profit, 2.5), + ], + }} + options={{ + responsive: true, + maintainAspectRatio: false, + interaction: { mode: "index", intersect: false }, + plugins: { + // Legend on top (start-aligned on desktop, centred on phones) with small labels on phones. + legend: { + position: "top", + align: isMobile ? "center" : "start", + labels: { + color: c.text, + usePointStyle: true, + boxWidth: isMobile ? 6 : 8, + font: { size: isMobile ? 11 : 12 }, + // Chart.js fixes the dot-to-text gap at half the font size; a leading en space widens it. + generateLabels: (chart) => + ChartJS.defaults.plugins.legend.labels.generateLabels(chart).map((item) => ({ ...item, text: `${LEGEND_TEXT_GAP}${item.text}` })), + }, + }, + tooltip: { + callbacks: { + label: (ctx) => { + const y = ctx.parsed.y ?? 0 + if (ctx.dataset.label === "Income") + return y < 0 ? `Income: ${formatSignedAmount(y)} (loss)` : `Income: ${formatSignedAmount(y)}` + if (ctx.dataset.label !== "Profit / loss") return `${ctx.dataset.label}: ${formatAmount(y)}` + const p = profitOrLoss(y) + return `${p.label}: ${p.amount}` + }, }, }, }, - }, - scales: { - x: { grid: { display: false }, ticks: { color: c.text, maxRotation: 0, autoSkip: true, maxTicksLimit: 10 } }, - y: { grid: { color: c.grid }, ticks: { color: c.text, callback: (v) => formatAmount(Number(v)) } }, - }, - }} - /> + scales: { + x: { + grid: { display: false }, + ticks: { color: c.text, maxRotation: 0, autoSkip: true, maxTicksLimit: isMobile ? MOBILE_POINTS : 10, font: tickFont }, + }, + y: { + grid: { color: c.grid }, + ticks: { + color: c.text, + font: tickFont, + maxTicksLimit: isMobile ? 5 : 8, + callback: (v) => (isMobile ? compactAmount(Number(v)) : formatAmount(Number(v))), + }, + }, + }, + }} + /> +
) } diff --git a/Frontend/erp-system/components/sales/LineTrackingPicker.tsx b/Frontend/erp-system/components/sales/LineTrackingPicker.tsx index 43ac207..0a9c633 100644 --- a/Frontend/erp-system/components/sales/LineTrackingPicker.tsx +++ b/Frontend/erp-system/components/sales/LineTrackingPicker.tsx @@ -147,9 +147,9 @@ function SerialPicker({ className="h-7 px-2 text-xs" disabled={toAdd.length === 0} onClick={selectAll} - title={toAdd.length < unselectedShown.length ? `Only ${units} serial${units === 1 ? "" : "s"} needed for this line` : undefined} + title={toAdd.length < unselectedShown.length ? `Ticks up to the ${units} serial${units === 1 ? "" : "s"} this line needs` : undefined} > - {toAdd.length > 0 && toAdd.length < unselectedShown.length ? `Select first ${toAdd.length}` : "Select all"} + Select all
diff --git a/Frontend/erp-system/components/sales/PaymentLinesEditor.tsx b/Frontend/erp-system/components/sales/PaymentLinesEditor.tsx index dc43307..81e062e 100644 --- a/Frontend/erp-system/components/sales/PaymentLinesEditor.tsx +++ b/Frontend/erp-system/components/sales/PaymentLinesEditor.tsx @@ -63,7 +63,10 @@ interface PaymentLinesEditorProps { export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors, methodOptions, methodForAccount }: PaymentLinesEditorProps) { const paid = paymentLinesTotal(lines) const remaining = round2(balance - paid) - const cols = methodOptions ? "grid-cols-[1fr_9rem_8rem_8rem]" : "grid-cols-[1fr_8rem_8rem]" + // Phones: each line is a stacked card (type on top, method + amount side by side). From `sm` up + // it becomes one row in a fixed-column grid. Full class strings so Tailwind can see them. + const cols = methodOptions ? "sm:grid-cols-[1fr_9rem_8rem_8rem]" : "sm:grid-cols-[1fr_8rem_8rem]" + const mobileLabel = "text-xs font-medium text-muted-foreground sm:hidden" function updateLine(key: number, patch: Partial) { onChange(lines.map((l) => (l.key === key ? { ...l, ...patch } : l))) @@ -83,7 +86,7 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors, return (
-
+
Payment type {methodOptions && Method} Paid amount @@ -91,11 +94,15 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
{lines.map((line, idx) => ( -
-
+
+
+ Payment type {lines.length > 1 ? idx + 1 : ""} value={line.glBankAccountId} onValueChange={(v) => selectAccount(line.key, v ?? "")}> @@ -112,9 +119,10 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
{methodOptions && ( -
+
+ Method value={line.method ?? ""} onValueChange={(v) => updateLine(line.key, { method: v ?? undefined })}> - + @@ -127,20 +135,22 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors,
)} -
+
+ Paid amount updateLine(line.key, { amount: e.target.value })} aria-label={`Paid amount, line ${idx + 1}`} aria-invalid={!!errors[`amount-${line.key}`]} - className="text-right tabular-nums" + className="h-11 text-right text-base tabular-nums sm:h-9" />
-
+
{/* Add sits on the last line itself, so a new split appears right below it. */} {idx === lines.length - 1 && ( - @@ -163,7 +174,7 @@ export function PaymentLinesEditor({ lines, onChange, accounts, balance, errors, ))} {/* Total is what's owed (fixed); Paid is what the lines add up to. */} -
+
Total {formatAmount(balance)} Paid amount diff --git a/Frontend/erp-system/components/sales/SalesInvoiceBulkPaymentDialog.tsx b/Frontend/erp-system/components/sales/SalesInvoiceBulkPaymentDialog.tsx index 8f0fabf..acda320 100644 --- a/Frontend/erp-system/components/sales/SalesInvoiceBulkPaymentDialog.tsx +++ b/Frontend/erp-system/components/sales/SalesInvoiceBulkPaymentDialog.tsx @@ -140,13 +140,16 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on return ( - + - + Pay {ordered.length} invoices — {customerName} + {/* Header and footer stay put; only the form scrolls, so Pay is always reachable on a phone. */} +
+ {draftCount > 0 && (

{draftCount} of these invoice{draftCount === 1 ? " is" : "s are"} still a draft — submitting will confirm{" "} @@ -154,12 +157,12 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on

)} -
+
- + @@ -167,8 +170,8 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on {allocation.map(({ inv, applied }) => ( - - + + @@ -220,11 +223,13 @@ export function SalesInvoiceBulkPaymentDialog({ invoices, open, onOpenChange, on /> + + - - diff --git a/Frontend/erp-system/components/sales/SalesInvoicePaymentDialog.tsx b/Frontend/erp-system/components/sales/SalesInvoicePaymentDialog.tsx index 7ca923b..c68904f 100644 --- a/Frontend/erp-system/components/sales/SalesInvoicePaymentDialog.tsx +++ b/Frontend/erp-system/components/sales/SalesInvoicePaymentDialog.tsx @@ -145,11 +145,14 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid return ( - + - {isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.invoiceNo} + {isDraft ? "Confirm & pay" : "Pay"} invoice {invoice.invoiceNo} + {/* Header and footer stay put; only the form scrolls, so Pay is always reachable on a phone. */} +
+

{isDraft && "This invoice is still a draft — submitting will confirm it, then record the payment. "} Balance due: {formatAmount(balance)} @@ -198,11 +201,13 @@ export function SalesInvoicePaymentDialog({ invoice, open, onOpenChange, onPaid /> +

+ - - diff --git a/Frontend/erp-system/components/ui/kpi-row.tsx b/Frontend/erp-system/components/ui/kpi-row.tsx index 9c10883..ee5779f 100644 --- a/Frontend/erp-system/components/ui/kpi-row.tsx +++ b/Frontend/erp-system/components/ui/kpi-row.tsx @@ -22,7 +22,7 @@ export function KpiRow({ tiles }: { tiles: KpiTileSpec[] | null }) { ? tiles.map((tile) => ( )) - : Array.from({ length: 2 }).map((_, i) => )} + : Array.from({ length: 2 }).map((_, i) => )} ) } diff --git a/Frontend/erp-system/components/ui/stat-card.tsx b/Frontend/erp-system/components/ui/stat-card.tsx index 118052e..f116eb5 100644 --- a/Frontend/erp-system/components/ui/stat-card.tsx +++ b/Frontend/erp-system/components/ui/stat-card.tsx @@ -46,63 +46,86 @@ export interface StatCardProps { label: string value: string | number icon?: LucideIcon + /** Small muted line under the value, e.g. "All time" or "3 with balance due". */ + hint?: string delta?: number deltaLabel?: string /** Set true for metrics where an increase is bad (e.g. churn, costs). */ invert?: boolean trend?: number[] className?: string + /** Extra classes for the value, e.g. a success/destructive colour. */ + valueClassName?: string + /** Extra classes for the icon tile, e.g. a warning tint instead of the primary one. */ + iconClassName?: string } +/** + * The app's KPI tile: a tinted icon square on the left, then label / bold value / optional + * hint stacked beside it. Every list page's stat row uses this so they all read the same. + */ export function StatCard({ label, value, icon: Icon, + hint, delta, deltaLabel, invert = false, trend, className, + valueClassName, + iconClassName, }: StatCardProps) { const hasDelta = typeof delta === "number" && delta !== 0 const isPositive = hasDelta && delta > 0 const isGood = hasDelta && (invert ? !isPositive : isPositive) return ( + // A size container: the card reads its *own* width, so it lays out the same whether a page puts + // it in a 2-up phone grid or a wide desktop row.
-
-

{label}

+ {/* Wide enough: icon beside the text. Narrow (e.g. two per row on a phone): icon above, so the + value keeps the full width instead of being cut off. */} +
{Icon && ( -
- -
- )} -
- -
-

{formatValue(value)}

- {trend && trend.length > 1 && } -
- - {hasDelta && ( -
- - {isPositive ? "+" : "-"} - {Math.abs(delta).toFixed(1)}% - - {deltaLabel && {deltaLabel}} + +
+ )} + +
+

{label}

+
+

+ {formatValue(value)} +

+ {trend && trend.length > 1 && } +
+ + {hasDelta ? ( +
+ + {isPositive ? "+" : "-"} + {Math.abs(delta).toFixed(1)}% + + {deltaLabel && {deltaLabel}} +
+ ) : ( + hint &&

{hint}

+ )}
- )} +
) } diff --git a/Frontend/erp-system/hooks/use-client-pagination.ts b/Frontend/erp-system/hooks/use-client-pagination.ts new file mode 100644 index 0000000..eb4734a --- /dev/null +++ b/Frontend/erp-system/hooks/use-client-pagination.ts @@ -0,0 +1,36 @@ +import { useState } from "react" + +import { PaginationMeta } from "@/types/common" + +/** + * Pages an already-loaded list in the browser, producing the same `PaginationMeta` shape the + * server-paged lists use so it plugs straight into `PaginationFooter`. + * + * `resetKey` (e.g. the selected date) sends the list back to page 1 when it changes. It's a key + * rather than the array itself because callers often pass `data ?? []`, a new array every render. + */ +export function useClientPagination(rows: T[], resetKey: unknown, initialPageSize = 10) { + const [page, setPage] = useState(1) + const [pageSize, setPageSizeState] = useState(initialPageSize) + const [prevKey, setPrevKey] = useState(resetKey) + + // Reset during render (React's "adjust state when a prop changes" pattern) — no effect needed. + if (!Object.is(prevKey, resetKey)) { + setPrevKey(resetKey) + setPage(1) + } + + const totalItems = rows.length + const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)) + // Clamped, so shrinking the list (or a bigger page size) never leaves an empty page showing. + const current = Math.min(page, totalPages) + const pageRows = rows.slice((current - 1) * pageSize, current * pageSize) + const pagination: PaginationMeta = { page: current, pageSize, totalItems, totalPages } + + function setPageSize(size: number) { + setPageSizeState(size) + setPage(1) + } + + return { pageRows, pagination, setPage, setPageSize } +} diff --git a/Frontend/erp-system/lib/api/sales.ts b/Frontend/erp-system/lib/api/sales.ts index 70b35e8..c53240d 100644 --- a/Frontend/erp-system/lib/api/sales.ts +++ b/Frontend/erp-system/lib/api/sales.ts @@ -9,6 +9,7 @@ import { SalesInvoiceStatus, SalesInvoicePayment, SalesInvoicePostingCheck, + SalesInvoiceListTotals, SalesInvoiceSummary, SalesListFilterParams, SalesReportDefinition, @@ -56,6 +57,11 @@ export const salesApi = { return apiRequest>(`/sales-invoices${buildQuery(params)}`) }, + /** Count/amount totals for the same filters as `listInvoices`, across every match (not one page). */ + invoiceTotals(params: Omit = {}): Promise { + return apiRequest(`/sales-invoices/totals${buildQuery(params)}`) + }, + /** Unit price a line would get on save when left blank (flat price, else GRN/FIFO average). */ getSuggestedPrice(itemId: number, warehouseId: number): Promise { return apiRequest(`/sales-invoices/suggested-price${buildQuery({ itemId, warehouseId })}`) diff --git a/Frontend/erp-system/lib/serial-ranges.ts b/Frontend/erp-system/lib/serial-ranges.ts new file mode 100644 index 0000000..183b59f --- /dev/null +++ b/Frontend/erp-system/lib/serial-ranges.ts @@ -0,0 +1,59 @@ +/** + * Collapses serial / warranty numbers into consecutive runs for display: + * SN-001, SN-002 … SN-050, SN-077 → "SN-001 – SN-050" (50) and "SN-077" (1). + * + * Uses the same "prefix + trailing number" reading as the GRN form's range builder, so a run + * entered there as From/adding comes back out as exactly one range here. + */ +export type SerialRange = { from: string; to: string; count: number } + +function parseTrailingNumber(s: string): { prefix: string; num: number; width: number } | null { + const m = s.match(/^(.*?)(\d+)$/) + if (!m) return null + return { prefix: m[1], num: parseInt(m[2], 10), width: m[2].length } +} + +/** Natural order: by prefix, then by the trailing number (so SN-9 sorts before SN-10). */ +function compareSerials(a: string, b: string): number { + const pa = parseTrailingNumber(a) + const pb = parseTrailingNumber(b) + if (pa && pb && pa.prefix === pb.prefix) return pa.num - pb.num + return a.localeCompare(b, undefined, { numeric: true }) +} + +export function toSerialRanges(values: string[]): SerialRange[] { + const sorted = [...new Set(values.map((v) => v.trim()).filter(Boolean))].sort(compareSerials) + const ranges: SerialRange[] = [] + let run: { start: string; prefix: string; width: number; last: number; lastText: string; count: number } | null = null + + for (const value of sorted) { + const parsed = parseTrailingNumber(value) + // Consecutive = same prefix, next number, written with the run's zero-padding. + const continues = + run !== null && + parsed !== null && + parsed.prefix === run.prefix && + parsed.num === run.last + 1 && + value === `${run.prefix}${String(parsed.num).padStart(run.width, "0")}` + + if (continues && run && parsed) { + run.last = parsed.num + run.lastText = value + run.count += 1 + continue + } + if (run) ranges.push({ from: run.start, to: run.lastText, count: run.count }) + run = parsed + ? { start: value, prefix: parsed.prefix, width: parsed.width, last: parsed.num, lastText: value, count: 1 } + : { start: value, prefix: `\u0000${value}`, width: 0, last: Number.NaN, lastText: value, count: 1 } + } + if (run) ranges.push({ from: run.start, to: run.lastText, count: run.count }) + return ranges +} + +/** One-line summary, e.g. "SN-001 – SN-050" or "SN-001 – SN-050, SN-077 +2 more". */ +export function formatSerialRanges(ranges: SerialRange[], max = 2): string { + const label = (r: SerialRange) => (r.count === 1 ? r.from : `${r.from} – ${r.to}`) + const shown = ranges.slice(0, max).map(label).join(", ") + return ranges.length > max ? `${shown} +${ranges.length - max} more` : shown +} diff --git a/Frontend/erp-system/lib/validations/phone.ts b/Frontend/erp-system/lib/validations/phone.ts new file mode 100644 index 0000000..1d0f500 --- /dev/null +++ b/Frontend/erp-system/lib/validations/phone.ts @@ -0,0 +1,56 @@ +/** + * Sri Lankan phone number rule shared by master-data forms (warehouses, …). Mirrors the + * backend's `PhoneRules.Pattern` so the client rejects exactly what the API would. + * + * Accepted: 10 digits starting with 0 (0771234567, 011 234 5678) or +94 followed by 9 digits + * (+94771234567, +94 77 123 4567). Spaces and hyphens are allowed between digits. + */ +export const PHONE_PATTERN = /^(?:0(?:[\s-]*\d){9}|\+94(?:[\s-]*\d){9})[\s-]*$/ + +/** Digit count allowed after the prefix's own digits, per format. */ +const LOCAL_DIGITS = 10 // 0 + 9 +const INTL_DIGITS = 11 // 94 + 9 + +const PHONE_EXAMPLE = "e.g. 0771234567 or +94 77 123 4567" + +/** + * Input filter for a phone field's onChange: drops disallowed characters and stops accepting + * digits once the number is full, so the limit is enforced while typing rather than on save. + */ +export function limitPhoneInput(raw: string): string { + let out = "" + let digits = 0 + for (const ch of raw) { + if (ch === "+") { + if (out.length === 0) out += ch // "+" only as the very first character + continue + } + if (ch === " " || ch === "-") { + out += ch + continue + } + if (!/\d/.test(ch)) continue + const max = out.startsWith("+") ? INTL_DIGITS : LOCAL_DIGITS + if (digits >= max) continue + out += ch + digits += 1 + } + return out +} + +/** Error message for an invalid phone, or null when valid. Blank is valid unless `required`. */ +export function phoneError(value: string, { required = false }: { required?: boolean } = {}): string | null { + const phone = value.trim() + if (!phone) return required ? "Enter a phone number" : null + if (/[^\d\s\-+]/.test(phone)) return "Phone number can only contain digits, spaces, - and a leading +" + const digits = phone.replace(/\D/g, "") + if (phone.startsWith("+")) { + if (!digits.startsWith("94")) return `International numbers must start with +94 — ${PHONE_EXAMPLE}` + if (digits.length !== INTL_DIGITS) return `+94 numbers need exactly 9 digits after +94 (${digits.length - 2} entered)` + } else { + if (!digits.startsWith("0")) return `Phone number must start with 0 or +94 — ${PHONE_EXAMPLE}` + if (digits.length !== LOCAL_DIGITS) return `Phone number must be exactly 10 digits (${digits.length} entered)` + } + if (!PHONE_PATTERN.test(phone)) return `Enter a valid phone number, ${PHONE_EXAMPLE}` + return null +} diff --git a/Frontend/erp-system/types/sales.ts b/Frontend/erp-system/types/sales.ts index 006e8fb..fc150ef 100644 --- a/Frontend/erp-system/types/sales.ts +++ b/Frontend/erp-system/types/sales.ts @@ -41,6 +41,16 @@ export interface SalesInvoiceTotals { balanceAmount: number } +/** Totals over every invoice matching the list filters (GET /sales-invoices/totals), not one page. */ +export interface SalesInvoiceListTotals { + count: number + netTotal: number + paidTotal: number + balanceTotal: number + unpaidCount: number + overdueCount: number +} + export interface SalesInvoiceSummary { salesInvoiceId: number invoiceNo: string
InvoiceDateDate Balance Applied
{inv.invoiceNo}{new Date(inv.invoiceDate).toLocaleDateString()}{inv.invoiceNo}{new Date(inv.invoiceDate).toLocaleDateString()} {formatAmount(inv.totals.balanceAmount)} {applied > 0 ? formatAmount(applied) : "—"}