Compare commits
12 Commits
1229081436
...
328b195127
| Author | SHA1 | Date | |
|---|---|---|---|
| 328b195127 | |||
| f6d457fe0e | |||
| c65445b94e | |||
| ccb094e57a | |||
| 0204430fa5 | |||
| 4fd9c52aaf | |||
| fde24b09c9 | |||
| a255893ada | |||
| d94612cc9c | |||
| 14026818e2 | |||
| 42eff3357e | |||
| d3a5d827f9 |
@@ -6,6 +6,92 @@ namespace PowderCoating.Application.DTOs.Accounting;
|
||||
// without needing a separate round-trip to the company settings.
|
||||
|
||||
|
||||
// ── Cash Flow Statement ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Cash Flow Statement using the direct (cash-basis) method for operating activities.
|
||||
/// Investing and Financing sections contain line items derived from account-level changes.
|
||||
/// BeginningCash + NetChangeInCash should equal EndingCash (within rounding tolerances).
|
||||
/// </summary>
|
||||
public class CashFlowStatementDto
|
||||
{
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public DateTime From { get; set; }
|
||||
public DateTime To { get; set; }
|
||||
public AccountingMethod Method { get; set; }
|
||||
|
||||
// ── Operating (direct / cash method) ───────────────────────────────────
|
||||
/// <summary>Customer invoice payments received in the period.</summary>
|
||||
public decimal CashFromCustomers { get; set; }
|
||||
/// <summary>Vendor bill payments made in the period.</summary>
|
||||
public decimal CashToVendors { get; set; }
|
||||
/// <summary>Direct expense payments made in the period (not via bills).</summary>
|
||||
public decimal CashForExpenses { get; set; }
|
||||
public decimal NetOperating => CashFromCustomers - CashToVendors - CashForExpenses;
|
||||
|
||||
// ── Investing ──────────────────────────────────────────────────────────
|
||||
public List<CashFlowLineDto> InvestingLines { get; set; } = new();
|
||||
public decimal NetInvesting => InvestingLines.Sum(l => l.Amount);
|
||||
|
||||
// ── Financing ──────────────────────────────────────────────────────────
|
||||
public List<CashFlowLineDto> FinancingLines { get; set; } = new();
|
||||
public decimal NetFinancing => FinancingLines.Sum(l => l.Amount);
|
||||
|
||||
// ── Summary ────────────────────────────────────────────────────────────
|
||||
public decimal BeginningCash { get; set; }
|
||||
public decimal NetChangeInCash => NetOperating + NetInvesting + NetFinancing;
|
||||
public decimal EndingCash => BeginningCash + NetChangeInCash;
|
||||
}
|
||||
|
||||
/// <summary>A single line in the Investing or Financing section of the Cash Flow Statement.</summary>
|
||||
public class CashFlowLineDto
|
||||
{
|
||||
public string Label { get; set; } = string.Empty;
|
||||
/// <summary>Positive = cash inflow, negative = cash outflow.</summary>
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
|
||||
// ── Customer / Vendor Statements ─────────────────────────────────────────────
|
||||
|
||||
public class CustomerStatementDto
|
||||
{
|
||||
public int CustomerId { get; set; }
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public string? CustomerAddress { get; set; }
|
||||
public DateTime From { get; set; }
|
||||
public DateTime To { get; set; }
|
||||
public decimal OpeningBalance { get; set; }
|
||||
public List<StatementLineDto> Lines { get; set; } = new();
|
||||
public decimal ClosingBalance { get; set; }
|
||||
}
|
||||
|
||||
public class VendorStatementDto
|
||||
{
|
||||
public int VendorId { get; set; }
|
||||
public string VendorName { get; set; } = string.Empty;
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public DateTime From { get; set; }
|
||||
public DateTime To { get; set; }
|
||||
public decimal OpeningBalance { get; set; }
|
||||
public List<StatementLineDto> Lines { get; set; } = new();
|
||||
public decimal ClosingBalance { get; set; }
|
||||
}
|
||||
|
||||
public class StatementLineDto
|
||||
{
|
||||
public DateTime Date { get; set; }
|
||||
/// <summary>E.g., "Invoice", "Payment", "Credit Applied", "Deposit Applied".</summary>
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public string Reference { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
/// <summary>Amount added to the balance (invoice for customer, bill for vendor).</summary>
|
||||
public decimal? Debit { get; set; }
|
||||
/// <summary>Amount reducing the balance (payment, credit).</summary>
|
||||
public decimal? Credit { get; set; }
|
||||
public decimal RunningBalance { get; set; }
|
||||
}
|
||||
|
||||
// ── AP Aging ──────────────────────────────────────────────────────────────────
|
||||
|
||||
public class ApAgingReportDto
|
||||
|
||||
@@ -82,6 +82,10 @@ public class CreateInvoiceDto
|
||||
public string? InternalNotes { get; set; }
|
||||
public string? Terms { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
/// <summary>Early-payment discount percentage parsed from the customer's payment terms (e.g., 2.0 for "2/10 Net 30"). Informational — does not auto-apply.</summary>
|
||||
public decimal EarlyPaymentDiscountPercent { get; set; }
|
||||
/// <summary>Number of days within which the early-payment discount applies (e.g., 10 for "2/10 Net 30").</summary>
|
||||
public int EarlyPaymentDiscountDays { get; set; }
|
||||
public List<CreateInvoiceItemDto> InvoiceItems { get; set; } = new();
|
||||
}
|
||||
|
||||
|
||||
@@ -120,6 +120,9 @@ public class CreateVendorDto
|
||||
[Display(Name = "Preferred Vendor")]
|
||||
public bool IsPreferred { get; set; } = false;
|
||||
|
||||
[Display(Name = "1099 Vendor")]
|
||||
public bool Is1099Vendor { get; set; } = false;
|
||||
|
||||
[Display(Name = "Default Expense Account")]
|
||||
public int? DefaultExpenseAccountId { get; set; }
|
||||
}
|
||||
@@ -201,6 +204,9 @@ public class UpdateVendorDto
|
||||
[Display(Name = "Preferred Vendor")]
|
||||
public bool IsPreferred { get; set; }
|
||||
|
||||
[Display(Name = "1099 Vendor")]
|
||||
public bool Is1099Vendor { get; set; }
|
||||
|
||||
[Display(Name = "Default Expense Account")]
|
||||
public int? DefaultExpenseAccountId { get; set; }
|
||||
}
|
||||
|
||||
@@ -35,4 +35,18 @@ public interface IFinancialReportService
|
||||
|
||||
/// <summary>Looks up the accounting method configured for the given company. Returns Accrual if not found.</summary>
|
||||
Task<AccountingMethod> GetCompanyAccountingMethodAsync(int companyId);
|
||||
|
||||
/// <summary>Returns a dated activity statement for a customer showing opening balance, all transactions in the period, and closing balance.</summary>
|
||||
Task<CustomerStatementDto> GetCustomerStatementAsync(int companyId, int customerId, DateTime from, DateTime to);
|
||||
|
||||
/// <summary>Returns a dated activity statement for a vendor showing opening balance, all transactions in the period, and closing balance.</summary>
|
||||
Task<VendorStatementDto> GetVendorStatementAsync(int companyId, int vendorId, DateTime from, DateTime to);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a Cash Flow Statement for the period using the direct (cash-basis) method for
|
||||
/// operating activities. Investing and Financing sections are derived from account-level data.
|
||||
/// BeginningCash is computed from all cash/bank account credits and debits prior to
|
||||
/// <paramref name="from"/>; EndingCash adds the net change during the period.
|
||||
/// </summary>
|
||||
Task<CashFlowStatementDto> GetCashFlowStatementAsync(int companyId, DateTime from, DateTime to);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ public interface IPdfService
|
||||
Task<byte[]> GenerateSalesTaxReportPdfAsync(SalesTaxReportDto dto);
|
||||
Task<byte[]> GenerateApAgingPdfAsync(ApAgingReportDto dto);
|
||||
Task<byte[]> GenerateTrialBalancePdfAsync(TrialBalanceDto dto);
|
||||
Task<byte[]> GenerateCashFlowStatementPdfAsync(CashFlowStatementDto dto);
|
||||
|
||||
Task<byte[]> GenerateGiftCertificatePdfAsync(
|
||||
GiftCertificateDto cert,
|
||||
|
||||
@@ -2593,4 +2593,120 @@ public class PdfService : IPdfService
|
||||
return document.GeneratePdf();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a Cash Flow Statement PDF with three sections (Operating, Investing, Financing)
|
||||
/// plus a summary reconciling beginning → ending cash. Uses a teal accent palette to
|
||||
/// visually distinguish it from the other financial statements.
|
||||
/// </summary>
|
||||
public async Task<byte[]> GenerateCashFlowStatementPdfAsync(CashFlowStatementDto dto)
|
||||
{
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
const string accent = "#0891b2";
|
||||
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
var document = Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.Letter);
|
||||
page.Margin(0.6f, Unit.Inch);
|
||||
page.PageColor(Colors.White);
|
||||
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
|
||||
|
||||
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Cash Flow Statement",
|
||||
$"{dto.From:MMMM d, yyyy} – {dto.To:MMMM d, yyyy}", accent));
|
||||
|
||||
page.Content().PaddingTop(12).Column(col =>
|
||||
{
|
||||
col.Spacing(4);
|
||||
|
||||
// ── Operating Activities ──────────────────────────────────────
|
||||
col.Item().Text("Operating Activities").Bold().FontSize(11).FontColor(accent);
|
||||
col.Item().Table(t =>
|
||||
{
|
||||
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
|
||||
CfRow(t, "Cash Received from Customers", dto.CashFromCustomers, false);
|
||||
CfRow(t, "Cash Paid to Vendors (Bills)", -dto.CashToVendors, false);
|
||||
CfRow(t, "Cash Paid for Expenses", -dto.CashForExpenses, false);
|
||||
CfTotalRow(t, "Net Cash from Operating Activities", dto.NetOperating);
|
||||
});
|
||||
|
||||
col.Item().PaddingTop(10).Text("Investing Activities").Bold().FontSize(11).FontColor(accent);
|
||||
col.Item().Table(t =>
|
||||
{
|
||||
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
|
||||
if (dto.InvestingLines.Count == 0)
|
||||
CfRow(t, "No investing activities recorded", 0, true);
|
||||
else
|
||||
foreach (var line in dto.InvestingLines)
|
||||
CfRow(t, line.Label, line.Amount, false);
|
||||
CfTotalRow(t, "Net Cash from Investing Activities", dto.NetInvesting);
|
||||
});
|
||||
|
||||
col.Item().PaddingTop(10).Text("Financing Activities").Bold().FontSize(11).FontColor(accent);
|
||||
col.Item().Table(t =>
|
||||
{
|
||||
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
|
||||
if (dto.FinancingLines.Count == 0)
|
||||
CfRow(t, "No financing activities recorded", 0, true);
|
||||
else
|
||||
foreach (var line in dto.FinancingLines)
|
||||
CfRow(t, line.Label, line.Amount, false);
|
||||
CfTotalRow(t, "Net Cash from Financing Activities", dto.NetFinancing);
|
||||
});
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────
|
||||
col.Item().PaddingTop(12).Table(t =>
|
||||
{
|
||||
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
|
||||
|
||||
void SumRow(string label, decimal amount, bool bold = false)
|
||||
{
|
||||
var bg = bold ? "#e0f2fe" : "#ffffff";
|
||||
var lText = t.Cell().Background(bg).PaddingVertical(4).PaddingHorizontal(6).Text(label).FontSize(9);
|
||||
if (bold) lText.Bold();
|
||||
var vText = t.Cell().Background(bg).PaddingVertical(4).PaddingHorizontal(6).AlignRight()
|
||||
.Text(amount.ToString("C")).FontSize(9)
|
||||
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
|
||||
if (bold) vText.Bold();
|
||||
}
|
||||
|
||||
SumRow("Beginning Cash Balance", dto.BeginningCash);
|
||||
SumRow("Net Change in Cash", dto.NetChangeInCash);
|
||||
SumRow("Ending Cash Balance", dto.EndingCash, bold: true);
|
||||
});
|
||||
});
|
||||
|
||||
page.Footer().AlignCenter().Text(text =>
|
||||
{
|
||||
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
|
||||
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
});
|
||||
});
|
||||
return document.GeneratePdf();
|
||||
});
|
||||
|
||||
static void CfRow(TableDescriptor t, string label, decimal amount, bool muted)
|
||||
{
|
||||
t.Cell().BorderBottom(0.5f).BorderColor("#e5e7eb")
|
||||
.PaddingVertical(3).PaddingHorizontal(6)
|
||||
.Text(label).FontSize(9).FontColor(muted ? Colors.Grey.Medium : Colors.Black);
|
||||
t.Cell().BorderBottom(0.5f).BorderColor("#e5e7eb")
|
||||
.PaddingVertical(3).PaddingHorizontal(6).AlignRight()
|
||||
.Text(muted ? "" : amount.ToString("C")).FontSize(9)
|
||||
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
|
||||
}
|
||||
|
||||
static void CfTotalRow(TableDescriptor t, string label, decimal amount)
|
||||
{
|
||||
t.Cell().Background("#f0f9ff").PaddingVertical(4).PaddingHorizontal(6)
|
||||
.Text(label).Bold().FontSize(9);
|
||||
t.Cell().Background("#f0f9ff").PaddingVertical(4).PaddingHorizontal(6).AlignRight()
|
||||
.Text(amount.ToString("C")).Bold().FontSize(9)
|
||||
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,3 +285,172 @@ public class VendorCreditApplication : BaseEntity
|
||||
public virtual VendorCredit VendorCredit { get; set; } = null!;
|
||||
public virtual Bill Bill { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A saved recipe for a document that should be automatically created on a recurring schedule.
|
||||
/// The <see cref="TemplateData"/> column stores a JSON blob whose schema depends on
|
||||
/// <see cref="TemplateType"/>: see <c>RecurringTransactionService</c> for the exact shape.
|
||||
/// <para>
|
||||
/// Bills are created as Draft so the user can review before posting.
|
||||
/// Expenses are created immediately (already-paid transactions).
|
||||
/// </para>
|
||||
/// Numbering: REC-YYMM-####
|
||||
/// </summary>
|
||||
public class RecurringTemplate : BaseEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public RecurringTemplateType TemplateType { get; set; }
|
||||
public RecurringFrequency Frequency { get; set; }
|
||||
/// <summary>Every N periods. E.g. Frequency=Monthly, IntervalCount=3 → quarterly.</summary>
|
||||
public int IntervalCount { get; set; } = 1;
|
||||
/// <summary>UTC date when the template will next fire. Set to the desired first occurrence date on creation.</summary>
|
||||
public DateTime NextFireDate { get; set; }
|
||||
/// <summary>Optional UTC date after which no further occurrences are generated.</summary>
|
||||
public DateTime? EndDate { get; set; }
|
||||
/// <summary>Optional hard cap on total occurrences. Null = unlimited.</summary>
|
||||
public int? MaxOccurrences { get; set; }
|
||||
/// <summary>How many documents have been generated so far.</summary>
|
||||
public int OccurrenceCount { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
/// <summary>JSON payload whose schema matches the TemplateType. See RecurringTransactionService.</summary>
|
||||
public string TemplateData { get; set; } = "{}";
|
||||
/// <summary>Last error from the background service, cleared on next successful fire.</summary>
|
||||
public string? LastError { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A named tax rate (e.g., "CA Sales Tax 8.25%") used to pre-fill the TaxPercent field on
|
||||
/// invoices when a taxable customer is selected. Companies can define multiple rates for
|
||||
/// different jurisdictions and mark one as default.
|
||||
/// </summary>
|
||||
public class TaxRate : BaseEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>Rate as a percentage, e.g., 8.25 means 8.25%.</summary>
|
||||
public decimal Rate { get; set; }
|
||||
public string? State { get; set; }
|
||||
public string? Description { get; set; }
|
||||
/// <summary>When true, this rate is auto-applied to new invoices for taxable customers.</summary>
|
||||
public bool IsDefault { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A depreciable fixed asset (oven, blast cabinet, spray booth, vehicle, etc.).
|
||||
/// Stores straight-line depreciation parameters and links to the three GL accounts needed
|
||||
/// to auto-post monthly depreciation journal entries.
|
||||
/// </summary>
|
||||
public class FixedAsset : BaseEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public DateTime PurchaseDate { get; set; }
|
||||
public decimal PurchaseCost { get; set; }
|
||||
/// <summary>Residual value at end of useful life (often $0 for shop equipment).</summary>
|
||||
public decimal SalvageValue { get; set; } = 0;
|
||||
/// <summary>Total depreciation period in months (e.g., 60 = 5 years).</summary>
|
||||
public int UsefulLifeMonths { get; set; }
|
||||
/// <summary>Running total of depreciation posted so far.</summary>
|
||||
public decimal AccumulatedDepreciation { get; set; } = 0;
|
||||
public bool IsDisposed { get; set; } = false;
|
||||
public DateTime? DisposalDate { get; set; }
|
||||
|
||||
// Computed — not persisted
|
||||
/// <summary>Current net book value: PurchaseCost minus AccumulatedDepreciation.</summary>
|
||||
public decimal BookValue => PurchaseCost - AccumulatedDepreciation;
|
||||
/// <summary>Straight-line monthly depreciation amount.</summary>
|
||||
public decimal MonthlyDepreciation => UsefulLifeMonths > 0
|
||||
? Math.Round((PurchaseCost - SalvageValue) / UsefulLifeMonths, 2) : 0;
|
||||
|
||||
// GL account links — all optional; assets without accounts can be tracked but not auto-posted
|
||||
/// <summary>Balance Sheet FixedAsset account (debited when asset is purchased).</summary>
|
||||
public int? AssetAccountId { get; set; }
|
||||
/// <summary>P&L Depreciation Expense account (debited each period).</summary>
|
||||
public int? DepreciationExpenseAccountId { get; set; }
|
||||
/// <summary>Balance Sheet Accumulated Depreciation account (credited each period).</summary>
|
||||
public int? AccumDepreciationAccountId { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Account? AssetAccount { get; set; }
|
||||
public virtual Account? DepreciationExpenseAccount { get; set; }
|
||||
public virtual Account? AccumDepreciationAccount { get; set; }
|
||||
public virtual ICollection<FixedAssetDepreciationEntry> DepreciationEntries { get; set; } = new List<FixedAssetDepreciationEntry>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records each periodic depreciation posting for a fixed asset. One record per asset per
|
||||
/// month/year combination; linked to the JournalEntry that was created so the posting
|
||||
/// can be traced back through the GL.
|
||||
/// </summary>
|
||||
public class FixedAssetDepreciationEntry : BaseEntity
|
||||
{
|
||||
public int FixedAssetId { get; set; }
|
||||
public int PeriodYear { get; set; }
|
||||
public int PeriodMonth { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
/// <summary>The JE that was posted for this depreciation period (null if manually recorded).</summary>
|
||||
public int? JournalEntryId { get; set; }
|
||||
|
||||
// Navigation
|
||||
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!;
|
||||
}
|
||||
|
||||
@@ -112,6 +112,12 @@ public class Company : BaseEntity
|
||||
/// </summary>
|
||||
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
|
||||
|
||||
/// <summary>
|
||||
/// When set, prevents creating or editing accounting entries (JEs, bills, expenses) with dates
|
||||
/// on or before this date. Protects closed periods from accidental backdating. Null = no lock.
|
||||
/// </summary>
|
||||
public DateTime? BookLockedThrough { get; set; }
|
||||
|
||||
// Settings
|
||||
public string? TimeZone { get; set; } = "America/New_York";
|
||||
public byte[]? LogoData { get; set; } // Legacy - kept for backward compatibility
|
||||
|
||||
@@ -42,6 +42,19 @@ public class Invoice : BaseEntity
|
||||
public string? Terms { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Early payment discount percentage (e.g., 2 means 2% discount).
|
||||
/// Parsed from the customer's payment terms when the invoice is created (e.g., "2/10 Net 30").
|
||||
/// Informational only — does not automatically reduce the amount due.
|
||||
/// </summary>
|
||||
public decimal EarlyPaymentDiscountPercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of days after invoice date within which the early payment discount applies.
|
||||
/// Parsed from the customer's payment terms (e.g., "2/10 Net 30" → 10 days).
|
||||
/// </summary>
|
||||
public int EarlyPaymentDiscountDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Original invoice number from an external system (e.g. QuickBooks invoice # "3048").
|
||||
/// Stored for searchability and traceability after import. Searchable from the invoice list.
|
||||
|
||||
@@ -35,6 +35,10 @@ public class Vendor : BaseEntity
|
||||
/// <summary>Default expense account pre-filled on new bill line items for this vendor.</summary>
|
||||
public int? DefaultExpenseAccountId { get; set; }
|
||||
|
||||
// 1099 Contractor tracking
|
||||
/// <summary>When true, this vendor is an independent contractor subject to 1099-NEC reporting.</summary>
|
||||
public bool Is1099Vendor { get; set; } = false;
|
||||
|
||||
// Navigation
|
||||
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
|
||||
public virtual ICollection<Bill> Bills { get; set; } = new List<Bill>();
|
||||
|
||||
@@ -94,6 +94,26 @@ public enum VendorCreditStatus
|
||||
Voided = 3
|
||||
}
|
||||
|
||||
/// <summary>Source document type for a recurring template — controls which entity is created on each fire.</summary>
|
||||
public enum RecurringTemplateType
|
||||
{
|
||||
/// <summary>Creates a vendor Bill (Draft, pending user review).</summary>
|
||||
Bill = 1,
|
||||
/// <summary>Creates a direct Expense entry (immediately recorded).</summary>
|
||||
Expense = 2
|
||||
}
|
||||
|
||||
/// <summary>How often a recurring template fires.</summary>
|
||||
public enum RecurringFrequency
|
||||
{
|
||||
Daily = 1,
|
||||
Weekly = 2,
|
||||
BiWeekly = 3,
|
||||
Monthly = 4,
|
||||
Quarterly = 5,
|
||||
Annually = 6
|
||||
}
|
||||
|
||||
/// <summary>Lifecycle state of a Manual Journal Entry.</summary>
|
||||
public enum JournalEntryStatus
|
||||
{
|
||||
|
||||
@@ -103,6 +103,23 @@ public interface IUnitOfWork : IDisposable
|
||||
// Bank Reconciliation
|
||||
IRepository<BankReconciliation> BankReconciliations { get; }
|
||||
|
||||
// Tax Rates
|
||||
IRepository<TaxRate> TaxRates { get; }
|
||||
|
||||
// Recurring Transactions
|
||||
IRepository<RecurringTemplate> RecurringTemplates { get; }
|
||||
|
||||
// Fixed Assets
|
||||
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; }
|
||||
|
||||
@@ -332,6 +332,24 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
/// <summary>Bank reconciliation sessions matching GL transactions to bank statements; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<BankReconciliation> BankReconciliations { get; set; }
|
||||
|
||||
/// <summary>Named tax rates used to pre-fill invoice tax percent by jurisdiction; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<TaxRate> TaxRates { get; set; }
|
||||
|
||||
/// <summary>Recurring transaction templates that auto-generate bills or expenses on a schedule; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<RecurringTemplate> RecurringTemplates { get; set; }
|
||||
|
||||
/// <summary>Fixed assets subject to straight-line depreciation; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<FixedAsset> FixedAssets { get; set; }
|
||||
/// <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>
|
||||
@@ -638,12 +656,84 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
modelBuilder.Entity<BankReconciliation>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
// Tax Rates: tenant-filtered
|
||||
modelBuilder.Entity<TaxRate>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
// Recurring Templates: tenant-filtered
|
||||
modelBuilder.Entity<RecurringTemplate>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
// Fixed Assets: tenant-filtered with soft delete; depreciation entries soft-delete only
|
||||
modelBuilder.Entity<FixedAsset>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<FixedAssetDepreciationEntry>().HasQueryFilter(e => !e.IsDeleted);
|
||||
|
||||
// FixedAsset → Account (three FKs): NoAction to avoid cascade conflicts; Account has no
|
||||
// reverse collection for FixedAssets so WithMany() is anonymous for each.
|
||||
modelBuilder.Entity<FixedAsset>()
|
||||
.HasOne(fa => fa.AssetAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(fa => fa.AssetAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
modelBuilder.Entity<FixedAsset>()
|
||||
.HasOne(fa => fa.DepreciationExpenseAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(fa => fa.DepreciationExpenseAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
modelBuilder.Entity<FixedAsset>()
|
||||
.HasOne(fa => fa.AccumDepreciationAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(fa => fa.AccumDepreciationAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// FixedAssetDepreciationEntry → JournalEntry: NoAction (entries outlive their JE)
|
||||
modelBuilder.Entity<FixedAssetDepreciationEntry>()
|
||||
.HasOne(e => e.JournalEntry)
|
||||
.WithMany()
|
||||
.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));
|
||||
modelBuilder.Entity<VendorCreditLineItem>().HasQueryFilter(e => !e.IsDeleted);
|
||||
modelBuilder.Entity<VendorCreditApplication>().HasQueryFilter(e => !e.IsDeleted);
|
||||
|
||||
// VendorCreditApplication: NoAction on both FKs to avoid SQL Server multiple-cascade-path error 1785.
|
||||
// Bills and VendorCredits both cascade-delete through Vendor, creating two paths to VendorCreditApplications.
|
||||
modelBuilder.Entity<VendorCreditApplication>()
|
||||
.HasOne(vca => vca.Bill)
|
||||
.WithMany()
|
||||
.HasForeignKey(vca => vca.BillId)
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
modelBuilder.Entity<VendorCreditApplication>()
|
||||
.HasOne(vca => vca.VendorCredit)
|
||||
.WithMany(vc => vc.Applications)
|
||||
.HasForeignKey(vca => vca.VendorCreditId)
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
// Purchase Orders
|
||||
modelBuilder.Entity<PurchaseOrder>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
@@ -78,13 +78,13 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
column: x => x.BillId,
|
||||
principalTable: "Bills",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
onDelete: ReferentialAction.NoAction);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId",
|
||||
column: x => x.VendorCreditId,
|
||||
principalTable: "VendorCredits",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
onDelete: ReferentialAction.NoAction);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
|
||||
Generated
+10105
File diff suppressed because it is too large
Load Diff
+112
@@ -0,0 +1,112 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPaymentTermsAndTaxRates : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "EarlyPaymentDiscountDays",
|
||||
table: "Invoices",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "EarlyPaymentDiscountPercent",
|
||||
table: "Invoices",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TaxRates",
|
||||
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),
|
||||
Rate = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
State = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDefault = table.Column<bool>(type: "bit", nullable: false),
|
||||
IsActive = 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_TaxRates", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3903));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3909));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3910));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "TaxRates");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EarlyPaymentDiscountDays",
|
||||
table: "Invoices");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EarlyPaymentDiscountPercent",
|
||||
table: "Invoices");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8472));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8478));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8479));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10186
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,171 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRecurringTemplates : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_VendorCreditApplications_Bills_BillId",
|
||||
table: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId",
|
||||
table: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "VendorCreditId1",
|
||||
table: "VendorCreditApplications",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "RecurringTemplates",
|
||||
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),
|
||||
TemplateType = table.Column<int>(type: "int", nullable: false),
|
||||
Frequency = table.Column<int>(type: "int", nullable: false),
|
||||
IntervalCount = table.Column<int>(type: "int", nullable: false),
|
||||
NextFireDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
EndDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
MaxOccurrences = table.Column<int>(type: "int", nullable: true),
|
||||
OccurrenceCount = table.Column<int>(type: "int", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false),
|
||||
TemplateData = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
LastError = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
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_RecurringTemplates", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 1, 54, 769, DateTimeKind.Utc).AddTicks(6262));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 1, 54, 769, DateTimeKind.Utc).AddTicks(6270));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 1, 54, 769, DateTimeKind.Utc).AddTicks(6271));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCreditApplications_VendorCreditId1",
|
||||
table: "VendorCreditApplications",
|
||||
column: "VendorCreditId1");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_VendorCreditApplications_Bills_BillId",
|
||||
table: "VendorCreditApplications",
|
||||
column: "BillId",
|
||||
principalTable: "Bills",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId",
|
||||
table: "VendorCreditApplications",
|
||||
column: "VendorCreditId",
|
||||
principalTable: "VendorCredits",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId1",
|
||||
table: "VendorCreditApplications",
|
||||
column: "VendorCreditId1",
|
||||
principalTable: "VendorCredits",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_VendorCreditApplications_Bills_BillId",
|
||||
table: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId",
|
||||
table: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId1",
|
||||
table: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "RecurringTemplates");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_VendorCreditApplications_VendorCreditId1",
|
||||
table: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "VendorCreditId1",
|
||||
table: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3903));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3909));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3910));
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_VendorCreditApplications_Bills_BillId",
|
||||
table: "VendorCreditApplications",
|
||||
column: "BillId",
|
||||
principalTable: "Bills",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId",
|
||||
table: "VendorCreditApplications",
|
||||
column: "VendorCreditId",
|
||||
principalTable: "VendorCredits",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10177
File diff suppressed because it is too large
Load Diff
+91
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class DropOrphanVendorCreditId1 : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId1",
|
||||
table: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_VendorCreditApplications_VendorCreditId1",
|
||||
table: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "VendorCreditId1",
|
||||
table: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(199));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(205));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(206));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "VendorCreditId1",
|
||||
table: "VendorCreditApplications",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 1, 54, 769, DateTimeKind.Utc).AddTicks(6262));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 1, 54, 769, DateTimeKind.Utc).AddTicks(6270));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 1, 54, 769, DateTimeKind.Utc).AddTicks(6271));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCreditApplications_VendorCreditId1",
|
||||
table: "VendorCreditApplications",
|
||||
column: "VendorCreditId1");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId1",
|
||||
table: "VendorCreditApplications",
|
||||
column: "VendorCreditId1",
|
||||
principalTable: "VendorCredits",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10366
File diff suppressed because it is too large
Load Diff
+199
@@ -0,0 +1,199 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddFixedAssetsLockAnd1099 : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "Is1099Vendor",
|
||||
table: "Vendors",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "BookLockedThrough",
|
||||
table: "Companies",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "FixedAssets",
|
||||
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),
|
||||
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
PurchaseDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
PurchaseCost = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
SalvageValue = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
UsefulLifeMonths = table.Column<int>(type: "int", nullable: false),
|
||||
AccumulatedDepreciation = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
IsDisposed = table.Column<bool>(type: "bit", nullable: false),
|
||||
DisposalDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
AssetAccountId = table.Column<int>(type: "int", nullable: true),
|
||||
DepreciationExpenseAccountId = table.Column<int>(type: "int", nullable: true),
|
||||
AccumDepreciationAccountId = table.Column<int>(type: "int", nullable: true),
|
||||
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_FixedAssets", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_FixedAssets_Accounts_AccumDepreciationAccountId",
|
||||
column: x => x.AccumDepreciationAccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_FixedAssets_Accounts_AssetAccountId",
|
||||
column: x => x.AssetAccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_FixedAssets_Accounts_DepreciationExpenseAccountId",
|
||||
column: x => x.DepreciationExpenseAccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "FixedAssetDepreciationEntries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
FixedAssetId = table.Column<int>(type: "int", nullable: false),
|
||||
PeriodYear = table.Column<int>(type: "int", nullable: false),
|
||||
PeriodMonth = table.Column<int>(type: "int", nullable: false),
|
||||
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
JournalEntryId = table.Column<int>(type: "int", nullable: true),
|
||||
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_FixedAssetDepreciationEntries", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_FixedAssetDepreciationEntries_FixedAssets_FixedAssetId",
|
||||
column: x => x.FixedAssetId,
|
||||
principalTable: "FixedAssets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_FixedAssetDepreciationEntries_JournalEntries_JournalEntryId",
|
||||
column: x => x.JournalEntryId,
|
||||
principalTable: "JournalEntries",
|
||||
principalColumn: "Id");
|
||||
});
|
||||
|
||||
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));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FixedAssetDepreciationEntries_FixedAssetId",
|
||||
table: "FixedAssetDepreciationEntries",
|
||||
column: "FixedAssetId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FixedAssetDepreciationEntries_JournalEntryId",
|
||||
table: "FixedAssetDepreciationEntries",
|
||||
column: "JournalEntryId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FixedAssets_AccumDepreciationAccountId",
|
||||
table: "FixedAssets",
|
||||
column: "AccumDepreciationAccountId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FixedAssets_AssetAccountId",
|
||||
table: "FixedAssets",
|
||||
column: "AssetAccountId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FixedAssets_DepreciationExpenseAccountId",
|
||||
table: "FixedAssets",
|
||||
column: "DepreciationExpenseAccountId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "FixedAssetDepreciationEntries");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "FixedAssets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Is1099Vendor",
|
||||
table: "Vendors");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BookLockedThrough",
|
||||
table: "Companies");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(199));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(205));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(206));
|
||||
}
|
||||
}
|
||||
}
|
||||
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")
|
||||
@@ -1633,6 +1766,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<bool>("AiPhotoQuotesEnabled")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<DateTime?>("BookLockedThrough")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("City")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -2994,6 +3130,142 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.ToTable("Expenses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.FixedAsset", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int?>("AccumDepreciationAccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal>("AccumulatedDepreciation")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int?>("AssetAccountId")
|
||||
.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<int?>("DepreciationExpenseAccountId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DisposalDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsDisposed")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal>("PurchaseCost")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<DateTime>("PurchaseDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal>("SalvageValue")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("UsefulLifeMonths")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccumDepreciationAccountId");
|
||||
|
||||
b.HasIndex("AssetAccountId");
|
||||
|
||||
b.HasIndex("DepreciationExpenseAccountId");
|
||||
|
||||
b.ToTable("FixedAssets");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.FixedAssetDepreciationEntry", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<decimal>("Amount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
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>("FixedAssetId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<int?>("JournalEntryId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("PeriodMonth")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("PeriodYear")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FixedAssetId");
|
||||
|
||||
b.HasIndex("JournalEntryId");
|
||||
|
||||
b.ToTable("FixedAssetDepreciationEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -3586,6 +3858,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<DateTime?>("DueDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("EarlyPaymentDiscountDays")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal>("EarlyPaymentDiscountPercent")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("ExternalReference")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
@@ -6287,7 +6565,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8472),
|
||||
CreatedAt = new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(966),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -6298,7 +6576,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8478),
|
||||
CreatedAt = new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(974),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -6309,7 +6587,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8479),
|
||||
CreatedAt = new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(976),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
@@ -7266,6 +7544,78 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.ToTable("QuoteStatusLookups");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.RecurringTemplate", 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<DateTime?>("EndDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("Frequency")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("IntervalCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("LastError")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("MaxOccurrences")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime>("NextFireDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int>("OccurrenceCount")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("TemplateData")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("TemplateType")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("RecurringTemplates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.Refund", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -7742,6 +8092,62 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.ToTable("SubscriptionPlanConfigs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.TaxRate", 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<string>("Description")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsDefault")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal>("Rate")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("State")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("TaxRates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.TermsAcceptance", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -7876,6 +8282,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("Is1099Vendor")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
@@ -8100,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)
|
||||
@@ -8313,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")
|
||||
@@ -8569,6 +9048,48 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Navigation("Vendor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.FixedAsset", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.Account", "AccumDepreciationAccount")
|
||||
.WithMany()
|
||||
.HasForeignKey("AccumDepreciationAccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("PowderCoating.Core.Entities.Account", "AssetAccount")
|
||||
.WithMany()
|
||||
.HasForeignKey("AssetAccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("PowderCoating.Core.Entities.Account", "DepreciationExpenseAccount")
|
||||
.WithMany()
|
||||
.HasForeignKey("DepreciationExpenseAccountId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("AccumDepreciationAccount");
|
||||
|
||||
b.Navigation("AssetAccount");
|
||||
|
||||
b.Navigation("DepreciationExpenseAccount");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.FixedAssetDepreciationEntry", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.FixedAsset", "FixedAsset")
|
||||
.WithMany("DepreciationEntries")
|
||||
.HasForeignKey("FixedAssetId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("PowderCoating.Core.Entities.JournalEntry", "JournalEntry")
|
||||
.WithMany()
|
||||
.HasForeignKey("JournalEntryId")
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
b.Navigation("FixedAsset");
|
||||
|
||||
b.Navigation("JournalEntry");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "IssuedBy")
|
||||
@@ -9740,13 +10261,13 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.HasOne("PowderCoating.Core.Entities.Bill", "Bill")
|
||||
.WithMany()
|
||||
.HasForeignKey("BillId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.OnDelete(DeleteBehavior.NoAction)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("PowderCoating.Core.Entities.VendorCredit", "VendorCredit")
|
||||
.WithMany("Applications")
|
||||
.HasForeignKey("VendorCreditId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.OnDelete(DeleteBehavior.NoAction)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Bill");
|
||||
@@ -9772,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");
|
||||
@@ -9816,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");
|
||||
@@ -9878,6 +10415,11 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Navigation("OvenBatches");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.FixedAsset", b =>
|
||||
{
|
||||
b.Navigation("DepreciationEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
|
||||
{
|
||||
b.Navigation("Redemptions");
|
||||
|
||||
@@ -154,6 +154,17 @@ public class UnitOfWork : IUnitOfWork
|
||||
// Bank Reconciliation
|
||||
private IRepository<BankReconciliation>? _bankReconciliations;
|
||||
|
||||
// Tax Rates
|
||||
private IRepository<TaxRate>? _taxRates;
|
||||
|
||||
// Recurring Transactions
|
||||
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"/>.
|
||||
/// The context is shared across all repositories created by this instance so that
|
||||
@@ -552,6 +563,26 @@ public class UnitOfWork : IUnitOfWork
|
||||
public IRepository<BankReconciliation> BankReconciliations =>
|
||||
_bankReconciliations ??= new Repository<BankReconciliation>(_context);
|
||||
|
||||
// Tax Rates
|
||||
/// <summary>Repository for <see cref="TaxRate"/> named tax rates used to pre-fill invoice tax percent by jurisdiction.</summary>
|
||||
public IRepository<TaxRate> TaxRates =>
|
||||
_taxRates ??= new Repository<TaxRate>(_context);
|
||||
|
||||
// Recurring Transactions
|
||||
/// <summary>Repository for <see cref="RecurringTemplate"/> — saved recipes that auto-generate bills or expenses on a schedule.</summary>
|
||||
public IRepository<RecurringTemplate> RecurringTemplates =>
|
||||
_recurringTemplates ??= new Repository<RecurringTemplate>(_context);
|
||||
public IRepository<FixedAsset> FixedAssets =>
|
||||
_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.
|
||||
/// Returns the number of state entries written.
|
||||
|
||||
@@ -713,6 +713,326 @@ public class FinancialReportService : IFinancialReportService
|
||||
return method ?? AccountingMethod.Accrual;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<CustomerStatementDto> GetCustomerStatementAsync(int companyId, int customerId, DateTime from, DateTime to)
|
||||
{
|
||||
var toEnd = to.AddDays(1).AddTicks(-1);
|
||||
var fromEnd = from.AddTicks(-1); // exclusive upper bound for pre-period queries
|
||||
|
||||
var companyName = await GetCompanyNameAsync(companyId);
|
||||
|
||||
var customer = await _context.Customers
|
||||
.Where(c => c.Id == customerId && c.CompanyId == companyId)
|
||||
.AsNoTracking().FirstOrDefaultAsync();
|
||||
if (customer == null) return new CustomerStatementDto { CompanyName = companyName, From = from, To = to };
|
||||
|
||||
var customerName = customer.IsCommercial
|
||||
? customer.CompanyName ?? string.Empty
|
||||
: $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();
|
||||
|
||||
var address = string.Join(", ", new[] { customer.Address, customer.City, customer.State, customer.ZipCode }
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s)));
|
||||
|
||||
// Opening balance: invoiced − paid before period start
|
||||
var preInvoiced = await _context.Invoices
|
||||
.Where(i => i.CustomerId == customerId
|
||||
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||
&& i.InvoiceDate < from)
|
||||
.SumAsync(i => (decimal?)i.Total) ?? 0;
|
||||
var prePaid = await _context.Payments
|
||||
.Where(p => p.Invoice.CustomerId == customerId
|
||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||
&& p.PaymentDate < from)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
var preCredits = await _context.CreditMemoApplications
|
||||
.Where(a => a.Invoice.CustomerId == customerId && a.AppliedDate < from)
|
||||
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
|
||||
|
||||
var openingBalance = preInvoiced - prePaid - preCredits;
|
||||
|
||||
// In-period activity — gather, then sort, then compute running balance
|
||||
var lines = new List<StatementLineDto>();
|
||||
|
||||
var periodInvoices = await _context.Invoices
|
||||
.Where(i => i.CustomerId == customerId
|
||||
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||
&& i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
|
||||
.AsNoTracking().ToListAsync();
|
||||
|
||||
foreach (var inv in periodInvoices)
|
||||
lines.Add(new StatementLineDto
|
||||
{
|
||||
Date = inv.InvoiceDate,
|
||||
Type = "Invoice",
|
||||
Reference = inv.InvoiceNumber,
|
||||
Description = "Invoice",
|
||||
Debit = inv.Total,
|
||||
});
|
||||
|
||||
var periodPayments = await _context.Payments
|
||||
.Include(p => p.Invoice)
|
||||
.Where(p => p.Invoice.CustomerId == customerId
|
||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||
&& p.PaymentDate >= from && p.PaymentDate <= toEnd)
|
||||
.AsNoTracking().ToListAsync();
|
||||
|
||||
foreach (var pay in periodPayments)
|
||||
lines.Add(new StatementLineDto
|
||||
{
|
||||
Date = pay.PaymentDate,
|
||||
Type = "Payment",
|
||||
Reference = pay.Invoice.InvoiceNumber,
|
||||
Description = pay.Notes ?? "Payment received",
|
||||
Credit = pay.Amount,
|
||||
});
|
||||
|
||||
var periodCredits = await _context.CreditMemoApplications
|
||||
.Include(a => a.Invoice)
|
||||
.Include(a => a.CreditMemo)
|
||||
.Where(a => a.Invoice.CustomerId == customerId
|
||||
&& a.AppliedDate >= from && a.AppliedDate <= toEnd)
|
||||
.AsNoTracking().ToListAsync();
|
||||
|
||||
foreach (var cr in periodCredits)
|
||||
lines.Add(new StatementLineDto
|
||||
{
|
||||
Date = cr.AppliedDate,
|
||||
Type = "Credit Applied",
|
||||
Reference = cr.Invoice?.InvoiceNumber ?? string.Empty,
|
||||
Description = $"Credit memo applied",
|
||||
Credit = cr.AmountApplied,
|
||||
});
|
||||
|
||||
// Sort by date then compute running balance
|
||||
lines = lines.OrderBy(l => l.Date).ThenBy(l => l.Type).ToList();
|
||||
var running = openingBalance;
|
||||
foreach (var line in lines)
|
||||
{
|
||||
running += (line.Debit ?? 0) - (line.Credit ?? 0);
|
||||
line.RunningBalance = running;
|
||||
}
|
||||
|
||||
return new CustomerStatementDto
|
||||
{
|
||||
CustomerId = customerId,
|
||||
CustomerName = customerName,
|
||||
CustomerAddress = address,
|
||||
CompanyName = companyName,
|
||||
From = from,
|
||||
To = to,
|
||||
OpeningBalance = openingBalance,
|
||||
Lines = lines,
|
||||
ClosingBalance = running,
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<VendorStatementDto> GetVendorStatementAsync(int companyId, int vendorId, DateTime from, DateTime to)
|
||||
{
|
||||
var toEnd = to.AddDays(1).AddTicks(-1);
|
||||
|
||||
var companyName = await GetCompanyNameAsync(companyId);
|
||||
|
||||
var vendor = await _context.Vendors
|
||||
.Where(v => v.Id == vendorId && v.CompanyId == companyId)
|
||||
.AsNoTracking().FirstOrDefaultAsync();
|
||||
if (vendor == null) return new VendorStatementDto { CompanyName = companyName, From = from, To = to };
|
||||
|
||||
// Opening balance: bills − payments − credits before period start
|
||||
var preBills = await _context.Bills
|
||||
.Where(b => b.VendorId == vendorId
|
||||
&& b.Status != BillStatus.Draft && b.Status != BillStatus.Voided
|
||||
&& b.BillDate < from)
|
||||
.SumAsync(b => (decimal?)b.Total) ?? 0;
|
||||
var prePayments = await _context.BillPayments
|
||||
.Where(bp => bp.Bill.VendorId == vendorId && bp.PaymentDate < from)
|
||||
.SumAsync(bp => (decimal?)bp.Amount) ?? 0;
|
||||
var preVcApplied = await _context.VendorCreditApplications
|
||||
.Where(vca => vca.Bill.VendorId == vendorId && vca.AppliedDate < from)
|
||||
.SumAsync(vca => (decimal?)vca.Amount) ?? 0;
|
||||
|
||||
var openingBalance = preBills - prePayments - preVcApplied;
|
||||
|
||||
var lines = new List<StatementLineDto>();
|
||||
|
||||
var periodBills = await _context.Bills
|
||||
.Where(b => b.VendorId == vendorId
|
||||
&& b.Status != BillStatus.Draft && b.Status != BillStatus.Voided
|
||||
&& b.BillDate >= from && b.BillDate <= toEnd)
|
||||
.AsNoTracking().ToListAsync();
|
||||
|
||||
foreach (var bill in periodBills)
|
||||
lines.Add(new StatementLineDto
|
||||
{
|
||||
Date = bill.BillDate,
|
||||
Type = "Bill",
|
||||
Reference = bill.BillNumber,
|
||||
Description = bill.Memo ?? "Vendor bill",
|
||||
Debit = bill.Total,
|
||||
});
|
||||
|
||||
var periodPayments = await _context.BillPayments
|
||||
.Include(bp => bp.Bill)
|
||||
.Where(bp => bp.Bill.VendorId == vendorId
|
||||
&& bp.PaymentDate >= from && bp.PaymentDate <= toEnd)
|
||||
.AsNoTracking().ToListAsync();
|
||||
|
||||
foreach (var pay in periodPayments)
|
||||
lines.Add(new StatementLineDto
|
||||
{
|
||||
Date = pay.PaymentDate,
|
||||
Type = "Payment",
|
||||
Reference = pay.Bill.BillNumber,
|
||||
Description = pay.Memo ?? "Bill payment",
|
||||
Credit = pay.Amount,
|
||||
});
|
||||
|
||||
var periodVcApplied = await _context.VendorCreditApplications
|
||||
.Include(vca => vca.VendorCredit)
|
||||
.Include(vca => vca.Bill)
|
||||
.Where(vca => vca.Bill.VendorId == vendorId
|
||||
&& vca.AppliedDate >= from && vca.AppliedDate <= toEnd)
|
||||
.AsNoTracking().ToListAsync();
|
||||
|
||||
foreach (var vca in periodVcApplied)
|
||||
lines.Add(new StatementLineDto
|
||||
{
|
||||
Date = vca.AppliedDate,
|
||||
Type = "Credit Applied",
|
||||
Reference = vca.VendorCredit.CreditNumber,
|
||||
Description = $"Vendor credit applied to {vca.Bill.BillNumber}",
|
||||
Credit = vca.Amount,
|
||||
});
|
||||
|
||||
lines = lines.OrderBy(l => l.Date).ThenBy(l => l.Type).ToList();
|
||||
var running = openingBalance;
|
||||
foreach (var line in lines)
|
||||
{
|
||||
running += (line.Debit ?? 0) - (line.Credit ?? 0);
|
||||
line.RunningBalance = running;
|
||||
}
|
||||
|
||||
return new VendorStatementDto
|
||||
{
|
||||
VendorId = vendorId,
|
||||
VendorName = vendor.CompanyName,
|
||||
CompanyName = companyName,
|
||||
From = from,
|
||||
To = to,
|
||||
OpeningBalance = openingBalance,
|
||||
Lines = lines,
|
||||
ClosingBalance = running,
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <summary>
|
||||
/// Computes a Cash Flow Statement for the given period using the direct (cash-basis) method
|
||||
/// for operating activities:
|
||||
/// <list type="bullet">
|
||||
/// <item><b>CashFromCustomers</b> — sum of <see cref="Payment"/> amounts in the period.</item>
|
||||
/// <item><b>CashToVendors</b> — sum of <see cref="BillPayment"/> amounts in the period.</item>
|
||||
/// <item><b>CashForExpenses</b> — sum of <see cref="Expense"/> amounts in the period.</item>
|
||||
/// </list>
|
||||
/// BeginningCash is derived by summing all Payment inflows minus BillPayment and Expense outflows
|
||||
/// prior to <paramref name="from"/>. This is an approximation when cash accounts have
|
||||
/// an OpeningBalance; it is the most accurate representation available without a dedicated
|
||||
/// cash-tracking journal.
|
||||
/// Investing and Financing sections are populated from the expense/asset account ledger
|
||||
/// (FixedAsset purchases from Expense entries whose account is FixedAsset subtype) and
|
||||
/// equity account changes respectively.
|
||||
/// </summary>
|
||||
public async Task<CashFlowStatementDto> GetCashFlowStatementAsync(int companyId, DateTime from, DateTime to)
|
||||
{
|
||||
var toEnd = to.Date.AddDays(1).AddTicks(-1);
|
||||
var companyName = await GetCompanyNameAsync(companyId);
|
||||
var method = await GetCompanyAccountingMethodAsync(companyId);
|
||||
|
||||
// ── Operating — direct / cash ──────────────────────────────────────
|
||||
var cashFromCustomers = await _context.Payments
|
||||
.IgnoreQueryFilters()
|
||||
.Where(p => p.CompanyId == companyId && !p.IsDeleted
|
||||
&& p.PaymentDate >= from && p.PaymentDate <= toEnd)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0m;
|
||||
|
||||
var cashToVendors = await _context.BillPayments
|
||||
.IgnoreQueryFilters()
|
||||
.Where(bp => bp.CompanyId == companyId && !bp.IsDeleted
|
||||
&& bp.PaymentDate >= from && bp.PaymentDate <= toEnd)
|
||||
.SumAsync(bp => (decimal?)bp.Amount) ?? 0m;
|
||||
|
||||
var cashForExpenses = await _context.Expenses
|
||||
.IgnoreQueryFilters()
|
||||
.Where(e => e.CompanyId == companyId && !e.IsDeleted
|
||||
&& e.Date >= from && e.Date <= toEnd)
|
||||
.SumAsync(e => (decimal?)e.Amount) ?? 0m;
|
||||
|
||||
// ── Investing — fixed-asset purchases from Expense entries ─────────
|
||||
var fixedAssetAccountIds = await _context.Accounts
|
||||
.IgnoreQueryFilters()
|
||||
.Where(a => a.CompanyId == companyId && !a.IsDeleted
|
||||
&& a.AccountSubType == AccountSubType.FixedAsset)
|
||||
.Select(a => a.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var capEx = fixedAssetAccountIds.Count > 0
|
||||
? (await _context.Expenses
|
||||
.IgnoreQueryFilters()
|
||||
.Where(e => e.CompanyId == companyId && !e.IsDeleted
|
||||
&& e.Date >= from && e.Date <= toEnd
|
||||
&& fixedAssetAccountIds.Contains(e.ExpenseAccountId))
|
||||
.SumAsync(e => (decimal?)e.Amount) ?? 0m)
|
||||
: 0m;
|
||||
|
||||
var investingLines = new List<CashFlowLineDto>();
|
||||
if (capEx != 0m)
|
||||
investingLines.Add(new CashFlowLineDto { Label = "Capital Expenditures", Amount = -capEx });
|
||||
|
||||
// ── Financing — placeholder (equity changes not explicitly tracked) ─
|
||||
var financingLines = new List<CashFlowLineDto>();
|
||||
|
||||
// ── Beginning cash ─────────────────────────────────────────────────
|
||||
// Cash account opening balances + pre-period payments in - pre-period payments out
|
||||
var cashAccountOpeningBalance = await _context.Accounts
|
||||
.IgnoreQueryFilters()
|
||||
.Where(a => a.CompanyId == companyId && !a.IsDeleted
|
||||
&& (a.AccountSubType == AccountSubType.Cash
|
||||
|| a.AccountSubType == AccountSubType.Checking
|
||||
|| a.AccountSubType == AccountSubType.Savings))
|
||||
.SumAsync(a => (decimal?)a.OpeningBalance) ?? 0m;
|
||||
|
||||
var prePaymentsIn = await _context.Payments
|
||||
.IgnoreQueryFilters()
|
||||
.Where(p => p.CompanyId == companyId && !p.IsDeleted && p.PaymentDate < from)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0m;
|
||||
|
||||
var preBillPaymentsOut = await _context.BillPayments
|
||||
.IgnoreQueryFilters()
|
||||
.Where(bp => bp.CompanyId == companyId && !bp.IsDeleted && bp.PaymentDate < from)
|
||||
.SumAsync(bp => (decimal?)bp.Amount) ?? 0m;
|
||||
|
||||
var preExpensesOut = await _context.Expenses
|
||||
.IgnoreQueryFilters()
|
||||
.Where(e => e.CompanyId == companyId && !e.IsDeleted && e.Date < from)
|
||||
.SumAsync(e => (decimal?)e.Amount) ?? 0m;
|
||||
|
||||
var beginningCash = cashAccountOpeningBalance + prePaymentsIn - preBillPaymentsOut - preExpensesOut;
|
||||
|
||||
return new CashFlowStatementDto
|
||||
{
|
||||
CompanyName = companyName,
|
||||
From = from,
|
||||
To = to,
|
||||
Method = method,
|
||||
CashFromCustomers = cashFromCustomers,
|
||||
CashToVendors = cashToVendors,
|
||||
CashForExpenses = cashForExpenses,
|
||||
InvestingLines = investingLines,
|
||||
FinancingLines = financingLines,
|
||||
BeginningCash = beginningCash,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up the company name by ID for report headers and AI prompt injection.
|
||||
/// Falls back to "Your Company" if the record is not found.
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace PowderCoating.Web.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// Singleton background service that wakes hourly and generates bills or expenses for any
|
||||
/// <see cref="RecurringTemplate"/> whose <c>NextFireDate</c> is today or in the past.
|
||||
/// Bills are created as Draft so users can review; Expenses are recorded immediately.
|
||||
/// NextFireDate is advanced after each successful fire. Templates are deactivated automatically
|
||||
/// when <c>MaxOccurrences</c> is reached or <c>EndDate</c> has passed.
|
||||
/// </summary>
|
||||
public class RecurringTransactionService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<RecurringTransactionService> _logger;
|
||||
|
||||
public RecurringTransactionService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<RecurringTransactionService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loops forever, sleeping one hour between passes.
|
||||
/// Uses <see cref="IServiceScopeFactory"/> to resolve scoped services (DbContext) from the
|
||||
/// singleton because BackgroundService lives for the application lifetime.
|
||||
/// </summary>
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("RecurringTransactionService started.");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await RunAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "RecurringTransactionService run failed.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromHours(1), stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("RecurringTransactionService stopped.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads all active templates whose NextFireDate is on or before today and fires each one.
|
||||
/// Uses IgnoreQueryFilters to bypass the HTTP-context-dependent tenant filter.
|
||||
/// </summary>
|
||||
private async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
|
||||
var today = DateTime.UtcNow.Date;
|
||||
|
||||
var due = await db.RecurringTemplates
|
||||
.IgnoreQueryFilters()
|
||||
.Where(t => !t.IsDeleted && t.IsActive && t.NextFireDate.Date <= today)
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (due.Count == 0) return;
|
||||
|
||||
_logger.LogInformation("RecurringTransactionService: {Count} template(s) due.", due.Count);
|
||||
|
||||
foreach (var template in due)
|
||||
{
|
||||
if (ct.IsCancellationRequested) break;
|
||||
await FireTemplateAsync(db, template, ct);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fires a single template: creates the document, updates OccurrenceCount + NextFireDate,
|
||||
/// and deactivates the template when limits are reached. Errors are captured in LastError
|
||||
/// so the service loop continues to process other templates.
|
||||
/// </summary>
|
||||
private async Task FireTemplateAsync(
|
||||
ApplicationDbContext db,
|
||||
RecurringTemplate template,
|
||||
CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (template.TemplateType == RecurringTemplateType.Bill)
|
||||
await CreateBillAsync(db, template, ct);
|
||||
else
|
||||
await CreateExpenseAsync(db, template, ct);
|
||||
|
||||
template.OccurrenceCount++;
|
||||
template.NextFireDate = AdvanceDate(template.NextFireDate, template.Frequency, template.IntervalCount);
|
||||
template.LastError = null;
|
||||
|
||||
// Deactivate when limits reached
|
||||
if (template.MaxOccurrences.HasValue && template.OccurrenceCount >= template.MaxOccurrences.Value)
|
||||
{
|
||||
template.IsActive = false;
|
||||
_logger.LogInformation("Template {Id} ({Name}) deactivated: MaxOccurrences reached.", template.Id, template.Name);
|
||||
}
|
||||
else if (template.EndDate.HasValue && template.NextFireDate.Date > template.EndDate.Value.Date)
|
||||
{
|
||||
template.IsActive = false;
|
||||
_logger.LogInformation("Template {Id} ({Name}) deactivated: EndDate passed.", template.Id, template.Name);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to fire recurring template {Id} ({Name}).", template.Id, template.Name);
|
||||
template.LastError = ex.Message;
|
||||
try { await db.SaveChangesAsync(ct); } catch { /* best-effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Bill creation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes the template's JSON payload and inserts a Draft <see cref="Bill"/> with
|
||||
/// its line items. GL posting is deferred — the user posts the Draft bill manually after review.
|
||||
/// </summary>
|
||||
private async Task CreateBillAsync(ApplicationDbContext db, RecurringTemplate template, CancellationToken ct)
|
||||
{
|
||||
var data = JsonSerializer.Deserialize<BillTemplateData>(template.TemplateData)
|
||||
?? throw new InvalidOperationException("Invalid bill template data.");
|
||||
|
||||
var bill = new Bill
|
||||
{
|
||||
BillNumber = await NextBillNumberAsync(db, ct),
|
||||
VendorId = data.VendorId,
|
||||
APAccountId = data.APAccountId,
|
||||
BillDate = DateTime.UtcNow,
|
||||
DueDate = data.Terms != null ? ParseDueDate(data.Terms) : null,
|
||||
Status = BillStatus.Draft,
|
||||
Terms = data.Terms,
|
||||
Memo = $"[Recurring] {data.Memo}".Trim(),
|
||||
SubTotal = data.LineItems?.Sum(l => l.Quantity * l.UnitPrice) ?? 0,
|
||||
TaxPercent = data.TaxPercent,
|
||||
TaxAmount = 0,
|
||||
Total = 0,
|
||||
CompanyId = template.CompanyId,
|
||||
CreatedBy = "Recurring",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
bill.TaxAmount = Math.Round(bill.SubTotal * bill.TaxPercent / 100, 2);
|
||||
bill.Total = bill.SubTotal + bill.TaxAmount;
|
||||
|
||||
db.Bills.Add(bill);
|
||||
await db.SaveChangesAsync(ct); // get bill.Id
|
||||
|
||||
int order = 1;
|
||||
foreach (var line in data.LineItems ?? [])
|
||||
{
|
||||
db.BillLineItems.Add(new BillLineItem
|
||||
{
|
||||
BillId = bill.Id,
|
||||
AccountId = line.AccountId,
|
||||
Description = line.Description,
|
||||
Quantity = line.Quantity,
|
||||
UnitPrice = line.UnitPrice,
|
||||
Amount = Math.Round(line.Quantity * line.UnitPrice, 2),
|
||||
DisplayOrder = order++,
|
||||
CompanyId = template.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
_logger.LogInformation("Recurring bill {BillNumber} created for template {Id}.", bill.BillNumber, template.Id);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Expense creation
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Deserializes the template's JSON payload and inserts an <see cref="Expense"/> immediately.
|
||||
/// Expenses are already-paid transactions so no user review is required.
|
||||
/// </summary>
|
||||
private async Task CreateExpenseAsync(ApplicationDbContext db, RecurringTemplate template, CancellationToken ct)
|
||||
{
|
||||
var data = JsonSerializer.Deserialize<ExpenseTemplateData>(template.TemplateData)
|
||||
?? throw new InvalidOperationException("Invalid expense template data.");
|
||||
|
||||
var expense = new Expense
|
||||
{
|
||||
ExpenseNumber = await NextExpenseNumberAsync(db, ct),
|
||||
Date = DateTime.UtcNow,
|
||||
VendorId = data.VendorId == 0 ? null : data.VendorId,
|
||||
ExpenseAccountId = data.ExpenseAccountId,
|
||||
PaymentAccountId = data.PaymentAccountId,
|
||||
PaymentMethod = (PaymentMethod)data.PaymentMethod,
|
||||
Amount = data.Amount,
|
||||
Memo = $"[Recurring] {data.Memo}".Trim(),
|
||||
CompanyId = template.CompanyId,
|
||||
CreatedBy = "Recurring",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
db.Expenses.Add(expense);
|
||||
|
||||
_logger.LogInformation("Recurring expense {ExpenseNumber} created for template {Id}.", expense.ExpenseNumber, template.Id);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Advances a date by one period (Frequency × IntervalCount).</summary>
|
||||
private static DateTime AdvanceDate(DateTime date, RecurringFrequency freq, int interval)
|
||||
{
|
||||
return freq switch
|
||||
{
|
||||
RecurringFrequency.Daily => date.AddDays(interval),
|
||||
RecurringFrequency.Weekly => date.AddDays(7 * interval),
|
||||
RecurringFrequency.BiWeekly => date.AddDays(14 * interval),
|
||||
RecurringFrequency.Monthly => date.AddMonths(interval),
|
||||
RecurringFrequency.Quarterly => date.AddMonths(3 * interval),
|
||||
RecurringFrequency.Annually => date.AddYears(interval),
|
||||
_ => date.AddMonths(interval)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the next sequential bill number (BILL-YYMM-####).
|
||||
/// Uses IgnoreQueryFilters so soft-deleted bills are included in the sequence scan.
|
||||
/// </summary>
|
||||
private static async Task<string> NextBillNumberAsync(ApplicationDbContext db, CancellationToken ct)
|
||||
{
|
||||
var prefix = $"BILL-{DateTime.Now:yyMM}-";
|
||||
var last = await db.Bills
|
||||
.IgnoreQueryFilters()
|
||||
.Where(b => b.BillNumber.StartsWith(prefix))
|
||||
.OrderByDescending(b => b.BillNumber)
|
||||
.Select(b => b.BillNumber)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
int next = 1;
|
||||
if (last != null && int.TryParse(last[prefix.Length..], out int n)) next = n + 1;
|
||||
return $"{prefix}{next:D4}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the next sequential expense number (EXP-YYMM-####).
|
||||
/// Uses IgnoreQueryFilters so soft-deleted expenses are included in the sequence scan.
|
||||
/// </summary>
|
||||
private static async Task<string> NextExpenseNumberAsync(ApplicationDbContext db, CancellationToken ct)
|
||||
{
|
||||
var prefix = $"EXP-{DateTime.Now:yyMM}-";
|
||||
var last = await db.Expenses
|
||||
.IgnoreQueryFilters()
|
||||
.Where(e => e.ExpenseNumber.StartsWith(prefix))
|
||||
.OrderByDescending(e => e.ExpenseNumber)
|
||||
.Select(e => e.ExpenseNumber)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
int next = 1;
|
||||
if (last != null && int.TryParse(last[prefix.Length..], out int n)) next = n + 1;
|
||||
return $"{prefix}{next:D4}";
|
||||
}
|
||||
|
||||
/// <summary>Best-effort due date from a payment terms string (delegates to the same patterns as PaymentTermsParser).</summary>
|
||||
private static DateTime? ParseDueDate(string terms)
|
||||
{
|
||||
var t = terms.Trim().ToUpperInvariant();
|
||||
if (t is "DUE ON RECEIPT" or "COD" or "IMMEDIATE") return DateTime.UtcNow.Date;
|
||||
|
||||
// "Net 30", "NET30", "2/10 Net 30" → extract trailing number
|
||||
var parts = t.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var last = parts.LastOrDefault();
|
||||
if (last != null && int.TryParse(last, out int days) && days > 0)
|
||||
return DateTime.UtcNow.Date.AddDays(days);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// JSON payload records (must match RecurringTemplatesController serialization)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
internal sealed record BillTemplateData(
|
||||
int VendorId,
|
||||
int APAccountId,
|
||||
string? Terms,
|
||||
string? Memo,
|
||||
decimal TaxPercent,
|
||||
List<BillLineData>? LineItems);
|
||||
|
||||
internal sealed record BillLineData(
|
||||
int? AccountId,
|
||||
string Description,
|
||||
decimal Quantity,
|
||||
decimal UnitPrice);
|
||||
|
||||
internal sealed record ExpenseTemplateData(
|
||||
int VendorId,
|
||||
int ExpenseAccountId,
|
||||
int PaymentAccountId,
|
||||
int PaymentMethod,
|
||||
decimal Amount,
|
||||
string? Memo);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -321,6 +321,19 @@ public class BillsController : Controller
|
||||
try
|
||||
{
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
|
||||
// Period lock check — block if the bill date is in a locked period
|
||||
if (currentUser != null)
|
||||
{
|
||||
var co = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
|
||||
if (Web.Helpers.AccountingPeriodValidator.IsLocked(dto.BillDate, co?.BookLockedThrough))
|
||||
{
|
||||
ModelState.AddModelError("BillDate", Web.Helpers.AccountingPeriodValidator.LockedMessage(co!.BookLockedThrough));
|
||||
await PopulateDropdownsAsync();
|
||||
return View(dto);
|
||||
}
|
||||
}
|
||||
|
||||
Bill? bill = null;
|
||||
|
||||
// Bill entity, PO back-reference, and optional immediate payment all commit
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -160,6 +160,10 @@ public class CompanySettingsController : Controller
|
||||
UpdatedAt = t.UpdatedAt
|
||||
}).ToList();
|
||||
|
||||
ViewBag.BookLockedThrough = company.BookLockedThrough.HasValue
|
||||
? (DateTime?)company.BookLockedThrough.Value.ToLocalTime()
|
||||
: null;
|
||||
|
||||
return View(dto);
|
||||
}
|
||||
catch (FormatException fex)
|
||||
@@ -227,6 +231,34 @@ public class CompanySettingsController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Locks the books through the given date, preventing new or edited accounting entries
|
||||
/// (JEs, bills, expenses) from being dated on or before this date. Null clears the lock.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> SetPeriodLock(DateTime? lockThrough)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null) return BadRequest();
|
||||
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
|
||||
if (company == null) return NotFound();
|
||||
|
||||
company.BookLockedThrough = lockThrough.HasValue
|
||||
? DateTime.SpecifyKind(lockThrough.Value.Date, DateTimeKind.Utc)
|
||||
: null;
|
||||
company.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.Companies.UpdateAsync(company);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = lockThrough.HasValue
|
||||
? $"Books locked through {lockThrough.Value:MMMM d, yyyy}."
|
||||
: "Period lock cleared — all periods are now open.";
|
||||
|
||||
return RedirectToAction(nameof(Index), null, "company-info");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serves the current company's logo as a binary file response. Logos are stored on the filesystem
|
||||
/// via <see cref="ICompanyLogoService"/> (primary) or as raw bytes in <c>Company.LogoData</c>
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
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;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Manages the company-wide credit memo register. Credit memos reduce a customer's outstanding
|
||||
/// balance and can be issued standalone (goodwill, billing correction) or linked to an original
|
||||
/// invoice (price dispute, rework resolution). Applied portions reduce invoice BalanceDue and
|
||||
/// customer.CreditBalance atomically inside a transaction.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public class CreditMemosController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ILogger<CreditMemosController> _logger;
|
||||
|
||||
public CreditMemosController(
|
||||
IUnitOfWork unitOfWork,
|
||||
ITenantContext tenantContext,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ILogger<CreditMemosController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_tenantContext = tenantContext;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Lists all credit memos for the current company with optional status and text filters.</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Index(string? status, string? search)
|
||||
{
|
||||
var memos = await _unitOfWork.CreditMemos.FindAsync(
|
||||
m => true, false,
|
||||
m => m.Customer);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
memos = memos.Where(m =>
|
||||
DisplayName(m.Customer).Contains(search, StringComparison.OrdinalIgnoreCase) ||
|
||||
m.MemoNumber.Contains(search, StringComparison.OrdinalIgnoreCase) ||
|
||||
m.Reason.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<CreditMemoStatus>(status, out var parsed))
|
||||
memos = memos.Where(m => m.Status == parsed).ToList();
|
||||
|
||||
ViewBag.Status = status ?? "";
|
||||
ViewBag.Search = search ?? "";
|
||||
ViewBag.ActiveCount = memos.Count(m => m.Status is CreditMemoStatus.Active or CreditMemoStatus.PartiallyApplied);
|
||||
ViewBag.OutstandingBalance = memos
|
||||
.Where(m => m.Status is not CreditMemoStatus.Voided and not CreditMemoStatus.FullyApplied)
|
||||
.Sum(m => m.RemainingBalance);
|
||||
|
||||
return View(memos.OrderByDescending(m => m.IssueDate).ToList());
|
||||
}
|
||||
|
||||
/// <summary>Shows a single credit memo with its full application history and an Apply modal for open invoices.</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Details(int id)
|
||||
{
|
||||
var memo = await _unitOfWork.CreditMemos.GetByIdAsync(
|
||||
id, false,
|
||||
m => m.Customer,
|
||||
m => m.OriginalInvoice,
|
||||
m => m.IssuedBy);
|
||||
|
||||
if (memo == null) return NotFound();
|
||||
|
||||
var applications = await _unitOfWork.CreditMemoApplications.FindAsync(
|
||||
a => a.CreditMemoId == id, false,
|
||||
a => a.Invoice,
|
||||
a => a.AppliedBy);
|
||||
|
||||
var openInvoices = await _unitOfWork.Invoices.FindAsync(
|
||||
i => i.CustomerId == memo.CustomerId
|
||||
&& i.Status != InvoiceStatus.Paid
|
||||
&& i.Status != InvoiceStatus.Voided
|
||||
&& i.Status != InvoiceStatus.WrittenOff);
|
||||
|
||||
ViewBag.Applications = applications.OrderByDescending(a => a.AppliedDate).ToList();
|
||||
ViewBag.OpenInvoices = openInvoices.Where(i => i.BalanceDue > 0).OrderBy(i => i.DueDate).ToList();
|
||||
ViewBag.CanApply = memo.Status is CreditMemoStatus.Active or CreditMemoStatus.PartiallyApplied
|
||||
&& memo.RemainingBalance > 0;
|
||||
|
||||
return View(memo);
|
||||
}
|
||||
|
||||
/// <summary>Shows the standalone credit-memo creation form. Accepts optional customerId/invoiceId query params to pre-populate.</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Create(int? customerId, int? invoiceId)
|
||||
{
|
||||
string? linkedInvoiceNumber = null;
|
||||
if (invoiceId.HasValue)
|
||||
{
|
||||
var inv = await _unitOfWork.Invoices.GetByIdAsync(invoiceId.Value);
|
||||
if (inv != null)
|
||||
{
|
||||
linkedInvoiceNumber = inv.InvoiceNumber;
|
||||
customerId ??= inv.CustomerId;
|
||||
}
|
||||
}
|
||||
|
||||
await PopulateCustomersAsync(customerId);
|
||||
ViewBag.LinkedInvoiceNumber = linkedInvoiceNumber;
|
||||
|
||||
return View(new CreditMemoCreateVm
|
||||
{
|
||||
CustomerId = customerId ?? 0,
|
||||
OriginalInvoiceId = invoiceId
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a standalone credit memo and immediately increments customer.CreditBalance so the
|
||||
/// credit is visible on the customer account before it is applied to any specific invoice.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(CreditMemoCreateVm vm)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
await PopulateCustomersAsync(vm.CustomerId);
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(vm.CustomerId);
|
||||
if (customer == null)
|
||||
{
|
||||
ModelState.AddModelError("CustomerId", "Customer not found.");
|
||||
await PopulateCustomersAsync(null);
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var memoNumber = await GenerateMemoNumberAsync(companyId);
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
|
||||
var memo = new CreditMemo
|
||||
{
|
||||
MemoNumber = memoNumber,
|
||||
CustomerId = vm.CustomerId,
|
||||
OriginalInvoiceId = vm.OriginalInvoiceId > 0 ? vm.OriginalInvoiceId : null,
|
||||
Amount = vm.Amount,
|
||||
AmountApplied = 0,
|
||||
IssueDate = DateTime.UtcNow,
|
||||
ExpiryDate = vm.ExpiryDate.HasValue
|
||||
? DateTime.SpecifyKind(vm.ExpiryDate.Value, DateTimeKind.Utc)
|
||||
: null,
|
||||
Reason = vm.Reason,
|
||||
Notes = vm.Notes,
|
||||
Status = CreditMemoStatus.Active,
|
||||
IssuedById = currentUser?.Id,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _unitOfWork.CreditMemos.AddAsync(memo);
|
||||
|
||||
customer.CreditBalance += vm.Amount;
|
||||
await _unitOfWork.Customers.UpdateAsync(customer);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Credit memo {memoNumber} for {vm.Amount:C} issued to {DisplayName(customer)}.";
|
||||
return RedirectToAction(nameof(Details), new { id = memo.Id });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies a portion of this credit memo to an open invoice. The applied amount is capped at the
|
||||
/// minimum of the requested amount, the memo's RemainingBalance, and the invoice's BalanceDue —
|
||||
/// preventing over-application even with concurrent requests. Customer.CreditBalance is reduced
|
||||
/// by the same applied amount. Automatically marks the invoice Paid when BalanceDue reaches zero.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Apply(int id, int invoiceId, decimal amount)
|
||||
{
|
||||
try
|
||||
{
|
||||
var memo = await _unitOfWork.CreditMemos.GetByIdAsync(id);
|
||||
if (memo == null) return NotFound();
|
||||
|
||||
var invoice = await _unitOfWork.Invoices.GetByIdAsync(invoiceId, false, i => i.Customer);
|
||||
if (invoice == null)
|
||||
{
|
||||
TempData["Error"] = "Invoice not found.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
if (memo.Status is CreditMemoStatus.Voided or CreditMemoStatus.FullyApplied)
|
||||
{
|
||||
TempData["Error"] = "Credit memo is not available to apply.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
var applyAmount = Math.Min(amount, Math.Min(memo.RemainingBalance, invoice.BalanceDue));
|
||||
if (applyAmount <= 0)
|
||||
{
|
||||
TempData["Error"] = "No applicable amount — invoice may already be paid or credit exhausted.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
|
||||
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||||
{
|
||||
await _unitOfWork.CreditMemoApplications.AddAsync(new CreditMemoApplication
|
||||
{
|
||||
CreditMemoId = id,
|
||||
InvoiceId = invoiceId,
|
||||
AmountApplied = applyAmount,
|
||||
AppliedDate = DateTime.UtcNow,
|
||||
AppliedById = currentUser?.Id,
|
||||
CompanyId = invoice.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
|
||||
invoice.CreditApplied += applyAmount;
|
||||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||
|
||||
memo.AmountApplied += applyAmount;
|
||||
memo.Status = memo.AmountApplied >= memo.Amount
|
||||
? CreditMemoStatus.FullyApplied
|
||||
: CreditMemoStatus.PartiallyApplied;
|
||||
await _unitOfWork.CreditMemos.UpdateAsync(memo);
|
||||
|
||||
if (invoice.Customer != null)
|
||||
{
|
||||
invoice.Customer.CreditBalance = Math.Max(0, invoice.Customer.CreditBalance - applyAmount);
|
||||
await _unitOfWork.Customers.UpdateAsync(invoice.Customer);
|
||||
}
|
||||
|
||||
if (invoice.BalanceDue <= 0 && invoice.Status != InvoiceStatus.Paid)
|
||||
{
|
||||
invoice.Status = InvoiceStatus.Paid;
|
||||
invoice.PaidDate = DateTime.UtcNow;
|
||||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||
}
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
});
|
||||
|
||||
TempData["Success"] = $"{applyAmount:C} applied to invoice {invoice.InvoiceNumber}.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error applying credit memo {MemoId} to invoice {InvoiceId}", id, invoiceId);
|
||||
TempData["Error"] = "An error occurred applying the credit.";
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Voids a credit memo and reverses only the unapplied remainder from customer.CreditBalance.
|
||||
/// The portion already applied to invoices is NOT reversed — those reductions to BalanceDue are
|
||||
/// settled and form part of the immutable audit trail.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Void(int id)
|
||||
{
|
||||
var memo = await _unitOfWork.CreditMemos.GetByIdAsync(id, false, m => m.Customer);
|
||||
if (memo == null) return NotFound();
|
||||
|
||||
if (memo.Status == CreditMemoStatus.Voided)
|
||||
{
|
||||
TempData["Error"] = "Credit memo is already voided.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
var remaining = memo.Amount - memo.AmountApplied;
|
||||
memo.Status = CreditMemoStatus.Voided;
|
||||
memo.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.CreditMemos.UpdateAsync(memo);
|
||||
|
||||
if (remaining > 0 && memo.Customer != null)
|
||||
{
|
||||
memo.Customer.CreditBalance = Math.Max(0, memo.Customer.CreditBalance - remaining);
|
||||
await _unitOfWork.Customers.UpdateAsync(memo.Customer);
|
||||
}
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
TempData["Success"] = "Credit memo voided. Unapplied balance reversed from customer credit.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
private async Task PopulateCustomersAsync(int? selectedId)
|
||||
{
|
||||
var customers = await _unitOfWork.Customers.GetAllAsync();
|
||||
ViewBag.Customers = customers
|
||||
.OrderBy(c => c.CompanyName ?? $"{c.ContactFirstName} {c.ContactLastName}".Trim())
|
||||
.Select(c => new SelectListItem
|
||||
{
|
||||
Value = c.Id.ToString(),
|
||||
Text = c.IsTaxExempt ? $"{DisplayName(c)} ★" : DisplayName(c),
|
||||
Selected = c.Id == selectedId
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the next sequential memo number in CM-YYMM-#### format.
|
||||
/// Uses IgnoreQueryFilters so soft-deleted memos count, preventing number reuse.
|
||||
/// </summary>
|
||||
private async Task<string> GenerateMemoNumberAsync(int companyId)
|
||||
{
|
||||
var prefix = $"CM-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-";
|
||||
var existing = (await _unitOfWork.CreditMemos.FindAsync(
|
||||
m => m.CompanyId == companyId && m.MemoNumber.StartsWith(prefix), true))
|
||||
.Select(m => m.MemoNumber)
|
||||
.ToList();
|
||||
|
||||
var maxNum = 0;
|
||||
foreach (var num in existing)
|
||||
{
|
||||
var suffix = num.Length >= prefix.Length + 4 ? num[prefix.Length..] : "";
|
||||
if (int.TryParse(suffix, out int n) && n > maxNum)
|
||||
maxNum = n;
|
||||
}
|
||||
return $"{prefix}{(maxNum + 1):D4}";
|
||||
}
|
||||
|
||||
private static string DisplayName(Customer? c) =>
|
||||
c == null ? string.Empty :
|
||||
!string.IsNullOrWhiteSpace(c.CompanyName) ? c.CompanyName
|
||||
: $"{c.ContactFirstName} {c.ContactLastName}".Trim();
|
||||
}
|
||||
|
||||
public class CreditMemoCreateVm
|
||||
{
|
||||
[Required, Range(1, int.MaxValue, ErrorMessage = "Please select a customer.")]
|
||||
public int CustomerId { get; set; }
|
||||
|
||||
[Required, Range(0.01, 1_000_000, ErrorMessage = "Amount must be greater than $0.00.")]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
[Required, MaxLength(500, ErrorMessage = "Reason cannot exceed 500 characters.")]
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(2000)]
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public DateTime? ExpiryDate { get; set; }
|
||||
|
||||
/// <summary>Optional link to the invoice that prompted this credit (price dispute, billing error, etc.).</summary>
|
||||
public int? OriginalInvoiceId { get; set; }
|
||||
}
|
||||
@@ -26,6 +26,7 @@ public class CustomersController : Controller
|
||||
private readonly ISubscriptionService _subscriptionService;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly IFinancialReportService _financialReports;
|
||||
|
||||
public CustomersController(
|
||||
IUnitOfWork unitOfWork,
|
||||
@@ -34,7 +35,8 @@ public class CustomersController : Controller
|
||||
INotificationService notificationService,
|
||||
ISubscriptionService subscriptionService,
|
||||
ITenantContext tenantContext,
|
||||
UserManager<ApplicationUser> userManager)
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IFinancialReportService financialReports)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
@@ -43,6 +45,7 @@ public class CustomersController : Controller
|
||||
_subscriptionService = subscriptionService;
|
||||
_tenantContext = tenantContext;
|
||||
_userManager = userManager;
|
||||
_financialReports = financialReports;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -935,6 +938,30 @@ public class CustomersController : Controller
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Displays or downloads a dated activity statement for a customer.
|
||||
/// Pass <c>pdf=true</c> to download the QuestPDF version; otherwise renders the HTML view.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Statement(int id, DateTime? from, DateTime? to, bool pdf = false)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var fromDate = from ?? new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1);
|
||||
var toDate = to ?? DateTime.Today;
|
||||
|
||||
var dto = await _financialReports.GetCustomerStatementAsync(companyId, id, fromDate, toDate);
|
||||
|
||||
if (pdf)
|
||||
{
|
||||
var bytes = StatementPdfHelper.Generate(
|
||||
dto.CustomerName, dto.CompanyName, dto.CustomerAddress,
|
||||
dto.From, dto.To, dto.OpeningBalance, dto.Lines, dto.ClosingBalance, isVendor: false);
|
||||
return File(bytes, "application/pdf", $"Statement-{dto.CustomerName}-{toDate:yyyyMMdd}.pdf");
|
||||
}
|
||||
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the next sequential credit memo number in CM-YYMM-#### format.
|
||||
/// Uses <c>ignoreQueryFilters: true</c> when scanning all existing memos so that
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Manages the fixed asset register. Tracks depreciable assets (ovens, blast cabinets,
|
||||
/// vehicles, etc.) using straight-line depreciation. PostDepreciation auto-generates
|
||||
/// Journal Entries for a selected month, crediting Accumulated Depreciation and debiting
|
||||
/// Depreciation Expense.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public class FixedAssetsController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly IAccountBalanceService _accountBalanceService;
|
||||
private readonly ILogger<FixedAssetsController> _logger;
|
||||
|
||||
public FixedAssetsController(
|
||||
IUnitOfWork unitOfWork,
|
||||
ITenantContext tenantContext,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
IAccountBalanceService accountBalanceService,
|
||||
ILogger<FixedAssetsController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_tenantContext = tenantContext;
|
||||
_userManager = userManager;
|
||||
_accountBalanceService = accountBalanceService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Lists all fixed assets for the current company with depreciation summary.</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var assets = await _unitOfWork.FixedAssets.FindAsync(
|
||||
fa => true, false,
|
||||
fa => fa.AssetAccount,
|
||||
fa => fa.DepreciationExpenseAccount,
|
||||
fa => fa.AccumDepreciationAccount);
|
||||
|
||||
ViewBag.TotalCost = assets.Sum(a => a.PurchaseCost);
|
||||
ViewBag.TotalAccumDeprec = assets.Sum(a => a.AccumulatedDepreciation);
|
||||
ViewBag.TotalBookValue = assets.Sum(a => a.BookValue);
|
||||
ViewBag.ActiveCount = assets.Count(a => !a.IsDisposed);
|
||||
|
||||
return View(assets.OrderBy(a => a.PurchaseDate).ToList());
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Details(int id)
|
||||
{
|
||||
var asset = await _unitOfWork.FixedAssets.GetByIdAsync(
|
||||
id, false,
|
||||
fa => fa.AssetAccount,
|
||||
fa => fa.DepreciationExpenseAccount,
|
||||
fa => fa.AccumDepreciationAccount);
|
||||
|
||||
if (asset == null) return NotFound();
|
||||
|
||||
var entries = await _unitOfWork.FixedAssetDepreciationEntries.FindAsync(
|
||||
e => e.FixedAssetId == id, false,
|
||||
e => e.JournalEntry);
|
||||
|
||||
ViewBag.Entries = entries.OrderByDescending(e => e.PeriodYear).ThenByDescending(e => e.PeriodMonth).ToList();
|
||||
ViewBag.MonthsRemaining = Math.Max(0, asset.UsefulLifeMonths - entries.Count(e => e.Amount > 0));
|
||||
ViewBag.FullyDepreciated = asset.AccumulatedDepreciation >= (asset.PurchaseCost - asset.SalvageValue);
|
||||
|
||||
return View(asset);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
await PopulateAccountsAsync();
|
||||
return View(new FixedAssetVm { PurchaseDate = DateTime.Today });
|
||||
}
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(FixedAssetVm vm)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
await PopulateAccountsAsync();
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
var asset = new FixedAsset
|
||||
{
|
||||
Name = vm.Name,
|
||||
Description = vm.Description,
|
||||
PurchaseDate = DateTime.SpecifyKind(vm.PurchaseDate, DateTimeKind.Utc),
|
||||
PurchaseCost = vm.PurchaseCost,
|
||||
SalvageValue = vm.SalvageValue,
|
||||
UsefulLifeMonths = vm.UsefulLifeMonths,
|
||||
AccumulatedDepreciation = vm.AccumulatedDepreciation,
|
||||
AssetAccountId = vm.AssetAccountId,
|
||||
DepreciationExpenseAccountId = vm.DepreciationExpenseAccountId,
|
||||
AccumDepreciationAccountId = vm.AccumDepreciationAccountId,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _unitOfWork.FixedAssets.AddAsync(asset);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Fixed asset \"{asset.Name}\" added.";
|
||||
return RedirectToAction(nameof(Details), new { id = asset.Id });
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Edit(int id)
|
||||
{
|
||||
var asset = await _unitOfWork.FixedAssets.GetByIdAsync(id);
|
||||
if (asset == null) return NotFound();
|
||||
|
||||
await PopulateAccountsAsync();
|
||||
return View(new FixedAssetVm
|
||||
{
|
||||
Id = asset.Id,
|
||||
Name = asset.Name,
|
||||
Description = asset.Description,
|
||||
PurchaseDate = asset.PurchaseDate.ToLocalTime(),
|
||||
PurchaseCost = asset.PurchaseCost,
|
||||
SalvageValue = asset.SalvageValue,
|
||||
UsefulLifeMonths = asset.UsefulLifeMonths,
|
||||
AccumulatedDepreciation = asset.AccumulatedDepreciation,
|
||||
AssetAccountId = asset.AssetAccountId,
|
||||
DepreciationExpenseAccountId = asset.DepreciationExpenseAccountId,
|
||||
AccumDepreciationAccountId = asset.AccumDepreciationAccountId,
|
||||
IsDisposed = asset.IsDisposed,
|
||||
DisposalDate = asset.DisposalDate?.ToLocalTime()
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Edit(int id, FixedAssetVm vm)
|
||||
{
|
||||
if (id != vm.Id) return BadRequest();
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
await PopulateAccountsAsync();
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
var asset = await _unitOfWork.FixedAssets.GetByIdAsync(id);
|
||||
if (asset == null) return NotFound();
|
||||
|
||||
asset.Name = vm.Name;
|
||||
asset.Description = vm.Description;
|
||||
asset.PurchaseDate = DateTime.SpecifyKind(vm.PurchaseDate, DateTimeKind.Utc);
|
||||
asset.PurchaseCost = vm.PurchaseCost;
|
||||
asset.SalvageValue = vm.SalvageValue;
|
||||
asset.UsefulLifeMonths = vm.UsefulLifeMonths;
|
||||
asset.AccumulatedDepreciation = vm.AccumulatedDepreciation;
|
||||
asset.AssetAccountId = vm.AssetAccountId;
|
||||
asset.DepreciationExpenseAccountId = vm.DepreciationExpenseAccountId;
|
||||
asset.AccumDepreciationAccountId = vm.AccumDepreciationAccountId;
|
||||
asset.IsDisposed = vm.IsDisposed;
|
||||
asset.DisposalDate = vm.IsDisposed && vm.DisposalDate.HasValue
|
||||
? DateTime.SpecifyKind(vm.DisposalDate.Value, DateTimeKind.Utc)
|
||||
: null;
|
||||
asset.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
TempData["Success"] = $"Fixed asset \"{asset.Name}\" updated.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Posts straight-line depreciation for all active assets for the specified year/month.
|
||||
/// Skips assets that have already been depreciated for the period, assets without GL accounts,
|
||||
/// and fully-depreciated assets (BookValue ≤ SalvageValue). Creates one JE per asset.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> PostDepreciation(int year, int month)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
|
||||
var assets = await _unitOfWork.FixedAssets.FindAsync(
|
||||
fa => !fa.IsDisposed, false,
|
||||
fa => fa.DepreciationEntries);
|
||||
|
||||
int posted = 0, skipped = 0;
|
||||
var errors = new List<string>();
|
||||
|
||||
foreach (var asset in assets)
|
||||
{
|
||||
// Skip assets missing required GL accounts
|
||||
if (!asset.DepreciationExpenseAccountId.HasValue || !asset.AccumDepreciationAccountId.HasValue)
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip already posted for this period
|
||||
if (asset.DepreciationEntries.Any(e => e.PeriodYear == year && e.PeriodMonth == month && !e.IsDeleted))
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var depreciableBase = asset.PurchaseCost - asset.SalvageValue;
|
||||
var remaining = depreciableBase - asset.AccumulatedDepreciation;
|
||||
|
||||
// Skip fully depreciated assets
|
||||
if (remaining <= 0)
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Don't over-depreciate in the final period
|
||||
var amount = Math.Min(asset.MonthlyDepreciation, remaining);
|
||||
|
||||
try
|
||||
{
|
||||
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||||
{
|
||||
// GL: DR Depreciation Expense / CR Accumulated Depreciation
|
||||
await _accountBalanceService.DebitAsync(asset.DepreciationExpenseAccountId, amount);
|
||||
await _accountBalanceService.CreditAsync(asset.AccumDepreciationAccountId, amount);
|
||||
|
||||
// Post JE
|
||||
var je = new JournalEntry
|
||||
{
|
||||
EntryNumber = await GenerateJeNumberAsync(companyId),
|
||||
EntryDate = new DateTime(year, month, DateTime.DaysInMonth(year, month), 0, 0, 0, DateTimeKind.Utc),
|
||||
Description = $"Depreciation — {asset.Name} ({month:D2}/{year})",
|
||||
Reference = asset.Name,
|
||||
Status = JournalEntryStatus.Posted,
|
||||
PostedBy = currentUser?.Email,
|
||||
PostedAt = DateTime.UtcNow,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Lines = new List<JournalEntryLine>
|
||||
{
|
||||
new() { AccountId = asset.DepreciationExpenseAccountId!.Value, DebitAmount = amount, CreditAmount = 0, Description = $"Depreciation — {asset.Name}", CompanyId = companyId, CreatedAt = DateTime.UtcNow },
|
||||
new() { AccountId = asset.AccumDepreciationAccountId!.Value, DebitAmount = 0, CreditAmount = amount, Description = $"Accum. depreciation — {asset.Name}", CompanyId = companyId, CreatedAt = DateTime.UtcNow }
|
||||
}
|
||||
};
|
||||
await _unitOfWork.JournalEntries.AddAsync(je);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Record the depreciation entry
|
||||
var entry = new FixedAssetDepreciationEntry
|
||||
{
|
||||
FixedAssetId = asset.Id,
|
||||
PeriodYear = year,
|
||||
PeriodMonth = month,
|
||||
Amount = amount,
|
||||
JournalEntryId = je.Id,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
await _unitOfWork.FixedAssetDepreciationEntries.AddAsync(entry);
|
||||
|
||||
asset.AccumulatedDepreciation += amount;
|
||||
asset.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
});
|
||||
|
||||
posted++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error posting depreciation for asset {AssetId}", asset.Id);
|
||||
errors.Add($"{asset.Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.Any())
|
||||
TempData["Error"] = $"Posted {posted}, skipped {skipped}. Errors: {string.Join("; ", errors)}";
|
||||
else
|
||||
TempData["Success"] = $"Depreciation posted: {posted} asset(s) for {new DateTime(year, month, 1):MMMM yyyy}. {skipped} skipped (already posted, no GL accounts, or fully depreciated).";
|
||||
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
var asset = await _unitOfWork.FixedAssets.GetByIdAsync(id);
|
||||
if (asset == null) return NotFound();
|
||||
|
||||
var hasEntries = (await _unitOfWork.FixedAssetDepreciationEntries.FindAsync(e => e.FixedAssetId == id)).Any();
|
||||
if (hasEntries)
|
||||
{
|
||||
TempData["Error"] = "Cannot delete an asset with depreciation history. Mark it as disposed instead.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
await _unitOfWork.FixedAssets.SoftDeleteAsync(id);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
TempData["Success"] = $"Fixed asset \"{asset.Name}\" deleted.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
private async Task PopulateAccountsAsync()
|
||||
{
|
||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
|
||||
var list = accounts.OrderBy(a => a.AccountNumber).ThenBy(a => a.Name).ToList();
|
||||
|
||||
ViewBag.AssetAccounts = list
|
||||
.Where(a => a.AccountType == AccountType.Asset)
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||
.ToList();
|
||||
|
||||
ViewBag.ExpenseAccounts = list
|
||||
.Where(a => a.AccountType == AccountType.Expense)
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||
.ToList();
|
||||
|
||||
// Accumulated depreciation typically lives as a negative Asset (contra-asset)
|
||||
ViewBag.AccumDeprecAccounts = list
|
||||
.Where(a => a.AccountType is AccountType.Asset or AccountType.Expense)
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>Generates next JE number in JE-YYMM-#### format, ignoring soft-deleted entries.</summary>
|
||||
private async Task<string> GenerateJeNumberAsync(int companyId)
|
||||
{
|
||||
var prefix = $"JE-{DateTime.Now:yyMM}-";
|
||||
var all = await _unitOfWork.JournalEntries.FindAsync(
|
||||
je => je.CompanyId == companyId && je.EntryNumber.StartsWith(prefix),
|
||||
ignoreQueryFilters: true);
|
||||
int next = all.Any()
|
||||
? all.Select(je => je.EntryNumber[prefix.Length..]).Select(s => int.TryParse(s, out int n) ? n : 0).Max() + 1
|
||||
: 1;
|
||||
return $"{prefix}{next:D4}";
|
||||
}
|
||||
}
|
||||
|
||||
public class FixedAssetVm
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required, MaxLength(200)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[MaxLength(1000)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[Required]
|
||||
public DateTime PurchaseDate { get; set; } = DateTime.Today;
|
||||
|
||||
[Required, Range(0.01, double.MaxValue, ErrorMessage = "Purchase cost must be greater than zero.")]
|
||||
public decimal PurchaseCost { get; set; }
|
||||
|
||||
[Range(0, double.MaxValue)]
|
||||
public decimal SalvageValue { get; set; } = 0;
|
||||
|
||||
[Required, Range(1, 600, ErrorMessage = "Useful life must be between 1 and 600 months.")]
|
||||
public int UsefulLifeMonths { get; set; } = 60;
|
||||
|
||||
[Range(0, double.MaxValue)]
|
||||
public decimal AccumulatedDepreciation { get; set; } = 0;
|
||||
|
||||
public int? AssetAccountId { get; set; }
|
||||
public int? DepreciationExpenseAccountId { get; set; }
|
||||
public int? AccumDepreciationAccountId { get; set; }
|
||||
|
||||
public bool IsDisposed { get; set; } = false;
|
||||
public DateTime? DisposalDate { get; set; }
|
||||
}
|
||||
@@ -276,6 +276,14 @@ public class InvoicesController : Controller
|
||||
ViewBag.OnlinePaymentsEnabled = onlinePaymentsAllowed
|
||||
&& company?.StripeConnectStatus == StripeConnectStatus.Active;
|
||||
|
||||
// Expense accounts for the write-off bad-debt modal
|
||||
var expenseAccounts = await _unitOfWork.Accounts.FindAsync(
|
||||
a => a.IsActive && a.AccountType == AccountType.Expense);
|
||||
ViewBag.ExpenseAccounts = expenseAccounts
|
||||
.OrderBy(a => a.AccountNumber).ThenBy(a => a.Name)
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||
.ToList();
|
||||
|
||||
if (guidedActivation == AppConstants.GuidedActivation.InvoiceCreatedStep)
|
||||
{
|
||||
ViewBag.GuidedActivationCallout = new Web.ViewModels.GuidedActivation.GuidedActivationCalloutViewModel
|
||||
@@ -1366,6 +1374,113 @@ public class InvoicesController : Controller
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// POST: /Invoices/WriteOff/5
|
||||
// -----------------------------------------------------------------------
|
||||
/// <summary>
|
||||
/// Writes off an uncollectible invoice. Posts a GL journal entry:
|
||||
/// DR Bad Debt Expense (user-selected account) for the remaining BalanceDue
|
||||
/// CR Accounts Receivable for the same amount
|
||||
/// Then marks the invoice WrittenOff and reduces customer.CurrentBalance.
|
||||
/// Only the outstanding BalanceDue is written off; amounts already collected are unaffected.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> WriteOff(int id, int? expenseAccountId, string? notes)
|
||||
{
|
||||
try
|
||||
{
|
||||
var invoice = await LoadInvoiceForViewAsync(id);
|
||||
if (invoice == null) return NotFound();
|
||||
|
||||
if (invoice.Status is InvoiceStatus.Paid or InvoiceStatus.Voided or InvoiceStatus.WrittenOff)
|
||||
{
|
||||
TempData["Error"] = "Invoice cannot be written off in its current status.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
var balanceDue = invoice.BalanceDue;
|
||||
if (balanceDue <= 0)
|
||||
{
|
||||
TempData["Error"] = "Invoice has no outstanding balance to write off.";
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
var arAccountId = await GetArAccountIdAsync(invoice.CompanyId);
|
||||
var badDebtAccountId = expenseAccountId > 0
|
||||
? expenseAccountId
|
||||
: await GetBadDebtAccountIdAsync(invoice.CompanyId);
|
||||
|
||||
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||||
{
|
||||
// GL: DR Bad Debt Expense / CR AR
|
||||
await _accountBalanceService.DebitAsync(badDebtAccountId, balanceDue);
|
||||
await _accountBalanceService.CreditAsync(arAccountId, balanceDue);
|
||||
|
||||
// Post a supporting JE for the audit trail
|
||||
var je = new JournalEntry
|
||||
{
|
||||
EntryNumber = await GenerateJournalEntryNumberAsync(invoice.CompanyId),
|
||||
EntryDate = DateTime.UtcNow,
|
||||
Description = $"Write-off of invoice {invoice.InvoiceNumber}{(string.IsNullOrWhiteSpace(notes) ? "" : $" — {notes}")}",
|
||||
Reference = invoice.InvoiceNumber,
|
||||
Status = JournalEntryStatus.Posted,
|
||||
PostedBy = currentUser?.Email,
|
||||
PostedAt = DateTime.UtcNow,
|
||||
CompanyId = invoice.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
Lines = new List<JournalEntryLine>
|
||||
{
|
||||
new JournalEntryLine
|
||||
{
|
||||
AccountId = badDebtAccountId ?? 0,
|
||||
Description = $"Bad debt — invoice {invoice.InvoiceNumber}",
|
||||
DebitAmount = balanceDue,
|
||||
CreditAmount = 0,
|
||||
CompanyId = invoice.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new JournalEntryLine
|
||||
{
|
||||
AccountId = arAccountId ?? 0,
|
||||
Description = $"Write-off AR — invoice {invoice.InvoiceNumber}",
|
||||
DebitAmount = 0,
|
||||
CreditAmount = balanceDue,
|
||||
CompanyId = invoice.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
}
|
||||
}
|
||||
};
|
||||
await _unitOfWork.JournalEntries.AddAsync(je);
|
||||
|
||||
// Reduce customer running balance
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(invoice.CustomerId);
|
||||
if (customer != null)
|
||||
{
|
||||
customer.CurrentBalance = Math.Max(0, customer.CurrentBalance - balanceDue);
|
||||
await _unitOfWork.Customers.UpdateAsync(customer);
|
||||
}
|
||||
|
||||
invoice.Status = InvoiceStatus.WrittenOff;
|
||||
invoice.UpdatedAt = DateTime.UtcNow;
|
||||
invoice.UpdatedBy = currentUser?.Email;
|
||||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
});
|
||||
|
||||
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} written off ({balanceDue:C} posted to Bad Debt Expense).";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error writing off invoice {Id}", id);
|
||||
TempData["Error"] = "An error occurred while writing off the invoice.";
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// GET: /Invoices/DownloadPdf/5
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -1851,6 +1966,54 @@ public class InvoicesController : Controller
|
||||
return $"{prefix}{(maxNum + 1):D4}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the customer's payment terms, derived due date, and early-payment discount info
|
||||
/// for the Invoice Create form so JavaScript can auto-populate those fields on customer selection.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetCustomerPaymentTerms(int customerId)
|
||||
{
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(customerId);
|
||||
if (customer == null) return NotFound();
|
||||
|
||||
var invoiceDate = DateTime.Today;
|
||||
var dueDate = PaymentTermsParser.CalculateDueDate(customer.PaymentTerms, invoiceDate);
|
||||
var (discountPercent, discountDays) = PaymentTermsParser.ParseEarlyPaymentDiscount(customer.PaymentTerms);
|
||||
|
||||
return Json(new
|
||||
{
|
||||
paymentTerms = customer.PaymentTerms,
|
||||
dueDate = dueDate?.ToString("yyyy-MM-dd"),
|
||||
earlyPaymentDiscountPercent = discountPercent,
|
||||
earlyPaymentDiscountDays = discountDays,
|
||||
isTaxExempt = customer.IsTaxExempt
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the default active tax rate for the current company, or zero for tax-exempt customers.
|
||||
/// Called by the Invoice Create form when the customer selection changes.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetTaxRateForCustomer(int customerId)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(customerId);
|
||||
if (customer == null) return NotFound();
|
||||
|
||||
if (customer.IsTaxExempt)
|
||||
return Json(new { taxPercent = 0m, taxRateName = (string?)null });
|
||||
|
||||
var defaultRate = await _unitOfWork.TaxRates
|
||||
.FirstOrDefaultAsync(r => r.IsDefault && r.IsActive && !r.IsDeleted);
|
||||
|
||||
return Json(new
|
||||
{
|
||||
taxPercent = defaultRate?.Rate ?? 0m,
|
||||
taxRateName = defaultRate?.Name
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populates ViewBag data used by both Create GET and Create POST (on validation failure re-display):
|
||||
/// — Active customer list for the customer dropdown.
|
||||
@@ -1913,6 +2076,40 @@ public class InvoicesController : Controller
|
||||
return accounts.FirstOrDefault()?.Id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the Bad Debt Expense account for write-offs — prefers an account whose name
|
||||
/// contains "bad debt", falls back to the first active Expense-type account.
|
||||
/// </summary>
|
||||
private async Task<int?> GetBadDebtAccountIdAsync(int companyId)
|
||||
{
|
||||
var expenses = await _unitOfWork.Accounts.FindAsync(
|
||||
a => a.IsActive && a.AccountType == AccountType.Expense);
|
||||
return expenses.FirstOrDefault(a => a.Name.Contains("bad", StringComparison.OrdinalIgnoreCase)
|
||||
|| a.Name.Contains("debt", StringComparison.OrdinalIgnoreCase))?.Id
|
||||
?? expenses.FirstOrDefault()?.Id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates the next sequential JE number in JE-YYMM-#### format.
|
||||
/// Queries across soft-deleted entries to prevent reuse after deletion.
|
||||
/// </summary>
|
||||
private async Task<string> GenerateJournalEntryNumberAsync(int companyId)
|
||||
{
|
||||
var prefix = $"JE-{DateTime.Now:yyMM}-";
|
||||
var all = await _unitOfWork.JournalEntries.FindAsync(
|
||||
je => je.CompanyId == companyId && je.EntryNumber.StartsWith(prefix),
|
||||
ignoreQueryFilters: true);
|
||||
|
||||
int next = 1;
|
||||
if (all.Any())
|
||||
{
|
||||
var nums = all.Select(je => je.EntryNumber[prefix.Length..])
|
||||
.Select(s => int.TryParse(s, out int n) ? n : 0);
|
||||
next = nums.Max() + 1;
|
||||
}
|
||||
return $"{prefix}{next:D4}";
|
||||
}
|
||||
|
||||
/// <summary>Looks up the "2200 Sales Tax Payable" account for this company, or any active Liability account with "tax" in the name.</summary>
|
||||
private async Task<int?> ResolveSalesTaxAccountIdAsync(int companyId)
|
||||
{
|
||||
|
||||
@@ -167,6 +167,14 @@ public class JournalEntriesController : Controller
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
// Period lock check — block posting if the entry date falls in a locked period
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(entry.CompanyId);
|
||||
if (Web.Helpers.AccountingPeriodValidator.IsLocked(entry.EntryDate, company?.BookLockedThrough))
|
||||
{
|
||||
TempData["Error"] = Web.Helpers.AccountingPeriodValidator.LockedMessage(company!.BookLockedThrough);
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||||
{
|
||||
entry.Status = JournalEntryStatus.Posted;
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
using PowderCoating.Web.BackgroundServices;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Manages recurring transaction templates. Each template is a saved recipe that the
|
||||
/// <see cref="RecurringTransactionService"/> uses to auto-generate bills or expenses on a schedule.
|
||||
/// Bills are created as Draft for user review; Expenses are recorded immediately.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public class RecurringTemplatesController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ILogger<RecurringTemplatesController> _logger;
|
||||
|
||||
public RecurringTemplatesController(
|
||||
IUnitOfWork unitOfWork,
|
||||
ITenantContext tenantContext,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ILogger<RecurringTemplatesController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_tenantContext = tenantContext;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Index
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Lists all recurring templates for the current company, active first then by name.</summary>
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var templates = await _unitOfWork.RecurringTemplates.GetAllAsync();
|
||||
return View(templates.OrderByDescending(t => t.IsActive).ThenBy(t => t.Name).ToList());
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Create
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
await PopulateDropDownsAsync();
|
||||
return View(new RecurringTemplateViewModel { StartDate = DateTime.Today.AddDays(1) });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves a new recurring template. Serializes the type-specific fields to JSON in TemplateData.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(RecurringTemplateViewModel vm)
|
||||
{
|
||||
if (!ModelState.IsValid) { await PopulateDropDownsAsync(); return View(vm); }
|
||||
|
||||
var user = await _userManager.GetUserAsync(User);
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
var template = new RecurringTemplate
|
||||
{
|
||||
Name = vm.Name,
|
||||
TemplateType = vm.TemplateType,
|
||||
Frequency = vm.Frequency,
|
||||
IntervalCount = Math.Max(1, vm.IntervalCount),
|
||||
NextFireDate = vm.StartDate.Date,
|
||||
EndDate = vm.EndDate?.Date,
|
||||
MaxOccurrences = vm.MaxOccurrences > 0 ? vm.MaxOccurrences : null,
|
||||
IsActive = true,
|
||||
TemplateData = BuildTemplateJson(vm),
|
||||
CompanyId = companyId,
|
||||
CreatedBy = user?.Email
|
||||
};
|
||||
|
||||
await _unitOfWork.RecurringTemplates.AddAsync(template);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Recurring template \"{template.Name}\" created.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Edit
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Edit(int id)
|
||||
{
|
||||
var template = await _unitOfWork.RecurringTemplates.GetByIdAsync(id);
|
||||
if (template == null) return NotFound();
|
||||
|
||||
await PopulateDropDownsAsync();
|
||||
return View(ToViewModel(template));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates a recurring template. Re-serializes TemplateData from form values.
|
||||
/// NextFireDate is not touched here — only StartDate on first save drives it.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Edit(int id, RecurringTemplateViewModel vm)
|
||||
{
|
||||
if (id != vm.Id) return BadRequest();
|
||||
if (!ModelState.IsValid) { await PopulateDropDownsAsync(); return View(vm); }
|
||||
|
||||
var template = await _unitOfWork.RecurringTemplates.GetByIdAsync(id);
|
||||
if (template == null) return NotFound();
|
||||
|
||||
template.Name = vm.Name;
|
||||
template.TemplateType = vm.TemplateType;
|
||||
template.Frequency = vm.Frequency;
|
||||
template.IntervalCount = Math.Max(1, vm.IntervalCount);
|
||||
template.EndDate = vm.EndDate?.Date;
|
||||
template.MaxOccurrences = vm.MaxOccurrences > 0 ? vm.MaxOccurrences : null;
|
||||
template.TemplateData = BuildTemplateJson(vm);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Recurring template \"{template.Name}\" updated.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ToggleActive / Delete / GenerateNow
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Pauses or resumes a recurring template.</summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> ToggleActive(int id)
|
||||
{
|
||||
var template = await _unitOfWork.RecurringTemplates.GetByIdAsync(id);
|
||||
if (template == null) return NotFound();
|
||||
|
||||
template.IsActive = !template.IsActive;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Template \"{template.Name}\" is now {(template.IsActive ? "active" : "paused")}.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
/// <summary>Soft-deletes a recurring template.</summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
var template = await _unitOfWork.RecurringTemplates.GetByIdAsync(id);
|
||||
if (template == null) return NotFound();
|
||||
|
||||
var name = template.Name;
|
||||
await _unitOfWork.RecurringTemplates.SoftDeleteAsync(id);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Template \"{name}\" deleted.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forces immediate generation of the next occurrence and advances NextFireDate,
|
||||
/// regardless of the scheduled date. Useful for testing a new template or catching up
|
||||
/// after a configuration change.
|
||||
/// <para>
|
||||
/// INTENTIONAL EXCEPTION to the no-DbContext-in-controllers rule: this action must
|
||||
/// execute the same multi-step entity creation that <see cref="RecurringTransactionService"/>
|
||||
/// does in a background scope. Creating an inner DI scope here avoids duplicating that
|
||||
/// service's interface (which would require exposing a public synchronous Fire method on a
|
||||
/// singleton BackgroundService, which is unsafe). The scope is disposed after the action
|
||||
/// completes, so no DbContext leak occurs. This pattern mirrors how BackgroundService
|
||||
/// itself resolves scoped services.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> GenerateNow(int id)
|
||||
{
|
||||
var template = await _unitOfWork.RecurringTemplates.GetByIdAsync(id);
|
||||
if (template == null) return NotFound();
|
||||
|
||||
try
|
||||
{
|
||||
// Intentional: create a fresh DI scope so the inner DbContext is isolated from the
|
||||
// request's IUnitOfWork context. See summary above for rationale.
|
||||
using var scope = HttpContext.RequestServices.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<PowderCoating.Infrastructure.Data.ApplicationDbContext>();
|
||||
|
||||
// Reload the tracked entity from this scope's DbContext
|
||||
var tracked = await db.RecurringTemplates.FindAsync(template.Id);
|
||||
if (tracked == null) return NotFound();
|
||||
|
||||
var svc = new RecurringTransactionService(null!, _logger as ILogger<RecurringTransactionService>
|
||||
?? Microsoft.Extensions.Logging.Abstractions.NullLogger<RecurringTransactionService>.Instance);
|
||||
|
||||
// Use reflection-free approach: call directly via internal method pattern
|
||||
await FireTemplateSynchronouslyAsync(db, tracked);
|
||||
|
||||
TempData["Success"] = $"Template \"{template.Name}\" generated on demand. Next fire: {tracked.NextFireDate:MM/dd/yyyy}.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "GenerateNow failed for template {Id}.", id);
|
||||
TempData["Error"] = $"Generation failed: {ex.Message}";
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Inline implementation of the fire logic so GenerateNow doesn't need to instantiate
|
||||
/// the BackgroundService (which has a singleton scope that may not be accessible here).
|
||||
/// </summary>
|
||||
private static async Task FireTemplateSynchronouslyAsync(
|
||||
PowderCoating.Infrastructure.Data.ApplicationDbContext db,
|
||||
RecurringTemplate template)
|
||||
{
|
||||
if (template.TemplateType == RecurringTemplateType.Bill)
|
||||
{
|
||||
var data = JsonSerializer.Deserialize<RecurringTransactionService.BillTemplateData>(template.TemplateData)
|
||||
?? throw new InvalidOperationException("Invalid bill template data.");
|
||||
|
||||
var prefix = $"BILL-{DateTime.Now:yyMM}-";
|
||||
var lastBill = await db.Bills
|
||||
.IgnoreQueryFilters()
|
||||
.Where(b => b.BillNumber.StartsWith(prefix))
|
||||
.OrderByDescending(b => b.BillNumber)
|
||||
.Select(b => b.BillNumber)
|
||||
.FirstOrDefaultAsync();
|
||||
int nextN = 1;
|
||||
if (lastBill != null && int.TryParse(lastBill[prefix.Length..], out int ln)) nextN = ln + 1;
|
||||
|
||||
var bill = new Bill
|
||||
{
|
||||
BillNumber = $"{prefix}{nextN:D4}",
|
||||
VendorId = data.VendorId,
|
||||
APAccountId = data.APAccountId,
|
||||
BillDate = DateTime.UtcNow,
|
||||
Status = BillStatus.Draft,
|
||||
Terms = data.Terms,
|
||||
Memo = $"[Recurring] {data.Memo}".Trim(),
|
||||
SubTotal = data.LineItems?.Sum(l => l.Quantity * l.UnitPrice) ?? 0,
|
||||
TaxPercent = data.TaxPercent,
|
||||
CompanyId = template.CompanyId,
|
||||
CreatedBy = "Recurring (on-demand)",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
bill.TaxAmount = Math.Round(bill.SubTotal * bill.TaxPercent / 100, 2);
|
||||
bill.Total = bill.SubTotal + bill.TaxAmount;
|
||||
db.Bills.Add(bill);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
int order = 1;
|
||||
foreach (var line in data.LineItems ?? [])
|
||||
{
|
||||
db.BillLineItems.Add(new BillLineItem
|
||||
{
|
||||
BillId = bill.Id,
|
||||
AccountId = line.AccountId,
|
||||
Description = line.Description,
|
||||
Quantity = line.Quantity,
|
||||
UnitPrice = line.UnitPrice,
|
||||
Amount = Math.Round(line.Quantity * line.UnitPrice, 2),
|
||||
DisplayOrder = order++,
|
||||
CompanyId = template.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var data = JsonSerializer.Deserialize<RecurringTransactionService.ExpenseTemplateData>(template.TemplateData)
|
||||
?? throw new InvalidOperationException("Invalid expense template data.");
|
||||
|
||||
var prefix = $"EXP-{DateTime.Now:yyMM}-";
|
||||
var lastExp = await db.Expenses
|
||||
.IgnoreQueryFilters()
|
||||
.Where(e => e.ExpenseNumber.StartsWith(prefix))
|
||||
.OrderByDescending(e => e.ExpenseNumber)
|
||||
.Select(e => e.ExpenseNumber)
|
||||
.FirstOrDefaultAsync();
|
||||
int nextN = 1;
|
||||
if (lastExp != null && int.TryParse(lastExp[prefix.Length..], out int ln)) nextN = ln + 1;
|
||||
|
||||
db.Expenses.Add(new Expense
|
||||
{
|
||||
ExpenseNumber = $"{prefix}{nextN:D4}",
|
||||
Date = DateTime.UtcNow,
|
||||
VendorId = data.VendorId == 0 ? null : data.VendorId,
|
||||
ExpenseAccountId = data.ExpenseAccountId,
|
||||
PaymentAccountId = data.PaymentAccountId,
|
||||
PaymentMethod = (PaymentMethod)data.PaymentMethod,
|
||||
Amount = data.Amount,
|
||||
Memo = $"[Recurring] {data.Memo}".Trim(),
|
||||
CompanyId = template.CompanyId,
|
||||
CreatedBy = "Recurring (on-demand)",
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
template.OccurrenceCount++;
|
||||
template.NextFireDate = AdvanceDate(template.NextFireDate, template.Frequency, template.IntervalCount);
|
||||
template.LastError = null;
|
||||
|
||||
if (template.MaxOccurrences.HasValue && template.OccurrenceCount >= template.MaxOccurrences.Value)
|
||||
template.IsActive = false;
|
||||
else if (template.EndDate.HasValue && template.NextFireDate.Date > template.EndDate.Value.Date)
|
||||
template.IsActive = false;
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static DateTime AdvanceDate(DateTime date, RecurringFrequency freq, int interval) => freq switch
|
||||
{
|
||||
RecurringFrequency.Daily => date.AddDays(interval),
|
||||
RecurringFrequency.Weekly => date.AddDays(7 * interval),
|
||||
RecurringFrequency.BiWeekly => date.AddDays(14 * interval),
|
||||
RecurringFrequency.Monthly => date.AddMonths(interval),
|
||||
RecurringFrequency.Quarterly => date.AddMonths(3 * interval),
|
||||
RecurringFrequency.Annually => date.AddYears(interval),
|
||||
_ => date.AddMonths(interval)
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Serializes type-specific form fields to the TemplateData JSON blob.
|
||||
/// Only bill or expense fields are included — unused type's fields are discarded.
|
||||
/// </summary>
|
||||
private static string BuildTemplateJson(RecurringTemplateViewModel vm)
|
||||
{
|
||||
if (vm.TemplateType == RecurringTemplateType.Bill)
|
||||
{
|
||||
var data = new RecurringTransactionService.BillTemplateData(
|
||||
VendorId: vm.BillVendorId ?? 0,
|
||||
APAccountId: vm.APAccountId ?? 0,
|
||||
Terms: vm.BillTerms,
|
||||
Memo: vm.BillMemo,
|
||||
TaxPercent: vm.BillTaxPercent,
|
||||
LineItems: vm.LineItems.Select(l => new RecurringTransactionService.BillLineData(
|
||||
AccountId: l.AccountId,
|
||||
Description: l.Description ?? string.Empty,
|
||||
Quantity: l.Quantity == 0 ? 1 : l.Quantity,
|
||||
UnitPrice: l.UnitPrice)).ToList());
|
||||
return JsonSerializer.Serialize(data);
|
||||
}
|
||||
else
|
||||
{
|
||||
var data = new RecurringTransactionService.ExpenseTemplateData(
|
||||
VendorId: vm.ExpenseVendorId ?? 0,
|
||||
ExpenseAccountId: vm.ExpenseAccountId ?? 0,
|
||||
PaymentAccountId: vm.PaymentAccountId ?? 0,
|
||||
PaymentMethod: (int)(vm.ExpensePaymentMethod ?? PaymentMethod.Cash),
|
||||
Amount: vm.ExpenseAmount,
|
||||
Memo: vm.ExpenseMemo);
|
||||
return JsonSerializer.Serialize(data);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Converts a saved <see cref="RecurringTemplate"/> entity back to the form ViewModel.</summary>
|
||||
private static RecurringTemplateViewModel ToViewModel(RecurringTemplate t)
|
||||
{
|
||||
var vm = new RecurringTemplateViewModel
|
||||
{
|
||||
Id = t.Id,
|
||||
Name = t.Name,
|
||||
TemplateType = t.TemplateType,
|
||||
Frequency = t.Frequency,
|
||||
IntervalCount = t.IntervalCount,
|
||||
StartDate = t.NextFireDate,
|
||||
EndDate = t.EndDate,
|
||||
MaxOccurrences = t.MaxOccurrences
|
||||
};
|
||||
|
||||
if (t.TemplateType == RecurringTemplateType.Bill)
|
||||
{
|
||||
var data = JsonSerializer.Deserialize<RecurringTransactionService.BillTemplateData>(t.TemplateData);
|
||||
if (data != null)
|
||||
{
|
||||
vm.BillVendorId = data.VendorId;
|
||||
vm.APAccountId = data.APAccountId;
|
||||
vm.BillTerms = data.Terms;
|
||||
vm.BillMemo = data.Memo;
|
||||
vm.BillTaxPercent = data.TaxPercent;
|
||||
vm.LineItems = (data.LineItems ?? []).Select(l => new LineItemInput
|
||||
{
|
||||
AccountId = l.AccountId,
|
||||
Description = l.Description,
|
||||
Quantity = l.Quantity,
|
||||
UnitPrice = l.UnitPrice
|
||||
}).ToList();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var data = JsonSerializer.Deserialize<RecurringTransactionService.ExpenseTemplateData>(t.TemplateData);
|
||||
if (data != null)
|
||||
{
|
||||
vm.ExpenseVendorId = data.VendorId == 0 ? null : data.VendorId;
|
||||
vm.ExpenseAccountId = data.ExpenseAccountId;
|
||||
vm.PaymentAccountId = data.PaymentAccountId;
|
||||
vm.ExpensePaymentMethod = (PaymentMethod)data.PaymentMethod;
|
||||
vm.ExpenseAmount = data.Amount;
|
||||
vm.ExpenseMemo = data.Memo;
|
||||
}
|
||||
}
|
||||
|
||||
return vm;
|
||||
}
|
||||
|
||||
/// <summary>Loads dropdowns for vendors, accounts, and payment methods into ViewBag.</summary>
|
||||
private async Task PopulateDropDownsAsync()
|
||||
{
|
||||
var vendors = await _unitOfWork.Vendors.GetAllAsync();
|
||||
ViewBag.Vendors = vendors.OrderBy(v => v.CompanyName)
|
||||
.Select(v => new SelectListItem(v.CompanyName, v.Id.ToString())).ToList();
|
||||
|
||||
var accounts = await _unitOfWork.Accounts.GetAllAsync();
|
||||
ViewBag.APAccounts = accounts
|
||||
.Where(a => a.AccountSubType == AccountSubType.AccountsPayable)
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} - {a.Name}", a.Id.ToString())).ToList();
|
||||
|
||||
ViewBag.ExpenseAccounts = accounts
|
||||
.Where(a => a.AccountType is AccountType.Expense or AccountType.CostOfGoods)
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} - {a.Name}", a.Id.ToString())).ToList();
|
||||
|
||||
ViewBag.BankAccounts = accounts
|
||||
.Where(a => a.AccountSubType is AccountSubType.Checking or AccountSubType.Savings
|
||||
or AccountSubType.Cash or AccountSubType.CreditCard)
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} - {a.Name}", a.Id.ToString())).ToList();
|
||||
|
||||
ViewBag.AllExpenseAccounts = accounts
|
||||
.Where(a => a.AccountType is AccountType.Expense or AccountType.CostOfGoods or AccountType.Asset)
|
||||
.OrderBy(a => a.AccountNumber)
|
||||
.Select(a => new SelectListItem($"{a.AccountNumber} - {a.Name}", a.Id.ToString())).ToList();
|
||||
|
||||
ViewBag.PaymentMethods = Enum.GetValues<PaymentMethod>()
|
||||
.Select(m => new SelectListItem(m.ToString(), ((int)m).ToString())).ToList();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// ViewModel
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Flat form model for Create and Edit. Type-specific sections are shown/hidden by JS.</summary>
|
||||
public class RecurringTemplateViewModel
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
[System.ComponentModel.DataAnnotations.Required]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public RecurringTemplateType TemplateType { get; set; } = RecurringTemplateType.Bill;
|
||||
public RecurringFrequency Frequency { get; set; } = RecurringFrequency.Monthly;
|
||||
public int IntervalCount { get; set; } = 1;
|
||||
public DateTime StartDate { get; set; } = DateTime.Today.AddDays(1);
|
||||
public DateTime? EndDate { get; set; }
|
||||
public int? MaxOccurrences { get; set; }
|
||||
|
||||
// Bill-specific
|
||||
public int? BillVendorId { get; set; }
|
||||
public int? APAccountId { get; set; }
|
||||
public string? BillTerms { get; set; }
|
||||
public string? BillMemo { get; set; }
|
||||
public decimal BillTaxPercent { get; set; }
|
||||
public List<LineItemInput> LineItems { get; set; } = [new()];
|
||||
|
||||
// Expense-specific
|
||||
public int? ExpenseVendorId { get; set; }
|
||||
public int? ExpenseAccountId { get; set; }
|
||||
public int? PaymentAccountId { get; set; }
|
||||
public PaymentMethod? ExpensePaymentMethod { get; set; } = PaymentMethod.Cash;
|
||||
public decimal ExpenseAmount { get; set; }
|
||||
public string? ExpenseMemo { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A single bill line item in the recurring template form.</summary>
|
||||
public class LineItemInput
|
||||
{
|
||||
public int? AccountId { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public decimal Quantity { get; set; } = 1;
|
||||
public decimal UnitPrice { get; set; }
|
||||
}
|
||||
@@ -1255,6 +1255,42 @@ public class ReportsController : Controller
|
||||
: File(pdfBytes, "application/pdf", $"TrialBalance-{asOfDate:yyyyMMdd}.pdf");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cash Flow Statement — shows cash receipts from customers, cash payments to vendors and
|
||||
/// for direct expenses, and a summary of beginning/ending cash position. Uses the direct
|
||||
/// (cash-basis) method for operating activities so the numbers reflect actual cash movement
|
||||
/// regardless of the company's accrual vs cash accounting preference.
|
||||
/// Gated behind <see cref="AllowAccounting"/>.
|
||||
/// </summary>
|
||||
// GET: /Reports/CashFlowStatement
|
||||
public async Task<IActionResult> CashFlowStatement(DateTime? from, DateTime? to)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
|
||||
var toDate = (to ?? DateTime.Today).Date;
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||
var dto = await _financialReports.GetCashFlowStatementAsync(companyId, fromDate, toDate);
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// PDF export of the Cash Flow Statement. Same inline/attachment pattern as other PDF actions.
|
||||
/// Gated behind <see cref="AllowAccounting"/>.
|
||||
/// </summary>
|
||||
// GET: /Reports/CashFlowStatementPdf
|
||||
public async Task<IActionResult> CashFlowStatementPdf(DateTime? from, DateTime? to, bool inline = false)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
|
||||
var toDate = (to ?? DateTime.Today).Date;
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||
var dto = await _financialReports.GetCashFlowStatementAsync(companyId, fromDate, toDate);
|
||||
var pdfBytes = await _pdfService.GenerateCashFlowStatementPdfAsync(dto);
|
||||
return inline
|
||||
? File(pdfBytes, "application/pdf")
|
||||
: File(pdfBytes, "application/pdf", $"CashFlow-{fromDate:yyyyMMdd}-{toDate:yyyyMMdd}.pdf");
|
||||
}
|
||||
|
||||
// ── INDIVIDUAL REPORT PAGES ──────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
@@ -2082,6 +2118,153 @@ 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
|
||||
/// for the selected calendar year. Flags vendors that exceed the $600 reporting threshold.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> TaxReporting1099(int? year)
|
||||
{
|
||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||
|
||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var _rcid) ? _rcid : 0;
|
||||
var reportYear = year ?? DateTime.Now.Year;
|
||||
|
||||
var periodStart = new DateTime(reportYear, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var periodEnd = new DateTime(reportYear, 12, 31, 23, 59, 59, DateTimeKind.Utc);
|
||||
|
||||
// Load 1099-eligible vendors
|
||||
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.Is1099Vendor)).ToList();
|
||||
|
||||
var rows = new List<Vendor1099Row>();
|
||||
|
||||
foreach (var vendor in vendors)
|
||||
{
|
||||
// Sum bills paid (using bill payment records) within the year
|
||||
var bills = await _unitOfWork.Bills.FindAsync(
|
||||
b => b.VendorId == vendor.Id,
|
||||
false,
|
||||
b => b.Payments);
|
||||
|
||||
decimal billPaid = bills
|
||||
.SelectMany(b => b.Payments)
|
||||
.Where(p => p.PaymentDate >= periodStart && p.PaymentDate <= periodEnd)
|
||||
.Sum(p => p.Amount);
|
||||
|
||||
// Sum direct expenses for this vendor within the year
|
||||
var expenses = await _unitOfWork.Expenses.FindAsync(
|
||||
e => e.VendorId == vendor.Id && e.Date >= periodStart && e.Date <= periodEnd);
|
||||
|
||||
decimal expensePaid = expenses.Sum(e => e.Amount);
|
||||
|
||||
var total = billPaid + expensePaid;
|
||||
|
||||
rows.Add(new Vendor1099Row
|
||||
{
|
||||
VendorId = vendor.Id,
|
||||
VendorName = vendor.CompanyName,
|
||||
TaxId = vendor.TaxId,
|
||||
Address = string.Join(", ", new[] { vendor.Address, vendor.City, vendor.State, vendor.ZipCode }
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))),
|
||||
BillsPaid = billPaid,
|
||||
ExpensesPaid = expensePaid,
|
||||
TotalPaid = total,
|
||||
NeedsForm = total >= 600m
|
||||
});
|
||||
}
|
||||
|
||||
ViewBag.ReportYear = reportYear;
|
||||
ViewBag.AvailableYears = Enumerable.Range(DateTime.Now.Year - 5, 6).OrderDescending().ToList();
|
||||
ViewBag.VendorsOver600 = rows.Count(r => r.NeedsForm);
|
||||
|
||||
return View(rows.OrderByDescending(r => r.TotalPaid).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up the current tenant's company name from the CompanyId claim. Used to inject the
|
||||
/// company name into AI prompts so the generated text refers to the actual business, not a
|
||||
@@ -2209,3 +2392,29 @@ 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; }
|
||||
public string VendorName { get; set; } = string.Empty;
|
||||
public string? TaxId { get; set; }
|
||||
public string? Address { get; set; }
|
||||
public decimal BillsPaid { get; set; }
|
||||
public decimal ExpensesPaid { get; set; }
|
||||
public decimal TotalPaid { get; set; }
|
||||
public bool NeedsForm { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Manages named tax rates used to pre-fill the tax percent field on invoices when a taxable
|
||||
/// customer is selected. Only one rate may be marked as default at a time; that default is
|
||||
/// auto-applied via the GetTaxRateForCustomer AJAX endpoint on the Invoice Create form.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||
public class TaxRatesController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly ILogger<TaxRatesController> _logger;
|
||||
|
||||
public TaxRatesController(
|
||||
IUnitOfWork unitOfWork,
|
||||
ITenantContext tenantContext,
|
||||
ILogger<TaxRatesController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_tenantContext = tenantContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>Lists all tax rates for the current company.</summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var rates = await _unitOfWork.TaxRates.GetAllAsync();
|
||||
return View(rates.OrderBy(r => r.Name).ToList());
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public IActionResult Create() => View(new TaxRate());
|
||||
|
||||
/// <summary>Creates a new tax rate. Enforces that only one rate is the default.</summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(TaxRate model)
|
||||
{
|
||||
if (!ModelState.IsValid) return View(model);
|
||||
|
||||
if (model.IsDefault)
|
||||
await ClearOtherDefaultsAsync(0);
|
||||
|
||||
await _unitOfWork.TaxRates.AddAsync(model);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Tax rate \"{model.Name}\" created.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Edit(int id)
|
||||
{
|
||||
var rate = await _unitOfWork.TaxRates.GetByIdAsync(id);
|
||||
if (rate == null) return NotFound();
|
||||
return View(rate);
|
||||
}
|
||||
|
||||
/// <summary>Updates an existing tax rate. Clears default flag on other rates when IsDefault is set.</summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Edit(int id, TaxRate model)
|
||||
{
|
||||
if (id != model.Id) return BadRequest();
|
||||
if (!ModelState.IsValid) return View(model);
|
||||
|
||||
var rate = await _unitOfWork.TaxRates.GetByIdAsync(id);
|
||||
if (rate == null) return NotFound();
|
||||
|
||||
if (model.IsDefault && !rate.IsDefault)
|
||||
await ClearOtherDefaultsAsync(id);
|
||||
|
||||
rate.Name = model.Name;
|
||||
rate.Rate = model.Rate;
|
||||
rate.State = model.State;
|
||||
rate.Description = model.Description;
|
||||
rate.IsDefault = model.IsDefault;
|
||||
rate.IsActive = model.IsActive;
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Tax rate \"{rate.Name}\" updated.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
/// <summary>Toggles IsActive without a full page reload.</summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> ToggleActive(int id)
|
||||
{
|
||||
var rate = await _unitOfWork.TaxRates.GetByIdAsync(id);
|
||||
if (rate == null) return NotFound();
|
||||
|
||||
rate.IsActive = !rate.IsActive;
|
||||
if (!rate.IsActive) rate.IsDefault = false;
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
/// <summary>Soft-deletes a tax rate. Blocked when the rate is currently the default.</summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
var rate = await _unitOfWork.TaxRates.GetByIdAsync(id);
|
||||
if (rate == null) return NotFound();
|
||||
|
||||
if (rate.IsDefault)
|
||||
{
|
||||
TempData["Error"] = "Cannot delete the default tax rate. Set another rate as default first.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
await _unitOfWork.TaxRates.SoftDeleteAsync(id);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = $"Tax rate \"{rate.Name}\" deleted.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears IsDefault on all rates except the one with <paramref name="exceptId"/>.
|
||||
/// Called before saving a newly-designated default to enforce the single-default invariant.
|
||||
/// </summary>
|
||||
private async Task ClearOtherDefaultsAsync(int exceptId)
|
||||
{
|
||||
var others = await _unitOfWork.TaxRates.FindAsync(r => r.IsDefault && r.Id != exceptId);
|
||||
foreach (var r in others)
|
||||
r.IsDefault = false;
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,11 @@ using Microsoft.AspNetCore.Mvc.Rendering;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.DTOs.Common;
|
||||
using PowderCoating.Application.DTOs.Vendor;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Web.Helpers;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
|
||||
@@ -27,17 +29,23 @@ public class VendorsController : Controller
|
||||
private readonly IMapper _mapper;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly ILogger<VendorsController> _logger;
|
||||
private readonly IFinancialReportService _financialReports;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
|
||||
public VendorsController(
|
||||
IUnitOfWork unitOfWork,
|
||||
IMapper mapper,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
ILogger<VendorsController> logger)
|
||||
ILogger<VendorsController> logger,
|
||||
IFinancialReportService financialReports,
|
||||
ITenantContext tenantContext)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
_userManager = userManager;
|
||||
_logger = logger;
|
||||
_financialReports = financialReports;
|
||||
_tenantContext = tenantContext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -377,6 +385,29 @@ public class VendorsController : Controller
|
||||
|
||||
/// <summary>
|
||||
/// Populates <c>ViewBag.ExpenseAccounts</c> with active Expense, Cost of Goods, and Asset accounts
|
||||
/// <summary>
|
||||
/// Displays or downloads a dated activity statement for a vendor.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Statement(int id, DateTime? from, DateTime? to, bool pdf = false)
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var fromDate = from ?? new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1);
|
||||
var toDate = to ?? DateTime.Today;
|
||||
|
||||
var dto = await _financialReports.GetVendorStatementAsync(companyId, id, fromDate, toDate);
|
||||
|
||||
if (pdf)
|
||||
{
|
||||
var bytes = StatementPdfHelper.Generate(
|
||||
dto.VendorName, dto.CompanyName, null,
|
||||
dto.From, dto.To, dto.OpeningBalance, dto.Lines, dto.ClosingBalance, isVendor: true);
|
||||
return File(bytes, "application/pdf", $"Statement-{dto.VendorName}-{toDate:yyyyMMdd}.pdf");
|
||||
}
|
||||
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
/// for the vendor's default expense account dropdown. All three account types are included because
|
||||
/// vendor bills can legitimately be coded to COGS (powder, materials) or asset accounts (equipment
|
||||
/// purchases) in addition to regular operating expenses. A "— None —" placeholder is prepended so
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace PowderCoating.Web.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether an accounting entry date falls within a locked period.
|
||||
/// The lock date is stored on the Company as BookLockedThrough and is set by CompanyAdmin
|
||||
/// users to prevent backdating entries into periods that have already been closed.
|
||||
/// </summary>
|
||||
public static class AccountingPeriodValidator
|
||||
{
|
||||
/// <summary>Returns true when the entry date is on or before the lock date (period is closed).</summary>
|
||||
public static bool IsLocked(DateTime entryDate, DateTime? lockedThrough) =>
|
||||
lockedThrough.HasValue && entryDate.Date <= lockedThrough.Value.Date;
|
||||
|
||||
/// <summary>User-facing message to display when a write is blocked by period locking.</summary>
|
||||
public static string LockedMessage(DateTime? lockedThrough) =>
|
||||
$"This period is locked — books are closed through {lockedThrough!.Value:MMMM d, yyyy}. " +
|
||||
"Unlock the period in Company Settings → Accounting to make changes.";
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace PowderCoating.Web.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Parses payment terms strings (e.g., "Net 30", "2/10 Net 30", "Due on Receipt")
|
||||
/// to compute due dates and extract early-payment discount terms.
|
||||
/// </summary>
|
||||
public static class PaymentTermsParser
|
||||
{
|
||||
private static readonly Regex NetDaysRegex = new(@"\bnet\s+(\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
private static readonly Regex EarlyDiscountRegex = new(@"(\d+(?:\.\d+)?)/(\d+)\s+net", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the due date calculated from <paramref name="invoiceDate"/> and the supplied terms.
|
||||
/// Returns null when the terms string cannot be parsed.
|
||||
/// </summary>
|
||||
public static DateTime? CalculateDueDate(string? terms, DateTime invoiceDate)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(terms)) return null;
|
||||
|
||||
var normalized = terms.Trim().ToLowerInvariant();
|
||||
if (Regex.IsMatch(normalized, @"\b(receipt|due\s*now|cod|immediate)\b"))
|
||||
return invoiceDate;
|
||||
|
||||
var match = NetDaysRegex.Match(terms);
|
||||
if (match.Success && int.TryParse(match.Groups[1].Value, out var days))
|
||||
return invoiceDate.AddDays(days);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts early-payment discount terms from a string like "2/10 Net 30".
|
||||
/// Returns (percent: 2, days: 10) for that example, or (0, 0) if not present.
|
||||
/// </summary>
|
||||
public static (decimal Percent, int Days) ParseEarlyPaymentDiscount(string? terms)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(terms)) return (0, 0);
|
||||
|
||||
var match = EarlyDiscountRegex.Match(terms);
|
||||
if (match.Success
|
||||
&& decimal.TryParse(match.Groups[1].Value, out var percent)
|
||||
&& int.TryParse(match.Groups[2].Value, out var days))
|
||||
return (percent, days);
|
||||
|
||||
return (0, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using PowderCoating.Application.DTOs.Accounting;
|
||||
using QuestPDF.Fluent;
|
||||
using QuestPDF.Helpers;
|
||||
using QuestPDF.Infrastructure;
|
||||
|
||||
namespace PowderCoating.Web.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Generates a QuestPDF account-activity statement. Shared by customer and vendor statement actions.
|
||||
/// </summary>
|
||||
public static class StatementPdfHelper
|
||||
{
|
||||
/// <summary>Generates and returns the raw PDF bytes for a statement.</summary>
|
||||
public static byte[] Generate(
|
||||
string entityName,
|
||||
string companyName,
|
||||
string? address,
|
||||
DateTime from,
|
||||
DateTime to,
|
||||
decimal openingBalance,
|
||||
List<StatementLineDto> lines,
|
||||
decimal closingBalance,
|
||||
bool isVendor)
|
||||
{
|
||||
var doc = Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Margin(40);
|
||||
page.Size(PageSizes.Letter);
|
||||
page.DefaultTextStyle(t => t.FontSize(10));
|
||||
|
||||
page.Header().Element(h =>
|
||||
{
|
||||
h.Column(col =>
|
||||
{
|
||||
col.Item().Text(companyName).Bold().FontSize(16);
|
||||
col.Item().Text($"{(isVendor ? "Vendor" : "Customer")} Statement").FontSize(12).FontColor("#555555");
|
||||
col.Item().Text($"Period: {from:MMM d, yyyy} – {to:MMM d, yyyy}");
|
||||
col.Item().PaddingTop(4).Text(entityName).Bold();
|
||||
if (!string.IsNullOrWhiteSpace(address))
|
||||
col.Item().Text(address).FontColor("#555555");
|
||||
});
|
||||
});
|
||||
|
||||
page.Content().PaddingTop(16).Column(col =>
|
||||
{
|
||||
col.Item().Table(t =>
|
||||
{
|
||||
t.ColumnsDefinition(c =>
|
||||
{
|
||||
c.ConstantColumn(72); // Date
|
||||
c.RelativeColumn(2); // Type / Ref
|
||||
c.RelativeColumn(3); // Description
|
||||
c.ConstantColumn(80); // Debit
|
||||
c.ConstantColumn(80); // Credit
|
||||
c.ConstantColumn(90); // Balance
|
||||
});
|
||||
|
||||
static IContainer HeaderCell(IContainer c) =>
|
||||
c.Background("#2c3e50").PaddingVertical(4).PaddingHorizontal(6);
|
||||
|
||||
t.Header(h =>
|
||||
{
|
||||
h.Cell().Element(HeaderCell).Text("Date").FontColor("white").Bold();
|
||||
h.Cell().Element(HeaderCell).Text("Type / Ref").FontColor("white").Bold();
|
||||
h.Cell().Element(HeaderCell).Text("Description").FontColor("white").Bold();
|
||||
h.Cell().Element(HeaderCell).AlignRight().Text("Debit").FontColor("white").Bold();
|
||||
h.Cell().Element(HeaderCell).AlignRight().Text("Credit").FontColor("white").Bold();
|
||||
h.Cell().Element(HeaderCell).AlignRight().Text("Balance").FontColor("white").Bold();
|
||||
});
|
||||
|
||||
static IContainer DataCell(IContainer c) =>
|
||||
c.BorderBottom(0.5f).BorderColor("#dddddd").PaddingVertical(3).PaddingHorizontal(6);
|
||||
|
||||
// Opening balance row
|
||||
t.Cell().Element(DataCell).Text(from.AddDays(-1).ToString("MM/dd/yy")).FontColor("#888888");
|
||||
t.Cell().Element(DataCell).Text("Opening Balance").Bold();
|
||||
t.Cell().Element(DataCell).Text("");
|
||||
t.Cell().Element(DataCell).Text("");
|
||||
t.Cell().Element(DataCell).Text("");
|
||||
t.Cell().Element(DataCell).AlignRight().Text(openingBalance.ToString("C"));
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
t.Cell().Element(DataCell).Text(line.Date.ToString("MM/dd/yy"));
|
||||
t.Cell().Element(DataCell).Text($"{line.Type}\n{line.Reference}");
|
||||
t.Cell().Element(DataCell).Text(line.Description);
|
||||
t.Cell().Element(DataCell).AlignRight().Text(line.Debit.HasValue ? line.Debit.Value.ToString("C") : "");
|
||||
t.Cell().Element(DataCell).AlignRight().Text(line.Credit.HasValue ? line.Credit.Value.ToString("C") : "");
|
||||
t.Cell().Element(DataCell).AlignRight().Text(line.RunningBalance.ToString("C"));
|
||||
}
|
||||
|
||||
static IContainer TotalCell(IContainer c) =>
|
||||
c.Background("#f0f4f8").PaddingVertical(4).PaddingHorizontal(6);
|
||||
|
||||
t.Cell().ColumnSpan(5).Element(TotalCell).Text("Closing Balance").Bold();
|
||||
t.Cell().Element(TotalCell).AlignRight().Text(closingBalance.ToString("C")).Bold();
|
||||
});
|
||||
});
|
||||
|
||||
page.Footer().AlignCenter().Text(t =>
|
||||
{
|
||||
t.Span($"Generated {DateTime.Now:MM/dd/yyyy HH:mm}").FontSize(8).FontColor("#888888");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return doc.GeneratePdf();
|
||||
}
|
||||
}
|
||||
@@ -239,6 +239,7 @@ builder.Services.AddHostedService<SubscriptionExpiryBackgroundService>();
|
||||
builder.Services.AddHostedService<AuditLogRetentionBackgroundService>();
|
||||
builder.Services.AddHostedService<StripeWebhookRetentionBackgroundService>();
|
||||
builder.Services.AddHostedService<SetupWizardReminderBackgroundService>();
|
||||
builder.Services.AddHostedService<RecurringTransactionService>();
|
||||
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
||||
builder.Services.AddScoped<IStripeService, StripeService>();
|
||||
builder.Services.AddScoped<IStripeConnectService, StripeConnectService>();
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
@model PowderCoating.Application.DTOs.Accounting.CreateAccountDto
|
||||
@model PowderCoating.Application.DTOs.Accounting.CreateAccountDto
|
||||
@using PowderCoating.Core.Enums
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "New Account";
|
||||
ViewData["PageIcon"] = "bi-journal-plus";
|
||||
ViewData["PageHelpTitle"] = "New Account";
|
||||
ViewData["PageHelpContent"] = "Add a custom account to the Chart of Accounts. Select a Sub-Type first — it auto-sets the Account Type. Use conventional numbering: 1000s = Assets, 2000s = Liabilities, 3000s = Equity, 4000s = Revenue, 5000s = Cost of Goods, 6000s+ = Expenses.";
|
||||
ViewData["PageHelpContent"] = "Add a custom account to the Chart of Accounts. Select a Sub-Type first — it auto-sets the Account Type. Use conventional numbering: 1000s = Assets, 2000s = Liabilities, 3000s = Equity, 4000s = Revenue, 5000s = Cost of Goods, 6000s+ = Expenses.";
|
||||
bool isInline = ViewBag.Inline == true;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
}
|
||||
<form asp-action="Create" method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
<div asp-validation-summary="ModelOnly" class="alert alert-danger mb-3"></div>
|
||||
<div asp-validation-summary="ModelOnly" class="alert alert-danger alert-permanent mb-3"></div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-sm-4">
|
||||
@@ -31,7 +31,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Account Number"
|
||||
data-bs-content="A numeric code for sorting and organizing accounts. Convention: 1000–1999 Assets, 2000–2999 Liabilities, 3000–3999 Equity, 4000–4999 Revenue, 5000–5999 Cost of Goods, 6000–9999 Expenses. Must be unique. Sub-accounts can use decimals (e.g. 6100.1).">
|
||||
data-bs-content="A numeric code for sorting and organizing accounts. Convention: 1000–1999 Assets, 2000–2999 Liabilities, 3000–3999 Equity, 4000–4999 Revenue, 5000–5999 Cost of Goods, 6000–9999 Expenses. Must be unique. Sub-accounts can use decimals (e.g. 6100.1).">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -55,7 +55,7 @@
|
||||
</a>
|
||||
</div>
|
||||
<select asp-for="AccountType" asp-items="ViewBag.AccountTypes" class="form-select" id="accountTypeSelect">
|
||||
<option value="">— Select Type —</option>
|
||||
<option value="">— Select Type —</option>
|
||||
</select>
|
||||
<span asp-validation-for="AccountType" class="text-danger small"></span>
|
||||
</div>
|
||||
@@ -70,7 +70,7 @@
|
||||
</a>
|
||||
</div>
|
||||
<select asp-for="AccountSubType" asp-items="ViewBag.AccountSubTypes" class="form-select" id="accountSubTypeSelect">
|
||||
<option value="">— Select Sub-Type —</option>
|
||||
<option value="">— Select Sub-Type —</option>
|
||||
</select>
|
||||
<span asp-validation-for="AccountSubType" class="text-danger small"></span>
|
||||
<div class="form-text text-primary" id="typeAutoSetHint" style="display:none">
|
||||
@@ -89,12 +89,12 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Parent Account"
|
||||
data-bs-content="Nest this account under a parent to create a hierarchy — e.g. 'Powder Costs' under 'Cost of Goods Sold'. Sub-accounts roll up into their parent on financial reports. Most accounts work fine without a parent.">
|
||||
data-bs-content="Nest this account under a parent to create a hierarchy — e.g. 'Powder Costs' under 'Cost of Goods Sold'. Sub-accounts roll up into their parent on financial reports. Most accounts work fine without a parent.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<select asp-for="ParentAccountId" asp-items="ViewBag.ParentAccounts" class="form-select">
|
||||
<option value="">— None (top-level account) —</option>
|
||||
<option value="">— None (top-level account) —</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -152,7 +152,7 @@
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
// SubType enum values → AccountType enum values (mirrors server-side mapping)
|
||||
// SubType enum values → AccountType enum values (mirrors server-side mapping)
|
||||
const subTypeToAccountType = {
|
||||
8: 1, 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, // Assets
|
||||
10: 2, 11: 2, 12: 2, 13: 2, // Liabilities
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
@model PowderCoating.Application.DTOs.Accounting.EditAccountDto
|
||||
@model PowderCoating.Application.DTOs.Accounting.EditAccountDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Edit Account";
|
||||
ViewData["PageIcon"] = "bi-pencil-square";
|
||||
ViewData["PageHelpTitle"] = "Edit Account";
|
||||
ViewData["PageHelpContent"] = "You can change number, name, type, sub-type, parent, and opening balance. Changing the account type or sub-type on an account that already has transactions is allowed but use caution — it changes how balances are reported going forward. Inactive accounts are hidden from pickers but preserved in history.";
|
||||
ViewData["PageHelpContent"] = "You can change number, name, type, sub-type, parent, and opening balance. Changing the account type or sub-type on an account that already has transactions is allowed but use caution — it changes how balances are reported going forward. Inactive accounts are hidden from pickers but preserved in history.";
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-start mb-4">
|
||||
@@ -18,7 +18,7 @@
|
||||
<form asp-action="Edit" method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
<input asp-for="Id" type="hidden" />
|
||||
<div asp-validation-summary="ModelOnly" class="alert alert-danger mb-3"></div>
|
||||
<div asp-validation-summary="ModelOnly" class="alert alert-danger alert-permanent mb-3"></div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-sm-4">
|
||||
@@ -27,7 +27,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Account Number"
|
||||
data-bs-content="A numeric code for sorting and organizing accounts. Convention: 1000–1999 Assets, 2000–2999 Liabilities, 3000–3999 Equity, 4000–4999 Revenue, 5000–5999 Cost of Goods, 6000–9999 Expenses. Must be unique.">
|
||||
data-bs-content="A numeric code for sorting and organizing accounts. Convention: 1000–1999 Assets, 2000–2999 Liabilities, 3000–3999 Equity, 4000–4999 Revenue, 5000–5999 Cost of Goods, 6000–9999 Expenses. Must be unique.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -81,12 +81,12 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Parent Account"
|
||||
data-bs-content="Nest this account under a parent to create a hierarchy — e.g. 'Powder Costs' under 'Cost of Goods Sold'. Sub-accounts roll up into their parent on financial reports. Most accounts work fine without a parent.">
|
||||
data-bs-content="Nest this account under a parent to create a hierarchy — e.g. 'Powder Costs' under 'Cost of Goods Sold'. Sub-accounts roll up into their parent on financial reports. Most accounts work fine without a parent.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<select asp-for="ParentAccountId" asp-items="ViewBag.ParentAccounts" class="form-select">
|
||||
<option value="">— None —</option>
|
||||
<option value="">— None —</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 alert-permanent 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>
|
||||
@@ -1,10 +1,10 @@
|
||||
@model PowderCoating.Application.DTOs.Appointment.CreateAppointmentDto
|
||||
@model PowderCoating.Application.DTOs.Appointment.CreateAppointmentDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "New Appointment";
|
||||
ViewData["PageIcon"] = "bi-calendar-plus";
|
||||
ViewData["PageHelpTitle"] = "New Appointment";
|
||||
ViewData["PageHelpContent"] = "Create an appointment to schedule a customer visit, drop-off, pick-up, or consultation. Select the Type first — the Linked Job field appears once a type is chosen. Reminder notifications fire before the scheduled start time.";
|
||||
ViewData["PageHelpContent"] = "Create an appointment to schedule a customer visit, drop-off, pick-up, or consultation. Select the Type first — the Linked Job field appears once a type is chosen. Reminder notifications fire before the scheduled start time.";
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-end align-items-center mb-4">
|
||||
@@ -20,7 +20,7 @@
|
||||
<form asp-action="Create" method="post">
|
||||
@if (!ViewData.ModelState.IsValid)
|
||||
{
|
||||
<div class="alert alert-danger">
|
||||
<div class="alert alert-danger alert-permanent">
|
||||
<h6 class="alert-heading"><i class="bi bi-exclamation-triangle me-2"></i>Please correct the following errors:</h6>
|
||||
<partial name="_ValidationSummary" />
|
||||
</div>
|
||||
@@ -143,7 +143,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Reminder Settings"
|
||||
data-bs-content="Enable a reminder to receive an in-app notification before the appointment. Set how many minutes in advance — e.g., 30 for a brief heads-up, 1440 for a full day before. Reminders are per-appointment and do not send external emails or SMS.">
|
||||
data-bs-content="Enable a reminder to receive an in-app notification before the appointment. Set how many minutes in advance — e.g., 30 for a brief heads-up, 1440 for a full day before. Reminders are per-appointment and do not send external emails or SMS.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
@model PowderCoating.Application.DTOs.Appointment.AppointmentDto
|
||||
@model PowderCoating.Application.DTOs.Appointment.AppointmentDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = $"Appointment {Model.AppointmentNumber}";
|
||||
ViewData["PageIcon"] = "bi-calendar-event";
|
||||
ViewData["PageHelpTitle"] = "Appointment Details";
|
||||
ViewData["PageHelpContent"] = "View all details for this appointment. Edit to update status or record actual times. Deleting permanently removes the record — consider setting status to Cancelled instead to preserve history.";
|
||||
ViewData["PageHelpContent"] = "View all details for this appointment. Edit to update status or record actual times. Deleting permanently removes the record — consider setting status to Cancelled instead to preserve history.";
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-end gap-2 mb-4">
|
||||
@@ -52,7 +52,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-info mb-0">
|
||||
<div class="alert alert-info alert-permanent mb-0">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
<strong>Internal Appointment</strong><br />
|
||||
<small>This appointment is not associated with a customer.</small>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@using PowderCoating.Application.DTOs.Subscription
|
||||
@using PowderCoating.Application.DTOs.Subscription
|
||||
@using PowderCoating.Core.Enums
|
||||
@{
|
||||
ViewData["Title"] = "Billing & Subscription";
|
||||
@@ -56,7 +56,7 @@
|
||||
|
||||
@if (isExpiringSoon)
|
||||
{
|
||||
<div class="alert alert-warning d-flex align-items-center justify-content-between mb-4" role="alert">
|
||||
<div class="alert alert-warning alert-permanent d-flex align-items-center justify-content-between mb-4" role="alert">
|
||||
<div>
|
||||
<i class="bi bi-clock-history me-2"></i>
|
||||
<strong>Your subscription expires in @status.DaysRemaining day@(status.DaysRemaining == 1 ? "" : "s").</strong>
|
||||
@@ -74,7 +74,7 @@
|
||||
}
|
||||
else if (status.IsGracePeriod)
|
||||
{
|
||||
<div class="alert alert-warning d-flex align-items-center justify-content-between mb-4" role="alert">
|
||||
<div class="alert alert-warning alert-permanent d-flex align-items-center justify-content-between mb-4" role="alert">
|
||||
<div>
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
<strong>Your subscription has expired.</strong>
|
||||
@@ -93,7 +93,7 @@ else if (status.IsGracePeriod)
|
||||
}
|
||||
else if (status.IsExpired)
|
||||
{
|
||||
<div class="alert alert-danger d-flex align-items-center justify-content-between mb-4" role="alert">
|
||||
<div class="alert alert-danger alert-permanent d-flex align-items-center justify-content-between mb-4" role="alert">
|
||||
<div>
|
||||
<i class="bi bi-x-octagon-fill me-2"></i>
|
||||
<strong>Your subscription has expired and access is restricted.</strong>
|
||||
@@ -136,7 +136,7 @@ else if (status.IsExpired)
|
||||
}
|
||||
else if (status.IsGracePeriod)
|
||||
{
|
||||
<span class="text-warning">Grace period — expired @status.EndDate.Value.ToString("MMM d, yyyy")</span>
|
||||
<span class="text-warning">Grace period — expired @status.EndDate.Value.ToString("MMM d, yyyy")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -266,7 +266,7 @@ else if (status.IsExpired)
|
||||
<strong>Cancellation & Refund Policy:</strong>
|
||||
You may cancel your subscription at any time from this page or by contacting
|
||||
<a href="mailto:support@powdercoatinglogix.com">support@powdercoatinglogix.com</a>.
|
||||
Cancellation takes effect at the end of your current billing period — you retain full access until then.
|
||||
Cancellation takes effect at the end of your current billing period — you retain full access until then.
|
||||
All fees are <strong>non-refundable</strong>; unused time is not credited back.
|
||||
See our <a asp-controller="Home" asp-action="TermsOfService" asp-fragment="section-5" target="_blank">full billing terms</a> for details.
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
@model PowderCoating.Application.DTOs.Accounting.CreateBillDto
|
||||
@model PowderCoating.Application.DTOs.Accounting.CreateBillDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "New Bill";
|
||||
ViewData["PageIcon"] = "bi-receipt-cutoff";
|
||||
ViewData["PageHelpTitle"] = "New Bill";
|
||||
ViewData["PageHelpContent"] = "Record a vendor invoice to track what you owe. Bills start as Draft (editable) and become Open once confirmed. Partial payments are supported — each payment reduces the balance. Link line items to expense accounts and optionally to specific jobs for cost tracking.";
|
||||
ViewData["PageHelpContent"] = "Record a vendor invoice to track what you owe. Bills start as Draft (editable) and become Open once confirmed. Partial payments are supported — each payment reduces the balance. Link line items to expense accounts and optionally to specific jobs for cost tracking.";
|
||||
string? fromPoNumber = ViewBag.FromPoNumber as string;
|
||||
int? fromPoId = ViewBag.FromPoId as int?;
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
<div>
|
||||
@if (!string.IsNullOrEmpty(fromPoNumber))
|
||||
{
|
||||
<p class="text-muted mb-0 small"><i class="bi bi-box-arrow-in-down text-success me-1"></i> Pre-filled from <strong>@fromPoNumber</strong> — review and save</p>
|
||||
<p class="text-muted mb-0 small"><i class="bi bi-box-arrow-in-down text-success me-1"></i> Pre-filled from <strong>@fromPoNumber</strong> — review and save</p>
|
||||
}
|
||||
</div>
|
||||
@if (fromPoId.HasValue)
|
||||
@@ -32,7 +32,7 @@
|
||||
{
|
||||
<input type="hidden" name="PurchaseOrderId" value="@Model.PurchaseOrderId" />
|
||||
}
|
||||
<div asp-validation-summary="ModelOnly" class="alert alert-danger mb-3"></div>
|
||||
<div asp-validation-summary="ModelOnly" class="alert alert-danger alert-permanent mb-3"></div>
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- Left column: Bill header -->
|
||||
@@ -44,7 +44,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Bill Details"
|
||||
data-bs-content="Vendor: who you're paying. AP Account: the liability account this bill posts to (e.g. Accounts Payable). Bill Date: date on the vendor's invoice. Due Date: when payment is due — drives overdue status. Vendor Invoice #: the vendor's own reference number for reconciliation. Payment Terms auto-fill from the vendor record.">
|
||||
data-bs-content="Vendor: who you're paying. AP Account: the liability account this bill posts to (e.g. Accounts Payable). Bill Date: date on the vendor's invoice. Due Date: when payment is due — drives overdue status. Vendor Invoice #: the vendor's own reference number for reconciliation. Payment Terms auto-fill from the vendor record.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -66,8 +66,8 @@
|
||||
<label asp-for="VendorId" class="form-label fw-medium">Vendor <span class="text-danger">*</span></label>
|
||||
<select asp-for="VendorId" asp-items="ViewBag.Vendors" class="form-select" id="vendorSelect"
|
||||
data-quick-add-url="/Vendors/Create" data-quick-add-title="Add New Vendor">
|
||||
<option value="">— Select Vendor —</option>
|
||||
<option value="__new__">+ Add New Vendor…</option>
|
||||
<option value="">— Select Vendor —</option>
|
||||
<option value="__new__">+ Add New Vendor…</option>
|
||||
</select>
|
||||
<span asp-validation-for="VendorId" class="text-danger small"></span>
|
||||
</div>
|
||||
@@ -100,7 +100,7 @@
|
||||
<label for="receiptFile" class="form-label fw-medium">Attach Receipt / Document</label>
|
||||
<input type="file" name="receiptFile" id="receiptFile" class="form-control"
|
||||
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf" />
|
||||
<div class="form-text">JPG, PNG, GIF, WebP, or PDF — up to 10 MB.</div>
|
||||
<div class="form-text">JPG, PNG, GIF, WebP, or PDF — up to 10 MB.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,7 +114,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Line Items"
|
||||
data-bs-content="Each line maps to an expense account (e.g. Supplies, Materials, Subcontractors). Optionally link a line to a Job to track costs against specific work orders. Qty × Unit Price = Amount. Use multiple lines to split one bill across different expense categories.">
|
||||
data-bs-content="Each line maps to an expense account (e.g. Supplies, Materials, Subcontractors). Optionally link a line to a Job to track costs against specific work orders. Qty × Unit Price = Amount. Use multiple lines to split one bill across different expense categories.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -150,7 +150,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
|
||||
data-bs-title="Bill Summary"
|
||||
data-bs-content="Tax % is applied to the line-item subtotal. The resulting Total is the full amount owed to the vendor. Partial payments are allowed — each payment recorded reduces the balance due until the bill is fully paid.">
|
||||
data-bs-content="Tax % is applied to the line-item subtotal. The resulting Total is the full amount owed to the vendor. Partial payments are allowed — each payment recorded reduces the balance due until the bill is fully paid.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -198,7 +198,7 @@
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-medium">Bank / Cash Account <span class="text-danger">*</span></label>
|
||||
<select name="bankAccountId" class="form-select form-select-sm" id="payNowBankAccount">
|
||||
<option value="">— Select Account —</option>
|
||||
<option value="">— Select Account —</option>
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.BankAccounts)
|
||||
{
|
||||
<option value="@item.Value">@item.Text</option>
|
||||
@@ -232,7 +232,7 @@
|
||||
<tr class="line-item-row">
|
||||
<td>
|
||||
<select class="form-select form-select-sm account-select" name="LineItems[INDEX].AccountId" required>
|
||||
<option value="">— Account —</option>
|
||||
<option value="">— Account —</option>
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.ExpenseAccounts)
|
||||
{
|
||||
<option value="@item.Value">@item.Text</option>
|
||||
@@ -242,7 +242,7 @@
|
||||
<td><input type="text" class="form-control form-control-sm" name="LineItems[INDEX].Description" placeholder="Description" /></td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm" name="LineItems[INDEX].JobId">
|
||||
<option value="">—</option>
|
||||
<option value="">—</option>
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.Jobs)
|
||||
{
|
||||
<option value="@item.Value">@item.Text</option>
|
||||
@@ -273,7 +273,7 @@
|
||||
<div class="mb-3">
|
||||
<label for="scanReceiptFile" class="form-label fw-medium">Receipt / Invoice Document</label>
|
||||
<input type="file" id="scanReceiptFile" class="form-control" accept=".jpg,.jpeg,.png,.gif,.webp,.pdf" />
|
||||
<div class="form-text">JPG, PNG, GIF, WebP, or PDF — up to 10 MB.</div>
|
||||
<div class="form-text">JPG, PNG, GIF, WebP, or PDF — up to 10 MB.</div>
|
||||
</div>
|
||||
<div id="scanReceiptStatus" class="text-muted small mt-2"></div>
|
||||
</div>
|
||||
@@ -393,8 +393,8 @@
|
||||
}
|
||||
if (lineCount === 0) addLineItem();
|
||||
|
||||
// ── AI Auto-suggest Account on description blur ───────────────────────
|
||||
// Keyword shortcuts — handle common cases with zero API cost
|
||||
// ── AI Auto-suggest Account on description blur ───────────────────────
|
||||
// Keyword shortcuts — handle common cases with zero API cost
|
||||
const _keywordMap = [
|
||||
{ words: ['electric','power','utility','gas','water','internet','phone','telecom'], hint: 'utilities' },
|
||||
{ words: ['powder','paint','coat','material','supply','supplies','chemical','resin'], hint: 'materials' },
|
||||
@@ -407,7 +407,7 @@
|
||||
{ words: ['advertising','marketing','promo'], hint: 'advertising' },
|
||||
];
|
||||
|
||||
// Session cache: description (lowercased) → { accountId, accountName }
|
||||
// Session cache: description (lowercased) → { accountId, accountName }
|
||||
const _suggestCache = new Map();
|
||||
|
||||
function _keywordGuess(description) {
|
||||
@@ -480,7 +480,7 @@
|
||||
hint2.className = 'ai-account-hint text-muted small mt-1';
|
||||
accountSel.parentNode.appendChild(hint2);
|
||||
}
|
||||
hint2.innerHTML = '<span class="spinner-border spinner-border-sm" style="width:.75rem;height:.75rem"></span> Thinking…';
|
||||
hint2.innerHTML = '<span class="spinner-border spinner-border-sm" style="width:.75rem;height:.75rem"></span> Thinking…';
|
||||
|
||||
try {
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
|
||||
@@ -501,14 +501,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Event delegation — works for dynamically added rows
|
||||
// Event delegation — works for dynamically added rows
|
||||
document.getElementById('lineItemsBody').addEventListener('blur', function (e) {
|
||||
if (e.target.matches('[name$=".Description"]')) {
|
||||
_suggestAccountForRow(e.target.closest('tr'));
|
||||
}
|
||||
}, true); // capture phase so blur bubbles
|
||||
|
||||
// ── Scan Receipt ─────────────────────────────────────────────────────
|
||||
// ── Scan Receipt ─────────────────────────────────────────────────────
|
||||
document.getElementById('scanReceiptUploadBtn').addEventListener('click', async function () {
|
||||
const fileInput = document.getElementById('scanReceiptFile');
|
||||
if (!fileInput.files.length) { alert('Please select a file.'); return; }
|
||||
@@ -535,7 +535,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-fill bill header — try to match vendor name to dropdown
|
||||
// Auto-fill bill header — try to match vendor name to dropdown
|
||||
if (data.vendorName) {
|
||||
const vendorSel = document.getElementById('vendorSelect');
|
||||
if (vendorSel && !vendorSel.value) {
|
||||
@@ -553,7 +553,7 @@
|
||||
vendorSel.value = bestOption.value;
|
||||
vendorSel.dispatchEvent(new Event('change'));
|
||||
} else {
|
||||
// No match — put the name in Memo so user knows what the AI saw
|
||||
// No match — put the name in Memo so user knows what the AI saw
|
||||
const memo = document.querySelector('[name="Memo"]');
|
||||
if (memo && !memo.value) memo.value = data.vendorName;
|
||||
}
|
||||
@@ -598,7 +598,7 @@
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('scanReceiptModal'));
|
||||
if (modal) modal.hide();
|
||||
|
||||
statusEl.textContent = 'Scan complete — review and adjust as needed.';
|
||||
statusEl.textContent = 'Scan complete — review and adjust as needed.';
|
||||
} catch (e) {
|
||||
statusEl.textContent = 'Error connecting to AI service.';
|
||||
} finally {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
@model PowderCoating.Application.DTOs.Accounting.EditBillDto
|
||||
@model PowderCoating.Application.DTOs.Accounting.EditBillDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Edit Bill";
|
||||
ViewData["PageIcon"] = "bi-pencil-square";
|
||||
ViewData["PageHelpTitle"] = "Edit Bill";
|
||||
ViewData["PageHelpContent"] = "Bills can only be edited while in Draft status. Once marked Open, they are locked — Void the bill and recreate it if corrections are needed after confirmation.";
|
||||
ViewData["PageHelpContent"] = "Bills can only be edited while in Draft status. Once marked Open, they are locked — Void the bill and recreate it if corrections are needed after confirmation.";
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-start mb-4">
|
||||
@@ -14,7 +14,7 @@
|
||||
<form asp-action="Edit" asp-route-id="@Model.Id" method="post" enctype="multipart/form-data" id="billForm">
|
||||
@Html.AntiForgeryToken()
|
||||
<input asp-for="Id" type="hidden" />
|
||||
<div asp-validation-summary="ModelOnly" class="alert alert-danger mb-3"></div>
|
||||
<div asp-validation-summary="ModelOnly" class="alert alert-danger alert-permanent mb-3"></div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-8">
|
||||
@@ -24,7 +24,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Bill Details"
|
||||
data-bs-content="Vendor: who you're paying. AP Account: the liability account this bill posts to (e.g. Accounts Payable). Bill Date: date on the vendor's invoice. Due Date: when payment is due — drives overdue status. Vendor Invoice #: the vendor's own reference number for reconciliation.">
|
||||
data-bs-content="Vendor: who you're paying. AP Account: the liability account this bill posts to (e.g. Accounts Payable). Bill Date: date on the vendor's invoice. Due Date: when payment is due — drives overdue status. Vendor Invoice #: the vendor's own reference number for reconciliation.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -34,8 +34,8 @@
|
||||
<label asp-for="VendorId" class="form-label fw-medium">Vendor <span class="text-danger">*</span></label>
|
||||
<select asp-for="VendorId" asp-items="ViewBag.Vendors" class="form-select"
|
||||
data-quick-add-url="/Vendors/Create" data-quick-add-title="Add New Vendor">
|
||||
<option value="">— Select Vendor —</option>
|
||||
<option value="__new__">+ Add New Vendor…</option>
|
||||
<option value="">— Select Vendor —</option>
|
||||
<option value="__new__">+ Add New Vendor…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
@@ -87,7 +87,7 @@
|
||||
}
|
||||
<input type="file" name="receiptFile" id="receiptFile" class="form-control"
|
||||
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf" />
|
||||
<div class="form-text">JPG, PNG, GIF, WebP, or PDF — up to 10 MB.</div>
|
||||
<div class="form-text">JPG, PNG, GIF, WebP, or PDF — up to 10 MB.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,7 +100,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Line Items"
|
||||
data-bs-content="Each line maps to an expense account (e.g. Supplies, Materials, Subcontractors). Optionally link a line to a Job to track costs against specific work orders. Qty × Unit Price = Amount. Use multiple lines to split one bill across different expense categories.">
|
||||
data-bs-content="Each line maps to an expense account (e.g. Supplies, Materials, Subcontractors). Optionally link a line to a Job to track costs against specific work orders. Qty × Unit Price = Amount. Use multiple lines to split one bill across different expense categories.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -134,7 +134,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
|
||||
data-bs-title="Bill Summary"
|
||||
data-bs-content="Tax % is applied to the line-item subtotal. The resulting Total is the full amount owed to the vendor. Partial payments are allowed — each payment recorded reduces the balance due until the bill is fully paid.">
|
||||
data-bs-content="Tax % is applied to the line-item subtotal. The resulting Total is the full amount owed to the vendor. Partial payments are allowed — each payment recorded reduces the balance due until the bill is fully paid.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -171,7 +171,7 @@
|
||||
<tr class="line-item-row">
|
||||
<td>
|
||||
<select class="form-select form-select-sm account-select" name="LineItems[INDEX].AccountId" required>
|
||||
<option value="">— Account —</option>
|
||||
<option value="">— Account —</option>
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.ExpenseAccounts)
|
||||
{
|
||||
<option value="@item.Value">@item.Text</option>
|
||||
@@ -181,7 +181,7 @@
|
||||
<td><input type="text" class="form-control form-control-sm" name="LineItems[INDEX].Description" placeholder="Description" /></td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm" name="LineItems[INDEX].JobId">
|
||||
<option value="">—</option>
|
||||
<option value="">—</option>
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.Jobs)
|
||||
{
|
||||
<option value="@item.Value">@item.Text</option>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model List<PowderCoating.Application.DTOs.Accounting.BillExpenseListDto>
|
||||
@model List<PowderCoating.Application.DTOs.Accounting.BillExpenseListDto>
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Bills / Expenses";
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
@if ((decimal)ViewBag.TotalOwed > 0)
|
||||
{
|
||||
<div class="alert alert-warning d-flex align-items-center gap-2 mb-4">
|
||||
<div class="alert alert-warning alert-permanent d-flex align-items-center gap-2 mb-4">
|
||||
<i class="bi bi-exclamation-circle fs-5"></i>
|
||||
<span>Outstanding bills: <strong>@(((decimal)ViewBag.TotalOwed).ToString("C"))</strong></span>
|
||||
<a asp-action="Index" asp-route-status="Unpaid" class="btn btn-sm btn-warning ms-auto">
|
||||
@@ -59,7 +59,7 @@
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<input type="search" name="search" value="@ViewBag.Search" class="form-control"
|
||||
placeholder="Search by #, vendor, memo, amount…" />
|
||||
placeholder="Search by #, vendor, memo, amount…" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="type" class="form-select">
|
||||
@@ -153,13 +153,13 @@
|
||||
}
|
||||
else if (entry.EntryType == "Expense")
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td><span class="badge bg-@entry.StatusColor">@entry.StatusLabel</span></td>
|
||||
<td class="text-end">@entry.Total.ToString("C")</td>
|
||||
<td class="text-end fw-medium @(entry.BalanceDue > 0 ? "text-danger" : "text-muted")">
|
||||
@(entry.EntryType == "Bill" ? entry.BalanceDue.ToString("C") : "—")
|
||||
@(entry.EntryType == "Bill" ? entry.BalanceDue.ToString("C") : "—")
|
||||
</td>
|
||||
<td>
|
||||
@if (entry.EntryType == "Bill")
|
||||
@@ -206,7 +206,7 @@ else
|
||||
asp-route-status="@ViewBag.StatusFilter"
|
||||
asp-route-search="@ViewBag.Search"
|
||||
asp-route-page="@((int)ViewBag.Page - 1)"
|
||||
asp-route-pageSize="@ViewBag.PageSize">‹ Prev</a>
|
||||
asp-route-pageSize="@ViewBag.PageSize">‹ Prev</a>
|
||||
</li>
|
||||
@for (var p = 1; p <= (int)ViewBag.TotalPages; p++)
|
||||
{
|
||||
@@ -225,11 +225,11 @@ else
|
||||
asp-route-status="@ViewBag.StatusFilter"
|
||||
asp-route-search="@ViewBag.Search"
|
||||
asp-route-page="@((int)ViewBag.Page + 1)"
|
||||
asp-route-pageSize="@ViewBag.PageSize">Next ›</a>
|
||||
asp-route-pageSize="@ViewBag.PageSize">Next ›</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-center text-muted small">
|
||||
Showing @(((int)ViewBag.Page - 1) * (int)ViewBag.PageSize + 1)–@(Math.Min((int)ViewBag.Page * (int)ViewBag.PageSize, (int)ViewBag.TotalCount))
|
||||
Showing @(((int)ViewBag.Page - 1) * (int)ViewBag.PageSize + 1)–@(Math.Min((int)ViewBag.Page * (int)ViewBag.PageSize, (int)ViewBag.TotalCount))
|
||||
of @ViewBag.TotalCount entries
|
||||
</p>
|
||||
</nav>
|
||||
|
||||
@@ -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 alert-permanent 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>
|
||||
}
|
||||
@@ -43,7 +43,7 @@
|
||||
<input type="hidden" asp-for="CompanyName" />
|
||||
<input type="hidden" asp-for="CreatedAt" />
|
||||
<input type="hidden" asp-for="CompanyId" />
|
||||
<div asp-validation-summary="ModelOnly" class="alert alert-danger" role="alert"></div>
|
||||
<div asp-validation-summary="ModelOnly" class="alert alert-danger alert-permanent" role="alert"></div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label asp-for="Title" class="form-label fw-semibold">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model PowderCoating.Application.DTOs.BugReport.CreateBugReportDto
|
||||
@model PowderCoating.Application.DTOs.BugReport.CreateBugReportDto
|
||||
@using PowderCoating.Core.Enums
|
||||
@{
|
||||
ViewData["Title"] = "Report a Bug";
|
||||
@@ -35,7 +35,7 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form asp-action="Submit" method="post" enctype="multipart/form-data">
|
||||
<div asp-validation-summary="ModelOnly" class="alert alert-danger" role="alert"></div>
|
||||
<div asp-validation-summary="ModelOnly" class="alert alert-danger alert-permanent" role="alert"></div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label asp-for="Title" class="form-label fw-semibold">
|
||||
@@ -59,10 +59,10 @@
|
||||
<div class="mb-4">
|
||||
<label asp-for="Priority" class="form-label fw-semibold">Priority</label>
|
||||
<select asp-for="Priority" class="form-select">
|
||||
<option value="@((int)BugReportPriority.Low)">Low – Minor inconvenience, workaround exists</option>
|
||||
<option value="@((int)BugReportPriority.Normal)" selected>Normal – Affects workflow but not critical</option>
|
||||
<option value="@((int)BugReportPriority.High)">High – Significantly impacts operations</option>
|
||||
<option value="@((int)BugReportPriority.Critical)">Critical – System unusable or data loss risk</option>
|
||||
<option value="@((int)BugReportPriority.Low)">Low – Minor inconvenience, workaround exists</option>
|
||||
<option value="@((int)BugReportPriority.Normal)" selected>Normal – Affects workflow but not critical</option>
|
||||
<option value="@((int)BugReportPriority.High)">High – Significantly impacts operations</option>
|
||||
<option value="@((int)BugReportPriority.Critical)">Critical – System unusable or data loss risk</option>
|
||||
</select>
|
||||
<span asp-validation-for="Priority" class="text-danger small"></span>
|
||||
</div>
|
||||
@@ -104,7 +104,7 @@
|
||||
const li = document.createElement('li');
|
||||
const sizeMb = (f.size / 1024 / 1024).toFixed(1);
|
||||
if (f.size > maxBytes) {
|
||||
li.innerHTML = `<i class="bi bi-exclamation-triangle text-danger"></i> ${f.name} (${sizeMb} MB) — exceeds 100 MB limit`;
|
||||
li.innerHTML = `<i class="bi bi-exclamation-triangle text-danger"></i> ${f.name} (${sizeMb} MB) — exceeds 100 MB limit`;
|
||||
} else {
|
||||
li.innerHTML = `<i class="bi bi-file-earmark text-secondary"></i> ${f.name} (${sizeMb} MB)`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model PowderCoating.Application.DTOs.Catalog.CategoryDto
|
||||
@model PowderCoating.Application.DTOs.Catalog.CategoryDto
|
||||
@{
|
||||
ViewData["Title"] = "Delete Category";
|
||||
var canDelete = ViewBag.CanDelete ?? false;
|
||||
@@ -18,7 +18,7 @@
|
||||
<div class="card-body">
|
||||
@if (!canDelete)
|
||||
{
|
||||
<div class="alert alert-danger">
|
||||
<div class="alert alert-danger alert-permanent">
|
||||
<i class="bi bi-x-circle-fill me-2"></i>
|
||||
<strong>Cannot Delete:</strong> This category cannot be deleted because it contains @(hasItems ? "items" : "") @(hasItems && hasSubCategories ? "and" : "") @(hasSubCategories ? "subcategories" : "").
|
||||
</div>
|
||||
@@ -46,7 +46,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-warning">
|
||||
<div class="alert alert-warning alert-permanent">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
<strong>Warning:</strong> You are about to delete this category. This action cannot be undone.
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model PowderCoating.Application.DTOs.Catalog.CatalogItemDto
|
||||
@model PowderCoating.Application.DTOs.Catalog.CatalogItemDto
|
||||
@{
|
||||
ViewData["Title"] = "Delete Catalog Item";
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-warning">
|
||||
<div class="alert alert-warning alert-permanent">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
<strong>Warning:</strong> You are about to delete this catalog item. This action cannot be undone.
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model PowderCoating.Application.DTOs.Company.CreateCompanyAdminDto
|
||||
@model PowderCoating.Application.DTOs.Company.CreateCompanyAdminDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Create Company Admin";
|
||||
@@ -27,7 +27,7 @@
|
||||
<h5 class="border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-building me-2 text-primary"></i>Company
|
||||
</h5>
|
||||
<div class="alert alert-info">
|
||||
<div class="alert alert-info alert-permanent">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
Creating admin user for: <strong>@Model.CompanyName</strong>
|
||||
</div>
|
||||
@@ -109,7 +109,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Permissions Notice -->
|
||||
<div class="alert alert-success mb-4">
|
||||
<div class="alert alert-success alert-permanent mb-4">
|
||||
<h6 class="alert-heading">
|
||||
<i class="bi bi-shield-check me-2"></i>Administrator Permissions
|
||||
</h6>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model PowderCoating.Application.DTOs.Company.CompanyDto
|
||||
@model PowderCoating.Application.DTOs.Company.CompanyDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = Model.CompanyName;
|
||||
@@ -470,7 +470,7 @@
|
||||
<i class="bi bi-fire me-1"></i>Reset All Company Data
|
||||
</h6>
|
||||
<p class="text-muted small mb-0">
|
||||
Permanently deletes <strong>all business data</strong> — customers, vendors, accounts, invoices, bills, jobs, quotes, inventory, catalog items, and more.
|
||||
Permanently deletes <strong>all business data</strong> — customers, vendors, accounts, invoices, bills, jobs, quotes, inventory, catalog items, and more.
|
||||
The company record, user accounts, and system configuration are preserved.
|
||||
Use this to wipe a migration and start fresh.
|
||||
</p>
|
||||
@@ -491,7 +491,7 @@
|
||||
There is no going back.
|
||||
@if (Model.UserCount > 0)
|
||||
{
|
||||
<br /><strong class="text-danger">This company has @Model.UserCount user(s) — remove them first.</strong>
|
||||
<br /><strong class="text-danger">This company has @Model.UserCount user(s) — remove them first.</strong>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
@@ -523,7 +523,7 @@
|
||||
<!-- Loading spinner -->
|
||||
<div id="oc-loading" class="d-flex justify-content-center align-items-center py-5">
|
||||
<div class="spinner-border text-primary" role="status">
|
||||
<span class="visually-hidden">Loading…</span>
|
||||
<span class="visually-hidden">Loading…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -545,14 +545,14 @@
|
||||
</div>
|
||||
|
||||
<table class="table table-sm table-borderless mb-0 small">
|
||||
<tr><th style="width:38%;" class="text-muted fw-normal">Role</th><td id="oc-role">—</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Department</th><td id="oc-dept">—</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Position</th><td id="oc-position">—</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Phone</th><td id="oc-phone">—</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Hired</th><td id="oc-hire">—</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Account created</th><td id="oc-created">—</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Last login</th><td id="oc-lastlogin">—</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Email confirmed</th><td id="oc-emailconf">—</td></tr>
|
||||
<tr><th style="width:38%;" class="text-muted fw-normal">Role</th><td id="oc-role">—</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Department</th><td id="oc-dept">—</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Position</th><td id="oc-position">—</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Phone</th><td id="oc-phone">—</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Hired</th><td id="oc-hire">—</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Account created</th><td id="oc-created">—</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Last login</th><td id="oc-lastlogin">—</td></tr>
|
||||
<tr><th class="text-muted fw-normal">Email confirmed</th><td id="oc-emailconf">—</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -680,7 +680,7 @@
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" id="resetUserId" name="id" />
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning">
|
||||
<div class="alert alert-warning alert-permanent">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
You are about to reset the password for <strong id="resetUserName"></strong>.
|
||||
</div>
|
||||
@@ -706,7 +706,7 @@
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
// Reset Data Modal — enable submit only when user types "DELETE"
|
||||
// Reset Data Modal — enable submit only when user types "DELETE"
|
||||
(function () {
|
||||
var input = document.getElementById('resetDataConfirmInput');
|
||||
var hidden = document.getElementById('resetDataConfirmHidden');
|
||||
@@ -725,7 +725,7 @@
|
||||
}
|
||||
})();
|
||||
|
||||
// Hard Delete Modal — enable submit only when user types "DELETE"
|
||||
// Hard Delete Modal — enable submit only when user types "DELETE"
|
||||
(function () {
|
||||
var input = document.getElementById('hardDeleteConfirmInput');
|
||||
var hidden = document.getElementById('hardDeleteConfirmHidden');
|
||||
@@ -760,7 +760,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── User Details Modal ────────────────────────────────────────────────
|
||||
// ── User Details Modal ────────────────────────────────────────────────
|
||||
(function () {
|
||||
const offcanvasEl = document.getElementById('userDetailOffcanvas');
|
||||
const oc = new bootstrap.Modal(offcanvasEl);
|
||||
@@ -806,12 +806,12 @@
|
||||
badge.textContent = u.isActive ? 'Active' : 'Inactive';
|
||||
badge.className = 'badge ms-auto ' + (u.isActive ? 'bg-success' : 'bg-danger');
|
||||
|
||||
document.getElementById('oc-role').textContent = u.companyRole ? u.companyRole.replace('Company', '') : '—';
|
||||
document.getElementById('oc-dept').textContent = u.department || '—';
|
||||
document.getElementById('oc-position').textContent = u.position || '—';
|
||||
document.getElementById('oc-phone').textContent = u.phone || '—';
|
||||
document.getElementById('oc-hire').textContent = u.hireDate || '—';
|
||||
document.getElementById('oc-created').textContent = u.createdAt || '—';
|
||||
document.getElementById('oc-role').textContent = u.companyRole ? u.companyRole.replace('Company', '') : '—';
|
||||
document.getElementById('oc-dept').textContent = u.department || '—';
|
||||
document.getElementById('oc-position').textContent = u.position || '—';
|
||||
document.getElementById('oc-phone').textContent = u.phone || '—';
|
||||
document.getElementById('oc-hire').textContent = u.hireDate || '—';
|
||||
document.getElementById('oc-created').textContent = u.createdAt || '—';
|
||||
document.getElementById('oc-lastlogin').textContent = u.lastLoginDate || 'Never';
|
||||
document.getElementById('oc-emailconf').innerHTML = u.emailConfirmed
|
||||
? '<span class="text-success"><i class="bi bi-check-circle-fill me-1"></i>Yes</span>'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model List<PowderCoating.Application.DTOs.Company.CompanyListDto>
|
||||
@model List<PowderCoating.Application.DTOs.Company.CompanyListDto>
|
||||
|
||||
@section Styles {
|
||||
<style>
|
||||
@@ -58,7 +58,7 @@
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="text" name="searchTerm" class="form-control"
|
||||
placeholder="Search by name, code, email, phone…"
|
||||
placeholder="Search by name, code, email, phone…"
|
||||
value="@searchTerm" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -230,7 +230,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile card view — shown on screens < 992px (table-responsive hidden by mobile-cards.css) -->
|
||||
<!-- Mobile card view — shown on screens < 992px (table-responsive hidden by mobile-cards.css) -->
|
||||
<div class="mobile-card-view">
|
||||
<div class="mobile-card-list">
|
||||
@foreach (var company in Model)
|
||||
@@ -274,7 +274,7 @@
|
||||
}
|
||||
</div>
|
||||
<div class="mobile-card-footer">
|
||||
<span class="btn btn-sm btn-outline-primary">View →</span>
|
||||
<span class="btn btn-sm btn-outline-primary">View →</span>
|
||||
</div>
|
||||
</a>
|
||||
}
|
||||
@@ -286,7 +286,7 @@
|
||||
{
|
||||
<div class="card-footer d-flex justify-content-between align-items-center">
|
||||
<div class="text-muted small">
|
||||
Showing @((pageNumber - 1) * pageSize + 1)–@(Math.Min(pageNumber * pageSize, totalCount)) of @totalCount companies
|
||||
Showing @((pageNumber - 1) * pageSize + 1)–@(Math.Min(pageNumber * pageSize, totalCount)) of @totalCount companies
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<div>
|
||||
@@ -413,10 +413,10 @@
|
||||
<h6 class="fw-bold text-danger"><i class="bi bi-fire me-2"></i>Hard Delete (Permanent)</h6>
|
||||
<p class="text-muted small mb-2">
|
||||
<strong class="text-danger">This cannot be undone.</strong>
|
||||
All company data — users, jobs, quotes, customers, invoices, and everything else — will be
|
||||
All company data — users, jobs, quotes, customers, invoices, and everything else — will be
|
||||
<strong>permanently and irreversibly deleted</strong> from the database.
|
||||
</p>
|
||||
<div class="alert alert-danger py-2 mb-3">
|
||||
<div class="alert alert-danger alert-permanent py-2 mb-3">
|
||||
<i class="bi bi-exclamation-octagon-fill me-2"></i>
|
||||
Type <strong>DELETE</strong> below to enable permanent deletion.
|
||||
</div>
|
||||
|
||||
@@ -211,6 +211,48 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Period Lock -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<h6 class="border-bottom pb-2 mb-3 text-muted">Period Locking</h6>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
@{
|
||||
var lockThrough = ViewBag.BookLockedThrough as DateTime?;
|
||||
}
|
||||
<label class="form-label">Books Locked Through</label>
|
||||
@if (lockThrough.HasValue)
|
||||
{
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-lock-fill text-warning"></i></span>
|
||||
<input type="text" class="form-control" value="@lockThrough.Value.ToString("MMMM d, yyyy")" readonly />
|
||||
<form asp-action="SetPeriodLock" method="post" class="d-inline">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-outline-secondary" title="Clear lock">
|
||||
<i class="bi bi-unlock"></i> Clear
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="form-text text-warning">Entries dated on or before this date are blocked.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-muted small mb-2">No period lock set — all dates are open.</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Set New Lock Date</label>
|
||||
<form asp-action="SetPeriodLock" method="post" class="d-flex gap-2">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="date" name="lockThrough" class="form-control" />
|
||||
<button type="submit" class="btn btn-warning" onclick="return confirm('Lock all accounting periods on or before this date? Users will not be able to post or edit entries in those periods.')">
|
||||
<i class="bi bi-lock me-1"></i>Lock
|
||||
</button>
|
||||
</form>
|
||||
<div class="form-text">Prevents backdating any GL entry (bills, JEs) into closed periods.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="address" class="form-label">Address</label>
|
||||
<input type="text" class="form-control" id="address" name="Address" value="@Model.Address" maxlength="200">
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
@model PowderCoating.Application.DTOs.User.CreateCompanyUserDto
|
||||
@model PowderCoating.Application.DTOs.User.CreateCompanyUserDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Add New User";
|
||||
ViewData["PageIcon"] = "bi-person-plus";
|
||||
ViewData["PageHelpTitle"] = "Add New User";
|
||||
ViewData["PageHelpContent"] = "Creates a new login account for a member of your company. The email address doubles as the login username. Set a temporary password — the user can change it from their Profile page after their first login. Assign a Role, then fine-tune individual permissions below.";
|
||||
ViewData["PageHelpContent"] = "Creates a new login account for a member of your company. The email address doubles as the login username. Set a temporary password — the user can change it from their Profile page after their first login. Assign a Role, then fine-tune individual permissions below.";
|
||||
}
|
||||
|
||||
<div class="container">
|
||||
@@ -26,7 +26,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Basic Information"
|
||||
data-bs-content="First Name, Last Name, and Email are required. The email is used as the login username — it must be unique across the system. Employee Number is an optional internal reference. The user can update their name and phone from their own Profile page after logging in.">
|
||||
data-bs-content="First Name, Last Name, and Email are required. The email is used as the login username — it must be unique across the system. Employee Number is an optional internal reference. The user can update their name and phone from their own Profile page after logging in.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -109,7 +109,7 @@
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div id="companyAdminAlert" class="alert alert-info" style="display: none;">
|
||||
<div id="companyAdminAlert" class="alert alert-info alert-permanent" style="display: none;">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
<strong>Company Admins automatically have all permissions.</strong> These checkboxes are disabled because Company Admins always have full access to all features.
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
@model PowderCoating.Application.DTOs.User.UpdateCompanyUserDto
|
||||
@model PowderCoating.Application.DTOs.User.UpdateCompanyUserDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Edit User";
|
||||
ViewData["PageIcon"] = "bi-person-gear";
|
||||
ViewData["PageHelpTitle"] = "Edit User";
|
||||
ViewData["PageHelpContent"] = "Update this user's account details, role, and permissions. Unchecking User Active prevents the user from logging in without deleting their account or history. Changing the email here also changes their login username — notify them so they can log in with the new address.";
|
||||
ViewData["PageHelpContent"] = "Update this user's account details, role, and permissions. Unchecking User Active prevents the user from logging in without deleting their account or history. Changing the email here also changes their login username — notify them so they can log in with the new address.";
|
||||
}
|
||||
|
||||
<div class="container">
|
||||
@@ -36,7 +36,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Basic Information"
|
||||
data-bs-content="Email is this user's login username — changing it here means they must use the new address to log in. User Active controls whether the account can sign in; deactivating preserves all data without deleting the account. To reset a password, the user can use Forgot Password on the login page, or a SuperAdmin can set one directly.">
|
||||
data-bs-content="Email is this user's login username — changing it here means they must use the new address to log in. User Active controls whether the account can sign in; deactivating preserves all data without deleting the account. To reset a password, the user can use Forgot Password on the login page, or a SuperAdmin can set one directly.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -80,7 +80,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Role & Department"
|
||||
data-bs-content="Changing the Role updates the user's base access level immediately on save. Termination Date is informational — to actually prevent login, also uncheck User Active above. Department and Position appear on the user's profile card and in the Manage Users list.">
|
||||
data-bs-content="Changing the Role updates the user's base access level immediately on save. Termination Date is informational — to actually prevent login, also uncheck User Active above. Department and Position appear on the user's profile card and in the Manage Users list.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -126,7 +126,7 @@
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div id="companyAdminAlert" class="alert alert-info" style="display: none;">
|
||||
<div id="companyAdminAlert" class="alert alert-info alert-permanent" style="display: none;">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
<strong>Company Admins automatically have all permissions.</strong> These checkboxes are disabled because Company Admins always have full access to all features.
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model PagedResult<PowderCoating.Application.DTOs.User.CompanyUserListDto>
|
||||
@model PagedResult<PowderCoating.Application.DTOs.User.CompanyUserListDto>
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Manage Users";
|
||||
@@ -247,7 +247,7 @@
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="id" id="banUserId" />
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning">
|
||||
<div class="alert alert-warning alert-permanent">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
Banning <strong id="banUserName"></strong> will immediately prevent them from logging in.
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
@model PowderCoating.Web.Controllers.CreditMemoCreateVm
|
||||
@{
|
||||
ViewData["Title"] = "Issue Credit Memo";
|
||||
var linkedInvoiceNumber = ViewBag.LinkedInvoiceNumber as string;
|
||||
var customers = ViewBag.Customers as List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem> ?? new();
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0"><i class="bi bi-journal-minus me-2 text-primary"></i>Issue Credit Memo</h4>
|
||||
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to Credit Memos
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-7">
|
||||
<div class="card">
|
||||
<div class="card-header fw-semibold">Credit Memo Details</div>
|
||||
<div class="card-body">
|
||||
<form asp-action="Create" method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
<asp-validation-summary asp-validation-summary="All" class="alert alert-danger alert-permanent"></asp-validation-summary>
|
||||
|
||||
@if (Model.OriginalInvoiceId.HasValue && !string.IsNullOrEmpty(linkedInvoiceNumber))
|
||||
{
|
||||
<input type="hidden" asp-for="OriginalInvoiceId" />
|
||||
<div class="alert alert-info alert-permanent py-2 mb-3 small">
|
||||
<i class="bi bi-link-45deg me-1"></i>
|
||||
Linked to invoice <strong>@linkedInvoiceNumber</strong>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mb-3">
|
||||
<label asp-for="CustomerId" class="form-label">Customer <span class="text-danger">*</span></label>
|
||||
<select asp-for="CustomerId" asp-items="customers" class="form-select">
|
||||
<option value="0">— select customer —</option>
|
||||
</select>
|
||||
<span asp-validation-for="CustomerId" class="text-danger small"></span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label asp-for="Amount" class="form-label">Credit Amount <span class="text-danger">*</span></label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input asp-for="Amount" type="number" step="0.01" min="0.01"
|
||||
class="form-control" placeholder="0.00" />
|
||||
</div>
|
||||
<span asp-validation-for="Amount" class="text-danger small"></span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label asp-for="Reason" class="form-label">Reason <span class="text-danger">*</span></label>
|
||||
<input asp-for="Reason" class="form-control"
|
||||
placeholder="e.g. Price adjustment, billing error, goodwill credit…" />
|
||||
<span asp-validation-for="Reason" class="text-danger small"></span>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label asp-for="Notes" class="form-label">Internal Notes</label>
|
||||
<textarea asp-for="Notes" class="form-control" rows="3"
|
||||
placeholder="Additional context for your records (not shown to customer)"></textarea>
|
||||
<span asp-validation-for="Notes" class="text-danger small"></span>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label asp-for="ExpiryDate" class="form-label">
|
||||
Expiry Date
|
||||
<span class="text-muted small ms-1">(optional — leave blank for no expiry)</span>
|
||||
</label>
|
||||
<input asp-for="ExpiryDate" type="date" class="form-control" />
|
||||
<span asp-validation-for="ExpiryDate" class="text-danger small"></span>
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="bi bi-check-lg me-1"></i>Issue Credit Memo
|
||||
</button>
|
||||
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3 border-0 bg-light">
|
||||
<div class="card-body py-2 small text-muted">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Issuing a credit memo immediately adds the amount to the customer's credit balance.
|
||||
You can apply it to one or more open invoices from the Credit Memo Details page.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,307 @@
|
||||
@model PowderCoating.Core.Entities.CreditMemo
|
||||
@using PowderCoating.Core.Entities
|
||||
@using PowderCoating.Core.Enums
|
||||
@{
|
||||
ViewData["Title"] = $"Credit Memo {Model.MemoNumber}";
|
||||
var applications = ViewBag.Applications as List<CreditMemoApplication> ?? new();
|
||||
var openInvoices = ViewBag.OpenInvoices as List<Invoice> ?? new();
|
||||
bool canApply = ViewBag.CanApply;
|
||||
|
||||
var (badgeClass, badgeLabel) = Model.Status switch
|
||||
{
|
||||
CreditMemoStatus.Active => ("bg-success-subtle text-success border border-success-subtle", "Active"),
|
||||
CreditMemoStatus.PartiallyApplied => ("bg-warning-subtle text-warning border border-warning-subtle", "Partially Applied"),
|
||||
CreditMemoStatus.FullyApplied => ("bg-secondary-subtle text-secondary border border-secondary-subtle", "Fully Applied"),
|
||||
CreditMemoStatus.Voided => ("bg-danger-subtle text-danger border border-danger-subtle", "Voided"),
|
||||
_ => ("bg-secondary-subtle text-secondary", Model.Status.ToString())
|
||||
};
|
||||
}
|
||||
|
||||
@if (TempData["Success"] != null)
|
||||
{
|
||||
<div class="alert alert-success alert-permanent alert-dismissible fade show">
|
||||
@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">
|
||||
@TempData["Error"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Header ────────────────────────────────────────────────────── *@
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h4 class="mb-1">
|
||||
<i class="bi bi-journal-minus me-2 text-primary"></i>@Model.MemoNumber
|
||||
<span class="badge @badgeClass ms-2 fs-6">@badgeLabel</span>
|
||||
</h4>
|
||||
<div class="text-muted">
|
||||
Customer: <a asp-controller="Customers" asp-action="Details" asp-route-id="@Model.CustomerId"
|
||||
class="fw-semibold text-decoration-none">
|
||||
@(string.IsNullOrWhiteSpace(Model.Customer?.CompanyName) ? $"{Model.Customer?.ContactFirstName} {Model.Customer?.ContactLastName}".Trim() : Model.Customer.CompanyName)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
@if (canApply && openInvoices.Any())
|
||||
{
|
||||
<button class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#applyModal">
|
||||
<i class="bi bi-check2-circle me-1"></i>Apply to Invoice
|
||||
</button>
|
||||
}
|
||||
@if (Model.Status != CreditMemoStatus.Voided)
|
||||
{
|
||||
<button class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#voidModal">
|
||||
<i class="bi bi-x-circle me-1"></i>Void
|
||||
</button>
|
||||
}
|
||||
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
@* ── Left: memo details ──────────────────────────────────────── *@
|
||||
<div class="col-lg-5">
|
||||
<div class="card h-100">
|
||||
<div class="card-header fw-semibold">Credit Memo Details</div>
|
||||
<div class="card-body">
|
||||
@* Balance summary *@
|
||||
<div class="row text-center mb-4">
|
||||
<div class="col-4 border-end">
|
||||
<div class="small text-muted">Total Credit</div>
|
||||
<div class="fs-5 fw-bold">@Model.Amount.ToString("C")</div>
|
||||
</div>
|
||||
<div class="col-4 border-end">
|
||||
<div class="small text-muted">Applied</div>
|
||||
<div class="fs-5 fw-bold text-secondary">@Model.AmountApplied.ToString("C")</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="small text-muted">Remaining</div>
|
||||
<div class="fs-5 fw-bold @(Model.RemainingBalance > 0 ? "text-success" : "text-secondary")">
|
||||
@Model.RemainingBalance.ToString("C")
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl class="row mb-0 small">
|
||||
<dt class="col-5 text-muted">Issue Date</dt>
|
||||
<dd class="col-7">@Model.IssueDate.ToLocalTime().ToString("MMMM d, yyyy")</dd>
|
||||
|
||||
<dt class="col-5 text-muted">Expiry Date</dt>
|
||||
<dd class="col-7">
|
||||
@if (Model.ExpiryDate.HasValue)
|
||||
{
|
||||
var expired = Model.ExpiryDate.Value < DateTime.UtcNow;
|
||||
<span class="@(expired ? "text-danger fw-semibold" : "")">
|
||||
@Model.ExpiryDate.Value.ToLocalTime().ToString("MMMM d, yyyy")
|
||||
@if (expired) { <small>(Expired)</small> }
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">No expiry</span>
|
||||
}
|
||||
</dd>
|
||||
|
||||
@if (Model.OriginalInvoice != null)
|
||||
{
|
||||
<dt class="col-5 text-muted">Original Invoice</dt>
|
||||
<dd class="col-7">
|
||||
<a asp-controller="Invoices" asp-action="Details" asp-route-id="@Model.OriginalInvoiceId"
|
||||
class="text-decoration-none">@Model.OriginalInvoice.InvoiceNumber</a>
|
||||
</dd>
|
||||
}
|
||||
|
||||
<dt class="col-5 text-muted">Issued By</dt>
|
||||
<dd class="col-7">@(Model.IssuedBy?.FullName ?? "System")</dd>
|
||||
|
||||
<dt class="col-5 text-muted">Reason</dt>
|
||||
<dd class="col-7">@Model.Reason</dd>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Notes))
|
||||
{
|
||||
<dt class="col-5 text-muted">Notes</dt>
|
||||
<dd class="col-7">@Model.Notes</dd>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Right: application history ──────────────────────────────── *@
|
||||
<div class="col-lg-7">
|
||||
<div class="card h-100">
|
||||
<div class="card-header fw-semibold d-flex justify-content-between align-items-center">
|
||||
<span>Application History</span>
|
||||
<span class="badge bg-secondary-subtle text-secondary">@applications.Count applied</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@if (!applications.Any())
|
||||
{
|
||||
<div class="p-4 text-muted text-center">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
This credit memo has not been applied to any invoice yet.
|
||||
@if (canApply && openInvoices.Any())
|
||||
{
|
||||
<span>Use <strong>Apply to Invoice</strong> above to apply it.</span>
|
||||
}
|
||||
else if (canApply && !openInvoices.Any())
|
||||
{
|
||||
<span>No open invoices with a balance due for this customer.</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Invoice</th>
|
||||
<th>Date Applied</th>
|
||||
<th class="text-end">Amount</th>
|
||||
<th>Applied By</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var a in applications)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
<a asp-controller="Invoices" asp-action="Details"
|
||||
asp-route-id="@a.InvoiceId" class="text-decoration-none">
|
||||
@(a.Invoice?.InvoiceNumber ?? $"#{a.InvoiceId}")
|
||||
</a>
|
||||
</td>
|
||||
<td>@a.AppliedDate.ToLocalTime().ToString("MM/dd/yyyy")</td>
|
||||
<td class="text-end fw-semibold text-success">@a.AmountApplied.ToString("C")</td>
|
||||
<td class="small text-muted">@(a.AppliedBy?.FullName ?? "—")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Apply Modal ─────────────────────────────────────────────── *@
|
||||
@if (canApply)
|
||||
{
|
||||
<div class="modal fade" id="applyModal" tabindex="-1"
|
||||
data-remaining-balance="@Model.RemainingBalance.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form asp-action="Apply" asp-route-id="@Model.Id" method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Apply Credit to Invoice</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-info alert-permanent py-2 small">
|
||||
<strong>Available credit: @Model.RemainingBalance.ToString("C")</strong>
|
||||
</div>
|
||||
|
||||
@if (!openInvoices.Any())
|
||||
{
|
||||
<p class="text-muted">No open invoices with a balance due for this customer.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Select Invoice</label>
|
||||
<select name="invoiceId" id="applyInvoiceId" class="form-select" required>
|
||||
<option value="">— choose invoice —</option>
|
||||
@foreach (var inv in openInvoices)
|
||||
{
|
||||
<option value="@inv.Id"
|
||||
data-balance="@inv.BalanceDue.ToString("F2")">
|
||||
@inv.InvoiceNumber — Due @inv.BalanceDue.ToString("C")
|
||||
@if (inv.DueDate.HasValue && inv.DueDate.Value < DateTime.UtcNow)
|
||||
{ <text>(Overdue)</text> }
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Amount to Apply</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input type="number" name="amount" id="applyAmount"
|
||||
class="form-control" step="0.01" min="0.01"
|
||||
max="@Model.RemainingBalance.ToString("F2")" required />
|
||||
</div>
|
||||
<div id="applyMaxHint" class="form-text text-muted"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (openInvoices.Any())
|
||||
{
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Apply Credit</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Void Confirm Modal ──────────────────────────────────────── *@
|
||||
@if (Model.Status != CreditMemoStatus.Voided)
|
||||
{
|
||||
<div class="modal fade" id="voidModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<form asp-action="Void" asp-route-id="@Model.Id" method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Void Credit Memo</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to void <strong>@Model.MemoNumber</strong>?</p>
|
||||
@if (Model.RemainingBalance > 0)
|
||||
{
|
||||
<div class="alert alert-warning alert-permanent py-2">
|
||||
The unapplied balance of <strong>@Model.RemainingBalance.ToString("C")</strong>
|
||||
will be reversed from <strong>@(string.IsNullOrWhiteSpace(Model.Customer?.CompanyName) ? $"{Model.Customer?.ContactFirstName} {Model.Customer?.ContactLastName}".Trim() : Model.Customer?.CompanyName)</strong>'s credit balance.
|
||||
</div>
|
||||
}
|
||||
@if (Model.AmountApplied > 0)
|
||||
{
|
||||
<p class="small text-muted mb-0">
|
||||
The <strong>@Model.AmountApplied.ToString("C")</strong> already applied to invoices
|
||||
will <em>not</em> be reversed.
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="submit" class="btn btn-danger">Void Credit Memo</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/credit-memo.js"></script>
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
@model List<PowderCoating.Core.Entities.CreditMemo>
|
||||
@using PowderCoating.Core.Enums
|
||||
@{
|
||||
ViewData["Title"] = "Credit Memos";
|
||||
var status = ViewBag.Status as string ?? "";
|
||||
var search = ViewBag.Search as string ?? "";
|
||||
int activeCount = ViewBag.ActiveCount;
|
||||
decimal outstanding = ViewBag.OutstandingBalance;
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h4 class="mb-0"><i class="bi bi-journal-minus me-2 text-primary"></i>Credit Memos</h4>
|
||||
<a asp-action="Create" class="btn btn-primary btn-sm">
|
||||
<i class="bi bi-plus-lg me-1"></i>Issue Credit Memo
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if (TempData["Success"] != null)
|
||||
{
|
||||
<div class="alert alert-success alert-permanent alert-dismissible fade show">
|
||||
@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">
|
||||
@TempData["Error"]
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Stats bar ─────────────────────────────────────────────────── *@
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card border-0 bg-primary-subtle h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-primary fw-semibold small">Active Memos</div>
|
||||
<div class="fs-3 fw-bold">@activeCount</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card border-0 bg-warning-subtle h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-warning fw-semibold small">Outstanding Credit</div>
|
||||
<div class="fs-3 fw-bold">@outstanding.ToString("C")</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card border-0 bg-success-subtle h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-success fw-semibold small">Total Memos</div>
|
||||
<div class="fs-3 fw-bold">@Model.Count</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="card border-0 bg-secondary-subtle h-100">
|
||||
<div class="card-body">
|
||||
<div class="text-secondary fw-semibold small">Total Issued</div>
|
||||
<div class="fs-3 fw-bold">@Model.Sum(m => m.Amount).ToString("C")</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Filters ──────────────────────────────────────────────────── *@
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-2">
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small mb-1">Search</label>
|
||||
<input name="search" value="@search" class="form-control form-control-sm"
|
||||
placeholder="Customer, memo #, or reason…" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1">Status</label>
|
||||
<select name="status" class="form-select form-select-sm">
|
||||
<option value="" selected="@(status == "")">All Statuses</option>
|
||||
<option value="Active" selected="@(status == "Active")">Active</option>
|
||||
<option value="PartiallyApplied" selected="@(status == "PartiallyApplied")">Partially Applied</option>
|
||||
<option value="FullyApplied" selected="@(status == "FullyApplied")">Fully Applied</option>
|
||||
<option value="Voided" selected="@(status == "Voided")">Voided</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-auto">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Filter</button>
|
||||
<a asp-action="Index" class="btn btn-sm btn-outline-secondary ms-1">Clear</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Table ────────────────────────────────────────────────────── *@
|
||||
@if (!Model.Any())
|
||||
{
|
||||
<div class="alert alert-info alert-permanent">No credit memos found.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Memo #</th>
|
||||
<th>Customer</th>
|
||||
<th class="text-end">Amount</th>
|
||||
<th class="text-end">Applied</th>
|
||||
<th class="text-end">Remaining</th>
|
||||
<th>Issue Date</th>
|
||||
<th>Expires</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var m in Model)
|
||||
{
|
||||
var rowClass = m.Status == CreditMemoStatus.Voided ? "text-muted" : "";
|
||||
var expired = m.ExpiryDate.HasValue && m.ExpiryDate.Value < DateTime.UtcNow
|
||||
&& m.Status != CreditMemoStatus.FullyApplied
|
||||
&& m.Status != CreditMemoStatus.Voided;
|
||||
<tr class="@rowClass">
|
||||
<td>
|
||||
<a asp-action="Details" asp-route-id="@m.Id" class="fw-semibold text-decoration-none">
|
||||
@m.MemoNumber
|
||||
</a>
|
||||
</td>
|
||||
<td>@(string.IsNullOrWhiteSpace(m.Customer?.CompanyName) ? $"{m.Customer?.ContactFirstName} {m.Customer?.ContactLastName}".Trim() : m.Customer.CompanyName)</td>
|
||||
<td class="text-end">@m.Amount.ToString("C")</td>
|
||||
<td class="text-end">@m.AmountApplied.ToString("C")</td>
|
||||
<td class="text-end @(m.RemainingBalance > 0 && m.Status != CreditMemoStatus.Voided ? "text-success fw-semibold" : "")">
|
||||
@m.RemainingBalance.ToString("C")
|
||||
</td>
|
||||
<td>@m.IssueDate.ToLocalTime().ToString("MM/dd/yyyy")</td>
|
||||
<td class="@(expired ? "text-danger fw-semibold" : "")">
|
||||
@(m.ExpiryDate.HasValue ? m.ExpiryDate.Value.ToLocalTime().ToString("MM/dd/yyyy") : "—")
|
||||
@if (expired) { <small>(Expired)</small> }
|
||||
</td>
|
||||
<td>
|
||||
@{
|
||||
var (badgeClass, badgeLabel) = m.Status switch
|
||||
{
|
||||
CreditMemoStatus.Active => ("bg-success-subtle text-success", "Active"),
|
||||
CreditMemoStatus.PartiallyApplied => ("bg-warning-subtle text-warning", "Partial"),
|
||||
CreditMemoStatus.FullyApplied => ("bg-secondary-subtle text-secondary", "Applied"),
|
||||
CreditMemoStatus.Voided => ("bg-danger-subtle text-danger", "Voided"),
|
||||
_ => ("bg-secondary-subtle text-secondary", m.Status.ToString())
|
||||
};
|
||||
}
|
||||
<span class="badge @badgeClass">@badgeLabel</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a asp-action="Details" asp-route-id="@m.Id"
|
||||
class="btn btn-sm btn-outline-primary">Details</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
@model PowderCoating.Application.DTOs.Customer.CustomerDto
|
||||
@model PowderCoating.Application.DTOs.Customer.CustomerDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Delete Customer";
|
||||
@@ -14,7 +14,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Warning Banner -->
|
||||
<div class="alert alert-danger d-flex align-items-start mb-4">
|
||||
<div class="alert alert-danger alert-permanent d-flex align-items-start mb-4">
|
||||
<i class="bi bi-exclamation-triangle-fill me-3" style="font-size: 1.5rem;"></i>
|
||||
<div>
|
||||
<h5 class="alert-heading mb-2">Are you sure you want to delete this customer?</h5>
|
||||
@@ -141,7 +141,7 @@
|
||||
|
||||
@if (Model.CurrentBalance > 0)
|
||||
{
|
||||
<div class="alert alert-warning d-flex align-items-center">
|
||||
<div class="alert alert-warning alert-permanent d-flex align-items-center">
|
||||
<i class="bi bi-exclamation-circle me-2"></i>
|
||||
<div>
|
||||
<strong>Warning:</strong> This customer has an outstanding balance of @Model.CurrentBalance.ToString("C"). Please ensure all balances are settled before deletion.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model PowderCoating.Application.DTOs.Customer.CustomerDto
|
||||
@model PowderCoating.Application.DTOs.Customer.CustomerDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = !string.IsNullOrWhiteSpace(Model.CompanyName)
|
||||
@@ -123,7 +123,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">Not set — invoices go to contact email</span>
|
||||
<span class="text-muted">Not set — invoices go to contact email</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
@@ -272,7 +272,7 @@
|
||||
{
|
||||
<div class="col-md-12">
|
||||
<label class="text-muted small mb-1">Tax Exempt Certificate</label>
|
||||
<div class="alert alert-success d-flex justify-content-between align-items-center mb-0 mt-2">
|
||||
<div class="alert alert-success alert-permanent d-flex justify-content-between align-items-center mb-0 mt-2">
|
||||
<div>
|
||||
<i class="bi bi-file-earmark-check me-2"></i>
|
||||
<strong>File on record:</strong> @Model.TaxExemptCertificateFileName
|
||||
@@ -287,7 +287,7 @@
|
||||
{
|
||||
<div class="col-md-12">
|
||||
<label class="text-muted small mb-1">Tax Exempt Certificate</label>
|
||||
<div class="alert alert-warning mb-0 mt-2">
|
||||
<div class="alert alert-warning alert-permanent mb-0 mt-2">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong>No certificate on file.</strong> Please upload a tax exempt certificate to complete the record.
|
||||
</div>
|
||||
@@ -459,6 +459,9 @@
|
||||
<a asp-action="Invoices" asp-route-id="@Model.Id" class="btn btn-outline-warning">
|
||||
<i class="bi bi-receipt me-2"></i>View Invoices
|
||||
</a>
|
||||
<a asp-action="Statement" asp-route-id="@Model.Id" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-journal-text me-2"></i>Statement
|
||||
</a>
|
||||
<a asp-controller="Jobs" asp-action="Create" asp-route-customerId="@Model.Id" class="btn btn-outline-success">
|
||||
<i class="bi bi-plus-circle me-2"></i>New Job
|
||||
</a>
|
||||
@@ -511,7 +514,7 @@
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Reason <span class="text-danger">*</span></label>
|
||||
<select name="Reason" class="form-select" required id="creditReasonSelect">
|
||||
<option value="">— Select reason —</option>
|
||||
<option value="">— Select reason —</option>
|
||||
<option value="Pre-payment / Deposit">Pre-payment / Deposit</option>
|
||||
<option value="Gift / Gift Card">Gift / Gift Card</option>
|
||||
<option value="Overpayment credit">Overpayment credit</option>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model PowderCoating.Application.DTOs.Customer.UpdateCustomerDto
|
||||
@model PowderCoating.Application.DTOs.Customer.UpdateCustomerDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Edit Customer";
|
||||
@@ -26,7 +26,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Company Information"
|
||||
data-bs-content="Company Name is used on quotes, invoices, and correspondence. Customer Type controls which features are available — Commercial customers get payment terms, credit limits, and pricing tier discounts. Status Inactive hides the customer from new quote/job dropdowns but preserves all history.">
|
||||
data-bs-content="Company Name is used on quotes, invoices, and correspondence. Customer Type controls which features are available — Commercial customers get payment terms, credit limits, and pricing tier discounts. Status Inactive hides the customer from new quote/job dropdowns but preserves all history.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -220,7 +220,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Business Information"
|
||||
data-bs-content="Payment Terms sets the default due date on invoices (e.g., Net 30 = 30 days from invoice date). Credit Limit is a soft warning cap — the system alerts when exceeded. Tax Exempt removes tax from all invoices; upload the exemption certificate in the Tax Exempt Certificate section below.">
|
||||
data-bs-content="Payment Terms sets the default due date on invoices (e.g., Net 30 = 30 days from invoice date). Credit Limit is a soft warning cap — the system alerts when exceeded. Tax Exempt removes tax from all invoices; upload the exemption certificate in the Tax Exempt Certificate section below.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -246,7 +246,7 @@
|
||||
<div class="col-md-3">
|
||||
<label asp-for="PricingTierId" class="form-label">Pricing Tier</label>
|
||||
<select asp-for="PricingTierId" asp-items="ViewBag.PricingTiers" class="form-select">
|
||||
<option value="">— No tier —</option>
|
||||
<option value="">— No tier —</option>
|
||||
</select>
|
||||
<small class="text-muted">Applies a discount to all quotes for this customer.</small>
|
||||
</div>
|
||||
@@ -291,7 +291,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Notification Preferences"
|
||||
data-bs-content="Controls when the customer receives automatic updates. Email notifications send status change alerts (e.g., job ready for pickup) to the customer's email address. SMS requires separate TCPA consent — uncheck 'SMS Notifications Active' to temporarily pause without revoking consent.">
|
||||
data-bs-content="Controls when the customer receives automatic updates. Email notifications send status change alerts (e.g., job ready for pickup) to the customer's email address. SMS requires separate TCPA consent — uncheck 'SMS Notifications Active' to temporarily pause without revoking consent.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -314,7 +314,7 @@
|
||||
<div class="mt-3">
|
||||
@if (Model.SmsConsentedAt.HasValue)
|
||||
{
|
||||
<!-- Consent already recorded — show status and allow pause/resume -->
|
||||
<!-- Consent already recorded — show status and allow pause/resume -->
|
||||
<div class="card border-success bg-success-subtle p-3 mb-2">
|
||||
<div class="d-flex align-items-start gap-3">
|
||||
<i class="bi bi-shield-fill-check text-success fs-4 mt-1"></i>
|
||||
@@ -339,7 +339,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<!-- No consent on file — show the compliance notice and consent checkbox -->
|
||||
<!-- No consent on file — show the compliance notice and consent checkbox -->
|
||||
<div class="alert alert-warning border-warning alert-permanent" role="alert">
|
||||
<h6 class="alert-heading fw-bold mb-2">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>SMS Consent Requirement (TCPA)
|
||||
@@ -399,7 +399,7 @@
|
||||
<div class="col-md-6">
|
||||
@if (Model.HasTaxExemptCertificate)
|
||||
{
|
||||
<div class="alert alert-success d-flex justify-content-between align-items-center">
|
||||
<div class="alert alert-success alert-permanent d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i class="bi bi-file-earmark-check me-2"></i>
|
||||
<strong>Certificate on file:</strong> @Model.TaxExemptCertificateFileName
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
@model PowderCoating.Application.DTOs.Accounting.CustomerStatementDto
|
||||
@{
|
||||
ViewData["Title"] = $"Statement – {Model.CustomerName}";
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-start mb-4 flex-wrap gap-2">
|
||||
<div>
|
||||
<h4 class="mb-0">Customer Statement</h4>
|
||||
<p class="text-muted mb-0">@Model.CustomerName · @Model.From.ToString("MMM d, yyyy") – @Model.To.ToString("MMM d, yyyy")</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<form method="get" class="d-flex gap-2 align-items-center">
|
||||
<input type="hidden" name="id" value="@(ViewContext.RouteData.Values["id"])" />
|
||||
<input type="date" name="from" class="form-control form-control-sm" value="@Model.From.ToString("yyyy-MM-dd")" />
|
||||
<input type="date" name="to" class="form-control form-control-sm" value="@Model.To.ToString("yyyy-MM-dd")" />
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">Refresh</button>
|
||||
</form>
|
||||
<a asp-action="Statement" asp-route-id="@(ViewContext.RouteData.Values["id"])"
|
||||
asp-route-from="@Model.From.ToString("yyyy-MM-dd")"
|
||||
asp-route-to="@Model.To.ToString("yyyy-MM-dd")"
|
||||
asp-route-pdf="true"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
<i class="bi bi-file-earmark-pdf me-1"></i>Download PDF
|
||||
</a>
|
||||
<a asp-action="Details" asp-route-id="@(ViewContext.RouteData.Values["id"])" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header border-0 py-3 bg-white d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<span class="fw-semibold">@Model.CustomerName</span>
|
||||
@if (!string.IsNullOrWhiteSpace(Model.CustomerAddress))
|
||||
{
|
||||
<span class="text-muted small ms-2">@Model.CustomerAddress</span>
|
||||
}
|
||||
</div>
|
||||
<div class="text-muted small">@Model.CompanyName</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-dark">
|
||||
<tr>
|
||||
<th style="width:100px">Date</th>
|
||||
<th style="width:120px">Type</th>
|
||||
<th style="width:130px">Reference</th>
|
||||
<th>Description</th>
|
||||
<th class="text-end" style="width:110px">Debit</th>
|
||||
<th class="text-end" style="width:110px">Credit</th>
|
||||
<th class="text-end" style="width:120px">Balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<!-- Opening balance -->
|
||||
<tr class="table-light fw-semibold">
|
||||
<td class="text-muted">@Model.From.AddDays(-1).ToString("MM/dd/yy")</td>
|
||||
<td colspan="5">Opening Balance</td>
|
||||
<td class="text-end">@Model.OpeningBalance.ToString("C")</td>
|
||||
</tr>
|
||||
|
||||
@if (!Model.Lines.Any())
|
||||
{
|
||||
<tr>
|
||||
<td colspan="7" class="text-center text-muted py-4">No activity in this period.</td>
|
||||
</tr>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (var line in Model.Lines)
|
||||
{
|
||||
<tr>
|
||||
<td class="text-muted small">@line.Date.ToString("MM/dd/yy")</td>
|
||||
<td>
|
||||
<span class="badge @(line.Type == "Invoice" ? "bg-primary" : line.Type == "Payment" ? "bg-success" : "bg-secondary") text-white">
|
||||
@line.Type
|
||||
</span>
|
||||
</td>
|
||||
<td class="small">@line.Reference</td>
|
||||
<td class="small text-muted">@line.Description</td>
|
||||
<td class="text-end small">@(line.Debit.HasValue ? line.Debit.Value.ToString("C") : "")</td>
|
||||
<td class="text-end small">@(line.Credit.HasValue ? line.Credit.Value.ToString("C") : "")</td>
|
||||
<td class="text-end small @(line.RunningBalance > 0 ? "text-danger" : "text-success") fw-semibold">
|
||||
@line.RunningBalance.ToString("C")
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Closing balance -->
|
||||
<tr class="table-secondary fw-bold">
|
||||
<td colspan="6">Closing Balance</td>
|
||||
<td class="text-end @(Model.ClosingBalance > 0 ? "text-danger" : "text-success")">
|
||||
@Model.ClosingBalance.ToString("C")
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,4 +1,4 @@
|
||||
@model PowderCoating.Application.DTOs.Dashboard.DashboardViewModel
|
||||
@model PowderCoating.Application.DTOs.Dashboard.DashboardViewModel
|
||||
@using Microsoft.AspNetCore.Html
|
||||
@using PowderCoating.Application.DTOs.Health
|
||||
@using PowderCoating.Web.ViewModels.Dashboard
|
||||
@@ -24,7 +24,7 @@
|
||||
<p class="mb-3" style="font-family:var(--font-display);font-size:1.35rem;font-weight:500;line-height:1.4;color:var(--pcl-ink);">
|
||||
@if (_attnCount > 0)
|
||||
{
|
||||
<span>Shop is </span><span style="color:var(--pcl-bad);">running hot</span><span> — @_attnCount item@(_attnCount == 1 ? "" : "s") need attention.</span>
|
||||
<span>Shop is </span><span style="color:var(--pcl-bad);">running hot</span><span> — @_attnCount item@(_attnCount == 1 ? "" : "s") need attention.</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -59,7 +59,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* PWA install banner — rendered by JS only on mobile, hidden once dismissed or already installed *@
|
||||
@* PWA install banner — rendered by JS only on mobile, hidden once dismissed or already installed *@
|
||||
<div id="pwa-install-banner" class="row mb-4" style="display:none!important;">
|
||||
<div class="col-12">
|
||||
<div class="alert alert-permanent mb-0 d-flex align-items-start gap-3 py-3"
|
||||
@@ -104,7 +104,7 @@
|
||||
@await Html.PartialAsync("_ShopProgressWidget", shopProgressWidget)
|
||||
}
|
||||
|
||||
@* Config health alert — only shown when there are setup gaps *@
|
||||
@* Config health alert — only shown when there are setup gaps *@
|
||||
@if (configHealth != null && !configHealth.IsHealthy)
|
||||
{
|
||||
<div class="row mb-4">
|
||||
@@ -406,15 +406,15 @@
|
||||
}
|
||||
@if (Model.AgingDays1To30 > 0)
|
||||
{
|
||||
<span><span class="me-1" style="display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--pcl-warn);"></span>1–30d @Model.AgingDays1To30.ToString("C0")</span>
|
||||
<span><span class="me-1" style="display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--pcl-warn);"></span>1–30d @Model.AgingDays1To30.ToString("C0")</span>
|
||||
}
|
||||
@if (Model.AgingDays31To60 > 0)
|
||||
{
|
||||
<span><span class="me-1" style="display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--pcl-bad);"></span>31–60d @Model.AgingDays31To60.ToString("C0")</span>
|
||||
<span><span class="me-1" style="display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--pcl-bad);"></span>31–60d @Model.AgingDays31To60.ToString("C0")</span>
|
||||
}
|
||||
@if (Model.AgingDays61To90 > 0)
|
||||
{
|
||||
<span><span class="me-1" style="display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--pcl-bad);"></span>61–90d @Model.AgingDays61To90.ToString("C0")</span>
|
||||
<span><span class="me-1" style="display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--pcl-bad);"></span>61–90d @Model.AgingDays61To90.ToString("C0")</span>
|
||||
}
|
||||
@if (Model.AgingDaysOver90 > 0)
|
||||
{
|
||||
@@ -536,7 +536,7 @@
|
||||
@if (line.EstCost.HasValue)
|
||||
{<span>@line.EstCost.Value.ToString("C")</span>}
|
||||
else
|
||||
{<span class="text-muted">—</span>}
|
||||
{<span class="text-muted">—</span>}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<button class="btn btn-sm btn-outline-danger mark-ordered-btn text-nowrap"
|
||||
@@ -552,7 +552,7 @@
|
||||
<tr>
|
||||
<td colspan="2">Vendor Total</td>
|
||||
<td class="text-end">@vendorGroup.TotalLbsNeeded.ToString("N2") lbs</td>
|
||||
<td class="text-end">@(vendorGroup.TotalEstCost > 0 ? vendorGroup.TotalEstCost.ToString("C") : "—")</td>
|
||||
<td class="text-end">@(vendorGroup.TotalEstCost > 0 ? vendorGroup.TotalEstCost.ToString("C") : "—")</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
@@ -571,7 +571,7 @@
|
||||
<div class="card border-0 shadow-sm dashboard-card" style="border-left: 4px solid #0d6efd !important;">
|
||||
<div class="card-header bg-body border-0 d-flex justify-content-between align-items-center pt-4 pb-3">
|
||||
<h5 class="mb-0 fw-bold">
|
||||
<i class="bi bi-box-arrow-in-down me-2 text-muted"></i>Powder Ordered — Awaiting Receipt
|
||||
<i class="bi bi-box-arrow-in-down me-2 text-muted"></i>Powder Ordered — Awaiting Receipt
|
||||
<span class="ms-2 text-muted fw-normal small" id="placed-count-label">@Model.PowderOrdersPlacedCount item@(Model.PowderOrdersPlacedCount == 1 ? "" : "s")</span>
|
||||
</h5>
|
||||
<small class="text-muted">Grouped by vendor · Enter lbs received to update inventory</small>
|
||||
@@ -630,13 +630,13 @@
|
||||
@if (line.EstCost.HasValue)
|
||||
{<span>@line.EstCost.Value.ToString("C")</span>}
|
||||
else
|
||||
{<span class="text-muted">—</span>}
|
||||
{<span class="text-muted">—</span>}
|
||||
</td>
|
||||
<td class="text-muted small">
|
||||
@if (line.OrderedAt.HasValue)
|
||||
{<span title="@line.OrderedAt.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("g")">@line.OrderedAt.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MMM d")</span>}
|
||||
else
|
||||
{<span>—</span>}
|
||||
{<span>—</span>}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<div class="d-flex align-items-center gap-1 justify-content-center receive-form-@line.CoatId">
|
||||
@@ -669,7 +669,7 @@
|
||||
<tr>
|
||||
<td colspan="2">Vendor Total</td>
|
||||
<td class="text-end">@vendorGroup.TotalLbsNeeded.ToString("N2") lbs</td>
|
||||
<td class="text-end">@(vendorGroup.TotalEstCost > 0 ? vendorGroup.TotalEstCost.ToString("C") : "—")</td>
|
||||
<td class="text-end">@(vendorGroup.TotalEstCost > 0 ? vendorGroup.TotalEstCost.ToString("C") : "—")</td>
|
||||
<td colspan="2"></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
@@ -704,7 +704,7 @@
|
||||
|
||||
<div id="apm-ai-status" class="d-none py-2 small mb-3"></div>
|
||||
|
||||
<div class="alert alert-info py-2 small mb-3">
|
||||
<div class="alert alert-info alert-permanent py-2 small mb-3">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Fields pre-filled from the powder order. Fill in any missing details then click Save.
|
||||
</div>
|
||||
@@ -739,7 +739,7 @@
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Category</label>
|
||||
<select class="form-select" id="apm-categoryId" name="inventoryCategoryId">
|
||||
<option value="">— Select category —</option>
|
||||
<option value="">— Select category —</option>
|
||||
@if (ViewBag.InventoryCategories != null)
|
||||
{
|
||||
foreach (var cat in (IEnumerable<dynamic>)ViewBag.InventoryCategories)
|
||||
@@ -753,7 +753,7 @@
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-medium">Primary Vendor</label>
|
||||
<select class="form-select" id="apm-vendorId" name="primaryVendorId">
|
||||
<option value="">— Select vendor —</option>
|
||||
<option value="">— Select vendor —</option>
|
||||
@if (ViewBag.VendorList != null)
|
||||
{
|
||||
foreach (var v in (IEnumerable<dynamic>)ViewBag.VendorList)
|
||||
@@ -883,7 +883,7 @@
|
||||
|
||||
const esc = s => s ? s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"') : '';
|
||||
const estCost = (c.lbsToOrder && c.costPerLb) ? (c.lbsToOrder * c.costPerLb) : null;
|
||||
const orderedDate = c.orderedAt ? new Date(c.orderedAt).toLocaleDateString('en-US',{month:'short',day:'numeric'}) : '—';
|
||||
const orderedDate = c.orderedAt ? new Date(c.orderedAt).toLocaleDateString('en-US',{month:'short',day:'numeric'}) : '—';
|
||||
const lbsFmt = c.lbsToOrder ? parseFloat(c.lbsToOrder).toFixed(2) : '0.00';
|
||||
|
||||
// Find or create vendor group
|
||||
@@ -928,7 +928,7 @@
|
||||
${c.finish ? `<span class="badge bg-light text-dark border ms-1">${esc(c.finish)}</span>` : ''}
|
||||
</td>
|
||||
<td class="text-end fw-medium">${lbsFmt} lbs</td>
|
||||
<td class="text-end">${estCost ? '$' + estCost.toFixed(2) : '<span class="text-muted">—</span>'}</td>
|
||||
<td class="text-end">${estCost ? '$' + estCost.toFixed(2) : '<span class="text-muted">—</span>'}</td>
|
||||
<td class="text-muted small">${orderedDate}</td>
|
||||
<td class="text-center">
|
||||
<div class="d-flex align-items-center gap-1 justify-content-center receive-form-${c.coatId}">
|
||||
@@ -979,7 +979,7 @@
|
||||
}
|
||||
qtyInput.classList.remove('is-invalid');
|
||||
|
||||
// Custom powder (no inventory item) → open modal to add to inventory
|
||||
// Custom powder (no inventory item) → open modal to add to inventory
|
||||
if (!hasInv) {
|
||||
const modal = document.getElementById('addPowderModal');
|
||||
// Pre-fill hidden + text fields
|
||||
@@ -1024,7 +1024,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Inventory item exists → receive directly
|
||||
// Inventory item exists → receive directly
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value
|
||||
?? document.querySelector('meta[name="__RequestVerificationToken"]')?.content;
|
||||
|
||||
@@ -1065,7 +1065,7 @@
|
||||
?? document.querySelector('meta[name="__RequestVerificationToken"]')?.content;
|
||||
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving…';
|
||||
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving…';
|
||||
|
||||
try {
|
||||
const resp = await fetch('@Url.Action("AddCustomPowderToInventory", "Dashboard")', {
|
||||
@@ -1094,7 +1094,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ── AI Lookup for Add Powder modal ───────────────────────────────────────
|
||||
// ââ€â‚¬Ã¢â€â‚¬ AI Lookup for Add Powder modal ââ€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬
|
||||
(function () {
|
||||
const apmBtn = document.getElementById('apm-ai-btn');
|
||||
const apmStatusEl = document.getElementById('apm-ai-status');
|
||||
@@ -1144,7 +1144,7 @@
|
||||
|
||||
const hasInput = manufacturer || colorName || colorCode || partNumber || itemName;
|
||||
if (!hasInput) {
|
||||
apmShowStatus('warning', '<i class="bi bi-exclamation-triangle me-1"></i>Fill in at least one field — Manufacturer, Color Name, Color Code, or Item Name — then try again.');
|
||||
apmShowStatus('warning', '<i class="bi bi-exclamation-triangle me-1"></i>Fill in at least one field — Manufacturer, Color Name, Color Code, or Item Name — then try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1153,7 +1153,7 @@
|
||||
document.getElementById('apm-bad-match-btn')?.remove();
|
||||
apmBtn.disabled = true;
|
||||
apmBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Looking up...';
|
||||
apmShowStatus('info', '<i class="bi bi-hourglass-split me-1"></i>Searching for product specifications…');
|
||||
apmShowStatus('info', '<i class="bi bi-hourglass-split me-1"></i>Searching for product specifications…');
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
@@ -1220,7 +1220,7 @@
|
||||
: '';
|
||||
apmShowStatus('success', `<i class="bi bi-check-circle me-1"></i>Auto-filled: ${filled.join(', ')}.${reasoning}`);
|
||||
} else {
|
||||
apmShowStatus('warning', '<i class="bi bi-info-circle me-1"></i>No new fields to fill — they may already be populated, or the product wasn\'t found.');
|
||||
apmShowStatus('warning', '<i class="bi bi-info-circle me-1"></i>No new fields to fill — they may already be populated, or the product wasn\'t found.');
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
@@ -1274,7 +1274,7 @@
|
||||
(function () {
|
||||
var DISMISSED_KEY = 'pcl_pwa_banner_dismissed';
|
||||
|
||||
// Already installed as standalone — never show
|
||||
// Already installed as standalone — never show
|
||||
var isStandalone = window.navigator.standalone === true ||
|
||||
window.matchMedia('(display-mode: standalone)').matches;
|
||||
if (isStandalone) return;
|
||||
@@ -1298,7 +1298,7 @@
|
||||
var isSafari = /webkit/i.test(ua) && !/crios|chrome|fxios|opios/i.test(ua);
|
||||
if (isSafari) {
|
||||
titleEl.textContent = 'Add to Home Screen';
|
||||
msgEl.innerHTML = 'For the best experience — and so the camera only asks once — open the ' +
|
||||
msgEl.innerHTML = 'For the best experience — and so the camera only asks once — open the ' +
|
||||
'<strong>Share menu</strong> <span style="font-size:1.1em">▲</span> at the bottom of Safari ' +
|
||||
'and tap <strong>Add to Home Screen</strong>.';
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@using PowderCoating.Web.Controllers
|
||||
@using PowderCoating.Web.Controllers
|
||||
@model List<EntityPurgeStat>
|
||||
@{
|
||||
ViewData["Title"] = "Data Purge & Cleanup";
|
||||
@@ -56,10 +56,10 @@
|
||||
}
|
||||
|
||||
@* Warning banner *@
|
||||
<div class="alert alert-warning d-flex gap-3 align-items-start mb-3">
|
||||
<div class="alert alert-warning alert-permanent d-flex gap-3 align-items-start mb-3">
|
||||
<i class="bi bi-exclamation-triangle-fill fs-4 flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
<strong>Destructive operation — this cannot be undone.</strong>
|
||||
<strong>Destructive operation — this cannot be undone.</strong>
|
||||
Purging permanently deletes records from the database. Soft-deleted records are hidden from users but still occupy database space. Use this tool periodically to reclaim space and keep the database clean.
|
||||
Job photo blobs in Azure Storage are also deleted when purging job photo records.
|
||||
</div>
|
||||
@@ -83,8 +83,8 @@
|
||||
<th style="width:36px"></th>
|
||||
<th>Entity</th>
|
||||
<th class="text-end" style="width:90px">Total</th>
|
||||
<th class="text-end" style="width:100px">0–30d</th>
|
||||
<th class="text-end" style="width:100px">30–90d</th>
|
||||
<th class="text-end" style="width:100px">0–30d</th>
|
||||
<th class="text-end" style="width:100px">30–90d</th>
|
||||
<th class="text-end" style="width:100px">>90d</th>
|
||||
<th style="width:130px">Oldest</th>
|
||||
<th style="width:42px">
|
||||
@@ -109,7 +109,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
@@ -117,24 +117,24 @@
|
||||
{
|
||||
<span class="badge bg-success-subtle text-success">@s.DeletedLast30Days</span>
|
||||
}
|
||||
else { <span class="text-muted">—</span> }
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td class="text-end">
|
||||
@if (s.Deleted30To90Days > 0)
|
||||
{
|
||||
<span class="badge bg-warning-subtle text-warning">@s.Deleted30To90Days</span>
|
||||
}
|
||||
else { <span class="text-muted">—</span> }
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td class="text-end">
|
||||
@if (s.DeletedOlderThan90Days > 0)
|
||||
{
|
||||
<span class="badge bg-danger-subtle text-danger">@s.DeletedOlderThan90Days</span>
|
||||
}
|
||||
else { <span class="text-muted">—</span> }
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td class="text-muted">
|
||||
@(s.OldestDeletion.HasValue ? s.OldestDeletion.Value.ToString("MM/dd/yyyy") : "—")
|
||||
@(s.OldestDeletion.HasValue ? s.OldestDeletion.Value.ToString("MM/dd/yyyy") : "—")
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<input type="checkbox" class="form-check-input entity-select"
|
||||
@@ -149,7 +149,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile card view for this group — shown on screens < 992px -->
|
||||
<!-- Mobile card view for this group — shown on screens < 992px -->
|
||||
<div class="mobile-card-view">
|
||||
<div class="px-3 pt-2 pb-1">
|
||||
<span class="text-muted text-uppercase fw-semibold" style="font-size:0.7rem;letter-spacing:.05em">@group.Key</span>
|
||||
@@ -164,7 +164,7 @@
|
||||
</div>
|
||||
<div class="mobile-card-title">
|
||||
<h6>@s.Label</h6>
|
||||
<small>Oldest: @(s.OldestDeletion.HasValue ? s.OldestDeletion.Value.ToString("MM/dd/yyyy") : "—")</small>
|
||||
<small>Oldest: @(s.OldestDeletion.HasValue ? s.OldestDeletion.Value.ToString("MM/dd/yyyy") : "—")</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-card-body">
|
||||
@@ -175,11 +175,11 @@
|
||||
{
|
||||
<span class="badge bg-secondary">@s.Total</span>
|
||||
}
|
||||
else { <span class="text-muted">—</span> }
|
||||
else { <span class="text-muted">—</span> }
|
||||
</span>
|
||||
</div>
|
||||
<div class="mobile-card-row">
|
||||
<span class="mobile-card-label">0–30d / 30–90d / >90d</span>
|
||||
<span class="mobile-card-label">0–30d / 30–90d / >90d</span>
|
||||
<span class="mobile-card-value">@s.DeletedLast30Days / @s.Deleted30To90Days / @s.DeletedOlderThan90Days</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -300,7 +300,7 @@
|
||||
const confirmModal = new bootstrap.Modal(document.getElementById('confirmModal'));
|
||||
const confirmSummary= document.getElementById('confirmSummary');
|
||||
|
||||
// ── Select all ──────────────────────────────────────────────────────────
|
||||
// ── Select all ──────────────────────────────────────────────────────────
|
||||
selectAll.addEventListener('change', () => {
|
||||
document.querySelectorAll('.entity-select:not(:disabled)').forEach(cb => {
|
||||
cb.checked = selectAll.checked;
|
||||
@@ -308,7 +308,7 @@
|
||||
updatePurgeBtn();
|
||||
});
|
||||
|
||||
// ── Group select all ────────────────────────────────────────────────────
|
||||
// ── Group select all ────────────────────────────────────────────────────
|
||||
document.querySelectorAll('.group-select-all').forEach(ga => {
|
||||
ga.addEventListener('change', () => {
|
||||
document.querySelectorAll(`.entity-select[data-group="${ga.dataset.group}"]:not(:disabled)`)
|
||||
@@ -335,7 +335,7 @@
|
||||
previewRes.classList.add('d-none');
|
||||
}
|
||||
|
||||
// ── Preview ─────────────────────────────────────────────────────────────
|
||||
// ── Preview ─────────────────────────────────────────────────────────────
|
||||
previewBtn.addEventListener('click', async () => {
|
||||
const entities = getSelectedEntities();
|
||||
if (!entities.length) {
|
||||
@@ -344,7 +344,7 @@
|
||||
}
|
||||
|
||||
previewBtn.disabled = true;
|
||||
previewBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Loading…';
|
||||
previewBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Loading…';
|
||||
|
||||
const days = document.getElementById('olderThanDays').value;
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]').value;
|
||||
@@ -379,7 +379,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ── Purge button → modal ────────────────────────────────────────────────
|
||||
// ── Purge button → modal ────────────────────────────────────────────────
|
||||
purgeBtn.addEventListener('click', () => {
|
||||
const entities = getSelectedEntities();
|
||||
const days = document.getElementById('olderThanDays').value;
|
||||
@@ -390,7 +390,7 @@
|
||||
confirmModal.show();
|
||||
});
|
||||
|
||||
// ── Confirm → submit form ───────────────────────────────────────────────
|
||||
// ── Confirm → submit form ───────────────────────────────────────────────
|
||||
document.getElementById('confirmPurgeBtn').addEventListener('click', () => {
|
||||
const entities = getSelectedEntities();
|
||||
const days = document.getElementById('olderThanDays').value;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model PowderCoating.Web.Controllers.DiagnosticsInfo
|
||||
@model PowderCoating.Web.Controllers.DiagnosticsInfo
|
||||
@{
|
||||
ViewData["Title"] = "System Diagnostics";
|
||||
ViewData["PageIcon"] = "bi-activity";
|
||||
@@ -47,11 +47,11 @@
|
||||
<td>
|
||||
@if (Model.CanWriteToAppPath)
|
||||
{
|
||||
<span class="badge bg-success">✓ YES</span>
|
||||
<span class="badge bg-success">✓ YES</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-danger">✗ NO - PERMISSION ISSUE</span>
|
||||
<span class="badge bg-danger">✗ NO - PERMISSION ISSUE</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -60,11 +60,11 @@
|
||||
<td>
|
||||
@if (Model.LogsDirectoryExists)
|
||||
{
|
||||
<span class="badge bg-success">✓ YES</span>
|
||||
<span class="badge bg-success">✓ YES</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-warning">✗ NO</span>
|
||||
<span class="badge bg-warning">✗ NO</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -73,11 +73,11 @@
|
||||
<td>
|
||||
@if (Model.CanWriteToLogsPath)
|
||||
{
|
||||
<span class="badge bg-success">✓ YES</span>
|
||||
<span class="badge bg-success">✓ YES</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-danger">✗ NO - PERMISSION ISSUE</span>
|
||||
<span class="badge bg-danger">✗ NO - PERMISSION ISSUE</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -111,7 +111,7 @@
|
||||
<p><strong>Message:</strong> @Model.LoggingTestMessage</p>
|
||||
@if (Model.LoggingTestSuccess)
|
||||
{
|
||||
<div class="alert alert-info">
|
||||
<div class="alert alert-info alert-permanent">
|
||||
<i class="bi bi-info-circle"></i> A test log entry was written. Check the log files below to see if it appears.
|
||||
</div>
|
||||
}
|
||||
@@ -132,7 +132,7 @@
|
||||
<div class="card-body">
|
||||
@if (Model.LogFilesError != null)
|
||||
{
|
||||
<div class="alert alert-danger">
|
||||
<div class="alert alert-danger alert-permanent">
|
||||
<strong>Error reading log files:</strong> @Model.LogFilesError
|
||||
</div>
|
||||
}
|
||||
@@ -162,7 +162,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-warning">
|
||||
<div class="alert alert-warning alert-permanent">
|
||||
<i class="bi bi-exclamation-triangle"></i> <strong>No log files found!</strong>
|
||||
<hr>
|
||||
<p class="mb-0">This means logs are not being written. Check the permissions above.</p>
|
||||
@@ -175,7 +175,7 @@
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-12">
|
||||
<div class="alert alert-info">
|
||||
<div class="alert alert-info alert-permanent">
|
||||
<h6><i class="bi bi-lightbulb"></i> Troubleshooting Tips</h6>
|
||||
<ul class="mb-0">
|
||||
<li>If "Can Write to Logs" shows NO, the IIS Application Pool doesn't have write permissions</li>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model PowderCoating.Web.Controllers.LogViewerModel
|
||||
@model PowderCoating.Web.Controllers.LogViewerModel
|
||||
@{
|
||||
ViewData["Title"] = "Log Viewer";
|
||||
ViewData["PageIcon"] = "bi-file-text";
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.Error))
|
||||
{
|
||||
<div class="alert alert-danger">
|
||||
<div class="alert alert-danger alert-permanent">
|
||||
<i class="bi bi-exclamation-triangle"></i> @Model.Error
|
||||
</div>
|
||||
}
|
||||
@@ -163,7 +163,7 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-info">
|
||||
<div class="alert alert-info alert-permanent">
|
||||
<i class="bi bi-info-circle"></i> No log files found in <code>@Model.LogsPath</code>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@using PowderCoating.Web.Controllers
|
||||
@using PowderCoating.Web.Controllers
|
||||
@model AdminEmailComposeModel
|
||||
@{
|
||||
ViewData["Title"] = "Admin Email";
|
||||
@@ -94,7 +94,7 @@
|
||||
|
||||
<div class="row g-4 align-items-start mb-4">
|
||||
<div class="col-lg-8">
|
||||
<div class="alert alert-info mb-0">
|
||||
<div class="alert alert-info alert-permanent mb-0">
|
||||
The email sends one company at a time to that company's <strong>Primary Contact Email</strong>.
|
||||
Rich text is supported, and the preview step will render one merged sample before anything sends.
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@using PowderCoating.Web.Controllers
|
||||
@using PowderCoating.Web.Controllers
|
||||
@model AdminEmailPreviewModel
|
||||
@{
|
||||
ViewData["Title"] = "Preview Admin Email";
|
||||
@@ -46,7 +46,7 @@
|
||||
<div class="card shadow-sm border-0 mb-4">
|
||||
<div class="card-header bg-transparent fw-semibold py-3">Delivery Summary</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info mb-0">
|
||||
<div class="alert alert-info alert-permanent mb-0">
|
||||
The system will process each selected company one at a time.
|
||||
The sample shown on the left uses the first available recipient after token replacement.
|
||||
</div>
|
||||
@@ -78,7 +78,7 @@
|
||||
<div class="small text-muted">@(string.IsNullOrWhiteSpace(row.RecipientEmail) ? "No primary contact email configured" : row.RecipientEmail)</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>@(string.IsNullOrWhiteSpace(row.CompanyAdminName) ? "—" : row.CompanyAdminName)</div>
|
||||
<div>@(string.IsNullOrWhiteSpace(row.CompanyAdminName) ? "—" : row.CompanyAdminName)</div>
|
||||
@if (!string.IsNullOrWhiteSpace(row.CompanyAdminEmail))
|
||||
{
|
||||
<div class="small text-muted">@row.CompanyAdminEmail</div>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
@model PowderCoating.Application.DTOs.Accounting.CreateExpenseDto
|
||||
@model PowderCoating.Application.DTOs.Accounting.CreateExpenseDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "New Expense";
|
||||
ViewData["PageIcon"] = "bi-receipt";
|
||||
ViewData["PageHelpTitle"] = "New Expense";
|
||||
ViewData["PageHelpContent"] = "Use this for purchases paid immediately — credit card swipes, cash payments, debit transactions. For vendor invoices paid later, use Bills instead. Select the expense account (what was bought) and the payment account (where the money came from).";
|
||||
ViewData["PageHelpContent"] = "Use this for purchases paid immediately — credit card swipes, cash payments, debit transactions. For vendor invoices paid later, use Bills instead. Select the expense account (what was bought) and the payment account (where the money came from).";
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-start mb-4">
|
||||
@@ -17,7 +17,7 @@
|
||||
<div class="card-body">
|
||||
<form asp-action="Create" method="post" enctype="multipart/form-data">
|
||||
@Html.AntiForgeryToken()
|
||||
<div asp-validation-summary="ModelOnly" class="alert alert-danger mb-3"></div>
|
||||
<div asp-validation-summary="ModelOnly" class="alert alert-danger alert-permanent mb-3"></div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-sm-6">
|
||||
@@ -40,13 +40,13 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Expense Account"
|
||||
data-bs-content="The expense category this purchase belongs to — e.g. Supplies, Materials, Utilities, Fuel. This account is debited when the expense is saved. Choose the most specific account that fits to keep your reports accurate.">
|
||||
data-bs-content="The expense category this purchase belongs to — e.g. Supplies, Materials, Utilities, Fuel. This account is debited when the expense is saved. Choose the most specific account that fits to keep your reports accurate.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<select asp-for="ExpenseAccountId" asp-items="ViewBag.ExpenseAccounts" class="form-select" id="expenseAccountSelect">
|
||||
<option value="">— Select Account —</option>
|
||||
<option value="">— Select Account —</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary text-nowrap" id="expAiSuggestBtn" title="AI-suggest expense account">
|
||||
<i class="bi bi-stars me-1"></i>AI Suggest
|
||||
@@ -66,12 +66,12 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Paid From"
|
||||
data-bs-content="The bank or cash account the money came out of — e.g. Business Checking, Petty Cash, Company Credit Card. This account is credited when the expense is saved. Used for bank reconciliation.">
|
||||
data-bs-content="The bank or cash account the money came out of — e.g. Business Checking, Petty Cash, Company Credit Card. This account is credited when the expense is saved. Used for bank reconciliation.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<select asp-for="PaymentAccountId" asp-items="ViewBag.PaymentAccounts" class="form-select">
|
||||
<option value="">— Select Account —</option>
|
||||
<option value="">— Select Account —</option>
|
||||
</select>
|
||||
<span asp-validation-for="PaymentAccountId" class="text-danger small"></span>
|
||||
</div>
|
||||
@@ -84,8 +84,8 @@
|
||||
<label asp-for="VendorId" class="form-label fw-medium">Vendor <span class="text-muted small">(optional)</span></label>
|
||||
<select asp-for="VendorId" asp-items="ViewBag.Vendors" class="form-select"
|
||||
data-quick-add-url="/Vendors/Create" data-quick-add-title="Add New Vendor">
|
||||
<option value="">— None —</option>
|
||||
<option value="__new__">+ Add New Vendor…</option>
|
||||
<option value="">— None —</option>
|
||||
<option value="__new__">+ Add New Vendor…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
@@ -99,7 +99,7 @@
|
||||
</a>
|
||||
</div>
|
||||
<select asp-for="JobId" asp-items="ViewBag.Jobs" class="form-select">
|
||||
<option value="">— None —</option>
|
||||
<option value="">— None —</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
// ── AI Suggest Account ────────────────────────────────────────────────
|
||||
// ── AI Suggest Account ────────────────────────────────────────────────
|
||||
let _expAiSuggestedAccountId = null;
|
||||
|
||||
document.getElementById('expAiSuggestBtn').addEventListener('click', async function () {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model PowderCoating.Application.DTOs.Accounting.EditExpenseDto
|
||||
@model PowderCoating.Application.DTOs.Accounting.EditExpenseDto
|
||||
@* Note: ReceiptFilePath is carried via hidden field to detect existing receipt *@
|
||||
|
||||
@{
|
||||
@@ -20,7 +20,7 @@
|
||||
@Html.AntiForgeryToken()
|
||||
<input asp-for="Id" type="hidden" />
|
||||
<input asp-for="ReceiptFilePath" type="hidden" />
|
||||
<div asp-validation-summary="ModelOnly" class="alert alert-danger mb-3"></div>
|
||||
<div asp-validation-summary="ModelOnly" class="alert alert-danger alert-permanent mb-3"></div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-sm-6">
|
||||
@@ -40,12 +40,12 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Expense Account"
|
||||
data-bs-content="The expense category this purchase belongs to — e.g. Supplies, Materials, Utilities, Fuel. This account is debited when the expense is saved. Choose the most specific account that fits to keep your reports accurate.">
|
||||
data-bs-content="The expense category this purchase belongs to — e.g. Supplies, Materials, Utilities, Fuel. This account is debited when the expense is saved. Choose the most specific account that fits to keep your reports accurate.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<select asp-for="ExpenseAccountId" asp-items="ViewBag.ExpenseAccounts" class="form-select">
|
||||
<option value="">— Select Account —</option>
|
||||
<option value="">— Select Account —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
@@ -54,12 +54,12 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Paid From"
|
||||
data-bs-content="The bank or cash account the money came out of — e.g. Business Checking, Petty Cash, Company Credit Card. This account is credited when the expense is saved. Used for bank reconciliation.">
|
||||
data-bs-content="The bank or cash account the money came out of — e.g. Business Checking, Petty Cash, Company Credit Card. This account is credited when the expense is saved. Used for bank reconciliation.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
<select asp-for="PaymentAccountId" asp-items="ViewBag.PaymentAccounts" class="form-select">
|
||||
<option value="">— Select Account —</option>
|
||||
<option value="">— Select Account —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
@@ -70,8 +70,8 @@
|
||||
<label asp-for="VendorId" class="form-label fw-medium">Vendor</label>
|
||||
<select asp-for="VendorId" asp-items="ViewBag.Vendors" class="form-select"
|
||||
data-quick-add-url="/Vendors/Create" data-quick-add-title="Add New Vendor">
|
||||
<option value="">— None —</option>
|
||||
<option value="__new__">+ Add New Vendor…</option>
|
||||
<option value="">— None —</option>
|
||||
<option value="__new__">+ Add New Vendor…</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
@@ -85,7 +85,7 @@
|
||||
</a>
|
||||
</div>
|
||||
<select asp-for="JobId" asp-items="ViewBag.Jobs" class="form-select">
|
||||
<option value="">— None —</option>
|
||||
<option value="">— None —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model List<PowderCoating.Application.DTOs.Accounting.ExpenseListDto>
|
||||
@model List<PowderCoating.Application.DTOs.Accounting.ExpenseListDto>
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Expenses";
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
@if ((decimal)ViewBag.TotalAmount > 0)
|
||||
{
|
||||
<div class="alert alert-info d-flex align-items-center gap-2 mb-4">
|
||||
<div class="alert alert-info alert-permanent d-flex align-items-center gap-2 mb-4">
|
||||
<i class="bi bi-info-circle fs-5"></i>
|
||||
<span>Total shown: <strong>@(((decimal)ViewBag.TotalAmount).ToString("C"))</strong></span>
|
||||
</div>
|
||||
@@ -34,7 +34,7 @@
|
||||
<form method="get" class="row g-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<input type="search" name="search" value="@ViewBag.Search" class="form-control"
|
||||
placeholder="Search memo or vendor…" />
|
||||
placeholder="Search memo or vendor…" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select name="accountId" class="form-select">
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
@{
|
||||
ViewData["Title"] = "Add Fixed Asset";
|
||||
ViewData["PageIcon"] = "bi-plus-circle";
|
||||
}
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
|
||||
<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 Asset Register
|
||||
</a>
|
||||
</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-building-gear me-2 text-primary"></i>New Fixed Asset</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form asp-action="Create" method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
|
||||
<!-- Asset Info -->
|
||||
<h6 class="border-bottom pb-2 mb-3 text-muted">Asset Information</h6>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12">
|
||||
<label class="form-label">Asset Name <span class="text-danger">*</span></label>
|
||||
<input type="text" name="Name" class="form-control" maxlength="200" required placeholder="e.g., Blast Cabinet #2, Paint Oven A" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Description</label>
|
||||
<input type="text" name="Description" class="form-control" maxlength="1000" placeholder="Optional notes about this asset" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Purchase Date <span class="text-danger">*</span></label>
|
||||
<input type="date" name="PurchaseDate" class="form-control" value="@DateTime.Today.ToString("yyyy-MM-dd")" required />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Purchase Cost <span class="text-danger">*</span></label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input type="number" name="PurchaseCost" class="form-control" step="0.01" min="0.01" required placeholder="0.00" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Salvage Value</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input type="number" name="SalvageValue" class="form-control" step="0.01" min="0" value="0" placeholder="0.00" />
|
||||
</div>
|
||||
<div class="form-text">Estimated residual value at end of useful life.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Useful Life (months) <span class="text-danger">*</span></label>
|
||||
<input type="number" name="UsefulLifeMonths" class="form-control" min="1" max="600" value="60" required />
|
||||
<div class="form-text">60 = 5 years, 120 = 10 years, etc.</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Prior Accumulated Depreciation</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input type="number" name="AccumulatedDepreciation" class="form-control" step="0.01" min="0" value="0" placeholder="0.00" />
|
||||
</div>
|
||||
<div class="form-text">Set if the asset was partially depreciated before being added here.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GL Accounts -->
|
||||
<h6 class="border-bottom pb-2 mb-3 text-muted">GL Account Mapping</h6>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Asset Account</label>
|
||||
<select name="AssetAccountId" class="form-select">
|
||||
<option value="">— None —</option>
|
||||
@if (ViewBag.AssetAccounts != null)
|
||||
{
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.AssetAccounts)
|
||||
{
|
||||
<option value="@item.Value">@item.Text</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
<div class="form-text">Balance sheet asset account (e.g., 1500 Equipment).</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Depreciation Expense Account</label>
|
||||
<select name="DepreciationExpenseAccountId" class="form-select">
|
||||
<option value="">— None —</option>
|
||||
@if (ViewBag.ExpenseAccounts != null)
|
||||
{
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.ExpenseAccounts)
|
||||
{
|
||||
<option value="@item.Value">@item.Text</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
<div class="form-text">P&L expense account (e.g., 6200 Depreciation Expense).</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Accumulated Depreciation Account</label>
|
||||
<select name="AccumDepreciationAccountId" class="form-select">
|
||||
<option value="">— None —</option>
|
||||
@if (ViewBag.AccumDeprecAccounts != null)
|
||||
{
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.AccumDeprecAccounts)
|
||||
{
|
||||
<option value="@item.Value">@item.Text</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
<div class="form-text">Contra-asset account (e.g., 1510 Accum. Depreciation — Equipment).</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>Add Asset
|
||||
</button>
|
||||
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,222 @@
|
||||
@using PowderCoating.Core.Entities
|
||||
@model FixedAsset
|
||||
|
||||
@{
|
||||
ViewData["Title"] = Model.Name;
|
||||
ViewData["PageIcon"] = "bi-building-gear";
|
||||
var fullyDeprec = Model.AccumulatedDepreciation >= (Model.PurchaseCost - Model.SalvageValue);
|
||||
var depreciableBase = Model.PurchaseCost - Model.SalvageValue;
|
||||
var progress = depreciableBase > 0
|
||||
? (double)(Model.AccumulatedDepreciation / depreciableBase) * 100
|
||||
: 100;
|
||||
var entries = ViewBag.Entries as List<FixedAssetDepreciationEntry> ?? new();
|
||||
var monthsRemaining = (int)(ViewBag.MonthsRemaining ?? 0);
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left me-1"></i>Asset Register
|
||||
</a>
|
||||
<div class="d-flex gap-2">
|
||||
@if (!Model.IsDisposed)
|
||||
{
|
||||
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-pencil me-1"></i>Edit
|
||||
</a>
|
||||
}
|
||||
<form asp-action="Delete" asp-route-id="@Model.Id" method="post"
|
||||
onsubmit="return confirm('Delete this asset? This cannot be undone. Assets with depreciation history cannot be deleted.')">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm">
|
||||
<i class="bi bi-trash me-1"></i>Delete
|
||||
</button>
|
||||
</form>
|
||||
</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>
|
||||
}
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- Left Column -->
|
||||
<div class="col-lg-4">
|
||||
<!-- Asset Details Card -->
|
||||
<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-building-gear me-2 text-primary"></i>@Model.Name
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (!string.IsNullOrWhiteSpace(Model.Description))
|
||||
{
|
||||
<p class="text-muted">@Model.Description</p>
|
||||
}
|
||||
|
||||
<div class="mb-3">
|
||||
@if (Model.IsDisposed)
|
||||
{
|
||||
<span class="badge bg-secondary fs-6">Disposed</span>
|
||||
@if (Model.DisposalDate.HasValue)
|
||||
{
|
||||
<div class="text-muted small mt-1">Disposed @Model.DisposalDate.Value.ToLocalTime().ToString("MM/dd/yyyy")</div>
|
||||
}
|
||||
}
|
||||
else if (fullyDeprec)
|
||||
{
|
||||
<span class="badge bg-light text-dark border fs-6">Fully Depreciated</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-success fs-6">Active</span>
|
||||
<div class="text-muted small mt-1">@monthsRemaining month@(monthsRemaining == 1 ? "" : "s") remaining</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<table class="table table-sm table-borderless mb-0">
|
||||
<tr>
|
||||
<td class="text-muted ps-0">Purchase Date</td>
|
||||
<td class="text-end fw-semibold">@Model.PurchaseDate.ToLocalTime().ToString("MM/dd/yyyy")</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted ps-0">Purchase Cost</td>
|
||||
<td class="text-end fw-semibold">@Model.PurchaseCost.ToString("C")</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted ps-0">Salvage Value</td>
|
||||
<td class="text-end">@Model.SalvageValue.ToString("C")</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted ps-0">Useful Life</td>
|
||||
<td class="text-end">@Model.UsefulLifeMonths months</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-muted ps-0">Monthly Depreciation</td>
|
||||
<td class="text-end">@Model.MonthlyDepreciation.ToString("C")</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Book Value Card -->
|
||||
<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-bar-chart me-2 text-primary"></i>Book Value</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between mb-1">
|
||||
<span class="text-muted">Accumulated Depreciation</span>
|
||||
<span class="text-danger fw-semibold">@Model.AccumulatedDepreciation.ToString("C")</span>
|
||||
</div>
|
||||
<div class="progress mb-3" style="height:8px;">
|
||||
<div class="progress-bar bg-danger" style="width: @progress.ToString("F1")%"></div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between border-top pt-2">
|
||||
<span class="fw-semibold">Book Value</span>
|
||||
<span class="fw-bold fs-5 @(Model.BookValue <= 0 ? "text-muted" : "text-success")">@Model.BookValue.ToString("C")</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GL Accounts Card -->
|
||||
@if (Model.AssetAccount != null || Model.DepreciationExpenseAccount != null || Model.AccumDepreciationAccount != null)
|
||||
{
|
||||
<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-diagram-3 me-2 text-primary"></i>GL Accounts</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (Model.AssetAccount != null)
|
||||
{
|
||||
<div class="mb-2">
|
||||
<div class="text-muted small">Asset Account</div>
|
||||
<div class="fw-semibold">@Model.AssetAccount.AccountNumber – @Model.AssetAccount.Name</div>
|
||||
</div>
|
||||
}
|
||||
@if (Model.DepreciationExpenseAccount != null)
|
||||
{
|
||||
<div class="mb-2">
|
||||
<div class="text-muted small">Depreciation Expense</div>
|
||||
<div class="fw-semibold">@Model.DepreciationExpenseAccount.AccountNumber – @Model.DepreciationExpenseAccount.Name</div>
|
||||
</div>
|
||||
}
|
||||
@if (Model.AccumDepreciationAccount != null)
|
||||
{
|
||||
<div>
|
||||
<div class="text-muted small">Accumulated Depreciation</div>
|
||||
<div class="fw-semibold">@Model.AccumDepreciationAccount.AccountNumber – @Model.AccumDepreciationAccount.Name</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Depreciation History -->
|
||||
<div class="col-lg-8">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<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-clock-history me-2 text-primary"></i>Depreciation History</h5>
|
||||
<span class="badge bg-light text-dark border">@entries.Count period@(entries.Count == 1 ? "" : "s") posted</span>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@if (!entries.Any())
|
||||
{
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-calendar-x display-4 d-block mb-3 opacity-25"></i>
|
||||
<p>No depreciation posted yet. Use the <strong>Post Monthly Depreciation</strong> button on the <a asp-action="Index">Asset Register</a>.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Period</th>
|
||||
<th class="text-end">Amount</th>
|
||||
<th>Journal Entry</th>
|
||||
<th>Posted</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var e in entries)
|
||||
{
|
||||
<tr>
|
||||
<td>@(new DateTime(e.PeriodYear, e.PeriodMonth, 1).ToString("MMMM yyyy"))</td>
|
||||
<td class="text-end text-danger">@e.Amount.ToString("C")</td>
|
||||
<td>
|
||||
@if (e.JournalEntry != null)
|
||||
{
|
||||
<a asp-controller="JournalEntries" asp-action="Details" asp-route-id="@e.JournalEntryId">
|
||||
@e.JournalEntry.EntryNumber
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-muted small">@e.CreatedAt.ToLocalTime().ToString("MM/dd/yyyy")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,145 @@
|
||||
@model PowderCoating.Web.Controllers.FixedAssetVm
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Edit Fixed Asset";
|
||||
ViewData["PageIcon"] = "bi-pencil";
|
||||
}
|
||||
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-8">
|
||||
|
||||
<div class="mb-4">
|
||||
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to Asset
|
||||
</a>
|
||||
</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-pencil me-2 text-primary"></i>Edit @Model.Name</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form asp-action="Edit" asp-route-id="@Model.Id" method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" asp-for="Id" />
|
||||
|
||||
<!-- Asset Info -->
|
||||
<h6 class="border-bottom pb-2 mb-3 text-muted">Asset Information</h6>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12">
|
||||
<label asp-for="Name" class="form-label">Asset Name <span class="text-danger">*</span></label>
|
||||
<input asp-for="Name" class="form-control" maxlength="200" required />
|
||||
<span asp-validation-for="Name" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label asp-for="Description" class="form-label">Description</label>
|
||||
<input asp-for="Description" class="form-control" maxlength="1000" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label asp-for="PurchaseDate" class="form-label">Purchase Date <span class="text-danger">*</span></label>
|
||||
<input asp-for="PurchaseDate" type="date" class="form-control" required />
|
||||
<span asp-validation-for="PurchaseDate" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label asp-for="PurchaseCost" class="form-label">Purchase Cost <span class="text-danger">*</span></label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input asp-for="PurchaseCost" type="number" step="0.01" min="0.01" class="form-control" />
|
||||
</div>
|
||||
<span asp-validation-for="PurchaseCost" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label asp-for="SalvageValue" class="form-label">Salvage Value</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input asp-for="SalvageValue" type="number" step="0.01" min="0" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label asp-for="UsefulLifeMonths" class="form-label">Useful Life (months) <span class="text-danger">*</span></label>
|
||||
<input asp-for="UsefulLifeMonths" type="number" min="1" max="600" class="form-control" />
|
||||
<span asp-validation-for="UsefulLifeMonths" class="text-danger"></span>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label asp-for="AccumulatedDepreciation" class="form-label">Accumulated Depreciation</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text">$</span>
|
||||
<input asp-for="AccumulatedDepreciation" type="number" step="0.01" min="0" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GL Accounts -->
|
||||
<h6 class="border-bottom pb-2 mb-3 text-muted">GL Account Mapping</h6>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-4">
|
||||
<label asp-for="AssetAccountId" class="form-label">Asset Account</label>
|
||||
<select asp-for="AssetAccountId" class="form-select">
|
||||
<option value="">— None —</option>
|
||||
@if (ViewBag.AssetAccounts != null)
|
||||
{
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.AssetAccounts)
|
||||
{
|
||||
<option value="@item.Value" selected="@(item.Value == Model.AssetAccountId?.ToString() ? "selected" : null)">@item.Text</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label asp-for="DepreciationExpenseAccountId" class="form-label">Depreciation Expense Account</label>
|
||||
<select asp-for="DepreciationExpenseAccountId" class="form-select">
|
||||
<option value="">— None —</option>
|
||||
@if (ViewBag.ExpenseAccounts != null)
|
||||
{
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.ExpenseAccounts)
|
||||
{
|
||||
<option value="@item.Value" selected="@(item.Value == Model.DepreciationExpenseAccountId?.ToString() ? "selected" : null)">@item.Text</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label asp-for="AccumDepreciationAccountId" class="form-label">Accumulated Depreciation Account</label>
|
||||
<select asp-for="AccumDepreciationAccountId" class="form-select">
|
||||
<option value="">— None —</option>
|
||||
@if (ViewBag.AccumDeprecAccounts != null)
|
||||
{
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.AccumDeprecAccounts)
|
||||
{
|
||||
<option value="@item.Value" selected="@(item.Value == Model.AccumDepreciationAccountId?.ToString() ? "selected" : null)">@item.Text</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Disposal -->
|
||||
<h6 class="border-bottom pb-2 mb-3 text-muted">Disposal</h6>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="form-check form-switch mt-2">
|
||||
<input asp-for="IsDisposed" class="form-check-input" type="checkbox" id="isDisposed" />
|
||||
<label asp-for="IsDisposed" class="form-check-label">Mark as Disposed</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4" id="disposalDateField" style="@(Model.IsDisposed ? "" : "display:none")">
|
||||
<label asp-for="DisposalDate" class="form-label">Disposal Date</label>
|
||||
<input asp-for="DisposalDate" type="date" class="form-control" />
|
||||
</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="Details" asp-route-id="@Model.Id" class="btn btn-outline-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@section Scripts {
|
||||
<script src="~/js/fixed-asset-edit.js" asp-append-version="true"></script>
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
@using PowderCoating.Core.Entities
|
||||
@model List<FixedAsset>
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Fixed Assets";
|
||||
ViewData["PageIcon"] = "bi-building-gear";
|
||||
var now = DateTime.Now;
|
||||
}
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div></div>
|
||||
<div class="d-flex gap-2">
|
||||
<a asp-action="Create" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-2"></i>Add Asset
|
||||
</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>
|
||||
}
|
||||
|
||||
<!-- 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">Active Assets</div>
|
||||
<div class="fs-3 fw-bold text-primary">@ViewBag.ActiveCount</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">Total Cost</div>
|
||||
<div class="fs-3 fw-bold">@((ViewBag.TotalCost as decimal? ?? 0).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">Accum. Depreciation</div>
|
||||
<div class="fs-3 fw-bold text-danger">@((ViewBag.TotalAccumDeprec as decimal? ?? 0).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">Total Book Value</div>
|
||||
<div class="fs-3 fw-bold text-success">@((ViewBag.TotalBookValue as decimal? ?? 0).ToString("C"))</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Post Depreciation -->
|
||||
<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-calendar-check me-2 text-primary"></i>Post Monthly Depreciation</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form asp-action="PostDepreciation" method="post" class="row g-3 align-items-end">
|
||||
@Html.AntiForgeryToken()
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Year</label>
|
||||
<input type="number" name="year" class="form-control" value="@now.Year" min="2000" max="2099" required />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Month</label>
|
||||
<select name="month" class="form-select">
|
||||
@for (int m = 1; m <= 12; m++)
|
||||
{
|
||||
<option value="@m" selected="@(m == now.Month ? "selected" : null)">
|
||||
@(new DateTime(now.Year, m, 1).ToString("MMMM"))
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<button type="submit" class="btn btn-outline-primary"
|
||||
onclick="return confirm('Post straight-line depreciation for all active assets for this period? Assets already posted for the period will be skipped.')">
|
||||
<i class="bi bi-send me-2"></i>Post Depreciation
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-md-3 text-muted small">
|
||||
Creates one Journal Entry per asset. Fully-depreciated and disposed assets are skipped.
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Asset Table -->
|
||||
<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-table me-2 text-primary"></i>Asset Register</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@if (!Model.Any())
|
||||
{
|
||||
<div class="text-center text-muted py-5">
|
||||
<i class="bi bi-building-gear display-4 d-block mb-3 opacity-25"></i>
|
||||
<p>No fixed assets yet. <a asp-action="Create">Add your first asset</a> to start tracking depreciation.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Asset</th>
|
||||
<th>Purchase Date</th>
|
||||
<th class="text-end">Cost</th>
|
||||
<th class="text-end">Salvage Value</th>
|
||||
<th class="text-center">Life (mo.)</th>
|
||||
<th class="text-end">Monthly Depr.</th>
|
||||
<th class="text-end">Accum. Depr.</th>
|
||||
<th class="text-end">Book Value</th>
|
||||
<th class="text-center">Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var a in Model)
|
||||
{
|
||||
var fullyDeprec = a.AccumulatedDepreciation >= (a.PurchaseCost - a.SalvageValue);
|
||||
<tr>
|
||||
<td>
|
||||
<a asp-action="Details" asp-route-id="@a.Id" class="fw-semibold text-decoration-none">
|
||||
@a.Name
|
||||
</a>
|
||||
@if (!string.IsNullOrWhiteSpace(a.Description))
|
||||
{
|
||||
<div class="text-muted small">@a.Description</div>
|
||||
}
|
||||
</td>
|
||||
<td>@a.PurchaseDate.ToLocalTime().ToString("MM/dd/yyyy")</td>
|
||||
<td class="text-end">@a.PurchaseCost.ToString("C")</td>
|
||||
<td class="text-end">@a.SalvageValue.ToString("C")</td>
|
||||
<td class="text-center">@a.UsefulLifeMonths</td>
|
||||
<td class="text-end">@a.MonthlyDepreciation.ToString("C")</td>
|
||||
<td class="text-end text-danger">@a.AccumulatedDepreciation.ToString("C")</td>
|
||||
<td class="text-end @(a.BookValue <= 0 ? "text-muted" : "text-success fw-semibold")">
|
||||
@a.BookValue.ToString("C")
|
||||
</td>
|
||||
<td class="text-center">
|
||||
@if (a.IsDisposed)
|
||||
{
|
||||
<span class="badge bg-secondary">Disposed</span>
|
||||
}
|
||||
else if (fullyDeprec)
|
||||
{
|
||||
<span class="badge bg-light text-dark border">Fully Depreciated</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-success">Active</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<a asp-action="Details" asp-route-id="@a.Id" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -28,7 +28,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-@statusClass d-flex align-items-center mb-4">
|
||||
<div class="alert alert-@statusClass alert-permanent d-flex align-items-center mb-4">
|
||||
<i class="bi bi-gift me-2" style="font-size:1.4rem;"></i>
|
||||
<div>
|
||||
<strong>@statusLabel</strong>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@{
|
||||
@{
|
||||
ViewData["Title"] = "Error";
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
@if (Context.TraceIdentifier != null)
|
||||
{
|
||||
<div class="alert alert-secondary mt-3">
|
||||
<div class="alert alert-secondary alert-permanent mt-3">
|
||||
<strong>Request ID:</strong> <code>@Context.TraceIdentifier</code>
|
||||
<br />
|
||||
<small class="text-muted">Please provide this ID when contacting support.</small>
|
||||
@@ -56,7 +56,7 @@
|
||||
@if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Development")
|
||||
{
|
||||
var logFileName = $"logs/errors-{DateTime.Now:yyyyMMdd}.txt";
|
||||
<div class="alert alert-warning mt-3">
|
||||
<div class="alert alert-warning alert-permanent mt-3">
|
||||
<strong><i class="bi bi-code-slash"></i> Development Mode:</strong>
|
||||
Check the error logs at <code>@logFileName</code> for details.
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@{
|
||||
@{
|
||||
ViewData["Title"] = "Welcome";
|
||||
Layout = null; // Use custom layout for login page
|
||||
}
|
||||
@@ -249,7 +249,7 @@
|
||||
|
||||
@if (User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
<div class="alert alert-success">
|
||||
<div class="alert alert-success alert-permanent">
|
||||
<i class="bi bi-check-circle me-2"></i>
|
||||
You're already logged in as <strong>@User.Identity.Name</strong>
|
||||
</div>
|
||||
@@ -272,7 +272,7 @@
|
||||
<a href="/Identity/Account/Register">
|
||||
<i class="bi bi-person-plus me-1"></i>Create an account
|
||||
</a>
|
||||
<span class="mx-2">•</span>
|
||||
<span class="mx-2">•</span>
|
||||
<a href="/Identity/Account/ForgotPassword">
|
||||
<i class="bi bi-question-circle me-1"></i>Forgot password?
|
||||
</a>
|
||||
@@ -297,7 +297,7 @@
|
||||
|
||||
<div class="text-center mt-4">
|
||||
<p style="color: rgba(255, 255, 255, 0.8); font-size: 0.875rem;">
|
||||
© 2024 Powder Coating Logix. All rights reserved.
|
||||
© 2024 Powder Coating Logix. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model PowderCoating.Application.DTOs.Inventory.InventoryItemDto
|
||||
@model PowderCoating.Application.DTOs.Inventory.InventoryItemDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Delete Inventory Item";
|
||||
@@ -14,7 +14,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Warning Banner -->
|
||||
<div class="alert alert-danger d-flex align-items-start mb-4">
|
||||
<div class="alert alert-danger alert-permanent d-flex align-items-start mb-4">
|
||||
<i class="bi bi-exclamation-triangle-fill me-3" style="font-size: 1.5rem;"></i>
|
||||
<div>
|
||||
<h5 class="alert-heading mb-2">Are you sure you want to delete this inventory item?</h5>
|
||||
@@ -143,7 +143,7 @@
|
||||
|
||||
@if (Model.QuantityOnHand > 0)
|
||||
{
|
||||
<div class="alert alert-warning d-flex align-items-center">
|
||||
<div class="alert alert-warning alert-permanent d-flex align-items-center">
|
||||
<i class="bi bi-exclamation-circle me-2"></i>
|
||||
<div>
|
||||
<strong>Warning:</strong> This item still has @Model.QuantityOnHand.ToString("N2") @Model.UnitOfMeasure in stock (value: @((Model.QuantityOnHand * Model.UnitCost).ToString("C"))). Consider transferring or adjusting inventory before deletion.
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
<a asp-action="Ledger" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-journal-text me-2"></i>Inventory Activity
|
||||
</a>
|
||||
<a asp-controller="PowderInsights" asp-action="Index" class="btn btn-outline-secondary" title="Powder usage analytics">
|
||||
<i class="bi bi-graph-up me-2"></i>Powder Insights
|
||||
</a>
|
||||
<a asp-action="SamplePanels" class="btn btn-outline-primary">
|
||||
<i class="bi bi-palette me-2"></i>Manage Sample Panels
|
||||
</a>
|
||||
|
||||
@@ -59,6 +59,8 @@
|
||||
<input type="hidden" asp-for="PreparedById" />
|
||||
<input type="hidden" asp-for="JobId" />
|
||||
<input type="hidden" asp-for="CustomerId" id="hiddenCustomerId" />
|
||||
<input type="hidden" asp-for="EarlyPaymentDiscountPercent" id="EarlyPaymentDiscountPercent" />
|
||||
<input type="hidden" asp-for="EarlyPaymentDiscountDays" id="EarlyPaymentDiscountDays" />
|
||||
|
||||
<div class="row g-4">
|
||||
<!-- LEFT: Main form -->
|
||||
@@ -180,6 +182,7 @@
|
||||
</a>
|
||||
</div>
|
||||
<select asp-for="Terms" asp-items="ViewBag.PaymentTermsOptions" class="form-select"></select>
|
||||
<div id="earlyPaymentDiscountNotice" class="form-text text-success d-none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -456,11 +459,56 @@
|
||||
function onCustomerChanged(select) {
|
||||
document.getElementById('hiddenCustomerId').value = select.value;
|
||||
const customerId = parseInt(select.value) || 0;
|
||||
const taxField = document.getElementById('TaxPercent');
|
||||
if (taxField) {
|
||||
taxField.value = taxExemptCustomerIds.has(customerId) ? 0 : companyTaxPercent;
|
||||
recalcTotals();
|
||||
}
|
||||
if (!customerId) return;
|
||||
|
||||
// Fetch payment terms + tax rate for the selected customer
|
||||
fetch(`/Invoices/GetCustomerPaymentTerms?customerId=${customerId}`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (!data) return;
|
||||
// Auto-fill Terms dropdown
|
||||
const termsSelect = document.getElementById('Terms');
|
||||
if (termsSelect && data.paymentTerms) {
|
||||
// Set the matching option, or fall back to the raw value
|
||||
const opt = Array.from(termsSelect.options).find(o => o.value === data.paymentTerms);
|
||||
if (opt) termsSelect.value = data.paymentTerms;
|
||||
// Trigger due date recalculation (invoice-due-date.js listens to 'change')
|
||||
termsSelect.dispatchEvent(new Event('change'));
|
||||
}
|
||||
// Show/hide early payment discount notice and persist values
|
||||
const discountEl = document.getElementById('earlyPaymentDiscountNotice');
|
||||
const discountPctField = document.getElementById('EarlyPaymentDiscountPercent');
|
||||
const discountDaysField = document.getElementById('EarlyPaymentDiscountDays');
|
||||
if (discountEl) {
|
||||
if (data.earlyPaymentDiscountPercent > 0) {
|
||||
discountEl.textContent = `${data.earlyPaymentDiscountPercent}% discount if paid within ${data.earlyPaymentDiscountDays} days`;
|
||||
discountEl.classList.remove('d-none');
|
||||
} else {
|
||||
discountEl.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
if (discountPctField) discountPctField.value = data.earlyPaymentDiscountPercent ?? 0;
|
||||
if (discountDaysField) discountDaysField.value = data.earlyPaymentDiscountDays ?? 0;
|
||||
}).catch(() => {});
|
||||
|
||||
// Fetch tax rate for the selected customer
|
||||
fetch(`/Invoices/GetTaxRateForCustomer?customerId=${customerId}`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => {
|
||||
if (!data) return;
|
||||
const taxField = document.getElementById('TaxPercent');
|
||||
if (taxField) {
|
||||
taxField.value = data.taxPercent ?? 0;
|
||||
recalcTotals();
|
||||
}
|
||||
}).catch(() => {
|
||||
// Fall back to client-side tax exempt check
|
||||
const taxField = document.getElementById('TaxPercent');
|
||||
if (taxField) {
|
||||
taxField.value = taxExemptCustomerIds.has(customerId) ? 0 : companyTaxPercent;
|
||||
recalcTotals();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Merchandise combobox ────────────────────────────────────────────────
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@using PowderCoating.Application.DTOs.Invoice
|
||||
@using PowderCoating.Application.DTOs.Invoice
|
||||
@using PowderCoating.Core.Enums
|
||||
@using PowderCoating.Web.Controllers
|
||||
@model InvoiceDto
|
||||
@@ -66,17 +66,17 @@
|
||||
|
||||
@if (!hasEmail)
|
||||
{
|
||||
<div class="alert alert-warning d-flex align-items-center gap-2 mb-4">
|
||||
<div class="alert alert-warning alert-permanent d-flex align-items-center gap-2 mb-4">
|
||||
<i class="bi bi-envelope-slash fs-5"></i>
|
||||
<span>
|
||||
<strong>@Model.CustomerName</strong> has no email address on file — you'll be prompted to enter one when sending.
|
||||
<strong>@Model.CustomerName</strong> has no email address on file — you'll be prompted to enter one when sending.
|
||||
<a asp-controller="Customers" asp-action="Edit" asp-route-id="@Model.CustomerId" class="alert-link">Add one in customer settings</a>.
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
else if (emailOptedOut)
|
||||
{
|
||||
<div class="alert alert-warning d-flex align-items-center gap-2 mb-4">
|
||||
<div class="alert alert-warning alert-permanent d-flex align-items-center gap-2 mb-4">
|
||||
<i class="bi bi-envelope-x fs-5"></i>
|
||||
<span>
|
||||
<strong>@Model.CustomerName</strong> has email notifications turned off.
|
||||
@@ -168,12 +168,12 @@
|
||||
<div class="col-md-4">
|
||||
<label class="text-muted small mb-1">Due Date</label>
|
||||
<p class="mb-0 @(Model.Status == InvoiceStatus.Overdue ? "text-danger fw-bold" : "")">
|
||||
@(Model.DueDate.HasValue ? Model.DueDate.Value.ToString("MMMM d, yyyy") : "—")
|
||||
@(Model.DueDate.HasValue ? Model.DueDate.Value.ToString("MMMM d, yyyy") : "—")
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="text-muted small mb-1">Sent Date</label>
|
||||
<p class="mb-0">@(Model.SentDate.HasValue ? Model.SentDate.Value.ToString("MMMM d, yyyy") : "—")</p>
|
||||
<p class="mb-0">@(Model.SentDate.HasValue ? Model.SentDate.Value.ToString("MMMM d, yyyy") : "—")</p>
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(Model.CustomerPO))
|
||||
{
|
||||
@@ -339,7 +339,7 @@
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-muted">
|
||||
@(gcItem.Description.Contains("for ") ? gcItem.Description.Substring(gcItem.Description.IndexOf("for ") + 4).TrimEnd(')') : "—")
|
||||
@(gcItem.Description.Contains("for ") ? gcItem.Description.Substring(gcItem.Description.IndexOf("for ") + 4).TrimEnd(')') : "—")
|
||||
</td>
|
||||
<td class="text-end fw-semibold">@gcItem.TotalPrice.ToString("C")</td>
|
||||
<td>
|
||||
@@ -385,7 +385,7 @@
|
||||
<tr>
|
||||
<td>@p.PaymentDate.ToString("MM/dd/yyyy")</td>
|
||||
<td>@p.PaymentMethodDisplay</td>
|
||||
<td>@(p.Reference ?? "—")</td>
|
||||
<td>@(p.Reference ?? "—")</td>
|
||||
<td>
|
||||
@if (!string.IsNullOrEmpty(p.DepositAccountName))
|
||||
{
|
||||
@@ -393,10 +393,10 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td>@(p.RecordedByName ?? "—")</td>
|
||||
<td>@(p.RecordedByName ?? "—")</td>
|
||||
<td class="text-end fw-semibold text-success">@p.Amount.ToString("C")</td>
|
||||
<td class="text-end">
|
||||
@if (!isVoided)
|
||||
@@ -452,7 +452,7 @@
|
||||
<td>@r.RefundDate.ToString("MM/dd/yyyy")</td>
|
||||
<td>@r.RefundMethodDisplay</td>
|
||||
<td>@r.Reason</td>
|
||||
<td>@(r.Reference ?? "—")</td>
|
||||
<td>@(r.Reference ?? "—")</td>
|
||||
<td><span class="badge bg-@refundStatusColor">@r.Status</span></td>
|
||||
<td class="text-end fw-semibold text-danger">(@r.Amount.ToString("C"))</td>
|
||||
<td class="text-nowrap">
|
||||
@@ -564,7 +564,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
|
||||
data-bs-title="Invoice Actions"
|
||||
data-bs-content="Workflow: Edit (Draft only) → Send Invoice (locks it, emails customer) → Record Payment. Partial payments are supported — record multiple payments until fully paid. Void cancels the invoice and reverses the customer balance without deleting history. Delete is only available for Drafts.">
|
||||
data-bs-content="Workflow: Edit (Draft only) → Send Invoice (locks it, emails customer) → Record Payment. Partial payments are supported — record multiple payments until fully paid. Void cancels the invoice and reverses the customer balance without deleting history. Delete is only available for Drafts.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -679,6 +679,13 @@
|
||||
</button>
|
||||
</form>
|
||||
}
|
||||
@if (!isVoided && Model.BalanceDue > 0)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-danger w-100"
|
||||
data-bs-toggle="modal" data-bs-target="#writeOffModal">
|
||||
<i class="bi bi-journal-x me-2"></i>Write Off
|
||||
</button>
|
||||
}
|
||||
@if (isDraft)
|
||||
{
|
||||
<form asp-action="Delete" asp-route-id="@Model.Id" method="post"
|
||||
@@ -754,7 +761,7 @@
|
||||
{
|
||||
<div class="mb-2">
|
||||
<span class="badge bg-success-subtle text-success mb-2">
|
||||
<i class="bi bi-check-circle me-1"></i>Active — expires @Model.PaymentLinkExpiresAt!.Value.ToString("MMM d")
|
||||
<i class="bi bi-check-circle me-1"></i>Active — expires @Model.PaymentLinkExpiresAt!.Value.ToString("MMM d")
|
||||
</span>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" id="paymentLinkInput" class="form-control font-monospace"
|
||||
@@ -777,7 +784,7 @@
|
||||
}
|
||||
else if (linkExpired)
|
||||
{
|
||||
<div class="alert alert-warning py-2 small mb-2">
|
||||
<div class="alert alert-warning alert-permanent py-2 small mb-2">
|
||||
<i class="bi bi-clock me-1"></i>Payment link expired.
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary btn-sm w-100"
|
||||
@@ -892,7 +899,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
|
||||
data-bs-title="Payment Reference"
|
||||
data-bs-content="Optional identifier for reconciliation — e.g., the check number, last 4 digits of the card, ACH transaction ID, or Venmo/PayPal confirmation code. Appears in payment history so you can match payments to your bank statement.">
|
||||
data-bs-content="Optional identifier for reconciliation — e.g., the check number, last 4 digits of the card, ACH transaction ID, or Venmo/PayPal confirmation code. Appears in payment history so you can match payments to your bank statement.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -1105,13 +1112,13 @@
|
||||
<form asp-action="IssueRefund" asp-route-invoiceId="@Model.Id" method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
<div class="modal-body">
|
||||
<div id="refundAlertCash" class="alert alert-info small mb-3">
|
||||
<div id="refundAlertCash" class="alert alert-info alert-permanent small mb-3">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
This records the refund intent. You still need to issue the actual refund (cash, check, etc.) manually.
|
||||
</div>
|
||||
<div id="refundAlertCredit" class="alert alert-success small mb-3 d-none">
|
||||
<i class="bi bi-piggy-bank me-1"></i>
|
||||
The refund amount will be added to the customer's store credit balance immediately — no manual action needed.
|
||||
The refund amount will be added to the customer's store credit balance immediately — no manual action needed.
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Amount <span class="text-danger">*</span></label>
|
||||
@@ -1181,7 +1188,7 @@
|
||||
<form asp-action="IssueCreditMemo" asp-route-invoiceId="@Model.Id" method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-info small mb-3">
|
||||
<div class="alert alert-info alert-permanent small mb-3">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
A credit memo adds store credit to the customer's account that can be applied against future invoices.
|
||||
</div>
|
||||
@@ -1233,7 +1240,7 @@
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Select Credit Memo <span class="text-danger">*</span></label>
|
||||
<select name="CreditMemoId" class="form-select" required>
|
||||
<option value="">— Select —</option>
|
||||
<option value="">— Select —</option>
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.AvailableCreditMemos)
|
||||
{
|
||||
<option value="@item.Value">@item.Text</option>
|
||||
@@ -1247,7 +1254,7 @@
|
||||
<input type="number" name="Amount" class="form-control" step="0.01" min="0.01"
|
||||
max="@Model.BalanceDue.ToString("F2")" value="@Model.BalanceDue.ToString("F2")" required />
|
||||
</div>
|
||||
<div class="form-text">Balance due: @Model.BalanceDue.ToString("C") — the system will cap at the memo's remaining balance.</div>
|
||||
<div class="form-text">Balance due: @Model.BalanceDue.ToString("C") — the system will cap at the memo's remaining balance.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -1308,6 +1315,55 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Write-Off Modal -->
|
||||
@if (!isVoided && Model.BalanceDue > 0)
|
||||
{
|
||||
<div class="modal fade" id="writeOffModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title"><i class="bi bi-journal-x me-2 text-danger"></i>Write Off Invoice</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<form asp-action="WriteOff" asp-route-id="@Model.Id" method="post">
|
||||
@Html.AntiForgeryToken()
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning alert-permanent py-2 mb-3">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
This will write off the remaining balance of <strong>@Model.BalanceDue.ToString("C")</strong>
|
||||
as bad debt. A GL journal entry will be posted. This action cannot be undone.
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Bad Debt Expense Account</label>
|
||||
<select name="expenseAccountId" class="form-select">
|
||||
<option value="">— Use default bad debt account —</option>
|
||||
@if (ViewBag.ExpenseAccounts != null)
|
||||
{
|
||||
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.ExpenseAccounts)
|
||||
{
|
||||
<option value="@item.Value">@item.Text</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
<div class="form-text">If blank, the system selects the first account with "bad" or "debt" in the name, or falls back to the first expense account.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Notes</label>
|
||||
<textarea name="notes" class="form-control" rows="2" placeholder="Reason for write-off (optional)"></textarea>
|
||||
</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-danger">
|
||||
<i class="bi bi-journal-x me-2"></i>Write Off @Model.BalanceDue.ToString("C")
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@section Scripts {
|
||||
<script>
|
||||
function openEditPaymentModal(paymentId, invoiceId, paymentDate, paymentMethod, reference, notes, depositAccountId) {
|
||||
@@ -1341,8 +1397,8 @@
|
||||
const max = Math.min(data.remainingBalance, @Model.BalanceDue.ToString("F2", System.Globalization.CultureInfo.InvariantCulture));
|
||||
document.getElementById('gcAmountInput').value = max.toFixed(2);
|
||||
document.getElementById('gcAmountInput').max = max;
|
||||
const expiry = data.expiryDate ? ` · Expires ${data.expiryDate}` : '';
|
||||
result.innerHTML = `<div class="alert alert-success py-1 mb-0 small"><i class="bi bi-check-circle me-1"></i><strong>${data.certificateCode}</strong> — $${data.remainingBalance.toFixed(2)} remaining${expiry}</div>`;
|
||||
const expiry = data.expiryDate ? ` · Expires ${data.expiryDate}` : '';
|
||||
result.innerHTML = `<div class="alert alert-success py-1 mb-0 small"><i class="bi bi-check-circle me-1"></i><strong>${data.certificateCode}</strong> — $${data.remainingBalance.toFixed(2)} remaining${expiry}</div>`;
|
||||
}
|
||||
} catch { result.innerHTML = '<div class="alert alert-danger py-1 mb-0 small">Lookup failed.</div>'; }
|
||||
document.getElementById('gcLookupSpinner').style.display = 'none';
|
||||
@@ -1455,7 +1511,7 @@
|
||||
<td class="small">${escHtml(n.type.replace(/([A-Z])/g, ' $1').trim())}</td>
|
||||
<td class="small"><i class="bi ${channelIcon} me-1"></i>${escHtml(n.channel)}</td>
|
||||
<td class="small">${escHtml(n.recipientName)}<br><span class="text-muted">${escHtml(n.recipient)}</span></td>
|
||||
<td class="small">${n.subject ? escHtml(n.subject) : '<span class="text-muted">—</span>'}</td>
|
||||
<td class="small">${n.subject ? escHtml(n.subject) : '<span class="text-muted">—</span>'}</td>
|
||||
<td><span class="badge bg-${statusClass}">${escHtml(n.status)}</span>${expandBtn}</td>
|
||||
</tr>${errorRow}`;
|
||||
}).join('');
|
||||
@@ -1499,7 +1555,7 @@
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
if (msg) msg.innerHTML = `
|
||||
<div class="alert alert-success py-2 small">
|
||||
<div class="alert alert-success alert-permanent py-2 small">
|
||||
<i class="bi bi-check-circle me-1"></i>New link generated!
|
||||
<a href="${data.paymentUrl}" target="_blank" class="alert-link ms-1">Open</a>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model PowderCoating.Application.DTOs.Invoice.UpdateInvoiceDto
|
||||
@model PowderCoating.Application.DTOs.Invoice.UpdateInvoiceDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Edit Invoice";
|
||||
@@ -38,7 +38,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Invoice Details"
|
||||
data-bs-content="Invoice Date is the date of issue and the reference for payment terms. Due Date drives overdue status and A/R aging. Payment Terms prints on the invoice — changing it here only affects this invoice. Draft, Sent, and Overdue invoices can be edited; Paid and Partially Paid invoices are locked.">
|
||||
data-bs-content="Invoice Date is the date of issue and the reference for payment terms. Due Date drives overdue status and A/R aging. Payment Terms prints on the invoice — changing it here only affects this invoice. Draft, Sent, and Overdue invoices can be edited; Paid and Partially Paid invoices are locked.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -79,7 +79,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Line Items"
|
||||
data-bs-content="Each row is a billable line on the invoice. Qty × Unit Price = Total per line; you can also override Total directly. Color is optional and appears under the description when printed. Add manual lines for charges not in the original job (e.g., rush fee, pickup charge).">
|
||||
data-bs-content="Each row is a billable line on the invoice. Qty × Unit Price = Total per line; you can also override Total directly. Color is optional and appears under the description when printed. Add manual lines for charges not in the original job (e.g., rush fee, pickup charge).">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -163,7 +163,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Notes"
|
||||
data-bs-content="Customer Notes appear on the printed and emailed invoice — use these for payment instructions, thank-you messages, or job-specific reminders. Internal Notes are only visible to staff in the app and are never sent to the customer.">
|
||||
data-bs-content="Customer Notes appear on the printed and emailed invoice — use these for payment instructions, thank-you messages, or job-specific reminders. Internal Notes are only visible to staff in the app and are never sent to the customer.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -196,7 +196,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Totals"
|
||||
data-bs-content="Subtotal = sum of all line item totals. Discount is a flat dollar amount deducted before tax. Tax % is applied to (Subtotal − Discount). Both default from the company settings but can be overridden for this invoice.">
|
||||
data-bs-content="Subtotal = sum of all line item totals. Discount is a flat dollar amount deducted before tax. Tax % is applied to (Subtotal − Discount). Both default from the company settings but can be overridden for this invoice.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -252,7 +252,7 @@
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-footer border-0 pt-0">
|
||||
<div class="alert alert-info mb-0 small py-2">
|
||||
<div class="alert alert-info alert-permanent mb-0 small py-2">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
<strong>Draft, Sent,</strong> and <strong>Overdue</strong> invoices can be edited.
|
||||
Paid invoices are locked.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model PowderCoating.Core.Entities.JobTemplate
|
||||
@model PowderCoating.Core.Entities.JobTemplate
|
||||
@{
|
||||
ViewData["Title"] = $"Edit Template: {Model.Name}";
|
||||
ViewData["PageIcon"] = "bi-pencil-square";
|
||||
@@ -31,7 +31,7 @@
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Default Customer</label>
|
||||
<select name="customerId" class="form-select">
|
||||
<option value="">— Any customer —</option>
|
||||
<option value="">— Any customer —</option>
|
||||
@foreach (SelectListItem item in (ViewBag.Customers as IEnumerable<SelectListItem> ?? Enumerable.Empty<SelectListItem>()))
|
||||
{
|
||||
if (item.Selected)
|
||||
@@ -70,7 +70,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mt-3">
|
||||
<div class="alert alert-info alert-permanent mt-3">
|
||||
<i class="bi bi-info-circle me-2"></i>
|
||||
To update the <strong>items and coatings</strong> on this template, create a new job with the desired configuration and save it as a template.
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model PowderCoating.Application.DTOs.Job.CreateJobDto
|
||||
@model PowderCoating.Application.DTOs.Job.CreateJobDto
|
||||
@using PowderCoating.Core.Entities
|
||||
|
||||
@{
|
||||
@@ -19,7 +19,7 @@
|
||||
<i class="bi bi-layout-text-window-reverse fs-5"></i>
|
||||
<div>
|
||||
Pre-filled from template <strong>@ViewBag.TemplateName</strong>.
|
||||
Items and coatings have been loaded — review and adjust before saving.
|
||||
Items and coatings have been loaded — review and adjust before saving.
|
||||
</div>
|
||||
<button type="button" class="btn-close ms-auto" data-bs-dismiss="alert"></button>
|
||||
</div>
|
||||
@@ -50,7 +50,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Job Details"
|
||||
data-bs-content="Core job information. Priority and due date are visible on the shop floor board and affect how work is sorted. Customer PO is the customer's own reference number for their purchase order — include it so it appears on invoices. Special Instructions go directly to the shop floor worker.">
|
||||
data-bs-content="Core job information. Priority and due date are visible on the shop floor board and affect how work is sorted. Customer PO is the customer's own reference number for their purchase order — include it so it appears on invoices. Special Instructions go directly to the shop floor worker.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -70,7 +70,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Job Priority"
|
||||
data-bs-content="Controls sort order on the shop floor board and job list. Rush and Urgent jobs are highlighted in red/orange. Normal is the default. Raise priority only when the customer has an actual deadline constraint — overuse of Rush dilutes its meaning for the shop floor team.">
|
||||
data-bs-content="Controls sort order on the shop floor board and job list. Rush and Urgent jobs are highlighted in red/orange. Normal is the default. Raise priority only when the customer has an actual deadline constraint — overuse of Rush dilutes its meaning for the shop floor team.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -100,7 +100,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Due Date"
|
||||
data-bs-content="The customer's deadline — when the work must be ready for pickup or delivery. Overdue jobs (past due date and not yet completed) are highlighted in red on the job list.">
|
||||
data-bs-content="The customer's deadline — when the work must be ready for pickup or delivery. Overdue jobs (past due date and not yet completed) are highlighted in red on the job list.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -130,7 +130,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Special Instructions"
|
||||
data-bs-content="Free-text notes visible to the shop floor worker on the work order. Use this for masking requirements, handling notes, customer preferences, or anything that doesn't fit in the item-level notes — e.g., 'Keep brackets separated, customer allergic to zinc primer'.">
|
||||
data-bs-content="Free-text notes visible to the shop floor worker on the work order. Use this for masking requirements, handling notes, customer preferences, or anything that doesn't fit in the item-level notes — e.g., 'Keep brackets separated, customer allergic to zinc primer'.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -201,7 +201,7 @@
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Job Items"
|
||||
data-bs-content="Each item represents a physical piece being coated. Use the wizard to pick from the catalog, enter custom dimensions, or upload a photo for AI analysis. Each item gets its own coating specification — color, powder, finish, and cure details. You can add multiple coating passes per item for multi-color or primer+topcoat work.">
|
||||
data-bs-content="Each item represents a physical piece being coated. Use the wizard to pick from the catalog, enter custom dimensions, or upload a photo for AI analysis. Each item gets its own coating specification — color, powder, finish, and cure details. You can add multiple coating passes per item for multi-color or primer+topcoat work.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -276,7 +276,7 @@
|
||||
<p class="mb-1 text-muted small" id="pricingPlaceholder">Pricing will update automatically as you add items.</p>
|
||||
<p class="mb-1 d-none" id="itemsSubtotalRow">Items Subtotal: <strong id="itemsSubtotalDisplay">$0.00</strong></p>
|
||||
<p class="mb-1 d-none" id="ovenBatchCostRow">
|
||||
<i class="bi bi-fire me-1"></i>Oven (<span id="ovenBatchesDisplay">1</span> batch × <span id="ovenCycleMinDisplay">45</span> min):
|
||||
<i class="bi bi-fire me-1"></i>Oven (<span id="ovenBatchesDisplay">1</span> batch × <span id="ovenCycleMinDisplay">45</span> min):
|
||||
<strong id="ovenBatchCostDisplay">$0.00</strong>
|
||||
</p>
|
||||
<p class="mb-1 text-success d-none" id="pricingTierDiscountRow">
|
||||
@@ -337,7 +337,7 @@
|
||||
<div class="col-6"><label class="form-label">Width (@(ViewBag.UseMetric == true ? "cm" : "in"))</label>
|
||||
<input type="number" id="rectWidth" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()"></div>
|
||||
</div>
|
||||
<small class="text-muted">Formula: L × W ÷ @(ViewBag.UseMetric == true ? "10,000" : "144")</small>
|
||||
<small class="text-muted">Formula: L × W ÷ @(ViewBag.UseMetric == true ? "10,000" : "144")</small>
|
||||
</div>
|
||||
<div id="cylinderInputs" style="display:none">
|
||||
<div class="row g-2">
|
||||
@@ -352,7 +352,7 @@
|
||||
<input type="number" id="circDiameter" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()">
|
||||
</div>
|
||||
<hr />
|
||||
<div class="alert alert-info mb-0"><strong>Result:</strong> <span id="calcResult">0.00</span> @ViewBag.AreaUnit</div>
|
||||
<div class="alert alert-info alert-permanent mb-0"><strong>Result:</strong> <span id="calcResult">0.00</span> @ViewBag.AreaUnit</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
@model PowderCoating.Application.DTOs.Job.JobDto
|
||||
@model PowderCoating.Application.DTOs.Job.JobDto
|
||||
|
||||
@{
|
||||
ViewData["Title"] = "Delete Job";
|
||||
@@ -14,7 +14,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Warning Banner -->
|
||||
<div class="alert alert-danger d-flex align-items-start mb-4">
|
||||
<div class="alert alert-danger alert-permanent d-flex align-items-start mb-4">
|
||||
<i class="bi bi-exclamation-triangle-fill me-3" style="font-size: 1.5rem;"></i>
|
||||
<div>
|
||||
<h5 class="alert-heading mb-2">Are you sure you want to delete this job?</h5>
|
||||
@@ -125,7 +125,7 @@
|
||||
|
||||
@if (Model.StatusIsWIP)
|
||||
{
|
||||
<div class="alert alert-warning d-flex align-items-center">
|
||||
<div class="alert alert-warning alert-permanent d-flex align-items-center">
|
||||
<i class="bi bi-exclamation-circle me-2"></i>
|
||||
<div>
|
||||
<strong>Warning:</strong> This job is currently in progress. Please ensure all work is properly documented before deletion.
|
||||
|
||||
@@ -2107,7 +2107,7 @@
|
||||
<input type="number" id="circDiameter" class="form-control" min="0" step="0.01" value="0" oninput="calculateSqFt()">
|
||||
</div>
|
||||
<hr />
|
||||
<div class="alert alert-info mb-0"><strong>Result:</strong> <span id="calcResult">0.00</span> @ViewBag.AreaUnit</div>
|
||||
<div class="alert alert-info alert-permanent mb-0"><strong>Result:</strong> <span id="calcResult">0.00</span> @ViewBag.AreaUnit</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
@@ -3224,7 +3224,7 @@
|
||||
placeholder="Brief description of when to use this template"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-light border mb-0">
|
||||
<div class="alert alert-light alert-permanent border mb-0">
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
The customer, special instructions, and all items with their coatings will be copied.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user