Compare commits

..

7 Commits

Author SHA1 Message Date
ImanThiyanga 99b669395e feat: add production navigation permissions and produced items page
- Created a new migration to add navigation items and permissions for the production section.
- Implemented the Produced Items page with filtering options for date range, templates, warehouses, and status.
- Integrated API calls to fetch produced items, templates, and warehouses.
- Added UI components for displaying produced items in a table format with relevant details.
2026-08-21 11:57:07 +05:30
ImanThiyanga 8835ba9500 Merge pull request 'Refactor code structure for improved readability and maintainability' (#49) from sales-ui-fixers into Dev
Reviewed-on: #49
2026-08-18 06:36:24 +00:00
ImanThiyanga 251b23f4b5 Merge pull request 'feat: enhance stage quantity management for running stock inputs' (#48) from bug-fix-production into Dev
Reviewed-on: #48
2026-08-18 06:35:24 +00:00
Dhananjaya99 283a56723f smal change in general ledger UI 2026-08-18 12:02:26 +05:30
Sasanka 9a38cc1131 Refactor code structure for improved readability and maintainability 2026-08-18 11:54:16 +05:30
Sasanka 1c5b05e315 feat: enhance stage quantity management for running stock inputs
- Updated `UpdateStageQuantitiesRequest` to include `reasonCodeId` for lowering planned quantities below consumed amounts.
- Modified `ProductionRunService` to allow adjustments to planned quantities for running stock inputs, enforcing reason code requirements for decreases.
- Enhanced `StageDrawer` and `StageEditorPanel` components to support reason code selection and display available stock.
- Added utility functions for stock management and improved user feedback for stock adjustments.
2026-08-17 14:30:55 +05:30
ImanThiyanga efa4ad3770 Merge pull request 'Feat/intigrate account service in grn module' (#47) from feat/intigrateAccountServiceInGrnModule into Dev
Reviewed-on: #47
2026-08-17 08:47:24 +00:00
35 changed files with 8576 additions and 1607 deletions
@@ -24,6 +24,19 @@ public sealed class ProductionRunsController : ApiControllerBase
CancellationToken ct)
=> Ok(await _runs.ListAsync(query, status, templateId, warehouseId, ct));
/// <summary>Produced-items report (FR-MFG-13): one row per run that has produced
/// something, plus a summary total over every filtered row.</summary>
[HttpGet("produced")]
[ProducesResponseType(typeof(ProducedListResponseDto), StatusCodes.Status200OK)]
public async Task<ActionResult<ProducedListResponseDto>> ListProduced(
[FromQuery] DateOnly? from,
[FromQuery] DateOnly? to,
[FromQuery] int? templateId,
[FromQuery] int? warehouseId,
[FromQuery] ProductionRunStatus? status,
CancellationToken ct)
=> Ok(await _runs.ListProducedAsync(from, to, templateId, warehouseId, status, ct));
[HttpGet("{runId:int}")]
[ProducesResponseType(typeof(RunGraphDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
+40 -2
View File
@@ -70,6 +70,35 @@ public sealed record RunEventDto(
int EventId, int? RunStageId, RunStageEventType EventType, string? Note,
JsonElement? Payload, int UserId, DateTime CreatedAt);
/// <summary>
/// One row of the produced-items report (<c>GET /production-runs/produced</c>) — the
/// terminal output of a run that has produced something, costed the same way
/// <c>PostReceiptAsync</c> costs the finished layer at approve (FR-MFG-13):
/// <c>unitCost = (Σ ConsumedValue − Σ ReturnedValue) / (ProducedQty − ScrappedQty)</c>.
/// One row per run — server validation (<see cref="ProductionGraphValidator"/> via
/// FR-MFG-05) guarantees exactly one item-bearing output per run, always the terminal
/// one, so run and produced line coincide 1:1.
/// <para><c>UnitCost</c>/<c>TotalCost</c> are final only once <c>Status</c> is
/// <c>Completed</c> — a leftover return can still shrink the pool between the terminal
/// stage's Done and its Approve, same caveat as <see cref="CostPoolDto"/>.</para>
/// </summary>
public sealed record ProducedLineDto(
int RunId, string DocNo, ProductionRunStatus Status,
int TemplateId, string TemplateName,
int WarehouseId, string WarehouseName,
int ItemId, string ItemName,
decimal ProducedQty, decimal ScrappedQty, decimal GoodQty,
decimal UnitCost, decimal TotalCost,
DateTime CreatedAt, DateTime? CompletedAt);
/// <summary>Aggregate footer for the produced-items report — totals over every row the filter
/// matched, not just the current page (there is no pagination here, matching the Sales report
/// convention of flat, filter-bounded rows).</summary>
public sealed record ProducedSummaryDto(
int RunCount, decimal TotalProducedQty, decimal TotalScrappedQty, decimal TotalGoodQty, decimal TotalCost);
public sealed record ProducedListResponseDto(IReadOnlyList<ProducedLineDto> Items, ProducedSummaryDto Summary);
/// <summary>Full run graph (docs/30 §D.2 <c>GET /production-runs/{id}</c>).</summary>
public sealed record RunGraphDto(
int RunId, string DocNo, int TemplateId, string TemplateName,
@@ -99,8 +128,10 @@ public sealed class CreateRunRequest
}
/// <summary>
/// Per-run scaling override (FR-MFG-08). Only accepted while the stage has not started
/// (<c>409 STAGE_NOT_EDITABLE</c>).
/// Per-run scaling override (FR-MFG-08). Accepted before the stage starts, and — for a Stock
/// input only — while it is running too: raising <see cref="StageQuantityLine.PlannedQty"/>
/// draws the extra amount from FIFO, lowering it returns the excess (<c>409
/// STAGE_NOT_EDITABLE</c> for every other status).
/// </summary>
public sealed class UpdateStageQuantitiesRequest
{
@@ -116,4 +147,11 @@ public sealed class StageQuantityLine
[Range(0.0001, double.MaxValue)]
public decimal PlannedQty { get; set; }
/// <summary>
/// Required only when this line lowers a running Stock input below what it has already
/// consumed — the excess is returned to stock the same way <c>ReturnLeftoverAsync</c>
/// does, and that always needs a reason (FR-MFG-14). Ignored otherwise.
/// </summary>
public int? ReasonCodeId { get; set; }
}
@@ -39,7 +39,8 @@ public sealed class NavItemConfiguration : IEntityTypeConfiguration<NavItem>
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 },
new NavItem { NavItemId = 13, Code = "sales", Label = "Sales", Href = "/dashboard/sales", SortOrder = 13 }
new NavItem { NavItemId = 13, Code = "sales", Label = "Sales", Href = "/dashboard/sales", SortOrder = 13 },
new NavItem { NavItemId = 14, Code = "production", Label = "Production", Href = "/dashboard/production", SortOrder = 14 }
);
}
}
@@ -61,7 +61,11 @@ public sealed class PermissionConfiguration : IEntityTypeConfiguration<Permissio
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 }
new Permission { PermissionId = 34, Code = "NAV:accounts.received-cheques", SubNavItemId = 22 },
new Permission { PermissionId = 35, Code = "NAV:production", NavItemId = 14 },
new Permission { PermissionId = 36, Code = "NAV:production.templates", SubNavItemId = 24 },
new Permission { PermissionId = 37, Code = "NAV:production.runs", SubNavItemId = 25 },
new Permission { PermissionId = 38, Code = "NAV:production.produced", SubNavItemId = 26 }
);
}
}
@@ -53,7 +53,11 @@ public sealed class SubNavItemConfiguration : IEntityTypeConfiguration<SubNavIte
// 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 }
new SubNavItem { SubNavItemId = 22, NavItemId = 12, Code = "accounts.received-cheques", Label = "Received Cheques", Href = "/dashboard/accounts/received-cheques", SortOrder = 3 },
// Production (NavItemId 14) children — mirror the sidebar order (AppSidebar.tsx).
new SubNavItem { SubNavItemId = 24, NavItemId = 14, Code = "production.templates", Label = "Templates", Href = "/dashboard/production/templates", SortOrder = 1 },
new SubNavItem { SubNavItemId = 25, NavItemId = 14, Code = "production.runs", Label = "Runs", Href = "/dashboard/production/runs", SortOrder = 2 },
new SubNavItem { SubNavItemId = 26, NavItemId = 14, Code = "production.produced", Label = "Produced", Href = "/dashboard/production/produced", SortOrder = 3 }
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,90 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace ERPCore.Migrations
{
/// <inheritdoc />
public partial class AddProductionNavPermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "nav_items",
columns: new[] { "NavItemId", "Code", "Href", "Icon", "Label", "SortOrder" },
values: new object[] { 14, "production", "/dashboard/production", null, "Production", 14 });
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[] { 35, "NAV:production", 14, null });
migrationBuilder.InsertData(
table: "sub_nav_items",
columns: new[] { "SubNavItemId", "Code", "Href", "Icon", "Label", "NavItemId", "SortOrder" },
values: new object[,]
{
{ 24, "production.templates", "/dashboard/production/templates", null, "Templates", 14, 1 },
{ 25, "production.runs", "/dashboard/production/runs", null, "Runs", 14, 2 },
{ 26, "production.produced", "/dashboard/production/produced", null, "Produced", 14, 3 }
});
migrationBuilder.InsertData(
table: "permissions",
columns: new[] { "PermissionId", "Code", "NavItemId", "SubNavItemId" },
values: new object[,]
{
{ 36, "NAV:production.templates", null, 24 },
{ 37, "NAV:production.runs", null, 25 },
{ 38, "NAV:production.produced", null, 26 }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 35);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 36);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 37);
migrationBuilder.DeleteData(
table: "permissions",
keyColumn: "PermissionId",
keyValue: 38);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 24);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 25);
migrationBuilder.DeleteData(
table: "sub_nav_items",
keyColumn: "SubNavItemId",
keyValue: 26);
migrationBuilder.DeleteData(
table: "nav_items",
keyColumn: "NavItemId",
keyValue: 14);
}
}
}
@@ -2334,6 +2334,15 @@ namespace ERPCore.Migrations
Label = "Sales",
SortOrder = 13,
Status = "Active"
},
new
{
NavItemId = 14,
Code = "production",
Href = "/dashboard/production",
Label = "Production",
SortOrder = 14,
Status = "Active"
});
});
@@ -2880,6 +2889,30 @@ namespace ERPCore.Migrations
PermissionId = 34,
Code = "NAV:accounts.received-cheques",
SubNavItemId = 22
},
new
{
PermissionId = 35,
Code = "NAV:production",
NavItemId = 14
},
new
{
PermissionId = 36,
Code = "NAV:production.templates",
SubNavItemId = 24
},
new
{
PermissionId = 37,
Code = "NAV:production.runs",
SubNavItemId = 25
},
new
{
PermissionId = 38,
Code = "NAV:production.produced",
SubNavItemId = 26
});
});
@@ -5296,6 +5329,36 @@ namespace ERPCore.Migrations
NavItemId = 12,
SortOrder = 3,
Status = "Active"
},
new
{
SubNavItemId = 24,
Code = "production.templates",
Href = "/dashboard/production/templates",
Label = "Templates",
NavItemId = 14,
SortOrder = 1,
Status = "Active"
},
new
{
SubNavItemId = 25,
Code = "production.runs",
Href = "/dashboard/production/runs",
Label = "Runs",
NavItemId = 14,
SortOrder = 2,
Status = "Active"
},
new
{
SubNavItemId = 26,
Code = "production.produced",
Href = "/dashboard/production/produced",
Label = "Produced",
NavItemId = 14,
SortOrder = 3,
Status = "Active"
});
});
@@ -18,6 +18,15 @@ public interface IProductionRunService
Task<ETagged<RunGraphDto>?> GetAsync(int runId, CancellationToken ct = default);
/// <summary>
/// Produced-items report (FR-MFG-13): one row per run that has produced something,
/// terminal-output quantities and roll-up cost, plus a summary total over every
/// filtered row. No pagination — bounded by the filters like the Sales reports.
/// </summary>
Task<ProducedListResponseDto> ListProducedAsync(
DateOnly? from, DateOnly? to, int? templateId, int? warehouseId, ProductionRunStatus? status,
CancellationToken ct = default);
/// <summary>
/// Instantiates a template (FR-MFG-08): copies every stage, input, output and edge,
/// scales all quantities by <c>targetQty / terminalOutputQtyPerBatch</c>, issues a
@@ -124,6 +124,75 @@ public sealed class ProductionRunService : IProductionRunService
return PagedResponse<RunSummaryDto>.Create(items, query.Page, query.PageSize, total);
}
public async Task<ProducedListResponseDto> ListProducedAsync(
DateOnly? from, DateOnly? to, int? templateId, int? warehouseId, ProductionRunStatus? status,
CancellationToken ct = default)
{
var q = _runs.Query().AsNoTracking()
// FR-MFG-05 (ProductionGraphValidator) guarantees exactly one item-bearing
// output per run, always the terminal one — a row exists once that output has
// actually produced something.
.Where(r => r.Stages.Any(s => s.Outputs.Any(o => o.ItemId != null && o.ProducedQty > 0)));
if (from is not null) q = q.Where(r => r.CreatedAt >= from.Value.ToDateTime(TimeOnly.MinValue));
if (to is not null) q = q.Where(r => r.CreatedAt < to.Value.AddDays(1).ToDateTime(TimeOnly.MinValue));
if (templateId is not null) q = q.Where(r => r.TemplateId == templateId);
if (warehouseId is not null) q = q.Where(r => r.WarehouseId == warehouseId);
if (status is not null) q = q.Where(r => r.Status == status);
// Projected to an anonymous type first, then mapped client-side — EF Core 10 cannot
// translate a record constructor call inside a projection alongside aggregates (same
// failure mode noted in ListAsync above).
var rows = await q
.OrderByDescending(r => r.RunId)
.Select(r => new
{
r.RunId,
r.DocNo,
r.Status,
r.TemplateId,
TemplateName = r.Template!.Name,
r.WarehouseId,
WarehouseName = r.Warehouse!.Name,
Output = r.Stages.SelectMany(s => s.Outputs)
.Where(o => o.ItemId != null)
.Select(o => new { o.ItemId, ItemName = o.Item!.Name, o.ProducedQty, o.ScrappedQty })
.First(),
ConsumedValue = r.Stages.SelectMany(s => s.Inputs).Sum(i => i.ConsumedValue),
ReturnedValue = r.Stages.SelectMany(s => s.Inputs).Sum(i => i.ReturnedValue),
r.CreatedAt,
r.CompletedAt
})
.ToListAsync(ct);
var items = rows.Select(r =>
{
var good = r.Output.ProducedQty - r.Output.ScrappedQty;
// Same formula PostReceiptAsync uses to cost the finished layer at approve
// (FR-MFG-13): pool / good qty, 6dp. TotalCost carries the pool exactly rather
// than good * unitCost, which drifts from it past ~100 units (see the ledgerValue
// rounding note in PostReceiptAsync).
var pool = r.ConsumedValue - r.ReturnedValue;
var unitCost = good > 0 ? Math.Round(pool / good, 6, MidpointRounding.AwayFromZero) : 0m;
return new ProducedLineDto(
r.RunId, r.DocNo, r.Status, r.TemplateId, r.TemplateName,
r.WarehouseId, r.WarehouseName,
r.Output.ItemId!.Value, r.Output.ItemName,
r.Output.ProducedQty, r.Output.ScrappedQty, good,
unitCost, pool,
r.CreatedAt, r.CompletedAt);
}).ToList();
var summary = new ProducedSummaryDto(
items.Count,
items.Sum(i => i.ProducedQty),
items.Sum(i => i.ScrappedQty),
items.Sum(i => i.GoodQty),
items.Sum(i => i.TotalCost));
return new ProducedListResponseDto(items, summary);
}
public async Task<ETagged<RunGraphDto>?> GetAsync(int runId, CancellationToken ct = default)
{
var run = await LoadGraphQuery().AsNoTracking().FirstOrDefaultAsync(r => r.RunId == runId, ct);
@@ -305,17 +374,58 @@ public sealed class ProductionRunService : IProductionRunService
stage = run.Stages.FirstOrDefault(s => s.RunStageId == runStageId)
?? throw new NotFoundException($"Stage {runStageId} is not part of run {runId}.");
// FR-MFG-08: editable until the stage starts. Once started, consumption has
// already happened against the planned figures.
if (stage.Status is not (ProductionStageStatus.Waiting or ProductionStageStatus.Ready))
// FR-MFG-08: freely editable before the stage starts. Once it is running, a Stock
// input can still move — raising it draws the extra amount via FIFO (mirroring
// StartStageAsync's initial draw), lowering it returns the excess to stock
// (mirroring ReturnLeftoverAsync) — but every other status stays locked.
if (stage.Status is not (ProductionStageStatus.Waiting or ProductionStageStatus.Ready or ProductionStageStatus.InProgress))
throw new DomainException(ErrorCodes.StageNotEditable,
$"Stage '{stage.Name}' is {stage.Status}; quantities can only be edited before it starts.", 409);
$"Stage '{stage.Name}' is {stage.Status}; quantities can only be edited before it starts or while it is running.", 409);
var now = DateTime.UtcNow;
var balances = new Dictionary<int, decimal>();
var consumed = new List<ConsumedInputDto>();
var returned = new List<object>();
foreach (var line in request.Inputs)
{
var input = stage.Inputs.FirstOrDefault(i => i.RunInputId == line.Id)
?? throw new DomainException(ErrorCodes.Validation,
$"Input {line.Id} is not on stage '{stage.Name}'.", 422);
if (stage.Status == ProductionStageStatus.InProgress && input.Source == StageInputSource.Stock)
{
var item = await _items.Query().AsNoTracking()
.FirstOrDefaultAsync(i => i.ItemId == input.ItemId, token)
?? throw new DomainException(ErrorCodes.Validation,
$"Item {input.ItemId} on stage '{stage.Name}' no longer exists.", 422);
var plannedBase = _measure.ToPacks(item, line.PlannedQty, input.QtyUnit);
var netConsumed = input.ConsumedQty - input.ReturnedQty;
var delta = plannedBase - netConsumed;
if (delta > 0)
{
var (dto, _) = await ConsumeInputAsync(run, input, item, delta, balances, now, token);
consumed.Add(dto);
}
else if (delta < 0)
{
var qty = -delta;
if (line.ReasonCodeId is null)
throw new DomainException(ErrorCodes.ReasonCodeRequired,
$"A reason code is required to lower input '{input.RunInputId}' below what has already been consumed.", 400);
await EnsureProductionReasonAsync(line.ReasonCodeId.Value, token);
var (unitCost, returnValue, _, _) = await ReturnInputAsync(run, input, qty, balances, now, token);
returned.Add(new
{
runInputId = input.RunInputId, qty, unitCost, value = returnValue,
reasonCodeId = line.ReasonCodeId
});
}
}
input.PlannedQty = line.PlannedQty;
}
@@ -330,12 +440,15 @@ public sealed class ProductionRunService : IProductionRunService
// Raising an upstream input's planned quantity means the deliveries that made
// this stage Ready no longer cover it, so it must fall back to Waiting. docs/30
// does not state this; it is the only behaviour consistent with FR-MFG-09.
// No-ops once the stage is running (RecomputeReadiness only moves Waiting/Ready).
RecomputeReadiness(stage);
AddEvent(run, stage, RunStageEventType.QuantityEdit, null, new
{
inputs = request.Inputs.Select(i => new { runInputId = i.Id, plannedQty = i.PlannedQty }),
outputs = request.Outputs.Select(o => new { runOutputId = o.Id, plannedQty = o.PlannedQty })
outputs = request.Outputs.Select(o => new { runOutputId = o.Id, plannedQty = o.PlannedQty }),
consumed = consumed.Select(c => new { c.RunInputId, c.ItemId, c.Qty, c.Value }),
returned
});
}, ct);
@@ -344,6 +457,87 @@ public sealed class ProductionRunService : IProductionRunService
return refreshed.Value.Stages.First(s => s.RunStageId == runStageId);
}
/// <summary>
/// FIFO-consumes <paramref name="delta"/> base units of a Stock input, posts the Out
/// ledger entry, and updates the input's ConsumedQty/ConsumedValue. Shared by
/// <see cref="StartStageAsync"/> (initial draw) and <see cref="UpdateStageQuantitiesAsync"/>
/// (raising planned qty on a running stage) so the two never drift apart.
/// </summary>
private async Task<(ConsumedInputDto Dto, StockLedger Entry)> ConsumeInputAsync(
ProductionRun run, RunStageInput input, Item item, decimal delta,
Dictionary<int, decimal> balances, DateTime now, CancellationToken ct)
{
var segments = await _fifo.ConsumeAsync(item.ItemId, run.WarehouseId, null, delta, ct);
var value = segments.Sum(s => s.Qty * s.UnitCost);
var unitCost = delta == 0 ? 0m : value / delta;
if (!balances.TryGetValue(item.ItemId, out var balance))
balance = await _fifo.GetOnHandAsync(item.ItemId, run.WarehouseId, ct);
balance -= delta;
balances[item.ItemId] = balance;
var entry = await _fifo.PostLedgerAsync(
item.ItemId, run.WarehouseId, binId: null, batchId: null, serialId: null, _currentUser.AuditUserId,
Direction.Out, delta, unitCost, balance,
LedgerSourceTypes.ProductionIssue, run.RunId, now, ct);
input.ConsumedQty += delta;
input.ConsumedValue += value;
var dto = new ConsumedInputDto(
input.RunInputId, item.ItemId, delta, value,
segments.Select(s => new ConsumedLayerDto(s.LayerId, s.Qty, s.UnitCost)).ToList());
return (dto, entry);
}
/// <summary>
/// Returns <paramref name="qty"/> base units of a Stock input's consumption back to
/// stock: a new inbound FIFO layer at the input's weighted consumed cost, plus the In
/// ledger entry. Cost-preserving — a full return zeroes the input's contribution to the
/// cost pool exactly, with no rounding crumb left behind (see the full-return branch
/// below). Shared by <see cref="ReturnLeftoverAsync"/> and
/// <see cref="UpdateStageQuantitiesAsync"/> (lowering planned qty on a running stage).
/// </summary>
private async Task<(decimal UnitCost, decimal Value, StockLayer Layer, StockLedger Entry)> ReturnInputAsync(
ProductionRun run, RunStageInput input, decimal qty,
Dictionary<int, decimal> balances, DateTime now, CancellationToken ct)
{
// Weighted cost actually consumed for THIS input — cost-preserving by construction,
// so what leaves the pool is exactly what entered it.
var weighted = input.ConsumedValue / input.ConsumedQty;
var unitCost = Math.Round(weighted, 6, MidpointRounding.AwayFromZero);
// Rounded once, from the UNROUNDED weighted cost. Rounding the rate first and then
// multiplying lets successive partial returns accumulate drift until the last one
// can exceed ConsumedValue.
var returnValue = input.ReturnedQty + qty == input.ConsumedQty
// Full return: take the exact residual so the input's contribution to the pool
// lands on precisely zero rather than a rounding crumb.
? input.ConsumedValue - input.ReturnedValue
: Math.Round(qty * weighted, 4, MidpointRounding.AwayFromZero);
var layer = await _fifo.CreateInboundLayerAsync(
input.ItemId!.Value, run.WarehouseId, batchId: null, serialId: null, grnLineId: null,
qty, unitCost, now, ct);
if (!balances.TryGetValue(input.ItemId.Value, out var balance))
balance = await _fifo.GetOnHandAsync(input.ItemId.Value, run.WarehouseId, ct);
balance += qty;
balances[input.ItemId.Value] = balance;
// binId stays null: this is raw material going back to general stock, not into the
// run's finished-goods output bin.
var entry = await _fifo.PostLedgerAsync(
input.ItemId.Value, run.WarehouseId, binId: null, batchId: null, serialId: null,
_currentUser.AuditUserId, Direction.In, qty, unitCost, balance,
LedgerSourceTypes.ProductionReturn, run.RunId, now, ct, valueOverride: returnValue);
input.ReturnedQty += qty;
input.ReturnedValue += returnValue;
return (unitCost, returnValue, layer, entry);
}
// --- stage actions -------------------------------------------------------
public async Task<StartStageResultDto> StartStageAsync(
@@ -361,7 +555,6 @@ public sealed class ProductionRunService : IProductionRunService
$"Stage '{stage.Name}' is {stage.Status}; only a Ready stage can be started.", 409);
var now = DateTime.UtcNow;
var actor = _currentUser.AuditUserId;
// Running balances accumulated per item across the loop: layers written in this
// transaction are invisible to GetOnHandAsync until SaveChanges, so two inputs of
@@ -386,27 +579,9 @@ public sealed class ProductionRunService : IProductionRunService
var delta = plannedBase - input.ConsumedQty;
if (delta <= 0) continue; // rework restart with no increase — nothing to draw
var segments = await _fifo.ConsumeAsync(item.ItemId, run.WarehouseId, null, delta, token);
var value = segments.Sum(s => s.Qty * s.UnitCost);
var unitCost = delta == 0 ? 0m : value / delta;
if (!balances.TryGetValue(item.ItemId, out var balance))
balance = await _fifo.GetOnHandAsync(item.ItemId, run.WarehouseId, token);
balance -= delta;
balances[item.ItemId] = balance;
var entry = await _fifo.PostLedgerAsync(
item.ItemId, run.WarehouseId, binId: null, batchId: null, serialId: null, actor,
Direction.Out, delta, unitCost, balance,
LedgerSourceTypes.ProductionIssue, run.RunId, now, token);
var (dto, entry) = await ConsumeInputAsync(run, input, item, delta, balances, now, token);
ledgerRefs.Add(entry);
input.ConsumedQty += delta;
input.ConsumedValue += value;
consumed.Add(new ConsumedInputDto(
input.RunInputId, item.ItemId, delta, value,
segments.Select(s => new ConsumedLayerDto(s.LayerId, s.Qty, s.UnitCost)).ToList()));
consumed.Add(dto);
}
stage.Status = ProductionStageStatus.InProgress;
@@ -649,36 +824,9 @@ public sealed class ProductionRunService : IProductionRunService
var now = DateTime.UtcNow;
var stage = run.Stages.First(s => s.RunStageId == input.RunStageId);
// Weighted cost actually consumed for THIS input — cost-preserving by
// construction, so what leaves the pool is exactly what entered it.
var weighted = input.ConsumedValue / input.ConsumedQty;
unitCost = Math.Round(weighted, 6, MidpointRounding.AwayFromZero);
returnedQty = request.Qty;
// Rounded once, from the UNROUNDED weighted cost. Rounding the rate first and
// then multiplying lets successive partial returns accumulate drift until the
// last one can exceed ConsumedValue.
returnValue = input.ReturnedQty + request.Qty == input.ConsumedQty
// Full return: take the exact residual so the input's contribution to the
// pool lands on precisely zero rather than a rounding crumb.
? input.ConsumedValue - input.ReturnedValue
: Math.Round(request.Qty * weighted, 4, MidpointRounding.AwayFromZero);
layer = await _fifo.CreateInboundLayerAsync(
input.ItemId.Value, run.WarehouseId, batchId: null, serialId: null, grnLineId: null,
request.Qty, unitCost, now, token);
var balance = await _fifo.GetOnHandAsync(input.ItemId.Value, run.WarehouseId, token) + request.Qty;
// binId stays null: this is raw material going back to general stock, not into the
// run's finished-goods output bin.
entry = await _fifo.PostLedgerAsync(
input.ItemId.Value, run.WarehouseId, binId: null, batchId: null, serialId: null,
_currentUser.AuditUserId, Direction.In, request.Qty, unitCost, balance,
LedgerSourceTypes.ProductionReturn, run.RunId, now, token, valueOverride: returnValue);
input.ReturnedQty += request.Qty;
input.ReturnedValue += returnValue;
(unitCost, returnValue, layer, entry) =
await ReturnInputAsync(run, input, request.Qty, new Dictionary<int, decimal>(), now, token);
var consumedTotal = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ConsumedValue);
var returnedTotal = run.Stages.SelectMany(s => s.Inputs).Sum(i => i.ReturnedValue);
@@ -1,6 +1,6 @@
"use client"
import { Fragment, useEffect, useState } from "react"
import { useEffect, useState } from "react"
import Link from "next/link"
import { ArrowLeft, BookOpen } from "lucide-react"
@@ -62,7 +62,7 @@ export default function GeneralLedgerReportPage() {
</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>
<p className="text-base text-muted-foreground">Every posted movement, grouped by transaction so every leg of a GRN, Sale, or other journal entry sits together.</p>
</div>
</div>
@@ -108,37 +108,32 @@ export default function GeneralLedgerReportPage() {
<Table className="mt-4 text-base">
<TableHeader>
<TableRow>
<TableHead className="h-11 px-3 text-sm">Account Code</TableHead>
<TableHead className="h-11 px-3 text-sm">Account Name</TableHead>
<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-sm">Description</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>
)
})}
{/* Grouped by transaction (entryDate, then journalNo) rather than by account, so every
leg of the same GRN/Sale/journal — the inventory line, the VAT line, the AP line —
lands on adjacent rows instead of being scattered under separate account blocks. */}
{[...rows]
.sort((a, b) => a.entryDate.localeCompare(b.entryDate) || a.journalNo.localeCompare(b.journalNo))
.map((row, i) => (
<TableRow key={i}>
<TableCell className="px-3 py-2.5 font-medium">{row.accountCode}</TableCell>
<TableCell className="px-3 py-2.5">{row.accountName}</TableCell>
<TableCell className="px-3 py-2.5">{formatReportDate(row.entryDate)}</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>
</TableRow>
))}
</TableBody>
</Table>
</div>
@@ -0,0 +1,242 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { PackageCheck } from "lucide-react"
import { cn } from "@/lib/utils"
import { productionRunsApi } from "@/lib/api/production-runs"
import { productionTemplatesApi } from "@/lib/api/production-templates"
import { warehousesApi } from "@/lib/api/warehouses"
import { errorMessage } from "@/lib/error-map"
import { ProducedListResponse, ProductionRunStatus, ProductionTemplateSummary } from "@/types/production"
import { Warehouse } from "@/types/master-data"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
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"
type StatusFilter = ProductionRunStatus | "All"
function runStatusBadgeClass(status: ProductionRunStatus) {
if (status === "Completed") return "bg-success/10 text-success"
if (status === "Cancelled") return "bg-destructive/10 text-destructive"
return "bg-info/10 text-info"
}
// 6dp on the wire (matches the server's PostReceiptAsync rounding); shown to 4dp so the unit
// cost of a fractional-value item is still legible without dragging the whole table wide.
function formatCost(value: number) {
return value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 4 })
}
export default function ProducedItemsPage() {
const router = useRouter()
// null = still loading (the codebase convention for "no data yet" vs "empty result").
const [data, setData] = useState<ProducedListResponse | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [templates, setTemplates] = useState<ProductionTemplateSummary[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [from, setFrom] = useState("")
const [to, setTo] = useState("")
const [templateId, setTemplateId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [status, setStatus] = useState<StatusFilter>("All")
const load = useCallback(() => {
setLoadError(null)
productionRunsApi
.listProduced({
from: from || undefined,
to: to || undefined,
templateId: templateId ?? undefined,
warehouseId: warehouseId ?? undefined,
status: status === "All" ? undefined : status,
})
.then(setData)
.catch((err) => {
setData({ items: [], summary: { runCount: 0, totalProducedQty: 0, totalScrappedQty: 0, totalGoodQty: 0, totalCost: 0 } })
setLoadError(errorMessage(err))
})
}, [from, to, templateId, warehouseId, status])
useEffect(load, [load])
useEffect(() => {
Promise.all([productionTemplatesApi.list({ pageSize: 200 }), warehousesApi.list({ pageSize: 200 })])
.then(([templateRes, warehouseRes]) => {
setTemplates(templateRes.items)
setWarehouses(warehouseRes.items)
})
.catch((err) => setLoadError(errorMessage(err)))
}, [])
const hasFilters = from !== "" || to !== "" || templateId !== null || warehouseId !== null || status !== "All"
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-bold text-foreground">Produced Items</h1>
<p className="text-base text-muted-foreground">
Finished-goods lines produced by runs, with the roll-up cost of production (FR-MFG-13).
</p>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-5">
<div className="flex flex-col gap-1.5">
<Label className="text-base">From</Label>
<Input type="date" value={from} onChange={(e) => setFrom(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={to} onChange={(e) => setTo(e.target.value)} className="h-11 text-base" />
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-base">Template</Label>
<Select<number | null> value={templateId} onValueChange={setTemplateId}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="All templates" />
</SelectTrigger>
<SelectContent>
{templates.map((t) => (
<SelectItem key={t.templateId} value={t.templateId} className="text-base">{t.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-base">Warehouse</Label>
<Select<number | null> value={warehouseId} onValueChange={setWarehouseId}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue placeholder="All warehouses" />
</SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={w.warehouseId} className="text-base">{w.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-base">Status</Label>
<Select<StatusFilter> value={status} onValueChange={(v) => setStatus(v ?? "All")}>
<SelectTrigger className="h-11! w-full text-base">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="All" className="text-base">All statuses</SelectItem>
<SelectItem value="InProgress" className="text-base">In Progress</SelectItem>
<SelectItem value="Completed" className="text-base">Completed</SelectItem>
<SelectItem value="Cancelled" className="text-base">Cancelled</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{hasFilters && (
<Button
variant="ghost"
size="sm"
className="w-fit"
onClick={() => {
setFrom("")
setTo("")
setTemplateId(null)
setWarehouseId(null)
setStatus("All")
}}
>
Clear filters
</Button>
)}
{loadError && (
<div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{loadError}</div>
)}
{data === null ? (
<div className="flex flex-col gap-3">
<Skeleton className="h-14 w-full" />
<Skeleton className="h-14 w-full" />
<Skeleton className="h-14 w-full" />
</div>
) : data.items.length === 0 ? (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed py-20 text-center">
<PackageCheck className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">
{hasFilters ? "No produced lines match your filter." : "No runs have produced anything yet."}
</p>
</div>
) : (
<div className="overflow-x-auto rounded-xl border">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-3 text-sm">Doc No</TableHead>
<TableHead className="h-12 px-3 text-sm">Status</TableHead>
<TableHead className="h-12 px-3 text-sm">Template</TableHead>
<TableHead className="h-12 px-3 text-sm">Warehouse</TableHead>
<TableHead className="h-12 px-3 text-sm">Item</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Produced</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Scrapped</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Good Qty</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Unit Cost</TableHead>
<TableHead className="h-12 px-3 text-right text-sm">Cost of Production</TableHead>
<TableHead className="h-12 px-3 text-sm">Completed</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.items.map((line) => (
<TableRow
key={line.runId}
className="cursor-pointer"
onClick={() => router.push(`/dashboard/production/runs/${line.runId}`)}
>
<TableCell className="px-3 py-3.5 font-medium text-foreground">{line.docNo}</TableCell>
<TableCell className="px-3 py-3.5">
<Badge variant="outline" className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", runStatusBadgeClass(line.status))}>
{line.status === "InProgress" ? "In Progress" : line.status}
</Badge>
</TableCell>
<TableCell className="px-3 py-3.5">{line.templateName}</TableCell>
<TableCell className="px-3 py-3.5">{line.warehouseName}</TableCell>
<TableCell className="px-3 py-3.5">{line.itemName}</TableCell>
<TableCell className="px-3 py-3.5 text-right">{line.producedQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right">{line.scrappedQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right">{line.goodQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right">{formatCost(line.unitCost)}</TableCell>
<TableCell className="px-3 py-3.5 text-right font-medium">{formatCost(line.totalCost)}</TableCell>
<TableCell className="px-3 py-3.5 text-muted-foreground">
{line.completedAt ? new Date(line.completedAt).toLocaleDateString() : "—"}
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell className="px-3 py-3.5 font-semibold" colSpan={5}>
Total ({data.summary.runCount} run{data.summary.runCount === 1 ? "" : "s"})
</TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalProducedQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalScrappedQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5 text-right font-semibold">{data.summary.totalGoodQty.toLocaleString()}</TableCell>
<TableCell className="px-3 py-3.5" />
<TableCell className="px-3 py-3.5 text-right font-semibold">
Cost of Production: {formatCost(data.summary.totalCost)}
</TableCell>
<TableCell className="px-3 py-3.5" />
</TableRow>
</TableFooter>
</Table>
</div>
)}
</div>
)
}
@@ -14,12 +14,14 @@ import {
RunStage,
RunStageInput,
RunStageOutput,
StageQtyUnit,
StageQuantityLine,
TransferLine,
} from "@/types/production"
import { ItemListItem, Uom } from "@/types/master-data"
import { baseUomLabel, contentUnitLabel, uomLabel } from "@/lib/uom-label"
import { ReasonCode } from "@/types/stock"
import { OnHand, ReasonCode } from "@/types/stock"
import { stockApi } from "@/lib/api/stock"
import { AlertDialog, AlertDialogContent } from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
@@ -60,6 +62,22 @@ function minutesSince(iso: string, now: number): number {
return Math.max(0, Math.floor((now - new Date(iso).getTime()) / 60_000))
}
/**
* Mirrors the server's `IItemMeasure.ToPacks` (Services/Stock/ItemMeasure.cs) closely enough to
* tell whether an edited planned qty is a raise or a lower — a UI-only estimate to decide when
* to ask for a reason up front. The server always recomputes it authoritatively.
*/
function toPacksClient(items: ItemListItem[], itemId: number | null, qty: number, unit: StageQtyUnit): number {
if (unit === "Pack" || itemId === null) return qty
const contentBaseQty = items.find((i) => i.itemId === itemId)?.contentBaseQty
if (!contentBaseQty) return qty
// Rounded the same way as the server's `ToPacks` (4dp, half away from zero) — ConsumedQty was
// stored at that rounded value, so comparing against the raw division here would flag an
// untouched Content-unit input (300 ml / 175 ml = 1.714285714... vs the stored 1.7143) as a
// decrease and demand a reason code nothing actually changed.
return Math.round((qty / contentBaseQty) * 10000) / 10000
}
function StatusPill({ status }: { status: StageStatus }) {
const color = STAGE_STATUS_COLOR[status]
return (
@@ -102,9 +120,13 @@ export function StageDrawer({
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
// Editable planned quantities (Ready only) keyed by run input/output id.
// Editable planned quantities keyed by run input/output id — Ready only, plus a running
// Stock input's planned qty (raises draw more via FIFO, lowers return the excess).
const [plannedInputs, setPlannedInputs] = useState<Record<number, string>>({})
const [plannedOutputs, setPlannedOutputs] = useState<Record<number, string>>({})
// Reason code for a running Stock input whose planned qty is being lowered below what it
// has already consumed — required by the server the same way a leftover return is.
const [inputReasonCode, setInputReasonCode] = useState<Record<number, number | null>>({})
// Complete-form state.
const [produced, setProduced] = useState<Record<number, string>>({})
@@ -135,6 +157,7 @@ export function StageDrawer({
setScrapReason(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, o.scrapReasonCodeId])))
setFieldValues((stage.fieldValues ?? {}) as Record<string, unknown>)
setTransferQty(Object.fromEntries(stage.outputs.map((o) => [o.runOutputId, String(o.availableToTransfer)])))
setInputReasonCode({})
setError(null)
idempotencyKey.current = crypto.randomUUID()
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -212,7 +235,11 @@ export function StageDrawer({
function saveQuantities() {
const inputs: StageQuantityLine[] = stage!.inputs
.map((i) => ({ id: i.runInputId, plannedQty: Number(plannedInputs[i.runInputId] ?? i.plannedQty) }))
.map((i) => ({
id: i.runInputId,
plannedQty: Number(plannedInputs[i.runInputId] ?? i.plannedQty),
reasonCodeId: inputReasonCode[i.runInputId] ?? null,
}))
.filter((line) => Number.isFinite(line.plannedQty) && line.plannedQty > 0)
const outputs: StageQuantityLine[] = stage!.outputs
.map((o) => ({ id: o.runOutputId, plannedQty: Number(plannedOutputs[o.runOutputId] ?? o.plannedQty) }))
@@ -284,6 +311,21 @@ export function StageDrawer({
const scrapReasons = reasonCodes.filter((r) => r.context === "Production")
// A running Stock input's planned qty can still move: raising it draws more via FIFO,
// lowering it returns the excess and — same rule a leftover return already enforces
// server-side — needs a reason. Upstream WIP has no stock behind it, so it stays read-only.
const runningStockInputs = stage.inputs.filter((i) => i.source === "Stock")
const isInputDecrease = (input: RunStageInput) => {
const plannedBase = toPacksClient(
items, input.itemId, Number(plannedInputs[input.runInputId] ?? input.plannedQty), input.qtyUnit
)
return Number.isFinite(plannedBase) && plannedBase < input.consumedQty - input.returnedQty
}
const missingInputReasons =
stage.status === "InProgress"
? runningStockInputs.filter((i) => isInputDecrease(i) && !inputReasonCode[i.runInputId]).map((i) => itemName(i.itemId))
: []
const missingFields = missingRequiredFields(stage.fieldDefs, fieldValues)
const missingScrapReasons = stage.outputs
.filter((o) => Number(scrapped[o.runOutputId] ?? 0) > 0 && !scrapReason[o.runOutputId])
@@ -339,6 +381,7 @@ export function StageDrawer({
onValueChange={(v) => setPlannedInputs((prev) => ({ ...prev, [input.runInputId]: v }))}
itemName={itemName}
unitLabel={inputUnit}
warehouseId={run.warehouseId}
/>
))}
</section>
@@ -424,6 +467,39 @@ export function StageDrawer({
</section>
)}
{stage.status === "InProgress" && runningStockInputs.length > 0 && (
<section className="flex flex-col gap-3">
<h3 className="text-sm font-bold text-foreground">Adjust input quantities</h3>
<p className="text-sm text-muted-foreground">
Raising a quantity draws the extra amount from stock; lowering it returns the excess — pick a
reason for any decrease.
</p>
{runningStockInputs.map((input) => (
<RunningStockInputRow
key={input.runInputId}
input={input}
value={plannedInputs[input.runInputId] ?? String(input.plannedQty)}
onValueChange={(v) => setPlannedInputs((prev) => ({ ...prev, [input.runInputId]: v }))}
itemName={itemName}
unitLabel={inputUnit}
warehouseId={run.warehouseId}
decreasing={isInputDecrease(input)}
reasonCodeId={inputReasonCode[input.runInputId] ?? null}
onReasonChange={(id) => setInputReasonCode((prev) => ({ ...prev, [input.runInputId]: id }))}
scrapReasons={scrapReasons}
/>
))}
{missingInputReasons.length > 0 && (
<p className="text-sm text-destructive">
Pick a reason for lowering: {missingInputReasons.join(", ")}.
</p>
)}
<Button variant="outline" onClick={saveQuantities} disabled={busy || missingInputReasons.length > 0}>
Save quantities
</Button>
</section>
)}
{stage.status === "InProgress" && (
<section className="flex flex-col gap-3">
<h3 className="text-sm font-bold text-foreground">Record output</h3>
@@ -662,6 +738,56 @@ export function StageDrawer({
}
/** Split out only because the input card has three mutually exclusive quantity presentations. */
/**
* Live available-stock lookup for a Stock input — shared by the Ready-state InputCard and the
* running-stage "Adjust input quantities" row, so the two never fetch it differently.
*/
function useAvailableStock(itemId: number | null, warehouseId: number, enabled: boolean) {
const [onHand, setOnHand] = useState<OnHand | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!enabled || itemId === null) {
setOnHand(null)
setError(null)
return
}
setLoading(true)
setError(null)
stockApi
.onHand(itemId, warehouseId)
.then(setOnHand)
.catch((err) => {
setOnHand(null)
setError(errorMessage(err))
})
.finally(() => setLoading(false))
}, [enabled, itemId, warehouseId])
return { onHand, loading, error }
}
/** `null` while loading/unavailable, otherwise the "Available: X" line InputCard and
* RunningStockInputRow both show under an editable Planned qty. */
function AvailableStockLine({
loading, error, onHand,
}: {
loading: boolean
error: string | null
onHand: OnHand | null
}) {
return (
<p className="text-right text-xs text-muted-foreground">
{loading
? "Checking available stock…"
: error
? "Available stock unknown"
: onHand
? `Available: ${fmt(onHand.available)}`
: null}
</p>
)
}
function InputCard({
input,
editable,
@@ -669,6 +795,7 @@ function InputCard({
onValueChange,
itemName,
unitLabel,
warehouseId,
}: {
input: RunStageInput
editable: boolean
@@ -676,10 +803,16 @@ function InputCard({
onValueChange: (value: string) => void
itemName: (id: number | null) => string
unitLabel: (input: RunStageInput) => string
warehouseId: number
}) {
const isUpstream = input.source === "Upstream"
const short = isUpstream && input.deliveredQty < input.plannedQty
// Available stock is only meaningful while the planned qty is still editable (Ready) and
// for a real stock item — Upstream inputs are WIP handed off from a parent stage, not stock.
const { onHand, loading: onHandLoading, error: onHandError } =
useAvailableStock(input.itemId, warehouseId, editable && !isUpstream)
return (
<div className="rounded-lg border border-border p-3">
<div className="flex items-baseline justify-between gap-2">
@@ -691,17 +824,22 @@ function InputCard({
<div className="mt-2 flex flex-col gap-1">
{editable ? (
<label className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Planned</span>
<Input
type="number"
min={0}
step="any"
value={value}
onChange={(e) => onValueChange(e.target.value)}
className="h-8 w-28 text-sm"
/>
</label>
<>
<label className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Planned</span>
<Input
type="number"
min={0}
step="any"
value={value}
onChange={(e) => onValueChange(e.target.value)}
className="h-8 w-28 text-sm"
/>
</label>
{!isUpstream && input.itemId !== null && (
<AvailableStockLine loading={onHandLoading} error={onHandError} onHand={onHand} />
)}
</>
) : (
<Row label="Planned">{fmt(input.plannedQty)}</Row>
)}
@@ -735,6 +873,69 @@ function InputCard({
)
}
/** A running Stock input's editable Planned qty, with the same live "Available: X" lookup
* InputCard shows before the stage starts, plus the reason picker a lowered qty requires. */
function RunningStockInputRow({
input,
value,
onValueChange,
itemName,
unitLabel,
warehouseId,
decreasing,
reasonCodeId,
onReasonChange,
scrapReasons,
}: {
input: RunStageInput
value: string
onValueChange: (value: string) => void
itemName: (id: number | null) => string
unitLabel: (input: RunStageInput) => string
warehouseId: number
decreasing: boolean
reasonCodeId: number | null
onReasonChange: (id: number) => void
scrapReasons: ReasonCode[]
}) {
const { onHand, loading, error } = useAvailableStock(input.itemId, warehouseId, true)
return (
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
<div className="flex items-baseline justify-between gap-2">
<p className="min-w-0 truncate text-sm font-medium text-foreground">{itemName(input.itemId)}</p>
<span className="shrink-0 text-xs text-muted-foreground">{unitLabel(input)}</span>
</div>
<label className="flex items-center justify-between gap-3 text-sm">
<span className="text-muted-foreground">Planned</span>
<Input
type="number"
min={0}
step="any"
value={value}
onChange={(e) => onValueChange(e.target.value)}
className="h-8 w-28 text-sm"
/>
</label>
<AvailableStockLine loading={loading} error={error} onHand={onHand} />
{decreasing && (
<Select<number> value={reasonCodeId} onValueChange={(v) => v && onReasonChange(v)}>
<SelectTrigger className="h-9! w-full text-sm">
<SelectValue placeholder="Reason for lowering (required)" />
</SelectTrigger>
<SelectContent>
{scrapReasons.map((r) => (
<SelectItem key={r.reasonCodeId} value={r.reasonCodeId} className="text-sm">
{r.code} — {r.description}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)
}
/** Thin wrapper so the drawer body stays readable; the renderer itself lives in its own file. */
function CustomFields({
stage,
@@ -27,6 +27,10 @@ function slugify(label: string) {
return label.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "")
}
function fmtStock(available: number | undefined) {
return (available ?? 0).toLocaleString(undefined, { maximumFractionDigits: 4 })
}
const ROLE_SUGGESTIONS = ["Assembly", "QA", "Welding", "Packing", "Inspection", "Cutting", "Soldering"]
const FIELD_TYPES: CustomFieldType[] = ["Text", "Number", "Checkbox", "Date", "Select"]
@@ -128,6 +132,7 @@ export function StageEditorPanel({
upstreamOptions,
items,
uoms,
stockByItem,
readOnly,
onChange,
onDelete,
@@ -138,6 +143,8 @@ export function StageEditorPanel({
upstreamOptions: UpstreamOutputOption[]
items: ItemListItem[]
uoms: Uom[]
/** Available stock per item, summed across every warehouse — templates aren't warehouse-scoped. */
stockByItem: Map<number, number>
readOnly: boolean
onChange: (patch: Partial<StageNodeData>) => void
onDelete: () => void
@@ -239,18 +246,19 @@ export function StageEditorPanel({
<Field>
<FieldLabel>Role label</FieldLabel>
<Input
<Select<string>
value={data.roleLabel}
disabled={readOnly}
onChange={(e) => onChange({ roleLabel: e.target.value })}
placeholder="e.g. QA"
list="role-suggestions"
/>
<datalist id="role-suggestions">
{ROLE_SUGGESTIONS.map((r) => (
<option key={r} value={r} />
))}
</datalist>
onValueChange={(v) => v && onChange({ roleLabel: v })}
>
<SelectTrigger className="h-8! w-full text-sm" disabled={readOnly}>
<SelectValue placeholder="Pick a role" />
</SelectTrigger>
<SelectContent>
{ROLE_SUGGESTIONS.map((r) => (
<SelectItem key={r} value={r} className="text-sm">{r}</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field>
@@ -312,6 +320,9 @@ export function StageEditorPanel({
{items.map((i) => (
<SelectItem key={i.itemId} value={i.itemId} className="text-sm">
{i.name} · {i.sku}
<span className="text-muted-foreground">
· Stock: {fmtStock(stockByItem.get(i.itemId))}
</span>
</SelectItem>
))}
</SelectContent>
@@ -26,6 +26,7 @@ import { cn } from "@/lib/utils"
import { productionTemplatesApi } from "@/lib/api/production-templates"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { stockApi } from "@/lib/api/stock"
import { errorMessage } from "@/lib/error-map"
import { CanvasAnnotation, ProductionTemplateGraph, SaveTemplateRequest, TemplateStatus } from "@/types/production"
import { ItemListItem, Uom } from "@/types/master-data"
@@ -172,6 +173,7 @@ function TemplateBuilderContent() {
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [stockByItem, setStockByItem] = useState<Map<number, number>>(new Map())
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
@@ -229,10 +231,21 @@ function TemplateBuilderContent() {
}, [isNew, templateId, load])
useEffect(() => {
Promise.all([itemsApi.list({ pageSize: 200, status: "Active" }), uomsApi.list({ pageSize: 200 })])
.then(([itemRes, uomRes]) => {
Promise.all([
itemsApi.list({ pageSize: 200, status: "Active" }),
uomsApi.list({ pageSize: 200 }),
stockApi.onHandList({ pageSize: 200 }),
])
.then(([itemRes, uomRes, onHandRes]) => {
setItems(itemRes.items)
setUoms(uomRes.items)
// Templates aren't tied to a warehouse, so the picker shows availability summed across
// every warehouse the item holds stock in.
const byItem = new Map<number, number>()
for (const row of onHandRes.items) {
byItem.set(row.itemId, (byItem.get(row.itemId) ?? 0) + row.available)
}
setStockByItem(byItem)
})
.catch((err) => toast.error("Could not load items and UOMs", errorMessage(err)))
}, [])
@@ -804,6 +817,7 @@ function TemplateBuilderContent() {
isTerminal={analysis.terminalIds.has(selectedNode.id)}
upstreamOptions={upstreamOptions}
items={items}
stockByItem={stockByItem}
uoms={uoms}
readOnly={locked}
onChange={(patch) => updateNodeData(selectedNode.id, patch)}
@@ -244,8 +244,8 @@ export default function BundleSalesPage() {
</TableCell>
<TableCell className="px-4 py-3.5">{row.customerSnapshotName}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5 text-right font-mono tabular-nums">{row.bundlePrice.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right font-mono font-semibold tabular-nums">{row.grandTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.bundlePrice.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.grandTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5">
<Badge variant="outline" className={statusClass(row.status)}>
{row.status}
@@ -271,11 +271,11 @@ export default function BundleSalesPage() {
<div className="grid gap-3 text-sm md:grid-cols-2">
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="text-muted-foreground">Rows loaded</div>
<div className="font-mono text-base font-semibold tabular-nums">{visibleRows.length}</div>
<div className="text-base font-semibold tabular-nums">{visibleRows.length}</div>
</div>
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="text-muted-foreground">Grand total</div>
<div className="font-mono text-base font-semibold tabular-nums">{bundleTotal.toFixed(2)}</div>
<div className="text-base font-semibold tabular-nums">{bundleTotal.toFixed(2)}</div>
</div>
</div>
</div>
@@ -145,7 +145,7 @@ export default function SalesDayEndPage() {
<ul className="list-inside list-disc text-sm">
{preview.draftSlipsBlockingClose.map((s) => (
<li key={`slip-${s.salesSlipId}`}>
<Link href={`/dashboard/sales/slips/${s.salesSlipId}`} className="underline">{s.slipNo}</Link> — {s.grandTotal.toFixed(2)}
{s.slipNo} — {s.grandTotal.toFixed(2)}
</li>
))}
{preview.draftBundleSalesBlockingClose.map((b) => (
@@ -431,8 +431,8 @@ export default function NewFreeIssuePage() {
<div className="font-medium">{row.itemName}</div>
<div className="text-xs text-muted-foreground">{row.itemSku} · {row.uomName}</div>
</TableCell>
<TableCell className="px-4 py-3.5 text-right font-mono tabular-nums">{row.qty}</TableCell>
<TableCell className="px-4 py-3.5 text-right font-mono tabular-nums">{row.freeQty}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.qty}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.freeQty}</TableCell>
<TableCell className="px-4 py-3.5">
<div className="flex justify-end gap-1.5">
<button
@@ -230,7 +230,7 @@ export default function NewSalesInvoicePage() {
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">New invoice</h1>
<h1 className="text-2xl font-bold text-foreground">New Sale</h1>
<p className="text-base text-muted-foreground">Select items, set quantity, unit price and line discount.</p>
</div>
</div>
@@ -75,7 +75,7 @@ export default function SalesInvoicesPage() {
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Sales Invoices</h1>
<h1 className="text-2xl font-bold text-foreground">Sales</h1>
<p className="text-base text-muted-foreground">Invoice register with filters, posting flow, and settlement tracking.</p>
</div>
<div className="flex flex-wrap gap-2">
@@ -90,7 +90,7 @@ export default function SalesInvoicesPage() {
</Link>
<Link href="/dashboard/sales/invoices/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Invoice
New Sale
</Link>
</div>
</div>
@@ -172,10 +172,10 @@ export default function SalesInvoicesPage() {
<TableCell className="px-4 py-3.5">{row.customerSnapshotName}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.invoiceDate).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5 text-right font-mono tabular-nums">{row.totals.freeQtyTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right font-mono tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right font-mono tabular-nums text-muted-foreground">-{row.totals.discountTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right font-mono font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.freeQtyTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right tabular-nums text-muted-foreground">-{row.totals.discountTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5">
<Badge variant="outline" className={statusClass(row.status)}>
{row.status}
@@ -200,15 +200,15 @@ export default function SalesInvoicesPage() {
<div className="grid gap-3 text-sm md:grid-cols-3">
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="text-muted-foreground">Gross total</div>
<div className="font-mono text-base font-semibold tabular-nums">{grossTotal.toFixed(2)}</div>
<div className="text-base font-semibold tabular-nums">{grossTotal.toFixed(2)}</div>
</div>
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="text-muted-foreground">Discount total</div>
<div className="font-mono text-base font-semibold tabular-nums">-{discountTotal.toFixed(2)}</div>
<div className="text-base font-semibold tabular-nums">-{discountTotal.toFixed(2)}</div>
</div>
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="text-muted-foreground">Net total</div>
<div className="font-mono text-base font-semibold tabular-nums">{netTotal.toFixed(2)}</div>
<div className="text-base font-semibold tabular-nums">{netTotal.toFixed(2)}</div>
</div>
</div>
</div>
@@ -1,5 +1,5 @@
import Link from "next/link"
import { FileBarChart, FileText, Lock, PackageX, ReceiptText, ShoppingCart } from "lucide-react"
import { FileBarChart, FileText, Lock, PackageX, ReceiptText } from "lucide-react"
import { buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
@@ -11,12 +11,6 @@ const sections = [
href: "/dashboard/sales/invoices",
icon: FileText,
},
{
title: "Slips",
description: "Counter-style sales documents.",
href: "/dashboard/sales/slips",
icon: ShoppingCart,
},
{
title: "Free Issues",
description: "Promotional free-issue slips.",
@@ -1,479 +0,0 @@
"use client"
import { use, useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ArrowLeft, ExternalLink, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { warehousesApi } from "@/lib/api/warehouses"
import { usersApi } from "@/lib/api/users"
import { errorMessage } from "@/lib/error-map"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
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, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
import { FreeIssuePromotionSuggestions } from "@/components/sales/FreeIssuePromotionSuggestions"
import { Customer } from "@/types/customers"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { ManagedUser } from "@/types/users"
import { CreateSalesSlipLineRequest, SalesFreeIssueSuggestion, SalesSlip, SalesSlipPostingCheck } from "@/types/sales"
import { toast } from "@/components/ui/toast"
type Line = CreateSalesSlipLineRequest & { key: string }
const money = new Intl.NumberFormat("en-LK", {
style: "currency",
currency: "LKR",
minimumFractionDigits: 2,
})
const blankLine = (key: string): Line => ({
key,
itemId: 0,
warehouseId: 0,
qty: 1,
freeQty: 0,
unitPrice: null,
allowManualPriceOverride: true,
discountMode: "Percentage",
discountPct: 0,
discountAmount: 0,
discountValue: 0,
taxPct: 0,
isFreeIssue: false,
parentLineId: null,
})
export default function SalesSlipDetailPage({ params }: { params: Promise<{ id: string }> }) {
const resolvedParams = use(params)
const slipId = Number(resolvedParams.id)
const [slip, setSlip] = useState<SalesSlip | null>(null)
const [etag, setEtag] = useState<string | null>(null)
const [customers, setCustomers] = useState<Customer[]>([])
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [users, setUsers] = useState<ManagedUser[]>([])
const [promotionSuggestion, setPromotionSuggestion] = useState<SalesFreeIssueSuggestion | null>(null)
const [postingCheck, setPostingCheck] = useState<SalesSlipPostingCheck | null>(null)
const [customerId, setCustomerId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
const [lines, setLines] = useState<Line[]>([blankLine("line-1")])
const [error, setError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [actionBusy, setActionBusy] = useState<"post" | "cancel" | null>(null)
useEffect(() => {
if (!Number.isFinite(slipId)) {
setError(`Invalid slip id '${resolvedParams.id}'.`)
return
}
Promise.all([
customersApi.list({ pageSize: 200 }),
itemsApi.list({ pageSize: 200 }),
uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
salesApi.getSlip(slipId),
])
.then(([cust, itemRes, uomRes, whRes, userRes, doc]) => {
setCustomers(cust.items)
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
setUsers(userRes.items)
setSlip(doc.data)
setEtag(doc.etag)
setCustomerId(doc.data.customerId)
setWarehouseId(doc.data.warehouseId)
setCashierUserId(doc.data.cashierUserId)
setPromotionSuggestion(null)
setLines(
doc.data.lines.map((line) => ({
key: String(line.salesSlipLineId),
itemId: line.itemId,
warehouseId: line.warehouseId,
qty: line.qty,
freeQty: line.freeQty,
unitPrice: line.unitPrice,
allowManualPriceOverride: true,
discountMode: line.discountMode,
discountPct: line.discountPct,
discountAmount: line.discountAmount,
discountValue: 0,
taxPct: line.taxPct,
isFreeIssue: line.isFreeIssue,
parentLineId: line.parentLineId,
}))
)
})
.then(async () => {
try {
const suggestions = await salesApi.getFreeIssueSuggestions(slipId)
setPromotionSuggestion(suggestions)
} catch {
setPromotionSuggestion(null)
}
})
.catch((err) => setError(errorMessage(err)))
}, [resolvedParams.id, slipId])
useEffect(() => {
if (!slip || slip.status !== "Draft") {
setPostingCheck(null)
return
}
salesApi
.checkSlipPosting(slipId)
.then((result) => setPostingCheck(result))
.catch(() => setPostingCheck(null))
}, [slip, slipId])
const subtotal = useMemo(
() => lines.reduce((sum, line) => sum + Number(line.qty || 0) * Number(line.unitPrice ?? 0), 0),
[lines]
)
function updateLine(key: string, patch: Partial<Line>) {
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
}
function selectItem(key: string, itemId: number) {
updateLine(key, {
itemId,
unitPrice: getSuggestedUnitPrice(items, itemId),
})
}
function resolveLineUnitPrice(line: Line): number | null {
if (line.unitPrice !== null && line.unitPrice !== undefined) return Number(line.unitPrice)
return getSuggestedUnitPrice(items, line.itemId)
}
function addLine() {
setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)])
}
function removeLine(key: string) {
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
}
async function save() {
if (!customerId || !warehouseId || !cashierUserId || !etag) return
setSaving(true)
setError(null)
try {
const payload = {
customerId,
warehouseId,
cashierUserId,
lines: lines.map((line) => ({
itemId: Number(line.itemId),
warehouseId: Number(line.warehouseId),
qty: Number(line.qty),
freeQty: Number(line.freeQty),
unitPrice: resolveLineUnitPrice(line),
allowManualPriceOverride: line.allowManualPriceOverride,
discountMode: line.discountMode,
discountPct: Number(line.discountPct),
discountAmount: Number(line.discountAmount),
discountValue: Number(line.discountValue),
taxPct: Number(line.taxPct),
isFreeIssue: line.isFreeIssue,
parentLineId: line.parentLineId || null,
})),
}
const updated = await salesApi.updateSlip(slipId, payload, etag)
setSlip(updated.data)
setEtag(updated.etag)
toast.success("Slip saved", updated.data.slipNo)
} catch (err) {
setError(errorMessage(err))
} finally {
setSaving(false)
}
}
async function post() {
if (!postingCheck?.canPost) {
setError("Resolve stock shortages before posting this slip.")
return
}
setActionBusy("post")
setError(null)
try {
const posted = await salesApi.postSlip(slipId)
setSlip(posted)
toast.success("Slip posted", posted.slipNo)
} catch (err) {
setError(errorMessage(err))
} finally {
setActionBusy(null)
}
}
async function cancel() {
setActionBusy("cancel")
setError(null)
try {
const cancelled = await salesApi.cancelSlip(slipId)
setSlip(cancelled)
toast.success("Slip cancelled", cancelled.slipNo)
} catch (err) {
setError(errorMessage(err))
} finally {
setActionBusy(null)
}
}
if (error && !slip) return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
if (!slip) return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">{error ?? "Slip data is loading or unavailable."}</div>
const locked = slip.status !== "Draft"
const canPost = slip.status === "Draft" && (postingCheck?.canPost ?? true) && actionBusy === null
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Link href="/dashboard/sales/slips" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">Sales Slip</h1>
<p className="text-base text-muted-foreground">{slip.slipNo} · {slip.status}</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Link
href={`/print/sales/slips/${slip.salesSlipId}`}
target="_blank"
rel="noopener noreferrer"
className={cn(buttonVariants({ variant: "outline", size: "default" }))}
>
<Printer className="size-4" />
Print
</Link>
<Button variant="outline" onClick={save} disabled={saving || locked}><Save className="size-4" />{saving ? "Saving..." : "Save"}</Button>
<Button variant="outline" onClick={post} disabled={!canPost}><Send className="size-4" />{postingCheck && !postingCheck.canPost ? "Resolve shortages first" : actionBusy === "post" ? "Posting..." : "Post"}</Button>
<Button variant="destructive" onClick={cancel} disabled={actionBusy !== null || locked}><X className="size-4" />{actionBusy === "cancel" ? "Cancelling..." : "Cancel"}</Button>
</div>
</div>
{error && <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>}
<section className="bg-white">
<div className="border-b pb-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="text-xs font-semibold uppercase tracking-[0.35em] text-muted-foreground">Sales Slip</div>
<h2 className="mt-2 text-3xl font-semibold text-foreground">{slip.slipNo}</h2>
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>Status:</span>
<span className={cn("inline-flex rounded-full border px-2 py-0.5 text-xs font-medium", slip.status === "Draft" ? "border-amber-200 bg-amber-50 text-amber-800" : slip.status === "Posted" ? "border-emerald-200 bg-emerald-50 text-emerald-800" : "border-rose-200 bg-rose-50 text-rose-800")}>
{slip.status}
</span>
<span>Date: {new Date(slip.slipDate).toLocaleDateString()}</span>
<span>Cashier: {users.find((u) => u.userId === slip.cashierUserId)?.displayName ?? `#${slip.cashierUserId}`}</span>
</div>
</div>
<div className="text-sm md:text-right">
<div className="font-semibold text-foreground">{customers.find((c) => c.customerId === slip.customerId)?.displayName ?? customers.find((c) => c.customerId === slip.customerId)?.name ?? slip.customerSnapshotName}</div>
<div className="text-muted-foreground">Warehouse: {warehouses.find((w) => w.warehouseId === slip.warehouseId)?.name ?? `#${slip.warehouseId}`}</div>
<div className="text-muted-foreground">Code: {warehouses.find((w) => w.warehouseId === slip.warehouseId)?.code ?? slip.warehouseId}</div>
</div>
</div>
</div>
<div className="grid gap-6 py-5 md:grid-cols-3">
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Customer</div>
<div className="mt-2 font-semibold text-foreground">{slip.customerSnapshotName}</div>
<div className="text-sm text-muted-foreground">Customer ID: {slip.customerId}</div>
</div>
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Warehouse</div>
<div className="mt-2 font-semibold text-foreground">{warehouses.find((w) => w.warehouseId === slip.warehouseId)?.name ?? `#${slip.warehouseId}`}</div>
<div className="text-sm text-muted-foreground">Code: {warehouses.find((w) => w.warehouseId === slip.warehouseId)?.code ?? slip.warehouseId}</div>
</div>
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
<div className="text-muted-foreground">Subtotal</div>
<div className="text-right font-medium">{money.format(slip.totals.subtotal)}</div>
<div className="text-muted-foreground">Discount</div>
<div className="text-right font-medium">{money.format(slip.totals.discountTotal)}</div>
<div className="text-muted-foreground">Free qty</div>
<div className="text-right font-medium">{slip.totals.freeQtyTotal.toFixed(0)}</div>
<div className="text-muted-foreground">Grand total</div>
<div className="text-right font-semibold">{money.format(slip.totals.grandTotal)}</div>
</div>
</div>
</div>
<div className="overflow-x-auto border-y">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-4 text-sm">Item</TableHead>
<TableHead className="h-12 px-4 text-sm">UOM</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Qty</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Free</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Unit price</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Line total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{slip.lines.map((line) => (
<TableRow key={line.salesSlipLineId}>
<TableCell className="px-4 py-3">
<div className="font-medium text-foreground">{line.description}</div>
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
<div className="mt-1 text-[11px] text-muted-foreground">
{getSuggestedUnitPrice(items, line.itemId) !== null
? `Suggested: ${money.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
: "No price suggestion available"}
</div>
</TableCell>
<TableCell className="px-4 py-3">{baseUomLabel(items, uoms, line.itemId)}</TableCell>
<TableCell className="px-4 py-3 text-right">{line.qty.toFixed(0)}</TableCell>
<TableCell className="px-4 py-3 text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "-"}</TableCell>
<TableCell className="px-4 py-3 text-right">{money.format(line.unitPrice)}</TableCell>
<TableCell className="px-4 py-3 text-right font-medium">{money.format(line.lineTotal)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</section>
{slip.status === "Draft" && postingCheck && !postingCheck.canPost ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="font-semibold">Stock shortage detected before posting</div>
<div className="mt-1">This slip cannot be posted until every line has enough available stock in the selected warehouse.</div>
</div>
<Link
href="/dashboard/receiving/grn/new"
className="inline-flex h-9 items-center gap-2 self-start rounded-full border border-amber-300 bg-white px-4 text-sm font-medium text-amber-950 shadow-sm hover:bg-amber-100"
>
<ExternalLink className="size-4" />
Create GRN
</Link>
</div>
<div className="mt-3 overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left text-xs uppercase tracking-wide text-amber-900/70">
<tr>
<th className="py-1 pr-3">Item</th>
<th className="py-1 pr-3">Warehouse</th>
<th className="py-1 pr-3 text-right">Requested</th>
<th className="py-1 pr-3 text-right">Available</th>
<th className="py-1 text-right">Short</th>
</tr>
</thead>
<tbody>
{postingCheck.issues.map((issue) => (
<tr key={issue.salesSlipLineId} className="border-t border-amber-200/60">
<td className="py-2 pr-3">
<div className="font-medium">{issue.itemSku}</div>
<div className="text-xs text-amber-900/70">{issue.itemName}{issue.isFreeIssue ? " · free issue" : ""}</div>
</td>
<td className="py-2 pr-3">{issue.warehouseId}</td>
<td className="py-2 pr-3 text-right font-mono tabular-nums">{issue.requestedQty.toFixed(0)}</td>
<td className="py-2 pr-3 text-right font-mono tabular-nums">{issue.availableQty.toFixed(0)}</td>
<td className="py-2 text-right font-mono tabular-nums">{issue.shortQty.toFixed(0)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null}
{slip.status === "Draft" ? (
<>
<div className="grid gap-4 md:grid-cols-3">
<div className="flex flex-col gap-2">
<Label>Customer</Label>
<Select value={customerId ? String(customerId) : ""} onValueChange={(v) => setCustomerId(v ? Number(v) : null)} disabled={locked}>
<SelectTrigger className="h-12!"><SelectValue /></SelectTrigger>
<SelectContent>{customers.map((c) => <SelectItem key={c.customerId} value={String(c.customerId)}>{c.customerCode} - {c.displayName ?? c.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Warehouse</Label>
<Select value={warehouseId ? String(warehouseId) : ""} onValueChange={(v) => setWarehouseId(v ? Number(v) : null)} disabled={locked}>
<SelectTrigger className="h-12!"><SelectValue /></SelectTrigger>
<SelectContent>{warehouses.map((w) => <SelectItem key={w.warehouseId} value={String(w.warehouseId)}>{w.code} - {w.name}</SelectItem>)}</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Cashier</Label>
<Select value={cashierUserId ? String(cashierUserId) : ""} onValueChange={(v) => setCashierUserId(v ? Number(v) : null)} disabled={locked}>
<SelectTrigger className="h-12!"><SelectValue /></SelectTrigger>
<SelectContent>{users.map((u) => <SelectItem key={u.userId} value={String(u.userId)}>{u.displayName}</SelectItem>)}</SelectContent>
</Select>
</div>
</div>
<div className="rounded-2xl border p-4">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold">Lines</h2>
<Button type="button" variant="outline" onClick={addLine} disabled={locked}><Plus className="size-4" /> Add line</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>UOM</TableHead>
<TableHead>Qty</TableHead>
<TableHead>Free</TableHead>
<TableHead>Unit price</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line) => (
<TableRow key={line.key}>
<TableCell>
<Select value={line.itemId ? String(line.itemId) : ""} onValueChange={(v) => selectItem(line.key, Number(v))} disabled={locked}>
<SelectTrigger className="h-11!"><SelectValue placeholder="Item" /></SelectTrigger>
<SelectContent>{items.map((i) => <SelectItem key={i.itemId} value={String(i.itemId)}>{i.sku} - {i.name}</SelectItem>)}</SelectContent>
</Select>
</TableCell>
<TableCell className="text-sm text-muted-foreground">{baseUomLabel(items, uoms, line.itemId)}</TableCell>
<TableCell><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} disabled={locked} /></TableCell>
<TableCell><Input type="number" min="0" step="0.01" value={line.freeQty} onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })} disabled={locked} /></TableCell>
<TableCell><Input type="number" min="0" step="0.01" value={line.unitPrice ?? ""} onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })} disabled={locked} /></TableCell>
<TableCell><Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} disabled={locked}><Minus className="size-4" /></Button></TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="mt-4 text-sm text-muted-foreground">Subtotal: {subtotal.toFixed(2)}</div>
</div>
<FreeIssuePromotionSuggestions suggestion={promotionSuggestion} />
<div className="flex justify-end gap-3">
<Link href="/dashboard/sales/slips" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>Back</Link>
<Button variant="outline" onClick={save} disabled={saving || locked}>Save</Button>
<Button onClick={post} disabled={!canPost}>Post</Button>
</div>
</>
) : null}
</div>
)
}
@@ -1,435 +0,0 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import Link from "next/link"
import { ArrowLeft, Minus, Plus, Save, Trash2 } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
import { getSuggestedUnitPrice } from "@/lib/sales-line-utils"
import { errorMessage } from "@/lib/error-map"
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { warehousesApi } from "@/lib/api/warehouses"
import { usersApi } from "@/lib/api/users"
import { Customer } from "@/types/customers"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { ManagedUser } from "@/types/users"
import { CreateSalesSlipLineRequest, CreateSalesSlipRequest } from "@/types/sales"
import { toast } from "@/components/ui/toast"
type Line = CreateSalesSlipLineRequest & { key: string }
const blankLine = (key: string): Line => ({
key,
itemId: 0,
warehouseId: 0,
qty: 1,
freeQty: 0,
unitPrice: null,
allowManualPriceOverride: true,
discountMode: "Percentage",
discountPct: 0,
discountAmount: 0,
discountValue: 0,
taxPct: 0,
isFreeIssue: false,
parentLineId: null,
})
const lkr = new Intl.NumberFormat("en-LK", {
style: "currency",
currency: "LKR",
minimumFractionDigits: 2,
})
export default function NewSalesSlipPage() {
const router = useRouter()
const [customers, setCustomers] = useState<Customer[]>([])
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [users, setUsers] = useState<ManagedUser[]>([])
const [customerId, setCustomerId] = useState<number | null>(null)
const [warehouseId, setWarehouseId] = useState<number | null>(null)
const [cashierUserId, setCashierUserId] = useState<number | null>(null)
const [lines, setLines] = useState<Line[]>([blankLine("line-1")])
const [loading, setLoading] = useState(true)
const [submitError, setSubmitError] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
useEffect(() => {
Promise.all([
customersApi.list({ pageSize: 200 }),
itemsApi.list({ pageSize: 200 }),
uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
])
.then(([cust, itemRes, uomRes, whRes, userRes]) => {
setCustomers(cust.items)
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
setUsers(userRes.items)
setCustomerId(cust.items[0]?.customerId ?? null)
setWarehouseId(whRes.items[0]?.warehouseId ?? null)
setCashierUserId(userRes.items[0]?.userId ?? null)
setLines([
{
...blankLine("line-1"),
itemId: itemRes.items[0]?.itemId ?? 0,
warehouseId: whRes.items[0]?.warehouseId ?? 0,
unitPrice: getSuggestedUnitPrice(itemRes.items, itemRes.items[0]?.itemId),
},
])
})
.catch((err) => setSubmitError(errorMessage(err)))
.finally(() => setLoading(false))
}, [])
function updateLine(key: string, patch: Partial<Line>) {
setLines((prev) => prev.map((line) => (line.key === key ? { ...line, ...patch } : line)))
}
function selectItem(key: string, itemId: number) {
const item = items.find((candidate) => candidate.itemId === itemId)
updateLine(key, {
itemId,
unitPrice: getSuggestedUnitPrice(items, itemId),
})
}
function resolveLineUnitPrice(line: Line): number | null {
if (line.unitPrice !== null && line.unitPrice !== undefined) return Number(line.unitPrice)
return getSuggestedUnitPrice(items, line.itemId)
}
function addLine() {
setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)])
}
function removeLine(key: string) {
setLines((prev) => (prev.length === 1 ? prev : prev.filter((line) => line.key !== key)))
}
const grossTotal = useMemo(
() => lines.reduce((sum, line) => sum + Number(line.unitPrice ?? 0) * Number(line.qty || 0), 0),
[lines],
)
const discountTotal = useMemo(
() =>
lines.reduce((sum, line) => {
const gross = Number(line.unitPrice ?? 0) * Number(line.qty || 0)
const mode = String(line.discountMode)
return sum + (mode === "Amount" ? Number(line.discountAmount || 0) : gross * (Number(line.discountPct || 0) / 100))
}, 0),
[lines],
)
const taxTotal = useMemo(
() =>
lines.reduce((sum, line) => {
const gross = Number(line.unitPrice ?? 0) * Number(line.qty || 0)
const mode = String(line.discountMode)
const discount = mode === "Amount" ? Number(line.discountAmount || 0) : gross * (Number(line.discountPct || 0) / 100)
const taxable = Math.max(0, gross - discount)
return sum + taxable * (Number(line.taxPct || 0) / 100)
}, 0),
[lines],
)
const netTotal = Math.max(0, grossTotal - discountTotal)
const payableTotal = netTotal + taxTotal
async function submit() {
if (!customerId || !warehouseId || !cashierUserId) return setSubmitError("Select customer, warehouse, and cashier.")
if (lines.some((line) => Number(line.itemId) === 0)) return setSubmitError("Select an item for every line.")
if (lines.some((line) => Number(line.warehouseId) === 0)) return setSubmitError("Select a warehouse for every line.")
const payload: CreateSalesSlipRequest = {
customerId,
warehouseId,
cashierUserId,
lines: lines.map((line) => ({
itemId: Number(line.itemId),
warehouseId: Number(line.warehouseId),
qty: Number(line.qty),
freeQty: Number(line.freeQty),
unitPrice: resolveLineUnitPrice(line),
allowManualPriceOverride: line.allowManualPriceOverride,
discountMode: line.discountMode,
discountPct: Number(line.discountPct),
discountAmount: Number(line.discountAmount),
discountValue: Number(line.discountValue),
taxPct: Number(line.taxPct),
isFreeIssue: line.isFreeIssue,
parentLineId: line.parentLineId || null,
})),
}
setSaving(true)
setSubmitError(null)
try {
const created = await salesApi.createSlip(payload)
toast.success("Slip created", created.data.slipNo)
router.push(`/dashboard/sales/slips/${created.data.salesSlipId}`)
} catch (err) {
setSubmitError(errorMessage(err))
} finally {
setSaving(false)
}
}
if (loading) {
return <div className="rounded-2xl border border-border bg-card p-8 text-muted-foreground shadow-[var(--shadow-panel)]">Loading masters...</div>
}
return (
<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/sales/slips" className={cn(buttonVariants({ variant: "outline", size: "icon" }))}>
<ArrowLeft className="size-4" />
</Link>
<div>
<h1 className="text-2xl font-bold text-foreground">New sales slip</h1>
<p className="text-base text-muted-foreground">Create counter sales slips from the live backend.</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" onClick={addLine}>
<Plus className="size-4" /> Add line
</Button>
<Button size="sm" onClick={submit} disabled={saving}>
<Save className="size-4" /> {saving ? "Saving..." : "Save slip"}
</Button>
</div>
</div>
{submitError ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{submitError}</div> : null}
<section className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
<h2 className="text-sm font-semibold">Slip header</h2>
<div className="mt-3 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="space-y-1.5">
<Label className="text-xs">Customer</Label>
<Select value={customerId ? String(customerId) : ""} onValueChange={(v) => setCustomerId(v ? Number(v) : null)}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select customer" />
</SelectTrigger>
<SelectContent>
{customers.map((c) => (
<SelectItem key={c.customerId} value={String(c.customerId)}>
{c.customerCode} - {c.displayName ?? c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Warehouse</Label>
<Select value={warehouseId ? String(warehouseId) : ""} onValueChange={(v) => setWarehouseId(v ? Number(v) : null)}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select warehouse" />
</SelectTrigger>
<SelectContent>
{warehouses.map((w) => (
<SelectItem key={w.warehouseId} value={String(w.warehouseId)}>
{w.code} - {w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Cashier</Label>
<Select value={cashierUserId ? String(cashierUserId) : ""} onValueChange={(v) => setCashierUserId(v ? Number(v) : null)}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select cashier" />
</SelectTrigger>
<SelectContent>
{users.map((u) => (
<SelectItem key={u.userId} value={String(u.userId)}>
{u.displayName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Slip summary</Label>
<div className="flex h-9 items-center gap-2 rounded-md border border-border bg-secondary/40 px-3 text-xs text-muted-foreground">
<Badge variant="outline" className="border-emerald-200 bg-emerald-50 text-emerald-800">
Draft
</Badge>
<span>{lkr.format(payableTotal)}</span>
</div>
</div>
</div>
</section>
<section className="rounded-2xl border border-border bg-card shadow-[var(--shadow-panel)]">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<h2 className="text-sm font-semibold">Slip lines</h2>
<Button type="button" variant="outline" size="sm" onClick={addLine}>
<Plus className="size-4" /> Add line
</Button>
</div>
<div className="overflow-x-auto">
<Table className="text-sm">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-4 text-sm">#</TableHead>
<TableHead className="h-12 px-4 text-sm">Item</TableHead>
<TableHead className="h-12 px-4 text-sm">UOM</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Qty</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Free</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Unit price</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Discount %</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Line total</TableHead>
<TableHead className="h-12 px-4 text-sm" />
</TableRow>
</TableHeader>
<TableBody>
{lines.map((line, idx) => {
const lineGross = Number(line.unitPrice ?? 0) * Number(line.qty || 0)
const lineDiscount =
String(line.discountMode) === "Amount"
? Number(line.discountAmount || 0)
: lineGross * (Number(line.discountPct || 0) / 100)
const lineNet = Math.max(0, lineGross - lineDiscount)
return (
<TableRow key={line.key} className="align-middle">
<TableCell className="px-4 py-2 text-xs text-muted-foreground">{idx + 1}</TableCell>
<TableCell className="px-4 py-2 min-w-64">
<Select value={line.itemId ? String(line.itemId) : ""} onValueChange={(v) => selectItem(line.key, Number(v))}>
<SelectTrigger className="h-9 text-sm">
<SelectValue placeholder="Select item" />
</SelectTrigger>
<SelectContent>
{items.map((candidate) => (
<SelectItem key={candidate.itemId} value={String(candidate.itemId)}>
{candidate.sku} - {candidate.name}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="px-4 py-2 min-w-36 text-sm text-muted-foreground">
{baseUomLabel(items, uoms, line.itemId)}
</TableCell>
<TableCell className="px-4 py-2">
<Input
type="number"
min="0"
step="0.01"
value={line.qty}
onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })}
className="h-9 w-24 text-right font-mono text-sm tabular-nums"
/>
</TableCell>
<TableCell className="px-4 py-2">
<Input
type="number"
min="0"
step="0.01"
value={line.freeQty}
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) })}
className="h-9 w-24 text-right font-mono text-sm tabular-nums"
/>
</TableCell>
<TableCell className="px-4 py-2">
<Input
type="number"
min="0"
step="0.01"
value={line.unitPrice ?? ""}
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
className="h-9 w-28 text-right font-mono text-sm tabular-nums"
/>
<div className="mt-1 text-[11px] text-muted-foreground">
{getSuggestedUnitPrice(items, line.itemId) !== null
? `Suggested: ${lkr.format(getSuggestedUnitPrice(items, line.itemId) ?? 0)}`
: "No price suggestion available"}
</div>
</TableCell>
<TableCell className="px-4 py-2">
<Input
type="number"
min="0"
max="100"
step="0.5"
value={line.discountPct}
onChange={(e) => updateLine(line.key, { discountPct: Number(e.target.value) || 0 })}
className="h-9 w-24 text-right font-mono text-sm tabular-nums"
/>
</TableCell>
<TableCell className="px-4 py-2 text-right font-mono font-semibold tabular-nums">{lkr.format(lineNet)}</TableCell>
<TableCell className="px-4 py-2 text-right">
<Button
variant="ghost"
size="icon"
className="size-8 text-muted-foreground"
onClick={() => removeLine(line.key)}
disabled={lines.length === 1}
>
<Trash2 className="size-4" />
</Button>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</section>
<section className="grid gap-4 md:grid-cols-2">
<div className="rounded-2xl border border-border bg-card p-4 shadow-[var(--shadow-panel)]">
<h3 className="text-sm font-semibold">Slip notes</h3>
<ul className="mt-3 space-y-2 text-sm text-muted-foreground">
<li className="rounded-md border border-border px-3 py-2">Cashier posting follows the standard sales-slip workflow.</li>
<li className="rounded-md border border-border px-3 py-2">Free issue lines are captured from the slip itself, not from a separate register here.</li>
<li className="rounded-md border border-border px-3 py-2">Use the slip detail page after save to post or cancel.</li>
</ul>
</div>
<dl className="rounded-2xl border border-border bg-card p-4 text-sm shadow-[var(--shadow-panel)]">
{[
["Gross", lkr.format(grossTotal)],
["Discount", `-${lkr.format(discountTotal)}`],
["Net", lkr.format(netTotal)],
["Tax", lkr.format(taxTotal)],
].map(([k, v]) => (
<div key={k} className="flex items-center justify-between py-1.5">
<dt className="text-muted-foreground">{k}</dt>
<dd className="font-mono tabular-nums">{v}</dd>
</div>
))}
<div className="mt-2 flex items-center justify-between border-t border-border pt-3">
<dt className="font-semibold">Payable</dt>
<dd className="font-mono text-base font-semibold tabular-nums">{lkr.format(payableTotal)}</dd>
</div>
</dl>
</section>
<div className="flex justify-end gap-3">
<Link href="/dashboard/sales/slips" className={cn(buttonVariants({ variant: "outline", size: "lg" }))}>
Cancel
</Link>
<Button size="lg" onClick={submit} disabled={saving}>
{saving ? "Saving..." : "Create slip"}
</Button>
</div>
</div>
)
}
@@ -1,235 +0,0 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { ChevronLeft, ChevronRight, Eye, Filter, Package2, Plus, Printer } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { errorMessage } from "@/lib/error-map"
import { Button, buttonVariants } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
import { PaginationMeta } from "@/types/common"
import { SalesSlipStatus, SalesSlipSummary } from "@/types/sales"
type StatusFilter = SalesSlipStatus | "All"
const PAGE_SIZE = 10
const tabs = ["All", "Draft", "Posted", "Cancelled"] as const
function statusClass(status: SalesSlipStatus) {
switch (status) {
case "Draft":
return "border-amber-200 bg-amber-50 text-amber-800"
case "Posted":
return "border-emerald-200 bg-emerald-50 text-emerald-800"
case "Cancelled":
return "border-rose-200 bg-rose-50 text-rose-800"
}
}
export default function SalesSlipsPage() {
const [rows, setRows] = useState<SalesSlipSummary[] | null>(null)
const [pagination, setPagination] = useState<PaginationMeta | null>(null)
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<StatusFilter>("All")
const [searchInput, setSearchInput] = useState("")
const [query, setQuery] = useState("")
const [page, setPage] = useState(1)
useEffect(() => {
const timeout = setTimeout(() => setQuery(searchInput.trim()), 300)
return () => clearTimeout(timeout)
}, [searchInput])
useEffect(() => setPage(1), [query, status])
useEffect(() => {
setError(null)
salesApi
.listSlips({ page, pageSize: PAGE_SIZE, status: status === "All" ? undefined : status })
.then((res) => {
setRows(res.items)
setPagination(res.pagination)
})
.catch((err) => setError(errorMessage(err)))
}, [page, status])
const visibleRows = useMemo(
() =>
rows?.filter((row) =>
`${row.slipNo} ${row.customerSnapshotName}`.toLowerCase().includes(query.toLowerCase())
) ?? [],
[rows, query]
)
const hasFilters = status !== "All" || query.length > 0
const grossTotal = visibleRows.reduce((sum, row) => sum + row.totals.subtotal, 0)
const discountTotal = visibleRows.reduce((sum, row) => sum + row.totals.discountTotal, 0)
const netTotal = visibleRows.reduce((sum, row) => sum + row.totals.grandTotal, 0)
const printHref = `/print/sales/slips?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}`
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-foreground">Sales Slips</h1>
<p className="text-base text-muted-foreground">Counter sales register with posting and cancellation flow.</p>
</div>
<div className="flex flex-wrap gap-2">
<Link
href={printHref}
target="_blank"
rel="noopener noreferrer"
className={cn(buttonVariants({ variant: "outline", size: "lg" }))}
>
<Printer className="size-5" />
Print batch
</Link>
<Link href="/dashboard/sales/slips/new" className={cn(buttonVariants({ size: "lg" }))}>
<Plus className="size-5" />
New Slip
</Link>
</div>
</div>
<div className="rounded-2xl border bg-card shadow-sm">
<div className="flex flex-col gap-3 border-b px-4 py-4 lg:flex-row lg:items-center">
<div className="flex flex-wrap gap-2">
{tabs.map((t) => (
<button
key={t}
onClick={() => setStatus(t)}
className={cn(
"rounded-full border px-3 py-1.5 text-xs font-medium transition-colors",
status === t ? "border-primary bg-primary text-primary-foreground" : "border-border text-muted-foreground hover:text-foreground"
)}
>
{t}
</button>
))}
</div>
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center lg:justify-end">
<Input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Filter by customer or slip number"
className="h-12 w-full lg:max-w-sm"
/>
<Button variant="outline" size="sm" className="lg:ml-auto">
<Filter className="size-4" />
Advanced
</Button>
</div>
</div>
{error && <div className="mx-4 mt-4 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 px-4 py-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{!error && rows !== null && visibleRows.length === 0 && (
<div className="flex flex-col items-center gap-4 px-4 py-20 text-center">
<Package2 className="size-12 text-muted-foreground" />
<p className="text-base text-muted-foreground">{hasFilters ? "No slips match your filters." : "No slips yet."}</p>
</div>
)}
{!error && rows !== null && visibleRows.length > 0 && (
<>
<div className="overflow-x-auto">
<Table className="text-base">
<TableHeader>
<TableRow>
<TableHead className="h-12 px-4 text-sm">Slip</TableHead>
<TableHead className="h-12 px-4 text-sm">Customer</TableHead>
<TableHead className="h-12 px-4 text-sm">Date</TableHead>
<TableHead className="h-12 px-4 text-sm">Status</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Lines</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Gross</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Discount</TableHead>
<TableHead className="h-12 px-4 text-sm text-right">Net</TableHead>
<TableHead className="h-12 px-4 text-sm">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleRows.map((row) => (
<TableRow key={row.salesSlipId} className="hover:bg-muted/40">
<TableCell className="px-4 py-3.5 font-medium">
<Link href={`/dashboard/sales/slips/${row.salesSlipId}`} className="hover:underline">
{row.slipNo}
</Link>
</TableCell>
<TableCell className="px-4 py-3.5">{row.customerSnapshotName}</TableCell>
<TableCell className="px-4 py-3.5 text-muted-foreground">{new Date(row.createdAt).toLocaleDateString()}</TableCell>
<TableCell className="px-4 py-3.5">
<Badge variant="outline" className={statusClass(row.status)}>
{row.status}
</Badge>
</TableCell>
<TableCell className="px-4 py-3.5 text-right font-mono tabular-nums">{row.totals.freeQtyTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right font-mono tabular-nums">{row.totals.subtotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right font-mono tabular-nums text-muted-foreground">-{row.totals.discountTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5 text-right font-mono font-semibold tabular-nums">{row.totals.grandTotal.toFixed(2)}</TableCell>
<TableCell className="px-4 py-3.5">
<div className="flex justify-end">
<Link href={`/dashboard/sales/slips/${row.salesSlipId}`} className="inline-flex h-8 w-8 items-center justify-center rounded-full border border-border text-muted-foreground hover:bg-muted hover:text-foreground" aria-label={`View slip ${row.slipNo}`}>
<Eye className="size-4" />
</Link>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="border-t px-4 py-3">
<div className="grid gap-3 text-sm md:grid-cols-3">
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="text-muted-foreground">Gross total</div>
<div className="font-mono text-base font-semibold tabular-nums">{grossTotal.toFixed(2)}</div>
</div>
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="text-muted-foreground">Discount total</div>
<div className="font-mono text-base font-semibold tabular-nums">-{discountTotal.toFixed(2)}</div>
</div>
<div className="rounded-lg border bg-muted/20 px-3 py-2">
<div className="text-muted-foreground">Net total</div>
<div className="font-mono text-base font-semibold tabular-nums">{netTotal.toFixed(2)}</div>
</div>
</div>
</div>
{pagination && pagination.totalPages > 1 && (
<div className="flex flex-col gap-3 border-t px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
Showing {(pagination.page - 1) * pagination.pageSize + 1}-{Math.min(pagination.page * pagination.pageSize, pagination.totalItems)} of {pagination.totalItems}
</p>
<div className="flex items-center gap-2">
<Button type="button" variant="outline" size="sm" disabled={pagination.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>
<ChevronLeft />
Previous
</Button>
<span className="text-sm text-muted-foreground">Page {pagination.page} of {pagination.totalPages}</span>
<Button type="button" variant="outline" size="sm" disabled={pagination.page >= pagination.totalPages} onClick={() => setPage((p) => Math.min(pagination.totalPages, p + 1))}>
Next
<ChevronRight />
</Button>
</div>
</div>
)}
</>
)}
</div>
</div>
)
}
+1 -1
View File
@@ -80,7 +80,7 @@ function LoginForm() {
<main className="relative z-10 flex min-h-screen w-full flex-col justify-center bg-white dark:bg-background px-6 py-12 sm:px-12 md:w-[45%]">
<div className="mb-8 flex justify-center">
<Link href="/" className="inline-flex items-center">
<Image src="/images/logo%20(2).png" alt="Hexa ERP" width={300} height={150} />
<Image src="/images/hexa-erp-logo.png" alt="Hexa ERP" width={300} height={150} />
</Link>
</div>
@@ -1,145 +0,0 @@
"use client"
import { use, useEffect, useMemo, useState } from "react"
import { Printer } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { customersApi } from "@/lib/api/customers"
import { itemsApi } from "@/lib/api/items"
import { uomsApi } from "@/lib/api/uoms"
import { baseUomLabel } from "@/lib/uom-label"
import { warehousesApi } from "@/lib/api/warehouses"
import { usersApi } from "@/lib/api/users"
import { errorMessage } from "@/lib/error-map"
import { Button } from "@/components/ui/button"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Customer } from "@/types/customers"
import { ItemListItem, Uom, Warehouse } from "@/types/master-data"
import { ManagedUser } from "@/types/users"
import { SalesSlip } from "@/types/sales"
export default function SalesSlipPrintPage({ params }: { params: Promise<{ id: string }> }) {
const resolvedParams = use(params)
const slipId = Number(resolvedParams.id)
const [slip, setSlip] = useState<SalesSlip | null>(null)
const [customers, setCustomers] = useState<Customer[]>([])
const [items, setItems] = useState<ItemListItem[]>([])
const [uoms, setUoms] = useState<Uom[]>([])
const [warehouses, setWarehouses] = useState<Warehouse[]>([])
const [users, setUsers] = useState<ManagedUser[]>([])
const [error, setError] = useState<string | null>(null)
useEffect(() => {
if (!Number.isFinite(slipId)) {
setError(`Invalid slip id '${resolvedParams.id}'.`)
return
}
Promise.all([
customersApi.list({ pageSize: 200 }),
itemsApi.list({ pageSize: 200 }),
uomsApi.list({ pageSize: 200 }),
warehousesApi.list({ pageSize: 200 }),
usersApi.list({ pageSize: 200 }),
salesApi.getSlip(slipId),
])
.then(([cust, itemRes, uomRes, whRes, userRes, doc]) => {
setCustomers(cust.items)
setItems(itemRes.items)
setUoms(uomRes.items)
setWarehouses(whRes.items)
setUsers(userRes.items)
setSlip(doc.data)
})
.catch((err) => setError(errorMessage(err)))
}, [resolvedParams.id, slipId])
const subtotal = useMemo(() => slip?.totals.subtotal ?? 0, [slip])
if (error && !slip) {
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
}
if (!slip) {
return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">{error ?? "Slip print data is loading or unavailable."}</div>
}
const customer = customers.find((c) => c.customerId === slip.customerId)
const warehouse = warehouses.find((w) => w.warehouseId === slip.warehouseId)
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 rounded-3xl border bg-white p-6 shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none">
<div className="flex items-center justify-end print:hidden">
<Button variant="outline" onClick={() => window.print()}>
<Printer className="size-4" />
Print
</Button>
</div>
<div className="border-b pb-5">
<div className="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">Sales Slip</div>
<h1 className="mt-2 text-3xl font-semibold text-foreground">{slip.slipNo}</h1>
<div className="mt-2 text-sm text-muted-foreground">Status: {slip.status} · Date: {new Date(slip.slipDate).toLocaleDateString()}</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-2xl border bg-muted/20 p-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Customer</div>
<div className="mt-2 text-lg font-semibold text-foreground">{slip.customerSnapshotName}</div>
<div className="mt-1 text-sm text-muted-foreground">Customer ID: {slip.customerId}</div>
{customer?.displayName ? <div className="mt-1 text-sm text-muted-foreground">Customer: {customer.displayName}</div> : null}
</div>
<div className="rounded-2xl border bg-muted/20 p-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Warehouse</div>
<div className="mt-2 text-lg font-semibold text-foreground">{warehouse?.name ?? `#${slip.warehouseId}`}</div>
<div className="mt-1 text-sm text-muted-foreground">Code: {warehouse?.code ?? slip.warehouseId}</div>
</div>
<div className="rounded-2xl border bg-muted/20 p-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Totals</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
<div className="text-muted-foreground">Subtotal</div><div className="text-right font-medium">{slip.totals.subtotal.toFixed(2)}</div>
<div className="text-muted-foreground">Discount</div><div className="text-right font-medium">{slip.totals.discountTotal.toFixed(2)}</div>
<div className="text-muted-foreground">Free qty</div><div className="text-right font-medium">{slip.totals.freeQtyTotal.toFixed(2)}</div>
<div className="text-muted-foreground">Net total</div><div className="text-right font-semibold">{slip.totals.grandTotal.toFixed(2)}</div>
</div>
</div>
</div>
<div className="overflow-hidden rounded-2xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead>UOM</TableHead>
<TableHead className="text-right">Qty</TableHead>
<TableHead className="text-right">Free</TableHead>
<TableHead className="text-right">Unit price</TableHead>
<TableHead className="text-right">Line total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{slip.lines.map((line) => (
<TableRow key={line.salesSlipLineId}>
<TableCell>
<div className="font-medium text-foreground">{line.description}</div>
<div className="text-xs text-muted-foreground">SKU {items.find((i) => i.itemId === line.itemId)?.sku ?? `#${line.itemId}`}</div>
</TableCell>
<TableCell>{baseUomLabel(items, uoms, line.itemId)}</TableCell>
<TableCell className="text-right">{line.qty.toFixed(0)}</TableCell>
<TableCell className="text-right">{line.freeQty > 0 ? line.freeQty.toFixed(0) : "—"}</TableCell>
<TableCell className="text-right">{line.unitPrice.toFixed(2)}</TableCell>
<TableCell className="text-right font-medium">{line.lineTotal.toFixed(2)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="rounded-2xl border bg-muted/20 p-4">
<div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Cashier</div>
<div className="mt-2 text-sm text-foreground">{users.find((u) => u.userId === slip.cashierUserId)?.displayName ?? `#${slip.cashierUserId}`}</div>
<div className="mt-3 text-sm text-muted-foreground">Subtotal: {subtotal.toFixed(2)}</div>
</div>
</div>
)
}
@@ -1,114 +0,0 @@
"use client"
import { Suspense, useEffect, useState } from "react"
import { useSearchParams } from "next/navigation"
import { Printer } from "lucide-react"
import { salesApi } from "@/lib/api/sales"
import { errorMessage } from "@/lib/error-map"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Skeleton } from "@/components/ui/skeleton"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { SalesSlipSummary } from "@/types/sales"
function statusClass(status: SalesSlipSummary["status"]) {
switch (status) {
case "Draft":
return "border-amber-200 bg-amber-50 text-amber-800"
case "Posted":
return "border-emerald-200 bg-emerald-50 text-emerald-800"
case "Cancelled":
return "border-rose-200 bg-rose-50 text-rose-800"
}
}
function SalesSlipBatchPrintContent() {
const searchParams = useSearchParams()
const status = searchParams.get("status") ?? "All"
const query = searchParams.get("q") ?? ""
const [rows, setRows] = useState<SalesSlipSummary[] | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
salesApi
.listSlips({ page: 1, pageSize: 200 })
.then((res) => setRows(res.items))
.catch((err) => setError(errorMessage(err)))
}, [])
const visibleRows = rows?.filter((row) => {
const matchesStatus = status === "All" || row.status === status
const matchesQuery =
`${row.slipNo} ${row.customerSnapshotName}`.toLowerCase().includes(query.toLowerCase())
return matchesStatus && matchesQuery
})
if (error && !rows) {
return <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div>
}
if (!rows) {
return <div className="rounded-2xl border border-dashed p-8 text-muted-foreground">{error ?? "Slip batch print data is loading or unavailable."}</div>
}
return (
<div className="mx-auto flex w-full max-w-6xl flex-col gap-6 rounded-3xl border bg-white p-6 shadow-sm print:rounded-none print:border-0 print:p-0 print:shadow-none">
<div className="flex items-center justify-end print:hidden">
<Button variant="outline" onClick={() => window.print()}>
<Printer className="size-4" />
Print
</Button>
</div>
<div className="border-b pb-4">
<div className="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">Sales Slips</div>
<h1 className="mt-2 text-3xl font-semibold text-foreground">Slip Batch Print</h1>
<p className="mt-2 text-sm text-muted-foreground">Printed register snapshot of current slips.</p>
</div>
<div className="overflow-hidden rounded-2xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Slip</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Date</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Lines</TableHead>
<TableHead className="text-right">Gross</TableHead>
<TableHead className="text-right">Discount</TableHead>
<TableHead className="text-right">Net</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visibleRows?.map((row) => (
<TableRow key={row.salesSlipId}>
<TableCell className="font-medium">{row.slipNo}</TableCell>
<TableCell>{row.customerSnapshotName}</TableCell>
<TableCell>{new Date(row.createdAt).toLocaleDateString()}</TableCell>
<TableCell>
<Badge variant="outline" className={statusClass(row.status)}>
{row.status}
</Badge>
</TableCell>
<TableCell className="text-right">{row.totals.freeQtyTotal.toFixed(2)}</TableCell>
<TableCell className="text-right">{row.totals.subtotal.toFixed(2)}</TableCell>
<TableCell className="text-right">-{row.totals.discountTotal.toFixed(2)}</TableCell>
<TableCell className="text-right font-medium">{row.totals.grandTotal.toFixed(2)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}
export default function SalesSlipBatchPrintPage() {
return (
<Suspense fallback={<Skeleton className="h-48 w-full" />}>
<SalesSlipBatchPrintContent />
</Suspense>
)
}
@@ -1,6 +1,7 @@
"use client"
import { useEffect, useState } from "react"
import Image from "next/image"
import Link from "next/link"
import { usePathname } from "next/navigation"
import {
@@ -114,15 +115,14 @@ const navItems: {
icon: ReceiptText,
chevron: true,
children: [
{ title: "Invoices", code: "sales.invoices", href: "/dashboard/sales/invoices", icon: FileText },
{ title: "Slips", code: "sales.slips", href: "/dashboard/sales/slips", icon: ShoppingCart },
{ title: "Sales", code: "sales.invoices", href: "/dashboard/sales/invoices", icon: FileText },
{ title: "Bundle Sales", code: "sales.bundle-sales", href: "/dashboard/sales/bundles", icon: Boxes },
{ title: "Free Issues", code: "sales.free-issues", href: "/dashboard/sales/free-issues", icon: PackageX },
// { title: "Reports", code: "sales.reports", href: "/dashboard/sales/reports", icon: FileBarChart },
],
},
{ title: "Sales Returns", code: "sales-returns", href: "/dashboard/sales/sales-returns", icon: Undo2 },
{ title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
{ title: "GRN", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
{
title: "Stock",
code: "stock",
@@ -144,7 +144,6 @@ const navItems: {
],
},
{ title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true },
{ title: "Orders", code: "orders", href: "/dashboard/orders", icon: ShoppingCart, chevron: true },
{
title: "Production",
code: "production",
@@ -155,6 +154,7 @@ const navItems: {
children: [
{ title: "Templates", code: "production.templates", href: "/dashboard/production/templates", icon: LayoutTemplate },
{ title: "Runs", code: "production.runs", href: "/dashboard/production/runs", icon: PlayCircle },
{ title: "Produced", code: "production.produced", href: "/dashboard/production/produced", icon: PackageCheck },
],
},
{
@@ -275,13 +275,14 @@ function SidebarContent({
)}
>
<Link href="/dashboard" className="flex items-center gap-2.5" onClick={onClose}>
<div className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-primary/10">
<svg viewBox="0 0 48 32" className="h-3.5 w-5 fill-primary">
<path d="M24 16c-3-8-11-12-16-8s-3 14 6 14c5 0 8.5-2.5 10-6 1.5 3.5 5 6 10 6 9 0 11-10 6-14s-13 0-16 8Z" />
</svg>
</div>
{!iconOnly && (
<span className="text-lg font-bold tracking-tight text-foreground">Hexa ERP</span>
{iconOnly ? (
<div className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-primary/10">
<svg viewBox="0 0 48 32" className="h-3.5 w-5 fill-primary">
<path d="M24 16c-3-8-11-12-16-8s-3 14 6 14c5 0 8.5-2.5 10-6 1.5 3.5 5 6 10 6 9 0 11-10 6-14s-13 0-16 8Z" />
</svg>
</div>
) : (
<Image src="/images/hexa-erp-logo.png" alt="Hexa ERP" width={165} height={40} className="h-10 w-auto" priority />
)}
</Link>
@@ -407,13 +408,11 @@ function SidebarContent({
})}
</ul>
<div className="flex items-center justify-center pt-6">
<div className="flex size-12 items-center justify-center rounded-2xl bg-muted ring-1 ring-foreground/10">
<svg viewBox="0 0 48 32" className="h-4 w-6 fill-muted-foreground">
<path d="M24 16c-3-8-11-12-16-8s-3 14 6 14c5 0 8.5-2.5 10-6 1.5 3.5 5 6 10 6 9 0 11-10 6-14s-13 0-16 8Z" />
</svg>
</div>
</div>
{!iconOnly && (
<p className="pt-6 text-center text-xs text-muted-foreground">
© {new Date().getFullYear()} Hexa ERP
</p>
)}
</nav>
)
}
@@ -38,6 +38,7 @@ const SEGMENT_LABELS: Record<string, string> = {
production: "Production",
templates: "Templates",
runs: "Runs",
invoices: "Sales",
}
function labelFor(segment: string): string {
@@ -79,6 +79,9 @@ const STOCK_TITLES: Record<string, string> = {
}
function titleFromPath(pathname: string) {
if (pathname === "/dashboard/sales/invoices") return "Sales"
if (pathname === "/dashboard/sales/invoices/new") return "New Sale"
if (pathname === "/dashboard/receiving/grn") return "Goods Receipt Notes"
if (pathname === "/dashboard/receiving/grn/new") return "Create Goods Receipt Note"
if (/^\/dashboard\/receiving\/grn\/[^/]+$/.test(pathname)) return "Goods Receipt Note"
@@ -1,42 +0,0 @@
"use client"
import Link from "next/link"
import { buttonVariants } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { SalesSlipSummary } from "@/types/sales"
export function FreeIssueSuggestions({ rows }: { rows: SalesSlipSummary[] }) {
if (rows.length === 0) return null
return (
<div className="rounded-2xl border p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold text-foreground">Free issue suggestions</h2>
<p className="text-sm text-muted-foreground">Recent free-issue slips created from the free-issues page.</p>
</div>
<Link href="/dashboard/sales/free-issues" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
Open free issues
</Link>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{rows.map((row) => (
<Link
key={row.salesSlipId}
href={`/dashboard/sales/free-issues/${row.salesSlipId}`}
className="rounded-xl border bg-muted/20 p-3 transition-colors hover:bg-muted/40"
>
<div className="flex items-center justify-between gap-2">
<div className="font-medium text-foreground">{row.slipNo}</div>
<div className="text-xs uppercase tracking-wide text-muted-foreground">{row.status}</div>
</div>
<div className="mt-1 text-sm text-muted-foreground">{row.customerSnapshotName}</div>
<div className="mt-2 text-sm text-foreground">Free qty total: {row.totals.freeQtyTotal.toFixed(2)}</div>
</Link>
))}
</div>
</div>
)
}
@@ -18,6 +18,7 @@ import {
CancelRunResult,
CompleteStageRequest,
CreateRunRequest,
ProducedListResponse,
ProductionRunGraph,
ProductionRunStatus,
ProductionRunSummary,
@@ -43,11 +44,24 @@ export interface ListRunsParams {
sort?: string
}
export interface ListProducedParams {
from?: string
to?: string
templateId?: number
warehouseId?: number
status?: ProductionRunStatus
}
export const productionRunsApi = {
list(params: ListRunsParams = {}): Promise<PagedResponse<ProductionRunSummary>> {
return apiRequest<PagedResponse<ProductionRunSummary>>(`/production-runs${buildQuery(params)}`)
},
/** Produced-items report (FR-MFG-13): flat rows + a summary total, no pagination. */
listProduced(params: ListProducedParams = {}): Promise<ProducedListResponse> {
return apiRequest<ProducedListResponse>(`/production-runs/produced${buildQuery(params)}`)
},
get(runId: number): Promise<ApiResult<ProductionRunGraph>> {
return apiRequestWithETag<ProductionRunGraph>(`/production-runs/${runId}`)
},

Before

Width:  |  Height:  |  Size: 829 KiB

After

Width:  |  Height:  |  Size: 829 KiB

@@ -112,6 +112,8 @@ export interface GeneralLedgerRow {
debitAmount: number
creditAmount: number
runningBalance: number
/** Which module posted this journal (GRN, Sales, ManualLedgerEntry, Acc_Account, ...) — shown as the "Controller" column. */
sourceModule: string | null
}
/** One line inside a Profit & Loss section — inferred shape (see the ProfitAndLossResponse note). */
+42
View File
@@ -306,6 +306,46 @@ export interface ProductionRunSummary {
completedAt: string | null
}
/**
* One row of the produced-items report (`GET /production-runs/produced`) — the terminal
* output of a run that has produced something. `unitCost`/`totalCost` use the same
* `pool / goodQty` formula the server applies at approve (FR-MFG-13); they are final only
* once `status` is `"Completed"` — a leftover return can still shrink the pool between the
* terminal stage's Done and its Approve.
*/
export interface ProducedLine {
runId: number
docNo: string
status: ProductionRunStatus
templateId: number
templateName: string
warehouseId: number
warehouseName: string
itemId: number
itemName: string
producedQty: number
scrappedQty: number
goodQty: number
unitCost: number
totalCost: number
createdAt: string
completedAt: string | null
}
/** Aggregate footer over every row the filter matched (no pagination). */
export interface ProducedSummary {
runCount: number
totalProducedQty: number
totalScrappedQty: number
totalGoodQty: number
totalCost: number
}
export interface ProducedListResponse {
items: ProducedLine[]
summary: ProducedSummary
}
export interface ProductionRunGraph {
runId: number
docNo: string
@@ -338,6 +378,8 @@ export interface CreateRunRequest {
export interface StageQuantityLine {
id: number
plannedQty: number
/** Required only when this lowers a running Stock input below what it has already consumed. */
reasonCodeId?: number | null
}
export interface UpdateStageQuantitiesRequest {