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:
@@ -1,4 +1,5 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Distribution.Models;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.DeliveryRide;
|
||||
|
||||
@@ -26,6 +27,16 @@ public class DeliveryTaskController(DeliveryTaskService service) : ControllerBas
|
||||
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}")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
|
||||
@@ -26,6 +26,27 @@ public class DeliveryTaskRepository(AppDBContext context) : IDeliveryTaskReposit
|
||||
.OrderBy(t => t.Sequence)
|
||||
.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) =>
|
||||
await context.DeliveryTasks
|
||||
.AnyAsync(t => t.FK_ShopPOId == shopPOId && t.FK_RideId == rideId);
|
||||
|
||||
@@ -87,6 +87,13 @@ public class DeliveryTaskService(
|
||||
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)
|
||||
{
|
||||
var task = await repo.GetByIdAsync(id);
|
||||
|
||||
@@ -6,6 +6,8 @@ public interface IDeliveryTaskRepository
|
||||
{
|
||||
Task<DeliveryTask?> GetByIdAsync(Guid id);
|
||||
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 AddAsync(DeliveryTask task);
|
||||
void Update(DeliveryTask task);
|
||||
|
||||
@@ -6,8 +6,9 @@ public interface IInvoiceRepository
|
||||
{
|
||||
Task<Models.Invoice?> GetByIdAsync(Guid id);
|
||||
Task<Models.Invoice?> GetByShopPOIdAsync(Guid shopPOId);
|
||||
Task<List<Models.Invoice>> GetAllAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status, int page, int pageSize);
|
||||
Task<int> GetCountAsync(Guid? agentId, Guid? routeId, Guid? shopId, InvoiceStatus? status);
|
||||
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, PaymentStatus? paymentStatus);
|
||||
Task<List<Models.Invoice>> GetOutstandingAsync();
|
||||
Task AddAsync(Models.Invoice invoice);
|
||||
void Update(Models.Invoice invoice);
|
||||
}
|
||||
|
||||
@@ -14,10 +14,18 @@ public class InvoiceController(InvoiceService service, ICurrentUserService curre
|
||||
[FromQuery] Guid? routeId,
|
||||
[FromQuery] Guid? shopId,
|
||||
[FromQuery] InvoiceStatus? status,
|
||||
[FromQuery] PaymentStatus? paymentStatus,
|
||||
[FromQuery] int page = 1,
|
||||
[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);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,3 +21,5 @@ public record InvoiceResponse(
|
||||
decimal TotalAmount,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public record OutstandingPaymentsResponse(int InvoiceCount, decimal OutstandingAmount);
|
||||
|
||||
@@ -18,27 +18,35 @@ public class InvoiceRepository(AppDBContext context) : IInvoiceRepository
|
||||
.Include(i => i.ShopPO)
|
||||
.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
|
||||
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
|
||||
.Where(i => i.IsActive
|
||||
&& (agentId == null || i.ShopPO.RaisedById == agentId)
|
||||
&& (routeId == null || i.ShopPO.Store.FK_RouteId == routeId)
|
||||
&& (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)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.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
|
||||
.Include(i => i.ShopPO).ThenInclude(p => p.Store)
|
||||
.CountAsync(i => i.IsActive
|
||||
&& (agentId == null || i.ShopPO.RaisedById == agentId)
|
||||
&& (routeId == null || i.ShopPO.Store.FK_RouteId == routeId)
|
||||
&& (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) =>
|
||||
await context.Invoices.AddAsync(invoice);
|
||||
|
||||
@@ -24,13 +24,21 @@ public class InvoiceService(
|
||||
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 items = await repo.GetAllAsync(agentId, routeId, shopId, status, page, pageSize);
|
||||
var total = await repo.GetCountAsync(agentId, routeId, shopId, status, paymentStatus);
|
||||
var items = await repo.GetAllAsync(agentId, routeId, shopId, status, paymentStatus, page, pageSize);
|
||||
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)
|
||||
{
|
||||
var invoice = await repo.GetByIdAsync(id)
|
||||
|
||||
@@ -11,7 +11,8 @@ public record CreateItemRequest(
|
||||
Guid? BrandId,
|
||||
Guid BaseUomId,
|
||||
decimal? SalePrice,
|
||||
List<UomConversionRequest>? Conversions);
|
||||
List<UomConversionRequest>? Conversions,
|
||||
decimal? ReorderLevel = null);
|
||||
|
||||
public record UpdateItemRequest(
|
||||
string Name,
|
||||
@@ -21,7 +22,8 @@ public record UpdateItemRequest(
|
||||
Guid? BrandId,
|
||||
Guid BaseUomId,
|
||||
decimal? SalePrice,
|
||||
bool IsActive = true);
|
||||
bool IsActive = true,
|
||||
decimal? ReorderLevel = null);
|
||||
|
||||
public record ReplaceConversionsRequest(List<UomConversionRequest> Conversions);
|
||||
|
||||
@@ -40,6 +42,7 @@ public record ItemSummaryResponse(
|
||||
Guid BaseUomId,
|
||||
string BaseUomName,
|
||||
decimal? SalePrice,
|
||||
decimal? ReorderLevel,
|
||||
Models.SyncSource SyncSource,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt);
|
||||
@@ -58,6 +61,7 @@ public record ItemDetailResponse(
|
||||
Guid BaseUomId,
|
||||
string BaseUomName,
|
||||
decimal? SalePrice,
|
||||
decimal? ReorderLevel,
|
||||
Models.SyncSource SyncSource,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt,
|
||||
|
||||
@@ -13,7 +13,7 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
|
||||
i.FK_SubCategoryId, i.SubCategory?.Name,
|
||||
i.FK_BrandId, i.Brand?.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) =>
|
||||
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_BrandId, i.Brand?.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());
|
||||
|
||||
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_BaseUomId = request.BaseUomId,
|
||||
SalePrice = request.SalePrice,
|
||||
ReorderLevel = request.ReorderLevel,
|
||||
SyncSource = Models.SyncSource.Manual
|
||||
};
|
||||
|
||||
@@ -84,6 +85,7 @@ public class ItemService(IItemRepository repo, IUnitOfWork uow)
|
||||
item.FK_BrandId = request.BrandId;
|
||||
item.FK_BaseUomId = request.BaseUomId;
|
||||
item.SalePrice = request.SalePrice;
|
||||
item.ReorderLevel = request.ReorderLevel;
|
||||
item.IsActive = request.IsActive;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
|
||||
@@ -14,6 +14,8 @@ public record ShopPOResponse(
|
||||
Guid Id,
|
||||
Guid StoreId,
|
||||
string StoreName,
|
||||
string? StoreAddress,
|
||||
string? StoreContactNumber,
|
||||
Guid RaisedById,
|
||||
Guid? InvoiceId,
|
||||
decimal TotalAmount,
|
||||
|
||||
@@ -14,6 +14,7 @@ public class ShopPOService(
|
||||
{
|
||||
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,
|
||||
p.Store?.StoreAddress, p.Store?.ContactNumber,
|
||||
p.RaisedById, p.InvoiceId, p.TotalAmount, p.Status,
|
||||
p.ConfirmedAt, p.ConfirmedBy, p.FulfilledAt,
|
||||
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<int> GetBalancesCountAsync(Guid? locationId, LocationType? locationType, Guid? itemId);
|
||||
Task<List<StockBalance>> GetBalancesByItemAsync(Guid itemId);
|
||||
Task<List<StockBalance>> GetLowStockAsync(Guid? locationId);
|
||||
Task AddBalanceAsync(StockBalance balance);
|
||||
void UpdateBalance(StockBalance balance);
|
||||
|
||||
|
||||
@@ -22,6 +22,13 @@ public class StockController(StockService service, ICurrentUserService currentUs
|
||||
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")]
|
||||
public async Task<IActionResult> GetItemTotal(Guid itemId)
|
||||
{
|
||||
|
||||
@@ -21,6 +21,14 @@ public record StockTotalResponse(
|
||||
decimal TotalQuantity,
|
||||
List<StockBalanceResponse> Breakdown);
|
||||
|
||||
public record LowStockItemResponse(
|
||||
Guid ItemId,
|
||||
string ItemName,
|
||||
Guid LocationId,
|
||||
string LocationName,
|
||||
decimal Quantity,
|
||||
decimal ReorderLevel);
|
||||
|
||||
// Movement
|
||||
public record StockMovementResponse(
|
||||
Guid Id,
|
||||
|
||||
@@ -41,6 +41,17 @@ public class StockRepository(AppDBContext db) : IStockRepository
|
||||
.Where(b => b.FK_ItemId == itemId)
|
||||
.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) =>
|
||||
await db.StockBalances.AddAsync(balance);
|
||||
|
||||
|
||||
@@ -87,6 +87,15 @@ public class StockService(
|
||||
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)
|
||||
{
|
||||
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()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
|
||||
b.Property<decimal?>("ReorderLevel")
|
||||
.HasColumnType("decimal(65,30)");
|
||||
|
||||
b.Property<decimal?>("SalePrice")
|
||||
.HasColumnType("decimal(65,30)");
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ namespace Distribution.Models
|
||||
|
||||
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>
|
||||
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 “Widgets” 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>
|
||||
)
|
||||
}
|
||||
+293
-105
@@ -20,14 +20,16 @@ import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
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 { cn } from '@/lib/utils'
|
||||
import {
|
||||
shopPos as shopPosApi,
|
||||
regions as regionsApi,
|
||||
routes as routesApi,
|
||||
warehouses as warehousesApi,
|
||||
loadingSheets as loadingSheetsApi,
|
||||
stock as stockApi,
|
||||
getApiError,
|
||||
getPaginatedItems,
|
||||
} from '@/lib/api/index'
|
||||
@@ -35,6 +37,26 @@ import type { LoadingSheetResponse, ShopPOResponse, RegionResponse, RouteRespons
|
||||
import type { LoadingSheetItemInput } from '@/lib/api/operations'
|
||||
|
||||
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 {
|
||||
open: boolean
|
||||
@@ -57,6 +79,8 @@ interface PickedLine {
|
||||
}
|
||||
|
||||
export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: LoadingSheetItemsDialogProps) {
|
||||
const [step, setStep] = useState<1 | 2 | 3>(1)
|
||||
|
||||
const [regionsList, setRegionsList] = useState<RegionResponse[]>([])
|
||||
const [routesList, setRoutesList] = useState<RouteResponse[]>([])
|
||||
const [warehousesList, setWarehousesList] = useState<WarehouseResponse[]>([])
|
||||
@@ -65,22 +89,27 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
|
||||
const [routeId, setRouteId] = useState('')
|
||||
const [warehouseId, setWarehouseId] = useState('')
|
||||
const [shopId, setShopId] = useState('')
|
||||
const [viewStoreId, setViewStoreId] = useState('')
|
||||
const [expandedStores, setExpandedStores] = useState<Set<string>>(new Set())
|
||||
|
||||
const [pos, setPos] = useState<ShopPOResponse[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [lines, setLines] = useState<Record<string, PickedLine>>({})
|
||||
|
||||
const [stockByItem, setStockByItem] = useState<Record<string, number>>({})
|
||||
const [stockLoading, setStockLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setStep(1)
|
||||
setRegionId('')
|
||||
setRouteId('')
|
||||
setWarehouseId('')
|
||||
setShopId('')
|
||||
setViewStoreId('')
|
||||
setExpandedStores(new Set())
|
||||
setPos([])
|
||||
setLines({})
|
||||
setStockByItem({})
|
||||
|
||||
Promise.all([
|
||||
regionsApi.list({ pageSize: 200 }),
|
||||
@@ -108,7 +137,6 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
|
||||
if (!open) return
|
||||
setLines({})
|
||||
setShopId('')
|
||||
setViewStoreId('')
|
||||
if (!routeId) {
|
||||
setPos([])
|
||||
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' }))
|
||||
.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 result: PickedLine[] = []
|
||||
@@ -164,22 +216,25 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
|
||||
[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 map = new Map<string, { itemName: string; qty: number; shops: Set<string> }>()
|
||||
for (const row of rows) {
|
||||
const line = lines[row.shopPOItemId] ?? row
|
||||
if (!line.checked || !(line.quantity > 0)) continue
|
||||
const e = map.get(row.itemId) ?? { itemName: row.itemName, qty: 0, shops: new Set<string>() }
|
||||
const map = new Map<string, { itemId: string; itemName: string; qty: number; available: number; shops: Set<string> }>()
|
||||
for (const row of selectedRows) {
|
||||
const line = lines[row.shopPOItemId]
|
||||
const e = map.get(row.itemId) ?? { itemId: row.itemId, itemName: row.itemName, qty: 0, available: stockByItem[row.itemId] ?? 0, shops: new Set<string>() }
|
||||
e.qty += line.quantity
|
||||
e.shops.add(row.storeId)
|
||||
map.set(row.itemId, e)
|
||||
}
|
||||
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 shortItems = summary.filter((e) => e.qty > e.available)
|
||||
|
||||
function toggleLine(line: PickedLine, checked: boolean) {
|
||||
setLines((prev) => ({ ...prev, [line.shopPOItemId]: { ...line, checked } }))
|
||||
@@ -189,8 +244,17 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
|
||||
setLines((prev) => ({ ...prev, [line.shopPOItemId]: { ...line, quantity } }))
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!sheet) return
|
||||
function toggleExpanded(storeId: string) {
|
||||
setExpandedStores((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(storeId)) next.delete(storeId)
|
||||
else next.add(storeId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
if (step === 1) {
|
||||
if (!routeId) {
|
||||
toast.add({ title: 'Select a route first', type: 'error' })
|
||||
return
|
||||
@@ -199,13 +263,26 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
|
||||
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) {
|
||||
setStep(2)
|
||||
return
|
||||
}
|
||||
if (step === 2) {
|
||||
if (selectedRows.length === 0) {
|
||||
toast.add({ title: 'Select at least one item', type: 'error' })
|
||||
return
|
||||
}
|
||||
setStep(3)
|
||||
}
|
||||
}
|
||||
|
||||
const items: LoadingSheetItemInput[] = selected.map((r) => {
|
||||
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]
|
||||
return {
|
||||
fK_ItemId: line.itemId,
|
||||
@@ -247,21 +324,55 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
|
||||
|
||||
if (!sheet) return null
|
||||
|
||||
const selectedWarehouseName = warehousesList.find((w) => w.id === warehouseId)?.name ?? ''
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="overflow-y-auto sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Set Loading Sheet Items</DialogTitle>
|
||||
<DialogDescription>
|
||||
Select a route first. Confirmed Shop POs for that route will then be available to pick
|
||||
items from.
|
||||
{sheet.status === 'Confirmed' && (
|
||||
<> This sheet is already confirmed, so the items you pick are saved on a new planned sheet for the same ride.</>
|
||||
)}
|
||||
{sheet.status === 'Confirmed'
|
||||
? 'This sheet is already confirmed, so the items you pick are saved on a new planned sheet for the same ride.'
|
||||
: 'Work through the steps below to build the loading sheet.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
{/* Step indicator */}
|
||||
<div className="flex 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: 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)}>
|
||||
@@ -291,21 +402,6 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
|
||||
</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 ?? '')}>
|
||||
@@ -320,11 +416,31 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
|
||||
</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">
|
||||
{!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 ? (
|
||||
{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>
|
||||
@@ -333,85 +449,54 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
|
||||
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="flex items-center gap-3 border-t px-3 py-2 first:border-t-0">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</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>
|
||||
) : (
|
||||
{expanded && (
|
||||
<div className="overflow-x-auto border-t bg-muted/20">
|
||||
<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>
|
||||
<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-left font-medium">PO</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>
|
||||
{viewGroup?.rows.map((row) => {
|
||||
{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">
|
||||
@@ -421,17 +506,30 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
|
||||
/>
|
||||
</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={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>
|
||||
)
|
||||
@@ -439,12 +537,102 @@ export function LoadingSheetItemsDialog({ open, onClose, onSuccess, sheet }: Loa
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" onClick={() => setViewStoreId('')}>Done</Button>
|
||||
)}
|
||||
</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's
|
||||
in stock at {selectedWarehouseName}. Saving will still work, but confirming this sheet will fail until the
|
||||
quantities are reduced to what'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>
|
||||
</Dialog>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { Fragment, Suspense, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { ArrowLeft01Icon, ViewIcon } from '@hugeicons/core-free-icons'
|
||||
import { ArrowLeft01Icon, ViewIcon, PencilEdit01Icon } from '@hugeicons/core-free-icons'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -25,6 +25,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { FieldGroup } from '@/components/ui/field'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
shopPos as shopPosApi,
|
||||
regions as regionsApi,
|
||||
@@ -53,6 +54,39 @@ import type { LoadingSheetItemInput } from '@/lib/api/operations'
|
||||
|
||||
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 {
|
||||
warehouseId: string
|
||||
quantity: number
|
||||
@@ -115,6 +149,8 @@ function LoadingSheetForm() {
|
||||
const [rideId, setRideId] = useState('')
|
||||
const [populatedByUserId, setPopulatedByUserId] = useState('')
|
||||
|
||||
const [step, setStep] = useState<1 | 2 | 3 | 4 | 5>(1)
|
||||
|
||||
const [viewStoreId, setViewStoreId] = useState('')
|
||||
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
|
||||
const [manualItems, setManualItems] = useState<ManualItem[]>([])
|
||||
@@ -369,6 +405,17 @@ function LoadingSheetForm() {
|
||||
return Array.from(map.values()).sort((a, b) => a.itemName.localeCompare(b.itemName))
|
||||
}, [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 map = new Map<string, { itemId: string; itemName: string; open: number }>()
|
||||
for (const row of rows) {
|
||||
@@ -441,6 +488,34 @@ function LoadingSheetForm() {
|
||||
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() {
|
||||
if (!routeId && manualItems.length === 0) {
|
||||
toast.add({ title: 'Select a route first', type: 'error' })
|
||||
@@ -541,13 +616,43 @@ function LoadingSheetForm() {
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">{isEdit ? 'Edit Loading Sheet' : 'New Loading Sheet'}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select a route first. Confirmed Shop POs for that route's shops will then be
|
||||
available to pick items from. Pick the ride to load last.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Work through the steps below to build the loading sheet.</p>
|
||||
</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">
|
||||
<h3 className="mb-4 text-sm font-medium text-foreground">Route & Warehouse</h3>
|
||||
<FieldGroup>
|
||||
@@ -616,7 +721,9 @@ function LoadingSheetForm() {
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="rounded-2xl border p-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-medium text-foreground">Items</h3>
|
||||
@@ -824,7 +931,9 @@ function LoadingSheetForm() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<div className="rounded-2xl border p-6">
|
||||
<div className="mb-4 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
@@ -897,7 +1006,9 @@ function LoadingSheetForm() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="rounded-2xl border p-6">
|
||||
<h3 className="mb-4 text-sm font-medium text-foreground">Ride & Assignment</h3>
|
||||
<FieldGroup>
|
||||
@@ -935,12 +1046,133 @@ function LoadingSheetForm() {
|
||||
</div>
|
||||
</FieldGroup>
|
||||
</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 & 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 & 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 className="w-full rounded-2xl border p-6">
|
||||
<h3 className="text-sm font-medium text-foreground">Loading Sheet Summary</h3>
|
||||
<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>
|
||||
|
||||
<Separator className="my-4" />
|
||||
@@ -969,15 +1201,31 @@ function LoadingSheetForm() {
|
||||
<p className="text-sm font-medium text-foreground">Total Quantity</p>
|
||||
<p className="text-xl font-semibold text-foreground">{totalQuantity}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => router.push('/operations/loading')}>
|
||||
<div className="flex items-center justify-between rounded-2xl border bg-muted/20 p-4">
|
||||
<div>
|
||||
{step > 1 && (
|
||||
<Button type="button" variant="outline" onClick={goBack}>
|
||||
Back
|
||||
</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}>
|
||||
{submitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Loading Sheet'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@ import type { VehicleResponse, DriverProfileResponse } from '@/types/api'
|
||||
const schema = z.object({
|
||||
vehicleId: z.string().min(1, 'Vehicle 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>
|
||||
@@ -54,7 +54,7 @@ export function RideSheet({ open, onClose, onSuccess }: RideSheetProps) {
|
||||
defaultValues: {
|
||||
vehicleId: '',
|
||||
driverProfileId: '',
|
||||
meterstart: 0,
|
||||
meterstart: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -64,7 +64,7 @@ export function RideSheet({ open, onClose, onSuccess }: RideSheetProps) {
|
||||
form.reset({
|
||||
vehicleId: '',
|
||||
driverProfileId: '',
|
||||
meterstart: 0,
|
||||
meterstart: undefined,
|
||||
})
|
||||
|
||||
Promise.all([
|
||||
@@ -82,7 +82,7 @@ export function RideSheet({ open, onClose, onSuccess }: RideSheetProps) {
|
||||
|
||||
async function onSubmit(values: FormValues) {
|
||||
try {
|
||||
await ridesApi.create(values)
|
||||
await ridesApi.create({ ...values, meterstart: values.meterstart ?? 0 })
|
||||
toast.add({ title: 'Ride created', type: 'success' })
|
||||
onSuccess()
|
||||
onClose()
|
||||
@@ -149,7 +149,6 @@ export function RideSheet({ open, onClose, onSuccess }: RideSheetProps) {
|
||||
control={form.control}
|
||||
name="meterstart"
|
||||
label="Meter Start (km)"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="meterstart"
|
||||
@@ -157,8 +156,8 @@ export function RideSheet({ open, onClose, onSuccess }: RideSheetProps) {
|
||||
ref={field.ref}
|
||||
type="number"
|
||||
min={0}
|
||||
value={Number(field.value as number)}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
value={field.value === undefined ? '' : Number(field.value as number)}
|
||||
onChange={(e) => field.onChange(e.target.value === '' ? undefined : Number(e.target.value))}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="0"
|
||||
aria-invalid={invalid}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { DashboardClient } from './_components/dashboard-client'
|
||||
|
||||
export default function DashboardPage() {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
return <DashboardClient />
|
||||
}
|
||||
|
||||
@@ -151,6 +151,20 @@ export function SalesClient() {
|
||||
fields: [{ key: 'storeName', primary: true }],
|
||||
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',
|
||||
title: 'Total',
|
||||
@@ -172,7 +186,7 @@ export function SalesClient() {
|
||||
{
|
||||
key: 'createdBy',
|
||||
title: 'Created By',
|
||||
fields: ['raisedByName'],
|
||||
fields: ['raisedByName', 'createdAt'],
|
||||
minWidth: 160,
|
||||
cellRender: (row) => (
|
||||
<div className="flex flex-col">
|
||||
@@ -180,6 +194,7 @@ export function SalesClient() {
|
||||
{row.raisedByRole && (
|
||||
<span className="text-xs text-muted-foreground">{row.raisedByRole}</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">{fmtDate(row.createdAt)}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -221,12 +236,6 @@ export function SalesClient() {
|
||||
<span className="text-sm text-muted-foreground">—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
title: 'Created',
|
||||
fields: [{ key: 'createdAt', transform: (v) => fmtDate(v as string) }],
|
||||
width: 140,
|
||||
},
|
||||
],
|
||||
rowActions: {
|
||||
actions: () => [
|
||||
|
||||
@@ -127,3 +127,10 @@
|
||||
@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);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
RideStatus,
|
||||
CancelRequestType,
|
||||
LoadingSheetStatus,
|
||||
DeliveryTaskStatus,
|
||||
} from '@/types/api'
|
||||
|
||||
import type { PageParams } from './master'
|
||||
@@ -16,7 +17,7 @@ import type { PageParams } from './master'
|
||||
export interface RideBody {
|
||||
vehicleId: string
|
||||
driverProfileId: string
|
||||
meterstart: number
|
||||
meterstart?: number
|
||||
}
|
||||
|
||||
export interface RideListParams extends PageParams {
|
||||
@@ -95,6 +96,10 @@ export interface DeliveryTaskItemQuantity {
|
||||
quantityFulfilled: number
|
||||
}
|
||||
|
||||
export interface DeliveryTaskListParams extends PageParams {
|
||||
status?: DeliveryTaskStatus
|
||||
}
|
||||
|
||||
export const deliveryTasks = {
|
||||
generate: (rideId: string) =>
|
||||
api.post<DeliveryTaskResponse[]>(`/rides/${rideId}/tasks/generate`).then(r => r.data),
|
||||
@@ -102,6 +107,9 @@ export const deliveryTasks = {
|
||||
listByRide: (rideId: string) =>
|
||||
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) =>
|
||||
api.get<DeliveryTaskResponse>(`/delivery-tasks/${id}`).then(r => r.data),
|
||||
|
||||
|
||||
@@ -4,10 +4,12 @@ import type {
|
||||
ShopPOResponse,
|
||||
InvoiceResponse,
|
||||
InvoiceStatus,
|
||||
PaymentStatus,
|
||||
PaymentResponse,
|
||||
PaymentMethod,
|
||||
EntryMode,
|
||||
ShopPOStatus,
|
||||
OutstandingPaymentsResponse,
|
||||
} from '@/types/api'
|
||||
|
||||
import type { PageParams } from './master'
|
||||
@@ -69,12 +71,16 @@ export interface InvoiceListParams extends PageParams {
|
||||
routeId?: string
|
||||
shopId?: string
|
||||
status?: InvoiceStatus
|
||||
paymentStatus?: PaymentStatus
|
||||
}
|
||||
|
||||
export const invoices = {
|
||||
list: (params?: InvoiceListParams) =>
|
||||
api.get<PaginatedResponse<InvoiceResponse>>('/invoices', { params }).then(r => r.data),
|
||||
|
||||
outstandingSummary: () =>
|
||||
api.get<OutstandingPaymentsResponse>('/invoices/outstanding-summary').then(r => r.data),
|
||||
|
||||
get: (id: string) =>
|
||||
api.get<InvoiceResponse>(`/invoices/${id}`).then(r => r.data),
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
PaginatedResponse,
|
||||
StockBalanceResponse,
|
||||
StockMovementResponse,
|
||||
LowStockItemResponse,
|
||||
LocationType,
|
||||
MovementType,
|
||||
} from '@/types/api'
|
||||
@@ -40,6 +41,9 @@ export const stock = {
|
||||
movements: (params?: StockMovementParams) =>
|
||||
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) =>
|
||||
api.post('/stock/adjustment', body).then(r => r.data),
|
||||
|
||||
|
||||
Generated
+59
-3
@@ -22,6 +22,7 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-grid-layout": "^2.2.4",
|
||||
"react-hook-form": "^7.86.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"shadcn": "^4.19.0",
|
||||
@@ -5134,6 +5135,12 @@
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"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": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz",
|
||||
@@ -7017,7 +7024,6 @@
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
@@ -8135,7 +8141,6 @@
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
@@ -8260,6 +8265,38 @@
|
||||
"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": {
|
||||
"version": "7.86.0",
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.86.0.tgz",
|
||||
@@ -8280,7 +8317,6 @@
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-leaflet": {
|
||||
@@ -8297,6 +8333,20 @@
|
||||
"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": {
|
||||
"version": "0.23.21",
|
||||
"resolved": "https://registry.npmjs.org/recast/-/recast-0.23.21.tgz",
|
||||
@@ -8372,6 +8422,12 @@
|
||||
"integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==",
|
||||
"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": {
|
||||
"version": "2.0.0-next.7",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.7.tgz",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-grid-layout": "^2.2.4",
|
||||
"react-hook-form": "^7.86.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"shadcn": "^4.19.0",
|
||||
|
||||
@@ -502,6 +502,15 @@ export interface StockBalanceResponse {
|
||||
locationName: string
|
||||
}
|
||||
|
||||
export interface LowStockItemResponse {
|
||||
itemId: string
|
||||
itemName: string
|
||||
locationId: string
|
||||
locationName: string
|
||||
quantity: number
|
||||
reorderLevel: number
|
||||
}
|
||||
|
||||
export interface StockMovementResponse {
|
||||
id: string
|
||||
itemId: string
|
||||
@@ -630,6 +639,8 @@ export interface ShopPOResponse {
|
||||
id: string
|
||||
storeId: string
|
||||
storeName: string
|
||||
storeAddress: string | null
|
||||
storeContactNumber: string | null
|
||||
raisedById: string
|
||||
invoiceId: string | null
|
||||
totalAmount: number
|
||||
@@ -667,6 +678,11 @@ export interface InvoiceResponse {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface OutstandingPaymentsResponse {
|
||||
invoiceCount: number
|
||||
outstandingAmount: number
|
||||
}
|
||||
|
||||
export interface PaymentResponse {
|
||||
id: string
|
||||
invoiceId: string
|
||||
|
||||
Reference in New Issue
Block a user