Compare commits

...

90 Commits

Author SHA1 Message Date
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
spouliot b7ab85ff92 Merge dev into master: QR scan URL fixes and http scheme failsafe 2026-05-22 17:41:01 -04:00
spouliot 15b070398b Change URL scheme fallback from https to http
Manufacturer product pages are often not on secure connections; http:// is the
safer default to avoid connection failures on non-SSL sites.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 17:40:14 -04:00
spouliot 14f220347b Add scheme failsafe to all inventory URL link buttons
If a stored URL is missing http:// or https:// the browser treats it as relative
and appends it to the app URL. Guard in three places:

- inventory-catalog-lookup.js syncLinkButton: ensureAbsoluteUrl() prepends https://
- inventory-label-scan.js syncLink: same guard for scan-filled URL fields
- Details.cshtml SafeUrl() Razor helper on SpecPageUrl, SdsUrl, TdsUrl links

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 17:38:09 -04:00
spouliot baec0b33f7 Fix QR scan stripping scheme from product URL
LookupAsync builds SpecPageUrl from the ProductUrlTemplate via TryBuildDirectUrl.
If the template is stored without a scheme the link is scheme-less and browsers
treat it as relative, appending it to the app URL.

The scanned QR URL is always fully-qualified and always the correct product page
(it came from the manufacturer's bag), so use it unconditionally as SpecPageUrl
on the pattern-matched QR path instead of only when SpecPageUrl was null.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 17:26:56 -04:00
spouliot ce7b00b68c Merge dev into master: inventory bin filter, print bin, mobile login fixes, QR scan fix 2026-05-22 15:22:38 -04:00
spouliot dfb1d34af3 Add inventory bin filter, print bin, mobile login fixes, and QR scan fix
- Inventory: location filter dropdown + Print Bin page (line #, name, color, SKU)
- Fix: Prismatic Powders QR scan now extracts manufacturer/SKU/color from URL path
  and uses full LookupAsync pipeline instead of relying on page fetch alone
- Fix: iOS Safari 'Login / data Zero KB' download -- add OnRejected HTML response to rate limiter
- Fix: mobile session logout -- ConfigureApplicationCookie with 30-day MaxAge persistent cookie
- Help: new 'Location Filtering & Bin Print' section in Inventory help article
- Help: HelpKnowledgeBase updated with bin filter and print bin details

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 15:19:11 -04:00
spouliot c5c1244177 Merge dev into master
- Inline item editing on Job/Quote/Invoice Details pages
- Live pricing summary and Job Costing card updates on save
- PatchItem legacy fallback for jobs without PricingBreakdownJson
- GetCostingBreakdown revenue from FinalPrice (not invoice total)
- Help docs: Inline Price Editing sections added to all three detail pages
- AI knowledge base updated with inline editing and costing revenue behavior
- AGENTS.md tracked; .gitignore updated for Claude Code settings and build logs
- Resolve conflict in Payment/Index.cshtml (em dash entity style)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 08:35:29 -04:00
spouliot 8c86eba4f2 Untrack .claude/settings.local.json (covered by .gitignore)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 08:34:19 -04:00
spouliot d4dddfa727 Track AGENTS.md; ignore Claude Code settings and build logs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 08:33:25 -04:00
spouliot 1bb07162cd Inline item editing on Job Details with live pricing and costing updates
- PatchItem: add case-insensitive JSON deserialization; add legacy fallback
  that computes a live breakdown from job items when PricingBreakdownJson is null
- PatchItem: return itemsSubtotal, subtotalBeforeDiscount, subtotalAfterDiscount,
  taxAmount in JSON response for immediate DOM updates
- GetCostingBreakdown: use job.FinalPrice as revenue (not invoice total) so
  costing figures reflect inline edits before an invoice exists
- Details.cshtml: add data-pb attributes to visible pricing rows; add
  job-final-price-display class to visible Total element
- Details.cshtml: wire afterSave callback to call costing.load() after each edit
- inline-item-edit.js: add afterSave hook in commit(); clean up debug logging
- Help docs: add Inline Price Editing sections to Jobs, Quotes, and Invoices
  help articles; add inline editing + job costing revenue notes to AI knowledge base

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 23:56:36 -04:00
spouliot ec925f9e08 Temp: add console.debug to updateTotals for diagnosis 2026-05-20 23:09:14 -04:00
spouliot 600196f679 Add ws://localhost:* to dev CSP connect-src for browser refresh
aspnetcore-browser-refresh.js uses plain ws:// (not wss://) so it was
blocked by the CSP which only listed wss://localhost:*. Both are needed
in dev: ws:// for the dotnet watch browser refresh socket, wss:// for
SignalR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 23:03:08 -04:00
spouliot eb13283e76 Fix inline edit not updating pricing breakdown on Job Details
Jobs/PatchItem now returns the full breakdown (itemsSubtotal,
subtotalBeforeDiscount, subtotalAfterDiscount, taxAmount) so all rows
in the pricing card update live without a page refresh.

Added data-pb attributes to the matching spans in the pricing panel.
Updated window.inlineItemEdit.totals config for jobs to map each
response key to its DOM selector.

updateTotals in inline-item-edit.js is now fully generic — cfg.totals
keys must match server response property names directly, eliminating
the old hardcoded tax/taxAmount and balance/balanceDue mismatches.
Updated Quote and Invoice configs accordingly (tax→taxAmount,
balance→balanceDue).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:58:26 -04:00
spouliot 30c644a8ec Fix service worker TypeError on localhost; inline edit config timing
sw.js: Remove fetch event interception entirely. The passthrough
e.respondWith(fetch(request)) call was throwing TypeError on localhost
HTTPS due to certificate trust differences in the SW context, causing
JS/CSS resource loads to fail. The SW exists for PWA installability
only — no interception is needed to satisfy that requirement.

inline-item-edit.js: Move window.inlineItemEdit config read inside
DOMContentLoaded so script load order vs. config assignment in
@section Scripts doesn't matter.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:48:31 -04:00
spouliot 0e480adbf6 Fix inline item editing never activating on details pages
The script IIFE was reading window.inlineItemEdit at load time, before
the inline <script> block in @section Scripts had executed to set it.
Config read moved inside DOMContentLoaded so it fires after all inline
scripts in the section have run, regardless of src vs. inline order.
cfg is now passed as a parameter to makeEditable and attachListeners
instead of being captured from the outer IIFE scope.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:37:27 -04:00
spouliot eaab0af51f Fix facility overhead missing from invoices on quote-based jobs
For quote-based jobs, invoice creation now reads fee components (oven,
facility overhead, shop supplies, rush fee) from the job's
PricingBreakdownJson snapshot rather than the source quote. The
FacilityOverheadCost column was added to Quotes in May 2026; older
quotes have 0 there even though overhead was included in their total,
causing invoices to silently drop the overhead charge. The job snapshot
is updated on every save so it always reflects the current pricing.
Tax rate and discount still come from the source quote as agreed terms.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 22:18:52 -04:00
spouliot 51a5268bc2 Fix Log Material dropdown invisible in dark mode
Replace hardcoded #fff / #f8f9fa / #dee2e6 / #e8eeff colors with Bootstrap
CSS variables so the dropdown respects the active theme.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 21:40:44 -04:00
spouliot a0bdd2b5b4 Sweep all .cshtml files for encoding corruption; add pre-commit guard
Replace all corruption variants with HTML entities across 226 view files:
- 3-char UTF-8-as-Win1252 sequences (ae-corruption)
- Standalone smart/curly quotes that break C# Razor expressions
- Partially re-corrupted variants where the 3rd byte was normalised to ASCII

tools/Fix-Encoding.ps1: re-runnable sweep; uses [char] code points so the
script itself never contains a literal non-ASCII character; supports -DryRun

.githooks/pre-commit: blocks commits containing the ae-corruption byte
signature (xc3xa2xe2x82xac); git core.hooksPath = .githooks so the
hook is repo-committed and active for all future work on this machine.

Build clean; 225 unit tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 21:37:10 -04:00
spouliot 21b39161a3 Fix encoding corruption in Bills and Expenses views
Replace literal Unicode chars (em dash, ellipsis, angle quotes, box-drawing)
with HTML entities to prevent corruption from AI tools and Windows encoding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 17:06:31 -04:00
spouliot b241daf15e Add packing slip PDF to invoice details page
Generates a no-price packing slip (items, color, qty + signature line) via
QuestPDF. New DownloadPackingSlip action reuses existing invoice data pipeline;
Packing Slip button opens inline in a new tab same as Print/PDF.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 16:52:46 -04:00
spouliot 25140554ad Merge hotfixes: Stripe receipt_email, surcharge fix, void deposit/credit, cache headers
- Remove receipt_email from Stripe PaymentIntent (any email accepted at checkout)
- Fix surcharge payment: input/validation based on total-with-fee, not base amount
- Add InvariantCulture to payment JS literals
- Fix voided invoice leaving deposits locked (re-releases for next invoice)
- Convert non-deposit payments to CRED- credits on void (preserves money trail)
- Cache-Control: no-store on authenticated pages (prevents browser cache corruption)
- Fix Edit Payment onclick encoding for apostrophes in reference/notes

Inline item editing (7fa385a) held in dev pending further testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:19:10 -04:00
spouliot 46cadea367 Add Cache-Control: no-store for authenticated pages; fix payment onclick encoding
Prevents browsers from caching authenticated pages, which resolves stale/corrupt
cache bugs (e.g. Firefox refusing to navigate to a specific invoice). Also fixes
the Edit Payment button onclick to use Json.Serialize for Reference/Notes so
apostrophes and other special characters don't break the JavaScript string literal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:18:04 -04:00
spouliot cfe937c0c3 Convert non-deposit payments to customer credits on invoice void
When voiding an invoice that has non-deposit payments (e.g. CC charges),
those payments are now converted to CRED- Deposit records so the money
trail is preserved and the credit auto-applies to the replacement invoice.
Deposits that were applied to the voided invoice are also re-released so
they can auto-apply again. Void confirmation dialog and success message
both reflect the credit amount when applicable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:18:03 -04:00
spouliot 3ad6b0d08f Fix voided invoice leaving deposits locked as applied
When an invoice was voided, deposits auto-applied at invoice creation
kept their AppliedToInvoiceId pointing at the voided invoice. The
replacement invoice lookup (AppliedToInvoiceId == null) skipped them,
so the deposit was never re-applied and the customer was charged in full.

Void now clears AppliedToInvoiceId/AppliedDate on all deposits tied to
the invoice so they're available for the next invoice, and credits the
CustomerDeposits 2300 liability account to restore the balance that was
debited when the deposits were originally applied.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:18:01 -04:00
spouliot fdac0240d1 Fix Stripe receipt_email + online payment surcharge and hardening
Remove receipt_email from PaymentIntent creation so customers can use
any email at Stripe checkout without a stored-email mismatch blocking
payment. Remove now-dead CustomerEmail from PaymentPageViewModel.

Fix surcharge payment input: amount field now represents the total the
customer pays (including fee); JS back-calculates base before sending
to server. Add InvariantCulture to numeric Razor→JS literals to prevent
comma-decimal cultures from truncating surcharge values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:17:57 -04:00
spouliot 81dc34bab4 Add Cache-Control: no-store for authenticated pages; fix payment onclick encoding
Prevents browsers from caching authenticated pages, which resolves stale/corrupt
cache bugs (e.g. Firefox refusing to navigate to a specific invoice). Also fixes
the Edit Payment button onclick to use Json.Serialize for Reference/Notes so
apostrophes and other special characters don't break the JavaScript string literal.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:11:03 -04:00
spouliot b9e9449c8b Convert non-deposit payments to customer credits on invoice void
When voiding an invoice that has non-deposit payments (e.g. CC charges),
those payments are now converted to CRED- Deposit records so the money
trail is preserved and the credit auto-applies to the replacement invoice.
Deposits that were applied to the voided invoice are also re-released so
they can auto-apply again. Void confirmation dialog and success message
both reflect the credit amount when applicable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:42:54 -04:00
spouliot fd38785942 Fix voided invoice leaving deposits locked as applied
When an invoice was voided, deposits auto-applied at invoice creation
kept their AppliedToInvoiceId pointing at the voided invoice. The
replacement invoice lookup (AppliedToInvoiceId == null) skipped them,
so the deposit was never re-applied and the customer was charged in full.

Void now clears AppliedToInvoiceId/AppliedDate on all deposits tied to
the invoice so they're available for the next invoice, and credits the
CustomerDeposits 2300 liability account to restore the balance that was
debited when the deposits were originally applied.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:27:10 -04:00
spouliot 33277de727 Payment hardening: InvariantCulture on JS literals, remove dead CustomerEmail
Razor numeric expressions emitted into JS literals (MAX_TOTAL,
SURCHARGE_VALUE) now use InvariantCulture, matching the pattern already
used on the deposit page. Without this, a server culture with comma
decimal separators would silently truncate values like 2.5% to 2.

CustomerEmail removed from PaymentPageViewModel and
DepositPaymentPageViewModel — it was populated from the DB on every
payment page load but never consumed after receipt_email was removed
from the Stripe PaymentIntent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:20:47 -04:00
spouliot 4ac62551f4 Fix online payment surcharge — input and validation based on total
The payment amount input was capped at BalanceDue (e.g. $5.00) but the
customer was being charged TotalWithSurcharge (e.g. $5.15), causing
validation to reject any attempt to pay the correct total amount.

Input now defaults to and accepts up to TotalWithSurcharge. On submit,
the JS back-calculates the base amount before sending to the server so
the server-side surcharge addition produces the same PaymentIntent total
that the Stripe Elements were initialized with — eliminating the
amount-mismatch error from Stripe on confirmation.

Also calls elements.update() when the amount changes so partial payments
don't cause an Elements/PaymentIntent amount mismatch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:16:50 -04:00
spouliot 7fa385aeb8 Inline item editing on details pages; fix Stripe receipt_email
Allow description, quantity, and price to be edited inline on Quote,
Job, and Invoice details pages without re-opening the wizard. Coating
and prep service rows remain read-only by design. Invoice editing is
gated to Draft/Sent/Overdue statuses; totals update live in the DOM.

Remove receipt_email from Stripe PaymentIntent creation so customers
can use any email they choose at checkout — Stripe validates format
and sends the receipt to whatever the customer enters in the Payment
Element, eliminating the risk of a stored email mismatch blocking a
payment from processing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 11:49:04 -04:00
spouliot 8452ea3fcd Merge remote-tracking branch 'origin/master' into dev 2026-05-20 10:37:45 -04:00
spouliot 9b34ff564e Update AI assistant and help docs for recent changes
HelpKnowledgeBase:
- Jobs list: document On Floor default view and 5 filter pills (All/On Floor/Overdue/Ready/Completed) with global counts
- Creating a job: add Oven & Batch Settings step
- Completing a job: new entry explaining Complete Job modal, per-color powder grouping, QR scan credit
- Invoice from job: note that coat colors appear in line item descriptions

Help/Jobs.cshtml:
- Overview: mention On Floor default and filter pills
- Creating a job: add oven/batch settings step in the numbered list
- New "Completing a Job" section: modal fields, powder grouping by color, QR credit, SMS behavior
- Invoice from job step: mention coat color in line item descriptions
- Add "Completing a Job" to page nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:15:58 -04:00
spouliot 24f3df1bbc Jobs list defaults to On Floor; add Completed filter pill; fix encoding bugs
- /Jobs now redirects to ?statusGroup=active so completed jobs don't clutter the default view
- Add Completed pill (filters Completed + ReadyForPickup + Delivered)
- Pill badge counts are now global DB counts, not page-local item counts
- Ready pill badge now shows ReadyForPickup-only count
- All pill links to ?statusGroup=all to bypass the redirect
- Fix double-encoded &amp; in Completed filter alert label
- Fix corrupted em dash (â€") in Customers/Details billing email fallback text

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 20:11:33 -04:00
spouliot 551116d7e5 Mobile layout fix for Job Details items; coat color on invoice line items
- Job Details: hide desktop item tables on mobile (d-none d-lg-block) so only
  the existing mobile-card layout shows on small screens — prevents 20-line rows
  on a narrow phone display
- Invoices Create (ForJob path): load job item coats and derive ColorName from
  coat colors when the item itself has no explicit color set; multiple coats join
  as 'Color1 / Color2' — lets customers distinguish repeated items (e.g. multiple
  caliper sets) on the invoice

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:34:54 -04:00
394 changed files with 146641 additions and 2556 deletions
-180
View File
@@ -1,180 +0,0 @@
{
"permissions": {
"allow": [
"Bash(dotnet build:*)",
"Bash(dir:*)",
"Bash(dotnet restore:*)",
"Bash(dotnet clean:*)",
"Bash(findstr:*)",
"Bash(dotnet ef migrations add:*)",
"Bash(dotnet ef migrations remove:*)",
"Bash(ls:*)",
"Bash(dotnet ef database update:*)",
"Bash(sqlcmd:*)",
"Bash(dotnet ef migrations script:*)",
"Bash(dotnet run:*)",
"Bash(timeout /t 15 dotnet run:*)",
"Bash(timeout /t 10 /nobreak)",
"Bash(ping:*)",
"Bash(start /B dotnet run:*)",
"Bash(test:*)",
"Bash(dotnet ef migrations:*)",
"Bash(grep:*)",
"Bash(xargs -I {} bash -c 'echo \"\"=== {} ===\"\" && head -20 {} | grep -E \"\"class|Authorize\"\"')",
"Bash(powershell:*)",
"Bash(dotnet tool install:*)",
"Bash(dotnet tool update:*)",
"Bash(xargs:*)",
"Bash(powershell -Command \"cd src\\\\PowderCoating.Web; dotnet ef migrations add UpdateQuoteForProspects --project ..\\\\PowderCoating.Infrastructure\")",
"Bash(powershell -Command:*)",
"Bash(taskkill:*)",
"Bash(netstat:*)",
"Bash(libman restore:*)",
"Bash(./start-app.bat)",
"Bash(dotnet-ef migrations add:*)",
"Bash(dotnet-ef database update:*)",
"Bash(./stop-app.bat)",
"Bash(timeout /t 3 /nobreak)",
"Bash(curl:*)",
"Bash(if [ -f \"stop-app.bat\" ])",
"Bash(then cmd.exe /c stop-app.bat)",
"Bash(else echo \"stop-app.bat not found\")",
"Bash(fi)",
"Bash(powershell.exe -Command \"Unblock-File -Path 'src/PowderCoating.Web/dotnet-tools.json'\":*)",
"Bash(powershell.exe -Command \"Get-Process | Where-Object {$_ProcessName -like ''*PowderCoating*''} | Stop-Process -Force\")",
"Bash(powershell.exe:*)",
"Bash(Select-String -Pattern \"error|Error\")",
"Bash(Select-String -NotMatch \"warning\")",
"Bash(tasklist:*)",
"Bash(dotnet add package:*)",
"Bash(start-process dotnet run:*)",
"Bash(Select-Object -ExpandProperty Id)",
"Bash(find:*)",
"Bash(cmd.exe:*)",
"Bash(dotnet ef dbcontext:*)",
"Bash(handle \"PowderCoating.Web.pdb\")",
"Bash(timeout:*)",
"Bash(del /F \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Web\\\\obj\\\\Debug\\\\net8.0\\\\PowderCoating.Web.pdb\")",
"Bash(Select-String -Pattern \"Build succeeded|Build FAILED|error\")",
"Bash(Select-Object -Last 10)",
"Bash(del \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Infrastructure\\\\Migrations\\\\20260211031319_RemovePreexistingCatalogData.cs\" \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Infrastructure\\\\Migrations\\\\20260211031319_RemovePreexistingCatalogData.Designer.cs\")",
"Bash(Select-String:*)",
"Bash(Select-Object -Last 5)",
"Bash(start-app.bat)",
"Bash(dotnet script:*)",
"Bash(dotnet list:*)",
"Bash(dotnet new:*)",
"Bash(stop-app.bat)",
"Bash(dotnet watch run:*)",
"Bash(cmd /c \"taskkill /F /PID 42108\")",
"Bash(cmd /c start-app.bat)",
"Bash(\"Y:/PCC/PowderCoatingApp/src/PowderCoating.Application/Services/PdfService.cs\":*)",
"Bash(/y/PCC/PowderCoatingApp/src/PowderCoating.Application/Services/PdfService.cs:*)",
"Bash(/tmp/remove_tempdata.pl:*)",
"Bash(chmod:*)",
"Bash(perl:*)",
"Bash(done)",
"Bash(cmd:*)",
"Bash(tail:*)",
"Bash(del:*)",
"Bash(dotnet add:*)",
"Bash(python3:*)",
"Bash(Stop-Process:*)",
"Bash(mv:*)",
"Bash(dotnet tool:*)",
"Bash(where libman:*)",
"Bash(find \"Y:/PCC/PowderCoatingApp\" -type f \\\\\\( -name \"*template*\" -o -name \"*import*\" -o -name \"*export*\" \\\\\\) -iname \"*.csv\" -o -iname \"*.xlsx\" -o -iname \"*.xls\" 2>/dev/null | head -50)",
"Bash(grep -n \"powderCostOverride\\\\|PowderCostOverride\\\\|pageMeta\\\\|quoteItems\\\\|existingItems\" \"Y:/PCC/PowderCoatingApp/src/PowderCoating.Web/Views/Quotes/Create.cshtml\" | head -20\ngrep -n \"powderCostOverride\\\\|PowderCostOverride\\\\|pageMeta\\\\|quoteItems\\\\|existingItems\" \"Y:/PCC/PowderCoatingApp/src/PowderCoating.Web/Views/Quotes/Edit.cshtml\" 2>/dev/null | head -20)",
"Bash(cat /tmp/sdktest/Program.cs | xxd | head -20)",
"Bash(cd /tmp/sdktest && rm -rf bin obj && cat Program.cs)",
"Bash(cat /tmp/sdktest/Program.cs | xxd | head -5)",
"WebSearch",
"WebFetch(domain:github.com)",
"WebFetch(domain:www.nuget.org)",
"Bash(wmic process:*)",
"Bash(grep -rn \"AI Photo\\\\|ai.*photo\\\\|photo.*quote\\\\|item-type\\\\|AiPhotoQuotes\\\\|ai_photo\" \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Web\\\\Views\\\\Quotes\\\\\" | grep -i \"photo\\\\|ai\" | head -20)",
"Bash(sed -i 's|\"aiAnalyzeUrl\": \"@Url.Action\\(\\\\\"AiAnalyzeItem\\\\\", \\\\\"Quotes\\\\\"\\)\",|\"aiAnalyzeUrl\": \"@Url.Action\\(\\\\\"AiAnalyzeItem\\\\\", \\\\\"Quotes\\\\\"\\)\",\\\\n \"aiPhotoQuotesEnabled\": @Json.Serialize\\(\\(bool\\)\\(ViewBag.AiPhotoQuotesEnabled ?? true\\)\\),|g' \\\\\n \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Web\\\\Views\\\\Quotes\\\\Edit.cshtml\" \\\\\n \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Web\\\\Views\\\\Jobs\\\\Create.cshtml\" \\\\\n \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Web\\\\Views\\\\Jobs\\\\Edit.cshtml\")",
"Bash(cp:*)",
"Bash(dotnet fsi -e \":*)",
"Read(//y/tmp/**)",
"Bash(cp /c/Users/spoul/.nuget/packages/stripe.net/50.4.1/stripe.net.50.4.1.nupkg stripe.zip)",
"Bash(unzip -o stripe.zip *.cs -d stripe_src)",
"Bash(dotnet ef:*)",
"Bash(Payment)",
"Bash(Deposit \")",
"Bash(node:*)",
"WebFetch(domain:quickbooks.intuit.com)",
"WebFetch(domain:www.saasant.com)",
"WebFetch(domain:www.liveflow.com)",
"WebFetch(domain:www.gentlefrog.com)",
"WebFetch(domain:blog.coupler.io)",
"WebFetch(domain:litextension.com)",
"WebFetch(domain:www.dancingnumbers.com)",
"WebFetch(domain:www.bizbooks.pro)",
"WebFetch(domain:support.saasant.com)",
"WebFetch(domain:support.getcount.com)",
"WebFetch(domain:planergy.com)",
"WebFetch(domain:www.wizxpert.com)",
"WebFetch(domain:www.trykeep.com)",
"WebFetch(domain:gentlefrog.com)",
"WebFetch(domain:www.syscloud.com)",
"WebFetch(domain:interopay.zendesk.com)",
"WebFetch(domain:docs.d-tools.cloud)",
"WebFetch(domain:paygration.com)",
"Bash([ ! -d \"Y:/PCC/PowderCoatingApp/src/PowderCoating.Web/Views/$controller\" ])",
"Bash(bash /tmp/check_actions.sh)",
"Bash(bash /tmp/verify_endpoints.sh)",
"Bash(bash /tmp/verify_services.sh)",
"Read(//y/PCC/Deployments/**)",
"Bash(mkdir -p \"Y:/PCC/Deployments\")",
"Bash(dotnet-script -e \"using System.Reflection; var a = Assembly.LoadFrom\\(\\\\\"Anthropic.SDK.dll\\\\\"\\); var types = a.GetTypes\\(\\).Where\\(t => t.Name.Contains\\(\\\\\"Document\\\\\"\\) || t.Name.Contains\\(\\\\\"Content\\\\\"\\)\\).Select\\(t => t.Name\\).OrderBy\\(n => n\\); foreach\\(var t in types\\) Console.WriteLine\\(t\\);\")",
"Bash(sort -t'-' -k3 -r)",
"Bash(wsl grep:*)",
"Bash(find src:*)",
"Bash(dotnet csharp *)",
"Read(//c/Users/spoul/.nuget/packages/stripe.net/50.4.1/lib/netstandard2.0/**)",
"Bash(dotnet publish *)",
"Bash(Compress-Archive -Path * -DestinationPath \"..\\\\deploy.zip\" -Force)",
"Bash(az webapp *)",
"Read(//y/PCC/**)",
"Bash(Get-Date -Format 'yyyyMMdd_HHmmss')",
"PowerShell(Get-Content *)",
"PowerShell(dotnet build *)",
"PowerShell(New-Item *)",
"PowerShell(& \"Y:\\\\PCC\\\\PowderCoatingApp\\\\scripts\\\\generate-migration-script.ps1\")",
"PowerShell(if \\(Test-Path \"Y:\\\\pcc\\\\deployment\\\\migrations.sql\"\\) { $f = Get-Item \"Y:\\\\pcc\\\\deployment\\\\migrations.sql\"; Write-Host \"File exists: $\\($f.Length\\) bytes\" } else { Write-Host \"File not created\" })",
"Bash(git add *)",
"Bash(git commit -m ' *)",
"Bash(git push *)",
"Bash(git commit *)",
"Bash(git checkout *)",
"Bash(git merge *)",
"Bash(dotnet package *)",
"Bash(dotnet test *)",
"Bash(git rm *)",
"Bash(git stash *)",
"Bash(dotnet ef *)",
"Bash(sqlcmd -S \".\\\\SQLEXPRESS\" -d PowderCoatingDb -Q \"SELECT Id, DisplayName, IsCoating, IsActive FROM InventoryCategoryLookups ORDER BY DisplayOrder\" -W)",
"Skill(schedule)",
"Bash(git -C \"//192.168.0.37/SCPSoftware/tmp/PowderCoatingApp-dev-perf\" log --oneline -10)",
"Bash(git -C \"//192.168.0.37/SCPSoftware/tmp/PowderCoatingApp-dev-perf\" status --short)",
"Bash(git *)",
"Bash(get-childitem -Recurse -Filter \"QuotesController.cs\")",
"Bash(Select-Object -ExpandProperty FullName)",
"Bash(dotnet user-secrets *)",
"Bash(Get-ChildItem -Path \"Y:\\\\PCC\\\\PowderCoatingApp\" -Directory)",
"Bash(Select-Object Name)",
"Bash(Get-Content *)",
"Bash(python -c \"import json; data=json.load\\(open\\('prismatic_powders.json','r',encoding='utf-8'\\)\\); print\\(f'Total records: {len\\(data\\)}'\\); print\\('First record:'\\); print\\(json.dumps\\(data[0], indent=2\\)\\)\")",
"Bash(python -c \"import json; data=json.load\\(open\\('prismatic_powders.json','r',encoding='utf-8'\\)\\); keys=list\\(data.keys\\(\\)\\); print\\('Top-level keys:', keys[:10]\\); first=data[keys[0]]; print\\('First record key:', keys[0]\\); print\\(json.dumps\\(first, indent=2\\)\\)\")",
"PowerShell(Get-ChildItem *)",
"PowerShell(Select-String *)",
"Bash(Select-Object -First 20)",
"PowerShell(node -e \"require\\('fs'\\).existsSync\\(require\\('path'\\).join\\(process.cwd\\(\\), 'node_modules', 'sharp'\\)\\) ? console.log\\('sharp ok'\\) : console.log\\('no sharp'\\)\")",
"WebFetch(domain:www.powdercoatinglogix.com)",
"PowerShell($bytes = [System.IO.File]::ReadAllBytes\\('src/PowderCoating.Web/Views/Jobs/Details.cshtml'\\); $text = [System.Text.Encoding]::UTF8.GetString\\($bytes\\); $idx = $text.IndexOf\\('hasPowderData'\\); $snippet = $text.Substring\\($idx - 20, 250\\); [System.Text.Encoding]::Unicode.GetBytes\\($snippet\\) | Format-Hex | Select-Object -First 30)",
"PowerShell($dll = \"C:\\\\Users\\\\spoul\\\\.nuget\\\\packages\\\\questpdf\\\\2024.12.3\\\\lib\\\\net6.0\\\\QuestPDF.dll\"; $asm = [Reflection.Assembly]::LoadFile\\($dll\\); $asm.GetTypes\\(\\) | Where-Object { $_.Name -eq \"ContainerExtensions\" } | ForEach-Object { $_.GetMethods\\(\\) | Where-Object { $_.Name -match \"Canvas|Rotat|Layer\" } | Select-Object Name } | Sort-Object Name -Unique)",
"PowerShell(Get-ChildItem \"C:\\\\Users\\\\spoul\\\\.nuget\\\\packages\\\\\" -ErrorAction SilentlyContinue | Where-Object { $_.Name -match \"quest|skia\" } | Select-Object Name)"
]
}
}
+27
View File
@@ -0,0 +1,27 @@
#!/bin/sh
# Pre-commit hook: block commits containing corrupted Unicode in .cshtml files.
#
# All corruption variants start with the UTF-8 byte sequence for a-circumflex
# followed by euro-sign (bytes C3 A2 E2 82 AC), which is the first two chars
# of every known corruption pattern. Grep for that byte sequence in staged files.
STAGED=$(git diff --cached --name-only | grep '\.cshtml$')
if [ -z "$STAGED" ]; then
exit 0
fi
# $'\xc3\xa2\xe2\x82\xac' = UTF-8 bytes for a-circumflex + euro-sign
CORRUPT=$(echo "$STAGED" | xargs grep -l $'\xc3\xa2\xe2\x82\xac' 2>/dev/null)
if [ -n "$CORRUPT" ]; then
echo ""
echo "ERROR: Corrupted Unicode characters detected in staged .cshtml files:"
echo "$CORRUPT" | sed 's/^/ /'
echo ""
echo "Fix by running: .\\tools\\Fix-Encoding.ps1"
echo "Then re-stage the files and commit again."
echo ""
exit 1
fi
exit 0
+5
View File
@@ -1,6 +1,11 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
# Claude Code tool settings and build logs
.claude/settings.local.json
.claude/settings.json
BuildLog*.txt
# User-specific files
*.rsuser
*.suo
+571
View File
@@ -0,0 +1,571 @@
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## Project Overview
A production-ready ASP.NET Core 8.0 MVC application for managing powder coating business operations. The application implements Clean Architecture with six projects across three layers (Domain, Application, Infrastructure) plus two presentation layers (Web MVC, RESTful API).
## Essential Commands
### Building and Running
```bash
# Build entire solution
dotnet build
# Run web application (MVC)
cd src/PowderCoating.Web
dotnet run
# Access at: https://localhost:58461
# Run web with auto-reload
dotnet watch run
# Run API
cd src/PowderCoating.Api
dotnet run
# Swagger UI at root URL
# Run tests
dotnet test # All tests
dotnet test tests/PowderCoating.UnitTests # Unit tests only
dotnet test tests/PowderCoating.IntegrationTests # Integration tests only
```
### Database Operations
```bash
# All EF commands run from Web project directory
cd src/PowderCoating.Web
# Create migration (must specify Infrastructure project)
dotnet ef migrations add MigrationName --project ../PowderCoating.Infrastructure
# Apply migrations
dotnet ef database update --project ../PowderCoating.Infrastructure
# Reset database (WARNING: deletes all data)
dotnet ef database drop --project ../PowderCoating.Infrastructure
dotnet ef database update --project ../PowderCoating.Infrastructure
# List migrations
dotnet ef migrations list --project ../PowderCoating.Infrastructure
# Remove last migration (if not applied)
dotnet ef migrations remove --project ../PowderCoating.Infrastructure
```
### Default Credentials
```
SuperAdmin (break glass): artemis@powdercoatinglogix.com / SuperAdmin123!
SuperAdmin (seed): superadmin@powdercoatinglogix.com / SuperAdmin123!
SuperAdmin (seed): spouliot@powdercoatinglogix.com / SuperAdmin123!
Company Admin (seed): demo@powdercoatinglogix.com / CompanyAdmin123!
```
## Architecture Overview
### Clean Architecture Layers
**Domain Layer (PowderCoating.Core)**
- Contains business entities, enums, and repository interfaces
- `BaseEntity` provides common properties for all entities (Id, CompanyId, CreatedAt, UpdatedAt, IsDeleted, audit fields)
- All entities inherit from BaseEntity and support soft delete
- No dependencies on other projects
**Application Layer (PowderCoating.Application)**
- DTOs organized by domain (Customer, Job, Equipment, Inventory, Maintenance)
- AutoMapper profiles with reverse mappings
- Service interfaces (IFileService, etc.)
- No UI or infrastructure dependencies
**Infrastructure Layer (PowderCoating.Infrastructure)**
- `ApplicationDbContext` with global query filters for soft deletes and multi-tenancy
- Generic `Repository<T>` implementing `IRepository<T>`
- `UnitOfWork` implementing `IUnitOfWork` with lazy-loaded repositories
- Seed data is triggered **manually** via Platform Management → Seed Data (not automatic on startup)
**Presentation Layers**
- `PowderCoating.Web`: MVC application with Razor views, Bootstrap 5 UI
- `PowderCoating.Api`: RESTful API with JWT authentication, Swagger documentation
### Key Design Patterns
**Repository Pattern**
- Generic `Repository<T>` in Infrastructure
- All CRUD operations, search, pagination, eager loading support
- Soft delete with `SoftDeleteAsync()` method
**Unit of Work Pattern**
- Coordinates multiple repositories
- Transaction support: `BeginTransactionAsync()`, `CommitTransactionAsync()`, `RollbackTransactionAsync()`
- Lazy instantiation of repositories
- `SaveChangesAsync()` or `CompleteAsync()` to persist changes
**Dependency Injection**
- All dependencies registered in `Program.cs`
- Controllers inject `IUnitOfWork` and `IMapper`
- Services are scoped to request lifetime
**Global Query Filters**
- Soft deletes: All queries automatically filter `IsDeleted == false`
- Multi-tenancy: Non-SuperAdmin users see only their company data
- Bypass with `ignoreQueryFilters: true` parameter in repository methods
### Multi-Tenancy Implementation
- `CompanyId` foreign key on all business entities
- `ITenantContext` injected into DbContext resolves current company
- SuperAdmin role can view all companies
- Global query filters enforce company isolation at database level
- Users have both system role (SuperAdmin) and company role (CompanyAdmin, Manager, Worker, Viewer)
## Data Access Rules (ENFORCE THESE)
> **`ApplicationDbContext` is NEVER injected into a controller.**
> All data access in controllers goes through `IUnitOfWork`. No exceptions outside the list below.
> **This rule is enforced at startup:** `EnforceDataAccessArchitecture()` in `Program.cs` scans all
> controllers at boot and throws if any non-exempt controller injects `ApplicationDbContext`.
> Full rationale and permanent exceptions list: `docs/DATA_ACCESS_ARCHITECTURE.md`
### Three tiers — use the right one:
**Tier 1 — Simple CRUD**`IUnitOfWork.EntityName` (generic `IRepository<T>`)
```csharp
var items = await _unitOfWork.CatalogItems.GetAllAsync();
await _unitOfWork.Announcements.AddAsync(entity);
await _unitOfWork.CompleteAsync();
```
**Tier 2 — Complex domain queries** → typed repositories on `IUnitOfWork`
```csharp
// Include chains and domain-specific queries belong in the repository, not the controller
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id);
var invoice = await _unitOfWork.Invoices.LoadForViewAsync(id);
var quote = await _unitOfWork.Quotes.GetByApprovalTokenAsync(token);
```
Typed repositories: `IJobRepository`, `IInvoiceRepository`, `IQuoteRepository`,
`ICustomerRepository`, `IBillRepository`, `IPurchaseOrderRepository`
— defined in `Core/Interfaces/Repositories/`, implemented in `Infrastructure/Repositories/`
**Tier 3 — Aggregate/reporting queries** → injected read services
```csharp
// P&L, AR aging, cycle time, powder usage — shaped DTOs, never tracked entities
var aging = await _financialReports.GetArAgingAsync(companyId);
```
Services: `IFinancialReportService`, `IOperationalReportService`
— defined in `Core/Interfaces/Services/`, implemented in `Infrastructure/Services/`
### Permanent exceptions (ApplicationDbContext allowed — intentional, documented):
`StripeWebhookController`, `WebhooksController`, `PaymentController`, `RegistrationController`,
`DataExportController`, `AccountDataExportController`, `DataPurgeController`,
`SystemInfoController`, `SystemLogsController`, `CompanyHealthController`
If you think you need a new exception, you almost certainly don't. Check the spec first.
---
## Data Access Patterns
### Common Controller Pattern
```csharp
public class ExampleController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
public ExampleController(IUnitOfWork unitOfWork, IMapper mapper)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
}
public async Task<IActionResult> Index()
{
var entities = await _unitOfWork.Examples.GetAllAsync();
var dtos = _mapper.Map<List<ExampleDto>>(entities);
return View(dtos);
}
[HttpPost]
public async Task<IActionResult> Create(CreateExampleDto dto)
{
var entity = _mapper.Map<Example>(dto);
await _unitOfWork.Examples.AddAsync(entity);
await _unitOfWork.CompleteAsync();
return RedirectToAction(nameof(Index));
}
public async Task<IActionResult> Delete(int id)
{
await _unitOfWork.Examples.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
return RedirectToAction(nameof(Index));
}
}
```
### Using Unit of Work Repositories
All entity repositories are available via `IUnitOfWork` properties:
- `_unitOfWork.Customers`
- `_unitOfWork.Jobs`
- `_unitOfWork.JobItems`
- `_unitOfWork.Quotes`
- `_unitOfWork.InventoryItems`
- `_unitOfWork.Equipment`
- `_unitOfWork.MaintenanceRecords`
- Plus additional entities (Suppliers, JobPhotos, JobNotes, etc.)
### Eager Loading Related Data
```csharp
// Load customer with related data
var customer = await _unitOfWork.Customers.GetByIdAsync(
id,
c => c.Jobs,
c => c.Quotes,
c => c.PricingTier
);
// Find with predicate and includes
var activeJobs = await _unitOfWork.Jobs.FindAsync(
j => j.Status != JobStatus.Completed,
j => j.Customer,
j => j.JobItems
);
```
### Pagination
```csharp
var pagedJobs = await _unitOfWork.Jobs.GetPagedAsync(
pageNumber: 1,
pageSize: 25,
j => j.Status == JobStatus.InPreparation,
j => j.Customer
);
```
## Important Domain Concepts
### Job Lifecycle
Jobs progress through 16 statuses:
1. **Pending** → Initial state
2. **Quoted** → Quote generated
3. **Approved** → Customer approved
4. **InPreparation** → Job prep started
5. **Sandblasting** → Surface prep
6. **MaskingTaping** → Masking areas
7. **Cleaning** → Pre-coat cleaning
8. **InOven** → Pre-heating
9. **Coating** → Applying powder
10. **Curing** → Heat curing
11. **QualityCheck** → Inspection
12. **Completed** → Work finished
13. **ReadyForPickup** → Awaiting customer
14. **Delivered** → Job delivered
15. **OnHold** → Paused
16. **Cancelled** → Cancelled
**Job Priorities**: Low, Normal, High, Urgent, Rush (color-coded in UI)
### Customer Types
- **Commercial**: B2B customers with pricing tiers, credit limits
- **Non-Commercial**: Individual customers, typically simpler pricing
### Inventory Management
**Transaction Types**: Purchase, Sale, Adjustment, Transfer, Return, Waste, Initial
- All transactions tracked in `InventoryTransaction` entity
- Reorder points trigger low-stock alerts
### Equipment & Maintenance
**Equipment Status**: Operational, NeedsMaintenance, UnderMaintenance, OutOfService, Retired
**Maintenance Priority**: Low, Normal, High, Critical
**Maintenance Status**: Scheduled, InProgress, Completed, Cancelled, Overdue
## Configuration Files
### Web Application (src/PowderCoating.Web/appsettings.json)
```json
{
"ConnectionStrings": {
"DefaultConnection": "Server=.\\SQLEXPRESS;Database=PowderCoatingDb;Trusted_Connection=true;MultipleActiveResultSets=true;TrustServerCertificate=true"
},
"AppSettings": {
"CompanyName": "Powder Coating Logix",
"DefaultQuoteValidityDays": 30,
"DefaultPaymentTerms": "Net 30",
"TaxRate": 0.0,
"Currency": "USD",
"TrialPeriodDays": 7,
"QuoteApprovalTokenDays": 30
},
"AI": {
"Anthropic": {
"ApiKey": "your-anthropic-api-key-here"
}
},
"SendGrid": { ... },
"Stripe": { ... },
"Storage": { ... }
}
```
**AI uses Anthropic Codex Sonnet 4.6** (`Codex-sonnet-4-6`) — NOT OpenAI. The `AI:Anthropic:ApiKey` config key is what the AI photo quoting and AI scheduling services read.
### API (src/PowderCoating.Api/appsettings.json)
```json
{
"JwtSettings": {
"SecretKey": "CHANGE-THIS-TO-YOUR-OWN-SECRET-KEY-AT-LEAST-32-CHARACTERS",
"Issuer": "PowderCoatingAPI",
"Audience": "PowderCoatingMobileApp",
"ExpirationMinutes": 1440
}
}
```
### Launch Settings (src/PowderCoating.Web/Properties/launchSettings.json)
Default ports:
- HTTPS: 58461
- HTTP: 58462
## Authentication & Authorization
### System Roles
- **SuperAdmin**: Platform-wide access, sees all companies and deleted records
- **Administrator**: Company admin
- **Manager**: Operations management
- **Employee**: Create/edit jobs and quotes
- **ShopFloor**: Update job status
- **ReadOnly**: View-only access
### Custom Authorization Policies
Defined in `PowderCoating.Shared/Constants/AppConstants.cs`:
- `RequireAdministratorRole`
- `CanManageJobs`
- `CanManageInventory`
- `CanManageUsers`
- `CanViewData`
Apply with `[Authorize(Policy = "PolicyName")]` on controllers/actions.
### JWT Authentication (API Only)
API uses JWT Bearer tokens. Web uses cookie-based Identity authentication.
## AutoMapper Configuration
AutoMapper is registered as singleton in `Program.cs`:
```csharp
builder.Services.AddSingleton(provider => new MapperConfiguration(cfg =>
{
cfg.AddMaps(typeof(ApplicationAssemblyMarker).Assembly);
}).CreateMapper());
```
All profiles in `Application/Mappings/` are auto-discovered. Profiles include reverse mappings for entity ↔ DTO conversion.
## Logging
Serilog configured to write:
- Console (structured logs)
- File: `logs/powdercoating-{Date}.txt` (rolling daily)
Access via constructor injection:
```csharp
private readonly ILogger<ExampleController> _logger;
```
## Common Development Tasks
### Adding a New Entity
1. Create entity class in `Core/Entities/` inheriting from `BaseEntity`
2. Add DbSet to `ApplicationDbContext`
3. Register repository property in `IUnitOfWork` interface
4. Add lazy-loaded property in `UnitOfWork` implementation
5. Create migration: `dotnet ef migrations add AddEntityName --project ../PowderCoating.Infrastructure`
6. Apply migration: `dotnet ef database update --project ../PowderCoating.Infrastructure`
### Adding a New Controller
1. Create DTOs in `Application/DTOs/`
2. Create AutoMapper profile in `Application/Mappings/`
3. Create controller in `Web/Controllers/`
4. Create views in `Web/Views/[ControllerName]/`
5. Add navigation link in `Views/Shared/_Layout.cshtml`
### Working with Soft Deletes
```csharp
// Soft delete (sets IsDeleted = true)
await _unitOfWork.Customers.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
// Physical delete (use sparingly)
await _unitOfWork.Customers.DeleteAsync(entity);
await _unitOfWork.CompleteAsync();
// Include deleted records in query
var allCustomers = await _unitOfWork.Customers.GetAllAsync(ignoreQueryFilters: true);
```
### Bypassing Multi-Tenancy Filters
Only for SuperAdmin users:
```csharp
// See all companies' data
var allJobs = await _unitOfWork.Jobs.GetAllAsync(ignoreQueryFilters: true);
```
## Implemented Modules
All modules below are fully implemented with controllers, views, and migrations applied.
### Operations
- **Jobs** — full lifecycle (16 statuses), worker assignment, time entries, rework tracking, shop access codes, job templates
- **Quotes** — multi-item pricing engine, AI Photo Quoting (Anthropic Codex Sonnet 4.6), quote-to-job conversion, customer approval portal, online payment
- **Invoices** — create from job, partial payments, voids, PDF download, email send; 1:1 Job→Invoice enforced by unique index
- **Deposits** — record against customer/job/quote; auto-applied to invoices on creation; receipt PDF via QuestPDF
- **Customers** — commercial and non-commercial types, pricing tiers, tax exempt flag + certificate upload, credit limits
- **Oven Scheduler** — batch jobs into named ovens, capacity planning, suggested batches
### Inventory & Purchasing
- **Inventory** — stock tracking, transactions, reorder alerts, powder coverage/efficiency fields
- **Vendors** — supplier management, payment terms, linked to inventory items
- **Purchase Orders** — create/submit/receive POs, convert to vendor bills
- **Accounts Payable** — vendor bills, AP ledger, payment tracking
### Shop Management
- **Shop Workers** — roles (Coater, Sandblaster, etc.), assignment to jobs and maintenance tasks
- **Equipment & Maintenance** — equipment status lifecycle, scheduled/completed maintenance records
- **Catalog Items** — pre-priced service catalog with default prices
- **Pricing Tiers** — customer discount tiers; use `CompanyAdminOnly` policy (not `RequireAdministratorRole`)
### Billing & Payments
- **Stripe** — subscription plans, checkout sessions, customer portal, webhooks (`/stripe/webhook`)
- **Stripe Connect** — embedded payments, OAuth flow for tenant onboarding
- **Twilio SMS** — `ISmsService` fully implemented; webhook at `POST /Webhooks/TwilioSms`
### Platform (SuperAdmin only)
- **Platform Users** — create/manage SuperAdmin accounts
- **Companies** — view/manage all tenant companies
- **Seed Data** — manual seeding via Platform Management UI (not automatic)
- **Subscription Plans** — `SubscriptionPlanConfig` controls per-plan limits and pricing
### Other
- **Help Center** — 14 fully-written articles at `Views/Help/`
- **Setup Wizard** — 10-step onboarding wizard at `SetupWizardController`
- **Reports** — 24 report actions including P&L, AR Aging, Powder Usage, Job Cycle Time, PDF exports
- **Gift Certificates** — issue, redeem, track balance
- **Announcements** — platform-wide announcements to tenants
### Key Pricing Rules
- Custom powder (no inventory item + `PowderToOrder` > 0): charge for the **full ordered quantity**, not just calculated usage
- In-stock inventory powder: charge for calculated usage only (surface area × lbs/sqft × unit cost)
- Tax exempt customers (`Customer.IsTaxExempt`): `TaxPercent` defaults to 0 on quote and invoice create; customer dropdown marks exempt customers with ★
### Pricing Routing Flags — Must Stay In Sync Across All Three Layers
`PricingCalculationService.CalculateQuoteItemPriceAsync` routes each item to the correct pricing path using boolean flags. **These flags MUST exist identically on `QuoteItem`, `JobItem`, and `CreateQuoteItemDto`, AND be mapped in all three `JobItemAssemblyService.CreateJobItem` overloads.**
| Flag | Effect if missing on JobItem |
|------|------------------------------|
| `IsAiItem` | Job repriced as calculated item; oven cost double-charged on every save |
| `IsGenericItem` | ManualUnitPrice ignored; price recalculated from surface area |
| `IsLaborItem` | Item repriced at surface-area rate instead of hours × labor rate |
| `IsSalesItem` | ManualUnitPrice ignored; item repriced using coat/surface math |
**Checklist when adding a new pricing routing flag:**
1. Add the property to `QuoteItem` (Core/Entities)
2. Add the property to `JobItem` (Core/Entities)
3. Add it to `CreateQuoteItemDto` (Application/DTOs)
4. Add it to `JobItemSeed` (private class in JobItemAssemblyService)
5. Map it in all three `JobItemAssemblyService.CreateJobItem` overloads
6. Include it in every `existingItemsData` JSON block in job views (`Edit.cshtml`, `EditItems.cshtml`) and in all job controller actions that build `CreateQuoteItemDto` from a `JobItem`
7. Add a migration if the field is new on a persisted entity
8. The structural test `PricingRoutingFlags_ExistOnBothQuoteItemAndJobItem` in `JobItemAssemblyServiceTests` will fail until steps 13 are done — this is intentional
### Branding
- Application name: **Powder Coating Logix**
- PCL logo: `wwwroot/images/pcl-logo.png` — used in sidebar header (when no tenant logo), login/register pages, sidebar footer
- Sidebar footer always shows PCL logo linking to `http://www.powdercoatinglogix.com`
- Tenant companies can upload their own logo (stored in Azure Blob `companylogos` container); it replaces the PCL logo in the sidebar header
## Known Issues
- Entity Framework warnings about global query filters on related entities (non-critical, informational only)
## File Upload Configuration
Limits defined in `AppConstants.cs`:
- Max file size: 10 MB
- Allowed extensions: jpg, jpeg, png, gif, pdf, doc, docx, xls, xlsx
## Testing Strategy
- **Unit Tests**: Test business logic in isolation
- **Integration Tests**: Test full request pipeline with test database
- Use xUnit framework
- Mock `IUnitOfWork` in unit tests
## Extending the System
### Adding AI Features
AI uses Anthropic Codex Sonnet 4.6 via `IAiQuoteService`. Configure the key under `AI:Anthropic:ApiKey` in `appsettings.json`.
1. Create service interface in `Application/Interfaces/`
2. Implement in `Infrastructure/Services/` calling the Anthropic client
3. Inject into controllers via DI
### SignalR Hubs
Two hubs are already implemented and mapped in `Program.cs`:
- `NotificationHub``/hubs/notifications` (company-scoped push notifications)
- `ShopHub``/hubs/shop` (real-time shop floor updates)
To add a new hub:
1. Create hub class in `Web/Hubs/`
2. Map hub in `Program.cs`: `app.MapHub<YourHub>("/hubpath")`
3. Use JavaScript client in views to connect
### Adding API Endpoints
1. Create controller in `Api/Controllers/` with `[ApiController]` attribute
2. Return `ActionResult<T>` types
3. Use `[Authorize]` for protected endpoints
4. Document with XML comments for Swagger
## Project Dependencies
Key NuGet packages:
- **AutoMapper 16.0.0**: Entity-to-DTO mapping
- **Entity Framework Core 8.0.11**: ORM and database access
- **Serilog.AspNetCore 8.0.3**: Structured logging
- **Microsoft.AspNetCore.Identity.UI 8.0.11**: Authentication
- **Swashbuckle.AspNetCore 7.2.0**: API documentation (API project)
## Security Considerations
- Password requirements: 8+ chars, uppercase, lowercase, digit
- HTTPS enforced in production
- SQL injection prevented by EF Core parameterization
- XSS protection via Razor encoding
- CSRF tokens on all forms (automatic with ASP.NET Core)
- Sensitive settings (connection strings, API keys) should use User Secrets in development and Azure Key Vault in production
## Active design work
A visual redesign is in progress. If the user asks about UI changes, dashboard/jobs/board styling, or the new design tokens, read `design_handoff_pcl_redesign/README.md` and follow `design_handoff_pcl_redesign/AGENTS.md` for that work.
+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>
@@ -44,6 +44,20 @@ namespace PowderCoating.Application.DTOs.Company
/// <summary>True when the company has an accepted agreement for the current SmsTermsVersion.</summary>
public bool HasCurrentSmsAgreement { get; set; }
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>
@@ -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; }
}
@@ -20,7 +20,7 @@ public class EquipmentDto
public string StatusDisplay { get; set; } = string.Empty;
public string? Location { get; set; }
public int RecommendedMaintenanceIntervalDays { get; set; }
public int? RecommendedMaintenanceIntervalDays { get; set; }
public DateTime? LastMaintenanceDate { get; set; }
public DateTime? NextScheduledMaintenance { 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")]
[Display(Name = "Recommended Maintenance Interval (Days)")]
public int RecommendedMaintenanceIntervalDays { get; set; }
public int? RecommendedMaintenanceIntervalDays { get; set; }
[Display(Name = "Last Maintenance Date")]
public DateTime? LastMaintenanceDate { get; set; }
@@ -21,6 +21,11 @@ public class JobImportDto
[Name("CustomerName")]
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")]
public string Status { get; set; } = "Pending";
@@ -68,6 +68,7 @@ public class InventoryListDto
public string? CategoryName { get; set; }
public string Category { get; set; } = string.Empty; // Legacy field
public string? ColorName { get; set; }
public string? Location { get; set; }
public decimal QuantityOnHand { get; set; }
public string UnitOfMeasure { get; set; } = "lbs";
public decimal ReorderPoint { get; set; }
@@ -33,6 +33,10 @@ public class InvoiceDto
public string? CustomerEmail { get; set; }
public string? CustomerPhone { get; set; }
public string? CustomerMobilePhone { get; set; }
public string? CustomerAddress { get; set; }
public string? CustomerCity { get; set; }
public string? CustomerState { get; set; }
public string? CustomerZipCode { get; set; }
public bool CustomerNotifyByEmail { get; set; }
public bool CustomerNotifyBySms { get; set; }
public string? PreparedById { get; set; }
@@ -325,7 +325,11 @@ public class JobItemDto
public bool IsGenericItem { get; set; }
public bool IsLaborItem { get; set; }
public bool IsSalesItem { get; set; }
public bool IsAiItem { 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<JobItemPrepServiceDto> PrepServices { get; set; } = new();
}
@@ -486,6 +490,7 @@ public class ReworkRecordDto
public decimal ActualReworkCost { get; set; }
public bool IsBillableToCustomer { get; set; }
public string? BillingNotes { get; set; }
public PowderCoating.Core.Enums.ReworkPricingType? ReworkPricingType { get; set; }
public PowderCoating.Core.Enums.ReworkStatus Status { get; set; }
public string StatusDisplay { get; set; } = string.Empty;
@@ -511,6 +516,11 @@ public class CreateReworkRecordDto
public decimal EstimatedReworkCost { get; set; }
public bool IsBillableToCustomer { 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
@@ -475,6 +475,11 @@ public class QuoteItemDto
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
public decimal ItemMaterialCost { get; set; }
public decimal ItemLaborCost { get; set; }
@@ -559,6 +564,11 @@ public class CreateQuoteItemDto
// ID of the AiItemPrediction record captured at analysis time (null for non-AI items)
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 +884,9 @@ public class QuotePricingResult
// Per-item results (same order as input items)
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 AllowAiCatalogPriceCheck { get; set; }
public bool AllowSms { get; set; }
public bool AllowCustomFormulas { get; set; }
public bool IsActive { get; set; }
public int SortOrder { get; set; }
}
@@ -74,6 +75,7 @@ public class UpdateSubscriptionPlanConfigDto
public bool AllowAiInventoryAssist { get; set; }
public bool AllowAiCatalogPriceCheck { get; set; }
public bool AllowSms { get; set; }
public bool AllowCustomFormulas { 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")]
public int? DefaultExpenseAccountId { get; set; }
public List<int> CategoryIds { get; set; } = new();
}
// ============================================================================
@@ -209,4 +211,6 @@ public class UpdateVendorDto
[Display(Name = "Default Expense Account")]
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 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);
}
@@ -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);
}
@@ -25,6 +25,12 @@ public interface IPdfService
CompanyInfoDto companyInfo,
QuoteTemplateSettingsDto? template = null);
Task<byte[]> GeneratePackingSlipPdfAsync(
InvoiceDto invoiceDto,
byte[]? companyLogo,
string? companyLogoContentType,
CompanyInfoDto companyInfo);
Task<byte[]> GeneratePurchaseOrderPdfAsync(
PurchaseOrderDto po,
byte[]? companyLogo,
@@ -13,4 +13,12 @@ public interface IQuotePricingAssemblyService
int companyId,
decimal? ovenRateOverride,
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);
}
@@ -20,7 +20,6 @@ public interface IStripeConnectService
decimal invoiceTotal,
decimal surchargeAmount,
string currency,
string customerEmail,
string invoiceNumber,
int invoiceId);
@@ -33,7 +32,6 @@ public interface IStripeConnectService
decimal depositAmount,
decimal surchargeAmount,
string currency,
string customerEmail,
string quoteNumber,
int quoteId);
}
@@ -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;
}
}
}
@@ -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; }
}
}
@@ -29,6 +29,10 @@ public class InvoiceProfile : Profile
: null))
.ForMember(d => d.CustomerPhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.Phone : null))
.ForMember(d => d.CustomerMobilePhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.MobilePhone : null))
.ForMember(d => d.CustomerAddress, o => o.MapFrom(s => s.Customer != null ? s.Customer.Address : null))
.ForMember(d => d.CustomerCity, o => o.MapFrom(s => s.Customer != null ? s.Customer.City : null))
.ForMember(d => d.CustomerState, o => o.MapFrom(s => s.Customer != null ? s.Customer.State : null))
.ForMember(d => d.CustomerZipCode, o => o.MapFrom(s => s.Customer != null ? s.Customer.ZipCode : null))
.ForMember(d => d.CustomerNotifyByEmail, o => o.MapFrom(s => s.Customer == null || s.Customer.NotifyByEmail))
.ForMember(d => d.CustomerNotifyBySms, o => o.MapFrom(s => s.Customer != null && s.Customer.NotifyBySms))
.ForMember(d => d.PreparedByName, o => o.MapFrom(s => s.PreparedBy != null
@@ -196,7 +196,9 @@ public class JobProfile : Profile
.ForMember(dest => dest.JobItemDescription,
opt => opt.MapFrom(src => src.JobItem != null ? src.JobItem.Description : null))
.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)
// (IsReworkJob and OriginalJobId map by convention; OriginalJobNumber needs explicit map — handled in controller)
@@ -159,6 +159,7 @@ public class QuoteProfile : Profile
.ReverseMap()
.ForMember(dest => dest.Quote, 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.PrepServices, 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.PrepServices, opt => opt.Ignore()) // Mapped separately
.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.CreatedAt, 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());
// 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>()
.ForMember(dest => dest.Coats, opt => opt.MapFrom(src => src.Coats))
.ForMember(dest => dest.PrepServices, opt => opt.MapFrom(src => src.PrepServices));
// ============================================================================
@@ -16,6 +16,7 @@
<PackageReference Include="FluentValidation" Version="11.11.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.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" />
@@ -53,7 +53,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
IncludePrepCost = source.IncludePrepCost,
Complexity = source.Complexity,
AiTags = source.AiTags,
AiPredictionId = source.AiPredictionId
AiPredictionId = source.AiPredictionId,
IsCustomFormulaItem = source.IsCustomFormulaItem,
CustomItemTemplateId = source.CustomItemTemplateId,
FormulaFieldValuesJson = source.FormulaFieldValuesJson
},
jobId,
companyId,
@@ -157,7 +160,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
IncludePrepCost = source.IncludePrepCost,
Complexity = source.Complexity,
AiTags = source.AiTags,
AiPredictionId = source.AiPredictionId
AiPredictionId = source.AiPredictionId,
IsCustomFormulaItem = source.IsCustomFormulaItem,
CustomItemTemplateId = source.CustomItemTemplateId,
FormulaFieldValuesJson = source.FormulaFieldValuesJson
},
jobId,
companyId,
@@ -259,7 +265,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
IncludePrepCost = source.IncludePrepCost,
Complexity = source.Complexity,
AiTags = source.AiTags,
AiPredictionId = source.AiPredictionId
AiPredictionId = source.AiPredictionId,
IsCustomFormulaItem = source.IsCustomFormulaItem,
CustomItemTemplateId = source.CustomItemTemplateId,
FormulaFieldValuesJson = source.FormulaFieldValuesJson
},
jobId,
companyId,
@@ -353,6 +362,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
Complexity = seed.Complexity,
AiTags = seed.AiTags,
AiPredictionId = seed.AiPredictionId,
IsCustomFormulaItem = seed.IsCustomFormulaItem,
CustomItemTemplateId = seed.CustomItemTemplateId,
FormulaFieldValuesJson = seed.FormulaFieldValuesJson,
CompanyId = companyId,
CreatedAt = createdAtUtc
};
@@ -480,6 +492,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
public string? Complexity { get; init; }
public string? AiTags { 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>
@@ -2753,4 +2753,187 @@ public class PdfService : IPdfService
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
}
}
// -----------------------------------------------------------------------
// Packing Slip
// -----------------------------------------------------------------------
/// <summary>
/// Generates a no-price packing slip PDF for the given invoice. Lists job items with
/// description, color, and quantity only — no unit prices or totals. Intended for
/// physical pickup/delivery paperwork where pricing should not be visible.
/// </summary>
public async Task<byte[]> GeneratePackingSlipPdfAsync(
InvoiceDto invoiceDto,
byte[]? companyLogo,
string? companyLogoContentType,
CompanyInfoDto companyInfo)
{
QuestPDF.Settings.License = LicenseType.Community;
const string accentColor = "#1e40af"; // blue
return await Task.Run(() =>
{
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.75f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
page.Header().Element(c => ComposePackingSlipHeader(c, companyLogo, companyInfo, accentColor, invoiceDto));
page.Content().Element(c => ComposePackingSlipContent(c, invoiceDto, accentColor));
page.Footer().AlignCenter().Text(text =>
{
text.Span("PACKING SLIP | ").FontSize(8).FontColor(Colors.Grey.Darken1);
text.Span(companyInfo.CompanyName).FontSize(8).FontColor(Colors.Grey.Darken1);
text.Span(" | Page ").FontSize(8).FontColor(Colors.Grey.Darken1);
text.CurrentPageNumber().FontSize(8).FontColor(Colors.Grey.Darken1);
text.Span(" of ").FontSize(8).FontColor(Colors.Grey.Darken1);
text.TotalPages().FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
});
return document.GeneratePdf();
});
}
/// <summary>
/// Header for the packing slip: company branding left, "PACKING SLIP" title + invoice/date info right.
/// </summary>
private void ComposePackingSlipHeader(IContainer container, byte[]? companyLogo, CompanyInfoDto companyInfo, string accentColor, InvoiceDto invoice)
{
container.Column(col =>
{
col.Item().Row(row =>
{
row.RelativeItem().Column(column =>
{
if (companyLogo != null && companyLogo.Length > 0)
column.Item().MaxHeight(60).Image(companyLogo);
else
column.Item().Text(companyInfo.CompanyName).FontSize(18).Bold().FontColor(accentColor);
if (!string.IsNullOrWhiteSpace(companyInfo.Address))
column.Item().Text(companyInfo.Address).FontSize(8).FontColor(Colors.Grey.Darken1);
var cityLine = $"{companyInfo.City}{(!string.IsNullOrEmpty(companyInfo.City) && !string.IsNullOrEmpty(companyInfo.State) ? ", " : "")}{companyInfo.State} {companyInfo.ZipCode}".Trim();
if (!string.IsNullOrWhiteSpace(cityLine))
column.Item().Text(cityLine).FontSize(8).FontColor(Colors.Grey.Darken1);
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
column.Item().Text(FormatPhoneNumber(companyInfo.Phone)).FontSize(8).FontColor(Colors.Grey.Darken1);
if (!string.IsNullOrWhiteSpace(companyInfo.PrimaryContactEmail))
column.Item().Text(companyInfo.PrimaryContactEmail).FontSize(8).FontColor(Colors.Grey.Darken1);
});
row.RelativeItem().AlignRight().Column(column =>
{
column.Item().Text("PACKING SLIP").FontSize(26).Bold().FontColor(accentColor);
column.Item().Text($"Invoice #: {invoice.InvoiceNumber}").FontSize(9).Bold();
column.Item().Text($"Date: {invoice.InvoiceDate:MMMM d, yyyy}").FontSize(9);
if (!string.IsNullOrWhiteSpace(invoice.JobNumber))
column.Item().Text($"Job #: {invoice.JobNumber}").FontSize(9);
});
});
col.Item().PaddingVertical(4).LineHorizontal(1).LineColor(accentColor);
});
}
/// <summary>
/// Body of the packing slip: customer info block, optional PO number, and an items table
/// showing description, color, and quantity — no prices.
/// </summary>
private void ComposePackingSlipContent(IContainer container, InvoiceDto invoice, string accentColor)
{
container.Column(col =>
{
// Customer info
col.Item().PaddingTop(12).Row(row =>
{
row.RelativeItem().Column(c =>
{
c.Item().Text("PREPARED FOR").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
c.Item().Text(invoice.CustomerName).Bold();
if (!string.IsNullOrWhiteSpace(invoice.CustomerAddress))
c.Item().Text(invoice.CustomerAddress).FontSize(9);
var cityLine = $"{invoice.CustomerCity}{(!string.IsNullOrEmpty(invoice.CustomerCity) && !string.IsNullOrEmpty(invoice.CustomerState) ? ", " : "")}{invoice.CustomerState} {invoice.CustomerZipCode}".Trim();
if (!string.IsNullOrWhiteSpace(cityLine))
c.Item().Text(cityLine).FontSize(9);
if (!string.IsNullOrWhiteSpace(invoice.CustomerPhone))
c.Item().Text(FormatPhoneNumber(invoice.CustomerPhone)).FontSize(9);
});
if (!string.IsNullOrWhiteSpace(invoice.CustomerPO))
{
row.ConstantItem(160).AlignRight().Column(c =>
{
c.Item().Text("PURCHASE ORDER").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
c.Item().Text(invoice.CustomerPO).Bold();
});
}
});
// Items table
col.Item().PaddingTop(16).Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.RelativeColumn(5);
cols.RelativeColumn(3);
cols.RelativeColumn(1);
});
table.Header(h =>
{
h.Cell().Background(accentColor).Padding(5).Text("Description").FontColor(Colors.White).Bold().FontSize(9);
h.Cell().Background(accentColor).Padding(5).Text("Color / Finish").FontColor(Colors.White).Bold().FontSize(9);
h.Cell().Background(accentColor).Padding(5).AlignCenter().Text("Qty").FontColor(Colors.White).Bold().FontSize(9);
});
var rowAlt = false;
foreach (var item in invoice.InvoiceItems.OrderBy(i => i.DisplayOrder))
{
var bg = rowAlt ? Colors.Grey.Lighten4 : Colors.White;
table.Cell().Background(bg).Padding(5).Column(c =>
{
c.Item().Text(item.Description).FontSize(9);
if (!string.IsNullOrWhiteSpace(item.Notes))
c.Item().Text(item.Notes).FontSize(8).FontColor(Colors.Grey.Darken1);
});
table.Cell().Background(bg).Padding(5).Text(item.ColorName ?? "—").FontSize(9);
table.Cell().Background(bg).Padding(5).AlignCenter().Text(item.Quantity.ToString("G")).FontSize(9);
rowAlt = !rowAlt;
}
});
// Notes (if any)
if (!string.IsNullOrWhiteSpace(invoice.Notes))
{
col.Item().PaddingTop(16).Column(c =>
{
c.Item().Text("Notes").Bold().FontSize(9);
c.Item().Text(invoice.Notes).FontSize(9).FontColor(Colors.Grey.Darken1);
});
}
// Received by signature line
col.Item().PaddingTop(32).Row(row =>
{
row.RelativeItem().Column(c =>
{
c.Item().BorderBottom(1).BorderColor(Colors.Grey.Darken1).PaddingBottom(2).Text(string.Empty);
c.Item().PaddingTop(2).Text("Received by / Date").FontSize(8).FontColor(Colors.Grey.Darken1);
});
row.ConstantItem(24);
row.RelativeItem().Column(c =>
{
c.Item().BorderBottom(1).BorderColor(Colors.Grey.Darken1).PaddingBottom(2).Text(string.Empty);
c.Item().PaddingTop(2).Text("Condition noted").FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
});
}
}
@@ -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>
/// Calculates the total price for a single quote line item, routing to the correct pricing
/// 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.
if (item.IsSalesItem && item.ManualUnitPrice.HasValue)
{
@@ -312,6 +342,8 @@ public class PricingCalculationService : IPricingCalculationService
{
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(
item.Coats[i], 0m, item.Quantity, i, 0, companyId);
coatMaterialCost += coatResult.CoatMaterialCost;
@@ -413,7 +445,9 @@ public class PricingCalculationService : IPricingCalculationService
for (int ci = 0; ci < item.Coats.Count; 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);
totalMaterialCost += coatResult.CoatMaterialCost;
@@ -431,7 +465,8 @@ public class PricingCalculationService : IPricingCalculationService
{
var firstCoatResult = await CalculateCoatPriceAsync(
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;
totalLaborCost = coatLaborCost;
}
@@ -628,6 +663,49 @@ public class PricingCalculationService : IPricingCalculationService
// 4. TOTAL ITEMS SUBTOTAL
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)
// 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.
@@ -806,7 +884,11 @@ public class PricingCalculationService : IPricingCalculationService
MaterialCosts = Math.Round(totalMaterialCosts, 2),
LaborCosts = Math.Round(totalLaborCosts, 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);
var dtoList = itemDtos.ToList();
var items = new List<QuoteItem>();
foreach (var itemDto in itemDtos)
foreach (var itemDto in dtoList)
{
var item = BuildQuoteItem(itemDto, quoteId, companyId, createdAtUtc);
await ApplyPricingAsync(item, itemDto, companyId, ovenRateOverride);
@@ -102,6 +103,17 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
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;
}
@@ -130,6 +142,14 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
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.Coats != null && itemDto.Coats.Any())
@@ -161,9 +181,10 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
/// <summary>
/// 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
/// item, an incoming <see cref="InventoryItem"/> is auto-created so the shop can track the powder
/// order and receive it later — see <see cref="CreateIncomingInventoryItemAsync"/> for details.
/// When a coat references the platform catalog (<c>CatalogItemId</c> set), the ID is stored on
/// <see cref="QuoteItemCoat.PowderCatalogItemId"/> so that at <em>approval</em> time the system
/// 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>
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];
if (coatDto.AddAsIncoming && coatDto.CatalogItemId.HasValue && !coatDto.InventoryItemId.HasValue)
coatDto.InventoryItemId = await CreateIncomingInventoryItemAsync(coatDto, companyId);
// Incoming-inventory creation is intentionally deferred to quote approval.
// PowderCatalogItemId is persisted on the coat entity for later use.
var coat = BuildQuoteItemCoat(coatDto, companyId, createdAtUtc);
var coatPricing = await _pricingService.CalculateCoatPriceAsync(
@@ -243,6 +264,9 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
IsAiItem = itemDto.IsAiItem,
AiTags = itemDto.AiTags,
AiPredictionId = itemDto.AiPredictionId,
IsCustomFormulaItem = itemDto.IsCustomFormulaItem,
CustomItemTemplateId = itemDto.CustomItemTemplateId,
FormulaFieldValuesJson = itemDto.FormulaFieldValuesJson,
CompanyId = companyId,
CreatedAt = createdAtUtc
};
@@ -256,6 +280,7 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
CoatName = coatDto.CoatName,
Sequence = coatDto.Sequence,
InventoryItemId = coatDto.InventoryItemId,
PowderCatalogItemId = coatDto.CatalogItemId,
ColorName = coatDto.ColorName,
VendorId = coatDto.VendorId,
ColorCode = coatDto.ColorCode,
@@ -305,34 +330,36 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
}
/// <summary>
/// Auto-creates an "incoming" <see cref="InventoryItem"/> when a user selects a powder from the
/// platform catalog that doesn't yet exist in their company's inventory.
/// Creates one "incoming" <see cref="InventoryItem"/> from a platform catalog entry.
/// 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
/// forcing the user to manually add the powder to inventory before quoting, we create an
/// IsIncoming=true record on their behalf. The shop can then receive the actual order against
/// this record later (updating quantity + receive date) without losing the link to the original quote.
/// Category resolution prefers the company's "POWDER" category (CategoryCode=="POWDER")
/// so the item always lands in the right bucket regardless of how many IsCoating categories
/// the company has defined. Falls back to the lowest-DisplayOrder IsCoating category.
///
/// The AI augmentation step (LookupByUrlAsync) fills in technical specs (cure temp/time, coverage,
/// color families, etc.) that may be missing from the scraped catalog JSON. It is best-effort —
/// if it fails, the item is still created with whatever data the catalog has.
///
/// 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.
/// AI augmentation fills in missing technical specs (cure temp/time, coverage, color families)
/// from the manufacturer product page. Best-effort — item is still created from catalog data
/// if the AI call fails.
/// </summary>
private async Task<int?> CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId)
private async Task<int?> CreateIncomingInventoryItemAsync(int catalogItemId, int companyId)
{
try
{
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(coatDto.CatalogItemId!.Value);
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(catalogItemId);
if (catalogItem == null) return null;
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
var coatingCategory = categories
.Where(c => c.IsActive && c.IsCoating)
.OrderBy(c => c.DisplayOrder)
.FirstOrDefault();
// Prefer the canonical "POWDER" category so catalog-sourced items never land in an
// unrelated coating category (e.g. "Cerakote") that happens to have IsCoating=true.
var coatingCategory = categories.FirstOrDefault(c => c.IsActive && c.IsCoating
&& 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 vendorNameLower = catalogItem.VendorName.ToLower();
@@ -437,17 +464,143 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
await _unitOfWork.InventoryItems.AddAsync(item);
await _unitOfWork.SaveChangesAsync();
coatDto.PowderCostPerLb = null;
_logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} via quote coat",
item.Id, item.Name, coatDto.CatalogItemId);
_logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} at quote approval",
item.Id, item.Name, catalogItemId);
return item.Id;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to create incoming inventory item from catalog {CatalogId}, continuing without inventory link",
coatDto.CatalogItemId);
catalogItemId);
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();
}
}
@@ -73,6 +73,9 @@ public class ApplicationUser : IdentityUser
// Passkey enrollment prompt
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
public bool IsBanned { get; set; } = false;
public DateTime? BannedAt { get; set; }
@@ -133,6 +133,15 @@ public class Company : BaseEntity
/// </summary>
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
public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>();
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; }
}
@@ -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; }
// Maintenance Information
public int RecommendedMaintenanceIntervalDays { get; set; }
public int? RecommendedMaintenanceIntervalDays { get; set; }
public DateTime? LastMaintenanceDate { 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
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
public virtual ICollection<Vendor> Vendors { get; set; } = new List<Vendor>();
}
@@ -52,6 +52,14 @@ public class JobItem : BaseEntity
public int? AiPredictionId { 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
public virtual Job Job { get; set; } = null!;
public virtual CatalogItem? CatalogItem { get; set; }
@@ -56,6 +56,14 @@ public class QuoteItem : BaseEntity
public int? AiPredictionId { 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
public virtual Quote Quote { get; set; } = null!;
public virtual CatalogItem? CatalogItem { get; set; }
@@ -15,6 +15,13 @@ public class QuoteItemCoat : BaseEntity
// Powder selection (same pattern as current QuoteItem)
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 int? VendorId { get; set; } // Vendor for custom powder
public string? ColorCode { get; set; } // RAL code, etc.
@@ -31,6 +31,9 @@ public class ReworkRecord : BaseEntity
public bool IsBillableToCustomer { 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 ────────────────────────────────────────────────────────────
public ReworkStatus Status { get; set; } = ReworkStatus.Open;
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>
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 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<Expense> Expenses { get; set; } = new List<Expense>();
public virtual Account? DefaultExpenseAccount { get; set; }
public virtual ICollection<InventoryCategoryLookup> Categories { get; set; } = new List<InventoryCategoryLookup>();
}
public class InventoryTransaction : BaseEntity
@@ -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
}
/// <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
{
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
}
@@ -155,6 +155,18 @@ IRepository<ReworkRecord> ReworkRecords { get; }
// Customer Intake Kiosk
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> CompleteAsync(); // Alias for SaveChangesAsync
@@ -92,4 +92,10 @@ public interface IJobRepository : IRepository<Job>
/// were never completed and rolled past their scheduled day.
/// </summary>
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);
}
@@ -289,6 +289,15 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
/// </summary>
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>
public DbSet<BugReport> BugReports { get; set; }
/// <summary>File attachments for bug reports; soft-delete only (no tenant filter — access controlled via parent BugReport).</summary>
@@ -374,6 +383,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>
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>
/// Platform-wide audit log capturing who changed what and when, across all tenants.
/// No global query filter — SuperAdmin controllers query this directly.
@@ -767,6 +787,32 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
.HasForeignKey(k => k.LinkedJobId)
.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
modelBuilder.Entity<Account>()
.HasOne(a => a.ParentAccount)
@@ -809,6 +855,15 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
.HasForeignKey(s => s.DefaultExpenseAccountId)
.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)
modelBuilder.Entity<Bill>()
.HasOne(b => b.APAccount)
@@ -2028,6 +2083,61 @@ modelBuilder.Entity<Job>()
.HasIndex(c => new { c.CompanyId, c.MemoNumber })
.IsUnique()
.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>
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));
}
}
}
@@ -556,6 +556,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("IsBanned")
.HasColumnType("bit");
b.Property<string>("KioskPin")
.HasColumnType("nvarchar(max)");
b.Property<decimal?>("LaborCostPerHour")
.HasColumnType("decimal(18,2)");
@@ -1923,6 +1926,15 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("TimeZone")
.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")
.HasColumnType("datetime2");
@@ -2650,6 +2662,88 @@ namespace PowderCoating.Infrastructure.Migrations
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 =>
{
b.Property<int>("Id")
@@ -2960,6 +3054,66 @@ namespace PowderCoating.Infrastructure.Migrations
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 =>
{
b.Property<int>("Id")
@@ -3045,7 +3199,7 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<decimal>("PurchasePrice")
.HasColumnType("decimal(18,2)");
b.Property<int>("RecommendedMaintenanceIntervalDays")
b.Property<int?>("RecommendedMaintenanceIntervalDays")
.HasColumnType("int");
b.Property<string>("SerialNumber")
@@ -3291,6 +3445,183 @@ namespace PowderCoating.Infrastructure.Migrations
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 =>
{
b.Property<int>("Id")
@@ -4473,6 +4804,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<int?>("CustomItemTemplateId")
.HasColumnType("int");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
@@ -4489,12 +4823,18 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("Finish")
.HasColumnType("nvarchar(max)");
b.Property<string>("FormulaFieldValuesJson")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IncludePrepCost")
.HasColumnType("bit");
b.Property<bool>("IsAiItem")
.HasColumnType("bit");
b.Property<bool>("IsCustomFormulaItem")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
@@ -4558,6 +4898,8 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("CatalogItemId");
b.HasIndex("CustomItemTemplateId");
b.HasIndex("JobId")
.HasDatabaseName("IX_JobItems_JobId");
@@ -6711,7 +7053,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186),
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9377),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -6722,7 +7064,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190),
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9381),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -6733,7 +7075,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191),
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9382),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -7260,6 +7602,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<int?>("CustomItemTemplateId")
.HasColumnType("int");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
@@ -7273,12 +7618,18 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int>("EstimatedMinutes")
.HasColumnType("int");
b.Property<string>("FormulaFieldValuesJson")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IncludePrepCost")
.HasColumnType("bit");
b.Property<bool>("IsAiItem")
.HasColumnType("bit");
b.Property<bool>("IsCustomFormulaItem")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
@@ -7348,6 +7699,8 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("CatalogItemId");
b.HasIndex("CustomItemTemplateId");
b.HasIndex("QuoteId")
.HasDatabaseName("IX_QuoteItems_QuoteId");
@@ -7414,6 +7767,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<int?>("PowderCatalogItemId")
.HasColumnType("int");
b.Property<decimal?>("PowderCostPerLb")
.HasColumnType("decimal(18,2)");
@@ -7990,6 +8346,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int?>("ReworkJobId")
.HasColumnType("int");
b.Property<int?>("ReworkPricingType")
.HasColumnType("int");
b.Property<int>("ReworkType")
.HasColumnType("int");
@@ -8073,6 +8432,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("AllowAiPhotoQuotes")
.HasColumnType("bit");
b.Property<bool>("AllowCustomFormulas")
.HasColumnType("bit");
b.Property<bool>("AllowOnlinePayments")
.HasColumnType("bit");
@@ -8249,6 +8611,61 @@ namespace PowderCoating.Infrastructure.Migrations
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 =>
{
b.Property<int>("Id")
@@ -8631,6 +9048,21 @@ namespace PowderCoating.Infrastructure.Migrations
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 =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
@@ -9016,6 +9448,16 @@ namespace PowderCoating.Infrastructure.Migrations
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 =>
{
b.HasOne("PowderCoating.Core.Entities.Company", null)
@@ -9079,6 +9521,17 @@ namespace PowderCoating.Infrastructure.Migrations
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 =>
{
b.HasOne("PowderCoating.Core.Entities.Company", null)
@@ -9161,6 +9614,46 @@ namespace PowderCoating.Infrastructure.Migrations
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 =>
{
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "IssuedBy")
@@ -9494,6 +9987,10 @@ namespace PowderCoating.Infrastructure.Migrations
.HasForeignKey("CatalogItemId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("PowderCoating.Core.Entities.CustomItemTemplate", "CustomItemTemplate")
.WithMany()
.HasForeignKey("CustomItemTemplateId");
b.HasOne("PowderCoating.Core.Entities.Job", "Job")
.WithMany("JobItems")
.HasForeignKey("JobId")
@@ -9504,6 +10001,8 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("CatalogItem");
b.Navigation("CustomItemTemplate");
b.Navigation("Job");
});
@@ -10113,6 +10612,10 @@ namespace PowderCoating.Infrastructure.Migrations
.WithMany()
.HasForeignKey("CatalogItemId");
b.HasOne("PowderCoating.Core.Entities.CustomItemTemplate", "CustomItemTemplate")
.WithMany()
.HasForeignKey("CustomItemTemplateId");
b.HasOne("PowderCoating.Core.Entities.Quote", "Quote")
.WithMany("QuoteItems")
.HasForeignKey("QuoteId")
@@ -10123,6 +10626,8 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("CatalogItem");
b.Navigation("CustomItemTemplate");
b.Navigation("Quote");
});
@@ -10369,6 +10874,21 @@ namespace PowderCoating.Infrastructure.Migrations
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 =>
{
b.Navigation("BillLineItems");
@@ -21,6 +21,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="NCalc2" Version="2.1.0" />
<PackageReference Include="SendGrid" Version="9.29.3" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="Stripe.net" Version="50.4.1" />
@@ -187,6 +187,14 @@ public class JobRepository : Repository<Job>, IJobRepository
.FirstOrDefaultAsync();
}
/// <inheritdoc/>
public async Task<int> GetReworkJobCountAsync(int originalJobId)
{
return await _context.Jobs
.IgnoreQueryFilters()
.CountAsync(j => j.OriginalJobId == originalJobId);
}
/// <inheritdoc/>
public async Task<List<Job>> GetOverdueScheduledJobsAsync()
{
@@ -123,6 +123,18 @@ public class UnitOfWork : IUnitOfWork
// Customer Intake Kiosk
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
private IPurchaseOrderRepository? _purchaseOrders;
private IRepository<PurchaseOrderItem>? _purchaseOrderItems;
@@ -457,6 +469,30 @@ public class UnitOfWork : IUnitOfWork
public IRepository<KioskSession> KioskSessions =>
_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
/// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary>
public IJobTemplateRepository JobTemplates =>
@@ -262,6 +262,7 @@ public class CsvImportService : ICsvImportService
JobNumber = "JOB-2601-0001",
CustomerEmail = "customer@example.com",
CustomerName = "Acme Corp (used if email is blank or not found)",
Description = "Sample job description",
Status = "Pending",
Priority = "Normal",
ScheduledDate = DateTime.Today.AddDays(7),
@@ -269,7 +270,7 @@ public class CsvImportService : ICsvImportService
FinalPrice = 750.00m,
CustomerPO = "PO-12345",
SpecialInstructions = "Handle with care",
Notes = "Sample job"
Notes = "Internal notes"
});
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.
/// 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.
/// Duplicate detection runs against both existing DB records (by email) and within the import file
/// itself, catching cases where the same email appears twice in one upload.
/// Duplicate detection uses a three-tier strategy, each tier only engaged when the previous
/// 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
/// 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
@@ -418,15 +426,53 @@ public class CsvImportService : ICsvImportService
// Get all existing customers for duplicate detection
var existingCustomers = await _unitOfWork.Customers.GetAllAsync();
// Tier 1 lookup: email → existing customer
var existingEmails = existingCustomers.Where(c => !string.IsNullOrEmpty(c.Email))
.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
var pricingTiers = await _unitOfWork.PricingTiers.GetAllAsync();
var pricingTierDict = pricingTiers.ToDictionary(pt => pt.TierName.ToUpper(), pt => pt, StringComparer.OrdinalIgnoreCase);
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)
{
rowNumber++;
@@ -434,7 +480,12 @@ public class CsvImportService : ICsvImportService
{
// Strip any literal quote characters that QB/Excel may wrap around field values
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 lastName = StripQuotes(record.ContactLastName)?.Trim();
@@ -451,20 +502,68 @@ public class CsvImportService : ICsvImportService
cleanCompanyName = derivedName;
}
// Check for duplicate email in existing data
if (!string.IsNullOrEmpty(cleanEmail) && existingEmails.ContainsKey(cleanEmail.ToLower()))
{
result.Warnings.Add($"Row {rowNumber}: Customer with email '{cleanEmail}' already exists in database. Skipping.");
result.SkippedCount++;
continue;
}
// Canonical display name used as part of composite keys in Tiers 2 and 3
var displayName = string.IsNullOrWhiteSpace(cleanCompanyName)
? $"{firstName} {lastName}".Trim()
: cleanCompanyName;
// Check for duplicate email within the import batch
if (!string.IsNullOrEmpty(cleanEmail) && customersToImport.Any(x => x.Email.Equals(cleanEmail, StringComparison.OrdinalIgnoreCase)))
// --- Tier 1: email dedup ---
// 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.");
result.SkippedCount++;
continue;
if (existingEmails.ContainsKey(cleanEmail.ToLower()))
{
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
@@ -512,6 +611,29 @@ public class CsvImportService : ICsvImportService
};
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)
{
@@ -1268,24 +1390,22 @@ public class CsvImportService : ICsvImportService
MissingFieldFound = null
});
var records = csv.GetRecords<JobImportDto>().ToList();
result.TotalRows = records.Count;
// Treat non-numeric values in decimal? fields (e.g. a spreadsheet "false" in FinalPrice)
// 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 existingJobNumbers = existingJobs.Where(j => !string.IsNullOrEmpty(j.JobNumber))
.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 customerByEmail = customers.Where(c => !string.IsNullOrEmpty(c.Email))
.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);
foreach (var c in customers)
{
@@ -1296,19 +1416,42 @@ public class CsvImportService : ICsvImportService
customerByName.TryAdd(name, c);
}
// Get job statuses for lookup
var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
var jobStatusDict = jobStatuses.ToDictionary(js => js.StatusCode.ToUpper(), js => js, StringComparer.OrdinalIgnoreCase);
// Get job priorities for lookup
var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
var jobPriorityDict = jobPriorities.ToDictionary(jp => jp.PriorityCode.ToUpper(), jp => jp, StringComparer.OrdinalIgnoreCase);
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
{
// Validate required fields
@@ -1414,7 +1557,9 @@ public class CsvImportService : ICsvImportService
CustomerPO = record.CustomerPO?.Trim(),
SpecialInstructions = record.SpecialInstructions?.Trim(),
InternalNotes = record.Notes?.Trim(),
Description = record.SpecialInstructions?.Trim() ?? "Imported job",
Description = record.Description?.Trim()
?? record.SpecialInstructions?.Trim()
?? "Imported job",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
@@ -2813,6 +2958,23 @@ public class CsvImportService : ICsvImportService
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 ───────────────────────────────────────────────────────────
/// <summary>
@@ -3340,4 +3502,23 @@ public class CsvImportService : ICsvImportService
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 };
}
}
}
@@ -0,0 +1,382 @@
using AutoMapper;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.DTOs.Company;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
namespace PowderCoating.Infrastructure.Services;
/// <summary>
/// Manages the community formula library: sharing a company template to the platform-wide
/// library, removing it, browsing published entries, and importing an entry as a local copy.
/// </summary>
public class FormulaLibraryService : IFormulaLibraryService
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly ILogger<FormulaLibraryService> _logger;
public FormulaLibraryService(IUnitOfWork unitOfWork, IMapper mapper, ILogger<FormulaLibraryService> logger)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_logger = logger;
}
/// <inheritdoc />
public async Task<IEnumerable<FormulaLibraryCardDto>> BrowseAsync(
int companyId, string? search, string? outputMode, string? industryHint)
{
var items = await _unitOfWork.FormulaLibrary.FindAsync(i => i.IsPublished);
if (!string.IsNullOrWhiteSpace(search))
{
var lower = search.ToLowerInvariant();
items = items.Where(i =>
i.Name.ToLowerInvariant().Contains(lower) ||
(i.Description != null && i.Description.ToLowerInvariant().Contains(lower)) ||
(i.Tags != null && i.Tags.ToLowerInvariant().Contains(lower)) ||
i.SourceCompanyName.ToLowerInvariant().Contains(lower));
}
if (!string.IsNullOrWhiteSpace(outputMode))
items = items.Where(i => i.OutputMode == outputMode);
if (!string.IsNullOrWhiteSpace(industryHint))
items = items.Where(i => i.IndustryHint != null &&
i.IndustryHint.ToLowerInvariant().Contains(industryHint.ToLowerInvariant()));
// Load InspiredBy for attribution line
var itemList = items.ToList();
var inspiredByIds = itemList
.Where(i => i.InspiredByFormulaLibraryItemId.HasValue)
.Select(i => i.InspiredByFormulaLibraryItemId!.Value)
.Distinct()
.ToHashSet();
Dictionary<int, FormulaLibraryItem> inspirations = new();
foreach (var id in inspiredByIds)
{
var parent = await _unitOfWork.FormulaLibrary.GetByIdAsync(id);
if (parent != null) inspirations[id] = parent;
}
// Attach navigation properties manually (PlainRepository doesn't eager-load)
foreach (var item in itemList)
{
if (item.InspiredByFormulaLibraryItemId.HasValue &&
inspirations.TryGetValue(item.InspiredByFormulaLibraryItemId.Value, out var parent))
item.InspiredBy = parent;
}
// Determine which entries this company has already imported
var imports = await _unitOfWork.FormulaLibraryImports.FindAsync(
imp => imp.CompanyId == companyId && !imp.IsDeleted);
var importedIds = imports.Select(imp => imp.FormulaLibraryItemId).ToHashSet();
// Load all ratings in one query for this page of items
var allItemIds = itemList.Select(i => i.Id).ToHashSet();
var allRatings = await _unitOfWork.FormulaLibraryRatings.FindAsync(
r => allItemIds.Contains(r.FormulaLibraryItemId));
// Group counts and find the current company's vote per item
var ratingsByItem = allRatings
.GroupBy(r => r.FormulaLibraryItemId)
.ToDictionary(g => g.Key, g => g.ToList());
var dtos = _mapper.Map<List<FormulaLibraryCardDto>>(itemList);
for (int i = 0; i < dtos.Count; i++)
{
dtos[i].AlreadyImported = importedIds.Contains(dtos[i].Id);
dtos[i].IsOwnFormula = itemList[i].SourceCompanyId == companyId;
if (ratingsByItem.TryGetValue(dtos[i].Id, out var ratings))
{
dtos[i].ThumbsUp = ratings.Count(r => r.IsPositive);
dtos[i].ThumbsDown = ratings.Count(r => !r.IsPositive);
var myRating = ratings.FirstOrDefault(r => r.CompanyId == companyId);
dtos[i].MyVote = myRating?.IsPositive;
}
}
// Sort: thumbs-up score descending, then import count, then name
return dtos
.OrderByDescending(d => d.ThumbsUp - d.ThumbsDown)
.ThenByDescending(d => d.ImportCount)
.ThenBy(d => d.Name);
}
/// <inheritdoc />
public async Task<FormulaLibraryDetailDto?> GetDetailAsync(int libraryItemId, int companyId)
{
var item = await _unitOfWork.FormulaLibrary.GetByIdAsync(libraryItemId);
if (item == null || !item.IsPublished) return null;
if (item.InspiredByFormulaLibraryItemId.HasValue)
item.InspiredBy = await _unitOfWork.FormulaLibrary.GetByIdAsync(
item.InspiredByFormulaLibraryItemId.Value);
var dto = _mapper.Map<FormulaLibraryDetailDto>(item);
var imp = await _unitOfWork.FormulaLibraryImports.FindAsync(
i => i.CompanyId == companyId && i.FormulaLibraryItemId == libraryItemId && !i.IsDeleted);
dto.AlreadyImported = imp.Any();
return dto;
}
/// <inheritdoc />
public async Task<int> ShareAsync(int companyId, string userId, ShareFormulaRequest request)
{
var template = await _unitOfWork.CustomItemTemplates.GetByIdAsync(request.CustomItemTemplateId);
if (template == null || template.CompanyId != companyId)
throw new InvalidOperationException("Template not found.");
if (!CanShare(template))
throw new InvalidOperationException("This template is not eligible to be shared.");
// Determine "Inspired by" — if this was imported from the library
int? inspiredById = null;
if (template.SourceFormulaLibraryItemId.HasValue && template.IsModifiedFromSource)
inspiredById = template.SourceFormulaLibraryItemId;
// Get company name for attribution
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
var companyName = company?.CompanyName ?? "Unknown Company";
// Re-use existing row if one exists (re-share after unshare, or update after edits)
var existing = await _unitOfWork.FormulaLibrary.FirstOrDefaultAsync(
f => f.SourceCustomItemTemplateId == template.Id && f.SourceCompanyId == companyId);
if (existing != null)
{
CopyFromTemplate(existing, template, companyName, request);
existing.InspiredByFormulaLibraryItemId = inspiredById;
existing.IsPublished = true;
existing.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.FormulaLibrary.UpdateAsync(existing);
await _unitOfWork.CompleteAsync();
return existing.Id;
}
var libraryItem = new FormulaLibraryItem
{
SharedByUserId = userId,
SharedAt = DateTime.UtcNow,
SourceCustomItemTemplateId = template.Id,
SourceCompanyId = companyId,
SourceCompanyName = companyName,
InspiredByFormulaLibraryItemId = inspiredById,
IsPublished = true,
};
CopyFromTemplate(libraryItem, template, companyName, request);
await _unitOfWork.FormulaLibrary.AddAsync(libraryItem);
await _unitOfWork.CompleteAsync();
return libraryItem.Id;
}
/// <inheritdoc />
public async Task UnshareAsync(int libraryItemId, int companyId)
{
var item = await _unitOfWork.FormulaLibrary.GetByIdAsync(libraryItemId);
if (item == null || item.SourceCompanyId != companyId) return;
item.IsPublished = false;
item.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.FormulaLibrary.UpdateAsync(item);
await _unitOfWork.CompleteAsync();
}
/// <inheritdoc />
public async Task<int> ImportAsync(int libraryItemId, int companyId, string userId)
{
var libraryItem = await _unitOfWork.FormulaLibrary.GetByIdAsync(libraryItemId);
if (libraryItem == null || !libraryItem.IsPublished)
throw new InvalidOperationException("Library entry not found or no longer published.");
// Return existing import if already imported
var existingImports = await _unitOfWork.FormulaLibraryImports.FindAsync(
i => i.CompanyId == companyId && i.FormulaLibraryItemId == libraryItemId && !i.IsDeleted);
var existingImport = existingImports.FirstOrDefault();
if (existingImport != null) return existingImport.ResultingCustomItemTemplateId;
// Create a local copy as a new CustomItemTemplate
var template = new CustomItemTemplate
{
CompanyId = companyId,
Name = libraryItem.Name,
Description = libraryItem.Description,
OutputMode = libraryItem.OutputMode,
FieldsJson = libraryItem.FieldsJson,
Formula = libraryItem.Formula,
DefaultRate = libraryItem.DefaultRate,
RateLabel = libraryItem.RateLabel,
Notes = libraryItem.Notes,
DiagramImagePath = libraryItem.DiagramImagePath,
DisplayOrder = 0,
IsActive = true,
SourceFormulaLibraryItemId = libraryItemId,
IsModifiedFromSource = false,
};
await _unitOfWork.CustomItemTemplates.AddAsync(template);
await _unitOfWork.CompleteAsync();
var importRecord = new FormulaLibraryImport
{
CompanyId = companyId,
FormulaLibraryItemId = libraryItemId,
ImportedByUserId = userId,
ImportedAt = DateTime.UtcNow,
ResultingCustomItemTemplateId = template.Id,
};
await _unitOfWork.FormulaLibraryImports.AddAsync(importRecord);
// Increment import counter
libraryItem.ImportCount++;
await _unitOfWork.FormulaLibrary.UpdateAsync(libraryItem);
await _unitOfWork.CompleteAsync();
return template.Id;
}
/// <inheritdoc />
public async Task<FormulaLibraryStatusDto> GetTemplateLibraryStatusAsync(int templateId, int companyId)
{
var template = await _unitOfWork.CustomItemTemplates.GetByIdAsync(templateId);
if (template == null || template.CompanyId != companyId)
return new FormulaLibraryStatusDto { CanShare = false };
var dto = new FormulaLibraryStatusDto { CanShare = CanShare(template) };
// Populate import attribution
if (template.SourceFormulaLibraryItemId.HasValue)
{
var source = await _unitOfWork.FormulaLibrary.GetByIdAsync(template.SourceFormulaLibraryItemId.Value);
if (source != null)
{
dto.ImportedFromName = source.Name;
dto.ImportedFromCompany = source.SourceCompanyName;
}
}
// Check if this template has an active library entry
var libraryItem = await _unitOfWork.FormulaLibrary.FirstOrDefaultAsync(
f => f.SourceCustomItemTemplateId == templateId && f.SourceCompanyId == companyId);
if (libraryItem != null)
{
dto.LibraryItemId = libraryItem.Id;
dto.IsPublished = libraryItem.IsPublished;
}
return dto;
}
/// <inheritdoc />
public async Task CascadeRemoveDiagramAsync(int sourceCustomItemTemplateId)
{
// Null out on the library item published from this template
var libraryItem = await _unitOfWork.FormulaLibrary.FirstOrDefaultAsync(
f => f.SourceCustomItemTemplateId == sourceCustomItemTemplateId);
if (libraryItem != null && libraryItem.DiagramImagePath != null)
{
libraryItem.DiagramImagePath = null;
libraryItem.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.FormulaLibrary.UpdateAsync(libraryItem);
// Null out on all imported copies
var imports = await _unitOfWork.FormulaLibraryImports.FindAsync(
i => i.FormulaLibraryItemId == libraryItem.Id && !i.IsDeleted);
foreach (var imp in imports)
{
var copy = await _unitOfWork.CustomItemTemplates.GetByIdAsync(
imp.ResultingCustomItemTemplateId);
if (copy != null && copy.DiagramImagePath != null)
{
copy.DiagramImagePath = null;
await _unitOfWork.CompleteAsync();
}
}
}
}
/// <inheritdoc />
public async Task<(int ThumbsUp, int ThumbsDown, bool? MyVote)> RateAsync(
int libraryItemId, int companyId, bool isPositive)
{
var item = await _unitOfWork.FormulaLibrary.GetByIdAsync(libraryItemId);
if (item == null || !item.IsPublished)
throw new InvalidOperationException("Library entry not found.");
// Companies cannot rate their own formula
if (item.SourceCompanyId == companyId)
throw new InvalidOperationException("You cannot rate your own formula.");
var existing = await _unitOfWork.FormulaLibraryRatings.FirstOrDefaultAsync(
r => r.FormulaLibraryItemId == libraryItemId && r.CompanyId == companyId);
bool? myVote;
if (existing != null && existing.IsPositive == isPositive)
{
// Same vote again — toggle off
await _unitOfWork.FormulaLibraryRatings.DeleteAsync(existing);
myVote = null;
}
else if (existing != null)
{
// Opposite vote — flip it
existing.IsPositive = isPositive;
existing.RatedAt = DateTime.UtcNow;
await _unitOfWork.FormulaLibraryRatings.UpdateAsync(existing);
myVote = isPositive;
}
else
{
// New vote
await _unitOfWork.FormulaLibraryRatings.AddAsync(new FormulaLibraryRating
{
FormulaLibraryItemId = libraryItemId,
CompanyId = companyId,
IsPositive = isPositive,
RatedAt = DateTime.UtcNow,
});
myVote = isPositive;
}
await _unitOfWork.CompleteAsync();
// Return fresh counts
var allRatings = await _unitOfWork.FormulaLibraryRatings.FindAsync(
r => r.FormulaLibraryItemId == libraryItemId);
var list = allRatings.ToList();
return (list.Count(r => r.IsPositive), list.Count(r => !r.IsPositive), myVote);
}
// ── Helpers ───────────────────────────────────────────────────────────
/// <summary>
/// A template is shareable if it was created fresh (no source library item) or
/// if it was imported but then modified by the company.
/// </summary>
private static bool CanShare(CustomItemTemplate t) =>
t.SourceFormulaLibraryItemId == null || t.IsModifiedFromSource;
private static void CopyFromTemplate(
FormulaLibraryItem dest, CustomItemTemplate src, string companyName, ShareFormulaRequest req)
{
dest.Name = src.Name;
dest.Description = src.Description;
dest.OutputMode = src.OutputMode;
dest.FieldsJson = src.FieldsJson;
dest.Formula = src.Formula;
dest.DefaultRate = src.DefaultRate;
dest.RateLabel = src.RateLabel;
dest.Notes = src.Notes;
dest.DiagramImagePath = src.DiagramImagePath;
dest.SourceCompanyName = companyName;
dest.Tags = req.Tags?.Trim();
dest.IndustryHint = req.IndustryHint?.Trim();
}
}
@@ -1209,6 +1209,15 @@ Rules:
sb.AppendLine("Page content:");
sb.AppendLine(pageContent);
}
else if (!string.IsNullOrWhiteSpace(fetchUrl))
{
// Page content unavailable (fetch failed or blocked) — still surface the URL so Claude
// can use its training knowledge of the manufacturer URL structure (e.g. Prismatic SKU
// in the path) to infer product identity rather than returning all-null fields.
sb.AppendLine();
sb.AppendLine($"Product URL (page content could not be fetched): {fetchUrl}");
sb.AppendLine("Use your training knowledge of this manufacturer and the URL to fill in as many fields as possible.");
}
return sb.ToString();
}
@@ -162,7 +162,6 @@ public class StripeConnectService : IStripeConnectService
decimal invoiceTotal,
decimal surchargeAmount,
string currency,
string customerEmail,
string invoiceNumber,
int invoiceId)
{
@@ -175,7 +174,6 @@ public class StripeConnectService : IStripeConnectService
{
Amount = amountInCents,
Currency = currency.ToLower(),
ReceiptEmail = customerEmail,
Description = $"Invoice {invoiceNumber}",
Metadata = new Dictionary<string, string>
{
@@ -215,7 +213,6 @@ public class StripeConnectService : IStripeConnectService
decimal depositAmount,
decimal surchargeAmount,
string currency,
string customerEmail,
string quoteNumber,
int quoteId)
{
@@ -228,7 +225,6 @@ public class StripeConnectService : IStripeConnectService
{
Amount = amountInCents,
Currency = currency.ToLower(),
ReceiptEmail = customerEmail,
Description = $"Deposit for quote {quoteNumber}",
Metadata = new Dictionary<string, string>
{
@@ -67,9 +67,9 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
}
/// <summary>
/// Opens a DI scope, queries non-Stripe-managed companies with active or grace-period subscriptions,
/// and calls <see cref="ProcessCompanyAsync"/> for each. A single <c>SaveChangesAsync</c> at the
/// end batches all status mutations into one round-trip. Errors are caught to keep the loop alive.
/// Opens a DI scope, queries non-Stripe-managed (trial) companies with active or grace-period
/// subscriptions, and calls <see cref="ProcessCompanyAsync"/> for each. Each company is saved
/// individually so a single failure does not prevent other companies from being updated.
/// </summary>
private async Task RunAsync(CancellationToken ct)
{
@@ -103,15 +103,27 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
_logger.LogDebug("Found {Count} companies to evaluate.", companies.Count);
// All companies reaching this point have no StripeSubscriptionId — they are trials.
// Paid subscribers are managed by Stripe and filtered out above.
var effectiveGraceDays = gracePeriodAppliesToTrials ? gracePeriodDays : 0;
foreach (var company in companies)
{
if (ct.IsCancellationRequested) break;
var isTrial = string.IsNullOrEmpty(company.StripeSubscriptionId);
var effectiveGraceDays = isTrial && !gracePeriodAppliesToTrials ? 0 : gracePeriodDays;
await ProcessCompanyAsync(db, emailService, adminNotification, company, today, effectiveGraceDays, ct);
try
{
await ProcessCompanyAsync(db, emailService, adminNotification, company, today, effectiveGraceDays, ct);
await db.SaveChangesAsync(ct);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to process subscription expiry for company {Id} ({Name}). Status change was not persisted.",
company.Id, company.CompanyName);
// Clear EF tracked changes so bad state does not bleed into the next company.
db.ChangeTracker.Clear();
}
}
await db.SaveChangesAsync(ct);
}
catch (Exception ex)
{
@@ -121,10 +133,15 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
/// <summary>
/// Evaluates a single company and performs any required status transitions or reminder sends.
/// Transition logic: Active past end date → GracePeriod; GracePeriod past grace deadline → Expired + deactivated.
/// Transition logic:
/// <list type="bullet">
/// <item>Active past end date, grace days = 0 → Expired + deactivated immediately (trials).</item>
/// <item>Active past end date, grace days &gt; 0 → GracePeriod + grace-period email.</item>
/// <item>GracePeriod past grace deadline → Expired + deactivated.</item>
/// </list>
/// Reminder emails at <see cref="ReminderDays"/> offsets are sent only while the company is still Active.
/// Platform admin is notified asynchronously (fire-and-forget) for both grace period start and full expiry
/// so that operator action can be taken without delaying the main processing loop.
/// Platform admin is notified asynchronously (fire-and-forget) so that operator action can be taken
/// without delaying the main processing loop.
/// </summary>
private async Task ProcessCompanyAsync(
ApplicationDbContext db,
@@ -153,35 +170,55 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
await WriteAuditLogAsync(db, company,
$"Auto-expired: grace period ended {gracePeriodDays} days after subscription end {endDate:d}.");
// Notify platform admin
_ = adminNotification.NotifyCompanyExpiredAsync(
company.Id, company.CompanyName,
company.PrimaryContactEmail ?? string.Empty, expiredDate);
}
else if (company.SubscriptionStatus == SubscriptionStatus.Active && today > endDate)
{
_logger.LogInformation(
"Company {Id} ({Name}) subscription ended. Entering grace period.",
company.Id, company.CompanyName);
if (gracePeriodDays == 0)
{
// No grace period configured — expire immediately without going through GracePeriod.
// Trials always land here since gracePeriodAppliesToTrials defaults to false.
_logger.LogInformation(
"Company {Id} ({Name}) subscription ended with no grace period. Marking Expired and deactivating.",
company.Id, company.CompanyName);
company.SubscriptionStatus = SubscriptionStatus.GracePeriod;
company.UpdatedAt = DateTime.UtcNow;
company.UpdatedBy = "System";
company.SubscriptionStatus = SubscriptionStatus.Expired;
company.IsActive = false;
company.UpdatedAt = DateTime.UtcNow;
company.UpdatedBy = "System";
await WriteAuditLogAsync(db, company,
$"Auto-moved to GracePeriod: subscription ended {endDate:d}. Grace period expires {expiredDate:d}.");
await WriteAuditLogAsync(db, company,
$"Auto-expired: subscription ended {endDate:d} with no grace period.");
// Send "grace period started" email to company immediately
await SendEmailIfNotSentAsync(db, emailService, company, today,
NotificationType.SubscriptionExpiryReminder,
daysBeforeExpiry: 0,
gracePeriodDays,
ct);
_ = adminNotification.NotifyCompanyExpiredAsync(
company.Id, company.CompanyName,
company.PrimaryContactEmail ?? string.Empty, endDate);
}
else
{
_logger.LogInformation(
"Company {Id} ({Name}) subscription ended. Entering {Days}-day grace period.",
company.Id, company.CompanyName, gracePeriodDays);
// Notify platform admin
_ = adminNotification.NotifyCompanyGracePeriodAsync(
company.Id, company.CompanyName,
company.PrimaryContactEmail ?? string.Empty, expiredDate);
company.SubscriptionStatus = SubscriptionStatus.GracePeriod;
company.UpdatedAt = DateTime.UtcNow;
company.UpdatedBy = "System";
await WriteAuditLogAsync(db, company,
$"Auto-moved to GracePeriod: subscription ended {endDate:d}. Grace period expires {expiredDate:d}.");
await SendEmailIfNotSentAsync(db, emailService, company, today,
NotificationType.SubscriptionExpiryReminder,
daysBeforeExpiry: 0,
gracePeriodDays,
ct);
_ = adminNotification.NotifyCompanyGracePeriodAsync(
company.Id, company.CompanyName,
company.PrimaryContactEmail ?? string.Empty, expiredDate);
}
}
// ── Reminder emails (only while still Active) ────────────────────
@@ -33,6 +33,9 @@ public class CompanySettingsController : Controller
private readonly IAuditLogService _auditLog;
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
private readonly IAzureBlobStorageService _blobStorage;
private readonly ICustomFormulaAiService _formulaAiService;
private readonly IFormulaLibraryService _formulaLibraryService;
public CompanySettingsController(
IUnitOfWork unitOfWork,
@@ -45,7 +48,10 @@ public class CompanySettingsController : Controller
IConfiguration configuration,
IAuditLogService auditLog,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager)
SignInManager<ApplicationUser> signInManager,
IAzureBlobStorageService blobStorage,
ICustomFormulaAiService formulaAiService,
IFormulaLibraryService formulaLibraryService)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
@@ -58,6 +64,9 @@ public class CompanySettingsController : Controller
_auditLog = auditLog;
_userManager = userManager;
_signInManager = signInManager;
_blobStorage = blobStorage;
_formulaAiService = formulaAiService;
_formulaLibraryService = formulaLibraryService;
}
/// <summary>
@@ -130,12 +139,20 @@ public class CompanySettingsController : Controller
var planConfig = await _unitOfWork.SubscriptionPlanConfigs.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan);
dto.AllowOnlinePayments = planConfig?.AllowOnlinePayments ?? false;
dto.AllowSms = planConfig?.AllowSms ?? false;
ViewBag.AllowCustomFormulas = AllowCustomFormulas();
dto.SmsEnabled = company.SmsEnabled;
dto.SmsDisabledByAdmin = company.SmsDisabledByAdmin;
dto.SmsTermsVersion = AppConstants.SmsTermsVersion;
dto.HasCurrentSmsAgreement = await _unitOfWork.CompanySmsAgreements
.AnyAsync(a => a.CompanyId == companyId.Value && a.TermsVersion == AppConstants.SmsTermsVersion);
// Timeclock settings
dto.TimeclockEnabled = company.TimeclockEnabled;
dto.TimeclockAllowMultiplePunchesPerDay = company.TimeclockAllowMultiplePunchesPerDay;
dto.TimeclockAutoClockOutHours = company.TimeclockAutoClockOutHours;
var kioskDevices = await _unitOfWork.TimeclockKioskDevices.FindAsync(d => d.CompanyId == companyId.Value);
ViewBag.TimeclockKioskDevices = kioskDevices.OrderBy(d => d.ActivatedAt).ToList();
// Flag whether Stripe Connect is configured (non-placeholder client ID)
var connectClientId = _configuration["Stripe:Connect:ConnectClientId"];
ViewBag.StripeConnectConfigured = !string.IsNullOrWhiteSpace(connectClientId)
@@ -734,6 +751,40 @@ public class CompanySettingsController : Controller
}
}
// POST: CompanySettings/UpdateTimeclockSettings
/// <summary>
/// Saves company-level timeclock settings (enabled toggle, multiple-punches-per-day, auto clock-out hours).
/// </summary>
[HttpPost]
public async Task<IActionResult> UpdateTimeclockSettings([FromBody] UpdateTimeclockSettingsDto dto)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "User does not have a company ID." });
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
if (company == null)
return Json(new { success = false, message = "Company not found." });
company.TimeclockEnabled = dto.TimeclockEnabled;
company.TimeclockAllowMultiplePunchesPerDay = dto.TimeclockAllowMultiplePunchesPerDay;
company.TimeclockAutoClockOutHours = dto.TimeclockAutoClockOutHours;
await _unitOfWork.Companies.UpdateAsync(company);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Company {CompanyId} timeclock settings updated", companyId);
return Json(new { success = true, message = "Timeclock settings saved." });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating timeclock settings");
return Json(new { success = false, message = "An error occurred while saving timeclock settings." });
}
}
/// <summary>
/// Builds a suggested AI profile draft from existing company configuration — company name/location,
/// named ovens, sandblasting capability, shop worker roles, coating inventory categories, and
@@ -2962,6 +3013,471 @@ public class CompanySettingsController : Controller
return RedirectToAction(nameof(DeleteAccount));
}
}
// ─── Custom Formula Item Templates ──────────────────────────────────────────
private bool AllowCustomFormulas() => HttpContext.Items["AllowCustomFormulas"] as bool? ?? false;
/// <summary>Returns all active + inactive formula templates for the current company.</summary>
[HttpGet]
public async Task<IActionResult> GetCustomItemTemplate(int id)
{
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(id);
if (entity == null || entity.CompanyId != companyId)
return Json(new { success = false, message = "Template not found." });
var dto = _mapper.Map<CustomItemTemplateDto>(entity);
return Json(new { success = true, template = dto });
}
/// <summary>Returns all active + inactive formula templates for the current company.</summary>
[HttpGet]
public async Task<IActionResult> GetCustomItemTemplates()
{
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var templates = await _unitOfWork.CustomItemTemplates.FindAsync(
t => t.CompanyId == companyId);
var dtos = _mapper.Map<List<CustomItemTemplateListDto>>(templates.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name));
return Json(new { success = true, templates = dtos });
}
/// <summary>Downloads all formula templates as a portable JSON backup file.</summary>
[HttpGet]
public async Task<IActionResult> ExportCustomItemTemplates()
{
if (!AllowCustomFormulas()) return Forbid();
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var templates = await _unitOfWork.CustomItemTemplates.FindAsync(t => t.CompanyId == companyId);
// Parse FieldsJson into a real JsonElement so it is embedded as a proper JSON array
// in the export file rather than as an escaped string. This makes the file human-readable
// and avoids round-trip corruption when files are manually edited.
static System.Text.Json.JsonElement ParseFields(string? raw)
{
try { return System.Text.Json.JsonDocument.Parse(raw ?? "[]").RootElement.Clone(); }
catch { return System.Text.Json.JsonDocument.Parse("[]").RootElement.Clone(); }
}
var export = new
{
exportedAt = DateTime.UtcNow,
version = 1,
templates = templates
.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
.Select(t => new
{
t.Name,
t.Description,
t.OutputMode,
Fields = ParseFields(t.FieldsJson),
t.Formula,
t.DefaultRate,
t.RateLabel,
t.Notes,
t.DisplayOrder,
t.IsActive
})
};
var json = System.Text.Json.JsonSerializer.Serialize(export,
new System.Text.Json.JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
});
var filename = $"formula-templates-{DateTime.UtcNow:yyyyMMdd}.json";
return File(System.Text.Encoding.UTF8.GetBytes(json), "application/json", filename);
}
/// <summary>
/// Imports formula templates from a JSON backup file produced by ExportCustomItemTemplates.
/// Templates whose name already exists in the company are skipped; all others are created.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ImportCustomItemTemplates(IFormFile file)
{
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
if (file == null || file.Length == 0) return Json(new { success = false, message = "No file selected." });
if (!file.FileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
return Json(new { success = false, message = "File must be a .json export file." });
if (file.Length > 512 * 1024)
return Json(new { success = false, message = "File is too large (max 512 KB)." });
string json;
using (var reader = new System.IO.StreamReader(file.OpenReadStream()))
json = await reader.ReadToEndAsync();
System.Text.Json.JsonElement root;
try
{
root = System.Text.Json.JsonDocument.Parse(json).RootElement;
}
catch
{
return Json(new { success = false, message = "Could not parse file — make sure it is a valid formula export." });
}
if (!root.TryGetProperty("templates", out var templatesEl) || templatesEl.ValueKind != System.Text.Json.JsonValueKind.Array)
return Json(new { success = false, message = "Invalid export format: missing \"templates\" array." });
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var existing = await _unitOfWork.CustomItemTemplates.FindAsync(t => t.CompanyId == companyId);
// Track names already in DB + names imported within this same file to prevent intra-file duplicates
var usedNames = existing.Select(t => t.Name.ToLowerInvariant()).ToHashSet();
int imported = 0, skipped = 0;
var skippedNames = new List<string>();
var errors = new List<string>();
foreach (var item in templatesEl.EnumerateArray())
{
try
{
var name = item.TryGetProperty("name", out var nEl) ? nEl.GetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(name)) { errors.Add("Skipped one template with no name."); continue; }
if (usedNames.Contains(name.ToLowerInvariant()))
{
skipped++;
skippedNames.Add(name);
continue;
}
var dto = new CreateCustomItemTemplateDto
{
Name = name,
Description = item.TryGetProperty("description", out var d) ? d.GetString() : null,
OutputMode = item.TryGetProperty("outputMode", out var om) ? om.GetString() ?? "FixedRate" : "FixedRate",
// "fields" is a real JSON array in the export; GetRawText() reconstructs the string
FieldsJson = item.TryGetProperty("fields", out var fj) ? fj.GetRawText() : "[]",
Formula = item.TryGetProperty("formula", out var f) ? f.GetString() ?? "" : "",
DefaultRate = item.TryGetProperty("defaultRate", out var dr) && dr.ValueKind == System.Text.Json.JsonValueKind.Number ? dr.GetDecimal() : null,
RateLabel = item.TryGetProperty("rateLabel", out var rl) ? rl.GetString() : null,
Notes = item.TryGetProperty("notes", out var n) ? n.GetString() : null,
DisplayOrder = item.TryGetProperty("displayOrder", out var dord) && dord.ValueKind == System.Text.Json.JsonValueKind.Number ? dord.GetInt32() : 0,
IsActive = true,
};
var fieldError = ValidateTemplateFields(dto.FieldsJson);
if (fieldError != null) { errors.Add($"\"{name}\": {fieldError}"); continue; }
var (normalizedFormula, formulaError) = _formulaAiService.NormalizeAndValidate(dto.Formula);
if (formulaError != null) { errors.Add($"\"{name}\": formula error &mdash; {formulaError}"); continue; }
dto.Formula = normalizedFormula;
var entity = _mapper.Map<CustomItemTemplate>(dto);
entity.CompanyId = companyId;
entity.CreatedAt = DateTime.UtcNow;
await _unitOfWork.CustomItemTemplates.AddAsync(entity);
usedNames.Add(name.ToLowerInvariant());
imported++;
}
catch (Exception ex)
{
errors.Add($"Unexpected error on one template: {ex.Message}");
}
}
if (imported > 0)
await _unitOfWork.CompleteAsync();
return Json(new { success = true, imported, skipped, skippedNames, errors });
}
/// <summary>Creates a new formula template for the current company.</summary>
[HttpPost]
public async Task<IActionResult> CreateCustomItemTemplate([FromBody] CreateCustomItemTemplateDto dto)
{
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data." });
var fieldError = ValidateTemplateFields(dto.FieldsJson);
if (fieldError != null) return Json(new { success = false, message = fieldError });
var (normalizedFormula, formulaError) = _formulaAiService.NormalizeAndValidate(dto.Formula);
if (formulaError != null) return Json(new { success = false, message = $"Formula error: {formulaError}" });
dto.Formula = normalizedFormula;
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var entity = _mapper.Map<CustomItemTemplate>(dto);
entity.CompanyId = companyId;
entity.CreatedAt = DateTime.UtcNow;
await _unitOfWork.CustomItemTemplates.AddAsync(entity);
await _unitOfWork.CompleteAsync();
return Json(new { success = true, id = entity.Id });
}
/// <summary>Updates an existing formula template owned by the current company.</summary>
[HttpPost]
public async Task<IActionResult> UpdateCustomItemTemplate([FromBody] UpdateCustomItemTemplateDto dto)
{
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data." });
var fieldError = ValidateTemplateFields(dto.FieldsJson);
if (fieldError != null) return Json(new { success = false, message = fieldError });
var (normalizedFormula, formulaError) = _formulaAiService.NormalizeAndValidate(dto.Formula);
if (formulaError != null) return Json(new { success = false, message = $"Formula error: {formulaError}" });
dto.Formula = normalizedFormula;
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(dto.Id);
if (entity == null || entity.CompanyId != companyId)
return Json(new { success = false, message = "Template not found." });
_mapper.Map(dto, entity);
entity.UpdatedAt = DateTime.UtcNow;
// If this was imported from the library, mark it as modified so the share button appears
if (entity.SourceFormulaLibraryItemId.HasValue)
entity.IsModifiedFromSource = true;
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
/// <summary>Soft-deletes a formula template owned by the current company.</summary>
[HttpPost]
public async Task<IActionResult> DeleteCustomItemTemplate(int id)
{
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(id);
if (entity == null || entity.CompanyId != companyId)
return Json(new { success = false, message = "Template not found." });
await _unitOfWork.CustomItemTemplates.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
// ── Community Library: share / unshare / status ───────────────────────
/// <summary>
/// Returns the community library status for a given template: whether it is published,
/// eligible to share, and where it was originally imported from if applicable.
/// </summary>
[HttpGet]
public async Task<IActionResult> FormulaLibraryStatus(int templateId)
{
if (!AllowCustomFormulas()) return Json(new { canShare = false });
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var status = await _formulaLibraryService.GetTemplateLibraryStatusAsync(templateId, companyId);
return Json(status);
}
/// <summary>
/// Publishes a company template to the community library (or re-publishes after unshare).
/// Only templates that are original creations or modified imports may be shared.
/// </summary>
[HttpPost]
public async Task<IActionResult> ShareFormula([FromBody] PowderCoating.Application.DTOs.Company.ShareFormulaRequest request)
{
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
try
{
var libraryItemId = await _formulaLibraryService.ShareAsync(companyId, userId, request);
return Json(new { success = true, libraryItemId });
}
catch (InvalidOperationException ex)
{
return Json(new { success = false, message = ex.Message });
}
}
/// <summary>Removes a template from the community library. Existing company imports are unaffected.</summary>
[HttpPost]
public async Task<IActionResult> UnshareFormula(int libraryItemId)
{
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
await _formulaLibraryService.UnshareAsync(libraryItemId, companyId);
return Json(new { success = true });
}
/// <summary>
/// Uploads a diagram image for a template to blob storage container
/// <c>formulatemplate-diagrams/{companyId}/{templateId}/diagram.{ext}</c>.
/// Returns the blob path for storage on the entity.
/// </summary>
[HttpPost]
public async Task<IActionResult> UploadTemplateDiagram(int templateId, IFormFile diagramFile)
{
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(templateId);
if (entity == null || entity.CompanyId != companyId)
return Json(new { success = false, message = "Template not found." });
var allowedTypes = new[] { "image/jpeg", "image/png", "image/gif", "image/webp" };
if (!allowedTypes.Contains(diagramFile.ContentType.ToLowerInvariant()))
return Json(new { success = false, message = "Only JPEG, PNG, GIF, or WebP images are allowed." });
if (diagramFile.Length > 10 * 1024 * 1024)
return Json(new { success = false, message = "Image must be under 10 MB." });
var ext = Path.GetExtension(diagramFile.FileName).ToLowerInvariant().TrimStart('.');
var blobPath = $"{companyId}/{templateId}/diagram.{ext}";
using var stream = diagramFile.OpenReadStream();
var (ok, err) = await _blobStorage.UploadAsync("formulatemplate-diagrams", blobPath, stream, diagramFile.ContentType);
if (!ok)
return Json(new { success = false, message = err });
entity.DiagramImagePath = blobPath;
entity.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.CompleteAsync();
return Json(new { success = true, diagramImagePath = blobPath });
}
/// <summary>
/// Serves a template diagram image from blob storage. The path is tenant-scoped
/// so cross-company access is prevented by checking CompanyId on the template.
/// </summary>
[HttpGet]
public async Task<IActionResult> TemplateDiagram(int templateId)
{
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(templateId);
if (entity == null || entity.CompanyId != companyId || string.IsNullOrEmpty(entity.DiagramImagePath))
return NotFound();
var (ok, bytes, contentType, _) = await _blobStorage.DownloadAsync("formulatemplate-diagrams", entity.DiagramImagePath);
if (!ok || bytes == null || bytes.Length == 0) return NotFound();
return File(bytes, contentType ?? "image/jpeg");
}
/// <summary>
/// Evaluates a NCalc formula with the supplied variable values, automatically injecting
/// three read-only shop-rate variables sourced from the company's operating costs:
/// <c>standard_labor_rate</c>, <c>additional_coat_labor_pct</c>, and <c>markup_pct</c>.
/// User-supplied variables take precedence so the test panel can override them.
/// </summary>
[HttpPost]
public async Task<IActionResult> EvaluateFormula([FromBody] EvaluateFormulaRequest req)
{
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
// Inject shop-rate system variables; user-supplied values win if the same key appears in both.
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId != null)
{
var costs = await _unitOfWork.CompanyOperatingCosts.FirstOrDefaultAsync(c => c.CompanyId == companyId.Value);
if (costs != null)
req = InjectShopRateVariables(req, costs);
}
var result = _formulaAiService.EvaluateFormula(req);
return Json(result);
}
/// <summary>
/// Merges <c>standard_labor_rate</c>, <c>additional_coat_labor_pct</c>, and <c>markup_pct</c>
/// from <paramref name="costs"/> into the request's variable map without overwriting any key
/// the caller already set (so the test panel can still override these values explicitly).
/// </summary>
private static EvaluateFormulaRequest InjectShopRateVariables(
EvaluateFormulaRequest req, CompanyOperatingCosts costs)
{
var vars = System.Text.Json.JsonSerializer
.Deserialize<Dictionary<string, System.Text.Json.JsonElement>>(req.VariablesJson ?? "{}") ?? new();
void Inject(string key, decimal value)
{
if (!vars.ContainsKey(key))
vars[key] = System.Text.Json.JsonDocument.Parse(value.ToString("G")).RootElement.Clone();
}
Inject("standard_labor_rate", costs.StandardLaborRate);
Inject("additional_coat_labor_pct", costs.AdditionalCoatLaborPercent);
Inject("markup_pct", costs.GeneralMarkupPercentage);
return new EvaluateFormulaRequest
{
Formula = req.Formula,
VariablesJson = System.Text.Json.JsonSerializer.Serialize(vars)
};
}
/// <summary>
/// Calls Claude to generate a formula template from a natural-language description
/// and an optional diagram image uploaded in the same multipart form.
/// </summary>
[HttpPost]
public async Task<IActionResult> GenerateFormulaFromAi([FromForm] string description, IFormFile? diagramImage)
{
if (!AllowCustomFormulas()) return Json(new { success = false, error = "Custom Formulas are not available on your current plan." });
if (string.IsNullOrWhiteSpace(description))
return Json(new { success = false, error = "Description is required." });
byte[]? imageBytes = null;
string? imageContentType = null;
if (diagramImage is { Length: > 0 })
{
using var ms = new MemoryStream();
await diagramImage.CopyToAsync(ms);
imageBytes = ms.ToArray();
imageContentType = diagramImage.ContentType;
}
var result = await _formulaAiService.GenerateFormulaAsync(
new GenerateFormulaFromAiRequest { Description = description },
imageBytes,
imageContentType);
return Json(result);
}
/// <summary>
/// Validates field variable names in a fieldsJson array against NCalc identifier rules:
/// must start with a letter, contain only letters/digits/underscores, and not use the
/// reserved name "rate" (which is auto-populated from the template's Default Rate).
/// Returns an error message string on failure, or null if all names are valid.
/// </summary>
private static string? ValidateTemplateFields(string? fieldsJson)
{
if (string.IsNullOrWhiteSpace(fieldsJson)) return null;
List<System.Text.Json.JsonElement>? fields;
try
{
fields = System.Text.Json.JsonSerializer.Deserialize<List<System.Text.Json.JsonElement>>(fieldsJson);
}
catch { return "Invalid fields JSON."; }
if (fields == null) return null;
var nameRegex = new System.Text.RegularExpressions.Regex(@"^[a-zA-Z][a-zA-Z0-9_]*$");
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var field in fields)
{
var name = field.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? "" : "";
if (string.IsNullOrEmpty(name))
return "All fields must have a variable name.";
if (name == "rate")
return $"\"rate\" is a reserved variable name — it is pre-populated from the template's Default Rate.";
if (!nameRegex.IsMatch(name))
return $"Invalid field name \"{name}\": must start with a letter and contain only letters, digits, or underscores.";
if (!seen.Add(name))
return $"Duplicate field name \"{name}\".";
}
return null;
}
}
public record SaveTemplateJsonRequest(int Id, string? Subject, string? Body);
@@ -293,6 +293,48 @@ public class DataPurgeController : Controller
break;
case "Jobs":
// Collect IDs first so all FK cleanup targets the exact same set of jobs.
var purgingJobIds = await _db.Jobs.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff)
.Select(e => e.Id)
.ToListAsync();
if (purgingJobIds.Count > 0)
{
// Non-nullable FK children must be deleted before the parent row can go.
// ReworkRecord.JobId (Restrict), PowderUsageLog.JobId (NoAction),
// OvenBatchItem.JobId (NoAction) — cannot be nulled, so rows are removed.
await _db.ReworkRecords.IgnoreQueryFilters()
.Where(r => purgingJobIds.Contains(r.JobId))
.ExecuteDeleteAsync();
await _db.PowderUsageLogs.IgnoreQueryFilters()
.Where(l => purgingJobIds.Contains(l.JobId))
.ExecuteDeleteAsync();
await _db.OvenBatchItems.IgnoreQueryFilters()
.Where(i => purgingJobIds.Contains(i.JobId))
.ExecuteDeleteAsync();
// Nullable FKs with NO ACTION / RESTRICT — null them out so the DELETE
// does not violate the constraint. KioskSession and NotificationLog are
// excluded here because their FKs use SET NULL and the DB handles them.
await _db.Invoices.IgnoreQueryFilters()
.Where(i => i.JobId.HasValue && purgingJobIds.Contains(i.JobId.Value))
.ExecuteUpdateAsync(s => s.SetProperty(i => i.JobId, (int?)null));
await _db.Deposits.IgnoreQueryFilters()
.Where(d => d.JobId.HasValue && purgingJobIds.Contains(d.JobId.Value))
.ExecuteUpdateAsync(s => s.SetProperty(d => d.JobId, (int?)null));
await _db.Appointments.IgnoreQueryFilters()
.Where(a => a.JobId.HasValue && purgingJobIds.Contains(a.JobId.Value))
.ExecuteUpdateAsync(s => s.SetProperty(a => a.JobId, (int?)null));
await _db.BillLineItems.IgnoreQueryFilters()
.Where(b => b.JobId.HasValue && purgingJobIds.Contains(b.JobId.Value))
.ExecuteUpdateAsync(s => s.SetProperty(b => b.JobId, (int?)null));
await _db.Expenses.IgnoreQueryFilters()
.Where(e => e.JobId.HasValue && purgingJobIds.Contains(e.JobId.Value))
.ExecuteUpdateAsync(s => s.SetProperty(e => e.JobId, (int?)null));
await _db.InventoryTransactions.IgnoreQueryFilters()
.Where(t => t.JobId.HasValue && purgingJobIds.Contains(t.JobId.Value))
.ExecuteUpdateAsync(s => s.SetProperty(t => t.JobId, (int?)null));
}
count = await _db.Jobs.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break;
@@ -0,0 +1,142 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Community formula library — browse published formulas from all companies and import
/// them into the current company's local template list.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanViewData)]
public class FormulaLibraryController : Controller
{
private readonly IFormulaLibraryService _libraryService;
private readonly ITenantContext _tenantContext;
private readonly IMapper _mapper;
private readonly IAzureBlobStorageService _blobStorage;
public FormulaLibraryController(
IFormulaLibraryService libraryService,
ITenantContext tenantContext,
IMapper mapper,
IAzureBlobStorageService blobStorage)
{
_libraryService = libraryService;
_tenantContext = tenantContext;
_mapper = mapper;
_blobStorage = blobStorage;
}
/// <summary>Browse the community library with optional search and filter params.</summary>
// GET: /FormulaLibrary
public async Task<IActionResult> Index(
string? search = null,
string? outputMode = null,
string? industryHint = null)
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return RedirectToAction("Index", "Home");
var items = await _libraryService.BrowseAsync(companyId.Value, search, outputMode, industryHint);
ViewBag.Search = search;
ViewBag.OutputMode = outputMode;
ViewBag.IndustryHint = industryHint;
ViewBag.TotalCount = items.Count();
return View(items);
}
/// <summary>Returns full detail JSON for the import preview modal.</summary>
// GET: /FormulaLibrary/Detail/5
[HttpGet]
public async Task<IActionResult> Detail(int id)
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return Json(new { error = "No company context." });
var detail = await _libraryService.GetDetailAsync(id, companyId.Value);
if (detail == null) return NotFound();
return Json(detail);
}
/// <summary>
/// Serves a formula diagram image by blob storage path. Used for library cards where the
/// diagram belongs to another company's template blob container.
/// </summary>
// GET: /FormulaLibrary/Diagram?path=...
[HttpGet]
public async Task<IActionResult> Diagram(string path)
{
if (string.IsNullOrWhiteSpace(path)) return NotFound();
// Sanitize: path must not escape the blob container
if (path.Contains("..") || path.StartsWith("/") || path.StartsWith("\\"))
return BadRequest();
var (ok, bytes, contentType, _) = await _blobStorage.DownloadAsync("formulatemplate-diagrams", path);
if (!ok || bytes == null || bytes.Length == 0) return NotFound();
return File(bytes, contentType ?? "image/jpeg");
}
/// <summary>
/// Records or toggles a thumbs-up/down vote for the current company.
/// Returns updated counts so the UI can update without a page reload.
/// Companies cannot rate their own formulas; own-formula cards have no rating buttons.
/// </summary>
// POST: /FormulaLibrary/Rate
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Rate([FromBody] RateFormulaRequest request)
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return Json(new { success = false, message = "No company context." });
try
{
var (up, down, myVote) = await _libraryService.RateAsync(
request.LibraryItemId, companyId.Value, request.IsPositive);
return Json(new { success = true, thumbsUp = up, thumbsDown = down, myVote });
}
catch (InvalidOperationException ex)
{
return Json(new { success = false, message = ex.Message });
}
}
/// <summary>Imports a library entry as a new local template for the current company.</summary>
// POST: /FormulaLibrary/Import
[HttpPost]
[ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> Import(int libraryItemId)
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
return Json(new { success = false, message = "No company context." });
try
{
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
var templateId = await _libraryService.ImportAsync(libraryItemId, companyId.Value, userId);
return Json(new { success = true, templateId });
}
catch (Exception ex)
{
return Json(new { success = false, message = ex.Message });
}
}
}
/// <summary>Body for the Rate endpoint.</summary>
public class RateFormulaRequest
{
public int LibraryItemId { get; set; }
/// <summary>True = thumbs up, false = thumbs down.</summary>
public bool IsPositive { get; set; }
}
@@ -125,5 +125,23 @@ namespace PowderCoating.Web.Controllers
{
return View();
}
/// <summary>
/// Serves the Custom Formula Item Templates help article explaining how to create NCalc formula
/// templates, use the AI generator, and add formula items to quotes and jobs.
/// </summary>
public IActionResult CustomFormulaTemplates()
{
return View();
}
/// <summary>
/// Serves the Timeclock help article explaining clock in/out, multi-segment days, the kiosk tablet
/// mode with PIN authentication, manager edit/delete tools, and the Attendance report.
/// </summary>
public IActionResult Timeclock()
{
return View();
}
}
}
@@ -56,14 +56,16 @@ public class InventoryController : Controller
/// <summary>
/// Displays the paginated inventory list with optional keyword search, category filter,
/// and a low-stock quick-filter. When lowStockOnly is active the default sort switches
/// to QuantityOnHand ascending so the most depleted items surface immediately. Stats
/// (total value, active count, low-stock count) are computed directly on the DbSet
/// using aggregate SQL to avoid loading all rows into memory.
/// color family filter, and a low-stock quick-filter. When lowStockOnly is active the
/// default sort switches to QuantityOnHand ascending so the most depleted items surface
/// immediately. Stats (total value, active count, low-stock count) are computed directly
/// on the DbSet using aggregate SQL to avoid loading all rows into memory.
/// </summary>
public async Task<IActionResult> Index(
string? searchTerm,
string? category,
string? location,
string? colorFamily,
string? sortColumn,
string sortDirection = "asc",
bool lowStockOnly = false,
@@ -87,49 +89,34 @@ public class InventoryController : Controller
};
gridRequest.Validate();
// Build search and category filter
System.Linq.Expressions.Expression<Func<InventoryItem, bool>>? filter = null;
var hasSearch = !string.IsNullOrWhiteSpace(searchTerm);
var hasCategory = !string.IsNullOrWhiteSpace(category);
var hasLocation = !string.IsNullOrWhiteSpace(location);
var hasColorFamily = !string.IsNullOrWhiteSpace(colorFamily);
if (lowStockOnly && !string.IsNullOrWhiteSpace(searchTerm))
var search = searchTerm?.ToLower() ?? "";
var cat = category ?? "";
var loc = location ?? "";
var colorFam = colorFamily ?? "";
// Single composable predicate — EF Core evaluates the captured booleans as constants
// so inactive conditions fold to true and are omitted from the generated SQL WHERE clause.
System.Linq.Expressions.Expression<Func<InventoryItem, bool>>? filter = null;
if (hasSearch || hasCategory || hasLocation || hasColorFamily || lowStockOnly)
{
var search = searchTerm.ToLower();
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint
&& (i.SKU.ToLower().Contains(search)
|| i.Name.ToLower().Contains(search)
filter = i =>
(!lowStockOnly || (i.IsActive && i.QuantityOnHand <= i.ReorderPoint)) &&
(!hasSearch || (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|| (i.Description != null && i.Description.ToLower().Contains(search))
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)));
}
else if (lowStockOnly)
{
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint;
}
else if (!string.IsNullOrWhiteSpace(searchTerm) && !string.IsNullOrWhiteSpace(category))
{
// Both search and category filter
var search = searchTerm.ToLower();
var cat = category;
filter = i => (i.SKU.ToLower().Contains(search)
|| i.Name.ToLower().Contains(search)
|| (i.Description != null && i.Description.ToLower().Contains(search))
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)))
&& i.Category.ToLower() == cat.ToLower();
}
else if (!string.IsNullOrWhiteSpace(searchTerm))
{
// Search only
var search = searchTerm.ToLower();
filter = i => i.SKU.ToLower().Contains(search)
|| i.Name.ToLower().Contains(search)
|| (i.Description != null && i.Description.ToLower().Contains(search))
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search));
}
else if (!string.IsNullOrWhiteSpace(category))
{
// Category filter only
var cat = category;
filter = i => i.Category.ToLower() == cat.ToLower();
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)))) &&
(!hasCategory || i.Category.ToLower() == cat.ToLower()) &&
(!hasLocation || (i.Location != null && i.Location.ToLower() == loc.ToLower())) &&
(!hasColorFamily || (i.ColorFamilies != null && (
i.ColorFamilies == colorFam ||
i.ColorFamilies.StartsWith(colorFam + ",") ||
i.ColorFamilies.EndsWith("," + colorFam) ||
i.ColorFamilies.Contains("," + colorFam + ","))));
}
// Build orderBy function
@@ -159,19 +146,30 @@ public class InventoryController : Controller
var pagedResult = PagedResult<InventoryListDto>.From(gridRequest, itemDtos, totalCount);
// Load all items once to compute sidebar stats and category list in memory
// Load all items once to compute sidebar stats and dropdown option lists in memory
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allItems = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
ViewBag.Categories = allItems.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToList();
ViewBag.Locations = allItems.Select(i => i.Location).Where(l => !string.IsNullOrWhiteSpace(l)).Distinct().OrderBy(l => l).ToList();
ViewBag.ColorFamilies = allItems
.Where(i => !string.IsNullOrEmpty(i.ColorFamilies))
.SelectMany(i => i.ColorFamilies!.Split(',', StringSplitOptions.RemoveEmptyEntries))
.Select(f => f.Trim())
.Where(f => f.Length > 0)
.Distinct()
.OrderBy(f => f)
.ToList();
ViewBag.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
ViewBag.StatsActiveCount = allItems.Count(i => i.IsActive);
ViewBag.StatsTotalValue = allItems.Sum(i => (decimal?)i.QuantityOnHand * i.UnitCost) ?? 0m;
// Set ViewBag for sorting and filters
ViewBag.SearchTerm = searchTerm;
ViewBag.Category = category;
ViewBag.SearchTerm = searchTerm;
ViewBag.Category = category;
ViewBag.Location = location;
ViewBag.ColorFamily = colorFamily;
ViewBag.LowStockOnly = lowStockOnly;
ViewBag.SortColumn = gridRequest.SortColumn;
ViewBag.SortColumn = gridRequest.SortColumn;
ViewBag.SortDirection = gridRequest.SortDirection;
return View(pagedResult);
@@ -184,6 +182,26 @@ public class InventoryController : Controller
}
}
/// <summary>
/// Returns a print-optimised list of all active inventory items in a given bin/location.
/// Renders without the site chrome (no layout) so the browser print dialog produces a
/// clean page. Items are sorted by name within the bin.
/// </summary>
public async Task<IActionResult> PrintBin(string location)
{
if (string.IsNullOrWhiteSpace(location))
return RedirectToAction(nameof(Index));
var loc = location.Trim();
var items = await _unitOfWork.InventoryItems.FindAsync(
i => i.Location != null && i.Location.ToLower() == loc.ToLower());
var dtos = _mapper.Map<List<InventoryListDto>>(items.OrderBy(i => i.Name).ToList());
ViewBag.Location = loc;
ViewBag.PrintedAt = DateTime.Now;
return View(dtos);
}
/// <summary>
/// Renders the inventory item detail page. The primary vendor name is looked up
/// separately because the repository does not eager-load Vendor by default, avoiding
@@ -897,10 +915,31 @@ public class InventoryController : Controller
if (!string.IsNullOrWhiteSpace(qrUrl))
{
// QR path: fetch the product page; LookupByUrlAsync now maps all identity + spec fields
aiResult = await _aiLookupService.LookupByUrlAsync(qrUrl, null);
if (aiResult.Success && aiResult.SpecPageUrl == null)
aiResult.SpecPageUrl = qrUrl;
// QR path: try to extract product identity from the URL using manufacturer patterns.
// Prismatic Powders URLs embed both the SKU and color slug in the path
// (e.g. /shop/powder-coating-colors/PMB-6906/fire-red), so we can feed the full
// LookupAsync pipeline (Serper + direct URL + Claude) with real context instead of
// relying solely on a page fetch that may fail in production.
var activePatterns = (await _unitOfWork.ManufacturerLookupPatterns.GetAllAsync(ignoreQueryFilters: true))
.Where(p => p.IsActive).ToList();
var (urlMfr, urlColor, urlPart) = TryParseManufacturerUrl(qrUrl, activePatterns);
if (!string.IsNullOrWhiteSpace(urlMfr))
{
aiResult = await _aiLookupService.LookupAsync(urlMfr, urlColor, null, urlPart);
// The scanned QR URL is always the authoritative product page link — it came
// directly from the manufacturer's bag and is always fully-qualified. Overwrite
// whatever LookupAsync returned (which may be a scheme-less path from the template).
if (aiResult.Success)
aiResult.SpecPageUrl = qrUrl;
}
else
{
// No pattern match — fall back to URL-based lookup
aiResult = await _aiLookupService.LookupByUrlAsync(qrUrl, null);
if (aiResult.Success && aiResult.SpecPageUrl == null)
aiResult.SpecPageUrl = qrUrl;
}
}
else if (!string.IsNullOrWhiteSpace(imageBase64))
{
@@ -1183,12 +1222,16 @@ public class InventoryController : Controller
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Find the default coating category to assign
// Find the default coating category to assign.
// Prefer the canonical "POWDER" category (CategoryCode == "POWDER") so catalog-sourced
// items always land in the right bucket regardless of how many IsCoating categories exist.
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
var coatingCategory = categories
.Where(c => c.IsActive && c.IsCoating)
.OrderBy(c => c.DisplayOrder)
.FirstOrDefault();
var coatingCategory = categories.FirstOrDefault(c => c.IsActive && c.IsCoating
&& c.CategoryCode.Equals("POWDER", StringComparison.OrdinalIgnoreCase))
?? categories
.Where(c => c.IsActive && c.IsCoating)
.OrderBy(c => c.DisplayOrder)
.FirstOrDefault();
if (coatingCategory == null)
return Json(new { success = false, error = "No active coating category found. Please configure inventory categories first." });
@@ -1272,6 +1315,72 @@ public class InventoryController : Controller
return transferEfficiency ?? DefaultTransferEfficiency;
}
/// <summary>
/// Reverse-parses a scanned QR URL against known manufacturer URL templates to extract
/// product identity (manufacturer name, color name, part number). For example, a Prismatic
/// Powders URL like /shop/powder-coating-colors/PMB-6906/fire-red yields manufacturer
/// "Prismatic Powders", partNumber "PMB-6906", and colorName "Fire Red". Returns nulls
/// when no active pattern matches the URL domain.
/// </summary>
private static (string? manufacturer, string? colorName, string? partNumber) TryParseManufacturerUrl(
string url, IEnumerable<Core.Entities.ManufacturerLookupPattern> patterns)
{
Uri? parsed;
try { parsed = new Uri(url); } catch { return (null, null, null); }
var host = parsed.Host.Replace("www.", "", StringComparison.OrdinalIgnoreCase);
foreach (var pattern in patterns.Where(p => !string.IsNullOrEmpty(p.Domain) && !string.IsNullOrEmpty(p.ProductUrlTemplate)))
{
if (!host.Equals(pattern.Domain, StringComparison.OrdinalIgnoreCase)) continue;
var template = pattern.ProductUrlTemplate!;
var placeholderKeys = new[] { "{partNumber}", "{slug}", "{colorName}", "{colorCode}" };
// Find the first placeholder to split off the static prefix
var firstIdx = placeholderKeys
.Select(ph => template.IndexOf(ph, StringComparison.OrdinalIgnoreCase))
.Where(i => i >= 0)
.DefaultIfEmpty(-1)
.Min();
if (firstIdx < 0) continue;
var staticPrefix = template[..firstIdx].TrimEnd('/');
var fullUrl = url.TrimEnd('/');
if (!fullUrl.StartsWith(staticPrefix, StringComparison.OrdinalIgnoreCase)) continue;
var templateSegments = template[firstIdx..].Split('/', StringSplitOptions.RemoveEmptyEntries);
var urlSegments = fullUrl[staticPrefix.Length..].Split('/', StringSplitOptions.RemoveEmptyEntries);
var extracted = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
bool matched = true;
for (int i = 0; i < templateSegments.Length && i < urlSegments.Length; i++)
{
var seg = templateSegments[i];
if (seg.StartsWith("{") && seg.EndsWith("}"))
extracted[seg[1..^1]] = urlSegments[i];
else if (!seg.Equals(urlSegments[i], StringComparison.OrdinalIgnoreCase))
{ matched = false; break; }
}
if (!matched) continue;
extracted.TryGetValue("partNumber", out var partNumber);
var slug = extracted.TryGetValue("slug", out var s) ? s
: extracted.TryGetValue("colorName", out var cn) ? cn
: extracted.TryGetValue("colorCode", out var cc) ? cc : null;
// Convert URL slug to display name: "fire-red" → "Fire Red"
string? colorName = slug == null ? null
: string.Join(" ", slug.Split(new[] { '-', '_' }, StringSplitOptions.RemoveEmptyEntries)
.Select(w => w.Length > 0 ? char.ToUpperInvariant(w[0]) + w[1..].ToLowerInvariant() : w));
return (pattern.ManufacturerName, colorName, partNumber);
}
return (null, null, null);
}
/// <summary>
/// Normalizes a string to title-case using the current culture's TextInfo. Applied to
/// inventory item names on create and edit so the list view is consistently formatted
@@ -1371,8 +1480,20 @@ public class InventoryController : Controller
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
ViewBag.AiInventoryAssistEnabled = await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId);
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId);
ViewBag.Vendors = new SelectList(vendors.Where(s => s.IsActive).OrderBy(s => s.CompanyName), "Id", "CompanyName");
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.IsActive, false, v => v.Categories))
.OrderBy(v => v.CompanyName).ToList();
ViewBag.Vendors = new SelectList(vendors, "Id", "CompanyName");
// Build {categoryId: [vendorId, ...]} so the inventory form can filter vendors by category
var categoryVendorMap = new Dictionary<string, List<int>>();
foreach (var v in vendors)
foreach (var cat in v.Categories)
{
var key = cat.Id.ToString();
if (!categoryVendorMap.ContainsKey(key)) categoryVendorMap[key] = new List<int>();
categoryVendorMap[key].Add(v.Id);
}
ViewBag.CategoryVendorMapJson = System.Text.Json.JsonSerializer.Serialize(categoryVendorMap);
// Load categories from lookup table
var allCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
@@ -1499,11 +1620,12 @@ public class InventoryController : Controller
/// Renders a print-optimised label for the inventory item containing the QR code,
/// item name, SKU, and colour. Designed to be printed directly from the browser.
/// </summary>
public async Task<IActionResult> Label(int? id)
public async Task<IActionResult> Label(int? id, bool embed = false)
{
if (id == null) return NotFound();
var item = await _unitOfWork.InventoryItems.GetByIdAsync(id.Value);
if (item == null) return NotFound();
ViewBag.IsEmbed = embed;
return View(_mapper.Map<InventoryItemDto>(item));
}
@@ -355,6 +355,15 @@ public class InvoicesController : Controller
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId.Value, false, j => j.Customer, j => j.JobItems);
if (job == null) return NotFound();
// Pre-load coats so we can derive color names for invoice line items
var activeItemIds = job.JobItems.Where(ji => !ji.IsDeleted).Select(ji => ji.Id).ToList();
var allCoats = activeItemIds.Any()
? (await _unitOfWork.JobItemCoats.FindAsync(c => activeItemIds.Contains(c.JobItemId) && !c.IsDeleted)).ToList()
: new List<JobItemCoat>();
var coatsByItem = allCoats
.GroupBy(c => c.JobItemId)
.ToDictionary(g => g.Key, g => g.OrderBy(c => c.Sequence).ToList());
// Validate no existing active invoice for this job (voided ones are kept as history)
var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId.Value);
if (existing != null && existing.Status != InvoiceStatus.Voided)
@@ -404,6 +413,16 @@ public class InvoicesController : Controller
revenueAccountId = ci.RevenueAccountId;
revenueAccountId ??= defaultRevenueAccount?.Id;
// Derive color from coats when the item itself has no explicit color set
var derivedColor = item.ColorName;
if (string.IsNullOrEmpty(derivedColor) && coatsByItem.TryGetValue(item.Id, out var itemCoats))
{
var coatColors = itemCoats
.Where(c => !string.IsNullOrEmpty(c.ColorName))
.Select(c => c.ColorName!);
derivedColor = string.Join(" / ", coatColors);
}
dto.InvoiceItems.Add(new CreateInvoiceItemDto
{
SourceJobItemId = item.Id,
@@ -412,7 +431,7 @@ public class InvoicesController : Controller
Quantity = item.Quantity > 0 ? item.Quantity : 1,
UnitPrice = item.UnitPrice,
TotalPrice = item.TotalPrice,
ColorName = item.ColorName,
ColorName = derivedColor,
Notes = item.Notes,
DisplayOrder = order++,
RevenueAccountId = revenueAccountId
@@ -445,16 +464,22 @@ public class InvoicesController : Controller
// If the job came from a quote, carry over the quote-level costs and agreed terms.
// The quote SubTotal = sum(items) + oven batch cost + shop supplies.
// Job items only capture per-item prices, so oven & shop supplies need a separate line.
// Read directly from the quote snapshot — never try to reverse-engineer from job.FinalPrice
// because FinalPrice is recalculated on every item edit and can drift from the original quote.
// For fee components, prefer the job's own breakdown snapshot (updated every time the job
// is saved) over the source quote — the quote's FacilityOverheadCost was only added in
// migration AddQuotePricingSnapshotFields (May 2026); older quotes have 0 there even though
// overhead was included in the quote total. Tax and discount still come from the quote
// because those represent the customer-approved agreed terms.
if (sourceQuote != null)
{
// Bundle all quote-level charges so the invoice subtotal matches the quote total.
// FacilityOverheadCost is included — it is a real cost baked into the quoted price.
var processingFees = sourceQuote.OvenBatchCost
+ sourceQuote.FacilityOverheadCost
+ sourceQuote.ShopSuppliesAmount
+ sourceQuote.RushFee;
// Prefer job breakdown values for dynamic fee components; fall back to quote for
// compatibility with jobs that were never re-saved after the May 2026 migration.
var ovenCost = jobBreakdown != null ? jobBreakdown.OvenBatchCost : sourceQuote.OvenBatchCost;
var overhead = jobBreakdown != null ? jobBreakdown.FacilityOverheadCost : sourceQuote.FacilityOverheadCost;
var shopSupplies = jobBreakdown != null ? jobBreakdown.ShopSuppliesAmount : sourceQuote.ShopSuppliesAmount;
var rushFee = jobBreakdown != null ? jobBreakdown.RushFee : sourceQuote.RushFee;
// Bundle all quote-level charges so the invoice subtotal matches the job total.
var processingFees = ovenCost + overhead + shopSupplies + rushFee;
if (processingFees > 0.01m)
{
@@ -1461,6 +1486,7 @@ public class InvoicesController : Controller
}
var currentUser = await _userManager.GetUserAsync(User);
var totalCreditCreated = 0m; // populated inside transaction, used in success message
await _unitOfWork.ExecuteInTransactionAsync(async () =>
{
@@ -1480,6 +1506,75 @@ public class InvoicesController : Controller
await _unitOfWork.Payments.SoftDeleteAsync(payment.Id);
}
// Re-release any deposits that were applied to this invoice so they can be
// auto-applied to the replacement invoice. Without this, AppliedToInvoiceId
// stays set and the deposit lookup (AppliedToInvoiceId == null) skips them.
var appliedDeposits = await _unitOfWork.Deposits.FindAsync(
d => d.AppliedToInvoiceId == invoice.Id && !d.IsDeleted);
var totalDepositReleased = 0m;
foreach (var deposit in appliedDeposits)
{
deposit.AppliedToInvoiceId = null;
deposit.AppliedDate = null;
deposit.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Deposits.UpdateAsync(deposit);
totalDepositReleased += deposit.Amount;
}
// Restore the CustomerDeposits 2300 liability that was cleared when the deposits
// were applied. Mirrors the DR at apply time; follows the same simplified reversal
// pattern as the rest of the void (regular payment GL entries are also left as-is).
if (totalDepositReleased > 0)
{
var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(invoice.CompanyId);
await _accountBalanceService.CreditAsync(custDepositsAcctId, totalDepositReleased);
}
// Convert non-deposit payments (cash, card, check, online) to customer credits so
// the money isn't lost when the invoice is voided. Each payment becomes a CRED-
// Deposit record linked to the same job; it will auto-apply when the replacement
// invoice is created, exactly like a normal deposit.
var nonDepositPayments = invoice.Payments
.Where(p => !p.IsDeleted && !(p.Reference ?? "").StartsWith("Deposit "))
.ToList();
if (nonDepositPayments.Any())
{
var credPrefix = $"CRED-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-";
var existingNums = (await _unitOfWork.Deposits.FindAsync(
d => d.CompanyId == invoice.CompanyId && d.ReceiptNumber.StartsWith(credPrefix),
ignoreQueryFilters: true))
.Select(d => d.ReceiptNumber).ToList();
var maxNum = 0;
foreach (var rn in existingNums)
{
var suffix = rn.Length >= credPrefix.Length + 4 ? rn[credPrefix.Length..] : "";
if (int.TryParse(suffix, out int parsed) && parsed > maxNum) maxNum = parsed;
}
var creditCustDepositsAcctId = await GetCustomerDepositsAccountIdAsync(invoice.CompanyId);
foreach (var payment in nonDepositPayments)
{
maxNum++;
await _unitOfWork.Deposits.AddAsync(new Core.Entities.Deposit
{
CompanyId = invoice.CompanyId,
CustomerId = invoice.CustomerId,
JobId = invoice.JobId,
Amount = payment.Amount,
PaymentMethod = payment.PaymentMethod,
ReceivedDate = payment.PaymentDate,
Reference = payment.Reference,
Notes = $"Credit from voided invoice {invoice.InvoiceNumber}" +
(string.IsNullOrWhiteSpace(payment.Notes) ? "." : $". Original: {payment.Notes}"),
ReceiptNumber = $"{credPrefix}{maxNum:D4}",
CreatedAt = DateTime.UtcNow
});
totalCreditCreated += payment.Amount;
}
// CR CustomerDeposits to create the liability matching the cash already in Checking
await _accountBalanceService.CreditAsync(creditCustDepositsAcctId, totalCreditCreated);
}
// Void any gift certificates that were generated from this invoice.
// Capture each GC's remaining balance BEFORE voiding so the GL entries below can use it.
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(invoice.CompanyId);
@@ -1531,7 +1626,10 @@ public class InvoicesController : Controller
}); // end ExecuteInTransactionAsync
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} has been voided.";
var creditMsg = totalCreditCreated > 0
? $" {totalCreditCreated:C} converted to customer credit and will auto-apply to the next invoice."
: "";
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} has been voided.{creditMsg}";
return RedirectToAction(nameof(Details), new { id });
}
catch (Exception ex)
@@ -1690,6 +1788,59 @@ public class InvoicesController : Controller
}
}
// -----------------------------------------------------------------------
// GET: /Invoices/DownloadPackingSlip/5
// -----------------------------------------------------------------------
/// <summary>
/// Generates a no-price packing slip PDF for physical pickup/delivery paperwork.
/// Reuses the same company branding and invoice data pipeline as DownloadPdf but
/// delegates to GeneratePackingSlipPdfAsync which omits all pricing columns.
/// </summary>
public async Task<IActionResult> DownloadPackingSlip(int? id, bool inline = false)
{
if (id == null) return NotFound();
try
{
var invoice = await LoadInvoiceForViewAsync(id.Value);
if (invoice == null) return NotFound();
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
var company = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
var companyInfo = new Application.DTOs.Company.CompanyInfoDto
{
CompanyName = company?.CompanyName ?? string.Empty,
Phone = company?.Phone,
Address = company?.Address,
City = company?.City,
State = company?.State,
ZipCode = company?.ZipCode,
PrimaryContactEmail = company?.PrimaryContactEmail
};
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
var dto = await BuildInvoiceDtoAsync(invoice);
var pdfBytes = await _pdfService.GeneratePackingSlipPdfAsync(dto, logoData, logoContentType, companyInfo);
var fileName = $"PackingSlip-{invoice.InvoiceNumber}.pdf";
if (inline)
{
Response.Headers["Content-Disposition"] = $"inline; filename=\"{fileName}\"";
return File(pdfBytes, "application/pdf");
}
return File(pdfBytes, "application/pdf", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating packing slip for invoice {Id}", id);
TempData["ErrorPermanent"] = $"Packing slip generation failed: {ex.Message}";
return RedirectToAction(nameof(Details), new { id });
}
}
// -----------------------------------------------------------------------
// GET: /Invoices/ForJob/5 — redirect to existing or Create
// -----------------------------------------------------------------------
@@ -3016,6 +3167,50 @@ public class InvoicesController : Controller
return false;
}
/// <summary>
/// Inline-edits description, quantity, and unit price on a single invoice line item.
/// Blocked on paid/voided invoices (same gate as the full Edit action).
/// Returns updated totals so the page can reflect the change without a reload.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> PatchItem([FromBody] PatchInvoiceItemRequest request)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
var item = await _unitOfWork.InvoiceItems.GetByIdAsync(request.ItemId);
if (item == null) return NotFound();
var invoice = await _unitOfWork.Invoices.GetByIdAsync(item.InvoiceId);
if (invoice == null || invoice.CompanyId != currentUser.CompanyId) return NotFound();
if (invoice.Status is not (InvoiceStatus.Draft or InvoiceStatus.Sent or InvoiceStatus.Overdue))
return BadRequest(new { error = "Cannot edit items on a paid or voided invoice." });
item.Description = request.Description.Trim();
item.Quantity = request.Quantity;
item.UnitPrice = request.UnitPrice;
item.TotalPrice = Math.Round(request.Quantity * request.UnitPrice, 2);
await _unitOfWork.InvoiceItems.UpdateAsync(item);
var allItems = await _unitOfWork.InvoiceItems.FindAsync(ii => ii.InvoiceId == invoice.Id);
var newSubTotal = allItems.Sum(i => i.TotalPrice);
invoice.SubTotal = newSubTotal;
invoice.TaxAmount = Math.Round(newSubTotal * invoice.TaxPercent / 100m, 2);
invoice.Total = Math.Round(newSubTotal - invoice.DiscountAmount + invoice.TaxAmount, 2);
await _unitOfWork.Invoices.UpdateAsync(invoice);
await _unitOfWork.CompleteAsync();
return Json(new {
lineTotal = item.TotalPrice,
subtotal = invoice.SubTotal,
taxAmount = invoice.TaxAmount,
total = invoice.Total,
balanceDue = invoice.BalanceDue
});
}
/// <summary>
/// Returns logo bytes and content type for PDF generation.
/// Prefers blob-stored logos (LogoFilePath) over the legacy DB column (LogoData).
@@ -3031,3 +3226,11 @@ public class InvoicesController : Controller
return (company.LogoData, company.LogoContentType);
}
}
public class PatchInvoiceItemRequest
{
public int ItemId { get; set; }
public string Description { get; set; } = string.Empty;
public decimal Quantity { get; set; }
public decimal UnitPrice { get; set; }
}
@@ -110,6 +110,11 @@ public class JobsController : Controller
{
try
{
// Default landing view: On Floor — redirect bare /Jobs to ?statusGroup=active
// so completed/cancelled jobs don't clutter the first screen.
if (string.IsNullOrEmpty(statusGroup) && string.IsNullOrEmpty(searchTerm) && string.IsNullOrEmpty(tagFilter))
return RedirectToAction("Index", new { statusGroup = "active" });
// Create and validate grid request
var gridRequest = new GridRequest
{
@@ -141,6 +146,13 @@ public class JobsController : Controller
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled;
}
else if (statusGroup == "completed")
{
filter = j => j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Completed
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered;
}
// "all" or unknown group: no filter applied (show every status)
}
else if (!string.IsNullOrWhiteSpace(searchTerm))
{
@@ -195,6 +207,27 @@ public class JobsController : Controller
gridRequest, jobDtos,
string.IsNullOrWhiteSpace(tagFilter) ? totalCount : jobDtos.Count);
// Pill badge counts — always global (not scoped to current filter/page)
var today = DateTime.Today;
ViewBag.AllJobCount = await _unitOfWork.Jobs.CountAsync();
ViewBag.ActiveCount = await _unitOfWork.Jobs.CountAsync(j =>
j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled);
ViewBag.OverdueCount = await _unitOfWork.Jobs.CountAsync(j =>
j.DueDate < today
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled);
ViewBag.CompletedCount = await _unitOfWork.Jobs.CountAsync(j =>
j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Completed
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered);
ViewBag.ReadyCount = await _unitOfWork.Jobs.CountAsync(j =>
j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup);
// Set ViewBag for sorting
ViewBag.SearchTerm = searchTerm;
ViewBag.StatusGroup = statusGroup;
@@ -456,9 +489,12 @@ public class JobsController : Controller
manualUnitPrice = ji.ManualUnitPrice ?? (ji.IsGenericItem || ji.IsSalesItem ? ji.UnitPrice : (decimal?)null),
powderCostOverride = ji.PowderCostOverride,
isGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && !ji.Coats.Any() && !ji.IsSalesItem),
isLaborItem = ji.IsLaborItem,
isSalesItem = ji.IsSalesItem,
isAiItem = ji.IsAiItem,
isLaborItem = ji.IsLaborItem,
isSalesItem = ji.IsSalesItem,
isAiItem = ji.IsAiItem,
isCustomFormulaItem = ji.IsCustomFormulaItem,
customItemTemplateId = ji.CustomItemTemplateId,
formulaFieldValuesJson = ji.FormulaFieldValuesJson,
sku = ji.Sku,
requiresSandblasting = ji.RequiresSandblasting,
requiresMasking = ji.RequiresMasking,
@@ -1140,6 +1176,26 @@ public class JobsController : Controller
}
}
// Option B: auto-add Custom Powder Order item on first save if not already present
var allCreateItems = dto.JobItems.ToList();
if (!allCreateItems.Any(d => d.IsGenericItem && d.Description?.StartsWith("Custom Powder Order") == true))
{
var powderDto = await BuildCustomPowderOrderDto(allCreateItems);
if (powderDto != null)
{
var pp = new QuoteItemPricingResult
{
UnitPrice = powderDto.ManualUnitPrice!.Value, TotalPrice = powderDto.ManualUnitPrice!.Value,
ItemSubtotal = powderDto.ManualUnitPrice!.Value, MaterialCost = powderDto.ManualUnitPrice!.Value,
LaborCost = 0, EquipmentCost = 0
};
var pi = _jobItemAssemblyService.CreateJobItem(powderDto, job.Id, companyId, pp, DateTime.UtcNow);
await _unitOfWork.JobItems.AddAsync(pi);
await _unitOfWork.SaveChangesAsync();
allCreateItems.Add(powderDto);
}
}
// Recalculate total from wizard items
var createCosts = await _pricingService.GetOperatingCostsAsync(companyId);
decimal? createOvenRate = null;
@@ -1150,7 +1206,7 @@ public class JobsController : Controller
createOvenRate = createOven.CostPerHour;
}
var totals = await _pricingService.CalculateQuoteTotalsAsync(
dto.JobItems, companyId, dto.CustomerId,
allCreateItems, companyId, dto.CustomerId,
await GetEffectiveTaxPercentAsync(dto.CustomerId, createCosts?.TaxPercent ?? 0m),
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, createOvenRate, dto.OvenBatches > 0 ? dto.OvenBatches : 1, dto.OvenCycleMinutes);
@@ -1246,9 +1302,12 @@ public class JobsController : Controller
CatalogItemId = ji.CatalogItemId,
ManualUnitPrice = ji.ManualUnitPrice ?? (ji.IsGenericItem ? ji.UnitPrice : null),
PowderCostOverride = ji.PowderCostOverride,
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
IsLaborItem = ji.IsLaborItem,
IsAiItem = ji.IsAiItem,
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
IsLaborItem = ji.IsLaborItem,
IsAiItem = ji.IsAiItem,
IsCustomFormulaItem = ji.IsCustomFormulaItem,
CustomItemTemplateId = ji.CustomItemTemplateId,
FormulaFieldValuesJson = ji.FormulaFieldValuesJson,
RequiresSandblasting = ji.RequiresSandblasting,
RequiresMasking = ji.RequiresMasking,
Notes = ji.Notes,
@@ -1361,6 +1420,26 @@ public class JobsController : Controller
}
}
// Option B: auto-add Custom Powder Order item on first save if not already present
var allEditItems = dto.JobItems.ToList();
if (!allEditItems.Any(d => d.IsGenericItem && d.Description?.StartsWith("Custom Powder Order") == true))
{
var powderDto = await BuildCustomPowderOrderDto(allEditItems);
if (powderDto != null)
{
var pp = new QuoteItemPricingResult
{
UnitPrice = powderDto.ManualUnitPrice!.Value, TotalPrice = powderDto.ManualUnitPrice!.Value,
ItemSubtotal = powderDto.ManualUnitPrice!.Value, MaterialCost = powderDto.ManualUnitPrice!.Value,
LaborCost = 0, EquipmentCost = 0
};
var pi = _jobItemAssemblyService.CreateJobItem(powderDto, id, companyId, pp, DateTime.UtcNow);
await _unitOfWork.JobItems.AddAsync(pi);
await _unitOfWork.SaveChangesAsync();
allEditItems.Add(powderDto);
}
}
// Now load and update the job itself
var job = await _unitOfWork.Jobs.GetByIdAsync(id);
if (job == null)
@@ -1614,7 +1693,7 @@ public class JobsController : Controller
}
// Recalculate FinalPrice from wizard items
if (dto.JobItems.Any())
if (allEditItems.Any())
{
var editCosts = await _pricingService.GetOperatingCostsAsync(companyId);
decimal? editOvenRate = null;
@@ -1625,7 +1704,7 @@ public class JobsController : Controller
editOvenRate = editOven.CostPerHour;
}
var totals = await _pricingService.CalculateQuoteTotalsAsync(
dto.JobItems, companyId, dto.CustomerId,
allEditItems, companyId, dto.CustomerId,
await GetEffectiveTaxPercentAsync(dto.CustomerId, editCosts?.TaxPercent ?? 0m),
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, editOvenRate, dto.OvenBatches > 0 ? dto.OvenBatches : 1, dto.OvenCycleMinutes);
job.FinalPrice = totals.Total;
@@ -1819,6 +1898,33 @@ public class JobsController : Controller
{
ViewBag.AiPhotoQuotesEnabled = await _subscriptionService.CanUseAiPhotoQuoteAsync(companyId);
var allowFormulas = HttpContext.Items["AllowCustomFormulas"] as bool? ?? false;
if (allowFormulas)
{
var formulaTemplates = await _unitOfWork.CustomItemTemplates.FindAsync(
t => t.CompanyId == companyId && t.IsActive);
ViewBag.CustomFormulaTemplates = formulaTemplates
.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
.Select(t => new
{
id = t.Id,
name = t.Name,
description = t.Description,
outputMode = t.OutputMode,
fieldsJson = t.FieldsJson,
formula = t.Formula,
defaultRate = t.DefaultRate,
rateLabel = t.RateLabel,
diagramImagePath = string.IsNullOrEmpty(t.DiagramImagePath)
? (string?)null
: Url.Action("TemplateDiagram", "CompanySettings", new { templateId = t.Id })
}).ToList();
}
else
{
ViewBag.CustomFormulaTemplates = new List<object>();
}
await PopulateDropdowns();
await PopulatePrepServicesAsync(companyId);
var costs = await _pricingService.GetOperatingCostsAsync(companyId);
@@ -2374,6 +2480,28 @@ public class JobsController : Controller
});
}
// When a rework job reaches a terminal status, close out the linked ReworkRecord
// on the original job so the shop doesn't have to do it manually.
// Cancelled → WrittenOff; any other terminal → Resolved.
if (newStatus?.IsTerminalStatus == true && job.IsReworkJob)
{
var linkedRecords = await _unitOfWork.ReworkRecords.FindAsync(
r => r.ReworkJobId == job.Id && r.CompanyId == job.CompanyId, false);
foreach (var rr in linkedRecords)
{
if (rr.Status == ReworkStatus.Resolved || rr.Status == ReworkStatus.WrittenOff)
continue;
rr.Status = newStatus.StatusCode == AppConstants.StatusCodes.Job.Cancelled
? ReworkStatus.WrittenOff
: ReworkStatus.Resolved;
rr.ResolvedDate ??= DateTime.UtcNow;
rr.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.ReworkRecords.UpdateAsync(rr);
}
if (linkedRecords.Any())
await _unitOfWork.SaveChangesAsync();
}
// Notify customer on status change (only if user opted in)
if (request.SendEmail && newStatus != null)
{
@@ -2926,9 +3054,12 @@ public class JobsController : Controller
CatalogItemId = ji.CatalogItemId,
ManualUnitPrice = ji.ManualUnitPrice ?? (ji.IsGenericItem ? ji.UnitPrice : null),
PowderCostOverride = ji.PowderCostOverride,
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
IsLaborItem = ji.IsLaborItem,
IsAiItem = ji.IsAiItem,
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
IsLaborItem = ji.IsLaborItem,
IsAiItem = ji.IsAiItem,
IsCustomFormulaItem = ji.IsCustomFormulaItem,
CustomItemTemplateId = ji.CustomItemTemplateId,
FormulaFieldValuesJson = ji.FormulaFieldValuesJson,
RequiresSandblasting = ji.RequiresSandblasting,
RequiresMasking = ji.RequiresMasking,
Notes = ji.Notes,
@@ -3044,6 +3175,26 @@ public class JobsController : Controller
}
}
// Option B: auto-add Custom Powder Order item on first save if not already present
var allUpdateItems = model.JobItems.ToList();
if (!allUpdateItems.Any(d => d.IsGenericItem && d.Description?.StartsWith("Custom Powder Order") == true))
{
var powderDto = await BuildCustomPowderOrderDto(allUpdateItems);
if (powderDto != null)
{
var pp = new QuoteItemPricingResult
{
UnitPrice = powderDto.ManualUnitPrice!.Value, TotalPrice = powderDto.ManualUnitPrice!.Value,
ItemSubtotal = powderDto.ManualUnitPrice!.Value, MaterialCost = powderDto.ManualUnitPrice!.Value,
LaborCost = 0, EquipmentCost = 0
};
var pi = _jobItemAssemblyService.CreateJobItem(powderDto, job.Id, currentUser.CompanyId, pp, DateTime.UtcNow);
await _unitOfWork.JobItems.AddAsync(pi);
await _unitOfWork.SaveChangesAsync();
allUpdateItems.Add(powderDto);
}
}
// Calculate full total (overhead, margins, tax) matching what Details shows
decimal? ovenRateOverride = null;
if (job.OvenCostId.HasValue)
@@ -3054,7 +3205,7 @@ public class JobsController : Controller
}
var updateCosts = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
var totals = await _pricingService.CalculateQuoteTotalsAsync(
model.JobItems, currentUser.CompanyId, job.CustomerId,
allUpdateItems, currentUser.CompanyId, job.CustomerId,
await GetEffectiveTaxPercentAsync(job.CustomerId, updateCosts?.TaxPercent ?? 0m),
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
ovenRateOverride, job.OvenBatches, job.OvenCycleMinutes);
@@ -3117,10 +3268,13 @@ public class JobsController : Controller
SurfaceAreaSqFt = ji.SurfaceAreaSqFt,
EstimatedMinutes = ji.EstimatedMinutes,
CatalogItemId = ji.CatalogItemId,
IsGenericItem = ji.IsGenericItem,
IsLaborItem = ji.IsLaborItem,
IsSalesItem = ji.IsSalesItem,
IsAiItem = ji.IsAiItem,
IsGenericItem = ji.IsGenericItem,
IsLaborItem = ji.IsLaborItem,
IsSalesItem = ji.IsSalesItem,
IsAiItem = ji.IsAiItem,
IsCustomFormulaItem = ji.IsCustomFormulaItem,
CustomItemTemplateId = ji.CustomItemTemplateId,
FormulaFieldValuesJson = ji.FormulaFieldValuesJson,
ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null),
IncludePrepCost = ji.IncludePrepCost,
Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto
@@ -3254,6 +3408,21 @@ public class JobsController : Controller
var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.UseMetric = useMetric;
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
var allowFormulas2 = HttpContext.Items["AllowCustomFormulas"] as bool? ?? false;
if (allowFormulas2)
{
var formulaTemplates = await _unitOfWork.CustomItemTemplates.FindAsync(t => t.CompanyId == companyId && t.IsActive);
ViewBag.CustomFormulaTemplates = formulaTemplates.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
.Select(t => new { id = t.Id, name = t.Name, description = t.Description, outputMode = t.OutputMode,
fieldsJson = t.FieldsJson, formula = t.Formula, defaultRate = t.DefaultRate, rateLabel = t.RateLabel,
diagramImagePath = string.IsNullOrEmpty(t.DiagramImagePath) ? null
: Url.Action("TemplateDiagram", "CompanySettings", new { templateId = t.Id }) }).ToList();
}
else
{
ViewBag.CustomFormulaTemplates = new List<object>();
}
}
/// <summary>
@@ -3289,6 +3458,66 @@ public class JobsController : Controller
return companyDefaultRate;
}
/// <summary>
/// Builds a "Custom Powder Order" DTO by aggregating all powder-to-order costs across the
/// submitted items. Returns null when no qualifying coats are present.
///
/// Two coat types qualify:
/// - Custom powder: no InventoryItemId, PowderToOrder &gt; 0, PowderCostPerLb &gt; 0
/// - Incoming powder: InventoryItemId set, inventoryItem.IsIncoming == true, PowderToOrder &gt; 0
/// (PowderCostPerLb is null for incoming powder — cost comes from inventoryItem.UnitCost)
/// </summary>
private async Task<CreateQuoteItemDto?> BuildCustomPowderOrderDto(IEnumerable<CreateQuoteItemDto> itemDtos)
{
var colorNames = new List<string>();
decimal totalCost = 0m;
foreach (var dto in itemDtos)
{
if (dto.Coats == null) continue;
foreach (var coat in dto.Coats)
{
if (!coat.InventoryItemId.HasValue &&
coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 &&
coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
{
// Custom powder: no inventory link, user entered cost per lb manually
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)
{
// Incoming powder: catalog-selected; PowderCostPerLb was cleared after incoming
// inventory item was created, so cost comes from inventoryItem.UnitCost
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 CreateQuoteItemDto
{
Description = description,
Quantity = 1,
IsGenericItem = true,
ManualUnitPrice = totalCost
};
}
private static QuotePricingBreakdownDto BuildPricingSnapshotDto(QuotePricingResult pr) =>
new QuotePricingBreakdownDto
{
@@ -3495,10 +3724,13 @@ public class JobsController : Controller
}
/// <summary>
/// Records a rework event against a job item (e.g. defect found during QC).
/// Automatically creates a new linked rework Job so the repair work can be tracked
/// through the same job lifecycle. The rework job inherits the original job's customer,
/// oven, and items so the shop has a complete specification to work from.
/// Records a rework event against a job. Optionally creates a linked rework job so the
/// repair can flow through the full shop lifecycle. When creating a rework job:
/// - Job number uses sub-number format: {parentNumber}-R{n} (e.g. JOB-2605-0007-R1)
/// - Only items selected by the user are copied (partial rework support)
/// - Pricing obeys the ReworkPricingType: ShopFault zeros all item prices;
/// CustomerReduced/CustomerFull copy prices as-is (user edits after if needed)
/// - Job starts at the first non-Pending status in the company's workflow
/// </summary>
[HttpPost]
public async Task<IActionResult> AddReworkRecord([FromBody] CreateReworkRecordDto dto)
@@ -3507,95 +3739,207 @@ public class JobsController : Controller
if (job == null) return NotFound();
var companyId = job.CompanyId;
Job? reworkJob = null;
// Generate rework job number
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First();
var allJobs = await _unitOfWork.Jobs.GetAllAsync(true);
var year = DateTime.Now.ToString("yy");
var month = DateTime.Now.ToString("MM");
var prefix = $"JOB-{year}{month}-";
var maxNum = allJobs
.Where(j => j.JobNumber.StartsWith(prefix))
.Select(j => { int.TryParse(j.JobNumber.Replace(prefix, ""), out int n); return n; })
.DefaultIfEmpty(0).Max();
var reworkJob = pendingStatus != null ? new Job
if (dto.CreateReworkJob && dto.ReworkJobItemIds != null && dto.ReworkJobItemIds.Count > 0 && dto.ReworkPricingType.HasValue)
{
JobNumber = $"{prefix}{(maxNum + 1):D4}",
CustomerId = job.CustomerId,
Description = $"REWORK: {job.Description}",
JobStatusId = pendingStatus.Id,
JobPriorityId = normalPriority.Id,
IsReworkJob = true,
OriginalJobId = job.Id,
SpecialInstructions = $"Rework of {job.JobNumber}.",
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
} : null;
if (reworkJob != null)
{
await _unitOfWork.Jobs.AddAsync(reworkJob);
await _unitOfWork.CompleteAsync();
// Copy items: specific item if flagged, otherwise all items
var itemsToCopy = dto.JobItemId.HasValue
? job.JobItems.Where(i => i.Id == dto.JobItemId.Value).ToList()
: job.JobItems.ToList();
foreach (var item in itemsToCopy)
var typeLabel = dto.ReworkType switch
{
var createdAtUtc = DateTime.UtcNow;
var newItem = _jobItemAssemblyService.CreateJobItem(item, reworkJob.Id, companyId, createdAtUtc);
ReworkType.InternalDefect => "Internal Defect",
ReworkType.CustomerWarranty => "Customer Warranty",
ReworkType.CustomerDamage => "Customer Damage",
_ => dto.ReworkType.ToString()
};
var reasonLabel = dto.Reason switch
{
ReworkReason.AdhesionFailure => "Adhesion Failure",
ReworkReason.Contamination => "Contamination",
ReworkReason.ColorMismatch => "Color Mismatch",
ReworkReason.RunsSags => "Runs / Sags",
ReworkReason.SurfacePrepFailure => "Surface Prep Failure",
ReworkReason.OvenIssue => "Oven Issue",
ReworkReason.InsufficientCoverage => "Insufficient Coverage",
ReworkReason.HandlingDamage => "Handling Damage",
_ => "Other"
};
var pricingLabel = dto.ReworkPricingType.Value switch
{
ReworkPricingType.ShopFault => "Shop Fault — no charge",
ReworkPricingType.CustomerReduced => "Customer responsible — reduced rate",
ReworkPricingType.CustomerFull => "Customer responsible — full price",
_ => ""
};
var defect = string.IsNullOrWhiteSpace(dto.DefectDescription) ? "" : $": {dto.DefectDescription}";
var reworkDescription = $"REWORK ({typeLabel} / {reasonLabel}){defect}. Pricing: {pricingLabel}.";
await _unitOfWork.JobItems.AddAsync(newItem);
await _unitOfWork.CompleteAsync();
foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(item, newItem.Id, companyId, createdAtUtc))
{
await _unitOfWork.JobItemCoats.AddAsync(coat);
}
foreach (var prepService in _jobItemAssemblyService.CreateJobItemPrepServices(item, newItem.Id, companyId, createdAtUtc))
{
await _unitOfWork.JobItemPrepServices.AddAsync(prepService);
}
}
await _unitOfWork.CompleteAsync();
var currentUserId = _userManager.GetUserId(User);
reworkJob = await BuildReworkJobAsync(job, dto.ReworkJobItemIds, dto.ReworkPricingType.Value, companyId, reworkDescription, currentUserId);
}
var record = new ReworkRecord
{
JobId = dto.JobId,
JobItemId = dto.JobItemId,
ReworkType = dto.ReworkType,
Reason = dto.Reason,
JobId = dto.JobId,
JobItemId = dto.JobItemId,
ReworkType = dto.ReworkType,
Reason = dto.Reason,
DefectDescription = dto.DefectDescription,
DiscoveredBy = dto.DiscoveredBy,
DiscoveredDate = dto.DiscoveredDate,
ReportedByName = dto.ReportedByName,
DiscoveredBy = dto.DiscoveredBy,
DiscoveredDate = dto.DiscoveredDate,
ReportedByName = dto.ReportedByName,
EstimatedReworkCost = dto.EstimatedReworkCost,
IsBillableToCustomer = dto.IsBillableToCustomer,
BillingNotes = dto.BillingNotes,
ReworkJobId = reworkJob?.Id,
Status = reworkJob != null ? ReworkStatus.InProgress : ReworkStatus.Open,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
BillingNotes = dto.BillingNotes,
ReworkPricingType = dto.ReworkPricingType,
ReworkJobId = reworkJob?.Id,
Status = reworkJob != null ? ReworkStatus.InProgress : ReworkStatus.Open,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.ReworkRecords.AddAsync(record);
await _unitOfWork.CompleteAsync();
// Reload with navigation for response
var saved = await _unitOfWork.ReworkRecords.FindAsync(r => r.Id == record.Id, false, r => r.JobItem, r => r.ReworkJob);
return Json(_mapper.Map<ReworkRecordDto>(saved.First()));
}
/// <summary>
/// Creates a linked rework Job from an existing rework record that was saved without one.
/// Uses sub-number format and applies the specified pricing attribution.
/// </summary>
[HttpPost]
public async Task<IActionResult> CreateReworkJob([FromBody] CreateReworkJobRequest req)
{
var reworkRecord = await _unitOfWork.ReworkRecords.GetByIdAsync(req.ReworkRecordId, false, r => r.Job);
if (reworkRecord == null) return NotFound();
var originalJob = await _unitOfWork.Jobs.LoadForDetailsAsync(reworkRecord.JobId);
if (originalJob == null) return NotFound();
var companyId = originalJob.CompanyId;
var itemIds = req.ItemIds ?? originalJob.JobItems.Select(i => i.Id).ToList();
var pricingType = req.ReworkPricingType ?? ReworkPricingType.ShopFault;
var pricingLabel = pricingType switch
{
ReworkPricingType.ShopFault => "Shop Fault — no charge",
ReworkPricingType.CustomerReduced => "Customer responsible — reduced rate",
ReworkPricingType.CustomerFull => "Customer responsible — full price",
_ => ""
};
var notes = string.IsNullOrWhiteSpace(req.Notes) ? "" : $" Notes: {req.Notes}";
var reworkDescription = $"REWORK: {pricingLabel}.{notes}";
var currentUserId = _userManager.GetUserId(User);
var reworkJob = await BuildReworkJobAsync(originalJob, itemIds, pricingType, companyId, reworkDescription, currentUserId);
reworkRecord.ReworkJobId = reworkJob.Id;
reworkRecord.ReworkPricingType = pricingType;
reworkRecord.Status = ReworkStatus.InProgress;
reworkRecord.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.ReworkRecords.UpdateAsync(reworkRecord);
await _unitOfWork.CompleteAsync();
return Json(new { success = true, reworkJobId = reworkJob.Id, reworkJobNumber = reworkJob.JobNumber });
}
/// <summary>
/// Shared helper that creates and persists a rework Job with sub-numbered job number,
/// copies the specified items (with coats and prep services), applies pricing attribution,
/// sets descriptive job description from the rework record data, and auto-records intake
/// (parts are already on hand when rework is logged).
/// Called by both AddReworkRecord and CreateReworkJob.
/// </summary>
private async Task<Job> BuildReworkJobAsync(
Job originalJob,
List<int> itemIds,
ReworkPricingType pricingType,
int companyId,
string reworkDescription,
string? checkedByUserId)
{
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
// First non-Pending status by workflow order
var firstActiveStatus = statuses
.Where(s => s.StatusCode != AppConstants.StatusCodes.Job.Pending)
.OrderBy(s => s.DisplayOrder)
.First();
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First();
// Sub-number: {parentJobNumber}-R{n+1}
var reworkCount = await _unitOfWork.Jobs.GetReworkJobCountAsync(originalJob.Id);
var reworkNumber = $"{originalJob.JobNumber}-R{reworkCount + 1}";
var reworkJob = new Job
{
JobNumber = reworkNumber,
CustomerId = originalJob.CustomerId,
Description = reworkDescription,
JobStatusId = firstActiveStatus.Id,
JobPriorityId = normalPriority.Id,
IsReworkJob = true,
OriginalJobId = originalJob.Id,
SpecialInstructions = $"Rework of {originalJob.JobNumber}.",
// Auto-intake: parts are already on hand when rework is logged
IntakeDate = DateTime.UtcNow,
IntakeConditionNotes = $"Parts auto-checked in as rework from {originalJob.JobNumber}.",
IntakeCheckedByUserId = checkedByUserId,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.Jobs.AddAsync(reworkJob);
await _unitOfWork.CompleteAsync();
var itemsToCopy = originalJob.JobItems.Where(i => itemIds.Contains(i.Id)).ToList();
var createdAtUtc = DateTime.UtcNow;
foreach (var item in itemsToCopy)
{
var newItem = _jobItemAssemblyService.CreateJobItem(item, reworkJob.Id, companyId, createdAtUtc);
// Shop-fault rework jobs are done at no charge
if (pricingType == ReworkPricingType.ShopFault)
{
newItem.UnitPrice = 0;
newItem.ManualUnitPrice = 0;
newItem.TotalPrice = 0;
}
await _unitOfWork.JobItems.AddAsync(newItem);
await _unitOfWork.CompleteAsync();
foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(item, newItem.Id, companyId, createdAtUtc))
await _unitOfWork.JobItemCoats.AddAsync(coat);
foreach (var prep in _jobItemAssemblyService.CreateJobItemPrepServices(item, newItem.Id, companyId, createdAtUtc))
await _unitOfWork.JobItemPrepServices.AddAsync(prep);
}
// Set intake part count now that items are known
reworkJob.IntakePartCount = (int)Math.Ceiling(itemsToCopy.Sum(i => i.Quantity));
// Write a pricing snapshot so the Details page and inline edit both work correctly
var itemsSubtotal = pricingType == ReworkPricingType.ShopFault
? 0m
: itemsToCopy.Sum(i => i.TotalPrice);
reworkJob.FinalPrice = itemsSubtotal;
reworkJob.PricingBreakdownJson = System.Text.Json.JsonSerializer.Serialize(new QuotePricingBreakdownDto
{
ItemsSubtotal = itemsSubtotal,
SubtotalBeforeDiscount = itemsSubtotal,
SubtotalAfterDiscount = itemsSubtotal,
Total = itemsSubtotal
});
await _unitOfWork.Jobs.UpdateAsync(reworkJob);
await _unitOfWork.CompleteAsync();
return reworkJob;
}
/// <summary>
/// Updates a rework record's status, resolution notes, cost, and billability.
/// Auto-sets ResolvedDate when status transitions to Resolved or WrittenOff (if not already set).
@@ -3647,66 +3991,6 @@ public class JobsController : Controller
return Json(new { success = true });
}
/// <summary>
/// Creates a new rework Job from an existing rework record and links them.
/// The rework job is a lightweight clone of the original job — same customer, description, and
/// oven — but starts fresh with Pending status so it goes through the full workflow again.
/// The ReworkJob FK on the rework record is updated so the Detail view can link to it.
/// </summary>
[HttpPost]
public async Task<IActionResult> CreateReworkJob([FromBody] CreateReworkJobRequest req)
{
var reworkRecord = await _unitOfWork.ReworkRecords.GetByIdAsync(req.ReworkRecordId, false, r => r.Job);
if (reworkRecord == null) return NotFound();
var originalJob = reworkRecord.Job;
var companyId = originalJob.CompanyId;
// Load status lookups to find Pending status
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
if (pendingStatus == null) return Json(new { success = false, message = "Could not find Pending status." });
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First();
// Generate job number
var allJobs = await _unitOfWork.Jobs.GetAllAsync(true);
var year = DateTime.Now.ToString("yy");
var month = DateTime.Now.ToString("MM");
var prefix = $"JOB-{year}{month}-";
var maxNum = allJobs
.Where(j => j.JobNumber.StartsWith(prefix))
.Select(j => { int.TryParse(j.JobNumber.Replace(prefix, ""), out int n); return n; })
.DefaultIfEmpty(0).Max();
var reworkJob = new Job
{
JobNumber = $"{prefix}{(maxNum + 1):D4}",
CustomerId = originalJob.CustomerId,
Description = $"REWORK: {originalJob.Description}",
JobStatusId = pendingStatus.Id,
JobPriorityId = normalPriority.Id,
IsReworkJob = true,
OriginalJobId = originalJob.Id,
SpecialInstructions = $"Rework of {originalJob.JobNumber}. {req.Notes}".Trim().TrimEnd('.') + ".",
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.Jobs.AddAsync(reworkJob);
await _unitOfWork.CompleteAsync();
// Link rework record to new job
reworkRecord.ReworkJobId = reworkJob.Id;
reworkRecord.Status = ReworkStatus.InProgress;
reworkRecord.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.ReworkRecords.UpdateAsync(reworkRecord);
await _unitOfWork.CompleteAsync();
return Json(new { success = true, reworkJobId = reworkJob.Id, reworkJobNumber = reworkJob.JobNumber });
}
// ── Quote-Changed Banner Actions ──────────────────────────────────────────
/// <summary>
@@ -3950,10 +4234,11 @@ public class JobsController : Controller
ovenCost = opCosts.OvenOperatingCostPerHour * defaultOvenCycleHours;
}
// 4. Revenue
decimal revenue = job.Invoice != null
? job.Invoice.Total
: (job.FinalPrice > 0 ? job.FinalPrice : job.QuotedPrice);
// 4. Revenue — prefer FinalPrice (reflects inline edits and job-level changes);
// fall back to Invoice.Total only when FinalPrice is zero (voided/zeroed job).
decimal revenue = job.FinalPrice > 0
? job.FinalPrice
: (job.Invoice?.Total ?? job.QuotedPrice);
// 5. Rework costs from linked rework jobs
var reworkRecords = await _unitOfWork.ReworkRecords.FindAsync(
@@ -3989,7 +4274,7 @@ public class JobsController : Controller
return Json(new {
revenue = Math.Round(revenue, 2),
revenueSource = job.Invoice != null ? "Invoice" : (job.FinalPrice > 0 ? "Final Price" : "Quoted Price"),
revenueSource = job.FinalPrice > 0 ? "Final Price" : (job.Invoice != null ? "Invoice" : "Quoted Price"),
powderCost = Math.Round(powderCost, 2),
laborCost = Math.Round(laborCost, 2),
ovenCost = Math.Round(ovenCost, 2),
@@ -4183,9 +4468,92 @@ public class JobsController : Controller
return Json(new { success = false, message = "An error occurred. Please try again." });
}
}
/// <summary>
/// Inline-edits description, quantity, and unit price on a single job line item.
/// Adjusts FinalPrice and the stored PricingBreakdownJson snapshot by the price delta.
/// Returns updated totals so the page can reflect the change without a reload.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> PatchItem([FromBody] PatchJobItemRequest request)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
var item = await _unitOfWork.JobItems.GetByIdAsync(request.ItemId);
if (item == null) return NotFound();
var job = await _unitOfWork.Jobs.GetByIdAsync(item.JobId);
if (job == null || job.CompanyId != currentUser.CompanyId) return NotFound();
var oldTotal = item.TotalPrice;
item.Description = request.Description.Trim();
item.Quantity = request.Quantity;
item.UnitPrice = request.UnitPrice;
item.TotalPrice = Math.Round(request.Quantity * request.UnitPrice, 2);
await _unitOfWork.JobItems.UpdateAsync(item);
var delta = item.TotalPrice - oldTotal;
job.FinalPrice = Math.Round(job.FinalPrice + delta, 2);
// Keep the stored pricing snapshot in sync so the breakdown panel stays consistent.
// Case-insensitive options handle JSON stored before PascalCase serialization was enforced.
QuotePricingBreakdownDto? pbFinal = null;
var jsonOpts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
{
var pb = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson, jsonOpts);
if (pb != null)
{
pb.ItemsSubtotal += delta;
pb.SubtotalBeforeDiscount += delta;
pb.SubtotalAfterDiscount = pb.SubtotalBeforeDiscount - pb.DiscountAmount;
pb.TaxAmount = Math.Round(pb.SubtotalAfterDiscount * pb.TaxPercent / 100m, 2);
pb.Total = Math.Round(pb.SubtotalAfterDiscount + pb.RushFee + pb.TaxAmount, 2);
job.FinalPrice = pb.Total;
job.PricingBreakdownJson = JsonSerializer.Serialize(pb);
pbFinal = pb;
}
}
await _unitOfWork.Jobs.UpdateAsync(job);
await _unitOfWork.CompleteAsync();
// For legacy jobs without a stored snapshot, derive breakdown from live item totals.
if (pbFinal == null)
{
var allItems = await _unitOfWork.JobItems.FindAsync(ji => ji.JobId == job.Id && !ji.IsDeleted);
var itemsSubtotal = allItems.Sum(ji => ji.TotalPrice);
var subtotal = itemsSubtotal + job.OvenBatchCost + job.ShopSuppliesAmount;
pbFinal = new QuotePricingBreakdownDto
{
ItemsSubtotal = itemsSubtotal,
SubtotalBeforeDiscount = subtotal,
SubtotalAfterDiscount = subtotal,
Total = job.FinalPrice
};
}
return Json(new {
lineTotal = item.TotalPrice,
finalPrice = job.FinalPrice,
itemsSubtotal = pbFinal.ItemsSubtotal,
subtotalBeforeDiscount = pbFinal.SubtotalBeforeDiscount,
subtotalAfterDiscount = pbFinal.SubtotalAfterDiscount,
taxAmount = pbFinal.TaxAmount
});
}
}
public class DeleteTimeEntryRequest { public int Id { get; set; } }
public class PatchJobItemRequest
{
public int ItemId { get; set; }
public string Description { get; set; } = string.Empty;
public decimal Quantity { get; set; }
public decimal UnitPrice { get; set; }
}
public class LogMaterialRequest
{
public int JobId { get; set; }
@@ -4194,7 +4562,13 @@ public class LogMaterialRequest
public string TransactionType { get; set; } = "JobUsage";
public string? Notes { get; set; }
}
public class CreateReworkJobRequest { public int ReworkRecordId { get; set; } public string? Notes { get; set; } }
public class CreateReworkJobRequest
{
public int ReworkRecordId { get; set; }
public List<int>? ItemIds { get; set; }
public PowderCoating.Core.Enums.ReworkPricingType? ReworkPricingType { get; set; }
public string? Notes { get; set; }
}
public class UpdateWorkerAssignmentRequest
{
@@ -580,7 +580,7 @@ public class MaintenanceController : Controller
// Calculate next scheduled maintenance
if (equipment.RecommendedMaintenanceIntervalDays > 0)
{
equipment.NextScheduledMaintenance = DateTime.UtcNow.AddDays(equipment.RecommendedMaintenanceIntervalDays);
equipment.NextScheduledMaintenance = DateTime.UtcNow.AddDays(equipment.RecommendedMaintenanceIntervalDays.Value);
}
equipment.UpdatedAt = DateTime.UtcNow;
@@ -75,7 +75,6 @@ public class PaymentController : Controller
CustomerName = customer != null
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
: "Valued Customer",
CustomerEmail = customer?.Email ?? string.Empty,
CompanyName = company!.CompanyName,
BalanceDue = invoice.BalanceDue,
InvoiceTotal = invoice.Total,
@@ -127,8 +126,6 @@ public class PaymentController : Controller
return BadRequest(new { error = "Invalid payment amount." });
var surcharge = CalculateSurcharge(request.Amount, company!);
var customer = await _context.Customers.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == invoice.CustomerId);
var (success, clientSecret, paymentIntentId, stripeError) =
await _stripeConnect.CreatePaymentIntentAsync(
@@ -136,7 +133,6 @@ public class PaymentController : Controller
invoiceTotal: request.Amount,
surchargeAmount: surcharge,
currency: "usd",
customerEmail: customer?.Email ?? string.Empty,
invoiceNumber: invoice.InvoiceNumber,
invoiceId: invoice.Id);
@@ -261,7 +257,6 @@ public class PaymentController : Controller
CustomerName = customer != null
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
: quote.ProspectContactName ?? "Valued Customer",
CustomerEmail = customer?.Email ?? quote.ProspectEmail ?? string.Empty,
CompanyName = company!.CompanyName,
DepositAmount = depositAmount,
QuoteTotal = quote.Total,
@@ -296,7 +291,6 @@ public class PaymentController : Controller
var depositAmount = Math.Round(quote!.Total * (quote.DepositPercent / 100m), 2);
var surcharge = CalculateSurcharge(depositAmount, company!);
var customerEmail = quote.Customer?.Email ?? quote.ProspectEmail ?? string.Empty;
var (success, clientSecret, paymentIntentId, stripeError) =
await _stripeConnect.CreateDepositPaymentIntentAsync(
@@ -304,7 +298,6 @@ public class PaymentController : Controller
depositAmount: depositAmount,
surchargeAmount: surcharge,
currency: "usd",
customerEmail: customerEmail,
quoteNumber: quote.QuoteNumber,
quoteId: quote.Id);
@@ -942,7 +935,6 @@ public class PaymentPageViewModel
public int InvoiceId { get; set; }
public string Token { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
public string CustomerEmail { get; set; } = string.Empty;
public string CompanyName { get; set; } = string.Empty;
public decimal BalanceDue { get; set; }
public decimal InvoiceTotal { get; set; }
@@ -963,7 +955,6 @@ public class DepositPaymentPageViewModel
public int QuoteId { get; set; }
public string Token { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
public string CustomerEmail { get; set; } = string.Empty;
public string CompanyName { get; set; } = string.Empty;
public decimal DepositAmount { get; set; }
public decimal QuoteTotal { get; set; }
@@ -65,6 +65,7 @@ public class PlatformSubscriptionController : Controller
AllowAiInventoryAssist = c.AllowAiInventoryAssist,
AllowAiCatalogPriceCheck = c.AllowAiCatalogPriceCheck,
AllowSms = c.AllowSms,
AllowCustomFormulas = c.AllowCustomFormulas,
IsActive = c.IsActive,
SortOrder = c.SortOrder
}).ToList();
@@ -106,6 +107,7 @@ public class PlatformSubscriptionController : Controller
AllowAiInventoryAssist = config.AllowAiInventoryAssist,
AllowAiCatalogPriceCheck = config.AllowAiCatalogPriceCheck,
AllowSms = config.AllowSms,
AllowCustomFormulas = config.AllowCustomFormulas,
IsActive = config.IsActive
};
@@ -152,6 +154,7 @@ public class PlatformSubscriptionController : Controller
config.AllowAiInventoryAssist = dto.AllowAiInventoryAssist;
config.AllowAiCatalogPriceCheck = dto.AllowAiCatalogPriceCheck;
config.AllowSms = dto.AllowSms;
config.AllowCustomFormulas = dto.AllowCustomFormulas;
config.IsActive = dto.IsActive;
await _unitOfWork.SubscriptionPlanConfigs.UpdateAsync(config);
@@ -28,6 +28,7 @@ public class QuoteApprovalController : Controller
private readonly ILogger<QuoteApprovalController> _logger;
private readonly IConfiguration _configuration;
private readonly IHubContext<NotificationHub> _hub;
private readonly IQuotePricingAssemblyService _assemblyService;
public QuoteApprovalController(
IUnitOfWork unitOfWork,
@@ -36,7 +37,8 @@ public class QuoteApprovalController : Controller
IStripeConnectService stripeConnect,
ILogger<QuoteApprovalController> logger,
IConfiguration configuration,
IHubContext<NotificationHub> hub)
IHubContext<NotificationHub> hub,
IQuotePricingAssemblyService assemblyService)
{
_unitOfWork = unitOfWork;
_notifications = notifications;
@@ -45,6 +47,7 @@ public class QuoteApprovalController : Controller
_logger = logger;
_configuration = configuration;
_hub = hub;
_assemblyService = assemblyService;
}
/// <summary>
@@ -177,6 +180,16 @@ public class QuoteApprovalController : Controller
await _unitOfWork.CompleteAsync();
// Create incoming inventory records for catalog-sourced coats deferred from quote-save time.
try
{
await _assemblyService.EnsureIncomingInventoryForApprovedQuoteAsync(quote.Id, quote.CompanyId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "EnsureIncomingInventory failed for quote {QuoteId} — continuing", quote.Id);
}
var approveEntry = new QuoteChangeHistory
{
QuoteId = quote.Id,
@@ -386,27 +386,29 @@ public class QuotesController : Controller
// never displays "× 0 min" when the oven was priced against DefaultOvenCycleMinutes.
quoteDto.PricingBreakdown = new QuotePricingBreakdownDto
{
MaterialCosts = quote.MaterialCosts,
LaborCosts = quote.LaborCosts,
EquipmentCosts = quote.EquipmentCosts,
ItemsSubtotal = quote.ItemsSubtotal,
OvenBatchCost = quote.OvenBatchCost,
OvenBatches = quote.OvenBatches,
OvenCycleMinutes = quote.OvenCycleMinutes ?? operatingCosts?.DefaultOvenCycleMinutes ?? 0,
ShopSuppliesAmount = quote.ShopSuppliesAmount,
ShopSuppliesPercent = quote.ShopSuppliesPercent,
OverheadCosts = quote.OverheadAmount,
OverheadPercent = quote.OverheadPercent,
ProfitMargin = quote.ProfitMargin,
ProfitPercent = quote.ProfitPercent,
SubtotalBeforeDiscount = quote.SubTotal,
DiscountAmount = quote.DiscountAmount,
DiscountPercent = quote.DiscountPercent,
SubtotalAfterDiscount = quote.SubTotal - quote.DiscountAmount,
RushFee = quote.RushFee,
TaxPercent = quote.TaxPercent,
TaxAmount = quote.TaxAmount,
Total = quote.Total
MaterialCosts = quote.MaterialCosts,
LaborCosts = quote.LaborCosts,
EquipmentCosts = quote.EquipmentCosts,
ItemsSubtotal = quote.ItemsSubtotal,
OvenBatchCost = quote.OvenBatchCost,
OvenBatches = quote.OvenBatches,
OvenCycleMinutes = quote.OvenCycleMinutes ?? operatingCosts?.DefaultOvenCycleMinutes ?? 0,
FacilityOverheadCost = quote.FacilityOverheadCost,
FacilityOverheadRatePerHour = quote.FacilityOverheadRatePerHour,
ShopSuppliesAmount = quote.ShopSuppliesAmount,
ShopSuppliesPercent = quote.ShopSuppliesPercent,
OverheadCosts = quote.OverheadAmount,
OverheadPercent = quote.OverheadPercent,
ProfitMargin = quote.ProfitMargin,
ProfitPercent = quote.ProfitPercent,
SubtotalBeforeDiscount = quote.SubTotal,
DiscountAmount = quote.DiscountAmount,
DiscountPercent = quote.DiscountPercent,
SubtotalAfterDiscount = quote.SubTotal - quote.DiscountAmount,
RushFee = quote.RushFee,
TaxPercent = quote.TaxPercent,
TaxAmount = quote.TaxAmount,
Total = quote.Total
};
// Load change history
@@ -570,27 +572,29 @@ public class QuotesController : Controller
// Populate pricing breakdown from stored snapshot values — never recalculate on load
quoteDto.PricingBreakdown = new QuotePricingBreakdownDto
{
MaterialCosts = quote.MaterialCosts,
LaborCosts = quote.LaborCosts,
EquipmentCosts = quote.EquipmentCosts,
ItemsSubtotal = quote.ItemsSubtotal,
OvenBatchCost = quote.OvenBatchCost,
OvenBatches = quote.OvenBatches,
OvenCycleMinutes = quote.OvenCycleMinutes ?? pdfOperatingCosts?.DefaultOvenCycleMinutes ?? 0,
ShopSuppliesAmount = quote.ShopSuppliesAmount,
ShopSuppliesPercent = quote.ShopSuppliesPercent,
OverheadCosts = quote.OverheadAmount,
OverheadPercent = quote.OverheadPercent,
ProfitMargin = quote.ProfitMargin,
ProfitPercent = quote.ProfitPercent,
SubtotalBeforeDiscount = quote.SubTotal,
DiscountAmount = quote.DiscountAmount,
DiscountPercent = quote.DiscountPercent,
SubtotalAfterDiscount = quote.SubTotal - quote.DiscountAmount,
RushFee = quote.RushFee,
TaxPercent = quote.TaxPercent,
TaxAmount = quote.TaxAmount,
Total = quote.Total
MaterialCosts = quote.MaterialCosts,
LaborCosts = quote.LaborCosts,
EquipmentCosts = quote.EquipmentCosts,
ItemsSubtotal = quote.ItemsSubtotal,
OvenBatchCost = quote.OvenBatchCost,
OvenBatches = quote.OvenBatches,
OvenCycleMinutes = quote.OvenCycleMinutes ?? pdfOperatingCosts?.DefaultOvenCycleMinutes ?? 0,
FacilityOverheadCost = quote.FacilityOverheadCost,
FacilityOverheadRatePerHour = quote.FacilityOverheadRatePerHour,
ShopSuppliesAmount = quote.ShopSuppliesAmount,
ShopSuppliesPercent = quote.ShopSuppliesPercent,
OverheadCosts = quote.OverheadAmount,
OverheadPercent = quote.OverheadPercent,
ProfitMargin = quote.ProfitMargin,
ProfitPercent = quote.ProfitPercent,
SubtotalBeforeDiscount = quote.SubTotal,
DiscountAmount = quote.DiscountAmount,
DiscountPercent = quote.DiscountPercent,
SubtotalAfterDiscount = quote.SubTotal - quote.DiscountAmount,
RushFee = quote.RushFee,
TaxPercent = quote.TaxPercent,
TaxAmount = quote.TaxAmount,
Total = quote.Total
};
if (currentUser?.CompanyId == null)
{
@@ -1316,6 +1320,7 @@ public class QuotesController : Controller
Terms = quote.Terms,
Notes = quote.Notes,
TaxPercent = quote.TaxPercent,
Total = quote.Total,
DiscountType = quote.DiscountType,
DiscountValue = quote.DiscountValue,
DiscountReason = quote.DiscountReason,
@@ -1338,9 +1343,27 @@ public class QuotesController : Controller
// Set calculated pricing — snapshot at save time; never recalculate on load
_quotePricingAssemblyService.ApplyPricingSnapshot(quote, pricingResult);
// Track changes
// All change history records are accumulated here, then saved in bulk below
var changeHistories = new List<QuoteChangeHistory>();
// Log a total-change entry now that the new Total is known
if (Math.Round(oldValues.Total, 2) != Math.Round(quote.Total, 2))
{
changeHistories.Add(new QuoteChangeHistory
{
QuoteId = quote.Id,
ChangedByUserId = currentUser.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Total",
OldValue = oldValues.Total.ToString("C"),
NewValue = quote.Total.ToString("C"),
ChangeDescription = $"Total changed from {oldValues.Total:C} to {quote.Total:C}",
CompanyId = currentUser.CompanyId
});
}
// Track changes
_logger.LogInformation("=== CHANGE TRACKING DEBUG ===");
_logger.LogInformation("Old Status: {OldStatus}, New Status: {NewStatus}", oldValues.QuoteStatusId, quote.QuoteStatusId);
_logger.LogInformation("Old Date: {OldDate}, New Date: {NewDate}", oldValues.QuoteDate, quote.QuoteDate);
@@ -2313,6 +2336,17 @@ public class QuotesController : Controller
_logger.LogInformation("Quote {QuoteId} approved by user {UserId}", id, currentUser.Id);
// Create incoming inventory records for any catalog-sourced coats that were deferred
// from quote-save time. One record per unique powder catalog item, de-duplicated.
try
{
await _quotePricingAssemblyService.EnsureIncomingInventoryForApprovedQuoteAsync(id, quote.CompanyId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "EnsureIncomingInventory failed for quote {QuoteId} — continuing", id);
}
// Notify customer that quote is approved (only if user opted in)
if (sendEmail)
{
@@ -2429,6 +2463,33 @@ public class QuotesController : Controller
var (_, quotePhotoMax) = await _subscriptionService.GetQuotePhotoCountAsync(companyId, 0);
ViewBag.QuotePhotosEnabled = quotePhotoMax != 0; // 0 = feature disabled for this plan
var allowFormulas = HttpContext.Items["AllowCustomFormulas"] as bool? ?? false;
if (allowFormulas)
{
var formulaTemplates = await _unitOfWork.CustomItemTemplates.FindAsync(
t => t.CompanyId == companyId && t.IsActive);
ViewBag.CustomFormulaTemplates = formulaTemplates
.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
.Select(t => new
{
id = t.Id,
name = t.Name,
description = t.Description,
outputMode = t.OutputMode,
fieldsJson = t.FieldsJson,
formula = t.Formula,
defaultRate = t.DefaultRate,
rateLabel = t.RateLabel,
diagramImagePath = string.IsNullOrEmpty(t.DiagramImagePath)
? (string?)null
: Url.Action("TemplateDiagram", "CompanySettings", new { templateId = t.Id })
}).ToList();
}
else
{
ViewBag.CustomFormulaTemplates = new List<object>();
}
// Customers
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
ViewBag.Customers = customers
@@ -2771,6 +2832,20 @@ public class QuotesController : Controller
await _unitOfWork.Quotes.UpdateAsync(quote);
await _unitOfWork.SaveChangesAsync();
// When transitioning to Approved: create incoming inventory records for catalog-sourced
// coats that were deferred from quote-save time (one record per unique powder, deduplicated).
if (newStatus.StatusCode == AppConstants.StatusCodes.Quote.Approved && oldStatusCode != AppConstants.StatusCodes.Quote.Approved)
{
try
{
await _quotePricingAssemblyService.EnsureIncomingInventoryForApprovedQuoteAsync(request.QuoteId, quote.CompanyId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "EnsureIncomingInventory failed for quote {QuoteId} — continuing", request.QuoteId);
}
}
// Auto-create job when quote is approved — guard against double-conversion
// (race condition: two simultaneous approval calls could both pass the oldStatusCode check)
if (newStatus.StatusCode == AppConstants.StatusCodes.Quote.Approved && oldStatusCode != AppConstants.StatusCodes.Quote.Approved
@@ -3118,6 +3193,22 @@ public class QuotesController : Controller
}
await _unitOfWork.Quotes.UpdateAsync(quote);
// Log send event so the history timeline shows when the quote was emailed
var sentHistoryEntry = new QuoteChangeHistory
{
QuoteId = quote.Id,
ChangedByUserId = currentUser!.Id,
ChangedAt = DateTime.UtcNow,
FieldName = "Sent",
OldValue = null,
NewValue = recipientEmail,
ChangeDescription = $"Quote sent to {recipientName} ({recipientEmail})",
CompanyId = quote.CompanyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.QuoteChangeHistories.AddAsync(sentHistoryEntry);
await _unitOfWork.CompleteAsync();
await _notificationService.NotifyQuoteSentAsync(quote, pdfBytes, pdfFilename, trimmedOverride);
@@ -3824,6 +3915,49 @@ public class QuotesController : Controller
}
return (company.LogoData, company.LogoContentType);
}
/// <summary>
/// Inline-edits description, quantity, and unit price on a single quote line item.
/// Adjusts stored quote totals by the price delta so the sidebar stays accurate.
/// Returns updated totals so the page can reflect the change without a reload.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> PatchItem([FromBody] PatchQuoteItemRequest request)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
var item = await _unitOfWork.QuoteItems.GetByIdAsync(request.ItemId);
if (item == null) return NotFound();
var quote = await _unitOfWork.Quotes.GetByIdAsync(item.QuoteId);
if (quote == null || quote.CompanyId != currentUser.CompanyId) return NotFound();
var oldTotal = item.TotalPrice;
item.Description = request.Description.Trim();
item.Quantity = request.Quantity;
item.UnitPrice = request.UnitPrice;
item.TotalPrice = Math.Round(request.Quantity * request.UnitPrice, 2);
await _unitOfWork.QuoteItems.UpdateAsync(item);
// Cascade delta through stored totals without re-running the pricing engine
var delta = item.TotalPrice - oldTotal;
quote.ItemsSubtotal += delta;
quote.SubTotal += delta;
quote.SubtotalAfterDiscount = quote.SubTotal - quote.DiscountAmount;
quote.TaxAmount = Math.Round(quote.SubtotalAfterDiscount * quote.TaxPercent / 100m, 2);
quote.Total = Math.Round(quote.SubtotalAfterDiscount + quote.RushFee + quote.TaxAmount, 2);
await _unitOfWork.Quotes.UpdateAsync(quote);
await _unitOfWork.CompleteAsync();
return Json(new {
lineTotal = item.TotalPrice,
subtotal = quote.SubTotal,
taxAmount = quote.TaxAmount,
total = quote.Total
});
}
}
// Request model for AJAX pricing calculation
@@ -3834,3 +3968,11 @@ public class UpdateQuoteStatusRequest
public int QuoteId { get; set; }
public int StatusId { get; set; }
}
public class PatchQuoteItemRequest
{
public int ItemId { get; set; }
public string Description { get; set; } = string.Empty;
public decimal Quantity { get; set; }
public decimal UnitPrice { get; set; }
}

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