Compare commits

..

61 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Build clean; 225 unit tests pass.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 19:34:54 -04:00
spouliot 8768e9813b Merge dev into master for release v2026.05.19b 2026-05-19 18:40:57 -04:00
spouliot 4a7087cc0c Fix NoExtraLayerCharge dropped in DeleteItem pricing recalculation
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>
2026-05-19 18:37:29 -04:00
spouliot 59b152c89f Fix noExtraLayerCharge missing from Job Details wizard item projection
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>
2026-05-19 18:36:04 -04:00
spouliot 441898b52f Fix NoExtraLayerCharge not persisting on quotes and job EditItems reload
- 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>
2026-05-19 18:33:50 -04:00
spouliot 3e30397302 Sync master back to dev (IF EXISTS migration hotfix) 2026-05-19 18:24:39 -04:00
spouliot 31c5746e5b Guard ShopWorker drops in AddAppointmentReminderSentAt migration with IF EXISTS
Prod and dev databases diverged on whether ShopWorker tables and indexes
exist, causing unconditional DROP statements to fail on prod. Replaced
all individual DropForeignKey/DropTable/DropIndex/DropColumn calls with
a single SQL block using IF EXISTS guards so the migration runs safely
regardless of DB state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 17:43:30 -04:00
spouliot 3f9ac27afa Merge dev into master for release v2026.05.19 2026-05-19 16:37:26 -04:00
spouliot df504674e9 Add oven/batch settings to job create and edit forms
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>
2026-05-19 16:27:54 -04:00
spouliot 07796b05c8 Clear ReminderSentAt when appointment is rescheduled
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>
2026-05-19 16:03:58 -04:00
spouliot 2bf8871892 Fix NoExtraLayerCharge persistence, appointment reminders, coat notes display, scroll restoration, and invoice Send dead-button
- Appointment reminders: add AppointmentReminderBackgroundService (60s poll), ReminderSentAt
  dedup stamp, NotifyAppointmentReminderAsync sends both customer email and creator staff email;
  AppointmentReminderStaff notification type + default template added; DateTime.Now used instead
  of UtcNow to match locally-stored ScheduledStartTime; ToLocalTime() double-conversion removed

- NoExtraLayerCharge not persisted: flag existed on CreateQuoteItemCoatDto and was used by
  pricing engine but never written to JobItemCoat/QuoteItemCoat entities — every edit reset it
  to false and re-applied the extra layer charge; added column to both entities (migration
  AddNoExtraLayerChargeToCoats), both read DTOs, all 3 JobItemAssemblyService overloads,
  JobItemCoatSeed inner class, and existingItemsData JSON in all 5 wizard views; fixed JS
  template path that hard-coded noExtraLayerCharge: false

- Coat notes not visible: notes were rendered in desktop job details but missing from the wizard
  item card summary and the mobile card view; both fixed

- Scroll position lost on item save: sessionStorage save/restore added to item-wizard.js owner
  form submit handler; path-keyed so cross-page navigation does not restore stale position;
  requestAnimationFrame used for reliable mobile scroll restoration

- Invoice Send dead button: #sendChannelModal was gated inside @if (isDraft) but the button
  targeting it fires for Sent/Overdue invoices too when customer has both email and SMS; modal
  moved outside the Draft guard

- InitialCreate migration added for fresh database installs; Baseline migration guarded with
  IF OBJECT_ID check so it no-ops on fresh DBs; Razor scoping bug fixed in Customers/Index.cshtml

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 15:48:16 -04:00
spouliot 8a0a564885 Merge dev into master for release v2026.05.18 2026-05-18 19:08:11 -04:00
spouliot dd4785b048 Fix empty-state button/text on list pages when search returns no results
Show 'Add Your First X' and onboarding copy only when the list is truly
empty. When a search or filter is active with no results, show 'Add X'
and 'No X match your search/filters' instead.

Affected: Customers (table + mobile views), Equipment, Inventory.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 18:49:23 -04:00
jenkins e185e3b7e3 Add XML doc comments to pricing assembly services
Added comprehensive XML documentation to JobItemAssemblyService and
QuotePricingAssemblyService — the most complex area of the codebase.
Comments explain the three-overload pattern, seed class rationale,
powder-to-order formula and industry default fallbacks, AI prediction
override tracking, and the incoming inventory auto-creation workflow.
PricingCalculationService was already well-documented; no changes needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 14:22:32 -04:00
spouliot 8acbc8605d Harden multi-tenant isolation across all user-facing controllers
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>
2026-05-17 18:04:22 -04:00
spouliot 485f0b69c8 Format Log Material dropdown as 'Manufacturer - Name (UoM)'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:51:32 -04:00
spouliot f380c152ca Promote job powders to top of Log Material dropdown
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>
2026-05-16 21:49:46 -04:00
spouliot 79c8c7e6a4 Add manufacturer to Log Material item combobox
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>
2026-05-16 21:46:48 -04:00
spouliot 6cf355071b Replace Log Material item dropdown with searchable combobox
Inventory lists grow over time; a plain <select> becomes unusable. The
new combobox filters as you type, supports keyboard navigation
(Arrow/Enter/Escape), and shows current stock on selection — matching
the pattern used by the powder picker in the item wizard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:41:14 -04:00
spouliot ebd474ae81 Fix log material dropdown showing undefined - camelCase JSON serialization
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>
2026-05-16 21:15:23 -04:00
spouliot 3c390a2e05 Merge branch 'dev' - invoice fixes, log material modal, complete job UX 2026-05-16 15:38:05 -04:00
spouliot 0df2353d4f Complete Job modal: ask powder usage once per color, not per item/coat
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>
2026-05-16 12:30:30 -04:00
spouliot be0a5b26e2 Update AI assistant and help docs for invoice and material logging changes
- HelpKnowledgeBase: invoice-from-job now mentions discount carried over,
  Discount Applied display row, and negative line items; new entry for
  PC-based Log Material modal on job details
- Help/Invoices.cshtml: from-job steps updated with discount/terms/due date
  pre-fill detail; sending section corrects due date source (quote/customer)
- Help/Jobs.cshtml: new "Logging Material Usage from a PC" section documenting
  the Log Material modal alongside the existing QR scan instructions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 12:15:20 -04:00
spouliot 36680eced9 Add manual Log Material modal to job details page
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>
2026-05-16 12:10:54 -04:00
spouliot 27aa4e0ea6 Invoice create: show discount row in totals, allow negative line items
- Add "Discount Applied" display row (red, hidden when zero) between subtotal
  and tax so users can see the discount being deducted at a glance
- Remove min="0" from UnitPrice and TotalPrice inputs (server-rendered and JS
  template) so negative adjustment lines can be entered without form rejection

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 11:41:47 -04:00
spouliot b2d6fae400 Fix failing test: revert quote-based discount to use sourceQuote.DiscountAmount
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>
2026-05-16 11:29:12 -04:00
spouliot 3a1928f9bf Fix invoice creation from job: discount ignored, wrong due date, wrong terms
- 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>
2026-05-16 10:45:40 -04:00
321 changed files with 44608 additions and 2609 deletions
-180
View File
@@ -1,180 +0,0 @@
{
"permissions": {
"allow": [
"Bash(dotnet build:*)",
"Bash(dir:*)",
"Bash(dotnet restore:*)",
"Bash(dotnet clean:*)",
"Bash(findstr:*)",
"Bash(dotnet ef migrations add:*)",
"Bash(dotnet ef migrations remove:*)",
"Bash(ls:*)",
"Bash(dotnet ef database update:*)",
"Bash(sqlcmd:*)",
"Bash(dotnet ef migrations script:*)",
"Bash(dotnet run:*)",
"Bash(timeout /t 15 dotnet run:*)",
"Bash(timeout /t 10 /nobreak)",
"Bash(ping:*)",
"Bash(start /B dotnet run:*)",
"Bash(test:*)",
"Bash(dotnet ef migrations:*)",
"Bash(grep:*)",
"Bash(xargs -I {} bash -c 'echo \"\"=== {} ===\"\" && head -20 {} | grep -E \"\"class|Authorize\"\"')",
"Bash(powershell:*)",
"Bash(dotnet tool install:*)",
"Bash(dotnet tool update:*)",
"Bash(xargs:*)",
"Bash(powershell -Command \"cd src\\\\PowderCoating.Web; dotnet ef migrations add UpdateQuoteForProspects --project ..\\\\PowderCoating.Infrastructure\")",
"Bash(powershell -Command:*)",
"Bash(taskkill:*)",
"Bash(netstat:*)",
"Bash(libman restore:*)",
"Bash(./start-app.bat)",
"Bash(dotnet-ef migrations add:*)",
"Bash(dotnet-ef database update:*)",
"Bash(./stop-app.bat)",
"Bash(timeout /t 3 /nobreak)",
"Bash(curl:*)",
"Bash(if [ -f \"stop-app.bat\" ])",
"Bash(then cmd.exe /c stop-app.bat)",
"Bash(else echo \"stop-app.bat not found\")",
"Bash(fi)",
"Bash(powershell.exe -Command \"Unblock-File -Path 'src/PowderCoating.Web/dotnet-tools.json'\":*)",
"Bash(powershell.exe -Command \"Get-Process | Where-Object {$_ProcessName -like ''*PowderCoating*''} | Stop-Process -Force\")",
"Bash(powershell.exe:*)",
"Bash(Select-String -Pattern \"error|Error\")",
"Bash(Select-String -NotMatch \"warning\")",
"Bash(tasklist:*)",
"Bash(dotnet add package:*)",
"Bash(start-process dotnet run:*)",
"Bash(Select-Object -ExpandProperty Id)",
"Bash(find:*)",
"Bash(cmd.exe:*)",
"Bash(dotnet ef dbcontext:*)",
"Bash(handle \"PowderCoating.Web.pdb\")",
"Bash(timeout:*)",
"Bash(del /F \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Web\\\\obj\\\\Debug\\\\net8.0\\\\PowderCoating.Web.pdb\")",
"Bash(Select-String -Pattern \"Build succeeded|Build FAILED|error\")",
"Bash(Select-Object -Last 10)",
"Bash(del \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Infrastructure\\\\Migrations\\\\20260211031319_RemovePreexistingCatalogData.cs\" \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Infrastructure\\\\Migrations\\\\20260211031319_RemovePreexistingCatalogData.Designer.cs\")",
"Bash(Select-String:*)",
"Bash(Select-Object -Last 5)",
"Bash(start-app.bat)",
"Bash(dotnet script:*)",
"Bash(dotnet list:*)",
"Bash(dotnet new:*)",
"Bash(stop-app.bat)",
"Bash(dotnet watch run:*)",
"Bash(cmd /c \"taskkill /F /PID 42108\")",
"Bash(cmd /c start-app.bat)",
"Bash(\"Y:/PCC/PowderCoatingApp/src/PowderCoating.Application/Services/PdfService.cs\":*)",
"Bash(/y/PCC/PowderCoatingApp/src/PowderCoating.Application/Services/PdfService.cs:*)",
"Bash(/tmp/remove_tempdata.pl:*)",
"Bash(chmod:*)",
"Bash(perl:*)",
"Bash(done)",
"Bash(cmd:*)",
"Bash(tail:*)",
"Bash(del:*)",
"Bash(dotnet add:*)",
"Bash(python3:*)",
"Bash(Stop-Process:*)",
"Bash(mv:*)",
"Bash(dotnet tool:*)",
"Bash(where libman:*)",
"Bash(find \"Y:/PCC/PowderCoatingApp\" -type f \\\\\\( -name \"*template*\" -o -name \"*import*\" -o -name \"*export*\" \\\\\\) -iname \"*.csv\" -o -iname \"*.xlsx\" -o -iname \"*.xls\" 2>/dev/null | head -50)",
"Bash(grep -n \"powderCostOverride\\\\|PowderCostOverride\\\\|pageMeta\\\\|quoteItems\\\\|existingItems\" \"Y:/PCC/PowderCoatingApp/src/PowderCoating.Web/Views/Quotes/Create.cshtml\" | head -20\ngrep -n \"powderCostOverride\\\\|PowderCostOverride\\\\|pageMeta\\\\|quoteItems\\\\|existingItems\" \"Y:/PCC/PowderCoatingApp/src/PowderCoating.Web/Views/Quotes/Edit.cshtml\" 2>/dev/null | head -20)",
"Bash(cat /tmp/sdktest/Program.cs | xxd | head -20)",
"Bash(cd /tmp/sdktest && rm -rf bin obj && cat Program.cs)",
"Bash(cat /tmp/sdktest/Program.cs | xxd | head -5)",
"WebSearch",
"WebFetch(domain:github.com)",
"WebFetch(domain:www.nuget.org)",
"Bash(wmic process:*)",
"Bash(grep -rn \"AI Photo\\\\|ai.*photo\\\\|photo.*quote\\\\|item-type\\\\|AiPhotoQuotes\\\\|ai_photo\" \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Web\\\\Views\\\\Quotes\\\\\" | grep -i \"photo\\\\|ai\" | head -20)",
"Bash(sed -i 's|\"aiAnalyzeUrl\": \"@Url.Action\\(\\\\\"AiAnalyzeItem\\\\\", \\\\\"Quotes\\\\\"\\)\",|\"aiAnalyzeUrl\": \"@Url.Action\\(\\\\\"AiAnalyzeItem\\\\\", \\\\\"Quotes\\\\\"\\)\",\\\\n \"aiPhotoQuotesEnabled\": @Json.Serialize\\(\\(bool\\)\\(ViewBag.AiPhotoQuotesEnabled ?? true\\)\\),|g' \\\\\n \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Web\\\\Views\\\\Quotes\\\\Edit.cshtml\" \\\\\n \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Web\\\\Views\\\\Jobs\\\\Create.cshtml\" \\\\\n \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Web\\\\Views\\\\Jobs\\\\Edit.cshtml\")",
"Bash(cp:*)",
"Bash(dotnet fsi -e \":*)",
"Read(//y/tmp/**)",
"Bash(cp /c/Users/spoul/.nuget/packages/stripe.net/50.4.1/stripe.net.50.4.1.nupkg stripe.zip)",
"Bash(unzip -o stripe.zip *.cs -d stripe_src)",
"Bash(dotnet ef:*)",
"Bash(Payment)",
"Bash(Deposit \")",
"Bash(node:*)",
"WebFetch(domain:quickbooks.intuit.com)",
"WebFetch(domain:www.saasant.com)",
"WebFetch(domain:www.liveflow.com)",
"WebFetch(domain:www.gentlefrog.com)",
"WebFetch(domain:blog.coupler.io)",
"WebFetch(domain:litextension.com)",
"WebFetch(domain:www.dancingnumbers.com)",
"WebFetch(domain:www.bizbooks.pro)",
"WebFetch(domain:support.saasant.com)",
"WebFetch(domain:support.getcount.com)",
"WebFetch(domain:planergy.com)",
"WebFetch(domain:www.wizxpert.com)",
"WebFetch(domain:www.trykeep.com)",
"WebFetch(domain:gentlefrog.com)",
"WebFetch(domain:www.syscloud.com)",
"WebFetch(domain:interopay.zendesk.com)",
"WebFetch(domain:docs.d-tools.cloud)",
"WebFetch(domain:paygration.com)",
"Bash([ ! -d \"Y:/PCC/PowderCoatingApp/src/PowderCoating.Web/Views/$controller\" ])",
"Bash(bash /tmp/check_actions.sh)",
"Bash(bash /tmp/verify_endpoints.sh)",
"Bash(bash /tmp/verify_services.sh)",
"Read(//y/PCC/Deployments/**)",
"Bash(mkdir -p \"Y:/PCC/Deployments\")",
"Bash(dotnet-script -e \"using System.Reflection; var a = Assembly.LoadFrom\\(\\\\\"Anthropic.SDK.dll\\\\\"\\); var types = a.GetTypes\\(\\).Where\\(t => t.Name.Contains\\(\\\\\"Document\\\\\"\\) || t.Name.Contains\\(\\\\\"Content\\\\\"\\)\\).Select\\(t => t.Name\\).OrderBy\\(n => n\\); foreach\\(var t in types\\) Console.WriteLine\\(t\\);\")",
"Bash(sort -t'-' -k3 -r)",
"Bash(wsl grep:*)",
"Bash(find src:*)",
"Bash(dotnet csharp *)",
"Read(//c/Users/spoul/.nuget/packages/stripe.net/50.4.1/lib/netstandard2.0/**)",
"Bash(dotnet publish *)",
"Bash(Compress-Archive -Path * -DestinationPath \"..\\\\deploy.zip\" -Force)",
"Bash(az webapp *)",
"Read(//y/PCC/**)",
"Bash(Get-Date -Format 'yyyyMMdd_HHmmss')",
"PowerShell(Get-Content *)",
"PowerShell(dotnet build *)",
"PowerShell(New-Item *)",
"PowerShell(& \"Y:\\\\PCC\\\\PowderCoatingApp\\\\scripts\\\\generate-migration-script.ps1\")",
"PowerShell(if \\(Test-Path \"Y:\\\\pcc\\\\deployment\\\\migrations.sql\"\\) { $f = Get-Item \"Y:\\\\pcc\\\\deployment\\\\migrations.sql\"; Write-Host \"File exists: $\\($f.Length\\) bytes\" } else { Write-Host \"File not created\" })",
"Bash(git add *)",
"Bash(git commit -m ' *)",
"Bash(git push *)",
"Bash(git commit *)",
"Bash(git checkout *)",
"Bash(git merge *)",
"Bash(dotnet package *)",
"Bash(dotnet test *)",
"Bash(git rm *)",
"Bash(git stash *)",
"Bash(dotnet ef *)",
"Bash(sqlcmd -S \".\\\\SQLEXPRESS\" -d PowderCoatingDb -Q \"SELECT Id, DisplayName, IsCoating, IsActive FROM InventoryCategoryLookups ORDER BY DisplayOrder\" -W)",
"Skill(schedule)",
"Bash(git -C \"//192.168.0.37/SCPSoftware/tmp/PowderCoatingApp-dev-perf\" log --oneline -10)",
"Bash(git -C \"//192.168.0.37/SCPSoftware/tmp/PowderCoatingApp-dev-perf\" status --short)",
"Bash(git *)",
"Bash(get-childitem -Recurse -Filter \"QuotesController.cs\")",
"Bash(Select-Object -ExpandProperty FullName)",
"Bash(dotnet user-secrets *)",
"Bash(Get-ChildItem -Path \"Y:\\\\PCC\\\\PowderCoatingApp\" -Directory)",
"Bash(Select-Object Name)",
"Bash(Get-Content *)",
"Bash(python -c \"import json; data=json.load\\(open\\('prismatic_powders.json','r',encoding='utf-8'\\)\\); print\\(f'Total records: {len\\(data\\)}'\\); print\\('First record:'\\); print\\(json.dumps\\(data[0], indent=2\\)\\)\")",
"Bash(python -c \"import json; data=json.load\\(open\\('prismatic_powders.json','r',encoding='utf-8'\\)\\); keys=list\\(data.keys\\(\\)\\); print\\('Top-level keys:', keys[:10]\\); first=data[keys[0]]; print\\('First record key:', keys[0]\\); print\\(json.dumps\\(first, indent=2\\)\\)\")",
"PowerShell(Get-ChildItem *)",
"PowerShell(Select-String *)",
"Bash(Select-Object -First 20)",
"PowerShell(node -e \"require\\('fs'\\).existsSync\\(require\\('path'\\).join\\(process.cwd\\(\\), 'node_modules', 'sharp'\\)\\) ? console.log\\('sharp ok'\\) : console.log\\('no sharp'\\)\")",
"WebFetch(domain:www.powdercoatinglogix.com)",
"PowerShell($bytes = [System.IO.File]::ReadAllBytes\\('src/PowderCoating.Web/Views/Jobs/Details.cshtml'\\); $text = [System.Text.Encoding]::UTF8.GetString\\($bytes\\); $idx = $text.IndexOf\\('hasPowderData'\\); $snippet = $text.Substring\\($idx - 20, 250\\); [System.Text.Encoding]::Unicode.GetBytes\\($snippet\\) | Format-Hex | Select-Object -First 30)",
"PowerShell($dll = \"C:\\\\Users\\\\spoul\\\\.nuget\\\\packages\\\\questpdf\\\\2024.12.3\\\\lib\\\\net6.0\\\\QuestPDF.dll\"; $asm = [Reflection.Assembly]::LoadFile\\($dll\\); $asm.GetTypes\\(\\) | Where-Object { $_.Name -eq \"ContainerExtensions\" } | ForEach-Object { $_.GetMethods\\(\\) | Where-Object { $_.Name -match \"Canvas|Rotat|Layer\" } | Select-Object Name } | Sort-Object Name -Unique)",
"PowerShell(Get-ChildItem \"C:\\\\Users\\\\spoul\\\\.nuget\\\\packages\\\\\" -ErrorAction SilentlyContinue | Where-Object { $_.Name -match \"quest|skia\" } | Select-Object Name)"
]
}
}
+27
View File
@@ -0,0 +1,27 @@
#!/bin/sh
# Pre-commit hook: block commits containing corrupted Unicode in .cshtml files.
#
# All corruption variants start with the UTF-8 byte sequence for a-circumflex
# followed by euro-sign (bytes C3 A2 E2 82 AC), which is the first two chars
# of every known corruption pattern. Grep for that byte sequence in staged files.
STAGED=$(git diff --cached --name-only | grep '\.cshtml$')
if [ -z "$STAGED" ]; then
exit 0
fi
# $'\xc3\xa2\xe2\x82\xac' = UTF-8 bytes for a-circumflex + euro-sign
CORRUPT=$(echo "$STAGED" | xargs grep -l $'\xc3\xa2\xe2\x82\xac' 2>/dev/null)
if [ -n "$CORRUPT" ]; then
echo ""
echo "ERROR: Corrupted Unicode characters detected in staged .cshtml files:"
echo "$CORRUPT" | sed 's/^/ /'
echo ""
echo "Fix by running: .\\tools\\Fix-Encoding.ps1"
echo "Then re-stage the files and commit again."
echo ""
exit 1
fi
exit 0
+5
View File
@@ -1,6 +1,11 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
# Claude Code tool settings and build logs
.claude/settings.local.json
.claude/settings.json
BuildLog*.txt
# User-specific files
*.rsuser
*.suo
+571
View File
@@ -0,0 +1,571 @@
# AGENTS.md
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
## Project Overview
A production-ready ASP.NET Core 8.0 MVC application for managing powder coating business operations. The application implements Clean Architecture with six projects across three layers (Domain, Application, Infrastructure) plus two presentation layers (Web MVC, RESTful API).
## Essential Commands
### Building and Running
```bash
# Build entire solution
dotnet build
# Run web application (MVC)
cd src/PowderCoating.Web
dotnet run
# Access at: https://localhost:58461
# Run web with auto-reload
dotnet watch run
# Run API
cd src/PowderCoating.Api
dotnet run
# Swagger UI at root URL
# Run tests
dotnet test # All tests
dotnet test tests/PowderCoating.UnitTests # Unit tests only
dotnet test tests/PowderCoating.IntegrationTests # Integration tests only
```
### Database Operations
```bash
# All EF commands run from Web project directory
cd src/PowderCoating.Web
# Create migration (must specify Infrastructure project)
dotnet ef migrations add MigrationName --project ../PowderCoating.Infrastructure
# Apply migrations
dotnet ef database update --project ../PowderCoating.Infrastructure
# Reset database (WARNING: deletes all data)
dotnet ef database drop --project ../PowderCoating.Infrastructure
dotnet ef database update --project ../PowderCoating.Infrastructure
# List migrations
dotnet ef migrations list --project ../PowderCoating.Infrastructure
# Remove last migration (if not applied)
dotnet ef migrations remove --project ../PowderCoating.Infrastructure
```
### Default Credentials
```
SuperAdmin (break glass): artemis@powdercoatinglogix.com / SuperAdmin123!
SuperAdmin (seed): superadmin@powdercoatinglogix.com / SuperAdmin123!
SuperAdmin (seed): spouliot@powdercoatinglogix.com / SuperAdmin123!
Company Admin (seed): demo@powdercoatinglogix.com / CompanyAdmin123!
```
## Architecture Overview
### Clean Architecture Layers
**Domain Layer (PowderCoating.Core)**
- Contains business entities, enums, and repository interfaces
- `BaseEntity` provides common properties for all entities (Id, CompanyId, CreatedAt, UpdatedAt, IsDeleted, audit fields)
- All entities inherit from BaseEntity and support soft delete
- No dependencies on other projects
**Application Layer (PowderCoating.Application)**
- DTOs organized by domain (Customer, Job, Equipment, Inventory, Maintenance)
- AutoMapper profiles with reverse mappings
- Service interfaces (IFileService, etc.)
- No UI or infrastructure dependencies
**Infrastructure Layer (PowderCoating.Infrastructure)**
- `ApplicationDbContext` with global query filters for soft deletes and multi-tenancy
- Generic `Repository<T>` implementing `IRepository<T>`
- `UnitOfWork` implementing `IUnitOfWork` with lazy-loaded repositories
- Seed data is triggered **manually** via Platform Management → Seed Data (not automatic on startup)
**Presentation Layers**
- `PowderCoating.Web`: MVC application with Razor views, Bootstrap 5 UI
- `PowderCoating.Api`: RESTful API with JWT authentication, Swagger documentation
### Key Design Patterns
**Repository Pattern**
- Generic `Repository<T>` in Infrastructure
- All CRUD operations, search, pagination, eager loading support
- Soft delete with `SoftDeleteAsync()` method
**Unit of Work Pattern**
- Coordinates multiple repositories
- Transaction support: `BeginTransactionAsync()`, `CommitTransactionAsync()`, `RollbackTransactionAsync()`
- Lazy instantiation of repositories
- `SaveChangesAsync()` or `CompleteAsync()` to persist changes
**Dependency Injection**
- All dependencies registered in `Program.cs`
- Controllers inject `IUnitOfWork` and `IMapper`
- Services are scoped to request lifetime
**Global Query Filters**
- Soft deletes: All queries automatically filter `IsDeleted == false`
- Multi-tenancy: Non-SuperAdmin users see only their company data
- Bypass with `ignoreQueryFilters: true` parameter in repository methods
### Multi-Tenancy Implementation
- `CompanyId` foreign key on all business entities
- `ITenantContext` injected into DbContext resolves current company
- SuperAdmin role can view all companies
- Global query filters enforce company isolation at database level
- Users have both system role (SuperAdmin) and company role (CompanyAdmin, Manager, Worker, Viewer)
## Data Access Rules (ENFORCE THESE)
> **`ApplicationDbContext` is NEVER injected into a controller.**
> All data access in controllers goes through `IUnitOfWork`. No exceptions outside the list below.
> **This rule is enforced at startup:** `EnforceDataAccessArchitecture()` in `Program.cs` scans all
> controllers at boot and throws if any non-exempt controller injects `ApplicationDbContext`.
> Full rationale and permanent exceptions list: `docs/DATA_ACCESS_ARCHITECTURE.md`
### Three tiers — use the right one:
**Tier 1 — Simple CRUD**`IUnitOfWork.EntityName` (generic `IRepository<T>`)
```csharp
var items = await _unitOfWork.CatalogItems.GetAllAsync();
await _unitOfWork.Announcements.AddAsync(entity);
await _unitOfWork.CompleteAsync();
```
**Tier 2 — Complex domain queries** → typed repositories on `IUnitOfWork`
```csharp
// Include chains and domain-specific queries belong in the repository, not the controller
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id);
var invoice = await _unitOfWork.Invoices.LoadForViewAsync(id);
var quote = await _unitOfWork.Quotes.GetByApprovalTokenAsync(token);
```
Typed repositories: `IJobRepository`, `IInvoiceRepository`, `IQuoteRepository`,
`ICustomerRepository`, `IBillRepository`, `IPurchaseOrderRepository`
— defined in `Core/Interfaces/Repositories/`, implemented in `Infrastructure/Repositories/`
**Tier 3 — Aggregate/reporting queries** → injected read services
```csharp
// P&L, AR aging, cycle time, powder usage — shaped DTOs, never tracked entities
var aging = await _financialReports.GetArAgingAsync(companyId);
```
Services: `IFinancialReportService`, `IOperationalReportService`
— defined in `Core/Interfaces/Services/`, implemented in `Infrastructure/Services/`
### Permanent exceptions (ApplicationDbContext allowed — intentional, documented):
`StripeWebhookController`, `WebhooksController`, `PaymentController`, `RegistrationController`,
`DataExportController`, `AccountDataExportController`, `DataPurgeController`,
`SystemInfoController`, `SystemLogsController`, `CompanyHealthController`
If you think you need a new exception, you almost certainly don't. Check the spec first.
---
## Data Access Patterns
### Common Controller Pattern
```csharp
public class ExampleController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
public ExampleController(IUnitOfWork unitOfWork, IMapper mapper)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
}
public async Task<IActionResult> Index()
{
var entities = await _unitOfWork.Examples.GetAllAsync();
var dtos = _mapper.Map<List<ExampleDto>>(entities);
return View(dtos);
}
[HttpPost]
public async Task<IActionResult> Create(CreateExampleDto dto)
{
var entity = _mapper.Map<Example>(dto);
await _unitOfWork.Examples.AddAsync(entity);
await _unitOfWork.CompleteAsync();
return RedirectToAction(nameof(Index));
}
public async Task<IActionResult> Delete(int id)
{
await _unitOfWork.Examples.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
return RedirectToAction(nameof(Index));
}
}
```
### Using Unit of Work Repositories
All entity repositories are available via `IUnitOfWork` properties:
- `_unitOfWork.Customers`
- `_unitOfWork.Jobs`
- `_unitOfWork.JobItems`
- `_unitOfWork.Quotes`
- `_unitOfWork.InventoryItems`
- `_unitOfWork.Equipment`
- `_unitOfWork.MaintenanceRecords`
- Plus additional entities (Suppliers, JobPhotos, JobNotes, etc.)
### Eager Loading Related Data
```csharp
// Load customer with related data
var customer = await _unitOfWork.Customers.GetByIdAsync(
id,
c => c.Jobs,
c => c.Quotes,
c => c.PricingTier
);
// Find with predicate and includes
var activeJobs = await _unitOfWork.Jobs.FindAsync(
j => j.Status != JobStatus.Completed,
j => j.Customer,
j => j.JobItems
);
```
### Pagination
```csharp
var pagedJobs = await _unitOfWork.Jobs.GetPagedAsync(
pageNumber: 1,
pageSize: 25,
j => j.Status == JobStatus.InPreparation,
j => j.Customer
);
```
## Important Domain Concepts
### Job Lifecycle
Jobs progress through 16 statuses:
1. **Pending** → Initial state
2. **Quoted** → Quote generated
3. **Approved** → Customer approved
4. **InPreparation** → Job prep started
5. **Sandblasting** → Surface prep
6. **MaskingTaping** → Masking areas
7. **Cleaning** → Pre-coat cleaning
8. **InOven** → Pre-heating
9. **Coating** → Applying powder
10. **Curing** → Heat curing
11. **QualityCheck** → Inspection
12. **Completed** → Work finished
13. **ReadyForPickup** → Awaiting customer
14. **Delivered** → Job delivered
15. **OnHold** → Paused
16. **Cancelled** → Cancelled
**Job Priorities**: Low, Normal, High, Urgent, Rush (color-coded in UI)
### Customer Types
- **Commercial**: B2B customers with pricing tiers, credit limits
- **Non-Commercial**: Individual customers, typically simpler pricing
### Inventory Management
**Transaction Types**: Purchase, Sale, Adjustment, Transfer, Return, Waste, Initial
- All transactions tracked in `InventoryTransaction` entity
- Reorder points trigger low-stock alerts
### Equipment & Maintenance
**Equipment Status**: Operational, NeedsMaintenance, UnderMaintenance, OutOfService, Retired
**Maintenance Priority**: Low, Normal, High, Critical
**Maintenance Status**: Scheduled, InProgress, Completed, Cancelled, Overdue
## Configuration Files
### Web Application (src/PowderCoating.Web/appsettings.json)
```json
{
"ConnectionStrings": {
"DefaultConnection": "Server=.\\SQLEXPRESS;Database=PowderCoatingDb;Trusted_Connection=true;MultipleActiveResultSets=true;TrustServerCertificate=true"
},
"AppSettings": {
"CompanyName": "Powder Coating Logix",
"DefaultQuoteValidityDays": 30,
"DefaultPaymentTerms": "Net 30",
"TaxRate": 0.0,
"Currency": "USD",
"TrialPeriodDays": 7,
"QuoteApprovalTokenDays": 30
},
"AI": {
"Anthropic": {
"ApiKey": "your-anthropic-api-key-here"
}
},
"SendGrid": { ... },
"Stripe": { ... },
"Storage": { ... }
}
```
**AI uses Anthropic Codex Sonnet 4.6** (`Codex-sonnet-4-6`) — NOT OpenAI. The `AI:Anthropic:ApiKey` config key is what the AI photo quoting and AI scheduling services read.
### API (src/PowderCoating.Api/appsettings.json)
```json
{
"JwtSettings": {
"SecretKey": "CHANGE-THIS-TO-YOUR-OWN-SECRET-KEY-AT-LEAST-32-CHARACTERS",
"Issuer": "PowderCoatingAPI",
"Audience": "PowderCoatingMobileApp",
"ExpirationMinutes": 1440
}
}
```
### Launch Settings (src/PowderCoating.Web/Properties/launchSettings.json)
Default ports:
- HTTPS: 58461
- HTTP: 58462
## Authentication & Authorization
### System Roles
- **SuperAdmin**: Platform-wide access, sees all companies and deleted records
- **Administrator**: Company admin
- **Manager**: Operations management
- **Employee**: Create/edit jobs and quotes
- **ShopFloor**: Update job status
- **ReadOnly**: View-only access
### Custom Authorization Policies
Defined in `PowderCoating.Shared/Constants/AppConstants.cs`:
- `RequireAdministratorRole`
- `CanManageJobs`
- `CanManageInventory`
- `CanManageUsers`
- `CanViewData`
Apply with `[Authorize(Policy = "PolicyName")]` on controllers/actions.
### JWT Authentication (API Only)
API uses JWT Bearer tokens. Web uses cookie-based Identity authentication.
## AutoMapper Configuration
AutoMapper is registered as singleton in `Program.cs`:
```csharp
builder.Services.AddSingleton(provider => new MapperConfiguration(cfg =>
{
cfg.AddMaps(typeof(ApplicationAssemblyMarker).Assembly);
}).CreateMapper());
```
All profiles in `Application/Mappings/` are auto-discovered. Profiles include reverse mappings for entity ↔ DTO conversion.
## Logging
Serilog configured to write:
- Console (structured logs)
- File: `logs/powdercoating-{Date}.txt` (rolling daily)
Access via constructor injection:
```csharp
private readonly ILogger<ExampleController> _logger;
```
## Common Development Tasks
### Adding a New Entity
1. Create entity class in `Core/Entities/` inheriting from `BaseEntity`
2. Add DbSet to `ApplicationDbContext`
3. Register repository property in `IUnitOfWork` interface
4. Add lazy-loaded property in `UnitOfWork` implementation
5. Create migration: `dotnet ef migrations add AddEntityName --project ../PowderCoating.Infrastructure`
6. Apply migration: `dotnet ef database update --project ../PowderCoating.Infrastructure`
### Adding a New Controller
1. Create DTOs in `Application/DTOs/`
2. Create AutoMapper profile in `Application/Mappings/`
3. Create controller in `Web/Controllers/`
4. Create views in `Web/Views/[ControllerName]/`
5. Add navigation link in `Views/Shared/_Layout.cshtml`
### Working with Soft Deletes
```csharp
// Soft delete (sets IsDeleted = true)
await _unitOfWork.Customers.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
// Physical delete (use sparingly)
await _unitOfWork.Customers.DeleteAsync(entity);
await _unitOfWork.CompleteAsync();
// Include deleted records in query
var allCustomers = await _unitOfWork.Customers.GetAllAsync(ignoreQueryFilters: true);
```
### Bypassing Multi-Tenancy Filters
Only for SuperAdmin users:
```csharp
// See all companies' data
var allJobs = await _unitOfWork.Jobs.GetAllAsync(ignoreQueryFilters: true);
```
## Implemented Modules
All modules below are fully implemented with controllers, views, and migrations applied.
### Operations
- **Jobs** — full lifecycle (16 statuses), worker assignment, time entries, rework tracking, shop access codes, job templates
- **Quotes** — multi-item pricing engine, AI Photo Quoting (Anthropic Codex Sonnet 4.6), quote-to-job conversion, customer approval portal, online payment
- **Invoices** — create from job, partial payments, voids, PDF download, email send; 1:1 Job→Invoice enforced by unique index
- **Deposits** — record against customer/job/quote; auto-applied to invoices on creation; receipt PDF via QuestPDF
- **Customers** — commercial and non-commercial types, pricing tiers, tax exempt flag + certificate upload, credit limits
- **Oven Scheduler** — batch jobs into named ovens, capacity planning, suggested batches
### Inventory & Purchasing
- **Inventory** — stock tracking, transactions, reorder alerts, powder coverage/efficiency fields
- **Vendors** — supplier management, payment terms, linked to inventory items
- **Purchase Orders** — create/submit/receive POs, convert to vendor bills
- **Accounts Payable** — vendor bills, AP ledger, payment tracking
### Shop Management
- **Shop Workers** — roles (Coater, Sandblaster, etc.), assignment to jobs and maintenance tasks
- **Equipment & Maintenance** — equipment status lifecycle, scheduled/completed maintenance records
- **Catalog Items** — pre-priced service catalog with default prices
- **Pricing Tiers** — customer discount tiers; use `CompanyAdminOnly` policy (not `RequireAdministratorRole`)
### Billing & Payments
- **Stripe** — subscription plans, checkout sessions, customer portal, webhooks (`/stripe/webhook`)
- **Stripe Connect** — embedded payments, OAuth flow for tenant onboarding
- **Twilio SMS** — `ISmsService` fully implemented; webhook at `POST /Webhooks/TwilioSms`
### Platform (SuperAdmin only)
- **Platform Users** — create/manage SuperAdmin accounts
- **Companies** — view/manage all tenant companies
- **Seed Data** — manual seeding via Platform Management UI (not automatic)
- **Subscription Plans** — `SubscriptionPlanConfig` controls per-plan limits and pricing
### Other
- **Help Center** — 14 fully-written articles at `Views/Help/`
- **Setup Wizard** — 10-step onboarding wizard at `SetupWizardController`
- **Reports** — 24 report actions including P&L, AR Aging, Powder Usage, Job Cycle Time, PDF exports
- **Gift Certificates** — issue, redeem, track balance
- **Announcements** — platform-wide announcements to tenants
### Key Pricing Rules
- Custom powder (no inventory item + `PowderToOrder` > 0): charge for the **full ordered quantity**, not just calculated usage
- In-stock inventory powder: charge for calculated usage only (surface area × lbs/sqft × unit cost)
- Tax exempt customers (`Customer.IsTaxExempt`): `TaxPercent` defaults to 0 on quote and invoice create; customer dropdown marks exempt customers with ★
### Pricing Routing Flags — Must Stay In Sync Across All Three Layers
`PricingCalculationService.CalculateQuoteItemPriceAsync` routes each item to the correct pricing path using boolean flags. **These flags MUST exist identically on `QuoteItem`, `JobItem`, and `CreateQuoteItemDto`, AND be mapped in all three `JobItemAssemblyService.CreateJobItem` overloads.**
| Flag | Effect if missing on JobItem |
|------|------------------------------|
| `IsAiItem` | Job repriced as calculated item; oven cost double-charged on every save |
| `IsGenericItem` | ManualUnitPrice ignored; price recalculated from surface area |
| `IsLaborItem` | Item repriced at surface-area rate instead of hours × labor rate |
| `IsSalesItem` | ManualUnitPrice ignored; item repriced using coat/surface math |
**Checklist when adding a new pricing routing flag:**
1. Add the property to `QuoteItem` (Core/Entities)
2. Add the property to `JobItem` (Core/Entities)
3. Add it to `CreateQuoteItemDto` (Application/DTOs)
4. Add it to `JobItemSeed` (private class in JobItemAssemblyService)
5. Map it in all three `JobItemAssemblyService.CreateJobItem` overloads
6. Include it in every `existingItemsData` JSON block in job views (`Edit.cshtml`, `EditItems.cshtml`) and in all job controller actions that build `CreateQuoteItemDto` from a `JobItem`
7. Add a migration if the field is new on a persisted entity
8. The structural test `PricingRoutingFlags_ExistOnBothQuoteItemAndJobItem` in `JobItemAssemblyServiceTests` will fail until steps 13 are done — this is intentional
### Branding
- Application name: **Powder Coating Logix**
- PCL logo: `wwwroot/images/pcl-logo.png` — used in sidebar header (when no tenant logo), login/register pages, sidebar footer
- Sidebar footer always shows PCL logo linking to `http://www.powdercoatinglogix.com`
- Tenant companies can upload their own logo (stored in Azure Blob `companylogos` container); it replaces the PCL logo in the sidebar header
## Known Issues
- Entity Framework warnings about global query filters on related entities (non-critical, informational only)
## File Upload Configuration
Limits defined in `AppConstants.cs`:
- Max file size: 10 MB
- Allowed extensions: jpg, jpeg, png, gif, pdf, doc, docx, xls, xlsx
## Testing Strategy
- **Unit Tests**: Test business logic in isolation
- **Integration Tests**: Test full request pipeline with test database
- Use xUnit framework
- Mock `IUnitOfWork` in unit tests
## Extending the System
### Adding AI Features
AI uses Anthropic Codex Sonnet 4.6 via `IAiQuoteService`. Configure the key under `AI:Anthropic:ApiKey` in `appsettings.json`.
1. Create service interface in `Application/Interfaces/`
2. Implement in `Infrastructure/Services/` calling the Anthropic client
3. Inject into controllers via DI
### SignalR Hubs
Two hubs are already implemented and mapped in `Program.cs`:
- `NotificationHub``/hubs/notifications` (company-scoped push notifications)
- `ShopHub``/hubs/shop` (real-time shop floor updates)
To add a new hub:
1. Create hub class in `Web/Hubs/`
2. Map hub in `Program.cs`: `app.MapHub<YourHub>("/hubpath")`
3. Use JavaScript client in views to connect
### Adding API Endpoints
1. Create controller in `Api/Controllers/` with `[ApiController]` attribute
2. Return `ActionResult<T>` types
3. Use `[Authorize]` for protected endpoints
4. Document with XML comments for Swagger
## Project Dependencies
Key NuGet packages:
- **AutoMapper 16.0.0**: Entity-to-DTO mapping
- **Entity Framework Core 8.0.11**: ORM and database access
- **Serilog.AspNetCore 8.0.3**: Structured logging
- **Microsoft.AspNetCore.Identity.UI 8.0.11**: Authentication
- **Swashbuckle.AspNetCore 7.2.0**: API documentation (API project)
## Security Considerations
- Password requirements: 8+ chars, uppercase, lowercase, digit
- HTTPS enforced in production
- SQL injection prevented by EF Core parameterization
- XSS protection via Razor encoding
- CSRF tokens on all forms (automatic with ASP.NET Core)
- Sensitive settings (connection strings, API keys) should use User Secrets in development and Azure Key Vault in production
## Active design work
A visual redesign is in progress. If the user asks about UI changes, dashboard/jobs/board styling, or the new design tokens, read `design_handoff_pcl_redesign/README.md` and follow `design_handoff_pcl_redesign/AGENTS.md` for that work.
@@ -68,6 +68,7 @@ public class InventoryListDto
public string? CategoryName { get; set; }
public string Category { get; set; } = string.Empty; // Legacy field
public string? ColorName { get; set; }
public string? Location { get; set; }
public decimal QuantityOnHand { get; set; }
public string UnitOfMeasure { get; set; } = "lbs";
public decimal ReorderPoint { get; set; }
@@ -33,6 +33,10 @@ public class InvoiceDto
public string? CustomerEmail { get; set; }
public string? CustomerPhone { get; set; }
public string? CustomerMobilePhone { get; set; }
public string? CustomerAddress { get; set; }
public string? CustomerCity { get; set; }
public string? CustomerState { get; set; }
public string? CustomerZipCode { get; set; }
public bool CustomerNotifyByEmail { get; set; }
public bool CustomerNotifyBySms { get; set; }
public string? PreparedById { get; set; }
@@ -137,6 +137,13 @@ public class CreateJobDto
[Display(Name = "Oven")]
public int? OvenCostId { get; set; }
[Display(Name = "Batches")]
[Range(1, 999)]
public int OvenBatches { get; set; } = 1;
[Display(Name = "Cycle Time (min)")]
public int? OvenCycleMinutes { get; set; }
[Required(ErrorMessage = "Description is required")]
[StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")]
[Display(Name = "Description")]
@@ -208,6 +215,16 @@ public class UpdateJobDto
[Display(Name = "Assigned Worker")]
public string? AssignedUserId { get; set; }
[Display(Name = "Oven")]
public int? OvenCostId { get; set; }
[Display(Name = "Batches")]
[Range(1, 999)]
public int OvenBatches { get; set; } = 1;
[Display(Name = "Cycle Time (min)")]
public int? OvenCycleMinutes { get; set; }
[Required(ErrorMessage = "Description is required")]
[StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")]
[Display(Name = "Description")]
@@ -381,6 +398,7 @@ public class JobItemCoatDto
public decimal? PowderCostPerLb { get; set; }
public decimal? PowderToOrder { get; set; }
public decimal? ActualPowderUsedLbs { get; set; } // Filled during job completion
public bool NoExtraLayerCharge { get; set; }
public string? Notes { get; set; }
}
@@ -389,7 +407,7 @@ public class CompleteJobDto
{
public int JobId { get; set; }
public decimal? ActualTimeSpentHours { get; set; }
public List<JobItemCoatUsageDto> CoatUsages { get; set; } = new();
public List<JobPowderUsageDto> PowderUsages { get; set; } = new();
public bool SendEmailToCustomer { get; set; } = false;
}
@@ -400,10 +418,10 @@ public class SendJobSmsRequest
public string Message { get; set; } = string.Empty;
}
// DTO for tracking actual powder usage per coat
public class JobItemCoatUsageDto
// DTO for tracking actual powder usage per inventory item (color) for the whole job
public class JobPowderUsageDto
{
public int JobItemCoatId { get; set; }
public int InventoryItemId { get; set; }
public decimal? ActualPowderUsedLbs { get; set; }
}
@@ -801,6 +801,7 @@ public class QuoteItemCoatDto
public decimal CoatMaterialCost { get; set; }
public decimal CoatLaborCost { get; set; }
public decimal CoatTotalCost { get; set; }
public bool NoExtraLayerCharge { get; set; }
public string? Notes { get; set; }
}
@@ -91,4 +91,11 @@ public interface INotificationService
/// Alert company staff when a Stripe chargeback (dispute) is opened on an invoice payment.
/// </summary>
Task NotifyChargebackAlertAsync(Invoice invoice, string disputeId, decimal amount, string reason);
/// <summary>
/// Sends an appointment reminder email to the linked customer (if opted in) and writes a
/// notification log row. Called by <see cref="PowderCoating.Web.BackgroundServices.AppointmentReminderBackgroundService"/>
/// when the reminder window opens. In-app bell notification is handled by the caller.
/// </summary>
Task NotifyAppointmentReminderAsync(Appointment appointment);
}
@@ -25,6 +25,12 @@ public interface IPdfService
CompanyInfoDto companyInfo,
QuoteTemplateSettingsDto? template = null);
Task<byte[]> GeneratePackingSlipPdfAsync(
InvoiceDto invoiceDto,
byte[]? companyLogo,
string? companyLogoContentType,
CompanyInfoDto companyInfo);
Task<byte[]> GeneratePurchaseOrderPdfAsync(
PurchaseOrderDto po,
byte[]? companyLogo,
@@ -20,7 +20,6 @@ public interface IStripeConnectService
decimal invoiceTotal,
decimal surchargeAmount,
string currency,
string customerEmail,
string invoiceNumber,
int invoiceId);
@@ -33,7 +32,6 @@ public interface IStripeConnectService
decimal depositAmount,
decimal surchargeAmount,
string currency,
string customerEmail,
string quoteNumber,
int quoteId);
}
@@ -29,6 +29,10 @@ public class InvoiceProfile : Profile
: null))
.ForMember(d => d.CustomerPhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.Phone : null))
.ForMember(d => d.CustomerMobilePhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.MobilePhone : null))
.ForMember(d => d.CustomerAddress, o => o.MapFrom(s => s.Customer != null ? s.Customer.Address : null))
.ForMember(d => d.CustomerCity, o => o.MapFrom(s => s.Customer != null ? s.Customer.City : null))
.ForMember(d => d.CustomerState, o => o.MapFrom(s => s.Customer != null ? s.Customer.State : null))
.ForMember(d => d.CustomerZipCode, o => o.MapFrom(s => s.Customer != null ? s.Customer.ZipCode : null))
.ForMember(d => d.CustomerNotifyByEmail, o => o.MapFrom(s => s.Customer == null || s.Customer.NotifyByEmail))
.ForMember(d => d.CustomerNotifyBySms, o => o.MapFrom(s => s.Customer != null && s.Customer.NotifyBySms))
.ForMember(d => d.PreparedByName, o => o.MapFrom(s => s.PreparedBy != null
@@ -4,8 +4,26 @@ using PowderCoating.Core.Entities;
namespace PowderCoating.Application.Services;
/// <summary>
/// Converts quote/job data into persisted <see cref="JobItem"/>, <see cref="JobItemCoat"/>,
/// and <see cref="JobItemPrepService"/> entities.
///
/// Three source types are supported, each with a matching overload:
/// 1. <see cref="CreateQuoteItemDto"/> — quote wizard (new job from form data + fresh pricing result)
/// 2. <see cref="QuoteItem"/> — quote-to-job conversion (copies a saved quote line)
/// 3. <see cref="JobItem"/> — job duplication / template instantiation (copies an existing job line)
///
/// The private <see cref="JobItemSeed"/> / <see cref="JobItemCoatSeed"/> / <see cref="JobItemPrepServiceSeed"/>
/// intermediary classes exist solely to give all three overload paths a single <see cref="BuildJobItem"/>
/// construction site — avoiding subtle copy-paste drift where one overload forgets to copy a new field.
/// </summary>
public class JobItemAssemblyService : IJobItemAssemblyService
{
/// <summary>
/// Creates a <see cref="JobItem"/> from a quote wizard DTO and a pre-calculated pricing result.
/// Used when creating a job directly from the job form or from an approved quote via the wizard.
/// Pricing is passed in separately because it was already computed upstream (CalculateQuoteItemPriceAsync).
/// </summary>
public JobItem CreateJobItem(CreateQuoteItemDto source, int jobId, int companyId, QuoteItemPricingResult pricing, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
@@ -42,6 +60,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
createdAtUtc);
}
/// <summary>
/// Builds <see cref="JobItemCoat"/> records from the coat DTOs in the quote wizard form.
/// PowderToOrder is recalculated server-side here (not trusted from the form) using surface area,
/// quantity, coverage, and transfer efficiency — the wizard's displayed value is for UI only.
/// </summary>
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
@@ -62,7 +85,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
Notes = c.Notes
Notes = c.Notes,
NoExtraLayerCharge = c.NoExtraLayerCharge
},
jobItemId,
companyId,
@@ -70,6 +94,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
.ToList() ?? [];
}
/// <summary>
/// Builds <see cref="JobItemPrepService"/> records (sandblasting, masking, etc.) from the
/// quote wizard DTO. These are per-item prep steps with individual time estimates that feed
/// labor cost calculations and shop floor instructions.
/// </summary>
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
@@ -85,6 +114,13 @@ public class JobItemAssemblyService : IJobItemAssemblyService
createdAtUtc);
}
/// <summary>
/// Creates a <see cref="JobItem"/> by copying a saved <see cref="QuoteItem"/> during quote-to-job conversion.
/// Prices are taken directly from the quote snapshot — no repricing occurs — so the job starts with
/// exactly the amounts that were approved by the customer.
/// The first coat's color/finish is promoted to the job item's top-level fields for quick display
/// (details remain in the coat records).
/// </summary>
public JobItem CreateJobItem(QuoteItem source, int jobId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
@@ -128,6 +164,12 @@ public class JobItemAssemblyService : IJobItemAssemblyService
createdAtUtc);
}
/// <summary>
/// Builds <see cref="JobItemCoat"/> records from a saved <see cref="QuoteItem"/> during quote-to-job conversion.
/// Coat appearance (color name, code, finish) is resolved from the linked <see cref="InventoryItem"/> if available,
/// because the inventory record is the canonical source of truth for a product's appearance —
/// the values typed into the quote form may be incomplete or informal.
/// </summary>
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
@@ -151,7 +193,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
Notes = c.Notes
Notes = c.Notes,
NoExtraLayerCharge = c.NoExtraLayerCharge
},
jobItemId,
companyId,
@@ -160,6 +203,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
.ToList() ?? [];
}
/// <summary>
/// Copies prep service records from a <see cref="QuoteItem"/> to a new job item during quote-to-job conversion.
/// </summary>
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
@@ -175,6 +221,12 @@ public class JobItemAssemblyService : IJobItemAssemblyService
createdAtUtc);
}
/// <summary>
/// Creates a new <see cref="JobItem"/> by cloning an existing one — used for job templates
/// and rework duplication where an existing job line is reused on a new job.
/// Prices are copied as-is from the source; the job controller is responsible for repricing
/// if operating costs have changed since the original job was created.
/// </summary>
public JobItem CreateJobItem(JobItem source, int jobId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
@@ -214,6 +266,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
createdAtUtc);
}
/// <summary>
/// Clones coat records from an existing <see cref="JobItem"/> onto a new job item.
/// PowderToOrder is copied verbatim (not recalculated) because the original job's powder
/// quantities may have been manually adjusted after initial calculation.
/// </summary>
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
@@ -234,7 +291,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = c.PowderToOrder,
Notes = c.Notes
Notes = c.Notes,
NoExtraLayerCharge = c.NoExtraLayerCharge
},
jobItemId,
companyId,
@@ -242,6 +300,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
.ToList() ?? [];
}
/// <summary>
/// Clones prep service records from an existing <see cref="JobItem"/> onto a new job item.
/// </summary>
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
{
ArgumentNullException.ThrowIfNull(source);
@@ -257,6 +318,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
createdAtUtc);
}
/// <summary>
/// Single construction point for all <see cref="JobItem"/> creation paths.
/// Centralised here so that adding a new field only requires one code change, not three.
/// </summary>
private static JobItem BuildJobItem(JobItemSeed seed, int jobId, int companyId, DateTime createdAtUtc)
{
return new JobItem
@@ -293,6 +358,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
};
}
/// <summary>
/// Single construction point for all <see cref="JobItemCoat"/> creation paths.
/// </summary>
private static JobItemCoat BuildJobItemCoat(JobItemCoatSeed seed, int jobItemId, int companyId, DateTime createdAtUtc)
{
return new JobItemCoat
@@ -310,11 +378,17 @@ public class JobItemAssemblyService : IJobItemAssemblyService
PowderCostPerLb = seed.PowderCostPerLb,
PowderToOrder = seed.PowderToOrder,
Notes = seed.Notes,
NoExtraLayerCharge = seed.NoExtraLayerCharge,
CompanyId = companyId,
CreatedAt = createdAtUtc
};
}
/// <summary>
/// Single construction point for all <see cref="JobItemPrepService"/> creation paths.
/// Returns an empty list (not null) when <paramref name="seeds"/> is null so callers
/// can safely iterate without a null check.
/// </summary>
private static IReadOnlyList<JobItemPrepService> BuildJobItemPrepServices(IEnumerable<JobItemPrepServiceSeed>? seeds, int jobItemId, int companyId, DateTime createdAtUtc)
{
return seeds?
@@ -330,6 +404,18 @@ public class JobItemAssemblyService : IJobItemAssemblyService
.ToList() ?? [];
}
/// <summary>
/// Returns the pounds of powder needed to coat a batch, preferring the pre-stored value
/// (which the user may have manually adjusted in the wizard) over a fresh recalculation.
///
/// Formula: (surfaceAreaSqFt × quantity) ÷ (coverageSqFtPerLb × transferEfficiency)
///
/// Industry defaults are applied when catalog data is missing:
/// - Coverage: 30 sqft/lb (typical for standard powder at 23 mil DFT)
/// - Transfer efficiency: 65% (industry average for electrostatic spray)
/// These are conservative defaults that slightly overestimate powder needed — intentional,
/// so the shop doesn't run short on a job.
/// </summary>
private static decimal? CalculatePowderToOrder(decimal? storedPowderToOrder, decimal surfaceAreaSqFt, decimal quantity, decimal coverageSqFtPerLb, decimal transferEfficiency)
{
if (storedPowderToOrder.HasValue && storedPowderToOrder.Value > 0)
@@ -343,6 +429,12 @@ public class JobItemAssemblyService : IJobItemAssemblyService
return Math.Round((surfaceAreaSqFt * quantity) / (coverage * efficiency), 2);
}
/// <summary>
/// Resolves the display appearance (color name, code, finish) for a coat, preferring the linked
/// <see cref="InventoryItem"/>'s values over whatever was typed into the quote form.
/// The inventory record is the canonical source of truth — the form values are used as a fallback
/// only when no inventory item is linked (e.g. custom/one-off powder).
/// </summary>
private static (string? ColorName, string? ColorCode, string? Finish) ResolveCoatAppearance(
string? colorName,
string? colorCode,
@@ -355,6 +447,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
return (inventoryItem.Name, inventoryItem.ColorCode, inventoryItem.Finish);
}
/// <summary>
/// Intermediate value object that normalises the three different source types
/// (DTO, QuoteItem, JobItem) into a single shape before the shared BuildJobItem factory method.
/// Using a seed class prevents subtle bugs where an overload forgets to map a new field.
/// </summary>
private sealed class JobItemSeed
{
public string Description { get; init; } = string.Empty;
@@ -385,6 +482,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
public int? AiPredictionId { get; init; }
}
/// <summary>Intermediate value object for coat creation — see <see cref="JobItemSeed"/> for rationale.</summary>
private sealed class JobItemCoatSeed
{
public string CoatName { get; init; } = string.Empty;
@@ -399,8 +497,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
public decimal? PowderCostPerLb { get; init; }
public decimal? PowderToOrder { get; init; }
public string? Notes { get; init; }
public bool NoExtraLayerCharge { get; init; }
}
/// <summary>Intermediate value object for prep service creation — see <see cref="JobItemSeed"/> for rationale.</summary>
private sealed class JobItemPrepServiceSeed
{
public int PrepServiceId { get; init; }
@@ -2753,4 +2753,187 @@ public class PdfService : IPdfService
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
}
}
// -----------------------------------------------------------------------
// Packing Slip
// -----------------------------------------------------------------------
/// <summary>
/// Generates a no-price packing slip PDF for the given invoice. Lists job items with
/// description, color, and quantity only — no unit prices or totals. Intended for
/// physical pickup/delivery paperwork where pricing should not be visible.
/// </summary>
public async Task<byte[]> GeneratePackingSlipPdfAsync(
InvoiceDto invoiceDto,
byte[]? companyLogo,
string? companyLogoContentType,
CompanyInfoDto companyInfo)
{
QuestPDF.Settings.License = LicenseType.Community;
const string accentColor = "#1e40af"; // blue
return await Task.Run(() =>
{
var document = Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.Letter);
page.Margin(0.75f, Unit.Inch);
page.PageColor(Colors.White);
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
page.Header().Element(c => ComposePackingSlipHeader(c, companyLogo, companyInfo, accentColor, invoiceDto));
page.Content().Element(c => ComposePackingSlipContent(c, invoiceDto, accentColor));
page.Footer().AlignCenter().Text(text =>
{
text.Span("PACKING SLIP | ").FontSize(8).FontColor(Colors.Grey.Darken1);
text.Span(companyInfo.CompanyName).FontSize(8).FontColor(Colors.Grey.Darken1);
text.Span(" | Page ").FontSize(8).FontColor(Colors.Grey.Darken1);
text.CurrentPageNumber().FontSize(8).FontColor(Colors.Grey.Darken1);
text.Span(" of ").FontSize(8).FontColor(Colors.Grey.Darken1);
text.TotalPages().FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
});
return document.GeneratePdf();
});
}
/// <summary>
/// Header for the packing slip: company branding left, "PACKING SLIP" title + invoice/date info right.
/// </summary>
private void ComposePackingSlipHeader(IContainer container, byte[]? companyLogo, CompanyInfoDto companyInfo, string accentColor, InvoiceDto invoice)
{
container.Column(col =>
{
col.Item().Row(row =>
{
row.RelativeItem().Column(column =>
{
if (companyLogo != null && companyLogo.Length > 0)
column.Item().MaxHeight(60).Image(companyLogo);
else
column.Item().Text(companyInfo.CompanyName).FontSize(18).Bold().FontColor(accentColor);
if (!string.IsNullOrWhiteSpace(companyInfo.Address))
column.Item().Text(companyInfo.Address).FontSize(8).FontColor(Colors.Grey.Darken1);
var cityLine = $"{companyInfo.City}{(!string.IsNullOrEmpty(companyInfo.City) && !string.IsNullOrEmpty(companyInfo.State) ? ", " : "")}{companyInfo.State} {companyInfo.ZipCode}".Trim();
if (!string.IsNullOrWhiteSpace(cityLine))
column.Item().Text(cityLine).FontSize(8).FontColor(Colors.Grey.Darken1);
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
column.Item().Text(FormatPhoneNumber(companyInfo.Phone)).FontSize(8).FontColor(Colors.Grey.Darken1);
if (!string.IsNullOrWhiteSpace(companyInfo.PrimaryContactEmail))
column.Item().Text(companyInfo.PrimaryContactEmail).FontSize(8).FontColor(Colors.Grey.Darken1);
});
row.RelativeItem().AlignRight().Column(column =>
{
column.Item().Text("PACKING SLIP").FontSize(26).Bold().FontColor(accentColor);
column.Item().Text($"Invoice #: {invoice.InvoiceNumber}").FontSize(9).Bold();
column.Item().Text($"Date: {invoice.InvoiceDate:MMMM d, yyyy}").FontSize(9);
if (!string.IsNullOrWhiteSpace(invoice.JobNumber))
column.Item().Text($"Job #: {invoice.JobNumber}").FontSize(9);
});
});
col.Item().PaddingVertical(4).LineHorizontal(1).LineColor(accentColor);
});
}
/// <summary>
/// Body of the packing slip: customer info block, optional PO number, and an items table
/// showing description, color, and quantity — no prices.
/// </summary>
private void ComposePackingSlipContent(IContainer container, InvoiceDto invoice, string accentColor)
{
container.Column(col =>
{
// Customer info
col.Item().PaddingTop(12).Row(row =>
{
row.RelativeItem().Column(c =>
{
c.Item().Text("PREPARED FOR").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
c.Item().Text(invoice.CustomerName).Bold();
if (!string.IsNullOrWhiteSpace(invoice.CustomerAddress))
c.Item().Text(invoice.CustomerAddress).FontSize(9);
var cityLine = $"{invoice.CustomerCity}{(!string.IsNullOrEmpty(invoice.CustomerCity) && !string.IsNullOrEmpty(invoice.CustomerState) ? ", " : "")}{invoice.CustomerState} {invoice.CustomerZipCode}".Trim();
if (!string.IsNullOrWhiteSpace(cityLine))
c.Item().Text(cityLine).FontSize(9);
if (!string.IsNullOrWhiteSpace(invoice.CustomerPhone))
c.Item().Text(FormatPhoneNumber(invoice.CustomerPhone)).FontSize(9);
});
if (!string.IsNullOrWhiteSpace(invoice.CustomerPO))
{
row.ConstantItem(160).AlignRight().Column(c =>
{
c.Item().Text("PURCHASE ORDER").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
c.Item().Text(invoice.CustomerPO).Bold();
});
}
});
// Items table
col.Item().PaddingTop(16).Table(table =>
{
table.ColumnsDefinition(cols =>
{
cols.RelativeColumn(5);
cols.RelativeColumn(3);
cols.RelativeColumn(1);
});
table.Header(h =>
{
h.Cell().Background(accentColor).Padding(5).Text("Description").FontColor(Colors.White).Bold().FontSize(9);
h.Cell().Background(accentColor).Padding(5).Text("Color / Finish").FontColor(Colors.White).Bold().FontSize(9);
h.Cell().Background(accentColor).Padding(5).AlignCenter().Text("Qty").FontColor(Colors.White).Bold().FontSize(9);
});
var rowAlt = false;
foreach (var item in invoice.InvoiceItems.OrderBy(i => i.DisplayOrder))
{
var bg = rowAlt ? Colors.Grey.Lighten4 : Colors.White;
table.Cell().Background(bg).Padding(5).Column(c =>
{
c.Item().Text(item.Description).FontSize(9);
if (!string.IsNullOrWhiteSpace(item.Notes))
c.Item().Text(item.Notes).FontSize(8).FontColor(Colors.Grey.Darken1);
});
table.Cell().Background(bg).Padding(5).Text(item.ColorName ?? "—").FontSize(9);
table.Cell().Background(bg).Padding(5).AlignCenter().Text(item.Quantity.ToString("G")).FontSize(9);
rowAlt = !rowAlt;
}
});
// Notes (if any)
if (!string.IsNullOrWhiteSpace(invoice.Notes))
{
col.Item().PaddingTop(16).Column(c =>
{
c.Item().Text("Notes").Bold().FontSize(9);
c.Item().Text(invoice.Notes).FontSize(9).FontColor(Colors.Grey.Darken1);
});
}
// Received by signature line
col.Item().PaddingTop(32).Row(row =>
{
row.RelativeItem().Column(c =>
{
c.Item().BorderBottom(1).BorderColor(Colors.Grey.Darken1).PaddingBottom(2).Text(string.Empty);
c.Item().PaddingTop(2).Text("Received by / Date").FontSize(8).FontColor(Colors.Grey.Darken1);
});
row.ConstantItem(24);
row.RelativeItem().Column(c =>
{
c.Item().BorderBottom(1).BorderColor(Colors.Grey.Darken1).PaddingBottom(2).Text(string.Empty);
c.Item().PaddingTop(2).Text("Condition noted").FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
});
}
}
@@ -6,6 +6,20 @@ using Microsoft.Extensions.Logging;
namespace PowderCoating.Application.Services;
/// <summary>
/// Orchestrates the full quote item assembly pipeline: pricing calculation, entity construction,
/// AI prediction tracking, and automatic inventory record creation for incoming powder orders.
///
/// This service sits above <see cref="PricingCalculationService"/> — it knows HOW to build and
/// persist quote entities, while PricingCalculationService knows HOW to compute dollar amounts.
/// Keeping them separate means pricing logic can be unit-tested without any entity construction concerns.
///
/// Key responsibilities:
/// - <see cref="ApplyPricingSnapshot"/> — stamps calculated totals onto the Quote entity so the
/// displayed price is frozen at quote time and won't change if operating costs are updated later.
/// - <see cref="CreateQuoteItemsAsync"/> — builds QuoteItem + coats + prep services for each DTO,
/// records AI prediction overrides, and auto-creates incoming inventory records when needed.
/// </summary>
public class QuotePricingAssemblyService : IQuotePricingAssemblyService
{
private readonly IUnitOfWork _unitOfWork;
@@ -25,6 +39,11 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
_logger = logger;
}
/// <summary>
/// Writes the calculated pricing breakdown onto the <see cref="Quote"/> entity as a snapshot.
/// Snapshots are critical: once a quote is sent to a customer, operating cost changes must NOT
/// silently alter the quoted amounts — the snapshot preserves what was presented at the time.
/// </summary>
public void ApplyPricingSnapshot(Quote quote, QuotePricingResult pricingResult)
{
ArgumentNullException.ThrowIfNull(quote);
@@ -56,6 +75,12 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
quote.Total = pricingResult.Total;
}
/// <summary>
/// Builds and prices all <see cref="QuoteItem"/> entities from the incoming DTOs.
/// For each item: constructs the entity, calculates pricing, records whether the user overrode
/// an AI estimate, then attaches coats (including auto-creating incoming inventory entries when
/// the user selects a catalog powder not yet in their inventory) and prep services.
/// </summary>
public async Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
IEnumerable<CreateQuoteItemDto> itemDtos,
int quoteId,
@@ -80,6 +105,13 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
return items;
}
/// <summary>
/// Routes a single item to the correct pricing path and stamps the result onto the entity.
/// Priority order matches the routing table in <see cref="PricingCalculationService.CalculateQuoteItemPriceAsync"/>:
/// AI items → Sales items → Catalog (no coats) → full calculation engine.
/// Keeping pricing logic in PricingCalculationService means this method only decides WHICH
/// path to take, never HOW to compute the price.
/// </summary>
private async Task ApplyPricingAsync(QuoteItem item, CreateQuoteItemDto itemDto, int companyId, decimal? ovenRateOverride)
{
if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0)
@@ -127,6 +159,12 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
ApplyCalculatedPricing(item, pricing);
}
/// <summary>
/// Builds <see cref="QuoteItemCoat"/> entities for a single item, including per-coat pricing.
/// If a coat has <c>AddAsIncoming = true</c> and references a catalog item but not an inventory
/// item, an incoming <see cref="InventoryItem"/> is auto-created so the shop can track the powder
/// order and receive it later — see <see cref="CreateIncomingInventoryItemAsync"/> for details.
/// </summary>
private async Task<List<QuoteItemCoat>> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
{
if (itemDto.Coats == null || itemDto.Coats.Count == 0)
@@ -158,6 +196,7 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
return coats;
}
/// <summary>Constructs <see cref="QuoteItemPrepService"/> entities from the item DTO's prep service list.</summary>
private static List<QuoteItemPrepService> BuildQuoteItemPrepServices(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
{
if (itemDto.PrepServices == null || itemDto.PrepServices.Count == 0)
@@ -175,6 +214,11 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
.ToList();
}
/// <summary>
/// Constructs a bare <see cref="QuoteItem"/> entity from the DTO — no pricing or coats yet.
/// Pricing is applied separately by <see cref="ApplyPricingAsync"/> to keep the construction
/// and calculation steps distinct and individually testable.
/// </summary>
private static QuoteItem BuildQuoteItem(CreateQuoteItemDto itemDto, int quoteId, int companyId, DateTime createdAtUtc)
{
return new QuoteItem
@@ -204,6 +248,7 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
};
}
/// <summary>Constructs a <see cref="QuoteItemCoat"/> entity from the coat DTO. Per-coat pricing is applied by the caller.</summary>
private static QuoteItemCoat BuildQuoteItemCoat(CreateQuoteItemCoatDto coatDto, int companyId, DateTime createdAtUtc)
{
return new QuoteItemCoat
@@ -219,12 +264,17 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
TransferEfficiency = coatDto.TransferEfficiency,
PowderCostPerLb = coatDto.PowderCostPerLb,
PowderToOrder = coatDto.PowderToOrder,
NoExtraLayerCharge = coatDto.NoExtraLayerCharge,
Notes = coatDto.Notes,
CompanyId = companyId,
CreatedAt = createdAtUtc
};
}
/// <summary>
/// Stamps the pricing result onto the quote item entity.
/// Broken out as a separate method because it's called from multiple branches of ApplyPricingAsync.
/// </summary>
private static void ApplyCalculatedPricing(QuoteItem item, QuoteItemPricingResult pricing)
{
item.UnitPrice = pricing.UnitPrice;
@@ -234,6 +284,13 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
item.ItemEquipmentCost = pricing.EquipmentCost;
}
/// <summary>
/// Checks whether the user changed the AI's surface area or price estimates before saving,
/// and sets <c>UserOverrodeEstimate = true</c> on the prediction record if they did.
/// This flag feeds the AI analytics reports — over time it reveals how accurate the AI is
/// and whether certain item types consistently need manual correction.
/// A tolerance of $0.01 / 0.01 sqft is used to ignore floating-point rounding noise.
/// </summary>
private async Task UpdateAiPredictionOverrideAsync(CreateQuoteItemDto itemDto, decimal finalUnitPrice)
{
if (!itemDto.AiPredictionId.HasValue) return;
@@ -247,6 +304,23 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
prediction.UpdatedAt = DateTime.UtcNow;
}
/// <summary>
/// Auto-creates an "incoming" <see cref="InventoryItem"/> when a user selects a powder from the
/// platform catalog that doesn't yet exist in their company's inventory.
///
/// WHY this exists: shops often quote jobs using powders they haven't ordered yet. Rather than
/// forcing the user to manually add the powder to inventory before quoting, we create an
/// IsIncoming=true record on their behalf. The shop can then receive the actual order against
/// this record later (updating quantity + receive date) without losing the link to the original quote.
///
/// The AI augmentation step (LookupByUrlAsync) fills in technical specs (cure temp/time, coverage,
/// color families, etc.) that may be missing from the scraped catalog JSON. It is best-effort —
/// if it fails, the item is still created with whatever data the catalog has.
///
/// After creation, <c>coatDto.PowderCostPerLb</c> is cleared so the pricing engine treats this
/// as an inventory-linked coat (not a custom powder), ensuring future repricings use the
/// inventory unit cost rather than the now-stale manual price from the quote form.
/// </summary>
private async Task<int?> CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId)
{
try
@@ -95,6 +95,12 @@ public class Appointment : BaseEntity
/// </summary>
public int ReminderMinutesBefore { get; set; } = 30;
/// <summary>
/// UTC timestamp when the reminder was dispatched. Null means it hasn't fired yet.
/// The background service uses this as a deduplication guard to prevent double-sending.
/// </summary>
public DateTime? ReminderSentAt { get; set; }
// Navigation Properties
public virtual Customer? Customer { get; set; }
public virtual Job? Job { get; set; }
@@ -42,6 +42,13 @@ public class JobItemCoat : BaseEntity
public string? PowderReceivedByUserId { get; set; }
public decimal? PowderReceivedLbs { get; set; }
// Pricing flags
/// <summary>
/// When true, the additional layer labor charge is not applied for this coat even if it is
/// not the first coat in the sequence. Used for clear coats, sealers, etc.
/// </summary>
public bool NoExtraLayerCharge { get; set; }
// Notes
public string? Notes { get; set; }
@@ -33,6 +33,13 @@ public class QuoteItemCoat : BaseEntity
public decimal CoatLaborCost { get; set; }
public decimal CoatTotalCost { get; set; }
// Pricing flags
/// <summary>
/// When true, the additional layer labor charge is not applied for this coat even if it is
/// not the first coat in the sequence. Used for clear coats, sealers, etc.
/// </summary>
public bool NoExtraLayerCharge { get; set; }
// Notes
public string? Notes { get; set; }
@@ -20,5 +20,7 @@ public enum NotificationType
SmsInboundStop = 12,
SmsInboundHelp = 13,
AdminEmail = 14,
SmsInboundStart = 15
SmsInboundStart = 15,
AppointmentReminder = 17,
AppointmentReminderStaff = 18
}
@@ -92,7 +92,11 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
if (companyIdClaim != null && int.TryParse(companyIdClaim, out int companyId))
return companyId;
return null;
// Authenticated but CompanyId claim is missing or invalid.
// Return 0 (never a real company ID) so the global filter generates
// "CompanyId = 0" which matches nothing — prevents null-comparison
// ambiguity from leaking cross-tenant rows.
return 0;
}
}
@@ -129,8 +133,11 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
{
get
{
// No HTTP context means background service, hosted service, or unit test — bypass tenant filter
if (_httpContextAccessor?.HttpContext == null) return true;
if (!IsSuperAdmin) return false;
return CurrentCompanyId == null || CurrentCompanyId == 1;
// CompanyId == 0 means no claim was present (break-glass / test SuperAdmins) — treat as platform admin
return CurrentCompanyId == null || CurrentCompanyId == 0 || CurrentCompanyId == 1;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -11,26 +11,20 @@ namespace PowderCoating.Infrastructure.Migrations
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7851));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7856));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7858));
// These UpdateData calls were generated from an existing live database.
// On a fresh install the PricingTiers table and its seed rows may not exist yet
// (seeding is manual via Platform Management → Seed Data), so guard each update.
migrationBuilder.Sql(@"
IF OBJECT_ID(N'[PricingTiers]', N'U') IS NOT NULL
BEGIN
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 1)
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377851Z' WHERE [Id] = 1;
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 2)
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377856Z' WHERE [Id] = 2;
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 3)
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377858Z' WHERE [Id] = 3;
END
");
}
/// <inheritdoc />
@@ -0,0 +1,217 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddAppointmentReminderSentAt : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Use IF EXISTS guards for all ShopWorker drops — prod and dev diverged on whether
// these objects exist, so unconditional drops would fail on whichever DB is missing them.
migrationBuilder.Sql(@"
IF EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_Jobs_ShopWorkers_ShopWorkerId')
ALTER TABLE [Jobs] DROP CONSTRAINT [FK_Jobs_ShopWorkers_ShopWorkerId];
IF EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_JobTimeEntries_ShopWorkers_ShopWorkerId')
ALTER TABLE [JobTimeEntries] DROP CONSTRAINT [FK_JobTimeEntries_ShopWorkers_ShopWorkerId];
IF EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_MaintenanceRecords_ShopWorkers_ShopWorkerId')
ALTER TABLE [MaintenanceRecords] DROP CONSTRAINT [FK_MaintenanceRecords_ShopWorkers_ShopWorkerId];
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ShopWorkerRoleCosts')
DROP TABLE [ShopWorkerRoleCosts];
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ShopWorkers')
DROP TABLE [ShopWorkers];
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_MaintenanceRecords_ShopWorkerId' AND object_id = OBJECT_ID('MaintenanceRecords'))
DROP INDEX [IX_MaintenanceRecords_ShopWorkerId] ON [MaintenanceRecords];
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_JobTimeEntries_ShopWorkerId' AND object_id = OBJECT_ID('JobTimeEntries'))
DROP INDEX [IX_JobTimeEntries_ShopWorkerId] ON [JobTimeEntries];
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Jobs_ShopWorkerId' AND object_id = OBJECT_ID('Jobs'))
DROP INDEX [IX_Jobs_ShopWorkerId] ON [Jobs];
IF EXISTS (SELECT 1 FROM sys.columns WHERE name = 'ShopWorkerId' AND object_id = OBJECT_ID('MaintenanceRecords'))
ALTER TABLE [MaintenanceRecords] DROP COLUMN [ShopWorkerId];
IF EXISTS (SELECT 1 FROM sys.columns WHERE name = 'ShopWorkerId' AND object_id = OBJECT_ID('JobTimeEntries'))
ALTER TABLE [JobTimeEntries] DROP COLUMN [ShopWorkerId];
IF EXISTS (SELECT 1 FROM sys.columns WHERE name = 'ShopWorkerId' AND object_id = OBJECT_ID('Jobs'))
ALTER TABLE [Jobs] DROP COLUMN [ShopWorkerId];
");
migrationBuilder.AddColumn<DateTime>(
name: "ReminderSentAt",
table: "Appointments",
type: "datetime2",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 15, 12, 57, 355, DateTimeKind.Utc).AddTicks(2970));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 15, 12, 57, 355, DateTimeKind.Utc).AddTicks(2976));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 15, 12, 57, 355, DateTimeKind.Utc).AddTicks(2977));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ReminderSentAt",
table: "Appointments");
migrationBuilder.AddColumn<int>(
name: "ShopWorkerId",
table: "MaintenanceRecords",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ShopWorkerId",
table: "JobTimeEntries",
type: "int",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "ShopWorkerId",
table: "Jobs",
type: "int",
nullable: true);
migrationBuilder.CreateTable(
name: "ShopWorkerRoleCosts",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
HourlyRate = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
Role = table.Column<int>(type: "int", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ShopWorkerRoleCosts", x => x.Id);
});
migrationBuilder.CreateTable(
name: "ShopWorkers",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
Email = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
Phone = table.Column<string>(type: "nvarchar(max)", nullable: true),
Role = table.Column<int>(type: "int", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_ShopWorkers", x => x.Id);
table.ForeignKey(
name: "FK_ShopWorkers_Companies_CompanyId",
column: x => x.CompanyId,
principalTable: "Companies",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3131));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3137));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3138));
migrationBuilder.CreateIndex(
name: "IX_MaintenanceRecords_ShopWorkerId",
table: "MaintenanceRecords",
column: "ShopWorkerId");
migrationBuilder.CreateIndex(
name: "IX_JobTimeEntries_ShopWorkerId",
table: "JobTimeEntries",
column: "ShopWorkerId");
migrationBuilder.CreateIndex(
name: "IX_Jobs_ShopWorkerId",
table: "Jobs",
column: "ShopWorkerId");
migrationBuilder.CreateIndex(
name: "IX_ShopWorkerRoleCosts_CompanyId_Role",
table: "ShopWorkerRoleCosts",
columns: new[] { "CompanyId", "Role" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ShopWorkers_CompanyId",
table: "ShopWorkers",
column: "CompanyId");
migrationBuilder.AddForeignKey(
name: "FK_Jobs_ShopWorkers_ShopWorkerId",
table: "Jobs",
column: "ShopWorkerId",
principalTable: "ShopWorkers",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_JobTimeEntries_ShopWorkers_ShopWorkerId",
table: "JobTimeEntries",
column: "ShopWorkerId",
principalTable: "ShopWorkers",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_MaintenanceRecords_ShopWorkers_ShopWorkerId",
table: "MaintenanceRecords",
column: "ShopWorkerId",
principalTable: "ShopWorkers",
principalColumn: "Id");
}
}
}
@@ -0,0 +1,83 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddNoExtraLayerChargeToCoats : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "NoExtraLayerCharge",
table: "QuoteItemCoats",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "NoExtraLayerCharge",
table: "JobItemCoats",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "NoExtraLayerCharge",
table: "QuoteItemCoats");
migrationBuilder.DropColumn(
name: "NoExtraLayerCharge",
table: "JobItemCoats");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 15, 44, 18, 742, DateTimeKind.Utc).AddTicks(3960));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 15, 44, 18, 742, DateTimeKind.Utc).AddTicks(3966));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 19, 15, 44, 18, 742, DateTimeKind.Utc).AddTicks(3967));
}
}
}
@@ -716,6 +716,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int>("ReminderMinutesBefore")
.HasColumnType("int");
b.Property<DateTime?>("ReminderSentAt")
.HasColumnType("datetime2");
b.Property<DateTime>("ScheduledEndTime")
.HasColumnType("datetime2");
@@ -4252,9 +4255,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<decimal>("ShopSuppliesPercent")
.HasColumnType("decimal(18,2)");
b.Property<int?>("ShopWorkerId")
.HasColumnType("int");
b.Property<string>("SpecialInstructions")
.HasColumnType("nvarchar(max)");
@@ -4296,8 +4296,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("ScheduledDate");
b.HasIndex("ShopWorkerId");
b.HasIndex("CompanyId", "CustomerId")
.HasDatabaseName("IX_Jobs_CompanyId_CustomerId");
@@ -4620,6 +4618,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int>("JobItemId")
.HasColumnType("int");
b.Property<bool>("NoExtraLayerCharge")
.HasColumnType("bit");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
@@ -5439,9 +5440,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<int?>("ShopWorkerId")
.HasColumnType("int");
b.Property<string>("Stage")
.HasColumnType("nvarchar(max)");
@@ -5464,8 +5462,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("JobId");
b.HasIndex("ShopWorkerId");
b.ToTable("JobTimeEntries");
});
@@ -5789,9 +5785,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<DateTime>("ScheduledDate")
.HasColumnType("datetime2");
b.Property<int?>("ShopWorkerId")
.HasColumnType("int");
b.Property<int>("Status")
.HasColumnType("int");
@@ -5822,8 +5815,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("ScheduledDate");
b.HasIndex("ShopWorkerId");
b.HasIndex("Status");
b.HasIndex("CompanyId", "ScheduledDate")
@@ -6720,7 +6711,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3131),
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -6731,7 +6722,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3137),
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -6742,7 +6733,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3138),
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -7417,6 +7408,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<bool>("NoExtraLayerCharge")
.HasColumnType("bit");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
@@ -8019,111 +8013,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("ReworkRecords");
});
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorker", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("Email")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsActive")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<string>("Phone")
.HasColumnType("nvarchar(max)");
b.Property<int>("Role")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("CompanyId");
b.ToTable("ShopWorkers");
});
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorkerRoleCost", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<decimal>("HourlyRate")
.HasColumnType("decimal(18,2)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<int>("Role")
.HasColumnType("int");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("CompanyId", "Role")
.IsUnique()
.HasDatabaseName("IX_ShopWorkerRoleCosts_CompanyId_Role");
b.ToTable("ShopWorkerRoleCosts");
});
modelBuilder.Entity("PowderCoating.Core.Entities.StripeWebhookEvent", b =>
{
b.Property<long>("Id")
@@ -9541,10 +9430,6 @@ namespace PowderCoating.Infrastructure.Migrations
.HasForeignKey("PowderCoating.Core.Entities.Job", "QuoteId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("PowderCoating.Core.Entities.ShopWorker", null)
.WithMany("AssignedJobs")
.HasForeignKey("ShopWorkerId");
b.Navigation("AssignedUser");
b.Navigation("Customer");
@@ -9847,13 +9732,7 @@ namespace PowderCoating.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("PowderCoating.Core.Entities.ShopWorker", "Worker")
.WithMany("TimeEntries")
.HasForeignKey("ShopWorkerId");
b.Navigation("Job");
b.Navigation("Worker");
});
modelBuilder.Entity("PowderCoating.Core.Entities.JournalEntry", b =>
@@ -9924,10 +9803,6 @@ namespace PowderCoating.Infrastructure.Migrations
.WithMany()
.HasForeignKey("RecurrenceParentId");
b.HasOne("PowderCoating.Core.Entities.ShopWorker", null)
.WithMany("AssignedMaintenanceTasks")
.HasForeignKey("ShopWorkerId");
b.Navigation("AssignedUser");
b.Navigation("Equipment");
@@ -10411,15 +10286,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("ReworkJob");
});
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorker", b =>
{
b.HasOne("PowderCoating.Core.Entities.Company", null)
.WithMany("ShopWorkers")
.HasForeignKey("CompanyId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
});
modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b =>
{
b.HasOne("PowderCoating.Core.Entities.Company", null)
@@ -10582,8 +10448,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("Quotes");
b.Navigation("ShopWorkers");
b.Navigation("Users");
b.Navigation("Vendors");
@@ -10749,15 +10613,6 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("Quotes");
});
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorker", b =>
{
b.Navigation("AssignedJobs");
b.Navigation("AssignedMaintenanceTasks");
b.Navigation("TimeEntries");
});
modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b =>
{
b.Navigation("BillPayments");
@@ -1209,6 +1209,15 @@ Rules:
sb.AppendLine("Page content:");
sb.AppendLine(pageContent);
}
else if (!string.IsNullOrWhiteSpace(fetchUrl))
{
// Page content unavailable (fetch failed or blocked) — still surface the URL so Claude
// can use its training knowledge of the manufacturer URL structure (e.g. Prismatic SKU
// in the path) to infer product identity rather than returning all-null fields.
sb.AppendLine();
sb.AppendLine($"Product URL (page content could not be fetched): {fetchUrl}");
sb.AppendLine("Use your training knowledge of this manufacturer and the URL to fill in as many fields as possible.");
}
return sb.ToString();
}
@@ -1152,6 +1152,156 @@ public class NotificationService : INotificationService
};
}
/// <summary>
/// Sends appointment reminder emails when an appointment's reminder window opens.
/// Two emails are dispatched independently:
/// <list type="bullet">
/// <item>Customer email — sent when a customer is linked, has an email address, and has
/// email notifications enabled (<see cref="Customer.NotifyByEmail"/>).</item>
/// <item>Staff email — sent to <see cref="BaseEntity.CreatedBy"/> (the user who created
/// the appointment). This fires regardless of whether a customer is linked.</item>
/// </list>
/// Called exclusively by
/// <see cref="PowderCoating.Web.BackgroundServices.AppointmentReminderBackgroundService"/>
/// after it stamps <c>ReminderSentAt</c> — the caller owns deduplication.
/// </summary>
public async Task NotifyAppointmentReminderAsync(Appointment appointment)
{
try
{
var (companyName, company) = await GetCompanyAsync(appointment.CompanyId);
var (replyToEmail, replyToName) = await GetEmailFromAsync(appointment.CompanyId);
var baseUrl = await GetBaseUrlAsync();
var locationLine = !string.IsNullOrWhiteSpace(appointment.Location)
? $"<br/><strong>Location:</strong> {WebUtility.HtmlEncode(appointment.Location)}"
: string.Empty;
var appointmentDate = appointment.ScheduledStartTime.ToString("dddd, MMMM d, yyyy");
var appointmentTime = appointment.IsAllDay
? "All Day"
: appointment.ScheduledStartTime.ToString("h:mm tt");
var defaultSubject = $"Appointment Reminder — {appointment.Title} on {appointment.ScheduledStartTime:MMMM d, yyyy}";
// ── Customer email ────────────────────────────────────────────────
if (appointment.CustomerId != null)
{
var customer = appointment.Customer
?? await _context.Customers.FindAsync(appointment.CustomerId.Value);
if (customer != null)
{
var customerName = GetCustomerDisplayName(customer);
var reminderEmails = ParseEmailList(customer.Email);
if (!customer.NotifyByEmail || reminderEmails.Count == 0)
{
if (reminderEmails.Count > 0)
{
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.AppointmentReminder,
customerName, string.Join(", ", reminderEmails), appointment.CompanyId,
customerId: customer.Id));
}
}
else
{
var customerValues = new Dictionary<string, string>
{
["companyName"] = companyName,
["customerName"] = customerName,
["appointmentTitle"] = appointment.Title,
["appointmentDate"] = appointmentDate,
["appointmentTime"] = appointmentTime,
["locationLine"] = locationLine
};
var (custSubject, custHtml) = await GetRenderedEmailAsync(
appointment.CompanyId, NotificationType.AppointmentReminder, customerValues, defaultSubject);
var custFullHtml = AppendUnsubscribeFooterHtml(custHtml, customer.UnsubscribeToken, company, baseUrl);
var custPlainText = StripHtml(custFullHtml);
var (custOk, custErr, custLog) = await SendToEmailListAsync(
customer.Email, customerName, custSubject, custPlainText, custFullHtml,
replyToEmail: replyToEmail, replyToName: replyToName);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Email,
NotificationType = NotificationType.AppointmentReminder,
Status = custOk ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = custLog,
Subject = custSubject,
Message = custPlainText,
ErrorMessage = custErr,
SentAt = DateTime.UtcNow,
CustomerId = customer.Id,
CompanyId = appointment.CompanyId
});
}
}
}
// ── Staff email ───────────────────────────────────────────────────
// Send to whoever created the appointment so they get an out-of-app reminder.
if (!string.IsNullOrWhiteSpace(appointment.CreatedBy))
{
// Look up the user's display name from Identity if available.
var staffUser = await _context.Users
.FirstOrDefaultAsync(u => u.Email == appointment.CreatedBy);
var staffName = !string.IsNullOrWhiteSpace(staffUser?.FullName)
? staffUser.FullName
: appointment.CreatedBy;
// Include a customer line only when a customer is linked.
var customerLine = appointment.Customer != null
? $"<br/><strong>Customer:</strong> {WebUtility.HtmlEncode(GetCustomerDisplayName(appointment.Customer))}"
: string.Empty;
var staffValues = new Dictionary<string, string>
{
["companyName"] = companyName,
["staffName"] = staffName,
["appointmentTitle"] = appointment.Title,
["appointmentDate"] = appointmentDate,
["appointmentTime"] = appointmentTime,
["customerLine"] = customerLine,
["locationLine"] = locationLine
};
var staffDefaultSubject = $"[Reminder] {appointment.Title} — {appointment.ScheduledStartTime:MMMM d, yyyy 'at' h:mm tt}";
var (staffSubject, staffHtml) = await GetRenderedEmailAsync(
appointment.CompanyId, NotificationType.AppointmentReminderStaff, staffValues, staffDefaultSubject);
var staffPlainText = StripHtml(staffHtml);
var (staffOk, staffErr, staffLog) = await SendToEmailListAsync(
appointment.CreatedBy, staffName, staffSubject, staffPlainText, staffHtml,
replyToEmail: replyToEmail, replyToName: replyToName);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Email,
NotificationType = NotificationType.AppointmentReminderStaff,
Status = staffOk ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = staffName,
Recipient = staffLog,
Subject = staffSubject,
Message = staffPlainText,
ErrorMessage = staffErr,
SentAt = DateTime.UtcNow,
CompanyId = appointment.CompanyId
});
}
}
catch (Exception ex)
{
_logger.LogError(ex, "NotifyAppointmentReminderAsync failed for appointment {AppointmentId}", appointment.Id);
}
}
// -----------------------------------------------------------------------
// Fallback default templates (used when company has no DB template)
// -----------------------------------------------------------------------
@@ -1217,6 +1367,14 @@ public class NotificationService : INotificationService
"Payment Reminder — Invoice {{invoiceNumber}} ({{daysOverdue}} days overdue)",
"<p>Dear {{customerName}},</p><p>This is a friendly reminder that invoice <strong>{{invoiceNumber}}</strong> for <strong>{{invoiceTotal}}</strong> was due on <strong>{{dueDate}}</strong> and is now <strong>{{daysOverdue}} days overdue</strong>.</p><p>Outstanding balance: <strong>{{balanceDue}}</strong></p><p>Please arrange payment at your earliest convenience. If you have already sent payment, please disregard this notice.</p><p>Thank you for your business with {{companyName}}.</p>"
),
[(NotificationType.AppointmentReminder, NotificationChannel.Email)] = (
"Appointment Reminder — {{appointmentTitle}} on {{appointmentDate}}",
"<p>Dear {{customerName}},</p><p>This is a reminder that you have an upcoming appointment with <strong>{{companyName}}</strong>.</p><p><strong>Appointment:</strong> {{appointmentTitle}}<br/><strong>Date &amp; Time:</strong> {{appointmentDate}} at {{appointmentTime}}{{locationLine}}</p><p>If you have any questions or need to reschedule, please contact us at your earliest convenience.</p><p>Thank you for choosing {{companyName}}.</p>"
),
[(NotificationType.AppointmentReminderStaff, NotificationChannel.Email)] = (
"[Reminder] {{appointmentTitle}} — {{appointmentDate}}",
"<p>Hi {{staffName}},</p><p>This is a reminder that you have an upcoming appointment.</p><p><strong>Appointment:</strong> {{appointmentTitle}}<br/><strong>Date &amp; Time:</strong> {{appointmentDate}} at {{appointmentTime}}{{customerLine}}{{locationLine}}</p><p>&#8212; {{companyName}}</p>"
),
};
public static (string? Subject, string Body)? Get(NotificationType type, NotificationChannel channel)
@@ -162,7 +162,6 @@ public class StripeConnectService : IStripeConnectService
decimal invoiceTotal,
decimal surchargeAmount,
string currency,
string customerEmail,
string invoiceNumber,
int invoiceId)
{
@@ -175,7 +174,6 @@ public class StripeConnectService : IStripeConnectService
{
Amount = amountInCents,
Currency = currency.ToLower(),
ReceiptEmail = customerEmail,
Description = $"Invoice {invoiceNumber}",
Metadata = new Dictionary<string, string>
{
@@ -215,7 +213,6 @@ public class StripeConnectService : IStripeConnectService
decimal depositAmount,
decimal surchargeAmount,
string currency,
string customerEmail,
string quoteNumber,
int quoteId)
{
@@ -228,7 +225,6 @@ public class StripeConnectService : IStripeConnectService
{
Amount = amountInCents,
Currency = currency.ToLower(),
ReceiptEmail = customerEmail,
Description = $"Deposit for quote {quoteNumber}",
Metadata = new Dictionary<string, string>
{
@@ -0,0 +1,162 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.Interfaces;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Web.BackgroundServices;
/// <summary>
/// Polls every 60 seconds for appointments whose reminder window has opened and dispatches
/// an email to the linked customer plus an in-app bell notification to company staff.
///
/// Deduplication strategy: after selecting candidates the service immediately stamps
/// <c>ReminderSentAt</c> on each appointment and saves before calling the notification
/// methods. This prevents a second loop iteration from re-sending if notifications are slow
/// or the application restarts mid-batch. A 24-hour lookback window caps the query so that
/// appointments that slipped through (e.g., server downtime) are silently skipped rather
/// than sending a stale reminder.
/// </summary>
public class AppointmentReminderBackgroundService : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<AppointmentReminderBackgroundService> _logger;
private static readonly TimeSpan PollingInterval = TimeSpan.FromMinutes(1);
/// <summary>
/// Appointments whose scheduled start is more than this far in the past are ignored even
/// if their reminder was never sent (server was down, etc.). We do not want to blast a
/// customer with a "your appointment is in 30 minutes" email hours after it was due.
/// </summary>
private static readonly TimeSpan MaxLookback = TimeSpan.FromHours(24);
public AppointmentReminderBackgroundService(
IServiceScopeFactory scopeFactory,
ILogger<AppointmentReminderBackgroundService> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
/// <summary>
/// Long-running loop that wakes every <see cref="PollingInterval"/> (60 s) and calls
/// <see cref="RunAsync"/>. Uses <see cref="Task.Delay"/> with the cancellation token so
/// the service shuts down promptly when the application stops.
/// </summary>
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("AppointmentReminderBackgroundService started.");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(PollingInterval, stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
if (stoppingToken.IsCancellationRequested) break;
await RunAsync(stoppingToken);
}
_logger.LogInformation("AppointmentReminderBackgroundService stopped.");
}
/// <summary>
/// One poll iteration: find all appointments whose reminder window has opened, stamp them,
/// then dispatch email + in-app notifications. A fresh DI scope is created per poll so that
/// the DbContext change tracker is clean each time.
/// </summary>
private async Task RunAsync(CancellationToken ct)
{
try
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var notificationService = scope.ServiceProvider.GetRequiredService<INotificationService>();
var inAppService = scope.ServiceProvider.GetRequiredService<IInAppNotificationService>();
// ScheduledStartTime is stored as server-local time (no UTC conversion on form submit),
// so compare against DateTime.Now rather than UtcNow to avoid a 4-hour EDT offset.
var now = DateTime.Now;
var lookback = now - MaxLookback;
// Find appointments where:
// - Reminder is enabled and has not been sent yet
// - The reminder window has opened: ScheduledStartTime - ReminderMinutesBefore <= now
// - The appointment hasn't been sitting unprocessed for more than MaxLookback
// - The appointment status is not terminal (not cancelled, completed, no-show, etc.)
// IgnoreQueryFilters bypasses the tenant filter — no HTTP context in a background service.
var candidates = await db.Appointments
.IgnoreQueryFilters()
.Include(a => a.Customer)
.Include(a => a.AppointmentStatus)
.Where(a =>
!a.IsDeleted &&
a.IsReminderEnabled &&
a.ReminderSentAt == null &&
a.ScheduledStartTime > lookback &&
EF.Functions.DateDiffMinute(now, a.ScheduledStartTime) <= a.ReminderMinutesBefore &&
!a.AppointmentStatus.IsTerminalStatus)
.ToListAsync(ct);
if (candidates.Count == 0) return;
_logger.LogInformation(
"AppointmentReminderBackgroundService: {Count} appointment reminder(s) to dispatch.",
candidates.Count);
// Stamp ReminderSentAt before sending — prevents a restart from re-sending.
var stampedAt = now;
foreach (var appt in candidates)
appt.ReminderSentAt = stampedAt;
await db.SaveChangesAsync(ct);
// Now send notifications. Failures here don't roll back the stamp because we'd
// rather skip one reminder than spam a customer on every restart.
foreach (var appt in candidates)
{
if (ct.IsCancellationRequested) break;
try
{
// Email to linked customer (no-ops internally if customer has opted out)
await notificationService.NotifyAppointmentReminderAsync(appt);
// In-app bell notification for company staff
var when = appt.IsAllDay
? appt.ScheduledStartTime.ToString("MMMM d, yyyy")
: appt.ScheduledStartTime.ToString("MMMM d, yyyy 'at' h:mm tt");
await inAppService.CreateAsync(
companyId: appt.CompanyId,
title: $"Appointment Reminder: {appt.Title}",
message: $"{appt.AppointmentNumber} is scheduled for {when}.",
notificationType: "AppointmentReminder",
link: $"/Appointments/Details/{appt.Id}",
customerId: appt.CustomerId);
_logger.LogInformation(
"Reminder dispatched for appointment {AppointmentNumber} (id {Id}, company {CompanyId}).",
appt.AppointmentNumber, appt.Id, appt.CompanyId);
}
catch (Exception ex)
{
_logger.LogError(ex,
"Failed to dispatch reminder for appointment {AppointmentId}.", appt.Id);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "AppointmentReminderBackgroundService poll failed.");
}
}
}
@@ -60,10 +60,11 @@ public class AccountingExportController : Controller
{
var start = startDate.Date;
var end = endDate.Date.AddDays(1).AddTicks(-1);
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// ── Load data ─────────────────────────────────────────────────────────
var invoices = (await _unitOfWork.Invoices.FindAsync(
i => i.InvoiceDate >= start && i.InvoiceDate <= end,
i => i.CompanyId == companyId && i.InvoiceDate >= start && i.InvoiceDate <= end,
false,
i => i.InvoiceItems,
i => i.Payments,
@@ -72,7 +73,7 @@ public class AccountingExportController : Controller
.ToList();
var expenses = (await _unitOfWork.Expenses.FindAsync(
e => e.Date >= start && e.Date <= end,
e => e.CompanyId == companyId && e.Date >= start && e.Date <= end,
false,
e => e.Vendor,
e => e.ExpenseAccount,
@@ -82,7 +83,7 @@ public class AccountingExportController : Controller
var bills = await _unitOfWork.Bills.GetForDateRangeAsync(start, end);
var customers = (await _unitOfWork.Customers.GetAllAsync())
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId))
.OrderBy(c => c.CompanyName ?? c.ContactFirstName)
.ToList();
@@ -381,9 +381,15 @@ public class AppointmentsController : Controller
return View(dto);
}
// Map changes
// Map changes — capture old start before overwrite so we can detect a reschedule.
var previousStart = appointment.ScheduledStartTime;
_mapper.Map(dto, appointment);
// If the appointment was rescheduled, clear the reminder stamp so the background
// service will fire again at the new time.
if (appointment.ScheduledStartTime != previousStart)
appointment.ReminderSentAt = null;
// Update
await _unitOfWork.Appointments.UpdateAsync(appointment);
await _unitOfWork.CompleteAsync();
@@ -486,9 +492,12 @@ public class AppointmentsController : Controller
try
{
var events = new List<CalendarEventDto>();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// 1. Fetch appointments in date range
var allAppointments = await _unitOfWork.Appointments.GetAllAsync(false,
var allAppointments = await _unitOfWork.Appointments.FindAsync(
a => a.CompanyId == companyId,
false,
a => a.Customer,
a => a.AppointmentType,
a => a.AppointmentStatus);
@@ -501,7 +510,9 @@ public class AppointmentsController : Controller
events.AddRange(appointmentEvents);
// 2. Fetch maintenance records in date range
var allMaintenanceRecords = await _unitOfWork.MaintenanceRecords.GetAllAsync(false,
var allMaintenanceRecords = await _unitOfWork.MaintenanceRecords.FindAsync(
m => m.CompanyId == companyId,
false,
m => m.Equipment);
var maintenanceRecords = allMaintenanceRecords
@@ -539,7 +550,9 @@ public class AppointmentsController : Controller
}
// 3. Fetch jobs and add as all-day events
var allJobs = await _unitOfWork.Jobs.GetAllAsync(false,
var allJobs = await _unitOfWork.Jobs.FindAsync(
j => j.CompanyId == companyId,
false,
j => j.Customer,
j => j.JobStatus);
@@ -718,6 +731,8 @@ public class AppointmentsController : Controller
var duration = appointment.ScheduledEndTime - appointment.ScheduledStartTime;
appointment.ScheduledStartTime = start;
appointment.ScheduledEndTime = end;
// Drag-drop always changes the time — reset so the reminder fires at the new time.
appointment.ReminderSentAt = null;
await _unitOfWork.Appointments.UpdateAsync(appointment);
await _unitOfWork.CompleteAsync();
@@ -746,13 +761,16 @@ public class AppointmentsController : Controller
try
{
var terminalCodes = new[] { AppConstants.StatusCodes.Job.Completed, AppConstants.StatusCodes.Job.Delivered, AppConstants.StatusCodes.Job.Cancelled };
var allJobs = await _unitOfWork.Jobs.GetAllAsync(false,
var calCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allJobs = await _unitOfWork.Jobs.FindAsync(
j => j.CompanyId == calCompanyId,
false,
j => j.Customer, j => j.JobStatus, j => j.JobItems);
// Load coats separately — filter by JobItemId using already-loaded item IDs
var jobItemIds = allJobs.SelectMany(j => j.JobItems.Select(i => i.Id)).ToList();
var allCoats = await _unitOfWork.JobItemCoats.FindAsync(
c => jobItemIds.Contains(c.JobItemId));
c => jobItemIds.Contains(c.JobItemId) && c.CompanyId == calCompanyId);
var coatsByItemId = allCoats
.Where(c => !c.IsDeleted)
@@ -891,7 +909,9 @@ public class AppointmentsController : Controller
/// </summary>
private async Task PopulateCreateDropdowns()
{
var customers = await _unitOfWork.Customers.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
var customerList = customers.Select(c => new
{
c.Id,
@@ -903,19 +923,16 @@ public class AppointmentsController : Controller
.ToList();
ViewBag.Customers = new SelectList(customerList, "Id", "DisplayName");
// Use cached appointment types
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var types = await _lookupCache.GetAppointmentTypeLookupsAsync(companyId);
ViewBag.AppointmentTypes = new SelectList(types.Where(t => t.IsActive).OrderBy(t => t.DisplayOrder), "Id", "DisplayName");
var companyIdForWorkers = _tenantContext.GetCurrentCompanyId() ?? 0;
var workers = await _userManager.Users
.Where(u => u.CompanyId == companyIdForWorkers && u.IsActive && u.CompanyRole != null)
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
.ToListAsync();
ViewBag.Workers = new SelectList(workers.Select(u => new { u.Id, FullName = u.FullName }), "Id", "FullName");
var jobs = await _unitOfWork.Jobs.GetAllAsync();
var jobs = await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId);
ViewBag.Jobs = new SelectList(jobs.OrderBy(j => j.JobNumber), "Id", "JobNumber");
}
@@ -27,15 +27,18 @@ namespace PowderCoating.Web.Controllers
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly ITenantContext _tenantContext;
private readonly ILogger<CatalogCategoriesController> _logger;
public CatalogCategoriesController(
IUnitOfWork unitOfWork,
IMapper mapper,
ITenantContext tenantContext,
ILogger<CatalogCategoriesController> logger)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_tenantContext = tenantContext;
_logger = logger;
}
@@ -52,8 +55,9 @@ namespace PowderCoating.Web.Controllers
{
try
{
var indexCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var categories = await _unitOfWork.CatalogCategories
.GetAllAsync(false,
.FindAsync(c => c.CompanyId == indexCompanyId, false,
c => c.ParentCategory,
c => c.SubCategories,
c => c.Items);
@@ -164,7 +168,8 @@ namespace PowderCoating.Web.Controllers
if (ModelState.IsValid)
{
// Check for duplicate category name under the same parent (case-insensitive)
var allCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allCategories = await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == companyId);
var existingCategory = allCategories.FirstOrDefault(c =>
c.Name.Equals(dto.Name.Trim(), StringComparison.OrdinalIgnoreCase) &&
c.ParentCategoryId == dto.ParentCategoryId);
@@ -272,7 +277,8 @@ namespace PowderCoating.Web.Controllers
if (nameChanged || parentChanged)
{
var allCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
var editCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allCategories = await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == editCompanyId);
var existingCategory = allCategories.FirstOrDefault(c =>
c.Id != id &&
c.Name.Equals(dto.Name.Trim(), StringComparison.OrdinalIgnoreCase) &&
@@ -444,7 +450,8 @@ namespace PowderCoating.Web.Controllers
var trimmedName = request.Name.Trim();
// Check for duplicate category name under the same parent (case-insensitive)
var allCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
var quickCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allCategories = await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == quickCompanyId);
var existingCategory = allCategories.FirstOrDefault(c =>
c.Name.Equals(trimmedName, StringComparison.OrdinalIgnoreCase) &&
c.ParentCategoryId == request.ParentCategoryId);
@@ -500,8 +507,9 @@ namespace PowderCoating.Web.Controllers
{
try
{
var treeCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var categories = await _unitOfWork.CatalogCategories
.GetAllAsync(false, c => c.SubCategories, c => c.Items);
.FindAsync(c => c.CompanyId == treeCompanyId, false, c => c.SubCategories, c => c.Items);
// Build tree from root categories
var rootCategories = categories
@@ -535,7 +543,8 @@ namespace PowderCoating.Web.Controllers
{
try
{
var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList();
var dropdownCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var categories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == dropdownCompanyId)).ToList();
// Build hierarchical list (parents before children)
var hierarchicalList = new List<CatalogCategory>();
@@ -573,7 +582,8 @@ namespace PowderCoating.Web.Controllers
/// </param>
private async Task PopulateParentCategoryDropdown(int? excludeCategoryId = null)
{
var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList();
var parentDropCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var categories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == parentDropCompanyId)).ToList();
// Exclude the current category and its descendants to prevent circular references
var excludedIds = new HashSet<int>();
@@ -700,7 +710,8 @@ namespace PowderCoating.Web.Controllers
if (categoryId == newParentId)
return true;
var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList();
var circleCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var categories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == circleCompanyId)).ToList();
var current = categories.FirstOrDefault(c => c.Id == newParentId);
while (current != null)
@@ -83,7 +83,8 @@ namespace PowderCoating.Web.Controllers
try
{
// Get all categories with their items
var allCategories = (await _unitOfWork.CatalogCategories.GetAllAsync(false, c => c.Items)).ToList();
var itemsCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allCategories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == itemsCompanyId, false, c => c.Items)).ToList();
var allItems = allCategories.SelectMany(c => c.Items).ToList();
// Apply search filter
@@ -578,7 +579,8 @@ namespace PowderCoating.Web.Controllers
return Json(new List<object>());
}
var allItems = await _unitOfWork.CatalogItems.GetAllAsync(false, i => i.Category);
var searchCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allItems = await _unitOfWork.CatalogItems.FindAsync(i => i.CompanyId == searchCompanyId, false, i => i.Category);
var search = searchTerm.ToLower();
var items = allItems
@@ -694,7 +696,8 @@ namespace PowderCoating.Web.Controllers
/// </summary>
private async Task PopulateCategoryDropdown()
{
var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var categories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == companyId)).ToList();
// Build hierarchical list (parents before children)
var hierarchicalList = new List<CatalogCategory>();
@@ -1045,7 +1048,7 @@ namespace PowderCoating.Web.Controllers
// Load all categories so we can build full paths (e.g. "Cerakote > Firearms").
// The full path gives Claude the coating-type context it needs — an item in
// "Firearms" under "Cerakote" costs very differently than one under "Powder Coat".
var allCategories = (await _unitOfWork.CatalogCategories.GetAllAsync())
var allCategories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == currentUser.CompanyId))
.ToDictionary(c => c.Id);
// Load company operating costs
@@ -142,10 +142,10 @@ public class CompanySettingsController : Controller
&& !connectClientId.Contains("your_connect_client_id_here", StringComparison.OrdinalIgnoreCase);
// Load notification templates for inline tab
var existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
var existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
if (seeded > 0)
existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
dto.NotificationTemplates = existing
.OrderBy(t => (int)t.NotificationType).ThenBy(t => (int)t.Channel)
@@ -755,8 +755,8 @@ public class CompanySettingsController : Controller
var costs = company.OperatingCosts;
var ovens = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive)).OrderBy(o => o.DisplayOrder).ToList();
var coatingCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsCoating)).ToList();
var ovens = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive && o.CompanyId == companyId.Value)).OrderBy(o => o.DisplayOrder).ToList();
var coatingCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsCoating && c.CompanyId == companyId.Value)).ToList();
var sb = new System.Text.StringBuilder();
@@ -920,7 +920,8 @@ public class CompanySettingsController : Controller
{
try
{
var statuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var statuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == companyId);
var sortedStatuses = statuses.OrderBy(s => s.DisplayOrder).ToList();
var dtos = _mapper.Map<List<JobStatusLookupDto>>(sortedStatuses);
@@ -1071,7 +1072,8 @@ public class CompanySettingsController : Controller
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var statuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId();
var statuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == (companyId ?? 0));
for (int i = 0; i < dto.OrderedIds.Count; i++)
{
@@ -1084,7 +1086,6 @@ public class CompanySettingsController : Controller
}
await _unitOfWork.CompleteAsync();
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId.HasValue) _lookupCache.InvalidateCompanyCache(companyId.Value);
_logger.LogInformation("Job statuses reordered");
@@ -1113,7 +1114,8 @@ public class CompanySettingsController : Controller
{
try
{
var priorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId);
var sortedPriorities = priorities.OrderBy(p => p.DisplayOrder).ToList();
var dtos = _mapper.Map<List<JobPriorityLookupDto>>(sortedPriorities);
@@ -1258,7 +1260,8 @@ public class CompanySettingsController : Controller
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var priorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId);
for (int i = 0; i < dto.OrderedIds.Count; i++)
{
@@ -1297,7 +1300,8 @@ public class CompanySettingsController : Controller
{
try
{
var statuses = await _unitOfWork.QuoteStatusLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var statuses = await _unitOfWork.QuoteStatusLookups.FindAsync(s => s.CompanyId == companyId);
var sortedStatuses = statuses.OrderBy(s => s.DisplayOrder).ToList();
var dtos = _mapper.Map<List<QuoteStatusLookupDto>>(sortedStatuses);
@@ -1478,7 +1482,8 @@ public class CompanySettingsController : Controller
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var statuses = await _unitOfWork.QuoteStatusLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var statuses = await _unitOfWork.QuoteStatusLookups.FindAsync(s => s.CompanyId == companyId);
for (int i = 0; i < dto.OrderedIds.Count; i++)
{
@@ -1517,7 +1522,8 @@ public class CompanySettingsController : Controller
{
try
{
var services = await _unitOfWork.PrepServices.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var services = await _unitOfWork.PrepServices.FindAsync(s => s.CompanyId == companyId);
var sortedServices = services.OrderBy(s => s.DisplayOrder).ToList();
var dtos = _mapper.Map<List<PrepServiceDto>>(sortedServices);
@@ -1639,7 +1645,8 @@ public class CompanySettingsController : Controller
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var services = await _unitOfWork.PrepServices.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var services = await _unitOfWork.PrepServices.FindAsync(s => s.CompanyId == companyId);
for (int i = 0; i < dto.OrderedIds.Count; i++)
{
@@ -1812,7 +1819,8 @@ public class CompanySettingsController : Controller
{
try
{
var types = await _unitOfWork.AppointmentTypeLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var types = await _unitOfWork.AppointmentTypeLookups.FindAsync(t => t.CompanyId == companyId);
var sortedTypes = types.OrderBy(t => t.DisplayOrder).ToList();
var dtos = _mapper.Map<List<AppointmentTypeLookupDto>>(sortedTypes);
@@ -1956,7 +1964,8 @@ public class CompanySettingsController : Controller
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var types = await _unitOfWork.AppointmentTypeLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var types = await _unitOfWork.AppointmentTypeLookups.FindAsync(t => t.CompanyId == companyId);
for (int i = 0; i < dto.OrderedIds.Count; i++)
{
@@ -1996,7 +2005,8 @@ public class CompanySettingsController : Controller
{
try
{
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
var sortedCategories = categories.OrderBy(c => c.DisplayOrder).ToList();
var dtos = _mapper.Map<List<InventoryCategoryLookupDto>>(sortedCategories);
@@ -2132,7 +2142,8 @@ public class CompanySettingsController : Controller
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
for (int i = 0; i < dto.OrderedIds.Count; i++)
{
@@ -2349,12 +2360,12 @@ public class CompanySettingsController : Controller
if (companyId == null) return RedirectToAction(nameof(Index));
// Load all existing templates for this company
var existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
var existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
// Auto-seed any missing canonical combinations
var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
if (seeded > 0)
existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
var dtos = existing.OrderBy(t => (int)t.NotificationType).ThenBy(t => (int)t.Channel)
.Select(t => new NotificationTemplateDto
@@ -315,7 +315,8 @@ public class CreditMemosController : Controller
private async Task PopulateCustomersAsync(int? selectedId)
{
var customers = await _unitOfWork.Customers.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
ViewBag.Customers = customers
.OrderBy(c => c.CompanyName ?? $"{c.ContactFirstName} {c.ContactLastName}".Trim())
.Select(c => new SelectListItem
@@ -342,14 +342,16 @@ public class DashboardController : Controller
TipOfTheDay = data.TipOfTheDay
};
// Resolve company once so all remaining queries are explicitly scoped
var currentCompanyId = _tenantContext.GetCurrentCompanyId();
var companyId = currentCompanyId ?? 0;
// Dropdowns for the "Add Custom Powder to Inventory" modal
var inventoryCategories = (await _unitOfWork.InventoryCategoryLookups.GetAllAsync())
.Where(c => c.IsActive)
var inventoryCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsActive && c.CompanyId == companyId))
.OrderBy(c => c.DisplayOrder)
.Select(c => new { c.Id, c.DisplayName })
.ToList();
var vendors = (await _unitOfWork.Vendors.GetAllAsync())
.Where(v => v.IsActive)
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.IsActive && v.CompanyId == companyId))
.OrderBy(v => v.CompanyName)
.Select(v => new { v.Id, v.CompanyName })
.ToList();
@@ -357,7 +359,6 @@ public class DashboardController : Controller
ViewBag.VendorList = vendors;
// Config health check — surface setup gaps to company admins
var currentCompanyId = _tenantContext.GetCurrentCompanyId();
if (currentCompanyId.HasValue)
{
ViewBag.ConfigHealth = await _configHealth.CheckAsync(currentCompanyId.Value);
@@ -711,8 +712,8 @@ public class DashboardController : Controller
i => i.Coats.Any(c => c.Id == coatId), false, i => i.Job);
var companyId = jobItem?.Job?.CompanyId ?? _tenantContext.GetCurrentCompanyId() ?? 0;
// Check SKU uniqueness
if (await _unitOfWork.InventoryItems.AnyAsync(i => i.SKU == sku.Trim()))
// Check SKU uniqueness within this company
if (await _unitOfWork.InventoryItems.AnyAsync(i => i.SKU == sku.Trim() && i.CompanyId == companyId))
return Json(new { success = false, message = $"SKU '{sku}' already exists in inventory." });
// Determine category display name for legacy field
@@ -64,6 +64,7 @@ public class InventoryController : Controller
public async Task<IActionResult> Index(
string? searchTerm,
string? category,
string? location,
string? sortColumn,
string sortDirection = "asc",
bool lowStockOnly = false,
@@ -87,50 +88,64 @@ public class InventoryController : Controller
};
gridRequest.Validate();
// Build search and category filter
// Build filter — compose search, category, location, and low-stock predicates
System.Linq.Expressions.Expression<Func<InventoryItem, bool>>? filter = null;
if (lowStockOnly && !string.IsNullOrWhiteSpace(searchTerm))
{
var search = searchTerm.ToLower();
var hasSearch = !string.IsNullOrWhiteSpace(searchTerm);
var hasCategory = !string.IsNullOrWhiteSpace(category);
var hasLocation = !string.IsNullOrWhiteSpace(location);
var search = searchTerm?.ToLower() ?? "";
var cat = category ?? "";
var loc = location ?? "";
if (lowStockOnly && hasSearch && hasLocation)
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint
&& (i.SKU.ToLower().Contains(search)
|| i.Name.ToLower().Contains(search)
&& (i.Location != null && i.Location.ToLower() == loc.ToLower())
&& (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)));
}
else if (lowStockOnly && hasSearch)
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint
&& (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)));
else if (lowStockOnly && hasLocation)
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint
&& (i.Location != null && i.Location.ToLower() == loc.ToLower());
else if (lowStockOnly)
{
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint;
}
else if (!string.IsNullOrWhiteSpace(searchTerm) && !string.IsNullOrWhiteSpace(category))
{
// Both search and category filter
var search = searchTerm.ToLower();
var cat = category;
filter = i => (i.SKU.ToLower().Contains(search)
|| i.Name.ToLower().Contains(search)
|| (i.Description != null && i.Description.ToLower().Contains(search))
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)))
else if (hasSearch && hasCategory && hasLocation)
filter = i => (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|| (i.Description != null && i.Description.ToLower().Contains(search))
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)))
&& i.Category.ToLower() == cat.ToLower()
&& (i.Location != null && i.Location.ToLower() == loc.ToLower());
else if (hasSearch && hasCategory)
filter = i => (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|| (i.Description != null && i.Description.ToLower().Contains(search))
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)))
&& i.Category.ToLower() == cat.ToLower();
}
else if (!string.IsNullOrWhiteSpace(searchTerm))
{
// Search only
var search = searchTerm.ToLower();
filter = i => i.SKU.ToLower().Contains(search)
|| i.Name.ToLower().Contains(search)
else if (hasSearch && hasLocation)
filter = i => (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|| (i.Description != null && i.Description.ToLower().Contains(search))
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)))
&& (i.Location != null && i.Location.ToLower() == loc.ToLower());
else if (hasSearch)
filter = i => i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|| (i.Description != null && i.Description.ToLower().Contains(search))
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search));
}
else if (!string.IsNullOrWhiteSpace(category))
{
// Category filter only
var cat = category;
else if (hasCategory && hasLocation)
filter = i => i.Category.ToLower() == cat.ToLower()
&& (i.Location != null && i.Location.ToLower() == loc.ToLower());
else if (hasCategory)
filter = i => i.Category.ToLower() == cat.ToLower();
}
else if (hasLocation)
filter = i => i.Location != null && i.Location.ToLower() == loc.ToLower();
// Build orderBy function
Func<IQueryable<InventoryItem>, IOrderedQueryable<InventoryItem>> orderBy = gridRequest.SortColumn switch
@@ -159,18 +174,21 @@ public class InventoryController : Controller
var pagedResult = PagedResult<InventoryListDto>.From(gridRequest, itemDtos, totalCount);
// Load all items once to compute sidebar stats and category list in memory
var allItems = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
// Load all items once to compute sidebar stats and dropdown option lists in memory
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allItems = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
ViewBag.Categories = allItems.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToList();
ViewBag.Locations = allItems.Select(i => i.Location).Where(l => !string.IsNullOrWhiteSpace(l)).Distinct().OrderBy(l => l).ToList();
ViewBag.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
ViewBag.StatsActiveCount = allItems.Count(i => i.IsActive);
ViewBag.StatsTotalValue = allItems.Sum(i => (decimal?)i.QuantityOnHand * i.UnitCost) ?? 0m;
// Set ViewBag for sorting and filters
ViewBag.SearchTerm = searchTerm;
ViewBag.Category = category;
ViewBag.SearchTerm = searchTerm;
ViewBag.Category = category;
ViewBag.Location = location;
ViewBag.LowStockOnly = lowStockOnly;
ViewBag.SortColumn = gridRequest.SortColumn;
ViewBag.SortColumn = gridRequest.SortColumn;
ViewBag.SortDirection = gridRequest.SortDirection;
return View(pagedResult);
@@ -183,6 +201,26 @@ public class InventoryController : Controller
}
}
/// <summary>
/// Returns a print-optimised list of all active inventory items in a given bin/location.
/// Renders without the site chrome (no layout) so the browser print dialog produces a
/// clean page. Items are sorted by name within the bin.
/// </summary>
public async Task<IActionResult> PrintBin(string location)
{
if (string.IsNullOrWhiteSpace(location))
return RedirectToAction(nameof(Index));
var loc = location.Trim();
var items = await _unitOfWork.InventoryItems.FindAsync(
i => i.Location != null && i.Location.ToLower() == loc.ToLower());
var dtos = _mapper.Map<List<InventoryListDto>>(items.OrderBy(i => i.Name).ToList());
ViewBag.Location = loc;
ViewBag.PrintedAt = DateTime.Now;
return View(dtos);
}
/// <summary>
/// Renders the inventory item detail page. The primary vendor name is looked up
/// separately because the repository does not eager-load Vendor by default, avoiding
@@ -896,10 +934,31 @@ public class InventoryController : Controller
if (!string.IsNullOrWhiteSpace(qrUrl))
{
// QR path: fetch the product page; LookupByUrlAsync now maps all identity + spec fields
aiResult = await _aiLookupService.LookupByUrlAsync(qrUrl, null);
if (aiResult.Success && aiResult.SpecPageUrl == null)
aiResult.SpecPageUrl = qrUrl;
// QR path: try to extract product identity from the URL using manufacturer patterns.
// Prismatic Powders URLs embed both the SKU and color slug in the path
// (e.g. /shop/powder-coating-colors/PMB-6906/fire-red), so we can feed the full
// LookupAsync pipeline (Serper + direct URL + Claude) with real context instead of
// relying solely on a page fetch that may fail in production.
var activePatterns = (await _unitOfWork.ManufacturerLookupPatterns.GetAllAsync(ignoreQueryFilters: true))
.Where(p => p.IsActive).ToList();
var (urlMfr, urlColor, urlPart) = TryParseManufacturerUrl(qrUrl, activePatterns);
if (!string.IsNullOrWhiteSpace(urlMfr))
{
aiResult = await _aiLookupService.LookupAsync(urlMfr, urlColor, null, urlPart);
// The scanned QR URL is always the authoritative product page link — it came
// directly from the manufacturer's bag and is always fully-qualified. Overwrite
// whatever LookupAsync returned (which may be a scheme-less path from the template).
if (aiResult.Success)
aiResult.SpecPageUrl = qrUrl;
}
else
{
// No pattern match — fall back to URL-based lookup
aiResult = await _aiLookupService.LookupByUrlAsync(qrUrl, null);
if (aiResult.Success && aiResult.SpecPageUrl == null)
aiResult.SpecPageUrl = qrUrl;
}
}
else if (!string.IsNullOrWhiteSpace(imageBase64))
{
@@ -1106,7 +1165,8 @@ public class InventoryController : Controller
// Build a set of SKUs already in this company's inventory so we can exclude them.
// When editing, the current item's own SKU is re-included so its catalog entry still appears.
var existingItems = await _unitOfWork.InventoryItems.GetAllAsync();
var skuCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var existingItems = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == skuCompanyId);
var existingSkus = existingItems
.Where(i => !string.IsNullOrWhiteSpace(i.ManufacturerPartNumber) && i.Id != (currentId ?? 0))
.Select(i => i.ManufacturerPartNumber!.Trim().ToLower())
@@ -1182,7 +1242,7 @@ public class InventoryController : Controller
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Find the default coating category to assign
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
var coatingCategory = categories
.Where(c => c.IsActive && c.IsCoating)
.OrderBy(c => c.DisplayOrder)
@@ -1270,6 +1330,72 @@ public class InventoryController : Controller
return transferEfficiency ?? DefaultTransferEfficiency;
}
/// <summary>
/// Reverse-parses a scanned QR URL against known manufacturer URL templates to extract
/// product identity (manufacturer name, color name, part number). For example, a Prismatic
/// Powders URL like /shop/powder-coating-colors/PMB-6906/fire-red yields manufacturer
/// "Prismatic Powders", partNumber "PMB-6906", and colorName "Fire Red". Returns nulls
/// when no active pattern matches the URL domain.
/// </summary>
private static (string? manufacturer, string? colorName, string? partNumber) TryParseManufacturerUrl(
string url, IEnumerable<Core.Entities.ManufacturerLookupPattern> patterns)
{
Uri? parsed;
try { parsed = new Uri(url); } catch { return (null, null, null); }
var host = parsed.Host.Replace("www.", "", StringComparison.OrdinalIgnoreCase);
foreach (var pattern in patterns.Where(p => !string.IsNullOrEmpty(p.Domain) && !string.IsNullOrEmpty(p.ProductUrlTemplate)))
{
if (!host.Equals(pattern.Domain, StringComparison.OrdinalIgnoreCase)) continue;
var template = pattern.ProductUrlTemplate!;
var placeholderKeys = new[] { "{partNumber}", "{slug}", "{colorName}", "{colorCode}" };
// Find the first placeholder to split off the static prefix
var firstIdx = placeholderKeys
.Select(ph => template.IndexOf(ph, StringComparison.OrdinalIgnoreCase))
.Where(i => i >= 0)
.DefaultIfEmpty(-1)
.Min();
if (firstIdx < 0) continue;
var staticPrefix = template[..firstIdx].TrimEnd('/');
var fullUrl = url.TrimEnd('/');
if (!fullUrl.StartsWith(staticPrefix, StringComparison.OrdinalIgnoreCase)) continue;
var templateSegments = template[firstIdx..].Split('/', StringSplitOptions.RemoveEmptyEntries);
var urlSegments = fullUrl[staticPrefix.Length..].Split('/', StringSplitOptions.RemoveEmptyEntries);
var extracted = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
bool matched = true;
for (int i = 0; i < templateSegments.Length && i < urlSegments.Length; i++)
{
var seg = templateSegments[i];
if (seg.StartsWith("{") && seg.EndsWith("}"))
extracted[seg[1..^1]] = urlSegments[i];
else if (!seg.Equals(urlSegments[i], StringComparison.OrdinalIgnoreCase))
{ matched = false; break; }
}
if (!matched) continue;
extracted.TryGetValue("partNumber", out var partNumber);
var slug = extracted.TryGetValue("slug", out var s) ? s
: extracted.TryGetValue("colorName", out var cn) ? cn
: extracted.TryGetValue("colorCode", out var cc) ? cc : null;
// Convert URL slug to display name: "fire-red" → "Fire Red"
string? colorName = slug == null ? null
: string.Join(" ", slug.Split(new[] { '-', '_' }, StringSplitOptions.RemoveEmptyEntries)
.Select(w => w.Length > 0 ? char.ToUpperInvariant(w[0]) + w[1..].ToLowerInvariant() : w));
return (pattern.ManufacturerName, colorName, partNumber);
}
return (null, null, null);
}
/// <summary>
/// Normalizes a string to title-case using the current culture's TextInfo. Applied to
/// inventory item names on create and edit so the list view is consistently formatted
@@ -1369,11 +1495,11 @@ public class InventoryController : Controller
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
ViewBag.AiInventoryAssistEnabled = await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId);
var vendors = await _unitOfWork.Vendors.GetAllAsync();
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId);
ViewBag.Vendors = new SelectList(vendors.Where(s => s.IsActive).OrderBy(s => s.CompanyName), "Id", "CompanyName");
// Load categories from lookup table
var allCategories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
var allCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
var categories = allCategories
.Where(c => c.IsActive)
.OrderBy(c => c.DisplayOrder)
@@ -1738,7 +1864,8 @@ public class InventoryController : Controller
DateTime? dateTo,
string? typeFilter)
{
var allItems = await _unitOfWork.InventoryItems.GetAllAsync();
var ledgerCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allItems = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == ledgerCompanyId);
var itemList = allItems
.Where(i => i.IsActive || i.QuantityOnHand > 0)
.OrderBy(i => i.Name)
@@ -340,13 +340,14 @@ public class InvoicesController : Controller
var costs = await _unitOfWork.CompanyOperatingCosts
.FirstOrDefaultAsync(c => c.CompanyId == currentUser.CompanyId && !c.IsDeleted);
var defaultTerms = prefs?.DefaultPaymentTerms ?? "Net 30";
var dto = new CreateInvoiceDto
{
PreparedById = currentUser.Id,
InvoiceDate = DateTime.Today,
DueDate = DateTime.Today.AddDays(prefs?.DefaultTurnaroundDays ?? 30),
DueDate = PaymentTermsParser.CalculateDueDate(defaultTerms, DateTime.Today),
TaxPercent = costs?.TaxPercent ?? 0,
Terms = prefs?.DefaultPaymentTerms ?? "Net 30"
Terms = defaultTerms
};
if (jobId.HasValue)
@@ -354,6 +355,15 @@ public class InvoicesController : Controller
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId.Value, false, j => j.Customer, j => j.JobItems);
if (job == null) return NotFound();
// Pre-load coats so we can derive color names for invoice line items
var activeItemIds = job.JobItems.Where(ji => !ji.IsDeleted).Select(ji => ji.Id).ToList();
var allCoats = activeItemIds.Any()
? (await _unitOfWork.JobItemCoats.FindAsync(c => activeItemIds.Contains(c.JobItemId) && !c.IsDeleted)).ToList()
: new List<JobItemCoat>();
var coatsByItem = allCoats
.GroupBy(c => c.JobItemId)
.ToDictionary(g => g.Key, g => g.OrderBy(c => c.Sequence).ToList());
// Validate no existing active invoice for this job (voided ones are kept as history)
var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId.Value);
if (existing != null && existing.Status != InvoiceStatus.Voided)
@@ -378,6 +388,13 @@ public class InvoicesController : Controller
var defaultRevenueAccount = await _unitOfWork.Accounts
.FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.IsActive);
// Deserialize the job's pricing snapshot up front — it is authoritative for discount,
// tax, and fees for both quote-based and direct jobs, because it is recalculated on
// every save and reflects any edits made after quote conversion.
QuotePricingBreakdownDto? jobBreakdown = null;
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
jobBreakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
// If the job came from a quote, load it so we can use the agreed pricing.
// The quote stores the approved total including oven batch cost and shop supplies —
// these are quote-level charges that are NOT stored on individual job items.
@@ -396,6 +413,16 @@ public class InvoicesController : Controller
revenueAccountId = ci.RevenueAccountId;
revenueAccountId ??= defaultRevenueAccount?.Id;
// Derive color from coats when the item itself has no explicit color set
var derivedColor = item.ColorName;
if (string.IsNullOrEmpty(derivedColor) && coatsByItem.TryGetValue(item.Id, out var itemCoats))
{
var coatColors = itemCoats
.Where(c => !string.IsNullOrEmpty(c.ColorName))
.Select(c => c.ColorName!);
derivedColor = string.Join(" / ", coatColors);
}
dto.InvoiceItems.Add(new CreateInvoiceItemDto
{
SourceJobItemId = item.Id,
@@ -404,7 +431,7 @@ public class InvoicesController : Controller
Quantity = item.Quantity > 0 ? item.Quantity : 1,
UnitPrice = item.UnitPrice,
TotalPrice = item.TotalPrice,
ColorName = item.ColorName,
ColorName = derivedColor,
Notes = item.Notes,
DisplayOrder = order++,
RevenueAccountId = revenueAccountId
@@ -437,16 +464,22 @@ public class InvoicesController : Controller
// If the job came from a quote, carry over the quote-level costs and agreed terms.
// The quote SubTotal = sum(items) + oven batch cost + shop supplies.
// Job items only capture per-item prices, so oven & shop supplies need a separate line.
// Read directly from the quote snapshot — never try to reverse-engineer from job.FinalPrice
// because FinalPrice is recalculated on every item edit and can drift from the original quote.
// For fee components, prefer the job's own breakdown snapshot (updated every time the job
// is saved) over the source quote — the quote's FacilityOverheadCost was only added in
// migration AddQuotePricingSnapshotFields (May 2026); older quotes have 0 there even though
// overhead was included in the quote total. Tax and discount still come from the quote
// because those represent the customer-approved agreed terms.
if (sourceQuote != null)
{
// Bundle all quote-level charges so the invoice subtotal matches the quote total.
// FacilityOverheadCost is included — it is a real cost baked into the quoted price.
var processingFees = sourceQuote.OvenBatchCost
+ sourceQuote.FacilityOverheadCost
+ sourceQuote.ShopSuppliesAmount
+ sourceQuote.RushFee;
// Prefer job breakdown values for dynamic fee components; fall back to quote for
// compatibility with jobs that were never re-saved after the May 2026 migration.
var ovenCost = jobBreakdown != null ? jobBreakdown.OvenBatchCost : sourceQuote.OvenBatchCost;
var overhead = jobBreakdown != null ? jobBreakdown.FacilityOverheadCost : sourceQuote.FacilityOverheadCost;
var shopSupplies = jobBreakdown != null ? jobBreakdown.ShopSuppliesAmount : sourceQuote.ShopSuppliesAmount;
var rushFee = jobBreakdown != null ? jobBreakdown.RushFee : sourceQuote.RushFee;
// Bundle all quote-level charges so the invoice subtotal matches the job total.
var processingFees = ovenCost + overhead + shopSupplies + rushFee;
if (processingFees > 0.01m)
{
@@ -461,17 +494,15 @@ public class InvoicesController : Controller
});
}
// Use the quote's agreed tax rate and discount — not current company defaults
dto.TaxPercent = sourceQuote.TaxPercent;
// Use the quote's agreed tax rate and discount — these represent the customer-approved
// price and must not be recomputed from the job's current state.
dto.TaxPercent = sourceQuote.TaxPercent;
dto.DiscountAmount = sourceQuote.DiscountAmount;
}
else if (hadJobItems)
{
// Direct job — no source quote. Read all charges from the pricing snapshot so the
// invoice always matches the total shown on the job's Pricing Summary card.
QuotePricingBreakdownDto? jobBreakdown = null;
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
jobBreakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
if (job.OvenBatchCost > 0.01m)
{
@@ -529,6 +560,22 @@ public class InvoicesController : Controller
RevenueAccountId = defaultRevenueAccount?.Id
});
}
dto.DiscountAmount = jobBreakdown?.DiscountAmount ?? 0;
}
// Inherit payment terms from the source quote or the customer — more specific than
// the company-wide default set in the outer DTO. Quote terms take priority because
// they represent the agreed price; customer terms are next best for direct jobs.
var inheritedTerms = sourceQuote?.Terms ?? job.Customer?.PaymentTerms;
if (!string.IsNullOrWhiteSpace(inheritedTerms))
{
dto.Terms = inheritedTerms;
dto.DueDate = PaymentTermsParser.CalculateDueDate(inheritedTerms, DateTime.Today)
?? dto.DueDate;
var (discPct, discDays) = PaymentTermsParser.ParseEarlyPaymentDiscount(inheritedTerms);
dto.EarlyPaymentDiscountPercent = discPct;
dto.EarlyPaymentDiscountDays = discDays;
}
// Override tax to 0 for tax-exempt customers, regardless of company default or quote rate
@@ -1439,6 +1486,7 @@ public class InvoicesController : Controller
}
var currentUser = await _userManager.GetUserAsync(User);
var totalCreditCreated = 0m; // populated inside transaction, used in success message
await _unitOfWork.ExecuteInTransactionAsync(async () =>
{
@@ -1458,6 +1506,75 @@ public class InvoicesController : Controller
await _unitOfWork.Payments.SoftDeleteAsync(payment.Id);
}
// Re-release any deposits that were applied to this invoice so they can be
// auto-applied to the replacement invoice. Without this, AppliedToInvoiceId
// stays set and the deposit lookup (AppliedToInvoiceId == null) skips them.
var appliedDeposits = await _unitOfWork.Deposits.FindAsync(
d => d.AppliedToInvoiceId == invoice.Id && !d.IsDeleted);
var totalDepositReleased = 0m;
foreach (var deposit in appliedDeposits)
{
deposit.AppliedToInvoiceId = null;
deposit.AppliedDate = null;
deposit.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Deposits.UpdateAsync(deposit);
totalDepositReleased += deposit.Amount;
}
// Restore the CustomerDeposits 2300 liability that was cleared when the deposits
// were applied. Mirrors the DR at apply time; follows the same simplified reversal
// pattern as the rest of the void (regular payment GL entries are also left as-is).
if (totalDepositReleased > 0)
{
var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(invoice.CompanyId);
await _accountBalanceService.CreditAsync(custDepositsAcctId, totalDepositReleased);
}
// Convert non-deposit payments (cash, card, check, online) to customer credits so
// the money isn't lost when the invoice is voided. Each payment becomes a CRED-
// Deposit record linked to the same job; it will auto-apply when the replacement
// invoice is created, exactly like a normal deposit.
var nonDepositPayments = invoice.Payments
.Where(p => !p.IsDeleted && !(p.Reference ?? "").StartsWith("Deposit "))
.ToList();
if (nonDepositPayments.Any())
{
var credPrefix = $"CRED-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-";
var existingNums = (await _unitOfWork.Deposits.FindAsync(
d => d.CompanyId == invoice.CompanyId && d.ReceiptNumber.StartsWith(credPrefix),
ignoreQueryFilters: true))
.Select(d => d.ReceiptNumber).ToList();
var maxNum = 0;
foreach (var rn in existingNums)
{
var suffix = rn.Length >= credPrefix.Length + 4 ? rn[credPrefix.Length..] : "";
if (int.TryParse(suffix, out int parsed) && parsed > maxNum) maxNum = parsed;
}
var creditCustDepositsAcctId = await GetCustomerDepositsAccountIdAsync(invoice.CompanyId);
foreach (var payment in nonDepositPayments)
{
maxNum++;
await _unitOfWork.Deposits.AddAsync(new Core.Entities.Deposit
{
CompanyId = invoice.CompanyId,
CustomerId = invoice.CustomerId,
JobId = invoice.JobId,
Amount = payment.Amount,
PaymentMethod = payment.PaymentMethod,
ReceivedDate = payment.PaymentDate,
Reference = payment.Reference,
Notes = $"Credit from voided invoice {invoice.InvoiceNumber}" +
(string.IsNullOrWhiteSpace(payment.Notes) ? "." : $". Original: {payment.Notes}"),
ReceiptNumber = $"{credPrefix}{maxNum:D4}",
CreatedAt = DateTime.UtcNow
});
totalCreditCreated += payment.Amount;
}
// CR CustomerDeposits to create the liability matching the cash already in Checking
await _accountBalanceService.CreditAsync(creditCustDepositsAcctId, totalCreditCreated);
}
// Void any gift certificates that were generated from this invoice.
// Capture each GC's remaining balance BEFORE voiding so the GL entries below can use it.
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(invoice.CompanyId);
@@ -1509,7 +1626,10 @@ public class InvoicesController : Controller
}); // end ExecuteInTransactionAsync
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} has been voided.";
var creditMsg = totalCreditCreated > 0
? $" {totalCreditCreated:C} converted to customer credit and will auto-apply to the next invoice."
: "";
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} has been voided.{creditMsg}";
return RedirectToAction(nameof(Details), new { id });
}
catch (Exception ex)
@@ -1668,6 +1788,59 @@ public class InvoicesController : Controller
}
}
// -----------------------------------------------------------------------
// GET: /Invoices/DownloadPackingSlip/5
// -----------------------------------------------------------------------
/// <summary>
/// Generates a no-price packing slip PDF for physical pickup/delivery paperwork.
/// Reuses the same company branding and invoice data pipeline as DownloadPdf but
/// delegates to GeneratePackingSlipPdfAsync which omits all pricing columns.
/// </summary>
public async Task<IActionResult> DownloadPackingSlip(int? id, bool inline = false)
{
if (id == null) return NotFound();
try
{
var invoice = await LoadInvoiceForViewAsync(id.Value);
if (invoice == null) return NotFound();
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
var company = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
var companyInfo = new Application.DTOs.Company.CompanyInfoDto
{
CompanyName = company?.CompanyName ?? string.Empty,
Phone = company?.Phone,
Address = company?.Address,
City = company?.City,
State = company?.State,
ZipCode = company?.ZipCode,
PrimaryContactEmail = company?.PrimaryContactEmail
};
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
var dto = await BuildInvoiceDtoAsync(invoice);
var pdfBytes = await _pdfService.GeneratePackingSlipPdfAsync(dto, logoData, logoContentType, companyInfo);
var fileName = $"PackingSlip-{invoice.InvoiceNumber}.pdf";
if (inline)
{
Response.Headers["Content-Disposition"] = $"inline; filename=\"{fileName}\"";
return File(pdfBytes, "application/pdf");
}
return File(pdfBytes, "application/pdf", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating packing slip for invoice {Id}", id);
TempData["ErrorPermanent"] = $"Packing slip generation failed: {ex.Message}";
return RedirectToAction(nameof(Details), new { id });
}
}
// -----------------------------------------------------------------------
// GET: /Invoices/ForJob/5 — redirect to existing or Create
// -----------------------------------------------------------------------
@@ -2191,7 +2364,7 @@ public class InvoicesController : Controller
/// </summary>
private async Task PopulateCreateViewBagAsync(int companyId, string? selectedTerms = null)
{
var customers = await _unitOfWork.Customers.GetAllAsync();
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
ViewBag.Customers = customers.Where(c => c.IsActive).OrderBy(c => c.CompanyName ?? c.ContactLastName).ToList();
// Expose company default tax rate and exempt customer IDs for client-side tax handling
@@ -2994,6 +3167,50 @@ public class InvoicesController : Controller
return false;
}
/// <summary>
/// Inline-edits description, quantity, and unit price on a single invoice line item.
/// Blocked on paid/voided invoices (same gate as the full Edit action).
/// Returns updated totals so the page can reflect the change without a reload.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> PatchItem([FromBody] PatchInvoiceItemRequest request)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
var item = await _unitOfWork.InvoiceItems.GetByIdAsync(request.ItemId);
if (item == null) return NotFound();
var invoice = await _unitOfWork.Invoices.GetByIdAsync(item.InvoiceId);
if (invoice == null || invoice.CompanyId != currentUser.CompanyId) return NotFound();
if (invoice.Status is not (InvoiceStatus.Draft or InvoiceStatus.Sent or InvoiceStatus.Overdue))
return BadRequest(new { error = "Cannot edit items on a paid or voided invoice." });
item.Description = request.Description.Trim();
item.Quantity = request.Quantity;
item.UnitPrice = request.UnitPrice;
item.TotalPrice = Math.Round(request.Quantity * request.UnitPrice, 2);
await _unitOfWork.InvoiceItems.UpdateAsync(item);
var allItems = await _unitOfWork.InvoiceItems.FindAsync(ii => ii.InvoiceId == invoice.Id);
var newSubTotal = allItems.Sum(i => i.TotalPrice);
invoice.SubTotal = newSubTotal;
invoice.TaxAmount = Math.Round(newSubTotal * invoice.TaxPercent / 100m, 2);
invoice.Total = Math.Round(newSubTotal - invoice.DiscountAmount + invoice.TaxAmount, 2);
await _unitOfWork.Invoices.UpdateAsync(invoice);
await _unitOfWork.CompleteAsync();
return Json(new {
lineTotal = item.TotalPrice,
subtotal = invoice.SubTotal,
taxAmount = invoice.TaxAmount,
total = invoice.Total,
balanceDue = invoice.BalanceDue
});
}
/// <summary>
/// Returns logo bytes and content type for PDF generation.
/// Prefers blob-stored logos (LogoFilePath) over the legacy DB column (LogoData).
@@ -3009,3 +3226,11 @@ public class InvoicesController : Controller
return (company.LogoData, company.LogoContentType);
}
}
public class PatchInvoiceItemRequest
{
public int ItemId { get; set; }
public string Description { get; set; } = string.Empty;
public decimal Quantity { get; set; }
public decimal UnitPrice { get; set; }
}
@@ -36,7 +36,9 @@ public class JobTemplatesController : Controller
/// </summary>
public async Task<IActionResult> Index()
{
var templates = await _unitOfWork.JobTemplates.GetAllAsync(
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var templates = await _unitOfWork.JobTemplates.FindAsync(
t => t.CompanyId == companyId,
false,
t => t.Customer,
t => t.Items);
@@ -110,6 +110,11 @@ public class JobsController : Controller
{
try
{
// Default landing view: On Floor — redirect bare /Jobs to ?statusGroup=active
// so completed/cancelled jobs don't clutter the first screen.
if (string.IsNullOrEmpty(statusGroup) && string.IsNullOrEmpty(searchTerm) && string.IsNullOrEmpty(tagFilter))
return RedirectToAction("Index", new { statusGroup = "active" });
// Create and validate grid request
var gridRequest = new GridRequest
{
@@ -141,6 +146,13 @@ public class JobsController : Controller
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled;
}
else if (statusGroup == "completed")
{
filter = j => j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Completed
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered;
}
// "all" or unknown group: no filter applied (show every status)
}
else if (!string.IsNullOrWhiteSpace(searchTerm))
{
@@ -195,6 +207,27 @@ public class JobsController : Controller
gridRequest, jobDtos,
string.IsNullOrWhiteSpace(tagFilter) ? totalCount : jobDtos.Count);
// Pill badge counts — always global (not scoped to current filter/page)
var today = DateTime.Today;
ViewBag.AllJobCount = await _unitOfWork.Jobs.CountAsync();
ViewBag.ActiveCount = await _unitOfWork.Jobs.CountAsync(j =>
j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled);
ViewBag.OverdueCount = await _unitOfWork.Jobs.CountAsync(j =>
j.DueDate < today
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled);
ViewBag.CompletedCount = await _unitOfWork.Jobs.CountAsync(j =>
j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Completed
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered);
ViewBag.ReadyCount = await _unitOfWork.Jobs.CountAsync(j =>
j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup);
// Set ViewBag for sorting
ViewBag.SearchTerm = searchTerm;
ViewBag.StatusGroup = statusGroup;
@@ -477,6 +510,7 @@ public class JobsController : Controller
transferEfficiency = c.TransferEfficiency,
powderCostPerLb = c.PowderCostPerLb,
powderToOrder = c.PowderToOrder,
noExtraLayerCharge = c.NoExtraLayerCharge,
notes = c.Notes
}),
prepServices = ji.PrepServices.Select(ps => new {
@@ -498,6 +532,23 @@ public class JobsController : Controller
.OrderByDescending(t => t.TransactionDate).ToList();
ViewBag.MaterialsUsed = allJobTransactions;
// Inventory items for the manual log-material modal
var inventoryItemsForModal = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == job.CompanyId))
.OrderBy(i => i.Name)
.Select(i => new { i.Id, i.Name, i.Manufacturer, i.UnitOfMeasure, i.QuantityOnHand })
.ToList();
var jsonOpts = new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase };
ViewBag.InventoryItemsForModal = System.Text.Json.JsonSerializer.Serialize(inventoryItemsForModal, jsonOpts);
// IDs of powders already assigned to this job's coats — shown at top of log-material dropdown
var jobPowderIds = (jobDto.Items ?? new List<PowderCoating.Application.DTOs.Job.JobItemDto>())
.SelectMany(i => i.Coats ?? new List<PowderCoating.Application.DTOs.Job.JobItemCoatDto>())
.Where(c => c.InventoryItemId.HasValue)
.Select(c => c.InventoryItemId!.Value)
.Distinct()
.ToList();
ViewBag.JobPowderIds = System.Text.Json.JsonSerializer.Serialize(jobPowderIds, jsonOpts);
// Pre-logged powder grouped by InventoryItemId (for Complete Job modal pre-fill)
ViewBag.PreLoggedPowder = allJobTransactions
.GroupBy(t => t.InventoryItemId)
@@ -511,7 +562,7 @@ public class JobsController : Controller
ViewBag.JobPhotoMax = photoMax;
// Customer list for inline customer-change dropdown
var allCustomers = await _unitOfWork.Customers.GetAllAsync();
var allCustomers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == job.CompanyId);
ViewBag.CustomerSelectList = allCustomers
.Where(c => c.IsActive)
.Select(c => new SelectListItem
@@ -617,7 +668,8 @@ public class JobsController : Controller
if (job == null) return NotFound();
var allStatuses = (await _unitOfWork.JobStatusLookups.GetAllAsync())
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allStatuses = (await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == companyId))
.OrderBy(s => s.DisplayOrder).ToList();
ViewBag.AllStatuses = allStatuses;
@@ -640,7 +692,7 @@ public class JobsController : Controller
if (job == null) return NotFound();
var allStatuses = (await _unitOfWork.JobStatusLookups.GetAllAsync()).ToList();
var allStatuses = (await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == job.CompanyId)).ToList();
var newStatus = allStatuses.FirstOrDefault(s => s.Id == newStatusId);
if (newStatus == null) return BadRequest("Invalid status.");
@@ -828,7 +880,7 @@ public class JobsController : Controller
// Optionally advance status to In Preparation
if (advanceToInPreparation && jobToUpdate.JobStatus.StatusCode != AppConstants.StatusCodes.Job.InPreparation)
{
var allStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
var allStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == jobToUpdate.CompanyId);
var inPrepStatus = allStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.InPreparation);
if (inPrepStatus != null)
{
@@ -885,7 +937,7 @@ public class JobsController : Controller
if (advanceToInPreparation && job.JobStatus.StatusCode != AppConstants.StatusCodes.Job.InPreparation && !job.JobStatus.IsTerminalStatus)
{
var allStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
var allStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == job.CompanyId);
var inPrepStatus = allStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.InPreparation);
if (inPrepStatus != null)
{
@@ -1060,6 +1112,8 @@ public class JobsController : Controller
QuoteId = dto.QuoteId,
AssignedUserId = dto.AssignedUserId,
OvenCostId = dto.OvenCostId,
OvenBatches = dto.OvenBatches > 0 ? dto.OvenBatches : 1,
OvenCycleMinutes = dto.OvenCycleMinutes,
Description = dto.Description,
JobPriorityId = dto.JobPriorityId,
JobStatusId = pendingStatus?.Id ?? 1,
@@ -1131,7 +1185,7 @@ public class JobsController : Controller
var totals = await _pricingService.CalculateQuoteTotalsAsync(
dto.JobItems, companyId, dto.CustomerId,
await GetEffectiveTaxPercentAsync(dto.CustomerId, createCosts?.TaxPercent ?? 0m),
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, createOvenRate, job.OvenBatches, job.OvenCycleMinutes);
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, createOvenRate, dto.OvenBatches > 0 ? dto.OvenBatches : 1, dto.OvenCycleMinutes);
job.FinalPrice = totals.Total;
job.OvenBatchCost = totals.OvenBatchCost;
@@ -1199,6 +1253,9 @@ public class JobsController : Controller
CustomerId = job.CustomerId,
QuoteId = job.QuoteId,
AssignedUserId = job.AssignedUserId,
OvenCostId = job.OvenCostId,
OvenBatches = job.OvenBatches > 0 ? job.OvenBatches : 1,
OvenCycleMinutes = job.OvenCycleMinutes,
Description = job.Description,
JobStatusId = job.JobStatusId,
JobPriorityId = job.JobPriorityId,
@@ -1243,6 +1300,7 @@ public class JobsController : Controller
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = c.PowderToOrder,
NoExtraLayerCharge = c.NoExtraLayerCharge,
Notes = c.Notes
}).ToList(),
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
@@ -1373,6 +1431,9 @@ public class JobsController : Controller
job.CustomerId = dto.CustomerId;
job.QuoteId = dto.QuoteId;
job.Description = dto.Description;
job.OvenCostId = dto.OvenCostId;
job.OvenBatches = dto.OvenBatches > 0 ? dto.OvenBatches : 1;
job.OvenCycleMinutes = dto.OvenCycleMinutes;
await RecordStatusChangeAsync(job, dto.JobStatusId);
job.JobStatusId = dto.JobStatusId;
job.JobPriorityId = dto.JobPriorityId;
@@ -1599,7 +1660,7 @@ public class JobsController : Controller
var totals = await _pricingService.CalculateQuoteTotalsAsync(
dto.JobItems, companyId, dto.CustomerId,
await GetEffectiveTaxPercentAsync(dto.CustomerId, editCosts?.TaxPercent ?? 0m),
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, editOvenRate, job.OvenBatches, job.OvenCycleMinutes);
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, editOvenRate, dto.OvenBatches > 0 ? dto.OvenBatches : 1, dto.OvenCycleMinutes);
job.FinalPrice = totals.Total;
job.OvenBatchCost = totals.OvenBatchCost;
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
@@ -1792,7 +1853,7 @@ public class JobsController : Controller
ViewBag.AiPhotoQuotesEnabled = await _subscriptionService.CanUseAiPhotoQuoteAsync(companyId);
await PopulateDropdowns();
await PopulatePrepServicesAsync();
await PopulatePrepServicesAsync(companyId);
var costs = await _pricingService.GetOperatingCostsAsync(companyId);
await PopulateJobItemDropDownsAsync(companyId, costs?.OvenOperatingCostPerHour ?? 45m);
ViewBag.TaxPercent = costs?.TaxPercent ?? 0m;
@@ -1800,6 +1861,7 @@ public class JobsController : Controller
ViewBag.ComplexityModeratePercent = costs?.ComplexityModeratePercent ?? 5m;
ViewBag.ComplexityComplexPercent = costs?.ComplexityComplexPercent ?? 15m;
ViewBag.ComplexityExtremePercent = costs?.ComplexityExtremePercent ?? 25m;
ViewBag.DefaultOvenCycleMinutes = costs?.DefaultOvenCycleMinutes ?? 45;
var useMetric = await _tenantContext.UseMetricSystemAsync();
ViewBag.UseMetric = useMetric;
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
@@ -1812,7 +1874,9 @@ public class JobsController : Controller
/// </summary>
private async Task PopulateDropdowns()
{
var customers = await _unitOfWork.Customers.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
ViewBag.Customers = new SelectList(
customers.Where(c => c.IsActive).Select(c => new
{
@@ -1823,8 +1887,6 @@ public class JobsController : Controller
}).OrderBy(c => c.DisplayName),
"Id",
"DisplayName");
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var users = await _userManager.Users
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
@@ -2206,13 +2268,13 @@ public class JobsController : Controller
/// Loads all active prep services into ViewBag for the item wizard's prep services step.
/// Prep services are ordered by DisplayOrder so they appear in the intended workflow sequence.
/// </summary>
private async Task PopulatePrepServicesAsync()
private async Task PopulatePrepServicesAsync(int companyId)
{
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive);
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive && ps.CompanyId == companyId);
ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList();
_logger.LogInformation("Populated {Count} active prep services", prepServices.Count());
var blastSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive);
var blastSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive && b.CompanyId == companyId);
ViewBag.BlastSetups = blastSetups.OrderBy(b => b.DisplayOrder)
.Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault })
.ToList();
@@ -2648,78 +2710,80 @@ public class JobsController : Controller
.GroupBy(t => t.InventoryItemId)
.ToDictionary(g => g.Key, g => Math.Abs(g.Sum(t => t.Quantity)));
// Update actual powder usage for each coat
foreach (var coatUsage in dto.CoatUsages)
// Process powder usage submitted per inventory item (color) for the whole job.
// Distribute entered lbs across coats sharing that InventoryItemId proportionally
// by estimated PowderToOrder so per-coat reporting stays meaningful.
// One inventory deduction per powder (net of pre-logged credit).
if (dto.PowderUsages.Any())
{
var jobItemCoat = await _unitOfWork.JobItemCoats.GetByIdAsync(
coatUsage.JobItemCoatId,
false,
jic => jic.InventoryItem);
// Load all coats for the job with their inventory items
var allCoats = (await _unitOfWork.JobItemCoats.FindAsync(
jic => jic.JobItem != null && jic.JobItem.JobId == dto.JobId,
false, jic => jic.InventoryItem, jic => jic.JobItem))
.ToList();
if (jobItemCoat != null)
foreach (var powderUsage in dto.PowderUsages)
{
jobItemCoat.ActualPowderUsedLbs = coatUsage.ActualPowderUsedLbs;
await _unitOfWork.JobItemCoats.UpdateAsync(jobItemCoat);
if (!powderUsage.ActualPowderUsedLbs.HasValue || powderUsage.ActualPowderUsedLbs.Value <= 0)
continue;
_logger.LogInformation("Updated JobItemCoat {CoatId} with {Lbs} lbs actual powder used",
coatUsage.JobItemCoatId, coatUsage.ActualPowderUsedLbs);
var invItemId = powderUsage.InventoryItemId;
var totalActualLbs = powderUsage.ActualPowderUsedLbs.Value;
// Deduct powder from inventory if using stock powder
if (jobItemCoat.InventoryItemId.HasValue &&
coatUsage.ActualPowderUsedLbs.HasValue &&
coatUsage.ActualPowderUsedLbs.Value > 0)
// Distribute across coats using this powder proportionally by estimated lbs
var coatsForPowder = allCoats.Where(c => c.InventoryItemId == invItemId).ToList();
if (coatsForPowder.Any())
{
var invItemId = jobItemCoat.InventoryItemId.Value;
var actualLbs = coatUsage.ActualPowderUsedLbs.Value;
// Apply available pre-logged credit so we don't double-deduct
var credit = preLoggedCredit.GetValueOrDefault(invItemId, 0m);
var deductNow = Math.Max(0m, actualLbs - credit);
// Consume credit (other coats sharing the same powder get whatever remains)
preLoggedCredit[invItemId] = Math.Max(0m, credit - actualLbs);
if (deductNow > 0)
var totalEstimated = coatsForPowder.Sum(c => c.PowderToOrder ?? 0m);
foreach (var coat in coatsForPowder)
{
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(invItemId);
if (inventoryItem != null)
{
var transaction = new InventoryTransaction
{
InventoryItemId = inventoryItem.Id,
TransactionType = InventoryTransactionType.JobUsage,
Quantity = -deductNow,
UnitCost = inventoryItem.UnitCost,
TotalCost = inventoryItem.UnitCost * deductNow,
TransactionDate = DateTime.UtcNow,
JobId = job.Id,
Reference = job.JobNumber,
Notes = $"Powder used for Job {job.JobNumber} - {jobItemCoat.CoatName} ({jobItemCoat.ColorName ?? "N/A"}) by {currentUser!.FirstName} {currentUser.LastName}",
BalanceAfter = inventoryItem.QuantityOnHand - deductNow,
CompanyId = job.CompanyId
};
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
inventoryItem.QuantityOnHand -= deductNow;
await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem);
// GL: DR COGS, CR Inventory Asset (accrual) — no-op if accounts not configured
if (inventoryItem.CogsAccountId.HasValue && inventoryItem.InventoryAccountId.HasValue)
{
var cost = deductNow * (inventoryItem.AverageCost > 0 ? inventoryItem.AverageCost : inventoryItem.UnitCost);
await _accountBalanceService.DebitAsync(inventoryItem.CogsAccountId, cost);
await _accountBalanceService.CreditAsync(inventoryItem.InventoryAccountId, cost);
}
_logger.LogInformation(
"Deducted {Lbs} lbs (net of pre-logged) of {Item} from inventory for Job {JobNumber}. New quantity: {NewQty}",
deductNow, inventoryItem.Name, job.JobNumber, inventoryItem.QuantityOnHand);
}
var share = totalEstimated > 0
? totalActualLbs * ((coat.PowderToOrder ?? 0m) / totalEstimated)
: totalActualLbs / coatsForPowder.Count;
coat.ActualPowderUsedLbs = Math.Round(share, 4);
await _unitOfWork.JobItemCoats.UpdateAsync(coat);
}
else
}
// Single inventory deduction for the whole powder, net of pre-logged credit
var credit = preLoggedCredit.GetValueOrDefault(invItemId, 0m);
var deductNow = Math.Max(0m, totalActualLbs - credit);
preLoggedCredit[invItemId] = 0m;
if (deductNow > 0)
{
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(invItemId);
if (inventoryItem != null)
{
inventoryItem.QuantityOnHand -= deductNow;
await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem);
var transaction = new InventoryTransaction
{
InventoryItemId = inventoryItem.Id,
TransactionType = InventoryTransactionType.JobUsage,
Quantity = -deductNow,
UnitCost = inventoryItem.UnitCost,
TotalCost = inventoryItem.UnitCost * deductNow,
TransactionDate = DateTime.UtcNow,
JobId = job.Id,
Reference = job.JobNumber,
Notes = $"Powder used for Job {job.JobNumber} by {currentUser!.FirstName} {currentUser.LastName}",
BalanceAfter = inventoryItem.QuantityOnHand,
CompanyId = job.CompanyId
};
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
if (inventoryItem.CogsAccountId.HasValue && inventoryItem.InventoryAccountId.HasValue)
{
var cost = deductNow * (inventoryItem.AverageCost > 0 ? inventoryItem.AverageCost : inventoryItem.UnitCost);
await _accountBalanceService.DebitAsync(inventoryItem.CogsAccountId, cost);
await _accountBalanceService.CreditAsync(inventoryItem.InventoryAccountId, cost);
}
_logger.LogInformation(
"Skipped inventory deduction for JobItemCoat {CoatId} — {Lbs} lbs already pre-logged for inventory item {InvItemId}",
coatUsage.JobItemCoatId, actualLbs, invItemId);
"Deducted {Lbs} lbs (net of pre-logged) of {Item} from inventory for Job {JobNumber}. New quantity: {NewQty}",
deductNow, inventoryItem.Name, job.JobNumber, inventoryItem.QuantityOnHand);
}
}
}
@@ -2916,6 +2980,7 @@ public class JobsController : Controller
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = c.PowderToOrder,
NoExtraLayerCharge = c.NoExtraLayerCharge,
Notes = c.Notes
}).ToList(),
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
@@ -3096,7 +3161,8 @@ public class JobsController : Controller
InventoryItemId = c.InventoryItemId,
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb
PowderCostPerLb = c.PowderCostPerLb,
NoExtraLayerCharge = c.NoExtraLayerCharge
}).ToList()
}).ToList();
@@ -3147,7 +3213,7 @@ public class JobsController : Controller
/// </summary>
private async Task PopulateJobItemDropDownsAsync(int companyId, decimal fallbackOvenRate)
{
var inventory = await _unitOfWork.InventoryItems.GetAllAsync(false, i => i.InventoryCategory);
var inventory = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId, false, i => i.InventoryCategory);
ViewBag.InventoryCoatings = inventory
.Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating)
.OrderBy(i => i.IsIncoming ? 1 : 0).ThenBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name)
@@ -3167,12 +3233,12 @@ public class JobsController : Controller
isIncoming = i.IsIncoming
}).ToList();
var vendors = await _unitOfWork.Vendors.GetAllAsync(false);
var vendors = await _unitOfWork.Vendors.FindAsync(s => s.CompanyId == companyId, false);
ViewBag.Vendors = vendors
.Where(s => s.IsActive).OrderBy(s => s.CompanyName)
.Select(s => new { value = s.Id.ToString(), text = s.CompanyName }).ToList();
var catalogItems = await _unitOfWork.CatalogItems.GetAllAsync(false, i => i.Category, i => i.Category.ParentCategory);
var catalogItems = await _unitOfWork.CatalogItems.FindAsync(i => i.CompanyId == companyId, false, i => i.Category, i => i.Category.ParentCategory);
ViewBag.CatalogItems = catalogItems
.Where(i => i.IsActive)
.OrderBy(i => i.Category.DisplayOrder).ThenBy(i => i.DisplayOrder)
@@ -3201,10 +3267,10 @@ public class JobsController : Controller
description = i.Description
}).ToList();
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive);
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive && ps.CompanyId == companyId);
ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList();
var blastSetupsForEditItems = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive);
var blastSetupsForEditItems = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive && b.CompanyId == companyId);
ViewBag.BlastSetups = blastSetupsForEditItems.OrderBy(b => b.DisplayOrder)
.Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault })
.ToList();
@@ -3917,10 +3983,11 @@ public class JobsController : Controller
ovenCost = opCosts.OvenOperatingCostPerHour * defaultOvenCycleHours;
}
// 4. Revenue
decimal revenue = job.Invoice != null
? job.Invoice.Total
: (job.FinalPrice > 0 ? job.FinalPrice : job.QuotedPrice);
// 4. Revenue — prefer FinalPrice (reflects inline edits and job-level changes);
// fall back to Invoice.Total only when FinalPrice is zero (voided/zeroed job).
decimal revenue = job.FinalPrice > 0
? job.FinalPrice
: (job.Invoice?.Total ?? job.QuotedPrice);
// 5. Rework costs from linked rework jobs
var reworkRecords = await _unitOfWork.ReworkRecords.FindAsync(
@@ -3956,7 +4023,7 @@ public class JobsController : Controller
return Json(new {
revenue = Math.Round(revenue, 2),
revenueSource = job.Invoice != null ? "Invoice" : (job.FinalPrice > 0 ? "Final Price" : "Quoted Price"),
revenueSource = job.FinalPrice > 0 ? "Final Price" : (job.Invoice != null ? "Invoice" : "Quoted Price"),
powderCost = Math.Round(powderCost, 2),
laborCost = Math.Round(laborCost, 2),
ovenCost = Math.Round(ovenCost, 2),
@@ -4080,9 +4147,170 @@ public class JobsController : Controller
_logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId);
}
/// <summary>
/// Logs manual material usage from the job details page. Mirrors the QR scan LogUsage
/// flow in InventoryController but returns JSON so the modal can close and refresh inline.
/// Quantity is always the amount USED (caller converts from remaining if needed).
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> LogMaterial([FromBody] LogMaterialRequest req)
{
try
{
if (req.QuantityUsed <= 0)
return Json(new { success = false, message = "Quantity used must be greater than zero." });
var item = await _unitOfWork.InventoryItems.GetByIdAsync(req.InventoryItemId);
if (item == null) return Json(new { success = false, message = "Inventory item not found." });
var job = await _unitOfWork.Jobs.GetByIdAsync(req.JobId);
if (job == null) return Json(new { success = false, message = "Job not found." });
var txnType = req.TransactionType == "Waste"
? InventoryTransactionType.Waste
: InventoryTransactionType.JobUsage;
item.QuantityOnHand -= req.QuantityUsed;
item.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.InventoryItems.UpdateAsync(item);
var txn = new PowderCoating.Core.Entities.InventoryTransaction
{
InventoryItemId = item.Id,
TransactionType = txnType,
Quantity = -req.QuantityUsed,
UnitCost = item.UnitCost,
TotalCost = req.QuantityUsed * item.UnitCost,
TransactionDate = DateTime.UtcNow,
BalanceAfter = item.QuantityOnHand,
JobId = req.JobId,
Reference = $"Job {job.JobNumber}",
Notes = req.Notes?.Trim(),
CompanyId = item.CompanyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.InventoryTransactions.AddAsync(txn);
await _unitOfWork.CompleteAsync();
// GL: DR COGS, CR Inventory Asset
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
{
var cost = req.QuantityUsed * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost);
await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
}
return Json(new
{
success = true,
message = $"Logged {req.QuantityUsed:N2} {item.UnitOfMeasure} of {item.Name}.",
newBalance = item.QuantityOnHand,
unitOfMeasure = item.UnitOfMeasure,
itemName = item.Name
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error logging material for job {JobId}", req.JobId);
return Json(new { success = false, message = "An error occurred. Please try again." });
}
}
/// <summary>
/// Inline-edits description, quantity, and unit price on a single job line item.
/// Adjusts FinalPrice and the stored PricingBreakdownJson snapshot by the price delta.
/// Returns updated totals so the page can reflect the change without a reload.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> PatchItem([FromBody] PatchJobItemRequest request)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
var item = await _unitOfWork.JobItems.GetByIdAsync(request.ItemId);
if (item == null) return NotFound();
var job = await _unitOfWork.Jobs.GetByIdAsync(item.JobId);
if (job == null || job.CompanyId != currentUser.CompanyId) return NotFound();
var oldTotal = item.TotalPrice;
item.Description = request.Description.Trim();
item.Quantity = request.Quantity;
item.UnitPrice = request.UnitPrice;
item.TotalPrice = Math.Round(request.Quantity * request.UnitPrice, 2);
await _unitOfWork.JobItems.UpdateAsync(item);
var delta = item.TotalPrice - oldTotal;
job.FinalPrice = Math.Round(job.FinalPrice + delta, 2);
// Keep the stored pricing snapshot in sync so the breakdown panel stays consistent.
// Case-insensitive options handle JSON stored before PascalCase serialization was enforced.
QuotePricingBreakdownDto? pbFinal = null;
var jsonOpts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
{
var pb = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson, jsonOpts);
if (pb != null)
{
pb.ItemsSubtotal += delta;
pb.SubtotalBeforeDiscount += delta;
pb.SubtotalAfterDiscount = pb.SubtotalBeforeDiscount - pb.DiscountAmount;
pb.TaxAmount = Math.Round(pb.SubtotalAfterDiscount * pb.TaxPercent / 100m, 2);
pb.Total = Math.Round(pb.SubtotalAfterDiscount + pb.RushFee + pb.TaxAmount, 2);
job.FinalPrice = pb.Total;
job.PricingBreakdownJson = JsonSerializer.Serialize(pb);
pbFinal = pb;
}
}
await _unitOfWork.Jobs.UpdateAsync(job);
await _unitOfWork.CompleteAsync();
// For legacy jobs without a stored snapshot, derive breakdown from live item totals.
if (pbFinal == null)
{
var allItems = await _unitOfWork.JobItems.FindAsync(ji => ji.JobId == job.Id && !ji.IsDeleted);
var itemsSubtotal = allItems.Sum(ji => ji.TotalPrice);
var subtotal = itemsSubtotal + job.OvenBatchCost + job.ShopSuppliesAmount;
pbFinal = new QuotePricingBreakdownDto
{
ItemsSubtotal = itemsSubtotal,
SubtotalBeforeDiscount = subtotal,
SubtotalAfterDiscount = subtotal,
Total = job.FinalPrice
};
}
return Json(new {
lineTotal = item.TotalPrice,
finalPrice = job.FinalPrice,
itemsSubtotal = pbFinal.ItemsSubtotal,
subtotalBeforeDiscount = pbFinal.SubtotalBeforeDiscount,
subtotalAfterDiscount = pbFinal.SubtotalAfterDiscount,
taxAmount = pbFinal.TaxAmount
});
}
}
public class DeleteTimeEntryRequest { public int Id { get; set; } }
public class PatchJobItemRequest
{
public int ItemId { get; set; }
public string Description { get; set; } = string.Empty;
public decimal Quantity { get; set; }
public decimal UnitPrice { get; set; }
}
public class LogMaterialRequest
{
public int JobId { get; set; }
public int InventoryItemId { get; set; }
public decimal QuantityUsed { get; set; }
public string TransactionType { get; set; } = "JobUsage";
public string? Notes { get; set; }
}
public class CreateReworkJobRequest { public int ReworkRecordId { get; set; } public string? Notes { get; set; } }
public class UpdateWorkerAssignmentRequest
@@ -90,8 +90,8 @@ public class JobsPriorityController : Controller
.ToList();
// Get priorities and workers for modal options
var priorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId);
var workers = await _userManager.Users
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
@@ -16,15 +16,18 @@ public class MaintenanceController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly ITenantContext _tenantContext;
private readonly ILogger<MaintenanceController> _logger;
public MaintenanceController(
IUnitOfWork unitOfWork,
IMapper mapper,
ITenantContext tenantContext,
ILogger<MaintenanceController> logger)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_tenantContext = tenantContext;
_logger = logger;
}
@@ -740,7 +743,8 @@ public class MaintenanceController : Controller
/// </summary>
private async Task PopulateViewBagAsync(int? selectedEquipmentId = null)
{
var equipment = await _unitOfWork.Equipment.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var equipment = await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId);
ViewBag.EquipmentList = new SelectList(
equipment.Where(e => e.IsActive).OrderBy(e => e.EquipmentName),
"Id",
@@ -179,8 +179,9 @@ public class OvenSchedulerController : Controller
public async Task<IActionResult> Suggest([FromBody] SuggestRequest req)
{
var goal = req?.OptimizationGoal ?? "maximize_throughput";
var suggestCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var equipmentList = (await _unitOfWork.OvenCosts.GetAllAsync())
var equipmentList = (await _unitOfWork.OvenCosts.FindAsync(o => o.CompanyId == suggestCompanyId))
.Where(o => o.IsActive)
.OrderBy(o => o.DisplayOrder).ThenBy(o => o.Label)
.ToList();
@@ -188,10 +189,11 @@ public class OvenSchedulerController : Controller
if (!equipmentList.Any())
return Json(new { success = false, error = "No active ovens found. Add Named Ovens in Settings → Operating Costs." });
var companyCosts = await _unitOfWork.CompanyOperatingCosts.GetAllAsync();
var companyCosts = await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == suggestCompanyId);
var defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45;
var queueJobs = (await _unitOfWork.Jobs.GetAllAsync(
var queueJobs = (await _unitOfWork.Jobs.FindAsync(
j => j.CompanyId == suggestCompanyId,
false,
j => j.Customer,
j => j.JobStatus,
@@ -265,7 +267,8 @@ public class OvenSchedulerController : Controller
if (req?.Batches == null || !req.Batches.Any())
return Json(new { success = false, error = "No batches provided." });
var companyCosts = await _unitOfWork.CompanyOperatingCosts.GetAllAsync();
var acceptCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var companyCosts = await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == acceptCompanyId);
var defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45;
var createdBatches = new List<object>();
@@ -357,7 +360,8 @@ public class OvenSchedulerController : Controller
if (oven == null)
return Json(new { success = false, error = "Oven not found." });
var companyCosts = await _unitOfWork.CompanyOperatingCosts.GetAllAsync();
var createBatchCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var companyCosts = await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == createBatchCompanyId);
var defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45;
var batchNumber = await GenerateBatchNumberAsync();
@@ -651,7 +655,8 @@ public class OvenSchedulerController : Controller
if (inOvenStatus != null)
{
var jobIds = batch.Items.Select(i => i.JobId).Distinct().ToHashSet();
var jobs = (await _unitOfWork.Jobs.GetAllAsync()).Where(j => jobIds.Contains(j.Id));
var startBatchCid = _tenantContext.GetCurrentCompanyId() ?? 0;
var jobs = await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == startBatchCid && jobIds.Contains(j.Id));
foreach (var job in jobs)
job.JobStatusId = inOvenStatus.Id;
}
@@ -75,7 +75,6 @@ public class PaymentController : Controller
CustomerName = customer != null
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
: "Valued Customer",
CustomerEmail = customer?.Email ?? string.Empty,
CompanyName = company!.CompanyName,
BalanceDue = invoice.BalanceDue,
InvoiceTotal = invoice.Total,
@@ -127,8 +126,6 @@ public class PaymentController : Controller
return BadRequest(new { error = "Invalid payment amount." });
var surcharge = CalculateSurcharge(request.Amount, company!);
var customer = await _context.Customers.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == invoice.CustomerId);
var (success, clientSecret, paymentIntentId, stripeError) =
await _stripeConnect.CreatePaymentIntentAsync(
@@ -136,7 +133,6 @@ public class PaymentController : Controller
invoiceTotal: request.Amount,
surchargeAmount: surcharge,
currency: "usd",
customerEmail: customer?.Email ?? string.Empty,
invoiceNumber: invoice.InvoiceNumber,
invoiceId: invoice.Id);
@@ -261,7 +257,6 @@ public class PaymentController : Controller
CustomerName = customer != null
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
: quote.ProspectContactName ?? "Valued Customer",
CustomerEmail = customer?.Email ?? quote.ProspectEmail ?? string.Empty,
CompanyName = company!.CompanyName,
DepositAmount = depositAmount,
QuoteTotal = quote.Total,
@@ -296,7 +291,6 @@ public class PaymentController : Controller
var depositAmount = Math.Round(quote!.Total * (quote.DepositPercent / 100m), 2);
var surcharge = CalculateSurcharge(depositAmount, company!);
var customerEmail = quote.Customer?.Email ?? quote.ProspectEmail ?? string.Empty;
var (success, clientSecret, paymentIntentId, stripeError) =
await _stripeConnect.CreateDepositPaymentIntentAsync(
@@ -304,7 +298,6 @@ public class PaymentController : Controller
depositAmount: depositAmount,
surchargeAmount: surcharge,
currency: "usd",
customerEmail: customerEmail,
quoteNumber: quote.QuoteNumber,
quoteId: quote.Id);
@@ -942,7 +935,6 @@ public class PaymentPageViewModel
public int InvoiceId { get; set; }
public string Token { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
public string CustomerEmail { get; set; } = string.Empty;
public string CompanyName { get; set; } = string.Empty;
public decimal BalanceDue { get; set; }
public decimal InvoiceTotal { get; set; }
@@ -963,7 +955,6 @@ public class DepositPaymentPageViewModel
public int QuoteId { get; set; }
public string Token { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
public string CustomerEmail { get; set; } = string.Empty;
public string CompanyName { get; set; } = string.Empty;
public decimal DepositAmount { get; set; }
public decimal QuoteTotal { get; set; }
@@ -14,12 +14,14 @@ public class PricingTiersController : Controller
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly ILogger<PricingTiersController> _logger;
private readonly ITenantContext _tenantContext;
public PricingTiersController(IUnitOfWork unitOfWork, IMapper mapper, ILogger<PricingTiersController> logger)
public PricingTiersController(IUnitOfWork unitOfWork, IMapper mapper, ILogger<PricingTiersController> logger, ITenantContext tenantContext)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_logger = logger;
_tenantContext = tenantContext;
}
/// <summary>
@@ -27,8 +29,9 @@ public class PricingTiersController : Controller
/// </summary>
public async Task<IActionResult> Index()
{
var tiers = await _unitOfWork.PricingTiers.GetAllAsync();
var customers = await _unitOfWork.Customers.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var tiers = await _unitOfWork.PricingTiers.FindAsync(t => t.CompanyId == companyId);
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
var customerCountByTier = customers
.Where(c => c.PricingTierId.HasValue)
@@ -255,7 +255,7 @@ public class QuotesController : Controller
// Calibration nudge — suppress when named blast setups exist OR legacy CFM is set
var costs = (await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId)).FirstOrDefault();
var hasNamedSetups = (await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive)).Any();
var hasNamedSetups = (await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive && b.CompanyId == companyId)).Any();
ViewBag.QuotingNotCalibrated = costs != null
&& !hasNamedSetups
&& costs.CompressorCfm == 0
@@ -441,7 +441,7 @@ public class QuotesController : Controller
ViewBag.Deposits = quoteDeposits;
// Customer list for inline customer-change dropdown
var allCustomers = await _unitOfWork.Customers.GetAllAsync();
var allCustomers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == quote.CompanyId);
ViewBag.CustomerSelectList = allCustomers
.Where(c => c.IsActive)
.Select(c => new SelectListItem
@@ -2430,7 +2430,7 @@ public class QuotesController : Controller
ViewBag.QuotePhotosEnabled = quotePhotoMax != 0; // 0 = feature disabled for this plan
// Customers
var customers = await _unitOfWork.Customers.GetAllAsync();
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
ViewBag.Customers = customers
.Select(c => new SelectListItem
{
@@ -2471,7 +2471,7 @@ public class QuotesController : Controller
}
// Inventory coatings — include incoming items so they can be quoted while powder is in transit
var inventory = await _unitOfWork.InventoryItems.GetAllAsync(false, i => i.InventoryCategory);
var inventory = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId, false, i => i.InventoryCategory);
ViewBag.InventoryCoatings = inventory
.Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating)
.OrderBy(i => i.IsIncoming ? 1 : 0).ThenBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name)
@@ -2492,13 +2492,13 @@ public class QuotesController : Controller
}).ToList();
// Vendors
var vendors = await _unitOfWork.Vendors.GetAllAsync(false);
var vendors = await _unitOfWork.Vendors.FindAsync(s => s.CompanyId == companyId, false);
ViewBag.Vendors = vendors
.Where(s => s.IsActive).OrderBy(s => s.CompanyName)
.Select(s => new { value = s.Id.ToString(), text = s.CompanyName }).ToList();
// Catalog items
var catalogItems = await _unitOfWork.CatalogItems.GetAllAsync(false, i => i.Category, i => i.Category.ParentCategory);
var catalogItems = await _unitOfWork.CatalogItems.FindAsync(i => i.CompanyId == companyId, false, i => i.Category, i => i.Category.ParentCategory);
ViewBag.CatalogItems = catalogItems
.Where(i => i.IsActive)
.OrderBy(i => i.Category.DisplayOrder).ThenBy(i => i.DisplayOrder)
@@ -2528,11 +2528,11 @@ public class QuotesController : Controller
}).ToList();
// Prep services
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive);
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive && ps.CompanyId == companyId);
ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList();
// Blast setups for wizard dropdown
var blastSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive);
var blastSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive && b.CompanyId == companyId);
ViewBag.BlastSetups = blastSetups.OrderBy(b => b.DisplayOrder)
.Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault })
.ToList();
@@ -2599,7 +2599,8 @@ public class QuotesController : Controller
/// </summary>
private async Task PopulatePricingTiersDropDownAsync()
{
var pricingTiers = await _unitOfWork.PricingTiers.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var pricingTiers = await _unitOfWork.PricingTiers.FindAsync(pt => pt.CompanyId == companyId);
ViewBag.PricingTiers = pricingTiers.OrderBy(pt => pt.TierName)
.Select(pt => new SelectListItem
{
@@ -2825,9 +2826,9 @@ public class QuotesController : Controller
// Do NOT assign fullItems to quote.QuoteItems — quote is a tracked entity and assigning
// no-tracking children (which may share InventoryItem instances) causes EF identity conflicts.
// Get default job statuses and priorities
var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
// Get default job statuses and priorities — scope to quote's company for defense-in-depth
var jobStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == quote.CompanyId);
var jobPriorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == quote.CompanyId);
var approvedStatus = jobStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Approved);
var normalPriority = jobPriorities.FirstOrDefault(p => p.PriorityCode == "NORMAL");
var rushPriority = jobPriorities.FirstOrDefault(p => p.PriorityCode == "RUSH");
@@ -3347,7 +3348,7 @@ public class QuotesController : Controller
CompanyBlastSetup? selectedBlastSetup = null;
if (request.BlastSetupId.HasValue)
{
var setups = await _unitOfWork.BlastSetups.FindAsync(b => b.Id == request.BlastSetupId.Value && b.IsActive);
var setups = await _unitOfWork.BlastSetups.FindAsync(b => b.Id == request.BlastSetupId.Value && b.IsActive && b.CompanyId == companyId);
selectedBlastSetup = setups.FirstOrDefault();
}
@@ -3823,6 +3824,49 @@ public class QuotesController : Controller
}
return (company.LogoData, company.LogoContentType);
}
/// <summary>
/// Inline-edits description, quantity, and unit price on a single quote line item.
/// Adjusts stored quote totals by the price delta so the sidebar stays accurate.
/// Returns updated totals so the page can reflect the change without a reload.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> PatchItem([FromBody] PatchQuoteItemRequest request)
{
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser == null) return Unauthorized();
var item = await _unitOfWork.QuoteItems.GetByIdAsync(request.ItemId);
if (item == null) return NotFound();
var quote = await _unitOfWork.Quotes.GetByIdAsync(item.QuoteId);
if (quote == null || quote.CompanyId != currentUser.CompanyId) return NotFound();
var oldTotal = item.TotalPrice;
item.Description = request.Description.Trim();
item.Quantity = request.Quantity;
item.UnitPrice = request.UnitPrice;
item.TotalPrice = Math.Round(request.Quantity * request.UnitPrice, 2);
await _unitOfWork.QuoteItems.UpdateAsync(item);
// Cascade delta through stored totals without re-running the pricing engine
var delta = item.TotalPrice - oldTotal;
quote.ItemsSubtotal += delta;
quote.SubTotal += delta;
quote.SubtotalAfterDiscount = quote.SubTotal - quote.DiscountAmount;
quote.TaxAmount = Math.Round(quote.SubtotalAfterDiscount * quote.TaxPercent / 100m, 2);
quote.Total = Math.Round(quote.SubtotalAfterDiscount + quote.RushFee + quote.TaxAmount, 2);
await _unitOfWork.Quotes.UpdateAsync(quote);
await _unitOfWork.CompleteAsync();
return Json(new {
lineTotal = item.TotalPrice,
subtotal = quote.SubTotal,
taxAmount = quote.TaxAmount,
total = quote.Total
});
}
}
// Request model for AJAX pricing calculation
@@ -3833,3 +3877,11 @@ public class UpdateQuoteStatusRequest
public int QuoteId { get; set; }
public int StatusId { get; set; }
}
public class PatchQuoteItemRequest
{
public int ItemId { get; set; }
public string Description { get; set; } = string.Empty;
public decimal Quantity { get; set; }
public decimal UnitPrice { get; set; }
}
@@ -44,7 +44,8 @@ public class RecurringTemplatesController : Controller
/// <summary>Lists all recurring templates for the current company, active first then by name.</summary>
public async Task<IActionResult> Index()
{
var templates = await _unitOfWork.RecurringTemplates.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var templates = await _unitOfWork.RecurringTemplates.FindAsync(t => t.CompanyId == companyId);
return View(templates.OrderByDescending(t => t.IsActive).ThenBy(t => t.Name).ToList());
}
@@ -425,11 +426,12 @@ public class RecurringTemplatesController : Controller
/// <summary>Loads dropdowns for vendors, accounts, and payment methods into ViewBag.</summary>
private async Task PopulateDropDownsAsync()
{
var vendors = await _unitOfWork.Vendors.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId);
ViewBag.Vendors = vendors.OrderBy(v => v.CompanyName)
.Select(v => new SelectListItem(v.CompanyName, v.Id.ToString())).ToList();
var accounts = await _unitOfWork.Accounts.GetAllAsync();
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId);
ViewBag.APAccounts = accounts
.Where(a => a.AccountSubType == AccountSubType.AccountsPayable)
.OrderBy(a => a.AccountNumber)
@@ -11,6 +11,7 @@ using PowderCoating.Core.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Shared.Constants;
using PowderCoating.Web.ViewModels.Reports;
using System.Security.Claims;
namespace PowderCoating.Web.Controllers;
@@ -25,8 +26,9 @@ public class ReportsController : Controller
private readonly UserManager<ApplicationUser> _userManager;
private readonly IAccountingAiService _accountingAi;
private readonly IAiUsageLogger _usageLogger;
private readonly ITenantContext _tenantContext;
public ReportsController(IUnitOfWork unitOfWork, ILogger<ReportsController> logger, IFinancialReportService financialReports, IOperationalReportService operationalReports, IPdfService pdfService, UserManager<ApplicationUser> userManager, IAccountingAiService accountingAi, IAiUsageLogger usageLogger)
public ReportsController(IUnitOfWork unitOfWork, ILogger<ReportsController> logger, IFinancialReportService financialReports, IOperationalReportService operationalReports, IPdfService pdfService, UserManager<ApplicationUser> userManager, IAccountingAiService accountingAi, IAiUsageLogger usageLogger, ITenantContext tenantContext)
{
_unitOfWork = unitOfWork;
_logger = logger;
@@ -36,6 +38,7 @@ public class ReportsController : Controller
_userManager = userManager;
_accountingAi = accountingAi;
_usageLogger = usageLogger;
_tenantContext = tenantContext;
}
/// <summary>
@@ -79,27 +82,26 @@ public class ReportsController : Controller
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
"MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Load only necessary data - optimized with filtering and minimal eager loading
// Jobs: Load all jobs (we need various status filters and the collection is needed for job status distribution)
// Note: Date filtering would exclude data needed for jobsByStatus calculation
var jobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority, j => j.AssignedUser)).ToList();
// Load only necessary data — all explicitly scoped to this company
var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority, j => j.AssignedUser)).ToList();
// Quotes: Load all quotes (needed for quote status distribution and conversion funnel)
var quotes = (await _unitOfWork.Quotes.GetAllAsync(false, q => q.Customer, q => q.QuoteStatus)).ToList();
var quotes = (await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId, false, q => q.Customer, q => q.QuoteStatus)).ToList();
// Customers: Load all (needed for active count and customer creation trend across all months)
var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList();
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList();
// Equipment: Load all for status distribution
var equipment = (await _unitOfWork.Equipment.GetAllAsync()).ToList();
var equipment = (await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId)).ToList();
// Inventory: Load all for low stock analysis
var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
// Appointments: Filter to relevant date range at DB level
var appointments = (await _unitOfWork.Appointments.FindAsync(
a => a.ScheduledStartTime >= startDate,
a => a.CompanyId == companyId && a.ScheduledStartTime >= startDate,
false,
a => a.Customer,
a => a.AppointmentType,
@@ -108,7 +110,7 @@ public class ReportsController : Controller
// Users with assigned jobs/appointments will be loaded below when building worker stats
// CatalogItems: Load all for category distribution
var catalogItems = (await _unitOfWork.CatalogItems.GetAllAsync(false, c => c.Category)).ToList();
var catalogItems = (await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == companyId, false, c => c.Category)).ToList();
// === OVERVIEW METRICS ===
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
@@ -382,7 +384,7 @@ public class ReportsController : Controller
.ToDictionary(g => g.Key, g => g.Count());
// === FINANCIAL ANALYTICS ===
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
var totalInvoiced = activeInvoices.Sum(i => i.Total);
@@ -781,7 +783,7 @@ public class ReportsController : Controller
// === POWDER CONSUMPTION VS PURCHASE ===
var allInventoryTransactions = (await _unitOfWork.InventoryTransactions
.GetAllAsync(false, t => t.InventoryItem))
.FindAsync(t => t.CompanyId == companyId, false, t => t.InventoryItem))
.ToList();
var powderConsumptionItems = allInventoryTransactions
@@ -1309,14 +1311,15 @@ public class ReportsController : Controller
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
"MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var jobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).ToList();
var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList();
var quotes = (await _unitOfWork.Quotes.GetAllAsync(false, q => q.QuoteStatus)).ToList();
var equipment = (await _unitOfWork.Equipment.GetAllAsync()).ToList();
var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
var allAppointments = await _unitOfWork.Appointments.GetAllAsync(false, a => a.AppointmentStatus);
var appointments = allAppointments.Where(a => a.ScheduledStartTime >= startDate).ToList();
var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).ToList();
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList();
var quotes = (await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId, false, q => q.QuoteStatus)).ToList();
var equipment = (await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId)).ToList();
var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
var allAppointments = await _unitOfWork.Appointments.FindAsync(a => a.CompanyId == companyId && a.ScheduledStartTime >= startDate, false, a => a.AppointmentStatus);
var appointments = allAppointments.ToList();
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
var activeJobs = jobs.Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
@@ -1384,7 +1387,8 @@ public class ReportsController : Controller
var now = DateTime.UtcNow;
var startDate = now.AddMonths(-months);
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var jobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).ToList();
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
var inRange = completedJobs.Where(j => j.UpdatedAt >= startDate).ToList();
var byMonth = inRange.GroupBy(j => new DateTime(j.UpdatedAt!.Value.Year, j.UpdatedAt.Value.Month, 1)).ToDictionary(g => g.Key, g => g.ToList());
@@ -1430,12 +1434,13 @@ public class ReportsController : Controller
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
"MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var jobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.JobStatus, j => j.JobPriority, j => j.AssignedUser)).ToList();
var equipment = (await _unitOfWork.Equipment.GetAllAsync()).ToList();
var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
var allAppts = await _unitOfWork.Appointments.GetAllAsync(false, a => a.AppointmentType, a => a.AppointmentStatus);
var appointments = allAppts.Where(a => a.ScheduledStartTime >= startDate).ToList();
var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.JobStatus, j => j.JobPriority, j => j.AssignedUser)).ToList();
var equipment = (await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId)).ToList();
var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
var allAppts = await _unitOfWork.Appointments.FindAsync(a => a.CompanyId == companyId && a.ScheduledStartTime >= startDate, false, a => a.AppointmentType, a => a.AppointmentStatus);
var appointments = allAppts.ToList();
var activeJobs = jobs.Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
@@ -1483,10 +1488,11 @@ public class ReportsController : Controller
var now = DateTime.UtcNow;
var startDate = now.AddMonths(-months);
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList();
var quotes = (await _unitOfWork.Quotes.GetAllAsync(false, q => q.QuoteStatus)).ToList();
var catalogItems = (await _unitOfWork.CatalogItems.GetAllAsync(false, c => c.Category)).ToList();
var completedJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority))
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList();
var quotes = (await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId, false, q => q.QuoteStatus)).ToList();
var catalogItems = (await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == companyId, false, c => c.Category)).ToList();
var completedJobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority))
.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
var customersByMonth = customers.Where(c => c.CreatedAt >= startDate).GroupBy(c => new DateTime(c.CreatedAt.Year, c.CreatedAt.Month, 1)).ToDictionary(g => g.Key, g => g.Count());
@@ -1523,7 +1529,8 @@ public class ReportsController : Controller
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
var now = DateTime.UtcNow;
var today = DateTime.Today;
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
var outstandingInvoices = activeInvoices.Where(i => i.Status != InvoiceStatus.Paid && i.Total > i.AmountPaid).ToList();
var overdueInvoices = activeInvoices.Where(i => i.Status != InvoiceStatus.Paid && i.DueDate.HasValue && i.DueDate.Value < today).ToList();
@@ -1574,7 +1581,8 @@ public class ReportsController : Controller
var monthLabels = new List<string>(); var monthlyBillsPaid = new List<decimal>(); var monthlyDirectExpenses = new List<decimal>();
// Also load collected payments for P&L comparison
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Payments)).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Payments)).ToList();
var paymentsByMonth = allInvoices.SelectMany(i => i.Payments.Where(p => !p.IsDeleted)).GroupBy(p => new DateTime(p.PaymentDate.Year, p.PaymentDate.Month, 1)).ToDictionary(g => g.Key, g => g.ToList());
var plRevenue = new List<decimal>(); var plExpenses = new List<decimal>(); var plNet = new List<decimal>();
for (var i = months - 1; i >= 0; i--)
@@ -1609,8 +1617,10 @@ public class ReportsController : Controller
{
var now = DateTime.UtcNow;
var startDate = now.AddMonths(-months);
var powderTransactions = (await _unitOfWork.InventoryTransactions.GetAllAsync(false, t => t.InventoryItem))
.Where(t => t.TransactionType == InventoryTransactionType.JobUsage && t.TransactionDate >= startDate).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var powderTransactions = (await _unitOfWork.InventoryTransactions.FindAsync(
t => t.CompanyId == companyId && t.TransactionType == InventoryTransactionType.JobUsage && t.TransactionDate >= startDate, false, t => t.InventoryItem))
.ToList();
var topColors = powderTransactions.Where(t => t.InventoryItem != null).GroupBy(t => t.InventoryItemId)
.Select(g => new PowderUsageByColorItem { InventoryItemId = g.Key, ColorName = g.First().InventoryItem!.ColorName ?? g.First().InventoryItem.Name, ColorCode = g.First().InventoryItem!.ColorCode, SKU = g.First().InventoryItem!.SKU, Manufacturer = g.First().InventoryItem!.Manufacturer, TotalLbsUsed = g.Sum(t => Math.Abs(t.Quantity)), TotalCost = g.Sum(t => Math.Abs(t.TotalCost)), JobCount = g.Where(t => !string.IsNullOrEmpty(t.Reference)).Select(t => t.Reference).Distinct().Count() })
@@ -1631,7 +1641,8 @@ public class ReportsController : Controller
/// <summary>Sales by Customer report — all active (non-voided) invoices grouped by customer, sorted by total invoiced.</summary>
public async Task<IActionResult> SalesByCustomer(int months = 6)
{
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
var items = activeInvoices.Where(i => i.Customer != null)
.GroupBy(i => new { i.CustomerId, Name = i.Customer!.IsCommercial ? i.Customer.CompanyName : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(), i.Customer.IsCommercial })
@@ -1650,8 +1661,9 @@ public class ReportsController : Controller
{
var now = DateTime.UtcNow;
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList();
var completedJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList();
var completedJobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
var items = customers.Where(c => c.IsActive).Select(c =>
{
var cJobs = completedJobs.Where(j => j.CustomerId == c.Id).ToList();
@@ -1682,7 +1694,8 @@ public class ReportsController : Controller
{
var now = DateTime.UtcNow;
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var completedJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode) && j.CompletedDate.HasValue).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var completedJobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode) && j.CompletedDate.HasValue).ToList();
var allStatusHistory = await _operationalReports.GetAllJobStatusHistoryAsync();
var historyByJob = allStatusHistory.GroupBy(h => h.JobId).ToDictionary(g => g.Key, g => g.OrderBy(h => h.ChangedDate).ToList());
var statusDisplayOrder = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK" };
@@ -1720,7 +1733,8 @@ public class ReportsController : Controller
var now = DateTime.UtcNow;
var today = DateTime.Today;
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
var activeJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var activeJobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
var items = activeJobs.Select(j => new JobStatusAgingItem
{
JobId = j.Id, JobNumber = j.JobNumber, CustomerName = j.Customer?.IsCommercial == true ? j.Customer.CompanyName ?? "Unknown" : $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim(),
@@ -1740,7 +1754,8 @@ public class ReportsController : Controller
{
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
var today = DateTime.Today;
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
var items = allInvoices.Where(i => i.Customer != null && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff && i.Status != InvoiceStatus.Paid)
.Select(i =>
{
@@ -1758,7 +1773,8 @@ public class ReportsController : Controller
/// </summary>
public async Task<IActionResult> PowderConsumption(int months = 6)
{
var allTx = (await _unitOfWork.InventoryTransactions.GetAllAsync(false, t => t.InventoryItem)).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allTx = (await _unitOfWork.InventoryTransactions.FindAsync(t => t.CompanyId == companyId, false, t => t.InventoryItem)).ToList();
var items = allTx.Where(t => t.InventoryItem != null)
.GroupBy(t => new { t.InventoryItemId, t.InventoryItem!.Name, t.InventoryItem.SKU, t.InventoryItem.ColorName, t.InventoryItem.ColorCode, t.InventoryItem.Manufacturer })
.Select(g => new PowderConsumptionItem { InventoryItemId = g.Key.InventoryItemId, ItemName = g.Key.Name, SKU = g.Key.SKU, ColorName = g.Key.ColorName, ColorCode = g.Key.ColorCode, Manufacturer = g.Key.Manufacturer, TotalPurchasedLbs = g.Where(t => t.TransactionType == InventoryTransactionType.Purchase || t.TransactionType == InventoryTransactionType.Initial).Sum(t => t.Quantity), TotalConsumedLbs = g.Where(t => t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste).Sum(t => Math.Abs(t.Quantity)), PurchaseCount = g.Count(t => t.TransactionType == InventoryTransactionType.Purchase), UsageJobCount = g.Where(t => t.TransactionType == InventoryTransactionType.JobUsage && !string.IsNullOrEmpty(t.Reference)).Select(t => t.Reference).Distinct().Count() })
@@ -1776,8 +1792,9 @@ public class ReportsController : Controller
public async Task<IActionResult> InventoryTurnover(int months = 6)
{
var daysInPeriod = months * 30.0;
var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
var allTx = (await _unitOfWork.InventoryTransactions.GetAllAsync(false, t => t.InventoryItem)).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
var allTx = (await _unitOfWork.InventoryTransactions.FindAsync(t => t.CompanyId == companyId, false, t => t.InventoryItem)).ToList();
var items = inventory.Where(i => i.IsActive).Select(i =>
{
var iTx = allTx.Where(t => t.InventoryItemId == i.Id).ToList();
@@ -1835,8 +1852,9 @@ public class ReportsController : Controller
var now = DateTime.UtcNow;
var today = DateTime.Today;
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Load invoices for AR data
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
var outstandingInvoices = activeInvoices.Where(i => i.BalanceDue > 0 && i.Status != InvoiceStatus.Paid).ToList();
@@ -1930,13 +1948,14 @@ public class ReportsController : Controller
var companyName = await GetCompanyNameAsync();
var today = DateTime.Today;
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Open AR invoices
var openInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments))
var openInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments))
.Where(i => i.BalanceDue > 0 && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff && i.Status != InvoiceStatus.Paid)
.ToList();
// Compute avg days to pay per customer from paid invoices
var paidInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Payments))
var paidInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Payments))
.Where(i => i.Status == InvoiceStatus.Paid && i.InvoiceDate != default)
.ToList();
var avgDaysByCustomer = paidInvoices
@@ -2137,7 +2156,8 @@ public class ReportsController : Controller
var companyName = await GetCompanyNameAsync();
var today = DateTime.Today;
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
var activeInvoices = allInvoices.Where(i =>
i.Status != InvoiceStatus.Voided &&
i.Status != InvoiceStatus.WrittenOff).ToList();
@@ -2256,8 +2276,9 @@ public class ReportsController : Controller
var companyName = await GetCompanyNameAsync();
var now = DateTime.UtcNow;
var startOfYear = new DateTime(now.Year, 1, 1);
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments))
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments))
.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff)
.ToList();
@@ -15,11 +15,13 @@ namespace PowderCoating.Web.Controllers;
public class SmsConsentAuditController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ITenantContext _tenantContext;
private readonly ILogger<SmsConsentAuditController> _logger;
public SmsConsentAuditController(IUnitOfWork unitOfWork, ILogger<SmsConsentAuditController> logger)
public SmsConsentAuditController(IUnitOfWork unitOfWork, ITenantContext tenantContext, ILogger<SmsConsentAuditController> logger)
{
_unitOfWork = unitOfWork;
_tenantContext = tenantContext;
_logger = logger;
}
@@ -30,7 +32,8 @@ public class SmsConsentAuditController : Controller
{
try
{
var allCustomers = await _unitOfWork.Customers.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allCustomers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
if (!string.IsNullOrWhiteSpace(search))
{
@@ -98,7 +101,8 @@ public class SmsConsentAuditController : Controller
{
try
{
var customers = (await _unitOfWork.Customers.GetAllAsync())
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId))
.OrderBy(c => c.CompanyName ?? c.ContactLastName ?? c.ContactFirstName)
.ToList();
@@ -32,7 +32,8 @@ public class TaxRatesController : Controller
[HttpGet]
public async Task<IActionResult> Index()
{
var rates = await _unitOfWork.TaxRates.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var rates = await _unitOfWork.TaxRates.FindAsync(r => r.CompanyId == companyId);
return View(rates.OrderBy(r => r.Name).ToList());
}
@@ -87,7 +87,8 @@ public class ToolsController : Controller
[HttpGet]
public async Task<IActionResult> GetImportAccounts()
{
var allAccounts = await _unitOfWork.Accounts.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId);
var revenue = allAccounts
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive)
@@ -123,7 +124,8 @@ public class ToolsController : Controller
/// </summary>
private async Task PopulateImportAccountDropdownsAsync()
{
var allAccounts = await _unitOfWork.Accounts.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId);
var revenueAccounts = allAccounts
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive)
@@ -1102,7 +1104,7 @@ public class ToolsController : Controller
// Validate account IDs belong to this company — stale page load can produce IDs
// that were valid before a data reset but no longer exist.
var validAccountIds = (await _unitOfWork.Accounts.GetAllAsync())
var validAccountIds = (await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value))
.Select(a => a.Id).ToHashSet();
if (revenueAccountId.HasValue && !validAccountIds.Contains(revenueAccountId.Value))
revenueAccountId = null;
@@ -1167,7 +1169,7 @@ public class ToolsController : Controller
// Validate account IDs belong to this company — stale page load can produce IDs
// that were valid before a data reset but no longer exist.
var validAccountIds = (await _unitOfWork.Accounts.GetAllAsync())
var validAccountIds = (await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value))
.Select(a => a.Id).ToHashSet();
if (inventoryAccountId.HasValue && !validAccountIds.Contains(inventoryAccountId.Value))
inventoryAccountId = null;
@@ -1939,7 +1941,7 @@ public class ToolsController : Controller
using (var archive = new System.IO.Compression.ZipArchive(memoryStream, System.IO.Compression.ZipArchiveMode.Create, true))
{
// 1. Customers
var customers = await _unitOfWork.Customers.GetAllAsync();
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId.Value);
var customersCsv = GenerateCustomersCsv(customers);
var customersEntry = archive.CreateEntry($"customers_{timestamp}.csv");
using (var entryStream = customersEntry.Open())
@@ -1949,7 +1951,7 @@ public class ToolsController : Controller
}
// 2. Quotes
var quotes = await _unitOfWork.Quotes.GetAllAsync(false, q => q.Customer, q => q.QuoteStatus);
var quotes = await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId.Value, false, q => q.Customer, q => q.QuoteStatus);
var quotesCsv = GenerateQuotesCsv(quotes);
var quotesEntry = archive.CreateEntry($"quotes_{timestamp}.csv");
using (var entryStream = quotesEntry.Open())
@@ -1959,7 +1961,7 @@ public class ToolsController : Controller
}
// 3. Jobs
var jobs = await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority);
var jobs = await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId.Value, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority);
var jobsCsv = GenerateJobsCsv(jobs);
var jobsEntry = archive.CreateEntry($"jobs_{timestamp}.csv");
using (var entryStream = jobsEntry.Open())
@@ -1969,7 +1971,7 @@ public class ToolsController : Controller
}
// 4. Appointments
var appointments = await _unitOfWork.Appointments.GetAllAsync(false,
var appointments = await _unitOfWork.Appointments.FindAsync(a => a.CompanyId == companyId.Value, false,
a => a.Customer, a => a.AppointmentType, a => a.AppointmentStatus);
var appointmentsCsv = GenerateAppointmentsCsv(appointments);
var appointmentsEntry = archive.CreateEntry($"appointments_{timestamp}.csv");
@@ -1980,9 +1982,9 @@ public class ToolsController : Controller
}
// 5. Catalog
var catalogCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
var catalogCategories = await _unitOfWork.CatalogCategories.FindAsync(cc => cc.CompanyId == companyId.Value);
var catalogCategoryPaths = BuildCategoryPathMap(catalogCategories);
var catalog = await _unitOfWork.CatalogItems.GetAllAsync();
var catalog = await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == companyId.Value);
var catalogCsv = GenerateCatalogCsv(catalog, catalogCategoryPaths);
var catalogEntry = archive.CreateEntry($"catalog_{timestamp}.csv");
using (var entryStream = catalogEntry.Open())
@@ -1992,7 +1994,7 @@ public class ToolsController : Controller
}
// 6. Inventory
var inventory = await _unitOfWork.InventoryItems.GetAllAsync();
var inventory = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId.Value);
var inventoryCsv = GenerateInventoryCsv(inventory);
var inventoryEntry = archive.CreateEntry($"inventory_{timestamp}.csv");
using (var entryStream = inventoryEntry.Open())
@@ -2002,7 +2004,7 @@ public class ToolsController : Controller
}
// 7. Equipment
var equipment = await _unitOfWork.Equipment.GetAllAsync();
var equipment = await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId.Value);
var equipmentCsv = GenerateEquipmentCsv(equipment);
var equipmentEntry = archive.CreateEntry($"equipment_{timestamp}.csv");
using (var entryStream = equipmentEntry.Open())
@@ -2012,7 +2014,7 @@ public class ToolsController : Controller
}
// 8. Maintenance
var maintenance = await _unitOfWork.MaintenanceRecords.GetAllAsync(false, m => m.Equipment);
var maintenance = await _unitOfWork.MaintenanceRecords.FindAsync(m => m.CompanyId == companyId.Value, false, m => m.Equipment);
var maintenanceCsv = GenerateMaintenanceCsv(maintenance);
var maintenanceEntry = archive.CreateEntry($"maintenance_{timestamp}.csv");
using (var entryStream = maintenanceEntry.Open())
@@ -2022,7 +2024,7 @@ public class ToolsController : Controller
}
// 9. Vendors
var vendors = await _unitOfWork.Vendors.GetAllAsync();
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId.Value);
var vendorsCsv = GenerateVendorsCsv(vendors);
var vendorsEntry = archive.CreateEntry($"vendors_{timestamp}.csv");
using (var entryStream = vendorsEntry.Open())
@@ -2032,7 +2034,7 @@ public class ToolsController : Controller
}
// 10. Prep Services
var prepServices = await _unitOfWork.PrepServices.GetAllAsync();
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.CompanyId == companyId.Value);
var prepServicesCsv = GeneratePrepServicesCsv(prepServices);
var prepServicesEntry = archive.CreateEntry($"prep_services_{timestamp}.csv");
using (var entryStream = prepServicesEntry.Open())
@@ -2042,7 +2044,7 @@ public class ToolsController : Controller
}
// 11. Invoices
var invoices = await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Job);
var invoices = await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId.Value, false, i => i.Customer, i => i.Job);
var invoicesCsv = GenerateInvoicesCsv(invoices);
var invoicesEntry = archive.CreateEntry($"invoices_{timestamp}.csv");
using (var entryStream = invoicesEntry.Open())
@@ -2052,7 +2054,7 @@ public class ToolsController : Controller
}
// 12. Chart of Accounts
var accounts = await _unitOfWork.Accounts.GetAllAsync();
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value);
var accountsCsv = GenerateChartOfAccountsCsv(accounts);
var accountsEntry = archive.CreateEntry($"chart_of_accounts_{timestamp}.csv");
using (var entryStream = accountsEntry.Open())
@@ -2062,7 +2064,7 @@ public class ToolsController : Controller
}
// 13. Expenses
var expenses = await _unitOfWork.Expenses.GetAllAsync(false, e => e.ExpenseAccount, e => e.PaymentAccount, e => e.Vendor, e => e.Job);
var expenses = await _unitOfWork.Expenses.FindAsync(e => e.CompanyId == companyId.Value, false, e => e.ExpenseAccount, e => e.PaymentAccount, e => e.Vendor, e => e.Job);
var expensesCsv = GenerateExpensesCsv(expenses);
var expensesEntry = archive.CreateEntry($"expenses_{timestamp}.csv");
using (var entryStream = expensesEntry.Open())
@@ -2072,7 +2074,7 @@ public class ToolsController : Controller
}
// 14. Payments
var payments = await _unitOfWork.Payments.GetAllAsync(false, p => p.Invoice);
var payments = await _unitOfWork.Payments.FindAsync(p => p.CompanyId == companyId.Value, false, p => p.Invoice);
var paymentsCsv = GeneratePaymentsCsv(payments);
var paymentsEntry = archive.CreateEntry($"payments_{timestamp}.csv");
using (var entryStream = paymentsEntry.Open())
@@ -2258,9 +2260,9 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index));
}
var catalogCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
var catalogCategories = await _unitOfWork.CatalogCategories.FindAsync(cc => cc.CompanyId == companyId.Value);
var catalogCategoryPaths = BuildCategoryPathMap(catalogCategories);
var catalogItems = await _unitOfWork.CatalogItems.GetAllAsync();
var catalogItems = await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == companyId.Value);
var csv = GenerateCatalogCsv(catalogItems, catalogCategoryPaths);
var fileName = $"catalog_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2326,7 +2328,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index));
}
var equipment = await _unitOfWork.Equipment.GetAllAsync();
var equipment = await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId.Value);
var csv = GenerateEquipmentCsv(equipment);
var fileName = $"equipment_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2407,13 +2409,13 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index));
}
// Load all lookup tables
var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
var quoteStatuses = await _unitOfWork.QuoteStatusLookups.GetAllAsync();
var inventoryCategories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
var appointmentStatuses = await _unitOfWork.AppointmentStatusLookups.GetAllAsync();
var appointmentTypes = await _unitOfWork.AppointmentTypeLookups.GetAllAsync();
// Load all lookup tables — scoped to this company
var jobStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == companyId.Value);
var jobPriorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId.Value);
var quoteStatuses = await _unitOfWork.QuoteStatusLookups.FindAsync(s => s.CompanyId == companyId.Value);
var inventoryCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId.Value);
var appointmentStatuses = await _unitOfWork.AppointmentStatusLookups.FindAsync(s => s.CompanyId == companyId.Value);
var appointmentTypes = await _unitOfWork.AppointmentTypeLookups.FindAsync(t => t.CompanyId == companyId.Value);
var csv = GenerateCompanySettingsCsv(company, jobStatuses, jobPriorities,
quoteStatuses, inventoryCategories, appointmentStatuses, appointmentTypes);
@@ -4092,7 +4094,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index));
}
var prepServices = await _unitOfWork.PrepServices.GetAllAsync();
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.CompanyId == companyId.Value);
var csv = GeneratePrepServicesCsv(prepServices);
var fileName = $"prep_services_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -4124,7 +4126,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index));
}
var vendors = await _unitOfWork.Vendors.GetAllAsync();
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId.Value);
var csv = GenerateVendorsCsv(vendors);
var fileName = $"vendors_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -4156,7 +4158,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index));
}
var accounts = await _unitOfWork.Accounts.GetAllAsync();
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value);
var csv = GenerateChartOfAccountsCsv(accounts);
var fileName = $"chart_of_accounts_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -215,6 +215,8 @@ public static class HelpKnowledgeBase
**Per-item cost breakdown:** On the Quote Details page, each line item shows a collapsible cost breakdown click the row to expand it and see how material, labor, equipment, complexity, and markup were calculated for that specific item. This is useful for spotting which items are underpriced or where costs are concentrated.
**Inline item editing on quotes:** On the Quote Details page, any unit price, quantity, or item description can be edited in-place by clicking the value directly. Press Enter or click away to save; press Escape to cancel. The pricing summary (subtotal, discount, tax, and total) updates immediately without reloading the page.
**Pricing Mode (Markup vs Margin):** In Company Settings Operating Costs you can choose between two pricing modes:
- *Markup on Materials* (default) the General Markup % is applied as a markup on top of calculated costs: `price = cost × (1 + markup%)`. A 25% markup on a $100 cost = $125.
- *Target Margin on Total Cost* the markup % is treated as a target gross margin: `price = cost ÷ (1 margin%)`. A 25% margin on a $100 cost = $133.33. The difference grows at higher percentages.
@@ -265,12 +267,15 @@ public static class HelpKnowledgeBase
**Job Priorities (color-coded):**
- Low (grey), Normal (blue), High (orange), Urgent (red), Rush (purple)
**Jobs list default view:** The Jobs list opens on the **On Floor** filter by default showing only active jobs (excludes Completed, Ready for Pickup, Delivered, Cancelled). Use the filter pills at the top to switch views: **All** shows every job regardless of status; **On Floor** shows active work; **Overdue** shows past-due active jobs; **Ready** shows jobs awaiting customer pickup; **Completed** shows all finished jobs (Completed + Ready for Pickup + Delivered). Each pill shows a live global count.
**How to create a job:**
1. Go to [Jobs](/Jobs) "New Job"
2. Select customer
3. Add line items (same wizard as quotes: Calculated, Custom Work, or AI Photo)
4. Set priority, due date, assigned worker, special instructions
5. Save
5. Optionally set Oven & Batch Settings select a named oven, number of batches, and cycle time. These affect the oven cost in pricing.
6. Save
**Job Priority Board:** [/JobsPriority](/JobsPriority) Kanban-style view of all active jobs sorted by priority and status.
@@ -296,13 +301,30 @@ public static class HelpKnowledgeBase
**Time Entries:** Track labor time on a job from the Details page.
**Rework:** If a job needs to be redone, a rework record can be created from the Details page. Tracks rework type, reason, and resolution.
**Rework / Redo:** Rework and redo mean the same thing in this system. If a finished part fails QA or a customer brings it back damaged, open the original job's Details page and use the **Rework Log** section to record it.
Each rework entry captures: the type (internal defect, customer damage, warranty), the reason (adhesion failure, color mismatch, runs/sags, insufficient coverage, etc.), a defect description, who discovered it, and pricing responsibility.
**Pricing responsibility options:**
- **Shop Fault no charge:** All rework job item prices are set to $0. Use this when the defect is the shop's fault.
- **Customer responsible reduced rate:** Item prices are copied from the original job. Edit them down after the rework job is created.
- **Customer responsible full price:** Item prices are copied from the original job as-is.
**Creating a rework job:** Toggle "Parts are back — create a Rework Job" in the log form. Select which items need to be redone and choose the pricing responsibility. The system creates a new job with a sub-number (e.g., JOB-2605-0001-R1), copies the selected items with their coats and prep services, and auto-records intake (parts are already on hand). The rework job description shows the defect type, reason, and pricing at the top of the job where it is easy to see.
**Rework job completion:** When the rework job reaches a terminal status (Completed, Delivered, etc.), the linked rework record on the original job is automatically marked as **Resolved**. If the rework job is Cancelled instead, the record is marked **Written Off**. No manual follow-up on the original job is needed.
**Job Templates:** [/JobTemplates](/JobTemplates) Save a job's items as a template to reuse for common work types. When creating a new job, select a template to pre-fill items.
**Changing the customer on a job:** On the Job Details page, the Customer field is an always-visible dropdown. Select a different customer a confirmation banner appears. Click **Save** to apply or **Cancel** to revert. Use this to correct a misassigned job or to move a walk-in job to a customer's proper record after they've been added to the system.
**Creating an invoice from a job:** On the Job Details page, look for the Invoice section and click "Create Invoice."
**Inline item price editing:** On the Job Details page, any unit price, quantity, or item description can be edited in-place without opening the full edit form. Click the value it becomes an input field. Type the new value, then press Enter or click away to save (Escape cancels). The pricing summary card (Items Subtotal, Subtotal, Tax, and Total) and the Job Costing card both update immediately without a page reload.
**Job Costing revenue:** The Job Costing card uses the job's Final Price as the revenue figure not the linked invoice total. This means inline price edits are reflected in the profit margin estimate immediately, even before an invoice exists.
**Completing a job:** When a job is ready to mark complete, click the **Complete Job** button on the Job Details page. A modal appears asking you to confirm the completion date, actual hours spent, and final price. If the job used powder from inventory, you will be asked to enter the actual lbs used the modal groups all coats by unique powder color (not per coat or per item) so you fill in one quantity per powder. The system deducts the entered amounts from inventory, crediting any quantities already logged via QR scan. Once confirmed, the job advances to Completed status, and you are prompted to create the invoice if one does not exist.
**Creating an invoice from a job:** On the Job Details page, look for the Invoice section and click "Create Invoice." The system pre-fills all line items, pricing, discount, tax rate, payment terms, and due date from the job and customer automatically. Line item descriptions include the coat color(s) for each item (e.g., "Color1 / Color2" if multiple coats), helping customers distinguish repeated items on the invoice. Review the Totals panel on the right if a discount was applied to the job it will show as a red "Discount Applied" line. Adjust anything you need, then save.
**Work Order QR Codes:** Every printed job work order includes two tiers of QR codes one for viewing the job, and a separate set for taking action on it. All QR codes require the worker to be logged in.
@@ -314,6 +336,8 @@ public static class HelpKnowledgeBase
All QR codes require login workers must have an active account. Logging in once on their phone is sufficient for the session.
**Logging material usage from a PC (without QR scan):** On the Job Details page, expand the Materials Used section and click **Log Material**. A modal opens where you can: select any inventory item from a dropdown (current stock level shown), choose whether to enter the amount used or the amount remaining (the system calculates usage automatically), pick a reason (Job Usage or Waste/Spillage), and add optional notes. Saves immediately and updates inventory on hand.
**Blank Work Order:** Print a pre-formatted paper work order to hand to a walk-in customer before creating a digital job record.
- Access: Jobs list page printer icon button "Blank Work Order" in the top-right toolbar. Or navigate directly to /WorkOrder/Blank.
- The PDF opens in a new tab ready to print. It includes: company logo and address, Drop Off Date field, Client Name / Client Phone / Due Date fields, 12-row parts table (Part Description / Color / Quote), Notes box, customizable Terms & Conditions text, and a Customer Signature line.
@@ -350,6 +374,8 @@ public static class HelpKnowledgeBase
**Payment methods:** Cash, Check, Credit/Debit Card, Bank Transfer (ACH), Digital Payment, Store Credit
**Inline item editing on invoices:** On the Invoice Details page, unit prices, quantities, and item descriptions can be edited in-place while the invoice is in Draft status. Click the value it becomes an input field. Press Enter or click away to save; press Escape to cancel. The invoice total updates immediately. Line items are locked once the invoice is Sent.
**Sending an invoice:** Invoice Details "Send" emails PDF to customer.
**Online Payments:** [/Invoices/OnlinePayments](/Invoices/OnlinePayments) Lists invoices with a shareable payment link the customer can pay without logging in. Requires Stripe Connect to be set up first (see below).
@@ -447,6 +473,13 @@ public static class HelpKnowledgeBase
- Every scan log is recorded as a JobUsage or Adjustment InventoryTransaction and immediately reduces QuantityOnHand; visible in Inventory Activity ledger
- First-time scan on a new device requires login; browser caches the session after that
**Location / Bin filtering and printing:**
- Every inventory item has an optional **Location** field (e.g. "Shelf A", "Bin 3") set on the Create/Edit form.
- Once any item has a location, a **Location dropdown** appears in the Inventory list filter bar (next to Category). Selecting a location filters the list to only items stored there. Can be combined with keyword search and category filter simultaneously.
- Each location badge in the table is a clickable link that instantly filters to that bin.
- With a location filter active, a **Print Bin** button appears in the filter banner. Clicking it opens a printer-ready page (new tab) at /Inventory/PrintBin?location=... listing all items in that bin with line number, name, color, and SKU. No site chrome prints cleanly on a standard sheet.
- The Location dropdown only appears when at least one item has a location value set. If a user doesn't see it, they need to fill in the Location field on at least one inventory item.
**Catalog Lookup & Label Scanner (when adding/editing inventory items):**
- When creating or editing an inventory item, click the **Lookup** button next to the SKU/Part Number field to search a built-in platform catalog of thousands of Prismatic Powders and other manufacturer SKUs. Select a match to auto-fill name, manufacturer, color code, finish, coverage rate, SDS/TDS links, and cure specs.
- The catalog only shows products not already in the company's inventory (prevents duplicates). When editing, the item's own catalog entry is always shown.
+62 -1
View File
@@ -180,6 +180,18 @@ builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
.AddDefaultUI()
.AddClaimsPrincipalFactory<ApplicationUserClaimsPrincipalFactory>();
// Configure the auth cookie to survive mobile browser suspensions (iOS Safari clears session
// cookies when it suspends a tab). Max-Age on the cookie itself makes it persistent regardless
// of whether the user checked "Remember me". SlidingExpiration renews the window on each request.
builder.Services.ConfigureApplicationCookie(options =>
{
options.ExpireTimeSpan = TimeSpan.FromDays(30);
options.SlidingExpiration = true;
options.Cookie.MaxAge = TimeSpan.FromDays(30);
options.Cookie.IsEssential = true;
options.Cookie.SameSite = SameSiteMode.Lax;
});
// Register HttpContextAccessor for multi-tenancy
builder.Services.AddHttpContextAccessor();
@@ -240,6 +252,7 @@ builder.Services.AddHostedService<AuditLogRetentionBackgroundService>();
builder.Services.AddHostedService<StripeWebhookRetentionBackgroundService>();
builder.Services.AddHostedService<SetupWizardReminderBackgroundService>();
builder.Services.AddHostedService<RecurringTransactionService>();
builder.Services.AddHostedService<AppointmentReminderBackgroundService>();
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
builder.Services.AddScoped<IStripeService, StripeService>();
builder.Services.AddScoped<IStripeConnectService, StripeConnectService>();
@@ -529,6 +542,49 @@ builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
// Return a proper HTML body on 429 so mobile browsers (especially iOS Safari) don't try to
// download the empty response as a file. Without this, Safari shows "Login / data Zero KB".
options.OnRejected = async (context, ct) =>
{
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
var isAjax = context.HttpContext.Request.Headers.XRequestedWith == "XMLHttpRequest"
|| (context.HttpContext.Request.Headers.Accept.ToString().Contains("application/json"));
if (isAjax)
{
context.HttpContext.Response.ContentType = "application/json; charset=utf-8";
await context.HttpContext.Response.WriteAsync(
"""{"success":false,"error":"Too many requests. Please wait a moment and try again."}""", ct);
}
else
{
context.HttpContext.Response.ContentType = "text/html; charset=utf-8";
await context.HttpContext.Response.WriteAsync("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Too Many Requests &mdash; Powder Coating Logix</title>
<style>
body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f5f5f5}
.card{background:#fff;border-radius:8px;padding:2rem;max-width:420px;text-align:center;box-shadow:0 2px 8px rgba(0,0,0,.1)}
h2{margin-top:0;color:#333}p{color:#666}a{color:#0d6efd}
</style>
</head>
<body>
<div class="card">
<h2>Too Many Requests</h2>
<p>You've made too many login attempts in a short period. Please wait a minute and try again.</p>
<a href="/Identity/Account/Login">Back to Login</a>
</div>
</body>
</html>
""", ct);
}
};
// login / password-reset — 10 per minute per IP
options.AddPolicy(AppConstants.RateLimitPolicies.Auth, ctx =>
RateLimitPartition.GetSlidingWindowLimiter(
@@ -633,7 +689,7 @@ app.Use(async (context, next) =>
: "'self' 'unsafe-inline' https://cdn.jsdelivr.net https://code.jquery.com https://js.stripe.com";
var cspConnectSrc = app.Environment.IsDevelopment()
? "'self' wss://localhost:* https://cdn.jsdelivr.net https://api.stripe.com" // Allow hot reload WebSocket in dev
? "'self' ws://localhost:* wss://localhost:* https://cdn.jsdelivr.net https://api.stripe.com" // Allow hot reload WebSocket in dev (ws:// for browser-refresh, wss:// for SignalR)
: "'self' https://cdn.jsdelivr.net https://api.stripe.com";
context.Response.Headers.Append("Content-Security-Policy",
@@ -652,6 +708,11 @@ app.Use(async (context, next) =>
context.Response.Headers.Append("Permissions-Policy",
"geolocation=(), microphone=(), camera=()");
// Prevent browsers from caching authenticated pages — avoids stale data and
// browser-specific cache corruption bugs (e.g. Firefox caching a partial load).
if (context.User.Identity?.IsAuthenticated == true)
context.Response.Headers.Append("Cache-Control", "no-store");
await next();
});
@@ -72,14 +72,14 @@
<input class="form-check-input" type="radio" name="format" value="xlsx" id="fmt_xlsx" checked />
<label class="form-check-label" for="fmt_xlsx">
<i class="bi bi-file-earmark-spreadsheet me-1 text-success"></i>
Excel (.xlsx) all sheets in one file
Excel (.xlsx) &mdash; all sheets in one file
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="format" value="csv" id="fmt_csv" />
<label class="form-check-label" for="fmt_csv">
<i class="bi bi-file-zip me-1 text-warning"></i>
CSV (.zip) one file per sheet
CSV (.zip) &mdash; one file per sheet
</label>
</div>
</div>
@@ -111,7 +111,7 @@
document.getElementById('exportForm').addEventListener('submit', function () {
var btn = document.getElementById('exportBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Generating';
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Generating&hellip;';
// Re-enable after 10s in case browser blocks the download dialog
setTimeout(function () {
btn.disabled = false;
@@ -49,7 +49,7 @@
<div class="mt-1 text-success" style="font-size:1.6rem;"><i class="bi bi-building"></i></div>
<div>
<div class="fw-semibold">QuickBooks Desktop</div>
<div class="text-muted small">IIF files import directly via File &gt; Utilities &gt; Import</div>
<div class="text-muted small">IIF files &mdash; import directly via File &gt; Utilities &gt; Import</div>
<div class="mt-2">
<span class="badge bg-light text-dark border me-1">customers.iif</span>
<span class="badge bg-light text-dark border me-1">invoices_payments.iif</span>
@@ -146,7 +146,7 @@
});
}
// Init mark CSV as selected by default
// Init &mdash; mark CSV as selected by default
document.getElementById('fmt-csv').checked = true;
updateFormatCards();
@@ -154,7 +154,7 @@
document.getElementById('exportForm').addEventListener('submit', function() {
const btn = document.getElementById('exportBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Generating';
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Generating&hellip;';
setTimeout(function() { btn.disabled = false; btn.innerHTML = '<i class="bi bi-download me-2"></i>Download Export Package'; }, 8000);
});
@@ -1,11 +1,11 @@
@model PowderCoating.Application.DTOs.Accounting.CreateAccountDto
@model PowderCoating.Application.DTOs.Accounting.CreateAccountDto
@using PowderCoating.Core.Enums
@{
ViewData["Title"] = "New Account";
ViewData["PageIcon"] = "bi-journal-plus";
ViewData["PageHelpTitle"] = "New Account";
ViewData["PageHelpContent"] = "Add a custom account to the Chart of Accounts. Select a Sub-Type first — it auto-sets the Account Type. Use conventional numbering: 1000s = Assets, 2000s = Liabilities, 3000s = Equity, 4000s = Revenue, 5000s = Cost of Goods, 6000s+ = Expenses.";
ViewData["PageHelpContent"] = "Add a custom account to the Chart of Accounts. Select a Sub-Type first &mdash; it auto-sets the Account Type. Use conventional numbering: 1000s = Assets, 2000s = Liabilities, 3000s = Equity, 4000s = Revenue, 5000s = Cost of Goods, 6000s+ = Expenses.";
bool isInline = ViewBag.Inline == true;
}
@@ -31,7 +31,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Account Number"
data-bs-content="A numeric code for sorting and organizing accounts. Convention: 1000–1999 Assets, 2000–2999 Liabilities, 3000–3999 Equity, 4000–4999 Revenue, 5000–5999 Cost of Goods, 6000–9999 Expenses. Must be unique. Sub-accounts can use decimals (e.g. 6100.1).">
data-bs-content="A numeric code for sorting and organizing accounts. Convention: 1000&ndash;1999 Assets, 2000&ndash;2999 Liabilities, 3000&ndash;3999 Equity, 4000&ndash;4999 Revenue, 5000&ndash;5999 Cost of Goods, 6000&ndash;9999 Expenses. Must be unique. Sub-accounts can use decimals (e.g. 6100.1).">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -55,7 +55,7 @@
</a>
</div>
<select asp-for="AccountType" asp-items="ViewBag.AccountTypes" class="form-select" id="accountTypeSelect">
<option value="">— Select Type —</option>
<option value="">&mdash; Select Type &mdash;</option>
</select>
<span asp-validation-for="AccountType" class="text-danger small"></span>
</div>
@@ -70,7 +70,7 @@
</a>
</div>
<select asp-for="AccountSubType" asp-items="ViewBag.AccountSubTypes" class="form-select" id="accountSubTypeSelect">
<option value="">— Select Sub-Type —</option>
<option value="">&mdash; Select Sub-Type &mdash;</option>
</select>
<span asp-validation-for="AccountSubType" class="text-danger small"></span>
<div class="form-text text-primary" id="typeAutoSetHint" style="display:none">
@@ -89,12 +89,12 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Parent Account"
data-bs-content="Nest this account under a parent to create a hierarchy — e.g. 'Powder Costs' under 'Cost of Goods Sold'. Sub-accounts roll up into their parent on financial reports. Most accounts work fine without a parent.">
data-bs-content="Nest this account under a parent to create a hierarchy &mdash; e.g. 'Powder Costs' under 'Cost of Goods Sold'. Sub-accounts roll up into their parent on financial reports. Most accounts work fine without a parent.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<select asp-for="ParentAccountId" asp-items="ViewBag.ParentAccounts" class="form-select">
<option value="">— None (top-level account) —</option>
<option value="">&mdash; None (top-level account) &mdash;</option>
</select>
</div>
@@ -152,7 +152,7 @@
<script>
(function () {
// SubType enum values ↠AccountType enum values (mirrors server-side mapping)
// SubType enum values â†' AccountType enum values (mirrors server-side mapping)
const subTypeToAccountType = {
8: 1, 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, // Assets
10: 2, 11: 2, 12: 2, 13: 2, // Liabilities
@@ -1,10 +1,10 @@
@model PowderCoating.Application.DTOs.Accounting.EditAccountDto
@model PowderCoating.Application.DTOs.Accounting.EditAccountDto
@{
ViewData["Title"] = "Edit Account";
ViewData["PageIcon"] = "bi-pencil-square";
ViewData["PageHelpTitle"] = "Edit Account";
ViewData["PageHelpContent"] = "You can change number, name, type, sub-type, parent, and opening balance. Changing the account type or sub-type on an account that already has transactions is allowed but use caution — it changes how balances are reported going forward. Inactive accounts are hidden from pickers but preserved in history.";
ViewData["PageHelpContent"] = "You can change number, name, type, sub-type, parent, and opening balance. Changing the account type or sub-type on an account that already has transactions is allowed but use caution &mdash; it changes how balances are reported going forward. Inactive accounts are hidden from pickers but preserved in history.";
}
<div class="d-flex justify-content-start mb-4">
@@ -27,7 +27,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Account Number"
data-bs-content="A numeric code for sorting and organizing accounts. Convention: 1000–1999 Assets, 2000–2999 Liabilities, 3000–3999 Equity, 4000–4999 Revenue, 5000–5999 Cost of Goods, 6000–9999 Expenses. Must be unique.">
data-bs-content="A numeric code for sorting and organizing accounts. Convention: 1000&ndash;1999 Assets, 2000&ndash;2999 Liabilities, 3000&ndash;3999 Equity, 4000&ndash;4999 Revenue, 5000&ndash;5999 Cost of Goods, 6000&ndash;9999 Expenses. Must be unique.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -81,12 +81,12 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Parent Account"
data-bs-content="Nest this account under a parent to create a hierarchy — e.g. 'Powder Costs' under 'Cost of Goods Sold'. Sub-accounts roll up into their parent on financial reports. Most accounts work fine without a parent.">
data-bs-content="Nest this account under a parent to create a hierarchy &mdash; e.g. 'Powder Costs' under 'Cost of Goods Sold'. Sub-accounts roll up into their parent on financial reports. Most accounts work fine without a parent.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<select asp-for="ParentAccountId" asp-items="ViewBag.ParentAccounts" class="form-select">
<option value="">— None —</option>
<option value="">&mdash; None &mdash;</option>
</select>
</div>
@@ -59,7 +59,7 @@
</div>
</div>
@* Bootstrap toast confirmation before recalculating balances *@
@* Bootstrap toast &mdash; confirmation before recalculating balances *@
<div class="toast-container position-fixed top-50 start-50 translate-middle p-3" style="z-index:1100">
<div id="recalcConfirmToast" class="toast align-items-center border-0 bg-dark text-white" role="alert" aria-atomic="true" data-bs-autohide="false">
<div class="toast-body d-flex flex-column gap-2 py-3 px-3">
@@ -153,7 +153,7 @@
<span class="fw-medium">@acct.Name</span>
@if (acct.IsSystem)
{
<span class="badge bg-secondary ms-1" title="System account cannot be deleted">sys</span>
<span class="badge bg-secondary ms-1" title="System account &mdash; cannot be deleted">sys</span>
}
</td>
<td><span class="text-muted small">@acct.AccountSubType.ToDisplayName()</span></td>
@@ -186,7 +186,7 @@
@if (!acct.IsSystem)
{
<form asp-action="Delete" asp-route-id="@acct.Id" method="post" class="d-inline"
onsubmit="return confirm('Delete account @acct.AccountNumber @acct.Name?')">
onsubmit="return confirm('Delete account @acct.AccountNumber &ndash; @acct.Name?')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete">
<i class="bi bi-trash"></i>
@@ -219,7 +219,7 @@
});
});
// Recalculate Balances show confirmation toast instead of native confirm()
// Recalculate Balances &mdash; show confirmation toast instead of native confirm()
const recalcToast = new bootstrap.Toast(document.getElementById('recalcConfirmToast'));
document.getElementById('btnRecalcBalances').addEventListener('click', () => {
@@ -2,7 +2,7 @@
@using PowderCoating.Core.Enums
@{
ViewData["Title"] = $"Ledger {Model.AccountNumber} {Model.Name}";
ViewData["Title"] = $"Ledger &mdash; {Model.AccountNumber} {Model.Name}";
ViewData["PageIcon"] = "bi-journal-text";
ViewData["PageHelpTitle"] = "Account Ledger";
ViewData["PageHelpContent"] = "A chronological list of every transaction posted to this account. Click any Reference to open the source record. Debit increases asset and expense accounts; credit increases liability, equity, and revenue accounts. Use the date range or quick buttons (This Month, YTD, etc.) to narrow the view.";
@@ -60,7 +60,7 @@
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a asp-action="Index">Chart of Accounts</a></li>
<li class="breadcrumb-item active">@Model.AccountNumber @Model.Name</li>
<li class="breadcrumb-item active">@Model.AccountNumber &ndash; @Model.Name</li>
</ol>
</nav>
@@ -166,7 +166,7 @@
<i class="bi bi-journal-text me-1"></i>
Transactions
<span class="text-muted fw-normal small ms-1">
@Model.From.ToString("MMM d") @Model.To.ToString("MMM d, yyyy")
@Model.From.ToString("MMM d") &ndash; @Model.To.ToString("MMM d, yyyy")
</span>
</span>
<a tabindex="0" class="help-icon" role="button"
@@ -205,7 +205,7 @@
<!-- Opening balance row -->
<tr class="table-light">
<td class="text-muted small">@Model.From.ToString("MM/dd/yyyy")</td>
<td><span class="fw-medium text-muted"></span></td>
<td><span class="fw-medium text-muted">&mdash;</span></td>
<td><span class="badge bg-dark-subtle text-dark">Opening Balance</span></td>
<td class="text-muted small">Balance brought forward as of @Model.From.ToString("MMM d, yyyy")</td>
<td></td>
@@ -1,4 +1,4 @@
@using PowderCoating.Core.Entities
@using PowderCoating.Core.Entities
@{
ViewData["Title"] = "Year-End Close";
ViewData["PageIcon"] = "bi-calendar-check";
@@ -33,7 +33,7 @@
<div class="alert alert-warning alert-permanent py-2 mb-4">
<i class="bi bi-exclamation-triangle me-2"></i>
<strong>What this does:</strong> Posts a Journal Entry dated December 31 that zeroes all Revenue
and Expense account balances into Retained Earnings — the standard accounting close.
and Expense account balances into Retained Earnings &mdash; the standard accounting close.
Run this <strong>after</strong> all entries for the year are posted and the period is locked.
A year can only be closed once.
</div>
@@ -99,7 +99,7 @@
<tr>
<td class="fw-bold">@c.ClosedYear</td>
<td>@c.ClosedAt.ToLocalTime().ToString("MM/dd/yyyy h:mm tt")</td>
<td>@(c.ClosedBy ?? "—")</td>
<td>@(c.ClosedBy ?? "&mdash;")</td>
<td>
@if (c.JournalEntry != null)
{
@@ -40,7 +40,7 @@
</div>
<div>
<div class="fs-3 fw-bold">@Model.TotalCallsLast30Days.ToString("N0")</div>
<div class="text-muted small">AI Calls Last 30 Days</div>
<div class="text-muted small">AI Calls &mdash; Last 30 Days</div>
</div>
</div>
</div>
@@ -96,11 +96,11 @@
<!-- Tier Legend -->
<div class="d-flex flex-wrap gap-2 mb-3 align-items-center">
<span class="text-muted small fw-semibold me-1">Usage Tier (last 30 days):</span>
<span class="badge bg-secondary">Inactive 0 calls</span>
<span class="badge bg-success">Light — 110</span>
<span class="badge bg-primary">Regular — 1150</span>
<span class="badge bg-warning text-dark">Heavy — 51200</span>
<span class="badge bg-danger">Power User 200+</span>
<span class="badge bg-secondary">Inactive &mdash; 0 calls</span>
<span class="badge bg-success">Light &mdash; 1&ndash;10</span>
<span class="badge bg-primary">Regular &mdash; 11&ndash;50</span>
<span class="badge bg-warning text-dark">Heavy &mdash; 51&ndash;200</span>
<span class="badge bg-danger">Power User &mdash; 200+</span>
</div>
<!-- Main Table -->
@@ -207,16 +207,16 @@
</span>
</td>
<td class="text-center @(row.Today > 0 ? "fw-semibold" : "text-muted")">
@(row.Today > 0 ? row.Today.ToString("N0") : "")
@(row.Today > 0 ? row.Today.ToString("N0") : "&mdash;")
</td>
<td class="text-center @(row.Last7Days > 0 ? "fw-semibold" : "text-muted")">
@(row.Last7Days > 0 ? row.Last7Days.ToString("N0") : "")
@(row.Last7Days > 0 ? row.Last7Days.ToString("N0") : "&mdash;")
</td>
<td class="text-center @(row.Last30Days > 0 ? "fw-semibold" : "text-muted")">
@(row.Last30Days > 0 ? row.Last30Days.ToString("N0") : "")
@(row.Last30Days > 0 ? row.Last30Days.ToString("N0") : "&mdash;")
</td>
<td class="text-center @(row.AllTime > 0 ? "" : "text-muted")">
@(row.AllTime > 0 ? row.AllTime.ToString("N0") : "")
@(row.AllTime > 0 ? row.AllTime.ToString("N0") : "&mdash;")
</td>
<td class="text-center @(row.PhotoCount > 0 ? "" : "text-muted")">
@if (row.PhotoCount > 0)
@@ -225,7 +225,7 @@
}
else
{
<span></span>
<span>&mdash;</span>
}
</td>
<td>
@@ -244,7 +244,7 @@
}
else
{
<span class="text-muted"></span>
<span class="text-muted">&mdash;</span>
}
</td>
<td class="text-center">
@@ -60,7 +60,7 @@
onclick="window.location='@Url.Action("Edit", new { id = a.Id })'">
<td>
<div class="fw-medium">@a.Title</div>
<small class="text-muted">@a.Message.Substring(0, Math.Min(60, a.Message.Length))@(a.Message.Length > 60 ? "" : "")</small>
<small class="text-muted">@a.Message.Substring(0, Math.Min(60, a.Message.Length))@(a.Message.Length > 60 ? "&hellip;" : "")</small>
</td>
<td><span class="badge @TypeBadge(a.Type)">@a.Type</span></td>
<td>
@@ -110,7 +110,7 @@
</tbody>
</table>
</div>
<!-- Mobile card view shown on screens < 992px -->
<!-- Mobile card view &mdash; shown on screens < 992px -->
<div class="mobile-card-view">
<div class="mobile-card-list">
@if (!Model.Any())
@@ -15,7 +15,7 @@
<div class="col-12">
<label class="form-label fw-medium">Message <span class="text-danger">*</span></label>
<textarea asp-for="Message" class="form-control" rows="3"
placeholder="The platform will be offline for maintenance on Saturday from 24 AM ET." required></textarea>
placeholder="The platform will be offline for maintenance on Saturday from 2&ndash;4 AM ET." required></textarea>
<span asp-validation-for="Message" class="text-danger small"></span>
</div>
@@ -42,7 +42,7 @@
<div id="planTargetGroup" style="display:none">
<label class="form-label fw-medium">Plan</label>
<select asp-for="TargetPlan" class="form-select">
<option value=""> select </option>
<option value="">&mdash; select &mdash;</option>
@foreach (var p in planConfigs)
{
<option value="@p.Plan">@p.DisplayName</option>
@@ -52,7 +52,7 @@
<div id="companyTargetGroup" style="display:none">
<label class="form-label fw-medium">Company</label>
<select asp-for="TargetCompanyId" class="form-select">
<option value=""> select </option>
<option value="">&mdash; select &mdash;</option>
@foreach (var c in companies)
{
<option value="@c.Id">@c.CompanyName</option>
@@ -93,7 +93,7 @@
<label class="form-label fw-medium text-muted">Preview</label>
<div id="announcementPreview" class="alert mb-0" role="alert">
<strong id="previewTitle">@Model.Title</strong>
<span id="previewMessage"> @Model.Message</span>
<span id="previewMessage"> &mdash; @Model.Message</span>
</div>
</div>
</div>
@@ -113,7 +113,7 @@
const preview = document.getElementById('announcementPreview');
preview.className = 'alert mb-0 ' + (typeMap[type] || 'alert-info');
document.getElementById('previewTitle').textContent = document.getElementById('Title').value || 'Title';
document.getElementById('previewMessage').textContent = ' ' + (document.getElementById('Message').value || 'Message');
document.getElementById('previewMessage').textContent = ' &mdash; ' + (document.getElementById('Message').value || 'Message');
}
document.getElementById('Type')?.addEventListener('change', updatePreview);
document.getElementById('Title')?.addEventListener('input', updatePreview);
@@ -1,10 +1,10 @@
@model PowderCoating.Application.DTOs.Appointment.CreateAppointmentDto
@model PowderCoating.Application.DTOs.Appointment.CreateAppointmentDto
@{
ViewData["Title"] = "New Appointment";
ViewData["PageIcon"] = "bi-calendar-plus";
ViewData["PageHelpTitle"] = "New Appointment";
ViewData["PageHelpContent"] = "Create an appointment to schedule a customer visit, drop-off, pick-up, or consultation. Select the Type first — the Linked Job field appears once a type is chosen. Reminder notifications fire before the scheduled start time.";
ViewData["PageHelpContent"] = "Create an appointment to schedule a customer visit, drop-off, pick-up, or consultation. Select the Type first &mdash; the Linked Job field appears once a type is chosen. Reminder notifications fire before the scheduled start time.";
}
<div class="d-flex justify-content-end align-items-center mb-4">
@@ -143,7 +143,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Reminder Settings"
data-bs-content="Enable a reminder to receive an in-app notification before the appointment. Set how many minutes in advance — e.g., 30 for a brief heads-up, 1440 for a full day before. Reminders are per-appointment and do not send external emails or SMS.">
data-bs-content="Enable a reminder to receive an in-app notification before the appointment. Set how many minutes in advance &mdash; e.g., 30 for a brief heads-up, 1440 for a full day before. Reminders are per-appointment and do not send external emails or SMS.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -1,10 +1,10 @@
@model PowderCoating.Application.DTOs.Appointment.AppointmentDto
@model PowderCoating.Application.DTOs.Appointment.AppointmentDto
@{
ViewData["Title"] = $"Appointment {Model.AppointmentNumber}";
ViewData["PageIcon"] = "bi-calendar-event";
ViewData["PageHelpTitle"] = "Appointment Details";
ViewData["PageHelpContent"] = "View all details for this appointment. Edit to update status or record actual times. Deleting permanently removes the record — consider setting status to Cancelled instead to preserve history.";
ViewData["PageHelpContent"] = "View all details for this appointment. Edit to update status or record actual times. Deleting permanently removes the record &mdash; consider setting status to Cancelled instead to preserve history.";
}
<div class="d-flex justify-content-end gap-2 mb-4">
@@ -115,7 +115,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Actual Times"
data-bs-content="Record when the customer actually arrived and when the appointment finished. These are optional and separate from the scheduled times useful for tracking punctuality and measuring how accurately appointments are estimated.">
data-bs-content="Record when the customer actually arrived and when the appointment finished. These are optional and separate from the scheduled times &mdash; useful for tracking punctuality and measuring how accurately appointments are estimated.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -169,7 +169,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Reminder Settings"
data-bs-content="Enable a reminder to receive an in-app notification before the appointment. Set how many minutes in advance e.g., 30 for a brief heads-up, 1440 for a full day before. Reminders are per-appointment and do not send external emails or SMS.">
data-bs-content="Enable a reminder to receive an in-app notification before the appointment. Set how many minutes in advance &mdash; e.g., 30 for a brief heads-up, 1440 for a full day before. Reminders are per-appointment and do not send external emails or SMS.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -46,19 +46,19 @@
<dd class="col-7">@Model.EntityType</dd>
<dt class="col-5 text-muted">Entity ID</dt>
<dd class="col-7">@(Model.EntityId ?? "")</dd>
<dd class="col-7">@(Model.EntityId ?? "&mdash;")</dd>
<dt class="col-5 text-muted">Description</dt>
<dd class="col-7">@(Model.EntityDescription ?? "")</dd>
<dd class="col-7">@(Model.EntityDescription ?? "&mdash;")</dd>
<dt class="col-5 text-muted">User</dt>
<dd class="col-7">@Model.UserName</dd>
<dt class="col-5 text-muted">Company</dt>
<dd class="col-7">@(Model.CompanyName ?? (Model.CompanyId?.ToString() ?? ""))</dd>
<dd class="col-7">@(Model.CompanyName ?? (Model.CompanyId?.ToString() ?? "&mdash;"))</dd>
<dt class="col-5 text-muted">IP Address</dt>
<dd class="col-7">@(Model.IpAddress ?? "")</dd>
<dd class="col-7">@(Model.IpAddress ?? "&mdash;")</dd>
</dl>
</div>
</div>
@@ -95,8 +95,8 @@
var newVal = newData.ValueKind == JsonValueKind.Object && newData.TryGetProperty(key, out var nv) ? nv.ToString() : null;
<tr>
<td class="fw-medium">@key</td>
<td class="text-danger font-monospace">@(oldVal ?? "")</td>
<td class="text-success font-monospace">@(newVal ?? "")</td>
<td class="text-danger font-monospace">@(oldVal ?? "&mdash;")</td>
<td class="text-success font-monospace">@(newVal ?? "&mdash;")</td>
</tr>
}
}
@@ -55,7 +55,7 @@
<form method="get" class="row g-2 align-items-end">
<div class="col-md-3">
<input name="search" value="@ViewBag.Search" class="form-control form-control-sm"
placeholder="User, entity name, ID" />
placeholder="User, entity name, ID&hellip;" />
</div>
<div class="col-md-2">
<select name="entityType" class="form-select form-select-sm">
@@ -145,7 +145,7 @@
</table>
</div>
<!-- Mobile card view shown on screens < 992px (table-responsive hidden by mobile-cards.css) -->
<!-- Mobile card view &mdash; shown on screens < 992px (table-responsive hidden by mobile-cards.css) -->
<div class="mobile-card-view">
<div class="mobile-card-list">
@if (!Model.Any())
@@ -196,7 +196,7 @@
{
<div class="card-footer d-flex align-items-center justify-content-between py-2">
<small class="text-muted">
Showing @((page - 1) * pageSize + 1)@Math.Min(page * pageSize, totalCount) of @totalCount.ToString("N0")
Showing @((page - 1) * pageSize + 1)&ndash;@Math.Min(page * pageSize, totalCount) of @totalCount.ToString("N0")
</small>
<nav>
<ul class="pagination pagination-sm mb-0">
@@ -18,7 +18,7 @@
<div class="mb-3">
<label class="form-label fw-semibold">Bank Account <span class="text-danger">*</span></label>
<select asp-for="AccountId" asp-items="accounts" class="form-select" required>
<option value=""> select account </option>
<option value="">&mdash; select account &mdash;</option>
</select>
<div class="form-text">Only Checking, Savings, and Cash accounts are listed.</div>
</div>
@@ -43,7 +43,7 @@
</div>
<div class="col-md-3 text-center">
<div class="card shadow-sm py-3">
<div class="fw-bold fs-5" id="difference"></div>
<div class="fw-bold fs-5" id="difference">&mdash;</div>
<div class="text-muted small">Difference</div>
</div>
</div>
@@ -212,7 +212,7 @@
document.getElementById('aiMatchBtn')?.addEventListener('click', async function() {
const btn = this;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Analyzing';
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Analyzing&hellip;';
try {
const resp = await fetch('/BankReconciliations/AiSuggestMatches', {
@@ -244,7 +244,7 @@
const td = row.querySelector('td:last-child');
if (td) {
const pct = Math.round(s.confidence * 100);
td.insertAdjacentHTML('afterend', `<td class="small text-info" style="white-space:nowrap">${pct}% ${s.reason}</td>`);
td.insertAdjacentHTML('afterend', `<td class="small text-info" style="white-space:nowrap">${pct}% &mdash; ${s.reason}</td>`);
}
}
});
@@ -256,7 +256,7 @@
if (aiSuggestions.length > 0) {
document.getElementById('aiMatchAccept').classList.remove('d-none');
} else {
insightsEl.innerHTML += '<br><span class="text-muted">No high-confidence suggestions found review items manually.</span>';
insightsEl.innerHTML += '<br><span class="text-muted">No high-confidence suggestions found &mdash; review items manually.</span>';
}
} catch (err) {
document.getElementById('aiMatchInsights').innerHTML = '<span class="text-danger">Error contacting AI service.</span>';
@@ -1,7 +1,7 @@
@model PowderCoating.Core.Entities.BankReconciliation
@using PowderCoating.Web.Controllers
@{
ViewData["Title"] = $"Reconciliation Report {Model.Account?.Name}";
ViewData["Title"] = $"Reconciliation Report &ndash; {Model.Account?.Name}";
var clearedDeposits = ViewBag.ClearedDeposits as IEnumerable<PowderCoating.Core.Entities.Payment> ?? Enumerable.Empty<PowderCoating.Core.Entities.Payment>();
var clearedPayments = ViewBag.ClearedPayments as List<ReconciliationItem> ?? new();
}
@@ -38,7 +38,7 @@
<td class="fw-semibold text-end text-success">@clearedDeposits.Sum(p => p.Amount).ToString("C")</td>
</tr>
<tr>
<td class="text-muted"> Cleared Payments:</td>
<td class="text-muted">&ndash; Cleared Payments:</td>
<td class="fw-semibold text-end text-danger">@clearedPayments.Sum(p => p.Amount).ToString("C")</td>
</tr>
<tr class="border-top">
@@ -248,7 +248,7 @@
{
<tr class="text-muted">
<td><code>@ban.IpAddress</code></td>
<td><small>@(ban.Reason ?? "")</small></td>
<td><small>@(ban.Reason ?? "&mdash;")</small></td>
<td><small>@ban.BannedAt.ToString("MMM dd, yyyy")</small></td>
<td>
@if (!ban.IsActive)
@@ -1,4 +1,4 @@
@using PowderCoating.Application.DTOs.Subscription
@using PowderCoating.Application.DTOs.Subscription
@using PowderCoating.Core.Enums
@{
ViewData["Title"] = "Billing & Subscription";
@@ -136,7 +136,7 @@ else if (status.IsExpired)
}
else if (status.IsGracePeriod)
{
<span class="text-warning">Grace period — expired @status.EndDate.Value.ToString("MMM d, yyyy")</span>
<span class="text-warning">Grace period &mdash; expired @status.EndDate.Value.ToString("MMM d, yyyy")</span>
}
else
{
@@ -266,7 +266,7 @@ else if (status.IsExpired)
<strong>Cancellation &amp; Refund Policy:</strong>
You may cancel your subscription at any time from this page or by contacting
<a href="mailto:support@powdercoatinglogix.com">support@powdercoatinglogix.com</a>.
Cancellation takes effect at the end of your current billing period — you retain full access until then.
Cancellation takes effect at the end of your current billing period &mdash; you retain full access until then.
All fees are <strong>non-refundable</strong>; unused time is not credited back.
See our <a asp-controller="Home" asp-action="TermsOfService" asp-fragment="section-5" target="_blank">full billing terms</a> for details.
</div>
+17 -17
View File
@@ -4,7 +4,7 @@
ViewData["Title"] = "New Bill";
ViewData["PageIcon"] = "bi-receipt-cutoff";
ViewData["PageHelpTitle"] = "New Bill";
ViewData["PageHelpContent"] = "Record a vendor invoice to track what you owe. Bills start as Draft (editable) and become Open once confirmed. Partial payments are supported each payment reduces the balance. Link line items to expense accounts and optionally to specific jobs for cost tracking.";
ViewData["PageHelpContent"] = "Record a vendor invoice to track what you owe. Bills start as Draft (editable) and become Open once confirmed. Partial payments are supported &mdash; each payment reduces the balance. Link line items to expense accounts and optionally to specific jobs for cost tracking.";
string? fromPoNumber = ViewBag.FromPoNumber as string;
int? fromPoId = ViewBag.FromPoId as int?;
}
@@ -13,7 +13,7 @@
<div>
@if (!string.IsNullOrEmpty(fromPoNumber))
{
<p class="text-muted mb-0 small"><i class="bi bi-box-arrow-in-down text-success me-1"></i> Pre-filled from <strong>@fromPoNumber</strong> review and save</p>
<p class="text-muted mb-0 small"><i class="bi bi-box-arrow-in-down text-success me-1"></i> Pre-filled from <strong>@fromPoNumber</strong> &mdash; review and save</p>
}
</div>
@if (fromPoId.HasValue)
@@ -44,7 +44,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Bill Details"
data-bs-content="Vendor: who you're paying. AP Account: the liability account this bill posts to (e.g. Accounts Payable). Bill Date: date on the vendor's invoice. Due Date: when payment is due drives overdue status. Vendor Invoice #: the vendor's own reference number for reconciliation. Payment Terms auto-fill from the vendor record.">
data-bs-content="Vendor: who you're paying. AP Account: the liability account this bill posts to (e.g. Accounts Payable). Bill Date: date on the vendor's invoice. Due Date: when payment is due &mdash; drives overdue status. Vendor Invoice #: the vendor's own reference number for reconciliation. Payment Terms auto-fill from the vendor record.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -66,8 +66,8 @@
<label asp-for="VendorId" class="form-label fw-medium">Vendor <span class="text-danger">*</span></label>
<select asp-for="VendorId" asp-items="ViewBag.Vendors" class="form-select" id="vendorSelect"
data-quick-add-url="/Vendors/Create" data-quick-add-title="Add New Vendor">
<option value=""> Select Vendor </option>
<option value="__new__">+ Add New Vendor</option>
<option value="">&mdash; Select Vendor &mdash;</option>
<option value="__new__">+ Add New Vendor&hellip;</option>
</select>
<span asp-validation-for="VendorId" class="text-danger small"></span>
</div>
@@ -100,7 +100,7 @@
<label for="receiptFile" class="form-label fw-medium">Attach Receipt / Document</label>
<input type="file" name="receiptFile" id="receiptFile" class="form-control"
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf" />
<div class="form-text">JPG, PNG, GIF, WebP, or PDF up to 10 MB.</div>
<div class="form-text">JPG, PNG, GIF, WebP, or PDF &mdash; up to 10 MB.</div>
</div>
</div>
</div>
@@ -150,7 +150,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
data-bs-title="Bill Summary"
data-bs-content="Tax % is applied to the line-item subtotal. The resulting Total is the full amount owed to the vendor. Partial payments are allowed each payment recorded reduces the balance due until the bill is fully paid.">
data-bs-content="Tax % is applied to the line-item subtotal. The resulting Total is the full amount owed to the vendor. Partial payments are allowed &mdash; each payment recorded reduces the balance due until the bill is fully paid.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -198,7 +198,7 @@
<div class="mb-2">
<label class="form-label small fw-medium">Bank / Cash Account <span class="text-danger">*</span></label>
<select name="bankAccountId" class="form-select form-select-sm" id="payNowBankAccount">
<option value=""> Select Account </option>
<option value="">&mdash; Select Account &mdash;</option>
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.BankAccounts)
{
<option value="@item.Value">@item.Text</option>
@@ -232,7 +232,7 @@
<tr class="line-item-row">
<td>
<select class="form-select form-select-sm account-select" name="LineItems[INDEX].AccountId" required>
<option value=""> Account </option>
<option value="">&mdash; Account &mdash;</option>
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.ExpenseAccounts)
{
<option value="@item.Value">@item.Text</option>
@@ -242,7 +242,7 @@
<td><input type="text" class="form-control form-control-sm" name="LineItems[INDEX].Description" placeholder="Description" /></td>
<td>
<select class="form-select form-select-sm" name="LineItems[INDEX].JobId">
<option value=""></option>
<option value="">&mdash;</option>
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.Jobs)
{
<option value="@item.Value">@item.Text</option>
@@ -273,7 +273,7 @@
<div class="mb-3">
<label for="scanReceiptFile" class="form-label fw-medium">Receipt / Invoice Document</label>
<input type="file" id="scanReceiptFile" class="form-control" accept=".jpg,.jpeg,.png,.gif,.webp,.pdf" />
<div class="form-text">JPG, PNG, GIF, WebP, or PDF up to 10 MB.</div>
<div class="form-text">JPG, PNG, GIF, WebP, or PDF &mdash; up to 10 MB.</div>
</div>
<div id="scanReceiptStatus" class="text-muted small mt-2"></div>
</div>
@@ -394,7 +394,7 @@
if (lineCount === 0) addLineItem();
// ── AI Auto-suggest Account on description blur ───────────────────────
// Keyword shortcuts handle common cases with zero API cost
// Keyword shortcuts &mdash; handle common cases with zero API cost
const _keywordMap = [
{ words: ['electric','power','utility','gas','water','internet','phone','telecom'], hint: 'utilities' },
{ words: ['powder','paint','coat','material','supply','supplies','chemical','resin'], hint: 'materials' },
@@ -480,7 +480,7 @@
hint2.className = 'ai-account-hint text-muted small mt-1';
accountSel.parentNode.appendChild(hint2);
}
hint2.innerHTML = '<span class="spinner-border spinner-border-sm" style="width:.75rem;height:.75rem"></span> Thinking';
hint2.innerHTML = '<span class="spinner-border spinner-border-sm" style="width:.75rem;height:.75rem"></span> Thinking&hellip;';
try {
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value;
@@ -501,7 +501,7 @@
}
}
// Event delegation works for dynamically added rows
// Event delegation &mdash; works for dynamically added rows
document.getElementById('lineItemsBody').addEventListener('blur', function (e) {
if (e.target.matches('[name$=".Description"]')) {
_suggestAccountForRow(e.target.closest('tr'));
@@ -535,7 +535,7 @@
return;
}
// Auto-fill bill header try to match vendor name to dropdown
// Auto-fill bill header &mdash; try to match vendor name to dropdown
if (data.vendorName) {
const vendorSel = document.getElementById('vendorSelect');
if (vendorSel && !vendorSel.value) {
@@ -553,7 +553,7 @@
vendorSel.value = bestOption.value;
vendorSel.dispatchEvent(new Event('change'));
} else {
// No match put the name in Memo so user knows what the AI saw
// No match &mdash; put the name in Memo so user knows what the AI saw
const memo = document.querySelector('[name="Memo"]');
if (memo && !memo.value) memo.value = data.vendorName;
}
@@ -598,7 +598,7 @@
const modal = bootstrap.Modal.getInstance(document.getElementById('scanReceiptModal'));
if (modal) modal.hide();
statusEl.textContent = 'Scan complete review and adjust as needed.';
statusEl.textContent = 'Scan complete &mdash; review and adjust as needed.';
} catch (e) {
statusEl.textContent = 'Error connecting to AI service.';
} finally {
@@ -5,7 +5,7 @@
ViewData["Title"] = $"Bill {Model.BillNumber}";
ViewData["PageIcon"] = "bi-receipt-cutoff";
ViewData["PageHelpTitle"] = "Bill Status";
ViewData["PageHelpContent"] = "Draft: editable, not yet confirmed. Open: awaiting payment. Partially Paid: some payments recorded. Paid: fully settled. Voided: cancelled preserves history. Edit is only available in Draft status. Use Void instead of deleting to keep a complete audit trail.";
ViewData["PageHelpContent"] = "Draft: editable, not yet confirmed. Open: awaiting payment. Partially Paid: some payments recorded. Paid: fully settled. Voided: cancelled &mdash; preserves history. Edit is only available in Draft status. Use Void instead of deleting to keep a complete audit trail.";
string StatusBadge(BillStatus s) => s switch
{
@@ -266,7 +266,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
data-bs-title="Balance Summary"
data-bs-content="Balance Due = Bill Total minus all payments recorded. Multiple partial payments are supported each reduces the balance until fully paid. Deleting a payment reverses it and restores the balance due.">
data-bs-content="Balance Due = Bill Total minus all payments recorded. Multiple partial payments are supported &mdash; each reduces the balance until fully paid. Deleting a payment reverses it and restores the balance due.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -322,7 +322,7 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Record Payment @Model.BillNumber</h5>
<h5 class="modal-title">Record Payment &mdash; @Model.BillNumber</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form asp-action="RecordPayment" method="post">
@@ -358,12 +358,12 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
data-bs-title="Bank Account"
data-bs-content="The bank or cash account this payment is drawn from. Used for bank reconciliation helps match this payment to the corresponding debit on your bank statement.">
data-bs-content="The bank or cash account this payment is drawn from. Used for bank reconciliation &mdash; helps match this payment to the corresponding debit on your bank statement.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<select name="BankAccountId" class="form-select" required>
<option value=""> Select Account </option>
<option value="">&mdash; Select Account &mdash;</option>
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.BankAccounts)
{
<option value="@item.Value">@item.Text</option>
@@ -423,7 +423,7 @@
<div class="col-12">
<label class="form-label fw-medium">Bank Account <span class="text-danger">*</span></label>
<select name="BankAccountId" id="editBillBankAccountId" class="form-select" required>
<option value=""> Select Account </option>
<option value="">&mdash; Select Account &mdash;</option>
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.BankAccounts)
{
<option value="@item.Value">@item.Text</option>
@@ -1,10 +1,10 @@
@model PowderCoating.Application.DTOs.Accounting.EditBillDto
@model PowderCoating.Application.DTOs.Accounting.EditBillDto
@{
ViewData["Title"] = "Edit Bill";
ViewData["PageIcon"] = "bi-pencil-square";
ViewData["PageHelpTitle"] = "Edit Bill";
ViewData["PageHelpContent"] = "Bills can only be edited while in Draft status. Once marked Open, they are locked Void the bill and recreate it if corrections are needed after confirmation.";
ViewData["PageHelpContent"] = "Bills can only be edited while in Draft status. Once marked Open, they are locked &mdash; Void the bill and recreate it if corrections are needed after confirmation.";
}
<div class="d-flex justify-content-start mb-4">
@@ -24,7 +24,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Bill Details"
data-bs-content="Vendor: who you're paying. AP Account: the liability account this bill posts to (e.g. Accounts Payable). Bill Date: date on the vendor's invoice. Due Date: when payment is due drives overdue status. Vendor Invoice #: the vendor's own reference number for reconciliation.">
data-bs-content="Vendor: who you're paying. AP Account: the liability account this bill posts to (e.g. Accounts Payable). Bill Date: date on the vendor's invoice. Due Date: when payment is due &mdash; drives overdue status. Vendor Invoice #: the vendor's own reference number for reconciliation.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -34,8 +34,8 @@
<label asp-for="VendorId" class="form-label fw-medium">Vendor <span class="text-danger">*</span></label>
<select asp-for="VendorId" asp-items="ViewBag.Vendors" class="form-select"
data-quick-add-url="/Vendors/Create" data-quick-add-title="Add New Vendor">
<option value=""> Select Vendor </option>
<option value="__new__">+ Add New Vendor</option>
<option value="">&mdash; Select Vendor &mdash;</option>
<option value="__new__">+ Add New Vendor&hellip;</option>
</select>
</div>
<div class="col-md-6">
@@ -87,7 +87,7 @@
}
<input type="file" name="receiptFile" id="receiptFile" class="form-control"
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf" />
<div class="form-text">JPG, PNG, GIF, WebP, or PDF up to 10 MB.</div>
<div class="form-text">JPG, PNG, GIF, WebP, or PDF &mdash; up to 10 MB.</div>
</div>
</div>
</div>
@@ -134,7 +134,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
data-bs-title="Bill Summary"
data-bs-content="Tax % is applied to the line-item subtotal. The resulting Total is the full amount owed to the vendor. Partial payments are allowed each payment recorded reduces the balance due until the bill is fully paid.">
data-bs-content="Tax % is applied to the line-item subtotal. The resulting Total is the full amount owed to the vendor. Partial payments are allowed &mdash; each payment recorded reduces the balance due until the bill is fully paid.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -171,7 +171,7 @@
<tr class="line-item-row">
<td>
<select class="form-select form-select-sm account-select" name="LineItems[INDEX].AccountId" required>
<option value=""> Account </option>
<option value="">&mdash; Account &mdash;</option>
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.ExpenseAccounts)
{
<option value="@item.Value">@item.Text</option>
@@ -181,7 +181,7 @@
<td><input type="text" class="form-control form-control-sm" name="LineItems[INDEX].Description" placeholder="Description" /></td>
<td>
<select class="form-select form-select-sm" name="LineItems[INDEX].JobId">
<option value=""></option>
<option value="">&mdash;</option>
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.Jobs)
{
<option value="@item.Value">@item.Text</option>
@@ -1,4 +1,4 @@
@model List<PowderCoating.Application.DTOs.Accounting.BillExpenseListDto>
@model List<PowderCoating.Application.DTOs.Accounting.BillExpenseListDto>
@{
ViewData["Title"] = "Bills / Expenses";
@@ -62,7 +62,7 @@
<form method="get" class="row g-2 align-items-end">
<div class="col-md-4">
<input type="search" name="search" value="@ViewBag.Search" class="form-control"
placeholder="Search by #, vendor, memo, amount…" />
placeholder="Search by #, vendor, memo, amount&hellip;" />
</div>
<div class="col-md-2">
<select name="type" class="form-select">
@@ -156,13 +156,13 @@
}
else if (entry.EntryType == "Expense")
{
<span class="text-muted">—</span>
<span class="text-muted">&mdash;</span>
}
</td>
<td><span class="badge bg-@entry.StatusColor">@entry.StatusLabel</span></td>
<td class="text-end">@entry.Total.ToString("C")</td>
<td class="text-end fw-medium @(entry.BalanceDue > 0 ? "text-danger" : "text-muted")">
@(entry.EntryType == "Bill" ? entry.BalanceDue.ToString("C") : "—")
@(entry.EntryType == "Bill" ? entry.BalanceDue.ToString("C") : "&mdash;")
</td>
<td>
@if (entry.EntryType == "Bill")
@@ -209,7 +209,7 @@ else
asp-route-status="@ViewBag.StatusFilter"
asp-route-search="@ViewBag.Search"
asp-route-page="@((int)ViewBag.Page - 1)"
asp-route-pageSize="@ViewBag.PageSize">‹ Prev</a>
asp-route-pageSize="@ViewBag.PageSize">&lsaquo; Prev</a>
</li>
@for (var p = 1; p <= (int)ViewBag.TotalPages; p++)
{
@@ -228,11 +228,11 @@ else
asp-route-status="@ViewBag.StatusFilter"
asp-route-search="@ViewBag.Search"
asp-route-page="@((int)ViewBag.Page + 1)"
asp-route-pageSize="@ViewBag.PageSize">Next ›</a>
asp-route-pageSize="@ViewBag.PageSize">Next &rsaquo;</a>
</li>
</ul>
<p class="text-center text-muted small">
Showing @(((int)ViewBag.Page - 1) * (int)ViewBag.PageSize + 1)–@(Math.Min((int)ViewBag.Page * (int)ViewBag.PageSize, (int)ViewBag.TotalCount))
Showing @(((int)ViewBag.Page - 1) * (int)ViewBag.PageSize + 1)&ndash;@(Math.Min((int)ViewBag.Page * (int)ViewBag.PageSize, (int)ViewBag.TotalCount))
of @ViewBag.TotalCount entries
</p>
</nav>
@@ -31,7 +31,7 @@
<div id="resultArea" class="d-none">
<div id="spinnerArea" class="text-center py-5 d-none">
<div class="spinner-border text-primary" style="width:2.5rem;height:2.5rem;" role="status"></div>
<p class="text-muted mt-3">Claude is reviewing your bill history</p>
<p class="text-muted mt-3">Claude is reviewing your bill history&hellip;</p>
</div>
<div id="errorArea" class="alert alert-danger alert-permanent d-none"></div>
@@ -2,7 +2,7 @@
@model BudgetCreateVm
@{
ViewData["Title"] = $"Edit Budget {Model.Name}";
ViewData["Title"] = $"Edit Budget &mdash; {Model.Name}";
ViewData["PageIcon"] = "bi-pencil";
var months = new[] { "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
}
@@ -24,7 +24,7 @@
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-0 py-3">
<h5 class="mb-0 fw-semibold">
<i class="bi bi-pie-chart me-2 text-primary"></i>@Model.Name @Model.FiscalYear
<i class="bi bi-pie-chart me-2 text-primary"></i>@Model.Name &mdash; @Model.FiscalYear
</h5>
</div>
<div class="card-body">
@@ -78,7 +78,7 @@ else
</td>
<td class="text-center">@b.Lines.Count</td>
<td class="text-end text-success">@b.Lines.Sum(l => l.Annual).ToString("C")</td>
<td class="text-end text-danger"></td>
<td class="text-end text-danger">&mdash;</td>
<td class="text-center">
@if (b.IsDefault)
{
@@ -246,7 +246,7 @@
</table>
</div>
<!-- Mobile card view shown on screens < 992px -->
<!-- Mobile card view &mdash; shown on screens < 992px -->
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var report in Model)
@@ -313,7 +313,7 @@
{
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-top">
<small class="text-muted">
Showing @((pageNumber - 1) * pageSize + 1)@(Math.Min(pageNumber * pageSize, totalCount)) of @totalCount
Showing @((pageNumber - 1) * pageSize + 1)&ndash;@(Math.Min(pageNumber * pageSize, totalCount)) of @totalCount
</small>
<nav>
<ul class="pagination pagination-sm mb-0">
@@ -1,4 +1,4 @@
@model PowderCoating.Application.DTOs.BugReport.CreateBugReportDto
@model PowderCoating.Application.DTOs.BugReport.CreateBugReportDto
@using PowderCoating.Core.Enums
@{
ViewData["Title"] = "Report a Bug";
@@ -59,10 +59,10 @@
<div class="mb-4">
<label asp-for="Priority" class="form-label fw-semibold">Priority</label>
<select asp-for="Priority" class="form-select">
<option value="@((int)BugReportPriority.Low)">Low – Minor inconvenience, workaround exists</option>
<option value="@((int)BugReportPriority.Normal)" selected>Normal – Affects workflow but not critical</option>
<option value="@((int)BugReportPriority.High)">High – Significantly impacts operations</option>
<option value="@((int)BugReportPriority.Critical)">Critical – System unusable or data loss risk</option>
<option value="@((int)BugReportPriority.Low)">Low &ndash; Minor inconvenience, workaround exists</option>
<option value="@((int)BugReportPriority.Normal)" selected>Normal &ndash; Affects workflow but not critical</option>
<option value="@((int)BugReportPriority.High)">High &ndash; Significantly impacts operations</option>
<option value="@((int)BugReportPriority.Critical)">Critical &ndash; System unusable or data loss risk</option>
</select>
<span asp-validation-for="Priority" class="text-danger small"></span>
</div>
@@ -104,7 +104,7 @@
const li = document.createElement('li');
const sizeMb = (f.size / 1024 / 1024).toFixed(1);
if (f.size > maxBytes) {
li.innerHTML = `<i class="bi bi-exclamation-triangle text-danger"></i> ${f.name} (${sizeMb} MB) — exceeds 100 MB limit`;
li.innerHTML = `<i class="bi bi-exclamation-triangle text-danger"></i> ${f.name} (${sizeMb} MB) &mdash; exceeds 100 MB limit`;
} else {
li.innerHTML = `<i class="bi bi-file-earmark text-secondary"></i> ${f.name} (${sizeMb} MB)`;
}
@@ -34,7 +34,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Parent Category"
data-bs-content="Leave as '(None)' to create a top-level category. Choose a parent to nest this under it e.g., place 'Aluminum Wheels' under a parent 'Wheels' category. This creates a browsable hierarchy in the catalog and quote wizard.">
data-bs-content="Leave as '(None)' to create a top-level category. Choose a parent to nest this under it &mdash; e.g., place 'Aluminum Wheels' under a parent 'Wheels' category. This creates a browsable hierarchy in the catalog and quote wizard.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -35,7 +35,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Parent Category"
data-bs-content="Leave as '(None)' to keep this as a top-level category. Choose a parent to nest it e.g., 'Aluminum Wheels' under 'Wheels'. The system prevents circular references (you cannot make a category its own ancestor).">
data-bs-content="Leave as '(None)' to keep this as a top-level category. Choose a parent to nest it &mdash; e.g., 'Aluminum Wheels' under 'Wheels'. The system prevents circular references (you cannot make a category its own ancestor).">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -3,7 +3,7 @@
ViewData["Title"] = "AI Catalog Price Check";
ViewData["PageIcon"] = "bi-robot";
ViewData["PageHelpTitle"] = "AI Catalog Price Check";
ViewData["PageHelpContent"] = "The AI Price Check reviews every item in your catalog against your actual operating costs and flags items that may be priced below cost, have thin margins, or appear unusually high. Results are estimates based on industry knowledge and your shop's rates always apply your own judgment before changing prices.";
ViewData["PageHelpContent"] = "The AI Price Check reviews every item in your catalog against your actual operating costs and flags items that may be priced below cost, have thin margins, or appear unusually high. Results are estimates based on industry knowledge and your shop's rates &mdash; always apply your own judgment before changing prices.";
var sortedResults = Model?.Results
.OrderBy(r => r.Verdict switch
@@ -82,7 +82,7 @@
<div class="progress-card">
<div class="icon"><i class="bi bi-robot"></i></div>
<h5>Analyzing your catalog</h5>
<p class="status-msg" id="overlay-status">Preparing items</p>
<p class="status-msg" id="overlay-status">Preparing items&hellip;</p>
<div class="progress-bar-track">
<div class="progress-bar-fill" id="overlay-bar"></div>
</div>
@@ -151,22 +151,22 @@
<div>
<h6 class="fw-semibold mb-1">What this analysis does</h6>
<p class="small text-muted mb-2">
Our AI system reviews every active, priced item in your catalog against your shop's actual operating costs
Our AI system reviews every active, priced item in your catalog against your shop's actual operating costs &mdash;
labor, oven time, sandblasting, coating booth, and powder material. For each item it estimates a
realistic surface area and processing time, calculates a cost floor, then compares that to your
current price and returns one of four verdicts:
</p>
<div class="d-flex flex-wrap gap-2 mb-2">
<span class="verdict-badge verdict-below-cost">Below Cost</span><span class="small text-muted align-self-center"> you're losing money on this item</span>
<span class="verdict-badge verdict-below-cost">Below Cost</span><span class="small text-muted align-self-center">&mdash; you're losing money on this item</span>
</div>
<div class="d-flex flex-wrap gap-2 mb-2">
<span class="verdict-badge verdict-low">Thin Margin</span><span class="small text-muted align-self-center"> above cost floor but below your target margin</span>
<span class="verdict-badge verdict-low">Thin Margin</span><span class="small text-muted align-self-center">&mdash; above cost floor but below your target margin</span>
</div>
<div class="d-flex flex-wrap gap-2 mb-2">
<span class="verdict-badge verdict-high">High</span><span class="small text-muted align-self-center"> significantly above typical market rates</span>
<span class="verdict-badge verdict-high">High</span><span class="small text-muted align-self-center">&mdash; significantly above typical market rates</span>
</div>
<div class="d-flex flex-wrap gap-2 mb-3">
<span class="verdict-badge verdict-ok">OK</span><span class="small text-muted align-self-center"> price is within a reasonable range</span>
<span class="verdict-badge verdict-ok">OK</span><span class="small text-muted align-self-center">&mdash; price is within a reasonable range</span>
</div>
<p class="small text-muted mb-0">
<i class="bi bi-exclamation-triangle me-1 text-warning"></i>
@@ -301,15 +301,15 @@ else
<div class="col-4 text-center">
<div class="small text-muted">Suggested</div>
<div class="fw-semibold text-primary">
@item.SuggestedPriceMin.ToString("C") @item.SuggestedPriceMax.ToString("C")
@item.SuggestedPriceMin.ToString("C") &ndash; @item.SuggestedPriceMax.ToString("C")
</div>
</div>
</div>
<div class="small text-muted mb-1">
<i class="bi bi-rulers me-1"></i>
Est. @item.EstimatedSqFtMin@item.EstimatedSqFtMax sqft &bull;
@item.EstimatedMinutesMin@item.EstimatedMinutesMax min
Est. @item.EstimatedSqFtMin&ndash;@item.EstimatedSqFtMax sqft &bull;
@item.EstimatedMinutesMin&ndash;@item.EstimatedMinutesMax min
</div>
<p class="small mb-1">@item.Reasoning</p>
@@ -20,7 +20,7 @@
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-4" role="alert">
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
<div>
<strong>Catalog item prices are fixed.</strong> The price you enter here is exactly what gets charged when this item is added to a quote or job no markup, no prep service charges, and no complexity adjustments are added on top. Make sure your price already includes labor, materials, and margin.
<strong>Catalog item prices are fixed.</strong> The price you enter here is exactly what gets charged when this item is added to a quote or job &mdash; no markup, no prep service charges, and no complexity adjustments are added on top. Make sure your price already includes labor, materials, and margin.
</div>
</div>
@@ -41,7 +41,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Item Name"
data-bs-content="Use a specific, recognizable name. The name appears on quotes, invoices, and in the picker dropdown. Good names include material and size where relevant e.g., 'Steel Bracket (6in)', '18in Alloy Wheel'.">
data-bs-content="Use a specific, recognizable name. The name appears on quotes, invoices, and in the picker dropdown. Good names include material and size where relevant &mdash; e.g., 'Steel Bracket (6in)', '18in Alloy Wheel'.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -75,7 +75,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Pricing"
data-bs-content="The Default Price is the exact amount charged when this item is added to a quote no markup, prep services, or complexity adjustments are applied on top. Set the all-in price you want to bill. Approximate Area is optional if set, it helps estimate powder needed for reporting purposes.">
data-bs-content="The Default Price is the exact amount charged when this item is added to a quote &mdash; no markup, prep services, or complexity adjustments are applied on top. Set the all-in price you want to bill. Approximate Area is optional &mdash; if set, it helps estimate powder needed for reporting purposes.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -87,7 +87,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Default Price"
data-bs-content="This is the final price charged to the customer no markup, prep services, or complexity charges are added on top. Enter the all-in amount you want to bill for this item. Staff can still override it on individual quotes if needed.">
data-bs-content="This is the final price charged to the customer &mdash; no markup, prep services, or complexity charges are added on top. Enter the all-in amount you want to bill for this item. Staff can still override it on individual quotes if needed.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -122,7 +122,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Financial Accounts"
data-bs-content="Links this item to your chart of accounts for accounting exports. Revenue Account is credited when invoiced; COGS Account is debited when materials are consumed. Leave both blank to use the company default accounts most shops only need to set these for items with special accounting treatment.">
data-bs-content="Links this item to your chart of accounts for accounting exports. Revenue Account is credited when invoiced; COGS Account is debited when materials are consumed. Leave both blank to use the company default accounts &mdash; most shops only need to set these for items with special accounting treatment.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -140,7 +140,7 @@
<select asp-for="CogsAccountId" class="form-select" asp-items="ViewBag.CogsAccounts"
data-quick-add-url="/Accounts/Create?preSubType=40" data-quick-add-title="Add COGS Account">
<option value="">(Default COGS account)</option>
<option value="__new__">+ Add New Account</option>
<option value="__new__">+ Add New Account&hellip;</option>
</select>
<small class="form-text text-muted">Account debited when materials are consumed.</small>
</div>
@@ -164,7 +164,7 @@
<div class="mb-3">
<label for="image" class="form-label fw-semibold">Upload Image</label>
<input type="file" class="form-control" id="image" name="image" accept="image/jpeg,image/png,image/gif,image/webp" onchange="previewCatalogImage(this)" />
<div class="form-text">Accepted formats: jpg, jpeg, png, gif, webp max 10 MB. A 200×200 thumbnail is generated automatically.</div>
<div class="form-text">Accepted formats: jpg, jpeg, png, gif, webp &mdash; max 10 MB. A 200×200 thumbnail is generated automatically.</div>
<div id="imagePreview" class="mt-2 d-none">
<img id="imagePreviewImg" src="" alt="Preview" style="max-width:200px;max-height:200px;object-fit:contain;border:1px solid #dee2e6;border-radius:6px;" />
</div>
@@ -35,7 +35,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Item Name"
data-bs-content="Use a specific, recognizable name. The name appears on quotes, invoices, and in the picker dropdown. Good names include material and size where relevant e.g., 'Steel Bracket (6in)', '18in Alloy Wheel'.">
data-bs-content="Use a specific, recognizable name. The name appears on quotes, invoices, and in the picker dropdown. Good names include material and size where relevant &mdash; e.g., 'Steel Bracket (6in)', '18in Alloy Wheel'.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -69,7 +69,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Pricing &amp; Status"
data-bs-content="The Default Price is the exact amount charged when this item is added to a quote no markup, prep services, or complexity adjustments are applied on top. Staff can override it on individual quotes. Set Status to Inactive to hide the item from the picker without deleting it.">
data-bs-content="The Default Price is the exact amount charged when this item is added to a quote &mdash; no markup, prep services, or complexity adjustments are applied on top. Staff can override it on individual quotes. Set Status to Inactive to hide the item from the picker without deleting it.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -81,7 +81,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Default Price"
data-bs-content="This is the final price charged to the customer no markup, prep services, or complexity charges are added on top. Enter the all-in amount you want to bill for this item. Staff can still override it on individual quotes if needed.">
data-bs-content="This is the final price charged to the customer &mdash; no markup, prep services, or complexity charges are added on top. Enter the all-in amount you want to bill for this item. Staff can still override it on individual quotes if needed.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -125,7 +125,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Financial Accounts"
data-bs-content="Links this item to your chart of accounts for accounting exports. Revenue Account is credited when invoiced; COGS Account is debited when materials are consumed. Leave both blank to use the company default accounts most shops only need to set these for items with special accounting treatment.">
data-bs-content="Links this item to your chart of accounts for accounting exports. Revenue Account is credited when invoiced; COGS Account is debited when materials are consumed. Leave both blank to use the company default accounts &mdash; most shops only need to set these for items with special accounting treatment.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -143,7 +143,7 @@
<select asp-for="CogsAccountId" class="form-select" asp-items="ViewBag.CogsAccounts"
data-quick-add-url="/Accounts/Create?preSubType=40" data-quick-add-title="Add COGS Account">
<option value="">(Default COGS account)</option>
<option value="__new__">+ Add New Account</option>
<option value="__new__">+ Add New Account&hellip;</option>
</select>
<small class="form-text text-muted">Account debited when materials are consumed.</small>
</div>
@@ -186,7 +186,7 @@
<div class="mb-3">
<label for="image" class="form-label fw-semibold">Upload Image</label>
<input type="file" class="form-control" id="image" name="image" accept="image/jpeg,image/png,image/gif,image/webp" onchange="previewCatalogImage(this)" />
<div class="form-text">Accepted formats: jpg, jpeg, png, gif, webp max 10 MB. A 200×200 thumbnail is generated automatically.</div>
<div class="form-text">Accepted formats: jpg, jpeg, png, gif, webp &mdash; max 10 MB. A 200×200 thumbnail is generated automatically.</div>
<div id="imagePreview" class="mt-2 d-none">
<img id="imagePreviewImg" src="" alt="Preview" style="max-width:200px;max-height:200px;object-fit:contain;border:1px solid #dee2e6;border-radius:6px;" />
</div>
@@ -3,7 +3,7 @@
ViewData["Title"] = "Product Catalog";
ViewData["PageIcon"] = "bi-book";
ViewData["PageHelpTitle"] = "Product Catalog";
ViewData["PageHelpContent"] = "The Product Catalog is a library of standard items (wheels, brackets, panels, etc.) that your shop regularly quotes and invoices. Each item has a fixed price when a catalog item is added to a quote or job, that price is used exactly as entered. No markup, no prep services, and no complexity charges are added on top. Organize items into categories to keep the catalog easy to browse.";
ViewData["PageHelpContent"] = "The Product Catalog is a library of standard items (wheels, brackets, panels, etc.) that your shop regularly quotes and invoices. Each item has a fixed price &mdash; when a catalog item is added to a quote or job, that price is used exactly as entered. No markup, no prep services, and no complexity charges are added on top. Organize items into categories to keep the catalog easy to browse.";
var totalItemsCount = ViewBag.TotalItemsCount ?? 0;
var activeItemsCount = ViewBag.ActiveItemsCount ?? 0;
var averagePrice = ViewBag.AveragePrice ?? 0m;
@@ -148,7 +148,7 @@
<div class="card-body">
<table class="table table-sm table-borderless mb-0">
<tr><th style="width:40%">Company Name</th><td>@Model.CompanyName</td></tr>
<tr><th>Code</th><td>@(Model.CompanyCode ?? "")</td></tr>
<tr><th>Code</th><td>@(Model.CompanyCode ?? "&mdash;")</td></tr>
<tr><th>Status</th><td><span class="badge @(Model.IsActive ? "bg-success" : "bg-danger")">@(Model.IsActive ? "Active" : "Inactive")</span></td></tr>
<tr><th>Time Zone</th><td>@(Model.TimeZone ?? "America/New_York")</td></tr>
<tr><th>Created</th><td>@Model.CreatedAt.ToString("MMM d, yyyy h:mm tt")</td></tr>
@@ -174,7 +174,7 @@
<table class="table table-sm table-borderless mb-0">
<tr><th style="width:40%">Contact Name</th><td>@Model.PrimaryContactName</td></tr>
<tr><th>Email</th><td><a href="mailto:@Model.PrimaryContactEmail">@Model.PrimaryContactEmail</a></td></tr>
<tr><th>Phone</th><td>@(Model.Phone ?? "")</td></tr>
<tr><th>Phone</th><td>@(Model.Phone ?? "&mdash;")</td></tr>
</table>
</div>
</div>
@@ -283,7 +283,7 @@
}
else { <span class="text-muted">N/A</span> }
</td>
<td>@(user.Department ?? "")</td>
<td>@(user.Department ?? "&mdash;")</td>
<td>
<span class="badge @(user.IsActive ? "bg-success" : "bg-danger")">
@(user.IsActive ? "Active" : "Inactive")
@@ -527,20 +527,20 @@
@{
var firstActivity = onboarding.FirstJobCreatedAt ?? onboarding.FirstQuoteCreatedAt;
}
@(firstActivity.HasValue ? firstActivity.Value.ToString("MMM d, yyyy") : "")
@(firstActivity.HasValue ? firstActivity.Value.ToString("MMM d, yyyy") : "&mdash;")
</td>
</tr>
<tr>
<th>First Invoice</th>
<td>@(onboarding.FirstInvoiceCreatedAt.HasValue ? onboarding.FirstInvoiceCreatedAt.Value.ToString("MMM d, yyyy") : "")</td>
<td>@(onboarding.FirstInvoiceCreatedAt.HasValue ? onboarding.FirstInvoiceCreatedAt.Value.ToString("MMM d, yyyy") : "&mdash;")</td>
</tr>
<tr>
<th>Workflow Completed</th>
<td>@(onboarding.FirstWorkflowCompletedAt.HasValue ? onboarding.FirstWorkflowCompletedAt.Value.ToString("MMM d, yyyy") : "")</td>
<td>@(onboarding.FirstWorkflowCompletedAt.HasValue ? onboarding.FirstWorkflowCompletedAt.Value.ToString("MMM d, yyyy") : "&mdash;")</td>
</tr>
<tr>
<th>Widget Dismissed</th>
<td>@(onboarding.GuidedActivationDismissedAt.HasValue ? onboarding.GuidedActivationDismissedAt.Value.ToString("MMM d, yyyy") : "")</td>
<td>@(onboarding.GuidedActivationDismissedAt.HasValue ? onboarding.GuidedActivationDismissedAt.Value.ToString("MMM d, yyyy") : "&mdash;")</td>
</tr>
</table>
</div>
@@ -618,7 +618,7 @@
</div><!-- /tab-content -->
<!-- Danger Zone (outside tabs always present) -->
<!-- Danger Zone (outside tabs &mdash; always present) -->
<div class="card shadow-sm border-danger mt-4">
<div class="card-header bg-light">
<h6 class="card-title mb-0 text-danger">
@@ -630,7 +630,7 @@
<div>
<h6 class="mb-1 text-warning-emphasis"><i class="bi bi-fire me-1"></i>Reset All Company Data</h6>
<p class="text-muted small mb-0">
Permanently deletes all business data customers, jobs, quotes, invoices, inventory, and more.
Permanently deletes all business data &mdash; customers, jobs, quotes, invoices, inventory, and more.
The company record, users, and settings are preserved. Use this to wipe a migration and start fresh.
</p>
</div>
@@ -646,7 +646,7 @@
Permanently deletes the company and everything in it. There is no going back.
@if (Model.UserCount > 0)
{
<br /><strong class="text-danger">This company has @Model.UserCount user(s) remove them first.</strong>
<br /><strong class="text-danger">This company has @Model.UserCount user(s) &mdash; remove them first.</strong>
}
</p>
</div>
@@ -672,7 +672,7 @@
</div>
<div class="modal-body p-0">
<div id="oc-loading" class="d-flex justify-content-center align-items-center py-5">
<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading</span></div>
<div class="spinner-border text-primary" role="status"><span class="visually-hidden">Loading&hellip;</span></div>
</div>
<div id="oc-content" style="display:none;">
<div class="px-3 pt-3 pb-2">
@@ -688,14 +688,14 @@
<span id="oc-status-badge" class="badge ms-auto"></span>
</div>
<table class="table table-sm table-borderless mb-0 small">
<tr><th style="width:38%;" class="text-muted fw-normal">Role</th><td id="oc-role"></td></tr>
<tr><th class="text-muted fw-normal">Department</th><td id="oc-dept"></td></tr>
<tr><th class="text-muted fw-normal">Position</th><td id="oc-position"></td></tr>
<tr><th class="text-muted fw-normal">Phone</th><td id="oc-phone"></td></tr>
<tr><th class="text-muted fw-normal">Hired</th><td id="oc-hire"></td></tr>
<tr><th class="text-muted fw-normal">Account created</th><td id="oc-created"></td></tr>
<tr><th class="text-muted fw-normal">Last login</th><td id="oc-lastlogin"></td></tr>
<tr><th class="text-muted fw-normal">Email confirmed</th><td id="oc-emailconf"></td></tr>
<tr><th style="width:38%;" class="text-muted fw-normal">Role</th><td id="oc-role">&mdash;</td></tr>
<tr><th class="text-muted fw-normal">Department</th><td id="oc-dept">&mdash;</td></tr>
<tr><th class="text-muted fw-normal">Position</th><td id="oc-position">&mdash;</td></tr>
<tr><th class="text-muted fw-normal">Phone</th><td id="oc-phone">&mdash;</td></tr>
<tr><th class="text-muted fw-normal">Hired</th><td id="oc-hire">&mdash;</td></tr>
<tr><th class="text-muted fw-normal">Account created</th><td id="oc-created">&mdash;</td></tr>
<tr><th class="text-muted fw-normal">Last login</th><td id="oc-lastlogin">&mdash;</td></tr>
<tr><th class="text-muted fw-normal">Email confirmed</th><td id="oc-emailconf">&mdash;</td></tr>
</table>
</div>
<hr class="my-0" />
@@ -127,12 +127,12 @@
</div>
<h5 class="card-title mb-3 pb-2 border-bottom">Feature Overrides</h5>
<p class="text-muted small mb-3">Override plan-level feature access for this company. Leave blank () to inherit from the subscription plan.</p>
<p class="text-muted small mb-3">Override plan-level feature access for this company. Leave blank (&mdash;) to inherit from the subscription plan.</p>
<div class="row g-3 mb-4">
<div class="col-md-6">
<label class="form-label fw-medium">Online Payments</label>
<select asp-for="OnlinePaymentsOverride" class="form-select">
<option value=""> Use plan default </option>
<option value="">&mdash; Use plan default &mdash;</option>
<option value="true">Force Enable</option>
<option value="false">Force Disable</option>
</select>
@@ -141,7 +141,7 @@
<div class="col-md-6">
<label class="form-label fw-medium">Accounting Module</label>
<select asp-for="AccountingOverride" class="form-select">
<option value=""> Use plan default </option>
<option value="">&mdash; Use plan default &mdash;</option>
<option value="true">Force Enable</option>
<option value="false">Force Disable</option>
</select>
@@ -150,7 +150,7 @@
</div>
<h5 class="card-title mb-3 pb-2 border-bottom">SMS Override</h5>
<p class="text-muted small mb-3">Use this to immediately cut off SMS for a company for example if they are sending abusive messages or have a billing dispute. This overrides the plan entitlement and the company's own opt-in setting.</p>
<p class="text-muted small mb-3">Use this to immediately cut off SMS for a company &mdash; for example if they are sending abusive messages or have a billing dispute. This overrides the plan entitlement and the company's own opt-in setting.</p>
<div class="mb-4">
<div class="form-check form-switch">
<input asp-for="SmsDisabledByAdmin" class="form-check-input" type="checkbox" role="switch" id="SmsDisabledByAdmin" />
@@ -61,7 +61,7 @@
<div class="input-group">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" name="searchTerm" class="form-control"
placeholder="Search by name, code, email, phone"
placeholder="Search by name, code, email, phone&hellip;"
value="@searchTerm" />
</div>
</div>
@@ -265,7 +265,7 @@
</table>
</div>
<!-- Mobile card view shown on screens < 992px (table-responsive hidden by mobile-cards.css) -->
<!-- Mobile card view &mdash; shown on screens < 992px (table-responsive hidden by mobile-cards.css) -->
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var company in Model)
@@ -321,7 +321,7 @@
{
<div class="card-footer d-flex justify-content-between align-items-center">
<div class="text-muted small">
Showing @((pageNumber - 1) * pageSize + 1)@(Math.Min(pageNumber * pageSize, totalCount)) of @totalCount companies
Showing @((pageNumber - 1) * pageSize + 1)&ndash;@(Math.Min(pageNumber * pageSize, totalCount)) of @totalCount companies
</div>
<div class="d-flex align-items-center gap-3">
<div>
@@ -448,7 +448,7 @@
<h6 class="fw-bold text-danger"><i class="bi bi-fire me-2"></i>Hard Delete (Permanent)</h6>
<p class="text-muted small mb-2">
<strong class="text-danger">This cannot be undone.</strong>
All company data users, jobs, quotes, customers, invoices, and everything else will be
All company data &mdash; users, jobs, quotes, customers, invoices, and everything else &mdash; will be
<strong>permanently and irreversibly deleted</strong> from the database.
</p>
<div class="alert alert-danger alert-permanent py-2 mb-3">
@@ -198,7 +198,7 @@
<form method="get" class="row g-2 align-items-end">
<div class="col-md-4">
<input name="search" value="@ViewBag.Search" class="form-control form-control-sm"
placeholder="Company name or email" />
placeholder="Company name or email&hellip;" />
</div>
<div class="col-md-3">
<select name="risk" class="form-select form-select-sm">
@@ -274,7 +274,7 @@
<td>
@if (h.RiskLevel == ChurnRisk.NeverActivated)
{
<span class="text-muted"></span>
<span class="text-muted">&mdash;</span>
}
else
{
@@ -338,7 +338,7 @@
</table>
</div>
<!-- Mobile card view shown on screens < 992px -->
<!-- Mobile card view &mdash; shown on screens < 992px -->
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var h in Model)
@@ -394,7 +394,7 @@
@if (!Model.Any(h => h.RiskLevel != ChurnRisk.Healthy) && Model.Any())
{
<div class="card-footer text-center py-3 text-success">
<i class="bi bi-check-circle-fill me-2"></i>All tenants are healthy no churn signals detected.
<i class="bi bi-check-circle-fill me-2"></i>All tenants are healthy &mdash; no churn signals detected.
</div>
}
</div>
@@ -53,7 +53,7 @@
<ul class="list-unstyled mb-3">
<li class="mb-2">
<i class="bi bi-person-x-fill text-danger me-2"></i>
<strong>@userCount user account@(userCount != 1 ? "s" : "")</strong> will be deactivated no one will be able to log in.
<strong>@userCount user account@(userCount != 1 ? "s" : "")</strong> will be deactivated &mdash; no one will be able to log in.
</li>
<li class="mb-2">
<i class="bi bi-briefcase-fill text-danger me-2"></i>
@@ -90,13 +90,13 @@
<div class="card-body">
<ul class="mb-0">
<li class="mb-1">
<strong>Pause instead of delete</strong> Contact support to temporarily suspend your account.
<strong>Pause instead of delete</strong> &mdash; Contact support to temporarily suspend your account.
</li>
<li class="mb-1">
<strong>Cancel your subscription</strong> Stop future billing without deleting your data.
<strong>Cancel your subscription</strong> &mdash; Stop future billing without deleting your data.
</li>
<li>
<strong>Export your data first</strong> Go to
<strong>Export your data first</strong> &mdash; Go to
<a asp-controller="Reports" asp-action="Index">Reports</a>
to export jobs, customers, invoices, and more before proceeding.
</li>
@@ -146,7 +146,7 @@
<i class="bi bi-trash3 me-1"></i>Delete My Account Permanently
</button>
<a asp-controller="CompanySettings" asp-action="Index" class="btn btn-outline-secondary px-4">
Cancel Keep My Account
Cancel &mdash; Keep My Account
</a>
</div>
</form>
@@ -176,7 +176,7 @@
// Extra guard: prevent accidental double-submit after the form is submitted.
document.getElementById('deleteAccountForm').addEventListener('submit', function () {
deleteBtn.disabled = true;
deleteBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Deleting';
deleteBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Deleting&hellip;';
});
})();
</script>
@@ -1,6 +1,6 @@
@model PowderCoating.Application.DTOs.Notification.NotificationTemplateDto
@{
ViewData["Title"] = $"Edit Template {Model.DisplayName}";
ViewData["Title"] = $"Edit Template &mdash; {Model.DisplayName}";
ViewData["PageIcon"] = "bi-envelope-gear";
var placeholders = ViewBag.Placeholders as List<(string Placeholder, string Description)>
?? new List<(string, string)>();
@@ -70,7 +70,7 @@
@if (isEmail)
{
<!-- Raw HTML textarea for email supports {{placeholders}} and full HTML -->
<!-- Raw HTML textarea for email &mdash; supports {{placeholders}} and full HTML -->
<textarea asp-for="Body" class="form-control font-monospace" rows="16"
placeholder="Enter HTML email body..."></textarea>
<div class="form-text text-muted mt-1">
@@ -131,7 +131,7 @@
<div>
<span class="badge bg-light text-dark border placeholder-pill px-2 py-1"
onclick="copyPlaceholder('@placeholder', this)"
title="@description click to copy">
title="@description &mdash; click to copy">
@placeholder
</span>
<span class="copy-feedback ms-1">Copied!</span>
@@ -1,4 +1,4 @@
@model PowderCoating.Application.DTOs.Company.CompanySettingsDto
@model PowderCoating.Application.DTOs.Company.CompanySettingsDto
@{
ViewData["Title"] = "Company Settings";
ViewData["PageIcon"] = "bi-building";
@@ -118,7 +118,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Company Information"
data-bs-content="This information appears on every customer-facing document quotes, invoices, and PDFs. Keep the company name, address, and email accurate so customers see the right details. The &lt;strong&gt;Primary Contact Email&lt;/strong&gt; is used as the reply-to address on all outgoing notifications.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Settings#company-information' target='_blank'&gt;Learn more →&lt;/a&gt;">
data-bs-content="This information appears on every customer-facing document &mdash; quotes, invoices, and PDFs. Keep the company name, address, and email accurate so customers see the right details. The &lt;strong&gt;Primary Contact Email&lt;/strong&gt; is used as the reply-to address on all outgoing notifications.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Settings#company-information' target='_blank'&gt;Learn more →&lt;/a&gt;">
<i class="bi bi-question-circle"></i>
</a>
</h5>
@@ -165,39 +165,39 @@
<label for="timeZone" class="form-label">Time Zone</label>
<select class="form-select" id="timeZone" name="TimeZone">
<optgroup label="United States">
<option value="America/New_York" selected="@(Model.TimeZone == "America/New_York" ? "selected" : null)">Eastern (ET) New York</option>
<option value="America/Chicago" selected="@(Model.TimeZone == "America/Chicago" ? "selected" : null)">Central (CT) Chicago</option>
<option value="America/Denver" selected="@(Model.TimeZone == "America/Denver" ? "selected" : null)">Mountain (MT) Denver</option>
<option value="America/Phoenix" selected="@(Model.TimeZone == "America/Phoenix" ? "selected" : null)">Mountain no-DST Phoenix</option>
<option value="America/Los_Angeles" selected="@(Model.TimeZone == "America/Los_Angeles" ? "selected" : null)">Pacific (PT) Los Angeles</option>
<option value="America/Anchorage" selected="@(Model.TimeZone == "America/Anchorage" ? "selected" : null)">Alaska (AKT) Anchorage</option>
<option value="Pacific/Honolulu" selected="@(Model.TimeZone == "Pacific/Honolulu" ? "selected" : null)">Hawaii (HT) Honolulu</option>
<option value="America/New_York" selected="@(Model.TimeZone == "America/New_York" ? "selected" : null)">Eastern (ET) &mdash; New York</option>
<option value="America/Chicago" selected="@(Model.TimeZone == "America/Chicago" ? "selected" : null)">Central (CT) &mdash; Chicago</option>
<option value="America/Denver" selected="@(Model.TimeZone == "America/Denver" ? "selected" : null)">Mountain (MT) &mdash; Denver</option>
<option value="America/Phoenix" selected="@(Model.TimeZone == "America/Phoenix" ? "selected" : null)">Mountain no-DST &mdash; Phoenix</option>
<option value="America/Los_Angeles" selected="@(Model.TimeZone == "America/Los_Angeles" ? "selected" : null)">Pacific (PT) &mdash; Los Angeles</option>
<option value="America/Anchorage" selected="@(Model.TimeZone == "America/Anchorage" ? "selected" : null)">Alaska (AKT) &mdash; Anchorage</option>
<option value="Pacific/Honolulu" selected="@(Model.TimeZone == "Pacific/Honolulu" ? "selected" : null)">Hawaii (HT) &mdash; Honolulu</option>
</optgroup>
<optgroup label="Canada">
<option value="America/Halifax" selected="@(Model.TimeZone == "America/Halifax" ? "selected" : null)">Atlantic (AT) Halifax</option>
<option value="America/Toronto" selected="@(Model.TimeZone == "America/Toronto" ? "selected" : null)">Eastern Toronto</option>
<option value="America/Winnipeg" selected="@(Model.TimeZone == "America/Winnipeg" ? "selected" : null)">Central Winnipeg</option>
<option value="America/Edmonton" selected="@(Model.TimeZone == "America/Edmonton" ? "selected" : null)">Mountain Edmonton</option>
<option value="America/Vancouver" selected="@(Model.TimeZone == "America/Vancouver" ? "selected" : null)">Pacific Vancouver</option>
<option value="America/Halifax" selected="@(Model.TimeZone == "America/Halifax" ? "selected" : null)">Atlantic (AT) &mdash; Halifax</option>
<option value="America/Toronto" selected="@(Model.TimeZone == "America/Toronto" ? "selected" : null)">Eastern &mdash; Toronto</option>
<option value="America/Winnipeg" selected="@(Model.TimeZone == "America/Winnipeg" ? "selected" : null)">Central &mdash; Winnipeg</option>
<option value="America/Edmonton" selected="@(Model.TimeZone == "America/Edmonton" ? "selected" : null)">Mountain &mdash; Edmonton</option>
<option value="America/Vancouver" selected="@(Model.TimeZone == "America/Vancouver" ? "selected" : null)">Pacific &mdash; Vancouver</option>
</optgroup>
<optgroup label="Europe">
<option value="Europe/London" selected="@(Model.TimeZone == "Europe/London" ? "selected" : null)">GMT/BST London</option>
<option value="Europe/Paris" selected="@(Model.TimeZone == "Europe/Paris" ? "selected" : null)">CET Paris / Berlin</option>
<option value="Europe/Helsinki" selected="@(Model.TimeZone == "Europe/Helsinki" ? "selected" : null)">EET Helsinki</option>
<option value="Europe/Moscow" selected="@(Model.TimeZone == "Europe/Moscow" ? "selected" : null)">MSK Moscow</option>
<option value="Europe/London" selected="@(Model.TimeZone == "Europe/London" ? "selected" : null)">GMT/BST &mdash; London</option>
<option value="Europe/Paris" selected="@(Model.TimeZone == "Europe/Paris" ? "selected" : null)">CET &mdash; Paris / Berlin</option>
<option value="Europe/Helsinki" selected="@(Model.TimeZone == "Europe/Helsinki" ? "selected" : null)">EET &mdash; Helsinki</option>
<option value="Europe/Moscow" selected="@(Model.TimeZone == "Europe/Moscow" ? "selected" : null)">MSK &mdash; Moscow</option>
</optgroup>
<optgroup label="Asia / Pacific">
<option value="Asia/Dubai" selected="@(Model.TimeZone == "Asia/Dubai" ? "selected" : null)">GST Dubai</option>
<option value="Asia/Kolkata" selected="@(Model.TimeZone == "Asia/Kolkata" ? "selected" : null)">IST India</option>
<option value="Asia/Bangkok" selected="@(Model.TimeZone == "Asia/Bangkok" ? "selected" : null)">ICT Bangkok</option>
<option value="Asia/Shanghai" selected="@(Model.TimeZone == "Asia/Shanghai" ? "selected" : null)">CST Beijing / Shanghai</option>
<option value="Asia/Tokyo" selected="@(Model.TimeZone == "Asia/Tokyo" ? "selected" : null)">JST Tokyo</option>
<option value="Australia/Sydney" selected="@(Model.TimeZone == "Australia/Sydney" ? "selected" : null)">AEST Sydney</option>
<option value="Pacific/Auckland" selected="@(Model.TimeZone == "Pacific/Auckland" ? "selected" : null)">NZST Auckland</option>
<option value="Asia/Dubai" selected="@(Model.TimeZone == "Asia/Dubai" ? "selected" : null)">GST &mdash; Dubai</option>
<option value="Asia/Kolkata" selected="@(Model.TimeZone == "Asia/Kolkata" ? "selected" : null)">IST &mdash; India</option>
<option value="Asia/Bangkok" selected="@(Model.TimeZone == "Asia/Bangkok" ? "selected" : null)">ICT &mdash; Bangkok</option>
<option value="Asia/Shanghai" selected="@(Model.TimeZone == "Asia/Shanghai" ? "selected" : null)">CST &mdash; Beijing / Shanghai</option>
<option value="Asia/Tokyo" selected="@(Model.TimeZone == "Asia/Tokyo" ? "selected" : null)">JST &mdash; Tokyo</option>
<option value="Australia/Sydney" selected="@(Model.TimeZone == "Australia/Sydney" ? "selected" : null)">AEST &mdash; Sydney</option>
<option value="Pacific/Auckland" selected="@(Model.TimeZone == "Pacific/Auckland" ? "selected" : null)">NZST &mdash; Auckland</option>
</optgroup>
<optgroup label="South America">
<option value="America/Sao_Paulo" selected="@(Model.TimeZone == "America/Sao_Paulo" ? "selected" : null)">BRT São Paulo</option>
<option value="America/Buenos_Aires" selected="@(Model.TimeZone == "America/Buenos_Aires" ? "selected" : null)">ART Buenos Aires</option>
<option value="America/Sao_Paulo" selected="@(Model.TimeZone == "America/Sao_Paulo" ? "selected" : null)">BRT &mdash; São Paulo</option>
<option value="America/Buenos_Aires" selected="@(Model.TimeZone == "America/Buenos_Aires" ? "selected" : null)">ART &mdash; Buenos Aires</option>
</optgroup>
<optgroup label="UTC">
<option value="UTC" selected="@(Model.TimeZone == "UTC" ? "selected" : null)">UTC</option>
@@ -243,7 +243,7 @@
}
else
{
<div class="text-muted small mb-2">No period lock set all dates are open.</div>
<div class="text-muted small mb-2">No period lock set &mdash; all dates are open.</div>
}
</div>
<div class="col-md-4">
@@ -353,7 +353,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Operating Costs"
data-bs-content="These are the rates the quoting engine uses to price every job automatically. Set them to your real shop costs and the system will produce accurate quotes without manual calculation. &lt;strong&gt;New quotes use the current rates&lt;/strong&gt; changing a rate here does not retroactively reprice existing quotes.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Settings#pricing-configuration' target='_blank'&gt;Learn more →&lt;/a&gt;">
data-bs-content="These are the rates the quoting engine uses to price every job automatically. Set them to your real shop costs and the system will produce accurate quotes without manual calculation. &lt;strong&gt;New quotes use the current rates&lt;/strong&gt; &mdash; changing a rate here does not retroactively reprice existing quotes.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Settings#pricing-configuration' target='_blank'&gt;Learn more →&lt;/a&gt;">
<i class="bi bi-question-circle"></i>
</a>
</h5>
@@ -368,7 +368,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Rates &amp; Costs"
data-bs-content="&lt;strong&gt;Standard Labor Rate&lt;/strong&gt; is the baseline $/hr for all coating work sandblasting and masking are multiplied from this. &lt;strong&gt;Powder Coating Cost/sq ft&lt;/strong&gt; is the fallback material rate used when you don't select a specific powder inventory item on a quote item. &lt;strong&gt;Additional Coat Labor&lt;/strong&gt; is the percentage of the base labor cost charged for each coat after the first (e.g. 30% means a 2nd coat adds 30% more labor).">
data-bs-content="&lt;strong&gt;Standard Labor Rate&lt;/strong&gt; is the baseline $/hr for all coating work &mdash; sandblasting and masking are multiplied from this. &lt;strong&gt;Powder Coating Cost/sq ft&lt;/strong&gt; is the fallback material rate used when you don't select a specific powder inventory item on a quote item. &lt;strong&gt;Additional Coat Labor&lt;/strong&gt; is the percentage of the base labor cost charged for each coat after the first (e.g. 30% means a 2nd coat adds 30% more labor).">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -506,7 +506,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Equipment Operating Costs"
data-bs-content="The hourly cost of running each piece of equipment, including energy and depreciation. These are added to quote items based on the prep services selected. The &lt;strong&gt;Default Oven Rate&lt;/strong&gt; is used on quotes where no named oven is chosen add individual shop ovens below if you have multiple ovens with different capacities and costs.">
data-bs-content="The hourly cost of running each piece of equipment, including energy and depreciation. These are added to quote items based on the prep services selected. The &lt;strong&gt;Default Oven Rate&lt;/strong&gt; is used on quotes where no named oven is chosen &mdash; add individual shop ovens below if you have multiple ovens with different capacities and costs.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -554,7 +554,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Pricing &amp; Profit"
data-bs-content="&lt;strong&gt;Markup mode&lt;/strong&gt; adds a % on top of material costs only (labor and equipment pass through at cost). &lt;strong&gt;Margin mode&lt;/strong&gt; targets a gross margin % of the total selling price e.g. 30% margin on a $100 cost base gives a $142.86 price. Note: margin % and markup % are not the same number. &lt;strong&gt;Shop Minimum&lt;/strong&gt; sets a floor price for any job.">
data-bs-content="&lt;strong&gt;Markup mode&lt;/strong&gt; adds a % on top of material costs only (labor and equipment pass through at cost). &lt;strong&gt;Margin mode&lt;/strong&gt; targets a gross margin % of the total selling price &mdash; e.g. 30% margin on a $100 cost base gives a $142.86 price. Note: margin % and markup % are not the same number. &lt;strong&gt;Shop Minimum&lt;/strong&gt; sets a floor price for any job.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -678,7 +678,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Part Complexity Multipliers"
data-bs-content="A percentage added to the price of &lt;strong&gt;calculated items&lt;/strong&gt; based on how intricate the part is. When adding an item in a quote, staff select a complexity level the system then applies this multiplier to account for the extra time and care needed. &lt;em&gt;Simple&lt;/em&gt; = 0% (flat panels, basic shapes). &lt;em&gt;Extreme&lt;/em&gt; = highly detailed, tight recesses, masking-intensive parts.">
data-bs-content="A percentage added to the price of &lt;strong&gt;calculated items&lt;/strong&gt; based on how intricate the part is. When adding an item in a quote, staff select a complexity level &mdash; the system then applies this multiplier to account for the extra time and care needed. &lt;em&gt;Simple&lt;/em&gt; = 0% (flat panels, basic shapes). &lt;em&gt;Extreme&lt;/em&gt; = highly detailed, tight recesses, masking-intensive parts.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -775,11 +775,11 @@
</div>
<div class="d-flex align-items-center gap-1 ms-auto">
<span class="text-muted">=</span>
<span id="ovenDimResult" class="fw-semibold small text-primary" style="min-width:65px;"></span>
<span id="ovenDimResult" class="fw-semibold small text-primary" style="min-width:65px;">&mdash;</span>
<button type="button" class="btn btn-sm btn-outline-primary" id="ovenDimApply" disabled>Use</button>
</div>
</div>
<div class="text-muted mt-1" style="font-size:.72rem;">W × D × H of oven interior 20% deducted for rack &amp; wall depth</div>
<div class="text-muted mt-1" style="font-size:.72rem;">W × D × H of oven interior &mdash; 20% deducted for rack &amp; wall depth</div>
</div>
<div class="mb-3">
<label for="ovenOrderInput" class="form-label">Display Order</label>
@@ -810,7 +810,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="AI Photo Quote Profile"
data-bs-content="Describe your shop's specialties, typical items, and pricing style in plain language. This text is injected into the AI's system prompt every time a photo quote is analysed the more specific you are, the better calibrated the estimates will be for your business. You can also describe anything the AI tends to get wrong. &lt;br&gt;&lt;br&gt;&lt;strong&gt;Additionally&lt;/strong&gt;, the AI automatically learns from quotes your team accepted without overriding those become calibration examples that improve accuracy over time.">
data-bs-content="Describe your shop's specialties, typical items, and pricing style in plain language. This text is injected into the AI's system prompt every time a photo quote is analysed &mdash; the more specific you are, the better calibrated the estimates will be for your business. You can also describe anything the AI tends to get wrong. &lt;br&gt;&lt;br&gt;&lt;strong&gt;Additionally&lt;/strong&gt;, the AI automatically learns from quotes your team accepted without overriding &mdash; those become calibration examples that improve accuracy over time.">
<i class="bi bi-question-circle"></i>
</a>
</h5>
@@ -821,9 +821,9 @@
<div class="mb-3">
<label for="aiContextProfile" class="form-label fw-semibold">Shop Description</label>
<textarea id="aiContextProfile" class="form-control" rows="8" maxlength="2000"
placeholder="Examples:&#10;• We specialise in automotive restoration wheels, frames, suspension brackets, and roll cages are our bread and butter.&#10;• Our customers expect premium pricing. We rarely work on items over 20 sqft.&#10;• Most items come to us already stripped; sandblasting adds roughly 15 min per item on average.&#10;• We use a 2-stage cure cycle pre-heat 10 min, coat, cure 20 min at 400°F.">@(Model.OperatingCosts?.AiContextProfile)</textarea>
placeholder="Examples:&#10;• We specialise in automotive restoration &mdash; wheels, frames, suspension brackets, and roll cages are our bread and butter.&#10;• Our customers expect premium pricing. We rarely work on items over 20 sqft.&#10;• Most items come to us already stripped; sandblasting adds roughly 15 min per item on average.&#10;• We use a 2-stage cure cycle &mdash; pre-heat 10 min, coat, cure 20 min at 400°F.">@(Model.OperatingCosts?.AiContextProfile)</textarea>
<div class="d-flex justify-content-between mt-1">
<small class="text-muted">Plain language write it as if briefing a new estimator on your shop.</small>
<small class="text-muted">Plain language &mdash; write it as if briefing a new estimator on your shop.</small>
<small class="text-muted"><span id="aiProfileCharCount">@(Model.OperatingCosts?.AiContextProfile?.Length ?? 0)</span>/2000</small>
</div>
</div>
@@ -832,7 +832,7 @@
<i class="bi bi-floppy me-1"></i> Save AI Profile
</button>
<button type="button" class="btn btn-outline-secondary" id="btnGenerateAiDraft"
title="Build a suggested profile from your existing settings ovens, workers, inventory categories, and rates">
title="Build a suggested profile from your existing settings &mdash; ovens, workers, inventory categories, and rates">
<i class="bi bi-stars me-1"></i> Generate from my settings
</button>
<span id="aiProfileStatus" class="small"></span>
@@ -843,9 +843,9 @@
<div class="card bg-light border-0">
<div class="card-body">
<h6 class="card-title"><i class="bi bi-lightbulb text-warning me-1"></i> How AI Learning Works</h6>
<p class="small mb-2"><strong>Layer 1 Pricing config:</strong> Your operating costs (labor, equipment, markup) are always injected automatically.</p>
<p class="small mb-2"><strong>Layer 2 Your shop profile:</strong> The description you write here is added to every AI analysis, guiding estimates toward your typical work.</p>
<p class="small mb-0"><strong>Layer 3 Automatic learning:</strong> Each time your team accepts an AI estimate without changing it, that item is silently added as a calibration example. The AI improves on its own the more you use it.</p>
<p class="small mb-2"><strong>Layer 1 &mdash; Pricing config:</strong> Your operating costs (labor, equipment, markup) are always injected automatically.</p>
<p class="small mb-2"><strong>Layer 2 &mdash; Your shop profile:</strong> The description you write here is added to every AI analysis, guiding estimates toward your typical work.</p>
<p class="small mb-0"><strong>Layer 3 &mdash; Automatic learning:</strong> Each time your team accepts an AI estimate without changing it, that item is silently added as a calibration example. The AI improves on its own the more you use it.</p>
</div>
</div>
</div>
@@ -874,10 +874,10 @@
<div class="mb-3">
<label class="form-label fw-medium">Shop Size</label>
<select class="form-select" id="shopCapabilityTier" style="max-width:320px">
<option value="0" selected="@(tierVal == 0 ? "selected" : null)">Garage Home setup, part-time</option>
<option value="1" selected="@(tierVal == 1 ? "selected" : null)">Small — 15 person shop</option>
<option value="2" selected="@(tierVal == 2 ? "selected" : null)">Medium Established shop, 510 people</option>
<option value="3" selected="@(tierVal == 3 ? "selected" : null)">Large High-volume, 10+ people</option>
<option value="0" selected="@(tierVal == 0 ? "selected" : null)">Garage &mdash; Home setup, part-time</option>
<option value="1" selected="@(tierVal == 1 ? "selected" : null)">Small &mdash; 1&ndash;5 person shop</option>
<option value="2" selected="@(tierVal == 2 ? "selected" : null)">Medium &mdash; Established shop, 5&ndash;10 people</option>
<option value="3" selected="@(tierVal == 3 ? "selected" : null)">Large &mdash; High-volume, 10+ people</option>
</select>
<div class="form-text">Used by the AI when estimating job complexity and throughput.</div>
</div>
@@ -978,11 +978,11 @@
<div class="mb-3">
<label class="form-label">Default Currency</label>
<select class="form-select" id="defaultCurrency" name="DefaultCurrency">
<option value="USD" selected="@(Model.Preferences?.DefaultCurrency == "USD" ? "selected" : null)">USD US Dollar</option>
<option value="CAD" selected="@(Model.Preferences?.DefaultCurrency == "CAD" ? "selected" : null)">CAD Canadian Dollar</option>
<option value="EUR" selected="@(Model.Preferences?.DefaultCurrency == "EUR" ? "selected" : null)">EUR Euro</option>
<option value="GBP" selected="@(Model.Preferences?.DefaultCurrency == "GBP" ? "selected" : null)">GBP British Pound</option>
<option value="AUD" selected="@(Model.Preferences?.DefaultCurrency == "AUD" ? "selected" : null)">AUD Australian Dollar</option>
<option value="USD" selected="@(Model.Preferences?.DefaultCurrency == "USD" ? "selected" : null)">USD &mdash; US Dollar</option>
<option value="CAD" selected="@(Model.Preferences?.DefaultCurrency == "CAD" ? "selected" : null)">CAD &mdash; Canadian Dollar</option>
<option value="EUR" selected="@(Model.Preferences?.DefaultCurrency == "EUR" ? "selected" : null)">EUR &mdash; Euro</option>
<option value="GBP" selected="@(Model.Preferences?.DefaultCurrency == "GBP" ? "selected" : null)">GBP &mdash; British Pound</option>
<option value="AUD" selected="@(Model.Preferences?.DefaultCurrency == "AUD" ? "selected" : null)">AUD &mdash; Australian Dollar</option>
</select>
</div>
</div>
@@ -1046,7 +1046,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Number Prefixes"
data-bs-content="The prefix is combined with a date stamp and sequence number to form record IDs for example prefix &lt;strong&gt;QT&lt;/strong&gt; produces &lt;em&gt;QT-2603-0042&lt;/em&gt;. Change the prefix to match your preferred numbering convention. Changing it only affects &lt;strong&gt;new&lt;/strong&gt; records; existing numbers are not renamed.">
data-bs-content="The prefix is combined with a date stamp and sequence number to form record IDs &mdash; for example prefix &lt;strong&gt;QT&lt;/strong&gt; produces &lt;em&gt;QT-2603-0042&lt;/em&gt;. Change the prefix to match your preferred numbering convention. Changing it only affects &lt;strong&gt;new&lt;/strong&gt; records; existing numbers are not renamed.">
<i class="bi bi-question-circle"></i>
</a>
</h6>
@@ -1082,7 +1082,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Job &amp; Workflow Defaults"
data-bs-content="Controls how jobs are created and flow through your shop. &lt;strong&gt;Require Customer PO&lt;/strong&gt; enforces that a PO number is entered before a job can be saved useful for commercial accounts. &lt;strong&gt;Allow Customer Approval&lt;/strong&gt; enables the approval step in the job workflow when a quote is approved, the job moves to an Approved status before work begins.">
data-bs-content="Controls how jobs are created and flow through your shop. &lt;strong&gt;Require Customer PO&lt;/strong&gt; enforces that a PO number is entered before a job can be saved &mdash; useful for commercial accounts. &lt;strong&gt;Allow Customer Approval&lt;/strong&gt; enables the approval step in the job workflow &mdash; when a quote is approved, the job moves to an Approved status before work begins.">
<i class="bi bi-question-circle"></i>
</a>
</h5>
@@ -1141,7 +1141,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Notifications &amp; Alerts"
data-bs-content="Controls which events send emails to your team and customers. Set the &lt;strong&gt;From Email Address&lt;/strong&gt; to a domain you control using a domain-verified address prevents emails landing in spam. If left blank, the system default address is used. Turn off notification types you don't need to avoid inbox noise.">
data-bs-content="Controls which events send emails to your team and customers. Set the &lt;strong&gt;From Email Address&lt;/strong&gt; to a domain you control &mdash; using a domain-verified address prevents emails landing in spam. If left blank, the system default address is used. Turn off notification types you don't need to avoid inbox noise.">
<i class="bi bi-question-circle"></i>
</a>
</h5>
@@ -1311,7 +1311,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Notification Templates"
data-bs-content="Customise the subject and body of every automated email sent by the system job status updates, quote approvals, invoice reminders, and more. Templates use &lt;strong&gt;&#123;&#123;placeholder&#125;&#125;&lt;/strong&gt; tokens that are replaced with live data when the email is sent. Click &lt;strong&gt;Edit&lt;/strong&gt; on any row to modify it; use &lt;strong&gt;Reset to Default&lt;/strong&gt; to restore the original wording at any time.&lt;br&gt;&lt;br&gt;Changes take effect immediately the next triggered notification will use the updated template.">
data-bs-content="Customise the subject and body of every automated email sent by the system &mdash; job status updates, quote approvals, invoice reminders, and more. Templates use &lt;strong&gt;&#123;&#123;placeholder&#125;&#125;&lt;/strong&gt; tokens that are replaced with live data when the email is sent. Click &lt;strong&gt;Edit&lt;/strong&gt; on any row to modify it; use &lt;strong&gt;Reset to Default&lt;/strong&gt; to restore the original wording at any time.&lt;br&gt;&lt;br&gt;Changes take effect immediately &mdash; the next triggered notification will use the updated template.">
<i class="bi bi-question-circle"></i>
</a>
</h5>
@@ -1371,7 +1371,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Data Retention"
data-bs-content="Controls how long records are kept. Most businesses set quote and job retention to &lt;strong&gt;7 years&lt;/strong&gt; to satisfy tax and audit requirements. &lt;strong&gt;Deleted record retention&lt;/strong&gt; is the grace period after a soft-delete before the record is permanently purged useful if someone accidentally deletes something.">
data-bs-content="Controls how long records are kept. Most businesses set quote and job retention to &lt;strong&gt;7 years&lt;/strong&gt; to satisfy tax and audit requirements. &lt;strong&gt;Deleted record retention&lt;/strong&gt; is the grace period after a soft-delete before the record is permanently purged &mdash; useful if someone accidentally deletes something.">
<i class="bi bi-question-circle"></i>
</a>
</h5>
@@ -1433,7 +1433,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Data Lookups"
data-bs-content="Lookups are the dropdown options that appear throughout the app job statuses, priorities, quote statuses, and more. You can rename labels, change colours, and reorder them to match your shop's terminology. &lt;strong&gt;Status codes&lt;/strong&gt; drive workflow logic and should not be changed unless you understand the impact.">
data-bs-content="Lookups are the dropdown options that appear throughout the app &mdash; job statuses, priorities, quote statuses, and more. You can rename labels, change colours, and reorder them to match your shop's terminology. &lt;strong&gt;Status codes&lt;/strong&gt; drive workflow logic and should not be changed unless you understand the impact.">
<i class="bi bi-question-circle"></i>
</a>
</h5>
@@ -1869,7 +1869,7 @@
<textarea id="woTerms" class="form-control" rows="5" maxlength="2000"
placeholder="e.g. *Products must be picked up within 5 days of notification of completion or a storage fee may apply."
>@(Model.Preferences?.WoTerms ?? "")</textarea>
<div class="form-text">Printed in italic at the bottom of every blank work order. Supports plain text use * or ** for visual emphasis.</div>
<div class="form-text">Printed in italic at the bottom of every blank work order. Supports plain text &mdash; use * or ** for visual emphasis.</div>
</div>
<div class="d-flex gap-2 align-items-center">
@@ -2168,7 +2168,7 @@
<div class="modal-content">
<div class="modal-header bg-primary text-white">
<h5 class="modal-title" id="smsTermsModalLabel">
<i class="bi bi-phone me-2"></i>SMS Notifications Terms of Service
<i class="bi bi-phone me-2"></i>SMS Notifications &mdash; Terms of Service
</h5>
</div>
<div class="modal-body">
@@ -2178,9 +2178,9 @@
</div>
<h6 class="fw-bold">1. Prior Express Written Consent Required</h6>
<p class="text-muted small">You <strong>must obtain clear, documented consent</strong> from each customer before sending them any SMS message. This means each customer must have explicitly agreed in writing or through a recorded digital interaction that they wish to receive text messages from your business. Enabling this feature is not consent on their behalf. You must collect and record their authorization individually, before enabling SMS for their account in this system.</p>
<p class="text-muted small">You <strong>must obtain clear, documented consent</strong> from each customer before sending them any SMS message. This means each customer must have explicitly agreed &mdash; in writing or through a recorded digital interaction &mdash; that they wish to receive text messages from your business. Enabling this feature is not consent on their behalf. You must collect and record their authorization individually, before enabling SMS for their account in this system.</p>
<h6 class="fw-bold">2. Federal Law Governs SMS Fines Are Real</h6>
<h6 class="fw-bold">2. Federal Law Governs SMS &mdash; Fines Are Real</h6>
<p class="text-muted small">The <strong>Telephone Consumer Protection Act (TCPA)</strong>, enforced by the Federal Communications Commission (FCC), imposes fines of <strong>$500 to $1,500 per individual message</strong> sent without proper authorization. These fines apply per text, not per customer. A single campaign to 100 unconsented recipients could result in exposure of $50,000 to $150,000. The FCC and private plaintiffs both actively pursue TCPA violations.</p>
<h6 class="fw-bold">3. Opt-Out Requests Must Be Honored Immediately</h6>
@@ -2189,7 +2189,7 @@
<h6 class="fw-bold">4. Message Rates &amp; Content Restrictions</h6>
<p class="text-muted small">Every message sent must include your business name and an opt-out reminder (e.g., "Reply STOP to opt out"). Messages must be directly relevant to the service the customer consented to receive and must not contain solicitations, promotions, or third-party offers unless the customer has separately consented to those.</p>
<h6 class="fw-bold">5. Your Responsibility Not Ours</h6>
<h6 class="fw-bold">5. Your Responsibility &mdash; Not Ours</h6>
<p class="text-muted small">Powder Coating Logix provides this feature as a communication tool only. <strong>We are not responsible for how you use it.</strong> You agree that your company is solely responsible for obtaining proper consent, maintaining records of that consent, honoring opt-outs, and ensuring all outbound messages comply with the TCPA, FCC regulations, and any applicable state laws. You agree to indemnify and hold Powder Coating Logix harmless from any claims, fines, or damages arising from your company's use of SMS.</p>
<hr />
@@ -2201,7 +2201,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" id="smsTermsDeclineBtn">Cancel Keep SMS Disabled</button>
<button type="button" class="btn btn-secondary" id="smsTermsDeclineBtn">Cancel &mdash; Keep SMS Disabled</button>
<button type="button" class="btn btn-primary" id="smsTermsAcceptBtn" disabled>
<i class="bi bi-check-circle me-1"></i>I Agree &amp; Enable SMS
</button>
@@ -2469,7 +2469,7 @@
});
});
// AI Profile char counter and save (elements only exist when AiPhotoQuotesEnabled)
// AI Profile &mdash; char counter and save (elements only exist when AiPhotoQuotesEnabled)
$('#aiContextProfile').on('input', function () {
$('#aiProfileCharCount').text($(this).val().length);
});
@@ -2511,7 +2511,7 @@
if (response.success) {
$('#aiContextProfile').val(response.draft);
$('#aiProfileCharCount').text(response.draft.length);
showToast('info', 'Draft generated review and edit it, then click Save AI Profile.');
showToast('info', 'Draft generated &mdash; review and edit it, then click Save AI Profile.');
} else {
showToast('error', response.message);
}
@@ -2525,7 +2525,7 @@
});
});
// Quoting Calibration save
// Quoting Calibration &mdash; save
$('#saveBlastProfile').on('click', function () {
var btn = $(this);
btn.prop('disabled', true).html('<span class="spinner-border spinner-border-sm"></span> Saving...');
@@ -2631,7 +2631,7 @@
};
}, 'Save Retention Policy');
// SMS toggle shows terms modal on first enable (or after terms version change)
// SMS toggle &mdash; shows terms modal on first enable (or after terms version change)
(function () {
const toggle = document.getElementById('smsEnabledToggle');
if (!toggle) return;
@@ -2737,7 +2737,7 @@
<script src="~/js/company-settings-lookups-modals.js" asp-append-version="true"></script>
<script>
// ── Oven Costs Management ──────────────────────────────────────────────
// Cache element references once modal is now outside all forms
// Cache element references once &mdash; modal is now outside all forms
const _ovenModal = new bootstrap.Modal(document.getElementById('ovenModal'));
const _ovenTitle = document.getElementById('ovenModalTitle');
@@ -2765,7 +2765,7 @@
if (!data.success) throw new Error(data.message);
renderOvenTable(data.data);
_ovenCount.textContent = data.data.length === 0
? 'No shop ovens default rate will be used on all quotes.'
? 'No shop ovens &mdash; default rate will be used on all quotes.'
: `${data.data.length} oven(s) configured`;
} catch (e) {
_ovenBody.innerHTML = `<tr><td colspan="6" class="text-center text-danger">Failed to load ovens: ${escHtml(e.message)}</td></tr>`;
@@ -2783,7 +2783,7 @@
<tr>
<td><strong>${escHtml(o.label)}</strong></td>
<td>$${parseFloat(o.costPerHour).toFixed(2)}/hr</td>
<td class="text-muted small">${o.maxLoadSqFt != null ? parseFloat(o.maxLoadSqFt).toFixed(0) + ' sqft' : ''}</td>
<td class="text-muted small">${o.maxLoadSqFt != null ? parseFloat(o.maxLoadSqFt).toFixed(0) + ' sqft' : '&mdash;'}</td>
<td>${o.displayOrder}</td>
<td>${o.isActive
? '<span class="badge bg-success">Active</span>'
@@ -2887,7 +2887,7 @@
document.getElementById('ovenCalcToggle').addEventListener('click', function (e) {
e.preventDefault();
const hidden = _calcPanel.classList.toggle('d-none');
if (!hidden) { _calcW.value = ''; _calcD.value = ''; _calcH.value = ''; _calcResult.textContent = ''; _calcApply.disabled = true; _calcW.focus(); }
if (!hidden) { _calcW.value = ''; _calcD.value = ''; _calcH.value = ''; _calcResult.textContent = '&mdash;'; _calcApply.disabled = true; _calcW.focus(); }
});
function _updateCalc() {
@@ -2907,7 +2907,7 @@
_calcApply.disabled = false;
_calcApply.dataset.val = val;
} else {
_calcResult.textContent = '';
_calcResult.textContent = '&mdash;';
_calcApply.disabled = true;
}
}
@@ -2925,7 +2925,7 @@
document.getElementById('ovenModal').addEventListener('hidden.bs.modal', function () {
_calcPanel.classList.add('d-none');
_calcW.value = ''; _calcD.value = ''; _calcH.value = '';
_calcResult.textContent = ''; _calcApply.disabled = true;
_calcResult.textContent = '&mdash;'; _calcApply.disabled = true;
});
// ─────────────────────────────────────────────────────────────────────
@@ -3108,7 +3108,7 @@
onmouseenter="this.classList.replace('bg-light','bg-primary');this.classList.replace('text-dark','text-white')"
onmouseleave="this.classList.replace('bg-primary','bg-light');this.classList.replace('text-white','text-dark')"
onclick="ntplCopyPlaceholder('${p.placeholder}', this)"
title="${p.description} click to copy">
title="${p.description} &mdash; click to copy">
${p.placeholder}
</span>
<span class="ms-1 text-success small" style="display:none;">Copied!</span>
@@ -435,13 +435,13 @@
<div class="col-sm-6">
<label for="blastSetupNozzleSize" class="form-label">Nozzle Size</label>
<select class="form-select blast-modal-input" id="blastSetupNozzleSize">
<option value="2">#2 (1/8") Very small / entry level</option>
<option value="3">#3 (3/16") Small / hobby</option>
<option value="4">#4 (1/4") Light duty</option>
<option value="5" selected>#5 (5/16") Medium (most common)</option>
<option value="6">#6 (3/8") Heavy duty</option>
<option value="7">#7 (7/16") High volume</option>
<option value="8">#8 (1/2") Industrial</option>
<option value="2">#2 (1/8") &mdash; Very small / entry level</option>
<option value="3">#3 (3/16") &mdash; Small / hobby</option>
<option value="4">#4 (1/4") &mdash; Light duty</option>
<option value="5" selected>#5 (5/16") &mdash; Medium (most common)</option>
<option value="6">#6 (3/8") &mdash; Heavy duty</option>
<option value="7">#7 (7/16") &mdash; High volume</option>
<option value="8">#8 (1/2") &mdash; Industrial</option>
</select>
</div>
<div class="col-sm-6">
@@ -465,7 +465,7 @@
<div class="col-sm-6 d-flex align-items-end">
<div class="w-100 p-3 bg-light rounded text-center">
<div class="text-muted small">Derived Rate</div>
<div class="fw-bold fs-5" id="blastSetupDerivedRate"></div>
<div class="fw-bold fs-5" id="blastSetupDerivedRate">&mdash;</div>
<div class="text-muted small">sqft/hr</div>
</div>
</div>
@@ -1,10 +1,10 @@
@model PowderCoating.Application.DTOs.User.CreateCompanyUserDto
@model PowderCoating.Application.DTOs.User.CreateCompanyUserDto
@{
ViewData["Title"] = "Add New User";
ViewData["PageIcon"] = "bi-person-plus";
ViewData["PageHelpTitle"] = "Add New User";
ViewData["PageHelpContent"] = "Creates a new login account for a member of your company. The email address doubles as the login username. Set a temporary password — the user can change it from their Profile page after their first login. Assign a Role, then fine-tune individual permissions below.";
ViewData["PageHelpContent"] = "Creates a new login account for a member of your company. The email address doubles as the login username. Set a temporary password &mdash; the user can change it from their Profile page after their first login. Assign a Role, then fine-tune individual permissions below.";
}
<div class="container">
@@ -26,7 +26,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Basic Information"
data-bs-content="First Name, Last Name, and Email are required. The email is used as the login username — it must be unique across the system. Employee Number is an optional internal reference. The user can update their name and phone from their own Profile page after logging in.">
data-bs-content="First Name, Last Name, and Email are required. The email is used as the login username &mdash; it must be unique across the system. Employee Number is an optional internal reference. The user can update their name and phone from their own Profile page after logging in.">
<i class="bi bi-question-circle"></i>
</a>
</div>

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