diff --git a/Distribution/Features/Brand/BrandController.cs b/Distribution/Features/Brand/BrandController.cs index 4efd152..7bf17c2 100644 --- a/Distribution/Features/Brand/BrandController.cs +++ b/Distribution/Features/Brand/BrandController.cs @@ -1,20 +1,16 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.Brand; [ApiController] [Route("api/brands")] -[Authorize] public class BrandController(BrandService service) : ControllerBase { [HttpGet] - [Authorize(Policy = "items.view")] public async Task GetAll() => Ok(await service.GetAllAsync()); [HttpGet("{id:guid}")] - [Authorize(Policy = "items.view")] public async Task GetById(Guid id) { var result = await service.GetByIdAsync(id); @@ -22,7 +18,6 @@ public class BrandController(BrandService service) : ControllerBase } [HttpPost] - [Authorize(Policy = "items.create")] public async Task Create(CreateBrandRequest request, CancellationToken ct) { var result = await service.CreateAsync(request, ct); @@ -30,7 +25,6 @@ public class BrandController(BrandService service) : ControllerBase } [HttpPut("{id:guid}")] - [Authorize(Policy = "items.update")] public async Task Update(Guid id, UpdateBrandRequest request, CancellationToken ct) { var result = await service.UpdateAsync(id, request, ct); @@ -38,7 +32,6 @@ public class BrandController(BrandService service) : ControllerBase } [HttpDelete("{id:guid}")] - [Authorize(Policy = "items.delete")] public async Task Delete(Guid id, CancellationToken ct) { var deleted = await service.DeleteAsync(id, ct); diff --git a/Distribution/Features/Category/CategoryController.cs b/Distribution/Features/Category/CategoryController.cs index cffea26..c3e4635 100644 --- a/Distribution/Features/Category/CategoryController.cs +++ b/Distribution/Features/Category/CategoryController.cs @@ -1,19 +1,15 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.Category; [ApiController] -[Authorize] public class CategoryController(CategoryService service) : ControllerBase { [HttpGet("api/categories")] - [Authorize(Policy = "categories.view")] public async Task GetAll() => Ok(await service.GetAllAsync()); [HttpGet("api/categories/{id:guid}")] - [Authorize(Policy = "categories.view")] public async Task GetById(Guid id) { var result = await service.GetByIdAsync(id); @@ -21,7 +17,6 @@ public class CategoryController(CategoryService service) : ControllerBase } [HttpPost("api/categories")] - [Authorize(Policy = "categories.create")] public async Task Create(CreateCategoryRequest request, CancellationToken ct) { var result = await service.CreateAsync(request, ct); @@ -29,7 +24,6 @@ public class CategoryController(CategoryService service) : ControllerBase } [HttpPut("api/categories/{id:guid}")] - [Authorize(Policy = "categories.update")] public async Task Update(Guid id, UpdateCategoryRequest request, CancellationToken ct) { var result = await service.UpdateAsync(id, request, ct); @@ -37,7 +31,6 @@ public class CategoryController(CategoryService service) : ControllerBase } [HttpDelete("api/categories/{id:guid}")] - [Authorize(Policy = "categories.delete")] public async Task Delete(Guid id, CancellationToken ct) { var deleted = await service.DeleteAsync(id, ct); @@ -45,7 +38,6 @@ public class CategoryController(CategoryService service) : ControllerBase } [HttpPost("api/categories/{categoryId:guid}/subcategories")] - [Authorize(Policy = "categories.create")] public async Task CreateSubCategory(Guid categoryId, CreateSubCategoryRequest request, CancellationToken ct) { var result = await service.CreateSubCategoryAsync(categoryId, request, ct); @@ -53,7 +45,6 @@ public class CategoryController(CategoryService service) : ControllerBase } [HttpPut("api/subcategories/{id:guid}")] - [Authorize(Policy = "categories.update")] public async Task UpdateSubCategory(Guid id, UpdateSubCategoryRequest request, CancellationToken ct) { var result = await service.UpdateSubCategoryAsync(id, request, ct); @@ -61,7 +52,6 @@ public class CategoryController(CategoryService service) : ControllerBase } [HttpDelete("api/subcategories/{id:guid}")] - [Authorize(Policy = "categories.delete")] public async Task DeleteSubCategory(Guid id, CancellationToken ct) { var deleted = await service.DeleteSubCategoryAsync(id, ct); diff --git a/Distribution/Features/Claims/ClaimController.cs b/Distribution/Features/Claims/ClaimController.cs index 8aa16c3..b4a6de2 100644 --- a/Distribution/Features/Claims/ClaimController.cs +++ b/Distribution/Features/Claims/ClaimController.cs @@ -1,11 +1,9 @@ -using Distribution.Infra.Authorization; -using Microsoft.AspNetCore.Authorization; +using Distribution.Infra.Authorization; using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.Claims; [ApiController] -[Authorize] public class ClaimController(ClaimService service, ICurrentUserService currentUser) : ControllerBase { [HttpGet("api/claims/{id:guid}")] @@ -27,7 +25,6 @@ public class ClaimController(ClaimService service, ICurrentUserService currentUs } [HttpPost("api/claims/{id:guid}/settle")] - [Authorize(Policy = "claims.settle")] public async Task Settle(Guid id, SettleClaimRequest request, CancellationToken ct) { var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); diff --git a/Distribution/Features/DeliveryRide/DeliveryTaskController.cs b/Distribution/Features/DeliveryRide/DeliveryTaskController.cs index 92b851a..b6dd733 100644 --- a/Distribution/Features/DeliveryRide/DeliveryTaskController.cs +++ b/Distribution/Features/DeliveryRide/DeliveryTaskController.cs @@ -1,10 +1,8 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.DeliveryRide; [ApiController] -[Authorize] [Route("api")] public class DeliveryTaskController(DeliveryTaskService service) : ControllerBase { diff --git a/Distribution/Features/DriverComplianceConfig/DriverComplianceConfigController.cs b/Distribution/Features/DriverComplianceConfig/DriverComplianceConfigController.cs index f2590d4..f5e3e89 100644 --- a/Distribution/Features/DriverComplianceConfig/DriverComplianceConfigController.cs +++ b/Distribution/Features/DriverComplianceConfig/DriverComplianceConfigController.cs @@ -1,16 +1,13 @@ -using Distribution.Infra.Authorization; -using Microsoft.AspNetCore.Authorization; +using Distribution.Infra.Authorization; using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.DriverComplianceConfig; [ApiController] [Route("api/driver-compliance-config")] -[Authorize] public class DriverComplianceConfigController(DriverComplianceConfigService service, ICurrentUserService currentUser) : ControllerBase { [HttpGet] - [Authorize(Policy = "compliance.manage")] public async Task Get() { var result = await service.GetAsync(); @@ -18,7 +15,6 @@ public class DriverComplianceConfigController(DriverComplianceConfigService serv } [HttpPut] - [Authorize(Policy = "compliance.manage")] public async Task Upsert(UpdateDriverComplianceConfigRequest request, CancellationToken ct) { var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); diff --git a/Distribution/Features/DriverProfile/DriverProfileController.cs b/Distribution/Features/DriverProfile/DriverProfileController.cs index 86e8130..a5a981b 100644 --- a/Distribution/Features/DriverProfile/DriverProfileController.cs +++ b/Distribution/Features/DriverProfile/DriverProfileController.cs @@ -1,15 +1,12 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.DriverProfile; [ApiController] [Route("api/driver-profiles")] -[Authorize] public class DriverProfileController(DriverProfileService service) : ControllerBase { [HttpGet] - [Authorize(Policy = "driver_profiles.view")] public async Task GetAll([FromQuery] int page = 1, [FromQuery] int pageSize = 20) { var result = await service.GetAllAsync(page, pageSize); @@ -17,7 +14,6 @@ public class DriverProfileController(DriverProfileService service) : ControllerB } [HttpGet("{id:guid}")] - [Authorize(Policy = "driver_profiles.view")] public async Task GetById(Guid id) { var result = await service.GetByIdAsync(id); @@ -25,7 +21,6 @@ public class DriverProfileController(DriverProfileService service) : ControllerB } [HttpPost] - [Authorize(Policy = "driver_profiles.create")] public async Task Create(CreateDriverProfileRequest request, CancellationToken ct) { var result = await service.CreateAsync(request, ct); @@ -33,7 +28,6 @@ public class DriverProfileController(DriverProfileService service) : ControllerB } [HttpPut("{id:guid}")] - [Authorize(Policy = "driver_profiles.update")] public async Task Update(Guid id, UpdateDriverProfileRequest request, CancellationToken ct) { var result = await service.UpdateAsync(id, request, ct); @@ -41,7 +35,6 @@ public class DriverProfileController(DriverProfileService service) : ControllerB } [HttpDelete("{id:guid}")] - [Authorize(Policy = "driver_profiles.deactivate")] public async Task Deactivate(Guid id, CancellationToken ct) { var success = await service.DeactivateAsync(id, ct); diff --git a/Distribution/Features/Grn/GrnController.cs b/Distribution/Features/Grn/GrnController.cs index 05bc233..ef93d56 100644 --- a/Distribution/Features/Grn/GrnController.cs +++ b/Distribution/Features/Grn/GrnController.cs @@ -1,11 +1,9 @@ -using Distribution.Infra.Authorization; -using Microsoft.AspNetCore.Authorization; +using Distribution.Infra.Authorization; using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.Grn; [ApiController] -[Authorize] public class GrnController(GrnService service, ICurrentUserService currentUser) : ControllerBase { [HttpGet("api/goods-receipts/{id:guid}")] diff --git a/Distribution/Features/Item/ItemController.cs b/Distribution/Features/Item/ItemController.cs index 00f129d..d497152 100644 --- a/Distribution/Features/Item/ItemController.cs +++ b/Distribution/Features/Item/ItemController.cs @@ -1,15 +1,12 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.Item; [ApiController] [Route("api/items")] -[Authorize] public class ItemController(ItemService service) : ControllerBase { [HttpGet] - [Authorize(Policy = "items.view")] public async Task GetAll( [FromQuery] Guid? categoryId, [FromQuery] Guid? subCategoryId, @@ -17,7 +14,6 @@ public class ItemController(ItemService service) : ControllerBase Ok(await service.GetAllAsync(categoryId, subCategoryId, brandId)); [HttpGet("{id:guid}")] - [Authorize(Policy = "items.view")] public async Task GetById(Guid id) { var result = await service.GetByIdAsync(id); @@ -25,7 +21,6 @@ public class ItemController(ItemService service) : ControllerBase } [HttpPost] - [Authorize(Policy = "items.create")] public async Task Create(CreateItemRequest request, CancellationToken ct) { try @@ -40,7 +35,6 @@ public class ItemController(ItemService service) : ControllerBase } [HttpPut("{id:guid}")] - [Authorize(Policy = "items.update")] public async Task Update(Guid id, UpdateItemRequest request, CancellationToken ct) { var result = await service.UpdateAsync(id, request, ct); @@ -48,7 +42,6 @@ public class ItemController(ItemService service) : ControllerBase } [HttpDelete("{id:guid}")] - [Authorize(Policy = "items.delete")] public async Task Delete(Guid id, CancellationToken ct) { var deleted = await service.DeleteAsync(id, ct); @@ -56,7 +49,6 @@ public class ItemController(ItemService service) : ControllerBase } [HttpPut("{id:guid}/uom-conversions")] - [Authorize(Policy = "items.update")] public async Task ReplaceConversions(Guid id, ReplaceConversionsRequest request, CancellationToken ct) { var result = await service.ReplaceConversionsAsync(id, request, ct); diff --git a/Distribution/Features/LoadingEvent/LoadingSheetController.cs b/Distribution/Features/LoadingEvent/LoadingSheetController.cs index 3ab199f..7e22deb 100644 --- a/Distribution/Features/LoadingEvent/LoadingSheetController.cs +++ b/Distribution/Features/LoadingEvent/LoadingSheetController.cs @@ -1,17 +1,14 @@ -using Distribution.Models; +using Distribution.Models; using Distribution.Infra.Authorization; -using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.LoadingEvent; [ApiController] [Route("api/loading-sheets")] -[Authorize] public class LoadingSheetController(LoadingSheetService service, ICurrentUserService currentUser) : ControllerBase { [HttpGet] - [Authorize(Policy = "loading.view")] public async Task GetAll( [FromQuery] Guid? rideId, [FromQuery] LoadingSheetStatus? status, @@ -23,7 +20,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer } [HttpGet("{id:guid}")] - [Authorize(Policy = "loading.view")] public async Task GetById(Guid id) { var result = await service.GetByIdAsync(id); @@ -31,7 +27,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer } [HttpPost] - [Authorize(Policy = "loading.dispatch")] public async Task Create(CreateLoadingSheetRequest request, CancellationToken ct) { try @@ -44,7 +39,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer } [HttpPut("{id:guid}/items")] - [Authorize(Policy = "loading.dispatch")] public async Task SetItems(Guid id, SetItemsRequest request, CancellationToken ct) { try @@ -57,7 +51,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer } [HttpPost("{id:guid}/confirm-dispatch")] - [Authorize(Policy = "loading.dispatch")] public async Task ConfirmDispatch(Guid id, CancellationToken ct) { var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); @@ -71,7 +64,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer } [HttpPut("{id:guid}/receipt-items")] - [Authorize(Policy = "loading.receive")] public async Task SetReceiptItems(Guid id, SetReceiptItemsRequest request, CancellationToken ct) { try @@ -84,7 +76,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer } [HttpPost("{id:guid}/confirm-receipt")] - [Authorize(Policy = "loading.receive")] public async Task ConfirmReceipt(Guid id, ConfirmReceiptRequest request, CancellationToken ct) { var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); @@ -98,7 +89,6 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer } [HttpPost("{id:guid}/resolve")] - [Authorize(Policy = "loading.resolve")] public async Task Resolve(Guid id, ResolveDiscrepancyRequest request, CancellationToken ct) { var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); diff --git a/Distribution/Features/Order/OrderController.cs b/Distribution/Features/Order/OrderController.cs index 9b75b04..97266bb 100644 --- a/Distribution/Features/Order/OrderController.cs +++ b/Distribution/Features/Order/OrderController.cs @@ -1,20 +1,16 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.Order; [ApiController] [Route("api/orders")] -[Authorize] public class OrderController(OrderService service) : ControllerBase { [HttpGet] - [Authorize(Policy = "orders.view")] public async Task GetAll() => Ok(await service.GetAllAsync()); [HttpGet("{id:guid}")] - [Authorize(Policy = "orders.view")] public async Task GetById(Guid id) { var result = await service.GetByIdAsync(id); @@ -22,7 +18,6 @@ public class OrderController(OrderService service) : ControllerBase } [HttpGet("store/{storeId:guid}")] - [Authorize(Policy = "orders.view")] public async Task GetByStore(Guid storeId) { var result = await service.GetByStoreIdAsync(storeId); @@ -30,7 +25,6 @@ public class OrderController(OrderService service) : ControllerBase } [HttpPost] - [Authorize(Policy = "orders.create")] public async Task Create(CreateOrderRequest request, CancellationToken ct) { try @@ -42,7 +36,6 @@ public class OrderController(OrderService service) : ControllerBase } [HttpPut("{id:guid}")] - [Authorize(Policy = "orders.update")] public async Task Update(Guid id, UpdateOrderRequest request, CancellationToken ct) { try @@ -54,7 +47,6 @@ public class OrderController(OrderService service) : ControllerBase } [HttpDelete("{id:guid}")] - [Authorize(Policy = "orders.delete")] public async Task Delete(Guid id, CancellationToken ct) { var deleted = await service.DeleteAsync(id, ct); diff --git a/Distribution/Features/Payment/PaymentController.cs b/Distribution/Features/Payment/PaymentController.cs index 9fef526..18fe7f3 100644 --- a/Distribution/Features/Payment/PaymentController.cs +++ b/Distribution/Features/Payment/PaymentController.cs @@ -1,10 +1,8 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.Payment; [ApiController] -[Authorize] [Route("api/sales/{saleId:guid}/payments")] public class PaymentController(PaymentService service) : ControllerBase { diff --git a/Distribution/Features/PriceList/PriceListController.cs b/Distribution/Features/PriceList/PriceListController.cs index d5d4eca..41aebc1 100644 --- a/Distribution/Features/PriceList/PriceListController.cs +++ b/Distribution/Features/PriceList/PriceListController.cs @@ -1,11 +1,9 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.PriceList; [ApiController] [Route("api/price-lists")] -[Authorize] public class PriceListController(PriceListService service) : ControllerBase { [HttpGet] diff --git a/Distribution/Features/PurchaseOrder/PoController.cs b/Distribution/Features/PurchaseOrder/PoController.cs index c1fa3dc..325113f 100644 --- a/Distribution/Features/PurchaseOrder/PoController.cs +++ b/Distribution/Features/PurchaseOrder/PoController.cs @@ -1,13 +1,11 @@ -using Distribution.Infra.Authorization; +using Distribution.Infra.Authorization; using Distribution.Models; -using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.Po; [ApiController] [Route("api/purchase-orders")] -[Authorize] public class PoController(PoService service, ICurrentUserService currentUser) : ControllerBase { [HttpGet] diff --git a/Distribution/Features/PurchaseReturn/PurchaseReturnController.cs b/Distribution/Features/PurchaseReturn/PurchaseReturnController.cs index f125024..54285c9 100644 --- a/Distribution/Features/PurchaseReturn/PurchaseReturnController.cs +++ b/Distribution/Features/PurchaseReturn/PurchaseReturnController.cs @@ -1,11 +1,9 @@ -using Distribution.Infra.Authorization; -using Microsoft.AspNetCore.Authorization; +using Distribution.Infra.Authorization; using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.PurchaseReturn; [ApiController] -[Authorize] public class PurchaseReturnController(PurchaseReturnService service, ICurrentUserService currentUser) : ControllerBase { [HttpGet("api/purchase-returns/{id:guid}")] diff --git a/Distribution/Features/Region/RegionController.cs b/Distribution/Features/Region/RegionController.cs index e085c52..61d33b8 100644 --- a/Distribution/Features/Region/RegionController.cs +++ b/Distribution/Features/Region/RegionController.cs @@ -1,20 +1,16 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.Region; [ApiController] [Route("api/regions")] -[Authorize] public class RegionController(RegionService service) : ControllerBase { [HttpGet] - [Authorize(Policy = "regions.view")] public async Task GetAll() => Ok(await service.GetAllAsync()); [HttpGet("{id:guid}")] - [Authorize(Policy = "regions.view")] public async Task GetById(Guid id) { var result = await service.GetByIdAsync(id); @@ -22,7 +18,6 @@ public class RegionController(RegionService service) : ControllerBase } [HttpPost] - [Authorize(Policy = "regions.create")] public async Task Create(CreateRegionRequest request, CancellationToken ct) { var result = await service.CreateAsync(request, ct); @@ -30,7 +25,6 @@ public class RegionController(RegionService service) : ControllerBase } [HttpPut("{id:guid}")] - [Authorize(Policy = "regions.update")] public async Task Update(Guid id, UpdateRegionRequest request, CancellationToken ct) { var result = await service.UpdateAsync(id, request, ct); @@ -38,7 +32,6 @@ public class RegionController(RegionService service) : ControllerBase } [HttpDelete("{id:guid}")] - [Authorize(Policy = "regions.delete")] public async Task Delete(Guid id, CancellationToken ct) { var deleted = await service.DeleteAsync(id, ct); diff --git a/Distribution/Features/Returns/ReturnController.cs b/Distribution/Features/Returns/ReturnController.cs index e5f74ef..4abc660 100644 --- a/Distribution/Features/Returns/ReturnController.cs +++ b/Distribution/Features/Returns/ReturnController.cs @@ -1,11 +1,9 @@ -using Distribution.Infra.Authorization; -using Microsoft.AspNetCore.Authorization; +using Distribution.Infra.Authorization; using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.Returns; [ApiController] -[Authorize] public class ReturnController(ReturnService service, ICurrentUserService currentUser) : ControllerBase { [HttpGet("api/returns/{id:guid}")] diff --git a/Distribution/Features/Ride/RideController.cs b/Distribution/Features/Ride/RideController.cs index a8bb3c0..e9f60c8 100644 --- a/Distribution/Features/Ride/RideController.cs +++ b/Distribution/Features/Ride/RideController.cs @@ -1,17 +1,14 @@ -using Distribution.Infra.Authorization; +using Distribution.Infra.Authorization; using Distribution.Models; -using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.Ride; [ApiController] [Route("api/rides")] -[Authorize] public class RideController(RideService service, ICurrentUserService currentUser) : ControllerBase { [HttpGet] - [Authorize(Policy = "rides.view")] public async Task GetAll( [FromQuery] RideStatus? status, [FromQuery] RideType? type, @@ -25,7 +22,6 @@ public class RideController(RideService service, ICurrentUserService currentUser } [HttpGet("{id:guid}")] - [Authorize(Policy = "rides.view")] public async Task GetById(Guid id) { var result = await service.GetByIdAsync(id); @@ -33,7 +29,6 @@ public class RideController(RideService service, ICurrentUserService currentUser } [HttpPost] - [Authorize(Policy = "rides.create")] public async Task Create(CreateRideRequest request, CancellationToken ct) { var result = await service.CreateAsync(request, ct); @@ -41,7 +36,6 @@ public class RideController(RideService service, ICurrentUserService currentUser } [HttpPut("{id:guid}/driver")] - [Authorize(Policy = "rides.manage")] public async Task AssignDriver(Guid id, AssignRideDriverRequest request, CancellationToken ct) { try @@ -54,7 +48,6 @@ public class RideController(RideService service, ICurrentUserService currentUser } [HttpPost("{id:guid}/open")] - [Authorize(Policy = "rides.manage")] public async Task Open(Guid id, CancellationToken ct) { try @@ -67,7 +60,6 @@ public class RideController(RideService service, ICurrentUserService currentUser } [HttpPost("{id:guid}/activate")] - [Authorize(Policy = "rides.manage")] public async Task Activate(Guid id, ActivateRideRequest request, CancellationToken ct) { try @@ -80,7 +72,6 @@ public class RideController(RideService service, ICurrentUserService currentUser } [HttpPost("{id:guid}/close")] - [Authorize(Policy = "rides.manage")] public async Task Close(Guid id, CloseRideRequest request, CancellationToken ct) { try @@ -93,7 +84,6 @@ public class RideController(RideService service, ICurrentUserService currentUser } [HttpPost("{id:guid}/cancel-request")] - [Authorize(Policy = "rides.cancel.request")] public async Task RaiseCancelRequest(Guid id, RaiseCancelRequest request, CancellationToken ct) { var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); @@ -107,7 +97,6 @@ public class RideController(RideService service, ICurrentUserService currentUser } [HttpGet("cancel-requests")] - [Authorize(Policy = "rides.view")] public async Task GetCancelRequests( [FromQuery] CancelRequestStatus? status, [FromQuery] int page = 1, @@ -118,7 +107,6 @@ public class RideController(RideService service, ICurrentUserService currentUser } [HttpPost("cancel-requests/{id:guid}/approve")] - [Authorize(Policy = "rides.cancel.approve")] public async Task ApproveCancelRequest(Guid id, CancellationToken ct) { var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); @@ -132,7 +120,6 @@ public class RideController(RideService service, ICurrentUserService currentUser } [HttpPost("cancel-requests/{id:guid}/reject")] - [Authorize(Policy = "rides.cancel.approve")] public async Task RejectCancelRequest(Guid id, CancellationToken ct) { var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); @@ -146,7 +133,6 @@ public class RideController(RideService service, ICurrentUserService currentUser } [HttpGet("{id:guid}/cancel-request")] - [Authorize(Policy = "rides.view")] public async Task GetCancelRequest(Guid id) { var result = await service.GetCancelRequestAsync(id); @@ -154,7 +140,6 @@ public class RideController(RideService service, ICurrentUserService currentUser } [HttpGet("breakdown-assist/{rideId:guid}")] - [Authorize(Policy = "rides.view")] public async Task GetBreakdownAssist(Guid rideId) { try diff --git a/Distribution/Features/Role/RoleController.cs b/Distribution/Features/Role/RoleController.cs index ce5f7e1..5c872f9 100644 --- a/Distribution/Features/Role/RoleController.cs +++ b/Distribution/Features/Role/RoleController.cs @@ -1,11 +1,9 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.Role; [ApiController] [Route("api/roles")] -[Authorize] public class RoleController(RoleService service) : ControllerBase { [HttpGet] diff --git a/Distribution/Features/Route/RouteController.cs b/Distribution/Features/Route/RouteController.cs index 9789613..2813e29 100644 --- a/Distribution/Features/Route/RouteController.cs +++ b/Distribution/Features/Route/RouteController.cs @@ -1,20 +1,16 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.Route; [ApiController] [Route("api/routes")] -[Authorize] public class RouteController(RouteService service) : ControllerBase { [HttpGet] - [Authorize(Policy = "routes.view")] public async Task GetAll() => Ok(await service.GetAllAsync()); [HttpGet("{id:guid}")] - [Authorize(Policy = "routes.view")] public async Task GetById(Guid id) { var result = await service.GetByIdAsync(id); @@ -22,7 +18,6 @@ public class RouteController(RouteService service) : ControllerBase } [HttpPost] - [Authorize(Policy = "routes.create")] public async Task Create(CreateRouteRequest request, CancellationToken ct) { var result = await service.CreateAsync(request, ct); @@ -30,7 +25,6 @@ public class RouteController(RouteService service) : ControllerBase } [HttpPut("{id:guid}")] - [Authorize(Policy = "routes.update")] public async Task Update(Guid id, UpdateRouteRequest request, CancellationToken ct) { var result = await service.UpdateAsync(id, request, ct); @@ -38,7 +32,6 @@ public class RouteController(RouteService service) : ControllerBase } [HttpDelete("{id:guid}")] - [Authorize(Policy = "routes.delete")] public async Task Delete(Guid id, CancellationToken ct) { var deleted = await service.DeleteAsync(id, ct); diff --git a/Distribution/Features/Sale/ISaleRepository.cs b/Distribution/Features/Sale/ISaleRepository.cs index b664c08..92ea633 100644 --- a/Distribution/Features/Sale/ISaleRepository.cs +++ b/Distribution/Features/Sale/ISaleRepository.cs @@ -5,7 +5,8 @@ namespace Distribution.Features.Sale; public interface ISaleRepository { Task GetByIdAsync(Guid id); - Task> GetAllAsync(); + Task> GetAllAsync(Guid? rideId, IEnumerable? storeIds, int page, int pageSize); + Task GetCountAsync(Guid? rideId, IEnumerable? storeIds); Task> GetByStoreIdAsync(Guid storeId); Task AddAsync(Models.Sale sale); void Update(Models.Sale sale); diff --git a/Distribution/Features/Sale/SaleController.cs b/Distribution/Features/Sale/SaleController.cs index 792e6b3..ee5b676 100644 --- a/Distribution/Features/Sale/SaleController.cs +++ b/Distribution/Features/Sale/SaleController.cs @@ -1,20 +1,19 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.Sale; [ApiController] [Route("api/sales")] -[Authorize] public class SaleController(SaleService service) : ControllerBase { [HttpGet] - [Authorize(Policy = "orders.view")] - public async Task GetAll() => - Ok(await service.GetAllAsync()); + public async Task GetAll( + [FromQuery] Guid? rideId, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20) => + Ok(await service.GetAllAsync(rideId, page, pageSize)); [HttpGet("{id:guid}")] - [Authorize(Policy = "orders.view")] public async Task GetById(Guid id) { var result = await service.GetByIdAsync(id); @@ -22,7 +21,6 @@ public class SaleController(SaleService service) : ControllerBase } [HttpGet("store/{storeId:guid}")] - [Authorize(Policy = "orders.view")] public async Task GetByStore(Guid storeId) { var result = await service.GetByStoreIdAsync(storeId); @@ -30,7 +28,6 @@ public class SaleController(SaleService service) : ControllerBase } [HttpPost] - [Authorize(Policy = "orders.create")] public async Task Create(CreateSaleRequest request, CancellationToken ct) { var result = await service.CreateAsync(request, ct); diff --git a/Distribution/Features/Sale/SaleRepository.cs b/Distribution/Features/Sale/SaleRepository.cs index af9e5a5..4768aba 100644 --- a/Distribution/Features/Sale/SaleRepository.cs +++ b/Distribution/Features/Sale/SaleRepository.cs @@ -9,8 +9,21 @@ public class SaleRepository(AppDBContext context) : ISaleRepository public async Task GetByIdAsync(Guid id) => await context.Sales.FindAsync(id); - public async Task> GetAllAsync() => - await context.Sales.ToListAsync(); + public async Task> GetAllAsync(Guid? rideId, IEnumerable? storeIds, int page, int pageSize) => + await context.Sales + .Where(s => s.IsActive && + (rideId == null || s.FK_RideId == rideId) && + (storeIds == null || storeIds.Contains(s.FK_StoreId))) + .OrderByDescending(s => s.CreatedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + public async Task GetCountAsync(Guid? rideId, IEnumerable? storeIds) => + await context.Sales + .CountAsync(s => s.IsActive && + (rideId == null || s.FK_RideId == rideId) && + (storeIds == null || storeIds.Contains(s.FK_StoreId))); public async Task> GetByStoreIdAsync(Guid storeId) => await context.Sales.Where(s => s.FK_StoreId == storeId && s.IsActive).ToListAsync(); diff --git a/Distribution/Features/Sale/SaleService.cs b/Distribution/Features/Sale/SaleService.cs index e7d3ecf..28ab2af 100644 --- a/Distribution/Features/Sale/SaleService.cs +++ b/Distribution/Features/Sale/SaleService.cs @@ -71,14 +71,14 @@ public class SaleService( sale.IsActive, sale.CreatedAt); - public async Task> GetAllAsync() + public async Task> GetAllAsync(Guid? rideId, int page, int pageSize) { - var sales = (await repo.GetAllAsync()).Where(s => s.IsActive).ToList(); + IEnumerable? storeIds = null; if (!HasGlobalScope && currentUser.UserId is not null) - { - var accessibleStoreIds = (await scopeAccess.GetAccessibleStoreIdsAsync(currentUser.UserId.Value)).ToHashSet(); - sales = sales.Where(s => accessibleStoreIds.Contains(s.FK_StoreId)).ToList(); - } + storeIds = await scopeAccess.GetAccessibleStoreIdsAsync(currentUser.UserId.Value); + + var total = await repo.GetCountAsync(rideId, storeIds); + var sales = await repo.GetAllAsync(rideId, storeIds, page, pageSize); var result = new List(); foreach (var sale in sales) @@ -88,7 +88,7 @@ public class SaleService( var items = await orderRepo.GetItemsByOrderIdAsync(order.Id); result.Add(ToResponse(sale, order, items)); } - return result; + return new PagedResponse(page, pageSize, total, result); } public async Task GetByIdAsync(Guid id) diff --git a/Distribution/Features/Scheme/SchemeController.cs b/Distribution/Features/Scheme/SchemeController.cs index 6a230eb..7cb79b3 100644 --- a/Distribution/Features/Scheme/SchemeController.cs +++ b/Distribution/Features/Scheme/SchemeController.cs @@ -1,11 +1,9 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.Scheme; [ApiController] [Route("api/schemes")] -[Authorize] public class SchemeController(SchemeService service) : ControllerBase { [HttpGet] @@ -120,7 +118,6 @@ public class SchemeController(SchemeService service) : ControllerBase [ApiController] [Route("api/orders")] -[Authorize] public class SchemeOrderController(SchemeService service) : ControllerBase { [HttpPost("{id:guid}/apply-scheme")] diff --git a/Distribution/Features/Stock/StockController.cs b/Distribution/Features/Stock/StockController.cs index b642c46..3f896bb 100644 --- a/Distribution/Features/Stock/StockController.cs +++ b/Distribution/Features/Stock/StockController.cs @@ -1,16 +1,14 @@ -using Distribution.Infra.Authorization; +using Distribution.Infra.Authorization; using Distribution.Models; -using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.Stock; [ApiController] [Route("api/stock")] -[Authorize] public class StockController(StockService service, ICurrentUserService currentUser) : ControllerBase { - // ── Query ────────────────────────────────────────────────────────────── + // ── Query ────────────────────────────────────────────────────────────── [HttpGet("balances")] public async Task GetBalances( @@ -46,7 +44,7 @@ public class StockController(StockService service, ICurrentUserService currentUs return Ok(result); } - // ── Write ────────────────────────────────────────────────────────────── + // ── Write ────────────────────────────────────────────────────────────── [HttpPost("transfer")] public async Task Transfer(TransferRequest request, CancellationToken ct) diff --git a/Distribution/Features/Store/Kyc/StoreKycDocumentController.cs b/Distribution/Features/Store/Kyc/StoreKycDocumentController.cs index 5625ad2..0377f57 100644 --- a/Distribution/Features/Store/Kyc/StoreKycDocumentController.cs +++ b/Distribution/Features/Store/Kyc/StoreKycDocumentController.cs @@ -1,15 +1,12 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.Store.Kyc; [ApiController] [Route("api/stores/{storeId:guid}/kyc-documents")] -[Authorize] public class StoreKycDocumentController(StoreKycDocumentService service) : ControllerBase { [HttpGet] - [Authorize(Policy = "stores.view")] public async Task GetByStoreId(Guid storeId) { var result = await service.GetByStoreIdAsync(storeId); @@ -17,7 +14,6 @@ public class StoreKycDocumentController(StoreKycDocumentService service) : Contr } [HttpPost] - [Authorize(Policy = "stores.update")] public async Task Upload(Guid storeId, [FromForm] UploadKycDocumentRequest request, CancellationToken ct) { var result = await service.UploadAsync(storeId, request, ct); diff --git a/Distribution/Features/Store/StoreController.cs b/Distribution/Features/Store/StoreController.cs index 78da8a7..364846a 100644 --- a/Distribution/Features/Store/StoreController.cs +++ b/Distribution/Features/Store/StoreController.cs @@ -1,20 +1,16 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.Store; [ApiController] [Route("api/stores")] -[Authorize] public class StoreController(StoreService service) : ControllerBase { [HttpGet] - [Authorize(Policy = "stores.view")] public async Task GetAll() => Ok(await service.GetAllAsync()); [HttpGet("{id:guid}")] - [Authorize(Policy = "stores.view")] public async Task GetById(Guid id) { var result = await service.GetByIdAsync(id); @@ -22,7 +18,6 @@ public class StoreController(StoreService service) : ControllerBase } [HttpPost] - [Authorize(Policy = "stores.create")] public async Task Create(CreateStoreRequest request, CancellationToken ct) { var result = await service.CreateAsync(request, ct); @@ -30,7 +25,6 @@ public class StoreController(StoreService service) : ControllerBase } [HttpPut("{id:guid}")] - [Authorize(Policy = "stores.update")] public async Task Update(Guid id, UpdateStoreRequest request, CancellationToken ct) { var result = await service.UpdateAsync(id, request, ct); @@ -38,7 +32,6 @@ public class StoreController(StoreService service) : ControllerBase } [HttpDelete("{id:guid}")] - [Authorize(Policy = "stores.delete")] public async Task Delete(Guid id, CancellationToken ct) { var deleted = await service.DeleteAsync(id, ct); @@ -46,7 +39,6 @@ public class StoreController(StoreService service) : ControllerBase } [HttpPut("{id:guid}/credit-limit")] - [Authorize(Policy = "stores.credit.update")] public async Task UpdateCreditLimit(Guid id, UpdateCreditLimitRequest request, CancellationToken ct) { var result = await service.UpdateCreditLimitAsync(id, request, ct); @@ -54,7 +46,6 @@ public class StoreController(StoreService service) : ControllerBase } [HttpPut("{id:guid}/outstanding-balance")] - [Authorize(Policy = "stores.credit.update")] public async Task UpdateOutstandingBalance(Guid id, UpdateOutstandingBalanceRequest request, CancellationToken ct) { var result = await service.UpdateOutstandingBalanceAsync(id, request, ct); @@ -62,7 +53,6 @@ public class StoreController(StoreService service) : ControllerBase } [HttpGet("{id:guid}/outstanding-balance")] - [Authorize(Policy = "stores.view")] public async Task GetOutstandingBalance(Guid id) { var result = await service.GetOutstandingBalanceAsync(id); @@ -70,7 +60,6 @@ public class StoreController(StoreService service) : ControllerBase } [HttpPut("{id:guid}/kyc/verify")] - [Authorize(Policy = "stores.kyc.verify")] public async Task VerifyKyc(Guid id, VerifyKycRequest request, CancellationToken ct) { var result = await service.VerifyKycAsync(id, request, ct); @@ -78,7 +67,6 @@ public class StoreController(StoreService service) : ControllerBase } [HttpPut("{id:guid}/segment")] - [Authorize(Policy = "stores.update")] public async Task ClassifySegment(Guid id, ClassifySegmentRequest request, CancellationToken ct) { var result = await service.ClassifySegmentAsync(id, request, ct); @@ -86,7 +74,6 @@ public class StoreController(StoreService service) : ControllerBase } [HttpGet("{id:guid}/visit-history")] - [Authorize(Policy = "stores.view")] public async Task GetVisitHistory(Guid id) { var result = await service.GetVisitHistoryAsync(id); @@ -94,7 +81,6 @@ public class StoreController(StoreService service) : ControllerBase } [HttpGet("{id:guid}/schemes/{schemeId:guid}/eligibility")] - [Authorize(Policy = "stores.view")] public async Task CheckSchemeEligibility(Guid id, Guid schemeId) { var result = await service.CheckSchemeEligibilityAsync(id, schemeId); @@ -102,7 +88,6 @@ public class StoreController(StoreService service) : ControllerBase } [HttpGet("{id:guid}/dashboard")] - [Authorize(Policy = "stores.view")] public async Task GetDashboard(Guid id, [FromQuery] string period) { var result = await service.GetDashboardAsync(id, period); diff --git a/Distribution/Features/Supplier/SupplierController.cs b/Distribution/Features/Supplier/SupplierController.cs index ad950ea..65210d7 100644 --- a/Distribution/Features/Supplier/SupplierController.cs +++ b/Distribution/Features/Supplier/SupplierController.cs @@ -1,11 +1,9 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.Supplier; [ApiController] [Route("api/suppliers")] -[Authorize] public class SupplierController(SupplierService service) : ControllerBase { [HttpGet] diff --git a/Distribution/Features/SupplierClaims/SupplierClaimController.cs b/Distribution/Features/SupplierClaims/SupplierClaimController.cs index 2fbbe19..6c96880 100644 --- a/Distribution/Features/SupplierClaims/SupplierClaimController.cs +++ b/Distribution/Features/SupplierClaims/SupplierClaimController.cs @@ -1,11 +1,9 @@ -using Distribution.Infra.Authorization; -using Microsoft.AspNetCore.Authorization; +using Distribution.Infra.Authorization; using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.SupplierClaims; [ApiController] -[Authorize] public class SupplierClaimController(SupplierClaimService service, ICurrentUserService currentUser) : ControllerBase { [HttpGet("api/supplier-claims/{id:guid}")] @@ -32,7 +30,6 @@ public class SupplierClaimController(SupplierClaimService service, ICurrentUserS } [HttpPost("api/supplier-claims/{id:guid}/approve")] - [Authorize(Policy = "suppliers.claims.approve")] public async Task Approve(Guid id, ApproveSupplierClaimRequest request, CancellationToken ct) { var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); @@ -46,7 +43,6 @@ public class SupplierClaimController(SupplierClaimService service, ICurrentUserS } [HttpPost("api/supplier-claims/{id:guid}/reject")] - [Authorize(Policy = "suppliers.claims.approve")] public async Task Reject(Guid id, RejectSupplierClaimRequest request, CancellationToken ct) { var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); diff --git a/Distribution/Features/Sync/SyncController.cs b/Distribution/Features/Sync/SyncController.cs index 2378ddc..2708196 100644 --- a/Distribution/Features/Sync/SyncController.cs +++ b/Distribution/Features/Sync/SyncController.cs @@ -1,11 +1,9 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.Sync; [ApiController] [Route("api/sync")] -[Authorize(Policy = "items.create")] public class SyncController(SyncService service) : ControllerBase { [HttpPost("uoms")] diff --git a/Distribution/Features/Uom/UomController.cs b/Distribution/Features/Uom/UomController.cs index ac594d4..da1ea22 100644 --- a/Distribution/Features/Uom/UomController.cs +++ b/Distribution/Features/Uom/UomController.cs @@ -1,20 +1,16 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.Uom; [ApiController] [Route("api/uoms")] -[Authorize] public class UomController(UomService service) : ControllerBase { [HttpGet] - [Authorize(Policy = "items.view")] public async Task GetAll() => Ok(await service.GetAllAsync()); [HttpGet("{id:guid}")] - [Authorize(Policy = "items.view")] public async Task GetById(Guid id) { var result = await service.GetByIdAsync(id); @@ -22,7 +18,6 @@ public class UomController(UomService service) : ControllerBase } [HttpPost] - [Authorize(Policy = "items.create")] public async Task Create(CreateUomRequest request, CancellationToken ct) { var result = await service.CreateAsync(request, ct); @@ -30,7 +25,6 @@ public class UomController(UomService service) : ControllerBase } [HttpPut("{id:guid}")] - [Authorize(Policy = "items.update")] public async Task Update(Guid id, UpdateUomRequest request, CancellationToken ct) { var result = await service.UpdateAsync(id, request, ct); @@ -38,7 +32,6 @@ public class UomController(UomService service) : ControllerBase } [HttpDelete("{id:guid}")] - [Authorize(Policy = "items.delete")] public async Task Delete(Guid id, CancellationToken ct) { var deleted = await service.DeleteAsync(id, ct); diff --git a/Distribution/Features/User/UserController.cs b/Distribution/Features/User/UserController.cs index f035f11..09db811 100644 --- a/Distribution/Features/User/UserController.cs +++ b/Distribution/Features/User/UserController.cs @@ -1,20 +1,16 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.User; [ApiController] [Route("api/users")] -[Authorize] public class UserController(UserService service) : ControllerBase { [HttpGet] - [Authorize(Policy = "users.view")] public async Task GetAll() => Ok(await service.GetAllAsync()); [HttpGet("{id:guid}")] - [Authorize(Policy = "users.view")] public async Task GetById(Guid id) { var result = await service.GetByIdAsync(id); @@ -22,7 +18,6 @@ public class UserController(UserService service) : ControllerBase } [HttpPost] - [Authorize(Policy = "users.create")] public async Task Create(CreateUserRequest request, CancellationToken ct) { var result = await service.CreateAsync(request, ct); @@ -30,7 +25,6 @@ public class UserController(UserService service) : ControllerBase } [HttpPut("{id:guid}")] - [Authorize(Policy = "users.update")] public async Task Update(Guid id, UpdateUserRequest request, CancellationToken ct) { var result = await service.UpdateAsync(id, request, ct); @@ -38,7 +32,6 @@ public class UserController(UserService service) : ControllerBase } [HttpPut("{id:guid}/role")] - [Authorize(Policy = "users.assign_role")] public async Task AssignRole(Guid id, AssignUserRoleRequest request, CancellationToken ct) { var result = await service.AssignRoleAsync(id, request, ct); @@ -46,7 +39,6 @@ public class UserController(UserService service) : ControllerBase } [HttpPut("{id:guid}/scope")] - [Authorize(Policy = "users.assign_scope")] public async Task AssignScope(Guid id, AssignUserScopeRequest request, CancellationToken ct) { var updated = await service.AssignScopeAsync(id, request, ct); @@ -54,7 +46,6 @@ public class UserController(UserService service) : ControllerBase } [HttpDelete("{id:guid}")] - [Authorize(Policy = "users.deactivate")] public async Task Deactivate(Guid id, CancellationToken ct) { var deactivated = await service.DeactivateAsync(id, ct); diff --git a/Distribution/Features/Vehicle/VehicleController.cs b/Distribution/Features/Vehicle/VehicleController.cs index 3dabed5..d19dd1a 100644 --- a/Distribution/Features/Vehicle/VehicleController.cs +++ b/Distribution/Features/Vehicle/VehicleController.cs @@ -1,20 +1,16 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.Vehicle; [ApiController] [Route("api/vehicles")] -[Authorize] public class VehicleController(VehicleService service) : ControllerBase { [HttpGet] - [Authorize(Policy = "vehicles.view")] public async Task GetAll() => Ok(await service.GetAllAsync()); [HttpGet("{id:guid}")] - [Authorize(Policy = "vehicles.view")] public async Task GetById(Guid id) { var result = await service.GetByIdAsync(id); @@ -22,7 +18,6 @@ public class VehicleController(VehicleService service) : ControllerBase } [HttpPost] - [Authorize(Policy = "vehicles.create")] public async Task Create(CreateVehicleRequest request, CancellationToken ct) { var result = await service.CreateAsync(request, ct); @@ -30,7 +25,6 @@ public class VehicleController(VehicleService service) : ControllerBase } [HttpPut("{id:guid}")] - [Authorize(Policy = "vehicles.update")] public async Task Update(Guid id, UpdateVehicleRequest request, CancellationToken ct) { var result = await service.UpdateAsync(id, request, ct); @@ -38,7 +32,6 @@ public class VehicleController(VehicleService service) : ControllerBase } [HttpDelete("{id:guid}")] - [Authorize(Policy = "vehicles.delete")] public async Task Delete(Guid id, CancellationToken ct) { var deleted = await service.DeleteAsync(id, ct); diff --git a/Distribution/Features/Warehouse/WarehouseController.cs b/Distribution/Features/Warehouse/WarehouseController.cs index bcc8464..043dfbd 100644 --- a/Distribution/Features/Warehouse/WarehouseController.cs +++ b/Distribution/Features/Warehouse/WarehouseController.cs @@ -1,20 +1,16 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.Warehouse; [ApiController] [Route("api/warehouses")] -[Authorize] public class WarehouseController(WarehouseService service) : ControllerBase { [HttpGet] - [Authorize(Policy = "warehouses.view")] public async Task GetAll() => Ok(await service.GetAllAsync()); [HttpGet("{id:guid}")] - [Authorize(Policy = "warehouses.view")] public async Task GetById(Guid id) { var result = await service.GetByIdAsync(id); @@ -22,7 +18,6 @@ public class WarehouseController(WarehouseService service) : ControllerBase } [HttpPost] - [Authorize(Policy = "warehouses.create")] public async Task Create(CreateWarehouseRequest request, CancellationToken ct) { var result = await service.CreateAsync(request, ct); @@ -30,7 +25,6 @@ public class WarehouseController(WarehouseService service) : ControllerBase } [HttpPut("{id:guid}")] - [Authorize(Policy = "warehouses.update")] public async Task Update(Guid id, UpdateWarehouseRequest request, CancellationToken ct) { var result = await service.UpdateAsync(id, request, ct); @@ -38,7 +32,6 @@ public class WarehouseController(WarehouseService service) : ControllerBase } [HttpDelete("{id:guid}")] - [Authorize(Policy = "warehouses.delete")] public async Task Delete(Guid id, CancellationToken ct) { var deleted = await service.DeleteAsync(id, ct); diff --git a/Distribution/Infra/Authorization/AuthenticatedAttribute.cs b/Distribution/Infra/Authorization/AuthenticatedAttribute.cs new file mode 100644 index 0000000..3423d88 --- /dev/null +++ b/Distribution/Infra/Authorization/AuthenticatedAttribute.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace Distribution.Infra.Authorization; + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public class AuthenticatedAttribute : Attribute, IAuthorizationFilter +{ + public void OnAuthorization(AuthorizationFilterContext context) + { + if (context.HttpContext.User.Identity?.IsAuthenticated != true) + context.Result = new UnauthorizedResult(); + } +} diff --git a/Distribution/Infra/Authorization/Controller/AuthorizationController.cs b/Distribution/Infra/Authorization/Controller/AuthorizationController.cs index df2e4d1..e1b6d2d 100644 --- a/Distribution/Infra/Authorization/Controller/AuthorizationController.cs +++ b/Distribution/Infra/Authorization/Controller/AuthorizationController.cs @@ -1,11 +1,9 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; namespace Distribution.Infra.Authorization; [ApiController] [Route("api/authorization")] -[Authorize] public class AuthorizationController( IPermissionService permissionService, IScopeAccessService scopeAccessService, diff --git a/Distribution/Infra/Authorization/PermissionAuthorizationHandler.cs b/Distribution/Infra/Authorization/PermissionAuthorizationHandler.cs index edcc4b3..77b0e72 100644 --- a/Distribution/Infra/Authorization/PermissionAuthorizationHandler.cs +++ b/Distribution/Infra/Authorization/PermissionAuthorizationHandler.cs @@ -1,24 +1,23 @@ -using Microsoft.AspNetCore.Authorization; + +//namespace Distribution.Infra.Authorization; -namespace Distribution.Infra.Authorization; +//public class PermissionAuthorizationHandler : AuthorizationHandler +//{ +// private readonly IPermissionService _permissionService; -public class PermissionAuthorizationHandler : AuthorizationHandler -{ - private readonly IPermissionService _permissionService; +// public PermissionAuthorizationHandler(IPermissionService permissionService) +// { +// _permissionService = permissionService; +// } - public PermissionAuthorizationHandler(IPermissionService permissionService) - { - _permissionService = permissionService; - } +// protected override async Task HandleRequirementAsync( +// AuthorizationHandlerContext context, PermissionRequirement requirement) +// { +// var roleIdClaim = context.User.FindFirst("role_id")?.Value; +// if (!Guid.TryParse(roleIdClaim, out var roleId)) +// return; - protected override async Task HandleRequirementAsync( - AuthorizationHandlerContext context, PermissionRequirement requirement) - { - var roleIdClaim = context.User.FindFirst("role_id")?.Value; - if (!Guid.TryParse(roleIdClaim, out var roleId)) - return; - - if (await _permissionService.HasPermissionAsync(roleId, requirement.PermissionCode)) - context.Succeed(requirement); - } -} +// if (await _permissionService.HasPermissionAsync(roleId, requirement.PermissionCode)) +// context.Succeed(requirement); +// } +//} diff --git a/Distribution/Infra/Authorization/PermissionPolicyProvider.cs b/Distribution/Infra/Authorization/PermissionPolicyProvider.cs index 9ee8ef9..a27cb7d 100644 --- a/Distribution/Infra/Authorization/PermissionPolicyProvider.cs +++ b/Distribution/Infra/Authorization/PermissionPolicyProvider.cs @@ -1,32 +1,31 @@ -using Microsoft.AspNetCore.Authorization; -using Microsoft.Extensions.Options; +//using Microsoft.Extensions.Options; -namespace Distribution.Infra.Authorization; +//namespace Distribution.Infra.Authorization; -public class PermissionPolicyProvider : IAuthorizationPolicyProvider -{ - private readonly DefaultAuthorizationPolicyProvider _fallback; +//public class PermissionPolicyProvider : IAuthorizationPolicyProvider +//{ +// private readonly DefaultAuthorizationPolicyProvider _fallback; - public PermissionPolicyProvider(IOptions options) - { - _fallback = new DefaultAuthorizationPolicyProvider(options); - } +// public PermissionPolicyProvider(IOptions options) +// { +// _fallback = new DefaultAuthorizationPolicyProvider(options); +// } - public Task GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync(); +// public Task GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync(); - public Task GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync(); +// public Task GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync(); - public Task GetPolicyAsync(string policyName) - { - if (policyName.Contains('.')) - { - var policy = new AuthorizationPolicyBuilder() - .RequireAuthenticatedUser() - .AddRequirements(new PermissionRequirement(policyName)) - .Build(); - return Task.FromResult(policy); - } +// public Task GetPolicyAsync(string policyName) +// { +// if (policyName.Contains('.')) +// { +// var policy = new AuthorizationPolicyBuilder() +// .RequireAuthenticatedUser() +// .AddRequirements(new PermissionRequirement(policyName)) +// .Build(); +// return Task.FromResult(policy); +// } - return _fallback.GetPolicyAsync(policyName); - } -} +// return _fallback.GetPolicyAsync(policyName); +// } +//} diff --git a/Distribution/Infra/Authorization/PermissionRequirement.cs b/Distribution/Infra/Authorization/PermissionRequirement.cs index a25e8f6..dacd23b 100644 --- a/Distribution/Infra/Authorization/PermissionRequirement.cs +++ b/Distribution/Infra/Authorization/PermissionRequirement.cs @@ -1,13 +1,12 @@ -using Microsoft.AspNetCore.Authorization; + +//namespace Distribution.Infra.Authorization; -namespace Distribution.Infra.Authorization; +//public class PermissionRequirement : IAuthorizationRequirement +//{ +// public string PermissionCode { get; } -public class PermissionRequirement : IAuthorizationRequirement -{ - public string PermissionCode { get; } - - public PermissionRequirement(string permissionCode) - { - PermissionCode = permissionCode; - } -} +// public PermissionRequirement(string permissionCode) +// { +// PermissionCode = permissionCode; +// } +//} diff --git a/Distribution/Middleware/AuthMiddleware.cs b/Distribution/Middleware/AuthMiddleware.cs new file mode 100644 index 0000000..e0acbc9 --- /dev/null +++ b/Distribution/Middleware/AuthMiddleware.cs @@ -0,0 +1,44 @@ +using System.IdentityModel.Tokens.Jwt; +using System.Text; +using Microsoft.IdentityModel.Tokens; + +namespace Distribution.Middleware; + +public class AuthMiddleware(RequestDelegate next, IConfiguration config) +{ + public async Task InvokeAsync(HttpContext context) + { + var token = context.Request.Headers.Authorization + .ToString() + .Replace("Bearer ", "", StringComparison.OrdinalIgnoreCase) + .Trim(); + + if (!string.IsNullOrEmpty(token)) + { + var key = config["Jwt:Key"]; + if (!string.IsNullOrEmpty(key)) + { + try + { + var principal = new JwtSecurityTokenHandler().ValidateToken(token, + new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)), + ValidateIssuer = true, + ValidIssuer = config["Jwt:Issuer"], + ValidateAudience = true, + ValidAudience = config["Jwt:Audience"], + ValidateLifetime = true, + ClockSkew = TimeSpan.FromMinutes(1) + }, out _); + + context.User = principal; + } + catch { } + } + } + + await next(context); + } +} diff --git a/Distribution/Program.cs b/Distribution/Program.cs index 11e4ff6..9d15715 100644 --- a/Distribution/Program.cs +++ b/Distribution/Program.cs @@ -1,6 +1,6 @@ -using System.Security.Claims; using Distribution.Data; using Distribution.Features.Auth; +using Distribution.Middleware; using Distribution.Features.Brand; using Distribution.Features.Category; using Distribution.Features.DeliveryRide; @@ -34,10 +34,7 @@ using Distribution.Features.Stock; using Distribution.Features.Warehouse; using Distribution.Infra.Authorization; using Distribution.Infra.UoW; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.Authorization; using Microsoft.EntityFrameworkCore; -using Microsoft.IdentityModel.Tokens; var builder = WebApplication.CreateBuilder(args); @@ -126,67 +123,26 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddSingleton(); +// ── Auth Service connector ─────────────────────────────────────────────────── +builder.Services.Configure( + builder.Configuration.GetSection(AuthServiceOptions.SectionName)); -var jwtSection = builder.Configuration.GetSection("Jwt"); -var jwtPublicKeyXml = jwtSection["PublicKey"] ?? throw new InvalidOperationException("Jwt:PublicKey is not configured."); -var jwtSigningKey = new RsaSecurityKey(RsaXmlKeyReader.ReadPublicKey(jwtPublicKeyXml)); +var authServiceBaseUrl = builder.Configuration[$"{AuthServiceOptions.SectionName}:BaseUrl"]; -builder.Services.AddAuthentication(options => - { - options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; - options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; - }) - .AddJwtBearer(options => - { - options.TokenValidationParameters = new TokenValidationParameters - { - ValidateIssuer = true, - ValidIssuer = jwtSection["Issuer"], - ValidateAudience = true, - ValidAudience = jwtSection["Audience"], - ValidateIssuerSigningKey = true, - IssuerSigningKey = jwtSigningKey, - ValidateLifetime = true, - ClockSkew = TimeSpan.FromMinutes(1) - }; +builder.Services.AddHttpClient(client => +{ + if (!string.IsNullOrWhiteSpace(authServiceBaseUrl)) + client.BaseAddress = new Uri(authServiceBaseUrl); +}); - // AuthHex issues the token and owns its claim shape; this app's authorization (permission - // policies, role-level checks) runs against the *local* Role/Permission tables, so every - // validated token is mapped back to a local User/Role once here, not duplicated per - // endpoint. A token for a user with no matching local User record is rejected outright — - // login via AuthHex does not implicitly provision a local user. - options.Events = new JwtBearerEvents - { - OnTokenValidated = async context => - { - var subClaim = context.Principal?.FindFirst("sub")?.Value - ?? context.Principal?.FindFirst(ClaimTypes.NameIdentifier)?.Value; +builder.Services.AddScoped(); - if (!Guid.TryParse(subClaim, out var userId)) - { - context.Fail("Token is missing a valid user id claim."); - return; - } - - var userRepo = context.HttpContext.RequestServices.GetRequiredService(); - var user = await userRepo.GetByIdAsync(userId); - if (user is null || !user.IsActive) - { - context.Fail("User not found or inactive."); - return; - } - - var identity = (ClaimsIdentity)context.Principal!.Identity!; - identity.AddClaim(new Claim("username", user.Username)); - identity.AddClaim(new Claim("role_id", user.FK_RoleId.ToString())); - identity.AddClaim(new Claim("role_level", (user.Role?.Level ?? 0).ToString())); - } - }; - }); - -builder.Services.AddAuthorization(); +var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get() ?? []; +builder.Services.AddCors(options => + options.AddDefaultPolicy(policy => + policy.WithOrigins(allowedOrigins) + .AllowAnyHeader() + .AllowAnyMethod())); builder.Services.AddControllers(); builder.Services.AddEndpointsApiExplorer(); @@ -202,9 +158,9 @@ if (app.Environment.IsDevelopment()) app.UseSwaggerUI(); } -app.UseHttpsRedirection(); -app.UseAuthentication(); -app.UseAuthorization(); +//app.UseHttpsRedirection(); +app.UseCors(); +app.UseMiddleware(); app.MapControllers(); app.Run(); diff --git a/Distribution/appsettings.json b/Distribution/appsettings.json index 8f6c632..d63b6f9 100644 --- a/Distribution/appsettings.json +++ b/Distribution/appsettings.json @@ -1,4 +1,5 @@ { + "Urls": "http://localhost:5089", "ConnectionStrings": { "DefaultConnection": "Server=187.127.102.190;Port=3306;Database=distribution;User=kalana;Password=kalana@hexdive" }, @@ -9,6 +10,9 @@ } }, "AllowedHosts": "*", + "Cors": { + "AllowedOrigins": [ "http://localhost:3000" ] + }, "Jwt": { "PublicKey": "1LlNkMBQNdpXJiDal7XMxkG/3ad+YBsMCuY9JD/abHMzniFXtQlovjfbeaaHJ0v1kvSo9731CJ0YC1qhPU5rPQwZwxOWZ9BOBZlMDghONdjOH/HyCUbb5Z18ibqc0QenFSnEYz+jkVZiayj8DV/+VUe+eKzpQTlU6aWtHvlbwfuXaDu+QvFlpLJ7/m8na+0s2nYhLX8Wfi4C/2AoNaYhFkIwYhMMGoaSHuIoQ5R6181Rh0gKvYopRW+IpTD5RV8bXV3AM6zOcoisOifBYROHwA5ZZpoHXuTvHYmPWW8kL8PKme7BwBldPi8KrJUroRE+WXA87aAA5Wtt1oxePcXvhQ==AQAB", "Audience": "AuthHexClient", diff --git a/Distribution/docs/06_sale_ride.md b/Distribution/docs/06_sale_ride.md index ef45871..5de12e8 100644 --- a/Distribution/docs/06_sale_ride.md +++ b/Distribution/docs/06_sale_ride.md @@ -64,16 +64,29 @@ When `FulfillImmediately = false`, no stock movement — order stays Pending for **Feature folder:** `Features/Payment/` -### API Endpoints +### Sales API Endpoints | Method | Route | Who | Description | |---|---|---|---| -| POST | `/api/sales/{id}/payments` | Driver/Agent | Record a payment against a sale | -| GET | `/api/sales/{id}/payments` | Any | List all payments for a sale | +| GET | `/api/sales` | Any | List sales — supports `?rideId=`, `?page=`, `?pageSize=` query params | +| POST | `/api/sales/{saleId:guid}/payments` | Driver/Agent | Record a payment against a sale | +| GET | `/api/sales/{saleId:guid}/payments` | Any | List all payments for a sale | + +**`GET /api/sales` filter params:** + +| Param | Type | Description | +|---|---|---| +| `rideId` | `Guid?` | Filter to sales created on a specific ride | +| `page` | `int` | Page number (default 1) | +| `pageSize` | `int` | Items per page (default 20) | + +Returns `PagedResponse`: `{ page, pageSize, total, items }`. + +Scope rules still apply: non-global users only see sales for stores in their scope. ### Payment Creation Logic -On `POST /api/sales/{id}/payments`: +On `POST /api/sales/{saleId:guid}/payments`: 1. Validate sale exists and is not `Cancelled`. 2. Add `Payment` record (`FK_SaleId`, `FK_StoreId` from sale, `Amount`, `Method`, `CollectedAt`, `CollectedBy`). 3. Sum all payments for this sale. Recalculate `Sale.PaymentStatus`: diff --git a/Distribution/docs/12_store_management.md b/Distribution/docs/12_store_management.md index a6676a9..daf6e56 100644 --- a/Distribution/docs/12_store_management.md +++ b/Distribution/docs/12_store_management.md @@ -100,8 +100,8 @@ One migration needed: ### New | Method | Route | Permission | Notes | |---|---|---|---| -| POST | /api/stores/{id}/kyc-documents | stores.update | Multipart upload, appends a KYC document | -| GET | /api/stores/{id}/kyc-documents | stores.view | Lists documents for a store | +| POST | /api/stores/{storeId:guid}/kyc-documents | stores.update | Multipart upload, appends a KYC document | +| GET | /api/stores/{storeId:guid}/kyc-documents | stores.view | Lists documents for a store | | PUT | /api/stores/{id}/kyc/verify | stores.kyc.verify | Sets KycStatus, body carries `status` (Verified/Rejected) + optional `notes` | | PUT | /api/stores/{id}/credit-limit | stores.credit.update | Requires Supervisor (roleLevel ≥ 20) or above | | PUT | /api/stores/{id}/outstanding-balance | stores.credit.update | Manual adjustment until Finance owns this; same permission as credit-limit | @@ -115,7 +115,7 @@ One migration needed: ## Request / Response Shapes -### POST `/api/stores/{id}/kyc-documents` (multipart/form-data) +### POST `/api/stores/{storeId:guid}/kyc-documents` (multipart/form-data) Fields: `documentType` (string), `file` (binary). Response: @@ -247,7 +247,7 @@ ever belong to a Store and have no independent lifecycle. ### KYC upload logic (pseudo) ``` -POST /api/stores/{id}/kyc-documents — multipart (documentType, file): +POST /api/stores/{storeId:guid}/kyc-documents — multipart (documentType, file): 1. Load Store by id, reject if missing/inactive or caller lacks store scope 2. Ensure wwwroot/uploads/kyc/{storeId}/ exists 3. Save file as {guid}.{ext}, record FileReference = "uploads/kyc/{storeId}/{guid}.{ext}" diff --git a/Distribution/docs/17_promotions_schemes.md b/Distribution/docs/17_promotions_schemes.md index 23a435f..f3ef70b 100644 --- a/Distribution/docs/17_promotions_schemes.md +++ b/Distribution/docs/17_promotions_schemes.md @@ -347,7 +347,7 @@ GET /api/schemes/eligible?storeId=...&orderId=... **Apply it:** ``` -POST /api/orders/{orderId}/apply-scheme +POST /api/orders/{id}/apply-scheme { "schemeId": "..." } ``` Adds an `OrderItem` line (`Soap-500ml`, qty 2, `UnitPrice = 0`) and a `SchemeRedemption` with diff --git a/Distribution/docs/19_finance_accounting.md b/Distribution/docs/19_finance_accounting.md index 906d3b5..9aacd80 100644 --- a/Distribution/docs/19_finance_accounting.md +++ b/Distribution/docs/19_finance_accounting.md @@ -46,7 +46,7 @@ home until this step. There is no invoice, payment, or GL entity for warehouse-f | Function reference | Endpoint intent | |---|---| | `createInvoice(orderId)` | `POST /api/orders/{id}/invoice` — the warehouse-fulfilled counterpart to [06_sale_ride.md](./06_sale_ride.md)'s field invoice creation | -| `recordPayment(invoiceId, amount, method)` | `POST /api/invoices/{id}/payments` | +| `recordPayment(invoiceId, amount, method)` | Current implemented path is `POST /api/sales/{saleId:guid}/payments` for field-sale collections; `POST /api/invoices/{id}/payments` remains the target endpoint for this future invoice-based finance step | | `postToGeneralLedger(transactionId)` | Internal — triggered on invoice/payment/credit-note events | | `generateTaxInvoice(invoiceId, jurisdiction)` | `GET /api/invoices/{id}/tax-invoice?jurisdiction=...` | | `getFinancialReport(entityId, period, reportType)` | `GET /api/finance/reports?entityId=...&period=...&reportType=...` | diff --git a/Distribution/docs/mainPlan.md b/Distribution/docs/mainPlan.md index 3c63180..24f5d6e 100644 --- a/Distribution/docs/mainPlan.md +++ b/Distribution/docs/mainPlan.md @@ -143,9 +143,9 @@ here — see `SRS_User_Management_Module.pdf`. | Function | What it does | Status | |---|---|---| -| `createStore(storeData, kycDocs)` | Onboards a new outlet with required KYC. | Partially implemented - store create exists, and KYC documents can now be uploaded/listed via separate `/api/stores/{id}/kyc-documents` endpoints rather than inline on create. | -| `uploadStoreKycDocument(storeId, documentType, file)` | Appends a KYC document to a store. | Implemented — `POST /api/stores/{id}/kyc-documents`. | -| `listStoreKycDocuments(storeId)` | Lists KYC documents for a store. | Implemented — `GET /api/stores/{id}/kyc-documents`. | +| `createStore(storeData, kycDocs)` | Onboards a new outlet with required KYC. | Partially implemented - store create exists, and KYC documents can now be uploaded/listed via separate `/api/stores/{storeId:guid}/kyc-documents` endpoints rather than inline on create. | +| `uploadStoreKycDocument(storeId, documentType, file)` | Appends a KYC document to a store. | Implemented — `POST /api/stores/{storeId:guid}/kyc-documents`. | +| `listStoreKycDocuments(storeId)` | Lists KYC documents for a store. | Implemented — `GET /api/stores/{storeId:guid}/kyc-documents`. | | `verifyStoreKyc(storeId)` | Marks KYC as verified/rejected by an approver. | Implemented - `PUT /api/stores/{id}/kyc/verify` updates `Store.KycStatus`. | | `updateStoreCreditLimit(storeId, newLimit, approverId)` | Changes a store's credit limit, subject to approval authority. | Implemented - `PUT /api/stores/{id}/credit-limit` updates `Store.CreditLimit` and requires the `stores.credit.update` permission plus role level >= 20. | | `updateStoreOutstandingBalance(storeId, amount, reason?)` | Manually adjusts the cached outstanding balance. | Implemented — `PUT /api/stores/{id}/outstanding-balance`. | @@ -281,7 +281,7 @@ here — see `SRS_User_Management_Module.pdf`. | Function | What it does | Status | |---|---|---| | `createInvoice(orderId)` | Generates a formal invoice from a fulfilled order. | Not implemented — no `Invoice` entity exists; `Sale`/`Order` remain the billable record. | -| `recordPayment(invoiceId, amount, method)` | Logs a payment/collection against an invoice. | Implemented against `Sale` rather than an invoice — `POST /api/sales/{saleId}/payments` (`Features/Payment`). Each payment sums against `Sale.TotalAmount` and updates `Sale.PaymentStatus` (`Pending`/`PartiallyPaid`/`Paid`); rejects (400) if the sale is `Cancelled`. `Method` is `Cash` or `Credit` (`Models/Enums.cs`). | +| `recordPayment(invoiceId, amount, method)` | Logs a payment/collection against an invoice. | Implemented against `Sale` rather than an invoice — `POST /api/sales/{saleId:guid}/payments` (`Features/Payment`). Each payment sums against `Sale.TotalAmount` and updates `Sale.PaymentStatus` (`Pending`/`PartiallyPaid`/`Paid`); rejects (400) if the sale is `Cancelled`. `Method` is `Cash` or `Credit` (`Models/Enums.cs`). | | `postToGeneralLedger(transactionId)` | Posts a financial transaction to the GL. | Not implemented — no GL/ledger entity exists. | | `generateTaxInvoice(invoiceId, jurisdiction)` | Produces a tax-compliant invoice (GST/VAT e-invoicing). | Not implemented. | | `getFinancialReport(entityId, period, reportType)` | Returns P&L/cash-flow reports scoped to a region/area. | Not implemented. | diff --git a/distribution_fe/.claude/scheduled_tasks.lock b/distribution_fe/.claude/scheduled_tasks.lock new file mode 100644 index 0000000..515312a --- /dev/null +++ b/distribution_fe/.claude/scheduled_tasks.lock @@ -0,0 +1 @@ +{"sessionId":"879f34f3-9715-423d-aee2-535ba3a8accd","pid":14552,"acquiredAt":1787669985270} \ No newline at end of file diff --git a/distribution_fe/app/(dashboard)/catalogue/brands/_components/brand-sheet.tsx b/distribution_fe/app/(dashboard)/catalogue/brands/_components/brand-sheet.tsx new file mode 100644 index 0000000..2861cd3 --- /dev/null +++ b/distribution_fe/app/(dashboard)/catalogue/brands/_components/brand-sheet.tsx @@ -0,0 +1,131 @@ +'use client' + +import { useEffect } from 'react' +import { useForm, zodResolver, z } from '@/lib/form' +import { FormField } from '@/components/shared/form-field' +import { Input } from '@/components/ui/input' +import { Checkbox } from '@/components/ui/checkbox' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { FieldGroup } from '@/components/ui/field' +import { toast } from '@/components/ui/toast' +import { brands as brandsApi, getApiError } from '@/lib/api/index' +import type { BrandResponse } from '@/types/api' + +const schema = z.object({ + name: z.string().min(1, 'Name is required'), + isActive: z.boolean(), +}) + +type FormValues = z.infer + +interface BrandSheetProps { + open: boolean + onClose: () => void + brand: BrandResponse | null + onSuccess: () => void +} + +export function BrandSheet({ open, onClose, brand, onSuccess }: BrandSheetProps) { + const isEdit = !!brand + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { name: '', isActive: true }, + }) + + useEffect(() => { + if (open) { + form.reset( + brand + ? { name: brand.name, isActive: brand.isActive } + : { name: '', isActive: true } + ) + } + }, [open, brand]) + + async function onSubmit(values: FormValues) { + try { + if (isEdit) { + await brandsApi.update(brand!.id, values) + toast.add({ title: 'Brand updated', type: 'success' }) + } else { + await brandsApi.create(values) + toast.add({ title: 'Brand created', type: 'success' }) + } + onSuccess() + onClose() + } catch (err) { + toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' }) + } + } + + return ( + !v && onClose()}> + + + {isEdit ? 'Edit Brand' : 'Add Brand'} + + {isEdit ? 'Update the brand details below.' : 'Add a new brand to the catalogue.'} + + + +
+ + ( + + )} + /> + + ( +
+ + +
+ )} + /> +
+ + + + + +
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/catalogue/brands/_components/brands-client.tsx b/distribution_fe/app/(dashboard)/catalogue/brands/_components/brands-client.tsx new file mode 100644 index 0000000..eb2a987 --- /dev/null +++ b/distribution_fe/app/(dashboard)/catalogue/brands/_components/brands-client.tsx @@ -0,0 +1,136 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { Add01Icon } from '@hugeicons/core-free-icons' + +import { DataTable } from '@/components/shared/data-table' +import type { TableConfig } from '@/components/shared/data-table' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/toast' +import { brands as brandsApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { BrandResponse } from '@/types/api' + +import { BrandSheet } from './brand-sheet' + +export function BrandsClient() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + + const [sheetOpen, setSheetOpen] = useState(false) + const [editing, setEditing] = useState(null) + + const fetchData = useCallback(async (p: number, ps: number) => { + setLoading(true) + try { + const res = await brandsApi.list({ page: p, pageSize: ps }) + setData(getPaginatedItems(res)) + setTotal(res.total) + } catch (err) { + toast.add({ title: 'Failed to load brands', description: getApiError(err), type: 'error' }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData(page, pageSize) + }, [fetchData, page, pageSize]) + + function openCreate() { + setEditing(null) + setSheetOpen(true) + } + + function openEdit(brand: BrandResponse) { + setEditing(brand) + setSheetOpen(true) + } + + const tableConfig = useMemo>(() => ({ + rowKey: 'id', + serverSide: true, + stickyHeader: true, + emptyMessage: 'No brands yet. Add one to get started.', + pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] }, + columns: [ + { + key: 'name', + title: 'Brand', + fields: [{ key: 'name', primary: true }], + sortable: true, + minWidth: 200, + }, + { + key: 'status', + title: 'Status', + fields: ['isActive'], + width: 120, + cellRender: (row) => ( + + {row.isActive ? 'Active' : 'Inactive'} + + ), + }, + { + key: 'createdAt', + title: 'Created', + fields: [{ + key: 'createdAt', + transform: (v) => new Date(v as string).toLocaleDateString('en-GB', { + day: '2-digit', month: 'short', year: 'numeric', + }), + }], + width: 140, + }, + ], + rowActions: { + actions: () => [ + { + action: 'edit', + label: 'Edit brand', + onClick: openEdit, + }, + ], + }, + }), [pageSize, openEdit]) + + return ( + <> +
+
+

Brands

+

+ Brands associated with catalogue items +

+
+ +
+ + { + setPage(p.page) + setPageSize(p.pageSize) + }} + /> + + setSheetOpen(false)} + brand={editing} + onSuccess={() => fetchData(page, pageSize)} + /> + + ) +} diff --git a/distribution_fe/app/(dashboard)/catalogue/brands/page.tsx b/distribution_fe/app/(dashboard)/catalogue/brands/page.tsx new file mode 100644 index 0000000..753bc3c --- /dev/null +++ b/distribution_fe/app/(dashboard)/catalogue/brands/page.tsx @@ -0,0 +1,5 @@ +import { BrandsClient } from './_components/brands-client' + +export default function BrandsPage() { + return +} diff --git a/distribution_fe/app/(dashboard)/catalogue/categories/_components/categories-client.tsx b/distribution_fe/app/(dashboard)/catalogue/categories/_components/categories-client.tsx new file mode 100644 index 0000000..0821633 --- /dev/null +++ b/distribution_fe/app/(dashboard)/catalogue/categories/_components/categories-client.tsx @@ -0,0 +1,140 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { Add01Icon } from '@hugeicons/core-free-icons' + +import { DataTable } from '@/components/shared/data-table' +import type { TableConfig } from '@/components/shared/data-table' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/toast' +import { categories as categoriesApi, getApiError } from '@/lib/api/index' +import type { CategoryResponse } from '@/types/api' + +import { CategorySheet } from './category-sheet' + +export function CategoriesClient() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + + const [sheetOpen, setSheetOpen] = useState(false) + const [editing, setEditing] = useState(null) + + const fetchData = useCallback(async () => { + setLoading(true) + try { + const res = await categoriesApi.list() + setData(res) + setTotal(res.length) + } catch (err) { + toast.add({ title: 'Failed to load categories', description: getApiError(err), type: 'error' }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData() + }, [fetchData]) + + function openCreate() { + setEditing(null) + setSheetOpen(true) + } + + function openEdit(category: CategoryResponse) { + setEditing(category) + setSheetOpen(true) + } + + const tableConfig = useMemo>(() => ({ + rowKey: 'id', + serverSide: false, + stickyHeader: true, + emptyMessage: 'No categories yet. Add one to get started.', + pagination: { enabled: true, pageSize: 20, pageSizeOptions: [10, 20, 50] }, + columns: [ + { + key: 'name', + title: 'Category', + fields: [{ key: 'name', primary: true }], + sortable: true, + minWidth: 200, + }, + { + key: 'subCategories', + title: 'Sub-categories', + fields: ['subCategories'], + width: 160, + cellRender: (row) => ( + + {row.subCategories.length} sub-categories + + ), + }, + { + key: 'status', + title: 'Status', + fields: ['isActive'], + width: 120, + cellRender: (row) => ( + + {row.isActive ? 'Active' : 'Inactive'} + + ), + }, + { + key: 'createdAt', + title: 'Created', + fields: [{ + key: 'createdAt', + transform: (v) => new Date(v as string).toLocaleDateString('en-GB', { + day: '2-digit', month: 'short', year: 'numeric', + }), + }], + width: 140, + }, + ], + rowActions: { + actions: () => [ + { + action: 'edit', + label: 'Edit category', + onClick: openEdit, + }, + ], + }, + }), [openEdit]) + + return ( + <> +
+
+

Categories

+

+ Product groupings and sub-categories +

+
+ +
+ + + + setSheetOpen(false)} + category={editing} + onSuccess={() => fetchData()} + /> + + ) +} diff --git a/distribution_fe/app/(dashboard)/catalogue/categories/_components/category-sheet.tsx b/distribution_fe/app/(dashboard)/catalogue/categories/_components/category-sheet.tsx new file mode 100644 index 0000000..51f70ef --- /dev/null +++ b/distribution_fe/app/(dashboard)/catalogue/categories/_components/category-sheet.tsx @@ -0,0 +1,131 @@ +'use client' + +import { useEffect } from 'react' +import { useForm, zodResolver, z } from '@/lib/form' +import { FormField } from '@/components/shared/form-field' +import { Input } from '@/components/ui/input' +import { Checkbox } from '@/components/ui/checkbox' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +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' + +const schema = z.object({ + name: z.string().min(1, 'Name is required'), + isActive: z.boolean(), +}) + +type FormValues = z.infer + +interface CategorySheetProps { + open: boolean + onClose: () => void + category: CategoryResponse | null + onSuccess: () => void +} + +export function CategorySheet({ open, onClose, category, onSuccess }: CategorySheetProps) { + const isEdit = !!category + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { name: '', isActive: true }, + }) + + useEffect(() => { + if (open) { + form.reset( + category + ? { name: category.name, isActive: category.isActive } + : { name: '', isActive: true } + ) + } + }, [open, category]) + + async function onSubmit(values: FormValues) { + try { + if (isEdit) { + await categoriesApi.update(category!.id, values) + toast.add({ title: 'Category updated', type: 'success' }) + } else { + await categoriesApi.create(values) + toast.add({ title: 'Category created', type: 'success' }) + } + onSuccess() + onClose() + } catch (err) { + toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' }) + } + } + + return ( + !v && onClose()}> + + + {isEdit ? 'Edit Category' : 'Add Category'} + + {isEdit ? 'Update the category details below.' : 'Add a new product category.'} + + + +
+ + ( + + )} + /> + + ( +
+ + +
+ )} + /> +
+ + + + + +
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/catalogue/categories/page.tsx b/distribution_fe/app/(dashboard)/catalogue/categories/page.tsx new file mode 100644 index 0000000..caf37fb --- /dev/null +++ b/distribution_fe/app/(dashboard)/catalogue/categories/page.tsx @@ -0,0 +1,5 @@ +import { CategoriesClient } from './_components/categories-client' + +export default function CategoriesPage() { + return +} diff --git a/distribution_fe/app/(dashboard)/catalogue/items/_components/item-sheet.tsx b/distribution_fe/app/(dashboard)/catalogue/items/_components/item-sheet.tsx new file mode 100644 index 0000000..ecc3f6c --- /dev/null +++ b/distribution_fe/app/(dashboard)/catalogue/items/_components/item-sheet.tsx @@ -0,0 +1,291 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useForm, zodResolver, z } from '@/lib/form' +import { FormField } from '@/components/shared/form-field' +import { Input } from '@/components/ui/input' +import { Checkbox } from '@/components/ui/checkbox' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { FieldGroup } from '@/components/ui/field' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { toast } from '@/components/ui/toast' +import { items as itemsApi, categories as categoriesApi, brands as brandsApi, uoms as uomsApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { ItemSummaryResponse, CategoryResponse, BrandResponse, UomResponse } from '@/types/api' + +const schema = z.object({ + sku: z.string().min(1, 'SKU is required'), + name: z.string().min(1, 'Name is required'), + categoryId: z.string().min(1, 'Category is required'), + brandId: z.string().optional(), + baseUomId: z.string().min(1, 'Base UOM is required'), + salePrice: z.number().min(0, 'Sale price must be 0 or more'), + isActive: z.boolean(), +}) + +type FormValues = z.infer + +interface ItemSheetProps { + open: boolean + onClose: () => void + item: ItemSummaryResponse | null + onSuccess: () => void +} + +export function ItemSheet({ open, onClose, item, onSuccess }: ItemSheetProps) { + const isEdit = !!item + + const [categories, setCategories] = useState([]) + const [brandsList, setBrandsList] = useState([]) + const [uomsList, setUomsList] = useState([]) + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + sku: '', + name: '', + categoryId: '', + brandId: undefined, + baseUomId: '', + salePrice: 0, + isActive: true, + }, + }) + + useEffect(() => { + if (open) { + Promise.all([ + categoriesApi.list(), + brandsApi.list({ page: 1, pageSize: 200 }), + uomsApi.list(), + ]).then(([cats, brandsRes, uomsRes]) => { + setCategories(cats) + setBrandsList(getPaginatedItems(brandsRes)) + setUomsList(uomsRes) + }).catch((err) => { + toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }) + }) + + form.reset( + item + ? { + sku: item.sku, + name: item.name, + categoryId: item.categoryId, + brandId: item.brandId ?? undefined, + baseUomId: item.baseUomId, + salePrice: item.salePrice, + isActive: item.isActive, + } + : { + sku: '', + name: '', + categoryId: '', + brandId: undefined, + baseUomId: '', + salePrice: 0, + isActive: true, + } + ) + } + }, [open, item]) + + async function onSubmit(values: FormValues) { + try { + const body = { + ...values, + brandId: values.brandId || null, + } + if (isEdit) { + await itemsApi.update(item!.id, body) + toast.add({ title: 'Item updated', type: 'success' }) + } else { + await itemsApi.create(body) + toast.add({ title: 'Item created', type: 'success' }) + } + onSuccess() + onClose() + } catch (err) { + toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' }) + } + } + + return ( + !v && onClose()}> + + + {isEdit ? 'Edit Item' : 'Add Item'} + + {isEdit ? 'Update the item details below.' : 'Add a new catalogue item.'} + + + +
+ + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + field.onChange(parseFloat(e.target.value) || 0)} + onBlur={field.onBlur} + placeholder="0.00" + aria-invalid={invalid} + /> + )} + /> + + ( +
+ + +
+ )} + /> +
+ + + + + +
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/catalogue/items/_components/items-client.tsx b/distribution_fe/app/(dashboard)/catalogue/items/_components/items-client.tsx new file mode 100644 index 0000000..dec1ee4 --- /dev/null +++ b/distribution_fe/app/(dashboard)/catalogue/items/_components/items-client.tsx @@ -0,0 +1,163 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { Add01Icon } from '@hugeicons/core-free-icons' + +import { DataTable } from '@/components/shared/data-table' +import type { TableConfig } from '@/components/shared/data-table' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/toast' +import { items as itemsApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { ItemSummaryResponse } from '@/types/api' + +import { ItemSheet } from './item-sheet' + +export function ItemsClient() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + + const [sheetOpen, setSheetOpen] = useState(false) + const [editing, setEditing] = useState(null) + + const fetchData = useCallback(async (p: number, ps: number) => { + setLoading(true) + try { + const res = await itemsApi.list({ page: p, pageSize: ps }) + setData(getPaginatedItems(res)) + setTotal(res.total) + } catch (err) { + toast.add({ title: 'Failed to load items', description: getApiError(err), type: 'error' }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData(page, pageSize) + }, [fetchData, page, pageSize]) + + function openCreate() { + setEditing(null) + setSheetOpen(true) + } + + function openEdit(item: ItemSummaryResponse) { + setEditing(item) + setSheetOpen(true) + } + + const tableConfig = useMemo>(() => ({ + rowKey: 'id', + serverSide: true, + stickyHeader: true, + emptyMessage: 'No items yet. Add one to get started.', + pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] }, + columns: [ + { + key: 'sku', + title: 'SKU', + fields: [{ key: 'sku' }], + width: 140, + }, + { + key: 'name', + title: 'Name', + fields: [{ key: 'name', primary: true }], + sortable: true, + minWidth: 200, + }, + { + key: 'categoryName', + title: 'Category', + fields: [{ key: 'categoryName' }], + minWidth: 140, + }, + { + key: 'brandName', + title: 'Brand', + fields: ['brandName'], + width: 140, + cellRender: (row) => ( + + {row.brandName ?? '—'} + + ), + }, + { + key: 'baseUomName', + title: 'Base UOM', + fields: [{ key: 'baseUomName' }], + width: 120, + }, + { + key: 'salePrice', + title: 'Sale Price', + fields: ['salePrice'], + width: 120, + cellRender: (row) => ( + Rs. {row.salePrice.toFixed(2)} + ), + }, + { + key: 'status', + title: 'Status', + fields: ['isActive'], + width: 120, + cellRender: (row) => ( + + {row.isActive ? 'Active' : 'Inactive'} + + ), + }, + ], + rowActions: { + actions: () => [ + { + action: 'edit', + label: 'Edit item', + onClick: openEdit, + }, + ], + }, + }), [pageSize]) + + return ( + <> +
+
+

Items

+

+ Products available for sale and distribution +

+
+ +
+ + { + setPage(p.page) + setPageSize(p.pageSize) + }} + /> + + setSheetOpen(false)} + item={editing} + onSuccess={() => fetchData(page, pageSize)} + /> + + ) +} diff --git a/distribution_fe/app/(dashboard)/catalogue/items/page.tsx b/distribution_fe/app/(dashboard)/catalogue/items/page.tsx new file mode 100644 index 0000000..09153b9 --- /dev/null +++ b/distribution_fe/app/(dashboard)/catalogue/items/page.tsx @@ -0,0 +1,5 @@ +import { ItemsClient } from './_components/items-client' + +export default function ItemsPage() { + return +} diff --git a/distribution_fe/app/(dashboard)/catalogue/uoms/_components/uom-sheet.tsx b/distribution_fe/app/(dashboard)/catalogue/uoms/_components/uom-sheet.tsx new file mode 100644 index 0000000..16133bf --- /dev/null +++ b/distribution_fe/app/(dashboard)/catalogue/uoms/_components/uom-sheet.tsx @@ -0,0 +1,131 @@ +'use client' + +import { useEffect } from 'react' +import { useForm, zodResolver, z } from '@/lib/form' +import { FormField } from '@/components/shared/form-field' +import { Input } from '@/components/ui/input' +import { Checkbox } from '@/components/ui/checkbox' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { FieldGroup } from '@/components/ui/field' +import { toast } from '@/components/ui/toast' +import { uoms as uomsApi, getApiError } from '@/lib/api/index' +import type { UomResponse } from '@/types/api' + +const schema = z.object({ + name: z.string().min(1, 'Name is required'), + isActive: z.boolean(), +}) + +type FormValues = z.infer + +interface UomSheetProps { + open: boolean + onClose: () => void + uom: UomResponse | null + onSuccess: () => void +} + +export function UomSheet({ open, onClose, uom, onSuccess }: UomSheetProps) { + const isEdit = !!uom + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { name: '', isActive: true }, + }) + + useEffect(() => { + if (open) { + form.reset( + uom + ? { name: uom.name, isActive: uom.isActive } + : { name: '', isActive: true } + ) + } + }, [open, uom]) + + async function onSubmit(values: FormValues) { + try { + if (isEdit) { + await uomsApi.update(uom!.id, values) + toast.add({ title: 'UOM updated', type: 'success' }) + } else { + await uomsApi.create(values) + toast.add({ title: 'UOM created', type: 'success' }) + } + onSuccess() + onClose() + } catch (err) { + toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' }) + } + } + + return ( + !v && onClose()}> + + + {isEdit ? 'Edit Unit of Measure' : 'Add Unit of Measure'} + + {isEdit ? 'Update the unit of measure details below.' : 'Add a new unit of measure.'} + + + +
+ + ( + + )} + /> + + ( +
+ + +
+ )} + /> +
+ + + + + +
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/catalogue/uoms/_components/uoms-client.tsx b/distribution_fe/app/(dashboard)/catalogue/uoms/_components/uoms-client.tsx new file mode 100644 index 0000000..200fab9 --- /dev/null +++ b/distribution_fe/app/(dashboard)/catalogue/uoms/_components/uoms-client.tsx @@ -0,0 +1,129 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { Add01Icon } from '@hugeicons/core-free-icons' + +import { DataTable } from '@/components/shared/data-table' +import type { TableConfig } from '@/components/shared/data-table' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/toast' +import { uoms as uomsApi, getApiError } from '@/lib/api/index' +import type { UomResponse } from '@/types/api' + +import { UomSheet } from './uom-sheet' + +export function UomsClient() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + + const [sheetOpen, setSheetOpen] = useState(false) + const [editing, setEditing] = useState(null) + + const fetchData = useCallback(async () => { + setLoading(true) + try { + const res = await uomsApi.list() + setData(res) + setTotal(res.length) + } catch (err) { + toast.add({ title: 'Failed to load units of measure', description: getApiError(err), type: 'error' }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData() + }, [fetchData]) + + function openCreate() { + setEditing(null) + setSheetOpen(true) + } + + function openEdit(uom: UomResponse) { + setEditing(uom) + setSheetOpen(true) + } + + const tableConfig = useMemo>(() => ({ + rowKey: 'id', + serverSide: false, + stickyHeader: true, + emptyMessage: 'No units of measure yet. Add one to get started.', + pagination: { enabled: true, pageSize: 20, pageSizeOptions: [10, 20, 50] }, + columns: [ + { + key: 'name', + title: 'Unit', + fields: [{ key: 'name', primary: true }], + sortable: true, + minWidth: 200, + }, + { + key: 'status', + title: 'Status', + fields: ['isActive'], + width: 120, + cellRender: (row) => ( + + {row.isActive ? 'Active' : 'Inactive'} + + ), + }, + { + key: 'createdAt', + title: 'Created', + fields: [{ + key: 'createdAt', + transform: (v) => new Date(v as string).toLocaleDateString('en-GB', { + day: '2-digit', month: 'short', year: 'numeric', + }), + }], + width: 140, + }, + ], + rowActions: { + actions: () => [ + { + action: 'edit', + label: 'Edit unit of measure', + onClick: openEdit, + }, + ], + }, + }), [openEdit]) + + return ( + <> +
+
+

Units of Measure

+

+ Units of measure and conversion factors +

+
+ +
+ + + + setSheetOpen(false)} + uom={editing} + onSuccess={() => fetchData()} + /> + + ) +} diff --git a/distribution_fe/app/(dashboard)/catalogue/uoms/page.tsx b/distribution_fe/app/(dashboard)/catalogue/uoms/page.tsx new file mode 100644 index 0000000..6d3e9ef --- /dev/null +++ b/distribution_fe/app/(dashboard)/catalogue/uoms/page.tsx @@ -0,0 +1,5 @@ +import { UomsClient } from './_components/uoms-client' + +export default function UomsPage() { + return +} diff --git a/distribution_fe/app/(dashboard)/drivers/_components/driver-sheet.tsx b/distribution_fe/app/(dashboard)/drivers/_components/driver-sheet.tsx new file mode 100644 index 0000000..bbb5129 --- /dev/null +++ b/distribution_fe/app/(dashboard)/drivers/_components/driver-sheet.tsx @@ -0,0 +1,221 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useForm, zodResolver, z } from '@/lib/form' +import { FormField } from '@/components/shared/form-field' +import { Input } from '@/components/ui/input' +import { Checkbox } from '@/components/ui/checkbox' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { FieldGroup } from '@/components/ui/field' +import { toast } from '@/components/ui/toast' +import { drivers as driversApi, users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { DriverProfileResponse, UserResponse } from '@/types/api' + +const schema = z.object({ + userId: z.string().min(1, 'User is required'), + licenseNumber: z.string().min(1, 'License number is required'), + licenseExpiry: z.string().min(1, 'License expiry is required'), + medicalClearanceExpiry: z.string().min(1, 'Medical clearance expiry is required'), + isActive: z.boolean(), +}) + +type FormValues = z.infer + +interface DriverSheetProps { + open: boolean + onClose: () => void + driver: DriverProfileResponse | null + onSuccess: () => void +} + +export function DriverSheet({ open, onClose, driver, onSuccess }: DriverSheetProps) { + const isEdit = !!driver + const [usersList, setUsersList] = useState([]) + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + userId: '', + licenseNumber: '', + licenseExpiry: '', + medicalClearanceExpiry: '', + isActive: true, + }, + }) + + useEffect(() => { + if (open) { + usersApi.list({ pageSize: 100 }).then((res) => setUsersList(getPaginatedItems(res))).catch((err) => + toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }) + ) + + form.reset( + driver + ? { + userId: driver.userId, + licenseNumber: driver.licenseNumber, + licenseExpiry: driver.licenseExpiry.split('T')[0], + medicalClearanceExpiry: driver.medicalClearanceExpiry.split('T')[0], + isActive: driver.isActive, + } + : { + userId: '', + licenseNumber: '', + licenseExpiry: '', + medicalClearanceExpiry: '', + isActive: true, + } + ) + } + }, [open, driver]) + + async function onSubmit(values: FormValues) { + try { + if (isEdit) { + await driversApi.update(driver!.id, values) + toast.add({ title: 'Driver updated', type: 'success' }) + } else { + await driversApi.create(values) + toast.add({ title: 'Driver created', type: 'success' }) + } + onSuccess() + onClose() + } catch (err) { + toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' }) + } + } + + return ( + !v && onClose()}> + + + {isEdit ? 'Edit Driver' : 'Add Driver'} + + {isEdit ? 'Update the driver profile below.' : 'Add a new driver profile to the system.'} + + + +
+ + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( +
+ + +
+ )} + /> +
+ + + + + +
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/drivers/_components/drivers-client.tsx b/distribution_fe/app/(dashboard)/drivers/_components/drivers-client.tsx new file mode 100644 index 0000000..3a1489d --- /dev/null +++ b/distribution_fe/app/(dashboard)/drivers/_components/drivers-client.tsx @@ -0,0 +1,177 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { Add01Icon } from '@hugeicons/core-free-icons' + +import { DataTable } from '@/components/shared/data-table' +import type { TableConfig } from '@/components/shared/data-table' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/toast' +import { drivers as driversApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { DriverProfileResponse } from '@/types/api' + +import { DriverSheet } from './driver-sheet' + +export function DriversClient() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + + const [sheetOpen, setSheetOpen] = useState(false) + const [editing, setEditing] = useState(null) + + const fetchData = useCallback(async (p: number, ps: number) => { + setLoading(true) + try { + const res = await driversApi.list({ page: p, pageSize: ps }) + setData(getPaginatedItems(res)) + setTotal(res.total) + } catch (err) { + toast.add({ title: 'Failed to load drivers', description: getApiError(err), type: 'error' }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData(page, pageSize) + }, [fetchData, page, pageSize]) + + function openCreate() { + setEditing(null) + setSheetOpen(true) + } + + function openEdit(driver: DriverProfileResponse) { + setEditing(driver) + setSheetOpen(true) + } + + async function toggleActive(driver: DriverProfileResponse) { + try { + await driversApi.update(driver.id, { isActive: !driver.isActive }) + toast.add({ + title: driver.isActive ? 'Driver deactivated' : 'Driver activated', + type: 'success', + }) + fetchData(page, pageSize) + } catch (err) { + toast.add({ title: 'Failed to update driver', description: getApiError(err), type: 'error' }) + } + } + + const tableConfig = useMemo>(() => ({ + rowKey: 'id', + serverSide: true, + stickyHeader: true, + emptyMessage: 'No driver profiles yet. Add one to get started.', + pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] }, + columns: [ + { + key: 'userFullName', + title: 'Driver', + fields: [{ key: 'userFullName', primary: true }], + minWidth: 180, + }, + { + key: 'licenseNumber', + title: 'License No.', + fields: [{ key: 'licenseNumber' }], + minWidth: 140, + }, + { + key: 'licenseExpiry', + title: 'License Expiry', + fields: [{ + key: 'licenseExpiry', + transform: (v) => new Date(v as string).toLocaleDateString('en-GB', { + day: '2-digit', month: 'short', year: 'numeric', + }), + }], + width: 150, + }, + { + key: 'medicalClearanceExpiry', + title: 'Medical Expiry', + fields: [{ + key: 'medicalClearanceExpiry', + transform: (v) => new Date(v as string).toLocaleDateString('en-GB', { + day: '2-digit', month: 'short', year: 'numeric', + }), + }], + width: 150, + }, + { + key: 'status', + title: 'Status', + fields: ['isActive'], + width: 120, + cellRender: (row) => ( + + {row.isActive ? 'Active' : 'Inactive'} + + ), + }, + ], + rowActions: { + actions: () => [ + { + action: 'edit', + label: 'Edit driver', + onClick: openEdit, + }, + { + action: 'deactivate', + label: 'Deactivate driver', + onClick: toggleActive, + visible: (target) => target.isActive, + }, + { + action: 'activate', + label: 'Activate driver', + onClick: toggleActive, + visible: (target) => !target.isActive, + }, + ], + }, + }), [pageSize, openEdit, toggleActive]) + + return ( + <> +
+
+

Driver Profiles

+

+ Driver records including license and medical clearance dates +

+
+ +
+ + { + setPage(p.page) + setPageSize(p.pageSize) + }} + /> + + setSheetOpen(false)} + driver={editing} + onSuccess={() => fetchData(page, pageSize)} + /> + + ) +} diff --git a/distribution_fe/app/(dashboard)/drivers/compliance/page.tsx b/distribution_fe/app/(dashboard)/drivers/compliance/page.tsx new file mode 100644 index 0000000..c40f5b2 --- /dev/null +++ b/distribution_fe/app/(dashboard)/drivers/compliance/page.tsx @@ -0,0 +1,73 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useForm, zodResolver, z } from '@/lib/form' +import { FormField } from '@/components/shared/form-field' +import { Checkbox } from '@/components/ui/checkbox' +import { Button } from '@/components/ui/button' +import { FieldGroup } from '@/components/ui/field' +import { toast } from '@/components/ui/toast' +import { compliance as complianceApi, getApiError } from '@/lib/api/index' + +const schema = z.object({ + enforceLicenseExpiry: z.boolean(), + enforceMedicalClearance: z.boolean(), +}) + +type FormValues = z.infer + +export default function CompliancePage() { + const [loading, setLoading] = useState(true) + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { enforceLicenseExpiry: false, enforceMedicalClearance: false }, + }) + + useEffect(() => { + complianceApi.get().then(cfg => { + form.reset({ enforceLicenseExpiry: cfg.enforceLicenseExpiry, enforceMedicalClearance: cfg.enforceMedicalClearance }) + }).catch(err => toast.add({ title: 'Failed to load config', description: getApiError(err), type: 'error' })) + .finally(() => setLoading(false)) + }, []) + + async function onSubmit(values: FormValues) { + try { + await complianceApi.update(values) + toast.add({ title: 'Compliance config saved', type: 'success' }) + } catch (err) { + toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' }) + } + } + + return ( +
+
+

Compliance

+

Rules that enforce license and medical clearance before a ride starts

+
+ {loading ?

Loading...

: ( +
+ + ( +
+ + +
+ )} /> + ( +
+ + +
+ )} /> +
+
+ +
+
+ )} +
+ ) +} diff --git a/distribution_fe/app/(dashboard)/drivers/page.tsx b/distribution_fe/app/(dashboard)/drivers/page.tsx new file mode 100644 index 0000000..0690d15 --- /dev/null +++ b/distribution_fe/app/(dashboard)/drivers/page.tsx @@ -0,0 +1,5 @@ +import { DriversClient } from './_components/drivers-client' + +export default function DriversPage() { + return +} diff --git a/distribution_fe/app/(dashboard)/network/regions/_components/region-sheet.tsx b/distribution_fe/app/(dashboard)/network/regions/_components/region-sheet.tsx new file mode 100644 index 0000000..d31ed67 --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/regions/_components/region-sheet.tsx @@ -0,0 +1,127 @@ +'use client' + +import { useEffect } from 'react' +import { useForm, zodResolver, z } from '@/lib/form' +import { FormField } from '@/components/shared/form-field' +import { Input } from '@/components/ui/input' +import { Checkbox } from '@/components/ui/checkbox' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { FieldGroup } from '@/components/ui/field' +import { toast } from '@/components/ui/toast' +import { regions as regionsApi, getApiError } from '@/lib/api/index' +import type { RegionResponse } from '@/types/api' + +const schema = z.object({ + name: z.string().min(1, 'Name is required').max(100, 'Name must be under 100 characters'), + isActive: z.boolean(), +}) + +type FormValues = z.infer + +interface RegionSheetProps { + open: boolean + onClose: () => void + region: RegionResponse | null + onSuccess: () => void +} + +export function RegionSheet({ open, onClose, region, onSuccess }: RegionSheetProps) { + const isEdit = !!region + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { name: '', isActive: true }, + }) + + useEffect(() => { + if (open) { + form.reset(region ? { name: region.name, isActive: region.isActive } : { name: '', isActive: true }) + } + }, [open, region]) + + async function onSubmit(values: FormValues) { + try { + if (isEdit) { + await regionsApi.update(region!.id, values) + toast.add({ title: 'Region updated', type: 'success' }) + } else { + await regionsApi.create(values) + toast.add({ title: 'Region created', type: 'success' }) + } + onSuccess() + onClose() + } catch (err) { + toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' }) + } + } + + return ( + !v && onClose()}> + + + {isEdit ? 'Edit Region' : 'Add Region'} + + {isEdit ? 'Update the region details below.' : 'Add a new geographic region to the distribution network.'} + + + +
+ + ( + + )} + /> + + ( +
+ + +
+ )} + /> +
+ + + + + +
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/network/regions/_components/regions-client.tsx b/distribution_fe/app/(dashboard)/network/regions/_components/regions-client.tsx new file mode 100644 index 0000000..3017e36 --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/regions/_components/regions-client.tsx @@ -0,0 +1,161 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { Add01Icon } from '@hugeicons/core-free-icons' + +import { DataTable } from '@/components/shared/data-table' +import type { TableConfig } from '@/components/shared/data-table' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/toast' +import { regions as regionsApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { RegionResponse } from '@/types/api' + +import { RegionSheet } from './region-sheet' + +export function RegionsClient() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + + const [sheetOpen, setSheetOpen] = useState(false) + const [editing, setEditing] = useState(null) + + const fetchData = useCallback(async (p: number, ps: number) => { + setLoading(true) + try { + const res = await regionsApi.list({ page: p, pageSize: ps }) + setData(getPaginatedItems(res)) + setTotal(Array.isArray(res) ? res.length : res.total) + } catch (err) { + toast.add({ title: 'Failed to load regions', description: getApiError(err), type: 'error' }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData(page, pageSize) + }, [fetchData, page, pageSize]) + + function openCreate() { + setEditing(null) + setSheetOpen(true) + } + + function openEdit(region: RegionResponse) { + setEditing(region) + setSheetOpen(true) + } + + async function toggleActive(region: RegionResponse) { + try { + await regionsApi.update(region.id, { isActive: !region.isActive }) + toast.add({ + title: region.isActive ? 'Region deactivated' : 'Region activated', + type: 'success', + }) + fetchData(page, pageSize) + } catch (err) { + toast.add({ title: 'Failed to update region', description: getApiError(err), type: 'error' }) + } + } + + const tableConfig = useMemo>(() => ({ + rowKey: 'id', + serverSide: true, + stickyHeader: true, + emptyMessage: 'No regions yet. Add one to get started.', + pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] }, + columns: [ + { + key: 'name', + title: 'Region', + fields: [{ key: 'name', primary: true }], + sortable: true, + minWidth: 200, + }, + { + key: 'status', + title: 'Status', + fields: ['isActive'], + width: 120, + cellRender: (row) => ( + + {row.isActive ? 'Active' : 'Inactive'} + + ), + }, + { + key: 'createdAt', + title: 'Created', + fields: [{ + key: 'createdAt', + transform: (v) => new Date(v as string).toLocaleDateString('en-GB', { + day: '2-digit', month: 'short', year: 'numeric', + }), + }], + width: 140, + }, + ], + rowActions: { + actions: () => [ + { + action: 'edit', + label: 'Edit region', + onClick: openEdit, + }, + { + action: 'deactivate', + label: 'Deactivate region', + onClick: toggleActive, + visible: (target) => target.isActive, + }, + { + action: 'activate', + label: 'Activate region', + onClick: toggleActive, + visible: (target) => !target.isActive, + }, + ], + }, + }), [pageSize, openEdit, toggleActive]) + + return ( + <> +
+
+

Regions

+

+ Geographic territories the distribution network operates in +

+
+ +
+ + { + setPage(p.page) + setPageSize(p.pageSize) + }} + /> + + setSheetOpen(false)} + region={editing} + onSuccess={() => fetchData(page, pageSize)} + /> + + ) +} diff --git a/distribution_fe/app/(dashboard)/network/regions/page.tsx b/distribution_fe/app/(dashboard)/network/regions/page.tsx new file mode 100644 index 0000000..698232a --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/regions/page.tsx @@ -0,0 +1,5 @@ +import { RegionsClient } from './_components/regions-client' + +export default function RegionsPage() { + return +} diff --git a/distribution_fe/app/(dashboard)/network/routes/_components/route-sheet.tsx b/distribution_fe/app/(dashboard)/network/routes/_components/route-sheet.tsx new file mode 100644 index 0000000..0c30c00 --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/routes/_components/route-sheet.tsx @@ -0,0 +1,234 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useForm, zodResolver, z } from '@/lib/form' +import { FormField } from '@/components/shared/form-field' +import { Input } from '@/components/ui/input' +import { Checkbox } from '@/components/ui/checkbox' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { FieldGroup } from '@/components/ui/field' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { toast } from '@/components/ui/toast' +import { routes as routesApi, regions as regionsApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { RouteResponse, RegionResponse } from '@/types/api' + +const schema = z.object({ + regionId: z.string().min(1, 'Region is required'), + routeName: z.string().min(1, 'Route name is required').max(100, 'Route name must be under 100 characters'), + startPoint: z.string().min(1, 'Start point is required').max(200), + endPoint: z.string().min(1, 'End point is required').max(200), + length: z.number().positive('Length must be greater than 0'), + isActive: z.boolean(), +}) + +type FormValues = z.infer + +interface RouteSheetProps { + open: boolean + onClose: () => void + route: RouteResponse | null + onSuccess: () => void +} + +export function RouteSheet({ open, onClose, route, onSuccess }: RouteSheetProps) { + const isEdit = !!route + const [regions, setRegions] = useState([]) + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { regionId: '', routeName: '', startPoint: '', endPoint: '', length: 0, isActive: true }, + }) + + useEffect(() => { + if (open) { + regionsApi.list({ page: 1, pageSize: 200 }) + .then((res) => setRegions(getPaginatedItems(res))) + .catch(() => toast.add({ title: 'Failed to load regions', type: 'error' })) + + form.reset(route + ? { + regionId: route.regionId, + routeName: route.routeName, + startPoint: route.startPoint, + endPoint: route.endPoint, + length: route.length, + isActive: route.isActive, + } + : { regionId: '', routeName: '', startPoint: '', endPoint: '', length: 0, isActive: true } + ) + } + }, [open, route]) + + async function onSubmit(values: FormValues) { + try { + if (isEdit) { + await routesApi.update(route!.id, values) + toast.add({ title: 'Route updated', type: 'success' }) + } else { + await routesApi.create(values) + toast.add({ title: 'Route created', type: 'success' }) + } + onSuccess() + onClose() + } catch (err) { + toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' }) + } + } + + return ( + !v && onClose()}> + + + {isEdit ? 'Edit Route' : 'Add Route'} + + {isEdit ? 'Update the route details below.' : 'Add a new delivery route to a region.'} + + + +
+ + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + field.onChange(parseFloat(e.target.value) || 0)} + onBlur={field.onBlur} + placeholder="e.g. 25" + min={0} + step="0.1" + aria-invalid={invalid} + /> + )} + /> + + ( +
+ + +
+ )} + /> +
+ + + + + +
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/network/routes/_components/routes-client.tsx b/distribution_fe/app/(dashboard)/network/routes/_components/routes-client.tsx new file mode 100644 index 0000000..942d587 --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/routes/_components/routes-client.tsx @@ -0,0 +1,178 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { Add01Icon } from '@hugeicons/core-free-icons' + +import { DataTable } from '@/components/shared/data-table' +import type { TableConfig } from '@/components/shared/data-table' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/toast' +import { routes as routesApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { RouteResponse } from '@/types/api' + +import { RouteSheet } from './route-sheet' + +export function RoutesClient() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + + const [sheetOpen, setSheetOpen] = useState(false) + const [editing, setEditing] = useState(null) + + const fetchData = useCallback(async (p: number, ps: number) => { + setLoading(true) + try { + const res = await routesApi.list({ page: p, pageSize: ps }) + setData(getPaginatedItems(res)) + setTotal(res.total) + } catch (err) { + toast.add({ title: 'Failed to load routes', description: getApiError(err), type: 'error' }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData(page, pageSize) + }, [fetchData, page, pageSize]) + + function openCreate() { + setEditing(null) + setSheetOpen(true) + } + + function openEdit(route: RouteResponse) { + setEditing(route) + setSheetOpen(true) + } + + async function toggleActive(route: RouteResponse) { + try { + await routesApi.update(route.id, { isActive: !route.isActive }) + toast.add({ + title: route.isActive ? 'Route deactivated' : 'Route activated', + type: 'success', + }) + fetchData(page, pageSize) + } catch (err) { + toast.add({ title: 'Failed to update route', description: getApiError(err), type: 'error' }) + } + } + + const tableConfig = useMemo>(() => ({ + rowKey: 'id', + serverSide: true, + stickyHeader: true, + emptyMessage: 'No routes yet. Add one to get started.', + pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] }, + columns: [ + { + key: 'routeName', + title: 'Route', + fields: [{ key: 'routeName', primary: true }], + sortable: true, + minWidth: 180, + }, + { + key: 'path', + title: 'Path', + fields: ['startPoint', 'endPoint'], + minWidth: 200, + cellRender: (row) => ( + + {row.startPoint} → {row.endPoint} + + ), + }, + { + key: 'length', + title: 'Length', + fields: [{ key: 'length', transform: (v) => `${v} km` }], + width: 100, + }, + { + key: 'status', + title: 'Status', + fields: ['isActive'], + width: 120, + cellRender: (row) => ( + + {row.isActive ? 'Active' : 'Inactive'} + + ), + }, + { + key: 'createdAt', + title: 'Created', + fields: [{ + key: 'createdAt', + transform: (v) => new Date(v as string).toLocaleDateString('en-GB', { + day: '2-digit', month: 'short', year: 'numeric', + }), + }], + width: 140, + }, + ], + rowActions: { + actions: () => [ + { + action: 'edit', + label: 'Edit route', + onClick: openEdit, + }, + { + action: 'deactivate', + label: 'Deactivate route', + onClick: toggleActive, + visible: (target) => target.isActive, + }, + { + action: 'activate', + label: 'Activate route', + onClick: toggleActive, + visible: (target) => !target.isActive, + }, + ], + }, + }), [pageSize, openEdit, toggleActive]) + + return ( + <> +
+
+

