Two-tab strip between Dashboard and the Operations section lets users
toggle between the shop-management view and the accounting view.
FOUC-prevention: server-side controller detection stamps data-nav-mode
on <html> before first paint; CSS hides the inactive side instantly.
JS (nav-mode.js) auto-switches to Finance when navigating to an
accounting controller, restores saved preference everywhere else.
Vendors appears in both modes; all 39 nav items carry data-nav tags.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Budgeting:
- Budget + BudgetLine entities with Jan–Dec monthly columns per GL account
- BudgetsController: Index, Create, Edit, SetDefault, Copy, Delete
- Copy action rolls a budget forward to a new fiscal year
- Budget vs. Actual report (BudgetVsActual): compares monthly budget amounts to
real P&L by calling GetProfitAndLossAsync once per month; variance shown as
favorable/unfavorable; year + budget selectors in header
- Views: Budgets/Index, Create, Edit with inline annual totals via budget-edit.js
- Nav link + report card on Landing
Year-End Close:
- YearEndClose entity records each closed year + JE reference for audit trail
- AccountsController.YearEndClose GET (history + form) + CloseYear POST
- Close zeroes all Revenue and Expense/COGS account balances into Retained Earnings
via IAccountBalanceService and posts a supporting JE dated Dec 31
- Idempotency: rejects attempt to close an already-closed year
- Pre-close checklist in view to guide the workflow
- Nav link under Finance
Migration AddBudgetsAndYearEndClose applied
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Invoice Write-Off: WriteOff POST action in InvoicesController posts bad-debt JE
(DR bad debt expense / CR AR), reduces customer balance, marks invoice WrittenOff;
write-off modal added to Invoice Details view with expense account selector
- Fixed Assets: FixedAsset + FixedAssetDepreciationEntry entities with straight-line
depreciation; FixedAssetsController (Index/Create/Edit/Details/PostDepreciation/Delete);
PostDepreciation auto-generates one JE per asset per period, skips already-posted,
fully-depreciated, and disposed assets; full CRUD views + nav link
- Period Locking: Company.BookLockedThrough field; AccountingPeriodValidator static helper;
lock check added to JE Post and Bill Create (blocks backdating into closed periods);
SetPeriodLock action + date picker UI in Company Settings Accounting section
- 1099 Tracking: Is1099Vendor flag on Vendor entity + DTOs; checkbox in Create/Edit views;
TaxReporting1099 report action + view lists payments by year, flags vendors >= $600;
report card added to Reports Landing
- Migration AddFixedAssetsLockAnd1099 applied
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CreditMemosController with Index, Details, Create, Apply, and Void actions.
All business logic (atomic apply transaction, RemainingBalance cap,
customer.CreditBalance adjustment, auto-Paid invoice when BalanceDue hits zero)
mirrors the invoice-centric IssueCreditMemo/ApplyCredit/VoidCreditMemo actions in
InvoicesController but redirects back to the credit memo rather than an invoice.
Views: Index (stats bar, status+search filter, table), Details (two-col layout
with application history table and Bootstrap Apply/Void confirm modals),
Create (customer dropdown, amount, reason, notes, optional expiry).
Apply modal populates amount automatically from min(remaining credit,
invoice balance due) via credit-memo.js data-attribute wiring (no inline scripts).
Nav: Credit Memos added to Billing & Payments section in _Layout.
Build: 0 errors. Unit tests: 200/200.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Drop orphan VendorCreditId1 column from VendorCreditApplications (was
scaffolded by EF because WithMany() lacked inverse navigation name;
fixed WithMany() → WithMany(vc => vc.Applications) in ApplicationDbContext)
- Wire EarlyPaymentDiscount fields through full data path: added
EarlyPaymentDiscountPercent/Days to CreateInvoiceDto, hidden inputs to
Invoice Create view, and JS to populate from customer AJAX response
- Add missing [HttpGet] attribute to TaxRatesController.Index
- Document GenerateNow architecture exception with XML rationale
Migration DropOrphanVendorCreditId1 applied. Build: 0 errors, 168 warnings.
Unit tests: 200/200 passing.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- VendorCredit, VendorCreditLineItem, VendorCreditApplication entities
- VendorCreditStatus enum (Open, PartiallyApplied, Applied, Voided)
- Migration AddVendorCredits: three new tables
- IUnitOfWork/UnitOfWork wired with all three repositories
- VendorCreditsController: Index (status tabs), Create, Details, Post, Apply, Void
- Post action: DR AP, CR each expense line (reverses original expense)
- Apply action: links credit to bill, updates Bill.AmountPaid and bill status
- Views: Index (summary cards + table), Create (dynamic line grid), Details (apply panel)
- Nav: Vendor Credits added to Finance section in _Layout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- JournalEntry + JournalEntryLine entities with Draft/Posted/Reversed lifecycle
- JournalEntryStatus enum (Draft, Posted, Reversed)
- Migration AddJournalEntries: two new tables with self-referencing reversal FK
- IUnitOfWork/UnitOfWork wired with JournalEntries + JournalEntryLines repos
- ApplicationDbContext: DbSets, tenant query filters, reversal FK config
- LedgerService: JE lines added as 10th source in GetAccountLedgerAsync and ComputePriorBalanceAsync
- JournalEntriesController: Index (All/Draft/Posted tabs), Create, Details, Post, Reverse, Delete
- Views: Index, Create (dynamic balanced line grid with running debit/credit totals), Details
- journal-entry-create.js: dynamic line management with balance indicator
- Nav: Journal Entries added to Finance section in _Layout
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- AP Aging report (GetApAgingAsync, controller actions, view, PDF export)
mirrors AR Aging — groups open bills by vendor, buckets by days past due date
- Trial Balance report (GetTrialBalanceAsync, view, PDF export)
uses Account.CurrentBalance, groups by AccountType, validates debits == credits
- Cash vs Accrual accounting method setting on Company entity
switchable at any time — report-time only, no GL re-posting on change
P&L cash: revenue = payments received; expenses = bills/expenses paid in period
Balance Sheet cash: omits AR and AP lines (no receivables/payables concept)
AccountingMethod badge shown on P&L and Balance Sheet views
- Migration A (AddAccountingMethod) applied, default = Accrual for all existing companies
- AP Aging and Trial Balance added to Reports Landing page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Non-terminal jobs scheduled for past dates now appear in a red 'Carried
Over' section at the top of today's board so they can't silently disappear.
Also added alert-permanent to the board tip so the layout doesn't auto-dismiss it.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Quotes: ad-hoc email modal on Quote Details lets staff send to an address not on file;
QuotesController passes overrideEmail through to NotificationService
- Quotes/Details view: SMS consent display, email/SMS send button state based on consent
- Accounting module: AccountingDisplayHelpers for consistent ledger formatting;
AccountsController + Accounts views improvements; AccountingEnums additions
- Bills/Expenses: AI account categorization fixes in BillsController and ExpensesController
- InventoryAiLookupService: TDS cure fallback no longer fires on AiAugmentFromUrl path
(LookupByUrlAsync already has it built in — was double-fetching)
- PdfService: quote/invoice PDF updates
- PricingCalculationService: minor pricing logic fix
- QuoteProfile: mapping updates for new quote fields
- ApplicationDbContextModelSnapshot: catches up to all 4 migrations in this branch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Voided invoices no longer block creating a new invoice for the same job: voided invoice's
JobId FK is cleared so the unique index slot is freed for the replacement
- Invoice Details view shows voided invoices as history rather than hiding them
- Payment terms: standardized SelectList (Due on Receipt, Net 15/30/45/60/90, 2% 10 Net 30,
COD) with custom-term preservation; invoice-due-date.js auto-updates Due Date on term change
- Shop supplies on direct (no-quote) jobs: InvoicesController derives the shop supplies line
from the company rate when the job has no source quote to read the pre-agreed amount from
- Job entity: ShopSuppliesAmount + ShopSuppliesPercent fields preserved through job lifecycle
- Migration: AddShopSuppliesAmountToJob
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- InventoryItem.IsIncoming: marks powder ordered but not yet received; enables QR code
printing on work orders while the shipment is in transit
- InventoryController.CreateIncomingFromCatalog: POST endpoint creates a 0-balance inventory
record from a PowderCatalogItem and returns it in wizard-compatible shape
- item-wizard.js: custom coat tab now searches the platform powder catalog as a fallback;
catalog results show an 'Add as Incoming Order' option; createIncomingFromCatalog POSTs
to server and selects the new item without a page refresh
- QuoteItemCoatDto: CatalogItemId + AddAsIncoming fields so the wizard can signal server-side
incoming-item creation during quote save
- Inventory Create/Edit/Index views: IsIncoming badge and field
- IInventoryAiLookupService: minor interface update
- Migration: AddInventoryIsIncoming
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Quote entity: ProspectSmsConsent (bool) + ProspectSmsConsentedAt (DateTime?) fields
- QuoteDtos: consent fields on Create/Update/Convert DTOs with TCPA guidance text
- Quote Create/Edit views: SMS consent checkbox shown when mobile number is entered
- Quote ConvertToCustomer view: staff must re-confirm consent carries over to customer record
- QuoteApproval: consent state exposed in ViewModel and ApprovalPage for transparency
- Consent timestamp cleared when prospect quote is linked to an existing customer
- Migration: AddProspectSmsConsent
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Customer entity + DTO: new BillingEmail field (accounting/invoicing address)
- Email fields now accept comma-separated lists; DTO validates each address individually
- NotificationService: SendToEmailListAsync helper fans out to all addresses in a list;
NotifyQuoteSentAsync accepts optional overrideEmail so staff can send to an ad-hoc address
- Migration: AddCustomerBillingEmail
- Customer Create/Edit/Details views updated to show Billing Email field
- customer-billing-email.js: client-side helpers for billing email input
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- CreateJobFromQuote now sets QuoteStatusId to CONVERTED after creating the job
- Added ConvertedToJobNumber to QuoteDto, populated in Details action
- 'View Job' button on Quote Details now shows the job number (e.g. 'View Job JOB-2505-0001')
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Extract _CompleteJobModal.cshtml partial; Details.cshtml uses PartialAsync
- Job board COMPLETED drop fetches partial via AJAX and shows modal in-place
- Add GET Jobs/CompleteJobModal action to load job data for the board modal
- install-app.js: persist installed state in localStorage; clears automatically when browser re-fires beforeinstallprompt after uninstall
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Dragging a card to the Completed column on the job board previously called
MoveCard directly, skipping email/SMS notifications, CompletedDate, powder
deduction, and the completion modal entirely.
Now detects the COMPLETED status code on the target column, reverts the
visual drag, and navigates to the job Details page with #completeModal in
the hash. Details.cshtml auto-opens the Mark Complete modal on arrival,
so the user goes through the same code path as the Complete Job button.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Quotes Create/Edit: hide 'Send via email' checkbox when customer has no
email; show badge 'send via SMS from details' or 'SMS consent required'
when customer has a mobile number. JS responds to customer dropdown change.
- Quotes Details: hide 'Send Quote via Email' button and approval email
checkbox; hide SMS button when no mobile; show consent-required note.
- Jobs Details (Mark Complete modal): hide email checkbox; show
'SMS notification will be sent' badge or consent-required note.
- Jobs Index (status modal): hide email row when customer has no email.
- Jobs Edit: hide 'Notify customer if status changes' when no email.
- Invoices Details: hide Send/Re-send buttons when no email (vs. disabled).
DTOs: added CustomerEmail + CustomerNotifyByEmail to JobDto/JobListDto;
added CustomerNotifyByEmail/CustomerMobilePhone/CustomerNotifyBySms to
QuoteDto. Mapped in JobProfile and QuotesController customer blocks.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Catalog DefaultPrice is always the base price — removed the IncludePrepCost
gate that was adding prep service labor on top of catalog items. PrepServices
on catalog items exist for scheduling purposes only, not pricing.
Also fixed Razor syntax bug in Details.cshtml where @(expr).ToString("F1")
rendered the raw decimal followed by the literal string ".ToString("F1")"
instead of the formatted value.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Quote.OvenCycleMinutes is nullable — null means 'use company default'.
The Details and DownloadPdf actions were converting null → 0 before passing
it to the view, so the pricing summary showed '× 0 min' even though the
oven cost was correctly calculated from DefaultOvenCycleMinutes.
Fix: resolve null against operatingCosts.DefaultOvenCycleMinutes in both
controller actions (DownloadPdf now loads operating costs for this). Added
a defensive > 0 guard in the view so the minutes clause is omitted entirely
if it still comes through as 0.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously the quick quote omitted the oven charge entirely, so saved quotes
were under-priced relative to full quotes from the same items.
Pricing: CalculatePricing now calculates ovenBatchCost = (cycleMin/60) × OvenOperatingCostPerHour
using DefaultOvenCycleMinutes (fallback 50 min), then adds it to the total as a quote-level
charge matching how PricingCalculationService handles oven costs.
Save path: SaveQuickQuoteRequest gains OvenBatchCost + OvenCycleMinutes; the Quote record
now stores OvenBatchCost, OvenCycleMinutes, and Total = ItemsSubtotal + OvenBatchCost.
Display: results card shows a sub-line under the estimate price:
"incl. oven 1 batch 50 min: $12.00"
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a tenant uploads a logo it is stored in Azure Blob Storage and
LogoData (the legacy DB byte[]) is cleared. All PDF controllers were
still reading the now-null LogoData, so logos never appeared on any
PDF after upload. Fixed by injecting ICompanyLogoService into all six
affected controllers (Quotes, Invoices, Deposits, GiftCertificates,
PurchaseOrders, CatalogItems) and loading the blob-stored logo first
before falling back to the legacy DB field.
Also added structured logging to the AI photo promotion path in
QuotesController Create/Edit POST so upload failures are visible in
production logs instead of silently swallowed.
Added onclick safety net to the Create and Edit quote submit buttons
so dynamically-injected hidden fields (AiPhotoTempIds) are written
before iOS Safari collects the form data on submit.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Invoice-basis report showing taxable vs non-taxable sales, tax billed
by GL account, monthly trend table/chart, and full invoice detail grid.
Non-taxable invoice rows shaded grey for easy scanning. Quick-preset
date buttons (This Month, Last Month, YTD, Last Year) for common filing
periods. CSV export formatted for accountants and tax-filing software.
Gated behind AllowAccounting() like other financial reports.
- SalesTaxReportDto + 3 supporting DTOs in FinancialReportDtos.cs
- GetSalesTaxReportAsync on IFinancialReportService + implementation
- GenerateSalesTaxReportPdfAsync on IPdfService + QuestPDF implementation
- SalesTax / SalesTaxPdf / SalesTaxCsv actions in ReportsController
- Views/Reports/SalesTax.cshtml with Chart.js monthly trend chart
- Landing page card added to Finance section
- HelpKnowledgeBase and Help/Reports.cshtml updated with full docs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- PowderCatalogController: Create, Edit, ToggleDiscontinued actions; searchable/filterable/sortable Index with pagination; AiLookup and AiAugmentFromUrl endpoints backed by IInventoryAiLookupService
- New views: Create, Edit, _Form partial (with AI-assisted field population), overhauled Index grid with completeness quality badges and responsive mobile cards
- New ViewModels: PowderCatalogIndexViewModel, PowderCatalogFormViewModel, PowderCatalogListItemViewModel
- AI lookup improvements: SpecificGravity field added to InventoryAiLookupResult; ApplyPowderFallbacks derives CoverageSqFtPerLb from specific gravity when docs omit it; DefaultTransferEfficiency (65%) applied everywhere transfer efficiency is null
- powder-catalog-ai-lookup.js: client-side AI lookup and URL augment wiring for the catalog form
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove parseInt() from time entry worker select — GUIDs were destroyed
to NaN → sent as null → FindByIdAsync(null) threw 500
- Ledger pencil: also show for Adjustment rows (no PO) so scan-without-job
entries get an edit button, not just JobUsage rows
- InventoryController: always write JobUsage type for scan-based logs;
accept Adjustment in edit endpoints; promote Adjustment→JobUsage when
a job is assigned via edit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GettingStarted: add 'Using on Mobile — Add to Home Screen' section covering
iOS Safari install flow, Android Chrome install, and PWA benefits (full-screen,
persistent camera permission). Includes Safari-required warning for iOS.
Inventory: add 'Catalog Lookup & Label Scanner' section covering smart catalog
search (filters out existing inventory, vendor-scoped with fallback), AI Lookup
fallback, camera label scanner (catalog-first then AI), and the add-stock prompt
when a scanned product is already in inventory.
HelpKnowledgeBase: sync both of the above for the AI Help Assistant, plus add
catalog lookup / label scanner detail to the INVENTORY section.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Shows a dismissible banner on mobile only, tailored to three cases:
- iOS + Safari: instructions to tap Share → Add to Home Screen
- iOS + other browser: tells user to open in Safari first (required for standalone)
- Android: instructions to tap menu → Install App / Add to Home Screen
Hidden when already running as standalone PWA, or after user dismisses it
(stored in localStorage so it stays gone). Explains camera permission benefit.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
iOS ignores manifest icons for home screen; requires apple-touch-icon link tag.
Generated 180x180 PNG with white background + padding so the transparent logo
renders correctly instead of filling black.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- PWA: manifest.json + minimal service worker so iOS/Android persist camera
permission after "Add to Home Screen"; theme-color and apple meta tags in layout
- PWA icons: 192x192 and 512x512 from transparent PCL logo; updated pcl-logo.png
- AI pricing: apply AdditionalCoatLaborPercent per extra coat on AI items,
matching the calculated-item path (was ignoring extra coats entirely)
- AI wizard: live price recalc when coats are added/removed; session-expiry
errors now show a clear "refresh and sign in" message instead of raw HTTP status;
smooth-scroll to follow-up/results sections on AI response
- Catalog lookup: exclude SKUs already in company inventory from results;
pass currentId on edit so own entry still appears; vendor-scoped search
with cross-vendor fallback; result count shown in multi-match modal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously these enrichments only ran in the label scanner path (ScanLabel).
The AI Lookup button and AiAugmentFromUrl went through separate code that
returned raw LookupAsync / LookupByUrlAsync results with no TDS fallback
and no SDS/TDS URL propagation to the form.
- InventoryController.ApplyTdsCureFallbackAsync: new private helper that
checks whether cure temp or cure time is still null after the primary
lookup, and if a TDS URL was returned calls FetchTdsCureSpecsAsync to
fill the gap. Mutates the result in place so callers just return it.
- AiLookup: calls ApplyTdsCureFallbackAsync after LookupAsync succeeds.
- AiAugmentFromUrl: calls ApplyTdsCureFallbackAsync after LookupByUrlAsync.
- ScanLabel: replaced the inline TDS fallback block with a call to the
same helper (merges catalog TDS URL into aiResult first so the helper
sees the best available URL).
- _InventoryColorFamilyScripts.cshtml: added fillDocUrl() helper that fills
field-sdsurl / field-tdsurl inputs and shows their open-link buttons when
the AI lookup returns sdsUrl / tdsUrl. These fields existed in the form
but were never populated by the AI Lookup button.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a scanned label matches an item already in the tenant's inventory,
the scanner now opens an inline modal asking the user to add stock to the
existing item rather than navigating away or creating a duplicate.
- InventoryController.AddStock: new POST endpoint that creates a Purchase
transaction, updates QuantityOnHand, and optionally updates UnitCost /
LastPurchasePrice when a new cost is provided. Returns new balance as JSON.
- InventoryController.ScanLabel: extends the duplicate-detection response
to include existingQuantityOnHand and existingUnitOfMeasure so the modal
can display current stock level.
- _LabelScanModal.cshtml: adds #addStockModal with quantity (+ UOM label),
optional unit cost (pre-filled from scan), optional notes, Add Stock CTA,
and an escape hatch to create a new entry instead.
- inventory-label-scan.js: when scan returns existingInventoryId the JS
opens addStockModal instead of a warning banner. Submitting POSTs to
/Inventory/AddStock and shows the updated balance in a success bar with
a link to the item. The 'new entry instead' path hides the modal and
pre-fills the create form with a softer duplicate warning.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
QR scanning:
- BarcodeDetector now snapshots to canvas before detect() instead of passing
live video element — more reliable across Chrome versions
- Uses BarcodeDetector.getSupportedFormats() to detect all formats the browser
supports rather than hardcoding ['qr_code'], catching data_matrix etc.
- jsQR fallback unchanged (attemptBoth inversion)
Processing overlay:
- Added #scan-processing overlay div to _LabelScanModal with spinner + message
- Camera/scanning UI blanks immediately when QR is found or Scan Text tapped;
overlay message differs per path ("QR code found..." vs "Reading label with AI...")
- Overlay hides on error (modal stays open); modal close triggers hideProcessing()
Camera permission:
- localStorage flag (scannerCameraGranted) set on every successful getUserMedia
- preWarmCamera() checks flag first, bypassing navigator.permissions.query which
can return 'prompt' for localhost even when Chrome has 'Allow' internally;
proactive getUserMedia on page load succeeds silently when permission is granted
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Platform PowderCatalogItem table (IPlainRepository, no tenant filter) with
full spec fields: cure temp/time, finish, color families, clear coat flag,
coverage sq ft/lb, transfer efficiency, IsUserContributed
- Two EF migrations: AddPowderCatalogItem + AddPowderCatalogSpecFields
- PowderCatalogController (SuperAdminOnly): import from Prismatic JSON scrape,
Lookup AJAX endpoint (catalog-first, ranked by SKU exact match), stats view
with Tenant Contributed card
- Unified smart Lookup button on inventory Create/Edit: catalog hit fills all
fields via catalogSnapshot pattern; AI augments cure/finish data from product
URL if subscription enabled; catalog miss falls through to AI lookup
- In-browser label scanner (_LabelScanModal): getUserMedia live camera feed,
jsQR auto-detects QR codes in rAF loop; "Scan Label Text" fallback sends
captured frame to Claude vision via /Inventory/ScanLabel
- ScanLabel endpoint handles both QR URL path (LookupByUrlAsync) and vision
path (ScanLabelAsync); auto-inserts unrecognized products as
IsUserContributed=true; returns wasInCatalog/addedToCatalog flags
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
System.Text.Json serializes PascalCase by default but the modal JS
expected camelCase — added CamelCase naming policy to the serializer.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Fix Razor rendering of TermsVersion — property chains after a literal
character need @() parentheses or Razor misparses the expression.
Also adds cleanup to EnsureNotificationTemplatesSeededAsync to remove
stale template rows (no longer canonical, never customised) on next
settings visit, so retired types like JobReadyForPickup SMS disappear
automatically.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>