Compare commits

...

5 Commits

Author SHA1 Message Date
spouliot dde66c807f Update help docs and AI knowledge base for Accountant role and new permissions
- Settings.cshtml: add Accountant to roles table with description; add
  Fine-Grained Permissions subsection with a full table of all 16 permissions
  including the new Can Manage Bills & AP and Can Manage Accounting entries
- HelpKnowledgeBase.cs: add Accountant to ROLE AWARENESS section at top;
  add Accountant to USER MANAGEMENT roles list with auto-checked permissions
  note; add Fine-grained permissions paragraph documenting CanManageBills,
  CanManageAccounting, and Accountant role defaults

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:44:09 -04:00
spouliot feff0fa73d Add Accountant role and CanManageBills/CanManageAccounting permissions
- AppConstants: add Accountant to CompanyRoles; add CanManageBills and
  CanManageAccounting to Policies
- ApplicationUser: add CanManageBills and CanManageAccounting bool fields
- UserManagementDtos: expose new fields in all three DTOs
- ClaimsPrincipalFactory: emit ManageBills and ManageAccounting claims
- Program.cs: add CanManageBills and CanManageAccounting policies;
  update CanManageInvoices, CanViewReports, CanManagePurchaseOrders,
  and CanManageVendors to auto-pass for Accountant role
- BillsController: replace CanManageInventory with CanManageBills on
  all write actions (correct policy — bills are not inventory)
- BankReconciliationsController: replace CanManageJobs with
  CanManageAccounting on write actions
- CompanyUsersController: add Accountant to validCompanyRoles (both
  Create/Edit), legacyRole switch, and all permission assignment blocks
- Create/Edit views: add Accountant option to role dropdown; add
  CanManageBills and CanManageAccounting checkboxes; JS auto-checks
  financial permissions when Accountant role is selected
- Migration AddAccountantRolePermissions: adds columns + backfills
  CanManageBills=1 and CanManageAccounting=1 for all CompanyAdmin users

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:42:53 -04:00
spouliot 59beba2e15 Update help docs and AI knowledge base for 4 new AI bookkeeping features
- Reports.cshtml: added AI Payment Risk Prediction, Ask Your Financials,
  and Bank Rec Auto-Match subsections under AI-Powered Reports; updated
  on-this-page nav with sub-links for all three
- AccountsPayable.cshtml: added Recurring Bill Detection section explaining
  pattern cards, frequency types, confidence badges, next expected date,
  and the 2-occurrence minimum
- HelpKnowledgeBase.cs: added Recurring Bill Detection to BILLS section;
  added AI Payment Risk Prediction and Ask Your Financials to REPORTS
  available-reports list; added features 12–15 to AI FEATURES section
  (Recurring Detection, Payment Risk, Financial Query, Bank Rec Auto-Match)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:30:39 -04:00
spouliot 959e323f3a Add 4 AI bookkeeping features
Feature 7: Bank Rec Auto-Match — AiSuggestMatches endpoint scores uncleared
transactions vs statement ending balance; AI Auto-Match panel in Reconcile.cshtml
with confidence highlights and Apply All button.

Feature 8: Late Payment Prediction — PredictLatePayments endpoint scores open AR
customers by risk (high/medium/low) using historical avg-days-to-pay + late rate;
rendered as badge table in AR Aging view via ar-aging-ai.js.

Feature 9: Natural Language Financial Queries — FinancialQuery GET page + RunFinancialQuery
POST; 12-month context snapshot pre-loaded; answers grounded in real data with
supporting facts, follow-up suggestions, session history, and example chips.

Feature 10: Recurring Bill Detection — RunRecurringDetection scans 12 months of bills
for vendor payment patterns (monthly/quarterly/annual); card grid view in Bills/RecurringDetection.cshtml
with confidence badges, next-expected-date, and suggested actions.