Routes

+

+ Delivery paths within a region assigned to rides +

+
+ +
+ + { + setPage(p.page) + setPageSize(p.pageSize) + }} + /> + + setSheetOpen(false)} + route={editing} + onSuccess={() => fetchData(page, pageSize)} + /> + + ) +} diff --git a/distribution_fe/app/(dashboard)/network/routes/page.tsx b/distribution_fe/app/(dashboard)/network/routes/page.tsx new file mode 100644 index 0000000..c1c6490 --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/routes/page.tsx @@ -0,0 +1,5 @@ +import { RoutesClient } from './_components/routes-client' + +export default function RoutesPage() { + return +} diff --git a/distribution_fe/app/(dashboard)/network/vehicles/_components/vehicle-sheet.tsx b/distribution_fe/app/(dashboard)/network/vehicles/_components/vehicle-sheet.tsx new file mode 100644 index 0000000..8fd4cb8 --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/vehicles/_components/vehicle-sheet.tsx @@ -0,0 +1,164 @@ +'use client' + +import { useEffect } from 'react' +import { useForm, zodResolver, z } from '@/lib/form' +import { FormField } from '@/components/shared/form-field' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { FieldGroup } from '@/components/ui/field' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { toast } from '@/components/ui/toast' +import { vehicles as vehiclesApi, getApiError } from '@/lib/api/index' +import type { VehicleResponse, VehicleType } from '@/types/api' + +const VEHICLE_TYPES: VehicleType[] = ['Car', 'Truck', 'Van', 'Motorcycle', 'Bicycle'] + +const schema = z.object({ + regNumber: z.string().min(1, 'Registration number is required').max(50), + numberPlate: z.string().min(1, 'Number plate is required').max(20), + type: z.enum(['Car', 'Truck', 'Van', 'Motorcycle', 'Bicycle']), +}) + +type FormValues = z.infer + +interface VehicleSheetProps { + open: boolean + onClose: () => void + vehicle: VehicleResponse | null + onSuccess: () => void +} + +export function VehicleSheet({ open, onClose, vehicle, onSuccess }: VehicleSheetProps) { + const isEdit = !!vehicle + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { regNumber: '', numberPlate: '', type: 'Car' }, + }) + + useEffect(() => { + if (open) { + form.reset(vehicle + ? { + regNumber: vehicle.regNumber, + numberPlate: vehicle.numberPlate, + type: vehicle.type, + } + : { regNumber: '', numberPlate: '', type: 'Car' } + ) + } + }, [open, vehicle]) + + async function onSubmit(values: FormValues) { + try { + if (isEdit) { + await vehiclesApi.update(vehicle!.id, values) + toast.add({ title: 'Vehicle updated', type: 'success' }) + } else { + await vehiclesApi.create(values) + toast.add({ title: 'Vehicle created', type: 'success' }) + } + onSuccess() + onClose() + } catch (err) { + toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' }) + } + } + + return ( + !v && onClose()}> + + + {isEdit ? 'Edit Vehicle' : 'Add Vehicle'} + + {isEdit ? 'Update the vehicle details below.' : 'Add a new vehicle to the fleet.'} + + + +
+ + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + + + + + +
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/network/vehicles/_components/vehicles-client.tsx b/distribution_fe/app/(dashboard)/network/vehicles/_components/vehicles-client.tsx new file mode 100644 index 0000000..5b838af --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/vehicles/_components/vehicles-client.tsx @@ -0,0 +1,170 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { Add01Icon } from '@hugeicons/core-free-icons' + +import { DataTable } from '@/components/shared/data-table' +import type { TableConfig } from '@/components/shared/data-table' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/toast' +import { vehicles as vehiclesApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { VehicleResponse } from '@/types/api' + +import { VehicleSheet } from './vehicle-sheet' + +export function VehiclesClient() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + + const [sheetOpen, setSheetOpen] = useState(false) + const [editing, setEditing] = useState(null) + + const fetchData = useCallback(async (p: number, ps: number) => { + setLoading(true) + try { + const res = await vehiclesApi.list({ page: p, pageSize: ps }) + setData(getPaginatedItems(res)) + setTotal(res.total) + } catch (err) { + toast.add({ title: 'Failed to load vehicles', description: getApiError(err), type: 'error' }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData(page, pageSize) + }, [fetchData, page, pageSize]) + + function openCreate() { + setEditing(null) + setSheetOpen(true) + } + + function openEdit(vehicle: VehicleResponse) { + setEditing(vehicle) + setSheetOpen(true) + } + + async function deactivate(vehicle: VehicleResponse) { + try { + await vehiclesApi.remove(vehicle.id) + toast.add({ + title: 'Vehicle deactivated', + type: 'success', + }) + fetchData(page, pageSize) + } catch (err) { + toast.add({ title: 'Failed to deactivate vehicle', description: getApiError(err), type: 'error' }) + } + } + + const tableConfig = useMemo>(() => ({ + rowKey: 'id', + serverSide: true, + stickyHeader: true, + emptyMessage: 'No vehicles yet. Add one to get started.', + pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] }, + columns: [ + { + key: 'regNumber', + title: 'Reg. Number', + fields: [{ key: 'regNumber', primary: true }], + sortable: true, + minWidth: 160, + }, + { + key: 'numberPlate', + title: 'Number Plate', + fields: [{ key: 'numberPlate' }], + minWidth: 140, + }, + { + key: 'type', + title: 'Type', + fields: ['type'], + width: 130, + cellRender: (row) => ( + {row.type} + ), + }, + { + key: 'status', + title: 'Status', + fields: ['isActive'], + width: 120, + cellRender: (row) => ( + + {row.isActive ? 'Active' : 'Inactive'} + + ), + }, + { + key: 'createdAt', + title: 'Created', + fields: [{ + key: 'createdAt', + transform: (v) => new Date(v as string).toLocaleDateString('en-GB', { + day: '2-digit', month: 'short', year: 'numeric', + }), + }], + width: 140, + }, + ], + rowActions: { + actions: (row) => [ + { + action: 'edit', + label: 'Edit vehicle', + onClick: openEdit, + }, + { + action: 'deactivate', + label: 'Deactivate vehicle', + onClick: deactivate, + visible: (target) => target.isActive, + }, + ], + }, + }), [pageSize, openEdit, deactivate]) + + return ( + <> +
+
+

Vehicles

+

+ Fleet vehicles available for delivery rides +

+
+ +
+ + { + setPage(p.page) + setPageSize(p.pageSize) + }} + /> + + setSheetOpen(false)} + vehicle={editing} + onSuccess={() => fetchData(page, pageSize)} + /> + + ) +} diff --git a/distribution_fe/app/(dashboard)/network/vehicles/page.tsx b/distribution_fe/app/(dashboard)/network/vehicles/page.tsx new file mode 100644 index 0000000..345101d --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/vehicles/page.tsx @@ -0,0 +1,5 @@ +import { VehiclesClient } from './_components/vehicles-client' + +export default function VehiclesPage() { + return +} diff --git a/distribution_fe/app/(dashboard)/network/warehouses/_components/warehouse-sheet.tsx b/distribution_fe/app/(dashboard)/network/warehouses/_components/warehouse-sheet.tsx new file mode 100644 index 0000000..f63a1e0 --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/warehouses/_components/warehouse-sheet.tsx @@ -0,0 +1,238 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useForm, zodResolver, z } from '@/lib/form' +import { FormField } from '@/components/shared/form-field' +import { Input } from '@/components/ui/input' +import { Checkbox } from '@/components/ui/checkbox' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { FieldGroup } from '@/components/ui/field' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { toast } from '@/components/ui/toast' +import { warehouses as warehousesApi, vehicles as vehiclesApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { WarehouseResponse, VehicleResponse, WarehouseType } from '@/types/api' + +const WAREHOUSE_TYPES: WarehouseType[] = [ + 'DistributionCenter', + 'FulfillmentCenter', + 'CrossDocking', + 'ColdStorage', + 'BondedWarehouse', + 'AutomatedWarehouse', +] + +const WAREHOUSE_TYPE_LABELS: Record = { + DistributionCenter: 'Distribution Center', + FulfillmentCenter: 'Fulfillment Center', + CrossDocking: 'Cross Docking', + ColdStorage: 'Cold Storage', + BondedWarehouse: 'Bonded Warehouse', + AutomatedWarehouse: 'Automated Warehouse', +} + +const schema = z.object({ + name: z.string().min(1, 'Name is required').max(100, 'Name must be under 100 characters'), + location: z.string().min(1, 'Location is required').max(200), + type: z.enum([ + 'DistributionCenter', + 'FulfillmentCenter', + 'CrossDocking', + 'ColdStorage', + 'BondedWarehouse', + 'AutomatedWarehouse', + ]), + vehicleId: z.string().nullable(), + isActive: z.boolean(), +}) + +type FormValues = z.infer + +interface WarehouseSheetProps { + open: boolean + onClose: () => void + warehouse: WarehouseResponse | null + onSuccess: () => void +} + +export function WarehouseSheet({ open, onClose, warehouse, onSuccess }: WarehouseSheetProps) { + const isEdit = !!warehouse + const [vehicles, setVehicles] = useState([]) + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { name: '', location: '', type: 'DistributionCenter', vehicleId: null, isActive: true }, + }) + + useEffect(() => { + if (open) { + vehiclesApi.list({ page: 1, pageSize: 200 }) + .then((res) => setVehicles(getPaginatedItems(res))) + .catch(() => toast.add({ title: 'Failed to load vehicles', type: 'error' })) + + form.reset(warehouse + ? { + name: warehouse.name, + location: warehouse.location, + type: warehouse.type, + vehicleId: warehouse.vehicleId, + isActive: warehouse.isActive, + } + : { name: '', location: '', type: 'DistributionCenter', vehicleId: null, isActive: true } + ) + } + }, [open, warehouse]) + + async function onSubmit(values: FormValues) { + try { + if (isEdit) { + await warehousesApi.update(warehouse!.id, values) + toast.add({ title: 'Warehouse updated', type: 'success' }) + } else { + await warehousesApi.create(values) + toast.add({ title: 'Warehouse created', type: 'success' }) + } + onSuccess() + onClose() + } catch (err) { + toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' }) + } + } + + return ( + !v && onClose()}> + + + {isEdit ? 'Edit Warehouse' : 'Add Warehouse'} + + {isEdit ? 'Update the warehouse details below.' : 'Add a new storage location to the network.'} + + + +
+ + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( +
+ + +
+ )} + /> +
+ + + + + +
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/network/warehouses/_components/warehouses-client.tsx b/distribution_fe/app/(dashboard)/network/warehouses/_components/warehouses-client.tsx new file mode 100644 index 0000000..f6e88a6 --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/warehouses/_components/warehouses-client.tsx @@ -0,0 +1,176 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { Add01Icon } from '@hugeicons/core-free-icons' + +import { DataTable } from '@/components/shared/data-table' +import type { TableConfig } from '@/components/shared/data-table' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/toast' +import { warehouses as warehousesApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { WarehouseResponse } from '@/types/api' + +import { WarehouseSheet } from './warehouse-sheet' + +export function WarehousesClient() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + + const [sheetOpen, setSheetOpen] = useState(false) + const [editing, setEditing] = useState(null) + + const fetchData = useCallback(async (p: number, ps: number) => { + setLoading(true) + try { + const res = await warehousesApi.list({ page: p, pageSize: ps }) + setData(getPaginatedItems(res)) + setTotal(res.total) + } catch (err) { + toast.add({ title: 'Failed to load warehouses', description: getApiError(err), type: 'error' }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData(page, pageSize) + }, [fetchData, page, pageSize]) + + function openCreate() { + setEditing(null) + setSheetOpen(true) + } + + function openEdit(warehouse: WarehouseResponse) { + setEditing(warehouse) + setSheetOpen(true) + } + + async function toggleActive(warehouse: WarehouseResponse) { + try { + await warehousesApi.update(warehouse.id, { isActive: !warehouse.isActive }) + toast.add({ + title: warehouse.isActive ? 'Warehouse deactivated' : 'Warehouse activated', + type: 'success', + }) + fetchData(page, pageSize) + } catch (err) { + toast.add({ title: 'Failed to update warehouse', description: getApiError(err), type: 'error' }) + } + } + + const tableConfig = useMemo>(() => ({ + rowKey: 'id', + serverSide: true, + stickyHeader: true, + emptyMessage: 'No warehouses yet. Add one to get started.', + pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] }, + columns: [ + { + key: 'name', + title: 'Warehouse', + fields: [{ key: 'name', primary: true }], + sortable: true, + minWidth: 200, + }, + { + key: 'location', + title: 'Location', + fields: [{ key: 'location' }], + minWidth: 180, + }, + { + key: 'type', + title: 'Type', + fields: ['type'], + minWidth: 180, + cellRender: (row) => ( + {row.type} + ), + }, + { + key: 'status', + title: 'Status', + fields: ['isActive'], + width: 120, + cellRender: (row) => ( + + {row.isActive ? 'Active' : 'Inactive'} + + ), + }, + { + key: 'createdAt', + title: 'Created', + fields: [{ + key: 'createdAt', + transform: (v) => new Date(v as string).toLocaleDateString('en-GB', { + day: '2-digit', month: 'short', year: 'numeric', + }), + }], + width: 140, + }, + ], + rowActions: { + actions: () => [ + { + action: 'edit', + label: 'Edit warehouse', + onClick: openEdit, + }, + { + action: 'deactivate', + label: 'Deactivate warehouse', + onClick: toggleActive, + visible: (target) => target.isActive, + }, + { + action: 'activate', + label: 'Activate warehouse', + onClick: toggleActive, + visible: (target) => !target.isActive, + }, + ], + }, + }), [pageSize, openEdit, toggleActive]) + + return ( + <> +
+
+

Warehouses

+

+ Storage locations where stock is held and loaded from +

+
+ +
+ + { + setPage(p.page) + setPageSize(p.pageSize) + }} + /> + + setSheetOpen(false)} + warehouse={editing} + onSuccess={() => fetchData(page, pageSize)} + /> + + ) +} diff --git a/distribution_fe/app/(dashboard)/network/warehouses/page.tsx b/distribution_fe/app/(dashboard)/network/warehouses/page.tsx new file mode 100644 index 0000000..3695fd7 --- /dev/null +++ b/distribution_fe/app/(dashboard)/network/warehouses/page.tsx @@ -0,0 +1,5 @@ +import { WarehousesClient } from './_components/warehouses-client' + +export default function WarehousesPage() { + return +} diff --git a/distribution_fe/app/(dashboard)/operations/deliveries/_components/delivery-tasks-client.tsx b/distribution_fe/app/(dashboard)/operations/deliveries/_components/delivery-tasks-client.tsx new file mode 100644 index 0000000..cbd4256 --- /dev/null +++ b/distribution_fe/app/(dashboard)/operations/deliveries/_components/delivery-tasks-client.tsx @@ -0,0 +1,172 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' + +import { DataTable } from '@/components/shared/data-table' +import type { TableConfig } from '@/components/shared/data-table' +import { Badge } from '@/components/ui/badge' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { toast } from '@/components/ui/toast' +import { deliveryTasks as deliveryTasksApi, rides as ridesApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { DeliveryTaskResponse, DeliveryTaskStatus, RideResponse } from '@/types/api' + +const fmtDate = (v: string) => + new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) + +const TASK_STATUS_VARIANT: Record = { + Pending: 'secondary', + Delivered: 'default', + PartiallyDelivered: 'secondary', + Failed: 'destructive', +} + +export function DeliveryTasksClient() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [rides, setRides] = useState([]) + const [selectedRideId, setSelectedRideId] = useState('') + + const selectedRide = useMemo( + () => rides.find((ride) => ride.id === selectedRideId) ?? null, + [rides, selectedRideId] + ) + + const fetchRides = useCallback(async () => { + try { + const res = await ridesApi.list({ page: 1, pageSize: 200, rideType: 'Delivery' }) + const items = getPaginatedItems(res) + setRides(items) + setSelectedRideId((current) => current || items[0]?.id || '') + } catch (err) { + toast.add({ title: 'Failed to load rides', description: getApiError(err), type: 'error' }) + } + }, []) + + const fetchData = useCallback(async (rideId: string, p: number, ps: number) => { + if (!rideId) { + setData([]) + setTotal(0) + setLoading(false) + return + } + + setLoading(true) + try { + const res = await deliveryTasksApi.listByRide(rideId, { page: p, pageSize: ps }) + setData(getPaginatedItems(res)) + setTotal(res.total) + } catch (err) { + toast.add({ title: 'Failed to load delivery tasks', description: getApiError(err), type: 'error' }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchRides() + }, [fetchRides]) + + useEffect(() => { + fetchData(selectedRideId, page, pageSize) + }, [fetchData, selectedRideId, page, pageSize]) + + const tableConfig = useMemo>(() => ({ + rowKey: 'id', + serverSide: true, + stickyHeader: true, + emptyMessage: selectedRideId ? 'No delivery tasks found for this ride.' : 'Select a ride to view its delivery tasks.', + pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] }, + columns: [ + { + key: 'storeName', + title: 'Store', + fields: [{ key: 'storeName', primary: true }], + minWidth: 200, + }, + { + key: 'sequence', + title: 'Sequence', + fields: ['sequence'], + width: 120, + cellRender: (row) => ( + {row.sequence} + ), + }, + { + key: 'status', + title: 'Status', + fields: ['status'], + width: 180, + cellRender: (row) => ( + {row.status} + ), + }, + { + key: 'createdAt', + title: 'Created', + fields: [{ key: 'createdAt', transform: (v) => fmtDate(v as string) }], + width: 140, + }, + ], + }), [pageSize]) + + return ( + <> +
+
+

