Compare commits

...

7 Commits

Author SHA1 Message Date
HarithaRandunu a271d1832e fix: resolve repo-wide bug with missing HRM and Manufacturing tables in EF model
- Identified and fixed a critical issue where 36 tables existed in the EF model but were absent in the actual database.
- Root cause traced to a `.gitignore` rule that prevented migration files from being tracked, leading to discrepancies between the model snapshot and the database.
- Removed problematic entities from the snapshot, generated a migration to add the missing tables, and confirmed successful application.
- Reverted the `.gitignore` rule to ensure future migrations are tracked properly.
- No changes to existing tables, ensuring no collateral schema drift occurred.
2026-07-31 18:57:32 +05:30
HarithaRandunu 22657f0910 feat: Implement new General Ledger frontend section with comprehensive report screens and cash/bank account management
- Added a new Ledgers sidebar section for statutory-format financial reports and cash/bank-account management.
- Introduced dedicated GL client for API interactions, handling response envelopes and error management.
- Developed report screens for Trial Balance, Balance Sheet, General Ledger, Profit & Loss, Cash Flow, Budget vs Actual, and a new Tax Report.
- Implemented CSV download functionality alongside existing PDF downloads for all report screens.
- Separated Cash and Bank accounts into distinct tables/endpoints, with updated create forms and unified list view.
- Created a new Accounts section for Cheque Management, moving Cash/Bank Accounts from the Ledgers section.
- Updated RBAC navigation to include new permissions and sub-navigation items for the added features.
- Ensured compliance with GL's updated API contract, including renaming fields and adjusting response shapes.
- Addressed various bugs and presentation issues, enhancing user experience across the new module.
2026-07-31 18:02:03 +05:30
ImanThiyanga 76484c7268 Merge pull request 'Refactor: Remove unused imports and clean up code across multiple pages' (#21) from fix/frontend-changers into Dev
Reviewed-on: #21
2026-07-31 05:42:56 +00:00
Sasanka 96f81cb03a Refactor: Remove unused imports and clean up code across multiple pages
- Removed unused Link imports and associated code for navigation in various pages.
- Simplified TableHeader components by removing unnecessary class names.
- Updated alert dialog styles for better consistency with the design system.
- Cleaned up imports in several components to streamline the codebase.
2026-07-31 11:04:32 +05:30
ImanThiyanga cb3f1e5cde Merge branch 'production' into Dev 2026-07-31 05:24:32 +00:00
ImanThiyanga c3fef88bcf Merge pull request 'feat: add production templates API and documentation for manufacturing phase 2' (#19) from feat/production-integration into Dev
Reviewed-on: #19
2026-07-31 04:54:00 +00:00
ImanThiyanga 8484601494 Merge pull request 'Dev' (#13) from Dev into production
Reviewed-on: #13
2026-07-22 06:12:34 +00:00
98 changed files with 36806 additions and 236 deletions
+8 -5
View File
@@ -31,8 +31,11 @@ Thumbs.db
.idea/
# ── Migrations ─────────────────────────────────────────────────────────
# New EF Core migrations are not committed. Note the 4 migrations already in
# Backend/ERPCore/Infra/Persistence/Migrations/ stay tracked — .gitignore does
# not apply to tracked files — so edits to those still get committed as normal.
# Untracking them too takes `git rm --cached`.
**/Migrations/
# Reverted 2026-07-31: excluding new EF Core migrations while
# ErpDbContextModelSnapshot.cs stayed tracked meant every `dotnet ef
# migrations add` after the initial 4 silently produced a migration git would
# never see, while the (tracked) snapshot's changes committed normally —
# so the snapshot kept claiming tables existed that no migration in git
# history ever created them. Confirmed live: 25 HRM tables + 11 Manufacturing
# tables were missing from the actual database for exactly this reason.
# Migrations now stay tracked like any other source file — commit them.
@@ -0,0 +1,43 @@
using ERPCore.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace ERPCore.Controllers;
/// <summary>
/// Generic reverse proxy into the external General Ledger service — forwards every
/// method/path/query/body under this prefix verbatim via
/// <see cref="IGeneralLedgerService"/> and returns GL's response (status, content-type,
/// body) unchanged. No endpoint-specific shape lives here; see
/// docs/12-GENERAL-LEDGER-INTEGRATION.md for the full GL contract and what this proxy
/// does and doesn't do. Gated by the same ERP door policy as every other v1 endpoint
/// (<see cref="ApiControllerBase"/>) — the shared GL API key is attached server-side
/// only and is never exposed to the frontend.
/// </summary>
[Route("api/v1/gl")]
public sealed class GeneralLedgerController : ApiControllerBase
{
private readonly IGeneralLedgerService _gl;
public GeneralLedgerController(IGeneralLedgerService gl) => _gl = gl;
[HttpGet("{**path}")]
public Task<IActionResult> Get(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Get, path, ct);
[HttpPost("{**path}")]
public Task<IActionResult> Post(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Post, path, ct);
[HttpPut("{**path}")]
public Task<IActionResult> Put(string path, CancellationToken ct) => ForwardAsync(HttpMethod.Put, path, ct);
private async Task<IActionResult> ForwardAsync(HttpMethod method, string path, CancellationToken ct)
{
var body = method == HttpMethod.Get ? null : Request.Body;
var result = await _gl.ForwardAsync(method, path, Request.QueryString.Value, Request.ContentType, body, ct);
return new ContentResult
{
StatusCode = result.StatusCode,
Content = result.Body,
ContentType = result.ContentType ?? "application/json"
};
}
}
@@ -0,0 +1,55 @@
using ERPCore.System.Errors;
namespace ERPCore.Infra.Gl;
/// <summary>
/// HTTP implementation of <see cref="IGeneralLedgerClient"/>. Registered as a typed
/// client (`AddHttpClient&lt;IGeneralLedgerClient, GeneralLedgerClient&gt;`) with its
/// `BaseAddress` bound from `GeneralLedgerService:BaseUrl`. Every call attaches the
/// shared `GeneralLedgerService:ApiKey` as `X-Api-Key` and streams the request/response
/// body straight through, unparsed — GL's own response (status, content-type, body) is
/// returned exactly as received; nothing here reshapes it.
/// </summary>
public sealed class GeneralLedgerClient(HttpClient http, IConfiguration configuration) : IGeneralLedgerClient
{
private readonly HttpClient _http = http;
private readonly string _apiKey = configuration["GeneralLedgerService:ApiKey"] ?? string.Empty;
public async Task<GeneralLedgerResponse> SendAsync(
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
{
var relativeUri = path.TrimStart('/') + queryString;
using var request = new HttpRequestMessage(method, relativeUri);
request.Headers.TryAddWithoutValidation("X-Api-Key", _apiKey);
if (body is not null && method != HttpMethod.Get)
{
var content = new StreamContent(body);
if (!string.IsNullOrEmpty(contentType))
content.Headers.TryAddWithoutValidation("Content-Type", contentType);
request.Content = content;
}
HttpResponseMessage response;
try
{
response = await _http.SendAsync(request, ct);
}
catch (HttpRequestException)
{
throw new DomainException(ErrorCodes.GlServiceUnavailable, "The General Ledger service is unreachable.", 503);
}
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
{
throw new DomainException(ErrorCodes.GlServiceUnavailable, "The General Ledger service timed out.", 503);
}
var responseBody = await response.Content.ReadAsStringAsync(ct);
return new GeneralLedgerResponse
{
StatusCode = (int)response.StatusCode,
ContentType = response.Content.Headers.ContentType?.ToString(),
Body = responseBody
};
}
}
@@ -0,0 +1,15 @@
namespace ERPCore.Infra.Gl;
/// <summary>
/// Raw HTTP result from the external General Ledger service — status code, content
/// type, and body exactly as GL returned them. Deliberately un-reshaped: GL's own
/// envelope (see the GL service's own API reference) is passed through byte-for-byte
/// so its camelCase-success/PascalCase-error inconsistency and full decimal precision
/// survive the hop unchanged (docs/12-GENERAL-LEDGER-INTEGRATION.md).
/// </summary>
public sealed class GeneralLedgerResponse
{
public int StatusCode { get; init; }
public string? ContentType { get; init; }
public string Body { get; init; } = string.Empty;
}
@@ -0,0 +1,13 @@
namespace ERPCore.Infra.Gl;
/// <summary>
/// Typed HTTP transport to the external General Ledger service. Injects the shared
/// `X-Api-Key` secret and forwards method/path/query/body/content-type verbatim —
/// see docs/12-GENERAL-LEDGER-INTEGRATION.md. Internal: only
/// <see cref="ERPCore.Services.Interfaces.IGeneralLedgerService"/> consumes this.
/// </summary>
public interface IGeneralLedgerClient
{
Task<GeneralLedgerResponse> SendAsync(
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct);
}
@@ -36,7 +36,9 @@ public sealed class NavItemConfiguration : IEntityTypeConfiguration<NavItem>
new NavItem { NavItemId = 7, Code = "warehouses", Label = "Warehouses", Href = "/dashboard/warehouse", SortOrder = 7 },
new NavItem { NavItemId = 8, Code = "orders", Label = "Orders", Href = "/dashboard/orders", SortOrder = 8 },
new NavItem { NavItemId = 9, Code = "settings", Label = "Settings", Href = "/dashboard/settings", SortOrder = 9 },
new NavItem { NavItemId = 10, Code = "help", Label = "Help", Href = "/dashboard/help", SortOrder = 10 }
new NavItem { NavItemId = 10, Code = "help", Label = "Help", Href = "/dashboard/help", SortOrder = 10 },
new NavItem { NavItemId = 11, Code = "ledgers", Label = "Ledgers", Href = "/dashboard/ledgers", SortOrder = 11 },
new NavItem { NavItemId = 12, Code = "accounts", Label = "Accounts", Href = "/dashboard/accounts", SortOrder = 12 }
);
}
}
@@ -42,10 +42,26 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
new Permission { PermissionId = 16, Code = "NAV:products.configuration", SubNavItemId = 6 },
new Permission { PermissionId = 17, Code = "NAV:settings.roles", SubNavItemId = 7 },
new Permission { PermissionId = 18, Code = "NAV:settings.users", SubNavItemId = 8 },
new Permission { PermissionId = 19, Code = "NAV:procurement.requisitions", SubNavItemId = 9 },
new Permission { PermissionId = 20, Code = "NAV:procurement.rfqs", SubNavItemId = 10 },
new Permission { PermissionId = 21, Code = "NAV:procurement.purchase-orders", SubNavItemId = 11 },
new Permission { PermissionId = 22, Code = "NAV:procurement.purchase-returns", SubNavItemId = 12 }
// IDs 28-31 (not 19-22): 19-22 were already claimed by the Ledgers permissions below;
// these procurement rows were never actually migrated into the database before now.
new Permission { PermissionId = 28, Code = "NAV:procurement.requisitions", SubNavItemId = 17 },
new Permission { PermissionId = 29, Code = "NAV:procurement.rfqs", SubNavItemId = 18 },
new Permission { PermissionId = 30, Code = "NAV:procurement.purchase-orders", SubNavItemId = 19 },
new Permission { PermissionId = 31, Code = "NAV:procurement.purchase-returns", SubNavItemId = 20 },
new Permission { PermissionId = 19, Code = "NAV:ledgers", NavItemId = 11 },
new Permission { PermissionId = 20, Code = "NAV:ledgers.trial-balance", SubNavItemId = 9 },
new Permission { PermissionId = 21, Code = "NAV:ledgers.balance-sheet", SubNavItemId = 10 },
new Permission { PermissionId = 22, Code = "NAV:ledgers.general-ledger", SubNavItemId = 11 },
new Permission { PermissionId = 23, Code = "NAV:ledgers.profit-and-loss", SubNavItemId = 12 },
new Permission { PermissionId = 24, Code = "NAV:ledgers.cash-flow", SubNavItemId = 13 },
new Permission { PermissionId = 25, Code = "NAV:ledgers.budget-vs-actual", SubNavItemId = 14 },
new Permission { PermissionId = 27, Code = "NAV:ledgers.tax-report", SubNavItemId = 16 },
// Moved under the new Accounts nav item (2026-07-31) — same PermissionId (26), just a
// renamed Code, so any role already granted this permission keeps it.
new Permission { PermissionId = 26, Code = "NAV:accounts.bank-accounts", SubNavItemId = 15 },
new Permission { PermissionId = 32, Code = "NAV:accounts", NavItemId = 12 },
new Permission { PermissionId = 33, Code = "NAV:accounts.cheque-books", SubNavItemId = 21 },
new Permission { PermissionId = 34, Code = "NAV:accounts.received-cheques", SubNavItemId = 22 }
);
}
}
@@ -34,10 +34,25 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
new SubNavItem { SubNavItemId = 7, NavItemId = 9, Code = "settings.roles", Label = "Roles", Href = "/dashboard/settings/roles", SortOrder = 1 },
new SubNavItem { SubNavItemId = 8, NavItemId = 9, Code = "settings.users", Label = "Users", Href = "/dashboard/settings/users", SortOrder = 2 },
// Procurement (NavItemId 4) children — mirror the hub page order.
new SubNavItem { SubNavItemId = 9, NavItemId = 4, Code = "procurement.requisitions", Label = "Requisitions", Href = "/dashboard/procurement/requisitions", SortOrder = 1 },
new SubNavItem { SubNavItemId = 10, NavItemId = 4, Code = "procurement.rfqs", Label = "RFQs", Href = "/dashboard/procurement/rfqs", SortOrder = 2 },
new SubNavItem { SubNavItemId = 11, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 },
new SubNavItem { SubNavItemId = 12, NavItemId = 4, Code = "procurement.purchase-returns", Label = "Purchase Returns", Href = "/dashboard/procurement/purchase-returns", SortOrder = 4 }
// IDs 17-20 (not 9-12): 9-12 were already claimed by the Ledgers sub-items below;
// these procurement rows were never actually migrated into the database before now.
new SubNavItem { SubNavItemId = 17, NavItemId = 4, Code = "procurement.requisitions", Label = "Requisitions", Href = "/dashboard/procurement/requisitions", SortOrder = 1 },
new SubNavItem { SubNavItemId = 18, NavItemId = 4, Code = "procurement.rfqs", Label = "RFQs", Href = "/dashboard/procurement/rfqs", SortOrder = 2 },
new SubNavItem { SubNavItemId = 19, NavItemId = 4, Code = "procurement.purchase-orders", Label = "Purchase Orders", Href = "/dashboard/procurement/purchase-orders", SortOrder = 3 },
new SubNavItem { SubNavItemId = 20, NavItemId = 4, Code = "procurement.purchase-returns", Label = "Purchase Returns", Href = "/dashboard/procurement/purchase-returns", SortOrder = 4 },
new SubNavItem { SubNavItemId = 9, NavItemId = 11, Code = "ledgers.trial-balance", Label = "Trial Balance", Href = "/dashboard/ledgers/trial-balance", SortOrder = 1 },
new SubNavItem { SubNavItemId = 10, NavItemId = 11, Code = "ledgers.balance-sheet", Label = "Balance Sheet", Href = "/dashboard/ledgers/balance-sheet", SortOrder = 2 },
new SubNavItem { SubNavItemId = 11, NavItemId = 11, Code = "ledgers.general-ledger", Label = "General Ledger", Href = "/dashboard/ledgers/general-ledger", SortOrder = 3 },
new SubNavItem { SubNavItemId = 12, NavItemId = 11, Code = "ledgers.profit-and-loss", Label = "Profit & Loss", Href = "/dashboard/ledgers/profit-and-loss", SortOrder = 4 },
new SubNavItem { SubNavItemId = 13, NavItemId = 11, Code = "ledgers.cash-flow", Label = "Cash Flow", Href = "/dashboard/ledgers/cash-flow", SortOrder = 5 },
new SubNavItem { SubNavItemId = 14, NavItemId = 11, Code = "ledgers.budget-vs-actual", Label = "Budget vs Actual", Href = "/dashboard/ledgers/budget-vs-actual", SortOrder = 6 },
new SubNavItem { SubNavItemId = 16, NavItemId = 11, Code = "ledgers.tax-report", Label = "Tax Report", Href = "/dashboard/ledgers/tax-report", SortOrder = 7 },
// Moved under the new Accounts nav item (2026-07-31) — kept the same SubNavItemId (15)
// rather than delete+recreate, so any role that already had this permission granted
// doesn't silently lose it just because the section it lives under changed.
new SubNavItem { SubNavItemId = 15, NavItemId = 12, Code = "accounts.bank-accounts", Label = "Cash / Bank Accounts", Href = "/dashboard/accounts/bank-accounts", SortOrder = 1 },
new SubNavItem { SubNavItemId = 21, NavItemId = 12, Code = "accounts.cheque-books", Label = "Cheque Books", Href = "/dashboard/accounts/cheque-books", SortOrder = 2 },
new SubNavItem { SubNavItemId = 22, NavItemId = 12, Code = "accounts.received-cheques", Label = "Received Cheques", Href = "/dashboard/accounts/received-cheques", SortOrder = 3 }
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,138 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ERPCore.Infra.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddLedgersNavSeed : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "nav_items",
columns: new[] { "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder" },
values: new object[] { 11, "ledgers", "/dashboard/ledgers", null, "Ledgers", 11 });
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[] { 19, "NAV:ledgers", 11, null });
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[,]
{
{ 9, "ledgers.trial-balance", "/dashboard/ledgers/trial-balance", null, "Trial Balance", 11, 1 },
{ 10, "ledgers.balance-sheet", "/dashboard/ledgers/balance-sheet", null, "Balance Sheet", 11, 2 },
{ 11, "ledgers.general-ledger", "/dashboard/ledgers/general-ledger", null, "General Ledger", 11, 3 },
{ 12, "ledgers.profit-and-loss", "/dashboard/ledgers/profit-and-loss", null, "Profit & Loss", 11, 4 },
{ 13, "ledgers.cash-flow", "/dashboard/ledgers/cash-flow", null, "Cash Flow", 11, 5 },
{ 14, "ledgers.budget-vs-actual", "/dashboard/ledgers/budget-vs-actual", null, "Budget vs Actual", 11, 6 },
{ 15, "ledgers.bank-accounts", "/dashboard/ledgers/bank-accounts", null, "Cash / Bank Accounts", 11, 7 }
});
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[,]
{
{ 20, "NAV:ledgers.trial-balance", null, 9 },
{ 21, "NAV:ledgers.balance-sheet", null, 10 },
{ 22, "NAV:ledgers.general-ledger", null, 11 },
{ 23, "NAV:ledgers.profit-and-loss", null, 12 },
{ 24, "NAV:ledgers.cash-flow", null, 13 },
{ 25, "NAV:ledgers.budget-vs-actual", null, 14 },
{ 26, "NAV:ledgers.bank-accounts", null, 15 }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 19);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 20);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 21);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 22);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 23);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 24);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 25);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 26);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 9);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 10);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 11);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 12);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 13);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 14);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 15);
migrationBuilder.DeleteData(
table: "nav_items",
keyColumn: "NavItemId",
keyValue: 11);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Infra.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddTaxReportNavSeed : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 15,
column: "SortOrder",
value: 8);
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[] { 16, "ledgers.tax-report", "/dashboard/ledgers/tax-report", null, "Tax Report", 11, 7 });
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[] { 27, "NAV:ledgers.tax-report", null, 16 });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 27);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 16);
migrationBuilder.UpdateData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 15,
column: "SortOrder",
value: 7);
}
}
}
@@ -0,0 +1,82 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ERPCore.Infra.Persistence.Migrations
{
/// <inheritdoc />
public partial class FixProcurementNavIdCollision : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[,]
{
{ 17, "procurement.requisitions", "/dashboard/procurement/requisitions", null, "Requisitions", 4, 1 },
{ 18, "procurement.rfqs", "/dashboard/procurement/rfqs", null, "RFQs", 4, 2 },
{ 19, "procurement.purchase-orders", "/dashboard/procurement/purchase-orders", null, "Purchase Orders", 4, 3 },
{ 20, "procurement.purchase-returns", "/dashboard/procurement/purchase-returns", null, "Purchase Returns", 4, 4 }
});
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[,]
{
{ 28, "NAV:procurement.requisitions", null, 17 },
{ 29, "NAV:procurement.rfqs", null, 18 },
{ 30, "NAV:procurement.purchase-orders", null, 19 },
{ 31, "NAV:procurement.purchase-returns", null, 20 }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 28);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 29);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 30);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 31);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 17);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 18);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 19);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 20);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,106 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ERPCore.Infra.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddAccountsNavSeed : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "nav_items",
columns: new[] { "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder" },
values: new object[] { 12, "accounts", "/dashboard/accounts", null, "Accounts", 12 });
migrationBuilder.UpdateData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 26,
column: "Code",
value: "NAV:accounts.bank-accounts");
migrationBuilder.UpdateData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 15,
columns: new[] { "Code", "Href", "NavItemId", "SortOrder" },
values: new object[] { "accounts.bank-accounts", "/dashboard/accounts/bank-accounts", 12, 1 });
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[] { 32, "NAV:accounts", 12, null });
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[,]
{
{ 21, "accounts.cheque-books", "/dashboard/accounts/cheque-books", null, "Cheque Books", 12, 2 },
{ 22, "accounts.received-cheques", "/dashboard/accounts/received-cheques", null, "Received Cheques", 12, 3 }
});
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[,]
{
{ 33, "NAV:accounts.cheque-books", null, 21 },
{ 34, "NAV:accounts.received-cheques", null, 22 }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 32);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 33);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 34);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 21);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 22);
migrationBuilder.DeleteData(
table: "nav_items",
keyColumn: "NavItemId",
keyValue: 12);
migrationBuilder.UpdateData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 26,
column: "Code",
value: "NAV:ledgers.bank-accounts");
migrationBuilder.UpdateData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 15,
columns: new[] { "Code", "Href", "NavItemId", "SortOrder" },
values: new object[] { "ledgers.bank-accounts", "/dashboard/ledgers/bank-accounts", 11, 8 });
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ERPCore.Infra.Persistence.Migrations
{
/// <inheritdoc />
public partial class production : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -1832,6 +1832,24 @@ namespace ERPCore.Infra.Persistence.Migrations
Label = "Help",
SortOrder = 10,
Status = "Active"
},
new
{
NavItemId = 11,
Code = "ledgers",
Href = "/dashboard/ledgers",
Label = "Ledgers",
SortOrder = 11,
Status = "Active"
},
new
{
NavItemId = 12,
Code = "accounts",
Href = "/dashboard/accounts",
Label = "Accounts",
SortOrder = 12,
Status = "Active"
});
});
@@ -2285,27 +2303,99 @@ namespace ERPCore.Infra.Persistence.Migrations
},
new
{
PermissionId = 19,
PermissionId = 28,
Code = "NAV:procurement.requisitions",
SubNavItemId = 9
SubNavItemId = 17
},
new
{
PermissionId = 29,
Code = "NAV:procurement.rfqs",
SubNavItemId = 18
},
new
{
PermissionId = 30,
Code = "NAV:procurement.purchase-orders",
SubNavItemId = 19
},
new
{
PermissionId = 31,
Code = "NAV:procurement.purchase-returns",
SubNavItemId = 20
},
new
{
PermissionId = 19,
Code = "NAV:ledgers",
NavItemId = 11
},
new
{
PermissionId = 20,
Code = "NAV:procurement.rfqs",
SubNavItemId = 10
Code = "NAV:ledgers.trial-balance",
SubNavItemId = 9
},
new
{
PermissionId = 21,
Code = "NAV:procurement.purchase-orders",
SubNavItemId = 11
Code = "NAV:ledgers.balance-sheet",
SubNavItemId = 10
},
new
{
PermissionId = 22,
Code = "NAV:procurement.purchase-returns",
Code = "NAV:ledgers.general-ledger",
SubNavItemId = 11
},
new
{
PermissionId = 23,
Code = "NAV:ledgers.profit-and-loss",
SubNavItemId = 12
},
new
{
PermissionId = 24,
Code = "NAV:ledgers.cash-flow",
SubNavItemId = 13
},
new
{
PermissionId = 25,
Code = "NAV:ledgers.budget-vs-actual",
SubNavItemId = 14
},
new
{
PermissionId = 27,
Code = "NAV:ledgers.tax-report",
SubNavItemId = 16
},
new
{
PermissionId = 26,
Code = "NAV:accounts.bank-accounts",
SubNavItemId = 15
},
new
{
PermissionId = 32,
Code = "NAV:accounts",
NavItemId = 12
},
new
{
PermissionId = 33,
Code = "NAV:accounts.cheque-books",
SubNavItemId = 21
},
new
{
PermissionId = 34,
Code = "NAV:accounts.received-cheques",
SubNavItemId = 22
});
});
@@ -3948,7 +4038,7 @@ namespace ERPCore.Infra.Persistence.Migrations
},
new
{
SubNavItemId = 9,
SubNavItemId = 17,
Code = "procurement.requisitions",
Href = "/dashboard/procurement/requisitions",
Label = "Requisitions",
@@ -3958,7 +4048,7 @@ namespace ERPCore.Infra.Persistence.Migrations
},
new
{
SubNavItemId = 10,
SubNavItemId = 18,
Code = "procurement.rfqs",
Href = "/dashboard/procurement/rfqs",
Label = "RFQs",
@@ -3968,7 +4058,7 @@ namespace ERPCore.Infra.Persistence.Migrations
},
new
{
SubNavItemId = 11,
SubNavItemId = 19,
Code = "procurement.purchase-orders",
Href = "/dashboard/procurement/purchase-orders",
Label = "Purchase Orders",
@@ -3978,13 +4068,113 @@ namespace ERPCore.Infra.Persistence.Migrations
},
new
{
SubNavItemId = 12,
SubNavItemId = 20,
Code = "procurement.purchase-returns",
Href = "/dashboard/procurement/purchase-returns",
Label = "Purchase Returns",
NavItemId = 4,
SortOrder = 4,
Status = "Active"
},
new
{
SubNavItemId = 9,
Code = "ledgers.trial-balance",
Href = "/dashboard/ledgers/trial-balance",
Label = "Trial Balance",
NavItemId = 11,
SortOrder = 1,
Status = "Active"
},
new
{
SubNavItemId = 10,
Code = "ledgers.balance-sheet",
Href = "/dashboard/ledgers/balance-sheet",
Label = "Balance Sheet",
NavItemId = 11,
SortOrder = 2,
Status = "Active"
},
new
{
SubNavItemId = 11,
Code = "ledgers.general-ledger",
Href = "/dashboard/ledgers/general-ledger",
Label = "General Ledger",
NavItemId = 11,
SortOrder = 3,
Status = "Active"
},
new
{
SubNavItemId = 12,
Code = "ledgers.profit-and-loss",
Href = "/dashboard/ledgers/profit-and-loss",
Label = "Profit & Loss",
NavItemId = 11,
SortOrder = 4,
Status = "Active"
},
new
{
SubNavItemId = 13,
Code = "ledgers.cash-flow",
Href = "/dashboard/ledgers/cash-flow",
Label = "Cash Flow",
NavItemId = 11,
SortOrder = 5,
Status = "Active"
},
new
{
SubNavItemId = 14,
Code = "ledgers.budget-vs-actual",
Href = "/dashboard/ledgers/budget-vs-actual",
Label = "Budget vs Actual",
NavItemId = 11,
SortOrder = 6,
Status = "Active"
},
new
{
SubNavItemId = 16,
Code = "ledgers.tax-report",
Href = "/dashboard/ledgers/tax-report",
Label = "Tax Report",
NavItemId = 11,
SortOrder = 7,
Status = "Active"
},
new
{
SubNavItemId = 15,
Code = "accounts.bank-accounts",
Href = "/dashboard/accounts/bank-accounts",
Label = "Cash / Bank Accounts",
NavItemId = 12,
SortOrder = 1,
Status = "Active"
},
new
{
SubNavItemId = 21,
Code = "accounts.cheque-books",
Href = "/dashboard/accounts/cheque-books",
Label = "Cheque Books",
NavItemId = 12,
SortOrder = 2,
Status = "Active"
},
new
{
SubNavItemId = 22,
Code = "accounts.received-cheques",
Href = "/dashboard/accounts/received-cheques",
Label = "Received Cheques",
NavItemId = 12,
SortOrder = 3,
Status = "Active"
});
});
+10
View File
@@ -1,6 +1,7 @@
using System.Text.Json.Serialization;
using ERPCore.Infra.Auth;
using ERPCore.Infra.Auth.AuthHex;
using ERPCore.Infra.Gl;
using ERPCore.Infra.Persistence;
using ERPCore.Infra.Storage;
using ERPCore.Infra.UoW;
@@ -51,6 +52,15 @@ builder.Services.AddScoped<IAuthUserService, AuthUserService>();
builder.Services.AddScoped<IAuthRecoveryService, AuthRecoveryService>();
builder.Services.AddScoped<IAuthAltService, AuthAltService>();
// General Ledger service proxy → external GL microservice (docs/12-GENERAL-LEDGER-INTEGRATION.md)
builder.Services.AddHttpClient<IGeneralLedgerClient, GeneralLedgerClient>(c =>
{
var baseUrl = builder.Configuration["GeneralLedgerService:BaseUrl"]
?? throw new InvalidOperationException("GeneralLedgerService:BaseUrl is not configured.");
c.BaseAddress = new Uri(baseUrl);
});
builder.Services.AddScoped<IGeneralLedgerService, GeneralLedgerService>();
// Current-user (audit actor). AuthHex has no sub/nameid → a claims transformation
// JIT-provisions a local shadow user and injects the local `int` id as `nameid`.
builder.Services.AddHttpContextAccessor();
@@ -0,0 +1,16 @@
using ERPCore.Infra.Gl;
using ERPCore.Services.Interfaces;
namespace ERPCore.Services;
/// <inheritdoc cref="IGeneralLedgerService"/>
public sealed class GeneralLedgerService : IGeneralLedgerService
{
private readonly IGeneralLedgerClient _client;
public GeneralLedgerService(IGeneralLedgerClient client) => _client = client;
public Task<GeneralLedgerResponse> ForwardAsync(
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct)
=> _client.SendAsync(method, path, queryString, contentType, body, ct);
}
@@ -0,0 +1,16 @@
using ERPCore.Infra.Gl;
namespace ERPCore.Services.Interfaces;
/// <summary>
/// Single entry point into the external General Ledger service — the one function
/// used both by <see cref="ERPCore.Controllers.GeneralLedgerController"/> (frontend
/// requests, forwarded verbatim) and, once wired, other ERPCore services that need to
/// post directly to GL (e.g. GRN confirm, adjustments — docs/12-GENERAL-LEDGER-INTEGRATION.md).
/// No business logic lives here yet; this pass only connects the transport.
/// </summary>
public interface IGeneralLedgerService
{
Task<GeneralLedgerResponse> ForwardAsync(
HttpMethod method, string path, string? queryString, string? contentType, Stream? body, CancellationToken ct);
}
@@ -66,4 +66,7 @@ public static class ErrorCodes
public const string LeftoverExceedsConsumed = "LEFTOVER_EXCEEDS_CONSUMED";
public const string RunCostClosed = "RUN_COST_CLOSED";
public const string RunNotCancellable = "RUN_NOT_CANCELLABLE";
// General Ledger service proxy (GeneralLedgerController → external GL service, docs/12)
public const string GlServiceUnavailable = "GL_SERVICE_UNAVAILABLE";
}
+1 -1
View File
@@ -6,7 +6,7 @@
}
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=postgres;Password=root"
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCore;Username=postgres;Password=dbuser"
},
"AuthHex": {
"BaseUrl": "http://localhost:5011"
+4
View File
@@ -22,5 +22,9 @@
"RootPath": "App_Data/hr-documents",
"MaxSizeBytes": 10485760
},
"GeneralLedgerService": {
"BaseUrl": "https://localhost:7024/api/v1/",
"ApiKey": "1A5FE0E389C029B1FBCD2A94650CEBF0656083BF06FF48E5E11987040ABA262D"
},
"AllowedHosts": "*"
}
+38
View File
@@ -105,6 +105,44 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
- [~] FEFO picking for perishables; block expired / on-hold issue — **issue-block done + verified** (`ONHOLD_NOT_ISSUABLE`, `EXPIRED_BATCH_BLOCKED`; on-hold/expired layers excluded from consume). FEFO *pick ordering* (oldest-expiry first) not yet built.
- [x] Reason codes (FR-X-04) — `ReasonCode` entity + `GET/POST /reason-codes`; standard set (docs/10 §B.8.3) seeded idempotently at startup (`DataSeeder`). Verified.
## 7. External Integrations
> **General Ledger service** (separate microservice, own repo/DB) — connected 2026-07-20 as a generic reverse-proxy only; no ERPCore business logic posts to it yet. Full contract + progress detail: `docs/12-GENERAL-LEDGER-INTEGRATION.md`.
- [~] Generic proxy `GET|POST|PUT /api/v1/gl/{**path}` (`GeneralLedgerController``IGeneralLedgerService``IGeneralLedgerClient`) — forwards method/path/query/body/content-type verbatim to the GL service with a server-attached `X-Api-Key`; GL's response (status + body) returned unchanged. ErpAccess-door-policy-gated like every other v1 endpoint. Config: `GeneralLedgerService:BaseUrl`/`ApiKey` in `appsettings.json`. Build verified clean; **not yet live-smoke-tested** (no running GL instance this pass).
- [ ] Internal wiring — ERPCore services (GRN confirm, adjustments, etc.) calling `IGeneralLedgerService` directly to post real journal entries. Deliberately deferred.
> ### 2026-07-20 — RBAC nav seed for the frontend's new "Ledgers" section
> The `Frontend/PROGRESS.md` §8 "Ledgers" sidebar section (docs/21-GENERAL-LEDGER-FRONTEND.md) needs a matching `NavItem`/`SubNavItem`/`Permission` row for every entry, or the sidebar filters it out for every role regardless of the frontend change (docs/10 C.8, `GET /auth/me`'s `navCodes`). Added via `NavItemConfiguration.cs`/`SubNavItemConfiguration.cs`/`PermissionConfiguration.cs` `HasData`: `NavItem` `ledgers` (id 11), 7 `SubNavItem` rows (ids 915, `ledgers.trial-balance` … `ledgers.bank-accounts`), 8 `Permission` rows (ids 1926) — same one-`Permission`-per-nav-entry convention as every existing nav row. Migration `AddLedgersNavSeed`.
> **Build note:** a locally running `ERPCore.exe` (PID 29692) held the default `bin/Debug` output locked for the whole session, so `dotnet ef migrations add` twice produced an empty no-op migration off a stale assembly (`--no-build` silently reused pre-edit code) before the real cause was found. Fixed by building to a scratch output directory (unaffected by the lock), copying the fresh `ERPCore.dll` over the locked `bin/Debug` copy (the running process only locks the `.exe`, not the `.dll`), then re-scaffolding — the resulting migration's `Up`/`Down` were verified by inspection against the identical, already-applied `AddRolesNavPermissions` migration's `InsertData`/`DeleteData` shape. The stray process was left running rather than killed, since it wasn't started by this work and may be in active use elsewhere.
> **Not yet applied to a live database** — no Postgres instance was available in this pass to run `dotnet ef database update` against. `dotnet build` is clean (0 warnings/0 errors).
> **Operational step still needed post-deploy (not code):** a new `NavItem`/`SubNavItem` carries no `RolePermission` grants by default — an administrator must check the new Ledgers permissions for the relevant role(s) via **Settings → Roles** before anyone sees the sidebar entry, same as every previous nav addition.
> ### 2026-07-30 — RBAC nav seed: 8th sub-item for the new "Tax Report" screen
> The frontend's GL-revision pass (`docs/21-GENERAL-LEDGER-FRONTEND.md`, Frontend/PROGRESS.md §8) added a Tax Report screen to the Ledgers sidebar section — needs the same nav-seed treatment as every other entry (docs/10 C.8). Added `SubNavItem` id 16 (`ledgers.tax-report`, `/dashboard/ledgers/tax-report`, sort order 7) and `Permission` id 27 (`NAV:ledgers.tax-report`); re-sequenced the existing `ledgers.bank-accounts` row's `SortOrder` from 7→8 so Tax Report sits before it, matching the sidebar array's actual order. Migration `AddTaxReportNavSeed` — no locked-process issue this time (confirmed no stray `ERPCore.exe` running before scaffolding), generated cleanly on the first attempt with real `InsertData`/`UpdateData`/`DeleteData` (`Down()` correctly restores `bank-accounts`' `SortOrder` to 7). `dotnet build` clean (0 warnings/0 errors). **Not yet applied to a live database** — same open item as the original `AddLedgersNavSeed` migration; both are still pending `dotnet ef database update` against a real Postgres instance.
> ### 2026-07-30 (2) — Fixed a real `SubNavItemId`/`PermissionId` collision between Procurement and Ledgers seed data
> **Root cause:** when the 2026-07-20 `AddLedgersNavSeed` migration was authored, its `SubNavItem`/`Permission` IDs were picked by looking at the *actual DB row count*, not the config source — but `SubNavItemConfiguration.cs`/`PermissionConfiguration.cs` already had `HasData` entries for Procurement's 4 sub-items (`procurement.requisitions`/`.rfqs`/`.purchase-orders`/`.purchase-returns`, ids 912/1922) that **had never actually been migrated into any database** (no migration `Up()` anywhere ever inserts them — confirmed by grep across every migration file). Ledgers then claimed the same ids (912 sub-nav, 1922 permission) for its own rows, so the config ended up with two `HasData` entries sharing the same primary key per table. `ErpDbContextModelSnapshot.cs` had silently absorbed both (`dotnet ef migrations add` doesn't hard-fail on this at scaffold time), but **EF's runtime model validator does** — `dotnet ef migrations add` for anything touching these tables, and by extension normal app startup/first `DbContext` use, throws `InvalidOperationException: A seed entity ... has the same key value as another seed entity mapped to the same table`. This is very likely the crash the user was hitting.
> **Fix:** moved Procurement's 4 sub-nav rows off the colliding ids onto **1720** (`SubNavItemId`) and **2831** (`PermissionId`), past every id already claimed by Ledgers/Tax-Report (max 16/27). Removed the phantom duplicate Procurement entries from `ErpDbContextModelSnapshot.cs` (they never reflected real DB state) so the differ could compute a clean diff, then generated migration **`FixProcurementNavIdCollision`** — pure `InsertData` for the 4 sub-nav rows + 4 permission rows at their new ids (this is also the *first* migration that actually creates Procurement's sub-nav-item/permission rows in the database at all). `Down()` is a clean `DeleteData` reversal.
> **Verified:** running `dotnet ef migrations add` against the pre-fix config reproduced the exact `InvalidOperationException` above (scaffold failed outright, no migration file produced), confirming this was a real, reproducible crash and not a false alarm; after the fix, the same command succeeded and `dotnet ef migrations list` builds the full model with no error, listing all 7 migrations (the last 2 — `AddTaxReportNavSeed`, `FixProcurementNavIdCollision` — still `(Pending)`, no Postgres instance available this session); `dotnet build` clean (0 warnings/0 errors). **Not yet applied to a live database** — same standing blocker as the two prior nav-seed migrations.
> Also fixed, same pass: `Frontend/erp-system/components/Layouts/AppSidebar.tsx`'s auto-expand-active-parent logic tripped `react-hooks/set-state-in-effect` (`setExpanded` called synchronously inside a `useEffect`) — converted to the same "adjust state during render" pattern used for the Ledgers report pages, keyed on a `pathname + item-codes` composite key (tracked via a `lastAutoExpandKey` state var) so it still re-fires once `items` populates after the RBAC `navCodes` fetch resolves. `npx eslint components/Layouts/AppSidebar.tsx` clean.
> ### 2026-07-31 — RBAC nav seed: new "Accounts" nav item (Cheque Management screens + Cash/Bank Accounts moved off Ledgers)
> The frontend added a new "Accounts" sidebar section (`Frontend/PROGRESS.md` §8) for the new Cheque Management screens and to hold Cash/Bank Accounts, which moved out of Ledgers into it (user-requested — Cheque Books/Received Cheques/Cash-Bank Accounts are all the same kind of operational account bookkeeping, not a statutory report). Migration **`AddAccountsNavSeed`**: `InsertData` for `NavItem` `accounts` (id 12) and two new `SubNavItem`/`Permission` pairs (`accounts.cheque-books` id 21/33, `accounts.received-cheques` id 22/34); **`UpdateData`, not delete-and-recreate**, for the existing Cash/Bank Accounts `SubNavItem`/`Permission` (ids 15/26) — same ids, just new `Code`/`Href`/`NavItemId` — so a role that had already been granted this permission under its old `ledgers.bank-accounts` code doesn't silently lose it just because the section changed. `Down()` correctly reverses both the inserts and the renamed-row update back to its Ledgers-era values.
> **No locked-process issue avoided this time** — `ERPCore.exe` was found running twice during this pass (the user had restarted it between turns to test the Tax Report fix); confirmed with the user before killing it each time, per this session's standing caution around stopping their dev server. `dotnet build` clean (0 warnings/0 errors); `dotnet ef migrations list` shows all 8 migrations with none pending. **Applied to the live database this session** (`dotnet ef database update`) — unlike every prior nav-seed migration this session, this one did not have to wait for a live Postgres instance to become available.
> **Operational step still needed post-deploy (not code):** same as every previous nav addition — an administrator must grant the new `NAV:accounts`/`NAV:accounts.cheque-books`/`NAV:accounts.received-cheques` permissions to the relevant role(s) via **Settings → Roles** before anyone sees the new sidebar entries (the re-homed `NAV:accounts.bank-accounts` keeps whatever grants it already had).
> ### 2026-07-31 (2) — Root-caused and fixed a repo-wide bug: 36 tables (all of HRM + all of Manufacturing) existed in the EF model but not in the actual database, and no `dotnet ef migrations add` could ever surface it
> **User-reported:** after rebasing `feat/general-ledger-service` onto `origin/Dev`, some tables from the other branch weren't being created by `migrations add` + `database update`. Ground-truthed against the live Postgres instance (queried `pg_tables`/`__EFMigrationsHistory` directly, since EF's own diff tooling only ever compares the compiled model against `ErpDbContextModelSnapshot.cs` — never the real database — so it's structurally blind to this class of bug): the database had 45 tables; the current model/snapshot expects 80. All 25 `hr_*` tables and all 11 `production_runs`/`production_templates`/`run_*`/`stage_*`/`template_stages` tables were completely absent, despite `ErpDbContext`/`Infra/Persistence/Configurations` fully describing them and `ErpDbContextModelSnapshot.cs` already listing them.
> **Root cause: a `.gitignore` rule (`**/Migrations/`, added early on to stop *new* EF migrations from being committed) combined disastrously with `ErpDbContextModelSnapshot.cs` staying tracked** (`.gitignore` doesn't retroactively untrack already-tracked files, and the snapshot was one of the original 4 tracked migrations). Every `dotnet ef migrations add` after that point updated the snapshot (which **did** get committed normally, since it was already tracked) but wrote its actual migration `.cs`/`.Designer.cs` pair as new, gitignored, never-committed files. Confirmed via `git show --stat` on every historical commit touching the snapshot: several — including the commit that added the entire HRM module and the one that added Manufacturing (`7d6e597`) — show large snapshot insertions with **zero** migration files in the same commit. Net effect: the snapshot has been silently lying about the applied-migration history for a long time; `dotnet ef migrations add` never detects a "missing" table because, as far as the (already-tracked, already-correct-looking) snapshot is concerned, nothing has changed — the actual `CreateTable` migration simply never existed anywhere in git, on any machine that didn't happen to still have it sitting locally, ungitignored-but-untracked.
> **Fix, in order:**
> 1. Confirmed the exact 36-table gap by comparing `pg_tables` against every `b.ToTable(...)` call in the snapshot (script, not archaeology — this is the only way to get ground truth once the snapshot itself is suspect).
> 2. Temporarily removed just those 36 entities' blocks from `ErpDbContextModelSnapshot.cs` (verified 23 balanced-brace occurrences per entity removed cleanly, nothing else touched), so `dotnet ef migrations add` would have something real to diff against.
> 3. Generated **`AddMissingHrmAndManufacturingTables`** — verified its `Up()` contains exactly 36 `CreateTable` calls (matching the missing-table list precisely, no more/fewer) and its `Down()` exactly 36 matching `DropTable` calls; no `AlterColumn`/`DropColumn`/`RenameColumn` against any pre-existing table, confirming this was a pure addition with zero collateral schema drift.
> 4. Applied it (`dotnet ef database update`); re-queried `pg_tables` live — all 81 tables (80 + `__EFMigrationsHistory`) now present. Confirmed fully settled by scaffolding one more throwaway migration afterward and checking it came back empty (no remaining model/snapshot drift), then removing it.
> 5. **Fixed the actual root cause, not just this one symptom:** reverted the `.gitignore` rule — EF Core migrations are now tracked like any other source file, so this can't recur the same way. Every migration created since the rule was added (`AddLedgersNavSeed`, `AddTaxReportNavSeed`, `FixProcurementNavIdCollision`, `AddAccountsNavSeed`, the empty `production` migration, and this pass's `AddMissingHrmAndManufacturingTables`) was sitting on disk ungitignored-but-uncommitted the whole time — now staged to actually join the repo.
> **Verified:** `dotnet build` clean (0 errors, pre-existing `CS8981` naming warning on the already-present `production` migration class only); `dotnet ef migrations list` shows all 10 migrations, none pending. **A locally running `ERPCore.exe` had to be stopped mid-session (user's explicit approval obtained first) to free the build lock**, same recurring issue as every previous migration pass this week.
> **Left as-is, deliberately:** the empty `production` migration (`20260731123720_production.cs`) — it's a harmless no-op (it was the user's own prior attempt to fix this exact bug, which came back empty for the reason explained above) and renaming/removing it now would just be churn; the real fix landed in the next migration.
> **Action needed from the user:** the `.gitignore` fix means these migration files are no longer excluded, but nothing has been `git add`ed or committed yet — per standing instruction, commits only happen when explicitly asked.
## Deferred (Phase 2+ — do NOT build now, hooks only)
- [ ] Vendor invoice + three-way match
- [ ] Reservation/allocation fulfilment
+40
View File
@@ -83,6 +83,46 @@ Convention: `docs/01-DOC-GUIDE.md §6`. Update this file in the **same commit**
- [ ] `412` conflict → prompt refetch before retry — `apiRequestWithETag` surfaces the ETag but no screen edits a GRN yet (GRN has no PUT), so untested in practice
- [x] No client-side gating on stock/availability/status (server-authoritative) — GRN create always submits to the server and surfaces `OVER_RECEIPT_TOLERANCE`/etc. via `error-map.ts` rather than pre-blocking
## 8. General Ledger (Ledgers + Accounts sections)
> Two sidebar sections (`app/dashboard/ledgers/*`, `app/dashboard/accounts/*`), sourced entirely from the external General Ledger service via ERPCore's generic proxy (`docs/12-GENERAL-LEDGER-INTEGRATION.md`). Full detail, decisions, and known gaps: `docs/21-GENERAL-LEDGER-FRONTEND.md`.
- [x] Ledgers: reports hub + 7 report screens (Trial Balance, Balance Sheet, General Ledger, Profit & Loss, Cash Flow, Budget vs Actual, Tax Report) — statutory-format header/table, PDF **and CSV** download via the same endpoint with `outputFormat=Pdf`/`Csv`
- [x] Accounts: hub + Cash/Bank Accounts — unified list (GL's own server-side `accountType` union + client-side text search) + create (Cash/Bank toggle; GL account is now auto-created server-side, **no picker** — see 2026-07-31 (6) below). **Moved here from Ledgers (2026-07-31 (5))**
- [ ] Cash/Bank Accounts — edit: **not built**, GL has no `GET`/`PUT` by id for either table to build it against (list shows a disabled Edit affordance with an explanatory tooltip instead of a broken form)
- [x] Accounts: Cheque Books — list/filter, create (auto-generates every leaf), drill-down to a book's own pages list, per-page details/issue/status-update in a modal
- [x] Accounts: Received Cheques — list/filter, create, per-row details/status-update in a modal
- [x] Sidebar "Ledgers" (7 sub-items) + new "Accounts" (3 sub-items) nav items (`components/Layouts/AppSidebar.tsx`) + header title mappings (`components/Layouts/Header.tsx`)
- [x] Dedicated GL fetch client (`lib/api/general-ledger.ts`) — GL's envelope differs from ERPCore's own `ProblemDetails`, so this does not reuse `lib/api-client.ts`; now also covers Cheque Management (`chequeBooksApi`/`chequePagesApi`/`receivedChequesApi`)
> **2026-07-30 — GL's 2026-07-22 backend revision built out (large pass).** Five reports restructured (Trial Balance flattened, Profit & Loss → nested named sections with a Gross Profit subtotal, Cash Flow → a real structured statement replacing the four `StatCard`s), a new CSV export on all seven reports (`components/reports/DownloadCsvButton.tsx`), a brand-new **Tax Report** screen (Income Tax Computation, collapsible optional-adjustments panel, payable/refundable sign-dependent final row), and Cash/Bank accounts split into two real GL endpoints (`POST /bank-accounts` vs `POST /cash-accounts`, unified `GET /bank-accounts?accountType=`) with a two-choice create-form toggle and a Cash Account Type picker that can create a new type on the fly. Extracted `components/reports/{ReportSection,ReportSubtotal}.tsx` — shared by Profit & Loss and Cash Flow rather than duplicating the "bordered section + bold subtotal" markup twice. Three response shapes (`ProfitAndLossResponse`/`CashFlowResponse`/`TaxSummaryResponse`) are **inferred** where GL's own reference doesn't spell out every field verbatim — flagged in `types/general-ledger.ts`'s own comments and `docs/21-GENERAL-LEDGER-FRONTEND.md`, same posture as the original inferred `BankAccount` shape. Backend: migration `AddTaxReportNavSeed` adds the 8th sidebar sub-item + its permission row. Verified: `tsc --noEmit` clean, `eslint` clean across every touched file, `npm run build` succeeds with all 9 `/dashboard/ledgers/*` routes (incl. `/tax-report`), `dotnet build` clean. **Not done:** live smoke test against a running GL instance (still no instance available this session) — the three inferred response shapes are the highest-value thing to verify first.
> **2026-07-20, same-day fixes (user-reported):** (1) General Ledger report was wrongly calling `GET /accounts` to populate an account picker — GL documents `accountId` on this report as a raw id, not a code-lookup value, so the picker is gone; the screen now only ever calls `/reports`, entering `accountId` directly and reading the account's code/name for display off the report's own returned rows instead. (2) `ReportType`/`ReportOutputFormat`/`GlAccountTypeId` converted from string/numeric literal unions to real TS enums. (3) Fixed a UI-only bug where a selected `<Select>` (Bank Account create's GL-account picker, Budget vs Actual's budget picker) displayed the raw numeric value instead of its label after selection — the underlying value sent to the server was always correct; `@base-ui/react/select`'s `Select.Value` needs an explicit `label` prop per `<SelectItem>` (separate from `children`) to resolve display text, which neither picker was passing. Fixed at the two call sites, not the shared `components/ui/select.tsx` primitive (out of scope — other numeric-valued `<Select>`s elsewhere in the app likely share this latent bug; flagged in `docs/21-GENERAL-LEDGER-FRONTEND.md` §4 for whoever next touches one). Verified: `tsc --noEmit` and `eslint` clean.
>
> **2026-07-20 (2) — `react-hooks/set-state-in-effect` errors resolved, Ledgers pages only (scope confirmed with the user — this is not an app-wide lint pass; the same error is pre-existing on ~35 other files elsewhere in the app, left untouched).** All 7 report/list screens called `setState` synchronously as the first statement of a data-fetching effect (clearing stale results before the async call) — flagged as an error, not just a warning, by this project's current eslint config. Fixed with React's own "adjust state during render" pattern instead of an effect: each page now tracks the key it last loaded for (`asOfDate`/period/`accountId`/`budgetId`) in a small extra piece of state, and resets the result/error state **during render** when that key changes (before the effect below ever runs) rather than synchronously inside the effect. Behavior is unchanged — stale results still clear the instant a filter changes. `bank-accounts/page.tsx`'s mount-only `load()` had a redundant `setError(null)` (state already starts `null`; nothing else ever recalls `load()`), removed outright rather than worked around. Verified: `npx eslint app/dashboard/ledgers` produces zero output, `tsc --noEmit` clean, `npm run build` succeeds.
>
> **2026-07-31 — Fixed a live runtime crash on Cash Flow: GL omits empty list/section fields entirely instead of sending `[]`/`{lines:[],total:0}`.** User-reported error clicking into the page: `TypeError: Cannot read properties of undefined (reading 'map')` at `bucketOperatingLines``report.nonCashAdjustments.map(...)`, confirming the exact risk `CashFlowResponse` had been flagged with since it was built (inferred shape, never verified live). Root cause: GL's serializer drops a list/section property from the JSON body altogether when there's nothing to report for the period, rather than emitting an empty array/zero-totalled object. Fixed defensively in `cash-flow/page.tsx` (`?? []` on `nonCashAdjustments`/`workingCapitalChanges`, a new `activitySectionLines()` helper + optional chaining for `investingActivities`/`financingActivities`/their `.total`) and, proactively, in `profit-and-loss/page.tsx` (`isEmpty` check and every section's `.total` access) since `ProfitAndLossResponse` shares the identical nested-section shape and was equally exposed — not yet crashed on, but certain to under the same conditions (a section with nothing posted for the period). `types/general-ledger.ts`'s `CashFlowResponse`/`ProfitAndLossResponse` fields updated from required to optional to match, with comments pointing back at this confirmed-live behavior. Verified: `tsc --noEmit`/`eslint` clean on all touched files; `npm run build`'s TypeScript step fails, but only on a pre-existing, unrelated `app/dashboard/hrm/employees/[id]/page.tsx` error present before this pass — out of scope per standing instruction to keep fixes scoped to Ledgers. **Tax Report's `TaxSummaryResponse` is the one remaining inferred shape not yet defensively hardened or live-verified** — same class of risk, flagged for the next time that screen is touched.
>
> **2026-07-31 (2) — Corrected against GL's own authoritative API reference (`04_API_Reference_And_Scenarios.md`, user-supplied): Cash Flow's shape was fundamentally wrong, not just missing defensive guards; Tax Report was missing five real fields.** With the actual GL API reference in hand (not inference), checked every report's response shape against it: Trial Balance, Balance Sheet, General Ledger, Profit & Loss, and Budget vs Actual all match exactly, confirming those five were built correctly. Two did not: **(1) `CashFlowResponse` doesn't have `netEarnings`/`nonCashAdjustments`/`workingCapitalChanges`/`netCashFromOperations` as flat top-level fields at all — everything genuinely nests under `operatingActivities` (`{ profitForPeriod, nonCashAdjustments[], workingCapitalChanges[], netCashFromOperatingActivities }`), and `investingActivities`/`financingActivities` each carry their own differently-named total (`netCashFromInvestingActivities`/`netCashFromFinancingActivities`), not a shared `total`.** This — not just "the field might be missing" — was the real cause of the crash fixed in the previous entry; the previous fix's defensive `?? []` guards were correct in spirit but pointed at the wrong (nonexistent) top-level fields, so the page would have kept rendering an empty operating-activities section forever even without crashing. Rewrote `cash-flow/page.tsx` and `CashFlowResponse`/added `CashFlowOperatingActivities`/`CashFlowInvestingActivities`/`CashFlowFinancingActivities` to `types/general-ledger.ts` to match the confirmed contract exactly; also caught that `workingCapitalChanges[]` entries use `changeAmount`, not `amount`. **(2) `TaxSummaryResponse`/the Tax Report's `ROWS` table were missing `nonDeductibleExpenses`, `corporateIncomeTax`, `apitCredit`, `whtCredit`, and `quarterlyTaxPayments` entirely** — real GL-computed figures that were silently never rendered, not just a wrong guess at a field name. Added all five in their correct position in the confirmed row order (`profitBeforeTax``balanceTaxPayable`). Verified: `tsc --noEmit`/`eslint` clean on every touched file.
>
> **2026-07-31 (3) — Balance Sheet regrouped into a proper LKAS Statement of Financial Position layout (user-reported).** `BalanceSheetRow`'s shape was already correct (confirmed against GL's reference above), but the flat one-table rendering made a rollup total visually indistinguishable from the leaf amounts it already sums — e.g. "Cash and Bank"'s balance already includes "Petty Cash"/"Main Operating Bank Account"/"Savings Bank Account" beneath it, but every row read the same weight (only `depth===0` did any, subtle, bolding), inviting a user to double-count by adding up everything they see. Rewrote `balance-sheet/page.tsx`: rows now group by `accountType` into ASSETS/LIABILITIES/EQUITY sections, each ending in a bold "Total {Section Name}" row (summed from that section's depth-0 rows only — a depth-0 row's balance already rolls up its own descendants, so summing depth-0 rows avoids double-counting), any row with a deeper row immediately following it is bolded as a rollup regardless of its own depth (not just the very top level), and a final "Total Liabilities and Equity" row for the standard balance-check. One quirk handled explicitly: GL's synthetic "Current Year Earnings" balancing row is documented to always carry `depth: 1` even though it's a peer Equity entry, not a child of whatever real account happens to precede it — a new `effectiveDepth()` helper special-cases it to 0 so it isn't mis-rendered as nested under (and excluded from the total alongside) an unrelated account. Manually verified the new grouping/summing logic against the actual numbers from the reported screenshot: Total Assets (5,880,466.50) = Total Liabilities (2,025,000.00) + Total Equity (3,855,466.50), exact match. Verified: `tsc --noEmit`/`eslint` clean.
>
> **2026-07-31 (4) — Superseded by GL's own retrofit: `BalanceSheet` is a genuinely different, classified response shape now, not just a re-grouping of the same flat array.** GL's own API reference (user-supplied) documents a 2026-07-31 backend retrofit: the flat recursive-rollup array (`{depth, lineItem, accountType, balance}`, what entry (3) above regrouped client-side) is replaced entirely by a **pre-classified nested object**`{ asOfDate, nonCurrentAssets: {lines[], total}, currentAssets: {lines[], total}, unclassifiedAssets: {lines[], total}, totalAssets, equity: {lines[], total}, nonCurrentLiabilities: {lines[], total}, currentLiabilities: {lines[], total}, unclassifiedLiabilities: {lines[], total}, totalEquityAndLiabilities }`, driven by a new `accounts.balance_sheet_classification` tag GL now maintains server-side. This means entry (3)'s client-side grouping/rollup logic (`effectiveDepth`, `sectionTotal`, the `depth`-based rollup-bolding) is entirely obsolete — GL now does the Non-Current/Current classification itself, the frontend just renders the sections it's given. Replaced `BalanceSheetRow` with `BalanceSheetLine`/`BalanceSheetSection`/`BalanceSheetResponse` in `types/general-ledger.ts` (every section marked optional, same defensive posture adopted for `CashFlowResponse`/`ProfitAndLossResponse` after the Cash Flow crash, since this exact shape isn't live-verified against this frontend yet) and rewrote `balance-sheet/page.tsx` from scratch to consume it. **Also changed the layout to match a user-supplied reference Statement of Financial Position image** (a real classified SOFP: Non-Current Assets/Current Assets each their own subtotaled block, then Equity and Liabilities the same way, ending in a Total Assets vs Total Equity-and-Liabilities check) — rather than inventing new one-off markup for this, reused the same `ReportSection`/`ReportSubtotal` shared components Profit & Loss and Cash Flow already use (one `ReportSection` per GL-provided section, a `ReportSubtotal` for each side's grand total), keeping Balance Sheet visually and structurally consistent with the rest of the Ledgers screens rather than a bespoke table. Account codes are deliberately not shown per line (the reference template shows plain line-item names only). Verified: `tsc --noEmit`/`eslint` clean; grepped the codebase to confirm no lingering references to the removed `BalanceSheetRow`/flat shape.
>
> **2026-07-31 (5) — New "Accounts" nav section: Cheque Management built out, Cash/Bank Accounts moved under it.** New Cheque Management module (`04_API_Reference_And_Scenarios.md`, Module: Cheque Management — added to GL 2026-07-30, beyond its original plan): two independent sub-areas, **Cheque Books/Pages** (cheques issued from this company's own supply) and **Received Cheques** (cheques received from others, deliberately unlinked to any cheque book). Added `PayeeType`/`ReceivedFromType`/`ChequeBookStatus`/`ChequePageIssueStatus`/`ChequePageStatusAction`/`ReceivedChequeStatus`/`ReceivedChequeStatusAction` enums and `ChequeBook`/`ChequePage`/`ReceivedCheque` (+ their create/status-update request types) to `types/general-ledger.ts`, and `chequeBooksApi`/`chequePagesApi`/`receivedChequesApi` to `lib/api/general-ledger.ts`. `branchId`/`companyId`/`payeeId`/`voucherId`/`referenceId` are GL's own documented "loose references" (no Branch/Company/Customer/Supplier table exists in that service) — taken as plain numeric inputs, not picker dropdowns, matching GL's stated design rather than fabricating master data that doesn't exist.
>
> **Cheque Books** (`app/dashboard/accounts/cheque-books/{page,new,[chequeBookNo]/page}.tsx`): list with a status filter, create form (bank account picker restricted to `Bank`-type accounts only — GL's own module note says cheque books are bank-account-only, never cash-account), and a book-detail page showing every leaf (`GET /cheque-books/{chequeBookNo}?expand=pages`) — clicking a leaf opens `components/accounts/ChequePageDialog.tsx`, a modal with read-only details plus status-appropriate actions (`Unused` → Issue/Cancel/Void; `Issued` → Clear/Bounce/Cancel; terminal statuses → read-only), each action revealing only the fields that specific transition actually needs (e.g. Clear asks for `clearedDate`, Cancel asks for `cancelReason`, Bounce/Void need nothing beyond an optional `performedBy`). A modal was chosen over a second-level page for the leaf-details view (left open in the request) so working through several leaves in one book doesn't lose the list's scroll position/context each time.
>
> **Received Cheques** (`app/dashboard/accounts/received-cheques/{page,new/page}.tsx` + `components/accounts/ReceivedChequeDialog.tsx`): same list-then-modal shape — status filter, create form, and a details/status-update modal (`Received` → Deposit/Cancel; `Deposited` → Clear/Return), Deposit asking for a bank-account picker + date, the rest needing nothing beyond an optional note.
>
> **2026-07-31 (6) — Two user-reported fixes: `glAccountCode` removed from Cash/Bank Account creation (further GL retrofit), and the three GL create-form pages widened to fill the page.** (1) GL's reference now documents that `POST /bank-accounts`/`POST /cash-accounts` no longer accept `glAccountCode` — the backing GL account (a `Bank`/`Cash` root, plus a type-header node for Cash) is always found-or-created server-side, never caller-selected. Removed the field from `CreateBankAccountRequest`/`CreateCashAccountRequest`, deleted the "GL account" `Select` and its `glAccountsApi.list()` fetch from `bank-accounts/new/page.tsx` outright, and dropped the check from `validateBankAccountForm`. Typed the create response as a new `CreateCashOrBankAccountResponse` (`glAccount` nested, confirmed from GL's doc) so the success toast can surface the auto-generated GL account code. The Cash/Bank **list** page is untouched — GL's list endpoint still returns a flat `glAccountId` per row, still resolved via `glAccountsApi.list()` there. (2) `bank-accounts/new`, `cheque-books/new`, and `received-cheques/new` each wrapped their form in a `max-w-lg` card, leaving roughly half of any normal desktop screen blank. Dropped the `max-w-lg` cap (now full-width, matching the un-capped card convention every report page already uses) and replaced the vertical one-field-per-row stacking (plus scattered ad-hoc `grid grid-cols-2` pairs) with one consistent `grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3` wrapper per form. Left the two modals (`ChequePageDialog`/`ReceivedChequeDialog`) at their existing fixed width on purpose — the complaint was about full-page create forms, not dialogs. Verified: `tsc --noEmit`/`eslint` clean on every touched file; `npm run build`'s Turbopack compile succeeds, its TypeScript step fails only on the same pre-existing, unrelated `app/dashboard/hrm/employees/[id]/page.tsx` error noted in earlier entries.
>
> **Cash/Bank Accounts moved from Ledgers to the new Accounts section** (user-requested), since it's the same kind of "operational account bookkeeping" as cheques, not a statutory report — `app/dashboard/ledgers/bank-accounts/*` relocated verbatim to `app/dashboard/accounts/bank-accounts/*` (internal links updated, no behavior change), removed from the Ledgers hub's card grid.
>
> **Backend:** migration `AddAccountsNavSeed` adds `NavItem` `accounts` (id 12) and two new `SubNavItem`/`Permission` pairs (`accounts.cheque-books`, `accounts.received-cheques`), and **re-homes** the existing Cash/Bank Accounts `SubNavItem`/`Permission` (ids 15/26) from Ledgers to Accounts via `UpdateData` (new `Code`/`Href`/`NavItemId`) rather than delete-and-recreate — keeps the same ids so any role already granted that permission doesn't silently lose it just because the section it lives under changed. Applied to the live database this session (`dotnet ef database update`).
>
> **Fields not explicitly spelled out verbatim in GL's reference** (its own numeric-id column names for `ChequeBook`/`ChequePage`, and `ReceivedCheque`'s JSON id field) are built from the request-body field names GL *does* document plus this project's consistent `<entity>Id` convention, flagged in `types/general-ledger.ts`'s comments — `chequeNo`/`chequeBookNo` (both explicitly documented as the identifying route values) are used for keys/URLs throughout instead, sidestepping the guess entirely wherever possible. **Not done:** live smoke test against a running GL instance — this entire module is unverified against real Cheque Management data. Verified: `tsc --noEmit`/`eslint` clean on every touched file; `npm run build` compiles successfully (Turbopack), its full-project TypeScript check still blocked only by the pre-existing, unrelated `hrm/employees/[id]` error.
>
> **2026-07-20 (3) — General Ledger report corrected again: `accountId` dropped entirely, not just made direct-entry.** The GL service's own contract changed (confirmed against its updated docs): `GeneralLedger`'s `accountId` is now optional, and the *omitted* case is the real General Ledger (every postable account together, each with its own running balance, sorted by `accountCode` then `entryDate`) — supplying `accountId` is a separate "Account Ledger" (single account + descendants) mode this page doesn't use. Superseding the same-day entry above: the numeric Account ID input is gone, `reportsApi.generalLedger()` dropped the `accountId` parameter, and the page now fetches on `periodStart`/`periodEnd` alone with sensible defaults — auto-fetching on page load like every other report screen (this also resolves the earlier-reported "no network call when landing on the page," which was the now-removed account-required gate). Result rows are grouped into per-account sections in the table (a header row wherever `accountCode` changes), matching the API's per-account running-balance reset. No frontend change was needed for the same-day `BalanceSheet` response addition (a synthetic `"Current Year Earnings"` equity row) — the existing generic row renderer already displays whatever rows come back. Verified: `tsc --noEmit` clean, `npx eslint app/dashboard/ledgers lib/api/general-ledger.ts` produces zero output, `npm run build` succeeds.
## 7. UX states
- [~] Loading / empty / error states on every list — done for the GRN list/create/detail screens; other screens still unbuilt
- [x] Transactional actions show server-returned side effects as confirmation — GRN confirm renders `createdLayers`/`ledgerRefs`/`poStatus` from the response
@@ -0,0 +1,204 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft } from "lucide-react"
import { bankAccountsApi, cashAccountTypesApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { validateBankAccountForm } from "@/lib/validations/general-ledger"
import { cn } from "@/lib/utils"
import { CashAccountType, CashBankAccountType } from "@/types/general-ledger"
import { Button, buttonVariants } from "@/components/ui/button"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { toast } from "@/components/ui/toast"
const OTHER_CASH_TYPE = "__other__"
export default function NewBankAccountPage() {
const router = useRouter()
const [accountType, setAccountType] = useState<CashBankAccountType>(CashBankAccountType.Bank)
const [cashAccountTypes, setCashAccountTypes] = useState<CashAccountType[] | null>(null)
const [cashAccountTypesError, setCashAccountTypesError] = useState<string | null>(null)
const [accountName, setAccountName] = useState("")
const [bankName, setBankName] = useState("")
const [cashAccountTypeChoice, setCashAccountTypeChoice] = useState("")
const [customCashAccountTypeName, setCustomCashAccountTypeName] = useState("")
const [accountNumber, setAccountNumber] = useState("")
const [currencyCode, setCurrencyCode] = useState("LKR")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
if (accountType !== CashBankAccountType.Cash || cashAccountTypes !== null) return
cashAccountTypesApi
.list()
.then(setCashAccountTypes)
.catch((err) => setCashAccountTypesError(errorMessage(err)))
// Only fetched once, lazily, the first time "Cash" is selected.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [accountType])
const cashAccountTypeName =
cashAccountTypeChoice === OTHER_CASH_TYPE ? customCashAccountTypeName.trim() : cashAccountTypeChoice
async function handleSubmit() {
const nextErrors = validateBankAccountForm({ accountName })
if (accountType === CashBankAccountType.Cash && !cashAccountTypeName) {
nextErrors.cashAccountTypeName = "Select or enter a cash account type"
}
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const created =
accountType === CashBankAccountType.Bank
? await bankAccountsApi.createBank({
accountName,
bankName: bankName || null,
accountNumber: accountNumber || null,
currencyCode: currencyCode || undefined,
})
: await bankAccountsApi.createCash({
accountName,
cashAccountTypeName,
accountNumber: accountNumber || null,
currencyCode: currencyCode || undefined,
})
toast.success(`${accountType} account created`, `${created.accountName} — GL account ${created.glAccount.accountCode}`)
router.push("/dashboard/accounts/bank-accounts")
} catch (err) {
toast.error(`Could not create ${accountType.toLowerCase()} account`, errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/accounts/bank-accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">New Cash / Bank Account</h1>
<p className="text-base text-muted-foreground">
Its ledger account is created automatically no need to pick one.
</p>
</div>
</div>
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<div className="mb-6 flex max-w-sm gap-2">
<Button
type="button"
variant={accountType === CashBankAccountType.Bank ? "default" : "outline"}
className="flex-1"
onClick={() => setAccountType(CashBankAccountType.Bank)}
>
Bank
</Button>
<Button
type="button"
variant={accountType === CashBankAccountType.Cash ? "default" : "outline"}
className="flex-1"
onClick={() => setAccountType(CashBankAccountType.Cash)}
>
Cash
</Button>
</div>
<div className="grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2 lg:grid-cols-3">
<Field data-invalid={!!errors.accountName}>
<FieldLabel htmlFor="ba-name">Account name</FieldLabel>
<Input
id="ba-name"
value={accountName}
onChange={(e) => setAccountName(e.target.value)}
placeholder={accountType === CashBankAccountType.Bank ? "Main Account" : "Head Office Petty Cash"}
aria-invalid={!!errors.accountName}
/>
<FieldError errors={[errors.accountName ? { message: errors.accountName } : undefined]} />
</Field>
{accountType === CashBankAccountType.Bank ? (
<>
<Field>
<FieldLabel htmlFor="ba-bank">Bank name (optional)</FieldLabel>
<Input id="ba-bank" value={bankName} onChange={(e) => setBankName(e.target.value)} placeholder="Commercial Bank" />
</Field>
<Field>
<FieldLabel htmlFor="ba-acct-no">Account number (optional)</FieldLabel>
<Input id="ba-acct-no" value={accountNumber} onChange={(e) => setAccountNumber(e.target.value)} placeholder="8001234567" />
</Field>
</>
) : (
<>
<Field data-invalid={!!errors.cashAccountTypeName}>
<FieldLabel htmlFor="ba-cash-type">Cash account type</FieldLabel>
<Select<string> value={cashAccountTypeChoice} onValueChange={(v) => setCashAccountTypeChoice(v ?? "")}>
<SelectTrigger id="ba-cash-type" className="w-full text-base" aria-invalid={!!errors.cashAccountTypeName}>
<SelectValue placeholder={cashAccountTypes === null ? "Loading…" : "Select a type"} />
</SelectTrigger>
<SelectContent>
{(cashAccountTypes ?? []).map((t) => (
<SelectItem key={t.cashAccountTypeId} value={t.name} label={t.name} className="text-base">
{t.name}
</SelectItem>
))}
<SelectItem value={OTHER_CASH_TYPE} label="Other, please specify" className="text-base">
Other, please specify
</SelectItem>
</SelectContent>
</Select>
{cashAccountTypeChoice === OTHER_CASH_TYPE && (
<Input
value={customCashAccountTypeName}
onChange={(e) => setCustomCashAccountTypeName(e.target.value)}
placeholder="e.g. Site Cash"
className="mt-2"
/>
)}
{cashAccountTypesError && (
<p className="text-sm text-destructive">{cashAccountTypesError}</p>
)}
<FieldError errors={[errors.cashAccountTypeName ? { message: errors.cashAccountTypeName } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="ba-acct-no">Account number (optional)</FieldLabel>
<Input
id="ba-acct-no"
value={accountNumber}
onChange={(e) => setAccountNumber(e.target.value)}
placeholder="Auto-generated if left blank"
/>
</Field>
</>
)}
<Field>
<FieldLabel htmlFor="ba-currency">Currency</FieldLabel>
<Input id="ba-currency" value={currencyCode} onChange={(e) => setCurrencyCode(e.target.value)} maxLength={3} placeholder="LKR" />
</Field>
</div>
<div className="mt-6 flex justify-end gap-3">
<Link href="/dashboard/accounts/bank-accounts" className={cn(buttonVariants({ variant: "outline" }), "min-w-36")}>
Cancel
</Link>
<Button className="min-w-36" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Creating…" : "Create"}
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,192 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Pencil, Plus, Search, Wallet } from "lucide-react"
import { bankAccountsApi, glAccountsApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { formatReportDate } from "@/lib/format"
import { cn } from "@/lib/utils"
import { CashAndBankAccountDto, CashBankAccountType, GlAccount } from "@/types/general-ledger"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
type AccountTypeFilter = CashBankAccountType | "Both"
export default function BankAccountsPage() {
const [accounts, setAccounts] = useState<CashAndBankAccountDto[] | null>(null)
const [glAccounts, setGlAccounts] = useState<GlAccount[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState("")
const [accountType, setAccountType] = useState<AccountTypeFilter>("Both")
// GL's own server-side accountType filter (2026-07-22 rework — was client-only over one table
// before) — re-fetches whenever the filter changes, unlike the plain client-side search below.
useEffect(() => {
let cancelled = false
Promise.all([bankAccountsApi.list(accountType), glAccountsApi.list()])
.then(([banks, gl]) => {
if (cancelled) return
setAccounts(banks)
setGlAccounts(gl.items)
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
return () => {
cancelled = true
}
}, [accountType])
const glAccountsById = useMemo(() => new Map((glAccounts ?? []).map((a) => [a.accountId, a])), [glAccounts])
const filtered = useMemo(() => {
if (!accounts) return null
const q = search.trim().toLowerCase()
if (!q) return accounts
return accounts.filter((a) => {
const gl = glAccountsById.get(a.glAccountId)
return (
a.accountName.toLowerCase().includes(q) ||
(a.bankName ?? "").toLowerCase().includes(q) ||
(a.cashAccountTypeName ?? "").toLowerCase().includes(q) ||
(a.accountNumber ?? "").toLowerCase().includes(q) ||
a.currencyCode.toLowerCase().includes(q) ||
(gl?.accountCode ?? "").toLowerCase().includes(q)
)
})
}, [accounts, search, glAccountsById])
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Cash / Bank Accounts</h1>
<p className="text-base text-muted-foreground">Cash and Bank accounts linked to a GL account, for reconciliation.</p>
</div>
</div>
<Link href="/dashboard/accounts/bank-accounts/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Account
</Link>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative flex-1">
<Search className="pointer-events-none absolute top-1/2 left-4 size-5 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search name, bank/type, account no. or currency…"
className="h-14 w-full pl-11 text-base"
aria-label="Search cash/bank accounts"
/>
</div>
<Select<AccountTypeFilter> value={accountType} onValueChange={(v) => setAccountType(v ?? "Both")}>
<SelectTrigger className="h-14! w-full text-base sm:w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="Both" label="All types" className="text-base">All types</SelectItem>
<SelectItem value={CashBankAccountType.Bank} label="Bank" className="text-base">Bank</SelectItem>
<SelectItem value={CashBankAccountType.Cash} label="Cash" className="text-base">Cash</SelectItem>
</SelectContent>
</Select>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && filtered === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && filtered !== null && filtered.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Wallet className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">
{search ? "No accounts match your search." : "No cash/bank accounts yet."}
</p>
</div>
)}
{!error && filtered !== null && filtered.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
<TableHead className="h-12 px-3 text-sm">Account name</TableHead>
<TableHead className="h-12 px-3 text-sm">Bank / Cash type</TableHead>
<TableHead className="h-12 px-3 text-sm">Account no.</TableHead>
<TableHead className="h-12 px-3 text-sm">GL account</TableHead>
<TableHead className="h-12 px-3 text-sm">Currency</TableHead>
<TableHead className="h-12 px-3 text-sm">Created</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((a) => {
const gl = glAccountsById.get(a.glAccountId)
return (
<TableRow key={`${a.accountType}-${a.accountId}`}>
<TableCell className="px-3 py-3.5">
<Badge
variant="outline"
className={cn(
"h-6 w-16 justify-center border-transparent text-sm",
a.accountType === CashBankAccountType.Cash
? "bg-success/10 text-success"
: "bg-primary/10 text-primary"
)}
>
{a.accountType}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5 font-medium">{a.accountName}</TableCell>
<TableCell className="px-3 py-3.5">{a.bankName ?? a.cashAccountTypeName ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5">{a.accountNumber ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">
{gl ? `${gl.accountCode}${gl.accountName}` : `#${a.glAccountId}`}
</TableCell>
<TableCell className="px-3 py-3.5">{a.currencyCode}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(a.createdAt)}</TableCell>
<TableCell className="px-3 py-3.5">
<Tooltip>
<TooltipTrigger
render={
<Button variant="ghost" size="icon-sm" disabled aria-label={`Edit ${a.accountName}`} />
}
>
<Pencil className="size-4" />
</TooltipTrigger>
<TooltipContent>
Editing isn&apos;t available yet the General Ledger service has no update endpoint for
{a.accountType === CashBankAccountType.Cash ? " cash" : " bank"} accounts.
</TooltipContent>
</Tooltip>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</div>
)
}
@@ -0,0 +1,159 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useParams } from "next/navigation"
import { ArrowLeft, BookText } from "lucide-react"
import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate } from "@/lib/format"
import { cn } from "@/lib/utils"
import { CashAndBankAccountDto, CashBankAccountType, ChequeBook, ChequePage, ChequePageIssueStatus } from "@/types/general-ledger"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { ChequePageDialog } from "@/components/accounts/ChequePageDialog"
const STATUS_BADGE: Record<ChequePageIssueStatus, string> = {
[ChequePageIssueStatus.Unused]: "bg-muted text-muted-foreground",
[ChequePageIssueStatus.Issued]: "bg-primary/10 text-primary",
[ChequePageIssueStatus.Cleared]: "bg-success/10 text-success",
[ChequePageIssueStatus.Bounced]: "bg-destructive/10 text-destructive",
[ChequePageIssueStatus.Cancelled]: "bg-destructive/10 text-destructive",
[ChequePageIssueStatus.Void]: "bg-muted text-muted-foreground",
}
export default function ChequeBookDetailPage() {
const params = useParams<{ chequeBookNo: string }>()
const chequeBookNo = decodeURIComponent(params.chequeBookNo)
const [book, setBook] = useState<ChequeBook | null>(null)
const [bankAccount, setBankAccount] = useState<CashAndBankAccountDto | null>(null)
const [error, setError] = useState<string | null>(null)
const [selectedPage, setSelectedPage] = useState<ChequePage | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
useEffect(() => {
let cancelled = false
chequeBooksApi
.get(chequeBookNo, true)
.then((res) => {
if (cancelled) return
setBook(res)
return bankAccountsApi.list(CashBankAccountType.Bank).then((banks) => {
if (cancelled) return
setBankAccount(banks.find((b) => b.accountId === res.bankAccountId) ?? null)
})
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
return () => {
cancelled = true
}
}, [chequeBookNo])
function handlePageUpdated(updated: ChequePage) {
setBook((prev) => (prev ? { ...prev, pages: prev.pages.map((p) => (p.chequeNo === updated.chequeNo ? updated : p)) } : prev))
setSelectedPage(updated)
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/accounts/cheque-books" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Cheque Book {chequeBookNo}</h1>
<p className="text-base text-muted-foreground">Every leaf in this book click one to view details or take an action.</p>
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && book === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && book !== null && (
<>
<div className="grid grid-cols-2 gap-4 rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5 sm:grid-cols-4">
<div>
<p className="text-sm text-muted-foreground">Bank account</p>
<p className="text-base font-medium">{bankAccount ? bankAccount.accountName : `#${book.bankAccountId}`}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Branch</p>
<p className="text-base font-medium">#{book.branchId}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Range</p>
<p className="text-base font-medium">
{book.startChequeNo} {book.endChequeNo}
</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Received</p>
<p className="text-base font-medium">{formatReportDate(book.receivedDate)}</p>
</div>
</div>
{book.pages.length === 0 ? (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<BookText className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No pages found for this book.</p>
</div>
) : (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Cheque no.</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Payee</TableHead>
<TableHead className="h-12 px-3 text-sm">Issue date</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{book.pages.map((p) => (
<TableRow
key={p.chequeNo}
className="cursor-pointer"
onClick={() => {
setSelectedPage(p)
setDialogOpen(true)
}}
>
<TableCell className="px-3 py-3.5 font-medium">{p.chequeNo}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[p.issueStatus])}>
{p.issueStatus}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">{p.payeeName ?? "—"}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(p.issueDate)}</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">
{p.amount !== null ? formatAmount(p.amount) : "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</>
)}
<ChequePageDialog page={selectedPage} open={dialogOpen} onOpenChange={setDialogOpen} onUpdated={handlePageUpdated} />
</div>
)
}
@@ -0,0 +1,216 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft } from "lucide-react"
import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { validateChequeBookForm } from "@/lib/validations/general-ledger"
import { cn } from "@/lib/utils"
import { CashAndBankAccountDto, CashBankAccountType } from "@/types/general-ledger"
import { Button, buttonVariants } from "@/components/ui/button"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { toast } from "@/components/ui/toast"
export default function NewChequeBookPage() {
const router = useRouter()
const [bankAccounts, setBankAccounts] = useState<CashAndBankAccountDto[] | null>(null)
const [bankAccountsError, setBankAccountsError] = useState<string | null>(null)
const [branchId, setBranchId] = useState("")
const [bankAccountId, setBankAccountId] = useState("")
const [chequeBookNo, setChequeBookNo] = useState("")
const [startChequeNo, setStartChequeNo] = useState("")
const [endChequeNo, setEndChequeNo] = useState("")
const [totalLeaves, setTotalLeaves] = useState("")
const [receivedDate, setReceivedDate] = useState("")
const [description, setDescription] = useState("")
const [createdBy, setCreatedBy] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
// Cheque books can only be tied to a real bank account — GL's own module note (§5.8) says
// statement import/reconcile, and by extension cheque books, are bank_account-only, never cash_account.
useEffect(() => {
bankAccountsApi
.list(CashBankAccountType.Bank)
.then(setBankAccounts)
.catch((err) => setBankAccountsError(errorMessage(err)))
}, [])
async function handleSubmit() {
const nextErrors = validateChequeBookForm({
branchId,
bankAccountId,
chequeBookNo,
startChequeNo,
endChequeNo,
totalLeaves,
receivedDate,
})
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const created = await chequeBooksApi.create({
branchId: Number(branchId),
bankAccountId: Number(bankAccountId),
chequeBookNo,
startChequeNo,
endChequeNo,
totalLeaves: Number(totalLeaves),
receivedDate,
description: description || undefined,
createdBy: createdBy || undefined,
})
toast.success("Cheque book created", `${created.chequeBookNo}${created.totalLeaves} leaves`)
router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(created.chequeBookNo)}`)
} catch (err) {
toast.error("Could not create cheque book", errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/accounts/cheque-books" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">New Cheque Book</h1>
<p className="text-base text-muted-foreground">
Every leaf from the start to end cheque number is generated automatically, all &quot;Unused&quot;.
</p>
</div>
</div>
{bankAccountsError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{bankAccountsError}</div>
)}
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<div className="grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2 lg:grid-cols-3">
<Field data-invalid={!!errors.bankAccountId}>
<FieldLabel htmlFor="cb-bank">Bank account</FieldLabel>
<Select<string> value={bankAccountId} onValueChange={(v) => setBankAccountId(v ?? "")}>
<SelectTrigger id="cb-bank" className="w-full text-base" aria-invalid={!!errors.bankAccountId}>
<SelectValue placeholder={bankAccounts === null ? "Loading…" : "Select a bank account"} />
</SelectTrigger>
<SelectContent>
{(bankAccounts ?? []).map((a) => (
<SelectItem key={a.accountId} value={String(a.accountId)} label={a.accountName} className="text-base">
{a.accountName}
{a.bankName ? `${a.bankName}` : ""}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.bankAccountId ? { message: errors.bankAccountId } : undefined]} />
</Field>
<Field data-invalid={!!errors.branchId}>
<FieldLabel htmlFor="cb-branch">Branch ID</FieldLabel>
<Input
id="cb-branch"
type="number"
value={branchId}
onChange={(e) => setBranchId(e.target.value)}
placeholder="1"
aria-invalid={!!errors.branchId}
/>
<FieldError errors={[errors.branchId ? { message: errors.branchId } : undefined]} />
</Field>
<Field data-invalid={!!errors.chequeBookNo}>
<FieldLabel htmlFor="cb-no">Cheque book number</FieldLabel>
<Input
id="cb-no"
value={chequeBookNo}
onChange={(e) => setChequeBookNo(e.target.value)}
placeholder="CB-0001"
aria-invalid={!!errors.chequeBookNo}
/>
<FieldError errors={[errors.chequeBookNo ? { message: errors.chequeBookNo } : undefined]} />
</Field>
<Field data-invalid={!!errors.startChequeNo}>
<FieldLabel htmlFor="cb-start">Start cheque no.</FieldLabel>
<Input
id="cb-start"
value={startChequeNo}
onChange={(e) => setStartChequeNo(e.target.value)}
placeholder="000001"
aria-invalid={!!errors.startChequeNo}
/>
<FieldError errors={[errors.startChequeNo ? { message: errors.startChequeNo } : undefined]} />
</Field>
<Field data-invalid={!!errors.endChequeNo}>
<FieldLabel htmlFor="cb-end">End cheque no.</FieldLabel>
<Input
id="cb-end"
value={endChequeNo}
onChange={(e) => setEndChequeNo(e.target.value)}
placeholder="000025"
aria-invalid={!!errors.endChequeNo}
/>
<FieldError errors={[errors.endChequeNo ? { message: errors.endChequeNo } : undefined]} />
</Field>
<Field data-invalid={!!errors.totalLeaves}>
<FieldLabel htmlFor="cb-leaves">Total leaves</FieldLabel>
<Input
id="cb-leaves"
type="number"
value={totalLeaves}
onChange={(e) => setTotalLeaves(e.target.value)}
placeholder="25"
aria-invalid={!!errors.totalLeaves}
/>
<p className="text-sm text-muted-foreground">Must equal end start + 1.</p>
<FieldError errors={[errors.totalLeaves ? { message: errors.totalLeaves } : undefined]} />
</Field>
<Field data-invalid={!!errors.receivedDate}>
<FieldLabel htmlFor="cb-received">Received date</FieldLabel>
<Input
id="cb-received"
type="date"
value={receivedDate}
onChange={(e) => setReceivedDate(e.target.value)}
aria-invalid={!!errors.receivedDate}
/>
<FieldError errors={[errors.receivedDate ? { message: errors.receivedDate } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="cb-desc">Description (optional)</FieldLabel>
<Input id="cb-desc" value={description} onChange={(e) => setDescription(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="cb-by">Created by (optional)</FieldLabel>
<Input id="cb-by" value={createdBy} onChange={(e) => setCreatedBy(e.target.value)} />
</Field>
</div>
<div className="mt-6 flex justify-end gap-3">
<Link href="/dashboard/accounts/cheque-books" className={cn(buttonVariants({ variant: "outline" }), "min-w-36")}>
Cancel
</Link>
<Button className="min-w-36" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Creating…" : "Create"}
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,154 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft, BookText, Plus } from "lucide-react"
import { bankAccountsApi, chequeBooksApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { formatReportDate } from "@/lib/format"
import { cn } from "@/lib/utils"
import { CashAndBankAccountDto, CashBankAccountType, ChequeBook, ChequeBookStatus } from "@/types/general-ledger"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
type StatusFilter = ChequeBookStatus | "All"
const STATUS_BADGE: Record<ChequeBookStatus, string> = {
[ChequeBookStatus.Active]: "bg-success/10 text-success",
[ChequeBookStatus.Completed]: "bg-primary/10 text-primary",
[ChequeBookStatus.Cancelled]: "bg-destructive/10 text-destructive",
}
export default function ChequeBooksPage() {
const router = useRouter()
const [books, setBooks] = useState<ChequeBook[] | null>(null)
const [bankAccounts, setBankAccounts] = useState<CashAndBankAccountDto[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<StatusFilter>("All")
useEffect(() => {
let cancelled = false
Promise.all([
chequeBooksApi.list(status === "All" ? undefined : { status }),
bankAccounts ? Promise.resolve(bankAccounts) : bankAccountsApi.list(CashBankAccountType.Bank),
])
.then(([result, banks]) => {
if (cancelled) return
setBooks(result.items)
setBankAccounts(banks)
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
return () => {
cancelled = true
}
// bankAccounts intentionally excluded — fetched once, reused across status re-fetches.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status])
const bankAccountsById = useMemo(() => new Map((bankAccounts ?? []).map((a) => [a.accountId, a])), [bankAccounts])
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Cheque Books</h1>
<p className="text-base text-muted-foreground">Cheque books issued from this company&apos;s own supply.</p>
</div>
</div>
<Link href="/dashboard/accounts/cheque-books/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Cheque Book
</Link>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-12! w-full text-base sm:w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" label="All statuses" className="text-base">All statuses</SelectItem>
<SelectItem value={ChequeBookStatus.Active} label="Active" className="text-base">Active</SelectItem>
<SelectItem value={ChequeBookStatus.Completed} label="Completed" className="text-base">Completed</SelectItem>
<SelectItem value={ChequeBookStatus.Cancelled} label="Cancelled" className="text-base">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && books === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && books !== null && books.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<BookText className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No cheque books yet.</p>
</div>
)}
{!error && books !== null && books.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Cheque book no.</TableHead>
<TableHead className="h-12 px-3 text-sm">Bank account</TableHead>
<TableHead className="h-12 px-3 text-sm">Branch</TableHead>
<TableHead className="h-12 px-3 text-sm">Range</TableHead>
<TableHead className="h-12 px-3 text-sm">Leaves</TableHead>
<TableHead className="h-12 px-3 text-sm">Received</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{books.map((b) => {
const bank = bankAccountsById.get(b.bankAccountId)
return (
<TableRow
key={b.chequeBookNo}
className="cursor-pointer"
onClick={() => router.push(`/dashboard/accounts/cheque-books/${encodeURIComponent(b.chequeBookNo)}`)}
>
<TableCell className="px-3 py-3.5 font-medium">{b.chequeBookNo}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">
{bank ? bank.accountName : `#${b.bankAccountId}`}
</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">#{b.branchId}</TableCell>
<TableCell className="px-3 py-3.5">
{b.startChequeNo} {b.endChequeNo}
</TableCell>
<TableCell className="px-3 py-3.5">{b.totalLeaves}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(b.receivedDate)}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[b.status])}>
{b.status}
</Badge>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</div>
)
}
@@ -0,0 +1,58 @@
import Link from "next/link"
import { BookText, Inbox, Wallet, type LucideIcon } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
const areas: { title: string; description: string; href: string; icon: LucideIcon }[] = [
{
title: "Cash / Bank Accounts",
description: "Cash and Bank accounts linked to a GL account — list, create, and reconcile against them.",
href: "/dashboard/accounts/bank-accounts",
icon: Wallet,
},
{
title: "Cheque Books",
description: "Cheque books issued from this companys own supply — issue, clear, bounce, cancel or void a leaf.",
href: "/dashboard/accounts/cheque-books",
icon: BookText,
},
{
title: "Received Cheques",
description: "Cheques received from customers/suppliers — deposit, clear, return, or cancel.",
href: "/dashboard/accounts/received-cheques",
icon: Inbox,
},
]
export default function AccountsHubPage() {
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Accounts</h1>
<p className="text-base text-muted-foreground">
Cash/Bank accounts and cheque management, from the General Ledger service.
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{areas.map((area) => (
<Link key={area.href} href={area.href}>
<Card className="h-full transition-shadow hover:shadow-md">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
<area.icon className="size-5" />
</div>
<CardTitle className="text-lg">{area.title}</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="text-base text-muted-foreground">{area.description}</p>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
)
}
@@ -0,0 +1,215 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { ArrowLeft } from "lucide-react"
import { receivedChequesApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { validateReceivedChequeForm } from "@/lib/validations/general-ledger"
import { cn } from "@/lib/utils"
import { ReceivedFromType } from "@/types/general-ledger"
import { Button, buttonVariants } from "@/components/ui/button"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { toast } from "@/components/ui/toast"
export default function NewReceivedChequePage() {
const router = useRouter()
const [companyId, setCompanyId] = useState("")
const [branchId, setBranchId] = useState("")
const [receivedFromType, setReceivedFromType] = useState<ReceivedFromType>(ReceivedFromType.Customer)
const [receivedFromId, setReceivedFromId] = useState("")
const [receivedFromName, setReceivedFromName] = useState("")
const [drawerBankName, setDrawerBankName] = useState("")
const [drawerBankBranch, setDrawerBankBranch] = useState("")
const [accountHolderName, setAccountHolderName] = useState("")
const [chequeNo, setChequeNo] = useState("")
const [chequeDate, setChequeDate] = useState("")
const [amount, setAmount] = useState("")
const [receivedDate, setReceivedDate] = useState("")
const [referenceType, setReferenceType] = useState("")
const [referenceId, setReferenceId] = useState("")
const [notes, setNotes] = useState("")
const [createdBy, setCreatedBy] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
async function handleSubmit() {
const nextErrors = validateReceivedChequeForm({ companyId, receivedFromName, chequeNo, chequeDate, amount, receivedDate })
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const created = await receivedChequesApi.create({
companyId: Number(companyId),
branchId: branchId ? Number(branchId) : undefined,
receivedFromType,
receivedFromId: receivedFromId ? Number(receivedFromId) : undefined,
receivedFromName,
drawerBankName: drawerBankName || undefined,
drawerBankBranch: drawerBankBranch || undefined,
accountHolderName: accountHolderName || undefined,
chequeNo,
chequeDate,
amount: Number(amount),
receivedDate,
referenceType: referenceType || undefined,
referenceId: referenceId ? Number(referenceId) : undefined,
notes: notes || undefined,
createdBy: createdBy || undefined,
})
toast.success("Received cheque recorded", `${created.chequeNo}${created.receivedFromName}`)
router.push("/dashboard/accounts/received-cheques")
} catch (err) {
toast.error("Could not record received cheque", errorMessage(err))
} finally {
setSubmitting(false)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/accounts/received-cheques" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">New Received Cheque</h1>
<p className="text-base text-muted-foreground">Record a cheque received from a customer, supplier, or other party.</p>
</div>
</div>
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<div className="grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2 lg:grid-cols-3">
<Field data-invalid={!!errors.companyId}>
<FieldLabel htmlFor="rc-company">Company ID</FieldLabel>
<Input
id="rc-company"
type="number"
value={companyId}
onChange={(e) => setCompanyId(e.target.value)}
placeholder="1"
aria-invalid={!!errors.companyId}
/>
<FieldError errors={[errors.companyId ? { message: errors.companyId } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="rc-branch">Branch ID (optional)</FieldLabel>
<Input id="rc-branch" type="number" value={branchId} onChange={(e) => setBranchId(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="rc-from-type">Received from type</FieldLabel>
<Select<ReceivedFromType> value={receivedFromType} onValueChange={(v) => setReceivedFromType(v ?? ReceivedFromType.Customer)}>
<SelectTrigger id="rc-from-type" className="w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(ReceivedFromType).map((t) => (
<SelectItem key={t} value={t} label={t} className="text-base">
{t}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field data-invalid={!!errors.receivedFromName}>
<FieldLabel htmlFor="rc-from-name">Received from name</FieldLabel>
<Input
id="rc-from-name"
value={receivedFromName}
onChange={(e) => setReceivedFromName(e.target.value)}
aria-invalid={!!errors.receivedFromName}
/>
<FieldError errors={[errors.receivedFromName ? { message: errors.receivedFromName } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="rc-from-id">Received from ID (optional)</FieldLabel>
<Input id="rc-from-id" type="number" value={receivedFromId} onChange={(e) => setReceivedFromId(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="rc-drawer-bank">Drawer bank (optional)</FieldLabel>
<Input id="rc-drawer-bank" value={drawerBankName} onChange={(e) => setDrawerBankName(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="rc-drawer-branch">Drawer branch (optional)</FieldLabel>
<Input id="rc-drawer-branch" value={drawerBankBranch} onChange={(e) => setDrawerBankBranch(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="rc-holder">Account holder name (optional)</FieldLabel>
<Input id="rc-holder" value={accountHolderName} onChange={(e) => setAccountHolderName(e.target.value)} />
</Field>
<Field data-invalid={!!errors.chequeNo}>
<FieldLabel htmlFor="rc-cheque-no">Cheque number</FieldLabel>
<Input id="rc-cheque-no" value={chequeNo} onChange={(e) => setChequeNo(e.target.value)} aria-invalid={!!errors.chequeNo} />
<FieldError errors={[errors.chequeNo ? { message: errors.chequeNo } : undefined]} />
</Field>
<Field data-invalid={!!errors.chequeDate}>
<FieldLabel htmlFor="rc-cheque-date">Cheque date</FieldLabel>
<Input
id="rc-cheque-date"
type="date"
value={chequeDate}
onChange={(e) => setChequeDate(e.target.value)}
aria-invalid={!!errors.chequeDate}
/>
<FieldError errors={[errors.chequeDate ? { message: errors.chequeDate } : undefined]} />
</Field>
<Field data-invalid={!!errors.amount}>
<FieldLabel htmlFor="rc-amount">Amount</FieldLabel>
<Input id="rc-amount" type="number" value={amount} onChange={(e) => setAmount(e.target.value)} aria-invalid={!!errors.amount} />
<FieldError errors={[errors.amount ? { message: errors.amount } : undefined]} />
</Field>
<Field data-invalid={!!errors.receivedDate}>
<FieldLabel htmlFor="rc-received-date">Received date</FieldLabel>
<Input
id="rc-received-date"
type="date"
value={receivedDate}
onChange={(e) => setReceivedDate(e.target.value)}
aria-invalid={!!errors.receivedDate}
/>
<FieldError errors={[errors.receivedDate ? { message: errors.receivedDate } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="rc-ref-type">Reference type (optional)</FieldLabel>
<Input id="rc-ref-type" value={referenceType} onChange={(e) => setReferenceType(e.target.value)} placeholder="Invoice" />
</Field>
<Field>
<FieldLabel htmlFor="rc-ref-id">Reference ID (optional)</FieldLabel>
<Input id="rc-ref-id" type="number" value={referenceId} onChange={(e) => setReferenceId(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="rc-notes">Notes (optional)</FieldLabel>
<Input id="rc-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="rc-created-by">Created by (optional)</FieldLabel>
<Input id="rc-created-by" value={createdBy} onChange={(e) => setCreatedBy(e.target.value)} />
</Field>
</div>
<div className="mt-6 flex justify-end gap-3">
<Link href="/dashboard/accounts/received-cheques" className={cn(buttonVariants({ variant: "outline" }), "min-w-36")}>
Cancel
</Link>
<Button className="min-w-36" onClick={handleSubmit} disabled={submitting}>
{submitting ? "Recording…" : "Record"}
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,151 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Inbox, Plus } from "lucide-react"
import { receivedChequesApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate } from "@/lib/format"
import { cn } from "@/lib/utils"
import { ReceivedCheque, ReceivedChequeStatus } from "@/types/general-ledger"
import { Badge } from "@/components/ui/badge"
import { buttonVariants } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { ReceivedChequeDialog } from "@/components/accounts/ReceivedChequeDialog"
type StatusFilter = ReceivedChequeStatus | "All"
const STATUS_BADGE: Record<ReceivedChequeStatus, string> = {
[ReceivedChequeStatus.Received]: "bg-muted text-muted-foreground",
[ReceivedChequeStatus.Deposited]: "bg-primary/10 text-primary",
[ReceivedChequeStatus.Cleared]: "bg-success/10 text-success",
[ReceivedChequeStatus.Returned]: "bg-destructive/10 text-destructive",
[ReceivedChequeStatus.Cancelled]: "bg-destructive/10 text-destructive",
}
export default function ReceivedChequesPage() {
const [cheques, setCheques] = useState<ReceivedCheque[] | null>(null)
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<StatusFilter>("All")
const [selected, setSelected] = useState<ReceivedCheque | null>(null)
const [dialogOpen, setDialogOpen] = useState(false)
useEffect(() => {
let cancelled = false
receivedChequesApi
.list(status === "All" ? undefined : { status })
.then((res) => {
if (!cancelled) setCheques(res.items)
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
return () => {
cancelled = true
}
}, [status])
function handleUpdated(updated: ReceivedCheque) {
setCheques((prev) => (prev ? prev.map((c) => (c.receivedChequeId === updated.receivedChequeId ? updated : c)) : prev))
setSelected(updated)
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/accounts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Received Cheques</h1>
<p className="text-base text-muted-foreground">Cheques received from customers, suppliers, or others.</p>
</div>
</div>
<Link href="/dashboard/accounts/received-cheques/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Received Cheque
</Link>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-12! w-full text-base sm:w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" label="All statuses" className="text-base">All statuses</SelectItem>
<SelectItem value={ReceivedChequeStatus.Received} label="Received" className="text-base">Received</SelectItem>
<SelectItem value={ReceivedChequeStatus.Deposited} label="Deposited" className="text-base">Deposited</SelectItem>
<SelectItem value={ReceivedChequeStatus.Cleared} label="Cleared" className="text-base">Cleared</SelectItem>
<SelectItem value={ReceivedChequeStatus.Returned} label="Returned" className="text-base">Returned</SelectItem>
<SelectItem value={ReceivedChequeStatus.Cancelled} label="Cancelled" className="text-base">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && cheques === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && cheques !== null && cheques.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Inbox className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No received cheques yet.</p>
</div>
)}
{!error && cheques !== null && cheques.length > 0 && (
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Cheque no.</TableHead>
<TableHead className="h-12 px-3 text-sm">Received from</TableHead>
<TableHead className="h-12 px-3 text-sm">Type</TableHead>
<TableHead className="h-12 px-3 text-sm text-right">Amount</TableHead>
<TableHead className="h-12 px-3 text-sm">Received</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{cheques.map((c) => (
<TableRow
key={c.receivedChequeId}
className="cursor-pointer"
onClick={() => {
setSelected(c)
setDialogOpen(true)
}}
>
<TableCell className="px-3 py-3.5 font-medium">{c.chequeNo}</TableCell>
<TableCell className="px-3 py-3.5">{c.receivedFromName}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{c.receivedFromType}</TableCell>
<TableCell className="px-3 py-3.5 text-right tabular-nums">{formatAmount(c.amount)}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">{formatReportDate(c.receivedDate)}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[c.status])}>
{c.status}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
<ReceivedChequeDialog cheque={selected} open={dialogOpen} onOpenChange={setDialogOpen} onUpdated={handleUpdated} />
</div>
)
}
@@ -0,0 +1,156 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Landmark } from "lucide-react"
import { reportsApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { formatReportDate, todayIso } from "@/lib/format"
import { cn } from "@/lib/utils"
import { BalanceSheetResponse, BalanceSheetSection, ReportType } from "@/types/general-ledger"
import { buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { DownloadCsvButton } from "@/components/reports/DownloadCsvButton"
import { DownloadPdfButton } from "@/components/reports/DownloadPdfButton"
import { ReportHeader } from "@/components/reports/ReportHeader"
import { ReportSection, ReportSectionLine } from "@/components/reports/ReportSection"
import { ReportSubtotal } from "@/components/reports/ReportSubtotal"
// GL's leaf rows carry `accountCode` too, but a classified Statement of Financial Position
// (matching the reference template this screen follows) shows plain line-item names only, no
// codes — same convention as the Balance Sheet's account-code-free presentation elsewhere.
function sectionLines(section?: BalanceSheetSection): ReportSectionLine[] {
return (section?.lines ?? []).map((l) => ({ label: l.accountName, amount: l.balance }))
}
export default function BalanceSheetPage() {
const [asOfDate, setAsOfDate] = useState(todayIso())
const [report, setReport] = useState<BalanceSheetResponse | null>(null)
const [error, setError] = useState<string | null>(null)
// Clears stale results the instant asOfDate changes, during the render that reacts to it —
// not inside the effect below, which would be a synchronous setState-in-effect
// (react-hooks/set-state-in-effect); this is React's own sanctioned "adjust state during
// render" pattern instead.
const [loadedFor, setLoadedFor] = useState<string | null>(null)
if (loadedFor !== asOfDate) {
setLoadedFor(asOfDate)
setReport(null)
setError(null)
}
useEffect(() => {
if (!asOfDate) return
let cancelled = false
reportsApi
.balanceSheet(asOfDate)
.then((res) => {
if (!cancelled) setReport(res)
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
return () => {
cancelled = true
}
}, [asOfDate])
const isEmpty =
report !== null &&
(report.nonCurrentAssets?.lines?.length ?? 0) === 0 &&
(report.currentAssets?.lines?.length ?? 0) === 0 &&
(report.unclassifiedAssets?.lines?.length ?? 0) === 0 &&
(report.equity?.lines?.length ?? 0) === 0 &&
(report.nonCurrentLiabilities?.lines?.length ?? 0) === 0 &&
(report.currentLiabilities?.lines?.length ?? 0) === 0 &&
(report.unclassifiedLiabilities?.lines?.length ?? 0) === 0
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/ledgers" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Balance Sheet</h1>
<p className="text-base text-muted-foreground">Statement of Financial Position Assets, Liabilities, Equity.</p>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="flex flex-col gap-1.5">
<Label className="text-base">As at</Label>
<Input type="date" value={asOfDate} onChange={(e) => setAsOfDate(e.target.value)} className="h-11 w-full text-base sm:w-60" />
</div>
<div className="flex gap-2">
<DownloadPdfButton reportType={ReportType.BalanceSheet} params={{ asOfDate }} disabled={!report} />
<DownloadCsvButton reportType={ReportType.BalanceSheet} params={{ asOfDate }} disabled={!report} />
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && report === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)}
{!error && isEmpty && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Landmark className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No Asset/Liability/Equity accounts as at this date.</p>
</div>
)}
{!error && report !== null && !isEmpty && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<ReportHeader title="Statement of Financial Position" subtitle={`As at ${formatReportDate(asOfDate)}`} />
<div className="mt-4">
<p className="mb-2 text-sm font-bold tracking-wide text-foreground uppercase">Assets</p>
<ReportSection
title="Non-Current Assets"
lines={sectionLines(report.nonCurrentAssets)}
total={report.nonCurrentAssets?.total}
/>
<ReportSection title="Current Assets" lines={sectionLines(report.currentAssets)} total={report.currentAssets?.total} />
<ReportSection
title="Unclassified Assets"
lines={sectionLines(report.unclassifiedAssets)}
total={report.unclassifiedAssets?.total}
/>
<ReportSubtotal label="Total Assets" amount={report.totalAssets} large />
<p className="mt-6 mb-2 text-sm font-bold tracking-wide text-foreground uppercase">Equity and Liabilities</p>
<ReportSection title="Equity" lines={sectionLines(report.equity)} total={report.equity?.total} />
<ReportSection
title="Non-Current Liabilities"
lines={sectionLines(report.nonCurrentLiabilities)}
total={report.nonCurrentLiabilities?.total}
/>
<ReportSection
title="Current Liabilities"
lines={sectionLines(report.currentLiabilities)}
total={report.currentLiabilities?.total}
/>
<ReportSection
title="Unclassified Liabilities"
lines={sectionLines(report.unclassifiedLiabilities)}
total={report.unclassifiedLiabilities?.total}
/>
<ReportSubtotal label="Total Equity and Liabilities" amount={report.totalEquityAndLiabilities} large />
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,179 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ArrowLeft, BadgeDollarSign } from "lucide-react"
import { glBudgetsApi, reportsApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { formatAmount } from "@/lib/format"
import { cn } from "@/lib/utils"
import { BudgetVsActualRow, GlBudget, ReportType } from "@/types/general-ledger"
import { buttonVariants } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { DownloadCsvButton } from "@/components/reports/DownloadCsvButton"
import { DownloadPdfButton } from "@/components/reports/DownloadPdfButton"
import { ReportHeader } from "@/components/reports/ReportHeader"
export default function BudgetVsActualPage() {
const [budgets, setBudgets] = useState<GlBudget[] | null>(null)
const [budgetsError, setBudgetsError] = useState<string | null>(null)
const [budgetId, setBudgetId] = useState<number | "">("")
const [rows, setRows] = useState<BudgetVsActualRow[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
glBudgetsApi
.list()
.then((res) => setBudgets(res))
.catch((err) => setBudgetsError(errorMessage(err)))
}, [])
// Clears stale results the instant budgetId changes, during the render that reacts to it —
// not inside the effect below, which would be a synchronous setState-in-effect
// (react-hooks/set-state-in-effect); this is React's own sanctioned "adjust state during
// render" pattern instead.
const [loadedFor, setLoadedFor] = useState<number | "">("")
if (loadedFor !== budgetId) {
setLoadedFor(budgetId)
setRows(null)
setError(null)
}
useEffect(() => {
if (!budgetId) return
let cancelled = false
reportsApi
.budgetVsActual(budgetId)
.then((res) => {
if (!cancelled) setRows(res)
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
return () => {
cancelled = true
}
}, [budgetId])
const selectedBudget = useMemo(() => (budgets ?? []).find((b) => b.budgetId === budgetId) ?? null, [budgets, budgetId])
const totalBudgeted = (rows ?? []).reduce((sum, r) => sum + r.budgetedAmount, 0)
const totalActual = (rows ?? []).reduce((sum, r) => sum + r.actualAmount, 0)
const totalVariance = (rows ?? []).reduce((sum, r) => sum + r.variance, 0)
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/ledgers" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Budget vs Actual</h1>
<p className="text-base text-muted-foreground">Budgeted amounts against real postings per account/period.</p>
</div>
</div>
{budgetsError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{budgetsError}</div>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="flex flex-col gap-1.5 sm:w-96">
<Label className="text-base">Budget</Label>
<Select<number | ""> value={budgetId} onValueChange={(v) => setBudgetId(v ?? "")}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder={budgets === null ? "Loading…" : "Select a budget"} />
</SelectTrigger>
<SelectContent>
{(budgets ?? []).map((b) => (
<SelectItem key={b.budgetId} value={b.budgetId} label={b.name} className="text-base">
{b.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex gap-2">
<DownloadPdfButton reportType={ReportType.BudgetVsActual} params={{ budgetId: budgetId || undefined }} disabled={!rows} />
<DownloadCsvButton reportType={ReportType.BudgetVsActual} params={{ budgetId: budgetId || undefined }} disabled={!rows} />
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && !budgetId && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<BadgeDollarSign className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">Select a budget to compare against actuals.</p>
</div>
)}
{!error && budgetId && rows === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)}
{!error && budgetId && rows !== null && rows.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<BadgeDollarSign className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">This budget has no lines yet.</p>
</div>
)}
{!error && budgetId && rows !== null && rows.length > 0 && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<ReportHeader title="Budget vs Actual" subtitle={selectedBudget?.name ?? ""} />
<Table className="mt-4 text-base">
<TableHeader>
<TableRow>
<TableHead className="h-11 px-3 text-sm">Account</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Budgeted</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Actual</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Variance</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.budgetLineId}>
<TableCell className="px-3 py-2.5">
<span className="text-muted-foreground">{row.accountCode}</span>{" "}
<span>{row.accountName}</span>
</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.budgetedAmount)}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.actualAmount)}</TableCell>
<TableCell
className={cn(
"px-3 py-2.5 text-right font-medium tabular-nums",
row.variance < 0 ? "text-destructive" : "text-success"
)}
>
{formatAmount(row.variance)}
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter className="border-t-2 border-foreground/70 bg-transparent">
<TableRow className="hover:bg-transparent">
<TableCell className="px-3 py-3 font-bold">Total</TableCell>
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalBudgeted)}</TableCell>
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalActual)}</TableCell>
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalVariance)}</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
)}
</div>
)
}
@@ -0,0 +1,184 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Wallet } from "lucide-react"
import { reportsApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { formatReportDate, startOfMonthIso, todayIso } from "@/lib/format"
import { cn } from "@/lib/utils"
import {
CashFlowFinancingActivities,
CashFlowInvestingActivities,
CashFlowResponse,
ReportType,
} from "@/types/general-ledger"
import { buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { DownloadCsvButton } from "@/components/reports/DownloadCsvButton"
import { DownloadPdfButton } from "@/components/reports/DownloadPdfButton"
import { ReportHeader } from "@/components/reports/ReportHeader"
import { ReportSection, ReportSectionLine } from "@/components/reports/ReportSection"
import { ReportSubtotal } from "@/components/reports/ReportSubtotal"
/**
* Combines `operatingActivities.nonCashAdjustments` + `.workingCapitalChanges` into one labeled
* set, then buckets by sign per docs/21-GENERAL-LEDGER-FRONTEND.md §3: "Additions to Cash"
* (amount >= 0) and "Subtractions From Cash" (amount < 0). A working-capital line's label uses its
* `direction` field ("Decrease in Trade Receivables"); a non-cash-adjustment line just prints its
* plain `description` ("Depreciation"), no Increase/Decrease prefix.
*/
function bucketOperatingLines(report: CashFlowResponse): { additions: ReportSectionLine[]; subtractions: ReportSectionLine[] } {
// GL omits these list fields entirely (rather than sending `[]`) when there's nothing to
// report for the period, instead of an empty array — confirmed live, not just a type-safety guard.
const operating = report.operatingActivities
const combined: ReportSectionLine[] = [
...(operating?.nonCashAdjustments ?? []).map((a) => ({ label: a.description, amount: a.amount })),
...(operating?.workingCapitalChanges ?? []).map((w) => ({
label: `${w.direction} in ${w.accountName}`,
amount: w.changeAmount,
})),
]
return {
additions: combined.filter((l) => l.amount >= 0),
subtractions: combined.filter((l) => l.amount < 0),
}
}
/** Same "GL omits empty list fields" defense as bucketOperatingLines the section itself,
* or just its `lines[]`, may be missing entirely rather than `{ lines: [], ...: 0 }`. */
function activitySectionLines(
section?: CashFlowInvestingActivities | CashFlowFinancingActivities | null
): ReportSectionLine[] {
return (section?.lines ?? []).map((l) => ({ label: l.description, amount: l.amount }))
}
export default function CashFlowPage() {
const [periodStart, setPeriodStart] = useState(startOfMonthIso())
const [periodEnd, setPeriodEnd] = useState(todayIso())
const [report, setReport] = useState<CashFlowResponse | null>(null)
const [error, setError] = useState<string | null>(null)
// Clears stale results the instant the period changes, during the render that reacts to it —
// not inside the effect below, which would be a synchronous setState-in-effect
// (react-hooks/set-state-in-effect); this is React's own sanctioned "adjust state during
// render" pattern instead.
const periodKey = `${periodStart}|${periodEnd}`
const [loadedFor, setLoadedFor] = useState<string | null>(null)
if (loadedFor !== periodKey) {
setLoadedFor(periodKey)
setReport(null)
setError(null)
}
useEffect(() => {
if (!periodStart || !periodEnd) return
let cancelled = false
reportsApi
.cashFlow(periodStart, periodEnd)
.then((res) => {
if (!cancelled) setReport(res)
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
return () => {
cancelled = true
}
}, [periodStart, periodEnd])
const buckets = report ? bucketOperatingLines(report) : null
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/ledgers" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Cash Flow</h1>
<p className="text-base text-muted-foreground">Statement of Cash Flows for a period.</p>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="grid grid-cols-2 gap-3 sm:flex sm:items-end">
<div className="flex flex-col gap-1.5">
<Label className="text-base">From</Label>
<Input type="date" value={periodStart} onChange={(e) => setPeriodStart(e.target.value)} className="h-11 text-base" />
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-base">To</Label>
<Input type="date" value={periodEnd} onChange={(e) => setPeriodEnd(e.target.value)} className="h-11 text-base" />
</div>
</div>
<div className="flex gap-2">
<DownloadPdfButton reportType={ReportType.CashFlow} params={{ periodStart, periodEnd }} disabled={!report} />
<DownloadCsvButton reportType={ReportType.CashFlow} params={{ periodStart, periodEnd }} disabled={!report} />
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && report === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)}
{!error && report !== null && buckets !== null && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<ReportHeader
title="Statement of Cash Flows"
subtitle={`For the period ${formatReportDate(periodStart)} to ${formatReportDate(periodEnd)}`}
/>
<div className="mt-4">
<ReportSubtotal label="Net Earnings" amount={report.operatingActivities?.profitForPeriod ?? 0} />
<ReportSection title="Additions to Cash" lines={buckets.additions} />
<ReportSection title="Subtractions From Cash" lines={buckets.subtractions} />
<ReportSubtotal
label="Net Cash From Operations"
amount={report.operatingActivities?.netCashFromOperatingActivities ?? 0}
/>
<ReportSection
title="Investing Activities"
lines={activitySectionLines(report.investingActivities)}
total={
(report.investingActivities?.lines?.length ?? 0) > 1
? report.investingActivities?.netCashFromInvestingActivities
: undefined
}
/>
<ReportSection
title="Financing Activities"
lines={activitySectionLines(report.financingActivities)}
total={
(report.financingActivities?.lines?.length ?? 0) > 1
? report.financingActivities?.netCashFromFinancingActivities
: undefined
}
/>
<ReportSubtotal label="Net Increase / Decrease in Cash" amount={report.netIncreaseDecreaseInCash} large />
</div>
<p className="mt-2 flex items-center gap-1.5 text-sm text-muted-foreground">
<Wallet className="size-4" />
Opening/closing cash balances are computed by the General Ledger service but not shown on this
screen, matching GL&apos;s own PDF/CSV output.
</p>
</div>
)}
</div>
)
}
@@ -0,0 +1,148 @@
"use client"
import { Fragment, useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, BookOpen } from "lucide-react"
import { reportsApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate, startOfMonthIso, todayIso } from "@/lib/format"
import { cn } from "@/lib/utils"
import { GeneralLedgerRow, ReportType } from "@/types/general-ledger"
import { buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { DownloadCsvButton } from "@/components/reports/DownloadCsvButton"
import { DownloadPdfButton } from "@/components/reports/DownloadPdfButton"
import { ReportHeader } from "@/components/reports/ReportHeader"
export default function GeneralLedgerReportPage() {
const [periodStart, setPeriodStart] = useState(startOfMonthIso())
const [periodEnd, setPeriodEnd] = useState(todayIso())
const [rows, setRows] = useState<GeneralLedgerRow[] | null>(null)
const [error, setError] = useState<string | null>(null)
// Clears stale results the instant the period changes, during the render that reacts to it —
// not inside the effect below, which would be a synchronous setState-in-effect
// (react-hooks/set-state-in-effect); this is React's own sanctioned "adjust state during
// render" pattern instead.
const periodKey = `${periodStart}|${periodEnd}`
const [loadedFor, setLoadedFor] = useState<string | null>(null)
if (loadedFor !== periodKey) {
setLoadedFor(periodKey)
setRows(null)
setError(null)
}
useEffect(() => {
if (!periodStart || !periodEnd) return
let cancelled = false
reportsApi
.generalLedger(periodStart, periodEnd)
.then((res) => {
if (!cancelled) setRows(res)
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
return () => {
cancelled = true
}
}, [periodStart, periodEnd])
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/ledgers" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">General Ledger</h1>
<p className="text-base text-muted-foreground">Every posted movement on every account, with a running balance per account.</p>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="grid grid-cols-2 gap-3 sm:flex sm:items-end">
<div className="flex flex-col gap-1.5">
<Label className="text-base">From</Label>
<Input type="date" value={periodStart} onChange={(e) => setPeriodStart(e.target.value)} className="h-11 text-base" />
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-base">To</Label>
<Input type="date" value={periodEnd} onChange={(e) => setPeriodEnd(e.target.value)} className="h-11 text-base" />
</div>
</div>
<div className="flex gap-2">
<DownloadPdfButton reportType={ReportType.GeneralLedger} params={{ periodStart, periodEnd }} disabled={!rows} />
<DownloadCsvButton reportType={ReportType.GeneralLedger} params={{ periodStart, periodEnd }} disabled={!rows} />
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && rows === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)}
{!error && rows !== null && rows.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<BookOpen className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No postings on any account in the selected period.</p>
</div>
)}
{!error && rows !== null && rows.length > 0 && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<ReportHeader title="General Ledger" subtitle={`${formatReportDate(periodStart)} to ${formatReportDate(periodEnd)}`} />
<Table className="mt-4 text-base">
<TableHeader>
<TableRow>
<TableHead className="h-11 px-3 text-sm">Date</TableHead>
<TableHead className="h-11 px-3 text-sm">Journal No.</TableHead>
<TableHead className="h-11 px-3 text-sm">Narration</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Debit</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Credit</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Running balance</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, i) => {
const isNewAccount = i === 0 || row.accountCode !== rows[i - 1].accountCode
return (
<Fragment key={i}>
{isNewAccount && (
<TableRow className="bg-muted/40 hover:bg-muted/40">
<TableCell colSpan={6} className="px-3 py-2 font-semibold">
{row.accountCode} {row.accountName}
</TableCell>
</TableRow>
)}
<TableRow>
<TableCell className="px-3 py-2.5">{formatReportDate(row.entryDate)}</TableCell>
<TableCell className="px-3 py-2.5 font-medium">{row.journalNo}</TableCell>
<TableCell className="px-3 py-2.5 text-muted-foreground">{row.narration ?? "—"}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debitAmount, true)}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.creditAmount, true)}</TableCell>
<TableCell className="px-3 py-2.5 text-right font-medium tabular-nums">{formatAmount(row.runningBalance)}</TableCell>
</TableRow>
</Fragment>
)
})}
</TableBody>
</Table>
</div>
)}
</div>
)
}
@@ -0,0 +1,91 @@
import Link from "next/link"
import {
BadgeDollarSign,
BookOpen,
Landmark,
LineChart,
PieChart,
Receipt,
Scale,
type LucideIcon,
} from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
const areas: { title: string; description: string; href: string; icon: LucideIcon }[] = [
{
title: "Trial Balance",
description: "Every postable account's debit/credit balance as at a given date.",
href: "/dashboard/ledgers/trial-balance",
icon: Scale,
},
{
title: "Balance Sheet",
description: "Statement of Financial Position — Assets, Liabilities and Equity as at a date.",
href: "/dashboard/ledgers/balance-sheet",
icon: Landmark,
},
{
title: "General Ledger",
description: "Every posted movement on one account across a date range, with running balance.",
href: "/dashboard/ledgers/general-ledger",
icon: BookOpen,
},
{
title: "Profit & Loss",
description: "Statement of Profit or Loss — Income and Expense for a period.",
href: "/dashboard/ledgers/profit-and-loss",
icon: LineChart,
},
{
title: "Cash Flow",
description: "Statement of Cash Flows — operating, investing and financing movement for a period.",
href: "/dashboard/ledgers/cash-flow",
icon: PieChart,
},
{
title: "Budget vs Actual",
description: "Budgeted amounts against real postings per account/period, with variance.",
href: "/dashboard/ledgers/budget-vs-actual",
icon: BadgeDollarSign,
},
{
title: "Tax Report",
description: "Income Tax Computation for a period, with optional adjustments.",
href: "/dashboard/ledgers/tax-report",
icon: Receipt,
},
]
export default function LedgersHubPage() {
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Ledgers</h1>
<p className="text-base text-muted-foreground">
Statutory-format financial reports from the General Ledger service.
</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
{areas.map((area) => (
<Link key={area.href} href={area.href}>
<Card className="h-full transition-shadow hover:shadow-md">
<CardHeader>
<div className="flex items-center gap-3">
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
<area.icon className="size-5" />
</div>
<CardTitle className="text-lg">{area.title}</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="text-base text-muted-foreground">{area.description}</p>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
)
}
@@ -0,0 +1,166 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, LineChart } from "lucide-react"
import { reportsApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { formatReportDate, startOfMonthIso, todayIso } from "@/lib/format"
import { cn } from "@/lib/utils"
import { ProfitAndLossResponse, ProfitAndLossSection, ReportType } from "@/types/general-ledger"
import { buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { DownloadCsvButton } from "@/components/reports/DownloadCsvButton"
import { DownloadPdfButton } from "@/components/reports/DownloadPdfButton"
import { ReportHeader } from "@/components/reports/ReportHeader"
import { ReportSection } from "@/components/reports/ReportSection"
import { ReportSubtotal } from "@/components/reports/ReportSubtotal"
function pnlSectionLines(section: ProfitAndLossSection | undefined) {
return (section?.lines ?? []).map((line) => ({
label: (
<>
<span className="text-muted-foreground">{line.accountCode}</span> <span>{line.accountName}</span>
</>
),
amount: line.amount,
}))
}
export default function ProfitAndLossPage() {
const [periodStart, setPeriodStart] = useState(startOfMonthIso())
const [periodEnd, setPeriodEnd] = useState(todayIso())
const [report, setReport] = useState<ProfitAndLossResponse | null>(null)
const [error, setError] = useState<string | null>(null)
// Clears stale results the instant the period changes, during the render that reacts to it —
// not inside the effect below, which would be a synchronous setState-in-effect
// (react-hooks/set-state-in-effect); this is React's own sanctioned "adjust state during
// render" pattern instead.
const periodKey = `${periodStart}|${periodEnd}`
const [loadedFor, setLoadedFor] = useState<string | null>(null)
if (loadedFor !== periodKey) {
setLoadedFor(periodKey)
setReport(null)
setError(null)
}
useEffect(() => {
if (!periodStart || !periodEnd) return
let cancelled = false
reportsApi
.profitAndLoss(periodStart, periodEnd)
.then((res) => {
if (!cancelled) setReport(res)
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
return () => {
cancelled = true
}
}, [periodStart, periodEnd])
// GL omits an empty/zero section entirely rather than sending `{ lines: [], total: 0 }` —
// confirmed on Cash Flow's equivalent fields, same inferred nested-section shape here.
const isEmpty =
report !== null &&
(report.sales?.lines?.length ?? 0) === 0 &&
(report.costOfSales?.lines?.length ?? 0) === 0 &&
(report.otherIncome?.lines?.length ?? 0) === 0 &&
(report.distributionExpenses?.lines?.length ?? 0) === 0 &&
(report.administrationExpenses?.lines?.length ?? 0) === 0 &&
(report.otherExpenses?.lines?.length ?? 0) === 0 &&
(report.financialExpenses?.lines?.length ?? 0) === 0 &&
(report.unclassified?.lines?.length ?? 0) === 0
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/ledgers" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Profit &amp; Loss</h1>
<p className="text-base text-muted-foreground">Statement of Profit or Loss Income and Expense for a period.</p>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="grid grid-cols-2 gap-3 sm:flex sm:items-end">
<div className="flex flex-col gap-1.5">
<Label className="text-base">From</Label>
<Input type="date" value={periodStart} onChange={(e) => setPeriodStart(e.target.value)} className="h-11 text-base" />
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-base">To</Label>
<Input type="date" value={periodEnd} onChange={(e) => setPeriodEnd(e.target.value)} className="h-11 text-base" />
</div>
</div>
<div className="flex gap-2">
<DownloadPdfButton reportType={ReportType.ProfitAndLoss} params={{ periodStart, periodEnd }} disabled={!report} />
<DownloadCsvButton reportType={ReportType.ProfitAndLoss} params={{ periodStart, periodEnd }} disabled={!report} />
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && report === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)}
{!error && isEmpty && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<LineChart className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No Income/Expense accounts posted in this period.</p>
</div>
)}
{!error && report !== null && !isEmpty && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<ReportHeader
title="Statement of Profit or Loss"
subtitle={`For the period ${formatReportDate(periodStart)} to ${formatReportDate(periodEnd)}`}
/>
<div className="mt-4">
<ReportSection title="Sales" lines={pnlSectionLines(report.sales)} total={report.sales?.total} />
<ReportSection title="Cost of Sales" lines={pnlSectionLines(report.costOfSales)} total={report.costOfSales?.total} />
<ReportSubtotal label="Gross Profit" amount={report.grossProfit} />
<ReportSection title="Other Income" lines={pnlSectionLines(report.otherIncome)} total={report.otherIncome?.total} />
<ReportSection
title="Distribution Expenses"
lines={pnlSectionLines(report.distributionExpenses)}
total={report.distributionExpenses?.total}
/>
<ReportSection
title="Administration Expenses"
lines={pnlSectionLines(report.administrationExpenses)}
total={report.administrationExpenses?.total}
/>
<ReportSection title="Other Expenses" lines={pnlSectionLines(report.otherExpenses)} total={report.otherExpenses?.total} />
<ReportSection
title="Financial Expenses"
lines={pnlSectionLines(report.financialExpenses)}
total={report.financialExpenses?.total}
/>
{report.unclassified && (
<ReportSection title="Unclassified" lines={pnlSectionLines(report.unclassified)} total={report.unclassified?.total} />
)}
<ReportSubtotal label="Net Profit for the Period" amount={report.netProfitForPeriod} large />
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,284 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, ChevronDown, ChevronRight, Receipt } from "lucide-react"
import { reportsApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate, startOfMonthIso, todayIso } from "@/lib/format"
import { cn } from "@/lib/utils"
import { ReportType, TaxSummaryResponse } from "@/types/general-ledger"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"
import { DownloadCsvButton } from "@/components/reports/DownloadCsvButton"
import { DownloadPdfButton } from "@/components/reports/DownloadPdfButton"
// Fixed row order + Add:/Less: labels per the confirmed GL contract
// (04_API_Reference_And_Scenarios.md, Module: Reporting — `profitBeforeTax` through
// `balanceTaxPayable`) — GL sends plain numbers, not pre-formatted rows; the prefix/bold treatment
// lives here, not derived from sign. `taxRatePercent` is shown separately below the table (it's a
// percentage, not a currency amount) rather than run through `formatAmount` here.
const ROWS: { key: keyof TaxSummaryResponse; label: string; bold?: boolean }[] = [
{ key: "profitBeforeTax", label: "Profit Before Tax" },
{ key: "nonDeductibleExpenses", label: "Add: Non-Deductible Expenses" },
{ key: "allowableDeductions", label: "Less: Allowable Deductions" },
{ key: "adjustedBusinessProfit", label: "Adjusted Business Profit", bold: true },
{ key: "otherTaxableIncome", label: "Add: Other Taxable Income" },
{ key: "assessableIncome", label: "Assessable Income", bold: true },
{ key: "qualifyingPaymentsReliefs", label: "Less: Qualifying Payments / Reliefs" },
{ key: "taxableIncome", label: "Taxable Income", bold: true },
{ key: "corporateIncomeTax", label: "Corporate Income Tax" },
{ key: "surchargeAmount", label: "Add: Surcharge / Education Levy" },
{ key: "grossTaxLiability", label: "Gross Tax Liability", bold: true },
{ key: "apitCredit", label: "Less: APIT Credit" },
{ key: "whtCredit", label: "Less: WHT Credit" },
{ key: "quarterlyTaxPayments", label: "Less: Quarterly Tax Payments" },
]
export default function TaxReportPage() {
const [periodStart, setPeriodStart] = useState(startOfMonthIso())
const [periodEnd, setPeriodEnd] = useState(todayIso())
const [adjustmentsOpen, setAdjustmentsOpen] = useState(false)
const [allowableDeductions, setAllowableDeductions] = useState("")
const [otherTaxableIncome, setOtherTaxableIncome] = useState("")
const [qualifyingPaymentsReliefs, setQualifyingPaymentsReliefs] = useState("")
const [surchargeAmount, setSurchargeAmount] = useState("")
const [taxRateOverride, setTaxRateOverride] = useState("")
const [report, setReport] = useState<TaxSummaryResponse | null>(null)
const [error, setError] = useState<string | null>(null)
// Optional inputs get no client-side default — an untouched field sends nothing (undefined,
// dropped by lib/api/general-ledger.ts's query builder), letting GL's own server-side
// defaulting be the single source of truth for what "not supplied" means.
const params = {
periodStart,
periodEnd,
allowableDeductions: allowableDeductions === "" ? undefined : Number(allowableDeductions),
otherTaxableIncome: otherTaxableIncome === "" ? undefined : Number(otherTaxableIncome),
qualifyingPaymentsReliefs: qualifyingPaymentsReliefs === "" ? undefined : Number(qualifyingPaymentsReliefs),
surchargeAmount: surchargeAmount === "" ? undefined : Number(surchargeAmount),
taxRateOverride: taxRateOverride === "" ? undefined : Number(taxRateOverride),
}
const paramsKey = JSON.stringify(params)
// Clears stale results the instant a param changes, during the render that reacts to it — not
// inside the effect below, which would be a synchronous setState-in-effect
// (react-hooks/set-state-in-effect); this is React's own sanctioned "adjust state during
// render" pattern instead.
const [loadedFor, setLoadedFor] = useState<string | null>(null)
if (loadedFor !== paramsKey) {
setLoadedFor(paramsKey)
setReport(null)
setError(null)
}
useEffect(() => {
if (!periodStart || !periodEnd) return
let cancelled = false
reportsApi
.taxSummary(params)
.then((res) => {
if (!cancelled) setReport(res)
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
return () => {
cancelled = true
}
// paramsKey covers every field inside params; re-running on params itself would compare by
// reference and fire every render, since it's a fresh object literal each time.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [paramsKey, periodStart, periodEnd])
const isRefund = report !== null && report.balanceTaxPayable < 0
const finalLabel = isRefund ? "BALANCE TAX REFUNDABLE" : "BALANCE TAX PAYABLE"
const finalAmount = report ? Math.abs(report.balanceTaxPayable) : 0
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/ledgers" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Tax Report</h1>
<p className="text-base text-muted-foreground">Income Tax Computation for a period.</p>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="grid grid-cols-2 gap-3 sm:flex sm:items-end">
<div className="flex flex-col gap-1.5">
<Label className="text-base">From</Label>
<Input type="date" value={periodStart} onChange={(e) => setPeriodStart(e.target.value)} className="h-11 text-base" />
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-base">To</Label>
<Input type="date" value={periodEnd} onChange={(e) => setPeriodEnd(e.target.value)} className="h-11 text-base" />
</div>
</div>
<div className="flex gap-2">
<DownloadPdfButton reportType={ReportType.TaxSummary} params={params} disabled={!report} />
<DownloadCsvButton reportType={ReportType.TaxSummary} params={params} disabled={!report} />
</div>
</div>
<div className="rounded-xl border">
<button
type="button"
onClick={() => setAdjustmentsOpen((v) => !v)}
className="flex w-full items-center gap-2 px-4 py-3 text-left text-base font-semibold"
>
{adjustmentsOpen ? <ChevronDown className="size-4" /> : <ChevronRight className="size-4" />}
Adjustments (optional)
</button>
{adjustmentsOpen && (
<div className="grid grid-cols-1 gap-3 border-t p-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="flex flex-col gap-1.5">
<Label className="text-sm">Allowable Deductions</Label>
<Input
type="number"
value={allowableDeductions}
onChange={(e) => setAllowableDeductions(e.target.value)}
placeholder="0"
className="h-10 text-base"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-sm">Other Taxable Income</Label>
<Input
type="number"
value={otherTaxableIncome}
onChange={(e) => setOtherTaxableIncome(e.target.value)}
placeholder="0"
className="h-10 text-base"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-sm">Qualifying Payments / Reliefs</Label>
<Input
type="number"
value={qualifyingPaymentsReliefs}
onChange={(e) => setQualifyingPaymentsReliefs(e.target.value)}
placeholder="System default"
className="h-10 text-base"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-sm">Surcharge / Education Levy</Label>
<Input
type="number"
value={surchargeAmount}
onChange={(e) => setSurchargeAmount(e.target.value)}
placeholder="0"
className="h-10 text-base"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-sm">Tax Rate Override (%)</Label>
<Input
type="number"
value={taxRateOverride}
onChange={(e) => setTaxRateOverride(e.target.value)}
placeholder="System default"
className="h-10 text-base"
/>
</div>
{(allowableDeductions ||
otherTaxableIncome ||
qualifyingPaymentsReliefs ||
surchargeAmount ||
taxRateOverride) && (
<div className="flex items-end">
<Button
variant="outline"
size="sm"
onClick={() => {
setAllowableDeductions("")
setOtherTaxableIncome("")
setQualifyingPaymentsReliefs("")
setSurchargeAmount("")
setTaxRateOverride("")
}}
>
Clear adjustments
</Button>
</div>
)}
</div>
)}
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && report === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)}
{!error && report !== null && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
{/* Own header, not the shared ReportHeader GL's own PDF gives this report a fuller
identity block (company name/address/TIN/BRN) that today lives only in GL's own
appsettings.json, with no endpoint exposing it. Deliberately not fabricated here
see docs/21-GENERAL-LEDGER-FRONTEND.md §3's open question. */}
<div className="flex flex-col items-center gap-1 border-b-2 border-foreground/70 px-4 pb-5 text-center">
<p className="text-xs font-semibold tracking-[0.2em] text-muted-foreground uppercase">
General Ledger
</p>
<h2 className="text-xl font-bold tracking-tight text-foreground uppercase">Income Tax Computation</h2>
<p className="text-base text-muted-foreground">
For the period {formatReportDate(periodStart)} to {formatReportDate(periodEnd)}
</p>
<p className="text-sm text-muted-foreground">
Company identity (name/address/TIN/BRN) isn&apos;t shown here GL exposes no endpoint for it yet.
</p>
</div>
<div className="flex items-center gap-2 py-3 text-muted-foreground">
<Receipt className="size-4" />
<p className="flex-1 text-sm">
All amounts in Sri Lankan Rupees (LKR) unless stated otherwise.
</p>
</div>
<Table className="text-base">
<TableBody>
{ROWS.map((row) => (
<TableRow key={row.key} className={row.bold ? "bg-muted/40 hover:bg-muted/40" : undefined}>
<TableCell className={cn("px-3 py-2.5", row.bold && "font-semibold")}>{row.label}</TableCell>
<TableCell className={cn("px-3 py-2.5 text-right tabular-nums", row.bold && "font-semibold")}>
{formatAmount(report[row.key] as number)}
</TableCell>
</TableRow>
))}
<TableRow className="border-t-2 border-foreground/70 bg-muted/60 hover:bg-muted/60">
<TableCell className="px-3 py-3 text-base font-bold">{finalLabel}</TableCell>
<TableCell className="px-3 py-3 text-right text-base font-bold tabular-nums">
{formatAmount(finalAmount)}
</TableCell>
</TableRow>
</TableBody>
</Table>
<p className="mt-3 text-sm text-muted-foreground">
Tax rate applied: {report.taxRatePercent}%
</p>
</div>
)}
</div>
)
}
@@ -0,0 +1,134 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Scale } from "lucide-react"
import { reportsApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate, todayIso } from "@/lib/format"
import { cn } from "@/lib/utils"
import { ReportType, TrialBalanceRow } from "@/types/general-ledger"
import { buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { DownloadCsvButton } from "@/components/reports/DownloadCsvButton"
import { DownloadPdfButton } from "@/components/reports/DownloadPdfButton"
import { ReportHeader } from "@/components/reports/ReportHeader"
export default function TrialBalancePage() {
const [asOfDate, setAsOfDate] = useState(todayIso())
const [rows, setRows] = useState<TrialBalanceRow[] | null>(null)
const [error, setError] = useState<string | null>(null)
// Clears stale results the instant asOfDate changes, during the render that reacts to it —
// not inside the effect below, which would be a synchronous setState-in-effect
// (react-hooks/set-state-in-effect); this is React's own sanctioned "adjust state during
// render" pattern instead.
const [loadedFor, setLoadedFor] = useState<string | null>(null)
if (loadedFor !== asOfDate) {
setLoadedFor(asOfDate)
setRows(null)
setError(null)
}
useEffect(() => {
if (!asOfDate) return
let cancelled = false
reportsApi
.trialBalance(asOfDate)
.then((res) => {
if (!cancelled) setRows(res)
})
.catch((err) => {
if (!cancelled) setError(errorMessage(err))
})
return () => {
cancelled = true
}
}, [asOfDate])
const totalDebit = (rows ?? []).reduce((sum, r) => sum + r.debit, 0)
const totalCredit = (rows ?? []).reduce((sum, r) => sum + r.credit, 0)
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/ledgers" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Trial Balance</h1>
<p className="text-base text-muted-foreground">Every postable account&apos;s balance as at a date.</p>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div className="flex flex-col gap-1.5">
<Label className="text-base">As at</Label>
<Input type="date" value={asOfDate} onChange={(e) => setAsOfDate(e.target.value)} className="h-11 w-full text-base sm:w-60" />
</div>
<div className="flex gap-2">
<DownloadPdfButton reportType={ReportType.TrialBalance} params={{ asOfDate }} disabled={!rows} />
<DownloadCsvButton reportType={ReportType.TrialBalance} params={{ asOfDate }} disabled={!rows} />
</div>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
)}
{!error && rows === null && (
<div className="flex flex-col gap-3">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
)}
{!error && rows !== null && rows.length === 0 && (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<Scale className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">No postable accounts as at this date.</p>
</div>
)}
{!error && rows !== null && rows.length > 0 && (
<div className="rounded-2xl bg-white p-6 shadow-sm ring-1 ring-black/5">
<ReportHeader title="Trial Balance" subtitle={`As at ${formatReportDate(asOfDate)}`} />
<Table className="mt-4 text-base">
<TableHeader>
<TableRow>
<TableHead className="h-11 px-3 text-sm">Account</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Debit</TableHead>
<TableHead className="h-11 px-3 text-right text-sm">Credit</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row, i) => (
<TableRow key={i}>
<TableCell className="px-3 py-2.5">
<span className="text-muted-foreground">{row.accountCode}</span>{" "}
<span>{row.accountName}</span>
</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.debit, true)}</TableCell>
<TableCell className="px-3 py-2.5 text-right tabular-nums">{formatAmount(row.credit, true)}</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter className="border-t-2 border-foreground/70 bg-transparent">
<TableRow className="hover:bg-transparent">
<TableCell className="px-3 py-3 font-bold">Total</TableCell>
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalDebit)}</TableCell>
<TableCell className="px-3 py-3 text-right font-bold tabular-nums">{formatAmount(totalCredit)}</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
)}
</div>
)
}
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import Link from "next/link"
import { AlertTriangle, ArrowLeft, Ban, Plus, Save, Send, Trash2 } from "lucide-react"
import { AlertTriangle, Ban, Plus, Save, Send, Trash2 } from "lucide-react"
import { isPoEditable, purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { warehousesApi } from "@/lib/api/warehouses"
@@ -269,9 +269,6 @@ export default function PurchaseOrderDetailPage() {
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/procurement/purchase-orders" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{po.docNo}</h1>
@@ -3,7 +3,7 @@
import { Suspense, useEffect, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
import { Plus, Trash2 } from "lucide-react"
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
import { requisitionsApi } from "@/lib/api/requisitions"
@@ -221,9 +221,6 @@ function NewPurchaseOrderContent() {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/procurement/purchase-orders" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">New Purchase Order</h1>
<p className="text-base text-muted-foreground">Auto-approved on creation; freely editable while open (FR-PROC-03..05).</p>
@@ -3,7 +3,7 @@
import { Suspense, useEffect, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import { ArrowLeft } from "lucide-react"
import {} from "lucide-react"
import { purchaseReturnsApi } from "@/lib/api/purchase-returns"
import { grnsApi } from "@/lib/api/grns"
@@ -156,9 +156,6 @@ function NewPurchaseReturnContent() {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/procurement/purchase-returns" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">New Purchase Return</h1>
<p className="text-base text-muted-foreground">Return received goods to the vendor; posts an outbound ledger entry immediately (FR-PROC-08).</p>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react"
import { useParams } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, FileText, Send, ShoppingCart } from "lucide-react"
import { FileText, Send, ShoppingCart } from "lucide-react"
import { requisitionsApi } from "@/lib/api/requisitions"
import { itemsApi } from "@/lib/api/items"
@@ -74,9 +74,6 @@ export default function RequisitionDetailPage() {
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/procurement/requisitions" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{requisition.docNo}</h1>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
import { Plus, Trash2 } from "lucide-react"
import { requisitionsApi } from "@/lib/api/requisitions"
import { itemsApi } from "@/lib/api/items"
@@ -103,9 +103,6 @@ export default function NewRequisitionPage() {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/procurement/requisitions" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">New Requisition</h1>
<p className="text-base text-muted-foreground">Request items for procurement; submit once the lines are ready (FR-PROC-01).</p>
@@ -3,7 +3,7 @@
import { useEffect, useMemo, useState } from "react"
import { useParams } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, ShoppingCart } from "lucide-react"
import { ShoppingCart } from "lucide-react"
import { rfqsApi } from "@/lib/api/rfqs"
import { vendorsApi } from "@/lib/api/vendors"
@@ -158,9 +158,6 @@ export default function RfqDetailPage() {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/procurement/rfqs" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{rfq.docNo}</h1>
@@ -3,7 +3,7 @@
import { Suspense, useEffect, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
import { Plus, Trash2 } from "lucide-react"
import { rfqsApi } from "@/lib/api/rfqs"
import { requisitionsApi } from "@/lib/api/requisitions"
@@ -149,9 +149,6 @@ function NewRfqContent() {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/procurement/rfqs" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">New RFQ</h1>
<p className="text-base text-muted-foreground">
@@ -20,7 +20,7 @@ import {
} from "@xyflow/react"
import "@xyflow/react/dist/style.css"
import { useTheme } from "next-themes"
import { AlertTriangle, ArrowLeft, Lock, Minus, Plus, Save, Square } from "lucide-react"
import { AlertTriangle, Lock, Minus, Plus, Save, Square } from "lucide-react"
import { cn } from "@/lib/utils"
import { productionTemplatesApi } from "@/lib/api/production-templates"
@@ -643,9 +643,6 @@ export default function TemplateBuilderPage() {
<div className="flex h-[calc(100vh-8rem)] flex-col gap-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/production/templates" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{name || "Untitled template"}</h1>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import Link from "next/link"
import { AlertTriangle, ArrowLeft, Save } from "lucide-react"
import { AlertTriangle, Save } from "lucide-react"
import { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories"
@@ -183,9 +183,6 @@ export default function ItemDetailPage() {
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{item.sku}</h1>
@@ -1,19 +1,17 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { useParams } from "next/navigation"
import { ArrowLeft, Ban, CheckCircle2, Network, Pencil, Plus } from "lucide-react"
import { Ban, CheckCircle2, Network, Pencil, Plus } from "lucide-react"
import { categoriesApi, subCategoriesApi } from "@/lib/api/categories"
import { errorMessage } from "@/lib/error-map"
import { validateCategoryName } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { Category, SubCategory } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
@@ -113,9 +111,6 @@ export default function CategorySubCategoriesPage() {
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/products/categories" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">{category ? `${category.name} — Subcategories` : "Subcategories"}</h1>
<p className="text-base text-muted-foreground">
@@ -171,13 +166,13 @@ export default function CategorySubCategoriesPage() {
{!error && subCategories !== null && subCategories.length > 0 && (
<Table className="text-base">
<TableHeader className="bg-indigo-50">
<TableRow className="hover:bg-indigo-50">
<TableHead className="h-12 px-3 text-sm text-indigo-700">ID</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Name</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Status</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Created At</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">ID</TableHead>
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Created At</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -1,18 +1,16 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Ban, CheckCircle2, Pencil, Plus, SwatchBook } from "lucide-react"
import { Ban, CheckCircle2, Pencil, Plus, SwatchBook } from "lucide-react"
import { itemTypesApi } from "@/lib/api/item-types"
import { errorMessage } from "@/lib/error-map"
import { validateItemTypeName } from "@/lib/validations/master-data"
import { cn } from "@/lib/utils"
import { ItemType } from "@/types/master-data"
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
@@ -110,9 +108,6 @@ export default function ItemTypesPage() {
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Item Types</h1>
<p className="text-base text-muted-foreground">
@@ -174,13 +169,13 @@ export default function ItemTypesPage() {
{!error && itemTypes !== null && itemTypes.length > 0 && (
<Table className="text-base">
<TableHeader className="bg-indigo-50">
<TableRow className="hover:bg-indigo-50">
<TableHead className="h-12 px-3 text-sm text-indigo-700">ID</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Name</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Status</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Created At</TableHead>
<TableHead className="h-12 px-3 text-sm text-indigo-700">Actions</TableHead>
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">ID</TableHead>
<TableHead className="h-12 px-3 text-sm">Name</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Created At</TableHead>
<TableHead className="h-12 px-3 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -3,7 +3,7 @@
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, Plus, X } from "lucide-react"
import { Plus, X } from "lucide-react"
import { itemsApi } from "@/lib/api/items"
import { categoriesApi } from "@/lib/api/categories"
@@ -254,9 +254,6 @@ export default function NewItemPage() {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">New Item</h1>
<p className="text-base text-muted-foreground">Category, subcategory, brand, and item types (FR-MD-01).</p>
@@ -532,14 +529,14 @@ export default function NewItemPage() {
{variants.length > 0 && (
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader className="bg-indigo-50">
<TableRow className="hover:bg-indigo-50">
<TableHeader>
<TableRow>
{activeCategories.map((cat) => (
<TableHead key={cat.itemTypeId} className="h-11 px-3 text-sm text-indigo-700">{cat.name}</TableHead>
<TableHead key={cat.itemTypeId} className="h-11 px-3 text-sm">{cat.name}</TableHead>
))}
<TableHead className="h-11 px-3 text-sm text-indigo-700">SKU</TableHead>
<TableHead className="h-11 px-3 text-sm">SKU</TableHead>
{priceMode === "fixed" && (
<TableHead className="h-11 px-3 text-sm text-indigo-700">Sale price</TableHead>
<TableHead className="h-11 px-3 text-sm">Sale price</TableHead>
)}
{/* Quantity column removed 2026-07-17: there is no `initialQty` on the
Item contract and no initial-receipt flow stock arrives via a GRN.
@@ -1,15 +1,12 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Package } from "lucide-react"
import { Package } from "lucide-react"
import { productConfigApi } from "@/lib/api/product-config"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { ProductConfig } from "@/types/master-data"
import { buttonVariants } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Switch } from "@/components/ui/switch"
import { toast } from "@/components/ui/toast"
@@ -70,9 +67,6 @@ export default function ProductSettingsPage() {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Product Configuration</h1>
<p className="text-base text-muted-foreground">
@@ -1,16 +1,14 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Plus, Ruler } from "lucide-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 { cn } from "@/lib/utils"
import { Uom } from "@/types/master-data"
import { Button, buttonVariants } from "@/components/ui/button"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
@@ -59,9 +57,6 @@ export default function UomsPage() {
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/products" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Units of Measure</h1>
<p className="text-base text-muted-foreground">Flat UOM master, used as item base UOMs and in per-item conversions (FR-MD-02).</p>
@@ -3,7 +3,7 @@
import { useEffect, useRef, useState } from "react"
import { useParams } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, CheckCircle2, PackageCheck, PackageX, ShieldAlert, XCircle } from "lucide-react"
import { CheckCircle2, PackageCheck, PackageX, ShieldAlert, XCircle } from "lucide-react"
import { grnsApi } from "@/lib/api/grns"
import { warehousesApi } from "@/lib/api/warehouses"
@@ -118,9 +118,6 @@ export default function GrnDetailPage() {
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/receiving/grn" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{grn.docNo}</h1>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, ExternalLink, Plus, RefreshCw, Trash2 } from "lucide-react"
import { ExternalLink, Plus, RefreshCw, Trash2 } from "lucide-react"
import { grnsApi } from "@/lib/api/grns"
import { purchaseOrdersApi } from "@/lib/api/purchase-orders"
@@ -303,9 +303,6 @@ export default function NewGrnPage() {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/receiving/grn" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">New GRN</h1>
<p className="text-base text-muted-foreground">Receive goods against a purchase order, or record a direct receipt (FR-GRN-01/02).</p>
@@ -446,7 +443,7 @@ export default function NewGrnPage() {
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-96 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm">Bin</TableHead>
<TableHead className="h-12 w-20 px-3 text-sm">Qty</TableHead>
@@ -454,7 +451,7 @@ export default function NewGrnPage() {
<TableHead className="h-12 w-24 px-3 text-sm">Disc %</TableHead>
<TableHead className="h-12 w-24 px-3 text-sm">VAT %</TableHead>
<TableHead className="h-12 w-28 px-3 text-sm text-right">Line total</TableHead>
<TableHead className="h-12 w-32 px-3 text-sm">Hold status</TableHead>
<TableHead className="h-12 w-56 px-3 text-sm">Hold status</TableHead>
<TableHead className="h-12 w-44 px-3 text-sm">Batch / Serial</TableHead>
<TableHead className="h-12 w-10 px-3" />
</TableRow>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import Link from "next/link"
import { AlertTriangle, ArrowLeft, Save } from "lucide-react"
import { AlertTriangle, Save } from "lucide-react"
import { navApi } from "@/lib/api/nav"
import { rolesApi } from "@/lib/api/roles"
@@ -132,9 +132,6 @@ export default function RoleDetailPage() {
<div className="flex flex-col gap-8">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/settings/roles" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{role.code}</h1>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, Save } from "lucide-react"
import { Save } from "lucide-react"
import { rolesApi } from "@/lib/api/roles"
import { usersApi } from "@/lib/api/users"
@@ -83,9 +83,6 @@ export default function UserDetailPage() {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/settings/users" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{user.username}</h1>
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, CheckCircle2, Plus, Trash2 } from "lucide-react"
import { CheckCircle2, Plus, Trash2 } from "lucide-react"
import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments"
import { warehousesApi } from "@/lib/api/warehouses"
@@ -123,9 +123,6 @@ export default function NewAdjustmentPage() {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/stock/adjustments" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">New Adjustment</h1>
<p className="text-base text-muted-foreground">Posts immediately on creation (FR-STK-07) a reason code is mandatory.</p>
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Plus, SlidersHorizontal } from "lucide-react"
import { Plus, SlidersHorizontal } from "lucide-react"
import { stockAdjustmentsApi } from "@/lib/api/stock-adjustments"
import { warehousesApi } from "@/lib/api/warehouses"
@@ -39,9 +39,6 @@ export default function AdjustmentsListPage() {
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Stock Adjustments</h1>
<p className="text-base text-muted-foreground">Increase, decrease, or write off stock with a reason code (FR-STK-07).</p>
@@ -2,15 +2,14 @@
import { useEffect, useState } from "react"
import { useParams } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, CheckCircle2, ClipboardCheck, Save } from "lucide-react"
import { CheckCircle2, ClipboardCheck, Save } from "lucide-react"
import { stockCountsApi } from "@/lib/api/stock-counts"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { PostCountResponse, StockCount } from "@/types/stock"
import { Button, buttonVariants } from "@/components/ui/button"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -91,9 +90,6 @@ export default function CountDetailPage() {
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/stock/counts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{count.docNo}</h1>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { ArrowLeft } from "lucide-react"
import {} from "lucide-react"
import { stockCountsApi } from "@/lib/api/stock-counts"
import { warehousesApi } from "@/lib/api/warehouses"
@@ -82,9 +82,6 @@ export default function NewCountPage() {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/stock/counts" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">New Count</h1>
<p className="text-base text-muted-foreground">System quantities are snapshotted immediately; enter counted quantities next.</p>
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, ClipboardList, Plus } from "lucide-react"
import { ClipboardList, Plus } from "lucide-react"
import { stockCountsApi } from "@/lib/api/stock-counts"
import { warehousesApi } from "@/lib/api/warehouses"
@@ -36,9 +36,6 @@ export default function CountsListPage() {
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Stock Counts</h1>
<p className="text-base text-muted-foreground">Cycle or full physical counts (FR-STK-08).</p>
@@ -2,17 +2,15 @@
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ArrowLeft, PackageSearch, Search } from "lucide-react"
import { PackageSearch, Search } from "lucide-react"
import { stockApi } from "@/lib/api/stock"
import { itemsApi } from "@/lib/api/items"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { OnHand } from "@/types/stock"
import { ItemListItem, Warehouse } from "@/types/master-data"
import { buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -59,9 +57,6 @@ export default function StockEnquiryPage() {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Stock Enquiry</h1>
<p className="text-base text-muted-foreground">On-hand, available, on-hold, and in-transit by item and warehouse (FR-STK-12).</p>
@@ -1,8 +1,7 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ArrowLeft, ChevronLeft, ChevronRight, ScrollText } from "lucide-react"
import { ChevronLeft, ChevronRight, ScrollText } from "lucide-react"
import { stockApi } from "@/lib/api/stock"
import { itemsApi } from "@/lib/api/items"
@@ -13,7 +12,7 @@ import { LedgerEntry } from "@/types/stock"
import { PaginationMeta } from "@/types/common"
import { ItemListItem, Warehouse } from "@/types/master-data"
import { Button, buttonVariants } from "@/components/ui/button"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
@@ -81,9 +80,6 @@ export default function StockLedgerPage() {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Stock Ledger</h1>
<p className="text-base text-muted-foreground">Immutable, append-only movement journal (FR-STK-01).</p>
@@ -1,18 +1,16 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { AlertTriangle, ArrowLeft, CheckCircle2 } from "lucide-react"
import { AlertTriangle, CheckCircle2 } from "lucide-react"
import { stockApi } from "@/lib/api/stock"
import { itemsApi } from "@/lib/api/items"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { ReorderAlert } from "@/types/stock"
import { ItemListItem, Warehouse } from "@/types/master-data"
import { Button, buttonVariants } from "@/components/ui/button"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
@@ -59,9 +57,6 @@ export default function ReorderAlertsPage() {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Reorder Alerts</h1>
<p className="text-base text-muted-foreground">Items at or below their reorder point (FR-STK-10).</p>
@@ -2,15 +2,13 @@
import { useEffect, useState } from "react"
import { useParams } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, CheckCircle2, PackageCheck, Truck } from "lucide-react"
import { CheckCircle2, PackageCheck, Truck } from "lucide-react"
import { stockTransfersApi } from "@/lib/api/stock-transfers"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { DispatchTransferResponse, ReceiveTransferResponse, StockTransfer } from "@/types/stock"
import { Button, buttonVariants } from "@/components/ui/button"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { toast } from "@/components/ui/toast"
@@ -85,9 +83,6 @@ export default function TransferDetailPage() {
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/stock/transfers" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{transfer.docNo}</h1>
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, Plus, Trash2 } from "lucide-react"
import { Plus, Trash2 } from "lucide-react"
import { stockTransfersApi } from "@/lib/api/stock-transfers"
import { warehousesApi } from "@/lib/api/warehouses"
@@ -138,9 +138,6 @@ export default function NewTransferPage() {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/stock/transfers" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">New Transfer</h1>
<p className="text-base text-muted-foreground">Create a transfer, then dispatch and receive it (FR-STK-05).</p>
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, ArrowLeftRight, Plus } from "lucide-react"
import { ArrowLeftRight, Plus } from "lucide-react"
import { stockTransfersApi } from "@/lib/api/stock-transfers"
import { warehousesApi } from "@/lib/api/warehouses"
@@ -36,9 +36,6 @@ export default function TransfersListPage() {
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Stock Transfers</h1>
<p className="text-base text-muted-foreground">Move stock between warehouses (FR-STK-05/06).</p>
@@ -2,18 +2,15 @@
import { Suspense, useEffect, useMemo, useState } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, BadgeDollarSign } from "lucide-react"
import { BadgeDollarSign } from "lucide-react"
import { stockApi } from "@/lib/api/stock"
import { itemsApi } from "@/lib/api/items"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { Valuation } from "@/types/stock"
import { ItemListItem, Warehouse } from "@/types/master-data"
import { buttonVariants } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { Skeleton } from "@/components/ui/skeleton"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -65,9 +62,6 @@ function ValuationContent() {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Valuation</h1>
<p className="text-base text-muted-foreground">FIFO cost-layer breakdown and total stock value (FR-STK-04).</p>
@@ -2,7 +2,7 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { AlertOctagon, ArrowLeft, CheckCircle2 } from "lucide-react"
import { AlertOctagon, CheckCircle2 } from "lucide-react"
import { isWastageReasonCode, wastageApi } from "@/lib/api/wastage"
import { reasonCodesApi } from "@/lib/api/reason-codes"
@@ -99,9 +99,6 @@ export default function NewWastagePage() {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/dashboard/stock/wastage" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Record Wastage</h1>
<p className="text-base text-muted-foreground">Posts immediately as a stock adjustment (FR-STK-07) a reason code is mandatory.</p>
@@ -2,7 +2,7 @@
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { AlertOctagon, ArrowLeft, Plus } from "lucide-react"
import { AlertOctagon, Plus } from "lucide-react"
import { isWastageReasonCode, wastageApi, WastageRecord } from "@/lib/api/wastage"
import { reasonCodesApi } from "@/lib/api/reason-codes"
@@ -68,9 +68,6 @@ export default function WastagePage() {
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/stock" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Wastage</h1>
<p className="text-base text-muted-foreground">
+1 -4
View File
@@ -3,7 +3,7 @@
import { useEffect, useState } from "react"
import { useParams, useRouter } from "next/navigation"
import Link from "next/link"
import { AlertTriangle, ArrowLeft, Save } from "lucide-react"
import { AlertTriangle, Save } from "lucide-react"
import { vendorsApi } from "@/lib/api/vendors"
import { errorMessage, fieldErrors } from "@/lib/error-map"
@@ -132,9 +132,6 @@ export default function VendorDetailPage() {
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/vendors" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-foreground">{vendor.code}</h1>
@@ -2,15 +2,13 @@
import { useEffect, useState } from "react"
import { useParams } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, MapPinned, Plus } from "lucide-react"
import { MapPinned, Plus } from "lucide-react"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage, fieldErrors } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { Bin, Warehouse } from "@/types/master-data"
import { Button, buttonVariants } from "@/components/ui/button"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import {
@@ -90,9 +88,6 @@ export default function WarehouseDetailPage() {
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard/warehouse" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">{warehouse.code}</h1>
<p className="text-base text-muted-foreground">{warehouse.name} · Bin/location structure (FR-WH-01, FR-MD-07)</p>
@@ -2,14 +2,13 @@
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, Plus, Warehouse as WarehouseIcon } from "lucide-react"
import { Plus, Warehouse as WarehouseIcon } from "lucide-react"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { cn } from "@/lib/utils"
import { Bin, Warehouse } from "@/types/master-data"
import { Button, buttonVariants } from "@/components/ui/button"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import {
@@ -100,9 +99,6 @@ export default function WarehousesPage() {
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link href="/dashboard" className={cn(buttonVariants({ variant: "ghost", size: "icon-lg" }))}>
<ArrowLeft className="size-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Warehouses</h1>
<p className="text-base text-muted-foreground">Multi-warehouse master data with per-warehouse bin/location structure (FR-WH-01, FR-MD-07).</p>
@@ -5,26 +5,36 @@ import Link from "next/link"
import { usePathname } from "next/navigation"
import {
Banknote,
BadgeDollarSign,
BookOpen,
BookText,
Boxes,
Building2,
CalendarCheck,
CalendarClock,
ChevronRight,
ClipboardList,
CreditCard,
Factory,
FileBarChart,
FileText,
HelpCircle,
IdCard,
Inbox,
Landmark,
LayoutGrid,
LayoutTemplate,
LineChart,
ListTree,
Menu,
Package,
PackageCheck,
PackageX,
PlayCircle,
PieChart,
Receipt,
Ruler,
Scale,
Settings,
ShieldCheck,
ShoppingCart,
@@ -32,6 +42,7 @@ import {
Tag,
Truck,
Users,
Wallet,
Warehouse,
X,
type LucideIcon,
@@ -120,6 +131,34 @@ const navItems: {
{ title: "Settings", code: "hrm.settings", href: "/dashboard/hrm/settings", icon: SlidersHorizontal },
],
},
{
title: "Ledgers",
code: "ledgers",
href: "/dashboard/ledgers",
icon: Landmark,
chevron: true,
children: [
{ title: "Trial Balance", code: "ledgers.trial-balance", href: "/dashboard/ledgers/trial-balance", icon: Scale },
{ title: "Balance Sheet", code: "ledgers.balance-sheet", href: "/dashboard/ledgers/balance-sheet", icon: Landmark },
{ title: "General Ledger", code: "ledgers.general-ledger", href: "/dashboard/ledgers/general-ledger", icon: BookOpen },
{ title: "Profit & Loss", code: "ledgers.profit-and-loss", href: "/dashboard/ledgers/profit-and-loss", icon: LineChart },
{ title: "Cash Flow", code: "ledgers.cash-flow", href: "/dashboard/ledgers/cash-flow", icon: PieChart },
{ title: "Budget vs Actual", code: "ledgers.budget-vs-actual", href: "/dashboard/ledgers/budget-vs-actual", icon: BadgeDollarSign },
{ title: "Tax Report", code: "ledgers.tax-report", href: "/dashboard/ledgers/tax-report", icon: Receipt },
],
},
{
title: "Accounts",
code: "accounts",
href: "/dashboard/accounts",
icon: CreditCard,
chevron: true,
children: [
{ title: "Cash / Bank Accounts", code: "accounts.bank-accounts", href: "/dashboard/accounts/bank-accounts", icon: Wallet },
{ title: "Cheque Books", code: "accounts.cheque-books", href: "/dashboard/accounts/cheque-books", icon: BookText },
{ title: "Received Cheques", code: "accounts.received-cheques", href: "/dashboard/accounts/received-cheques", icon: Inbox },
],
},
{
title: "Settings",
code: "settings",
@@ -157,7 +196,13 @@ function SidebarContent({
// route auto-expanded; user toggles are preserved across navigation.
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
useEffect(() => {
// Adjusted during render (not in an effect) each time pathname or the
// available item set changes — `items` starts empty while auth/nav codes
// are loading, so this also needs to re-run once the real list arrives.
const autoExpandKey = `${pathname}::${items.map((i) => i.code).join(",")}`
const [lastAutoExpandKey, setLastAutoExpandKey] = useState<string | null>(null)
if (autoExpandKey !== lastAutoExpandKey) {
setLastAutoExpandKey(autoExpandKey)
const parent = items.find((i) => {
if (!i.children?.length) return false
if (i.children.some((c) => pathname === c.href || pathname.startsWith(`${c.href}/`))) return true
@@ -168,7 +213,7 @@ function SidebarContent({
if (parent) {
setExpanded((prev) => (prev[parent.code] ? prev : { ...prev, [parent.code]: true }))
}
}, [pathname, items])
}
const toggleExpand = (code: string) =>
setExpanded((prev) => ({ ...prev, [code]: !prev[code] }))
@@ -1,6 +1,6 @@
"use client"
import { useEffect, useState } from "react"
import { useState } from "react"
import Link from "next/link"
import { usePathname, useRouter } from "next/navigation"
import { ArrowLeft, Bell, LogOut, Settings, User } from "lucide-react"
@@ -41,6 +41,27 @@ const PROCUREMENT_TITLES: Record<string, string> = {
"/dashboard/procurement/purchase-returns/new": "New Purchase Return",
}
const LEDGER_TITLES: Record<string, string> = {
"/dashboard/ledgers": "Ledgers",
"/dashboard/ledgers/trial-balance": "Trial Balance",
"/dashboard/ledgers/balance-sheet": "Balance Sheet",
"/dashboard/ledgers/general-ledger": "General Ledger",
"/dashboard/ledgers/profit-and-loss": "Profit & Loss",
"/dashboard/ledgers/cash-flow": "Cash Flow",
"/dashboard/ledgers/budget-vs-actual": "Budget vs Actual",
"/dashboard/ledgers/tax-report": "Tax Report",
}
const ACCOUNTS_TITLES: Record<string, string> = {
"/dashboard/accounts": "Accounts",
"/dashboard/accounts/bank-accounts": "Cash / Bank Accounts",
"/dashboard/accounts/bank-accounts/new": "New Bank Account",
"/dashboard/accounts/cheque-books": "Cheque Books",
"/dashboard/accounts/cheque-books/new": "New Cheque Book",
"/dashboard/accounts/received-cheques": "Received Cheques",
"/dashboard/accounts/received-cheques/new": "New Received Cheque",
}
const STOCK_TITLES: Record<string, string> = {
"/dashboard/stock": "Stock Management",
"/dashboard/stock/enquiry": "Stock Enquiry",
@@ -62,6 +83,11 @@ function titleFromPath(pathname: string) {
if (pathname === "/dashboard/receiving/grn/new") return "Create Goods Receipt Note"
if (/^\/dashboard\/receiving\/grn\/[^/]+$/.test(pathname)) return "Goods Receipt Note"
if (LEDGER_TITLES[pathname]) return LEDGER_TITLES[pathname]
if (ACCOUNTS_TITLES[pathname]) return ACCOUNTS_TITLES[pathname]
if (/^\/dashboard\/accounts\/cheque-books\/[^/]+$/.test(pathname)) return "Cheque Book"
if (STOCK_TITLES[pathname]) return STOCK_TITLES[pathname]
if (/^\/dashboard\/stock\/transfers\/[^/]+$/.test(pathname)) return "Stock Transfer"
if (/^\/dashboard\/stock\/counts\/[^/]+$/.test(pathname)) return "Stock Count"
@@ -140,8 +166,7 @@ export function Header() {
// Read after mount, not during render: localStorage doesn't exist on the server, and
// reading it while rendering would desync the hydration pass.
const [user, setUser] = useState<AuthUser | null>(null)
useEffect(() => setUser(getStoredUser()), [])
const [user] = useState<AuthUser | null>(() => getStoredUser())
const markAllAsRead = () =>
setNotifications((prev) => prev.map((n) => ({ ...n, unread: false })))
@@ -0,0 +1,381 @@
"use client"
import { useState } from "react"
import { chequePagesApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate } from "@/lib/format"
import { validateIssueChequeForm } from "@/lib/validations/general-ledger"
import { cn } from "@/lib/utils"
import { ChequePage, ChequePageIssueStatus, ChequePageStatusAction, PayeeType } from "@/types/general-ledger"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { toast } from "@/components/ui/toast"
const STATUS_BADGE: Record<ChequePageIssueStatus, string> = {
[ChequePageIssueStatus.Unused]: "bg-muted text-muted-foreground",
[ChequePageIssueStatus.Issued]: "bg-primary/10 text-primary",
[ChequePageIssueStatus.Cleared]: "bg-success/10 text-success",
[ChequePageIssueStatus.Bounced]: "bg-destructive/10 text-destructive",
[ChequePageIssueStatus.Cancelled]: "bg-destructive/10 text-destructive",
[ChequePageIssueStatus.Void]: "bg-muted text-muted-foreground",
}
type PendingAction = "Issue" | ChequePageStatusAction | null
interface ChequePageDialogProps {
page: ChequePage | null
open: boolean
onOpenChange: (open: boolean) => void
/** Called with the server's response after a successful issue/status-update, so the caller's list stays in sync. */
onUpdated: (updated: ChequePage) => void
}
/** View a single cheque page's details, and (from `Unused`/`Issued`) issue it or move it through
* Clear/Bounce/Cancel/Void a modal rather than a separate page, so acting on several leaves from
* a book's page list doesn't lose scroll position/context each time (docs/21 §7). */
export function ChequePageDialog({ page, open, onOpenChange, onUpdated }: ChequePageDialogProps) {
const [pendingAction, setPendingAction] = useState<PendingAction>(null)
const [submitting, setSubmitting] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const [payeeType, setPayeeType] = useState<PayeeType>(PayeeType.Supplier)
const [payeeName, setPayeeName] = useState("")
const [payeeId, setPayeeId] = useState("")
const [issueDate, setIssueDate] = useState("")
const [amount, setAmount] = useState("")
const [currencyCode, setCurrencyCode] = useState("LKR")
const [voucherId, setVoucherId] = useState("")
const [referenceNo, setReferenceNo] = useState("")
const [purpose, setPurpose] = useState("")
const [isCrossCheque, setIsCrossCheque] = useState(false)
const [isAccountPayee, setIsAccountPayee] = useState(false)
const [isPostDated, setIsPostDated] = useState(false)
const [notes, setNotes] = useState("")
const [printedBy, setPrintedBy] = useState("")
const [clearedDate, setClearedDate] = useState("")
const [cancelReason, setCancelReason] = useState("")
const [performedBy, setPerformedBy] = useState("")
function resetActionState() {
setPendingAction(null)
setErrors({})
setPayeeType(PayeeType.Supplier)
setPayeeName("")
setPayeeId("")
setIssueDate("")
setAmount("")
setCurrencyCode("LKR")
setVoucherId("")
setReferenceNo("")
setPurpose("")
setIsCrossCheque(false)
setIsAccountPayee(false)
setIsPostDated(false)
setNotes("")
setPrintedBy("")
setClearedDate("")
setCancelReason("")
setPerformedBy("")
}
function handleOpenChange(next: boolean) {
if (!next) resetActionState()
onOpenChange(next)
}
async function submitIssue() {
if (!page) return
const nextErrors = validateIssueChequeForm({ payeeName, issueDate, amount })
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
setSubmitting(true)
try {
const updated = await chequePagesApi.issue(page.chequeNo, {
payeeType,
payeeId: payeeId ? Number(payeeId) : undefined,
payeeName,
issueDate,
amount: Number(amount),
currencyCode: currencyCode || undefined,
voucherId: voucherId ? Number(voucherId) : undefined,
referenceNo: referenceNo || undefined,
purpose: purpose || undefined,
isCrossCheque,
isAccountPayee,
isPostDated,
notes: notes || undefined,
printedBy: printedBy || undefined,
})
toast.success("Cheque issued", `${updated.chequeNo}${updated.payeeName}`)
onUpdated(updated)
resetActionState()
} catch (err) {
toast.error("Could not issue cheque", errorMessage(err))
} finally {
setSubmitting(false)
}
}
async function submitStatusAction(action: ChequePageStatusAction) {
if (!page) return
if (action === ChequePageStatusAction.Clear && !clearedDate) {
setErrors({ clearedDate: "Cleared date is required" })
return
}
if (action === ChequePageStatusAction.Cancel && !cancelReason.trim()) {
setErrors({ cancelReason: "Cancel reason is required" })
return
}
setSubmitting(true)
try {
const updated = await chequePagesApi.updateStatus(page.chequeNo, {
action,
clearedDate: action === ChequePageStatusAction.Clear ? clearedDate : undefined,
cancelReason: action === ChequePageStatusAction.Cancel ? cancelReason : undefined,
performedBy: performedBy || undefined,
})
toast.success(`Cheque ${action.toLowerCase()}d`, updated.chequeNo)
onUpdated(updated)
resetActionState()
} catch (err) {
toast.error(`Could not ${action.toLowerCase()} cheque`, errorMessage(err))
} finally {
setSubmitting(false)
}
}
if (!page) return null
const availableActions: ChequePageStatusAction[] =
page.issueStatus === ChequePageIssueStatus.Unused
? [ChequePageStatusAction.Cancel, ChequePageStatusAction.Void]
: page.issueStatus === ChequePageIssueStatus.Issued
? [ChequePageStatusAction.Clear, ChequePageStatusAction.Bounce, ChequePageStatusAction.Cancel]
: []
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-lg">
Cheque {page.chequeNo}
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[page.issueStatus])}>
{page.issueStatus}
</Badge>
</DialogTitle>
</DialogHeader>
{!pendingAction && (
<div className="flex flex-col gap-4">
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt className="text-muted-foreground">Payee</dt>
<dd>{page.payeeName ?? "—"}</dd>
<dt className="text-muted-foreground">Payee type</dt>
<dd>{page.payeeType ?? "—"}</dd>
<dt className="text-muted-foreground">Issue date</dt>
<dd>{formatReportDate(page.issueDate)}</dd>
<dt className="text-muted-foreground">Amount</dt>
<dd>{page.amount !== null ? formatAmount(page.amount) : "—"}</dd>
<dt className="text-muted-foreground">Reference no.</dt>
<dd>{page.referenceNo ?? "—"}</dd>
<dt className="text-muted-foreground">Purpose</dt>
<dd>{page.purpose ?? "—"}</dd>
<dt className="text-muted-foreground">Notes</dt>
<dd>{page.notes ?? "—"}</dd>
{page.issueStatus === ChequePageIssueStatus.Cleared && (
<>
<dt className="text-muted-foreground">Cleared date</dt>
<dd>{formatReportDate(page.clearedDate)}</dd>
</>
)}
{page.issueStatus === ChequePageIssueStatus.Cancelled && (
<>
<dt className="text-muted-foreground">Cancel reason</dt>
<dd>{page.cancelReason ?? "—"}</dd>
</>
)}
</dl>
{availableActions.length > 0 && (
<div className="flex flex-wrap gap-2 border-t pt-4">
{page.issueStatus === ChequePageIssueStatus.Unused && (
<Button size="sm" onClick={() => setPendingAction("Issue")}>
Issue Cheque
</Button>
)}
{availableActions.map((action) => (
<Button key={action} size="sm" variant="outline" onClick={() => setPendingAction(action)}>
{action}
</Button>
))}
</div>
)}
</div>
)}
{pendingAction === "Issue" && (
<FieldGroup>
<Field data-invalid={!!errors.payeeName}>
<FieldLabel htmlFor="cp-payee-name">Payee name</FieldLabel>
<Input id="cp-payee-name" value={payeeName} onChange={(e) => setPayeeName(e.target.value)} aria-invalid={!!errors.payeeName} />
<FieldError errors={[errors.payeeName ? { message: errors.payeeName } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="cp-payee-type">Payee type</FieldLabel>
<Select<PayeeType> value={payeeType} onValueChange={(v) => setPayeeType(v ?? PayeeType.Supplier)}>
<SelectTrigger id="cp-payee-type" className="w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(PayeeType).map((t) => (
<SelectItem key={t} value={t} label={t} className="text-base">
{t}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
<FieldLabel htmlFor="cp-payee-id">Payee ID (optional)</FieldLabel>
<Input id="cp-payee-id" type="number" value={payeeId} onChange={(e) => setPayeeId(e.target.value)} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field data-invalid={!!errors.issueDate}>
<FieldLabel htmlFor="cp-issue-date">Issue date</FieldLabel>
<Input
id="cp-issue-date"
type="date"
value={issueDate}
onChange={(e) => setIssueDate(e.target.value)}
aria-invalid={!!errors.issueDate}
/>
<FieldError errors={[errors.issueDate ? { message: errors.issueDate } : undefined]} />
</Field>
<Field data-invalid={!!errors.amount}>
<FieldLabel htmlFor="cp-amount">Amount</FieldLabel>
<Input id="cp-amount" type="number" value={amount} onChange={(e) => setAmount(e.target.value)} aria-invalid={!!errors.amount} />
<FieldError errors={[errors.amount ? { message: errors.amount } : undefined]} />
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field>
<FieldLabel htmlFor="cp-currency">Currency</FieldLabel>
<Input id="cp-currency" value={currencyCode} onChange={(e) => setCurrencyCode(e.target.value)} maxLength={3} />
</Field>
<Field>
<FieldLabel htmlFor="cp-voucher">Voucher ID (optional)</FieldLabel>
<Input id="cp-voucher" type="number" value={voucherId} onChange={(e) => setVoucherId(e.target.value)} />
</Field>
</div>
<Field>
<FieldLabel htmlFor="cp-ref">Reference no. (optional)</FieldLabel>
<Input id="cp-ref" value={referenceNo} onChange={(e) => setReferenceNo(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="cp-purpose">Purpose (optional)</FieldLabel>
<Input id="cp-purpose" value={purpose} onChange={(e) => setPurpose(e.target.value)} />
</Field>
<div className="flex flex-wrap gap-4">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={isCrossCheque} onCheckedChange={(v) => setIsCrossCheque(v === true)} />
Cross cheque
</label>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={isAccountPayee} onCheckedChange={(v) => setIsAccountPayee(v === true)} />
Account payee
</label>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={isPostDated} onCheckedChange={(v) => setIsPostDated(v === true)} />
Post-dated
</label>
</div>
<Field>
<FieldLabel htmlFor="cp-notes">Notes (optional)</FieldLabel>
<Input id="cp-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="cp-printed-by">Printed by (optional)</FieldLabel>
<Input id="cp-printed-by" value={printedBy} onChange={(e) => setPrintedBy(e.target.value)} />
</Field>
</FieldGroup>
)}
{pendingAction === ChequePageStatusAction.Clear && (
<FieldGroup>
<Field data-invalid={!!errors.clearedDate}>
<FieldLabel htmlFor="cp-cleared-date">Cleared date</FieldLabel>
<Input
id="cp-cleared-date"
type="date"
value={clearedDate}
onChange={(e) => setClearedDate(e.target.value)}
aria-invalid={!!errors.clearedDate}
/>
<FieldError errors={[errors.clearedDate ? { message: errors.clearedDate } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="cp-performed-by">Performed by (optional)</FieldLabel>
<Input id="cp-performed-by" value={performedBy} onChange={(e) => setPerformedBy(e.target.value)} />
</Field>
</FieldGroup>
)}
{pendingAction === ChequePageStatusAction.Cancel && (
<FieldGroup>
<Field data-invalid={!!errors.cancelReason}>
<FieldLabel htmlFor="cp-cancel-reason">Cancel reason</FieldLabel>
<Input
id="cp-cancel-reason"
value={cancelReason}
onChange={(e) => setCancelReason(e.target.value)}
aria-invalid={!!errors.cancelReason}
/>
<FieldError errors={[errors.cancelReason ? { message: errors.cancelReason } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="cp-performed-by">Performed by (optional)</FieldLabel>
<Input id="cp-performed-by" value={performedBy} onChange={(e) => setPerformedBy(e.target.value)} />
</Field>
</FieldGroup>
)}
{(pendingAction === ChequePageStatusAction.Bounce || pendingAction === ChequePageStatusAction.Void) && (
<FieldGroup>
<Field>
<FieldLabel htmlFor="cp-performed-by">Performed by (optional)</FieldLabel>
<Input id="cp-performed-by" value={performedBy} onChange={(e) => setPerformedBy(e.target.value)} />
</Field>
</FieldGroup>
)}
{pendingAction && (
<DialogFooter>
<Button variant="outline" onClick={() => resetActionState()} disabled={submitting}>
Back
</Button>
<Button onClick={() => (pendingAction === "Issue" ? submitIssue() : submitStatusAction(pendingAction))} disabled={submitting}>
{submitting ? "Submitting…" : pendingAction === "Issue" ? "Issue Cheque" : pendingAction}
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,236 @@
"use client"
import { useEffect, useState } from "react"
import { bankAccountsApi, receivedChequesApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { formatAmount, formatReportDate } from "@/lib/format"
import { cn } from "@/lib/utils"
import {
CashAndBankAccountDto,
CashBankAccountType,
ReceivedCheque,
ReceivedChequeStatus,
ReceivedChequeStatusAction,
} from "@/types/general-ledger"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { toast } from "@/components/ui/toast"
const STATUS_BADGE: Record<ReceivedChequeStatus, string> = {
[ReceivedChequeStatus.Received]: "bg-muted text-muted-foreground",
[ReceivedChequeStatus.Deposited]: "bg-primary/10 text-primary",
[ReceivedChequeStatus.Cleared]: "bg-success/10 text-success",
[ReceivedChequeStatus.Returned]: "bg-destructive/10 text-destructive",
[ReceivedChequeStatus.Cancelled]: "bg-destructive/10 text-destructive",
}
interface ReceivedChequeDialogProps {
cheque: ReceivedCheque | null
open: boolean
onOpenChange: (open: boolean) => void
onUpdated: (updated: ReceivedCheque) => void
}
/** View a received cheque's details and (from `Received`/`Deposited`) move it through
* Deposit/Clear/Return/Cancel a modal, same posture as `ChequePageDialog`. */
export function ReceivedChequeDialog({ cheque, open, onOpenChange, onUpdated }: ReceivedChequeDialogProps) {
const [pendingAction, setPendingAction] = useState<ReceivedChequeStatusAction | null>(null)
const [submitting, setSubmitting] = useState(false)
const [errors, setErrors] = useState<Record<string, string>>({})
const [bankAccounts, setBankAccounts] = useState<CashAndBankAccountDto[] | null>(null)
const [depositBankAccountId, setDepositBankAccountId] = useState("")
const [depositDate, setDepositDate] = useState("")
const [performedBy, setPerformedBy] = useState("")
const [notes, setNotes] = useState("")
useEffect(() => {
if (pendingAction !== ReceivedChequeStatusAction.Deposit || bankAccounts !== null) return
bankAccountsApi.list(CashBankAccountType.Bank).then(setBankAccounts).catch(() => setBankAccounts([]))
// Only fetched once, lazily, the first time Deposit is chosen.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pendingAction])
function resetActionState() {
setPendingAction(null)
setErrors({})
setDepositBankAccountId("")
setDepositDate("")
setPerformedBy("")
setNotes("")
}
function handleOpenChange(next: boolean) {
if (!next) resetActionState()
onOpenChange(next)
}
async function submitStatusAction(action: ReceivedChequeStatusAction) {
if (!cheque) return
if (action === ReceivedChequeStatusAction.Deposit) {
const nextErrors: Record<string, string> = {}
if (!depositBankAccountId) nextErrors.depositBankAccountId = "Select a deposit bank account"
if (!depositDate) nextErrors.depositDate = "Deposit date is required"
setErrors(nextErrors)
if (Object.keys(nextErrors).length > 0) return
}
setSubmitting(true)
try {
const updated = await receivedChequesApi.updateStatus(cheque.receivedChequeId, {
action,
depositBankAccountId: action === ReceivedChequeStatusAction.Deposit ? Number(depositBankAccountId) : undefined,
depositDate: action === ReceivedChequeStatusAction.Deposit ? depositDate : undefined,
notes: notes || undefined,
performedBy: performedBy || undefined,
})
toast.success(`Cheque ${action.toLowerCase()}ed`, updated.chequeNo)
onUpdated(updated)
resetActionState()
} catch (err) {
toast.error(`Could not ${action.toLowerCase()} cheque`, errorMessage(err))
} finally {
setSubmitting(false)
}
}
if (!cheque) return null
const availableActions: ReceivedChequeStatusAction[] =
cheque.status === ReceivedChequeStatus.Received
? [ReceivedChequeStatusAction.Deposit, ReceivedChequeStatusAction.Cancel]
: cheque.status === ReceivedChequeStatus.Deposited
? [ReceivedChequeStatusAction.Clear, ReceivedChequeStatusAction.Return]
: []
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-lg">
Cheque {cheque.chequeNo}
<Badge variant="outline" className={cn("h-6 justify-center border-transparent text-sm", STATUS_BADGE[cheque.status])}>
{cheque.status}
</Badge>
</DialogTitle>
</DialogHeader>
{!pendingAction && (
<div className="flex flex-col gap-4">
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt className="text-muted-foreground">Received from</dt>
<dd>{cheque.receivedFromName}</dd>
<dt className="text-muted-foreground">Type</dt>
<dd>{cheque.receivedFromType}</dd>
<dt className="text-muted-foreground">Cheque date</dt>
<dd>{formatReportDate(cheque.chequeDate)}</dd>
<dt className="text-muted-foreground">Amount</dt>
<dd>{formatAmount(cheque.amount)}</dd>
<dt className="text-muted-foreground">Received date</dt>
<dd>{formatReportDate(cheque.receivedDate)}</dd>
<dt className="text-muted-foreground">Drawer bank</dt>
<dd>{cheque.drawerBankName ?? "—"}</dd>
<dt className="text-muted-foreground">Drawer branch</dt>
<dd>{cheque.drawerBankBranch ?? "—"}</dd>
<dt className="text-muted-foreground">Account holder</dt>
<dd>{cheque.accountHolderName ?? "—"}</dd>
<dt className="text-muted-foreground">Reference</dt>
<dd>{cheque.referenceType ?? "—"}</dd>
<dt className="text-muted-foreground">Notes</dt>
<dd>{cheque.notes ?? "—"}</dd>
{cheque.status === ReceivedChequeStatus.Deposited && (
<>
<dt className="text-muted-foreground">Deposited to</dt>
<dd>#{cheque.depositBankAccountId}</dd>
<dt className="text-muted-foreground">Deposit date</dt>
<dd>{formatReportDate(cheque.depositDate)}</dd>
</>
)}
</dl>
{availableActions.length > 0 && (
<div className="flex flex-wrap gap-2 border-t pt-4">
{availableActions.map((action) => (
<Button key={action} size="sm" variant="outline" onClick={() => setPendingAction(action)}>
{action}
</Button>
))}
</div>
)}
</div>
)}
{pendingAction === ReceivedChequeStatusAction.Deposit && (
<FieldGroup>
<Field data-invalid={!!errors.depositBankAccountId}>
<FieldLabel htmlFor="rc-deposit-bank">Deposit bank account</FieldLabel>
<Select<string> value={depositBankAccountId} onValueChange={(v) => setDepositBankAccountId(v ?? "")}>
<SelectTrigger id="rc-deposit-bank" className="w-full text-base" aria-invalid={!!errors.depositBankAccountId}>
<SelectValue placeholder={bankAccounts === null ? "Loading…" : "Select a bank account"} />
</SelectTrigger>
<SelectContent>
{(bankAccounts ?? []).map((a) => (
<SelectItem key={a.accountId} value={String(a.accountId)} label={a.accountName} className="text-base">
{a.accountName}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError errors={[errors.depositBankAccountId ? { message: errors.depositBankAccountId } : undefined]} />
</Field>
<Field data-invalid={!!errors.depositDate}>
<FieldLabel htmlFor="rc-deposit-date">Deposit date</FieldLabel>
<Input
id="rc-deposit-date"
type="date"
value={depositDate}
onChange={(e) => setDepositDate(e.target.value)}
aria-invalid={!!errors.depositDate}
/>
<FieldError errors={[errors.depositDate ? { message: errors.depositDate } : undefined]} />
</Field>
<Field>
<FieldLabel htmlFor="rc-performed-by">Performed by (optional)</FieldLabel>
<Input id="rc-performed-by" value={performedBy} onChange={(e) => setPerformedBy(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="rc-notes">Notes (optional)</FieldLabel>
<Input id="rc-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
</Field>
</FieldGroup>
)}
{(pendingAction === ReceivedChequeStatusAction.Clear ||
pendingAction === ReceivedChequeStatusAction.Return ||
pendingAction === ReceivedChequeStatusAction.Cancel) && (
<FieldGroup>
<Field>
<FieldLabel htmlFor="rc-performed-by">Performed by (optional)</FieldLabel>
<Input id="rc-performed-by" value={performedBy} onChange={(e) => setPerformedBy(e.target.value)} />
</Field>
<Field>
<FieldLabel htmlFor="rc-notes">Notes (optional)</FieldLabel>
<Input id="rc-notes" value={notes} onChange={(e) => setNotes(e.target.value)} />
</Field>
</FieldGroup>
)}
{pendingAction && (
<DialogFooter>
<Button variant="outline" onClick={() => resetActionState()} disabled={submitting}>
Back
</Button>
<Button onClick={() => submitStatusAction(pendingAction)} disabled={submitting}>
{submitting ? "Submitting…" : pendingAction}
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,42 @@
"use client"
import { useState } from "react"
import { FileSpreadsheet } from "lucide-react"
import { reportsApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { ReportType } from "@/types/general-ledger"
import { Button } from "@/components/ui/button"
import { toast } from "@/components/ui/toast"
interface DownloadCsvButtonProps {
reportType: ReportType
/** Same filter params already sent to the Json call — outputFormat is added here, not by the caller. */
params: Record<string, string | number | undefined>
/** Disable while the on-screen report itself hasn't loaded (nothing to name the download after would be odd otherwise). */
disabled?: boolean
}
/** Same mechanism as DownloadPdfButton, `outputFormat=Csv` — GL's bytes are downloaded unmodified. */
export function DownloadCsvButton({ reportType, params, disabled }: DownloadCsvButtonProps) {
const [downloading, setDownloading] = useState(false)
async function handleDownload() {
setDownloading(true)
try {
await reportsApi.downloadCsv(reportType, params)
} catch (err) {
toast.error("Could not download report", errorMessage(err))
} finally {
setDownloading(false)
}
}
return (
<Button variant="outline" onClick={handleDownload} disabled={disabled || downloading}>
<FileSpreadsheet className="size-4" />
{downloading ? "Preparing…" : "Download CSV"}
</Button>
)
}
@@ -0,0 +1,41 @@
"use client"
import { useState } from "react"
import { Download } from "lucide-react"
import { reportsApi } from "@/lib/api/general-ledger"
import { errorMessage } from "@/lib/error-map"
import { ReportType } from "@/types/general-ledger"
import { Button } from "@/components/ui/button"
import { toast } from "@/components/ui/toast"
interface DownloadPdfButtonProps {
reportType: ReportType
/** Same filter params already sent to the Json call — outputFormat is added here, not by the caller. */
params: Record<string, string | number | undefined>
/** Disable while the on-screen report itself hasn't loaded (nothing to name the download after would be odd otherwise). */
disabled?: boolean
}
export function DownloadPdfButton({ reportType, params, disabled }: DownloadPdfButtonProps) {
const [downloading, setDownloading] = useState(false)
async function handleDownload() {
setDownloading(true)
try {
await reportsApi.downloadPdf(reportType, params)
} catch (err) {
toast.error("Could not download report", errorMessage(err))
} finally {
setDownloading(false)
}
}
return (
<Button variant="outline" onClick={handleDownload} disabled={disabled || downloading}>
<Download className="size-4" />
{downloading ? "Preparing…" : "Download PDF"}
</Button>
)
}
@@ -0,0 +1,27 @@
// Statutory-style report header (docs/21-GENERAL-LEDGER-FRONTEND.md "Sri Lankan Standard report
// UI"): centered title block, LKAS-aligned statement names, period/as-at line, currency note —
// the same shape whether the report renders on screen or the downloaded PDF (the PDF itself is
// rendered server-side by the GL service; this header is the on-screen equivalent).
interface ReportHeaderProps {
title: string
subtitle: string
currencyNote?: string
}
export function ReportHeader({
title,
subtitle,
currencyNote = "All amounts in Sri Lankan Rupees (LKR) unless stated otherwise.",
}: ReportHeaderProps) {
return (
<div className="flex flex-col items-center gap-1 border-b-2 border-foreground/70 px-4 pb-5 text-center">
<p className="text-xs font-semibold tracking-[0.2em] text-muted-foreground uppercase">
General Ledger
</p>
<h2 className="text-xl font-bold tracking-tight text-foreground uppercase">{title}</h2>
<p className="text-base text-muted-foreground">{subtitle}</p>
<p className="text-sm text-muted-foreground">{currencyNote}</p>
</div>
)
}
@@ -0,0 +1,42 @@
import { formatAmount } from "@/lib/format"
import { Table, TableBody, TableCell, TableFooter, TableRow } from "@/components/ui/table"
export interface ReportSectionLine {
label: React.ReactNode
amount: number
}
interface ReportSectionProps {
title: string
lines: ReportSectionLine[]
/** Omit to hide the total row entirely (e.g. a single-line section that would just repeat itself). */
total?: number
}
/** One bordered statement section: its lines, then an optional bold total row. Renders nothing when there are no lines. */
export function ReportSection({ title, lines, total }: ReportSectionProps) {
if (lines.length === 0) return null
return (
<div className="mb-4 rounded-lg border p-4">
<p className="mb-2 text-sm font-semibold tracking-wide text-muted-foreground uppercase">{title}</p>
<Table className="text-base">
<TableBody>
{lines.map((line, i) => (
<TableRow key={i} className="border-0 hover:bg-transparent">
<TableCell className="px-0 py-1.5">{line.label}</TableCell>
<TableCell className="px-0 py-1.5 text-right tabular-nums">{formatAmount(line.amount)}</TableCell>
</TableRow>
))}
</TableBody>
{total !== undefined && (
<TableFooter className="border-t bg-transparent">
<TableRow className="hover:bg-transparent">
<TableCell className="px-0 py-2 font-semibold">Total {title}</TableCell>
<TableCell className="px-0 py-2 text-right font-semibold tabular-nums">{formatAmount(total)}</TableCell>
</TableRow>
</TableFooter>
)}
</Table>
</div>
)
}
@@ -0,0 +1,23 @@
import { formatAmount } from "@/lib/format"
import { cn } from "@/lib/utils"
interface ReportSubtotalProps {
label: string
amount: number
large?: boolean
}
/** A bold, unbordered subtotal/total line (Gross Profit, Net Cash From Operations, the final total, etc.). */
export function ReportSubtotal({ label, amount, large }: ReportSubtotalProps) {
return (
<div
className={cn(
"mb-4 flex items-center justify-between rounded-lg bg-muted/40 px-4 py-3 font-bold",
large && "text-lg"
)}
>
<span>{label}</span>
<span className="tabular-nums">{formatAmount(amount)}</span>
</div>
)
}
@@ -43,23 +43,23 @@ const variantConfig: Record<
> = {
info: {
icon: InfoIcon,
iconClass: "text-sky-600 bg-sky-50",
ringClass: "ring-sky-100",
iconClass: "text-info bg-info/10",
ringClass: "ring-info/20",
},
warning: {
icon: AlertTriangleIcon,
iconClass: "text-amber-600 bg-amber-50",
ringClass: "ring-amber-100",
iconClass: "text-warning bg-warning/10",
ringClass: "ring-warning/20",
},
destructive: {
icon: XCircleIcon,
iconClass: "text-red-600 bg-red-50",
ringClass: "ring-red-100",
iconClass: "text-destructive bg-destructive/10",
ringClass: "ring-destructive/20",
},
success: {
icon: CheckCircle2Icon,
iconClass: "text-emerald-600 bg-emerald-50",
ringClass: "ring-emerald-100",
iconClass: "text-success bg-success/10",
ringClass: "ring-success/20",
},
}
@@ -92,7 +92,7 @@ function AlertDialogContent({
<AlertDialogPrimitive.Popup
data-slot="alert-dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-2xl bg-white p-6 shadow-xl ring-1 duration-150 outline-none sm:max-w-sm",
"fixed top-1/2 left-1/2 z-50 w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 rounded-2xl bg-popover p-6 shadow-xl ring-1 duration-150 outline-none sm:max-w-sm",
"data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
ringClass,
className
@@ -105,11 +105,11 @@ function AlertDialogContent({
</div>
<div className="flex flex-col gap-1.5">
<AlertDialogPrimitive.Title className="text-base font-bold text-slate-900">
<AlertDialogPrimitive.Title className="text-base font-bold text-foreground">
{title}
</AlertDialogPrimitive.Title>
{description && (
<AlertDialogPrimitive.Description className="text-sm text-slate-500">
<AlertDialogPrimitive.Description className="text-sm text-muted-foreground">
{description}
</AlertDialogPrimitive.Description>
)}
@@ -0,0 +1,322 @@
// Client for the external General Ledger service, reached through ERPCore's generic
// reverse-proxy at /api/v1/gl/* (docs/12-GENERAL-LEDGER-INTEGRATION.md). Deliberately NOT
// built on lib/api-client.ts's apiRequest/apiRequestWithETag: those assume ERPCore's own
// RFC 7807 ProblemDetails error shape and a bare-DTO success body. GL wraps every response
// (success AND error) in its own `{ statusCode, success, message, data }` envelope instead,
// and — a documented GL quirk — success bodies are camelCase while error bodies are
// PascalCase, so this module unwraps both forms itself rather than trusting one casing.
import {
CashAccountType,
CashAndBankAccountDto,
CashBankAccountType,
CreateBankAccountRequest,
CreateCashAccountRequest,
CreateCashOrBankAccountResponse,
GlAccountListResult,
GlBudget,
GlFilePayload,
ReportOutputFormat,
ReportType,
TrialBalanceRow,
BalanceSheetResponse,
GeneralLedgerRow,
ProfitAndLossResponse,
CashFlowResponse,
BudgetVsActualRow,
TaxSummaryResponse,
TaxSummaryParams,
GlPagedResult,
ChequeBook,
ChequeBookStatus,
ChequePage,
CreateChequeBookRequest,
IssueChequePageRequest,
UpdateChequePageStatusRequest,
ReceivedCheque,
ReceivedChequeStatus,
ReceivedFromType,
CreateReceivedChequeRequest,
UpdateReceivedChequeStatusRequest,
} from "@/types/general-ledger"
const GL_BASE = "/api/v1/gl"
/** Duck-type compatible with lib/error-map.ts's ApiErrorLike — `detail` carries GL's own message. */
export class GlApiError extends Error {
status: number
detail: string
constructor(status: number, message: string) {
super(message)
this.status = status
this.detail = message
}
}
interface GlEnvelope<T> {
statusCode?: number
StatusCode?: number
success?: boolean
Success?: boolean
message?: string
Message?: string
data?: T
Data?: T
// Surfaces only when the proxy itself fails before reaching GL (e.g. ERPCore's own
// 503 GL_SERVICE_UNAVAILABLE ProblemDetails) rather than GL's own envelope.
title?: string
detail?: string
}
type GlQueryValue = string | number | undefined
async function glRequest<T>(
path: string,
options: { method?: string; query?: Record<string, GlQueryValue>; body?: unknown } = {}
): Promise<T> {
const { method = "GET", query, body } = options
const search = new URLSearchParams()
if (query) {
for (const [key, value] of Object.entries(query)) {
if (value === undefined || value === "") continue
search.set(key, String(value))
}
}
const qs = search.toString()
const response = await fetch(`${GL_BASE}${path}${qs ? `?${qs}` : ""}`, {
method,
credentials: "include", // the proxy is ErpAccess-gated, same as every other v1 endpoint
headers: {
Accept: "application/json",
...(body !== undefined ? { "Content-Type": "application/json" } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
})
let envelope: GlEnvelope<T> | null = null
try {
envelope = (await response.json()) as GlEnvelope<T>
} catch {
// Non-JSON body — e.g. an unreachable proxy hop. Falls through to the generic message below.
}
const success = envelope?.success ?? envelope?.Success ?? false
if (!response.ok || !success) {
const message =
envelope?.message ?? envelope?.Message ?? envelope?.detail ?? envelope?.title ??
response.statusText ?? "General Ledger service request failed"
throw new GlApiError(response.status, message)
}
return (envelope?.data ?? envelope?.Data) as T
}
/** Decodes a base64 payload and triggers a browser download — no server round-trip needed. */
function downloadBase64File(base64: string, fileName: string, contentType: string) {
const byteChars = atob(base64)
const byteNumbers = new Array(byteChars.length)
for (let i = 0; i < byteChars.length; i++) byteNumbers[i] = byteChars.charCodeAt(i)
const blob = new Blob([new Uint8Array(byteNumbers)], { type: contentType })
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
/** Shared by downloadPdf/downloadCsv — same report call, only `outputFormat` differs. */
async function downloadReportFile(
reportType: ReportType,
outputFormat: ReportOutputFormat.Pdf | ReportOutputFormat.Csv,
params: Record<string, GlQueryValue>
): Promise<void> {
const payload = await glRequest<GlFilePayload>("/reports", {
query: { reportType, outputFormat, ...params },
})
downloadBase64File(payload.contentBase64, payload.fileName, payload.contentType)
}
export const reportsApi = {
trialBalance(asOfDate: string) {
return glRequest<TrialBalanceRow[]>("/reports", {
query: { reportType: ReportType.TrialBalance, outputFormat: ReportOutputFormat.Json, asOfDate },
})
},
/** Confirmed classified-statement shape (2026-07-31 rework) — see types/general-ledger.ts's BalanceSheetResponse note. */
balanceSheet(asOfDate: string) {
return glRequest<BalanceSheetResponse>("/reports", {
query: { reportType: ReportType.BalanceSheet, outputFormat: ReportOutputFormat.Json, asOfDate },
})
},
// GL's `accountCode` param is optional (renamed from `accountId` in GL's 2026-07-22 revision,
// CLAUDE.md Rule 8.2 on GL's side — behavior unchanged): omitted, this returns the true General
// Ledger — every postable account's own transactions together, each with its own running
// balance (resets per account), sorted by accountCode then entryDate. Supplying accountCode
// switches to "Account Ledger" mode (one account + its descendants, one running balance) — not
// used by this page; add it back with an accountCode param if a single-account view is needed later.
generalLedger(periodStart: string, periodEnd: string) {
return glRequest<GeneralLedgerRow[]>("/reports", {
query: { reportType: ReportType.GeneralLedger, outputFormat: ReportOutputFormat.Json, periodStart, periodEnd },
})
},
/** Nested-sections shape as of the 2026-07-22 rework — see types/general-ledger.ts's ProfitAndLossResponse note. */
profitAndLoss(periodStart: string, periodEnd: string) {
return glRequest<ProfitAndLossResponse>("/reports", {
query: { reportType: ReportType.ProfitAndLoss, outputFormat: ReportOutputFormat.Json, periodStart, periodEnd },
})
},
/** Confirmed structured-statement shape (2026-07-22 rework) — see types/general-ledger.ts's CashFlowResponse note; everything nests under `operatingActivities`. */
cashFlow(periodStart: string, periodEnd: string) {
return glRequest<CashFlowResponse>("/reports", {
query: { reportType: ReportType.CashFlow, outputFormat: ReportOutputFormat.Json, periodStart, periodEnd },
})
},
budgetVsActual(budgetId: number) {
return glRequest<BudgetVsActualRow[]>("/reports", {
query: { reportType: ReportType.BudgetVsActual, outputFormat: ReportOutputFormat.Json, budgetId },
})
},
/** Income Tax Computation — new report (2026-07-22). Optional params get no client-side default; an untouched field sends nothing, letting GL's own server-side defaulting be the single source of truth. */
taxSummary(params: TaxSummaryParams) {
return glRequest<TaxSummaryResponse>("/reports", {
query: { reportType: ReportType.TaxSummary, outputFormat: ReportOutputFormat.Json, ...params },
})
},
/** Same report call as the Json variants above, only `outputFormat` differs — the PDF bytes come from the same endpoint. */
downloadPdf(reportType: ReportType, params: Record<string, GlQueryValue>): Promise<void> {
return downloadReportFile(reportType, ReportOutputFormat.Pdf, params)
},
/** Same mechanism as downloadPdf, `outputFormat=Csv` — GL's bytes are downloaded unmodified, not reshaped/reformatted client-side. */
downloadCsv(reportType: ReportType, params: Record<string, GlQueryValue>): Promise<void> {
return downloadReportFile(reportType, ReportOutputFormat.Csv, params)
},
}
/**
* Chart of Accounts used by the Cash/Bank Accounts list page to resolve each row's `glAccountId`
* into a readable account code/name (retrofit 2026-07-31: the create form no longer needs this at
* all, since `glAccountCode` was removed from the create request the GL account is auto-created).
* The General Ledger **report** page deliberately does NOT use this: it always calls the report in
* full-ledger mode (no `accountCode`), so every account's code/name shown come from the report's own
* rows (`GeneralLedgerRow.accountCode`/`accountName`), not a separate `/accounts` call see
* docs/21-GENERAL-LEDGER-FRONTEND.md.
*/
export const glAccountsApi = {
list(): Promise<GlAccountListResult> {
return glRequest<GlAccountListResult>("/accounts")
},
}
/** Used only to populate the Budget vs Actual report's budget picker. */
export const glBudgetsApi = {
list(): Promise<GlBudget[]> {
return glRequest<GlBudget[]>("/budgets")
},
}
export const bankAccountsApi = {
/** GL's own server-side union of both tables (2026-07-22 rework) — `accountType` narrows which table(s) contribute rows; client-side filters still layer on top. */
list(accountType?: CashBankAccountType | "Both"): Promise<CashAndBankAccountDto[]> {
return glRequest<CashAndBankAccountDto[]>("/bank-accounts", { query: { accountType } })
},
createBank(request: CreateBankAccountRequest): Promise<CreateCashOrBankAccountResponse> {
return glRequest<CreateCashOrBankAccountResponse>("/bank-accounts", { method: "POST", body: request })
},
createCash(request: CreateCashAccountRequest): Promise<CreateCashOrBankAccountResponse> {
return glRequest<CreateCashOrBankAccountResponse>("/cash-accounts", { method: "POST", body: request })
},
// No get()/update(): GL exposes no GET/PUT by id for either bank_account or cash_account today
// (see docs/21-GENERAL-LEDGER-FRONTEND.md's "Known gap — edit").
}
/** Feeds the Cash/Bank create form's Cash Account Type picker; a name with no match creates a new type on the fly server-side (nothing to pre-create from this list). */
export const cashAccountTypesApi = {
list(): Promise<CashAccountType[]> {
return glRequest<CashAccountType[]>("/cash-account-types")
},
}
/**
* Cheque Books/Pages cheques issued from this company's own cheque books (Cheque Management
* module, added to GL 2026-07-30). `chequeBookNo` is the identifying value GL uses in its own
* routes, not a numeric id. No `list()`/`get()` for pages standalone a book's pages are always
* read via `get(chequeBookNo, true)`'s `pages[]`, which is the only place this frontend needs them.
*/
export const chequeBooksApi = {
list(params?: {
bankAccountId?: number
branchId?: number
status?: ChequeBookStatus
page?: number
pageSize?: number
}): Promise<GlPagedResult<ChequeBook>> {
return glRequest<GlPagedResult<ChequeBook>>("/cheque-books", { query: { ...params } })
},
/** `expandPages` maps to GL's `?expand=pages` — omit it for just the book's own fields. */
get(chequeBookNo: string, expandPages = false): Promise<ChequeBook> {
return glRequest<ChequeBook>(`/cheque-books/${encodeURIComponent(chequeBookNo)}`, {
query: expandPages ? { expand: "pages" } : undefined,
})
},
/** Auto-generates every leaf (`totalLeaves` `ChequePage` rows, all `Unused`) in the same call — the response's `pages[]` already has them. */
create(request: CreateChequeBookRequest): Promise<ChequeBook> {
return glRequest<ChequeBook>("/cheque-books", { method: "POST", body: request })
},
}
export const chequePagesApi = {
issue(chequeNo: string, request: IssueChequePageRequest): Promise<ChequePage> {
return glRequest<ChequePage>(`/cheque-pages/${encodeURIComponent(chequeNo)}/issue`, {
method: "PUT",
body: request,
})
},
/** `Clear`/`Bounce`/`Cancel`/`Void` — only valid from certain `issueStatus` values, see `types/general-ledger.ts`'s `ChequePageStatusAction`. */
updateStatus(chequeNo: string, request: UpdateChequePageStatusRequest): Promise<ChequePage> {
return glRequest<ChequePage>(`/cheque-pages/${encodeURIComponent(chequeNo)}/status`, {
method: "PUT",
body: request,
})
},
}
/** Received Cheques — cheques received from customers/suppliers/others, deliberately unlinked to any `ChequeBook`. */
export const receivedChequesApi = {
list(params?: {
companyId?: number
branchId?: number
status?: ReceivedChequeStatus
receivedFromType?: ReceivedFromType
page?: number
pageSize?: number
}): Promise<GlPagedResult<ReceivedCheque>> {
return glRequest<GlPagedResult<ReceivedCheque>>("/received-cheques", { query: { ...params } })
},
create(request: CreateReceivedChequeRequest): Promise<ReceivedCheque> {
return glRequest<ReceivedCheque>("/received-cheques", { method: "POST", body: request })
},
/** `Deposit`/`Clear`/`Return`/`Cancel` — only valid from certain statuses, see `types/general-ledger.ts`'s `ReceivedChequeStatusAction`. */
updateStatus(id: number, request: UpdateReceivedChequeStatusRequest): Promise<ReceivedCheque> {
return glRequest<ReceivedCheque>(`/received-cheques/${id}/status`, { method: "PUT", body: request })
},
}
+35
View File
@@ -0,0 +1,35 @@
// Formatting helpers for statutory-style financial reports (docs/21-GENERAL-LEDGER-FRONTEND.md) —
// comma-grouped thousands, fixed 2 decimals, negatives in parentheses (standard financial-statement
// convention), rather than the plain `.toFixed(2)` used by the inventory-side stock screens.
const AMOUNT_FORMATTER = new Intl.NumberFormat("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
/** `1234.5` -> "1,234.50"; `-1234.5` -> "(1,234.50)"; `0`/`null`/`undefined` -> the given fallback. */
export function formatAmount(value: number | null | undefined, zeroDash = false): string {
if (value === null || value === undefined || Number.isNaN(value)) return "—"
if (zeroDash && value === 0) return "—"
const formatted = AMOUNT_FORMATTER.format(Math.abs(value))
return value < 0 ? `(${formatted})` : formatted
}
/** `"2026-07-01"` / an ISO timestamp -> "01 Jul 2026" for report headers and tables. */
export function formatReportDate(value: string | null | undefined): string {
if (!value) return "—"
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric" })
}
/** Today's date as `YYYY-MM-DD`, for default report filter values. */
export function todayIso(): string {
return new Date().toISOString().slice(0, 10)
}
/** The first day of the current month as `YYYY-MM-DD`, for default period-start filter values. */
export function startOfMonthIso(): string {
const now = new Date()
return new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10)
}
@@ -0,0 +1,55 @@
// Client-side UX validation only (docs/20-FRONTEND.md §3.1) — required fields the browser
// already knows about. Everything else is server-authoritative and surfaced via the GL
// service's own error message (lib/error-map.ts).
export function validateBankAccountForm(input: { accountName: string }): Record<string, string> {
const errors: Record<string, string> = {}
if (!input.accountName.trim()) errors.accountName = "Account name is required"
return errors
}
export function validateChequeBookForm(input: {
branchId: string
bankAccountId: string
chequeBookNo: string
startChequeNo: string
endChequeNo: string
totalLeaves: string
receivedDate: string
}): Record<string, string> {
const errors: Record<string, string> = {}
if (!input.branchId.trim()) errors.branchId = "Branch ID is required"
if (!input.bankAccountId) errors.bankAccountId = "Select a bank account"
if (!input.chequeBookNo.trim()) errors.chequeBookNo = "Cheque book number is required"
if (!input.startChequeNo.trim()) errors.startChequeNo = "Start cheque number is required"
if (!input.endChequeNo.trim()) errors.endChequeNo = "End cheque number is required"
if (!input.totalLeaves.trim()) errors.totalLeaves = "Total leaves is required"
if (!input.receivedDate) errors.receivedDate = "Received date is required"
return errors
}
export function validateIssueChequeForm(input: { payeeName: string; issueDate: string; amount: string }): Record<string, string> {
const errors: Record<string, string> = {}
if (!input.payeeName.trim()) errors.payeeName = "Payee name is required"
if (!input.issueDate) errors.issueDate = "Issue date is required"
if (!input.amount.trim() || Number(input.amount) <= 0) errors.amount = "Amount must be greater than 0"
return errors
}
export function validateReceivedChequeForm(input: {
companyId: string
receivedFromName: string
chequeNo: string
chequeDate: string
amount: string
receivedDate: string
}): Record<string, string> {
const errors: Record<string, string> = {}
if (!input.companyId.trim()) errors.companyId = "Company ID is required"
if (!input.receivedFromName.trim()) errors.receivedFromName = "Received-from name is required"
if (!input.chequeNo.trim()) errors.chequeNo = "Cheque number is required"
if (!input.chequeDate) errors.chequeDate = "Cheque date is required"
if (!input.amount.trim() || Number(input.amount) <= 0) errors.amount = "Amount must be greater than 0"
if (!input.receivedDate) errors.receivedDate = "Received date is required"
return errors
}
+558
View File
@@ -0,0 +1,558 @@
// Mirrors the external General Ledger service's own contract (04_API_Reference_And_Scenarios.md
// in that service's repo — ERPCore does not re-document it, see docs/12-GENERAL-LEDGER-INTEGRATION.md).
// Every field here is read through ERPCore's generic proxy (`lib/api/general-ledger.ts`), which
// forwards byte-for-byte, so these types describe GL's response `data` shape directly, not an
// ERPCore DTO. As of GL's 2026-07-22 revision (docs/21-GENERAL-LEDGER-FRONTEND.md §0/§3/§4).
/** `account_type` seed rows — the block each drives in a generated account code (1000/2000/3000/4000/5000). */
export enum GlAccountTypeId {
Asset = 1,
Liability = 2,
Equity = 3,
Income = 4,
Expense = 5,
}
export interface GlAccount {
accountId: number
accountCode: string
accountName: string
accountTypeId: GlAccountTypeId
parentAccountId: number | null
isControlAccount: boolean
isPostable: boolean
isActive: boolean
currencyCode: string
cashFlowCategory?: string | null
}
export interface GlAccountListResult {
items: GlAccount[]
totalCount: number
page: number | null
pageSize: number | null
}
export interface GlBudget {
budgetId: number
fiscalYearId: number
name: string
lines: unknown[]
}
export enum ReportOutputFormat {
Json = "Json",
Pdf = "Pdf",
Csv = "Csv",
}
export enum ReportType {
TrialBalance = "TrialBalance",
BalanceSheet = "BalanceSheet",
GeneralLedger = "GeneralLedger",
ProfitAndLoss = "ProfitAndLoss",
CashFlow = "CashFlow",
BudgetVsActual = "BudgetVsActual",
TaxSummary = "TaxSummary",
}
/** Flat as of the 2026-07-22 revision — GL dropped the hierarchy (`depth`/`indentedCode`) this report used to carry. */
export interface TrialBalanceRow {
accountCode: string
accountName: string
debit: number
credit: number
}
/** One leaf account within a Balance Sheet section — no `depth`/hierarchy anymore (2026-07-31 rework, see BalanceSheetResponse). */
export interface BalanceSheetLine {
accountCode: string
accountName: string
balance: number
}
export interface BalanceSheetSection {
lines: BalanceSheetLine[]
total: number
}
/**
* Confirmed shape (04_API_Reference_And_Scenarios.md, Module: Reporting, retrofit 2026-07-31)
* replaces the old flat recursive-rollup array (`{depth, lineItem, accountType, balance}`) entirely
* with a classified LKAS 1 Statement of Financial Position: Non-Current/Current split for both
* Assets and Liabilities, driven by GL's new `accounts.balance_sheet_classification` tag.
* `equity.lines[]` always includes a synthetic `{ accountCode: "", accountName: "Current Year
* Earnings", balance }` line (even at `0.00`). Untagged leaf accounts land in
* `unclassifiedAssets`/`unclassifiedLiabilities` rather than being silently dropped.
*
* Every section is optional same defensive posture as `CashFlowResponse`/`ProfitAndLossResponse`
* (confirmed live: GL omits an empty section from the JSON entirely rather than sending
* `{ lines: [], total: 0 }`), applied here pre-emptively since this exact shape hasn't been
* live-verified against this frontend yet.
*/
export interface BalanceSheetResponse {
asOfDate: string
nonCurrentAssets?: BalanceSheetSection
currentAssets?: BalanceSheetSection
unclassifiedAssets?: BalanceSheetSection
totalAssets: number
equity?: BalanceSheetSection
nonCurrentLiabilities?: BalanceSheetSection
currentLiabilities?: BalanceSheetSection
unclassifiedLiabilities?: BalanceSheetSection
totalEquityAndLiabilities: number
}
export interface GeneralLedgerRow {
entryDate: string
journalNo: string
accountCode: string
accountName: string
narration: string | null
debitAmount: number
creditAmount: number
runningBalance: number
}
/** One line inside a Profit & Loss section — inferred shape (see the ProfitAndLossResponse note). */
export interface ProfitAndLossLine {
accountCode: string
accountName: string
amount: number
}
export interface ProfitAndLossSection {
lines: ProfitAndLossLine[]
total: number
}
/**
* Nested-sections shape (2026-07-22 rework, replaces the old flat `ProfitAndLossRow[]`). GL's own
* reference names the sections and the two top-level totals but does not spell out each line's
* exact field names `ProfitAndLossLine` above is an **inferred** shape (matching every other
* report's `accountCode`/`accountName` convention), not a confirmed contract. `unclassified` is
* only present with lines if the Chart of Accounts has untagged Income/Expense accounts.
*
* Every section is optional **confirmed live** (2026-07-31, via the identical bug on
* `CashFlowResponse`'s list fields below): GL's serializer omits a section from the JSON
* entirely when it has nothing to report for the period, rather than sending `{ lines: [], total: 0 }`.
* Every consumer must optional-chain (`report.sales?.lines`), never assume presence.
*/
export interface ProfitAndLossResponse {
sales?: ProfitAndLossSection
costOfSales?: ProfitAndLossSection
grossProfit: number
otherIncome?: ProfitAndLossSection
distributionExpenses?: ProfitAndLossSection
administrationExpenses?: ProfitAndLossSection
otherExpenses?: ProfitAndLossSection
financialExpenses?: ProfitAndLossSection
unclassified?: ProfitAndLossSection
netProfitForPeriod: number
}
export interface CashFlowNonCashAdjustment {
description: string
amount: number
}
/** `changeAmount`, not `amount` — confirmed field name (04_API_Reference_And_Scenarios.md, Module: Reporting). */
export interface CashFlowWorkingCapitalChange {
accountCode: string
accountName: string
direction: "Increase" | "Decrease"
changeAmount: number
}
/** One line inside an Investing/Financing section GL's reference confirms `lines[]` exists but not
* this line's own field names; `{description, amount}` here matches every other report's line-item
* convention but is not verified verbatim. */
export interface CashFlowActivityLine {
description: string
amount: number
}
export interface CashFlowOperatingActivities {
profitForPeriod: number
nonCashAdjustments: CashFlowNonCashAdjustment[]
workingCapitalChanges: CashFlowWorkingCapitalChange[]
netCashFromOperatingActivities: number
}
export interface CashFlowInvestingActivities {
lines: CashFlowActivityLine[]
netCashFromInvestingActivities: number
}
export interface CashFlowFinancingActivities {
lines: CashFlowActivityLine[]
netCashFromFinancingActivities: number
}
/**
* Confirmed shape (04_API_Reference_And_Scenarios.md, Module: Reporting GL's own API reference,
* not inferred). **Everything nests under `operatingActivities`** the previous version of this
* type had `netEarnings`/`nonCashAdjustments`/`workingCapitalChanges`/`netCashFromOperations` as
* flat top-level fields, which was wrong and caused a live runtime crash (`Cannot read properties
* of undefined (reading 'map')` on `nonCashAdjustments` — it was never at the top level to begin
* with). `investingActivities`/`financingActivities` each have their own differently-named total
* field (`netCashFromInvestingActivities`/`netCashFromFinancingActivities`), not a shared `total`.
* `openingCashBalance`/`closingCashBalance` are returned for validation but deliberately not
* rendered on screen GL's own PDF/CSV doesn't print them either.
*/
export interface CashFlowResponse {
periodStart: string
periodEnd: string
operatingActivities: CashFlowOperatingActivities
investingActivities: CashFlowInvestingActivities
financingActivities: CashFlowFinancingActivities
netIncreaseDecreaseInCash: number
openingCashBalance: number
closingCashBalance: number
}
export interface BudgetVsActualRow {
budgetLineId: number
accountCode: string
accountName: string
periodId: number
budgetedAmount: number
actualAmount: number
variance: number
}
/**
* Income Tax Computation (2026-07-22 redesign). Confirmed shape (04_API_Reference_And_Scenarios.md,
* Module: Reporting) not inferred. The previous version of this type was missing
* `nonDeductibleExpenses`, `corporateIncomeTax`, `apitCredit`, `whtCredit`, and
* `quarterlyTaxPayments` entirely, which meant the Tax Report screen was silently dropping real
* GL-computed figures rather than a made-up guess just being wrong. Full row order,
* `profitBeforeTax` `balanceTaxPayable`, with bold reconciliation checkpoints at
* `adjustedBusinessProfit`/`assessableIncome`/`taxableIncome`/`grossTaxLiability`/the final total.
*/
export interface TaxSummaryResponse {
periodStart: string
periodEnd: string
profitBeforeTax: number
nonDeductibleExpenses: number
allowableDeductions: number
adjustedBusinessProfit: number
otherTaxableIncome: number
assessableIncome: number
qualifyingPaymentsReliefs: number
taxableIncome: number
taxRatePercent: number
corporateIncomeTax: number
surchargeAmount: number
grossTaxLiability: number
apitCredit: number
whtCredit: number
quarterlyTaxPayments: number
balanceTaxPayable: number
}
export interface TaxSummaryParams {
periodStart: string
periodEnd: string
allowableDeductions?: number
otherTaxableIncome?: number
qualifyingPaymentsReliefs?: number
surchargeAmount?: number
taxRateOverride?: number
}
/** `outputFormat=Pdf`/`Csv` response shape — base64-encoded bytes inside the normal envelope either way. */
export interface GlFilePayload {
fileName: string
contentType: string
contentBase64: string
}
/** Cash and Bank are two separate GL tables/endpoints (2026-07-22 rework) — this discriminates the unified list row and the create-form toggle, not a database column on either side. */
export enum CashBankAccountType {
Cash = "Cash",
Bank = "Bank",
}
/**
* `GET /bank-accounts?accountType=Cash|Bank|Both` row shape (2026-07-22 rework) GL's own reference
* documents this explicitly (`docs/21` §4), unlike the old single-table `BankAccount` shape it
* replaces, which was inferred. `bankName` is `null` on `Cash` rows, `cashAccountTypeName` is `null`
* on `Bank` rows.
*/
export interface CashAndBankAccountDto {
accountType: CashBankAccountType
accountId: number
accountName: string
bankName: string | null
cashAccountTypeName: string | null
accountNumber: string | null
glAccountId: number
currencyCode: string
createdAt: string
}
/**
* Confirmed (04_API_Reference_And_Scenarios.md, Module: Bank, retrofit 2026-07-31)
* `glAccountCode` was removed from this request entirely. The backing GL account is now always
* auto-created server-side (a root "Bank" header account is found-or-created, then a postable leaf
* named after `accountName` is created under it) the caller never selects or supplies a GL account.
*/
export interface CreateBankAccountRequest {
accountName: string
bankName?: string | null
accountNumber?: string | null
currencyCode?: string
}
/**
* Confirmed (retrofit 2026-07-31) same `glAccountCode` removal as `CreateBankAccountRequest`, plus
* one more auto-created level: a root "Cash" header, then a per-`cashAccountTypeName` header (created
* once, reused thereafter), then a postable leaf named after `accountName`.
*/
export interface CreateCashAccountRequest {
accountName: string
cashAccountTypeName: string
accountNumber?: string | null
currencyCode?: string
}
/**
* `POST /bank-accounts` / `POST /cash-accounts` response (retrofit 2026-07-31) since the GL account
* is now auto-created rather than caller-supplied, the created leaf account (nested under its
* auto-created/reused header via `parentAccount`) is returned under `glAccount` so the caller can see
* exactly what was generated. `glAccount`/`glAccountId` are confirmed from the reference doc; the
* other fields are inferred (they mirror the create request's own fields plus an id, following this
* project's usual `<entity>` response convention).
*/
export interface CreateCashOrBankAccountResponse {
accountName: string
bankName?: string | null
cashAccountTypeName?: string | null
accountNumber?: string | null
currencyCode: string
glAccountId: number
glAccount: GlAccount & { parentAccount?: GlAccount | null }
}
/** `GET /cash-account-types` row — flat reference list (seeded Petty Cash / Till Cash / Safe Cash / Cash in Transit, grows over time via on-the-fly creation from the create form). */
export interface CashAccountType {
cashAccountTypeId: number
name: string
}
/** Shared `{ items, totalCount, page, pageSize }` list envelope used by every Cheque Management list endpoint. */
export interface GlPagedResult<T> {
items: T[]
totalCount: number
page: number | null
pageSize: number | null
}
// ---------------------------------------------------------------------------
// Cheque Management (new GL module, added 2026-07-30, beyond the original plan).
// Purely operational tracking — no endpoint here ever creates/touches a journal entry itself.
// `branchId`/`companyId`/`payeeId`/`voucherId`/`referenceId` are documented as deliberately
// "loose references" (plain unvalidated numbers) — no Branch/Company/Customer/Supplier table
// exists in this GL service for them to point at, so the frontend takes them as free-entry
// numbers rather than picker dropdowns, matching GL's own stated design.
// ---------------------------------------------------------------------------
export enum PayeeType {
Supplier = "Supplier",
Customer = "Customer",
Employee = "Employee",
Other = "Other",
}
export enum ReceivedFromType {
Customer = "Customer",
Supplier = "Supplier",
Other = "Other",
}
export enum ChequeBookStatus {
Active = "Active",
Completed = "Completed",
Cancelled = "Cancelled",
}
export enum ChequePageIssueStatus {
Unused = "Unused",
Issued = "Issued",
Cleared = "Cleared",
Bounced = "Bounced",
Cancelled = "Cancelled",
Void = "Void",
}
/** `PUT /cheque-pages/{chequeNo}/status`'s `action` values. */
export enum ChequePageStatusAction {
Clear = "Clear",
Bounce = "Bounce",
Cancel = "Cancel",
Void = "Void",
}
export enum ReceivedChequeStatus {
Received = "Received",
Deposited = "Deposited",
Cleared = "Cleared",
Returned = "Returned",
Cancelled = "Cancelled",
}
/** `PUT /received-cheques/{id}/status`'s `action` values. */
export enum ReceivedChequeStatusAction {
Deposit = "Deposit",
Clear = "Clear",
Return = "Return",
Cancel = "Cancel",
}
/**
* A single leaf of a Cheque Book. GL's own reference confirms every field named in the `issue`
* request body plus `issueStatus`/`printedAt`/`clearedDate`/`clearedByBank`/`cancelReason` in
* prose this type is built from those, not a guessed shape. `chequeNo` (not a numeric id) is
* the documented identifying value for `GET/PUT /cheque-pages/{chequeNo}`, so it's used as the
* key/URL param throughout rather than an unconfirmed `chequePageId`.
*/
export interface ChequePage {
chequeNo: string
chequeBookNo?: string
issueStatus: ChequePageIssueStatus
payeeType: PayeeType | null
payeeId: number | null
payeeName: string | null
issueDate: string | null
amount: number | null
currencyCode: string | null
voucherId: number | null
referenceNo: string | null
purpose: string | null
isCrossCheque: boolean | null
isAccountPayee: boolean | null
isPostDated: boolean | null
notes: string | null
printedBy: string | null
printedAt: string | null
clearedDate: string | null
clearedByBank: boolean | null
cancelReason: string | null
}
/**
* A cheque book issued from this company's own supply. `chequeBookNo` (caller-supplied, unique)
* is the documented identifying value for `GET /cheque-books/{chequeBookNo}`, used as the
* key/URL param throughout. `pages[]` is only populated when fetched with `?expand=pages`.
*/
export interface ChequeBook {
chequeBookNo: string
branchId: number
bankAccountId: number
startChequeNo: string
endChequeNo: string
totalLeaves: number
receivedDate: string
description: string | null
createdBy: string | null
status: ChequeBookStatus
pages: ChequePage[]
}
export interface CreateChequeBookRequest {
branchId: number
bankAccountId: number
chequeBookNo: string
startChequeNo: string
endChequeNo: string
totalLeaves: number
receivedDate: string
description?: string
createdBy?: string
}
export interface IssueChequePageRequest {
payeeType: PayeeType
payeeId?: number
payeeName: string
issueDate: string
amount: number
currencyCode?: string
voucherId?: number
referenceNo?: string
purpose?: string
isCrossCheque?: boolean
isAccountPayee?: boolean
isPostDated?: boolean
notes?: string
printedBy?: string
}
export interface UpdateChequePageStatusRequest {
action: ChequePageStatusAction
/** Required only for `action: "Clear"`. */
clearedDate?: string
/** Required only for `action: "Cancel"`. */
cancelReason?: string
performedBy?: string
}
/**
* A cheque received from a customer/supplier/other party deliberately has no link to a
* `ChequeBook` (it isn't one of this company's own). GL's reference uses a numeric `{id}` in the
* URL for `GET/PUT /received-cheques/{id}` without spelling out the JSON field's exact name
* `receivedChequeId` follows this project's consistent `<entity>Id` convention (e.g. `assetId`,
* `disposalId`), not a confirmed literal.
*/
export interface ReceivedCheque {
receivedChequeId: number
companyId: number
branchId: number | null
receivedFromType: ReceivedFromType
receivedFromId: number | null
receivedFromName: string
drawerBankName: string | null
drawerBankBranch: string | null
accountHolderName: string | null
chequeNo: string
chequeDate: string
amount: number
receivedDate: string
referenceType: string | null
referenceId: number | null
notes: string | null
createdBy: string | null
status: ReceivedChequeStatus
depositBankAccountId: number | null
depositDate: string | null
}
export interface CreateReceivedChequeRequest {
companyId: number
branchId?: number
receivedFromType: ReceivedFromType
receivedFromId?: number
receivedFromName: string
drawerBankName?: string
drawerBankBranch?: string
accountHolderName?: string
chequeNo: string
chequeDate: string
amount: number
receivedDate: string
referenceType?: string
referenceId?: number
notes?: string
createdBy?: string
}
export interface UpdateReceivedChequeStatusRequest {
action: ReceivedChequeStatusAction
/** Required only for `action: "Deposit"`. */
depositBankAccountId?: number
/** Required only for `action: "Deposit"`. */
depositDate?: string
notes?: string
performedBy?: string
}
+5 -1
View File
@@ -45,7 +45,9 @@ erp-monorepo/
├── 02-SECURITY.md # accepted-risks register + per-feature security checklist
├── 10-BACKEND-PHASE1.md # backend spec: SRS + ER/entities + tech + architecture
├── 11-BACKEND-PHASE1.md # backend API reference (complete req/res)
── 20-FRONTEND.md # frontend user-flows + architecture rules + validation posture
── 12-GENERAL-LEDGER-INTEGRATION.md # ERPCore ↔ external General Ledger service (transport only)
├── 20-FRONTEND.md # frontend user-flows + architecture rules + validation posture
└── 21-GENERAL-LEDGER-FRONTEND.md # Ledgers section: reports UI + cash/bank accounts
```
The `Backend/ERPCore/` internal layout is created in §5.3.
@@ -340,6 +342,8 @@ All frontend work is governed by `20-FRONTEND.md`.
| API endpoints, request/response shapes, error catalog, enums (Phase 1) | **`11-BACKEND-PHASE1.md`** |
| HRM requirements, business rules, entities, ER model (Phase 2) | **`12-BACKEND-HRM.md`** |
| HRM API endpoints, request/response shapes, error catalog (Phase 2) | **`13-BACKEND-HRM-API.md`** |
| Connecting to the external General Ledger service (proxy, config, API key) | **`12-GENERAL-LEDGER-INTEGRATION.md`** |
| The Ledgers frontend section (reports, cash/bank accounts) | **`21-GENERAL-LEDGER-FRONTEND.md`** |
| Frontend user-flows, screen flow, architecture rules, validation posture (Phase 1) | **`20-FRONTEND.md`** |
| HRM frontend user-flows (Phase 2) | **`21-FRONTEND-HRM.md`** |
| Manufacturing requirements, business rules, entities, stock/costing integration, API (Phase 2) | **`30-BACKEND-PHASE2.md`** |
+2
View File
@@ -38,10 +38,12 @@
| `11-BACKEND-PHASE1.md` | High | Backend **API reference**: every endpoint with complete request/response, error catalog, enums. | Any API contract / controller / client work (Phase 1). | Claude + humans |
| `12-BACKEND-HRM.md` | High | HRM backend spec: SRS, ER model, HRM-specific architecture notes. Schema is **authoritative** here. | Any HRM model / business-rule / requirement work (Phase 2). | Claude + humans |
| `13-BACKEND-HRM-API.md` | High | HRM **API reference**: every endpoint, error catalog additions, enums. | Any HRM API contract / controller work. | Claude + humans |
| `12-GENERAL-LEDGER-INTEGRATION.md` | High | ERPCore ↔ external **General Ledger service**: connection/proxy contract, config, progress. GL's own endpoint contract lives in the GL service's own repo, not here. | Any work touching the GL proxy or a future internal GL caller. | Claude + humans |
| `20-FRONTEND.md` | High | Frontend **user-flows**, architecture rules to follow, **validation posture**. | Any frontend work (Phase 1). | Claude + humans |
| `21-FRONTEND-HRM.md` | High | HRM frontend **user-flows**, screens, validation specifics. | Any HRM frontend work. | Claude + humans |
| `30-BACKEND-PHASE2.md` | High | Manufacturing (Production Lines) backend: SRS, ER model, status machines, stock/costing integration, **and** the full API contract — model and API in one doc, unlike Phase 1. Schema is **authoritative** here. | Any manufacturing model / rule / API work. | Claude + humans |
| `21-FRONTEND-PHASE2.md` | High | Manufacturing frontend **user-flows**: template canvas builder, run board, run execution screens. | Any manufacturing frontend work. | Claude + humans |
| `21-GENERAL-LEDGER-FRONTEND.md` | High | **Ledgers** sidebar section: statutory-format report screens + PDF download, Cash/Bank Accounts (list/create; edit gap explained). Extends `20-FRONTEND.md` rather than duplicating it. | Any work on `app/dashboard/ledgers/*`. | Claude + humans |
| `Backend/PROGRESS.md` | Low | Backend **change checklist**, git-shared. | After making backend changes. | **Claude** |
| `Frontend/PROGRESS.md` | Low | Frontend **change checklist**, git-shared. | After making frontend changes. | **Claude** |
+156
View File
@@ -0,0 +1,156 @@
# 12 · GENERAL LEDGER INTEGRATION — ERPCore ↔ External GL Service
> **Navigation:** you arrived from `00-CORE.md`. This doc is the **connection layer only** — the transport between ERPCore and the external General Ledger service. It is not the GL service's own API contract: every endpoint, request/response shape, and error code the GL service exposes is documented in that service's own repo (`04_API_Reference_And_Scenarios.md`, its `01_Full_Development_Plan.md`/`02_Phase_By_Phase_Development_Plan.md`/`03_Progress_Tracker.md` companions, and its Postman collection) — ERPCore does not duplicate that content here (`01-DOC-GUIDE.md` §5, single source of truth per topic).
> **Record work:** `Backend/PROGRESS.md`.
---
## 1. What this is
The General Ledger service is a **separate ASP.NET Core microservice** — its own repo, own database, no UI, no end-user login. It is called server-to-server, authenticated with a single shared secret (`X-Api-Key`), by "the core backend" — which, from this repo's side, is ERPCore.
This pass connects the two systems at the **transport level only**:
- A generic reverse-proxy endpoint on ERPCore (`/api/v1/gl/*`) that the frontend can call, which forwards the request to the GL service and returns its response unchanged.
- A service-layer function (`IGeneralLedgerService.ForwardAsync`) that does the actual forwarding — usable today by the controller, and **intended to be called directly by other ERPCore services later** (GRN confirm, adjustments, etc. posting real journal entries into GL instead of — or alongside — the local `JournalEntryStub`). That internal wiring is **explicitly deferred**; nothing in ERPCore calls GL yet except the proxy itself.
There is **no GL-specific business logic, no typed DTOs, and no per-endpoint validation** in ERPCore for this integration. Every request under `/api/v1/gl/*` is forwarded byte-for-byte — method, path, query string, request body, and `Content-Type` — and GL's response (status code, content type, body) comes back exactly as GL sent it, un-reshaped.
---
## 2. Architecture
```
Frontend ──► ERPCore /api/v1/gl/{**path} (ErpAccess door policy, same as every other v1 endpoint)
GeneralLedgerController (Controllers/GeneralLedgerController.cs)
│ Request.Method, path, QueryString, ContentType, Body — untouched
IGeneralLedgerService.ForwardAsync (Services/GeneralLedgerService.cs)
│ the one function — controller and future internal callers both use this
IGeneralLedgerClient.SendAsync (Infra/Gl/GeneralLedgerClient.cs)
│ attaches X-Api-Key, streams body through, no parsing
External GL service (GeneralLedgerService:BaseUrl)
```
**Why a generic proxy and not typed endpoints:** the GL service's own reference (`04_API_Reference_And_Scenarios.md`) documents ~15 modules and 40+ endpoint variations (Accounts, Fiscal Calendar, Currency, Cost Centers, Journal Entries, Recurring Entries, Tax, Fixed Assets, Bank, Budget, Reporting, System Config, Audit Log). Modelling all of that as ERPCore DTOs before any of it is actually consumed would be premature — this pass connects the pipe; the next pass (deferred, see §6) picks specific GL calls to wire into specific ERPCore workflows and can build typed request/response DTOs for exactly those calls at that point.
**Layering matches the existing AuthHex integration** (`Infra/Auth/AuthHex/IAuthHexClient``Services/Auth/*``AuthController`), except AuthHex's client unwraps a known envelope into typed results, while GL's client deliberately does not — see §4.
---
## 3. Configuration
`Backend/ERPCore/appsettings.json`:
```json
"GeneralLedgerService": {
"BaseUrl": "https://localhost:7024/api/v1/",
"ApiKey": ""
}
```
| Key | Purpose |
|---|---|
| `GeneralLedgerService:BaseUrl` | The GL service's base URL, **including** `/api/v1/` and a trailing slash (so relative paths combine correctly against `HttpClient.BaseAddress`). GL's own docs note it calls `UseHttpsRedirection()` before its API-key check runs — use the `https://` URL directly, per GL's own integration guide §3, to avoid every call taking a `307` round-trip. |
| `GeneralLedgerService:ApiKey` | The shared secret sent as `X-Api-Key` on every forwarded call. **Ships blank** — same pattern as this project's own `ApiKey`-style secrets (02-SECURITY AR-06): get a value from the GL service's own `POST /api/v1/dev-tools/generate-api-key` (Development only) and paste it in here *and* into the GL service's own `ApiKey:Value` config — it is one shared string on both sides, not a per-caller credential. |
Not touched by this pass: `appsettings.Development.json` / `appsettings.Production.json` — fill in a real key locally the same way `AuthHex:BaseUrl` and other per-environment values are handled today (edit the file directly for local dev; environment-variable override for prod, per `00-CORE.md §5.4`'s secrets note).
---
## 4. The proxy contract
### `{GET|POST|PUT} /api/v1/gl/{**path}`
- **Auth:** requires a valid ERPCore session (AuthHex-issued token, `erp_at` cookie or Bearer header) satisfying the `ErpAccess` door policy — identical to every other `/api/v1/*` endpoint (`ApiControllerBase`). The GL `X-Api-Key` itself is attached **server-side only**; the frontend never sees it and cannot set it.
- **Method:** only `GET`/`POST`/`PUT` are exposed — the GL service's own API reference has no `PATCH` or `DELETE` endpoint anywhere in its surface, so those verbs aren't wired.
- **`{**path}`:** everything after `/api/v1/gl/` is forwarded as-is to the GL service's own `/api/v1/{path}` — e.g. a frontend call to `POST /api/v1/gl/journal-entries` reaches GL's `POST /api/v1/journal-entries`. Query string is forwarded unchanged.
- **Body:** for `POST`/`PUT`, the raw request body (and its `Content-Type` header) is streamed straight through — this is what makes the one `multipart/form-data` endpoint (bank statement import) work through the proxy without ERPCore needing to understand multipart at all.
- **Response:** GL's status code, `Content-Type`, and body are returned to the frontend **unchanged**. This deliberately preserves two documented GL quirks rather than papering over them:
- GL's success bodies are camelCase (`{"statusCode":200,...}`), its error bodies (from its own exception/API-key middleware) are PascalCase (`{"StatusCode":400,...}`) — GL's own docs flag this as a known inconsistency, not fixed on their side yet. ERPCore does not normalize it; the frontend must handle both shapes (case-insensitive deserialization), same as GL's own integration guide recommends to *its* callers.
- `outputFormat=Json` report amounts keep full decimal precision (no rounding) — since ERPCore never deserializes the body into its own model, nothing here can accidentally truncate it.
- **This proxy needs zero code changes whenever GL's own domain contract evolves — confirmed again as of GL's 2026-07-22 revision (§7).** Five reports were restructured, `outputFormat` gained a `Csv` value, Cash/Bank accounts split into two endpoints, and numerous fields were renamed from numeric IDs to business codes (`CLAUDE.md` Rule 8.2 on GL's side) — none of it touched this file. That's the entire point of a byte-for-byte proxy over typed per-endpoint DTOs (§2's rationale): a generic passthrough only ever needs a code change when the *transport* contract changes (new HTTP verb, new content type, new auth mechanism), never when GL's own request/response payloads gain, lose, or rename fields. §7 is a curated reference of what's actually being called today, kept for the frontend team's convenience — it is documentation, not something this proxy depends on or needs updated in lockstep with.
### Failure modes added by ERPCore itself
| Case | Status | `code` |
|---|---|---|
| GL service unreachable (connection refused/DNS/etc.) | 503 | `GL_SERVICE_UNAVAILABLE` |
| GL service timed out | 503 | `GL_SERVICE_UNAVAILABLE` |
| No/invalid ERPCore session | 401/403 | (standard ERPCore door-policy response — request never reaches the proxy) |
Everything else — `401` for a bad `X-Api-Key` (can't happen here since ERPCore always attaches the configured key, but a misconfigured/blank key on either side would surface as this), every domain `400`/`404`/`409`/`422` GL itself returns — passes straight through with GL's own body and message, untouched.
---
## 5. What is *not* done in this pass
- **No internal service calls into GL.** `IGeneralLedgerService.ForwardAsync` exists and is registered in DI, but no ERPCore service (GRN, Adjustment, Transfer, PO, etc.) calls it yet. The local `JournalEntryStub` (docs/10 §C.7, FR-STK-13) is untouched and remains the only GL-adjacent artifact those flows produce today.
- **No typed request/response DTOs** for any GL endpoint — the proxy is byte-for-byte, so nothing in ERPCore currently understands GL's schema.
- **No path allowlist.** The proxy forwards *any* path under `/api/v1/gl/` to GL — it trusts GL's own routing/auth to reject anything invalid. Acceptable for now because the proxy is still gated by ERPCore's own door policy (only ERP-admitted users reach it at all) and GL requires its own valid `X-Api-Key` regardless, but worth revisiting alongside per-endpoint RBAC (02-SECURITY Part D) if GL exposes anything sufficiently destructive to warrant narrowing.
- **No `appsettings.Development.json`/`appsettings.Production.json` changes** — only the base `appsettings.json` shape was added, per this pass's scope (connect the pipe, not configure every environment).
- **No CORS/rate-limiting review specific to this proxy** — it inherits whatever ERPCore already has (currently none, same accepted gap as `AuthController`, 02-SECURITY AR-08).
---
## 6. Progress
- [x] **Transport wired (2026-07-20).** `Infra/Gl/{IGeneralLedgerClient,GeneralLedgerClient,GeneralLedgerResponse}` — typed `HttpClient` (`GeneralLedgerService:BaseUrl`), attaches `X-Api-Key` (`GeneralLedgerService:ApiKey`), streams request/response bodies through unparsed. `Services/{Interfaces/IGeneralLedgerService,GeneralLedgerService}` — the shared forwarding function. `Controllers/GeneralLedgerController``GET|POST|PUT /api/v1/gl/{**path}`, `ErpAccess`-gated like every other v1 controller. Config keys added to `appsettings.json`. Build verified clean (`dotnet build`, 0 warnings/0 errors).
- [ ] **Live smoke test against a running GL instance** — not yet run in this pass (no GL instance available in this session). Before relying on this in anger: generate a dev API key from GL's `POST /api/v1/dev-tools/generate-api-key`, put it in both sides' config, and round-trip at least one call per HTTP verb (e.g. `GET /api/v1/gl/accounts`, `POST /api/v1/gl/accounts`, `PUT /api/v1/gl/accounts/{code}`) plus the multipart import call, to confirm the byte-for-byte passthrough holds in practice (headers, streaming, status codes).
- [ ] **Internal wiring** — pick the first real ERPCore→GL use case (most likely candidate: posting `FifoCostingService`/`StockMutator`-driven movements as real GL journal entries instead of `JournalEntryStub` rows) and build the typed request/response DTOs + service calls for that specific flow through `IGeneralLedgerService`. Deliberately deferred per this pass's scope.
- [ ] **Security pass once real traffic flows** — revisit the "no path allowlist" note in §5, decide whether `GeneralLedgerService:ApiKey` needs to move to a secret store before any shared/staging use (same trigger as `02-SECURITY.md` AR-06), and whether this proxy needs its own accepted-risk entry there.
---
## 7. Endpoint reference — GL endpoints currently used (or planned) by the frontend
**Purpose of this section:** a quick-reference summary of exactly which GL endpoints the frontend calls, for connecting new screens without re-reading GL's full `04_API_Reference_And_Scenarios.md` from scratch each time. **This is a curated summary, not the canonical contract** — for exact request/response shapes, every field, and every error case, GL's own reference stays authoritative (`01-DOC-GUIDE.md §5`). Every call below goes through this proxy at `/api/v1/gl/{path}` (drop the leading `/api/v1` from GL's own documented paths, prefix `/api/v1/gl` instead — e.g. GL's `GET /api/v1/reports` is called here as `GET /api/v1/gl/reports`).
As of GL's 2026-07-22 revision. Full detail for anything marked "see GL §X" is in GL's `04_API_Reference_And_Scenarios.md` at that section.
### Reporting (`docs/21` §3 — all seven Ledgers report screens)
| Method | Path | Used for | Key params |
|---|---|---|---|
| `GET` | `/reports` | All seven report screens, one shared endpoint | `reportType` (`GeneralLedger`\|`TrialBalance`\|`BalanceSheet`\|`ProfitAndLoss`\|`CashFlow`\|`BudgetVsActual`\|`TaxSummary`), `outputFormat` (`Json`\|`Pdf`\|`Csv`), plus report-specific params — see GL §"GET /reports" for the full per-`reportType` table |
One endpoint, one row — every report screen and both download buttons (`DownloadPdfButton`/`DownloadCsvButton`) call this same path with different query parameters. Nothing else in this module hits a second reporting endpoint.
### Accounts (`docs/21` §3/§4 — GL-account pickers only, never a screen of its own here)
| Method | Path | Used for | Key params |
|---|---|---|---|
| `GET` | `/accounts` | Populates the `glAccountCode` picker on the Bank/Cash create forms | none required — full list |
This module never creates, edits, or has a dedicated screen for GL's own Chart of Accounts — `GET /accounts` is called purely to feed a `code — name` dropdown elsewhere.
### Bank & Cash Accounts (`docs/21` §4)
| Method | Path | Used for | Key params |
|---|---|---|---|
| `GET` | `/bank-accounts` | Unified Cash+Bank list page | `accountType` (optional — `Cash`\|`Bank`\|`Both`, default `Both`) |
| `POST` | `/bank-accounts` | Create form, "Bank" selected | `accountName`, `bankName`/`accountNumber` (optional), `glAccountCode`, `currencyCode` (optional) |
| `POST` | `/cash-accounts` | Create form, "Cash" selected | `accountName`, `cashAccountTypeName`, `accountNumber` (optional, auto-generated if omitted), `glAccountCode`, `currencyCode` (optional) |
| `GET` | `/cash-account-types` | Populates the Cash Account Type picker on the create form | none — full list |
| `POST` | `/bank-accounts/{id}/statement-lines/import` | Not yet built on the frontend — bank statement `.xlsx` import, `multipart/form-data` | field name `file` |
| `PUT` | `/bank-statement-lines/{id}/reconcile` | Not yet built on the frontend — reconcile a statement line | `matchedJournalNo` |
No `GET`/`PUT` by id exists on GL's side for either `bank_account` or `cash_account` — this is the entire reason edit can't be built yet (`docs/21` §4's known gap).
### Budgets (`docs/21` §3 — picker only)
| Method | Path | Used for | Key params |
|---|---|---|---|
| `GET` | `/budgets` | Populates the `budgetId` picker on the Budget vs Actual report screen | none — full list |
### Not currently called by this frontend, listed for awareness
GL exposes a substantially larger surface than this module touches — Journal Entries, Fiscal Calendar, Cost Centers, Currency, Tax Codes, Fixed Assets, Recurring Templates, System Config, Audit Log. None of these have a screen in `docs/21` today. If a future pass adds one, add its row to the relevant table above rather than leaving this reference to drift out of sync with what's actually called — that's the whole value of keeping this section current.
---
*End of 12-GENERAL-LEDGER-INTEGRATION.md. Hub: `00-CORE.md`. Record work: `Backend/PROGRESS.md`.*
+532
View File
@@ -0,0 +1,532 @@
# 21 · GENERAL LEDGER — Frontend (Ledgers section)
> **Navigation:** you arrived from `00-CORE.md`. The transport this section calls through is
> `docs/12-GENERAL-LEDGER-INTEGRATION.md` (ERPCore's `/api/v1/gl/*` proxy into the external General
> Ledger service — that doc's new §7 has a curated endpoint reference for everything this page uses).
> General frontend architecture/validation rules are `20-FRONTEND.md` — this doc only adds what's
> specific to the Ledgers screens. Record work in `Frontend/PROGRESS.md`.
> **GL's own endpoint contract** (exact request/response shapes, error cases) lives in the General
> Ledger service's own repo (`04_API_Reference_And_Scenarios.md`) — not duplicated here in full, per
> `01-DOC-GUIDE.md §5`'s single-source-of-truth rule; `docs/12` §7 is a summary for quick reference,
> that doc is the canonical detail.
---
## 0. Major update, 2026-07-22 — the GL backend changed substantially since this section was last built
**Nothing in this pass has been implemented against the frontend codebase yet.** The 2026-07-20 build (§6's history) reflects an earlier, now-superseded GL contract. This revision describes the *target* design against the confirmed, live-verified-on-GL's-side contract as of 2026-07-22 — every section below should be read as "what to build," not "what exists," until §6 says otherwise.
Three things changed on GL's side, in order of how much they affect this doc:
1. **Five reports got real structural rework** (not just field renames) — Trial Balance flattened, Profit & Loss restructured into named sections, Cash Flow's display simplified, Tax Report **completely redesigned** into an Income Tax Computation (previously a VAT/WHT/NBT summary), plus a new `outputFormat=Csv` on all seven reports.
2. **Cash and Bank accounts are two separate GL tables/endpoints now**, not one table with a type field — `POST /bank-accounts` (unchanged) and a new `POST /cash-accounts`, unified for reading via `GET /bank-accounts?accountType=Cash|Bank|Both`.
3. **`CLAUDE.md` Rule 8.2 renamed numeric ID fields to business codes across most of GL's API** — the one that reaches this page: `GeneralLedger`'s `accountId` param is now `accountCode` (still optional, same two-mode behavior as before), and the Bank/Cash create forms' `glAccountId` is now `glAccountCode`.
---
## 1. What this is
A new **Ledgers** sidebar section (`app/dashboard/ledgers/*`) giving the frontend statutory-format
financial reports and cash/bank-account management, sourced entirely from the external General Ledger
service via ERPCore's generic proxy (`docs/12-GENERAL-LEDGER-INTEGRATION.md`). Every screen calls
`GET /api/v1/gl/reports` or `/gl/bank-accounts`/`/gl/cash-accounts` — there is no ERPCore business
logic behind any of it yet (that internal wiring, e.g. posting real journal entries from
GRN/adjustments, is tracked separately and deliberately deferred, see `docs/12` §6).
**Sidebar structure** (`components/Layouts/AppSidebar.tsx`, mirrored server-side in
`Backend/ERPCore/Infra/Persistence/Configurations/{NavItem,SubNavItem,Permission}Configuration.cs`
per the existing RBAC-nav convention, docs/10 C.8/C.9) — **gains one entry, Tax Report:**
```
Ledgers
├── Trial Balance /dashboard/ledgers/trial-balance
├── Balance Sheet /dashboard/ledgers/balance-sheet
├── General Ledger /dashboard/ledgers/general-ledger
├── Profit & Loss /dashboard/ledgers/profit-and-loss
├── Cash Flow /dashboard/ledgers/cash-flow
├── Budget vs Actual /dashboard/ledgers/budget-vs-actual
├── Tax Report /dashboard/ledgers/tax-report (new)
└── Cash / Bank Accounts /dashboard/ledgers/bank-accounts (+ /new)
```
`/dashboard/ledgers` itself is a card-grid hub, same pattern as `/dashboard/stock` — gains an 8th card.
---
## 2. Transport: a dedicated GL client, not `lib/api-client.ts`
`lib/api/general-ledger.ts` is a **separate** fetch client from the one every other screen in this
app uses (`lib/api-client.ts`'s `apiRequest`/`apiRequestWithETag`). Reason: those assume ERPCore's
own RFC 7807 `ProblemDetails` error shape and a bare-DTO success body. The GL service wraps **every**
response — success and error alike — in its own `{ statusCode, success, message, data }` envelope,
and (a documented GL quirk, carried through the proxy unchanged per `docs/12` §4) success bodies are
camelCase while error bodies are PascalCase. `glRequest()` unwraps both casings itself and throws a
`GlApiError` shaped `{ status, detail }` — duck-type compatible with `lib/error-map.ts`'s
`ApiErrorLike`, so `errorMessage()`/toasts work unchanged across both API surfaces.
All calls are relative to `/api/v1/gl/*` — same-origin through the existing Next `rewrites()` proxy
(`next.config.ts`) → ERPCore → the GL service. No new proxy config was needed, and **still isn't** for
any of this revision's changes — ERPCore's `GeneralLedgerController` (docs/12) is a byte-for-byte
passthrough that never inspects GL's own field names or report contents, so every rename/restructure
described in this doc requires zero ERPCore-side change, only frontend-side (`docs/12` §4's new note
on this).
**No ETag/If-Match anywhere in this module** — the GL service's own reference documents no
concurrency tokens on any of the endpoints this UI calls.
---
## 3. Reports: "Sri Lankan Standard" report UI
Every report screen (`app/dashboard/ledgers/{trial-balance,balance-sheet,general-ledger,profit-and-loss,cash-flow,budget-vs-actual,tax-report}/page.tsx`)
shares three building blocks:
- **`components/reports/ReportHeader.tsx`** — a centered statutory header block: small-caps
"General Ledger" eyebrow, the report's **LKAS-aligned statement name** (not always GL's own
`reportType` value — see the mapping below), the as-at/for-period line, and a currency note. This
is the "Sri Lankan Standard report UI" requested: the on-screen equivalent of the formal
company-financial-statement layout (title block, period line, right-aligned money columns,
bold/indented subtotal rows), not a bespoke ad-hoc table per screen. **The Tax Report screen does
not use this component** — see its own entry below, it needs a fuller identity block GL's own PDF
gives that report alone.
- **`components/reports/DownloadPdfButton.tsx`** — re-issues the *exact same* report call with
`outputFormat=Pdf` instead of `Json`, base64-decodes `data.contentBase64` client-side into a
`Blob`, and triggers a browser download.
- **`components/reports/DownloadCsvButton.tsx`** *(new)* — identical mechanism, `outputFormat=Csv`
instead, `Blob` typed `text/csv`, triggers a `.csv` download. Sits next to the PDF button on every
screen — two download buttons now, not one. No proxy/transport concern either (`docs/12` §7) — same
passthrough as `Pdf` always was.
| Screen | `reportType` | On-screen title | Required params | Response shape |
|---|---|---|---|---|
| Trial Balance | `TrialBalance` | Trial Balance | `asOfDate` | **Flat** array `{accountCode, accountName, debit, credit}` — no `depth` anymore |
| Balance Sheet | `BalanceSheet` | Statement of Financial Position | `asOfDate` | Unchanged — hierarchical array `{depth, lineItem, accountType, balance}`, always ends with a synthetic `"Current Year Earnings"` row under Equity |
| General Ledger | `GeneralLedger` | General Ledger | `periodStart`, `periodEnd` (`accountCode` optional — renamed from `accountId`, see below) | Unchanged shape |
| Profit & Loss | `ProfitAndLoss` | Statement of Profit or Loss | `periodStart`, `periodEnd` | **Nested object**, not a flat array — named sections, see below |
| Cash Flow | `CashFlow` | Statement of Cash Flows | `periodStart`, `periodEnd` | Nested object; `workingCapitalChanges[]` gained `direction`; `openingCashBalance`/`closingCashBalance` still returned, not displayed |
| Budget vs Actual | `BudgetVsActual` | Budget vs Actual | `budgetId` | Unchanged |
| Tax Report *(new)* | `TaxSummary` | Income Tax Computation | `periodStart`, `periodEnd`; optional `allowableDeductions`, `otherTaxableIncome`, `qualifyingPaymentsReliefs`, `surchargeAmount`, `taxRateOverride` | Single flat object, see below |
**Defaults, so a screen is never blank on first load:** `asOfDate`/`periodEnd` default to today,
`periodStart` to the 1st of the current month (`lib/format.ts`'s `todayIso`/`startOfMonthIso`) —
these params are *required* by GL (400 if missing), so the UI always sends something sensible rather
than erroring on mount. The Tax Report's five optional parameters get **no forced client-side
default** — an untouched field sends nothing, letting GL's own server-side defaulting (`0` for four
of them, `system_config`-driven for `qualifyingPaymentsReliefs`/the tax rate) be the single source of
truth for what "not supplied" means.
**`reportType`/`outputFormat` are TS enums** (`types/general-ledger.ts`'s `ReportType`,
`ReportOutputFormat`) — `ReportOutputFormat` gains a `Csv` member. `GlAccount.accountTypeId` stays a
`GlAccountTypeId` enum (`Asset=1``Expense=5`).
**General Ledger: `accountId` renamed to `accountCode` (GL's `CLAUDE.md` Rule 8.2), behavior
unchanged from the 2026-07-20 correction.** GL's own reference still documents two modes:
`accountCode` supplied → "Account Ledger" (one account + its descendants, one running balance);
`accountCode` omitted → the **true General Ledger**, every `is_postable` account's own transactions
together, each with its own running balance that resets whenever the account changes, sorted by
`accountCode` then `entryDate`. This screen still always calls the second mode —
`reportsApi.generalLedger(periodStart, periodEnd)` never sends `accountCode` — and has no account
picker/input, same as before; only the underlying param name the client would use *if* a
single-account mode were ever added changed, not this screen's own behavior.
**Amount formatting** (`lib/format.ts`): comma-grouped thousands + fixed 2 decimals + parentheses for
negatives (`formatAmount`) — standard financial-statement convention. **CSV export does not reuse
this formatter** — GL's own CSV cells are plain decimals with a leading minus sign, no thousands
separator, no parentheses (spreadsheet-numeric-parsing convention, not human-display convention).
`DownloadCsvButton` downloads GL's bytes unmodified, same "don't reshape what GL sent" posture as the
PDF button.
**Hierarchical rows:** Balance Sheet still carries `depth`, rendered with `depth`-proportional left
padding. **Trial Balance dropped this entirely** — now a plain flat list, same row-component style as
Budget vs Actual.
**Profit & Loss is a nested object, not a flat array.** GL's response has named sections — `sales`,
`costOfSales`, `otherIncome`, `distributionExpenses`, `administrationExpenses`, `otherExpenses`,
`financialExpenses`, `unclassified` (only present with lines if the COA has untagged Income/Expense
accounts — GL flags this as worth watching for during setup) — each `{ lines[], total }`, plus
top-level `grossProfit`/`netProfitForPeriod`. The screen renders one bordered section per non-empty
group, in this fixed order: Sales, Cost of Sales, **Gross Profit** (its own bold subtotal row, not a
section), Other Income, the four expense groups, `unclassified` last if present, then **Net Profit
for the Period**.
**Cash Flow is a structured statement layout, not four `StatCard`s.** `Net Earnings` as its own line,
an "Additions to Cash" bordered section and a "Subtractions From Cash" bordered section — client-side
bucketed by sign from the combined `nonCashAdjustments[]` + `workingCapitalChanges[]` (a working-capital
line's label uses its `direction` field, e.g. `direction: "Decrease"` + `accountName: "Trade
Receivables"` → `"Decrease in Trade Receivables"`; a non-cash-adjustment line just prints its plain
`description`, e.g. `"Depreciation"`, no Increase/Decrease prefix) — then `Net Cash From Operations`
as a subtotal, Investing/Financing sections (their `lines[]`, no subtotal row when a section has only
one line), ending in the final combined total. `openingCashBalance`/`closingCashBalance` are fetched
(still in the response) but **not rendered anywhere on screen** — same "computed but not displayed"
posture GL's own PDF/CSV takes.
**Tax Report (`app/dashboard/ledgers/tax-report/page.tsx`) is an entirely new screen.** Layout:
- A `periodStart`/`periodEnd` date-range picker, same component as every other period-based report.
- **A collapsible "Adjustments" panel**, collapsed by default, with five optional numeric inputs —
`Allowable Deductions`, `Other Taxable Income`, `Qualifying Payments / Reliefs`,
`Surcharge / Education Levy`, and `Tax Rate Override` (%). Each maps directly to the report call's
optional query params.
- Result: a two-column `Description`/`Amount` table in GL's own fixed order (`profitBeforeTax` through
`balanceTaxPayable`) — `Add:`/`Less:` prefixes are literal row labels the component renders per a
fixed lookup, not derived from the amount's sign. Bold rows for the reconciliation checkpoints
(`Adjusted Business Profit`, `Assessable Income`, `Taxable Income`, `Gross Tax Liability`, the final
total).
- **The final row's label depends on the sign of `balanceTaxPayable`** — GL's own PDF renders a
negative value (credits/payments exceeded the gross liability) as **"BALANCE TAX REFUNDABLE"**, not
a negative "payable" figure (confirmed live on GL's own seeded loss-making test data,
`03_Progress_Tracker.md`). The on-screen table should match this exactly: `balanceTaxPayable >= 0`
label `"BALANCE TAX PAYABLE"`, amount as-is; `balanceTaxPayable < 0` → label
`"BALANCE TAX REFUNDABLE"`, amount shown as its absolute value (not in parentheses — GL's own PDF
changes the *label*, not the sign display, for this one row specifically).
- Uses its own header block, not the shared `ReportHeader` — GL's own PDF gives this one report a
fuller identity block (company name, address, TIN, BRN) via `Company:Address`/`Tin`/`Brn` config
values (`05_LKAS_Report_PDF_Templates.md` §0/§8, confirmed implemented server-side). **Open question,
genuinely unresolved:** GL exposes no endpoint returning these four values as data — they're
`appsettings.json` entries, read only by the PDF renderer. The on-screen React version needs the
*same* values from *somewhere*, and there's no way to fetch them from GL today. Two options, neither
picked here: (a) GL adds a small `GET /company-info`-style endpoint; (b) the frontend duplicates
these four values in its own config/env, accepting the risk of drifting out of sync with GL's
`appsettings.json` whenever one side changes without the other. Flag this for a decision before
building this screen's header — don't silently pick one.
---
## 4. Cash / Bank Accounts
`app/dashboard/ledgers/bank-accounts/page.tsx` (list) + `/new/page.tsx` (create). No delete — GL's
own reference has no delete endpoint for either concept, consistent with every other master in this
system (FR-MD-08's deactivate-not-delete posture, though GL doesn't even expose deactivate here).
**Rework — Cash and Bank are separate GL tables/endpoints, not one table with a type field.** An
earlier design (drafted, never shipped on GL's side) proposed a single `bank_account.accountType`
discriminator; GL's team judged a cash account a *peer* concept to a bank account, not a *kind of*
bank account, and built two separate tables instead (`Accounting_System_Design_LKAS.md` §5.8). This
still satisfies the original requirement — **the create page only ever offers exactly two choices,
Cash or Bank, nothing else** — the two-choice constraint now lives in "which endpoint does the form
submit to," not "which value of a discriminator field."
### The three endpoints this page now uses
- **`POST /bank-accounts`** — unchanged in shape from the original 2026-07-18 design, except
`glAccountId``glAccountCode` (Rule 8.2). Fields: `accountName` (required), `bankName`/
`accountNumber` (both optional/nullable — a Bank account can genuinely be created with these blank
and filled in later, unlike Cash's stricter rule below), `glAccountCode` (required), `currencyCode`
(optional, defaults `"LKR"`).
- **`POST /cash-accounts`** *(new)*`accountName` (required), `cashAccountTypeName` (required —
matched case-insensitively against GL's `cash_account_type` reference table; a name with no
existing match **creates a new type on the fly**, so a frontend "Other, please specify" free-text
option becomes a permanent selectable choice for every future cash account created after it),
`accountNumber` (**optional** — if omitted, GL auto-generates a sequential `CASH-000001`-style
reference; if supplied, used as entered), `glAccountCode` (required), `currencyCode` (optional,
defaults `"LKR"`).
- **`GET /cash-account-types`** *(new)* — flat, unfiltered list, seeded with Petty Cash / Till Cash /
Safe Cash / Cash in Transit, growing over time. Feeds the create form's Cash Account Type dropdown.
### Create form
A **two-choice toggle** at the top — **Cash** or **Bank**, backed by a `CashBankAccountType` TS enum
(`Cash`/`Bank`) — decides which of the two fieldsets below shows and which endpoint the form submits
to:
- **Bank selected:** Bank Name + Account Number fields (both optional on this side — GL doesn't
enforce them as required for a Bank row), submits to `POST /bank-accounts`.
- **Cash selected:** a Cash Account Type `<Select>` fed by `GET /cash-account-types`, with an "Other,
please specify" option that reveals a free-text input — its value is sent as `cashAccountTypeName`
regardless of whether it matched an existing type or not, letting GL's own on-the-fly-creation
handle the rest (no separate "create type first, then create account" round trip needed on the
frontend's part). An optional Account Number field, with placeholder text noting it'll be
auto-generated if left blank. Submits to `POST /cash-accounts`.
- `glAccountCode` (required regardless of type, references GL's own Chart of Accounts) stays a
`code — name` picker fed by `glAccountsApi.list()` (`GET /accounts`) either way — both kinds still
need a linked GL account, nothing about that requirement differs by type.
### List page — now a unified, filterable view
`GET /bank-accounts?accountType=Cash|Bank|Both` (default `Both`) replaces the old
"flat, unfiltered, unpaginated bank-accounts-only" list — it's GL's own server-side union of both
tables now, not a client-side-only filter over one table. Response per row:
`{ accountType, accountId, accountName, bankName, cashAccountTypeName, accountNumber, glAccountId,
currencyCode, createdAt }` — `bankName` is `null` on `Cash` rows, `cashAccountTypeName` is `null` on
`Bank` rows. The list table should show an `accountType` badge/column per row (a small "Cash"/"Bank"
pill next to the account name) so the two kinds are visually distinguishable at a glance. Client-side
filters (name/bank-or-type/account-number/currency/linked-GL-code) still layer on top of whichever
server-side `accountType` filter is active — GL's own filter narrows which table(s) contribute rows;
everything else about the existing client-side filtering approach is unchanged.
### Known gap — edit, still unresolved
**The user-facing requirement was list → create → edit (no delete). Edit still cannot be built.** GL's
own reference documents no `GET`/`PUT` by id for either `bank_account` or `cash_account` — nothing to
edit against, for either kind. The list page shows a **disabled** Edit button with a tooltip
explaining the gap, same posture as before this rework. **This needs a GL service change** (add the
missing endpoints, for both tables now, not just one) before a real edit screen can exist.
### Known gap — response shape, now resolved for Bank, still open for the unified list's exact field names
GL's reference previously never showed a full bank-account response body (only the create request
fields), so `types/general-ledger.ts`'s old `BankAccount` interface was an **inferred** shape. This is
now resolved for the unified list — `GET /bank-accounts`'s response fields are explicitly documented
(above) — but double-check the exact TS interface against a live call before trusting it blindly;
inferred-shape mistakes have been a real, repeated source of drift in this module (`§6`'s history).
### Fixed — selected GL account showed as a raw number, not its name (2026-07-20, unaffected by this rework)
The `<Select>` picker (bank account form, and Budget vs Actual's budget picker) displayed the
selected item's **numeric value** after picking it, even though the correct id/code was genuinely
being sent to the server. Cause: this app's `<Select>` wraps `@base-ui/react/select`, whose
`Select.Value` resolves its displayed text from each `Select.Item`'s **`label` prop**, a separate
field from `children`. Fixed by passing `label={...}` explicitly on each `<SelectItem>`. **Not fixed
at the shared `components/ui/select.tsx` level** — flagged for whoever next touches a similar picker
elsewhere in the app. The new Cash Account Type picker (this rework) should apply the same
`label={...}` pattern from the start, not reintroduce the bug.
---
## 4a. Accounts section (2026-07-31) — Cheque Management (new module) + Cash/Bank Accounts moved here
**A new top-level sidebar item, "Accounts"** (`app/dashboard/accounts/*`), sitting alongside
"Ledgers" rather than under it — Cash/Bank Accounts and cheques are operational account
bookkeeping, not a statutory report, so they don't belong under the Ledgers report hub. **Cash /
Bank Accounts moved here verbatim** from `ledgers/bank-accounts` (§4 above) — same code, same
behavior, just relocated; the Ledgers hub's card grid dropped its Cash/Bank Accounts card
accordingly.
**Cheque Management** is a new GL module (`04_API_Reference_And_Scenarios.md`, Module: Cheque
Management — added to GL 2026-07-30, beyond its original plan), purely operational tracking: no
endpoint in it ever creates or touches a journal entry itself. Two independent sub-areas, per GL's
own spec:
- **Cheque Books/Pages** (`app/dashboard/accounts/cheque-books/*`) — cheques issued from this
company's own cheque book supply. `POST /cheque-books` auto-generates every leaf (`ChequePage`,
all `Unused`) in one call. List page filters by status; create form restricts the bank-account
picker to `Bank`-type accounts only (GL's own module note: cheque books are bank-account-only,
never cash-account). Clicking a book navigates to `[chequeBookNo]/page.tsx`, which fetches
`GET /cheque-books/{chequeBookNo}?expand=pages` and lists every leaf — clicking a leaf opens
`components/accounts/ChequePageDialog.tsx`.
- **Received Cheques** (`app/dashboard/accounts/received-cheques/*`) — cheques received from
customers/suppliers/others, deliberately **not** linked to any `ChequeBook`. Same list-then-modal
shape via `components/accounts/ReceivedChequeDialog.tsx`.
**Modal chosen over a second-level page for leaf/row details** (left open by the request) — working
through several cheque pages in one book, or several received cheques in the list, without losing
the list's scroll position/filter state each time. Each dialog shows read-only details plus only the
actions valid from the item's current status (per GL's own status-transition tables), and each
action reveals only the extra fields *that specific transition* needs — e.g. `Clear` asks for
`clearedDate`, `Cancel` asks for `cancelReason`, `Bounce`/`Void`/`Deposit`(+bank picker) need
nothing else beyond an optional `performedBy`/note. This mirrors GL's own validation exactly rather
than showing one giant always-visible form.
**`branchId`/`companyId`/`payeeId`/`voucherId`/`referenceId` are plain numeric inputs, not
dropdowns** — GL's reference explicitly documents these as "loose references" (no Branch/Company/
Customer/Supplier table exists in that service for them to point at), so building a picker against
non-existent master data would be worse than a labeled numeric field.
**Types built from request-body fields GL does document, not guessed wholesale** — `ChequeBook`/
`ChequePage`/`ReceivedCheque` in `types/general-ledger.ts` are assembled from every field named in
the `POST`/`PUT` request bodies plus the status fields GL's prose confirms (`issueStatus`/
`printedAt`/`clearedDate`/`clearedByBank`/`cancelReason`, etc.) — not a shape invented from nothing.
Two things genuinely are guesses, flagged in the type comments: `ChequeBook`/`ChequePage`'s internal
numeric PK column names (GL never states them — `chequeBookNo`/`chequeNo`, both explicitly
documented as the identifying route values, are used for every key/URL instead, sidestepping the
guess), and `ReceivedCheque`'s JSON id field name (`receivedChequeId`, inferred from this project's
own `<entity>Id` convention — GL's reference only shows a numeric `{id}` in the URL, not the field
name).
**Backend:** migration `AddAccountsNavSeed` — new `NavItem` `accounts` (id 12) and two new
`SubNavItem`/`Permission` pairs (`accounts.cheque-books`, `accounts.received-cheques`); the existing
Cash/Bank Accounts `SubNavItem`/`Permission` (ids 15/26) were **updated in place**, not deleted and
recreated, so a role already granted that permission under its old `ledgers.bank-accounts` code
keeps it. Applied to the live database this session.
**Not done — live smoke test.** This entire module (both sub-areas) is unverified against real
Cheque Management data — no running GL instance with cheque data was available this session. Same
"verify before trusting the inferred pieces" posture as the rest of `§6`'s history.
---
## 5. RBAC nav seed (backend, small, additive)
The sidebar's role-based visibility (`docs/10 C.8`, `GET /auth/me`'s `navCodes`) requires every
`AppSidebar.tsx` entry's `code` to have a matching backend `NavItem`/`SubNavItem` row, or the item is
invisible to every role regardless of the frontend change. Migration `AddLedgersNavSeed`,
`Backend/ERPCore/Infra/Persistence/Migrations`:
- `NavItem` `ledgers` (id 11) + **8** `SubNavItem` rows (`ledgers.trial-balance``ledgers.tax-report`
(new, id 16) … `ledgers.bank-accounts`, ids 916) — `NavItemConfiguration.cs`/`SubNavItemConfiguration.cs`.
- **9** matching `Permission` rows (`NAV:ledgers`, `NAV:ledgers.*`, ids 1927, `NAV:ledgers.tax-report`
new at id 27) — `PermissionConfiguration.cs`, following the existing one-`Permission`-per-nav-entry
convention exactly.
**Operational step still needed, not code:** a brand-new `NavItem`/`SubNavItem` carries no
`RolePermission` grants by default. An administrator must open **Settings → Roles**, edit the relevant
role(s), and check the new Ledgers permissions (including the new Tax Report one) before anyone with
that role sees the sidebar entries — normal onboarding, not a bug.
---
## 6. Progress
- [x] **Reports, original 6 screens (2026-07-20):** all 6 report screens + hub, all calling the live
GL proxy, all with a working PDF download. Verified `tsc --noEmit`/`eslint` clean.
- [x] **Cash/Bank Accounts — list + create, original single-table design (2026-07-20):**
client-side-filtered list, create form with a GL-account picker.
- [x] **RBAC nav seed, original 7-entry version (2026-07-20):** migration `AddLedgersNavSeed`, not yet
applied to a live database.
- [x] **Corrections (2026-07-20):** General Ledger dropped `accountId` entirely (later renamed to
`accountCode`, see below), `ReportType`/`ReportOutputFormat`/`GlAccountTypeId` became TS enums,
fixed the Select-shows-raw-number bug on two pickers.
- [x] **This revision, built (2026-07-30).** Every item in §3/§4's rework is now implemented against
the frontend codebase:
- `components/reports/DownloadCsvButton.tsx` — new, added to all seven report screens (alongside
the existing `DownloadPdfButton`).
- `components/reports/ReportSection.tsx`/`ReportSubtotal.tsx` — new shared components, extracted
because Profit & Loss and Cash Flow both needed the identical "bordered section of lines + a
bold subtotal row" shape; not built as one-off per-screen markup.
- Trial Balance screen — flat-list renderer, `depth`/`indentedCode` dropped.
- Profit & Loss screen — full rework to the nested-sections object, fixed section order (Sales →
Cost of Sales → **Gross Profit** → Other Income → the four expense groups → Unclassified if
present → **Net Profit for the Period**), empty sections hidden.
- Cash Flow screen — replaced the four `StatCard`s with the structured statement layout: Net
Earnings, Additions/Subtractions to Cash (sign-bucketed from the combined
`nonCashAdjustments[]`/`workingCapitalChanges[]`, working-capital lines labeled via their
`direction`), Net Cash From Operations, Investing/Financing sections (subtotal row suppressed
when a section has exactly one line, per spec), final combined total.
`openingCashBalance`/`closingCashBalance` are fetched but deliberately not rendered.
- **New Tax Report screen** (`app/dashboard/ledgers/tax-report/page.tsx`) — period picker,
collapsible Adjustments panel (5 optional numeric inputs, no client-side default), fixed-order
two-column result table with Add:/Less: row labels from a static lookup, bold reconciliation
checkpoints, and the payable/refundable label+sign logic on the final row. Uses its own header,
not the shared `ReportHeader` — the open question about `Company:Address`/`Tin`/`Brn` (§3, end)
is **still unresolved**; the header explicitly states the company-identity fields aren't shown
rather than fabricating placeholder values, and a hub card was added.
- `types/general-ledger.ts``ReportOutputFormat` gained `Csv`; `ReportType` gained `TaxSummary`;
`GeneralLedger`'s (unused-by-this-screen) param renamed `accountCode`; new `CashBankAccountType`
enum; `TrialBalanceRow` flattened; `ProfitAndLossResponse`/`CashFlowResponse`/`TaxSummaryResponse`
added (all three **inferred** in places GL's own reference doesn't spell out every field verbatim
— flagged in the type file's own doc comments, same posture as the original inferred
`BankAccount` shape); `BankAccount` replaced by the unified `CashAndBankAccountDto` plus separate
`CreateBankAccountRequest`/`CreateCashAccountRequest` types.
- Cash/Bank create form — Bank/Cash toggle (two buttons, not a dropdown), conditional fieldsets,
Cash Account Type `<Select>` (with `label={...}` set correctly from the start, avoiding the
raw-number-display bug from §4's earlier fix) fed by `GET /cash-account-types`, an "Other, please
specify" free-text option, `glAccountCode` picker now keyed by the account's code string instead
of its numeric id.
- Cash/Bank list page — server-side `accountType` filter (`Both`/`Bank`/`Cash`, a `<Select>` next
to the existing search box) driving `GET /bank-accounts?accountType=...`, client-side text search
still layered on top, a Cash/Bank badge column, Edit still disabled with its explanatory tooltip
(now type-aware: "...for cash accounts"/"...for bank accounts").
- RBAC nav seed — migration `AddTaxReportNavSeed` adds `SubNavItem` id 16
(`ledgers.tax-report`) and `Permission` id 27, and re-sequences `ledgers.bank-accounts`'s
`SortOrder` from 7→8 so Tax Report sits before it, matching the sidebar's array order.
- Verified: `tsc --noEmit` clean, `npx eslint` clean across every touched file, `npm run build`
succeeds with all 9 `/dashboard/ledgers/*` routes present (including `/tax-report`), `dotnet
build` clean (0 warnings/0 errors) after the migration.
- [x] **Live smoke test, partial (2026-07-31) — Cash Flow crashed, now fixed.** A GL instance became
reachable and a user hit the exact risk flagged above: `Cash Flow` threw
`TypeError: Cannot read properties of undefined (reading 'map')` on `report.nonCashAdjustments.map`.
Confirmed root cause — **GL's serializer omits an empty/zero list or section field from the JSON
entirely, rather than sending `[]` or `{ lines: [], total: 0 }`.** Fixed in `cash-flow/page.tsx`
(`?? []` defaults, an `activitySectionLines()` helper, optional chaining on
`investingActivities`/`financingActivities`/their `.total`) and proactively in
`profit-and-loss/page.tsx` (same defensive treatment for every section, since
`ProfitAndLossResponse` shares the identical nested-section shape and hadn't crashed yet only
because no period tested so far happened to have an empty section). `types/general-ledger.ts`'s
`CashFlowResponse` and `ProfitAndLossResponse` fields changed from required to optional to match.
**`TaxSummaryResponse` is still unverified against this same risk** — it wasn't touched this pass;
treat it as equally likely to have optional/omittable fields until proven otherwise. The unified
`CashAndBankAccountDto` also remains unverified live.
- [x] **Corrected against GL's own API reference (2026-07-31 (2)) — Cash Flow's shape was wrong, not
just fragile; Tax Report was missing five fields.** The user supplied GL's own
`04_API_Reference_And_Scenarios.md`, letting every report be checked against a confirmed contract
instead of inference for the first time. Trial Balance, Balance Sheet, General Ledger,
Profit & Loss, and Budget vs Actual all match it exactly. Two didn't:
- **`CashFlowResponse` — everything nests under `operatingActivities`.** There is no top-level
`netEarnings`/`nonCashAdjustments`/`workingCapitalChanges`/`netCashFromOperations` at all; the
real shape is `operatingActivities: { profitForPeriod, nonCashAdjustments[],
workingCapitalChanges[], netCashFromOperatingActivities }`, and `investingActivities`/
`financingActivities` each have their own differently-named total
(`netCashFromInvestingActivities`/`netCashFromFinancingActivities`), not a shared `total`. This
was the actual cause of the crash in the entry above — the previous fix's `?? []` guards were
aimed at fields that never existed at that nesting level, so the page would have kept silently
rendering an empty Operating Activities section forever, crash or not. Also: `workingCapitalChanges[]`
entries use `changeAmount`, not `amount`. Rewrote `cash-flow/page.tsx` and
`types/general-ledger.ts` (`CashFlowResponse` plus new `CashFlowOperatingActivities`/
`CashFlowInvestingActivities`/`CashFlowFinancingActivities`) to match exactly.
- **`TaxSummaryResponse` was missing `nonDeductibleExpenses`, `corporateIncomeTax`, `apitCredit`,
`whtCredit`, `quarterlyTaxPayments` entirely** — real GL-computed figures silently never shown,
not a wrong guess at a name. Added all five to the type and to `tax-report/page.tsx`'s `ROWS`
table in their correct position in the confirmed order.
- `CashFlowActivityLine`'s own field names (`{description, amount}` inside `investingActivities`/
`financingActivities` `lines[]`) and `CashAndBankAccountDto` remain unverified — GL's reference
confirms the containing shapes but not these inner fields verbatim.
- Verified: `tsc --noEmit`/`eslint` clean on every touched file.
- [x] **Balance Sheet regrouped into a proper LKAS Statement of Financial Position layout
(2026-07-31 (3), user-reported).** `BalanceSheetRow`'s shape is correct (matches the reference
above exactly) — this was a presentation bug, not a data bug. The flat one-table rendering made a
rollup total visually indistinguishable from the leaf amounts it already sums (e.g. "Cash and
Bank"'s balance already includes "Petty Cash"/"Main Operating Bank Account"/"Savings Bank Account"
beneath it), only `depth===0` did any bolding, and there was no section grouping at all — a "Type"
column per row instead of an ASSETS/LIABILITIES/EQUITY structure. Rewrote `balance-sheet/page.tsx`
to group by `accountType` into sections, each with a bold "Total {Section}" row (summed from that
section's depth-0 rows only, since a depth-0 row's balance already rolls up its descendants), any
row followed immediately by a deeper row is bolded as a rollup (not just the top level), and a
final "Total Liabilities and Equity" row for the standard balance check. Handles one GL quirk
explicitly: the synthetic "Current Year Earnings" balancing row always carries `depth: 1` even
though it's a peer Equity entry, not a child of whatever precedes it — a new `effectiveDepth()`
helper special-cases it to 0. Verified against the actual reported numbers: Total Assets
(5,880,466.50) = Total Liabilities (2,025,000.00) + Total Equity (3,855,466.50), exact match.
`tsc --noEmit`/`eslint` clean.
- [x] **Superseded by a real GL retrofit (2026-07-31 (4)) — `BalanceSheet` is now a pre-classified
nested shape, not a flat array to regroup client-side.** GL's own API reference documents a
2026-07-31 backend change: the flat `{depth, lineItem, accountType, balance}` recursive-rollup
array (what the entry above regrouped client-side) is replaced by
`{ asOfDate, nonCurrentAssets: {lines[], total}, currentAssets: {lines[], total},
unclassifiedAssets: {lines[], total}, totalAssets, equity: {lines[], total},
nonCurrentLiabilities: {lines[], total}, currentLiabilities: {lines[], total},
unclassifiedLiabilities: {lines[], total}, totalEquityAndLiabilities }`, driven by GL's new
`accounts.balance_sheet_classification` tag. GL now does the Non-Current/Current classification
itself — the previous entry's client-side `effectiveDepth`/`sectionTotal`/depth-based rollup
logic is entirely obsolete. Replaced `BalanceSheetRow` with `BalanceSheetLine`/
`BalanceSheetSection`/`BalanceSheetResponse` (every section optional, same defensive posture as
`CashFlowResponse`/`ProfitAndLossResponse` after the Cash Flow crash, since this shape isn't
live-verified against this frontend yet) and rewrote `balance-sheet/page.tsx` from scratch.
**Also restyled to match a user-supplied reference Statement of Financial Position image** — a
real classified SOFP, Non-Current/Current Assets each their own subtotaled block, then Equity and
Liabilities the same way, ending in a Total Assets vs Total Equity-and-Liabilities check — by
reusing the same `ReportSection`/`ReportSubtotal` components Profit & Loss and Cash Flow already
use, rather than one-off markup; line items show plain account names only, no codes, matching the
reference. Verified: `tsc --noEmit`/`eslint` clean; confirmed no remaining references to the
removed `BalanceSheetRow`.
- [x] **New "Accounts" nav section built (2026-07-31 (5)) — Cheque Management (Cheque Books/Pages +
Received Cheques) plus Cash/Bank Accounts moved here from Ledgers.** Full detail: §4a above. Six
new screens (`cheque-books` list/create/detail, `received-cheques` list/create) plus two new
modal components (`ChequePageDialog`/`ReceivedChequeDialog`), new types/API client functions for
the whole Cheque Management module, migration `AddAccountsNavSeed` (applied live). Verified:
`tsc --noEmit`/`eslint` clean, `npm run build` compiles successfully. **Not done — live smoke
test**, this module's inferred id-field-name gaps are flagged in §4a.
- [x] **`glAccountCode` removed from Cash/Bank Account creation (2026-07-31 (6)), matching a further
GL retrofit.** GL's own reference now documents that `POST /bank-accounts`/`POST /cash-accounts`
no longer accept a `glAccountCode` at all — the backing GL account (and, for Cash, its type-header
node) is always auto-created server-side, never caller-selected. Removed the field from
`CreateBankAccountRequest`/`CreateCashAccountRequest`, deleted the "GL account" `Select` and its
`glAccountsApi.list()` fetch from `bank-accounts/new/page.tsx` entirely, and dropped the matching
check from `validateBankAccountForm`. The create response is typed as the new
`CreateCashOrBankAccountResponse` (`glAccount` nested, confirmed from the doc; other fields
inferred) so the success toast can show the auto-generated GL account code back to the user. The
Cash/Bank **list** page is unaffected — GL's unified list endpoint still returns a flat
`glAccountId` per row, still resolved against `glAccountsApi.list()` there, same as before.
- [x] **Create-form layout widened to fill the page (2026-07-31 (6)).** The three GL create forms
under Accounts (`bank-accounts/new`, `cheque-books/new`, `received-cheques/new`) each wrapped
their fields in a `max-w-lg` card, leaving roughly half the page blank on any normal desktop
width. Dropped the `max-w-lg` cap (now full-width, matching the report pages' own `rounded-2xl
bg-white p-6 ...` card convention, which was never capped) and replaced the vertical
one-field-per-row `FieldGroup` stacking (plus occasional ad-hoc `grid grid-cols-2` pairs) with one
consistent `grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3` wrapper per form, so fields actually
spread across the available width instead of stretching a single narrow column. Modals
(`ChequePageDialog`/`ReceivedChequeDialog`) were deliberately left at their existing fixed width —
a dialog is supposed to stay narrow, this complaint was about full-page create forms only.
- [ ] **Not done — internal ERPCore→GL wiring.** Unrelated to this revision, still deferred
(`docs/12` §6).
- [ ] **Deferred — bank/cash account edit.** Needs GL service changes first (§4); now needs them for
*both* tables, not just one.
- [ ] **Open — Tax Report's company-identity header fields.** Still genuinely unresolved (§3, end);
not picked in this pass either. The screen ships without them rather than guessing.
---
*End of 21-GENERAL-LEDGER-FRONTEND.md. Transport: `docs/12-GENERAL-LEDGER-INTEGRATION.md`. General frontend rules: `20-FRONTEND.md`. Record work: `Frontend/PROGRESS.md`.*