Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd5acbab76 | |||
| 29f181d5e1 | |||
| dc7e7a70fc |
@@ -19,6 +19,7 @@ public class DeliveryTaskController(DeliveryTaskService service) : ControllerBas
|
||||
}
|
||||
|
||||
[HttpGet("rides/{rideId:guid}/tasks")]
|
||||
[HttpGet("rides/{rideId:guid}/delivery-tasks")]
|
||||
public async Task<IActionResult> GetByRide(Guid rideId)
|
||||
{
|
||||
var result = await service.GetByRideIdAsync(rideId);
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.DriverComplianceConfig;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/driver-compliance-config")]
|
||||
public class DriverComplianceConfigController(DriverComplianceConfigService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Get()
|
||||
{
|
||||
var result = await service.GetAsync();
|
||||
return result is null ? NoContent() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
public async Task<IActionResult> Upsert(UpdateDriverComplianceConfigRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
var result = await service.UpsertAsync(request, userId, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Distribution.Features.DriverComplianceConfig;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.DriverProfile;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/driver-profiles")]
|
||||
public class DriverProfileController(DriverProfileService service) : ControllerBase
|
||||
public class DriverProfileController(
|
||||
DriverProfileService service,
|
||||
DriverComplianceConfigService complianceService,
|
||||
ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||
@@ -23,8 +28,19 @@ public class DriverProfileController(DriverProfileService service) : ControllerB
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(CreateDriverProfileRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
try
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return NotFound(new { error = ex.Message });
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Conflict(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
@@ -40,4 +56,19 @@ public class DriverProfileController(DriverProfileService service) : ControllerB
|
||||
var success = await service.DeactivateAsync(id, ct);
|
||||
return success ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
[HttpGet("compliance")]
|
||||
public async Task<IActionResult> GetCompliance()
|
||||
{
|
||||
var result = await complianceService.GetAsync();
|
||||
return result is null ? NoContent() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPut("compliance")]
|
||||
public async Task<IActionResult> UpsertCompliance(UpdateDriverComplianceConfigRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
var result = await complianceService.UpsertAsync(request, userId, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ public record DriverProfileResponse(
|
||||
DateTime CreatedAt);
|
||||
|
||||
public record CreateDriverProfileRequest(
|
||||
Guid FK_UserId,
|
||||
Guid UserId,
|
||||
string LicenseNumber,
|
||||
DateTime LicenseExpiry,
|
||||
DateTime MedicalClearanceExpiry);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Features.User;
|
||||
using Distribution.Infra.UoW;
|
||||
|
||||
namespace Distribution.Features.DriverProfile;
|
||||
|
||||
public class DriverProfileService(IDriverProfileRepository repo, IUnitOfWork uow)
|
||||
public class DriverProfileService(IDriverProfileRepository repo, IUnitOfWork uow, IUserRepository userRepo)
|
||||
{
|
||||
public async Task<PagedResponse<DriverProfileResponse>> GetAllAsync(int page, int pageSize)
|
||||
{
|
||||
@@ -21,13 +22,17 @@ public class DriverProfileService(IDriverProfileRepository repo, IUnitOfWork uow
|
||||
|
||||
public async Task<DriverProfileResponse> CreateAsync(CreateDriverProfileRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var existing = await repo.GetByUserIdAsync(request.FK_UserId);
|
||||
var user = await userRepo.GetByIdAsync(request.UserId);
|
||||
if (user is null)
|
||||
throw new KeyNotFoundException($"User {request.UserId} not found");
|
||||
|
||||
var existing = await repo.GetByUserIdAsync(request.UserId);
|
||||
if (existing is not null && existing.IsActive)
|
||||
throw new InvalidOperationException("An active driver profile already exists for this user");
|
||||
|
||||
var profile = new global::Distribution.Models.DriverProfile
|
||||
{
|
||||
FK_UserId = request.FK_UserId,
|
||||
FK_UserId = request.UserId,
|
||||
LicenseNumber = request.LicenseNumber,
|
||||
LicenseExpiry = request.LicenseExpiry,
|
||||
MedicalClearanceExpiry = request.MedicalClearanceExpiry
|
||||
|
||||
@@ -6,6 +6,16 @@ namespace Distribution.Features.Grn;
|
||||
[ApiController]
|
||||
public class GrnController(GrnService service, ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/goods-receipts")]
|
||||
public async Task<IActionResult> GetAll(
|
||||
[FromQuery] Guid? purchaseOrderId,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
var result = await service.GetAllAsync(purchaseOrderId, page, pageSize);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("api/goods-receipts/{id:guid}")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
@@ -13,6 +23,19 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("api/goods-receipts")]
|
||||
public async Task<IActionResult> CreateFlat(CreateFlatGrnRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
try
|
||||
{
|
||||
var inner = new CreateGrnRequest(request.WarehouseId, request.Items);
|
||||
var result = await service.CreateAsync(request.PurchaseOrderId, inner, userId, ct);
|
||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpGet("api/purchase-orders/{id:guid}/goods-receipts")]
|
||||
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
|
||||
Ok(await service.GetByPurchaseOrderIdAsync(id));
|
||||
|
||||
@@ -4,6 +4,8 @@ public record GrnItemRequest(Guid PurchaseOrderItemId, int AcceptedQuantity, int
|
||||
|
||||
public record CreateGrnRequest(Guid WarehouseId, List<GrnItemRequest> Items);
|
||||
|
||||
public record CreateFlatGrnRequest(Guid PurchaseOrderId, Guid WarehouseId, List<GrnItemRequest> Items);
|
||||
|
||||
public record GrnItemResponse(
|
||||
Guid Id,
|
||||
Guid PurchaseOrderItemId,
|
||||
|
||||
@@ -11,6 +11,21 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
|
||||
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
|
||||
.FirstOrDefaultAsync(gr => gr.Id == id);
|
||||
|
||||
public async Task<List<Models.GoodsReceipt>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize) =>
|
||||
await context.GoodsReceipts
|
||||
.Include(gr => gr.Warehouse)
|
||||
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
|
||||
.Where(gr => gr.IsActive && (purchaseOrderId == null || gr.FK_PurchaseOrderId == purchaseOrderId))
|
||||
.OrderByDescending(gr => gr.ReceivedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<int> GetCountAsync(Guid? purchaseOrderId) =>
|
||||
await context.GoodsReceipts
|
||||
.Where(gr => gr.IsActive && (purchaseOrderId == null || gr.FK_PurchaseOrderId == purchaseOrderId))
|
||||
.CountAsync();
|
||||
|
||||
public async Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
||||
await context.GoodsReceipts
|
||||
.Include(gr => gr.Warehouse)
|
||||
|
||||
@@ -19,6 +19,13 @@ public class GrnService(
|
||||
new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty,
|
||||
gr.ReceivedAt, gr.CreatedAt, gr.Items.Where(i => i.IsActive).Select(MapItem).ToList());
|
||||
|
||||
public async Task<PagedResponse<GrnResponse>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize)
|
||||
{
|
||||
var total = await repo.GetCountAsync(purchaseOrderId);
|
||||
var items = await repo.GetAllAsync(purchaseOrderId, page, pageSize);
|
||||
return new PagedResponse<GrnResponse>(page, pageSize, total, items.Select(Map).ToList());
|
||||
}
|
||||
|
||||
public async Task<GrnResponse?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var gr = await repo.GetByIdAsync(id);
|
||||
|
||||
@@ -3,6 +3,8 @@ namespace Distribution.Features.Grn;
|
||||
public interface IGrnRepository
|
||||
{
|
||||
Task<Models.GoodsReceipt?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.GoodsReceipt>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize);
|
||||
Task<int> GetCountAsync(Guid? purchaseOrderId);
|
||||
Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId);
|
||||
Task AddAsync(Models.GoodsReceipt entity);
|
||||
|
||||
|
||||
@@ -100,4 +100,43 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPatch("{id:guid}/dispatch")]
|
||||
public async Task<IActionResult> PatchDispatch(Guid id, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
try
|
||||
{
|
||||
var result = await service.ConfirmDispatchAsync(id, userId, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPatch("{id:guid}/receipt")]
|
||||
public async Task<IActionResult> PatchReceipt(Guid id, PatchReceiptRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
try
|
||||
{
|
||||
var result = await service.PatchReceiptAsync(id, request.Items, userId, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPatch("{id:guid}/resolve")]
|
||||
public async Task<IActionResult> PatchResolve(Guid id, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
try
|
||||
{
|
||||
var result = await service.PatchResolveAsync(id, userId, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException) { return NotFound(); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,3 +35,7 @@ public record ConfirmReceiptRequest(int Meterstart);
|
||||
|
||||
public record ResolveDiscrepancyRequest(List<ResolveItemInput> Items, int Meterstart);
|
||||
public record ResolveItemInput(Guid FK_ItemId, decimal QuantityResolved);
|
||||
|
||||
// Combined PATCH endpoints used by the frontend
|
||||
public record PatchReceiptItemInput(Guid ItemId, decimal QuantityReceived);
|
||||
public record PatchReceiptRequest(List<PatchReceiptItemInput> Items);
|
||||
|
||||
@@ -225,6 +225,98 @@ public class LoadingSheetService(
|
||||
return ToResponse(sheet);
|
||||
}
|
||||
|
||||
public async Task<LoadingSheetResponse> PatchReceiptAsync(Guid id, List<PatchReceiptItemInput> items, Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var sheet = await repo.GetByIdAsync(id)
|
||||
?? throw new KeyNotFoundException("Loading sheet not found");
|
||||
|
||||
if (sheet.Status != LoadingSheetStatus.DispatchConfirmed)
|
||||
throw new InvalidOperationException($"Cannot set receipt for a sheet in {sheet.Status} status");
|
||||
|
||||
foreach (var input in items)
|
||||
{
|
||||
var sheetItem = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
|
||||
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this loading sheet");
|
||||
sheetItem.QuantityReceived = input.QuantityReceived;
|
||||
}
|
||||
|
||||
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
|
||||
?? throw new KeyNotFoundException("Ride not found");
|
||||
|
||||
var hasDiscrepancy = sheet.Items.Any(i => i.QuantityReceived != i.QuantityDispatched);
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
if (hasDiscrepancy)
|
||||
{
|
||||
sheet.Status = LoadingSheetStatus.DiscrepancyFlagged;
|
||||
}
|
||||
else
|
||||
{
|
||||
sheet.Status = LoadingSheetStatus.ReceiptConfirmed;
|
||||
sheet.ReceiptConfirmedAt = DateTime.Now;
|
||||
sheet.ReceiptConfirmedBy = userId;
|
||||
|
||||
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityReceived!.Value)).ToList();
|
||||
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
|
||||
|
||||
ride.Status = RideStatus.Active;
|
||||
ride.ActivatedAt = DateTime.Now;
|
||||
ride.Meterstart = 0;
|
||||
rideRepo.Update(ride);
|
||||
}
|
||||
|
||||
repo.Update(sheet);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return ToResponse(sheet);
|
||||
}
|
||||
|
||||
public async Task<LoadingSheetResponse> PatchResolveAsync(Guid id, Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var sheet = await repo.GetByIdAsync(id)
|
||||
?? throw new KeyNotFoundException("Loading sheet not found");
|
||||
|
||||
if (sheet.Status != LoadingSheetStatus.DiscrepancyFlagged)
|
||||
throw new InvalidOperationException($"Cannot resolve a sheet in {sheet.Status} status");
|
||||
|
||||
foreach (var item in sheet.Items)
|
||||
{
|
||||
if (!item.QuantityReceived.HasValue)
|
||||
throw new InvalidOperationException($"Item {item.FK_ItemId} has no received quantity to resolve from");
|
||||
item.QuantityResolved = item.QuantityReceived.Value;
|
||||
}
|
||||
|
||||
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
|
||||
?? throw new KeyNotFoundException("Ride not found");
|
||||
|
||||
sheet.Status = LoadingSheetStatus.Resolved;
|
||||
sheet.ReceiptConfirmedAt = DateTime.Now;
|
||||
sheet.ReceiptConfirmedBy = userId;
|
||||
|
||||
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityResolved!.Value)).ToList();
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
|
||||
|
||||
ride.Status = RideStatus.Active;
|
||||
ride.ActivatedAt = DateTime.Now;
|
||||
ride.Meterstart = 0;
|
||||
rideRepo.Update(ride);
|
||||
|
||||
repo.Update(sheet);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return ToResponse(sheet);
|
||||
}
|
||||
|
||||
private async Task ApplyLoadMovementsAsync(LoadingSheet sheet, Guid vehicleId, List<(Guid ItemId, decimal Qty)> lines, Guid userId)
|
||||
{
|
||||
foreach (var (itemId, qty) in lines.Where(l => l.Qty > 0))
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Payment;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/payments")]
|
||||
public class FlatPaymentController(PaymentService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll(
|
||||
[FromQuery] Guid? storeId,
|
||||
[FromQuery] Guid? saleId,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20)
|
||||
{
|
||||
var result = await service.GetAllAsync(storeId, saleId, page, pageSize);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var result = await service.GetByIdAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(CreateFlatPaymentRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var inner = new CreatePaymentRequest(request.Amount, request.Method);
|
||||
var result = await service.CreateAsync(request.SaleId, inner, ct);
|
||||
return result is null ? NotFound(new { error = "Sale not found or access denied" }) : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@ namespace Distribution.Features.Payment;
|
||||
|
||||
public interface IPaymentRepository
|
||||
{
|
||||
Task<Models.Payment?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.Payment>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize);
|
||||
Task<int> GetCountAsync(Guid? storeId, Guid? saleId);
|
||||
Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId);
|
||||
Task AddAsync(Models.Payment payment);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace Distribution.Features.Payment;
|
||||
|
||||
public record CreatePaymentRequest(decimal Amount, PaymentMethod Method);
|
||||
|
||||
public record CreateFlatPaymentRequest(Guid SaleId, decimal Amount, PaymentMethod Method);
|
||||
|
||||
public record PaymentResponse(
|
||||
Guid Id,
|
||||
Guid SaleId,
|
||||
|
||||
@@ -6,6 +6,26 @@ namespace Distribution.Features.Payment;
|
||||
|
||||
public class PaymentRepository(AppDBContext context) : IPaymentRepository
|
||||
{
|
||||
public async Task<Models.Payment?> GetByIdAsync(Guid id) =>
|
||||
await context.Payments.FirstOrDefaultAsync(p => p.Id == id && p.IsActive);
|
||||
|
||||
public async Task<List<Models.Payment>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize) =>
|
||||
await context.Payments
|
||||
.Where(p => p.IsActive
|
||||
&& (storeId == null || p.FK_StoreId == storeId)
|
||||
&& (saleId == null || p.FK_SaleId == saleId))
|
||||
.OrderByDescending(p => p.CollectedAt)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<int> GetCountAsync(Guid? storeId, Guid? saleId) =>
|
||||
await context.Payments
|
||||
.Where(p => p.IsActive
|
||||
&& (storeId == null || p.FK_StoreId == storeId)
|
||||
&& (saleId == null || p.FK_SaleId == saleId))
|
||||
.CountAsync();
|
||||
|
||||
public async Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId) =>
|
||||
await context.Payments
|
||||
.Where(p => p.FK_SaleId == saleId && p.IsActive)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Distribution.Features.Sale;
|
||||
using Distribution.Features.Stock;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
@@ -14,6 +15,19 @@ public class PaymentService(
|
||||
private static PaymentResponse ToResponse(Models.Payment p) =>
|
||||
new(p.Id, p.FK_SaleId, p.FK_StoreId, p.Amount, p.Method, p.CollectedAt, p.CollectedBy, p.CreatedAt);
|
||||
|
||||
public async Task<PagedResponse<PaymentResponse>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize)
|
||||
{
|
||||
var total = await repo.GetCountAsync(storeId, saleId);
|
||||
var items = await repo.GetAllAsync(storeId, saleId, page, pageSize);
|
||||
return new PagedResponse<PaymentResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
|
||||
}
|
||||
|
||||
public async Task<PaymentResponse?> GetByIdAsync(Guid id)
|
||||
{
|
||||
var payment = await repo.GetByIdAsync(id);
|
||||
return payment is null ? null : ToResponse(payment);
|
||||
}
|
||||
|
||||
public async Task<List<PaymentResponse>?> GetBySaleIdAsync(Guid saleId)
|
||||
{
|
||||
var sale = await saleRepo.GetByIdAsync(saleId);
|
||||
|
||||
@@ -59,6 +59,7 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPatch("{id:guid}/activate")]
|
||||
[HttpPost("{id:guid}/activate")]
|
||||
public async Task<IActionResult> Activate(Guid id, ActivateRideRequest request, CancellationToken ct)
|
||||
{
|
||||
@@ -71,6 +72,7 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPatch("{id:guid}/close")]
|
||||
[HttpPost("{id:guid}/close")]
|
||||
public async Task<IActionResult> Close(Guid id, CloseRideRequest request, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -33,4 +33,15 @@ public class SaleController(SaleService service) : ControllerBase
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||
}
|
||||
|
||||
[HttpPatch("{id:guid}/cancel")]
|
||||
public async Task<IActionResult> Cancel(Guid id, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.CancelAsync(id, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +119,26 @@ public class SaleService(
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<SaleResponse?> CancelAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var sale = await repo.GetByIdAsync(id);
|
||||
if (sale is null || !sale.IsActive) return null;
|
||||
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null;
|
||||
if (sale.Status == SaleStatus.Cancelled)
|
||||
throw new InvalidOperationException("Sale is already cancelled");
|
||||
|
||||
sale.Status = SaleStatus.Cancelled;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(sale); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
var order = await repo.GetOrderBySaleIdAsync(id);
|
||||
if (order is null) return null;
|
||||
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
|
||||
return ToResponse(sale, order, items);
|
||||
}
|
||||
|
||||
public async Task<SaleResponse?> CreateAsync(CreateSaleRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (!await HasStoreScopeAsync(request.StoreId)) return null;
|
||||
|
||||
@@ -93,4 +93,11 @@ public class StoreController(StoreService service) : ControllerBase
|
||||
var result = await service.GetDashboardAsync(id, period);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPatch("{id:guid}/kyc-status")]
|
||||
public async Task<IActionResult> PatchKycStatus(Guid id, PatchKycStatusRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.VerifyKycAsync(id, new VerifyKycRequest(request.KycStatus, null), ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,4 +53,6 @@ public record StoreVisitHistoryItem(Guid VisitId, DateTime VisitDate, Guid RepUs
|
||||
|
||||
public record SchemeEligibilityResponse(bool Eligible, string? Reason);
|
||||
|
||||
public record PatchKycStatusRequest(KycStatus KycStatus);
|
||||
|
||||
public record StoreDashboardResponse(Guid StoreId, string Period, int OrderCount, decimal TotalOrderValue, DateTime? LastOrderDate);
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ export function CategoriesClient() {
|
||||
width: 160,
|
||||
cellRender: (row) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{row.subCategories.length} sub-categories
|
||||
{(row.subCategories ?? []).length} sub-categories
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
||||
+183
-2
@@ -1,6 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Add01Icon, PencilEdit01Icon, Delete01Icon, Tick01Icon, Cancel01Icon } from '@hugeicons/core-free-icons'
|
||||
import { useForm, zodResolver, z } from '@/lib/form'
|
||||
import { FormField } from '@/components/shared/form-field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
@@ -17,7 +19,7 @@ import {
|
||||
import { FieldGroup } from '@/components/ui/field'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { categories as categoriesApi, getApiError } from '@/lib/api/index'
|
||||
import type { CategoryResponse } from '@/types/api'
|
||||
import type { CategoryResponse, SubCategoryResponse } from '@/types/api'
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
@@ -36,6 +38,13 @@ interface CategorySheetProps {
|
||||
export function CategorySheet({ open, onClose, category, onSuccess }: CategorySheetProps) {
|
||||
const isEdit = !!category
|
||||
|
||||
const [subCategories, setSubCategories] = useState<SubCategoryResponse[]>([])
|
||||
const [isAdding, setIsAdding] = useState(false)
|
||||
const [addingName, setAddingName] = useState('')
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editingName, setEditingName] = useState('')
|
||||
const [busyId, setBusyId] = useState<string | null>(null)
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: { name: '', isActive: true },
|
||||
@@ -48,6 +57,11 @@ export function CategorySheet({ open, onClose, category, onSuccess }: CategorySh
|
||||
? { name: category.name, isActive: category.isActive }
|
||||
: { name: '', isActive: true }
|
||||
)
|
||||
setSubCategories(category?.subCategories ?? [])
|
||||
setIsAdding(false)
|
||||
setAddingName('')
|
||||
setEditingId(null)
|
||||
setEditingName('')
|
||||
}
|
||||
}, [open, category])
|
||||
|
||||
@@ -67,6 +81,52 @@ export function CategorySheet({ open, onClose, category, onSuccess }: CategorySh
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAddSubCategory() {
|
||||
const name = addingName.trim()
|
||||
if (!name) return
|
||||
setBusyId('new')
|
||||
try {
|
||||
const created = await categoriesApi.createSubCategory(category!.id, { name, isActive: true })
|
||||
setSubCategories(prev => [...prev, created as SubCategoryResponse])
|
||||
setAddingName('')
|
||||
setIsAdding(false)
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to add sub-category', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveEdit(subId: string) {
|
||||
const name = editingName.trim()
|
||||
if (!name) return
|
||||
setBusyId(subId)
|
||||
try {
|
||||
await categoriesApi.updateSubCategory(category!.id, subId, { name })
|
||||
setSubCategories(prev => prev.map(s => s.id === subId ? { ...s, name } : s))
|
||||
setEditingId(null)
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to update sub-category', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(subId: string) {
|
||||
setBusyId(subId)
|
||||
try {
|
||||
await categoriesApi.deleteSubCategory(subId)
|
||||
setSubCategories(prev => prev.filter(s => s.id !== subId))
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to delete sub-category', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setBusyId(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
||||
@@ -116,6 +176,127 @@ export function CategorySheet({ open, onClose, category, onSuccess }: CategorySh
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
{isEdit && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">Sub-categories</span>
|
||||
{!isAdding && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsAdding(true)}
|
||||
>
|
||||
<HugeiconsIcon icon={Add01Icon} size={14} />
|
||||
Add
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-0.5 rounded-md border bg-muted/30 px-1 py-1">
|
||||
{subCategories.length === 0 && !isAdding && (
|
||||
<p className="px-2 py-2 text-sm text-muted-foreground">No sub-categories yet.</p>
|
||||
)}
|
||||
|
||||
{subCategories.map((sub) => (
|
||||
<div key={sub.id} className="flex items-center gap-1 rounded px-2 py-1 hover:bg-muted/60">
|
||||
{editingId === sub.id ? (
|
||||
<>
|
||||
<Input
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); handleSaveEdit(sub.id) }
|
||||
if (e.key === 'Escape') setEditingId(null)
|
||||
}}
|
||||
className="h-7 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
disabled={busyId === sub.id}
|
||||
onClick={() => handleSaveEdit(sub.id)}
|
||||
>
|
||||
<HugeiconsIcon icon={Tick01Icon} size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => setEditingId(null)}
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} size={14} />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1 text-sm">{sub.name}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => { setEditingId(sub.id); setEditingName(sub.name) }}
|
||||
>
|
||||
<HugeiconsIcon icon={PencilEdit01Icon} size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0 text-destructive hover:text-destructive"
|
||||
disabled={busyId === sub.id}
|
||||
onClick={() => handleDelete(sub.id)}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete01Icon} size={14} />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isAdding && (
|
||||
<div className="flex items-center gap-1 rounded px-2 py-1">
|
||||
<Input
|
||||
value={addingName}
|
||||
onChange={(e) => setAddingName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); handleAddSubCategory() }
|
||||
if (e.key === 'Escape') { setIsAdding(false); setAddingName('') }
|
||||
}}
|
||||
placeholder="Sub-category name"
|
||||
className="h-7 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
disabled={busyId === 'new'}
|
||||
onClick={handleAddSubCategory}
|
||||
>
|
||||
<HugeiconsIcon icon={Tick01Icon} size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => { setIsAdding(false); setAddingName('') }}
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { DashboardCanvas } from '@/components/dashboard/dashboard-canvas'
|
||||
|
||||
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 <DashboardCanvas />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { GridLayout, useContainerWidth, type LayoutItem } from 'react-grid-layout'
|
||||
import 'react-grid-layout/css/styles.css'
|
||||
|
||||
import { useAuth } from '@/contexts/auth-context'
|
||||
import { dashboard, type WidgetLayoutItem } from '@/lib/api/dashboard'
|
||||
import { WIDGET_REGISTRY } from '@/lib/widgets/registry'
|
||||
import { ROLE_DEFAULT_LAYOUTS } from '@/lib/widgets/defaults'
|
||||
import { getApiError } from '@/lib/api'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { WidgetContainer } from './widget-container'
|
||||
import { WidgetPicker } from './widget-picker'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import {
|
||||
PencilEdit01Icon,
|
||||
Tick02Icon,
|
||||
Cancel01Icon,
|
||||
Add01Icon,
|
||||
Refresh01Icon,
|
||||
} from '@hugeicons/core-free-icons'
|
||||
|
||||
// ─── Layout helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
function toGridItems(items: WidgetLayoutItem[], editable: boolean): LayoutItem[] {
|
||||
return items.map(item => {
|
||||
const def = WIDGET_REGISTRY[item.widgetType]
|
||||
return {
|
||||
i: item.i,
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
w: item.w,
|
||||
h: item.h,
|
||||
minW: def?.minW ?? 2,
|
||||
minH: def?.minH ?? 2,
|
||||
...(def?.maxW ? { maxW: def.maxW } : {}),
|
||||
isDraggable: editable,
|
||||
isResizable: editable,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function applyGridChanges(updated: readonly LayoutItem[], current: WidgetLayoutItem[]): WidgetLayoutItem[] {
|
||||
const typeMap = Object.fromEntries(current.map(item => [item.i, item.widgetType]))
|
||||
return updated
|
||||
.filter(l => typeMap[l.i])
|
||||
.map(l => ({ i: l.i, widgetType: typeMap[l.i], x: l.x, y: l.y, w: l.w, h: l.h }))
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function DashboardCanvas() {
|
||||
const { user } = useAuth()
|
||||
const { width, containerRef } = useContainerWidth()
|
||||
|
||||
const [savedLayout, setSavedLayout] = useState<WidgetLayoutItem[]>([])
|
||||
const [editingLayout, setEditingLayout] = useState<WidgetLayoutItem[]>([])
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
|
||||
const roleDefault = ROLE_DEFAULT_LAYOUTS[user.role] ?? []
|
||||
|
||||
// ─── Load ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const loadPreferences = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await dashboard.getPreferences()
|
||||
setSavedLayout(res.layout)
|
||||
} catch {
|
||||
setSavedLayout(roleDefault)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user.role])
|
||||
|
||||
useEffect(() => { loadPreferences() }, [loadPreferences])
|
||||
|
||||
// ─── Edit mode ─────────────────────────────────────────────────────────────
|
||||
|
||||
function startEditing() {
|
||||
setEditingLayout([...savedLayout])
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
function cancelEditing() {
|
||||
setEditingLayout([])
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
async function saveLayout() {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await dashboard.savePreferences(editingLayout)
|
||||
setSavedLayout(editingLayout)
|
||||
setIsEditing(false)
|
||||
toast.add({ title: 'Dashboard saved', type: 'success' })
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function resetToDefaults() {
|
||||
setEditingLayout([...roleDefault])
|
||||
}
|
||||
|
||||
// ─── Grid mutations ────────────────────────────────────────────────────────
|
||||
|
||||
function onGridChange(updated: readonly LayoutItem[]) {
|
||||
if (!isEditing) return
|
||||
setEditingLayout(prev => applyGridChanges(updated, prev))
|
||||
}
|
||||
|
||||
function removeWidget(id: string) {
|
||||
setEditingLayout(prev => prev.filter(item => item.i !== id))
|
||||
}
|
||||
|
||||
function addWidget(widgetType: string) {
|
||||
const def = WIDGET_REGISTRY[widgetType]
|
||||
if (!def) return
|
||||
const newItem: WidgetLayoutItem = {
|
||||
i: `${widgetType}-${Date.now()}`,
|
||||
widgetType,
|
||||
x: 0,
|
||||
y: Infinity,
|
||||
w: def.defaultW,
|
||||
h: def.defaultH,
|
||||
}
|
||||
setEditingLayout(prev => [...prev, newItem])
|
||||
}
|
||||
|
||||
// ─── Render ────────────────────────────────────────────────────────────────
|
||||
|
||||
const activeLayout = isEditing ? editingLayout : savedLayout
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Skeleton className="h-7 w-28" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</div>
|
||||
<Skeleton className="h-9 w-24" />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-48" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Dashboard</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isEditing
|
||||
? 'Drag to reposition · Resize from the corner · Add or remove widgets'
|
||||
: `Welcome back, ${user.fullName}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={resetToDefaults} disabled={isSaving}>
|
||||
<HugeiconsIcon icon={Refresh01Icon} size={14} strokeWidth={2} />
|
||||
Reset
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setPickerOpen(true)} disabled={isSaving}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={14} strokeWidth={2} />
|
||||
Add widget
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={cancelEditing} disabled={isSaving}>
|
||||
<HugeiconsIcon icon={Cancel01Icon} size={14} strokeWidth={2} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={saveLayout} disabled={isSaving}>
|
||||
<HugeiconsIcon icon={Tick02Icon} size={14} strokeWidth={2} />
|
||||
{isSaving ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={startEditing}>
|
||||
<HugeiconsIcon icon={PencilEdit01Icon} size={14} strokeWidth={2} />
|
||||
Customize
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid */}
|
||||
{activeLayout.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-2xl border border-dashed py-20 text-center">
|
||||
<p className="text-sm font-medium text-foreground">Your dashboard is empty</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Click Customize, then Add widget to get started.
|
||||
</p>
|
||||
{!isEditing && (
|
||||
<Button variant="outline" size="sm" className="mt-4" onClick={startEditing}>
|
||||
<HugeiconsIcon icon={PencilEdit01Icon} size={14} strokeWidth={2} />
|
||||
Customize
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div ref={containerRef}>
|
||||
<GridLayout
|
||||
width={width}
|
||||
layout={toGridItems(activeLayout, isEditing)}
|
||||
gridConfig={{ cols: 12, rowHeight: 80, margin: [12, 12] as const }}
|
||||
dragConfig={{ handle: '.drag-handle' }}
|
||||
resizeConfig={{ handles: ['se'] as const }}
|
||||
onLayoutChange={onGridChange}
|
||||
className="relative"
|
||||
>
|
||||
{activeLayout.map(item => (
|
||||
<div key={item.i}>
|
||||
<WidgetContainer
|
||||
widgetType={item.widgetType}
|
||||
editMode={isEditing}
|
||||
onRemove={() => removeWidget(item.i)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</GridLayout>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Widget Picker */}
|
||||
<WidgetPicker
|
||||
open={pickerOpen}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
role={user.role}
|
||||
currentLayout={editingLayout}
|
||||
onAdd={addWidget}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import { WIDGET_REGISTRY } from '@/lib/widgets/registry'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Cancel01Icon, DragDropVerticalIcon } from '@hugeicons/core-free-icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface WidgetContainerProps {
|
||||
widgetType: string
|
||||
editMode: boolean
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
export function WidgetContainer({ widgetType, editMode, onRemove }: WidgetContainerProps) {
|
||||
const def = WIDGET_REGISTRY[widgetType]
|
||||
const Component = def?.component
|
||||
|
||||
return (
|
||||
<Card className={cn('flex h-full flex-col overflow-hidden transition-shadow', editMode && 'ring-1 ring-border')}>
|
||||
<CardHeader className="flex-row items-center justify-between gap-2 pb-2 pt-3">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{editMode && (
|
||||
<span
|
||||
className="drag-handle shrink-0 cursor-grab touch-none text-muted-foreground active:cursor-grabbing"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<HugeiconsIcon icon={DragDropVerticalIcon} size={14} strokeWidth={2} />
|
||||
</span>
|
||||
)}
|
||||
<CardTitle className="truncate">{def?.label ?? widgetType}</CardTitle>
|
||||
</div>
|
||||
{editMode && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={onRemove}
|
||||
aria-label={`Remove ${def?.label ?? widgetType} widget`}
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} size={14} strokeWidth={2} />
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-hidden pt-0">
|
||||
{Component ? (
|
||||
<Component />
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Unknown widget type: {widgetType}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
import { getWidgetsForRole, type WidgetDefinition } from '@/lib/widgets/registry'
|
||||
import type { Role } from '@/contexts/auth-context'
|
||||
import type { WidgetLayoutItem } from '@/lib/api/dashboard'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
} from '@/components/ui/sheet'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Add01Icon } from '@hugeicons/core-free-icons'
|
||||
|
||||
interface WidgetPickerProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
role: Role
|
||||
currentLayout: WidgetLayoutItem[]
|
||||
onAdd: (widgetType: string) => void
|
||||
}
|
||||
|
||||
export function WidgetPicker({ open, onClose, role, currentLayout, onAdd }: WidgetPickerProps) {
|
||||
const available = getWidgetsForRole(role)
|
||||
const activeTypes = new Set(currentLayout.map(l => l.widgetType))
|
||||
|
||||
function handleAdd(def: WidgetDefinition) {
|
||||
onAdd(def.type)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={v => { if (!v) onClose() }}>
|
||||
<SheetContent side="right" className="flex w-full flex-col gap-0 sm:max-w-sm">
|
||||
<SheetHeader className="px-4 pb-3 pt-4">
|
||||
<SheetTitle>Add Widget</SheetTitle>
|
||||
<SheetDescription>
|
||||
Choose a widget to add to your dashboard.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<Separator />
|
||||
<div className="flex flex-1 flex-col gap-1 overflow-auto p-4">
|
||||
{available.map(def => {
|
||||
const alreadyAdded = activeTypes.has(def.type)
|
||||
return (
|
||||
<div
|
||||
key={def.type}
|
||||
className="flex items-start justify-between gap-3 rounded-xl border p-3"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<p className="text-sm font-medium">{def.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{def.description}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Default size: {def.defaultW}×{def.defaultH}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={alreadyAdded ? 'outline' : 'default'}
|
||||
className="mt-0.5 shrink-0"
|
||||
onClick={() => handleAdd(def)}
|
||||
>
|
||||
<HugeiconsIcon icon={Add01Icon} size={14} strokeWidth={2} />
|
||||
{alreadyAdded ? 'Add another' : 'Add'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { rides } from '@/lib/api/index'
|
||||
import type { RideResponse } from '@/types/api'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
export function ActiveRidesWidget() {
|
||||
const [data, setData] = useState<RideResponse[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await rides.list({ status: 'Active', pageSize: 5, page: 1 })
|
||||
setData(res.items ?? [])
|
||||
setTotal(res.total ?? 0)
|
||||
} catch {
|
||||
// silent — widget shows empty state
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 overflow-hidden">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold">{total}</span>
|
||||
<span className="text-sm text-muted-foreground">active ride{total !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 overflow-auto">
|
||||
{data.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No active rides right now</p>
|
||||
) : (
|
||||
data.map(ride => (
|
||||
<div
|
||||
key={ride.id}
|
||||
className="flex items-center justify-between rounded-xl border px-3 py-2"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium">{ride.driverFullName}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{ride.routeName} · {ride.vehicleNumberPlate}</span>
|
||||
</div>
|
||||
<Badge variant="secondary" className="ml-2 shrink-0">{ride.rideType}</Badge>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { loadingSheets } from '@/lib/api/index'
|
||||
import type { LoadingSheetResponse } from '@/types/api'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
Pending: 'outline',
|
||||
DispatchConfirmed: 'secondary',
|
||||
ReceiptConfirmed: 'default',
|
||||
DiscrepancyFlagged: 'destructive',
|
||||
Resolved: 'default',
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
Pending: 'Pending',
|
||||
DispatchConfirmed: 'Dispatched',
|
||||
ReceiptConfirmed: 'Received',
|
||||
DiscrepancyFlagged: 'Discrepancy',
|
||||
Resolved: 'Resolved',
|
||||
}
|
||||
|
||||
export function LoadingSheetsWidget() {
|
||||
const [data, setData] = useState<LoadingSheetResponse[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await loadingSheets.list({ status: 'Pending', pageSize: 5, page: 1 })
|
||||
setData(res.items ?? [])
|
||||
setTotal(res.total ?? 0)
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 overflow-hidden">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold">{total}</span>
|
||||
<span className="text-sm text-muted-foreground">pending sheet{total !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 overflow-auto">
|
||||
{data.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No pending loading sheets</p>
|
||||
) : (
|
||||
data.map(sheet => (
|
||||
<div key={sheet.id} className="flex items-center justify-between rounded-xl border px-3 py-2">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium">{sheet.warehouseName}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{sheet.items.length} item{sheet.items.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant={STATUS_VARIANT[sheet.status] ?? 'outline'} className="ml-2 shrink-0">
|
||||
{STATUS_LABEL[sheet.status] ?? sheet.status}
|
||||
</Badge>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { purchaseOrders } from '@/lib/api/index'
|
||||
import type { PurchaseOrderResponse } from '@/types/api'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export function PendingPOsWidget() {
|
||||
const [data, setData] = useState<PurchaseOrderResponse[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await purchaseOrders.list({ status: 'Issued', pageSize: 5, page: 1 })
|
||||
setData(res.items ?? [])
|
||||
setTotal(res.total ?? 0)
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 overflow-hidden">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold">{total}</span>
|
||||
<span className="text-sm text-muted-foreground">pending order{total !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 overflow-auto">
|
||||
{data.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No pending purchase orders</p>
|
||||
) : (
|
||||
data.map(po => (
|
||||
<div key={po.id} className="flex items-center justify-between rounded-xl border px-3 py-2">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium">{po.supplierName}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{po.items.length} line{po.items.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<span className="ml-2 shrink-0 text-sm font-semibold">
|
||||
Rs. {po.items.reduce((s, i) => s + i.quantity * i.unitCost, 0).toLocaleString('en-LK', { minimumFractionDigits: 0 })}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { orders } from '@/lib/api/index'
|
||||
import type { OrderResponse } from '@/types/api'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
const STATUS_VARIANT: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
Pending: 'outline',
|
||||
PartiallyFulfilled: 'secondary',
|
||||
Fulfilled: 'default',
|
||||
Cancelled: 'destructive',
|
||||
}
|
||||
|
||||
export function RecentOrdersWidget() {
|
||||
const [data, setData] = useState<OrderResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await orders.list({ pageSize: 6, page: 1 })
|
||||
setData(res.items ?? [])
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-2 overflow-hidden">
|
||||
{data.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No orders yet</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5 overflow-auto">
|
||||
{data.map(order => (
|
||||
<div key={order.id} className="flex items-center justify-between rounded-xl border px-3 py-2">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate font-mono text-xs text-muted-foreground">
|
||||
#{order.id.slice(0, 8).toUpperCase()}
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
Rs. {order.totalAmount.toLocaleString('en-LK', { minimumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant={STATUS_VARIANT[order.status] ?? 'outline'} className="ml-2 shrink-0">
|
||||
{order.status}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { stock } from '@/lib/api/index'
|
||||
import type { StockBalanceResponse } from '@/types/api'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
const LOW_STOCK_THRESHOLD = 10
|
||||
|
||||
export function StockAlertsWidget() {
|
||||
const [alerts, setAlerts] = useState<StockBalanceResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await stock.balances({ pageSize: 100, page: 1 })
|
||||
const low = (res.items ?? []).filter(b => b.quantity <= LOW_STOCK_THRESHOLD)
|
||||
setAlerts(low)
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const outOfStock = alerts.filter(a => a.quantity === 0)
|
||||
const lowStock = alerts.filter(a => a.quantity > 0)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-3 overflow-hidden">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold">{alerts.length}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
alert{alerts.length !== 1 ? 's' : ''}
|
||||
{outOfStock.length > 0 && (
|
||||
<span className="ml-1 text-destructive">({outOfStock.length} out of stock)</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 overflow-auto">
|
||||
{alerts.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">All stock levels are healthy</p>
|
||||
) : (
|
||||
alerts.map(b => (
|
||||
<div key={`${b.itemId}-${b.locationId}`} className="flex items-center justify-between rounded-xl border px-3 py-2">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium">{b.itemName}</span>
|
||||
<span className="text-xs text-muted-foreground capitalize">{b.locationType}</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant={b.quantity === 0 ? 'destructive' : 'outline'}
|
||||
className="ml-2 shrink-0"
|
||||
>
|
||||
{b.quantity === 0 ? 'Out of stock' : `${b.quantity} left`}
|
||||
</Badge>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { sales } from '@/lib/api/index'
|
||||
import type { SaleResponse } from '@/types/api'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export function TodaySalesWidget() {
|
||||
const [items, setItems] = useState<SaleResponse[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
try {
|
||||
const res = await sales.list({ from: today, to: today, pageSize: 50, page: 1 })
|
||||
setItems(res.items ?? [])
|
||||
setTotal(res.total ?? 0)
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [fetchData])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-10 w-32" />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Skeleton className="h-16" />
|
||||
<Skeleton className="h-16" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const revenue = items.reduce((sum, s) => sum + s.totalAmount, 0)
|
||||
const completed = items.filter(s => s.status === 'Completed').length
|
||||
const pending = items.filter(s => s.status === 'Pending').length
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col gap-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-3xl font-bold">
|
||||
Rs. {revenue.toLocaleString('en-LK', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">revenue today</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<div className="flex flex-col rounded-xl border px-3 py-2">
|
||||
<span className="text-xl font-bold">{total}</span>
|
||||
<span className="text-xs text-muted-foreground">total</span>
|
||||
</div>
|
||||
<div className="flex flex-col rounded-xl border px-3 py-2">
|
||||
<span className="text-xl font-bold text-green-600">{completed}</span>
|
||||
<span className="text-xs text-muted-foreground">completed</span>
|
||||
</div>
|
||||
<div className="flex flex-col rounded-xl border px-3 py-2">
|
||||
<span className="text-xl font-bold text-amber-500">{pending}</span>
|
||||
<span className="text-xs text-muted-foreground">pending</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn('rounded-2xl border bg-card text-card-foreground shadow-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn('flex flex-col gap-1 px-4 pt-4 pb-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
return (
|
||||
<p
|
||||
data-slot="card-title"
|
||||
className={cn('text-sm font-semibold leading-none', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<'p'>) {
|
||||
return (
|
||||
<p
|
||||
data-slot="card-description"
|
||||
className={cn('text-xs text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn('px-4 pb-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn('flex items-center px-4 pb-4', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }
|
||||
@@ -95,3 +95,4 @@ Ordered by **data dependency** — each module can only work once the modules ab
|
||||
- Pages live in `app/(dashboard)/<module>/page.tsx`.
|
||||
- Shared UI goes in `components/shared/`, module-specific UI stays co-located with the page.
|
||||
- Permissions are checked via `useAuth()` — never hard-code role names in pages.
|
||||
|
||||
|
||||
@@ -75,10 +75,13 @@ export const categories = {
|
||||
api.put<CategoryResponse>(`/categories/${id}`, body).then(r => r.data),
|
||||
|
||||
createSubCategory: (categoryId: string, body: SubCategoryBody) =>
|
||||
api.post(`/categories/${categoryId}/sub-categories`, body).then(r => r.data),
|
||||
api.post(`/categories/${categoryId}/subcategories`, body).then(r => r.data),
|
||||
|
||||
updateSubCategory: (categoryId: string, subId: string, body: Partial<SubCategoryBody>) =>
|
||||
api.put(`/categories/${categoryId}/sub-categories/${subId}`, body).then(r => r.data),
|
||||
updateSubCategory: (_categoryId: string, subId: string, body: Partial<SubCategoryBody>) =>
|
||||
api.put(`/subcategories/${subId}`, body).then(r => r.data),
|
||||
|
||||
deleteSubCategory: (subId: string) =>
|
||||
api.delete(`/subcategories/${subId}`),
|
||||
}
|
||||
|
||||
// ─── Brands ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
export interface WidgetLayoutItem {
|
||||
i: string
|
||||
widgetType: string
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
|
||||
export interface DashboardPreferencesResponse {
|
||||
userId: string | null
|
||||
role: string
|
||||
isDefault: boolean
|
||||
layout: WidgetLayoutItem[]
|
||||
}
|
||||
|
||||
export const dashboard = {
|
||||
getPreferences: () =>
|
||||
api.get<DashboardPreferencesResponse>('/dashboard/preferences').then(r => r.data),
|
||||
|
||||
savePreferences: (layout: WidgetLayoutItem[]) =>
|
||||
api.put<DashboardPreferencesResponse>('/dashboard/preferences', { layout }).then(r => r.data),
|
||||
|
||||
getDefaults: (role: string) =>
|
||||
api.get<DashboardPreferencesResponse>(`/dashboard/defaults/${role}`).then(r => r.data),
|
||||
|
||||
saveDefaults: (role: string, layout: WidgetLayoutItem[]) =>
|
||||
api.put<DashboardPreferencesResponse>(`/dashboard/defaults/${role}`, { layout }).then(r => r.data),
|
||||
}
|
||||
@@ -10,6 +10,8 @@ export { rides, loadingSheets, deliveryTasks } from './operations'
|
||||
export { stock } from './stock'
|
||||
export { sales, orders, payments } from './sales'
|
||||
export { getPaginatedItems } from './paginated'
|
||||
export { dashboard } from './dashboard'
|
||||
export type { WidgetLayoutItem, DashboardPreferencesResponse } from './dashboard'
|
||||
|
||||
export type { PageParams } from './master'
|
||||
export { getApiError } from '@/lib/api'
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { WidgetLayoutItem } from '@/lib/api/dashboard'
|
||||
|
||||
export const ROLE_DEFAULT_LAYOUTS: Record<string, WidgetLayoutItem[]> = {
|
||||
admin: [
|
||||
{ i: 'today-sales-1', widgetType: 'today-sales', x: 0, y: 0, w: 4, h: 4 },
|
||||
{ i: 'active-rides-1', widgetType: 'active-rides', x: 4, y: 0, w: 4, h: 4 },
|
||||
{ i: 'stock-alerts-1', widgetType: 'stock-alerts', x: 8, y: 0, w: 4, h: 4 },
|
||||
{ i: 'pending-purchase-orders-1', widgetType: 'pending-purchase-orders', x: 0, y: 4, w: 4, h: 4 },
|
||||
{ i: 'loading-sheets-1', widgetType: 'loading-sheets', x: 4, y: 4, w: 4, h: 4 },
|
||||
{ i: 'recent-orders-1', widgetType: 'recent-orders', x: 0, y: 8, w: 8, h: 4 },
|
||||
],
|
||||
warehouse: [
|
||||
{ i: 'active-rides-1', widgetType: 'active-rides', x: 0, y: 0, w: 6, h: 4 },
|
||||
{ i: 'stock-alerts-1', widgetType: 'stock-alerts', x: 6, y: 0, w: 6, h: 4 },
|
||||
{ i: 'loading-sheets-1', widgetType: 'loading-sheets', x: 0, y: 4, w: 6, h: 4 },
|
||||
{ i: 'pending-purchase-orders-1', widgetType: 'pending-purchase-orders', x: 6, y: 4, w: 6, h: 4 },
|
||||
],
|
||||
sales: [
|
||||
{ i: 'today-sales-1', widgetType: 'today-sales', x: 0, y: 0, w: 4, h: 4 },
|
||||
{ i: 'recent-orders-1', widgetType: 'recent-orders', x: 4, y: 0, w: 8, h: 4 },
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import type { Role } from '@/contexts/auth-context'
|
||||
|
||||
import { ActiveRidesWidget } from '@/components/dashboard/widgets/active-rides-widget'
|
||||
import { TodaySalesWidget } from '@/components/dashboard/widgets/today-sales-widget'
|
||||
import { PendingPOsWidget } from '@/components/dashboard/widgets/pending-pos-widget'
|
||||
import { StockAlertsWidget } from '@/components/dashboard/widgets/stock-alerts-widget'
|
||||
import { RecentOrdersWidget } from '@/components/dashboard/widgets/recent-orders-widget'
|
||||
import { LoadingSheetsWidget } from '@/components/dashboard/widgets/loading-sheets-widget'
|
||||
|
||||
export interface WidgetDefinition {
|
||||
type: string
|
||||
label: string
|
||||
description: string
|
||||
defaultW: number
|
||||
defaultH: number
|
||||
minW: number
|
||||
minH: number
|
||||
maxW?: number
|
||||
allowedRoles: Role[] | '*'
|
||||
component: ComponentType
|
||||
}
|
||||
|
||||
export const WIDGET_REGISTRY: Record<string, WidgetDefinition> = {
|
||||
'active-rides': {
|
||||
type: 'active-rides',
|
||||
label: 'Active Rides',
|
||||
description: 'Live view of all currently active rides with driver and route info.',
|
||||
defaultW: 4,
|
||||
defaultH: 4,
|
||||
minW: 3,
|
||||
minH: 3,
|
||||
allowedRoles: ['admin', 'warehouse'],
|
||||
component: ActiveRidesWidget,
|
||||
},
|
||||
'today-sales': {
|
||||
type: 'today-sales',
|
||||
label: "Today's Sales",
|
||||
description: "Total revenue and sales counts for today.",
|
||||
defaultW: 4,
|
||||
defaultH: 4,
|
||||
minW: 2,
|
||||
minH: 3,
|
||||
allowedRoles: ['admin', 'sales'],
|
||||
component: TodaySalesWidget,
|
||||
},
|
||||
'pending-purchase-orders': {
|
||||
type: 'pending-purchase-orders',
|
||||
label: 'Pending Purchase Orders',
|
||||
description: 'Purchase orders that have been issued but not yet fully received.',
|
||||
defaultW: 4,
|
||||
defaultH: 4,
|
||||
minW: 3,
|
||||
minH: 3,
|
||||
allowedRoles: ['admin', 'warehouse'],
|
||||
component: PendingPOsWidget,
|
||||
},
|
||||
'stock-alerts': {
|
||||
type: 'stock-alerts',
|
||||
label: 'Stock Alerts',
|
||||
description: 'Items at zero or critically low stock levels across all locations.',
|
||||
defaultW: 4,
|
||||
defaultH: 4,
|
||||
minW: 3,
|
||||
minH: 3,
|
||||
allowedRoles: ['admin', 'warehouse'],
|
||||
component: StockAlertsWidget,
|
||||
},
|
||||
'recent-orders': {
|
||||
type: 'recent-orders',
|
||||
label: 'Recent Orders',
|
||||
description: 'Latest customer orders with status and value.',
|
||||
defaultW: 5,
|
||||
defaultH: 4,
|
||||
minW: 4,
|
||||
minH: 3,
|
||||
allowedRoles: ['admin', 'sales'],
|
||||
component: RecentOrdersWidget,
|
||||
},
|
||||
'loading-sheets': {
|
||||
type: 'loading-sheets',
|
||||
label: 'Pending Loading Sheets',
|
||||
description: 'Loading sheets awaiting dispatch confirmation.',
|
||||
defaultW: 4,
|
||||
defaultH: 4,
|
||||
minW: 3,
|
||||
minH: 3,
|
||||
allowedRoles: ['admin', 'warehouse'],
|
||||
component: LoadingSheetsWidget,
|
||||
},
|
||||
}
|
||||
|
||||
export function getWidgetsForRole(role: Role): WidgetDefinition[] {
|
||||
return Object.values(WIDGET_REGISTRY).filter(def =>
|
||||
def.allowedRoles === '*' || def.allowedRoles.includes(role)
|
||||
)
|
||||
}
|
||||
Generated
+59
-3
@@ -19,6 +19,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",
|
||||
"shadcn": "^4.19.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
@@ -5092,6 +5093,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",
|
||||
@@ -6969,7 +6976,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"
|
||||
@@ -8087,7 +8093,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",
|
||||
@@ -8212,6 +8217,38 @@
|
||||
"react": "^19.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-draggable": {
|
||||
"version": "4.7.1",
|
||||
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.7.1.tgz",
|
||||
"integrity": "sha512-wa3tzfFnYt3yaZLuyU58fl1TNunfWfBekDgWhZA1+gb2jnp42wZ0ymuopR6M5kqDYmm4hKmzGlcKWjZf3Zb6RQ==",
|
||||
"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",
|
||||
@@ -8232,9 +8269,22 @@
|
||||
"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-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",
|
||||
@@ -8310,6 +8360,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",
|
||||
|
||||
@@ -23,6 +23,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",
|
||||
"shadcn": "^4.19.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
|
||||
Reference in New Issue
Block a user