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>
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>
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>
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>
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>
- 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>
- 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>
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>
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>
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>
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>
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>
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>
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>
- /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 & 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>
- 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>
After deleting a job item, the remaining-items DTO projection was missing
NoExtraLayerCharge, causing PricingCalculationService to treat all coats as
extra-charge when recalculating the job total post-delete.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
WizardExistingItems coat serialization in Details GET omitted noExtraLayerCharge,
so editing a line item from the Details page always lost the no-charge flag.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- QuotePricingAssemblyService.BuildQuoteItemCoat: map NoExtraLayerCharge from
CreateQuoteItemCoatDto to QuoteItemCoat on every quote save (was always omitted)
- JobsController.EditItems GET: include NoExtraLayerCharge in coat mapping when
reloading existing items for the wizard (was dropped, causing revert on second edit)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CreateJobDto and UpdateJobDto now carry OvenCostId, OvenBatches, and
OvenCycleMinutes. The Create POST sets these on the new Job entity and
passes them to the pricing engine; the Edit GET populates them from the
existing job so the form reflects saved values, and the Edit POST writes
them back before repricing.
Both Jobs/Create.cshtml and Jobs/Edit.cshtml now include an Oven & Batch
Settings card (matching the quote form) with oven selector, batch count,
and cycle time inputs. The wizard init block now passes the selected
OvenCostId instead of null so live auto-pricing reflects the oven cost.
ViewBag.DefaultOvenCycleMinutes added to PopulateCreateEditWizardViewBagsAsync
so the placeholder in both views shows the company default.
Also fixed: NoExtraLayerCharge was missing from the Edit GET coat DTO
mapping (would have caused the flag to reset to false on next edit).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Edit POST now detects if ScheduledStartTime changed (via previousStart
comparison after AutoMapper merge) and nulls ReminderSentAt so the
background service will fire the reminder again at the new time.
Calendar drag-drop (UpdateEventTime) always clears ReminderSentAt since
rescheduling is its only purpose.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Added explicit CompanyId == companyId predicates to every tenant-scoped
query in 22 controllers so cross-tenant data leakage is impossible even
if EF Core global query filters are bypassed or misconfigured.
Also fixed ApplicationDbContext.IsPlatformAdmin to correctly return true
for SuperAdmins with no CompanyId claim (break-glass accounts) and when
no HTTP context is present (background services, unit tests), resolving
225 unit test failures that stemmed from the global filter blocking all
in-memory test data.
New MultiTenantIsolationTests class (8 tests) verifies the explicit
predicate layer independently of the global query filters.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Powders already assigned to this job's coats appear under a 'This Job'
section header, then a divider, then 'All Inventory' — so the most
relevant choices are always one click away.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Shows manufacturer name as muted secondary text in each dropdown row
and includes it in the search filter, so users can find a powder by
brand when multiple items share a similar name.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
System.Text.Json defaults to PascalCase; JS reads camelCase. Add
JsonNamingPolicy.CamelCase to the InventoryItemsForModal serialization.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The modal was showing one row per coat per item, so a job with 5 items
each with 2 coats of the same powder produced 10 identical input rows.
Now groups by unique InventoryItemId and shows one row per powder color
for the whole job. The controller distributes the entered total across
coats proportionally by their estimated PowderToOrder so per-coat
reporting data is preserved. A single inventory transaction is created
per powder (net of any pre-logged scan credit).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PC users were blocked to QR scan only for logging material usage. Now a
"Log Material" button opens an inline modal with:
- Inventory item dropdown (name + unit of measure, current stock shown on select)
- Entry method toggle: "Amount Used" or "Amount Remaining" (computes used = onHand - remaining)
- Reason: Job Usage or Waste/Spillage
- Notes field
Submits via AJAX to Jobs/LogMaterial (new POST action) which mirrors the
InventoryController.LogUsage flow — updates QuantityOnHand, creates InventoryTransaction,
posts GL entries (DR COGS / CR Inventory). QR scan button retained as icon.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The quote discount must come from the agreed quote price, not the job's pricing
snapshot (which may have DiscountAmount=0 for legacy or unset reasons). The job
snapshot fix only applies to direct jobs where no source quote exists.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- DueDate was computed from DefaultTurnaroundDays (a shop ops setting) instead
of from the payment terms string; now uses PaymentTermsParser throughout
- Discount was never applied for direct jobs (PricingBreakdownJson was read for
fees but DiscountAmount was silently skipped)
- Quote-based jobs used sourceQuote.DiscountAmount, ignoring any discount edits
made to the job after quote conversion; now prefers the job's pricing snapshot
- Payment terms and due date now inherit from sourceQuote.Terms → customer.PaymentTerms
→ company default, so the invoice reflects the agreed or customer-specific terms
- EarlyPaymentDiscount fields now populated from inherited terms
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes the ShopWorker and ShopWorkerRoleCost entities, all related DTOs,
mappings, controllers, views, and import/export paths. Worker identity is
now handled entirely through ApplicationUser with per-user LaborCostPerHour.
ShopWorkerRoleCosts table remains in production pending manual data migration.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Removes the ShopWorker and ShopWorkerRoleCost entities, all related DTOs,
mappings, controllers, views, and import/export paths. Worker identity is
now handled entirely through ApplicationUser with per-user LaborCostPerHour.
ShopWorkerRoleCosts table remains in production pending manual data migration.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Jobs used company default TaxPercent for every pricing recalculation
(Create, Edit, UpdateItems, DeleteJobItem) without checking the customer's
IsTaxExempt flag. Added GetEffectiveTaxPercentAsync helper and wired it
into all seven call sites so tax-exempt customers are never billed tax
regardless of which path triggers the recalc.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Store complete PricingBreakdownJson snapshot on Job at every save point so
the Details page reads stored data rather than re-running the pricing engine
- Add 7 missing fields to Quote entity (FacilityOverheadCost, tier/quote discounts,
SubtotalAfterDiscount) and persist them via ApplyPricingSnapshot
- Fix OvenCostId-as-rate bug in JobsController (FK was passed as decimal $/hr)
- Fix hardcoded LaborCost * 0.4 multiplier in two JobItemAssemblyService overloads
- Fix FacilityOverheadCost dropped from invoices in both quote and direct-job paths
- Fix RushFee missing from direct-job invoices (read from PricingBreakdownJson)
- Fix Notes and CatalogItemId not copied to InvoiceItem
- Add 14 unit tests in PricingStageFlowTests covering all three pricing stages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Remote sessions (customer's phone) no longer get the 45-second inactivity redirect
that requires a KioskDevice cookie — would have landed them on an error page.
Intakes staff table hides non-essential columns on small screens so the primary
customer/status/actions columns are visible without horizontal scrolling.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
BatchId (Guid?) is stamped on every certificate in a bulk run so the batch
is permanently addressable. BulkResult is now a bookmarkable GET by batchId
rather than TempData, so users can return to re-download at any time.
BatchDownloadPdf is a GET link (no form POST needed). Index shows a Batch
badge on bulk certs that links directly back to the batch result page.
Migration: AddGiftCertificateBatchId
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The HTML entity sweep script had a bug where it wrote empty files for any
view that contained no target Unicode characters, zeroing out 215 view files.
All views restored from the pre-sweep commit (cefdf3e).
Bulk gift certificate feature:
- BulkCreateGiftCertificateDto with Quantity (1-500), Amount, Reason, Expiry, Notes
- GenerateBulkGiftCertificatePdfAsync on IPdfService / PdfService: one Letter page
per cert, reusing the same purple/gold branded ComposeGiftCertificateContent helper
- GiftCertificatesController: BulkCreate GET/POST, BulkResult GET, BulkDownloadPdf POST
- Views: BulkCreate.cshtml (form with live total preview), BulkResult.cshtml (table +
Download All PDF button that POSTs cert IDs to avoid URL length limits)
- gift-certificate-bulk.js: live preview + spinner/disable on submit
- Index.cshtml: Bulk Create button added alongside New Certificate
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Carry OvenBatches/OvenCycleMinutes from Quote → Job entity (was missing fields; all job pricing recalcs hardcoded 1/null)
- Fix invoice creation from job always showing Quantity=1 (was using TotalPrice as UnitPrice with qty 1)
- Add IsAiItem to JobItem + migration; map in all 3 JobItemAssemblyService.CreateJobItem overloads so AI photo jobs no longer double-price on first edit after quote→job conversion
- Propagate IsAiItem through all existingItemsData JSON blocks in Jobs views (Edit, EditItems, Create) so the wizard preserves AI routing on re-edit
- Add PricingRoutingFlags_ExistOnBothQuoteItemAndJobItem structural test + 3 behavioral IsAiItem tests to JobItemAssemblyServiceTests
- Consolidate item wizard partials (_ItemWizardModal, _SqFtCalculatorModal) and item-wizard.css into shared locations
- Document pricing flag propagation checklist in CLAUDE.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- CatalogLookup now returns all partial color name matches ranked by
specificity (exact vendor+color first, same-vendor partial, cross-vendor)
with isExact flag so JS can decide to auto-fill vs show modal
- Removed cross-vendor fallback that was silently overwriting manufacturer
field with wrong brand when vendor-scoped search found nothing
- Picker modal now includes "Not listed — search online" option that
triggers AI lookup as an escape hatch from the catalog results
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Companies list and Company Health now hide Expired/Canceled accounts
whose subscription ended 14+ days ago; show/hide toggle via banner
- KPI cards on Company Health exclude churned tenants when hidden
- showChurned param threads through sort, pagination, search, and filter forms
- Powder catalog: fix missing UnitPrice on user-contributed entries;
add back-sync to fill catalog gaps on existing matches; wire
AiAugmentFromUrl and manual inventory Create into catalog contribute path
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Route param renamed customerId→id so /Kiosk/SmsConsent/15307 binds correctly
(default MVC route uses {id}; mismatched name caused GetByIdAsync(0)→404→loop)
- Cache entry cleared in GET (not just POST) so returning to Welcome after seeing
the form never redirects again
- Added POST /Kiosk/CancelSmsConsent for staff to free the kiosk if they pushed
consent accidentally — Customer Details shows a Cancel button after pushing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Notification bell:
- Bell now polls /InAppNotifications/Recent every 60s as a SignalR fallback
- Bell dropdown refresh on open so count is always current when staff looks at it
SMS consent → kiosk flow:
- Staff clicks "Get SMS Consent" on Customer Details → AJAX POST to
/Kiosk/PushSmsConsent stores customer in IMemoryCache (10 min TTL)
- Kiosk PollSession returns smsConsentPending + customerId so tablet navigates
to /Kiosk/SmsConsent/{customerId} automatically
- Customer reads TCPA consent on tablet, taps I Agree or No Thanks
- On agree: NotifyBySms/SmsConsentedAt/SmsConsentMethod set; in-app notification
fires; cache cleared; tablet returns to Welcome
- Removed Customers/SmsConsent (staff-browser version); moved view to Kiosk/
Button alignment:
- kiosk.css: added display:flex + align-items:center + justify-content:center to
all kiosk body buttons so content is centred vertically in tall button outlines
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New GET/POST Customers/SmsConsent/{id}: full-screen kiosk-layout page staff
opens and hands to the customer to read TCPA consent language and tap I Agree
- On agreement: sets Customer.NotifyBySms, SmsConsentedAt (UTC), SmsConsentMethod
= "InPerson", clears SmsOptedOutAt
- Redirects back if customer has already consented (no double-consent)
- Customer Details: "Get SMS Consent" badge link shown when NotifyBySms is false;
SMS on badge shows consent date on hover when consented
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New CompanyPreferences.KioskIntakeOutput setting ("Quote" default / "Job"): controls
what the kiosk creates on submission; shown as a card-style radio toggle in
Company Settings → Kiosk tab
- KioskSession.LinkedQuoteId added so quote-first sessions link back to the draft quote
- Migration AddKioskIntakeOutputSetting applies both schema changes
- ProcessSubmissionAsync branches on setting: creates Draft quote (quote-first) or
Pending job (job-first); save order fixed (CompleteAsync before using DB-assigned Id as FK)
- Terms.cshtml pricing paragraph is now dynamic: "subject to formal quote" for Quote mode,
"team member will reach out about pricing" for Job mode
- Customer Intakes list: "View Quote" button appears when LinkedQuoteId is set
- Notification label fixed: Remote sessions now say "Remote Intake", not "Walk-in Intake"
- Inactivity reset shortened to 45 s on intake steps
- Signature pad: hosted locally (no CDN), canvas resize deferred via requestAnimationFrame
- AI photo upload: client-side compression to ≤1200px + AbortController 120 s timeout
- Help article and AI knowledge base updated with kiosk feature
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New Help article at /Help/CustomerIntakeKiosk covering setup, in-person
and remote intake flows, what happens on submission, reviewing intakes,
and troubleshooting (signature pad, connection issues, seed data)
- Add kiosk entry to _HelpNav under Operations
- Update HelpKnowledgeBase: nav overview, full kiosk section, two new
common workflow entries (walk-in kiosk and remote intake)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Fix Jobs.Id FK violation: save job first with CompleteAsync() to get
its DB-assigned Id, THEN set session.LinkedJobId and save again.
Previously job.Id was still 0 when written to the nullable FK column.
- Replace ?? 1 fallbacks on JobStatusId/JobPriorityId with explicit
InvalidOperationException — hardcoded 1 may not exist in the company's
lookup tables; now fails loud with a clear message instead of an FK error.
- Add ValidateSessionState check to Terms POST so expired/already-submitted
sessions don't re-run ProcessSubmissionAsync and create duplicate jobs.
- Null-guard session.JobDescription before slicing for notification snippet.
- Tighten catch block: wrap the fallback CompleteAsync in its own try/catch
so a secondary failure doesn't mask the original error in logs.
- Swap Job.Description / SpecialInstructions: Description now holds the
actual job description text; SpecialInstructions records the intake source.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ProcessSubmissionAsync was creating a Job without JobPriorityId, leaving
it as 0 which violates the FK to JobPriorityLookups. Look up the NORMAL
priority the same way JobsController does everywhere else.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
_KioskLayout inactivity timer now reads ViewBag.InactivityTimeoutMs
(defaults to 5 min). PopulateKioskViewBagFromSession sets it to 45 s
on every intake step so an abandoned form auto-returns to the waiting
screen. Welcome screen and Confirmation page are unaffected.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
SignalR WebSocket and SSE both receive immediate 'Handshake was canceled' from the
server-side hub context. The 15-second delay between negotiate and SSE connect
reveals the handshake timer has expired before the transport opens — caused by Azure
App Service's ingress proxy resetting anonymous long-lived connections.
Replacement: /Kiosk/PollSession (anonymous GET, no-cache) queried every 3 seconds.
Returns the most recent Active InPerson session created in the last 60 seconds.
The kiosk navigates when hasSession=true. Status dot: gray->green on first success,
yellow on network error, blue when navigating. Removed signalr.min.js from kiosk layout.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three bugs identified:
1. Routing: /Kiosk/Intake/{token}/{action} had no matching route — 4-segment
URL fell through the default 3-segment {controller}/{action}/{id?} route.
Added explicit kiosk_intake route in Program.cs.
2. View names: Contact/Job/Terms/Confirmation actions returned View(model)
which resolved to Views/Kiosk/{Action}.cshtml — those files don't exist.
Views live in Views/Kiosk/Intake/. Fixed all six return statements.
3. Diagnostics: conn dot now starts gray ("Connecting...") and turns green
only when SignalR actually connects. Red + message if no company ID or
connection fails. Makes it easy to confirm the hub connection is live.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Without this, data-company-id was empty and the JS connected to kiosk- (no ID),
so StartSession signals never reached the tablet.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>