diff --git a/Distribution/Features/DeliveryRide/DeliveryTaskController.cs b/Distribution/Features/DeliveryRide/DeliveryTaskController.cs index b6dd733..919155e 100644 --- a/Distribution/Features/DeliveryRide/DeliveryTaskController.cs +++ b/Distribution/Features/DeliveryRide/DeliveryTaskController.cs @@ -19,6 +19,7 @@ public class DeliveryTaskController(DeliveryTaskService service) : ControllerBas } [HttpGet("rides/{rideId:guid}/tasks")] + [HttpGet("rides/{rideId:guid}/delivery-tasks")] public async Task GetByRide(Guid rideId) { var result = await service.GetByRideIdAsync(rideId); diff --git a/Distribution/Features/DriverComplianceConfig/DriverComplianceConfigController.cs b/Distribution/Features/DriverComplianceConfig/DriverComplianceConfigController.cs deleted file mode 100644 index f5e3e89..0000000 --- a/Distribution/Features/DriverComplianceConfig/DriverComplianceConfigController.cs +++ /dev/null @@ -1,24 +0,0 @@ -using Distribution.Infra.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace Distribution.Features.DriverComplianceConfig; - -[ApiController] -[Route("api/driver-compliance-config")] -public class DriverComplianceConfigController(DriverComplianceConfigService service, ICurrentUserService currentUser) : ControllerBase -{ - [HttpGet] - public async Task Get() - { - var result = await service.GetAsync(); - return result is null ? NoContent() : Ok(result); - } - - [HttpPut] - public async Task Upsert(UpdateDriverComplianceConfigRequest request, CancellationToken ct) - { - var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); - var result = await service.UpsertAsync(request, userId, ct); - return Ok(result); - } -} diff --git a/Distribution/Features/DriverProfile/DriverProfileController.cs b/Distribution/Features/DriverProfile/DriverProfileController.cs index a5a981b..d22743d 100644 --- a/Distribution/Features/DriverProfile/DriverProfileController.cs +++ b/Distribution/Features/DriverProfile/DriverProfileController.cs @@ -1,10 +1,15 @@ -using Microsoft.AspNetCore.Mvc; +using Distribution.Features.DriverComplianceConfig; +using Distribution.Infra.Authorization; +using Microsoft.AspNetCore.Mvc; namespace Distribution.Features.DriverProfile; [ApiController] [Route("api/driver-profiles")] -public class DriverProfileController(DriverProfileService service) : ControllerBase +public class DriverProfileController( + DriverProfileService service, + DriverComplianceConfigService complianceService, + ICurrentUserService currentUser) : ControllerBase { [HttpGet] public async Task GetAll([FromQuery] int page = 1, [FromQuery] int pageSize = 20) @@ -23,8 +28,19 @@ public class DriverProfileController(DriverProfileService service) : ControllerB [HttpPost] public async Task Create(CreateDriverProfileRequest request, CancellationToken ct) { - var result = await service.CreateAsync(request, ct); - return CreatedAtAction(nameof(GetById), new { id = result.Id }, result); + try + { + var result = await service.CreateAsync(request, ct); + return CreatedAtAction(nameof(GetById), new { id = result.Id }, result); + } + catch (KeyNotFoundException ex) + { + return NotFound(new { error = ex.Message }); + } + catch (InvalidOperationException ex) + { + return Conflict(new { error = ex.Message }); + } } [HttpPut("{id:guid}")] @@ -40,4 +56,19 @@ public class DriverProfileController(DriverProfileService service) : ControllerB var success = await service.DeactivateAsync(id, ct); return success ? NoContent() : NotFound(); } + + [HttpGet("compliance")] + public async Task GetCompliance() + { + var result = await complianceService.GetAsync(); + return result is null ? NoContent() : Ok(result); + } + + [HttpPut("compliance")] + public async Task UpsertCompliance(UpdateDriverComplianceConfigRequest request, CancellationToken ct) + { + var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); + var result = await complianceService.UpsertAsync(request, userId, ct); + return Ok(result); + } } diff --git a/Distribution/Features/DriverProfile/DriverProfileDTOs.cs b/Distribution/Features/DriverProfile/DriverProfileDTOs.cs index f4332cf..ba059f2 100644 --- a/Distribution/Features/DriverProfile/DriverProfileDTOs.cs +++ b/Distribution/Features/DriverProfile/DriverProfileDTOs.cs @@ -11,7 +11,7 @@ public record DriverProfileResponse( DateTime CreatedAt); public record CreateDriverProfileRequest( - Guid FK_UserId, + Guid UserId, string LicenseNumber, DateTime LicenseExpiry, DateTime MedicalClearanceExpiry); diff --git a/Distribution/Features/DriverProfile/DriverProfileService.cs b/Distribution/Features/DriverProfile/DriverProfileService.cs index dc7a0ac..a4bc473 100644 --- a/Distribution/Features/DriverProfile/DriverProfileService.cs +++ b/Distribution/Features/DriverProfile/DriverProfileService.cs @@ -1,9 +1,10 @@ using Distribution.Features.Stock; +using Distribution.Features.User; using Distribution.Infra.UoW; namespace Distribution.Features.DriverProfile; -public class DriverProfileService(IDriverProfileRepository repo, IUnitOfWork uow) +public class DriverProfileService(IDriverProfileRepository repo, IUnitOfWork uow, IUserRepository userRepo) { public async Task> GetAllAsync(int page, int pageSize) { @@ -21,13 +22,17 @@ public class DriverProfileService(IDriverProfileRepository repo, IUnitOfWork uow public async Task CreateAsync(CreateDriverProfileRequest request, CancellationToken ct = default) { - var existing = await repo.GetByUserIdAsync(request.FK_UserId); + var user = await userRepo.GetByIdAsync(request.UserId); + if (user is null) + throw new KeyNotFoundException($"User {request.UserId} not found"); + + var existing = await repo.GetByUserIdAsync(request.UserId); if (existing is not null && existing.IsActive) throw new InvalidOperationException("An active driver profile already exists for this user"); var profile = new global::Distribution.Models.DriverProfile { - FK_UserId = request.FK_UserId, + FK_UserId = request.UserId, LicenseNumber = request.LicenseNumber, LicenseExpiry = request.LicenseExpiry, MedicalClearanceExpiry = request.MedicalClearanceExpiry diff --git a/Distribution/Features/Grn/GrnController.cs b/Distribution/Features/Grn/GrnController.cs index ef93d56..60d11b4 100644 --- a/Distribution/Features/Grn/GrnController.cs +++ b/Distribution/Features/Grn/GrnController.cs @@ -6,6 +6,16 @@ namespace Distribution.Features.Grn; [ApiController] public class GrnController(GrnService service, ICurrentUserService currentUser) : ControllerBase { + [HttpGet("api/goods-receipts")] + public async Task GetAll( + [FromQuery] Guid? purchaseOrderId, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20) + { + var result = await service.GetAllAsync(purchaseOrderId, page, pageSize); + return Ok(result); + } + [HttpGet("api/goods-receipts/{id:guid}")] public async Task GetById(Guid id) { @@ -13,6 +23,19 @@ public class GrnController(GrnService service, ICurrentUserService currentUser) return result is null ? NotFound() : Ok(result); } + [HttpPost("api/goods-receipts")] + public async Task CreateFlat(CreateFlatGrnRequest request, CancellationToken ct) + { + var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); + try + { + var inner = new CreateGrnRequest(request.WarehouseId, request.Items); + var result = await service.CreateAsync(request.PurchaseOrderId, inner, userId, ct); + return CreatedAtAction(nameof(GetById), new { id = result.Id }, result); + } + catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); } + } + [HttpGet("api/purchase-orders/{id:guid}/goods-receipts")] public async Task GetByPurchaseOrder(Guid id) => Ok(await service.GetByPurchaseOrderIdAsync(id)); diff --git a/Distribution/Features/Grn/GrnDTOs.cs b/Distribution/Features/Grn/GrnDTOs.cs index 1fa768d..9d858a0 100644 --- a/Distribution/Features/Grn/GrnDTOs.cs +++ b/Distribution/Features/Grn/GrnDTOs.cs @@ -4,6 +4,8 @@ public record GrnItemRequest(Guid PurchaseOrderItemId, int AcceptedQuantity, int public record CreateGrnRequest(Guid WarehouseId, List Items); +public record CreateFlatGrnRequest(Guid PurchaseOrderId, Guid WarehouseId, List Items); + public record GrnItemResponse( Guid Id, Guid PurchaseOrderItemId, diff --git a/Distribution/Features/Grn/GrnRepository.cs b/Distribution/Features/Grn/GrnRepository.cs index 40b6572..22cc657 100644 --- a/Distribution/Features/Grn/GrnRepository.cs +++ b/Distribution/Features/Grn/GrnRepository.cs @@ -11,6 +11,21 @@ public class GrnRepository(AppDBContext context) : IGrnRepository .Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item) .FirstOrDefaultAsync(gr => gr.Id == id); + public async Task> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize) => + await context.GoodsReceipts + .Include(gr => gr.Warehouse) + .Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item) + .Where(gr => gr.IsActive && (purchaseOrderId == null || gr.FK_PurchaseOrderId == purchaseOrderId)) + .OrderByDescending(gr => gr.ReceivedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + public async Task GetCountAsync(Guid? purchaseOrderId) => + await context.GoodsReceipts + .Where(gr => gr.IsActive && (purchaseOrderId == null || gr.FK_PurchaseOrderId == purchaseOrderId)) + .CountAsync(); + public async Task> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) => await context.GoodsReceipts .Include(gr => gr.Warehouse) diff --git a/Distribution/Features/Grn/GrnService.cs b/Distribution/Features/Grn/GrnService.cs index 349cc1f..9268f02 100644 --- a/Distribution/Features/Grn/GrnService.cs +++ b/Distribution/Features/Grn/GrnService.cs @@ -19,6 +19,13 @@ public class GrnService( new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty, gr.ReceivedAt, gr.CreatedAt, gr.Items.Where(i => i.IsActive).Select(MapItem).ToList()); + public async Task> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize) + { + var total = await repo.GetCountAsync(purchaseOrderId); + var items = await repo.GetAllAsync(purchaseOrderId, page, pageSize); + return new PagedResponse(page, pageSize, total, items.Select(Map).ToList()); + } + public async Task GetByIdAsync(Guid id) { var gr = await repo.GetByIdAsync(id); diff --git a/Distribution/Features/Grn/IGrnRepository.cs b/Distribution/Features/Grn/IGrnRepository.cs index 2a30abf..fb26c77 100644 --- a/Distribution/Features/Grn/IGrnRepository.cs +++ b/Distribution/Features/Grn/IGrnRepository.cs @@ -3,6 +3,8 @@ namespace Distribution.Features.Grn; public interface IGrnRepository { Task GetByIdAsync(Guid id); + Task> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize); + Task GetCountAsync(Guid? purchaseOrderId); Task> GetByPurchaseOrderIdAsync(Guid purchaseOrderId); Task AddAsync(Models.GoodsReceipt entity); diff --git a/Distribution/Features/LoadingEvent/LoadingSheetController.cs b/Distribution/Features/LoadingEvent/LoadingSheetController.cs index 7e22deb..3d233ae 100644 --- a/Distribution/Features/LoadingEvent/LoadingSheetController.cs +++ b/Distribution/Features/LoadingEvent/LoadingSheetController.cs @@ -100,4 +100,43 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer catch (KeyNotFoundException) { return NotFound(); } catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } } + + [HttpPatch("{id:guid}/dispatch")] + public async Task PatchDispatch(Guid id, CancellationToken ct) + { + var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); + try + { + var result = await service.ConfirmDispatchAsync(id, userId, ct); + return Ok(result); + } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } + } + + [HttpPatch("{id:guid}/receipt")] + public async Task PatchReceipt(Guid id, PatchReceiptRequest request, CancellationToken ct) + { + var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); + try + { + var result = await service.PatchReceiptAsync(id, request.Items, userId, ct); + return Ok(result); + } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } + } + + [HttpPatch("{id:guid}/resolve")] + public async Task PatchResolve(Guid id, CancellationToken ct) + { + var userId = currentUser.UserId ?? throw new UnauthorizedAccessException(); + try + { + var result = await service.PatchResolveAsync(id, userId, ct); + return Ok(result); + } + catch (KeyNotFoundException) { return NotFound(); } + catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } + } } diff --git a/Distribution/Features/LoadingEvent/LoadingSheetDTOs.cs b/Distribution/Features/LoadingEvent/LoadingSheetDTOs.cs index 3786e47..ae2e0d6 100644 --- a/Distribution/Features/LoadingEvent/LoadingSheetDTOs.cs +++ b/Distribution/Features/LoadingEvent/LoadingSheetDTOs.cs @@ -35,3 +35,7 @@ public record ConfirmReceiptRequest(int Meterstart); public record ResolveDiscrepancyRequest(List Items, int Meterstart); public record ResolveItemInput(Guid FK_ItemId, decimal QuantityResolved); + +// Combined PATCH endpoints used by the frontend +public record PatchReceiptItemInput(Guid ItemId, decimal QuantityReceived); +public record PatchReceiptRequest(List Items); diff --git a/Distribution/Features/LoadingEvent/LoadingSheetService.cs b/Distribution/Features/LoadingEvent/LoadingSheetService.cs index 52b86ed..dadcc42 100644 --- a/Distribution/Features/LoadingEvent/LoadingSheetService.cs +++ b/Distribution/Features/LoadingEvent/LoadingSheetService.cs @@ -225,6 +225,98 @@ public class LoadingSheetService( return ToResponse(sheet); } + public async Task PatchReceiptAsync(Guid id, List items, Guid userId, CancellationToken ct = default) + { + var sheet = await repo.GetByIdAsync(id) + ?? throw new KeyNotFoundException("Loading sheet not found"); + + if (sheet.Status != LoadingSheetStatus.DispatchConfirmed) + throw new InvalidOperationException($"Cannot set receipt for a sheet in {sheet.Status} status"); + + foreach (var input in items) + { + var sheetItem = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.ItemId) + ?? throw new InvalidOperationException($"Item {input.ItemId} is not on this loading sheet"); + sheetItem.QuantityReceived = input.QuantityReceived; + } + + var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId) + ?? throw new KeyNotFoundException("Ride not found"); + + var hasDiscrepancy = sheet.Items.Any(i => i.QuantityReceived != i.QuantityDispatched); + + await uow.BeginAsync(ct); + try + { + if (hasDiscrepancy) + { + sheet.Status = LoadingSheetStatus.DiscrepancyFlagged; + } + else + { + sheet.Status = LoadingSheetStatus.ReceiptConfirmed; + sheet.ReceiptConfirmedAt = DateTime.Now; + sheet.ReceiptConfirmedBy = userId; + + var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityReceived!.Value)).ToList(); + await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId); + + ride.Status = RideStatus.Active; + ride.ActivatedAt = DateTime.Now; + ride.Meterstart = 0; + rideRepo.Update(ride); + } + + repo.Update(sheet); + await uow.CommitAsync(ct); + } + catch { await uow.RollbackAsync(ct); throw; } + + return ToResponse(sheet); + } + + public async Task PatchResolveAsync(Guid id, Guid userId, CancellationToken ct = default) + { + var sheet = await repo.GetByIdAsync(id) + ?? throw new KeyNotFoundException("Loading sheet not found"); + + if (sheet.Status != LoadingSheetStatus.DiscrepancyFlagged) + throw new InvalidOperationException($"Cannot resolve a sheet in {sheet.Status} status"); + + foreach (var item in sheet.Items) + { + if (!item.QuantityReceived.HasValue) + throw new InvalidOperationException($"Item {item.FK_ItemId} has no received quantity to resolve from"); + item.QuantityResolved = item.QuantityReceived.Value; + } + + var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId) + ?? throw new KeyNotFoundException("Ride not found"); + + sheet.Status = LoadingSheetStatus.Resolved; + sheet.ReceiptConfirmedAt = DateTime.Now; + sheet.ReceiptConfirmedBy = userId; + + var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityResolved!.Value)).ToList(); + + await uow.BeginAsync(ct); + try + { + await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId); + + ride.Status = RideStatus.Active; + ride.ActivatedAt = DateTime.Now; + ride.Meterstart = 0; + rideRepo.Update(ride); + + repo.Update(sheet); + await uow.CommitAsync(ct); + } + catch { await uow.RollbackAsync(ct); throw; } + + return ToResponse(sheet); + } + private async Task ApplyLoadMovementsAsync(LoadingSheet sheet, Guid vehicleId, List<(Guid ItemId, decimal Qty)> lines, Guid userId) { foreach (var (itemId, qty) in lines.Where(l => l.Qty > 0)) diff --git a/Distribution/Features/Payment/FlatPaymentController.cs b/Distribution/Features/Payment/FlatPaymentController.cs new file mode 100644 index 0000000..0d5927b --- /dev/null +++ b/Distribution/Features/Payment/FlatPaymentController.cs @@ -0,0 +1,38 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Distribution.Features.Payment; + +[ApiController] +[Route("api/payments")] +public class FlatPaymentController(PaymentService service) : ControllerBase +{ + [HttpGet] + public async Task GetAll( + [FromQuery] Guid? storeId, + [FromQuery] Guid? saleId, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 20) + { + var result = await service.GetAllAsync(storeId, saleId, page, pageSize); + return Ok(result); + } + + [HttpGet("{id:guid}")] + public async Task GetById(Guid id) + { + var result = await service.GetByIdAsync(id); + return result is null ? NotFound() : Ok(result); + } + + [HttpPost] + public async Task Create(CreateFlatPaymentRequest request, CancellationToken ct) + { + try + { + var inner = new CreatePaymentRequest(request.Amount, request.Method); + var result = await service.CreateAsync(request.SaleId, inner, ct); + return result is null ? NotFound(new { error = "Sale not found or access denied" }) : Ok(result); + } + catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } + } +} diff --git a/Distribution/Features/Payment/IPaymentRepository.cs b/Distribution/Features/Payment/IPaymentRepository.cs index 6ee40af..5e7d79d 100644 --- a/Distribution/Features/Payment/IPaymentRepository.cs +++ b/Distribution/Features/Payment/IPaymentRepository.cs @@ -4,6 +4,9 @@ namespace Distribution.Features.Payment; public interface IPaymentRepository { + Task GetByIdAsync(Guid id); + Task> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize); + Task GetCountAsync(Guid? storeId, Guid? saleId); Task> GetBySaleIdAsync(Guid saleId); Task AddAsync(Models.Payment payment); } diff --git a/Distribution/Features/Payment/PaymentDTOs.cs b/Distribution/Features/Payment/PaymentDTOs.cs index cedd6bf..bc125c6 100644 --- a/Distribution/Features/Payment/PaymentDTOs.cs +++ b/Distribution/Features/Payment/PaymentDTOs.cs @@ -4,6 +4,8 @@ namespace Distribution.Features.Payment; public record CreatePaymentRequest(decimal Amount, PaymentMethod Method); +public record CreateFlatPaymentRequest(Guid SaleId, decimal Amount, PaymentMethod Method); + public record PaymentResponse( Guid Id, Guid SaleId, diff --git a/Distribution/Features/Payment/PaymentRepository.cs b/Distribution/Features/Payment/PaymentRepository.cs index e4fb6ce..13cd944 100644 --- a/Distribution/Features/Payment/PaymentRepository.cs +++ b/Distribution/Features/Payment/PaymentRepository.cs @@ -6,6 +6,26 @@ namespace Distribution.Features.Payment; public class PaymentRepository(AppDBContext context) : IPaymentRepository { + public async Task GetByIdAsync(Guid id) => + await context.Payments.FirstOrDefaultAsync(p => p.Id == id && p.IsActive); + + public async Task> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize) => + await context.Payments + .Where(p => p.IsActive + && (storeId == null || p.FK_StoreId == storeId) + && (saleId == null || p.FK_SaleId == saleId)) + .OrderByDescending(p => p.CollectedAt) + .Skip((page - 1) * pageSize) + .Take(pageSize) + .ToListAsync(); + + public async Task GetCountAsync(Guid? storeId, Guid? saleId) => + await context.Payments + .Where(p => p.IsActive + && (storeId == null || p.FK_StoreId == storeId) + && (saleId == null || p.FK_SaleId == saleId)) + .CountAsync(); + public async Task> GetBySaleIdAsync(Guid saleId) => await context.Payments .Where(p => p.FK_SaleId == saleId && p.IsActive) diff --git a/Distribution/Features/Payment/PaymentService.cs b/Distribution/Features/Payment/PaymentService.cs index 22f8819..9d7b905 100644 --- a/Distribution/Features/Payment/PaymentService.cs +++ b/Distribution/Features/Payment/PaymentService.cs @@ -1,4 +1,5 @@ using Distribution.Features.Sale; +using Distribution.Features.Stock; using Distribution.Infra.Authorization; using Distribution.Infra.UoW; using Distribution.Models; @@ -14,6 +15,19 @@ public class PaymentService( private static PaymentResponse ToResponse(Models.Payment p) => new(p.Id, p.FK_SaleId, p.FK_StoreId, p.Amount, p.Method, p.CollectedAt, p.CollectedBy, p.CreatedAt); + public async Task> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize) + { + var total = await repo.GetCountAsync(storeId, saleId); + var items = await repo.GetAllAsync(storeId, saleId, page, pageSize); + return new PagedResponse(page, pageSize, total, items.Select(ToResponse).ToList()); + } + + public async Task GetByIdAsync(Guid id) + { + var payment = await repo.GetByIdAsync(id); + return payment is null ? null : ToResponse(payment); + } + public async Task?> GetBySaleIdAsync(Guid saleId) { var sale = await saleRepo.GetByIdAsync(saleId); diff --git a/Distribution/Features/Ride/RideController.cs b/Distribution/Features/Ride/RideController.cs index e9f60c8..f936c39 100644 --- a/Distribution/Features/Ride/RideController.cs +++ b/Distribution/Features/Ride/RideController.cs @@ -59,6 +59,7 @@ public class RideController(RideService service, ICurrentUserService currentUser catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } } + [HttpPatch("{id:guid}/activate")] [HttpPost("{id:guid}/activate")] public async Task Activate(Guid id, ActivateRideRequest request, CancellationToken ct) { @@ -71,6 +72,7 @@ public class RideController(RideService service, ICurrentUserService currentUser catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); } } + [HttpPatch("{id:guid}/close")] [HttpPost("{id:guid}/close")] public async Task Close(Guid id, CloseRideRequest request, CancellationToken ct) { diff --git a/Distribution/Features/Sale/SaleController.cs b/Distribution/Features/Sale/SaleController.cs index ee5b676..becfa62 100644 --- a/Distribution/Features/Sale/SaleController.cs +++ b/Distribution/Features/Sale/SaleController.cs @@ -33,4 +33,15 @@ public class SaleController(SaleService service) : ControllerBase var result = await service.CreateAsync(request, ct); return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result); } + + [HttpPatch("{id:guid}/cancel")] + public async Task Cancel(Guid id, CancellationToken ct) + { + try + { + var result = await service.CancelAsync(id, ct); + return result is null ? NotFound() : Ok(result); + } + catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); } + } } diff --git a/Distribution/Features/Sale/SaleService.cs b/Distribution/Features/Sale/SaleService.cs index 28ab2af..dd4bfd8 100644 --- a/Distribution/Features/Sale/SaleService.cs +++ b/Distribution/Features/Sale/SaleService.cs @@ -119,6 +119,26 @@ public class SaleService( return result; } + public async Task CancelAsync(Guid id, CancellationToken ct = default) + { + var sale = await repo.GetByIdAsync(id); + if (sale is null || !sale.IsActive) return null; + if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null; + if (sale.Status == SaleStatus.Cancelled) + throw new InvalidOperationException("Sale is already cancelled"); + + sale.Status = SaleStatus.Cancelled; + + await uow.BeginAsync(ct); + try { repo.Update(sale); await uow.CommitAsync(ct); } + catch { await uow.RollbackAsync(ct); throw; } + + var order = await repo.GetOrderBySaleIdAsync(id); + if (order is null) return null; + var items = await orderRepo.GetItemsByOrderIdAsync(order.Id); + return ToResponse(sale, order, items); + } + public async Task CreateAsync(CreateSaleRequest request, CancellationToken ct = default) { if (!await HasStoreScopeAsync(request.StoreId)) return null; diff --git a/Distribution/Features/Store/StoreController.cs b/Distribution/Features/Store/StoreController.cs index 364846a..2de0d27 100644 --- a/Distribution/Features/Store/StoreController.cs +++ b/Distribution/Features/Store/StoreController.cs @@ -93,4 +93,11 @@ public class StoreController(StoreService service) : ControllerBase var result = await service.GetDashboardAsync(id, period); return result is null ? NotFound() : Ok(result); } + + [HttpPatch("{id:guid}/kyc-status")] + public async Task PatchKycStatus(Guid id, PatchKycStatusRequest request, CancellationToken ct) + { + var result = await service.VerifyKycAsync(id, new VerifyKycRequest(request.KycStatus, null), ct); + return result is null ? NotFound() : Ok(result); + } } diff --git a/Distribution/Features/Store/StoreDTOs.cs b/Distribution/Features/Store/StoreDTOs.cs index 4e59e66..5969f93 100644 --- a/Distribution/Features/Store/StoreDTOs.cs +++ b/Distribution/Features/Store/StoreDTOs.cs @@ -53,4 +53,6 @@ public record StoreVisitHistoryItem(Guid VisitId, DateTime VisitDate, Guid RepUs public record SchemeEligibilityResponse(bool Eligible, string? Reason); +public record PatchKycStatusRequest(KycStatus KycStatus); + public record StoreDashboardResponse(Guid StoreId, string Period, int OrderCount, decimal TotalOrderValue, DateTime? LastOrderDate); diff --git a/distribution_fe/app/(dashboard)/catalogue/categories/_components/categories-client.tsx b/distribution_fe/app/(dashboard)/catalogue/categories/_components/categories-client.tsx index 0821633..4e507fc 100644 --- a/distribution_fe/app/(dashboard)/catalogue/categories/_components/categories-client.tsx +++ b/distribution_fe/app/(dashboard)/catalogue/categories/_components/categories-client.tsx @@ -70,7 +70,7 @@ export function CategoriesClient() { width: 160, cellRender: (row) => ( - {row.subCategories.length} sub-categories + {(row.subCategories ?? []).length} sub-categories ), }, diff --git a/distribution_fe/app/(dashboard)/catalogue/categories/_components/category-sheet.tsx b/distribution_fe/app/(dashboard)/catalogue/categories/_components/category-sheet.tsx index 51f70ef..47e2ea3 100644 --- a/distribution_fe/app/(dashboard)/catalogue/categories/_components/category-sheet.tsx +++ b/distribution_fe/app/(dashboard)/catalogue/categories/_components/category-sheet.tsx @@ -1,6 +1,8 @@ 'use client' -import { useEffect } from 'react' +import { useEffect, useState } from 'react' +import { HugeiconsIcon } from '@hugeicons/react' +import { Add01Icon, PencilEdit01Icon, Delete01Icon, Tick01Icon, Cancel01Icon } from '@hugeicons/core-free-icons' import { useForm, zodResolver, z } from '@/lib/form' import { FormField } from '@/components/shared/form-field' import { Input } from '@/components/ui/input' @@ -17,7 +19,7 @@ import { import { FieldGroup } from '@/components/ui/field' import { toast } from '@/components/ui/toast' import { categories as categoriesApi, getApiError } from '@/lib/api/index' -import type { CategoryResponse } from '@/types/api' +import type { CategoryResponse, SubCategoryResponse } from '@/types/api' const schema = z.object({ name: z.string().min(1, 'Name is required'), @@ -36,6 +38,13 @@ interface CategorySheetProps { export function CategorySheet({ open, onClose, category, onSuccess }: CategorySheetProps) { const isEdit = !!category + const [subCategories, setSubCategories] = useState([]) + const [isAdding, setIsAdding] = useState(false) + const [addingName, setAddingName] = useState('') + const [editingId, setEditingId] = useState(null) + const [editingName, setEditingName] = useState('') + const [busyId, setBusyId] = useState(null) + const form = useForm({ resolver: zodResolver(schema), defaultValues: { name: '', isActive: true }, @@ -48,6 +57,11 @@ export function CategorySheet({ open, onClose, category, onSuccess }: CategorySh ? { name: category.name, isActive: category.isActive } : { name: '', isActive: true } ) + setSubCategories(category?.subCategories ?? []) + setIsAdding(false) + setAddingName('') + setEditingId(null) + setEditingName('') } }, [open, category]) @@ -67,6 +81,52 @@ export function CategorySheet({ open, onClose, category, onSuccess }: CategorySh } } + async function handleAddSubCategory() { + const name = addingName.trim() + if (!name) return + setBusyId('new') + try { + const created = await categoriesApi.createSubCategory(category!.id, { name, isActive: true }) + setSubCategories(prev => [...prev, created as SubCategoryResponse]) + setAddingName('') + setIsAdding(false) + onSuccess() + } catch (err) { + toast.add({ title: 'Failed to add sub-category', description: getApiError(err), type: 'error' }) + } finally { + setBusyId(null) + } + } + + async function handleSaveEdit(subId: string) { + const name = editingName.trim() + if (!name) return + setBusyId(subId) + try { + await categoriesApi.updateSubCategory(category!.id, subId, { name }) + setSubCategories(prev => prev.map(s => s.id === subId ? { ...s, name } : s)) + setEditingId(null) + onSuccess() + } catch (err) { + toast.add({ title: 'Failed to update sub-category', description: getApiError(err), type: 'error' }) + } finally { + setBusyId(null) + } + } + + async function handleDelete(subId: string) { + setBusyId(subId) + try { + await categoriesApi.deleteSubCategory(subId) + setSubCategories(prev => prev.filter(s => s.id !== subId)) + onSuccess() + } catch (err) { + toast.add({ title: 'Failed to delete sub-category', description: getApiError(err), type: 'error' }) + } finally { + setBusyId(null) + } + } + return ( !v && onClose()}> @@ -116,6 +176,127 @@ export function CategorySheet({ open, onClose, category, onSuccess }: CategorySh /> + {isEdit && ( +
+
+ Sub-categories + {!isAdding && ( + + )} +
+ +
+ {subCategories.length === 0 && !isAdding && ( +

No sub-categories yet.

+ )} + + {subCategories.map((sub) => ( +
+ {editingId === sub.id ? ( + <> + setEditingName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { e.preventDefault(); handleSaveEdit(sub.id) } + if (e.key === 'Escape') setEditingId(null) + }} + className="h-7 text-sm" + autoFocus + /> + + + + ) : ( + <> + {sub.name} + + + + )} +
+ ))} + + {isAdding && ( +
+ setAddingName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { e.preventDefault(); handleAddSubCategory() } + if (e.key === 'Escape') { setIsAdding(false); setAddingName('') } + }} + placeholder="Sub-category name" + className="h-7 text-sm" + autoFocus + /> + + +
+ )} +
+
+ )} +