Refactor/main flow #49

Merged
DeepnaPooja merged 6 commits from refactor/main_flow into dev 2026-09-29 15:15:51 +05:30
30 changed files with 4988 additions and 32 deletions
@@ -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)
+6 -2
View File
@@ -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,
+4 -2
View File
@@ -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);
@@ -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)
{
+8
View File
@@ -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)");
+3
View File
@@ -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 &ldquo;Widgets&rdquo; to add some.
</div>
) : (
<div ref={containerRef} className="w-full">
{mounted && (
<ResponsiveGridLayout
width={width}
layouts={visibleLayouts}
breakpoints={DEFAULT_BREAKPOINTS}
cols={DEFAULT_COLS}
rowHeight={ROW_HEIGHT}
margin={GRID_MARGIN}
containerPadding={[0, 0]}
maxRows={Infinity}
dragConfig={{ enabled: editMode }}
resizeConfig={{ enabled: editMode, handles: ['se'] }}
autoSize
onLayoutChange={handleLayoutChange}
>
{visibleWidgets.map(w => (
<div key={w.id} className="relative flex flex-col overflow-auto rounded-2xl border bg-card p-5">
{editMode && (
<button
onClick={() => toggleWidget(w.id, false)}
aria-label="Remove widget"
className="absolute right-3 top-3 flex h-6 w-6 items-center justify-center rounded-full border bg-background text-muted-foreground hover:text-foreground"
>
<HugeiconsIcon icon={Cancel01Icon} size={12} />
</button>
)}
<div className="mb-3.5 shrink-0">
<div className="text-sm font-semibold text-foreground">{w.title}</div>
<div className="mt-1"><CategoryPill cat={w.cat} /></div>
</div>
<WidgetBody id={w.id} data={data} />
</div>
))}
</ResponsiveGridLayout>
)}
</div>
)}
</div>
)
}
@@ -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}
+3 -6
View File
@@ -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 />
}
+7
View File
@@ -126,4 +126,11 @@
html {
@apply font-sans;
}
}
/* react-grid-layout's default resize-handle mark is a fixed dark color that
disappears against dark-mode cards — recolor it to the theme's muted tone. */
.react-grid-item > .react-resizable-handle::after {
border-right-color: var(--muted-foreground);
border-bottom-color: var(--muted-foreground);
}
+9 -1
View File
@@ -7,6 +7,7 @@ import type {
RideStatus,
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),
+6
View File
@@ -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),
+4
View File
@@ -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),
+59 -3
View File
@@ -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",
+1
View File
@@ -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",
+14
View File
@@ -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
@@ -667,6 +676,11 @@ export interface InvoiceResponse {
createdAt: string
}
export interface OutstandingPaymentsResponse {
invoiceCount: number
outstandingAmount: number
}
export interface PaymentResponse {
id: string
invoiceId: string