Merge branch 'dev' into production
This commit is contained in:
@@ -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<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "items.view")]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
|
||||
@@ -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<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
[HttpGet("api/categories/{id:guid}")]
|
||||
[Authorize(Policy = "categories.view")]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> DeleteSubCategory(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteSubCategoryAsync(id, ct);
|
||||
|
||||
@@ -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<IActionResult> Settle(Guid id, SettleClaimRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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<IActionResult> Get()
|
||||
{
|
||||
var result = await service.GetAsync();
|
||||
@@ -18,7 +15,6 @@ public class DriverComplianceConfigController(DriverComplianceConfigService serv
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
[Authorize(Policy = "compliance.manage")]
|
||||
public async Task<IActionResult> Upsert(UpdateDriverComplianceConfigRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
|
||||
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> Deactivate(Guid id, CancellationToken ct)
|
||||
{
|
||||
var success = await service.DeactivateAsync(id, ct);
|
||||
|
||||
@@ -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}")]
|
||||
|
||||
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> ReplaceConversions(Guid id, ReplaceConversionsRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.ReplaceConversionsAsync(id, request, ct);
|
||||
|
||||
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> Resolve(Guid id, ResolveDiscrepancyRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
|
||||
@@ -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<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "orders.view")]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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}")]
|
||||
|
||||
@@ -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<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "regions.view")]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
|
||||
@@ -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}")]
|
||||
|
||||
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> GetBreakdownAssist(Guid rideId)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "routes.view")]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
|
||||
@@ -5,7 +5,8 @@ namespace Distribution.Features.Sale;
|
||||
public interface ISaleRepository
|
||||
{
|
||||
Task<Models.Sale?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.Sale>> GetAllAsync();
|
||||
Task<List<Models.Sale>> GetAllAsync(Guid? rideId, IEnumerable<Guid>? storeIds, int page, int pageSize);
|
||||
Task<int> GetCountAsync(Guid? rideId, IEnumerable<Guid>? storeIds);
|
||||
Task<List<Models.Sale>> GetByStoreIdAsync(Guid storeId);
|
||||
Task AddAsync(Models.Sale sale);
|
||||
void Update(Models.Sale sale);
|
||||
|
||||
@@ -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<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> Create(CreateSaleRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.CreateAsync(request, ct);
|
||||
|
||||
@@ -9,8 +9,21 @@ public class SaleRepository(AppDBContext context) : ISaleRepository
|
||||
public async Task<Models.Sale?> GetByIdAsync(Guid id) =>
|
||||
await context.Sales.FindAsync(id);
|
||||
|
||||
public async Task<List<Models.Sale>> GetAllAsync() =>
|
||||
await context.Sales.ToListAsync();
|
||||
public async Task<List<Models.Sale>> GetAllAsync(Guid? rideId, IEnumerable<Guid>? 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<int> GetCountAsync(Guid? rideId, IEnumerable<Guid>? storeIds) =>
|
||||
await context.Sales
|
||||
.CountAsync(s => s.IsActive &&
|
||||
(rideId == null || s.FK_RideId == rideId) &&
|
||||
(storeIds == null || storeIds.Contains(s.FK_StoreId)));
|
||||
|
||||
public async Task<List<Models.Sale>> GetByStoreIdAsync(Guid storeId) =>
|
||||
await context.Sales.Where(s => s.FK_StoreId == storeId && s.IsActive).ToListAsync();
|
||||
|
||||
@@ -71,14 +71,14 @@ public class SaleService(
|
||||
sale.IsActive,
|
||||
sale.CreatedAt);
|
||||
|
||||
public async Task<List<SaleResponse>> GetAllAsync()
|
||||
public async Task<PagedResponse<SaleResponse>> GetAllAsync(Guid? rideId, int page, int pageSize)
|
||||
{
|
||||
var sales = (await repo.GetAllAsync()).Where(s => s.IsActive).ToList();
|
||||
IEnumerable<Guid>? 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<SaleResponse>();
|
||||
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<SaleResponse>(page, pageSize, total, result);
|
||||
}
|
||||
|
||||
public async Task<SaleResponse?> GetByIdAsync(Guid id)
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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<IActionResult> GetBalances(
|
||||
@@ -46,7 +44,7 @@ public class StockController(StockService service, ICurrentUserService currentUs
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// ── Write ──────────────────────────────────────────────────────────────
|
||||
// ── Write ──────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpPost("transfer")]
|
||||
public async Task<IActionResult> Transfer(TransferRequest request, CancellationToken ct)
|
||||
|
||||
@@ -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<IActionResult> 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<IActionResult> Upload(Guid storeId, [FromForm] UploadKycDocumentRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UploadAsync(storeId, request, ct);
|
||||
|
||||
@@ -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<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "stores.view")]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> GetDashboard(Guid id, [FromQuery] string period)
|
||||
{
|
||||
var result = await service.GetDashboardAsync(id, period);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<IActionResult> 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<IActionResult> Reject(Guid id, RejectSupplierClaimRequest request, CancellationToken ct)
|
||||
{
|
||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "items.view")]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
|
||||
@@ -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<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "users.view")]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> Deactivate(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deactivated = await service.DeactivateAsync(id, ct);
|
||||
|
||||
@@ -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<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "vehicles.view")]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
|
||||
@@ -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<IActionResult> GetAll() =>
|
||||
Ok(await service.GetAllAsync());
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize(Policy = "warehouses.view")]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await service.DeleteAsync(id, ct);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
//namespace Distribution.Infra.Authorization;
|
||||
|
||||
namespace Distribution.Infra.Authorization;
|
||||
//public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
|
||||
//{
|
||||
// private readonly IPermissionService _permissionService;
|
||||
|
||||
public class PermissionAuthorizationHandler : AuthorizationHandler<PermissionRequirement>
|
||||
{
|
||||
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);
|
||||
// }
|
||||
//}
|
||||
|
||||
@@ -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<AuthorizationOptions> options)
|
||||
{
|
||||
_fallback = new DefaultAuthorizationPolicyProvider(options);
|
||||
}
|
||||
// public PermissionPolicyProvider(IOptions<AuthorizationOptions> options)
|
||||
// {
|
||||
// _fallback = new DefaultAuthorizationPolicyProvider(options);
|
||||
// }
|
||||
|
||||
public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
|
||||
// public Task<AuthorizationPolicy> GetDefaultPolicyAsync() => _fallback.GetDefaultPolicyAsync();
|
||||
|
||||
public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync();
|
||||
// public Task<AuthorizationPolicy?> GetFallbackPolicyAsync() => _fallback.GetFallbackPolicyAsync();
|
||||
|
||||
public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
|
||||
{
|
||||
if (policyName.Contains('.'))
|
||||
{
|
||||
var policy = new AuthorizationPolicyBuilder()
|
||||
.RequireAuthenticatedUser()
|
||||
.AddRequirements(new PermissionRequirement(policyName))
|
||||
.Build();
|
||||
return Task.FromResult<AuthorizationPolicy?>(policy);
|
||||
}
|
||||
// public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
|
||||
// {
|
||||
// if (policyName.Contains('.'))
|
||||
// {
|
||||
// var policy = new AuthorizationPolicyBuilder()
|
||||
// .RequireAuthenticatedUser()
|
||||
// .AddRequirements(new PermissionRequirement(policyName))
|
||||
// .Build();
|
||||
// return Task.FromResult<AuthorizationPolicy?>(policy);
|
||||
// }
|
||||
|
||||
return _fallback.GetPolicyAsync(policyName);
|
||||
}
|
||||
}
|
||||
// return _fallback.GetPolicyAsync(policyName);
|
||||
// }
|
||||
//}
|
||||
|
||||
@@ -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;
|
||||
// }
|
||||
//}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+20
-64
@@ -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<IPermissionRepository, PermissionRepository>();
|
||||
builder.Services.AddScoped<IScopeRepository, ScopeRepository>();
|
||||
builder.Services.AddScoped<IPermissionService, PermissionService>();
|
||||
builder.Services.AddScoped<IScopeAccessService, ScopeAccessService>();
|
||||
builder.Services.AddScoped<IAuthorizationHandler, PermissionAuthorizationHandler>();
|
||||
builder.Services.AddSingleton<IAuthorizationPolicyProvider, PermissionPolicyProvider>();
|
||||
// ── Auth Service connector ───────────────────────────────────────────────────
|
||||
builder.Services.Configure<AuthServiceOptions>(
|
||||
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<AuthServiceClient>(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<IAuthServiceClient, AuthServiceClient>();
|
||||
|
||||
if (!Guid.TryParse(subClaim, out var userId))
|
||||
{
|
||||
context.Fail("Token is missing a valid user id claim.");
|
||||
return;
|
||||
}
|
||||
|
||||
var userRepo = context.HttpContext.RequestServices.GetRequiredService<IUserRepository>();
|
||||
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<string[]>() ?? [];
|
||||
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<AuthMiddleware>();
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -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": "<RSAKeyValue><Modulus>1LlNkMBQNdpXJiDal7XMxkG/3ad+YBsMCuY9JD/abHMzniFXtQlovjfbeaaHJ0v1kvSo9731CJ0YC1qhPU5rPQwZwxOWZ9BOBZlMDghONdjOH/HyCUbb5Z18ibqc0QenFSnEYz+jkVZiayj8DV/+VUe+eKzpQTlU6aWtHvlbwfuXaDu+QvFlpLJ7/m8na+0s2nYhLX8Wfi4C/2AoNaYhFkIwYhMMGoaSHuIoQ5R6181Rh0gKvYopRW+IpTD5RV8bXV3AM6zOcoisOifBYROHwA5ZZpoHXuTvHYmPWW8kL8PKme7BwBldPi8KrJUroRE+WXA87aAA5Wtt1oxePcXvhQ==</Modulus><Exponent>AQAB</Exponent></RSAKeyValue>",
|
||||
"Audience": "AuthHexClient",
|
||||
|
||||
@@ -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<SaleResponse>`: `{ 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`:
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=...` |
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"sessionId":"879f34f3-9715-423d-aee2-535ba3a8accd","pid":14552,"acquiredAt":1787669985270}
|
||||
@@ -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<typeof schema>
|
||||
|
||||
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<FormValues>({
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Edit Brand' : 'Add Brand'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? 'Update the brand details below.' : 'Add a new brand to the catalogue.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
label="Brand Name"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="name"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. Coca-Cola"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="isActive"
|
||||
checked={field.value as boolean}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm font-medium cursor-pointer">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Brand'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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<BrandResponse[]>([])
|
||||
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<BrandResponse | null>(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<TableConfig<BrandResponse>>(() => ({
|
||||
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) => (
|
||||
<Badge variant={row.isActive ? 'default' : 'secondary'}>
|
||||
{row.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Brands</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Brands associated with catalogue items
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openCreate}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||
Add Brand
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
config={tableConfig}
|
||||
data={data}
|
||||
loading={loading}
|
||||
totalCount={total}
|
||||
onServerSideChange={({ page: p }) => {
|
||||
setPage(p.page)
|
||||
setPageSize(p.pageSize)
|
||||
}}
|
||||
/>
|
||||
|
||||
<BrandSheet
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
brand={editing}
|
||||
onSuccess={() => fetchData(page, pageSize)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { BrandsClient } from './_components/brands-client'
|
||||
|
||||
export default function BrandsPage() {
|
||||
return <BrandsClient />
|
||||
}
|
||||
+140
@@ -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<CategoryResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<CategoryResponse | null>(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<TableConfig<CategoryResponse>>(() => ({
|
||||
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) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{row.subCategories.length} sub-categories
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Status',
|
||||
fields: ['isActive'],
|
||||
width: 120,
|
||||
cellRender: (row) => (
|
||||
<Badge variant={row.isActive ? 'default' : 'secondary'}>
|
||||
{row.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Categories</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Product groupings and sub-categories
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openCreate}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||
Add Category
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
config={tableConfig}
|
||||
data={data}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<CategorySheet
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
category={editing}
|
||||
onSuccess={() => fetchData()}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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<typeof schema>
|
||||
|
||||
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<FormValues>({
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Edit Category' : 'Add Category'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? 'Update the category details below.' : 'Add a new product category.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
label="Category Name"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="name"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. Beverages"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="isActive"
|
||||
checked={field.value as boolean}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm font-medium cursor-pointer">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Category'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CategoriesClient } from './_components/categories-client'
|
||||
|
||||
export default function CategoriesPage() {
|
||||
return <CategoriesClient />
|
||||
}
|
||||
@@ -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<typeof schema>
|
||||
|
||||
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<CategoryResponse[]>([])
|
||||
const [brandsList, setBrandsList] = useState<BrandResponse[]>([])
|
||||
const [uomsList, setUomsList] = useState<UomResponse[]>([])
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Edit Item' : 'Add Item'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? 'Update the item details below.' : 'Add a new catalogue item.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sku"
|
||||
label="SKU"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="sku"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. ITEM-001"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
label="Item Name"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="name"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. Coca-Cola 330ml"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="categoryId"
|
||||
label="Category"
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Select value={field.value as string} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select category..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map((cat) => (
|
||||
<SelectItem key={cat.id} value={cat.id}>
|
||||
{cat.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="brandId"
|
||||
label="Brand"
|
||||
render={({ field }) => (
|
||||
<Select value={field.value as string ?? ''} onValueChange={(v) => field.onChange(v === '' ? undefined : v)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select brand..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">None</SelectItem>
|
||||
{brandsList.map((b) => (
|
||||
<SelectItem key={b.id} value={b.id}>
|
||||
{b.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="baseUomId"
|
||||
label="Base UOM"
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Select value={field.value as string} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select unit of measure..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uomsList.map((u) => (
|
||||
<SelectItem key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="salePrice"
|
||||
label="Sale Price"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="salePrice"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={String(field.value as number)}
|
||||
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="0.00"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="isActive"
|
||||
checked={field.value as boolean}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm font-medium cursor-pointer">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Item'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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<ItemSummaryResponse[]>([])
|
||||
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<ItemSummaryResponse | null>(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<TableConfig<ItemSummaryResponse>>(() => ({
|
||||
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) => (
|
||||
<span className="text-sm">
|
||||
{row.brandName ?? '—'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'baseUomName',
|
||||
title: 'Base UOM',
|
||||
fields: [{ key: 'baseUomName' }],
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
key: 'salePrice',
|
||||
title: 'Sale Price',
|
||||
fields: ['salePrice'],
|
||||
width: 120,
|
||||
cellRender: (row) => (
|
||||
<span className="text-sm">Rs. {row.salePrice.toFixed(2)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Status',
|
||||
fields: ['isActive'],
|
||||
width: 120,
|
||||
cellRender: (row) => (
|
||||
<Badge variant={row.isActive ? 'default' : 'secondary'}>
|
||||
{row.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
],
|
||||
rowActions: {
|
||||
actions: () => [
|
||||
{
|
||||
action: 'edit',
|
||||
label: 'Edit item',
|
||||
onClick: openEdit,
|
||||
},
|
||||
],
|
||||
},
|
||||
}), [pageSize])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Items</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Products available for sale and distribution
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openCreate}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||
Add Item
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
config={tableConfig}
|
||||
data={data}
|
||||
loading={loading}
|
||||
totalCount={total}
|
||||
onServerSideChange={({ page: p }) => {
|
||||
setPage(p.page)
|
||||
setPageSize(p.pageSize)
|
||||
}}
|
||||
/>
|
||||
|
||||
<ItemSheet
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
item={editing}
|
||||
onSuccess={() => fetchData(page, pageSize)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ItemsClient } from './_components/items-client'
|
||||
|
||||
export default function ItemsPage() {
|
||||
return <ItemsClient />
|
||||
}
|
||||
@@ -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<typeof schema>
|
||||
|
||||
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<FormValues>({
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Edit Unit of Measure' : 'Add Unit of Measure'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? 'Update the unit of measure details below.' : 'Add a new unit of measure.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
label="Unit Name"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="name"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. Kilogram"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="isActive"
|
||||
checked={field.value as boolean}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm font-medium cursor-pointer">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create UOM'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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<UomResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<UomResponse | null>(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<TableConfig<UomResponse>>(() => ({
|
||||
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) => (
|
||||
<Badge variant={row.isActive ? 'default' : 'secondary'}>
|
||||
{row.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Units of Measure</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Units of measure and conversion factors
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openCreate}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||
Add UOM
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
config={tableConfig}
|
||||
data={data}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<UomSheet
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
uom={editing}
|
||||
onSuccess={() => fetchData()}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { UomsClient } from './_components/uoms-client'
|
||||
|
||||
export default function UomsPage() {
|
||||
return <UomsClient />
|
||||
}
|
||||
@@ -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<typeof schema>
|
||||
|
||||
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<UserResponse[]>([])
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Edit Driver' : 'Add Driver'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? 'Update the driver profile below.' : 'Add a new driver profile to the system.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="userId"
|
||||
label="User"
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Select value={field.value as string} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a user" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{usersList.map((u) => (
|
||||
<SelectItem key={u.id} value={u.id}>
|
||||
{u.fullName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="licenseNumber"
|
||||
label="License Number"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="licenseNumber"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. B1234567"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="licenseExpiry"
|
||||
label="License Expiry"
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
type="date"
|
||||
id="licenseExpiry"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="medicalClearanceExpiry"
|
||||
label="Medical Clearance Expiry"
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
type="date"
|
||||
id="medicalClearanceExpiry"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="isActive"
|
||||
checked={field.value as boolean}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm font-medium cursor-pointer">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Driver'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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<DriverProfileResponse[]>([])
|
||||
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<DriverProfileResponse | null>(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<TableConfig<DriverProfileResponse>>(() => ({
|
||||
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) => (
|
||||
<Badge variant={row.isActive ? 'default' : 'secondary'}>
|
||||
{row.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
],
|
||||
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 (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Driver Profiles</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Driver records including license and medical clearance dates
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openCreate}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||
Add Driver
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
config={tableConfig}
|
||||
data={data}
|
||||
loading={loading}
|
||||
totalCount={total}
|
||||
onServerSideChange={({ page: p }) => {
|
||||
setPage(p.page)
|
||||
setPageSize(p.pageSize)
|
||||
}}
|
||||
/>
|
||||
|
||||
<DriverSheet
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
driver={editing}
|
||||
onSuccess={() => fetchData(page, pageSize)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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<typeof schema>
|
||||
|
||||
export default function CompliancePage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const form = useForm<FormValues>({
|
||||
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 (
|
||||
<div className="flex flex-col gap-6 max-w-lg">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Compliance</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">Rules that enforce license and medical clearance before a ride starts</p>
|
||||
</div>
|
||||
{loading ? <p className="text-muted-foreground text-sm">Loading...</p> : (
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||
<FieldGroup>
|
||||
<FormField control={form.control} name="enforceLicenseExpiry"
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox id="enforceLicenseExpiry" checked={field.value as boolean} onCheckedChange={field.onChange} />
|
||||
<label htmlFor="enforceLicenseExpiry" className="text-sm font-medium cursor-pointer">Enforce license expiry check</label>
|
||||
</div>
|
||||
)} />
|
||||
<FormField control={form.control} name="enforceMedicalClearance"
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox id="enforceMedicalClearance" checked={field.value as boolean} onCheckedChange={field.onChange} />
|
||||
<label htmlFor="enforceMedicalClearance" className="text-sm font-medium cursor-pointer">Enforce medical clearance check</label>
|
||||
</div>
|
||||
)} />
|
||||
</FieldGroup>
|
||||
<div>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>{form.formState.isSubmitting ? 'Saving…' : 'Save Changes'}</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { DriversClient } from './_components/drivers-client'
|
||||
|
||||
export default function DriversPage() {
|
||||
return <DriversClient />
|
||||
}
|
||||
@@ -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<typeof schema>
|
||||
|
||||
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<FormValues>({
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Edit Region' : 'Add Region'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? 'Update the region details below.' : 'Add a new geographic region to the distribution network.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
label="Region Name"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="name"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. Western Province"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="isActive"
|
||||
checked={field.value as boolean}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm font-medium cursor-pointer">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Region'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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<RegionResponse[]>([])
|
||||
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<RegionResponse | null>(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<TableConfig<RegionResponse>>(() => ({
|
||||
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) => (
|
||||
<Badge variant={row.isActive ? 'default' : 'secondary'}>
|
||||
{row.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Regions</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Geographic territories the distribution network operates in
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openCreate}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||
Add Region
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
config={tableConfig}
|
||||
data={data}
|
||||
loading={loading}
|
||||
totalCount={total}
|
||||
onServerSideChange={({ page: p }) => {
|
||||
setPage(p.page)
|
||||
setPageSize(p.pageSize)
|
||||
}}
|
||||
/>
|
||||
|
||||
<RegionSheet
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
region={editing}
|
||||
onSuccess={() => fetchData(page, pageSize)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { RegionsClient } from './_components/regions-client'
|
||||
|
||||
export default function RegionsPage() {
|
||||
return <RegionsClient />
|
||||
}
|
||||
@@ -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<typeof schema>
|
||||
|
||||
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<RegionResponse[]>([])
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Edit Route' : 'Add Route'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? 'Update the route details below.' : 'Add a new delivery route to a region.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="regionId"
|
||||
label="Region"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Select value={field.value as string} onValueChange={field.onChange}>
|
||||
<SelectTrigger aria-invalid={invalid}>
|
||||
<SelectValue placeholder="Select a region">
|
||||
{regions.find((region) => region.id === field.value)?.name}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{regions.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="routeName"
|
||||
label="Route Name"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="routeName"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. Route A1"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="startPoint"
|
||||
label="Start Point"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="startPoint"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. Colombo Fort"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="endPoint"
|
||||
label="End Point"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="endPoint"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. Kaduwela"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="length"
|
||||
label="Length (km)"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="length"
|
||||
type="number"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as number}
|
||||
onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. 25"
|
||||
min={0}
|
||||
step="0.1"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="isActive"
|
||||
checked={field.value as boolean}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm font-medium cursor-pointer">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Route'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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<RouteResponse[]>([])
|
||||
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<RouteResponse | null>(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<TableConfig<RouteResponse>>(() => ({
|
||||
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) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{row.startPoint} <span className="mx-1 text-foreground">→</span> {row.endPoint}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'length',
|
||||
title: 'Length',
|
||||
fields: [{ key: 'length', transform: (v) => `${v} km` }],
|
||||
width: 100,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Status',
|
||||
fields: ['isActive'],
|
||||
width: 120,
|
||||
cellRender: (row) => (
|
||||
<Badge variant={row.isActive ? 'default' : 'secondary'}>
|
||||
{row.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Routes</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Delivery paths within a region assigned to rides
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openCreate}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||
Add Route
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
config={tableConfig}
|
||||
data={data}
|
||||
loading={loading}
|
||||
totalCount={total}
|
||||
onServerSideChange={({ page: p }) => {
|
||||
setPage(p.page)
|
||||
setPageSize(p.pageSize)
|
||||
}}
|
||||
/>
|
||||
|
||||
<RouteSheet
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
route={editing}
|
||||
onSuccess={() => fetchData(page, pageSize)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { RoutesClient } from './_components/routes-client'
|
||||
|
||||
export default function RoutesPage() {
|
||||
return <RoutesClient />
|
||||
}
|
||||
@@ -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<typeof schema>
|
||||
|
||||
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<FormValues>({
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Edit Vehicle' : 'Add Vehicle'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? 'Update the vehicle details below.' : 'Add a new vehicle to the fleet.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="regNumber"
|
||||
label="Registration Number"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="regNumber"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. WP-CAB-1234"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="numberPlate"
|
||||
label="Number Plate"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="numberPlate"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. CAB-1234"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
label="Vehicle Type"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Select value={field.value as string} onValueChange={field.onChange}>
|
||||
<SelectTrigger aria-invalid={invalid}>
|
||||
<SelectValue placeholder="Select vehicle type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{VEHICLE_TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t}>{t}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Vehicle'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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<VehicleResponse[]>([])
|
||||
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<VehicleResponse | null>(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<TableConfig<VehicleResponse>>(() => ({
|
||||
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) => (
|
||||
<Badge variant="outline">{row.type}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Status',
|
||||
fields: ['isActive'],
|
||||
width: 120,
|
||||
cellRender: (row) => (
|
||||
<Badge variant={row.isActive ? 'default' : 'secondary'}>
|
||||
{row.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Vehicles</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Fleet vehicles available for delivery rides
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openCreate}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||
Add Vehicle
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
config={tableConfig}
|
||||
data={data}
|
||||
loading={loading}
|
||||
totalCount={total}
|
||||
onServerSideChange={({ page: p }) => {
|
||||
setPage(p.page)
|
||||
setPageSize(p.pageSize)
|
||||
}}
|
||||
/>
|
||||
|
||||
<VehicleSheet
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
vehicle={editing}
|
||||
onSuccess={() => fetchData(page, pageSize)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { VehiclesClient } from './_components/vehicles-client'
|
||||
|
||||
export default function VehiclesPage() {
|
||||
return <VehiclesClient />
|
||||
}
|
||||
@@ -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<WarehouseType, string> = {
|
||||
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<typeof schema>
|
||||
|
||||
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<VehicleResponse[]>([])
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Edit Warehouse' : 'Add Warehouse'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? 'Update the warehouse details below.' : 'Add a new storage location to the network.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
label="Warehouse Name"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="name"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. Colombo Central Warehouse"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="location"
|
||||
label="Location"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="location"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. No. 10, Main Street, Colombo 01"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
label="Warehouse Type"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Select value={field.value as string} onValueChange={field.onChange}>
|
||||
<SelectTrigger aria-invalid={invalid}>
|
||||
<SelectValue placeholder="Select warehouse type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{WAREHOUSE_TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t}>{WAREHOUSE_TYPE_LABELS[t]}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="vehicleId"
|
||||
label="Assigned Vehicle (optional)"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={(field.value as string | null) ?? ''}
|
||||
onValueChange={(v) => field.onChange(v === '' ? null : v)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">None</SelectItem>
|
||||
{vehicles.map((v) => (
|
||||
<SelectItem key={v.id} value={v.id}>
|
||||
{v.regNumber} — {v.numberPlate}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="isActive"
|
||||
checked={field.value as boolean}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm font-medium cursor-pointer">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Warehouse'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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<WarehouseResponse[]>([])
|
||||
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<WarehouseResponse | null>(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<TableConfig<WarehouseResponse>>(() => ({
|
||||
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) => (
|
||||
<Badge variant="outline">{row.type}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Status',
|
||||
fields: ['isActive'],
|
||||
width: 120,
|
||||
cellRender: (row) => (
|
||||
<Badge variant={row.isActive ? 'default' : 'secondary'}>
|
||||
{row.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Warehouses</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Storage locations where stock is held and loaded from
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openCreate}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||
Add Warehouse
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
config={tableConfig}
|
||||
data={data}
|
||||
loading={loading}
|
||||
totalCount={total}
|
||||
onServerSideChange={({ page: p }) => {
|
||||
setPage(p.page)
|
||||
setPageSize(p.pageSize)
|
||||
}}
|
||||
/>
|
||||
|
||||
<WarehouseSheet
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
warehouse={editing}
|
||||
onSuccess={() => fetchData(page, pageSize)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { WarehousesClient } from './_components/warehouses-client'
|
||||
|
||||
export default function WarehousesPage() {
|
||||
return <WarehousesClient />
|
||||
}
|
||||
+172
@@ -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<DeliveryTaskStatus, 'default' | 'secondary' | 'destructive'> = {
|
||||
Pending: 'secondary',
|
||||
Delivered: 'default',
|
||||
PartiallyDelivered: 'secondary',
|
||||
Failed: 'destructive',
|
||||
}
|
||||
|
||||
export function DeliveryTasksClient() {
|
||||
const [data, setData] = useState<DeliveryTaskResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [rides, setRides] = useState<RideResponse[]>([])
|
||||
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<TableConfig<DeliveryTaskResponse>>(() => ({
|
||||
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) => (
|
||||
<span className="text-sm">{row.sequence}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Status',
|
||||
fields: ['status'],
|
||||
width: 180,
|
||||
cellRender: (row) => (
|
||||
<Badge variant={TASK_STATUS_VARIANT[row.status]}>{row.status}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
title: 'Created',
|
||||
fields: [{ key: 'createdAt', transform: (v) => fmtDate(v as string) }],
|
||||
width: 140,
|
||||
},
|
||||
],
|
||||
}), [pageSize])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Delivery Tasks</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Individual store drop-offs within the selected ride
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full max-w-sm">
|
||||
<Select
|
||||
value={selectedRideId || '__none__'}
|
||||
onValueChange={(value) => {
|
||||
const nextRideId = !value || value === '__none__' ? '' : value
|
||||
setSelectedRideId(nextRideId)
|
||||
setPage(1)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a ride..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__" disabled={rides.length > 0}>Select a ride...</SelectItem>
|
||||
{rides.map((ride) => (
|
||||
<SelectItem key={ride.id} value={ride.id}>
|
||||
{ride.routeName} - {ride.vehicleNumberPlate}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedRide && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Status: {selectedRide.status} | Driver: {selectedRide.driverFullName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
config={tableConfig}
|
||||
data={data}
|
||||
loading={loading}
|
||||
totalCount={total}
|
||||
onServerSideChange={({ page: p }) => {
|
||||
setPage(p.page)
|
||||
setPageSize(p.pageSize)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { DeliveryTasksClient } from './_components/delivery-tasks-client'
|
||||
|
||||
export default function DeliveryTasksPage() {
|
||||
return <DeliveryTasksClient />
|
||||
}
|
||||
+156
@@ -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<LoadingSheetStatus, 'default' | 'secondary' | 'destructive'> = {
|
||||
Pending: 'secondary',
|
||||
DispatchConfirmed: 'default',
|
||||
ReceiptConfirmed: 'default',
|
||||
DiscrepancyFlagged: 'destructive',
|
||||
Resolved: 'secondary',
|
||||
}
|
||||
|
||||
export function LoadingSheetsClient() {
|
||||
const [data, setData] = useState<LoadingSheetResponse[]>([])
|
||||
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<TableConfig<LoadingSheetResponse>>(() => ({
|
||||
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) => (
|
||||
<span className="font-mono text-sm">{truncId(row.id)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'warehouseName',
|
||||
title: 'Warehouse',
|
||||
fields: [{ key: 'warehouseName', primary: true }],
|
||||
minWidth: 180,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Status',
|
||||
fields: ['status'],
|
||||
width: 180,
|
||||
cellRender: (row) => (
|
||||
<Badge variant={LS_STATUS_VARIANT[row.status]}>{row.status}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'itemCount',
|
||||
title: 'Items',
|
||||
fields: ['items'],
|
||||
width: 120,
|
||||
cellRender: (row) => (
|
||||
<span className="text-sm text-muted-foreground">{row.items.length} items</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Loading Sheets</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Track goods loaded onto a vehicle before dispatch
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
config={tableConfig}
|
||||
data={data}
|
||||
loading={loading}
|
||||
totalCount={total}
|
||||
onServerSideChange={({ page: p }) => {
|
||||
setPage(p.page)
|
||||
setPageSize(p.pageSize)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LoadingSheetsClient } from './_components/loading-sheets-client'
|
||||
|
||||
export default function LoadingSheetsPage() {
|
||||
return <LoadingSheetsClient />
|
||||
}
|
||||
@@ -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<typeof schema>
|
||||
|
||||
interface RideSheetProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
function ensureItems<T>(value: { items?: T[] } | null | undefined): T[] {
|
||||
return Array.isArray(value?.items) ? value.items : []
|
||||
}
|
||||
|
||||
export function RideSheet({ open, onClose, onSuccess }: RideSheetProps) {
|
||||
const [vehiclesList, setVehiclesList] = useState<VehicleResponse[]>([])
|
||||
const [driversList, setDriversList] = useState<DriverProfileResponse[]>([])
|
||||
const [routesList, setRoutesList] = useState<RouteResponse[]>([])
|
||||
const [warehousesList, setWarehousesList] = useState<WarehouseResponse[]>([])
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Ride</DialogTitle>
|
||||
<DialogDescription>Start a new delivery or sales run.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rideType"
|
||||
label="Ride Type"
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Select value={field.value as string} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select ride type..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="" disabled>Select ride type...</SelectItem>
|
||||
<SelectItem value="Delivery">Delivery</SelectItem>
|
||||
<SelectItem value="Sale">Sale</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="vehicleId"
|
||||
label="Vehicle"
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Select value={field.value as string} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select available vehicle..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="" disabled>Select available vehicle...</SelectItem>
|
||||
{vehiclesList.map((v) => (
|
||||
<SelectItem key={v.id} value={v.id}>
|
||||
{v.regNumber} — {v.numberPlate}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="driverProfileId"
|
||||
label="Driver"
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Select value={field.value as string} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select driver..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="" disabled>Select driver...</SelectItem>
|
||||
{driversList.map((d) => (
|
||||
<SelectItem key={d.id} value={d.id}>
|
||||
{d.userFullName} — {d.licenseNumber}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="routeId"
|
||||
label="Route"
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Select value={field.value as string} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select route..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="" disabled>Select route...</SelectItem>
|
||||
{routesList.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>
|
||||
{r.routeName} ({r.startPoint} → {r.endPoint})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="sourceWarehouseId"
|
||||
label="Source Warehouse"
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Select value={field.value as string} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select warehouse..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="" disabled>Select warehouse...</SelectItem>
|
||||
{warehousesList.map((w) => (
|
||||
<SelectItem key={w.id} value={w.id}>
|
||||
{w.name} — {w.location}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="meterstart"
|
||||
label="Meter Start (km)"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="meterstart"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
type="number"
|
||||
min={0}
|
||||
value={Number(field.value as number)}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="0"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? 'Creating…' : 'Create Ride'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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<RideStatus, 'default' | 'secondary' | 'destructive'> = {
|
||||
Draft: 'secondary',
|
||||
Open: 'secondary',
|
||||
Active: 'default',
|
||||
CancelRequested: 'destructive',
|
||||
Cancelled: 'destructive',
|
||||
Closed: 'secondary',
|
||||
}
|
||||
|
||||
const RIDE_TYPE_VARIANT: Record<RideType, 'default' | 'secondary'> = {
|
||||
Delivery: 'default',
|
||||
Sale: 'secondary',
|
||||
}
|
||||
|
||||
export function RidesClient() {
|
||||
const [data, setData] = useState<RideResponse[]>([])
|
||||
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<TableConfig<RideResponse>>(() => ({
|
||||
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) => (
|
||||
<div>
|
||||
<p className="text-sm font-medium">{row.vehicleNumberPlate}</p>
|
||||
<p className="text-xs text-muted-foreground">{row.driverFullName}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'routeName',
|
||||
title: 'Route',
|
||||
fields: [{ key: 'routeName', primary: true }],
|
||||
minWidth: 160,
|
||||
},
|
||||
{
|
||||
key: 'rideType',
|
||||
title: 'Type',
|
||||
fields: ['rideType'],
|
||||
width: 120,
|
||||
cellRender: (row) => (
|
||||
<Badge variant={RIDE_TYPE_VARIANT[row.rideType]}>{row.rideType}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Status',
|
||||
fields: ['status'],
|
||||
width: 160,
|
||||
cellRender: (row) => (
|
||||
<Badge variant={RIDE_STATUS_VARIANT[row.status]}>{row.status}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Rides</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Delivery runs assigned to a driver and vehicle
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setSheetOpen(true)}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||
Add Ride
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
config={tableConfig}
|
||||
data={data}
|
||||
loading={loading}
|
||||
totalCount={total}
|
||||
onServerSideChange={({ page: p }) => {
|
||||
setPage(p.page)
|
||||
setPageSize(p.pageSize)
|
||||
}}
|
||||
/>
|
||||
|
||||
<RideSheet
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
onSuccess={() => fetchData(page, pageSize)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { RidesClient } from './_components/rides-client'
|
||||
|
||||
export default function RidesPage() {
|
||||
return <RidesClient />
|
||||
}
|
||||
@@ -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<typeof schema>
|
||||
|
||||
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<RegionResponse[]>([])
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Edit Price List' : 'Add Price List'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? 'Update the price list details below.' : 'Create a new price list.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
label="Name"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="name"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. Standard Q1 2025"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="regionId"
|
||||
label="Region"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value as string}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All Regions" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">All Regions</SelectItem>
|
||||
{regionsList.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="effectiveFrom"
|
||||
label="Effective From"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="effectiveFrom"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
type="date"
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="effectiveTo"
|
||||
label="Effective To"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="effectiveTo"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
type="date"
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="isActive"
|
||||
checked={field.value as boolean}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm font-medium cursor-pointer">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Price List'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
+150
@@ -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<PriceListResponse[]>([])
|
||||
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<PriceListResponse | null>(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<TableConfig<PriceListResponse>>(() => ({
|
||||
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) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{row.regionName ?? 'All regions'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'dateRange',
|
||||
title: 'Effective Period',
|
||||
fields: ['effectiveFrom', 'effectiveTo'],
|
||||
width: 220,
|
||||
cellRender: (row) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{fmtDate(row.effectiveFrom)} → {fmtDate(row.effectiveTo)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Status',
|
||||
fields: ['isActive'],
|
||||
width: 120,
|
||||
cellRender: (row) => (
|
||||
<Badge variant={row.isActive ? 'default' : 'secondary'}>
|
||||
{row.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
],
|
||||
rowActions: {
|
||||
actions: () => [
|
||||
{
|
||||
action: 'edit',
|
||||
label: 'Edit price list',
|
||||
onClick: openEdit,
|
||||
},
|
||||
],
|
||||
},
|
||||
}), [pageSize])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Price Lists</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Item prices scoped by region and active date range
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openCreate}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||
Add Price List
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
config={tableConfig}
|
||||
data={data}
|
||||
loading={loading}
|
||||
totalCount={total}
|
||||
onServerSideChange={({ page: p }) => {
|
||||
setPage(p.page)
|
||||
setPageSize(p.pageSize)
|
||||
}}
|
||||
/>
|
||||
|
||||
<PriceListSheet
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
priceList={editing}
|
||||
onSuccess={() => fetchData(page, pageSize)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PriceListsClient } from './_components/price-lists-client'
|
||||
|
||||
export default function PriceListsPage() {
|
||||
return <PriceListsClient />
|
||||
}
|
||||
@@ -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<typeof schema>
|
||||
|
||||
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<RegionResponse[]>([])
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Edit Scheme' : 'Add Scheme'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? 'Update the scheme details below.' : 'Create a new promotion or discount scheme.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
label="Name"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="name"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="e.g. Buy 2 Get 1 Free"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ruleType"
|
||||
label="Rule Type"
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value as string}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select rule type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="BuyXGetY">BuyXGetY</SelectItem>
|
||||
<SelectItem value="SlabDiscount">SlabDiscount</SelectItem>
|
||||
<SelectItem value="FlatPercent">FlatPercent</SelectItem>
|
||||
<SelectItem value="FreeBundle">FreeBundle</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="regionId"
|
||||
label="Region"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value as string}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="All Regions" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="">All Regions</SelectItem>
|
||||
{regionsList.map((r) => (
|
||||
<SelectItem key={r.id} value={r.id}>{r.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dateRangeFrom"
|
||||
label="Date Range From"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="dateRangeFrom"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
type="date"
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="dateRangeTo"
|
||||
label="Date Range To"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="dateRangeTo"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
type="date"
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isActive"
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="isActive"
|
||||
checked={field.value as boolean}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm font-medium cursor-pointer">
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? 'Saving…' : isEdit ? 'Save Changes' : 'Create Scheme'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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<SchemeRuleType, 'default' | 'secondary'> = {
|
||||
BuyXGetY: 'default',
|
||||
SlabDiscount: 'secondary',
|
||||
FlatPercent: 'secondary',
|
||||
FreeBundle: 'default',
|
||||
}
|
||||
|
||||
export function SchemesClient() {
|
||||
const [data, setData] = useState<SchemeResponse[]>([])
|
||||
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<SchemeResponse | null>(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<TableConfig<SchemeResponse>>(() => ({
|
||||
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) => (
|
||||
<Badge variant={RULE_TYPE_VARIANT[row.ruleType]}>{row.ruleType}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'regionName',
|
||||
title: 'Region',
|
||||
fields: ['regionName'],
|
||||
width: 140,
|
||||
cellRender: (row) => (
|
||||
<span className="text-sm text-muted-foreground">{row.regionName ?? 'All'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'dateRange',
|
||||
title: 'Date Range',
|
||||
fields: ['dateRangeFrom', 'dateRangeTo'],
|
||||
width: 220,
|
||||
cellRender: (row) => (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{fmtDate(row.dateRangeFrom)} → {fmtDate(row.dateRangeTo)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Status',
|
||||
fields: ['isActive'],
|
||||
width: 120,
|
||||
cellRender: (row) => (
|
||||
<Badge variant={row.isActive ? 'default' : 'secondary'}>
|
||||
{row.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
],
|
||||
rowActions: {
|
||||
actions: () => [
|
||||
{
|
||||
action: 'edit',
|
||||
label: 'Edit scheme',
|
||||
onClick: openEdit,
|
||||
},
|
||||
],
|
||||
},
|
||||
}), [pageSize])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Schemes</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Promotions, bundle deals, and slab discounts applied at sale
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={openCreate}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||
Add Scheme
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
config={tableConfig}
|
||||
data={data}
|
||||
loading={loading}
|
||||
totalCount={total}
|
||||
onServerSideChange={({ page: p }) => {
|
||||
setPage(p.page)
|
||||
setPageSize(p.pageSize)
|
||||
}}
|
||||
/>
|
||||
|
||||
<SchemeSheet
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
scheme={editing}
|
||||
onSuccess={() => fetchData(page, pageSize)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SchemesClient } from './_components/schemes-client'
|
||||
|
||||
export default function SchemesPage() {
|
||||
return <SchemesClient />
|
||||
}
|
||||
@@ -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<GrnResponse[]>([])
|
||||
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<TableConfig<GrnResponse>>(() => ({
|
||||
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) => (
|
||||
<span className="font-mono text-sm">{truncId(row.id)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'purchaseOrderId',
|
||||
title: 'PO ID',
|
||||
fields: ['purchaseOrderId'],
|
||||
width: 140,
|
||||
cellRender: (row) => (
|
||||
<span className="font-mono text-sm">{truncId(row.purchaseOrderId)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'warehouseName',
|
||||
title: 'Warehouse',
|
||||
fields: [{ key: 'warehouseName', primary: true }],
|
||||
minWidth: 180,
|
||||
},
|
||||
{
|
||||
key: 'itemCount',
|
||||
title: 'Items',
|
||||
fields: ['items'],
|
||||
width: 120,
|
||||
cellRender: (row) => (
|
||||
<span className="text-sm text-muted-foreground">{row.items.length} items</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Goods Receipts</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Stock received into a warehouse against a purchase order
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
config={tableConfig}
|
||||
data={data}
|
||||
loading={loading}
|
||||
totalCount={total}
|
||||
onServerSideChange={({ page: p }) => {
|
||||
setPage(p.page)
|
||||
setPageSize(p.pageSize)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { GrnClient } from './_components/grn-client'
|
||||
|
||||
export default function GrnPage() {
|
||||
return <GrnClient />
|
||||
}
|
||||
+147
@@ -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<PurchaseOrderStatus, 'default' | 'secondary' | 'destructive'> = {
|
||||
Issued: 'default',
|
||||
PartiallyReceived: 'secondary',
|
||||
Received: 'secondary',
|
||||
Closed: 'secondary',
|
||||
Cancelled: 'destructive',
|
||||
}
|
||||
|
||||
export function PurchaseOrdersClient() {
|
||||
const [data, setData] = useState<PurchaseOrderResponse[]>([])
|
||||
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<TableConfig<PurchaseOrderResponse>>(() => ({
|
||||
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) => (
|
||||
<span className="font-mono text-sm">{truncId(row.id)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'supplierName',
|
||||
title: 'Supplier',
|
||||
fields: [{ key: 'supplierName', primary: true }],
|
||||
minWidth: 180,
|
||||
},
|
||||
{
|
||||
key: 'itemCount',
|
||||
title: 'Items',
|
||||
fields: ['items'],
|
||||
width: 120,
|
||||
cellRender: (row) => (
|
||||
<span className="text-sm text-muted-foreground">{row.items.length} items</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Status',
|
||||
fields: ['status'],
|
||||
width: 160,
|
||||
cellRender: (row) => (
|
||||
<Badge variant={PO_STATUS_VARIANT[row.status]}>{row.status}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Purchase Orders</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Orders placed with suppliers to restock inventory
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
config={tableConfig}
|
||||
data={data}
|
||||
loading={loading}
|
||||
totalCount={total}
|
||||
onServerSideChange={({ page: p }) => {
|
||||
setPage(p.page)
|
||||
setPageSize(p.pageSize)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PurchaseOrdersClient } from './_components/purchase-orders-client'
|
||||
|
||||
export default function PurchaseOrdersPage() {
|
||||
return <PurchaseOrdersClient />
|
||||
}
|
||||
@@ -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<typeof schema>
|
||||
|
||||
interface SaleSheetProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
function ensureItems<T>(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<StoreResponse[]>([])
|
||||
const [ridesList, setRidesList] = useState<RideResponse[]>([])
|
||||
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="flex max-h-[85vh] flex-col overflow-hidden sm:max-w-6xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Sale</DialogTitle>
|
||||
<DialogDescription>Record a sale for a store with one or more items.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex min-h-0 flex-1 flex-col gap-6">
|
||||
<div className="grid min-h-0 gap-6 lg:grid-cols-3">
|
||||
<FieldGroup className="lg:col-span-2">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="storeId"
|
||||
label="Store"
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Select value={field.value as string} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select store..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="" disabled>Select store...</SelectItem>
|
||||
{storesList.map((store) => (
|
||||
<SelectItem key={store.id} value={store.id}>
|
||||
{store.storeName} - {store.ownerName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rideId"
|
||||
label="Ride"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={(field.value as string) || '__none__'}
|
||||
onValueChange={(value) => field.onChange(value === '__none__' ? '' : value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select ride..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">No ride</SelectItem>
|
||||
{ridesList.map((ride) => (
|
||||
<SelectItem key={ride.id} value={ride.id}>
|
||||
{ride.routeName} - {ride.vehicleNumberPlate}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="fulfillImmediately"
|
||||
render={({ field }) => (
|
||||
<label className="flex items-center gap-3 text-sm font-medium">
|
||||
<Checkbox
|
||||
checked={Boolean(field.value)}
|
||||
onCheckedChange={(checked) => field.onChange(Boolean(checked))}
|
||||
/>
|
||||
Fulfill immediately
|
||||
</label>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="rounded-2xl border p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground">Sale Summary</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Quantity and unit price are handled in the selected item's base UOM.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">Total</p>
|
||||
<p className="text-lg font-semibold text-foreground">Rs. {totalAmount.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
|
||||
<div className="flex min-h-0 flex-col rounded-2xl border p-4 lg:col-span-1">
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground">Items</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Only this panel scrolls when multiple items are added.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => append(createDefaultItem())}>
|
||||
Add Item
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto pr-1">
|
||||
{fields.map((fieldItem, index) => {
|
||||
const selectedItem = itemsById.get(watchedItems[index]?.itemId ?? '')
|
||||
|
||||
return (
|
||||
<div key={fieldItem.id} className="space-y-4 rounded-2xl border p-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`items.${index}.itemId`}
|
||||
label="Item"
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value as string}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value)
|
||||
const nextItem = value ? itemsById.get(value) : undefined
|
||||
if (nextItem) {
|
||||
form.setValue(`items.${index}.unitPrice`, nextItem.salePrice, { shouldValidate: true })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select item..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="" disabled>Select item...</SelectItem>
|
||||
{itemsList.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
{item.name} - {item.sku}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`items.${index}.quantity`}
|
||||
label="Quantity"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id={`items.${index}.quantity`}
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
type="number"
|
||||
min={1}
|
||||
value={Number(field.value as number)}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="1"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`items.${index}.unitPrice`}
|
||||
label="Unit Price"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id={`items.${index}.unitPrice`}
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
type="number"
|
||||
min={0.01}
|
||||
step="0.01"
|
||||
value={Number(field.value as number)}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="0.00"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{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.'}
|
||||
</p>
|
||||
|
||||
{fields.length > 1 && (
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => remove(index)}>
|
||||
Remove Item
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? 'Creating...' : 'Create Sale'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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<SaleStatus, 'default' | 'secondary' | 'destructive'> = {
|
||||
Pending: 'secondary',
|
||||
Completed: 'default',
|
||||
Cancelled: 'destructive',
|
||||
}
|
||||
|
||||
const PAYMENT_STATUS_VARIANT: Record<PaymentStatus, 'default' | 'secondary' | 'destructive'> = {
|
||||
Pending: 'secondary',
|
||||
PartiallyPaid: 'secondary',
|
||||
Paid: 'default',
|
||||
}
|
||||
|
||||
export function SalesClient() {
|
||||
const [data, setData] = useState<SaleResponse[]>([])
|
||||
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<TableConfig<SaleResponse>>(() => ({
|
||||
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) => (
|
||||
<span className="font-mono text-sm">{truncId(row.id)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'storeId',
|
||||
title: 'Store ID',
|
||||
fields: ['storeId'],
|
||||
width: 140,
|
||||
cellRender: (row) => (
|
||||
<span className="font-mono text-sm">{truncId(row.storeId)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'totalAmount',
|
||||
title: 'Total',
|
||||
fields: ['totalAmount'],
|
||||
width: 140,
|
||||
cellRender: (row) => (
|
||||
<span className="text-sm font-medium">{fmtAmount(row.totalAmount)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Status',
|
||||
fields: ['status'],
|
||||
width: 140,
|
||||
cellRender: (row) => (
|
||||
<Badge variant={SALE_STATUS_VARIANT[row.status]}>{row.status}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'paymentStatus',
|
||||
title: 'Payment',
|
||||
fields: ['paymentStatus'],
|
||||
width: 160,
|
||||
cellRender: (row) => (
|
||||
<Badge variant={PAYMENT_STATUS_VARIANT[row.paymentStatus]}>{row.paymentStatus}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Sales</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Sales transactions recorded against stores
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setSheetOpen(true)}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||
Add Sale
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
config={tableConfig}
|
||||
data={data}
|
||||
loading={loading}
|
||||
totalCount={total}
|
||||
onServerSideChange={({ page: p }) => {
|
||||
setPage(p.page)
|
||||
setPageSize(p.pageSize)
|
||||
}}
|
||||
/>
|
||||
|
||||
<SaleSheet
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
onSuccess={() => fetchData(page, pageSize)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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<OrderStatus, 'default' | 'secondary' | 'destructive'> = {
|
||||
Pending: 'secondary',
|
||||
PartiallyFulfilled: 'secondary',
|
||||
Fulfilled: 'default',
|
||||
Cancelled: 'destructive',
|
||||
}
|
||||
|
||||
export function OrdersClient() {
|
||||
const [data, setData] = useState<OrderResponse[]>([])
|
||||
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<TableConfig<OrderResponse>>(() => ({
|
||||
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) => (
|
||||
<span className="font-mono text-sm">{truncId(row.id)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'saleId',
|
||||
title: 'Sale ID',
|
||||
fields: ['saleId'],
|
||||
width: 140,
|
||||
cellRender: (row) => (
|
||||
<span className="font-mono text-sm">{truncId(row.saleId)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'storeId',
|
||||
title: 'Store ID',
|
||||
fields: ['storeId'],
|
||||
width: 140,
|
||||
cellRender: (row) => (
|
||||
<span className="font-mono text-sm">{truncId(row.storeId)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'totalAmount',
|
||||
title: 'Total',
|
||||
fields: ['totalAmount'],
|
||||
width: 140,
|
||||
cellRender: (row) => (
|
||||
<span className="text-sm font-medium">{fmtAmount(row.totalAmount)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Status',
|
||||
fields: ['status'],
|
||||
width: 180,
|
||||
cellRender: (row) => (
|
||||
<Badge variant={ORDER_STATUS_VARIANT[row.status]}>{row.status}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Orders</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Fulfillment status of store orders
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
config={tableConfig}
|
||||
data={data}
|
||||
loading={loading}
|
||||
totalCount={total}
|
||||
onServerSideChange={({ page: p }) => {
|
||||
setPage(p.page)
|
||||
setPageSize(p.pageSize)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { OrdersClient } from './_components/orders-client'
|
||||
|
||||
export default function OrdersPage() {
|
||||
return <OrdersClient />
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SalesClient } from './_components/sales-client'
|
||||
|
||||
export default function SalesPage() {
|
||||
return <SalesClient />
|
||||
}
|
||||
@@ -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<typeof schema>
|
||||
|
||||
interface PaymentSheetProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export function PaymentSheet({ open, onClose, onSuccess }: PaymentSheetProps) {
|
||||
const form = useForm<FormValues>({
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Record Payment</DialogTitle>
|
||||
<DialogDescription>Record a cash or credit payment from a store.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
|
||||
<FieldGroup>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="saleId"
|
||||
label="Sale ID"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="saleId"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="Sale ID"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="storeId"
|
||||
label="Store ID"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="storeId"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="Store ID"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="amount"
|
||||
label="Amount"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="amount"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={Number(field.value as string)}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
onBlur={field.onBlur}
|
||||
placeholder="0.00"
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="method"
|
||||
label="Payment Method"
|
||||
required
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value as string}
|
||||
onValueChange={field.onChange}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select method" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Cash">Cash</SelectItem>
|
||||
<SelectItem value="Credit">Credit</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="collectedAt"
|
||||
label="Collected At"
|
||||
required
|
||||
render={({ field, invalid }) => (
|
||||
<Input
|
||||
id="collectedAt"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
type="date"
|
||||
value={field.value as string}
|
||||
onChange={field.onChange}
|
||||
onBlur={field.onBlur}
|
||||
aria-invalid={invalid}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.formState.isSubmitting}>
|
||||
{form.formState.isSubmitting ? 'Saving…' : 'Record Payment'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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<PaymentMethod, 'default' | 'secondary'> = {
|
||||
Cash: 'default',
|
||||
Credit: 'secondary',
|
||||
}
|
||||
|
||||
export function PaymentsClient() {
|
||||
const [data, setData] = useState<PaymentResponse[]>([])
|
||||
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<TableConfig<PaymentResponse>>(() => ({
|
||||
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) => (
|
||||
<span className="font-mono text-sm">{truncId(row.id)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'storeId',
|
||||
title: 'Store ID',
|
||||
fields: ['storeId'],
|
||||
width: 140,
|
||||
cellRender: (row) => (
|
||||
<span className="font-mono text-sm">{truncId(row.storeId)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
title: 'Amount',
|
||||
fields: ['amount'],
|
||||
width: 140,
|
||||
cellRender: (row) => (
|
||||
<span className="text-sm font-medium">{fmtAmount(row.amount)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'method',
|
||||
title: 'Method',
|
||||
fields: ['method'],
|
||||
width: 120,
|
||||
cellRender: (row) => (
|
||||
<Badge variant={PAYMENT_METHOD_VARIANT[row.method]}>{row.method}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
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) => (
|
||||
<span className="text-sm text-muted-foreground">{row.collectedBy}</span>
|
||||
),
|
||||
},
|
||||
],
|
||||
}), [pageSize])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Payments</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Cash and credit payments collected from stores
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setSheetOpen(true)}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||
Record Payment
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
config={tableConfig}
|
||||
data={data}
|
||||
loading={loading}
|
||||
totalCount={total}
|
||||
onServerSideChange={({ page: p }) => {
|
||||
setPage(p.page)
|
||||
setPageSize(p.pageSize)
|
||||
}}
|
||||
/>
|
||||
|
||||
<PaymentSheet
|
||||
open={sheetOpen}
|
||||
onClose={() => setSheetOpen(false)}
|
||||
onSuccess={() => fetchData(page, pageSize)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user