Compare commits

..

88 Commits

Author SHA1 Message Date
spouliot c0e4a66126 Phase 4: Past appointments + AI prediction demo data
- Appointments: add ~25 past appointments (last 90 days) with Completed,
  Cancelled, No Show, and Rescheduled statuses; completed records carry
  ActualStartTime/ActualEndTime with realistic variance; cancel/no-show
  notes explain why; customer label falls back to ContactFirst/LastName
  for residential customers
- Fix future appointment title for residential customers (was always using
  CompanyName which is null for individuals)
- New SeedDataService.AiPredictions.cs: seeds 8 AiItemPrediction records
  (varied complexity/confidence/tags/reasoning) and attaches them to the
  first 8 eligible QuoteItems, marking those items IsAiItem=true; 3 of 8
  have UserOverrodeEstimate=true for AI Accuracy report demo
- SeedDataService.cs: wire SeedAiPredictionsAsync after Invoices
- Remove.cs: collect QuoteItem.AiPredictionId FKs before deleting items,
  then delete orphaned AiItemPrediction records after quotes are removed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 22:40:12 -04:00
spouliot dbd39a9fe5 Phase 3: AR/AP aging buckets, PO seeder, Bills vendor fix
- Bills.cs: replace aceHardware/fastenal lookups with grainger/harbor/localSupply
  to match Phase 1 vendor renames; update all vendor invoice number prefixes
- Bills.cs: add 3 AP aging-bucket bills (30-60, 61-90, 90+ days overdue) so all
  four AP aging buckets are populated for report demos
- Invoices.cs: add 3 more overdue invoices (31-60, 61-90, 90+ day AR buckets)
  alongside the existing 21-day overdue; total now 29 invoices
- New SeedDataService.PurchaseOrders.cs: 7 POs — 3 Received (historical), 2
  Submitted (in-flight), 2 Draft (pending approval); links to inventory items
  where available
- SeedDataService.cs: wire SeedPurchaseOrdersAsync after Vendors seeder
- Remove.cs: add PO + POItem cleanup inside Bills removal block (two-step ID
  fetch to avoid nested LINQ translation issues)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 22:29:31 -04:00
spouliot 584664e7c8 Demo seed Phase 2: workers, time entries, maintenance records
- 5 named shop workers seeded as ApplicationUser (Employee role):
  Mike Sanders (Coater), Jake Wilson (Sandblaster), Sarah Brooks (Inspector),
  Tyler Green (General), Chris Mason (Lead) — @pcldemo.com fingerprint domain
- Job time entries seeded for all in-progress and completed jobs;
  Worker Productivity report will have data from day one
- Maintenance history seeded per equipment: 2 completed records + 1 upcoming
  scheduled + 1 overdue record on Pressure Pot for overdue alert demo