Delivery Tasks

+

+ Individual store drop-offs within the selected ride +

+
+
+ + {selectedRide && ( +

+ Status: {selectedRide.status} | Driver: {selectedRide.driverFullName} +

+ )} +
+
+ + { + setPage(p.page) + setPageSize(p.pageSize) + }} + /> + + ) +} diff --git a/distribution_fe/app/(dashboard)/operations/deliveries/page.tsx b/distribution_fe/app/(dashboard)/operations/deliveries/page.tsx new file mode 100644 index 0000000..b2362b9 --- /dev/null +++ b/distribution_fe/app/(dashboard)/operations/deliveries/page.tsx @@ -0,0 +1,5 @@ +import { DeliveryTasksClient } from './_components/delivery-tasks-client' + +export default function DeliveryTasksPage() { + return +} diff --git a/distribution_fe/app/(dashboard)/operations/loading/_components/loading-sheets-client.tsx b/distribution_fe/app/(dashboard)/operations/loading/_components/loading-sheets-client.tsx new file mode 100644 index 0000000..ceb351c --- /dev/null +++ b/distribution_fe/app/(dashboard)/operations/loading/_components/loading-sheets-client.tsx @@ -0,0 +1,156 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' +import { DataTable } from '@/components/shared/data-table' +import type { TableConfig } from '@/components/shared/data-table' +import { Badge } from '@/components/ui/badge' +import { toast } from '@/components/ui/toast' +import { loadingSheets as loadingSheetsApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { LoadingSheetResponse, LoadingSheetStatus } from '@/types/api' + +const fmtDate = (v: string) => + new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) + +const truncId = (id: string) => id.substring(0, 8) + +const LS_STATUS_VARIANT: Record = { + Pending: 'secondary', + DispatchConfirmed: 'default', + ReceiptConfirmed: 'default', + DiscrepancyFlagged: 'destructive', + Resolved: 'secondary', +} + +export function LoadingSheetsClient() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + + const fetchData = useCallback(async (p: number, ps: number) => { + setLoading(true) + try { + const res = await loadingSheetsApi.list({ page: p, pageSize: ps }) + setData(getPaginatedItems(res)) + setTotal(res.total) + } catch (err) { + toast.add({ title: 'Failed to load loading sheets', description: getApiError(err), type: 'error' }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData(page, pageSize) + }, [fetchData, page, pageSize]) + + async function handleConfirmDispatch(row: LoadingSheetResponse) { + try { + await loadingSheetsApi.confirmDispatch(row.id) + toast.add({ title: 'Dispatch confirmed', type: 'success' }) + fetchData(page, pageSize) + } catch (err) { + toast.add({ title: 'Failed to confirm dispatch', description: getApiError(err), type: 'error' }) + } + } + + async function handleConfirmReceipt(row: LoadingSheetResponse) { + try { + await loadingSheetsApi.confirmReceipt(row.id, []) + toast.add({ title: 'Receipt confirmed', type: 'success' }) + fetchData(page, pageSize) + } catch (err) { + toast.add({ title: 'Failed to confirm receipt', description: getApiError(err), type: 'error' }) + } + } + + const tableConfig = useMemo>(() => ({ + rowKey: 'id', + serverSide: true, + stickyHeader: true, + emptyMessage: 'No loading sheets found.', + pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] }, + columns: [ + { + key: 'id', + title: 'Sheet ID', + fields: ['id'], + width: 140, + cellRender: (row) => ( + {truncId(row.id)} + ), + }, + { + key: 'warehouseName', + title: 'Warehouse', + fields: [{ key: 'warehouseName', primary: true }], + minWidth: 180, + }, + { + key: 'status', + title: 'Status', + fields: ['status'], + width: 180, + cellRender: (row) => ( + {row.status} + ), + }, + { + key: 'itemCount', + title: 'Items', + fields: ['items'], + width: 120, + cellRender: (row) => ( + {row.items.length} items + ), + }, + { + key: 'createdAt', + title: 'Created', + fields: [{ key: 'createdAt', transform: (v) => fmtDate(v as string) }], + width: 140, + }, + ], + rowActions: { + actions: () => [ + { + action: 'confirm', + label: 'Confirm dispatch', + onClick: handleConfirmDispatch, + visible: (target) => target.status === 'Pending', + }, + { + action: 'confirm', + label: 'Confirm receipt', + onClick: handleConfirmReceipt, + visible: (target) => target.status === 'DispatchConfirmed', + }, + ], + }, + }), [pageSize]) + + return ( + <> +
+
+

