Compare commits

...

10 Commits

36 changed files with 13467 additions and 553 deletions
-1
View File
@@ -37,7 +37,6 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
public DbSet<Supplier> Suppliers { get; set; }
public DbSet<DriverProfile> DriverProfiles { get; set; }
public DbSet<DriverComplianceConfig> DriverComplianceConfigs { get; set; }
public DbSet<RideCancelRequest> RideCancelRequests { get; set; }
public DbSet<Scheme> Schemes { get; set; }
public DbSet<SchemeRedemption> SchemeRedemptions { 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 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 SaleReturnItem1Id = new("52000000-0000-0000-0000-000000000001");
@@ -84,7 +87,10 @@ public static class CommerceSeed
);
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(
+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 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 ShopPOItem2Id = new("34000000-0000-0000-0000-000000000002");
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 Invoice2Id = new("34500000-0000-0000-0000-000000000002");
internal static readonly Guid LoadingSheet1Id = new("35000000-0000-0000-0000-000000000001");
@@ -86,13 +89,28 @@ public static class OperationsDataSeed
FulfilledAt = null,
CreatedAt = new DateTime(2026, 8, 30, 10, 0, 0),
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(
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 = 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(
@@ -110,6 +128,21 @@ public static class OperationsDataSeed
TotalAmount = 3000m,
CreatedAt = new DateTime(2026, 8, 25, 8, 30, 0),
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 RideCancelRequest1Id = new("65000000-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");
@@ -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 }
);
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(
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 }
@@ -5,6 +5,9 @@ namespace Distribution.Features.Payment;
public record CreatePaymentRequest(
decimal Amount,
PaymentMethod Method,
EntryMode EntryMode = EntryMode.RealTime,
string? ChequeNumber = null,
DateTime? ChequeDate = null,
Guid? CollectedByUserId = null);
public record PaymentResponse(
@@ -12,6 +15,10 @@ public record PaymentResponse(
Guid InvoiceId,
decimal Amount,
PaymentMethod Method,
EntryMode EntryMode,
string? ChequeNumber,
DateTime? ChequeDate,
DateTime CollectedAt,
Guid CollectedBy,
Guid? RecordedBy,
DateTime CreatedAt);
@@ -12,7 +12,8 @@ public class PaymentService(
ICurrentUserService currentUser)
{
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)
{
@@ -30,16 +31,28 @@ public class PaymentService(
if (invoice.Status == InvoiceStatus.Finalized)
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");
var collectedBy = request.CollectedByUserId ?? recorderId;
var recordedBy = request.EntryMode == EntryMode.DayEndManual && collectedBy != recorderId
? recorderId
: (Guid?)null;
var payment = new Models.Payment
{
FK_InvoiceId = invoiceId,
Amount = request.Amount,
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,
CollectedBy = userId
CollectedBy = collectedBy,
RecordedBy = recordedBy
};
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);
}
@@ -898,23 +898,7 @@ namespace Distribution.Migrations
private static void DropForeignKeyIfExists(MigrationBuilder migrationBuilder, string tableName, string constraintName)
{
migrationBuilder.Sql($"""
SET @sql = IF(
EXISTS(
SELECT 1
FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE()
AND TABLE_NAME = '{tableName}'
AND CONSTRAINT_NAME = '{constraintName}'
AND CONSTRAINT_TYPE = 'FOREIGN KEY'
),
'ALTER TABLE `{tableName}` DROP FOREIGN KEY `{constraintName}`',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
""");
migrationBuilder.Sql($"ALTER TABLE `{tableName}` DROP FOREIGN KEY IF EXISTS `{constraintName}`;");
}
private static void DropIndexIfExists(MigrationBuilder migrationBuilder, string tableName, string indexName)
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"),
Status = 2,
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")
.HasColumnType("decimal(65,30)");
b.Property<DateTime?>("ChequeDate")
.HasColumnType("datetime(6)");
b.Property<string>("ChequeNumber")
.HasColumnType("longtext");
b.Property<DateTime>("CollectedAt")
.HasColumnType("datetime(6)");
@@ -842,6 +861,9 @@ namespace Distribution.Migrations
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime(6)");
b.Property<int>("EntryMode")
.HasColumnType("int");
b.Property<Guid>("FK_InvoiceId")
.HasColumnType("char(36)");
@@ -851,6 +873,9 @@ namespace Distribution.Migrations
b.Property<int>("Method")
.HasColumnType("int");
b.Property<Guid?>("RecordedBy")
.HasColumnType("char(36)");
b.HasKey("Id");
b.HasIndex("FK_InvoiceId");
@@ -865,9 +890,49 @@ namespace Distribution.Migrations
CollectedAt = new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified),
CollectedBy = new Guid("29000000-0000-0000-0000-000000000002"),
CreatedAt = new DateTime(2026, 8, 25, 9, 30, 0, 0, DateTimeKind.Unspecified),
EntryMode = 0,
FK_InvoiceId = new Guid("34500000-0000-0000-0000-000000000001"),
IsActive = true,
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 =>
{
b.Property<Guid>("Id")
@@ -2104,6 +2117,20 @@ namespace Distribution.Migrations
RaisedById = new Guid("29000000-0000-0000-0000-000000000003"),
Status = 0,
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,
QuantityFulfilled = 0,
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");
});
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 =>
{
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 CancelRequestStatus { Pending, Approved, Rejected }
public enum PurchaseOrderStatus { Issued, PartiallyReceived, Received, Closed, Cancelled }
public enum ShopPOStatus { Pending, PartiallyFulfilled, Fulfilled, Cancelled }
@@ -24,7 +22,9 @@ namespace Distribution.Models
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 }
+4
View File
@@ -10,7 +10,11 @@ namespace Distribution.Models
public decimal Amount { 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 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,8 +1,23 @@
# Refactor Implementation Plan
**Status**: Ready to implement
**Status**: Implementation complete — migration pending (see Phase 5)
**Design docs**: [order_invoice_lifecycle.md](./order_invoice_lifecycle.md), [loading_event.md](./loading_event.md), [dispatch_pipeline.md](./dispatch_pipeline.md), [ride_lifecycle.md](./ride_lifecycle.md)
| Phase | Title | Status |
|-------|-------|--------|
| 1 | Enums | ✅ Done |
| 2 | Remove Sale | ✅ Done |
| 3 | Model Changes | ✅ Done |
| 4 | DbContext | ✅ Done |
| 5 | Migration | ⚠️ Migration created — `dotnet ef database update` failing (FK drop error — fix applied 2026-09-17) |
| 6 | Invoice Feature | ✅ Done (GET list/detail only; create/print/finalize endpoints pending) |
| 7 | ShopPO Feature | ✅ Done (GET list/detail only; confirm/cancel/items endpoints pending) |
| 8 | Payment Feature | ✅ Done |
| 9 | LoadingEvent Overhaul | ✅ Done |
| 10 | Ride Overhaul | ✅ Done |
| 11 | SysConfig + InvoiceCreationMode | ✅ Done (replaces original Program.cs/appsettings plan; implemented as DB-backed key-value store) |
| 12 | Permission Seeding | ⏳ Pending |
---
## Scope
@@ -1,11 +1,36 @@
# [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)
**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
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
`RideStatus` (`Open`/`Cancelled`/`Closed`). Add `CancelTypeCode`, `CancelReason`, `CancelledAt`
columns to `DeliveryRun`.
- Drop the `RideCancelRequest` table, its Fluent/EF configuration, and
`RideCancelRequestRepository`/`IRideCancelRequestRepository` — the request/approve/reject flow is
gone.
- ~~Drop the `RideCancelRequest` table, its Fluent/EF configuration, and
`RideCancelRequestRepository`/`IRideCancelRequestRepository`~~ — **Done (2026-09-17)**: model,
repository, `DbSet`, and seed data removed; `CancelRequestStatus` enum removed as unused;
migration `RemoveRideCancelRequest` applied, dropping the table.
- Remove `RouteId`, `RideType`, and `SourceWarehouseId`/`SourceWarehouseName` from
`CreateRideRequest`, `RideResponse`, and `BreakdownAssistRideResponse` in `RideDTOs.cs`. Remove the
open-orders list from `BreakdownAssistResponse`. Remove the `RaiseCancelRequest`/cancel-request
@@ -8,9 +8,18 @@ import { DataTable } from '@/components/shared/data-table'
import type { TableConfig } from '@/components/shared/data-table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { toast } from '@/components/ui/toast'
import { loadingSheets as loadingSheetsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { LoadingSheetResponse, LoadingSheetStatus } from '@/types/api'
import { loadingSheets as loadingSheetsApi, rides as ridesApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { LoadingSheetResponse, LoadingSheetStatus, RideResponse } from '@/types/api'
const ALL = '__all__'
import { LoadingSheetViewSheet } from './loading-sheet-view-sheet'
import { LoadingSheetCreateDialog } from './loading-sheet-create-dialog'
@@ -36,10 +45,25 @@ export function LoadingSheetsClient() {
const [viewOpen, setViewOpen] = useState(false)
const [selectedSheet, setSelectedSheet] = useState<LoadingSheetResponse | null>(null)
const [ridesList, setRidesList] = useState<RideResponse[]>([])
const [rideId, setRideId] = useState('')
const [statusFilter, setStatusFilter] = useState<LoadingSheetStatus | ''>('')
useEffect(() => {
ridesApi.list({ pageSize: 200 })
.then((res) => setRidesList(getPaginatedItems(res)))
.catch((err) => toast.add({ title: 'Failed to load rides', description: getApiError(err), type: 'error' }))
}, [])
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
try {
const res = await loadingSheetsApi.list({ page: p, pageSize: ps })
const res = await loadingSheetsApi.list({
page: p,
pageSize: ps,
rideId: rideId || undefined,
status: statusFilter || undefined,
})
setData(getPaginatedItems(res))
setTotal(res.total)
} catch (err) {
@@ -47,12 +71,19 @@ export function LoadingSheetsClient() {
} finally {
setLoading(false)
}
}, [])
}, [rideId, statusFilter])
useEffect(() => {
fetchData(page, pageSize)
}, [fetchData, page, pageSize])
const hasFilters = !!rideId || !!statusFilter
function clearFilters() {
setRideId('')
setStatusFilter('')
}
function handleView(row: LoadingSheetResponse) {
setSelectedSheet(row)
setViewOpen(true)
@@ -159,6 +190,49 @@ export function LoadingSheetsClient() {
</Button>
</div>
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Ride</label>
<Select
value={rideId || ALL}
onValueChange={(v) => { setRideId(v === ALL ? '' : v); setPage(1) }}
>
<SelectTrigger className="w-52">
<SelectValue placeholder="All rides" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All rides</SelectItem>
{ridesList.map((r) => (
<SelectItem key={r.id} value={r.id}>{r.vehicleNumberPlate} — {r.driverFullName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Status</label>
<Select
value={statusFilter || ALL}
onValueChange={(v) => { setStatusFilter(v === ALL ? '' : v as LoadingSheetStatus); setPage(1) }}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All statuses</SelectItem>
<SelectItem value="Planned">Planned</SelectItem>
<SelectItem value="Confirmed">Confirmed</SelectItem>
</SelectContent>
</Select>
</div>
{hasFilters && (
<Button variant="ghost" size="sm" onClick={() => { clearFilters(); setPage(1) }}>
Clear filters
</Button>
)}
</div>
<DataTable
config={tableConfig}
data={data}
@@ -0,0 +1,216 @@
'use client'
import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowLeft01Icon } from '@hugeicons/core-free-icons'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/toast'
import { shopPos as shopPosApi, getApiError } from '@/lib/api/index'
import type { ShopPOResponse, ShopPOStatus } from '@/types/api'
const PO_STATUS_VARIANT: Record<ShopPOStatus, 'default' | 'secondary' | 'destructive'> = {
Pending: 'secondary',
PartiallyFulfilled: 'secondary',
Fulfilled: 'default',
Cancelled: 'destructive',
}
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
const truncId = (id: string) => id.substring(0, 8)
const fmtDateTime = (v: string | null) =>
v
? new Date(v).toLocaleString('en-GB', {
day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
})
: '—'
function Stat({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-sm font-medium text-foreground">{value}</span>
</div>
)
}
export default function ShopPOViewPage() {
const router = useRouter()
const params = useParams<{ id: string }>()
const [po, setPo] = useState<ShopPOResponse | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
setLoading(true)
shopPosApi
.get(params.id)
.then((res) => {
if (!cancelled) setPo(res)
})
.catch((err) => {
toast.add({ title: 'Failed to load Shop PO', description: getApiError(err), type: 'error' })
router.push('/sales')
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [params.id, router])
async function handleConfirm() {
if (!po) return
try {
const updated = await shopPosApi.confirm(po.id)
setPo(updated)
toast.add({ title: 'Shop PO confirmed', type: 'success' })
} catch (err) {
toast.add({ title: 'Failed to confirm Shop PO', description: getApiError(err), type: 'error' })
}
}
async function handleCancel() {
if (!po) return
try {
const updated = await shopPosApi.cancel(po.id)
setPo(updated)
toast.add({ title: 'Shop PO cancelled', type: 'success' })
} catch (err) {
toast.add({ title: 'Failed to cancel Shop PO', description: getApiError(err), type: 'error' })
}
}
if (loading) {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-4">
<Button type="button" variant="outline" size="sm" onClick={() => router.push('/sales')}>
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
Back
</Button>
</div>
<p className="text-sm text-muted-foreground">Loading Shop PO…</p>
</div>
)
}
if (!po) return null
const activeItems = po.items.filter((i) => i.isActive)
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4">
<Button type="button" variant="outline" size="sm" onClick={() => router.push('/sales')}>
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
Back
</Button>
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold text-foreground">
Shop PO <span className="font-mono text-lg text-muted-foreground">{truncId(po.id)}</span>
</h1>
<Badge variant={PO_STATUS_VARIANT[po.status]}>{po.status}</Badge>
</div>
<p className="text-sm text-muted-foreground">{po.storeName}</p>
</div>
</div>
<div className="flex items-center gap-2">
{!po.confirmedAt && po.status !== 'Cancelled' && (
<Button type="button" onClick={handleConfirm}>
Confirm PO
</Button>
)}
{po.status !== 'Cancelled' && po.status !== 'Fulfilled' && (
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel PO
</Button>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4 rounded-2xl border p-4 sm:grid-cols-4">
<Stat
label="Invoice"
value={
po.invoiceId ? (
<Button
type="button"
variant="link"
className="h-auto p-0 font-mono text-sm"
onClick={() => router.push(`/sales/invoices/${po.invoiceId}`)}
>
{truncId(po.invoiceId)}
</Button>
) : (
'Not raised yet'
)
}
/>
<Stat label="Created" value={fmtDateTime(po.createdAt)} />
<Stat label="Confirmed" value={fmtDateTime(po.confirmedAt)} />
<Stat label="Fulfilled" value={fmtDateTime(po.fulfilledAt)} />
</div>
<div className="rounded-2xl border p-6">
<div className="mb-4">
<h3 className="text-sm font-medium text-foreground">Items</h3>
<p className="text-sm text-muted-foreground">
{activeItems.length} item{activeItems.length === 1 ? '' : 's'} on this PO.
</p>
</div>
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="min-w-[220px] px-3 py-2 text-left font-medium">Item</th>
<th className="w-28 px-3 py-2 text-right font-medium">Quantity</th>
<th className="w-28 px-3 py-2 text-right font-medium">Fulfilled</th>
<th className="w-32 px-3 py-2 text-right font-medium">Unit Price</th>
<th className="w-32 px-3 py-2 text-right font-medium">Line Total</th>
</tr>
</thead>
<tbody>
{activeItems.length === 0 ? (
<tr>
<td colSpan={5} className="px-3 py-4 text-center text-muted-foreground">
No items on this PO.
</td>
</tr>
) : (
activeItems.map((item) => (
<tr key={item.id} className="border-t">
<td className="px-3 py-2">{item.itemName}</td>
<td className="px-3 py-2 text-right">{item.quantity}</td>
<td className="px-3 py-2 text-right">{item.quantityFulfilled}</td>
<td className="px-3 py-2 text-right">{fmtAmount(item.unitPrice)}</td>
<td className="px-3 py-2 text-right font-medium">{fmtAmount(item.quantity * item.unitPrice)}</td>
</tr>
))
)}
</tbody>
{activeItems.length > 0 && (
<tfoot>
<tr className="border-t bg-muted/30">
<td colSpan={4} className="px-3 py-3 text-right text-sm font-medium text-foreground">
Total
</td>
<td className="px-3 py-3 text-right text-base font-semibold text-foreground">
{fmtAmount(po.totalAmount)}
</td>
</tr>
</tfoot>
)}
</table>
</div>
</div>
</div>
)
}
@@ -1,6 +1,7 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react'
import { Add01Icon } from '@hugeicons/core-free-icons'
@@ -8,12 +9,26 @@ import { DataTable } from '@/components/shared/data-table'
import type { TableConfig } from '@/components/shared/data-table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { toast } from '@/components/ui/toast'
import { shopPos as shopPosApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { ShopPOResponse, ShopPOStatus } from '@/types/api'
import {
shopPos as shopPosApi,
users as usersApi,
routes as routesApi,
stores as storesApi,
getApiError,
getPaginatedItems,
} from '@/lib/api/index'
import type { ShopPOResponse, ShopPOStatus, UserResponse, RouteResponse, StoreResponse } from '@/types/api'
import { ShopPOViewSheet } from './shop-po-view-sheet'
import { ShopPOCreateSheet } from './shop-po-create-sheet'
const ALL = '__all__'
type ConfirmedFilter = 'all' | 'confirmed' | 'unconfirmed'
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
@@ -29,19 +44,47 @@ const PO_STATUS_VARIANT: Record<ShopPOStatus, 'default' | 'secondary' | 'destruc
}
export function SalesClient() {
const router = useRouter()
const [data, setData] = useState<ShopPOResponse[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [viewOpen, setViewOpen] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [selectedPO, setSelectedPO] = useState<ShopPOResponse | null>(null)
const [agentsList, setAgentsList] = useState<UserResponse[]>([])
const [routesList, setRoutesList] = useState<RouteResponse[]>([])
const [storesList, setStoresList] = useState<StoreResponse[]>([])
const [agentId, setAgentId] = useState('')
const [routeId, setRouteId] = useState('')
const [shopId, setShopId] = useState('')
const [confirmedFilter, setConfirmedFilter] = useState<ConfirmedFilter>('all')
useEffect(() => {
Promise.all([
usersApi.list({ pageSize: 200 }),
routesApi.list({ pageSize: 200 }),
storesApi.list({ pageSize: 200 }),
])
.then(([u, r, s]) => {
setAgentsList(getPaginatedItems(u))
setRoutesList(getPaginatedItems(r))
setStoresList(getPaginatedItems(s))
})
.catch((err) => toast.add({ title: 'Failed to load filter options', description: getApiError(err), type: 'error' }))
}, [])
const fetchData = useCallback(async (p: number, ps: number) => {
setLoading(true)
try {
const res = await shopPosApi.list({ page: p, pageSize: ps })
const res = await shopPosApi.list({
page: p,
pageSize: ps,
agentId: agentId || undefined,
routeId: routeId || undefined,
shopId: shopId || undefined,
confirmed: confirmedFilter === 'all' ? undefined : confirmedFilter === 'confirmed',
})
setData(getPaginatedItems(res))
setTotal(res.total)
} catch (err) {
@@ -49,15 +92,23 @@ export function SalesClient() {
} finally {
setLoading(false)
}
}, [])
}, [agentId, routeId, shopId, confirmedFilter])
useEffect(() => {
fetchData(page, pageSize)
}, [fetchData, page, pageSize])
const hasFilters = !!agentId || !!routeId || !!shopId || confirmedFilter !== 'all'
function clearFilters() {
setAgentId('')
setRouteId('')
setShopId('')
setConfirmedFilter('all')
}
function handleView(row: ShopPOResponse) {
setSelectedPO(row)
setViewOpen(true)
router.push(`/sales/${row.id}`)
}
async function handleConfirm(row: ShopPOResponse) {
@@ -170,12 +221,91 @@ export function SalesClient() {
Purchase orders raised by agents on behalf of stores
</p>
</div>
<Button onClick={() => setCreateOpen(true)}>
<Button onClick={() => router.push('/sales/new')}>
<HugeiconsIcon icon={Add01Icon} size={16} />
New PO
</Button>
</div>
<div className="flex flex-wrap items-end gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Agent</label>
<Select
value={agentId || ALL}
onValueChange={(v) => { setAgentId(v === ALL ? '' : v); setPage(1) }}
>
<SelectTrigger className="w-44">
<SelectValue placeholder="All agents" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All agents</SelectItem>
{agentsList.map((u) => (
<SelectItem key={u.id} value={u.id}>{u.fullName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Route</label>
<Select
value={routeId || ALL}
onValueChange={(v) => { setRouteId(v === ALL ? '' : v); setPage(1) }}
>
<SelectTrigger className="w-44">
<SelectValue placeholder="All routes" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All routes</SelectItem>
{routesList.map((r) => (
<SelectItem key={r.id} value={r.id}>{r.routeName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Store</label>
<Select
value={shopId || ALL}
onValueChange={(v) => { setShopId(v === ALL ? '' : v); setPage(1) }}
>
<SelectTrigger className="w-44">
<SelectValue placeholder="All stores" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All stores</SelectItem>
{storesList.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.storeName}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-muted-foreground">Confirmation</label>
<Select
value={confirmedFilter}
onValueChange={(v) => { setConfirmedFilter(v as ConfirmedFilter); setPage(1) }}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="All" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="confirmed">Confirmed</SelectItem>
<SelectItem value="unconfirmed">Unconfirmed</SelectItem>
</SelectContent>
</Select>
</div>
{hasFilters && (
<Button variant="ghost" size="sm" onClick={() => { clearFilters(); setPage(1) }}>
Clear filters
</Button>
)}
</div>
<DataTable
config={tableConfig}
data={data}
@@ -186,18 +316,6 @@ export function SalesClient() {
setPageSize(p.pageSize)
}}
/>
<ShopPOCreateSheet
open={createOpen}
onClose={() => setCreateOpen(false)}
onSuccess={() => fetchData(page, pageSize)}
/>
<ShopPOViewSheet
open={viewOpen}
onClose={() => setViewOpen(false)}
po={selectedPO}
/>
</>
)
}
@@ -1,217 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import { useForm, zodResolver, z } from '@/lib/form'
import { FormField } from '@/components/shared/form-field'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog'
import { FieldGroup } from '@/components/ui/field'
import { toast } from '@/components/ui/toast'
import { shopPos as shopPosApi, stores as storesApi, items as itemsApi, getApiError, getPaginatedItems } from '@/lib/api/index'
import type { StoreResponse, ItemSummaryResponse } from '@/types/api'
const itemLineSchema = z.object({
itemId: z.string().min(1, 'Item required'),
quantity: z.number().int().min(1, 'Min 1'),
unitPrice: z.number().min(0, 'Price must be 0 or more'),
})
const schema = z.object({
storeId: z.string().min(1, 'Store is required'),
items: z.array(itemLineSchema).min(1, 'At least one item is required'),
})
type FormValues = z.infer<typeof schema>
interface ShopPOCreateSheetProps {
open: boolean
onClose: () => void
onSuccess: () => void
}
const emptyLine = { itemId: '', quantity: 1, unitPrice: 0 }
export function ShopPOCreateSheet({ open, onClose, onSuccess }: ShopPOCreateSheetProps) {
const [storesList, setStoresList] = useState<StoreResponse[]>([])
const [itemsList, setItemsList] = useState<ItemSummaryResponse[]>([])
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { storeId: '', items: [emptyLine] },
})
const watchedItems = form.watch('items')
useEffect(() => {
if (!open) return
form.reset({ storeId: '', items: [emptyLine] })
Promise.all([
storesApi.list({ pageSize: 200 }),
itemsApi.list({ pageSize: 200 }),
])
.then(([s, i]) => {
setStoresList(getPaginatedItems(s))
setItemsList(getPaginatedItems(i))
})
.catch((err) => toast.add({ title: 'Failed to load reference data', description: getApiError(err), type: 'error' }))
}, [open])
function addLine() {
form.setValue('items', [...watchedItems, emptyLine])
}
function removeLine(idx: number) {
form.setValue('items', watchedItems.filter((_, i) => i !== idx))
}
async function onSubmit(values: FormValues) {
try {
await shopPosApi.create(values)
toast.add({ title: 'Shop PO created', type: 'success' })
onSuccess()
onClose()
} catch (err) {
toast.add({ title: 'Failed to create Shop PO', description: getApiError(err), type: 'error' })
}
}
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>New Shop PO</DialogTitle>
<DialogDescription>Raise a purchase order on behalf of a store.</DialogDescription>
</DialogHeader>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
<FieldGroup>
<FormField
control={form.control}
name="storeId"
label="Store"
required
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select store..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="" disabled>Select store...</SelectItem>
{storesList.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.storeName} — {s.ownerName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
</FieldGroup>
<div>
<div className="flex items-center justify-between mb-2">
<p className="text-sm font-medium">Line Items</p>
<Button type="button" variant="outline" size="sm" onClick={addLine}>
+ Add Item
</Button>
</div>
<div className="flex flex-col gap-3">
{watchedItems.map((_, idx) => (
<div key={idx} className="grid grid-cols-[1fr_80px_100px_36px] gap-2 items-end">
<FormField
control={form.control}
name={`items.${idx}.itemId`}
label={idx === 0 ? 'Item' : undefined}
render={({ field }) => (
<Select value={field.value as string} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="Select item..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="" disabled>Select item...</SelectItem>
{itemsList.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.name} ({item.sku})
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
<FormField
control={form.control}
name={`items.${idx}.quantity`}
label={idx === 0 ? 'Qty' : undefined}
render={({ field, invalid }) => (
<Input
type="number"
min={1}
value={Number(field.value as number)}
onChange={(e) => field.onChange(Number(e.target.value))}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
<FormField
control={form.control}
name={`items.${idx}.unitPrice`}
label={idx === 0 ? 'Unit Price' : undefined}
render={({ field, invalid }) => (
<Input
type="number"
step="0.01"
min={0}
value={Number(field.value as number)}
onChange={(e) => field.onChange(Number(e.target.value))}
onBlur={field.onBlur}
aria-invalid={invalid}
/>
)}
/>
<div className={idx === 0 ? 'mt-6' : ''}>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeLine(idx)}
disabled={watchedItems.length === 1}
className="h-9 w-9 p-0 text-muted-foreground hover:text-destructive"
>
×
</Button>
</div>
</div>
))}
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? 'Creating…' : 'Create PO'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
@@ -1,106 +0,0 @@
'use client'
import { Badge } from '@/components/ui/badge'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Separator } from '@/components/ui/separator'
import type { ShopPOResponse, ShopPOStatus } from '@/types/api'
const PO_STATUS_VARIANT: Record<ShopPOStatus, 'default' | 'secondary' | 'destructive'> = {
Pending: 'secondary',
PartiallyFulfilled: 'secondary',
Fulfilled: 'default',
Cancelled: 'destructive',
}
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
const truncId = (id: string) => id.substring(0, 8)
const fmtDateTime = (v: string | null) =>
v
? new Date(v).toLocaleString('en-GB', {
day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
})
: '—'
interface ShopPOViewSheetProps {
open: boolean
onClose: () => void
po: ShopPOResponse | null
}
function Row({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-center justify-between py-2 text-sm border-b last:border-0">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium text-right">{value}</span>
</div>
)
}
export function ShopPOViewSheet({ open, onClose, po }: ShopPOViewSheetProps) {
if (!po) return null
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Shop PO Details</DialogTitle>
</DialogHeader>
<div className="flex flex-col">
<Row label="PO ID" value={<span className="font-mono">{truncId(po.id)}</span>} />
<Row label="Store" value={po.storeName} />
<Row label="Status" value={<Badge variant={PO_STATUS_VARIANT[po.status]}>{po.status}</Badge>} />
<Row label="Total Amount" value={fmtAmount(po.totalAmount)} />
<Row label="Invoice" value={po.invoiceId ? <span className="font-mono">{truncId(po.invoiceId)}</span> : 'No invoice yet'} />
<Row label="Confirmed At" value={fmtDateTime(po.confirmedAt)} />
<Row label="Fulfilled At" value={fmtDateTime(po.fulfilledAt)} />
<Row label="Created At" value={fmtDateTime(po.createdAt)} />
</div>
<Separator />
<div>
<p className="text-sm font-medium mb-2">Line Items ({po.items.filter(i => i.isActive).length})</p>
<div className="overflow-x-auto rounded-md border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="text-left font-medium px-3 py-2">Item</th>
<th className="text-right font-medium px-3 py-2">Qty</th>
<th className="text-right font-medium px-3 py-2">Fulfilled</th>
<th className="text-right font-medium px-3 py-2">Unit Price</th>
<th className="text-right font-medium px-3 py-2">Line Total</th>
</tr>
</thead>
<tbody>
{po.items.filter(i => i.isActive).length === 0 ? (
<tr>
<td colSpan={5} className="text-center text-muted-foreground px-3 py-4">
No items on this PO.
</td>
</tr>
) : (
po.items.filter(i => i.isActive).map((item) => (
<tr key={item.id} className="border-t">
<td className="px-3 py-2">{item.itemName}</td>
<td className="text-right px-3 py-2">{item.quantity}</td>
<td className="text-right px-3 py-2">{item.quantityFulfilled}</td>
<td className="text-right px-3 py-2">{fmtAmount(item.unitPrice)}</td>
<td className="text-right px-3 py-2 font-medium">{fmtAmount(item.quantity * item.unitPrice)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,245 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { HugeiconsIcon } from '@hugeicons/react'
import { ArrowLeft01Icon, Add01Icon } from '@hugeicons/core-free-icons'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/toast'
import { invoices as invoicesApi, payments as paymentsApi, getApiError } from '@/lib/api/index'
import type { InvoiceResponse, InvoiceStatus, PaymentResponse, PaymentMethod, PaymentStatus } from '@/types/api'
import { PaymentSheet } from '../../payments/_components/payment-sheet'
import { PaymentViewSheet } from '../../payments/_components/payment-view-sheet'
const INVOICE_STATUS_VARIANT: Record<InvoiceStatus, 'default' | 'secondary' | 'outline'> = {
Draft: 'secondary',
Printed: 'outline',
Finalized: 'default',
}
const PAYMENT_STATUS_VARIANT: Record<PaymentStatus, 'default' | 'secondary' | 'destructive'> = {
Pending: 'destructive',
PartiallyPaid: 'secondary',
Paid: 'default',
}
const PAYMENT_METHOD_VARIANT: Record<PaymentMethod, 'default' | 'secondary' | 'outline'> = {
Cash: 'default',
Cheque: 'outline',
Credit: 'secondary',
}
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
const truncId = (id: string) => id.substring(0, 8)
const fmtDateTime = (v: string | null) =>
v
? new Date(v).toLocaleString('en-GB', {
day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit',
})
: '—'
function Stat({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex flex-col gap-0.5">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-sm font-medium text-foreground">{value}</span>
</div>
)
}
export default function InvoiceViewPage() {
const router = useRouter()
const params = useParams<{ id: string }>()
const [invoice, setInvoice] = useState<InvoiceResponse | null>(null)
const [payments, setPayments] = useState<PaymentResponse[]>([])
const [loading, setLoading] = useState(true)
const [paymentSheetOpen, setPaymentSheetOpen] = useState(false)
const [viewPayment, setViewPayment] = useState<PaymentResponse | null>(null)
const fetchInvoice = useCallback(async () => {
const inv = await invoicesApi.get(params.id)
setInvoice(inv)
return inv
}, [params.id])
const fetchPayments = useCallback(async () => {
const list = await paymentsApi.listByInvoice(params.id)
setPayments(list)
}, [params.id])
useEffect(() => {
let cancelled = false
setLoading(true)
Promise.all([fetchInvoice(), fetchPayments()])
.catch((err) => {
toast.add({ title: 'Failed to load invoice', description: getApiError(err), type: 'error' })
router.push('/sales')
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [fetchInvoice, fetchPayments, router])
async function handlePrint() {
if (!invoice) return
try {
const updated = await invoicesApi.print(invoice.id)
setInvoice(updated)
toast.add({ title: 'Invoice printed', type: 'success' })
} catch (err) {
toast.add({ title: 'Failed to print invoice', description: getApiError(err), type: 'error' })
}
}
async function handleFinalize() {
if (!invoice) return
try {
const updated = await invoicesApi.finalize(invoice.id)
setInvoice(updated)
toast.add({ title: 'Invoice finalized', type: 'success' })
} catch (err) {
toast.add({ title: 'Failed to finalize invoice', description: getApiError(err), type: 'error' })
}
}
if (loading) {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-4">
<Button type="button" variant="outline" size="sm" onClick={() => router.push('/sales')}>
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
Back
</Button>
</div>
<p className="text-sm text-muted-foreground">Loading invoice…</p>
</div>
)
}
if (!invoice) return null
const paidAmount = payments.reduce((sum, p) => sum + p.amount, 0)
const balance = invoice.totalAmount - paidAmount
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4">
<Button type="button" variant="outline" size="sm" onClick={() => router.push(`/sales/${invoice.shopPOId}`)}>
<HugeiconsIcon icon={ArrowLeft01Icon} size={16} strokeWidth={2} />
Back to PO
</Button>
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold text-foreground">
Invoice <span className="font-mono text-lg text-muted-foreground">{truncId(invoice.id)}</span>
</h1>
<Badge variant={INVOICE_STATUS_VARIANT[invoice.status]}>{invoice.status}</Badge>
<Badge variant={PAYMENT_STATUS_VARIANT[invoice.paymentStatus]}>{invoice.paymentStatus}</Badge>
</div>
<p className="text-sm text-muted-foreground">{invoice.storeName}</p>
</div>
</div>
<div className="flex items-center gap-2">
{invoice.status === 'Draft' && (
<Button type="button" onClick={handlePrint}>
Print Invoice
</Button>
)}
{invoice.status === 'Printed' && (
<Button type="button" onClick={handleFinalize}>
Finalize Invoice
</Button>
)}
{invoice.paymentStatus !== 'Paid' && (
<Button type="button" variant="outline" onClick={() => setPaymentSheetOpen(true)}>
<HugeiconsIcon icon={Add01Icon} size={16} />
Record Payment
</Button>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4 rounded-2xl border p-4 sm:grid-cols-4">
<Stat label="Total" value={fmtAmount(invoice.totalAmount)} />
<Stat label="Paid" value={fmtAmount(paidAmount)} />
<Stat label="Balance" value={fmtAmount(balance)} />
<Stat label="Created" value={fmtDateTime(invoice.createdAt)} />
<Stat label="Printed" value={fmtDateTime(invoice.printedAt)} />
<Stat label="Finalized" value={fmtDateTime(invoice.finalizedAt)} />
</div>
<div className="rounded-2xl border p-6">
<div className="mb-4">
<h3 className="text-sm font-medium text-foreground">Payments</h3>
<p className="text-sm text-muted-foreground">
{payments.length} payment{payments.length === 1 ? '' : 's'} recorded against this invoice.
</p>
</div>
<div className="overflow-x-auto rounded-xl border">
<table className="w-full text-sm">
<thead className="bg-muted/50">
<tr>
<th className="min-w-[120px] px-3 py-2 text-left font-medium">Payment ID</th>
<th className="w-32 px-3 py-2 text-right font-medium">Amount</th>
<th className="w-28 px-3 py-2 text-left font-medium">Method</th>
<th className="w-36 px-3 py-2 text-left font-medium">Collected At</th>
<th className="w-10 px-3 py-2"></th>
</tr>
</thead>
<tbody>
{payments.length === 0 ? (
<tr>
<td colSpan={5} className="px-3 py-4 text-center text-muted-foreground">
No payments recorded yet.
</td>
</tr>
) : (
payments.map((payment) => (
<tr key={payment.id} className="border-t">
<td className="px-3 py-2 font-mono">{truncId(payment.id)}</td>
<td className="px-3 py-2 text-right font-medium">{fmtAmount(payment.amount)}</td>
<td className="px-3 py-2">
<Badge variant={PAYMENT_METHOD_VARIANT[payment.method]}>{payment.method}</Badge>
</td>
<td className="px-3 py-2">{fmtDateTime(payment.collectedAt)}</td>
<td className="px-3 py-2 text-right">
<Button type="button" variant="ghost" size="sm" onClick={() => setViewPayment(payment)}>
View
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
<PaymentSheet
open={paymentSheetOpen}
onClose={() => setPaymentSheetOpen(false)}
onSuccess={() => {
fetchPayments()
fetchInvoice()
}}
invoices={[invoice]}
/>
<PaymentViewSheet
open={!!viewPayment}
onClose={() => setViewPayment(null)}
payment={viewPayment}
invoiceName={`${invoice.storeName} — ${truncId(invoice.id)}`}
/>
</div>
)
}
@@ -11,6 +11,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { Button } from '@/components/ui/button'
import {
Dialog,
@@ -25,11 +26,23 @@ import { toast } from '@/components/ui/toast'
import { payments as paymentsApi, getApiError } from '@/lib/api/index'
import type { InvoiceResponse } from '@/types/api'
const schema = z.object({
invoiceId: z.string().min(1, 'Invoice is required'),
amount: z.number().min(0.01, 'Amount must be greater than 0'),
method: z.enum(['Cash', 'Credit']),
})
const schema = z
.object({
invoiceId: z.string().min(1, 'Invoice is required'),
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>
@@ -50,18 +63,35 @@ export function PaymentSheet({ open, onClose, onSuccess, invoices }: PaymentShee
invoiceId: '',
amount: 0,
method: 'Cash',
entryMode: 'RealTime',
chequeNumber: '',
chequeDate: '',
},
})
const method = form.watch('method')
useEffect(() => {
if (!open) return
form.reset({ invoiceId: '', amount: 0, method: 'Cash' })
form.reset({
invoiceId: '',
amount: 0,
method: 'Cash',
entryMode: 'RealTime',
chequeNumber: '',
chequeDate: '',
})
}, [open])
async function onSubmit(values: FormValues) {
const { invoiceId, ...rest } = values
const { invoiceId, method, chequeNumber, chequeDate, ...rest } = values
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' })
onSuccess()
onClose()
@@ -77,7 +107,7 @@ export function PaymentSheet({ open, onClose, onSuccess, invoices }: PaymentShee
<DialogContent className="overflow-y-auto sm:max-w-lg">
<DialogHeader>
<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>
<form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6">
@@ -137,11 +167,71 @@ export function PaymentSheet({ open, onClose, onSuccess, invoices }: PaymentShee
</SelectTrigger>
<SelectContent>
<SelectItem value="Cash">Cash</SelectItem>
<SelectItem value="Cheque">Cheque</SelectItem>
<SelectItem value="Credit">Credit</SelectItem>
</SelectContent>
</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>
<DialogFooter>
@@ -7,13 +7,19 @@ import {
DialogHeader,
DialogTitle,
} 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',
Cheque: 'outline',
Credit: 'secondary',
}
const ENTRY_MODE_LABEL: Record<EntryMode, string> = {
RealTime: 'Real-time',
DayEndManual: 'Day-end entry',
}
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
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',
})
const fmtDate = (v: string) =>
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
interface PaymentViewSheetProps {
open: boolean
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="Amount" value={fmtAmount(payment.amount)} />
<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 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)} />
</div>
</DialogContent>
@@ -11,7 +11,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { toast } from '@/components/ui/toast'
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 { PaymentViewSheet } from './payment-view-sheet'
@@ -22,11 +22,17 @@ const fmtDate = (v: string) =>
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString()}`
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',
Cheque: 'outline',
Credit: 'secondary',
}
const ENTRY_MODE_LABEL: Record<EntryMode, string> = {
RealTime: 'Real-time',
DayEndManual: 'Day-end entry',
}
export function PaymentsClient() {
const [invoicesList, setInvoicesList] = useState<InvoiceResponse[]>([])
const [selectedInvoiceId, setSelectedInvoiceId] = useState('')
@@ -119,6 +125,15 @@ export function PaymentsClient() {
<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',
title: 'Collected At',
@@ -143,7 +158,7 @@ export function PaymentsClient() {
<div>
<h1 className="text-2xl font-semibold text-foreground">Payments</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Cash and credit payments collected against invoices
Cash, cheque, and credit payments collected against invoices
</p>
</div>
<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,
PaymentResponse,
PaymentMethod,
EntryMode,
ShopPOStatus,
} from '@/types/api'
@@ -87,6 +88,9 @@ export const invoices = {
export interface PaymentBody {
amount: number
method: PaymentMethod
entryMode?: EntryMode
chequeNumber?: string | null
chequeDate?: string | null
collectedByUserId?: string | null
}
+13
View File
@@ -16,6 +16,7 @@
"axios": "^1.19.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cn": "^0.3.0",
"leaflet": "^1.9.4",
"next": "16.2.6",
"next-themes": "^0.4.6",
@@ -3749,6 +3750,18 @@
"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": {
"version": "13.0.3",
"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",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cn": "^0.3.0",
"leaflet": "^1.9.4",
"next": "16.2.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 InvoiceCreationMode = 'ModeA' | 'ModeB'
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 VehicleType = 'Car' | 'Truck' | 'Van' | 'Motorcycle' | 'Bicycle'
export type WarehouseType =
@@ -666,8 +667,12 @@ export interface PaymentResponse {
invoiceId: string
amount: number
method: PaymentMethod
entryMode: EntryMode
chequeNumber: string | null
chequeDate: string | null
collectedAt: string
collectedBy: string
recordedBy: string | null
createdAt: string
}