10.
Implementation A partner, not just software HexDive Technologies' local team handles setup, data migration, training and ongoing support — in Sinhala, Tamil and English. [confirm] 1 Discovery Map your network, products and processes. 2 Configuration Set up depots, pricing, schemes, routes and users. 3 Migration & training Move your data and train every team. 4 Go-live & support Dedicated account manager and local support desk.
This commit is contained in:
@@ -76,6 +76,11 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
public DbSet<SupplierPaymentGRN> SupplierPaymentGRNs { get; set; }
|
||||
public DbSet<Notification> Notifications { get; set; }
|
||||
public DbSet<NotificationRecipient> NotificationRecipients { get; set; }
|
||||
public DbSet<ExpenseCategory> ExpenseCategories { get; set; }
|
||||
public DbSet<ExpenseVendor> ExpenseVendors { get; set; }
|
||||
public DbSet<Expense> Expenses { get; set; }
|
||||
public DbSet<ExpenseAttachment> ExpenseAttachments { get; set; }
|
||||
public DbSet<RecurringExpenseTemplate> RecurringExpenseTemplates { get; set; }
|
||||
public DbSet<StoreCreditMovement> StoreCreditMovements { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
@@ -813,7 +818,88 @@ public class AppDBContext(DbContextOptions<AppDBContext> options) : DbContext(op
|
||||
modelBuilder.Entity<StoreCreditMovement>()
|
||||
.HasIndex(m => new { m.FK_StoreId, m.OccurredAt });
|
||||
|
||||
// --- Expense ---
|
||||
|
||||
modelBuilder.Entity<ExpenseCategory>()
|
||||
.HasOne(c => c.Parent)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.FK_ParentCategoryId)
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired(false);
|
||||
|
||||
modelBuilder.Entity<ExpenseCategory>()
|
||||
.Property(c => c.Name).HasMaxLength(100);
|
||||
modelBuilder.Entity<ExpenseCategory>()
|
||||
.Property(c => c.Code).HasMaxLength(50);
|
||||
modelBuilder.Entity<ExpenseCategory>()
|
||||
.HasIndex(c => new { c.FK_ParentCategoryId, c.Name }).IsUnique();
|
||||
|
||||
modelBuilder.Entity<ExpenseVendor>()
|
||||
.Property(v => v.Name).HasMaxLength(200);
|
||||
modelBuilder.Entity<ExpenseVendor>()
|
||||
.HasIndex(v => v.Name).IsUnique();
|
||||
|
||||
modelBuilder.Entity<Expense>()
|
||||
.HasOne(e => e.Category).WithMany()
|
||||
.HasForeignKey(e => e.FK_CategoryId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
modelBuilder.Entity<Expense>()
|
||||
.HasOne(e => e.Warehouse).WithMany()
|
||||
.HasForeignKey(e => e.FK_WarehouseId)
|
||||
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
|
||||
modelBuilder.Entity<Expense>()
|
||||
.HasOne(e => e.Vendor).WithMany()
|
||||
.HasForeignKey(e => e.FK_VendorId)
|
||||
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
|
||||
modelBuilder.Entity<Expense>()
|
||||
.HasOne(e => e.BankAccount).WithMany()
|
||||
.HasForeignKey(e => e.FK_BankAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
|
||||
modelBuilder.Entity<Expense>()
|
||||
.HasOne(e => e.ChequePage).WithMany()
|
||||
.HasForeignKey(e => e.FK_ChequePageId)
|
||||
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
|
||||
modelBuilder.Entity<Expense>()
|
||||
.HasOne(e => e.RecurringTemplate).WithMany()
|
||||
.HasForeignKey(e => e.FK_RecurringTemplateId)
|
||||
.OnDelete(DeleteBehavior.SetNull).IsRequired(false);
|
||||
modelBuilder.Entity<Expense>()
|
||||
.Property(e => e.Payee).HasMaxLength(200);
|
||||
modelBuilder.Entity<Expense>()
|
||||
.Property(e => e.BillRef).HasMaxLength(100);
|
||||
modelBuilder.Entity<Expense>()
|
||||
.HasIndex(e => new { e.Status, e.BillDate });
|
||||
|
||||
modelBuilder.Entity<ExpenseAttachment>()
|
||||
.HasOne(a => a.Expense).WithMany(e => e.Attachments)
|
||||
.HasForeignKey(a => a.FK_ExpenseId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
modelBuilder.Entity<ExpenseAttachment>()
|
||||
.Property(a => a.OriginalFileName).HasMaxLength(260);
|
||||
modelBuilder.Entity<ExpenseAttachment>()
|
||||
.Property(a => a.StoredPath).HasMaxLength(500);
|
||||
modelBuilder.Entity<ExpenseAttachment>()
|
||||
.Property(a => a.ContentType).HasMaxLength(100);
|
||||
|
||||
modelBuilder.Entity<RecurringExpenseTemplate>()
|
||||
.HasOne(t => t.Category).WithMany()
|
||||
.HasForeignKey(t => t.FK_CategoryId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
modelBuilder.Entity<RecurringExpenseTemplate>()
|
||||
.HasOne(t => t.Warehouse).WithMany()
|
||||
.HasForeignKey(t => t.FK_WarehouseId)
|
||||
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
|
||||
modelBuilder.Entity<RecurringExpenseTemplate>()
|
||||
.HasOne(t => t.Vendor).WithMany()
|
||||
.HasForeignKey(t => t.FK_VendorId)
|
||||
.OnDelete(DeleteBehavior.Restrict).IsRequired(false);
|
||||
modelBuilder.Entity<RecurringExpenseTemplate>()
|
||||
.Property(t => t.Name).HasMaxLength(200);
|
||||
modelBuilder.Entity<RecurringExpenseTemplate>()
|
||||
.HasIndex(t => new { t.IsPaused, t.NextRunDate });
|
||||
|
||||
Seed.RoleAndPermissionSeed.Apply(modelBuilder);
|
||||
Seed.ExpenseSeed.Apply(modelBuilder);
|
||||
Seed.MasterDataSeed.Apply(modelBuilder);
|
||||
Seed.OperationsDataSeed.Apply(modelBuilder);
|
||||
Seed.ProcurementSeed.Apply(modelBuilder);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Data.Seed;
|
||||
|
||||
public static class ExpenseSeed
|
||||
{
|
||||
private static readonly DateTime SeedDate = new(2026, 10, 21);
|
||||
|
||||
// Top-level categories
|
||||
private static readonly Guid CatSalaries = new("30000000-0000-0000-0000-000000000001");
|
||||
private static readonly Guid CatUtilities = new("30000000-0000-0000-0000-000000000002");
|
||||
private static readonly Guid CatRent = new("30000000-0000-0000-0000-000000000003");
|
||||
private static readonly Guid CatVehicle = new("30000000-0000-0000-0000-000000000004");
|
||||
private static readonly Guid CatOffice = new("30000000-0000-0000-0000-000000000005");
|
||||
private static readonly Guid CatBankFees = new("30000000-0000-0000-0000-000000000006");
|
||||
private static readonly Guid CatMisc = new("30000000-0000-0000-0000-000000000007");
|
||||
|
||||
public static void Apply(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<ExpenseCategory>().HasData(
|
||||
// Top-level
|
||||
new ExpenseCategory { Id = CatSalaries, Name = "Salaries", Code = "SAL", CreatedAt = SeedDate, IsActive = true },
|
||||
new ExpenseCategory { Id = CatUtilities, Name = "Utilities", Code = "UTL", CreatedAt = SeedDate, IsActive = true },
|
||||
new ExpenseCategory { Id = CatRent, Name = "Rent", Code = "RNT", CreatedAt = SeedDate, IsActive = true },
|
||||
new ExpenseCategory { Id = CatVehicle, Name = "Vehicle", Code = "VEH", CreatedAt = SeedDate, IsActive = true },
|
||||
new ExpenseCategory { Id = CatOffice, Name = "Office", Code = "OFF", CreatedAt = SeedDate, IsActive = true },
|
||||
new ExpenseCategory { Id = CatBankFees, Name = "Bank Fees", Code = "BNK", CreatedAt = SeedDate, IsActive = true },
|
||||
new ExpenseCategory { Id = CatMisc, Name = "Miscellaneous", Code = "MSC", CreatedAt = SeedDate, IsActive = true },
|
||||
|
||||
// Titles under Salaries
|
||||
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000101"), Name = "Basic Salary", FK_ParentCategoryId = CatSalaries, CreatedAt = SeedDate, IsActive = true },
|
||||
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000102"), Name = "Bonus", FK_ParentCategoryId = CatSalaries, CreatedAt = SeedDate, IsActive = true },
|
||||
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000103"), Name = "Overtime", FK_ParentCategoryId = CatSalaries, CreatedAt = SeedDate, IsActive = true },
|
||||
|
||||
// Titles under Utilities
|
||||
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000201"), Name = "Electricity", FK_ParentCategoryId = CatUtilities, CreatedAt = SeedDate, IsActive = true },
|
||||
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000202"), Name = "Water", FK_ParentCategoryId = CatUtilities, CreatedAt = SeedDate, IsActive = true },
|
||||
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000203"), Name = "Internet", FK_ParentCategoryId = CatUtilities, CreatedAt = SeedDate, IsActive = true },
|
||||
|
||||
// Titles under Rent
|
||||
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000301"), Name = "Warehouse Rent", FK_ParentCategoryId = CatRent, CreatedAt = SeedDate, IsActive = true },
|
||||
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000302"), Name = "Office Rent", FK_ParentCategoryId = CatRent, CreatedAt = SeedDate, IsActive = true },
|
||||
|
||||
// Titles under Vehicle
|
||||
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000401"), Name = "Fuel", FK_ParentCategoryId = CatVehicle, CreatedAt = SeedDate, IsActive = true },
|
||||
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000402"), Name = "Maintenance", FK_ParentCategoryId = CatVehicle, CreatedAt = SeedDate, IsActive = true },
|
||||
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000403"), Name = "Insurance", FK_ParentCategoryId = CatVehicle, CreatedAt = SeedDate, IsActive = true },
|
||||
|
||||
// Titles under Office
|
||||
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000501"), Name = "Stationery", FK_ParentCategoryId = CatOffice, CreatedAt = SeedDate, IsActive = true },
|
||||
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000502"), Name = "Cleaning", FK_ParentCategoryId = CatOffice, CreatedAt = SeedDate, IsActive = true },
|
||||
|
||||
// Titles under Bank Fees
|
||||
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000601"), Name = "Service Charges", FK_ParentCategoryId = CatBankFees, CreatedAt = SeedDate, IsActive = true },
|
||||
|
||||
// Titles under Misc
|
||||
new ExpenseCategory { Id = new("30000000-0000-0000-0000-000000000701"), Name = "Other", FK_ParentCategoryId = CatMisc, CreatedAt = SeedDate, IsActive = true }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,13 @@ public static class RoleAndPermissionSeed
|
||||
private static readonly Guid PermSupplierClaimManage= new("20000000-0000-0000-0000-000000000048");
|
||||
private static readonly Guid PermSysConfigView = new("20000000-0000-0000-0000-000000000049");
|
||||
private static readonly Guid PermSysConfigManage = new("20000000-0000-0000-0000-000000000050");
|
||||
private static readonly Guid PermExpenseView = new("20000000-0000-0000-0000-000000000051");
|
||||
private static readonly Guid PermExpenseCreate = new("20000000-0000-0000-0000-000000000052");
|
||||
private static readonly Guid PermExpensePost = new("20000000-0000-0000-0000-000000000053");
|
||||
private static readonly Guid PermExpensePay = new("20000000-0000-0000-0000-000000000054");
|
||||
private static readonly Guid PermExpenseVoid = new("20000000-0000-0000-0000-000000000055");
|
||||
private static readonly Guid PermExpenseManageCats = new("20000000-0000-0000-0000-000000000056");
|
||||
private static readonly Guid PermExpenseManageRec = new("20000000-0000-0000-0000-000000000057");
|
||||
|
||||
public static void Apply(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -134,7 +141,14 @@ public static class RoleAndPermissionSeed
|
||||
new Permission { Id = PermSupplierClaimView, Code = "supplier-claim.view", Description = "View supplier claims", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermSupplierClaimManage,Code = "supplier-claim.manage", Description = "Create and resolve supplier claims", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermSysConfigView, Code = "sys-config.view", Description = "View system configuration settings", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermSysConfigManage, Code = "sys-config.manage", Description = "Update system configuration settings", CreatedAt = SeedDate, IsActive = true }
|
||||
new Permission { Id = PermSysConfigManage, Code = "sys-config.manage", Description = "Update system configuration settings", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermExpenseView, Code = "expense.view", Description = "View expenses, categories, vendors and recurring templates", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermExpenseCreate, Code = "expense.create", Description = "Create and edit draft expenses", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermExpensePost, Code = "expense.post", Description = "Post a draft expense (approve it)", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermExpensePay, Code = "expense.pay", Description = "Pay a posted expense (debit a bank account or issue a cheque)", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermExpenseVoid, Code = "expense.void", Description = "Void an expense", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermExpenseManageCats, Code = "expense.manage_categories", Description = "Manage expense categories, titles and vendors", CreatedAt = SeedDate, IsActive = true },
|
||||
new Permission { Id = PermExpenseManageRec, Code = "expense.manage_recurring", Description = "Manage recurring expense templates", CreatedAt = SeedDate, IsActive = true }
|
||||
);
|
||||
|
||||
// SuperAdmin gets every permission
|
||||
@@ -191,7 +205,14 @@ public static class RoleAndPermissionSeed
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSupplierClaimView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSupplierClaimManage,CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigManage, CreatedAt = SeedDate, IsActive = true }
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermSysConfigManage, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpenseView, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpenseCreate, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpensePost, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpensePay, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpenseVoid, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpenseManageCats, CreatedAt = SeedDate, IsActive = true },
|
||||
new RolePermission { FK_RoleId = SuperAdminRoleId, FK_PermissionId = PermExpenseManageRec, CreatedAt = SeedDate, IsActive = true }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Expense;
|
||||
|
||||
[ApiController]
|
||||
public class ExpenseAttachmentController(ExpenseAttachmentService service) : ControllerBase
|
||||
{
|
||||
[HttpPost("api/expenses/{expenseId:guid}/attachments")]
|
||||
[Authorize("expense.create")]
|
||||
[RequestSizeLimit(10 * 1024 * 1024)]
|
||||
public async Task<IActionResult> Upload(Guid expenseId, IFormFile file, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.UploadAsync(expenseId, file, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpGet("api/expense-attachments/{id:guid}/download")]
|
||||
[Authorize("expense.view")]
|
||||
public async Task<IActionResult> Download(Guid id, CancellationToken ct)
|
||||
{
|
||||
var result = await service.DownloadAsync(id, ct);
|
||||
if (result is null) return NotFound();
|
||||
return File(result.Value.Stream, result.Value.ContentType, result.Value.FileName);
|
||||
}
|
||||
|
||||
[HttpDelete("api/expense-attachments/{id:guid}")]
|
||||
[Authorize("expense.create")]
|
||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct) =>
|
||||
await service.DeleteAsync(id, ct) ? NoContent() : NotFound();
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.Expense;
|
||||
|
||||
public class ExpenseAttachmentService(
|
||||
AppDBContext context,
|
||||
IConfiguration config,
|
||||
IUnitOfWork uow,
|
||||
ICurrentUserService currentUser)
|
||||
{
|
||||
private const long MaxBytes = 10 * 1024 * 1024;
|
||||
private static readonly string[] AllowedExtensions = [".pdf", ".jpg", ".jpeg", ".png", ".webp"];
|
||||
private static readonly string[] AllowedContentTypes =
|
||||
["application/pdf", "image/jpeg", "image/jpg", "image/png", "image/webp"];
|
||||
|
||||
private string AttachmentsRoot =>
|
||||
config["Expense:AttachmentsRoot"]
|
||||
?? throw new InvalidOperationException("Expense:AttachmentsRoot is not configured");
|
||||
|
||||
public async Task<ExpenseAttachmentResponse?> UploadAsync(Guid expenseId, IFormFile file, CancellationToken ct)
|
||||
{
|
||||
var expense = await context.Expenses.FirstOrDefaultAsync(e => e.Id == expenseId && e.IsActive, ct);
|
||||
if (expense is null) return null;
|
||||
|
||||
if (file is null || file.Length == 0)
|
||||
throw new InvalidOperationException("File is empty");
|
||||
if (file.Length > MaxBytes)
|
||||
throw new InvalidOperationException($"File exceeds the {MaxBytes / (1024 * 1024)}MB limit");
|
||||
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(ext))
|
||||
throw new InvalidOperationException($"File type '{ext}' is not allowed");
|
||||
if (!AllowedContentTypes.Contains(file.ContentType.ToLowerInvariant()))
|
||||
throw new InvalidOperationException($"Content type '{file.ContentType}' is not allowed");
|
||||
|
||||
var userId = currentUser.UserId
|
||||
?? throw new InvalidOperationException("Authenticated user is required");
|
||||
|
||||
var now = DateTime.Now;
|
||||
var relativeDir = Path.Combine(now.Year.ToString("D4"), now.Month.ToString("D2"));
|
||||
var absoluteDir = Path.Combine(AttachmentsRoot, relativeDir);
|
||||
Directory.CreateDirectory(absoluteDir);
|
||||
|
||||
var storedName = $"{Guid.NewGuid()}{ext}";
|
||||
var absolutePath = Path.Combine(absoluteDir, storedName);
|
||||
|
||||
await using (var stream = File.Create(absolutePath))
|
||||
{
|
||||
await file.CopyToAsync(stream, ct);
|
||||
}
|
||||
|
||||
var attachment = new Models.ExpenseAttachment
|
||||
{
|
||||
FK_ExpenseId = expenseId,
|
||||
OriginalFileName = file.FileName,
|
||||
StoredPath = absolutePath,
|
||||
ContentType = file.ContentType,
|
||||
SizeBytes = file.Length,
|
||||
UploadedByUserId = userId
|
||||
};
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await context.ExpenseAttachments.AddAsync(attachment, ct);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await uow.RollbackAsync(ct);
|
||||
try { File.Delete(absolutePath); } catch { }
|
||||
throw;
|
||||
}
|
||||
|
||||
return new ExpenseAttachmentResponse(attachment.Id, attachment.OriginalFileName,
|
||||
attachment.ContentType, attachment.SizeBytes, attachment.CreatedAt);
|
||||
}
|
||||
|
||||
public async Task<(Stream Stream, string ContentType, string FileName)?> DownloadAsync(Guid attachmentId, CancellationToken ct)
|
||||
{
|
||||
var a = await context.ExpenseAttachments.FirstOrDefaultAsync(x => x.Id == attachmentId && x.IsActive, ct);
|
||||
if (a is null) return null;
|
||||
if (!File.Exists(a.StoredPath)) return null;
|
||||
|
||||
var stream = File.OpenRead(a.StoredPath);
|
||||
return (stream, a.ContentType, a.OriginalFileName);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid attachmentId, CancellationToken ct)
|
||||
{
|
||||
var a = await context.ExpenseAttachments.FirstOrDefaultAsync(x => x.Id == attachmentId && x.IsActive, ct);
|
||||
if (a is null) return false;
|
||||
|
||||
a.IsActive = false;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { context.ExpenseAttachments.Update(a); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
// Best-effort file removal — keep DB soft-delete even if the file is gone.
|
||||
try { File.Delete(a.StoredPath); } catch { }
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.Expense;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/expenses")]
|
||||
public class ExpenseController(
|
||||
ExpenseService service,
|
||||
IPermissionService permissions,
|
||||
ICurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
private async Task<bool> HasAsync(string code)
|
||||
{
|
||||
if (currentUser.RoleId is not { } roleId) return false;
|
||||
return await permissions.HasPermissionAsync(roleId, code);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Authorize("expense.view")]
|
||||
public async Task<IActionResult> List(
|
||||
[FromQuery] ExpenseStatus? status,
|
||||
[FromQuery] Guid? categoryId,
|
||||
[FromQuery] Guid? warehouseId,
|
||||
[FromQuery] DateTime? from,
|
||||
[FromQuery] DateTime? to,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20) =>
|
||||
Ok(await service.ListAsync(status, categoryId, warehouseId, from, to, page, pageSize));
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
[Authorize("expense.view")]
|
||||
public async Task<IActionResult> Get(Guid id)
|
||||
{
|
||||
var result = await service.GetAsync(id);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create endpoint takes a required ?action= query to drive the 4 form buttons:
|
||||
/// draft → Save draft; post → Save & post (requires expense.post); pay → Save & pay
|
||||
/// (requires expense.post + expense.pay). Draft is the default.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[Authorize("expense.create")]
|
||||
public async Task<IActionResult> Create(
|
||||
CreateExpenseRequest request,
|
||||
[FromQuery] ExpenseAction action = ExpenseAction.Draft,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (action is ExpenseAction.Post or ExpenseAction.Pay && !await HasAsync("expense.post"))
|
||||
return Forbid();
|
||||
if (action == ExpenseAction.Pay && !await HasAsync("expense.pay"))
|
||||
return Forbid();
|
||||
|
||||
try { return Ok(await service.CreateAsync(request, action, ct)); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize("expense.create")]
|
||||
public async Task<IActionResult> UpdateDraft(Guid id, UpdateExpenseDraftRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.UpdateDraftAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/post")]
|
||||
[Authorize("expense.post")]
|
||||
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.PostAsync(id, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/pay")]
|
||||
[Authorize("expense.pay")]
|
||||
public async Task<IActionResult> Pay(Guid id, ExpensePaymentRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.PayAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/void")]
|
||||
[Authorize("expense.void")]
|
||||
public async Task<IActionResult> Void(Guid id, VoidExpenseRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.VoidAsync(id, request?.Reason, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Expense;
|
||||
|
||||
public record ExpensePaymentRequest(
|
||||
PaymentMethod Method,
|
||||
Guid? BankAccountId,
|
||||
Guid? ChequePageId);
|
||||
|
||||
public record CreateExpenseRequest(
|
||||
Guid CategoryId,
|
||||
Guid? WarehouseId,
|
||||
decimal Amount,
|
||||
DateTime BillDate,
|
||||
DateTime? DueDate,
|
||||
DateTime? PeriodCovered,
|
||||
Guid? VendorId,
|
||||
string? Payee,
|
||||
string? BillRef,
|
||||
string? Description,
|
||||
/// <summary>Required when action = Pay. Ignored for Draft / Post.</summary>
|
||||
ExpensePaymentRequest? Payment);
|
||||
|
||||
public record UpdateExpenseDraftRequest(
|
||||
Guid CategoryId,
|
||||
Guid? WarehouseId,
|
||||
decimal Amount,
|
||||
DateTime BillDate,
|
||||
DateTime? DueDate,
|
||||
DateTime? PeriodCovered,
|
||||
Guid? VendorId,
|
||||
string? Payee,
|
||||
string? BillRef,
|
||||
string? Description);
|
||||
|
||||
public record VoidExpenseRequest(string? Reason);
|
||||
|
||||
public record ExpenseAttachmentResponse(
|
||||
Guid Id,
|
||||
string OriginalFileName,
|
||||
string ContentType,
|
||||
long SizeBytes,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public record ExpenseResponse(
|
||||
Guid Id,
|
||||
Guid CategoryId,
|
||||
string CategoryName,
|
||||
Guid? ParentCategoryId,
|
||||
string? ParentCategoryName,
|
||||
Guid? WarehouseId,
|
||||
string? WarehouseName,
|
||||
decimal Amount,
|
||||
DateTime BillDate,
|
||||
DateTime? DueDate,
|
||||
DateTime? PeriodCovered,
|
||||
Guid? VendorId,
|
||||
string? VendorName,
|
||||
string? Payee,
|
||||
string? BillRef,
|
||||
string? Description,
|
||||
ExpenseStatus Status,
|
||||
PaymentMethod? Method,
|
||||
Guid? BankAccountId,
|
||||
Guid? ChequePageId,
|
||||
string? ChequeNo,
|
||||
DateTime? PaidAt,
|
||||
Guid RecordedByUserId,
|
||||
Guid? PostedByUserId,
|
||||
Guid? PaidByUserId,
|
||||
Guid? VoidedByUserId,
|
||||
string? VoidReason,
|
||||
Guid? RecurringTemplateId,
|
||||
List<ExpenseAttachmentResponse> Attachments,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public record PagedExpensesResponse(List<ExpenseResponse> Items, int Page, int PageSize, int Total);
|
||||
@@ -0,0 +1,50 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.Expense;
|
||||
|
||||
public class ExpenseRepository(AppDBContext context) : IExpenseRepository
|
||||
{
|
||||
private IQueryable<Models.Expense> Base() =>
|
||||
context.Expenses
|
||||
.Include(e => e.Category!).ThenInclude(c => c.Parent)
|
||||
.Include(e => e.Warehouse)
|
||||
.Include(e => e.Vendor)
|
||||
.Include(e => e.ChequePage)
|
||||
.Include(e => e.Attachments.Where(a => a.IsActive));
|
||||
|
||||
public async Task<Models.Expense?> GetByIdAsync(Guid id) =>
|
||||
await Base().FirstOrDefaultAsync(e => e.Id == id && e.IsActive);
|
||||
|
||||
private IQueryable<Models.Expense> Filtered(
|
||||
ExpenseStatus? status, Guid? categoryId, Guid? warehouseId, DateTime? from, DateTime? to) =>
|
||||
Base()
|
||||
.Where(e => e.IsActive)
|
||||
.Where(e => !status.HasValue || e.Status == status)
|
||||
.Where(e => !categoryId.HasValue
|
||||
|| e.FK_CategoryId == categoryId
|
||||
|| e.Category!.FK_ParentCategoryId == categoryId)
|
||||
.Where(e => !warehouseId.HasValue || e.FK_WarehouseId == warehouseId)
|
||||
.Where(e => !from.HasValue || e.BillDate >= from)
|
||||
.Where(e => !to.HasValue || e.BillDate <= to);
|
||||
|
||||
public async Task<List<Models.Expense>> ListAsync(
|
||||
ExpenseStatus? status, Guid? categoryId, Guid? warehouseId, DateTime? from, DateTime? to,
|
||||
int page, int pageSize) =>
|
||||
await Filtered(status, categoryId, warehouseId, from, to)
|
||||
.OrderByDescending(e => e.BillDate)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<int> CountAsync(
|
||||
ExpenseStatus? status, Guid? categoryId, Guid? warehouseId, DateTime? from, DateTime? to) =>
|
||||
await Filtered(status, categoryId, warehouseId, from, to).CountAsync();
|
||||
|
||||
public async Task AddAsync(Models.Expense entity) =>
|
||||
await context.Expenses.AddAsync(entity);
|
||||
|
||||
public void Update(Models.Expense entity) =>
|
||||
context.Expenses.Update(entity);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Features.BankAccount;
|
||||
using Distribution.Features.ChequeBook;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.Expense;
|
||||
|
||||
public class ExpenseService(
|
||||
AppDBContext context,
|
||||
IExpenseRepository repo,
|
||||
IBankAccountRepository bankAccountRepo,
|
||||
IChequeBookRepository chequeBookRepo,
|
||||
IUnitOfWork uow,
|
||||
ICurrentUserService currentUser)
|
||||
{
|
||||
private static ExpenseAttachmentResponse MapAttachment(Models.ExpenseAttachment a) =>
|
||||
new(a.Id, a.OriginalFileName, a.ContentType, a.SizeBytes, a.CreatedAt);
|
||||
|
||||
private static ExpenseResponse ToResponse(Models.Expense e) =>
|
||||
new(e.Id,
|
||||
e.FK_CategoryId, e.Category?.Name ?? string.Empty,
|
||||
e.Category?.FK_ParentCategoryId, e.Category?.Parent?.Name,
|
||||
e.FK_WarehouseId, e.Warehouse?.Name,
|
||||
e.Amount, e.BillDate, e.DueDate, e.PeriodCovered,
|
||||
e.FK_VendorId, e.Vendor?.Name,
|
||||
e.Payee, e.BillRef, e.Description,
|
||||
e.Status, e.Method, e.FK_BankAccountId, e.FK_ChequePageId, e.ChequePage?.ChequeNo,
|
||||
e.PaidAt,
|
||||
e.RecordedByUserId, e.PostedByUserId, e.PaidByUserId, e.VoidedByUserId, e.VoidReason,
|
||||
e.FK_RecurringTemplateId,
|
||||
e.Attachments.Where(a => a.IsActive).Select(MapAttachment).ToList(),
|
||||
e.CreatedAt);
|
||||
|
||||
public async Task<PagedExpensesResponse> ListAsync(
|
||||
ExpenseStatus? status, Guid? categoryId, Guid? warehouseId, DateTime? from, DateTime? to,
|
||||
int page, int pageSize)
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1 || pageSize > 200) pageSize = 20;
|
||||
|
||||
var items = await repo.ListAsync(status, categoryId, warehouseId, from, to, page, pageSize);
|
||||
var total = await repo.CountAsync(status, categoryId, warehouseId, from, to);
|
||||
return new PagedExpensesResponse(items.Select(ToResponse).ToList(), page, pageSize, total);
|
||||
}
|
||||
|
||||
public async Task<ExpenseResponse?> GetAsync(Guid id)
|
||||
{
|
||||
var e = await repo.GetByIdAsync(id);
|
||||
return e is null ? null : ToResponse(e);
|
||||
}
|
||||
|
||||
public async Task<ExpenseResponse> CreateAsync(CreateExpenseRequest request, ExpenseAction action, CancellationToken ct = default)
|
||||
{
|
||||
ValidateBase(request);
|
||||
|
||||
var category = await context.ExpenseCategories.Include(c => c.Parent)
|
||||
.FirstOrDefaultAsync(c => c.Id == request.CategoryId && c.IsActive, ct)
|
||||
?? throw new InvalidOperationException("Category not found");
|
||||
if (category.FK_ParentCategoryId is null)
|
||||
throw new InvalidOperationException("Pick a specific title under a category, not the category itself");
|
||||
|
||||
var userId = currentUser.UserId
|
||||
?? throw new InvalidOperationException("Authenticated user is required");
|
||||
|
||||
var entity = new Models.Expense
|
||||
{
|
||||
FK_CategoryId = request.CategoryId,
|
||||
FK_WarehouseId = request.WarehouseId,
|
||||
Amount = request.Amount,
|
||||
BillDate = request.BillDate.Date,
|
||||
DueDate = request.DueDate?.Date,
|
||||
PeriodCovered = request.PeriodCovered?.Date,
|
||||
FK_VendorId = request.VendorId,
|
||||
Payee = request.Payee,
|
||||
BillRef = request.BillRef,
|
||||
Description = request.Description,
|
||||
RecordedByUserId = userId,
|
||||
Status = ExpenseStatus.Draft
|
||||
};
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await repo.AddAsync(entity);
|
||||
|
||||
if (action is ExpenseAction.Post or ExpenseAction.Pay)
|
||||
{
|
||||
entity.Status = ExpenseStatus.Posted;
|
||||
entity.PostedByUserId = userId;
|
||||
entity.PostedAt = DateTime.Now;
|
||||
}
|
||||
|
||||
if (action == ExpenseAction.Pay)
|
||||
{
|
||||
if (request.Payment is null)
|
||||
throw new InvalidOperationException("Payment details are required when action = Pay");
|
||||
await ApplyPaymentAsync(entity, request.Payment, userId, ct);
|
||||
}
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return ToResponse((await repo.GetByIdAsync(entity.Id))!);
|
||||
}
|
||||
|
||||
public async Task<ExpenseResponse?> UpdateDraftAsync(Guid id, UpdateExpenseDraftRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var entity = await repo.GetByIdAsync(id);
|
||||
if (entity is null) return null;
|
||||
if (entity.Status != ExpenseStatus.Draft)
|
||||
throw new InvalidOperationException("Only Draft expenses can be edited");
|
||||
|
||||
if (request.Amount <= 0) throw new InvalidOperationException("Amount must be greater than zero");
|
||||
|
||||
entity.FK_CategoryId = request.CategoryId;
|
||||
entity.FK_WarehouseId = request.WarehouseId;
|
||||
entity.Amount = request.Amount;
|
||||
entity.BillDate = request.BillDate.Date;
|
||||
entity.DueDate = request.DueDate?.Date;
|
||||
entity.PeriodCovered = request.PeriodCovered?.Date;
|
||||
entity.FK_VendorId = request.VendorId;
|
||||
entity.Payee = request.Payee;
|
||||
entity.BillRef = request.BillRef;
|
||||
entity.Description = request.Description;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return ToResponse((await repo.GetByIdAsync(id))!);
|
||||
}
|
||||
|
||||
public async Task<ExpenseResponse?> PostAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var entity = await repo.GetByIdAsync(id);
|
||||
if (entity is null) return null;
|
||||
if (entity.Status != ExpenseStatus.Draft)
|
||||
throw new InvalidOperationException("Only Draft expenses can be posted");
|
||||
|
||||
var userId = currentUser.UserId
|
||||
?? throw new InvalidOperationException("Authenticated user is required");
|
||||
|
||||
entity.Status = ExpenseStatus.Posted;
|
||||
entity.PostedByUserId = userId;
|
||||
entity.PostedAt = DateTime.Now;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return ToResponse((await repo.GetByIdAsync(id))!);
|
||||
}
|
||||
|
||||
public async Task<ExpenseResponse?> PayAsync(Guid id, ExpensePaymentRequest payment, CancellationToken ct = default)
|
||||
{
|
||||
var entity = await repo.GetByIdAsync(id);
|
||||
if (entity is null) return null;
|
||||
if (entity.Status != ExpenseStatus.Posted)
|
||||
throw new InvalidOperationException("Only Posted expenses can be paid");
|
||||
|
||||
var userId = currentUser.UserId
|
||||
?? throw new InvalidOperationException("Authenticated user is required");
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
await ApplyPaymentAsync(entity, payment, userId, ct);
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return ToResponse((await repo.GetByIdAsync(id))!);
|
||||
}
|
||||
|
||||
public async Task<ExpenseResponse?> VoidAsync(Guid id, string? reason, CancellationToken ct = default)
|
||||
{
|
||||
var entity = await repo.GetByIdAsync(id);
|
||||
if (entity is null) return null;
|
||||
if (entity.Status == ExpenseStatus.Void)
|
||||
throw new InvalidOperationException("Already voided");
|
||||
if (entity.Status == ExpenseStatus.Paid)
|
||||
throw new InvalidOperationException("Paid expenses cannot be voided — issue a reversing entry instead");
|
||||
|
||||
var userId = currentUser.UserId
|
||||
?? throw new InvalidOperationException("Authenticated user is required");
|
||||
|
||||
entity.Status = ExpenseStatus.Void;
|
||||
entity.VoidedByUserId = userId;
|
||||
entity.VoidedAt = DateTime.Now;
|
||||
entity.VoidReason = reason;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return ToResponse((await repo.GetByIdAsync(id))!);
|
||||
}
|
||||
|
||||
// ─── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static void ValidateBase(CreateExpenseRequest request)
|
||||
{
|
||||
if (request.Amount <= 0) throw new InvalidOperationException("Amount must be greater than zero");
|
||||
if (request.DueDate.HasValue && request.DueDate.Value.Date < request.BillDate.Date)
|
||||
throw new InvalidOperationException("Due date cannot be earlier than bill date");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Settles payment: Cash → debit a BankAccount (incl. petty cash which is just a Cash account);
|
||||
/// Cheque → consume an Unused ChequePage from an Active cheque book.
|
||||
/// </summary>
|
||||
private async Task ApplyPaymentAsync(Models.Expense entity, ExpensePaymentRequest payment, Guid userId, CancellationToken ct)
|
||||
{
|
||||
if (payment.Method == PaymentMethod.Credit)
|
||||
throw new InvalidOperationException("Credit is not a valid method for expense payments");
|
||||
|
||||
if (payment.Method == PaymentMethod.Cash)
|
||||
{
|
||||
if (payment.BankAccountId is null)
|
||||
throw new InvalidOperationException("BankAccountId is required when Method is Cash");
|
||||
|
||||
var account = await bankAccountRepo.GetByIdAsync(payment.BankAccountId.Value)
|
||||
?? throw new InvalidOperationException("BankAccountId does not reference a valid account");
|
||||
if (account.IsActive is false)
|
||||
throw new InvalidOperationException("The selected account is inactive");
|
||||
|
||||
entity.FK_BankAccountId = account.Id;
|
||||
}
|
||||
else // Cheque
|
||||
{
|
||||
if (payment.ChequePageId is null)
|
||||
throw new InvalidOperationException("ChequePageId is required when Method is Cheque");
|
||||
|
||||
var page = await context.ChequePages
|
||||
.Include(p => p.ChequeBook)
|
||||
.FirstOrDefaultAsync(p => p.Id == payment.ChequePageId.Value, ct)
|
||||
?? throw new InvalidOperationException("ChequePageId does not reference a valid cheque page");
|
||||
if (page.IssueStatus != ChequePageIssueStatus.Unused)
|
||||
throw new InvalidOperationException($"Cannot issue a cheque in status '{page.IssueStatus}'");
|
||||
if (page.ChequeBook is null || page.ChequeBook.Status != ChequeBookStatus.Active)
|
||||
throw new InvalidOperationException("The cheque book is not active");
|
||||
|
||||
page.IssueStatus = ChequePageIssueStatus.Issued;
|
||||
page.PayeeType = PayeeType.Other;
|
||||
page.PayeeName = entity.Payee ?? entity.Vendor?.Name ?? "Expense";
|
||||
page.IssueDate = DateTime.Now;
|
||||
page.Amount = entity.Amount;
|
||||
page.CurrencyCode = "LKR";
|
||||
page.PrintedAt = DateTime.Now;
|
||||
|
||||
chequeBookRepo.UpdatePage(page);
|
||||
entity.FK_ChequePageId = page.Id;
|
||||
entity.ChequePage = page;
|
||||
}
|
||||
|
||||
entity.Method = payment.Method;
|
||||
entity.Status = ExpenseStatus.Paid;
|
||||
entity.PaidByUserId = userId;
|
||||
entity.PaidAt = DateTime.Now;
|
||||
|
||||
// TODO ledger: when accounting is wired in, post a journal entry here —
|
||||
// Debit Expense (category)
|
||||
// Credit Cash/Bank (BankAccount) OR Cheques-outstanding (for Cheque payments)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.Expense;
|
||||
|
||||
public interface IExpenseRepository
|
||||
{
|
||||
Task<Models.Expense?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.Expense>> ListAsync(
|
||||
ExpenseStatus? status,
|
||||
Guid? categoryId,
|
||||
Guid? warehouseId,
|
||||
DateTime? from,
|
||||
DateTime? to,
|
||||
int page,
|
||||
int pageSize);
|
||||
Task<int> CountAsync(
|
||||
ExpenseStatus? status,
|
||||
Guid? categoryId,
|
||||
Guid? warehouseId,
|
||||
DateTime? from,
|
||||
DateTime? to);
|
||||
Task AddAsync(Models.Expense entity);
|
||||
void Update(Models.Expense entity);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.ExpenseCategory;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/expense-categories")]
|
||||
public class ExpenseCategoryController(ExpenseCategoryService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize("expense.view")]
|
||||
public async Task<IActionResult> Tree() => Ok(await service.GetTreeAsync());
|
||||
|
||||
[HttpPost]
|
||||
[Authorize("expense.manage_categories")]
|
||||
public async Task<IActionResult> Create(CreateExpenseCategoryRequest request, CancellationToken ct)
|
||||
{
|
||||
try { return Ok(await service.CreateAsync(request, ct)); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize("expense.manage_categories")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateExpenseCategoryRequest request, CancellationToken ct)
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Distribution.Features.ExpenseCategory;
|
||||
|
||||
public record ExpenseCategoryResponse(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string? Code,
|
||||
Guid? ParentCategoryId,
|
||||
bool IsActive,
|
||||
List<ExpenseCategoryResponse> Children);
|
||||
|
||||
public record CreateExpenseCategoryRequest(
|
||||
string Name,
|
||||
string? Code,
|
||||
Guid? ParentCategoryId);
|
||||
|
||||
public record UpdateExpenseCategoryRequest(
|
||||
string Name,
|
||||
string? Code,
|
||||
bool IsActive);
|
||||
@@ -0,0 +1,19 @@
|
||||
using Distribution.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.ExpenseCategory;
|
||||
|
||||
public class ExpenseCategoryRepository(AppDBContext context) : IExpenseCategoryRepository
|
||||
{
|
||||
public async Task<Models.ExpenseCategory?> GetByIdAsync(Guid id) =>
|
||||
await context.ExpenseCategories.FirstOrDefaultAsync(c => c.Id == id);
|
||||
|
||||
public async Task<List<Models.ExpenseCategory>> GetAllAsync() =>
|
||||
await context.ExpenseCategories.OrderBy(c => c.FK_ParentCategoryId).ThenBy(c => c.Name).ToListAsync();
|
||||
|
||||
public async Task AddAsync(Models.ExpenseCategory entity) =>
|
||||
await context.ExpenseCategories.AddAsync(entity);
|
||||
|
||||
public void Update(Models.ExpenseCategory entity) =>
|
||||
context.ExpenseCategories.Update(entity);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using Distribution.Infra.UoW;
|
||||
|
||||
namespace Distribution.Features.ExpenseCategory;
|
||||
|
||||
public class ExpenseCategoryService(IExpenseCategoryRepository repo, IUnitOfWork uow)
|
||||
{
|
||||
public async Task<List<ExpenseCategoryResponse>> GetTreeAsync()
|
||||
{
|
||||
var all = await repo.GetAllAsync();
|
||||
var byParent = all.ToLookup(c => c.FK_ParentCategoryId);
|
||||
|
||||
return all
|
||||
.Where(c => c.FK_ParentCategoryId is null)
|
||||
.Select(c => Map(c, byParent))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static ExpenseCategoryResponse Map(Models.ExpenseCategory c, ILookup<Guid?, Models.ExpenseCategory> byParent) =>
|
||||
new(c.Id, c.Name, c.Code, c.FK_ParentCategoryId, c.IsActive,
|
||||
byParent[c.Id].Select(child => Map(child, byParent)).ToList());
|
||||
|
||||
public async Task<ExpenseCategoryResponse> CreateAsync(CreateExpenseCategoryRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
throw new InvalidOperationException("Name is required");
|
||||
|
||||
if (request.ParentCategoryId is { } parentId)
|
||||
{
|
||||
var parent = await repo.GetByIdAsync(parentId)
|
||||
?? throw new InvalidOperationException("Parent category not found");
|
||||
if (parent.FK_ParentCategoryId is not null)
|
||||
throw new InvalidOperationException("Only two levels are allowed — cannot nest under a title");
|
||||
}
|
||||
|
||||
var entity = new Models.ExpenseCategory
|
||||
{
|
||||
Name = request.Name.Trim(),
|
||||
Code = string.IsNullOrWhiteSpace(request.Code) ? null : request.Code.Trim(),
|
||||
FK_ParentCategoryId = request.ParentCategoryId
|
||||
};
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { await repo.AddAsync(entity); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return new ExpenseCategoryResponse(entity.Id, entity.Name, entity.Code, entity.FK_ParentCategoryId, entity.IsActive, []);
|
||||
}
|
||||
|
||||
public async Task<ExpenseCategoryResponse?> UpdateAsync(Guid id, UpdateExpenseCategoryRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var entity = await repo.GetByIdAsync(id);
|
||||
if (entity is null) return null;
|
||||
|
||||
entity.Name = request.Name.Trim();
|
||||
entity.Code = string.IsNullOrWhiteSpace(request.Code) ? null : request.Code.Trim();
|
||||
entity.IsActive = request.IsActive;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return new ExpenseCategoryResponse(entity.Id, entity.Name, entity.Code, entity.FK_ParentCategoryId, entity.IsActive, []);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.ExpenseCategory;
|
||||
|
||||
public interface IExpenseCategoryRepository
|
||||
{
|
||||
Task<Models.ExpenseCategory?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.ExpenseCategory>> GetAllAsync();
|
||||
Task AddAsync(Models.ExpenseCategory entity);
|
||||
void Update(Models.ExpenseCategory entity);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.ExpenseVendor;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/expense-vendors")]
|
||||
public class ExpenseVendorController(ExpenseVendorService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize("expense.view")]
|
||||
public async Task<IActionResult> List(
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 50) =>
|
||||
Ok(await service.ListAsync(search, page, pageSize));
|
||||
|
||||
[HttpPost]
|
||||
[Authorize("expense.manage_categories")]
|
||||
public async Task<IActionResult> Create(CreateExpenseVendorRequest request, CancellationToken ct)
|
||||
{
|
||||
try { return Ok(await service.CreateAsync(request, ct)); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize("expense.manage_categories")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateExpenseVendorRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace Distribution.Features.ExpenseVendor;
|
||||
|
||||
public record ExpenseVendorResponse(Guid Id, string Name, string? ContactInfo, bool IsActive);
|
||||
|
||||
public record CreateExpenseVendorRequest(string Name, string? ContactInfo);
|
||||
|
||||
public record UpdateExpenseVendorRequest(string Name, string? ContactInfo, bool IsActive);
|
||||
@@ -0,0 +1,31 @@
|
||||
using Distribution.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.ExpenseVendor;
|
||||
|
||||
public class ExpenseVendorRepository(AppDBContext context) : IExpenseVendorRepository
|
||||
{
|
||||
public async Task<Models.ExpenseVendor?> GetByIdAsync(Guid id) =>
|
||||
await context.ExpenseVendors.FirstOrDefaultAsync(v => v.Id == id);
|
||||
|
||||
public async Task<List<Models.ExpenseVendor>> ListAsync(string? search, int page, int pageSize) =>
|
||||
await context.ExpenseVendors
|
||||
.Where(v => v.IsActive && (search == null || v.Name.Contains(search)))
|
||||
.OrderBy(v => v.Name)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<int> CountAsync(string? search) =>
|
||||
await context.ExpenseVendors
|
||||
.CountAsync(v => v.IsActive && (search == null || v.Name.Contains(search)));
|
||||
|
||||
public async Task<bool> NameExistsAsync(string name, Guid? excludeId) =>
|
||||
await context.ExpenseVendors.AnyAsync(v => v.Name == name && (excludeId == null || v.Id != excludeId));
|
||||
|
||||
public async Task AddAsync(Models.ExpenseVendor entity) =>
|
||||
await context.ExpenseVendors.AddAsync(entity);
|
||||
|
||||
public void Update(Models.ExpenseVendor entity) =>
|
||||
context.ExpenseVendors.Update(entity);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Distribution.Infra.UoW;
|
||||
|
||||
namespace Distribution.Features.ExpenseVendor;
|
||||
|
||||
public record PagedExpenseVendorsResponse(List<ExpenseVendorResponse> Items, int Page, int PageSize, int Total);
|
||||
|
||||
public class ExpenseVendorService(IExpenseVendorRepository repo, IUnitOfWork uow)
|
||||
{
|
||||
private static ExpenseVendorResponse Map(Models.ExpenseVendor v) =>
|
||||
new(v.Id, v.Name, v.ContactInfo, v.IsActive);
|
||||
|
||||
public async Task<PagedExpenseVendorsResponse> ListAsync(string? search, int page, int pageSize)
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1 || pageSize > 200) pageSize = 50;
|
||||
|
||||
search = string.IsNullOrWhiteSpace(search) ? null : search.Trim();
|
||||
var items = await repo.ListAsync(search, page, pageSize);
|
||||
var total = await repo.CountAsync(search);
|
||||
return new PagedExpenseVendorsResponse(items.Select(Map).ToList(), page, pageSize, total);
|
||||
}
|
||||
|
||||
public async Task<ExpenseVendorResponse> CreateAsync(CreateExpenseVendorRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var name = (request.Name ?? "").Trim();
|
||||
if (string.IsNullOrEmpty(name)) throw new InvalidOperationException("Name is required");
|
||||
if (await repo.NameExistsAsync(name, null))
|
||||
throw new InvalidOperationException("A vendor with this name already exists");
|
||||
|
||||
var entity = new Models.ExpenseVendor { Name = name, ContactInfo = request.ContactInfo };
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { await repo.AddAsync(entity); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return Map(entity);
|
||||
}
|
||||
|
||||
public async Task<ExpenseVendorResponse?> UpdateAsync(Guid id, UpdateExpenseVendorRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var entity = await repo.GetByIdAsync(id);
|
||||
if (entity is null) return null;
|
||||
|
||||
var name = (request.Name ?? "").Trim();
|
||||
if (string.IsNullOrEmpty(name)) throw new InvalidOperationException("Name is required");
|
||||
if (await repo.NameExistsAsync(name, id))
|
||||
throw new InvalidOperationException("A vendor with this name already exists");
|
||||
|
||||
entity.Name = name;
|
||||
entity.ContactInfo = request.ContactInfo;
|
||||
entity.IsActive = request.IsActive;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return Map(entity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Distribution.Features.ExpenseVendor;
|
||||
|
||||
public interface IExpenseVendorRepository
|
||||
{
|
||||
Task<Models.ExpenseVendor?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.ExpenseVendor>> ListAsync(string? search, int page, int pageSize);
|
||||
Task<int> CountAsync(string? search);
|
||||
Task<bool> NameExistsAsync(string name, Guid? excludeId);
|
||||
Task AddAsync(Models.ExpenseVendor entity);
|
||||
void Update(Models.ExpenseVendor entity);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Distribution.Features.RecurringExpense;
|
||||
|
||||
public interface IRecurringExpenseRepository
|
||||
{
|
||||
Task<Models.RecurringExpenseTemplate?> GetByIdAsync(Guid id);
|
||||
Task<List<Models.RecurringExpenseTemplate>> ListAsync(int page, int pageSize);
|
||||
Task<int> CountAsync();
|
||||
Task<List<Models.RecurringExpenseTemplate>> ListDueAsync(DateTime asOf);
|
||||
Task AddAsync(Models.RecurringExpenseTemplate entity);
|
||||
void Update(Models.RecurringExpenseTemplate entity);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Distribution.Features.RecurringExpense;
|
||||
|
||||
public record ToggleRequest(bool Paused);
|
||||
|
||||
[ApiController]
|
||||
[Route("api/recurring-expenses")]
|
||||
public class RecurringExpenseController(RecurringExpenseService service) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Authorize("expense.view")]
|
||||
public async Task<IActionResult> List([FromQuery] int page = 1, [FromQuery] int pageSize = 20) =>
|
||||
Ok(await service.ListAsync(page, pageSize));
|
||||
|
||||
[HttpPost]
|
||||
[Authorize("expense.manage_recurring")]
|
||||
public async Task<IActionResult> Create(CreateRecurringExpenseRequest request, CancellationToken ct)
|
||||
{
|
||||
try { return Ok(await service.CreateAsync(request, ct)); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
[Authorize("expense.manage_recurring")]
|
||||
public async Task<IActionResult> Update(Guid id, UpdateRecurringExpenseRequest request, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await service.UpdateAsync(id, request, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/toggle")]
|
||||
[Authorize("expense.manage_recurring")]
|
||||
public async Task<IActionResult> Toggle(Guid id, ToggleRequest body, CancellationToken ct)
|
||||
{
|
||||
var result = await service.ToggleAsync(id, body.Paused, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost("run-due")]
|
||||
[Authorize("expense.manage_recurring")]
|
||||
public async Task<IActionResult> RunDue(CancellationToken ct)
|
||||
{
|
||||
try { return Ok(await service.RunDueAsync(ct)); }
|
||||
catch (InvalidOperationException ex) { return BadRequest(new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Distribution.Models;
|
||||
|
||||
namespace Distribution.Features.RecurringExpense;
|
||||
|
||||
public record CreateRecurringExpenseRequest(
|
||||
string Name,
|
||||
Guid CategoryId,
|
||||
Guid? WarehouseId,
|
||||
Guid? VendorId,
|
||||
decimal Amount,
|
||||
string? Payee,
|
||||
string? Description,
|
||||
RecurringFrequency Frequency,
|
||||
int? DayOfMonth,
|
||||
int? DayOfWeek,
|
||||
DateTime StartDate,
|
||||
DateTime? EndDate,
|
||||
bool AutoPost);
|
||||
|
||||
public record UpdateRecurringExpenseRequest(
|
||||
string Name,
|
||||
Guid CategoryId,
|
||||
Guid? WarehouseId,
|
||||
Guid? VendorId,
|
||||
decimal Amount,
|
||||
string? Payee,
|
||||
string? Description,
|
||||
RecurringFrequency Frequency,
|
||||
int? DayOfMonth,
|
||||
int? DayOfWeek,
|
||||
DateTime StartDate,
|
||||
DateTime? EndDate,
|
||||
bool AutoPost);
|
||||
|
||||
public record RecurringExpenseResponse(
|
||||
Guid Id,
|
||||
string Name,
|
||||
Guid CategoryId,
|
||||
string CategoryName,
|
||||
Guid? WarehouseId,
|
||||
string? WarehouseName,
|
||||
Guid? VendorId,
|
||||
string? VendorName,
|
||||
decimal Amount,
|
||||
string? Payee,
|
||||
string? Description,
|
||||
RecurringFrequency Frequency,
|
||||
int? DayOfMonth,
|
||||
int? DayOfWeek,
|
||||
DateTime StartDate,
|
||||
DateTime? EndDate,
|
||||
DateTime NextRunDate,
|
||||
bool AutoPost,
|
||||
bool IsPaused,
|
||||
bool IsActive,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public record PagedRecurringExpensesResponse(List<RecurringExpenseResponse> Items, int Page, int PageSize, int Total);
|
||||
|
||||
public record RunDueResponse(int Generated, List<Guid> ExpenseIds);
|
||||
@@ -0,0 +1,39 @@
|
||||
using Distribution.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.RecurringExpense;
|
||||
|
||||
public class RecurringExpenseRepository(AppDBContext context) : IRecurringExpenseRepository
|
||||
{
|
||||
private IQueryable<Models.RecurringExpenseTemplate> Base() =>
|
||||
context.RecurringExpenseTemplates
|
||||
.Include(t => t.Category)
|
||||
.Include(t => t.Warehouse)
|
||||
.Include(t => t.Vendor);
|
||||
|
||||
public async Task<Models.RecurringExpenseTemplate?> GetByIdAsync(Guid id) =>
|
||||
await Base().FirstOrDefaultAsync(t => t.Id == id);
|
||||
|
||||
public async Task<List<Models.RecurringExpenseTemplate>> ListAsync(int page, int pageSize) =>
|
||||
await Base()
|
||||
.Where(t => t.IsActive)
|
||||
.OrderBy(t => t.IsPaused).ThenBy(t => t.NextRunDate)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<int> CountAsync() =>
|
||||
await context.RecurringExpenseTemplates.CountAsync(t => t.IsActive);
|
||||
|
||||
public async Task<List<Models.RecurringExpenseTemplate>> ListDueAsync(DateTime asOf) =>
|
||||
await Base()
|
||||
.Where(t => t.IsActive && !t.IsPaused && t.NextRunDate <= asOf
|
||||
&& (t.EndDate == null || t.EndDate >= asOf))
|
||||
.ToListAsync();
|
||||
|
||||
public async Task AddAsync(Models.RecurringExpenseTemplate entity) =>
|
||||
await context.RecurringExpenseTemplates.AddAsync(entity);
|
||||
|
||||
public void Update(Models.RecurringExpenseTemplate entity) =>
|
||||
context.RecurringExpenseTemplates.Update(entity);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using Distribution.Data;
|
||||
using Distribution.Infra.Authorization;
|
||||
using Distribution.Infra.UoW;
|
||||
using Distribution.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Features.RecurringExpense;
|
||||
|
||||
public class RecurringExpenseService(
|
||||
AppDBContext context,
|
||||
IRecurringExpenseRepository repo,
|
||||
IUnitOfWork uow,
|
||||
ICurrentUserService currentUser)
|
||||
{
|
||||
private static RecurringExpenseResponse Map(Models.RecurringExpenseTemplate t) =>
|
||||
new(t.Id, t.Name,
|
||||
t.FK_CategoryId, t.Category?.Name ?? string.Empty,
|
||||
t.FK_WarehouseId, t.Warehouse?.Name,
|
||||
t.FK_VendorId, t.Vendor?.Name,
|
||||
t.Amount, t.Payee, t.Description,
|
||||
t.Frequency, t.DayOfMonth, t.DayOfWeek,
|
||||
t.StartDate, t.EndDate, t.NextRunDate, t.AutoPost, t.IsPaused, t.IsActive, t.CreatedAt);
|
||||
|
||||
public async Task<PagedRecurringExpensesResponse> ListAsync(int page, int pageSize)
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1 || pageSize > 200) pageSize = 20;
|
||||
var items = await repo.ListAsync(page, pageSize);
|
||||
var total = await repo.CountAsync();
|
||||
return new PagedRecurringExpensesResponse(items.Select(Map).ToList(), page, pageSize, total);
|
||||
}
|
||||
|
||||
public async Task<RecurringExpenseResponse> CreateAsync(CreateRecurringExpenseRequest request, CancellationToken ct = default)
|
||||
{
|
||||
ValidateSchedule(request.Frequency, request.DayOfMonth, request.DayOfWeek);
|
||||
if (request.Amount <= 0) throw new InvalidOperationException("Amount must be greater than zero");
|
||||
|
||||
var category = await context.ExpenseCategories.FirstOrDefaultAsync(c => c.Id == request.CategoryId && c.IsActive, ct)
|
||||
?? throw new InvalidOperationException("Category not found");
|
||||
if (category.FK_ParentCategoryId is null)
|
||||
throw new InvalidOperationException("Pick a specific title under a category");
|
||||
|
||||
var entity = new Models.RecurringExpenseTemplate
|
||||
{
|
||||
Name = request.Name.Trim(),
|
||||
FK_CategoryId = request.CategoryId,
|
||||
FK_WarehouseId = request.WarehouseId,
|
||||
FK_VendorId = request.VendorId,
|
||||
Amount = request.Amount,
|
||||
Payee = request.Payee,
|
||||
Description = request.Description,
|
||||
Frequency = request.Frequency,
|
||||
DayOfMonth = request.DayOfMonth,
|
||||
DayOfWeek = request.DayOfWeek,
|
||||
StartDate = request.StartDate.Date,
|
||||
EndDate = request.EndDate?.Date,
|
||||
NextRunDate = request.StartDate.Date,
|
||||
AutoPost = request.AutoPost
|
||||
};
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { await repo.AddAsync(entity); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return Map((await repo.GetByIdAsync(entity.Id))!);
|
||||
}
|
||||
|
||||
public async Task<RecurringExpenseResponse?> UpdateAsync(Guid id, UpdateRecurringExpenseRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var entity = await repo.GetByIdAsync(id);
|
||||
if (entity is null) return null;
|
||||
|
||||
ValidateSchedule(request.Frequency, request.DayOfMonth, request.DayOfWeek);
|
||||
if (request.Amount <= 0) throw new InvalidOperationException("Amount must be greater than zero");
|
||||
|
||||
entity.Name = request.Name.Trim();
|
||||
entity.FK_CategoryId = request.CategoryId;
|
||||
entity.FK_WarehouseId = request.WarehouseId;
|
||||
entity.FK_VendorId = request.VendorId;
|
||||
entity.Amount = request.Amount;
|
||||
entity.Payee = request.Payee;
|
||||
entity.Description = request.Description;
|
||||
entity.Frequency = request.Frequency;
|
||||
entity.DayOfMonth = request.DayOfMonth;
|
||||
entity.DayOfWeek = request.DayOfWeek;
|
||||
entity.StartDate = request.StartDate.Date;
|
||||
entity.EndDate = request.EndDate?.Date;
|
||||
entity.AutoPost = request.AutoPost;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return Map((await repo.GetByIdAsync(id))!);
|
||||
}
|
||||
|
||||
public async Task<RecurringExpenseResponse?> ToggleAsync(Guid id, bool paused, CancellationToken ct = default)
|
||||
{
|
||||
var entity = await repo.GetByIdAsync(id);
|
||||
if (entity is null) return null;
|
||||
entity.IsPaused = paused;
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try { repo.Update(entity); await uow.CommitAsync(ct); }
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return Map(entity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Materializes every active, non-paused template whose NextRunDate ≤ today
|
||||
/// into an Expense row, then advances NextRunDate by one period. Idempotent
|
||||
/// per call — advances only after a successful insert.
|
||||
/// </summary>
|
||||
public async Task<RunDueResponse> RunDueAsync(CancellationToken ct = default)
|
||||
{
|
||||
var today = DateTime.Today;
|
||||
var due = await repo.ListDueAsync(today);
|
||||
if (due.Count == 0) return new RunDueResponse(0, []);
|
||||
|
||||
var userId = currentUser.UserId
|
||||
?? throw new InvalidOperationException("Authenticated user is required");
|
||||
|
||||
var createdIds = new List<Guid>();
|
||||
|
||||
await uow.BeginAsync(ct);
|
||||
try
|
||||
{
|
||||
foreach (var t in due)
|
||||
{
|
||||
var expense = new Models.Expense
|
||||
{
|
||||
FK_CategoryId = t.FK_CategoryId,
|
||||
FK_WarehouseId = t.FK_WarehouseId,
|
||||
FK_VendorId = t.FK_VendorId,
|
||||
Amount = t.Amount,
|
||||
BillDate = t.NextRunDate,
|
||||
PeriodCovered = new DateTime(t.NextRunDate.Year, t.NextRunDate.Month, 1),
|
||||
Payee = t.Payee,
|
||||
Description = t.Description,
|
||||
Status = t.AutoPost ? ExpenseStatus.Posted : ExpenseStatus.Draft,
|
||||
RecordedByUserId = userId,
|
||||
PostedByUserId = t.AutoPost ? userId : null,
|
||||
PostedAt = t.AutoPost ? DateTime.Now : null,
|
||||
FK_RecurringTemplateId = t.Id
|
||||
};
|
||||
await context.Expenses.AddAsync(expense, ct);
|
||||
createdIds.Add(expense.Id);
|
||||
|
||||
t.NextRunDate = AdvanceNext(t.NextRunDate, t.Frequency);
|
||||
repo.Update(t);
|
||||
}
|
||||
|
||||
await uow.CommitAsync(ct);
|
||||
}
|
||||
catch { await uow.RollbackAsync(ct); throw; }
|
||||
|
||||
return new RunDueResponse(createdIds.Count, createdIds);
|
||||
}
|
||||
|
||||
private static DateTime AdvanceNext(DateTime from, RecurringFrequency freq) => freq switch
|
||||
{
|
||||
RecurringFrequency.Weekly => from.AddDays(7),
|
||||
RecurringFrequency.Monthly => from.AddMonths(1),
|
||||
RecurringFrequency.Quarterly => from.AddMonths(3),
|
||||
RecurringFrequency.Yearly => from.AddYears(1),
|
||||
_ => throw new InvalidOperationException($"Unknown frequency {freq}")
|
||||
};
|
||||
|
||||
private static void ValidateSchedule(RecurringFrequency freq, int? dayOfMonth, int? dayOfWeek)
|
||||
{
|
||||
if (freq == RecurringFrequency.Weekly)
|
||||
{
|
||||
if (dayOfWeek is null or < 0 or > 6)
|
||||
throw new InvalidOperationException("DayOfWeek (0-6) is required for Weekly");
|
||||
}
|
||||
else
|
||||
{
|
||||
if (dayOfMonth is null or < 1 or > 31)
|
||||
throw new InvalidOperationException("DayOfMonth (1-31) is required for Monthly/Quarterly/Yearly");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -68,4 +68,10 @@ namespace Distribution.Models
|
||||
public enum ReceivedChequeStatusAction { Deposit, Clear, Return, Cancel }
|
||||
|
||||
public enum ReceivedFromType { Customer, Supplier, Other }
|
||||
|
||||
public enum ExpenseStatus { Draft, Posted, Paid, Void }
|
||||
|
||||
public enum ExpenseAction { Draft, Post, Pay }
|
||||
|
||||
public enum RecurringFrequency { Weekly, Monthly, Quarterly, Yearly }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
public class Expense : Template
|
||||
{
|
||||
/// <summary>Leaf category (Title). Parent is the user-facing Category group.</summary>
|
||||
public Guid FK_CategoryId { get; set; }
|
||||
[ForeignKey("FK_CategoryId")]
|
||||
public ExpenseCategory? Category { get; set; }
|
||||
|
||||
/// <summary>Branch the expense is attributed to. Null = company-wide.</summary>
|
||||
public Guid? FK_WarehouseId { get; set; }
|
||||
[ForeignKey("FK_WarehouseId")]
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
[Precision(18, 2)]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
public DateTime BillDate { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
/// <summary>First day of the month the expense covers (YYYY-MM-01).</summary>
|
||||
public DateTime? PeriodCovered { get; set; }
|
||||
|
||||
public Guid? FK_VendorId { get; set; }
|
||||
[ForeignKey("FK_VendorId")]
|
||||
public ExpenseVendor? Vendor { get; set; }
|
||||
|
||||
public string? Payee { get; set; }
|
||||
public string? BillRef { get; set; }
|
||||
public string? Description { get; set; }
|
||||
|
||||
public ExpenseStatus Status { get; set; } = ExpenseStatus.Draft;
|
||||
|
||||
/// <summary>Payment settlement — null until Status transitions to Paid.</summary>
|
||||
public PaymentMethod? Method { get; set; }
|
||||
public Guid? FK_BankAccountId { get; set; }
|
||||
[ForeignKey("FK_BankAccountId")]
|
||||
public BankAccount? BankAccount { get; set; }
|
||||
|
||||
public Guid? FK_ChequePageId { get; set; }
|
||||
[ForeignKey("FK_ChequePageId")]
|
||||
public ChequePage? ChequePage { get; set; }
|
||||
|
||||
public DateTime? PaidAt { get; set; }
|
||||
|
||||
public Guid RecordedByUserId { get; set; }
|
||||
public Guid? PostedByUserId { get; set; }
|
||||
public DateTime? PostedAt { get; set; }
|
||||
public Guid? PaidByUserId { get; set; }
|
||||
public Guid? VoidedByUserId { get; set; }
|
||||
public DateTime? VoidedAt { get; set; }
|
||||
public string? VoidReason { get; set; }
|
||||
|
||||
/// <summary>Null for manually-entered expenses; set when generated by run-due.</summary>
|
||||
public Guid? FK_RecurringTemplateId { get; set; }
|
||||
[ForeignKey("FK_RecurringTemplateId")]
|
||||
public RecurringExpenseTemplate? RecurringTemplate { get; set; }
|
||||
|
||||
public ICollection<ExpenseAttachment> Attachments { get; set; } = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
public class ExpenseAttachment : Template
|
||||
{
|
||||
public Guid FK_ExpenseId { get; set; }
|
||||
[ForeignKey("FK_ExpenseId")]
|
||||
public Expense? Expense { get; set; }
|
||||
|
||||
public string OriginalFileName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Absolute path on disk. Served through a streaming endpoint, never statically.</summary>
|
||||
public string StoredPath { get; set; } = string.Empty;
|
||||
|
||||
public string ContentType { get; set; } = string.Empty;
|
||||
public long SizeBytes { get; set; }
|
||||
|
||||
public Guid UploadedByUserId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Two-level tree: top-level categories (e.g. "Salaries", "Utilities") and
|
||||
/// child "titles" (e.g. "Bonus", "Electricity"). Expenses link to a leaf (Title).
|
||||
/// </summary>
|
||||
public class ExpenseCategory : Template
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Code { get; set; }
|
||||
|
||||
public Guid? FK_ParentCategoryId { get; set; }
|
||||
[ForeignKey("FK_ParentCategoryId")]
|
||||
public ExpenseCategory? Parent { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Distribution.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Lightweight vendor directory used only by the Expenses module. Kept
|
||||
/// separate from Supplier (which has performance scores, PO/GRN linkage).
|
||||
/// </summary>
|
||||
public class ExpenseVendor : Template
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? ContactInfo { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Distribution.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Blueprint for a recurring expense. Materialized into Expense rows by
|
||||
/// POST /api/recurring-expenses/run-due based on NextRunDate.
|
||||
/// </summary>
|
||||
public class RecurringExpenseTemplate : Template
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public Guid FK_CategoryId { get; set; }
|
||||
[ForeignKey("FK_CategoryId")]
|
||||
public ExpenseCategory? Category { get; set; }
|
||||
|
||||
public Guid? FK_WarehouseId { get; set; }
|
||||
[ForeignKey("FK_WarehouseId")]
|
||||
public Warehouse? Warehouse { get; set; }
|
||||
|
||||
public Guid? FK_VendorId { get; set; }
|
||||
[ForeignKey("FK_VendorId")]
|
||||
public ExpenseVendor? Vendor { get; set; }
|
||||
|
||||
[Precision(18, 2)]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
public string? Payee { get; set; }
|
||||
public string? Description { get; set; }
|
||||
|
||||
public RecurringFrequency Frequency { get; set; }
|
||||
/// <summary>1..31 for Monthly/Quarterly/Yearly. Ignored for Weekly.</summary>
|
||||
public int? DayOfMonth { get; set; }
|
||||
/// <summary>0..6 (Sun=0) for Weekly. Ignored otherwise.</summary>
|
||||
public int? DayOfWeek { get; set; }
|
||||
|
||||
public DateTime StartDate { get; set; }
|
||||
public DateTime? EndDate { get; set; }
|
||||
public DateTime NextRunDate { get; set; }
|
||||
|
||||
/// <summary>When true, generated expenses jump straight to Posted; otherwise Draft.</summary>
|
||||
public bool AutoPost { get; set; }
|
||||
|
||||
public bool IsPaused { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,10 @@ using Distribution.Features.Scheme;
|
||||
using Distribution.Features.Store;
|
||||
using Distribution.Features.Supplier;
|
||||
using Distribution.Features.SupplierPayment;
|
||||
using Distribution.Features.Expense;
|
||||
using Distribution.Features.ExpenseCategory;
|
||||
using Distribution.Features.ExpenseVendor;
|
||||
using Distribution.Features.RecurringExpense;
|
||||
using Distribution.Features.Sync;
|
||||
using Distribution.Features.Uom;
|
||||
using Distribution.Features.User;
|
||||
@@ -103,6 +107,10 @@ builder.Services.AddScoped<IBankAccountRepository, BankAccountRepository>();
|
||||
builder.Services.AddScoped<IChequeBookRepository, ChequeBookRepository>();
|
||||
builder.Services.AddScoped<IReceivedChequeRepository, ReceivedChequeRepository>();
|
||||
builder.Services.AddScoped<ISupplierPaymentRepository, SupplierPaymentRepository>();
|
||||
builder.Services.AddScoped<IExpenseCategoryRepository, ExpenseCategoryRepository>();
|
||||
builder.Services.AddScoped<IExpenseVendorRepository, ExpenseVendorRepository>();
|
||||
builder.Services.AddScoped<IExpenseRepository, ExpenseRepository>();
|
||||
builder.Services.AddScoped<IRecurringExpenseRepository, RecurringExpenseRepository>();
|
||||
builder.Services.AddScoped<Distribution.Features.StoreCredit.IStoreCreditMovementRepository, Distribution.Features.StoreCredit.StoreCreditMovementRepository>();
|
||||
|
||||
builder.Services.AddScoped<RegionService>();
|
||||
@@ -143,6 +151,11 @@ builder.Services.AddScoped<BankAccountService>();
|
||||
builder.Services.AddScoped<ChequeBookService>();
|
||||
builder.Services.AddScoped<ReceivedChequeService>();
|
||||
builder.Services.AddScoped<SupplierPaymentService>();
|
||||
builder.Services.AddScoped<ExpenseCategoryService>();
|
||||
builder.Services.AddScoped<ExpenseVendorService>();
|
||||
builder.Services.AddScoped<ExpenseService>();
|
||||
builder.Services.AddScoped<ExpenseAttachmentService>();
|
||||
builder.Services.AddScoped<RecurringExpenseService>();
|
||||
builder.Services.AddScoped<SysConfigService>();
|
||||
|
||||
builder.Services.AddScoped<INotificationRepository, NotificationRepository>();
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"Expense": {
|
||||
"AttachmentsRoot": "/home/distribution/core/assets/expenses"
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,11 @@
|
||||
"StoreManagement": {
|
||||
"UnverifiedKycOrderThreshold": 0
|
||||
},
|
||||
"Expense": {
|
||||
// Dev default. Production overrides to /home/distribution/core/assets/expenses
|
||||
// via appsettings.Production.json or an ENV var.
|
||||
"AttachmentsRoot": "D:\\erp-storage\\expenses"
|
||||
},
|
||||
"AuthService": {
|
||||
"BaseUrl": ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { EmptyState } from '@/components/shared/empty-state'
|
||||
import { useTopLoader } from '@/components/shared/top-loader'
|
||||
import { useAuth } from '@/contexts/auth-context'
|
||||
import { expenseCategories as api, getApiError } from '@/lib/api/index'
|
||||
import type { ExpenseCategoryResponse } from '@/types/expenses'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Add01Icon, Folder01Icon } from '@hugeicons/core-free-icons'
|
||||
|
||||
export default function ExpenseCategoriesPage() {
|
||||
const { hasPermission } = useAuth()
|
||||
const canManage = hasPermission('expense.manage_categories')
|
||||
|
||||
const [tree, setTree] = useState<ExpenseCategoryResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [dialogOpen, setDialogOpen] = useState<null | { parentId: string | null; parentName?: string }>(null)
|
||||
const [editing, setEditing] = useState<ExpenseCategoryResponse | null>(null)
|
||||
|
||||
useTopLoader(loading)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try { setTree(await api.tree()) }
|
||||
catch (err) { toast.add({ title: 'Failed to load categories', description: getApiError(err), type: 'error' }) }
|
||||
finally { setLoading(false) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Expense Categories</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Two-level tree. Expenses are tagged with a Title (a child under a Category).
|
||||
</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<Button onClick={() => setDialogOpen({ parentId: null })}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} /> Add Category
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-2xl border">
|
||||
{!loading && tree.length === 0 ? (
|
||||
<EmptyState icon={Folder01Icon} title="No categories yet" description="Add a top-level category then create titles under it." />
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
{tree.map(parent => (
|
||||
<li key={parent.id} className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-medium">{parent.name}</span>
|
||||
{parent.code && <span className="ml-2 text-xs text-muted-foreground">{parent.code}</span>}
|
||||
{!parent.isActive && <Badge variant="secondary" className="ml-2">Inactive</Badge>}
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(parent)}>Edit</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => setDialogOpen({ parentId: parent.id, parentName: parent.name })}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={12} /> Add title
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{parent.children.length > 0 && (
|
||||
<ul className="mt-2 ml-4 divide-y border-l pl-4">
|
||||
{parent.children.map(child => (
|
||||
<li key={child.id} className="py-2 flex items-center justify-between text-sm">
|
||||
<div>
|
||||
{child.name}
|
||||
{!child.isActive && <Badge variant="secondary" className="ml-2">Inactive</Badge>}
|
||||
</div>
|
||||
{canManage && (
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(child)}>Edit</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<CategoryDialog
|
||||
open={!!dialogOpen}
|
||||
parentId={dialogOpen?.parentId ?? null}
|
||||
parentName={dialogOpen?.parentName}
|
||||
onClose={() => setDialogOpen(null)}
|
||||
onSaved={() => { setDialogOpen(null); load() }}
|
||||
/>
|
||||
|
||||
<EditCategoryDialog
|
||||
category={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => { setEditing(null); load() }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoryDialog({
|
||||
open, parentId, parentName, onClose, onSaved,
|
||||
}: {
|
||||
open: boolean
|
||||
parentId: string | null
|
||||
parentName?: string
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => { if (open) { setName(''); setCode('') } }, [open])
|
||||
|
||||
async function submit() {
|
||||
if (!name.trim()) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.create({ name: name.trim(), code: code || null, parentCategoryId: parentId })
|
||||
toast.add({ title: 'Saved', type: 'success' })
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to save', description: getApiError(err), type: 'error' })
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={v => !v && onClose()}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{parentId ? `New title under ${parentName}` : 'New category'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<Field>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<Input autoFocus value={name} onChange={e => setName(e.target.value)} />
|
||||
</Field>
|
||||
{!parentId && (
|
||||
<Field>
|
||||
<FieldLabel>Code (optional)</FieldLabel>
|
||||
<Input value={code} onChange={e => setCode(e.target.value)} placeholder="e.g. SAL" />
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={submit} disabled={busy || !name.trim()}>Save</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function EditCategoryDialog({
|
||||
category, onClose, onSaved,
|
||||
}: {
|
||||
category: ExpenseCategoryResponse | null
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [active, setActive] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (category) { setName(category.name); setCode(category.code ?? ''); setActive(category.isActive) }
|
||||
}, [category])
|
||||
|
||||
if (!category) return null
|
||||
|
||||
async function submit() {
|
||||
if (!category) return
|
||||
if (!name.trim()) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.update(category.id, { name: name.trim(), code: code || null, isActive: active })
|
||||
toast.add({ title: 'Updated', type: 'success' })
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to update', description: getApiError(err), type: 'error' })
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!category} onOpenChange={v => !v && onClose()}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit {category.name}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<Field>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<Input value={name} onChange={e => setName(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Code</FieldLabel>
|
||||
<Input value={code} onChange={e => setCode(e.target.value)} />
|
||||
</Field>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={active} onChange={e => setActive(e.target.checked)} />
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={submit} disabled={busy || !name.trim()}>Save</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { EmptyState } from '@/components/shared/empty-state'
|
||||
import { useTopLoader } from '@/components/shared/top-loader'
|
||||
import { useAuth } from '@/contexts/auth-context'
|
||||
import {
|
||||
expenseCategories as categoriesApi,
|
||||
recurringExpenses as api,
|
||||
warehouses as warehousesApi,
|
||||
getApiError,
|
||||
getPaginatedItems,
|
||||
} from '@/lib/api/index'
|
||||
import type {
|
||||
CreateRecurringExpenseRequest,
|
||||
ExpenseCategoryResponse,
|
||||
RecurringExpenseResponse,
|
||||
RecurringFrequency,
|
||||
} from '@/types/expenses'
|
||||
import type { WarehouseResponse } from '@/types/api'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Add01Icon, Calendar03Icon } from '@hugeicons/core-free-icons'
|
||||
|
||||
const fmtDate = (v: string) =>
|
||||
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
const fmtAmount = (n: number) =>
|
||||
`Rs. ${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
|
||||
export default function RecurringExpensesPage() {
|
||||
const { hasPermission } = useAuth()
|
||||
const canManage = hasPermission('expense.manage_recurring')
|
||||
|
||||
const [items, setItems] = useState<RecurringExpenseResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editing, setEditing] = useState<RecurringExpenseResponse | 'new' | null>(null)
|
||||
const [runBusy, setRunBusy] = useState(false)
|
||||
|
||||
useTopLoader(loading || runBusy)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try { setItems((await api.list({ pageSize: 100 })).items) }
|
||||
catch (err) { toast.add({ title: 'Failed to load templates', description: getApiError(err), type: 'error' }) }
|
||||
finally { setLoading(false) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
async function toggle(t: RecurringExpenseResponse) {
|
||||
try {
|
||||
await api.toggle(t.id, !t.isPaused)
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to toggle', description: getApiError(err), type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function runDue() {
|
||||
setRunBusy(true)
|
||||
try {
|
||||
const res = await api.runDue()
|
||||
toast.add({ title: `Generated ${res.generated} expense(s)`, type: 'success' })
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to run', description: getApiError(err), type: 'error' })
|
||||
} finally { setRunBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Recurring Expenses</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Templates that materialize into drafts (or posted expenses) when their next-run date arrives.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{canManage && (
|
||||
<>
|
||||
<Button variant="outline" disabled={runBusy} onClick={runDue}>
|
||||
{runBusy ? 'Running…' : 'Run due now'}
|
||||
</Button>
|
||||
<Button onClick={() => setEditing('new')}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} /> New Template
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 rounded-md border">
|
||||
{!loading && items.length === 0 ? (
|
||||
<EmptyState icon={Calendar03Icon} title="No templates yet" description="Create a template for a recurring bill — rent, salaries, subscriptions." />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Branch</TableHead>
|
||||
<TableHead className="text-right">Amount</TableHead>
|
||||
<TableHead>Frequency</TableHead>
|
||||
<TableHead>Next run</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map(t => (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell className="font-medium">{t.name}</TableCell>
|
||||
<TableCell className="text-sm">{t.categoryName}</TableCell>
|
||||
<TableCell className="text-sm">{t.warehouseName ?? <em className="text-muted-foreground">Company-wide</em>}</TableCell>
|
||||
<TableCell className="text-right font-mono">{fmtAmount(t.amount)}</TableCell>
|
||||
<TableCell><Badge variant="outline">{t.frequency}{t.autoPost ? ' · auto-post' : ''}</Badge></TableCell>
|
||||
<TableCell>{fmtDate(t.nextRunDate)}</TableCell>
|
||||
<TableCell>{t.isPaused ? <Badge variant="secondary">Paused</Badge> : <Badge>Active</Badge>}</TableCell>
|
||||
<TableCell className="text-right space-x-1">
|
||||
{canManage && (
|
||||
<>
|
||||
<Button size="sm" variant="ghost" onClick={() => toggle(t)}>{t.isPaused ? 'Resume' : 'Pause'}</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setEditing(t)}>Edit</Button>
|
||||
</>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TemplateDialog
|
||||
template={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => { setEditing(null); load() }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TemplateDialog({
|
||||
template, onClose, onSaved,
|
||||
}: {
|
||||
template: RecurringExpenseResponse | 'new' | null
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const isNew = template === 'new'
|
||||
const current = template !== 'new' ? template : null
|
||||
|
||||
const [cats, setCats] = useState<ExpenseCategoryResponse[]>([])
|
||||
const [whs, setWhs] = useState<WarehouseResponse[]>([])
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [categoryId, setCategoryId] = useState('')
|
||||
const [titleId, setTitleId] = useState('')
|
||||
const [warehouseId, setWarehouseId] = useState<string>('__company__')
|
||||
const [amount, setAmount] = useState('')
|
||||
const [frequency, setFrequency] = useState<RecurringFrequency>('Monthly')
|
||||
const [dayOfMonth, setDayOfMonth] = useState<string>('1')
|
||||
const [dayOfWeek, setDayOfWeek] = useState<string>('1')
|
||||
const [startDate, setStartDate] = useState(() => new Date().toISOString().slice(0, 10))
|
||||
const [endDate, setEndDate] = useState('')
|
||||
const [autoPost, setAutoPost] = useState(false)
|
||||
const [description, setDescription] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!template) return
|
||||
categoriesApi.tree().then(setCats).catch(() => {})
|
||||
warehousesApi.list({ pageSize: 200 }).then(r => setWhs(getPaginatedItems(r))).catch(() => {})
|
||||
|
||||
if (current) {
|
||||
setName(current.name)
|
||||
// Figure out parent category for the title
|
||||
categoriesApi.tree().then(tree => {
|
||||
const parent = tree.find(c => c.children.some(ch => ch.id === current.categoryId))
|
||||
setCategoryId(parent?.id ?? '')
|
||||
setTitleId(current.categoryId)
|
||||
})
|
||||
setWarehouseId(current.warehouseId ?? '__company__')
|
||||
setAmount(String(current.amount))
|
||||
setFrequency(current.frequency)
|
||||
setDayOfMonth(current.dayOfMonth ? String(current.dayOfMonth) : '1')
|
||||
setDayOfWeek(current.dayOfWeek != null ? String(current.dayOfWeek) : '1')
|
||||
setStartDate(current.startDate.slice(0, 10))
|
||||
setEndDate(current.endDate?.slice(0, 10) ?? '')
|
||||
setAutoPost(current.autoPost)
|
||||
setDescription(current.description ?? '')
|
||||
} else {
|
||||
setName(''); setCategoryId(''); setTitleId(''); setWarehouseId('__company__'); setAmount('')
|
||||
setFrequency('Monthly'); setDayOfMonth('1'); setDayOfWeek('1')
|
||||
setStartDate(new Date().toISOString().slice(0, 10)); setEndDate(''); setAutoPost(false); setDescription('')
|
||||
}
|
||||
}, [template, current])
|
||||
|
||||
const titles = useMemo(() => cats.find(c => c.id === categoryId)?.children ?? [], [cats, categoryId])
|
||||
|
||||
if (!template) return null
|
||||
|
||||
async function submit() {
|
||||
if (!name.trim() || !titleId) { toast.add({ title: 'Name and Title are required', type: 'error' }); return }
|
||||
const amt = parseFloat(amount)
|
||||
if (!amt || amt <= 0) { toast.add({ title: 'Amount must be greater than zero', type: 'error' }); return }
|
||||
|
||||
const body: CreateRecurringExpenseRequest = {
|
||||
name: name.trim(),
|
||||
categoryId: titleId,
|
||||
warehouseId: warehouseId === '__company__' ? null : warehouseId,
|
||||
amount: amt,
|
||||
payee: null,
|
||||
description: description || null,
|
||||
frequency,
|
||||
dayOfMonth: frequency === 'Weekly' ? null : parseInt(dayOfMonth),
|
||||
dayOfWeek: frequency === 'Weekly' ? parseInt(dayOfWeek) : null,
|
||||
startDate,
|
||||
endDate: endDate || null,
|
||||
autoPost,
|
||||
}
|
||||
|
||||
setBusy(true)
|
||||
try {
|
||||
if (isNew) await api.create(body)
|
||||
else if (current) await api.update(current.id, body)
|
||||
toast.add({ title: 'Saved', type: 'success' })
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed', description: getApiError(err), type: 'error' })
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!template} onOpenChange={v => !v && onClose()}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isNew ? 'New Recurring Template' : `Edit ${current?.name}`}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<Field>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<Input value={name} onChange={e => setName(e.target.value)} placeholder="e.g. Office rent — Colombo HQ" />
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field>
|
||||
<FieldLabel>Category</FieldLabel>
|
||||
<Select value={categoryId} onValueChange={v => { setCategoryId(v ?? ''); setTitleId('') }}>
|
||||
<SelectTrigger><SelectValue placeholder="Pick a category" /></SelectTrigger>
|
||||
<SelectContent>{cats.map(c => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Title</FieldLabel>
|
||||
<Select value={titleId} onValueChange={v => setTitleId(v ?? '')} disabled={!categoryId}>
|
||||
<SelectTrigger><SelectValue placeholder="Pick a title" /></SelectTrigger>
|
||||
<SelectContent>{titles.map(t => <SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field>
|
||||
<FieldLabel>Branch</FieldLabel>
|
||||
<Select value={warehouseId} onValueChange={v => setWarehouseId(v ?? '__company__')}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__company__">Company-wide</SelectItem>
|
||||
{whs.map(w => <SelectItem key={w.id} value={w.id}>{w.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Amount</FieldLabel>
|
||||
<Input type="number" step="0.01" value={amount} onChange={e => setAmount(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Field>
|
||||
<FieldLabel>Frequency</FieldLabel>
|
||||
<Select value={frequency} onValueChange={v => setFrequency((v ?? 'Monthly') as RecurringFrequency)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Weekly">Weekly</SelectItem>
|
||||
<SelectItem value="Monthly">Monthly</SelectItem>
|
||||
<SelectItem value="Quarterly">Quarterly</SelectItem>
|
||||
<SelectItem value="Yearly">Yearly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
{frequency === 'Weekly' ? (
|
||||
<Field>
|
||||
<FieldLabel>Day of week</FieldLabel>
|
||||
<Select value={dayOfWeek} onValueChange={v => setDayOfWeek(v ?? '1')}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d, i) =>
|
||||
<SelectItem key={i} value={String(i)}>{d}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
) : (
|
||||
<Field>
|
||||
<FieldLabel>Day of month</FieldLabel>
|
||||
<Input type="number" min="1" max="31" value={dayOfMonth} onChange={e => setDayOfMonth(e.target.value)} />
|
||||
</Field>
|
||||
)}
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={autoPost} onChange={e => setAutoPost(e.target.checked)} />
|
||||
Auto-post (skip Draft)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field>
|
||||
<FieldLabel>Start date</FieldLabel>
|
||||
<Input type="date" value={startDate} onChange={e => setStartDate(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>End date (optional)</FieldLabel>
|
||||
<Input type="date" value={endDate} onChange={e => setEndDate(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field>
|
||||
<FieldLabel>Description</FieldLabel>
|
||||
<Input value={description} onChange={e => setDescription(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={submit} disabled={busy}>{busy ? 'Saving…' : 'Save'}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { EmptyState } from '@/components/shared/empty-state'
|
||||
import { useTopLoader } from '@/components/shared/top-loader'
|
||||
import { useAuth } from '@/contexts/auth-context'
|
||||
import { expenseVendors as api, getApiError } from '@/lib/api/index'
|
||||
import type { ExpenseVendorResponse } from '@/types/expenses'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Add01Icon, UserGroup03Icon } from '@hugeicons/core-free-icons'
|
||||
|
||||
export default function ExpenseVendorsPage() {
|
||||
const { hasPermission } = useAuth()
|
||||
const canManage = hasPermission('expense.manage_categories')
|
||||
|
||||
const [items, setItems] = useState<ExpenseVendorResponse[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const pageSize = 20
|
||||
const [search, setSearch] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [editing, setEditing] = useState<ExpenseVendorResponse | 'new' | null>(null)
|
||||
|
||||
useTopLoader(loading)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.list({ page, pageSize, search: search || undefined })
|
||||
setItems(res.items); setTotal(res.total)
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to load vendors', description: getApiError(err), type: 'error' })
|
||||
} finally { setLoading(false) }
|
||||
}, [page, search])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
const pageCount = Math.max(1, Math.ceil(total / pageSize))
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Expense Vendors</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">Lightweight vendor directory used by the Expenses module.</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<Button onClick={() => setEditing('new')}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} /> Add Vendor
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 max-w-sm">
|
||||
<Input placeholder="Search vendors…" value={search} onChange={e => { setPage(1); setSearch(e.target.value) }} />
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-md border">
|
||||
{!loading && items.length === 0 ? (
|
||||
<EmptyState icon={UserGroup03Icon} title="No vendors yet" description="Add vendors to pick them when creating expenses." />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Contact</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.map(v => (
|
||||
<TableRow key={v.id}>
|
||||
<TableCell className="font-medium">{v.name}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{v.contactInfo ?? '—'}</TableCell>
|
||||
<TableCell>{v.isActive ? <Badge>Active</Badge> : <Badge variant="secondary">Inactive</Badge>}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{canManage && <Button size="sm" variant="ghost" onClick={() => setEditing(v)}>Edit</Button>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">{total} vendor(s)</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>Prev</Button>
|
||||
<span className="px-2 py-1">{page} / {pageCount}</span>
|
||||
<Button variant="outline" size="sm" disabled={page >= pageCount} onClick={() => setPage(p => p + 1)}>Next</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<VendorDialog
|
||||
vendor={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => { setEditing(null); load() }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VendorDialog({
|
||||
vendor, onClose, onSaved,
|
||||
}: {
|
||||
vendor: ExpenseVendorResponse | 'new' | null
|
||||
onClose: () => void
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [contact, setContact] = useState('')
|
||||
const [active, setActive] = useState(true)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (vendor && vendor !== 'new') { setName(vendor.name); setContact(vendor.contactInfo ?? ''); setActive(vendor.isActive) }
|
||||
else if (vendor === 'new') { setName(''); setContact(''); setActive(true) }
|
||||
}, [vendor])
|
||||
|
||||
if (!vendor) return null
|
||||
|
||||
async function submit() {
|
||||
if (!vendor || !name.trim()) return
|
||||
setBusy(true)
|
||||
try {
|
||||
if (vendor === 'new') await api.create({ name: name.trim(), contactInfo: contact || null })
|
||||
else await api.update(vendor.id, { name: name.trim(), contactInfo: contact || null, isActive: active })
|
||||
toast.add({ title: 'Saved', type: 'success' })
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed', description: getApiError(err), type: 'error' })
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={!!vendor} onOpenChange={v => !v && onClose()}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{vendor === 'new' ? 'Add Vendor' : `Edit ${vendor.name}`}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<Field>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<Input autoFocus value={name} onChange={e => setName(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Contact</FieldLabel>
|
||||
<Input value={contact} onChange={e => setContact(e.target.value)} placeholder="Phone / email" />
|
||||
</Field>
|
||||
{vendor !== 'new' && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={active} onChange={e => setActive(e.target.checked)} />
|
||||
Active
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={submit} disabled={busy || !name.trim()}>Save</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ExpenseViewClient } from '../_components/expense-view-client'
|
||||
|
||||
export default async function ExpenseViewPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
return <ExpenseViewClient expenseId={id} />
|
||||
}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { useTopLoader } from '@/components/shared/top-loader'
|
||||
import { useAuth } from '@/contexts/auth-context'
|
||||
import { expenses as expensesApi, getApiError } from '@/lib/api/index'
|
||||
import type { ExpenseResponse, ExpenseStatus } from '@/types/expenses'
|
||||
|
||||
import { PayExpenseDialog } from './pay-expense-dialog'
|
||||
|
||||
const STATUS_VARIANT: Record<ExpenseStatus, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
Draft: 'secondary', Posted: 'outline', Paid: 'default', Void: 'destructive',
|
||||
}
|
||||
|
||||
const fmtDate = (v: string | null) =>
|
||||
v ? new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) : '—'
|
||||
|
||||
const fmtAmount = (n: number) =>
|
||||
`Rs. ${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
|
||||
export function ExpenseViewClient({ expenseId }: { expenseId: string }) {
|
||||
const router = useRouter()
|
||||
const { hasPermission } = useAuth()
|
||||
const canPost = hasPermission('expense.post')
|
||||
const canPay = hasPermission('expense.pay')
|
||||
const canVoid = hasPermission('expense.void')
|
||||
const canCreate = hasPermission('expense.create')
|
||||
|
||||
const [expense, setExpense] = useState<ExpenseResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [payOpen, setPayOpen] = useState(false)
|
||||
const [voidOpen, setVoidOpen] = useState(false)
|
||||
const [voidReason, setVoidReason] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
useTopLoader(loading || busy)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try { setExpense(await expensesApi.get(expenseId)) }
|
||||
catch (err) {
|
||||
toast.add({ title: 'Failed to load expense', description: getApiError(err), type: 'error' })
|
||||
router.push('/payments/expenses')
|
||||
} finally { setLoading(false) }
|
||||
}, [expenseId, router])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
async function handlePost() {
|
||||
setBusy(true)
|
||||
try { await expensesApi.post(expenseId); toast.add({ title: 'Expense posted', type: 'success' }); await load() }
|
||||
catch (err) { toast.add({ title: 'Failed to post', description: getApiError(err), type: 'error' }) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
async function confirmVoid() {
|
||||
const reason = voidReason.trim()
|
||||
if (!reason) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await expensesApi.void(expenseId, reason)
|
||||
toast.add({ title: 'Expense voided', type: 'success' })
|
||||
setVoidOpen(false)
|
||||
setVoidReason('')
|
||||
await load()
|
||||
}
|
||||
catch (err) { toast.add({ title: 'Failed to void', description: getApiError(err), type: 'error' }) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
async function handleFile(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await expensesApi.uploadAttachment(expenseId, file)
|
||||
toast.add({ title: 'Attachment uploaded', type: 'success' })
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Upload failed', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setBusy(false)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAttachment(id: string) {
|
||||
if (!window.confirm('Delete this attachment?')) return
|
||||
setBusy(true)
|
||||
try { await expensesApi.deleteAttachment(id); await load() }
|
||||
catch (err) { toast.add({ title: 'Delete failed', description: getApiError(err), type: 'error' }) }
|
||||
finally { setBusy(false) }
|
||||
}
|
||||
|
||||
if (loading && !expense) return null
|
||||
if (!expense) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<nav className="text-sm text-muted-foreground mb-2">
|
||||
<span className="hover:underline cursor-pointer" onClick={() => router.push('/payments/expenses')}>Expenses</span>
|
||||
<span className="mx-1">/</span>
|
||||
<span className="text-foreground">{expense.categoryName}</span>
|
||||
</nav>
|
||||
<h1 className="text-2xl font-semibold text-foreground flex items-center gap-2">
|
||||
{fmtAmount(expense.amount)}
|
||||
<Badge variant={STATUS_VARIANT[expense.status]}>{expense.status}</Badge>
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
{expense.parentCategoryName ? `${expense.parentCategoryName} → ${expense.categoryName}` : expense.categoryName}
|
||||
{' · '}
|
||||
{expense.warehouseName ?? 'Company-wide'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{expense.status === 'Draft' && canPost && (
|
||||
<Button variant="outline" disabled={busy} onClick={handlePost}>Post</Button>
|
||||
)}
|
||||
{expense.status === 'Posted' && canPay && (
|
||||
<Button disabled={busy} onClick={() => setPayOpen(true)}>Pay</Button>
|
||||
)}
|
||||
{expense.status !== 'Void' && expense.status !== 'Paid' && canVoid && (
|
||||
<Button variant="outline" disabled={busy} onClick={() => { setVoidReason(''); setVoidOpen(true) }}>Void</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-4 rounded-2xl border p-6 sm:grid-cols-4">
|
||||
<Stat label="Bill date" value={fmtDate(expense.billDate)} />
|
||||
<Stat label="Due date" value={fmtDate(expense.dueDate)} />
|
||||
<Stat label="Period" value={expense.periodCovered ? new Date(expense.periodCovered).toLocaleDateString('en-GB', { month: 'long', year: 'numeric' }) : '—'} />
|
||||
<Stat label="Paid at" value={fmtDate(expense.paidAt)} />
|
||||
<Stat label="Vendor" value={expense.vendorName ?? '—'} />
|
||||
<Stat label="Payee" value={expense.payee ?? '—'} />
|
||||
<Stat label="Bill ref" value={expense.billRef ?? '—'} />
|
||||
<Stat label="Method" value={expense.method ?? '—'} />
|
||||
</div>
|
||||
|
||||
{expense.description && (
|
||||
<div className="mt-4 rounded-2xl border p-4">
|
||||
<p className="text-xs text-muted-foreground mb-1">Description</p>
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap">{expense.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6 rounded-2xl border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-sm font-medium">Attachments</h3>
|
||||
{canCreate && (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef} type="file" className="hidden"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.webp"
|
||||
onChange={handleFile}
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={() => fileInputRef.current?.click()} disabled={busy}>
|
||||
Upload
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expense.attachments.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No attachments.</p>
|
||||
) : (
|
||||
<ul className="divide-y border rounded-md">
|
||||
{expense.attachments.map(a => (
|
||||
<li key={a.id} className="flex items-center justify-between px-3 py-2 text-sm">
|
||||
<div>
|
||||
<a
|
||||
href={expensesApi.downloadAttachmentUrl(a.id)}
|
||||
target="_blank" rel="noreferrer"
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{a.originalFileName}
|
||||
</a>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{(a.sizeBytes / 1024).toFixed(1)} KB · {a.contentType}
|
||||
</span>
|
||||
</div>
|
||||
{canCreate && (
|
||||
<Button size="sm" variant="ghost" onClick={() => handleDeleteAttachment(a.id)}>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PayExpenseDialog
|
||||
open={payOpen}
|
||||
onClose={() => setPayOpen(false)}
|
||||
amount={expense.amount}
|
||||
onConfirm={async (payment) => {
|
||||
setBusy(true)
|
||||
try {
|
||||
await expensesApi.pay(expenseId, payment)
|
||||
toast.add({ title: 'Expense paid', type: 'success' })
|
||||
setPayOpen(false)
|
||||
await load()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Payment failed', description: getApiError(err), type: 'error' })
|
||||
} finally { setBusy(false) }
|
||||
}}
|
||||
/>
|
||||
|
||||
<Dialog open={voidOpen} onOpenChange={v => !v && setVoidOpen(false)}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Void expense</DialogTitle>
|
||||
<DialogDescription>This marks the expense as void. Enter a reason — it will be stored on the record.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Field>
|
||||
<FieldLabel>Reason <span className="text-destructive">*</span></FieldLabel>
|
||||
<Input
|
||||
autoFocus
|
||||
value={voidReason}
|
||||
onChange={e => setVoidReason(e.target.value)}
|
||||
placeholder="e.g. Duplicate entry"
|
||||
/>
|
||||
</Field>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setVoidOpen(false)} disabled={busy}>Cancel</Button>
|
||||
<Button variant="destructive" onClick={confirmVoid} disabled={busy || !voidReason.trim()}>
|
||||
{busy ? 'Voiding…' : 'Void'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Add01Icon, ReceiptDollarIcon } from '@hugeicons/core-free-icons'
|
||||
|
||||
import { DataTable } from '@/components/shared/data-table'
|
||||
import type { TableConfig } from '@/components/shared/data-table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { EmptyState } from '@/components/shared/empty-state'
|
||||
import { useTopLoader } from '@/components/shared/top-loader'
|
||||
import { expenses as expensesApi, getApiError } from '@/lib/api/index'
|
||||
import { useAuth } from '@/contexts/auth-context'
|
||||
import type { ExpenseResponse, ExpenseStatus } from '@/types/expenses'
|
||||
|
||||
const STATUS_VARIANT: Record<ExpenseStatus, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
Draft: 'secondary',
|
||||
Posted: 'outline',
|
||||
Paid: 'default',
|
||||
Void: 'destructive',
|
||||
}
|
||||
|
||||
const fmtDate = (v: string) =>
|
||||
new Date(v).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
||||
const fmtAmount = (n: number) => `Rs. ${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
const truncId = (id: string) => id.substring(0, 8)
|
||||
|
||||
export function ExpensesClient() {
|
||||
const router = useRouter()
|
||||
const { hasPermission } = useAuth()
|
||||
const canCreate = hasPermission('expense.create')
|
||||
|
||||
const [data, setData] = useState<ExpenseResponse[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
|
||||
useTopLoader(loading)
|
||||
|
||||
const fetchData = useCallback(async (p: number, ps: number) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await expensesApi.list({ page: p, pageSize: ps })
|
||||
setData(res.items)
|
||||
setTotal(res.total)
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to load expenses', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData(page, pageSize) }, [fetchData, page, pageSize])
|
||||
|
||||
const tableConfig = useMemo<TableConfig<ExpenseResponse>>(() => ({
|
||||
rowKey: 'id',
|
||||
serverSide: true,
|
||||
stickyHeader: true,
|
||||
pagination: { enabled: true, pageSize, pageSizeOptions: [10, 20, 50] },
|
||||
emptyState: (
|
||||
<EmptyState
|
||||
icon={ReceiptDollarIcon}
|
||||
title="No expenses yet"
|
||||
description="Record company expenses like salaries, utilities and rent. Drafts can be approved then paid."
|
||||
action={canCreate ? (
|
||||
<Button size="sm" onClick={() => router.push('/payments/expenses/new')}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={14} />
|
||||
New Expense
|
||||
</Button>
|
||||
) : undefined}
|
||||
/>
|
||||
),
|
||||
columns: [
|
||||
{
|
||||
key: 'billDate', title: 'Bill Date', fields: ['billDate'], width: 120,
|
||||
cellRender: (r) => fmtDate(r.billDate),
|
||||
},
|
||||
{
|
||||
key: 'category', title: 'Category', fields: [{ key: 'categoryName', primary: true }], minWidth: 180,
|
||||
cellRender: (r) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{r.categoryName}</span>
|
||||
{r.parentCategoryName && (
|
||||
<span className="text-xs text-muted-foreground">{r.parentCategoryName}</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'vendor', title: 'Vendor / Payee', fields: ['vendorName'], minWidth: 160,
|
||||
cellRender: (r) => <span className="text-sm">{r.vendorName ?? r.payee ?? '—'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'amount', title: 'Amount', fields: ['amount'], width: 140,
|
||||
align: 'right',
|
||||
cellRender: (r) => <span className="text-sm font-medium">{fmtAmount(r.amount)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'status', title: 'Status', fields: ['status'], width: 120,
|
||||
cellRender: (r) => <Badge variant={STATUS_VARIANT[r.status]}>{r.status}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'branch', title: 'Branch', fields: ['warehouseName'], width: 160,
|
||||
cellRender: (r) => <span className="text-sm">{r.warehouseName ?? <em className="text-muted-foreground">Company-wide</em>}</span>,
|
||||
},
|
||||
{
|
||||
key: 'id', title: 'Ref', fields: ['id'], width: 110,
|
||||
cellRender: (r) => <span className="font-mono text-xs text-muted-foreground">{truncId(r.id)}</span>,
|
||||
},
|
||||
],
|
||||
rowActions: {
|
||||
actions: () => [
|
||||
{
|
||||
action: 'view',
|
||||
label: 'View expense',
|
||||
onClick: (r: ExpenseResponse) => router.push(`/payments/expenses/${r.id}`),
|
||||
},
|
||||
],
|
||||
},
|
||||
}), [pageSize, router, canCreate])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Expenses</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Record and track company expenses — salaries, utilities, rent, fuel and more
|
||||
</p>
|
||||
</div>
|
||||
{canCreate && (
|
||||
<Button onClick={() => router.push('/payments/expenses/new')}>
|
||||
<HugeiconsIcon icon={Add01Icon} size={16} />
|
||||
New Expense
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
config={tableConfig}
|
||||
data={data}
|
||||
loading={loading}
|
||||
totalCount={total}
|
||||
onServerSideChange={({ page: p }) => { setPage(p.page); setPageSize(p.pageSize) }}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { HugeiconsIcon } from '@hugeicons/react'
|
||||
import { Add01Icon } from '@hugeicons/core-free-icons'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { useTopLoader } from '@/components/shared/top-loader'
|
||||
import { useAuth } from '@/contexts/auth-context'
|
||||
import {
|
||||
expenseCategories as categoriesApi,
|
||||
expenseVendors as vendorsApi,
|
||||
expenses as expensesApi,
|
||||
warehouses as warehousesApi,
|
||||
getApiError,
|
||||
getPaginatedItems,
|
||||
} from '@/lib/api/index'
|
||||
import type {
|
||||
CreateExpenseCategoryRequest,
|
||||
CreateExpenseRequest,
|
||||
CreateExpenseVendorRequest,
|
||||
ExpenseAction,
|
||||
ExpenseCategoryResponse,
|
||||
ExpensePaymentRequest,
|
||||
ExpenseVendorResponse,
|
||||
} from '@/types/expenses'
|
||||
import type { WarehouseResponse } from '@/types/api'
|
||||
|
||||
import { PayExpenseDialog } from './pay-expense-dialog'
|
||||
|
||||
export function NewExpenseClient() {
|
||||
const router = useRouter()
|
||||
const { hasPermission } = useAuth()
|
||||
const canPost = hasPermission('expense.post')
|
||||
const canPay = hasPermission('expense.pay') && canPost
|
||||
const canManageCats = hasPermission('expense.manage_categories')
|
||||
|
||||
const [categories, setCategories] = useState<ExpenseCategoryResponse[]>([])
|
||||
const [vendors, setVendors] = useState<ExpenseVendorResponse[]>([])
|
||||
const [whs, setWhs] = useState<WarehouseResponse[]>([])
|
||||
|
||||
const [categoryId, setCategoryId] = useState('')
|
||||
const [titleId, setTitleId] = useState('')
|
||||
const [warehouseId, setWarehouseId] = useState<string>('__company__')
|
||||
const [amount, setAmount] = useState('')
|
||||
const [billDate, setBillDate] = useState(() => new Date().toISOString().slice(0, 10))
|
||||
const [dueDate, setDueDate] = useState('')
|
||||
const [periodCovered, setPeriodCovered] = useState('')
|
||||
const [vendorId, setVendorId] = useState<string>('')
|
||||
const [payee, setPayee] = useState('')
|
||||
const [billRef, setBillRef] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [payDialogOpen, setPayDialogOpen] = useState(false)
|
||||
const [addTitleOpen, setAddTitleOpen] = useState(false)
|
||||
const [addVendorOpen, setAddVendorOpen] = useState(false)
|
||||
|
||||
useTopLoader(submitting)
|
||||
|
||||
const loadCategories = useCallback(async () => {
|
||||
try { setCategories(getPaginatedItems(await categoriesApi.tree())) }
|
||||
catch (err) { toast.add({ title: 'Failed to load categories', description: getApiError(err), type: 'error' }) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadCategories()
|
||||
vendorsApi.list({ pageSize: 200 })
|
||||
.then(r => setVendors(getPaginatedItems(r)))
|
||||
.catch(() => {})
|
||||
warehousesApi.list({ pageSize: 200 })
|
||||
.then(r => setWhs(getPaginatedItems(r)))
|
||||
.catch(() => {})
|
||||
}, [loadCategories])
|
||||
|
||||
const titles = useMemo(
|
||||
() => categories.find(c => c.id === categoryId)?.children ?? [],
|
||||
[categoryId, categories],
|
||||
)
|
||||
|
||||
function buildBody(): CreateExpenseRequest | null {
|
||||
if (!titleId) { toast.add({ title: 'Title is required', type: 'error' }); return null }
|
||||
const amt = parseFloat(amount)
|
||||
if (!amt || amt <= 0) { toast.add({ title: 'Amount must be greater than zero', type: 'error' }); return null }
|
||||
if (!billDate) { toast.add({ title: 'Bill date is required', type: 'error' }); return null }
|
||||
|
||||
return {
|
||||
categoryId: titleId,
|
||||
warehouseId: warehouseId === '__company__' ? null : warehouseId,
|
||||
amount: amt,
|
||||
billDate,
|
||||
dueDate: dueDate || null,
|
||||
periodCovered: periodCovered ? `${periodCovered}-01` : null,
|
||||
vendorId: vendorId || null,
|
||||
payee: payee || null,
|
||||
billRef: billRef || null,
|
||||
description: description || null,
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(action: ExpenseAction, payment?: ExpensePaymentRequest) {
|
||||
const body = buildBody()
|
||||
if (!body) return
|
||||
if (action === 'Pay') body.payment = payment
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const created = await expensesApi.create(body, action)
|
||||
toast.add({ title: `Expense ${action === 'Draft' ? 'saved as draft' : action === 'Post' ? 'posted' : 'posted and paid'}`, type: 'success' })
|
||||
router.push(`/payments/expenses/${created.id}`)
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to save expense', description: getApiError(err), type: 'error' })
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<nav className="text-sm text-muted-foreground mb-2">
|
||||
<span>Accounts</span> <span className="mx-1">/</span>
|
||||
<span className="hover:underline cursor-pointer" onClick={() => router.push('/payments/expenses')}>Expenses</span>
|
||||
<span className="mx-1">/</span>
|
||||
<span className="text-foreground">New</span>
|
||||
</nav>
|
||||
|
||||
<h1 className="text-2xl font-semibold text-foreground">New Expense</h1>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Record a bill — pick its category and title, then save it as a draft, post it, or post and pay it in one step.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 rounded-2xl border p-6 space-y-5">
|
||||
<Field>
|
||||
<FieldLabel>Branch</FieldLabel>
|
||||
<Select value={warehouseId} onValueChange={v => setWarehouseId(v ?? '__company__')}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__company__">Company-wide</SelectItem>
|
||||
{whs.map(w => <SelectItem key={w.id} value={w.id}>{w.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field>
|
||||
<FieldLabel>Category <span className="text-destructive">*</span></FieldLabel>
|
||||
<Select value={categoryId} onValueChange={v => { setCategoryId(v ?? ''); setTitleId('') }}>
|
||||
<SelectTrigger><SelectValue placeholder="Pick a category" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{categories.map(c => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<div className="flex items-center justify-between">
|
||||
<FieldLabel>Title <span className="text-destructive">*</span></FieldLabel>
|
||||
{canManageCats && categoryId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddTitleOpen(true)}
|
||||
className="text-xs text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
<HugeiconsIcon icon={Add01Icon} size={12} /> Add title
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Select value={titleId} onValueChange={v => setTitleId(v ?? '')} disabled={!categoryId}>
|
||||
<SelectTrigger><SelectValue placeholder={categoryId ? 'Pick a title' : 'Pick a category first'} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{titles.map(t => <SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Field>
|
||||
<FieldLabel>Amount <span className="text-destructive">*</span></FieldLabel>
|
||||
<Input type="number" step="0.01" value={amount} onChange={e => setAmount(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Bill date <span className="text-destructive">*</span></FieldLabel>
|
||||
<Input type="date" value={billDate} onChange={e => setBillDate(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Due date</FieldLabel>
|
||||
<Input type="date" value={dueDate} onChange={e => setDueDate(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<Field>
|
||||
<div className="flex items-center justify-between">
|
||||
<FieldLabel>Vendor (optional)</FieldLabel>
|
||||
{canManageCats && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAddVendorOpen(true)}
|
||||
className="text-xs text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
<HugeiconsIcon icon={Add01Icon} size={12} /> Add vendor
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Select value={vendorId} onValueChange={v => setVendorId(v ?? '')}>
|
||||
<SelectTrigger><SelectValue placeholder="— none —" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{vendors.map(v => <SelectItem key={v.id} value={v.id}>{v.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Payee</FieldLabel>
|
||||
<Input value={payee} onChange={e => setPayee(e.target.value)} placeholder="Name on the cheque / cash paid to" />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Bill / account ref</FieldLabel>
|
||||
<Input value={billRef} onChange={e => setBillRef(e.target.value)} placeholder="Bill no, meter reading, …" />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field>
|
||||
<FieldLabel>Period covered</FieldLabel>
|
||||
<Input type="month" value={periodCovered} onChange={e => setPeriodCovered(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Description</FieldLabel>
|
||||
<Input value={description} onChange={e => setDescription(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => router.push('/payments/expenses')}>Cancel</Button>
|
||||
<Button variant="outline" disabled={submitting} onClick={() => submit('Draft')}>
|
||||
{submitting ? 'Saving…' : 'Save draft'}
|
||||
</Button>
|
||||
{canPost && (
|
||||
<Button variant="outline" disabled={submitting} onClick={() => submit('Post')}>
|
||||
Save & post
|
||||
</Button>
|
||||
)}
|
||||
{canPay && (
|
||||
<Button disabled={submitting} onClick={() => { if (buildBody()) setPayDialogOpen(true) }}>
|
||||
Save & pay
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PayExpenseDialog
|
||||
open={payDialogOpen}
|
||||
onClose={() => setPayDialogOpen(false)}
|
||||
amount={parseFloat(amount) || 0}
|
||||
onConfirm={async (payment) => { setPayDialogOpen(false); await submit('Pay', payment) }}
|
||||
/>
|
||||
|
||||
<AddTitleDialog
|
||||
open={addTitleOpen}
|
||||
onClose={() => setAddTitleOpen(false)}
|
||||
parentCategoryId={categoryId}
|
||||
onCreated={async (createdId) => { await loadCategories(); setTitleId(createdId) }}
|
||||
/>
|
||||
|
||||
<AddVendorDialog
|
||||
open={addVendorOpen}
|
||||
onClose={() => setAddVendorOpen(false)}
|
||||
onCreated={(v) => { setVendors(prev => [...prev, v]); setVendorId(v.id) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── inline sub-components kept here to avoid file explosion ──────────────────
|
||||
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
|
||||
function AddTitleDialog({
|
||||
open, onClose, parentCategoryId, onCreated,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
parentCategoryId: string
|
||||
onCreated: (createdId: string) => void
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function submit() {
|
||||
if (!name.trim()) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const body: CreateExpenseCategoryRequest = { name: name.trim(), parentCategoryId }
|
||||
const created = await categoriesApi.create(body)
|
||||
toast.add({ title: 'Title added', type: 'success' })
|
||||
onCreated(created.id)
|
||||
setName('')
|
||||
onClose()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to add title', description: getApiError(err), type: 'error' })
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={v => !v && onClose()}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Title</DialogTitle>
|
||||
<DialogDescription>Create a new title under the selected category.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Field>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<Input autoFocus value={name} onChange={e => setName(e.target.value)} />
|
||||
</Field>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={submit} disabled={busy || !name.trim()}>{busy ? 'Saving…' : 'Add'}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function AddVendorDialog({
|
||||
open, onClose, onCreated,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onCreated: (v: ExpenseVendorResponse) => void
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [contact, setContact] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function submit() {
|
||||
if (!name.trim()) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const body: CreateExpenseVendorRequest = { name: name.trim(), contactInfo: contact || null }
|
||||
const created = await vendorsApi.create(body)
|
||||
toast.add({ title: 'Vendor added', type: 'success' })
|
||||
onCreated(created)
|
||||
setName(''); setContact('')
|
||||
onClose()
|
||||
} catch (err) {
|
||||
toast.add({ title: 'Failed to add vendor', description: getApiError(err), type: 'error' })
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={v => !v && onClose()}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Vendor</DialogTitle>
|
||||
<DialogDescription>Vendors are shared across the Expenses module only.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<Field>
|
||||
<FieldLabel>Name</FieldLabel>
|
||||
<Input autoFocus value={name} onChange={e => setName(e.target.value)} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>Contact (optional)</FieldLabel>
|
||||
<Input value={contact} onChange={e => setContact(e.target.value)} placeholder="Phone / email" />
|
||||
</Field>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={submit} disabled={busy || !name.trim()}>{busy ? 'Saving…' : 'Add'}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Field, FieldLabel } from '@/components/ui/field'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import {
|
||||
bankAccounts as bankAccountsApi,
|
||||
chequeBooks as chequeBooksApi,
|
||||
getApiError,
|
||||
} from '@/lib/api/index'
|
||||
import {
|
||||
BankAccountType,
|
||||
ChequePageIssueStatus,
|
||||
type BankAccountResponse,
|
||||
type ChequePageResponse,
|
||||
} from '@/types/payments'
|
||||
import type { ExpensePaymentRequest } from '@/types/expenses'
|
||||
|
||||
type Method = 'Cash' | 'Cheque'
|
||||
|
||||
/**
|
||||
* Shared dialog for both "Save & pay" (on new expense) and "Pay" (on view).
|
||||
* Cash → pick a BankAccount (Petty Cash is a Cash account; one-click button up top).
|
||||
* Cheque → pick an Unused page from an Active cheque book.
|
||||
*/
|
||||
export function PayExpenseDialog({
|
||||
open, onClose, amount, onConfirm,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
amount: number
|
||||
onConfirm: (payment: ExpensePaymentRequest) => void | Promise<void>
|
||||
}) {
|
||||
const [method, setMethod] = useState<Method>('Cash')
|
||||
const [bankAccountId, setBankAccountId] = useState('')
|
||||
const [chequePageId, setChequePageId] = useState('')
|
||||
const [accounts, setAccounts] = useState<BankAccountResponse[]>([])
|
||||
const [pages, setPages] = useState<ChequePageResponse[]>([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setMethod('Cash'); setBankAccountId(''); setChequePageId('')
|
||||
bankAccountsApi.list()
|
||||
.then(list => setAccounts(list.filter(a => a.isActive !== false)))
|
||||
.catch(() => {})
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || method !== 'Cheque') return
|
||||
chequeBooksApi.list({ status: 'Active' as never, page: 1, pageSize: 50 })
|
||||
.then(async res => {
|
||||
const expanded = await Promise.all(res.items.map(b => chequeBooksApi.get(b.chequeBookNo, true)))
|
||||
const unused: ChequePageResponse[] = []
|
||||
expanded.forEach(book => book.pages.filter(p => p.issueStatus === ChequePageIssueStatus.Unused).forEach(p => unused.push(p)))
|
||||
setPages(unused)
|
||||
})
|
||||
.catch(err => toast.add({ title: 'Failed to load cheque pages', description: getApiError(err), type: 'error' }))
|
||||
}, [open, method])
|
||||
|
||||
const defaultPettyCash = useMemo(
|
||||
() => accounts.find(a => a.accountType === BankAccountType.Cash && a.isDefault),
|
||||
[accounts],
|
||||
)
|
||||
|
||||
async function submit() {
|
||||
if (method === 'Cash' && !bankAccountId) { toast.add({ title: 'Pick an account', type: 'error' }); return }
|
||||
if (method === 'Cheque' && !chequePageId) { toast.add({ title: 'Pick a cheque page', type: 'error' }); return }
|
||||
|
||||
setBusy(true)
|
||||
try {
|
||||
await onConfirm({
|
||||
method,
|
||||
bankAccountId: method === 'Cash' ? bankAccountId : null,
|
||||
chequePageId: method === 'Cheque' ? chequePageId : null,
|
||||
})
|
||||
} finally { setBusy(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={v => !v && onClose()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Pay Expense</DialogTitle>
|
||||
<DialogDescription>
|
||||
{amount > 0 ? `Settling Rs. ${amount.toLocaleString(undefined, { minimumFractionDigits: 2 })}.` : 'Pick the payment source.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{defaultPettyCash && method === 'Cash' && !bankAccountId && (
|
||||
<Button
|
||||
type="button" variant="outline" className="w-full justify-start"
|
||||
onClick={() => setBankAccountId(defaultPettyCash.accountId)}
|
||||
>
|
||||
Use Petty Cash — {defaultPettyCash.accountName}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Field>
|
||||
<FieldLabel>Method</FieldLabel>
|
||||
<Select value={method} onValueChange={v => setMethod((v ?? 'Cash') as Method)}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Cash">Cash</SelectItem>
|
||||
<SelectItem value="Cheque">Cheque</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
{method === 'Cash' && (
|
||||
<Field>
|
||||
<FieldLabel>Account</FieldLabel>
|
||||
<Select value={bankAccountId} onValueChange={v => setBankAccountId(v ?? '')}>
|
||||
<SelectTrigger><SelectValue placeholder="Pick a cash or bank account" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{accounts.map(a => (
|
||||
<SelectItem key={a.accountId} value={a.accountId}>
|
||||
{a.accountName}{a.bankName ? ` (${a.bankName})` : ''}
|
||||
{a.accountType === BankAccountType.Cash && a.isDefault ? ' — default' : ''}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{method === 'Cheque' && (
|
||||
<Field>
|
||||
<FieldLabel>Cheque Page</FieldLabel>
|
||||
<Select value={chequePageId} onValueChange={v => setChequePageId(v ?? '')}>
|
||||
<SelectTrigger><SelectValue placeholder="Pick an unused cheque page" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{pages.map(p => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.chequeBookNo} / {p.chequeNo}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={submit} disabled={busy}>{busy ? 'Paying…' : 'Confirm payment'}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NewExpenseClient } from '../_components/new-expense-client'
|
||||
|
||||
export default function NewExpensePage() {
|
||||
return <NewExpenseClient />
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ExpensesClient } from './_components/expenses-client'
|
||||
|
||||
export default function ExpensesPage() {
|
||||
return <ExpensesClient />
|
||||
}
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
BankIcon,
|
||||
PackageMoving01Icon,
|
||||
Settings01Icon,
|
||||
ReceiptDollarIcon,
|
||||
Folder01Icon,
|
||||
Calendar03Icon,
|
||||
UserGroup03Icon,
|
||||
} from '@hugeicons/core-free-icons'
|
||||
|
||||
export interface NavItem {
|
||||
@@ -127,7 +131,7 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Accounts',
|
||||
title: 'Accounting',
|
||||
icon: BankIcon,
|
||||
items: [
|
||||
{ title: 'Analytics', href: '/payments/analytics', icon: BankIcon, description: 'Overview of bank accounts, cheque books and received cheques' },
|
||||
@@ -136,6 +140,16 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
{ title: 'Received Cheques', href: '/payments/received-cheques', icon: DollarSquareIcon, description: 'Cheques received from customers, suppliers, or others' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Expenses',
|
||||
icon: ReceiptDollarIcon,
|
||||
items: [
|
||||
{ title: 'Expenses', href: '/payments/expenses', icon: ReceiptDollarIcon, description: 'Record and track company expenses — salaries, utilities, rent and more', permission: 'expense.view' },
|
||||
{ title: 'Recurring Expenses', href: '/payments/expense-recurring', icon: Calendar03Icon, description: 'Templates that generate expenses on a schedule', permission: 'expense.view' },
|
||||
{ title: 'Expense Categories', href: '/payments/expense-categories', icon: Folder01Icon, description: 'Two-level category tree used to classify expenses', permission: 'expense.view' },
|
||||
{ title: 'Expense Vendors', href: '/payments/expense-vendors', icon: UserGroup03Icon, description: 'Vendor directory used by the Expenses module', permission: 'expense.view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Returns & Claims',
|
||||
icon: ClipboardListIcon,
|
||||
|
||||
@@ -99,6 +99,19 @@ export const PERMISSION_CATEGORIES: PermissionCategory[] = [
|
||||
'compliance.manage',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'expenses',
|
||||
label: 'Expenses',
|
||||
codes: [
|
||||
'expense.view',
|
||||
'expense.create',
|
||||
'expense.post',
|
||||
'expense.pay',
|
||||
'expense.void',
|
||||
'expense.manage_categories',
|
||||
'expense.manage_recurring',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
label: 'User Management',
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { api } from '@/lib/api'
|
||||
import type {
|
||||
CreateExpenseCategoryRequest,
|
||||
CreateExpenseRequest,
|
||||
CreateExpenseVendorRequest,
|
||||
CreateRecurringExpenseRequest,
|
||||
ExpenseAction,
|
||||
ExpenseAttachmentResponse,
|
||||
ExpenseCategoryResponse,
|
||||
ExpensePaymentRequest,
|
||||
ExpenseResponse,
|
||||
ExpenseStatus,
|
||||
ExpenseVendorResponse,
|
||||
PagedExpenseVendorsResponse,
|
||||
PagedExpensesResponse,
|
||||
PagedRecurringExpensesResponse,
|
||||
RecurringExpenseResponse,
|
||||
RunDueResponse,
|
||||
UpdateExpenseCategoryRequest,
|
||||
UpdateExpenseDraftRequest,
|
||||
UpdateExpenseVendorRequest,
|
||||
UpdateRecurringExpenseRequest,
|
||||
} from '@/types/expenses'
|
||||
|
||||
// Categories
|
||||
|
||||
export const expenseCategories = {
|
||||
tree: () =>
|
||||
api.get<ExpenseCategoryResponse[]>('/expense-categories').then(r => r.data),
|
||||
|
||||
create: (body: CreateExpenseCategoryRequest) =>
|
||||
api.post<ExpenseCategoryResponse>('/expense-categories', body).then(r => r.data),
|
||||
|
||||
update: (id: string, body: UpdateExpenseCategoryRequest) =>
|
||||
api.put<ExpenseCategoryResponse>(`/expense-categories/${id}`, body).then(r => r.data),
|
||||
}
|
||||
|
||||
// Vendors
|
||||
|
||||
export const expenseVendors = {
|
||||
list: (params?: { search?: string; page?: number; pageSize?: number }) =>
|
||||
api.get<PagedExpenseVendorsResponse>('/expense-vendors', { params }).then(r => r.data),
|
||||
|
||||
create: (body: CreateExpenseVendorRequest) =>
|
||||
api.post<ExpenseVendorResponse>('/expense-vendors', body).then(r => r.data),
|
||||
|
||||
update: (id: string, body: UpdateExpenseVendorRequest) =>
|
||||
api.put<ExpenseVendorResponse>(`/expense-vendors/${id}`, body).then(r => r.data),
|
||||
}
|
||||
|
||||
// Expenses
|
||||
|
||||
export interface ExpenseListParams {
|
||||
status?: ExpenseStatus
|
||||
categoryId?: string
|
||||
warehouseId?: string
|
||||
from?: string
|
||||
to?: string
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export const expenses = {
|
||||
list: (params?: ExpenseListParams) =>
|
||||
api.get<PagedExpensesResponse>('/expenses', { params }).then(r => r.data),
|
||||
|
||||
get: (id: string) =>
|
||||
api.get<ExpenseResponse>(`/expenses/${id}`).then(r => r.data),
|
||||
|
||||
create: (body: CreateExpenseRequest, action: ExpenseAction = 'Draft') =>
|
||||
api.post<ExpenseResponse>('/expenses', body, { params: { action } }).then(r => r.data),
|
||||
|
||||
updateDraft: (id: string, body: UpdateExpenseDraftRequest) =>
|
||||
api.put<ExpenseResponse>(`/expenses/${id}`, body).then(r => r.data),
|
||||
|
||||
post: (id: string) =>
|
||||
api.post<ExpenseResponse>(`/expenses/${id}/post`).then(r => r.data),
|
||||
|
||||
pay: (id: string, body: ExpensePaymentRequest) =>
|
||||
api.post<ExpenseResponse>(`/expenses/${id}/pay`, body).then(r => r.data),
|
||||
|
||||
void: (id: string, reason?: string) =>
|
||||
api.post<ExpenseResponse>(`/expenses/${id}/void`, { reason }).then(r => r.data),
|
||||
|
||||
uploadAttachment: (expenseId: string, file: File) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return api.post<ExpenseAttachmentResponse>(`/expenses/${expenseId}/attachments`, form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}).then(r => r.data)
|
||||
},
|
||||
|
||||
downloadAttachmentUrl: (attachmentId: string) =>
|
||||
`${api.defaults.baseURL}/expense-attachments/${attachmentId}/download`,
|
||||
|
||||
deleteAttachment: (attachmentId: string) =>
|
||||
api.delete(`/expense-attachments/${attachmentId}`),
|
||||
}
|
||||
|
||||
// Recurring
|
||||
|
||||
export const recurringExpenses = {
|
||||
list: (params?: { page?: number; pageSize?: number }) =>
|
||||
api.get<PagedRecurringExpensesResponse>('/recurring-expenses', { params }).then(r => r.data),
|
||||
|
||||
create: (body: CreateRecurringExpenseRequest) =>
|
||||
api.post<RecurringExpenseResponse>('/recurring-expenses', body).then(r => r.data),
|
||||
|
||||
update: (id: string, body: UpdateRecurringExpenseRequest) =>
|
||||
api.put<RecurringExpenseResponse>(`/recurring-expenses/${id}`, body).then(r => r.data),
|
||||
|
||||
toggle: (id: string, paused: boolean) =>
|
||||
api.post<RecurringExpenseResponse>(`/recurring-expenses/${id}/toggle`, { paused }).then(r => r.data),
|
||||
|
||||
runDue: () =>
|
||||
api.post<RunDueResponse>('/recurring-expenses/run-due').then(r => r.data),
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export { chequeBooks, chequePages } from './cheques'
|
||||
export { receivedCheques } from './received-cheques'
|
||||
export { supplierPayments } from './supplier-payments'
|
||||
export { notifications } from './notifications'
|
||||
export { expenseCategories, expenseVendors, expenses, recurringExpenses } from './expenses'
|
||||
export { getPaginatedItems } from './paginated'
|
||||
|
||||
export type { PageParams } from './master'
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
export type ExpenseStatus = 'Draft' | 'Posted' | 'Paid' | 'Void'
|
||||
export type ExpenseAction = 'Draft' | 'Post' | 'Pay'
|
||||
export type RecurringFrequency = 'Weekly' | 'Monthly' | 'Quarterly' | 'Yearly'
|
||||
|
||||
export interface ExpenseCategoryResponse {
|
||||
id: string
|
||||
name: string
|
||||
code: string | null
|
||||
parentCategoryId: string | null
|
||||
isActive: boolean
|
||||
children: ExpenseCategoryResponse[]
|
||||
}
|
||||
|
||||
export interface CreateExpenseCategoryRequest {
|
||||
name: string
|
||||
code?: string | null
|
||||
parentCategoryId?: string | null
|
||||
}
|
||||
|
||||
export interface UpdateExpenseCategoryRequest {
|
||||
name: string
|
||||
code?: string | null
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export interface ExpenseVendorResponse {
|
||||
id: string
|
||||
name: string
|
||||
contactInfo: string | null
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export interface PagedExpenseVendorsResponse {
|
||||
items: ExpenseVendorResponse[]
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface CreateExpenseVendorRequest {
|
||||
name: string
|
||||
contactInfo?: string | null
|
||||
}
|
||||
|
||||
export interface UpdateExpenseVendorRequest {
|
||||
name: string
|
||||
contactInfo?: string | null
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export interface ExpenseAttachmentResponse {
|
||||
id: string
|
||||
originalFileName: string
|
||||
contentType: string
|
||||
sizeBytes: number
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface ExpensePaymentRequest {
|
||||
method: 'Cash' | 'Cheque'
|
||||
bankAccountId?: string | null
|
||||
chequePageId?: string | null
|
||||
}
|
||||
|
||||
export interface ExpenseResponse {
|
||||
id: string
|
||||
categoryId: string
|
||||
categoryName: string
|
||||
parentCategoryId: string | null
|
||||
parentCategoryName: string | null
|
||||
warehouseId: string | null
|
||||
warehouseName: string | null
|
||||
amount: number
|
||||
billDate: string
|
||||
dueDate: string | null
|
||||
periodCovered: string | null
|
||||
vendorId: string | null
|
||||
vendorName: string | null
|
||||
payee: string | null
|
||||
billRef: string | null
|
||||
description: string | null
|
||||
status: ExpenseStatus
|
||||
method: 'Cash' | 'Cheque' | 'Credit' | null
|
||||
bankAccountId: string | null
|
||||
chequePageId: string | null
|
||||
chequeNo: string | null
|
||||
paidAt: string | null
|
||||
recordedByUserId: string
|
||||
postedByUserId: string | null
|
||||
paidByUserId: string | null
|
||||
voidedByUserId: string | null
|
||||
voidReason: string | null
|
||||
recurringTemplateId: string | null
|
||||
attachments: ExpenseAttachmentResponse[]
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface PagedExpensesResponse {
|
||||
items: ExpenseResponse[]
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface CreateExpenseRequest {
|
||||
categoryId: string
|
||||
warehouseId?: string | null
|
||||
amount: number
|
||||
billDate: string
|
||||
dueDate?: string | null
|
||||
periodCovered?: string | null
|
||||
vendorId?: string | null
|
||||
payee?: string | null
|
||||
billRef?: string | null
|
||||
description?: string | null
|
||||
payment?: ExpensePaymentRequest
|
||||
}
|
||||
|
||||
export interface UpdateExpenseDraftRequest {
|
||||
categoryId: string
|
||||
warehouseId?: string | null
|
||||
amount: number
|
||||
billDate: string
|
||||
dueDate?: string | null
|
||||
periodCovered?: string | null
|
||||
vendorId?: string | null
|
||||
payee?: string | null
|
||||
billRef?: string | null
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export interface RecurringExpenseResponse {
|
||||
id: string
|
||||
name: string
|
||||
categoryId: string
|
||||
categoryName: string
|
||||
warehouseId: string | null
|
||||
warehouseName: string | null
|
||||
vendorId: string | null
|
||||
vendorName: string | null
|
||||
amount: number
|
||||
payee: string | null
|
||||
description: string | null
|
||||
frequency: RecurringFrequency
|
||||
dayOfMonth: number | null
|
||||
dayOfWeek: number | null
|
||||
startDate: string
|
||||
endDate: string | null
|
||||
nextRunDate: string
|
||||
autoPost: boolean
|
||||
isPaused: boolean
|
||||
isActive: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface PagedRecurringExpensesResponse {
|
||||
items: RecurringExpenseResponse[]
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface CreateRecurringExpenseRequest {
|
||||
name: string
|
||||
categoryId: string
|
||||
warehouseId?: string | null
|
||||
vendorId?: string | null
|
||||
amount: number
|
||||
payee?: string | null
|
||||
description?: string | null
|
||||
frequency: RecurringFrequency
|
||||
dayOfMonth?: number | null
|
||||
dayOfWeek?: number | null
|
||||
startDate: string
|
||||
endDate?: string | null
|
||||
autoPost: boolean
|
||||
}
|
||||
|
||||
export type UpdateRecurringExpenseRequest = CreateRecurringExpenseRequest
|
||||
|
||||
export interface RunDueResponse {
|
||||
generated: number
|
||||
expenseIds: string[]
|
||||
}
|
||||
Reference in New Issue
Block a user