Compare commits
5 Commits
1f10f233dc
...
c4e016c460
| Author | SHA1 | Date | |
|---|---|---|---|
| c4e016c460 | |||
| 7e8418685c | |||
| cbc72ef830 | |||
| 4324ba1a96 | |||
| 1af16d3dec |
@@ -33,10 +33,11 @@ public sealed class BundleSalesController : ApiControllerBase
|
|||||||
[ProducesResponseType(typeof(PagedResponse<BundleSaleSummaryDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(PagedResponse<BundleSaleSummaryDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<ActionResult<PagedResponse<BundleSaleSummaryDto>>> List(
|
public async Task<ActionResult<PagedResponse<BundleSaleSummaryDto>>> List(
|
||||||
[FromQuery] PageQuery query,
|
[FromQuery] PageQuery query,
|
||||||
|
[FromQuery] BundleSaleStatus? status,
|
||||||
[FromQuery] int? customerId,
|
[FromQuery] int? customerId,
|
||||||
[FromQuery] int? warehouseId,
|
[FromQuery] int? warehouseId,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
=> Ok(await _bundles.ListAsync(query, customerId, warehouseId, ct));
|
=> Ok(await _bundles.ListAsync(query, status, customerId, warehouseId, ct));
|
||||||
|
|
||||||
[HttpGet("{bundleSaleId:int}")]
|
[HttpGet("{bundleSaleId:int}")]
|
||||||
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(BundleSaleDto), StatusCodes.Status200OK)]
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
|||||||
private readonly IRepository<Warehouse> _warehouses;
|
private readonly IRepository<Warehouse> _warehouses;
|
||||||
private readonly IRepository<User> _users;
|
private readonly IRepository<User> _users;
|
||||||
private readonly ISalesDomainService _sales;
|
private readonly ISalesDomainService _sales;
|
||||||
|
private readonly IUomConverter _uomConverter;
|
||||||
private readonly ISalesPostingService _posting;
|
private readonly ISalesPostingService _posting;
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
private readonly INumberSequenceService _numbers;
|
private readonly INumberSequenceService _numbers;
|
||||||
@@ -37,6 +38,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
|||||||
IRepository<Warehouse> warehouses,
|
IRepository<Warehouse> warehouses,
|
||||||
IRepository<User> users,
|
IRepository<User> users,
|
||||||
ISalesDomainService sales,
|
ISalesDomainService sales,
|
||||||
|
IUomConverter uomConverter,
|
||||||
ISalesPostingService posting,
|
ISalesPostingService posting,
|
||||||
ICurrentUser currentUser,
|
ICurrentUser currentUser,
|
||||||
INumberSequenceService numbers,
|
INumberSequenceService numbers,
|
||||||
@@ -50,6 +52,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
|||||||
_warehouses = warehouses;
|
_warehouses = warehouses;
|
||||||
_users = users;
|
_users = users;
|
||||||
_sales = sales;
|
_sales = sales;
|
||||||
|
_uomConverter = uomConverter;
|
||||||
_posting = posting;
|
_posting = posting;
|
||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
_numbers = numbers;
|
_numbers = numbers;
|
||||||
@@ -85,7 +88,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
|||||||
x.BundleSaleTemplateLineId, x.ItemId, x.UomId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList());
|
x.BundleSaleTemplateLineId, x.ItemId, x.UomId, x.WarehouseId, x.Qty, x.UnitPrice, x.IncludeInBundle, x.SortOrder)).ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, int? customerId, int? warehouseId, CancellationToken ct = default)
|
public async Task<PagedResponse<BundleSaleSummaryDto>> ListAsync(PageQuery query, BundleSaleStatus? status, int? customerId, int? warehouseId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
IQueryable<BundleSale> q = _bundles.Query().AsNoTracking().Include(x => x.Lines);
|
IQueryable<BundleSale> q = _bundles.Query().AsNoTracking().Include(x => x.Lines);
|
||||||
if (!string.IsNullOrWhiteSpace(query.Q))
|
if (!string.IsNullOrWhiteSpace(query.Q))
|
||||||
@@ -93,6 +96,7 @@ public sealed class BundleSaleService : IBundleSaleService
|
|||||||
var term = query.Q.Trim();
|
var term = query.Q.Trim();
|
||||||
q = q.Where(x => EF.Functions.ILike(x.BundleNo, $"%{term}%") || EF.Functions.ILike(x.BundleName, $"%{term}%") || EF.Functions.ILike(x.BundleCode, $"%{term}%"));
|
q = q.Where(x => EF.Functions.ILike(x.BundleNo, $"%{term}%") || EF.Functions.ILike(x.BundleName, $"%{term}%") || EF.Functions.ILike(x.BundleCode, $"%{term}%"));
|
||||||
}
|
}
|
||||||
|
if (status is not null) q = q.Where(x => x.Status == status);
|
||||||
if (customerId is not null) q = q.Where(x => x.CustomerId == customerId);
|
if (customerId is not null) q = q.Where(x => x.CustomerId == customerId);
|
||||||
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
|
if (warehouseId is not null) q = q.Where(x => x.WarehouseId == warehouseId);
|
||||||
var total = await q.CountAsync(ct);
|
var total = await q.CountAsync(ct);
|
||||||
@@ -204,21 +208,23 @@ public sealed class BundleSaleService : IBundleSaleService
|
|||||||
{
|
{
|
||||||
if (r.Qty <= 0)
|
if (r.Qty <= 0)
|
||||||
throw new DomainException(ErrorCodes.Validation, "Bundle line quantity must be greater than zero.", 422);
|
throw new DomainException(ErrorCodes.Validation, "Bundle line quantity must be greater than zero.", 422);
|
||||||
if (r.WarehouseId != warehouseId)
|
|
||||||
throw new DomainException(ErrorCodes.Validation,
|
// Bundle sales use the header warehouse as the source of truth for stock and pricing.
|
||||||
$"Bundle line warehouse {r.WarehouseId} must match header warehouse {warehouseId}.", 422);
|
// Keep any per-line warehouse input from drifting away from the header.
|
||||||
|
var lineWarehouseId = warehouseId;
|
||||||
|
|
||||||
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
|
var item = await _items.Query().AsNoTracking().FirstAsync(x => x.ItemId == r.ItemId, ct);
|
||||||
await _sales.ValidateSalesLineAsync(warehouseId, r.ItemId, r.UomId, r.WarehouseId, r.Qty, 0m, null, ct);
|
await _sales.ValidateSalesLineAsync(warehouseId, r.ItemId, r.UomId, lineWarehouseId, r.Qty, 0m, null, ct);
|
||||||
var resolved = await _sales.ResolveLinePriceAsync(r.ItemId, r.WarehouseId, r.UnitPrice, true, ct);
|
var (qtyBase, unitCostBase) = await _uomConverter.ToBaseAsync(item, r.UomId, r.Qty, r.UnitPrice, ct);
|
||||||
var calc = _sales.ComputeLine(r.Qty, 0m, resolved.UnitPrice, SalesDiscountMode.Amount, 0m, 0m, 0m, 0m, false);
|
var calc = _sales.ComputeLine(qtyBase, 0m, unitCostBase, SalesDiscountMode.Amount, 0m, 0m, 0m, 0m, false);
|
||||||
lines.Add(new BundleSaleLine
|
lines.Add(new BundleSaleLine
|
||||||
{
|
{
|
||||||
ItemId = r.ItemId,
|
ItemId = r.ItemId,
|
||||||
Description = item.Name,
|
Description = item.Name,
|
||||||
Qty = r.Qty,
|
Qty = qtyBase,
|
||||||
UomId = r.UomId,
|
UomId = item.BaseUomId,
|
||||||
WarehouseId = r.WarehouseId,
|
WarehouseId = lineWarehouseId,
|
||||||
UnitPrice = resolved.UnitPrice,
|
UnitPrice = unitCostBase,
|
||||||
LineTotal = calc.LineTotal,
|
LineTotal = calc.LineTotal,
|
||||||
IncludeInBundle = r.IncludeInBundle,
|
IncludeInBundle = r.IncludeInBundle,
|
||||||
IsComponent = true,
|
IsComponent = true,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
|||||||
private readonly IRepository<Item> _items;
|
private readonly IRepository<Item> _items;
|
||||||
private readonly IFifoCostingService _fifo;
|
private readonly IFifoCostingService _fifo;
|
||||||
private readonly ISalesDomainService _sales;
|
private readonly ISalesDomainService _sales;
|
||||||
|
private readonly IUomConverter _uomConverter;
|
||||||
private readonly ICurrentUser _currentUser;
|
private readonly ICurrentUser _currentUser;
|
||||||
private readonly IUnitOfWork _uow;
|
private readonly IUnitOfWork _uow;
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
|||||||
IRepository<Item> items,
|
IRepository<Item> items,
|
||||||
IFifoCostingService fifo,
|
IFifoCostingService fifo,
|
||||||
ISalesDomainService sales,
|
ISalesDomainService sales,
|
||||||
|
IUomConverter uomConverter,
|
||||||
ICurrentUser currentUser,
|
ICurrentUser currentUser,
|
||||||
IUnitOfWork uow)
|
IUnitOfWork uow)
|
||||||
{
|
{
|
||||||
@@ -39,6 +41,7 @@ public sealed class SalesPostingService : ISalesPostingService
|
|||||||
_items = items;
|
_items = items;
|
||||||
_fifo = fifo;
|
_fifo = fifo;
|
||||||
_sales = sales;
|
_sales = sales;
|
||||||
|
_uomConverter = uomConverter;
|
||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
}
|
}
|
||||||
@@ -119,15 +122,14 @@ public sealed class SalesPostingService : ISalesPostingService
|
|||||||
{
|
{
|
||||||
if (!await _sales.IsStockedItemAsync(line.ItemId, ct))
|
if (!await _sales.IsStockedItemAsync(line.ItemId, ct))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
var item = await _items.Query().AsNoTracking()
|
||||||
|
.FirstAsync(x => x.ItemId == line.ItemId, ct);
|
||||||
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
|
var available = await _fifo.GetOnHandAsync(line.ItemId, line.WarehouseId, ct);
|
||||||
if (available >= line.Qty) continue;
|
if (available >= line.Qty) continue;
|
||||||
|
|
||||||
var item = await _items.Query().AsNoTracking()
|
issues.Add(new BundleSalePostingIssueDto(
|
||||||
.Where(x => x.ItemId == line.ItemId)
|
line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, line.Qty, available, line.Qty - available));
|
||||||
.Select(x => new { x.Sku, x.Name })
|
|
||||||
.FirstAsync(ct);
|
|
||||||
|
|
||||||
issues.Add(new BundleSalePostingIssueDto(line.BundleSaleLineId, line.ItemId, item.Sku, item.Name, line.WarehouseId, line.Qty, available, line.Qty - available));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, issues.Count == 0, issues);
|
return new BundleSalePostingCheckDto(bundle.BundleSaleId, bundle.BundleNo, bundle.Status, issues.Count == 0, issues);
|
||||||
@@ -139,10 +141,10 @@ public sealed class SalesPostingService : ISalesPostingService
|
|||||||
notFoundMessage: $"Sales invoice {salesInvoiceId} was not found.",
|
notFoundMessage: $"Sales invoice {salesInvoiceId} was not found.",
|
||||||
statusSelector: x => x.Status,
|
statusSelector: x => x.Status,
|
||||||
ensureDraftMessage: x => $"Sales invoice {x.SalesInvoiceId} is {x.Status} and cannot be posted.",
|
ensureDraftMessage: x => $"Sales invoice {x.SalesInvoiceId} is {x.Status} and cannot be posted.",
|
||||||
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
|
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.UomId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
|
||||||
setPosted: x => x.Status = SalesInvoiceStatus.Posted,
|
setPosted: x => x.Status = SalesInvoiceStatus.Posted,
|
||||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||||
sourceDocType: nameof(SalesInvoice),
|
sourceDocType: DocumentTypes.SalesInvoice,
|
||||||
getDocId: x => x.SalesInvoiceId,
|
getDocId: x => x.SalesInvoiceId,
|
||||||
ct: ct);
|
ct: ct);
|
||||||
|
|
||||||
@@ -152,10 +154,10 @@ public sealed class SalesPostingService : ISalesPostingService
|
|||||||
notFoundMessage: $"Sales slip {salesSlipId} was not found.",
|
notFoundMessage: $"Sales slip {salesSlipId} was not found.",
|
||||||
statusSelector: x => x.Status,
|
statusSelector: x => x.Status,
|
||||||
ensureDraftMessage: x => $"Sales slip {x.SalesSlipId} is {x.Status} and cannot be posted.",
|
ensureDraftMessage: x => $"Sales slip {x.SalesSlipId} is {x.Status} and cannot be posted.",
|
||||||
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
|
getLines: x => x.Lines.Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.UomId, l.Qty + l.FreeQty, l.Qty, l.FreeQty)),
|
||||||
setPosted: x => x.Status = SalesSlipStatus.Posted,
|
setPosted: x => x.Status = SalesSlipStatus.Posted,
|
||||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||||
sourceDocType: nameof(SalesSlip),
|
sourceDocType: DocumentTypes.SalesSlip,
|
||||||
getDocId: x => x.SalesSlipId,
|
getDocId: x => x.SalesSlipId,
|
||||||
ct: ct);
|
ct: ct);
|
||||||
|
|
||||||
@@ -165,10 +167,12 @@ public sealed class SalesPostingService : ISalesPostingService
|
|||||||
notFoundMessage: $"Bundle sale {bundleSaleId} was not found.",
|
notFoundMessage: $"Bundle sale {bundleSaleId} was not found.",
|
||||||
statusSelector: x => x.Status,
|
statusSelector: x => x.Status,
|
||||||
ensureDraftMessage: x => $"Bundle sale {x.BundleSaleId} is {x.Status} and cannot be posted.",
|
ensureDraftMessage: x => $"Bundle sale {x.BundleSaleId} is {x.Status} and cannot be posted.",
|
||||||
getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.Qty, l.Qty, 0m)),
|
// Bundle lines are normalized to base UOM on save, so posting should consume the
|
||||||
|
// stored base quantity directly instead of converting again.
|
||||||
|
getLines: x => x.Lines.Where(l => l.IncludeInBundle).Select(l => new PostingLine(l.ItemId, l.WarehouseId, l.UomId, l.Qty, l.Qty, 0m)),
|
||||||
setPosted: x => x.Status = BundleSaleStatus.Posted,
|
setPosted: x => x.Status = BundleSaleStatus.Posted,
|
||||||
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
setUpdated: x => x.UpdatedAt = DateTime.UtcNow,
|
||||||
sourceDocType: nameof(BundleSale),
|
sourceDocType: DocumentTypes.BundleSale,
|
||||||
getDocId: x => x.BundleSaleId,
|
getDocId: x => x.BundleSaleId,
|
||||||
ct: ct);
|
ct: ct);
|
||||||
|
|
||||||
@@ -210,5 +214,5 @@ public sealed class SalesPostingService : ISalesPostingService
|
|||||||
}, ct);
|
}, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record PostingLine(int ItemId, int WarehouseId, decimal Qty, decimal PaidQty, decimal FreeQty);
|
private sealed record PostingLine(int ItemId, int WarehouseId, int UomId, decimal Qty, decimal PaidQty, decimal FreeQty);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ namespace ERPCore.Services;
|
|||||||
|
|
||||||
public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionService
|
public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionService
|
||||||
{
|
{
|
||||||
private const decimal FreeIssueThreshold = 10m;
|
|
||||||
|
|
||||||
private readonly IRepository<SalesSlip> _slips;
|
private readonly IRepository<SalesSlip> _slips;
|
||||||
private readonly IRepository<Item> _items;
|
private readonly IRepository<Item> _items;
|
||||||
|
|
||||||
@@ -27,7 +25,14 @@ public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionS
|
|||||||
|
|
||||||
if (slip is null) return null;
|
if (slip is null) return null;
|
||||||
|
|
||||||
var itemIds = slip.Lines.Select(x => x.ItemId).Distinct().ToList();
|
var freeIssueLines = slip.Lines
|
||||||
|
.Where(x => x.IsFreeIssue || x.FreeQty > 0m)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (freeIssueLines.Count == 0)
|
||||||
|
return new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, Array.Empty<SalesFreeIssueSuggestionLineDto>());
|
||||||
|
|
||||||
|
var itemIds = freeIssueLines.Select(x => x.ItemId).Distinct().ToList();
|
||||||
var candidateItems = await _items.Query().AsNoTracking()
|
var candidateItems = await _items.Query().AsNoTracking()
|
||||||
.Where(x => itemIds.Contains(x.ItemId) && x.Status == EntityStatus.Active)
|
.Where(x => itemIds.Contains(x.ItemId) && x.Status == EntityStatus.Active)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
@@ -35,13 +40,10 @@ public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionS
|
|||||||
var byItemId = candidateItems.ToDictionary(x => x.ItemId);
|
var byItemId = candidateItems.ToDictionary(x => x.ItemId);
|
||||||
var suggestions = new List<SalesFreeIssueSuggestionLineDto>();
|
var suggestions = new List<SalesFreeIssueSuggestionLineDto>();
|
||||||
|
|
||||||
foreach (var line in slip.Lines.Where(x => x.Qty >= FreeIssueThreshold))
|
foreach (var line in freeIssueLines)
|
||||||
{
|
{
|
||||||
if (!byItemId.TryGetValue(line.ItemId, out var item)) continue;
|
if (!byItemId.TryGetValue(line.ItemId, out var item)) continue;
|
||||||
|
|
||||||
var freeQty = Math.Floor(line.Qty / FreeIssueThreshold);
|
|
||||||
if (freeQty <= 0m) continue;
|
|
||||||
|
|
||||||
var rewardOptions = new List<SalesFreeIssueRewardOptionDto>
|
var rewardOptions = new List<SalesFreeIssueRewardOptionDto>
|
||||||
{
|
{
|
||||||
new(item.ItemId, item.Sku, item.Name, item.SalePrice)
|
new(item.ItemId, item.Sku, item.Name, item.SalePrice)
|
||||||
@@ -62,8 +64,8 @@ public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionS
|
|||||||
item.Sku,
|
item.Sku,
|
||||||
item.Name,
|
item.Name,
|
||||||
line.Qty,
|
line.Qty,
|
||||||
freeQty,
|
line.FreeQty,
|
||||||
FreeIssueThreshold,
|
line.Qty,
|
||||||
rewardOptions));
|
rewardOptions));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,4 +73,4 @@ public sealed class SalesPromotionSuggestionService : ISalesPromotionSuggestionS
|
|||||||
? new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, Array.Empty<SalesFreeIssueSuggestionLineDto>())
|
? new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, Array.Empty<SalesFreeIssueSuggestionLineDto>())
|
||||||
: new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, suggestions);
|
: new SalesFreeIssueSuggestionDto(slip.SalesSlipId, slip.SlipNo, slip.SlipDate, suggestions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ConnectionStrings": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": "Host=localhost;Port=5432;Database=ERPCoreTest;Username=postgres;Password=root"
|
"DefaultConnection": "Host=187.127.102.190;Port=5432;Database=ERPCoreTest;Username=postgres;Password=post@hexdive"
|
||||||
},
|
},
|
||||||
"AuthHex": {
|
"AuthHex": {
|
||||||
"BaseUrl": "http://localhost:5011"
|
"BaseUrl": "http://localhost:5011"
|
||||||
|
|||||||
@@ -195,11 +195,9 @@ export default function PurchaseOrderDetailPage() {
|
|||||||
setPo(updated)
|
setPo(updated)
|
||||||
setLines(toDraftLines(updated))
|
setLines(toDraftLines(updated))
|
||||||
toast.success("Purchase order approved", `${updated.docNo} — now ${updated.status} and locked for editing.`)
|
toast.success("Purchase order approved", `${updated.docNo} — now ${updated.status} and locked for editing.`)
|
||||||
toast.success("Purchase order approved", `${updated.docNo} — now ${updated.status} and locked for editing.`)
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSaveError(errorMessage(err))
|
setSaveError(errorMessage(err))
|
||||||
toast.error("Could not approve purchase order", errorMessage(err))
|
toast.error("Could not approve purchase order", errorMessage(err))
|
||||||
toast.error("Could not approve purchase order", errorMessage(err))
|
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
@@ -286,10 +284,7 @@ export default function PurchaseOrderDetailPage() {
|
|||||||
<>
|
<>
|
||||||
<Button variant="success" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}>
|
<Button variant="success" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}>
|
||||||
<Check className="size-5" />
|
<Check className="size-5" />
|
||||||
{submitting ? "Approving…" : "Approve"}
|
{submitting ? "Approving�" : "Approve"}
|
||||||
<Button variant="success" size="lg" onClick={handleSubmitPo} disabled={submitting || deleting}>
|
|
||||||
<Check className="size-5" />
|
|
||||||
{submitting ? "Approving…" : "Approve"}
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" size="lg" onClick={handleDelete} disabled={deleting || submitting}>
|
<Button variant="destructive" size="lg" onClick={handleDelete} disabled={deleting || submitting}>
|
||||||
<Trash2 className="size-5" />
|
<Trash2 className="size-5" />
|
||||||
|
|||||||
@@ -52,10 +52,10 @@ function newKey() {
|
|||||||
return `poline-${keySeq}`
|
return `poline-${keySeq}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unit price and tax are no longer entered at PO creation — pricing is captured at GRN
|
// Tax is still not entered at PO creation — it's captured at GRN receipt (with discount/VAT
|
||||||
// receipt (with discount/VAT there). They default to 0 here and stay off the form, but
|
// there) and stays off this form, though it remains on the payload since the backend line
|
||||||
// remain on the payload because the backend line DTO still requires them; a PO prefilled
|
// DTO still requires it. Unit price *is* entered here; a PO prefilled from an RFQ starts
|
||||||
// from an RFQ keeps its negotiated price (below).
|
// from its negotiated price (below) but stays editable.
|
||||||
function emptyLine(): DraftLine {
|
function emptyLine(): DraftLine {
|
||||||
return { key: newKey(), itemId: null, uomId: null, warehouseId: null, qty: "", unitPrice: "0", tax: "0" }
|
return { key: newKey(), itemId: null, uomId: null, warehouseId: null, qty: "", unitPrice: "0", tax: "0" }
|
||||||
}
|
}
|
||||||
@@ -306,7 +306,7 @@ function NewPurchaseOrderContent() {
|
|||||||
{!loading && (
|
{!loading && (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-4">
|
||||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
<div className="flex flex-col gap-2 sm:col-span-1">
|
||||||
<Label className="text-base">Vendor</Label>
|
<Label className="text-base">Vendor</Label>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Select<number | null>
|
<Select<number | null>
|
||||||
@@ -406,13 +406,14 @@ function NewPurchaseOrderContent() {
|
|||||||
|
|
||||||
{lines.length > 0 && (
|
{lines.length > 0 && (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table className="text-base">
|
<Table className="table-fixed text-base">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
|
<TableHead className="h-12 w-32 px-3 text-sm">Item</TableHead>
|
||||||
<TableHead className="h-12 w-28 px-3 text-sm">UOM</TableHead>
|
<TableHead className="h-12 w-16 px-3 text-sm">UOM</TableHead>
|
||||||
<TableHead className="h-12 w-40 px-3 text-sm">Warehouse</TableHead>
|
<TableHead className="h-12 w-20 px-3 text-sm">Warehouse</TableHead>
|
||||||
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
|
<TableHead className="h-12 w-24 px-3 text-sm">Qty</TableHead>
|
||||||
|
<TableHead className="h-12 w-28 px-3 text-sm">Unit price</TableHead>
|
||||||
<TableHead className="h-12 w-10 px-3" />
|
<TableHead className="h-12 w-10 px-3" />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -424,11 +425,15 @@ function NewPurchaseOrderContent() {
|
|||||||
<TableRow key={line.key}>
|
<TableRow key={line.key}>
|
||||||
<TableCell className="px-3 py-3 align-top">
|
<TableCell className="px-3 py-3 align-top">
|
||||||
{requisitionId || rfqId ? (
|
{requisitionId || rfqId ? (
|
||||||
<div className="flex h-11 items-center text-base">{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</div>
|
<div className="flex h-11 items-center truncate text-base" title={item ? `${item.sku} — ${item.name}` : undefined}>{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
||||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
|
<SelectTrigger
|
||||||
|
className="h-11! w-full text-base"
|
||||||
|
aria-invalid={!!errors.itemId}
|
||||||
|
title={item ? `${item.sku} — ${item.name}` : undefined}
|
||||||
|
>
|
||||||
<SelectValue placeholder="Select item" />
|
<SelectValue placeholder="Select item" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -485,6 +490,18 @@ function NewPurchaseOrderContent() {
|
|||||||
/>
|
/>
|
||||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-3 align-top">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
value={line.unitPrice}
|
||||||
|
aria-invalid={!!errors.unitPrice}
|
||||||
|
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value })}
|
||||||
|
className="h-11 text-base"
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.unitPrice ? { message: errors.unitPrice } : undefined]} />
|
||||||
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3 align-top">
|
<TableCell className="px-3 py-3 align-top">
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
||||||
<Trash2 className="size-5" />
|
<Trash2 className="size-5" />
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ export default function ItemDetailPage() {
|
|||||||
<h1 className="text-2xl font-bold text-foreground">{item.sku}</h1>
|
<h1 className="text-2xl font-bold text-foreground">{item.sku}</h1>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", item.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground")}
|
className={cn("h-6 w-fit justify-center border-transparent px-2.5 text-sm", item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive")}
|
||||||
>
|
>
|
||||||
{item.status}
|
{item.status}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -257,7 +257,15 @@ export default function BrandsPage() {
|
|||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{b.brandId}</TableCell>
|
<TableCell className="px-3 py-3.5 text-muted-foreground">#{b.brandId}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{b.name}</TableCell>
|
<TableCell className="px-3 py-3.5 font-medium">{b.name}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<Badge variant={b.status === "Active" ? "default" : "secondary"}>{b.status}</Badge>
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"border-transparent",
|
||||||
|
b.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{b.status}
|
||||||
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(b.createdAt).toLocaleDateString()}</TableCell>
|
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(b.createdAt).toLocaleDateString()}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Ban, CheckCircle2, Network, Pencil, Plus } from "lucide-react"
|
|||||||
import { categoriesApi, subCategoriesApi } from "@/lib/api/categories"
|
import { categoriesApi, subCategoriesApi } from "@/lib/api/categories"
|
||||||
import { errorMessage } from "@/lib/error-map"
|
import { errorMessage } from "@/lib/error-map"
|
||||||
import { validateCategoryName } from "@/lib/validations/master-data"
|
import { validateCategoryName } from "@/lib/validations/master-data"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
import { Category, SubCategory } from "@/types/master-data"
|
import { Category, SubCategory } from "@/types/master-data"
|
||||||
|
|
||||||
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
import { AlertDialog, AlertDialogContent, AlertDialogTrigger } from "@/components/ui/alert-dialog"
|
||||||
@@ -181,7 +182,15 @@ export default function CategorySubCategoriesPage() {
|
|||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{s.subCategoryId}</TableCell>
|
<TableCell className="px-3 py-3.5 text-muted-foreground">#{s.subCategoryId}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{s.name}</TableCell>
|
<TableCell className="px-3 py-3.5 font-medium">{s.name}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<Badge variant={s.status === "Active" ? "default" : "secondary"}>{s.status}</Badge>
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"border-transparent",
|
||||||
|
s.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{s.status}
|
||||||
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(s.createdAt).toLocaleDateString()}</TableCell>
|
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(s.createdAt).toLocaleDateString()}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
|
|||||||
@@ -256,7 +256,15 @@ export default function CategoriesPage() {
|
|||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">#{c.categoryId}</TableCell>
|
<TableCell className="px-3 py-3.5 text-muted-foreground">#{c.categoryId}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
|
<TableCell className="px-3 py-3.5 font-medium">{c.name}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
<Badge variant={c.status === "Active" ? "default" : "secondary"}>{c.status}</Badge>
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"border-transparent",
|
||||||
|
c.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{c.status}
|
||||||
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
|
<TableCell className="px-3 py-3.5 text-muted-foreground">{new Date(c.createdAt).toLocaleDateString()}</TableCell>
|
||||||
<TableCell className="px-3 py-3.5">
|
<TableCell className="px-3 py-3.5">
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ export default function ItemsPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
"h-6 w-fit justify-center border-transparent px-2.5 text-sm",
|
||||||
item.status === "Active" ? "bg-success/10 text-success" : "bg-muted text-muted-foreground"
|
item.status === "Active" ? "bg-success/10 text-success" : "bg-destructive/10 text-destructive"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.status}
|
{item.status}
|
||||||
|
|||||||
@@ -440,220 +440,220 @@ export default function NewGrnPage() {
|
|||||||
|
|
||||||
{!poLoading && lines.length > 0 && (
|
{!poLoading && lines.length > 0 && (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table className="text-base">
|
<Table className="text-base">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="h-12 w-96 px-3 text-sm">Item</TableHead>
|
<TableHead className="h-12 w-56 px-3 text-sm">Item</TableHead>
|
||||||
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
|
<TableHead className="h-12 w-24 px-3 text-sm">UOM</TableHead>
|
||||||
<TableHead className="h-12 w-28 px-3 text-sm">Bin</TableHead>
|
<TableHead className="h-12 w-28 px-3 text-sm">Bin</TableHead>
|
||||||
<TableHead className="h-12 w-20 px-3 text-sm">Qty</TableHead>
|
<TableHead className="h-12 w-32 px-3 text-sm">Qty</TableHead>
|
||||||
<TableHead className="h-12 w-28 px-3 text-sm">Unit cost</TableHead>
|
<TableHead className="h-12 w-28 px-3 text-sm">Unit cost</TableHead>
|
||||||
<TableHead className="h-12 w-24 px-3 text-sm">Disc %</TableHead>
|
<TableHead className="h-12 w-24 px-3 text-sm">Disc %</TableHead>
|
||||||
<TableHead className="h-12 w-24 px-3 text-sm">VAT %</TableHead>
|
<TableHead className="h-12 w-24 px-3 text-sm">VAT %</TableHead>
|
||||||
<TableHead className="h-12 w-28 px-3 text-sm text-right">Line total</TableHead>
|
<TableHead className="h-12 w-28 px-3 text-sm text-right">Line total</TableHead>
|
||||||
<TableHead className="h-12 w-56 px-3 text-sm">Hold status</TableHead>
|
<TableHead className="h-12 w-56 px-3 text-sm">Hold status</TableHead>
|
||||||
<TableHead className="h-12 w-44 px-3 text-sm">Batch / Serial</TableHead>
|
<TableHead className="h-12 w-44 px-3 text-sm">Batch / Serial</TableHead>
|
||||||
<TableHead className="h-12 w-10 px-3" />
|
<TableHead className="h-12 w-10 px-3" />
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{lines.map((line) => {
|
{lines.map((line) => {
|
||||||
const item = itemFor(line.itemId)
|
const item = itemFor(line.itemId)
|
||||||
const errors = lineErrors[line.key] ?? {}
|
const errors = lineErrors[line.key] ?? {}
|
||||||
return (
|
return (
|
||||||
<TableRow key={line.key}>
|
<TableRow key={line.key}>
|
||||||
<TableCell className="px-3 py-3 align-top">
|
<TableCell className="px-3 py-3 align-top">
|
||||||
{line.poLineId ? (
|
{line.poLineId ? (
|
||||||
<div className="flex h-11 items-center text-base">
|
<div className="flex h-11 items-center text-base">
|
||||||
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
|
{item ? `${item.sku} — ${item.name}` : `Item #${line.itemId}`}
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
|
||||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
|
|
||||||
<SelectValue placeholder="Select item" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{(items ?? []).map((i) => (
|
|
||||||
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
|
|
||||||
{i.sku} — {i.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-3 py-3 align-top">
|
|
||||||
{line.poLineId ? (
|
|
||||||
<div className="flex h-11 items-center text-base">
|
|
||||||
{uoms?.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Select<number | null> value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}>
|
|
||||||
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.uomId}>
|
|
||||||
<SelectValue placeholder="UOM" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{(uoms ?? []).map((u) => (
|
|
||||||
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
|
|
||||||
{u.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FieldError errors={[errors.uomId ? { message: errors.uomId } : undefined]} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-3 py-3 align-top">
|
|
||||||
<Select<number | null> value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}>
|
|
||||||
<SelectTrigger className="h-11! w-full text-base">
|
|
||||||
<SelectValue placeholder="None" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{bins.map((b) => (
|
|
||||||
<SelectItem key={b.binId} value={b.binId} className="text-base">
|
|
||||||
{b.code}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-3 py-3 align-top">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="any"
|
|
||||||
value={line.qty}
|
|
||||||
aria-invalid={!!errors.qty}
|
|
||||||
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
|
|
||||||
className="h-11 text-base"
|
|
||||||
/>
|
|
||||||
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-3 py-3 align-top">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="any"
|
|
||||||
value={line.unitCost}
|
|
||||||
aria-invalid={!!errors.unitCost}
|
|
||||||
onChange={(e) => updateLine(line.key, { unitCost: e.target.value })}
|
|
||||||
className="h-11 text-base"
|
|
||||||
/>
|
|
||||||
<FieldError errors={[errors.unitCost ? { message: errors.unitCost } : undefined]} />
|
|
||||||
{line.poUnitPrice !== null && Number(line.unitCost) !== line.poUnitPrice && (
|
|
||||||
<p className="mt-1 text-xs text-warning">
|
|
||||||
PO price {line.poUnitPrice.toFixed(2)} — variance recorded
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-3 py-3 align-top">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
step="any"
|
|
||||||
value={line.discountPct}
|
|
||||||
aria-invalid={!!errors.discountPct}
|
|
||||||
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
|
||||||
className="h-11 text-base"
|
|
||||||
/>
|
|
||||||
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-3 py-3 align-top">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="100"
|
|
||||||
step="any"
|
|
||||||
value={line.vatPct}
|
|
||||||
aria-invalid={!!errors.vatPct}
|
|
||||||
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
|
|
||||||
className="h-11 text-base"
|
|
||||||
/>
|
|
||||||
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
|
|
||||||
{(() => {
|
|
||||||
const c = computeLine(line)
|
|
||||||
return (
|
|
||||||
<div className="flex h-11 flex-col justify-center">
|
|
||||||
<span>{c.lineTotal.toFixed(2)}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
net {c.receivedValue.toFixed(2)} + VAT {c.vatAmount.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
) : (
|
||||||
})()}
|
<>
|
||||||
</TableCell>
|
<Select<number | null> value={line.itemId} onValueChange={(v) => updateLine(line.key, { itemId: v })}>
|
||||||
<TableCell className="px-3 py-3 align-top">
|
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.itemId}>
|
||||||
<Select<HoldStatus>
|
<SelectValue placeholder="Select item" />
|
||||||
value={line.holdStatus}
|
</SelectTrigger>
|
||||||
onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })}
|
<SelectContent>
|
||||||
>
|
{(items ?? []).map((i) => (
|
||||||
<SelectTrigger className="h-11! w-full text-base">
|
<SelectItem key={i.itemId} value={i.itemId} className="text-base">
|
||||||
<SelectValue />
|
{i.sku} — {i.name}
|
||||||
</SelectTrigger>
|
</SelectItem>
|
||||||
<SelectContent>
|
))}
|
||||||
<SelectItem value="Available" className="text-base">Available</SelectItem>
|
</SelectContent>
|
||||||
<SelectItem value="OnHold" className="text-base">On hold (inspection)</SelectItem>
|
</Select>
|
||||||
</SelectContent>
|
<FieldError errors={[errors.itemId ? { message: errors.itemId } : undefined]} />
|
||||||
</Select>
|
</>
|
||||||
</TableCell>
|
)}
|
||||||
<TableCell className="px-3 py-3 align-top">
|
</TableCell>
|
||||||
{item?.trackingMode === "Batch" && (
|
<TableCell className="px-3 py-3 align-top">
|
||||||
<div className="flex flex-col gap-1.5">
|
{line.poLineId ? (
|
||||||
<Input
|
<div className="flex h-11 items-center text-base">
|
||||||
placeholder="Batch no."
|
{uoms?.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}
|
||||||
value={line.batchNo}
|
</div>
|
||||||
aria-invalid={!!errors.batchNo}
|
) : (
|
||||||
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
|
<>
|
||||||
className="h-9 text-sm"
|
<Select<number | null> value={line.uomId} onValueChange={(v) => updateLine(line.key, { uomId: v })}>
|
||||||
/>
|
<SelectTrigger className="h-11! w-full text-base" aria-invalid={!!errors.uomId}>
|
||||||
<Input
|
<SelectValue placeholder="UOM" />
|
||||||
type="date"
|
</SelectTrigger>
|
||||||
value={line.expiryDate}
|
<SelectContent>
|
||||||
onChange={(e) => updateLine(line.key, { expiryDate: e.target.value })}
|
{(uoms ?? []).map((u) => (
|
||||||
className="h-9 text-sm"
|
<SelectItem key={u.uomId} value={u.uomId} className="text-base">
|
||||||
/>
|
{u.name}
|
||||||
<FieldError errors={[errors.batchNo ? { message: errors.batchNo } : undefined]} />
|
</SelectItem>
|
||||||
</div>
|
))}
|
||||||
)}
|
</SelectContent>
|
||||||
{item?.trackingMode === "Serial" && (
|
</Select>
|
||||||
<div className="flex flex-col gap-1.5">
|
<FieldError errors={[errors.uomId ? { message: errors.uomId } : undefined]} />
|
||||||
<textarea
|
</>
|
||||||
placeholder="One serial per line"
|
)}
|
||||||
value={line.serialNumbersText}
|
</TableCell>
|
||||||
aria-invalid={!!errors.serialNumbers}
|
<TableCell className="px-3 py-3 align-top">
|
||||||
onChange={(e) => updateLine(line.key, { serialNumbersText: e.target.value })}
|
<Select<number | null> value={line.binId} onValueChange={(v) => updateLine(line.key, { binId: v })}>
|
||||||
className="min-h-16 w-full rounded-lg border border-input bg-transparent p-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
<SelectTrigger className="h-11! w-full text-base">
|
||||||
/>
|
<SelectValue placeholder="None" />
|
||||||
<FieldError errors={[errors.serialNumbers ? { message: errors.serialNumbers } : undefined]} />
|
</SelectTrigger>
|
||||||
</div>
|
<SelectContent>
|
||||||
)}
|
{bins.map((b) => (
|
||||||
{(!item || item.trackingMode === "None") && (
|
<SelectItem key={b.binId} value={b.binId} className="text-base">
|
||||||
<span className="text-sm text-muted-foreground">—</span>
|
{b.code}
|
||||||
)}
|
</SelectItem>
|
||||||
</TableCell>
|
))}
|
||||||
<TableCell className="px-3 py-3 align-top">
|
</SelectContent>
|
||||||
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
</Select>
|
||||||
<Trash2 className="size-5" />
|
</TableCell>
|
||||||
</Button>
|
<TableCell className="px-3 py-3 align-top">
|
||||||
</TableCell>
|
<Input
|
||||||
</TableRow>
|
type="number"
|
||||||
)
|
min="0"
|
||||||
})}
|
step="any"
|
||||||
</TableBody>
|
value={line.qty}
|
||||||
</Table>
|
aria-invalid={!!errors.qty}
|
||||||
|
onChange={(e) => updateLine(line.key, { qty: e.target.value })}
|
||||||
|
className="h-11 text-base"
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.qty ? { message: errors.qty } : undefined]} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-3 align-top">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
step="any"
|
||||||
|
value={line.unitCost}
|
||||||
|
aria-invalid={!!errors.unitCost}
|
||||||
|
onChange={(e) => updateLine(line.key, { unitCost: e.target.value })}
|
||||||
|
className="h-11 text-base"
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.unitCost ? { message: errors.unitCost } : undefined]} />
|
||||||
|
{line.poUnitPrice !== null && Number(line.unitCost) !== line.poUnitPrice && (
|
||||||
|
<p className="mt-1 text-xs text-warning">
|
||||||
|
PO price {line.poUnitPrice.toFixed(2)} — variance recorded
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-3 align-top">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="any"
|
||||||
|
value={line.discountPct}
|
||||||
|
aria-invalid={!!errors.discountPct}
|
||||||
|
onChange={(e) => updateLine(line.key, { discountPct: e.target.value })}
|
||||||
|
className="h-11 text-base"
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.discountPct ? { message: errors.discountPct } : undefined]} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-3 align-top">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="any"
|
||||||
|
value={line.vatPct}
|
||||||
|
aria-invalid={!!errors.vatPct}
|
||||||
|
onChange={(e) => updateLine(line.key, { vatPct: e.target.value })}
|
||||||
|
className="h-11 text-base"
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.vatPct ? { message: errors.vatPct } : undefined]} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-3 align-top text-right tabular-nums">
|
||||||
|
{(() => {
|
||||||
|
const c = computeLine(line)
|
||||||
|
return (
|
||||||
|
<div className="flex h-11 flex-col justify-center">
|
||||||
|
<span>{c.lineTotal.toFixed(2)}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
net {c.receivedValue.toFixed(2)} + VAT {c.vatAmount.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-3 align-top">
|
||||||
|
<Select<HoldStatus>
|
||||||
|
value={line.holdStatus}
|
||||||
|
onValueChange={(v) => v && updateLine(line.key, { holdStatus: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-11! w-full text-base">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Available" className="text-base">Available</SelectItem>
|
||||||
|
<SelectItem value="OnHold" className="text-base">On hold (inspection)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-3 align-top">
|
||||||
|
{item?.trackingMode === "Batch" && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Input
|
||||||
|
placeholder="Batch no."
|
||||||
|
value={line.batchNo}
|
||||||
|
aria-invalid={!!errors.batchNo}
|
||||||
|
onChange={(e) => updateLine(line.key, { batchNo: e.target.value })}
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={line.expiryDate}
|
||||||
|
onChange={(e) => updateLine(line.key, { expiryDate: e.target.value })}
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.batchNo ? { message: errors.batchNo } : undefined]} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{item?.trackingMode === "Serial" && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<textarea
|
||||||
|
placeholder="One serial per line"
|
||||||
|
value={line.serialNumbersText}
|
||||||
|
aria-invalid={!!errors.serialNumbers}
|
||||||
|
onChange={(e) => updateLine(line.key, { serialNumbersText: e.target.value })}
|
||||||
|
className="min-h-16 w-full rounded-lg border border-input bg-transparent p-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50"
|
||||||
|
/>
|
||||||
|
<FieldError errors={[errors.serialNumbers ? { message: errors.serialNumbers } : undefined]} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(!item || item.trackingMode === "None") && (
|
||||||
|
<span className="text-sm text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="px-3 py-3 align-top">
|
||||||
|
<Button type="button" variant="ghost" size="icon" onClick={() => removeLine(line.key)} aria-label="Remove line">
|
||||||
|
<Trash2 className="size-5" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!poLoading && lines.length > 0 && (
|
{!poLoading && lines.length > 0 && (
|
||||||
<div className="flex justify-end gap-6 pr-12 text-base">
|
<div className="flex justify-end gap-3 border-t border-border pt-4 text-base">
|
||||||
<span className="text-muted-foreground">Document total (incl. VAT)</span>
|
<span className="text-muted-foreground">Document total (incl. VAT)</span>
|
||||||
<span className="font-semibold tabular-nums">
|
<span className="font-semibold tabular-nums">
|
||||||
{lines.reduce((sum, l) => sum + computeLine(l).lineTotal, 0).toFixed(2)}
|
{lines.reduce((sum, l) => sum + computeLine(l).lineTotal, 0).toFixed(2)}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useMemo, useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useParams, useRouter } from "next/navigation"
|
import { useParams, useRouter } from "next/navigation"
|
||||||
import { ArrowLeft, CheckCircle2, Edit, Minus, Plus, Printer, Save, XCircle } from "lucide-react"
|
import { ArrowLeft, CheckCircle2, Edit, ExternalLink, Minus, Plus, Printer, Save, XCircle } from "lucide-react"
|
||||||
|
|
||||||
import { bundleApi } from "@/lib/api/bundles"
|
import { bundleApi } from "@/lib/api/bundles"
|
||||||
import { itemsApi } from "@/lib/api/items"
|
import { itemsApi } from "@/lib/api/items"
|
||||||
@@ -26,6 +26,18 @@ import { ManagedUser } from "@/types/users"
|
|||||||
|
|
||||||
type EditableLine = BundleSaleTemplateLine & { key: string }
|
type EditableLine = BundleSaleTemplateLine & { key: string }
|
||||||
|
|
||||||
|
const createBlankLine = (templateLine?: BundleSaleTemplateLine): EditableLine => ({
|
||||||
|
key: crypto.randomUUID(),
|
||||||
|
bundleSaleTemplateLineId: templateLine?.bundleSaleTemplateLineId ?? 0,
|
||||||
|
itemId: templateLine?.itemId ?? 0,
|
||||||
|
uomId: templateLine?.uomId ?? 0,
|
||||||
|
warehouseId: templateLine?.warehouseId ?? 0,
|
||||||
|
qty: templateLine?.qty ?? 1,
|
||||||
|
unitPrice: templateLine?.unitPrice ?? 0,
|
||||||
|
includeInBundle: templateLine?.includeInBundle ?? true,
|
||||||
|
sortOrder: templateLine?.sortOrder ?? 0,
|
||||||
|
})
|
||||||
|
|
||||||
function statusClass(status: BundleSale["status"]) {
|
function statusClass(status: BundleSale["status"]) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "Draft":
|
case "Draft":
|
||||||
@@ -94,7 +106,7 @@ export default function BundleSaleDetailPage() {
|
|||||||
key: `${line.bundleSaleLineId}`,
|
key: `${line.bundleSaleLineId}`,
|
||||||
bundleSaleTemplateLineId: line.bundleSaleLineId,
|
bundleSaleTemplateLineId: line.bundleSaleLineId,
|
||||||
itemId: line.itemId,
|
itemId: line.itemId,
|
||||||
uomId: line.uomId,
|
uomId: items.find((candidate) => candidate.itemId === line.itemId)?.baseUomId ?? line.uomId,
|
||||||
warehouseId: line.warehouseId,
|
warehouseId: line.warehouseId,
|
||||||
qty: line.qty,
|
qty: line.qty,
|
||||||
unitPrice: line.unitPrice,
|
unitPrice: line.unitPrice,
|
||||||
@@ -104,7 +116,7 @@ export default function BundleSaleDetailPage() {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}, [bundleSaleId, params.id])
|
}, [bundleSaleId, params.id, items])
|
||||||
|
|
||||||
const templateLabel = useMemo(() => templates.find((t) => t.bundleSaleTemplateId === templateId)?.templateName ?? "Template", [templates, templateId])
|
const templateLabel = useMemo(() => templates.find((t) => t.bundleSaleTemplateId === templateId)?.templateName ?? "Template", [templates, templateId])
|
||||||
const componentSubtotal = useMemo(() => lines.reduce((sum, line) => sum + Number(line.qty || 0) * Number(line.unitPrice || 0), 0), [lines])
|
const componentSubtotal = useMemo(() => lines.reduce((sum, line) => sum + Number(line.qty || 0) * Number(line.unitPrice || 0), 0), [lines])
|
||||||
@@ -116,9 +128,9 @@ export default function BundleSaleDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addLine() {
|
function addLine() {
|
||||||
const source = lines[lines.length - 1]
|
const source = template?.lines[0]
|
||||||
if (!source) return
|
if (!source) return
|
||||||
setLines((prev) => [...prev, { ...source, key: crypto.randomUUID() }])
|
setLines((prev) => [...prev, createBlankLine(source)])
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeLine(key: string) {
|
function removeLine(key: string) {
|
||||||
@@ -170,6 +182,20 @@ export default function BundleSaleDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shortageIssues = bundle && bundle.status === "Draft"
|
||||||
|
? bundle.lines
|
||||||
|
.filter((line) => line.includeInBundle)
|
||||||
|
.filter((line) => Number(line.qty || 0) > 0)
|
||||||
|
.map((line) => ({
|
||||||
|
bundleSaleLineId: line.bundleSaleLineId,
|
||||||
|
itemId: line.itemId,
|
||||||
|
itemSku: items.find((item) => item.itemId === line.itemId)?.sku ?? `#${line.itemId}`,
|
||||||
|
itemName: items.find((item) => item.itemId === line.itemId)?.name ?? line.itemId.toString(),
|
||||||
|
warehouseId: line.warehouseId,
|
||||||
|
requestedQty: line.qty,
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
|
||||||
async function cancelBundle() {
|
async function cancelBundle() {
|
||||||
if (!bundle) return
|
if (!bundle) return
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
@@ -236,6 +262,47 @@ export default function BundleSaleDetailPage() {
|
|||||||
|
|
||||||
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null}
|
{error ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{error}</div> : null}
|
||||||
|
|
||||||
|
{bundle.status === "Draft" && shortageIssues.length > 0 ? (
|
||||||
|
<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 bundle cannot be posted until every included component 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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{shortageIssues.map((issue) => (
|
||||||
|
<tr key={issue.bundleSaleLineId} 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}</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>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="rounded-2xl border bg-card p-4 shadow-sm">
|
<section className="rounded-2xl border bg-card p-4 shadow-sm">
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -304,7 +371,7 @@ export default function BundleSaleDetailPage() {
|
|||||||
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
|
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
|
||||||
const itemId = Number(v)
|
const itemId = Number(v)
|
||||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||||
updateLine(line.key, { itemId, unitPrice: item?.salePrice ?? line.unitPrice })
|
updateLine(line.key, { itemId, uomId: item?.baseUomId ?? line.uomId, unitPrice: item?.salePrice ?? line.unitPrice })
|
||||||
}} disabled={!editing || !isDraft}>
|
}} disabled={!editing || !isDraft}>
|
||||||
<SelectTrigger className="h-9 text-sm">
|
<SelectTrigger className="h-9 text-sm">
|
||||||
<SelectValue placeholder="Select item" />
|
<SelectValue placeholder="Select item" />
|
||||||
@@ -319,7 +386,7 @@ export default function BundleSaleDetailPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="min-w-40">
|
<TableCell className="min-w-40">
|
||||||
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })} disabled={!editing || !isDraft}>
|
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })} disabled>
|
||||||
<SelectTrigger className="h-9 text-sm">
|
<SelectTrigger className="h-9 text-sm">
|
||||||
<SelectValue placeholder="Select UOM" />
|
<SelectValue placeholder="Select UOM" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -334,15 +401,78 @@ export default function BundleSaleDetailPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right w-28"><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /></TableCell>
|
<TableCell className="text-right w-28"><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /></TableCell>
|
||||||
<TableCell className="text-right w-32"><Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /></TableCell>
|
<TableCell className="text-right w-32"><Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} disabled={!editing || !isDraft} className="text-right" /></TableCell>
|
||||||
<TableCell className="text-right">{line.includeInBundle ? "Yes" : "No"}</TableCell>
|
<TableCell className="text-right">
|
||||||
|
{editing && isDraft ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateLine(line.key, { includeInBundle: !line.includeInBundle })}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-3 py-1 text-xs font-medium",
|
||||||
|
line.includeInBundle ? "bg-emerald-50 text-emerald-800" : "bg-muted text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{line.includeInBundle ? "Included" : "Excluded"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
line.includeInBundle ? "Included" : "Excluded"
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
{editing && isDraft ? <TableCell className="text-right"><Button type="button" variant="ghost" size="icon-sm" onClick={() => removeLine(line.key)} disabled={lines.length === 1}><Minus className="size-4" /></Button></TableCell> : null}
|
{editing && isDraft ? <TableCell className="text-right"><Button type="button" variant="ghost" size="icon-sm" onClick={() => removeLine(line.key)} disabled={lines.length === 1}><Minus className="size-4" /></Button></TableCell> : null}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
{lines.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={editing && isDraft ? 6 : 5} className="py-10 text-center text-sm text-muted-foreground">
|
||||||
|
No component rows yet. Click Add line to start.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : null}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{bundle.status === "Draft" && shortageIssues.length > 0 ? (
|
||||||
|
<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 bundle cannot be posted until every included component 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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{shortageIssues.map((issue) => (
|
||||||
|
<tr key={issue.bundleSaleLineId} 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}</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>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<section className="rounded-2xl border bg-card p-4 shadow-sm">
|
<section className="rounded-2xl border bg-card p-4 shadow-sm">
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
<div><div className="text-xs text-muted-foreground">Component subtotal</div><div className="text-lg font-semibold">{componentSubtotal.toFixed(2)}</div></div>
|
<div><div className="text-xs text-muted-foreground">Component subtotal</div><div className="text-lg font-semibold">{componentSubtotal.toFixed(2)}</div></div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { Suspense, useEffect, useMemo, useState } from "react"
|
import { Suspense, useEffect, useMemo, useState } from "react"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, Minus, Plus, Save } from "lucide-react"
|
import { ArrowLeft, ExternalLink, Minus, Plus, Save } from "lucide-react"
|
||||||
|
|
||||||
import { bundleApi } from "@/lib/api/bundles"
|
import { bundleApi } from "@/lib/api/bundles"
|
||||||
import { customersApi } from "@/lib/api/customers"
|
import { customersApi } from "@/lib/api/customers"
|
||||||
@@ -27,7 +27,17 @@ import { BundleSaleTemplate, BundleSaleTemplateLine, BundleSaleTemplateSummary,
|
|||||||
|
|
||||||
type EditableLine = BundleSaleTemplateLine & { key: string }
|
type EditableLine = BundleSaleTemplateLine & { key: string }
|
||||||
|
|
||||||
const blankLine = (source: BundleSaleTemplateLine): EditableLine => ({ ...source, key: crypto.randomUUID() })
|
const createBlankLine = (templateLine?: BundleSaleTemplateLine): EditableLine => ({
|
||||||
|
key: crypto.randomUUID(),
|
||||||
|
bundleSaleTemplateLineId: templateLine?.bundleSaleTemplateLineId ?? 0,
|
||||||
|
itemId: templateLine?.itemId ?? 0,
|
||||||
|
uomId: templateLine?.uomId ?? 0,
|
||||||
|
warehouseId: templateLine?.warehouseId ?? 0,
|
||||||
|
qty: templateLine?.qty ?? 1,
|
||||||
|
unitPrice: templateLine?.unitPrice ?? 0,
|
||||||
|
includeInBundle: templateLine?.includeInBundle ?? true,
|
||||||
|
sortOrder: templateLine?.sortOrder ?? 0,
|
||||||
|
})
|
||||||
|
|
||||||
function NewBundleSaleContent() {
|
function NewBundleSaleContent() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -81,10 +91,20 @@ function NewBundleSaleContent() {
|
|||||||
if (!templateId) return
|
if (!templateId) return
|
||||||
bundleApi.getTemplate(templateId).then((res) => {
|
bundleApi.getTemplate(templateId).then((res) => {
|
||||||
setTemplate(res)
|
setTemplate(res)
|
||||||
setLines(res.lines.map(blankLine))
|
setLines(
|
||||||
|
res.lines.length > 0
|
||||||
|
? res.lines.map((line) => {
|
||||||
|
const item = items.find((candidate) => candidate.itemId === line.itemId)
|
||||||
|
return createBlankLine({
|
||||||
|
...line,
|
||||||
|
uomId: item?.baseUomId ?? line.uomId,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
: [createBlankLine()]
|
||||||
|
)
|
||||||
setBundlePrice(res.lines.reduce((sum, line) => sum + line.qty * line.unitPrice, 0))
|
setBundlePrice(res.lines.reduce((sum, line) => sum + line.qty * line.unitPrice, 0))
|
||||||
}).catch((err) => setSubmitError(errorMessage(err)))
|
}).catch((err) => setSubmitError(errorMessage(err)))
|
||||||
}, [templateId])
|
}, [items, templateId])
|
||||||
|
|
||||||
const templateLabel = useMemo(() => template?.templateName ?? "Select template", [template])
|
const templateLabel = useMemo(() => template?.templateName ?? "Select template", [template])
|
||||||
const componentSubtotal = useMemo(() => lines.reduce((sum, line) => sum + Number(line.qty || 0) * Number(line.unitPrice || 0), 0), [lines])
|
const componentSubtotal = useMemo(() => lines.reduce((sum, line) => sum + Number(line.qty || 0) * Number(line.unitPrice || 0), 0), [lines])
|
||||||
@@ -94,9 +114,7 @@ function NewBundleSaleContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addLine() {
|
function addLine() {
|
||||||
const source = lines[lines.length - 1] ?? template?.lines[0]
|
setLines((prev) => [...prev, createBlankLine(template?.lines[0])])
|
||||||
if (!source) return
|
|
||||||
setLines((prev) => [...prev, blankLine(source)])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeLine(key: string) {
|
function removeLine(key: string) {
|
||||||
@@ -151,6 +169,22 @@ function NewBundleSaleContent() {
|
|||||||
|
|
||||||
{submitError ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{submitError}</div> : null}
|
{submitError ? <div className="rounded-lg border border-destructive/30 bg-destructive/5 p-5 text-base text-destructive">{submitError}</div> : null}
|
||||||
|
|
||||||
|
<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">Need stock before saving this bundle?</div>
|
||||||
|
<div className="mt-1">Save the bundle as a draft first. After that, open the draft detail page to check shortages and create a GRN if needed.</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>
|
||||||
|
|
||||||
<section className="rounded-2xl border bg-card p-4 shadow-sm">
|
<section className="rounded-2xl border bg-card p-4 shadow-sm">
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@@ -222,13 +256,14 @@ function NewBundleSaleContent() {
|
|||||||
{lines.map((line) => (
|
{lines.map((line) => (
|
||||||
<TableRow key={line.key}>
|
<TableRow key={line.key}>
|
||||||
<TableCell className="min-w-72">
|
<TableCell className="min-w-72">
|
||||||
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
|
<Select value={line.itemId ? String(line.itemId) : "all"} onValueChange={(v) => {
|
||||||
const itemId = Number(v)
|
const itemId = Number(v)
|
||||||
const item = items.find((candidate) => candidate.itemId === itemId)
|
const item = items.find((candidate) => candidate.itemId === itemId)
|
||||||
updateLine(line.key, {
|
updateLine(line.key, {
|
||||||
itemId,
|
itemId,
|
||||||
unitPrice: item?.salePrice ?? line.unitPrice,
|
uomId: item?.baseUomId ?? line.uomId,
|
||||||
})
|
unitPrice: item?.salePrice ?? line.unitPrice,
|
||||||
|
})
|
||||||
}}>
|
}}>
|
||||||
<SelectTrigger className="h-9 text-sm">
|
<SelectTrigger className="h-9 text-sm">
|
||||||
<SelectValue placeholder="Select item" />
|
<SelectValue placeholder="Select item" />
|
||||||
@@ -243,7 +278,7 @@ function NewBundleSaleContent() {
|
|||||||
</Select>
|
</Select>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="min-w-40">
|
<TableCell className="min-w-40">
|
||||||
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })}>
|
<Select value={line.uomId ? String(line.uomId) : "all"} onValueChange={(v) => updateLine(line.key, { uomId: v === "all" ? 0 : Number(v) })} disabled>
|
||||||
<SelectTrigger className="h-9 text-sm">
|
<SelectTrigger className="h-9 text-sm">
|
||||||
<SelectValue placeholder="Select UOM" />
|
<SelectValue placeholder="Select UOM" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -258,12 +293,30 @@ function NewBundleSaleContent() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right w-28"><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="text-right" /></TableCell>
|
<TableCell className="text-right w-28"><Input type="number" min="0" step="0.01" value={line.qty} onChange={(e) => updateLine(line.key, { qty: Number(e.target.value) })} className="text-right" /></TableCell>
|
||||||
<TableCell className="text-right w-32"><Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} className="text-right" /></TableCell>
|
<TableCell className="text-right w-32"><Input type="number" min="0" step="0.01" value={line.unitPrice} onChange={(e) => updateLine(line.key, { unitPrice: Number(e.target.value) })} className="text-right" /></TableCell>
|
||||||
<TableCell className="text-right">{line.includeInBundle ? "Yes" : "No"}</TableCell>
|
<TableCell className="text-right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateLine(line.key, { includeInBundle: !line.includeInBundle })}
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-3 py-1 text-xs font-medium",
|
||||||
|
line.includeInBundle ? "bg-emerald-50 text-emerald-800" : "bg-muted text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{line.includeInBundle ? "Included" : "Excluded"}
|
||||||
|
</button>
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Button type="button" variant="ghost" size="icon-sm" onClick={() => removeLine(line.key)} disabled={lines.length === 1}><Minus className="size-4" /></Button>
|
<Button type="button" variant="ghost" size="icon-sm" onClick={() => removeLine(line.key)} disabled={lines.length === 1}><Minus className="size-4" /></Button>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
{lines.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="py-10 text-center text-sm text-muted-foreground">
|
||||||
|
No component rows yet. Click Add component to start.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -84,7 +84,18 @@ export default function BundleSalesPage() {
|
|||||||
.catch((err) => setError(errorMessage(err)))
|
.catch((err) => setError(errorMessage(err)))
|
||||||
}, [page, status, query, customerId, warehouseId])
|
}, [page, status, query, customerId, warehouseId])
|
||||||
|
|
||||||
const visibleRows = useMemo(() => rows ?? [], [rows])
|
const visibleRows = useMemo(
|
||||||
|
() =>
|
||||||
|
rows?.filter((row) => {
|
||||||
|
const matchesStatus = status === "All" || row.status === status
|
||||||
|
const matchesQuery =
|
||||||
|
`${row.bundleNo} ${row.bundleName} ${row.customerSnapshotName} ${row.bundleCode}`
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(query.toLowerCase())
|
||||||
|
return matchesStatus && matchesQuery
|
||||||
|
}) ?? [],
|
||||||
|
[rows, query, status]
|
||||||
|
)
|
||||||
const hasFilters = status !== "All" || query.length > 0 || customerId !== null || warehouseId !== null
|
const hasFilters = status !== "All" || query.length > 0 || customerId !== null || warehouseId !== null
|
||||||
const bundleTotal = visibleRows.reduce((sum, row) => sum + row.grandTotal, 0)
|
const bundleTotal = visibleRows.reduce((sum, row) => sum + row.grandTotal, 0)
|
||||||
const printHref = `/print/sales/bundles?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}&customerId=${customerId ?? ""}&warehouseId=${warehouseId ?? ""}`
|
const printHref = `/print/sales/bundles?status=${encodeURIComponent(status)}&q=${encodeURIComponent(query)}&customerId=${customerId ?? ""}&warehouseId=${warehouseId ?? ""}`
|
||||||
@@ -125,7 +136,12 @@ export default function BundleSalesPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-col gap-3 lg:flex-row lg:items-center lg:justify-end">
|
<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 bundle, code, or customer" className="h-12 w-full lg:max-w-sm" />
|
<Input
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
placeholder="Filter by bundle, code, or customer"
|
||||||
|
className="h-12 w-full lg:max-w-sm"
|
||||||
|
/>
|
||||||
<Button variant="outline" size="sm" className="lg:ml-auto" onClick={() => setShowFilters((v) => !v)}>
|
<Button variant="outline" size="sm" className="lg:ml-auto" onClick={() => setShowFilters((v) => !v)}>
|
||||||
<Filter className="size-4" />
|
<Filter className="size-4" />
|
||||||
Advanced
|
Advanced
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { use, useEffect, useMemo, useState } from "react"
|
import { use, useEffect, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { ArrowLeft, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
|
import { ArrowLeft, ExternalLink, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
|
||||||
|
|
||||||
import { salesApi } from "@/lib/api/sales"
|
import { salesApi } from "@/lib/api/sales"
|
||||||
import { customersApi } from "@/lib/api/customers"
|
import { customersApi } from "@/lib/api/customers"
|
||||||
@@ -157,6 +157,11 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveLineUnitPrice(line: Line): number | null {
|
||||||
|
if (line.unitPrice !== null && line.unitPrice !== undefined) return Number(line.unitPrice)
|
||||||
|
return getSuggestedUnitPrice(items, line.itemId)
|
||||||
|
}
|
||||||
|
|
||||||
function addLine() {
|
function addLine() {
|
||||||
setLines((prev) => [...prev, { ...blankLine(`line-${Date.now()}`), warehouseId: warehouseId ?? 0 }])
|
setLines((prev) => [...prev, { ...blankLine(`line-${Date.now()}`), warehouseId: warehouseId ?? 0 }])
|
||||||
}
|
}
|
||||||
@@ -181,14 +186,14 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
customerId,
|
customerId,
|
||||||
warehouseId,
|
warehouseId,
|
||||||
invoiceType,
|
invoiceType,
|
||||||
lines: lines.map((line) => ({
|
lines: lines.map((line) => ({
|
||||||
itemId: Number(line.itemId),
|
itemId: Number(line.itemId),
|
||||||
uomId: Number(line.uomId),
|
uomId: Number(line.uomId),
|
||||||
warehouseId: Number(line.warehouseId),
|
warehouseId: Number(line.warehouseId),
|
||||||
qty: Number(line.qty),
|
qty: Number(line.qty),
|
||||||
freeQty: Number(line.freeQty),
|
freeQty: Number(line.freeQty),
|
||||||
unitPrice: line.unitPrice === null ? null : Number(line.unitPrice),
|
unitPrice: resolveLineUnitPrice(line),
|
||||||
allowManualPriceOverride: line.allowManualPriceOverride,
|
allowManualPriceOverride: line.allowManualPriceOverride,
|
||||||
discountMode: line.discountMode,
|
discountMode: line.discountMode,
|
||||||
discountPct: Number(line.discountPct),
|
discountPct: Number(line.discountPct),
|
||||||
discountAmount: Number(line.discountAmount),
|
discountAmount: Number(line.discountAmount),
|
||||||
@@ -378,8 +383,19 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
{invoice.status === "Draft" && postingCheck && !postingCheck.canPost ? (
|
{invoice.status === "Draft" && postingCheck && !postingCheck.canPost ? (
|
||||||
<div className="border-t pt-5">
|
<div className="border-t pt-5">
|
||||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||||
<div className="font-semibold">Stock shortage detected before posting</div>
|
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
<div className="mt-1 text-sm">The invoice cannot be posted until every line has enough available stock in the selected warehouse.</div>
|
<div>
|
||||||
|
<div className="font-semibold">Stock shortage detected before posting</div>
|
||||||
|
<div className="mt-1 text-sm">The invoice 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">
|
<div className="mt-3 overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-left text-xs uppercase tracking-wide text-amber-900/70">
|
<thead className="text-left text-xs uppercase tracking-wide text-amber-900/70">
|
||||||
@@ -432,6 +448,15 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
<Save className="size-4" />
|
<Save className="size-4" />
|
||||||
{saving ? "Saving..." : "Save invoice"}
|
{saving ? "Saving..." : "Save invoice"}
|
||||||
</button>
|
</button>
|
||||||
|
{postingCheck && !postingCheck.canPost ? (
|
||||||
|
<Link
|
||||||
|
href="/dashboard/receiving/grn/new"
|
||||||
|
className="inline-flex h-9 items-center gap-2 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" />
|
||||||
|
Resolve stock in GRN
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
<button type="button" onClick={post} disabled={!canPost} className="inline-flex h-9 items-center gap-2 rounded-full bg-black px-4 text-sm font-medium text-white shadow-sm hover:bg-black/90 disabled:cursor-not-allowed disabled:opacity-40">
|
<button type="button" onClick={post} disabled={!canPost} className="inline-flex h-9 items-center gap-2 rounded-full bg-black px-4 text-sm font-medium text-white shadow-sm hover:bg-black/90 disabled:cursor-not-allowed disabled:opacity-40">
|
||||||
<Send className="size-4" />
|
<Send className="size-4" />
|
||||||
{postingCheck && !postingCheck.canPost ? "Resolve shortages first" : busy === "post" ? "Posting..." : "Post invoice"}
|
{postingCheck && !postingCheck.canPost ? "Resolve shortages first" : busy === "post" ? "Posting..." : "Post invoice"}
|
||||||
@@ -496,15 +521,15 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 w-28">
|
<td className="px-4 py-3 w-28">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="0"
|
min="0"
|
||||||
step="1"
|
step="1"
|
||||||
value={line.freeQty}
|
value={line.freeQty}
|
||||||
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) || 0 })}
|
onChange={(e) => updateLine(line.key, { freeQty: Number(e.target.value) || 0 })}
|
||||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
|
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 w-32">
|
<td className="px-4 py-3 w-32">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -514,6 +539,11 @@ export default function SalesInvoiceDetailPage({ params }: { params: Promise<{ i
|
|||||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
|
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
|
||||||
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
|
className="h-10 w-full rounded-lg border border-input bg-background px-3 text-right font-mono tabular-nums text-sm"
|
||||||
/>
|
/>
|
||||||
|
<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>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 w-24 text-right">
|
<td className="px-4 py-3 w-24 text-right">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -400,6 +400,11 @@ export default function NewSalesInvoicePage() {
|
|||||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
|
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"
|
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>
|
||||||
<TableCell className="px-4 py-2">
|
<TableCell className="px-4 py-2">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { use, useEffect, useMemo, useState } from "react"
|
import { use, useEffect, useMemo, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ArrowLeft, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
|
import { ArrowLeft, ExternalLink, Minus, Plus, Printer, Save, Send, X } from "lucide-react"
|
||||||
|
|
||||||
import { salesApi } from "@/lib/api/sales"
|
import { salesApi } from "@/lib/api/sales"
|
||||||
import { customersApi } from "@/lib/api/customers"
|
import { customersApi } from "@/lib/api/customers"
|
||||||
@@ -155,6 +155,11 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveLineUnitPrice(line: Line): number | null {
|
||||||
|
if (line.unitPrice !== null && line.unitPrice !== undefined) return Number(line.unitPrice)
|
||||||
|
return getSuggestedUnitPrice(items, line.itemId)
|
||||||
|
}
|
||||||
|
|
||||||
function addLine() {
|
function addLine() {
|
||||||
setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)])
|
setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)])
|
||||||
}
|
}
|
||||||
@@ -172,14 +177,14 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
|
|||||||
customerId,
|
customerId,
|
||||||
warehouseId,
|
warehouseId,
|
||||||
cashierUserId,
|
cashierUserId,
|
||||||
lines: lines.map((line) => ({
|
lines: lines.map((line) => ({
|
||||||
itemId: Number(line.itemId),
|
itemId: Number(line.itemId),
|
||||||
uomId: Number(line.uomId),
|
uomId: Number(line.uomId),
|
||||||
warehouseId: Number(line.warehouseId),
|
warehouseId: Number(line.warehouseId),
|
||||||
qty: Number(line.qty),
|
qty: Number(line.qty),
|
||||||
freeQty: Number(line.freeQty),
|
freeQty: Number(line.freeQty),
|
||||||
unitPrice: line.unitPrice === null ? null : Number(line.unitPrice),
|
unitPrice: resolveLineUnitPrice(line),
|
||||||
allowManualPriceOverride: line.allowManualPriceOverride,
|
allowManualPriceOverride: line.allowManualPriceOverride,
|
||||||
discountMode: line.discountMode,
|
discountMode: line.discountMode,
|
||||||
discountPct: Number(line.discountPct),
|
discountPct: Number(line.discountPct),
|
||||||
discountAmount: Number(line.discountAmount),
|
discountAmount: Number(line.discountAmount),
|
||||||
@@ -335,10 +340,15 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
|
|||||||
<TableCell className="px-4 py-3">
|
<TableCell className="px-4 py-3">
|
||||||
<div className="font-medium text-foreground">{line.description}</div>
|
<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="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>
|
||||||
<TableCell className="px-4 py-3">{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</TableCell>
|
<TableCell className="px-4 py-3">{uoms.find((u) => u.uomId === line.uomId)?.name ?? `#${line.uomId}`}</TableCell>
|
||||||
<TableCell className="px-4 py-3 text-right">{line.qty.toFixed(0)}</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">{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">{money.format(line.unitPrice)}</TableCell>
|
||||||
<TableCell className="px-4 py-3 text-right font-medium">{money.format(line.lineTotal)}</TableCell>
|
<TableCell className="px-4 py-3 text-right font-medium">{money.format(line.lineTotal)}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -350,8 +360,19 @@ export default function SalesSlipDetailPage({ params }: { params: Promise<{ id:
|
|||||||
|
|
||||||
{slip.status === "Draft" && postingCheck && !postingCheck.canPost ? (
|
{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="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
|
||||||
<div className="font-semibold">Stock shortage detected before posting</div>
|
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
<div className="mt-1">This slip cannot be posted until every line has enough available stock in the selected warehouse.</div>
|
<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">
|
<div className="mt-3 overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="text-left text-xs uppercase tracking-wide text-amber-900/70">
|
<thead className="text-left text-xs uppercase tracking-wide text-amber-900/70">
|
||||||
|
|||||||
@@ -111,6 +111,11 @@ export default function NewSalesSlipPage() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveLineUnitPrice(line: Line): number | null {
|
||||||
|
if (line.unitPrice !== null && line.unitPrice !== undefined) return Number(line.unitPrice)
|
||||||
|
return getSuggestedUnitPrice(items, line.itemId)
|
||||||
|
}
|
||||||
|
|
||||||
function addLine() {
|
function addLine() {
|
||||||
setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)])
|
setLines((prev) => [...prev, blankLine(`line-${Date.now()}`)])
|
||||||
}
|
}
|
||||||
@@ -162,7 +167,7 @@ export default function NewSalesSlipPage() {
|
|||||||
warehouseId: Number(line.warehouseId),
|
warehouseId: Number(line.warehouseId),
|
||||||
qty: Number(line.qty),
|
qty: Number(line.qty),
|
||||||
freeQty: Number(line.freeQty),
|
freeQty: Number(line.freeQty),
|
||||||
unitPrice: line.unitPrice === null ? null : Number(line.unitPrice),
|
unitPrice: resolveLineUnitPrice(line),
|
||||||
allowManualPriceOverride: line.allowManualPriceOverride,
|
allowManualPriceOverride: line.allowManualPriceOverride,
|
||||||
discountMode: line.discountMode,
|
discountMode: line.discountMode,
|
||||||
discountPct: Number(line.discountPct),
|
discountPct: Number(line.discountPct),
|
||||||
@@ -366,6 +371,11 @@ export default function NewSalesSlipPage() {
|
|||||||
onChange={(e) => updateLine(line.key, { unitPrice: e.target.value === "" ? null : Number(e.target.value) })}
|
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"
|
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>
|
||||||
<TableCell className="px-4 py-2">
|
<TableCell className="px-4 py-2">
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -1,69 +1,3 @@
|
|||||||
import Link from "next/link"
|
|
||||||
import {
|
|
||||||
AlertOctagon,
|
|
||||||
AlertTriangle,
|
|
||||||
ArrowLeftRight,
|
|
||||||
BadgeDollarSign,
|
|
||||||
ClipboardList,
|
|
||||||
PackageSearch,
|
|
||||||
ScrollText,
|
|
||||||
SlidersHorizontal,
|
|
||||||
type LucideIcon,
|
|
||||||
} from "lucide-react"
|
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
|
|
||||||
const areas: { title: string; description: string; href: string; icon: LucideIcon }[] = [
|
|
||||||
{
|
|
||||||
title: "Stock Enquiry",
|
|
||||||
description: "On-hand, available, on-hold, and in-transit quantities by item and warehouse.",
|
|
||||||
href: "/dashboard/stock/enquiry",
|
|
||||||
icon: PackageSearch,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Stock Ledger",
|
|
||||||
description: "The immutable, append-only movement journal — every in/out with running balance.",
|
|
||||||
href: "/dashboard/stock/ledger",
|
|
||||||
icon: ScrollText,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Valuation",
|
|
||||||
description: "FIFO cost-layer breakdown and total stock value by item and warehouse.",
|
|
||||||
href: "/dashboard/stock/valuation",
|
|
||||||
icon: BadgeDollarSign,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Transfers",
|
|
||||||
description: "Move stock between warehouses: create, dispatch, and receive (in-transit).",
|
|
||||||
href: "/dashboard/stock/transfers",
|
|
||||||
icon: ArrowLeftRight,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Adjustments",
|
|
||||||
description: "Increase, decrease, or write off stock with a mandatory reason code.",
|
|
||||||
href: "/dashboard/stock/adjustments",
|
|
||||||
icon: SlidersHorizontal,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Counts",
|
|
||||||
description: "Cycle or full physical counts — snapshot, enter counts, post variance.",
|
|
||||||
href: "/dashboard/stock/counts",
|
|
||||||
icon: ClipboardList,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Reorder Alerts",
|
|
||||||
description: "Items at or below their reorder point, with a one-click requisition.",
|
|
||||||
href: "/dashboard/stock/reorder-alerts",
|
|
||||||
icon: AlertTriangle,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Wastage",
|
|
||||||
description: "Damage, theft/loss, and expiry write-offs — reason-coded adjustments with a totals report.",
|
|
||||||
href: "/dashboard/stock/wastage",
|
|
||||||
icon: AlertOctagon,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function StockHubPage() {
|
export default function StockHubPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
@@ -73,26 +7,6 @@ export default function StockHubPage() {
|
|||||||
FIFO-costed stock across multiple warehouses (FR-STK-01..14).
|
FIFO-costed stock across multiple warehouses (FR-STK-01..14).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
|
||||||
{areas.map((area) => (
|
|
||||||
<Link key={area.href} href={area.href}>
|
|
||||||
<Card className="h-full transition-shadow hover:shadow-md">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex size-11 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
|
||||||
<area.icon className="size-5" />
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-lg">{area.title}</CardTitle>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<p className="text-base text-muted-foreground">{area.description}</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { useEffect, useState } from "react"
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { usePathname } from "next/navigation"
|
import { usePathname } from "next/navigation"
|
||||||
import {
|
import {
|
||||||
|
AlertOctagon,
|
||||||
|
AlertTriangle,
|
||||||
|
ArrowLeftRight,
|
||||||
Banknote,
|
Banknote,
|
||||||
BadgeDollarSign,
|
BadgeDollarSign,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
@@ -29,6 +32,7 @@ import {
|
|||||||
Menu,
|
Menu,
|
||||||
Package,
|
Package,
|
||||||
PackageCheck,
|
PackageCheck,
|
||||||
|
PackageSearch,
|
||||||
PackageX,
|
PackageX,
|
||||||
PlayCircle,
|
PlayCircle,
|
||||||
PieChart,
|
PieChart,
|
||||||
@@ -36,6 +40,7 @@ import {
|
|||||||
ReceiptText,
|
ReceiptText,
|
||||||
Ruler,
|
Ruler,
|
||||||
Scale,
|
Scale,
|
||||||
|
ScrollText,
|
||||||
Settings,
|
Settings,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
@@ -116,7 +121,26 @@ const navItems: {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
{ title: "Receiving", code: "receiving", href: "/dashboard/receiving/grn", icon: PackageCheck, chevron: true },
|
||||||
{ title: "Stock", code: "stock", href: "/dashboard/stock", icon: Warehouse, chevron: true },
|
{
|
||||||
|
title: "Stock",
|
||||||
|
code: "stock",
|
||||||
|
href: "/dashboard/stock",
|
||||||
|
// Clicking "Stock" itself lands on Stock Ledger — the hub page underneath has nothing on
|
||||||
|
// it (its card grid was removed once the sidebar grew these sub-items), same as Procurement.
|
||||||
|
landingHref: "/dashboard/stock/ledger",
|
||||||
|
icon: Warehouse,
|
||||||
|
chevron: true,
|
||||||
|
children: [
|
||||||
|
{ title: "Stock Ledger", code: "stock.ledger", href: "/dashboard/stock/ledger", icon: ScrollText },
|
||||||
|
{ title: "Stock Enquiry", code: "stock.enquiry", href: "/dashboard/stock/enquiry", icon: PackageSearch },
|
||||||
|
{ title: "Valuation", code: "stock.valuation", href: "/dashboard/stock/valuation", icon: BadgeDollarSign },
|
||||||
|
{ title: "Transfers", code: "stock.transfers", href: "/dashboard/stock/transfers", icon: ArrowLeftRight },
|
||||||
|
{ title: "Adjustments", code: "stock.adjustments", href: "/dashboard/stock/adjustments", icon: SlidersHorizontal },
|
||||||
|
{ title: "Counts", code: "stock.counts", href: "/dashboard/stock/counts", icon: ClipboardList },
|
||||||
|
{ title: "Reorder Alerts", code: "stock.reorder-alerts", href: "/dashboard/stock/reorder-alerts", icon: AlertTriangle },
|
||||||
|
{ title: "Wastage", code: "stock.wastage", href: "/dashboard/stock/wastage", icon: AlertOctagon },
|
||||||
|
],
|
||||||
|
},
|
||||||
{ title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true },
|
{ title: "Warehouses", code: "warehouses", href: "/dashboard/warehouse", icon: Building2, chevron: true },
|
||||||
{ title: "Orders", code: "orders", href: "/dashboard/orders", icon: ShoppingCart, chevron: true },
|
{ title: "Orders", code: "orders", href: "/dashboard/orders", icon: ShoppingCart, chevron: true },
|
||||||
{
|
{
|
||||||
@@ -402,14 +426,15 @@ export function AppSidebar() {
|
|||||||
// flashing the full menu to a restricted role. Once resolved, a nav item
|
// flashing the full menu to a restricted role. Once resolved, a nav item
|
||||||
// is visible if its own code is granted, or (for parents) if any child is.
|
// is visible if its own code is granted, or (for parents) if any child is.
|
||||||
//
|
//
|
||||||
// "procurement", "hrm", "sales" and "production" are exempted from that check (frontend-only): no
|
// "procurement", "hrm", "sales", "production" and "stock" are exempted from that check
|
||||||
// role is currently seeded with NAV:procurement/NAV:hrm/NAV:production or their children
|
// (frontend-only): no role is currently seeded with NAV:procurement/NAV:hrm/NAV:production
|
||||||
// server-side, which would hide the whole section for everyone. Remove each bypass once roles are granted
|
// or their children server-side (stock's children specifically have no SubNavItem rows at
|
||||||
// the permission properly (Settings → Roles → Sidebar permissions) or a backend seed
|
// all yet), which would hide the whole section for everyone. Remove each bypass once roles
|
||||||
// grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility
|
// are granted the permission properly (Settings → Roles → Sidebar permissions) or a backend
|
||||||
|
// seed grants it. This is also flagged in 02-SECURITY.md as the AR-09 sidebar-visibility
|
||||||
// stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce
|
// stopgap for HRM's salary/PII data — it hides HRM from the UI but doesn't enforce
|
||||||
// anything server-side.
|
// anything server-side.
|
||||||
const bypassCodes = new Set(["procurement", "sales", "hrm", "production"])
|
const bypassCodes = new Set(["procurement", "sales", "hrm", "production", "stock"])
|
||||||
const visibleItems = loading
|
const visibleItems = loading
|
||||||
? []
|
? []
|
||||||
: navItems
|
: navItems
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { usePathname, useRouter } from "next/navigation"
|
import { usePathname, useRouter } from "next/navigation"
|
||||||
import { ArrowLeft, Bell, LogOut, Settings, User } from "lucide-react"
|
import { ArrowLeft, Bell, LogOut, Settings, User } from "lucide-react"
|
||||||
@@ -165,9 +165,12 @@ export function Header() {
|
|||||||
const [notifications, setNotifications] = useState(initialNotifications)
|
const [notifications, setNotifications] = useState(initialNotifications)
|
||||||
const unreadCount = notifications.filter((n) => n.unread).length
|
const unreadCount = notifications.filter((n) => n.unread).length
|
||||||
|
|
||||||
// Read after mount, not during render: localStorage doesn't exist on the server, and
|
// Read after mount so the first client render matches the server render.
|
||||||
// reading it while rendering would desync the hydration pass.
|
const [user, setUser] = useState<AuthUser | null>(null)
|
||||||
const [user] = useState<AuthUser | null>(() => getStoredUser())
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUser(getStoredUser())
|
||||||
|
}, [])
|
||||||
|
|
||||||
const markAllAsRead = () =>
|
const markAllAsRead = () =>
|
||||||
setNotifications((prev) => prev.map((n) => ({ ...n, unread: false })))
|
setNotifications((prev) => prev.map((n) => ({ ...n, unread: false })))
|
||||||
@@ -278,16 +281,18 @@ export function Header() {
|
|||||||
<DropdownMenuTrigger className="flex items-center gap-2 rounded-full py-1 pr-2 pl-1 outline-none hover:bg-muted">
|
<DropdownMenuTrigger className="flex items-center gap-2 rounded-full py-1 pr-2 pl-1 outline-none hover:bg-muted">
|
||||||
<Avatar>
|
<Avatar>
|
||||||
<AvatarFallback className="bg-primary/10 font-semibold text-primary">
|
<AvatarFallback className="bg-primary/10 font-semibold text-primary">
|
||||||
{initials(displayName(user))}
|
{user ? initials(displayName(user)) : "?"}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span className="hidden text-sm font-semibold text-foreground sm:block">
|
<span className="hidden text-sm font-semibold text-foreground sm:block">
|
||||||
{displayName(user)}
|
{user ? displayName(user) : "Signed in"}
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-80 p-2">
|
<DropdownMenuContent align="end" className="w-80 p-2">
|
||||||
<div className="px-2 py-2.5">
|
<div className="px-2 py-2.5">
|
||||||
<p className="text-base font-semibold text-foreground">{displayName(user)}</p>
|
<p className="text-base font-semibold text-foreground">
|
||||||
|
{user ? displayName(user) : "Signed in"}
|
||||||
|
</p>
|
||||||
{user?.email && <p className="text-sm font-normal text-muted-foreground">{user.email}</p>}
|
{user?.email && <p className="text-sm font-normal text-muted-foreground">{user.email}</p>}
|
||||||
</div>
|
</div>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Lightbulb, PackageCheck } from "lucide-react"
|
import { Lightbulb, PackageCheck } from "lucide-react"
|
||||||
@@ -11,7 +11,7 @@ export function FreeIssuePromotionSuggestions({ suggestion }: { suggestion: Sale
|
|||||||
if (!suggestion || suggestion.lines.length === 0) {
|
if (!suggestion || suggestion.lines.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-dashed p-6 text-sm text-muted-foreground">
|
<div className="rounded-2xl border border-dashed p-6 text-sm text-muted-foreground">
|
||||||
No free-issue promotion suggestions were generated for this slip yet.
|
No free-issue suggestions were generated for this slip yet.
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -22,10 +22,10 @@ export function FreeIssuePromotionSuggestions({ suggestion }: { suggestion: Sale
|
|||||||
<div>
|
<div>
|
||||||
<div className="inline-flex items-center gap-2 rounded-full bg-amber-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-amber-700">
|
<div className="inline-flex items-center gap-2 rounded-full bg-amber-500/10 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-amber-700">
|
||||||
<Lightbulb className="size-3.5" />
|
<Lightbulb className="size-3.5" />
|
||||||
Backend suggestion
|
Suggestion only
|
||||||
</div>
|
</div>
|
||||||
<h2 className="mt-2 text-lg font-semibold text-foreground">Free-issue promotions</h2>
|
<h2 className="mt-2 text-lg font-semibold text-foreground">Free-issue promotions</h2>
|
||||||
<p className="text-sm text-muted-foreground">The server suggests reward quantities and alternate products for this slip.</p>
|
<p className="text-sm text-muted-foreground">These are suggestions only. You can review them before creating a free issue.</p>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/dashboard/sales/free-issues/new" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
<Link href="/dashboard/sales/free-issues/new" className={cn(buttonVariants({ variant: "outline", size: "sm" }))}>
|
||||||
Create free issue
|
Create free issue
|
||||||
@@ -40,14 +40,12 @@ export function FreeIssuePromotionSuggestions({ suggestion }: { suggestion: Sale
|
|||||||
<div className="font-medium text-foreground">{line.itemName}</div>
|
<div className="font-medium text-foreground">{line.itemName}</div>
|
||||||
<div className="text-sm text-muted-foreground">{line.itemSku} • Qty {line.qty}</div>
|
<div className="text-sm text-muted-foreground">{line.itemSku} • Qty {line.qty}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold text-primary">
|
<div className="rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold text-primary">Free issue</div>
|
||||||
Buy {line.triggerQty} get {line.suggestedFreeQty} free
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2 text-sm">
|
<div className="mt-3 flex flex-wrap gap-2 text-sm">
|
||||||
{line.rewardOptions.map((option, index) => (
|
{line.rewardOptions.map((option, index) => (
|
||||||
<span key={option.itemId} className={cn("inline-flex items-center gap-1 rounded-full border px-3 py-1", index === 0 && "border-primary bg-primary/5 text-primary") }>
|
<span key={option.itemId} className={cn("inline-flex items-center gap-1 rounded-full border px-3 py-1", index === 0 && "border-primary bg-primary/5 text-primary")}>
|
||||||
{index === 0 ? <PackageCheck className="size-3.5" /> : null}
|
{index === 0 ? <PackageCheck className="size-3.5" /> : null}
|
||||||
{option.name}
|
{option.name}
|
||||||
</span>
|
</span>
|
||||||
@@ -55,11 +53,11 @@ export function FreeIssuePromotionSuggestions({ suggestion }: { suggestion: Sale
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 text-sm text-muted-foreground">
|
<div className="mt-3 text-sm text-muted-foreground">
|
||||||
Suggested free qty: <span className="font-medium text-foreground">{line.suggestedFreeQty.toFixed(2)}</span>
|
Free qty: <span className="font-medium text-foreground">{line.suggestedFreeQty.toFixed(2)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user