Supporting: 4 new DTO groups in AccountingAiDtos.cs, 4 method signatures in
IAccountingAiService.cs, 4 implementations in AccountingAiService.cs, 4 new
AiFeatures constants, 2 new Landing page AI report cards.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:22:49 -04:00
spouliot e2f9e9ae4f Button consistency sweep + mobile responsiveness patches
- Standardize modal dismiss/cancel buttons to btn-outline-secondary across 70+ views
- Remove btn-sm from page-level Create and Back buttons (Index + Detail pages)
- Fix Edit buttons on Details pages: btn-secondary -> btn-warning
- Fix form Cancel/Back links: btn-secondary -> btn-outline-secondary
- Add 10 CSS patches to site.css for mobile/tablet responsiveness:
  top-navbar overflow prevention, page-header flex-wrap at 575px,
  table action button min-height override, notification dropdown width cap,
  tablet content padding

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 19:04:10 -04:00
98 changed files with 13233 additions and 487 deletions
@@ -322,3 +322,214 @@ public class ClaudeAnomalyFlag
public string? RecommendedAction { get; set; }
public string? BillNumber { get; set; }
}
// ── Feature 7: Bank Rec Auto-Match ───────────────────────────────────────────
public class BankRecMatchItem
{
public string EntityType { get; set; } = string.Empty; // "Payment", "BillPayment", "Expense"
public int EntityId { get; set; }
public string Date { get; set; } = string.Empty; // ISO 8601
public string Reference { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public decimal Amount { get; set; }
public string Direction { get; set; } = string.Empty; // "deposit" or "payment"
}
public class AutoMatchRequest
{
public List<BankRecMatchItem> UnclearedItems { get; set; } = new();
public decimal BeginningBalance { get; set; }
public decimal StatementEndingBalance { get; set; }
}
public class AutoMatchSuggestion
{
public string EntityType { get; set; } = string.Empty;
public int EntityId { get; set; }
public double Confidence { get; set; } // 0.01.0
public string Reason { get; set; } = string.Empty;
}
public class AutoMatchResult
{
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
public List<AutoMatchSuggestion> SuggestedCleared { get; set; } = new();
public List<string> Insights { get; set; } = new();
}
/// <summary>Internal JSON schema that Claude returns for bank rec auto-match.</summary>
public class ClaudeAutoMatchResponse
{
public List<ClaudeAutoMatchSuggestion> SuggestedCleared { get; set; } = new();
public List<string> Insights { get; set; } = new();
}
public class ClaudeAutoMatchSuggestion
{
public string EntityType { get; set; } = string.Empty;
public int EntityId { get; set; }
public double Confidence { get; set; }
public string Reason { get; set; } = string.Empty;
}
// ── Feature 8: Late Payment Prediction ───────────────────────────────────────
public class OpenInvoiceSummary
{
public string InvoiceNumber { get; set; } = string.Empty;
public decimal BalanceDue { get; set; }
public string? DueDateIso { get; set; }
public int DaysOverdue { get; set; }
}
public class LatePaymentCustomerData
{
public string CustomerName { get; set; } = string.Empty;
public decimal TotalOwed { get; set; }
public double AvgDaysToPay { get; set; } // historical average
public int TotalInvoicesAllTime { get; set; }
public int LateInvoicesAllTime { get; set; }
public List<OpenInvoiceSummary> OpenInvoices { get; set; } = new();
}
public class LatePaymentPredictionRequest
{
public string CompanyName { get; set; } = string.Empty;
public List<LatePaymentCustomerData> Customers { get; set; } = new();
}
public class LatePaymentPrediction
{
public string CustomerName { get; set; } = string.Empty;
/// <summary>"high", "medium", or "low"</summary>
public string RiskLevel { get; set; } = "medium";
public int EstimatedDaysToPayment { get; set; }
public string Reasoning { get; set; } = string.Empty;
}
public class LatePaymentPredictionResult
{
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
public List<LatePaymentPrediction> Predictions { get; set; } = new();
public List<string> Insights { get; set; } = new();
}
/// <summary>Internal JSON schema that Claude returns for late payment predictions.</summary>
public class ClaudeLatePaymentResponse
{
public List<ClaudeLatePaymentPrediction> Predictions { get; set; } = new();
public List<string> Insights { get; set; } = new();
}
public class ClaudeLatePaymentPrediction
{
public string CustomerName { get; set; } = string.Empty;
public string RiskLevel { get; set; } = "medium";
public int EstimatedDaysToPayment { get; set; }
public string Reasoning { get; set; } = string.Empty;
}
// ── Feature 9: Natural Language Financial Queries ─────────────────────────────
public class MonthlyFinancialSummary
{
public string Month { get; set; } = string.Empty; // "YYYY-MM"
public decimal Revenue { get; set; }
public decimal Expenses { get; set; }
public decimal NetIncome { get; set; }
}
public class FinancialQueryContext
{
public string CompanyName { get; set; } = string.Empty;
public string AsOfDate { get; set; } = string.Empty;
public decimal TotalRevenueYtd { get; set; }
public decimal TotalExpensesYtd { get; set; }
public decimal NetIncomeYtd { get; set; }
public decimal ArOutstanding { get; set; }
public decimal ApOutstanding { get; set; }
public List<MonthlyFinancialSummary> Last12Months { get; set; } = new();
public List<ExpenseByCategory> ExpensesByCategory { get; set; } = new();
}
public class FinancialQueryRequest
{
public string Question { get; set; } = string.Empty;
public FinancialQueryContext Context { get; set; } = new();
}
public class FinancialQueryResult
{
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
public string Answer { get; set; } = string.Empty;
public string? FollowUpSuggestion { get; set; }
public List<string> RelevantFacts { get; set; } = new();
}
/// <summary>Internal JSON schema that Claude returns for financial queries.</summary>
public class ClaudeFinancialQueryResponse
{
public string Answer { get; set; } = string.Empty;
public string? FollowUpSuggestion { get; set; }
public List<string> RelevantFacts { get; set; } = new();
}
// ── Feature 10: Recurring Bill Detection ─────────────────────────────────────
public class RecurringBillHistoryItem
{
public string VendorName { get; set; } = string.Empty;
public string BillNumber { get; set; } = string.Empty;
public decimal Amount { get; set; }
public string DateIso { get; set; } = string.Empty;
public string? Memo { get; set; }
}
public class RecurringBillDetectionRequest
{
public string CompanyName { get; set; } = string.Empty;
public List<RecurringBillHistoryItem> Bills { get; set; } = new();
}
public class RecurringBillPattern
{
public string VendorName { get; set; } = string.Empty;
/// <summary>"monthly", "quarterly", "biannual", "annual"</summary>
public string Frequency { get; set; } = string.Empty;
public decimal TypicalAmount { get; set; }
public string? NextExpectedDateIso { get; set; }
/// <summary>"high", "medium", or "low"</summary>
public string Confidence { get; set; } = "medium";
public string Description { get; set; } = string.Empty;
public string? SuggestedAction { get; set; }
}
public class RecurringBillDetectionResult
{
public bool Success { get; set; }
public string? ErrorMessage { get; set; }
public List<RecurringBillPattern> Patterns { get; set; } = new();
public List<string> Insights { get; set; } = new();
}
/// <summary>Internal JSON schema that Claude returns for recurring bill detection.</summary>
public class ClaudeRecurringBillResponse
{
public List<ClaudeRecurringPattern> Patterns { get; set; } = new();
public List<string> Insights { get; set; } = new();
}
public class ClaudeRecurringPattern
{
public string VendorName { get; set; } = string.Empty;
public string Frequency { get; set; } = string.Empty;
public decimal TypicalAmount { get; set; }
public string? NextExpectedDateIso { get; set; }
public string Confidence { get; set; } = "medium";
public string Description { get; set; } = string.Empty;
public string? SuggestedAction { get; set; }
}
@@ -41,6 +41,8 @@ public class CompanyUserDto
public bool CanManageMaintenance { get; set; }
public bool CanManageInvoices { get; set; }
public bool CanViewReports { get; set; }
public bool CanManageBills { get; set; }
public bool CanManageAccounting { get; set; }
}
/// <summary>
@@ -156,6 +158,12 @@ public class CreateCompanyUserDto
[Display(Name = "Can View Reports")]
public bool CanViewReports { get; set; }
[Display(Name = "Can Manage Bills & AP")]
public bool CanManageBills { get; set; }
[Display(Name = "Can Manage Accounting")]
public bool CanManageAccounting { get; set; }
[Display(Name = "Send Welcome Email")]
public bool SendWelcomeEmail { get; set; } = true;
}
@@ -258,4 +266,10 @@ public class UpdateCompanyUserDto
[Display(Name = "Can View Reports")]
public bool CanViewReports { get; set; }
[Display(Name = "Can Manage Bills & AP")]
public bool CanManageBills { get; set; }
[Display(Name = "Can Manage Accounting")]
public bool CanManageAccounting { get; set; }
}
@@ -43,4 +43,33 @@ public interface IAccountingAiService
/// Returns a ranked list of flagged items with recommended actions.
/// </summary>
Task<AnomalyDetectionResult> DetectAnomaliesAsync(AnomalyDetectionRequest request);
/// <summary>
/// Suggests which uncleared bank rec items should be marked as cleared to reconcile
/// a statement. Returns a ranked list of suggestions with confidence scores based on
/// amount/date patterns and the gap between the current cleared balance and the
/// statement ending balance.
/// </summary>
Task<AutoMatchResult> AutoMatchReconciliationAsync(AutoMatchRequest request);
/// <summary>
/// Predicts likelihood of late payment for each open AR customer using their historical
/// payment behavior (avg days to pay, late rate) combined with current overdue status.
/// Returns risk levels (high/medium/low) and estimated days to collection.
/// </summary>
Task<LatePaymentPredictionResult> PredictLatePaymentsAsync(LatePaymentPredictionRequest request);
/// <summary>
/// Answers a plain-English financial question (e.g. "What did we spend on powder last quarter?")
/// using pre-loaded company financial context. Returns a direct answer, supporting facts,
/// and an optional follow-up question suggestion.
/// </summary>
Task<FinancialQueryResult> AnswerFinancialQueryAsync(FinancialQueryRequest request);
/// <summary>
/// Analyzes 612 months of bill history to detect recurring payment patterns per vendor.
/// Returns detected patterns with frequency, typical amount, next expected date, and
/// suggested actions (e.g. set a reminder, create a template).
/// </summary>
Task<RecurringBillDetectionResult> DetectRecurringBillsAsync(RecurringBillDetectionRequest request);
}
@@ -50,6 +50,8 @@ public class ApplicationUser : IdentityUser
public bool CanManageMaintenance { get; set; } = false;
public bool CanManageInvoices { get; set; } = false;
public bool CanViewReports { get; set; } = false;
public bool CanManageBills { get; set; } = false;
public bool CanManageAccounting { get; set; } = false;
// Profile Photo (filesystem storage)
public string? ProfilePictureFilePath { get; set; } // Relative path from ContentRoot/media/ (e.g., "123/profile-photos/user-abc.jpg")
@@ -0,0 +1,90 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddAccountantRolePermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "CanManageAccounting",
table: "AspNetUsers",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "CanManageBills",
table: "AspNetUsers",
type: "bit",
nullable: false,
defaultValue: false);
// Grant both new permissions to all existing CompanyAdmin users so they don't lose access
migrationBuilder.Sql(@"
UPDATE AspNetUsers
SET CanManageBills = 1, CanManageAccounting = 1
WHERE CompanyRole = 'CompanyAdmin'
");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 23, 40, 54, 100, DateTimeKind.Utc).AddTicks(8999));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 23, 40, 54, 100, DateTimeKind.Utc).AddTicks(9005));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 10, 23, 40, 54, 100, DateTimeKind.Utc).AddTicks(9007));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CanManageAccounting",
table: "AspNetUsers");
migrationBuilder.DropColumn(
name: "CanManageBills",
table: "AspNetUsers");
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));
}
}
}
@@ -902,4 +902,454 @@ Account Spend Trends (this month vs historical):
return new AnomalyDetectionResult { Success = false, ErrorMessage = "An error occurred while running the analysis." };
}
}
// ── Feature 7: Bank Rec Auto-Match ────────────────────────────────────────
/// <summary>
/// Suggests which uncleared bank rec transactions to mark as cleared to close the gap
/// between the current running balance and the statement ending balance. The items list
/// includes both deposits and payments with their direction tag so Claude can reason about
/// net effect. Confidence scores reflect how cleanly each item contributes to reaching the
/// target ending balance — items that together sum close to the required difference score
/// higher than items that alone overshoot. MaxTokens is 1024; the response is typically
/// compact because we only need entity-type/id pairs plus a short reason per item.
/// </summary>
public async Task<AutoMatchResult> AutoMatchReconciliationAsync(AutoMatchRequest request)
{
var apiKey = GetApiKey();
if (apiKey == null)
return new AutoMatchResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
try
{
var systemPrompt = @"You are a bank reconciliation assistant for a powder coating business.
Given a list of uncleared transactions and a target statement ending balance, suggest which transactions
to mark as cleared so that: Beginning Balance + cleared deposits - cleared payments = Statement Ending Balance.
Respond ONLY with a valid JSON object — no markdown, no explanation.
Schema:
{
""suggestedCleared"": [
{
""entityType"": ""Payment"" | ""BillPayment"" | ""Expense"",
""entityId"": number,
""confidence"": number (0.0 to 1.0),
""reason"": ""string — one sentence why this item should be cleared""
}
],
""insights"": [""string"", ...]
}
Rules:
- Select the combination of items whose net effect (deposits minus payments) gets closest to the difference needed
- Difference needed = statementEndingBalance - beginningBalance
- confidence 0.9-1.0: item clearly belongs in this period (date and amount both fit)
- confidence 0.6-0.89: likely but not certain
- confidence below 0.6: possible but uncertain — include only if needed to close the gap
- insights: 2-4 observations about patterns or items that need manual review
- Do NOT suggest clearing items you are uncertain about just to force a zero balance";
var itemsJson = JsonSerializer.Serialize(request.UnclearedItems);
var needed = request.StatementEndingBalance - request.BeginningBalance;
var userPrompt = $@"Suggest which transactions to clear for this bank reconciliation.
Beginning Balance: {request.BeginningBalance:F2}
Statement Ending Balance: {request.StatementEndingBalance:F2}
Difference needed (deposits - payments): {needed:F2}
Uncleared transactions:
{itemsJson}";
var client = new AnthropicClient(apiKey);
var messageParams = new MessageParameters
{
Model = Model,
MaxTokens = 1024,
SystemMessage = systemPrompt,
Messages = new List<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
}
}
};
var response = await SendAsync(client, messageParams);
var rawText = response.FirstMessage?.Text
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
?? "";
if (string.IsNullOrWhiteSpace(rawText))
return new AutoMatchResult { Success = false, ErrorMessage = "Empty response from AI." };
var raw = StripJsonFences(rawText);
var parsed = JsonSerializer.Deserialize<ClaudeAutoMatchResponse>(raw, JsonOpts);
if (parsed == null)
return new AutoMatchResult { Success = false, ErrorMessage = "Could not parse AI response." };
return new AutoMatchResult
{
Success = true,
SuggestedCleared = (parsed.SuggestedCleared ?? new()).Select(s => new AutoMatchSuggestion
{
EntityType = s.EntityType,
EntityId = s.EntityId,
Confidence = s.Confidence,
Reason = s.Reason
}).ToList(),
Insights = parsed.Insights ?? new()
};
}
catch (OperationCanceledException)
{
_logger.LogWarning("Claude AI bank rec auto-match timed out after 60 seconds");
return new AutoMatchResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
}
catch (Exception ex)
{
_logger.LogError(ex, "Error running bank rec auto-match with AI");
return new AutoMatchResult { Success = false, ErrorMessage = "An error occurred while running auto-match." };
}
}
// ── Feature 8: Late Payment Prediction ────────────────────────────────────
/// <summary>
/// Predicts payment risk per open AR customer by combining current overdue status with
/// historical behavior metrics (avg days to pay, late rate). The late rate is pre-calculated
/// as LateInvoicesAllTime / TotalInvoicesAllTime so Claude receives a 01 ratio rather than
/// raw counts, which produces more consistent confidence scoring across customers with very
/// different invoice volumes. Risk levels are validated against the three allowed values and
/// default to "medium" when Claude returns anything outside the expected set.
/// </summary>
public async Task<LatePaymentPredictionResult> PredictLatePaymentsAsync(LatePaymentPredictionRequest request)
{
var apiKey = GetApiKey();
if (apiKey == null)
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
try
{
var systemPrompt = @"You are an accounts receivable risk analyst for a powder coating business.
Given open AR data and each customer's historical payment behavior, predict payment risk for each customer.
Respond ONLY with a valid JSON object — no markdown, no explanation.
Schema:
{
""predictions"": [
{
""customerName"": ""string"",
""riskLevel"": ""high"" | ""medium"" | ""low"",
""estimatedDaysToPayment"": number,
""reasoning"": ""string — one sentence explaining the prediction""
}
],
""insights"": [""string"", ...]
}
Rules:
- riskLevel ""high"": customer has a history of late payment AND is already overdue, or has a very high late rate
- riskLevel ""medium"": customer is overdue but has reasonable historical performance, or is current but has a spotty history
- riskLevel ""low"": customer typically pays on time and is not severely overdue
- estimatedDaysToPayment: realistic estimate of additional days until payment, based on history and overdue status
- insights: 2-4 portfolio-level observations (e.g. which customers need immediate follow-up)
- Only include predictions for customers with open invoices";
var customersJson = JsonSerializer.Serialize(request.Customers.Select(c => new
{
c.CustomerName,
c.TotalOwed,
c.AvgDaysToPay,
LatePaymentRate = c.TotalInvoicesAllTime > 0
? Math.Round((double)c.LateInvoicesAllTime / c.TotalInvoicesAllTime, 2)
: 0,
c.OpenInvoices
}));
var userPrompt = $@"Predict payment risk for open AR customers of {request.CompanyName}.
Customer data (includes historical payment behavior):
{customersJson}";
var client = new AnthropicClient(apiKey);
var messageParams = new MessageParameters
{
Model = Model,
MaxTokens = 1024,
SystemMessage = systemPrompt,
Messages = new List<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
}
}
};
var response = await SendAsync(client, messageParams);
var rawText = response.FirstMessage?.Text
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
?? "";
if (string.IsNullOrWhiteSpace(rawText))
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "Empty response from AI." };
var raw = StripJsonFences(rawText);
var parsed = JsonSerializer.Deserialize<ClaudeLatePaymentResponse>(raw, JsonOpts);
if (parsed == null)
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "Could not parse AI response." };
var validRiskLevels = new[] { "high", "medium", "low" };
var predictions = (parsed.Predictions ?? new()).Select(p => new LatePaymentPrediction
{
CustomerName = p.CustomerName,
RiskLevel = validRiskLevels.Contains(p.RiskLevel?.ToLowerInvariant()) ? p.RiskLevel!.ToLowerInvariant() : "medium",
EstimatedDaysToPayment = p.EstimatedDaysToPayment,
Reasoning = p.Reasoning
}).ToList();
return new LatePaymentPredictionResult
{
Success = true,
Predictions = predictions,
Insights = parsed.Insights ?? new()
};
}
catch (OperationCanceledException)
{
_logger.LogWarning("Claude AI late payment prediction timed out after 60 seconds");
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
}
catch (Exception ex)
{
_logger.LogError(ex, "Error predicting late payments with AI");
return new LatePaymentPredictionResult { Success = false, ErrorMessage = "An error occurred while predicting payment risk." };
}
}
// ── Feature 9: Natural Language Financial Queries ─────────────────────────
/// <summary>
/// Answers a free-text financial question using a pre-loaded snapshot of the company's
/// financial data. The context object is serialized to JSON and embedded in the user prompt
/// so Claude has concrete numbers to reason over rather than fabricating estimates. The
/// system prompt explicitly constrains Claude to the data provided and forbids it from
/// making up figures outside the snapshot — this prevents hallucination of specific dollar
/// amounts. RelevantFacts is a list of supporting data points Claude pulled from the context
/// to justify the answer, displayed below the answer in the UI so users can verify.
/// MaxTokens is raised to 1500 to accommodate answers with multiple supporting facts.
/// </summary>
public async Task<FinancialQueryResult> AnswerFinancialQueryAsync(FinancialQueryRequest request)
{
var apiKey = GetApiKey();
if (apiKey == null)
return new FinancialQueryResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
try
{
var systemPrompt = @"You are a financial analyst assistant for a powder coating business.
Answer plain-English financial questions using ONLY the data provided in the context.
Respond ONLY with a valid JSON object — no markdown, no explanation.
Schema:
{
""answer"": ""string — direct, plain-English answer to the question"",
""followUpSuggestion"": ""string — one optional follow-up question the user might want to ask next, or null"",
""relevantFacts"": [""string"", ...]
}
Rules:
- answer: be direct and specific with dollar amounts and percentages from the data
- If the data does not contain enough information to answer the question, say so clearly in the answer
- Do NOT invent or estimate figures that are not in the provided data
- relevantFacts: 2-5 specific data points from the context that support the answer (formatted as ""Label: $X"" or ""Label: X%"")
- followUpSuggestion: suggest the natural next question the user would want to ask, or null if not obvious
- Keep the answer under 100 words — be concise";
var contextJson = JsonSerializer.Serialize(request.Context);
var userPrompt = $@"Question: {request.Question}
Financial context:
{contextJson}";
var client = new AnthropicClient(apiKey);
var messageParams = new MessageParameters
{
Model = Model,
MaxTokens = 1500,
SystemMessage = systemPrompt,
Messages = new List<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
}
}
};
var response = await SendAsync(client, messageParams);
var rawText = response.FirstMessage?.Text
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
?? "";
if (string.IsNullOrWhiteSpace(rawText))
return new FinancialQueryResult { Success = false, ErrorMessage = "Empty response from AI." };
var raw = StripJsonFences(rawText);
var parsed = JsonSerializer.Deserialize<ClaudeFinancialQueryResponse>(raw, JsonOpts);
if (parsed == null)
return new FinancialQueryResult { Success = false, ErrorMessage = "Could not parse AI response." };
return new FinancialQueryResult
{
Success = true,
Answer = parsed.Answer ?? string.Empty,
FollowUpSuggestion = parsed.FollowUpSuggestion,
RelevantFacts = parsed.RelevantFacts ?? new()
};
}
catch (OperationCanceledException)
{
_logger.LogWarning("Claude AI financial query timed out after 60 seconds");
return new FinancialQueryResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
}
catch (Exception ex)
{
_logger.LogError(ex, "Error answering financial query with AI");
return new FinancialQueryResult { Success = false, ErrorMessage = "An error occurred while answering your question." };
}
}
// ── Feature 10: Recurring Bill Detection ──────────────────────────────────
/// <summary>
/// Analyzes 612 months of historical bills to detect recurring payment patterns per vendor.
/// Bills are grouped by vendor in the prompt so Claude can see the full chronological series
/// for each vendor at a glance. The confidence field ("high"/"medium"/"low") reflects how
/// regular the cadence is — a bill appearing every 2832 days for 6 consecutive months is
/// high confidence; 23 occurrences at similar amounts is medium. NextExpectedDateIso is
/// calculated by Claude from the pattern's most recent date plus the detected period length.
/// MaxTokens is 1500 to accommodate multi-vendor response objects with multiple patterns.
/// </summary>
public async Task<RecurringBillDetectionResult> DetectRecurringBillsAsync(RecurringBillDetectionRequest request)
{
var apiKey = GetApiKey();
if (apiKey == null)
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "Anthropic API key is not configured." };
try
{
var systemPrompt = @"You are a recurring expense analyst for a powder coating business.
Analyze the provided bill history to detect recurring payment patterns per vendor.
Respond ONLY with a valid JSON object — no markdown, no explanation.
Schema:
{
""patterns"": [
{
""vendorName"": ""string"",
""frequency"": ""monthly"" | ""quarterly"" | ""biannual"" | ""annual"" | ""irregular"",
""typicalAmount"": number,
""nextExpectedDateIso"": ""YYYY-MM-DD or null"",
""confidence"": ""high"" | ""medium"" | ""low"",
""description"": ""string — one sentence describing the pattern"",
""suggestedAction"": ""string — one specific action to take, or null""
}
],
""insights"": [""string"", ...]
}
Rules:
- Only report patterns with at least 2 occurrences
- monthly: bills occurring every 2535 days
- quarterly: bills occurring every 80100 days
- biannual: bills occurring every 170195 days
- annual: bills occurring roughly once per year
- irregular: a vendor bills regularly but the cadence is inconsistent
- confidence ""high"": 4+ occurrences with consistent timing (within ±5 days of the period)
- confidence ""medium"": 23 occurrences with consistent timing, or 4+ with variable timing
- confidence ""low"": pattern is weak but worth monitoring
- nextExpectedDateIso: estimate based on the last bill date + the detected period; null if irregular or low confidence
- suggestedAction: e.g. ""Set a monthly reminder for this bill"" or ""Create a recurring bill template"" or null
- insights: 2-4 portfolio-level observations about the company's recurring expense profile
- If no recurring patterns are found, return an empty patterns array";
// Group bills by vendor for clarity in the prompt
var grouped = request.Bills
.GroupBy(b => b.VendorName)
.Select(g => new
{
VendorName = g.Key,
Bills = g.OrderBy(b => b.DateIso).Select(b => new { b.DateIso, b.Amount, b.BillNumber, b.Memo })
});
var billsJson = JsonSerializer.Serialize(grouped);
var userPrompt = $@"Detect recurring bill patterns for {request.CompanyName}.
Data covers the last 612 months of bills, grouped by vendor.
Bill history by vendor:
{billsJson}";
var client = new AnthropicClient(apiKey);
var messageParams = new MessageParameters
{
Model = Model,
MaxTokens = 1500,
SystemMessage = systemPrompt,
Messages = new List<Message>
{
new Message
{
Role = RoleType.User,
Content = new List<ContentBase> { new TextContent { Text = userPrompt } }
}
}
};
var response = await SendAsync(client, messageParams);
var rawText = response.FirstMessage?.Text
?? response.Content?.OfType<TextContent>().FirstOrDefault()?.Text
?? "";
if (string.IsNullOrWhiteSpace(rawText))
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "Empty response from AI." };
var raw = StripJsonFences(rawText);
var parsed = JsonSerializer.Deserialize<ClaudeRecurringBillResponse>(raw, JsonOpts);
if (parsed == null)
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "Could not parse AI response." };
var validConfidence = new[] { "high", "medium", "low" };
var validFrequency = new[] { "monthly", "quarterly", "biannual", "annual", "irregular" };
return new RecurringBillDetectionResult
{
Success = true,
Patterns = (parsed.Patterns ?? new()).Select(p => new RecurringBillPattern
{
VendorName = p.VendorName,
Frequency = validFrequency.Contains(p.Frequency?.ToLowerInvariant()) ? p.Frequency!.ToLowerInvariant() : "irregular",
TypicalAmount = p.TypicalAmount,
NextExpectedDateIso = p.NextExpectedDateIso,
Confidence = validConfidence.Contains(p.Confidence?.ToLowerInvariant()) ? p.Confidence!.ToLowerInvariant() : "medium",
Description = p.Description,
SuggestedAction = p.SuggestedAction
}).ToList(),
Insights = parsed.Insights ?? new()
};
}
catch (OperationCanceledException)
{
_logger.LogWarning("Claude AI recurring bill detection timed out after 60 seconds");
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "The AI service did not respond in time. Please try again." };
}
catch (Exception ex)
{
_logger.LogError(ex, "Error detecting recurring bills with AI");
return new RecurringBillDetectionResult { Success = false, ErrorMessage = "An error occurred while analyzing bill patterns." };
}
}
}
@@ -137,6 +137,16 @@ public class ApplicationUserClaimsPrincipalFactory : UserClaimsPrincipalFactory<
identity.AddClaim(new Claim("Permission", "ViewReports"));
}
if (user.CanManageBills)
{
identity.AddClaim(new Claim("Permission", "ManageBills"));
}
if (user.CanManageAccounting)
{
identity.AddClaim(new Claim("Permission", "ManageAccounting"));
}
return identity;
}
}
@@ -31,6 +31,7 @@ public static class AppConstants
{
public const string CompanyAdmin = "CompanyAdmin";
public const string Manager = "Manager";
public const string Accountant = "Accountant";
public const string Worker = "Worker";
public const string Viewer = "Viewer";
}
@@ -58,6 +59,8 @@ public static class AppConstants
public const string CanManageMaintenance = "CanManageMaintenance";
public const string CanManageInvoices = "CanManageInvoices";
public const string CanViewReports = "CanViewReports";
public const string CanManageBills = "CanManageBills";
public const string CanManageAccounting = "CanManageAccounting";
}
public static class FileUpload
@@ -103,6 +106,10 @@ public static class AppConstants
public const string FinancialSummary = "FinancialSummary";
public const string CashFlowForecast = "CashFlowForecast";
public const string AnomalyDetection = "AnomalyDetection";
public const string BankRecAutoMatch = "BankRecAutoMatch";
public const string LatePaymentPrediction = "LatePaymentPrediction";
public const string FinancialQuery = "FinancialQuery";
public const string RecurringBillDetection = "RecurringBillDetection";
}
public static class Legal
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.AI;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
@@ -15,13 +16,19 @@ public class BankReconciliationsController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ITenantContext _tenantContext;
private readonly IAccountingAiService _accountingAi;
private readonly IAiUsageLogger _usageLogger;
public BankReconciliationsController(
IUnitOfWork unitOfWork,
ITenantContext tenantContext)
ITenantContext tenantContext,
IAccountingAiService accountingAi,
IAiUsageLogger usageLogger)
{
_unitOfWork = unitOfWork;
_tenantContext = tenantContext;
_accountingAi = accountingAi;
_usageLogger = usageLogger;
}
private bool AllowAccounting() =>
@@ -49,7 +56,7 @@ public class BankReconciliationsController : Controller
// ── Create ───────────────────────────────────────────────────────────────
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
public async Task<IActionResult> Create()
{
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
@@ -58,7 +65,7 @@ public class BankReconciliationsController : Controller
}
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(BankReconciliation model)
{
@@ -164,7 +171,7 @@ public class BankReconciliationsController : Controller
/// Returns updated running totals as JSON.
/// </summary>
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ToggleCleared(
int reconId, string entityType, int entityId, bool isCleared)
@@ -200,7 +207,7 @@ public class BankReconciliationsController : Controller
/// <summary>Completes the reconciliation. Only allowed when Difference == 0.00.</summary>
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Complete(int id, decimal difference)
{
@@ -269,6 +276,91 @@ public class BankReconciliationsController : Controller
return View(recon);
}
// ── AI Auto-Match (AJAX) ──────────────────────────────────────────────────
/// <summary>
/// AJAX endpoint. Passes uncleared bank rec items to Claude and returns suggested items
/// to mark as cleared. The controller assembles all three transaction types (deposits,
/// bill payments, expenses) for the reconciliation's account, then delegates scoring to
/// <see cref="IAccountingAiService.AutoMatchReconciliationAsync"/>. The caller applies
/// suggestions client-side by auto-checking the corresponding table rows.
/// </summary>
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageAccounting)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> AiSuggestMatches(int reconId)
{
if (!AllowAccounting()) return Forbid();
var recon = (await _unitOfWork.BankReconciliations.FindAsync(
br => br.Id == reconId, false, br => br.Account))
.FirstOrDefault();
if (recon == null) return NotFound();
var accountId = recon.AccountId;
var statementDate = recon.StatementDate;
var items = new List<BankRecMatchItem>();
(await _unitOfWork.Payments.FindAsync(
p => p.DepositAccountId == accountId && p.PaymentDate <= statementDate && !p.IsCleared))
.ToList()
.ForEach(p => items.Add(new BankRecMatchItem
{
EntityType = "Payment",
EntityId = p.Id,
Date = p.PaymentDate.ToString("yyyy-MM-dd"),
Reference = p.Reference ?? $"PMT-{p.Id}",
Description = $"Payment #{p.InvoiceId}",
Amount = p.Amount,
Direction = "deposit"
}));
(await _unitOfWork.BillPayments.FindAsync(
bp => bp.BankAccountId == accountId && bp.PaymentDate <= statementDate && !bp.IsCleared))
.ToList()
.ForEach(bp => items.Add(new BankRecMatchItem
{
EntityType = "BillPayment",
EntityId = bp.Id,
Date = bp.PaymentDate.ToString("yyyy-MM-dd"),
Reference = bp.PaymentNumber,
Description = bp.Memo ?? bp.BillId.ToString(),
Amount = bp.Amount,
Direction = "payment"
}));
(await _unitOfWork.Expenses.FindAsync(
e => e.PaymentAccountId == accountId && e.Date <= statementDate && !e.IsCleared))
.ToList()
.ForEach(e => items.Add(new BankRecMatchItem
{
EntityType = "Expense",
EntityId = e.Id,
Date = e.Date.ToString("yyyy-MM-dd"),
Reference = e.ExpenseNumber,
Description = e.Memo ?? string.Empty,
Amount = e.Amount,
Direction = "payment"
}));
if (!items.Any())
return Json(new { success = false, errorMessage = "No uncleared transactions to analyze." });
var request = new AutoMatchRequest
{
UnclearedItems = items,
BeginningBalance = recon.BeginningBalance,
StatementEndingBalance = recon.EndingBalance
};
var result = await _accountingAi.AutoMatchReconciliationAsync(request);
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
await _usageLogger.LogAsync(recon.CompanyId, userId, AppConstants.AiFeatures.BankRecAutoMatch, result.Success);
return Json(result);
}
// ── Helpers ──────────────────────────────────────────────────────────────
private async Task PopulateAccountDropdownAsync()
@@ -1,4 +1,4 @@
using AutoMapper;
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Identity;
@@ -58,13 +58,13 @@ public class BillsController : Controller
_usageLogger = usageLogger;
}
// ── Index ────────────────────────────────────────────────────────────────
// -- Index ----------------------------------------------------------------
/// <summary>
/// Lists bills and direct expenses in a unified AP ledger view. The <paramref name="type"/>
/// parameter lets the caller pin the list to Bills only, Expenses only, or both (null).
/// Expenses are inherently fully paid so they are always excluded when the caller filters to
/// "Unpaid" or "Overdue" — preventing them from inflating the "amount owed" summary.
/// "Unpaid" or "Overdue" — preventing them from inflating the "amount owed" summary.
/// Amount-based search strips leading $ and commas before comparing so "$1,234" works naturally.
/// </summary>
public async Task<IActionResult> Index(string? type, string? search, string? status, int page = 1, int pageSize = 25)
@@ -112,7 +112,7 @@ public class BillsController : Controller
}));
}
// Expenses are always fully paid — exclude when filtering to unpaid/overdue bills only
// Expenses are always fully paid — exclude when filtering to unpaid/overdue bills only
if ((type == null || type == "Expense") && status != "Unpaid" && status != "Overdue")
{
var expSearch = search;
@@ -160,13 +160,13 @@ public class BillsController : Controller
return View(pagedEntries);
}
// ── Create ───────────────────────────────────────────────────────────────
// -- Create ---------------------------------------------------------------
// ── Create from Purchase Order ────────────────────────────────────────────
// -- Create from Purchase Order --------------------------------------------
/// <summary>
/// Scaffolds a new bill pre-filled from a received purchase order. Only POs in
/// <c>Received</c> or <c>PartiallyReceived</c> status can be billed — earlier states mean
/// <c>Received</c> or <c>PartiallyReceived</c> status can be billed — earlier states mean
/// goods have not yet arrived and no liability has been incurred. If a bill already exists for
/// the PO the user is redirected to the existing bill to prevent duplicate AP entries.
/// Line items are copied from PO items (using inventory item names where available), and
@@ -174,7 +174,7 @@ public class BillsController : Controller
/// <c>DefaultExpenseAccountId</c> is used to pre-categorise all lines, falling back to the
/// first active Expense/COGS account when the vendor has no default configured.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> CreateFromPurchaseOrder(int purchaseOrderId)
{
var currentUser = await _userManager.GetUserAsync(User);
@@ -248,7 +248,7 @@ public class BillsController : Controller
return View("Create", dto);
}
// ── Create ───────────────────────────────────────────────────────────────
// -- Create ---------------------------------------------------------------
/// <summary>
/// Returns the blank bill creation form. When <paramref name="vendorId"/> is supplied the
@@ -257,7 +257,7 @@ public class BillsController : Controller
/// amount. The AP account is pre-filled with the first active AccountsPayable sub-type account
/// so the double-entry pair is ready without manual lookup.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> Create(int? vendorId)
{
var dto = new CreateBillDto
@@ -291,14 +291,14 @@ public class BillsController : Controller
/// review before committing to AP. Empty line items (zero account or zero price) are stripped
/// before validation to avoid spurious errors when the browser submits blank rows.
/// If <paramref name="payNow"/> is true a <see cref="BillPayment"/> record is inserted
/// immediately and the bill status is advanced to <c>Paid</c> or <c>PartiallyPaid</c> —
/// immediately and the bill status is advanced to <c>Paid</c> or <c>PartiallyPaid</c> —
/// useful for entering historical bills that were already settled. Account balance side
/// effects are deliberately deferred to <see cref="MarkOpen"/> so that Draft bills do not
/// affect the AP ledger until they are approved. If the bill was created from a PO the
/// back-reference <c>PurchaseOrder.BillId</c> is set to establish the 1:1 linkage.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> Create(CreateBillDto dto, IFormFile? receiptFile,
bool payNow = false,
DateTime? paymentDate = null,
@@ -322,7 +322,7 @@ public class BillsController : Controller
{
var currentUser = await _userManager.GetUserAsync(User);
// Period lock check — block if the bill date is in a locked period
// Period lock check — block if the bill date is in a locked period
if (currentUser != null)
{
var co = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
@@ -399,7 +399,7 @@ public class BillsController : Controller
await _unitOfWork.CompleteAsync();
});
// Receipt upload after the transaction commits — bill.Id is set and core data
// Receipt upload after the transaction commits — bill.Id is set and core data
// is secure. A blob failure here leaves the bill intact without an attachment.
if (receiptFile != null && receiptFile.Length > 0)
{
@@ -428,7 +428,7 @@ public class BillsController : Controller
}
}
// ── Details ──────────────────────────────────────────────────────────────
// -- Details --------------------------------------------------------------
/// <summary>
/// Displays full bill detail including line items, payments, and the payment entry form.
@@ -454,7 +454,7 @@ public class BillsController : Controller
.ToList();
ViewBag.BankAccounts = bankAccounts
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
ViewBag.PaymentMethods = Enum.GetValues<PaymentMethod>()
@@ -464,7 +464,7 @@ public class BillsController : Controller
return View(dto);
}
// ── Edit ─────────────────────────────────────────────────────────────────
// -- Edit -----------------------------------------------------------------
/// <summary>
/// Returns the edit form for a bill. Only <c>Draft</c> bills are editable; once a bill is
@@ -472,7 +472,7 @@ public class BillsController : Controller
/// unreconciled ledger entries. Paid and Voided bills are also blocked to preserve the
/// audit trail.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> Edit(int? id)
{
if (id == null) return NotFound();
@@ -523,7 +523,7 @@ public class BillsController : Controller
/// storage; the old blob is deleted before the new one is written to avoid orphaned files.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> Edit(int id, EditBillDto dto, IFormFile? receiptFile)
{
if (id != dto.Id) return NotFound();
@@ -620,7 +620,7 @@ public class BillsController : Controller
}
}
// ── Mark Open (Draft Open) ─────────────────────────────────────────────
// -- Mark Open (Draft ? Open) ---------------------------------------------
/// <summary>
/// Transitions a bill from <c>Draft</c> to <c>Open</c> (the AP approval step). This is
@@ -631,7 +631,7 @@ public class BillsController : Controller
/// deferred from bill creation to give users a review window without polluting the ledger.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> MarkOpen(int id)
{
var bill = await _unitOfWork.Bills.GetByIdAsync(id, false, b => b.LineItems);
@@ -669,7 +669,7 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id });
}
// ── Record Payment ───────────────────────────────────────────────────────
// -- Record Payment -------------------------------------------------------
/// <summary>
/// Records a full or partial payment against an open bill. Overpayment is blocked because
@@ -681,7 +681,7 @@ public class BillsController : Controller
/// any positive remainder leaves the bill in <c>PartiallyPaid</c>.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> RecordPayment(RecordBillPaymentDto dto)
{
if (!ModelState.IsValid)
@@ -752,7 +752,7 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id = dto.BillId });
}
// ── Delete Payment ───────────────────────────────────────────────────────
// -- Delete Payment -------------------------------------------------------
/// <summary>
/// Reverses a previously recorded payment. All double-entry effects of
@@ -762,7 +762,7 @@ public class BillsController : Controller
/// <c>PartiallyPaid</c> depending on the remaining <c>AmountPaid</c> after reversal.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> DeletePayment(int paymentId, int billId)
{
try
@@ -809,7 +809,7 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id = billId });
}
// ── Edit Payment ─────────────────────────────────────────────────────────
// -- Edit Payment ---------------------------------------------------------
/// <summary>
/// Updates non-financial attributes of a payment (date, method, check number, memo) and,
@@ -818,7 +818,7 @@ public class BillsController : Controller
/// amount on the AP side does not change so no AP balance adjustment is needed.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> EditPayment(EditBillPaymentDto dto)
{
if (!ModelState.IsValid)
@@ -863,11 +863,11 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id = dto.BillId });
}
// ── Void ─────────────────────────────────────────────────────────────────
// -- Void -----------------------------------------------------------------
/// <summary>
/// Voids an open or partially-paid bill, removing the remaining AP liability from the ledger.
/// Only the unpaid portion (<c>BalanceDue</c>) is reversed on the AP account — any payments
/// Only the unpaid portion (<c>BalanceDue</c>) is reversed on the AP account — any payments
/// already recorded remain as historical cash transactions. The vendor balance is likewise
/// reduced only by the outstanding balance, not the total. To signal "fully settled" without
/// leaving a positive <c>BalanceDue</c>, <c>AmountPaid</c> is set equal to <c>Total</c>
@@ -922,7 +922,7 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id });
}
// ── AJAX: Vendor default expense account ────────────────────────────────
// -- AJAX: Vendor default expense account --------------------------------
/// <summary>
/// AJAX endpoint that returns a vendor's default expense account and payment terms. Called by
@@ -940,7 +940,7 @@ public class BillsController : Controller
});
}
// ── Helpers ──────────────────────────────────────────────────────────────
// -- Helpers --------------------------------------------------------------
/// <summary>
/// Loads all dropdown lists needed by the Create and Edit views into <c>ViewBag</c>: vendors,
@@ -979,7 +979,7 @@ public class BillsController : Controller
/// <summary>
/// Generates a sequential payment reference number in the format <c>BPMT-YYMM-####</c>.
/// Same monotonic sequence logic as <see cref="GenerateBillNumberAsync"/> — soft-deleted
/// Same monotonic sequence logic as <see cref="GenerateBillNumberAsync"/> — soft-deleted
/// records are included in the scan so payment numbers are never reused.
/// </summary>
private async Task<string> GeneratePaymentNumberAsync()
@@ -994,7 +994,7 @@ public class BillsController : Controller
return $"{prefix}{next:D4}";
}
// ── Receipt File: Download / Remove ─────────────────────────────────────
// -- Receipt File: Download / Remove -------------------------------------
/// <summary>
/// Downloads the receipt attachment for a bill as a file-download response. Unlike expense
@@ -1022,7 +1022,7 @@ public class BillsController : Controller
/// window where the UI shows a broken attachment link.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
public async Task<IActionResult> RemoveReceipt(int id)
{
var bill = await _unitOfWork.Bills.GetByIdAsync(id);
@@ -1039,7 +1039,7 @@ public class BillsController : Controller
return RedirectToAction(nameof(Details), new { id });
}
// ── AI: Receipt Scanning ─────────────────────────────────────────────────
// -- AI: Receipt Scanning -------------------------------------------------
/// <summary>
/// AI-powered receipt scanning endpoint. Accepts an image or PDF of a vendor receipt, passes
@@ -1051,7 +1051,7 @@ public class BillsController : Controller
/// model can match categories to the company's specific chart of accounts.
/// </summary>
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
public async Task<IActionResult> ScanReceipt(IFormFile? receiptImage)
{
@@ -1092,7 +1092,7 @@ public class BillsController : Controller
return Json(result);
}
// ── AI: Account Suggestion ────────────────────────────────────────────────
// -- AI: Account Suggestion ------------------------------------------------
/// <summary>
/// AI-powered account categorisation for a single bill line item. When the caller does not
@@ -1103,7 +1103,7 @@ public class BillsController : Controller
/// full account list in the DOM. Rate-limited to the <c>Ai</c> policy.
/// </summary>
[HttpPost]
[Authorize(Policy = AppConstants.Policies.CanManageInventory)]
[Authorize(Policy = AppConstants.Policies.CanManageBills)]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
public async Task<IActionResult> SuggestAccount([FromBody] AccountSuggestionRequest request)
{
@@ -1136,7 +1136,69 @@ public class BillsController : Controller
return Json(result);
}
// ── Receipt File Helpers ──────────────────────────────────────────────────
// -- AI: Recurring Bill Detection ------------------------------------------
/// <summary>
/// GET page — displays the recurring bill detection tool. No data is pre-fetched here;
/// the user triggers the scan by clicking a button which calls <see cref="RunRecurringDetection"/>.
/// </summary>
public IActionResult RecurringDetection() => View();
/// <summary>
/// AJAX POST — loads up to 12 months of bill history for the company and passes it to
/// Claude for recurring pattern analysis. Only posted bills (Draft/Open/Partial/Paid) are
/// included; Voided bills are excluded so cancelled payments do not distort the pattern.
/// Results are returned as JSON for client-side rendering in the view.
/// </summary>
[HttpPost]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RunRecurringDetection()
{
try
{
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
var cutoff = DateTime.Today.AddMonths(-12);
var bills = (await _unitOfWork.Bills.GetAllAsync(false, b => b.Vendor))
.Where(b => b.Status != BillStatus.Voided && b.BillDate >= cutoff)
.ToList();
if (!bills.Any())
return Json(new RecurringBillDetectionResult
{
Success = true,
Insights = new List<string> { "No bill history found in the last 12 months." }
});
var companyName = (await _unitOfWork.Companies.GetByIdAsync(companyId))?.CompanyName ?? "Your Company";
var request = new RecurringBillDetectionRequest
{
CompanyName = companyName,
Bills = bills.Select(b => new RecurringBillHistoryItem
{
VendorName = b.Vendor?.CompanyName ?? $"Vendor #{b.VendorId}",
BillNumber = b.BillNumber,
Amount = b.Total,
DateIso = b.BillDate.ToString("yyyy-MM-dd"),
Memo = b.Memo
}).ToList()
};
var result = await _accountingAi.DetectRecurringBillsAsync(request);
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
await _usageLogger.LogAsync(companyId, userId, AppConstants.AiFeatures.RecurringBillDetection, result.Success);
return Json(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error running recurring bill detection");
return Json(new RecurringBillDetectionResult { Success = false, ErrorMessage = "An error occurred while analyzing bill patterns." });
}
}
// -- Receipt File Helpers --------------------------------------------------
/// <summary>
/// Uploads a receipt file to Azure Blob Storage under the path
@@ -277,6 +277,7 @@ public class CompanyUsersController : Controller
{
AppConstants.CompanyRoles.CompanyAdmin,
AppConstants.CompanyRoles.Manager,
AppConstants.CompanyRoles.Accountant,
AppConstants.CompanyRoles.Worker,
AppConstants.CompanyRoles.Viewer
};
@@ -329,7 +330,9 @@ public class CompanyUsersController : Controller
CanManageVendors = forceAllPermissions || model.CanManageVendors,
CanManageMaintenance = forceAllPermissions || model.CanManageMaintenance,
CanManageInvoices = forceAllPermissions || model.CanManageInvoices,
CanViewReports = forceAllPermissions || model.CanViewReports
CanViewReports = forceAllPermissions || model.CanViewReports,
CanManageBills = forceAllPermissions || model.CanManageBills,
CanManageAccounting = forceAllPermissions || model.CanManageAccounting
};
var result = await _userManager.CreateAsync(user, model.Password);
@@ -341,6 +344,7 @@ public class CompanyUsersController : Controller
{
AppConstants.CompanyRoles.CompanyAdmin => AppConstants.Roles.Administrator,
AppConstants.CompanyRoles.Manager => AppConstants.Roles.Manager,
AppConstants.CompanyRoles.Accountant => AppConstants.Roles.Employee,
AppConstants.CompanyRoles.Worker => AppConstants.Roles.Employee,
_ => AppConstants.Roles.ReadOnly
};
@@ -454,7 +458,9 @@ public class CompanyUsersController : Controller
CanManageVendors = user.CanManageVendors,
CanManageMaintenance = user.CanManageMaintenance,
CanManageInvoices = user.CanManageInvoices,
CanViewReports = user.CanViewReports
CanViewReports = user.CanViewReports,
CanManageBills = user.CanManageBills,
CanManageAccounting = user.CanManageAccounting
};
ViewBag.ReturnUrl = returnUrl;
@@ -538,6 +544,7 @@ public class CompanyUsersController : Controller
{
AppConstants.CompanyRoles.CompanyAdmin,
AppConstants.CompanyRoles.Manager,
AppConstants.CompanyRoles.Accountant,
AppConstants.CompanyRoles.Worker,
AppConstants.CompanyRoles.Viewer
};
@@ -608,6 +615,8 @@ public class CompanyUsersController : Controller
user.CanManageMaintenance = forceAllPermissions || model.CanManageMaintenance;
user.CanManageInvoices = forceAllPermissions || model.CanManageInvoices;
user.CanViewReports = forceAllPermissions || model.CanViewReports;
user.CanManageBills = forceAllPermissions || model.CanManageBills;
user.CanManageAccounting = forceAllPermissions || model.CanManageAccounting;
user.UpdatedAt = DateTime.UtcNow;
var result = await _userManager.UpdateAsync(user);
@@ -2118,6 +2118,195 @@ public class ReportsController : Controller
}
}
// ── AI: Late Payment Prediction ───────────────────────────────────────────
/// <summary>
/// AJAX POST — loads all open AR invoices with customer payment history, then asks Claude
/// to score each customer's payment risk. Avg days to pay and late rate are pre-computed
/// from the full invoice history rather than open invoices only, so customers with only
/// one open invoice still get meaningful risk scoring based on prior behavior.
/// Gated behind <see cref="AllowAccounting"/>.
/// </summary>
[HttpPost]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
public async Task<IActionResult> PredictLatePayments()
{
if (!AllowAccounting()) return Json(new { success = false, error = "Accounting module is not enabled." });
try
{
var companyName = await GetCompanyNameAsync();
var today = DateTime.Today;
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
var activeInvoices = allInvoices.Where(i =>
i.Status != InvoiceStatus.Voided &&
i.Status != InvoiceStatus.WrittenOff).ToList();
static string CustomerDisplayName(Invoice i) =>
i.Customer?.CompanyName ?? (i.Customer != null
? $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim()
: $"Customer #{i.CustomerId}");
var outstandingByCustomer = activeInvoices
.Where(i => i.BalanceDue > 0 && i.Status != InvoiceStatus.Paid)
.GroupBy(i => CustomerDisplayName(i))
.ToList();
// Pre-compute per-customer historical behavior from all paid invoices
var historyByCustomer = activeInvoices
.Where(i => i.Status == InvoiceStatus.Paid && i.PaidDate.HasValue && i.SentDate.HasValue)
.GroupBy(i => CustomerDisplayName(i))
.ToDictionary(
g => g.Key,
g => new
{
AvgDaysToPay = g.Average(i => (i.PaidDate!.Value - i.SentDate!.Value).TotalDays),
TotalInvoices = g.Count(),
LateInvoices = g.Count(i => i.DueDate.HasValue && i.PaidDate!.Value > i.DueDate.Value)
});
var customerData = outstandingByCustomer.Select(g =>
{
var history = historyByCustomer.GetValueOrDefault(g.Key);
return new LatePaymentCustomerData
{
CustomerName = g.Key,
TotalOwed = g.Sum(i => i.BalanceDue),
AvgDaysToPay = history?.AvgDaysToPay ?? 30,
TotalInvoicesAllTime = history?.TotalInvoices ?? 0,
LateInvoicesAllTime = history?.LateInvoices ?? 0,
OpenInvoices = g.Select(i => new OpenInvoiceSummary
{
InvoiceNumber = i.InvoiceNumber,
BalanceDue = i.BalanceDue,
DueDateIso = i.DueDate?.ToString("yyyy-MM-dd"),
DaysOverdue = i.DueDate.HasValue && i.DueDate.Value < today
? (today - i.DueDate.Value).Days : 0
}).ToList()
};
}).ToList();
if (!customerData.Any())
return Json(new LatePaymentPredictionResult { Success = true, Insights = new() { "No outstanding invoices to analyze." } });
var result = await _accountingAi.PredictLatePaymentsAsync(new LatePaymentPredictionRequest
{
CompanyName = companyName,
Customers = customerData
});
var lpCid = int.TryParse(User.FindFirst("CompanyId")?.Value, out var _lpC) ? _lpC : 0;
await _usageLogger.LogAsync(lpCid, User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "", AppConstants.AiFeatures.LatePaymentPrediction, result.Success);
return Json(result);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error predicting late payments");
return Json(new LatePaymentPredictionResult { Success = false, ErrorMessage = "An error occurred while analyzing payment risk." });
}
}
// ── AI: Natural Language Financial Queries ────────────────────────────────
/// <summary>
/// GET page for the natural language financial query tool. Pre-loads the financial context
/// snapshot so the first query does not have a visible data-fetch delay — the context is
/// serialized into a hidden field and passed back on the POST.
/// </summary>
public async Task<IActionResult> FinancialQuery()
{
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
ViewBag.Context = await BuildFinancialQueryContextAsync();
return View();
}
/// <summary>
/// AJAX POST — receives the user's plain-English question and the pre-built context object,
/// then sends both to Claude. The context is passed from client to server (rather than
/// re-fetched on every request) so that rapid follow-up questions do not trigger additional
/// database round-trips. The context JSON is validated server-side before passing to the AI
/// service so a corrupted hidden field cannot cause a crash.
/// </summary>
[HttpPost]
[EnableRateLimiting(AppConstants.RateLimitPolicies.Ai)]
public async Task<IActionResult> RunFinancialQuery([FromBody] FinancialQueryRequest? request)
{
if (!AllowAccounting()) return Json(new { success = false, error = "Accounting module is not enabled." });
if (request == null || string.IsNullOrWhiteSpace(request.Question))
return Json(new FinancialQueryResult { Success = false, ErrorMessage = "Please enter a question." });
// If context is empty (e.g. client didn't pass it), rebuild from DB
if (request.Context == null || string.IsNullOrWhiteSpace(request.Context.CompanyName))
request.Context = await BuildFinancialQueryContextAsync();
var result = await _accountingAi.AnswerFinancialQueryAsync(request);
var fqCid = int.TryParse(User.FindFirst("CompanyId")?.Value, out var _fqC) ? _fqC : 0;
await _usageLogger.LogAsync(fqCid, User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "", AppConstants.AiFeatures.FinancialQuery, result.Success);
return Json(result);
}
/// <summary>
/// Builds a <see cref="FinancialQueryContext"/> snapshot from live DB data covering
/// YTD totals, last 12 months of monthly revenue/expense summaries, and current
/// AR/AP outstanding. This is factored out so both the GET page load and the fallback
/// POST path share identical context-building logic.
/// </summary>
private async Task<FinancialQueryContext> BuildFinancialQueryContextAsync()
{
var companyName = await GetCompanyNameAsync();
var now = DateTime.UtcNow;
var startOfYear = new DateTime(now.Year, 1, 1);
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments))
.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff)
.ToList();
var ytdRevenue = allInvoices.Where(i => i.InvoiceDate >= startOfYear).Sum(i => i.Total);
var arOutstanding = allInvoices.Where(i => i.BalanceDue > 0 && i.Status != InvoiceStatus.Paid).Sum(i => i.BalanceDue);
var allBills = await _operationalReports.GetActiveBillsAsync();
var ytdExpenses = allBills.Where(b => b.BillDate >= startOfYear).Sum(b => b.Total);
var apOutstanding = allBills.Where(b => b.BalanceDue > 0).Sum(b => b.BalanceDue);
// Monthly summaries for last 12 months
var monthly = new List<MonthlyFinancialSummary>();
for (var i = 11; i >= 0; i--)
{
var monthStart = new DateTime(now.Year, now.Month, 1).AddMonths(-i);
var monthEnd = monthStart.AddMonths(1);
var rev = allInvoices.Where(inv => inv.InvoiceDate >= monthStart && inv.InvoiceDate < monthEnd).Sum(inv => inv.Total);
var exp = allBills.Where(b => b.BillDate >= monthStart && b.BillDate < monthEnd).Sum(b => b.Total);
monthly.Add(new MonthlyFinancialSummary
{
Month = monthStart.ToString("yyyy-MM"),
Revenue = rev,
Expenses = exp,
NetIncome = rev - exp
});
}
// Expense breakdown from bills by account
var expensesByAccount = allBills
.GroupBy(b => b.Memo ?? "Uncategorized")
.Select(g => new ExpenseByCategory { Category = g.Key, Amount = g.Sum(b => b.Total) })
.OrderByDescending(e => e.Amount)
.Take(10)
.ToList();
return new FinancialQueryContext
{
CompanyName = companyName,
AsOfDate = now.ToString("yyyy-MM-dd"),
TotalRevenueYtd = ytdRevenue,
TotalExpensesYtd = ytdExpenses,
NetIncomeYtd = ytdRevenue - ytdExpenses,
ArOutstanding = arOutstanding,
ApOutstanding = apOutstanding,
Last12Months = monthly,
ExpensesByCategory = expensesByAccount
};
}
// GET: /Reports/BudgetVsActual
/// <summary>
/// Budget vs. Actual report: compares a budget's monthly line amounts against real P&amp;L activity
@@ -82,7 +82,8 @@ public static class HelpKnowledgeBase
ROLE AWARENESS:
- SuperAdmin: Full access to everything including Platform Management tools
- CompanyAdmin: Full access to all company features including Settings, Users, Billing
- Manager: Access to jobs, quotes, invoices, customers, inventory, reports no platform tools
- Manager: Access to jobs, quotes, invoices, customers, inventory, vendors, reports no platform tools
- Accountant: Financial focus bills & AP, invoices, bank reconciliations, chart of accounts, vendors, purchase orders, reports; no jobs, settings, or user management
- Worker: Can create/edit jobs and quotes; no settings, billing, or user management
- Viewer: Read-only access to most data; no create/edit capabilities
@@ -511,6 +512,8 @@ public static class HelpKnowledgeBase
**AI Receipt Scanning:** Bills "Scan Receipt" upload a photo or PDF of a vendor receipt/invoice and AI will pre-fill the vendor, date, amount, and line items.
**Recurring Bill Detection:** [Bills](/Bills) "Detect Recurring Bills" button (top-left of the bills list, or navigate to /Bills/RecurringDetection). AI scans the last 12 months of bills to identify vendors you pay on a regular schedule. Each pattern card shows the vendor name, frequency (Monthly / Bi-Monthly / Quarterly / Annual / Irregular), typical amount, confidence level (High / Medium / Low), estimated next expected date, and a suggested action (e.g., "Set up auto-pay" or "Budget monthly"). Useful for cash flow planning knowing what's coming reduces surprises. At least 2 occurrences are needed before the AI can establish a pattern; one-time bills are filtered out automatically.
**Accounts:** [/Accounts](/Accounts) The chart of accounts (Assets, Liabilities, Equity, Revenue, COGS, Expenses). Accounts are assigned to bill line items for financial reporting.
**Accounting Export:** [/AccountingExport](/AccountingExport) Export financial data to accounting software.
@@ -577,6 +580,8 @@ public static class HelpKnowledgeBase
- *Cash Flow Forecast* 30/60/90-day projection based on open AR, AP, and job pipeline
- *Anomaly Detection* AI scans for duplicate bills, amount spikes, unusual vendors
- *AR Follow-Up Emails* AI drafts collection emails for overdue invoices (from AR Aging report)
- *AI Payment Risk Prediction* on the AR Aging report, click "Predict Payment Risk" to get an AI assessment for each outstanding customer: High / Medium / Low risk with reasoning and recommended action (call now, send reminder, standard follow-up). Powered by each customer's payment history, current balance, and days outstanding.
- *Ask Your Financials* [/Reports/FinancialQuery](/Reports/FinancialQuery) natural language query interface. Type any financial question ("What were my top expenses last quarter?", "Which customers owe the most?") and the AI answers using your real data. Includes suggestion chips, follow-up prompts, supporting facts, and session history. The right panel shows a YTD financial snapshot (revenue, expenses, net income, open AR, open AP).
- *Powder Usage Report* powder consumption by item/job
- *Job Cycle Time Report* how long jobs spend in each status
@@ -891,8 +896,9 @@ public static class HelpKnowledgeBase
**Where:** [Company Users](/CompanyUsers) via Settings menu Users
**Roles:**
- *CompanyAdmin* full company access including settings, users, billing
- *Manager* jobs, quotes, invoices, customers, inventory, reports no settings or user management
- *CompanyAdmin* full company access including settings, users, billing. All permissions granted automatically.
- *Manager* jobs, quotes, invoices, customers, inventory, vendors, reports no settings or user management
- *Accountant* financial focus: bills & AP, invoices, bank reconciliations, chart of accounts, vendors, purchase orders, and reports. No job management, settings, or user management. When selected, the system auto-checks the five relevant permissions (Invoices, Reports, Vendors, Bills & AP, Accounting).
- *Worker* create/edit jobs and quotes; no settings, billing, or user management
- *Viewer* read-only access
@@ -902,6 +908,12 @@ public static class HelpKnowledgeBase
3. System sends an invitation email
4. Save
**Fine-grained permissions:** Below the role dropdown on the Create/Edit user form, individual permission checkboxes let you grant specific capabilities beyond what the role provides. Notable permissions:
- *Can Manage Bills & AP* access to vendor bills, expenses, bill payments, and recurring bill detection. The Bills controller requires this permission for all write actions.
- *Can Manage Accounting* access to chart of accounts, bank reconciliations, and journal entries.
- *Can View Reports* access to all financial reports and AI analytics features (cash flow, anomaly detection, financial queries, late payment prediction).
CompanyAdmin users always have all permissions (checkboxes are locked). Accountant role auto-checks: Can Manage Invoices, Can View Reports, Can Manage Vendors, Can Manage Bills & AP, and Can Manage Accounting.
**Resetting a password (sending a reset link):** On the Company Users list or the user's Details page, click the envelope-arrow button (<i class="bi bi-envelope-arrow-up"></i>) next to the user. This sends the user an email with a secure password reset link they click it and choose a new password themselves. This is the recommended way to help a user who is locked out or who fat-fingered their email at signup.
**Deactivating a user:** Use the toggle on the user list or the edit form.
@@ -1239,6 +1251,14 @@ public static class HelpKnowledgeBase
11. **AI Quick Quote** A floating button (visible on every page) that lets you get an instant rough estimate from a verbal description ideal for phone calls and walk-in customers. Type a description such as "4 wheels, gloss black, need sandblasting", enter quantity and coat count, and the AI returns a price estimate with a confidence score. Detected color names are matched against your inventory so you can see at a glance whether you have the powder in stock. You can then save the quote under a "Walk-In / Phone" customer with one click and reassign it to the real customer record later. Access via the **dark-blue floating button** in the bottom-right corner, just above the AI Help button.
12. **Recurring Bill Detection** AI scans the last 12 months of vendor bills to identify recurring payment patterns. Access via [Bills](/Bills) "Detect Recurring Bills." See the BILLS section above for full details.
13. **AI Payment Risk Prediction** On the [AR Aging](/Reports/ArAging) report, click "Predict Payment Risk" to get a risk assessment (High / Medium / Low) for each outstanding customer with reasoning and a recommended action. Powered by payment history, current balance, and days outstanding.
14. **Ask Your Financials** Natural language financial queries at [/Reports/FinancialQuery](/Reports/FinancialQuery). Ask questions in plain English and get answers drawn from your real financial data, with supporting facts and follow-up prompts. See the REPORTS section above for full details.
15. **Bank Rec Auto-Match** On the [Bank Reconciliations](/BankReconciliations) Reconcile page, click "AI Suggest Matches" to have AI review your uncleared transactions and suggest which ones to mark as cleared to reach your target balance. Each suggestion includes a confidence percentage and a reason. Click "Apply All Suggestions" to mark all recommended items cleared in one step. You still control the final reconciliation the AI suggestions are a starting point, not a commitment.
**Plan availability:** AI Photo Quotes and AI Inventory Assist are enabled at the subscription plan level. If you do not see the AI Photo Quote option in the quote wizard or the AI lookup button on inventory items, the feature may not be included in your current plan. Contact your administrator or check [Billing](/Billing) to see your plan details.
The AI Profile (in Company Settings) lets you describe your shop's specialties to improve AI quote estimates. This tab only appears when AI Photo Quotes are enabled for your account.
+37 -3
View File
@@ -414,6 +414,9 @@ builder.Services.AddAuthorization(options =>
var user = context.User;
if (user.IsInRole(AppConstants.Roles.SuperAdmin))
return true;
var companyRole = user.FindFirst("CompanyRole")?.Value;
if (companyRole == AppConstants.CompanyRoles.Accountant)
return true;
return user.HasClaim("Permission", "ManageVendors");
}));
@@ -425,7 +428,8 @@ builder.Services.AddAuthorization(options =>
return true;
var companyRole = user.FindFirst("CompanyRole")?.Value;
if (companyRole == AppConstants.CompanyRoles.CompanyAdmin ||
companyRole == AppConstants.CompanyRoles.Manager)
companyRole == AppConstants.CompanyRoles.Manager ||
companyRole == AppConstants.CompanyRoles.Accountant)
return true;
return user.HasClaim("Permission", "ManageInventory") ||
user.HasClaim("Permission", "ManageVendors");
@@ -448,7 +452,8 @@ builder.Services.AddAuthorization(options =>
return true;
var companyRole = user.FindFirst("CompanyRole")?.Value;
if (companyRole == AppConstants.CompanyRoles.CompanyAdmin ||
companyRole == AppConstants.CompanyRoles.Manager)
companyRole == AppConstants.CompanyRoles.Manager ||
companyRole == AppConstants.CompanyRoles.Accountant)
return true;
return user.HasClaim("Permission", "ManageInvoices") ||
user.HasClaim("Permission", "ManageJobs");
@@ -462,11 +467,40 @@ builder.Services.AddAuthorization(options =>
return true;
var companyRole = user.FindFirst("CompanyRole")?.Value;
if (companyRole == AppConstants.CompanyRoles.CompanyAdmin ||
companyRole == AppConstants.CompanyRoles.Manager)
companyRole == AppConstants.CompanyRoles.Manager ||
companyRole == AppConstants.CompanyRoles.Accountant)
return true;
return user.HasClaim("Permission", "ViewReports");
}));
options.AddPolicy("CanManageBills", policy =>
policy.RequireAssertion(context =>
{
var user = context.User;
if (user.IsInRole(AppConstants.Roles.SuperAdmin))
return true;
var companyRole = user.FindFirst("CompanyRole")?.Value;
if (companyRole == AppConstants.CompanyRoles.CompanyAdmin ||
companyRole == AppConstants.CompanyRoles.Manager ||
companyRole == AppConstants.CompanyRoles.Accountant)
return true;
return user.HasClaim("Permission", "ManageBills");
}));
options.AddPolicy("CanManageAccounting", policy =>
policy.RequireAssertion(context =>
{
var user = context.User;
if (user.IsInRole(AppConstants.Roles.SuperAdmin))
return true;
var companyRole = user.FindFirst("CompanyRole")?.Value;
if (companyRole == AppConstants.CompanyRoles.CompanyAdmin ||
companyRole == AppConstants.CompanyRoles.Manager ||
companyRole == AppConstants.CompanyRoles.Accountant)
return true;
return user.HasClaim("Permission", "ManageAccounting");
}));
options.AddPolicy("CanManageUsers", policy =>
policy.RequireAssertion(context =>
{
@@ -6,7 +6,7 @@
<div class="container-fluid py-3" style="max-width:700px">
<div class="d-flex align-items-center gap-3 mb-3">
<a asp-action="Index" class="btn btn-outline-secondary btn-sm"><i class="bi bi-arrow-left me-1"></i>Back</a>
<a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>Back</a>
<h4 class="mb-0"><i class="bi bi-megaphone me-2 text-primary"></i>New Announcement</h4>
</div>
@@ -6,7 +6,7 @@
<div class="container-fluid py-3" style="max-width:700px">
<div class="d-flex align-items-center gap-3 mb-3">
<a asp-action="Index" class="btn btn-outline-secondary btn-sm"><i class="bi bi-arrow-left me-1"></i>Back</a>
<a asp-action="Index" class="btn btn-outline-secondary"><i class="bi bi-arrow-left me-1"></i>Back</a>
<h4 class="mb-0"><i class="bi bi-megaphone me-2 text-primary"></i>Edit Announcement</h4>
</div>
@@ -20,7 +20,7 @@
<div class="container-fluid py-3">
<div class="d-flex align-items-center justify-content-between mb-3">
<h4 class="mb-0"><i class="bi bi-megaphone me-2 text-primary"></i>Announcements</h4>
<a asp-action="Create" class="btn btn-primary btn-sm">
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-lg me-1"></i>New Announcement
</a>
</div>
@@ -69,7 +69,7 @@
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary" id="quickCreateSubmit">
<i class="bi bi-check-circle"></i> Create Appointment
</button>
@@ -17,7 +17,7 @@
<div class="container-fluid py-3" style="max-width:900px">
<div class="d-flex align-items-center gap-3 mb-3">
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
<h4 class="mb-0"><i class="bi bi-shield-check me-2 text-primary"></i>Audit Entry #@Model.Id</h4>
@@ -6,7 +6,7 @@
<div class="d-flex align-items-center mb-3 gap-2">
<h4 class="mb-0 fw-semibold">Bank Reconciliation</h4>
<a asp-action="Create" class="btn btn-sm btn-primary ms-auto">
<a asp-action="Create" class="btn btn-primary ms-auto">
<i class="bi bi-plus-lg me-1"></i>Start New Reconciliation
</a>
</div>
@@ -115,6 +115,27 @@
</div>
</div>
<!-- AI Auto-Match panel -->
<div class="card shadow-sm mb-3 border-0 bg-light">
<div class="card-body d-flex align-items-center gap-3 flex-wrap">
<div>
<span class="fw-semibold"><i class="bi bi-robot text-primary me-1"></i>AI Auto-Match</span>
<span class="text-muted small ms-2">Let Claude suggest which transactions to clear based on amounts and dates.</span>
</div>
<button id="aiMatchBtn" class="btn btn-outline-primary btn-sm ms-auto" type="button">
<i class="bi bi-magic me-1"></i>Suggest Matches
</button>
</div>
<div id="aiMatchResult" class="d-none px-3 pb-3">
<div id="aiMatchInsights" class="mb-2 text-muted small"></div>
<div id="aiMatchActions" class="d-flex gap-2 flex-wrap">
<button id="aiMatchAccept" class="btn btn-sm btn-success d-none">
<i class="bi bi-check-all me-1"></i>Apply All Suggestions
</button>
</div>
</div>
</div>
<form asp-action="Complete" method="post" id="completeForm">
@Html.AntiForgeryToken()
<input type="hidden" name="id" value="@recon?.Id" />
@@ -184,6 +205,88 @@
});
recalculate();
// ── AI Auto-Match ──────────────────────────────────────────────────────────
let aiSuggestions = [];
document.getElementById('aiMatchBtn')?.addEventListener('click', async function() {
const btn = this;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Analyzing…';
try {
const resp = await fetch('/BankReconciliations/AiSuggestMatches', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'RequestVerificationToken': token
},
body: new URLSearchParams({ reconId })
});
const data = await resp.json();
const resultEl = document.getElementById('aiMatchResult');
const insightsEl = document.getElementById('aiMatchInsights');
resultEl.classList.remove('d-none');
if (!data.success) {
insightsEl.innerHTML = `<span class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>${data.errorMessage || 'AI unavailable.'}</span>`;
return;
}
aiSuggestions = data.suggestedCleared || [];
// Highlight suggested rows
aiSuggestions.forEach(s => {
const row = document.querySelector(`.recon-row[data-type="${s.entityType}"][data-id="${s.entityId}"]`);
if (row) {
row.classList.add('table-info');
const td = row.querySelector('td:last-child');
if (td) {
const pct = Math.round(s.confidence * 100);
td.insertAdjacentHTML('afterend', `<td class="small text-info" style="white-space:nowrap">${pct}% — ${s.reason}</td>`);
}
}
});
// Insights
const insights = data.insights || [];
insightsEl.innerHTML = insights.map(i => `<i class="bi bi-lightbulb me-1 text-warning"></i>${i}`).join('<br>');
if (aiSuggestions.length > 0) {
document.getElementById('aiMatchAccept').classList.remove('d-none');
} else {
insightsEl.innerHTML += '<br><span class="text-muted">No high-confidence suggestions found — review items manually.</span>';
}
} catch (err) {
document.getElementById('aiMatchInsights').innerHTML = '<span class="text-danger">Error contacting AI service.</span>';
document.getElementById('aiMatchResult').classList.remove('d-none');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-magic me-1"></i>Suggest Matches';
}
});
document.getElementById('aiMatchAccept')?.addEventListener('click', async function() {
for (const s of aiSuggestions) {
const row = document.querySelector(`.recon-row[data-type="${s.entityType}"][data-id="${s.entityId}"]`);
if (!row) continue;
const cb = row.querySelector('.cleared-checkbox');
if (!cb || cb.checked) continue;
cb.checked = true;
// Persist via the existing toggle endpoint
try {
await fetch('/BankReconciliations/ToggleCleared', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': token },
body: new URLSearchParams({ reconId, entityType: s.entityType, entityId: s.entityId, isCleared: true })
});
} catch {}
}
recalculate();
this.textContent = 'Applied';
this.disabled = true;
});
})();
</script>
}
+23 -23
View File
@@ -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)
@@ -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,12 +273,12 @@
<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>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="scanReceiptUploadBtn">
<i class="bi bi-camera me-1"></i>Scan &amp; Fill
</button>
@@ -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 {
@@ -5,7 +5,10 @@
ViewData["PageIcon"] = "bi-receipt-cutoff";
}
<div class="d-flex justify-content-end mb-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<a asp-action="RecurringDetection" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-robot me-1"></i>Detect Recurring Bills
</a>
<div class="btn-group">
<a asp-controller="Bills" asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-lg me-1"></i>New Bill
@@ -0,0 +1,58 @@
@{
ViewData["Title"] = "Recurring Bill Detection";
ViewData["PageIcon"] = "bi-arrow-repeat";
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="fw-semibold mb-1"><i class="bi bi-arrow-repeat text-primary me-2"></i>Recurring Bill Detection</h4>
<p class="text-muted small mb-0">Claude analyzes your last 12 months of bills to find recurring payment patterns and help you anticipate upcoming expenses.</p>
</div>
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Bills
</a>
</div>
<form id="scanForm" method="post" asp-action="RunRecurringDetection">
@Html.AntiForgeryToken()
<div class="card shadow-sm mb-4 border-0 bg-light">
<div class="card-body d-flex align-items-center gap-3 flex-wrap">
<div>
<span class="fw-semibold"><i class="bi bi-robot text-primary me-1"></i>AI Analysis</span>
<span class="text-muted small ms-2">Scans up to 12 months of bills grouped by vendor to detect patterns.</span>
</div>
<button id="scanBtn" type="submit" class="btn btn-primary ms-auto">
<i class="bi bi-magic me-1"></i>Detect Recurring Bills
</button>
</div>
</div>
</form>
<div id="resultArea" class="d-none">
<div id="spinnerArea" class="text-center py-5 d-none">
<div class="spinner-border text-primary" style="width:2.5rem;height:2.5rem;" role="status"></div>
<p class="text-muted mt-3">Claude is reviewing your bill history…</p>
</div>
<div id="errorArea" class="alert alert-danger alert-permanent d-none"></div>
<div id="insightsArea" class="alert alert-info alert-permanent d-none mb-3">
<i class="bi bi-lightbulb me-2"></i><span id="insightsList"></span>
</div>
<div id="noPatterns" class="card shadow-sm d-none">
<div class="card-body text-center py-5 text-muted">
<i class="bi bi-search fs-1 d-block mb-2"></i>
<p class="mb-0 fw-semibold">No recurring patterns detected</p>
<p class="small">Need at least 2 occurrences of a vendor bill at a similar cadence. Add more bill history and try again.</p>
</div>
</div>
<div id="patternsArea" class="d-none">
<div class="row g-3" id="patternCards"></div>
</div>
</div>
@section Scripts {
<script src="/js/recurring-detection.js"></script>
}
@@ -8,7 +8,7 @@
}
<div class="mb-4">
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to Budgets
</a>
</div>
@@ -48,7 +48,7 @@
<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">
<button type="button" class="btn btn-sm btn-outline-secondary" id="fillEvenlyBtn">
<i class="bi bi-distribute-horizontal me-1"></i>Spread Annual Evenly
</button>
</div>
@@ -1,14 +1,14 @@
@using PowderCoating.Web.Controllers
@using PowderCoating.Web.Controllers
@model BudgetCreateVm
@{
ViewData["Title"] = $"Edit Budget — {Model.Name}";
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">
<a asp-action="Index" class="btn btn-outline-secondary">
<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">
@@ -24,7 +24,7 @@
<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
<i class="bi bi-pie-chart me-2 text-primary"></i>@Model.Name @Model.FiscalYear
</h5>
</div>
<div class="card-body">
@@ -51,7 +51,7 @@
<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">
<button type="button" class="btn btn-sm btn-outline-secondary" id="fillEvenlyBtn">
<i class="bi bi-distribute-horizontal me-1"></i>Spread Annual Evenly
</button>
</div>
@@ -91,7 +91,7 @@
</div>
<div class="d-flex justify-content-between align-items-center mb-4">
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i> Back to Catalog
</a>
@if (!(bool)(ViewBag.AiPriceCheckEnabled ?? true))
@@ -215,7 +215,7 @@
<div id="categoryModalError" class="alert alert-danger d-none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="saveCategoryBtn">
<i class="bi bi-check-circle me-1"></i>Create Category
</button>
@@ -238,7 +238,7 @@
<div id="categoryModalError" class="alert alert-danger d-none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="saveCategoryBtn">
<i class="bi bi-check-circle me-1"></i>Create Category
</button>
@@ -184,7 +184,7 @@
</div>
<div class="d-flex gap-2 justify-content-end">
<a asp-action="Index" class="btn btn-secondary">Cancel</a>
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-save me-1"></i>Create Company
</button>
@@ -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>
@@ -625,7 +625,7 @@
@Html.AntiForgeryToken()
<input type="hidden" name="confirmation" id="resetDataConfirmHidden" />
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" id="btnResetDataConfirm" class="btn btn-warning" disabled>
<i class="bi bi-fire me-1"></i>Reset All Data
</button>
@@ -656,7 +656,7 @@
@Html.AntiForgeryToken()
<input type="hidden" name="confirmation" id="hardDeleteConfirmHidden" />
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" id="btnHardDeleteConfirm" class="btn btn-danger" disabled>
<i class="bi bi-trash me-1"></i>Permanently Delete Company
</button>
@@ -694,7 +694,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<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-key me-2"></i>Reset Password
</button>
@@ -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>'
@@ -160,7 +160,7 @@
</div>
<div class="d-flex gap-2 justify-content-end">
<a asp-action="Index" class="btn btn-secondary">Cancel</a>
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-save me-1"></i>Save Changes
</button>
@@ -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,7 +413,7 @@
<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 alert-permanent py-2 mb-3">
@@ -439,7 +439,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</div>
@@ -777,7 +777,7 @@
<div id="ovenErrorMsg" class="alert alert-danger d-none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveOven()">Save</button>
</div>
</div>
@@ -71,7 +71,7 @@
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="saveJobStatusBtn">
<i class="bi bi-check-circle me-1"></i>Save
</button>
@@ -121,7 +121,7 @@
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="saveJobPriorityBtn">
<i class="bi bi-check-circle me-1"></i>Save
</button>
@@ -195,7 +195,7 @@
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="saveQuoteStatusBtn">
<i class="bi bi-check-circle me-1"></i>Save
</button>
@@ -242,7 +242,7 @@
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="saveInventoryCategoryBtn">
<i class="bi bi-check-circle me-1"></i>Save
</button>
@@ -335,7 +335,7 @@
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="saveAppointmentTypeBtn">
<i class="bi bi-check-circle me-1"></i>Save
</button>
@@ -387,7 +387,7 @@
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="savePrepServiceBtn">
<i class="bi bi-check-circle me-1"></i>Save
</button>
@@ -490,7 +490,7 @@
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="saveBlastSetupBtn">
<i class="bi bi-check-circle me-1"></i>Save
</button>
@@ -78,6 +78,7 @@
<select asp-for="CompanyRole" class="form-select">
<option value="Viewer">Viewer (Read-only)</option>
<option value="Worker">Worker</option>
<option value="Accountant">Accountant</option>
<option value="Manager">Manager</option>
<option value="CompanyAdmin">Company Admin</option>
</select>
@@ -198,10 +199,23 @@
<label asp-for="CanViewReports" class="form-check-label">Can View Reports</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanManageBills" class="form-check-input permission-checkbox" />
<label asp-for="CanManageBills" class="form-check-label">Can Manage Bills &amp; AP</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanManageAccounting" class="form-check-input permission-checkbox" />
<label asp-for="CanManageAccounting" class="form-check-label">Can Manage Accounting</label>
<div class="form-text text-muted small">Chart of accounts, bank reconciliations, journal entries</div>
</div>
</div>
</div>
<div class="d-flex gap-2 justify-content-end">
<a asp-action="Index" class="btn btn-secondary">Cancel</a>
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
<button type="submit" class="btn btn-primary">
<i class="bi bi-save me-1"></i>Create User
</button>
@@ -223,19 +237,24 @@
const permissionCheckboxes = document.querySelectorAll('.permission-checkbox');
const adminAlert = document.getElementById('companyAdminAlert');
function updatePermissionState() {
const isCompanyAdmin = roleSelect.value === 'CompanyAdmin';
const accountantDefaults = ['CanManageInvoices', 'CanViewReports', 'CanManageVendors', 'CanManageBills', 'CanManageAccounting'];
function updatePermissionState() {
const role = roleSelect.value;
const isCompanyAdmin = role === 'CompanyAdmin';
const isAccountant = role === 'Accountant';
// Show/hide alert
adminAlert.style.display = isCompanyAdmin ? 'block' : 'none';
// Check all and disable if Company Admin, otherwise enable
permissionCheckboxes.forEach(checkbox => {
if (isCompanyAdmin) {
checkbox.checked = true;
checkbox.disabled = true;
} else {
checkbox.disabled = false;
if (isAccountant) {
checkbox.checked = accountantDefaults.includes(checkbox.id);
}
}
});
}
@@ -90,6 +90,7 @@
<select asp-for="CompanyRole" class="form-select">
<option value="Viewer">Viewer (Read-only)</option>
<option value="Worker">Worker</option>
<option value="Accountant">Accountant</option>
<option value="Manager">Manager</option>
<option value="CompanyAdmin">Company Admin</option>
</select>
@@ -215,17 +216,30 @@
<label asp-for="CanViewReports" class="form-check-label">Can View Reports</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanManageBills" class="form-check-input permission-checkbox" />
<label asp-for="CanManageBills" class="form-check-label">Can Manage Bills &amp; AP</label>
</div>
</div>
<div class="col-md-6">
<div class="form-check">
<input asp-for="CanManageAccounting" class="form-check-input permission-checkbox" />
<label asp-for="CanManageAccounting" class="form-check-label">Can Manage Accounting</label>
<div class="form-text text-muted small">Chart of accounts, bank reconciliations, journal entries</div>
</div>
</div>
</div>
<div class="d-flex gap-2 justify-content-end">
@if (!string.IsNullOrEmpty(ViewBag.ReturnUrl))
{
<input type="hidden" name="returnUrl" value="@ViewBag.ReturnUrl" />
<a href="@ViewBag.ReturnUrl" class="btn btn-secondary">Cancel</a>
<a href="@ViewBag.ReturnUrl" class="btn btn-outline-secondary">Cancel</a>
}
else
{
<a asp-action="Index" class="btn btn-secondary">Cancel</a>
<a asp-action="Index" class="btn btn-outline-secondary">Cancel</a>
}
<button type="submit" class="btn btn-primary">
<i class="bi bi-save me-1"></i>Save Changes
@@ -249,26 +263,30 @@
const adminAlert = document.getElementById('companyAdminAlert');
const isSuperAdmin = @((ViewBag.IsSuperAdmin as bool? ?? false) ? "true" : "false");
const accountantDefaults = ['CanManageInvoices', 'CanViewReports', 'CanManageVendors', 'CanManageBills', 'CanManageAccounting'];
function updatePermissionState() {
const isCompanyAdmin = roleSelect.value === 'CompanyAdmin';
const role = roleSelect.value;
const isCompanyAdmin = role === 'CompanyAdmin';
const isAccountant = role === 'Accountant';
if (isSuperAdmin) {
// SuperAdmins can always edit individual permissions
adminAlert.style.display = 'none';
permissionCheckboxes.forEach(checkbox => { checkbox.disabled = false; });
return;
}
// Show/hide alert
adminAlert.style.display = isCompanyAdmin ? 'block' : 'none';
// Check all and disable if Company Admin, otherwise enable
permissionCheckboxes.forEach(checkbox => {
if (isCompanyAdmin) {
checkbox.checked = true;
checkbox.disabled = true;
} else {
checkbox.disabled = false;
if (isAccountant) {
checkbox.checked = accountantDefaults.includes(checkbox.id);
}
}
});
}
@@ -1,4 +1,4 @@
@model PagedResult<PowderCoating.Application.DTOs.User.CompanyUserListDto>
@model PagedResult<PowderCoating.Application.DTOs.User.CompanyUserListDto>
@{
ViewData["Title"] = "Manage Users";
@@ -258,7 +258,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<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-slash-circle"></i> Ban User</button>
</div>
</form>
@@ -7,7 +7,7 @@
<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">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to Credit Memos
</a>
</div>
@@ -59,7 +59,7 @@
<i class="bi bi-x-circle me-1"></i>Void
</button>
}
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
</div>
@@ -247,14 +247,14 @@
@if (openInvoices.Any())
{
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-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>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
</div>
}
</form>
@@ -293,7 +293,7 @@
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-danger">Void Credit Memo</button>
</div>
</form>
@@ -10,7 +10,7 @@
<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">
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-lg me-1"></i>Issue Credit Memo
</a>
</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>130d @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>3160d @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>6190d @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 &middot; 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>
@@ -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)
@@ -814,7 +814,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary" id="apm-saveBtn">
<i class="bi bi-plus-circle me-1"></i>Add to Inventory
</button>
@@ -883,7 +883,7 @@
const esc = s => s ? s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') : '';
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">&#9650;</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";
@@ -59,7 +59,7 @@
<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">030d</th>
<th class="text-end" style="width:100px">3090d</th>
<th class="text-end" style="width:100px">&gt;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 / &gt;90d</span>
<span class="mobile-card-label">030d / 3090d / &gt;90d</span>
<span class="mobile-card-value">@s.DeletedLast30Days / @s.Deleted30To90Days / @s.DeletedOlderThan90Days</span>
</div>
</div>
@@ -279,7 +279,7 @@
<p class="mb-0 text-muted">Are you sure you want to continue?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmPurgeBtn">
<i class="bi bi-trash3-fill me-2"></i>Yes, Purge Permanently
</button>
@@ -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;
@@ -8,7 +8,7 @@
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-end mb-3">
<a asp-action="Index" class="btn btn-secondary">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Diagnostics
</a>
</div>
@@ -183,7 +183,7 @@
class="btn btn-outline-secondary btn-sm">
<i class="bi bi-box-arrow-up-right me-1"></i>Open in New Tab
</a>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
@@ -7,7 +7,7 @@
<div class="col-lg-8">
<div class="mb-4">
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to Asset Register
</a>
</div>
@@ -14,7 +14,7 @@
}
<div class="d-flex justify-content-between align-items-center mb-4">
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Asset Register
</a>
<div class="d-flex gap-2">
@@ -9,7 +9,7 @@
<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">
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to Asset
</a>
</div>
@@ -229,6 +229,43 @@
</p>
</section>
<section id="recurring-detection" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-arrow-repeat text-primary me-2"></i>Recurring Bill Detection
</h2>
<p>
The <strong>Detect Recurring Bills</strong> tool is accessible from the Bills list via the
button in the top-left of the page, or directly at
<a asp-controller="Bills" asp-action="RecurringDetection">/Bills/RecurringDetection</a>.
Click <strong>"Detect Recurring Bills"</strong> and Claude analyzes the last 12 months of your
bill history to find vendors you pay on a regular schedule.
</p>
<p>
Each detected pattern is shown as a card with:
</p>
<ul class="mb-3">
<li class="mb-1"><strong>Vendor name</strong> and detected frequency (monthly, quarterly, biannual, annual).</li>
<li class="mb-1"><strong>Typical amount</strong> — the usual charge from that vendor.</li>
<li class="mb-1"><strong>Next expected date</strong> — Claude's estimate of when the next bill is likely to arrive.</li>
<li class="mb-1"><strong>Confidence badge</strong> — High (4+ consistent occurrences), Medium (23 occurrences or variable timing), Low (weak pattern, worth monitoring).</li>
<li class="mb-1"><strong>Suggested action</strong> — for example, "Set a monthly reminder for this bill."</li>
</ul>
<p>
This is useful for cash flow planning — knowing that a $1,200 electricity bill arrives on the
15th every month, or that your insurance renews every January, lets you reserve funds in advance
and avoid surprises. High-confidence patterns are reliable enough to act on; Low-confidence
patterns are worth keeping an eye on but should not be treated as certain.
</p>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
Recurring bill detection requires at least 2 occurrences of a vendor bill at a similar
interval to detect a pattern. Shops with less than 2 months of history will see few or no
results. The scan covers bills only — direct expenses are not included.
</div>
</div>
</section>
<section id="expense-accounts" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-folder2-open text-primary me-2"></i>Expense Accounts
@@ -251,6 +251,57 @@
You can edit the draft before sending.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">AI Payment Risk Prediction</h3>
<p>
Available inside the <strong>AR Aging</strong> report (<a asp-controller="Reports" asp-action="ArAging">/Reports/ArAging</a>).
Click <strong>"Predict Payment Risk"</strong> at the bottom of the page to have Claude analyze
each open AR customer and assign a risk level:
</p>
<ul class="mb-3">
<li class="mb-1"><strong>High</strong> — customer has a history of late payment and is already overdue; prioritize a phone call today.</li>
<li class="mb-1"><strong>Medium</strong> — overdue but reasonable history, or current but spotty past performance.</li>
<li class="mb-1"><strong>Low</strong> — typically pays on time; no immediate follow-up needed.</li>
</ul>
<p>
Each prediction includes an estimated number of additional days to collection and a one-sentence
explanation of the scoring. Use this to triage your collection calls — start with High-risk
customers first.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">Ask Your Financials (Natural Language Queries)</h3>
<p>
A conversational AI tool at <a asp-controller="Reports" asp-action="FinancialQuery">/Reports/FinancialQuery</a>
that lets you ask plain-English questions about your business finances and get direct answers
grounded in your actual data. Example questions:
</p>
<ul class="mb-3">
<li class="mb-1">"What was our revenue this year?"</li>
<li class="mb-1">"What are our biggest expenses?"</li>
<li class="mb-1">"Which month had the highest revenue?"</li>
<li class="mb-1">"How much do customers owe us?"</li>
</ul>
<p>
Each answer includes <strong>supporting facts</strong> pulled directly from your data so you can
verify the figures, and a <strong>follow-up suggestion</strong> for what to ask next. Claude
will not invent numbers — if the data is not available in the snapshot, it says so. The page
also shows clickable example chips and remembers your last 5 questions during the session.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">Bank Rec AI Auto-Match</h3>
<p>
Inside <strong>Bank Reconciliation</strong> (<a asp-controller="BankReconciliations" asp-action="Index">/BankReconciliations</a>),
the <strong>Reconcile</strong> working view includes an <strong>AI Auto-Match</strong> panel.
Click <strong>"Suggest Matches"</strong> and Claude analyzes all uncleared transactions against
your statement ending balance, then suggests which items to mark as cleared — sorted by
confidence score with a one-sentence reason for each.
</p>
<p>
Click <strong>"Apply All Suggestions"</strong> to accept them in bulk; the checkboxes are marked
and persisted automatically. Review the highlighted rows (shown in blue) before applying if you
want to verify each one individually. Auto-match does not complete the reconciliation — you
still click "Complete Reconciliation" yourself once the difference reaches $0.00.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">Powder Insights</h3>
<p>
An AI-powered analysis of your powder usage patterns, efficiency, and cost optimization,
@@ -304,6 +355,9 @@
<a class="nav-link py-1 px-3 small text-body ps-4" href="#financial-reports" style="font-size:.75rem">Sales Tax Report</a>
<a class="nav-link py-1 px-3 small text-body" href="#operations-reports">Operations Reports</a>
<a class="nav-link py-1 px-3 small text-body" href="#ai-reports">AI-Powered Reports</a>
<a class="nav-link py-1 px-3 small text-body ps-4" href="#ai-reports" style="font-size:.75rem">Payment Risk Prediction</a>
<a class="nav-link py-1 px-3 small text-body ps-4" href="#ai-reports" style="font-size:.75rem">Ask Your Financials</a>
<a class="nav-link py-1 px-3 small text-body ps-4" href="#ai-reports" style="font-size:.75rem">Bank Rec Auto-Match</a>
<a class="nav-link py-1 px-3 small text-body" href="#pdf-export">PDF &amp; CSV Export</a>
</nav>
</div>
@@ -548,13 +548,52 @@
<tr><th style="width:25%">Role</th><th>Access level</th></tr>
</thead>
<tbody>
<tr><td><span class="badge bg-danger">CompanyAdmin</span></td><td>Full company access including settings, users, and billing.</td></tr>
<tr><td><span class="badge bg-warning text-dark">Manager</span></td><td>Jobs, quotes, invoices, customers, inventory, reports — no settings or user management.</td></tr>
<tr><td><span class="badge bg-danger">Company Admin</span></td><td>Full company access including settings, users, and billing. All permissions granted automatically.</td></tr>
<tr><td><span class="badge bg-warning text-dark">Manager</span></td><td>Jobs, quotes, invoices, customers, inventory, vendors, reports — no settings or user management.</td></tr>
<tr><td><span class="badge bg-success">Accountant</span></td><td>Financial focus: bills &amp; AP, invoices, bank reconciliations, chart of accounts, vendors, purchase orders, and reports. No job management or settings access.</td></tr>
<tr><td><span class="badge bg-primary">Worker</span></td><td>Create and edit jobs and quotes; no settings, billing, or user management.</td></tr>
<tr><td><span class="badge bg-secondary">Viewer</span></td><td>Read-only access to most data.</td></tr>
</tbody>
</table>
</div>
<p class="small text-muted mb-3">
When you select <strong>Accountant</strong> in the role dropdown, the permissions form automatically
pre-checks the five relevant permissions (Invoices, Reports, Vendors, Bills &amp; AP, Accounting).
You can adjust the individual checkboxes for users whose needs differ from the default.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2">Fine-Grained Permissions</h3>
<p>
Below the role dropdown, each user has individual permission checkboxes. These let you grant
specific capabilities independently of the role — for example, giving a Worker access to view
reports without making them a Manager. Company Admins always have all permissions and the
checkboxes are locked.
</p>
<div class="table-responsive mb-3">
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr><th style="width:35%">Permission</th><th>What it unlocks</th></tr>
</thead>
<tbody>
<tr><td>Can Manage Jobs</td><td>Create, edit, and update job status.</td></tr>
<tr><td>Can Manage Inventory</td><td>Add, edit, and adjust inventory items and stock levels.</td></tr>
<tr><td>Can Manage Customers</td><td>Create and edit customer records.</td></tr>
<tr><td>Can Create Quotes</td><td>Build and send quotes to customers.</td></tr>
<tr><td>Can Approve Quotes</td><td>Internally approve quotes on behalf of the customer.</td></tr>
<tr><td>Can Manage Calendar</td><td>Create and edit appointments.</td></tr>
<tr><td>Can View Calendar</td><td>View the appointments calendar (read-only).</td></tr>
<tr><td>Can Manage Products</td><td>Create and edit catalog items.</td></tr>
<tr><td>Can View Products</td><td>Browse the catalog item list (read-only).</td></tr>
<tr><td>Can Manage Equipment</td><td>Add equipment records and log maintenance.</td></tr>
<tr><td>Can Manage Vendors</td><td>Create and edit vendor records.</td></tr>
<tr><td>Can Manage Maintenance</td><td>Schedule and complete maintenance tasks.</td></tr>
<tr><td>Can Manage Invoices</td><td>Create invoices and record payments.</td></tr>
<tr><td>Can View Reports</td><td>Access all reports and AI analytics features.</td></tr>
<tr><td><strong>Can Manage Bills &amp; AP</strong></td><td>Create and pay vendor bills, record expenses, and use recurring bill detection. Grants access to the full Accounts Payable section.</td></tr>
<tr><td><strong>Can Manage Accounting</strong></td><td>Access the chart of accounts, bank reconciliations, and manual journal entries.</td></tr>
</tbody>
</table>
</div>
<h3 class="h6 fw-semibold mt-3 mb-2">Resetting a Password</h3>
<p>
@@ -40,7 +40,7 @@
<a asp-controller="Dashboard" asp-action="Index" class="btn btn-primary">
<i class="bi bi-house-door"></i> Go to Dashboard
</a>
<button onclick="history.back()" class="btn btn-secondary">
<button onclick="history.back()" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Go Back
</button>
</div>
@@ -606,7 +606,7 @@
</div>
</div>
<div class="modal-footer border-0">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
@@ -633,7 +633,7 @@
</div>
</div>
<div class="modal-footer border-0">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
@@ -37,7 +37,7 @@
</div>
}
</div>
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to Inventory
</a>
</div>
@@ -80,7 +80,7 @@
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm"><i class="bi bi-search"></i></button>
<a asp-action="Ledger" class="btn btn-outline-secondary btn-sm">Clear</a>
<a asp-action="Ledger" class="btn btn-outline-secondary">Clear</a>
</div>
</div>
</form>
@@ -439,7 +439,7 @@
<div id="gcModalError" class="alert alert-danger d-none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-warning" onclick="addGiftCertLineItem(this)">
<i class="bi bi-plus-circle me-1"></i>Add to Invoice
</button>
@@ -1,4 +1,4 @@
@using PowderCoating.Application.DTOs.Invoice
@using PowderCoating.Application.DTOs.Invoice
@using PowderCoating.Core.Enums
@using PowderCoating.Web.Controllers
@model InvoiceDto
@@ -69,7 +69,7 @@
<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>
@@ -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>
@@ -761,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"
@@ -899,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>
@@ -1054,7 +1054,7 @@
</div>
</div>
<div class="modal-footer d-none" id="resendInvoiceFooter">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
@@ -1095,7 +1095,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
@@ -1118,7 +1118,7 @@
</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>
@@ -1240,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>
@@ -1254,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">
@@ -1336,7 +1336,7 @@
<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>
<option value=""> Use default bad debt account </option>
@if (ViewBag.ExpenseAccounts != null)
{
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.ExpenseAccounts)
@@ -1397,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';
@@ -1511,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('');
+10 -10
View File
@@ -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">
@@ -355,7 +355,7 @@
<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>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="useSqFtResult()">
<i class="bi bi-check-circle me-1"></i>Use This Value
</button>
+89 -89
View File
@@ -1,4 +1,4 @@
@model PowderCoating.Application.DTOs.Job.JobDto
@model PowderCoating.Application.DTOs.Job.JobDto
@{
ViewData["Title"] = $"Job {Model.JobNumber}";
@@ -57,7 +57,7 @@
}
else
{
<span>Shop work has started review the quote and apply any changes manually.</span>
<span>Shop work has started review the quote and apply any changes manually.</span>
}
</div>
<div class="d-flex gap-2 flex-wrap">
@@ -217,7 +217,7 @@
</button>
</div>
<div id="scheduledDate-saving" class="d-none mt-1 small text-muted">
<span class="spinner-border spinner-border-sm me-1"></span>Saving
<span class="spinner-border spinner-border-sm me-1"></span>Saving
</div>
</div>
</div>
@@ -263,7 +263,7 @@
<i class="bi bi-x-circle me-1"></i><small>Clear date</small>
</button>
<div id="dueDate-saving" class="d-none mt-1 small text-muted">
<span class="spinner-border spinner-border-sm me-1"></span>Saving
<span class="spinner-border spinner-border-sm me-1"></span>Saving
</div>
</div>
</div>
@@ -273,7 +273,7 @@
<div class="d-flex align-items-center gap-2">
<select id="workerAssignmentSelect" class="form-select form-select-sm"
onchange="updateWorkerAssignment(this)">
<option value=""> Unassigned </option>
<option value=""> Unassigned </option>
@foreach (var w in (IEnumerable<SelectListItem>)ViewBag.Workers)
{
if (w.Value == Model.AssignedUserId)
@@ -287,7 +287,7 @@
}
</select>
<span id="workerSaveIndicator" class="text-muted small d-none">
<span class="spinner-border spinner-border-sm me-1"></span>Saving
<span class="spinner-border spinner-border-sm me-1"></span>Saving
</span>
<span id="workerSavedTick" class="text-success small d-none">
<i class="bi bi-check-circle-fill"></i>
@@ -321,7 +321,7 @@
<div class="card-body">
@* ── Catalog Products ── *@
@* ── Catalog Products ── *@
@if (catalogItems.Any())
{
<h6 class="text-primary mb-3"><i class="bi bi-bag-check me-2"></i>Catalog Products</h6>
@@ -351,10 +351,10 @@
{
<br />
<small class="ms-3">
<strong>@coat.CoatName</strong>
<strong>@coat.CoatName</strong>
@if (!string.IsNullOrEmpty(coat.ColorName))
{
<text> @coat.ColorName</text>
<text> @coat.ColorName</text>
@if (!string.IsNullOrEmpty(coat.VendorName))
{
<text> (@coat.VendorName)</text>
@@ -373,7 +373,7 @@
<span class="badge bg-info ms-1" style="font-size:.7em;">@coat.PowderToOrder.Value.ToString("0.##") lbs</span>
@if (!coat.InventoryItemId.HasValue)
{
<span class="badge bg-warning text-dark ms-1" style="font-size:.7em;" title="Custom powder must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER POWDER</span>
<span class="badge bg-warning text-dark ms-1" style="font-size:.7em;" title="Custom powder must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER POWDER</span>
}
}
@if (!string.IsNullOrEmpty(coat.Notes))
@@ -390,7 +390,7 @@
@foreach (var ps in item.PrepServices)
{
<br />
<small class="ms-3"> <strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong> <span class="text-muted"> @ps.EstimatedMinutes min</span></small>
<small class="ms-3"> <strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong> <span class="text-muted"> @ps.EstimatedMinutes min</span></small>
}
}
@if (!string.IsNullOrEmpty(item.Notes))
@@ -414,7 +414,7 @@
</div>
}
@* ── Custom Work ── *@
@* ── Custom Work ── *@
@if (customItems.Any())
{
<h6 class="text-success mb-3"><i class="bi bi-calculator me-2"></i>Custom Work</h6>
@@ -478,10 +478,10 @@
{
<br />
<small class="ms-3">
<strong>@coat.CoatName</strong>
<strong>@coat.CoatName</strong>
@if (!string.IsNullOrEmpty(coat.ColorName))
{
<text> @coat.ColorName</text>
<text> @coat.ColorName</text>
@if (!string.IsNullOrEmpty(coat.VendorName))
{
<text> (@coat.VendorName)</text>
@@ -500,7 +500,7 @@
<span class="badge bg-info ms-1" style="font-size:.7em;">@coat.PowderToOrder.Value.ToString("0.##") lbs</span>
@if (!coat.InventoryItemId.HasValue)
{
<span class="badge bg-warning text-dark ms-1" style="font-size:.7em;" title="Custom powder must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER POWDER</span>
<span class="badge bg-warning text-dark ms-1" style="font-size:.7em;" title="Custom powder must be purchased before coating"><i class="bi bi-cart me-1"></i>ORDER POWDER</span>
}
}
@if (!string.IsNullOrEmpty(coat.Notes))
@@ -517,7 +517,7 @@
@foreach (var ps in item.PrepServices)
{
<br />
<small class="ms-3"> <strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong> <span class="text-muted"> @ps.EstimatedMinutes min</span></small>
<small class="ms-3"> <strong>@(ps.PrepServiceName ?? $"Service #{ps.PrepServiceId}")</strong> <span class="text-muted"> @ps.EstimatedMinutes min</span></small>
}
}
@if (!string.IsNullOrEmpty(item.Notes))
@@ -532,7 +532,7 @@
<text>@item.SurfaceAreaSqFt.ToString("F2") @ViewBag.AreaUnit</text>
<br /><small class="text-muted">per item</small>
}
else { <span class="text-muted"></span> }
else { <span class="text-muted"></span> }
</td>
<td class="text-center">
@if (item.EstimatedMinutes > 0)
@@ -540,7 +540,7 @@
<text>@item.EstimatedMinutes min</text>
<br /><small class="text-muted">per item</small>
}
else { <span class="text-muted"></span> }
else { <span class="text-muted"></span> }
</td>
<td class="text-center">
@if (totalPowderNeeded > 0)
@@ -548,7 +548,7 @@
<strong class="text-success">@totalPowderNeeded.ToString("F2") lbs</strong>
<br /><small class="text-muted">total batch</small>
}
else { <span class="text-muted"></span> }
else { <span class="text-muted"></span> }
</td>
<td class="text-end">@item.UnitPrice.ToString("C")</td>
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
@@ -565,7 +565,7 @@
</div>
}
@* ── Labor ── *@
@* ── Labor ── *@
@if (laborItems.Any())
{
<h6 class="text-warning mb-3"><i class="bi bi-person-gear me-2"></i>Labor</h6>
@@ -599,7 +599,7 @@
{
<text>@item.EstimatedMinutes min</text>
}
else { <span class="text-muted"></span> }
else { <span class="text-muted"></span> }
</td>
<td class="text-end">@item.UnitPrice.ToString("C")</td>
<td class="text-end fw-semibold">@item.TotalPrice.ToString("C")</td>
@@ -616,7 +616,7 @@
</div>
}
@* ── Mobile cards ── *@
@* ── Mobile cards ── *@
<div class="d-lg-none mt-2">
@foreach (var item in Model.Items)
{
@@ -653,7 +653,7 @@
<span class="mobile-card-value">
@foreach (var coat in item.Coats.OrderBy(c => c.Sequence))
{
<small class="d-block">@coat.CoatName@(!string.IsNullOrEmpty(coat.ColorName) ? $" {coat.ColorName}" : "")</small>
<small class="d-block">@coat.CoatName@(!string.IsNullOrEmpty(coat.ColorName) ? $" {coat.ColorName}" : "")</small>
}
</span>
</div>
@@ -704,7 +704,7 @@
<i class="bi bi-chevron-down collapse-chevron ms-1" style="transition:transform .2s;"></i>
</div>
<div class="d-flex align-items-center gap-3">
<span class="text-muted small">Total: <strong id="totalHoursDisplay"></strong></span>
<span class="text-muted small">Total: <strong id="totalHoursDisplay"></strong></span>
@{
var estimatedMins = Model.Items?.Sum(i => i.EstimatedMinutes * i.Quantity) ?? 0;
var estimatedHrs = estimatedMins / 60m;
@@ -741,7 +741,7 @@
<tfoot class="table-light fw-semibold">
<tr>
<td colspan="3">Total</td>
<td class="text-end" id="timeEntriesTotalHours"></td>
<td class="text-end" id="timeEntriesTotalHours"></td>
<td colspan="3"></td>
</tr>
</tfoot>
@@ -1099,7 +1099,7 @@
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="intakeModalLabel">
<i class="bi bi-box-seam me-2 text-info"></i>Part Intake Check In
<i class="bi bi-box-seam me-2 text-info"></i>Part Intake Check In
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
@@ -1117,7 +1117,7 @@
value="@(Model.IntakePartCount.HasValue ? Model.IntakePartCount.Value.ToString() : "")"
placeholder="@intakeExpectedCount" />
<div id="intakeMismatchAlert" class="alert alert-warning alert-permanent mt-2 py-2 d-none">
<i class="bi bi-exclamation-triangle me-1"></i>Count doesn't match expected note the discrepancy below.
<i class="bi bi-exclamation-triangle me-1"></i>Count doesn't match expected note the discrepancy below.
</div>
</div>
<div class="mb-3">
@@ -1138,7 +1138,7 @@
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-info text-white" id="intakeSaveBtn">
<i class="bi bi-check-circle me-1"></i>@(Model.IntakeDate.HasValue ? "Update Intake" : "Complete Check-In")
</button>
@@ -1195,7 +1195,7 @@
<div id="depositFormError" class="alert alert-danger d-none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-success" id="saveDepositBtn">
<i class="bi bi-check-circle me-1"></i>Save & Generate Receipt
</button>
@@ -1310,7 +1310,7 @@
<a asp-action="Intake" asp-route-id="@Model.Id"
class="btn @(Model.IntakeDate.HasValue ? "btn-outline-secondary" : "btn-outline-info")"
title="@(Model.IntakeDate.HasValue ? "Update part intake record" : "Check in parts for this job")">
<i class=bi bi-box-seam me-2></i>@(Model.IntakeDate.HasValue ? "Intake " : "Intake")
<i class=bi bi-box-seam me-2></i>@(Model.IntakeDate.HasValue ? "Intake ?" : "Intake")
</a>
}
@{
@@ -1368,7 +1368,7 @@
</div>
</div>
<!-- Pricing Summary (internal d-print-none) -->
<!-- Pricing Summary (internal d-print-none) -->
@{
var jobPb = ViewBag.JobPricingBreakdown as PowderCoating.Application.DTOs.Quote.QuotePricingBreakdownDto;
}
@@ -1400,7 +1400,7 @@
@if (jobPb.OvenBatchCost > 0)
{
<div class="d-flex justify-content-between mb-2">
<span><i class="bi bi-fire me-1"></i>Oven (@jobPb.OvenBatches batch@(jobPb.OvenBatches != 1 ? "es" : "")@(jobPb.OvenCycleMinutes > 0 ? $" × {jobPb.OvenCycleMinutes} min" : "")):</span>
<span><i class="bi bi-fire me-1"></i>Oven (@jobPb.OvenBatches batch@(jobPb.OvenBatches != 1 ? "es" : "")@(jobPb.OvenCycleMinutes > 0 ? $" {jobPb.OvenCycleMinutes} min" : "")):</span>
<strong>@jobPb.OvenBatchCost.ToString("C")</strong>
</div>
}
@@ -1518,7 +1518,7 @@
}
else if (allCatalog)
{
<div class="text-muted small fst-italic">All items use fixed catalog pricing no per-category cost split available.</div>
<div class="text-muted small fst-italic">All items use fixed catalog pricing no per-category cost split available.</div>
}
else
{
@@ -1547,7 +1547,7 @@
@if (jobPb.FacilityOverheadCost > 0)
{
<div class="d-flex justify-content-between small mb-1">
<span class="text-muted">Facility overhead (@jobPb.FacilityOverheadRatePerHour.ToString("C2")/hr × estimated hours)</span>
<span class="text-muted">Facility overhead (@jobPb.FacilityOverheadRatePerHour.ToString("C2")/hr estimated hours)</span>
<span>@jobPb.FacilityOverheadCost.ToString("C")</span>
</div>
}
@@ -1712,11 +1712,11 @@
<div class="px-3 pt-3 pb-2">
<div class="d-flex justify-content-between align-items-center mb-1">
<span class="text-muted small">Revenue <span id="costingRevenueSource" class="badge bg-light text-secondary ms-1"></span></span>
<span class="fw-semibold" id="costingRevenue"></span>
<span class="fw-semibold" id="costingRevenue"></span>
</div>
<div class="d-flex justify-content-between small text-muted mb-1 ps-2">
<span>Powder / Materials <a href="#" class="text-muted ms-1" onclick="costing.toggleDetail('powder');return false;"><i class="bi bi-chevron-down" id="powderChevron"></i></a></span>
<span id="costingPowder"></span>
<span id="costingPowder"></span>
</div>
<div id="powderDetail" style="display:none;" class="ps-3 pb-1">
<table class="table table-sm table-borderless mb-0" style="font-size:0.78rem;">
@@ -1725,7 +1725,7 @@
</div>
<div class="d-flex justify-content-between small text-muted mb-1 ps-2">
<span>Labor (<span id="costingLaborHours">0</span> hrs) <a href="#" class="text-muted ms-1" onclick="costing.toggleDetail('labor');return false;"><i class="bi bi-chevron-down" id="laborChevron"></i></a></span>
<span id="costingLabor"></span>
<span id="costingLabor"></span>
</div>
<div id="laborDetail" style="display:none;" class="ps-3 pb-1">
<table class="table table-sm table-borderless mb-0" style="font-size:0.78rem;">
@@ -1734,12 +1734,12 @@
</div>
<div class="d-flex justify-content-between small text-muted mb-1 ps-2">
<span>Oven / Equipment <span id="costingOvenLabel" class="text-muted"></span></span>
<span id="costingOven"></span>
<span id="costingOven"></span>
</div>
<div id="costingReworkSection" style="display:none;">
<div class="d-flex justify-content-between small text-muted mb-1 ps-2">
<span>Rework Costs <a href="#" class="text-muted ms-1" onclick="costing.toggleDetail('rework');return false;"><i class="bi bi-chevron-down" id="reworkChevron"></i></a></span>
<span id="costingRework"></span>
<span id="costingRework"></span>
</div>
<div id="reworkDetail" style="display:none;" class="ps-3 pb-1">
<table class="table table-sm table-borderless mb-0" style="font-size:0.78rem;">
@@ -1748,25 +1748,25 @@
</div>
<div class="d-flex justify-content-between small text-success mb-1 ps-2">
<span>Billed to Customer</span>
<span id="costingReworkBilled"></span>
<span id="costingReworkBilled"></span>
</div>
</div>
<hr class="my-2" />
<div class="d-flex justify-content-between small mb-1 ps-2">
<span class="text-muted">Total Costs</span>
<span id="costingTotal" class="text-danger"></span>
<span id="costingTotal" class="text-danger"></span>
</div>
<div class="d-flex justify-content-between fw-bold mb-1">
<span>Gross Profit</span>
<span id="costingProfit"></span>
<span id="costingProfit"></span>
</div>
<div class="d-flex justify-content-between small text-muted mb-1">
<span>Gross Margin</span>
<span id="costingMargin"></span>
<span id="costingMargin"></span>
</div>
<div class="d-flex justify-content-between small text-muted">
<span>Margin vs Quote</span>
<span id="costingQuotedMargin"></span>
<span id="costingQuotedMargin"></span>
</div>
</div>
<div id="costingNotes" class="px-3 pb-3" style="font-size:0.75rem;"></div>
@@ -1869,7 +1869,7 @@
</div>
<div class="mb-3">
<label class="form-label">Tags
<small class="text-muted fw-normal ms-1"> colors, finish, or other keywords</small>
<small class="text-muted fw-normal ms-1"> colors, finish, or other keywords</small>
</label>
<input type="hidden" id="photoTagsHidden" name="tags" />
<div id="photoTagsContainer"></div>
@@ -1895,7 +1895,7 @@
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="jobPhotoModule.uploadPhoto()">
<i class="bi bi-upload me-1"></i>Upload
</button>
@@ -1948,7 +1948,7 @@
<textarea class="form-control" id="editPhotoCaption" rows="2" placeholder="Add a description or note..."></textarea>
</div>
<div class="mb-0">
<label class="form-label fw-semibold">Tags <small class="text-muted fw-normal ms-1"> colors, finish, keywords</small></label>
<label class="form-label fw-semibold">Tags <small class="text-muted fw-normal ms-1"> colors, finish, keywords</small></label>
<input type="hidden" id="editPhotoTagsHidden" />
<div id="editPhotoTagsContainer"></div>
</div>
@@ -1962,10 +1962,10 @@
<button type="button" class="btn btn-danger" onclick="jobPhotoModule.deletePhoto()">
<i class="bi bi-trash me-1"></i>Delete
</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
</div>
<div id="editModeButtons" class="d-none d-flex gap-2 w-100 justify-content-end">
<button type="button" class="btn btn-secondary" onclick="jobPhotoModule.cancelPhotoEdit()">Cancel</button>
<button type="button" class="btn btn-outline-secondary" onclick="jobPhotoModule.cancelPhotoEdit()">Cancel</button>
<button type="button" class="btn btn-primary" onclick="jobPhotoModule.savePhotoEdit()">
<i class="bi bi-check-lg me-1"></i>Save Changes
</button>
@@ -2000,7 +2000,7 @@
<div class="mb-2">
<label class="form-label fw-semibold" for="smsMessageText">Message</label>
<textarea class="form-control" id="smsMessageText" rows="5"
placeholder="Type your message" maxlength="160"></textarea>
placeholder="Type your message" maxlength="160"></textarea>
<div class="d-flex justify-content-between mt-1">
<div id="smsStopWarning" class="text-warning small d-none">
<i class="bi bi-exclamation-triangle me-1"></i>"Reply STOP to opt out." will be appended automatically.
@@ -2012,7 +2012,7 @@
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal" id="smsDismissBtn">
Skip don't send
Skip don't send
</button>
<button type="button" class="btn btn-info text-white" id="smsSendBtn">
<i class="bi bi-send me-1"></i>Send SMS
@@ -2059,7 +2059,7 @@
<p class="mb-0 small" id="deleteConfirmItemName"></p>
</div>
<div class="modal-footer gap-2">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger btn-sm" id="deleteConfirmBtn">
<i class="bi bi-trash me-1"></i>Delete
</button>
@@ -2110,7 +2110,7 @@
<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>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="useSqFtResult()">
<i class="bi bi-check-circle me-1"></i>Use This Value
</button>
@@ -2223,7 +2223,7 @@
<div class="col-md-6">
<label class="form-label">Specific Item (optional)</label>
<select class="form-select" id="rwJobItem">
<option value=""> Whole Job </option>
<option value=""> Whole Job </option>
@if (Model.Items != null)
{
@foreach (var item in Model.Items)
@@ -2285,9 +2285,9 @@
<div class="col-md-6">
<label class="form-label">Resolution</label>
<select class="form-select" id="rwResolution">
<option value=""> Pending </option>
<option value="0">Recoated No Charge</option>
<option value="1">Recoated Billed to Customer</option>
<option value=""> Pending </option>
<option value="0">Recoated No Charge</option>
<option value="1">Recoated Billed to Customer</option>
<option value="2">Customer Credited</option>
<option value="3">Written Off</option>
<option value="4">No Action Required</option>
@@ -2324,7 +2324,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-warning" id="reworkSaveBtn" onclick="rework.save()">
<i class="bi bi-floppy me-1"></i>Save
</button>
@@ -2346,7 +2346,7 @@
<div class="mb-3">
<label class="form-label fw-semibold">Worker <span class="text-danger">*</span></label>
<select class="form-select" id="teWorkerId">
<option value=""> Select worker </option>
<option value=""> Select worker </option>
@foreach (var w in (ViewBag.ShopWorkers as IEnumerable<dynamic> ?? []))
{
<option value="@w.Id">@w.Name</option>
@@ -2365,7 +2365,7 @@
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Stage / Task</label>
<input type="text" class="form-control" id="teStage" placeholder="e.g. Sandblasting, Coating, Masking" list="stageOptions" />
<input type="text" class="form-control" id="teStage" placeholder="e.g. Sandblasting, Coating, Masking" list="stageOptions" />
<datalist id="stageOptions">
<option value="Sandblasting"></option>
<option value="Masking & Taping"></option>
@@ -2380,7 +2380,7 @@
</div>
<div class="mb-3">
<label class="form-label">Notes</label>
<textarea class="form-control" id="teNotes" rows="2" placeholder="Optional notes"></textarea>
<textarea class="form-control" id="teNotes" rows="2" placeholder="Optional notes"></textarea>
</div>
<div class="text-danger small d-none" id="teError"></div>
</div>
@@ -2418,7 +2418,7 @@
<script src="~/js/job-photos.js" asp-append-version="true"></script>
<script src="~/js/customer-change.js" asp-append-version="true"></script>
<script>
// ── Inline date editing ──────────────────────────────────────────────
// ── Inline date editing ──────────────────────────────────────────────
const jobId = @Model.Id;
const antiForgeryToken = () => document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
@@ -2544,7 +2544,7 @@
jobPhotoModule.init(@Model.Id, @Html.Raw(ViewBag.PhotoTagSuggestions ?? "[]"));
// ── Auto-submit after wizard saves an item ────────────────────────
// ── Auto-submit after wizard saves an item ────────────────────────
let itemsModified = false;
// Wrap wizardSave to set a flag before the modal hides
@@ -2562,12 +2562,12 @@
}
});
// ── Delete confirmation modal ─────────────────────────────────────
// ── Delete confirmation modal ─────────────────────────────────────
let pendingDeleteItemId = -1;
const deleteModal = new bootstrap.Modal(document.getElementById('deleteConfirmModal'));
const deleteItemToken = document.querySelector('input[name="__RequestVerificationToken"]').value;
// Delegated listener handles all delete buttons via data attributes
// Delegated listener handles all delete buttons via data attributes
document.addEventListener('click', function (e) {
const btn = e.target.closest('[data-delete-id]');
if (!btn) return;
@@ -2600,7 +2600,7 @@
});
</script>
<!-- ── Rework / Warranty ────────────────────────────────────────────── -->
<!-- ── Rework / Warranty ────────────────────────────────────────────── -->
<script>
const rework = (() => {
const jid = @Model.Id;
@@ -2645,12 +2645,12 @@
</div>
<div class="small mt-1 text-muted">${r.defectDescription}</div>
<div class="small text-muted mt-1">
Found: ${r.discoveredByDisplay} ${new Date(r.discoveredDate).toLocaleDateString()}
${r.reportedByName ? ' ' + r.reportedByName : ''}
Found: ${r.discoveredByDisplay} ${new Date(r.discoveredDate).toLocaleDateString()}
${r.reportedByName ? ' ' + r.reportedByName : ''}
${r.jobItemDescription ? ' | Item: ' + r.jobItemDescription : ''}
</div>
${r.reworkJobNumber ? `<div class="small mt-1"><i class="bi bi-briefcase me-1"></i>Rework Job: <a href="/Jobs/Details/${r.reworkJobId}" class="text-decoration-none fw-semibold">${r.reworkJobNumber}</a></div>` : ''}
${r.resolutionDisplay ? `<div class="small text-success mt-1"><i class="bi bi-check-circle me-1"></i>${r.resolutionDisplay}${r.actualReworkCost > 0 ? ' $' + r.actualReworkCost.toFixed(2) : ''}</div>` : ''}
${r.resolutionDisplay ? `<div class="small text-success mt-1"><i class="bi bi-check-circle me-1"></i>${r.resolutionDisplay}${r.actualReworkCost > 0 ? ' $' + r.actualReworkCost.toFixed(2) : ''}</div>` : ''}
</div>`).join('');
}
@@ -2756,7 +2756,7 @@
})();
</script>
<!-- ── Job Costing ──────────────────────────────────────────────────── -->
<!-- ── Job Costing ──────────────────────────────────────────────────── -->
<script>
const costing = (() => {
const jid = @Model.Id;
@@ -2796,7 +2796,7 @@
document.getElementById('costingReworkBilled').textContent = fmt(d.reworkBilledToCustomer);
const rBody = document.getElementById('reworkCostLines');
rBody.innerHTML = d.reworkLines.map(l => `<tr>
<td class="text-muted">${l.jobNumber ? `<a href="/Jobs/Details" class="text-decoration-none">${l.jobNumber}</a>` : 'No job'} ${l.reason}${l.isEstimate ? ' <span class="badge bg-secondary" style="font-size:0.65rem;">est.</span>' : ''}</td>
<td class="text-muted">${l.jobNumber ? `<a href="/Jobs/Details" class="text-decoration-none">${l.jobNumber}</a>` : 'No job'} ${l.reason}${l.isEstimate ? ' <span class="badge bg-secondary" style="font-size:0.65rem;">est.</span>' : ''}</td>
<td class="text-end text-nowrap">${l.billedToCustomer > 0 ? `<span class="text-success">${fmt(l.billedToCustomer)} billed</span>` : 'absorbed'}</td>
<td class="text-end text-nowrap fw-semibold">${fmt(l.cost)}</td></tr>`).join('');
} else {
@@ -2812,14 +2812,14 @@
document.getElementById('costingMargin').textContent = `${d.grossMargin}%`;
document.getElementById('costingQuotedMargin').textContent =
d.quotedPrice > 0 ? `${d.quotedMargin}% (quoted ${fmt(d.quotedPrice)})` : '';
d.quotedPrice > 0 ? `${d.quotedMargin}% (quoted ${fmt(d.quotedPrice)})` : '';
// Powder detail lines
const pBody = document.getElementById('powderLines');
pBody.innerHTML = d.hasPowderData
? d.powderLines.map(l => `<tr>
<td class="text-muted" style="max-width:160px;white-space:normal;">${l.description}${l.isActual ? ' <span class="badge bg-success" style="font-size:0.65rem;">actual</span>' : ''}</td>
<td class="text-end text-nowrap">${l.lbs} lbs × ${fmt(l.costPerLb)}/lb</td>
<td class="text-end text-nowrap">${l.lbs} lbs ${fmt(l.costPerLb)}/lb</td>
<td class="text-end text-nowrap fw-semibold">${fmt(l.total)}</td></tr>`).join('')
: '<tr><td colspan="3" class="text-muted">No powder cost data on coats.</td></tr>';
@@ -2827,16 +2827,16 @@
const lBody = document.getElementById('laborLines');
lBody.innerHTML = d.hasLaborData
? d.laborLines.map(l => `<tr>
<td class="text-muted">${l.worker}${l.stage ? ' ' + l.stage : ''}<br/><small>${l.workDate}</small></td>
<td class="text-end text-nowrap">${l.hours}h × ${fmt(l.rate)}/hr${l.usingFallback ? ' <span title="Using standard labor rate" class="text-muted">*</span>' : ''}</td>
<td class="text-muted">${l.worker}${l.stage ? ' ' + l.stage : ''}<br/><small>${l.workDate}</small></td>
<td class="text-end text-nowrap">${l.hours}h ${fmt(l.rate)}/hr${l.usingFallback ? ' <span title="Using standard labor rate" class="text-muted">*</span>' : ''}</td>
<td class="text-end text-nowrap fw-semibold">${fmt(l.total)}</td></tr>`).join('')
: '<tr><td colspan="3" class="text-muted">No time entries logged yet.</td></tr>';
// Notes
const notes = [];
if (!d.hasPowderData && d.hasPowderRateButNoQty) notes.push(' Surface area not set on one or more items edit the item and enter a surface area to calculate powder cost.');
else if (!d.hasPowderData) notes.push(' Add powder cost per lb on coat records to include material cost.');
if (!d.hasLaborData) notes.push(' Log time entries to include labor cost.');
if (!d.hasPowderData && d.hasPowderRateButNoQty) notes.push('? Surface area not set on one or more items edit the item and enter a surface area to calculate powder cost.');
else if (!d.hasPowderData) notes.push('? Add powder cost per lb on coat records to include material cost.');
if (!d.hasLaborData) notes.push('? Log time entries to include labor cost.');
if (d.laborLines?.some(l => l.usingFallback)) notes.push('* One or more workers using standard labor rate fallback.');
document.getElementById('costingNotes').innerHTML = notes.map(n => `<div class="text-muted">${n}</div>`).join('');
@@ -2865,7 +2865,7 @@
})();
</script>
<!-- ── Time Tracking ─────────────────────────────────────────────────── -->
<!-- ── Time Tracking ─────────────────────────────────────────────────── -->
<script>
const timeTracking = (() => {
const jid = @Model.Id;
@@ -2873,7 +2873,7 @@
const modal = new bootstrap.Modal(document.getElementById('timeEntryModal'));
let entries = [];
// ── Load ──────────────────────────────────────────────────────────
// ── Load ──────────────────────────────────────────────────────────
async function load() {
const r = await fetch(`/Jobs/GetTimeEntries?jobId=${jid}`);
entries = await r.json();
@@ -2904,7 +2904,7 @@
<td class="fw-semibold">${esc(e.workerName)}</td>
<td class="small">${d}</td>
<td class="text-end fw-semibold">${e.hoursWorked.toFixed(2)}</td>
<td class="small">${e.stage ? `<span class="badge bg-secondary-subtle text-secondary">${esc(e.stage)}</span>` : '<span class="text-muted"></span>'}</td>
<td class="small">${e.stage ? `<span class="badge bg-secondary-subtle text-secondary">${esc(e.stage)}</span>` : '<span class="text-muted"></span>'}</td>
<td class="small text-muted">${esc(e.notes ?? '')}</td>
<td class="text-end">
<button class="btn btn-xs btn-outline-secondary me-1 py-0 px-1" title="Edit" onclick="timeTracking.openEdit(${e.id})"><i class="bi bi-pencil"></i></button>
@@ -2916,12 +2916,12 @@
}
function updateTotals(total) {
const fmt = total > 0 ? total.toFixed(2) + ' hrs' : '';
const fmt = total > 0 ? total.toFixed(2) + ' hrs' : '';
document.getElementById('totalHoursDisplay').textContent = fmt;
document.getElementById('timeEntriesTotalHours').textContent = total > 0 ? total.toFixed(2) : '';
document.getElementById('timeEntriesTotalHours').textContent = total > 0 ? total.toFixed(2) : '';
}
// ── Modal helpers ─────────────────────────────────────────────────
// ── Modal helpers ─────────────────────────────────────────────────
function openAdd() {
document.getElementById('timeEntryModalTitle').textContent = 'Log Time';
document.getElementById('teEntryId').value = '0';
@@ -3027,7 +3027,7 @@
}
});
// ── Deposits ─────────────────────────────────────────────────────────────
// ── Deposits ─────────────────────────────────────────────────────────────
// Note: antiForgeryToken() is already defined above in this script block
document.getElementById('addDepositForm')?.addEventListener('submit', async function(e) {
e.preventDefault();
@@ -3041,7 +3041,7 @@
}
if (errEl) errEl.classList.add('d-none');
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving'; }
if (btn) { btn.disabled = true; btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving'; }
const params = new URLSearchParams(new FormData(form));
@@ -3083,7 +3083,7 @@
}
}
// ── Collapsible sections ──────────────────────────────────────────────────
// ── Collapsible sections ──────────────────────────────────────────────────
(function () {
const storageKey = 'jobDetailCollapse_@Model.Id';
const sections = ['collapseTimeTracking', 'collapsePartIntake', 'collapsePhotos', 'collapseDeposits', 'collapseMaterials'];
@@ -3122,7 +3122,7 @@
});
})();
// ── Part Intake Modal ─────────────────────────────────────────────────────
// ── Part Intake Modal ─────────────────────────────────────────────────────
(function () {
const expectedCount = @intakeExpectedCount;
const partCountInput = document.getElementById('intakePartCount');
@@ -3215,7 +3215,7 @@
<div class="mb-3">
<label class="form-label fw-semibold">Template Name <span class="text-danger">*</span></label>
<input type="text" name="templateName" class="form-control" required maxlength="100"
placeholder="e.g. Wheel Refinish Standard 4pc">
placeholder="e.g. Wheel Refinish Standard 4pc">
</div>
<div class="mb-3">
+9 -9
View File
@@ -1,4 +1,4 @@
@model PowderCoating.Application.DTOs.Job.UpdateJobDto
@model PowderCoating.Application.DTOs.Job.UpdateJobDto
@using PowderCoating.Core.Entities
@{
@@ -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="Job Details"
data-bs-content="Core job information. Priority and due date are visible on the shop floor board and job list. Customer PO is the customer's own reference number — it appears on invoices. Special Instructions go directly to the shop floor worker on the work order.">
data-bs-content="Core job information. Priority and due date are visible on the shop floor board and job list. Customer PO is the customer's own reference number it appears on invoices. Special Instructions go directly to the shop floor worker on the work order.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -45,7 +45,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 Status"
data-bs-content="Tracks where the job is in the workflow: Pending → Approved → Sandblasting → Cleaning → Coating → Curing → QualityCheck → Completed → ReadyForPickup → Delivered. Status changes trigger customer email notifications (if enabled). Use OnHold to pause work without losing progress.">
data-bs-content="Tracks where the job is in the workflow: Pending Approved Sandblasting Cleaning Coating Curing QualityCheck Completed ReadyForPickup Delivered. Status changes trigger customer email notifications (if enabled). Use OnHold to pause work without losing progress.">
<i class="bi bi-question-circle"></i>
</a>
</label>
@@ -57,7 +57,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>
</label>
@@ -85,7 +85,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>
</label>
@@ -170,7 +170,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>
@@ -245,7 +245,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">
@@ -322,7 +322,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">
@@ -340,7 +340,7 @@
<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>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="useSqFtResult()">
<i class="bi bi-check-circle me-1"></i>Use This Value
</button>
@@ -1,8 +1,8 @@
@model PowderCoating.Application.DTOs.Job.JobEditItemsViewModel
@model PowderCoating.Application.DTOs.Job.JobEditItemsViewModel
@using PowderCoating.Core.Entities
@{
ViewData["Title"] = $"Edit Items — {Model.JobNumber}";
ViewData["Title"] = $"Edit Items {Model.JobNumber}";
ViewData["PageIcon"] = "bi-list-check";
}
@@ -64,7 +64,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">
@@ -118,7 +118,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">
@@ -136,7 +136,7 @@
<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>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="useSqFtResult()">
<i class="bi bi-check-circle me-1"></i>Use This Value
</button>
@@ -455,7 +455,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="saveWorkerAssignment">
<i class="bi bi-save me-2"></i>Save Assignment
</button>
@@ -491,7 +491,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="savePriority">
<i class="bi bi-save me-2"></i>Save Priority
</button>
@@ -540,7 +540,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="saveStatus">
<i class="bi bi-save me-2"></i>Save Status
</button>
@@ -527,7 +527,7 @@
<p class="text-muted small mb-0">This will update the job status immediately.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="btn-confirm-advance">
<i class="bi bi-check-lg me-2"></i>Yes, Advance
</button>
@@ -1,4 +1,4 @@
@model PowderCoating.Application.DTOs.Job.JobDto
@model PowderCoating.Application.DTOs.Job.JobDto
@{
var emailDefault = ViewBag.EmailDefaultOnComplete == true;
var preLoggedPowder = ViewBag.PreLoggedPowder as Dictionary<int, decimal> ?? new Dictionary<int, decimal>();
@@ -97,7 +97,7 @@
@if (preFilledLbs > 0)
{
<small class="text-success d-block mt-1">
<i class="bi bi-check-circle me-1"></i>Already logged — inventory adjusted
<i class="bi bi-check-circle me-1"></i>Already logged inventory adjusted
</small>
}
</td>
@@ -111,7 +111,7 @@
<td colspan="5">
<small class="text-muted fst-italic">
<i class="bi bi-info-circle me-1"></i>
@item.Description — No coat information available (legacy job item)
@item.Description No coat information available (legacy job item)
</small>
</td>
</tr>
@@ -122,7 +122,7 @@
</div>
<div class="alert alert-info alert-permanent mb-0">
<i class="bi bi-info-circle me-2"></i>
<small>Pre-filled values were already logged via scan — inventory is already adjusted for those. You can edit the amount; only the difference will be applied to inventory.</small>
<small>Pre-filled values were already logged via scan inventory is already adjusted for those. You can edit the amount; only the difference will be applied to inventory.</small>
</div>
</div>
}
@@ -152,7 +152,7 @@
}
</div>
<div>
<button type="button" class="btn btn-secondary me-2" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary me-2" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-success">
<i class="bi bi-check-circle me-1"></i>Mark as Completed
</button>
@@ -98,14 +98,14 @@
</div>
</div>
<div class="text-end">
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-secondary">Cancel</a>
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-outline-secondary">Cancel</a>
</div>
}
else
{
<!-- Simple single delete -->
<div class="d-flex gap-2 justify-content-end">
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-secondary">Cancel</a>
<a asp-action="Details" asp-route-id="@Model.Id" class="btn btn-outline-secondary">Cancel</a>
<form asp-action="Delete" method="post">
@Html.AntiForgeryToken()
<input type="hidden" name="id" value="@Model.Id" />
@@ -1,4 +1,4 @@
@using PowderCoating.Core.Entities
@using PowderCoating.Core.Entities
@model ManufacturerLookupPattern
@{
ViewData["Title"] = "Add Manufacturer Pattern";
@@ -6,7 +6,7 @@
<div class="container-fluid py-3">
<div class="d-flex align-items-center mb-3">
<a asp-action="Index" class="btn btn-outline-secondary btn-sm me-3">
<a asp-action="Index" class="btn btn-outline-secondary me-3">
<i class="bi bi-arrow-left"></i>
</a>
<h4 class="mb-0"><i class="bi bi-link-45deg me-2 text-primary"></i>Add Manufacturer Pattern</h4>
@@ -39,9 +39,9 @@
<input asp-for="ProductUrlTemplate" class="form-control" placeholder="e.g. https://www.prismaticpowders.com/shop/powder-coating-colors/{partNumber}/{slug}" />
<div class="form-text">
Supported placeholders:
<code>{partNumber}</code> — manufacturer part number (slashes normalized to hyphens),
<code>{slug}</code> — color name transformed by Slug Transform below,
<code>{colorCode}</code> — color code as-is.
<code>{partNumber}</code> manufacturer part number (slashes normalized to hyphens),
<code>{slug}</code> color name transformed by Slug Transform below,
<code>{colorCode}</code> color code as-is.
If a required placeholder is missing at runtime the template is skipped and the system falls back to a search URL.
</div>
<span asp-validation-for="ProductUrlTemplate" class="text-danger small"></span>
@@ -50,10 +50,10 @@
<div class="mb-3">
<label asp-for="SlugTransform" class="form-label fw-medium">Slug Transform</label>
<select asp-for="SlugTransform" class="form-select">
<option value="LowerHyphen">LowerHyphen — e.g. "jet-black"</option>
<option value="LowerUnderscore">LowerUnderscore — e.g. "jet_black"</option>
<option value="TitleHyphen">TitleHyphen — e.g. "Jet-Black"</option>
<option value="AsIs">AsIs — color name unchanged</option>
<option value="LowerHyphen">LowerHyphen e.g. "jet-black"</option>
<option value="LowerUnderscore">LowerUnderscore e.g. "jet_black"</option>
<option value="TitleHyphen">TitleHyphen e.g. "Jet-Black"</option>
<option value="AsIs">AsIs color name unchanged</option>
</select>
<div class="form-text">How the color name is converted to a URL slug when building the product URL.</div>
<span asp-validation-for="SlugTransform" class="text-danger small"></span>
@@ -6,7 +6,7 @@
<div class="container-fluid py-3">
<div class="d-flex align-items-center mb-3">
<a asp-action="Index" class="btn btn-outline-secondary btn-sm me-3">
<a asp-action="Index" class="btn btn-outline-secondary me-3">
<i class="bi bi-arrow-left"></i>
</a>
<h4 class="mb-0"><i class="bi bi-trash3 me-2 text-danger"></i>Delete Pattern</h4>
@@ -1,4 +1,4 @@
@using PowderCoating.Core.Entities
@using PowderCoating.Core.Entities
@model ManufacturerLookupPattern
@{
ViewData["Title"] = "Edit Pattern";
@@ -6,7 +6,7 @@
<div class="container-fluid py-3">
<div class="d-flex align-items-center mb-3">
<a asp-action="Index" class="btn btn-outline-secondary btn-sm me-3">
<a asp-action="Index" class="btn btn-outline-secondary me-3">
<i class="bi bi-arrow-left"></i>
</a>
<h4 class="mb-0"><i class="bi bi-pencil me-2 text-primary"></i>Edit Pattern</h4>
@@ -40,9 +40,9 @@
<input asp-for="ProductUrlTemplate" class="form-control" placeholder="e.g. https://www.prismaticpowders.com/shop/powder-coating-colors/{partNumber}/{slug}" />
<div class="form-text">
Supported placeholders:
<code>{partNumber}</code> — manufacturer part number (slashes normalized to hyphens),
<code>{slug}</code> — color name transformed by Slug Transform below,
<code>{colorCode}</code> — color code as-is.
<code>{partNumber}</code> manufacturer part number (slashes normalized to hyphens),
<code>{slug}</code> color name transformed by Slug Transform below,
<code>{colorCode}</code> color code as-is.
If a required placeholder is missing at runtime the template is skipped and the system falls back to a search URL.
</div>
<span asp-validation-for="ProductUrlTemplate" class="text-danger small"></span>
@@ -51,10 +51,10 @@
<div class="mb-3">
<label asp-for="SlugTransform" class="form-label fw-medium">Slug Transform</label>
<select asp-for="SlugTransform" class="form-select">
<option value="LowerHyphen">LowerHyphen — e.g. "jet-black"</option>
<option value="LowerUnderscore">LowerUnderscore — e.g. "jet_black"</option>
<option value="TitleHyphen">TitleHyphen — e.g. "Jet-Black"</option>
<option value="AsIs">AsIs — color name unchanged</option>
<option value="LowerHyphen">LowerHyphen e.g. "jet-black"</option>
<option value="LowerUnderscore">LowerUnderscore e.g. "jet_black"</option>
<option value="TitleHyphen">TitleHyphen e.g. "Jet-Black"</option>
<option value="AsIs">AsIs color name unchanged</option>
</select>
<div class="form-text">How the color name is converted to a URL slug when building the product URL.</div>
<span asp-validation-for="SlugTransform" class="text-danger small"></span>
@@ -9,7 +9,7 @@
<div class="container-fluid py-3" style="max-width:800px">
<div class="d-flex align-items-center gap-3 mb-3">
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
<h4 class="mb-0"><i class="bi bi-bell me-2 text-primary"></i>Notification Details</h4>
@@ -96,7 +96,7 @@
</div>
<div class="d-flex justify-content-between mt-4">
<a asp-action="Index" class="btn btn-secondary">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Cancel
</a>
<button type="submit" class="btn btn-primary">
@@ -1,4 +1,4 @@
@model PowderCoating.Application.DTOs.User.SuperAdminDetailsDto
@model PowderCoating.Application.DTOs.User.SuperAdminDetailsDto
@{
ViewData["Title"] = "User Details";
var isSuperAdmin = ViewBag.IsSuperAdmin as bool? ?? false;
@@ -198,7 +198,7 @@
<hr />
<div class="d-flex justify-content-between mt-4">
<a asp-action="Index" class="btn btn-secondary">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to List
</a>
<div class="d-flex gap-2">
@@ -289,7 +289,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-warning">
<i class="bi bi-key"></i> Reset Password
</button>
@@ -323,7 +323,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<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-slash-circle"></i> Ban User</button>
</div>
</form>
@@ -87,7 +87,7 @@
</div>
<div class="d-flex justify-content-between mt-4">
<a asp-action="Index" asp-route-filter="superadmins" class="btn btn-secondary">
<a asp-action="Index" asp-route-filter="superadmins" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Cancel
</a>
<button type="submit" class="btn btn-primary">
@@ -1,4 +1,4 @@
@{
@{
ViewData["Title"] = "Grant SuperAdmin Access";
ViewData["PageIcon"] = "bi-shield-plus";
}
@@ -100,7 +100,7 @@
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<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-shield-plus"></i> Grant SuperAdmin
</button>
@@ -1,4 +1,4 @@
@model PagedResult<PowderCoating.Application.DTOs.User.PlatformUserListDto>
@model PagedResult<PowderCoating.Application.DTOs.User.PlatformUserListDto>
@section Styles {
<style>
@@ -188,7 +188,7 @@
}
else
{
<span class="btn btn-outline-secondary disabled" title="Root account — protected">
<span class="btn btn-outline-secondary disabled" title="Root account protected">
<i class="bi bi-shield-lock"></i>
</span>
}
@@ -234,7 +234,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 user in Model.Items)
@@ -295,7 +295,7 @@
</div>
</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>
}
@@ -349,7 +349,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-warning">
<i class="bi bi-key"></i> Reset Password
</button>
@@ -382,7 +382,7 @@
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<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-shield-x"></i> Revoke Access
</button>
@@ -7,7 +7,7 @@
<div class="container-fluid py-3">
<div class="d-flex align-items-center mb-3">
<a asp-action="Index" class="btn btn-outline-secondary btn-sm me-3">
<a asp-action="Index" class="btn btn-outline-secondary me-3">
<i class="bi bi-arrow-left"></i>
</a>
<div>
@@ -7,7 +7,7 @@
<div class="container-fluid py-3">
<div class="d-flex align-items-center mb-3">
<a asp-action="Index" class="btn btn-outline-secondary btn-sm me-3">
<a asp-action="Index" class="btn btn-outline-secondary me-3">
<i class="bi bi-arrow-left"></i>
</a>
<div>
@@ -1,4 +1,4 @@
@model PowderCoating.Application.DTOs.Powder.PowderInsightsDashboardDto
@model PowderCoating.Application.DTOs.Powder.PowderInsightsDashboardDto
@{
ViewData["Title"] = "Powder Insights";
ViewData["PageIcon"] = "bi-graph-up";
@@ -6,12 +6,12 @@
}
<div class="d-flex justify-content-end mb-4">
<a asp-controller="Inventory" asp-action="Index" class="btn btn-outline-secondary btn-sm">
<a asp-controller="Inventory" asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-box-seam me-1"></i>Inventory
</a>
</div>
@* ── KPI cards ── *@
@* ── KPI cards ── *@
<div class="row g-3 mb-4">
<div class="col-6 col-md-3">
<div class="card border-0 shadow-sm h-100">
@@ -49,7 +49,7 @@
</div>
</div>
@* ── Tabs ── *@
@* ── Tabs ── *@
<ul class="nav nav-tabs mb-3" id="insightsTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="forecast-tab" data-bs-toggle="tab" data-bs-target="#forecast" type="button">
@@ -74,7 +74,7 @@
<div class="tab-content">
@* ── Tab 1: Stock Forecast (Layer 2, immediate value) ── *@
@* ── Tab 1: Stock Forecast (Layer 2, immediate value) ── *@
<div class="tab-pane fade show active" id="forecast" role="tabpanel">
@if (!Model.LowStockAlerts.Any())
{
@@ -87,7 +87,7 @@
{
<div class="card border-0 shadow-sm">
<div class="card-header bg-transparent">
<h6 class="mb-0"><i class="bi bi-box-seam me-2"></i>Powder Demand vs. Stock — Active Jobs</h6>
<h6 class="mb-0"><i class="bi bi-box-seam me-2"></i>Powder Demand vs. Stock Active Jobs</h6>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
@@ -115,7 +115,7 @@
<td class="text-end fw-semibold">@item.CurrentStockLbs.ToString("0.##") lbs</td>
<td class="text-end">@item.ScheduledDemandLbs.ToString("0.##") lbs</td>
<td class="text-end @(item.ShortfallLbs > 0 ? "text-danger fw-bold" : "text-muted")">
@(item.ShortfallLbs > 0 ? $"{item.ShortfallLbs:0.##} lbs" : "—")
@(item.ShortfallLbs > 0 ? $"{item.ShortfallLbs:0.##} lbs" : "")
</td>
<td class="text-center">@item.ActiveJobCount</td>
<td class="text-center">
@@ -141,7 +141,7 @@
}
</div>
@* ── Tab 2: Coverage Efficiency (Layer 2) ── *@
@* ── Tab 2: Coverage Efficiency (Layer 2) ── *@
<div class="tab-pane fade" id="efficiency" role="tabpanel">
@if (!readiness.IsLayer2Ready)
{
@@ -157,7 +157,7 @@
else if (!Model.EfficiencyBySku.Any())
{
<div class="text-center py-5 text-muted">
<p>No efficiency data yet — record actual powder usage on completed jobs to see this chart.</p>
<p>No efficiency data yet record actual powder usage on completed jobs to see this chart.</p>
</div>
}
else
@@ -188,11 +188,11 @@
<strong>@eff.Name</strong>
@if (!string.IsNullOrEmpty(eff.ColorName))
{
<br /><small class="text-muted">@eff.ColorName · @eff.Manufacturer</small>
<br /><small class="text-muted">@eff.ColorName · @eff.Manufacturer</small>
}
@if (!eff.HasEnoughData)
{
<br /><small class="text-muted fst-italic">Low confidence — need 5+ samples</small>
<br /><small class="text-muted fst-italic">Low confidence need 5+ samples</small>
}
</td>
<td class="text-end">@eff.CatalogCoverageSqFtPerLb.ToString("0.#") sq ft/lb</td>
@@ -214,7 +214,7 @@
}
</div>
@* ── Tab 3: Predictive (Layer 3, gated) ── *@
@* ── Tab 3: Predictive (Layer 3, gated) ── *@
<div class="tab-pane fade" id="predictive" role="tabpanel">
@if (!readiness.IsLayer3Ready)
{
@@ -238,9 +238,9 @@
<div class="alert alert-info alert-permanent text-start">
<h6 class="alert-heading"><i class="bi bi-lightbulb me-2"></i>What unlocks here</h6>
<ul class="mb-0 small">
<li><strong>Smart reorder suggestions</strong> — quantity recommendations based on your actual usage history + scheduled job pipeline</li>
<li><strong>Waste pattern detection</strong> — identifies jobs and powder types that consistently over-consume</li>
<li><strong>Per-powder efficiency corrections</strong> — suggests updating coverage defaults based on real data</li>
<li><strong>Smart reorder suggestions</strong> quantity recommendations based on your actual usage history + scheduled job pipeline</li>
<li><strong>Waste pattern detection</strong> identifies jobs and powder types that consistently over-consume</li>
<li><strong>Per-powder efficiency corrections</strong> suggests updating coverage defaults based on real data</li>
</ul>
</div>
</div>
@@ -258,7 +258,7 @@
</div>
@if (!Model.ReorderSuggestions.Any())
{
<div class="card-body text-muted text-center py-4">No reorder suggestions — stock levels look good for upcoming pipeline.</div>
<div class="card-body text-muted text-center py-4">No reorder suggestions stock levels look good for upcoming pipeline.</div>
}
else
{
@@ -284,7 +284,7 @@
<strong>@s.Name</strong>
@if (!string.IsNullOrEmpty(s.ColorName))
{
<br /><small class="text-muted">@s.ColorName · @s.Manufacturer</small>
<br /><small class="text-muted">@s.ColorName · @s.Manufacturer</small>
}
</td>
<td class="text-end">@s.CurrentStockLbs.ToString("0.#") lbs</td>
@@ -311,7 +311,7 @@
@* Waste Patterns *@
<div class="card border-0 shadow-sm">
<div class="card-header bg-transparent">
<h6 class="mb-0"><i class="bi bi-exclamation-triangle me-2 text-warning"></i>Waste Patterns <small class="text-muted fw-normal">— coats that used &gt;20% more than estimated</small></h6>
<h6 class="mb-0"><i class="bi bi-exclamation-triangle me-2 text-warning"></i>Waste Patterns <small class="text-muted fw-normal"> coats that used &gt;20% more than estimated</small></h6>
</div>
@if (!Model.WastePatterns.Any())
{
@@ -344,7 +344,7 @@
<br /><small class="text-muted">@w.CoatName</small>
</td>
<td class="text-muted small">@(w.InventoryItemName ?? "Custom")</td>
<td>@(w.Complexity ?? "—")</td>
<td>@(w.Complexity ?? "")</td>
<td class="text-end">@w.EstimatedLbs.ToString("0.##") lbs</td>
<td class="text-end">@w.ActualLbs.ToString("0.##") lbs</td>
<td class="text-end text-danger fw-bold">+@w.OveragePct.ToString("0.#")%</td>
@@ -1,4 +1,4 @@
@model PowderCoating.Application.DTOs.Quote.CreateQuoteDto
@model PowderCoating.Application.DTOs.Quote.CreateQuoteDto
@using PowderCoating.Core.Entities
@{
@@ -51,7 +51,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Customer vs Prospect/Walk-In"
data-bs-content="Choose &lt;strong&gt;Existing Customer&lt;/strong&gt; if this person is already in your system. Choose &lt;strong&gt;New Prospect/Walk-In&lt;/strong&gt; if they haven't committed yet — their details stay on the quote. When they approve, you can convert them to a full customer record with one click.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#prospect-conversion' target='_blank'&gt;Learn more →&lt;/a&gt;">
data-bs-content="Choose &lt;strong&gt;Existing Customer&lt;/strong&gt; if this person is already in your system. Choose &lt;strong&gt;New Prospect/Walk-In&lt;/strong&gt; if they haven't committed yet their details stay on the quote. When they approve, you can convert them to a full customer record with one click.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#prospect-conversion' target='_blank'&gt;Learn more &lt;/a&gt;">
<i class="bi bi-question-circle"></i>
</a>
</h5>
@@ -146,7 +146,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Quote Information"
data-bs-content="Set the quote date, expiration, and any internal notes. The &lt;strong&gt;Expiration Date&lt;/strong&gt; is shown to the customer — once it passes the quote is flagged Expired and can no longer be approved without editing. The &lt;strong&gt;Customer PO&lt;/strong&gt; field is optional — use it if the customer provides their own purchase order number.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-statuses' target='_blank'&gt;Learn more →&lt;/a&gt;">
data-bs-content="Set the quote date, expiration, and any internal notes. The &lt;strong&gt;Expiration Date&lt;/strong&gt; is shown to the customer once it passes the quote is flagged Expired and can no longer be approved without editing. The &lt;strong&gt;Customer PO&lt;/strong&gt; field is optional use it if the customer provides their own purchase order number.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-statuses' target='_blank'&gt;Learn more &lt;/a&gt;">
<i class="bi bi-question-circle"></i>
</a>
</h5>
@@ -210,7 +210,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Oven &amp; Batch Pricing"
data-bs-content="The oven cost is charged once per batch at the quote level, not per item. Estimate how many oven loads the full job will fill — for example, if you have 20 small parts and your oven fits 10, that's 2 batches. Cycle time is how long each batch runs. The cost is calculated from your oven's hourly rate in Settings.">
data-bs-content="The oven cost is charged once per batch at the quote level, not per item. Estimate how many oven loads the full job will fill for example, if you have 20 small parts and your oven fits 10, that's 2 batches. Cycle time is how long each batch runs. The cost is calculated from your oven's hourly rate in Settings.">
<i class="bi bi-question-circle"></i>
</a>
</h5>
@@ -253,7 +253,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Quote Item Types"
data-bs-content="&lt;strong&gt;Calculated&lt;/strong&gt; — you enter surface area (sq ft) and the system prices it using your rates for materials, labour, and overhead.&lt;br&gt;&lt;strong&gt;Custom Work&lt;/strong&gt; — you enter a description and a manual price. Use this for flat-rate jobs or work that doesn't fit the formula.&lt;br&gt;&lt;strong&gt;AI Photo&lt;/strong&gt; — upload photos and let the AI estimate surface area and complexity for you.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-items' target='_blank'&gt;Learn more →&lt;/a&gt;">
data-bs-content="&lt;strong&gt;Calculated&lt;/strong&gt; you enter surface area (sq ft) and the system prices it using your rates for materials, labour, and overhead.&lt;br&gt;&lt;strong&gt;Custom Work&lt;/strong&gt; you enter a description and a manual price. Use this for flat-rate jobs or work that doesn't fit the formula.&lt;br&gt;&lt;strong&gt;AI Photo&lt;/strong&gt; upload photos and let the AI estimate surface area and complexity for you.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-items' target='_blank'&gt;Learn more &lt;/a&gt;">
<i class="bi bi-question-circle"></i>
</a>
</h5>
@@ -314,7 +314,7 @@
<div class="form-check">
<input asp-for="HideDiscountFromCustomer" class="form-check-input" type="checkbox" id="hideDiscountFromCustomer" />
<label class="form-check-label small" for="hideDiscountFromCustomer">
Hide discount from customer — PDFs and approval portal show final price only
Hide discount from customer PDFs and approval portal show final price only
</label>
</div>
</div>
@@ -329,7 +329,7 @@
<a tabindex="0" class="help-icon text-white" role="button"
data-bs-toggle="popover" data-bs-placement="left"
data-bs-title="Pricing Summary"
data-bs-content="The total is built up from materials, labour, equipment time, overhead, and profit margin — all based on the rates in Settings. A &lt;strong&gt;Tier Discount&lt;/strong&gt; appears automatically if the customer has a pricing tier assigned. A &lt;strong&gt;Rush Fee&lt;/strong&gt; is added when Rush Job is checked.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#pricing-breakdown' target='_blank'&gt;Learn more →&lt;/a&gt;">
data-bs-content="The total is built up from materials, labour, equipment time, overhead, and profit margin all based on the rates in Settings. A &lt;strong&gt;Tier Discount&lt;/strong&gt; appears automatically if the customer has a pricing tier assigned. A &lt;strong&gt;Rush Fee&lt;/strong&gt; is added when Rush Job is checked.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#pricing-breakdown' target='_blank'&gt;Learn more &lt;/a&gt;">
<i class="bi bi-question-circle"></i>
</a>
</h5>
@@ -340,7 +340,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">
@@ -379,7 +379,7 @@
<div class="row g-3" id="stagedPhotoGrid"></div>
<div id="stagedPhotoUploadProgress" class="d-none mt-2">
<div class="progress"><div class="progress-bar progress-bar-striped progress-bar-animated" style="width:100%"></div></div>
<small class="text-muted">Uploading…</small>
<small class="text-muted">Uploading</small>
</div>
</div>
</div>
@@ -446,7 +446,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">
@@ -464,7 +464,7 @@
<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>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="useSqFtResult()">
<i class="bi bi-check-circle me-1"></i>Use This Value
</button>
@@ -681,7 +681,7 @@
<script src="~/lib/tom-select/js/tom-select.complete.min.js"></script>
<script src="~/js/item-wizard.js?v=@DateTime.Now.Ticks"></script>
<script>
// ── Quick / Full quote mode toggle ──────────────────────────────────
// ── Quick / Full quote mode toggle ──────────────────────────────────
(function () {
const STORAGE_KEY = 'pcl_quote_mode';
const form = document.getElementById('quoteForm');
@@ -690,7 +690,7 @@
function applyMode(mode) {
if (mode === 'simple') {
form.classList.add('quote-simple-mode');
hint.textContent = 'Advanced fields are hidden — switch to Full Quote to see them.';
hint.textContent = 'Advanced fields are hidden switch to Full Quote to see them.';
} else {
form.classList.remove('quote-simple-mode');
hint.textContent = '';
@@ -758,8 +758,8 @@
smsNote.style.display = 'inline';
smsNote.className = hasSms ? 'badge bg-info text-white' : 'badge bg-warning text-dark';
smsNote.innerHTML = hasSms
? '<i class="bi bi-phone me-1"></i>No email — send via SMS from quote details'
: '<i class="bi bi-phone-slash me-1"></i>No email — SMS consent required';
? '<i class="bi bi-phone me-1"></i>No email send via SMS from quote details'
: '<i class="bi bi-phone-slash me-1"></i>No email SMS consent required';
} else {
smsNote.style.display = 'none';
}
@@ -32,7 +32,7 @@
<button onclick="printQuotePdf(@Model.Id)" class="btn btn-info">
<i class="bi bi-printer me-1"></i>Print
</button>
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-secondary">
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-warning">
<i class="bi bi-pencil me-1"></i>Edit
</a>
@if (Model.IsProspect && Model.StatusCode == "APPROVED")
@@ -796,10 +796,10 @@
<button type="button" class="btn btn-danger" id="qpDeleteBtn" onclick="qpGallery.deletePhoto()">
<i class="bi bi-trash me-1"></i>Delete
</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
</div>
<div id="qpEditButtons" class="d-none d-flex gap-2 w-100 justify-content-end">
<button type="button" class="btn btn-secondary" onclick="qpGallery.cancelEdit()">Cancel</button>
<button type="button" class="btn btn-outline-secondary" onclick="qpGallery.cancelEdit()">Cancel</button>
<button type="button" class="btn btn-primary" onclick="qpGallery.saveEdit()">
<i class="bi bi-check-lg me-1"></i>Save Caption
</button>
@@ -1897,7 +1897,7 @@
<div id="depositFormError" class="alert alert-danger d-none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-success" id="saveDepositBtn">
<i class="bi bi-check-circle me-1"></i>Save & Generate Receipt
</button>
@@ -2156,7 +2156,7 @@
<div id="quoteAdHocEmailError" class="text-danger small mt-1 d-none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="sendQuoteToAdHocEmail(@Model.Id)">
<i class="bi bi-send me-1"></i>Send
</button>
@@ -2186,7 +2186,7 @@
</div>
</div>
<div class="modal-footer d-none" id="sendQuoteSmsFooter">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
@@ -2213,7 +2213,7 @@
</div>
</div>
<div class="modal-footer d-none" id="sendQuoteFooter">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
@@ -2254,7 +2254,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
+18 -18
View File
@@ -1,4 +1,4 @@
@model PowderCoating.Application.DTOs.Quote.UpdateQuoteDto
@model PowderCoating.Application.DTOs.Quote.UpdateQuoteDto
@using PowderCoating.Core.Entities
@{
@@ -109,7 +109,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Quote Information"
data-bs-content="Set the quote date, expiration, and any internal notes. The &lt;strong&gt;Expiration Date&lt;/strong&gt; is shown to the customer — once it passes the quote is flagged Expired and can no longer be approved without editing. The &lt;strong&gt;Customer PO&lt;/strong&gt; field is optional — use it if the customer provides their own purchase order number.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-statuses' target='_blank'&gt;Learn more →&lt;/a&gt;">
data-bs-content="Set the quote date, expiration, and any internal notes. The &lt;strong&gt;Expiration Date&lt;/strong&gt; is shown to the customer once it passes the quote is flagged Expired and can no longer be approved without editing. The &lt;strong&gt;Customer PO&lt;/strong&gt; field is optional use it if the customer provides their own purchase order number.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-statuses' target='_blank'&gt;Learn more &lt;/a&gt;">
<i class="bi bi-question-circle"></i>
</a>
</h5>
@@ -173,7 +173,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Oven &amp; Batch Pricing"
data-bs-content="The oven cost is charged once per batch at the quote level, not per item. Estimate how many oven loads the full job will fill — for example, if you have 20 small parts and your oven fits 10, that's 2 batches. Cycle time is how long each batch runs. The cost is calculated from your oven's hourly rate in Settings.">
data-bs-content="The oven cost is charged once per batch at the quote level, not per item. Estimate how many oven loads the full job will fill for example, if you have 20 small parts and your oven fits 10, that's 2 batches. Cycle time is how long each batch runs. The cost is calculated from your oven's hourly rate in Settings.">
<i class="bi bi-question-circle"></i>
</a>
</h5>
@@ -216,7 +216,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Quote Item Types"
data-bs-content="&lt;strong&gt;Calculated&lt;/strong&gt; — you enter surface area (sq ft) and the system prices it using your rates for materials, labour, and overhead.&lt;br&gt;&lt;strong&gt;Custom Work&lt;/strong&gt; — you enter a description and a manual price. Use this for flat-rate jobs or work that doesn't fit the formula.&lt;br&gt;&lt;strong&gt;AI Photo&lt;/strong&gt; — upload photos and let the AI estimate surface area and complexity for you.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-items' target='_blank'&gt;Learn more →&lt;/a&gt;">
data-bs-content="&lt;strong&gt;Calculated&lt;/strong&gt; you enter surface area (sq ft) and the system prices it using your rates for materials, labour, and overhead.&lt;br&gt;&lt;strong&gt;Custom Work&lt;/strong&gt; you enter a description and a manual price. Use this for flat-rate jobs or work that doesn't fit the formula.&lt;br&gt;&lt;strong&gt;AI Photo&lt;/strong&gt; upload photos and let the AI estimate surface area and complexity for you.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#quote-items' target='_blank'&gt;Learn more &lt;/a&gt;">
<i class="bi bi-question-circle"></i>
</a>
</h5>
@@ -277,7 +277,7 @@
<div class="form-check">
<input asp-for="HideDiscountFromCustomer" class="form-check-input" type="checkbox" id="hideDiscountFromCustomer" />
<label class="form-check-label small" for="hideDiscountFromCustomer">
Hide discount from customer — PDFs and approval portal show final price only
Hide discount from customer PDFs and approval portal show final price only
</label>
</div>
</div>
@@ -292,7 +292,7 @@
<a tabindex="0" class="help-icon text-white" role="button"
data-bs-toggle="popover" data-bs-placement="left"
data-bs-title="Pricing Summary"
data-bs-content="The total is built up from materials, labour, equipment time, overhead, and profit margin — all based on the rates in Settings. A &lt;strong&gt;Tier Discount&lt;/strong&gt; appears automatically if the customer has a pricing tier assigned. A &lt;strong&gt;Rush Fee&lt;/strong&gt; is added when Rush Job is checked.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#pricing-breakdown' target='_blank'&gt;Learn more →&lt;/a&gt;">
data-bs-content="The total is built up from materials, labour, equipment time, overhead, and profit margin all based on the rates in Settings. A &lt;strong&gt;Tier Discount&lt;/strong&gt; appears automatically if the customer has a pricing tier assigned. A &lt;strong&gt;Rush Fee&lt;/strong&gt; is added when Rush Job is checked.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Quotes#pricing-breakdown' target='_blank'&gt;Learn more &lt;/a&gt;">
<i class="bi bi-question-circle"></i>
</a>
</h5>
@@ -303,7 +303,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">
@@ -396,7 +396,7 @@
<textarea class="form-control form-control-sm caption-textarea" rows="2" placeholder="Add a caption...">@photo.Caption</textarea>
<div class="d-flex gap-1 mt-1">
<button type="button" class="btn btn-primary btn-sm save-caption-btn flex-grow-1">Save</button>
<button type="button" class="btn btn-secondary btn-sm cancel-caption-btn">Cancel</button>
<button type="button" class="btn btn-outline-secondary btn-sm cancel-caption-btn">Cancel</button>
</div>
</div>
}
@@ -407,7 +407,7 @@
</div>
<div id="editPhotoUploadProgress" class="d-none mt-2">
<div class="progress"><div class="progress-bar progress-bar-striped progress-bar-animated" style="width:100%"></div></div>
<small class="text-muted">Uploading…</small>
<small class="text-muted">Uploading</small>
</div>
</div>
</div>
@@ -435,11 +435,11 @@
<span id="smsNotifyNote" class="badge @(editHasSms ? "bg-info text-white" : "bg-warning text-dark")">
@if (editHasSms)
{
<i class="bi bi-phone me-1"></i><text>No email — send via SMS from quote details</text>
<i class="bi bi-phone me-1"></i><text>No email send via SMS from quote details</text>
}
else
{
<i class="bi bi-phone-slash me-1"></i><text>No email — SMS consent required</text>
<i class="bi bi-phone-slash me-1"></i><text>No email SMS consent required</text>
}
</span>
}
@@ -483,7 +483,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">
@@ -501,7 +501,7 @@
<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>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="useSqFtResult()">
<i class="bi bi-check-circle me-1"></i>Use This Value
</button>
@@ -579,7 +579,7 @@
@Html.Raw(Json.Serialize(ViewBag.BlastSetups ?? new List<object>()))
</script>
<!-- Existing items — always populated on Edit -->
<!-- Existing items always populated on Edit -->
<script id="existingItemsData" type="application/json">
@Html.Raw(System.Text.Json.JsonSerializer.Serialize((Model.QuoteItems ?? new List<PowderCoating.Application.DTOs.Quote.CreateQuoteItemDto>()).Select((item, i) => new {
description = item.Description,
@@ -740,8 +740,8 @@
smsNote.style.display = 'inline';
smsNote.className = hasSms ? 'badge bg-info text-white' : 'badge bg-warning text-dark';
smsNote.innerHTML = hasSms
? '<i class="bi bi-phone me-1"></i>No email — send via SMS from quote details'
: '<i class="bi bi-phone-slash me-1"></i>No email — SMS consent required';
? '<i class="bi bi-phone me-1"></i>No email send via SMS from quote details'
: '<i class="bi bi-phone-slash me-1"></i>No email SMS consent required';
} else {
smsNote.style.display = 'none';
}
@@ -811,7 +811,7 @@
}
});
// Quote photo direct upload (Edit page — quoteId is known)
// Quote photo direct upload (Edit page quoteId is known)
(function () {
const quoteId = @Model.Id;
const uploadUrl = '@Url.Action("UploadQuotePhoto", "Quotes")';
@@ -868,7 +868,7 @@
<textarea class="form-control form-control-sm caption-textarea" rows="2" placeholder="Add a caption..."></textarea>
<div class="d-flex gap-1 mt-1">
<button type="button" class="btn btn-primary btn-sm save-caption-btn flex-grow-1">Save</button>
<button type="button" class="btn btn-secondary btn-sm cancel-caption-btn">Cancel</button>
<button type="button" class="btn btn-outline-secondary btn-sm cancel-caption-btn">Cancel</button>
</div>
</div>
</div>
@@ -343,7 +343,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="saveStatus">
<i class="bi bi-save me-2"></i>Save Status
</button>
@@ -5,7 +5,7 @@
<div class="container py-4" style="max-width:800px">
<div class="d-flex align-items-center gap-3 mb-4">
<a asp-action="Manage" class="btn btn-outline-secondary btn-sm">
<a asp-action="Manage" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
<h4 class="mb-0"><i class="bi bi-plus-circle me-2 text-primary"></i>New Release Note</h4>
@@ -5,7 +5,7 @@
<div class="container py-4" style="max-width:800px">
<div class="d-flex align-items-center gap-3 mb-4">
<a asp-action="Manage" class="btn btn-outline-secondary btn-sm">
<a asp-action="Manage" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
<h4 class="mb-0"><i class="bi bi-pencil me-2 text-primary"></i>Edit v@Model.Version</h4>
@@ -240,3 +240,42 @@ else
<i class="bi bi-info-circle me-1"></i>
Generated @DateTime.Now.ToString("MMM d, yyyy h:mm tt") · Includes all open invoices (excluding Draft and Voided). Age calculated from due date.
</div>
@if (Model.Customers.Any())
{
<!-- AI Late Payment Prediction -->
<div class="card shadow-sm mt-4 border-0 no-print" id="aiRiskCard">
<div class="card-header d-flex align-items-center gap-2">
<i class="bi bi-robot text-primary"></i>
<span class="fw-semibold">AI Payment Risk Prediction</span>
<button id="aiRiskBtn" class="btn btn-sm btn-outline-primary ms-auto">
<i class="bi bi-magic me-1"></i>Predict Payment Risk
</button>
</div>
<div class="card-body d-none" id="aiRiskBody">
<div id="aiRiskSpinner" class="text-center py-3 d-none">
<div class="spinner-border text-primary" role="status"></div>
<p class="text-muted mt-2 small">Claude is analyzing payment behavior…</p>
</div>
<div id="aiRiskError" class="alert alert-danger alert-permanent d-none"></div>
<div id="aiRiskInsights" class="text-muted small mb-3"></div>
<div id="aiRiskTable" class="table-responsive d-none">
<table class="table table-sm align-middle">
<thead class="table-light">
<tr>
<th>Customer</th>
<th>Risk</th>
<th>Est. Days to Payment</th>
<th>Reasoning</th>
</tr>
</thead>
<tbody id="aiRiskRows"></tbody>
</table>
</div>
</div>
</div>
}
@section Scripts {
<script src="/js/ar-aging-ai.js"></script>
}
@@ -0,0 +1,122 @@
@using System.Text.Json
@{
ViewData["Title"] = "Ask Your Financials";
ViewData["PageIcon"] = "bi-chat-dots";
var context = ViewBag.Context as PowderCoating.Application.DTOs.AI.FinancialQueryContext;
var contextJson = JsonSerializer.Serialize(context);
}
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h4 class="fw-semibold mb-1"><i class="bi bi-chat-dots text-primary me-2"></i>Ask Your Financials</h4>
<p class="text-muted small mb-0">Ask Claude a plain-English question about your business finances. Data as of @DateTime.Today.ToString("MMMM d, yyyy").</p>
</div>
<a asp-action="Landing" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Reports
</a>
</div>
<div class="row g-4">
<div class="col-lg-8">
<!-- Query input -->
<div class="card shadow-sm mb-4">
<div class="card-body">
<label class="form-label fw-semibold">Your question</label>
<div class="input-group">
<input type="text" id="queryInput" class="form-control form-control-lg"
placeholder="e.g. What did we spend on powder last quarter?"
autocomplete="off" />
<button id="queryBtn" class="btn btn-primary px-4" type="button">
<i class="bi bi-send me-1"></i>Ask
</button>
</div>
<div class="mt-2 d-flex flex-wrap gap-2" id="suggestionChips">
<span class="text-muted small me-1">Try:</span>
</div>
</div>
</div>
<!-- Answer area -->
<div id="answerArea" class="d-none">
<div class="card shadow-sm border-primary border-opacity-25">
<div class="card-header bg-primary-subtle text-primary-emphasis fw-semibold">
<i class="bi bi-robot me-1"></i>Claude's Answer
</div>
<div class="card-body">
<div id="answerSpinner" class="text-center py-3 d-none">
<div class="spinner-border text-primary" role="status"></div>
<p class="text-muted mt-2 small">Analyzing your financials…</p>
</div>
<div id="answerError" class="alert alert-danger alert-permanent d-none"></div>
<p id="answerText" class="mb-3 fs-6 d-none"></p>
<div id="factsArea" class="d-none">
<p class="small fw-semibold text-muted mb-1">Supporting data:</p>
<ul id="factsList" class="list-unstyled small text-muted mb-0"></ul>
</div>
<div id="followUpArea" class="mt-3 pt-3 border-top d-none">
<span class="text-muted small me-2">Follow-up suggestion:</span>
<button id="followUpBtn" class="btn btn-sm btn-outline-primary"></button>
</div>
</div>
</div>
<!-- History -->
<div id="historyArea" class="mt-3 d-none">
<p class="text-muted small fw-semibold mb-2"><i class="bi bi-clock-history me-1"></i>Earlier questions this session</p>
<div id="historyList" class="d-flex flex-column gap-2"></div>
</div>
</div>
</div>
<div class="col-lg-4">
<!-- Snapshot -->
<div class="card shadow-sm mb-3">
<div class="card-header fw-semibold text-muted small">
<i class="bi bi-graph-up me-1"></i>YTD Snapshot
</div>
<div class="card-body pb-2">
<div class="d-flex justify-content-between small mb-2">
<span class="text-muted">Revenue</span>
<span class="fw-medium">@context?.TotalRevenueYtd.ToString("C0")</span>
</div>
<div class="d-flex justify-content-between small mb-2">
<span class="text-muted">Expenses</span>
<span class="fw-medium">@context?.TotalExpensesYtd.ToString("C0")</span>
</div>
<div class="d-flex justify-content-between small mb-2 border-top pt-2">
<span class="fw-semibold">Net Income</span>
<span class="fw-bold @(context?.NetIncomeYtd >= 0 ? "text-success" : "text-danger")">
@context?.NetIncomeYtd.ToString("C0")
</span>
</div>
<div class="d-flex justify-content-between small mb-2 border-top pt-2">
<span class="text-muted">AR Outstanding</span>
<span class="fw-medium text-warning">@context?.ArOutstanding.ToString("C0")</span>
</div>
<div class="d-flex justify-content-between small mb-2">
<span class="text-muted">AP Outstanding</span>
<span class="fw-medium text-danger">@context?.ApOutstanding.ToString("C0")</span>
</div>
</div>
</div>
<!-- Tips -->
<div class="card shadow-sm border-0 bg-light">
<div class="card-body small">
<p class="fw-semibold text-muted mb-2"><i class="bi bi-lightbulb me-1 text-warning"></i>Tips</p>
<ul class="list-unstyled text-muted mb-0 small">
<li class="mb-1">Ask about specific time periods: "last month", "Q1", "this year"</li>
<li class="mb-1">Compare periods: "compared to last quarter"</li>
<li class="mb-1">Ask about vendors, categories, or customers</li>
<li>Claude only uses data it was given — it won't invent figures</li>
</ul>
</div>
</div>
</div>
</div>
<input type="hidden" id="contextJson" value="@Html.Raw(System.Text.Encodings.Web.HtmlEncoder.Default.Encode(contextJson))" />
@section Scripts {
<script src="/js/financial-query.js"></script>
}
@@ -3403,7 +3403,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
@@ -123,6 +123,22 @@
<p>AI scans recent bills and expense trends for duplicate entries, unusual amounts, and accounts running over their historical average.</p>
<div class="report-arrow">Run analysis <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="FinancialQuery" class="report-card">
<div class="report-card-icon" style="background:#eff6ff;color:#2563eb;">
<i class="bi bi-chat-dots"></i>
</div>
<h5>Ask Your Financials</h5>
<p>Ask Claude plain-English questions about your revenue, expenses, and AR. Answers are grounded in your actual financial data.</p>
<div class="report-arrow">Ask a question <i class="bi bi-arrow-right"></i></div>
</a>
<a asp-controller="Reports" asp-action="ArAging" class="report-card">
<div class="report-card-icon" style="background:#fef3c7;color:#d97706;">
<i class="bi bi-hourglass-split"></i>
</div>
<h5>AR Aging + Risk Prediction</h5>
<p>View outstanding invoices by aging bucket, then run AI payment risk scoring to prioritize your follow-up calls.</p>
<div class="report-arrow">View aging <i class="bi bi-arrow-right"></i></div>
</a>
</div>
</div>
@@ -1,4 +1,4 @@
@model List<PowderCoating.Core.Entities.Company>
@model List<PowderCoating.Core.Entities.Company>
@{
ViewData["Title"] = "Seed Data Management";
ViewData["PageIcon"] = "bi-database-fill-gear";
@@ -196,7 +196,7 @@
</table>
</div>
<!-- Mobile card view — shown on screens < 992px -->
<!-- Mobile card view shown on screens < 992px -->
<div class="mobile-card-view d-lg-none">
<div class="mobile-card-list">
@foreach (var company in Model)
@@ -363,7 +363,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-warning" id="unseedSubmitBtn" disabled>
<i class="bi bi-database-fill-dash me-1"></i>Remove Selected Data
</button>
@@ -228,7 +228,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="applyLaborCalc()">
<i class="bi bi-check2-circle me-1"></i>Apply &amp; Close
</button>
@@ -298,7 +298,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="applyEquipCalc()">
<i class="bi bi-check2-circle me-1"></i>Apply &amp; Close
</button>
@@ -371,7 +371,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="applyPowderCalc()">
<i class="bi bi-check2-circle me-1"></i>Apply &amp; Close
</button>
@@ -1503,10 +1503,10 @@
aria-label="Toggle sidebar">
<i class="bi bi-list" style="font-size: 1.5rem;"></i>
</button>
<div class="d-flex align-items-center gap-2">
<div class="d-flex align-items-center gap-2" style="min-width:0;overflow:hidden;">
@if (ViewData["PageIcon"] != null)
{
<i class="bi @ViewData["PageIcon"]" style="font-size:1.25rem;color:var(--bs-secondary-color);"></i>
<i class="bi @ViewData["PageIcon"]" style="font-size:1.25rem;color:var(--bs-secondary-color);flex-shrink:0;"></i>
}
<h1 class="page-title mb-0">@ViewData["Title"]</h1>
@if (ViewData["PageHelpContent"] != null)
@@ -2287,7 +2287,7 @@
<p class="text-muted mb-0" id="globalConfirmMessage"></p>
</div>
<div class="modal-footer border-0 justify-content-center gap-2 pb-4">
<button type="button" class="btn btn-secondary px-4" id="globalConfirmCancel">Cancel</button>
<button type="button" class="btn btn-outline-secondary px-4" id="globalConfirmCancel">Cancel</button>
<button type="button" class="btn btn-danger px-4" id="globalConfirmOk">Confirm</button>
</div>
</div>
@@ -274,7 +274,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
@@ -13,7 +13,7 @@
<div class="container-fluid py-3" style="max-width:960px">
<div class="d-flex align-items-center gap-3 mb-3">
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
<h4 class="mb-0">
@@ -67,7 +67,7 @@
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center py-2">
<span class="fw-semibold">Raw Payload</span>
<button class="btn btn-outline-secondary btn-sm" onclick="copyJson()">
<button class="btn btn-outline-secondary" onclick="copyJson()">
<i class="bi bi-clipboard me-1"></i>Copy
</button>
</div>
@@ -1,8 +1,8 @@
@using PowderCoating.Core.Entities
@using PowderCoating.Core.Entities
@using PowderCoating.Core.Enums
@model Company
@{
ViewData["Title"] = $"Manage – {Model.CompanyName}";
ViewData["Title"] = $"Manage {Model.CompanyName}";
var planConfigs = (dynamic)ViewBag.PlanConfigs;
string PlanName(int plan)
@@ -22,11 +22,11 @@
<div class="container-fluid py-3" style="max-width:900px">
<div class="d-flex align-items-center gap-3 mb-3">
<a asp-action="Index" class="btn btn-outline-secondary btn-sm">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back
</a>
<a asp-controller="Companies" asp-action="Edit" asp-route-id="@Model.Id"
class="btn btn-outline-secondary btn-sm">
class="btn btn-outline-secondary">
<i class="bi bi-building me-1"></i>Edit Company
</a>
<h4 class="mb-0">
@@ -66,9 +66,9 @@
<dt class="col-7 text-muted">Users</dt>
<dd class="col-5 fw-semibold">@ViewBag.UserCount</dd>
<dt class="col-7 text-muted">Stripe Customer</dt>
<dd class="col-5"><code class="small">@(Model.StripeCustomerId ?? "—")</code></dd>
<dd class="col-5"><code class="small">@(Model.StripeCustomerId ?? "")</code></dd>
<dt class="col-7 text-muted">Stripe Sub</dt>
<dd class="col-5"><code class="small">@(Model.StripeSubscriptionId ?? "—")</code></dd>
<dd class="col-5"><code class="small">@(Model.StripeSubscriptionId ?? "")</code></dd>
</dl>
</div>
</div>
@@ -99,7 +99,7 @@
<form method="post" asp-action="UpdateSubscription" asp-route-id="@Model.Id">
@Html.AntiForgeryToken()
@* Comped / Internal card — prominent *@
@* Comped / Internal card prominent *@
<div class="card border-0 shadow-sm mb-3 @(Model.IsComped ? "border-success border-2" : "")">
<div class="card-header border-0 py-3 @(Model.IsComped ? "bg-success bg-opacity-10" : "bg-white")">
<h6 class="mb-0 fw-semibold">
@@ -351,11 +351,11 @@
</div>
<dl class="row small mb-3">
<dt class="col-5 text-muted">Invoice</dt>
<dd class="col-7 font-monospace" id="refund-invoice-number">—</dd>
<dd class="col-7 font-monospace" id="refund-invoice-number"></dd>
<dt class="col-5 text-muted">Amount Paid</dt>
<dd class="col-7 fw-semibold" id="refund-amount-paid">—</dd>
<dd class="col-7 fw-semibold" id="refund-amount-paid"></dd>
<dt class="col-5 text-muted">Max Refundable</dt>
<dd class="col-7 fw-semibold text-success" id="refund-max-amount">—</dd>
<dd class="col-7 fw-semibold text-success" id="refund-max-amount"></dd>
</dl>
<div class="mb-3">
<label class="form-label fw-medium">Refund Amount</label>
@@ -374,7 +374,7 @@
<div id="refund-result" class="d-none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="refund-submit-btn" onclick="submitRefund()">
<i class="bi bi-arrow-counterclockwise me-1"></i>Issue Refund
</button>
@@ -402,7 +402,7 @@
@section Scripts {
<script>
// ── State for the refund modal ────────────────────────────────────────────────
// ── State for the refund modal ────────────────────────────────────────────────
let _refundPaymentIntentId = null;
let _refundMaxCents = 0;
let _refundAmountPaid = '';
@@ -442,7 +442,7 @@ async function loadPaymentHistory() {
: '';
const refundedCell = ch.amountRefunded
? `<span class="text-danger small">${ch.amountRefunded}</span>`
: '<span class="text-muted small">—</span>';
: '<span class="text-muted small"></span>';
const desc = ch.description ? `<span class="text-muted">${ch.description}</span>` : `<code class="small">${ch.id}</code>`;
// Show Refund button only for succeeded charges that still have something refundable
@@ -469,7 +469,7 @@ async function loadPaymentHistory() {
}
function openRefundModal(chargeId, refundableCents, amountPaid, displayLabel) {
_refundPaymentIntentId = chargeId; // reusing variable — now holds charge ID
_refundPaymentIntentId = chargeId; // reusing variable now holds charge ID
_refundMaxCents = refundableCents;
_refundAmountPaid = amountPaid;
_refundInvoiceNumber = displayLabel;
@@ -509,7 +509,7 @@ async function submitRefund() {
}
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Processing…';
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Processing';
try {
const formData = new FormData();
@@ -204,7 +204,7 @@
<small class="text-muted me-auto">
<i class="bi bi-lightbulb me-1"></i>Easter egg unlocked! 🎉
</small>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
+131
View File
@@ -1020,4 +1020,135 @@ a.tag-index-badge:hover {
.mw-xs { max-width: 280px; }
.mw-sm { max-width: 360px; }
.mw-md { max-width: 480px; }
/* =============================================================
MOBILE / TABLET RESPONSIVENESS Phase 2 patches
============================================================= */
/* 1. Top-navbar: prevent left-side from overflowing on phone
The navbar left side is: hamburger + icon + h1.page-title + company badge.
On narrow phones (<576px) this row is ~340px of content in ~330px space.
Fix: let the page-title shrink and truncate; hide company badge. */
@media (max-width: 575.98px) {
.top-navbar {
padding: 0.625rem 0.75rem;
gap: 0.35rem;
}
/* Allow left cluster to shrink — prevents pushing user-menu off-screen */
.top-navbar > .d-flex:first-child {
min-width: 0;
flex: 1 1 0;
gap: 0.35rem;
}
.page-title {
font-size: 0.95rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
max-width: 40vw;
}
/* Company badge in top-navbar adds width without adding value on phone — hide it */
.top-navbar .badge.bg-primary {
display: none !important;
}
}
/* 2. Content area: flex children need min-width:0 to allow shrinking
Without this, h4/h5 inside a flex container can't shrink below their
natural content width, blowing the container past the viewport edge. */
.d-flex > h4,
.d-flex > h5 {
min-width: 0;
}
/* 3. Page-header rows: let title stack above action buttons on phone
The near-universal pattern is:
d-flex justify-content-between align-items-center mb-3 / mb-4
Adding flex-wrap lets the h4 take line 1, buttons wrap to line 2.
The mb-3/mb-4 specificity ensures we only hit block-level separators,
not inline rows or filter bars. */
@media (max-width: 575.98px) {
.d-flex.justify-content-between.align-items-center.mb-3,
.d-flex.justify-content-between.align-items-center.mb-4,
.d-flex.align-items-center.justify-content-between.mb-3,
.d-flex.align-items-center.justify-content-between.mb-4 {
flex-wrap: wrap;
gap: 0.5rem;
}
}
/* 4. Card headers: let title + card-level actions wrap on phone
Card headers use the same justify-content-between pattern. */
@media (max-width: 575.98px) {
.card-header .d-flex.justify-content-between,
.card-header .d-flex.align-items-center.gap-2 {
flex-wrap: wrap;
gap: 0.5rem;
}
.card-header h5 {
min-width: 0;
flex-shrink: 1;
}
}
/* 5. Details page top button bars: wrap instead of overflow
Pattern: d-flex justify-content-end gap-2 mb-4 (Download PDF, Back, Edit) */
@media (max-width: 575.98px) {
.d-flex.justify-content-end.gap-2.mb-4,
.d-flex.gap-2.justify-content-end.mb-4 {
flex-wrap: wrap;
}
}
/* 6. Alert filter banners: wrap action button below text on phone
Pattern: alert d-flex justify-content-between align-items-center */
@media (max-width: 575.98px) {
.alert.d-flex.justify-content-between.align-items-center,
.alert.d-flex.align-items-center.justify-content-between {
flex-wrap: wrap;
gap: 0.4rem;
}
}
/* 7. Table row buttons: exempt from the global 44px touch-target floor
The global rule (.btn, .btn-sm { min-height: 44px }) is right for form
buttons, but forces table rows to ~50px tall on mobile too bloated.
Buttons inside .table and inside .btn-group-sm stay compact. */
@media (max-width: 768px) {
.table .btn,
.table .btn-sm,
.btn-group-sm > .btn {
min-height: unset !important;
padding: 0.25rem 0.5rem !important;
font-size: 0.8rem !important;
}
}
/* 8. Notification dropdown: prevent overflow on narrow phones
Fixed width: 360px clips content on 360px phones. */
@media (max-width: 575.98px) {
.notif-dropdown {
width: calc(100vw - 2rem) !important;
min-width: unset !important;
}
}
/* 9. Search inputs in card/page headers: remove enforced min-width
Several views set style="min-width:200px" inline on the input-group.
This is fine on tablets but breaks layout on <400px phones. */
@media (max-width: 400px) {
.card-header .input-group,
.input-group[style*="min-width"] {
min-width: unset !important;
}
}
/* 10. Tablet (768px): tighten content area padding
2rem padding on both sides = 64px wasted on a 768px screen. Use 1.25rem. */
@media (min-width: 577px) and (max-width: 991px) {
.content-area {
padding: 1.25rem;
}
}
.mw-lg { max-width: 640px; }
@@ -0,0 +1,70 @@
(function () {
const btn = document.getElementById('aiRiskBtn');
if (!btn) return;
btn.addEventListener('click', async function () {
const body = document.getElementById('aiRiskBody');
const spinner = document.getElementById('aiRiskSpinner');
const errEl = document.getElementById('aiRiskError');
const insights = document.getElementById('aiRiskInsights');
const table = document.getElementById('aiRiskTable');
const rows = document.getElementById('aiRiskRows');
body.classList.remove('d-none');
spinner.classList.remove('d-none');
errEl.classList.add('d-none');
table.classList.add('d-none');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Analyzing…';
try {
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
const resp = await fetch('/Reports/PredictLatePayments', {
method: 'POST',
headers: { 'RequestVerificationToken': token }
});
const data = await resp.json();
spinner.classList.add('d-none');
if (!data.success) {
errEl.textContent = data.errorMessage || 'AI service unavailable.';
errEl.classList.remove('d-none');
return;
}
const preds = data.predictions || [];
const insightList = data.insights || [];
insights.innerHTML = insightList
.map(i => `<i class="bi bi-lightbulb text-warning me-1"></i>${i}`)
.join('<br>');
const riskBadge = level => {
if (level === 'high') return 'bg-danger text-white';
if (level === 'medium') return 'bg-warning text-dark';
return 'bg-success text-white';
};
rows.innerHTML = preds.map(p => `
<tr>
<td class="fw-medium">${p.customerName}</td>
<td><span class="badge ${riskBadge(p.riskLevel)}">${p.riskLevel.charAt(0).toUpperCase() + p.riskLevel.slice(1)}</span></td>
<td>${p.estimatedDaysToPayment > 0 ? p.estimatedDaysToPayment + ' days' : 'Soon'}</td>
<td class="text-muted small">${p.reasoning}</td>
</tr>
`).join('');
if (preds.length > 0) table.classList.remove('d-none');
else insights.innerHTML += '<br><span class="text-muted">No open invoices to predict.</span>';
} catch (err) {
spinner.classList.add('d-none');
errEl.textContent = 'Error contacting AI service.';
errEl.classList.remove('d-none');
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-magic me-1"></i>Predict Payment Risk';
}
});
})();
@@ -0,0 +1,116 @@
(function () {
const queryInput = document.getElementById('queryInput');
const queryBtn = document.getElementById('queryBtn');
const answerArea = document.getElementById('answerArea');
const spinner = document.getElementById('answerSpinner');
const errEl = document.getElementById('answerError');
const answerText = document.getElementById('answerText');
const factsArea = document.getElementById('factsArea');
const factsList = document.getElementById('factsList');
const followUpArea = document.getElementById('followUpArea');
const followUpBtn = document.getElementById('followUpBtn');
const historyArea = document.getElementById('historyArea');
const historyList = document.getElementById('historyList');
const chips = document.getElementById('suggestionChips');
const contextJson = document.getElementById('contextJson')?.value ?? '{}';
const suggestions = [
'What was our revenue this year?',
'What are our biggest expenses?',
'How much do customers owe us?',
'What is our net income year to date?',
'Which month had the highest revenue?',
];
let sessionHistory = [];
suggestions.forEach(s => {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'btn btn-sm btn-outline-secondary rounded-pill';
chip.textContent = s;
chip.addEventListener('click', () => { queryInput.value = s; runQuery(s); });
chips.appendChild(chip);
});
queryBtn.addEventListener('click', () => runQuery(queryInput.value.trim()));
queryInput.addEventListener('keydown', e => { if (e.key === 'Enter') runQuery(queryInput.value.trim()); });
followUpBtn.addEventListener('click', () => { queryInput.value = followUpBtn.textContent; runQuery(followUpBtn.textContent); });
async function runQuery(question) {
if (!question) return;
// Save previous answer to history before replacing
const prevQuestion = queryInput.dataset.lastQuestion;
const prevAnswer = answerText.textContent;
if (prevQuestion && prevAnswer) {
sessionHistory.push({ question: prevQuestion, answer: prevAnswer });
renderHistory();
}
queryInput.dataset.lastQuestion = question;
answerArea.classList.remove('d-none');
spinner.classList.remove('d-none');
errEl.classList.add('d-none');
answerText.classList.add('d-none');
factsArea.classList.add('d-none');
followUpArea.classList.add('d-none');
queryBtn.disabled = true;
queryBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>';
try {
let context = {};
try { context = JSON.parse(contextJson); } catch {}
const resp = await fetch('/Reports/RunFinancialQuery', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question, context })
});
const data = await resp.json();
spinner.classList.add('d-none');
if (!data.success) {
errEl.textContent = data.errorMessage || 'AI service unavailable.';
errEl.classList.remove('d-none');
return;
}
answerText.textContent = data.answer;
answerText.classList.remove('d-none');
const facts = data.relevantFacts || [];
if (facts.length > 0) {
factsList.innerHTML = facts.map(f => `<li><i class="bi bi-dot"></i>${f}</li>`).join('');
factsArea.classList.remove('d-none');
}
if (data.followUpSuggestion) {
followUpBtn.textContent = data.followUpSuggestion;
followUpArea.classList.remove('d-none');
}
} catch {
spinner.classList.add('d-none');
errEl.textContent = 'Error contacting AI service.';
errEl.classList.remove('d-none');
} finally {
queryBtn.disabled = false;
queryBtn.innerHTML = '<i class="bi bi-send me-1"></i>Ask';
}
}
function renderHistory() {
if (sessionHistory.length === 0) return;
historyArea.classList.remove('d-none');
historyList.innerHTML = '';
sessionHistory.slice(-5).reverse().forEach(entry => {
const item = document.createElement('div');
item.className = 'card border-0 bg-light p-2 small cursor-pointer';
item.style.cursor = 'pointer';
item.innerHTML = `<span class="fw-medium text-muted">${entry.question}</span><br><span class="text-truncate d-block" style="max-width:100%">${entry.answer.substring(0, 100)}${entry.answer.length > 100 ? '…' : ''}</span>`;
item.addEventListener('click', () => { queryInput.value = entry.question; });
historyList.appendChild(item);
});
}
})();
@@ -0,0 +1,111 @@
(function () {
const form = document.getElementById('scanForm');
const scanBtn = document.getElementById('scanBtn');
const resultArea = document.getElementById('resultArea');
const spinner = document.getElementById('spinnerArea');
const errArea = document.getElementById('errorArea');
const insArea = document.getElementById('insightsArea');
const insList = document.getElementById('insightsList');
const noPatterns = document.getElementById('noPatterns');
const patternsArea = document.getElementById('patternsArea');
const patternCards = document.getElementById('patternCards');
if (!form) return;
form.addEventListener('submit', async function (e) {
e.preventDefault();
resultArea.classList.remove('d-none');
spinner.classList.remove('d-none');
errArea.classList.add('d-none');
insArea.classList.add('d-none');
noPatterns.classList.add('d-none');
patternsArea.classList.add('d-none');
patternCards.innerHTML = '';
scanBtn.disabled = true;
scanBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Analyzing…';
try {
const token = form.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
const resp = await fetch('/Bills/RunRecurringDetection', {
method: 'POST',
headers: { 'RequestVerificationToken': token }
});
const data = await resp.json();
spinner.classList.add('d-none');
if (!data.success) {
errArea.textContent = data.errorMessage || 'AI service unavailable.';
errArea.classList.remove('d-none');
return;
}
const insights = data.insights || [];
if (insights.length > 0) {
insList.innerHTML = insights.join('<br>');
insArea.classList.remove('d-none');
}
const patterns = data.patterns || [];
if (patterns.length === 0) {
noPatterns.classList.remove('d-none');
return;
}
patternsArea.classList.remove('d-none');
patterns.forEach(p => {
const confBadge = p.confidence === 'high' ? 'bg-success text-white'
: p.confidence === 'medium' ? 'bg-warning text-dark'
: 'bg-secondary text-white';
const freqIcon = p.frequency === 'monthly' ? 'bi-calendar-month'
: p.frequency === 'quarterly' ? 'bi-calendar3'
: p.frequency === 'annual' ? 'bi-calendar-check'
: 'bi-arrow-repeat';
const nextDate = p.nextExpectedDateIso
? new Date(p.nextExpectedDateIso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
: null;
const col = document.createElement('div');
col.className = 'col-md-6 col-xl-4';
col.innerHTML = `
<div class="card shadow-sm h-100">
<div class="card-header d-flex align-items-center gap-2">
<i class="bi ${freqIcon} text-primary"></i>
<span class="fw-semibold text-truncate">${p.vendorName}</span>
<span class="badge ${confBadge} ms-auto">${p.confidence}</span>
</div>
<div class="card-body small">
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Frequency</span>
<span class="fw-medium text-capitalize">${p.frequency}</span>
</div>
<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Typical amount</span>
<span class="fw-medium">${p.typicalAmount.toLocaleString('en-US', { style: 'currency', currency: 'USD' })}</span>
</div>
${nextDate ? `<div class="d-flex justify-content-between mb-2">
<span class="text-muted">Next expected</span>
<span class="fw-medium text-warning">${nextDate}</span>
</div>` : ''}
<p class="text-muted mb-2">${p.description}</p>
${p.suggestedAction ? `<div class="alert alert-info alert-permanent py-1 px-2 mb-0 small">
<i class="bi bi-arrow-right-circle me-1"></i>${p.suggestedAction}
</div>` : ''}
</div>
</div>
`;
patternCards.appendChild(col);
});
} catch {
spinner.classList.add('d-none');
errArea.textContent = 'Error contacting AI service.';
errArea.classList.remove('d-none');
} finally {
scanBtn.disabled = false;
scanBtn.innerHTML = '<i class="bi bi-magic me-1"></i>Detect Recurring Bills';
}
});
})();