Loading Sheets

+

+ Track goods loaded onto a vehicle before dispatch +

+
+
+ + { + setPage(p.page) + setPageSize(p.pageSize) + }} + /> + + ) +} diff --git a/distribution_fe/app/(dashboard)/operations/loading/page.tsx b/distribution_fe/app/(dashboard)/operations/loading/page.tsx new file mode 100644 index 0000000..04f6f8b --- /dev/null +++ b/distribution_fe/app/(dashboard)/operations/loading/page.tsx @@ -0,0 +1,5 @@ +import { LoadingSheetsClient } from './_components/loading-sheets-client' + +export default function LoadingSheetsPage() { + return +} diff --git a/distribution_fe/app/(dashboard)/operations/rides/_components/ride-sheet.tsx b/distribution_fe/app/(dashboard)/operations/rides/_components/ride-sheet.tsx new file mode 100644 index 0000000..04ef51c --- /dev/null +++ b/distribution_fe/app/(dashboard)/operations/rides/_components/ride-sheet.tsx @@ -0,0 +1,266 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useForm, zodResolver, z } from '@/lib/form' +import { FormField } from '@/components/shared/form-field' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { FieldGroup } from '@/components/ui/field' +import { toast } from '@/components/ui/toast' +import { + rides as ridesApi, + vehicles as vehiclesApi, + drivers as driversApi, + routes as routesApi, + warehouses as warehousesApi, + getApiError, +} from '@/lib/api/index' +import type { VehicleResponse, DriverProfileResponse, RouteResponse, WarehouseResponse } from '@/types/api' + +const schema = z.object({ + rideType: z.enum(['Delivery', 'Sale']), + vehicleId: z.string().min(1, 'Vehicle is required'), + driverProfileId: z.string().min(1, 'Driver is required'), + routeId: z.string().min(1, 'Route is required'), + sourceWarehouseId: z.string().min(1, 'Warehouse is required'), + meterstart: z.number().min(0, 'Meter start must be 0 or more'), +}) + +type FormValues = z.infer + +interface RideSheetProps { + open: boolean + onClose: () => void + onSuccess: () => void +} + +function ensureItems(value: { items?: T[] } | null | undefined): T[] { + return Array.isArray(value?.items) ? value.items : [] +} + +export function RideSheet({ open, onClose, onSuccess }: RideSheetProps) { + const [vehiclesList, setVehiclesList] = useState([]) + const [driversList, setDriversList] = useState([]) + const [routesList, setRoutesList] = useState([]) + const [warehousesList, setWarehousesList] = useState([]) + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + rideType: 'Delivery', + vehicleId: '', + driverProfileId: '', + routeId: '', + sourceWarehouseId: '', + meterstart: 0, + }, + }) + + useEffect(() => { + if (!open) return + + form.reset({ + rideType: 'Delivery', + vehicleId: '', + driverProfileId: '', + routeId: '', + sourceWarehouseId: '', + meterstart: 0, + }) + + // available=true tells backend to exclude vehicles already on an open ride + Promise.all([ + vehiclesApi.list({ available: true, isActive: true, pageSize: 200 }), + driversApi.list({ pageSize: 200 }), + routesApi.list({ pageSize: 200 }), + warehousesApi.list({ pageSize: 200 }), + ]) + .then(([v, d, r, w]) => { + setVehiclesList(ensureItems(v)) + setDriversList(ensureItems(d)) + setRoutesList(ensureItems(r)) + setWarehousesList(ensureItems(w)) + }) + .catch((err) => { + toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }) + }) + }, [open]) + + async function onSubmit(values: FormValues) { + try { + await ridesApi.create(values) + toast.add({ title: 'Ride created', type: 'success' }) + onSuccess() + onClose() + } catch (err) { + toast.add({ title: 'Failed to create ride', description: getApiError(err), type: 'error' }) + } + } + + return ( + !v && onClose()}> + + + Create Ride + Start a new delivery or sales run. + + +
+ + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + field.onChange(Number(e.target.value))} + onBlur={field.onBlur} + placeholder="0" + aria-invalid={invalid} + /> + )} + /> + + + + + + +
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/operations/rides/_components/rides-client.tsx b/distribution_fe/app/(dashboard)/operations/rides/_components/rides-client.tsx new file mode 100644 index 0000000..b650852 --- /dev/null +++ b/distribution_fe/app/(dashboard)/operations/rides/_components/rides-client.tsx @@ -0,0 +1,188 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { Add01Icon } from '@hugeicons/core-free-icons' + +import { DataTable } from '@/components/shared/data-table' +import type { TableConfig } from '@/components/shared/data-table' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/toast' +import { rides as ridesApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { RideResponse, RideStatus, RideType } from '@/types/api' + +import { RideSheet } from './ride-sheet' + +const fmtDate = (v: string) => + new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) + +const RIDE_STATUS_VARIANT: Record = { + Draft: 'secondary', + Open: 'secondary', + Active: 'default', + CancelRequested: 'destructive', + Cancelled: 'destructive', + Closed: 'secondary', +} + +const RIDE_TYPE_VARIANT: Record = { + Delivery: 'default', + Sale: 'secondary', +} + +export function RidesClient() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + + const [sheetOpen, setSheetOpen] = useState(false) + + const fetchData = useCallback(async (p: number, ps: number) => { + setLoading(true) + try { + const res = await ridesApi.list({ page: p, pageSize: ps }) + setData(getPaginatedItems(res)) + setTotal(res.total) + } catch (err) { + toast.add({ title: 'Failed to load rides', description: getApiError(err), type: 'error' }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData(page, pageSize) + }, [fetchData, page, pageSize]) + + async function handleActivate(row: RideResponse) { + try { + await ridesApi.activate(row.id) + toast.add({ title: 'Ride activated', type: 'success' }) + fetchData(page, pageSize) + } catch (err) { + toast.add({ title: 'Failed to activate ride', description: getApiError(err), type: 'error' }) + } + } + + async function handleClose(row: RideResponse) { + const input = window.prompt('Enter meter end reading:') + if (input === null) return + const meterend = Number(input) + if (isNaN(meterend) || meterend < 0) { + toast.add({ title: 'Invalid meter reading', type: 'error' }) + return + } + try { + await ridesApi.close(row.id, meterend) + toast.add({ title: 'Ride closed', type: 'success' }) + fetchData(page, pageSize) + } catch (err) { + toast.add({ title: 'Failed to close ride', description: getApiError(err), type: 'error' }) + } + } + + const tableConfig = useMemo>(() => ({ + rowKey: 'id', + serverSide: true, + stickyHeader: true, + emptyMessage: 'No rides found.', + pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] }, + columns: [ + { + key: 'vehicle', + title: 'Vehicle / Driver', + fields: ['vehicleNumberPlate', 'driverFullName'], + minWidth: 200, + cellRender: (row) => ( +
+

