Merge branch 'dev' into production
This commit is contained in:
@@ -19,6 +19,7 @@ public class DeliveryTaskController(DeliveryTaskService service) : ControllerBas
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("rides/{rideId:guid}/tasks")]
|
[HttpGet("rides/{rideId:guid}/tasks")]
|
||||||
|
[HttpGet("rides/{rideId:guid}/delivery-tasks")]
|
||||||
public async Task<IActionResult> GetByRide(Guid rideId)
|
public async Task<IActionResult> GetByRide(Guid rideId)
|
||||||
{
|
{
|
||||||
var result = await service.GetByRideIdAsync(rideId);
|
var result = await service.GetByRideIdAsync(rideId);
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
using Distribution.Infra.Authorization;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace Distribution.Features.DriverComplianceConfig;
|
|
||||||
|
|
||||||
[ApiController]
|
|
||||||
[Route("api/driver-compliance-config")]
|
|
||||||
public class DriverComplianceConfigController(DriverComplianceConfigService service, ICurrentUserService currentUser) : ControllerBase
|
|
||||||
{
|
|
||||||
[HttpGet]
|
|
||||||
public async Task<IActionResult> Get()
|
|
||||||
{
|
|
||||||
var result = await service.GetAsync();
|
|
||||||
return result is null ? NoContent() : Ok(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPut]
|
|
||||||
public async Task<IActionResult> Upsert(UpdateDriverComplianceConfigRequest request, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
|
||||||
var result = await service.UpsertAsync(request, userId, ct);
|
|
||||||
return Ok(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Distribution.Features.DriverComplianceConfig;
|
||||||
|
using Distribution.Infra.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Distribution.Features.DriverProfile;
|
namespace Distribution.Features.DriverProfile;
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/driver-profiles")]
|
[Route("api/driver-profiles")]
|
||||||
public class DriverProfileController(DriverProfileService service) : ControllerBase
|
public class DriverProfileController(
|
||||||
|
DriverProfileService service,
|
||||||
|
DriverComplianceConfigService complianceService,
|
||||||
|
ICurrentUserService currentUser) : ControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetAll([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
public async Task<IActionResult> GetAll([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
|
||||||
@@ -23,8 +28,19 @@ public class DriverProfileController(DriverProfileService service) : ControllerB
|
|||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> Create(CreateDriverProfileRequest request, CancellationToken ct)
|
public async Task<IActionResult> Create(CreateDriverProfileRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var result = await service.CreateAsync(request, ct);
|
try
|
||||||
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
{
|
||||||
|
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}")]
|
[HttpPut("{id:guid}")]
|
||||||
@@ -40,4 +56,19 @@ public class DriverProfileController(DriverProfileService service) : ControllerB
|
|||||||
var success = await service.DeactivateAsync(id, ct);
|
var success = await service.DeactivateAsync(id, ct);
|
||||||
return success ? NoContent() : NotFound();
|
return success ? NoContent() : NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("compliance")]
|
||||||
|
public async Task<IActionResult> GetCompliance()
|
||||||
|
{
|
||||||
|
var result = await complianceService.GetAsync();
|
||||||
|
return result is null ? NoContent() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPut("compliance")]
|
||||||
|
public async Task<IActionResult> UpsertCompliance(UpdateDriverComplianceConfigRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
var result = await complianceService.UpsertAsync(request, userId, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ public record DriverProfileResponse(
|
|||||||
DateTime CreatedAt);
|
DateTime CreatedAt);
|
||||||
|
|
||||||
public record CreateDriverProfileRequest(
|
public record CreateDriverProfileRequest(
|
||||||
Guid FK_UserId,
|
Guid UserId,
|
||||||
string LicenseNumber,
|
string LicenseNumber,
|
||||||
DateTime LicenseExpiry,
|
DateTime LicenseExpiry,
|
||||||
DateTime MedicalClearanceExpiry);
|
DateTime MedicalClearanceExpiry);
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
using Distribution.Features.Stock;
|
using Distribution.Features.Stock;
|
||||||
|
using Distribution.Features.User;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
|
|
||||||
namespace Distribution.Features.DriverProfile;
|
namespace Distribution.Features.DriverProfile;
|
||||||
|
|
||||||
public class DriverProfileService(IDriverProfileRepository repo, IUnitOfWork uow)
|
public class DriverProfileService(IDriverProfileRepository repo, IUnitOfWork uow, IUserRepository userRepo)
|
||||||
{
|
{
|
||||||
public async Task<PagedResponse<DriverProfileResponse>> GetAllAsync(int page, int pageSize)
|
public async Task<PagedResponse<DriverProfileResponse>> GetAllAsync(int page, int pageSize)
|
||||||
{
|
{
|
||||||
@@ -21,13 +22,17 @@ public class DriverProfileService(IDriverProfileRepository repo, IUnitOfWork uow
|
|||||||
|
|
||||||
public async Task<DriverProfileResponse> CreateAsync(CreateDriverProfileRequest request, CancellationToken ct = default)
|
public async Task<DriverProfileResponse> CreateAsync(CreateDriverProfileRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var existing = await repo.GetByUserIdAsync(request.FK_UserId);
|
var user = await userRepo.GetByIdAsync(request.UserId);
|
||||||
|
if (user is null)
|
||||||
|
throw new KeyNotFoundException($"User {request.UserId} not found");
|
||||||
|
|
||||||
|
var existing = await repo.GetByUserIdAsync(request.UserId);
|
||||||
if (existing is not null && existing.IsActive)
|
if (existing is not null && existing.IsActive)
|
||||||
throw new InvalidOperationException("An active driver profile already exists for this user");
|
throw new InvalidOperationException("An active driver profile already exists for this user");
|
||||||
|
|
||||||
var profile = new global::Distribution.Models.DriverProfile
|
var profile = new global::Distribution.Models.DriverProfile
|
||||||
{
|
{
|
||||||
FK_UserId = request.FK_UserId,
|
FK_UserId = request.UserId,
|
||||||
LicenseNumber = request.LicenseNumber,
|
LicenseNumber = request.LicenseNumber,
|
||||||
LicenseExpiry = request.LicenseExpiry,
|
LicenseExpiry = request.LicenseExpiry,
|
||||||
MedicalClearanceExpiry = request.MedicalClearanceExpiry
|
MedicalClearanceExpiry = request.MedicalClearanceExpiry
|
||||||
|
|||||||
@@ -6,6 +6,16 @@ namespace Distribution.Features.Grn;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
public class GrnController(GrnService service, ICurrentUserService currentUser) : ControllerBase
|
public class GrnController(GrnService service, ICurrentUserService currentUser) : ControllerBase
|
||||||
{
|
{
|
||||||
|
[HttpGet("api/goods-receipts")]
|
||||||
|
public async Task<IActionResult> GetAll(
|
||||||
|
[FromQuery] Guid? purchaseOrderId,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20)
|
||||||
|
{
|
||||||
|
var result = await service.GetAllAsync(purchaseOrderId, page, pageSize);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("api/goods-receipts/{id:guid}")]
|
[HttpGet("api/goods-receipts/{id:guid}")]
|
||||||
public async Task<IActionResult> GetById(Guid id)
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
{
|
{
|
||||||
@@ -13,6 +23,19 @@ public class GrnController(GrnService service, ICurrentUserService currentUser)
|
|||||||
return result is null ? NotFound() : Ok(result);
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPost("api/goods-receipts")]
|
||||||
|
public async Task<IActionResult> CreateFlat(CreateFlatGrnRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var inner = new CreateGrnRequest(request.WarehouseId, request.Items);
|
||||||
|
var result = await service.CreateAsync(request.PurchaseOrderId, inner, userId, ct);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("api/purchase-orders/{id:guid}/goods-receipts")]
|
[HttpGet("api/purchase-orders/{id:guid}/goods-receipts")]
|
||||||
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
|
public async Task<IActionResult> GetByPurchaseOrder(Guid id) =>
|
||||||
Ok(await service.GetByPurchaseOrderIdAsync(id));
|
Ok(await service.GetByPurchaseOrderIdAsync(id));
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ public record GrnItemRequest(Guid PurchaseOrderItemId, int AcceptedQuantity, int
|
|||||||
|
|
||||||
public record CreateGrnRequest(Guid WarehouseId, List<GrnItemRequest> Items);
|
public record CreateGrnRequest(Guid WarehouseId, List<GrnItemRequest> Items);
|
||||||
|
|
||||||
|
public record CreateFlatGrnRequest(Guid PurchaseOrderId, Guid WarehouseId, List<GrnItemRequest> Items);
|
||||||
|
|
||||||
public record GrnItemResponse(
|
public record GrnItemResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid PurchaseOrderItemId,
|
Guid PurchaseOrderItemId,
|
||||||
|
|||||||
@@ -11,6 +11,21 @@ public class GrnRepository(AppDBContext context) : IGrnRepository
|
|||||||
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
|
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
|
||||||
.FirstOrDefaultAsync(gr => gr.Id == id);
|
.FirstOrDefaultAsync(gr => gr.Id == id);
|
||||||
|
|
||||||
|
public async Task<List<Models.GoodsReceipt>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize) =>
|
||||||
|
await context.GoodsReceipts
|
||||||
|
.Include(gr => gr.Warehouse)
|
||||||
|
.Include(gr => gr.Items).ThenInclude(i => i.PurchaseOrderItem).ThenInclude(poi => poi.Item)
|
||||||
|
.Where(gr => gr.IsActive && (purchaseOrderId == null || gr.FK_PurchaseOrderId == purchaseOrderId))
|
||||||
|
.OrderByDescending(gr => gr.ReceivedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<int> GetCountAsync(Guid? purchaseOrderId) =>
|
||||||
|
await context.GoodsReceipts
|
||||||
|
.Where(gr => gr.IsActive && (purchaseOrderId == null || gr.FK_PurchaseOrderId == purchaseOrderId))
|
||||||
|
.CountAsync();
|
||||||
|
|
||||||
public async Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
public async Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId) =>
|
||||||
await context.GoodsReceipts
|
await context.GoodsReceipts
|
||||||
.Include(gr => gr.Warehouse)
|
.Include(gr => gr.Warehouse)
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ public class GrnService(
|
|||||||
new(gr.Id, gr.FK_PurchaseOrderId, gr.FK_ReceivedByUserId, gr.FK_WarehouseId, gr.Warehouse?.Name ?? string.Empty,
|
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());
|
gr.ReceivedAt, gr.CreatedAt, gr.Items.Where(i => i.IsActive).Select(MapItem).ToList());
|
||||||
|
|
||||||
|
public async Task<PagedResponse<GrnResponse>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize)
|
||||||
|
{
|
||||||
|
var total = await repo.GetCountAsync(purchaseOrderId);
|
||||||
|
var items = await repo.GetAllAsync(purchaseOrderId, page, pageSize);
|
||||||
|
return new PagedResponse<GrnResponse>(page, pageSize, total, items.Select(Map).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<GrnResponse?> GetByIdAsync(Guid id)
|
public async Task<GrnResponse?> GetByIdAsync(Guid id)
|
||||||
{
|
{
|
||||||
var gr = await repo.GetByIdAsync(id);
|
var gr = await repo.GetByIdAsync(id);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ namespace Distribution.Features.Grn;
|
|||||||
public interface IGrnRepository
|
public interface IGrnRepository
|
||||||
{
|
{
|
||||||
Task<Models.GoodsReceipt?> GetByIdAsync(Guid id);
|
Task<Models.GoodsReceipt?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<Models.GoodsReceipt>> GetAllAsync(Guid? purchaseOrderId, int page, int pageSize);
|
||||||
|
Task<int> GetCountAsync(Guid? purchaseOrderId);
|
||||||
Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId);
|
Task<List<Models.GoodsReceipt>> GetByPurchaseOrderIdAsync(Guid purchaseOrderId);
|
||||||
Task AddAsync(Models.GoodsReceipt entity);
|
Task AddAsync(Models.GoodsReceipt entity);
|
||||||
|
|
||||||
|
|||||||
@@ -100,4 +100,43 @@ public class LoadingSheetController(LoadingSheetService service, ICurrentUserSer
|
|||||||
catch (KeyNotFoundException) { return NotFound(); }
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{id:guid}/dispatch")]
|
||||||
|
public async Task<IActionResult> PatchDispatch(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.ConfirmDispatchAsync(id, userId, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{id:guid}/receipt")]
|
||||||
|
public async Task<IActionResult> PatchReceipt(Guid id, PatchReceiptRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.PatchReceiptAsync(id, request.Items, userId, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{id:guid}/resolve")]
|
||||||
|
public async Task<IActionResult> PatchResolve(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var userId = currentUser.UserId ?? throw new UnauthorizedAccessException();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.PatchResolveAsync(id, userId, ct);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException) { return NotFound(); }
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,3 +35,7 @@ public record ConfirmReceiptRequest(int Meterstart);
|
|||||||
|
|
||||||
public record ResolveDiscrepancyRequest(List<ResolveItemInput> Items, int Meterstart);
|
public record ResolveDiscrepancyRequest(List<ResolveItemInput> Items, int Meterstart);
|
||||||
public record ResolveItemInput(Guid FK_ItemId, decimal QuantityResolved);
|
public record ResolveItemInput(Guid FK_ItemId, decimal QuantityResolved);
|
||||||
|
|
||||||
|
// Combined PATCH endpoints used by the frontend
|
||||||
|
public record PatchReceiptItemInput(Guid ItemId, decimal QuantityReceived);
|
||||||
|
public record PatchReceiptRequest(List<PatchReceiptItemInput> Items);
|
||||||
|
|||||||
@@ -225,6 +225,98 @@ public class LoadingSheetService(
|
|||||||
return ToResponse(sheet);
|
return ToResponse(sheet);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<LoadingSheetResponse> PatchReceiptAsync(Guid id, List<PatchReceiptItemInput> items, Guid userId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var sheet = await repo.GetByIdAsync(id)
|
||||||
|
?? throw new KeyNotFoundException("Loading sheet not found");
|
||||||
|
|
||||||
|
if (sheet.Status != LoadingSheetStatus.DispatchConfirmed)
|
||||||
|
throw new InvalidOperationException($"Cannot set receipt for a sheet in {sheet.Status} status");
|
||||||
|
|
||||||
|
foreach (var input in items)
|
||||||
|
{
|
||||||
|
var sheetItem = sheet.Items.FirstOrDefault(i => i.FK_ItemId == input.ItemId)
|
||||||
|
?? throw new InvalidOperationException($"Item {input.ItemId} is not on this loading sheet");
|
||||||
|
sheetItem.QuantityReceived = input.QuantityReceived;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
|
||||||
|
?? throw new KeyNotFoundException("Ride not found");
|
||||||
|
|
||||||
|
var hasDiscrepancy = sheet.Items.Any(i => i.QuantityReceived != i.QuantityDispatched);
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (hasDiscrepancy)
|
||||||
|
{
|
||||||
|
sheet.Status = LoadingSheetStatus.DiscrepancyFlagged;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sheet.Status = LoadingSheetStatus.ReceiptConfirmed;
|
||||||
|
sheet.ReceiptConfirmedAt = DateTime.Now;
|
||||||
|
sheet.ReceiptConfirmedBy = userId;
|
||||||
|
|
||||||
|
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityReceived!.Value)).ToList();
|
||||||
|
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
|
||||||
|
|
||||||
|
ride.Status = RideStatus.Active;
|
||||||
|
ride.ActivatedAt = DateTime.Now;
|
||||||
|
ride.Meterstart = 0;
|
||||||
|
rideRepo.Update(ride);
|
||||||
|
}
|
||||||
|
|
||||||
|
repo.Update(sheet);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return ToResponse(sheet);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<LoadingSheetResponse> PatchResolveAsync(Guid id, Guid userId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var sheet = await repo.GetByIdAsync(id)
|
||||||
|
?? throw new KeyNotFoundException("Loading sheet not found");
|
||||||
|
|
||||||
|
if (sheet.Status != LoadingSheetStatus.DiscrepancyFlagged)
|
||||||
|
throw new InvalidOperationException($"Cannot resolve a sheet in {sheet.Status} status");
|
||||||
|
|
||||||
|
foreach (var item in sheet.Items)
|
||||||
|
{
|
||||||
|
if (!item.QuantityReceived.HasValue)
|
||||||
|
throw new InvalidOperationException($"Item {item.FK_ItemId} has no received quantity to resolve from");
|
||||||
|
item.QuantityResolved = item.QuantityReceived.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var ride = await rideRepo.GetByIdAsync(sheet.FK_RideId)
|
||||||
|
?? throw new KeyNotFoundException("Ride not found");
|
||||||
|
|
||||||
|
sheet.Status = LoadingSheetStatus.Resolved;
|
||||||
|
sheet.ReceiptConfirmedAt = DateTime.Now;
|
||||||
|
sheet.ReceiptConfirmedBy = userId;
|
||||||
|
|
||||||
|
var lines = sheet.Items.Select(i => (i.FK_ItemId, i.QuantityResolved!.Value)).ToList();
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ApplyLoadMovementsAsync(sheet, ride.FK_VehicleId, lines, userId);
|
||||||
|
|
||||||
|
ride.Status = RideStatus.Active;
|
||||||
|
ride.ActivatedAt = DateTime.Now;
|
||||||
|
ride.Meterstart = 0;
|
||||||
|
rideRepo.Update(ride);
|
||||||
|
|
||||||
|
repo.Update(sheet);
|
||||||
|
await uow.CommitAsync(ct);
|
||||||
|
}
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
return ToResponse(sheet);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task ApplyLoadMovementsAsync(LoadingSheet sheet, Guid vehicleId, List<(Guid ItemId, decimal Qty)> lines, Guid userId)
|
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))
|
foreach (var (itemId, qty) in lines.Where(l => l.Qty > 0))
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Distribution.Features.Payment;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/payments")]
|
||||||
|
public class FlatPaymentController(PaymentService service) : ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetAll(
|
||||||
|
[FromQuery] Guid? storeId,
|
||||||
|
[FromQuery] Guid? saleId,
|
||||||
|
[FromQuery] int page = 1,
|
||||||
|
[FromQuery] int pageSize = 20)
|
||||||
|
{
|
||||||
|
var result = await service.GetAllAsync(storeId, saleId, page, pageSize);
|
||||||
|
return Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
public async Task<IActionResult> GetById(Guid id)
|
||||||
|
{
|
||||||
|
var result = await service.GetByIdAsync(id);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> Create(CreateFlatPaymentRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var inner = new CreatePaymentRequest(request.Amount, request.Method);
|
||||||
|
var result = await service.CreateAsync(request.SaleId, inner, ct);
|
||||||
|
return result is null ? NotFound(new { error = "Sale not found or access denied" }) : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,9 @@ namespace Distribution.Features.Payment;
|
|||||||
|
|
||||||
public interface IPaymentRepository
|
public interface IPaymentRepository
|
||||||
{
|
{
|
||||||
|
Task<Models.Payment?> GetByIdAsync(Guid id);
|
||||||
|
Task<List<Models.Payment>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize);
|
||||||
|
Task<int> GetCountAsync(Guid? storeId, Guid? saleId);
|
||||||
Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId);
|
Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId);
|
||||||
Task AddAsync(Models.Payment payment);
|
Task AddAsync(Models.Payment payment);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ namespace Distribution.Features.Payment;
|
|||||||
|
|
||||||
public record CreatePaymentRequest(decimal Amount, PaymentMethod Method);
|
public record CreatePaymentRequest(decimal Amount, PaymentMethod Method);
|
||||||
|
|
||||||
|
public record CreateFlatPaymentRequest(Guid SaleId, decimal Amount, PaymentMethod Method);
|
||||||
|
|
||||||
public record PaymentResponse(
|
public record PaymentResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid SaleId,
|
Guid SaleId,
|
||||||
|
|||||||
@@ -6,6 +6,26 @@ namespace Distribution.Features.Payment;
|
|||||||
|
|
||||||
public class PaymentRepository(AppDBContext context) : IPaymentRepository
|
public class PaymentRepository(AppDBContext context) : IPaymentRepository
|
||||||
{
|
{
|
||||||
|
public async Task<Models.Payment?> GetByIdAsync(Guid id) =>
|
||||||
|
await context.Payments.FirstOrDefaultAsync(p => p.Id == id && p.IsActive);
|
||||||
|
|
||||||
|
public async Task<List<Models.Payment>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize) =>
|
||||||
|
await context.Payments
|
||||||
|
.Where(p => p.IsActive
|
||||||
|
&& (storeId == null || p.FK_StoreId == storeId)
|
||||||
|
&& (saleId == null || p.FK_SaleId == saleId))
|
||||||
|
.OrderByDescending(p => p.CollectedAt)
|
||||||
|
.Skip((page - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
public async Task<int> GetCountAsync(Guid? storeId, Guid? saleId) =>
|
||||||
|
await context.Payments
|
||||||
|
.Where(p => p.IsActive
|
||||||
|
&& (storeId == null || p.FK_StoreId == storeId)
|
||||||
|
&& (saleId == null || p.FK_SaleId == saleId))
|
||||||
|
.CountAsync();
|
||||||
|
|
||||||
public async Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId) =>
|
public async Task<List<Models.Payment>> GetBySaleIdAsync(Guid saleId) =>
|
||||||
await context.Payments
|
await context.Payments
|
||||||
.Where(p => p.FK_SaleId == saleId && p.IsActive)
|
.Where(p => p.FK_SaleId == saleId && p.IsActive)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Distribution.Features.Sale;
|
using Distribution.Features.Sale;
|
||||||
|
using Distribution.Features.Stock;
|
||||||
using Distribution.Infra.Authorization;
|
using Distribution.Infra.Authorization;
|
||||||
using Distribution.Infra.UoW;
|
using Distribution.Infra.UoW;
|
||||||
using Distribution.Models;
|
using Distribution.Models;
|
||||||
@@ -14,6 +15,19 @@ public class PaymentService(
|
|||||||
private static PaymentResponse ToResponse(Models.Payment p) =>
|
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);
|
new(p.Id, p.FK_SaleId, p.FK_StoreId, p.Amount, p.Method, p.CollectedAt, p.CollectedBy, p.CreatedAt);
|
||||||
|
|
||||||
|
public async Task<PagedResponse<PaymentResponse>> GetAllAsync(Guid? storeId, Guid? saleId, int page, int pageSize)
|
||||||
|
{
|
||||||
|
var total = await repo.GetCountAsync(storeId, saleId);
|
||||||
|
var items = await repo.GetAllAsync(storeId, saleId, page, pageSize);
|
||||||
|
return new PagedResponse<PaymentResponse>(page, pageSize, total, items.Select(ToResponse).ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PaymentResponse?> GetByIdAsync(Guid id)
|
||||||
|
{
|
||||||
|
var payment = await repo.GetByIdAsync(id);
|
||||||
|
return payment is null ? null : ToResponse(payment);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<PaymentResponse>?> GetBySaleIdAsync(Guid saleId)
|
public async Task<List<PaymentResponse>?> GetBySaleIdAsync(Guid saleId)
|
||||||
{
|
{
|
||||||
var sale = await saleRepo.GetByIdAsync(saleId);
|
var sale = await saleRepo.GetByIdAsync(saleId);
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{id:guid}/activate")]
|
||||||
[HttpPost("{id:guid}/activate")]
|
[HttpPost("{id:guid}/activate")]
|
||||||
public async Task<IActionResult> Activate(Guid id, ActivateRideRequest request, CancellationToken ct)
|
public async Task<IActionResult> Activate(Guid id, ActivateRideRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -71,6 +72,7 @@ public class RideController(RideService service, ICurrentUserService currentUser
|
|||||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{id:guid}/close")]
|
||||||
[HttpPost("{id:guid}/close")]
|
[HttpPost("{id:guid}/close")]
|
||||||
public async Task<IActionResult> Close(Guid id, CloseRideRequest request, CancellationToken ct)
|
public async Task<IActionResult> Close(Guid id, CloseRideRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -33,4 +33,15 @@ public class SaleController(SaleService service) : ControllerBase
|
|||||||
var result = await service.CreateAsync(request, ct);
|
var result = await service.CreateAsync(request, ct);
|
||||||
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
return result is null ? Forbid() : CreatedAtAction(nameof(GetById), new { id = result.Id }, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{id:guid}/cancel")]
|
||||||
|
public async Task<IActionResult> Cancel(Guid id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var result = await service.CancelAsync(id, ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex) { return Conflict(new { error = ex.Message }); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,26 @@ public class SaleService(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<SaleResponse?> CancelAsync(Guid id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var sale = await repo.GetByIdAsync(id);
|
||||||
|
if (sale is null || !sale.IsActive) return null;
|
||||||
|
if (!await HasStoreScopeAsync(sale.FK_StoreId)) return null;
|
||||||
|
if (sale.Status == SaleStatus.Cancelled)
|
||||||
|
throw new InvalidOperationException("Sale is already cancelled");
|
||||||
|
|
||||||
|
sale.Status = SaleStatus.Cancelled;
|
||||||
|
|
||||||
|
await uow.BeginAsync(ct);
|
||||||
|
try { repo.Update(sale); await uow.CommitAsync(ct); }
|
||||||
|
catch { await uow.RollbackAsync(ct); throw; }
|
||||||
|
|
||||||
|
var order = await repo.GetOrderBySaleIdAsync(id);
|
||||||
|
if (order is null) return null;
|
||||||
|
var items = await orderRepo.GetItemsByOrderIdAsync(order.Id);
|
||||||
|
return ToResponse(sale, order, items);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<SaleResponse?> CreateAsync(CreateSaleRequest request, CancellationToken ct = default)
|
public async Task<SaleResponse?> CreateAsync(CreateSaleRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (!await HasStoreScopeAsync(request.StoreId)) return null;
|
if (!await HasStoreScopeAsync(request.StoreId)) return null;
|
||||||
|
|||||||
@@ -93,4 +93,11 @@ public class StoreController(StoreService service) : ControllerBase
|
|||||||
var result = await service.GetDashboardAsync(id, period);
|
var result = await service.GetDashboardAsync(id, period);
|
||||||
return result is null ? NotFound() : Ok(result);
|
return result is null ? NotFound() : Ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpPatch("{id:guid}/kyc-status")]
|
||||||
|
public async Task<IActionResult> PatchKycStatus(Guid id, PatchKycStatusRequest request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var result = await service.VerifyKycAsync(id, new VerifyKycRequest(request.KycStatus, null), ct);
|
||||||
|
return result is null ? NotFound() : Ok(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,4 +53,6 @@ public record StoreVisitHistoryItem(Guid VisitId, DateTime VisitDate, Guid RepUs
|
|||||||
|
|
||||||
public record SchemeEligibilityResponse(bool Eligible, string? Reason);
|
public record SchemeEligibilityResponse(bool Eligible, string? Reason);
|
||||||
|
|
||||||
|
public record PatchKycStatusRequest(KycStatus KycStatus);
|
||||||
|
|
||||||
public record StoreDashboardResponse(Guid StoreId, string Period, int OrderCount, decimal TotalOrderValue, DateTime? LastOrderDate);
|
public record StoreDashboardResponse(Guid StoreId, string Period, int OrderCount, decimal TotalOrderValue, DateTime? LastOrderDate);
|
||||||
|
|||||||
+1
-1
@@ -70,7 +70,7 @@ export function CategoriesClient() {
|
|||||||
width: 160,
|
width: 160,
|
||||||
cellRender: (row) => (
|
cellRender: (row) => (
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{row.subCategories.length} sub-categories
|
{(row.subCategories ?? []).length} sub-categories
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
+183
-2
@@ -1,6 +1,8 @@
|
|||||||
'use client'
|
'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 { useForm, zodResolver, z } from '@/lib/form'
|
||||||
import { FormField } from '@/components/shared/form-field'
|
import { FormField } from '@/components/shared/form-field'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -17,7 +19,7 @@ import {
|
|||||||
import { FieldGroup } from '@/components/ui/field'
|
import { FieldGroup } from '@/components/ui/field'
|
||||||
import { toast } from '@/components/ui/toast'
|
import { toast } from '@/components/ui/toast'
|
||||||
import { categories as categoriesApi, getApiError } from '@/lib/api/index'
|
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({
|
const schema = z.object({
|
||||||
name: z.string().min(1, 'Name is required'),
|
name: z.string().min(1, 'Name is required'),
|
||||||
@@ -36,6 +38,13 @@ interface CategorySheetProps {
|
|||||||
export function CategorySheet({ open, onClose, category, onSuccess }: CategorySheetProps) {
|
export function CategorySheet({ open, onClose, category, onSuccess }: CategorySheetProps) {
|
||||||
const isEdit = !!category
|
const isEdit = !!category
|
||||||
|
|
||||||
|
const [subCategories, setSubCategories] = useState<SubCategoryResponse[]>([])
|
||||||
|
const [isAdding, setIsAdding] = useState(false)
|
||||||
|
const [addingName, setAddingName] = useState('')
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null)
|
||||||
|
const [editingName, setEditingName] = useState('')
|
||||||
|
const [busyId, setBusyId] = useState<string | null>(null)
|
||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: { name: '', isActive: true },
|
defaultValues: { name: '', isActive: true },
|
||||||
@@ -48,6 +57,11 @@ export function CategorySheet({ open, onClose, category, onSuccess }: CategorySh
|
|||||||
? { name: category.name, isActive: category.isActive }
|
? { name: category.name, isActive: category.isActive }
|
||||||
: { name: '', isActive: true }
|
: { name: '', isActive: true }
|
||||||
)
|
)
|
||||||
|
setSubCategories(category?.subCategories ?? [])
|
||||||
|
setIsAdding(false)
|
||||||
|
setAddingName('')
|
||||||
|
setEditingId(null)
|
||||||
|
setEditingName('')
|
||||||
}
|
}
|
||||||
}, [open, category])
|
}, [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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
<DialogContent className="overflow-y-auto sm:max-w-lg">
|
||||||
@@ -116,6 +176,127 @@ export function CategorySheet({ open, onClose, category, onSuccess }: CategorySh
|
|||||||
/>
|
/>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
|
|
||||||
|
{isEdit && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">Sub-categories</span>
|
||||||
|
{!isAdding && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsAdding(true)}
|
||||||
|
>
|
||||||
|
<HugeiconsIcon icon={Add01Icon} size={14} />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-0.5 rounded-md border bg-muted/30 px-1 py-1">
|
||||||
|
{subCategories.length === 0 && !isAdding && (
|
||||||
|
<p className="px-2 py-2 text-sm text-muted-foreground">No sub-categories yet.</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{subCategories.map((sub) => (
|
||||||
|
<div key={sub.id} className="flex items-center gap-1 rounded px-2 py-1 hover:bg-muted/60">
|
||||||
|
{editingId === sub.id ? (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
value={editingName}
|
||||||
|
onChange={(e) => setEditingName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); handleSaveEdit(sub.id) }
|
||||||
|
if (e.key === 'Escape') setEditingId(null)
|
||||||
|
}}
|
||||||
|
className="h-7 text-sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
disabled={busyId === sub.id}
|
||||||
|
onClick={() => handleSaveEdit(sub.id)}
|
||||||
|
>
|
||||||
|
<HugeiconsIcon icon={Tick01Icon} size={14} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
onClick={() => setEditingId(null)}
|
||||||
|
>
|
||||||
|
<HugeiconsIcon icon={Cancel01Icon} size={14} />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="flex-1 text-sm">{sub.name}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
onClick={() => { setEditingId(sub.id); setEditingName(sub.name) }}
|
||||||
|
>
|
||||||
|
<HugeiconsIcon icon={PencilEdit01Icon} size={14} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0 text-destructive hover:text-destructive"
|
||||||
|
disabled={busyId === sub.id}
|
||||||
|
onClick={() => handleDelete(sub.id)}
|
||||||
|
>
|
||||||
|
<HugeiconsIcon icon={Delete01Icon} size={14} />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isAdding && (
|
||||||
|
<div className="flex items-center gap-1 rounded px-2 py-1">
|
||||||
|
<Input
|
||||||
|
value={addingName}
|
||||||
|
onChange={(e) => setAddingName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); handleAddSubCategory() }
|
||||||
|
if (e.key === 'Escape') { setIsAdding(false); setAddingName('') }
|
||||||
|
}}
|
||||||
|
placeholder="Sub-category name"
|
||||||
|
className="h-7 text-sm"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
disabled={busyId === 'new'}
|
||||||
|
onClick={handleAddSubCategory}
|
||||||
|
>
|
||||||
|
<HugeiconsIcon icon={Tick01Icon} size={14} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shrink-0"
|
||||||
|
onClick={() => { setIsAdding(false); setAddingName('') }}
|
||||||
|
>
|
||||||
|
<HugeiconsIcon icon={Cancel01Icon} size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -95,3 +95,4 @@ Ordered by **data dependency** — each module can only work once the modules ab
|
|||||||
- Pages live in `app/(dashboard)/<module>/page.tsx`.
|
- Pages live in `app/(dashboard)/<module>/page.tsx`.
|
||||||
- Shared UI goes in `components/shared/`, module-specific UI stays co-located with the page.
|
- Shared UI goes in `components/shared/`, module-specific UI stays co-located with the page.
|
||||||
- Permissions are checked via `useAuth()` — never hard-code role names in pages.
|
- Permissions are checked via `useAuth()` — never hard-code role names in pages.
|
||||||
|
|
||||||
|
|||||||
@@ -75,10 +75,13 @@ export const categories = {
|
|||||||
api.put<CategoryResponse>(`/categories/${id}`, body).then(r => r.data),
|
api.put<CategoryResponse>(`/categories/${id}`, body).then(r => r.data),
|
||||||
|
|
||||||
createSubCategory: (categoryId: string, body: SubCategoryBody) =>
|
createSubCategory: (categoryId: string, body: SubCategoryBody) =>
|
||||||
api.post(`/categories/${categoryId}/sub-categories`, body).then(r => r.data),
|
api.post(`/categories/${categoryId}/subcategories`, body).then(r => r.data),
|
||||||
|
|
||||||
updateSubCategory: (categoryId: string, subId: string, body: Partial<SubCategoryBody>) =>
|
updateSubCategory: (_categoryId: string, subId: string, body: Partial<SubCategoryBody>) =>
|
||||||
api.put(`/categories/${categoryId}/sub-categories/${subId}`, body).then(r => r.data),
|
api.put(`/subcategories/${subId}`, body).then(r => r.data),
|
||||||
|
|
||||||
|
deleteSubCategory: (subId: string) =>
|
||||||
|
api.delete(`/subcategories/${subId}`),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Brands ───────────────────────────────────────────────────────────────────
|
// ─── Brands ───────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user