Merge pull request 'Refactor/ride lifecycle' (#26) from refactor/ride_lifecycle into dev

Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
2026-09-18 06:51:51 +00:00
28 changed files with 12769 additions and 184 deletions
-1
View File
@@ -37,7 +37,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<Supplier> Suppliers { get; set; } public DbSet<Supplier> Suppliers { get; set; }
public DbSet<DriverProfile> DriverProfiles { get; set; } public DbSet<DriverProfile> DriverProfiles { get; set; }
public DbSet<DriverComplianceConfig> DriverComplianceConfigs { get; set; } public DbSet<DriverComplianceConfig> DriverComplianceConfigs { get; set; }
public DbSet<RideCancelRequest> RideCancelRequests { get; set; }
public DbSet<Scheme> Schemes { get; set; } public DbSet<Scheme> Schemes { get; set; }
public DbSet<SchemeRedemption> SchemeRedemptions { get; set; } public DbSet<SchemeRedemption> SchemeRedemptions { get; set; }
public DbSet<SchemeBundleComponent> SchemeBundleComponents { get; set; } public DbSet<SchemeBundleComponent> SchemeBundleComponents { get; set; }
+7 -1
View File
@@ -16,6 +16,9 @@ public static class CommerceSeed
private static readonly Guid SchemeRedemption1Id = new("50000000-0000-0000-0000-000000000001"); private static readonly Guid SchemeRedemption1Id = new("50000000-0000-0000-0000-000000000001");
private static readonly Guid Payment1Id = new("47000000-0000-0000-0000-000000000001"); private static readonly Guid Payment1Id = new("47000000-0000-0000-0000-000000000001");
private static readonly Guid Payment2Id = new("47000000-0000-0000-0000-000000000002");
private static readonly Guid Payment3Id = new("47000000-0000-0000-0000-000000000003");
private static readonly Guid Payment4Id = new("47000000-0000-0000-0000-000000000004");
private static readonly Guid SaleReturn1Id = new("51000000-0000-0000-0000-000000000001"); private static readonly Guid SaleReturn1Id = new("51000000-0000-0000-0000-000000000001");
private static readonly Guid SaleReturnItem1Id = new("52000000-0000-0000-0000-000000000001"); private static readonly Guid SaleReturnItem1Id = new("52000000-0000-0000-0000-000000000001");
@@ -84,7 +87,10 @@ public static class CommerceSeed
); );
modelBuilder.Entity<Payment>().HasData( modelBuilder.Entity<Payment>().HasData(
new Payment { Id = Payment1Id, FK_InvoiceId = OperationsDataSeed.Invoice1Id, Amount = 3000m, Method = PaymentMethod.Cash, CollectedAt = new DateTime(2026, 8, 25, 9, 30, 0), CollectedBy = MasterDataSeed.SalesRep1UserId, CreatedAt = new DateTime(2026, 8, 25, 9, 30, 0), IsActive = true } new Payment { Id = Payment1Id, FK_InvoiceId = OperationsDataSeed.Invoice1Id, Amount = 3000m, Method = PaymentMethod.Cash, EntryMode = EntryMode.RealTime, CollectedAt = new DateTime(2026, 8, 25, 9, 30, 0), CollectedBy = MasterDataSeed.SalesRep1UserId, CreatedAt = new DateTime(2026, 8, 25, 9, 30, 0), IsActive = true },
new Payment { Id = Payment2Id, FK_InvoiceId = OperationsDataSeed.Invoice2Id, Amount = 1000m, Method = PaymentMethod.Cash, EntryMode = EntryMode.RealTime, CollectedAt = new DateTime(2026, 8, 31, 9, 20, 0), CollectedBy = MasterDataSeed.SalesRep2UserId, CreatedAt = new DateTime(2026, 8, 31, 9, 20, 0), IsActive = true },
new Payment { Id = Payment3Id, FK_InvoiceId = OperationsDataSeed.Invoice2Id, Amount = 800m, Method = PaymentMethod.Cheque, EntryMode = EntryMode.DayEndManual, ChequeNumber = "000456", ChequeDate = new DateTime(2026, 8, 31), CollectedAt = new DateTime(2026, 8, 31, 11, 0, 0), CollectedBy = MasterDataSeed.SalesRep2UserId, RecordedBy = MasterDataSeed.Supervisor1UserId, CreatedAt = new DateTime(2026, 8, 31, 18, 0, 0), IsActive = true },
new Payment { Id = Payment4Id, FK_InvoiceId = OperationsDataSeed.Invoice2Id, Amount = 300m, Method = PaymentMethod.Credit, EntryMode = EntryMode.RealTime, CollectedAt = new DateTime(2026, 8, 31, 11, 5, 0), CollectedBy = MasterDataSeed.SalesRep2UserId, CreatedAt = new DateTime(2026, 8, 31, 11, 5, 0), IsActive = true }
); );
modelBuilder.Entity<SaleReturn>().HasData( modelBuilder.Entity<SaleReturn>().HasData(
+34 -1
View File
@@ -13,12 +13,15 @@ public static class OperationsDataSeed
internal static readonly Guid ShopPO1Id = new("33000000-0000-0000-0000-000000000001"); internal static readonly Guid ShopPO1Id = new("33000000-0000-0000-0000-000000000001");
internal static readonly Guid ShopPO2Id = new("33000000-0000-0000-0000-000000000002"); internal static readonly Guid ShopPO2Id = new("33000000-0000-0000-0000-000000000002");
internal static readonly Guid ShopPO3Id = new("33000000-0000-0000-0000-000000000003");
internal static readonly Guid ShopPOItem1Id = new("34000000-0000-0000-0000-000000000001"); internal static readonly Guid ShopPOItem1Id = new("34000000-0000-0000-0000-000000000001");
internal static readonly Guid ShopPOItem2Id = new("34000000-0000-0000-0000-000000000002"); internal static readonly Guid ShopPOItem2Id = new("34000000-0000-0000-0000-000000000002");
internal static readonly Guid ShopPOItem3Id = new("34000000-0000-0000-0000-000000000003"); internal static readonly Guid ShopPOItem3Id = new("34000000-0000-0000-0000-000000000003");
internal static readonly Guid ShopPOItem4Id = new("34000000-0000-0000-0000-000000000004");
internal static readonly Guid Invoice1Id = new("34500000-0000-0000-0000-000000000001"); internal static readonly Guid Invoice1Id = new("34500000-0000-0000-0000-000000000001");
internal static readonly Guid Invoice2Id = new("34500000-0000-0000-0000-000000000002");
internal static readonly Guid LoadingSheet1Id = new("35000000-0000-0000-0000-000000000001"); internal static readonly Guid LoadingSheet1Id = new("35000000-0000-0000-0000-000000000001");
@@ -86,13 +89,28 @@ public static class OperationsDataSeed
FulfilledAt = null, FulfilledAt = null,
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0), CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0),
IsActive = true IsActive = true
},
new ShopPO
{
Id = ShopPO3Id,
FK_StoreId = MasterDataSeed.KandyStoreId,
RaisedById = MasterDataSeed.SalesRep2UserId,
InvoiceId = Invoice2Id,
TotalAmount = 2400m,
Status = ShopPOStatus.Fulfilled,
ConfirmedAt = new DateTime(2026, 8, 31, 8, 30, 0),
ConfirmedBy = MasterDataSeed.Supervisor1UserId,
FulfilledAt = new DateTime(2026, 8, 31, 13, 0, 0),
CreatedAt = new DateTime(2026, 8, 31, 9, 0, 0),
IsActive = true
} }
); );
modelBuilder.Entity<ShopPOItem>().HasData( modelBuilder.Entity<ShopPOItem>().HasData(
new ShopPOItem { Id = ShopPOItem1Id, FK_ShopPOId = ShopPO1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, Quantity = 30, QuantityFulfilled = 30, UnitPrice = 60m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true }, new ShopPOItem { Id = ShopPOItem1Id, FK_ShopPOId = ShopPO1Id, FK_ItemId = MasterDataSeed.Coke500ItemId, Quantity = 30, QuantityFulfilled = 30, UnitPrice = 60m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
new ShopPOItem { Id = ShopPOItem2Id, FK_ShopPOId = ShopPO1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, Quantity = 24, QuantityFulfilled = 24, UnitPrice = 50m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true }, new ShopPOItem { Id = ShopPOItem2Id, FK_ShopPOId = ShopPO1Id, FK_ItemId = MasterDataSeed.LaysClassicItemId, Quantity = 24, QuantityFulfilled = 24, UnitPrice = 50m, CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
new ShopPOItem { Id = ShopPOItem3Id, FK_ShopPOId = ShopPO2Id, FK_ItemId = MasterDataSeed.Coke1500ItemId, Quantity = 10, QuantityFulfilled = 0, UnitPrice = 120m, CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0), IsActive = true } new ShopPOItem { Id = ShopPOItem3Id, FK_ShopPOId = ShopPO2Id, FK_ItemId = MasterDataSeed.Coke1500ItemId, Quantity = 10, QuantityFulfilled = 0, UnitPrice = 120m, CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0), IsActive = true },
new ShopPOItem { Id = ShopPOItem4Id, FK_ShopPOId = ShopPO3Id, FK_ItemId = MasterDataSeed.Coke1500ItemId, Quantity = 20, QuantityFulfilled = 20, UnitPrice = 120m, CreatedAt = new DateTime(2026, 8, 31, 9, 0, 0), IsActive = true }
); );
modelBuilder.Entity<Invoice>().HasData( modelBuilder.Entity<Invoice>().HasData(
@@ -110,6 +128,21 @@ public static class OperationsDataSeed
TotalAmount = 3000m, TotalAmount = 3000m,
CreatedAt = new DateTime(2026, 8, 25, 8, 30, 0), CreatedAt = new DateTime(2026, 8, 25, 8, 30, 0),
IsActive = true IsActive = true
},
new Invoice
{
Id = Invoice2Id,
FK_ShopPOId = ShopPO3Id,
Status = InvoiceStatus.Printed,
PaymentStatus = PaymentStatus.PartiallyPaid,
CreatedBy = MasterDataSeed.SalesRep2UserId,
PrintedAt = new DateTime(2026, 8, 31, 9, 15, 0),
PrintedBy = MasterDataSeed.SalesRep2UserId,
FinalizedAt = null,
FinalizedBy = null,
TotalAmount = 2400m,
CreatedAt = new DateTime(2026, 8, 31, 9, 0, 0),
IsActive = true
} }
); );
@@ -32,8 +32,6 @@ public static class OperationsExtrasSeed
private static readonly Guid DriverComplianceConfig1Id = new("64000000-0000-0000-0000-000000000001"); private static readonly Guid DriverComplianceConfig1Id = new("64000000-0000-0000-0000-000000000001");
private static readonly Guid RideCancelRequest1Id = new("65000000-0000-0000-0000-000000000001");
private static readonly Guid RouteVisitLog1Id = new("66000000-0000-0000-0000-000000000001"); private static readonly Guid RouteVisitLog1Id = new("66000000-0000-0000-0000-000000000001");
private static readonly Guid RouteVisitLog2Id = new("66000000-0000-0000-0000-000000000002"); private static readonly Guid RouteVisitLog2Id = new("66000000-0000-0000-0000-000000000002");
@@ -95,21 +93,6 @@ public static class OperationsExtrasSeed
new DriverComplianceConfig { Id = DriverComplianceConfig1Id, EnforceLicenseExpiry = true, EnforceMedicalClearance = true, UpdatedAt = new DateTime(2026, 8, 20), UpdatedBy = MasterDataSeed.Supervisor1UserId } new DriverComplianceConfig { Id = DriverComplianceConfig1Id, EnforceLicenseExpiry = true, EnforceMedicalClearance = true, UpdatedAt = new DateTime(2026, 8, 20), UpdatedBy = MasterDataSeed.Supervisor1UserId }
); );
modelBuilder.Entity<RideCancelRequest>().HasData(
new RideCancelRequest
{
Id = RideCancelRequest1Id,
FK_RideId = OperationsDataSeed.ActiveDeliveryRunId,
TypeCode = CancelRequestType.VehicleBreakdown,
Reason = "Reported minor engine issue, resolved after inspection",
RequestedBy = MasterDataSeed.Driver2UserId,
RequestedAt = new DateTime(2026, 8, 30, 9, 10, 0),
Status = CancelRequestStatus.Rejected,
ApprovedBy = MasterDataSeed.Supervisor1UserId,
ApprovedAt = new DateTime(2026, 8, 30, 9, 15, 0)
}
);
modelBuilder.Entity<RouteVisitLog>().HasData( modelBuilder.Entity<RouteVisitLog>().HasData(
new RouteVisitLog { Id = RouteVisitLog1Id, FK_RouteId = MasterDataSeed.ColomboRouteId, FK_StoreId = MasterDataSeed.FortStoreId, FK_UserId = MasterDataSeed.SalesRep1UserId, CheckInAt = new DateTime(2026, 8, 25, 9, 0, 0), CheckInLatitude = 6.9344m, CheckInLongitude = 79.8428m, IsDeviationFlagged = false, CheckOutAt = new DateTime(2026, 8, 25, 9, 25, 0), CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true }, new RouteVisitLog { Id = RouteVisitLog1Id, FK_RouteId = MasterDataSeed.ColomboRouteId, FK_StoreId = MasterDataSeed.FortStoreId, FK_UserId = MasterDataSeed.SalesRep1UserId, CheckInAt = new DateTime(2026, 8, 25, 9, 0, 0), CheckInLatitude = 6.9344m, CheckInLongitude = 79.8428m, IsDeviationFlagged = false, CheckOutAt = new DateTime(2026, 8, 25, 9, 25, 0), CreatedAt = new DateTime(2026, 8, 25, 9, 0, 0), IsActive = true },
new RouteVisitLog { Id = RouteVisitLog2Id, FK_RouteId = MasterDataSeed.ColomboRouteId, FK_StoreId = MasterDataSeed.DehiwalaStoreId, FK_UserId = MasterDataSeed.SalesRep1UserId, CheckInAt = new DateTime(2026, 8, 25, 10, 0, 0), CheckInLatitude = 6.9000m, CheckInLongitude = 79.8654m, IsDeviationFlagged = true, CheckOutAt = null, CreatedAt = new DateTime(2026, 8, 25, 10, 0, 0), IsActive = true } new RouteVisitLog { Id = RouteVisitLog2Id, FK_RouteId = MasterDataSeed.ColomboRouteId, FK_StoreId = MasterDataSeed.DehiwalaStoreId, FK_UserId = MasterDataSeed.SalesRep1UserId, CheckInAt = new DateTime(2026, 8, 25, 10, 0, 0), CheckInLatitude = 6.9000m, CheckInLongitude = 79.8654m, IsDeviationFlagged = true, CheckOutAt = null, CreatedAt = new DateTime(2026, 8, 25, 10, 0, 0), IsActive = true }
@@ -5,6 +5,9 @@ namespace Distribution.Features.Payment;
public record CreatePaymentRequest( public record CreatePaymentRequest(
decimal Amount, decimal Amount,
PaymentMethod Method, PaymentMethod Method,
EntryMode EntryMode = EntryMode.RealTime,
string? ChequeNumber = null,
DateTime? ChequeDate = null,
Guid? CollectedByUserId = null); Guid? CollectedByUserId = null);
public record PaymentResponse( public record PaymentResponse(
@@ -12,6 +15,10 @@ public record PaymentResponse(
Guid InvoiceId, Guid InvoiceId,
decimal Amount, decimal Amount,
PaymentMethod Method, PaymentMethod Method,
EntryMode EntryMode,
string? ChequeNumber,
DateTime? ChequeDate,
DateTime CollectedAt, DateTime CollectedAt,
Guid CollectedBy, Guid CollectedBy,
Guid? RecordedBy,
DateTime CreatedAt); DateTime CreatedAt);
@@ -12,7 +12,8 @@ public class PaymentService(
ICurrentUserService currentUser) ICurrentUserService currentUser)
{ {
private static PaymentResponse ToResponse(Models.Payment p) => private static PaymentResponse ToResponse(Models.Payment p) =>
new(p.Id, p.FK_InvoiceId, p.Amount, p.Method, p.CollectedAt, p.CollectedBy, p.CreatedAt); new(p.Id, p.FK_InvoiceId, p.Amount, p.Method, p.EntryMode, p.ChequeNumber, p.ChequeDate,
p.CollectedAt, p.CollectedBy, p.RecordedBy, p.CreatedAt);
public async Task<List<PaymentResponse>?> GetByInvoiceIdAsync(Guid invoiceId) public async Task<List<PaymentResponse>?> GetByInvoiceIdAsync(Guid invoiceId)
{ {
@@ -30,16 +31,28 @@ public class PaymentService(
if (invoice.Status == InvoiceStatus.Finalized) if (invoice.Status == InvoiceStatus.Finalized)
throw new InvalidOperationException("Cannot record a payment against a finalized invoice"); throw new InvalidOperationException("Cannot record a payment against a finalized invoice");
var userId = currentUser.UserId ?? request.CollectedByUserId if (request.Method == PaymentMethod.Cheque &&
(string.IsNullOrWhiteSpace(request.ChequeNumber) || request.ChequeDate is null))
throw new InvalidOperationException("ChequeNumber and ChequeDate are required when Method is Cheque");
var recorderId = currentUser.UserId ?? request.CollectedByUserId
?? throw new InvalidOperationException("CollectedByUserId is required while auth is unavailable"); ?? throw new InvalidOperationException("CollectedByUserId is required while auth is unavailable");
var collectedBy = request.CollectedByUserId ?? recorderId;
var recordedBy = request.EntryMode == EntryMode.DayEndManual && collectedBy != recorderId
? recorderId
: (Guid?)null;
var payment = new Models.Payment var payment = new Models.Payment
{ {
FK_InvoiceId = invoiceId, FK_InvoiceId = invoiceId,
Amount = request.Amount, Amount = request.Amount,
Method = request.Method, Method = request.Method,
EntryMode = request.EntryMode,
ChequeNumber = request.Method == PaymentMethod.Cheque ? request.ChequeNumber : null,
ChequeDate = request.Method == PaymentMethod.Cheque ? request.ChequeDate : null,
CollectedAt = DateTime.Now, CollectedAt = DateTime.Now,
CollectedBy = userId CollectedBy = collectedBy,
RecordedBy = recordedBy
}; };
await uow.BeginAsync(ct); await uow.BeginAsync(ct);
@@ -1,14 +0,0 @@
using Distribution.Models;
namespace Distribution.Features.Ride;
public interface IRideCancelRequestRepository
{
Task<RideCancelRequest?> GetByIdAsync(Guid id);
Task<RideCancelRequest?> GetByRideIdAsync(Guid rideId);
Task<RideCancelRequest?> GetPendingByRideIdAsync(Guid rideId);
Task<List<RideCancelRequest>> GetAllAsync(CancelRequestStatus? status, int page, int pageSize);
Task<int> GetCountAsync(CancelRequestStatus? status);
Task AddAsync(RideCancelRequest request);
void Update(RideCancelRequest request);
}
@@ -1,39 +0,0 @@
using Distribution.Data;
using Distribution.Models;
using Microsoft.EntityFrameworkCore;
namespace Distribution.Features.Ride;
public class RideCancelRequestRepository(AppDBContext context) : IRideCancelRequestRepository
{
public async Task<RideCancelRequest?> GetByIdAsync(Guid id) =>
await context.RideCancelRequests.FirstOrDefaultAsync(r => r.Id == id);
public async Task<RideCancelRequest?> GetByRideIdAsync(Guid rideId) =>
await context.RideCancelRequests
.Where(r => r.FK_RideId == rideId)
.OrderByDescending(r => r.RequestedAt)
.FirstOrDefaultAsync();
public async Task<RideCancelRequest?> GetPendingByRideIdAsync(Guid rideId) =>
await context.RideCancelRequests
.FirstOrDefaultAsync(r => r.FK_RideId == rideId && r.Status == CancelRequestStatus.Pending);
public async Task<List<RideCancelRequest>> GetAllAsync(CancelRequestStatus? status, int page, int pageSize) =>
await context.RideCancelRequests
.Where(r => status == null || r.Status == status)
.OrderByDescending(r => r.RequestedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<int> GetCountAsync(CancelRequestStatus? status) =>
await context.RideCancelRequests
.CountAsync(r => status == null || r.Status == status);
public async Task AddAsync(RideCancelRequest request) =>
await context.RideCancelRequests.AddAsync(request);
public void Update(RideCancelRequest request) =>
context.RideCancelRequests.Update(request);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,59 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class RemoveRideCancelRequest : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "RideCancelRequests");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "RideCancelRequests",
columns: table => new
{
Id = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
FK_RideId = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
ApprovedAt = table.Column<DateTime>(type: "datetime(6)", nullable: true),
ApprovedBy = table.Column<Guid>(type: "char(36)", nullable: true, collation: "ascii_general_ci"),
Reason = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
RequestedAt = table.Column<DateTime>(type: "datetime(6)", nullable: false),
RequestedBy = table.Column<Guid>(type: "char(36)", nullable: false, collation: "ascii_general_ci"),
Status = table.Column<int>(type: "int", nullable: false),
TypeCode = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_RideCancelRequests", x => x.Id);
table.ForeignKey(
name: "FK_RideCancelRequests_DeliveryRuns_FK_RideId",
column: x => x.FK_RideId,
principalTable: "DeliveryRuns",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.InsertData(
table: "RideCancelRequests",
columns: new[] { "Id", "ApprovedAt", "ApprovedBy", "FK_RideId", "Reason", "RequestedAt", "RequestedBy", "Status", "TypeCode" },
values: new object[] { new Guid("65000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 30, 9, 15, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new Guid("31000000-0000-0000-0000-000000000002"), "Reported minor engine issue, resolved after inspection", new DateTime(2026, 8, 30, 9, 10, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000005"), 2, 0 });
migrationBuilder.CreateIndex(
name: "IX_RideCancelRequests_FK_RideId",
table: "RideCancelRequests",
column: "FK_RideId");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,69 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class AddPaymentChequeAndEntryMode : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "ChequeDate",
table: "Payments",
type: "datetime(6)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ChequeNumber",
table: "Payments",
type: "longtext",
nullable: true)
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.AddColumn<int>(
name: "EntryMode",
table: "Payments",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<Guid>(
name: "RecordedBy",
table: "Payments",
type: "char(36)",
nullable: true,
collation: "ascii_general_ci");
migrationBuilder.UpdateData(
table: "Payments",
keyColumn: "Id",
keyValue: new Guid("47000000-0000-0000-0000-000000000001"),
columns: new[] { "ChequeDate", "ChequeNumber", "EntryMode", "RecordedBy" },
values: new object[] { null, null, 0, null });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ChequeDate",
table: "Payments");
migrationBuilder.DropColumn(
name: "ChequeNumber",
table: "Payments");
migrationBuilder.DropColumn(
name: "EntryMode",
table: "Payments");
migrationBuilder.DropColumn(
name: "RecordedBy",
table: "Payments");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,76 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Distribution.Migrations
{
/// <inheritdoc />
public partial class SeedInvoice2AndMorePayments : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.InsertData(
table: "ShopPOs",
columns: new[] { "Id", "ConfirmedAt", "ConfirmedBy", "CreatedAt", "FK_StoreId", "FulfilledAt", "InvoiceId", "IsActive", "RaisedById", "Status", "TotalAmount" },
values: new object[] { new Guid("33000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 31, 8, 30, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000001"), new DateTime(2026, 8, 31, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("26000000-0000-0000-0000-000000000005"), new DateTime(2026, 8, 31, 13, 0, 0, 0, DateTimeKind.Unspecified), new Guid("34500000-0000-0000-0000-000000000002"), true, new Guid("29000000-0000-0000-0000-000000000003"), 2, 2400m });
migrationBuilder.InsertData(
table: "Invoices",
columns: new[] { "Id", "CreatedAt", "CreatedBy", "FK_ShopPOId", "FinalizedAt", "FinalizedBy", "IsActive", "PaymentStatus", "PrintedAt", "PrintedBy", "Status", "TotalAmount" },
values: new object[] { new Guid("34500000-0000-0000-0000-000000000002"), new DateTime(2026, 8, 31, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000003"), new Guid("33000000-0000-0000-0000-000000000003"), null, null, true, 1, new DateTime(2026, 8, 31, 9, 15, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000003"), 1, 2400m });
migrationBuilder.InsertData(
table: "ShopPOItems",
columns: new[] { "Id", "CreatedAt", "FK_ItemId", "FK_ShopPOId", "IsActive", "Quantity", "QuantityFulfilled", "UnitPrice" },
values: new object[] { new Guid("34000000-0000-0000-0000-000000000004"), new DateTime(2026, 8, 31, 9, 0, 0, 0, DateTimeKind.Unspecified), new Guid("23000000-0000-0000-0000-000000000002"), new Guid("33000000-0000-0000-0000-000000000003"), true, 20, 20, 120m });
migrationBuilder.InsertData(
table: "Payments",
columns: new[] { "Id", "Amount", "ChequeDate", "ChequeNumber", "CollectedAt", "CollectedBy", "CreatedAt", "EntryMode", "FK_InvoiceId", "IsActive", "Method", "RecordedBy" },
values: new object[,]
{
{ new Guid("47000000-0000-0000-0000-000000000002"), 1000m, null, null, new DateTime(2026, 8, 31, 9, 20, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 31, 9, 20, 0, 0, DateTimeKind.Unspecified), 0, new Guid("34500000-0000-0000-0000-000000000002"), true, 0, null },
{ new Guid("47000000-0000-0000-0000-000000000003"), 800m, new DateTime(2026, 8, 31, 0, 0, 0, 0, DateTimeKind.Unspecified), "000456", new DateTime(2026, 8, 31, 11, 0, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 31, 18, 0, 0, 0, DateTimeKind.Unspecified), 1, new Guid("34500000-0000-0000-0000-000000000002"), true, 1, new Guid("29000000-0000-0000-0000-000000000001") },
{ new Guid("47000000-0000-0000-0000-000000000004"), 300m, null, null, new DateTime(2026, 8, 31, 11, 5, 0, 0, DateTimeKind.Unspecified), new Guid("29000000-0000-0000-0000-000000000003"), new DateTime(2026, 8, 31, 11, 5, 0, 0, DateTimeKind.Unspecified), 0, new Guid("34500000-0000-0000-0000-000000000002"), true, 2, null }
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DeleteData(
table: "Payments",
keyColumn: "Id",
keyValue: new Guid("47000000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "Payments",
keyColumn: "Id",
keyValue: new Guid("47000000-0000-0000-0000-000000000003"));
migrationBuilder.DeleteData(
table: "Payments",
keyColumn: "Id",
keyValue: new Guid("47000000-0000-0000-0000-000000000004"));
migrationBuilder.DeleteData(
table: "ShopPOItems",
keyColumn: "Id",
keyValue: new Guid("34000000-0000-0000-0000-000000000004"));
migrationBuilder.DeleteData(
table: "Invoices",
keyColumn: "Id",
keyValue: new Guid("34500000-0000-0000-0000-000000000002"));
migrationBuilder.DeleteData(
table: "ShopPOs",
keyColumn: "Id",
keyValue: new Guid("33000000-0000-0000-0000-000000000003"));
}
}
}
@@ -572,6 +572,19 @@ namespace Distribution.Migrations
PrintedBy = new Guid("29000000-0000-0000-0000-000000000002"), PrintedBy = new Guid("29000000-0000-0000-0000-000000000002"),
Status = 2, Status = 2,
TotalAmount = 3000m TotalAmount = 3000m
},
new
{
Id = new Guid("34500000-0000-0000-0000-000000000002"),
CreatedAt = new DateTime(2026, 8, 31, 9, 0, 0, 0, DateTimeKind.Unspecified),
CreatedBy = new Guid("29000000-0000-0000-0000-000000000003"),
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000003"),
IsActive = true,
PaymentStatus = 1,
PrintedAt = new DateTime(2026, 8, 31, 9, 15, 0, 0, DateTimeKind.Unspecified),
PrintedBy = new Guid("29000000-0000-0000-0000-000000000003"),
Status = 1,
TotalAmount = 2400m
}); });
}); });
@@ -833,6 +846,12 @@ namespace Distribution.Migrations
b.Property<decimal>("Amount") b.Property<decimal>("Amount")
.HasColumnType("decimal(65,30)"); .HasColumnType("decimal(65,30)");
b.Property<DateTime?>("ChequeDate")
.HasColumnType("datetime(6)");
b.Property<string>("ChequeNumber")
.HasColumnType("longtext");
b.Property<DateTime>("CollectedAt") b.Property<DateTime>("CollectedAt")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
@@ -842,6 +861,9 @@ namespace Distribution.Migrations
b.Property<DateTime>("CreatedAt") b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)"); .HasColumnType("datetime(6)");
b.Property<int>("EntryMode")
.HasColumnType("int");
b.Property<Guid>("FK_InvoiceId") b.Property<Guid>("FK_InvoiceId")
.HasColumnType("char(36)"); .HasColumnType("char(36)");
@@ -851,6 +873,9 @@ namespace Distribution.Migrations
b.Property<int>("Method") b.Property<int>("Method")
.HasColumnType("int"); .HasColumnType("int");
b.Property<Guid?>("RecordedBy")
.HasColumnType("char(36)");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("FK_InvoiceId"); b.HasIndex("FK_InvoiceId");
@@ -865,9 +890,49 @@ namespace Distribution.Migrations
CollectedAt = new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified), CollectedAt = new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified),
CollectedBy = new Guid("29000000-0000-0000-0000-000000000002"), CollectedBy = new Guid("29000000-0000-0000-0000-000000000002"),
CreatedAt = new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified), CreatedAt = new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified),
EntryMode = 0,
FK_InvoiceId = new Guid("34500000-0000-0000-0000-000000000001"), FK_InvoiceId = new Guid("34500000-0000-0000-0000-000000000001"),
IsActive = true, IsActive = true,
Method = 0 Method = 0
},
new
{
Id = new Guid("47000000-0000-0000-0000-000000000002"),
Amount = 1000m,
CollectedAt = new DateTime(2026, 8, 31, 9, 20, 0, 0, DateTimeKind.Unspecified),
CollectedBy = new Guid("29000000-0000-0000-0000-000000000003"),
CreatedAt = new DateTime(2026, 8, 31, 9, 20, 0, 0, DateTimeKind.Unspecified),
EntryMode = 0,
FK_InvoiceId = new Guid("34500000-0000-0000-0000-000000000002"),
IsActive = true,
Method = 0
},
new
{
Id = new Guid("47000000-0000-0000-0000-000000000003"),
Amount = 800m,
ChequeDate = new DateTime(2026, 8, 31, 0, 0, 0, 0, DateTimeKind.Unspecified),
ChequeNumber = "000456",
CollectedAt = new DateTime(2026, 8, 31, 11, 0, 0, 0, DateTimeKind.Unspecified),
CollectedBy = new Guid("29000000-0000-0000-0000-000000000003"),
CreatedAt = new DateTime(2026, 8, 31, 18, 0, 0, 0, DateTimeKind.Unspecified),
EntryMode = 1,
FK_InvoiceId = new Guid("34500000-0000-0000-0000-000000000002"),
IsActive = true,
Method = 1,
RecordedBy = new Guid("29000000-0000-0000-0000-000000000001")
},
new
{
Id = new Guid("47000000-0000-0000-0000-000000000004"),
Amount = 300m,
CollectedAt = new DateTime(2026, 8, 31, 11, 5, 0, 0, DateTimeKind.Unspecified),
CollectedBy = new Guid("29000000-0000-0000-0000-000000000003"),
CreatedAt = new DateTime(2026, 8, 31, 11, 5, 0, 0, DateTimeKind.Unspecified),
EntryMode = 0,
FK_InvoiceId = new Guid("34500000-0000-0000-0000-000000000002"),
IsActive = true,
Method = 2
}); });
}); });
@@ -1407,58 +1472,6 @@ namespace Distribution.Migrations
}); });
}); });
modelBuilder.Entity("Distribution.Models.RideCancelRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("char(36)");
b.Property<DateTime?>("ApprovedAt")
.HasColumnType("datetime(6)");
b.Property<Guid?>("ApprovedBy")
.HasColumnType("char(36)");
b.Property<Guid>("FK_RideId")
.HasColumnType("char(36)");
b.Property<string>("Reason")
.IsRequired()
.HasColumnType("longtext");
b.Property<DateTime>("RequestedAt")
.HasColumnType("datetime(6)");
b.Property<Guid>("RequestedBy")
.HasColumnType("char(36)");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<int>("TypeCode")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("FK_RideId");
b.ToTable("RideCancelRequests");
b.HasData(
new
{
Id = new Guid("65000000-0000-0000-0000-000000000001"),
ApprovedAt = new DateTime(2026, 8, 30, 9, 15, 0, 0, DateTimeKind.Unspecified),
ApprovedBy = new Guid("29000000-0000-0000-0000-000000000001"),
FK_RideId = new Guid("31000000-0000-0000-0000-000000000002"),
Reason = "Reported minor engine issue, resolved after inspection",
RequestedAt = new DateTime(2026, 8, 30, 9, 10, 0, 0, DateTimeKind.Unspecified),
RequestedBy = new Guid("29000000-0000-0000-0000-000000000005"),
Status = 2,
TypeCode = 0
});
});
modelBuilder.Entity("Distribution.Models.Role", b => modelBuilder.Entity("Distribution.Models.Role", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -2104,6 +2117,20 @@ namespace Distribution.Migrations
RaisedById = new Guid("29000000-0000-0000-0000-000000000003"), RaisedById = new Guid("29000000-0000-0000-0000-000000000003"),
Status = 0, Status = 0,
TotalAmount = 1200m TotalAmount = 1200m
},
new
{
Id = new Guid("33000000-0000-0000-0000-000000000003"),
ConfirmedAt = new DateTime(2026, 8, 31, 8, 30, 0, 0, DateTimeKind.Unspecified),
ConfirmedBy = new Guid("29000000-0000-0000-0000-000000000001"),
CreatedAt = new DateTime(2026, 8, 31, 9, 0, 0, 0, DateTimeKind.Unspecified),
FK_StoreId = new Guid("26000000-0000-0000-0000-000000000005"),
FulfilledAt = new DateTime(2026, 8, 31, 13, 0, 0, 0, DateTimeKind.Unspecified),
InvoiceId = new Guid("34500000-0000-0000-0000-000000000002"),
IsActive = true,
RaisedById = new Guid("29000000-0000-0000-0000-000000000003"),
Status = 2,
TotalAmount = 2400m
}); });
}); });
@@ -2175,6 +2202,17 @@ namespace Distribution.Migrations
Quantity = 10, Quantity = 10,
QuantityFulfilled = 0, QuantityFulfilled = 0,
UnitPrice = 120m UnitPrice = 120m
},
new
{
Id = new Guid("34000000-0000-0000-0000-000000000004"),
CreatedAt = new DateTime(2026, 8, 31, 9, 0, 0, 0, DateTimeKind.Unspecified),
FK_ItemId = new Guid("23000000-0000-0000-0000-000000000002"),
FK_ShopPOId = new Guid("33000000-0000-0000-0000-000000000003"),
IsActive = true,
Quantity = 20,
QuantityFulfilled = 20,
UnitPrice = 120m
}); });
}); });
@@ -3553,17 +3591,6 @@ namespace Distribution.Migrations
b.Navigation("Region"); b.Navigation("Region");
}); });
modelBuilder.Entity("Distribution.Models.RideCancelRequest", b =>
{
b.HasOne("Distribution.Models.DeliveryRun", "Ride")
.WithMany()
.HasForeignKey("FK_RideId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Ride");
});
modelBuilder.Entity("Distribution.Models.RolePermission", b => modelBuilder.Entity("Distribution.Models.RolePermission", b =>
{ {
b.HasOne("Distribution.Models.Permission", "Permission") b.HasOne("Distribution.Models.Permission", "Permission")
+3 -3
View File
@@ -14,8 +14,6 @@ namespace Distribution.Models
public enum CancelRequestType { VehicleBreakdown, DriverUnavailable, RouteIssue, Other } public enum CancelRequestType { VehicleBreakdown, DriverUnavailable, RouteIssue, Other }
public enum CancelRequestStatus { Pending, Approved, Rejected }
public enum PurchaseOrderStatus { Issued, PartiallyReceived, Received, Closed, Cancelled } public enum PurchaseOrderStatus { Issued, PartiallyReceived, Received, Closed, Cancelled }
public enum ShopPOStatus { Pending, PartiallyFulfilled, Fulfilled, Cancelled } public enum ShopPOStatus { Pending, PartiallyFulfilled, Fulfilled, Cancelled }
@@ -24,7 +22,9 @@ namespace Distribution.Models
public enum PaymentStatus { Pending, PartiallyPaid, Paid } public enum PaymentStatus { Pending, PartiallyPaid, Paid }
public enum PaymentMethod { Cash, Credit } public enum PaymentMethod { Cash, Cheque, Credit }
public enum EntryMode { RealTime, DayEndManual }
public enum DeliveryTaskStatus { Pending, Delivered, PartiallyDelivered, Failed } public enum DeliveryTaskStatus { Pending, Delivered, PartiallyDelivered, Failed }
+4
View File
@@ -10,7 +10,11 @@ namespace Distribution.Models
public decimal Amount { get; set; } public decimal Amount { get; set; }
public PaymentMethod Method { get; set; } public PaymentMethod Method { get; set; }
public EntryMode EntryMode { get; set; } = EntryMode.RealTime;
public string? ChequeNumber { get; set; }
public DateTime? ChequeDate { get; set; }
public DateTime CollectedAt { get; set; } public DateTime CollectedAt { get; set; }
public Guid CollectedBy { get; set; } public Guid CollectedBy { get; set; }
public Guid? RecordedBy { get; set; }
} }
} }
-23
View File
@@ -1,23 +0,0 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Distribution.Models
{
public class RideCancelRequest
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid FK_RideId { get; set; }
[ForeignKey("FK_RideId")]
public DeliveryRun Ride { get; set; }
public CancelRequestType TypeCode { get; set; }
public string Reason { get; set; }
public Guid RequestedBy { get; set; }
public DateTime RequestedAt { get; set; } = DateTime.Now;
public CancelRequestStatus Status { get; set; } = CancelRequestStatus.Pending;
public Guid? ApprovedBy { get; set; }
public DateTime? ApprovedAt { get; set; }
}
}
@@ -1,11 +1,36 @@
# [Refactor] Ride Lifecycle — Route-Free, Type-Free Rides # [Refactor] Ride Lifecycle — Route-Free, Type-Free Rides
**Status**: Proposed (design only — no code changes yet) **Status**: Mostly implemented — see [Implementation Status](#implementation-status-2026-09-17)
**Refactors**: [../03_ride_lifecycle.md](../03_ride_lifecycle.md) (kept as-is, this is the target design) **Refactors**: [../03_ride_lifecycle.md](../03_ride_lifecycle.md) (kept as-is, this is the target design)
**Depends On**: 02 — Stock Ledger **Depends On**: 02 — Stock Ledger
--- ---
## Implementation Status (2026-09-17)
**Done**, verified against `Models/DeliveryRun.cs`, `Features/Ride/*`:
- `RideStatus` collapsed to `Open`/`Cancelled`/`Closed` — no Draft/Active stage anywhere.
- `FK_RouteId`, `RideType`, `ActivatedAt`, `FK_SourceWarehouseId` are all gone from `DeliveryRun`.
- `POST /api/rides` creates directly into `Open` with `StartedAt` set; no `/open`/`/activate` routes.
- `POST /api/rides/{id}/cancel` cancels an `Open` ride directly — no approval step, no
`CancelRequested` stage.
- Driver compliance check (`CheckDriverComplianceAsync`) and the "vehicle can't have two Open rides"
guard are both wired into `RideService.CreateAsync`.
- Dead `RideCancelRequest` model/repository/table removed (2026-09-17) — see Migration Notes.
**Not done / diverges from this doc:**
1. **`GET /api/rides/breakdown-assist/{rideId}`** — not implemented at all (no route in
`RideController.cs`), not even the trimmed "vehicle stock levels only" version this doc describes.
2. **Impact item 1 (delivery task generation) confirmed still broken, honestly**:
`DeliveryTaskService.GenerateAsync` is a one-line `throw new NotImplementedException("... route-
based dispatch not yet implemented.")` — matches this doc's prediction exactly, not yet designed.
3. **Impact item 4 (stock reconciliation at Close/Cancel without a source warehouse)** — still
unaddressed; `CloseAsync`/`CancelAsync` do not touch `StockBalance` at all.
4. **Impact item 2 (loading sheet auto-population)** — resolved differently than either this doc or
`loading_event.md` predicted; see that document's own Implementation Status section.
---
## Goal ## Goal
Ride should not require a `Route`, should not carry a `RideType`, should not be gated by a warehouse's `Type`, and should not even pin down a source warehouse on the ride record itself. Per direction from the business owner, the ride record carries only what a real dispatch needs to track — **"a ride only carries vehicle, driver assign, and meter reading from start to end."** Everything route-shaped (which stores get visited, which orders/items travel on this trip) and everything warehouse-shaped (where stock loads from/returns to) moves out of the Ride entity's responsibility entirely; neither is replaced by another matching mechanism here. Ride should not require a `Route`, should not carry a `RideType`, should not be gated by a warehouse's `Type`, and should not even pin down a source warehouse on the ride record itself. Per direction from the business owner, the ride record carries only what a real dispatch needs to track — **"a ride only carries vehicle, driver assign, and meter reading from start to end."** Everything route-shaped (which stores get visited, which orders/items travel on this trip) and everything warehouse-shaped (where stock loads from/returns to) moves out of the Ride entity's responsibility entirely; neither is replaced by another matching mechanism here.
@@ -215,9 +240,10 @@ Not part of this docs-only pass, but captured for the eventual implementation st
`DeliveryRun`/`Ride` and their Fluent/EF configuration. `Status` stays, re-typed to the narrowed `DeliveryRun`/`Ride` and their Fluent/EF configuration. `Status` stays, re-typed to the narrowed
`RideStatus` (`Open`/`Cancelled`/`Closed`). Add `CancelTypeCode`, `CancelReason`, `CancelledAt` `RideStatus` (`Open`/`Cancelled`/`Closed`). Add `CancelTypeCode`, `CancelReason`, `CancelledAt`
columns to `DeliveryRun`. columns to `DeliveryRun`.
- Drop the `RideCancelRequest` table, its Fluent/EF configuration, and - ~~Drop the `RideCancelRequest` table, its Fluent/EF configuration, and
`RideCancelRequestRepository`/`IRideCancelRequestRepository` — the request/approve/reject flow is `RideCancelRequestRepository`/`IRideCancelRequestRepository`~~ — **Done (2026-09-17)**: model,
gone. repository, `DbSet`, and seed data removed; `CancelRequestStatus` enum removed as unused;
migration `RemoveRideCancelRequest` applied, dropping the table.
- Remove `RouteId`, `RideType`, and `SourceWarehouseId`/`SourceWarehouseName` from - Remove `RouteId`, `RideType`, and `SourceWarehouseId`/`SourceWarehouseName` from
`CreateRideRequest`, `RideResponse`, and `BreakdownAssistRideResponse` in `RideDTOs.cs`. Remove the `CreateRideRequest`, `RideResponse`, and `BreakdownAssistRideResponse` in `RideDTOs.cs`. Remove the
open-orders list from `BreakdownAssistResponse`. Remove the `RaiseCancelRequest`/cancel-request open-orders list from `BreakdownAssistResponse`. Remove the `RaiseCancelRequest`/cancel-request
@@ -11,6 +11,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select' } from '@/components/ui/select'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { import {
Dialog, Dialog,
@@ -25,11 +26,23 @@ import { toast } from '@/components/ui/toast'
import { payments as paymentsApi, getApiError } from '@/lib/api/index' import { payments as paymentsApi, getApiError } from '@/lib/api/index'
import type { InvoiceResponse } from '@/types/api' import type { InvoiceResponse } from '@/types/api'
const schema = z.object({ const schema = z
invoiceId: z.string().min(1, 'Invoice is required'), .object({
amount: z.number().min(0.01, 'Amount must be greater than 0'), invoiceId: z.string().min(1, 'Invoice is required'),
method: z.enum(['Cash', 'Credit']), amount: z.number().min(0.01, 'Amount must be greater than 0'),
}) method: z.enum(['Cash', 'Cheque', 'Credit']),
entryMode: z.enum(['RealTime', 'DayEndManual']),
chequeNumber: z.string().optional(),
chequeDate: z.string().optional(),
})
.refine((data) => data.method !== 'Cheque' || !!data.chequeNumber, {
message: 'Cheque number is required for cheque payments',
path: ['chequeNumber'],
})
.refine((data) => data.method !== 'Cheque' || !!data.chequeDate, {
message: 'Cheque date is required for cheque payments',
path: ['chequeDate'],
})
type FormValues = z.infer<typeof schema> type FormValues = z.infer<typeof schema>
@@ -50,18 +63,35 @@ export function PaymentSheet({ open, onClose, onSuccess, invoices }: PaymentShee
invoiceId: '', invoiceId: '',
amount: 0, amount: 0,
method: 'Cash', method: 'Cash',
entryMode: 'RealTime',
chequeNumber: '',
chequeDate: '',
}, },
}) })
const method = form.watch('method')
useEffect(() => { useEffect(() => {
if (!open) return if (!open) return
form.reset({ invoiceId: '', amount: 0, method: 'Cash' }) form.reset({
invoiceId: '',
amount: 0,
method: 'Cash',
entryMode: 'RealTime',
chequeNumber: '',
chequeDate: '',
})
}, [open]) }, [open])
async function onSubmit(values: FormValues) { async function onSubmit(values: FormValues) {
const { invoiceId, ...rest } = values const { invoiceId, method, chequeNumber, chequeDate, ...rest } = values
try { try {
await paymentsApi.create(invoiceId, rest) await paymentsApi.create(invoiceId, {
...rest,
method,
chequeNumber: method === 'Cheque' ? chequeNumber : undefined,
chequeDate: method === 'Cheque' ? chequeDate : undefined,
})
toast.add({ title: 'Payment recorded', type: 'success' }) toast.add({ title: 'Payment recorded', type: 'success' })
onSuccess() onSuccess()
onClose() onClose()
@@ -77,7 +107,7 @@ export function PaymentSheet({ open, onClose, onSuccess, invoices }: PaymentShee
<DialogContent className="overflow-y-auto sm:max-w-lg"> <DialogContent className="overflow-y-auto sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>Record Payment</DialogTitle> <DialogTitle>Record Payment</DialogTitle>
<DialogDescription>Record a cash or credit payment against an invoice.</DialogDescription> <DialogDescription>Record a cash, cheque, or credit payment against an invoice.</DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6"> <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
@@ -137,11 +167,71 @@ export function PaymentSheet({ open, onClose, onSuccess, invoices }: PaymentShee
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="Cash">Cash</SelectItem> <SelectItem value="Cash">Cash</SelectItem>
<SelectItem value="Cheque">Cheque</SelectItem>
<SelectItem value="Credit">Credit</SelectItem> <SelectItem value="Credit">Credit</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
/> />
{method === 'Cheque' && (
<>
<FormField
control={form.control}
name="chequeNumber"
label="Cheque Number"
required
render={({ field, invalid }) => (
<Input
id="chequeNumber"
name={field.name}
ref={field.ref}
value={(field.value as string) ?? ''}
onChange={field.onChange}
onBlur={field.onBlur}
placeholder="e.g. 000123"
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name="chequeDate"
label="Cheque Date"
required
render={({ field, invalid }) => (
<Input
id="chequeDate"
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="entryMode"
label="Entry Mode"
required
render={({ field }) => (
<ToggleGroup
variant="outline"
value={[field.value as string]}
onValueChange={(value: string[]) => value[0] && field.onChange(value[0])}
>
<ToggleGroupItem value="RealTime">Real-time</ToggleGroupItem>
<ToggleGroupItem value="DayEndManual">Day-end entry</ToggleGroupItem>
</ToggleGroup>
)}
/>
</FieldGroup> </FieldGroup>
<DialogFooter> <DialogFooter>
@@ -7,13 +7,19 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import type { PaymentResponse, PaymentMethod } from '@/types/api' import type { PaymentResponse, PaymentMethod, EntryMode } from '@/types/api'
const PAYMENT_METHOD_VARIANT: Record<PaymentMethod, 'default' | 'secondary'> = { const PAYMENT_METHOD_VARIANT: Record<PaymentMethod, 'default' | 'secondary' | 'outline'> = {
Cash: 'default', Cash: 'default',
Cheque: 'outline',
Credit: 'secondary', Credit: 'secondary',
} }
const ENTRY_MODE_LABEL: Record<EntryMode, string> = {
RealTime: 'Real-time',
DayEndManual: 'Day-end entry',
}
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}` const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
const truncId = (id: string) => id.substring(0, 8) const truncId = (id: string) => id.substring(0, 8)
@@ -22,6 +28,9 @@ const fmtDateTime = (v: string) =>
day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
}) })
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
interface PaymentViewSheetProps { interface PaymentViewSheetProps {
open: boolean open: boolean
onClose: () => void onClose: () => void
@@ -53,8 +62,18 @@ export function PaymentViewSheet({ open, onClose, payment, invoiceName }: Paymen
<Row label="Invoice" value={invoiceName ?? <span className="font-mono">{truncId(payment.invoiceId)}</span>} /> <Row label="Invoice" value={invoiceName ?? <span className="font-mono">{truncId(payment.invoiceId)}</span>} />
<Row label="Amount" value={fmtAmount(payment.amount)} /> <Row label="Amount" value={fmtAmount(payment.amount)} />
<Row label="Method" value={<Badge variant={PAYMENT_METHOD_VARIANT[payment.method]}>{payment.method}</Badge>} /> <Row label="Method" value={<Badge variant={PAYMENT_METHOD_VARIANT[payment.method]}>{payment.method}</Badge>} />
{payment.method === 'Cheque' && (
<>
<Row label="Cheque Number" value={payment.chequeNumber ?? '—'} />
<Row label="Cheque Date" value={payment.chequeDate ? fmtDate(payment.chequeDate) : '—'} />
</>
)}
<Row label="Entry Mode" value={<Badge variant="secondary">{ENTRY_MODE_LABEL[payment.entryMode]}</Badge>} />
<Row label="Collected At" value={fmtDateTime(payment.collectedAt)} /> <Row label="Collected At" value={fmtDateTime(payment.collectedAt)} />
<Row label="Collected By" value={<span className="font-mono">{truncId(payment.collectedBy)}</span>} /> <Row label="Collected By" value={<span className="font-mono">{truncId(payment.collectedBy)}</span>} />
{payment.recordedBy && (
<Row label="Recorded By" value={<span className="font-mono">{truncId(payment.recordedBy)}</span>} />
)}
<Row label="Created At" value={fmtDateTime(payment.createdAt)} /> <Row label="Created At" value={fmtDateTime(payment.createdAt)} />
</div> </div>
</DialogContent> </DialogContent>
@@ -11,7 +11,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { toast } from '@/components/ui/toast' import { toast } from '@/components/ui/toast'
import { invoices as invoicesApi, payments as paymentsApi, getApiError, getPaginatedItems } from '@/lib/api/index' import { invoices as invoicesApi, payments as paymentsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { PaymentResponse, PaymentMethod, InvoiceResponse } from '@/types/api' import type { PaymentResponse, PaymentMethod, EntryMode, InvoiceResponse } from '@/types/api'
import { PaymentSheet } from './payment-sheet' import { PaymentSheet } from './payment-sheet'
import { PaymentViewSheet } from './payment-view-sheet' import { PaymentViewSheet } from './payment-view-sheet'
@@ -22,11 +22,17 @@ const fmtDate = (v: string) =>
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}` const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
const truncId = (id: string) => id.substring(0, 8) const truncId = (id: string) => id.substring(0, 8)
const PAYMENT_METHOD_VARIANT: Record<PaymentMethod, 'default' | 'secondary'> = { const PAYMENT_METHOD_VARIANT: Record<PaymentMethod, 'default' | 'secondary' | 'outline'> = {
Cash: 'default', Cash: 'default',
Cheque: 'outline',
Credit: 'secondary', Credit: 'secondary',
} }
const ENTRY_MODE_LABEL: Record<EntryMode, string> = {
RealTime: 'Real-time',
DayEndManual: 'Day-end entry',
}
export function PaymentsClient() { export function PaymentsClient() {
const [invoicesList, setInvoicesList] = useState<InvoiceResponse[]>([]) const [invoicesList, setInvoicesList] = useState<InvoiceResponse[]>([])
const [selectedInvoiceId, setSelectedInvoiceId] = useState('') const [selectedInvoiceId, setSelectedInvoiceId] = useState('')
@@ -119,6 +125,15 @@ export function PaymentsClient() {
<Badge variant={PAYMENT_METHOD_VARIANT[row.method]}>{row.method}</Badge> <Badge variant={PAYMENT_METHOD_VARIANT[row.method]}>{row.method}</Badge>
), ),
}, },
{
key: 'entryMode',
title: 'Entry Mode',
fields: ['entryMode'],
width: 130,
cellRender: (row) => (
<Badge variant="secondary">{ENTRY_MODE_LABEL[row.entryMode]}</Badge>
),
},
{ {
key: 'collectedAt', key: 'collectedAt',
title: 'Collected At', title: 'Collected At',
@@ -143,7 +158,7 @@ export function PaymentsClient() {
<div> <div>
<h1 className="text-2xl font-semibold text-foreground">Payments</h1> <h1 className="text-2xl font-semibold text-foreground">Payments</h1>
<p className="text-sm text-muted-foreground mt-0.5"> <p className="text-sm text-muted-foreground mt-0.5">
Cash and credit payments collected against invoices Cash, cheque, and credit payments collected against invoices
</p> </p>
</div> </div>
<Button onClick={() => setSheetOpen(true)}> <Button onClick={() => setSheetOpen(true)}>
@@ -0,0 +1,89 @@
"use client"
import * as React from "react"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}
>({
size: "default",
variant: "default",
spacing: 2,
orientation: "horizontal",
})
function ToggleGroup({
className,
variant,
size,
spacing = 2,
orientation = "horizontal",
children,
...props
}: ToggleGroupPrimitive.Props &
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}) {
return (
<ToggleGroupPrimitive
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] data-[spacing=0]:data-[variant=outline]:rounded-2xl data-vertical:flex-col data-vertical:items-stretch",
className
)}
{...props}
>
<ToggleGroupContext.Provider
value={{ variant, size, spacing, orientation }}
>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive>
)
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<TogglePrimitive
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 group-data-[spacing=0]/toggle-group:shadow-none focus:z-10 focus-visible:z-10 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-2xl group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-2xl group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-2xl group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-2xl data-[state=on]:bg-muted group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</TogglePrimitive>
)
}
export { ToggleGroup, ToggleGroupItem }
+44
View File
@@ -0,0 +1,44 @@
"use client"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "cn"
const toggleVariants = cva(
"group/toggle inline-flex items-center justify-center gap-1 rounded-2xl text-sm font-medium whitespace-nowrap transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/30 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 aria-pressed:bg-muted dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent hover:bg-muted",
},
size: {
default:
"h-8 min-w-8 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
sm: "h-7 min-w-7 px-2.5 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5",
lg: "h-9 min-w-9 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant = "default",
size = "default",
...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
+4
View File
@@ -6,6 +6,7 @@ import type {
InvoiceStatus, InvoiceStatus,
PaymentResponse, PaymentResponse,
PaymentMethod, PaymentMethod,
EntryMode,
ShopPOStatus, ShopPOStatus,
} from '@/types/api' } from '@/types/api'
@@ -87,6 +88,9 @@ export const invoices = {
export interface PaymentBody { export interface PaymentBody {
amount: number amount: number
method: PaymentMethod method: PaymentMethod
entryMode?: EntryMode
chequeNumber?: string | null
chequeDate?: string | null
collectedByUserId?: string | null collectedByUserId?: string | null
} }
+13
View File
@@ -16,6 +16,7 @@
"axios": "^1.19.0", "axios": "^1.19.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cn": "^0.3.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"next": "16.2.6", "next": "16.2.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
@@ -3749,6 +3750,18 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/cn": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/cn/-/cn-0.3.0.tgz",
"integrity": "sha512-qRoU0ARrZD3Bbj1wKFftEKxPjSnzTEdFkf22NVhW+zU9D/wajhoP+qUZ5DChhhSOBCH1onV12hrmGqfu1+5Z2Q==",
"license": "MIT",
"bin": {
"cn": "bin/cn.mjs"
},
"engines": {
"node": ">=20"
}
},
"node_modules/code-block-writer": { "node_modules/code-block-writer": {
"version": "13.0.3", "version": "13.0.3",
"resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz",
+1
View File
@@ -20,6 +20,7 @@
"axios": "^1.19.0", "axios": "^1.19.0",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cn": "^0.3.0",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"next": "16.2.6", "next": "16.2.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
+6 -1
View File
@@ -15,7 +15,8 @@ export type ShopPOStatus = 'Pending' | 'PartiallyFulfilled' | 'Fulfilled' | 'Can
export type InvoiceStatus = 'Draft' | 'Printed' | 'Finalized' export type InvoiceStatus = 'Draft' | 'Printed' | 'Finalized'
export type InvoiceCreationMode = 'ModeA' | 'ModeB' export type InvoiceCreationMode = 'ModeA' | 'ModeB'
export type PaymentStatus = 'Pending' | 'PartiallyPaid' | 'Paid' export type PaymentStatus = 'Pending' | 'PartiallyPaid' | 'Paid'
export type PaymentMethod = 'Cash' | 'Credit' export type PaymentMethod = 'Cash' | 'Cheque' | 'Credit'
export type EntryMode = 'RealTime' | 'DayEndManual'
export type DeliveryTaskStatus = 'Pending' | 'Delivered' | 'PartiallyDelivered' | 'Failed' export type DeliveryTaskStatus = 'Pending' | 'Delivered' | 'PartiallyDelivered' | 'Failed'
export type VehicleType = 'Car' | 'Truck' | 'Van' | 'Motorcycle' | 'Bicycle' export type VehicleType = 'Car' | 'Truck' | 'Van' | 'Motorcycle' | 'Bicycle'
export type WarehouseType = export type WarehouseType =
@@ -666,8 +667,12 @@ export interface PaymentResponse {
invoiceId: string invoiceId: string
amount: number amount: number
method: PaymentMethod method: PaymentMethod
entryMode: EntryMode
chequeNumber: string | null
chequeDate: string | null
collectedAt: string collectedAt: string
collectedBy: string collectedBy: string
recordedBy: string | null
createdAt: string createdAt: string
} }