Compare commits
28 Commits
03b425a12f
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 7735fe3cce | |||
| 249128e852 | |||
| c0e4a66126 | |||
| dbd39a9fe5 | |||
| 584664e7c8 | |||
| 1255bc0670 | |||
| 01f6897d08 | |||
| 72382a5dd5 | |||
| 86a293a927 | |||
| 35264e6b2a | |||
| 0b839d0746 | |||
| 66c3febd7a | |||
| b8057295ec | |||
| 14d6c82839 | |||
| db4b73013a | |||
| e313149f08 | |||
| 82fb48f7a5 | |||
| 427c52a499 | |||
| d92266b027 | |||
| 750e1b1c5b | |||
| 94a89ee175 | |||
| 711cd01cd3 | |||
| 7cbae31916 | |||
| 9367e358d9 | |||
| 9f1460c9c0 | |||
| 94e536178c | |||
| 456d054229 | |||
| f38a1e3273 |
@@ -0,0 +1,431 @@
|
||||
-- =============================================================================
|
||||
-- Company Data Purge Script
|
||||
-- Removes financial, job, and quote data dated before a cutoff date.
|
||||
-- Customers and Vendors are always preserved.
|
||||
--
|
||||
-- WHAT THIS DELETES (using each entity's own business date, not CreatedAt):
|
||||
-- • Journal entries (EntryDate) & bank reconciliations (StatementDate)
|
||||
-- • Bills (BillDate), bill payments (PaymentDate), purchase orders (OrderDate)
|
||||
-- • Vendor credits (CreditDate), expenses (Date)
|
||||
-- • Invoices (InvoiceDate), payments (PaymentDate), deposits (ReceivedDate)
|
||||
-- • Credit memos, refunds, gift cert redemptions tied to deleted invoices
|
||||
-- • Jobs (IntakeDate, falling back to CreatedAt when null) and all child records
|
||||
-- • Quotes (QuoteDate) and all child records
|
||||
--
|
||||
-- WHAT THIS KEEPS (always):
|
||||
-- • Customers, customer notes, customer contacts, preferred powders
|
||||
-- • Vendors
|
||||
-- • Inventory items and inventory transactions
|
||||
-- • Equipment, catalog items, pricing tiers
|
||||
-- • Company settings and configuration
|
||||
-- • Any record whose business date >= @CutoffDate
|
||||
--
|
||||
-- INSTRUCTIONS:
|
||||
-- 1. Set @CompanyId — find it with: SELECT Id, Name FROM Companies
|
||||
-- 2. Set @CutoffDate — records dated BEFORE this date are deleted
|
||||
-- 3. Run with @DryRun = 1 first and review the row counts printed
|
||||
-- 4. Back up the database before setting @DryRun = 0
|
||||
-- 5. Set @DryRun = 0 and run again to apply
|
||||
-- =============================================================================
|
||||
|
||||
DECLARE @CutoffDate DATE = '2026-01-01'; -- Delete records dated BEFORE this date
|
||||
DECLARE @CompanyId INT = 0; -- !! Set to your company ID before running
|
||||
DECLARE @DryRun BIT = 1; -- 1 = preview counts only | 0 = apply deletes
|
||||
|
||||
-- =============================================================================
|
||||
SET NOCOUNT ON;
|
||||
|
||||
IF @CompanyId = 0
|
||||
BEGIN
|
||||
RAISERROR('ERROR: Set @CompanyId before running this script. Run: SELECT Id, Name FROM Companies', 16, 1);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
PRINT '============================================================';
|
||||
PRINT 'Purge run at: ' + CONVERT(NVARCHAR, GETDATE(), 120);
|
||||
PRINT 'Company ID : ' + CAST(@CompanyId AS NVARCHAR);
|
||||
PRINT 'Cutoff date : ' + CAST(@CutoffDate AS NVARCHAR) + ' (records BEFORE this date)';
|
||||
PRINT 'Dry run : ' + CASE @DryRun WHEN 1 THEN 'YES — no changes will be made' ELSE 'NO — deletes will be applied' END;
|
||||
PRINT '============================================================';
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
-- ===========================================================================
|
||||
-- SECTION 1 — JOURNAL ENTRIES & GL
|
||||
-- Uses EntryDate (JournalEntries) and StatementDate (BankReconciliations)
|
||||
-- ===========================================================================
|
||||
PRINT '';
|
||||
PRINT '--- Section 1: Journal Entries & GL ---';
|
||||
|
||||
-- Null the self-referential ReversalOfId FK before deleting
|
||||
UPDATE JournalEntries
|
||||
SET ReversalOfId = NULL
|
||||
WHERE CompanyId = @CompanyId
|
||||
AND ReversalOfId IN (SELECT Id FROM JournalEntries WHERE CompanyId = @CompanyId AND CAST(EntryDate AS DATE) < @CutoffDate);
|
||||
|
||||
DELETE FROM JournalEntryLines
|
||||
WHERE JournalEntryId IN (
|
||||
SELECT Id FROM JournalEntries WHERE CompanyId = @CompanyId AND CAST(EntryDate AS DATE) < @CutoffDate);
|
||||
PRINT 'JournalEntryLines deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM JournalEntries
|
||||
WHERE CompanyId = @CompanyId AND CAST(EntryDate AS DATE) < @CutoffDate;
|
||||
PRINT 'JournalEntries deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM BankReconciliations
|
||||
WHERE CompanyId = @CompanyId AND CAST(StatementDate AS DATE) < @CutoffDate;
|
||||
PRINT 'BankReconciliations deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- ===========================================================================
|
||||
-- SECTION 2 — BILLS, PURCHASE ORDERS & EXPENSES
|
||||
-- Uses CreditDate (VendorCredits), BillDate (Bills), OrderDate (POs), Date (Expenses)
|
||||
-- ===========================================================================
|
||||
PRINT '';
|
||||
PRINT '--- Section 2: Bills, Purchase Orders & Expenses ---';
|
||||
|
||||
-- Vendor credits (must come before Bills because VendorCreditApplications references both)
|
||||
DELETE FROM VendorCreditApplications
|
||||
WHERE VendorCreditId IN (
|
||||
SELECT Id FROM VendorCredits WHERE CompanyId = @CompanyId AND CAST(CreditDate AS DATE) < @CutoffDate);
|
||||
PRINT 'VendorCreditApplications deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM VendorCreditLineItems
|
||||
WHERE VendorCreditId IN (
|
||||
SELECT Id FROM VendorCredits WHERE CompanyId = @CompanyId AND CAST(CreditDate AS DATE) < @CutoffDate);
|
||||
PRINT 'VendorCreditLineItems deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM VendorCredits
|
||||
WHERE CompanyId = @CompanyId AND CAST(CreditDate AS DATE) < @CutoffDate;
|
||||
PRINT 'VendorCredits deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- Bills
|
||||
DELETE FROM BillPayments
|
||||
WHERE BillId IN (
|
||||
SELECT Id FROM Bills WHERE CompanyId = @CompanyId AND CAST(BillDate AS DATE) < @CutoffDate);
|
||||
PRINT 'BillPayments deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM BillLineItems
|
||||
WHERE BillId IN (
|
||||
SELECT Id FROM Bills WHERE CompanyId = @CompanyId AND CAST(BillDate AS DATE) < @CutoffDate);
|
||||
PRINT 'BillLineItems deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM Bills
|
||||
WHERE CompanyId = @CompanyId AND CAST(BillDate AS DATE) < @CutoffDate;
|
||||
PRINT 'Bills deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- Purchase orders
|
||||
DELETE FROM PurchaseOrderItems
|
||||
WHERE PurchaseOrderId IN (
|
||||
SELECT Id FROM PurchaseOrders WHERE CompanyId = @CompanyId AND CAST(OrderDate AS DATE) < @CutoffDate);
|
||||
PRINT 'PurchaseOrderItems deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM PurchaseOrders
|
||||
WHERE CompanyId = @CompanyId AND CAST(OrderDate AS DATE) < @CutoffDate;
|
||||
PRINT 'PurchaseOrders deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- Expenses (Date column)
|
||||
DELETE FROM Expenses
|
||||
WHERE CompanyId = @CompanyId AND CAST([Date] AS DATE) < @CutoffDate;
|
||||
PRINT 'Expenses deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- ===========================================================================
|
||||
-- SECTION 3 — INVOICES, PAYMENTS & DEPOSITS
|
||||
-- Uses InvoiceDate (Invoices), PaymentDate (Payments), ReceivedDate (Deposits)
|
||||
-- CreditMemos/Refunds/GiftCertRedemptions have no standalone date — deleted
|
||||
-- only when their parent invoice falls within the cutoff.
|
||||
-- ===========================================================================
|
||||
PRINT '';
|
||||
PRINT '--- Section 3: Invoices, Payments & Deposits ---';
|
||||
|
||||
-- CreditMemos: NULL the OriginalInvoiceId FK before deleting the invoice it points to
|
||||
UPDATE CreditMemos
|
||||
SET OriginalInvoiceId = NULL
|
||||
WHERE CompanyId = @CompanyId
|
||||
AND OriginalInvoiceId IN (
|
||||
SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CAST(InvoiceDate AS DATE) < @CutoffDate);
|
||||
|
||||
DELETE FROM CreditMemoApplications
|
||||
WHERE InvoiceId IN (
|
||||
SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CAST(InvoiceDate AS DATE) < @CutoffDate)
|
||||
OR CreditMemoId IN (
|
||||
SELECT Id FROM CreditMemos WHERE CompanyId = @CompanyId AND CAST(CreatedAt AS DATE) < @CutoffDate);
|
||||
PRINT 'CreditMemoApplications deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM CreditMemos
|
||||
WHERE CompanyId = @CompanyId AND CAST(CreatedAt AS DATE) < @CutoffDate;
|
||||
PRINT 'CreditMemos deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- Refunds and gift-cert redemptions tied to deleted invoices
|
||||
DELETE FROM Refunds
|
||||
WHERE InvoiceId IN (
|
||||
SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CAST(InvoiceDate AS DATE) < @CutoffDate);
|
||||
PRINT 'Refunds deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM GiftCertificateRedemptions
|
||||
WHERE InvoiceId IN (
|
||||
SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CAST(InvoiceDate AS DATE) < @CutoffDate);
|
||||
PRINT 'GiftCertificateRedemptions deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- Payments (PaymentDate)
|
||||
DELETE FROM Payments
|
||||
WHERE InvoiceId IN (
|
||||
SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CAST(InvoiceDate AS DATE) < @CutoffDate);
|
||||
PRINT 'Payments deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- InvoiceItems (NULL SourceJobItemId on any invoice items that survive but point at deleted jobs)
|
||||
UPDATE InvoiceItems
|
||||
SET SourceJobItemId = NULL
|
||||
WHERE SourceJobItemId IN (
|
||||
SELECT ji.Id FROM JobItems ji
|
||||
INNER JOIN Jobs j ON ji.JobId = j.Id
|
||||
WHERE j.CompanyId = @CompanyId
|
||||
AND CAST(COALESCE(j.IntakeDate, j.CreatedAt) AS DATE) < @CutoffDate);
|
||||
|
||||
DELETE FROM InvoiceItems
|
||||
WHERE InvoiceId IN (
|
||||
SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CAST(InvoiceDate AS DATE) < @CutoffDate);
|
||||
PRINT 'InvoiceItems deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- Deposits: clear the AppliedToInvoiceId FK before deleting the invoices
|
||||
UPDATE Deposits
|
||||
SET AppliedToInvoiceId = NULL,
|
||||
AppliedDate = NULL
|
||||
WHERE CompanyId = @CompanyId
|
||||
AND AppliedToInvoiceId IN (
|
||||
SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CAST(InvoiceDate AS DATE) < @CutoffDate);
|
||||
|
||||
-- Now delete deposits that fall within the cutoff (ReceivedDate)
|
||||
DELETE FROM Deposits
|
||||
WHERE CompanyId = @CompanyId AND CAST(ReceivedDate AS DATE) < @CutoffDate;
|
||||
PRINT 'Deposits deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- Notification logs referencing deleted invoices
|
||||
UPDATE NotificationLogs
|
||||
SET InvoiceId = NULL
|
||||
WHERE CompanyId = @CompanyId
|
||||
AND InvoiceId IN (
|
||||
SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CAST(InvoiceDate AS DATE) < @CutoffDate);
|
||||
|
||||
DELETE FROM Invoices
|
||||
WHERE CompanyId = @CompanyId AND CAST(InvoiceDate AS DATE) < @CutoffDate;
|
||||
PRINT 'Invoices deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- ===========================================================================
|
||||
-- SECTION 4 — JOBS
|
||||
-- Uses COALESCE(IntakeDate, CreatedAt) — IntakeDate is the business date;
|
||||
-- falls back to CreatedAt when not set (e.g. jobs created before IntakeDate existed).
|
||||
-- ===========================================================================
|
||||
PRINT '';
|
||||
PRINT '--- Section 4: Jobs ---';
|
||||
|
||||
-- NULL FKs in other tables that point to jobs/job-items being deleted --
|
||||
|
||||
-- BillLineItems.JobId (bill survived but referenced a deleted job)
|
||||
UPDATE BillLineItems
|
||||
SET JobId = NULL
|
||||
WHERE JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate);
|
||||
|
||||
-- Expenses.JobId
|
||||
UPDATE Expenses
|
||||
SET JobId = NULL
|
||||
WHERE CompanyId = @CompanyId
|
||||
AND JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate);
|
||||
|
||||
-- Appointments.JobId
|
||||
UPDATE Appointments
|
||||
SET JobId = NULL
|
||||
WHERE CompanyId = @CompanyId
|
||||
AND JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate);
|
||||
|
||||
-- Deposits.JobId (NoAction FK — must NULL before deleting job)
|
||||
UPDATE Deposits
|
||||
SET JobId = NULL
|
||||
WHERE CompanyId = @CompanyId
|
||||
AND JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate);
|
||||
|
||||
-- NotificationLogs.JobId
|
||||
UPDATE NotificationLogs
|
||||
SET JobId = NULL
|
||||
WHERE CompanyId = @CompanyId
|
||||
AND JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate);
|
||||
|
||||
-- OvenBatchItems: delete before OvenBatches and before JobItems
|
||||
DELETE FROM OvenBatchItems
|
||||
WHERE JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate);
|
||||
PRINT 'OvenBatchItems deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- Clean up now-empty OvenBatches (batches belonging to this company with no remaining items)
|
||||
DELETE FROM OvenBatches
|
||||
WHERE CompanyId = @CompanyId
|
||||
AND Id NOT IN (SELECT DISTINCT OvenBatchId FROM OvenBatchItems);
|
||||
PRINT 'OvenBatches deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- ReworkRecords (JobId required FK; ReworkJobId optional)
|
||||
DELETE FROM ReworkRecords
|
||||
WHERE JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate)
|
||||
OR ReworkJobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate);
|
||||
PRINT 'ReworkRecords deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- JobItem children (coats and prep services)
|
||||
DELETE FROM JobItemCoats
|
||||
WHERE JobItemId IN (
|
||||
SELECT Id FROM JobItems WHERE JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate));
|
||||
PRINT 'JobItemCoats deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM JobItemPrepServices
|
||||
WHERE JobItemId IN (
|
||||
SELECT Id FROM JobItems WHERE JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate));
|
||||
PRINT 'JobItemPrepServices deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM JobItems
|
||||
WHERE JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate);
|
||||
PRINT 'JobItems deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- Job metadata tables
|
||||
DELETE FROM JobChangeHistories
|
||||
WHERE JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate);
|
||||
PRINT 'JobChangeHistories deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM JobTimeEntries
|
||||
WHERE JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate);
|
||||
PRINT 'JobTimeEntries deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM JobPhotos
|
||||
WHERE JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate);
|
||||
PRINT 'JobPhotos deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM JobNotes
|
||||
WHERE JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate);
|
||||
PRINT 'JobNotes deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM JobStatusHistory
|
||||
WHERE JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate);
|
||||
PRINT 'JobStatusHistory deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM JobDailyPriorities
|
||||
WHERE JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate);
|
||||
PRINT 'JobDailyPriorities deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM PowderUsageLogs
|
||||
WHERE JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate);
|
||||
PRINT 'PowderUsageLogs deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM AiItemPredictions
|
||||
WHERE CompanyId = @CompanyId AND CAST(CreatedAt AS DATE) < @CutoffDate;
|
||||
PRINT 'AiItemPredictions deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM Jobs
|
||||
WHERE CompanyId = @CompanyId
|
||||
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate;
|
||||
PRINT 'Jobs deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- ===========================================================================
|
||||
-- SECTION 5 — QUOTES
|
||||
-- Uses QuoteDate
|
||||
-- ===========================================================================
|
||||
PRINT '';
|
||||
PRINT '--- Section 5: Quotes ---';
|
||||
|
||||
-- NULL FKs that point to quotes being deleted
|
||||
UPDATE Deposits
|
||||
SET QuoteId = NULL
|
||||
WHERE CompanyId = @CompanyId
|
||||
AND QuoteId IN (
|
||||
SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CAST(QuoteDate AS DATE) < @CutoffDate);
|
||||
|
||||
UPDATE NotificationLogs
|
||||
SET QuoteId = NULL
|
||||
WHERE CompanyId = @CompanyId
|
||||
AND QuoteId IN (
|
||||
SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CAST(QuoteDate AS DATE) < @CutoffDate);
|
||||
|
||||
-- QuoteItem children
|
||||
DELETE FROM QuoteItemCoats
|
||||
WHERE QuoteItemId IN (
|
||||
SELECT Id FROM QuoteItems WHERE QuoteId IN (
|
||||
SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CAST(QuoteDate AS DATE) < @CutoffDate));
|
||||
PRINT 'QuoteItemCoats deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM QuoteItemPrepServices
|
||||
WHERE QuoteItemId IN (
|
||||
SELECT Id FROM QuoteItems WHERE QuoteId IN (
|
||||
SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CAST(QuoteDate AS DATE) < @CutoffDate));
|
||||
PRINT 'QuoteItemPrepServices deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM QuoteItems
|
||||
WHERE QuoteId IN (
|
||||
SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CAST(QuoteDate AS DATE) < @CutoffDate);
|
||||
PRINT 'QuoteItems deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM QuoteChangeHistories
|
||||
WHERE QuoteId IN (
|
||||
SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CAST(QuoteDate AS DATE) < @CutoffDate);
|
||||
PRINT 'QuoteChangeHistories deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM QuotePhotos
|
||||
WHERE QuoteId IN (
|
||||
SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CAST(QuoteDate AS DATE) < @CutoffDate);
|
||||
PRINT 'QuotePhotos deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM QuotePrepServices
|
||||
WHERE QuoteId IN (
|
||||
SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CAST(QuoteDate AS DATE) < @CutoffDate);
|
||||
PRINT 'QuotePrepServices deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM Quotes
|
||||
WHERE CompanyId = @CompanyId AND CAST(QuoteDate AS DATE) < @CutoffDate;
|
||||
PRINT 'Quotes deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- ===========================================================================
|
||||
-- SUMMARY & COMMIT / ROLLBACK
|
||||
-- ===========================================================================
|
||||
PRINT '';
|
||||
PRINT '============================================================';
|
||||
|
||||
IF @DryRun = 1
|
||||
BEGIN
|
||||
PRINT 'DRY RUN complete — rolling back. No data was changed.';
|
||||
PRINT 'Set @DryRun = 0 and run again to apply the deletes.';
|
||||
ROLLBACK TRANSACTION;
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
PRINT 'Purge complete — committing.';
|
||||
COMMIT TRANSACTION;
|
||||
END
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Customer;
|
||||
|
||||
public class CustomerContactDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int CustomerId { get; set; }
|
||||
public string FirstName { get; set; } = string.Empty;
|
||||
public string? LastName { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public string? ContactRole { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? MobilePhone { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public string DisplayName => string.IsNullOrWhiteSpace(LastName) ? FirstName : $"{FirstName} {LastName}";
|
||||
}
|
||||
|
||||
public class CreateCustomerContactDto
|
||||
{
|
||||
[Required(ErrorMessage = "First name is required.")]
|
||||
[StringLength(100)]
|
||||
[Display(Name = "First Name")]
|
||||
public string FirstName { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(100)]
|
||||
[Display(Name = "Last Name")]
|
||||
public string? LastName { get; set; }
|
||||
|
||||
[StringLength(100)]
|
||||
[Display(Name = "Job Title")]
|
||||
public string? Title { get; set; }
|
||||
|
||||
[StringLength(50)]
|
||||
[Display(Name = "Role")]
|
||||
public string? ContactRole { get; set; }
|
||||
|
||||
[EmailAddress]
|
||||
[StringLength(200)]
|
||||
[Display(Name = "Email")]
|
||||
public string? Email { get; set; }
|
||||
|
||||
[Phone]
|
||||
[StringLength(20)]
|
||||
[Display(Name = "Phone")]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
[Phone]
|
||||
[StringLength(20)]
|
||||
[Display(Name = "Mobile Phone")]
|
||||
public string? MobilePhone { get; set; }
|
||||
|
||||
[StringLength(500)]
|
||||
[Display(Name = "Notes")]
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateCustomerContactDto : CreateCustomerContactDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int CustomerId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace PowderCoating.Application.DTOs.Customer;
|
||||
|
||||
/// <summary>A single entry in the customer activity timeline feed on the Details page.</summary>
|
||||
public class CustomerTimelineEventDto
|
||||
{
|
||||
public DateTime Date { get; set; }
|
||||
public string Icon { get; set; } = string.Empty;
|
||||
public string BadgeColor { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string? Subtitle { get; set; }
|
||||
public decimal? Amount { get; set; }
|
||||
public int? EntityId { get; set; }
|
||||
public string? LinkController { get; set; }
|
||||
public string? LinkAction { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Aggregate lifetime metrics displayed in the CRM stats card on Customer Details.</summary>
|
||||
public class CustomerLifetimeStatsDto
|
||||
{
|
||||
public int TotalJobs { get; set; }
|
||||
public int ActiveJobs { get; set; }
|
||||
/// <summary>Sum of Total on non-voided invoices.</summary>
|
||||
public decimal TotalRevenue { get; set; }
|
||||
/// <summary>Sum of AmountPaid on non-voided invoices.</summary>
|
||||
public decimal TotalCollected { get; set; }
|
||||
/// <summary>Mean FinalPrice across all jobs for this customer.</summary>
|
||||
public decimal AverageJobValue { get; set; }
|
||||
public DateTime? LastJobDate { get; set; }
|
||||
public int? DaysSinceLastJob { get; set; }
|
||||
public int TotalQuotes { get; set; }
|
||||
public int TotalInvoices { get; set; }
|
||||
public decimal OpenBalance { get; set; }
|
||||
/// <summary>Id of the most recent job — used by the "Repeat Last Job" button on Customer Details.</summary>
|
||||
public int? LastJobId { get; set; }
|
||||
}
|
||||
@@ -36,6 +36,16 @@ public class CustomerDto
|
||||
public bool NotifyBySms { get; set; }
|
||||
public DateTime? SmsConsentedAt { get; set; }
|
||||
public string? SmsConsentMethod { get; set; }
|
||||
|
||||
// CRM
|
||||
public string? LeadSource { get; set; }
|
||||
|
||||
// Ship-to address
|
||||
public string? ShipToAddress { get; set; }
|
||||
public string? ShipToCity { get; set; }
|
||||
public string? ShipToState { get; set; }
|
||||
public string? ShipToZipCode { get; set; }
|
||||
public string? ShipToCountry { get; set; }
|
||||
}
|
||||
|
||||
public class CreateCustomerDto : IValidatableObject
|
||||
@@ -115,6 +125,31 @@ public class CreateCustomerDto : IValidatableObject
|
||||
[StringLength(2000)]
|
||||
public string? GeneralNotes { get; set; }
|
||||
|
||||
[Display(Name = "How did you find us?")]
|
||||
[StringLength(100)]
|
||||
public string? LeadSource { get; set; }
|
||||
|
||||
// Ship-to / alternate address
|
||||
[Display(Name = "Ship-To Street Address")]
|
||||
[StringLength(500)]
|
||||
public string? ShipToAddress { get; set; }
|
||||
|
||||
[Display(Name = "City")]
|
||||
[StringLength(100)]
|
||||
public string? ShipToCity { get; set; }
|
||||
|
||||
[Display(Name = "State")]
|
||||
[StringLength(50)]
|
||||
public string? ShipToState { get; set; }
|
||||
|
||||
[Display(Name = "Zip Code")]
|
||||
[StringLength(20)]
|
||||
public string? ShipToZipCode { get; set; }
|
||||
|
||||
[Display(Name = "Country")]
|
||||
[StringLength(100)]
|
||||
public string? ShipToCountry { get; set; }
|
||||
|
||||
[Display(Name = "Notify by Email")]
|
||||
public bool NotifyByEmail { get; set; } = true;
|
||||
|
||||
|
||||
@@ -63,4 +63,22 @@ public class CustomerImportDto
|
||||
|
||||
[Name("Notes")]
|
||||
public string? Notes { get; set; }
|
||||
|
||||
[Name("LeadSource")]
|
||||
public string? LeadSource { get; set; }
|
||||
|
||||
[Name("ShipToAddress")]
|
||||
public string? ShipToAddress { get; set; }
|
||||
|
||||
[Name("ShipToCity")]
|
||||
public string? ShipToCity { get; set; }
|
||||
|
||||
[Name("ShipToState")]
|
||||
public string? ShipToState { get; set; }
|
||||
|
||||
[Name("ShipToZipCode")]
|
||||
public string? ShipToZipCode { get; set; }
|
||||
|
||||
[Name("ShipToCountry")]
|
||||
public string? ShipToCountry { get; set; }
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ public class InvoiceImportDto
|
||||
[Name("DueDate")]
|
||||
public DateTime? DueDate { get; set; }
|
||||
|
||||
[Name("Project Name", "ProjectName")]
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
[Name("SubTotal")]
|
||||
public decimal SubTotal { get; set; }
|
||||
|
||||
|
||||
@@ -49,6 +49,9 @@ public class JobImportDto
|
||||
[Name("SpecialInstructions")]
|
||||
public string? SpecialInstructions { get; set; }
|
||||
|
||||
[Name("ProjectName")]
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
[Name("Notes")]
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ public class QuoteImportDto
|
||||
[Name("ExpirationDate")]
|
||||
public DateTime? ExpirationDate { get; set; }
|
||||
|
||||
[Name("ProjectName")]
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
[Name("Subtotal")]
|
||||
public decimal Subtotal { get; set; }
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ public class InvoiceDto
|
||||
public string? InternalNotes { get; set; }
|
||||
public string? Terms { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public string? ExternalReference { get; set; }
|
||||
public int? SalesTaxAccountId { get; set; }
|
||||
public string? SalesTaxAccountName { get; set; }
|
||||
@@ -88,6 +89,7 @@ public class CreateInvoiceDto
|
||||
public string? InternalNotes { get; set; }
|
||||
public string? Terms { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
/// <summary>Early-payment discount percentage parsed from the customer's payment terms (e.g., 2.0 for "2/10 Net 30"). Informational — does not auto-apply.</summary>
|
||||
public decimal EarlyPaymentDiscountPercent { get; set; }
|
||||
/// <summary>Number of days within which the early-payment discount applies (e.g., 10 for "2/10 Net 30").</summary>
|
||||
@@ -105,6 +107,7 @@ public class UpdateInvoiceDto
|
||||
public string? InternalNotes { get; set; }
|
||||
public string? Terms { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public List<CreateInvoiceItemDto> InvoiceItems { get; set; } = new();
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ public class JobDto
|
||||
public decimal DiscountValue { get; set; }
|
||||
public string? DiscountReason { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public string? SpecialInstructions { get; set; }
|
||||
public string? InternalNotes { get; set; }
|
||||
public string? Tags { get; set; }
|
||||
@@ -114,6 +115,7 @@ public class JobListDto
|
||||
public string? CustomerEmail { get; set; }
|
||||
public bool CustomerNotifyByEmail { get; set; } = true;
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
public decimal FinalPrice { get; set; }
|
||||
@@ -167,6 +169,7 @@ public class CreateJobDto
|
||||
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
|
||||
[Display(Name = "Customer PO")]
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
|
||||
[Display(Name = "Special Instructions")]
|
||||
@@ -252,6 +255,7 @@ public class UpdateJobDto
|
||||
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
|
||||
[Display(Name = "Customer PO")]
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
|
||||
[Display(Name = "Special Instructions")]
|
||||
|
||||
@@ -107,6 +107,7 @@ public class QuoteDto
|
||||
public string? Terms { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public string? Tags { get; set; }
|
||||
|
||||
// Items
|
||||
@@ -234,6 +235,7 @@ public class CreateQuoteDto
|
||||
[Display(Name = "Customer PO Number")]
|
||||
[StringLength(50)]
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
[Display(Name = "Tags")]
|
||||
[StringLength(500)]
|
||||
@@ -376,6 +378,7 @@ public class UpdateQuoteDto
|
||||
[Display(Name = "Customer PO Number")]
|
||||
[StringLength(50)]
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
[Display(Name = "Tags")]
|
||||
[StringLength(500)]
|
||||
|
||||
@@ -45,6 +45,19 @@ public class RemoveSeedDataOptions
|
||||
public bool Catalog { get; set; }
|
||||
public bool PricingTiers { get; set; }
|
||||
public bool OperatingCosts { get; set; }
|
||||
public bool Bills { get; set; }
|
||||
public bool Expenses { get; set; }
|
||||
public bool Workers { get; set; }
|
||||
public bool Vendors { get; set; }
|
||||
public bool NamedOvens { get; set; }
|
||||
public bool Appointments { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true, all removal blocks skip fingerprint matching and delete by CompanyId only.
|
||||
/// Use for demo resets where the goal is a full wipe regardless of which code version seeded
|
||||
/// the data. Never set this on a real tenant company.
|
||||
/// </summary>
|
||||
public bool ForceRemoveAll { get; set; }
|
||||
}
|
||||
|
||||
public class SeedDataResult
|
||||
|
||||
@@ -41,5 +41,12 @@ public class CustomerProfile : Profile
|
||||
opt => opt.MapFrom(src => !string.IsNullOrEmpty(src.ContactFirstName) || !string.IsNullOrEmpty(src.ContactLastName)
|
||||
? $"{src.ContactFirstName} {src.ContactLastName}".Trim()
|
||||
: string.Empty));
|
||||
|
||||
// CustomerContact
|
||||
CreateMap<CustomerContact, CustomerContactDto>();
|
||||
CreateMap<CreateCustomerContactDto, CustomerContact>();
|
||||
CreateMap<UpdateCustomerContactDto, CustomerContact>()
|
||||
.ForMember(dest => dest.Id, opt => opt.Ignore()); // Id is set by the controller, not mapped
|
||||
CreateMap<CustomerContact, UpdateCustomerContactDto>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ public class InvoiceProfile : Profile
|
||||
|
||||
CreateMap<Invoice, InvoiceDto>()
|
||||
.ForMember(d => d.JobNumber, o => o.MapFrom(s => s.Job != null ? s.Job.JobNumber : string.Empty))
|
||||
.ForMember(d => d.ProjectName, o => o.MapFrom(s => s.ProjectName ?? (s.Job != null ? s.Job.ProjectName : null)))
|
||||
.ForMember(d => d.CustomerName, o => o.MapFrom(s => s.Customer != null
|
||||
? (s.Customer.IsCommercial
|
||||
? s.Customer.CompanyName
|
||||
|
||||
@@ -98,12 +98,7 @@ public class PdfService : IPdfService
|
||||
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
|
||||
|
||||
page.Header().Element(c => ComposeInvoiceHeader(c, companyLogo, companyInfo, accentColor, invoiceDto));
|
||||
page.Content().Layers(layers =>
|
||||
{
|
||||
layers.PrimaryLayer().Element(c => ComposeInvoiceContent(c, invoiceDto, accentColor, template));
|
||||
if (invoiceDto.Status == InvoiceStatus.Paid)
|
||||
layers.Layer().Element(c => ComposePaidStamp(c));
|
||||
});
|
||||
page.Content().Element(c => ComposeInvoiceContent(c, invoiceDto, accentColor, template));
|
||||
page.Footer().AlignCenter().Text(text =>
|
||||
{
|
||||
text.CurrentPageNumber();
|
||||
@@ -148,8 +143,18 @@ public class PdfService : IPdfService
|
||||
column.Item().Text(cityLine).FontSize(9).FontColor(Colors.Grey.Darken1);
|
||||
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
|
||||
column.Item().Text(FormatPhoneNumber(companyInfo.Phone)).FontSize(9).FontColor(Colors.Grey.Darken1);
|
||||
if (!string.IsNullOrWhiteSpace(companyInfo.PrimaryContactEmail))
|
||||
column.Item().Text(companyInfo.PrimaryContactEmail).FontSize(9).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
|
||||
if (invoice.Status == InvoiceStatus.Paid)
|
||||
{
|
||||
row.RelativeItem().AlignCenter().AlignMiddle()
|
||||
.Border(2).BorderColor(Colors.Green.Darken1)
|
||||
.PaddingVertical(6).PaddingHorizontal(16)
|
||||
.Text("PAID").FontSize(20).Bold().FontColor(Colors.Green.Darken1).LetterSpacing(0.15f);
|
||||
}
|
||||
|
||||
row.RelativeItem().AlignRight().Column(column =>
|
||||
{
|
||||
column.Item().Text("INVOICE").FontSize(28).Bold().FontColor(accentColor);
|
||||
@@ -165,27 +170,6 @@ public class PdfService : IPdfService
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders a semi-transparent angled PAID stamp centred over the invoice content layer.
|
||||
/// Uses QuestPDF layout primitives (AlignCenter, AlignMiddle, Rotate, Opacity) so no
|
||||
/// external Skia/SkiaSharp dependency is needed.
|
||||
/// </summary>
|
||||
private static void ComposePaidStamp(IContainer container)
|
||||
{
|
||||
container
|
||||
.AlignCenter()
|
||||
.AlignMiddle()
|
||||
.Rotate(-45f)
|
||||
.Border(5)
|
||||
.BorderColor(Colors.Green.Darken2)
|
||||
.PaddingVertical(14)
|
||||
.PaddingHorizontal(28)
|
||||
.Text("PAID")
|
||||
.FontSize(80)
|
||||
.Bold()
|
||||
.FontColor(Colors.Green.Darken2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composes the body of the invoice PDF: bill-to address block, job reference, alternating-row
|
||||
/// line-item table, and a right-aligned totals block that conditionally shows discount, tax,
|
||||
@@ -217,6 +201,8 @@ public class PdfService : IPdfService
|
||||
c.Item().Text($"Job #: {invoice.JobNumber}");
|
||||
if (!string.IsNullOrWhiteSpace(invoice.CustomerPO))
|
||||
c.Item().Text($"PO #: {invoice.CustomerPO}");
|
||||
if (!string.IsNullOrWhiteSpace(invoice.ProjectName))
|
||||
c.Item().Text($"Project: {invoice.ProjectName}");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -609,6 +595,15 @@ public class PdfService : IPdfService
|
||||
row.RelativeItem().Text(quote.CustomerPO).FontSize(9);
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(quote.ProjectName))
|
||||
{
|
||||
column.Item().Row(row =>
|
||||
{
|
||||
row.ConstantItem(80).Text("Project:").FontSize(9);
|
||||
row.RelativeItem().Text(quote.ProjectName).FontSize(9);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,17 @@ public class Customer : BaseEntity
|
||||
public bool IsActive { get; set; } = true;
|
||||
public DateTime? LastContactDate { get; set; }
|
||||
|
||||
// CRM fields
|
||||
/// <summary>How the customer found the shop (Walk-In, Google Search, Customer Referral, etc.).</summary>
|
||||
public string? LeadSource { get; set; }
|
||||
|
||||
// Ship-to / alternate address (separate from billing address above)
|
||||
public string? ShipToAddress { get; set; }
|
||||
public string? ShipToCity { get; set; }
|
||||
public string? ShipToState { get; set; }
|
||||
public string? ShipToZipCode { get; set; }
|
||||
public string? ShipToCountry { get; set; }
|
||||
|
||||
// Notification preferences
|
||||
public bool NotifyByEmail { get; set; } = true;
|
||||
// NotifyBySms is only set to true after explicit staff-recorded consent (TCPA compliance)
|
||||
@@ -55,4 +66,5 @@ public class Customer : BaseEntity
|
||||
|
||||
public virtual ICollection<NotificationLog> NotificationLogs { get; set; } = new List<NotificationLog>();
|
||||
public virtual ICollection<Invoice> Invoices { get; set; } = new List<Invoice>();
|
||||
public virtual ICollection<CustomerContact> CustomerContacts { get; set; } = new List<CustomerContact>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PowderCoating.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// An additional contact person associated with a customer account.
|
||||
/// Commercial customers frequently have separate billing, operations, and drop-off contacts.
|
||||
/// The primary contact remains on the Customer entity; these are supplementary.
|
||||
/// </summary>
|
||||
public class CustomerContact : BaseEntity
|
||||
{
|
||||
public int CustomerId { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string FirstName { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(100)]
|
||||
public string? LastName { get; set; }
|
||||
|
||||
/// <summary>Job title / role at the company, e.g. "Purchasing Manager".</summary>
|
||||
[StringLength(100)]
|
||||
public string? Title { get; set; }
|
||||
|
||||
/// <summary>Functional role: Billing, Operations, Drop-off, Sales, General, etc.</summary>
|
||||
[StringLength(50)]
|
||||
public string? ContactRole { get; set; }
|
||||
|
||||
[StringLength(200)]
|
||||
public string? Email { get; set; }
|
||||
|
||||
[StringLength(20)]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
[StringLength(20)]
|
||||
public string? MobilePhone { get; set; }
|
||||
|
||||
[StringLength(500)]
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public virtual Customer? Customer { get; set; }
|
||||
}
|
||||
@@ -48,6 +48,7 @@ public class Invoice : BaseEntity
|
||||
public string? InternalNotes { get; set; }
|
||||
public string? Terms { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Early payment discount percentage (e.g., 2 means 2% discount).
|
||||
|
||||
@@ -47,6 +47,7 @@ public class Job : BaseEntity
|
||||
|
||||
// Additional Information
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public string? SpecialInstructions { get; set; }
|
||||
public string? InternalNotes { get; set; } // Internal notes from quote
|
||||
public string? Tags { get; set; }
|
||||
|
||||
@@ -88,6 +88,7 @@ public class Quote : BaseEntity
|
||||
public string? Terms { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public string? Tags { get; set; }
|
||||
|
||||
// Conversion tracking
|
||||
|
||||
@@ -152,6 +152,20 @@ public class CustomerNote : BaseEntity
|
||||
public virtual Customer Customer { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records an inventory item as a preferred powder for a specific customer.
|
||||
/// Shown on Customer Details for faster quoting of repeat orders.
|
||||
/// </summary>
|
||||
public class CustomerPreferredPowder : BaseEntity
|
||||
{
|
||||
public int CustomerId { get; set; }
|
||||
public int InventoryItemId { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public virtual Customer Customer { get; set; } = null!;
|
||||
public virtual InventoryItem InventoryItem { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class JobStatusHistory : BaseEntity
|
||||
{
|
||||
public int JobId { get; set; }
|
||||
|
||||
@@ -43,6 +43,8 @@ public interface IUnitOfWork : IDisposable
|
||||
IJobPhotoRepository JobPhotos { get; }
|
||||
IRepository<JobNote> JobNotes { get; }
|
||||
IRepository<CustomerNote> CustomerNotes { get; }
|
||||
IRepository<CustomerContact> CustomerContacts { get; }
|
||||
IRepository<CustomerPreferredPowder> CustomerPreferredPowders { get; }
|
||||
IRepository<JobStatusHistory> JobStatusHistory { get; }
|
||||
IRepository<PricingTier> PricingTiers { get; }
|
||||
|
||||
|
||||
@@ -230,6 +230,10 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
public DbSet<JobNote> JobNotes { get; set; }
|
||||
/// <summary>Free-text notes added to a customer record by staff; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<CustomerNote> CustomerNotes { get; set; }
|
||||
/// <summary>Additional contacts (billing, ops, drop-off) associated with a customer; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<CustomerContact> CustomerContacts { get; set; }
|
||||
/// <summary>Inventory items marked as frequently used for a customer; shown on Customer Details for faster quoting.</summary>
|
||||
public DbSet<CustomerPreferredPowder> CustomerPreferredPowders { get; set; }
|
||||
/// <summary>Audit trail of every status transition on a job, referencing the lookup-table statuses.</summary>
|
||||
public DbSet<JobStatusHistory> JobStatusHistory { get; set; }
|
||||
/// <summary>Customer pricing tiers (Standard, Preferred, Premium); tenant-filtered with soft delete.</summary>
|
||||
@@ -551,6 +555,8 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<CustomerNote>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<CustomerPreferredPowder>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
||||
@@ -1719,6 +1725,23 @@ modelBuilder.Entity<Job>()
|
||||
.HasIndex(cn => new { cn.CustomerId, cn.CreatedAt })
|
||||
.HasDatabaseName("IX_CustomerNotes_CustomerId_CreatedAt");
|
||||
|
||||
modelBuilder.Entity<CustomerPreferredPowder>()
|
||||
.HasIndex(p => new { p.CustomerId, p.InventoryItemId })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_CustomerPreferredPowders_CustomerId_InventoryItemId");
|
||||
|
||||
modelBuilder.Entity<CustomerPreferredPowder>()
|
||||
.HasOne(p => p.Customer)
|
||||
.WithMany()
|
||||
.HasForeignKey(p => p.CustomerId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<CustomerPreferredPowder>()
|
||||
.HasOne(p => p.InventoryItem)
|
||||
.WithMany()
|
||||
.HasForeignKey(p => p.InventoryItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// ===================================================================
|
||||
// END PERFORMANCE OPTIMIZATION INDEXES
|
||||
// ===================================================================
|
||||
@@ -2242,6 +2265,8 @@ modelBuilder.Entity<Job>()
|
||||
|
||||
if (entry.State == EntityState.Added)
|
||||
{
|
||||
// Only stamp if not already set — seeders set historical dates and rely on them being preserved.
|
||||
if (entity.CreatedAt == default)
|
||||
entity.CreatedAt = DateTime.UtcNow;
|
||||
entity.CreatedBy = currentUser;
|
||||
|
||||
|
||||
src/PowderCoating.Infrastructure/Migrations/20260608182208_AddProjectNameToQuotesAndJobs.Designer.cs
Generated
+11165
File diff suppressed because it is too large
Load Diff
+81
@@ -0,0 +1,81 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddProjectNameToQuotesAndJobs : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ProjectName",
|
||||
table: "Quotes",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ProjectName",
|
||||
table: "Jobs",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7640));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7646));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7647));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ProjectName",
|
||||
table: "Quotes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ProjectName",
|
||||
table: "Jobs");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9377));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9381));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9382));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+11168
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddInvoiceProjectName : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ProjectName",
|
||||
table: "Invoices",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2471));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2477));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2478));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ProjectName",
|
||||
table: "Invoices");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7640));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7646));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7647));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+11239
File diff suppressed because it is too large
Load Diff
+110
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCustomerPreferredPowders : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CustomerPreferredPowders",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
CustomerId = table.Column<int>(type: "int", nullable: false),
|
||||
InventoryItemId = table.Column<int>(type: "int", nullable: false),
|
||||
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CustomerPreferredPowders", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_CustomerPreferredPowders_Customers_CustomerId",
|
||||
column: x => x.CustomerId,
|
||||
principalTable: "Customers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_CustomerPreferredPowders_InventoryItems_InventoryItemId",
|
||||
column: x => x.InventoryItemId,
|
||||
principalTable: "InventoryItems",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9947));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9953));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9954));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CustomerPreferredPowders_CustomerId_InventoryItemId",
|
||||
table: "CustomerPreferredPowders",
|
||||
columns: new[] { "CustomerId", "InventoryItemId" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CustomerPreferredPowders_InventoryItemId",
|
||||
table: "CustomerPreferredPowders",
|
||||
column: "InventoryItemId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "CustomerPreferredPowders");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2471));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2477));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2478));
|
||||
}
|
||||
}
|
||||
}
|
||||
+11345
File diff suppressed because it is too large
Load Diff
+164
@@ -0,0 +1,164 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCustomerContactsAndCrmFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "LeadSource",
|
||||
table: "Customers",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ShipToAddress",
|
||||
table: "Customers",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ShipToCity",
|
||||
table: "Customers",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ShipToCountry",
|
||||
table: "Customers",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ShipToState",
|
||||
table: "Customers",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ShipToZipCode",
|
||||
table: "Customers",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CustomerContacts",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
CustomerId = table.Column<int>(type: "int", nullable: false),
|
||||
FirstName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||
LastName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
||||
Title = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
||||
ContactRole = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
|
||||
Email = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||
Phone = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
|
||||
MobilePhone = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
|
||||
Notes = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: true),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CustomerContacts", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_CustomerContacts_Customers_CustomerId",
|
||||
column: x => x.CustomerId,
|
||||
principalTable: "Customers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9129));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9137));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9138));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CustomerContacts_CustomerId",
|
||||
table: "CustomerContacts",
|
||||
column: "CustomerId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "CustomerContacts");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LeadSource",
|
||||
table: "Customers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShipToAddress",
|
||||
table: "Customers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShipToCity",
|
||||
table: "Customers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShipToCountry",
|
||||
table: "Customers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShipToState",
|
||||
table: "Customers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShipToZipCode",
|
||||
table: "Customers");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9947));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9953));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9954));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2818,6 +2818,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<DateTime?>("LastContactDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("LeadSource")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("MobilePhone")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -2836,6 +2839,21 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<int?>("PricingTierId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ShipToAddress")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ShipToCity")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ShipToCountry")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ShipToState")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ShipToZipCode")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("SmsConsentMethod")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -2894,6 +2912,81 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.ToTable("Customers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerContact", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("ContactRole")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("nvarchar(50)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("CustomerId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("nvarchar(200)");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<string>("MobilePhone")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasMaxLength(20)
|
||||
.HasColumnType("nvarchar(20)");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("nvarchar(100)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CustomerId");
|
||||
|
||||
b.ToTable("CustomerContacts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerNote", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -2944,6 +3037,58 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.ToTable("CustomerNotes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerPreferredPowder", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("CustomerId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("InventoryItemId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InventoryItemId");
|
||||
|
||||
b.HasIndex("CustomerId", "InventoryItemId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_CustomerPreferredPowders_CustomerId_InventoryItemId");
|
||||
|
||||
b.ToTable("CustomerPreferredPowders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.DashboardTip", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -4269,6 +4414,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("PreparedById")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("ProjectName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("PublicViewToken")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -4560,6 +4708,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("PricingBreakdownJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ProjectName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("QuoteId")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -7053,7 +7204,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9377),
|
||||
CreatedAt = new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9129),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -7064,7 +7215,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9381),
|
||||
CreatedAt = new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9137),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -7075,7 +7226,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9382),
|
||||
CreatedAt = new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9138),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
@@ -7385,6 +7536,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<decimal>("ProfitPercent")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("ProjectName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ProspectAddress")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -9474,6 +9628,17 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Navigation("PricingTier");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerContact", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.Customer", "Customer")
|
||||
.WithMany("CustomerContacts")
|
||||
.HasForeignKey("CustomerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Customer");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerNote", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.Customer", "Customer")
|
||||
@@ -9485,6 +9650,25 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Navigation("Customer");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerPreferredPowder", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.Customer", "Customer")
|
||||
.WithMany()
|
||||
.HasForeignKey("CustomerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("PowderCoating.Core.Entities.InventoryItem", "InventoryItem")
|
||||
.WithMany()
|
||||
.HasForeignKey("InventoryItemId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Customer");
|
||||
|
||||
b.Navigation("InventoryItem");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.Deposit", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.Invoice", "AppliedToInvoice")
|
||||
@@ -10980,6 +11164,8 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b =>
|
||||
{
|
||||
b.Navigation("CustomerContacts");
|
||||
|
||||
b.Navigation("CustomerNotes");
|
||||
|
||||
b.Navigation("Invoices");
|
||||
|
||||
@@ -70,6 +70,8 @@ public class UnitOfWork : IUnitOfWork
|
||||
private IJobPhotoRepository? _jobPhotos;
|
||||
private IRepository<JobNote>? _jobNotes;
|
||||
private IRepository<CustomerNote>? _customerNotes;
|
||||
private IRepository<CustomerContact>? _customerContacts;
|
||||
private IRepository<CustomerPreferredPowder>? _customerPreferredPowders;
|
||||
private IRepository<JobStatusHistory>? _jobStatusHistory;
|
||||
private IRepository<PricingTier>? _pricingTiers;
|
||||
|
||||
@@ -321,6 +323,11 @@ public class UnitOfWork : IUnitOfWork
|
||||
/// <summary>Repository for <see cref="CustomerNote"/> free-text staff notes on customer records; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<CustomerNote> CustomerNotes =>
|
||||
_customerNotes ??= new Repository<CustomerNote>(_context);
|
||||
/// <summary>Repository for <see cref="CustomerContact"/> additional contacts (billing, ops, drop-off) on commercial accounts; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<CustomerContact> CustomerContacts =>
|
||||
_customerContacts ??= new Repository<CustomerContact>(_context);
|
||||
public IRepository<CustomerPreferredPowder> CustomerPreferredPowders =>
|
||||
_customerPreferredPowders ??= new Repository<CustomerPreferredPowder>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="JobStatusHistory"/> status-transition audit records; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<JobStatusHistory> JobStatusHistory =>
|
||||
|
||||
@@ -605,6 +605,12 @@ public class CsvImportService : ICsvImportService
|
||||
PaymentTerms = record.PaymentTerms?.Trim() ?? "Net 30",
|
||||
IsTaxExempt = record.TaxExempt ?? false,
|
||||
GeneralNotes = record.Notes?.Trim(),
|
||||
LeadSource = record.LeadSource?.Trim(),
|
||||
ShipToAddress = record.ShipToAddress?.Trim(),
|
||||
ShipToCity = record.ShipToCity?.Trim(),
|
||||
ShipToState = record.ShipToState?.Trim(),
|
||||
ShipToZipCode = record.ShipToZipCode?.Trim(),
|
||||
ShipToCountry = record.ShipToCountry?.Trim(),
|
||||
IsActive = record.IsActive ?? true,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
@@ -1284,6 +1290,7 @@ public class CsvImportService : ICsvImportService
|
||||
Total = record.Total,
|
||||
Notes = record.Notes?.Trim(),
|
||||
Terms = record.TermsAndConditions?.Trim(),
|
||||
ProjectName = record.ProjectName?.Trim(),
|
||||
IsCommercial = customerId.HasValue,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
@@ -1557,6 +1564,7 @@ public class CsvImportService : ICsvImportService
|
||||
CustomerPO = record.CustomerPO?.Trim(),
|
||||
SpecialInstructions = record.SpecialInstructions?.Trim(),
|
||||
InternalNotes = record.Notes?.Trim(),
|
||||
ProjectName = record.ProjectName?.Trim(),
|
||||
Description = record.Description?.Trim()
|
||||
?? record.SpecialInstructions?.Trim()
|
||||
?? "Imported job",
|
||||
@@ -3149,6 +3157,7 @@ public class CsvImportService : ICsvImportService
|
||||
existing.CustomerPO = string.IsNullOrWhiteSpace(record.CustomerPO) ? null : record.CustomerPO.Trim();
|
||||
existing.Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim();
|
||||
existing.Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim();
|
||||
existing.ProjectName = string.IsNullOrWhiteSpace(record.ProjectName) ? null : record.ProjectName.Trim();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
result.SuccessCount++;
|
||||
}
|
||||
@@ -3172,7 +3181,8 @@ public class CsvImportService : ICsvImportService
|
||||
AmountPaid = record.AmountPaid,
|
||||
CustomerPO = string.IsNullOrWhiteSpace(record.CustomerPO) ? null : record.CustomerPO.Trim(),
|
||||
Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim(),
|
||||
Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim()
|
||||
Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim(),
|
||||
ProjectName = string.IsNullOrWhiteSpace(record.ProjectName) ? null : record.ProjectName.Trim()
|
||||
};
|
||||
await _unitOfWork.Invoices.AddAsync(invoice);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
@@ -47,6 +47,8 @@ public class EmailService : IEmailService
|
||||
if (!_hostEnvironment.IsProduction())
|
||||
subject = $"[{_hostEnvironment.EnvironmentName}] {subject}";
|
||||
|
||||
(toEmail, toName) = RedirectIfNonProd(toEmail, toName);
|
||||
|
||||
var fromEmail = _configuration["SendGrid:FromEmail"] ?? "noreply@example.com";
|
||||
var fromName = _configuration["SendGrid:FromName"] ?? "Powder Coating";
|
||||
|
||||
@@ -104,6 +106,8 @@ public class EmailService : IEmailService
|
||||
if (!_hostEnvironment.IsProduction())
|
||||
subject = $"[{_hostEnvironment.EnvironmentName}] {subject}";
|
||||
|
||||
(toEmail, toName) = RedirectIfNonProd(toEmail, toName);
|
||||
|
||||
var fromEmail = _configuration["SendGrid:FromEmail"] ?? "noreply@example.com";
|
||||
var fromName = _configuration["SendGrid:FromName"] ?? "Powder Coating";
|
||||
|
||||
@@ -138,6 +142,20 @@ public class EmailService : IEmailService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In non-production environments, redirects outbound email to <c>SendGrid:DevRedirectEmail</c>
|
||||
/// so real customers are never contacted outside of production. Double-gated on environment
|
||||
/// name AND the config value so a misconfigured prod deploy can't accidentally redirect.
|
||||
/// </summary>
|
||||
private (string email, string name) RedirectIfNonProd(string toEmail, string toName)
|
||||
{
|
||||
if (_hostEnvironment.IsProduction()) return (toEmail, toName);
|
||||
var devEmail = _configuration["SendGrid:DevRedirectEmail"];
|
||||
if (string.IsNullOrWhiteSpace(devEmail)) return (toEmail, toName);
|
||||
_logger.LogWarning("Non-production environment: redirecting email from {Original} to dev address {Dev}", toEmail, devEmail);
|
||||
return (devEmail, $"[DEV → {toName} <{toEmail}>]");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends the built SendGrid message and interprets the HTTP response. Extracted so both
|
||||
/// send methods share identical dispatch and logging logic.
|
||||
|
||||
@@ -94,7 +94,7 @@ public class NotificationService : INotificationService
|
||||
quote.CompanyId, NotificationType.QuoteSent, values,
|
||||
$"Your Quote {quote.QuoteNumber} from {companyName}");
|
||||
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, baseUrl);
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, baseUrl, replyToEmail);
|
||||
var plainText = StripHtml(fullHtml);
|
||||
|
||||
var (success, error) = await _emailService.SendEmailAsync(
|
||||
@@ -137,7 +137,7 @@ public class NotificationService : INotificationService
|
||||
quote.CompanyId, NotificationType.QuoteSent, values,
|
||||
$"Your Quote {quote.QuoteNumber} from {companyName}");
|
||||
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, baseUrl);
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, baseUrl, replyToEmail);
|
||||
var plainText = StripHtml(fullHtml);
|
||||
|
||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||
@@ -300,7 +300,7 @@ public class NotificationService : INotificationService
|
||||
quote.CompanyId, NotificationType.QuoteApproved, values,
|
||||
$"Quote {quote.QuoteNumber} Approved — {companyName}");
|
||||
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
|
||||
var plainText = StripHtml(fullHtml);
|
||||
|
||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||
@@ -383,7 +383,7 @@ public class NotificationService : INotificationService
|
||||
var (subject, htmlBody) = await GetRenderedEmailAsync(
|
||||
job.CompanyId, notifType, values, defaultSubject);
|
||||
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
|
||||
var plainText = StripHtml(fullHtml);
|
||||
|
||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||
@@ -451,7 +451,7 @@ public class NotificationService : INotificationService
|
||||
job.CompanyId, NotificationType.JobCompleted, values,
|
||||
$"Job {job.JobNumber} Complete — {companyName}");
|
||||
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
|
||||
var plainText = StripHtml(fullHtml);
|
||||
|
||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||
@@ -674,7 +674,7 @@ public class NotificationService : INotificationService
|
||||
""";
|
||||
}
|
||||
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
|
||||
var plainText = !string.IsNullOrEmpty(paymentUrl)
|
||||
? StripHtml(htmlBody) + $"\r\n\r\nPay online: {paymentUrl}"
|
||||
: StripHtml(fullHtml);
|
||||
@@ -793,7 +793,7 @@ public class NotificationService : INotificationService
|
||||
invoice.CompanyId, NotificationType.PaymentReceived, values,
|
||||
$"Payment Received — Invoice {invoice.InvoiceNumber}");
|
||||
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
|
||||
var plainText = StripHtml(fullHtml);
|
||||
|
||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||
@@ -867,7 +867,7 @@ public class NotificationService : INotificationService
|
||||
invoice.CompanyId, NotificationType.PaymentReminder, values,
|
||||
$"Payment Reminder — Invoice {invoice.InvoiceNumber} ({daysOverdue} days overdue)");
|
||||
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
|
||||
var plainText = StripHtml(fullHtml);
|
||||
|
||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||
@@ -971,7 +971,7 @@ public class NotificationService : INotificationService
|
||||
var (subject, htmlBody) = await GetRenderedEmailAsync(
|
||||
quote.CompanyId, notificationType, values, defaultSubject);
|
||||
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, await GetBaseUrlAsync());
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, await GetBaseUrlAsync(), replyToEmail);
|
||||
var plainText = StripHtml(fullHtml);
|
||||
|
||||
var (success, error) = await _emailService.SendEmailAsync(
|
||||
@@ -1218,7 +1218,7 @@ public class NotificationService : INotificationService
|
||||
var (custSubject, custHtml) = await GetRenderedEmailAsync(
|
||||
appointment.CompanyId, NotificationType.AppointmentReminder, customerValues, defaultSubject);
|
||||
|
||||
var custFullHtml = AppendUnsubscribeFooterHtml(custHtml, customer.UnsubscribeToken, company, baseUrl);
|
||||
var custFullHtml = AppendUnsubscribeFooterHtml(custHtml, customer.UnsubscribeToken, company, baseUrl, replyToEmail);
|
||||
var custPlainText = StripHtml(custFullHtml);
|
||||
|
||||
var (custOk, custErr, custLog) = await SendToEmailListAsync(
|
||||
@@ -1388,17 +1388,25 @@ public class NotificationService : INotificationService
|
||||
/// <summary>
|
||||
/// Appends CAN-SPAM required footer as HTML.
|
||||
/// </summary>
|
||||
private static string AppendUnsubscribeFooterHtml(string htmlBody, string? token, Company? company = null, string? baseUrl = null)
|
||||
private static string AppendUnsubscribeFooterHtml(string htmlBody, string? token, Company? company = null, string? baseUrl = null, string? replyToEmail = null)
|
||||
{
|
||||
var hasUnsubscribeUrl = !string.IsNullOrEmpty(token) && !string.IsNullOrEmpty(baseUrl);
|
||||
var hasAddress = company != null && !string.IsNullOrWhiteSpace(company.Address);
|
||||
var hasReplyTo = !string.IsNullOrWhiteSpace(replyToEmail);
|
||||
|
||||
if (!hasUnsubscribeUrl && !hasAddress)
|
||||
if (!hasUnsubscribeUrl && !hasAddress && !hasReplyTo)
|
||||
return htmlBody;
|
||||
|
||||
var footer = "<hr style=\"border: none; border-top: 1px solid #eee; margin: 24px 0;\">" +
|
||||
"<p style=\"font-size: 0.8em; color: #888; margin: 0;\">";
|
||||
|
||||
if (hasReplyTo)
|
||||
{
|
||||
var encodedEmail = WebUtility.HtmlEncode(replyToEmail!);
|
||||
footer += $"Questions? Reply to this email or contact us at <a href=\"mailto:{encodedEmail}\" style=\"color: #888;\">{encodedEmail}</a>";
|
||||
if (hasAddress || hasUnsubscribeUrl) footer += "<br>";
|
||||
}
|
||||
|
||||
if (hasAddress)
|
||||
{
|
||||
var addressLine = BuildAddressLine(company!);
|
||||
@@ -1535,7 +1543,15 @@ public class NotificationService : INotificationService
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||
|
||||
return (prefs?.EmailFromAddress, prefs?.EmailFromName);
|
||||
var email = prefs?.EmailFromAddress;
|
||||
var name = prefs?.EmailFromName;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
_logger.LogWarning("No Reply-To email configured for company {CompanyId} — outgoing emails will show platform sender as reply address", companyId);
|
||||
else
|
||||
_logger.LogDebug("Reply-To for company {CompanyId}: {ReplyToEmail}", companyId, email);
|
||||
|
||||
return (email, name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -44,8 +44,11 @@ public partial class SeedDataService
|
||||
var accounts = new List<Account>
|
||||
{
|
||||
// ── ASSETS ────────────────────────────────────────────────────────
|
||||
new Account { AccountNumber = "1000", Name = "Checking Account", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Checking, IsSystem = true, IsActive = true, Description = "Primary business checking account", CompanyId = company.Id, CreatedAt = now },
|
||||
new Account { AccountNumber = "1010", Name = "Savings Account", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Savings, IsSystem = false, IsActive = true, Description = "Business savings account", CompanyId = company.Id, CreatedAt = now },
|
||||
// Opening balances represent accumulated cash before the 12-month seeded history window.
|
||||
// Without them, 12 months of seeded expenses outpace ~3 months of seeded revenue and
|
||||
// the checking account shows a large negative — unrealistic for a demo.
|
||||
new Account { AccountNumber = "1000", Name = "Checking Account", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Checking, IsSystem = true, IsActive = true, Description = "Primary business checking account", OpeningBalance = 75_000m, OpeningBalanceDate = now.AddYears(-1), CurrentBalance = 75_000m, CompanyId = company.Id, CreatedAt = now },
|
||||
new Account { AccountNumber = "1010", Name = "Savings Account", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Savings, IsSystem = false, IsActive = true, Description = "Business savings account", OpeningBalance = 14_500m, OpeningBalanceDate = now.AddYears(-1), CurrentBalance = 14_500m, CompanyId = company.Id, CreatedAt = now },
|
||||
new Account { AccountNumber = "1100", Name = "Accounts Receivable", AccountType = AccountType.Asset, AccountSubType = AccountSubType.AccountsReceivable, IsSystem = true, IsActive = true, Description = "Amounts owed by customers for services", CompanyId = company.Id, CreatedAt = now },
|
||||
new Account { AccountNumber = "1200", Name = "Inventory - Powder", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Inventory, IsSystem = false, IsActive = true, Description = "Powder coating materials in stock", CompanyId = company.Id, CreatedAt = now },
|
||||
new Account { AccountNumber = "1210", Name = "Inventory - Consumables", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Inventory, IsSystem = false, IsActive = true, Description = "Masking, tape, and other consumables", CompanyId = company.Id, CreatedAt = now },
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
|
||||
public partial class SeedDataService
|
||||
{
|
||||
/// <summary>
|
||||
/// Seeds 8 <see cref="AiItemPrediction"/> demo records and attaches them to the first
|
||||
/// 8 eligible <see cref="QuoteItem"/> records, marking those items as AI-analysed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// "Eligible" means <c>SurfaceAreaSqFt > 0</c>, not a labor item, and not already
|
||||
/// linked to a prediction. This ensures the seeder is safe to run even if a partial seed
|
||||
/// left some items pre-linked.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Each prediction record captures a realistic AI analysis: predicted surface area,
|
||||
/// estimated minutes, complexity tier, unit price, confidence level, reasoning text, and
|
||||
/// comma-separated AI tags. Three of the eight items have <c>UserOverrodeEstimate = true</c>
|
||||
/// to demonstrate the override-tracking feature on the AI Accuracy report.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Items are updated in-place with <c>IsAiItem = true</c> and the FK
|
||||
/// <c>AiPredictionId</c> pointing to the new prediction. <c>SaveChangesAsync</c> is called
|
||||
/// per item so any single FK conflict (unlikely in a fresh seed) does not abort the others.
|
||||
/// </para>
|
||||
/// Idempotency: returns 0 immediately if any AiItemPrediction records already exist for
|
||||
/// the company.
|
||||
/// </remarks>
|
||||
/// <param name="company">The tenant company to seed predictions for.</param>
|
||||
/// <returns>Number of prediction records inserted, or 0 if already seeded.</returns>
|
||||
private async Task<int> SeedAiPredictionsAsync(Company company)
|
||||
{
|
||||
var existingCount = await _context.Set<AiItemPrediction>()
|
||||
.IgnoreQueryFilters()
|
||||
.CountAsync(p => p.CompanyId == company.Id && !p.IsDeleted);
|
||||
|
||||
if (existingCount > 0)
|
||||
return 0;
|
||||
|
||||
// Grab the first 8 eligible quote items ordered by id for determinism
|
||||
var quoteItems = await _context.Set<QuoteItem>()
|
||||
.IgnoreQueryFilters()
|
||||
.Include(qi => qi.Quote)
|
||||
.Where(qi => qi.CompanyId == company.Id
|
||||
&& !qi.IsDeleted
|
||||
&& qi.SurfaceAreaSqFt > 0
|
||||
&& !qi.IsLaborItem
|
||||
&& qi.AiPredictionId == null)
|
||||
.OrderBy(qi => qi.Id)
|
||||
.Take(8)
|
||||
.ToListAsync();
|
||||
|
||||
if (quoteItems.Count == 0)
|
||||
return 0;
|
||||
|
||||
// Per-slot prediction specs — deterministic, varied across complexity/confidence tiers.
|
||||
// PredictedSqFt is intentionally close but NOT identical to the actual item SqFt so the
|
||||
// AI Accuracy report shows realistic prediction deltas.
|
||||
var specs = new[]
|
||||
{
|
||||
// slot 0 — complex automotive, AI nailed it
|
||||
( sqft: 13.5m, mins: 88, complexity: "Complex", confidence: "High",
|
||||
price: 125.00m, tags: "automotive,tubular,custom", rounds: 1, overrode: false,
|
||||
reasoning: "Detected a tubular motorcycle frame with multiple weld joints. High complexity due to intricate geometry and masking requirements around bearing surfaces. Confidence high — similar frames appear frequently in training data." ),
|
||||
|
||||
// slot 1 — wheel set, quick read, accepted as-is
|
||||
( sqft: 11.2m, mins: 42, complexity: "Simple", confidence: "High",
|
||||
price: 98.00m, tags: "automotive,wheels,aluminum", rounds: 1, overrode: false,
|
||||
reasoning: "Four aluminum wheels, uniform shape, minimal masking needed. Straightforward batch candidate for the main oven. Estimated surface area based on standard 18\" wheel profile." ),
|
||||
|
||||
// slot 2 — bumper job, user bumped sqft slightly
|
||||
( sqft: 14.8m, mins: 62, complexity: "Moderate", confidence: "Medium",
|
||||
price: 135.00m, tags: "automotive,bumper,off-road", rounds: 2, overrode: true,
|
||||
reasoning: "Steel off-road bumper and rock sliders. Moderate complexity — flat stock with mounting tabs. Second image round requested for accurate rock slider dimensions. User adjusted surface area slightly after physical measurement." ),
|
||||
|
||||
// slot 3 — large gate, low confidence, user corrected price
|
||||
( sqft: 34.2m, mins: 195, complexity: "Complex", confidence: "Low",
|
||||
price: 310.00m, tags: "architectural,gate,ornamental", rounds: 2, overrode: true,
|
||||
reasoning: "Wrought iron entry gate with decorative scrollwork. Low confidence due to depth ambiguity in photos — scrollwork surface area is difficult to estimate from images alone. Recommend physical measurement before finalising price. User overrode unit price after measuring on-site." ),
|
||||
|
||||
// slot 4 — patio furniture, solid read
|
||||
( sqft: 22.8m, mins: 52, complexity: "Moderate", confidence: "High",
|
||||
price: 195.00m, tags: "furniture,outdoor,patio", rounds: 1, overrode: false,
|
||||
reasoning: "Six-piece patio furniture set: four chairs, one table, one side table. Powder-coated tubular steel, standard outdoor finish. Good photo coverage — confidence high. Recommend Textured Beige or Satin Bronze for exterior durability." ),
|
||||
|
||||
// slot 5 — handrail, accepted price
|
||||
( sqft: 39.0m, mins: 118, complexity: "Moderate", confidence: "High",
|
||||
price: 342.00m, tags: "architectural,handrail,railing", rounds: 1, overrode: false,
|
||||
reasoning: "40-foot steel handrail system, square tube construction. Consistent profile makes area calculation straightforward. Standard Gloss Black most common finish for this application — confirmed with customer." ),
|
||||
|
||||
// slot 6 — brake calipers, small & simple
|
||||
( sqft: 3.8m, mins: 30, complexity: "Simple", confidence: "High",
|
||||
price: 65.00m, tags: "automotive,brake,caliper", rounds: 1, overrode: false,
|
||||
reasoning: "Set of four brake calipers, cast iron with machined mating surfaces. Masking required on piston bores and bleed nipples. Candy Red most requested finish. High confidence — calipers are a common item with well-established pricing." ),
|
||||
|
||||
// slot 7 — bicycle frame, two-round conversation
|
||||
( sqft: 6.1m, mins: 65, complexity: "Moderate", confidence: "Medium",
|
||||
price: 82.00m, tags: "recreational,bicycle,frame", rounds: 2, overrode: true,
|
||||
reasoning: "Road bicycle frame, aluminium alloy. Second image round needed to assess cable routing channels and dropout geometry. Moderate complexity due to small-radius bends. User adjusted surface area after AI initially underestimated top-tube length." )
|
||||
};
|
||||
|
||||
var seeded = 0;
|
||||
|
||||
for (int i = 0; i < quoteItems.Count && i < specs.Length; i++)
|
||||
{
|
||||
var item = quoteItems[i];
|
||||
var s = specs[i];
|
||||
|
||||
var prediction = new AiItemPrediction
|
||||
{
|
||||
PredictedSurfaceAreaSqFt = s.sqft,
|
||||
PredictedMinutes = s.mins,
|
||||
PredictedComplexity = s.complexity,
|
||||
PredictedUnitPrice = s.price,
|
||||
Confidence = s.confidence,
|
||||
Reasoning = s.reasoning,
|
||||
AiTags = s.tags,
|
||||
ConversationRounds = s.rounds,
|
||||
UserOverrodeEstimate = s.overrode,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = item.CreatedAt
|
||||
};
|
||||
|
||||
await _context.Set<AiItemPrediction>().AddAsync(prediction);
|
||||
await _context.SaveChangesAsync();
|
||||
seeded++;
|
||||
|
||||
// Mark the quote item as AI-analysed and link the prediction
|
||||
item.IsAiItem = true;
|
||||
item.AiPredictionId = prediction.Id;
|
||||
item.AiTags = s.tags;
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return seeded;
|
||||
}
|
||||
}
|
||||
@@ -346,6 +346,113 @@ public partial class SeedDataService
|
||||
// Get status IDs by code for easy assignment
|
||||
var scheduledStatusId = appointmentStatuses.First(s => s.StatusCode == "SCHEDULED").Id;
|
||||
var confirmedStatusId = appointmentStatuses.First(s => s.StatusCode == "CONFIRMED").Id;
|
||||
var completedStatusId = appointmentStatuses.First(s => s.StatusCode == "COMPLETED").Id;
|
||||
var cancelledStatusId = appointmentStatuses.First(s => s.StatusCode == "CANCELLED").Id;
|
||||
var noShowStatusId = appointmentStatuses.First(s => s.StatusCode == "NO_SHOW").Id;
|
||||
var rescheduledStatusId = appointmentStatuses.First(s => s.StatusCode == "RESCHEDULED").Id;
|
||||
|
||||
// ── PAST APPOINTMENTS (last 90 days) — Completed, Cancelled, No Show ──────
|
||||
// Walks backward through weekdays; ~40% chance of an appointment per day
|
||||
// for ~25 records spread naturally across the history window.
|
||||
var pastRandom = new Random(77); // separate seed keeps past/future independent
|
||||
var pastAppointmentSeq = 1;
|
||||
|
||||
static string? CancelNote(Random r)
|
||||
{
|
||||
var reasons = new[]
|
||||
{
|
||||
"Customer cancelled — rescheduling for next week.",
|
||||
"Customer cancelled — no reason given.",
|
||||
"Shop closed for equipment maintenance.",
|
||||
"Customer called to reschedule.",
|
||||
"Customer unavailable — will call back.",
|
||||
"Cancelled by shop — scheduling conflict."
|
||||
};
|
||||
return reasons[r.Next(reasons.Length)];
|
||||
}
|
||||
|
||||
for (int daysBack = 1; daysBack <= 90 && pastAppointmentSeq <= 25; daysBack++)
|
||||
{
|
||||
var pastDate = DateTime.Today.AddDays(-daysBack);
|
||||
if (pastDate.DayOfWeek == DayOfWeek.Saturday || pastDate.DayOfWeek == DayOfWeek.Sunday)
|
||||
continue;
|
||||
if (pastRandom.Next(100) >= 40) // ~40% chance = ~26 weekday hits over 90 days
|
||||
continue;
|
||||
|
||||
var aptType = appointmentTypes[pastRandom.Next(appointmentTypes.Count)];
|
||||
var customer = customers[pastRandom.Next(customers.Count)];
|
||||
|
||||
int startHour = pastRandom.Next(8, 17);
|
||||
int startMinute = pastRandom.Next(0, 4) * 15;
|
||||
var aptStart = new DateTime(pastDate.Year, pastDate.Month, pastDate.Day, startHour, startMinute, 0, DateTimeKind.Utc);
|
||||
int duration = pastRandom.Next(1, 5) * 30;
|
||||
var aptEnd = aptStart.AddMinutes(duration);
|
||||
|
||||
// 60% Completed, 25% Cancelled, 10% No Show, 5% Rescheduled
|
||||
int roll = pastRandom.Next(100);
|
||||
int pastStatusId;
|
||||
DateTime? actualStart = null, actualEnd = null;
|
||||
string? pastNotes = null;
|
||||
|
||||
if (roll < 60)
|
||||
{
|
||||
pastStatusId = completedStatusId;
|
||||
actualStart = aptStart.AddMinutes(pastRandom.Next(-5, 11)); // ±5–10 min variance
|
||||
actualEnd = aptEnd.AddMinutes(pastRandom.Next(-10, 16));
|
||||
}
|
||||
else if (roll < 85)
|
||||
{
|
||||
pastStatusId = cancelledStatusId;
|
||||
pastNotes = CancelNote(pastRandom);
|
||||
}
|
||||
else if (roll < 95)
|
||||
{
|
||||
pastStatusId = noShowStatusId;
|
||||
pastNotes = "Customer did not arrive. Follow-up call left.";
|
||||
}
|
||||
else
|
||||
{
|
||||
pastStatusId = rescheduledStatusId;
|
||||
pastNotes = "Rescheduled at customer request — see follow-up appointment.";
|
||||
}
|
||||
|
||||
// Optional job link (35% chance for past JOB_WORK; 15% for others)
|
||||
int? pastJobId = null;
|
||||
if (jobs.Any())
|
||||
{
|
||||
int linkChance = aptType.TypeCode == "JOB_WORK" ? 35 : 15;
|
||||
if (pastRandom.Next(100) < linkChance)
|
||||
pastJobId = jobs[pastRandom.Next(jobs.Count)].Id;
|
||||
}
|
||||
|
||||
string? assignedId = null;
|
||||
if (workers.Any() && pastRandom.Next(100) < 60)
|
||||
assignedId = workers[pastRandom.Next(workers.Count)].Id;
|
||||
|
||||
var pastLabel = string.IsNullOrEmpty(customer.CompanyName) ? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim() : customer.CompanyName;
|
||||
var pastTitle = $"{pastLabel} — {appointmentTitles[aptType.TypeCode][pastRandom.Next(appointmentTitles[aptType.TypeCode].Length)]}";
|
||||
|
||||
appointments.Add(new Appointment
|
||||
{
|
||||
AppointmentNumber = $"APT-{pastDate:yyMM}-{pastAppointmentSeq++:D4}",
|
||||
CustomerId = customer.Id,
|
||||
JobId = pastJobId,
|
||||
AppointmentStatusId = pastStatusId,
|
||||
AppointmentTypeId = aptType.Id,
|
||||
AssignedUserId = assignedId,
|
||||
Title = pastTitle,
|
||||
ScheduledStartTime = aptStart,
|
||||
ScheduledEndTime = aptEnd,
|
||||
ActualStartTime = actualStart,
|
||||
ActualEndTime = actualEnd,
|
||||
IsAllDay = false,
|
||||
IsReminderEnabled = false, // reminders don't fire for past appointments
|
||||
ReminderMinutesBefore = 30,
|
||||
Notes = pastNotes,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = aptStart.AddDays(-pastRandom.Next(1, 8)) // booked 1–7 days ahead
|
||||
});
|
||||
}
|
||||
|
||||
// Generate 50 appointments across next 60 days (weekdays only)
|
||||
int appointmentsCreated = 0;
|
||||
@@ -388,7 +495,8 @@ public partial class SeedDataService
|
||||
int statusId = random.Next(100) < 80 ? scheduledStatusId : confirmedStatusId;
|
||||
|
||||
// Title
|
||||
string title = $"{customer.CompanyName} - {appointmentTitles[appointmentType.TypeCode][random.Next(appointmentTitles[appointmentType.TypeCode].Length)]}";
|
||||
string customerLabel = string.IsNullOrEmpty(customer.CompanyName) ? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim() : customer.CompanyName;
|
||||
string title = $"{customerLabel} - {appointmentTitles[appointmentType.TypeCode][random.Next(appointmentTitles[appointmentType.TypeCode].Length)]}";
|
||||
|
||||
// Optional job link (40% chance if type is JOB_WORK, 20% for others)
|
||||
int? jobId = null;
|
||||
|
||||
@@ -86,8 +86,9 @@ public partial class SeedDataService
|
||||
|
||||
var prismatic = vendors.FirstOrDefault(v => v.CompanyName.Contains("Prismatic")) ?? vendors.FirstOrDefault();
|
||||
var columbia = vendors.FirstOrDefault(v => v.CompanyName.Contains("Columbia")) ?? vendors.FirstOrDefault();
|
||||
var aceHardware = vendors.FirstOrDefault(v => v.CompanyName.Contains("Ace")) ?? vendors.FirstOrDefault();
|
||||
var fastenal = vendors.FirstOrDefault(v => v.CompanyName.Contains("Fastenal")) ?? vendors.FirstOrDefault();
|
||||
var grainger = vendors.FirstOrDefault(v => v.CompanyName.Contains("Grainger")) ?? vendors.FirstOrDefault();
|
||||
var harbor = vendors.FirstOrDefault(v => v.CompanyName.Contains("Harbor")) ?? vendors.FirstOrDefault();
|
||||
var localSupply = vendors.FirstOrDefault(v => v.CompanyName.Contains("Local")) ?? vendors.FirstOrDefault();
|
||||
var fallback = vendors.FirstOrDefault();
|
||||
|
||||
if (fallback == null)
|
||||
@@ -125,6 +126,105 @@ public partial class SeedDataService
|
||||
return bill;
|
||||
}
|
||||
|
||||
// ── ELECTRIC UTILITY — months −12 through −4 (paid) ─────────────────
|
||||
// Monthly bills going back 12 months fill the AP and expense trend charts.
|
||||
// Amounts reflect seasonal variation (higher summer AC, higher winter heat).
|
||||
var elecAmounts = new decimal[] { 640m, 620m, 595m, 580m, 570m, 558m, 545m, 562m, 574m };
|
||||
for (int m = 12; m >= 4; m--)
|
||||
{
|
||||
var bd = now.AddDays(-(m * 30 + 2));
|
||||
var dd = bd.AddDays(15);
|
||||
var pd = dd.AddDays(2);
|
||||
var amt = elecAmounts[12 - m];
|
||||
await AddBill(new Bill
|
||||
{
|
||||
VendorInvoiceNumber = $"ELEC-HIST-{m:D2}",
|
||||
VendorId = fallback.Id,
|
||||
APAccountId = apAccount.Id,
|
||||
BillDate = bd,
|
||||
DueDate = dd,
|
||||
Status = BillStatus.Paid,
|
||||
Terms = "Due on Receipt",
|
||||
Memo = $"Electric — {m} months ago",
|
||||
SubTotal = amt,
|
||||
Total = amt,
|
||||
AmountPaid = amt,
|
||||
CreatedAt = bd,
|
||||
LineItems = { new BillLineItem { AccountId = utilitiesAccount?.Id, Description = "Commercial Electric — monthly usage", Quantity = 1, UnitPrice = amt, Amount = amt, DisplayOrder = 1 } }
|
||||
}, new BillPayment
|
||||
{
|
||||
VendorId = fallback.Id,
|
||||
BankAccountId = checkingAccount.Id,
|
||||
PaymentDate = pd,
|
||||
Amount = amt,
|
||||
PaymentMethod = PaymentMethod.BankTransferACH,
|
||||
Memo = $"Electric bill — auto pay (month -{m})"
|
||||
});
|
||||
}
|
||||
|
||||
// ── POWDER ORDERS — months −12, −9, −6 (quarterly history) ───────────
|
||||
await AddBill(new Bill
|
||||
{
|
||||
VendorInvoiceNumber = "PP-61041",
|
||||
VendorId = (prismatic ?? fallback).Id,
|
||||
APAccountId = apAccount.Id,
|
||||
BillDate = now.AddDays(-365),
|
||||
DueDate = now.AddDays(-335),
|
||||
Status = BillStatus.Paid,
|
||||
Terms = "Net 30",
|
||||
Memo = "Annual powder restock — month 12",
|
||||
SubTotal = 1_080.00m, Total = 1_080.00m, AmountPaid = 1_080.00m,
|
||||
CreatedAt = now.AddDays(-365),
|
||||
LineItems =
|
||||
{
|
||||
new BillLineItem { AccountId = powderAccount?.Id, Description = "Matte Black Powder — 50 lbs", Quantity = 2, UnitPrice = 178.00m, Amount = 356.00m, DisplayOrder = 1 },
|
||||
new BillLineItem { AccountId = powderAccount?.Id, Description = "Gloss White Powder — 50 lbs", Quantity = 2, UnitPrice = 160.00m, Amount = 320.00m, DisplayOrder = 2 },
|
||||
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Masking Tape & Plugs Kit", Quantity = 2, UnitPrice = 152.00m, Amount = 304.00m, DisplayOrder = 3 },
|
||||
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Ground Straps Assortment", Quantity = 1, UnitPrice = 100.00m, Amount = 100.00m, DisplayOrder = 4 }
|
||||
}
|
||||
}, new BillPayment { VendorId = (prismatic ?? fallback).Id, BankAccountId = checkingAccount.Id, PaymentDate = now.AddDays(-335), Amount = 1_080.00m, PaymentMethod = PaymentMethod.BankTransferACH, Memo = "PP-61041 — paid in full" });
|
||||
|
||||
await AddBill(new Bill
|
||||
{
|
||||
VendorInvoiceNumber = "PP-68820",
|
||||
VendorId = (columbia ?? fallback).Id,
|
||||
APAccountId = apAccount.Id,
|
||||
BillDate = now.AddDays(-275),
|
||||
DueDate = now.AddDays(-245),
|
||||
Status = BillStatus.Paid,
|
||||
Terms = "Net 30",
|
||||
Memo = "Quarterly specialty colors — month 9",
|
||||
SubTotal = 940.00m, Total = 940.00m, AmountPaid = 940.00m,
|
||||
CreatedAt = now.AddDays(-275),
|
||||
LineItems =
|
||||
{
|
||||
new BillLineItem { AccountId = powderAccount?.Id, Description = "Candy Red Metallic — 10 lbs", Quantity = 3, UnitPrice = 145.00m, Amount = 435.00m, DisplayOrder = 1 },
|
||||
new BillLineItem { AccountId = powderAccount?.Id, Description = "Safety Yellow Powder — 25 lbs", Quantity = 2, UnitPrice = 115.00m, Amount = 230.00m, DisplayOrder = 2 },
|
||||
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Hanging Racks & J-Hooks", Quantity = 1, UnitPrice = 275.00m, Amount = 275.00m, DisplayOrder = 3 }
|
||||
}
|
||||
}, new BillPayment { VendorId = (columbia ?? fallback).Id, BankAccountId = checkingAccount.Id, PaymentDate = now.AddDays(-245), Amount = 940.00m, PaymentMethod = PaymentMethod.BankTransferACH, Memo = "PP-68820 — paid in full" });
|
||||
|
||||
await AddBill(new Bill
|
||||
{
|
||||
VendorInvoiceNumber = "PP-73110",
|
||||
VendorId = (prismatic ?? fallback).Id,
|
||||
APAccountId = apAccount.Id,
|
||||
BillDate = now.AddDays(-185),
|
||||
DueDate = now.AddDays(-155),
|
||||
Status = BillStatus.Paid,
|
||||
Terms = "Net 30",
|
||||
Memo = "Quarterly powder restock — month 6",
|
||||
SubTotal = 1_020.00m, Total = 1_020.00m, AmountPaid = 1_020.00m,
|
||||
CreatedAt = now.AddDays(-185),
|
||||
LineItems =
|
||||
{
|
||||
new BillLineItem { AccountId = powderAccount?.Id, Description = "Matte Black Powder — 25 lbs", Quantity = 4, UnitPrice = 89.00m, Amount = 356.00m, DisplayOrder = 1 },
|
||||
new BillLineItem { AccountId = powderAccount?.Id, Description = "Gloss White Powder — 25 lbs", Quantity = 3, UnitPrice = 86.50m, Amount = 259.50m, DisplayOrder = 2 },
|
||||
new BillLineItem { AccountId = powderAccount?.Id, Description = "Satin Bronze Powder — 25 lbs", Quantity = 2, UnitPrice = 138.00m, Amount = 276.00m, DisplayOrder = 3 },
|
||||
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Wire Brushes & Abrasives",Quantity = 1, UnitPrice = 128.50m, Amount = 128.50m, DisplayOrder = 4 }
|
||||
}
|
||||
}, new BillPayment { VendorId = (prismatic ?? fallback).Id, BankAccountId = checkingAccount.Id, PaymentDate = now.AddDays(-155), Amount = 1_020.00m, PaymentMethod = PaymentMethod.BankTransferACH, Memo = "PP-73110 — paid in full" });
|
||||
|
||||
// ── POWDER ORDERS ─────────────────────────────────────────────────────
|
||||
|
||||
// Month -3: Large powder restock — Paid
|
||||
@@ -270,11 +370,11 @@ public partial class SeedDataService
|
||||
|
||||
// ── CONSUMABLES / HARDWARE ─────────────────────────────────────────────
|
||||
|
||||
// Month -3: Fastenal hardware — Paid
|
||||
// Month -3: Harbor Freight consumables — Paid
|
||||
await AddBill(new Bill
|
||||
{
|
||||
VendorInvoiceNumber = "FST-18822",
|
||||
VendorId = (fastenal ?? fallback).Id,
|
||||
VendorInvoiceNumber = "HBF-18822",
|
||||
VendorId = (harbor ?? fallback).Id,
|
||||
APAccountId = apAccount.Id,
|
||||
BillDate = now.AddDays(-85),
|
||||
DueDate = now.AddDays(-55),
|
||||
@@ -293,20 +393,20 @@ public partial class SeedDataService
|
||||
}
|
||||
}, new BillPayment
|
||||
{
|
||||
VendorId = (fastenal ?? fallback).Id,
|
||||
VendorId = (harbor ?? fallback).Id,
|
||||
BankAccountId = checkingAccount.Id,
|
||||
PaymentDate = now.AddDays(-55),
|
||||
Amount = 412.50m,
|
||||
PaymentMethod = PaymentMethod.Check,
|
||||
CheckNumber = "1082",
|
||||
Memo = "FST-18822 — paid in full"
|
||||
Memo = "HBF-18822 — paid in full"
|
||||
});
|
||||
|
||||
// Month -1: Fastenal — Paid
|
||||
// Month -1: Harbor Freight — Paid
|
||||
await AddBill(new Bill
|
||||
{
|
||||
VendorInvoiceNumber = "FST-20041",
|
||||
VendorId = (fastenal ?? fallback).Id,
|
||||
VendorInvoiceNumber = "HBF-20041",
|
||||
VendorId = (harbor ?? fallback).Id,
|
||||
APAccountId = apAccount.Id,
|
||||
BillDate = now.AddDays(-40),
|
||||
DueDate = now.AddDays(-10),
|
||||
@@ -325,20 +425,20 @@ public partial class SeedDataService
|
||||
}
|
||||
}, new BillPayment
|
||||
{
|
||||
VendorId = (fastenal ?? fallback).Id,
|
||||
VendorId = (harbor ?? fallback).Id,
|
||||
BankAccountId = checkingAccount.Id,
|
||||
PaymentDate = now.AddDays(-10),
|
||||
Amount = 298.00m,
|
||||
PaymentMethod = PaymentMethod.Check,
|
||||
CheckNumber = "1086",
|
||||
Memo = "FST-20041 — paid in full"
|
||||
Memo = "HBF-20041 — paid in full"
|
||||
});
|
||||
|
||||
// Current: Fastenal — Open
|
||||
// Current: Harbor Freight — Open
|
||||
await AddBill(new Bill
|
||||
{
|
||||
VendorInvoiceNumber = "FST-20441",
|
||||
VendorId = (fastenal ?? fallback).Id,
|
||||
VendorInvoiceNumber = "HBF-20441",
|
||||
VendorId = (harbor ?? fallback).Id,
|
||||
APAccountId = apAccount.Id,
|
||||
BillDate = now.AddDays(-7),
|
||||
DueDate = now.AddDays(23),
|
||||
@@ -361,8 +461,8 @@ public partial class SeedDataService
|
||||
// Month -3: Sandblaster service — Paid
|
||||
await AddBill(new Bill
|
||||
{
|
||||
VendorInvoiceNumber = "ACE-6901",
|
||||
VendorId = (aceHardware ?? fallback).Id,
|
||||
VendorInvoiceNumber = "GRG-6901",
|
||||
VendorId = (grainger ?? fallback).Id,
|
||||
APAccountId = apAccount.Id,
|
||||
BillDate = now.AddDays(-80),
|
||||
DueDate = now.AddDays(-50),
|
||||
@@ -380,20 +480,20 @@ public partial class SeedDataService
|
||||
}
|
||||
}, new BillPayment
|
||||
{
|
||||
VendorId = (aceHardware ?? fallback).Id,
|
||||
VendorId = (grainger ?? fallback).Id,
|
||||
BankAccountId = checkingAccount.Id,
|
||||
PaymentDate = now.AddDays(-50),
|
||||
Amount = 310.00m,
|
||||
PaymentMethod = PaymentMethod.Check,
|
||||
CheckNumber = "1079",
|
||||
Memo = "ACE-6901 — sandblaster parts"
|
||||
Memo = "GRG-6901 — sandblaster parts"
|
||||
});
|
||||
|
||||
// Month -1: Oven repair — Partially paid
|
||||
await AddBill(new Bill
|
||||
{
|
||||
VendorInvoiceNumber = "ACE-7714",
|
||||
VendorId = (aceHardware ?? fallback).Id,
|
||||
VendorInvoiceNumber = "GRG-7714",
|
||||
VendorId = (grainger ?? fallback).Id,
|
||||
APAccountId = apAccount.Id,
|
||||
BillDate = now.AddDays(-20),
|
||||
DueDate = now.AddDays(10),
|
||||
@@ -411,13 +511,13 @@ public partial class SeedDataService
|
||||
}
|
||||
}, new BillPayment
|
||||
{
|
||||
VendorId = (aceHardware ?? fallback).Id,
|
||||
VendorId = (grainger ?? fallback).Id,
|
||||
BankAccountId = checkingAccount.Id,
|
||||
PaymentDate = now.AddDays(-10),
|
||||
Amount = 200.00m,
|
||||
PaymentMethod = PaymentMethod.Check,
|
||||
CheckNumber = "1087",
|
||||
Memo = "ACE-7714 — partial payment"
|
||||
Memo = "GRG-7714 — partial payment"
|
||||
});
|
||||
|
||||
// ── UTILITIES (3 months each) ─────────────────────────────────────────
|
||||
@@ -509,6 +609,74 @@ public partial class SeedDataService
|
||||
Memo = "Electric bill — auto pay"
|
||||
});
|
||||
|
||||
// ── AP AGING DEMO BILLS ───────────────────────────────────────────────
|
||||
// These open/unpaid bills populate all four AP aging buckets for report demos.
|
||||
|
||||
// 30-60 day bucket: consumables that slipped through AP (~40 days past due)
|
||||
await AddBill(new Bill
|
||||
{
|
||||
VendorInvoiceNumber = "HBF-19900",
|
||||
VendorId = (harbor ?? fallback).Id,
|
||||
APAccountId = apAccount.Id,
|
||||
BillDate = now.AddDays(-70),
|
||||
DueDate = now.AddDays(-40),
|
||||
Status = BillStatus.Open,
|
||||
Terms = "Net 30",
|
||||
Memo = "Shop consumables — unpaid (aging demo)",
|
||||
SubTotal = 228.50m,
|
||||
Total = 228.50m,
|
||||
AmountPaid = 0m,
|
||||
CreatedAt = now.AddDays(-70),
|
||||
LineItems =
|
||||
{
|
||||
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Silicone Plugs Assortment", Quantity = 2, UnitPrice = 64.25m, Amount = 128.50m, DisplayOrder = 1 },
|
||||
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Ground Strap Replacements", Quantity = 1, UnitPrice = 100.00m, Amount = 100.00m, DisplayOrder = 2 }
|
||||
}
|
||||
});
|
||||
|
||||
// 61-90 day bucket: blast media from Local Industrial Supply (~72 days past due)
|
||||
await AddBill(new Bill
|
||||
{
|
||||
VendorInvoiceNumber = "LIS-3301",
|
||||
VendorId = (localSupply ?? fallback).Id,
|
||||
APAccountId = apAccount.Id,
|
||||
BillDate = now.AddDays(-102),
|
||||
DueDate = now.AddDays(-72),
|
||||
Status = BillStatus.Open,
|
||||
Terms = "Net 30",
|
||||
Memo = "Aluminum oxide blast media — unpaid (aging demo)",
|
||||
SubTotal = 385.00m,
|
||||
Total = 385.00m,
|
||||
AmountPaid = 0m,
|
||||
CreatedAt = now.AddDays(-102),
|
||||
LineItems =
|
||||
{
|
||||
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Aluminum Oxide #80 Grit — 100 lb bag", Quantity = 5, UnitPrice = 77.00m, Amount = 385.00m, DisplayOrder = 1 }
|
||||
}
|
||||
});
|
||||
|
||||
// 90+ day bucket: old Grainger equipment parts order (~98 days past due)
|
||||
await AddBill(new Bill
|
||||
{
|
||||
VendorInvoiceNumber = "GRG-5001",
|
||||
VendorId = (grainger ?? fallback).Id,
|
||||
APAccountId = apAccount.Id,
|
||||
BillDate = now.AddDays(-128),
|
||||
DueDate = now.AddDays(-98),
|
||||
Status = BillStatus.Open,
|
||||
Terms = "Net 30",
|
||||
Memo = "Oven conveyor motor parts — unpaid (aging demo)",
|
||||
SubTotal = 492.00m,
|
||||
Total = 492.00m,
|
||||
AmountPaid = 0m,
|
||||
CreatedAt = now.AddDays(-128),
|
||||
LineItems =
|
||||
{
|
||||
new BillLineItem { AccountId = equipRepairsAccount?.Id, Description = "Conveyor Drive Motor 1/2 HP", Quantity = 1, UnitPrice = 312.00m, Amount = 312.00m, DisplayOrder = 1 },
|
||||
new BillLineItem { AccountId = equipRepairsAccount?.Id, Description = "Drive Chain Sprocket (2-pack)", Quantity = 1, UnitPrice = 180.00m, Amount = 180.00m, DisplayOrder = 2 }
|
||||
}
|
||||
});
|
||||
|
||||
// Electric — current month (open)
|
||||
await AddBill(new Bill
|
||||
{
|
||||
@@ -625,50 +793,55 @@ public partial class SeedDataService
|
||||
seeded++;
|
||||
}
|
||||
|
||||
// ── SHOP RENT — 3 months ──────────────────────────────────────────────
|
||||
// ── SHOP RENT — 12 months ─────────────────────────────────────────────
|
||||
// Monthly rent payments going back 12 months fill the P&L expense chart.
|
||||
var rentAccount = rentAcct ?? fallbackExpense;
|
||||
await AddExp(now.AddDays(-95), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, "Shop rent — 3 months ago");
|
||||
await AddExp(now.AddDays(-65), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, "Shop rent — 2 months ago");
|
||||
await AddExp(now.AddDays(-35), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, "Shop rent — last month");
|
||||
for (int m = 12; m >= 1; m--)
|
||||
await AddExp(now.AddDays(-(m * 30 + 2)), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, $"Shop rent — {m} month{(m == 1 ? "" : "s")} ago");
|
||||
await AddExp(now.AddDays(-3), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, "Shop rent — current month");
|
||||
|
||||
// ── NATURAL GAS — 3 months ────────────────────────────────────────────
|
||||
// ── NATURAL GAS — 12 months ───────────────────────────────────────────
|
||||
// Gas usage varies seasonally — higher in winter months.
|
||||
var utilAccount = utilitiesAcct ?? fallbackExpense;
|
||||
await AddExp(now.AddDays(-88), null, utilAccount, checkingAccount, PaymentMethod.BankTransferACH, 218.44m, "Natural gas — 3 months ago");
|
||||
await AddExp(now.AddDays(-58), null, utilAccount, checkingAccount, PaymentMethod.BankTransferACH, 241.60m, "Natural gas — 2 months ago");
|
||||
await AddExp(now.AddDays(-28), null, utilAccount, checkingAccount, PaymentMethod.BankTransferACH, 196.30m, "Natural gas — last month");
|
||||
decimal[] gasAmts = [310m, 295m, 260m, 218m, 185m, 172m, 168m, 179m, 196m, 225m, 255m, 241m];
|
||||
for (int m = 12; m >= 1; m--)
|
||||
await AddExp(now.AddDays(-(m * 30 - 2)), null, utilAccount, checkingAccount, PaymentMethod.BankTransferACH, gasAmts[12 - m], $"Natural gas — {m} month{(m == 1 ? "" : "s")} ago");
|
||||
|
||||
// ── INSURANCE ─────────────────────────────────────────────────────────
|
||||
// ── INSURANCE — quarterly (4 payments over 12 months) ─────────────────
|
||||
var insAccount = insuranceAcct ?? fallbackExpense;
|
||||
await AddExp(now.AddDays(-90), null, insAccount, checkingAccount, PaymentMethod.Check, 785.00m, "Business liability insurance — quarterly premium Q1");
|
||||
await AddExp(now.AddDays(-1), null, insAccount, checkingAccount, PaymentMethod.Check, 785.00m, "Business liability insurance — quarterly premium Q2");
|
||||
await AddExp(now.AddDays(-365), null, insAccount, checkingAccount, PaymentMethod.Check, 785.00m, "Business liability insurance — quarterly premium Q1 (prior year)");
|
||||
await AddExp(now.AddDays(-274), null, insAccount, checkingAccount, PaymentMethod.Check, 785.00m, "Business liability insurance — quarterly premium Q2");
|
||||
await AddExp(now.AddDays(-182), null, insAccount, checkingAccount, PaymentMethod.Check, 785.00m, "Business liability insurance — quarterly premium Q3");
|
||||
await AddExp(now.AddDays(-91), null, insAccount, checkingAccount, PaymentMethod.Check, 810.00m, "Business liability insurance — quarterly premium Q4 (rate increase)");
|
||||
|
||||
// ── MARKETING / ADVERTISING ───────────────────────────────────────────
|
||||
// ── MARKETING / ADVERTISING — monthly for 12 months ──────────────────
|
||||
var adAccount = advertisingAcct ?? fallbackExpense;
|
||||
await AddExp(now.AddDays(-80), null, adAccount, cc, PaymentMethod.CreditDebitCard, 150.00m, "Google Ads — local search campaign");
|
||||
await AddExp(now.AddDays(-50), null, adAccount, cc, PaymentMethod.CreditDebitCard, 150.00m, "Google Ads — local search campaign");
|
||||
await AddExp(now.AddDays(-20), null, adAccount, cc, PaymentMethod.CreditDebitCard, 175.00m, "Google Ads — expanded local campaign");
|
||||
decimal[] adAmts = [120m, 120m, 135m, 150m, 150m, 165m, 150m, 165m, 175m, 175m, 175m, 175m];
|
||||
for (int m = 12; m >= 1; m--)
|
||||
await AddExp(now.AddDays(-(m * 30 + 5)), null, adAccount, cc, PaymentMethod.CreditDebitCard, adAmts[12 - m], "Google Ads — local search campaign");
|
||||
await AddExp(now.AddDays(-15), null, adAccount, cc, PaymentMethod.CreditDebitCard, 89.00m, "Yelp advertising — monthly");
|
||||
await AddExp(now.AddDays(-5), null, adAccount, cc, PaymentMethod.CreditDebitCard, 175.00m, "Google Ads — current month");
|
||||
|
||||
// ── SOFTWARE SUBSCRIPTIONS ────────────────────────────────────────────
|
||||
// ── SOFTWARE SUBSCRIPTIONS — 12 months ───────────────────────────────
|
||||
var swAccount = officeSuppliesAcct ?? fallbackExpense;
|
||||
await AddExp(now.AddDays(-90), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — monthly");
|
||||
await AddExp(now.AddDays(-60), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — monthly");
|
||||
await AddExp(now.AddDays(-30), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — monthly");
|
||||
await AddExp(now.AddDays(-2), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — monthly");
|
||||
for (int m = 12; m >= 1; m--)
|
||||
await AddExp(now.AddDays(-(m * 30)), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — monthly");
|
||||
await AddExp(now.AddDays(-2), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — current month");
|
||||
|
||||
// ── OFFICE SUPPLIES ───────────────────────────────────────────────────
|
||||
// ── OFFICE SUPPLIES — quarterly ───────────────────────────────────────
|
||||
var offAccount = officeSuppliesAcct ?? fallbackExpense;
|
||||
await AddExp(now.AddDays(-75), vendors.FirstOrDefault()?.Id, offAccount, cc, PaymentMethod.CreditDebitCard, 63.40m, "Office supplies — printer paper, labels, pens");
|
||||
await AddExp(now.AddDays(-8), vendors.FirstOrDefault()?.Id, offAccount, cc, PaymentMethod.CreditDebitCard, 47.83m, "Office supplies — printer paper, pens, labels");
|
||||
var firstVendorId = vendors.FirstOrDefault()?.Id;
|
||||
await AddExp(now.AddDays(-270), firstVendorId, offAccount, cc, PaymentMethod.CreditDebitCard, 58.90m, "Office supplies — printer paper, labels, pens");
|
||||
await AddExp(now.AddDays(-180), firstVendorId, offAccount, cc, PaymentMethod.CreditDebitCard, 63.40m, "Office supplies — printer paper, labels, pens");
|
||||
await AddExp(now.AddDays(-90), firstVendorId, offAccount, cc, PaymentMethod.CreditDebitCard, 71.25m, "Office supplies — printer paper, labels, pens");
|
||||
await AddExp(now.AddDays(-8), firstVendorId, offAccount, cc, PaymentMethod.CreditDebitCard, 47.83m, "Office supplies — printer paper, pens, labels");
|
||||
|
||||
// ── BANK FEES ─────────────────────────────────────────────────────────
|
||||
// ── BANK FEES — 12 months ─────────────────────────────────────────────
|
||||
var bankAccount = bankChargesAcct ?? fallbackExpense;
|
||||
await AddExp(now.AddDays(-85), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 28.50m, "Monthly card processing fees");
|
||||
await AddExp(now.AddDays(-55), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 31.20m, "Monthly card processing fees");
|
||||
await AddExp(now.AddDays(-25), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 29.80m, "Monthly card processing fees");
|
||||
await AddExp(now.AddDays(-3), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 32.15m, "Monthly card processing fees");
|
||||
decimal[] feeAmts = [24m, 25m, 26m, 28m, 27m, 29m, 28m, 30m, 31m, 29m, 31m, 32m];
|
||||
for (int m = 12; m >= 1; m--)
|
||||
await AddExp(now.AddDays(-(m * 30 - 1)), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, feeAmts[12 - m], "Monthly card processing fees");
|
||||
await AddExp(now.AddDays(-3), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 32.15m, "Monthly card processing fees — current month");
|
||||
|
||||
return seeded;
|
||||
}
|
||||
|
||||
@@ -6,53 +6,37 @@ namespace PowderCoating.Infrastructure.Services;
|
||||
public partial class SeedDataService
|
||||
{
|
||||
/// <summary>
|
||||
/// Seeds 100 realistic customers (60 commercial, 40 individual/non-commercial) for
|
||||
/// the given company, spanning automotive, industrial, architectural, fitness, marine,
|
||||
/// furniture, government, and specialty verticals.
|
||||
/// Seeds customers at a rate of 15 per calendar month from January of the current year
|
||||
/// through the current month, mimicking a real shop that acquires customers steadily
|
||||
/// as the year progresses. Seeding in January produces 15 customers; seeding in June
|
||||
/// produces 90 (15 × 6 months).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Idempotency: returns (0, empty warnings) immediately if any non-deleted customers already
|
||||
/// exist for this company, preventing duplicate customer sets on repeated seed runs.
|
||||
/// The first 27 customers are always the hand-crafted anchor accounts (10 commercial,
|
||||
/// 17 individual) inserted in a deterministic, index-stable order. <see cref="SeedJobsAsync"/>
|
||||
/// maps customer indices 0–9 to specific commercial price profiles, so the commercial
|
||||
/// anchors must always occupy the lowest database IDs for this company.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Each customer is inserted individually (rather than in a single <c>AddRange</c>) so that
|
||||
/// a duplicate-email collision on any single record is caught and converted to a warning
|
||||
/// rather than aborting the entire batch. The EF entity is detached on failure to prevent
|
||||
/// the DbContext change-tracker from retrying the failed insert on the next
|
||||
/// <c>SaveChangesAsync</c> call.
|
||||
/// Remaining slots in each month (15 − anchors_that_month) are filled with procedurally
|
||||
/// generated individual customers drawn from NC-area name and city pools, so the
|
||||
/// New Customers per Month chart shows a consistent 15-bar pattern regardless of
|
||||
/// the reseed date.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Pricing tiers (Standard, Silver, Gold, Platinum) are resolved by name from the company's
|
||||
/// already-seeded tiers. If a tier is missing the customer still inserts with no tier,
|
||||
/// rather than throwing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Government/municipal customers (<c>Metro Transit Authority</c>, <c>Municipal Services Group</c>,
|
||||
/// <c>Regional Airport Authority</c>, <c>County School District</c>) are seeded with
|
||||
/// <c>IsTaxExempt = true</c> to demonstrate the tax-exempt workflow, matching the
|
||||
/// production rule that tax-exempt customers get 0 % tax on quotes and invoices.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The two local helper functions <c>Comm()</c> and <c>Indiv()</c> reduce the per-row
|
||||
/// line count; they are defined as local functions rather than private methods because
|
||||
/// they capture the <c>company</c> parameter by closure and are only needed here.
|
||||
/// Idempotency: returns 0 immediately if any non-deleted customers already exist for
|
||||
/// this company (they are removed by the reset sweep before a full reseed).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="company">The tenant company to seed customers for.</param>
|
||||
/// <returns>
|
||||
/// A tuple of (<c>seededCount</c>, <c>warnings</c>) where <c>seededCount</c> is the number
|
||||
/// of records actually inserted and <c>warnings</c> lists any customers that were skipped
|
||||
/// (e.g. because the email already existed).
|
||||
/// </returns>
|
||||
/// <returns>A tuple of (seededCount, warnings) where warnings list skipped records.</returns>
|
||||
private async Task<(int seededCount, List<string> warnings)> SeedCustomersAsync(Company company)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
int seededCount = 0;
|
||||
int skippedCount = 0;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Early exit — same pattern as all other seeders
|
||||
var existingCount = await _context.Set<Customer>()
|
||||
.IgnoreQueryFilters()
|
||||
.CountAsync(c => c.CompanyId == company.Id && !c.IsDeleted);
|
||||
@@ -70,191 +54,176 @@ public partial class SeedDataService
|
||||
var goldTier = tiers.FirstOrDefault(t => t.TierName == "Gold");
|
||||
var platinumTier = tiers.FirstOrDefault(t => t.TierName == "Platinum");
|
||||
|
||||
// ── Local helpers keep each customer to 2-3 lines ──────────────────────
|
||||
//
|
||||
// Comm() builds a commercial (B2B) Customer with credit limit, tax ID, and pricing tier.
|
||||
// The LastContactDate formula scrambles the months value so that contacts are spread
|
||||
// across the past 25 days rather than clustering on the same date for all customers.
|
||||
// ── Anchor customer builders (commercial and individual) ──────────────
|
||||
Customer Comm(string co, string fn, string ln, string em, string ph,
|
||||
string city, string st, string zip, string terms, decimal credit, decimal bal,
|
||||
string tax, PricingTier? tier, string notes, int months, bool taxExempt = false) =>
|
||||
string tax, PricingTier? tier, string notes, bool taxExempt = false) =>
|
||||
new Customer
|
||||
{
|
||||
CompanyName = co, ContactFirstName = fn, ContactLastName = ln, Email = em,
|
||||
Phone = ph, City = city, State = st, ZipCode = zip,
|
||||
IsCommercial = true, TaxId = tax, CreditLimit = credit, CurrentBalance = bal,
|
||||
PaymentTerms = terms, PricingTierId = tier?.Id, IsTaxExempt = taxExempt,
|
||||
IsActive = true,
|
||||
LastContactDate = now.AddDays(-((months * 11 + 3) % 25 + 1)),
|
||||
GeneralNotes = notes, CompanyId = company.Id, CreatedAt = now.AddMonths(-months)
|
||||
IsActive = true, GeneralNotes = notes, CompanyId = company.Id
|
||||
};
|
||||
|
||||
// Indiv() builds a non-commercial (retail) Customer with simpler fields:
|
||||
// no credit limit, no tax ID, payment terms default to "Due on receipt".
|
||||
Customer Indiv(string fn, string ln, string em, string ph,
|
||||
string city, string st, string zip, string notes, int months) =>
|
||||
string city, string st, string zip, string notes) =>
|
||||
new Customer
|
||||
{
|
||||
ContactFirstName = fn, ContactLastName = ln, Email = em, Phone = ph,
|
||||
City = city, State = st, ZipCode = zip,
|
||||
IsCommercial = false, PaymentTerms = "Due on receipt", IsActive = true,
|
||||
LastContactDate = now.AddDays(-((months * 17 + 5) % 50 + 5)),
|
||||
GeneralNotes = notes, CompanyId = company.Id, CreatedAt = now.AddMonths(-months)
|
||||
GeneralNotes = notes, CompanyId = company.Id
|
||||
};
|
||||
|
||||
var customers = new List<Customer>
|
||||
// ── 27 hand-crafted anchors — ORDER IS CRITICAL ───────────────────────
|
||||
// SeedJobsAsync.CustomerProfile() maps indices 0–9 to commercial accounts
|
||||
// and 10–26 to known individual accounts. Never reorder these.
|
||||
var anchors = new List<Customer>
|
||||
{
|
||||
// ─── Commercial Customers (60) ────────────────────────────────────
|
||||
// Commercial (ci 0–9) — drive the Revenue by Customer report story
|
||||
Comm("Carolina Fabrication", "Matt", "Henderson", "matt@carolinafab.com", "(919) 234-5678", "Raleigh", "NC", "27601", "Net 30", 50000m, 3200m, "56-7890123", platinumTier, "Largest account; structural steel and custom fab runs weekly"),
|
||||
Comm("Apex Motorsports", "Chris", "Tanner", "ctanner@apexmotorsports.com", "(919) 345-6789", "Apex", "NC", "27502", "Net 30", 35000m, 2100m, "23-4567890", goldTier, "Race parts, chassis work, performance components"),
|
||||
Comm("Triangle Offroad", "Jason", "Pruitt", "jpruitt@triangleoffroad.com", "(919) 456-7890", "Durham", "NC", "27701", "Net 15", 30000m, 1800m, "34-5678901", goldTier, "Jeep and truck accessory coatings; skid plates, bumpers"),
|
||||
Comm("Smith Welding & Steel", "Bill", "Smith", "bsmith@smithwelding.com", "(919) 567-8901", "Garner", "NC", "27529", "Net 30", 20000m, 950m, "45-6789012", silverTier, "Custom steel fab, gates, railings; repeat orders monthly"),
|
||||
Comm("Raleigh Architectural Metals", "Karen", "Morales", "kmorales@raleigharchitectural.com", "(919) 678-9012", "Raleigh", "NC", "27604", "Net 30", 25000m, 1400m, "67-8901234", goldTier, "Decorative ironwork, balcony railings, entry gates"),
|
||||
Comm("East Coast Powderworks", "Tony", "Greco", "tgreco@eastcoastpw.com", "(252) 789-0123", "Greenville", "NC", "27858", "Net 15", 15000m, 620m, "78-9012345", silverTier, "Metal fab and fabrication outsourcing"),
|
||||
Comm("Piedmont Metal Works", "Derek", "Shaw", "dshaw@piedmontmetalworks.com", "(336) 890-1234", "Greensboro", "NC", "27401", "Net 30", 10000m, 380m, "89-0123456", standardTier, "Heavy industrial parts, machine guards, conveyor frames"),
|
||||
Comm("Cary Industrial Solutions", "Linda", "Patel", "lpatel@caryindustrial.com", "(919) 901-2345", "Cary", "NC", "27511", "Net 30", 18000m, 870m, "90-1234567", silverTier, "Equipment casings, pump housings, control panels"),
|
||||
Comm("Durham Tech Equipment", "Ryan", "Blake", "rblake@durhamtech.com", "(919) 012-3456", "Durham", "NC", "27703", "Net 30", 28000m, 1150m, "01-2345678", goldTier, "Lab and research equipment frames and enclosures"),
|
||||
Comm("Wake County Fleet Services", "Michelle", "Coleman", "mcoleman@wakecountyfleet.gov", "(919) 123-4560", "Raleigh", "NC", "27602", "Net 60", 75000m, 2800m, "56-7890124", platinumTier, "Government fleet contract — tax exempt; trailers, truck beds", true),
|
||||
|
||||
// Auto & Motorsports (12)
|
||||
Comm("Acme Manufacturing Corp", "John", "Smith", "john.smith@acmemfg.com", "(555) 234-5678", "Chicago", "IL", "60601", "Net 30", 50000m, 12500m, "12-3456789", platinumTier, "Large volume customer, weekly shipments", 18),
|
||||
Comm("Precision Auto Parts LLC", "Sarah", "Johnson", "sjohnson@precisionauto.com", "(555) 345-6789", "Detroit", "MI", "48201", "Net 30", 35000m, 8750m, "23-4567890", goldTier, "Automotive parts manufacturer", 15),
|
||||
Comm("Classic Wheel Restoration", "Robert", "Taylor", "rtaylor@classicwheels.com", "(555) 789-0123", "Phoenix", "AZ", "85001", "Net 15", 15000m, 3200m, "67-8901234", silverTier, "Classic car wheel specialist", 10),
|
||||
Comm("MotorSports Custom Shop", "Chris", "Brown", "cbrown@motorsportscustom.com", "(555) 901-2345", "Indianapolis", "IN", "46201", "Net 15", 20000m, 9500m, "89-0123456", silverTier, "Performance parts and custom fabrication", 8),
|
||||
Comm("Metro Automotive Group", "Frank", "DeNucci", "frank.dnucci@metroauto.com", "(555) 210-3311", "Detroit", "MI", "48202", "Net 30", 28000m, 6400m, "14-2233445", goldTier, "Multi-brand dealership network", 11),
|
||||
Comm("Coastal Customs & Fabrication", "Danny", "Morales", "dmorales@coastalcustoms.com", "(555) 887-6543", "San Diego", "CA", "92103", "Net 15", 18000m, 4100m, "22-3344556", silverTier, "Custom truck and SUV builds", 6),
|
||||
Comm("Desert Speed Shop", "Kyle", "Rennick", "kyle@desertspeedshop.com", "(555) 766-5544", "Scottsdale", "AZ", "85251", "Net 15", 12000m, 2800m, "33-4455667", standardTier, "Performance tuning and fabrication", 4),
|
||||
Comm("Midwest Motorsports", "Troy", "Edelmann", "troy@midwestmotorsports.com", "(555) 342-1122", "Columbus", "OH", "43201", "Net 30", 22000m, 5300m, "44-5566778", goldTier, "Racing team equipment and parts", 9),
|
||||
Comm("Track Day Performance", "Megan", "Schultz", "megan@trackdayperformance.com", "(555) 456-9988", "Charlotte", "NC", "28201", "Net 15", 16000m, 3700m, "55-6677889", silverTier, "Track prep and safety equipment", 5),
|
||||
Comm("Vintage Velocity Restorations", "Harold", "Pearce", "harold@vintagevelocity.com", "(555) 321-7654", "Nashville", "TN", "37201", "Net 30", 25000m, 6100m, "66-7788990", goldTier, "High-end vintage and classic car restoration", 13),
|
||||
Comm("American Iron Custom Cycles", "Bret", "Conner", "bret@americanironcycles.com", "(555) 654-3210", "Milwaukee", "WI", "53201", "Net 15", 14000m, 3100m, "77-8899001", silverTier, "Custom motorcycle builds and parts", 7),
|
||||
Comm("All-American Auto Body", "Steve", "Kozlowski", "steve@allamericanautobody.com", "(555) 213-4567", "Cleveland", "OH", "44101", "Net 30", 20000m, 4800m, "88-9900112", goldTier, "Collision repair and custom coating", 10),
|
||||
|
||||
// Industrial & Manufacturing (10)
|
||||
Comm("Industrial Furniture Co", "Jennifer","Anderson", "janderson@indfurniture.com", "(555) 890-1234", "Seattle", "WA", "98101", "Net 30", 30000m, 7800m, "78-9012345", goldTier, "Office and outdoor furniture manufacturer", 16),
|
||||
Comm("Commercial HVAC Systems", "Kevin", "Garcia", "kgarcia@commercialhvac.com", "(555) 345-6780", "Atlanta", "GA", "30301", "Net 30", 32000m, 8900m, "23-4567891", goldTier, "HVAC ductwork and equipment casings", 17),
|
||||
Comm("Agricultural Equipment Inc", "Sandra", "White", "swhite@agequipment.com", "(555) 678-9013", "Des Moines", "IA", "50301", "Net 30", 42000m, 16800m, "56-7890124", goldTier, "Farm equipment parts and implements", 19),
|
||||
Comm("Steel City Fabricators", "Tony", "Marchetti", "tony@steelcityfab.com", "(555) 412-3344", "Pittsburgh", "PA", "15201", "Net 30", 38000m, 10200m, "99-0011223", platinumTier, "Heavy structural steel fabrication", 14),
|
||||
Comm("Precision Metal Works", "Diane", "Tran", "diane@precisionmetalworks.com", "(555) 503-2211", "Portland", "OR", "97205", "Net 30", 26000m, 5900m, "10-1122334", goldTier, "CNC machined parts and assemblies", 12),
|
||||
Comm("Continental Manufacturing", "Phil", "Stavros", "pstavros@continentalmfg.com", "(555) 216-8877", "Cleveland", "OH", "44115", "Net 45", 55000m, 18400m, "21-2233445", platinumTier, "Industrial component manufacturing", 20),
|
||||
Comm("Eagle Industrial Coatings", "Deb", "Hensley", "deb@eagleindustrialcoatings.com", "(555) 317-5566", "Indianapolis", "IN", "46204", "Net 30", 19000m, 4300m, "32-3344556", silverTier, "Subcontract coating for industrial parts", 7),
|
||||
Comm("Summit Metal Fabricators", "Russ", "Fontaine", "russ@summitmetalfab.com", "(555) 720-4433", "Denver", "CO", "80202", "Net 30", 31000m, 7700m, "43-4455667", goldTier, "Custom metal fabrication and welding", 11),
|
||||
Comm("Iron Horse Manufacturing", "Craig", "Bukowski", "craig@ironhorsemfg.com", "(555) 414-7788", "Milwaukee", "WI", "53202", "Net 30", 24000m, 5600m, "54-5566778", goldTier, "Heavy equipment components and frames", 9),
|
||||
Comm("Pacific Metal Works", "Yuki", "Tanaka", "ytanaka@pacificmetalworks.com", "(555) 206-3322", "Seattle", "WA", "98104", "Net 15", 17000m, 3800m, "65-6677889", silverTier, "Sheet metal fabrication and finishing", 6),
|
||||
|
||||
// Architectural & Construction (8)
|
||||
Comm("Urban Railings & Gates", "Michael", "Chen", "mchen@urbanrailings.com", "(555) 456-7890", "San Francisco", "CA", "94102", "Net 15", 25000m, 5200m, "34-5678901", silverTier, "Ornamental iron railings and gates", 12),
|
||||
Comm("Heritage Architectural Metalworks","Thomas","Miller", "tmiller@heritagemetal.com", "(555) 123-4567", "Charleston", "SC", "29401", "Net 30", 28000m, 6700m, "01-2345678", goldTier, "Historic restoration and custom architectural pieces",13),
|
||||
Comm("Skyline Structural Steel", "Marcus", "Webb", "mwebb@skylinsteel.com", "(555) 312-9876", "Chicago", "IL", "60607", "Net 45", 65000m, 22000m, "76-7788990", platinumTier, "Commercial and industrial structural steel", 22),
|
||||
Comm("Premier Fence & Gate Co", "Lori", "Hale", "lori@premierfenceandgate.com", "(555) 602-1199", "Phoenix", "AZ", "85004", "Net 30", 23000m, 5400m, "87-8899001", goldTier, "Residential and commercial fencing", 8),
|
||||
Comm("Modern Railing Systems", "Evan", "Choi", "echoi@modernrailingsystems.com", "(555) 415-8844", "San Jose", "CA", "95110", "Net 30", 27000m, 6200m, "98-9900112", goldTier, "Interior and exterior railing design", 10),
|
||||
Comm("Coastal Aluminum Products", "Patty", "Larson", "plarson@coastalaluminum.com", "(555) 904-3366", "Tampa", "FL", "33601", "Net 30", 21000m, 4700m, "09-0011223", goldTier, "Aluminum windows, doors, and structures", 7),
|
||||
Comm("Metro Door & Window", "Sam", "Petrov", "spetrov@metrodoorwindow.com", "(555) 718-4455", "Brooklyn", "NY", "11201", "Net 30", 29000m, 7100m, "20-1122334", goldTier, "Commercial door and window systems", 11),
|
||||
Comm("Rocky Mountain Ironworks", "Buck", "Ramsey", "buck@rockymtnironworks.com", "(555) 303-6677", "Denver", "CO", "80203", "Net 15", 18500m, 4100m, "31-2233445", silverTier, "Custom wrought iron and steel artisan work", 5),
|
||||
|
||||
// Fitness & Recreation (5)
|
||||
Comm("Fitness Equipment Solutions", "Lisa", "Martinez", "lmartinez@fitequip.com", "(555) 567-8901", "Austin", "TX", "78701", "Net 30", 40000m, 15600m, "45-6789012", goldTier, "Gym equipment frames and accessories", 14),
|
||||
Comm("Playground Equipment USA", "Nancy", "Martinez", "nmartinez@playgroundusa.com", "(555) 456-7891", "Portland", "OR", "97201", "Net 30", 38000m, 14500m, "34-5678902", platinumTier, "Commercial playground equipment manufacturer", 22),
|
||||
Comm("Diamond Fitness Equipment", "Lamar", "Okafor", "lamar@diamondfitness.com", "(555) 713-2288", "Houston", "TX", "77002", "Net 30", 33000m, 8100m, "42-3344556", goldTier, "Commercial gym and fitness center equipment", 9),
|
||||
Comm("Peak Performance Products", "Stacy", "Owens", "stacy@peakperformanceproducts.com", "(555) 503-7711", "Eugene", "OR", "97401", "Net 15", 16000m, 3500m, "53-4455667", silverTier, "Outdoor fitness and sports equipment", 6),
|
||||
Comm("All-Star Sports Equipment", "Jerome", "Watkins", "jwatkins@allstarsports.com", "(555) 314-5533", "St. Louis", "MO", "63101", "Net 30", 22000m, 5100m, "64-5566778", goldTier, "Team sports equipment and facilities", 8),
|
||||
|
||||
// Marine (4)
|
||||
Comm("Marine Equipment Corp", "Patricia","Wilson", "pwilson@marineequip.com", "(555) 234-5679", "Miami", "FL", "33101", "Net 30", 35000m, 11400m, "12-3456780", silverTier, "Boat hardware and marine fittings", 11),
|
||||
Comm("Gulf Coast Marine Supply", "Hector", "Vega", "hvega@gulfcoastmarine.com", "(555) 985-6644", "New Orleans", "LA", "70112", "Net 30", 28000m, 6800m, "75-6677889", goldTier, "Commercial and recreational marine hardware", 9),
|
||||
Comm("Pacific Yacht Hardware", "Erin", "Nakamura", "enakamura@pacificyacht.com", "(555) 310-8822", "Long Beach", "CA", "90802", "Net 15", 20000m, 4500m, "86-7788990", silverTier, "High-end yacht fittings and hardware", 6),
|
||||
Comm("Lakeside Boat Works", "Walt", "Bauer", "walt@lakesideboatworks.com", "(555) 616-3311", "Grand Rapids", "MI", "49501", "Net 30", 15000m, 3200m, "97-8899001", silverTier, "Freshwater boat repair and custom builds", 4),
|
||||
|
||||
// Furniture & Commercial (5)
|
||||
Comm("Office Systems International", "Brian", "Lee", "blee@officesystems.com", "(555) 567-8902", "Dallas", "TX", "75201", "Net 15", 27000m, 5600m, "45-6789013", silverTier, "Office furniture components and accessories", 9),
|
||||
Comm("Retail Display Solutions", "Gina", "Russo", "gruso@retaildisplay.com", "(555) 312-6644", "Chicago", "IL", "60608", "Net 30", 19000m, 4300m, "08-9900112", goldTier, "Retail shelving, fixtures, and displays", 7),
|
||||
Comm("Restaurant Equipment Co", "Marco", "Benetti", "mbenetti@restaurantequipment.com", "(555) 305-1122", "Miami", "FL", "33102", "Net 30", 24000m, 5800m, "19-0011223", goldTier, "Commercial kitchen and restaurant equipment", 10),
|
||||
Comm("Outdoor Living Products", "Cheryl", "Dobbs", "cdobbs@outdoorlivingproducts.com", "(555) 480-7799", "Tempe", "AZ", "85281", "Net 30", 21000m, 4600m, "30-1122334", goldTier, "Patio and outdoor furniture manufacturer", 8),
|
||||
Comm("Commercial Shelving Systems", "Ray", "Obasi", "robasi@commercialshelving.com", "(555) 832-5544", "Houston", "TX", "77003", "Net 30", 16000m, 3400m, "41-2233445", silverTier, "Warehouse and retail shelving solutions", 5),
|
||||
|
||||
// Energy, Transit & Government (7)
|
||||
Comm("Metro Transit Authority", "David", "Williams", "dwilliams@metrota.gov", "(555) 678-9012", "Boston", "MA", "02101", "Net 60", 75000m, 22000m, "56-7890123", platinumTier, "Government transit contract — tax exempt", 24, true),
|
||||
Comm("Green Energy Solutions", "Amanda", "Davis", "adavis@greenenergy.com", "(555) 012-3456", "Denver", "CO", "80201", "Net 30", 45000m, 18200m, "90-1234567", platinumTier, "Solar panel frames and mounting hardware", 20),
|
||||
Comm("Solar Power Systems Inc", "Neil", "Ostrowski", "nostrowski@solarpowersys.com", "(555) 408-4411", "San Jose", "CA", "95112", "Net 30", 36000m, 9200m, "52-3344556", goldTier, "Solar racking and structural components", 11),
|
||||
Comm("Wind Energy Components", "Tara", "Haas", "thaas@windenergy.com", "(555) 605-8833", "Austin", "TX", "78702", "Net 45", 48000m, 15600m, "63-4455667", platinumTier, "Wind turbine hardware and mounting systems", 16),
|
||||
Comm("Municipal Services Group", "Roy", "Nkosi", "rnkosi@municipalservices.gov", "(555) 608-2233", "Sacramento", "CA", "95814", "Net 60", 60000m, 19800m, "74-5566778", platinumTier, "City infrastructure and public works — tax exempt", 28, true),
|
||||
Comm("Regional Airport Authority", "Lisa", "Crane", "lcrane@regionairport.gov", "(555) 904-5511", "Tampa", "FL", "33602", "Net 60", 55000m, 17200m, "85-6677889", platinumTier, "Airport infrastructure — tax exempt", 21, true),
|
||||
Comm("County School District", "Terry", "Vance", "tvance@countyschools.edu", "(555) 317-8866", "Indianapolis", "IN", "46205", "Net 60", 40000m, 12500m, "96-7788990", goldTier, "School facility equipment — tax exempt", 15, true),
|
||||
|
||||
// Specialty (9)
|
||||
Comm("Medical Equipment Corp", "Paula", "Jennings", "pjennings@medicalequip.com", "(555) 215-6655", "Philadelphia", "PA", "19103", "Net 30", 42000m, 12800m, "07-8899001", goldTier, "Medical and laboratory equipment frames", 13),
|
||||
Comm("Food Processing Equipment", "Luis", "Espinoza", "lespinoza@foodprocessingequip.com", "(555) 816-3388", "Indianapolis", "IN", "46206", "Net 30", 31000m, 7400m, "18-9900112", goldTier, "Food-safe coating for processing equipment", 9),
|
||||
Comm("Security Solutions Group", "Dale", "Pratt", "dpratt@securitysolutionsgrp.com", "(555) 214-4477", "Dallas", "TX", "75202", "Net 30", 26000m, 5900m, "29-0011223", goldTier, "Security enclosures and equipment housing", 8),
|
||||
Comm("Mining Equipment Corp", "Rex", "Harmon", "rharmon@miningequip.com", "(555) 801-6622", "Salt Lake City", "UT", "84101", "Net 30", 48000m, 16400m, "40-1122334", platinumTier, "Mining and extraction equipment components", 17),
|
||||
Comm("Construction Equipment Co", "Wayne", "Briggs", "wbriggs@constructionequipco.com", "(555) 918-7733", "Oklahoma City", "OK", "73101", "Net 30", 37000m, 10100m, "51-2233445", goldTier, "Construction and earthmoving equipment parts", 12),
|
||||
Comm("Water Treatment Systems", "Irene", "Kamau", "ikamau@watertreatmentsys.com", "(555) 503-9944", "Portland", "OR", "97206", "Net 45", 44000m, 14100m, "62-3344556", platinumTier, "Municipal and industrial water treatment equipment", 18),
|
||||
Comm("Rail Equipment Systems", "Doug", "Stafford", "dstafford@railequipmentsys.com", "(555) 312-7766", "Chicago", "IL", "60609", "Net 45", 52000m, 17800m, "73-4455667", platinumTier, "Railway maintenance and rolling stock equipment", 23),
|
||||
Comm("Telecommunications Tower Co", "Maggie", "Solis", "msolis@telcotowers.com", "(555) 469-5588", "Dallas", "TX", "75203", "Net 30", 35000m, 9500m, "84-5566778", goldTier, "Cell tower hardware and mounting equipment", 10),
|
||||
Comm("Data Center Infrastructure", "Bo", "Kimura", "bkimura@datacenterinfra.com", "(555) 408-2266", "San Jose", "CA", "95113", "Net 30", 29000m, 7200m, "95-6677889", goldTier, "Server rack frames and data center equipment", 7),
|
||||
|
||||
// ─── Individual / Non-Commercial Customers (40) ───────────────────
|
||||
|
||||
Indiv("James", "Thompson", "jthompson@email.com", "(555) 111-2222", "Los Angeles", "CA", "90001", "Classic car restoration hobbyist", 6),
|
||||
Indiv("Mary", "Harris", "mharris@email.com", "(555) 222-3333", "Houston", "TX", "77001", "Patio furniture refurbishment", 4),
|
||||
Indiv("William", "Clark", "wclark@email.com", "(555) 333-4444", "Philadelphia", "PA", "19101", "Motorcycle customization", 7),
|
||||
Indiv("Elizabeth","Lewis", "elewis@email.com", "(555) 444-5555", "Phoenix", "AZ", "85001", "Garden furniture restoration", 3),
|
||||
Indiv("Richard", "Walker", "rwalker@email.com", "(555) 555-6666", "San Antonio", "TX", "78201", "Custom bike parts", 5),
|
||||
Indiv("Barbara", "Hall", "bhall@email.com", "(555) 666-7777", "San Diego", "CA", "92101", "Antique furniture hardware", 2),
|
||||
Indiv("Joseph", "Allen", "jallen@email.com", "(555) 777-8888", "Dallas", "TX", "75201", "Hot rod restoration", 8),
|
||||
Indiv("Susan", "Young", "syoung@email.com", "(555) 888-9999", "San Jose", "CA", "95101", "Home décor projects", 1),
|
||||
Indiv("Charles", "King", "cking@email.com", "(555) 999-0000", "Austin", "TX", "78701", "Vintage car parts", 5),
|
||||
Indiv("Linda", "Wright", "lwright@email.com", "(555) 000-1111", "Jacksonville", "FL", "32201", "Outdoor metalwork restoration", 3),
|
||||
Indiv("Gary", "Nelson", "gnelson@email.com", "(555) 131-4141", "Minneapolis", "MN", "55401", "Snowmobile frame and parts", 2),
|
||||
Indiv("Carol", "Evans", "carol.evans@email.com", "(555) 242-5252", "Portland", "OR", "97207", "Vintage bicycle restoration", 1),
|
||||
Indiv("Kenneth", "Scott", "kscott@email.com", "(555) 353-6363", "Baltimore", "MD", "21201", "Antique tool restoration", 3),
|
||||
Indiv("Helen", "Green", "hgreen@email.com", "(555) 464-7474", "Memphis", "TN", "38101", "Wrought iron bed frame", 4),
|
||||
Indiv("Donald", "Baker", "dbaker@email.com", "(555) 575-8585", "Louisville", "KY", "40201", "Classic truck restoration", 6),
|
||||
Indiv("Donna", "Adams", "dadams@email.com", "(555) 686-9696", "Richmond", "VA", "23218", "Outdoor light fixture set", 2),
|
||||
Indiv("Steven", "Nelson", "steven.n@email.com", "(555) 797-0707", "Columbus", "OH", "43202", "Motorcycle frame and tank", 5),
|
||||
Indiv("Patricia", "Carter", "pcarter@email.com", "(555) 808-1818", "Austin", "TX", "78703", "Patio table and chair set — 6pc", 3),
|
||||
Indiv("Mark", "Mitchell", "mmitchell@email.com", "(555) 919-2929", "Denver", "CO", "80204", "Car wheels — set of 4", 1),
|
||||
Indiv("Sandra", "Perez", "sperez@email.com", "(555) 020-3030", "El Paso", "TX", "79901", "Spiral staircase railing", 4),
|
||||
Indiv("George", "Roberts", "groberts@email.com", "(555) 141-4242", "Fort Worth", "TX", "76101", "Boat trailer frame", 3),
|
||||
Indiv("Kathleen", "Turner", "kturner@email.com", "(555) 252-5353", "Nashville", "TN", "37202", "Fireplace grate and screen", 2),
|
||||
Indiv("Eric", "Phillips", "ephillips@email.com", "(555) 363-6464", "Seattle", "WA", "98105", "Mountain bike frame", 1),
|
||||
Indiv("Sharon", "Campbell", "scampbell@email.com", "(555) 474-7575", "Boston", "MA", "02102", "Iron garden bench set", 5),
|
||||
Indiv("Larry", "Parker", "lparker@email.com", "(555) 585-8686", "Detroit", "MI", "48203", "Classic Mustang wheels and trim", 8),
|
||||
Indiv("Shirley", "Evans", "shevans@email.com", "(555) 696-9797", "Charlotte", "NC", "28202", "Deck railing system", 3),
|
||||
Indiv("Timothy", "Edwards", "tedwards@email.com", "(555) 707-0808", "Memphis", "TN", "38102", "ATV frame and fenders", 2),
|
||||
Indiv("Angela", "Collins", "acollins@email.com", "(555) 818-1919", "Las Vegas", "NV", "89101", "Casino chair legs — set of 24", 4),
|
||||
Indiv("Harold", "Stewart", "hstewart@email.com", "(555) 929-2020", "Tucson", "AZ", "85701", "Vintage pickup restoration parts", 6),
|
||||
Indiv("Pamela", "Sanchez", "psanchez@email.com", "(555) 030-3131", "Sacramento", "CA", "95815", "Wrought iron wine rack", 1),
|
||||
Indiv("Edward", "Morris", "emorris@email.com", "(555) 141-4343", "Raleigh", "NC", "27601", "Trailer hitch and receiver set", 2),
|
||||
Indiv("Frances", "Rogers", "frogers@email.com", "(555) 252-5454", "Minneapolis", "MN", "55402", "Mid-century chair frames — 4pc", 3),
|
||||
Indiv("Phillip", "Reed", "preed@email.com", "(555) 363-6565", "Omaha", "NE", "68101", "Go-kart frame and roll cage", 1),
|
||||
Indiv("Ruth", "Cook", "rcook@email.com", "(555) 474-7676", "Tulsa", "OK", "74101", "Farmhouse shelving brackets — large set", 2),
|
||||
Indiv("Andrew", "Morgan", "amorgan@email.com", "(555) 585-8787", "Atlanta", "GA", "30302", "Drift car cage and subframe", 4),
|
||||
Indiv("Mildred", "Bell", "mbell@email.com", "(555) 696-9898", "Cincinnati", "OH", "45201", "Garden gate and fence panels", 5),
|
||||
Indiv("Ralph", "Murphy", "rmurphy@email.com", "(555) 707-0909", "Fresno", "CA", "93701", "Custom motorcycle exhaust system", 3),
|
||||
Indiv("Lois", "Rivera", "lrivera@email.com", "(555) 818-1010", "Corpus Christi", "TX", "78401", "Outdoor kitchen frame and brackets", 2),
|
||||
Indiv("Roy", "Cooper", "rcooper@email.com", "(555) 929-2121", "Arlington", "TX", "76001", "Vintage tractor restoration parts", 7),
|
||||
Indiv("Vera", "Richardson","vrichardson@email.com", "(555) 030-3232", "Lexington", "KY", "40502", "Wrought iron headboard and footboard", 4),
|
||||
// Individual (ci 10–26) — residential work and hobby builds
|
||||
Indiv("John", "Davis", "jdavis@email.com", "(919) 111-2222", "Raleigh", "NC", "27609", "Classic car restoration hobbyist; Ford Mustangs"),
|
||||
Indiv("Sarah", "Jenkins", "sjenkins@email.com", "(919) 222-3333", "Durham", "NC", "27707", "Motorcycle customization; Harley-Davidson parts"),
|
||||
Indiv("Mike", "Thompson", "mthompson@email.com", "(919) 333-4444", "Apex", "NC", "27502", "Jeep Wrangler build, wheels and bumpers"),
|
||||
Indiv("Robert", "Miller", "rmiller@email.com", "(919) 444-5555", "Cary", "NC", "27513", "Patio furniture set, railings"),
|
||||
Indiv("Jennifer", "Clark", "jclark@email.com", "(919) 555-6666", "Chapel Hill", "NC", "27514", "Bicycle frame restoration; vintage road bikes"),
|
||||
Indiv("David", "Wilson", "dwilson@email.com", "(919) 666-7777", "Wake Forest", "NC", "27587", "1969 Camaro restoration project, multiple phases"),
|
||||
Indiv("Lisa", "Anderson", "landerson@email.com", "(919) 777-8888", "Morrisville", "NC", "27560", "Home decor metal pieces, garden art"),
|
||||
Indiv("Thomas", "Harris", "tharris@email.com", "(252) 888-9999", "New Bern", "NC", "28560", "Boat trailer hardware, dock cleats"),
|
||||
Indiv("Karen", "White", "kwhite@email.com", "(919) 999-0000", "Fuquay-Varina","NC", "27526", "Antique fireplace grate and hardware restoration"),
|
||||
Indiv("James", "Taylor", "jtaylor@email.com", "(919) 000-1111", "Garner", "NC", "27529", "1955 Ford F100 hot rod build"),
|
||||
Indiv("Michelle", "Brown", "mbrown@email.com", "(919) 131-4141", "Holly Springs","NC", "27540", "Outdoor furniture set, 6 chairs and table"),
|
||||
Indiv("Chris", "Lee", "clee@email.com", "(984) 242-5252", "Raleigh", "NC", "27610", "Custom BMX frame — Candy Red"),
|
||||
Indiv("Amanda", "Garcia", "agarcia@email.com", "(919) 353-6363", "Clayton", "NC", "27520", "Motorcycle frame and forks — Flat Black"),
|
||||
Indiv("Kevin", "Martinez", "kmartinez@email.com", "(919) 464-7474", "Wendell", "NC", "27591", "Snowmobile frame and tunnel"),
|
||||
Indiv("Nancy", "Rodriguez", "nrodriguez@email.com", "(919) 575-8585", "Knightdale", "NC", "27545", "Wrought iron garden trellis and gate"),
|
||||
Indiv("Brian", "Hall", "bhall@email.com", "(919) 686-9696", "Zebulon", "NC", "27597", "Utility trailer frame and hitch assembly"),
|
||||
Indiv("Patricia", "Young", "pyoung@email.com", "(919) 797-0707", "Louisburg", "NC", "27549", "Front porch railings — Gloss Black"),
|
||||
};
|
||||
|
||||
// Add customers one at a time to handle duplicates gracefully
|
||||
foreach (var customer in customers)
|
||||
// Spread anchors evenly from Jan 1 of this year through the current month so the
|
||||
// New Customers chart shows them distributed rather than all in one bar.
|
||||
int currentMonth = now.Month;
|
||||
for (int i = 0; i < anchors.Count; i++)
|
||||
{
|
||||
int month = 1 + (i * currentMonth / anchors.Count);
|
||||
month = Math.Clamp(month, 1, currentMonth);
|
||||
int day = 3 + (i % 20);
|
||||
anchors[i].CreatedAt = new DateTime(now.Year, month, day, 8, 0, 0, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
// Insert anchors in deterministic order (commercial first, then individual).
|
||||
// This guarantees that job seeder indices 0-9 = commercial, 10-26 = individual.
|
||||
foreach (var anchor in anchors)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existingCustomer = await _context.Set<Customer>()
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(c => c.Email == customer.Email
|
||||
&& c.CompanyId == company.Id && !c.IsDeleted);
|
||||
|
||||
if (existingCustomer != null)
|
||||
{
|
||||
skippedCount++;
|
||||
var name = customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}";
|
||||
warnings.Add($"⊘ Skipped: {name} — email {customer.Email} already exists");
|
||||
continue;
|
||||
}
|
||||
|
||||
await _context.Set<Customer>().AddAsync(customer);
|
||||
var dup = await _context.Set<Customer>().IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(c => c.Email == anchor.Email && c.CompanyId == company.Id && !c.IsDeleted);
|
||||
if (dup != null) { warnings.Add($"Skipped anchor {anchor.Email} — already exists"); continue; }
|
||||
await _context.Set<Customer>().AddAsync(anchor);
|
||||
await _context.SaveChangesAsync();
|
||||
seededCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
skippedCount++;
|
||||
var name = customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}";
|
||||
warnings.Add($"⊘ Skipped: {name} — {GetFriendlyErrorMessage(ex, "customer")}");
|
||||
if (_context.Entry(customer).State != EntityState.Detached)
|
||||
_context.Entry(customer).State = EntityState.Detached;
|
||||
warnings.Add($"Skipped anchor {anchor.Email} — {GetFriendlyErrorMessage(ex, "customer")}");
|
||||
if (_context.Entry(anchor).State != Microsoft.EntityFrameworkCore.EntityState.Detached)
|
||||
_context.Entry(anchor).State = Microsoft.EntityFrameworkCore.EntityState.Detached;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Name and location pools for procedural fill customers ─────────────
|
||||
string[] firstNames =
|
||||
{
|
||||
"Liam", "Emma", "Noah", "Olivia", "Oliver", "Charlotte", "Elijah", "Amelia",
|
||||
"Aiden", "Harper", "Lucas", "Evelyn", "Mason", "Grace", "Ethan", "Sofia",
|
||||
"Logan", "Chloe", "Caleb", "Victoria", "Ryan", "Zoe", "Nathan", "Lily",
|
||||
"Carter", "Aria", "Dylan", "Nora", "Brandon", "Hazel",
|
||||
};
|
||||
string[] lastNames =
|
||||
{
|
||||
"Mitchell", "Reyes", "Turner", "Phillips", "Campbell", "Parker",
|
||||
"Evans", "Edwards", "Collins", "Stewart", "Fletcher", "Morris",
|
||||
"Morgan", "Bell", "Murphy", "Bailey", "Rivera", "Cooper",
|
||||
"Richardson","Cox", "Howard", "Ward", "Torres", "Peterson",
|
||||
"Gray", "Ramirez", "James", "Watson", "Brooks", "Kelly",
|
||||
};
|
||||
string[] cities = { "Raleigh", "Durham", "Cary", "Apex", "Wake Forest", "Garner",
|
||||
"Morrisville", "Fuquay-Varina", "Holly Springs", "Knightdale",
|
||||
"Wendell", "Clayton", "Zebulon", "Pittsboro", "Lillington" };
|
||||
string[] zips = { "27601", "27707", "27511", "27502", "27587", "27529",
|
||||
"27560", "27526", "27540", "27545",
|
||||
"27591", "27520", "27597", "27312", "27546" };
|
||||
string[] domains = { "gmail.com", "yahoo.com", "outlook.com", "hotmail.com", "icloud.com", "aol.com" };
|
||||
|
||||
// Count anchors already assigned to each calendar month
|
||||
var anchorsPerMonth = new int[currentMonth + 1];
|
||||
foreach (var a in anchors)
|
||||
{
|
||||
if (a.CreatedAt.Month >= 1 && a.CreatedAt.Month <= currentMonth)
|
||||
anchorsPerMonth[a.CreatedAt.Month]++;
|
||||
}
|
||||
|
||||
// Fill each month to exactly 15 customers using procedurally generated individuals.
|
||||
// These go into db after the 27 anchors, so job seeder indices 27+ get 0 jobs (default case).
|
||||
int genIdx = 0;
|
||||
for (int month = 1; month <= currentMonth; month++)
|
||||
{
|
||||
int needed = Math.Max(0, 15 - anchorsPerMonth[month]);
|
||||
var monthStart = new DateTime(now.Year, month, 1, 9, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
for (int slot = 0; slot < needed; slot++, genIdx++)
|
||||
{
|
||||
string fn = firstNames[genIdx % firstNames.Length];
|
||||
string ln = lastNames[(genIdx / firstNames.Length) % lastNames.Length];
|
||||
string city = cities[genIdx % cities.Length];
|
||||
string zip = zips[genIdx % zips.Length];
|
||||
string email = $"{fn.ToLower()}{ln.ToLower()}{genIdx + 1}@{domains[genIdx % domains.Length]}";
|
||||
string phone = $"({600 + genIdx % 400}) {200 + genIdx % 800:D3}-{genIdx % 9000 + 1000:D4}";
|
||||
int day = needed > 1 ? 1 + (slot * 27 / needed) : 14;
|
||||
|
||||
var gen = new Customer
|
||||
{
|
||||
ContactFirstName = fn,
|
||||
ContactLastName = ln,
|
||||
Email = email,
|
||||
Phone = phone,
|
||||
City = city,
|
||||
State = "NC",
|
||||
ZipCode = zip,
|
||||
IsCommercial = false,
|
||||
PaymentTerms = "Due on receipt",
|
||||
IsActive = true,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = monthStart.AddDays(day),
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await _context.Set<Customer>().AddAsync(gen);
|
||||
await _context.SaveChangesAsync();
|
||||
seededCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
warnings.Add($"Skipped generated customer {email} — {GetFriendlyErrorMessage(ex, "customer")}");
|
||||
if (_context.Entry(gen).State != Microsoft.EntityFrameworkCore.EntityState.Detached)
|
||||
_context.Entry(gen).State = Microsoft.EntityFrameworkCore.EntityState.Detached;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ public partial class SeedDataService
|
||||
{
|
||||
new Equipment
|
||||
{
|
||||
EquipmentName = "Batch Powder Coating Oven #1",
|
||||
EquipmentName = "Main Batch Oven",
|
||||
EquipmentNumber = $"{company.CompanyCode}-OVN-001",
|
||||
EquipmentType = "Oven",
|
||||
Manufacturer = "Reliant Finishing Systems",
|
||||
@@ -78,7 +78,7 @@ public partial class SeedDataService
|
||||
},
|
||||
new Equipment
|
||||
{
|
||||
EquipmentName = "Batch Powder Coating Oven #2",
|
||||
EquipmentName = "Small Batch Oven",
|
||||
EquipmentNumber = $"{company.CompanyCode}-OVN-002",
|
||||
EquipmentType = "Oven",
|
||||
Manufacturer = "Reliant Finishing Systems",
|
||||
@@ -99,7 +99,7 @@ public partial class SeedDataService
|
||||
},
|
||||
new Equipment
|
||||
{
|
||||
EquipmentName = "Automated Powder Coating Booth #1",
|
||||
EquipmentName = "Powder Coating Booth",
|
||||
EquipmentNumber = $"{company.CompanyCode}-BOOTH-001",
|
||||
EquipmentType = "Spray Booth",
|
||||
Manufacturer = "Nordson Corporation",
|
||||
@@ -120,7 +120,7 @@ public partial class SeedDataService
|
||||
},
|
||||
new Equipment
|
||||
{
|
||||
EquipmentName = "Manual Powder Coating Booth #2",
|
||||
EquipmentName = "Manual Powder Booth",
|
||||
EquipmentNumber = $"{company.CompanyCode}-BOOTH-002",
|
||||
EquipmentType = "Spray Booth",
|
||||
Manufacturer = "Columbia Coatings",
|
||||
@@ -162,7 +162,7 @@ public partial class SeedDataService
|
||||
},
|
||||
new Equipment
|
||||
{
|
||||
EquipmentName = "Media Blast Room",
|
||||
EquipmentName = "Pressure Pot Blaster",
|
||||
EquipmentNumber = $"{company.CompanyCode}-BLAST-002",
|
||||
EquipmentType = "Sandblaster",
|
||||
Manufacturer = "Clemco Industries",
|
||||
@@ -183,7 +183,7 @@ public partial class SeedDataService
|
||||
},
|
||||
new Equipment
|
||||
{
|
||||
EquipmentName = "Rotary Screw Air Compressor",
|
||||
EquipmentName = "Air Compressor",
|
||||
EquipmentNumber = $"{company.CompanyCode}-COMP-001",
|
||||
EquipmentType = "Compressor",
|
||||
Manufacturer = "Atlas Copco",
|
||||
@@ -204,28 +204,28 @@ public partial class SeedDataService
|
||||
},
|
||||
new Equipment
|
||||
{
|
||||
EquipmentName = "Overhead Conveyor System",
|
||||
EquipmentNumber = $"{company.CompanyCode}-CONV-001",
|
||||
EquipmentType = "Conveyor",
|
||||
Manufacturer = "Pacline Conveyors",
|
||||
Model = "PAC-500 Overhead",
|
||||
EquipmentName = "Forklift",
|
||||
EquipmentNumber = $"{company.CompanyCode}-FORK-001",
|
||||
EquipmentType = "Forklift",
|
||||
Manufacturer = "Toyota",
|
||||
Model = "8FGCU25",
|
||||
SerialNumber = "PAC50034521",
|
||||
PurchaseDate = DateTime.UtcNow.AddYears(-4),
|
||||
PurchasePrice = 52000m,
|
||||
PurchasePrice = 28000m,
|
||||
WarrantyExpiration = DateTime.UtcNow.AddYears(-2),
|
||||
Status = EquipmentStatus.Operational,
|
||||
Location = "Main Production Line",
|
||||
Location = "Loading Area",
|
||||
RecommendedMaintenanceIntervalDays = 180,
|
||||
LastMaintenanceDate = DateTime.UtcNow.AddDays(-120),
|
||||
NextScheduledMaintenance = DateTime.UtcNow.AddDays(60),
|
||||
Notes = "500 lb capacity overhead conveyor with power and free sections",
|
||||
LastMaintenanceDate = DateTime.UtcNow.AddDays(-90),
|
||||
NextScheduledMaintenance = DateTime.UtcNow.AddDays(90),
|
||||
Notes = "5,000 lb capacity propane forklift — used for loading/unloading customer parts",
|
||||
IsActive = true,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = DateTime.UtcNow.AddYears(-4)
|
||||
},
|
||||
new Equipment
|
||||
{
|
||||
EquipmentName = "Parts Washer System",
|
||||
EquipmentName = "Wash Station",
|
||||
EquipmentNumber = $"{company.CompanyCode}-WASH-001",
|
||||
EquipmentType = "Washer",
|
||||
Manufacturer = "Better Engineering",
|
||||
|
||||
+29
-16
@@ -54,14 +54,22 @@ public partial class SeedDataService
|
||||
|
||||
if (items.Count == 0) return 0;
|
||||
|
||||
// Load completed/delivered jobs to generate usage transactions against
|
||||
var completedJobs = await _context.Set<Job>()
|
||||
// Two-query approach: resolve status IDs first to avoid Include() navigation
|
||||
// returning null when global query filters interact with IgnoreQueryFilters().
|
||||
var completedStatusIds = await _context.Set<JobStatusLookup>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(s => s.CompanyId == company.Id
|
||||
&& new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }.Contains(s.StatusCode))
|
||||
.Select(s => s.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var completedJobs = completedStatusIds.Count == 0
|
||||
? new List<Job>()
|
||||
: await _context.Set<Job>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(j => j.CompanyId == company.Id && !j.IsDeleted
|
||||
&& (j.JobStatus.StatusCode == "COMPLETED"
|
||||
|| j.JobStatus.StatusCode == "READY_FOR_PICKUP"
|
||||
|| j.JobStatus.StatusCode == "DELIVERED"))
|
||||
.Include(j => j.JobStatus)
|
||||
&& completedStatusIds.Contains(j.JobStatusId))
|
||||
.Include(j => j.JobItems)
|
||||
.OrderBy(j => j.Id)
|
||||
.ToListAsync();
|
||||
|
||||
@@ -90,14 +98,18 @@ public partial class SeedDataService
|
||||
});
|
||||
}
|
||||
|
||||
// ── Purchase transactions — 3 months of restocks ──────────────────────
|
||||
// Simulate monthly powder purchases for top items
|
||||
var powderItems = items.Take(8).ToList(); // focus on powder coat items
|
||||
|
||||
foreach (var (offset, qtyMult) in new (int daysAgo, decimal mult)[] {
|
||||
(85, 1.2m), (55, 1.0m), (25, 0.9m) })
|
||||
// ── Purchase transactions — 12 months of monthly restocks ────────────
|
||||
var powderItems = items.Take(8).ToList();
|
||||
// Quantities vary slightly month to month to give the inventory chart a natural shape
|
||||
var purchaseOffsets = new (int daysAgo, decimal mult)[]
|
||||
{
|
||||
foreach (var item in powderItems.Take(4)) // 4 items per purchase cycle
|
||||
(365, 0.80m), (335, 0.85m), (305, 0.90m), (275, 0.95m),
|
||||
(245, 1.00m), (215, 1.05m), (185, 1.10m), (155, 1.10m),
|
||||
(125, 1.00m), ( 95, 1.10m), ( 65, 1.00m), ( 35, 0.95m)
|
||||
};
|
||||
foreach (var (offset, qtyMult) in purchaseOffsets)
|
||||
{
|
||||
foreach (var item in powderItems.Take(4))
|
||||
{
|
||||
var qty = Math.Round(25m * qtyMult, 0);
|
||||
txns.Add(new InventoryTransaction
|
||||
@@ -144,9 +156,10 @@ public partial class SeedDataService
|
||||
|
||||
foreach (var (job, idx) in completedJobs.Select((j, i) => (j, i)))
|
||||
{
|
||||
// Completed date spread: most within the last 60 days
|
||||
var daysAgo = 10 + (idx % 55);
|
||||
var usageDate = now.AddDays(-daysAgo);
|
||||
// Use the job's actual completion date so powder usage history spans the same
|
||||
// 12-month window as jobs, giving the Powder Usage report non-trivial data in
|
||||
// every month rather than clustering everything in the last 60 days.
|
||||
var usageDate = (job.CompletedDate ?? job.ScheduledDate ?? now.AddDays(-30)).Date;
|
||||
|
||||
// Pick a color-matched powder item (or rotate)
|
||||
var firstItem = job.JobItems?.FirstOrDefault();
|
||||
|
||||
@@ -183,6 +183,23 @@ public partial class SeedDataService
|
||||
}
|
||||
}
|
||||
|
||||
// ── Months −12 through −4: 8 paid invoices per month ────────────────
|
||||
// 8 × avg ~$650 = ~$5,200/month collected revenue, which exceeds the seeded
|
||||
// ~$4,200/month in operating expenses, so the P&L chart shows a consistent profit.
|
||||
// Payment methods and tax rates alternate for variety in the ledger.
|
||||
var histMethods = new[] { PaymentMethod.BankTransferACH, PaymentMethod.Check, PaymentMethod.CreditDebitCard, PaymentMethod.Cash };
|
||||
for (int monthBack = 12; monthBack >= 4; monthBack--)
|
||||
{
|
||||
for (int inv = 0; inv < 8; inv++)
|
||||
{
|
||||
var daysAgo = monthBack * 30 + 25 - (inv * 3);
|
||||
var taxPct = (monthBack + inv) % 2 == 0 ? 7.5m : 0m;
|
||||
var method = histMethods[(monthBack * 8 + inv) % histMethods.Length];
|
||||
var chkRef = method == PaymentMethod.Check ? $"CHK-{9000 + monthBack * 8 + inv:D4}" : null;
|
||||
await Inv(InvoiceStatus.Paid, daysAgo, 30, taxPct, "Net 30", "Thank you for your business!", method, chkRef);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Month −3 (6 paid) ─────────────────────────────────────────────────
|
||||
await Inv(InvoiceStatus.Paid, 88, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH);
|
||||
await Inv(InvoiceStatus.Paid, 84, 30, 7.5m, "Net 30", "Thank you for your business!", PaymentMethod.Check, "CHK-1041");
|
||||
@@ -201,24 +218,35 @@ public partial class SeedDataService
|
||||
await Inv(InvoiceStatus.PartiallyPaid, 40, 30, 0m, "Net 30", "50% deposit received — balance due.", PaymentMethod.Check, "CHK-1053");
|
||||
await Inv(InvoiceStatus.PartiallyPaid, 37, 30, 7.5m, "Net 30", "Deposit on file — balance due on pickup.", PaymentMethod.BankTransferACH);
|
||||
|
||||
// ── Month −1 (5 paid + 2 partial + 2 sent) ───────────────────────────
|
||||
await Inv(InvoiceStatus.Paid, 32, 30, 0m, "Net 30", "Thank you!", PaymentMethod.BankTransferACH);
|
||||
await Inv(InvoiceStatus.Paid, 28, 30, 7.5m, "Net 30", "Thank you for your business!", PaymentMethod.Check, "CHK-1056");
|
||||
await Inv(InvoiceStatus.Paid, 24, 14, 0m, "Net 14", "Thank you!", PaymentMethod.Cash);
|
||||
await Inv(InvoiceStatus.Paid, 20, 30, 7.5m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH);
|
||||
await Inv(InvoiceStatus.Paid, 16, 30, 0m, "Net 30", "Thank you!", PaymentMethod.CreditDebitCard);
|
||||
// ── Month −1 (7 paid + 2 partial + 2 sent) ───────────────────────────
|
||||
// 7 paid × avg ~$650 + 2 partial × 50% × avg ~$650 ≈ $5,200 collected — above expenses.
|
||||
await Inv(InvoiceStatus.Paid, 35, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH);
|
||||
await Inv(InvoiceStatus.Paid, 32, 30, 7.5m, "Net 30", "Thank you!", PaymentMethod.Check, "CHK-1054");
|
||||
await Inv(InvoiceStatus.Paid, 28, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH);
|
||||
await Inv(InvoiceStatus.Paid, 24, 14, 7.5m, "Net 14", "Thank you!", PaymentMethod.Cash);
|
||||
await Inv(InvoiceStatus.Paid, 20, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH);
|
||||
await Inv(InvoiceStatus.Paid, 18, 30, 7.5m, "Net 30", "Thank you!", PaymentMethod.CreditDebitCard);
|
||||
await Inv(InvoiceStatus.Paid, 16, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.Check, "CHK-1058");
|
||||
await Inv(InvoiceStatus.PartiallyPaid, 14, 30, 7.5m, "Net 30", "50% deposit received.", PaymentMethod.Check, "CHK-1060");
|
||||
await Inv(InvoiceStatus.PartiallyPaid, 11, 30, 0m, "Net 30", "50% deposit — balance due on completion.", PaymentMethod.BankTransferACH);
|
||||
await Inv(InvoiceStatus.Sent, 9, 30, 7.5m, "Net 30", "Payment due within 30 days.");
|
||||
await Inv(InvoiceStatus.Sent, 6, 30, 0m, "Net 30", "Payment due within 30 days.");
|
||||
|
||||
// ── Current month (1 overdue + 2 sent + 1 draft) ─────────────────────
|
||||
// Overdue: created 35 days ago on Net 14 terms → 21 days past due
|
||||
// Overdue: created 35 days ago on Net 14 terms → 21 days past due (1–30 bucket)
|
||||
await Inv(InvoiceStatus.Sent, 35, 14, 7.5m, "Net 14", "PAST DUE — please remit payment immediately.");
|
||||
await Inv(InvoiceStatus.Sent, 4, 30, 0m, "Net 30", "Payment due within 30 days.");
|
||||
await Inv(InvoiceStatus.Sent, 2, 30, 7.5m, "Net 30", "Payment due within 30 days.");
|
||||
await Inv(InvoiceStatus.Draft, 1, 30, 0m, "Net 30", null);
|
||||
|
||||
// ── AR Aging demo invoices — populate all four overdue buckets ────────
|
||||
// 31–60 day bucket: issued 55 days ago, Net 10 → 45 days past due
|
||||
await Inv(InvoiceStatus.Sent, 55, 10, 0m, "Net 10", "PAST DUE 45 days — second notice sent.");
|
||||
// 61–90 day bucket: issued 80 days ago, Net 10 → 70 days past due
|
||||
await Inv(InvoiceStatus.Sent, 80, 10, 7.5m, "Net 10", "PAST DUE 70 days — final notice. Collections pending.");
|
||||
// 90+ day bucket: issued 120 days ago, Net 14 → 106 days past due
|
||||
await Inv(InvoiceStatus.PartiallyPaid, 120, 14, 0m, "Net 14", "PAST DUE 106 days — partial payment received, balance outstanding.");
|
||||
|
||||
return seeded;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,41 +7,30 @@ namespace PowderCoating.Infrastructure.Services;
|
||||
public partial class SeedDataService
|
||||
{
|
||||
/// <summary>
|
||||
/// Seeds 50 powder coating jobs that collectively demonstrate all 16 job statuses,
|
||||
/// realistic date progressions, varied priorities, and quote linkage for the first 25 jobs.
|
||||
/// Seeds 50 powder coating jobs distributed across all 16 statuses with a realistic
|
||||
/// weighted distribution (Delivered most common, exactly one Cancelled, one OnHold)
|
||||
/// and a shuffled visit order so jobs from different customers are interleaved naturally
|
||||
/// rather than appearing as per-customer blocks.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Idempotency: returns 0 immediately if any non-deleted jobs already exist for this company.
|
||||
/// Per-customer job counts and price ranges are defined by <c>CustomerProfile(ci)</c>
|
||||
/// where <c>ci</c> is the customer's position in the Id-ascending list seeded by
|
||||
/// <c>SeedCustomersAsync</c> (0 = Carolina Fabrication, the largest account).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The method depends on job-status and job-priority lookup rows (populated earlier in the
|
||||
/// seed sequence), and on at least one customer record. It returns 0 if any of these
|
||||
/// dependencies are missing so the overall seed degrades gracefully.
|
||||
/// A shuffled <c>visitSchedule</c> (fixed seed 42) drives the outer loop so that
|
||||
/// Carolina Fabrication, Apex Motorsports, and individual customers appear
|
||||
/// interleaved in creation-date order rather than in consecutive customer blocks.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Job numbers follow the production format <c>JOB-YYMM-####</c>. The seeder scans
|
||||
/// existing numbers with the current month prefix and starts its sequence above the current
|
||||
/// maximum so demo jobs never collide with real jobs created in the same calendar month.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The first 25 jobs are linked to approved quotes (loaded from the previously seeded
|
||||
/// quotes). When a match is found the job inherits the quote's customer, description,
|
||||
/// quoted price, and customer PO — matching the production quote-to-job conversion path.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Date logic groups jobs into three buckets: early-stage (future scheduled date),
|
||||
/// in-progress (past start date, no completion), and completed/terminal (both started
|
||||
/// and completed dates in the past). This ensures the dashboard pipeline and calendar
|
||||
/// views display a realistic spread rather than all jobs sharing the same date.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The <c>IgnoreQueryFilters()</c> call on the existence check ensures that soft-deleted
|
||||
/// leftover jobs from a previous seed run are detected and do not cause duplicate inserts.
|
||||
/// Status pool (fixed seed 99): Delivered ×10, Completed ×8,
|
||||
/// ReadyForPickup ×5, then decreasing counts for in-progress stages, with
|
||||
/// exactly one Cancelled and one OnHold.
|
||||
/// Priority pool (fixed seed 77): Normal 76 %, High 12 %, Urgent 8 %,
|
||||
/// Rush 4 % — rush is genuinely rare.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="company">The tenant company to seed jobs for.</param>
|
||||
/// <returns>Number of jobs inserted, or 0 if already seeded or dependencies are missing.</returns>
|
||||
private async Task<int> SeedJobsAsync(Company company)
|
||||
{
|
||||
var existingCount = await _context.Set<Job>()
|
||||
@@ -73,11 +62,11 @@ public partial class SeedDataService
|
||||
if (customers.Count == 0)
|
||||
return 0;
|
||||
|
||||
// Grab approved quotes to link to jobs
|
||||
var approvedQuotes = await _context.Set<Quote>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(q => q.CompanyId == company.Id && q.QuoteStatus.StatusCode == "APPROVED")
|
||||
.OrderBy(q => q.Id)
|
||||
.OrderBy(q => q.CustomerId)
|
||||
.ThenBy(q => q.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var shopUsers = await _context.Set<ApplicationUser>()
|
||||
@@ -86,7 +75,6 @@ public partial class SeedDataService
|
||||
.ToListAsync();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var prefix = $"JOB-{now:yy}{now.Month:D2}-";
|
||||
var existing = await _context.Set<Job>()
|
||||
.IgnoreQueryFilters()
|
||||
@@ -98,129 +86,240 @@ public partial class SeedDataService
|
||||
if (n.Length >= 13 && int.TryParse(n.Substring(9, 4), out var x) && x > maxNum) maxNum = x;
|
||||
var seq = maxNum + 1;
|
||||
|
||||
// ── Status plan (50 jobs, covering all 16 statuses) ──────────────────
|
||||
// Active pipeline: PENDING(4) QUOTED(3) APPROVED(4) IN_PREPARATION(4)
|
||||
// SANDBLASTING(4) MASKING_TAPING(3) CLEANING(3) IN_OVEN(3)
|
||||
// COATING(4) CURING(3) QUALITY_CHECK(3) COMPLETED(5)
|
||||
// READY_FOR_PICKUP(4) DELIVERED(3) ON_HOLD(2) CANCELLED(2)
|
||||
//
|
||||
// Maps job index to a status code, distributing all 16 statuses across 50 jobs.
|
||||
// ON_HOLD and CANCELLED are placed last (indices 48–49) because they are terminal
|
||||
// side-branches that affect date logic and status history traversal differently.
|
||||
static string StatusFor(int i) => i switch
|
||||
// ── Per-customer profile: (jobCount, minJobValue, maxJobValue) ─────────
|
||||
// Indices match the customer insertion order from SeedCustomersAsync (ascending Id):
|
||||
// 0=Carolina Fabrication, 1=Apex Motorsports, 2=Triangle Offroad,
|
||||
// 3=Smith Welding, 4=Raleigh Architectural, 5=East Coast Powderworks,
|
||||
// 6=Piedmont Metal Works, 7=Cary Industrial, 8=Durham Tech, 9=Wake County Fleet,
|
||||
// 10–26 = individual residential customers
|
||||
static (int count, decimal minVal, decimal maxVal) CustomerProfile(int ci) => ci switch
|
||||
{
|
||||
< 4 => "PENDING",
|
||||
< 7 => "QUOTED",
|
||||
< 11 => "APPROVED",
|
||||
< 15 => "IN_PREPARATION",
|
||||
< 19 => "SANDBLASTING",
|
||||
< 22 => "MASKING_TAPING",
|
||||
< 25 => "CLEANING",
|
||||
< 28 => "IN_OVEN",
|
||||
< 32 => "COATING",
|
||||
< 35 => "CURING",
|
||||
< 38 => "QUALITY_CHECK",
|
||||
< 43 => "COMPLETED",
|
||||
< 47 => "READY_FOR_PICKUP",
|
||||
< 48 => "DELIVERED",
|
||||
< 49 => "ON_HOLD",
|
||||
_ => "CANCELLED"
|
||||
0 => (7, 800m, 2500m), // Carolina Fabrication — largest account
|
||||
1 => (6, 400m, 1500m), // Apex Motorsports
|
||||
2 => (5, 350m, 1200m), // Triangle Offroad
|
||||
3 => (4, 250m, 800m), // Smith Welding
|
||||
4 => (4, 300m, 900m), // Raleigh Architectural Metals
|
||||
5 => (3, 200m, 600m), // East Coast Powderworks
|
||||
6 => (3, 150m, 450m), // Piedmont Metal Works
|
||||
7 => (2, 200m, 500m), // Cary Industrial Solutions
|
||||
8 => (2, 350m, 900m), // Durham Tech Equipment
|
||||
9 => (3, 400m, 1500m), // Wake County Fleet Services
|
||||
10 => (2, 75m, 250m), // John Davis
|
||||
11 => (1, 150m, 350m), // Sarah Jenkins
|
||||
12 => (1, 200m, 400m), // Mike Thompson
|
||||
13 => (2, 100m, 300m), // Robert Miller
|
||||
14 => (0, 0m, 0m), // Jennifer Clark — prospect only
|
||||
15 => (1, 100m, 250m), // David Wilson
|
||||
16 => (0, 0m, 0m), // Lisa Anderson — prospect only
|
||||
17 => (1, 150m, 300m), // Thomas Harris
|
||||
18 => (0, 0m, 0m), // Karen White — no jobs yet
|
||||
19 => (1, 250m, 500m), // James Taylor
|
||||
20 => (0, 0m, 0m), // Michelle Brown — no jobs yet
|
||||
21 => (1, 100m, 250m), // Chris Lee
|
||||
22 => (0, 0m, 0m), // Amanda Garcia — no jobs yet
|
||||
23 => (1, 150m, 350m), // Kevin Martinez
|
||||
24 => (0, 0m, 0m), // Nancy Rodriguez — no jobs yet
|
||||
25 => (0, 0m, 0m), // Brian Hall — no jobs yet
|
||||
_ => (0, 0m, 0m), // Patricia Young — no jobs yet
|
||||
};
|
||||
|
||||
// Maps job index modulo 10 to a priority code. RUSH and URGENT are intentionally
|
||||
// over-represented (4 of 10) relative to production averages so the priority colour
|
||||
// badges and rush-fee logic are clearly visible in demo data.
|
||||
static string PriorityFor(int i) => (i % 10) switch
|
||||
// ── Status pool: realistic shop distribution (total = 50) ──────────────
|
||||
// Delivered and Completed dominate; exactly one Cancelled and one OnHold.
|
||||
var statusPool = new List<string>();
|
||||
foreach (var (code, count) in new (string Code, int Count)[]
|
||||
{
|
||||
0 => "RUSH",
|
||||
1 => "RUSH",
|
||||
2 => "URGENT",
|
||||
3 => "URGENT",
|
||||
4 => "HIGH",
|
||||
5 => "HIGH",
|
||||
6 => "HIGH",
|
||||
_ => "NORMAL"
|
||||
};
|
||||
("DELIVERED", 10),
|
||||
("COMPLETED", 8),
|
||||
("READY_FOR_PICKUP", 5),
|
||||
("IN_PREPARATION", 4),
|
||||
("SANDBLASTING", 4),
|
||||
("COATING", 3),
|
||||
("QUALITY_CHECK", 3),
|
||||
("CURING", 3),
|
||||
("MASKING_TAPING", 2),
|
||||
("IN_OVEN", 2),
|
||||
("CLEANING", 1),
|
||||
("PENDING", 1),
|
||||
("QUOTED", 1),
|
||||
("APPROVED", 1),
|
||||
("ON_HOLD", 1),
|
||||
("CANCELLED", 1),
|
||||
})
|
||||
{
|
||||
for (int k = 0; k < count; k++) statusPool.Add(code);
|
||||
}
|
||||
// Fisher-Yates shuffle with a fixed seed so resets produce the same distribution
|
||||
var statusRng = new Random(99);
|
||||
for (int k = statusPool.Count - 1; k > 0; k--)
|
||||
{
|
||||
var swap = statusRng.Next(k + 1);
|
||||
(statusPool[k], statusPool[swap]) = (statusPool[swap], statusPool[k]);
|
||||
}
|
||||
|
||||
// Returns description, finish color, prep flags, and estimated minutes for a job item.
|
||||
// Indexed by (i * 3 + j) % 15 so that item variety cycles independently of the job index,
|
||||
// preventing every job from having the same first item.
|
||||
// ── Priority pool: realistic distribution (total = 50) ─────────────────
|
||||
// Rush jobs are genuinely rare; most work is Normal priority.
|
||||
var priorityPool = new List<string>();
|
||||
foreach (var (code, count) in new (string Code, int Count)[]
|
||||
{
|
||||
("NORMAL", 38),
|
||||
("HIGH", 6),
|
||||
("URGENT", 4),
|
||||
("RUSH", 2),
|
||||
})
|
||||
{
|
||||
for (int k = 0; k < count; k++) priorityPool.Add(code);
|
||||
}
|
||||
var priorityRng = new Random(77);
|
||||
for (int k = priorityPool.Count - 1; k > 0; k--)
|
||||
{
|
||||
var swap = priorityRng.Next(k + 1);
|
||||
(priorityPool[k], priorityPool[swap]) = (priorityPool[swap], priorityPool[k]);
|
||||
}
|
||||
|
||||
// ── Customer visit schedule: interleave commercial (ci 0–9) and individual (ci 10+) ──
|
||||
// A plain Fisher-Yates on the full list clusters commercial entries because they
|
||||
// outnumber individual ones 4:1; splitting into two pools and distributing
|
||||
// individual jobs evenly throughout ensures the two types never appear in blocks.
|
||||
var commercialVisits = new List<int>();
|
||||
var individualVisits = new List<int>();
|
||||
for (int ci = 0; ci < customers.Count; ci++)
|
||||
{
|
||||
var (numJobs, _, _) = CustomerProfile(ci);
|
||||
for (int j = 0; j < numJobs; j++)
|
||||
(ci < 10 ? commercialVisits : individualVisits).Add(ci);
|
||||
}
|
||||
var rngC = new Random(42);
|
||||
for (int k = commercialVisits.Count - 1; k > 0; k--)
|
||||
{
|
||||
var swap = rngC.Next(k + 1);
|
||||
(commercialVisits[k], commercialVisits[swap]) = (commercialVisits[swap], commercialVisits[k]);
|
||||
}
|
||||
var rngI = new Random(17);
|
||||
for (int k = individualVisits.Count - 1; k > 0; k--)
|
||||
{
|
||||
var swap = rngI.Next(k + 1);
|
||||
(individualVisits[k], individualVisits[swap]) = (individualVisits[swap], individualVisits[k]);
|
||||
}
|
||||
// Distribute individual visits at evenly-spaced positions throughout the commercial list
|
||||
var visitSchedule = new List<int>(commercialVisits.Count + individualVisits.Count);
|
||||
double indStride = individualVisits.Count > 0
|
||||
? (commercialVisits.Count + 1.0) / (individualVisits.Count + 1.0)
|
||||
: double.MaxValue;
|
||||
int indInsertIdx = 0;
|
||||
for (int comIdx = 0; comIdx < commercialVisits.Count; comIdx++)
|
||||
{
|
||||
while (indInsertIdx < individualVisits.Count && (indInsertIdx + 1) * indStride <= comIdx + 1)
|
||||
visitSchedule.Add(individualVisits[indInsertIdx++]);
|
||||
visitSchedule.Add(commercialVisits[comIdx]);
|
||||
}
|
||||
while (indInsertIdx < individualVisits.Count)
|
||||
visitSchedule.Add(individualVisits[indInsertIdx++]);
|
||||
|
||||
// Job item descriptions and specs — 15-item pool cycling via (visitIdx*3 + itemIdx) % 15.
|
||||
static (string desc, string color, bool sand, bool mask, int mins) ItemSpec(int i, int j) =>
|
||||
((i * 3 + j) % 15) switch
|
||||
{
|
||||
0 => ("18\" Aluminum Wheels — Matte Black", "Matte Black", true, false, 45),
|
||||
1 => ("17\" Steel Wheels — Gloss White", "Gloss White", false, false, 30),
|
||||
2 => ("Valve Covers — Wrinkle Red", "Wrinkle Red", true, true, 40),
|
||||
3 => ("Motorcycle Frame — Flat Black", "Flat Black", true, false, 90),
|
||||
4 => ("Steel Shelving Units", "Textured Gray", true, false, 55),
|
||||
0 => ("18\" Aluminum Wheels (set of 4)", "Gloss Black", false, false, 45),
|
||||
1 => ("17\" Steel Wheels (set of 4)", "Signal White", false, false, 30),
|
||||
2 => ("Jeep Bumper & Rock Sliders", "Matte Black", true, false, 60),
|
||||
3 => ("Motorcycle Frame", "Matte Black", true, false, 90),
|
||||
4 => ("Steel Shelving Units (10-shelf set)", "Textured Gray", true, false, 55),
|
||||
5 => ("Industrial Machine Guard Panels", "Safety Yellow", false, false, 35),
|
||||
6 => ("Aluminum Window Frames", "Satin Bronze", false, true, 50),
|
||||
7 => ("Steel Handrail — 40 ft run", "Gloss Black", true, false, 120),
|
||||
8 => ("Wrought Iron Gate", "Hammered Black", true, false, 180),
|
||||
9 => ("Brake Calipers — Gloss Yellow", "Gloss Yellow", false, true, 35),
|
||||
6 => ("Aluminum Window Frames (set of 8)", "Satin Bronze", false, true, 50),
|
||||
7 => ("Steel Handrail System — 40 ft", "Gloss Black", true, false, 120),
|
||||
8 => ("Wrought Iron Entry Gate", "Hammered Black", true, false, 180),
|
||||
9 => ("Brake Calipers (set of 4)", "Candy Red", false, true, 35),
|
||||
10 => ("Restaurant Chair Frames (set of 20)", "Hammered Bronze", false, false, 60),
|
||||
11 => ("Bicycle Frame — Candy Blue", "Candy Blue", true, true, 60),
|
||||
11 => ("Bicycle Frame", "Candy Red", true, true, 60),
|
||||
12 => ("Compressor Tank", "Safety Orange", true, false, 45),
|
||||
13 => ("Patio Furniture Set", "Textured Beige", false, false, 50),
|
||||
_ => ("Custom Steel Parts — Batch", "Matte Gray", true, false, 40)
|
||||
13 => ("Patio Furniture Set (6 pieces)", "Textured Beige", false, false, 50),
|
||||
_ => ("Custom Steel Fabrication — Batch", "Matte Black", true, false, 40)
|
||||
};
|
||||
|
||||
var jobs = new List<Job>();
|
||||
var quoteIdx = 0;
|
||||
var jobsByCustomer = new int[customers.Count]; // within-customer job counter per ci
|
||||
var jobIdx = 0; // global counter for misc modulos
|
||||
var inProgressCount = 0; // caps "Carried Over" card to 2 jobs
|
||||
var completedJobCount = 0; // drives linear date spread over 12 months
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
for (int visitIdx = 0; visitIdx < visitSchedule.Count; visitIdx++, jobIdx++, seq++)
|
||||
{
|
||||
var statusCode = StatusFor(i);
|
||||
var priorityCode = PriorityFor(i);
|
||||
var customer = customers[i % customers.Count];
|
||||
var ci = visitSchedule[visitIdx];
|
||||
var customer = customers[ci];
|
||||
var j = jobsByCustomer[ci]++; // within-customer job index
|
||||
var (_, minVal, maxVal) = CustomerProfile(ci);
|
||||
|
||||
// Link an approved quote to the first 25 in-progress/active jobs
|
||||
var statusCode = statusPool[visitIdx];
|
||||
var priorityCode = priorityPool[visitIdx];
|
||||
|
||||
// Try to link the first available approved quote for this customer
|
||||
Quote? linkedQuote = null;
|
||||
if (i < 25 && quoteIdx < approvedQuotes.Count)
|
||||
for (int qi = quoteIdx; qi < approvedQuotes.Count; qi++)
|
||||
{
|
||||
// Only link if the quote's customer matches OR if customers align by index
|
||||
linkedQuote = approvedQuotes[quoteIdx++];
|
||||
customer = customers.FirstOrDefault(c => c.Id == linkedQuote.CustomerId) ?? customer;
|
||||
if (approvedQuotes[qi].CustomerId == customer.Id)
|
||||
{
|
||||
linkedQuote = approvedQuotes[qi];
|
||||
quoteIdx = qi + 1;
|
||||
break;
|
||||
}
|
||||
// Every 4th job, forcibly consume the next available approved quote
|
||||
if (quoteIdx % 4 == 0 && qi == quoteIdx)
|
||||
{
|
||||
linkedQuote = approvedQuotes[qi];
|
||||
quoteIdx++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Date logic — creation spread from -21 days to today
|
||||
// Scheduled: future for early statuses, past for completed ones
|
||||
var isCompleted = statusCode is "COMPLETED" or "READY_FOR_PICKUP" or "DELIVERED" or "CANCELLED";
|
||||
// Date logic: completed jobs furthest back, ready-for-pickup recent past,
|
||||
// in-progress spread forward, pending/quoted in the future.
|
||||
var isCompleted = statusCode is "COMPLETED" or "DELIVERED" or "CANCELLED";
|
||||
var isReadyForPickup = statusCode == "READY_FOR_PICKUP";
|
||||
var isInProgress = statusCode is "IN_PREPARATION" or "SANDBLASTING" or "MASKING_TAPING"
|
||||
or "CLEANING" or "IN_OVEN" or "COATING" or "CURING" or "QUALITY_CHECK";
|
||||
var isEarly = statusCode is "PENDING" or "QUOTED" or "APPROVED";
|
||||
|
||||
int daysAgo = isCompleted ? 14 + (i % 7)
|
||||
: isInProgress ? 5 + (i % 7)
|
||||
: 0 + (i % 5);
|
||||
if (isInProgress) inProgressCount++;
|
||||
if (isCompleted) completedJobCount++;
|
||||
|
||||
// Completed jobs spread linearly ~1–12 months back for chart coverage.
|
||||
// Ready-for-pickup: job finished recently, just waiting on customer.
|
||||
// In-progress: first 3 are genuinely past-due ("Carried Over"); rest spread into future.
|
||||
int daysAgo = isCompleted ? 30 + (completedJobCount - 1) * 14
|
||||
: isReadyForPickup ? 5 + (visitIdx % 8)
|
||||
: isInProgress ? 10 + (visitIdx % 40)
|
||||
: 2 + (visitIdx % 15);
|
||||
var createdDate = now.AddDays(-daysAgo);
|
||||
var scheduledDate = isCompleted ? createdDate.AddDays(2)
|
||||
: isInProgress ? now.AddDays(-(i % 3))
|
||||
: now.AddDays(2 + (i % 10));
|
||||
|
||||
var scheduledDate = isCompleted ? createdDate.AddDays(3 + (visitIdx % 5))
|
||||
: isReadyForPickup ? now.AddDays(visitIdx % 5)
|
||||
: isInProgress ? now.AddDays(inProgressCount <= 3 ? -(4 - inProgressCount) : inProgressCount - 3)
|
||||
: now.AddDays(3 + (visitIdx % 12));
|
||||
var rushDays = priorityCode == "RUSH" ? 2 : priorityCode == "URGENT" ? 3 : 7;
|
||||
var dueDate = scheduledDate.AddDays(rushDays);
|
||||
var startedDate = (!isEarly) ? scheduledDate : (DateTime?)null;
|
||||
var completedDate = isCompleted ? scheduledDate.AddDays(1) : (DateTime?)null;
|
||||
var startedDate = isCompleted || isReadyForPickup || isInProgress ? (DateTime?)scheduledDate : null;
|
||||
var completedDate = isCompleted || isReadyForPickup ? scheduledDate.AddDays(1) : (DateTime?)null;
|
||||
|
||||
var assignedUserId = shopUsers.Count > 0 ? shopUsers[i % shopUsers.Count].Id : null;
|
||||
|
||||
var itemCount = 1 + (i % 3);
|
||||
// Per-customer value targeting: deterministic variance within the customer's price range
|
||||
var range = maxVal - minVal;
|
||||
var targetValue = minVal + range * ((ci * 7 + j * 13) % 100) / 100m;
|
||||
var itemCount = 1 + (visitIdx % 3);
|
||||
var items = new List<JobItem>();
|
||||
|
||||
for (int j = 0; j < itemCount; j++)
|
||||
for (int k = 0; k < itemCount; k++)
|
||||
{
|
||||
var (desc, color, sand, mask, mins) = ItemSpec(i, j);
|
||||
var qty = 1 + (j % 3);
|
||||
var unitPrice = linkedQuote != null && j == 0
|
||||
? Math.Round((linkedQuote.Total / itemCount), 2)
|
||||
: Math.Round(75m + (i % 8) * 12.5m + j * 15m, 2);
|
||||
var (desc, color, sand, mask, mins) = ItemSpec(visitIdx, k);
|
||||
var qty = 1 + (k % 3);
|
||||
var unitPrice = linkedQuote != null && k == 0
|
||||
? Math.Round(linkedQuote.Total / itemCount, 2)
|
||||
: Math.Round(targetValue / itemCount / qty, 2);
|
||||
|
||||
items.Add(new JobItem
|
||||
{
|
||||
Description = desc,
|
||||
Quantity = qty,
|
||||
ColorName = color,
|
||||
SurfaceAreaSqFt = 10m + j * 3.5m,
|
||||
SurfaceAreaSqFt = 10m + k * 3.5m,
|
||||
UnitPrice = unitPrice,
|
||||
TotalPrice = unitPrice * qty,
|
||||
LaborCost = Math.Round(unitPrice * qty * 0.35m, 2),
|
||||
@@ -240,7 +339,7 @@ public partial class SeedDataService
|
||||
JobNumber = $"{prefix}{seq:D4}",
|
||||
CustomerId = customer.Id,
|
||||
QuoteId = linkedQuote?.Id,
|
||||
AssignedUserId = assignedUserId,
|
||||
AssignedUserId = shopUsers.Count > 0 ? shopUsers[visitIdx % shopUsers.Count].Id : null,
|
||||
Description = linkedQuote?.Description
|
||||
?? $"Powder coating services for {customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()}",
|
||||
JobStatusId = jobStatuses[statusCode],
|
||||
@@ -252,18 +351,20 @@ public partial class SeedDataService
|
||||
QuotedPrice = quotedPrice,
|
||||
FinalPrice = finalPrice,
|
||||
IsRushJob = priorityCode == "RUSH",
|
||||
CustomerPO = linkedQuote?.CustomerPO ?? (i % 3 == 0 ? $"PO-{40000 + i}" : null),
|
||||
SpecialInstructions = i % 6 == 0 ? "Customer supplied parts — handle with extra care." :
|
||||
i % 11 == 0 ? "Match existing color exactly — bring sample for approval." : null,
|
||||
InternalNotes = i % 8 == 0 ? "Vintage parts — do not use aggressive blast media." : null,
|
||||
RequiresCustomerApproval = i % 5 == 0,
|
||||
IsCustomerApproved = i % 5 != 0 || !isEarly,
|
||||
CustomerPO = customer.IsCommercial && visitIdx % 3 == 0 ? $"PO-{40000 + visitIdx}" : null,
|
||||
SpecialInstructions = visitIdx % 6 == 0 ? "Customer supplied parts — handle with extra care." :
|
||||
visitIdx % 11 == 0 ? "Match existing color exactly — bring sample for approval." : null,
|
||||
InternalNotes = visitIdx % 8 == 0 ? "Vintage parts — do not use aggressive blast media." : null,
|
||||
RequiresCustomerApproval = visitIdx % 5 == 0,
|
||||
IsCustomerApproved = visitIdx % 5 != 0 || !isInProgress,
|
||||
JobItems = items,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = createdDate
|
||||
CreatedAt = createdDate,
|
||||
// Set UpdatedAt to the historical event date so analytics charts group into the
|
||||
// correct month. The EF interceptor only stamps UpdatedAt on Modified saves,
|
||||
// leaving it null for seeded entities, which the analytics filter treats as excluded.
|
||||
UpdatedAt = completedDate ?? (isInProgress ? scheduledDate : (DateTime?)null) ?? createdDate
|
||||
});
|
||||
|
||||
seq++;
|
||||
}
|
||||
|
||||
await _context.Set<Job>().AddRangeAsync(jobs);
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
|
||||
public partial class SeedDataService
|
||||
{
|
||||
/// <summary>
|
||||
/// Seeds maintenance records for all seeded equipment: historical completed records,
|
||||
/// upcoming scheduled records, and one overdue record for the Pressure Pot so the
|
||||
/// Equipment Maintenance report always has meaningful data.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Each piece of equipment gets 2-3 completed historical maintenance records (up to
|
||||
/// 12 months back) plus one upcoming scheduled record. The Pressure Pot additionally
|
||||
/// has one overdue record (past due date, still Scheduled) to populate the overdue
|
||||
/// indicator on the Equipment page.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Labor and parts costs are realistic for shop equipment maintenance, giving the
|
||||
/// Equipment Maintenance Cost report non-trivial totals from day one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Idempotency: bails early if any maintenance records already exist for this company's equipment.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<int> SeedMaintenanceRecordsAsync(Company company)
|
||||
{
|
||||
var equipmentIds = await _context.Set<Equipment>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(e => e.CompanyId == company.Id && !e.IsDeleted && SeededEquipmentSerials.Contains(e.SerialNumber))
|
||||
.Select(e => e.Id)
|
||||
.ToListAsync();
|
||||
|
||||
if (equipmentIds.Count == 0) return 0;
|
||||
|
||||
var existingCount = await _context.Set<MaintenanceRecord>()
|
||||
.IgnoreQueryFilters()
|
||||
.CountAsync(m => equipmentIds.Contains(m.EquipmentId));
|
||||
if (existingCount > 0) return 0;
|
||||
|
||||
var equipment = await _context.Set<Equipment>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(e => equipmentIds.Contains(e.Id))
|
||||
.ToListAsync();
|
||||
|
||||
// Try to grab a worker user to assign as performed-by
|
||||
var worker = await _userManager.Users
|
||||
.Where(u => u.CompanyId == company.Id && u.IsActive)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var records = new List<MaintenanceRecord>();
|
||||
|
||||
// Per-equipment maintenance spec: (type, daysAgoFirst, intervalDays, laborCost, partsCost, notes)
|
||||
// Each equipment gets 2 completed records + 1 scheduled upcoming.
|
||||
// The Pressure Pot also gets 1 overdue record.
|
||||
static (string type, decimal labor, decimal parts, string desc, string work)
|
||||
MaintSpec(int i) => (i % 5) switch
|
||||
{
|
||||
0 => ("Preventive", 120m, 45m, "Quarterly preventive maintenance", "Inspected elements, cleaned contacts, checked gaskets"),
|
||||
1 => ("Inspection", 80m, 0m, "Monthly operational inspection", "Checked all systems, calibrated temperature probes"),
|
||||
2 => ("Repair", 200m, 185m, "Filter and seal replacement", "Replaced intake filters and worn door seals"),
|
||||
3 => ("Preventive", 140m, 60m, "Semi-annual preventive maintenance", "Lubricated moving parts, replaced wear items"),
|
||||
_ => ("Inspection", 60m, 0m, "Pre-season inspection and cleaning", "Full operational test, cleaned all surfaces"),
|
||||
};
|
||||
|
||||
int idx = 0;
|
||||
foreach (var eq in equipment)
|
||||
{
|
||||
var isPressurePot = eq.SerialNumber == "CLM101223456"; // Media Blast Room / Pressure Pot
|
||||
|
||||
for (int r = 0; r < 2; r++)
|
||||
{
|
||||
var (mtype, labor, parts, desc, work) = MaintSpec(idx + r);
|
||||
var daysAgo = 180 - r * 60 - (idx % 4) * 15;
|
||||
var scheduled = now.AddDays(-daysAgo);
|
||||
var total = labor + parts;
|
||||
|
||||
records.Add(new MaintenanceRecord
|
||||
{
|
||||
EquipmentId = eq.Id,
|
||||
MaintenanceType = mtype,
|
||||
Status = MaintenanceStatus.Completed,
|
||||
Priority = MaintenancePriority.Normal,
|
||||
ScheduledDate = scheduled,
|
||||
CompletedDate = scheduled.AddDays(1),
|
||||
PerformedById = worker?.Id,
|
||||
AssignedUserId = worker?.Id,
|
||||
Description = desc,
|
||||
WorkPerformed = work,
|
||||
LaborCost = labor,
|
||||
PartsCost = parts,
|
||||
TotalCost = total,
|
||||
DowntimeHours = 2m + r,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = scheduled.AddDays(-7)
|
||||
});
|
||||
}
|
||||
|
||||
// One upcoming scheduled record
|
||||
var upcomingDays = 15 + (idx % 30);
|
||||
records.Add(new MaintenanceRecord
|
||||
{
|
||||
EquipmentId = eq.Id,
|
||||
MaintenanceType = "Preventive",
|
||||
Status = MaintenanceStatus.Scheduled,
|
||||
Priority = MaintenancePriority.Normal,
|
||||
ScheduledDate = now.AddDays(upcomingDays),
|
||||
AssignedUserId = worker?.Id,
|
||||
Description = "Scheduled preventive maintenance",
|
||||
LaborCost = 0m, PartsCost = 0m, TotalCost = 0m,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = now.AddDays(-7)
|
||||
});
|
||||
|
||||
// Overdue record for the pressure pot only
|
||||
if (isPressurePot)
|
||||
{
|
||||
records.Add(new MaintenanceRecord
|
||||
{
|
||||
EquipmentId = eq.Id,
|
||||
MaintenanceType = "Repair",
|
||||
Status = MaintenanceStatus.Scheduled,
|
||||
Priority = MaintenancePriority.High,
|
||||
ScheduledDate = now.AddDays(-20), // overdue
|
||||
AssignedUserId = worker?.Id,
|
||||
Description = "Filter replacement — OVERDUE",
|
||||
Notes = "Media filter became clogged ahead of schedule. Shop is running reduced blast capacity until repaired.",
|
||||
LaborCost = 0m, PartsCost = 0m, TotalCost = 0m,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = now.AddDays(-30)
|
||||
});
|
||||
}
|
||||
|
||||
idx++;
|
||||
}
|
||||
|
||||
await _context.Set<MaintenanceRecord>().AddRangeAsync(records);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return records.Count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
|
||||
public partial class SeedDataService
|
||||
{
|
||||
/// <summary>
|
||||
/// Seeds 7 purchase orders across three vendors covering a 3-month window:
|
||||
/// 3 Received (historical), 2 Submitted (in-flight), and 2 Draft (pending approval).
|
||||
/// This gives every PO status a visible example for demo walkthroughs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Vendors are resolved by partial name match against the company's vendor list — the
|
||||
/// same approach used by <see cref="SeedBillsAsync"/>. PO numbers follow the convention
|
||||
/// <c>PO-YYMM-####</c> stamped at seed time.
|
||||
///
|
||||
/// Received POs link back to the <see cref="Bill"/> that was created after receipt when
|
||||
/// one with a matching vendor invoice number exists; otherwise <c>BillId</c> is left null
|
||||
/// so the PO still seeds cleanly even if bills were skipped.
|
||||
///
|
||||
/// Idempotency: returns 0 immediately if any purchase orders already exist for the company.
|
||||
/// </remarks>
|
||||
/// <param name="company">The tenant company to seed purchase orders for.</param>
|
||||
/// <returns>Number of PO records inserted, or 0 if already seeded.</returns>
|
||||
private async Task<int> SeedPurchaseOrdersAsync(Company company)
|
||||
{
|
||||
var existingCount = await _context.Set<PurchaseOrder>()
|
||||
.IgnoreQueryFilters()
|
||||
.CountAsync(p => p.CompanyId == company.Id && !p.IsDeleted);
|
||||
|
||||
if (existingCount > 0)
|
||||
return 0;
|
||||
|
||||
var vendors = await _context.Set<Vendor>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(v => v.CompanyId == company.Id && !v.IsDeleted)
|
||||
.ToListAsync();
|
||||
|
||||
if (vendors.Count == 0)
|
||||
return 0;
|
||||
|
||||
var prismatic = vendors.FirstOrDefault(v => v.CompanyName.Contains("Prismatic")) ?? vendors.First();
|
||||
var columbia = vendors.FirstOrDefault(v => v.CompanyName.Contains("Columbia")) ?? vendors.First();
|
||||
var harbor = vendors.FirstOrDefault(v => v.CompanyName.Contains("Harbor")) ?? vendors.First();
|
||||
var grainger = vendors.FirstOrDefault(v => v.CompanyName.Contains("Grainger")) ?? vendors.First();
|
||||
var localSupply = vendors.FirstOrDefault(v => v.CompanyName.Contains("Local")) ?? vendors.First();
|
||||
|
||||
// Resolve inventory item IDs for PO line items (optional — may be null if inventory
|
||||
// wasn't seeded yet; the PO seeds cleanly either way using the Description field).
|
||||
var glossBlack = await _context.Set<InventoryItem>().IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(i => i.CompanyId == company.Id && i.SKU.EndsWith("-PWD-GBK-001") && !i.IsDeleted);
|
||||
var matteBlack = await _context.Set<InventoryItem>().IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(i => i.CompanyId == company.Id && i.SKU.EndsWith("-PWD-MBK-001") && !i.IsDeleted);
|
||||
var superChrome = await _context.Set<InventoryItem>().IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(i => i.CompanyId == company.Id && i.SKU.EndsWith("-PWD-CHR-001") && !i.IsDeleted);
|
||||
var candyRed = await _context.Set<InventoryItem>().IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(i => i.CompanyId == company.Id && i.SKU.EndsWith("-PWD-CRD-001") && !i.IsDeleted);
|
||||
var blastMedia = await _context.Set<InventoryItem>().IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(i => i.CompanyId == company.Id && i.SKU.EndsWith("-BLM-001") && !i.IsDeleted);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var pfx = $"PO-{now:yy}{now.Month:D2}-";
|
||||
var seq = 1;
|
||||
var seeded = 0;
|
||||
|
||||
async Task<PurchaseOrder> AddPO(PurchaseOrder po)
|
||||
{
|
||||
po.PoNumber = $"{pfx}{seq++:D4}";
|
||||
po.CompanyId = company.Id;
|
||||
foreach (var item in po.Items)
|
||||
{
|
||||
item.CompanyId = company.Id;
|
||||
item.CreatedAt = po.OrderDate;
|
||||
}
|
||||
await _context.Set<PurchaseOrder>().AddAsync(po);
|
||||
await _context.SaveChangesAsync();
|
||||
seeded++;
|
||||
return po;
|
||||
}
|
||||
|
||||
// ── RECEIVED (historical — tied to bills already in the system) ───────
|
||||
|
||||
// PO-1: Prismatic Powders — powder restock 3 months ago (Received, matches bill PP-77211)
|
||||
await AddPO(new PurchaseOrder
|
||||
{
|
||||
VendorId = prismatic.Id,
|
||||
Status = PurchaseOrderStatus.Received,
|
||||
OrderDate = now.AddDays(-95),
|
||||
ExpectedDeliveryDate = now.AddDays(-80),
|
||||
ReceivedDate = now.AddDays(-82),
|
||||
SubTotal = 1_145.00m,
|
||||
TotalAmount = 1_145.00m,
|
||||
Notes = "Quarterly powder restock — Q1",
|
||||
CreatedAt = now.AddDays(-95),
|
||||
Items =
|
||||
{
|
||||
new PurchaseOrderItem { InventoryItemId = matteBlack?.Id, Description = "Matte Black Powder — 50 lb bags", UnitOfMeasure = "bag", QuantityOrdered = 2, QuantityReceived = 2, UnitCost = 178.00m, LineTotal = 356.00m },
|
||||
new PurchaseOrderItem { InventoryItemId = glossBlack?.Id, Description = "Gloss Black Powder — 50 lb bags", UnitOfMeasure = "bag", QuantityOrdered = 2, QuantityReceived = 2, UnitCost = 165.00m, LineTotal = 330.00m },
|
||||
new PurchaseOrderItem { Description = "Satin Silver Powder — 25 lb bags", UnitOfMeasure = "bag", QuantityOrdered = 2, QuantityReceived = 2, UnitCost = 144.50m, LineTotal = 289.00m },
|
||||
new PurchaseOrderItem { Description = "Masking Tape & Plugs Kit", UnitOfMeasure = "kit", QuantityOrdered = 1, QuantityReceived = 1, UnitCost = 170.00m, LineTotal = 170.00m }
|
||||
}
|
||||
});
|
||||
|
||||
// PO-2: Columbia Coatings — specialty colors 2 months ago (Received, matches bill CC-4401)
|
||||
await AddPO(new PurchaseOrder
|
||||
{
|
||||
VendorId = columbia.Id,
|
||||
Status = PurchaseOrderStatus.Received,
|
||||
OrderDate = now.AddDays(-70),
|
||||
ExpectedDeliveryDate = now.AddDays(-58),
|
||||
ReceivedDate = now.AddDays(-60),
|
||||
SubTotal = 986.00m,
|
||||
TotalAmount = 986.00m,
|
||||
Notes = "Specialty metallic & candy colors order",
|
||||
CreatedAt = now.AddDays(-70),
|
||||
Items =
|
||||
{
|
||||
new PurchaseOrderItem { InventoryItemId = candyRed?.Id, Description = "Candy Red Metallic — 10 lb bags", UnitOfMeasure = "bag", QuantityOrdered = 3, QuantityReceived = 3, UnitCost = 145.00m, LineTotal = 435.00m },
|
||||
new PurchaseOrderItem { InventoryItemId = superChrome?.Id, Description = "Chrome Effect Powder — 10 lb bags", UnitOfMeasure = "bag", QuantityOrdered = 2, QuantityReceived = 2, UnitCost = 168.00m, LineTotal = 336.00m },
|
||||
new PurchaseOrderItem { Description = "Hammertone Bronze — 10 lb bags", UnitOfMeasure = "bag", QuantityOrdered = 1, QuantityReceived = 1, UnitCost = 150.50m, LineTotal = 150.50m },
|
||||
new PurchaseOrderItem { Description = "Ground Straps & Hooks", UnitOfMeasure = "lot", QuantityOrdered = 1, QuantityReceived = 1, UnitCost = 64.50m, LineTotal = 64.50m }
|
||||
}
|
||||
});
|
||||
|
||||
// PO-3: Harbor Freight Tools — consumables 6 weeks ago (Received, matches bill HBF-18822 timing)
|
||||
await AddPO(new PurchaseOrder
|
||||
{
|
||||
VendorId = harbor.Id,
|
||||
Status = PurchaseOrderStatus.Received,
|
||||
OrderDate = now.AddDays(-48),
|
||||
ExpectedDeliveryDate = now.AddDays(-40),
|
||||
ReceivedDate = now.AddDays(-42),
|
||||
SubTotal = 412.50m,
|
||||
TotalAmount = 412.50m,
|
||||
Notes = "Shop consumables & hardware restock",
|
||||
CreatedAt = now.AddDays(-48),
|
||||
Items =
|
||||
{
|
||||
new PurchaseOrderItem { Description = "J-Hook Hangers Assortment", UnitOfMeasure = "pkg", QuantityOrdered = 2, QuantityReceived = 2, UnitCost = 89.75m, LineTotal = 179.50m },
|
||||
new PurchaseOrderItem { Description = "Masking Caps — Mixed (100-pack)", UnitOfMeasure = "box", QuantityOrdered = 2, QuantityReceived = 2, UnitCost = 60.00m, LineTotal = 120.00m },
|
||||
new PurchaseOrderItem { Description = "Wire Brushes & Abrasives", UnitOfMeasure = "lot", QuantityOrdered = 1, QuantityReceived = 1, UnitCost = 113.00m, LineTotal = 113.00m }
|
||||
}
|
||||
});
|
||||
|
||||
// ── SUBMITTED (in-flight — awaiting delivery) ─────────────────────────
|
||||
|
||||
// PO-4: Grainger Industrial Supply — safety equipment & filter replacement (matches open GRG-7714 partial bill)
|
||||
await AddPO(new PurchaseOrder
|
||||
{
|
||||
VendorId = grainger.Id,
|
||||
Status = PurchaseOrderStatus.Submitted,
|
||||
OrderDate = now.AddDays(-14),
|
||||
ExpectedDeliveryDate = now.AddDays(3),
|
||||
SubTotal = 648.00m,
|
||||
TotalAmount = 648.00m,
|
||||
Notes = "Blast room filter replacement + safety restocking",
|
||||
CreatedAt = now.AddDays(-14),
|
||||
Items =
|
||||
{
|
||||
new PurchaseOrderItem { Description = "HEPA Filter Cartridges — 12-pack", UnitOfMeasure = "box", QuantityOrdered = 2, QuantityReceived = 0, UnitCost = 189.00m, LineTotal = 378.00m },
|
||||
new PurchaseOrderItem { Description = "Blast Nozzle Tungsten — 3/8\"", UnitOfMeasure = "ea", QuantityOrdered = 2, QuantityReceived = 0, UnitCost = 85.00m, LineTotal = 170.00m },
|
||||
new PurchaseOrderItem { Description = "Safety Respirators (10-pack)", UnitOfMeasure = "box", QuantityOrdered = 1, QuantityReceived = 0, UnitCost = 100.00m, LineTotal = 100.00m }
|
||||
}
|
||||
});
|
||||
|
||||
// PO-5: Prismatic Powders — current month powder order (matches open bill PP-88530)
|
||||
await AddPO(new PurchaseOrder
|
||||
{
|
||||
VendorId = prismatic.Id,
|
||||
Status = PurchaseOrderStatus.Submitted,
|
||||
OrderDate = now.AddDays(-8),
|
||||
ExpectedDeliveryDate = now.AddDays(7),
|
||||
SubTotal = 1_050.00m,
|
||||
TotalAmount = 1_050.00m,
|
||||
Notes = "June powder restock — Matte Black + seasonal Gloss Red",
|
||||
CreatedAt = now.AddDays(-8),
|
||||
Items =
|
||||
{
|
||||
new PurchaseOrderItem { InventoryItemId = matteBlack?.Id, Description = "Matte Black Powder — 25 lb bags", UnitOfMeasure = "bag", QuantityOrdered = 6, QuantityReceived = 0, UnitCost = 89.00m, LineTotal = 534.00m },
|
||||
new PurchaseOrderItem { Description = "Gloss Red Powder — 10 lb bags", UnitOfMeasure = "bag", QuantityOrdered = 2, QuantityReceived = 0, UnitCost = 132.00m, LineTotal = 264.00m },
|
||||
new PurchaseOrderItem { Description = "Hanging Racks (10-pack)", UnitOfMeasure = "pkg", QuantityOrdered = 2, QuantityReceived = 0, UnitCost = 126.00m, LineTotal = 252.00m }
|
||||
}
|
||||
});
|
||||
|
||||
// ── DRAFT (pending review / approval) ─────────────────────────────────
|
||||
|
||||
// PO-6: Harbor Freight Tools — shop tools pending manager approval
|
||||
await AddPO(new PurchaseOrder
|
||||
{
|
||||
VendorId = harbor.Id,
|
||||
Status = PurchaseOrderStatus.Draft,
|
||||
OrderDate = now.AddDays(-3),
|
||||
ExpectedDeliveryDate = now.AddDays(10),
|
||||
SubTotal = 318.75m,
|
||||
TotalAmount = 318.75m,
|
||||
Notes = "Monthly hardware restock — needs approval before submit",
|
||||
InternalNotes = "Manager review requested: higher than normal month due to extra hooks for upcoming Apex Motorsports batch.",
|
||||
CreatedAt = now.AddDays(-3),
|
||||
Items =
|
||||
{
|
||||
new PurchaseOrderItem { Description = "Hanging Racks & J-Hooks", UnitOfMeasure = "pkg", QuantityOrdered = 1, QuantityReceived = 0, UnitCost = 198.75m, LineTotal = 198.75m },
|
||||
new PurchaseOrderItem { Description = "Masking Caps — Mixed (100-pack)", UnitOfMeasure = "box", QuantityOrdered = 2, QuantityReceived = 0, UnitCost = 60.00m, LineTotal = 120.00m }
|
||||
}
|
||||
});
|
||||
|
||||
// PO-7: Local Industrial Supply — blast media restock (inventory currently at zero)
|
||||
await AddPO(new PurchaseOrder
|
||||
{
|
||||
VendorId = localSupply.Id,
|
||||
Status = PurchaseOrderStatus.Draft,
|
||||
OrderDate = now.AddDays(-1),
|
||||
ExpectedDeliveryDate = now.AddDays(5),
|
||||
SubTotal = 385.00m,
|
||||
TotalAmount = 385.00m,
|
||||
Notes = "URGENT — blast media out of stock, production blocked on Pressure Pot Blaster",
|
||||
InternalNotes = "Rush order requested; call LIS rep for same-week delivery.",
|
||||
CreatedAt = now.AddDays(-1),
|
||||
Items =
|
||||
{
|
||||
new PurchaseOrderItem { InventoryItemId = blastMedia?.Id, Description = "Aluminum Oxide #80 Grit — 100 lb bag", UnitOfMeasure = "bag", QuantityOrdered = 5, QuantityReceived = 0, UnitCost = 77.00m, LineTotal = 385.00m }
|
||||
}
|
||||
});
|
||||
|
||||
return seeded;
|
||||
}
|
||||
}
|
||||
@@ -6,39 +6,31 @@ namespace PowderCoating.Infrastructure.Services;
|
||||
public partial class SeedDataService
|
||||
{
|
||||
/// <summary>
|
||||
/// Seeds 75 realistic powder coating quotes spread across seven item categories
|
||||
/// (automotive wheels, industrial, architectural, fitness, marine, furniture, misc)
|
||||
/// with a realistic status distribution: Draft (8), Sent (12), Approved (35),
|
||||
/// Rejected (10), and Expired (10).
|
||||
/// Seeds 35 realistic quotes spanning the full status lifecycle: Draft, Sent, Approved,
|
||||
/// Rejected, and Expired — with Approved as the clear majority so that SeedJobsAsync
|
||||
/// has enough linked quotes to demonstrate the quote-to-job conversion workflow.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Idempotency: returns 0 immediately if any non-deleted quotes already exist for
|
||||
/// this company, preventing duplicate quote sets on repeated seed runs.
|
||||
/// Customers are loaded in Id-ascending order (matching the job seeder) and a
|
||||
/// shuffled visit schedule interleaves commercial and individual customers so that
|
||||
/// statuses and customer types are naturally mixed rather than appearing in blocks.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Quote numbers follow the production format <c>QT-YYMM-####</c>. The seeder scans
|
||||
/// existing numbers with the current month prefix and starts its sequence above the
|
||||
/// current maximum so seeded quotes never collide with real quotes created in the
|
||||
/// same month.
|
||||
/// Status pool (fixed seed 55): Approved ×18, Sent ×8, Draft ×4,
|
||||
/// Rejected ×3, Expired ×2. Shuffled with Fisher-Yates (fixed seed 55)
|
||||
/// so every reset produces the same interleaved sequence.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Pricing is deliberately simple (sqft × $8.50 + variance) rather than running through
|
||||
/// <c>IPricingCalculationService</c> — this avoids a dependency on company operating cost
|
||||
/// config that may not yet be populated when seed runs.
|
||||
/// Per-customer quote counts mirror real business activity: top commercial accounts
|
||||
/// (Carolina Fabrication, Apex, Triangle) generate the most quotes; prospect customers
|
||||
/// (Jennifer Clark, Lisa Anderson, Michelle Brown) have quotes but no jobs.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Tax-exempt customers automatically receive a 0 % tax rate (matching the production
|
||||
/// behaviour in <c>QuotesController</c>). Rush fees (15 %) are added every 12th quote.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The method requires that customers and quote-status lookup rows already exist for the
|
||||
/// company; it returns 0 if either dependency is missing so that the overall seed
|
||||
/// operation degrades gracefully rather than throwing.
|
||||
/// Dates spread over 7–185 days back (roughly 6 months) with a small jitter term so
|
||||
/// historical charts show a natural activity curve without obviously linear spacing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="company">The tenant company to seed quotes for.</param>
|
||||
/// <returns>Number of quotes inserted, or 0 if already seeded or dependencies are missing.</returns>
|
||||
private async Task<int> SeedQuotesAsync(Company company)
|
||||
{
|
||||
var existingCount = await _context.Set<Quote>()
|
||||
@@ -56,14 +48,14 @@ public partial class SeedDataService
|
||||
if (quoteStatuses.Count == 0)
|
||||
return 0;
|
||||
|
||||
// Load all commercial customers
|
||||
var customers = await _context.Set<Customer>()
|
||||
// Load in Id order — same ordering as the job seeder so CustomerProfile(ci) indices align
|
||||
var allCustomers = await _context.Set<Customer>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(c => c.CompanyId == company.Id && c.IsCommercial && !c.IsDeleted)
|
||||
.Where(c => c.CompanyId == company.Id && !c.IsDeleted)
|
||||
.OrderBy(c => c.Id)
|
||||
.ToListAsync();
|
||||
|
||||
if (customers.Count == 0)
|
||||
if (allCustomers.Count == 0)
|
||||
return 0;
|
||||
|
||||
var preparedByUser = await _userManager.Users
|
||||
@@ -71,8 +63,6 @@ public partial class SeedDataService
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Avoid duplicate quote numbers
|
||||
var prefix = $"QT-{now:yy}{now.Month:D2}-";
|
||||
var existing = await _context.Set<Quote>()
|
||||
.IgnoreQueryFilters()
|
||||
@@ -84,122 +74,143 @@ public partial class SeedDataService
|
||||
if (n.Length >= 12 && int.TryParse(n.Substring(8, 4), out var x) && x > maxNum) maxNum = x;
|
||||
var seq = maxNum + 1;
|
||||
|
||||
// ── Data arrays for varied, realistic content ─────────────────────────
|
||||
|
||||
// Returns an array of realistic item descriptions for a given category bucket (0–6).
|
||||
// Using a local static function keeps the description data close to where it is
|
||||
// consumed and avoids polluting the partial class with per-seeder detail arrays.
|
||||
static string[] ItemDescs(int category) => category switch
|
||||
// ── Per-customer quote counts (must sum to 35) ─────────────────────────
|
||||
// Indices match the Id-ascending customer list from SeedCustomersAsync:
|
||||
// 0=Carolina Fabrication, 1=Apex Motorsports, 2=Triangle Offroad,
|
||||
// 3=Smith Welding, 4=Raleigh Architectural, 5=East Coast Powderworks,
|
||||
// 6=Piedmont Metal Works, 7=Cary Industrial, 8=Durham Tech, 9=Wake County Fleet,
|
||||
// 10–26 = individual residential customers
|
||||
static int QuotesFor(int ci) => ci switch
|
||||
{
|
||||
0 => new[] {
|
||||
"18\" Aluminum Wheels — Matte Black",
|
||||
"17\" Steel Wheels — Gloss White",
|
||||
"20\" Alloy Wheels — Satin Silver",
|
||||
"16\" Chrome Replica Wheels — Gloss Black",
|
||||
"Motorcycle Frame — Flat Black",
|
||||
"Motorcycle Swingarm & Forks — Gloss Black",
|
||||
"Exhaust Headers — High-Temp Flat Black",
|
||||
"Intake Manifold — Wrinkle Red",
|
||||
"Valve Covers — Gloss Red",
|
||||
"Brake Calipers — Gloss Yellow" },
|
||||
1 => new[] {
|
||||
"Steel Shelving Units (10-shelf set)",
|
||||
"Industrial Equipment Frame",
|
||||
"Machine Guard Panels",
|
||||
"Conveyor Frame Sections",
|
||||
"Heavy Equipment Brackets",
|
||||
"Pump Housing Assembly",
|
||||
"Control Panel Enclosure",
|
||||
"Storage Rack System",
|
||||
"Scissor Lift Platform",
|
||||
"Compressor Tank" },
|
||||
2 => new[] {
|
||||
"Aluminum Window Frames (set of 8)",
|
||||
"Steel Handrail System — 40 ft",
|
||||
"Wrought Iron Fence Panels (6-panel set)",
|
||||
"Entry Gate — Custom Design",
|
||||
"Structural Steel Columns (set of 4)",
|
||||
"Balcony Railing — Satin Black",
|
||||
"Steel Door Frames (3 units)",
|
||||
"Architectural Steel Beams",
|
||||
"Decorative Ironwork — Stair Baluster",
|
||||
"Aluminum Storefront Frame" },
|
||||
3 => new[] {
|
||||
"Commercial Gym Equipment Frame",
|
||||
"Weight Rack & Benches",
|
||||
"Outdoor Playground Equipment Parts",
|
||||
"Bicycle Frame — Gloss Blue",
|
||||
"BMX Frame Set — Candy Red" },
|
||||
4 => new[] {
|
||||
"Boat Trailer Frame — Marine Grade",
|
||||
"Aluminum Dock Cleats & Hardware",
|
||||
"Outboard Motor Bracket",
|
||||
"Marine Fuel Tank Brackets" },
|
||||
5 => new[] {
|
||||
"Restaurant Chair Frames (set of 20)",
|
||||
"Steel Dining Table Bases (set of 8)",
|
||||
"Patio Furniture Set — 6 Pieces",
|
||||
"Café Chairs — Hammered Bronze (12-pc)",
|
||||
"Commercial Bar Stools (set of 10)" },
|
||||
_ => new[] {
|
||||
"Custom Steel Parts — Batch Order",
|
||||
"Agricultural Equipment Panels",
|
||||
"Traffic Sign Frames (set of 15)",
|
||||
"Utility Trailer Hitch Assembly",
|
||||
"Solar Panel Mounting Brackets" }
|
||||
0 => 4, // Carolina Fabrication — most quotes
|
||||
1 => 3, // Apex Motorsports
|
||||
2 => 3, // Triangle Offroad
|
||||
3 => 3, // Smith Welding
|
||||
4 => 2, // Raleigh Architectural Metals
|
||||
5 => 2, // East Coast Powderworks
|
||||
6 => 1, // Piedmont Metal Works
|
||||
7 => 1, // Cary Industrial Solutions
|
||||
8 => 2, // Durham Tech Equipment
|
||||
9 => 3, // Wake County Fleet Services
|
||||
10 => 1, // John Davis
|
||||
11 => 1, // Sarah Jenkins
|
||||
12 => 1, // Mike Thompson
|
||||
13 => 1, // Robert Miller
|
||||
14 => 1, // Jennifer Clark — prospect (quote only, no job)
|
||||
15 => 1, // David Wilson
|
||||
16 => 1, // Lisa Anderson — prospect
|
||||
17 => 1, // Thomas Harris
|
||||
19 => 1, // James Taylor
|
||||
20 => 1, // Michelle Brown — prospect
|
||||
21 => 1, // Chris Lee
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
// Returns finish color, prep flags, estimated minutes, and surface area for item index i.
|
||||
// Cycling modulo 9 ensures variety across all 75 quotes without requiring a large lookup table.
|
||||
static (string color, bool sandblast, bool mask, int minutes, decimal sqft) ItemSpec(int i) => (i % 9) switch
|
||||
// ── Status pool: realistic distribution (total = 35) ───────────────────
|
||||
// Approved is the majority; Rejected and Expired are rare.
|
||||
var statusPool = new List<string>();
|
||||
foreach (var (code, count) in new (string Code, int Count)[]
|
||||
{
|
||||
0 => ("Matte Black", true, false, 45, 12.0m),
|
||||
1 => ("Gloss White", false, false, 30, 8.5m),
|
||||
2 => ("Satin Silver", true, true, 60, 15.0m),
|
||||
3 => ("Candy Red", false, true, 35, 9.0m),
|
||||
4 => ("Textured Gray", true, false, 50, 18.0m),
|
||||
5 => ("Gloss Black", true, false, 40, 11.0m),
|
||||
6 => ("Hammered Bronze", false, false, 55, 20.0m),
|
||||
7 => ("Satin Graphite", true, true, 65, 25.0m),
|
||||
_ => ("Flat Black", true, false, 35, 10.0m)
|
||||
};
|
||||
("APPROVED", 18),
|
||||
("SENT", 8),
|
||||
("DRAFT", 4),
|
||||
("REJECTED", 3),
|
||||
("EXPIRED", 2),
|
||||
})
|
||||
{
|
||||
for (int k = 0; k < count; k++) statusPool.Add(code);
|
||||
}
|
||||
// Fisher-Yates shuffle — fixed seed for deterministic resets
|
||||
var statusRng = new Random(55);
|
||||
for (int k = statusPool.Count - 1; k > 0; k--)
|
||||
{
|
||||
var swap = statusRng.Next(k + 1);
|
||||
(statusPool[k], statusPool[swap]) = (statusPool[swap], statusPool[k]);
|
||||
}
|
||||
|
||||
// Maps quote index to a status code following the distribution plan above.
|
||||
// APPROVED is the majority (35/75) to give SeedJobsAsync enough approved quotes to link jobs to.
|
||||
static string StatusFor(int i) => i switch
|
||||
// ── Customer visit schedule: interleave commercial (ci 0–9) and individual (ci 10+) ──
|
||||
// Same two-pool approach as the job seeder — prevents commercial customers from
|
||||
// clustering at the top of any list sorted by quote number or date.
|
||||
var commercialVisits = new List<int>();
|
||||
var individualVisits = new List<int>();
|
||||
for (int ci = 0; ci < allCustomers.Count; ci++)
|
||||
{
|
||||
< 8 => "DRAFT",
|
||||
< 20 => "SENT",
|
||||
< 55 => "APPROVED",
|
||||
< 65 => "REJECTED",
|
||||
_ => "EXPIRED"
|
||||
var count = QuotesFor(ci);
|
||||
for (int j = 0; j < count; j++)
|
||||
(ci < 10 ? commercialVisits : individualVisits).Add(ci);
|
||||
}
|
||||
var rngC = new Random(42);
|
||||
for (int k = commercialVisits.Count - 1; k > 0; k--)
|
||||
{
|
||||
var swap = rngC.Next(k + 1);
|
||||
(commercialVisits[k], commercialVisits[swap]) = (commercialVisits[swap], commercialVisits[k]);
|
||||
}
|
||||
var rngI = new Random(17);
|
||||
for (int k = individualVisits.Count - 1; k > 0; k--)
|
||||
{
|
||||
var swap = rngI.Next(k + 1);
|
||||
(individualVisits[k], individualVisits[swap]) = (individualVisits[swap], individualVisits[k]);
|
||||
}
|
||||
var visitSchedule = new List<int>(commercialVisits.Count + individualVisits.Count);
|
||||
double indStride = individualVisits.Count > 0
|
||||
? (commercialVisits.Count + 1.0) / (individualVisits.Count + 1.0)
|
||||
: double.MaxValue;
|
||||
int indInsertIdx = 0;
|
||||
for (int comIdx = 0; comIdx < commercialVisits.Count; comIdx++)
|
||||
{
|
||||
while (indInsertIdx < individualVisits.Count && (indInsertIdx + 1) * indStride <= comIdx + 1)
|
||||
visitSchedule.Add(individualVisits[indInsertIdx++]);
|
||||
visitSchedule.Add(commercialVisits[comIdx]);
|
||||
}
|
||||
while (indInsertIdx < individualVisits.Count)
|
||||
visitSchedule.Add(individualVisits[indInsertIdx++]);
|
||||
|
||||
// ── Item catalogue — 15 common powder coating jobs ──────────────────────
|
||||
static (string desc, string color, bool sand, bool mask, int mins, decimal sqft) ItemSpec(int i) =>
|
||||
(i % 15) switch
|
||||
{
|
||||
0 => ("18\" Aluminum Wheels (set of 4)", "Gloss Black", false, false, 45, 12.0m),
|
||||
1 => ("17\" Steel Wheels (set of 4)", "Signal White", false, false, 30, 8.5m),
|
||||
2 => ("Jeep Bumper & Rock Sliders", "Matte Black", true, false, 60, 15.0m),
|
||||
3 => ("Motorcycle Frame", "Matte Black", true, false, 90, 14.0m),
|
||||
4 => ("Steel Shelving Units (10-shelf set)", "Textured Gray", true, false, 55, 18.0m),
|
||||
5 => ("Industrial Machine Guard Panels", "Safety Yellow", false, false, 35, 20.0m),
|
||||
6 => ("Aluminum Window Frames (set of 8)", "Satin Bronze", false, true, 50, 22.0m),
|
||||
7 => ("Steel Handrail System — 40 ft", "Gloss Black", true, false, 120, 40.0m),
|
||||
8 => ("Wrought Iron Entry Gate", "Hammered Black", true, false, 180, 35.0m),
|
||||
9 => ("Brake Calipers (set of 4)", "Candy Red", false, true, 35, 4.0m),
|
||||
10 => ("Restaurant Chair Frames (set of 20)", "Hammered Bronze", false, false, 60, 30.0m),
|
||||
11 => ("Bicycle Frame", "Candy Red", true, true, 60, 6.5m),
|
||||
12 => ("Compressor Tank", "Safety Orange", true, false, 45, 10.0m),
|
||||
13 => ("Patio Furniture Set (6 pieces)", "Textured Beige", false, false, 50, 24.0m),
|
||||
_ => ("Custom Steel Fabrication — Batch", "Matte Black", true, false, 40, 15.0m)
|
||||
};
|
||||
|
||||
var quotes = new List<Quote>();
|
||||
var quotesByCustomer = new int[allCustomers.Count]; // within-customer quote counter
|
||||
|
||||
for (int i = 0; i < 75; i++)
|
||||
for (int visitIdx = 0; visitIdx < visitSchedule.Count; visitIdx++)
|
||||
{
|
||||
var customer = customers[i % customers.Count];
|
||||
var statusCode = StatusFor(i);
|
||||
var ci = visitSchedule[visitIdx];
|
||||
var customer = allCustomers[ci];
|
||||
var j = quotesByCustomer[ci]++; // within-customer quote index (unused except for date jitter)
|
||||
var statusCode = statusPool[visitIdx];
|
||||
|
||||
// Spread creation dates over the past 90 days; older first
|
||||
var daysAgo = 90 - (int)(i * 1.2);
|
||||
// Dates spread 7–185 days back; small jitter via ci so identical-status quotes
|
||||
// for the same customer don't land on exactly the same day.
|
||||
var daysAgo = Math.Max(7, 185 - visitIdx * 5 + (ci % 5));
|
||||
var quoteDate = now.AddDays(-daysAgo);
|
||||
var expireDate = quoteDate.AddDays(30);
|
||||
|
||||
var category = i % 7;
|
||||
var descs = ItemDescs(category);
|
||||
var itemCount = 1 + (i % 3);
|
||||
|
||||
var itemCount = 1 + (visitIdx % 3);
|
||||
var items = new List<QuoteItem>();
|
||||
for (int j = 0; j < itemCount; j++)
|
||||
|
||||
for (int k = 0; k < itemCount; k++)
|
||||
{
|
||||
var desc = descs[(i + j) % descs.Length];
|
||||
var (color, sand, mask, mins, sqft) = ItemSpec(i + j);
|
||||
var qty = 1 + (j % 4);
|
||||
// Unit price scales with surface area and adds a modest multiplier per customer tier
|
||||
var tierMult = 1m + ((customer.PricingTier?.DiscountPercent ?? 0m) / 100m * -1m);
|
||||
var unitPrice = Math.Round(sqft * 8.50m * tierMult + (i % 5) * 4.5m, 2);
|
||||
var (desc, color, sand, mask, mins, sqft) = ItemSpec(visitIdx * 3 + k);
|
||||
var qty = 1 + (k % 4);
|
||||
var tierMult = 1m - ((customer.PricingTier?.DiscountPercent ?? 0m) / 100m);
|
||||
var unitPrice = Math.Round(sqft * 8.50m * tierMult + (visitIdx % 6) * 5.0m, 2);
|
||||
|
||||
items.Add(new QuoteItem
|
||||
{
|
||||
@@ -211,8 +222,8 @@ public partial class SeedDataService
|
||||
RequiresSandblasting = sand,
|
||||
RequiresMasking = mask,
|
||||
EstimatedMinutes = mins,
|
||||
Complexity = (i % 4) switch { 0 => "Simple", 1 => "Moderate", 2 => "Complex", _ => "Simple" },
|
||||
Notes = j == 0 && i % 5 == 0 ? $"{color} finish requested" : null,
|
||||
Complexity = (visitIdx % 4) switch { 0 => "Simple", 1 => "Moderate", 2 => "Complex", _ => "Simple" },
|
||||
Notes = k == 0 && visitIdx % 5 == 0 ? $"Customer requested {color} — confirm shade before run." : null,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = quoteDate
|
||||
});
|
||||
@@ -224,17 +235,17 @@ public partial class SeedDataService
|
||||
var afterDiscount = subtotal - discountAmt;
|
||||
var taxPct = customer.IsTaxExempt ? 0m : 7.5m;
|
||||
var taxAmt = Math.Round(afterDiscount * taxPct / 100m, 2);
|
||||
var rushFee = i % 12 == 0 ? Math.Round(afterDiscount * 0.15m, 2) : 0m;
|
||||
var rushFee = visitIdx % 10 == 0 ? Math.Round(afterDiscount * 0.15m, 2) : 0m;
|
||||
var total = afterDiscount + taxAmt + rushFee;
|
||||
|
||||
var quote = new Quote
|
||||
quotes.Add(new Quote
|
||||
{
|
||||
QuoteNumber = $"{prefix}{seq:D4}",
|
||||
CustomerId = customer.Id,
|
||||
PreparedById = preparedByUser?.Id,
|
||||
QuoteStatusId = quoteStatuses[statusCode],
|
||||
IsCommercial = customer.IsCommercial,
|
||||
IsRushJob = i % 12 == 0,
|
||||
IsRushJob = visitIdx % 10 == 0,
|
||||
QuoteDate = quoteDate,
|
||||
ExpirationDate = expireDate,
|
||||
SentDate = statusCode != "DRAFT" ? quoteDate.AddDays(1) : null,
|
||||
@@ -247,20 +258,19 @@ public partial class SeedDataService
|
||||
TaxAmount = taxAmt,
|
||||
RushFee = rushFee,
|
||||
Total = total,
|
||||
Description = $"Powder coating services — {descs[i % descs.Length].Split('—')[0].Trim()}",
|
||||
Description = $"Powder coating services — {items[0].Description.Split('(')[0].TrimEnd()}",
|
||||
Terms = customer.PaymentTerms ?? "Net 30",
|
||||
Notes = i % 7 == 0 ? "Customer requested color sample before full run." :
|
||||
i % 13 == 0 ? "Rush turnaround requested — 3 business days." : null,
|
||||
CustomerPO = i % 2 == 0 ? $"PO-{30000 + i}" : null,
|
||||
RequiresDeposit = i % 4 == 0,
|
||||
DepositPercent = i % 4 == 0 ? 50m : 0m,
|
||||
Notes = visitIdx % 8 == 0 ? "Customer requested color sample before full production run." :
|
||||
visitIdx % 13 == 0 ? "Rush turnaround requested — 3 business days." : null,
|
||||
CustomerPO = customer.IsCommercial && visitIdx % 2 == 0 ? $"PO-{30000 + visitIdx}" : null,
|
||||
RequiresDeposit = visitIdx % 4 == 0,
|
||||
DepositPercent = visitIdx % 4 == 0 ? 50m : 0m,
|
||||
QuoteItems = items,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = quoteDate,
|
||||
UpdatedAt = statusCode == "DRAFT" ? quoteDate : quoteDate.AddDays(1)
|
||||
};
|
||||
});
|
||||
|
||||
quotes.Add(quote);
|
||||
seq++;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
|
||||
@@ -12,14 +13,17 @@ public partial class SeedDataService
|
||||
/// </summary>
|
||||
private static readonly string[] SeededCustomerEmails =
|
||||
[
|
||||
"john.smith@acmemfg.com", "sjohnson@precisionauto.com", "mchen@urbanrailings.com",
|
||||
"lmartinez@fitequip.com", "dwilliams@metrota.gov", "rtaylor@classicwheels.com",
|
||||
"janderson@indfurniture.com", "cbrown@motorsportscustom.com", "adavis@greenenergy.com",
|
||||
"tmiller@heritagemetal.com", "pwilson@marineequip.com", "kgarcia@commercialhvac.com",
|
||||
"nmartinez@playgroundusa.com", "blee@officesystems.com", "swhite@agequipment.com",
|
||||
"jthompson@email.com", "mharris@email.com", "wclark@email.com", "elewis@email.com",
|
||||
"rwalker@email.com", "bhall@email.com", "jallen@email.com", "syoung@email.com",
|
||||
"cking@email.com", "lwright@email.com"
|
||||
// Commercial — NC Triangle area
|
||||
"matt@carolinafab.com", "ctanner@apexmotorsports.com", "jpruitt@triangleoffroad.com",
|
||||
"bsmith@smithwelding.com", "kmorales@raleigharchitectural.com", "tgreco@eastcoastpw.com",
|
||||
"dshaw@piedmontmetalworks.com", "lpatel@caryindustrial.com", "rblake@durhamtech.com",
|
||||
"mcoleman@wakecountyfleet.gov",
|
||||
// Individual residential
|
||||
"jdavis@email.com", "sjenkins@email.com", "mthompson@email.com", "rmiller@email.com",
|
||||
"jclark@email.com", "dwilson@email.com", "landerson@email.com", "tharris@email.com",
|
||||
"kwhite@email.com", "jtaylor@email.com", "mbrown@email.com", "clee@email.com",
|
||||
"agarcia@email.com", "kmartinez@email.com", "nrodriguez@email.com",
|
||||
"bhall@email.com", "pyoung@email.com"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
@@ -52,9 +56,11 @@ public partial class SeedDataService
|
||||
/// </summary>
|
||||
private static readonly string[] SeededInventorySkuSuffixes =
|
||||
[
|
||||
"-PWD-BLK-001", "-PWD-WHT-001", "-PWD-RED-001", "-PWD-BLU-001",
|
||||
"-PWD-GRY-001", "-PWD-YEL-001", "-PWD-ORG-001", "-PWD-GRN-001",
|
||||
"-CLN-001", "-MSK-001"
|
||||
// 6 powders
|
||||
"-PWD-GBK-001", "-PWD-MBK-001", "-PWD-CHR-001", "-PWD-CRD-001",
|
||||
"-PWD-SWH-001", "-PWD-IPU-001",
|
||||
// 5 consumables
|
||||
"-MSK-001", "-PLG-001", "-HKS-001", "-ACT-001", "-BLM-001"
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
@@ -74,28 +80,25 @@ public partial class SeedDataService
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// All queries use <c>IgnoreQueryFilters()</c> so that records already soft-deleted by users
|
||||
/// are still found and physically removed — this prevents orphaned data from accumulating
|
||||
/// in the database after partial cleanup.
|
||||
/// When <c>ForceRemoveAll = true</c> a topologically ordered pre-sweep deletes every
|
||||
/// child record for the company that has a NO_ACTION FK pointing at a parent we need
|
||||
/// to delete later. This avoids FK constraint errors regardless of how much user-created
|
||||
/// data has accumulated since the last seed. The sweep order mirrors the FK dependency
|
||||
/// graph derived from <c>sys.foreign_keys</c>:
|
||||
/// OvenBatchItems → PowderUsageLogs → InAppNotifications → QuotePhotos → GiftCertificates
|
||||
/// → JobTemplates → CreditMemoApplications → Refunds → CreditMemos → ReworkRecords
|
||||
/// → Deposits → Payments → InventoryTransactions → Invoices → Appointments
|
||||
/// → VendorCreditApplications → Bills → VendorCredits → PurchaseOrders → Expenses
|
||||
/// → OvenBatches → (self-referential Jobs.OriginalJobId nulled via raw SQL)
|
||||
/// then the main entity blocks run in safe order.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Child records (job items, quote items, transactions, maintenance records, etc.) are deleted
|
||||
/// first before their parent to avoid FK constraint violations. Each category is committed
|
||||
/// with its own <c>SaveChangesAsync()</c> call so a failure in one category does not roll
|
||||
/// back deletions already completed in an earlier category.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Lookup tables (job status, job priority, quote status) are intentionally NOT removed —
|
||||
/// they are system-level data shared across the company's real records.
|
||||
/// For the selective (non-force) path the Customers block also pre-deletes Invoices,
|
||||
/// Payments, Deposits, QuotePhotos, and InAppNotifications that reference the seeded
|
||||
/// customer/job/quote IDs, so those deletes succeed even when the broader pre-sweep
|
||||
/// has not run.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="companyId">ID of the tenant company whose seed data should be removed.</param>
|
||||
/// <param name="options">Flags controlling which data categories to delete.</param>
|
||||
/// <returns>
|
||||
/// A <see cref="SeedDataResult"/> with <c>Success = true</c> and a count of records removed,
|
||||
/// or <c>Success = false</c> with an error message if the company was not found or an
|
||||
/// exception was thrown.
|
||||
/// </returns>
|
||||
public async Task<SeedDataResult> RemoveSeedDataAsync(int companyId, RemoveSeedDataOptions options)
|
||||
{
|
||||
var result = new SeedDataResult { Success = true };
|
||||
@@ -115,24 +118,176 @@ public partial class SeedDataService
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- Customers (+ their jobs, quotes, and related items) ---
|
||||
// ── ForceRemoveAll pre-sweep ──────────────────────────────────────────
|
||||
// Deletes every record that has a NO_ACTION FK pointing at a table we delete
|
||||
// later. Order follows the FK dependency graph (leaves first, roots last).
|
||||
// Each tier is committed before the next so EF's change tracker stays clean.
|
||||
if (options.ForceRemoveAll)
|
||||
{
|
||||
// Local helper: delete all rows of T for this company, return count.
|
||||
// All entities inherit BaseEntity which exposes CompanyId.
|
||||
async Task<int> Sweep<T>() where T : BaseEntity
|
||||
{
|
||||
var rows = await _context.Set<T>().IgnoreQueryFilters()
|
||||
.Where(e => e.CompanyId == companyId).ToListAsync();
|
||||
if (rows.Any()) _context.Set<T>().RemoveRange(rows);
|
||||
return rows.Count;
|
||||
}
|
||||
|
||||
// Tier 1 — pure leaf records (block nothing of their own)
|
||||
await Sweep<JobTimeEntry>(); // FK → Jobs (Cascade by convention — sweep before jobs)
|
||||
await Sweep<OvenBatchItem>(); // NO_ACTION → Jobs, JobItems, JobItemCoats
|
||||
await Sweep<PowderUsageLog>(); // NO_ACTION → Jobs, JobItems, JobItemCoats
|
||||
await Sweep<InAppNotification>(); // NO_ACTION → Customers, Invoices, Quotes
|
||||
await Sweep<QuotePhoto>(); // NO_ACTION → Quotes
|
||||
await Sweep<GiftCertificate>(); // NO_ACTION → Customers (GiftCertRedemptions CASCADE)
|
||||
await Sweep<JobTemplate>(); // NO_ACTION → Customers (JobTemplateItems CASCADE)
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Tier 2 — credit/rework chain (each row blocks the next tier)
|
||||
await Sweep<CreditMemoApplication>(); // NO_ACTION → Bills, VendorCredits
|
||||
await Sweep<Refund>(); // NO_ACTION → Invoices, Payments, CreditMemos
|
||||
await Sweep<CreditMemo>(); // NO_ACTION → Customers, Invoices, ReworkRecords
|
||||
await _context.SaveChangesAsync();
|
||||
await Sweep<ReworkRecord>(); // NO_ACTION → Jobs, JobItems (after CreditMemos gone)
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Tier 3 — financial records that block Invoices / Jobs / Quotes
|
||||
await Sweep<Deposit>(); // NO_ACTION → Jobs, Quotes (CASCADE from Customer anyway)
|
||||
await Sweep<Payment>(); // NO_ACTION → Invoices
|
||||
await Sweep<InventoryTransaction>(); // NO_ACTION → Jobs, PurchaseOrders
|
||||
await _context.SaveChangesAsync();
|
||||
await Sweep<Invoice>(); // NO_ACTION → Jobs (InvoiceItems, GiftCertRedemptions CASCADE)
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Tier 4 — appointments (NO_ACTION → Customers AND Jobs)
|
||||
await Sweep<Appointment>();
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Tier 5 — vendor/purchasing chain (BillLineItems.JobId NO_ACTION blocks Jobs)
|
||||
await Sweep<VendorCreditApplication>(); // NO_ACTION → Bills
|
||||
await _context.SaveChangesAsync();
|
||||
await Sweep<Bill>(); // CASCADE → BillLineItems, BillPayments
|
||||
await Sweep<VendorCredit>(); // CASCADE → VendorCreditLineItems
|
||||
await Sweep<PurchaseOrder>(); // CASCADE → PurchaseOrderItems
|
||||
await Sweep<Expense>(); // NO_ACTION → Jobs
|
||||
await Sweep<OvenBatch>(); // NO_ACTION → Equipment, OvenCosts
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Jobs have a self-referential NO_ACTION FK (OriginalJobId). NULL it before
|
||||
// deleting so EF doesn't fail on ordering within the same-table batch delete.
|
||||
await _context.Database.ExecuteSqlRawAsync(
|
||||
"UPDATE Jobs SET OriginalJobId = NULL WHERE CompanyId = {0}", companyId);
|
||||
|
||||
details.Add("✓ Pre-sweep complete: child records cleared in FK-safe order");
|
||||
}
|
||||
|
||||
// ── Customers (+ jobs, quotes, invoices, and all related children) ────
|
||||
if (options.Customers)
|
||||
{
|
||||
var seededCustomerIds = await _context.Customers
|
||||
.IgnoreQueryFilters()
|
||||
var seededCustomerIds = options.ForceRemoveAll
|
||||
? await _context.Customers.IgnoreQueryFilters()
|
||||
.Where(c => c.CompanyId == companyId)
|
||||
.Select(c => c.Id).ToListAsync()
|
||||
: await _context.Customers.IgnoreQueryFilters()
|
||||
.Where(c => c.CompanyId == companyId && SeededCustomerEmails.Contains(c.Email))
|
||||
.Select(c => c.Id)
|
||||
.ToListAsync();
|
||||
.Select(c => c.Id).ToListAsync();
|
||||
|
||||
if (seededCustomerIds.Any())
|
||||
{
|
||||
// Jobs and their child records
|
||||
var seededJobIds = await _context.Jobs
|
||||
.IgnoreQueryFilters()
|
||||
var seededJobIds = await _context.Jobs.IgnoreQueryFilters()
|
||||
.Where(j => j.CompanyId == companyId && seededCustomerIds.Contains(j.CustomerId))
|
||||
.Select(j => j.Id)
|
||||
.ToListAsync();
|
||||
.Select(j => j.Id).ToListAsync();
|
||||
|
||||
var seededQuoteIds = await _context.Quotes.IgnoreQueryFilters()
|
||||
.Where(q => q.CompanyId == companyId && q.CustomerId.HasValue
|
||||
&& seededCustomerIds.Contains(q.CustomerId.Value))
|
||||
.Select(q => q.Id).ToListAsync();
|
||||
|
||||
// ── Pre-delete records with NO_ACTION FKs pointing at Jobs/Quotes/Customers ──
|
||||
// For ForceRemoveAll these are already gone (pre-sweep above). For selective
|
||||
// removal this is the only pass, so we scope to the seeded entity IDs.
|
||||
|
||||
// Appointments — NO_ACTION → Customers AND Jobs
|
||||
if (seededCustomerIds.Any() || seededJobIds.Any())
|
||||
{
|
||||
var appts = await _context.Set<Appointment>().IgnoreQueryFilters()
|
||||
.Where(a => a.CompanyId == companyId
|
||||
&& (a.CustomerId.HasValue && seededCustomerIds.Contains(a.CustomerId.Value)
|
||||
|| a.JobId.HasValue && seededJobIds.Contains(a.JobId.Value)))
|
||||
.ToListAsync();
|
||||
if (appts.Any()) _context.Set<Appointment>().RemoveRange(appts);
|
||||
}
|
||||
|
||||
// InAppNotifications — NO_ACTION → Customers, Quotes (Invoices handled below)
|
||||
if (seededCustomerIds.Any() || seededQuoteIds.Any())
|
||||
{
|
||||
var nots = await _context.Set<InAppNotification>().IgnoreQueryFilters()
|
||||
.Where(n => n.CompanyId == companyId
|
||||
&& (n.CustomerId.HasValue && seededCustomerIds.Contains(n.CustomerId.Value)
|
||||
|| n.QuoteId.HasValue && seededQuoteIds.Contains(n.QuoteId.Value)))
|
||||
.ToListAsync();
|
||||
if (nots.Any()) _context.Set<InAppNotification>().RemoveRange(nots);
|
||||
}
|
||||
|
||||
// QuotePhotos — NO_ACTION → Quotes
|
||||
if (seededQuoteIds.Any())
|
||||
{
|
||||
var qp = await _context.Set<QuotePhoto>().IgnoreQueryFilters()
|
||||
.Where(p => p.QuoteId.HasValue && seededQuoteIds.Contains(p.QuoteId.Value)).ToListAsync();
|
||||
if (qp.Any()) _context.Set<QuotePhoto>().RemoveRange(qp);
|
||||
}
|
||||
|
||||
// Deposits — NO_ACTION → Jobs, Quotes (CustomerId is CASCADE from Customer but
|
||||
// we delete deposits explicitly so jobs/quotes can be deleted first)
|
||||
if (seededCustomerIds.Any())
|
||||
{
|
||||
var deps = await _context.Set<Deposit>().IgnoreQueryFilters()
|
||||
.Where(d => seededCustomerIds.Contains(d.CustomerId)).ToListAsync();
|
||||
if (deps.Any()) _context.Set<Deposit>().RemoveRange(deps);
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
// Invoices — NO_ACTION → Jobs AND Customers; must be gone before Jobs are deleted.
|
||||
// Collect invoice IDs first so Payments (NO_ACTION → Invoices) can be cleared.
|
||||
List<int> seededInvoiceIds = [];
|
||||
if (seededCustomerIds.Any())
|
||||
{
|
||||
seededInvoiceIds = await _context.Set<Invoice>().IgnoreQueryFilters()
|
||||
.Where(i => i.CompanyId == companyId
|
||||
&& seededCustomerIds.Contains(i.CustomerId))
|
||||
.Select(i => i.Id).ToListAsync();
|
||||
|
||||
if (seededInvoiceIds.Any())
|
||||
{
|
||||
// InAppNotifications referencing these invoices
|
||||
var invNots = await _context.Set<InAppNotification>().IgnoreQueryFilters()
|
||||
.Where(n => n.InvoiceId.HasValue && seededInvoiceIds.Contains(n.InvoiceId.Value))
|
||||
.ToListAsync();
|
||||
if (invNots.Any()) _context.Set<InAppNotification>().RemoveRange(invNots);
|
||||
|
||||
// Payments — NO_ACTION → Invoices
|
||||
var pmts = await _context.Set<Payment>().IgnoreQueryFilters()
|
||||
.Where(p => seededInvoiceIds.Contains(p.InvoiceId)).ToListAsync();
|
||||
if (pmts.Any()) _context.Set<Payment>().RemoveRange(pmts);
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
var invoices = await _context.Set<Invoice>().IgnoreQueryFilters()
|
||||
.Where(i => seededInvoiceIds.Contains(i.Id)).ToListAsync();
|
||||
if (invoices.Any())
|
||||
{
|
||||
_context.Set<Invoice>().RemoveRange(invoices); // InvoiceItems CASCADE
|
||||
totalRemoved += invoices.Count;
|
||||
details.Add($"✓ Removed {invoices.Count} invoice(s)");
|
||||
}
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Jobs and their cascade children ─────────────────────────────────
|
||||
if (seededJobIds.Any())
|
||||
{
|
||||
var jobPhotos = await _context.JobPhotos.IgnoreQueryFilters()
|
||||
@@ -155,22 +310,28 @@ public partial class SeedDataService
|
||||
.Where(p => seededJobIds.Contains(p.JobId)).ToListAsync();
|
||||
if (jobPrepServices.Any()) _context.JobPrepServices.RemoveRange(jobPrepServices);
|
||||
|
||||
var timeEntries = await _context.Set<JobTimeEntry>().IgnoreQueryFilters()
|
||||
.Where(te => seededJobIds.Contains(te.JobId)).ToListAsync();
|
||||
if (timeEntries.Any()) _context.Set<JobTimeEntry>().RemoveRange(timeEntries);
|
||||
|
||||
var jobs = await _context.Jobs.IgnoreQueryFilters()
|
||||
.Where(j => seededJobIds.Contains(j.Id)).ToListAsync();
|
||||
_context.Jobs.RemoveRange(jobs);
|
||||
totalRemoved += jobs.Count;
|
||||
details.Add($"✓ Removed {jobs.Count} seeded job(s)");
|
||||
details.Add($"✓ Removed {jobs.Count} job(s)");
|
||||
}
|
||||
|
||||
// Quotes and their child records
|
||||
var seededQuoteIds = await _context.Quotes
|
||||
.IgnoreQueryFilters()
|
||||
.Where(q => q.CompanyId == companyId && q.CustomerId.HasValue && seededCustomerIds.Contains(q.CustomerId.Value))
|
||||
.Select(q => q.Id)
|
||||
.ToListAsync();
|
||||
|
||||
// ── Quotes and their cascade children ───────────────────────────────
|
||||
if (seededQuoteIds.Any())
|
||||
{
|
||||
// Collect AiItemPrediction IDs before removing QuoteItems (FK is NoAction —
|
||||
// predictions must be orphaned after items are gone, then deleted separately).
|
||||
var predictionIds = await _context.QuoteItems.IgnoreQueryFilters()
|
||||
.Where(qi => seededQuoteIds.Contains(qi.QuoteId) && qi.AiPredictionId != null)
|
||||
.Select(qi => qi.AiPredictionId!.Value)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
|
||||
var quoteItems = await _context.QuoteItems.IgnoreQueryFilters()
|
||||
.Where(qi => seededQuoteIds.Contains(qi.QuoteId)).ToListAsync();
|
||||
if (quoteItems.Any()) _context.QuoteItems.RemoveRange(quoteItems);
|
||||
@@ -183,10 +344,24 @@ public partial class SeedDataService
|
||||
.Where(q => seededQuoteIds.Contains(q.Id)).ToListAsync();
|
||||
_context.Quotes.RemoveRange(quotes);
|
||||
totalRemoved += quotes.Count;
|
||||
details.Add($"✓ Removed {quotes.Count} seeded quote(s)");
|
||||
details.Add($"✓ Removed {quotes.Count} quote(s)");
|
||||
|
||||
if (predictionIds.Any())
|
||||
{
|
||||
var predictions = await _context.Set<AiItemPrediction>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(p => predictionIds.Contains(p.Id))
|
||||
.ToListAsync();
|
||||
if (predictions.Any())
|
||||
{
|
||||
_context.Set<AiItemPrediction>().RemoveRange(predictions);
|
||||
totalRemoved += predictions.Count;
|
||||
details.Add($"✓ Removed {predictions.Count} AI prediction(s)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Customer notes
|
||||
// Customer notes (CASCADE from Customer, but explicit for clarity)
|
||||
var customerNotes = await _context.CustomerNotes.IgnoreQueryFilters()
|
||||
.Where(n => seededCustomerIds.Contains(n.CustomerId)).ToListAsync();
|
||||
if (customerNotes.Any()) _context.CustomerNotes.RemoveRange(customerNotes);
|
||||
@@ -195,7 +370,7 @@ public partial class SeedDataService
|
||||
.Where(c => seededCustomerIds.Contains(c.Id)).ToListAsync();
|
||||
_context.Customers.RemoveRange(customers);
|
||||
totalRemoved += customers.Count;
|
||||
details.Add($"✓ Removed {customers.Count} seeded customer(s)");
|
||||
details.Add($"✓ Removed {customers.Count} customer(s)");
|
||||
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
@@ -205,7 +380,7 @@ public partial class SeedDataService
|
||||
}
|
||||
}
|
||||
|
||||
// --- Inventory Items ---
|
||||
// ── Inventory Items ───────────────────────────────────────────────────
|
||||
if (options.InventoryItems)
|
||||
{
|
||||
var seededSkus = SeededInventorySkuSuffixes.Select(s => $"{company.CompanyCode}{s}").ToArray();
|
||||
@@ -223,7 +398,7 @@ public partial class SeedDataService
|
||||
|
||||
_context.InventoryItems.RemoveRange(inventoryItems);
|
||||
totalRemoved += inventoryItems.Count;
|
||||
details.Add($"✓ Removed {inventoryItems.Count} seeded inventory item(s)");
|
||||
details.Add($"✓ Removed {inventoryItems.Count} inventory item(s)");
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
else
|
||||
@@ -232,7 +407,7 @@ public partial class SeedDataService
|
||||
}
|
||||
}
|
||||
|
||||
// --- Equipment (+ maintenance records) ---
|
||||
// ── Equipment (+ maintenance records) ────────────────────────────────
|
||||
if (options.Equipment)
|
||||
{
|
||||
var seededEquipment = await _context.Equipment
|
||||
@@ -249,7 +424,7 @@ public partial class SeedDataService
|
||||
|
||||
_context.Equipment.RemoveRange(seededEquipment);
|
||||
totalRemoved += seededEquipment.Count;
|
||||
details.Add($"✓ Removed {seededEquipment.Count} seeded equipment record(s)");
|
||||
details.Add($"✓ Removed {seededEquipment.Count} equipment record(s)");
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
else
|
||||
@@ -258,7 +433,7 @@ public partial class SeedDataService
|
||||
}
|
||||
}
|
||||
|
||||
// --- Catalog Items & Categories ---
|
||||
// ── Catalog Items & Categories ────────────────────────────────────────
|
||||
if (options.Catalog)
|
||||
{
|
||||
var seededCategories = await _context.CatalogCategories
|
||||
@@ -277,12 +452,12 @@ public partial class SeedDataService
|
||||
{
|
||||
_context.CatalogItems.RemoveRange(catalogItems);
|
||||
totalRemoved += catalogItems.Count;
|
||||
details.Add($"✓ Removed {catalogItems.Count} seeded catalog item(s)");
|
||||
details.Add($"✓ Removed {catalogItems.Count} catalog item(s)");
|
||||
}
|
||||
|
||||
_context.CatalogCategories.RemoveRange(seededCategories);
|
||||
totalRemoved += seededCategories.Count;
|
||||
details.Add($"✓ Removed {seededCategories.Count} seeded catalog categor(y/ies)");
|
||||
details.Add($"✓ Removed {seededCategories.Count} catalog categor(y/ies)");
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
else
|
||||
@@ -291,7 +466,7 @@ public partial class SeedDataService
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pricing Tiers ---
|
||||
// ── Pricing Tiers ─────────────────────────────────────────────────────
|
||||
if (options.PricingTiers)
|
||||
{
|
||||
var tiers = await _context.PricingTiers
|
||||
@@ -303,7 +478,7 @@ public partial class SeedDataService
|
||||
{
|
||||
_context.PricingTiers.RemoveRange(tiers);
|
||||
totalRemoved += tiers.Count;
|
||||
details.Add($"✓ Removed {tiers.Count} seeded pricing tier(s)");
|
||||
details.Add($"✓ Removed {tiers.Count} pricing tier(s)");
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
else
|
||||
@@ -312,7 +487,7 @@ public partial class SeedDataService
|
||||
}
|
||||
}
|
||||
|
||||
// --- Operating Costs ---
|
||||
// ── Operating Costs ───────────────────────────────────────────────────
|
||||
if (options.OperatingCosts)
|
||||
{
|
||||
var costs = await _context.CompanyOperatingCosts
|
||||
@@ -324,7 +499,7 @@ public partial class SeedDataService
|
||||
{
|
||||
_context.CompanyOperatingCosts.RemoveRange(costs);
|
||||
totalRemoved += costs.Count;
|
||||
details.Add($"✓ Removed operating costs record");
|
||||
details.Add("✓ Removed operating costs record");
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
else
|
||||
@@ -333,6 +508,162 @@ public partial class SeedDataService
|
||||
}
|
||||
}
|
||||
|
||||
// ── Purchase Orders ───────────────────────────────────────────────────
|
||||
// Handled in the pre-sweep for ForceRemoveAll; this block serves selective removal.
|
||||
if (options.Bills && !options.ForceRemoveAll)
|
||||
{
|
||||
var poIds = await _context.Set<PurchaseOrder>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(p => p.CompanyId == companyId)
|
||||
.Select(p => p.Id)
|
||||
.ToListAsync();
|
||||
|
||||
if (poIds.Any())
|
||||
{
|
||||
var poItems = await _context.Set<PurchaseOrderItem>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(i => poIds.Contains(i.PurchaseOrderId))
|
||||
.ToListAsync();
|
||||
if (poItems.Any()) _context.Set<PurchaseOrderItem>().RemoveRange(poItems);
|
||||
|
||||
var pos = await _context.Set<PurchaseOrder>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(p => poIds.Contains(p.Id))
|
||||
.ToListAsync();
|
||||
_context.Set<PurchaseOrder>().RemoveRange(pos);
|
||||
totalRemoved += pos.Count;
|
||||
details.Add($"✓ Removed {pos.Count} purchase order(s)");
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Vendor Bills ──────────────────────────────────────────────────────
|
||||
// Handled in the pre-sweep for ForceRemoveAll; this block serves selective removal.
|
||||
if (options.Bills && !options.ForceRemoveAll)
|
||||
{
|
||||
var billIds = await _context.Set<Bill>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(b => b.CompanyId == companyId)
|
||||
.Select(b => b.Id)
|
||||
.ToListAsync();
|
||||
|
||||
if (billIds.Any())
|
||||
{
|
||||
var payments = await _context.Set<BillPayment>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(p => billIds.Contains(p.BillId))
|
||||
.ToListAsync();
|
||||
if (payments.Any()) _context.Set<BillPayment>().RemoveRange(payments);
|
||||
|
||||
var lineItems = await _context.Set<BillLineItem>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(li => billIds.Contains(li.BillId))
|
||||
.ToListAsync();
|
||||
if (lineItems.Any()) _context.Set<BillLineItem>().RemoveRange(lineItems);
|
||||
|
||||
var bills = await _context.Set<Bill>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(b => billIds.Contains(b.Id))
|
||||
.ToListAsync();
|
||||
_context.Set<Bill>().RemoveRange(bills);
|
||||
totalRemoved += bills.Count;
|
||||
details.Add($"✓ Removed {bills.Count} vendor bill(s)");
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
details.Add("• No vendor bills found");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Expenses ──────────────────────────────────────────────────────────
|
||||
// Handled in the pre-sweep for ForceRemoveAll; this block serves selective removal.
|
||||
if (options.Expenses && !options.ForceRemoveAll)
|
||||
{
|
||||
var expenses = await _context.Set<Expense>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(e => e.CompanyId == companyId)
|
||||
.ToListAsync();
|
||||
|
||||
if (expenses.Any())
|
||||
{
|
||||
_context.Set<Expense>().RemoveRange(expenses);
|
||||
totalRemoved += expenses.Count;
|
||||
details.Add($"✓ Removed {expenses.Count} expense(s)");
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
details.Add("• No expenses found");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Vendors ───────────────────────────────────────────────────────────
|
||||
if (options.Vendors || options.ForceRemoveAll)
|
||||
{
|
||||
var vendors = await _context.Set<Vendor>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(v => v.CompanyId == companyId)
|
||||
.ToListAsync();
|
||||
|
||||
if (vendors.Any())
|
||||
{
|
||||
_context.Set<Vendor>().RemoveRange(vendors);
|
||||
totalRemoved += vendors.Count;
|
||||
details.Add($"✓ Removed {vendors.Count} vendor(s)");
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
details.Add("• No vendors found");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Named Ovens (OvenCost) ────────────────────────────────────────────
|
||||
if (options.NamedOvens || options.ForceRemoveAll)
|
||||
{
|
||||
var ovens = await _context.Set<OvenCost>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(o => o.CompanyId == companyId)
|
||||
.ToListAsync();
|
||||
|
||||
if (ovens.Any())
|
||||
{
|
||||
_context.Set<OvenCost>().RemoveRange(ovens);
|
||||
totalRemoved += ovens.Count;
|
||||
details.Add($"✓ Removed {ovens.Count} named oven(s)");
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
details.Add("• No named ovens found");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Shop Workers ──────────────────────────────────────────────────────
|
||||
if (options.Workers)
|
||||
{
|
||||
var workerUsers = options.ForceRemoveAll
|
||||
? await _userManager.Users
|
||||
.Where(u => u.CompanyId == companyId && u.CompanyRole == "Worker")
|
||||
.ToListAsync()
|
||||
: await _userManager.Users
|
||||
.Where(u => SeededWorkerEmails.Contains(u.Email) && u.CompanyId == companyId)
|
||||
.ToListAsync();
|
||||
|
||||
if (workerUsers.Any())
|
||||
{
|
||||
foreach (var wu in workerUsers)
|
||||
await _userManager.DeleteAsync(wu);
|
||||
totalRemoved += workerUsers.Count;
|
||||
details.Add($"✓ Removed {workerUsers.Count} demo shop worker(s)");
|
||||
}
|
||||
else
|
||||
{
|
||||
details.Add("• No demo shop workers found");
|
||||
}
|
||||
}
|
||||
|
||||
result.ItemsSeeded = totalRemoved;
|
||||
result.Details = details;
|
||||
result.Message = totalRemoved > 0
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
|
||||
public partial class SeedDataService
|
||||
{
|
||||
/// <summary>
|
||||
/// Canonical emails of the 5 demo shop workers. Used as fingerprints in RemoveSeedDataAsync
|
||||
/// to avoid needing a special "IsSeeded" flag on ApplicationUser.
|
||||
/// </summary>
|
||||
internal static readonly string[] SeededWorkerEmails =
|
||||
[
|
||||
"mike.sanders@pcldemo.com",
|
||||
"jake.wilson@pcldemo.com",
|
||||
"sarah.brooks@pcldemo.com",
|
||||
"tyler.green@pcldemo.com",
|
||||
"chris.mason@pcldemo.com",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Seeds 5 named shop workers as ApplicationUser records for the demo company:
|
||||
/// Mike Sanders (Coater), Jake Wilson (Sandblaster), Sarah Brooks (Inspector),
|
||||
/// Tyler Green (General Worker), and Chris Mason (Shop Lead).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Workers are ApplicationUser records with CompanyRole = ShopFloor and the
|
||||
/// Employee system role. They are seeded before jobs and time entries so that
|
||||
/// AssignedUserId on Job and UserId on JobTimeEntry can reference them.
|
||||
///
|
||||
/// Uses a consistent email domain (@pcldemo.com) that will never conflict with
|
||||
/// real user accounts, making them safe to identify and remove on Demo Reset.
|
||||
///
|
||||
/// Idempotency: bails early if any of the 5 worker emails already exist.
|
||||
/// </remarks>
|
||||
private async Task<int> SeedShopWorkersAsync(Company company)
|
||||
{
|
||||
var anyExists = await _userManager.Users
|
||||
.AnyAsync(u => SeededWorkerEmails.Contains(u.Email) && u.CompanyId == company.Id);
|
||||
if (anyExists) return 0;
|
||||
|
||||
const string defaultPassword = "Worker123!";
|
||||
int created = 0;
|
||||
|
||||
// (email, firstName, lastName, empNum, position, laborRate)
|
||||
var workers = new (string email, string fn, string ln, string emp, string pos, decimal rate)[]
|
||||
{
|
||||
("mike.sanders@pcldemo.com", "Mike", "Sanders", "EMP-001", "Coater", 22.00m),
|
||||
("jake.wilson@pcldemo.com", "Jake", "Wilson", "EMP-002", "Sandblaster", 20.00m),
|
||||
("sarah.brooks@pcldemo.com", "Sarah", "Brooks", "EMP-003", "Quality Inspector", 24.00m),
|
||||
("tyler.green@pcldemo.com", "Tyler", "Green", "EMP-004", "General Worker", 18.00m),
|
||||
("chris.mason@pcldemo.com", "Chris", "Mason", "EMP-005", "Shop Lead", 28.00m),
|
||||
};
|
||||
|
||||
foreach (var (email, fn, ln, emp, pos, rate) in workers)
|
||||
{
|
||||
var user = await _userManager.FindByEmailAsync(email);
|
||||
if (user != null) continue;
|
||||
|
||||
user = new ApplicationUser
|
||||
{
|
||||
UserName = email,
|
||||
Email = email,
|
||||
FirstName = fn,
|
||||
LastName = ln,
|
||||
EmployeeNumber = emp,
|
||||
Department = "Shop Floor",
|
||||
Position = pos,
|
||||
LaborCostPerHour = rate,
|
||||
EmailConfirmed = true,
|
||||
HireDate = DateTime.UtcNow.AddMonths(-12),
|
||||
IsActive = true,
|
||||
CompanyId = company.Id,
|
||||
CompanyRole = AppConstants.CompanyRoles.Worker,
|
||||
CanManageJobs = true,
|
||||
CanViewShopFloor = true,
|
||||
CreatedAt = DateTime.UtcNow.AddMonths(-12)
|
||||
};
|
||||
|
||||
var result = await _userManager.CreateAsync(user, defaultPassword);
|
||||
if (result.Succeeded)
|
||||
{
|
||||
await _userManager.AddToRoleAsync(user, AppConstants.Roles.Employee);
|
||||
created++;
|
||||
}
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds job time entries for completed and in-progress jobs, giving the Worker
|
||||
/// Productivity report meaningful data from day one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Each completed or in-progress job receives 2–4 time entries spread across the
|
||||
/// 5 demo workers, with realistic hours for each coating stage (sandblasting,
|
||||
/// masking, coating, curing, inspection). The total hours roughly correlate with
|
||||
/// the job's EstimatedMinutes from its first JobItem.
|
||||
///
|
||||
/// Idempotency: bails early if any time entries already exist for this company's jobs.
|
||||
/// </remarks>
|
||||
private async Task<int> SeedJobTimeEntriesAsync(Company company)
|
||||
{
|
||||
var existingCount = await _context.Set<JobTimeEntry>()
|
||||
.IgnoreQueryFilters()
|
||||
.CountAsync(te => te.CompanyId == company.Id && !te.IsDeleted);
|
||||
if (existingCount > 0) return 0;
|
||||
|
||||
var workers = await _userManager.Users
|
||||
.Where(u => SeededWorkerEmails.Contains(u.Email) && u.CompanyId == company.Id)
|
||||
.OrderBy(u => u.Email)
|
||||
.ToListAsync();
|
||||
|
||||
if (workers.Count == 0) return 0;
|
||||
|
||||
// Resolve status IDs first — avoids relying on Include(j => j.JobStatus) which can
|
||||
// silently return null navigation properties when query filters interact with IgnoreQueryFilters.
|
||||
var workedStatusIds = await _context.Set<JobStatusLookup>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(s => s.CompanyId == company.Id && new[]
|
||||
{
|
||||
"IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING",
|
||||
"IN_OVEN", "COATING", "CURING", "QUALITY_CHECK",
|
||||
"COMPLETED", "READY_FOR_PICKUP", "DELIVERED"
|
||||
}.Contains(s.StatusCode))
|
||||
.Select(s => s.Id)
|
||||
.ToListAsync();
|
||||
|
||||
if (workedStatusIds.Count == 0) return 0;
|
||||
|
||||
var workedJobs = await _context.Set<Job>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(j => j.CompanyId == company.Id && !j.IsDeleted
|
||||
&& workedStatusIds.Contains(j.JobStatusId))
|
||||
.ToListAsync();
|
||||
|
||||
if (workedJobs.Count == 0) return 0;
|
||||
|
||||
string[] stages = ["Sandblasting", "Masking & Prep", "Coating", "Curing", "Inspection"];
|
||||
decimal[] stageHours = [1.5m, 0.75m, 1.25m, 0.5m, 0.5m];
|
||||
|
||||
var entries = new List<JobTimeEntry>();
|
||||
int jobIdx = 0;
|
||||
|
||||
foreach (var job in workedJobs)
|
||||
{
|
||||
// 2-4 entries per job cycling through stages
|
||||
var entryCount = 2 + (jobIdx % 3);
|
||||
var workDate = (job.StartedDate ?? job.CreatedAt).AddDays(1);
|
||||
|
||||
for (int e = 0; e < entryCount; e++)
|
||||
{
|
||||
var worker = workers[(jobIdx + e) % workers.Count];
|
||||
var stageIdx = e % stages.Length;
|
||||
|
||||
entries.Add(new JobTimeEntry
|
||||
{
|
||||
JobId = job.Id,
|
||||
UserId = worker.Id,
|
||||
UserDisplayName = worker.FullName,
|
||||
WorkDate = workDate.AddDays(e),
|
||||
HoursWorked = stageHours[stageIdx],
|
||||
Stage = stages[stageIdx],
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = workDate.AddDays(e)
|
||||
});
|
||||
}
|
||||
|
||||
jobIdx++;
|
||||
}
|
||||
|
||||
await _context.Set<JobTimeEntry>().AddRangeAsync(entries);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return entries.Count;
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using Microsoft.Data.SqlClient;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
@@ -13,15 +14,18 @@ public partial class SeedDataService : ISeedDataService
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly UserManager<ApplicationUser> _userManager;
|
||||
private readonly RoleManager<IdentityRole> _roleManager;
|
||||
private readonly IAccountBalanceService _accountBalanceService;
|
||||
|
||||
public SeedDataService(
|
||||
ApplicationDbContext context,
|
||||
UserManager<ApplicationUser> userManager,
|
||||
RoleManager<IdentityRole> roleManager)
|
||||
RoleManager<IdentityRole> roleManager,
|
||||
IAccountBalanceService accountBalanceService)
|
||||
{
|
||||
_context = context;
|
||||
_userManager = userManager;
|
||||
_roleManager = roleManager;
|
||||
_accountBalanceService = accountBalanceService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -411,17 +415,68 @@ public partial class SeedDataService : ISeedDataService
|
||||
}
|
||||
catch (Exception ex) { errors.Add($"✗ Customers: {ex.Message}"); _context.ChangeTracker.Clear(); }
|
||||
|
||||
// Workers must be seeded before jobs so AssignedUserId FK resolves
|
||||
await RunSeeder("Shop workers", details, errors, result, () => SeedShopWorkersAsync(company));
|
||||
await RunSeeder("Equipment", details, errors, result, () => SeedEquipmentAsync(company));
|
||||
await RunSeeder("Maintenance", details, errors, result, () => SeedMaintenanceRecordsAsync(company));
|
||||
await RunSeeder("Vendors", details, errors, result, () => SeedVendorsAsync(company));
|
||||
await RunSeeder("Purchase orders", details, errors, result, () => SeedPurchaseOrdersAsync(company));
|
||||
await RunSeeder("Named ovens", details, errors, result, () => SeedOvenCostsAsync(company));
|
||||
await RunSeeder("Catalog", details, errors, result, () => SeedCatalogAsync(company));
|
||||
await RunSeeder("Quotes", details, errors, result, () => SeedQuotesAsync(company));
|
||||
await RunSeeder("Jobs", details, errors, result, () => SeedJobsAsync(company));
|
||||
await RunSeeder("Job history", details, errors, result, () => SeedJobStatusHistoryAsync(company));
|
||||
await RunSeeder("Time entries", details, errors, result, () => SeedJobTimeEntriesAsync(company));
|
||||
await RunSeeder("Inv. txns", details, errors, result, () => SeedInventoryTransactionsAsync(company));
|
||||
await RunSeeder("Invoices", details, errors, result, () => SeedInvoicesAsync(company));
|
||||
await RunSeeder("AI predictions", details, errors, result, () => SeedAiPredictionsAsync(company));
|
||||
// Ensure chart of accounts exists before bills/expenses — both seeders silently return 0
|
||||
// if the AP or checking account is missing. SeedDefaultChartOfAccountsAsync is idempotent.
|
||||
try
|
||||
{
|
||||
var accountsAdded = await SeedDefaultChartOfAccountsAsync(company);
|
||||
var systemAccountsAdded = await EnsureSystemAccountsAsync(company);
|
||||
if (accountsAdded > 0)
|
||||
details.Add($"✓ {accountsAdded} chart of account(s) created");
|
||||
if (systemAccountsAdded > 0)
|
||||
details.Add($"✓ {systemAccountsAdded} missing system account(s) added");
|
||||
}
|
||||
catch (Exception ex) { errors.Add($"✗ Chart of accounts: {ex.Message}"); _context.ChangeTracker.Clear(); }
|
||||
|
||||
await RunSeeder("Vendor bills", details, errors, result, () => SeedBillsAsync(company));
|
||||
await RunSeeder("Expenses", details, errors, result, () => SeedExpensesAsync(company));
|
||||
|
||||
// Accounts survive resets (no removal sweep), so the chart-of-accounts seeder skips them
|
||||
// on every reset after the first. But 12 months of seeded expenses outpace ~3 months of
|
||||
// seeded revenue, and without a prior-period cash balance the checking account shows a
|
||||
// large negative. Patch the opening balances unconditionally so every reset is realistic.
|
||||
try
|
||||
{
|
||||
var checkingAcct = await _context.Set<Account>().IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted
|
||||
&& a.AccountSubType == AccountSubType.Checking);
|
||||
if (checkingAcct != null && checkingAcct.OpeningBalance == 0)
|
||||
{
|
||||
checkingAcct.OpeningBalance = 75_000m;
|
||||
checkingAcct.OpeningBalanceDate = DateTime.UtcNow.AddYears(-1);
|
||||
checkingAcct.CurrentBalance = 75_000m;
|
||||
await _context.SaveChangesAsync();
|
||||
details.Add("✓ Checking account opening balance set to $75,000");
|
||||
}
|
||||
var savingsAcct = await _context.Set<Account>().IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted
|
||||
&& a.AccountSubType == AccountSubType.Savings);
|
||||
if (savingsAcct != null && savingsAcct.OpeningBalance == 0)
|
||||
{
|
||||
savingsAcct.OpeningBalance = 14_500m;
|
||||
savingsAcct.OpeningBalanceDate = DateTime.UtcNow.AddYears(-1);
|
||||
savingsAcct.CurrentBalance = 14_500m;
|
||||
await _context.SaveChangesAsync();
|
||||
details.Add("✓ Savings account opening balance set to $14,500");
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { errors.Add($"✗ Account opening balances: {ex.Message}"); _context.ChangeTracker.Clear(); }
|
||||
|
||||
await RunSeeder("Appointments", details, errors, result, () => SeedAppointmentsAsync(company));
|
||||
|
||||
if (company.CompanyCode == "DEMO")
|
||||
@@ -437,6 +492,15 @@ public partial class SeedDataService : ISeedDataService
|
||||
catch (Exception ex) { errors.Add($"✗ Demo users: {ex.Message}"); _context.ChangeTracker.Clear(); }
|
||||
}
|
||||
|
||||
// Replay all GL transactions so CurrentBalance reflects the full seeded history,
|
||||
// including the opening balances patched above.
|
||||
try
|
||||
{
|
||||
await _accountBalanceService.RecalculateAllAsync(company.Id);
|
||||
details.Add("✓ Account balances recalculated");
|
||||
}
|
||||
catch (Exception ex) { errors.Add($"✗ Account balance recalculation: {ex.Message}"); _context.ChangeTracker.Clear(); }
|
||||
|
||||
if (errors.Any())
|
||||
{
|
||||
details.AddRange(errors);
|
||||
@@ -742,325 +806,94 @@ public partial class SeedDataService : ISeedDataService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds ten representative inventory items (eight powder colours, one cleaner, one
|
||||
/// masking tape roll) for the company, linking each to the appropriate category lookup.
|
||||
/// Seeds 11 inventory items (6 powder colours + 5 consumables) for the company.
|
||||
/// Two powders are intentionally below reorder point (low-stock alert) and one
|
||||
/// consumable is at zero (out-of-stock), matching the demo company spec.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Returns a tuple rather than a plain int because each item is saved individually
|
||||
/// (one <c>SaveChangesAsync</c> call per item) so that a duplicate-SKU error on one
|
||||
/// item does not roll back the entire batch. Failed items are captured as per-item
|
||||
/// warning strings rather than aborting the seeder.
|
||||
/// Powders are the six colours featured in the demo company's jobs and quotes:
|
||||
/// Gloss Black, Matte Black, Super Chrome (low), Candy Red (low), Signal White,
|
||||
/// Illusion Purple. Consumables are the five shop supplies shown in tutorials:
|
||||
/// Masking Tape, Silicone Plugs (out-of-stock), Hanging Hooks, Acetone, Blast Media.
|
||||
///
|
||||
/// SKUs are prefixed with the company's <see cref="Company.CompanyCode"/> to guarantee
|
||||
/// uniqueness across tenants in a shared database — e.g., <c>DEMO-PWD-BLK-001</c>.
|
||||
/// A missing or empty CompanyCode throws <see cref="InvalidOperationException"/> because
|
||||
/// SKU collisions would violate the unique index on the InventoryItems table.
|
||||
///
|
||||
/// Category IDs are resolved by <c>CategoryCode</c> (e.g., "POWDER", "CLEANER") rather
|
||||
/// than hard-coded IDs because lookup IDs differ per company and per environment.
|
||||
///
|
||||
/// All powder items default to <c>CoverageSqFtPerLb = 30</c> and
|
||||
/// <c>TransferEfficiency = 65</c>, which are industry-standard starting values used by
|
||||
/// the pricing engine when calculating powder needed per coat.
|
||||
/// SKUs are prefixed with <see cref="Company.CompanyCode"/> to guarantee uniqueness
|
||||
/// across tenants in a shared database (e.g., DEMO-PWD-GBK-001).
|
||||
/// </remarks>
|
||||
/// <param name="company">The tenant company to seed inventory for.</param>
|
||||
/// <returns>
|
||||
/// A tuple of (count of items successfully inserted, list of per-item warning messages
|
||||
/// for skipped or failed items).
|
||||
/// </returns>
|
||||
private async Task<(int seededCount, List<string> warnings)> SeedInventoryItemsAsync(Company company)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
int seededCount = 0;
|
||||
|
||||
// Validate company code
|
||||
if (string.IsNullOrWhiteSpace(company.CompanyCode))
|
||||
{
|
||||
throw new InvalidOperationException($"Company {company.CompanyName} (ID: {company.Id}) has no CompanyCode. Cannot seed inventory with unique SKUs.");
|
||||
}
|
||||
throw new InvalidOperationException($"Company {company.CompanyName} (ID: {company.Id}) has no CompanyCode.");
|
||||
|
||||
var skuPrefix = company.CompanyCode;
|
||||
|
||||
// Get category lookups to link items properly
|
||||
var categories = await _context.InventoryCategoryLookups
|
||||
.IgnoreQueryFilters()
|
||||
.Where(c => c.CompanyId == company.Id && !c.IsDeleted)
|
||||
.ToListAsync();
|
||||
|
||||
var powderCategory = categories.FirstOrDefault(c => c.CategoryCode == "POWDER");
|
||||
var cleanerCategory = categories.FirstOrDefault(c => c.CategoryCode == "CLEANER");
|
||||
var maskingCategory = categories.FirstOrDefault(c => c.CategoryCode == "MASKING");
|
||||
var powderCat = categories.FirstOrDefault(c => c.CategoryCode == "POWDER");
|
||||
var cleanerCat = categories.FirstOrDefault(c => c.CategoryCode == "CLEANER");
|
||||
var maskingCat = categories.FirstOrDefault(c => c.CategoryCode == "MASKING");
|
||||
var abrasiveCat = categories.FirstOrDefault(c => c.CategoryCode == "ABRASIVE");
|
||||
var consumeCat = categories.FirstOrDefault(c => c.CategoryCode == "CONSUMABLE");
|
||||
|
||||
// Use company code prefix to ensure unique SKUs across companies
|
||||
// ── Helper: powder item ───────────────────────────────────────────────
|
||||
InventoryItem Pwd(string sku, string name, string color, string ral, string finish,
|
||||
string mfr, string mfrPn, int qty, int reorder, int reorderQty, decimal cost) =>
|
||||
new InventoryItem
|
||||
{
|
||||
SKU = $"{skuPrefix}-{sku}", Name = name, Description = $"{finish} {color} powder coating",
|
||||
Category = "Powder", InventoryCategoryId = powderCat?.Id,
|
||||
ColorName = color, ColorCode = ral, Finish = finish,
|
||||
Manufacturer = mfr, ManufacturerPartNumber = mfrPn,
|
||||
QuantityOnHand = qty, UnitOfMeasure = "lbs",
|
||||
ReorderPoint = reorder, ReorderQuantity = reorderQty,
|
||||
MinimumStock = reorder / 2, MaximumStock = reorderQty * 4,
|
||||
UnitCost = cost, AverageCost = cost, LastPurchasePrice = cost,
|
||||
LastPurchaseDate = DateTime.UtcNow.AddDays(-30),
|
||||
CoverageSqFtPerLb = 30m, TransferEfficiency = 65m,
|
||||
IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// ── Helper: supply/consumable item ────────────────────────────────────
|
||||
InventoryItem Supply(string sku, string name, string desc, string cat,
|
||||
int? catId, string uom, int qty, int reorder, int reorderQty, decimal cost) =>
|
||||
new InventoryItem
|
||||
{
|
||||
SKU = $"{skuPrefix}-{sku}", Name = name, Description = desc,
|
||||
Category = cat, InventoryCategoryId = catId,
|
||||
QuantityOnHand = qty, UnitOfMeasure = uom,
|
||||
ReorderPoint = reorder, ReorderQuantity = reorderQty,
|
||||
MinimumStock = reorder / 2, MaximumStock = reorderQty * 4,
|
||||
UnitCost = cost, AverageCost = cost, LastPurchasePrice = cost,
|
||||
LastPurchaseDate = DateTime.UtcNow.AddDays(-20),
|
||||
IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// ── 6 Powders (2 low-stock, 0 out-of-stock) ──────────────────────────
|
||||
// Super Chrome (40 lbs) and Candy Red (25 lbs) are below reorder point
|
||||
// so the dashboard low-stock alert card is populated on first load.
|
||||
|
||||
var inventoryItems = new List<InventoryItem>
|
||||
{
|
||||
new InventoryItem
|
||||
{
|
||||
SKU = $"{skuPrefix}-PWD-BLK-001",
|
||||
Name = "Matte Black Powder",
|
||||
Description = "High-quality matte black powder coating",
|
||||
Category = "Powder",
|
||||
InventoryCategoryId = powderCategory?.Id,
|
||||
ColorName = "Matte Black",
|
||||
ColorCode = "RAL 9005",
|
||||
Finish = "Matte",
|
||||
Manufacturer = "Tiger Drylac",
|
||||
ManufacturerPartNumber = "TG-MB-001",
|
||||
QuantityOnHand = 500,
|
||||
UnitOfMeasure = "lbs",
|
||||
ReorderPoint = 100,
|
||||
ReorderQuantity = 250,
|
||||
MinimumStock = 50,
|
||||
MaximumStock = 1000,
|
||||
UnitCost = 4.50m,
|
||||
AverageCost = 4.50m,
|
||||
LastPurchasePrice = 4.50m,
|
||||
LastPurchaseDate = DateTime.UtcNow.AddDays(-30),
|
||||
CoverageSqFtPerLb = 30m,
|
||||
TransferEfficiency = 65m,
|
||||
IsActive = true,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new InventoryItem
|
||||
{
|
||||
SKU = $"{skuPrefix}-PWD-WHT-001",
|
||||
Name = "Gloss White Powder",
|
||||
Description = "High-gloss white powder coating",
|
||||
Category = "Powder",
|
||||
InventoryCategoryId = powderCategory?.Id,
|
||||
ColorName = "Gloss White",
|
||||
ColorCode = "RAL 9010",
|
||||
Finish = "Gloss",
|
||||
Manufacturer = "Tiger Drylac",
|
||||
ManufacturerPartNumber = "TG-GW-001",
|
||||
QuantityOnHand = 400,
|
||||
UnitOfMeasure = "lbs",
|
||||
ReorderPoint = 100,
|
||||
ReorderQuantity = 250,
|
||||
MinimumStock = 50,
|
||||
MaximumStock = 1000,
|
||||
UnitCost = 4.25m,
|
||||
AverageCost = 4.25m,
|
||||
LastPurchasePrice = 4.25m,
|
||||
LastPurchaseDate = DateTime.UtcNow.AddDays(-25),
|
||||
CoverageSqFtPerLb = 30m,
|
||||
TransferEfficiency = 65m,
|
||||
IsActive = true,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new InventoryItem
|
||||
{
|
||||
SKU = $"{skuPrefix}-PWD-RED-001",
|
||||
Name = "Gloss Red Powder",
|
||||
Description = "Vibrant gloss red powder coating",
|
||||
Category = "Powder",
|
||||
InventoryCategoryId = powderCategory?.Id,
|
||||
ColorName = "Traffic Red",
|
||||
ColorCode = "RAL 3020",
|
||||
Finish = "Gloss",
|
||||
Manufacturer = "Tiger Drylac",
|
||||
ManufacturerPartNumber = "TG-GR-001",
|
||||
QuantityOnHand = 150,
|
||||
UnitOfMeasure = "lbs",
|
||||
ReorderPoint = 50,
|
||||
ReorderQuantity = 100,
|
||||
MinimumStock = 25,
|
||||
MaximumStock = 500,
|
||||
UnitCost = 5.75m,
|
||||
AverageCost = 5.75m,
|
||||
LastPurchasePrice = 5.75m,
|
||||
LastPurchaseDate = DateTime.UtcNow.AddDays(-20),
|
||||
CoverageSqFtPerLb = 30m,
|
||||
TransferEfficiency = 65m,
|
||||
IsActive = true,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new InventoryItem
|
||||
{
|
||||
SKU = $"{skuPrefix}-PWD-BLU-001",
|
||||
Name = "Metallic Blue Powder",
|
||||
Description = "Metallic blue powder coating with shimmer",
|
||||
Category = "Powder",
|
||||
InventoryCategoryId = powderCategory?.Id,
|
||||
ColorName = "Metallic Blue",
|
||||
ColorCode = "RAL 5002",
|
||||
Finish = "Metallic",
|
||||
Manufacturer = "Axalta",
|
||||
ManufacturerPartNumber = "AX-MB-001",
|
||||
QuantityOnHand = 200,
|
||||
UnitOfMeasure = "lbs",
|
||||
ReorderPoint = 75,
|
||||
ReorderQuantity = 150,
|
||||
MinimumStock = 25,
|
||||
MaximumStock = 500,
|
||||
UnitCost = 6.25m,
|
||||
AverageCost = 6.25m,
|
||||
LastPurchasePrice = 6.25m,
|
||||
LastPurchaseDate = DateTime.UtcNow.AddDays(-15),
|
||||
CoverageSqFtPerLb = 30m,
|
||||
TransferEfficiency = 65m,
|
||||
IsActive = true,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new InventoryItem
|
||||
{
|
||||
SKU = $"{skuPrefix}-PWD-GRY-001",
|
||||
Name = "Textured Gray Powder",
|
||||
Description = "Textured gray powder coating for industrial use",
|
||||
Category = "Powder",
|
||||
InventoryCategoryId = powderCategory?.Id,
|
||||
ColorName = "Textured Gray",
|
||||
ColorCode = "RAL 7037",
|
||||
Finish = "Textured",
|
||||
Manufacturer = "Axalta",
|
||||
ManufacturerPartNumber = "AX-TG-001",
|
||||
QuantityOnHand = 300,
|
||||
UnitOfMeasure = "lbs",
|
||||
ReorderPoint = 75,
|
||||
ReorderQuantity = 150,
|
||||
MinimumStock = 50,
|
||||
MaximumStock = 600,
|
||||
UnitCost = 5.00m,
|
||||
AverageCost = 5.00m,
|
||||
LastPurchasePrice = 5.00m,
|
||||
LastPurchaseDate = DateTime.UtcNow.AddDays(-10),
|
||||
CoverageSqFtPerLb = 30m,
|
||||
TransferEfficiency = 65m,
|
||||
IsActive = true,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new InventoryItem
|
||||
{
|
||||
SKU = $"{skuPrefix}-PWD-YEL-001",
|
||||
Name = "Safety Yellow Powder",
|
||||
Description = "High-visibility safety yellow powder coating",
|
||||
Category = "Powder",
|
||||
InventoryCategoryId = powderCategory?.Id,
|
||||
ColorName = "Safety Yellow",
|
||||
ColorCode = "RAL 1003",
|
||||
Finish = "Gloss",
|
||||
Manufacturer = "Tiger Drylac",
|
||||
ManufacturerPartNumber = "TG-SY-001",
|
||||
QuantityOnHand = 125,
|
||||
UnitOfMeasure = "lbs",
|
||||
ReorderPoint = 50,
|
||||
ReorderQuantity = 100,
|
||||
MinimumStock = 25,
|
||||
MaximumStock = 400,
|
||||
UnitCost = 5.50m,
|
||||
AverageCost = 5.50m,
|
||||
LastPurchasePrice = 5.50m,
|
||||
LastPurchaseDate = DateTime.UtcNow.AddDays(-5),
|
||||
CoverageSqFtPerLb = 30m,
|
||||
TransferEfficiency = 65m,
|
||||
IsActive = true,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new InventoryItem
|
||||
{
|
||||
SKU = $"{skuPrefix}-PWD-ORG-001",
|
||||
Name = "Orange Powder",
|
||||
Description = "Bright orange powder coating",
|
||||
Category = "Powder",
|
||||
InventoryCategoryId = powderCategory?.Id,
|
||||
ColorName = "Pure Orange",
|
||||
ColorCode = "RAL 2004",
|
||||
Finish = "Gloss",
|
||||
Manufacturer = "Axalta",
|
||||
ManufacturerPartNumber = "AX-PO-001",
|
||||
QuantityOnHand = 100,
|
||||
UnitOfMeasure = "lbs",
|
||||
ReorderPoint = 40,
|
||||
ReorderQuantity = 80,
|
||||
MinimumStock = 20,
|
||||
MaximumStock = 300,
|
||||
UnitCost = 5.85m,
|
||||
AverageCost = 5.85m,
|
||||
LastPurchasePrice = 5.85m,
|
||||
LastPurchaseDate = DateTime.UtcNow.AddDays(-12),
|
||||
CoverageSqFtPerLb = 30m,
|
||||
TransferEfficiency = 65m,
|
||||
IsActive = true,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new InventoryItem
|
||||
{
|
||||
SKU = $"{skuPrefix}-PWD-GRN-001",
|
||||
Name = "Forest Green Powder",
|
||||
Description = "Deep forest green powder coating",
|
||||
Category = "Powder",
|
||||
InventoryCategoryId = powderCategory?.Id,
|
||||
ColorName = "Forest Green",
|
||||
ColorCode = "RAL 6009",
|
||||
Finish = "Matte",
|
||||
Manufacturer = "Tiger Drylac",
|
||||
ManufacturerPartNumber = "TG-FG-001",
|
||||
QuantityOnHand = 175,
|
||||
UnitOfMeasure = "lbs",
|
||||
ReorderPoint = 60,
|
||||
ReorderQuantity = 120,
|
||||
MinimumStock = 30,
|
||||
MaximumStock = 400,
|
||||
UnitCost = 5.25m,
|
||||
AverageCost = 5.25m,
|
||||
LastPurchasePrice = 5.25m,
|
||||
LastPurchaseDate = DateTime.UtcNow.AddDays(-8),
|
||||
CoverageSqFtPerLb = 30m,
|
||||
TransferEfficiency = 65m,
|
||||
IsActive = true,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new InventoryItem
|
||||
{
|
||||
SKU = $"{skuPrefix}-CLN-001",
|
||||
Name = "Pre-Treatment Cleaner",
|
||||
Description = "Industrial degreaser and cleaner",
|
||||
Category = "Cleaner",
|
||||
InventoryCategoryId = cleanerCategory?.Id,
|
||||
QuantityOnHand = 50,
|
||||
UnitOfMeasure = "gallons",
|
||||
ReorderPoint = 10,
|
||||
ReorderQuantity = 25,
|
||||
MinimumStock = 5,
|
||||
MaximumStock = 100,
|
||||
UnitCost = 12.50m,
|
||||
AverageCost = 12.50m,
|
||||
LastPurchasePrice = 12.50m,
|
||||
LastPurchaseDate = DateTime.UtcNow.AddDays(-20),
|
||||
IsActive = true,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new InventoryItem
|
||||
{
|
||||
SKU = $"{skuPrefix}-MSK-001",
|
||||
Name = "High-Temp Masking Tape",
|
||||
Description = "Heat-resistant masking tape for powder coating",
|
||||
Category = "Masking",
|
||||
InventoryCategoryId = maskingCategory?.Id,
|
||||
QuantityOnHand = 200,
|
||||
UnitOfMeasure = "rolls",
|
||||
ReorderPoint = 50,
|
||||
ReorderQuantity = 100,
|
||||
MinimumStock = 25,
|
||||
MaximumStock = 500,
|
||||
UnitCost = 8.75m,
|
||||
AverageCost = 8.75m,
|
||||
LastPurchasePrice = 8.75m,
|
||||
LastPurchaseDate = DateTime.UtcNow.AddDays(-15),
|
||||
IsActive = true,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
}
|
||||
Pwd("PWD-GBK-001", "Gloss Black", "Gloss Black", "RAL 9005", "Gloss", "Prismatic Powders", "PP-GBK-001", 300, 80, 200, 4.50m),
|
||||
Pwd("PWD-MBK-001", "Matte Black", "Matte Black", "RAL 9005", "Matte", "Prismatic Powders", "PP-MBK-001", 500, 100, 250, 4.50m),
|
||||
Pwd("PWD-CHR-001", "Super Chrome", "Super Chrome", "RAL 9006", "Chrome", "Columbia Coatings", "CC-CHR-001", 40, 100, 150, 8.75m), // LOW STOCK
|
||||
Pwd("PWD-CRD-001", "Candy Red", "Candy Red", "RAL 3028", "Candy", "Prismatic Powders", "PP-CRD-001", 25, 50, 100, 6.50m), // LOW STOCK
|
||||
Pwd("PWD-SWH-001", "Signal White", "Signal White", "RAL 9003", "Gloss", "Columbia Coatings", "CC-SWH-001", 400, 80, 200, 4.25m),
|
||||
Pwd("PWD-IPU-001", "Illusion Purple","Illusion Purple","RAL 4005", "Metallic", "Prismatic Powders", "PP-IPU-001", 150, 60, 120, 7.25m),
|
||||
|
||||
// ── 5 Consumables (1 out-of-stock) ───────────────────────────────
|
||||
// Silicone Plugs at qty=0 so the dashboard shows one out-of-stock item.
|
||||
|
||||
Supply("MSK-001", "High-Temp Masking Tape", "2-inch heat-resistant masking tape", "Masking Supplies", maskingCat?.Id, "rolls", 80, 30, 100, 8.75m),
|
||||
Supply("PLG-001", "Silicone Plugs Assorted", "Assorted silicone masking plugs (bag of 100)", "Masking Supplies", maskingCat?.Id, "bags", 0, 50, 100, 14.50m), // OUT OF STOCK
|
||||
Supply("HKS-001", "Powder Coating Hooks", "Steel hanging hooks for racking parts", "Consumables", consumeCat?.Id, "count", 200, 50, 200, 0.35m),
|
||||
Supply("ACT-001", "Acetone Degreaser", "Industrial acetone for pre-coating degreasing", "Cleaner", cleanerCat?.Id, "gallons", 20, 5, 25, 18.00m),
|
||||
Supply("BLM-001", "Aluminum Oxide Blast Media","120-grit aluminum oxide blasting media", "Abrasive Media", abrasiveCat?.Id, "lbs", 250, 100, 250, 1.85m),
|
||||
};
|
||||
|
||||
// Add inventory items one at a time to handle duplicates gracefully
|
||||
@@ -1244,9 +1077,9 @@ public partial class SeedDataService : ISeedDataService
|
||||
{
|
||||
new Vendor { CompanyId = company.Id, CompanyName = "Prismatic Powders", ContactName = "Sales", Email = "sales@prismaticpowders.com", Phone = "800-867-4445", Website = "https://www.prismaticpowders.com", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
|
||||
new Vendor { CompanyId = company.Id, CompanyName = "Columbia Coatings", ContactName = "Sales", Email = "info@columbiacoatings.com", Phone = "888-265-8247", Website = "https://www.columbiacoatings.com", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
|
||||
new Vendor { CompanyId = company.Id, CompanyName = "Sherwin-Williams Industrial", ContactName = "Account Rep", Email = "industrial@sherwin-williams.com", Phone = "800-524-5979", Website = "https://www.sherwin-williams.com", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
|
||||
new Vendor { CompanyId = company.Id, CompanyName = "Ace Hardware Supply", ContactName = "Purchasing", Email = "supply@acehardware.com", Phone = "630-990-6600", Website = "https://www.acehardware.com", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
|
||||
new Vendor { CompanyId = company.Id, CompanyName = "Fastenal Industrial", ContactName = "Sales Team", Email = "sales@fastenal.com", Phone = "507-454-5374", Website = "https://www.fastenal.com", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
|
||||
new Vendor { CompanyId = company.Id, CompanyName = "Harbor Freight Tools", ContactName = "Purchasing", Email = "purchasing@harborfreight.com", Phone = "800-444-3353", Website = "https://www.harborfreight.com", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
|
||||
new Vendor { CompanyId = company.Id, CompanyName = "Grainger Industrial Supply",ContactName = "Account Rep", Email = "accounts@grainger.com", Phone = "800-472-4643", Website = "https://www.grainger.com", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
|
||||
new Vendor { CompanyId = company.Id, CompanyName = "Local Industrial Supply", ContactName = "Sales Team", Email = "sales@localindustrialsupply.com", Phone = "(919) 555-0100", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
|
||||
};
|
||||
|
||||
await _context.Set<Vendor>().AddRangeAsync(vendors);
|
||||
|
||||
@@ -127,6 +127,7 @@ public class AccountDataExportController : Controller
|
||||
switch (sheet)
|
||||
{
|
||||
case "Customers": await AddCustomersSheet(package, companyId, headerColor); break;
|
||||
case "CustomerContacts": await AddCustomerContactsSheet(package, companyId, headerColor); break;
|
||||
case "Jobs": await AddJobsSheet(package, companyId, headerColor); break;
|
||||
case "Quotes": await AddQuotesSheet(package, companyId, headerColor); break;
|
||||
case "Invoices": await AddInvoicesSheet(package, companyId, headerColor); break;
|
||||
@@ -175,6 +176,7 @@ public class AccountDataExportController : Controller
|
||||
switch (sheet)
|
||||
{
|
||||
case "Customers": WriteCsvEntry(zip, "Customers.csv", await BuildCustomersCsv(companyId)); break;
|
||||
case "CustomerContacts": WriteCsvEntry(zip, "CustomerContacts.csv", await BuildCustomerContactsCsv(companyId)); break;
|
||||
case "Jobs": WriteCsvEntry(zip, "Jobs.csv", await BuildJobsCsv(companyId)); break;
|
||||
case "Quotes": WriteCsvEntry(zip, "Quotes.csv", await BuildQuotesCsv(companyId)); break;
|
||||
case "Invoices": WriteCsvEntry(zip, "Invoices.csv", await BuildInvoicesCsv(companyId)); break;
|
||||
@@ -299,7 +301,9 @@ public class AccountDataExportController : Controller
|
||||
var data = await FetchCustomersAsync(companyId);
|
||||
var ws = pkg.Workbook.Worksheets.Add("Customers");
|
||||
var headers = new[] { "ID", "Company Name", "First Name", "Last Name", "Email", "Phone",
|
||||
"Commercial", "City", "State", "Active", "Credit Limit", "Current Balance", "Created At" };
|
||||
"Commercial", "City", "State", "Active", "Credit Limit", "Current Balance",
|
||||
"Lead Source", "Ship-To Address", "Ship-To City", "Ship-To State", "Ship-To Zip", "Ship-To Country",
|
||||
"Created At" };
|
||||
WriteHeader(ws, headers, hdr);
|
||||
for (int i = 0; i < data.Count; i++)
|
||||
{
|
||||
@@ -311,7 +315,34 @@ public class AccountDataExportController : Controller
|
||||
ws.Cells[r, 8].Value = c.City; ws.Cells[r, 9].Value = c.State;
|
||||
ws.Cells[r, 10].Value = c.IsActive ? "Yes" : "No";
|
||||
ws.Cells[r, 11].Value = c.CreditLimit; ws.Cells[r, 12].Value = c.CurrentBalance;
|
||||
ws.Cells[r, 13].Value = c.CreatedAt.ToString("yyyy-MM-dd");
|
||||
ws.Cells[r, 13].Value = c.LeadSource;
|
||||
ws.Cells[r, 14].Value = c.ShipToAddress; ws.Cells[r, 15].Value = c.ShipToCity;
|
||||
ws.Cells[r, 16].Value = c.ShipToState; ws.Cells[r, 17].Value = c.ShipToZipCode;
|
||||
ws.Cells[r, 18].Value = c.ShipToCountry;
|
||||
ws.Cells[r, 19].Value = c.CreatedAt.ToString("yyyy-MM-dd");
|
||||
}
|
||||
AutoFit(ws, headers.Length);
|
||||
}
|
||||
|
||||
private async Task AddCustomerContactsSheet(ExcelPackage pkg, int companyId, Color hdr)
|
||||
{
|
||||
var data = await _db.CustomerContacts.AsNoTracking()
|
||||
.Include(cc => cc.Customer)
|
||||
.Where(cc => cc.CompanyId == companyId && !cc.IsDeleted)
|
||||
.OrderBy(cc => cc.Customer!.CompanyName).ThenBy(cc => cc.LastName).ThenBy(cc => cc.FirstName)
|
||||
.ToListAsync();
|
||||
|
||||
var ws = pkg.Workbook.Worksheets.Add("CustomerContacts");
|
||||
var headers = new[] { "CustomerEmail", "FirstName", "LastName", "Title", "ContactRole", "Email", "Phone", "MobilePhone", "Notes" };
|
||||
WriteHeader(ws, headers, hdr);
|
||||
for (int i = 0; i < data.Count; i++)
|
||||
{
|
||||
var r = i + 2; var cc = data[i];
|
||||
ws.Cells[r, 1].Value = cc.Customer?.Email;
|
||||
ws.Cells[r, 2].Value = cc.FirstName; ws.Cells[r, 3].Value = cc.LastName;
|
||||
ws.Cells[r, 4].Value = cc.Title; ws.Cells[r, 5].Value = cc.ContactRole;
|
||||
ws.Cells[r, 6].Value = cc.Email; ws.Cells[r, 7].Value = cc.Phone;
|
||||
ws.Cells[r, 8].Value = cc.MobilePhone; ws.Cells[r, 9].Value = cc.Notes;
|
||||
}
|
||||
AutoFit(ws, headers.Length);
|
||||
}
|
||||
@@ -326,7 +357,7 @@ public class AccountDataExportController : Controller
|
||||
var data = await FetchJobsAsync(companyId);
|
||||
var ws = pkg.Workbook.Worksheets.Add("Jobs");
|
||||
var headers = new[] { "ID", "Job Number", "Customer", "Status", "Priority",
|
||||
"Description", "Due Date", "Final Price", "Created At" };
|
||||
"Description", "Project Name", "Due Date", "Final Price", "Created At" };
|
||||
WriteHeader(ws, headers, hdr);
|
||||
for (int i = 0; i < data.Count; i++)
|
||||
{
|
||||
@@ -337,9 +368,10 @@ public class AccountDataExportController : Controller
|
||||
ws.Cells[r, 4].Value = j.JobStatus?.DisplayName ?? j.JobStatusId.ToString();
|
||||
ws.Cells[r, 5].Value = j.JobPriority?.DisplayName ?? j.JobPriorityId.ToString();
|
||||
ws.Cells[r, 6].Value = j.Description;
|
||||
ws.Cells[r, 7].Value = j.DueDate?.ToString("yyyy-MM-dd");
|
||||
ws.Cells[r, 8].Value = j.FinalPrice;
|
||||
ws.Cells[r, 9].Value = j.CreatedAt.ToString("yyyy-MM-dd");
|
||||
ws.Cells[r, 7].Value = j.ProjectName;
|
||||
ws.Cells[r, 8].Value = j.DueDate?.ToString("yyyy-MM-dd");
|
||||
ws.Cells[r, 9].Value = j.FinalPrice;
|
||||
ws.Cells[r, 10].Value = j.CreatedAt.ToString("yyyy-MM-dd");
|
||||
}
|
||||
AutoFit(ws, headers.Length);
|
||||
}
|
||||
@@ -353,7 +385,7 @@ public class AccountDataExportController : Controller
|
||||
var data = await FetchQuotesAsync(companyId);
|
||||
var ws = pkg.Workbook.Worksheets.Add("Quotes");
|
||||
var headers = new[] { "ID", "Quote Number", "Customer / Prospect", "Status",
|
||||
"Quote Date", "Expiration Date", "Subtotal", "Tax", "Total" };
|
||||
"Quote Date", "Expiration Date", "Project Name", "Subtotal", "Tax", "Total" };
|
||||
WriteHeader(ws, headers, hdr);
|
||||
for (int i = 0; i < data.Count; i++)
|
||||
{
|
||||
@@ -363,7 +395,8 @@ public class AccountDataExportController : Controller
|
||||
ws.Cells[r, 4].Value = q.QuoteStatus?.DisplayName ?? q.QuoteStatusId.ToString();
|
||||
ws.Cells[r, 5].Value = q.QuoteDate.ToString("yyyy-MM-dd");
|
||||
ws.Cells[r, 6].Value = q.ExpirationDate?.ToString("yyyy-MM-dd");
|
||||
ws.Cells[r, 7].Value = q.SubTotal; ws.Cells[r, 8].Value = q.TaxAmount; ws.Cells[r, 9].Value = q.Total;
|
||||
ws.Cells[r, 7].Value = q.ProjectName;
|
||||
ws.Cells[r, 8].Value = q.SubTotal; ws.Cells[r, 9].Value = q.TaxAmount; ws.Cells[r, 10].Value = q.Total;
|
||||
}
|
||||
AutoFit(ws, headers.Length);
|
||||
}
|
||||
@@ -377,7 +410,7 @@ public class AccountDataExportController : Controller
|
||||
var data = await FetchInvoicesAsync(companyId);
|
||||
var ws = pkg.Workbook.Worksheets.Add("Invoices");
|
||||
var headers = new[] { "ID", "Invoice #", "Customer", "Status", "Invoice Date",
|
||||
"Due Date", "Subtotal", "Tax", "Total", "Amount Paid", "Balance Due" };
|
||||
"Due Date", "Project Name", "Subtotal", "Tax", "Total", "Amount Paid", "Balance Due" };
|
||||
WriteHeader(ws, headers, hdr);
|
||||
for (int i = 0; i < data.Count; i++)
|
||||
{
|
||||
@@ -389,9 +422,10 @@ public class AccountDataExportController : Controller
|
||||
ws.Cells[r, 3].Value = cust; ws.Cells[r, 4].Value = inv.Status.ToString();
|
||||
ws.Cells[r, 5].Value = inv.InvoiceDate.ToString("yyyy-MM-dd");
|
||||
ws.Cells[r, 6].Value = inv.DueDate?.ToString("yyyy-MM-dd");
|
||||
ws.Cells[r, 7].Value = inv.SubTotal; ws.Cells[r, 8].Value = inv.TaxAmount;
|
||||
ws.Cells[r, 9].Value = inv.Total; ws.Cells[r, 10].Value = inv.AmountPaid;
|
||||
ws.Cells[r, 11].Value = inv.BalanceDue;
|
||||
ws.Cells[r, 7].Value = inv.ProjectName;
|
||||
ws.Cells[r, 8].Value = inv.SubTotal; ws.Cells[r, 9].Value = inv.TaxAmount;
|
||||
ws.Cells[r, 10].Value = inv.Total; ws.Cells[r, 11].Value = inv.AmountPaid;
|
||||
ws.Cells[r, 12].Value = inv.BalanceDue;
|
||||
}
|
||||
AutoFit(ws, headers.Length);
|
||||
}
|
||||
@@ -487,15 +521,30 @@ public class AccountDataExportController : Controller
|
||||
{
|
||||
var data = await FetchCustomersAsync(companyId);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("CompanyName,ContactFirstName,ContactLastName,Email,Phone,MobilePhone,Address,City,State,ZipCode,Country,CustomerType,PricingTierCode,CreditLimit,PaymentTerms,TaxExempt,TaxId,IsActive,Notes");
|
||||
sb.AppendLine("CompanyName,ContactFirstName,ContactLastName,Email,Phone,MobilePhone,Address,City,State,ZipCode,Country,CustomerType,PricingTierCode,CreditLimit,PaymentTerms,TaxExempt,TaxId,IsActive,Notes,LeadSource,ShipToAddress,ShipToCity,ShipToState,ShipToZipCode,ShipToCountry");
|
||||
foreach (var c in data)
|
||||
{
|
||||
var customerType = c.IsCommercial ? "Commercial" : "Non-Commercial";
|
||||
sb.AppendLine($"{CsvEscape(c.CompanyName)},{CsvEscape(c.ContactFirstName)},{CsvEscape(c.ContactLastName)},{CsvEscape(c.Email)},{CsvEscape(c.Phone)},{CsvEscape(c.MobilePhone)},{CsvEscape(c.Address)},{CsvEscape(c.City)},{CsvEscape(c.State)},{CsvEscape(c.ZipCode)},{CsvEscape(c.Country)},{customerType},{CsvEscape(c.PricingTier?.TierName)},{c.CreditLimit},{CsvEscape(c.PaymentTerms)},{c.IsTaxExempt.ToString().ToLower()},{CsvEscape(c.TaxId)},{c.IsActive.ToString().ToLower()},{CsvEscape(c.GeneralNotes)}");
|
||||
sb.AppendLine($"{CsvEscape(c.CompanyName)},{CsvEscape(c.ContactFirstName)},{CsvEscape(c.ContactLastName)},{CsvEscape(c.Email)},{CsvEscape(c.Phone)},{CsvEscape(c.MobilePhone)},{CsvEscape(c.Address)},{CsvEscape(c.City)},{CsvEscape(c.State)},{CsvEscape(c.ZipCode)},{CsvEscape(c.Country)},{customerType},{CsvEscape(c.PricingTier?.TierName)},{c.CreditLimit},{CsvEscape(c.PaymentTerms)},{c.IsTaxExempt.ToString().ToLower()},{CsvEscape(c.TaxId)},{c.IsActive.ToString().ToLower()},{CsvEscape(c.GeneralNotes)},{CsvEscape(c.LeadSource)},{CsvEscape(c.ShipToAddress)},{CsvEscape(c.ShipToCity)},{CsvEscape(c.ShipToState)},{CsvEscape(c.ShipToZipCode)},{CsvEscape(c.ShipToCountry)}");
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Builds the customer contacts CSV. CustomerEmail is the join key for re-import.</summary>
|
||||
private async Task<string> BuildCustomerContactsCsv(int companyId)
|
||||
{
|
||||
var data = await _db.CustomerContacts.AsNoTracking()
|
||||
.Include(cc => cc.Customer)
|
||||
.Where(cc => cc.CompanyId == companyId && !cc.IsDeleted)
|
||||
.OrderBy(cc => cc.Customer!.CompanyName).ThenBy(cc => cc.LastName).ThenBy(cc => cc.FirstName)
|
||||
.ToListAsync();
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("CustomerEmail,FirstName,LastName,Title,ContactRole,Email,Phone,MobilePhone,Notes");
|
||||
foreach (var cc in data)
|
||||
sb.AppendLine($"{CsvEscape(cc.Customer?.Email)},{CsvEscape(cc.FirstName)},{CsvEscape(cc.LastName)},{CsvEscape(cc.Title)},{CsvEscape(cc.ContactRole)},{CsvEscape(cc.Email)},{CsvEscape(cc.Phone)},{CsvEscape(cc.MobilePhone)},{CsvEscape(cc.Notes)}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Column names match <c>JobImportDto</c> exactly so the file can be re-imported.
|
||||
/// CustomerEmail is used (not display name) because the importer resolves the customer FK by email.
|
||||
@@ -504,13 +553,13 @@ public class AccountDataExportController : Controller
|
||||
{
|
||||
var data = await FetchJobsAsync(companyId);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("JobNumber,CustomerEmail,CustomerName,Status,Priority,ScheduledDate,DueDate,FinalPrice,CustomerPO,SpecialInstructions,Notes");
|
||||
sb.AppendLine("JobNumber,CustomerEmail,CustomerName,Status,Priority,ScheduledDate,DueDate,ProjectName,FinalPrice,CustomerPO,SpecialInstructions,Notes");
|
||||
foreach (var j in data)
|
||||
{
|
||||
var customerName = !string.IsNullOrWhiteSpace(j.Customer?.CompanyName)
|
||||
? j.Customer.CompanyName
|
||||
: $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim();
|
||||
sb.AppendLine($"{CsvEscape(j.JobNumber)},{CsvEscape(j.Customer?.Email)},{CsvEscape(customerName)},{CsvEscape(j.JobStatus?.DisplayName)},{CsvEscape(j.JobPriority?.DisplayName)},{j.ScheduledDate?.ToString("yyyy-MM-dd")},{j.DueDate?.ToString("yyyy-MM-dd")},{j.FinalPrice},{CsvEscape(j.CustomerPO)},{CsvEscape(j.SpecialInstructions)},{CsvEscape(j.InternalNotes)}");
|
||||
sb.AppendLine($"{CsvEscape(j.JobNumber)},{CsvEscape(j.Customer?.Email)},{CsvEscape(customerName)},{CsvEscape(j.JobStatus?.DisplayName)},{CsvEscape(j.JobPriority?.DisplayName)},{j.ScheduledDate?.ToString("yyyy-MM-dd")},{j.DueDate?.ToString("yyyy-MM-dd")},{CsvEscape(j.ProjectName)},{j.FinalPrice},{CsvEscape(j.CustomerPO)},{CsvEscape(j.SpecialInstructions)},{CsvEscape(j.InternalNotes)}");
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
@@ -520,13 +569,13 @@ public class AccountDataExportController : Controller
|
||||
{
|
||||
var data = await FetchQuotesAsync(companyId);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("QuoteNumber,CustomerEmail,CustomerName,ProspectCompany,ProspectContact,ProspectEmail,ProspectPhone,Status,QuoteDate,ExpirationDate,Subtotal,TaxAmount,Total,Notes,TermsAndConditions");
|
||||
sb.AppendLine("QuoteNumber,CustomerEmail,CustomerName,ProspectCompany,ProspectContact,ProspectEmail,ProspectPhone,Status,QuoteDate,ExpirationDate,ProjectName,Subtotal,TaxAmount,Total,Notes,TermsAndConditions");
|
||||
foreach (var q in data)
|
||||
{
|
||||
var customerName = !string.IsNullOrWhiteSpace(q.Customer?.CompanyName)
|
||||
? q.Customer.CompanyName
|
||||
: $"{q.Customer?.ContactFirstName} {q.Customer?.ContactLastName}".Trim();
|
||||
sb.AppendLine($"{CsvEscape(q.QuoteNumber)},{CsvEscape(q.Customer?.Email)},{CsvEscape(customerName)},{CsvEscape(q.ProspectCompanyName)},{CsvEscape(q.ProspectContactName)},{CsvEscape(q.ProspectEmail)},{CsvEscape(q.ProspectPhone)},{CsvEscape(q.QuoteStatus?.DisplayName)},{q.QuoteDate:yyyy-MM-dd},{q.ExpirationDate?.ToString("yyyy-MM-dd")},{q.SubTotal},{q.TaxAmount},{q.Total},{CsvEscape(q.Notes)},{CsvEscape(q.Terms)}");
|
||||
sb.AppendLine($"{CsvEscape(q.QuoteNumber)},{CsvEscape(q.Customer?.Email)},{CsvEscape(customerName)},{CsvEscape(q.ProspectCompanyName)},{CsvEscape(q.ProspectContactName)},{CsvEscape(q.ProspectEmail)},{CsvEscape(q.ProspectPhone)},{CsvEscape(q.QuoteStatus?.DisplayName)},{q.QuoteDate:yyyy-MM-dd},{q.ExpirationDate?.ToString("yyyy-MM-dd")},{CsvEscape(q.ProjectName)},{q.SubTotal},{q.TaxAmount},{q.Total},{CsvEscape(q.Notes)},{CsvEscape(q.Terms)}");
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
@@ -539,13 +588,13 @@ public class AccountDataExportController : Controller
|
||||
{
|
||||
var data = await FetchInvoicesAsync(companyId);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("ID,Invoice #,Customer,Status,Invoice Date,Due Date,Subtotal,Tax,Total,Amount Paid,Balance Due");
|
||||
sb.AppendLine("ID,Invoice #,Customer,Status,Invoice Date,Due Date,Project Name,Subtotal,Tax,Total,Amount Paid,Balance Due");
|
||||
foreach (var inv in data)
|
||||
{
|
||||
var cust = inv.Customer != null
|
||||
? (inv.Customer.CompanyName ?? $"{inv.Customer.ContactFirstName} {inv.Customer.ContactLastName}".Trim())
|
||||
: $"Customer #{inv.CustomerId}";
|
||||
sb.AppendLine($"{inv.Id},{CsvEscape(inv.InvoiceNumber)},{CsvEscape(cust)},{inv.Status},{inv.InvoiceDate:yyyy-MM-dd},{inv.DueDate?.ToString("yyyy-MM-dd")},{inv.SubTotal},{inv.TaxAmount},{inv.Total},{inv.AmountPaid},{inv.BalanceDue}");
|
||||
sb.AppendLine($"{inv.Id},{CsvEscape(inv.InvoiceNumber)},{CsvEscape(cust)},{inv.Status},{inv.InvoiceDate:yyyy-MM-dd},{inv.DueDate?.ToString("yyyy-MM-dd")},{CsvEscape(inv.ProjectName)},{inv.SubTotal},{inv.TaxAmount},{inv.Total},{inv.AmountPaid},{inv.BalanceDue}");
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
@@ -230,11 +230,19 @@ public class CompanySettingsController : Controller
|
||||
return Json(new { success = false, message = "Company not found." });
|
||||
}
|
||||
|
||||
// Update company properties
|
||||
_mapper.Map(dto, company);
|
||||
company.UpdatedAt = DateTime.UtcNow;
|
||||
// Explicit assignment avoids AutoMapper quirks with tracked EF entities
|
||||
company.CompanyName = dto.CompanyName.Trim();
|
||||
company.CompanyCode = string.IsNullOrWhiteSpace(dto.CompanyCode) ? null : dto.CompanyCode.Trim();
|
||||
company.PrimaryContactName = dto.PrimaryContactName.Trim();
|
||||
company.PrimaryContactEmail = dto.PrimaryContactEmail.Trim();
|
||||
company.Phone = string.IsNullOrWhiteSpace(dto.Phone) ? null : dto.Phone.Trim();
|
||||
company.Address = string.IsNullOrWhiteSpace(dto.Address) ? null : dto.Address.Trim();
|
||||
company.City = string.IsNullOrWhiteSpace(dto.City) ? null : dto.City.Trim();
|
||||
company.State = string.IsNullOrWhiteSpace(dto.State) ? null : dto.State.Trim();
|
||||
company.ZipCode = string.IsNullOrWhiteSpace(dto.ZipCode) ? null : dto.ZipCode.Trim();
|
||||
company.TimeZone = string.IsNullOrWhiteSpace(dto.TimeZone) ? null : dto.TimeZone.Trim();
|
||||
company.AccountingMethod = dto.AccountingMethod;
|
||||
|
||||
await _unitOfWork.Companies.UpdateAsync(company);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("Company {CompanyId} settings updated by user", companyId);
|
||||
|
||||
@@ -91,7 +91,9 @@ public class CustomersController : Controller
|
||||
// Build orderBy function
|
||||
Func<IQueryable<Customer>, IOrderedQueryable<Customer>> orderBy = gridRequest.SortColumn switch
|
||||
{
|
||||
"CompanyName" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.CompanyName) : q.OrderByDescending(c => c.CompanyName),
|
||||
"CompanyName" => q => gridRequest.SortDirection == "asc"
|
||||
? q.OrderBy(c => c.CompanyName ?? c.ContactLastName)
|
||||
: q.OrderByDescending(c => c.CompanyName ?? c.ContactLastName),
|
||||
"ContactName" => q => gridRequest.SortDirection == "asc"
|
||||
? q.OrderBy(c => c.ContactFirstName).ThenBy(c => c.ContactLastName)
|
||||
: q.OrderByDescending(c => c.ContactFirstName).ThenByDescending(c => c.ContactLastName),
|
||||
@@ -100,7 +102,7 @@ public class CustomersController : Controller
|
||||
"CurrentBalance" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.CurrentBalance) : q.OrderByDescending(c => c.CurrentBalance),
|
||||
"IsActive" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.IsActive) : q.OrderByDescending(c => c.IsActive),
|
||||
"LastContactDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.LastContactDate) : q.OrderByDescending(c => c.LastContactDate),
|
||||
_ => q => q.OrderBy(c => c.CompanyName)
|
||||
_ => q => q.OrderBy(c => c.CompanyName ?? c.ContactLastName)
|
||||
};
|
||||
|
||||
// Get paged data
|
||||
@@ -144,9 +146,11 @@ public class CustomersController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders the customer detail page, including the 10 most-recent non-voided credit memos.
|
||||
/// Credit memos are loaded separately (not via eager loading) because the customer entity
|
||||
/// does not navigate to CreditMemo; this keeps the Customer aggregate lean.
|
||||
/// Renders the customer detail page. In addition to basic info and credit memos, runs
|
||||
/// four sequential queries (jobs, quotes, invoices, deposits) to build:
|
||||
/// (1) <see cref="CustomerLifetimeStatsDto"/> — aggregate KPIs for the stats card
|
||||
/// (2) <see cref="CustomerTimelineEventDto"/> list — last 15 events for the activity feed
|
||||
/// Credit memos are loaded separately because the Customer aggregate does not navigate to them.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Details(int? id)
|
||||
{
|
||||
@@ -170,6 +174,120 @@ public class CustomersController : Controller
|
||||
.Take(10)
|
||||
.ToList();
|
||||
|
||||
// CRM queries — must be sequential; EF Core's DbContext is not thread-safe
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CustomerId == id.Value && j.CompanyId == companyId, false, j => j.JobStatus)).ToList();
|
||||
var quotes = (await _unitOfWork.Quotes.FindAsync(q => q.CustomerId == id.Value && q.CompanyId == companyId, false, q => q.QuoteStatus)).ToList();
|
||||
var invoices = (await _unitOfWork.Invoices.FindAsync(i => i.CustomerId == id.Value && i.CompanyId == companyId)).ToList();
|
||||
var deposits = (await _unitOfWork.Deposits.FindAsync(d => d.CustomerId == id.Value && d.CompanyId == companyId)).ToList();
|
||||
|
||||
var pendingPickups = (await _unitOfWork.Jobs.FindAsync(
|
||||
j => j.CustomerId == id.Value && j.CompanyId == companyId
|
||||
&& j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup,
|
||||
false, j => j.JobStatus))
|
||||
.OrderBy(j => j.UpdatedAt)
|
||||
.ToList();
|
||||
ViewBag.PendingPickups = pendingPickups;
|
||||
|
||||
var customerNotes = (await _unitOfWork.CustomerNotes.FindAsync(n => n.CustomerId == id.Value))
|
||||
.OrderByDescending(n => n.CreatedAt)
|
||||
.ToList();
|
||||
ViewBag.CustomerNotes = customerNotes;
|
||||
|
||||
var preferredPowders = (await _unitOfWork.CustomerPreferredPowders.FindAsync(
|
||||
p => p.CustomerId == id.Value, false, p => p.InventoryItem))
|
||||
.ToList();
|
||||
ViewBag.PreferredPowders = preferredPowders;
|
||||
|
||||
var customerContacts = (await _unitOfWork.CustomerContacts.FindAsync(n => n.CustomerId == id.Value))
|
||||
.OrderBy(c => c.FirstName)
|
||||
.ToList();
|
||||
ViewBag.CustomerContacts = customerContacts;
|
||||
|
||||
// Stats
|
||||
var nonVoided = invoices.Where(i => i.Status != InvoiceStatus.Voided).ToList();
|
||||
var stats = new CustomerLifetimeStatsDto
|
||||
{
|
||||
TotalJobs = jobs.Count,
|
||||
ActiveJobs = jobs.Count(j => j.JobStatus != null && !j.JobStatus.IsTerminalStatus),
|
||||
TotalRevenue = nonVoided.Sum(i => i.Total),
|
||||
TotalCollected = nonVoided.Sum(i => i.AmountPaid),
|
||||
AverageJobValue = jobs.Count > 0 ? jobs.Average(j => j.FinalPrice) : 0,
|
||||
LastJobDate = jobs.Count > 0 ? jobs.Max(j => (DateTime?)j.CreatedAt) : null,
|
||||
LastJobId = jobs.Count > 0 ? jobs.OrderByDescending(j => j.CreatedAt).First().Id : (int?)null,
|
||||
TotalQuotes = quotes.Count,
|
||||
TotalInvoices = invoices.Count,
|
||||
OpenBalance = customer.CurrentBalance
|
||||
};
|
||||
stats.DaysSinceLastJob = stats.LastJobDate.HasValue
|
||||
? (int)(DateTime.UtcNow - stats.LastJobDate.Value).TotalDays
|
||||
: null;
|
||||
|
||||
// Timeline: merge all event types, sort descending, cap at 15
|
||||
var events = new List<CustomerTimelineEventDto>();
|
||||
|
||||
foreach (var j in jobs)
|
||||
events.Add(new CustomerTimelineEventDto
|
||||
{
|
||||
Date = j.CreatedAt,
|
||||
Icon = "bi-briefcase",
|
||||
BadgeColor = "primary",
|
||||
Title = $"Job {j.JobNumber}",
|
||||
Subtitle = j.Description,
|
||||
Amount = j.FinalPrice > 0 ? j.FinalPrice : null,
|
||||
EntityId = j.Id,
|
||||
LinkController = "Jobs",
|
||||
LinkAction = "Details"
|
||||
});
|
||||
|
||||
foreach (var q in quotes)
|
||||
events.Add(new CustomerTimelineEventDto
|
||||
{
|
||||
Date = q.QuoteDate,
|
||||
Icon = "bi-file-text",
|
||||
BadgeColor = "info",
|
||||
Title = $"Quote {q.QuoteNumber}",
|
||||
Subtitle = q.QuoteStatus?.DisplayName,
|
||||
Amount = q.Total > 0 ? q.Total : null,
|
||||
EntityId = q.Id,
|
||||
LinkController = "Quotes",
|
||||
LinkAction = "Details"
|
||||
});
|
||||
|
||||
foreach (var inv in invoices)
|
||||
events.Add(new CustomerTimelineEventDto
|
||||
{
|
||||
Date = inv.InvoiceDate,
|
||||
Icon = inv.Status == InvoiceStatus.Paid ? "bi-receipt-cutoff" : "bi-receipt",
|
||||
BadgeColor = inv.Status == InvoiceStatus.Paid ? "success" : "warning",
|
||||
Title = $"Invoice {inv.InvoiceNumber}",
|
||||
Subtitle = inv.Status.ToString(),
|
||||
Amount = inv.Total,
|
||||
EntityId = inv.Id,
|
||||
LinkController = "Invoices",
|
||||
LinkAction = "Details"
|
||||
});
|
||||
|
||||
foreach (var dep in deposits)
|
||||
events.Add(new CustomerTimelineEventDto
|
||||
{
|
||||
Date = dep.ReceivedDate,
|
||||
Icon = "bi-cash-coin",
|
||||
BadgeColor = "success",
|
||||
Title = "Deposit received",
|
||||
Subtitle = dep.ReceiptNumber,
|
||||
Amount = dep.Amount,
|
||||
EntityId = dep.JobId,
|
||||
LinkController = dep.JobId.HasValue ? "Jobs" : null,
|
||||
LinkAction = dep.JobId.HasValue ? "Details" : null
|
||||
});
|
||||
|
||||
ViewBag.CrmStats = stats;
|
||||
ViewBag.Timeline = events
|
||||
.OrderByDescending(e => e.Date)
|
||||
.Take(15)
|
||||
.ToList();
|
||||
|
||||
var customerDto = _mapper.Map<CustomerDto>(customer);
|
||||
return View(customerDto);
|
||||
}
|
||||
@@ -938,6 +1056,308 @@ public class CustomersController : Controller
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a quick internal note to the customer record. Returns the rendered note HTML so
|
||||
/// the caller can prepend it to the notes list without a full page reload.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> AddCustomerNote(int id, string note, bool isImportant = false)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(note))
|
||||
return Json(new { success = false, message = "Note cannot be empty." });
|
||||
|
||||
try
|
||||
{
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
|
||||
if (customer == null) return Json(new { success = false, message = "Customer not found." });
|
||||
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
var entity = new PowderCoating.Core.Entities.CustomerNote
|
||||
{
|
||||
CustomerId = id,
|
||||
Note = note.Trim(),
|
||||
IsImportant = isImportant,
|
||||
CreatedBy = currentUser?.Email
|
||||
};
|
||||
|
||||
await _unitOfWork.CustomerNotes.AddAsync(entity);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
var displayDate = entity.CreatedAt.ToLocalTime().ToString("MMM dd, yyyy h:mm tt");
|
||||
var author = currentUser?.Email ?? "Staff";
|
||||
var noteHtml = $@"<div class=""customer-note-item d-flex gap-2 py-2 border-bottom"" data-note-id=""{entity.Id}"">
|
||||
<div class=""flex-grow-1"">
|
||||
{(isImportant ? @"<span class=""text-warning me-1"" title=""Important"">★</span>" : "")}
|
||||
<span class=""note-text"">{System.Web.HttpUtility.HtmlEncode(entity.Note)}</span>
|
||||
<div class=""text-muted"" style=""font-size:0.75rem;"">{System.Web.HttpUtility.HtmlEncode(author)} — {displayDate}</div>
|
||||
</div>
|
||||
<button type=""button"" class=""btn btn-sm btn-link text-danger p-0 flex-shrink-0""
|
||||
onclick=""deleteCustomerNote({id}, {entity.Id})"" title=""Delete note"">
|
||||
<i class=""bi bi-trash""></i>
|
||||
</button>
|
||||
</div>";
|
||||
|
||||
return Json(new { success = true, noteHtml });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error adding note to customer {CustomerId}", id);
|
||||
return Json(new { success = false, message = "An error occurred." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Soft-deletes a single customer note. Only the owning company can delete their own notes
|
||||
/// (enforced via CompanyId on the entity + global query filter).
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> DeleteCustomerNote(int id, int noteId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var note = await _unitOfWork.CustomerNotes.GetByIdAsync(noteId);
|
||||
if (note == null || note.CustomerId != id)
|
||||
return Json(new { success = false, message = "Note not found." });
|
||||
|
||||
await _unitOfWork.CustomerNotes.SoftDeleteAsync(note);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return Json(new { success = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting note {NoteId} for customer {CustomerId}", noteId, id);
|
||||
return Json(new { success = false, message = "An error occurred." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns up to 10 inventory items matching the search term for the preferred-powder typeahead.
|
||||
/// Results are scoped to the current company and only include active items.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> SearchInventoryItems(string term)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(term) || term.Length < 2)
|
||||
return Json(Array.Empty<object>());
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var lower = term.ToLower();
|
||||
var items = (await _unitOfWork.InventoryItems.FindAsync(
|
||||
i => i.CompanyId == companyId && i.IsActive
|
||||
&& (i.Name.ToLower().Contains(lower) || (i.SKU != null && i.SKU.ToLower().Contains(lower)))))
|
||||
.OrderBy(i => i.Name)
|
||||
.Take(10)
|
||||
.Select(i => new { i.Id, i.Name, i.ColorName, sku = i.SKU })
|
||||
.ToList();
|
||||
|
||||
return Json(items);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Associates an inventory item as a preferred powder for a customer.
|
||||
/// Silently succeeds if the association already exists (idempotent).
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> AddPreferredPowder(int id, int inventoryItemId, string? notes = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
|
||||
if (customer == null) return Json(new { success = false, message = "Customer not found." });
|
||||
|
||||
var item = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId);
|
||||
if (item == null) return Json(new { success = false, message = "Inventory item not found." });
|
||||
|
||||
var existing = (await _unitOfWork.CustomerPreferredPowders.FindAsync(
|
||||
p => p.CustomerId == id && p.InventoryItemId == inventoryItemId)).FirstOrDefault();
|
||||
if (existing != null)
|
||||
return Json(new { success = false, message = $"{item.Name} is already in preferred powders." });
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
await _unitOfWork.CustomerPreferredPowders.AddAsync(new PowderCoating.Core.Entities.CustomerPreferredPowder
|
||||
{
|
||||
CustomerId = id,
|
||||
InventoryItemId = inventoryItemId,
|
||||
Notes = notes?.Trim(),
|
||||
CompanyId = companyId
|
||||
});
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return Json(new { success = true, itemId = inventoryItemId, itemName = item.Name, notes = notes?.Trim() });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error adding preferred powder for customer {CustomerId}", id);
|
||||
return Json(new { success = false, message = "An error occurred." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a preferred-powder association by inventory item ID. Soft-deletes the record
|
||||
/// so the history is preserved but it no longer appears on the customer page.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> RemovePreferredPowder(int id, int itemId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var record = (await _unitOfWork.CustomerPreferredPowders.FindAsync(
|
||||
p => p.CustomerId == id && p.InventoryItemId == itemId)).FirstOrDefault();
|
||||
if (record == null) return Json(new { success = false, message = "Record not found." });
|
||||
|
||||
await _unitOfWork.CustomerPreferredPowders.SoftDeleteAsync(record);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return Json(new { success = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error removing preferred powder {ItemId} for customer {CustomerId}", itemId, id);
|
||||
return Json(new { success = false, message = "An error occurred." });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Customer Contacts ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns the JSON representation of a single contact for pre-populating the edit modal.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetContact(int id, int contactId)
|
||||
{
|
||||
var contact = await _unitOfWork.CustomerContacts.GetByIdAsync(contactId);
|
||||
if (contact == null || contact.CustomerId != id)
|
||||
return Json(new { success = false });
|
||||
|
||||
var dto = _mapper.Map<PowderCoating.Application.DTOs.Customer.UpdateCustomerContactDto>(contact);
|
||||
return Json(new { success = true, contact = dto });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new contact to the customer record. Returns rendered row HTML so the
|
||||
/// caller can append it to the contacts table without a full reload.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> AddContact(int id, PowderCoating.Application.DTOs.Customer.CreateCustomerContactDto dto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return Json(new { success = false, message = ModelState.Values.SelectMany(v => v.Errors).FirstOrDefault()?.ErrorMessage ?? "Invalid data." });
|
||||
|
||||
try
|
||||
{
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
|
||||
if (customer == null) return Json(new { success = false, message = "Customer not found." });
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var entity = _mapper.Map<PowderCoating.Core.Entities.CustomerContact>(dto);
|
||||
entity.CustomerId = id;
|
||||
entity.CompanyId = companyId;
|
||||
|
||||
await _unitOfWork.CustomerContacts.AddAsync(entity);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
var rowHtml = BuildContactRowHtml(id, entity);
|
||||
return Json(new { success = true, contactId = entity.Id, rowHtml });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error adding contact to customer {CustomerId}", id);
|
||||
return Json(new { success = false, message = "An error occurred." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates an existing contact record in place. Returns the updated row HTML.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> UpdateContact(int id, PowderCoating.Application.DTOs.Customer.UpdateCustomerContactDto dto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return Json(new { success = false, message = ModelState.Values.SelectMany(v => v.Errors).FirstOrDefault()?.ErrorMessage ?? "Invalid data." });
|
||||
|
||||
try
|
||||
{
|
||||
var contact = await _unitOfWork.CustomerContacts.GetByIdAsync(dto.Id);
|
||||
if (contact == null || contact.CustomerId != id)
|
||||
return Json(new { success = false, message = "Contact not found." });
|
||||
|
||||
_mapper.Map(dto, contact);
|
||||
contact.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _unitOfWork.CustomerContacts.UpdateAsync(contact);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
var rowHtml = BuildContactRowHtml(id, contact);
|
||||
return Json(new { success = true, contactId = contact.Id, rowHtml });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error updating contact {ContactId} for customer {CustomerId}", dto.Id, id);
|
||||
return Json(new { success = false, message = "An error occurred." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Soft-deletes a contact. Only the owning company can delete their contacts
|
||||
/// (enforced via CompanyId + global query filter).
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> DeleteContact(int id, int contactId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var contact = await _unitOfWork.CustomerContacts.GetByIdAsync(contactId);
|
||||
if (contact == null || contact.CustomerId != id)
|
||||
return Json(new { success = false, message = "Contact not found." });
|
||||
|
||||
await _unitOfWork.CustomerContacts.SoftDeleteAsync(contact);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return Json(new { success = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error deleting contact {ContactId} for customer {CustomerId}", contactId, id);
|
||||
return Json(new { success = false, message = "An error occurred." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the table-row HTML for a contact. Kept server-side so the same markup is
|
||||
/// used for both the initial page render and the AJAX insert/replace path.
|
||||
/// </summary>
|
||||
private static string BuildContactRowHtml(int customerId, PowderCoating.Core.Entities.CustomerContact c)
|
||||
{
|
||||
var displayName = string.IsNullOrWhiteSpace(c.LastName) ? c.FirstName : $"{c.FirstName} {c.LastName}";
|
||||
var titlePart = !string.IsNullOrWhiteSpace(c.Title) ? System.Web.HttpUtility.HtmlEncode(c.Title) : "";
|
||||
var roleBadge = !string.IsNullOrWhiteSpace(c.ContactRole)
|
||||
? $"<span class=\"badge bg-secondary bg-opacity-10 text-secondary ms-1\">{System.Web.HttpUtility.HtmlEncode(c.ContactRole)}</span>"
|
||||
: "";
|
||||
var email = !string.IsNullOrWhiteSpace(c.Email)
|
||||
? $"<a href=\"mailto:{System.Web.HttpUtility.HtmlEncode(c.Email)}\" class=\"text-decoration-none small\">{System.Web.HttpUtility.HtmlEncode(c.Email)}</a>"
|
||||
: "<span class=\"text-muted small\">—</span>";
|
||||
var phone = !string.IsNullOrWhiteSpace(c.Phone ?? c.MobilePhone)
|
||||
? $"<span class=\"small\">{System.Web.HttpUtility.HtmlEncode(c.Phone ?? c.MobilePhone)}</span>"
|
||||
: "<span class=\"text-muted small\">—</span>";
|
||||
|
||||
return $@"<tr data-contact-id=""{c.Id}"">
|
||||
<td>
|
||||
<div class=""fw-semibold"">{System.Web.HttpUtility.HtmlEncode(displayName)}{roleBadge}</div>
|
||||
{(string.IsNullOrWhiteSpace(titlePart) ? "" : $"<div class=\"text-muted\" style=\"font-size:0.75rem;\">{titlePart}</div>")}
|
||||
</td>
|
||||
<td>{email}</td>
|
||||
<td>{phone}</td>
|
||||
<td class=""text-end"">
|
||||
<button type=""button"" class=""btn btn-sm btn-outline-secondary me-1""
|
||||
onclick=""editContact({customerId}, {c.Id})"" title=""Edit"">
|
||||
<i class=""bi bi-pencil""></i>
|
||||
</button>
|
||||
<button type=""button"" class=""btn btn-sm btn-outline-danger""
|
||||
onclick=""deleteContact({customerId}, {c.Id})"" title=""Delete"">
|
||||
<i class=""bi bi-trash""></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Displays or downloads a dated activity statement for a customer.
|
||||
/// Pass <c>pdf=true</c> to download the QuestPDF version; otherwise renders the HTML view.
|
||||
|
||||
@@ -116,6 +116,7 @@ public class DataExportController : Controller
|
||||
switch (sheet)
|
||||
{
|
||||
case "Customers": await AddCustomersSheet(package, companyId, headerColor); break;
|
||||
case "CustomerContacts": await AddCustomerContactsSheet(package, companyId, headerColor); break;
|
||||
case "Jobs": await AddJobsSheet(package, companyId, headerColor); break;
|
||||
case "Quotes": await AddQuotesSheet(package, companyId, headerColor); break;
|
||||
case "Invoices": await AddInvoicesSheet(package, companyId, headerColor); break;
|
||||
@@ -165,6 +166,7 @@ public class DataExportController : Controller
|
||||
switch (sheet)
|
||||
{
|
||||
case "Customers": WriteCsvEntry(zip, "Customers.csv", await BuildCustomersCsv(companyId)); break;
|
||||
case "CustomerContacts": WriteCsvEntry(zip, "CustomerContacts.csv", await BuildCustomerContactsCsv(companyId)); break;
|
||||
case "Jobs": WriteCsvEntry(zip, "Jobs.csv", await BuildJobsCsv(companyId)); break;
|
||||
case "Quotes": WriteCsvEntry(zip, "Quotes.csv", await BuildQuotesCsv(companyId)); break;
|
||||
case "Invoices": WriteCsvEntry(zip, "Invoices.csv", await BuildInvoicesCsv(companyId)); break;
|
||||
@@ -240,7 +242,9 @@ public class DataExportController : Controller
|
||||
var ws = pkg.Workbook.Worksheets.Add("Customers");
|
||||
var headers = new[] { "ID", "Company Name", "First Name", "Last Name", "Email", "Phone",
|
||||
"Commercial", "City", "State", "Active", "Credit Limit",
|
||||
"Current Balance", "Created At" };
|
||||
"Current Balance", "Lead Source",
|
||||
"Ship-To Address", "Ship-To City", "Ship-To State", "Ship-To Zip", "Ship-To Country",
|
||||
"Created At" };
|
||||
WriteHeader(ws, headers, hdr);
|
||||
|
||||
for (int i = 0; i < data.Count; i++)
|
||||
@@ -259,7 +263,13 @@ public class DataExportController : Controller
|
||||
ws.Cells[r, 10].Value = c.IsActive ? "Yes" : "No";
|
||||
ws.Cells[r, 11].Value = c.CreditLimit;
|
||||
ws.Cells[r, 12].Value = c.CurrentBalance;
|
||||
ws.Cells[r, 13].Value = c.CreatedAt.ToString("yyyy-MM-dd");
|
||||
ws.Cells[r, 13].Value = c.LeadSource;
|
||||
ws.Cells[r, 14].Value = c.ShipToAddress;
|
||||
ws.Cells[r, 15].Value = c.ShipToCity;
|
||||
ws.Cells[r, 16].Value = c.ShipToState;
|
||||
ws.Cells[r, 17].Value = c.ShipToZipCode;
|
||||
ws.Cells[r, 18].Value = c.ShipToCountry;
|
||||
ws.Cells[r, 19].Value = c.CreatedAt.ToString("yyyy-MM-dd");
|
||||
}
|
||||
AutoFit(ws, headers.Length);
|
||||
}
|
||||
@@ -282,7 +292,7 @@ public class DataExportController : Controller
|
||||
|
||||
var ws = pkg.Workbook.Worksheets.Add("Jobs");
|
||||
var headers = new[] { "ID", "Job Number", "Customer", "Status", "Priority",
|
||||
"Description", "Due Date", "Final Price", "Created At" };
|
||||
"Description", "Project Name", "Due Date", "Final Price", "Created At" };
|
||||
WriteHeader(ws, headers, hdr);
|
||||
|
||||
for (int i = 0; i < data.Count; i++)
|
||||
@@ -295,9 +305,10 @@ public class DataExportController : Controller
|
||||
ws.Cells[r, 4].Value = j.JobStatus?.DisplayName ?? j.JobStatusId.ToString();
|
||||
ws.Cells[r, 5].Value = j.JobPriority?.DisplayName ?? j.JobPriorityId.ToString();
|
||||
ws.Cells[r, 6].Value = j.Description;
|
||||
ws.Cells[r, 7].Value = j.DueDate?.ToString("yyyy-MM-dd");
|
||||
ws.Cells[r, 8].Value = j.FinalPrice;
|
||||
ws.Cells[r, 9].Value = j.CreatedAt.ToString("yyyy-MM-dd");
|
||||
ws.Cells[r, 7].Value = j.ProjectName;
|
||||
ws.Cells[r, 8].Value = j.DueDate?.ToString("yyyy-MM-dd");
|
||||
ws.Cells[r, 9].Value = j.FinalPrice;
|
||||
ws.Cells[r, 10].Value = j.CreatedAt.ToString("yyyy-MM-dd");
|
||||
}
|
||||
AutoFit(ws, headers.Length);
|
||||
}
|
||||
@@ -318,7 +329,7 @@ public class DataExportController : Controller
|
||||
|
||||
var ws = pkg.Workbook.Worksheets.Add("Quotes");
|
||||
var headers = new[] { "ID", "Quote Number", "Customer / Prospect", "Status",
|
||||
"Quote Date", "Expiration Date", "Subtotal", "Tax", "Total" };
|
||||
"Quote Date", "Expiration Date", "Project Name", "Subtotal", "Tax", "Total" };
|
||||
WriteHeader(ws, headers, hdr);
|
||||
|
||||
for (int i = 0; i < data.Count; i++)
|
||||
@@ -331,9 +342,10 @@ public class DataExportController : Controller
|
||||
ws.Cells[r, 4].Value = q.QuoteStatus?.DisplayName ?? q.QuoteStatusId.ToString();
|
||||
ws.Cells[r, 5].Value = q.QuoteDate.ToString("yyyy-MM-dd");
|
||||
ws.Cells[r, 6].Value = q.ExpirationDate?.ToString("yyyy-MM-dd");
|
||||
ws.Cells[r, 7].Value = q.SubTotal;
|
||||
ws.Cells[r, 8].Value = q.TaxAmount;
|
||||
ws.Cells[r, 9].Value = q.Total;
|
||||
ws.Cells[r, 7].Value = q.ProjectName;
|
||||
ws.Cells[r, 8].Value = q.SubTotal;
|
||||
ws.Cells[r, 9].Value = q.TaxAmount;
|
||||
ws.Cells[r, 10].Value = q.Total;
|
||||
}
|
||||
AutoFit(ws, headers.Length);
|
||||
}
|
||||
@@ -456,7 +468,7 @@ public class DataExportController : Controller
|
||||
|
||||
var ws = pkg.Workbook.Worksheets.Add("Invoices");
|
||||
var headers = new[] { "ID", "Invoice #", "Customer", "Status", "Invoice Date",
|
||||
"Due Date", "Subtotal", "Tax", "Total", "Amount Paid", "Balance Due" };
|
||||
"Due Date", "Project Name", "Subtotal", "Tax", "Total", "Amount Paid", "Balance Due" };
|
||||
WriteHeader(ws, headers, hdr);
|
||||
|
||||
for (int i = 0; i < data.Count; i++)
|
||||
@@ -472,11 +484,12 @@ public class DataExportController : Controller
|
||||
ws.Cells[r, 4].Value = inv.Status.ToString();
|
||||
ws.Cells[r, 5].Value = inv.InvoiceDate.ToString("yyyy-MM-dd");
|
||||
ws.Cells[r, 6].Value = inv.DueDate?.ToString("yyyy-MM-dd");
|
||||
ws.Cells[r, 7].Value = inv.SubTotal;
|
||||
ws.Cells[r, 8].Value = inv.TaxAmount;
|
||||
ws.Cells[r, 9].Value = inv.Total;
|
||||
ws.Cells[r, 10].Value = inv.AmountPaid;
|
||||
ws.Cells[r, 11].Value = inv.BalanceDue;
|
||||
ws.Cells[r, 7].Value = inv.ProjectName;
|
||||
ws.Cells[r, 8].Value = inv.SubTotal;
|
||||
ws.Cells[r, 9].Value = inv.TaxAmount;
|
||||
ws.Cells[r, 10].Value = inv.Total;
|
||||
ws.Cells[r, 11].Value = inv.AmountPaid;
|
||||
ws.Cells[r, 12].Value = inv.BalanceDue;
|
||||
}
|
||||
AutoFit(ws, headers.Length);
|
||||
}
|
||||
@@ -530,11 +543,11 @@ public class DataExportController : Controller
|
||||
.Include(c => c.PricingTier)
|
||||
.Where(c => c.CompanyId == companyId && !c.IsDeleted).OrderBy(c => c.CompanyName).ToListAsync();
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("CompanyName,ContactFirstName,ContactLastName,Email,Phone,MobilePhone,Address,City,State,ZipCode,Country,CustomerType,PricingTierCode,CreditLimit,PaymentTerms,TaxExempt,TaxId,IsActive,Notes");
|
||||
sb.AppendLine("CompanyName,ContactFirstName,ContactLastName,Email,Phone,MobilePhone,Address,City,State,ZipCode,Country,CustomerType,PricingTierCode,CreditLimit,PaymentTerms,TaxExempt,TaxId,IsActive,Notes,LeadSource,ShipToAddress,ShipToCity,ShipToState,ShipToZipCode,ShipToCountry");
|
||||
foreach (var c in data)
|
||||
{
|
||||
var customerType = c.IsCommercial ? "Commercial" : "Non-Commercial";
|
||||
sb.AppendLine($"{CsvEscape(c.CompanyName)},{CsvEscape(c.ContactFirstName)},{CsvEscape(c.ContactLastName)},{CsvEscape(c.Email)},{CsvEscape(c.Phone)},{CsvEscape(c.MobilePhone)},{CsvEscape(c.Address)},{CsvEscape(c.City)},{CsvEscape(c.State)},{CsvEscape(c.ZipCode)},{CsvEscape(c.Country)},{customerType},{CsvEscape(c.PricingTier?.TierName)},{c.CreditLimit},{CsvEscape(c.PaymentTerms)},{c.IsTaxExempt.ToString().ToLower()},{CsvEscape(c.TaxId)},{c.IsActive.ToString().ToLower()},{CsvEscape(c.GeneralNotes)}");
|
||||
sb.AppendLine($"{CsvEscape(c.CompanyName)},{CsvEscape(c.ContactFirstName)},{CsvEscape(c.ContactLastName)},{CsvEscape(c.Email)},{CsvEscape(c.Phone)},{CsvEscape(c.MobilePhone)},{CsvEscape(c.Address)},{CsvEscape(c.City)},{CsvEscape(c.State)},{CsvEscape(c.ZipCode)},{CsvEscape(c.Country)},{customerType},{CsvEscape(c.PricingTier?.TierName)},{c.CreditLimit},{CsvEscape(c.PaymentTerms)},{c.IsTaxExempt.ToString().ToLower()},{CsvEscape(c.TaxId)},{c.IsActive.ToString().ToLower()},{CsvEscape(c.GeneralNotes)},{CsvEscape(c.LeadSource)},{CsvEscape(c.ShipToAddress)},{CsvEscape(c.ShipToCity)},{CsvEscape(c.ShipToState)},{CsvEscape(c.ShipToZipCode)},{CsvEscape(c.ShipToCountry)}");
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
@@ -552,13 +565,13 @@ public class DataExportController : Controller
|
||||
.Include(j => j.Customer).Include(j => j.JobStatus).Include(j => j.JobPriority)
|
||||
.OrderByDescending(j => j.CreatedAt).ToListAsync();
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("JobNumber,CustomerEmail,CustomerName,Status,Priority,ScheduledDate,DueDate,FinalPrice,CustomerPO,SpecialInstructions,Notes");
|
||||
sb.AppendLine("JobNumber,CustomerEmail,CustomerName,Status,Priority,ScheduledDate,DueDate,ProjectName,FinalPrice,CustomerPO,SpecialInstructions,Notes");
|
||||
foreach (var j in data)
|
||||
{
|
||||
var customerName = !string.IsNullOrWhiteSpace(j.Customer?.CompanyName)
|
||||
? j.Customer.CompanyName
|
||||
: $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim();
|
||||
sb.AppendLine($"{CsvEscape(j.JobNumber)},{CsvEscape(j.Customer?.Email)},{CsvEscape(customerName)},{CsvEscape(j.JobStatus?.DisplayName)},{CsvEscape(j.JobPriority?.DisplayName)},{j.ScheduledDate?.ToString("yyyy-MM-dd")},{j.DueDate?.ToString("yyyy-MM-dd")},{j.FinalPrice},{CsvEscape(j.CustomerPO)},{CsvEscape(j.SpecialInstructions)},{CsvEscape(j.InternalNotes)}");
|
||||
sb.AppendLine($"{CsvEscape(j.JobNumber)},{CsvEscape(j.Customer?.Email)},{CsvEscape(customerName)},{CsvEscape(j.JobStatus?.DisplayName)},{CsvEscape(j.JobPriority?.DisplayName)},{j.ScheduledDate?.ToString("yyyy-MM-dd")},{j.DueDate?.ToString("yyyy-MM-dd")},{CsvEscape(j.ProjectName)},{j.FinalPrice},{CsvEscape(j.CustomerPO)},{CsvEscape(j.SpecialInstructions)},{CsvEscape(j.InternalNotes)}");
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
@@ -574,13 +587,13 @@ public class DataExportController : Controller
|
||||
.Where(q => q.CompanyId == companyId && !q.IsDeleted)
|
||||
.Include(q => q.Customer).Include(q => q.QuoteStatus).OrderByDescending(q => q.QuoteDate).ToListAsync();
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("QuoteNumber,CustomerEmail,CustomerName,ProspectCompany,ProspectContact,ProspectEmail,ProspectPhone,Status,QuoteDate,ExpirationDate,Subtotal,TaxAmount,Total,Notes,TermsAndConditions");
|
||||
sb.AppendLine("QuoteNumber,CustomerEmail,CustomerName,ProspectCompany,ProspectContact,ProspectEmail,ProspectPhone,Status,QuoteDate,ExpirationDate,ProjectName,Subtotal,TaxAmount,Total,Notes,TermsAndConditions");
|
||||
foreach (var q in data)
|
||||
{
|
||||
var customerName = !string.IsNullOrWhiteSpace(q.Customer?.CompanyName)
|
||||
? q.Customer.CompanyName
|
||||
: $"{q.Customer?.ContactFirstName} {q.Customer?.ContactLastName}".Trim();
|
||||
sb.AppendLine($"{CsvEscape(q.QuoteNumber)},{CsvEscape(q.Customer?.Email)},{CsvEscape(customerName)},{CsvEscape(q.ProspectCompanyName)},{CsvEscape(q.ProspectContactName)},{CsvEscape(q.ProspectEmail)},{CsvEscape(q.ProspectPhone)},{CsvEscape(q.QuoteStatus?.DisplayName)},{q.QuoteDate:yyyy-MM-dd},{q.ExpirationDate?.ToString("yyyy-MM-dd")},{q.SubTotal},{q.TaxAmount},{q.Total},{CsvEscape(q.Notes)},{CsvEscape(q.Terms)}");
|
||||
sb.AppendLine($"{CsvEscape(q.QuoteNumber)},{CsvEscape(q.Customer?.Email)},{CsvEscape(customerName)},{CsvEscape(q.ProspectCompanyName)},{CsvEscape(q.ProspectContactName)},{CsvEscape(q.ProspectEmail)},{CsvEscape(q.ProspectPhone)},{CsvEscape(q.QuoteStatus?.DisplayName)},{q.QuoteDate:yyyy-MM-dd},{q.ExpirationDate?.ToString("yyyy-MM-dd")},{CsvEscape(q.ProjectName)},{q.SubTotal},{q.TaxAmount},{q.Total},{CsvEscape(q.Notes)},{CsvEscape(q.Terms)}");
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
@@ -596,17 +609,68 @@ public class DataExportController : Controller
|
||||
.Where(i => i.CompanyId == companyId && !i.IsDeleted)
|
||||
.Include(i => i.Customer).OrderByDescending(i => i.InvoiceDate).ToListAsync();
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("ID,Invoice #,Customer,Status,Invoice Date,Due Date,Subtotal,Tax,Total,Amount Paid,Balance Due");
|
||||
sb.AppendLine("ID,Invoice #,Customer,Status,Invoice Date,Due Date,Project Name,Subtotal,Tax,Total,Amount Paid,Balance Due");
|
||||
foreach (var inv in data)
|
||||
{
|
||||
var cust = inv.Customer != null
|
||||
? (inv.Customer.CompanyName ?? $"{inv.Customer.ContactFirstName} {inv.Customer.ContactLastName}".Trim())
|
||||
: $"Customer #{inv.CustomerId}";
|
||||
sb.AppendLine($"{inv.Id},{CsvEscape(inv.InvoiceNumber)},{CsvEscape(cust)},{inv.Status},{inv.InvoiceDate:yyyy-MM-dd},{inv.DueDate?.ToString("yyyy-MM-dd")},{inv.SubTotal},{inv.TaxAmount},{inv.Total},{inv.AmountPaid},{inv.BalanceDue}");
|
||||
sb.AppendLine($"{inv.Id},{CsvEscape(inv.InvoiceNumber)},{CsvEscape(cust)},{inv.Status},{inv.InvoiceDate:yyyy-MM-dd},{inv.DueDate?.ToString("yyyy-MM-dd")},{CsvEscape(inv.ProjectName)},{inv.SubTotal},{inv.TaxAmount},{inv.Total},{inv.AmountPaid},{inv.BalanceDue}");
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a CustomerContacts worksheet: one row per additional contact linked to the company's customers.
|
||||
/// CustomerEmail is the join key used by the importer to re-link contacts to their parent customer.
|
||||
/// </summary>
|
||||
private async Task AddCustomerContactsSheet(ExcelPackage pkg, int companyId, Color hdr)
|
||||
{
|
||||
var data = await _db.CustomerContacts.AsNoTracking().IgnoreQueryFilters()
|
||||
.Include(cc => cc.Customer)
|
||||
.Where(cc => cc.CompanyId == companyId && !cc.IsDeleted)
|
||||
.OrderBy(cc => cc.Customer!.CompanyName).ThenBy(cc => cc.LastName).ThenBy(cc => cc.FirstName)
|
||||
.ToListAsync();
|
||||
|
||||
var ws = pkg.Workbook.Worksheets.Add("CustomerContacts");
|
||||
var headers = new[] { "CustomerEmail", "FirstName", "LastName", "Title", "ContactRole", "Email", "Phone", "MobilePhone", "Notes" };
|
||||
WriteHeader(ws, headers, hdr);
|
||||
|
||||
for (int i = 0; i < data.Count; i++)
|
||||
{
|
||||
var r = i + 2;
|
||||
var cc = data[i];
|
||||
ws.Cells[r, 1].Value = cc.Customer?.Email;
|
||||
ws.Cells[r, 2].Value = cc.FirstName;
|
||||
ws.Cells[r, 3].Value = cc.LastName;
|
||||
ws.Cells[r, 4].Value = cc.Title;
|
||||
ws.Cells[r, 5].Value = cc.ContactRole;
|
||||
ws.Cells[r, 6].Value = cc.Email;
|
||||
ws.Cells[r, 7].Value = cc.Phone;
|
||||
ws.Cells[r, 8].Value = cc.MobilePhone;
|
||||
ws.Cells[r, 9].Value = cc.Notes;
|
||||
}
|
||||
AutoFit(ws, headers.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the customer contacts CSV string for the specified company.
|
||||
/// CustomerEmail is the join key used by the importer to re-link contacts to their parent customer.
|
||||
/// </summary>
|
||||
private async Task<string> BuildCustomerContactsCsv(int companyId)
|
||||
{
|
||||
var data = await _db.CustomerContacts.AsNoTracking().IgnoreQueryFilters()
|
||||
.Include(cc => cc.Customer)
|
||||
.Where(cc => cc.CompanyId == companyId && !cc.IsDeleted)
|
||||
.OrderBy(cc => cc.Customer!.CompanyName).ThenBy(cc => cc.LastName).ThenBy(cc => cc.FirstName)
|
||||
.ToListAsync();
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("CustomerEmail,FirstName,LastName,Title,ContactRole,Email,Phone,MobilePhone,Notes");
|
||||
foreach (var cc in data)
|
||||
sb.AppendLine($"{CsvEscape(cc.Customer?.Email)},{CsvEscape(cc.FirstName)},{CsvEscape(cc.LastName)},{CsvEscape(cc.Title)},{CsvEscape(cc.ContactRole)},{CsvEscape(cc.Email)},{CsvEscape(cc.Phone)},{CsvEscape(cc.MobilePhone)},{CsvEscape(cc.Notes)}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the inventory CSV string for the specified company, ordered alphabetically by name.
|
||||
/// Column names match <see cref="InventoryItemImportDto"/> exactly so the file can be re-imported.
|
||||
|
||||
@@ -372,6 +372,7 @@ public class InvoicesController : Controller
|
||||
dto.JobId = job.Id;
|
||||
dto.CustomerId = job.CustomerId;
|
||||
dto.CustomerPO = job.CustomerPO;
|
||||
dto.ProjectName = job.ProjectName;
|
||||
|
||||
// Resolve catalog item revenue accounts for pre-population
|
||||
var catalogItemIds = job.JobItems
|
||||
@@ -710,6 +711,7 @@ public class InvoicesController : Controller
|
||||
InternalNotes = dto.InternalNotes,
|
||||
Terms = dto.Terms,
|
||||
CustomerPO = dto.CustomerPO,
|
||||
ProjectName = dto.ProjectName,
|
||||
CompanyId = currentUser.CompanyId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CreatedBy = currentUser.Email
|
||||
@@ -901,6 +903,7 @@ public class InvoicesController : Controller
|
||||
InternalNotes = invoice.InternalNotes,
|
||||
Terms = invoice.Terms,
|
||||
CustomerPO = invoice.CustomerPO,
|
||||
ProjectName = invoice.ProjectName ?? invoice.Job?.ProjectName,
|
||||
InvoiceItems = invoice.InvoiceItems
|
||||
.Where(i => !i.IsDeleted)
|
||||
.OrderBy(i => i.DisplayOrder)
|
||||
@@ -1036,6 +1039,7 @@ public class InvoicesController : Controller
|
||||
invoice.InternalNotes = dto.InternalNotes;
|
||||
invoice.Terms = dto.Terms;
|
||||
invoice.CustomerPO = dto.CustomerPO;
|
||||
invoice.ProjectName = dto.ProjectName;
|
||||
invoice.UpdatedAt = DateTime.UtcNow;
|
||||
invoice.UpdatedBy = currentUser?.Email;
|
||||
|
||||
@@ -1884,7 +1888,7 @@ public class InvoicesController : Controller
|
||||
/// Details view can show an inline toast with the delivery outcome.
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> ResendInvoice(int id, string? overrideEmail = null)
|
||||
public async Task<IActionResult> ResendInvoice(int id, string? overrideEmail = null, bool sendEmail = true, bool sendSms = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -1898,7 +1902,9 @@ public class InvoicesController : Controller
|
||||
if (invoice.Status == InvoiceStatus.Voided || invoice.Status == InvoiceStatus.WrittenOff)
|
||||
return Json(new { success = false, message = "Voided invoices cannot be resent." });
|
||||
|
||||
// Validate override email when provided
|
||||
if (!sendEmail && !sendSms)
|
||||
return Json(new { success = false, message = "Select at least one delivery channel (email or SMS)." });
|
||||
|
||||
overrideEmail = overrideEmail?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(overrideEmail) && !overrideEmail.Contains('@'))
|
||||
return Json(new { success = false, message = "The email address provided is not valid." });
|
||||
@@ -1911,11 +1917,26 @@ public class InvoicesController : Controller
|
||||
? overrideEmail
|
||||
: invoice.Customer?.BillingEmail ?? invoice.Customer?.Email ?? string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(recipientEmail))
|
||||
if (sendEmail && string.IsNullOrWhiteSpace(recipientEmail))
|
||||
return Json(new { success = false, message = "No email address on file. Please provide an address to send to." });
|
||||
|
||||
// Ensure a permanent view token exists so the SMS link always works.
|
||||
string? viewUrl = null;
|
||||
if (sendSms)
|
||||
{
|
||||
if (string.IsNullOrEmpty(invoice.PublicViewToken))
|
||||
{
|
||||
invoice.PublicViewToken = Guid.NewGuid().ToString("N");
|
||||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
viewUrl = $"{Request.Scheme}://{Request.Host}/invoice/{invoice.PublicViewToken}";
|
||||
}
|
||||
|
||||
byte[]? pdfBytes = null;
|
||||
string? pdfFilename = null;
|
||||
if (sendEmail)
|
||||
{
|
||||
try
|
||||
{
|
||||
pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId);
|
||||
@@ -1925,18 +1946,26 @@ public class InvoicesController : Controller
|
||||
{
|
||||
_logger.LogWarning(pdfEx, "PDF generation failed during resend of invoice {Id}; sending without attachment", id);
|
||||
}
|
||||
}
|
||||
|
||||
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, pdfFilename, overrideEmail: overrideEmail);
|
||||
await _notificationService.NotifyInvoiceSentAsync(
|
||||
invoice, pdfBytes, pdfFilename,
|
||||
overrideEmail: overrideEmail,
|
||||
sendSms: sendSms,
|
||||
viewUrl: viewUrl);
|
||||
|
||||
var latestLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
|
||||
|
||||
if (latestLog?.Status == NotificationStatus.Failed)
|
||||
return Json(new { success = false, message = $"Email delivery failed: {latestLog.ErrorMessage}" });
|
||||
return Json(new { success = false, message = $"Delivery failed: {latestLog.ErrorMessage}" });
|
||||
|
||||
if (latestLog?.Status == NotificationStatus.Skipped)
|
||||
if (latestLog?.Status == NotificationStatus.Skipped && !sendSms)
|
||||
return Json(new { success = false, message = $"{recipientName} has email notifications disabled or no email address on file." });
|
||||
|
||||
return Json(new { success = true, message = $"Invoice sent to {recipientEmail}." });
|
||||
var channels = new List<string>();
|
||||
if (sendEmail && !string.IsNullOrWhiteSpace(recipientEmail)) channels.Add($"email ({recipientEmail})");
|
||||
if (sendSms) channels.Add("SMS");
|
||||
return Json(new { success = true, message = $"Invoice re-sent via {string.Join(" and ", channels)}." });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -152,6 +152,10 @@ public class JobsController : Controller
|
||||
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup
|
||||
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered;
|
||||
}
|
||||
else if (statusGroup == "ready")
|
||||
{
|
||||
filter = j => j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup;
|
||||
}
|
||||
// "all" or unknown group: no filter applied (show every status)
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
@@ -1981,6 +1985,146 @@ public class JobsController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <summary>
|
||||
/// Creates a new job that is a copy of an existing job. All items, coats, and prep services
|
||||
/// are deep-copied. Pricing-routing flags (IsAiItem, IsGenericItem, IsLaborItem, IsSalesItem)
|
||||
/// are preserved so pricing behaves identically. Dates, worker assignment, and invoice links
|
||||
/// are cleared; status resets to Pending so the job enters the normal workflow from the start.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
||||
public async Task<IActionResult> CloneJob(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var source = await _unitOfWork.Jobs.LoadForDetailsAsync(id);
|
||||
if (source == null) return NotFound();
|
||||
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var pendingStatus = await _unitOfWork.JobStatusLookups.FirstOrDefaultAsync(
|
||||
s => s.StatusCode == AppConstants.StatusCodes.Job.Pending && s.CompanyId == companyId);
|
||||
if (pendingStatus == null)
|
||||
{
|
||||
this.ToastError("Could not find Pending status for this company.");
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
|
||||
var newJob = new Job
|
||||
{
|
||||
JobNumber = await GenerateJobNumber(),
|
||||
CustomerId = source.CustomerId,
|
||||
CompanyId = companyId,
|
||||
JobStatusId = pendingStatus.Id,
|
||||
JobPriorityId = source.JobPriorityId,
|
||||
Description = source.Description,
|
||||
CustomerPO = source.CustomerPO,
|
||||
ProjectName = source.ProjectName,
|
||||
SpecialInstructions = source.SpecialInstructions,
|
||||
InternalNotes = source.InternalNotes,
|
||||
Tags = source.Tags,
|
||||
IsRushJob = source.IsRushJob,
|
||||
RequiresCustomerApproval = source.RequiresCustomerApproval,
|
||||
DiscountType = source.DiscountType,
|
||||
DiscountValue = source.DiscountValue,
|
||||
DiscountReason = source.DiscountReason,
|
||||
OvenCostId = source.OvenCostId,
|
||||
OvenBatches = source.OvenBatches,
|
||||
OvenCycleMinutes = source.OvenCycleMinutes,
|
||||
ShopSuppliesPercent = source.ShopSuppliesPercent,
|
||||
ShopAccessCode = Guid.NewGuid()
|
||||
};
|
||||
|
||||
await _unitOfWork.Jobs.AddAsync(newJob);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
foreach (var srcItem in source.JobItems.Where(i => !i.IsDeleted))
|
||||
{
|
||||
var newItem = new JobItem
|
||||
{
|
||||
JobId = newJob.Id,
|
||||
CompanyId = companyId,
|
||||
Description = srcItem.Description,
|
||||
Quantity = srcItem.Quantity,
|
||||
ColorName = srcItem.ColorName,
|
||||
ColorCode = srcItem.ColorCode,
|
||||
Finish = srcItem.Finish,
|
||||
SurfaceArea = srcItem.SurfaceArea,
|
||||
SurfaceAreaSqFt = srcItem.SurfaceAreaSqFt,
|
||||
CatalogItemId = srcItem.CatalogItemId,
|
||||
UnitPrice = srcItem.UnitPrice,
|
||||
TotalPrice = srcItem.TotalPrice,
|
||||
LaborCost = srcItem.LaborCost,
|
||||
IsGenericItem = srcItem.IsGenericItem,
|
||||
ManualUnitPrice = srcItem.ManualUnitPrice,
|
||||
PowderCostOverride = srcItem.PowderCostOverride,
|
||||
IsLaborItem = srcItem.IsLaborItem,
|
||||
IsSalesItem = srcItem.IsSalesItem,
|
||||
IsAiItem = srcItem.IsAiItem,
|
||||
AiTags = srcItem.AiTags,
|
||||
IsCustomFormulaItem = srcItem.IsCustomFormulaItem,
|
||||
CustomItemTemplateId = srcItem.CustomItemTemplateId,
|
||||
FormulaFieldValuesJson = srcItem.FormulaFieldValuesJson,
|
||||
Sku = srcItem.Sku,
|
||||
IncludePrepCost = srcItem.IncludePrepCost,
|
||||
RequiresSandblasting = srcItem.RequiresSandblasting,
|
||||
RequiresMasking = srcItem.RequiresMasking,
|
||||
EstimatedMinutes = srcItem.EstimatedMinutes,
|
||||
Complexity = srcItem.Complexity,
|
||||
Notes = srcItem.Notes
|
||||
// AiPredictionId intentionally not copied — prediction belongs to original quote
|
||||
};
|
||||
|
||||
await _unitOfWork.JobItems.AddAsync(newItem);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
foreach (var srcCoat in srcItem.Coats.Where(c => !c.IsDeleted))
|
||||
{
|
||||
await _unitOfWork.JobItemCoats.AddAsync(new JobItemCoat
|
||||
{
|
||||
JobItemId = newItem.Id,
|
||||
CompanyId = companyId,
|
||||
CoatName = srcCoat.CoatName,
|
||||
Sequence = srcCoat.Sequence,
|
||||
InventoryItemId = srcCoat.InventoryItemId,
|
||||
ColorName = srcCoat.ColorName,
|
||||
VendorId = srcCoat.VendorId,
|
||||
ColorCode = srcCoat.ColorCode,
|
||||
Finish = srcCoat.Finish,
|
||||
CoverageSqFtPerLb = srcCoat.CoverageSqFtPerLb,
|
||||
TransferEfficiency = srcCoat.TransferEfficiency,
|
||||
PowderCostPerLb = srcCoat.PowderCostPerLb,
|
||||
PowderToOrder = srcCoat.PowderToOrder,
|
||||
NoExtraLayerCharge = srcCoat.NoExtraLayerCharge,
|
||||
Notes = srcCoat.Notes
|
||||
// Powder ordering / receiving tracking fields intentionally not copied
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var srcPrep in srcItem.PrepServices.Where(p => !p.IsDeleted))
|
||||
{
|
||||
await _unitOfWork.JobItemPrepServices.AddAsync(new JobItemPrepService
|
||||
{
|
||||
JobItemId = newItem.Id,
|
||||
CompanyId = companyId,
|
||||
PrepServiceId = srcPrep.PrepServiceId,
|
||||
EstimatedMinutes = srcPrep.EstimatedMinutes,
|
||||
BlastSetupId = srcPrep.BlastSetupId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
this.ToastSuccess($"Job cloned as {newJob.JobNumber} — review and update dates before scheduling.");
|
||||
return RedirectToAction(nameof(Details), new { id = newJob.Id });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error cloning job {JobId}", id);
|
||||
this.ToastError("An error occurred while cloning the job.");
|
||||
return RedirectToAction(nameof(Details), new { id });
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates the next sequential job number in the format PREFIX-YYMM-#### (e.g. JOB-2404-0001).
|
||||
/// Uses IgnoreQueryFilters so soft-deleted jobs are included in the sequence counter —
|
||||
/// this prevents number reuse if a job is deleted after being created this month.
|
||||
|
||||
@@ -1957,12 +1957,10 @@ public class QuotesController : Controller
|
||||
if (dto.SmsConsent)
|
||||
await _notificationService.NotifySmsConsentGrantedAsync(customer);
|
||||
|
||||
// Get "Converted" status (cached)
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var statuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
|
||||
var convertedStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Converted);
|
||||
|
||||
// Update quote to link to new customer
|
||||
// Update quote to link to new customer.
|
||||
// Do NOT set "Converted" status here — that status is reserved for when a job is
|
||||
// actually created via CreateJobFromQuote. Keeping the quote at "Approved" lets the
|
||||
// user immediately click "Create Job from Quote" on the next screen.
|
||||
quote.CustomerId = customer.Id;
|
||||
|
||||
// Clear prospect fields
|
||||
@@ -1977,14 +1975,11 @@ public class QuotesController : Controller
|
||||
quote.ProspectSmsConsent = false;
|
||||
quote.ProspectSmsConsentedAt = null;
|
||||
|
||||
// Update status to converted
|
||||
quote.QuoteStatusId = convertedStatus?.Id ?? quote.QuoteStatusId;
|
||||
|
||||
await _unitOfWork.Quotes.UpdateAsync(quote);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
this.ToastSuccess($"Prospect/Walk-In successfully converted to customer! Quote {quote.QuoteNumber} has been updated.");
|
||||
return RedirectToAction("Details", "Customers", new { id = customer.Id });
|
||||
this.ToastSuccess($"Customer record created! You can now create a job from quote {quote.QuoteNumber}.");
|
||||
return RedirectToAction(nameof(Details), new { id = dto.QuoteId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -2958,6 +2953,7 @@ public class QuotesController : Controller
|
||||
Total = quote.Total
|
||||
}),
|
||||
CustomerPO = quote.CustomerPO,
|
||||
ProjectName = quote.ProjectName,
|
||||
InternalNotes = quote.Notes, // Copy internal notes from quote
|
||||
IsCustomerApproved = true,
|
||||
IsRushJob = quote.IsRushJob,
|
||||
|
||||
@@ -146,12 +146,19 @@ public class ReportsController : Controller
|
||||
var momGrowth = revenueLastMonth > 0 ? Math.Round((revenueThisMonth - revenueLastMonth) / revenueLastMonth * 100, 1) : 0m;
|
||||
|
||||
// === REVENUE ANALYTICS ===
|
||||
// Pre-filter completed jobs by date range once for monthly calculations
|
||||
var completedJobsInRange = completedJobs.Where(j => j.UpdatedAt >= startDate).ToList();
|
||||
// CompletedDate is the authoritative "when the job finished" date.
|
||||
// UpdatedAt is set by the EF interceptor only on Modified saves, so seeded/imported
|
||||
// jobs may have UpdatedAt = null. Fall back to CreatedAt as a last resort.
|
||||
static DateTime JobMonthDate(Job j) =>
|
||||
j.CompletedDate ?? j.UpdatedAt ?? j.CreatedAt;
|
||||
|
||||
var completedJobsInRange = completedJobs
|
||||
.Where(j => JobMonthDate(j) >= startDate)
|
||||
.ToList();
|
||||
|
||||
// Group by month for efficient monthly aggregations
|
||||
var jobsByMonth = completedJobsInRange
|
||||
.GroupBy(j => new DateTime(j.UpdatedAt.Value.Year, j.UpdatedAt.Value.Month, 1))
|
||||
.GroupBy(j => { var d = JobMonthDate(j); return new DateTime(d.Year, d.Month, 1); })
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
// Monthly revenue trend
|
||||
|
||||
@@ -106,6 +106,83 @@ public class SeedDataController : Controller
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wipes all seeded data from the DEMO company and immediately re-seeds it with fresh demo data
|
||||
/// so all dates are current. Intended for tutorial recording resets — one click returns the demo
|
||||
/// company to a clean, realistic state without touching any other tenant.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> ResetDemoCompany()
|
||||
{
|
||||
try
|
||||
{
|
||||
var companies = await _seedDataService.GetCompaniesAsync();
|
||||
var demo = companies.FirstOrDefault(c => c.CompanyCode == "DEMO");
|
||||
if (demo == null)
|
||||
{
|
||||
TempData["ErrorMessage"] = "Demo company (code: DEMO) not found. Run Seed System Data first.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
// Full wipe — ForceRemoveAll bypasses fingerprint matching so stale seed data from
|
||||
// previous code versions (different emails, renamed SKUs, etc.) is always cleared.
|
||||
var removeOptions = new RemoveSeedDataOptions
|
||||
{
|
||||
Customers = true,
|
||||
InventoryItems = true,
|
||||
Equipment = true,
|
||||
Catalog = true,
|
||||
PricingTiers = true,
|
||||
OperatingCosts = true,
|
||||
Bills = true,
|
||||
Expenses = true,
|
||||
Workers = false, // workers stay static — never deleted on reset
|
||||
Vendors = true,
|
||||
NamedOvens = true,
|
||||
Appointments = true,
|
||||
ForceRemoveAll = true,
|
||||
};
|
||||
|
||||
var removeResult = await _seedDataService.RemoveSeedDataAsync(demo.Id, removeOptions);
|
||||
if (!removeResult.Success)
|
||||
{
|
||||
TempData["ErrorMessage"] = $"Wipe step failed: {removeResult.Message}";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
// Re-seed with today's dates
|
||||
var seedResult = await _seedDataService.SeedCompanyDataAsync(demo.Id);
|
||||
|
||||
if (seedResult.Success)
|
||||
{
|
||||
TempData["SuccessMessage"] = $"Demo company reset complete. {seedResult.ItemsSeeded} records re-seeded with today's dates.";
|
||||
TempData["SeedDetails"] = string.Join("|", seedResult.Details);
|
||||
TempData["ItemsSeeded"] = seedResult.ItemsSeeded;
|
||||
|
||||
if (seedResult.Warnings.Any())
|
||||
{
|
||||
TempData["WarningMessage"] = $"{seedResult.ItemsSkipped} item(s) were skipped";
|
||||
var displayWarnings = seedResult.Warnings.Take(30).ToList();
|
||||
if (seedResult.Warnings.Count > 30)
|
||||
displayWarnings.Add($"... and {seedResult.Warnings.Count - 30} more (see logs)");
|
||||
TempData["SeedWarnings"] = string.Join("|", displayWarnings);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
TempData["ErrorMessage"] = $"Wipe succeeded but re-seed failed: {seedResult.Message}";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error resetting demo company");
|
||||
TempData["ErrorMessage"] = $"An error occurred during demo reset: {ex.Message}";
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes previously seeded demo data from a company according to the supplied options (e.g., jobs only, or all data). Used during QA/demo resets to return a company to a clean state without a full database drop.
|
||||
/// </summary>
|
||||
|
||||
@@ -2092,6 +2092,27 @@ public class ToolsController : Controller
|
||||
{
|
||||
await writer.WriteAsync(purchaseOrdersCsv);
|
||||
}
|
||||
|
||||
// 16. Company Settings
|
||||
var settingsCompany = await _unitOfWork.Companies.GetByIdAsync(companyId.Value, false,
|
||||
c => c.OperatingCosts, c => c.Preferences, c => c.PricingTiers);
|
||||
if (settingsCompany != null)
|
||||
{
|
||||
var settingsJobStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == companyId.Value);
|
||||
var settingsJobPriorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId.Value);
|
||||
var settingsQuoteStatuses = await _unitOfWork.QuoteStatusLookups.FindAsync(s => s.CompanyId == companyId.Value);
|
||||
var settingsInventoryCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId.Value);
|
||||
var settingsApptStatuses = await _unitOfWork.AppointmentStatusLookups.FindAsync(s => s.CompanyId == companyId.Value);
|
||||
var settingsApptTypes = await _unitOfWork.AppointmentTypeLookups.FindAsync(t => t.CompanyId == companyId.Value);
|
||||
var settingsCsv = GenerateCompanySettingsCsv(settingsCompany, settingsJobStatuses, settingsJobPriorities,
|
||||
settingsQuoteStatuses, settingsInventoryCategories, settingsApptStatuses, settingsApptTypes);
|
||||
var settingsEntry = archive.CreateEntry($"company_settings_{timestamp}.csv");
|
||||
using (var entryStream = settingsEntry.Open())
|
||||
using (var writer = new System.IO.StreamWriter(entryStream))
|
||||
{
|
||||
await writer.WriteAsync(settingsCsv);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
memoryStream.Position = 0;
|
||||
@@ -2785,23 +2806,46 @@ public class ToolsController : Controller
|
||||
sb.AppendLine("State,");
|
||||
sb.AppendLine("ZipCode,");
|
||||
sb.AppendLine("TimeZone,America/New_York");
|
||||
sb.AppendLine("AccountingMethod,Accrual");
|
||||
sb.AppendLine("TimeclockEnabled,true");
|
||||
sb.AppendLine("TimeclockAllowMultiplePunchesPerDay,true");
|
||||
sb.AppendLine("TimeclockAutoClockOutHours,");
|
||||
sb.AppendLine();
|
||||
|
||||
// Operating Costs
|
||||
sb.AppendLine("[Operating Costs]");
|
||||
sb.AppendLine("StandardLaborRate,65.00");
|
||||
sb.AppendLine("LaborCostPerHour,");
|
||||
sb.AppendLine("AdditionalCoatLaborPercent,30");
|
||||
sb.AppendLine("OvenOperatingCostPerHour,25.00");
|
||||
sb.AppendLine("DefaultOvenCycleMinutes,45");
|
||||
sb.AppendLine("SandblasterCostPerHour,35.00");
|
||||
sb.AppendLine("CoatingBoothCostPerHour,30.00");
|
||||
sb.AppendLine("PowderCoatingCostPerSqFt,0.50");
|
||||
sb.AppendLine("PricingMode,MarkupOnMaterial");
|
||||
sb.AppendLine("GeneralMarkupPercentage,35");
|
||||
sb.AppendLine("TargetMarginPercent,0");
|
||||
sb.AppendLine("TaxPercent,8.5");
|
||||
sb.AppendLine("ShopSuppliesRate,5");
|
||||
sb.AppendLine("RushChargeType,Percentage");
|
||||
sb.AppendLine("RushChargePercentage,25");
|
||||
sb.AppendLine("RushChargeFixedAmount,0");
|
||||
sb.AppendLine("ShopMinimumCharge,50.00");
|
||||
sb.AppendLine("ComplexitySimplePercent,0");
|
||||
sb.AppendLine("ComplexityModeratePercent,5");
|
||||
sb.AppendLine("ComplexityComplexPercent,15");
|
||||
sb.AppendLine("ComplexityExtremePercent,25");
|
||||
sb.AppendLine("ShopCapabilityTier,Small");
|
||||
sb.AppendLine("BlastSetupType,SiphonCabinet");
|
||||
sb.AppendLine("CompressorCfm,0");
|
||||
sb.AppendLine("BlastNozzleSize,4");
|
||||
sb.AppendLine("PrimaryBlastSubstrate,Mixed");
|
||||
sb.AppendLine("BlastRateSqFtPerHourOverride,");
|
||||
sb.AppendLine("CoatingGunType,Corona");
|
||||
sb.AppendLine("CoatingRateSqFtPerHourOverride,");
|
||||
sb.AppendLine("MonthlyRent,0");
|
||||
sb.AppendLine("MonthlyUtilities,0");
|
||||
sb.AppendLine("MonthlyBillableHours,160");
|
||||
sb.AppendLine();
|
||||
|
||||
// Preferences
|
||||
@@ -2813,16 +2857,22 @@ public class ToolsController : Controller
|
||||
sb.AppendLine("DefaultQuoteValidityDays,30");
|
||||
sb.AppendLine("QuoteNumberPrefix,QT");
|
||||
sb.AppendLine("JobNumberPrefix,JOB");
|
||||
sb.AppendLine("InvoiceNumberPrefix,INV");
|
||||
sb.AppendLine("UseMetricSystem,false");
|
||||
sb.AppendLine("DefaultJobPriority,Normal");
|
||||
sb.AppendLine("RequireCustomerPO,false");
|
||||
sb.AppendLine("AllowCustomerApproval,true");
|
||||
sb.AppendLine("DefaultTurnaroundDays,7");
|
||||
sb.AppendLine("EmailFromAddress,");
|
||||
sb.AppendLine("EmailFromName,");
|
||||
sb.AppendLine("EmailNotificationsEnabled,true");
|
||||
sb.AppendLine("NotifyOnNewJob,true");
|
||||
sb.AppendLine("NotifyOnNewQuote,true");
|
||||
sb.AppendLine("NotifyOnJobStatusChange,true");
|
||||
sb.AppendLine("NotifyOnQuoteApproval,true");
|
||||
sb.AppendLine("NotifyOnPaymentReceived,true");
|
||||
sb.AppendLine("PaymentRemindersEnabled,false");
|
||||
sb.AppendLine("PaymentReminderDays,7,14,30");
|
||||
sb.AppendLine("QuoteExpiryWarningDays,3");
|
||||
sb.AppendLine("DueDateWarningDays,2");
|
||||
sb.AppendLine("MaintenanceAlertDays,7");
|
||||
@@ -2831,6 +2881,16 @@ public class ToolsController : Controller
|
||||
sb.AppendLine("LogRetentionDays,90");
|
||||
sb.AppendLine("AutoArchiveJobsDays,365");
|
||||
sb.AppendLine("DeletedRecordRetentionDays,30");
|
||||
sb.AppendLine("QtAccentColor,#374151");
|
||||
sb.AppendLine("QtDefaultTerms,");
|
||||
sb.AppendLine("QtFooterNote,");
|
||||
sb.AppendLine("InAccentColor,#374151");
|
||||
sb.AppendLine("InDefaultTerms,");
|
||||
sb.AppendLine("InFooterNote,");
|
||||
sb.AppendLine("WoAccentColor,#374151");
|
||||
sb.AppendLine("WoTerms,");
|
||||
sb.AppendLine("KioskIntakeOutput,Quote");
|
||||
sb.AppendLine("MigratingFromQuickBooks,false");
|
||||
sb.AppendLine();
|
||||
|
||||
// Pricing Tiers
|
||||
@@ -2925,6 +2985,10 @@ public class ToolsController : Controller
|
||||
sb.AppendLine($"State,{EscapeCsv(company.State)}");
|
||||
sb.AppendLine($"ZipCode,{EscapeCsv(company.ZipCode)}");
|
||||
sb.AppendLine($"TimeZone,{EscapeCsv(company.TimeZone)}");
|
||||
sb.AppendLine($"AccountingMethod,{company.AccountingMethod}");
|
||||
sb.AppendLine($"TimeclockEnabled,{company.TimeclockEnabled.ToString().ToLower()}");
|
||||
sb.AppendLine($"TimeclockAllowMultiplePunchesPerDay,{company.TimeclockAllowMultiplePunchesPerDay.ToString().ToLower()}");
|
||||
sb.AppendLine($"TimeclockAutoClockOutHours,{company.TimeclockAutoClockOutHours?.ToString() ?? ""}");
|
||||
sb.AppendLine();
|
||||
|
||||
// Operating Costs
|
||||
@@ -2933,18 +2997,37 @@ public class ToolsController : Controller
|
||||
var costs = company.OperatingCosts;
|
||||
sb.AppendLine("[Operating Costs]");
|
||||
sb.AppendLine($"StandardLaborRate,{costs.StandardLaborRate}");
|
||||
sb.AppendLine($"LaborCostPerHour,{costs.LaborCostPerHour?.ToString() ?? ""}");
|
||||
sb.AppendLine($"AdditionalCoatLaborPercent,{costs.AdditionalCoatLaborPercent}");
|
||||
sb.AppendLine($"OvenOperatingCostPerHour,{costs.OvenOperatingCostPerHour}");
|
||||
sb.AppendLine($"DefaultOvenCycleMinutes,{costs.DefaultOvenCycleMinutes}");
|
||||
sb.AppendLine($"SandblasterCostPerHour,{costs.SandblasterCostPerHour}");
|
||||
sb.AppendLine($"CoatingBoothCostPerHour,{costs.CoatingBoothCostPerHour}");
|
||||
sb.AppendLine($"PowderCoatingCostPerSqFt,{costs.PowderCoatingCostPerSqFt}");
|
||||
sb.AppendLine($"PricingMode,{costs.PricingMode}");
|
||||
sb.AppendLine($"GeneralMarkupPercentage,{costs.GeneralMarkupPercentage}");
|
||||
sb.AppendLine($"TargetMarginPercent,{costs.TargetMarginPercent}");
|
||||
sb.AppendLine($"TaxPercent,{costs.TaxPercent}");
|
||||
sb.AppendLine($"ShopSuppliesRate,{costs.ShopSuppliesRate}");
|
||||
sb.AppendLine($"RushChargeType,{EscapeCsv(costs.RushChargeType)}");
|
||||
sb.AppendLine($"RushChargePercentage,{costs.RushChargePercentage}");
|
||||
sb.AppendLine($"RushChargeFixedAmount,{costs.RushChargeFixedAmount}");
|
||||
sb.AppendLine($"ShopMinimumCharge,{costs.ShopMinimumCharge}");
|
||||
sb.AppendLine($"ComplexitySimplePercent,{costs.ComplexitySimplePercent}");
|
||||
sb.AppendLine($"ComplexityModeratePercent,{costs.ComplexityModeratePercent}");
|
||||
sb.AppendLine($"ComplexityComplexPercent,{costs.ComplexityComplexPercent}");
|
||||
sb.AppendLine($"ComplexityExtremePercent,{costs.ComplexityExtremePercent}");
|
||||
sb.AppendLine($"ShopCapabilityTier,{costs.ShopCapabilityTier}");
|
||||
sb.AppendLine($"BlastSetupType,{costs.BlastSetupType}");
|
||||
sb.AppendLine($"CompressorCfm,{costs.CompressorCfm}");
|
||||
sb.AppendLine($"BlastNozzleSize,{costs.BlastNozzleSize}");
|
||||
sb.AppendLine($"PrimaryBlastSubstrate,{costs.PrimaryBlastSubstrate}");
|
||||
sb.AppendLine($"BlastRateSqFtPerHourOverride,{costs.BlastRateSqFtPerHourOverride?.ToString() ?? ""}");
|
||||
sb.AppendLine($"CoatingGunType,{costs.CoatingGunType}");
|
||||
sb.AppendLine($"CoatingRateSqFtPerHourOverride,{costs.CoatingRateSqFtPerHourOverride?.ToString() ?? ""}");
|
||||
sb.AppendLine($"MonthlyRent,{costs.MonthlyRent}");
|
||||
sb.AppendLine($"MonthlyUtilities,{costs.MonthlyUtilities}");
|
||||
sb.AppendLine($"MonthlyBillableHours,{costs.MonthlyBillableHours}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
@@ -2960,16 +3043,22 @@ public class ToolsController : Controller
|
||||
sb.AppendLine($"DefaultQuoteValidityDays,{prefs.DefaultQuoteValidityDays}");
|
||||
sb.AppendLine($"QuoteNumberPrefix,{EscapeCsv(prefs.QuoteNumberPrefix)}");
|
||||
sb.AppendLine($"JobNumberPrefix,{EscapeCsv(prefs.JobNumberPrefix)}");
|
||||
sb.AppendLine($"InvoiceNumberPrefix,{EscapeCsv(prefs.InvoiceNumberPrefix)}");
|
||||
sb.AppendLine($"UseMetricSystem,{prefs.UseMetricSystem.ToString().ToLower()}");
|
||||
sb.AppendLine($"DefaultJobPriority,{EscapeCsv(prefs.DefaultJobPriority)}");
|
||||
sb.AppendLine($"RequireCustomerPO,{prefs.RequireCustomerPO.ToString().ToLower()}");
|
||||
sb.AppendLine($"AllowCustomerApproval,{prefs.AllowCustomerApproval.ToString().ToLower()}");
|
||||
sb.AppendLine($"DefaultTurnaroundDays,{prefs.DefaultTurnaroundDays}");
|
||||
sb.AppendLine($"EmailFromAddress,{EscapeCsv(prefs.EmailFromAddress)}");
|
||||
sb.AppendLine($"EmailFromName,{EscapeCsv(prefs.EmailFromName)}");
|
||||
sb.AppendLine($"EmailNotificationsEnabled,{prefs.EmailNotificationsEnabled.ToString().ToLower()}");
|
||||
sb.AppendLine($"NotifyOnNewJob,{prefs.NotifyOnNewJob.ToString().ToLower()}");
|
||||
sb.AppendLine($"NotifyOnNewQuote,{prefs.NotifyOnNewQuote.ToString().ToLower()}");
|
||||
sb.AppendLine($"NotifyOnJobStatusChange,{prefs.NotifyOnJobStatusChange.ToString().ToLower()}");
|
||||
sb.AppendLine($"NotifyOnQuoteApproval,{prefs.NotifyOnQuoteApproval.ToString().ToLower()}");
|
||||
sb.AppendLine($"NotifyOnPaymentReceived,{prefs.NotifyOnPaymentReceived.ToString().ToLower()}");
|
||||
sb.AppendLine($"PaymentRemindersEnabled,{prefs.PaymentRemindersEnabled.ToString().ToLower()}");
|
||||
sb.AppendLine($"PaymentReminderDays,{EscapeCsv(prefs.PaymentReminderDays)}");
|
||||
sb.AppendLine($"QuoteExpiryWarningDays,{prefs.QuoteExpiryWarningDays}");
|
||||
sb.AppendLine($"DueDateWarningDays,{prefs.DueDateWarningDays}");
|
||||
sb.AppendLine($"MaintenanceAlertDays,{prefs.MaintenanceAlertDays}");
|
||||
@@ -2978,6 +3067,16 @@ public class ToolsController : Controller
|
||||
sb.AppendLine($"LogRetentionDays,{prefs.LogRetentionDays}");
|
||||
sb.AppendLine($"AutoArchiveJobsDays,{prefs.AutoArchiveJobsDays}");
|
||||
sb.AppendLine($"DeletedRecordRetentionDays,{prefs.DeletedRecordRetentionDays}");
|
||||
sb.AppendLine($"QtAccentColor,{EscapeCsv(prefs.QtAccentColor)}");
|
||||
sb.AppendLine($"QtDefaultTerms,{EscapeCsv(prefs.QtDefaultTerms)}");
|
||||
sb.AppendLine($"QtFooterNote,{EscapeCsv(prefs.QtFooterNote)}");
|
||||
sb.AppendLine($"InAccentColor,{EscapeCsv(prefs.InAccentColor)}");
|
||||
sb.AppendLine($"InDefaultTerms,{EscapeCsv(prefs.InDefaultTerms)}");
|
||||
sb.AppendLine($"InFooterNote,{EscapeCsv(prefs.InFooterNote)}");
|
||||
sb.AppendLine($"WoAccentColor,{EscapeCsv(prefs.WoAccentColor)}");
|
||||
sb.AppendLine($"WoTerms,{EscapeCsv(prefs.WoTerms)}");
|
||||
sb.AppendLine($"KioskIntakeOutput,{EscapeCsv(prefs.KioskIntakeOutput)}");
|
||||
sb.AppendLine($"MigratingFromQuickBooks,{prefs.MigratingFromQuickBooks.ToString().ToLower()}");
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ public static class HelpKnowledgeBase
|
||||
- *Commercial*: Businesses. Can have a pricing tier, credit limit, tax exempt status, and linked quotes/jobs.
|
||||
- *Non-Commercial*: Individual consumers. Simpler setup.
|
||||
|
||||
**Key fields:** Name, email, phone, address, customer type, pricing tier, credit limit, tax exempt (with certificate upload), notes.
|
||||
**Key fields:** Name, email, phone, address, customer type, pricing tier, credit limit, tax exempt (with certificate upload), notes, lead source, ship-to address.
|
||||
|
||||
**How to add a customer:**
|
||||
1. Go to [Customers](/Customers)
|
||||
@@ -161,10 +161,20 @@ public static class HelpKnowledgeBase
|
||||
3. Fill in name, contact info, select type
|
||||
4. Save
|
||||
|
||||
**Customer Details page** (/Customers/Details/ID) shows: contact info, all linked jobs, quotes, invoices, deposits, balance, notes.
|
||||
**Customer Details page** (/Customers/Details/ID) shows: contact info, all linked jobs, quotes, invoices, deposits, balance, notes, additional contacts.
|
||||
|
||||
**Customer Notes:** Add internal notes on the Details page. Notes are private (not visible to the customer).
|
||||
|
||||
**Additional Contacts:** Store billing contacts, ops contacts, drop-off contacts, etc. on the Customer Details page. These are for staff reference only — all automated notifications (emails, SMS) go to the primary email/phone on the main customer record, not to additional contacts. If invoices need to go to a separate address, use the Billing Email field on the main record.
|
||||
|
||||
**Lead Source:** Optional field on the customer record indicating how they found the shop (Walk-In, Google Search, Customer Referral, Social Media, Website, Repeat Customer, Trade Show, Flyer/Print, Other).
|
||||
|
||||
**Ship-To Address:** Optional separate address for pickups or deliveries. Shown alongside the billing address on the Customer Details page when set.
|
||||
|
||||
**Preferred Powders:** On the Customer Details page, the Preferred Powders card lets staff tag inventory items that a customer regularly orders. Use the search box to find a powder by name or SKU and click Add. Remove with the × button. This is a staff-reference tool only — it does not auto-select powders on quotes or jobs. Items must already exist in Inventory to appear in the search.
|
||||
|
||||
**Outstanding Pickups (Ready for Pickup card):** When one or more of a customer's jobs are in "Ready for Pickup" status, a highlighted card appears on their Customer Details page showing each job number and how many days it has been waiting. Color coding: amber = 3–6 days, red = 7+ days. The card disappears once all jobs move out of Ready for Pickup status. Useful for front desk staff to instantly see during a call whether parts are ready for this customer.
|
||||
|
||||
**Deactivating a customer:** Use the Delete/Deactivate option — this soft-deletes (hides) the customer but does not erase data.
|
||||
|
||||
**Pricing Tiers:** Assign a tier (configured at [Pricing Tiers](/PricingTiers)) to automatically apply a discount to that customer's quotes.
|
||||
@@ -194,8 +204,9 @@ public static class HelpKnowledgeBase
|
||||
2. Choose Quick Quote (fast) or Full Quote (complete form) using the toggle at the top
|
||||
3. Select existing customer OR enter prospect info (name, email, phone)
|
||||
4. Add line items using the item wizard (3 item types below)
|
||||
5. Review the pricing breakdown
|
||||
6. Save as Draft or Send immediately
|
||||
5. Optionally enter a **Project Name** — a short label (e.g. "Shop Equipment Rack") that carries through to the job and invoice when the quote is converted.
|
||||
6. Review the pricing breakdown
|
||||
7. Save as Draft or Send immediately
|
||||
|
||||
**Item types in the quote/job wizard:**
|
||||
1. *Product from Catalog* — pick a pre-priced catalog item; price is fixed, no surface-area calculation
|
||||
@@ -286,8 +297,9 @@ public static class HelpKnowledgeBase
|
||||
2. Select customer
|
||||
3. Add line items (same wizard as quotes: Calculated, Custom Work, or AI Photo)
|
||||
4. Set priority, due date, assigned worker, special instructions
|
||||
5. Optionally set Oven & Batch Settings — select a named oven, number of batches, and cycle time. These affect the oven cost in pricing.
|
||||
6. Save
|
||||
5. Optionally enter a **Project Name** — a short label (e.g. "Front Gate Panels") that appears on the job, linked invoice, and printed documents to help the customer identify what the work is for.
|
||||
6. Optionally set Oven & Batch Settings — select a named oven, number of batches, and cycle time. These affect the oven cost in pricing.
|
||||
7. Save
|
||||
|
||||
**Job Priority Board:** [/JobsPriority](/JobsPriority) — Kanban-style view of all active jobs sorted by priority and status.
|
||||
|
||||
@@ -328,6 +340,8 @@ public static class HelpKnowledgeBase
|
||||
|
||||
**Job Templates:** [/JobTemplates](/JobTemplates) — Save a job's items as a template to reuse for common work types. When creating a new job, select a template to pre-fill items.
|
||||
|
||||
**Cloning a Job:** On any Job Details page, click the **Clone Job** button (copy icon in the header toolbar). The system creates a new draft job immediately and redirects you to it. The clone copies: customer, description, PO number, project name, special instructions, tags, priority, discount %, oven settings, and all line items with their coats and prep services. It does NOT copy: due date, scheduled date, assigned worker, photos, notes, time entries, status history, or any linked invoice or payments. The clone starts in Pending status so it goes through the normal workflow.
|
||||
|
||||
**Changing the customer on a job:** On the Job Details page, the Customer field is an always-visible dropdown. Select a different customer — a confirmation banner appears. Click **Save** to apply or **Cancel** to revert. Use this to correct a misassigned job or to move a walk-in job to a customer's proper record after they've been added to the system.
|
||||
|
||||
**Inline item price editing:** On the Job Details page, any unit price, quantity, or item description can be edited in-place without opening the full edit form. Click the value — it becomes an input field. Type the new value, then press Enter or click away to save (Escape cancels). The pricing summary card (Items Subtotal, Subtotal, Tax, and Total) and the Job Costing card both update immediately without a page reload.
|
||||
@@ -377,8 +391,10 @@ public static class HelpKnowledgeBase
|
||||
- *Voided* — cancelled invoice
|
||||
- *Written Off* — uncollectable, written off
|
||||
|
||||
**Project Name on invoices:** If the linked job had a Project Name set, it auto-fills on the invoice and appears on the printed PDF to help the customer identify the work.
|
||||
|
||||
**How to create an invoice:**
|
||||
1. From the Job Details page → "Create Invoice" (recommended — pre-fills all items), OR
|
||||
1. From the Job Details page → "Create Invoice" (recommended — pre-fills all items including Project Name), OR
|
||||
2. Go to [Invoices](/Invoices) → "New Invoice" and select a job
|
||||
|
||||
**Recording a payment:**
|
||||
@@ -470,6 +486,8 @@ public static class HelpKnowledgeBase
|
||||
- **Low Stock** (red) — quantity is greater than zero but at or below the reorder point; time to reorder
|
||||
- **Out of Stock** (dark/black) — quantity is zero; an alert banner appears on the Details page
|
||||
|
||||
**Low Stock stat card (clickable filter):** The "Low Stock" KPI card at the top of the Inventory page is clickable. Click it to instantly filter the list to only items needing reorder. Click it again (or clear the filter banner) to return to the full list. This is the fastest way to generate a reorder checklist.
|
||||
|
||||
**Stock Adjustment:** From Inventory Details, click "Stock Adjustment" to open the quick-adjust modal. Choose Add Stock, Remove Stock, or Set Exact, enter the quantity, select a reason (required), and optionally add notes. Every adjustment is automatically recorded as a transaction with the reason and notes included.
|
||||
|
||||
**Inventory transactions:** Every stock movement is recorded automatically — Initial (item creation), Purchase (PO receipt), Adjustment (manual or edit), Job Usage (powder consumed on a job coat), Sale, Return, Waste, Transfer. Each record stores date, quantity delta, unit cost, and running balance after the change.
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
@foreach (var item in new[]
|
||||
{
|
||||
("Customers", "people", "Customers"),
|
||||
("CustomerContacts", "person-lines-fill", "Customer Contacts"),
|
||||
("Jobs", "tools", "Jobs"),
|
||||
("Quotes", "file-earmark-text", "Quotes"),
|
||||
("Invoices", "receipt", "Invoices"),
|
||||
|
||||
@@ -312,7 +312,7 @@
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end">
|
||||
<button type="submit" class="btn btn-primary" id="btnSaveCompanyInfo">
|
||||
<button type="button" class="btn btn-primary" id="btnSaveCompanyInfo">
|
||||
<i class="bi bi-save"></i> Save Changes
|
||||
</button>
|
||||
</div>
|
||||
@@ -2749,10 +2749,8 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Company Info Form Submit
|
||||
$('#companyInfoForm').on('submit', function (e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Company Info Save
|
||||
$('#btnSaveCompanyInfo').on('click', function () {
|
||||
const formData = {
|
||||
CompanyName: $('#companyName').val(),
|
||||
CompanyCode: $('#companyCode').val(),
|
||||
@@ -3192,7 +3190,7 @@
|
||||
|
||||
// Button success animation helper
|
||||
function showButtonSuccess(btn, originalHtml, duration = 2000) {
|
||||
btn.removeClass('btn-primary').addClass('btn-success');
|
||||
btn.prop('disabled', false).removeClass('btn-primary').addClass('btn-success');
|
||||
btn.html('<i class="bi bi-check-circle-fill"></i> Saved!');
|
||||
setTimeout(function() {
|
||||
btn.removeClass('btn-success').addClass('btn-primary');
|
||||
|
||||
@@ -209,6 +209,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ship-To Address Section -->
|
||||
<div class="mb-4">
|
||||
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
|
||||
<h5 class="mb-0"><i class="bi bi-truck me-2 text-primary"></i>Ship-To / Pickup Address</h5>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Ship-To Address"
|
||||
data-bs-content="Optional. Fill in only if this customer picks up or receives deliveries at a different address than their billing address. Leave blank to use the billing address above.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
<span class="text-muted small fw-normal">(optional — leave blank if same as billing)</span>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label asp-for="ShipToAddress" class="form-label">Street Address</label>
|
||||
<input asp-for="ShipToAddress" class="form-control" placeholder="Enter ship-to street address" />
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label asp-for="ShipToCity" class="form-label">City</label>
|
||||
<input asp-for="ShipToCity" class="form-control" placeholder="Enter city" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label asp-for="ShipToState" class="form-label">State</label>
|
||||
<input asp-for="ShipToState" class="form-control" placeholder="Enter state" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label asp-for="ShipToZipCode" class="form-label">Zip Code</label>
|
||||
<input asp-for="ShipToZipCode" class="form-control" placeholder="12345" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label asp-for="ShipToCountry" class="form-label">Country</label>
|
||||
<input asp-for="ShipToCountry" class="form-control" placeholder="USA" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Business Information Section -->
|
||||
<div class="mb-4">
|
||||
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
|
||||
@@ -282,6 +318,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lead Source Section -->
|
||||
<div class="mb-4">
|
||||
<h5 class="border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-signpost me-2 text-primary"></i>How Did They Find Us?
|
||||
</h5>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label asp-for="LeadSource" class="form-label">Lead Source</label>
|
||||
<select asp-for="LeadSource" class="form-select">
|
||||
<option value="">— Not specified —</option>
|
||||
<option value="Walk-In">Walk-In</option>
|
||||
<option value="Google Search">Google Search</option>
|
||||
<option value="Customer Referral">Customer Referral</option>
|
||||
<option value="Social Media">Social Media</option>
|
||||
<option value="Website">Website</option>
|
||||
<option value="Repeat Customer">Repeat Customer</option>
|
||||
<option value="Trade Show / Event">Trade Show / Event</option>
|
||||
<option value="Flyer / Print Ad">Flyer / Print Ad</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes Section -->
|
||||
<div class="mb-4">
|
||||
<h5 class="border-bottom pb-2 mb-3">
|
||||
|
||||
@@ -216,27 +216,50 @@
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (!string.IsNullOrEmpty(Model.Address))
|
||||
@{
|
||||
bool hasBilling = !string.IsNullOrEmpty(Model.Address);
|
||||
bool hasShipTo = !string.IsNullOrEmpty(Model.ShipToAddress) || !string.IsNullOrEmpty(Model.ShipToCity);
|
||||
}
|
||||
@if (hasShipTo)
|
||||
{
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small mb-1">Billing Address</label>
|
||||
@if (hasBilling)
|
||||
{
|
||||
<p class="mb-1">@Model.Address</p>
|
||||
<p class="mb-0">
|
||||
@if (!string.IsNullOrEmpty(Model.City)) { <span>@Model.City</span> }
|
||||
@if (!string.IsNullOrEmpty(Model.State)) { <span>, @Model.State</span> }
|
||||
@if (!string.IsNullOrEmpty(Model.ZipCode)) { <span> @Model.ZipCode</span> }
|
||||
</p>
|
||||
@if (!string.IsNullOrEmpty(Model.Country)) { <p class="mb-0 text-muted">@Model.Country</p> }
|
||||
}
|
||||
else { <p class="text-muted mb-0">Not provided</p> }
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small mb-1">
|
||||
<i class="bi bi-truck me-1"></i>Ship-To / Pickup Address
|
||||
</label>
|
||||
<p class="mb-1">@Model.ShipToAddress</p>
|
||||
<p class="mb-0">
|
||||
@if (!string.IsNullOrEmpty(Model.ShipToCity)) { <span>@Model.ShipToCity</span> }
|
||||
@if (!string.IsNullOrEmpty(Model.ShipToState)) { <span>, @Model.ShipToState</span> }
|
||||
@if (!string.IsNullOrEmpty(Model.ShipToZipCode)) { <span> @Model.ShipToZipCode</span> }
|
||||
</p>
|
||||
@if (!string.IsNullOrEmpty(Model.ShipToCountry)) { <p class="mb-0 text-muted">@Model.ShipToCountry</p> }
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (hasBilling)
|
||||
{
|
||||
<p class="mb-2">@Model.Address</p>
|
||||
<p class="mb-0">
|
||||
@if (!string.IsNullOrEmpty(Model.City))
|
||||
{
|
||||
<span>@Model.City</span>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Model.State))
|
||||
{
|
||||
<span>, @Model.State</span>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Model.ZipCode))
|
||||
{
|
||||
<span> @Model.ZipCode</span>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Model.City)) { <span>@Model.City</span> }
|
||||
@if (!string.IsNullOrEmpty(Model.State)) { <span>, @Model.State</span> }
|
||||
@if (!string.IsNullOrEmpty(Model.ZipCode)) { <span> @Model.ZipCode</span> }
|
||||
</p>
|
||||
@if (!string.IsNullOrEmpty(Model.Country))
|
||||
{
|
||||
<p class="mb-0 text-muted">@Model.Country</p>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Model.Country)) { <p class="mb-0 text-muted">@Model.Country</p> }
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -262,6 +285,15 @@
|
||||
<label class="text-muted small mb-1">Payment Terms</label>
|
||||
<p class="mb-0">@(Model.PaymentTerms ?? "Not set")</p>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(Model.LeadSource))
|
||||
{
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small mb-1">Lead Source</label>
|
||||
<p class="mb-0">
|
||||
<i class="bi bi-signpost me-1 text-muted"></i>@Model.LeadSource
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small mb-1">Credit Limit</label>
|
||||
<p class="mb-0 fw-semibold">@Model.CreditLimit.ToString("C")</p>
|
||||
@@ -328,6 +360,211 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Additional Contacts -->
|
||||
@{
|
||||
var customerContacts = ViewBag.CustomerContacts as List<PowderCoating.Core.Entities.CustomerContact>;
|
||||
}
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0 fw-semibold">
|
||||
<i class="bi bi-people me-2 text-primary"></i>Additional Contacts
|
||||
</h5>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<span class="text-muted small">
|
||||
<i class="bi bi-info-circle me-1"></i>For staff reference — automated notifications still go to the primary contact above.
|
||||
</span>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||
data-bs-toggle="modal" data-bs-target="#contactModal"
|
||||
onclick="openAddContactModal()">
|
||||
<i class="bi bi-plus-circle me-1"></i>Add Contact
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="ps-3">Name / Role</th>
|
||||
<th>Email</th>
|
||||
<th>Phone</th>
|
||||
<th class="text-end pe-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="contacts-table-body">
|
||||
@if (customerContacts != null && customerContacts.Count > 0)
|
||||
{
|
||||
@foreach (var c in customerContacts)
|
||||
{
|
||||
var displayName = string.IsNullOrWhiteSpace(c.LastName) ? c.FirstName : $"{c.FirstName} {c.LastName}";
|
||||
<tr data-contact-id="@c.Id">
|
||||
<td class="ps-3">
|
||||
<div class="fw-semibold">
|
||||
@displayName
|
||||
@if (!string.IsNullOrEmpty(c.ContactRole))
|
||||
{
|
||||
<span class="badge bg-secondary bg-opacity-10 text-secondary ms-1">@c.ContactRole</span>
|
||||
}
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(c.Title))
|
||||
{
|
||||
<div class="text-muted" style="font-size:0.75rem;">@c.Title</div>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (!string.IsNullOrEmpty(c.Email))
|
||||
{
|
||||
<a href="mailto:@c.Email" class="text-decoration-none small">@c.Email</a>
|
||||
}
|
||||
else { <span class="text-muted small">—</span> }
|
||||
</td>
|
||||
<td>
|
||||
@if (!string.IsNullOrEmpty(c.Phone ?? c.MobilePhone))
|
||||
{
|
||||
<span class="small">@(c.Phone ?? c.MobilePhone)</span>
|
||||
}
|
||||
else { <span class="text-muted small">—</span> }
|
||||
</td>
|
||||
<td class="text-end pe-3">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary me-1"
|
||||
onclick="editContact(@Model.Id, @c.Id)" title="Edit">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger"
|
||||
onclick="deleteContact(@Model.Id, @c.Id)" title="Delete">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<tr id="no-contacts-placeholder">
|
||||
<td colspan="4" class="text-muted small px-3 py-2">No additional contacts. Click “Add Contact” to add billing, ops, or drop-off contacts.</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Customer Notes -->
|
||||
@{
|
||||
var customerNotes = ViewBag.CustomerNotes as List<PowderCoating.Core.Entities.CustomerNote>;
|
||||
}
|
||||
<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-sticky me-2 text-primary"></i>Internal Notes
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="customer-notes-list">
|
||||
@if (customerNotes != null && customerNotes.Count > 0)
|
||||
{
|
||||
@foreach (var note in customerNotes)
|
||||
{
|
||||
<div class="customer-note-item d-flex gap-2 px-3 py-2 border-bottom" data-note-id="@note.Id">
|
||||
<div class="flex-grow-1">
|
||||
@if (note.IsImportant)
|
||||
{
|
||||
<span class="text-warning me-1" title="Important">★</span>
|
||||
}
|
||||
<span class="note-text small">@note.Note</span>
|
||||
<div class="text-muted" style="font-size:0.75rem;">
|
||||
@(note.CreatedBy ?? "Staff") — @note.CreatedAt.Tz(ViewBag.CompanyTimeZone as string).ToString("MMM dd, yyyy h:mm tt")
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-link text-danger p-0 flex-shrink-0 align-self-start"
|
||||
onclick="deleteCustomerNote(@Model.Id, @note.Id)" title="Delete note">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div id="no-notes-placeholder" class="px-3 py-2 text-muted small">No notes yet.</div>
|
||||
}
|
||||
</div>
|
||||
<div class="px-3 py-3 border-top bg-light">
|
||||
<div class="mb-2">
|
||||
<textarea id="newNoteText" class="form-control form-control-sm" rows="2"
|
||||
placeholder="Add an internal note..." maxlength="2000"></textarea>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="form-check form-check-sm mb-0">
|
||||
<input class="form-check-input" type="checkbox" id="newNoteImportant">
|
||||
<label class="form-check-label small" for="newNoteImportant">
|
||||
<span class="text-warning">★</span> Mark important
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-primary" onclick="addCustomerNote(@Model.Id)">
|
||||
<i class="bi bi-plus-circle me-1"></i>Add Note
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity Timeline -->
|
||||
@{
|
||||
var timeline = ViewBag.Timeline as List<PowderCoating.Application.DTOs.Customer.CustomerTimelineEventDto>;
|
||||
}
|
||||
@if (timeline != null && timeline.Count > 0)
|
||||
{
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-header bg-white border-0 py-3 d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0 fw-semibold">
|
||||
<i class="bi bi-clock-history me-2 text-primary"></i>Recent Activity
|
||||
</h5>
|
||||
<a asp-action="Activity" asp-route-id="@Model.Id" class="btn btn-sm btn-outline-secondary">
|
||||
View All
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@foreach (var ev in timeline)
|
||||
{
|
||||
var hasLink = ev.LinkController != null && ev.EntityId.HasValue;
|
||||
var rowTag = hasLink ? "a" : "div";
|
||||
var href = hasLink
|
||||
? Url.Action(ev.LinkAction, ev.LinkController, new { id = ev.EntityId })
|
||||
: null;
|
||||
<div class="d-flex align-items-start gap-3 px-3 py-3 border-bottom @(hasLink ? "timeline-row" : "")">
|
||||
<div class="rounded-circle d-flex align-items-center justify-content-center flex-shrink-0 mt-1"
|
||||
style="width:34px;height:34px;background:var(--bs-@(ev.BadgeColor)-bg-subtle,#f0f0f0);">
|
||||
<i class="bi @ev.Icon text-@ev.BadgeColor" style="font-size:0.9rem;"></i>
|
||||
</div>
|
||||
<div class="flex-grow-1 min-width-0">
|
||||
@if (hasLink)
|
||||
{
|
||||
<a asp-controller="@ev.LinkController" asp-action="@ev.LinkAction" asp-route-id="@ev.EntityId"
|
||||
class="fw-semibold text-decoration-none text-body d-block text-truncate">@ev.Title</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="fw-semibold d-block text-truncate">@ev.Title</span>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(ev.Subtitle))
|
||||
{
|
||||
<span class="text-muted small d-block text-truncate">@ev.Subtitle</span>
|
||||
}
|
||||
<span class="text-muted" style="font-size:0.75rem;">@ev.Date.Tz(ViewBag.CompanyTimeZone as string).ToString("MMM dd, yyyy")</span>
|
||||
</div>
|
||||
@if (ev.Amount.HasValue)
|
||||
{
|
||||
<div class="text-end flex-shrink-0">
|
||||
<span class="fw-semibold small">@ev.Amount.Value.ToString("C")</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Right Column - Statistics -->
|
||||
@@ -378,6 +615,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Outstanding Pickups -->
|
||||
@{
|
||||
var pendingPickups = ViewBag.PendingPickups as List<PowderCoating.Core.Entities.Job>;
|
||||
}
|
||||
@if (pendingPickups != null && pendingPickups.Count > 0)
|
||||
{
|
||||
<div class="card border-0 shadow-sm mb-4 border-warning border-opacity-50">
|
||||
<div class="card-header bg-warning bg-opacity-10 border-0 py-3">
|
||||
<h5 class="mb-0 fw-semibold text-warning-emphasis">
|
||||
<i class="bi bi-truck me-2"></i>Ready for Pickup
|
||||
<span class="badge bg-warning text-dark ms-2">@pendingPickups.Count</span>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@foreach (var pickup in pendingPickups)
|
||||
{
|
||||
var daysWaiting = (int)(DateTime.UtcNow - (pickup.UpdatedAt ?? pickup.CreatedAt)).TotalDays;
|
||||
<div class="d-flex align-items-center gap-2 px-3 py-2 border-bottom">
|
||||
<div class="flex-grow-1">
|
||||
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@pickup.Id"
|
||||
class="fw-semibold text-decoration-none small">@pickup.JobNumber</a>
|
||||
@if (!string.IsNullOrEmpty(pickup.Description))
|
||||
{
|
||||
<div class="text-muted text-truncate" style="font-size:0.75rem;max-width:160px;">@pickup.Description</div>
|
||||
}
|
||||
</div>
|
||||
<span class="badge @(daysWaiting >= 7 ? "bg-danger" : daysWaiting >= 3 ? "bg-warning text-dark" : "bg-success")">
|
||||
@(daysWaiting == 0 ? "Today" : $"{daysWaiting}d waiting")
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Store Credit History -->
|
||||
@{
|
||||
var creditMemos = ViewBag.CreditMemos as List<PowderCoating.Core.Entities.CreditMemo>;
|
||||
@@ -430,30 +702,139 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Activity -->
|
||||
<!-- Customer Stats -->
|
||||
@{
|
||||
var crmStats = ViewBag.CrmStats as PowderCoating.Application.DTOs.Customer.CustomerLifetimeStatsDto;
|
||||
}
|
||||
@if (crmStats != null)
|
||||
{
|
||||
<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-clock-history me-2 text-primary"></i>Activity
|
||||
<i class="bi bi-bar-chart-line me-2 text-primary"></i>Customer Stats
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="text-muted small mb-1">Last Contact</label>
|
||||
<p class="mb-0">
|
||||
@if (Model.LastContactDate.HasValue)
|
||||
<!-- Jobs row -->
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-6 text-center p-2" style="border-right:1px solid #dee2e6;">
|
||||
<div class="text-muted small mb-1">Total Jobs</div>
|
||||
<div class="fs-4 fw-bold text-primary">@crmStats.TotalJobs</div>
|
||||
@if (crmStats.ActiveJobs > 0)
|
||||
{
|
||||
<span>@Model.LastContactDate.Value.ToString("MMMM dd, yyyy")</span>
|
||||
<span class="badge bg-success bg-opacity-10 text-success" style="font-size:0.7rem;">
|
||||
@crmStats.ActiveJobs active
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="col-6 text-center p-2">
|
||||
<div class="text-muted small mb-1">Avg Job Value</div>
|
||||
<div class="fs-4 fw-bold">@crmStats.AverageJobValue.ToString("C0")</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-2" />
|
||||
<!-- Revenue row -->
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-6">
|
||||
<div class="text-muted small mb-1">Lifetime Revenue</div>
|
||||
<div class="fw-bold">@crmStats.TotalRevenue.ToString("C")</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="text-muted small mb-1">Total Collected</div>
|
||||
<div class="fw-bold text-success">@crmStats.TotalCollected.ToString("C")</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr class="my-2" />
|
||||
<!-- Footer stats -->
|
||||
<div class="d-flex justify-content-between text-muted small mt-2">
|
||||
<span>
|
||||
@if (crmStats.DaysSinceLastJob.HasValue)
|
||||
{
|
||||
<i class="bi bi-calendar-check me-1"></i>
|
||||
@if (crmStats.DaysSinceLastJob == 0)
|
||||
{
|
||||
<span>Last job today</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">No contact recorded</span>
|
||||
<span>Last job @crmStats.DaysSinceLastJob days ago</span>
|
||||
}
|
||||
</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>No jobs yet</span>
|
||||
}
|
||||
</span>
|
||||
<span>
|
||||
<i class="bi bi-file-text me-1"></i>@crmStats.TotalQuotes quote@(crmStats.TotalQuotes == 1 ? "" : "s")
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-muted small mb-1">Customer Since</label>
|
||||
<p class="mb-0">@Model.CreatedAt.ToString("MMMM dd, yyyy")</p>
|
||||
<div class="text-muted small mt-1">
|
||||
<i class="bi bi-person me-1"></i>Customer since @Model.CreatedAt.ToString("MMM yyyy")
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Preferred Powders -->
|
||||
@{
|
||||
var preferredPowders = ViewBag.PreferredPowders as List<PowderCoating.Core.Entities.CustomerPreferredPowder>;
|
||||
}
|
||||
<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-droplet-fill me-2 text-primary"></i>Preferred Powders
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div id="preferred-powders-list">
|
||||
@if (preferredPowders != null && preferredPowders.Count > 0)
|
||||
{
|
||||
@foreach (var p in preferredPowders)
|
||||
{
|
||||
<div class="d-flex align-items-center gap-2 px-3 py-2 border-bottom" data-powder-id="@p.InventoryItemId">
|
||||
<i class="bi bi-droplet-fill text-primary flex-shrink-0" style="font-size:0.8rem;"></i>
|
||||
<div class="flex-grow-1">
|
||||
<span class="small fw-semibold">@p.InventoryItem.Name</span>
|
||||
@if (!string.IsNullOrEmpty(p.InventoryItem.ColorName))
|
||||
{
|
||||
<span class="text-muted small ms-1">— @p.InventoryItem.ColorName</span>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(p.Notes))
|
||||
{
|
||||
<div class="text-muted" style="font-size:0.75rem;">@p.Notes</div>
|
||||
}
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-link text-danger p-0 flex-shrink-0"
|
||||
onclick="removePreferredPowder(@Model.Id, @p.InventoryItemId)"
|
||||
title="Remove from preferred">×</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div id="no-powders-placeholder" class="px-3 py-2 text-muted small">No preferred powders yet.</div>
|
||||
}
|
||||
</div>
|
||||
<div class="px-3 py-3 border-top bg-light position-relative">
|
||||
<div class="mb-2">
|
||||
<input type="text" id="powderSearchInput" class="form-control form-control-sm"
|
||||
placeholder="Search powder by name or SKU..."
|
||||
oninput="searchInventoryItems(this.value)"
|
||||
autocomplete="off" />
|
||||
<input type="hidden" id="selectedPowderId" />
|
||||
<div id="powderSearchResults" class="dropdown-menu w-100 p-0"
|
||||
style="display:none;position:absolute;z-index:1000;max-height:200px;overflow-y:auto;"></div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<input type="text" id="powderNotes" class="form-control form-control-sm"
|
||||
placeholder="Optional notes (e.g. "customer prefers this for wheels")"
|
||||
maxlength="500" />
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-primary w-100"
|
||||
onclick="addPreferredPowder(@Model.Id)">
|
||||
<i class="bi bi-plus-circle me-1"></i>Add Powder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -482,6 +863,17 @@
|
||||
<a asp-controller="Jobs" asp-action="Create" asp-route-customerId="@Model.Id" class="btn btn-outline-success">
|
||||
<i class="bi bi-plus-circle me-2"></i>New Job
|
||||
</a>
|
||||
@{
|
||||
var crmStatsForActions = ViewBag.CrmStats as PowderCoating.Application.DTOs.Customer.CustomerLifetimeStatsDto;
|
||||
}
|
||||
@if (crmStatsForActions?.LastJobId != null)
|
||||
{
|
||||
<a asp-controller="Jobs" asp-action="CloneJob" asp-route-id="@crmStatsForActions.LastJobId"
|
||||
class="btn btn-outline-secondary"
|
||||
title="Create a new job pre-filled with the last job's items and settings">
|
||||
<i class="bi bi-arrow-repeat me-2"></i>Repeat Last Job
|
||||
</a>
|
||||
}
|
||||
<a asp-controller="Quotes" asp-action="Create" asp-route-customerId="@Model.Id" class="btn btn-outline-info">
|
||||
<i class="bi bi-file-text me-2"></i>New Quote
|
||||
</a>
|
||||
@@ -502,6 +894,72 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add / Edit Contact Modal -->
|
||||
<div class="modal fade" id="contactModal" tabindex="-1" aria-labelledby="contactModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="contactModalLabel">
|
||||
<i class="bi bi-person-plus me-2 text-primary"></i><span id="contactModalTitle">Add Contact</span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="contactId" value="0" />
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label fw-semibold">First Name <span class="text-danger">*</span></label>
|
||||
<input type="text" id="contactFirstName" class="form-control" maxlength="100" placeholder="First name" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Last Name</label>
|
||||
<input type="text" id="contactLastName" class="form-control" maxlength="100" placeholder="Last name" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Job Title</label>
|
||||
<input type="text" id="contactTitle" class="form-control" maxlength="100" placeholder="e.g. Purchasing Manager" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Role</label>
|
||||
<select id="contactRole" class="form-select">
|
||||
<option value="">— Select —</option>
|
||||
<option value="Billing">Billing</option>
|
||||
<option value="Operations">Operations</option>
|
||||
<option value="Drop-Off">Drop-Off</option>
|
||||
<option value="Sales">Sales</option>
|
||||
<option value="General">General</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Email</label>
|
||||
<input type="email" id="contactEmail" class="form-control" maxlength="200" placeholder="email@example.com" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Phone</label>
|
||||
<input type="tel" id="contactPhone" class="form-control" maxlength="20" placeholder="(555) 123-4567" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Mobile Phone</label>
|
||||
<input type="tel" id="contactMobilePhone" class="form-control" maxlength="20" placeholder="(555) 123-4567" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label">Notes</label>
|
||||
<textarea id="contactNotes" class="form-control" rows="2" maxlength="500" placeholder="Optional notes about this contact..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div id="contactModalError" class="alert alert-danger alert-permanent mt-3 d-none"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveContact(@Model.Id)" id="saveContactBtn">
|
||||
<i class="bi bi-check-circle me-1"></i>Save Contact
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Store Credit Modal -->
|
||||
@if (User.IsInRole("SuperAdmin") || User.IsInRole("Administrator"))
|
||||
{
|
||||
|
||||
@@ -213,6 +213,42 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ship-To Address Section -->
|
||||
<div class="mb-4">
|
||||
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
|
||||
<h5 class="mb-0"><i class="bi bi-truck me-2 text-primary"></i>Ship-To / Pickup Address</h5>
|
||||
<a tabindex="0" class="help-icon" role="button"
|
||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||
data-bs-title="Ship-To Address"
|
||||
data-bs-content="Optional. Fill in only if this customer picks up or receives deliveries at a different address than their billing address. Leave blank to use the billing address above.">
|
||||
<i class="bi bi-question-circle"></i>
|
||||
</a>
|
||||
<span class="text-muted small fw-normal">(optional — leave blank if same as billing)</span>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label asp-for="ShipToAddress" class="form-label">Street Address</label>
|
||||
<input asp-for="ShipToAddress" class="form-control" placeholder="Enter ship-to street address" />
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label asp-for="ShipToCity" class="form-label">City</label>
|
||||
<input asp-for="ShipToCity" class="form-control" placeholder="Enter city" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label asp-for="ShipToState" class="form-label">State</label>
|
||||
<input asp-for="ShipToState" class="form-control" placeholder="Enter state" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label asp-for="ShipToZipCode" class="form-label">Zip Code</label>
|
||||
<input asp-for="ShipToZipCode" class="form-control" placeholder="12345" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label asp-for="ShipToCountry" class="form-label">Country</label>
|
||||
<input asp-for="ShipToCountry" class="form-control" placeholder="USA" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Business Information Section -->
|
||||
<div class="mb-4">
|
||||
<div class="d-flex align-items-center gap-2 border-bottom pb-2 mb-3">
|
||||
@@ -270,6 +306,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lead Source Section -->
|
||||
<div class="mb-4">
|
||||
<h5 class="border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-signpost me-2 text-primary"></i>How Did They Find Us?
|
||||
</h5>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label asp-for="LeadSource" class="form-label">Lead Source</label>
|
||||
<select asp-for="LeadSource" class="form-select">
|
||||
<option value="">— Not specified —</option>
|
||||
<option value="Walk-In">Walk-In</option>
|
||||
<option value="Google Search">Google Search</option>
|
||||
<option value="Customer Referral">Customer Referral</option>
|
||||
<option value="Social Media">Social Media</option>
|
||||
<option value="Website">Website</option>
|
||||
<option value="Repeat Customer">Repeat Customer</option>
|
||||
<option value="Trade Show / Event">Trade Show / Event</option>
|
||||
<option value="Flyer / Print Ad">Flyer / Print Ad</option>
|
||||
<option value="Other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes Section -->
|
||||
<div class="mb-4">
|
||||
<h5 class="border-bottom pb-2 mb-3">
|
||||
|
||||
@@ -180,6 +180,13 @@
|
||||
<i class="bi bi-people me-1 text-muted"></i>Customers
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mb-1">
|
||||
<input class="form-check-input sheet-check" type="checkbox" name="sheets"
|
||||
value="CustomerContacts" id="chkCustomerContacts" checked />
|
||||
<label class="form-check-label" for="chkCustomerContacts">
|
||||
<i class="bi bi-person-lines-fill me-1 text-muted"></i>Customer Contacts
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-check mb-1">
|
||||
<input class="form-check-input sheet-check" type="checkbox" name="sheets"
|
||||
value="Jobs" id="chkJobs" checked />
|
||||
|
||||
@@ -136,8 +136,26 @@
|
||||
</p>
|
||||
<p>The details page shows:</p>
|
||||
<ul>
|
||||
<li><strong>Contact information</strong> — name, email, phone, and address.</li>
|
||||
<li><strong>Account summary</strong> — current balance, credit limit, and pricing tier.</li>
|
||||
<li><strong>Contact information</strong> — name, email, phone, address, and lead source.</li>
|
||||
<li><strong>Account summary</strong> — current balance, credit limit, store credit, and pricing tier.</li>
|
||||
<li>
|
||||
<strong>Ready for Pickup</strong> — if any of this customer’s jobs are in “Ready for Pickup” status,
|
||||
a highlighted card appears in the right column showing each job number and how many days it has been waiting.
|
||||
Jobs waiting 3–6 days show in amber; 7+ days in red.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Additional Contacts</strong> — billing contacts, ops contacts, drop-off contacts, and so on.
|
||||
See the Additional Contacts section below.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Internal Notes</strong> — private notes added by your staff (not visible to the customer).
|
||||
Notes can be marked as important <span class="text-warning">★</span> to highlight them for the team.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Preferred Powders</strong> — inventory items this customer frequently uses. Staff can
|
||||
search and add powders here so that anyone creating a quote or job for this customer can quickly
|
||||
see which colors they prefer. See the Preferred Powders section below.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Jobs tab</strong> — every job created for this customer, with status and date. Click
|
||||
a job number to open it.
|
||||
@@ -153,7 +171,7 @@
|
||||
<li>
|
||||
<strong>Deposits tab</strong> — all deposits recorded for this customer across any job or quote.
|
||||
</li>
|
||||
<li><strong>Notes</strong> — any notes saved against the customer record.</li>
|
||||
<li><strong>Recent Activity</strong> — a combined timeline of the last 15 events (jobs, quotes, invoices, deposits) in reverse chronological order.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -195,6 +213,122 @@
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="additional-contacts" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-people text-primary me-2"></i>Additional Contacts
|
||||
</h2>
|
||||
<p>
|
||||
Commercial customers often have more than one person involved in their account — a purchasing
|
||||
manager, a billing contact, or the person who actually drops off and picks up parts. The
|
||||
<strong>Additional Contacts</strong> section on the Customer Details page lets you store all of
|
||||
them in one place so your team always knows who to call.
|
||||
</p>
|
||||
<p>To add a contact, open the Customer Details page and click <strong>Add Contact</strong> in the
|
||||
Additional Contacts card. You can record:</p>
|
||||
<ul class="mb-3">
|
||||
<li><strong>Name</strong> — first and last name.</li>
|
||||
<li><strong>Job Title</strong> — their role at the company (e.g., “Purchasing Manager”).</li>
|
||||
<li><strong>Role</strong> — a category tag: Billing, Operations, Drop-Off, Sales, General, or Other.</li>
|
||||
<li><strong>Email & Phone</strong> — their direct contact details.</li>
|
||||
<li><strong>Notes</strong> — anything else your team should know about this person.</li>
|
||||
</ul>
|
||||
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-0" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
<strong>Notifications always go to the primary contact.</strong> Additional contacts are for
|
||||
staff reference only. All automated emails (job ready for pickup, invoice sent, quote
|
||||
approval links, etc.) and SMS messages are sent to the email address and phone number on the
|
||||
main customer record — not to the contacts listed here. If you need invoices routed to a
|
||||
different address, use the <strong>Billing / Accounting Email</strong> field on the main
|
||||
customer record instead.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="ship-to-address" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-truck text-primary me-2"></i>Ship-To / Pickup Address
|
||||
</h2>
|
||||
<p>
|
||||
Some customers have a different address for pickups or deliveries than their billing address. You
|
||||
can record a separate <strong>Ship-To</strong> address on the Create or Edit form. Leave it blank
|
||||
if the customer picks up from the same address they bill from.
|
||||
</p>
|
||||
<p>
|
||||
When a ship-to address is on file, the Customer Details page splits the Address card into two
|
||||
columns — billing on the left, ship-to on the right — so the difference is immediately visible
|
||||
to anyone looking up the customer.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="lead-source" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-signpost text-primary me-2"></i>Lead Source
|
||||
</h2>
|
||||
<p>
|
||||
The <strong>Lead Source</strong> field lets you record how a customer found your shop. Options
|
||||
include Walk-In, Google Search, Customer Referral, Social Media, Website, Repeat Customer, Trade
|
||||
Show / Event, Flyer / Print Ad, and Other.
|
||||
</p>
|
||||
<p>
|
||||
This field is optional and is shown on the Customer Details page under Business Information. It
|
||||
is useful for understanding which marketing channels are bringing in customers over time.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="preferred-powders" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-droplet-half text-primary me-2"></i>Preferred Powders
|
||||
</h2>
|
||||
<p>
|
||||
The <strong>Preferred Powders</strong> card on the Customer Details page lets you tag inventory
|
||||
items that this customer regularly orders. It is a staff-reference tool — it does not auto-select
|
||||
powders on quotes or jobs, but it gives anyone creating a quote a quick look at what colors this
|
||||
customer has used before.
|
||||
</p>
|
||||
<p>To add a preferred powder:</p>
|
||||
<ol class="mb-3">
|
||||
<li class="mb-1">Open the Customer Details page.</li>
|
||||
<li class="mb-1">In the <strong>Preferred Powders</strong> card, type part of the powder name or SKU into the search box.</li>
|
||||
<li class="mb-1">Select the item from the dropdown and click <strong>Add</strong>.</li>
|
||||
</ol>
|
||||
<p>To remove a preferred powder, click the <strong>×</strong> button next to the item in the list.</p>
|
||||
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
|
||||
<i class="bi bi-info-circle-fill flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
Only items that already exist in your <strong>Inventory</strong> can be added as preferred powders.
|
||||
If a color isn’t appearing in the search, check that it has been added to inventory first.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="outstanding-pickups" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-box-seam text-primary me-2"></i>Outstanding Pickups
|
||||
</h2>
|
||||
<p>
|
||||
When one or more of a customer’s jobs are in <strong>Ready for Pickup</strong> status, a
|
||||
highlighted card appears in the right column of their Customer Details page. This lets your front desk
|
||||
staff immediately see — without opening the Jobs list — whether a customer calling or walking
|
||||
in has finished work waiting for them.
|
||||
</p>
|
||||
<p>The card shows:</p>
|
||||
<ul class="mb-3">
|
||||
<li>The job number (clickable, opens the Job Details page).</li>
|
||||
<li>How many days the job has been waiting in “Ready for Pickup” status.</li>
|
||||
</ul>
|
||||
<p>Color coding helps prioritize follow-up calls:</p>
|
||||
<ul class="mb-3">
|
||||
<li><span class="badge bg-warning text-dark">Amber</span> — waiting 3–6 days.</li>
|
||||
<li><span class="badge bg-danger">Red</span> — waiting 7 or more days.</li>
|
||||
<li>No color — waiting 0–2 days (recently completed).</li>
|
||||
</ul>
|
||||
<p>
|
||||
The card disappears automatically once all jobs for this customer have moved out of
|
||||
“Ready for Pickup” status (e.g., to Delivered).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="deactivating-a-customer" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-person-dash text-primary me-2"></i>Deactivating a Customer
|
||||
@@ -235,6 +369,11 @@
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#customer-details">Customer Details Page</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#credit-limit">Credit Limit</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#tax-exempt">Tax Exempt</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#additional-contacts">Additional Contacts</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#ship-to-address">Ship-To Address</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#lead-source">Lead Source</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#preferred-powders">Preferred Powders</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#outstanding-pickups">Outstanding Pickups</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#deactivating-a-customer">Deactivating a Customer</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -415,7 +415,11 @@
|
||||
An alert banner is shown on the item's Details page prompting you to use Stock Adjustment to add inventory.
|
||||
</li>
|
||||
</ul>
|
||||
<p>Low Stock and Out of Stock items appear in the Inventory Alerts section on the Dashboard and in the Operations Report. Use the <strong>Low Stock</strong> filter on the Inventory list to see only items needing attention.</p>
|
||||
<p>
|
||||
Low Stock and Out of Stock items appear in the Inventory Alerts section on the Dashboard and in the Operations Report.
|
||||
The <strong>Low Stock</strong> stat card at the top of the Inventory page is clickable — click it to instantly
|
||||
filter the list to only items needing attention. Click it again (or clear the filter) to return to the full list.
|
||||
</p>
|
||||
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-0" role="alert">
|
||||
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
|
||||
<div>
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
<li class="mb-2">Select the customer and then select the job this invoice is for.</li>
|
||||
<li class="mb-2">Add or adjust line items as needed.</li>
|
||||
<li class="mb-2">Set the invoice date, due date, and any notes.</li>
|
||||
<li class="mb-2">Optionally enter a <strong>Project Name</strong>. When creating from a job, this pre-fills from the job's project name automatically.</li>
|
||||
<li class="mb-2">Click <strong>Save Invoice</strong>.</li>
|
||||
</ol>
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@
|
||||
<li class="mb-2">Choose a <strong>Priority</strong> — Normal is the default; see the Job Priority section below for all levels.</li>
|
||||
<li class="mb-2">Optionally assign a <strong>Worker</strong> from your shop workers list.</li>
|
||||
<li class="mb-2">Enter the customer's <strong>PO Number</strong> if they require one for their own records.</li>
|
||||
<li class="mb-2">Optionally enter a <strong>Project Name</strong> — a short label that groups related jobs (e.g., “Spring Fleet Refresh”). It appears on the job, its invoice, and printed work orders.</li>
|
||||
<li class="mb-2">Add any <strong>Special Instructions</strong> your team needs to know before starting work.</li>
|
||||
<li class="mb-2">Add one or more <strong>Line Items</strong> describing each piece being coated. See the Job Items section below.</li>
|
||||
<li class="mb-2">
|
||||
@@ -553,6 +554,33 @@
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section id="clone-job" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-copy text-primary me-2"></i>Cloning a Job
|
||||
</h2>
|
||||
<p>
|
||||
If you need to create a new job that is identical or very similar to one you have already done,
|
||||
use the <strong>Clone</strong> button on the Job Details page. This saves you from re-entering
|
||||
all the line items and coatings from scratch.
|
||||
</p>
|
||||
<p>Cloning copies the following to a brand-new Pending job:</p>
|
||||
<ul class="mb-3">
|
||||
<li>Customer and all job settings (description, PO number, project name, special instructions, tags, priority, discount, oven settings)</li>
|
||||
<li>All line items with their coatings, colors, prep services, and pricing</li>
|
||||
</ul>
|
||||
<p>The following are <strong>not</strong> copied:</p>
|
||||
<ul class="mb-3">
|
||||
<li>Scheduled date, due date — you set these on the new job</li>
|
||||
<li>Assigned worker</li>
|
||||
<li>Photos, job notes, and time entries</li>
|
||||
<li>Invoice and payment records</li>
|
||||
</ul>
|
||||
<p>
|
||||
After cloning, the new job opens directly so you can review it, adjust dates, and save.
|
||||
A new unique job number (<code>JOB-YYMM-####</code>) is generated automatically.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section id="shop-display-and-board" class="mb-5">
|
||||
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
|
||||
<i class="bi bi-display text-primary me-2"></i>Shop Display and Priority Board
|
||||
@@ -827,6 +855,7 @@
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#photos-notes">Photos and Notes</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#time-and-rework">Time Entries and Rework</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#job-templates">Job Templates</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#clone-job">Cloning a Job</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#shop-display-and-board">Shop Display & Priority Board</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#part-intake">Part Intake</a>
|
||||
<a class="nav-link py-1 px-3 small text-body" href="#shop-mobile">Shop Mobile</a>
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
</li>
|
||||
<li class="mb-2">Set the <strong>Quote Date</strong> (defaults to today) and the <strong>Expiry Date</strong> (defaults to the system's configured validity period).</li>
|
||||
<li class="mb-2">Add a <strong>Subject</strong> or description to identify the work being quoted.</li>
|
||||
<li class="mb-2">Optionally enter a <strong>Project Name</strong> — a short label that groups related work (e.g., “Fleet Refresh Q2”). It appears on the quote PDF and carries over to the job and invoice when converted.</li>
|
||||
<li class="mb-2">Add one or more <strong>Line Items</strong> — see the Quote Items section below for item types.</li>
|
||||
<li class="mb-2">Add any <strong>Notes</strong> for the customer (these appear on the printed quote).</li>
|
||||
<li class="mb-2">Add any internal <strong>Notes</strong> that are for your team only.</li>
|
||||
|
||||
@@ -44,11 +44,21 @@
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<a asp-action="Index" asp-route-lowStockOnly="true" class="text-decoration-none"
|
||||
title="Click to filter list to low stock items">
|
||||
@{ var _lowStockActive = (bool)(ViewBag.LowStockOnly ?? false); }
|
||||
<div class="card border-0 shadow-sm @(_lowStockActive ? "border-danger border" : "")"
|
||||
style="cursor:pointer;transition:box-shadow .15s;">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<p class="text-muted mb-1" style="font-size: 0.875rem;">Low Stock Items</p>
|
||||
<p class="text-muted mb-1" style="font-size: 0.875rem;">
|
||||
Low Stock Items
|
||||
@if (lowStockCount > 0)
|
||||
{
|
||||
<i class="bi bi-funnel-fill ms-1 text-danger" style="font-size:.7rem;" title="Click to filter"></i>
|
||||
}
|
||||
</p>
|
||||
<h3 class="mb-0 fw-bold @(lowStockCount > 0 ? "text-danger" : "")">@lowStockCount</h3>
|
||||
</div>
|
||||
<div class="rounded-circle p-3" style="background: #fee2e2;">
|
||||
@@ -57,6 +67,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3">
|
||||
@@ -102,11 +113,13 @@
|
||||
<div class="stat-value">@Model.TotalCount</div>
|
||||
<div class="stat-label">Total</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<a asp-action="Index" asp-route-lowStockOnly="true" class="text-decoration-none">
|
||||
<div class="stat-item" style="cursor:pointer;">
|
||||
<div class="stat-icon"><i class="bi bi-exclamation-triangle text-danger"></i></div>
|
||||
<div class="stat-value @(lowStockCount > 0 ? "text-danger" : "")">@lowStockCount</div>
|
||||
<div class="stat-label">Low Stock</div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="stat-item">
|
||||
<div class="stat-icon"><i class="bi bi-check-circle text-success"></i></div>
|
||||
<div class="stat-value">@activeCount</div>
|
||||
|
||||
@@ -101,15 +101,6 @@
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.label-scan-hint {
|
||||
font-size: 9px;
|
||||
color: #888;
|
||||
text-align: center;
|
||||
border-top: 1px dashed #ccc;
|
||||
padding-top: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ── Print styles ───────────────────────────────────── */
|
||||
@@media print {
|
||||
body { background: #fff; padding: 0; }
|
||||
@@ -138,7 +129,7 @@
|
||||
}
|
||||
|
||||
<div class="label-card">
|
||||
<div class="label-logo">Powder Coating Logix</div>
|
||||
<div class="label-logo">Powder Coating Logix • Scan to log usage</div>
|
||||
|
||||
<div class="label-qr">
|
||||
<img src="/Inventory/QrCode/@Model.Id?size=8" alt="QR Code for @Model.Name" />
|
||||
@@ -148,30 +139,24 @@
|
||||
|
||||
<div class="label-sku">SKU: @Model.SKU</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.ColorName))
|
||||
@if (!string.IsNullOrEmpty(Model.ColorName) && !string.Equals(Model.ColorName, Model.Name, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
<div class="label-color">
|
||||
@Model.ColorName
|
||||
@if (!string.IsNullOrEmpty(Model.Finish))
|
||||
@if (!string.IsNullOrEmpty(Model.Finish) && Model.Name.IndexOf(Model.Finish, StringComparison.OrdinalIgnoreCase) < 0)
|
||||
{
|
||||
<span> — @Model.Finish</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.Manufacturer))
|
||||
{
|
||||
<div class="label-sku" style="color:#777">@Model.Manufacturer</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(Model.Location))
|
||||
{
|
||||
<div class="label-location">Location: @Model.Location</div>
|
||||
}
|
||||
|
||||
<div class="label-scan-hint">
|
||||
Scan to log usage • Powder Coating Logix
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
|
||||
@@ -170,6 +170,12 @@
|
||||
<input asp-for="CustomerPO" class="form-control" placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-12">
|
||||
<label asp-for="ProjectName" class="form-label fw-semibold mb-0">Project Name</label>
|
||||
<input asp-for="ProjectName" class="form-control" placeholder="Optional — prints on invoice" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-12">
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
var isVoided = Model.Status == InvoiceStatus.Voided || Model.Status == InvoiceStatus.WrittenOff;
|
||||
var canEdit = Model.Status is InvoiceStatus.Draft or InvoiceStatus.Sent or InvoiceStatus.Overdue;
|
||||
var canPay = !isVoided && Model.BalanceDue > 0;
|
||||
var canResend = !isDraft && !isVoided && Model.Status != InvoiceStatus.Paid;
|
||||
var canResend = !isDraft && !isVoided;
|
||||
var hasEmail = !string.IsNullOrWhiteSpace(Model.CustomerEmail);
|
||||
var emailOptedOut = hasEmail && !Model.CustomerNotifyByEmail;
|
||||
var smsPhone = !string.IsNullOrWhiteSpace(Model.CustomerMobilePhone) ? Model.CustomerMobilePhone : Model.CustomerPhone;
|
||||
@@ -193,6 +193,13 @@
|
||||
<p class="mb-0">@Model.CustomerPO</p>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(Model.ProjectName))
|
||||
{
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small mb-1">Project Name</label>
|
||||
<p class="mb-0">@Model.ProjectName</p>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(Model.ExternalReference))
|
||||
{
|
||||
<div class="col-md-6">
|
||||
@@ -647,23 +654,43 @@
|
||||
</a>
|
||||
@if (canResend)
|
||||
{
|
||||
@if (!hasEmail)
|
||||
@if (hasEmail && !emailOptedOut && hasSms)
|
||||
{
|
||||
@* Both email + SMS — channel choice modal *@
|
||||
<button type="button" class="btn btn-outline-primary"
|
||||
data-bs-toggle="modal" data-bs-target="#sendToAdHocEmailModal">
|
||||
<i class="bi bi-send me-2"></i>Send Invoice
|
||||
data-bs-toggle="modal" data-bs-target="#resendChannelModal">
|
||||
<i class="bi bi-send me-2"></i>Re-send Invoice
|
||||
</button>
|
||||
}
|
||||
else if (emailOptedOut)
|
||||
else if (hasSms && (!hasEmail || emailOptedOut))
|
||||
{
|
||||
<button type="button" class="btn btn-outline-primary" disabled
|
||||
title="Email notifications are turned off for this customer">
|
||||
@* SMS only *@
|
||||
<button type="button" class="btn btn-outline-primary"
|
||||
onclick="resendInvoice(@Model.Id, null, false, true)">
|
||||
<i class="bi bi-phone me-2"></i>Re-send via SMS
|
||||
</button>
|
||||
}
|
||||
else if (hasEmail && !emailOptedOut)
|
||||
{
|
||||
@* Email only *@
|
||||
<button type="button" class="btn btn-outline-primary"
|
||||
onclick="resendInvoice(@Model.Id)">
|
||||
<i class="bi bi-send me-2"></i>Re-send Invoice
|
||||
</button>
|
||||
}
|
||||
else if (!hasEmail)
|
||||
{
|
||||
@* No email on file — let staff enter one *@
|
||||
<button type="button" class="btn btn-outline-primary"
|
||||
data-bs-toggle="modal" data-bs-target="#sendToAdHocEmailModal">
|
||||
<i class="bi bi-send me-2"></i>Re-send Invoice
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button type="button" class="btn btn-outline-primary" onclick="resendInvoice(@Model.Id)">
|
||||
@* Email opted out, no SMS *@
|
||||
<button type="button" class="btn btn-outline-primary" disabled
|
||||
title="Email notifications are turned off for this customer and no mobile number is on file">
|
||||
<i class="bi bi-send me-2"></i>Re-send Invoice
|
||||
</button>
|
||||
}
|
||||
@@ -1131,6 +1158,46 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Re-send Channel Choice Modal (email opted-in + SMS both available) -->
|
||||
@if (canResend && hasEmail && !emailOptedOut && hasSms)
|
||||
{
|
||||
<div class="modal fade" id="resendChannelModal" tabindex="-1" aria-labelledby="resendChannelModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header border-0 pb-0">
|
||||
<h5 class="modal-title" id="resendChannelModalLabel">
|
||||
<i class="bi bi-send text-primary me-2"></i>Re-send Invoice
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body pt-2">
|
||||
<p class="mb-3">How would you like to re-send <strong>@Model.InvoiceNumber</strong> to <strong>@Model.CustomerName</strong>?</p>
|
||||
<div class="d-grid gap-2">
|
||||
<button type="button" class="btn btn-outline-primary text-start"
|
||||
onclick="resendInvoice(@Model.Id, null, true, false)" data-bs-dismiss="modal">
|
||||
<i class="bi bi-envelope me-2"></i>Email only
|
||||
<small class="d-block text-muted ms-4">PDF attached · @Model.CustomerEmail</small>
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-primary text-start"
|
||||
onclick="resendInvoice(@Model.Id, null, false, true)" data-bs-dismiss="modal">
|
||||
<i class="bi bi-phone me-2"></i>SMS only
|
||||
<small class="d-block text-muted ms-4">View link · @smsPhone</small>
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary text-start"
|
||||
onclick="resendInvoice(@Model.Id, null, true, true)" data-bs-dismiss="modal">
|
||||
<i class="bi bi-send me-2"></i>Both Email & SMS
|
||||
<small class="d-block text-muted ms-4">PDF via email + view link via SMS</small>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer border-0 pt-0">
|
||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Notifications Sent Modal -->
|
||||
<div class="modal fade" id="invoiceNotificationsModal" tabindex="-1" aria-labelledby="invoiceNotificationsModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
@@ -1530,7 +1597,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function resendInvoice(invoiceId, overrideEmail) {
|
||||
function resendInvoice(invoiceId, overrideEmail, sendEmail = true, sendSms = false) {
|
||||
document.getElementById('resendInvoiceSending').classList.remove('d-none');
|
||||
document.getElementById('resendInvoiceResult').classList.add('d-none');
|
||||
document.getElementById('resendInvoiceFooter').classList.add('d-none');
|
||||
@@ -1542,6 +1609,8 @@
|
||||
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
let url = '@Url.Action("ResendInvoice", "Invoices")?id=' + invoiceId;
|
||||
if (overrideEmail) url += '&overrideEmail=' + encodeURIComponent(overrideEmail);
|
||||
url += '&sendEmail=' + (sendEmail ? 'true' : 'false');
|
||||
url += '&sendSms=' + (sendSms ? 'true' : 'false');
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
@@ -1560,11 +1629,11 @@
|
||||
if (data.success) {
|
||||
icon.className = 'bi bi-check-circle-fill text-success fs-1 d-block mb-3';
|
||||
header.className = 'modal-header bg-success text-white';
|
||||
showInfo(data.message, 'Email Sent');
|
||||
showInfo(data.message, 'Invoice Sent');
|
||||
} else {
|
||||
icon.className = 'bi bi-x-circle-fill text-danger fs-1 d-block mb-3';
|
||||
header.className = 'modal-header bg-danger text-white';
|
||||
showWarning(data.message, 'Email Not Sent');
|
||||
showWarning(data.message, 'Send Failed');
|
||||
}
|
||||
msg.textContent = data.message;
|
||||
})
|
||||
|
||||
@@ -62,6 +62,12 @@
|
||||
<input asp-for="CustomerPO" class="form-control" placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-12">
|
||||
<label asp-for="ProjectName" class="form-label fw-semibold">Project Name</label>
|
||||
<input asp-for="ProjectName" class="form-control" placeholder="Optional — prints on invoice" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mt-1">
|
||||
<div class="col-md-12">
|
||||
<label asp-for="Terms" class="form-label fw-semibold">Payment Terms</label>
|
||||
|
||||
@@ -124,6 +124,10 @@
|
||||
</div>
|
||||
<input asp-for="CustomerPO" class="form-control" placeholder="Enter PO number" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="ProjectName" class="form-label">Project Name</label>
|
||||
<input asp-for="ProjectName" class="form-control" placeholder="e.g. Kitchen Remodel, Fleet Vehicle #3…" />
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<label asp-for="SpecialInstructions" class="form-label mb-0">Special Instructions</label>
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
title="Save this job as a reusable template">
|
||||
<i class="bi bi-layout-text-window-reverse me-2"></i>Save as Template
|
||||
</button>
|
||||
<a asp-action="CloneJob" asp-route-id="@Model.Id" class="btn btn-outline-secondary"
|
||||
title="Create a new job pre-filled with this job's items and settings">
|
||||
<i class="bi bi-arrow-repeat me-2"></i>Clone Job
|
||||
</a>
|
||||
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-warning">
|
||||
<i class="bi bi-pencil me-2"></i>Edit
|
||||
</a>
|
||||
@@ -172,6 +176,13 @@
|
||||
<label class="text-muted small mb-1">Customer PO</label>
|
||||
<p class="mb-0">@(Model.CustomerPO ?? "Not provided")</p>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(Model.ProjectName))
|
||||
{
|
||||
<div class="col-md-6">
|
||||
<label class="text-muted small mb-1">Project</label>
|
||||
<p class="mb-0">@Model.ProjectName</p>
|
||||
</div>
|
||||
}
|
||||
<div class="col-12">
|
||||
<label class="text-muted small mb-1">Description</label>
|
||||
<p class="mb-0">@Model.Description</p>
|
||||
|
||||
@@ -101,6 +101,10 @@
|
||||
<label asp-for="CustomerPO" class="form-label">Customer PO</label>
|
||||
<input asp-for="CustomerPO" class="form-control" placeholder="Enter PO number" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label asp-for="ProjectName" class="form-label">Project Name</label>
|
||||
<input asp-for="ProjectName" class="form-control" placeholder="e.g. Kitchen Remodel, Fleet Vehicle #3…" />
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<label asp-for="SpecialInstructions" class="form-label">Special Instructions</label>
|
||||
<textarea asp-for="SpecialInstructions" class="form-control" rows="3" placeholder="Any special instructions"></textarea>
|
||||
|
||||
@@ -147,7 +147,7 @@
|
||||
<a href="@Url.Action("Index", new { statusGroup = "overdue" })" class="pcl-pill @(_activeGroup == "overdue" ? "active" : "")">
|
||||
Overdue <span class="pcl-pill-count">@_overdue</span>
|
||||
</a>
|
||||
<a href="@Url.Action("Index", new { searchTerm = "ReadyForPickup" })" class="pcl-pill @(_activeSearch == "ReadyForPickup" ? "active" : "")">
|
||||
<a href="@Url.Action("Index", new { statusGroup = "ready" })" class="pcl-pill @(_activeGroup == "ready" ? "active" : "")">
|
||||
Ready <span class="pcl-pill-count">@_ready</span>
|
||||
</a>
|
||||
<a href="@Url.Action("Index", new { statusGroup = "completed" })" class="pcl-pill @(_activeGroup == "completed" ? "active" : "")">
|
||||
@@ -162,10 +162,20 @@
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
|
||||
<h5 class="mt-3 text-muted">No jobs found</h5>
|
||||
<p class="text-muted mb-4">Get started by creating your first job</p>
|
||||
@if (_allCount > 0)
|
||||
{
|
||||
<p class="text-muted mb-4">No jobs match your current filter.</p>
|
||||
<a href="@Url.Action("Index", new { statusGroup = "active" })" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-circle me-2"></i>Clear Filters
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted mb-4">Get started by creating your first job.</p>
|
||||
<a asp-action="Create" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-2"></i>Create Your First Job
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
@@ -328,10 +338,20 @@
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
|
||||
<h5 class="mt-3 text-muted">No jobs found</h5>
|
||||
<p class="text-muted mb-4">Get started by creating your first job</p>
|
||||
@if (_allCount > 0)
|
||||
{
|
||||
<p class="text-muted mb-4">No jobs match your current filter.</p>
|
||||
<a href="@Url.Action("Index", new { statusGroup = "active" })" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-x-circle me-2"></i>Clear Filters
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted mb-4">Get started by creating your first job.</p>
|
||||
<a asp-action="Create" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-2"></i>Create Your First Job
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
|
||||
@@ -357,6 +357,13 @@
|
||||
<div class="info-value">@Model.CustomerPO</div>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(Model.ProjectName))
|
||||
{
|
||||
<div class="info-row">
|
||||
<div class="info-label">Project</div>
|
||||
<div class="info-value">@Model.ProjectName</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="section-title">
|
||||
|
||||
@@ -187,6 +187,12 @@
|
||||
<input asp-for="CustomerPO" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<div class="col-md-6">
|
||||
<label asp-for="ProjectName" class="form-label"></label>
|
||||
<input asp-for="ProjectName" class="form-control" placeholder="e.g. Kitchen Remodel, Fleet Vehicle #3…" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<div class="col-md-6">
|
||||
<label asp-for="Notes" class="form-label"></label>
|
||||
|
||||
@@ -183,6 +183,10 @@
|
||||
{
|
||||
<p><strong>Customer PO:</strong> @Model.CustomerPO</p>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Model.ProjectName))
|
||||
{
|
||||
<p><strong>Project:</strong> @Model.ProjectName</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(Model.Description))
|
||||
|
||||
@@ -150,6 +150,12 @@
|
||||
<input asp-for="CustomerPO" class="form-control" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<div class="col-md-6">
|
||||
<label asp-for="ProjectName" class="form-label"></label>
|
||||
<input asp-for="ProjectName" class="form-control" placeholder="e.g. Kitchen Remodel, Fleet Vehicle #3…" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<div class="col-md-6">
|
||||
<label asp-for="Notes" class="form-label"></label>
|
||||
|
||||
@@ -294,9 +294,9 @@
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-file-text" style="font-size: 4rem; color: #ccc;"></i>
|
||||
<p class="text-muted mt-3">No quotes found.</p>
|
||||
@if (!string.IsNullOrEmpty(searchTerm) || statusFilter.HasValue)
|
||||
@if (!string.IsNullOrEmpty(searchTerm) || statusFilter.HasValue || !string.IsNullOrEmpty(statusCode) || !string.IsNullOrEmpty(ViewBag.TagFilter as string))
|
||||
{
|
||||
<p class="text-muted">Try adjusting your filters or <a asp-action="Index" asp-controller="Quotes">view all quotes</a>.</p>
|
||||
<p class="text-muted">No quotes match your current filter. <a asp-action="Index" asp-controller="Quotes">View all quotes</a>.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -98,6 +98,44 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Demo Reset Card — only shown when the DEMO company exists -->
|
||||
@{
|
||||
var demoCompany = Model?.FirstOrDefault(c => c.CompanyCode == "DEMO");
|
||||
}
|
||||
@if (demoCompany != null)
|
||||
{
|
||||
<div class="card mb-4 border-danger">
|
||||
<div class="card-header bg-danger text-white d-flex align-items-center gap-2">
|
||||
<i class="bi bi-arrow-repeat fs-5"></i>
|
||||
<h5 class="mb-0">Reset Demo Company</h5>
|
||||
<span class="badge bg-white text-danger ms-auto">Tutorial Prep</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-text">
|
||||
Wipes <strong>all seeded data</strong> from the Demo Company and immediately re-seeds it with
|
||||
fresh records dated relative to <strong>today</strong>. Use this before every recording session
|
||||
so jobs, quotes, invoices, and AR aging always look current.
|
||||
</p>
|
||||
<ul class="mb-3 small">
|
||||
<li>Removes: customers, jobs, quotes, invoices, inventory, equipment, catalog, pricing tiers, operating costs, vendor bills, expenses</li>
|
||||
<li>Re-seeds: 100 customers, 50 jobs across all statuses, quotes, invoices, inventory transactions, vendor bills, appointments — all dated from today</li>
|
||||
<li>Preserves: user accounts, company settings, lookup tables (job statuses, priorities, etc.)</li>
|
||||
</ul>
|
||||
<div class="alert alert-warning alert-permanent mb-3">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
<strong>This permanently deletes and recreates all demo data.</strong> Any manual edits made to the demo company will be lost.
|
||||
</div>
|
||||
<form asp-action="ResetDemoCompany" method="post"
|
||||
onsubmit="return confirm('Reset the Demo Company?\n\nThis will DELETE all seeded data and re-seed it with fresh records dated today.\n\nAny manual edits to the demo company will be lost.');">
|
||||
@Html.AntiForgeryToken()
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="bi bi-arrow-repeat me-2"></i>Reset Demo Company Now
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- System Data Card -->
|
||||
<div class="card mb-4 border-primary">
|
||||
<div class="card-header bg-primary text-white">
|
||||
|
||||
@@ -45,7 +45,8 @@
|
||||
"SendGrid": {
|
||||
"ApiKey": "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",
|
||||
"FromEmail": "spouliot@scppowdercoating.com",
|
||||
"FromName": "Powder Coating App Staff"
|
||||
"FromName": "Powder Coating App Staff",
|
||||
"DevRedirectEmail": "spouliot@scppowdercoating.com"
|
||||
},
|
||||
"Twilio": {
|
||||
"AccountSid": "your-twilio-account-sid",
|
||||
|
||||
@@ -38,6 +38,278 @@ async function cancelSmsConsent() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Customer Notes ────────────────────────────────────────────────────────────
|
||||
|
||||
async function addCustomerNote(customerId) {
|
||||
const textarea = document.getElementById('newNoteText');
|
||||
const importantCb = document.getElementById('newNoteImportant');
|
||||
const note = textarea?.value?.trim();
|
||||
if (!note) { toastr.warning('Please enter a note.'); return; }
|
||||
|
||||
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
try {
|
||||
const res = await fetch(`/Customers/AddCustomerNote/${customerId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tok },
|
||||
body: `note=${encodeURIComponent(note)}&isImportant=${importantCb?.checked ?? false}`
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
const list = document.getElementById('customer-notes-list');
|
||||
const placeholder = document.getElementById('no-notes-placeholder');
|
||||
if (placeholder) placeholder.remove();
|
||||
list.insertAdjacentHTML('afterbegin', data.noteHtml);
|
||||
textarea.value = '';
|
||||
if (importantCb) importantCb.checked = false;
|
||||
toastr.success('Note added.');
|
||||
} else {
|
||||
toastr.error(data.message || 'Could not add note.');
|
||||
}
|
||||
} catch {
|
||||
toastr.error('An error occurred. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCustomerNote(customerId, noteId) {
|
||||
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
try {
|
||||
const res = await fetch(`/Customers/DeleteCustomerNote/${customerId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tok },
|
||||
body: `noteId=${noteId}`
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
document.querySelector(`[data-note-id="${noteId}"]`)?.remove();
|
||||
const list = document.getElementById('customer-notes-list');
|
||||
if (list && list.querySelectorAll('.customer-note-item').length === 0)
|
||||
list.insertAdjacentHTML('afterbegin', '<div id="no-notes-placeholder" class="px-3 py-2 text-muted small">No notes yet.</div>');
|
||||
} else {
|
||||
toastr.error(data.message || 'Could not delete note.');
|
||||
}
|
||||
} catch {
|
||||
toastr.error('An error occurred. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Preferred Powders ─────────────────────────────────────────────────────────
|
||||
|
||||
let _powderSearchTimer = null;
|
||||
|
||||
function searchInventoryItems(term) {
|
||||
clearTimeout(_powderSearchTimer);
|
||||
const dropdown = document.getElementById('powderSearchResults');
|
||||
if (!term || term.length < 2) {
|
||||
if (dropdown) { dropdown.innerHTML = ''; dropdown.style.display = 'none'; }
|
||||
return;
|
||||
}
|
||||
|
||||
_powderSearchTimer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch(`/Customers/SearchInventoryItems?term=${encodeURIComponent(term)}`);
|
||||
const data = await res.json();
|
||||
if (!dropdown) return;
|
||||
dropdown.innerHTML = data.length === 0
|
||||
? '<div class="dropdown-item text-muted small">No results</div>'
|
||||
: data.map(i => { const label = JSON.stringify(i.name + (i.colorName ? ' — ' + i.colorName : '')).replace(/"/g, '"'); return `<button type="button" class="dropdown-item small"
|
||||
onclick="selectPowder(${i.id}, ${label})">${i.name}${i.colorName ? ' <span class=\'text-muted\'>' + i.colorName + '</span>' : ''} <span class="badge bg-light text-muted border">${i.sku ?? ''}</span></button>`; }).join('');
|
||||
dropdown.style.display = 'block';
|
||||
} catch { /* silent */ }
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function selectPowder(itemId, label) {
|
||||
document.getElementById('selectedPowderId').value = itemId;
|
||||
document.getElementById('powderSearchInput').value = label;
|
||||
const dropdown = document.getElementById('powderSearchResults');
|
||||
if (dropdown) { dropdown.innerHTML = ''; dropdown.style.display = 'none'; }
|
||||
}
|
||||
|
||||
async function addPreferredPowder(customerId) {
|
||||
const itemId = document.getElementById('selectedPowderId')?.value;
|
||||
const notes = document.getElementById('powderNotes')?.value?.trim() ?? '';
|
||||
if (!itemId) { toastr.warning('Please search for and select a powder first.'); return; }
|
||||
|
||||
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
try {
|
||||
const res = await fetch(`/Customers/AddPreferredPowder/${customerId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tok },
|
||||
body: `inventoryItemId=${itemId}¬es=${encodeURIComponent(notes)}`
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
const list = document.getElementById('preferred-powders-list');
|
||||
const placeholder = document.getElementById('no-powders-placeholder');
|
||||
if (placeholder) placeholder.remove();
|
||||
const notesHtml = data.notes ? `<div class="text-muted" style="font-size:0.75rem;">${data.notes}</div>` : '';
|
||||
list.insertAdjacentHTML('beforeend',
|
||||
`<div class="d-flex align-items-center gap-2 px-3 py-2 border-bottom" data-powder-id="${data.itemId}">
|
||||
<i class="bi bi-droplet-fill text-primary flex-shrink-0" style="font-size:0.8rem;"></i>
|
||||
<div class="flex-grow-1"><span class="small fw-semibold">${data.itemName}</span>${notesHtml}</div>
|
||||
<button type="button" class="btn btn-sm btn-link text-danger p-0"
|
||||
onclick="removePreferredPowder(${customerId}, ${data.itemId})" title="Remove">×</button>
|
||||
</div>`);
|
||||
document.getElementById('powderSearchInput').value = '';
|
||||
document.getElementById('selectedPowderId').value = '';
|
||||
if (document.getElementById('powderNotes')) document.getElementById('powderNotes').value = '';
|
||||
toastr.success(`${data.itemName} added to preferred powders.`);
|
||||
} else {
|
||||
toastr.warning(data.message || 'Could not add powder.');
|
||||
}
|
||||
} catch {
|
||||
toastr.error('An error occurred. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
async function removePreferredPowder(customerId, itemId) {
|
||||
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
try {
|
||||
const res = await fetch(`/Customers/RemovePreferredPowder/${customerId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tok },
|
||||
body: `itemId=${itemId}`
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
document.querySelector(`[data-powder-id="${itemId}"]`)?.remove();
|
||||
const list = document.getElementById('preferred-powders-list');
|
||||
if (list && list.querySelectorAll('[data-powder-id]').length === 0)
|
||||
list.insertAdjacentHTML('afterbegin', '<div id="no-powders-placeholder" class="px-3 py-2 text-muted small">No preferred powders yet.</div>');
|
||||
} else {
|
||||
toastr.error(data.message || 'Could not remove powder.');
|
||||
}
|
||||
} catch {
|
||||
toastr.error('An error occurred. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Customer Contacts ──────────────────────────────────────────────────────
|
||||
|
||||
function openAddContactModal() {
|
||||
document.getElementById('contactId').value = '0';
|
||||
document.getElementById('contactModalTitle').textContent = 'Add Contact';
|
||||
document.getElementById('contactFirstName').value = '';
|
||||
document.getElementById('contactLastName').value = '';
|
||||
document.getElementById('contactTitle').value = '';
|
||||
document.getElementById('contactRole').value = '';
|
||||
document.getElementById('contactEmail').value = '';
|
||||
document.getElementById('contactPhone').value = '';
|
||||
document.getElementById('contactMobilePhone').value = '';
|
||||
document.getElementById('contactNotes').value = '';
|
||||
document.getElementById('contactModalError').classList.add('d-none');
|
||||
}
|
||||
|
||||
async function editContact(customerId, contactId) {
|
||||
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
try {
|
||||
const res = await fetch(`/Customers/GetContact/${customerId}?contactId=${contactId}`);
|
||||
const data = await res.json();
|
||||
if (!data.success) { toastr.error('Could not load contact.'); return; }
|
||||
|
||||
const c = data.contact;
|
||||
document.getElementById('contactId').value = c.id;
|
||||
document.getElementById('contactModalTitle').textContent = 'Edit Contact';
|
||||
document.getElementById('contactFirstName').value = c.firstName ?? '';
|
||||
document.getElementById('contactLastName').value = c.lastName ?? '';
|
||||
document.getElementById('contactTitle').value = c.title ?? '';
|
||||
document.getElementById('contactRole').value = c.contactRole ?? '';
|
||||
document.getElementById('contactEmail').value = c.email ?? '';
|
||||
document.getElementById('contactPhone').value = c.phone ?? '';
|
||||
document.getElementById('contactMobilePhone').value = c.mobilePhone ?? '';
|
||||
document.getElementById('contactNotes').value = c.notes ?? '';
|
||||
document.getElementById('contactModalError').classList.add('d-none');
|
||||
|
||||
new bootstrap.Modal(document.getElementById('contactModal')).show();
|
||||
} catch {
|
||||
toastr.error('An error occurred loading the contact.');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveContact(customerId) {
|
||||
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
const contactId = parseInt(document.getElementById('contactId').value ?? '0', 10);
|
||||
const firstName = document.getElementById('contactFirstName').value.trim();
|
||||
|
||||
if (!firstName) {
|
||||
const err = document.getElementById('contactModalError');
|
||||
err.textContent = 'First name is required.';
|
||||
err.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
FirstName: firstName,
|
||||
LastName: document.getElementById('contactLastName').value.trim(),
|
||||
Title: document.getElementById('contactTitle').value.trim(),
|
||||
ContactRole: document.getElementById('contactRole').value,
|
||||
Email: document.getElementById('contactEmail').value.trim(),
|
||||
Phone: document.getElementById('contactPhone').value.trim(),
|
||||
MobilePhone: document.getElementById('contactMobilePhone').value.trim(),
|
||||
Notes: document.getElementById('contactNotes').value.trim(),
|
||||
});
|
||||
|
||||
const isEdit = contactId > 0;
|
||||
if (isEdit) { params.append('Id', contactId); params.append('CustomerId', customerId); }
|
||||
|
||||
const url = isEdit ? `/Customers/UpdateContact/${customerId}` : `/Customers/AddContact/${customerId}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tok },
|
||||
body: params.toString()
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (data.success) {
|
||||
bootstrap.Modal.getInstance(document.getElementById('contactModal'))?.hide();
|
||||
const tbody = document.getElementById('contacts-table-body');
|
||||
const placeholder = document.getElementById('no-contacts-placeholder');
|
||||
if (placeholder) placeholder.remove();
|
||||
|
||||
if (isEdit) {
|
||||
const existing = tbody.querySelector(`tr[data-contact-id="${contactId}"]`);
|
||||
if (existing) existing.outerHTML = data.rowHtml;
|
||||
else tbody.insertAdjacentHTML('beforeend', data.rowHtml);
|
||||
} else {
|
||||
tbody.insertAdjacentHTML('beforeend', data.rowHtml);
|
||||
}
|
||||
toastr.success(isEdit ? 'Contact updated.' : 'Contact added.');
|
||||
} else {
|
||||
const err = document.getElementById('contactModalError');
|
||||
err.textContent = data.message || 'An error occurred.';
|
||||
err.classList.remove('d-none');
|
||||
}
|
||||
} catch {
|
||||
toastr.error('An error occurred. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteContact(customerId, contactId) {
|
||||
if (!confirm('Delete this contact?')) return;
|
||||
const tok = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
|
||||
try {
|
||||
const res = await fetch(`/Customers/DeleteContact/${customerId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tok },
|
||||
body: `contactId=${contactId}`
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
document.querySelector(`tr[data-contact-id="${contactId}"]`)?.remove();
|
||||
const tbody = document.getElementById('contacts-table-body');
|
||||
if (tbody && tbody.querySelectorAll('tr[data-contact-id]').length === 0)
|
||||
tbody.insertAdjacentHTML('afterbegin', '<tr id="no-contacts-placeholder"><td colspan="4" class="text-muted small px-3 py-2">No additional contacts.</td></tr>');
|
||||
toastr.success('Contact deleted.');
|
||||
} else {
|
||||
toastr.error(data.message || 'Could not delete contact.');
|
||||
}
|
||||
} catch {
|
||||
toastr.error('An error occurred. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
window.updateCustomerSmsStatus = function () {
|
||||
const section = document.getElementById('sms-status-section');
|
||||
if (!section) return;
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
using AutoMapper;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Moq;
|
||||
using PowderCoating.Application.DTOs.Customer;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Infrastructure.Repositories;
|
||||
using PowderCoating.Web.Controllers;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace PowderCoating.UnitTests;
|
||||
|
||||
public class CustomersControllerCrmTests
|
||||
{
|
||||
// ── Details — guard ───────────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Details_WhenCustomerNotFound_ReturnsNotFound()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
var controller = CreateController(context, companyId: 1);
|
||||
|
||||
var result = await controller.Details(id: 999);
|
||||
|
||||
Assert.IsType<NotFoundResult>(result);
|
||||
}
|
||||
|
||||
// ── Details — zero-history customer ───────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Details_WithNoHistory_ProducesZeroStats()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedCustomer(context, id: 1, companyId: 1);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(context, companyId: 1);
|
||||
var result = await controller.Details(id: 1);
|
||||
|
||||
var view = Assert.IsType<ViewResult>(result);
|
||||
var stats = view.ViewData["CrmStats"] as CustomerLifetimeStatsDto;
|
||||
|
||||
Assert.NotNull(stats);
|
||||
Assert.Equal(0, stats.TotalJobs);
|
||||
Assert.Equal(0, stats.TotalQuotes);
|
||||
Assert.Equal(0m, stats.TotalRevenue);
|
||||
Assert.Equal(0m, stats.AverageJobValue); // no divide-by-zero
|
||||
Assert.Null(stats.DaysSinceLastJob);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Details_WithNoHistory_DoesNotRenderTimeline()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedCustomer(context, id: 1, companyId: 1);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(context, companyId: 1);
|
||||
var result = await controller.Details(id: 1);
|
||||
|
||||
var view = Assert.IsType<ViewResult>(result);
|
||||
var timeline = view.ViewData["Timeline"] as List<CustomerTimelineEventDto>;
|
||||
|
||||
Assert.NotNull(timeline);
|
||||
Assert.Empty(timeline);
|
||||
}
|
||||
|
||||
// ── Details — stats calculation ───────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Details_WithJobsAndInvoices_CalculatesStatsCorrectly()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedCustomer(context, id: 1, companyId: 1);
|
||||
var activeStatus = SeedJobStatus(context, id: 1, isTerminal: false);
|
||||
SeedJob(context, id: 1, customerId: 1, companyId: 1, statusId: activeStatus.Id, finalPrice: 300m);
|
||||
SeedJob(context, id: 2, customerId: 1, companyId: 1, statusId: activeStatus.Id, finalPrice: 700m);
|
||||
SeedInvoice(context, id: 1, customerId: 1, companyId: 1, total: 300m, amountPaid: 300m, status: InvoiceStatus.Paid);
|
||||
SeedInvoice(context, id: 2, customerId: 1, companyId: 1, total: 700m, amountPaid: 400m, status: InvoiceStatus.PartiallyPaid);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(context, companyId: 1);
|
||||
var result = await controller.Details(id: 1);
|
||||
|
||||
var view = Assert.IsType<ViewResult>(result);
|
||||
var stats = view.ViewData["CrmStats"] as CustomerLifetimeStatsDto;
|
||||
|
||||
Assert.NotNull(stats);
|
||||
Assert.Equal(2, stats.TotalJobs);
|
||||
Assert.Equal(2, stats.ActiveJobs);
|
||||
Assert.Equal(2, stats.TotalInvoices);
|
||||
Assert.Equal(1000m, stats.TotalRevenue);
|
||||
Assert.Equal(700m, stats.TotalCollected);
|
||||
Assert.Equal(500m, stats.AverageJobValue);
|
||||
}
|
||||
|
||||
// ── Details — voided invoices excluded ────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Details_VoidedInvoicesExcludedFromRevenueAndCollected()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedCustomer(context, id: 1, companyId: 1);
|
||||
var status = SeedJobStatus(context, id: 1, isTerminal: false);
|
||||
SeedJob(context, id: 1, customerId: 1, companyId: 1, statusId: status.Id, finalPrice: 500m);
|
||||
SeedInvoice(context, id: 1, customerId: 1, companyId: 1, total: 500m, amountPaid: 500m, status: InvoiceStatus.Paid);
|
||||
SeedInvoice(context, id: 2, customerId: 1, companyId: 1, total: 999m, amountPaid: 0m, status: InvoiceStatus.Voided);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(context, companyId: 1);
|
||||
var result = await controller.Details(id: 1);
|
||||
|
||||
var view = Assert.IsType<ViewResult>(result);
|
||||
var stats = view.ViewData["CrmStats"] as CustomerLifetimeStatsDto;
|
||||
|
||||
Assert.NotNull(stats);
|
||||
Assert.Equal(500m, stats.TotalRevenue); // voided invoice excluded
|
||||
Assert.Equal(500m, stats.TotalCollected); // voided invoice excluded
|
||||
Assert.Equal(2, stats.TotalInvoices); // count includes voided (informational)
|
||||
}
|
||||
|
||||
// ── Details — active-job count excludes terminal statuses ─────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Details_ActiveJobsExcludesTerminalStatuses()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedCustomer(context, id: 1, companyId: 1);
|
||||
var active = SeedJobStatus(context, id: 1, isTerminal: false);
|
||||
var completed = SeedJobStatus(context, id: 2, isTerminal: true);
|
||||
SeedJob(context, id: 1, customerId: 1, companyId: 1, statusId: active.Id, finalPrice: 100m);
|
||||
SeedJob(context, id: 2, customerId: 1, companyId: 1, statusId: completed.Id, finalPrice: 200m);
|
||||
SeedJob(context, id: 3, customerId: 1, companyId: 1, statusId: completed.Id, finalPrice: 300m);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(context, companyId: 1);
|
||||
var result = await controller.Details(id: 1);
|
||||
|
||||
var view = Assert.IsType<ViewResult>(result);
|
||||
var stats = view.ViewData["CrmStats"] as CustomerLifetimeStatsDto;
|
||||
|
||||
Assert.NotNull(stats);
|
||||
Assert.Equal(3, stats.TotalJobs);
|
||||
Assert.Equal(1, stats.ActiveJobs);
|
||||
}
|
||||
|
||||
// ── Details — timeline cap and sort ───────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Details_TimelineCappedAt15Events()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedCustomer(context, id: 1, companyId: 1);
|
||||
var status = SeedJobStatus(context, id: 1, isTerminal: false);
|
||||
|
||||
for (int i = 1; i <= 18; i++)
|
||||
{
|
||||
context.Jobs.Add(new Job
|
||||
{
|
||||
Id = i,
|
||||
CompanyId = 1,
|
||||
CustomerId = 1,
|
||||
JobStatusId = status.Id,
|
||||
JobNumber = $"JOB-0001-{i:D4}",
|
||||
Description = $"Job {i}",
|
||||
FinalPrice = 100m,
|
||||
CreatedAt = new DateTime(2026, 1, i, 0, 0, 0, DateTimeKind.Utc)
|
||||
});
|
||||
}
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(context, companyId: 1);
|
||||
var result = await controller.Details(id: 1);
|
||||
|
||||
var view = Assert.IsType<ViewResult>(result);
|
||||
var timeline = view.ViewData["Timeline"] as List<CustomerTimelineEventDto>;
|
||||
|
||||
Assert.NotNull(timeline);
|
||||
Assert.Equal(15, timeline.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Details_TimelineIsSortedNewestFirst()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedCustomer(context, id: 1, companyId: 1);
|
||||
|
||||
// Use Invoice.InvoiceDate for timeline dates — SaveChangesAsync stamps CreatedAt but
|
||||
// does not touch InvoiceDate, so we can seed distinct values that survive the save.
|
||||
SeedInvoice(context, id: 1, customerId: 1, companyId: 1, total: 100m, amountPaid: 100m,
|
||||
status: InvoiceStatus.Paid, invoiceDate: new DateTime(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
SeedInvoice(context, id: 2, customerId: 1, companyId: 1, total: 200m, amountPaid: 0m,
|
||||
status: InvoiceStatus.Sent, invoiceDate: new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
SeedInvoice(context, id: 3, customerId: 1, companyId: 1, total: 300m, amountPaid: 0m,
|
||||
status: InvoiceStatus.Sent, invoiceDate: new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc));
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(context, companyId: 1);
|
||||
var result = await controller.Details(id: 1);
|
||||
|
||||
var view = Assert.IsType<ViewResult>(result);
|
||||
var timeline = view.ViewData["Timeline"] as List<CustomerTimelineEventDto>;
|
||||
|
||||
Assert.NotNull(timeline);
|
||||
Assert.Equal(3, timeline.Count);
|
||||
Assert.True(timeline[0].Date > timeline[1].Date, "First event should be the newest");
|
||||
Assert.True(timeline[1].Date > timeline[2].Date, "Events should be descending");
|
||||
}
|
||||
|
||||
// ── Details — tenant isolation ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Details_DoesNotIncludeJobsFromOtherCompanies()
|
||||
{
|
||||
await using var context = CreateContext();
|
||||
SeedCustomer(context, id: 1, companyId: 1);
|
||||
var status = SeedJobStatus(context, id: 1, isTerminal: false);
|
||||
SeedJob(context, id: 1, customerId: 1, companyId: 1, statusId: status.Id, finalPrice: 500m); // this company
|
||||
SeedJob(context, id: 2, customerId: 1, companyId: 2, statusId: status.Id, finalPrice: 999m); // other company
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(context, companyId: 1);
|
||||
var result = await controller.Details(id: 1);
|
||||
|
||||
var view = Assert.IsType<ViewResult>(result);
|
||||
var stats = view.ViewData["CrmStats"] as CustomerLifetimeStatsDto;
|
||||
|
||||
Assert.NotNull(stats);
|
||||
Assert.Equal(1, stats.TotalJobs);
|
||||
Assert.Equal(500m, stats.AverageJobValue);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
private static CustomersController CreateController(ApplicationDbContext context, int companyId)
|
||||
{
|
||||
var uow = new UnitOfWork(context);
|
||||
|
||||
// Tests cover CrmStats/Timeline logic, not DTO mapping — stub mapper to return a valid model
|
||||
var mapperMock = new Mock<IMapper>();
|
||||
mapperMock
|
||||
.Setup(m => m.Map<CustomerDto>(It.IsAny<Customer>()))
|
||||
.Returns((Customer c) => new CustomerDto { Id = c.Id, CompanyName = c.CompanyName, IsActive = c.IsActive });
|
||||
var mapper = mapperMock.Object;
|
||||
|
||||
var tenantContext = new Mock<ITenantContext>();
|
||||
tenantContext.Setup(t => t.GetCurrentCompanyId()).Returns(companyId);
|
||||
|
||||
var controller = new CustomersController(
|
||||
uow,
|
||||
mapper,
|
||||
Mock.Of<ILogger<CustomersController>>(),
|
||||
Mock.Of<INotificationService>(),
|
||||
Mock.Of<ISubscriptionService>(),
|
||||
tenantContext.Object,
|
||||
CreateUserManagerMock().Object,
|
||||
Mock.Of<IFinancialReportService>());
|
||||
|
||||
controller.ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext()
|
||||
};
|
||||
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static void SeedCustomer(ApplicationDbContext context, int id, int companyId)
|
||||
{
|
||||
context.Customers.Add(new Customer
|
||||
{
|
||||
Id = id,
|
||||
CompanyId = companyId,
|
||||
CompanyName = $"Test Customer {id}",
|
||||
IsActive = true,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
private static JobStatusLookup SeedJobStatus(ApplicationDbContext context, int id, bool isTerminal)
|
||||
{
|
||||
var status = new JobStatusLookup
|
||||
{
|
||||
Id = id,
|
||||
StatusCode = isTerminal ? "COMPLETED" : "IN_PROGRESS",
|
||||
DisplayName = isTerminal ? "Completed" : "In Progress",
|
||||
DisplayOrder = id,
|
||||
IsTerminalStatus = isTerminal
|
||||
};
|
||||
context.JobStatusLookups.Add(status);
|
||||
return status;
|
||||
}
|
||||
|
||||
private static void SeedJob(
|
||||
ApplicationDbContext context, int id, int customerId, int companyId, int statusId, decimal finalPrice)
|
||||
{
|
||||
context.Jobs.Add(new Job
|
||||
{
|
||||
Id = id,
|
||||
CompanyId = companyId,
|
||||
CustomerId = customerId,
|
||||
JobStatusId = statusId,
|
||||
JobNumber = $"JOB-0001-{id:D4}",
|
||||
Description = $"Job {id}",
|
||||
FinalPrice = finalPrice,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
private static Job MakeJob(int id, int customerId, int companyId, int statusId, DateTime date) =>
|
||||
new Job
|
||||
{
|
||||
Id = id,
|
||||
CompanyId = companyId,
|
||||
CustomerId = customerId,
|
||||
JobStatusId = statusId,
|
||||
JobNumber = $"JOB-0001-{id:D4}",
|
||||
Description = $"Job {id}",
|
||||
FinalPrice = 100m,
|
||||
CreatedAt = date
|
||||
};
|
||||
|
||||
private static void SeedInvoice(
|
||||
ApplicationDbContext context, int id, int customerId, int companyId,
|
||||
decimal total, decimal amountPaid, InvoiceStatus status,
|
||||
DateTime? invoiceDate = null)
|
||||
{
|
||||
context.Invoices.Add(new Invoice
|
||||
{
|
||||
Id = id,
|
||||
CompanyId = companyId,
|
||||
CustomerId = customerId,
|
||||
InvoiceNumber = $"INV-0001-{id:D4}",
|
||||
InvoiceDate = invoiceDate ?? DateTime.UtcNow,
|
||||
Total = total,
|
||||
AmountPaid = amountPaid,
|
||||
Status = status
|
||||
});
|
||||
}
|
||||
|
||||
private static Mock<UserManager<ApplicationUser>> CreateUserManagerMock()
|
||||
{
|
||||
var store = new Mock<IUserStore<ApplicationUser>>();
|
||||
return new Mock<UserManager<ApplicationUser>>(
|
||||
store.Object, null!, null!, null!, null!, null!, null!, null!, null!);
|
||||
}
|
||||
|
||||
private static ApplicationDbContext CreateContext()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
|
||||
// SuperAdmin principal: bypasses the CompanyId global query filter so all
|
||||
// seeded rows are visible, matching the same approach in DepositsControllerTests.
|
||||
var identity = new ClaimsIdentity(new[] { new Claim(ClaimTypes.Role, "SuperAdmin") }, "Test");
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
|
||||
byte[]? noBytes = null;
|
||||
var sessionMock = new Mock<ISession>();
|
||||
sessionMock.Setup(s => s.TryGetValue(It.IsAny<string>(), out noBytes)).Returns(false);
|
||||
|
||||
var httpContextMock = new Mock<HttpContext>();
|
||||
httpContextMock.SetupGet(c => c.User).Returns(principal);
|
||||
httpContextMock.SetupGet(c => c.Session).Returns(sessionMock.Object);
|
||||
|
||||
var accessor = new Mock<IHttpContextAccessor>();
|
||||
accessor.SetupGet(a => a.HttpContext).Returns(httpContextMock.Object);
|
||||
|
||||
return new ApplicationDbContext(options, accessor.Object, null!);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user