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>
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>
- 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>
- 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>
The Quotes index now excludes Converted status by default so the list
stays focused on active work. Converted quotes remain accessible via
the status filter dropdown. Selecting any explicit status filter
(including Converted) bypasses the exclusion as expected.
Also consolidated the two GetQuoteStatusLookupsAsync calls into one
at the top of the action since the cache makes it free.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bug 1 — Invoice total didn't match job total for direct jobs:
- Root cause: all three item-save paths in JobsController passed null for
ovenCostId, so FinalPrice/ShopSuppliesAmount were stored without oven cost
while the Details page recalculated live with OvenCostId and showed higher.
- Add OvenBatchCost stored field to Job entity (migration AddJobOvenBatchCost,
default 0 for existing rows).
- Fix Create, Edit, and UpdateItems to pass job.OvenCostId and save OvenBatchCost.
- Fix InvoicesController.Create GET for direct jobs to use stored OvenBatchCost
and ShopSuppliesAmount as separate labeled lines instead of recalculating
shop supplies from scratch (which excluded the oven cost base).
Bug 2 — Quote status stayed Draft after "Send Quote via Email":
- ResendQuote advanced the approval token and sent the email but never
updated the status. Added Draft → Sent advancement (same guard used by
the SMS send path) so the status updates on successful email send.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- AccountingDropdownHelper: wired into BillsController and ExpensesController,
replacing 35-40 lines of duplicated DB queries per controller
- AppConstants.StatusCodes: added Job.* and Quote.* constants to replace all
magic status strings across Jobs, Quotes, Appointments, OvenScheduler,
AiQuickQuote, QuoteApproval, and AccountingDropdownHelper
- AccountingRules: extracted IsNormalDebitBalance into shared Infrastructure
helper; removed duplicate private method from AccountBalanceService and
LedgerService (~50 lines deleted)
- AccountDataExportController: extracted 9 Fetch*Async methods (superset of
includes) so Add*Sheet and Build*Csv no longer duplicate DB queries; each
entity is queried once regardless of whether XLSX or CSV format is requested
- BillsController.Create and ExpensesController.Create wrapped in
ExecuteInTransactionAsync; blob uploads moved after commit to keep
financial data atomic and prevent orphaned blobs from rolling back
- Number generators (Appointments, CreditMemo, OvenBatch) fixed from full-table
GetAllAsync to prefix-filtered FindAsync
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>
- 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>
- 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>
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>
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>
- 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>
- 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>
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>
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>
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>
- 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>
Each catalog item now supports one optional image (jpg/jpeg/png/gif/webp,
max 10 MB). Uploading generates a 200x200 JPEG thumbnail automatically via
SixLabors.ImageSharp. Images are stored in Azure Blob Storage under a new
catalogimages container, keyed by {companyId}/catalog/{itemId}/.
- CatalogItem entity: ImagePath + ThumbnailPath (nullable string fields)
- Migration: AddCatalogItemImages applied
- ICatalogImageService / CatalogImageService: upload, thumbnail generation,
delete; old blobs replaced atomically on re-upload
- CatalogItemsController: Create/Edit accept optional IFormFile image;
Image(id, thumbnail) action serves blobs with [Authorize] so wizard users
can load thumbnails without CanManageProducts policy
- Catalog index (_CategoryNode): 40x40 thumbnail (or placeholder icon)
left of each item name
- Details view: image card in right column with click-to-full-size link
- Create/Edit views: file picker with live preview; Edit shows current
thumbnail with Remove checkbox
- Wizard (item-wizard.js): thumbnails in product list with hover preview
that follows the cursor (showCatalogPreview / moveCatalogPreview);
fixed Bootstrap d-flex !important bug that broke the filter box by
moving flex layout to an inner wrapper div
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New AI Quick Quote floating button: staff type a verbal description to
get an instant price estimate for phone/walk-in customers; detected
color names are fuzzy-matched against inventory for stock status;
saves draft quote under a Walk-In / Phone customer with one click
- Inline customer change on Quote Details and Job Details: always-visible
native select with inline confirmation banner (no TomSelect dependency);
ChangeCustomer AJAX endpoints on QuotesController and JobsController
- Quote Edit page: customer dropdown is now editable (lock removed)
- Fix AutoMapper missing CatalogCategory -> UpdateCategoryDto mapping
that caused a crash on the catalog category Edit page
- Help docs and AI knowledge base updated for all three features
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>