{row.vehicleNumberPlate}

+

{row.driverFullName}

+
+ ), + }, + { + key: 'routeName', + title: 'Route', + fields: [{ key: 'routeName', primary: true }], + minWidth: 160, + }, + { + key: 'rideType', + title: 'Type', + fields: ['rideType'], + width: 120, + cellRender: (row) => ( + {row.rideType} + ), + }, + { + key: 'status', + title: 'Status', + fields: ['status'], + width: 160, + cellRender: (row) => ( + {row.status} + ), + }, + { + key: 'createdAt', + title: 'Created', + fields: [{ key: 'createdAt', transform: (v) => fmtDate(v as string) }], + width: 140, + }, + ], + rowActions: { + actions: () => [ + { + action: 'activate', + label: 'Activate ride', + onClick: handleActivate, + visible: (target) => target.status === 'Open', + }, + { + action: 'close', + label: 'Close ride', + onClick: handleClose, + visible: (target) => target.status === 'Active', + }, + ], + }, + }), [pageSize]) + + return ( + <> +
+
+

Rides

+

+ Delivery runs assigned to a driver and vehicle +

+
+ +
+ + { + setPage(p.page) + setPageSize(p.pageSize) + }} + /> + + setSheetOpen(false)} + onSuccess={() => fetchData(page, pageSize)} + /> + + ) +} diff --git a/distribution_fe/app/(dashboard)/operations/rides/page.tsx b/distribution_fe/app/(dashboard)/operations/rides/page.tsx new file mode 100644 index 0000000..af5376f --- /dev/null +++ b/distribution_fe/app/(dashboard)/operations/rides/page.tsx @@ -0,0 +1,5 @@ +import { RidesClient } from './_components/rides-client' + +export default function RidesPage() { + return +} diff --git a/distribution_fe/app/(dashboard)/pricing/price-lists/_components/price-list-sheet.tsx b/distribution_fe/app/(dashboard)/pricing/price-lists/_components/price-list-sheet.tsx new file mode 100644 index 0000000..9ec5f14 --- /dev/null +++ b/distribution_fe/app/(dashboard)/pricing/price-lists/_components/price-list-sheet.tsx @@ -0,0 +1,222 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useForm, zodResolver, z } from '@/lib/form' +import { FormField } from '@/components/shared/form-field' +import { Input } from '@/components/ui/input' +import { Checkbox } from '@/components/ui/checkbox' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { FieldGroup } from '@/components/ui/field' +import { toast } from '@/components/ui/toast' +import { priceLists as priceListsApi, regions as regionsApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { PriceListResponse, RegionResponse } from '@/types/api' + +const schema = z.object({ + name: z.string().min(1, 'Name is required'), + regionId: z.string().optional(), + effectiveFrom: z.string().min(1, 'Required'), + effectiveTo: z.string().min(1, 'Required'), + isActive: z.boolean(), +}) + +type FormValues = z.infer + +interface PriceListSheetProps { + open: boolean + onClose: () => void + priceList: PriceListResponse | null + onSuccess: () => void +} + +export function PriceListSheet({ open, onClose, priceList, onSuccess }: PriceListSheetProps) { + const isEdit = !!priceList + const [regionsList, setRegionsList] = useState([]) + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { name: '', regionId: '', effectiveFrom: '', effectiveTo: '', isActive: true }, + }) + + useEffect(() => { + if (open) { + form.reset( + priceList + ? { + name: priceList.name, + regionId: priceList.regionId ?? '', + effectiveFrom: priceList.effectiveFrom.split('T')[0], + effectiveTo: priceList.effectiveTo.split('T')[0], + isActive: priceList.isActive, + } + : { name: '', regionId: '', effectiveFrom: '', effectiveTo: '', isActive: true } + ) + } + }, [open, priceList]) + + useEffect(() => { + if (open) { + regionsApi.list({ pageSize: 100 }).then((res) => setRegionsList(getPaginatedItems(res))).catch(() => {}) + } + }, [open]) + + async function onSubmit(values: FormValues) { + try { + const body = { + name: values.name, + regionId: values.regionId || null, + effectiveFrom: values.effectiveFrom, + effectiveTo: values.effectiveTo, + isActive: values.isActive, + items: [], + } + if (isEdit) { + await priceListsApi.update(priceList!.id, body) + toast.add({ title: 'Price list updated', type: 'success' }) + } else { + await priceListsApi.create(body) + toast.add({ title: 'Price list created', type: 'success' }) + } + onSuccess() + onClose() + } catch (err) { + toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' }) + } + } + + return ( + !v && onClose()}> + + + {isEdit ? 'Edit Price List' : 'Add Price List'} + + {isEdit ? 'Update the price list details below.' : 'Create a new price list.'} + + + +
+ + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( +
+ + +
+ )} + /> +
+ + + + + +
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/pricing/price-lists/_components/price-lists-client.tsx b/distribution_fe/app/(dashboard)/pricing/price-lists/_components/price-lists-client.tsx new file mode 100644 index 0000000..b464396 --- /dev/null +++ b/distribution_fe/app/(dashboard)/pricing/price-lists/_components/price-lists-client.tsx @@ -0,0 +1,150 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { Add01Icon } from '@hugeicons/core-free-icons' + +import { DataTable } from '@/components/shared/data-table' +import type { TableConfig } from '@/components/shared/data-table' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/toast' +import { priceLists as priceListsApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { PriceListResponse } from '@/types/api' + +import { PriceListSheet } from './price-list-sheet' + +const fmtDate = (v: string) => + new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) + +export function PriceListsClient() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + + const [sheetOpen, setSheetOpen] = useState(false) + const [editing, setEditing] = useState(null) + + const fetchData = useCallback(async (p: number, ps: number) => { + setLoading(true) + try { + const res = await priceListsApi.list({ page: p, pageSize: ps }) + setData(getPaginatedItems(res)) + setTotal(res.total) + } catch (err) { + toast.add({ title: 'Failed to load price lists', description: getApiError(err), type: 'error' }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData(page, pageSize) + }, [fetchData, page, pageSize]) + + function openCreate() { + setEditing(null) + setSheetOpen(true) + } + + function openEdit(priceList: PriceListResponse) { + setEditing(priceList) + setSheetOpen(true) + } + + const tableConfig = useMemo>(() => ({ + rowKey: 'id', + serverSide: true, + stickyHeader: true, + emptyMessage: 'No price lists yet. Add one to get started.', + pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] }, + columns: [ + { + key: 'name', + title: 'Name', + fields: [{ key: 'name', primary: true }], + sortable: true, + minWidth: 200, + }, + { + key: 'regionName', + title: 'Region', + fields: ['regionName'], + width: 160, + cellRender: (row) => ( + + {row.regionName ?? 'All regions'} + + ), + }, + { + key: 'dateRange', + title: 'Effective Period', + fields: ['effectiveFrom', 'effectiveTo'], + width: 220, + cellRender: (row) => ( + + {fmtDate(row.effectiveFrom)} → {fmtDate(row.effectiveTo)} + + ), + }, + { + key: 'status', + title: 'Status', + fields: ['isActive'], + width: 120, + cellRender: (row) => ( + + {row.isActive ? 'Active' : 'Inactive'} + + ), + }, + ], + rowActions: { + actions: () => [ + { + action: 'edit', + label: 'Edit price list', + onClick: openEdit, + }, + ], + }, + }), [pageSize]) + + return ( + <> +
+
+

Price Lists

+

+ Item prices scoped by region and active date range +

+
+ +
+ + { + setPage(p.page) + setPageSize(p.pageSize) + }} + /> + + setSheetOpen(false)} + priceList={editing} + onSuccess={() => fetchData(page, pageSize)} + /> + + ) +} diff --git a/distribution_fe/app/(dashboard)/pricing/price-lists/page.tsx b/distribution_fe/app/(dashboard)/pricing/price-lists/page.tsx new file mode 100644 index 0000000..0932aef --- /dev/null +++ b/distribution_fe/app/(dashboard)/pricing/price-lists/page.tsx @@ -0,0 +1,5 @@ +import { PriceListsClient } from './_components/price-lists-client' + +export default function PriceListsPage() { + return +} diff --git a/distribution_fe/app/(dashboard)/pricing/schemes/_components/scheme-sheet.tsx b/distribution_fe/app/(dashboard)/pricing/schemes/_components/scheme-sheet.tsx new file mode 100644 index 0000000..0ddb345 --- /dev/null +++ b/distribution_fe/app/(dashboard)/pricing/schemes/_components/scheme-sheet.tsx @@ -0,0 +1,261 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useForm, zodResolver, z } from '@/lib/form' +import { FormField } from '@/components/shared/form-field' +import { Input } from '@/components/ui/input' +import { Checkbox } from '@/components/ui/checkbox' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { FieldGroup } from '@/components/ui/field' +import { toast } from '@/components/ui/toast' +import { schemes as schemesApi, regions as regionsApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { SchemeResponse, RegionResponse } from '@/types/api' + +const schema = z.object({ + name: z.string().min(1, 'Name is required'), + ruleType: z.enum(['BuyXGetY', 'SlabDiscount', 'FlatPercent', 'FreeBundle']), + regionId: z.string().optional(), + dateRangeFrom: z.string().min(1, 'Required'), + dateRangeTo: z.string().min(1, 'Required'), + isActive: z.boolean(), +}) + +type FormValues = z.infer + +interface SchemeSheetProps { + open: boolean + onClose: () => void + scheme: SchemeResponse | null + onSuccess: () => void +} + +export function SchemeSheet({ open, onClose, scheme, onSuccess }: SchemeSheetProps) { + const isEdit = !!scheme + const [regionsList, setRegionsList] = useState([]) + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: '', + ruleType: 'FlatPercent', + regionId: '', + dateRangeFrom: '', + dateRangeTo: '', + isActive: true, + }, + }) + + useEffect(() => { + if (open) { + form.reset( + scheme + ? { + name: scheme.name, + ruleType: scheme.ruleType, + regionId: scheme.regionId ?? '', + dateRangeFrom: scheme.dateRangeFrom.split('T')[0], + dateRangeTo: scheme.dateRangeTo.split('T')[0], + isActive: scheme.isActive, + } + : { + name: '', + ruleType: 'FlatPercent', + regionId: '', + dateRangeFrom: '', + dateRangeTo: '', + isActive: true, + } + ) + } + }, [open, scheme]) + + useEffect(() => { + if (open) { + regionsApi.list({ pageSize: 100 }).then((res) => setRegionsList(getPaginatedItems(res))).catch(() => {}) + } + }, [open]) + + async function onSubmit(values: FormValues) { + try { + const body = { + name: values.name, + ruleType: values.ruleType, + regionId: values.regionId || null, + dateRangeFrom: values.dateRangeFrom, + dateRangeTo: values.dateRangeTo, + isActive: values.isActive, + } + if (isEdit) { + await schemesApi.update(scheme!.id, body) + toast.add({ title: 'Scheme updated', type: 'success' }) + } else { + await schemesApi.create(body) + toast.add({ title: 'Scheme created', type: 'success' }) + } + onSuccess() + onClose() + } catch (err) { + toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' }) + } + } + + return ( + !v && onClose()}> + + + {isEdit ? 'Edit Scheme' : 'Add Scheme'} + + {isEdit ? 'Update the scheme details below.' : 'Create a new promotion or discount scheme.'} + + + +
+ + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( +
+ + +
+ )} + /> +
+ + + + + +
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/pricing/schemes/_components/schemes-client.tsx b/distribution_fe/app/(dashboard)/pricing/schemes/_components/schemes-client.tsx new file mode 100644 index 0000000..01a4b48 --- /dev/null +++ b/distribution_fe/app/(dashboard)/pricing/schemes/_components/schemes-client.tsx @@ -0,0 +1,164 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { Add01Icon } from '@hugeicons/core-free-icons' + +import { DataTable } from '@/components/shared/data-table' +import type { TableConfig } from '@/components/shared/data-table' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/toast' +import { schemes as schemesApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { SchemeResponse, SchemeRuleType } from '@/types/api' + +import { SchemeSheet } from './scheme-sheet' + +const fmtDate = (v: string) => + new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) + +const RULE_TYPE_VARIANT: Record = { + BuyXGetY: 'default', + SlabDiscount: 'secondary', + FlatPercent: 'secondary', + FreeBundle: 'default', +} + +export function SchemesClient() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + + const [sheetOpen, setSheetOpen] = useState(false) + const [editing, setEditing] = useState(null) + + const fetchData = useCallback(async (p: number, ps: number) => { + setLoading(true) + try { + const res = await schemesApi.list({ page: p, pageSize: ps }) + setData(getPaginatedItems(res)) + setTotal(res.total) + } catch (err) { + toast.add({ title: 'Failed to load schemes', description: getApiError(err), type: 'error' }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData(page, pageSize) + }, [fetchData, page, pageSize]) + + function openCreate() { + setEditing(null) + setSheetOpen(true) + } + + function openEdit(scheme: SchemeResponse) { + setEditing(scheme) + setSheetOpen(true) + } + + const tableConfig = useMemo>(() => ({ + rowKey: 'id', + serverSide: true, + stickyHeader: true, + emptyMessage: 'No schemes yet. Add one to get started.', + pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] }, + columns: [ + { + key: 'name', + title: 'Name', + fields: [{ key: 'name', primary: true }], + sortable: true, + minWidth: 200, + }, + { + key: 'ruleType', + title: 'Rule Type', + fields: ['ruleType'], + width: 160, + cellRender: (row) => ( + {row.ruleType} + ), + }, + { + key: 'regionName', + title: 'Region', + fields: ['regionName'], + width: 140, + cellRender: (row) => ( + {row.regionName ?? 'All'} + ), + }, + { + key: 'dateRange', + title: 'Date Range', + fields: ['dateRangeFrom', 'dateRangeTo'], + width: 220, + cellRender: (row) => ( + + {fmtDate(row.dateRangeFrom)} → {fmtDate(row.dateRangeTo)} + + ), + }, + { + key: 'status', + title: 'Status', + fields: ['isActive'], + width: 120, + cellRender: (row) => ( + + {row.isActive ? 'Active' : 'Inactive'} + + ), + }, + ], + rowActions: { + actions: () => [ + { + action: 'edit', + label: 'Edit scheme', + onClick: openEdit, + }, + ], + }, + }), [pageSize]) + + return ( + <> +
+
+

Schemes

+

+ Promotions, bundle deals, and slab discounts applied at sale +

+
+ +
+ + { + setPage(p.page) + setPageSize(p.pageSize) + }} + /> + + setSheetOpen(false)} + scheme={editing} + onSuccess={() => fetchData(page, pageSize)} + /> + + ) +} diff --git a/distribution_fe/app/(dashboard)/pricing/schemes/page.tsx b/distribution_fe/app/(dashboard)/pricing/schemes/page.tsx new file mode 100644 index 0000000..e1b7444 --- /dev/null +++ b/distribution_fe/app/(dashboard)/pricing/schemes/page.tsx @@ -0,0 +1,5 @@ +import { SchemesClient } from './_components/schemes-client' + +export default function SchemesPage() { + return +} diff --git a/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-client.tsx b/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-client.tsx new file mode 100644 index 0000000..821ee32 --- /dev/null +++ b/distribution_fe/app/(dashboard)/procurement/grn/_components/grn-client.tsx @@ -0,0 +1,118 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' + +import { DataTable } from '@/components/shared/data-table' +import type { TableConfig } from '@/components/shared/data-table' +import { toast } from '@/components/ui/toast' +import { grn as grnApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { GrnResponse } from '@/types/api' + +const fmtDate = (v: string) => + new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) + +const truncId = (id: string) => id.substring(0, 8) + +export function GrnClient() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + + const fetchData = useCallback(async (p: number, ps: number) => { + setLoading(true) + try { + const res = await grnApi.list({ page: p, pageSize: ps }) + setData(getPaginatedItems(res)) + setTotal(res.total) + } catch (err) { + toast.add({ title: 'Failed to load goods receipts', description: getApiError(err), type: 'error' }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData(page, pageSize) + }, [fetchData, page, pageSize]) + + const tableConfig = useMemo>(() => ({ + rowKey: 'id', + serverSide: true, + stickyHeader: true, + emptyMessage: 'No goods receipts found.', + pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] }, + columns: [ + { + key: 'id', + title: 'GRN ID', + fields: ['id'], + width: 140, + cellRender: (row) => ( + {truncId(row.id)} + ), + }, + { + key: 'purchaseOrderId', + title: 'PO ID', + fields: ['purchaseOrderId'], + width: 140, + cellRender: (row) => ( + {truncId(row.purchaseOrderId)} + ), + }, + { + key: 'warehouseName', + title: 'Warehouse', + fields: [{ key: 'warehouseName', primary: true }], + minWidth: 180, + }, + { + key: 'itemCount', + title: 'Items', + fields: ['items'], + width: 120, + cellRender: (row) => ( + {row.items.length} items + ), + }, + { + key: 'receivedAt', + title: 'Received At', + fields: [{ key: 'receivedAt', transform: (v) => fmtDate(v as string) }], + width: 140, + }, + { + key: 'createdAt', + title: 'Created', + fields: [{ key: 'createdAt', transform: (v) => fmtDate(v as string) }], + width: 140, + }, + ], + }), [pageSize]) + + return ( + <> +
+
+

