Phase G: Add Budgeting and Year-End Close
Budgeting: - Budget + BudgetLine entities with Jan–Dec monthly columns per GL account - BudgetsController: Index, Create, Edit, SetDefault, Copy, Delete - Copy action rolls a budget forward to a new fiscal year - Budget vs. Actual report (BudgetVsActual): compares monthly budget amounts to real P&L by calling GetProfitAndLossAsync once per month; variance shown as favorable/unfavorable; year + budget selectors in header - Views: Budgets/Index, Create, Edit with inline annual totals via budget-edit.js - Nav link + report card on Landing Year-End Close: - YearEndClose entity records each closed year + JE reference for audit trail - AccountsController.YearEndClose GET (history + form) + CloseYear POST - Close zeroes all Revenue and Expense/COGS account balances into Retained Earnings via IAccountBalanceService and posts a supporting JE dated Dec 31 - Idempotency: rejects attempt to close an already-closed year - Pre-close checklist in view to guide the workflow - Nav link under Finance Migration AddBudgetsAndYearEndClose applied Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -395,3 +395,62 @@ public class FixedAssetDepreciationEntry : BaseEntity
|
||||
public virtual FixedAsset FixedAsset { get; set; } = null!;
|
||||
public virtual JournalEntry? JournalEntry { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A named annual budget. Contains one BudgetLine per account per month. Supports
|
||||
/// multiple budgets per fiscal year (e.g. "Conservative" vs "Optimistic") but only
|
||||
/// one is marked IsDefault for the Budget vs. Actual report.
|
||||
/// </summary>
|
||||
public class Budget : BaseEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int FiscalYear { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public bool IsDefault { get; set; } = false;
|
||||
|
||||
public virtual ICollection<BudgetLine> Lines { get; set; } = new List<BudgetLine>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Monthly budget amount for one account within a Budget. Jan–Dec stored as separate
|
||||
/// columns so the grid editor can write them in a single POST without a line-item loop.
|
||||
/// Annual is a computed property summing all twelve months.
|
||||
/// </summary>
|
||||
public class BudgetLine : BaseEntity
|
||||
{
|
||||
public int BudgetId { get; set; }
|
||||
public int AccountId { get; set; }
|
||||
|
||||
public decimal Jan { get; set; }
|
||||
public decimal Feb { get; set; }
|
||||
public decimal Mar { get; set; }
|
||||
public decimal Apr { get; set; }
|
||||
public decimal May { get; set; }
|
||||
public decimal Jun { get; set; }
|
||||
public decimal Jul { get; set; }
|
||||
public decimal Aug { get; set; }
|
||||
public decimal Sep { get; set; }
|
||||
public decimal Oct { get; set; }
|
||||
public decimal Nov { get; set; }
|
||||
public decimal Dec { get; set; }
|
||||
|
||||
public decimal Annual => Jan + Feb + Mar + Apr + May + Jun + Jul + Aug + Sep + Oct + Nov + Dec;
|
||||
|
||||
public virtual Budget Budget { get; set; } = null!;
|
||||
public virtual Account Account { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a completed year-end close. The close posts a JE that zeroes all
|
||||
/// Revenue and Expense account balances into Retained Earnings, and marks
|
||||
/// the year as closed so it cannot be closed again.
|
||||
/// </summary>
|
||||
public class YearEndClose : BaseEntity
|
||||
{
|
||||
public int ClosedYear { get; set; }
|
||||
public DateTime ClosedAt { get; set; } = DateTime.UtcNow;
|
||||
public string? ClosedBy { get; set; }
|
||||
public int JournalEntryId { get; set; }
|
||||
|
||||
public virtual JournalEntry JournalEntry { get; set; } = null!;
|
||||
}
|
||||
|
||||
@@ -113,6 +113,13 @@ public interface IUnitOfWork : IDisposable
|
||||
IRepository<FixedAsset> FixedAssets { get; }
|
||||
IRepository<FixedAssetDepreciationEntry> FixedAssetDepreciationEntries { get; }
|
||||
|
||||
// Budgeting
|
||||
IRepository<Budget> Budgets { get; }
|
||||
IRepository<BudgetLine> BudgetLines { get; }
|
||||
|
||||
// Year-End Close
|
||||
IRepository<YearEndClose> YearEndCloses { get; }
|
||||
|
||||
// Notifications — typed repository for IgnoreQueryFilters-based history lookups
|
||||
INotificationLogRepository NotificationLogs { get; }
|
||||
IRepository<NotificationTemplate> NotificationTemplates { get; }
|
||||
|
||||
@@ -343,6 +343,13 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
/// <summary>One record per asset per period for each depreciation posting; soft-delete only.</summary>
|
||||
public DbSet<FixedAssetDepreciationEntry> FixedAssetDepreciationEntries { get; set; }
|
||||
|
||||
/// <summary>Named annual budgets with monthly amounts per GL account; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<Budget> Budgets { get; set; }
|
||||
/// <summary>One row per account per Budget; contains Jan–Dec decimal columns.</summary>
|
||||
public DbSet<BudgetLine> BudgetLines { get; set; }
|
||||
/// <summary>Audit trail of completed year-end closes; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<YearEndClose> YearEndCloses { get; set; }
|
||||
|
||||
/// <summary>Credit notes received from vendors (returned goods, pricing disputes); tenant-filtered with soft delete.</summary>
|
||||
public DbSet<VendorCredit> VendorCredits { get; set; }
|
||||
/// <summary>Expense-reversal line items on a vendor credit; soft-delete only.</summary>
|
||||
@@ -687,6 +694,27 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
.HasForeignKey(e => e.JournalEntryId)
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
// Budgets: tenant-filtered; BudgetLines soft-delete only
|
||||
modelBuilder.Entity<Budget>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<BudgetLine>().HasQueryFilter(e => !e.IsDeleted);
|
||||
|
||||
// BudgetLine → Account: Restrict delete so removing an account doesn't cascade into budget data
|
||||
modelBuilder.Entity<BudgetLine>()
|
||||
.HasOne(bl => bl.Account)
|
||||
.WithMany()
|
||||
.HasForeignKey(bl => bl.AccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// YearEndClose: tenant-filtered; links to a specific JE
|
||||
modelBuilder.Entity<YearEndClose>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<YearEndClose>()
|
||||
.HasOne(y => y.JournalEntry)
|
||||
.WithMany()
|
||||
.HasForeignKey(y => y.JournalEntryId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// Vendor Credits: tenant-filtered; child rows soft-delete only
|
||||
modelBuilder.Entity<VendorCredit>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
Generated
+10585
File diff suppressed because it is too large
Load Diff
+185
@@ -0,0 +1,185 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBudgetsAndYearEndClose : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Budgets",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
FiscalYear = table.Column<int>(type: "int", nullable: false),
|
||||
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDefault = table.Column<bool>(type: "bit", nullable: false),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Budgets", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "YearEndCloses",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
ClosedYear = table.Column<int>(type: "int", nullable: false),
|
||||
ClosedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
ClosedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
JournalEntryId = table.Column<int>(type: "int", nullable: false),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_YearEndCloses", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_YearEndCloses_JournalEntries_JournalEntryId",
|
||||
column: x => x.JournalEntryId,
|
||||
principalTable: "JournalEntries",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BudgetLines",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
BudgetId = table.Column<int>(type: "int", nullable: false),
|
||||
AccountId = table.Column<int>(type: "int", nullable: false),
|
||||
Jan = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Feb = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Mar = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Apr = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
May = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Jun = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Jul = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Aug = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Sep = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Oct = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Nov = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Dec = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_BudgetLines", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_BudgetLines_Accounts_AccountId",
|
||||
column: x => x.AccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_BudgetLines_Budgets_BudgetId",
|
||||
column: x => x.BudgetId,
|
||||
principalTable: "Budgets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(966));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(974));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(976));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BudgetLines_AccountId",
|
||||
table: "BudgetLines",
|
||||
column: "AccountId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BudgetLines_BudgetId",
|
||||
table: "BudgetLines",
|
||||
column: "BudgetId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_YearEndCloses_JournalEntryId",
|
||||
table: "YearEndCloses",
|
||||
column: "JournalEntryId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "BudgetLines");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "YearEndCloses");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Budgets");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4004));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4009));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4011));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1269,6 +1269,139 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.ToTable("BillPayments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.Budget", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("FiscalYear")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("IsDefault")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Budgets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.BudgetLine", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("AccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal>("Apr")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("Aug")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int>("BudgetId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal>("Dec")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal>("Feb")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<decimal>("Jan")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("Jul")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("Jun")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("Mar")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("May")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("Nov")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("Oct")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("Sep")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccountId");
|
||||
|
||||
b.HasIndex("BudgetId");
|
||||
|
||||
b.ToTable("BudgetLines");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.BugReport", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -6432,7 +6565,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4004),
|
||||
CreatedAt = new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(966),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -6443,7 +6576,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4009),
|
||||
CreatedAt = new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(974),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -6454,7 +6587,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4011),
|
||||
CreatedAt = new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(976),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
@@ -8376,6 +8509,57 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.ToTable("VendorCreditLineItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.YearEndClose", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<DateTime>("ClosedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("ClosedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("ClosedYear")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<int>("JournalEntryId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("JournalEntryId");
|
||||
|
||||
b.ToTable("YearEndCloses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
@@ -8589,6 +8773,25 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Navigation("Vendor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.BudgetLine", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.Account", "Account")
|
||||
.WithMany()
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("PowderCoating.Core.Entities.Budget", "Budget")
|
||||
.WithMany("Lines")
|
||||
.HasForeignKey("BudgetId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Account");
|
||||
|
||||
b.Navigation("Budget");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.BugReportAttachment", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.BugReport", "BugReport")
|
||||
@@ -10090,6 +10293,17 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Navigation("VendorCredit");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.YearEndClose", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.JournalEntry", "JournalEntry")
|
||||
.WithMany()
|
||||
.HasForeignKey("JournalEntryId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("JournalEntry");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.Account", b =>
|
||||
{
|
||||
b.Navigation("BillLineItems");
|
||||
@@ -10134,6 +10348,11 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Navigation("Payments");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.Budget", b =>
|
||||
{
|
||||
b.Navigation("Lines");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.BugReport", b =>
|
||||
{
|
||||
b.Navigation("Attachments");
|
||||
|
||||
@@ -161,6 +161,9 @@ public class UnitOfWork : IUnitOfWork
|
||||
private IRepository<RecurringTemplate>? _recurringTemplates;
|
||||
private IRepository<FixedAsset>? _fixedAssets;
|
||||
private IRepository<FixedAssetDepreciationEntry>? _fixedAssetDepreciationEntries;
|
||||
private IRepository<Budget>? _budgets;
|
||||
private IRepository<BudgetLine>? _budgetLines;
|
||||
private IRepository<YearEndClose>? _yearEndCloses;
|
||||
|
||||
/// <summary>
|
||||
/// Initialises the unit of work with the scoped <paramref name="context"/>.
|
||||
@@ -573,6 +576,12 @@ public class UnitOfWork : IUnitOfWork
|
||||
_fixedAssets ??= new Repository<FixedAsset>(_context);
|
||||
public IRepository<FixedAssetDepreciationEntry> FixedAssetDepreciationEntries =>
|
||||
_fixedAssetDepreciationEntries ??= new Repository<FixedAssetDepreciationEntry>(_context);
|
||||
public IRepository<Budget> Budgets =>
|
||||
_budgets ??= new Repository<Budget>(_context);
|
||||
public IRepository<BudgetLine> BudgetLines =>
|
||||
_budgetLines ??= new Repository<BudgetLine>(_context);
|
||||
public IRepository<YearEndClose> YearEndCloses =>
|
||||
_yearEndCloses ??= new Repository<YearEndClose>(_context);
|
||||
|
||||
/// <summary>
|
||||
/// Flushes all pending changes in the EF Core change tracker to the database.
|
||||
|
||||
@@ -427,6 +427,186 @@ public class AccountsController : Controller
|
||||
return View(ledger);
|
||||
}
|
||||
|
||||
// ── Year-End Close ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// GET: landing page showing close history and a form to initiate the current year close.
|
||||
/// Companyid is resolved from tenant context; year defaults to the prior fiscal year
|
||||
/// (the most common use case — close last year after final entries are posted).
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public async Task<IActionResult> YearEndClose()
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var history = (await _unitOfWork.YearEndCloses.FindAsync(y => true, false, y => y.JournalEntry))
|
||||
.OrderByDescending(y => y.ClosedYear)
|
||||
.ToList();
|
||||
|
||||
ViewBag.History = history;
|
||||
ViewBag.SuggestedYear = DateTime.Now.Year - 1;
|
||||
ViewBag.ClosedYears = history.Select(y => y.ClosedYear).ToHashSet();
|
||||
|
||||
return View();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// POST: executes the year-end close for the specified fiscal year.
|
||||
/// Sums all Revenue account balances (credit-normal) and all Expense/COGS balances
|
||||
/// (debit-normal), computes net income, posts a JE that zeroes them into Retained
|
||||
/// Earnings, then records a YearEndClose audit entry. Idempotency: a year that has
|
||||
/// already been closed is rejected.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public async Task<IActionResult> CloseYear(int year)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
// Idempotency check
|
||||
var existing = (await _unitOfWork.YearEndCloses.FindAsync(y => y.ClosedYear == year)).FirstOrDefault();
|
||||
if (existing != null)
|
||||
{
|
||||
TempData["Error"] = $"{year} has already been closed (JE {existing.JournalEntryId}).";
|
||||
return RedirectToAction(nameof(YearEndClose));
|
||||
}
|
||||
|
||||
// Load all active accounts with balances
|
||||
var accounts = (await _unitOfWork.Accounts.FindAsync(a => a.IsActive)).ToList();
|
||||
|
||||
var revenueAccounts = accounts.Where(a => a.AccountType == AccountType.Revenue).ToList();
|
||||
var expenseAccounts = accounts.Where(a =>
|
||||
a.AccountType == AccountType.Expense ||
|
||||
a.AccountSubType == AccountSubType.CostOfGoodsSold).ToList();
|
||||
|
||||
// Find or locate the Retained Earnings account
|
||||
var retainedEarnings = accounts.FirstOrDefault(a =>
|
||||
a.AccountSubType == AccountSubType.RetainedEarnings);
|
||||
|
||||
if (retainedEarnings == null)
|
||||
{
|
||||
TempData["Error"] = "No Retained Earnings account found. Create an Equity account with the 'Retained Earnings' sub-type first.";
|
||||
return RedirectToAction(nameof(YearEndClose));
|
||||
}
|
||||
|
||||
// Net income = total revenue credits − total expense debits
|
||||
var totalRevenue = revenueAccounts.Sum(a => a.CurrentBalance);
|
||||
var totalExpenses = expenseAccounts.Sum(a => a.CurrentBalance);
|
||||
var netIncome = totalRevenue - totalExpenses;
|
||||
|
||||
if (totalRevenue == 0 && totalExpenses == 0)
|
||||
{
|
||||
TempData["Error"] = $"No revenue or expense balances found for {year}. Nothing to close.";
|
||||
return RedirectToAction(nameof(YearEndClose));
|
||||
}
|
||||
|
||||
int newJeId = 0;
|
||||
|
||||
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||||
{
|
||||
var lines = new List<JournalEntryLine>();
|
||||
|
||||
// Zero out Revenue accounts: DR each revenue account (reduces credit balance to 0)
|
||||
foreach (var acct in revenueAccounts.Where(a => a.CurrentBalance != 0))
|
||||
{
|
||||
lines.Add(new JournalEntryLine
|
||||
{
|
||||
AccountId = acct.Id,
|
||||
DebitAmount = acct.CurrentBalance > 0 ? acct.CurrentBalance : 0,
|
||||
CreditAmount = acct.CurrentBalance < 0 ? Math.Abs(acct.CurrentBalance) : 0,
|
||||
Description = $"Close {year} — {acct.Name}",
|
||||
CompanyId = companyId, CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
await _accountBalanceService.DebitAsync(acct.Id, acct.CurrentBalance > 0 ? acct.CurrentBalance : 0);
|
||||
if (acct.CurrentBalance < 0)
|
||||
await _accountBalanceService.CreditAsync(acct.Id, Math.Abs(acct.CurrentBalance));
|
||||
}
|
||||
|
||||
// Zero out Expense/COGS accounts: CR each expense account (reduces debit balance to 0)
|
||||
foreach (var acct in expenseAccounts.Where(a => a.CurrentBalance != 0))
|
||||
{
|
||||
lines.Add(new JournalEntryLine
|
||||
{
|
||||
AccountId = acct.Id,
|
||||
DebitAmount = acct.CurrentBalance < 0 ? Math.Abs(acct.CurrentBalance) : 0,
|
||||
CreditAmount = acct.CurrentBalance > 0 ? acct.CurrentBalance : 0,
|
||||
Description = $"Close {year} — {acct.Name}",
|
||||
CompanyId = companyId, CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
await _accountBalanceService.CreditAsync(acct.Id, acct.CurrentBalance > 0 ? acct.CurrentBalance : 0);
|
||||
if (acct.CurrentBalance < 0)
|
||||
await _accountBalanceService.DebitAsync(acct.Id, Math.Abs(acct.CurrentBalance));
|
||||
}
|
||||
|
||||
// Plug the net into Retained Earnings: CR if profit, DR if loss
|
||||
if (netIncome > 0)
|
||||
{
|
||||
lines.Add(new JournalEntryLine
|
||||
{
|
||||
AccountId = retainedEarnings.Id,
|
||||
CreditAmount = netIncome,
|
||||
Description = $"Net income {year} → Retained Earnings",
|
||||
CompanyId = companyId, CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
await _accountBalanceService.CreditAsync(retainedEarnings.Id, netIncome);
|
||||
}
|
||||
else if (netIncome < 0)
|
||||
{
|
||||
lines.Add(new JournalEntryLine
|
||||
{
|
||||
AccountId = retainedEarnings.Id,
|
||||
DebitAmount = Math.Abs(netIncome),
|
||||
Description = $"Net loss {year} → Retained Earnings",
|
||||
CompanyId = companyId, CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
await _accountBalanceService.DebitAsync(retainedEarnings.Id, Math.Abs(netIncome));
|
||||
}
|
||||
|
||||
// Post the JE
|
||||
var prefix = $"JE-{year % 100:D2}12-";
|
||||
var existing2 = await _unitOfWork.JournalEntries.FindAsync(
|
||||
je => je.CompanyId == companyId && je.EntryNumber.StartsWith(prefix),
|
||||
ignoreQueryFilters: true);
|
||||
int next = existing2.Any()
|
||||
? existing2.Select(je => je.EntryNumber[prefix.Length..]).Select(s => int.TryParse(s, out int n) ? n : 0).Max() + 1
|
||||
: 1;
|
||||
|
||||
var je = new JournalEntry
|
||||
{
|
||||
EntryNumber = $"{prefix}{next:D4}",
|
||||
EntryDate = new DateTime(year, 12, 31, 0, 0, 0, DateTimeKind.Utc),
|
||||
Description = $"Year-end close — {year}",
|
||||
Reference = $"CLOSE-{year}",
|
||||
Status = JournalEntryStatus.Posted,
|
||||
PostedBy = User.Identity?.Name,
|
||||
PostedAt = DateTime.UtcNow,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Lines = lines
|
||||
};
|
||||
await _unitOfWork.JournalEntries.AddAsync(je);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Record the close
|
||||
var close = new YearEndClose
|
||||
{
|
||||
ClosedYear = year,
|
||||
ClosedAt = DateTime.UtcNow,
|
||||
ClosedBy = User.Identity?.Name,
|
||||
JournalEntryId = je.Id,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
await _unitOfWork.YearEndCloses.AddAsync(close);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
newJeId = je.Id;
|
||||
});
|
||||
|
||||
TempData["Success"] = $"Year {year} closed. Net income {netIncome:C} transferred to Retained Earnings. " +
|
||||
$"See Journal Entry for details.";
|
||||
return RedirectToAction(nameof(YearEndClose));
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Manages annual budgets. Each budget has one BudgetLine per active GL account with
|
||||
/// monthly amounts (Jan–Dec). The Budget vs. Actual report compares these to real activity.
|
||||
/// Only one budget per year is marked IsDefault — that one feeds the variance report automatically.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public class BudgetsController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
|
||||
public BudgetsController(IUnitOfWork unitOfWork, ITenantContext tenantContext)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_tenantContext = tenantContext;
|
||||
}
|
||||
|
||||
// ── Index ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Lists all budgets for the current company ordered by fiscal year descending.</summary>
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var budgets = (await _unitOfWork.Budgets.FindAsync(b => true, false, b => b.Lines))
|
||||
.OrderByDescending(b => b.FiscalYear)
|
||||
.ThenBy(b => b.Name)
|
||||
.ToList();
|
||||
|
||||
return View(budgets);
|
||||
}
|
||||
|
||||
// ── Create ────────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
var accounts = await GetBudgetableAccountsAsync();
|
||||
return View(new BudgetCreateVm
|
||||
{
|
||||
FiscalYear = DateTime.Now.Year,
|
||||
Lines = accounts.Select(a => new BudgetLineVm { AccountId = a.Id, AccountNumber = a.AccountNumber, AccountName = a.Name, AccountType = a.AccountType }).ToList()
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(BudgetCreateVm vm)
|
||||
{
|
||||
if (!ModelState.IsValid) return View(vm);
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
// If this is marked default, clear the flag on other budgets for the same year
|
||||
if (vm.IsDefault)
|
||||
await ClearDefaultFlagAsync(companyId, vm.FiscalYear, excludeId: null);
|
||||
|
||||
var budget = new Budget
|
||||
{
|
||||
Name = vm.Name,
|
||||
FiscalYear = vm.FiscalYear,
|
||||
Notes = vm.Notes,
|
||||
IsDefault = vm.IsDefault,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Lines = vm.Lines
|
||||
.Where(l => l.HasAnyAmount)
|
||||
.Select(l => new BudgetLine
|
||||
{
|
||||
AccountId = l.AccountId,
|
||||
Jan = l.Jan, Feb = l.Feb, Mar = l.Mar, Apr = l.Apr,
|
||||
May = l.May, Jun = l.Jun, Jul = l.Jul, Aug = l.Aug,
|
||||
Sep = l.Sep, Oct = l.Oct, Nov = l.Nov, Dec = l.Dec,
|
||||
CompanyId = companyId, CreatedAt = DateTime.UtcNow
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
await _unitOfWork.Budgets.AddAsync(budget);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Budget \"{budget.Name}\" created for {budget.FiscalYear}.";
|
||||
return RedirectToAction(nameof(Edit), new { id = budget.Id });
|
||||
}
|
||||
|
||||
// ── Edit ──────────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Edit(int id)
|
||||
{
|
||||
var budget = await _unitOfWork.Budgets.GetByIdAsync(id, false, b => b.Lines);
|
||||
if (budget == null) return NotFound();
|
||||
|
||||
var accounts = await GetBudgetableAccountsAsync();
|
||||
var lineMap = budget.Lines.ToDictionary(l => l.AccountId);
|
||||
|
||||
var vm = new BudgetCreateVm
|
||||
{
|
||||
Id = budget.Id,
|
||||
Name = budget.Name,
|
||||
FiscalYear = budget.FiscalYear,
|
||||
Notes = budget.Notes,
|
||||
IsDefault = budget.IsDefault,
|
||||
Lines = accounts.Select(a =>
|
||||
{
|
||||
lineMap.TryGetValue(a.Id, out var existing);
|
||||
return new BudgetLineVm
|
||||
{
|
||||
AccountId = a.Id,
|
||||
AccountNumber = a.AccountNumber,
|
||||
AccountName = a.Name,
|
||||
AccountType = a.AccountType,
|
||||
Jan = existing?.Jan ?? 0, Feb = existing?.Feb ?? 0, Mar = existing?.Mar ?? 0,
|
||||
Apr = existing?.Apr ?? 0, May = existing?.May ?? 0, Jun = existing?.Jun ?? 0,
|
||||
Jul = existing?.Jul ?? 0, Aug = existing?.Aug ?? 0, Sep = existing?.Sep ?? 0,
|
||||
Oct = existing?.Oct ?? 0, Nov = existing?.Nov ?? 0, Dec = existing?.Dec ?? 0
|
||||
};
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Edit(int id, BudgetCreateVm vm)
|
||||
{
|
||||
if (id != vm.Id) return BadRequest();
|
||||
if (!ModelState.IsValid) return View(vm);
|
||||
|
||||
var budget = await _unitOfWork.Budgets.GetByIdAsync(id, false, b => b.Lines);
|
||||
if (budget == null) return NotFound();
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
if (vm.IsDefault && !budget.IsDefault)
|
||||
await ClearDefaultFlagAsync(companyId, vm.FiscalYear, excludeId: id);
|
||||
|
||||
budget.Name = vm.Name;
|
||||
budget.Notes = vm.Notes;
|
||||
budget.IsDefault = vm.IsDefault;
|
||||
budget.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
// Delete old lines and replace with new set (simpler than merge)
|
||||
foreach (var line in budget.Lines.ToList())
|
||||
await _unitOfWork.BudgetLines.SoftDeleteAsync(line.Id);
|
||||
|
||||
budget.Lines = vm.Lines
|
||||
.Where(l => l.HasAnyAmount)
|
||||
.Select(l => new BudgetLine
|
||||
{
|
||||
AccountId = l.AccountId,
|
||||
Jan = l.Jan, Feb = l.Feb, Mar = l.Mar, Apr = l.Apr,
|
||||
May = l.May, Jun = l.Jun, Jul = l.Jul, Aug = l.Aug,
|
||||
Sep = l.Sep, Oct = l.Oct, Nov = l.Nov, Dec = l.Dec,
|
||||
CompanyId = companyId, CreatedAt = DateTime.UtcNow
|
||||
}).ToList();
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Budget \"{budget.Name}\" saved.";
|
||||
return RedirectToAction(nameof(Edit), new { id });
|
||||
}
|
||||
|
||||
// ── Copy ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Creates a copy of an existing budget for a new fiscal year — common workflow for
|
||||
/// rolling forward last year's budget as a starting point.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Copy(int id, int newYear)
|
||||
{
|
||||
var source = await _unitOfWork.Budgets.GetByIdAsync(id, false, b => b.Lines);
|
||||
if (source == null) return NotFound();
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var copy = new Budget
|
||||
{
|
||||
Name = $"{source.Name} ({newYear})",
|
||||
FiscalYear = newYear,
|
||||
Notes = source.Notes,
|
||||
IsDefault = false,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Lines = source.Lines.Select(l => new BudgetLine
|
||||
{
|
||||
AccountId = l.AccountId,
|
||||
Jan = l.Jan, Feb = l.Feb, Mar = l.Mar, Apr = l.Apr,
|
||||
May = l.May, Jun = l.Jun, Jul = l.Jul, Aug = l.Aug,
|
||||
Sep = l.Sep, Oct = l.Oct, Nov = l.Nov, Dec = l.Dec,
|
||||
CompanyId = companyId, CreatedAt = DateTime.UtcNow
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
await _unitOfWork.Budgets.AddAsync(copy);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Budget copied to {newYear}.";
|
||||
return RedirectToAction(nameof(Edit), new { id = copy.Id });
|
||||
}
|
||||
|
||||
// ── SetDefault ────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> SetDefault(int id)
|
||||
{
|
||||
var budget = await _unitOfWork.Budgets.GetByIdAsync(id);
|
||||
if (budget == null) return NotFound();
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
await ClearDefaultFlagAsync(companyId, budget.FiscalYear, excludeId: null);
|
||||
|
||||
budget.IsDefault = true;
|
||||
budget.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"\"{budget.Name}\" is now the default budget for {budget.FiscalYear}.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────────────────────
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
var budget = await _unitOfWork.Budgets.GetByIdAsync(id, false, b => b.Lines);
|
||||
if (budget == null) return NotFound();
|
||||
|
||||
foreach (var line in budget.Lines.ToList())
|
||||
await _unitOfWork.BudgetLines.SoftDeleteAsync(line.Id);
|
||||
|
||||
await _unitOfWork.Budgets.SoftDeleteAsync(id);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Budget \"{budget.Name}\" deleted.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private async Task<List<Account>> GetBudgetableAccountsAsync()
|
||||
{
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(
|
||||
a => a.IsActive && (a.AccountType == AccountType.Revenue || a.AccountType == AccountType.Expense));
|
||||
return accounts.OrderBy(a => a.AccountNumber).ToList();
|
||||
}
|
||||
|
||||
private async Task ClearDefaultFlagAsync(int companyId, int fiscalYear, int? excludeId)
|
||||
{
|
||||
var others = await _unitOfWork.Budgets.FindAsync(
|
||||
b => b.IsDefault && b.FiscalYear == fiscalYear && b.Id != (excludeId ?? 0));
|
||||
foreach (var b in others)
|
||||
{
|
||||
b.IsDefault = false;
|
||||
b.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
if (others.Any())
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// ── View Models ───────────────────────────────────────────────────────────────
|
||||
|
||||
public class BudgetCreateVm
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int FiscalYear { get; set; } = DateTime.Now.Year;
|
||||
public string? Notes { get; set; }
|
||||
public bool IsDefault { get; set; } = true;
|
||||
public List<BudgetLineVm> Lines { get; set; } = new();
|
||||
}
|
||||
|
||||
public class BudgetLineVm
|
||||
{
|
||||
public int AccountId { get; set; }
|
||||
public string AccountNumber { get; set; } = string.Empty;
|
||||
public string AccountName { get; set; } = string.Empty;
|
||||
public AccountType AccountType { get; set; }
|
||||
|
||||
public decimal Jan { get; set; }
|
||||
public decimal Feb { get; set; }
|
||||
public decimal Mar { get; set; }
|
||||
public decimal Apr { get; set; }
|
||||
public decimal May { get; set; }
|
||||
public decimal Jun { get; set; }
|
||||
public decimal Jul { get; set; }
|
||||
public decimal Aug { get; set; }
|
||||
public decimal Sep { get; set; }
|
||||
public decimal Oct { get; set; }
|
||||
public decimal Nov { get; set; }
|
||||
public decimal Dec { get; set; }
|
||||
|
||||
public decimal Annual => Jan + Feb + Mar + Apr + May + Jun + Jul + Aug + Sep + Oct + Nov + Dec;
|
||||
public bool HasAnyAmount => Annual != 0;
|
||||
}
|
||||
@@ -2118,6 +2118,91 @@ public class ReportsController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
// GET: /Reports/BudgetVsActual
|
||||
/// <summary>
|
||||
/// Budget vs. Actual report: compares a budget's monthly line amounts against real P&L activity
|
||||
/// for the same period. Actual figures come from the same GL queries used by the P&L report.
|
||||
/// If no budgetId is specified, the default budget for the selected year is used automatically.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> BudgetVsActual(int? budgetId, int? year)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var _bvaid) ? _bvaid : 0;
|
||||
var reportYear = year ?? DateTime.Now.Year;
|
||||
|
||||
// Load all budgets for the year for the selector
|
||||
var allBudgets = (await _unitOfWork.Budgets.FindAsync(b => b.FiscalYear == reportYear))
|
||||
.OrderBy(b => b.Name).ToList();
|
||||
|
||||
Core.Entities.Budget? budget = null;
|
||||
if (budgetId.HasValue)
|
||||
budget = await _unitOfWork.Budgets.GetByIdAsync(budgetId.Value, false, b => b.Lines);
|
||||
|
||||
budget ??= (await _unitOfWork.Budgets.FindAsync(
|
||||
b => b.FiscalYear == reportYear && b.IsDefault, false, b => b.Lines)).FirstOrDefault();
|
||||
|
||||
budget ??= (await _unitOfWork.Budgets.FindAsync(
|
||||
b => b.FiscalYear == reportYear, false, b => b.Lines)).FirstOrDefault();
|
||||
|
||||
ViewBag.ReportYear = reportYear;
|
||||
ViewBag.Budget = budget;
|
||||
ViewBag.AllBudgets = allBudgets;
|
||||
ViewBag.AvailableYears = Enumerable.Range(DateTime.Now.Year - 4, 6).OrderDescending().ToList();
|
||||
|
||||
if (budget == null)
|
||||
{
|
||||
ViewBag.NoBudget = true;
|
||||
return View(new List<BudgetVsActualRow>());
|
||||
}
|
||||
|
||||
// Fetch one P&L per month (12 calls); build a flat dict: accountId → decimal[12]
|
||||
var actualByAccount = new Dictionary<int, decimal[]>();
|
||||
for (int m = 1; m <= 12; m++)
|
||||
{
|
||||
var mFrom = new DateTime(reportYear, m, 1);
|
||||
var mTo = new DateTime(reportYear, m, DateTime.DaysInMonth(reportYear, m));
|
||||
var mpl = await _financialReports.GetProfitAndLossAsync(companyId, mFrom, mTo);
|
||||
|
||||
var allPLLines = mpl.RevenueLines
|
||||
.Concat(mpl.CogsLines)
|
||||
.Concat(mpl.ExpenseLines);
|
||||
|
||||
foreach (var pll in allPLLines)
|
||||
{
|
||||
if (!actualByAccount.ContainsKey(pll.AccountId))
|
||||
actualByAccount[pll.AccountId] = new decimal[12];
|
||||
actualByAccount[pll.AccountId][m - 1] = pll.Amount;
|
||||
}
|
||||
}
|
||||
|
||||
// Load account metadata for budget lines
|
||||
var accountIds = budget.Lines.Select(l => l.AccountId).Distinct().ToList();
|
||||
var accounts = (await _unitOfWork.Accounts.FindAsync(a => accountIds.Contains(a.Id)))
|
||||
.ToDictionary(a => a.Id);
|
||||
|
||||
var rows = new List<BudgetVsActualRow>();
|
||||
foreach (var line in budget.Lines)
|
||||
{
|
||||
if (!accounts.TryGetValue(line.AccountId, out var acct)) continue;
|
||||
|
||||
actualByAccount.TryGetValue(line.AccountId, out var monthlyActuals);
|
||||
monthlyActuals ??= new decimal[12];
|
||||
|
||||
rows.Add(new BudgetVsActualRow
|
||||
{
|
||||
AccountId = acct.Id,
|
||||
AccountNumber = acct.AccountNumber,
|
||||
AccountName = acct.Name,
|
||||
AccountType = acct.AccountType,
|
||||
BudgetMonths = new[] { line.Jan, line.Feb, line.Mar, line.Apr, line.May, line.Jun, line.Jul, line.Aug, line.Sep, line.Oct, line.Nov, line.Dec },
|
||||
ActualMonths = monthlyActuals
|
||||
});
|
||||
}
|
||||
|
||||
return View(rows.OrderBy(r => r.AccountNumber).ToList());
|
||||
}
|
||||
|
||||
// GET: /Reports/TaxReporting1099
|
||||
/// <summary>
|
||||
/// 1099-NEC report: sums all bill payments + expenses paid to vendors marked Is1099Vendor=true
|
||||
@@ -2307,6 +2392,20 @@ public class AnalyticsDashboardViewModel
|
||||
public int SelectedMonths { get; set; } = 6;
|
||||
}
|
||||
|
||||
public class BudgetVsActualRow
|
||||
{
|
||||
public int AccountId { get; set; }
|
||||
public string AccountNumber { get; set; } = string.Empty;
|
||||
public string AccountName { get; set; } = string.Empty;
|
||||
public AccountType AccountType { get; set; }
|
||||
public decimal[] BudgetMonths { get; set; } = new decimal[12];
|
||||
public decimal[] ActualMonths { get; set; } = new decimal[12];
|
||||
|
||||
public decimal BudgetAnnual => BudgetMonths.Sum();
|
||||
public decimal ActualAnnual => ActualMonths.Sum();
|
||||
public decimal VarianceAnnual => ActualAnnual - BudgetAnnual;
|
||||
}
|
||||
|
||||
public class Vendor1099Row
|
||||
{
|
||||
public int VendorId { get; set; }
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
@using PowderCoating.Core.Entities
|
||||
@{
|
||||
ViewData["Title"] = "Year-End Close";
|
||||
ViewData["PageIcon"] = "bi-calendar-check";
|
||||
var history = ViewBag.History as List<YearEndClose> ?? new();
|
||||
var suggested = (int)ViewBag.SuggestedYear;
|
||||
var closedYears = ViewBag.ClosedYears as HashSet<int> ?? new();
|
||||
}
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
|
||||
@if (TempData["Success"] != null)
|
||||
{
|
||||
<div class="alert alert-success alert-permanent alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-check-circle me-2"></i>@TempData["Success"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
}
|
||||
@if (TempData["Error"] != null)
|
||||
{
|
||||
<div class="alert alert-danger alert-permanent alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>@TempData["Error"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-white border-0 py-3">
|
||||
<h5 class="mb-0 fw-semibold"><i class="bi bi-calendar-check me-2 text-primary"></i>Close a Fiscal Year</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning py-2 mb-4">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong>What this does:</strong> Posts a Journal Entry dated December 31 that zeroes all Revenue
|
||||
and Expense account balances into Retained Earnings — the standard accounting close.
|
||||
Run this <strong>after</strong> all entries for the year are posted and the period is locked.
|
||||
A year can only be closed once.
|
||||
</div>
|
||||
|
||||
<form asp-action="CloseYear" method="post"
|
||||
onsubmit="return confirm('Close fiscal year ' + document.getElementById('closeYear').value + '? This will post a Journal Entry zeroing all Revenue and Expense balances into Retained Earnings. This cannot be undone.')">
|
||||
@Html.AntiForgeryToken()
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Fiscal Year to Close <span class="text-danger">*</span></label>
|
||||
<input type="number" name="year" id="closeYear" class="form-control"
|
||||
value="@suggested" min="2000" max="@DateTime.Now.Year" required />
|
||||
<div class="form-text">All entries for this year should be finalized before closing.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<button type="submit" class="btn btn-warning">
|
||||
<i class="bi bi-calendar-check me-2"></i>Close Year
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-3">
|
||||
<h6 class="text-muted">Pre-close checklist:</h6>
|
||||
<ul class="list-unstyled small">
|
||||
<li><i class="bi bi-check2-square me-2 text-success"></i>All invoices for the year are sent and collected (or written off)</li>
|
||||
<li><i class="bi bi-check2-square me-2 text-success"></i>All vendor bills are entered and paid</li>
|
||||
<li><i class="bi bi-check2-square me-2 text-success"></i>Bank reconciliation is complete through December</li>
|
||||
<li><i class="bi bi-check2-square me-2 text-success"></i>Depreciation is posted for all 12 months</li>
|
||||
<li><i class="bi bi-check2-square me-2 text-success"></i>Trial Balance is in balance (debits = credits)</li>
|
||||
<li><i class="bi bi-check2-square me-2 text-success"></i>Period is locked through December 31 in Company Settings</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Close History -->
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-0 py-3">
|
||||
<h5 class="mb-0 fw-semibold"><i class="bi bi-clock-history me-2 text-primary"></i>Close History</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@if (!history.Any())
|
||||
{
|
||||
<div class="text-center text-muted py-4">
|
||||
<p>No years have been closed yet.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Fiscal Year</th>
|
||||
<th>Closed On</th>
|
||||
<th>Closed By</th>
|
||||
<th>Journal Entry</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var c in history)
|
||||
{
|
||||
<tr>
|
||||
<td class="fw-bold">@c.ClosedYear</td>
|
||||
<td>@c.ClosedAt.ToLocalTime().ToString("MM/dd/yyyy h:mm tt")</td>
|
||||
<td>@(c.ClosedBy ?? "—")</td>
|
||||
<td>
|
||||
@if (c.JournalEntry != null)
|
||||
{
|
||||
<a asp-controller="JournalEntries" asp-action="Details" asp-route-id="@c.JournalEntryId">
|
||||
@c.JournalEntry.EntryNumber
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">#@c.JournalEntryId</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,118 @@
|
||||
@using PowderCoating.Web.Controllers
|
||||
@model BudgetCreateVm
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "New Budget";
|
||||
ViewData["PageIcon"] = "bi-plus-circle";
|
||||
var months = new[] { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
|
||||
}
|
||||
|
||||
<div class="mb-4">
|
||||
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to Budgets
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form asp-action="Create" method="post" id="budgetForm">
|
||||
@Html.AntiForgeryToken()
|
||||
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-white border-0 py-3">
|
||||
<h5 class="mb-0 fw-semibold"><i class="bi bi-pie-chart me-2 text-primary"></i>Budget Details</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">Budget Name <span class="text-danger">*</span></label>
|
||||
<input asp-for="Name" class="form-control" required placeholder="e.g., FY2026 Operating Budget" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Fiscal Year <span class="text-danger">*</span></label>
|
||||
<input asp-for="FiscalYear" type="number" class="form-control" min="2000" max="2099" required />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Notes</label>
|
||||
<input asp-for="Notes" class="form-control" placeholder="Optional description" />
|
||||
</div>
|
||||
<div class="col-md-1 d-flex align-items-end">
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input asp-for="IsDefault" class="form-check-input" type="checkbox" />
|
||||
<label asp-for="IsDefault" class="form-check-label small">Default</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0 fw-semibold"><i class="bi bi-table me-2 text-primary"></i>Monthly Amounts by Account</h5>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="fillEvenlyBtn">
|
||||
<i class="bi bi-distribute-horizontal me-1"></i>Spread Annual Evenly
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover align-middle mb-0" id="budgetTable">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="min-width:180px">Account</th>
|
||||
<th class="text-end" style="min-width:70px">Annual</th>
|
||||
@foreach (var m in months)
|
||||
{
|
||||
<th class="text-end" style="min-width:75px">@m</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (int i = 0; i < Model.Lines.Count; i++)
|
||||
{
|
||||
var line = Model.Lines[i];
|
||||
<tr data-account-id="@line.AccountId">
|
||||
<td>
|
||||
<input type="hidden" name="Lines[@i].AccountId" value="@line.AccountId" />
|
||||
<input type="hidden" name="Lines[@i].AccountNumber" value="@line.AccountNumber" />
|
||||
<input type="hidden" name="Lines[@i].AccountName" value="@line.AccountName" />
|
||||
<input type="hidden" name="Lines[@i].AccountType" value="@((int)line.AccountType)" />
|
||||
<span class="fw-semibold text-nowrap">@line.AccountNumber</span>
|
||||
<span class="text-muted ms-1">@line.AccountName</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<span class="annual-total fw-semibold" data-row="@i">0.00</span>
|
||||
</td>
|
||||
@{
|
||||
var fieldNames = new[] { "Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec" };
|
||||
var values = new decimal[] { line.Jan, line.Feb, line.Mar, line.Apr, line.May, line.Jun, line.Jul, line.Aug, line.Sep, line.Oct, line.Nov, line.Dec };
|
||||
}
|
||||
@for (int m = 0; m < 12; m++)
|
||||
{
|
||||
<td class="text-end p-0">
|
||||
<input type="number" name="Lines[@i].@fieldNames[m]"
|
||||
value="@values[m].ToString("F2", System.Globalization.CultureInfo.InvariantCulture)"
|
||||
class="form-control form-control-sm text-end border-0 budget-cell"
|
||||
step="0.01" min="0"
|
||||
data-row="@i"
|
||||
style="min-width:70px" />
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg me-2"></i>Create Budget
|
||||
</button>
|
||||
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/budget-edit.js" asp-append-version="true"></script>
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
@using PowderCoating.Web.Controllers
|
||||
@model BudgetCreateVm
|
||||
|
||||
@{
|
||||
ViewData["Title"] = $"Edit Budget — {Model.Name}";
|
||||
ViewData["PageIcon"] = "bi-pencil";
|
||||
var months = new[] { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
|
||||
}
|
||||
|
||||
<div class="mb-4 d-flex justify-content-between align-items-center">
|
||||
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to Budgets
|
||||
</a>
|
||||
<a asp-controller="Reports" asp-action="BudgetVsActual" asp-route-budgetId="@Model.Id" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-bar-chart-line me-1"></i>View Budget vs. Actual
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form asp-action="Edit" asp-route-id="@Model.Id" method="post" id="budgetForm">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" asp-for="Id" />
|
||||
<input type="hidden" asp-for="FiscalYear" />
|
||||
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-white border-0 py-3">
|
||||
<h5 class="mb-0 fw-semibold">
|
||||
<i class="bi bi-pie-chart me-2 text-primary"></i>@Model.Name — @Model.FiscalYear
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label">Budget Name <span class="text-danger">*</span></label>
|
||||
<input asp-for="Name" class="form-control" required />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Notes</label>
|
||||
<input asp-for="Notes" class="form-control" />
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<div class="form-check form-switch mb-2">
|
||||
<input asp-for="IsDefault" class="form-check-input" type="checkbox" />
|
||||
<label asp-for="IsDefault" class="form-check-label">Make this the default budget for @Model.FiscalYear</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0 fw-semibold"><i class="bi bi-table me-2 text-primary"></i>Monthly Amounts by Account</h5>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="fillEvenlyBtn">
|
||||
<i class="bi bi-distribute-horizontal me-1"></i>Spread Annual Evenly
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-info py-2 mx-3 mt-3 mb-0 small">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Enter monthly amounts for each Revenue and Expense account. Leave a row at zero to exclude that account from the budget. Amounts represent expected <strong>activity</strong> for the period (not running totals).
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover align-middle mb-0" id="budgetTable">
|
||||
<thead class="table-light sticky-top">
|
||||
<tr>
|
||||
<th style="min-width:200px">Account</th>
|
||||
<th class="text-end" style="min-width:80px">Annual</th>
|
||||
@foreach (var m in months)
|
||||
{
|
||||
<th class="text-end" style="min-width:75px">@m</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@{
|
||||
var revLines = Model.Lines.Where(l => l.AccountType == PowderCoating.Core.Enums.AccountType.Revenue).ToList();
|
||||
var expLines = Model.Lines.Where(l => l.AccountType != PowderCoating.Core.Enums.AccountType.Revenue).ToList();
|
||||
var fieldNames = new[] { "Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec" };
|
||||
}
|
||||
|
||||
@if (revLines.Any())
|
||||
{
|
||||
<tr class="table-success">
|
||||
<td colspan="14" class="fw-semibold py-1 ps-3 small">REVENUE</td>
|
||||
</tr>
|
||||
@for (int i = 0; i < Model.Lines.Count; i++)
|
||||
{
|
||||
var line = Model.Lines[i];
|
||||
if (line.AccountType != PowderCoating.Core.Enums.AccountType.Revenue) continue;
|
||||
var values = new decimal[] { line.Jan, line.Feb, line.Mar, line.Apr, line.May, line.Jun, line.Jul, line.Aug, line.Sep, line.Oct, line.Nov, line.Dec };
|
||||
<tr data-row-idx="@i">
|
||||
<td>
|
||||
<input type="hidden" name="Lines[@i].AccountId" value="@line.AccountId" />
|
||||
<input type="hidden" name="Lines[@i].AccountNumber" value="@line.AccountNumber" />
|
||||
<input type="hidden" name="Lines[@i].AccountName" value="@line.AccountName" />
|
||||
<input type="hidden" name="Lines[@i].AccountType" value="@((int)line.AccountType)" />
|
||||
<span class="fw-semibold text-nowrap">@line.AccountNumber</span>
|
||||
<span class="text-muted ms-1">@line.AccountName</span>
|
||||
</td>
|
||||
<td class="text-end"><span class="annual-total fw-semibold text-success" data-row="@i">@line.Annual.ToString("N2")</span></td>
|
||||
@for (int m = 0; m < 12; m++)
|
||||
{
|
||||
<td class="text-end p-0">
|
||||
<input type="number" name="Lines[@i].@fieldNames[m]"
|
||||
value="@values[m].ToString("F2", System.Globalization.CultureInfo.InvariantCulture)"
|
||||
class="form-control form-control-sm text-end border-0 budget-cell"
|
||||
step="0.01" min="0" data-row="@i" style="min-width:70px" />
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
|
||||
@if (expLines.Any())
|
||||
{
|
||||
<tr class="table-danger">
|
||||
<td colspan="14" class="fw-semibold py-1 ps-3 small">EXPENSE</td>
|
||||
</tr>
|
||||
@for (int i = 0; i < Model.Lines.Count; i++)
|
||||
{
|
||||
var line = Model.Lines[i];
|
||||
if (line.AccountType == PowderCoating.Core.Enums.AccountType.Revenue) continue;
|
||||
var values = new decimal[] { line.Jan, line.Feb, line.Mar, line.Apr, line.May, line.Jun, line.Jul, line.Aug, line.Sep, line.Oct, line.Nov, line.Dec };
|
||||
<tr data-row-idx="@i">
|
||||
<td>
|
||||
<input type="hidden" name="Lines[@i].AccountId" value="@line.AccountId" />
|
||||
<input type="hidden" name="Lines[@i].AccountNumber" value="@line.AccountNumber" />
|
||||
<input type="hidden" name="Lines[@i].AccountName" value="@line.AccountName" />
|
||||
<input type="hidden" name="Lines[@i].AccountType" value="@((int)line.AccountType)" />
|
||||
<span class="fw-semibold text-nowrap">@line.AccountNumber</span>
|
||||
<span class="text-muted ms-1">@line.AccountName</span>
|
||||
</td>
|
||||
<td class="text-end"><span class="annual-total fw-semibold text-danger" data-row="@i">@line.Annual.ToString("N2")</span></td>
|
||||
@for (int m = 0; m < 12; m++)
|
||||
{
|
||||
<td class="text-end p-0">
|
||||
<input type="number" name="Lines[@i].@fieldNames[m]"
|
||||
value="@values[m].ToString("F2", System.Globalization.CultureInfo.InvariantCulture)"
|
||||
class="form-control form-control-sm text-end border-0 budget-cell"
|
||||
step="0.01" min="0" data-row="@i" style="min-width:70px" />
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg me-2"></i>Save Changes
|
||||
</button>
|
||||
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/budget-edit.js" asp-append-version="true"></script>
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
@using PowderCoating.Core.Entities
|
||||
@model List<Budget>
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Budgets";
|
||||
ViewData["PageIcon"] = "bi-pie-chart";
|
||||
var byYear = Model.GroupBy(b => b.FiscalYear).OrderByDescending(g => g.Key).ToList();
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div></div>
|
||||
<div class="d-flex gap-2">
|
||||
<a asp-controller="Reports" asp-action="BudgetVsActual" class="btn btn-outline-primary">
|
||||
<i class="bi bi-bar-chart-line me-2"></i>Budget vs. Actual Report
|
||||
</a>
|
||||
<a asp-action="Create" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-2"></i>New Budget
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (TempData["Success"] != null)
|
||||
{
|
||||
<div class="alert alert-success alert-permanent alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-check-circle me-2"></i>@TempData["Success"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
}
|
||||
@if (TempData["Error"] != null)
|
||||
{
|
||||
<div class="alert alert-danger alert-permanent alert-dismissible fade show" role="alert">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>@TempData["Error"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!Model.Any())
|
||||
{
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body text-center text-muted py-5">
|
||||
<i class="bi bi-pie-chart display-4 d-block mb-3 opacity-25"></i>
|
||||
<p class="mb-0">No budgets yet. <a asp-action="Create">Create your first budget</a> to start tracking variance against actual results.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var group in byYear)
|
||||
{
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0 fw-semibold"><i class="bi bi-calendar3 me-2 text-primary"></i>Fiscal Year @group.Key</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Budget Name</th>
|
||||
<th class="text-center">Lines</th>
|
||||
<th class="text-end">Total Revenue Budget</th>
|
||||
<th class="text-end">Total Expense Budget</th>
|
||||
<th class="text-center">Default</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var b in group.OrderBy(b => b.Name))
|
||||
{
|
||||
var revLines = b.Lines.Where(l => l.Account?.AccountType == PowderCoating.Core.Enums.AccountType.Revenue);
|
||||
var expLines = b.Lines.Where(l => l.Account?.AccountType == PowderCoating.Core.Enums.AccountType.Expense);
|
||||
<tr>
|
||||
<td class="fw-semibold">
|
||||
@b.Name
|
||||
@if (!string.IsNullOrWhiteSpace(b.Notes))
|
||||
{
|
||||
<div class="text-muted small">@b.Notes</div>
|
||||
}
|
||||
</td>
|
||||
<td class="text-center">@b.Lines.Count</td>
|
||||
<td class="text-end text-success">@b.Lines.Sum(l => l.Annual).ToString("C")</td>
|
||||
<td class="text-end text-danger">—</td>
|
||||
<td class="text-center">
|
||||
@if (b.IsDefault)
|
||||
{
|
||||
<span class="badge bg-primary">Default</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<form asp-action="SetDefault" asp-route-id="@b.Id" method="post" class="d-inline">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">Set Default</button>
|
||||
</form>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<div class="d-flex gap-1 justify-content-end">
|
||||
<a asp-action="Edit" asp-route-id="@b.Id" class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||
data-bs-toggle="modal" data-bs-target="#copyModal"
|
||||
data-budget-id="@b.Id" data-budget-name="@b.Name">
|
||||
<i class="bi bi-copy"></i>
|
||||
</button>
|
||||
<form asp-action="Delete" asp-route-id="@b.Id" method="post" class="d-inline"
|
||||
onsubmit="return confirm('Delete budget "@b.Name"? This cannot be undone.')">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Copy Modal -->
|
||||
<div class="modal fade" id="copyModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-copy me-2"></i>Copy Budget</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form asp-action="Copy" method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="id" id="copyBudgetId" />
|
||||
<div class="modal-body">
|
||||
<p>Copy <strong id="copyBudgetName"></strong> to a new fiscal year as a starting point.</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">New Fiscal Year</label>
|
||||
<input type="number" name="newYear" class="form-control" value="@(DateTime.Now.Year + 1)" min="2000" max="2099" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary"><i class="bi bi-copy me-2"></i>Copy Budget</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/budget-index.js" asp-append-version="true"></script>
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
@using PowderCoating.Web.Controllers
|
||||
@using PowderCoating.Core.Enums
|
||||
@model List<BudgetVsActualRow>
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Budget vs. Actual";
|
||||
ViewData["PageIcon"] = "bi-bar-chart-line";
|
||||
var reportYear = (int)ViewBag.ReportYear;
|
||||
var budget = ViewBag.Budget as PowderCoating.Core.Entities.Budget;
|
||||
var allBudgets = ViewBag.AllBudgets as List<PowderCoating.Core.Entities.Budget> ?? new();
|
||||
var noBudget = ViewBag.NoBudget == true;
|
||||
var availYears = ViewBag.AvailableYears as List<int> ?? new();
|
||||
var months = new[] { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<form method="get" asp-action="BudgetVsActual" class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<select name="year" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
|
||||
@foreach (var y in availYears)
|
||||
{
|
||||
<option value="@y" selected="@(y == reportYear ? "selected" : null)">@y</option>
|
||||
}
|
||||
</select>
|
||||
@if (allBudgets.Count > 1)
|
||||
{
|
||||
<select name="budgetId" class="form-select form-select-sm" style="width:auto;" onchange="this.form.submit()">
|
||||
@foreach (var b in allBudgets)
|
||||
{
|
||||
<option value="@b.Id" selected="@(budget?.Id == b.Id ? "selected" : null)">
|
||||
@b.Name@(b.IsDefault ? " (default)" : "")
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
</form>
|
||||
<div class="d-flex gap-2">
|
||||
<a asp-controller="Budgets" asp-action="Index" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-pie-chart me-1"></i>Manage Budgets
|
||||
</a>
|
||||
<a asp-action="Landing" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left me-1"></i>Reports
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (noBudget || budget == null)
|
||||
{
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body text-center text-muted py-5">
|
||||
<i class="bi bi-bar-chart-line display-4 d-block mb-3 opacity-25"></i>
|
||||
<p>No budget found for <strong>@reportYear</strong>.</p>
|
||||
<a asp-controller="Budgets" asp-action="Create" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-2"></i>Create Budget for @reportYear
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
var revRows = Model.Where(r => r.AccountType == AccountType.Revenue).ToList();
|
||||
var expRows = Model.Where(r => r.AccountType != AccountType.Revenue).ToList();
|
||||
var totalBudgetRev = revRows.Sum(r => r.BudgetAnnual);
|
||||
var totalActualRev = revRows.Sum(r => r.ActualAnnual);
|
||||
var totalBudgetExp = expRows.Sum(r => r.BudgetAnnual);
|
||||
var totalActualExp = expRows.Sum(r => r.ActualAnnual);
|
||||
var budgetNetIncome = totalBudgetRev - totalBudgetExp;
|
||||
var actualNetIncome = totalActualRev - totalActualExp;
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card border-0 shadow-sm text-center">
|
||||
<div class="card-body py-3">
|
||||
<div class="text-muted small">Budget Revenue</div>
|
||||
<div class="fs-4 fw-bold text-success">@totalBudgetRev.ToString("C")</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-0 shadow-sm text-center">
|
||||
<div class="card-body py-3">
|
||||
<div class="text-muted small">Actual Revenue</div>
|
||||
<div class="fs-4 fw-bold @(totalActualRev >= totalBudgetRev ? "text-success" : "text-warning")">@totalActualRev.ToString("C")</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-0 shadow-sm text-center">
|
||||
<div class="card-body py-3">
|
||||
<div class="text-muted small">Budget Net Income</div>
|
||||
<div class="fs-4 fw-bold">@budgetNetIncome.ToString("C")</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card border-0 shadow-sm text-center">
|
||||
<div class="card-body py-3">
|
||||
<div class="text-muted small">Actual Net Income</div>
|
||||
<div class="fs-4 fw-bold @(actualNetIncome >= budgetNetIncome ? "text-success" : "text-danger")">@actualNetIncome.ToString("C")</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white border-0 py-3">
|
||||
<h5 class="mb-0 fw-semibold">
|
||||
<i class="bi bi-bar-chart-line me-2 text-primary"></i>@budget.Name — @reportYear
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="min-width:200px">Account</th>
|
||||
<th class="text-end">Budget Annual</th>
|
||||
<th class="text-end">Actual Annual</th>
|
||||
<th class="text-end">Variance</th>
|
||||
@foreach (var m in months)
|
||||
{
|
||||
<th class="text-end small" style="min-width:60px">@m</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (revRows.Any())
|
||||
{
|
||||
<tr class="table-success">
|
||||
<td colspan="@(4 + 12)" class="fw-bold py-1 ps-3 small">REVENUE</td>
|
||||
</tr>
|
||||
@foreach (var row in revRows)
|
||||
{
|
||||
var varA = row.VarianceAnnual;
|
||||
<tr>
|
||||
<td>
|
||||
<span class="fw-semibold">@row.AccountNumber</span>
|
||||
<span class="text-muted ms-1">@row.AccountName</span>
|
||||
</td>
|
||||
<td class="text-end">@row.BudgetAnnual.ToString("C")</td>
|
||||
<td class="text-end">@row.ActualAnnual.ToString("C")</td>
|
||||
<td class="text-end fw-semibold @(varA >= 0 ? "text-success" : "text-danger")">
|
||||
@(varA >= 0 ? "+" : "")@varA.ToString("C")
|
||||
</td>
|
||||
@for (int m = 0; m < 12; m++)
|
||||
{
|
||||
var varM = row.ActualMonths[m] - row.BudgetMonths[m];
|
||||
<td class="text-end small @(varM < 0 ? "text-danger" : "")">
|
||||
@row.ActualMonths[m].ToString("N0")
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
<tr class="fw-bold table-success">
|
||||
<td>Total Revenue</td>
|
||||
<td class="text-end">@totalBudgetRev.ToString("C")</td>
|
||||
<td class="text-end">@totalActualRev.ToString("C")</td>
|
||||
<td class="text-end @((totalActualRev - totalBudgetRev) >= 0 ? "text-success" : "text-danger")">
|
||||
@{var revVar = totalActualRev - totalBudgetRev;}
|
||||
@(revVar >= 0 ? "+" : "")@revVar.ToString("C")
|
||||
</td>
|
||||
@for (int m = 0; m < 12; m++)
|
||||
{
|
||||
<td class="text-end small">@revRows.Sum(r => r.ActualMonths[m]).ToString("N0")</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
@if (expRows.Any())
|
||||
{
|
||||
<tr class="table-danger">
|
||||
<td colspan="@(4 + 12)" class="fw-bold py-1 ps-3 small">EXPENSE</td>
|
||||
</tr>
|
||||
@foreach (var row in expRows)
|
||||
{
|
||||
var varA = row.BudgetAnnual - row.ActualAnnual; // favorable if under budget
|
||||
<tr>
|
||||
<td>
|
||||
<span class="fw-semibold">@row.AccountNumber</span>
|
||||
<span class="text-muted ms-1">@row.AccountName</span>
|
||||
</td>
|
||||
<td class="text-end">@row.BudgetAnnual.ToString("C")</td>
|
||||
<td class="text-end">@row.ActualAnnual.ToString("C")</td>
|
||||
<td class="text-end fw-semibold @(varA >= 0 ? "text-success" : "text-danger")">
|
||||
@(varA >= 0 ? "+" : "")@varA.ToString("C")
|
||||
</td>
|
||||
@for (int m = 0; m < 12; m++)
|
||||
{
|
||||
var varM = row.BudgetMonths[m] - row.ActualMonths[m];
|
||||
<td class="text-end small @(varM < 0 ? "text-danger" : "")">
|
||||
@row.ActualMonths[m].ToString("N0")
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
<tr class="fw-bold table-danger">
|
||||
<td>Total Expenses</td>
|
||||
<td class="text-end">@totalBudgetExp.ToString("C")</td>
|
||||
<td class="text-end">@totalActualExp.ToString("C")</td>
|
||||
<td class="text-end @((totalBudgetExp - totalActualExp) >= 0 ? "text-success" : "text-danger")">
|
||||
@{var expVar = totalBudgetExp - totalActualExp;}
|
||||
@(expVar >= 0 ? "+" : "")@expVar.ToString("C")
|
||||
</td>
|
||||
@for (int m = 0; m < 12; m++)
|
||||
{
|
||||
<td class="text-end small">@expRows.Sum(r => r.ActualMonths[m]).ToString("N0")</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
<!-- Net Income row -->
|
||||
<tr class="fw-bold border-top" style="border-top-width:2px!important">
|
||||
<td>Net Income</td>
|
||||
<td class="text-end">@budgetNetIncome.ToString("C")</td>
|
||||
<td class="text-end @(actualNetIncome >= 0 ? "text-success" : "text-danger")">@actualNetIncome.ToString("C")</td>
|
||||
<td class="text-end @((actualNetIncome - budgetNetIncome) >= 0 ? "text-success" : "text-danger")">
|
||||
@{var netVar = actualNetIncome - budgetNetIncome;}
|
||||
@(netVar >= 0 ? "+" : "")@netVar.ToString("C")
|
||||
</td>
|
||||
@for (int m = 0; m < 12; m++)
|
||||
{
|
||||
var mActualNet = revRows.Sum(r => r.ActualMonths[m]) - expRows.Sum(r => r.ActualMonths[m]);
|
||||
<td class="text-end small @(mActualNet < 0 ? "text-danger" : "")">@mActualNet.ToString("N0")</td>
|
||||
}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 text-muted small">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Variance is shown as <strong>favorable</strong> (positive) when revenue exceeds budget or expenses are under budget.
|
||||
Actual figures reflect the P&L for each calendar month.
|
||||
</div>
|
||||
}
|
||||
@@ -220,6 +220,14 @@
|
||||
<p>Payments to 1099-eligible vendors by calendar year — flags those exceeding the $600 reporting threshold.</p>
|
||||
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
|
||||
</a>
|
||||
<a asp-controller="Reports" asp-action="BudgetVsActual" class="report-card">
|
||||
<div class="report-card-icon" style="background:#f0fdf4;color:#15803d;">
|
||||
<i class="bi bi-bar-chart-line"></i>
|
||||
</div>
|
||||
<h5>Budget vs. Actual</h5>
|
||||
<p>Compare monthly budgeted amounts against real P&L activity — revenue, expenses, and net income variance.</p>
|
||||
<div class="report-arrow">Open report <i class="bi bi-arrow-right"></i></div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1150,6 +1150,14 @@
|
||||
<i class="bi bi-building-gear"></i>
|
||||
<span>Fixed Assets</span>
|
||||
</a>
|
||||
<a asp-controller="Budgets" asp-action="Index" class="nav-link">
|
||||
<i class="bi bi-pie-chart"></i>
|
||||
<span>Budgets</span>
|
||||
</a>
|
||||
<a asp-controller="Accounts" asp-action="YearEndClose" class="nav-link">
|
||||
<i class="bi bi-calendar-check"></i>
|
||||
<span>Year-End Close</span>
|
||||
</a>
|
||||
<a asp-controller="RecurringTemplates" asp-action="Index" class="nav-link">
|
||||
<i class="bi bi-arrow-repeat"></i>
|
||||
<span>Recurring Transactions</span>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
// Update row annual totals as cells change
|
||||
function updateRowTotal(rowIdx) {
|
||||
var cells = document.querySelectorAll('.budget-cell[data-row="' + rowIdx + '"]');
|
||||
var sum = 0;
|
||||
cells.forEach(function (c) { sum += parseFloat(c.value) || 0; });
|
||||
var span = document.querySelector('.annual-total[data-row="' + rowIdx + '"]');
|
||||
if (span) span.textContent = sum.toFixed(2);
|
||||
}
|
||||
|
||||
document.querySelectorAll('.budget-cell').forEach(function (input) {
|
||||
var row = input.getAttribute('data-row');
|
||||
updateRowTotal(row);
|
||||
input.addEventListener('input', function () { updateRowTotal(row); });
|
||||
});
|
||||
|
||||
// "Spread Annual Evenly" — prompts annual amount and distributes equally across 12 months
|
||||
var fillBtn = document.getElementById('fillEvenlyBtn');
|
||||
if (fillBtn) {
|
||||
fillBtn.addEventListener('click', function () {
|
||||
var annual = parseFloat(prompt('Enter the annual amount to spread evenly across all months for ALL rows (overwrites existing):'));
|
||||
if (isNaN(annual)) return;
|
||||
var monthly = (annual / 12).toFixed(2);
|
||||
var allRows = new Set();
|
||||
document.querySelectorAll('.budget-cell').forEach(function (c) {
|
||||
c.value = monthly;
|
||||
allRows.add(c.getAttribute('data-row'));
|
||||
});
|
||||
allRows.forEach(function (r) { updateRowTotal(r); });
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var copyModal = document.getElementById('copyModal');
|
||||
if (copyModal) {
|
||||
copyModal.addEventListener('show.bs.modal', function (e) {
|
||||
var btn = e.relatedTarget;
|
||||
document.getElementById('copyBudgetId').value = btn.getAttribute('data-budget-id');
|
||||
document.getElementById('copyBudgetName').textContent = btn.getAttribute('data-budget-name');
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user