- Equipment renamed to spec names: Main Batch Oven, Small Batch Oven, Powder
  Coating Booth, Blast Cabinet, Pressure Pot Blaster, Air Compressor, Wash
  Station, Forklift (replaced Overhead Conveyor which wasn't in spec)
- RemoveSeedDataOptions.Workers added; Remove.cs cleans up workers + time
  entries on Demo Reset; SeedDataController resets workers in ResetDemoCompany

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 22:20:04 -04:00
spouliot 1255bc0670 Demo seed Phase 1: NC identity, spec inventory, revenue targeting
- Customers: 10 NC commercial (Carolina Fabrication, Apex Motorsports, Triangle
  Offroad, Smith Welding, Raleigh Architectural Metals, etc.) + 17 residential,
  all anchored to Raleigh-Durham area for cohesive tutorial identity
- Inventory: 6 spec powders (Gloss Black, Matte Black, Super Chrome, Candy Red,
  Signal White, Illusion Purple) + 5 consumables (Tape, Silicone Plugs, Hooks,
  Acetone, Blast Media); 2 low-stock + 1 out-of-stock for dashboard alerts
- Vendors: updated to spec (Prismatic Powders, Columbia Coatings, Harbor Freight,
  Grainger, Local Industrial Supply)
- Quotes: 35 quotes (was 20) with 5-status distribution; dates span 5-6 months
- Jobs: 50 jobs (was ~32) with per-customer price ranges so Revenue by Customer
  report shows realistic Pareto curve (Carolina Fabrication largest, etc.)
- Remove.cs: fingerprints updated for all 27 new customer emails + 11 new SKUs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 22:12:47 -04:00
spouliot 01f6897d08 Scale demo seed data down for tutorial recordings
Customers: 100 → 27 (15 commercial across auto/industrial/architectural/
fitness/marine/energy, including 2 tax-exempt govts; 12 individuals)

Quotes: 75 → 20; date range extended to 4-6 months (was 90 days);
status distribution adjusted proportionally (2 draft, 3 sent, 10 approved,
3 rejected, 2 expired)

Jobs: fixed 50-loop → per-customer 0-5 jobs (~32 total); jobIdx cycles
all 16 statuses globally so every status is visible; creation dates spread
across 1-5 months for in-progress/early jobs, 2-6 months for completed jobs

SeededCustomerEmails updated to match new 27-customer set (added
gnelson@email.com and carol.evans@email.com)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 21:56:32 -04:00
spouliot 72382a5dd5 Fix demo reset: wipe bills/expenses, fix apostrophe display bug
- Add Bills and Expenses flags to RemoveSeedDataOptions
- RemoveSeedDataAsync: delete BillPayments + BillLineItems + Bills, then
  Expenses for the company when those flags are set
- ResetDemoCompany action: enable Bills=true and Expenses=true so all
  seeded AP data is cleared before re-seeding (was skipping on second reset)
- Fix apostrophe in success message (was &apos; in C# string, double-encoded
  by Razor to literal &apos; on screen)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 21:48:43 -04:00
spouliot 86a293a927 Add one-click Demo Company reset for tutorial recording prep
New ResetDemoCompany POST action wipes all seeded data (customers, jobs,
quotes, invoices, inventory, equipment, catalog, pricing tiers, operating
costs) from the DEMO company and immediately re-seeds with fresh records
dated relative to today. Seed data already used relative dates so every
reset produces a realistic, current-looking dataset.

View adds a red "Reset Demo Company" card at the top of the Seed Data page,
visible only when the DEMO company exists. Single button with confirm dialog;
shows exactly what will be wiped and what will be preserved (user accounts,
company settings, lookup tables).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 21:34:57 -04:00
spouliot 35264e6b2a Fix preferred powder selection and expand company settings export
- customer-details.js: encode double quotes in JSON.stringify output as &quot; so onclick attributes parse correctly when powder names contain double quotes
- ToolsController: add company_settings CSV to ExportAllCsv ZIP archive (was missing entirely)
- ToolsController: add ~30 missing fields to GenerateCompanySettingsCsv — AccountingMethod, timeclock settings, all shop capability/blast/coat rate fields, complexity surcharge percents, pricing mode, invoice number prefix, email-from fields, per-event notification flags, payment reminder settings, document accent colors/terms/footer notes, kiosk intake output
- Update GenerateCompanySettingsTemplate to match so import template stays in sync with export

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 21:12:49 -04:00
spouliot 0b839d0746 Fix Company Settings save, invoice PAID stamp, and purge script
- Company Settings: switch save button from type=submit to type=button
  to bypass HTML5 form validation blocking the submit event; replace
  AutoMapper Map() with explicit property assignment so EF change
  tracking reliably detects mutations; fix showButtonSuccess() never
  re-enabling the button after a successful save
- Invoice PDF: move PAID stamp into the header row as a centered middle
  column so it sits between the company and invoice blocks without
  adding height to the document
- Purge script: use business-date fields instead of CreatedAt so
  imported records (which all share today's CreatedAt) are correctly
  filtered by actual transaction dates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 17:36:15 -04:00
spouliot 66c3febd7a Move invoice PAID stamp inline to header; add email to company block
- Remove watermark overlay layer; PAID badge now sits centered between
  the header row and the accent rule so it never obscures line items
- Add PrimaryContactEmail to company info block in header
- Remove ComposePaidStamp helper (no longer needed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 16:39:11 -04:00
spouliot b8057295ec Redirect emails to dev address in non-production; fix PAID stamp color
- EmailService: add RedirectIfNonProd() mirroring SmsService pattern;
  reads SendGrid:DevRedirectEmail and redirects all outbound email in
  non-production so real customers are never contacted on local/dev
- appsettings.json: set DevRedirectEmail to spouliot@scppowdercoating.com
- PdfService: revert Opacity() (not in QuestPDF 2024.12.3); use
  Colors.Green.Lighten2 for stamp + border to achieve lighter look

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 16:32:01 -04:00
spouliot 14d6c82839 Make invoice PAID stamp smaller and semi-transparent
Reduce font 80→52, border 5→3, add 35% opacity so stamp no longer
obscures line items on dense invoices.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 16:27:33 -04:00
spouliot db4b73013a Simplify inventory label: combine header and scan hint, remove dashed footer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 16:24:49 -04:00
spouliot e313149f08 Fix inventory label duplicate color/finish display
- Suppress ColorName line if it matches the item Name (powders use
  color name as their item name, causing it to show twice)
- Suppress Finish if already contained in the item Name
- Always show Manufacturer regardless of whether it is populated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 16:07:43 -04:00
spouliot 82fb48f7a5 Patch export/import for missing fields; add CustomerContacts export
- DataExportController + AccountDataExportController: add ProjectName to
  Jobs, Quotes, Invoices (XLSX + CSV); add LeadSource + ShipTo fields to
  Customers (XLSX + CSV); add CustomerContacts sheet/CSV (new)
- Both export views: add Customer Contacts checkbox (checked by default)
- CustomerImportDto: add LeadSource + ShipTo* fields
- JobImportDto: add ProjectName
- QuoteImportDto: add ProjectName
- InvoiceImportDto: add Project Name (dual-name alias for round-trip)
- CsvImportService: wire all new import fields to entity creation;
  also patch invoice update path for ProjectName
- Add scripts/purge_imported_data.sql (dry-run T-SQL for data cleanup)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 15:14:27 -04:00
spouliot 427c52a499 Fix Ready for Pickup filter returning no results
The Ready pill passed searchTerm=ReadyForPickup which did a text search —
"readyforpickup" (no spaces) never matched the display name "Ready for Pickup".
Converted to statusGroup=ready and added the corresponding controller case.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 14:39:35 -04:00
spouliot d92266b027 Fix empty state showing Create First button when a filter is active
Jobs: use AllJobCount (global total) to distinguish truly-empty from
filter-returned-nothing; show Clear Filters button in the latter case.
Quotes: expand the filter-active check to include tagFilter and statusCode,
which were missing from the condition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 14:20:20 -04:00
spouliot 750e1b1c5b Fix preferred powder search dropdown not appearing
Inline display:none!important on the results div blocked all CSS rules
from showing it, including the :not(:empty) trick. Switched to explicit
JS show/hide so the dropdown is reliably visible after typing 2+ chars.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 13:59:14 -04:00
spouliot 94a89ee175 Add CRM features: Additional Contacts, Lead Source, Ship-To Address; update Help docs
- New CustomerContact entity + migration (AddCustomerContactsAndCrmFields)
- Customer.LeadSource + ShipToAddress/City/State/ZipCode/Country fields
- Additional Contacts card on Customer Details with AJAX add/edit/delete
- Lead Source dropdown on Create/Edit; Ship-To section on Create/Edit
- Customer Details: side-by-side billing/ship-to when ship-to is set
- Help docs: Customers (contacts, ship-to, lead source, preferred powders, outstanding pickups)
- Help docs: Jobs (clone job, project name), Quotes (project name), Invoices (project name), Inventory (low stock clickable filter)
- HelpKnowledgeBase.cs updated for all features above

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 12:46:08 -04:00
spouliot 711cd01cd3 Add CRM features: Outstanding Pickups, Customer Notes, Clone Job, Preferred Powders
- Outstanding Pickups card on Customer Details shows jobs awaiting pickup with age badges
- Customer Notes log: inline add/delete notes with important flag, AJAX-backed
- Clone Job action on Jobs controller; Repeat Last Job button on Customer Details quick actions
- Preferred Powders per customer: typeahead inventory search, AJAX add/remove
- CustomerPreferredPowder entity + migration; unit tests for CRM stats/timeline logic
- Fix EF Core concurrency bug: parallel Task.WhenAll FindAsync replaced with sequential awaits

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 19:59:32 -04:00
spouliot 7cbae31916 Fix invoice ProjectName not pre-filling on edit; add to Details view
Edit GET now falls back to job.ProjectName for invoices created before the
column was added. Details view shows Project Name alongside Customer PO.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:58:09 -04:00
spouliot 9367e358d9 Add Project Name field to invoice create and edit forms
Stores ProjectName on the Invoice entity (previously only inherited from the
linked job at display time). Pre-fills from the job when creating from a job.
Migration: AddInvoiceProjectName.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:50:02 -04:00
spouliot 9f1460c9c0 Make Low Stock stat card clickable to filter inventory by low stock items
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:48:04 -04:00
spouliot 94e536178c Add optional Project Name field to quotes, jobs, and printed documents
- Add ProjectName (nvarchar 100, nullable) to Quote and Job entities;
  migration AddProjectNameToQuotesAndJobs applied
- Add ProjectName to all relevant DTOs: QuoteDto/Create/Update,
  JobDto/List/Create/Update, InvoiceDto (mapped from Job.ProjectName
  via AutoMapper so the invoice PDF picks it up without a separate column)
- Form field added after Customer PO in Quote Create/Edit and Job Create/Edit
- CreateJobFromQuote copies ProjectName from quote to job automatically
- Details views (Quote and Job) display Project when set
- Printable quote PDF: Project row in the quote details block
- Work order: Project row in customer/job info section
- Invoice PDF: Project shown in the Job Reference block alongside Job # and PO #

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 14:48:28 -04:00
spouliot 456d054229 Fix prospect quote conversion losing the job; add reply-to in email footer
QuotesController — ConvertToCustomer POST was wrongly setting the quote
status to 'Converted' (which means a job exists) and redirecting to the
customer page with no job created. The quote then disappeared from the
default list filter and the user had no way to create the job without
hunting for it. Fix: leave the quote at 'Approved' after customer
creation and redirect back to the quote details page with a toast
prompting the next step. 'Converted' status is now set exclusively by
CreateJobFromQuote when a job actually exists.

NotificationService — add tenant reply-to email address as a visible
line in the email footer so customers who ignore or whose mail client
doesn't honour the Reply-To header still have a clear address to contact.
Also adds Warning-level logging when no reply-to is configured for a
company so future routing issues are diagnosable from app logs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 10:35:48 -04:00
spouliot f38a1e3273 Add Reply-To diagnostic logging to GetEmailFromAsync
Logs a Warning when no Reply-To email is configured for a company
(so the logs show why replies land at the platform sender address)
and a Debug entry when one is set, making future send issues
diagnosable without needing the SendGrid Activity API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 11:08:45 -04:00
spouliot 03b425a12f Update blast rate tests to match nozzle-primary formula
Remove the CFM=0→zero test (CFM no longer in the formula path).
Update expected values to match the new nozzle-primary tables and
corrected TierDefaults CFM/nozzle pairings. Add WetBlasting and
RustAndScale substrate coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:21:39 -04:00
spouliot 8453449833 Recalibrate blast rate formula from industry reference tables
Replace the inaccurate CFM-based formula with nozzle-primary tables
sourced from industry standard abrasive blast cleaning references:
- Pressure pot: midpoints averaged from two reference tables
  (#4 nozzle: 115 sqft/hr, #5: 175 sqft/hr, etc.)
- Siphon cabinet: dedicated siphon cabinet reference table
  (#4 nozzle: 125 sqft/hr, #3: 75 sqft/hr, etc.)
- SiphonPot: 80% of pressure pot rate (open gravity feed, no enclosure)
- WetBlasting: 60% of pressure pot rate (water-media reduces velocity)

CFM is removed from the rate formula entirely — nozzle size determines
throughput and CFM draw, so CFM is a consequence of nozzle choice, not
an independent variable. Override field still bypasses formula for shops
that have measured their own throughput.

Also corrects TierDefaults nozzle/CFM pairings which were mismatched
(e.g. Small tier had 40 CFM assigned to a #5 nozzle that needs 150 CFM).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:12:45 -04:00
spouliot ad986561c9 Fix AI quote blast rate: single formula path, correct client preview
Root cause: company-settings-lookups.js had its own baseByCfm/multiplier
tables that were completely different from ShopCapabilityCalculator.cs,
so the UI showed an inflated rate (e.g. 82 sqft/hr) while the AI prompt
received the server-computed rate (e.g. 9 sqft/hr).

- Add CompanySettingsController.DeriveBlastRate endpoint — thin GET that
  calls ShopCapabilityCalculator directly; now the single formula path
- Delete all client-side formula code (baseByCfm, multiplier tables,
  deriveBlastRate) — ~30 lines removed
- Modal live preview calls /CompanySettings/DeriveBlastRate with 250ms
  debounce instead of computing locally
- Blast setup table uses setup.derivedRate from GetBlastSetups (already
  server-computed) instead of recalculating client-side
- QuotesController.AiAnalyzeItem: when no blast setup is explicitly
  selected, fall back to the company's default blast setup so the
  configured rate is always used

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 15:57:46 -04:00
spouliot 0d5553f3b2 Fix dark mode hover colors in coat/powder dropdown menus
Replace all hardcoded light-mode colors (#f0f4ff, #e8eeff, #fff8e1, #fff)
with Bootstrap CSS variables (--bs-secondary-bg, --bs-primary-bg-subtle,
--bs-warning-bg-subtle, --bs-body-bg) so dropdown containers and hover
states render correctly in both light and dark mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 15:12:24 -04:00
spouliot 87bbf158a4 Fix material usage logging: remaining weight mode, edit modal, and consolidate duplicate logic
- InventoryController: extract RecordInventoryUsageAsync helper; both LogUsage
  (scan page) and LogMaterial (jobs modal, moved from JobsController) call it —
  no more duplicate save/GL logic across two controllers
- Log Material modal: replace radio buttons with prominent toggle buttons so the
  active mode (Amount Used vs Amount Remaining) is always visually obvious; add
  always-visible preview line showing exactly what will be logged before saving
- Edit Usage modal: add quantity field (pre-populated from existing transaction)
  with delta adjustment to InventoryItem.QuantityOnHand on save; include
  completed/terminal jobs in the dropdown so entries can be corrected after a
  job is marked done
- Scan page job picker: include jobs completed within the last 7 days (marked
  with '(completed)') so usage can be logged after a job is finished

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 14:31:02 -04:00
spouliot f453a95f28 Add hover tooltips on job list rows showing description and PO number
Adds CustomerPO to JobListDto (maps by convention), then builds a
Bootstrap tooltip per row with description · PO: xxx, skipping blank
fields. Rows with neither get no tooltip. Helps identify jobs at a
glance without opening the details page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 18:53:52 -04:00
spouliot d9e98a55d2 Fix customer email inputs to allow comma-separated addresses
type="email" triggers jQuery Validate's email rule which rejects commas,
blocking multi-address input despite the multiple attribute. Switching to
type="text" defers validation to the server-side SplitEmails/MailAddress
logic in the DTO which already handles comma-separated lists correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:16:30 -04:00
spouliot 99deca3b62 Default imported formula templates to active regardless of export state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 11:14:48 -04:00
spouliot 23e64829bb Fix formula export/import: embed fields as real JSON array, not escaped string
Previously FieldsJson was serialized as an escaped string in the export
file, which was fragile and unreadable. Now parsed into a JsonElement and
embedded as a proper JSON array under the key "fields". Import reads it
back with GetRawText() to reconstruct the stored string. This prevents
the null/empty fields bug caused by manually-edited or round-tripped files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 11:06:38 -04:00
spouliot cd4c233b60 Fix formula export casing: use camelCase to match import property lookups
System.Text.Json defaults to PascalCase for anonymous types, producing
"Name"/"OutputMode" etc., while the import used TryGetProperty("name")
causing every template to fail with "no name". Adding CamelCase naming
policy aligns the export format with what the import expects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 10:52:58 -04:00
spouliot 6c07216c64 Fix custom formula item pricing: multiply by quantity, not divide
ManualUnitPrice holds the per-item formula result. The previous code
incorrectly treated it as the batch total and divided by Quantity,
causing the unit price to shrink as quantity increased. Now follows
the same pattern as every other ManualUnitPrice path in this method.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 10:27:11 -04:00
spouliot b23bea6db0 Add formula template export/import and unsaved-changes guard
- Export: GET /CompanySettings/ExportCustomItemTemplates downloads all
  company templates as an indented JSON backup (strips internal IDs/paths)
- Import: POST /CompanySettings/ImportCustomItemTemplates restores from
  that file; runs full field + formula validation, skips name duplicates,
  returns per-item results (imported / skipped / errors)
- Unsaved-changes guard: cfModal now intercepts backdrop/ESC/X when the
  form is dirty and prompts before discarding work
- Export and Import buttons added to the Custom Formulas card header

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 09:24:02 -04:00
spouliot cf07356147 Fix all NU1605 errors: suppress via Directory.Build.props instead of per-package pins
NCalc2 -> Antlr4 -> Antlr4.Runtime -> NETStandard.Library 1.6.0 triggers 6+
NU1605 downgrade warnings on linux-x64 publish (System.IO.FileSystem.Primitives,
System.Text.Encoding.Extensions, System.Diagnostics.Tracing, Microsoft.Win32.Primitives,
System.IO.FileSystem, System.Net.Primitives). All are harmless — .NET 8 supplies
these natively. Directory.Build.props suppresses NU1605 solution-wide cleanly.
Removes the individual System.Runtime.InteropServices pin added in previous commit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 23:41:56 -04:00
spouliot 39b103a482 Fix NU1605 package downgrade: pin System.Runtime.InteropServices 4.3.0
NCalc2 -> Antlr4 -> NETStandard.Library transitive dependency chain requires
System.Runtime.InteropServices >= 4.3.0, but the resolved version was 4.1.0.
Explicit pin in Application.csproj resolves the Jenkins publish failure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 23:27:26 -04:00
spouliot 4aae2df5b5 Merge dev into master
Includes: Community Formula Library, Custom Formula Templates, Employee Timeclock,
Formula Library ratings, Job Profitability report, Quote Revision History,
flat-rate coat wizard UX improvements, customer import dedup fixes, inventory
incoming powder fixes, Custom Powder Order line item, and various bug fixes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 23:16:10 -04:00
spouliot 3416c242f1 Update help docs and AI knowledge base for item wizard changes
- Quotes help: expand Item Types section to all 7 types (was showing only 3)
- Quotes help: update Coatings section to document Flat-Rate coat spec feature
- Quotes help: add Color Name tip to Custom Powder Order section
- HelpKnowledgeBase: update item types list to all 7 types with accurate descriptions
- HelpKnowledgeBase: add Color Name / blur-autofill note to Custom Powder Order entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 23:05:47 -04:00
spouliot 7e31846777 Fix flat-rate coat wizard UX, &mdash; literal, select caret overlap, and walkthrough modal sizing
- Flat-rate items now default coat type to Custom so Color Name field is immediately visible
- Catalog search blur copies typed text to Color Name when no catalog result was selected
- Item card shows 'No color specified' badge when coat has powder-to-order but no color name
- Color Name label marked required with '(shows on quote)' hint
- Coat name select min-width prevents text overlapping Bootstrap caret arrow
- Remove extra unbalanced </div> from renderSalesFields
- Fix literal &mdash; in quote simple-mode hint (textContent → innerHTML)
- Formula walkthrough modal fixed at 700px so all steps render at identical window size

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 23:01:33 -04:00
spouliot ed35362c7a Add Formula Library ratings, Job Profitability report, and Quote Revision History improvements
- Formula Library ratings: thumbs up/down per company per formula; toggle on/off; sorts by net score; own formulas not rateable; FormulaLibraryRating entity + migration AddFormulaLibraryRatings
- Job Profitability report: actual labor cost (logged hours x StandardLaborRate) vs powder cost vs billed price per job; gross margin % color-coded; time-tracked-only filter; totals footer
- Quote Revision History: track Total price changes on every save; log Sent/Resent events with recipient email; replace flat table with grouped timeline UI (icons per event type, total-change badge on header)
- Setup Wizard: cap CompletedCount at TotalSteps so old 10-step data no longer shows 10/5
- Formula Library card: fix badge overflow on long titles; add Rate: label to make voting buttons discoverable
- Help docs and AI knowledge base updated for all three features

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 09:02:07 -04:00
spouliot 81119035c7 Restructure formula template modal for correct mobile flow
Reorganises the Add/Edit Formula Template modal into four logical rows
so that on mobile the sections stack in the natural authoring order:
  1. Name / Description | Output Mode / Rate
  2. Fields (left) | Formula + Test (right)
  3. Diagram | AI Generator
  4. Notes + Active (full width)

Previously, Fields appeared after the Formula on mobile because the left
column (containing Formula) stacked before the right column (containing
Fields). Also compacted Default Rate and Rate Label into a 2-column
mini-row so they sit side by side on all screen sizes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 23:00:39 -04:00
spouliot 0deef574c3 Fix formula pages mobile responsiveness; fix missing mobile tabs
- Custom Formulas and Timeclock tabs were completely missing from the mobile
  dropdown selector, making them unreachable on phones; also adds AI Profile
  and Online Payments which were similarly absent
- Formula library header: flex-column on mobile so title and button stack
  cleanly instead of colliding
- Filter bar: icon-only button gets a visible label on mobile; added col-12
  so it renders full-width correctly at xs
- Import modal: add modal-dialog-scrollable so body scrolls on small screens;
  wrap field table in table-responsive to prevent horizontal overflow
- Settings card header: flex-column on mobile + flex-wrap on button group
  so the three buttons don't overflow off the right edge

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 22:48:33 -04:00
spouliot efc4e9dadf Fix NCalc case sensitivity and add formula validation
- Normalize IF/Abs/Pow/etc. to lowercase before evaluation so AI-generated
  or manually typed uppercase function names no longer cause "Function not
  found" errors
- Add NormalizeAndValidate() which normalizes then does a parse-only check
  on save — invalid formulas are rejected with a clear error before storing
- Update AI system prompt to list all functions in lowercase and explicitly
  call out case-sensitivity; add if() to the supported function list
- Add collapsible NCalc quick-reference panel in the formula editor showing
  all operators, functions (lowercase), built-in variables, and an example

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 22:09:43 -04:00
spouliot ca7e905832 Add Community Formula Library feature
Companies can now share their custom formula templates to a platform-wide
community library. Other tenants can browse, preview, and import formulas
as independent local copies. Includes attribution (source company name),
"Inspired by" lineage for re-contributed formulas, import counts, own-formula
badge, cascade diagram nullification, and AI assistant + help docs updates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 21:54:51 -04:00
spouliot 32d09b38f1 Fix invisible formula/AI badges: move custom bg-* colors to site.css
bg-purple (formula, AI badges) was only defined in company-settings-lookups.css
which is not loaded on Quote/Job pages. The badge element rendered with no
background, appearing as a blank space in front of the item description.

Moved all 13 custom color utilities (purple, pink, cyan, teal, indigo, lime,
brown, gray, orange, yellow, green, blue, red) to site.css so they are
available globally. company-settings-lookups.css retains its definitions for
now (harmless duplication; can be cleaned up later).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 18:25:36 -04:00
spouliot 3cee1307fc Fix customer import: normalize blank email to null, not empty string
The UNIQUE index on (CompanyId, Email) uses HasFilter([Email] IS NOT NULL),
so NULL allows multiple rows but empty string '' does not — every blank-email
customer after the first was hitting a duplicate-key violation at save time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:21:56 -04:00
spouliot be89327c01 Fix customer import dedup: use composite keys, not exclusive tiers
New logic:
  Tier 1 - email present: email match -> skip (unchanged)
  Tier 2 - email absent + phone present: name + phone composite -> skip
  Tier 3 - email and phone absent: name + city/state/zip composite -> warn, import anyway

Tier 2 requires BOTH name and phone to match so two people sharing an
office line don't falsely collide. Tier 3 warns but imports because
location data is too imprecise to hard-skip on.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 13:02:10 -04:00
spouliot 8f955851e5 Improve customer import duplicate detection to 3-tier strategy
Tier 1 (email): existing behavior, now uses HashSet instead of O(n²) .Any()
Tier 2 (phone): when email is absent, deduplicate by normalised phone number
  (last 10 digits of MobilePhone then Phone) against both DB and within-batch
Tier 3 (name): when both email and phone are absent, warn but still import

Fixes customers with no email being silently skipped or left undetected as
duplicates. NormalizePhone strips formatting so (423) 331-9834 and
423-331-9834 match correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 12:48:40 -04:00
spouliot 972123c7a2 Fix incoming powder inventory: defer creation to approval, deduplicate, fix category
Three bugs fixed:

1. Wrong timing — inventory items with IsIncoming=true were auto-created during
   quote save (in QuotePricingAssemblyService). Now deferred to quote approval so
   inventory only reflects powders the shop is actually going to process.

2. Duplicate records — same powder on multiple items in one quote created multiple
   inventory records. Now grouped by PowderCatalogItemId: one record per unique
   catalog powder, all matching coats linked to the same record.

3. Wrong category — category resolution used first IsCoating=true by DisplayOrder,
   which could land items in Cerakote or other unintended categories. Now prefers
   CategoryCode==POWDER explicitly, with DisplayOrder fallback.

Changes:
- QuoteItemCoat: add PowderCatalogItemId int? — persists catalog reference at quote
  save time so the approval path knows what to create
- QuotePricingAssemblyService.BuildQuoteItemCoatsAsync: store PowderCatalogItemId
  on coat instead of calling CreateIncomingInventoryItemAsync immediately
- QuotePricingAssemblyService.CreateIncomingInventoryItemAsync: signature changed
  from (coatDto, companyId) to (catalogItemId, companyId); category lookup prefers
  POWDER code; no longer clears PowderCostPerLb on the DTO
- QuotePricingAssemblyService.EnsureIncomingInventoryForApprovedQuoteAsync: new
  public method called at approval — loads pending coats, groups by catalog ID,
  creates one inventory item per group, links all coats in each group
- IQuotePricingAssemblyService: exposes EnsureIncomingInventoryForApprovedQuoteAsync
- QuotesController.ApproveQuote: calls EnsureIncomingInventory after save
- QuotesController.ChangeQuoteStatus: calls EnsureIncomingInventory on Approved
- QuoteApprovalController: injects IQuotePricingAssemblyService; calls
  EnsureIncomingInventory in ApproveInternal (customer-facing portal path)
- InventoryController.CreateIncomingFromCatalog: same category fix (prefers POWDER)
- Migration: AddPowderCatalogItemIdToCoat (nullable int on QuoteItemCoats)
- Tests: updated AddAsIncoming test to verify deferred behavior; new deduplication test

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 10:12:24 -04:00
spouliot 9dd36238bb Add timeclock break/lunch tracking, manual entries, and attendance period picker
- ClockEntryType enum (Work/Break/Lunch) on EmployeeClockEntry; default 0 = Work
  so all existing entries are unaffected
- Migration AddClockEntryType applied
- Break and Lunch buttons on clock status card (only when AllowMultiplePunchesPerDay
  is enabled); GoOnBreak closes current Work segment and opens Break/Lunch segment
- Return to Work button when on break/lunch; closes break segment, opens new Work
- Status badges on clock card and Who'\''s In grid: Working / On Break / At Lunch
- Break/Lunch hours excluded from all day totals, week totals, metrics, and CSV
- Manager: Manual Entry modal to create a time entry for any company employee
- Attendance report defaults to current ISO week; Week/Month mode toggle with
  auto-submitting dropdowns (last 12 weeks or months); period label shown inline
- Attendance CSV: Type column added; day/week totals blank on Break/Lunch rows;
  filename uses period label
- Week subtotal rows suppressed in single-week view (shown in month view only)
- Help article and AI knowledge base updated for all new features

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 09:30:39 -04:00
spouliot 8ae61b6c78 Add missing AddTimeclockSettings migration
Migration was scaffolded in the background while the dev server held the PDB lock
and silently failed to write the file. Re-ran and applied now.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 00:20:53 -04:00
spouliot 97745f9a65 Add Timeclock settings tab in Company Settings with multi-kiosk support
Settings tab (Company Settings > Timeclock):
- Enable/disable timeclock toggle (hides nav link and attendance report when off)
- Allow multiple clock-ins per day toggle
- Auto clock-out after X hours (auto-closes forgotten open entries on next punch)
- Kiosk devices table: lists activated tablets with name, activated date, last seen;
  Deactivate button removes that device's access immediately

Multi-kiosk support (replaces single TimeclockKioskToken on Company):
- New TimeclockKioskDevice entity (one row per tablet, unique token, DeviceName, LastSeenAt)
- KioskActivate GET shows a form for optional device name before activating
- KioskDeactivate POST accepts device ID, deletes specific row (not all devices)
- Kiosk validation (Kiosk, KioskEmployees, KioskPunch) queries device table with
  ignoreQueryFilters since no user is logged in on kiosk requests
- LastSeenAt updated on each Kiosk page load

Enforcement:
- ClockIn and KioskPunch both auto-close stale entries if AutoClockOutHours is set
- ClockIn and KioskPunch both block second same-day punch if AllowMultiplePunches=false
- TimeclockEnabled=false hides nav link (SubscriptionMiddleware sets Items key) and
  returns Forbid on kiosk punch
- Migration: AddTimeclockSettings (adds 3 columns to Companies, new TimeclockKioskDevices table)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-27 00:12:46 -04:00
spouliot e124fd5c8b Fix duplicate @section Scripts crash on Edit User page
Merging the kiosk PIN script and the role-permissions script into a single
@section Scripts block fixes the InvalidOperationException thrown when Razor
encounters two sections with the same name in the same view.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 20:28:08 -04:00
spouliot 6c2fe6e1c4 Add Employee Timeclock feature with kiosk, attendance report, and payroll CSV export
- New EmployeeClockEntry entity (facility-level attendance, separate from job time entries)
- KioskPin added to ApplicationUser; TimeclockKioskToken added to Company
- TimeclockController: clock in/out, who's in, 14-day history, manager edit/delete,
  tablet kiosk with device-cookie auth, PIN management via Users edit page
- Kiosk UI: employee tile grid + 4-digit PIN pad + auto-detect clock-in vs clock-out
- Attendance report at /Reports/Attendance with weekly subtotal rows
- Payroll CSV export at /Reports/AttendanceCsv (flat, one row per segment)
- AllowCustomFormulas wired through PlatformSubscriptionController + subscription views
- Fix soft-delete bug on CustomItemTemplate (missing HasQueryFilter in OnModelCreating)
- Help article (Help/Timeclock.cshtml) and AI knowledge base updated
- Migrations: AddEmployeeTimeclock, AddTimeclockKioskToken

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 19:53:13 -04:00
spouliot f625be01a3 Fix Facility Overhead not appearing on Quote Details view
Both PricingBreakdown constructions in QuotesController.Details were
missing FacilityOverheadCost and FacilityOverheadRatePerHour mappings,
so the view condition (FacilityOverheadCost > 0) was always false even
though the overhead was correctly stored on the Quote entity and included
in the total. The quote-to-job conversion block already had them; now all
three are consistent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 13:11:52 -04:00
spouliot e6c4cfb38b Fix all FK constraint violations when purging soft-deleted Jobs
Before deleting Jobs, now:
- Deletes non-nullable child rows: ReworkRecords, PowderUsageLogs, OvenBatchItems
- Nulls out nullable FK refs: Invoices, Deposits, Appointments, BillLineItems,
  Expenses, InventoryTransactions
DB-cascade / SET NULL relationships (JobChangeHistory, JobStatusHistory,
JobTimeEntry, JobItems, JobNotes, JobPhotos, KioskSession, NotificationLog)
are excluded — the DB handles them automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 13:04:13 -04:00
spouliot 5b5247624c Fix data purge FK violation on Appointments and apply pending migration
DataPurgeController was deleting Jobs without first clearing the nullable
Appointments.JobId FK, causing FK_Appointments_Jobs_JobId violations.
Fix nulls out the FK on any linked appointments before the DELETE runs.

Also applies migration AddAllowCustomFormulas (AllowCustomFormulas column
on SubscriptionPlanConfigs for custom formula pricing feature gating).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 08:54:48 -04:00
spouliot 91a176ce5c Add Description field to job CSV import template
JobImportDto was missing Description despite Job entity having the field.
Downloadable template now includes a Description column; the importer maps
it directly to Job.Description with a fallback chain of Description ->
SpecialInstructions -> "Imported job".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 23:41:58 -04:00
spouliot a7ad0e1de8 Add Custom Powder Order line item and fix CSV import FinalPrice crash
Custom powder/incoming powder material cost now flows into a separate
auto-generated 'Custom Powder Order' line item instead of rolling into
individual item prices, so users can add shipping charges before the
customer sees the total. A dashed yellow preview card in the wizard
shows the material cost and lets users edit the total (including shipping)
before saving. After first save the price is user-owned.

Also fixes a fatal CSV import crash when FinalPrice contains a non-numeric
value (e.g. 'false' from a spreadsheet formula): the job CSV importer now
streams rows one at a time with a lenient decimal converter, treating bad
values as $0 with a per-row warning instead of aborting the entire import.

Updated HelpKnowledgeBase.cs and Help articles (Jobs, Quotes) with
Custom Powder Order behavior and a new Data Import / Export section.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 23:37:46 -04:00
spouliot e4a256a6c4 Fix subscription expiry logic and HTML entities in page titles
Subscription expiry (SubscriptionExpiryBackgroundService):
- Trials with no grace period now go directly Active -> Expired instead
  of briefly entering GracePeriod for a day, which was causing repeated
  'Grace Period Started' admin notification emails
- Remove redundant isTrial variable (query already filters to non-Stripe
  companies, so all processed companies are trials by definition)
- Save per-company inside the loop so a single SaveChangesAsync failure
  no longer discards all other companies' status changes and notification
  log entries (which was the other cause of repeated emails)

HTML entities in page titles (33 views):
- Replace &ndash; / &mdash; with plain ' - ' in ViewData["Title"] C#
  strings; Razor HTML-encodes these when rendering @ViewData["Title"],
  causing browsers to display the literal text '&ndash;' instead of a dash

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 09:58:37 -04:00
spouliot e476b4744d Fix subscription expiry logic and HTML entities in page titles
Subscription expiry (SubscriptionExpiryBackgroundService):
- Trials with no grace period now go directly Active -> Expired instead
  of briefly entering GracePeriod for a day, which was causing repeated
  'Grace Period Started' admin notification emails
- Remove redundant isTrial variable (query already filters to non-Stripe
  companies, so all processed companies are trials by definition)
- Save per-company inside the loop so a single SaveChangesAsync failure
  no longer discards all other companies' status changes and notification
  log entries (which was the other cause of repeated emails)

HTML entities in page titles (33 views):
- Replace &ndash; / &mdash; with plain ' - ' in ViewData["Title"] C#
  strings; Razor HTML-encodes these when rendering @ViewData["Title"],
  causing browsers to display the literal text '&ndash;' instead of a dash

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 09:43:41 -04:00
spouliot 04d16109ae Simplify location display on inventory QR label
Plain text 'Location: <value>' in larger bold font instead of
pill badge with map pin icon.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:12:10 -04:00
spouliot f0f3717681 Fix three bugs: vendor duplicate check, page size dropdown, label location
- Vendor Create: reject duplicate company names (case-insensitive) before
  saving; works for both the standalone form and the inline quick-add modal
- _Pagination: define changePageSize() JS function (was called but never
  existed, breaking page size dropdown on every paginated list)
- Inventory Label: show bin/location on printed QR code labels

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:12:07 -04:00
spouliot e23b006139 Add color family filter to inventory index
Adds an 'All Colors' dropdown to the inventory filter bar populated from
the ColorFamilies values already stored on inventory items. Selecting a
family (e.g. 'Red') returns only items tagged with that family.

Also refactors the 16-branch if/else filter builder into a single
composable predicate, making future filter additions trivial.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:12:05 -04:00
spouliot 0f35946973 Fix dark mode: main settings nav tab buttons showing white UA background
The #settingsTabs <button> elements had no explicit background-color,
letting browser UA button styling (white) bleed through in dark mode.
Added transparent overrides so the dark body background shows instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:12:02 -04:00
spouliot 19e1ce858f Simplify location display on inventory QR label
Plain text 'Location: <value>' in larger bold font instead of
pill badge with map pin icon.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 18:08:59 -04:00
spouliot 026e646295 Fix three bugs: vendor duplicate check, page size dropdown, label location
- Vendor Create: reject duplicate company names (case-insensitive) before
  saving; works for both the standalone form and the inline quick-add modal
- _Pagination: define changePageSize() JS function (was called but never
  existed, breaking page size dropdown on every paginated list)
- Inventory Label: show bin/location on printed QR code labels

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:58:23 -04:00
spouliot b7fcefa765 Add color family filter to inventory index
Adds an 'All Colors' dropdown to the inventory filter bar populated from
the ColorFamilies values already stored on inventory items. Selecting a
family (e.g. 'Red') returns only items tagged with that family.

Also refactors the 16-branch if/else filter builder into a single
composable predicate, making future filter additions trivial.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:25:14 -04:00
spouliot 1722cd4124 Fix dark mode: main settings nav tab buttons showing white UA background
The #settingsTabs <button> elements had no explicit background-color,
letting browser UA button styling (white) bleed through in dark mode.
Added transparent overrides so the dark body background shows instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:31:33 -04:00
spouliot c3742e1585 Fix NCalc type error when evaluating AI-generated formulas
NCalc2 operates on double internally; passing decimal parameters caused
'Operator * cannot be applied to double and decimal' at runtime.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 14:27:42 -04:00
spouliot 1a6f855c05 Merge feature/custom-formula-templates into dev 2026-05-24 11:42:22 -04:00
spouliot d28e639d1b Add formula template walkthrough and UX improvements
- 7-step guided walkthrough modal (concept, output modes, fields, formula,
  testing, box example, cylinder example); auto-shown first time the tab
  opens with no templates; always accessible via "How it works" button
- Variable pill badges below formula input showing all valid field names + rate;
  update live as fields are added/renamed; clickable to insert at cursor
- Fix: Add Field no longer shows validation error on blank new rows; validation
  only fires once the user has typed something
- Help article: added Common Formula Patterns section with box, cylinder, and
  flat panel worked examples (fields table + formula + expected output)
- HelpKnowledgeBase updated with pattern examples and walkthrough note

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 11:40:54 -04:00
spouliot 10f668fd73 Merge dev into master for prod deploy 2026-05-24 10:46:23 -04:00
spouliot 19b7a9a473 Fix equipment creation blocked by maintenance interval validation
RecommendedMaintenanceIntervalDays was a non-nullable int with [Range(1,3650)],
so submitting the form without filling it in bound to 0 and failed validation.
Made nullable on the entity, both DTOs, and the one controller callsite that
calls .AddDays() (now uses .Value). Migration applied.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 10:38:05 -04:00
spouliot 4650ba3d4d Fix custom formula wizard bugs and add field name validation
- Fix Add Field blanking inputs: cfFields was IIFE-scoped so inline oninput
  handlers couldn't reach it; expose cfUpdateField on window
- Fix ManualUnitPrice dropped in buildItemFromData: condition excluded
  isCustomFormulaItem, causing FixedRate items to reprice from scratch
- Fix formula card missing on job pages: load CustomFormulaTemplates in
  PopulateJobItemDropDownsAsync so Details, EditItems, and Edit all get it;
  add customFormulaTemplates + formulaEvalUrl to Details and EditItems pageMeta
- Add NCalc field name validation: client-side inline feedback (is-invalid +
  message on oninput) and pre-save sweep; server-side ValidateTemplateFields
  on Create and Update; rules: letter-start, letters/digits/underscores only,
  no duplicates, "rate" reserved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 10:28:41 -04:00
spouliot 1eba50cf0f Add Custom Formula Item Templates with AI generation and wizard integration
Introduces per-company reusable NCalc2 pricing formula templates for complex
fabricated items (roof curbs, enclosures, welded frames). Templates support
two output modes — FixedRate (formula yields a dollar amount) and SurfaceAreaSqFt
(formula yields sq ft fed into the standard coating engine). Includes:

- CustomItemTemplate entity, migration (AddCustomItemTemplates), IUnitOfWork repo
- IsCustomFormulaItem / CustomItemTemplateId / FormulaFieldValuesJson flags on
  QuoteItem, JobItem, CreateQuoteItemDto; mapped in all 3 JobItemAssemblyService
  overloads and all existingItemsData JSON projections + pageMeta blocks
- ICustomFormulaAiService / CustomFormulaAiService: Claude-powered formula
  generator (natural language + optional diagram image) and NCalc2 evaluator
- CompanySettings CRUD endpoints: GetCustomItemTemplates, Create/Update/Delete,
  UploadTemplateDiagram, TemplateDiagram (blob serve), EvaluateFormula, GenerateFormulaFromAi
- Company Settings "Custom Formulas" tab + cfModal + company-settings-custom-formulas.js
- item-wizard.js: formula item type card, renderFormulaFields, wzFormulaRecalc
  (live evaluate via POST), collectStep2 formula branch, buildCardHtml / emitHiddenFields
- Formula badge in Quotes/Details and Jobs/Details; AI badge gap fixed in Jobs/Details
- Help article (CustomFormulaTemplates.cshtml), Help Index card, HelpController action,
  HelpKnowledgeBase entry; 225/225 unit tests passing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 15:09:22 -04:00
spouliot e443457139 Update help docs and AI assistant for QR modal and vendor supply categories
- HelpKnowledgeBase.cs: QR label now opens a preview modal (not new tab); mention list-page QR button; add vendor supply categories knowledge
- Help/Inventory.cshtml: Update QR printing steps for modal workflow; document list-page QR button
- Help/Vendors.cshtml: Add Supply Categories section with filtering behaviour and fallback; add nav link

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 10:48:00 -04:00
spouliot edf56c1164 Fix iframe blocking: SAMEORIGIN + frame-ancestors 'self'
X-Frame-Options: DENY blocked all iframe embeds including our own QR label
modal. Changed to SAMEORIGIN and added frame-ancestors 'self' to CSP so
same-origin iframes (Label page) load correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 10:02:23 -04:00
spouliot b9cd693421 Fix QR label modal: allow self in frame-src CSP, fix Bootstrap API call
frame-src was missing 'self' so the Label iframe was blocked by CSP.
bootstrap.Modal.getOrCreate does not exist; correct method is getOrCreateInstance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 09:59:14 -04:00
spouliot d77b3778ac Add vendor supply categories with inventory auto-filter
Vendors can now be tagged with one or more inventory categories (Powder,
Chemical, etc.) via checkboxes on the Create/Edit form. The inventory
Create/Edit vendor dropdown automatically filters to matching vendors when
a category is selected; falls back to all vendors if none are tagged.
Includes migration AddVendorCategories (VendorInventoryCategories join table).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 09:52:34 -04:00
spouliot a7bf97a2df Add Print QR Label action to inventory list (desktop + mobile)
Opens the same iframe modal used on the Details page; the iframe src is
set dynamically so a single shared modal handles every row.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 09:35:13 -04:00
spouliot 05935b110a Open QR Label in modal instead of a new browser tab
Adds an embed mode to the Label view (hides standalone nav controls) and
an iframe-based modal on Inventory Details. The modal footer Print button
calls contentWindow.print() so the print dialog opens without spawning a
new window.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 09:32:57 -04:00
spouliot 64a9c1531b Fix &mdash; HTML entity rendering across 60 views
Razor's @() expression auto-encodes &, turning &mdash; into &amp;mdash; which
rendered as literal text in the browser. Wrapped all such expressions in
@Html.Raw() so the em-dash entity is passed through unescaped.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 09:27:45 -04:00
spouliot f018653c18 Add rework pricing type (Fixed vs Per-Item) and inline rework flow on Job Details
Adds a PricingType enum to ReworkRecord (FixedPrice | PerItem), surfaces the
choice in the rework modal on Job Details, and wires the resulting unit/total
price display. Includes migration AddReworkPricingType, updated repository
query for rework history, and help article updates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 09:27:34 -04:00
297 changed files with 193835 additions and 2174 deletions
+11
View File
@@ -0,0 +1,11 @@
<Project>
<PropertyGroup>
<!--
NCalc2 2.1.0 -> Antlr4 4.6.4 -> Antlr4.Runtime -> NETStandard.Library 1.6.0 pulls in
old package versions that trigger NU1605 downgrade warnings when publishing for linux-x64.
These are harmless false positives — .NET 8 supplies all of these natively at runtime.
Suppressing NU1605 here is cleaner than pinning every affected transitive package individually.
-->
<NoWarn>$(NoWarn);NU1605</NoWarn>
</PropertyGroup>
</Project>
+431
View File
@@ -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
@@ -44,6 +44,20 @@ namespace PowderCoating.Application.DTOs.Company
/// <summary>True when the company has an accepted agreement for the current SmsTermsVersion.</summary> /// <summary>True when the company has an accepted agreement for the current SmsTermsVersion.</summary>
public bool HasCurrentSmsAgreement { get; set; } public bool HasCurrentSmsAgreement { get; set; }
public string SmsTermsVersion { get; set; } = string.Empty; public string SmsTermsVersion { get; set; } = string.Empty;
// Timeclock settings
public bool TimeclockEnabled { get; set; }
public bool TimeclockAllowMultiplePunchesPerDay { get; set; }
public int? TimeclockAutoClockOutHours { get; set; }
}
/// <summary>DTO for updating company-level timeclock settings from the Settings tab.</summary>
public class UpdateTimeclockSettingsDto
{
public bool TimeclockEnabled { get; set; }
public bool TimeclockAllowMultiplePunchesPerDay { get; set; }
[Range(1, 24, ErrorMessage = "Auto clock-out must be between 1 and 24 hours.")]
public int? TimeclockAutoClockOutHours { get; set; }
} }
/// <summary> /// <summary>
@@ -0,0 +1,174 @@
using System.ComponentModel.DataAnnotations;
namespace PowderCoating.Application.DTOs.Company;
// ============================================================================
// LIST DTO - For Company Settings tab table
// ============================================================================
public class CustomItemTemplateListDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public string OutputMode { get; set; } = "FixedRate";
public int FieldCount { get; set; }
public decimal? DefaultRate { get; set; }
public string? RateLabel { get; set; }
public bool IsActive { get; set; }
public int DisplayOrder { get; set; }
public string? DiagramImagePath { get; set; }
}
// ============================================================================
// FULL DTO - For Edit modal and formula evaluation
// ============================================================================
public class CustomItemTemplateDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public string OutputMode { get; set; } = "FixedRate";
public string FieldsJson { get; set; } = "[]";
public string Formula { get; set; } = string.Empty;
public decimal? DefaultRate { get; set; }
public string? RateLabel { get; set; }
public string? Notes { get; set; }
public int DisplayOrder { get; set; }
public bool IsActive { get; set; }
public string? DiagramImagePath { get; set; }
}
// ============================================================================
// CREATE DTO
// ============================================================================
public class CreateCustomItemTemplateDto
{
[Required]
[StringLength(100)]
public string Name { get; set; } = string.Empty;
[StringLength(500)]
public string? Description { get; set; }
/// <summary>"FixedRate" or "SurfaceAreaSqFt"</summary>
[Required]
public string OutputMode { get; set; } = "FixedRate";
/// <summary>JSON array of field definitions: [{name, label, unit, defaultValue}]</summary>
[Required]
public string FieldsJson { get; set; } = "[]";
[Required]
public string Formula { get; set; } = string.Empty;
public decimal? DefaultRate { get; set; }
[StringLength(50)]
public string? RateLabel { get; set; }
[StringLength(1000)]
public string? Notes { get; set; }
public int DisplayOrder { get; set; }
public bool IsActive { get; set; } = true;
}
// ============================================================================
// UPDATE DTO
// ============================================================================
public class UpdateCustomItemTemplateDto
{
public int Id { get; set; }
[Required]
[StringLength(100)]
public string Name { get; set; } = string.Empty;
[StringLength(500)]
public string? Description { get; set; }
[Required]
public string OutputMode { get; set; } = "FixedRate";
[Required]
public string FieldsJson { get; set; } = "[]";
[Required]
public string Formula { get; set; } = string.Empty;
public decimal? DefaultRate { get; set; }
[StringLength(50)]
public string? RateLabel { get; set; }
[StringLength(1000)]
public string? Notes { get; set; }
public int DisplayOrder { get; set; }
public bool IsActive { get; set; } = true;
/// <summary>Existing diagram path — kept if no new file is uploaded.</summary>
public string? DiagramImagePath { get; set; }
}
// ============================================================================
// WIZARD PICKER DTO - Lean DTO for populating the quote wizard template list
// ============================================================================
public class CustomItemTemplatePickerDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public string OutputMode { get; set; } = "FixedRate";
public string FieldsJson { get; set; } = "[]";
public string Formula { get; set; } = string.Empty;
public decimal? DefaultRate { get; set; }
public string? RateLabel { get; set; }
public string? DiagramImagePath { get; set; }
}
// ============================================================================
// AI GENERATION DTOs
// ============================================================================
public class GenerateFormulaFromAiRequest
{
[Required]
public string Description { get; set; } = string.Empty;
}
public class GenerateFormulaFromAiResponse
{
public bool Success { get; set; }
public string? Error { get; set; }
public string? Name { get; set; }
public string? OutputMode { get; set; }
public string? FieldsJson { get; set; }
public string? Formula { get; set; }
public decimal? DefaultRate { get; set; }
public string? RateLabel { get; set; }
public string? Reasoning { get; set; }
/// <summary>Result of running the formula with any sample values found in the description.</summary>
public decimal? VerificationResult { get; set; }
public string? VerificationInputs { get; set; }
}
// ============================================================================
// FORMULA EVALUATION DTOs
// ============================================================================
public class EvaluateFormulaRequest
{
[Required]
public string Formula { get; set; } = string.Empty;
/// <summary>JSON object of variable name → value pairs, e.g. {"box_l": 43, "rate": 0.05}</summary>
[Required]
public string VariablesJson { get; set; } = "{}";
}
public class EvaluateFormulaResponse
{
public bool Success { get; set; }
public decimal? Result { get; set; }
public string? Error { get; set; }
}
@@ -0,0 +1,78 @@
namespace PowderCoating.Application.DTOs.Company;
// ── Browse / card display ──────────────────────────────────────────────────
/// <summary>Lean DTO for the community library browse grid card.</summary>
public class FormulaLibraryCardDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
public string OutputMode { get; set; } = "FixedRate";
public string? Tags { get; set; }
public string? IndustryHint { get; set; }
public string SourceCompanyName { get; set; } = string.Empty;
public int ImportCount { get; set; }
public DateTime SharedAt { get; set; }
public string? DiagramImagePath { get; set; }
/// <summary>Non-null when this formula was derived from another library entry.</summary>
public int? InspiredByFormulaLibraryItemId { get; set; }
public string? InspiredByName { get; set; }
public string? InspiredByCompanyName { get; set; }
/// <summary>True when the current company has already imported this entry.</summary>
public bool AlreadyImported { get; set; }
/// <summary>True when this formula was shared by the current browsing company.</summary>
public bool IsOwnFormula { get; set; }
/// <summary>Total thumbs-up votes across all companies.</summary>
public int ThumbsUp { get; set; }
/// <summary>Total thumbs-down votes across all companies.</summary>
public int ThumbsDown { get; set; }
/// <summary>The current browsing company's vote: true = up, false = down, null = no vote.</summary>
public bool? MyVote { get; set; }
}
// ── Full detail (import preview modal) ────────────────────────────────────
/// <summary>Full DTO used in the import preview modal — shows fields and formula.</summary>
public class FormulaLibraryDetailDto : FormulaLibraryCardDto
{
public string FieldsJson { get; set; } = "[]";
public string Formula { get; set; } = string.Empty;
public decimal? DefaultRate { get; set; }
public string? RateLabel { get; set; }
public string? Notes { get; set; }
public int FieldCount { get; set; }
}
// ── Share from Company Settings ───────────────────────────────────────────
/// <summary>Submitted when a company admin shares one of their templates to the community library.</summary>
public class ShareFormulaRequest
{
public int CustomItemTemplateId { get; set; }
public string? Tags { get; set; }
public string? IndustryHint { get; set; }
}
// ── Company Settings list view ─────────────────────────────────────────────
/// <summary>Status of a template relative to the community library, shown in Company Settings.</summary>
public class FormulaLibraryStatusDto
{
/// <summary>The FormulaLibraryItem Id, if this template has ever been shared.</summary>
public int? LibraryItemId { get; set; }
public bool IsPublished { get; set; }
/// <summary>Whether this template is eligible to be shared (original or modified import).</summary>
public bool CanShare { get; set; }
/// <summary>Set when this template was imported; the name of the original library entry.</summary>
public string? ImportedFromName { get; set; }
public string? ImportedFromCompany { get; set; }
}
@@ -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 bool NotifyBySms { get; set; }
public DateTime? SmsConsentedAt { get; set; } public DateTime? SmsConsentedAt { get; set; }
public string? SmsConsentMethod { 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 public class CreateCustomerDto : IValidatableObject
@@ -115,6 +125,31 @@ public class CreateCustomerDto : IValidatableObject
[StringLength(2000)] [StringLength(2000)]
public string? GeneralNotes { get; set; } 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")] [Display(Name = "Notify by Email")]
public bool NotifyByEmail { get; set; } = true; public bool NotifyByEmail { get; set; } = true;
@@ -20,7 +20,7 @@ public class EquipmentDto
public string StatusDisplay { get; set; } = string.Empty; public string StatusDisplay { get; set; } = string.Empty;
public string? Location { get; set; } public string? Location { get; set; }
public int RecommendedMaintenanceIntervalDays { get; set; } public int? RecommendedMaintenanceIntervalDays { get; set; }
public DateTime? LastMaintenanceDate { get; set; } public DateTime? LastMaintenanceDate { get; set; }
public DateTime? NextScheduledMaintenance { get; set; } public DateTime? NextScheduledMaintenance { get; set; }
public int? DaysUntilMaintenance { get; set; } public int? DaysUntilMaintenance { get; set; }
@@ -101,7 +101,7 @@ public class CreateEquipmentDto
[Range(1, 3650, ErrorMessage = "Maintenance interval must be between 1 and 3650 days")] [Range(1, 3650, ErrorMessage = "Maintenance interval must be between 1 and 3650 days")]
[Display(Name = "Recommended Maintenance Interval (Days)")] [Display(Name = "Recommended Maintenance Interval (Days)")]
public int RecommendedMaintenanceIntervalDays { get; set; } public int? RecommendedMaintenanceIntervalDays { get; set; }
[Display(Name = "Last Maintenance Date")] [Display(Name = "Last Maintenance Date")]
public DateTime? LastMaintenanceDate { get; set; } public DateTime? LastMaintenanceDate { get; set; }
@@ -63,4 +63,22 @@ public class CustomerImportDto
[Name("Notes")] [Name("Notes")]
public string? Notes { get; set; } 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")] [Name("DueDate")]
public DateTime? DueDate { get; set; } public DateTime? DueDate { get; set; }
[Name("Project Name", "ProjectName")]
public string? ProjectName { get; set; }
[Name("SubTotal")] [Name("SubTotal")]
public decimal SubTotal { get; set; } public decimal SubTotal { get; set; }
@@ -21,6 +21,11 @@ public class JobImportDto
[Name("CustomerName")] [Name("CustomerName")]
public string? CustomerName { get; set; } public string? CustomerName { get; set; }
// Optional short label for the job (maps directly to Job.Description).
// When blank, the system falls back to SpecialInstructions, then "Imported job".
[Name("Description")]
public string? Description { get; set; }
[Name("Status")] [Name("Status")]
public string Status { get; set; } = "Pending"; public string Status { get; set; } = "Pending";
@@ -44,6 +49,9 @@ public class JobImportDto
[Name("SpecialInstructions")] [Name("SpecialInstructions")]
public string? SpecialInstructions { get; set; } public string? SpecialInstructions { get; set; }
[Name("ProjectName")]
public string? ProjectName { get; set; }
[Name("Notes")] [Name("Notes")]
public string? Notes { get; set; } public string? Notes { get; set; }
} }
@@ -38,6 +38,9 @@ public class QuoteImportDto
[Name("ExpirationDate")] [Name("ExpirationDate")]
public DateTime? ExpirationDate { get; set; } public DateTime? ExpirationDate { get; set; }
[Name("ProjectName")]
public string? ProjectName { get; set; }
[Name("Subtotal")] [Name("Subtotal")]
public decimal Subtotal { get; set; } public decimal Subtotal { get; set; }
@@ -57,6 +57,7 @@ public class InvoiceDto
public string? InternalNotes { get; set; } public string? InternalNotes { get; set; }
public string? Terms { get; set; } public string? Terms { get; set; }
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public string? ExternalReference { get; set; } public string? ExternalReference { get; set; }
public int? SalesTaxAccountId { get; set; } public int? SalesTaxAccountId { get; set; }
public string? SalesTaxAccountName { get; set; } public string? SalesTaxAccountName { get; set; }
@@ -88,6 +89,7 @@ public class CreateInvoiceDto
public string? InternalNotes { get; set; } public string? InternalNotes { get; set; }
public string? Terms { get; set; } public string? Terms { get; set; }
public string? CustomerPO { 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> /// <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; } 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> /// <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? InternalNotes { get; set; }
public string? Terms { get; set; } public string? Terms { get; set; }
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public List<CreateInvoiceItemDto> InvoiceItems { get; set; } = new(); public List<CreateInvoiceItemDto> InvoiceItems { get; set; } = new();
} }
@@ -52,6 +52,7 @@ public class JobDto
public decimal DiscountValue { get; set; } public decimal DiscountValue { get; set; }
public string? DiscountReason { get; set; } public string? DiscountReason { get; set; }
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public string? SpecialInstructions { get; set; } public string? SpecialInstructions { get; set; }
public string? InternalNotes { get; set; } public string? InternalNotes { get; set; }
public string? Tags { get; set; } public string? Tags { get; set; }
@@ -113,6 +114,8 @@ public class JobListDto
public string? CustomerEmail { get; set; } public string? CustomerEmail { get; set; }
public bool CustomerNotifyByEmail { get; set; } = true; public bool CustomerNotifyByEmail { get; set; } = true;
public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public DateTime? ScheduledDate { get; set; } public DateTime? ScheduledDate { get; set; }
public DateTime? DueDate { get; set; } public DateTime? DueDate { get; set; }
public decimal FinalPrice { get; set; } public decimal FinalPrice { get; set; }
@@ -166,6 +169,7 @@ public class CreateJobDto
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")] [StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
[Display(Name = "Customer PO")] [Display(Name = "Customer PO")]
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")] [StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
[Display(Name = "Special Instructions")] [Display(Name = "Special Instructions")]
@@ -251,6 +255,7 @@ public class UpdateJobDto
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")] [StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
[Display(Name = "Customer PO")] [Display(Name = "Customer PO")]
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")] [StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
[Display(Name = "Special Instructions")] [Display(Name = "Special Instructions")]
@@ -325,7 +330,11 @@ public class JobItemDto
public bool IsGenericItem { get; set; } public bool IsGenericItem { get; set; }
public bool IsLaborItem { get; set; } public bool IsLaborItem { get; set; }
public bool IsSalesItem { get; set; } public bool IsSalesItem { get; set; }
public bool IsAiItem { get; set; }
public string? Sku { get; set; } public string? Sku { get; set; }
public bool IsCustomFormulaItem { get; set; }
public int? CustomItemTemplateId { get; set; }
public string? FormulaFieldValuesJson { get; set; }
public List<JobItemCoatDto> Coats { get; set; } = new(); public List<JobItemCoatDto> Coats { get; set; } = new();
public List<JobItemPrepServiceDto> PrepServices { get; set; } = new(); public List<JobItemPrepServiceDto> PrepServices { get; set; } = new();
} }
@@ -486,6 +495,7 @@ public class ReworkRecordDto
public decimal ActualReworkCost { get; set; } public decimal ActualReworkCost { get; set; }
public bool IsBillableToCustomer { get; set; } public bool IsBillableToCustomer { get; set; }
public string? BillingNotes { get; set; } public string? BillingNotes { get; set; }
public PowderCoating.Core.Enums.ReworkPricingType? ReworkPricingType { get; set; }
public PowderCoating.Core.Enums.ReworkStatus Status { get; set; } public PowderCoating.Core.Enums.ReworkStatus Status { get; set; }
public string StatusDisplay { get; set; } = string.Empty; public string StatusDisplay { get; set; } = string.Empty;
@@ -511,6 +521,11 @@ public class CreateReworkRecordDto
public decimal EstimatedReworkCost { get; set; } public decimal EstimatedReworkCost { get; set; }
public bool IsBillableToCustomer { get; set; } public bool IsBillableToCustomer { get; set; }
public string? BillingNotes { get; set; } public string? BillingNotes { get; set; }
// Rework job creation (opt-in)
public bool CreateReworkJob { get; set; }
public List<int>? ReworkJobItemIds { get; set; } // null = not creating a job
public PowderCoating.Core.Enums.ReworkPricingType? ReworkPricingType { get; set; }
} }
public class UpdateReworkRecordDto public class UpdateReworkRecordDto
@@ -107,6 +107,7 @@ public class QuoteDto
public string? Terms { get; set; } public string? Terms { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public string? Tags { get; set; } public string? Tags { get; set; }
// Items // Items
@@ -234,6 +235,7 @@ public class CreateQuoteDto
[Display(Name = "Customer PO Number")] [Display(Name = "Customer PO Number")]
[StringLength(50)] [StringLength(50)]
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
[Display(Name = "Tags")] [Display(Name = "Tags")]
[StringLength(500)] [StringLength(500)]
@@ -376,6 +378,7 @@ public class UpdateQuoteDto
[Display(Name = "Customer PO Number")] [Display(Name = "Customer PO Number")]
[StringLength(50)] [StringLength(50)]
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
[Display(Name = "Tags")] [Display(Name = "Tags")]
[StringLength(500)] [StringLength(500)]
@@ -475,6 +478,11 @@ public class QuoteItemDto
public bool IsAiItem { get; set; } public bool IsAiItem { get; set; }
// Custom formula item
public bool IsCustomFormulaItem { get; set; }
public int? CustomItemTemplateId { get; set; }
public string? FormulaFieldValuesJson { get; set; }
// Cost breakdown snapshot // Cost breakdown snapshot
public decimal ItemMaterialCost { get; set; } public decimal ItemMaterialCost { get; set; }
public decimal ItemLaborCost { get; set; } public decimal ItemLaborCost { get; set; }
@@ -559,6 +567,11 @@ public class CreateQuoteItemDto
// ID of the AiItemPrediction record captured at analysis time (null for non-AI items) // ID of the AiItemPrediction record captured at analysis time (null for non-AI items)
public int? AiPredictionId { get; set; } public int? AiPredictionId { get; set; }
// Custom formula item routing — see IsCustomFormulaItem in PricingCalculationService
public bool IsCustomFormulaItem { get; set; }
public int? CustomItemTemplateId { get; set; }
public string? FormulaFieldValuesJson { get; set; }
} }
// ============================================================================ // ============================================================================
@@ -874,4 +887,9 @@ public class QuotePricingResult
// Per-item results (same order as input items) // Per-item results (same order as input items)
public List<QuoteItemPricingResult> ItemResults { get; set; } = new(); public List<QuoteItemPricingResult> ItemResults { get; set; } = new();
// Pending Custom Powder Order preview — populated only when no "Custom Powder Order" item
// exists yet (first save scenario). Amount and color list let the UI show a preview row.
public decimal CustomPowderOrderAmount { get; set; }
public List<string> CustomPowderOrderColors { get; set; } = new();
} }
@@ -26,6 +26,7 @@ public class SubscriptionPlanConfigDto
public bool AllowAiInventoryAssist { get; set; } public bool AllowAiInventoryAssist { get; set; }
public bool AllowAiCatalogPriceCheck { get; set; } public bool AllowAiCatalogPriceCheck { get; set; }
public bool AllowSms { get; set; } public bool AllowSms { get; set; }
public bool AllowCustomFormulas { get; set; }
public bool IsActive { get; set; } public bool IsActive { get; set; }
public int SortOrder { get; set; } public int SortOrder { get; set; }
} }
@@ -74,6 +75,7 @@ public class UpdateSubscriptionPlanConfigDto
public bool AllowAiInventoryAssist { get; set; } public bool AllowAiInventoryAssist { get; set; }
public bool AllowAiCatalogPriceCheck { get; set; } public bool AllowAiCatalogPriceCheck { get; set; }
public bool AllowSms { get; set; } public bool AllowSms { get; set; }
public bool AllowCustomFormulas { get; set; }
public bool IsActive { get; set; } public bool IsActive { get; set; }
} }
@@ -0,0 +1,74 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Application.DTOs.Timeclock;
public class EmployeeClockEntryDto
{
public int Id { get; set; }
public string UserId { get; set; } = string.Empty;
public string UserDisplayName { get; set; } = string.Empty;
public DateTime ClockInTime { get; set; }
public DateTime? ClockOutTime { get; set; }
public decimal? HoursWorked { get; set; }
public ClockEntryType EntryType { get; set; } = ClockEntryType.Work;
public string? Notes { get; set; }
public bool IsOpen => ClockOutTime == null;
}
public class ClockInRequest
{
public string? Notes { get; set; }
}
public class ClockOutRequest
{
public int EntryId { get; set; }
public string? Notes { get; set; }
}
/// <summary>
/// Request sent from the kiosk tablet — employee taps their tile and enters a PIN.
/// The server determines whether to clock in or clock out based on the employee's open entry.
/// </summary>
public class KioskPunchRequest
{
public string UserId { get; set; } = string.Empty;
public string Pin { get; set; } = string.Empty;
}
public class EditClockEntryRequest
{
public int Id { get; set; }
public DateTime ClockInTime { get; set; }
public DateTime? ClockOutTime { get; set; }
public string? Notes { get; set; }
}
/// <summary>
/// Sent when an employee clicks Break or Lunch to pause their work segment.
/// The server closes the current Work entry and opens a Break/Lunch entry.
/// </summary>
public class GoOnBreakRequest
{
/// <summary>Must be <see cref="ClockEntryType.Break"/> or <see cref="ClockEntryType.Lunch"/>.</summary>
public ClockEntryType BreakType { get; set; }
}
/// <summary>Manager request to create a time entry on behalf of any company employee.</summary>
public class ManualEntryRequest
{
public string UserId { get; set; } = string.Empty;
public DateTime ClockInTime { get; set; }
public DateTime? ClockOutTime { get; set; }
public string? Notes { get; set; }
}
/// <summary>Employee tile shown on the kiosk employee-selection grid.</summary>
public class KioskEmployeeDto
{
public string UserId { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public string Initials { get; set; } = string.Empty;
/// <summary>True when the employee has an open clock entry right now.</summary>
public bool IsClockedIn { get; set; }
}
@@ -125,6 +125,8 @@ public class CreateVendorDto
[Display(Name = "Default Expense Account")] [Display(Name = "Default Expense Account")]
public int? DefaultExpenseAccountId { get; set; } public int? DefaultExpenseAccountId { get; set; }
public List<int> CategoryIds { get; set; } = new();
} }
// ============================================================================ // ============================================================================
@@ -209,4 +211,6 @@ public class UpdateVendorDto
[Display(Name = "Default Expense Account")] [Display(Name = "Default Expense Account")]
public int? DefaultExpenseAccountId { get; set; } public int? DefaultExpenseAccountId { get; set; }
public List<int> CategoryIds { get; set; } = new();
} }
@@ -18,7 +18,8 @@ public class WizardProgressDto
public bool IsStepSkipped(int step) => SkippedSteps.Contains(step); public bool IsStepSkipped(int step) => SkippedSteps.Contains(step);
public bool IsStepTouched(int step) => IsStepDone(step) || IsStepSkipped(step); public bool IsStepTouched(int step) => IsStepDone(step) || IsStepSkipped(step);
public int CompletedCount => DoneSteps.Count + SkippedSteps.Count; // Capped at TotalSteps so old step data from a larger wizard doesn't overflow the display.
public int CompletedCount => Math.Min(DoneSteps.Count + SkippedSteps.Count, TotalSteps);
public int ProgressPercent => TotalSteps == 0 ? 0 : (int)Math.Round((double)CompletedCount / TotalSteps * 100); public int ProgressPercent => TotalSteps == 0 ? 0 : (int)Math.Round((double)CompletedCount / TotalSteps * 100);
} }
@@ -0,0 +1,30 @@
using PowderCoating.Application.DTOs.Company;
namespace PowderCoating.Application.Interfaces;
public interface ICustomFormulaAiService
{
/// <summary>
/// Generates a NCalc formula, field list, and notes from a natural-language description
/// and an optional diagram image. Returns a <see cref="GenerateFormulaFromAiResponse"/>
/// ready to pre-fill the template editor.
/// </summary>
Task<GenerateFormulaFromAiResponse> GenerateFormulaAsync(
GenerateFormulaFromAiRequest request,
byte[]? imageBytes = null,
string? imageContentType = null);
/// <summary>
/// Evaluates a NCalc formula with the supplied variable map and returns the numeric result.
/// Safe server-side only — no user-controlled code execution.
/// </summary>
EvaluateFormulaResponse EvaluateFormula(EvaluateFormulaRequest request);
/// <summary>
/// Normalizes NCalc built-in function names to lowercase (IF→if, Abs→abs, etc.) then
/// attempts a parse-only evaluation to catch syntax errors before the formula is saved.
/// Returns the normalized formula string and a null error on success, or the original
/// formula and an error message on failure.
/// </summary>
(string NormalizedFormula, string? Error) NormalizeAndValidate(string formula);
}
@@ -0,0 +1,61 @@
using PowderCoating.Application.DTOs.Company;
namespace PowderCoating.Application.Interfaces;
/// <summary>
/// Manages the community formula library: sharing, unsharing, importing, and browsing.
/// </summary>
public interface IFormulaLibraryService
{
/// <summary>
/// Returns all published library entries, with AlreadyImported populated for the given company.
/// Optionally filters by search term, output mode, or industry hint.
/// </summary>
Task<IEnumerable<FormulaLibraryCardDto>> BrowseAsync(
int companyId,
string? search = null,
string? outputMode = null,
string? industryHint = null);
/// <summary>Full detail for the import preview modal, including field list and formula.</summary>
Task<FormulaLibraryDetailDto?> GetDetailAsync(int libraryItemId, int companyId);
/// <summary>
/// Publishes a company template to the community library.
/// If the template was previously shared and unpublished, re-publishes the existing row.
/// Updates the library entry fields from the current template state on re-share.
/// </summary>
Task<int> ShareAsync(int companyId, string userId, ShareFormulaRequest request);
/// <summary>Sets IsPublished = false. Existing imports are unaffected.</summary>
Task UnshareAsync(int libraryItemId, int companyId);
/// <summary>
/// Copies a library entry into the company's local CustomItemTemplate table.
/// If the company already has an import record for this entry, returns the existing template id.
/// </summary>
Task<int> ImportAsync(int libraryItemId, int companyId, string userId);
/// <summary>
/// Returns the library status for a given CustomItemTemplate — whether it is shared,
/// eligible to be shared, and where it was imported from if applicable.
/// </summary>
Task<FormulaLibraryStatusDto> GetTemplateLibraryStatusAsync(int templateId, int companyId);
/// <summary>
/// Nulls out DiagramImagePath on the FormulaLibraryItem and all imported copies
/// when a source template's diagram is removed. Call from CompanySettingsController
/// when a diagram is deleted or replaced.
/// </summary>
Task CascadeRemoveDiagramAsync(int sourceCustomItemTemplateId);
/// <summary>
/// Records or toggles a thumbs-up/down vote from the given company.
/// If the same vote already exists it is removed (toggle off).
/// If the opposite vote exists it is replaced.
/// Companies cannot rate their own formulas.
/// Returns the updated counts for the library entry.
/// </summary>
Task<(int ThumbsUp, int ThumbsDown, bool? MyVote)> RateAsync(
int libraryItemId, int companyId, bool isPositive);
}
@@ -13,4 +13,12 @@ public interface IQuotePricingAssemblyService
int companyId, int companyId,
decimal? ovenRateOverride, decimal? ovenRateOverride,
DateTime createdAtUtc); DateTime createdAtUtc);
/// <summary>
/// Creates one <see cref="InventoryItem"/> (IsIncoming=true) per unique powder catalog entry
/// referenced by coats on the given quote, then links those coats to the new inventory records.
/// Must be called after a quote transitions to Approved status.
/// Safe to call multiple times — coats that already have an InventoryItemId are skipped.
/// </summary>
Task EnsureIncomingInventoryForApprovedQuoteAsync(int quoteId, int companyId);
} }
@@ -45,6 +45,9 @@ public class RemoveSeedDataOptions
public bool Catalog { get; set; } public bool Catalog { get; set; }
public bool PricingTiers { get; set; } public bool PricingTiers { get; set; }
public bool OperatingCosts { get; set; } public bool OperatingCosts { get; set; }
public bool Bills { get; set; }
public bool Expenses { get; set; }
public bool Workers { get; set; }
} }
public class SeedDataResult public class SeedDataResult
@@ -0,0 +1,41 @@
using AutoMapper;
using PowderCoating.Core.Entities;
using PowderCoating.Application.DTOs.Company;
namespace PowderCoating.Application.Mappings;
public class CustomItemTemplateProfile : Profile
{
public CustomItemTemplateProfile()
{
CreateMap<CustomItemTemplate, CustomItemTemplateListDto>()
.ForMember(dest => dest.FieldCount,
opt => opt.MapFrom(src => CountFields(src.FieldsJson)));
CreateMap<CustomItemTemplate, CustomItemTemplateDto>();
CreateMap<CustomItemTemplate, CustomItemTemplatePickerDto>();
CreateMap<CreateCustomItemTemplateDto, CustomItemTemplate>();
CreateMap<UpdateCustomItemTemplateDto, CustomItemTemplate>()
.ForMember(dest => dest.DiagramImagePath, opt => opt.Ignore()); // set by controller after blob upload
CreateMap<CustomItemTemplate, UpdateCustomItemTemplateDto>();
}
private static int CountFields(string fieldsJson)
{
try
{
var doc = System.Text.Json.JsonDocument.Parse(fieldsJson);
return doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array
? doc.RootElement.GetArrayLength()
: 0;
}
catch
{
return 0;
}
}
}
@@ -41,5 +41,12 @@ public class CustomerProfile : Profile
opt => opt.MapFrom(src => !string.IsNullOrEmpty(src.ContactFirstName) || !string.IsNullOrEmpty(src.ContactLastName) opt => opt.MapFrom(src => !string.IsNullOrEmpty(src.ContactFirstName) || !string.IsNullOrEmpty(src.ContactLastName)
? $"{src.ContactFirstName} {src.ContactLastName}".Trim() ? $"{src.ContactFirstName} {src.ContactLastName}".Trim()
: string.Empty)); : 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>();
} }
} }
@@ -0,0 +1,35 @@
using AutoMapper;
using PowderCoating.Core.Entities;
using PowderCoating.Application.DTOs.Company;
namespace PowderCoating.Application.Mappings;
public class FormulaLibraryProfile : Profile
{
public FormulaLibraryProfile()
{
CreateMap<FormulaLibraryItem, FormulaLibraryCardDto>()
.ForMember(dest => dest.InspiredByName,
opt => opt.MapFrom(src => src.InspiredBy != null ? src.InspiredBy.Name : null))
.ForMember(dest => dest.InspiredByCompanyName,
opt => opt.MapFrom(src => src.InspiredBy != null ? src.InspiredBy.SourceCompanyName : null))
.ForMember(dest => dest.AlreadyImported, opt => opt.Ignore()); // set by service
CreateMap<FormulaLibraryItem, FormulaLibraryDetailDto>()
.IncludeBase<FormulaLibraryItem, FormulaLibraryCardDto>()
.ForMember(dest => dest.FieldCount,
opt => opt.MapFrom(src => CountFields(src.FieldsJson)));
}
private static int CountFields(string fieldsJson)
{
try
{
var doc = System.Text.Json.JsonDocument.Parse(fieldsJson);
return doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array
? doc.RootElement.GetArrayLength()
: 0;
}
catch { return 0; }
}
}
@@ -19,6 +19,7 @@ public class InvoiceProfile : Profile
CreateMap<Invoice, InvoiceDto>() CreateMap<Invoice, InvoiceDto>()
.ForMember(d => d.JobNumber, o => o.MapFrom(s => s.Job != null ? s.Job.JobNumber : string.Empty)) .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 .ForMember(d => d.CustomerName, o => o.MapFrom(s => s.Customer != null
? (s.Customer.IsCommercial ? (s.Customer.IsCommercial
? s.Customer.CompanyName ? s.Customer.CompanyName
@@ -196,7 +196,9 @@ public class JobProfile : Profile
.ForMember(dest => dest.JobItemDescription, .ForMember(dest => dest.JobItemDescription,
opt => opt.MapFrom(src => src.JobItem != null ? src.JobItem.Description : null)) opt => opt.MapFrom(src => src.JobItem != null ? src.JobItem.Description : null))
.ForMember(dest => dest.ReworkJobNumber, .ForMember(dest => dest.ReworkJobNumber,
opt => opt.MapFrom(src => src.ReworkJob != null ? src.ReworkJob.JobNumber : null)); opt => opt.MapFrom(src => src.ReworkJob != null ? src.ReworkJob.JobNumber : null))
.ForMember(dest => dest.ReworkPricingType,
opt => opt.MapFrom(src => src.ReworkPricingType));
// Job → JobDto (rework fields) // Job → JobDto (rework fields)
// (IsReworkJob and OriginalJobId map by convention; OriginalJobNumber needs explicit map — handled in controller) // (IsReworkJob and OriginalJobId map by convention; OriginalJobNumber needs explicit map — handled in controller)
@@ -159,6 +159,7 @@ public class QuoteProfile : Profile
.ReverseMap() .ReverseMap()
.ForMember(dest => dest.Quote, opt => opt.Ignore()) .ForMember(dest => dest.Quote, opt => opt.Ignore())
.ForMember(dest => dest.CatalogItem, opt => opt.Ignore()) .ForMember(dest => dest.CatalogItem, opt => opt.Ignore())
.ForMember(dest => dest.CustomItemTemplate, opt => opt.Ignore())
.ForMember(dest => dest.Coats, opt => opt.Ignore()) .ForMember(dest => dest.Coats, opt => opt.Ignore())
.ForMember(dest => dest.PrepServices, opt => opt.Ignore()) .ForMember(dest => dest.PrepServices, opt => opt.Ignore())
.ForMember(dest => dest.CompanyId, opt => opt.Ignore()) .ForMember(dest => dest.CompanyId, opt => opt.Ignore())
@@ -180,6 +181,7 @@ public class QuoteProfile : Profile
.ForMember(dest => dest.Coats, opt => opt.Ignore()) // Mapped separately .ForMember(dest => dest.Coats, opt => opt.Ignore()) // Mapped separately
.ForMember(dest => dest.PrepServices, opt => opt.Ignore()) // Mapped separately .ForMember(dest => dest.PrepServices, opt => opt.Ignore()) // Mapped separately
.ForMember(dest => dest.CatalogItem, opt => opt.Ignore()) .ForMember(dest => dest.CatalogItem, opt => opt.Ignore())
.ForMember(dest => dest.CustomItemTemplate, opt => opt.Ignore()) // FK only; nav set by EF
.ForMember(dest => dest.CompanyId, opt => opt.Ignore()) .ForMember(dest => dest.CompanyId, opt => opt.Ignore())
.ForMember(dest => dest.CreatedAt, opt => opt.Ignore()) .ForMember(dest => dest.CreatedAt, opt => opt.Ignore())
.ForMember(dest => dest.UpdatedAt, opt => opt.Ignore()) .ForMember(dest => dest.UpdatedAt, opt => opt.Ignore())
@@ -190,7 +192,10 @@ public class QuoteProfile : Profile
.ForMember(dest => dest.UpdatedBy, opt => opt.Ignore()); .ForMember(dest => dest.UpdatedBy, opt => opt.Ignore());
// QuoteItem -> CreateQuoteItemDto (for Edit view) // QuoteItem -> CreateQuoteItemDto (for Edit view)
// Coats and PrepServices must be mapped explicitly; convention-based collection mapping
// is unreliable for ICollection<T> → List<T2> with different element types.
CreateMap<QuoteItem, CreateQuoteItemDto>() CreateMap<QuoteItem, CreateQuoteItemDto>()
.ForMember(dest => dest.Coats, opt => opt.MapFrom(src => src.Coats))
.ForMember(dest => dest.PrepServices, opt => opt.MapFrom(src => src.PrepServices)); .ForMember(dest => dest.PrepServices, opt => opt.MapFrom(src => src.PrepServices));
// ============================================================================ // ============================================================================
@@ -16,6 +16,7 @@
<PackageReference Include="FluentValidation" Version="11.11.0" /> <PackageReference Include="FluentValidation" Version="11.11.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" /> <PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="NCalc2" Version="2.1.0" />
<PackageReference Include="QuestPDF" Version="2024.12.3" /> <PackageReference Include="QuestPDF" Version="2024.12.3" />
@@ -53,7 +53,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
IncludePrepCost = source.IncludePrepCost, IncludePrepCost = source.IncludePrepCost,
Complexity = source.Complexity, Complexity = source.Complexity,
AiTags = source.AiTags, AiTags = source.AiTags,
AiPredictionId = source.AiPredictionId AiPredictionId = source.AiPredictionId,
IsCustomFormulaItem = source.IsCustomFormulaItem,
CustomItemTemplateId = source.CustomItemTemplateId,
FormulaFieldValuesJson = source.FormulaFieldValuesJson
}, },
jobId, jobId,
companyId, companyId,
@@ -157,7 +160,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
IncludePrepCost = source.IncludePrepCost, IncludePrepCost = source.IncludePrepCost,
Complexity = source.Complexity, Complexity = source.Complexity,
AiTags = source.AiTags, AiTags = source.AiTags,
AiPredictionId = source.AiPredictionId AiPredictionId = source.AiPredictionId,
IsCustomFormulaItem = source.IsCustomFormulaItem,
CustomItemTemplateId = source.CustomItemTemplateId,
FormulaFieldValuesJson = source.FormulaFieldValuesJson
}, },
jobId, jobId,
companyId, companyId,
@@ -259,7 +265,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
IncludePrepCost = source.IncludePrepCost, IncludePrepCost = source.IncludePrepCost,
Complexity = source.Complexity, Complexity = source.Complexity,
AiTags = source.AiTags, AiTags = source.AiTags,
AiPredictionId = source.AiPredictionId AiPredictionId = source.AiPredictionId,
IsCustomFormulaItem = source.IsCustomFormulaItem,
CustomItemTemplateId = source.CustomItemTemplateId,
FormulaFieldValuesJson = source.FormulaFieldValuesJson
}, },
jobId, jobId,
companyId, companyId,
@@ -353,6 +362,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
Complexity = seed.Complexity, Complexity = seed.Complexity,
AiTags = seed.AiTags, AiTags = seed.AiTags,
AiPredictionId = seed.AiPredictionId, AiPredictionId = seed.AiPredictionId,
IsCustomFormulaItem = seed.IsCustomFormulaItem,
CustomItemTemplateId = seed.CustomItemTemplateId,
FormulaFieldValuesJson = seed.FormulaFieldValuesJson,
CompanyId = companyId, CompanyId = companyId,
CreatedAt = createdAtUtc CreatedAt = createdAtUtc
}; };
@@ -480,6 +492,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
public string? Complexity { get; init; } public string? Complexity { get; init; }
public string? AiTags { get; init; } public string? AiTags { get; init; }
public int? AiPredictionId { get; init; } public int? AiPredictionId { get; init; }
public bool IsCustomFormulaItem { get; init; }
public int? CustomItemTemplateId { get; init; }
public string? FormulaFieldValuesJson { get; init; }
} }
/// <summary>Intermediate value object for coat creation — see <see cref="JobItemSeed"/> for rationale.</summary> /// <summary>Intermediate value object for coat creation — see <see cref="JobItemSeed"/> for rationale.</summary>
@@ -98,12 +98,7 @@ public class PdfService : IPdfService
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial")); page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
page.Header().Element(c => ComposeInvoiceHeader(c, companyLogo, companyInfo, accentColor, invoiceDto)); page.Header().Element(c => ComposeInvoiceHeader(c, companyLogo, companyInfo, accentColor, invoiceDto));
page.Content().Layers(layers => page.Content().Element(c => ComposeInvoiceContent(c, invoiceDto, accentColor, template));
{
layers.PrimaryLayer().Element(c => ComposeInvoiceContent(c, invoiceDto, accentColor, template));
if (invoiceDto.Status == InvoiceStatus.Paid)
layers.Layer().Element(c => ComposePaidStamp(c));
});
page.Footer().AlignCenter().Text(text => page.Footer().AlignCenter().Text(text =>
{ {
text.CurrentPageNumber(); text.CurrentPageNumber();
@@ -148,8 +143,18 @@ public class PdfService : IPdfService
column.Item().Text(cityLine).FontSize(9).FontColor(Colors.Grey.Darken1); column.Item().Text(cityLine).FontSize(9).FontColor(Colors.Grey.Darken1);
if (!string.IsNullOrWhiteSpace(companyInfo.Phone)) if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
column.Item().Text(FormatPhoneNumber(companyInfo.Phone)).FontSize(9).FontColor(Colors.Grey.Darken1); 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 => row.RelativeItem().AlignRight().Column(column =>
{ {
column.Item().Text("INVOICE").FontSize(28).Bold().FontColor(accentColor); 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> /// <summary>
/// Composes the body of the invoice PDF: bill-to address block, job reference, alternating-row /// 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, /// 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}"); c.Item().Text($"Job #: {invoice.JobNumber}");
if (!string.IsNullOrWhiteSpace(invoice.CustomerPO)) if (!string.IsNullOrWhiteSpace(invoice.CustomerPO))
c.Item().Text($"PO #: {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); 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);
});
}
}); });
} }
@@ -220,6 +220,16 @@ public class PricingCalculationService : IPricingCalculationService
}; };
} }
/// <summary>
/// Returns true when a coat requires ordering custom powder that is not in inventory.
/// Only coats with an explicit PowderToOrder quantity qualify — coats without a quantity
/// fall through to the standard surface-area pricing path in CalculateCoatPriceAsync.
/// </summary>
private static bool IsCustomPowderCoat(CreateQuoteItemCoatDto coat) =>
!coat.InventoryItemId.HasValue &&
coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 &&
coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0;
/// <summary> /// <summary>
/// Calculates the total price for a single quote line item, routing to the correct pricing /// Calculates the total price for a single quote line item, routing to the correct pricing
/// path based on item type: /// path based on item type:
@@ -288,6 +298,26 @@ public class PricingCalculationService : IPricingCalculationService
}; };
} }
// Custom formula items (FixedRate mode): the wizard evaluated the NCalc formula server-side
// and stored the per-item result as ManualUnitPrice. Multiply by Quantity for the total,
// exactly like every other item type that uses ManualUnitPrice.
// SurfaceAreaSqFt mode: ManualUnitPrice is null; the formula produced sqft which was stored
// in SurfaceAreaSqFt, so the item falls through to the standard calculated path below.
if (item.IsCustomFormulaItem && item.ManualUnitPrice.HasValue)
{
var formulaUnitPrice = item.ManualUnitPrice.Value;
var formulaTotal = formulaUnitPrice * item.Quantity;
return new QuoteItemPricingResult
{
MaterialCost = 0,
LaborCost = 0,
EquipmentCost = 0,
ItemSubtotal = formulaTotal,
UnitPrice = formulaUnitPrice,
TotalPrice = formulaTotal
};
}
// Sales items (off-the-shelf merchandise) — manual unit price, no coating calculation. // Sales items (off-the-shelf merchandise) — manual unit price, no coating calculation.
if (item.IsSalesItem && item.ManualUnitPrice.HasValue) if (item.IsSalesItem && item.ManualUnitPrice.HasValue)
{ {
@@ -312,6 +342,8 @@ public class PricingCalculationService : IPricingCalculationService
{ {
for (int i = 0; i < item.Coats.Count; i++) for (int i = 0; i < item.Coats.Count; i++)
{ {
// Custom powder material moves to the "Custom Powder Order" line item
if (IsCustomPowderCoat(item.Coats[i])) continue;
var coatResult = await CalculateCoatPriceAsync( var coatResult = await CalculateCoatPriceAsync(
item.Coats[i], 0m, item.Quantity, i, 0, companyId); item.Coats[i], 0m, item.Quantity, i, 0, companyId);
coatMaterialCost += coatResult.CoatMaterialCost; coatMaterialCost += coatResult.CoatMaterialCost;
@@ -413,7 +445,9 @@ public class PricingCalculationService : IPricingCalculationService
for (int ci = 0; ci < item.Coats.Count; ci++) for (int ci = 0; ci < item.Coats.Count; ci++)
{ {
var coat = item.Coats[ci]; var coat = item.Coats[ci];
if (!coat.InventoryItemId.HasValue && coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0) // Custom powder with PowderToOrder moves to the "Custom Powder Order" line item; skip here
if (!coat.InventoryItemId.HasValue && coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0
&& !IsCustomPowderCoat(coat))
{ {
var coatResult = await CalculateCoatPriceAsync(coat, item.SurfaceAreaSqFt, item.Quantity, ci, 0, companyId); var coatResult = await CalculateCoatPriceAsync(coat, item.SurfaceAreaSqFt, item.Quantity, ci, 0, companyId);
totalMaterialCost += coatResult.CoatMaterialCost; totalMaterialCost += coatResult.CoatMaterialCost;
@@ -431,7 +465,8 @@ public class PricingCalculationService : IPricingCalculationService
{ {
var firstCoatResult = await CalculateCoatPriceAsync( var firstCoatResult = await CalculateCoatPriceAsync(
item.Coats[0], item.SurfaceAreaSqFt, item.Quantity, 0, item.EstimatedMinutes, companyId); item.Coats[0], item.SurfaceAreaSqFt, item.Quantity, 0, item.EstimatedMinutes, companyId);
totalMaterialCost = firstCoatResult.CoatMaterialCost; // Custom powder material moves to the "Custom Powder Order" line item; keep the labor
totalMaterialCost = IsCustomPowderCoat(item.Coats[0]) ? 0m : firstCoatResult.CoatMaterialCost;
coatLaborCost = firstCoatResult.CoatLaborCost; coatLaborCost = firstCoatResult.CoatLaborCost;
totalLaborCost = coatLaborCost; totalLaborCost = coatLaborCost;
} }
@@ -628,6 +663,49 @@ public class PricingCalculationService : IPricingCalculationService
// 4. TOTAL ITEMS SUBTOTAL // 4. TOTAL ITEMS SUBTOTAL
var itemsSubtotal = catalogItemsWithoutCoatsTotal + calculatedItemsSubtotal; var itemsSubtotal = catalogItemsWithoutCoatsTotal + calculatedItemsSubtotal;
// Powder-to-order costs are excluded from individual item prices and collected in a
// "Custom Powder Order" line item added at save time. For live pricing previews (before
// save), add them back here so the displayed total stays correct throughout the session.
// Two coat types qualify: custom powder (no InventoryItemId, manual PowderCostPerLb) and
// incoming powder (InventoryItemId set, IsIncoming=true, cost from inventoryItem.UnitCost).
bool hasCustomPowderOrderItem = items.Any(i =>
i.IsGenericItem && i.Description?.StartsWith("Custom Powder Order") == true);
decimal customPowderOrderAmount = 0m;
var customPowderOrderColors = new List<string>();
if (!hasCustomPowderOrderItem)
{
foreach (var item in items.Where(i => i.Coats != null))
{
foreach (var c in item.Coats!)
{
if (!c.InventoryItemId.HasValue &&
c.PowderToOrder.HasValue && c.PowderToOrder.Value > 0 &&
c.PowderCostPerLb.HasValue && c.PowderCostPerLb.Value > 0)
{
customPowderOrderAmount += c.PowderToOrder.Value * c.PowderCostPerLb.Value;
if (!string.IsNullOrWhiteSpace(c.ColorName))
customPowderOrderColors.Add(c.ColorName);
}
else if (c.InventoryItemId.HasValue && c.PowderToOrder.HasValue && c.PowderToOrder.Value > 0)
{
var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(c.InventoryItemId.Value);
if (invItem?.IsIncoming == true)
{
customPowderOrderAmount += c.PowderToOrder.Value * invItem.UnitCost;
var colorName = !string.IsNullOrWhiteSpace(c.ColorName) ? c.ColorName : invItem.Name;
if (!string.IsNullOrWhiteSpace(colorName))
customPowderOrderColors.Add(colorName);
}
}
}
}
if (customPowderOrderAmount > 0)
{
itemsSubtotal += customPowderOrderAmount;
totalMaterialCosts += customPowderOrderAmount;
}
}
// 4b. OVEN BATCH COST (quote-level: batches × cycle time × oven rate) // 4b. OVEN BATCH COST (quote-level: batches × cycle time × oven rate)
// AI items already have oven cost baked into their AI-estimated price, so we only // AI items already have oven cost baked into their AI-estimated price, so we only
// charge the proportion of the oven that's attributable to non-AI items. // charge the proportion of the oven that's attributable to non-AI items.
@@ -806,7 +884,11 @@ public class PricingCalculationService : IPricingCalculationService
MaterialCosts = Math.Round(totalMaterialCosts, 2), MaterialCosts = Math.Round(totalMaterialCosts, 2),
LaborCosts = Math.Round(totalLaborCosts, 2), LaborCosts = Math.Round(totalLaborCosts, 2),
EquipmentCosts = Math.Round(totalEquipmentCosts, 2), EquipmentCosts = Math.Round(totalEquipmentCosts, 2),
ItemResults = itemResults ItemResults = itemResults,
CustomPowderOrderAmount = Math.Round(customPowderOrderAmount, 2),
CustomPowderOrderColors = customPowderOrderColors
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList()
}; };
} }
} }
@@ -90,8 +90,9 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
{ {
ArgumentNullException.ThrowIfNull(itemDtos); ArgumentNullException.ThrowIfNull(itemDtos);
var dtoList = itemDtos.ToList();
var items = new List<QuoteItem>(); var items = new List<QuoteItem>();
foreach (var itemDto in itemDtos) foreach (var itemDto in dtoList)
{ {
var item = BuildQuoteItem(itemDto, quoteId, companyId, createdAtUtc); var item = BuildQuoteItem(itemDto, quoteId, companyId, createdAtUtc);
await ApplyPricingAsync(item, itemDto, companyId, ovenRateOverride); await ApplyPricingAsync(item, itemDto, companyId, ovenRateOverride);
@@ -102,6 +103,17 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
items.Add(item); items.Add(item);
} }
// Option B: auto-create the Custom Powder Order item only on first save.
// Once user-owned, they manage its price (e.g. to add shipping) — we never overwrite it.
bool hasExistingCustomPowderOrder = dtoList.Any(d =>
d.IsGenericItem && d.Description?.StartsWith("Custom Powder Order") == true);
if (!hasExistingCustomPowderOrder)
{
var customPowderItem = await BuildCustomPowderOrderItemAsync(dtoList, quoteId, companyId, createdAtUtc);
if (customPowderItem != null)
items.Add(customPowderItem);
}
return items; return items;
} }
@@ -130,6 +142,14 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
return; return;
} }
if (itemDto.IsCustomFormulaItem && itemDto.ManualUnitPrice.HasValue)
{
item.UnitPrice = itemDto.ManualUnitPrice.Value;
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
_logger.LogInformation("Custom formula item (FixedRate) price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
return;
}
if (itemDto.CatalogItemId.HasValue) if (itemDto.CatalogItemId.HasValue)
{ {
if (itemDto.Coats != null && itemDto.Coats.Any()) if (itemDto.Coats != null && itemDto.Coats.Any())
@@ -161,9 +181,10 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
/// <summary> /// <summary>
/// Builds <see cref="QuoteItemCoat"/> entities for a single item, including per-coat pricing. /// Builds <see cref="QuoteItemCoat"/> entities for a single item, including per-coat pricing.
/// If a coat has <c>AddAsIncoming = true</c> and references a catalog item but not an inventory /// When a coat references the platform catalog (<c>CatalogItemId</c> set), the ID is stored on
/// item, an incoming <see cref="InventoryItem"/> is auto-created so the shop can track the powder /// <see cref="QuoteItemCoat.PowderCatalogItemId"/> so that at <em>approval</em> time the system
/// order and receive it later — see <see cref="CreateIncomingInventoryItemAsync"/> for details. /// can create exactly one <see cref="InventoryItem"/> per unique powder across all coats on the
/// quote (deduplication). No inventory is created during quote save.
/// </summary> /// </summary>
private async Task<List<QuoteItemCoat>> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc) private async Task<List<QuoteItemCoat>> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
{ {
@@ -175,8 +196,8 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
{ {
var coatDto = itemDto.Coats[coatIndex]; var coatDto = itemDto.Coats[coatIndex];
if (coatDto.AddAsIncoming && coatDto.CatalogItemId.HasValue && !coatDto.InventoryItemId.HasValue) // Incoming-inventory creation is intentionally deferred to quote approval.
coatDto.InventoryItemId = await CreateIncomingInventoryItemAsync(coatDto, companyId); // PowderCatalogItemId is persisted on the coat entity for later use.
var coat = BuildQuoteItemCoat(coatDto, companyId, createdAtUtc); var coat = BuildQuoteItemCoat(coatDto, companyId, createdAtUtc);
var coatPricing = await _pricingService.CalculateCoatPriceAsync( var coatPricing = await _pricingService.CalculateCoatPriceAsync(
@@ -243,6 +264,9 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
IsAiItem = itemDto.IsAiItem, IsAiItem = itemDto.IsAiItem,
AiTags = itemDto.AiTags, AiTags = itemDto.AiTags,
AiPredictionId = itemDto.AiPredictionId, AiPredictionId = itemDto.AiPredictionId,
IsCustomFormulaItem = itemDto.IsCustomFormulaItem,
CustomItemTemplateId = itemDto.CustomItemTemplateId,
FormulaFieldValuesJson = itemDto.FormulaFieldValuesJson,
CompanyId = companyId, CompanyId = companyId,
CreatedAt = createdAtUtc CreatedAt = createdAtUtc
}; };
@@ -256,6 +280,7 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
CoatName = coatDto.CoatName, CoatName = coatDto.CoatName,
Sequence = coatDto.Sequence, Sequence = coatDto.Sequence,
InventoryItemId = coatDto.InventoryItemId, InventoryItemId = coatDto.InventoryItemId,
PowderCatalogItemId = coatDto.CatalogItemId,
ColorName = coatDto.ColorName, ColorName = coatDto.ColorName,
VendorId = coatDto.VendorId, VendorId = coatDto.VendorId,
ColorCode = coatDto.ColorCode, ColorCode = coatDto.ColorCode,
@@ -305,34 +330,36 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
} }
/// <summary> /// <summary>
/// Auto-creates an "incoming" <see cref="InventoryItem"/> when a user selects a powder from the /// Creates one "incoming" <see cref="InventoryItem"/> from a platform catalog entry.
/// platform catalog that doesn't yet exist in their company's inventory. /// Called at quote-approval time (not during quote save) so inventory records only appear
/// when a job is actually going to be created. The caller groups coats by
/// <c>PowderCatalogItemId</c> and calls this once per unique catalog item, preventing
/// duplicate records when the same powder appears on multiple items in the same quote.
/// ///
/// WHY this exists: shops often quote jobs using powders they haven't ordered yet. Rather than /// Category resolution prefers the company's "POWDER" category (CategoryCode=="POWDER")
/// forcing the user to manually add the powder to inventory before quoting, we create an /// so the item always lands in the right bucket regardless of how many IsCoating categories
/// IsIncoming=true record on their behalf. The shop can then receive the actual order against /// the company has defined. Falls back to the lowest-DisplayOrder IsCoating category.
/// this record later (updating quantity + receive date) without losing the link to the original quote.
/// ///
/// The AI augmentation step (LookupByUrlAsync) fills in technical specs (cure temp/time, coverage, /// AI augmentation fills in missing technical specs (cure temp/time, coverage, color families)
/// color families, etc.) that may be missing from the scraped catalog JSON. It is best-effort — /// from the manufacturer product page. Best-effort — item is still created from catalog data
/// if it fails, the item is still created with whatever data the catalog has. /// if the AI call fails.
///
/// After creation, <c>coatDto.PowderCostPerLb</c> is cleared so the pricing engine treats this
/// as an inventory-linked coat (not a custom powder), ensuring future repricings use the
/// inventory unit cost rather than the now-stale manual price from the quote form.
/// </summary> /// </summary>
private async Task<int?> CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId) private async Task<int?> CreateIncomingInventoryItemAsync(int catalogItemId, int companyId)
{ {
try try
{ {
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(coatDto.CatalogItemId!.Value); var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(catalogItemId);
if (catalogItem == null) return null; if (catalogItem == null) return null;
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync(); var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
var coatingCategory = categories // Prefer the canonical "POWDER" category so catalog-sourced items never land in an
.Where(c => c.IsActive && c.IsCoating) // unrelated coating category (e.g. "Cerakote") that happens to have IsCoating=true.
.OrderBy(c => c.DisplayOrder) var coatingCategory = categories.FirstOrDefault(c => c.IsActive && c.IsCoating
.FirstOrDefault(); && c.CategoryCode.Equals("POWDER", StringComparison.OrdinalIgnoreCase))
?? categories
.Where(c => c.IsActive && c.IsCoating)
.OrderBy(c => c.DisplayOrder)
.FirstOrDefault();
var vendors = await _unitOfWork.Vendors.GetAllAsync(); var vendors = await _unitOfWork.Vendors.GetAllAsync();
var vendorNameLower = catalogItem.VendorName.ToLower(); var vendorNameLower = catalogItem.VendorName.ToLower();
@@ -437,17 +464,143 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
await _unitOfWork.InventoryItems.AddAsync(item); await _unitOfWork.InventoryItems.AddAsync(item);
await _unitOfWork.SaveChangesAsync(); await _unitOfWork.SaveChangesAsync();
coatDto.PowderCostPerLb = null; _logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} at quote approval",
_logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} via quote coat", item.Id, item.Name, catalogItemId);
item.Id, item.Name, coatDto.CatalogItemId);
return item.Id; return item.Id;
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "Failed to create incoming inventory item from catalog {CatalogId}, continuing without inventory link", _logger.LogWarning(ex, "Failed to create incoming inventory item from catalog {CatalogId}, continuing without inventory link",
coatDto.CatalogItemId); catalogItemId);
return null; return null;
} }
} }
/// <summary>
/// Scans all coat DTOs for powder that must be ordered (custom or catalog-sourced) and returns a
/// single "Custom Powder Order" QuoteItem aggregating all material costs and color names.
/// Returns null when no such coats are found. Used by <see cref="CreateQuoteItemsAsync"/>
/// on the first save only — Option B means the user owns the price after creation.
///
/// Coat types that qualify:
/// - Custom powder: no InventoryItemId, manual PowderCostPerLb &gt; 0 (user-entered)
/// - Catalog-sourced pending incoming: CatalogItemId set, no InventoryItemId, PowderCostPerLb
/// pre-filled from catalog unit price (inventory creation deferred to approval)
/// - Legacy path: InventoryItemId set and item.IsIncoming == true (pre-fix records)
/// </summary>
private async Task<QuoteItem?> BuildCustomPowderOrderItemAsync(
IReadOnlyList<CreateQuoteItemDto> itemDtos, int quoteId, int companyId, DateTime createdAtUtc)
{
var colorNames = new List<string>();
decimal totalCost = 0m;
foreach (var itemDto in itemDtos)
{
if (itemDto.Coats == null) continue;
foreach (var coat in itemDto.Coats)
{
if (!coat.InventoryItemId.HasValue &&
coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 &&
coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
{
// Custom powder (manual cost) or catalog-sourced incoming (cost pre-filled from catalog).
// Both arrive here the same way: PowderCostPerLb set, no inventory link yet.
totalCost += coat.PowderToOrder.Value * coat.PowderCostPerLb.Value;
if (!string.IsNullOrWhiteSpace(coat.ColorName))
colorNames.Add(coat.ColorName);
}
else if (coat.InventoryItemId.HasValue && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
{
// Legacy path: inventory was already created (quotes saved before the deferred-creation fix).
// PowderCostPerLb was cleared on those coats so cost must come from inventory.
var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value);
if (invItem?.IsIncoming == true)
{
totalCost += coat.PowderToOrder.Value * invItem.UnitCost;
var colorName = !string.IsNullOrWhiteSpace(coat.ColorName) ? coat.ColorName : invItem.Name;
if (!string.IsNullOrWhiteSpace(colorName))
colorNames.Add(colorName);
}
}
}
}
if (totalCost <= 0) return null;
var uniqueColors = colorNames
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var description = uniqueColors.Any()
? $"Custom Powder Order ({string.Join(", ", uniqueColors)})"
: "Custom Powder Order";
return new QuoteItem
{
QuoteId = quoteId,
Description = description,
Quantity = 1,
IsGenericItem = true,
ManualUnitPrice = totalCost,
UnitPrice = totalCost,
TotalPrice = totalCost,
ItemMaterialCost = totalCost,
CompanyId = companyId,
CreatedAt = createdAtUtc,
Coats = [],
PrepServices = []
};
}
/// <summary>
/// Called at quote approval time to create exactly one <see cref="InventoryItem"/> per unique
/// powder catalog entry referenced across all coats on the quote, then links each coat to its
/// new (or existing) inventory record.
///
/// WHY deferred: during quoting the job may never be approved, so creating inventory records at
/// quote-save time produces orphaned, never-ordered items. Deferring to approval ensures inventory
/// only reflects powders the shop is actually going to process.
///
/// Deduplication: multiple items on the same quote that use the same catalog powder receive the
/// same InventoryItemId — no duplicate records are created.
///
/// Idempotent: coats that already have an InventoryItemId are skipped, so calling this method
/// on an already-approved quote (e.g. retry after a transient error) is safe.
/// </summary>
public async Task EnsureIncomingInventoryForApprovedQuoteAsync(int quoteId, int companyId)
{
// Load all QuoteItems for this quote with their coats so we can inspect PowderCatalogItemId.
var quoteItems = await _unitOfWork.QuoteItems.FindAsync(
qi => qi.QuoteId == quoteId && qi.CompanyId == companyId,
false,
qi => qi.Coats);
var pendingCoats = quoteItems
.SelectMany(qi => qi.Coats)
.Where(c => c.PowderCatalogItemId.HasValue && !c.InventoryItemId.HasValue)
.ToList();
if (pendingCoats.Count == 0) return;
// Group by catalog item ID so each unique powder generates exactly one inventory record.
var groups = pendingCoats
.GroupBy(c => c.PowderCatalogItemId!.Value)
.ToList();
foreach (var group in groups)
{
var newInventoryId = await CreateIncomingInventoryItemAsync(group.Key, companyId);
if (newInventoryId == null) continue;
// Link every coat in this group to the single newly-created inventory record.
foreach (var coat in group)
{
coat.InventoryItemId = newInventoryId;
coat.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.QuoteItemCoats.UpdateAsync(coat);
}
}
await _unitOfWork.SaveChangesAsync();
}
} }
@@ -5,145 +5,165 @@ namespace PowderCoating.Application.Services;
/// <summary> /// <summary>
/// Derives sqft/hr throughput rates from a shop's equipment configuration. /// Derives sqft/hr throughput rates from a shop's equipment configuration.
/// Used in two places: the AI photo quote prompt (so Claude reasons from real shop /// Used by the AI photo quote prompt (so Claude reasons from real shop speeds)
/// speeds) and the calculated-item wizard (to show a suggested blast time hint). /// and the Company Settings live preview (so the UI always shows the same rate
/// the AI will use — single formula path, no client-side duplication).
/// ///
/// Formula: /// Both pressure pots and siphon cabinets are nozzle-primary: nozzle size
/// BlastRate = BaseByCfm(cfm) × NozzleMultiplier × SetupMultiplier × SubstrateMultiplier /// determines throughput and CFM draw. CFM is not used in the rate formula.
/// ///
/// Base rates by CFM represent a pressure pot at #5 nozzle removing paint. /// Sources:
/// All multipliers are relative to that baseline. /// Pressure pot rates — averaged from two industry standard abrasive blast
/// cleaning reference tables.
/// Siphon cabinet rates — industry reference table for siphon-fed cabinets.
/// Substrate multipliers — relative removal difficulty vs. paint baseline.
/// </summary> /// </summary>
public static class ShopCapabilityCalculator public static class ShopCapabilityCalculator
{ {
// ── Blast rate derivation ───────────────────────────────────────────────── // ── Public entry points ────────────────────────────────────────────────────
/// <summary> /// <summary>
/// Returns the effective blast rate in sqft/hr. /// Returns the effective blast rate in sqft/hr for company-level operating costs.
/// If <see cref="CompanyOperatingCosts.BlastRateSqFtPerHourOverride"/> is set, returns it directly. /// BlastRateSqFtPerHourOverride bypasses the formula when set.
/// Otherwise derives from CFM, nozzle, setup type, and substrate.
/// Returns 0 when CFM is not configured (shop hasn't calibrated yet).
/// </summary> /// </summary>
public static decimal GetBlastRateSqFtPerHour(CompanyOperatingCosts costs) public static decimal GetBlastRateSqFtPerHour(CompanyOperatingCosts costs)
{ {
if (costs.BlastRateSqFtPerHourOverride.HasValue && costs.BlastRateSqFtPerHourOverride.Value > 0) if (costs.BlastRateSqFtPerHourOverride.HasValue && costs.BlastRateSqFtPerHourOverride.Value > 0)
return costs.BlastRateSqFtPerHourOverride.Value; return costs.BlastRateSqFtPerHourOverride.Value;
if (costs.CompressorCfm <= 0) return CalculateBlastRate(costs.BlastNozzleSize, costs.BlastSetupType, costs.PrimaryBlastSubstrate);
return 0m;
var baseRate = BaseByCfm(costs.CompressorCfm);
var nozzle = NozzleMultiplier(costs.BlastNozzleSize);
var setup = SetupMultiplier(costs.BlastSetupType);
var substrate = SubstrateMultiplier(costs.PrimaryBlastSubstrate);
return Math.Round(baseRate * nozzle * setup * substrate, 1);
} }
/// <summary> /// <summary>
/// Returns the effective blast rate in sqft/hr for a named <see cref="CompanyBlastSetup"/>. /// Returns the effective blast rate in sqft/hr for a named blast setup.
/// Identical logic to the <see cref="CompanyOperatingCosts"/> overload — uses override if set, /// BlastRateSqFtPerHourOverride bypasses the formula when set.
/// otherwise derives from the setup's equipment specs.
/// </summary> /// </summary>
public static decimal GetBlastRateSqFtPerHour(CompanyBlastSetup setup) public static decimal GetBlastRateSqFtPerHour(CompanyBlastSetup setup)
{ {
if (setup.BlastRateSqFtPerHourOverride.HasValue && setup.BlastRateSqFtPerHourOverride.Value > 0) if (setup.BlastRateSqFtPerHourOverride.HasValue && setup.BlastRateSqFtPerHourOverride.Value > 0)
return setup.BlastRateSqFtPerHourOverride.Value; return setup.BlastRateSqFtPerHourOverride.Value;
if (setup.CompressorCfm <= 0) return CalculateBlastRate(setup.BlastNozzleSize, setup.SetupType, setup.PrimarySubstrate);
return 0m;
var baseRate = BaseByCfm(setup.CompressorCfm);
var nozzle = NozzleMultiplier(setup.BlastNozzleSize);
var setupMult = SetupMultiplier(setup.SetupType);
var substrate = SubstrateMultiplier(setup.PrimarySubstrate);
return Math.Round(baseRate * nozzle * setupMult * substrate, 1);
} }
/// <summary> /// <summary>
/// Returns the effective coating application rate in sqft/hr. /// Returns the effective coating application rate in sqft/hr.
/// If override is set, returns it directly. /// Override bypasses the formula when set.
/// Otherwise derives a sensible default from gun type.
/// </summary> /// </summary>
public static decimal GetCoatingRateSqFtPerHour(CompanyOperatingCosts costs) public static decimal GetCoatingRateSqFtPerHour(CompanyOperatingCosts costs)
{ {
if (costs.CoatingRateSqFtPerHourOverride.HasValue && costs.CoatingRateSqFtPerHourOverride.Value > 0) if (costs.CoatingRateSqFtPerHourOverride.HasValue && costs.CoatingRateSqFtPerHourOverride.Value > 0)
return costs.CoatingRateSqFtPerHourOverride.Value; return costs.CoatingRateSqFtPerHourOverride.Value;
// Corona and tribo guns are roughly similar on flat parts; tribo edges out on complex geometry.
// Without more equipment data (voltage, gun model) we use a single reasonable default.
return costs.CoatingGunType switch return costs.CoatingGunType switch
{ {
CoatingGunType.Corona => 40m, CoatingGunType.Corona => 40m,
CoatingGunType.Tribo => 35m, // slower on flat but better on complex; conservative default CoatingGunType.Tribo => 35m,
CoatingGunType.Both => 40m, CoatingGunType.Both => 40m,
_ => 40m _ => 40m
}; };
} }
/// <summary> /// <summary>
/// Returns default equipment field values for a given capability tier. /// Returns default equipment field values for a given capability tier, applied
/// Applied during Setup Wizard tier selection so the shop gets reasonable /// during Setup Wizard tier selection so new shops get reasonable starting values.
/// starting values even if they never visit the Quoting Calibration tab. /// CFM defaults reflect typical compressor sizes for each tier; they appear in the
/// UI for reference but are not used in the rate formula.
/// </summary> /// </summary>
public static (BlastSetupType SetupType, decimal Cfm, int NozzleSize, BlastSubstrateType Substrate) public static (BlastSetupType SetupType, decimal Cfm, int NozzleSize, BlastSubstrateType Substrate)
TierDefaults(ShopCapabilityTier tier) => tier switch TierDefaults(ShopCapabilityTier tier) => tier switch
{ {
ShopCapabilityTier.Garage => (BlastSetupType.SiphonCabinet, 7m, 4, BlastSubstrateType.Mixed), ShopCapabilityTier.Garage => (BlastSetupType.SiphonCabinet, 7m, 3, BlastSubstrateType.Mixed),
ShopCapabilityTier.Small => (BlastSetupType.PressurePot, 40m, 5, BlastSubstrateType.Mixed), ShopCapabilityTier.Small => (BlastSetupType.PressurePot, 49m, 3, BlastSubstrateType.Mixed),
ShopCapabilityTier.Medium => (BlastSetupType.PressurePot, 80m, 5, BlastSubstrateType.Mixed), ShopCapabilityTier.Medium => (BlastSetupType.PressurePot, 90m, 4, BlastSubstrateType.Mixed),
ShopCapabilityTier.Large => (BlastSetupType.PressurePot, 150m, 6, BlastSubstrateType.Mixed), ShopCapabilityTier.Large => (BlastSetupType.PressurePot, 150m, 5, BlastSubstrateType.Mixed),
_ => (BlastSetupType.SiphonCabinet, 7m, 4, BlastSubstrateType.Mixed) _ => (BlastSetupType.SiphonCabinet, 7m, 3, BlastSubstrateType.Mixed)
}; };
// ── Private helpers ─────────────────────────────────────────────────────── // ── Core formula (single path for all callers) ─────────────────────────────
/// <summary> /// <summary>
/// Base sqft/hr at a pressure pot, #5 nozzle, removing paint. /// Nozzle-primary blast rate calculation. Nozzle size determines throughput;
/// Calibrated so that real-world examples produce expected results: /// setup type routes to the appropriate reference table; substrate adjusts for
/// - 7 CFM siphon cabinet → ~2 sqft/hr (garage coater, 3+ hrs/wheel) /// removal difficulty. CFM is not used — it is a consequence of nozzle choice,
/// - 40 CFM pressure pot → ~15 sqft/hr (small shop, ~30 min/wheel) /// not an independent variable in throughput.
/// - 80 CFM pressure pot → ~25 sqft/hr (medium shop)
/// - 150 CFM pressure pot → ~40 sqft/hr (large shop, ~10 min/wheel)
/// </summary> /// </summary>
private static decimal BaseByCfm(decimal cfm) => cfm switch private static decimal CalculateBlastRate(int nozzle, BlastSetupType setupType, BlastSubstrateType substrate)
{ {
< 10 => 5m, var baseRate = setupType switch
< 20 => 9m, {
< 40 => 15m, BlastSetupType.PressurePot => PressurePotRateByNozzle(nozzle),
< 80 => 25m, BlastSetupType.SiphonCabinet => SiphonCabinetRateByNozzle(nozzle),
< 120 => 35m, // Siphon pot: open gravity feed, no enclosure penalty, ~80% of pressure pot
_ => 45m BlastSetupType.SiphonPot => Math.Round(PressurePotRateByNozzle(nozzle) * 0.80m, 1),
// Wet blasting: water-media mix reduces impact velocity, ~60% of dry pressure pot
BlastSetupType.WetBlasting => Math.Round(PressurePotRateByNozzle(nozzle) * 0.60m, 1),
_ => 0m
};
return Math.Round(baseRate * SubstrateMultiplier(substrate), 1);
}
/// <summary>
/// Midpoint cleaning rates for a pressure pot at adequate air supply, by nozzle size.
/// Averaged from two industry-standard abrasive blast cleaning reference tables.
/// #1 (1/16"): 20-35 sqft/hr avg → 20
/// #2 (1/8"): 40-60 sqft/hr avg → 40
/// #3 (3/16"): 60-85 sqft/hr avg → 75
/// #4 (1/4"): 90-110 sqft/hr avg → 115
/// #5 (5/16"): 130-160 sqft/hr avg → 175
/// #6 (3/8"): 180-230 sqft/hr avg → 245
/// #7 (7/16"): 240-300 sqft/hr avg → 325
/// #8 (1/2"): 320-400 sqft/hr avg → 430
/// </summary>
private static decimal PressurePotRateByNozzle(int nozzle) => nozzle switch
{
1 => 20m,
2 => 40m,
3 => 75m,
4 => 115m,
5 => 175m,
6 => 245m,
7 => 325m,
8 => 430m,
_ => 100m
}; };
private static decimal NozzleMultiplier(int nozzle) => nozzle switch /// <summary>
/// Midpoint cleaning rates for siphon-fed blast cabinets, by nozzle size.
/// Source: industry reference table for siphon cabinet production rates.
/// #1 (1/16"): 10-25 sqft/hr → 18
/// #2 (1/8"): 25-50 sqft/hr → 38
/// #3 (3/16"): 50-100 sqft/hr → 75
/// #4 (1/4"): 100-150 sqft/hr → 125
/// #5 (5/16"): 150-225 sqft/hr → 188
/// #6 (3/8"): 225-300 sqft/hr → 263
/// #7 (7/16"): 300-375 sqft/hr → 338
/// #8 (1/2"): 375-450 sqft/hr → 413
/// </summary>
private static decimal SiphonCabinetRateByNozzle(int nozzle) => nozzle switch
{ {
2 => 0.35m, 1 => 18m,
3 => 0.55m, 2 => 38m,
4 => 0.75m, 3 => 75m,
5 => 1.00m, 4 => 125m,
6 => 1.30m, 5 => 188m,
7 => 1.65m, 6 => 263m,
8 => 2.00m, 7 => 338m,
_ => 1.00m 8 => 413m,
}; _ => 80m
private static decimal SetupMultiplier(BlastSetupType setup) => setup switch
{
BlastSetupType.SiphonCabinet => 0.50m, // enclosed, low pressure, repositioning time
BlastSetupType.SiphonPot => 0.70m,
BlastSetupType.PressurePot => 1.00m, // baseline
BlastSetupType.WetBlasting => 0.60m,
_ => 1.00m
}; };
/// <summary>
/// Adjustment for substrate removal difficulty relative to paint (baseline = 1.0).
/// Powder coat strips faster than paint; rust and scale requires multiple passes.
/// </summary>
private static decimal SubstrateMultiplier(BlastSubstrateType substrate) => substrate switch private static decimal SubstrateMultiplier(BlastSubstrateType substrate) => substrate switch
{ {
BlastSubstrateType.PowderCoat => 1.25m, // faster to remove than paint BlastSubstrateType.PowderCoat => 1.25m,
BlastSubstrateType.Paint => 1.00m, // baseline BlastSubstrateType.Paint => 1.00m,
BlastSubstrateType.Mixed => 0.90m, BlastSubstrateType.Mixed => 0.90m,
BlastSubstrateType.RustAndScale => 0.70m, // requires more passes BlastSubstrateType.RustAndScale => 0.70m,
_ => 0.90m _ => 0.90m
}; };
} }
@@ -73,6 +73,9 @@ public class ApplicationUser : IdentityUser
// Passkey enrollment prompt // Passkey enrollment prompt
public bool PasskeyPromptDismissed { get; set; } = false; public bool PasskeyPromptDismissed { get; set; } = false;
/// <summary>BCrypt hash of the employee's 4-digit kiosk PIN. Null means kiosk timeclock is disabled for this user.</summary>
public string? KioskPin { get; set; }
// Ban // Ban
public bool IsBanned { get; set; } = false; public bool IsBanned { get; set; } = false;
public DateTime? BannedAt { get; set; } public DateTime? BannedAt { get; set; }
@@ -133,6 +133,15 @@ public class Company : BaseEntity
/// </summary> /// </summary>
public string? KioskActivationToken { get; set; } public string? KioskActivationToken { get; set; }
/// <summary>Timeclock feature enabled for this company. When false, the nav link, dashboard, and reports are hidden.</summary>
public bool TimeclockEnabled { get; set; } = true;
/// <summary>When true, employees can clock in/out multiple times per day (lunch breaks, etc.). When false, only one in/out pair is allowed per day.</summary>
public bool TimeclockAllowMultiplePunchesPerDay { get; set; } = true;
/// <summary>If set, any open clock entry older than this many hours is automatically closed on the next clock-in. Null = no auto clock-out.</summary>
public int? TimeclockAutoClockOutHours { get; set; }
// Navigation Properties // Navigation Properties
public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>(); public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>();
public virtual ICollection<Customer> Customers { get; set; } = new List<Customer>(); public virtual ICollection<Customer> Customers { get; set; } = new List<Customer>();
@@ -0,0 +1,53 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// A per-company reusable pricing formula template. Users define named input fields and an
/// NCalc expression that produces either a fixed dollar amount (FixedRate) or a surface area
/// in square feet (SurfaceAreaSqFt) that feeds the standard coatings pricing path.
/// </summary>
public class CustomItemTemplate : BaseEntity
{
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
/// <summary>"FixedRate" or "SurfaceAreaSqFt" — controls which pricing path is used after evaluation.</summary>
public string OutputMode { get; set; } = "FixedRate";
/// <summary>JSON array of field definitions: [{name, label, unit, defaultValue}]</summary>
public string FieldsJson { get; set; } = "[]";
/// <summary>NCalc expression using field name slugs and the reserved variable 'rate'.</summary>
public string Formula { get; set; } = string.Empty;
/// <summary>Default rate value populated into the quote wizard; user can override per quote.</summary>
public decimal? DefaultRate { get; set; }
/// <summary>Display label for the rate field, e.g. "$/sq in" or "$/lb".</summary>
public string? RateLabel { get; set; }
public string? Notes { get; set; }
public int DisplayOrder { get; set; }
public bool IsActive { get; set; } = true;
/// <summary>
/// Optional reference diagram (shop drawing, sketch) stored in blob storage.
/// Shown in the template editor and quote wizard so users know which measurements to take.
/// Path format: {companyId}/{templateId}/diagram.{ext}
/// </summary>
public string? DiagramImagePath { get; set; }
// ── Community library tracking ─────────────────────────────────────────
/// <summary>
/// Set when this template was imported from the community library.
/// Null for originally created templates.
/// </summary>
public int? SourceFormulaLibraryItemId { get; set; }
public virtual FormulaLibraryItem? SourceFormulaLibraryItem { get; set; }
/// <summary>
/// True once the user edits an imported template. Only modified imports (and original
/// creations) are eligible to be shared back to the community library.
/// </summary>
public bool IsModifiedFromSource { get; set; }
}
@@ -41,6 +41,17 @@ public class Customer : BaseEntity
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
public DateTime? LastContactDate { get; set; } 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 // Notification preferences
public bool NotifyByEmail { get; set; } = true; public bool NotifyByEmail { get; set; } = true;
// NotifyBySms is only set to true after explicit staff-recorded consent (TCPA compliance) // 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<NotificationLog> NotificationLogs { get; set; } = new List<NotificationLog>();
public virtual ICollection<Invoice> Invoices { get; set; } = new List<Invoice>(); 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; }
}
@@ -0,0 +1,32 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
/// <summary>
/// Facility-level clock-in/clock-out record for an employee.
/// Tracks when an employee arrives and leaves the facility — separate from JobTimeEntry which tracks
/// hours against a specific job. Multiple entries per day are fully supported (lunch breaks, etc.).
/// The only enforced constraint: a user may not have more than one open entry (ClockOutTime == null) at a time.
/// </summary>
public class EmployeeClockEntry : BaseEntity
{
public string UserId { get; set; } = string.Empty;
public DateTime ClockInTime { get; set; }
/// <summary>Null means the employee is currently clocked in.</summary>
public DateTime? ClockOutTime { get; set; }
/// <summary>Stored at clock-out time: (ClockOutTime - ClockInTime) in hours, rounded to 2 decimal places.</summary>
public decimal? HoursWorked { get; set; }
/// <summary>
/// Whether this segment is regular work time, a break, or a lunch period.
/// Only <see cref="ClockEntryType.Work"/> entries count toward paid-hours totals.
/// </summary>
public ClockEntryType EntryType { get; set; } = ClockEntryType.Work;
public string? Notes { get; set; }
public virtual ApplicationUser User { get; set; } = null!;
}
+1 -1
View File
@@ -19,7 +19,7 @@ public class Equipment : BaseEntity
public string? Location { get; set; } public string? Location { get; set; }
// Maintenance Information // Maintenance Information
public int RecommendedMaintenanceIntervalDays { get; set; } public int? RecommendedMaintenanceIntervalDays { get; set; }
public DateTime? LastMaintenanceDate { get; set; } public DateTime? LastMaintenanceDate { get; set; }
public DateTime? NextScheduledMaintenance { get; set; } public DateTime? NextScheduledMaintenance { get; set; }
@@ -0,0 +1,19 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Records that a company imported a specific FormulaLibraryItem into their local template library.
/// Tenant-scoped via BaseEntity.CompanyId. One row per (company, library item) — re-importing the
/// same item overwrites the existing row rather than creating a duplicate.
/// </summary>
public class FormulaLibraryImport : BaseEntity
{
public int FormulaLibraryItemId { get; set; }
public virtual FormulaLibraryItem FormulaLibraryItem { get; set; } = null!;
public string ImportedByUserId { get; set; } = string.Empty;
public DateTime ImportedAt { get; set; } = DateTime.UtcNow;
/// <summary>The CustomItemTemplate row created in this company's local library on import.</summary>
public int ResultingCustomItemTemplateId { get; set; }
public virtual CustomItemTemplate ResultingCustomItemTemplate { get; set; } = null!;
}
@@ -0,0 +1,70 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Platform-level community library entry for a shared custom formula template.
/// Not tenant-scoped — no BaseEntity, no CompanyId, no soft delete.
/// Shared voluntarily by the originating company; imported as independent copies by others.
/// </summary>
public class FormulaLibraryItem
{
public int Id { get; set; }
// ── Formula content (copied from CustomItemTemplate at share time) ─────
public string Name { get; set; } = string.Empty;
public string? Description { get; set; }
/// <summary>"FixedRate" or "SurfaceAreaSqFt" — mirrors CustomItemTemplate.OutputMode.</summary>
public string OutputMode { get; set; } = "FixedRate";
/// <summary>JSON array of field definitions: [{name, label, unit, defaultValue}]</summary>
public string FieldsJson { get; set; } = "[]";
/// <summary>NCalc expression using field name slugs and the reserved variable 'rate'.</summary>
public string Formula { get; set; } = string.Empty;
public decimal? DefaultRate { get; set; }
public string? RateLabel { get; set; }
public string? Notes { get; set; }
/// <summary>
/// Blob path referencing the source template's diagram image.
/// Nulled out (here and on all imports) if the source template's diagram is removed.
/// </summary>
public string? DiagramImagePath { get; set; }
// ── Attribution ────────────────────────────────────────────────────────
/// <summary>Comma-separated community tags, e.g. "HVAC,Sheet Metal".</summary>
public string? Tags { get; set; }
/// <summary>Optional industry hint shown on the browse card, e.g. "HVAC", "Automotive".</summary>
public string? IndustryHint { get; set; }
/// <summary>Id of the CustomItemTemplate this was shared from.</summary>
public int SourceCustomItemTemplateId { get; set; }
public int SourceCompanyId { get; set; }
/// <summary>Denormalized company name so it renders without a join when the company is gone.</summary>
public string SourceCompanyName { get; set; } = string.Empty;
/// <summary>
/// When non-null, this entry was derived from an imported formula that was subsequently
/// modified. Points to the original library entry. Shown as "Inspired by..." on the browse card.
/// </summary>
public int? InspiredByFormulaLibraryItemId { get; set; }
public virtual FormulaLibraryItem? InspiredBy { get; set; }
public string SharedByUserId { get; set; } = string.Empty;
public DateTime SharedAt { get; set; } = DateTime.UtcNow;
/// <summary>False when the creator has removed it from the community library.</summary>
public bool IsPublished { get; set; } = true;
/// <summary>Running count of how many companies have imported this entry.</summary>
public int ImportCount { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; }
}
@@ -0,0 +1,24 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// One thumbs-up or thumbs-down vote per company per library formula.
/// Platform-level — no BaseEntity, no soft delete, no CompanyId tenant filter.
/// Unique constraint enforced at the DB level: (FormulaLibraryItemId, CompanyId).
/// </summary>
public class FormulaLibraryRating
{
public int Id { get; set; }
public int FormulaLibraryItemId { get; set; }
/// <summary>The company casting the vote.</summary>
public int CompanyId { get; set; }
/// <summary>True = thumbs up, false = thumbs down.</summary>
public bool IsPositive { get; set; }
public DateTime RatedAt { get; set; } = DateTime.UtcNow;
// Navigation
public virtual FormulaLibraryItem FormulaLibraryItem { get; set; } = null!;
}
@@ -12,4 +12,5 @@ public class InventoryCategoryLookup : BaseEntity
// Relationships // Relationships
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>(); public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
public virtual ICollection<Vendor> Vendors { get; set; } = new List<Vendor>();
} }
@@ -48,6 +48,7 @@ public class Invoice : BaseEntity
public string? InternalNotes { get; set; } public string? InternalNotes { get; set; }
public string? Terms { get; set; } public string? Terms { get; set; }
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
/// <summary> /// <summary>
/// Early payment discount percentage (e.g., 2 means 2% discount). /// Early payment discount percentage (e.g., 2 means 2% discount).
+1
View File
@@ -47,6 +47,7 @@ public class Job : BaseEntity
// Additional Information // Additional Information
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public string? SpecialInstructions { get; set; } public string? SpecialInstructions { get; set; }
public string? InternalNotes { get; set; } // Internal notes from quote public string? InternalNotes { get; set; } // Internal notes from quote
public string? Tags { get; set; } public string? Tags { get; set; }
@@ -52,6 +52,14 @@ public class JobItem : BaseEntity
public int? AiPredictionId { get; set; } public int? AiPredictionId { get; set; }
public virtual AiItemPrediction? AiPrediction { get; set; } public virtual AiItemPrediction? AiPrediction { get; set; }
// Custom formula item — see IsCustomFormulaItem routing in PricingCalculationService
public bool IsCustomFormulaItem { get; set; }
public int? CustomItemTemplateId { get; set; }
public virtual CustomItemTemplate? CustomItemTemplate { get; set; }
/// <summary>Snapshot of field name/value pairs used in the formula, stored as JSON for display on details views.</summary>
public string? FormulaFieldValuesJson { get; set; }
// Relationships // Relationships
public virtual Job Job { get; set; } = null!; public virtual Job Job { get; set; } = null!;
public virtual CatalogItem? CatalogItem { get; set; } public virtual CatalogItem? CatalogItem { get; set; }
+1
View File
@@ -88,6 +88,7 @@ public class Quote : BaseEntity
public string? Terms { get; set; } public string? Terms { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public string? Tags { get; set; } public string? Tags { get; set; }
// Conversion tracking // Conversion tracking
@@ -56,6 +56,14 @@ public class QuoteItem : BaseEntity
public int? AiPredictionId { get; set; } public int? AiPredictionId { get; set; }
public virtual AiItemPrediction? AiPrediction { get; set; } public virtual AiItemPrediction? AiPrediction { get; set; }
// Custom formula item — see IsCustomFormulaItem routing in PricingCalculationService
public bool IsCustomFormulaItem { get; set; }
public int? CustomItemTemplateId { get; set; }
public virtual CustomItemTemplate? CustomItemTemplate { get; set; }
/// <summary>Snapshot of field name/value pairs used in the formula, stored as JSON for display on details views.</summary>
public string? FormulaFieldValuesJson { get; set; }
// Relationships // Relationships
public virtual Quote Quote { get; set; } = null!; public virtual Quote Quote { get; set; } = null!;
public virtual CatalogItem? CatalogItem { get; set; } public virtual CatalogItem? CatalogItem { get; set; }
@@ -15,6 +15,13 @@ public class QuoteItemCoat : BaseEntity
// Powder selection (same pattern as current QuoteItem) // Powder selection (same pattern as current QuoteItem)
public int? InventoryItemId { get; set; } // In-stock powder public int? InventoryItemId { get; set; } // In-stock powder
/// <summary>
/// Platform powder catalog item that this coat was sourced from.
/// Persisted so that at quote-approval time the system can create exactly one
/// IsIncoming InventoryItem per unique catalog powder (deduplication), rather
/// than creating during quote-save when the job may never be approved.
/// </summary>
public int? PowderCatalogItemId { get; set; }
public string? ColorName { get; set; } // Color name public string? ColorName { get; set; } // Color name
public int? VendorId { get; set; } // Vendor for custom powder public int? VendorId { get; set; } // Vendor for custom powder
public string? ColorCode { get; set; } // RAL code, etc. public string? ColorCode { get; set; } // RAL code, etc.
@@ -31,6 +31,9 @@ public class ReworkRecord : BaseEntity
public bool IsBillableToCustomer { get; set; } public bool IsBillableToCustomer { get; set; }
public string? BillingNotes { get; set; } public string? BillingNotes { get; set; }
// Pricing attribution for the linked rework job (null on pre-existing records)
public ReworkPricingType? ReworkPricingType { get; set; }
// ── Resolution ──────────────────────────────────────────────────────────── // ── Resolution ────────────────────────────────────────────────────────────
public ReworkStatus Status { get; set; } = ReworkStatus.Open; public ReworkStatus Status { get; set; } = ReworkStatus.Open;
public ReworkResolution? Resolution { get; set; } public ReworkResolution? Resolution { get; set; }
@@ -52,6 +52,9 @@ public class SubscriptionPlanConfig : BaseEntity
/// <summary>When true, companies on this plan can send SMS notifications to customers (subject to platform kill-switch and per-company opt-in).</summary> /// <summary>When true, companies on this plan can send SMS notifications to customers (subject to platform kill-switch and per-company opt-in).</summary>
public bool AllowSms { get; set; } = false; public bool AllowSms { get; set; } = false;
/// <summary>When true, companies on this plan can create and use Custom Formula Item Templates in quotes and jobs.</summary>
public bool AllowCustomFormulas { get; set; } = false;
public bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
public int SortOrder { get; set; } public int SortOrder { get; set; }
} }
@@ -45,6 +45,7 @@ public class Vendor : BaseEntity
public virtual ICollection<BillPayment> BillPayments { get; set; } = new List<BillPayment>(); public virtual ICollection<BillPayment> BillPayments { get; set; } = new List<BillPayment>();
public virtual ICollection<Expense> Expenses { get; set; } = new List<Expense>(); public virtual ICollection<Expense> Expenses { get; set; } = new List<Expense>();
public virtual Account? DefaultExpenseAccount { get; set; } public virtual Account? DefaultExpenseAccount { get; set; }
public virtual ICollection<InventoryCategoryLookup> Categories { get; set; } = new List<InventoryCategoryLookup>();
} }
public class InventoryTransaction : BaseEntity public class InventoryTransaction : BaseEntity
@@ -151,6 +152,20 @@ public class CustomerNote : BaseEntity
public virtual Customer Customer { get; set; } = null!; 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 class JobStatusHistory : BaseEntity
{ {
public int JobId { get; set; } public int JobId { get; set; }
@@ -0,0 +1,22 @@
namespace PowderCoating.Core.Entities;
/// <summary>
/// Represents an activated shop-floor kiosk tablet for the timeclock.
/// One row per device; multiple rows per company are supported so shops can have
/// tablets at multiple entry points. The <see cref="Token"/> is stored in a
/// device-specific cookie and validated on every kiosk request.
/// </summary>
public class TimeclockKioskDevice : BaseEntity
{
/// <summary>Human-readable label for this device (e.g. "Front Entrance Tablet").</summary>
public string? DeviceName { get; set; }
/// <summary>Cryptographically random token written to the device cookie on activation. Revoke by deleting this row.</summary>
public string Token { get; set; } = string.Empty;
/// <summary>UTC timestamp when a manager activated this device.</summary>
public DateTime ActivatedAt { get; set; }
/// <summary>UTC timestamp of the most recent kiosk request from this device; null if never used after activation.</summary>
public DateTime? LastSeenAt { get; set; }
}
+8
View File
@@ -144,6 +144,14 @@ public enum ReworkResolution
NoActionRequired = 4 NoActionRequired = 4
} }
/// <summary>Who bears the cost of the rework job, recorded at the time the rework is logged.</summary>
public enum ReworkPricingType
{
ShopFault = 0, // Redo is on the shop — rework job items priced at $0
CustomerReduced = 1, // Customer caused it; we're helping — prices copied, user edits
CustomerFull = 2 // Customer caused it; full original pricing applies
}
public enum BugReportStatus public enum BugReportStatus
{ {
New = 0, New = 0,
@@ -0,0 +1,17 @@
namespace PowderCoating.Core.Enums;
/// <summary>
/// Labels what kind of time a <see cref="PowderCoating.Core.Entities.EmployeeClockEntry"/> represents.
/// Only <see cref="Work"/> segments count toward paid-hours totals; Break and Lunch are informational.
/// </summary>
public enum ClockEntryType
{
/// <summary>Normal productive work time (default).</summary>
Work = 0,
/// <summary>Short rest/break period — unpaid, excluded from hour totals.</summary>
Break = 1,
/// <summary>Meal/lunch period — unpaid, excluded from hour totals.</summary>
Lunch = 2
}
@@ -43,6 +43,8 @@ public interface IUnitOfWork : IDisposable
IJobPhotoRepository JobPhotos { get; } IJobPhotoRepository JobPhotos { get; }
IRepository<JobNote> JobNotes { get; } IRepository<JobNote> JobNotes { get; }
IRepository<CustomerNote> CustomerNotes { get; } IRepository<CustomerNote> CustomerNotes { get; }
IRepository<CustomerContact> CustomerContacts { get; }
IRepository<CustomerPreferredPowder> CustomerPreferredPowders { get; }
IRepository<JobStatusHistory> JobStatusHistory { get; } IRepository<JobStatusHistory> JobStatusHistory { get; }
IRepository<PricingTier> PricingTiers { get; } IRepository<PricingTier> PricingTiers { get; }
@@ -155,6 +157,18 @@ IRepository<ReworkRecord> ReworkRecords { get; }
// Customer Intake Kiosk // Customer Intake Kiosk
IRepository<KioskSession> KioskSessions { get; } IRepository<KioskSession> KioskSessions { get; }
// Custom Formula Templates
IRepository<CustomItemTemplate> CustomItemTemplates { get; }
// Formula Community Library
IPlainRepository<FormulaLibraryItem> FormulaLibrary { get; }
IRepository<FormulaLibraryImport> FormulaLibraryImports { get; }
IPlainRepository<FormulaLibraryRating> FormulaLibraryRatings { get; }
// Employee Timeclock
IRepository<EmployeeClockEntry> EmployeeClockEntries { get; }
IRepository<TimeclockKioskDevice> TimeclockKioskDevices { get; }
Task<int> SaveChangesAsync(); Task<int> SaveChangesAsync();
Task<int> CompleteAsync(); // Alias for SaveChangesAsync Task<int> CompleteAsync(); // Alias for SaveChangesAsync
@@ -92,4 +92,10 @@ public interface IJobRepository : IRepository<Job>
/// were never completed and rolled past their scheduled day. /// were never completed and rolled past their scheduled day.
/// </summary> /// </summary>
Task<List<Job>> GetOverdueScheduledJobsAsync(); Task<List<Job>> GetOverdueScheduledJobsAsync();
/// <summary>
/// Returns the count of rework jobs linked to <paramref name="originalJobId"/>
/// (including soft-deleted) so the next rework suffix (R1, R2, …) can be determined.
/// </summary>
Task<int> GetReworkJobCountAsync(int originalJobId);
} }
@@ -230,6 +230,10 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
public DbSet<JobNote> JobNotes { get; set; } public DbSet<JobNote> JobNotes { get; set; }
/// <summary>Free-text notes added to a customer record by staff; tenant-filtered with soft delete.</summary> /// <summary>Free-text notes added to a customer record by staff; tenant-filtered with soft delete.</summary>
public DbSet<CustomerNote> CustomerNotes { get; set; } 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> /// <summary>Audit trail of every status transition on a job, referencing the lookup-table statuses.</summary>
public DbSet<JobStatusHistory> JobStatusHistory { get; set; } public DbSet<JobStatusHistory> JobStatusHistory { get; set; }
/// <summary>Customer pricing tiers (Standard, Preferred, Premium); tenant-filtered with soft delete.</summary> /// <summary>Customer pricing tiers (Standard, Preferred, Premium); tenant-filtered with soft delete.</summary>
@@ -289,6 +293,15 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
/// </summary> /// </summary>
public DbSet<PowderCatalogItem> PowderCatalogItems { get; set; } public DbSet<PowderCatalogItem> PowderCatalogItems { get; set; }
/// <summary>Community library of shared formula templates. Platform-level, no tenant filter.</summary>
public DbSet<FormulaLibraryItem> FormulaLibraryItems { get; set; }
/// <summary>Per-company record of which community library formulas a company has imported.</summary>
public DbSet<FormulaLibraryImport> FormulaLibraryImports { get; set; }
/// <summary>Per-company thumbs-up / thumbs-down vote on community library formulas.</summary>
public DbSet<FormulaLibraryRating> FormulaLibraryRatings { get; set; }
/// <summary>User-submitted bug reports; tenant-filtered with soft delete.</summary> /// <summary>User-submitted bug reports; tenant-filtered with soft delete.</summary>
public DbSet<BugReport> BugReports { get; set; } public DbSet<BugReport> BugReports { get; set; }
/// <summary>File attachments for bug reports; soft-delete only (no tenant filter — access controlled via parent BugReport).</summary> /// <summary>File attachments for bug reports; soft-delete only (no tenant filter — access controlled via parent BugReport).</summary>
@@ -374,6 +387,17 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
/// <summary>Customer self-service intake sessions (walk-in tablet or remote email link); tenant-filtered with soft delete.</summary> /// <summary>Customer self-service intake sessions (walk-in tablet or remote email link); tenant-filtered with soft delete.</summary>
public DbSet<KioskSession> KioskSessions { get; set; } public DbSet<KioskSession> KioskSessions { get; set; }
// Custom Formula Templates
/// <summary>Per-company reusable NCalc pricing formula templates; tenant-filtered with soft delete.</summary>
public DbSet<CustomItemTemplate> CustomItemTemplates { get; set; }
// Employee Timeclock
/// <summary>Facility-level clock-in/clock-out entries per employee; tenant-filtered with soft delete. Multiple entries per day are supported (lunch breaks, etc.).</summary>
public DbSet<EmployeeClockEntry> EmployeeClockEntries { get; set; }
/// <summary>One row per activated kiosk tablet per company. Token stored in device cookie; delete row to revoke a device.</summary>
public DbSet<TimeclockKioskDevice> TimeclockKioskDevices { get; set; }
/// <summary> /// <summary>
/// Platform-wide audit log capturing who changed what and when, across all tenants. /// Platform-wide audit log capturing who changed what and when, across all tenants.
/// No global query filter — SuperAdmin controllers query this directly. /// No global query filter — SuperAdmin controllers query this directly.
@@ -531,6 +555,8 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId)); !e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<CustomerNote>().HasQueryFilter(e => modelBuilder.Entity<CustomerNote>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId)); !e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<CustomerPreferredPowder>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e => modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId)); !e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e => modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
@@ -767,6 +793,32 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
.HasForeignKey(k => k.LinkedJobId) .HasForeignKey(k => k.LinkedJobId)
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
// Custom Formula Templates — tenant-filtered + soft delete
modelBuilder.Entity<CustomItemTemplate>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// Employee Timeclock — tenant-filtered + soft delete
modelBuilder.Entity<EmployeeClockEntry>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// FK to ApplicationUser: Restrict delete so removing a user doesn't erase attendance history.
// Use DeleteBehavior.Restrict rather than NoAction to surface a cleaner error in EF.
modelBuilder.Entity<EmployeeClockEntry>()
.HasOne(c => c.User)
.WithMany()
.HasForeignKey(c => c.UserId)
.OnDelete(DeleteBehavior.Restrict);
// Composite index for "who's clocked in today" and date-range attendance reports
modelBuilder.Entity<EmployeeClockEntry>()
.HasIndex(c => new { c.CompanyId, c.ClockInTime });
// Timeclock kiosk devices — one row per activated tablet per company
modelBuilder.Entity<TimeclockKioskDevice>().HasQueryFilter(d =>
!d.IsDeleted && (IsPlatformAdmin || d.CompanyId == CurrentCompanyId));
modelBuilder.Entity<TimeclockKioskDevice>()
.HasIndex(d => d.Token).IsUnique();
modelBuilder.Entity<TimeclockKioskDevice>()
.HasIndex(d => d.CompanyId);
// Account self-referencing hierarchy // Account self-referencing hierarchy
modelBuilder.Entity<Account>() modelBuilder.Entity<Account>()
.HasOne(a => a.ParentAccount) .HasOne(a => a.ParentAccount)
@@ -809,6 +861,15 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
.HasForeignKey(s => s.DefaultExpenseAccountId) .HasForeignKey(s => s.DefaultExpenseAccountId)
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
// Vendor ↔ InventoryCategoryLookup (many-to-many supply categories)
modelBuilder.Entity<Vendor>()
.HasMany(v => v.Categories)
.WithMany(c => c.Vendors)
.UsingEntity<Dictionary<string, object>>(
"VendorInventoryCategories",
j => j.HasOne<InventoryCategoryLookup>().WithMany().HasForeignKey("InventoryCategoryLookupId"),
j => j.HasOne<Vendor>().WithMany().HasForeignKey("VendorId"));
// Bill → APAccount (no cascade to avoid cycles) // Bill → APAccount (no cascade to avoid cycles)
modelBuilder.Entity<Bill>() modelBuilder.Entity<Bill>()
.HasOne(b => b.APAccount) .HasOne(b => b.APAccount)
@@ -1664,6 +1725,23 @@ modelBuilder.Entity<Job>()
.HasIndex(cn => new { cn.CustomerId, cn.CreatedAt }) .HasIndex(cn => new { cn.CustomerId, cn.CreatedAt })
.HasDatabaseName("IX_CustomerNotes_CustomerId_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 // END PERFORMANCE OPTIMIZATION INDEXES
// =================================================================== // ===================================================================
@@ -2028,6 +2106,61 @@ modelBuilder.Entity<Job>()
.HasIndex(c => new { c.CompanyId, c.MemoNumber }) .HasIndex(c => new { c.CompanyId, c.MemoNumber })
.IsUnique() .IsUnique()
.HasDatabaseName("IX_CreditMemos_CompanyId_MemoNumber"); .HasDatabaseName("IX_CreditMemos_CompanyId_MemoNumber");
// FormulaLibraryItem — platform-level, no tenant filter, no soft delete
// Self-referential "Inspired by" FK uses NoAction; cascade nullification handled in service.
modelBuilder.Entity<FormulaLibraryItem>()
.HasOne(f => f.InspiredBy)
.WithMany()
.HasForeignKey(f => f.InspiredByFormulaLibraryItemId)
.IsRequired(false)
.OnDelete(DeleteBehavior.NoAction);
modelBuilder.Entity<FormulaLibraryItem>()
.HasIndex(f => f.SourceCompanyId)
.HasDatabaseName("IX_FormulaLibraryItems_SourceCompanyId");
modelBuilder.Entity<FormulaLibraryItem>()
.HasIndex(f => f.IsPublished)
.HasDatabaseName("IX_FormulaLibraryItems_IsPublished");
// FormulaLibraryImport — tenant-scoped; unique per (CompanyId, FormulaLibraryItemId)
modelBuilder.Entity<FormulaLibraryImport>()
.HasOne(i => i.FormulaLibraryItem)
.WithMany()
.HasForeignKey(i => i.FormulaLibraryItemId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<FormulaLibraryImport>()
.HasOne(i => i.ResultingCustomItemTemplate)
.WithMany()
.HasForeignKey(i => i.ResultingCustomItemTemplateId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<FormulaLibraryImport>()
.HasIndex(i => new { i.CompanyId, i.FormulaLibraryItemId })
.IsUnique()
.HasDatabaseName("IX_FormulaLibraryImports_Company_Item");
// FormulaLibraryRating — platform-level; one vote per company per formula
modelBuilder.Entity<FormulaLibraryRating>()
.HasOne(r => r.FormulaLibraryItem)
.WithMany()
.HasForeignKey(r => r.FormulaLibraryItemId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<FormulaLibraryRating>()
.HasIndex(r => new { r.FormulaLibraryItemId, r.CompanyId })
.IsUnique()
.HasDatabaseName("IX_FormulaLibraryRatings_Item_Company");
// CustomItemTemplate → FormulaLibraryItem (nullable; only set on imported templates)
modelBuilder.Entity<CustomItemTemplate>()
.HasOne(t => t.SourceFormulaLibraryItem)
.WithMany()
.HasForeignKey(t => t.SourceFormulaLibraryItemId)
.IsRequired(false)
.OnDelete(DeleteBehavior.SetNull);
} }
/// <summary> /// <summary>
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 AddReworkPricingType : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "ReworkPricingType",
table: "ReworkRecords",
type: "int",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8533));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8542));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8543));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ReworkPricingType",
table: "ReworkRecords");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,93 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddVendorCategories : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "VendorInventoryCategories",
columns: table => new
{
InventoryCategoryLookupId = table.Column<int>(type: "int", nullable: false),
VendorId = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_VendorInventoryCategories", x => new { x.InventoryCategoryLookupId, x.VendorId });
table.ForeignKey(
name: "FK_VendorInventoryCategories_InventoryCategoryLookups_InventoryCategoryLookupId",
column: x => x.InventoryCategoryLookupId,
principalTable: "InventoryCategoryLookups",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_VendorInventoryCategories_Vendors_VendorId",
column: x => x.VendorId,
principalTable: "Vendors",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4300));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4313));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4315));
migrationBuilder.CreateIndex(
name: "IX_VendorInventoryCategories_VendorId",
table: "VendorInventoryCategories",
column: "VendorId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "VendorInventoryCategories");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8533));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8542));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8543));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,197 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddCustomItemTemplates : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "CustomItemTemplateId",
table: "QuoteItems",
type: "int",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "FormulaFieldValuesJson",
table: "QuoteItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsCustomFormulaItem",
table: "QuoteItems",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "CustomItemTemplateId",
table: "JobItems",
type: "int",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "FormulaFieldValuesJson",
table: "JobItems",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsCustomFormulaItem",
table: "JobItems",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "CustomItemTemplates",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
OutputMode = table.Column<string>(type: "nvarchar(max)", nullable: false),
FieldsJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
Formula = table.Column<string>(type: "nvarchar(max)", nullable: false),
DefaultRate = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
RateLabel = table.Column<string>(type: "nvarchar(max)", nullable: true),
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
DisplayOrder = table.Column<int>(type: "int", nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false),
DiagramImagePath = 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_CustomItemTemplates", x => x.Id);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 15, 30, 51, 760, DateTimeKind.Utc).AddTicks(9869));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 15, 30, 51, 760, DateTimeKind.Utc).AddTicks(9876));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 15, 30, 51, 760, DateTimeKind.Utc).AddTicks(9878));
migrationBuilder.CreateIndex(
name: "IX_QuoteItems_CustomItemTemplateId",
table: "QuoteItems",
column: "CustomItemTemplateId");
migrationBuilder.CreateIndex(
name: "IX_JobItems_CustomItemTemplateId",
table: "JobItems",
column: "CustomItemTemplateId");
migrationBuilder.AddForeignKey(
name: "FK_JobItems_CustomItemTemplates_CustomItemTemplateId",
table: "JobItems",
column: "CustomItemTemplateId",
principalTable: "CustomItemTemplates",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_QuoteItems_CustomItemTemplates_CustomItemTemplateId",
table: "QuoteItems",
column: "CustomItemTemplateId",
principalTable: "CustomItemTemplates",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_JobItems_CustomItemTemplates_CustomItemTemplateId",
table: "JobItems");
migrationBuilder.DropForeignKey(
name: "FK_QuoteItems_CustomItemTemplates_CustomItemTemplateId",
table: "QuoteItems");
migrationBuilder.DropTable(
name: "CustomItemTemplates");
migrationBuilder.DropIndex(
name: "IX_QuoteItems_CustomItemTemplateId",
table: "QuoteItems");
migrationBuilder.DropIndex(
name: "IX_JobItems_CustomItemTemplateId",
table: "JobItems");
migrationBuilder.DropColumn(
name: "CustomItemTemplateId",
table: "QuoteItems");
migrationBuilder.DropColumn(
name: "FormulaFieldValuesJson",
table: "QuoteItems");
migrationBuilder.DropColumn(
name: "IsCustomFormulaItem",
table: "QuoteItems");
migrationBuilder.DropColumn(
name: "CustomItemTemplateId",
table: "JobItems");
migrationBuilder.DropColumn(
name: "FormulaFieldValuesJson",
table: "JobItems");
migrationBuilder.DropColumn(
name: "IsCustomFormulaItem",
table: "JobItems");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4300));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4313));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4315));
}
}
}
@@ -0,0 +1,79 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class MakeMaintenanceIntervalNullable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "RecommendedMaintenanceIntervalDays",
table: "Equipment",
type: "int",
nullable: true,
oldClrType: typeof(int),
oldType: "int");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8197));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8203));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8204));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<int>(
name: "RecommendedMaintenanceIntervalDays",
table: "Equipment",
type: "int",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "int",
oldNullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4300));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4313));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4315));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddAllowCustomFormulas : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AllowCustomFormulas",
table: "SubscriptionPlanConfigs",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8290));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8297));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8298));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AllowCustomFormulas",
table: "SubscriptionPlanConfigs");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8197));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8203));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8204));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,115 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddEmployeeTimeclock : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "KioskPin",
table: "AspNetUsers",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.CreateTable(
name: "EmployeeClockEntries",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
UserId = table.Column<string>(type: "nvarchar(450)", nullable: false),
ClockInTime = table.Column<DateTime>(type: "datetime2", nullable: false),
ClockOutTime = table.Column<DateTime>(type: "datetime2", nullable: true),
HoursWorked = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
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_EmployeeClockEntries", x => x.Id);
table.ForeignKey(
name: "FK_EmployeeClockEntries_AspNetUsers_UserId",
column: x => x.UserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1387));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1395));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1397));
migrationBuilder.CreateIndex(
name: "IX_EmployeeClockEntries_CompanyId_ClockInTime",
table: "EmployeeClockEntries",
columns: new[] { "CompanyId", "ClockInTime" });
migrationBuilder.CreateIndex(
name: "IX_EmployeeClockEntries_UserId",
table: "EmployeeClockEntries",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "EmployeeClockEntries");
migrationBuilder.DropColumn(
name: "KioskPin",
table: "AspNetUsers");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8290));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8297));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8298));
}
}
}
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 AddTimeclockKioskToken : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "TimeclockKioskToken",
table: "Companies",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4791));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4801));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4803));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "TimeclockKioskToken",
table: "Companies");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1387));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1395));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1397));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,141 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddTimeclockSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "TimeclockKioskToken",
table: "Companies");
migrationBuilder.AddColumn<bool>(
name: "TimeclockAllowMultiplePunchesPerDay",
table: "Companies",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "TimeclockAutoClockOutHours",
table: "Companies",
type: "int",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "TimeclockEnabled",
table: "Companies",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.CreateTable(
name: "TimeclockKioskDevices",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
DeviceName = table.Column<string>(type: "nvarchar(max)", nullable: true),
Token = table.Column<string>(type: "nvarchar(450)", nullable: false),
ActivatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
LastSeenAt = table.Column<DateTime>(type: "datetime2", 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_TimeclockKioskDevices", x => x.Id);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3772));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3777));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3779));
migrationBuilder.CreateIndex(
name: "IX_TimeclockKioskDevices_CompanyId",
table: "TimeclockKioskDevices",
column: "CompanyId");
migrationBuilder.CreateIndex(
name: "IX_TimeclockKioskDevices_Token",
table: "TimeclockKioskDevices",
column: "Token",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "TimeclockKioskDevices");
migrationBuilder.DropColumn(
name: "TimeclockAllowMultiplePunchesPerDay",
table: "Companies");
migrationBuilder.DropColumn(
name: "TimeclockAutoClockOutHours",
table: "Companies");
migrationBuilder.DropColumn(
name: "TimeclockEnabled",
table: "Companies");
migrationBuilder.AddColumn<string>(
name: "TimeclockKioskToken",
table: "Companies",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4791));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4801));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4803));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddClockEntryType : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "EntryType",
table: "EmployeeClockEntries",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3040));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3052));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3054));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "EntryType",
table: "EmployeeClockEntries");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3772));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3777));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3779));
}
}
}
@@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddPowderCatalogItemIdToCoat : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "PowderCatalogItemId",
table: "QuoteItemCoats",
type: "int",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8956));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8962));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8964));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PowderCatalogItemId",
table: "QuoteItemCoats");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3040));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3052));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3054));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,214 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddFormulaLibrary : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsModifiedFromSource",
table: "CustomItemTemplates",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "SourceFormulaLibraryItemId",
table: "CustomItemTemplates",
type: "int",
nullable: true);
migrationBuilder.CreateTable(
name: "FormulaLibraryItems",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
OutputMode = table.Column<string>(type: "nvarchar(max)", nullable: false),
FieldsJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
Formula = table.Column<string>(type: "nvarchar(max)", nullable: false),
DefaultRate = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
RateLabel = table.Column<string>(type: "nvarchar(max)", nullable: true),
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
DiagramImagePath = table.Column<string>(type: "nvarchar(max)", nullable: true),
Tags = table.Column<string>(type: "nvarchar(max)", nullable: true),
IndustryHint = table.Column<string>(type: "nvarchar(max)", nullable: true),
SourceCustomItemTemplateId = table.Column<int>(type: "int", nullable: false),
SourceCompanyId = table.Column<int>(type: "int", nullable: false),
SourceCompanyName = table.Column<string>(type: "nvarchar(max)", nullable: false),
InspiredByFormulaLibraryItemId = table.Column<int>(type: "int", nullable: true),
SharedByUserId = table.Column<string>(type: "nvarchar(max)", nullable: false),
SharedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
IsPublished = table.Column<bool>(type: "bit", nullable: false),
ImportCount = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_FormulaLibraryItems", x => x.Id);
table.ForeignKey(
name: "FK_FormulaLibraryItems_FormulaLibraryItems_InspiredByFormulaLibraryItemId",
column: x => x.InspiredByFormulaLibraryItemId,
principalTable: "FormulaLibraryItems",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "FormulaLibraryImports",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
FormulaLibraryItemId = table.Column<int>(type: "int", nullable: false),
ImportedByUserId = table.Column<string>(type: "nvarchar(max)", nullable: false),
ImportedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
ResultingCustomItemTemplateId = table.Column<int>(type: "int", nullable: false),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_FormulaLibraryImports", x => x.Id);
table.ForeignKey(
name: "FK_FormulaLibraryImports_CustomItemTemplates_ResultingCustomItemTemplateId",
column: x => x.ResultingCustomItemTemplateId,
principalTable: "CustomItemTemplates",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_FormulaLibraryImports_FormulaLibraryItems_FormulaLibraryItemId",
column: x => x.FormulaLibraryItemId,
principalTable: "FormulaLibraryItems",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3849));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3855));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3856));
migrationBuilder.CreateIndex(
name: "IX_CustomItemTemplates_SourceFormulaLibraryItemId",
table: "CustomItemTemplates",
column: "SourceFormulaLibraryItemId");
migrationBuilder.CreateIndex(
name: "IX_FormulaLibraryImports_Company_Item",
table: "FormulaLibraryImports",
columns: new[] { "CompanyId", "FormulaLibraryItemId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_FormulaLibraryImports_FormulaLibraryItemId",
table: "FormulaLibraryImports",
column: "FormulaLibraryItemId");
migrationBuilder.CreateIndex(
name: "IX_FormulaLibraryImports_ResultingCustomItemTemplateId",
table: "FormulaLibraryImports",
column: "ResultingCustomItemTemplateId");
migrationBuilder.CreateIndex(
name: "IX_FormulaLibraryItems_InspiredByFormulaLibraryItemId",
table: "FormulaLibraryItems",
column: "InspiredByFormulaLibraryItemId");
migrationBuilder.CreateIndex(
name: "IX_FormulaLibraryItems_IsPublished",
table: "FormulaLibraryItems",
column: "IsPublished");
migrationBuilder.CreateIndex(
name: "IX_FormulaLibraryItems_SourceCompanyId",
table: "FormulaLibraryItems",
column: "SourceCompanyId");
migrationBuilder.AddForeignKey(
name: "FK_CustomItemTemplates_FormulaLibraryItems_SourceFormulaLibraryItemId",
table: "CustomItemTemplates",
column: "SourceFormulaLibraryItemId",
principalTable: "FormulaLibraryItems",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_CustomItemTemplates_FormulaLibraryItems_SourceFormulaLibraryItemId",
table: "CustomItemTemplates");
migrationBuilder.DropTable(
name: "FormulaLibraryImports");
migrationBuilder.DropTable(
name: "FormulaLibraryItems");
migrationBuilder.DropIndex(
name: "IX_CustomItemTemplates_SourceFormulaLibraryItemId",
table: "CustomItemTemplates");
migrationBuilder.DropColumn(
name: "IsModifiedFromSource",
table: "CustomItemTemplates");
migrationBuilder.DropColumn(
name: "SourceFormulaLibraryItemId",
table: "CustomItemTemplates");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8956));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8962));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8964));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,92 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddFormulaLibraryRatings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "FormulaLibraryRatings",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
FormulaLibraryItemId = table.Column<int>(type: "int", nullable: false),
CompanyId = table.Column<int>(type: "int", nullable: false),
IsPositive = table.Column<bool>(type: "bit", nullable: false),
RatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_FormulaLibraryRatings", x => x.Id);
table.ForeignKey(
name: "FK_FormulaLibraryRatings_FormulaLibraryItems_FormulaLibraryItemId",
column: x => x.FormulaLibraryItemId,
principalTable: "FormulaLibraryItems",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
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));
migrationBuilder.CreateIndex(
name: "IX_FormulaLibraryRatings_Item_Company",
table: "FormulaLibraryRatings",
columns: new[] { "FormulaLibraryItemId", "CompanyId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "FormulaLibraryRatings");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3849));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3855));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3856));
}
}
}
@@ -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));
}
}
}
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));
}
}
}
@@ -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));
}
}
}
@@ -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));
}
}
}
@@ -556,6 +556,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("IsBanned") b.Property<bool>("IsBanned")
.HasColumnType("bit"); .HasColumnType("bit");
b.Property<string>("KioskPin")
.HasColumnType("nvarchar(max)");
b.Property<decimal?>("LaborCostPerHour") b.Property<decimal?>("LaborCostPerHour")
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
@@ -1923,6 +1926,15 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("TimeZone") b.Property<string>("TimeZone")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<bool>("TimeclockAllowMultiplePunchesPerDay")
.HasColumnType("bit");
b.Property<int?>("TimeclockAutoClockOutHours")
.HasColumnType("int");
b.Property<bool>("TimeclockEnabled")
.HasColumnType("bit");
b.Property<DateTime?>("UpdatedAt") b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
@@ -2650,6 +2662,88 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("CreditMemoApplications"); b.ToTable("CreditMemoApplications");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.CustomItemTemplate", 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<decimal?>("DefaultRate")
.HasColumnType("decimal(18,2)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<string>("DiagramImagePath")
.HasColumnType("nvarchar(max)");
b.Property<int>("DisplayOrder")
.HasColumnType("int");
b.Property<string>("FieldsJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Formula")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<bool>("IsModifiedFromSource")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<string>("OutputMode")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("RateLabel")
.HasColumnType("nvarchar(max)");
b.Property<int?>("SourceFormulaLibraryItemId")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("SourceFormulaLibraryItemId");
b.ToTable("CustomItemTemplates");
});
modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b => modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -2724,6 +2818,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<DateTime?>("LastContactDate") b.Property<DateTime?>("LastContactDate")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
b.Property<string>("LeadSource")
.HasColumnType("nvarchar(max)");
b.Property<string>("MobilePhone") b.Property<string>("MobilePhone")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -2742,6 +2839,21 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int?>("PricingTierId") b.Property<int?>("PricingTierId")
.HasColumnType("int"); .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") b.Property<string>("SmsConsentMethod")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -2800,6 +2912,81 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("Customers"); 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 => modelBuilder.Entity("PowderCoating.Core.Entities.CustomerNote", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -2850,6 +3037,58 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("CustomerNotes"); 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 => modelBuilder.Entity("PowderCoating.Core.Entities.DashboardTip", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -2960,6 +3199,66 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("Deposits"); b.ToTable("Deposits");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.EmployeeClockEntry", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("ClockInTime")
.HasColumnType("datetime2");
b.Property<DateTime?>("ClockOutTime")
.HasColumnType("datetime2");
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<int>("EntryType")
.HasColumnType("int");
b.Property<decimal?>("HoursWorked")
.HasColumnType("decimal(18,2)");
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.Property<string>("UserId")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.HasKey("Id");
b.HasIndex("UserId");
b.HasIndex("CompanyId", "ClockInTime");
b.ToTable("EmployeeClockEntries");
});
modelBuilder.Entity("PowderCoating.Core.Entities.Equipment", b => modelBuilder.Entity("PowderCoating.Core.Entities.Equipment", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -3045,7 +3344,7 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<decimal>("PurchasePrice") b.Property<decimal>("PurchasePrice")
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
b.Property<int>("RecommendedMaintenanceIntervalDays") b.Property<int?>("RecommendedMaintenanceIntervalDays")
.HasColumnType("int"); .HasColumnType("int");
b.Property<string>("SerialNumber") b.Property<string>("SerialNumber")
@@ -3291,6 +3590,183 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("FixedAssetDepreciationEntries"); b.ToTable("FixedAssetDepreciationEntries");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryImport", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<int>("FormulaLibraryItemId")
.HasColumnType("int");
b.Property<DateTime>("ImportedAt")
.HasColumnType("datetime2");
b.Property<string>("ImportedByUserId")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<int>("ResultingCustomItemTemplateId")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("FormulaLibraryItemId");
b.HasIndex("ResultingCustomItemTemplateId");
b.HasIndex("CompanyId", "FormulaLibraryItemId")
.IsUnique()
.HasDatabaseName("IX_FormulaLibraryImports_Company_Item");
b.ToTable("FormulaLibraryImports");
});
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<decimal?>("DefaultRate")
.HasColumnType("decimal(18,2)");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<string>("DiagramImagePath")
.HasColumnType("nvarchar(max)");
b.Property<string>("FieldsJson")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Formula")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("ImportCount")
.HasColumnType("int");
b.Property<string>("IndustryHint")
.HasColumnType("nvarchar(max)");
b.Property<int?>("InspiredByFormulaLibraryItemId")
.HasColumnType("int");
b.Property<bool>("IsPublished")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<string>("OutputMode")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("RateLabel")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("SharedAt")
.HasColumnType("datetime2");
b.Property<string>("SharedByUserId")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("SourceCompanyId")
.HasColumnType("int");
b.Property<string>("SourceCompanyName")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("SourceCustomItemTemplateId")
.HasColumnType("int");
b.Property<string>("Tags")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("InspiredByFormulaLibraryItemId");
b.HasIndex("IsPublished")
.HasDatabaseName("IX_FormulaLibraryItems_IsPublished");
b.HasIndex("SourceCompanyId")
.HasDatabaseName("IX_FormulaLibraryItems_SourceCompanyId");
b.ToTable("FormulaLibraryItems");
});
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryRating", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<int>("FormulaLibraryItemId")
.HasColumnType("int");
b.Property<bool>("IsPositive")
.HasColumnType("bit");
b.Property<DateTime>("RatedAt")
.HasColumnType("datetime2");
b.HasKey("Id");
b.HasIndex("FormulaLibraryItemId", "CompanyId")
.IsUnique()
.HasDatabaseName("IX_FormulaLibraryRatings_Item_Company");
b.ToTable("FormulaLibraryRatings");
});
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b => modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -3938,6 +4414,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("PreparedById") b.Property<string>("PreparedById")
.HasColumnType("nvarchar(450)"); .HasColumnType("nvarchar(450)");
b.Property<string>("ProjectName")
.HasColumnType("nvarchar(max)");
b.Property<string>("PublicViewToken") b.Property<string>("PublicViewToken")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -4229,6 +4708,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("PricingBreakdownJson") b.Property<string>("PricingBreakdownJson")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<string>("ProjectName")
.HasColumnType("nvarchar(max)");
b.Property<int?>("QuoteId") b.Property<int?>("QuoteId")
.HasColumnType("int"); .HasColumnType("int");
@@ -4473,6 +4955,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("CreatedBy") b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<int?>("CustomItemTemplateId")
.HasColumnType("int");
b.Property<DateTime?>("DeletedAt") b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
@@ -4489,12 +4974,18 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("Finish") b.Property<string>("Finish")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<string>("FormulaFieldValuesJson")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IncludePrepCost") b.Property<bool>("IncludePrepCost")
.HasColumnType("bit"); .HasColumnType("bit");
b.Property<bool>("IsAiItem") b.Property<bool>("IsAiItem")
.HasColumnType("bit"); .HasColumnType("bit");
b.Property<bool>("IsCustomFormulaItem")
.HasColumnType("bit");
b.Property<bool>("IsDeleted") b.Property<bool>("IsDeleted")
.HasColumnType("bit"); .HasColumnType("bit");
@@ -4558,6 +5049,8 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("CatalogItemId"); b.HasIndex("CatalogItemId");
b.HasIndex("CustomItemTemplateId");
b.HasIndex("JobId") b.HasIndex("JobId")
.HasDatabaseName("IX_JobItems_JobId"); .HasDatabaseName("IX_JobItems_JobId");
@@ -6711,7 +7204,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 1, Id = 1,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186), CreatedAt = new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9129),
Description = "Standard pricing for regular customers", Description = "Standard pricing for regular customers",
DiscountPercent = 0m, DiscountPercent = 0m,
IsActive = true, IsActive = true,
@@ -6722,7 +7215,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 2, Id = 2,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190), CreatedAt = new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9137),
Description = "5% discount for preferred customers", Description = "5% discount for preferred customers",
DiscountPercent = 5m, DiscountPercent = 5m,
IsActive = true, IsActive = true,
@@ -6733,7 +7226,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 3, Id = 3,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191), CreatedAt = new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9138),
Description = "10% discount for premium customers", Description = "10% discount for premium customers",
DiscountPercent = 10m, DiscountPercent = 10m,
IsActive = true, IsActive = true,
@@ -7043,6 +7536,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<decimal>("ProfitPercent") b.Property<decimal>("ProfitPercent")
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
b.Property<string>("ProjectName")
.HasColumnType("nvarchar(max)");
b.Property<string>("ProspectAddress") b.Property<string>("ProspectAddress")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -7260,6 +7756,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("CreatedBy") b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<int?>("CustomItemTemplateId")
.HasColumnType("int");
b.Property<DateTime?>("DeletedAt") b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
@@ -7273,12 +7772,18 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int>("EstimatedMinutes") b.Property<int>("EstimatedMinutes")
.HasColumnType("int"); .HasColumnType("int");
b.Property<string>("FormulaFieldValuesJson")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IncludePrepCost") b.Property<bool>("IncludePrepCost")
.HasColumnType("bit"); .HasColumnType("bit");
b.Property<bool>("IsAiItem") b.Property<bool>("IsAiItem")
.HasColumnType("bit"); .HasColumnType("bit");
b.Property<bool>("IsCustomFormulaItem")
.HasColumnType("bit");
b.Property<bool>("IsDeleted") b.Property<bool>("IsDeleted")
.HasColumnType("bit"); .HasColumnType("bit");
@@ -7348,6 +7853,8 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("CatalogItemId"); b.HasIndex("CatalogItemId");
b.HasIndex("CustomItemTemplateId");
b.HasIndex("QuoteId") b.HasIndex("QuoteId")
.HasDatabaseName("IX_QuoteItems_QuoteId"); .HasDatabaseName("IX_QuoteItems_QuoteId");
@@ -7414,6 +7921,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("Notes") b.Property<string>("Notes")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<int?>("PowderCatalogItemId")
.HasColumnType("int");
b.Property<decimal?>("PowderCostPerLb") b.Property<decimal?>("PowderCostPerLb")
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
@@ -7990,6 +8500,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int?>("ReworkJobId") b.Property<int?>("ReworkJobId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<int?>("ReworkPricingType")
.HasColumnType("int");
b.Property<int>("ReworkType") b.Property<int>("ReworkType")
.HasColumnType("int"); .HasColumnType("int");
@@ -8073,6 +8586,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("AllowAiPhotoQuotes") b.Property<bool>("AllowAiPhotoQuotes")
.HasColumnType("bit"); .HasColumnType("bit");
b.Property<bool>("AllowCustomFormulas")
.HasColumnType("bit");
b.Property<bool>("AllowOnlinePayments") b.Property<bool>("AllowOnlinePayments")
.HasColumnType("bit"); .HasColumnType("bit");
@@ -8249,6 +8765,61 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("TermsAcceptances"); b.ToTable("TermsAcceptances");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.TimeclockKioskDevice", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<DateTime>("ActivatedAt")
.HasColumnType("datetime2");
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("DeviceName")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<DateTime?>("LastSeenAt")
.HasColumnType("datetime2");
b.Property<string>("Token")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("CompanyId");
b.HasIndex("Token")
.IsUnique();
b.ToTable("TimeclockKioskDevices");
});
modelBuilder.Entity("PowderCoating.Core.Entities.UserPasskey", b => modelBuilder.Entity("PowderCoating.Core.Entities.UserPasskey", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -8631,6 +9202,21 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("YearEndCloses"); b.ToTable("YearEndCloses");
}); });
modelBuilder.Entity("VendorInventoryCategories", b =>
{
b.Property<int>("InventoryCategoryLookupId")
.HasColumnType("int");
b.Property<int>("VendorId")
.HasColumnType("int");
b.HasKey("InventoryCategoryLookupId", "VendorId");
b.HasIndex("VendorId");
b.ToTable("VendorInventoryCategories");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b => modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
{ {
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
@@ -9016,6 +9602,16 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("Invoice"); b.Navigation("Invoice");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.CustomItemTemplate", b =>
{
b.HasOne("PowderCoating.Core.Entities.FormulaLibraryItem", "SourceFormulaLibraryItem")
.WithMany()
.HasForeignKey("SourceFormulaLibraryItemId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("SourceFormulaLibraryItem");
});
modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b => modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b =>
{ {
b.HasOne("PowderCoating.Core.Entities.Company", null) b.HasOne("PowderCoating.Core.Entities.Company", null)
@@ -9032,6 +9628,17 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("PricingTier"); 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 => modelBuilder.Entity("PowderCoating.Core.Entities.CustomerNote", b =>
{ {
b.HasOne("PowderCoating.Core.Entities.Customer", "Customer") b.HasOne("PowderCoating.Core.Entities.Customer", "Customer")
@@ -9043,6 +9650,25 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("Customer"); 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 => modelBuilder.Entity("PowderCoating.Core.Entities.Deposit", b =>
{ {
b.HasOne("PowderCoating.Core.Entities.Invoice", "AppliedToInvoice") b.HasOne("PowderCoating.Core.Entities.Invoice", "AppliedToInvoice")
@@ -9079,6 +9705,17 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("RecordedBy"); b.Navigation("RecordedBy");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.EmployeeClockEntry", b =>
{
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("PowderCoating.Core.Entities.Equipment", b => modelBuilder.Entity("PowderCoating.Core.Entities.Equipment", b =>
{ {
b.HasOne("PowderCoating.Core.Entities.Company", null) b.HasOne("PowderCoating.Core.Entities.Company", null)
@@ -9161,6 +9798,46 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("JournalEntry"); b.Navigation("JournalEntry");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryImport", b =>
{
b.HasOne("PowderCoating.Core.Entities.FormulaLibraryItem", "FormulaLibraryItem")
.WithMany()
.HasForeignKey("FormulaLibraryItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("PowderCoating.Core.Entities.CustomItemTemplate", "ResultingCustomItemTemplate")
.WithMany()
.HasForeignKey("ResultingCustomItemTemplateId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("FormulaLibraryItem");
b.Navigation("ResultingCustomItemTemplate");
});
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryItem", b =>
{
b.HasOne("PowderCoating.Core.Entities.FormulaLibraryItem", "InspiredBy")
.WithMany()
.HasForeignKey("InspiredByFormulaLibraryItemId")
.OnDelete(DeleteBehavior.NoAction);
b.Navigation("InspiredBy");
});
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryRating", b =>
{
b.HasOne("PowderCoating.Core.Entities.FormulaLibraryItem", "FormulaLibraryItem")
.WithMany()
.HasForeignKey("FormulaLibraryItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("FormulaLibraryItem");
});
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b => modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
{ {
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "IssuedBy") b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "IssuedBy")
@@ -9494,6 +10171,10 @@ namespace PowderCoating.Infrastructure.Migrations
.HasForeignKey("CatalogItemId") .HasForeignKey("CatalogItemId")
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
b.HasOne("PowderCoating.Core.Entities.CustomItemTemplate", "CustomItemTemplate")
.WithMany()
.HasForeignKey("CustomItemTemplateId");
b.HasOne("PowderCoating.Core.Entities.Job", "Job") b.HasOne("PowderCoating.Core.Entities.Job", "Job")
.WithMany("JobItems") .WithMany("JobItems")
.HasForeignKey("JobId") .HasForeignKey("JobId")
@@ -9504,6 +10185,8 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("CatalogItem"); b.Navigation("CatalogItem");
b.Navigation("CustomItemTemplate");
b.Navigation("Job"); b.Navigation("Job");
}); });
@@ -10113,6 +10796,10 @@ namespace PowderCoating.Infrastructure.Migrations
.WithMany() .WithMany()
.HasForeignKey("CatalogItemId"); .HasForeignKey("CatalogItemId");
b.HasOne("PowderCoating.Core.Entities.CustomItemTemplate", "CustomItemTemplate")
.WithMany()
.HasForeignKey("CustomItemTemplateId");
b.HasOne("PowderCoating.Core.Entities.Quote", "Quote") b.HasOne("PowderCoating.Core.Entities.Quote", "Quote")
.WithMany("QuoteItems") .WithMany("QuoteItems")
.HasForeignKey("QuoteId") .HasForeignKey("QuoteId")
@@ -10123,6 +10810,8 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("CatalogItem"); b.Navigation("CatalogItem");
b.Navigation("CustomItemTemplate");
b.Navigation("Quote"); b.Navigation("Quote");
}); });
@@ -10369,6 +11058,21 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("JournalEntry"); b.Navigation("JournalEntry");
}); });
modelBuilder.Entity("VendorInventoryCategories", b =>
{
b.HasOne("PowderCoating.Core.Entities.InventoryCategoryLookup", null)
.WithMany()
.HasForeignKey("InventoryCategoryLookupId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("PowderCoating.Core.Entities.Vendor", null)
.WithMany()
.HasForeignKey("VendorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("PowderCoating.Core.Entities.Account", b => modelBuilder.Entity("PowderCoating.Core.Entities.Account", b =>
{ {
b.Navigation("BillLineItems"); b.Navigation("BillLineItems");
@@ -10460,6 +11164,8 @@ namespace PowderCoating.Infrastructure.Migrations
modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b => modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b =>
{ {
b.Navigation("CustomerContacts");
b.Navigation("CustomerNotes"); b.Navigation("CustomerNotes");
b.Navigation("Invoices"); b.Navigation("Invoices");
@@ -21,6 +21,7 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="NCalc2" Version="2.1.0" />
<PackageReference Include="SendGrid" Version="9.29.3" /> <PackageReference Include="SendGrid" Version="9.29.3" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" /> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="Stripe.net" Version="50.4.1" /> <PackageReference Include="Stripe.net" Version="50.4.1" />
@@ -187,6 +187,14 @@ public class JobRepository : Repository<Job>, IJobRepository
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
} }
/// <inheritdoc/>
public async Task<int> GetReworkJobCountAsync(int originalJobId)
{
return await _context.Jobs
.IgnoreQueryFilters()
.CountAsync(j => j.OriginalJobId == originalJobId);
}
/// <inheritdoc/> /// <inheritdoc/>
public async Task<List<Job>> GetOverdueScheduledJobsAsync() public async Task<List<Job>> GetOverdueScheduledJobsAsync()
{ {
@@ -70,6 +70,8 @@ public class UnitOfWork : IUnitOfWork
private IJobPhotoRepository? _jobPhotos; private IJobPhotoRepository? _jobPhotos;
private IRepository<JobNote>? _jobNotes; private IRepository<JobNote>? _jobNotes;
private IRepository<CustomerNote>? _customerNotes; private IRepository<CustomerNote>? _customerNotes;
private IRepository<CustomerContact>? _customerContacts;
private IRepository<CustomerPreferredPowder>? _customerPreferredPowders;
private IRepository<JobStatusHistory>? _jobStatusHistory; private IRepository<JobStatusHistory>? _jobStatusHistory;
private IRepository<PricingTier>? _pricingTiers; private IRepository<PricingTier>? _pricingTiers;
@@ -123,6 +125,18 @@ public class UnitOfWork : IUnitOfWork
// Customer Intake Kiosk // Customer Intake Kiosk
private IRepository<KioskSession>? _kioskSessions; private IRepository<KioskSession>? _kioskSessions;
// Employee Timeclock
private IRepository<EmployeeClockEntry>? _employeeClockEntries;
private IRepository<TimeclockKioskDevice>? _timeclockKioskDevices;
// Custom Formula Templates
private IRepository<CustomItemTemplate>? _customItemTemplates;
// Formula Community Library
private IPlainRepository<FormulaLibraryItem>? _formulaLibrary;
private IRepository<FormulaLibraryImport>? _formulaLibraryImports;
private IPlainRepository<FormulaLibraryRating>? _formulaLibraryRatings;
// Purchase Orders // Purchase Orders
private IPurchaseOrderRepository? _purchaseOrders; private IPurchaseOrderRepository? _purchaseOrders;
private IRepository<PurchaseOrderItem>? _purchaseOrderItems; private IRepository<PurchaseOrderItem>? _purchaseOrderItems;
@@ -309,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> /// <summary>Repository for <see cref="CustomerNote"/> free-text staff notes on customer records; tenant-filtered with soft delete.</summary>
public IRepository<CustomerNote> CustomerNotes => public IRepository<CustomerNote> CustomerNotes =>
_customerNotes ??= new Repository<CustomerNote>(_context); _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> /// <summary>Repository for <see cref="JobStatusHistory"/> status-transition audit records; tenant-filtered with soft delete.</summary>
public IRepository<JobStatusHistory> JobStatusHistory => public IRepository<JobStatusHistory> JobStatusHistory =>
@@ -457,6 +476,30 @@ public class UnitOfWork : IUnitOfWork
public IRepository<KioskSession> KioskSessions => public IRepository<KioskSession> KioskSessions =>
_kioskSessions ??= new Repository<KioskSession>(_context); _kioskSessions ??= new Repository<KioskSession>(_context);
/// <summary>Repository for <see cref="EmployeeClockEntry"/> facility-level clock-in/clock-out records; tenant-filtered with soft delete. Multiple entries per day are fully supported.</summary>
public IRepository<EmployeeClockEntry> EmployeeClockEntries =>
_employeeClockEntries ??= new Repository<EmployeeClockEntry>(_context);
/// <summary>Repository for <see cref="TimeclockKioskDevice"/> activated tablet records; one row per device. Delete a row to revoke that device's access.</summary>
public IRepository<TimeclockKioskDevice> TimeclockKioskDevices =>
_timeclockKioskDevices ??= new Repository<TimeclockKioskDevice>(_context);
/// <summary>Repository for <see cref="CustomItemTemplate"/> per-company reusable NCalc pricing formula templates; tenant-filtered with soft delete.</summary>
public IRepository<CustomItemTemplate> CustomItemTemplates =>
_customItemTemplates ??= new Repository<CustomItemTemplate>(_context);
/// <summary>Repository for <see cref="FormulaLibraryItem"/> community library entries; platform-level, no tenant filter.</summary>
public IPlainRepository<FormulaLibraryItem> FormulaLibrary =>
_formulaLibrary ??= new PlainRepository<FormulaLibraryItem>(_context);
/// <summary>Repository for <see cref="FormulaLibraryImport"/> per-company import records; tenant-filtered with soft delete.</summary>
public IRepository<FormulaLibraryImport> FormulaLibraryImports =>
_formulaLibraryImports ??= new Repository<FormulaLibraryImport>(_context);
/// <summary>Repository for <see cref="FormulaLibraryRating"/> per-company thumbs votes; platform-level, no tenant filter.</summary>
public IPlainRepository<FormulaLibraryRating> FormulaLibraryRatings =>
_formulaLibraryRatings ??= new PlainRepository<FormulaLibraryRating>(_context);
// Job Templates // Job Templates
/// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary> /// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary>
public IJobTemplateRepository JobTemplates => public IJobTemplateRepository JobTemplates =>
@@ -262,6 +262,7 @@ public class CsvImportService : ICsvImportService
JobNumber = "JOB-2601-0001", JobNumber = "JOB-2601-0001",
CustomerEmail = "customer@example.com", CustomerEmail = "customer@example.com",
CustomerName = "Acme Corp (used if email is blank or not found)", CustomerName = "Acme Corp (used if email is blank or not found)",
Description = "Sample job description",
Status = "Pending", Status = "Pending",
Priority = "Normal", Priority = "Normal",
ScheduledDate = DateTime.Today.AddDays(7), ScheduledDate = DateTime.Today.AddDays(7),
@@ -269,7 +270,7 @@ public class CsvImportService : ICsvImportService
FinalPrice = 750.00m, FinalPrice = 750.00m,
CustomerPO = "PO-12345", CustomerPO = "PO-12345",
SpecialInstructions = "Handle with care", SpecialInstructions = "Handle with care",
Notes = "Sample job" Notes = "Internal notes"
}); });
csv.NextRecord(); csv.NextRecord();
@@ -388,8 +389,15 @@ public class CsvImportService : ICsvImportService
/// Imports customers from a CSV stream and persists valid rows to the database for the given company. /// Imports customers from a CSV stream and persists valid rows to the database for the given company.
/// The import uses a two-phase approach: all rows are parsed and validated first, then each validated /// The import uses a two-phase approach: all rows are parsed and validated first, then each validated
/// entity is saved individually so that a single bad row does not roll back the entire batch. /// entity is saved individually so that a single bad row does not roll back the entire batch.
/// Duplicate detection runs against both existing DB records (by email) and within the import file /// Duplicate detection uses a three-tier strategy, each tier only engaged when the previous
/// itself, catching cases where the same email appears twice in one upload. /// identifier is absent:
/// Tier 1 — email address (case-insensitive): if email is present and matches a DB record or
/// earlier batch row the row is skipped.
/// Tier 2 — name + normalised phone composite: used when email is absent. Combining name with
/// phone prevents false positives when two people share a number (e.g. a family).
/// Row is skipped on match.
/// Tier 3 — name + city/state/zip composite: used when both email and phone are absent.
/// Location data is imprecise so this emits a warning but still imports the row.
/// Pricing tiers are resolved by tier name; an unrecognised name is demoted to a warning and the /// Pricing tiers are resolved by tier name; an unrecognised name is demoted to a warning and the
/// customer is imported without a tier rather than being skipped entirely. /// customer is imported without a tier rather than being skipped entirely.
/// Contact names are split on the first space into FirstName / LastName because the CSV carries a /// Contact names are split on the first space into FirstName / LastName because the CSV carries a
@@ -418,15 +426,53 @@ public class CsvImportService : ICsvImportService
// Get all existing customers for duplicate detection // Get all existing customers for duplicate detection
var existingCustomers = await _unitOfWork.Customers.GetAllAsync(); var existingCustomers = await _unitOfWork.Customers.GetAllAsync();
// Tier 1 lookup: email → existing customer
var existingEmails = existingCustomers.Where(c => !string.IsNullOrEmpty(c.Email)) var existingEmails = existingCustomers.Where(c => !string.IsNullOrEmpty(c.Email))
.ToDictionary(c => c.Email!.ToLower(), c => c, StringComparer.OrdinalIgnoreCase); .ToDictionary(c => c.Email!.ToLower(), c => c, StringComparer.OrdinalIgnoreCase);
// Tier 2 lookup: (normalised phone + "|" + display name) → existing customer.
// Combining name with phone avoids false positives when two people share a number.
var existingByPhoneAndName = new Dictionary<string, Customer>(StringComparer.OrdinalIgnoreCase);
foreach (var c in existingCustomers)
{
var phone = NormalizePhone(c.MobilePhone) ?? NormalizePhone(c.Phone);
if (phone == null) continue;
var name = string.IsNullOrWhiteSpace(c.CompanyName)
? $"{c.ContactFirstName} {c.ContactLastName}".Trim()
: c.CompanyName;
var key = $"{phone}|{name}";
if (!existingByPhoneAndName.ContainsKey(key))
existingByPhoneAndName[key] = c;
}
// Tier 3 lookup: (display name + "|" + city + "|" + state + "|" + zip) → existing customer.
// Only populated when a customer has at least one location field so the key isn't trivially blank.
var existingByNameAndLocation = new Dictionary<string, Customer>(StringComparer.OrdinalIgnoreCase);
foreach (var c in existingCustomers)
{
if (string.IsNullOrWhiteSpace(c.City) && string.IsNullOrWhiteSpace(c.State) && string.IsNullOrWhiteSpace(c.ZipCode))
continue;
var name = string.IsNullOrWhiteSpace(c.CompanyName)
? $"{c.ContactFirstName} {c.ContactLastName}".Trim()
: c.CompanyName;
if (string.IsNullOrWhiteSpace(name)) continue;
var key = $"{name}|{c.City}|{c.State}|{c.ZipCode}";
if (!existingByNameAndLocation.ContainsKey(key))
existingByNameAndLocation[key] = c;
}
// Get pricing tiers for lookup // Get pricing tiers for lookup
var pricingTiers = await _unitOfWork.PricingTiers.GetAllAsync(); var pricingTiers = await _unitOfWork.PricingTiers.GetAllAsync();
var pricingTierDict = pricingTiers.ToDictionary(pt => pt.TierName.ToUpper(), pt => pt, StringComparer.OrdinalIgnoreCase); var pricingTierDict = pricingTiers.ToDictionary(pt => pt.TierName.ToUpper(), pt => pt, StringComparer.OrdinalIgnoreCase);
var customersToImport = new List<(int RowNumber, Customer Customer, string Email)>(); var customersToImport = new List<(int RowNumber, Customer Customer, string Email)>();
// Within-batch tracking sets (prevent duplicate detection against rows already queued)
var batchEmails = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var batchPhoneAndName = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var batchNameAndLocation = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var record in records) foreach (var record in records)
{ {
rowNumber++; rowNumber++;
@@ -434,7 +480,12 @@ public class CsvImportService : ICsvImportService
{ {
// Strip any literal quote characters that QB/Excel may wrap around field values // Strip any literal quote characters that QB/Excel may wrap around field values
var cleanCompanyName = StripQuotes(record.CompanyName); var cleanCompanyName = StripQuotes(record.CompanyName);
var cleanEmail = StripQuotes(record.Email); // Normalise to null (not empty string) — the UNIQUE index on (CompanyId, Email)
// uses HasFilter("[Email] IS NOT NULL"), so NULL is allowed for multiple rows
// but "" (empty string) is not NULL and would cause a unique-constraint violation
// on the second blank-email customer saved.
var rawEmail = StripQuotes(record.Email);
var cleanEmail = string.IsNullOrWhiteSpace(rawEmail) ? null : rawEmail;
var firstName = StripQuotes(record.ContactFirstName)?.Trim(); var firstName = StripQuotes(record.ContactFirstName)?.Trim();
var lastName = StripQuotes(record.ContactLastName)?.Trim(); var lastName = StripQuotes(record.ContactLastName)?.Trim();
@@ -451,20 +502,68 @@ public class CsvImportService : ICsvImportService
cleanCompanyName = derivedName; cleanCompanyName = derivedName;
} }
// Check for duplicate email in existing data // Canonical display name used as part of composite keys in Tiers 2 and 3
if (!string.IsNullOrEmpty(cleanEmail) && existingEmails.ContainsKey(cleanEmail.ToLower())) var displayName = string.IsNullOrWhiteSpace(cleanCompanyName)
{ ? $"{firstName} {lastName}".Trim()
result.Warnings.Add($"Row {rowNumber}: Customer with email '{cleanEmail}' already exists in database. Skipping."); : cleanCompanyName;
result.SkippedCount++;
continue;
}
// Check for duplicate email within the import batch // --- Tier 1: email dedup ---
if (!string.IsNullOrEmpty(cleanEmail) && customersToImport.Any(x => x.Email.Equals(cleanEmail, StringComparison.OrdinalIgnoreCase))) // Only engaged when the row has an email address.
if (!string.IsNullOrEmpty(cleanEmail))
{ {
result.Warnings.Add($"Row {rowNumber}: Duplicate email '{cleanEmail}' found in import file. Skipping."); if (existingEmails.ContainsKey(cleanEmail.ToLower()))
result.SkippedCount++; {
continue; result.Warnings.Add($"Row {rowNumber}: Customer with email '{cleanEmail}' already exists in database. Skipping.");
result.SkippedCount++;
continue;
}
if (batchEmails.Contains(cleanEmail))
{
result.Warnings.Add($"Row {rowNumber}: Duplicate email '{cleanEmail}' found in import file. Skipping.");
result.SkippedCount++;
continue;
}
}
else
{
// --- Tier 2: name + phone composite dedup (email absent) ---
// Requiring both name and phone to match avoids false positives when two
// unrelated people happen to share a phone number (e.g. a shared office line).
var normalizedPhone = NormalizePhone(record.MobilePhone) ?? NormalizePhone(record.Phone);
if (normalizedPhone != null)
{
var phoneNameKey = $"{normalizedPhone}|{displayName}";
if (existingByPhoneAndName.TryGetValue(phoneNameKey, out var existingMatch))
{
result.Warnings.Add($"Row {rowNumber}: '{displayName}' has no email; name + phone matches existing customer '{existingMatch.CompanyName}'. Skipping.");
result.SkippedCount++;
continue;
}
if (batchPhoneAndName.Contains(phoneNameKey))
{
result.Warnings.Add($"Row {rowNumber}: '{displayName}' has no email; duplicate name + phone found in import file. Skipping.");
result.SkippedCount++;
continue;
}
}
else
{
// --- Tier 3: name + location composite warning (no email, no phone) ---
// Location data is imprecise so we warn but still import — a name + city
// collision across unrelated people is plausible enough not to hard-skip.
var city = record.City?.Trim();
var state = record.State?.Trim();
var zip = record.ZipCode?.Trim();
var hasLocation = !string.IsNullOrWhiteSpace(city) || !string.IsNullOrWhiteSpace(state) || !string.IsNullOrWhiteSpace(zip);
if (hasLocation && !string.IsNullOrWhiteSpace(displayName))
{
var locationKey = $"{displayName}|{city}|{state}|{zip}";
if (existingByNameAndLocation.ContainsKey(locationKey) || batchNameAndLocation.Contains(locationKey))
{
result.Warnings.Add($"Row {rowNumber}: '{displayName}' has no email or phone; name + location matches an existing record. Imported anyway — verify manually.");
}
}
}
} }
// Resolve pricing tier // Resolve pricing tier
@@ -506,12 +605,41 @@ public class CsvImportService : ICsvImportService
PaymentTerms = record.PaymentTerms?.Trim() ?? "Net 30", PaymentTerms = record.PaymentTerms?.Trim() ?? "Net 30",
IsTaxExempt = record.TaxExempt ?? false, IsTaxExempt = record.TaxExempt ?? false,
GeneralNotes = record.Notes?.Trim(), 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, IsActive = record.IsActive ?? true,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow UpdatedAt = DateTime.UtcNow
}; };
customersToImport.Add((rowNumber, customer, cleanEmail ?? string.Empty)); customersToImport.Add((rowNumber, customer, cleanEmail ?? string.Empty));
// Register in batch tracking so later rows are checked against this one
if (!string.IsNullOrEmpty(cleanEmail))
{
batchEmails.Add(cleanEmail);
}
else
{
var normalizedPhone = NormalizePhone(record.MobilePhone) ?? NormalizePhone(record.Phone);
if (normalizedPhone != null)
{
batchPhoneAndName.Add($"{normalizedPhone}|{displayName}");
}
else
{
var city = record.City?.Trim();
var state = record.State?.Trim();
var zip = record.ZipCode?.Trim();
var hasLocation = !string.IsNullOrWhiteSpace(city) || !string.IsNullOrWhiteSpace(state) || !string.IsNullOrWhiteSpace(zip);
if (hasLocation && !string.IsNullOrWhiteSpace(displayName))
batchNameAndLocation.Add($"{displayName}|{city}|{state}|{zip}");
}
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -1162,6 +1290,7 @@ public class CsvImportService : ICsvImportService
Total = record.Total, Total = record.Total,
Notes = record.Notes?.Trim(), Notes = record.Notes?.Trim(),
Terms = record.TermsAndConditions?.Trim(), Terms = record.TermsAndConditions?.Trim(),
ProjectName = record.ProjectName?.Trim(),
IsCommercial = customerId.HasValue, IsCommercial = customerId.HasValue,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow UpdatedAt = DateTime.UtcNow
@@ -1268,24 +1397,22 @@ public class CsvImportService : ICsvImportService
MissingFieldFound = null MissingFieldFound = null
}); });
var records = csv.GetRecords<JobImportDto>().ToList(); // Treat non-numeric values in decimal? fields (e.g. a spreadsheet "false" in FinalPrice)
result.TotalRows = records.Count; // as null rather than throwing a fatal TypeConverterException.
csv.Context.TypeConverterCache.AddConverter<decimal?>(new LenientNullableDecimalConverter());
_logger.LogInformation("Starting import of {Count} jobs for company {CompanyId}", records.Count, companyId); // Read header row first so we know field count before iterating rows.
await csv.ReadAsync();
csv.ReadHeader();
// Get all existing jobs for duplicate detection // Pre-load lookup data before streaming rows so async calls don't interleave with CSV reads.
var existingJobs = await _unitOfWork.Jobs.GetAllAsync(); var existingJobs = await _unitOfWork.Jobs.GetAllAsync();
var existingJobNumbers = existingJobs.Where(j => !string.IsNullOrEmpty(j.JobNumber)) var existingJobNumbers = existingJobs.Where(j => !string.IsNullOrEmpty(j.JobNumber))
.ToDictionary(j => j.JobNumber.ToUpper(), j => j, StringComparer.OrdinalIgnoreCase); .ToDictionary(j => j.JobNumber.ToUpper(), j => j, StringComparer.OrdinalIgnoreCase);
// Get customers for lookup — build two dictionaries so we can resolve by email
// first and fall back to company name when the customer has no email on file.
var customers = await _unitOfWork.Customers.GetAllAsync(); var customers = await _unitOfWork.Customers.GetAllAsync();
var customerByEmail = customers.Where(c => !string.IsNullOrEmpty(c.Email)) var customerByEmail = customers.Where(c => !string.IsNullOrEmpty(c.Email))
.ToDictionary(c => c.Email!.Trim().ToLower(), c => c, StringComparer.OrdinalIgnoreCase); .ToDictionary(c => c.Email!.Trim().ToLower(), c => c, StringComparer.OrdinalIgnoreCase);
// Name fallback: keyed on CompanyName (commercial) or "First Last" (non-commercial).
// TryAdd ensures that if two customers share the same name the first one wins and the
// lookup warning will prompt the user to resolve the ambiguity manually.
var customerByName = new Dictionary<string, Customer>(StringComparer.OrdinalIgnoreCase); var customerByName = new Dictionary<string, Customer>(StringComparer.OrdinalIgnoreCase);
foreach (var c in customers) foreach (var c in customers)
{ {
@@ -1296,19 +1423,42 @@ public class CsvImportService : ICsvImportService
customerByName.TryAdd(name, c); customerByName.TryAdd(name, c);
} }
// Get job statuses for lookup
var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync(); var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
var jobStatusDict = jobStatuses.ToDictionary(js => js.StatusCode.ToUpper(), js => js, StringComparer.OrdinalIgnoreCase); var jobStatusDict = jobStatuses.ToDictionary(js => js.StatusCode.ToUpper(), js => js, StringComparer.OrdinalIgnoreCase);
// Get job priorities for lookup
var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync(); var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
var jobPriorityDict = jobPriorities.ToDictionary(jp => jp.PriorityCode.ToUpper(), jp => jp, StringComparer.OrdinalIgnoreCase); var jobPriorityDict = jobPriorities.ToDictionary(jp => jp.PriorityCode.ToUpper(), jp => jp, StringComparer.OrdinalIgnoreCase);
var jobsToImport = new List<(int RowNumber, Job Job, string JobNumber)>(); var jobsToImport = new List<(int RowNumber, Job Job, string JobNumber)>();
foreach (var record in records) // Stream rows one at a time so a bad type conversion on a single row (e.g. "false"
// in a decimal field) is caught per-row rather than aborting the entire import.
while (await csv.ReadAsync())
{ {
rowNumber++; result.TotalRows++;
JobImportDto record;
try
{
record = csv.GetRecord<JobImportDto>()
?? throw new InvalidOperationException("Row returned null record.");
}
catch (Exception parseEx)
{
result.Errors.Add($"Row {csv.Context.Parser?.Row}: Could not parse row - {parseEx.InnerException?.Message ?? parseEx.Message}");
result.ErrorCount++;
_logger.LogWarning(parseEx, "Parse error at CSV row {Row}", csv.Context.Parser?.Row);
continue;
}
rowNumber = csv.Context.Parser?.Row ?? rowNumber + 1;
// Warn when FinalPrice was non-numeric (lenient converter returned null).
var rawFinalPrice = csv.TryGetField<string>(7, out var fpStr) ? fpStr : null;
if (!string.IsNullOrWhiteSpace(rawFinalPrice) && record.FinalPrice == null
&& !decimal.TryParse(rawFinalPrice, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out _))
{
result.Warnings.Add($"Row {rowNumber}: FinalPrice value '{rawFinalPrice}' could not be parsed as a number; defaulting to $0.");
}
try try
{ {
// Validate required fields // Validate required fields
@@ -1414,7 +1564,10 @@ public class CsvImportService : ICsvImportService
CustomerPO = record.CustomerPO?.Trim(), CustomerPO = record.CustomerPO?.Trim(),
SpecialInstructions = record.SpecialInstructions?.Trim(), SpecialInstructions = record.SpecialInstructions?.Trim(),
InternalNotes = record.Notes?.Trim(), InternalNotes = record.Notes?.Trim(),
Description = record.SpecialInstructions?.Trim() ?? "Imported job", ProjectName = record.ProjectName?.Trim(),
Description = record.Description?.Trim()
?? record.SpecialInstructions?.Trim()
?? "Imported job",
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow UpdatedAt = DateTime.UtcNow
}; };
@@ -2813,6 +2966,23 @@ public class CsvImportService : ICsvImportService
return trimmed; return trimmed;
} }
/// <summary>
/// Normalises a phone string to its last 10 digits for duplicate-detection comparisons.
/// Stripping to digits only means formatting differences such as (423)&nbsp;331-9834,
/// 423-331-9834, and 4233319834 all produce the same key. Returns null when the input
/// contains fewer than 7 digits — too short to be a real phone number and avoids false
/// positive matches on placeholder values like "N/A" or extension-only strings.
/// </summary>
/// <param name="phone">Raw phone string as read from the CSV, or null.</param>
/// <returns>Last 10 (or all, if fewer than 10) digits of the input; null if input is unusable.</returns>
private static string? NormalizePhone(string? phone)
{
if (string.IsNullOrWhiteSpace(phone)) return null;
var digits = new string(phone.Where(char.IsDigit).ToArray());
if (digits.Length < 7) return null;
return digits.Length >= 10 ? digits[^10..] : digits;
}
// ── Invoice Import ─────────────────────────────────────────────────────────── // ── Invoice Import ───────────────────────────────────────────────────────────
/// <summary> /// <summary>
@@ -2984,9 +3154,10 @@ public class CsvImportService : ICsvImportService
existing.DiscountAmount = record.DiscountAmount; existing.DiscountAmount = record.DiscountAmount;
existing.Total = record.Total; existing.Total = record.Total;
existing.AmountPaid = record.AmountPaid; existing.AmountPaid = record.AmountPaid;
existing.CustomerPO = string.IsNullOrWhiteSpace(record.CustomerPO) ? null : record.CustomerPO.Trim(); existing.CustomerPO = string.IsNullOrWhiteSpace(record.CustomerPO) ? null : record.CustomerPO.Trim();
existing.Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim(); existing.Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim();
existing.Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim(); existing.Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim();
existing.ProjectName = string.IsNullOrWhiteSpace(record.ProjectName) ? null : record.ProjectName.Trim();
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
result.SuccessCount++; result.SuccessCount++;
} }
@@ -3008,9 +3179,10 @@ public class CsvImportService : ICsvImportService
DiscountAmount = record.DiscountAmount, DiscountAmount = record.DiscountAmount,
Total = record.Total, Total = record.Total,
AmountPaid = record.AmountPaid, AmountPaid = record.AmountPaid,
CustomerPO = string.IsNullOrWhiteSpace(record.CustomerPO) ? null : record.CustomerPO.Trim(), CustomerPO = string.IsNullOrWhiteSpace(record.CustomerPO) ? null : record.CustomerPO.Trim(),
Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.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.Invoices.AddAsync(invoice);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
@@ -3340,4 +3512,23 @@ public class CsvImportService : ICsvImportService
return result; return result;
} }
} }
/// <summary>
/// Returns null for any value that cannot be parsed as a decimal, instead of throwing a
/// TypeConverterException. Registered globally on the job CSV reader so that spreadsheet
/// artefacts like "false" in a price column are treated as $0 with a warning.
/// </summary>
private sealed class LenientNullableDecimalConverter : CsvHelper.TypeConversion.ITypeConverter
{
public object? ConvertFromString(string? text, CsvHelper.IReaderRow row, CsvHelper.Configuration.MemberMapData memberMapData)
{
if (string.IsNullOrWhiteSpace(text)) return null;
return decimal.TryParse(text, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var v)
? (object?)v
: null;
}
public string? ConvertToString(object? value, CsvHelper.IWriterRow row, CsvHelper.Configuration.MemberMapData memberMapData)
=> value?.ToString();
}
} }
@@ -0,0 +1,247 @@
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using NCalc2;
using Anthropic.SDK;
using Anthropic.SDK.Messaging;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.DTOs.Company;
using PowderCoating.Application.Interfaces;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Generates NCalc pricing formula templates from natural-language descriptions using
/// Claude Sonnet. Accepts an optional diagram image so the model can see the physical
/// shape being estimated. The model returns a structured JSON object containing the
/// field list, NCalc expression, output mode, and verification inputs; the service
/// parses and returns it as a <see cref="GenerateFormulaFromAiResponse"/>.
/// </summary>
public class CustomFormulaAiService : ICustomFormulaAiService
{
private readonly IConfiguration _config;
private readonly ILogger<CustomFormulaAiService> _logger;
private const string SystemPrompt = @"You are an expert pricing formula engineer for a powder coating business.
Your job is to generate NCalc expressions that calculate either a fixed price or a surface area
from user-supplied field values.
CRITICAL: NCalc function names are CASE-SENSITIVE and must be ALL LOWERCASE.
Supported built-in functions (always write these exactly as shown):
if(condition, trueValue, falseValue) conditional expression
abs(x) absolute value
round(x, digits) round to N decimal places
max(a, b) larger of two values
min(a, b) smaller of two values
pow(base, exponent) exponentiation
sqrt(x) square root
Standard operators: + - * / %
Comparison operators: < > <= >= == !=
Boolean operators: && || !
Do NOT use: IF, Abs, Round, Max, Min, Pow, Sqrt (uppercase versions) NCalc will reject them.
The user will describe a custom fabricated item (e.g., 'Roof curb', 'Electrical enclosure',
'Tubular frame') and you must produce a pricing formula template.
Respond ONLY with a valid JSON object matching this exact schema no markdown, no explanation:
{
""name"": ""string short template name (e.g. 'Roof Curb', 'Electrical Enclosure')"",
""outputMode"": ""FixedRate"" | ""SurfaceAreaSqFt"",
""fields"": [
{
""name"": ""snake_case_variable_name"",
""label"": ""Human-readable label"",
""unit"": ""in / ft / mm / cm / qty / lbs or empty string"",
""defaultValue"": number
}
],
""formula"": ""NCalc expression using field name variables and optionally 'rate'"",
""defaultRate"": number or null,
""rateLabel"": ""string label for the rate field, e.g. '$/sq ft' null if no rate"",
""reasoning"": ""1-2 sentences explaining how the formula was derived"",
""verificationInputs"": { ""variable_name"": number },
""verificationResult"": number
}
Built-in shop-rate variables (injected automatically at eval time do NOT redeclare them as fields):
standard_labor_rate shop billing rate in $/hr (e.g. hours * standard_labor_rate)
additional_coat_labor_pct extra-coat labor surcharge 0100 (e.g. cost * (1 + additional_coat_labor_pct/100))
markup_pct general markup percentage 0100 (e.g. cost * (1 + markup_pct/100))
Rules:
- Use FixedRate when the formula directly calculates a dollar amount (e.g. surface area × rate per sqft)
- Use SurfaceAreaSqFt when the formula calculates square footage and the standard pricing engine handles the rest
- Always include a 'rate' variable when outputMode is FixedRate and the price scales with dimensions, UNLESS the formula already uses standard_labor_rate or another built-in
- Field names must be valid NCalc identifiers (letters, digits, underscores; no spaces or hyphens)
- Do NOT include standard_labor_rate, additional_coat_labor_pct, or markup_pct in the fields array they are injected automatically
- verificationInputs and verificationResult must use the exact field names and formula you generated
- Surface area formulas for box shapes: 2*(L*W + L*H + W*H) where L/W/H are in the same unit; convert to sqft if needed
- For inch inputs convert to sqft: divide by 144 (sqinsqft) or use /12 per side before multiplying
";
public CustomFormulaAiService(IConfiguration config, ILogger<CustomFormulaAiService> logger)
{
_config = config;
_logger = logger;
}
/// <inheritdoc />
public async Task<GenerateFormulaFromAiResponse> GenerateFormulaAsync(
GenerateFormulaFromAiRequest request,
byte[]? imageBytes = null,
string? imageContentType = null)
{
var apiKey = _config["AI:Anthropic:ApiKey"];
if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-"))
{
return new GenerateFormulaFromAiResponse
{
Success = false,
Error = "Anthropic API key is not configured. Add AI:Anthropic:ApiKey to appsettings.json."
};
}
try
{
var client = new AnthropicClient(apiKey);
var userContent = new List<ContentBase>();
if (imageBytes is { Length: > 0 } && !string.IsNullOrWhiteSpace(imageContentType))
{
userContent.Add(new ImageContent
{
Source = new ImageSource
{
MediaType = imageContentType,
Data = Convert.ToBase64String(imageBytes)
}
});
}
userContent.Add(new TextContent { Text = request.Description });
var messages = new List<Message>
{
new() { Role = RoleType.User, Content = userContent }
};
var response = await client.Messages.GetClaudeMessageAsync(new MessageParameters
{
Model = "claude-sonnet-4-6",
MaxTokens = 1024,
SystemMessage = SystemPrompt,
Messages = messages
});
var rawJson = response.Message.ToString().Trim();
// Strip markdown code fences if the model adds them
if (rawJson.StartsWith("```"))
{
var start = rawJson.IndexOf('\n') + 1;
var end = rawJson.LastIndexOf("```");
if (end > start) rawJson = rawJson[start..end].Trim();
}
using var doc = JsonDocument.Parse(rawJson);
var root = doc.RootElement;
var fieldsJson = root.TryGetProperty("fields", out var fieldsEl)
? fieldsEl.GetRawText()
: "[]";
decimal? defaultRate = null;
if (root.TryGetProperty("defaultRate", out var rateEl) && rateEl.ValueKind == JsonValueKind.Number)
defaultRate = rateEl.GetDecimal();
decimal? verificationResult = null;
if (root.TryGetProperty("verificationResult", out var vrEl) && vrEl.ValueKind == JsonValueKind.Number)
verificationResult = vrEl.GetDecimal();
string? verificationInputs = null;
if (root.TryGetProperty("verificationInputs", out var viEl))
verificationInputs = viEl.GetRawText();
return new GenerateFormulaFromAiResponse
{
Success = true,
Name = root.TryGetProperty("name", out var nameEl) ? nameEl.GetString() : null,
OutputMode = root.TryGetProperty("outputMode", out var omEl) ? omEl.GetString() : "FixedRate",
FieldsJson = fieldsJson,
Formula = root.TryGetProperty("formula", out var fEl) ? fEl.GetString() : null,
DefaultRate = defaultRate,
RateLabel = root.TryGetProperty("rateLabel", out var rlEl) ? rlEl.GetString() : null,
Reasoning = root.TryGetProperty("reasoning", out var reEl) ? reEl.GetString() : null,
VerificationResult = verificationResult,
VerificationInputs = verificationInputs
};
}
catch (Exception ex)
{
_logger.LogError(ex, "CustomFormulaAiService.GenerateFormulaAsync failed");
return new GenerateFormulaFromAiResponse { Success = false, Error = ex.Message };
}
}
// Lowercase NCalc built-in names before evaluation so that user-typed or AI-generated
// uppercase variants (IF, Abs, POW, etc.) don't produce "Function not found" errors.
private static readonly Regex _ncalcFuncRegex = new(
@"\b(if|abs|round|max|min|pow|sqrt|ceiling|floor|truncate|sign|log|exp)\b(?=\s*\()",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static string NormalizeFormula(string formula) =>
_ncalcFuncRegex.Replace(formula, m => m.Value.ToLowerInvariant());
/// <inheritdoc />
public (string NormalizedFormula, string? Error) NormalizeAndValidate(string formula)
{
if (string.IsNullOrWhiteSpace(formula))
return (formula, "Formula cannot be empty.");
var normalized = NormalizeFormula(formula);
try
{
var expr = new Expression(normalized);
if (expr.HasErrors())
return (formula, expr.Error);
return (normalized, null);
}
catch (Exception ex)
{
return (formula, ex.Message);
}
}
/// <inheritdoc />
public EvaluateFormulaResponse EvaluateFormula(EvaluateFormulaRequest request)
{
if (string.IsNullOrWhiteSpace(request?.Formula))
return new EvaluateFormulaResponse { Success = false, Error = "Formula is required." };
try
{
var variables = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(
request.VariablesJson ?? "{}") ?? new();
var expr = new Expression(NormalizeFormula(request.Formula));
foreach (var kv in variables)
{
expr.Parameters[kv.Key] = kv.Value.ValueKind == JsonValueKind.Number
? (object)kv.Value.GetDouble()
: (object)(kv.Value.GetString() ?? "");
}
var result = expr.Evaluate();
var decResult = Convert.ToDecimal(result);
return new EvaluateFormulaResponse { Success = true, Result = Math.Round(decResult, 4) };
}
catch (Exception ex)
{
return new EvaluateFormulaResponse { Success = false, Error = ex.Message };
}
}
}

Some files were not shown because too many files have changed in this diff Show More