Goods Receipts

+

+ Stock received into a warehouse against a purchase order +

+
+
+ + { + setPage(p.page) + setPageSize(p.pageSize) + }} + /> + + ) +} diff --git a/distribution_fe/app/(dashboard)/procurement/grn/page.tsx b/distribution_fe/app/(dashboard)/procurement/grn/page.tsx new file mode 100644 index 0000000..44917d0 --- /dev/null +++ b/distribution_fe/app/(dashboard)/procurement/grn/page.tsx @@ -0,0 +1,5 @@ +import { GrnClient } from './_components/grn-client' + +export default function GrnPage() { + return +} diff --git a/distribution_fe/app/(dashboard)/procurement/orders/_components/purchase-orders-client.tsx b/distribution_fe/app/(dashboard)/procurement/orders/_components/purchase-orders-client.tsx new file mode 100644 index 0000000..26ad5a9 --- /dev/null +++ b/distribution_fe/app/(dashboard)/procurement/orders/_components/purchase-orders-client.tsx @@ -0,0 +1,147 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' +import { DataTable } from '@/components/shared/data-table' +import type { TableConfig } from '@/components/shared/data-table' +import { Badge } from '@/components/ui/badge' +import { toast } from '@/components/ui/toast' +import { purchaseOrders as purchaseOrdersApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { PurchaseOrderResponse, PurchaseOrderStatus } from '@/types/api' + +const fmtDate = (v: string) => + new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) + +const truncId = (id: string) => id.substring(0, 8) + +const PO_STATUS_VARIANT: Record = { + Issued: 'default', + PartiallyReceived: 'secondary', + Received: 'secondary', + Closed: 'secondary', + Cancelled: 'destructive', +} + +export function PurchaseOrdersClient() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + + const fetchData = useCallback(async (p: number, ps: number) => { + setLoading(true) + try { + const res = await purchaseOrdersApi.list({ page: p, pageSize: ps }) + setData(getPaginatedItems(res)) + setTotal(res.total) + } catch (err) { + toast.add({ title: 'Failed to load purchase orders', description: getApiError(err), type: 'error' }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData(page, pageSize) + }, [fetchData, page, pageSize]) + + async function handleCancel(row: PurchaseOrderResponse) { + const reason = window.prompt('Enter cancellation reason:') + if (reason === null) return + if (!reason.trim()) { + toast.add({ title: 'Cancellation reason is required', type: 'error' }) + return + } + try { + await purchaseOrdersApi.cancel(row.id, reason.trim()) + toast.add({ title: 'Purchase order cancelled', type: 'success' }) + fetchData(page, pageSize) + } catch (err) { + toast.add({ title: 'Failed to cancel order', description: getApiError(err), type: 'error' }) + } + } + + const tableConfig = useMemo>(() => ({ + rowKey: 'id', + serverSide: true, + stickyHeader: true, + emptyMessage: 'No purchase orders found.', + pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] }, + columns: [ + { + key: 'id', + title: 'Order ID', + fields: ['id'], + width: 140, + cellRender: (row) => ( + {truncId(row.id)} + ), + }, + { + key: 'supplierName', + title: 'Supplier', + fields: [{ key: 'supplierName', primary: true }], + minWidth: 180, + }, + { + key: 'itemCount', + title: 'Items', + fields: ['items'], + width: 120, + cellRender: (row) => ( + {row.items.length} items + ), + }, + { + key: 'status', + title: 'Status', + fields: ['status'], + width: 160, + cellRender: (row) => ( + {row.status} + ), + }, + { + key: 'createdAt', + title: 'Created', + fields: [{ key: 'createdAt', transform: (v) => fmtDate(v as string) }], + width: 140, + }, + ], + rowActions: { + actions: () => [ + { + action: 'cancel', + label: 'Cancel order', + tone: 'destructive', + onClick: handleCancel, + visible: (target) => target.status === 'Issued', + }, + ], + }, + }), [pageSize]) + + return ( + <> +
+
+

Purchase Orders

+

+ Orders placed with suppliers to restock inventory +

+
+
+ + { + setPage(p.page) + setPageSize(p.pageSize) + }} + /> + + ) +} diff --git a/distribution_fe/app/(dashboard)/procurement/orders/page.tsx b/distribution_fe/app/(dashboard)/procurement/orders/page.tsx new file mode 100644 index 0000000..f50bdf2 --- /dev/null +++ b/distribution_fe/app/(dashboard)/procurement/orders/page.tsx @@ -0,0 +1,5 @@ +import { PurchaseOrdersClient } from './_components/purchase-orders-client' + +export default function PurchaseOrdersPage() { + return +} diff --git a/distribution_fe/app/(dashboard)/sales/_components/sale-sheet.tsx b/distribution_fe/app/(dashboard)/sales/_components/sale-sheet.tsx new file mode 100644 index 0000000..5451b75 --- /dev/null +++ b/distribution_fe/app/(dashboard)/sales/_components/sale-sheet.tsx @@ -0,0 +1,356 @@ +'use client' + +import { useEffect, useMemo, useState } from 'react' +import { useFieldArray } from 'react-hook-form' +import { useForm, zodResolver, z } from '@/lib/form' +import { FormField } from '@/components/shared/form-field' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Checkbox } from '@/components/ui/checkbox' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { FieldGroup } from '@/components/ui/field' +import { toast } from '@/components/ui/toast' +import { + sales as salesApi, + stores as storesApi, + rides as ridesApi, + items as itemsApi, + getApiError, +} from '@/lib/api/index' +import type { ItemSummaryResponse, RideResponse, StoreResponse } from '@/types/api' + +const saleItemSchema = z.object({ + itemId: z.string().min(1, 'Item is required'), + quantity: z.number().min(1, 'Quantity must be at least 1'), + unitPrice: z.number().min(0.01, 'Unit price must be greater than 0'), +}) + +const schema = z.object({ + storeId: z.string().min(1, 'Store is required'), + rideId: z.string(), + fulfillImmediately: z.boolean(), + items: z.array(saleItemSchema).min(1, 'At least one item is required'), +}) + +type FormValues = z.infer + +interface SaleSheetProps { + open: boolean + onClose: () => void + onSuccess: () => void +} + +function ensureItems(value: { items?: T[] } | null | undefined): T[] { + return Array.isArray(value?.items) ? value.items : [] +} + +function createDefaultItem() { + return { + itemId: '', + quantity: 1, + unitPrice: 0, + } +} + +export function SaleSheet({ open, onClose, onSuccess }: SaleSheetProps) { + const [storesList, setStoresList] = useState([]) + const [ridesList, setRidesList] = useState([]) + const [itemsList, setItemsList] = useState([]) + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + storeId: '', + rideId: '', + fulfillImmediately: false, + items: [createDefaultItem()], + }, + }) + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: 'items', + }) + + const watchedItems = form.watch('items') + const itemsById = useMemo( + () => new Map(itemsList.map((item) => [item.id, item])), + [itemsList] + ) + const totalAmount = useMemo( + () => watchedItems.reduce((sum, item) => sum + (Number(item.quantity) || 0) * (Number(item.unitPrice) || 0), 0), + [watchedItems] + ) + + useEffect(() => { + if (!open) return + + form.reset({ + storeId: '', + rideId: '', + fulfillImmediately: false, + items: [createDefaultItem()], + }) + + Promise.all([ + storesApi.list({ pageSize: 200 }), + ridesApi.list({ pageSize: 200, rideType: 'Sale' }), + itemsApi.list({ pageSize: 200 }), + ]) + .then(([stores, rides, items]) => { + setStoresList(ensureItems(stores)) + setRidesList(ensureItems(rides)) + setItemsList(ensureItems(items)) + }) + .catch((err) => { + toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }) + }) + }, [open]) + + async function onSubmit(values: FormValues) { + try { + await salesApi.create({ + storeId: values.storeId, + rideId: values.rideId || null, + fulfillImmediately: values.fulfillImmediately, + items: values.items, + }) + toast.add({ title: 'Sale created', type: 'success' }) + onSuccess() + onClose() + } catch (err) { + toast.add({ title: 'Failed to create sale', description: getApiError(err), type: 'error' }) + } + } + + return ( + !v && onClose()}> + + + Create Sale + Record a sale for a store with one or more items. + + +
+
+ +
+ ( + + )} + /> + + ( + + )} + /> +
+ + ( + + )} + /> + +
+
+
+

Sale Summary

+

+ Quantity and unit price are handled in the selected item's base UOM. +

+
+
+

Total

+

Rs. {totalAmount.toLocaleString()}

+
+
+
+
+ +
+
+
+

Items

+

+ Only this panel scrolls when multiple items are added. +

+
+ +
+ +
+ {fields.map((fieldItem, index) => { + const selectedItem = itemsById.get(watchedItems[index]?.itemId ?? '') + + return ( +
+ ( + + )} + /> + +
+ ( + field.onChange(Number(e.target.value))} + onBlur={field.onBlur} + placeholder="1" + aria-invalid={invalid} + /> + )} + /> + + ( + field.onChange(Number(e.target.value))} + onBlur={field.onBlur} + placeholder="0.00" + aria-invalid={invalid} + /> + )} + /> +
+ +

+ {selectedItem + ? `Quantity is in ${selectedItem.baseUomName}. Unit price is for one ${selectedItem.baseUomName.toLowerCase()}.` + : 'Quantity uses the selected item base UOM. Unit price is for one base-UOM unit.'} +

+ + {fields.length > 1 && ( +
+ +
+ )} +
+ ) + })} +
+
+
+ + + + + +
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/sales/_components/sales-client.tsx b/distribution_fe/app/(dashboard)/sales/_components/sales-client.tsx new file mode 100644 index 0000000..370cf08 --- /dev/null +++ b/distribution_fe/app/(dashboard)/sales/_components/sales-client.tsx @@ -0,0 +1,160 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { Add01Icon } from '@hugeicons/core-free-icons' + +import { DataTable } from '@/components/shared/data-table' +import type { TableConfig } from '@/components/shared/data-table' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/toast' +import { sales as salesApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { SaleResponse, SaleStatus, PaymentStatus } from '@/types/api' + +import { SaleSheet } from './sale-sheet' + +const fmtDate = (v: string) => + new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) + +const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}` +const truncId = (id: string) => id.substring(0, 8) + +const SALE_STATUS_VARIANT: Record = { + Pending: 'secondary', + Completed: 'default', + Cancelled: 'destructive', +} + +const PAYMENT_STATUS_VARIANT: Record = { + Pending: 'secondary', + PartiallyPaid: 'secondary', + Paid: 'default', +} + +export function SalesClient() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [sheetOpen, setSheetOpen] = useState(false) + + const fetchData = useCallback(async (p: number, ps: number) => { + setLoading(true) + try { + const res = await salesApi.list({ page: p, pageSize: ps }) + setData(getPaginatedItems(res)) + setTotal(res.total) + } catch (err) { + toast.add({ title: 'Failed to load sales', description: getApiError(err), type: 'error' }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData(page, pageSize) + }, [fetchData, page, pageSize]) + + const tableConfig = useMemo>(() => ({ + rowKey: 'id', + serverSide: true, + stickyHeader: true, + emptyMessage: 'No sales found.', + pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] }, + columns: [ + { + key: 'id', + title: 'Sale ID', + fields: ['id'], + width: 140, + cellRender: (row) => ( + {truncId(row.id)} + ), + }, + { + key: 'storeId', + title: 'Store ID', + fields: ['storeId'], + width: 140, + cellRender: (row) => ( + {truncId(row.storeId)} + ), + }, + { + key: 'totalAmount', + title: 'Total', + fields: ['totalAmount'], + width: 140, + cellRender: (row) => ( + {fmtAmount(row.totalAmount)} + ), + }, + { + key: 'status', + title: 'Status', + fields: ['status'], + width: 140, + cellRender: (row) => ( + {row.status} + ), + }, + { + key: 'paymentStatus', + title: 'Payment', + fields: ['paymentStatus'], + width: 160, + cellRender: (row) => ( + {row.paymentStatus} + ), + }, + { + key: 'saleDate', + title: 'Sale Date', + fields: [{ key: 'saleDate', transform: (v) => fmtDate(v as string) }], + width: 140, + }, + { + key: 'createdAt', + title: 'Created', + fields: [{ key: 'createdAt', transform: (v) => fmtDate(v as string) }], + width: 140, + }, + ], + }), [pageSize]) + + return ( + <> +
+
+

Sales

+

+ Sales transactions recorded against stores +

+
+ +
+ + { + setPage(p.page) + setPageSize(p.pageSize) + }} + /> + + setSheetOpen(false)} + onSuccess={() => fetchData(page, pageSize)} + /> + + ) +} diff --git a/distribution_fe/app/(dashboard)/sales/orders/_components/orders-client.tsx b/distribution_fe/app/(dashboard)/sales/orders/_components/orders-client.tsx new file mode 100644 index 0000000..0670c9f --- /dev/null +++ b/distribution_fe/app/(dashboard)/sales/orders/_components/orders-client.tsx @@ -0,0 +1,139 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' + +import { DataTable } from '@/components/shared/data-table' +import type { TableConfig } from '@/components/shared/data-table' +import { Badge } from '@/components/ui/badge' +import { toast } from '@/components/ui/toast' +import { orders as ordersApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { OrderResponse, OrderStatus } from '@/types/api' + +const fmtDate = (v: string) => + new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) + +const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}` +const truncId = (id: string) => id.substring(0, 8) + +const ORDER_STATUS_VARIANT: Record = { + Pending: 'secondary', + PartiallyFulfilled: 'secondary', + Fulfilled: 'default', + Cancelled: 'destructive', +} + +export function OrdersClient() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + + const fetchData = useCallback(async (p: number, ps: number) => { + setLoading(true) + try { + const res = await ordersApi.list({ page: p, pageSize: ps }) + setData(getPaginatedItems(res)) + setTotal(res.total) + } catch (err) { + toast.add({ title: 'Failed to load orders', description: getApiError(err), type: 'error' }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData(page, pageSize) + }, [fetchData, page, pageSize]) + + const tableConfig = useMemo>(() => ({ + rowKey: 'id', + serverSide: true, + stickyHeader: true, + emptyMessage: 'No orders found.', + pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] }, + columns: [ + { + key: 'id', + title: 'Order ID', + fields: ['id'], + width: 140, + cellRender: (row) => ( + {truncId(row.id)} + ), + }, + { + key: 'saleId', + title: 'Sale ID', + fields: ['saleId'], + width: 140, + cellRender: (row) => ( + {truncId(row.saleId)} + ), + }, + { + key: 'storeId', + title: 'Store ID', + fields: ['storeId'], + width: 140, + cellRender: (row) => ( + {truncId(row.storeId)} + ), + }, + { + key: 'totalAmount', + title: 'Total', + fields: ['totalAmount'], + width: 140, + cellRender: (row) => ( + {fmtAmount(row.totalAmount)} + ), + }, + { + key: 'status', + title: 'Status', + fields: ['status'], + width: 180, + cellRender: (row) => ( + {row.status} + ), + }, + { + key: 'orderDate', + title: 'Order Date', + fields: [{ key: 'orderDate', transform: (v) => fmtDate(v as string) }], + width: 140, + }, + { + key: 'createdAt', + title: 'Created', + fields: [{ key: 'createdAt', transform: (v) => fmtDate(v as string) }], + width: 140, + }, + ], + }), [pageSize]) + + return ( + <> +
+
+

