- 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>
- 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>
QuoteDeclinedByCustomer was used for both approve and decline responses,
so approval notifications showed the wrong type in the log. Added a distinct
QuoteApprovedByCustomer = 16 enum value, wired up the correct type in
NotificationService, added default templates in both the service fallback
dictionary and SeedData, and updated placeholder hints in CompanySettings.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The SMS path was sending the message but never updating QuoteStatusId or
SentDate, leaving the quote in Draft. Now mirrors the email send path:
transitions Draft → Sent and stamps SentDate on first send only.
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>
The banner fires when quote.UpdatedAt > job.QuoteSnapshotUpdatedAt. The
snapshot was captured before saving quote.ConvertedToJobId, so the EF
interceptor's automatic UpdatedAt stamp on that save always made the quote
appear newer than the snapshot — triggering the banner on every freshly
converted job even with no actual changes.
Fix: after saving ConvertedToJobId, re-stamp QuoteSnapshotUpdatedAt to the
quote's final UpdatedAt value and save the job once more. The snapshot now
includes the conversion write, so the comparison is equal (not "after") and
the banner stays hidden until the quote genuinely changes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PricingBreakdown was only populated when quote.Total > 0, but the Details
view unconditionally dereferences PricingBreakdown.ItemsSubtotal. Sandblast-
only quotes can legitimately have a $0 total (no powder/oven costs), leaving
PricingBreakdown null and crashing the Details render.
Removed the Total > 0 guard from both Details action overloads — always
populate PricingBreakdown from the stored snapshot fields (all values are 0
for an unpriced or sandblast-only quote, which is safe for display).
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>
Companies with null StripeSubscriptionId are on trial and have no real
subscription income. They were being counted as paying Active/GracePeriod
customers, inflating MRR, ARR, plan distribution, and 12-month trend.
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>
AiLookup and ScanLabel were running separate catalog lookup + auto-contribute
code paths. Both now go through EnrichFromCatalogAsync so any future change
to catalog logic only needs to be made once.
- EnrichFromCatalogAsync: private helper that finds a matching PowderCatalogItem
by SKU + manufacturer, overwrites AI-inferred spec fields with catalog values
(catalog is authoritative), fills gaps for URL/price fields with ??=, and
optionally auto-contributes new entries to the platform catalog. Returns
(wasInCatalog, addedToCatalog) for callers that show UI badges.
- AiLookup: now calls EnrichFromCatalogAsync then ApplyTdsCureFallbackAsync
before returning — same enrichment pipeline as ScanLabel.
- ScanLabel: replaced ~50-line inline catalog block with two helper calls.
Return statement simplified from catalogMatch?.X ?? aiResult.X to just
aiResult.X since EnrichFromCatalogAsync already merged catalog values in.
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>
After the main AI lookup and catalog search, if CureTemperatureF or
CureTimeMinutes is still null but a TDS URL was found, fetch that page
and ask Claude to extract just the cure schedule.
- IInventoryAiLookupService.FetchTdsCureSpecsAsync: new interface method
- InventoryAiLookupService.FetchTdsCureSpecsAsync: fetches the TDS URL via
the existing FetchPageAsync pipeline (JSON-LD + doc-link extraction, HTML
stripping). If the page is a PDF or unreachable, returns Success=false
silently so no error surfaces in the UI. Otherwise sends a small targeted
prompt that asks only for cureTemperatureF and cureTimeMinutes and uses
MaxTokens=256 so the call is fast and cheap.
- InventoryController.ScanLabel: after catalog lookup, computes the resolved
cure values (catalog preferred over AI result). If either is null and a
TDS URL exists, calls FetchTdsCureSpecsAsync and merges any newly found
values back into aiResult before building the JSON response.
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>
After resolving manufacturer + SKU from the scan, ScanLabel now queries the
tenant's InventoryItems: first by ManufacturerPartNumber exact match (most
precise), then by ColorName + Manufacturer fuzzy match as fallback.
If a match is found, the response includes existingInventoryId and
existingInventoryName. The JS fillFromScan() shows a warning banner with a
direct link to the existing item instead of the normal success message. Form
fields are still pre-filled so the user can proceed to add a new entry (e.g.
a different lot or bag size) if that was the intent.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
QR scanning:
- Run BarcodeDetector and jsQR in parallel — jsQR starts after JSQR_DELAY_MS
(1.5 s) so both decode simultaneously. BarcodeDetector silently returns empty
arrays for some QR variants; running jsQR in parallel via a separate rAF loop
(rafId2) and its own off-screen canvas catches those cases. First decoder to
find anything calls handleQrResult and sets qrFound = true; the other stops.
Price extraction (two bugs):
- ScanLabel: unitPrice was catalogMatch?.UnitPrice ?? 0m, ignoring aiResult
.UnitCostPerLb entirely when no catalog match — changed to fall through to AI result
- AppendOffer: only read JSON-LD "price" field; Shopify AggregateOffer uses
"lowPrice" instead — now checked as fallback so Prismatic Powders prices are found
Camera pre-warm:
- Reverted localStorage approach (caused getUserMedia to fire on every page load,
showing Chrome's "Ask" prompt immediately before user clicked anything)
- Restored Permissions API gate: preWarmCamera only calls getUserMedia when
navigator.permissions.query returns 'granted', never risks a page-load prompt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- LookupByUrlAsync now maps all identity + spec fields from Claude response
(manufacturer, SKU, colorName, description, sdsUrl, tdsUrl, unitCostPerLb, etc.)
Previously only augmenting fields were mapped; Columbia QR path left 80% blank
- Vision scan follow-up: after ScanLabelAsync reads label text, automatically run
LookupAsync using the extracted manufacturer + color/SKU to fill SDS/TDS URLs,
product page, image, description, and any specs not printed on the bag;
label values (cure schedule, SKU) remain authoritative and are never overwritten
- SDS/TDS URL extraction: added ExtractDocumentLinks() that scans anchor tags in
raw HTML before tag-stripping, injects found URLs as [Structured Data] lines so
Claude can read and echo them back in the JSON response; previously all hrefs
were lost with the HTML stripping
- Added SdsUrl/TdsUrl to InventoryAiLookupResult, Claude system prompt JSON schema,
LookupAsync mapping, and ScanLabel response (catalog match ?? aiResult fallback)
- SDS/TDS now also stored on auto-contributed catalog entries
- jsQR inversionAttempts: 'dontInvert' → 'attemptBoth' for better QR detection
under varying label contrast and lighting conditions
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>
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>
Customers who replied STOP by mistake can now reply START, YES, or
UNSTOP to automatically re-enable their SMS opt-in — no staff action
needed. Adds SmsInboundStart notification type, HandleStartAsync in
WebhooksController, and updates AI knowledge base and help docs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add /SmsAgreements SuperAdmin page listing per-company SMS terms acceptance
status, with stats cards, filter/search, and a full acceptance history modal
(terms version, accepted by, timestamp, IP, user agent)
- Add SMS Agreements nav link under Tenants & Billing in the platform sidebar
- Update HelpKnowledgeBase and Help docs (Quotes, Settings) to document
quote approval via SMS and the reuse of existing approval tokens
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add 'Send Quote via SMS' button on quote details page that sends the approval
link to the customer via SMS (respects NotifyBySms, handles prospects via ProspectPhone)
- Reuses existing valid approval token rather than regenerating, so a previously
emailed link stays valid when SMS is also sent
- Fix Twilio appsettings.json placeholders (real credentials moved to gitignored
appsettings.Development.json)
- Fix passkey login ignoring ReturnUrl: biometric login on the login page now
respects the form's ReturnUrl hidden field so QR-code and deep-link flows
redirect correctly after authentication instead of always going to the dashboard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Three-tier SMS gate: platform kill-switch → admin force-disable → plan AllowSms → company opt-in
- CompanySmsAgreement entity records admin acceptance of TCPA terms with IP, user agent, and terms version
- SMS terms of service modal on Company Settings with versioned re-agreement (AppConstants.SmsTermsVersion)
- Dev redirect: non-production SMS routed to Twilio:DevRedirectPhone to protect real customer numbers
- Removed redundant Ready for Pickup SMS (Job Completed covers it)
- Role-based compose modal on job completion: Admin/Manager reviews and edits before send; ShopFloor auto-sends
- Send SMS button on job details for ad-hoc messages (Admin/Manager only)
- SendJobSmsAsync auto-appends STOP opt-out language if missing
- Migrations: AddSmsGating, AddCompanySmsAgreement
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
DashboardReadService no longer loads full entity lists and filters in memory.
All job panels (today/overdue/in-progress) now execute targeted COUNT + capped
SELECT queries in SQL. AR aging buckets, powder order lines, bill totals, and
active-customer counts are all aggregated at the DB level. The SuperAdmin action
previously loaded every company row to compute plan distribution and alert lists;
it now delegates to a new GetSuperAdminDashboardDataAsync() that uses SQL GROUP BY
and projections instead.
DashboardIndexData record updated to carry pre-sliced counts and capped lists so
the controller only does lightweight DTO projection. DashboardPowderOrderLineData
replaces the deep Job→JobItem→Coat Include chains with a single flat coat query
projected in SQL. OnlineUserMiddleware switches its per-user throttle from a
static ConcurrentDictionary (grows forever) to IMemoryCache with a 60-second
sliding expiry.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When a source quote is edited after a job was created from it, the job
details page now shows a warning banner with the date of the change and
a link to the quote. Two actions are offered:
- Re-sync from Quote: replaces all job items, coats, prep services, and
pricing from the current quote. Only available while the job is still
in a pre-production status (Pending, Quoted, Approved); hidden once
shop work has started (InPreparation or beyond).
- Dismiss: acknowledges the change without altering the job, clearing
the banner by advancing the stored snapshot timestamp.
Implemented via Job.QuoteSnapshotUpdatedAt (new nullable column), set at
quote→job conversion time. The banner fires when quote.UpdatedAt exceeds
this baseline. Migration: AddJobQuoteSnapshotUpdatedAt.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Progress widget dismiss now POSTs to Dashboard/DismissProgressWidget, writing
GuidedActivationDismissedAt to the DB so the widget stays hidden across devices
and cache clears (localStorage alone wasn't enough). BuildShopProgressWidgetAsync
suppresses the widget server-side when AllDone + dismissed.
New SuperAdmin page at /OnboardingProgress shows the activation funnel across
all tenant companies: wizard status, chosen path, milestone progress bar, key
dates (first job/quote, first invoice, workflow completed, widget dismissed),
and a status badge (Not Started / In Progress / Complete / Dismissed). Nav link
added under Users & Activity in the Platform Management sidebar.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous AnyAsync check used global query filters which hide
soft-deleted records. Deleting a lookup sets UpdatedAt on the record
(EF interceptor stamps Modified entities) but the IsDeleted filter
made it invisible to the query. Added ignoreQueryFilters: true with
an explicit CompanyId predicate so soft-deleted lookups are included —
any deletion or edit now correctly marks the step complete.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Injects ISubscriptionService into DashboardController and calls
GetUserCountAsync to check the plan's MaxUsers limit. When MaxUsers == 1
the "Bring your crew in" step is omitted from the progress widget entirely,
so solo-plan users aren't prompted to do something their subscription
doesn't allow. Plans with MaxUsers > 1 or unlimited (-1) show the step
as before.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Setup Wizard: reduced from 10 steps to 5 (Company Info → QB Migration →
Pricing Defaults → Named Ovens → Notifications). Removed Doc Numbering,
Job Settings, Payment Terms, Pricing Tiers, and Team Members steps — these
all have sensible defaults and are accessible any time in Company Settings.
Wizard now completes in ~5 minutes instead of 15–20.
Dashboard progress widget (new): "Get the most out of your shop" checklist
appears for Company Admins after wizard completion. Tracks six post-setup
activation tasks with dynamic progress badge, motivating subtitle copy,
collapsed-state persistence via localStorage, and a full completion state
("Your shop is fully set up 🎉") that replaces the checklist at 100%.
The next recommended step is highlighted with a solid CTA button and a
subtle blue row tint. Completed steps show encouraging green subtext instead
of just "Done". Widget disappears from controller when AllDone would have
caused a silent vanish — now renders the completion state instead.
Guided activation (Daily Board): rewrote the BoardIntroStep callout to lead
with "This is your shop in real time" and a plain-English description of the
board's purpose. Added a separate InstructionText field to
GuidedActivationCalloutViewModel so the "Move this job to the next stage"
action prompt renders as a distinct bold line with an arrow icon rather than
being buried in the body copy. After the stage change, the confirmation
callout now reads "Nice — your workflow just updated" to reinforce what just
happened before prompting the invoice step.
All copy passes the "shop owner, not SaaS" test: no technical jargon,
benefit-driven descriptions, natural language throughout.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Phase 3 — eliminated ApplicationDbContext from all non-exempt controllers,
routing all data access through IUnitOfWork. Added IPlainRepository<T> for
the four platform entities (Announcement, BannedIp, DashboardTip, ReleaseNote)
that intentionally don't extend BaseEntity and therefore can't use the
constrained IRepository<T>. Added permanent-exception comments to the 18
controllers that legitimately retain direct DbContext access (Identity infra,
cross-tenant platform ops, bulk streaming exports).
Phase 4 — added EnforceDataAccessArchitecture() to Program.cs, a startup
gate that reflects over every Controller subclass and throws at boot if any
non-exempt controller injects ApplicationDbContext. The app cannot start with
a violation.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Migrated InvoicesController, QuotesController, JobsController, BillsController,
PurchaseOrdersController, and CustomersController to route all data access
through IUnitOfWork typed/generic repositories instead of injecting
ApplicationDbContext directly.
New typed repositories added: IJobRepository (GetScheduledJobsForDateAsync,
GetActiveJobsForMobileAsync, LoadForCostingAsync), INotificationLogRepository
(GetLatestForJobAsync, GetAllForJobAsync), IQuoteRepository (GetItemsWithCoatsAsync
with CatalogItem eager load + AsNoTracking), and IJobRepository.GetOrphanedConversionJobAsync.
All EF complex include chains relocated into repository methods; controllers now
call named query methods rather than composing raw IQueryable chains.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Email delivery failures and PDF generation errors now show a permanent
warning/error toast that requires manual dismissal, so users cannot
accidentally miss critical action-blocking feedback.
- ToastHelper: WarningPermanent TempData key + Warning/WarningPermanent
extension methods on both ITempDataDictionary and Controller
- SetNotificationResultToast: NotificationStatus.Failed now uses
ToastWarningPermanent (previously auto-dismissed in 5 s)
- InvoicesController.Send: TempData["Warning"] → TempData["WarningPermanent"]
when PDF generation or email dispatch fails
- InvoicesController.DownloadPdf: TempData["Error"] → TempData["ErrorPermanent"]
with the actual exception message so root cause is visible
- _Layout.cshtml: WarningPermanent hidden div
- toast-notifications.js: WarningPermanent handler (timeOut: 0)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- PasskeyController: set LastLoginDate on passkey sign-in so Company Health
and audit pages show accurate last-login times (was always showing 'Never')
- Jobs/Index status modal: disable 'Notify customer' email toggle and show
warning when customer has notifications turned off; CustomerNotifyByEmail
added to JobListDto + JobProfile mapping + data-customer-notify attribute
- Quotes/Create: disable 'Send quote via email' checkbox with 'Notifications
off' badge when selected customer has email opt-out; ViewBag.CustomerEmailOptOutIds
added alongside existing CustomerTaxExemptIds pattern
- Quotes/Create: Quick Quote / Full Quote segmented toggle at top of form;
hides non-essential fields (dates, notes, tags, oven, discount, photos) in
Quick mode; selection persisted in localStorage
- InvoicesController Send action: improved error logging and user-facing
warning when PDF generation or email dispatch fails after status is saved
- item-wizard.js: guard item restoration with try/catch; ensure writeHiddenFields
always runs on form submit via capture-phase listener
- Help docs and AI knowledge base updated for all new features
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add "Don't ask me again" to passkey enrollment prompt (PasskeyPromptDismissed
field on ApplicationUser; DismissPrompt POST action; migration applied)
- Add Subscription & Features button to Companies/Index btn-group and
Companies/Edit header for direct navigation to SubscriptionManagement/Manage
- Add Edit Company back-link on SubscriptionManagement/Manage
- Remove duplicate AI Features section from Companies/Edit (managed exclusively
via Subscription & Features page)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- GET: sets ViewBag.NextRunAvailable if last run was within 90 days;
view disables the button and shows the next eligible date
- POST: returns early with a warning if called before the 90-day window
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Skip $0-priced items (placeholders/category headers) in RunAiPriceCheck
- Build full category path (e.g. "Cerakote > Firearms") via BuildCategoryPath
so Claude receives coating-type context — Cerakote pricing differs significantly
from standard powder coat
- Update AI system prompt to instruct Claude to use the category path when
determining process type, equipment, cure times, and market rates
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Root cause: MaxTokens=4096 was too low — 25 items at ~250 tokens each hit the
limit mid-array (logged error showed Path: $[17]).
- MaxTokens: 4096 → 8192
- BatchSize: 25 → 15 items (keeps each response well under the limit)
- StripJsonFences → ExtractJsonArray: now also handles prose before/after the
JSON array, and recovers truncated responses by finding the last complete
object and closing the array — so partial batches return whatever Claude
finished rather than nothing
- GET action: added try-catch around ResultsJson deserialization so a bad DB
row shows a friendly "re-run" warning instead of a raw error page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Shows a modal overlay with animated progress bar and batch-aware status messages
while Claude is analyzing. Progress animates in two phases: ease-out to ~85%
over the estimated duration, then a slow crawl to 99% so it never falsely
"completes" before the server responds.
- Overlay driven by CSS (hidden until .active added by JS)
- Item count passed from controller as data-item-count on the run button
- Batch count derived from item count (batches of 25) to show accurate
"Analyzing batch N of M…" messages
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude reviews every active catalog item against the shop's own operating costs
and returns a per-item verdict (below-cost / thin-margin / high / ok) with a
suggested price range, cost floor, and assumptions.
- New entity: CatalogPriceCheckReport (JSON blob, archived per company)
- New service: IAiCatalogPriceCheckService / AiCatalogPriceCheckService
batches items 25 at a time to stay within model context limits
- Two new controller actions: GET AiPriceCheck (view report) + POST RunAiPriceCheck
- AiPriceCheck view: summary cards (counts by verdict), color-coded item cards
with Edit Price link, assumptions detail, and loading spinner on submit
- AI Price Check button added to catalog Index header
- Migration AddCatalogPriceCheckReport applied
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After password login, users are routed through /Passkey/EnrollPrompt
before reaching the dashboard. The page shows an Enable / Maybe later
choice using the auth layout for a clean full-screen experience.
Users who already have a passkey are skipped past instantly.
Removes the floating bottom-right card from _Layout — the dedicated
page is a better UX touchpoint (one moment, right after login, rather
than a floating card on every page).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>