Merge pull request 'Dev' (#53) from dev into production

Reviewed-on: #53
This commit was merged in pull request #53.
This commit is contained in:
2026-09-29 12:14:53 +00:00
35 changed files with 5671 additions and 265 deletions
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc; using Distribution.Models;
using Microsoft.AspNetCore.Mvc;
namespace Distribution.Features.DeliveryRide; namespace Distribution.Features.DeliveryRide;
@@ -26,6 +27,16 @@ public class DeliveryTaskController(DeliveryTaskService service) : ControllerBas
return Ok(result); return Ok(result);
} }
[HttpGet("delivery-tasks")]
public async Task<IActionResult> GetAll(
[FromQuery] DeliveryTaskStatus? status,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20)
{
var result = await service.GetAllAsync(status, page, pageSize);
return Ok(result);
}
[HttpGet("delivery-tasks/{id:guid}")] [HttpGet("delivery-tasks/{id:guid}")]
public async Task<IActionResult> GetById(Guid id) public async Task<IActionResult> GetById(Guid id)
{ {
@@ -26,6 +26,27 @@ public class DeliveryTaskRepository(AppDBContext context) : IDeliveryTaskReposit
.OrderBy(t => t.Sequence) .OrderBy(t => t.Sequence)
.ToListAsync(); .ToListAsync();
public Task<List<DeliveryTask>> GetAllAsync(DeliveryTaskStatus? status, int page, int pageSize)
{
var query = context.DeliveryTasks
.Include(t => t.ShopPO)
.ThenInclude(o => o.Store)
.Include(t => t.ShopPO)
.ThenInclude(o => o.Items.Where(i => i.IsActive))
.ThenInclude(i => i.Item)
.AsQueryable();
if (status.HasValue) query = query.Where(t => t.Status == status.Value);
return query.OrderByDescending(t => t.CreatedAt)
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
}
public Task<int> GetCountAsync(DeliveryTaskStatus? status)
{
var query = context.DeliveryTasks.AsQueryable();
if (status.HasValue) query = query.Where(t => t.Status == status.Value);
return query.CountAsync();
}
public async Task<bool> ExistsForOrderAndRideAsync(Guid shopPOId, Guid rideId) => public async Task<bool> ExistsForOrderAndRideAsync(Guid shopPOId, Guid rideId) =>
await context.DeliveryTasks await context.DeliveryTasks
.AnyAsync(t => t.FK_ShopPOId == shopPOId && t.FK_RideId == rideId); .AnyAsync(t => t.FK_ShopPOId == shopPOId && t.FK_RideId == rideId);
@@ -87,6 +87,13 @@ public class DeliveryTaskService(
return tasks.Select(ToResponse).ToList(); return tasks.Select(ToResponse).ToList();
} }
public async Task<PagedResponse<DeliveryTaskResponse>> GetAllAsync(DeliveryTaskStatus? status, int page, int pageSize)
{
var items = await repo.GetAllAsync(status, page, pageSize);
var total = await repo.GetCountAsync(status);
return new PagedResponse<DeliveryTaskResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
}
public async Task<DeliveryTaskResponse?> GetByIdAsync(Guid id) public async Task<DeliveryTaskResponse?> GetByIdAsync(Guid id)
{ {
var task = await repo.GetByIdAsync(id); var task = await repo.GetByIdAsync(id);
@@ -6,6 +6,8 @@ public interface IDeliveryTaskRepository
{ {
Task<DeliveryTask?> GetByIdAsync(Guid id); Task<DeliveryTask?> GetByIdAsync(Guid id);
Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId); Task<List<DeliveryTask>> GetByRideIdAsync(Guid rideId);
Task<List<DeliveryTask>> GetAllAsync(DeliveryTaskStatus? status, int page, int pageSize);
Task<int> GetCountAsync(DeliveryTaskStatus? status);
Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId); Task<bool> ExistsForOrderAndRideAsync(Guid orderId, Guid rideId);
Task AddAsync(DeliveryTask task); Task AddAsync(DeliveryTask task);
void Update(DeliveryTask task); void Update(DeliveryTask task);
@@ -6,8 +6,9 @@ public interface IInvoiceRepository
{ {
Task<Models.Invoice?> GetByIdAsync(Guid id); Task<Models.Invoice?> GetByIdAsync(Guid id);
Task<Models.Invoice?> GetByShopPOIdAsync(Guid shopPOId); Task<Models.Invoice?> GetByShopPOIdAsync(Guid shopPOId);
Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, int page, int pageSize); Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize);
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status); Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus);
Task<List<Models.Invoice>> GetOutstandingAsync();
Task AddAsync(Models.Invoice invoice); Task AddAsync(Models.Invoice invoice);
void Update(Models.Invoice invoice); void Update(Models.Invoice invoice);
} }
@@ -14,10 +14,18 @@ public class InvoiceController(InvoiceService service, ICurrentUserService curre
[FromQuery] Guid? routeId, [FromQuery] Guid? routeId,
[FromQuery] Guid? shopId, [FromQuery] Guid? shopId,
[FromQuery] InvoiceStatus? status, [FromQuery] InvoiceStatus? status,
[FromQuery] PaymentStatus? paymentStatus,
[FromQuery] int page = 1, [FromQuery] int page = 1,
[FromQuery] int pageSize = 20) [FromQuery] int pageSize = 20)
{ {
var result = await service.GetAllAsync(agentId, routeId, shopId, status, page, pageSize); var result = await service.GetAllAsync(agentId, routeId, shopId, status, paymentStatus, page, pageSize);
return Ok(result);
}
[HttpGet("outstanding-summary")]
public async Task<IActionResult> GetOutstandingSummary()
{
var result = await service.GetOutstandingSummaryAsync();
return Ok(result); return Ok(result);
} }
@@ -21,3 +21,5 @@ public record InvoiceResponse(
decimal TotalAmount, decimal TotalAmount,
bool IsActive, bool IsActive,
DateTime CreatedAt); DateTime CreatedAt);
public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount);
@@ -18,27 +18,35 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
.Include(i => i.ShopPO) .Include(i => i.ShopPO)
.FirstOrDefaultAsync(i => i.FK_ShopPOId == shopPOId && i.IsActive); .FirstOrDefaultAsync(i => i.FK_ShopPOId == shopPOId && i.IsActive);
public async Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, int page, int pageSize) => public async Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize) =>
await context.Invoices await context.Invoices
.Include(i => i.ShopPO).ThenInclude(p => p.Store) .Include(i => i.ShopPO).ThenInclude(p => p.Store)
.Where(i => i.IsActive .Where(i => i.IsActive
&& (agentId == null || i.ShopPO.RaisedById == agentId) && (agentId == null || i.ShopPO.RaisedById == agentId)
&& (routeId == null || i.ShopPO.Store.FK_RouteId == routeId) && (routeId == null || i.ShopPO.Store.FK_RouteId == routeId)
&& (shopId == null || i.ShopPO.FK_StoreId == shopId) && (shopId == null || i.ShopPO.FK_StoreId == shopId)
&& (status == null || i.Status == status)) && (status == null || i.Status == status)
&& (paymentStatus == null || i.PaymentStatus == paymentStatus))
.OrderByDescending(i => i.CreatedAt) .OrderByDescending(i => i.CreatedAt)
.Skip((page - 1) * pageSize) .Skip((page - 1) * pageSize)
.Take(pageSize) .Take(pageSize)
.ToListAsync(); .ToListAsync();
public async Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status) => public async Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus) =>
await context.Invoices await context.Invoices
.Include(i => i.ShopPO).ThenInclude(p => p.Store) .Include(i => i.ShopPO).ThenInclude(p => p.Store)
.CountAsync(i => i.IsActive .CountAsync(i => i.IsActive
&& (agentId == null || i.ShopPO.RaisedById == agentId) && (agentId == null || i.ShopPO.RaisedById == agentId)
&& (routeId == null || i.ShopPO.Store.FK_RouteId == routeId) && (routeId == null || i.ShopPO.Store.FK_RouteId == routeId)
&& (shopId == null || i.ShopPO.FK_StoreId == shopId) && (shopId == null || i.ShopPO.FK_StoreId == shopId)
&& (status == null || i.Status == status)); && (status == null || i.Status == status)
&& (paymentStatus == null || i.PaymentStatus == paymentStatus));
public async Task<List<Models.Invoice>> GetOutstandingAsync() =>
await context.Invoices
.Include(i => i.Payments.Where(p => p.IsActive))
.Where(i => i.IsActive && i.PaymentStatus != PaymentStatus.Paid)
.ToListAsync();
public async Task AddAsync(Models.Invoice invoice) => public async Task AddAsync(Models.Invoice invoice) =>
await context.Invoices.AddAsync(invoice); await context.Invoices.AddAsync(invoice);
@@ -24,13 +24,21 @@ public class InvoiceService(
return invoice is null ? null : ToResponse(invoice); return invoice is null ? null : ToResponse(invoice);
} }
public async Task<PagedResponse<InvoiceResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, int page, int pageSize) public async Task<PagedResponse<InvoiceResponse>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, PaymentStatus? paymentStatus, int page, int pageSize)
{ {
var total = await repo.GetCountAsync(agentId, routeId, shopId, status); var total = await repo.GetCountAsync(agentId, routeId, shopId, status, paymentStatus);
var items = await repo.GetAllAsync(agentId, routeId, shopId, status, page, pageSize); var items = await repo.GetAllAsync(agentId, routeId, shopId, status, paymentStatus, page, pageSize);
return new PagedResponse<InvoiceResponse>(page, pageSize, total, items.Select(ToResponse).ToList()); return new PagedResponse<InvoiceResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
} }
public async Task<OutstandingPaymentsResponse> GetOutstandingSummaryAsync()
{
var invoices = await repo.GetOutstandingAsync();
var count = invoices.Count;
var outstanding = invoices.Sum(i => i.TotalAmount - i.Payments.Sum(p => p.Amount));
return new OutstandingPaymentsResponse(count, outstanding);
}
public async Task<InvoiceResponse> PrintAsync(Guid id, Guid printedBy, CancellationToken ct = default) public async Task<InvoiceResponse> PrintAsync(Guid id, Guid printedBy, CancellationToken ct = default)
{ {
var invoice = await repo.GetByIdAsync(id) var invoice = await repo.GetByIdAsync(id)
+6 -2
View File
@@ -11,7 +11,8 @@ public record CreateItemRequest(
Guid? BrandId, Guid? BrandId,
Guid BaseUomId, Guid BaseUomId,
decimal? SalePrice, decimal? SalePrice,
List<UomConversionRequest>? Conversions); List<UomConversionRequest>? Conversions,
decimal? ReorderLevel = null);
public record UpdateItemRequest( public record UpdateItemRequest(
string Name, string Name,
@@ -21,7 +22,8 @@ public record UpdateItemRequest(
Guid? BrandId, Guid? BrandId,
Guid BaseUomId, Guid BaseUomId,
decimal? SalePrice, decimal? SalePrice,
bool IsActive = true); bool IsActive = true,
decimal? ReorderLevel = null);
public record ReplaceConversionsRequest(List<UomConversionRequest> Conversions); public record ReplaceConversionsRequest(List<UomConversionRequest> Conversions);
@@ -40,6 +42,7 @@ public record ItemSummaryResponse(
Guid BaseUomId, Guid BaseUomId,
string BaseUomName, string BaseUomName,
decimal? SalePrice, decimal? SalePrice,
decimal? ReorderLevel,
Models.SyncSource SyncSource, Models.SyncSource SyncSource,
bool IsActive, bool IsActive,
DateTime CreatedAt); DateTime CreatedAt);
@@ -58,6 +61,7 @@ public record ItemDetailResponse(
Guid BaseUomId, Guid BaseUomId,
string BaseUomName, string BaseUomName,
decimal? SalePrice, decimal? SalePrice,
decimal? ReorderLevel,
Models.SyncSource SyncSource, Models.SyncSource SyncSource,
bool IsActive, bool IsActive,
DateTime CreatedAt, DateTime CreatedAt,
+4 -2
View File
@@ -13,7 +13,7 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
i.FK_SubCategoryId, i.SubCategory?.Name, i.FK_SubCategoryId, i.SubCategory?.Name,
i.FK_BrandId, i.Brand?.Name, i.FK_BrandId, i.Brand?.Name,
i.FK_BaseUomId, i.BaseUom.Name, i.FK_BaseUomId, i.BaseUom.Name,
i.SalePrice, i.SyncSource, i.IsActive, i.CreatedAt); i.SalePrice, i.ReorderLevel, i.SyncSource, i.IsActive, i.CreatedAt);
private static ItemDetailResponse MapDetail(Models.Item i) => private static ItemDetailResponse MapDetail(Models.Item i) =>
new(i.Id, i.Sku, i.Name, i.Description, new(i.Id, i.Sku, i.Name, i.Description,
@@ -21,7 +21,7 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
i.FK_SubCategoryId, i.SubCategory?.Name, i.FK_SubCategoryId, i.SubCategory?.Name,
i.FK_BrandId, i.Brand?.Name, i.FK_BrandId, i.Brand?.Name,
i.FK_BaseUomId, i.BaseUom.Name, i.FK_BaseUomId, i.BaseUom.Name,
i.SalePrice, i.SyncSource, i.IsActive, i.CreatedAt, i.SalePrice, i.ReorderLevel, i.SyncSource, i.IsActive, i.CreatedAt,
i.Conversions.Select(MapConversion).ToList()); i.Conversions.Select(MapConversion).ToList());
public async Task<List<ItemSummaryResponse>> GetAllAsync(Guid? categoryId = null, Guid? subCategoryId = null, Guid? brandId = null) => public async Task<List<ItemSummaryResponse>> GetAllAsync(Guid? categoryId = null, Guid? subCategoryId = null, Guid? brandId = null) =>
@@ -51,6 +51,7 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
FK_BrandId = request.BrandId, FK_BrandId = request.BrandId,
FK_BaseUomId = request.BaseUomId, FK_BaseUomId = request.BaseUomId,
SalePrice = request.SalePrice, SalePrice = request.SalePrice,
ReorderLevel = request.ReorderLevel,
SyncSource = Models.SyncSource.Manual SyncSource = Models.SyncSource.Manual
}; };
@@ -84,6 +85,7 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
item.FK_BrandId = request.BrandId; item.FK_BrandId = request.BrandId;
item.FK_BaseUomId = request.BaseUomId; item.FK_BaseUomId = request.BaseUomId;
item.SalePrice = request.SalePrice; item.SalePrice = request.SalePrice;
item.ReorderLevel = request.ReorderLevel;
item.IsActive = request.IsActive; item.IsActive = request.IsActive;
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
@@ -14,6 +14,8 @@ public record ShopPOResponse(
Guid Id, Guid Id,
Guid StoreId, Guid StoreId,
string StoreName, string StoreName,
string? StoreAddress,
string? StoreContactNumber,
Guid RaisedById, Guid RaisedById,
Guid? InvoiceId, Guid? InvoiceId,
decimal TotalAmount, decimal TotalAmount,
@@ -14,6 +14,7 @@ public class ShopPOService(
{ {
private static ShopPOResponse ToResponse(Models.ShopPO p, Dictionary<Guid, (string Name, string Role)>? users = null) => private static ShopPOResponse ToResponse(Models.ShopPO p, Dictionary<Guid, (string Name, string Role)>? users = null) =>
new(p.Id, p.FK_StoreId, p.Store?.StoreName ?? string.Empty, new(p.Id, p.FK_StoreId, p.Store?.StoreName ?? string.Empty,
p.Store?.StoreAddress, p.Store?.ContactNumber,
p.RaisedById, p.InvoiceId, p.TotalAmount, p.Status, p.RaisedById, p.InvoiceId, p.TotalAmount, p.Status,
p.ConfirmedAt, p.ConfirmedBy, p.FulfilledAt, p.ConfirmedAt, p.ConfirmedBy, p.FulfilledAt,
p.Items.Where(i => i.IsActive).Select(i => p.Items.Where(i => i.IsActive).Select(i =>
@@ -10,6 +10,7 @@ public interface IStockRepository
Task<List<StockBalance>> GetBalancesAsync(Guid? locationId, LocationType? locationType, Guid? itemId, int page, int pageSize); Task<List<StockBalance>> GetBalancesAsync(Guid? locationId, LocationType? locationType, Guid? itemId, int page, int pageSize);
Task<int> GetBalancesCountAsync(Guid? locationId, LocationType? locationType, Guid? itemId); Task<int> GetBalancesCountAsync(Guid? locationId, LocationType? locationType, Guid? itemId);
Task<List<StockBalance>> GetBalancesByItemAsync(Guid itemId); Task<List<StockBalance>> GetBalancesByItemAsync(Guid itemId);
Task<List<StockBalance>> GetLowStockAsync(Guid? locationId);
Task AddBalanceAsync(StockBalance balance); Task AddBalanceAsync(StockBalance balance);
void UpdateBalance(StockBalance balance); void UpdateBalance(StockBalance balance);
@@ -22,6 +22,13 @@ public class StockController(StockService service, ICurrentUserService currentUs
return Ok(result); return Ok(result);
} }
[HttpGet("low-stock")]
public async Task<IActionResult> GetLowStock([FromQuery] Guid? locationId)
{
var result = await service.GetLowStockAsync(locationId);
return Ok(result);
}
[HttpGet("balances/{itemId}/total")] [HttpGet("balances/{itemId}/total")]
public async Task<IActionResult> GetItemTotal(Guid itemId) public async Task<IActionResult> GetItemTotal(Guid itemId)
{ {
+8
View File
@@ -21,6 +21,14 @@ public record StockTotalResponse(
decimal TotalQuantity, decimal TotalQuantity,
List<StockBalanceResponse> Breakdown); List<StockBalanceResponse> Breakdown);
public record LowStockItemResponse(
Guid ItemId,
string ItemName,
Guid LocationId,
string LocationName,
decimal Quantity,
decimal ReorderLevel);
// Movement // Movement
public record StockMovementResponse( public record StockMovementResponse(
Guid Id, Guid Id,
@@ -41,6 +41,17 @@ public class StockRepository(AppDBContext db) : IStockRepository
.Where(b => b.FK_ItemId == itemId) .Where(b => b.FK_ItemId == itemId)
.ToListAsync(); .ToListAsync();
public Task<List<StockBalance>> GetLowStockAsync(Guid? locationId)
{
var query = db.StockBalances
.Include(b => b.Item)
.Where(b => b.LocationType == LocationType.Warehouse
&& b.Item.ReorderLevel != null
&& b.Quantity < b.Item.ReorderLevel);
if (locationId.HasValue) query = query.Where(b => b.LocationId == locationId.Value);
return query.OrderBy(b => b.Quantity).ToListAsync();
}
public async Task AddBalanceAsync(StockBalance balance) => public async Task AddBalanceAsync(StockBalance balance) =>
await db.StockBalances.AddAsync(balance); await db.StockBalances.AddAsync(balance);
@@ -87,6 +87,15 @@ public class StockService(
items.Select(b => MapBalance(b, names.GetValueOrDefault(b.LocationId, "—"))).ToList()); items.Select(b => MapBalance(b, names.GetValueOrDefault(b.LocationId, "—"))).ToList());
} }
public async Task<List<LowStockItemResponse>> GetLowStockAsync(Guid? locationId)
{
var balances = await repo.GetLowStockAsync(locationId);
var names = await ResolveLocationNamesAsync(balances.Select(b => (b.LocationId, b.LocationType)));
return balances.Select(b => new LowStockItemResponse(
b.FK_ItemId, b.Item.Name, b.LocationId, names.GetValueOrDefault(b.LocationId, "—"),
b.Quantity, b.Item.ReorderLevel!.Value)).ToList();
}
public async Task<StockTotalResponse?> GetItemTotalAsync(Guid itemId) public async Task<StockTotalResponse?> GetItemTotalAsync(Guid itemId)
{ {
var item = await itemRepo.GetByIdAsync(itemId); var item = await itemRepo.GetByIdAsync(itemId);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,57 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddItemReorderLevel : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "ReorderLevel",
table: "Items",
type: "decimal(65,30)",
nullable: true);
migrationBuilder.UpdateData(
table: "Items",
keyColumn: "Id",
keyValue: new Guid("23000000-0000-0000-0000-000000000001"),
column: "ReorderLevel",
value: null);
migrationBuilder.UpdateData(
table: "Items",
keyColumn: "Id",
keyValue: new Guid("23000000-0000-0000-0000-000000000002"),
column: "ReorderLevel",
value: null);
migrationBuilder.UpdateData(
table: "Items",
keyColumn: "Id",
keyValue: new Guid("23000000-0000-0000-0000-000000000003"),
column: "ReorderLevel",
value: null);
migrationBuilder.UpdateData(
table: "Items",
keyColumn: "Id",
keyValue: new Guid("23000000-0000-0000-0000-000000000004"),
column: "ReorderLevel",
value: null);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ReorderLevel",
table: "Items");
}
}
}
@@ -634,6 +634,10 @@ namespace Distribution.Migrations
.IsRequired() .IsRequired()
.HasColumnType("longtext"); .HasColumnType("longtext");
b.Property<decimal?>("ReorderLevel")
.HasColumnType("decimal(65,30)");
b.Property<decimal?>("SalePrice") b.Property<decimal?>("SalePrice")
.HasColumnType("decimal(65,30)"); .HasColumnType("decimal(65,30)");
+3
View File
@@ -26,6 +26,9 @@ namespace Distribution.Models
public decimal? SalePrice { get; set; } public decimal? SalePrice { get; set; }
/// <summary>Warehouse quantity threshold below which the item shows up in low-stock alerts; null disables alerting for this item.</summary>
public decimal? ReorderLevel { get; set; }
/// <summary>Cost/purchase price, used to value free-goods scheme redemptions for getSchemeROI.</summary> /// <summary>Cost/purchase price, used to value free-goods scheme redemptions for getSchemeROI.</summary>
public decimal? CostPrice { get; set; } public decimal? CostPrice { get; set; }
@@ -0,0 +1,665 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon, Cancel01Icon } from '@hugeicons/core-free-icons'
import { ResponsiveGridLayout, DEFAULT_BREAKPOINTS, DEFAULT_COLS, useContainerWidth } from 'react-grid-layout'
import type { Layout, LayoutItem } from 'react-grid-layout'
import 'react-grid-layout/css/styles.css'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from '@/components/ui/toast'
import {
shopPos,
rides,
loadingSheets,
deliveryTasks,
stock,
invoices,
getApiError,
getPaginatedItems,
} from '@/lib/api/index'
import type { LowStockItemResponse, StockMovementResponse } from '@/types/api'
const STORAGE_KEY = 'distribution-dashboard-widgets-v3'
const ROW_HEIGHT = 36
const GRID_MARGIN: [number, number] = [16, 16]
type Breakpoint = keyof typeof DEFAULT_BREAKPOINTS
const BREAKPOINT_ORDER = Object.keys(DEFAULT_BREAKPOINTS) as Breakpoint[]
type Category = 'sales' | 'ops' | 'stock' | 'finance'
type Size = 'sm' | 'md' | 'lg'
interface WidgetMeta {
id: string
cat: Category
title: string
size: Size
}
const CATEGORY_META: Record<Category, { label: string; text: string; bg: string; solid: string }> = {
sales: { label: 'Sales & Fulfillment', text: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-50 dark:bg-blue-500/10', solid: 'bg-blue-600' },
ops: { label: 'Operations', text: 'text-violet-600 dark:text-violet-400', bg: 'bg-violet-50 dark:bg-violet-500/10', solid: 'bg-violet-600' },
stock: { label: 'Stock', text: 'text-amber-600 dark:text-amber-400', bg: 'bg-amber-50 dark:bg-amber-500/10', solid: 'bg-amber-600' },
finance: { label: 'Finance', text: 'text-emerald-600 dark:text-emerald-400', bg: 'bg-emerald-50 dark:bg-emerald-500/10', solid: 'bg-emerald-600' },
}
const WIDGET_META: WidgetMeta[] = [
{ id: 'pendingPO', cat: 'sales', title: 'Pending Shop POs', size: 'sm' },
{ id: 'awaitingConfirm', cat: 'sales', title: 'Awaiting Confirmation', size: 'sm' },
{ id: 'fulfillRate', cat: 'sales', title: 'Fulfillment Rate', size: 'md' },
{ id: 'openRides', cat: 'ops', title: 'Open Rides', size: 'md' },
{ id: 'loadingSheetsByStatus', cat: 'ops', title: 'Loading Sheets by Status', size: 'md' },
{ id: 'deliveryTasks', cat: 'ops', title: 'Pending Delivery Tasks', size: 'sm' },
{ id: 'lowStock', cat: 'stock', title: 'Low Stock Alerts', size: 'lg' },
{ id: 'stockMovements', cat: 'stock', title: 'Recent Stock Movements', size: 'lg' },
{ id: 'warehouseBalance', cat: 'stock', title: 'Warehouse Balances', size: 'md' },
{ id: 'outstandingPay', cat: 'finance', title: 'Outstanding Payments', size: 'sm' },
{ id: 'invoiceStatus', cat: 'finance', title: 'Invoices by Status', size: 'md' },
]
const DEFAULT_ACTIVE = [
'pendingPO', 'awaitingConfirm', 'fulfillRate', 'openRides', 'loadingSheetsByStatus',
'deliveryTasks', 'lowStock', 'outstandingPay', 'invoiceStatus',
]
/** Width (in that breakpoint's own columns), height, and minimums per widget size, per breakpoint. Narrower breakpoints
* give small/medium widgets a full-width row instead of shrinking them into unreadably thin columns. */
const SIZE_CONFIG: Record<Breakpoint, Record<Size, { w: number; h: number; minW: number; minH: number }>> = {
lg: {
sm: { w: 3, h: 4, minW: 2, minH: 3 },
md: { w: 6, h: 5, minW: 3, minH: 3 },
lg: { w: 12, h: 7, minW: 4, minH: 4 },
},
md: {
sm: { w: 5, h: 4, minW: 3, minH: 3 },
md: { w: 10, h: 5, minW: 4, minH: 3 },
lg: { w: 10, h: 7, minW: 4, minH: 4 },
},
sm: {
sm: { w: 3, h: 4, minW: 2, minH: 3 },
md: { w: 6, h: 5, minW: 3, minH: 3 },
lg: { w: 6, h: 7, minW: 3, minH: 4 },
},
xs: {
sm: { w: 4, h: 4, minW: 2, minH: 3 },
md: { w: 4, h: 5, minW: 2, minH: 3 },
lg: { w: 4, h: 7, minW: 2, minH: 4 },
},
xxs: {
sm: { w: 2, h: 4, minW: 1, minH: 3 },
md: { w: 2, h: 5, minW: 1, minH: 3 },
lg: { w: 2, h: 7, minW: 1, minH: 4 },
},
}
/** Lays widgets out left-to-right for one breakpoint's column count, wrapping to a new row when a widget won't fit. */
function generateFlowLayout(ids: string[], bp: Breakpoint): LayoutItem[] {
const cols = DEFAULT_COLS[bp]
const metas = WIDGET_META.filter(w => ids.includes(w.id))
let x = 0
let y = 0
let rowH = 0
const items: LayoutItem[] = []
for (const m of metas) {
const cfg = SIZE_CONFIG[bp][m.size]
if (x + cfg.w > cols) {
x = 0
y += rowH
rowH = 0
}
items.push({ i: m.id, x, y, w: cfg.w, h: cfg.h, minW: cfg.minW, minH: cfg.minH })
x += cfg.w
rowH = Math.max(rowH, cfg.h)
}
return items
}
function generateFlowLayouts(ids: string[]): Record<Breakpoint, LayoutItem[]> {
return Object.fromEntries(BREAKPOINT_ORDER.map(bp => [bp, generateFlowLayout(ids, bp)])) as Record<Breakpoint, LayoutItem[]>
}
/** Places a newly-added widget below everything else on a given breakpoint, so it never overlaps what's already there. */
function appendWidget(layout: LayoutItem[], meta: WidgetMeta, bp: Breakpoint): LayoutItem[] {
const cfg = SIZE_CONFIG[bp][meta.size]
const y = layout.reduce((max, item) => Math.max(max, item.y + item.h), 0)
return [...layout, { i: meta.id, x: 0, y, w: cfg.w, h: cfg.h, minW: cfg.minW, minH: cfg.minH }]
}
interface DashboardLayoutState {
activeIds: string[]
layouts: Record<Breakpoint, LayoutItem[]>
}
function defaultLayoutState(): DashboardLayoutState {
return { activeIds: [...DEFAULT_ACTIVE], layouts: generateFlowLayouts(DEFAULT_ACTIVE) }
}
function loadStoredLayoutState(): DashboardLayoutState {
try {
const raw = window.localStorage.getItem(STORAGE_KEY)
if (!raw) return defaultLayoutState()
const parsed = JSON.parse(raw)
const validIds = new Set(WIDGET_META.map(w => w.id))
const activeIds: string[] = Array.isArray(parsed?.activeIds)
? parsed.activeIds.filter((id: unknown): id is string => typeof id === 'string' && validIds.has(id))
: []
if (activeIds.length === 0) return defaultLayoutState()
const layouts = {} as Record<Breakpoint, LayoutItem[]>
for (const bp of BREAKPOINT_ORDER) {
const storedLayout: LayoutItem[] = Array.isArray(parsed?.layouts?.[bp])
? parsed.layouts[bp].filter((l: unknown): l is LayoutItem =>
!!l && typeof l === 'object' && typeof (l as LayoutItem).i === 'string' && validIds.has((l as LayoutItem).i) && activeIds.includes((l as LayoutItem).i))
: []
const missingIds = activeIds.filter(id => !storedLayout.some(l => l.i === id))
layouts[bp] = missingIds.length > 0 ? [...storedLayout, ...generateFlowLayout(missingIds, bp)] : storedLayout
}
return { activeIds, layouts }
} catch {
return defaultLayoutState()
}
}
const fmtMoney = (v: number) => `Rs. ${v.toLocaleString('en-LK', { minimumFractionDigits: 2 })}`
interface OpenRide {
id: string
vehicleNumberPlate: string
driverFullName: string
}
interface WarehouseBalanceRow {
locationId: string
name: string
quantity: number
}
interface DashboardData {
pendingPOCount: number
awaitingConfirmCount: number
totalPOCount: number
fulfilledPOCount: number
openRides: OpenRide[]
plannedSheetsCount: number
confirmedSheetsCount: number
pendingTasksCount: number
partialTasksCount: number
lowStock: LowStockItemResponse[]
movements: StockMovementResponse[]
warehouseBalances: WarehouseBalanceRow[]
outstandingAmount: number
outstandingInvoiceCount: number
draftInvoiceCount: number
printedInvoiceCount: number
finalizedInvoiceCount: number
}
async function loadDashboardData(): Promise<DashboardData> {
const [
pendingPO, awaitingConfirm, allPO, fulfilledPO,
openRidesRes,
plannedSheets, confirmedSheets,
pendingTasks, partialTasks,
lowStock,
movements,
balances,
outstanding,
draftInv, printedInv, finalizedInv,
] = await Promise.all([
shopPos.list({ status: 'Pending', pageSize: 1 }),
shopPos.list({ confirmed: true, status: 'Pending', pageSize: 1 }),
shopPos.list({ pageSize: 1 }),
shopPos.list({ status: 'Fulfilled', pageSize: 1 }),
rides.list({ status: 'Open', pageSize: 5 }),
loadingSheets.list({ status: 'Planned', pageSize: 1 }),
loadingSheets.list({ status: 'Confirmed', pageSize: 1 }),
deliveryTasks.list({ status: 'Pending', pageSize: 1 }),
deliveryTasks.list({ status: 'PartiallyDelivered', pageSize: 1 }),
stock.lowStock(),
stock.movements({ pageSize: 5 }),
stock.balances({ locationType: 'Warehouse', pageSize: 300 }),
invoices.outstandingSummary(),
invoices.list({ status: 'Draft', pageSize: 1 }),
invoices.list({ status: 'Printed', pageSize: 1 }),
invoices.list({ status: 'Finalized', pageSize: 1 }),
])
const balanceRows = getPaginatedItems(balances)
const byWarehouse = new Map<string, WarehouseBalanceRow>()
for (const b of balanceRows) {
const existing = byWarehouse.get(b.locationId)
if (existing) existing.quantity += b.quantity
else byWarehouse.set(b.locationId, { locationId: b.locationId, name: b.locationName, quantity: b.quantity })
}
const warehouseBalances = Array.from(byWarehouse.values())
.sort((a, b) => b.quantity - a.quantity)
.slice(0, 5)
return {
pendingPOCount: pendingPO.total,
awaitingConfirmCount: awaitingConfirm.total,
totalPOCount: allPO.total,
fulfilledPOCount: fulfilledPO.total,
openRides: getPaginatedItems(openRidesRes).map(r => ({ id: r.id, vehicleNumberPlate: r.vehicleNumberPlate, driverFullName: r.driverFullName })),
plannedSheetsCount: plannedSheets.total,
confirmedSheetsCount: confirmedSheets.total,
pendingTasksCount: pendingTasks.total,
partialTasksCount: partialTasks.total,
lowStock: getPaginatedItems(lowStock).slice(0, 5),
movements: getPaginatedItems(movements),
warehouseBalances,
outstandingAmount: outstanding.outstandingAmount,
outstandingInvoiceCount: outstanding.invoiceCount,
draftInvoiceCount: draftInv.total,
printedInvoiceCount: printedInv.total,
finalizedInvoiceCount: finalizedInv.total,
}
}
function CategoryPill({ cat }: { cat: Category }) {
const meta = CATEGORY_META[cat]
return (
<span className={`inline-block rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide ${meta.text} ${meta.bg}`}>
{meta.label}
</span>
)
}
function Bar({ pct, colorClass }: { pct: number; colorClass: string }) {
return (
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div className={`h-full rounded-full ${colorClass}`} style={{ width: `${Math.min(100, Math.max(0, pct))}%` }} />
</div>
)
}
function WidgetBody({ id, data }: { id: string; data: DashboardData }) {
switch (id) {
case 'pendingPO':
return (
<div>
<div className="text-3xl font-bold text-foreground">{data.pendingPOCount}</div>
<div className="mt-1.5 text-xs text-muted-foreground">{data.totalPOCount} total orders</div>
</div>
)
case 'awaitingConfirm':
return (
<div>
<div className="text-3xl font-bold text-foreground">{data.awaitingConfirmCount}</div>
<div className="mt-1.5 text-xs text-muted-foreground">Confirmed, not yet loaded</div>
</div>
)
case 'fulfillRate': {
const pct = data.totalPOCount > 0 ? Math.round((data.fulfilledPOCount / data.totalPOCount) * 100) : 0
return (
<div>
<div className="mb-2.5 text-2xl font-bold text-foreground">{pct}%</div>
<Bar pct={pct} colorClass="bg-blue-600" />
<div className="mt-2.5 text-xs text-muted-foreground">{data.fulfilledPOCount} of {data.totalPOCount} orders fulfilled</div>
</div>
)
}
case 'openRides':
return data.openRides.length === 0 ? (
<p className="text-xs text-muted-foreground">No open rides right now.</p>
) : (
<div className="flex flex-col gap-2">
{data.openRides.map(r => (
<div key={r.id} className="flex items-center justify-between rounded-lg bg-muted/50 px-3 py-2">
<div>
<div className="text-sm font-medium text-foreground">{r.vehicleNumberPlate}</div>
<div className="text-xs text-muted-foreground">{r.driverFullName}</div>
</div>
<span className="rounded-full bg-violet-600 px-2.5 py-1 text-[11px] font-semibold text-white">Open</span>
</div>
))}
</div>
)
case 'loadingSheetsByStatus': {
const total = data.plannedSheetsCount + data.confirmedSheetsCount
const plannedPct = total > 0 ? (data.plannedSheetsCount / total) * 100 : 0
const confirmedPct = total > 0 ? (data.confirmedSheetsCount / total) * 100 : 0
return (
<div className="flex flex-col gap-3">
<div>
<div className="mb-1 flex justify-between text-xs">
<span className="text-muted-foreground">Planned</span>
<span className="font-semibold text-foreground">{data.plannedSheetsCount}</span>
</div>
<Bar pct={plannedPct} colorClass="bg-amber-600" />
</div>
<div>
<div className="mb-1 flex justify-between text-xs">
<span className="text-muted-foreground">Confirmed</span>
<span className="font-semibold text-foreground">{data.confirmedSheetsCount}</span>
</div>
<Bar pct={confirmedPct} colorClass="bg-violet-600" />
</div>
</div>
)
}
case 'deliveryTasks':
return (
<div>
<div className="text-3xl font-bold text-foreground">{data.pendingTasksCount}</div>
<div className="mt-1.5 text-xs text-muted-foreground">{data.partialTasksCount} partially delivered</div>
</div>
)
case 'lowStock':
return data.lowStock.length === 0 ? (
<p className="text-xs text-muted-foreground">No items below their reorder level.</p>
) : (
<div>
<div className="grid grid-cols-[2fr_1.4fr_0.8fr_0.8fr] gap-2 border-b pb-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
<span>Item</span><span>Warehouse</span><span>On hand</span><span>Reorder</span>
</div>
{data.lowStock.map(row => (
<div key={`${row.itemId}-${row.locationId}`} className="grid grid-cols-[2fr_1.4fr_0.8fr_0.8fr] gap-2 border-b border-muted py-2.5 text-sm last:border-0">
<span className="text-foreground">{row.itemName}</span>
<span className="text-muted-foreground">{row.locationName}</span>
<span className="font-semibold text-destructive">{row.quantity}</span>
<span className="text-muted-foreground">{row.reorderLevel}</span>
</div>
))}
</div>
)
case 'stockMovements':
return data.movements.length === 0 ? (
<p className="text-xs text-muted-foreground">No recent stock movements.</p>
) : (
<div>
<div className="grid grid-cols-[2fr_1fr_0.8fr_2fr] gap-2 border-b pb-2 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
<span>Item</span><span>Type</span><span>Qty</span><span>Route</span>
</div>
{data.movements.map(m => (
<div key={m.id} className="grid grid-cols-[2fr_1fr_0.8fr_2fr] gap-2 border-b border-muted py-2.5 text-sm last:border-0">
<span className="text-foreground">{m.itemName}</span>
<span className="font-medium text-foreground">{m.type}</span>
<span className="text-muted-foreground">{m.quantity}</span>
<span className="truncate text-muted-foreground">{m.fromLocationName ?? '—'} → {m.toLocationName ?? '—'}</span>
</div>
))}
</div>
)
case 'warehouseBalance': {
const max = Math.max(1, ...data.warehouseBalances.map(w => w.quantity))
return data.warehouseBalances.length === 0 ? (
<p className="text-xs text-muted-foreground">No warehouse stock recorded.</p>
) : (
<div className="flex flex-col gap-3">
{data.warehouseBalances.map(w => (
<div key={w.locationId}>
<div className="mb-1 flex justify-between text-xs">
<span className="text-muted-foreground">{w.name}</span>
<span className="font-semibold text-foreground">{w.quantity}</span>
</div>
<Bar pct={(w.quantity / max) * 100} colorClass="bg-amber-600" />
</div>
))}
</div>
)
}
case 'outstandingPay':
return (
<div>
<div className="text-3xl font-bold text-foreground">{fmtMoney(data.outstandingAmount)}</div>
<div className="mt-1.5 text-xs text-muted-foreground">{data.outstandingInvoiceCount} invoices</div>
</div>
)
case 'invoiceStatus': {
const total = data.draftInvoiceCount + data.printedInvoiceCount + data.finalizedInvoiceCount
const segs = [
{ label: 'Draft', count: data.draftInvoiceCount, color: 'bg-slate-400' },
{ label: 'Printed', count: data.printedInvoiceCount, color: 'bg-amber-600' },
{ label: 'Finalized', count: data.finalizedInvoiceCount, color: 'bg-emerald-600' },
]
return (
<div>
<div className="flex h-2.5 overflow-hidden rounded-full bg-muted">
{segs.map(s => (
<div key={s.label} className={s.color} style={{ width: total > 0 ? `${(s.count / total) * 100}%` : '0%' }} />
))}
</div>
<div className="mt-3.5 flex flex-col gap-1.5">
{segs.map(s => (
<div key={s.label} className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5 text-muted-foreground">
<span className={`inline-block h-2 w-2 rounded-full ${s.color}`} />
{s.label}
</span>
<span className="font-semibold text-foreground">{s.count}</span>
</div>
))}
</div>
</div>
)
}
default:
return null
}
}
function mergeLayoutsChange(
full: Record<Breakpoint, LayoutItem[]>,
changed: Partial<Record<Breakpoint, Layout>>,
): Record<Breakpoint, LayoutItem[]> {
const next = { ...full }
for (const bp of BREAKPOINT_ORDER) {
const changedArr = changed[bp]
if (!changedArr) continue
next[bp] = full[bp].map(item => {
const updated = changedArr.find(c => c.i === item.i)
return updated ? { ...item, x: updated.x, y: updated.y, w: updated.w, h: updated.h } : item
})
}
return next
}
export function DashboardClient() {
const [data, setData] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true)
const [state, setState] = useState<DashboardLayoutState>(defaultLayoutState)
const [editMode, setEditMode] = useState(false)
const [showPicker, setShowPicker] = useState(false)
const [category, setCategory] = useState<'all' | Category>('all')
const { width, containerRef, mounted } = useContainerWidth({ initialWidth: 1200 })
useEffect(() => {
setState(loadStoredLayoutState())
}, [])
const persist = useCallback((next: DashboardLayoutState) => {
setState(next)
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
} catch {
// localStorage unavailable (private browsing, etc.) — layout just won't persist
}
}, [])
const fetchData = useCallback(async () => {
setLoading(true)
try {
setData(await loadDashboardData())
} catch (err) {
toast.add({ title: 'Failed to load dashboard', description: getApiError(err), type: 'error' })
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
}, [fetchData])
function toggleWidget(id: string, checked: boolean) {
if (checked) {
const meta = WIDGET_META.find(w => w.id === id)
if (!meta || state.activeIds.includes(id)) return
const layouts = { ...state.layouts }
for (const bp of BREAKPOINT_ORDER) layouts[bp] = appendWidget(layouts[bp], meta, bp)
persist({ activeIds: [...state.activeIds, id], layouts })
} else {
const layouts = { ...state.layouts }
for (const bp of BREAKPOINT_ORDER) layouts[bp] = layouts[bp].filter(l => l.i !== id)
persist({ activeIds: state.activeIds.filter(x => x !== id), layouts })
}
}
function handleLayoutChange(_layout: Layout, changedLayouts: Partial<Record<Breakpoint, Layout>>) {
setState(prev => {
const next = { ...prev, layouts: mergeLayoutsChange(prev.layouts, changedLayouts) }
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next))
} catch {
// localStorage unavailable — position just won't persist
}
return next
})
}
const visibleWidgets = useMemo(
() => state.activeIds
.map(id => WIDGET_META.find(w => w.id === id))
.filter((w): w is WidgetMeta => Boolean(w))
.filter(w => category === 'all' || w.cat === category),
[state.activeIds, category],
)
const visibleLayouts = useMemo(() => {
const visibleIds = new Set(visibleWidgets.map(w => w.id))
return Object.fromEntries(
BREAKPOINT_ORDER.map(bp => [bp, state.layouts[bp].filter(l => visibleIds.has(l.i))]),
) as Record<Breakpoint, LayoutItem[]>
}, [state.layouts, visibleWidgets])
const filters: Array<{ key: 'all' | Category; label: string }> = [
{ key: 'all', label: 'All widgets' },
{ key: 'sales', label: CATEGORY_META.sales.label },
{ key: 'ops', label: CATEGORY_META.ops.label },
{ key: 'stock', label: CATEGORY_META.stock.label },
{ key: 'finance', label: CATEGORY_META.finance.label },
]
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold text-foreground">Dashboard</h1>
<p className="mt-1 text-sm text-muted-foreground">
{editMode ? 'Drag to move, drag a corner to resize, or tick which widgets show.' : 'Add, remove, drag and resize widgets to make this view yours.'}
</p>
</div>
<div className="relative flex items-center gap-2">
{editMode && (
<Button variant="outline" size="sm" onClick={() => setShowPicker(v => !v)}>
<HugeiconsIcon icon={Add01Icon} size={14} />
Widgets
</Button>
)}
<Button variant={editMode ? 'default' : 'outline'} size="sm" onClick={() => { setEditMode(v => !v); setShowPicker(false) }}>
{editMode ? 'Done' : 'Customize Dashboard'}
</Button>
{showPicker && (
<div className="absolute right-0 top-full z-20 mt-2 w-72 rounded-2xl border bg-popover p-3 shadow-lg">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-semibold text-foreground">Widgets</span>
<button onClick={() => setShowPicker(false)} aria-label="Close" className="rounded-full bg-muted p-1 text-muted-foreground">
<HugeiconsIcon icon={Cancel01Icon} size={12} />
</button>
</div>
<div className="flex flex-col gap-1">
{WIDGET_META.map(w => {
const checked = state.activeIds.includes(w.id)
return (
<label
key={w.id}
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 hover:bg-muted"
>
<Checkbox checked={checked} onCheckedChange={(v) => toggleWidget(w.id, v === true)} />
<span className="text-sm text-foreground">{w.title}</span>
</label>
)
})}
</div>
</div>
)}
</div>
</div>
<div className="flex flex-wrap gap-2">
{filters.map(f => (
<button
key={f.key}
onClick={() => setCategory(f.key)}
className={`rounded-full border px-3.5 py-1.5 text-xs font-semibold transition-colors ${
category === f.key
? 'border-foreground bg-foreground text-background'
: 'border-border bg-background text-muted-foreground hover:bg-muted'
}`}
>
{f.label}
</button>
))}
</div>
{loading || !data ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-40 rounded-2xl" />
))}
</div>
) : visibleWidgets.length === 0 ? (
<div className="rounded-2xl border border-dashed p-12 text-center text-sm text-muted-foreground">
No widgets on this dashboard yet. Click &ldquo;Widgets&rdquo; to add some.
</div>
) : (
<div ref={containerRef} className="w-full">
{mounted && (
<ResponsiveGridLayout
width={width}
layouts={visibleLayouts}
breakpoints={DEFAULT_BREAKPOINTS}
cols={DEFAULT_COLS}
rowHeight={ROW_HEIGHT}
margin={GRID_MARGIN}
containerPadding={[0, 0]}
maxRows={Infinity}
dragConfig={{ enabled: editMode }}
resizeConfig={{ enabled: editMode, handles: ['se'] }}
autoSize
onLayoutChange={handleLayoutChange}
>
{visibleWidgets.map(w => (
<div key={w.id} className="relative flex flex-col overflow-auto rounded-2xl border bg-card p-5">
{editMode && (
<button
onClick={() => toggleWidget(w.id, false)}
aria-label="Remove widget"
className="absolute right-3 top-3 flex h-6 w-6 items-center justify-center rounded-full border bg-background text-muted-foreground hover:text-foreground"
>
<HugeiconsIcon icon={Cancel01Icon} size={12} />
</button>
)}
<div className="mb-3.5 shrink-0">
<div className="text-sm font-semibold text-foreground">{w.title}</div>
<div className="mt-1"><CategoryPill cat={w.cat} /></div>
</div>
<WidgetBody id={w.id} data={data} />
</div>
))}
</ResponsiveGridLayout>
)}
</div>
)}
</div>
)
}
@@ -20,14 +20,16 @@ import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { HugeiconsIcon } from '@hugeicons/react' import { HugeiconsIcon } from '@hugeicons/react'
import { ViewIcon } from '@hugeicons/core-free-icons' import { ArrowDown01Icon, ArrowUp01Icon, Alert02Icon } from '@hugeicons/core-free-icons'
import { toast } from '@/components/ui/toast' import { toast } from '@/components/ui/toast'
import { cn } from '@/lib/utils'
import { import {
shopPos as shopPosApi, shopPos as shopPosApi,
regions as regionsApi, regions as regionsApi,
routes as routesApi, routes as routesApi,
warehouses as warehousesApi, warehouses as warehousesApi,
loadingSheets as loadingSheetsApi, loadingSheets as loadingSheetsApi,
stock as stockApi,
getApiError, getApiError,
getPaginatedItems, getPaginatedItems,
} from '@/lib/api/index' } from '@/lib/api/index'
@@ -35,6 +37,26 @@ import type { LoadingSheetResponse, ShopPOResponse, RegionResponse, RouteRespons
import type { LoadingSheetItemInput } from '@/lib/api/operations' import type { LoadingSheetItemInput } from '@/lib/api/operations'
const ALL = '__all__' const ALL = '__all__'
const STEPS = [
{
n: 1,
label: 'Route & Warehouse',
title: 'Where is this load coming from?',
help: 'Pick the route this ride will deliver, and the warehouse the vehicle will be loaded from. Every item on this sheet will be dispatched from that one warehouse.',
},
{
n: 2,
label: 'Pick Items',
title: 'What should be loaded?',
help: "Click a shop to open its pending orders, tick the items to load, and adjust quantities if needed. The \"In stock\" column shows what's on hand at the warehouse you picked.",
},
{
n: 3,
label: 'Review & Confirm',
title: 'Double-check before saving',
help: 'This is everything that will be saved to the sheet. Items marked "Short" have more requested than is in stock — confirming the sheet will fail until those are fixed.',
},
] as const
interface LoadingSheetItemsDialogProps { interface LoadingSheetItemsDialogProps {
open: boolean open: boolean
@@ -57,6 +79,8 @@ interface PickedLine {
} }
export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: LoadingSheetItemsDialogProps) { export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: LoadingSheetItemsDialogProps) {
const [step, setStep] = useState<1 | 2 | 3>(1)
const [regionsList, setRegionsList] = useState<RegionResponse[]>([]) const [regionsList, setRegionsList] = useState<RegionResponse[]>([])
const [routesList, setRoutesList] = useState<RouteResponse[]>([]) const [routesList, setRoutesList] = useState<RouteResponse[]>([])
const [warehousesList, setWarehousesList] = useState<WarehouseResponse[]>([]) const [warehousesList, setWarehousesList] = useState<WarehouseResponse[]>([])
@@ -65,22 +89,27 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
const [routeId, setRouteId] = useState('') const [routeId, setRouteId] = useState('')
const [warehouseId, setWarehouseId] = useState('') const [warehouseId, setWarehouseId] = useState('')
const [shopId, setShopId] = useState('') const [shopId, setShopId] = useState('')
const [viewStoreId, setViewStoreId] = useState('') const [expandedStores, setExpandedStores] = useState<Set<string>>(new Set())
const [pos, setPos] = useState<ShopPOResponse[]>([]) const [pos, setPos] = useState<ShopPOResponse[]>([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [lines, setLines] = useState<Record<string, PickedLine>>({}) const [lines, setLines] = useState<Record<string, PickedLine>>({})
const [stockByItem, setStockByItem] = useState<Record<string, number>>({})
const [stockLoading, setStockLoading] = useState(false)
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
setStep(1)
setRegionId('') setRegionId('')
setRouteId('') setRouteId('')
setWarehouseId('') setWarehouseId('')
setShopId('') setShopId('')
setViewStoreId('') setExpandedStores(new Set())
setPos([]) setPos([])
setLines({}) setLines({})
setStockByItem({})
Promise.all([ Promise.all([
regionsApi.list({ pageSize: 200 }), regionsApi.list({ pageSize: 200 }),
@@ -108,7 +137,6 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
if (!open) return if (!open) return
setLines({}) setLines({})
setShopId('') setShopId('')
setViewStoreId('')
if (!routeId) { if (!routeId) {
setPos([]) setPos([])
return return
@@ -121,7 +149,31 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
}) })
.catch((err) => toast.add({ title: 'Failed to load Shop POs', description: getApiError(err), type: 'error' })) .catch((err) => toast.add({ title: 'Failed to load Shop POs', description: getApiError(err), type: 'error' }))
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, [open, routeId]) }, [open, routeId, regionId])
// Stock in the chosen warehouse is fetched once the user moves past step 1, so
// step 2 can show — and warn against — quantities that exceed what's on hand.
useEffect(() => {
if (!open || !warehouseId || step < 2) return
setStockLoading(true)
stockApi.balances({ locationId: warehouseId, locationType: 'Warehouse', pageSize: 1000 })
.then((res) => {
const map: Record<string, number> = {}
for (const b of getPaginatedItems(res)) map[b.itemId] = b.quantity
setStockByItem(map)
})
.catch((err) => toast.add({ title: 'Failed to load warehouse stock', description: getApiError(err), type: 'error' }))
.finally(() => setStockLoading(false))
}, [open, warehouseId, step])
// First-time users land on step 2 with everything collapsed and no obvious next
// move, so the first shop is opened automatically to show what "pick items" means.
useEffect(() => {
if (step !== 2 || pos.length === 0 || expandedStores.size > 0) return
const firstStoreId = pos[0]?.storeId
if (firstStoreId) setExpandedStores(new Set([firstStoreId]))
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [step, pos])
const rows = useMemo<PickedLine[]>(() => { const rows = useMemo<PickedLine[]>(() => {
const result: PickedLine[] = [] const result: PickedLine[] = []
@@ -164,22 +216,25 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
[allShopGroups, shopId] [allShopGroups, shopId]
) )
const viewGroup = allShopGroups.find((g) => g.storeId === viewStoreId) const selectedRows = useMemo(
() => rows.filter((r) => (lines[r.shopPOItemId]?.checked ?? false) && lines[r.shopPOItemId].quantity > 0),
[rows, lines]
)
const summary = useMemo(() => { const summary = useMemo(() => {
const map = new Map<string, { itemName: string; qty: number; shops: Set<string> }>() const map = new Map<string, { itemId: string; itemName: string; qty: number; available: number; shops: Set<string> }>()
for (const row of rows) { for (const row of selectedRows) {
const line = lines[row.shopPOItemId] ?? row const line = lines[row.shopPOItemId]
if (!line.checked || !(line.quantity > 0)) continue const e = map.get(row.itemId) ?? { itemId: row.itemId, itemName: row.itemName, qty: 0, available: stockByItem[row.itemId] ?? 0, shops: new Set<string>() }
const e = map.get(row.itemId) ?? { itemName: row.itemName, qty: 0, shops: new Set<string>() }
e.qty += line.quantity e.qty += line.quantity
e.shops.add(row.storeId) e.shops.add(row.storeId)
map.set(row.itemId, e) map.set(row.itemId, e)
} }
return Array.from(map.values()).sort((a, b) => a.itemName.localeCompare(b.itemName)) return Array.from(map.values()).sort((a, b) => a.itemName.localeCompare(b.itemName))
}, [rows, lines]) }, [selectedRows, lines, stockByItem])
const summaryTotal = summary.reduce((s, e) => s + e.qty, 0) const summaryTotal = summary.reduce((s, e) => s + e.qty, 0)
const shortItems = summary.filter((e) => e.qty > e.available)
function toggleLine(line: PickedLine, checked: boolean) { function toggleLine(line: PickedLine, checked: boolean) {
setLines((prev) => ({ ...prev, [line.shopPOItemId]: { ...line, checked } })) setLines((prev) => ({ ...prev, [line.shopPOItemId]: { ...line, checked } }))
@@ -189,23 +244,45 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
setLines((prev) => ({ ...prev, [line.shopPOItemId]: { ...line, quantity } })) setLines((prev) => ({ ...prev, [line.shopPOItemId]: { ...line, quantity } }))
} }
async function handleSubmit() { function toggleExpanded(storeId: string) {
if (!sheet) return setExpandedStores((prev) => {
if (!routeId) { const next = new Set(prev)
toast.add({ title: 'Select a route first', type: 'error' }) if (next.has(storeId)) next.delete(storeId)
return else next.add(storeId)
} return next
if (!warehouseId) { })
toast.add({ title: 'Select a warehouse first', type: 'error' }) }
return
}
const selected = rows.filter((r) => (lines[r.shopPOItemId]?.checked ?? false) && lines[r.shopPOItemId].quantity > 0)
if (selected.length === 0) {
toast.add({ title: 'Select at least one item', type: 'error' })
return
}
const items: LoadingSheetItemInput[] = selected.map((r) => { function goNext() {
if (step === 1) {
if (!routeId) {
toast.add({ title: 'Select a route first', type: 'error' })
return
}
if (!warehouseId) {
toast.add({ title: 'Select a warehouse first', type: 'error' })
return
}
setStep(2)
return
}
if (step === 2) {
if (selectedRows.length === 0) {
toast.add({ title: 'Select at least one item', type: 'error' })
return
}
setStep(3)
}
}
function goBack() {
setStep((s) => (s === 3 ? 2 : 1) as 1 | 2)
}
async function handleConfirmSave() {
if (!sheet) return
const items: LoadingSheetItemInput[] = selectedRows.map((r) => {
const line = lines[r.shopPOItemId] const line = lines[r.shopPOItemId]
return { return {
fK_ItemId: line.itemId, fK_ItemId: line.itemId,
@@ -247,203 +324,314 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
if (!sheet) return null if (!sheet) return null
const selectedWarehouseName = warehousesList.find((w) => w.id === warehouseId)?.name ?? ''
return ( return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}> <Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-3xl"> <DialogContent className="overflow-y-auto sm:max-w-3xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Set Loading Sheet Items</DialogTitle> <DialogTitle>Set Loading Sheet Items</DialogTitle>
<DialogDescription> <DialogDescription>
Select a route first. Confirmed Shop POs for that route will then be available to pick {sheet.status === 'Confirmed'
items from. ? 'This sheet is already confirmed, so the items you pick are saved on a new planned sheet for the same ride.'
{sheet.status === 'Confirmed' && ( : 'Work through the steps below to build the loading sheet.'}
<> This sheet is already confirmed, so the items you pick are saved on a new planned sheet for the same ride.</>
)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-wrap items-end gap-3"> {/* Step indicator */}
<div className="flex flex-col gap-1"> <div className="flex items-center gap-2 text-xs">
<label className="text-xs text-muted-foreground">Region</label> {STEPS.map((s, idx) => (
<Select value={regionId || ALL} onValueChange={(v) => setRegionId(!v || v === ALL ? '' : v)}> <div key={s.n} className="flex items-center gap-2">
<SelectTrigger className="w-48"> <button
<SelectValue placeholder="All regions" /> type="button"
</SelectTrigger> disabled={s.n > step}
<SelectContent> onClick={() => s.n < step && setStep(s.n)}
<SelectItem value={ALL}>All regions</SelectItem> className={cn(
{regionsList.map((r) => ( 'flex h-6 w-6 items-center justify-center rounded-full border font-semibold transition-colors',
<SelectItem key={r.id} value={r.id}>{r.name}</SelectItem> step === s.n
))} ? 'border-primary bg-primary text-primary-foreground'
</SelectContent> : step > s.n
</Select> ? 'border-primary/40 bg-primary/10 text-primary hover:bg-primary/20'
</div> : 'border-muted-foreground/30 text-muted-foreground',
s.n < step && 'cursor-pointer'
<div className="flex flex-col gap-1"> )}
<label className="text-xs text-muted-foreground">Route *</label> >
<Select value={routeId} onValueChange={(v) => setRouteId(v ?? '')}> {step > s.n ? '✓' : s.n}
<SelectTrigger className="w-48"> </button>
<SelectValue placeholder="Select route..." /> <span className={cn(step === s.n ? 'font-medium text-foreground' : 'text-muted-foreground')}>{s.label}</span>
</SelectTrigger> {idx < STEPS.length - 1 && <div className="mx-1 h-px w-8 bg-border" />}
<SelectContent>
{routesList.map((r) => (
<SelectItem key={r.id} value={r.id}>{r.routeName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Shop</label>
<Select value={shopId || ALL} onValueChange={(v) => setShopId(!v || v === ALL ? '' : v)} disabled={!routeId}>
<SelectTrigger className="w-48">
<SelectValue placeholder="All shops" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All shops</SelectItem>
{allShopGroups.map((g) => (
<SelectItem key={g.storeId} value={g.storeId}>{g.storeName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Warehouse *</label>
<Select value={warehouseId} onValueChange={(v) => setWarehouseId(v ?? '')}>
<SelectTrigger className="w-52">
<SelectValue placeholder="Select warehouse..." />
</SelectTrigger>
<SelectContent>
{warehousesList.map((w) => (
<SelectItem key={w.id} value={w.id}>{w.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="rounded-xl border">
{!routeId ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Select a route to see shops with confirmed POs.</p>
) : loading ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Loading…</p>
) : shopGroups.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">No shops with confirmed POs found for this selection.</p>
) : (
shopGroups.map((group) => {
const groupLines = group.rows.map((r) => lines[r.shopPOItemId] ?? r)
const allChecked = groupLines.every((l) => l.checked)
const someChecked = groupLines.some((l) => l.checked)
return (
<div key={group.storeId} className="flex items-center gap-3 border-t px-3 py-2 first:border-t-0">
<Checkbox
checked={allChecked}
indeterminate={someChecked && !allChecked}
onCheckedChange={(v) => group.rows.forEach((r) => toggleLine(lines[r.shopPOItemId] ?? r, v === true))}
/>
<span className="min-w-0 flex-1 truncate font-medium">{group.storeName}</span>
<span className="text-xs text-muted-foreground">
{group.poIds.size} PO{group.poIds.size === 1 ? '' : 's'}
</span>
<Button
type="button"
variant="ghost"
size="icon"
aria-label={`View confirmed PO items for ${group.storeName}`}
onClick={() => setViewStoreId(group.storeId)}
>
<HugeiconsIcon icon={ViewIcon} size={18} />
</Button>
</div>
)
})
)}
</div>
<div className="rounded-xl border">
<div className="flex items-center justify-between bg-muted/50 px-3 py-2 text-sm font-medium">
<span>Loading Sheet Summary</span>
<span className="text-muted-foreground">{summary.length} item{summary.length === 1 ? '' : 's'} · {summaryTotal} units</span>
</div>
{summary.length === 0 ? (
<p className="px-3 py-4 text-center text-sm text-muted-foreground">Tick items above to build the summary.</p>
) : (
<table className="w-full text-sm">
<tbody>
{summary.map((e) => (
<tr key={e.itemName} className="border-t first:border-t-0">
<td className="px-3 py-2">{e.itemName}</td>
<td className="px-3 py-2 text-muted-foreground">{e.shops.size} shop{e.shops.size === 1 ? '' : 's'}</td>
<td className="px-3 py-2 text-right font-semibold">{e.qty}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="button" disabled={submitting} onClick={handleSubmit}>
{submitting ? 'Saving…' : 'Save Items'}
</Button>
</DialogFooter>
<Dialog open={!!viewGroup} onOpenChange={(v) => !v && setViewStoreId('')}>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{viewGroup?.storeName}</DialogTitle>
<DialogDescription>
Confirmed PO items. Tick the items to load and set the quantity.
</DialogDescription>
</DialogHeader>
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="w-10 px-3 py-2"></th>
<th className="px-3 py-2 text-left font-medium">Item</th>
<th className="w-24 px-3 py-2 text-left font-medium">PO</th>
<th className="w-24 px-3 py-2 text-right font-medium">Ordered</th>
<th className="w-32 px-3 py-2 text-right font-medium">Qty to load</th>
</tr>
</thead>
<tbody>
{viewGroup?.rows.map((row) => {
const line = lines[row.shopPOItemId] ?? row
return (
<tr key={row.shopPOItemId} className="border-t">
<td className="px-3 py-2">
<Checkbox
checked={line.checked}
onCheckedChange={(v) => toggleLine(row, v === true)}
/>
</td>
<td className="px-3 py-2">{row.itemName}</td>
<td className="px-3 py-2 font-mono text-xs text-muted-foreground">{row.shopPOId.substring(0, 8)}</td>
<td className="px-3 py-2 text-right text-muted-foreground">{row.remainingQty}</td>
<td className="px-3 py-2">
<Input
type="number"
min={1}
max={row.remainingQty}
value={line.quantity}
disabled={!line.checked}
onChange={(e) => setLineQuantity(row, Number(e.target.value))}
/>
</td>
</tr>
)
})}
</tbody>
</table>
</div> </div>
<DialogFooter> ))}
<Button type="button" onClick={() => setViewStoreId('')}>Done</Button> </div>
</DialogFooter>
</DialogContent> {/* Step title & guidance */}
</Dialog> <div className="rounded-lg bg-muted/40 px-3 py-2">
<p className="text-sm font-medium">{STEPS[step - 1].title}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{STEPS[step - 1].help}</p>
</div>
{/* Step 1: Route & Warehouse */}
{step === 1 && (
<div className="flex flex-wrap items-end gap-3 py-2">
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Region</label>
<Select value={regionId || ALL} onValueChange={(v) => setRegionId(!v || v === ALL ? '' : v)}>
<SelectTrigger className="w-48">
<SelectValue placeholder="All regions" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All regions</SelectItem>
{regionsList.map((r) => (
<SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Route *</label>
<Select value={routeId} onValueChange={(v) => setRouteId(v ?? '')}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Select route..." />
</SelectTrigger>
<SelectContent>
{routesList.map((r) => (
<SelectItem key={r.id} value={r.id}>{r.routeName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Warehouse *</label>
<Select value={warehouseId} onValueChange={(v) => setWarehouseId(v ?? '')}>
<SelectTrigger className="w-52">
<SelectValue placeholder="Select warehouse..." />
</SelectTrigger>
<SelectContent>
{warehousesList.map((w) => (
<SelectItem key={w.id} value={w.id}>{w.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
{/* Step 2: Pick Items */}
{step === 2 && (
<div className="flex flex-col gap-3 py-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm text-muted-foreground">
Route <span className="font-medium text-foreground">{routesList.find((r) => r.id === routeId)?.routeName}</span>
{' · '}Warehouse <span className="font-medium text-foreground">{selectedWarehouseName}</span>
</div>
<Select value={shopId || ALL} onValueChange={(v) => setShopId(!v || v === ALL ? '' : v)}>
<SelectTrigger className="w-48">
<SelectValue placeholder="All shops" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All shops</SelectItem>
{allShopGroups.map((g) => (
<SelectItem key={g.storeId} value={g.storeId}>{g.storeName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="rounded-xl border">
{loading ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">Loading…</p>
) : shopGroups.length === 0 ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">No shops with confirmed POs found for this selection.</p>
) : (
shopGroups.map((group) => {
const groupLines = group.rows.map((r) => lines[r.shopPOItemId] ?? r)
const allChecked = groupLines.every((l) => l.checked)
const someChecked = groupLines.some((l) => l.checked)
const checkedCount = groupLines.filter((l) => l.checked).length
const expanded = expandedStores.has(group.storeId)
return (
<div key={group.storeId} className="border-t first:border-t-0">
<div className="flex items-center gap-3 px-3 py-2">
<Checkbox
checked={allChecked}
indeterminate={someChecked && !allChecked}
title="Select all items for this shop"
onCheckedChange={(v) => group.rows.forEach((r) => toggleLine(lines[r.shopPOItemId] ?? r, v === true))}
/>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 text-left"
onClick={() => toggleExpanded(group.storeId)}
>
<span className="min-w-0 flex-1 truncate font-medium">{group.storeName}</span>
{checkedCount > 0 && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
{checkedCount} selected
</span>
)}
<span className="text-xs text-muted-foreground">
{group.poIds.size} PO{group.poIds.size === 1 ? '' : 's'}
</span>
<span className="hidden text-xs text-muted-foreground sm:inline">
{expanded ? 'Hide items' : 'View items'}
</span>
<HugeiconsIcon icon={expanded ? ArrowUp01Icon : ArrowDown01Icon} size={16} />
</button>
</div>
{expanded && (
<div className="overflow-x-auto border-t bg-muted/20">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-muted-foreground">
<th className="w-10 px-3 py-2"></th>
<th className="px-3 py-2 text-left font-medium">Item</th>
<th className="w-24 px-3 py-2 text-right font-medium">Ordered</th>
<th className="w-28 px-3 py-2 text-right font-medium">In stock</th>
<th className="w-32 px-3 py-2 text-right font-medium">Qty to load</th>
</tr>
</thead>
<tbody>
{group.rows.map((row) => {
const line = lines[row.shopPOItemId] ?? row
const available = stockByItem[row.itemId] ?? 0
const short = line.checked && line.quantity > available
return (
<tr key={row.shopPOItemId} className="border-t">
<td className="px-3 py-2">
<Checkbox
checked={line.checked}
onCheckedChange={(v) => toggleLine(row, v === true)}
/>
</td>
<td className="px-3 py-2">{row.itemName}</td>
<td className="px-3 py-2 text-right text-muted-foreground">{row.remainingQty}</td>
<td className={cn('px-3 py-2 text-right', stockLoading ? 'text-muted-foreground' : available === 0 ? 'text-destructive' : 'text-muted-foreground')}>
{stockLoading ? '…' : available}
</td>
<td className="px-3 py-2">
<div className="flex items-center justify-end gap-1">
<Input
type="number"
min={1}
max={row.remainingQty}
value={line.quantity}
disabled={!line.checked}
className={cn('w-24', short && 'border-amber-500 focus-visible:ring-amber-500')}
onChange={(e) => setLineQuantity(row, Number(e.target.value))}
/>
{short && (
<HugeiconsIcon icon={Alert02Icon} size={16} className="shrink-0 text-amber-500" />
)}
</div>
{short && (
<p className="mt-1 text-right text-[11px] text-amber-600">
Only {available} available — rest stays pending
</p>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
)
})
)}
</div>
<p className="text-xs text-muted-foreground">
{selectedRows.length} item line{selectedRows.length === 1 ? '' : 's'} selected. Expand a shop to tick items and set quantities.
</p>
</div>
)}
{/* Step 3: Review & Confirm */}
{step === 3 && (
<div className="flex flex-col gap-3 py-2">
<div className="text-sm text-muted-foreground">
Route <span className="font-medium text-foreground">{routesList.find((r) => r.id === routeId)?.routeName}</span>
{' · '}Warehouse <span className="font-medium text-foreground">{selectedWarehouseName}</span>
</div>
{shortItems.length > 0 && (
<div className="flex items-start gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-400">
<HugeiconsIcon icon={Alert02Icon} size={16} className="mt-0.5 shrink-0" />
<span>
{shortItems.length} item{shortItems.length === 1 ? '' : 's'} exceed{shortItems.length === 1 ? 's' : ''} what&apos;s
in stock at {selectedWarehouseName}. Saving will still work, but confirming this sheet will fail until the
quantities are reduced to what&apos;s available — go back to adjust them.
</span>
</div>
)}
<div className="rounded-xl border">
<div className="flex items-center justify-between bg-muted/50 px-3 py-2 text-sm font-medium">
<span>Loading Sheet Summary</span>
<span className="text-muted-foreground">{summary.length} item{summary.length === 1 ? '' : 's'} · {summaryTotal} units</span>
</div>
{summary.length === 0 ? (
<p className="px-3 py-4 text-center text-sm text-muted-foreground">No items selected.</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-muted-foreground">
<th className="px-3 py-2 text-left font-medium">Item</th>
<th className="px-3 py-2 text-left font-medium">Shops</th>
<th className="px-3 py-2 text-right font-medium">In stock</th>
<th className="px-3 py-2 text-right font-medium">Qty to load</th>
<th className="px-3 py-2 text-right font-medium">Short</th>
</tr>
</thead>
<tbody>
{summary.map((e) => {
const shortQty = Math.max(0, e.qty - e.available)
return (
<tr key={e.itemId} className="border-t">
<td className="px-3 py-2">{e.itemName}</td>
<td className="px-3 py-2 text-muted-foreground">{e.shops.size} shop{e.shops.size === 1 ? '' : 's'}</td>
<td className="px-3 py-2 text-right text-muted-foreground">{e.available}</td>
<td className="px-3 py-2 text-right font-semibold">{e.qty}</td>
<td className={cn('px-3 py-2 text-right', shortQty > 0 ? 'font-medium text-amber-600' : 'text-muted-foreground')}>
{shortQty > 0 ? shortQty : '—'}
</td>
</tr>
)
})}
</tbody>
</table>
)}
</div>
</div>
)}
<DialogFooter className="flex items-center justify-between sm:justify-between">
<div>
{step > 1 && (
<Button type="button" variant="outline" onClick={goBack}>
Back
</Button>
)}
</div>
<div className="flex gap-2">
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
{step < 3 ? (
<Button type="button" onClick={goNext}>
{step === 1 ? 'Next: Pick items' : `Next: Review${selectedRows.length > 0 ? ` (${selectedRows.length})` : ''}`}
</Button>
) : (
<Button type="button" disabled={submitting} onClick={handleConfirmSave}>
{submitting ? 'Saving…' : 'Save Items'}
</Button>
)}
</div>
</DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) )
@@ -3,7 +3,7 @@
import { Fragment, Suspense, useEffect, useMemo, useRef, useState } from 'react' import { Fragment, Suspense, useEffect, useMemo, useRef, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation' import { useRouter, useSearchParams } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react' import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowLeft01Icon, ViewIcon } from '@hugeicons/core-free-icons' import { ArrowLeft01Icon, ViewIcon, PencilEdit01Icon } from '@hugeicons/core-free-icons'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -25,6 +25,7 @@ import { Button } from '@/components/ui/button'
import { FieldGroup } from '@/components/ui/field' import { FieldGroup } from '@/components/ui/field'
import { Separator } from '@/components/ui/separator' import { Separator } from '@/components/ui/separator'
import { toast } from '@/components/ui/toast' import { toast } from '@/components/ui/toast'
import { cn } from '@/lib/utils'
import { import {
shopPos as shopPosApi, shopPos as shopPosApi,
regions as regionsApi, regions as regionsApi,
@@ -53,6 +54,39 @@ import type { LoadingSheetItemInput } from '@/lib/api/operations'
const ALL = '__all__' const ALL = '__all__'
const STEPS = [
{
n: 1,
label: 'Route & Warehouse',
title: 'Where is this load coming from?',
help: "Pick the route this ride will deliver (optional if you're only adding items manually below) and the warehouse every item will be dispatched from.",
},
{
n: 2,
label: 'Ride & Assignment',
title: 'Which vehicle, and who is populating this sheet?',
help: 'Choose the open ride whose vehicle gets loaded, and who is creating this sheet.',
},
{
n: 3,
label: 'Items',
title: 'What should be loaded from Shop POs?',
help: "Expand a shop to tick items and set quantities. If the main warehouse doesn't have enough stock, use \"Take remaining from another warehouse\" to top up the rest.",
},
{
n: 4,
label: 'Additional Items',
title: "Anything to load that's not on a PO?",
help: "Optional — add extra stock for the vehicle that isn't tied to any shop order.",
},
{
n: 5,
label: 'Review & Confirm',
title: 'Double-check before saving',
help: 'This is everything that will be saved to the sheet. Fix anything short before confirming later on the loading sheet list.',
},
] as const
interface ExtraAllocation { interface ExtraAllocation {
warehouseId: string warehouseId: string
quantity: number quantity: number
@@ -115,6 +149,8 @@ function LoadingSheetForm() {
const [rideId, setRideId] = useState('') const [rideId, setRideId] = useState('')
const [populatedByUserId, setPopulatedByUserId] = useState('') const [populatedByUserId, setPopulatedByUserId] = useState('')
const [step, setStep] = useState<1 | 2 | 3 | 4 | 5>(1)
const [viewStoreId, setViewStoreId] = useState('') const [viewStoreId, setViewStoreId] = useState('')
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([]) const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
const [manualItems, setManualItems] = useState<ManualItem[]>([]) const [manualItems, setManualItems] = useState<ManualItem[]>([])
@@ -369,6 +405,17 @@ function LoadingSheetForm() {
return Array.from(map.values()).sort((a, b) => a.itemName.localeCompare(b.itemName)) return Array.from(map.values()).sort((a, b) => a.itemName.localeCompare(b.itemName))
}, [selectedRows, lines, manualItems, itemsList]) }, [selectedRows, lines, manualItems, itemsList])
const reviewShopGroups = useMemo(() => {
const map = new Map<string, { storeId: string; storeName: string; rows: { row: PickedLine; line: PickedLine }[] }>()
for (const row of selectedRows) {
const line = lines[row.shopPOItemId]
const g = map.get(row.storeId) ?? { storeId: row.storeId, storeName: row.storeName, rows: [] }
g.rows.push({ row, line })
map.set(row.storeId, g)
}
return Array.from(map.values()).sort((a, b) => a.storeName.localeCompare(b.storeName))
}, [selectedRows, lines])
const stockOverview = useMemo(() => { const stockOverview = useMemo(() => {
const map = new Map<string, { itemId: string; itemName: string; open: number }>() const map = new Map<string, { itemId: string; itemName: string; open: number }>()
for (const row of rows) { for (const row of rows) {
@@ -441,6 +488,34 @@ function LoadingSheetForm() {
updateExtras(line, line.extras.filter((_, i) => i !== index)) updateExtras(line, line.extras.filter((_, i) => i !== index))
} }
function goNext() {
if (step === 1) {
if (!warehouseId) {
toast.add({ title: 'Select a warehouse first', type: 'error' })
return
}
setStep(2)
return
}
if (step === 2) {
if (!rideId) {
toast.add({ title: 'Select a ride to load the vehicle', type: 'error' })
return
}
if (!populatedByUserId) {
toast.add({ title: 'Select who is populating this sheet', type: 'error' })
return
}
setStep(3)
return
}
if (step < 5) setStep((s) => (s + 1) as typeof step)
}
function goBack() {
setStep((s) => Math.max(1, s - 1) as typeof step)
}
async function handleSubmit() { async function handleSubmit() {
if (!routeId && manualItems.length === 0) { if (!routeId && manualItems.length === 0) {
toast.add({ title: 'Select a route first', type: 'error' }) toast.add({ title: 'Select a route first', type: 'error' })
@@ -541,13 +616,43 @@ function LoadingSheetForm() {
</Button> </Button>
<div> <div>
<h1 className="text-2xl font-semibold text-foreground">{isEdit ? 'Edit Loading Sheet' : 'New Loading Sheet'}</h1> <h1 className="text-2xl font-semibold text-foreground">{isEdit ? 'Edit Loading Sheet' : 'New Loading Sheet'}</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">Work through the steps below to build the loading sheet.</p>
Select a route first. Confirmed Shop POs for that route&apos;s shops will then be
available to pick items from. Pick the ride to load last.
</p>
</div> </div>
</div> </div>
{/* Step indicator */}
<div className="flex flex-wrap items-center gap-2 text-xs">
{STEPS.map((s, idx) => (
<div key={s.n} className="flex items-center gap-2">
<button
type="button"
disabled={s.n > step}
onClick={() => s.n < step && setStep(s.n)}
className={cn(
'flex h-6 w-6 items-center justify-center rounded-full border font-semibold transition-colors',
step === s.n
? 'border-primary bg-primary text-primary-foreground'
: step > s.n
? 'border-primary/40 bg-primary/10 text-primary hover:bg-primary/20'
: 'border-muted-foreground/30 text-muted-foreground',
s.n < step && 'cursor-pointer'
)}
>
{step > s.n ? '✓' : s.n}
</button>
<span className={cn(step === s.n ? 'font-medium text-foreground' : 'text-muted-foreground')}>{s.label}</span>
{idx < STEPS.length - 1 && <div className="mx-1 h-px w-8 bg-border" />}
</div>
))}
</div>
{/* Step title & guidance */}
<div className="rounded-lg bg-muted/40 px-3 py-2">
<p className="text-sm font-medium">{STEPS[step - 1].title}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{STEPS[step - 1].help}</p>
</div>
{step === 1 && (
<div className="rounded-2xl border p-6"> <div className="rounded-2xl border p-6">
<h3 className="mb-4 text-sm font-medium text-foreground">Route &amp; Warehouse</h3> <h3 className="mb-4 text-sm font-medium text-foreground">Route &amp; Warehouse</h3>
<FieldGroup> <FieldGroup>
@@ -616,7 +721,9 @@ function LoadingSheetForm() {
</div> </div>
</FieldGroup> </FieldGroup>
</div> </div>
)}
{step === 3 && (
<div className="rounded-2xl border p-6"> <div className="rounded-2xl border p-6">
<div className="mb-4"> <div className="mb-4">
<h3 className="text-sm font-medium text-foreground">Items</h3> <h3 className="text-sm font-medium text-foreground">Items</h3>
@@ -824,7 +931,9 @@ function LoadingSheetForm() {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
)}
{step === 4 && (
<div className="rounded-2xl border p-6"> <div className="rounded-2xl border p-6">
<div className="mb-4 flex items-center justify-between gap-4"> <div className="mb-4 flex items-center justify-between gap-4">
<div> <div>
@@ -897,7 +1006,9 @@ function LoadingSheetForm() {
</div> </div>
)} )}
</div> </div>
)}
{step === 2 && (
<div className="rounded-2xl border p-6"> <div className="rounded-2xl border p-6">
<h3 className="mb-4 text-sm font-medium text-foreground">Ride &amp; Assignment</h3> <h3 className="mb-4 text-sm font-medium text-foreground">Ride &amp; Assignment</h3>
<FieldGroup> <FieldGroup>
@@ -935,12 +1046,133 @@ function LoadingSheetForm() {
</div> </div>
</FieldGroup> </FieldGroup>
</div> </div>
)}
{step === 5 && (
<div className="flex flex-col gap-4">
<div className="rounded-2xl border p-6">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-medium text-foreground">Route &amp; Warehouse</h3>
<Button type="button" variant="ghost" size="icon" aria-label="Edit route & warehouse" onClick={() => setStep(1)}>
<HugeiconsIcon icon={PencilEdit01Icon} size={16} strokeWidth={2} />
</Button>
</div>
<dl className="grid gap-3 text-sm sm:grid-cols-2 lg:grid-cols-4">
<div>
<dt className="text-xs text-muted-foreground">Region</dt>
<dd className="font-medium text-foreground">{regionsList.find((r) => r.id === regionId)?.name ?? 'All regions'}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">Route</dt>
<dd className="font-medium text-foreground">{routesList.find((r) => r.id === routeId)?.routeName ?? '—'}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">Shop filter</dt>
<dd className="font-medium text-foreground">{storesList.find((s) => s.id === storeId)?.storeName ?? 'All shops'}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">Warehouse</dt>
<dd className="font-medium text-foreground">{warehousesList.find((w) => w.id === warehouseId)?.name ?? '—'}</dd>
</div>
</dl>
</div>
<div className="rounded-2xl border p-6">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-medium text-foreground">Ride &amp; Assignment</h3>
<Button type="button" variant="ghost" size="icon" aria-label="Edit ride & assignment" onClick={() => setStep(2)}>
<HugeiconsIcon icon={PencilEdit01Icon} size={16} strokeWidth={2} />
</Button>
</div>
<dl className="grid gap-3 text-sm sm:grid-cols-2">
<div>
<dt className="text-xs text-muted-foreground">Ride (vehicle)</dt>
<dd className="font-medium text-foreground">
{(() => {
const r = ridesList.find((r) => r.id === rideId)
return r ? `${r.vehicleNumberPlate} — ${r.driverFullName}` : '—'
})()}
</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">Populated by</dt>
<dd className="font-medium text-foreground">{usersList.find((u) => u.id === populatedByUserId)?.fullName ?? '—'}</dd>
</div>
</dl>
</div>
<div className="rounded-2xl border p-6">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-medium text-foreground">Items from Shop POs</h3>
<Button type="button" variant="ghost" size="icon" aria-label="Edit items from Shop POs" onClick={() => setStep(3)}>
<HugeiconsIcon icon={PencilEdit01Icon} size={16} strokeWidth={2} />
</Button>
</div>
{reviewShopGroups.length === 0 ? (
<p className="rounded-xl border px-3 py-4 text-center text-sm text-muted-foreground">No Shop PO items selected.</p>
) : (
<div className="flex flex-col gap-3">
{reviewShopGroups.map((group) => (
<div key={group.storeId} className="overflow-hidden rounded-xl border">
<div className="bg-muted/50 px-3 py-2 text-sm font-medium">{group.storeName}</div>
<table className="w-full text-sm">
<tbody>
{group.rows.map(({ row, line }) => (
<Fragment key={row.shopPOItemId}>
<tr className="border-t">
<td className="px-3 py-2">{row.itemName}</td>
<td className="px-3 py-2 text-right text-muted-foreground">
{warehousesList.find((w) => w.id === warehouseId)?.name}
</td>
<td className="w-24 px-3 py-2 text-right font-semibold">{line.quantity}</td>
</tr>
{line.extras.filter((e) => e.warehouseId && e.quantity > 0).map((extra, idx) => (
<tr key={idx} className="border-t bg-muted/20">
<td className="px-3 py-2 text-xs text-muted-foreground">— top-up</td>
<td className="px-3 py-2 text-right text-muted-foreground">
{warehousesList.find((w) => w.id === extra.warehouseId)?.name}
</td>
<td className="w-24 px-3 py-2 text-right font-semibold">{extra.quantity}</td>
</tr>
))}
</Fragment>
))}
</tbody>
</table>
</div>
))}
</div>
)}
</div>
<div className="rounded-2xl border p-6">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-medium text-foreground">Additional Items</h3>
<Button type="button" variant="ghost" size="icon" aria-label="Edit additional items" onClick={() => setStep(4)}>
<HugeiconsIcon icon={PencilEdit01Icon} size={16} strokeWidth={2} />
</Button>
</div>
{manualItems.length === 0 ? (
<p className="rounded-xl border px-3 py-4 text-center text-sm text-muted-foreground">No additional items.</p>
) : (
<table className="w-full text-sm">
<tbody>
{manualItems.map((m) => (
<tr key={m.key} className="border-t first:border-t-0">
<td className="px-3 py-2">{itemsList.find((i) => i.id === m.itemId)?.name ?? '—'}</td>
<td className="px-3 py-2 text-right text-muted-foreground">{warehousesList.find((w) => w.id === m.warehouseId)?.name ?? '—'}</td>
<td className="w-24 px-3 py-2 text-right font-semibold">{m.quantity}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div>
<div className="w-full rounded-2xl border p-6"> <div className="w-full rounded-2xl border p-6">
<h3 className="text-sm font-medium text-foreground">Loading Sheet Summary</h3> <h3 className="text-sm font-medium text-foreground">Loading Sheet Summary</h3>
<p className="mt-1 text-sm text-muted-foreground"> <p className="mt-1 text-sm text-muted-foreground">
{selectedRows.length} item{selectedRows.length === 1 ? '' : 's'} selected. {selectedRows.length} item{selectedRows.length === 1 ? '' : 's'} from Shop POs, {manualItems.length} additional item{manualItems.length === 1 ? '' : 's'}.
</p> </p>
<Separator className="my-4" /> <Separator className="my-4" />
@@ -969,15 +1201,31 @@ function LoadingSheetForm() {
<p className="text-sm font-medium text-foreground">Total Quantity</p> <p className="text-sm font-medium text-foreground">Total Quantity</p>
<p className="text-xl font-semibold text-foreground">{totalQuantity}</p> <p className="text-xl font-semibold text-foreground">{totalQuantity}</p>
</div> </div>
</div>
</div>
)}
<div className="mt-6 flex justify-end gap-2"> <div className="flex items-center justify-between rounded-2xl border bg-muted/20 p-4">
<Button type="button" variant="outline" onClick={() => router.push('/operations/loading')}> <div>
Cancel {step > 1 && (
<Button type="button" variant="outline" onClick={goBack}>
Back
</Button> </Button>
)}
</div>
<div className="flex gap-2">
<Button type="button" variant="ghost" onClick={() => router.push('/operations/loading')}>
Cancel
</Button>
{step < 5 ? (
<Button type="button" onClick={goNext}>
{step === 1 ? 'Next: Ride & Assignment' : step === 4 ? `Next: Review${(selectedRows.length + manualItems.length) > 0 ? ` (${totalQuantity})` : ''}` : 'Next'}
</Button>
) : (
<Button type="button" disabled={submitting} onClick={handleSubmit}> <Button type="button" disabled={submitting} onClick={handleSubmit}>
{submitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Loading Sheet'} {submitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Loading Sheet'}
</Button> </Button>
</div> )}
</div> </div>
</div> </div>
</div> </div>
@@ -34,7 +34,7 @@ import type { VehicleResponse, DriverProfileResponse } from '@/types/api'
const schema = z.object({ const schema = z.object({
vehicleId: z.string().min(1, 'Vehicle is required'), vehicleId: z.string().min(1, 'Vehicle is required'),
driverProfileId: z.string().min(1, 'Driver is required'), driverProfileId: z.string().min(1, 'Driver is required'),
meterstart: z.number().min(0, 'Meter start must be 0 or more'), meterstart: z.number().min(0, 'Meter start must be 0 or more').optional(),
}) })
type FormValues = z.infer<typeof schema> type FormValues = z.infer<typeof schema>
@@ -54,7 +54,7 @@ export function RideSheet({ open, onClose, onSuccess }: RideSheetProps) {
defaultValues: { defaultValues: {
vehicleId: '', vehicleId: '',
driverProfileId: '', driverProfileId: '',
meterstart: 0, meterstart: undefined,
}, },
}) })
@@ -64,7 +64,7 @@ export function RideSheet({ open, onClose, onSuccess }: RideSheetProps) {
form.reset({ form.reset({
vehicleId: '', vehicleId: '',
driverProfileId: '', driverProfileId: '',
meterstart: 0, meterstart: undefined,
}) })
Promise.all([ Promise.all([
@@ -82,7 +82,7 @@ export function RideSheet({ open, onClose, onSuccess }: RideSheetProps) {
async function onSubmit(values: FormValues) { async function onSubmit(values: FormValues) {
try { try {
await ridesApi.create(values) await ridesApi.create({ ...values, meterstart: values.meterstart ?? 0 })
toast.add({ title: 'Ride created', type: 'success' }) toast.add({ title: 'Ride created', type: 'success' })
onSuccess() onSuccess()
onClose() onClose()
@@ -149,7 +149,6 @@ export function RideSheet({ open, onClose, onSuccess }: RideSheetProps) {
control={form.control} control={form.control}
name="meterstart" name="meterstart"
label="Meter Start (km)" label="Meter Start (km)"
required
render={({ field, invalid }) => ( render={({ field, invalid }) => (
<Input <Input
id="meterstart" id="meterstart"
@@ -157,8 +156,8 @@ export function RideSheet({ open, onClose, onSuccess }: RideSheetProps) {
ref={field.ref} ref={field.ref}
type="number" type="number"
min={0} min={0}
value={Number(field.value as number)} value={field.value === undefined ? '' : Number(field.value as number)}
onChange={(e) => field.onChange(Number(e.target.value))} onChange={(e) => field.onChange(e.target.value === '' ? undefined : Number(e.target.value))}
onBlur={field.onBlur} onBlur={field.onBlur}
placeholder="0" placeholder="0"
aria-invalid={invalid} aria-invalid={invalid}
+3 -6
View File
@@ -1,8 +1,5 @@
import { DashboardClient } from './_components/dashboard-client'
export default function DashboardPage() { export default function DashboardPage() {
return ( return <DashboardClient />
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-semibold text-foreground">Dashboard</h1>
<p className="text-muted-foreground">Welcome to the Distribution ERP.</p>
</div>
)
} }
@@ -151,6 +151,20 @@ export function SalesClient() {
fields: [{ key: 'storeName', primary: true }], fields: [{ key: 'storeName', primary: true }],
minWidth: 180, minWidth: 180,
}, },
{
key: 'storeDetails',
title: 'Store Contact',
fields: ['storeAddress', 'storeContactNumber'],
minWidth: 200,
cellRender: (row) => (
<div className="flex flex-col">
<span className="text-sm">{row.storeAddress ?? '—'}</span>
{row.storeContactNumber && (
<span className="text-xs text-muted-foreground">{row.storeContactNumber}</span>
)}
</div>
),
},
{ {
key: 'totalAmount', key: 'totalAmount',
title: 'Total', title: 'Total',
@@ -172,7 +186,7 @@ export function SalesClient() {
{ {
key: 'createdBy', key: 'createdBy',
title: 'Created By', title: 'Created By',
fields: ['raisedByName'], fields: ['raisedByName', 'createdAt'],
minWidth: 160, minWidth: 160,
cellRender: (row) => ( cellRender: (row) => (
<div className="flex flex-col"> <div className="flex flex-col">
@@ -180,6 +194,7 @@ export function SalesClient() {
{row.raisedByRole && ( {row.raisedByRole && (
<span className="text-xs text-muted-foreground">{row.raisedByRole}</span> <span className="text-xs text-muted-foreground">{row.raisedByRole}</span>
)} )}
<span className="text-xs text-muted-foreground">{fmtDate(row.createdAt)}</span>
</div> </div>
), ),
}, },
@@ -221,12 +236,6 @@ export function SalesClient() {
<span className="text-sm text-muted-foreground">—</span> <span className="text-sm text-muted-foreground">—</span>
), ),
}, },
{
key: 'createdAt',
title: 'Created',
fields: [{ key: 'createdAt', transform: (v) => fmtDate(v as string) }],
width: 140,
},
], ],
rowActions: { rowActions: {
actions: () => [ actions: () => [
+7
View File
@@ -126,4 +126,11 @@
html { html {
@apply font-sans; @apply font-sans;
} }
}
/* react-grid-layout's default resize-handle mark is a fixed dark color that
disappears against dark-mode cards — recolor it to the theme's muted tone. */
.react-grid-item > .react-resizable-handle::after {
border-right-color: var(--muted-foreground);
border-bottom-color: var(--muted-foreground);
} }
+9 -1
View File
@@ -7,6 +7,7 @@ import type {
RideStatus, RideStatus,
CancelRequestType, CancelRequestType,
LoadingSheetStatus, LoadingSheetStatus,
DeliveryTaskStatus,
} from '@/types/api' } from '@/types/api'
import type { PageParams } from './master' import type { PageParams } from './master'
@@ -16,7 +17,7 @@ import type { PageParams } from './master'
export interface RideBody { export interface RideBody {
vehicleId: string vehicleId: string
driverProfileId: string driverProfileId: string
meterstart: number meterstart?: number
} }
export interface RideListParams extends PageParams { export interface RideListParams extends PageParams {
@@ -95,6 +96,10 @@ export interface DeliveryTaskItemQuantity {
quantityFulfilled: number quantityFulfilled: number
} }
export interface DeliveryTaskListParams extends PageParams {
status?: DeliveryTaskStatus
}
export const deliveryTasks = { export const deliveryTasks = {
generate: (rideId: string) => generate: (rideId: string) =>
api.post<DeliveryTaskResponse[]>(`/rides/${rideId}/tasks/generate`).then(r => r.data), api.post<DeliveryTaskResponse[]>(`/rides/${rideId}/tasks/generate`).then(r => r.data),
@@ -102,6 +107,9 @@ export const deliveryTasks = {
listByRide: (rideId: string) => listByRide: (rideId: string) =>
api.get<DeliveryTaskResponse[]>(`/rides/${rideId}/tasks`).then(r => r.data), api.get<DeliveryTaskResponse[]>(`/rides/${rideId}/tasks`).then(r => r.data),
list: (params?: DeliveryTaskListParams) =>
api.get<PaginatedResponse<DeliveryTaskResponse>>('/delivery-tasks', { params }).then(r => r.data),
get: (id: string) => get: (id: string) =>
api.get<DeliveryTaskResponse>(`/delivery-tasks/${id}`).then(r => r.data), api.get<DeliveryTaskResponse>(`/delivery-tasks/${id}`).then(r => r.data),
+6
View File
@@ -4,10 +4,12 @@ import type {
ShopPOResponse, ShopPOResponse,
InvoiceResponse, InvoiceResponse,
InvoiceStatus, InvoiceStatus,
PaymentStatus,
PaymentResponse, PaymentResponse,
PaymentMethod, PaymentMethod,
EntryMode, EntryMode,
ShopPOStatus, ShopPOStatus,
OutstandingPaymentsResponse,
} from '@/types/api' } from '@/types/api'
import type { PageParams } from './master' import type { PageParams } from './master'
@@ -69,12 +71,16 @@ export interface InvoiceListParams extends PageParams {
routeId?: string routeId?: string
shopId?: string shopId?: string
status?: InvoiceStatus status?: InvoiceStatus
paymentStatus?: PaymentStatus
} }
export const invoices = { export const invoices = {
list: (params?: InvoiceListParams) => list: (params?: InvoiceListParams) =>
api.get<PaginatedResponse<InvoiceResponse>>('/invoices', { params }).then(r => r.data), api.get<PaginatedResponse<InvoiceResponse>>('/invoices', { params }).then(r => r.data),
outstandingSummary: () =>
api.get<OutstandingPaymentsResponse>('/invoices/outstanding-summary').then(r => r.data),
get: (id: string) => get: (id: string) =>
api.get<InvoiceResponse>(`/invoices/${id}`).then(r => r.data), api.get<InvoiceResponse>(`/invoices/${id}`).then(r => r.data),
+4
View File
@@ -3,6 +3,7 @@ import type {
PaginatedResponse, PaginatedResponse,
StockBalanceResponse, StockBalanceResponse,
StockMovementResponse, StockMovementResponse,
LowStockItemResponse,
LocationType, LocationType,
MovementType, MovementType,
} from '@/types/api' } from '@/types/api'
@@ -40,6 +41,9 @@ export const stock = {
movements: (params?: StockMovementParams) => movements: (params?: StockMovementParams) =>
api.get<PaginatedResponse<StockMovementResponse>>('/stock/movements', { params }).then(r => r.data), api.get<PaginatedResponse<StockMovementResponse>>('/stock/movements', { params }).then(r => r.data),
lowStock: (locationId?: string) =>
api.get<LowStockItemResponse[]>('/stock/low-stock', { params: { locationId } }).then(r => r.data),
adjust: (body: AdjustmentBody) => adjust: (body: AdjustmentBody) =>
api.post('/stock/adjustment', body).then(r => r.data), api.post('/stock/adjustment', body).then(r => r.data),
+59 -3
View File
@@ -22,6 +22,7 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-grid-layout": "^2.2.4",
"react-hook-form": "^7.86.0", "react-hook-form": "^7.86.0",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"shadcn": "^4.19.0", "shadcn": "^4.19.0",
@@ -5134,6 +5135,12 @@
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-equals": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-4.0.3.tgz",
"integrity": "sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg==",
"license": "MIT"
},
"node_modules/fast-glob": { "node_modules/fast-glob": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
@@ -7017,7 +7024,6 @@
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0" "js-tokens": "^3.0.0 || ^4.0.0"
@@ -8135,7 +8141,6 @@
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"loose-envify": "^1.4.0", "loose-envify": "^1.4.0",
@@ -8260,6 +8265,38 @@
"react": "^19.2.4" "react": "^19.2.4"
} }
}, },
"node_modules/react-draggable": {
"version": "4.7.2",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.7.2.tgz",
"integrity": "sha512-pmqDx72EqhMGoJe/x42ZO2wJ9TIz7qJ5OPnFu6lVWWznEuJumlSjR+J0V4tNyDFhUo6dVvv56FeiTqN+arTgnw==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-grid-layout": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/react-grid-layout/-/react-grid-layout-2.2.4.tgz",
"integrity": "sha512-Eb57FsgOMYOfsUGrMI1ku/FFR+dPNPrE8qo+3hwZubpqVSy4GO9v52DeX50Tl3JDYAlCypP4rmw7Vrqk/zOIvA==",
"license": "MIT",
"dependencies": {
"clsx": "^2.1.1",
"fast-equals": "^4.0.3",
"prop-types": "^15.8.1",
"react-draggable": "^4.4.6",
"react-resizable": "^3.1.3",
"resize-observer-polyfill": "^1.5.1"
},
"peerDependencies": {
"react": ">= 16.3.0",
"react-dom": ">= 16.3.0"
}
},
"node_modules/react-hook-form": { "node_modules/react-hook-form": {
"version": "7.86.0", "version": "7.86.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.86.0.tgz", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.86.0.tgz",
@@ -8280,7 +8317,6 @@
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-leaflet": { "node_modules/react-leaflet": {
@@ -8297,6 +8333,20 @@
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
} }
}, },
"node_modules/react-resizable": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.2.0.tgz",
"integrity": "sha512-3NKQ0SLZV7rs3LQHeXlOzDSRQfFrkX6TVet77/Qk03zqiZyee37b7N8/gwDJAA8UUjRz7PdWCCy49hcso45SMQ==",
"license": "MIT",
"dependencies": {
"prop-types": "15.x",
"react-draggable": "^4.5.0"
},
"peerDependencies": {
"react": ">= 16.3",
"react-dom": ">= 16.3"
}
},
"node_modules/recast": { "node_modules/recast": {
"version": "0.23.21", "version": "0.23.21",
"resolved": "https://registry.npmjs.org/recast/-/recast-0.23.21.tgz", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.21.tgz",
@@ -8372,6 +8422,12 @@
"integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==", "integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
"license": "MIT"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "2.0.0-next.7", "version": "2.0.0-next.7",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
+1
View File
@@ -26,6 +26,7 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-grid-layout": "^2.2.4",
"react-hook-form": "^7.86.0", "react-hook-form": "^7.86.0",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
"shadcn": "^4.19.0", "shadcn": "^4.19.0",
+16
View File
@@ -502,6 +502,15 @@ export interface StockBalanceResponse {
locationName: string locationName: string
} }
export interface LowStockItemResponse {
itemId: string
itemName: string
locationId: string
locationName: string
quantity: number
reorderLevel: number
}
export interface StockMovementResponse { export interface StockMovementResponse {
id: string id: string
itemId: string itemId: string
@@ -630,6 +639,8 @@ export interface ShopPOResponse {
id: string id: string
storeId: string storeId: string
storeName: string storeName: string
storeAddress: string | null
storeContactNumber: string | null
raisedById: string raisedById: string
invoiceId: string | null invoiceId: string | null
totalAmount: number totalAmount: number
@@ -667,6 +678,11 @@ export interface InvoiceResponse {
createdAt: string createdAt: string
} }
export interface OutstandingPaymentsResponse {
invoiceCount: number
outstandingAmount: number
}
export interface PaymentResponse { export interface PaymentResponse {
id: string id: string
invoiceId: string invoiceId: string