Orders

+

+ Fulfillment status of store orders +

+
+
+ + { + setPage(p.page) + setPageSize(p.pageSize) + }} + /> + + ) +} diff --git a/distribution_fe/app/(dashboard)/sales/orders/page.tsx b/distribution_fe/app/(dashboard)/sales/orders/page.tsx new file mode 100644 index 0000000..ebaa59d --- /dev/null +++ b/distribution_fe/app/(dashboard)/sales/orders/page.tsx @@ -0,0 +1,5 @@ +import { OrdersClient } from './_components/orders-client' + +export default function OrdersPage() { + return +} diff --git a/distribution_fe/app/(dashboard)/sales/page.tsx b/distribution_fe/app/(dashboard)/sales/page.tsx new file mode 100644 index 0000000..81141c0 --- /dev/null +++ b/distribution_fe/app/(dashboard)/sales/page.tsx @@ -0,0 +1,5 @@ +import { SalesClient } from './_components/sales-client' + +export default function SalesPage() { + return +} diff --git a/distribution_fe/app/(dashboard)/sales/payments/_components/payment-sheet.tsx b/distribution_fe/app/(dashboard)/sales/payments/_components/payment-sheet.tsx new file mode 100644 index 0000000..cd23ffe --- /dev/null +++ b/distribution_fe/app/(dashboard)/sales/payments/_components/payment-sheet.tsx @@ -0,0 +1,200 @@ +'use client' + +import { useEffect } from 'react' +import { useForm, zodResolver, z } from '@/lib/form' +import { FormField } from '@/components/shared/form-field' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { FieldGroup } from '@/components/ui/field' +import { toast } from '@/components/ui/toast' +import { payments as paymentsApi, getApiError } from '@/lib/api/index' + +const schema = z.object({ + saleId: z.string().min(1, 'Sale ID is required'), + storeId: z.string().min(1, 'Store ID is required'), + amount: z.number().min(0.01, 'Amount must be greater than 0'), + method: z.enum(['Cash', 'Credit']), + collectedAt: z.string().min(1, 'Date is required'), +}) + +type FormValues = z.infer + +interface PaymentSheetProps { + open: boolean + onClose: () => void + onSuccess: () => void +} + +export function PaymentSheet({ open, onClose, onSuccess }: PaymentSheetProps) { + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + saleId: '', + storeId: '', + amount: 0, + method: 'Cash', + collectedAt: '', + }, + }) + + useEffect(() => { + if (open) { + form.reset({ + saleId: '', + storeId: '', + amount: 0, + method: 'Cash', + collectedAt: '', + }) + } + }, [open]) + + async function onSubmit(values: FormValues) { + try { + await paymentsApi.create(values) + toast.add({ title: 'Payment recorded', type: 'success' }) + onSuccess() + onClose() + } catch (err) { + toast.add({ title: 'Failed to record payment', description: getApiError(err), type: 'error' }) + } + } + + return ( + !v && onClose()}> + + + Record Payment + Record a cash or credit payment from a store. + + +
+ + ( + + )} + /> + + ( + + )} + /> + + ( + field.onChange(Number(e.target.value))} + onBlur={field.onBlur} + placeholder="0.00" + aria-invalid={invalid} + /> + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + + + + + +
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/sales/payments/_components/payments-client.tsx b/distribution_fe/app/(dashboard)/sales/payments/_components/payments-client.tsx new file mode 100644 index 0000000..debf358 --- /dev/null +++ b/distribution_fe/app/(dashboard)/sales/payments/_components/payments-client.tsx @@ -0,0 +1,148 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { Add01Icon } from '@hugeicons/core-free-icons' + +import { DataTable } from '@/components/shared/data-table' +import type { TableConfig } from '@/components/shared/data-table' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/toast' +import { payments as paymentsApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { PaymentResponse, PaymentMethod } from '@/types/api' + +import { PaymentSheet } from './payment-sheet' + +const fmtDate = (v: string) => + new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) + +const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}` +const truncId = (id: string) => id.substring(0, 8) + +const PAYMENT_METHOD_VARIANT: Record = { + Cash: 'default', + Credit: 'secondary', +} + +export function PaymentsClient() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + + const [sheetOpen, setSheetOpen] = useState(false) + + const fetchData = useCallback(async (p: number, ps: number) => { + setLoading(true) + try { + const res = await paymentsApi.list({ page: p, pageSize: ps }) + setData(getPaginatedItems(res)) + setTotal(res.total) + } catch (err) { + toast.add({ title: 'Failed to load payments', description: getApiError(err), type: 'error' }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData(page, pageSize) + }, [fetchData, page, pageSize]) + + const tableConfig = useMemo>(() => ({ + rowKey: 'id', + serverSide: true, + stickyHeader: true, + emptyMessage: 'No payments found.', + pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] }, + columns: [ + { + key: 'id', + title: 'Payment ID', + fields: ['id'], + width: 140, + cellRender: (row) => ( + {truncId(row.id)} + ), + }, + { + key: 'storeId', + title: 'Store ID', + fields: ['storeId'], + width: 140, + cellRender: (row) => ( + {truncId(row.storeId)} + ), + }, + { + key: 'amount', + title: 'Amount', + fields: ['amount'], + width: 140, + cellRender: (row) => ( + {fmtAmount(row.amount)} + ), + }, + { + key: 'method', + title: 'Method', + fields: ['method'], + width: 120, + cellRender: (row) => ( + {row.method} + ), + }, + { + key: 'collectedAt', + title: 'Collected At', + fields: [{ key: 'collectedAt', transform: (v) => fmtDate(v as string) }], + width: 140, + }, + { + key: 'collectedBy', + title: 'Collected By', + fields: ['collectedBy'], + minWidth: 160, + cellRender: (row) => ( + {row.collectedBy} + ), + }, + ], + }), [pageSize]) + + return ( + <> +
+
+

Payments

+

+ Cash and credit payments collected from stores +

+
+ +
+ + { + setPage(p.page) + setPageSize(p.pageSize) + }} + /> + + setSheetOpen(false)} + onSuccess={() => fetchData(page, pageSize)} + /> + + ) +} diff --git a/distribution_fe/app/(dashboard)/sales/payments/page.tsx b/distribution_fe/app/(dashboard)/sales/payments/page.tsx new file mode 100644 index 0000000..9c21151 --- /dev/null +++ b/distribution_fe/app/(dashboard)/sales/payments/page.tsx @@ -0,0 +1,5 @@ +import { PaymentsClient } from './_components/payments-client' + +export default function PaymentsPage() { + return +} diff --git a/distribution_fe/app/(dashboard)/stock/_components/stock-balances-client.tsx b/distribution_fe/app/(dashboard)/stock/_components/stock-balances-client.tsx new file mode 100644 index 0000000..10e696b --- /dev/null +++ b/distribution_fe/app/(dashboard)/stock/_components/stock-balances-client.tsx @@ -0,0 +1,116 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' + +import { DataTable } from '@/components/shared/data-table' +import type { TableConfig } from '@/components/shared/data-table' +import { Badge } from '@/components/ui/badge' +import { toast } from '@/components/ui/toast' +import { stock as stockApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { StockBalanceResponse, LocationType } from '@/types/api' + +const fmtDate = (v: string) => + new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) + +const LOCATION_TYPE_VARIANT: Record = { + Warehouse: 'default', + Vehicle: 'secondary', +} + +export function StockBalancesClient() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + + const fetchData = useCallback(async (p: number, ps: number) => { + setLoading(true) + try { + const res = await stockApi.balances({ page: p, pageSize: ps }) + setData(getPaginatedItems(res)) + setTotal(res.total) + } catch (err) { + toast.add({ title: 'Failed to load stock balances', description: getApiError(err), type: 'error' }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData(page, pageSize) + }, [fetchData, page, pageSize]) + + const tableConfig = useMemo>(() => ({ + rowKey: (row) => `${row.itemId}-${row.locationId}`, + serverSide: true, + stickyHeader: true, + emptyMessage: 'No stock balances found.', + pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] }, + columns: [ + { + key: 'itemName', + title: 'Item', + fields: [{ key: 'itemName', primary: true }], + minWidth: 200, + }, + { + key: 'locationId', + title: 'Location ID', + fields: ['locationId'], + width: 160, + cellRender: (row) => ( + {row.locationId.substring(0, 8)} + ), + }, + { + key: 'locationType', + title: 'Location Type', + fields: ['locationType'], + width: 160, + cellRender: (row) => ( + {row.locationType} + ), + }, + { + key: 'quantity', + title: 'Quantity', + fields: ['quantity'], + width: 120, + cellRender: (row) => ( + {row.quantity} + ), + }, + { + key: 'updatedAt', + title: 'Updated', + fields: [{ key: 'updatedAt', transform: (v) => fmtDate(v as string) }], + width: 140, + }, + ], + }), [pageSize]) + + return ( + <> +
+
+

Stock Balances

+

+ Current quantity of each item across warehouses and vehicles +

+
+
+ + { + setPage(p.page) + setPageSize(p.pageSize) + }} + /> + + ) +} diff --git a/distribution_fe/app/(dashboard)/stock/movements/_components/stock-movements-client.tsx b/distribution_fe/app/(dashboard)/stock/movements/_components/stock-movements-client.tsx new file mode 100644 index 0000000..73c4bcc --- /dev/null +++ b/distribution_fe/app/(dashboard)/stock/movements/_components/stock-movements-client.tsx @@ -0,0 +1,135 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' + +import { DataTable } from '@/components/shared/data-table' +import type { TableConfig } from '@/components/shared/data-table' +import { Badge } from '@/components/ui/badge' +import { toast } from '@/components/ui/toast' +import { stock as stockApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { StockMovementResponse, MovementType } from '@/types/api' + +const fmtDate = (v: string) => + new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) + +const MOVEMENT_TYPE_VARIANT: Record = { + Sale: 'default', + CustomerReturn: 'secondary', + Transfer: 'secondary', + Damage: 'destructive', + Adjustment: 'secondary', + Load: 'default', + PurchaseReceipt: 'default', + PurchaseReturn: 'secondary', +} + +export function StockMovementsClient() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + + const fetchData = useCallback(async (p: number, ps: number) => { + setLoading(true) + try { + const res = await stockApi.movements({ page: p, pageSize: ps }) + setData(getPaginatedItems(res)) + setTotal(res.total) + } catch (err) { + toast.add({ title: 'Failed to load stock movements', description: getApiError(err), type: 'error' }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData(page, pageSize) + }, [fetchData, page, pageSize]) + + const tableConfig = useMemo>(() => ({ + rowKey: 'id', + serverSide: true, + stickyHeader: true, + emptyMessage: 'No stock movements found.', + pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] }, + columns: [ + { + key: 'itemName', + title: 'Item', + fields: [{ key: 'itemName', primary: true }], + minWidth: 200, + }, + { + key: 'type', + title: 'Type', + fields: ['type'], + width: 160, + cellRender: (row) => ( + {row.type} + ), + }, + { + key: 'fromLocationType', + title: 'From', + fields: ['fromLocationType'], + width: 140, + cellRender: (row) => ( + + {row.fromLocationType ?? '—'} + + ), + }, + { + key: 'toLocationType', + title: 'To', + fields: ['toLocationType'], + width: 140, + cellRender: (row) => ( + + {row.toLocationType ?? '—'} + + ), + }, + { + key: 'quantity', + title: 'Quantity', + fields: ['quantity'], + width: 120, + cellRender: (row) => ( + {row.quantity} + ), + }, + { + key: 'createdAt', + title: 'Created', + fields: [{ key: 'createdAt', transform: (v) => fmtDate(v as string) }], + width: 140, + }, + ], + }), [pageSize]) + + return ( + <> +
+
+

Stock Movements

+

+ Full history of stock in and out across all locations +

+
+
+ + { + setPage(p.page) + setPageSize(p.pageSize) + }} + /> + + ) +} diff --git a/distribution_fe/app/(dashboard)/stock/movements/page.tsx b/distribution_fe/app/(dashboard)/stock/movements/page.tsx new file mode 100644 index 0000000..00a73d3 --- /dev/null +++ b/distribution_fe/app/(dashboard)/stock/movements/page.tsx @@ -0,0 +1,5 @@ +import { StockMovementsClient } from './_components/stock-movements-client' + +export default function StockMovementsPage() { + return +} diff --git a/distribution_fe/app/(dashboard)/stock/page.tsx b/distribution_fe/app/(dashboard)/stock/page.tsx new file mode 100644 index 0000000..1297314 --- /dev/null +++ b/distribution_fe/app/(dashboard)/stock/page.tsx @@ -0,0 +1,5 @@ +import { StockBalancesClient } from './_components/stock-balances-client' + +export default function StockBalancesPage() { + return +} diff --git a/distribution_fe/app/(dashboard)/stores/_components/store-sheet.tsx b/distribution_fe/app/(dashboard)/stores/_components/store-sheet.tsx new file mode 100644 index 0000000..96d17dc --- /dev/null +++ b/distribution_fe/app/(dashboard)/stores/_components/store-sheet.tsx @@ -0,0 +1,322 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useForm, zodResolver, z } from '@/lib/form' +import { FormField } from '@/components/shared/form-field' +import { Input } from '@/components/ui/input' +import { Checkbox } from '@/components/ui/checkbox' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { FieldGroup } from '@/components/ui/field' +import { toast } from '@/components/ui/toast' +import { stores as storesApi, routes as routesApi, getApiError } from '@/lib/api/index' +import type { StoreResponse, RouteResponse } from '@/types/api' + +const schema = z.object({ + storeName: z.string().min(1, 'Store name is required'), + ownerName: z.string().min(1, 'Owner name is required'), + storeAddress: z.string().min(1, 'Store address is required'), + routeId: z.string().min(1, 'Route is required'), + latitude: z.number(), + longitude: z.number(), + creditLimit: z.number().min(0), + segment: z.string().optional(), + isActive: z.boolean(), +}) + +type FormValues = z.infer + +interface StoreSheetProps { + open: boolean + onClose: () => void + store: StoreResponse | null + onSuccess: () => void +} + +function ensureItems(value: { items?: T[] } | null | undefined): T[] { + return Array.isArray(value?.items) ? value.items : [] +} + +export function StoreSheet({ open, onClose, store, onSuccess }: StoreSheetProps) { + const isEdit = !!store + const [routes, setRoutes] = useState([]) + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + storeName: '', + ownerName: '', + storeAddress: '', + routeId: '', + latitude: 0, + longitude: 0, + creditLimit: 0, + segment: '', + isActive: true, + }, + }) + + useEffect(() => { + if (open) { + routesApi.list({ pageSize: 100 }).then((res) => setRoutes(ensureItems(res))).catch((err) => + toast.add({ title: 'Failed to load routes', description: getApiError(err), type: 'error' }) + ) + + form.reset( + store + ? { + storeName: store.storeName, + ownerName: store.ownerName, + storeAddress: store.storeAddress, + routeId: store.routeId, + latitude: store.latitude, + longitude: store.longitude, + creditLimit: store.creditLimit, + segment: store.segment ?? '', + isActive: store.isActive, + } + : { + storeName: '', + ownerName: '', + storeAddress: '', + routeId: '', + latitude: 0, + longitude: 0, + creditLimit: 0, + segment: '', + isActive: true, + } + ) + } + }, [open, store]) + + async function onSubmit(values: FormValues) { + try { + const body = { + ...values, + segment: values.segment || undefined, + } + if (isEdit) { + await storesApi.update(store!.id, body) + toast.add({ title: 'Store updated', type: 'success' }) + } else { + await storesApi.create(body) + toast.add({ title: 'Store created', type: 'success' }) + } + onSuccess() + onClose() + } catch (err) { + toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' }) + } + } + + return ( + !v && onClose()}> + + + {isEdit ? 'Edit Store' : 'Add Store'} + + {isEdit ? 'Update the store details below.' : 'Add a new retail store to the network.'} + + + +
+ + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + field.onChange(parseFloat(e.target.value) || 0)} + onBlur={field.onBlur} + placeholder="e.g. 6.9271" + aria-invalid={invalid} + /> + )} + /> + + ( + field.onChange(parseFloat(e.target.value) || 0)} + onBlur={field.onBlur} + placeholder="e.g. 79.8612" + aria-invalid={invalid} + /> + )} + /> + + ( + field.onChange(parseFloat(e.target.value) || 0)} + onBlur={field.onBlur} + placeholder="0" + aria-invalid={invalid} + /> + )} + /> + + ( + + )} + /> + + ( +
+ + +
+ )} + /> +
+ + + + + +
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/stores/_components/stores-client.tsx b/distribution_fe/app/(dashboard)/stores/_components/stores-client.tsx new file mode 100644 index 0000000..017399f --- /dev/null +++ b/distribution_fe/app/(dashboard)/stores/_components/stores-client.tsx @@ -0,0 +1,178 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { Add01Icon } from '@hugeicons/core-free-icons' + +import { DataTable } from '@/components/shared/data-table' +import type { TableConfig } from '@/components/shared/data-table' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/toast' +import { stores as storesApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { StoreResponse, KycStatus } from '@/types/api' + +import { StoreSheet } from './store-sheet' + +function kycBadgeVariant(status: KycStatus): 'default' | 'secondary' | 'destructive' { + if (status === 'Verified') return 'default' + if (status === 'Rejected') return 'destructive' + return 'secondary' +} + +export function StoresClient() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + + const [sheetOpen, setSheetOpen] = useState(false) + const [editing, setEditing] = useState(null) + + const fetchData = useCallback(async (p: number, ps: number) => { + setLoading(true) + try { + const res = await storesApi.list({ page: p, pageSize: ps }) + setData(getPaginatedItems(res)) + setTotal(res.total) + } catch (err) { + toast.add({ title: 'Failed to load stores', description: getApiError(err), type: 'error' }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData(page, pageSize) + }, [fetchData, page, pageSize]) + + function openCreate() { + setEditing(null) + setSheetOpen(true) + } + + function openEdit(store: StoreResponse) { + setEditing(store) + setSheetOpen(true) + } + + async function toggleActive(store: StoreResponse) { + try { + await storesApi.update(store.id, { isActive: !store.isActive }) + toast.add({ + title: store.isActive ? 'Store deactivated' : 'Store activated', + type: 'success', + }) + fetchData(page, pageSize) + } catch (err) { + toast.add({ title: 'Failed to update store', description: getApiError(err), type: 'error' }) + } + } + + const tableConfig = useMemo>(() => ({ + rowKey: 'id', + serverSide: true, + stickyHeader: true, + emptyMessage: 'No stores yet. Add one to get started.', + pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] }, + columns: [ + { + key: 'storeName', + title: 'Store', + fields: ['storeName', 'ownerName'], + minWidth: 200, + cellRender: (row) => ( +
+ {row.storeName} + {row.ownerName} +
+ ), + }, + { + key: 'routeId', + title: 'Route', + fields: [{ key: 'routeId' }], + minWidth: 140, + }, + { + key: 'kycStatus', + title: 'KYC', + fields: ['kycStatus'], + width: 130, + cellRender: (row) => ( + + {row.kycStatus} + + ), + }, + { + key: 'status', + title: 'Status', + fields: ['isActive'], + width: 120, + cellRender: (row) => ( + + {row.isActive ? 'Active' : 'Inactive'} + + ), + }, + ], + rowActions: { + actions: () => [ + { + action: 'edit', + label: 'Edit store', + onClick: openEdit, + }, + { + action: 'deactivate', + label: 'Deactivate store', + onClick: toggleActive, + visible: (target) => target.isActive, + }, + { + action: 'activate', + label: 'Activate store', + onClick: toggleActive, + visible: (target) => !target.isActive, + }, + ], + }, + }), [pageSize, openEdit, toggleActive]) + + return ( + <> +
+
+

Stores

+

+ Retail store accounts, credit limits, and KYC status +

+
+ +
+ + { + setPage(p.page) + setPageSize(p.pageSize) + }} + /> + + setSheetOpen(false)} + store={editing} + onSuccess={() => fetchData(page, pageSize)} + /> + + ) +} diff --git a/distribution_fe/app/(dashboard)/stores/page.tsx b/distribution_fe/app/(dashboard)/stores/page.tsx new file mode 100644 index 0000000..deb9d99 --- /dev/null +++ b/distribution_fe/app/(dashboard)/stores/page.tsx @@ -0,0 +1,5 @@ +import { StoresClient } from './_components/stores-client' + +export default function StoresPage() { + return +} diff --git a/distribution_fe/app/(dashboard)/suppliers/_components/supplier-sheet.tsx b/distribution_fe/app/(dashboard)/suppliers/_components/supplier-sheet.tsx new file mode 100644 index 0000000..f2ad787 --- /dev/null +++ b/distribution_fe/app/(dashboard)/suppliers/_components/supplier-sheet.tsx @@ -0,0 +1,154 @@ +'use client' + +import { useEffect } from 'react' +import { useForm, zodResolver, z } from '@/lib/form' +import { FormField } from '@/components/shared/form-field' +import { Input } from '@/components/ui/input' +import { Checkbox } from '@/components/ui/checkbox' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { FieldGroup } from '@/components/ui/field' +import { toast } from '@/components/ui/toast' +import { suppliers as suppliersApi, getApiError } from '@/lib/api/index' +import type { SupplierResponse } from '@/types/api' + +const schema = z.object({ + name: z.string().min(1, 'Name is required'), + contactInfo: z.string().optional(), + isActive: z.boolean(), +}) + +type FormValues = z.infer + +interface SupplierSheetProps { + open: boolean + onClose: () => void + supplier: SupplierResponse | null + onSuccess: () => void +} + +export function SupplierSheet({ open, onClose, supplier, onSuccess }: SupplierSheetProps) { + const isEdit = !!supplier + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { name: '', contactInfo: '', isActive: true }, + }) + + useEffect(() => { + if (open) { + form.reset( + supplier + ? { name: supplier.name, contactInfo: supplier.contactInfo ?? '', isActive: supplier.isActive } + : { name: '', contactInfo: '', isActive: true } + ) + } + }, [open, supplier]) + + async function onSubmit(values: FormValues) { + try { + const body = { + ...values, + contactInfo: values.contactInfo || null, + } + if (isEdit) { + await suppliersApi.update(supplier!.id, body) + toast.add({ title: 'Supplier updated', type: 'success' }) + } else { + await suppliersApi.create(body) + toast.add({ title: 'Supplier created', type: 'success' }) + } + onSuccess() + onClose() + } catch (err) { + toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' }) + } + } + + return ( + !v && onClose()}> + + + {isEdit ? 'Edit Supplier' : 'Add Supplier'} + + {isEdit ? 'Update the supplier details below.' : 'Add a new supplier account.'} + + + +
+ + ( + + )} + /> + + ( + + )} + /> + + ( +
+ + +
+ )} + /> +
+ + + + + +
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/suppliers/_components/suppliers-client.tsx b/distribution_fe/app/(dashboard)/suppliers/_components/suppliers-client.tsx new file mode 100644 index 0000000..a35aab0 --- /dev/null +++ b/distribution_fe/app/(dashboard)/suppliers/_components/suppliers-client.tsx @@ -0,0 +1,147 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { Add01Icon } from '@hugeicons/core-free-icons' + +import { DataTable } from '@/components/shared/data-table' +import type { TableConfig } from '@/components/shared/data-table' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/toast' +import { suppliers as suppliersApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { SupplierResponse } from '@/types/api' + +import { SupplierSheet } from './supplier-sheet' + +export function SuppliersClient() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + + const [sheetOpen, setSheetOpen] = useState(false) + const [editing, setEditing] = useState(null) + + const fetchData = useCallback(async (p: number, ps: number) => { + setLoading(true) + try { + const res = await suppliersApi.list({ page: p, pageSize: ps }) + setData(getPaginatedItems(res)) + setTotal(res.total) + } catch (err) { + toast.add({ title: 'Failed to load suppliers', description: getApiError(err), type: 'error' }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData(page, pageSize) + }, [fetchData, page, pageSize]) + + function openCreate() { + setEditing(null) + setSheetOpen(true) + } + + function openEdit(supplier: SupplierResponse) { + setEditing(supplier) + setSheetOpen(true) + } + + const tableConfig = useMemo>(() => ({ + rowKey: 'id', + serverSide: true, + stickyHeader: true, + emptyMessage: 'No suppliers yet. Add one to get started.', + pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] }, + columns: [ + { + key: 'name', + title: 'Supplier', + fields: [{ key: 'name', primary: true }], + sortable: true, + minWidth: 200, + }, + { + key: 'contactInfo', + title: 'Contact', + fields: ['contactInfo'], + minWidth: 180, + cellRender: (row) => ( + + {row.contactInfo ?? '—'} + + ), + }, + { + key: 'status', + title: 'Status', + fields: ['isActive'], + width: 120, + cellRender: (row) => ( + + {row.isActive ? 'Active' : 'Inactive'} + + ), + }, + { + key: 'createdAt', + title: 'Created', + fields: [{ + key: 'createdAt', + transform: (v) => new Date(v as string).toLocaleDateString('en-GB', { + day: '2-digit', month: 'short', year: 'numeric', + }), + }], + width: 140, + }, + ], + rowActions: { + actions: () => [ + { + action: 'edit', + label: 'Edit supplier', + onClick: openEdit, + }, + ], + }, + }), [pageSize]) + + return ( + <> +
+
+

Suppliers

+

+ Supplier accounts used for raising purchase orders +

+
+ +
+ + { + setPage(p.page) + setPageSize(p.pageSize) + }} + /> + + setSheetOpen(false)} + supplier={editing} + onSuccess={() => fetchData(page, pageSize)} + /> + + ) +} diff --git a/distribution_fe/app/(dashboard)/suppliers/page.tsx b/distribution_fe/app/(dashboard)/suppliers/page.tsx new file mode 100644 index 0000000..46bc38c --- /dev/null +++ b/distribution_fe/app/(dashboard)/suppliers/page.tsx @@ -0,0 +1,5 @@ +import { SuppliersClient } from './_components/suppliers-client' + +export default function SuppliersPage() { + return +} diff --git a/distribution_fe/app/(dashboard)/users/_components/user-sheet.tsx b/distribution_fe/app/(dashboard)/users/_components/user-sheet.tsx new file mode 100644 index 0000000..049c805 --- /dev/null +++ b/distribution_fe/app/(dashboard)/users/_components/user-sheet.tsx @@ -0,0 +1,323 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useForm, zodResolver, z } from '@/lib/form' +import { FormField } from '@/components/shared/form-field' +import { Input } from '@/components/ui/input' +import { Checkbox } from '@/components/ui/checkbox' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { FieldGroup } from '@/components/ui/field' +import { toast } from '@/components/ui/toast' +import { users as usersApi, roles as rolesApi, getApiError } from '@/lib/api/index' +import type { UserResponse, RoleResponse } from '@/types/api' + +const createSchema = z.object({ + fullName: z.string().min(1, 'Full name is required'), + username: z.string().min(1, 'Username is required'), + roleId: z.string().min(1, 'Role is required'), + password: z.string().min(6, 'Password must be at least 6 characters'), + isActive: z.boolean(), +}) + +const editSchema = z.object({ + fullName: z.string().min(1, 'Full name is required'), + username: z.string().min(1, 'Username is required'), + roleId: z.string().min(1, 'Role is required'), + isActive: z.boolean(), +}) + +type CreateFormValues = z.infer +type EditFormValues = z.infer + +interface UserSheetProps { + open: boolean + onClose: () => void + user: UserResponse | null + onSuccess: () => void +} + +export function UserSheet({ open, onClose, user, onSuccess }: UserSheetProps) { + const isEdit = !!user + const [roles, setRoles] = useState([]) + + const createForm = useForm({ + resolver: zodResolver(createSchema), + defaultValues: { fullName: '', username: '', roleId: '', password: '', isActive: true }, + }) + + const editForm = useForm({ + resolver: zodResolver(editSchema), + defaultValues: { fullName: '', username: '', roleId: '', isActive: true }, + }) + + const form = isEdit ? editForm : createForm + + useEffect(() => { + if (open) { + rolesApi.list().then(setRoles).catch((err) => + toast.add({ title: 'Failed to load roles', description: getApiError(err), type: 'error' }) + ) + + if (isEdit && user) { + editForm.reset({ + fullName: user.fullName, + username: user.username, + roleId: user.roleId, + isActive: user.isActive, + }) + } else { + createForm.reset({ fullName: '', username: '', roleId: '', password: '', isActive: true }) + } + } + }, [open, user]) + + async function onSubmitCreate(values: CreateFormValues) { + try { + await usersApi.create(values) + toast.add({ title: 'User created', type: 'success' }) + onSuccess() + onClose() + } catch (err) { + toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' }) + } + } + + async function onSubmitEdit(values: EditFormValues) { + try { + await usersApi.update(user!.id, values) + toast.add({ title: 'User updated', type: 'success' }) + onSuccess() + onClose() + } catch (err) { + toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' }) + } + } + + return ( + !v && onClose()}> + + + {isEdit ? 'Edit User' : 'Add User'} + + {isEdit ? 'Update the user details below.' : 'Create a new staff account.'} + + + + {isEdit ? ( +
+ + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( +
+ + +
+ )} + /> +
+ + + + + +
+ ) : ( +
+ + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( + + )} + /> + + ( +
+ + +
+ )} + /> +
+ + + + + +
+ )} +
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/users/_components/users-client.tsx b/distribution_fe/app/(dashboard)/users/_components/users-client.tsx new file mode 100644 index 0000000..1038283 --- /dev/null +++ b/distribution_fe/app/(dashboard)/users/_components/users-client.tsx @@ -0,0 +1,161 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { Add01Icon } from '@hugeicons/core-free-icons' + +import { DataTable } from '@/components/shared/data-table' +import type { TableConfig } from '@/components/shared/data-table' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/toast' +import { users as usersApi, getApiError, getPaginatedItems } from '@/lib/api/index' +import type { UserResponse } from '@/types/api' + +import { UserSheet } from './user-sheet' + +export function UsersClient() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + + const [sheetOpen, setSheetOpen] = useState(false) + const [editing, setEditing] = useState(null) + + const fetchData = useCallback(async (p: number, ps: number) => { + setLoading(true) + try { + const res = await usersApi.list({ page: p, pageSize: ps }) + setData(getPaginatedItems(res)) + setTotal(res.total) + } catch (err) { + toast.add({ title: 'Failed to load users', description: getApiError(err), type: 'error' }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData(page, pageSize) + }, [fetchData, page, pageSize]) + + function openCreate() { + setEditing(null) + setSheetOpen(true) + } + + function openEdit(user: UserResponse) { + setEditing(user) + setSheetOpen(true) + } + + async function toggleActive(user: UserResponse) { + try { + await usersApi.update(user.id, { isActive: !user.isActive }) + toast.add({ + title: user.isActive ? 'User deactivated' : 'User activated', + type: 'success', + }) + fetchData(page, pageSize) + } catch (err) { + toast.add({ title: 'Failed to update user', description: getApiError(err), type: 'error' }) + } + } + + const tableConfig = useMemo>(() => ({ + rowKey: 'id', + serverSide: true, + stickyHeader: true, + emptyMessage: 'No users yet. Add one to get started.', + pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] }, + columns: [ + { + key: 'fullName', + title: 'Name', + fields: ['fullName', 'username'], + minWidth: 200, + cellRender: (row) => ( +
+ {row.fullName} + {row.username} +
+ ), + }, + { + key: 'roleName', + title: 'Role', + fields: [{ key: 'roleName' }], + minWidth: 140, + }, + { + key: 'status', + title: 'Status', + fields: ['isActive'], + width: 120, + cellRender: (row) => ( + + {row.isActive ? 'Active' : 'Inactive'} + + ), + }, + ], + rowActions: { + actions: () => [ + { + action: 'edit', + label: 'Edit user', + onClick: openEdit, + }, + { + action: 'deactivate', + label: 'Deactivate user', + onClick: toggleActive, + visible: (target) => target.isActive, + }, + { + action: 'activate', + label: 'Activate user', + onClick: toggleActive, + visible: (target) => !target.isActive, + }, + ], + }, + }), [pageSize, openEdit, toggleActive]) + + return ( + <> +
+
+

Users

+

+ Staff accounts and their role assignments +

+
+ +
+ + { + setPage(p.page) + setPageSize(p.pageSize) + }} + /> + + setSheetOpen(false)} + user={editing} + onSuccess={() => fetchData(page, pageSize)} + /> + + ) +} diff --git a/distribution_fe/app/(dashboard)/users/page.tsx b/distribution_fe/app/(dashboard)/users/page.tsx new file mode 100644 index 0000000..a748a9d --- /dev/null +++ b/distribution_fe/app/(dashboard)/users/page.tsx @@ -0,0 +1,5 @@ +import { UsersClient } from './_components/users-client' + +export default function UsersPage() { + return +} diff --git a/distribution_fe/app/(dashboard)/users/roles/_components/role-sheet.tsx b/distribution_fe/app/(dashboard)/users/roles/_components/role-sheet.tsx new file mode 100644 index 0000000..c5ea2c6 --- /dev/null +++ b/distribution_fe/app/(dashboard)/users/roles/_components/role-sheet.tsx @@ -0,0 +1,152 @@ +'use client' + +import { useEffect } from 'react' +import { useForm, zodResolver, z } from '@/lib/form' +import { FormField } from '@/components/shared/form-field' +import { Input } from '@/components/ui/input' +import { Checkbox } from '@/components/ui/checkbox' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { FieldGroup } from '@/components/ui/field' +import { toast } from '@/components/ui/toast' +import { roles as rolesApi, getApiError } from '@/lib/api/index' +import type { RoleResponse } from '@/types/api' + +const schema = z.object({ + name: z.string().min(1, 'Name is required'), + level: z.number().int().min(1, 'Level must be at least 1'), + isActive: z.boolean(), +}) + +type FormValues = z.infer + +interface RoleSheetProps { + open: boolean + onClose: () => void + role: RoleResponse | null + onSuccess: () => void +} + +export function RoleSheet({ open, onClose, role, onSuccess }: RoleSheetProps) { + const isEdit = !!role + + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { name: '', level: 1, isActive: true }, + }) + + useEffect(() => { + if (open) { + form.reset( + role + ? { name: role.name, level: role.level, isActive: role.isActive } + : { name: '', level: 1, isActive: true } + ) + } + }, [open, role]) + + async function onSubmit(values: FormValues) { + try { + if (isEdit) { + await rolesApi.update(role!.id, values) + toast.add({ title: 'Role updated', type: 'success' }) + } else { + await rolesApi.create(values) + toast.add({ title: 'Role created', type: 'success' }) + } + onSuccess() + onClose() + } catch (err) { + toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' }) + } + } + + return ( + !v && onClose()}> + + + {isEdit ? 'Edit Role' : 'Add Role'} + + {isEdit ? 'Update the role details below.' : 'Add a new role to the system.'} + + + +
+ + ( + + )} + /> + + ( + field.onChange(parseInt(e.target.value) || 1)} + onBlur={field.onBlur} + placeholder="e.g. 1" + aria-invalid={invalid} + /> + )} + /> + + ( +
+ + +
+ )} + /> +
+ + + + + +
+
+
+ ) +} diff --git a/distribution_fe/app/(dashboard)/users/roles/_components/roles-client.tsx b/distribution_fe/app/(dashboard)/users/roles/_components/roles-client.tsx new file mode 100644 index 0000000..71baf0a --- /dev/null +++ b/distribution_fe/app/(dashboard)/users/roles/_components/roles-client.tsx @@ -0,0 +1,149 @@ +'use client' + +import { useState, useEffect, useCallback, useMemo } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { Add01Icon } from '@hugeicons/core-free-icons' + +import { DataTable } from '@/components/shared/data-table' +import type { TableConfig } from '@/components/shared/data-table' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/toast' +import { roles as rolesApi, getApiError } from '@/lib/api/index' +import type { RoleResponse } from '@/types/api' + +import { RoleSheet } from './role-sheet' + +export function RolesClient() { + const [data, setData] = useState([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + + const [sheetOpen, setSheetOpen] = useState(false) + const [editing, setEditing] = useState(null) + + const fetchData = useCallback(async () => { + setLoading(true) + try { + const res = await rolesApi.list() + setData(res) + setTotal(res.length) + } catch (err) { + toast.add({ title: 'Failed to load roles', description: getApiError(err), type: 'error' }) + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + fetchData() + }, [fetchData]) + + function openCreate() { + setEditing(null) + setSheetOpen(true) + } + + function openEdit(role: RoleResponse) { + setEditing(role) + setSheetOpen(true) + } + + async function toggleActive(role: RoleResponse) { + try { + await rolesApi.update(role.id, { isActive: !role.isActive }) + toast.add({ + title: role.isActive ? 'Role deactivated' : 'Role activated', + type: 'success', + }) + fetchData() + } catch (err) { + toast.add({ title: 'Failed to update role', description: getApiError(err), type: 'error' }) + } + } + + const tableConfig = useMemo>(() => ({ + rowKey: 'id', + stickyHeader: true, + emptyMessage: 'No roles yet. Add one to get started.', + pagination: { enabled: true, pageSize: 20, pageSizeOptions: [10, 20, 50] }, + columns: [ + { + key: 'name', + title: 'Role', + fields: [{ key: 'name', primary: true }], + sortable: true, + minWidth: 200, + }, + { + key: 'level', + title: 'Level', + fields: [{ key: 'level', transform: (v) => `Level ${v}` }], + width: 100, + }, + { + key: 'status', + title: 'Status', + fields: ['isActive'], + width: 120, + cellRender: (row) => ( + + {row.isActive ? 'Active' : 'Inactive'} + + ), + }, + ], + rowActions: { + actions: () => [ + { + action: 'edit', + label: 'Edit role', + onClick: openEdit, + }, + { + action: 'deactivate', + label: 'Deactivate role', + onClick: toggleActive, + visible: (target) => target.isActive, + }, + { + action: 'activate', + label: 'Activate role', + onClick: toggleActive, + visible: (target) => !target.isActive, + }, + ], + }, + }), [openEdit, toggleActive]) + + return ( + <> +
+
+

Roles

+

+ Permission sets that control what each role can see and do +

+
+ +
+ + + + setSheetOpen(false)} + role={editing} + onSuccess={() => fetchData()} + /> + + ) +} diff --git a/distribution_fe/app/(dashboard)/users/roles/page.tsx b/distribution_fe/app/(dashboard)/users/roles/page.tsx new file mode 100644 index 0000000..9f2f408 --- /dev/null +++ b/distribution_fe/app/(dashboard)/users/roles/page.tsx @@ -0,0 +1,5 @@ +import { RolesClient } from './_components/roles-client' + +export default function RolesPage() { + return +} diff --git a/distribution_fe/app/layout.tsx b/distribution_fe/app/layout.tsx index d19ccf2..3bc86e3 100644 --- a/distribution_fe/app/layout.tsx +++ b/distribution_fe/app/layout.tsx @@ -3,7 +3,7 @@ import { Geist_Mono, Outfit, Nunito_Sans } from "next/font/google" import "./globals.css" import { ThemeProvider } from "@/components/theme-provider" import { AuthProvider } from "@/contexts/auth-context" -import { TooltipProvider } from "@/components/ui/tooltip" +import { UiProvider } from "@/components/ui/provider" import { cn } from "@/lib/utils" const nunitoSansHeading = Nunito_Sans({ subsets: ['latin'], variable: '--font-heading' }) @@ -21,12 +21,12 @@ export default function RootLayout({ suppressHydrationWarning className={cn('antialiased', fontMono.variable, 'font-sans', outfit.variable, nunitoSansHeading.variable)} > - + - + {children} - + diff --git a/distribution_fe/app/not-found.tsx b/distribution_fe/app/not-found.tsx new file mode 100644 index 0000000..97363aa --- /dev/null +++ b/distribution_fe/app/not-found.tsx @@ -0,0 +1,60 @@ +'use client' + +import Link from 'next/link' +import { useRouter } from 'next/navigation' +import { HugeiconsIcon } from '@hugeicons/react' +import { ArrowLeft01Icon, Home01Icon, CustomerSupportIcon } from '@hugeicons/core-free-icons' + +import { Button } from '@/components/ui/button' + +export default function NotFoundPage() { + const router = useRouter() + + return ( +
+ {/* Wordmark */} +
+
+ D +
+ Distribution +
+ + {/* 404 badge */} +
+ Error 404 +
+ + {/* Heading */} +

+ Page not found +

+ + {/* Assurance copy */} +

+ This URL doesn't exist in the system — but everything else is working fine. +

+

+ Your data, orders, and operations are all safe and unaffected. You may have followed a broken link or typed an address that has changed. +

+ + {/* CTAs */} +
+ + +
+ + {/* Support nudge */} +

+ + If you believe this is a mistake, contact your system administrator. +

+
+ ) +} diff --git a/distribution_fe/components/layout/app-sidebar.tsx b/distribution_fe/components/layout/app-sidebar.tsx index 40c1c75..22883c0 100644 --- a/distribution_fe/components/layout/app-sidebar.tsx +++ b/distribution_fe/components/layout/app-sidebar.tsx @@ -1,6 +1,7 @@ 'use client' import Link from 'next/link' +import { useEffect, useState } from 'react' import { usePathname } from 'next/navigation' import { HugeiconsIcon } from '@hugeicons/react' import { ChevronRightIcon, UserIcon } from '@hugeicons/core-free-icons' @@ -31,6 +32,11 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip' import { useAuth, type Role } from '@/contexts/auth-context' import { DASHBOARD_ITEM, NAV_GROUPS, type NavGroup, type NavItem } from '@/config/nav' @@ -48,6 +54,32 @@ export function AppSidebar() { const pathname = usePathname() const dashboardActive = pathname === DASHBOARD_ITEM.href + const [openGroups, setOpenGroups] = useState>({}) + + useEffect(() => { + setOpenGroups((prev) => { + const next = { ...prev } + + for (const group of NAV_GROUPS) { + if (!isGroupVisible(group, permissions)) continue + + const visibleItems = group.items.filter((item) => canSee(item, permissions)) + const groupActive = visibleItems.some( + (item) => + pathname === item.href || + (item.href !== '/' && pathname.startsWith(item.href)), + ) + + if (groupActive) { + next[group.title] = true + } else if (!(group.title in next)) { + next[group.title] = false + } + } + + return next + }) + }, [pathname, permissions]) return ( @@ -95,7 +127,10 @@ export function AppSidebar() { return ( + setOpenGroups((prev) => ({ ...prev, [group.title]: open })) + } className="group/collapsible" > @@ -126,15 +161,18 @@ export function AppSidebar() { (item.href !== '/' && pathname.startsWith(item.href)) return ( - - } - isActive={isActive} - > - - {item.title} - - + + + } />} + isActive={isActive} + > + + {item.title} + + + {item.description ?? item.title} + ) })} diff --git a/distribution_fe/components/shared/data-table/data-table.tsx b/distribution_fe/components/shared/data-table/data-table.tsx index 55bab03..f86475b 100644 --- a/distribution_fe/components/shared/data-table/data-table.tsx +++ b/distribution_fe/components/shared/data-table/data-table.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState, useMemo, useRef, useCallback } from 'react'; +import React, { useState, useMemo, useRef, useCallback, useEffect } from 'react'; import Link from 'next/link'; import { HugeiconsIcon } from '@hugeicons/react'; import { @@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; import { cn } from '@/lib/utils'; +import { RowActionButton, RowActions } from '@/components/shared/row-actions'; import { TableToolbar } from './table-toolbar'; import { TablePagination } from './table-pagination'; @@ -27,7 +28,7 @@ import { exportTable } from './hooks/use-table-export'; import { getNestedValue, resolveUrlTemplate } from './utils/nested-value'; import type { TableConfig, ColumnDef, FieldConfig, SortState, FilterState, PageState, - ServerSideParams, ExportFormat, + ServerSideParams, ExportFormat, RowActionItem, } from './types'; interface DataTableProps { @@ -48,6 +49,29 @@ function normalizeField(f: FieldConfig | string): FieldConfig { return typeof f === 'string' ? { key: f } : f; } +function formatTextValue(raw: unknown): string { + if (raw == null) return ''; + if (typeof raw === 'string') return raw; + if (typeof raw === 'number' || typeof raw === 'bigint') return String(raw); + if (typeof raw === 'boolean') return raw ? 'Yes' : 'No'; + + if (Array.isArray(raw)) { + if (raw.length === 0) return ''; + if (raw.every(item => item == null || ['string', 'number', 'bigint', 'boolean'].includes(typeof item))) { + return raw.map(item => formatTextValue(item)).filter(Boolean).join(', '); + } + return `${raw.length} item${raw.length === 1 ? '' : 's'}`; + } + + if (typeof raw === 'object') { + const record = raw as Record; + const namedValue = record.name ?? record.title ?? record.label ?? record.id; + return namedValue == null ? '' : formatTextValue(namedValue); + } + + return String(raw); +} + function SortIcon({ colKey, sortState }: { colKey: string; sortState: SortState }) { if (sortState.column !== colKey) { return ( @@ -62,6 +86,36 @@ function SortIcon({ colKey, sortState }: { colKey: string; sortState: SortState : ; } +function DataTableRowActions({ + row, + actions, +}: { + row: T; + actions: RowActionItem[]; +}) { + const visibleActions = actions.filter(action => action.visible ? action.visible(row) : true); + + if (visibleActions.length === 0) return null; + + return ( + + {visibleActions.map((action) => ( + { + e.stopPropagation(); + action.onClick(row); + }} + /> + ))} + + ); +} + function MobileCard({ row, columns, @@ -73,7 +127,7 @@ function MobileCard({ mobileCardCfg?: TableConfig['mobileCard']; onClick?: (row: T) => void; }) { - const actionCol = columns.find(c => !c.title && c.cellRender); + const actionCol = columns.find(c => c.key === '__row_actions' || (!c.title && c.cellRender)); const dataColumns = columns.filter(c => !!c.title); const primaryCol = dataColumns[0]; @@ -153,11 +207,34 @@ function CellContent({ const fields = col.fields.map(normalizeField); const layout = col.layout ?? 'vertical'; + if (fields.length === 1 && (fields[0].renderer ?? 'text') === 'text') { + const field = fields[0]; + const raw = getNestedValue(row, field.key); + const text = field.transform + ? field.transform(raw, row) + : `${field.prefix ?? ''}${formatTextValue(raw)}${field.suffix ?? ''}`; + + return ( +
+ {text || '—'} +
+ ); + } + const rendered = fields.map((field, i) => { const raw = getNestedValue(row, field.key); const text = field.transform ? field.transform(raw, row) - : `${field.prefix ?? ''}${raw ?? ''}${field.suffix ?? ''}`; + : `${field.prefix ?? ''}${formatTextValue(raw)}${field.suffix ?? ''}`; const renderer = field.renderer ?? 'text'; @@ -190,17 +267,19 @@ function CellContent({ } return ( - {text} - + ); }); @@ -232,6 +311,7 @@ export function DataTable({ onRowClick, onServerSideChange, }: DataTableProps) { + const safeData = useMemo(() => (Array.isArray(data) ? data : []), [data]); const [sortState, setSortState] = useState({ column: null, direction: null }); const [filterState, setFilterState] = useState({ global: '', columns: {} }); const [pageState, setPageState] = useState({ @@ -257,11 +337,51 @@ export function DataTable({ return String(row[config.rowKey as keyof T]); } - const normalizedColumns = useMemo(() => - config.columns.map(col => ({ + const normalizedColumns = useMemo(() => { + const baseColumns = config.columns.map(col => ({ ...col, fields: col.fields.map(normalizeField) as FieldConfig[], - })), [config.columns]); + })); + + if (!config.rowActions) { + return baseColumns; + } + + const actionsColumn: ColumnDef = { + key: '__row_actions', + title: config.rowActions.title ?? 'Action', + fields: [], + width: config.rowActions.width ?? 96, + minWidth: config.rowActions.minWidth ?? 96, + resizable: false, + hideable: false, + align: config.rowActions.align ?? 'right', + exportable: false, + cellRender: (row: T) => ( + + ), + }; + + return [ + ...baseColumns, + actionsColumn, + ]; + }, [config.columns, config.rowActions]); + + useEffect(() => { + setColumnOrder((prev) => { + const nextKeys = normalizedColumns.map((col) => col.key); + const kept = prev.filter((key) => nextKeys.includes(key)); + const missing = nextKeys.filter((key) => !kept.includes(key)); + const next = [...kept, ...missing]; + + if (next.length === prev.length && next.every((key, index) => key === prev[index])) { + return prev; + } + + return next; + }); + }, [normalizedColumns]); const orderedColumns = useMemo(() => columnOrder @@ -271,23 +391,23 @@ export function DataTable({ ); const visibleColumns = useMemo(() => - orderedColumns.filter(c => !hiddenColumns.has(c.key)), + orderedColumns.filter(c => c.hideable === false || !hiddenColumns.has(c.key)), [orderedColumns, hiddenColumns] ); const filteredData = useMemo(() => - config.serverSide ? data : filterData(data, filterState, normalizedColumns as ColumnDef[]), - [data, filterState, normalizedColumns, config.serverSide] + config.serverSide ? safeData : filterData(safeData, filterState, normalizedColumns as ColumnDef[]), + [safeData, filterState, normalizedColumns, config.serverSide] ); const sortedData = useMemo(() => - config.serverSide ? data : sortData(filteredData, sortState, normalizedColumns as ColumnDef[]), + config.serverSide ? safeData : sortData(filteredData, sortState, normalizedColumns as ColumnDef[]), [filteredData, sortState, normalizedColumns, config.serverSide] ); const totalRecords = useMemo(() => - config.serverSide ? (totalCount ?? data.length) : filteredData.length, - [config.serverSide, totalCount, data.length, filteredData.length] + config.serverSide ? (totalCount ?? safeData.length) : filteredData.length, + [config.serverSide, totalCount, safeData.length, filteredData.length] ); const rowHeightPx = useMemo(() => { @@ -353,7 +473,7 @@ export function DataTable({ const next = new Set(selectedKeys); next.has(key) ? next.delete(key) : next.add(key); setSelectedKeys(next); - onSelectionChange?.(data.filter(r => next.has(rowKey(r)))); + onSelectionChange?.(safeData.filter(r => next.has(rowKey(r)))); } function toggleAll() { @@ -364,7 +484,7 @@ export function DataTable({ selectablePageData.forEach(r => next.add(rowKey(r))); } setSelectedKeys(next); - onSelectionChange?.(data.filter(r => next.has(rowKey(r)))); + onSelectionChange?.(safeData.filter(r => next.has(rowKey(r)))); } function clearSelection() { @@ -382,6 +502,9 @@ export function DataTable({ } function toggleColumnVisibility(key: string) { + const column = orderedColumns.find(col => col.key === key); + if (!column || column.hideable === false) return; + setHiddenColumns(prev => { const next = new Set(prev); next.has(key) ? next.delete(key) : next.add(key); diff --git a/distribution_fe/components/shared/data-table/table-toolbar.tsx b/distribution_fe/components/shared/data-table/table-toolbar.tsx index 6fa2574..137f547 100644 --- a/distribution_fe/components/shared/data-table/table-toolbar.tsx +++ b/distribution_fe/components/shared/data-table/table-toolbar.tsx @@ -46,6 +46,7 @@ export function TableToolbar({ onClearSelection, }: TableToolbarProps) { const inputRef = useRef(null); + const toggleableColumns = columns.filter(col => col.hideable !== false); return (
@@ -91,7 +92,7 @@ export function TableToolbar({ Columns - {columns.map(col => ( + {toggleableColumns.map(col => ( ; @@ -49,6 +56,7 @@ export interface ColumnDef { minWidth?: number; resizable?: boolean; visible?: boolean; + hideable?: boolean; align?: ColumnAlign; group?: string; exportable?: boolean; @@ -75,6 +83,23 @@ export interface MobileCardConfig { detailHref?: (row: T) => string; } +export interface RowActionItem { + action: RowActionKind; + label: string; + onClick: (row: T) => void; + visible?: (row: T) => boolean; + disabled?: (row: T) => boolean; + tone?: 'default' | 'destructive'; +} + +export interface RowActionsConfig { + title?: string; + width?: number; + minWidth?: number; + align?: ColumnAlign; + actions: (row: T) => RowActionItem[]; +} + export interface TableConfig { columns: ColumnDef[]; rowKey?: keyof T | ((row: T) => string); @@ -93,6 +118,7 @@ export interface TableConfig { isRowSelectable?: (row: T) => boolean; expandRender?: (row: T) => React.ReactNode; mobileCard?: MobileCardConfig; + rowActions?: RowActionsConfig; } export interface SortState { diff --git a/distribution_fe/components/shared/form-field.tsx b/distribution_fe/components/shared/form-field.tsx new file mode 100644 index 0000000..a1565cc --- /dev/null +++ b/distribution_fe/components/shared/form-field.tsx @@ -0,0 +1,63 @@ +'use client' + +import { + Controller, + type FieldValues, + type FieldPath, + type Control, + type ControllerRenderProps, + type FieldError, +} from 'react-hook-form' + +import { + Field, + FieldLabel, + FieldDescription, + FieldError as FieldErrorDisplay, +} from '@/components/ui/field' + +interface FormFieldProps = FieldPath> { + control: Control + name: TName + label?: string + description?: string + required?: boolean + render: (props: { + field: ControllerRenderProps + error?: FieldError + invalid: boolean + }) => React.ReactNode +} + +export function FormField = FieldPath>({ + control, + name, + label, + description, + required, + render, +}: FormFieldProps) { + return ( + ( + + {label && ( + + {label} + {required && *} + + )} + {render({ field, error: fieldState.error, invalid: fieldState.invalid })} + {description && !fieldState.error && ( + {description} + )} + {fieldState.error && ( + + )} + + )} + /> + ) +} diff --git a/distribution_fe/components/shared/row-actions.tsx b/distribution_fe/components/shared/row-actions.tsx new file mode 100644 index 0000000..9622e10 --- /dev/null +++ b/distribution_fe/components/shared/row-actions.tsx @@ -0,0 +1,70 @@ +"use client" + +import * as React from "react" +import { HugeiconsIcon } from "@hugeicons/react" +import { + Cancel01Icon, + CheckmarkCircle02Icon, + Delete02Icon, + PencilEdit01Icon, +} from "@hugeicons/core-free-icons" + +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" + +const ACTION_ICONS = { + activate: CheckmarkCircle02Icon, + cancel: Delete02Icon, + close: Cancel01Icon, + confirm: CheckmarkCircle02Icon, + deactivate: Cancel01Icon, + edit: PencilEdit01Icon, +} as const + +type RowActionKind = keyof typeof ACTION_ICONS + +function RowActions({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function RowActionButton({ + action, + label, + tone = "default", + className, + ...props +}: Omit, "children"> & { + action: RowActionKind + label: string + tone?: "default" | "destructive" +}) { + const icon = ACTION_ICONS[action] + + return ( + + ) +} + +export { RowActions, RowActionButton } diff --git a/distribution_fe/components/theme-provider.tsx b/distribution_fe/components/theme-provider.tsx index 3d44f48..0fc9916 100644 --- a/distribution_fe/components/theme-provider.tsx +++ b/distribution_fe/components/theme-provider.tsx @@ -47,7 +47,7 @@ function ThemeHotkey() { return } - if (event.key.toLowerCase() !== "d") { + if (typeof event.key !== "string" || event.key.toLowerCase() !== "d") { return } diff --git a/distribution_fe/components/ui/dialog.tsx b/distribution_fe/components/ui/dialog.tsx new file mode 100644 index 0000000..db7393f --- /dev/null +++ b/distribution_fe/components/ui/dialog.tsx @@ -0,0 +1,160 @@ +"use client" + +import * as React from "react" +import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { HugeiconsIcon } from "@hugeicons/react" +import { Cancel01Icon } from "@hugeicons/core-free-icons" + +function Dialog({ ...props }: DialogPrimitive.Root.Props) { + return +} + +function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { + return +} + +function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) { + return +} + +function DialogClose({ ...props }: DialogPrimitive.Close.Props) { + return +} + +function DialogOverlay({ + className, + ...props +}: DialogPrimitive.Backdrop.Props) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: DialogPrimitive.Popup.Props & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + } + > + + Close + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + }> + Close + + )} +
+ ) +} + +function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: DialogPrimitive.Description.Props) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/distribution_fe/components/ui/field.tsx b/distribution_fe/components/ui/field.tsx new file mode 100644 index 0000000..6b1bacc --- /dev/null +++ b/distribution_fe/components/ui/field.tsx @@ -0,0 +1,238 @@ +"use client" + +import { useMemo } from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" +import { Separator } from "@/components/ui/separator" + +function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) { + return ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3", + className + )} + {...props} + /> + ) +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) { + return ( + + ) +} + +function FieldGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +const fieldVariants = cva( + "group/field flex w-full gap-3 data-[invalid=true]:text-destructive", + { + variants: { + orientation: { + vertical: "flex-col *:w-full [&>.sr-only]:w-auto", + horizontal: + "flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + responsive: + "flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px", + }, + }, + defaultVariants: { + orientation: "vertical", + }, + } +) + +function Field({ + className, + orientation = "vertical", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function FieldContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps) { + return ( +