Compare commits

..

50 Commits

Author SHA1 Message Date
spouliot df9863a0bb Merge branch 'dev' 2026-05-15 21:13:04 -04:00
spouliot 6cefdff18c Ignore TODO.txt from source control
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 21:06:48 -04:00
spouliot 91a5dbe30c Reorganize Operating Costs tab into individual section cards
Replaces single large card with six labeled section cards (Rates & Costs,
Facility Overhead, Equipment, Pricing & Profit, Rush Charges, Complexity)
to reduce visual density and improve scannability.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 21:06:04 -04:00
spouliot b2a1b9a0be Remove ShopWorker entity and migrate worker identity to ApplicationUser
Removes the ShopWorker and ShopWorkerRoleCost entities, all related DTOs,
mappings, controllers, views, and import/export paths. Worker identity is
now handled entirely through ApplicationUser with per-user LaborCostPerHour.
ShopWorkerRoleCosts table remains in production pending manual data migration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 21:06:04 -04:00
spouliot 1a44133a63 Remove ShopWorker entity and migrate worker identity to ApplicationUser
Removes the ShopWorker and ShopWorkerRoleCost entities, all related DTOs,
mappings, controllers, views, and import/export paths. Worker identity is
now handled entirely through ApplicationUser with per-user LaborCostPerHour.
ShopWorkerRoleCosts table remains in production pending manual data migration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 20:32:32 -04:00
spouliot 7020797a25 Merge dev: tax-exempt pricing fixes, job details Unicode cleanup
- Fix tax-exempt customers being charged tax on all job save/recalc paths (7 call sites in JobsController)
- Fix JS falsy-zero bug in quote preview tax calculation (item-wizard.js)
- Fix quote preview not recalculating on customer change (Create.cshtml)
- Add AddQuotePricingSnapshotFields migration (missing from prior session)
- Fix intake button rendering &#10003; as literal text (Html.Raw fix)
- Clean up corrupted Unicode box-drawing chars in Job Details view

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:52:39 -04:00
spouliot 3b5511a703 Fix corrupted Unicode characters and intake button rendering in Job Details
- Replace mojibake box-drawing chars (U+2500 encoded as Windows-1252) with
  plain ASCII dashes throughout all comments in Details.cshtml
- Fix intake button showing literal '&#10003;' text: the entity was inside a
  C# string so Razor HTML-encoded the '&'; switched to Html.Raw() so the
  checkmark renders correctly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:43:36 -04:00
spouliot 8df37ca760 Fix tax-exempt customers charged tax on all job save paths
Jobs used company default TaxPercent for every pricing recalculation
(Create, Edit, UpdateItems, DeleteJobItem) without checking the customer's
IsTaxExempt flag. Added GetEffectiveTaxPercentAsync helper and wired it
into all seven call sites so tax-exempt customers are never billed tax
regardless of which path triggers the recalc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:15:43 -04:00
spouliot 7239f55308 Fix tax-exempt customers always charged tax in quote preview
parseFloat('0') is falsy in JS, so '0 || pageMeta.taxPercent' was
falling through to the company default rate even when the TaxPercent
field was correctly set to 0 for a tax-exempt customer. Use an
explicit field presence check instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:05:07 -04:00
spouliot 09e077897b Fix quote preview not recalculating when tax-exempt customer is selected
When a customer was changed to/from a tax-exempt customer, the hidden
TaxPercent field was correctly updated to 0 but the live pricing preview
was not re-run, so the display showed a stale total with tax applied.
Selecting a tax-exempt customer now immediately triggers a recalc so
the on-screen total matches the amount that will be saved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 15:58:20 -04:00
spouliot 051c86810e Add missing AddQuotePricingSnapshotFields migration
Seven new decimal columns on Quotes table that were added to the entity
in the pricing audit but the migration was never created (name collision
with a prior attempt in the previous session caused the scaffold to fail).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 15:48:46 -04:00
spouliot 6721de91e4 Fix pricing consistency across Quote → Job → Invoice; add stage-flow tests
- Store complete PricingBreakdownJson snapshot on Job at every save point so
  the Details page reads stored data rather than re-running the pricing engine
- Add 7 missing fields to Quote entity (FacilityOverheadCost, tier/quote discounts,
  SubtotalAfterDiscount) and persist them via ApplyPricingSnapshot
- Fix OvenCostId-as-rate bug in JobsController (FK was passed as decimal $/hr)
- Fix hardcoded LaborCost * 0.4 multiplier in two JobItemAssemblyService overloads
- Fix FacilityOverheadCost dropped from invoices in both quote and direct-job paths
- Fix RushFee missing from direct-job invoices (read from PricingBreakdownJson)
- Fix Notes and CatalogItemId not copied to InvoiceItem
- Add 14 unit tests in PricingStageFlowTests covering all three pricing stages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 15:03:06 -04:00
spouliot 226a6237a6 Fix corrupted Unicode characters in Jobs/Details.cshtml
All � replacement characters replaced with correct HTML entities
(&mdash;, &ndash;, &bull;, &times;, &hellip;) and restored a
corrupted class attribute with missing double quotes on the Intake button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 09:51:00 -04:00
spouliot cf6acc125f Complete mobile card view coverage for all remaining pages
- CSS fix: change blanket .table-responsive hide to only trigger when
  a .mobile-card-view sibling exists (.mobile-card-view ~ .table-responsive
  and :has() rule) — auto-fixes 60+ forms/reports/detail/help pages that
  were showing blank on mobile by making their tables scroll instead
- Add mobile card views to remaining list pages:
  JobsPriority (overdue jobs, main board, maintenance sections)
  NotificationLogs (email/SMS log entries)
  AiUsageReport (per-company AI usage breakdown)
  GiftCertificates/BulkResult (batch certificate list)
  Inventory/SamplePanels (Need to Order + On Wall tabs)
  BannedIps (active bans + lifted/expired bans)
  OnboardingProgress (per-company activation funnel)
  ReleaseNotes/Manage (versioned changelog entries)
  StorageMigration/Results (file migration status list)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 23:31:38 -04:00
spouliot f467862877 Add mobile card views to 12 high-priority list pages
Pages were blank on phones because mobile-cards.css hides .table-responsive
below 992px. Added .mobile-card-view sections to: GiftCertificates, PurchaseOrders,
CreditMemos, VendorCredits, JournalEntries, Appointments, InAppNotifications,
BankReconciliations, FixedAssets, RecurringTemplates, SmsAgreements, SmsConsentAudit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 23:07:52 -04:00
spouliot 7ad7d84016 Add mobile card views to Invoices and Intakes list pages
Both pages were blank on phones because mobile-cards.css hides .table-responsive
below 992px but neither page had a .mobile-card-view section. Added card-per-row
mobile layout to match the Customers page pattern — tappable cards with status
badges, key fields, and action buttons sized for touch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 22:51:22 -04:00
spouliot 75b0a8afe2 Fix kiosk inactivity timer for remote sessions; make Intakes table mobile-responsive
Remote sessions (customer's phone) no longer get the 45-second inactivity redirect
that requires a KioskDevice cookie — would have landed them on an error page.
Intakes staff table hides non-essential columns on small screens so the primary
customer/status/actions columns are visible without horizontal scrolling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 21:00:43 -04:00
spouliot 38748c2152 Add BatchId to GiftCertificate for persistent bulk batch tracking
BatchId (Guid?) is stamped on every certificate in a bulk run so the batch
is permanently addressable. BulkResult is now a bookmarkable GET by batchId
rather than TempData, so users can return to re-download at any time.
BatchDownloadPdf is a GET link (no form POST needed). Index shows a Batch
badge on bulk certs that links directly back to the batch result page.

Migration: AddGiftCertificateBatchId

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 20:32:56 -04:00
spouliot 4ec55e7290 Restore all zeroed views + add bulk gift certificate creation
The HTML entity sweep script had a bug where it wrote empty files for any
view that contained no target Unicode characters, zeroing out 215 view files.
All views restored from the pre-sweep commit (cefdf3e).

Bulk gift certificate feature:
- BulkCreateGiftCertificateDto with Quantity (1-500), Amount, Reason, Expiry, Notes
- GenerateBulkGiftCertificatePdfAsync on IPdfService / PdfService: one Letter page
  per cert, reusing the same purple/gold branded ComposeGiftCertificateContent helper
- GiftCertificatesController: BulkCreate GET/POST, BulkResult GET, BulkDownloadPdf POST
- Views: BulkCreate.cshtml (form with live total preview), BulkResult.cshtml (table +
  Download All PDF button that POSTs cert IDs to avoid URL length limits)
- gift-certificate-bulk.js: live preview + spinner/disable on submit
- Index.cshtml: Bulk Create button added alongside New Certificate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 20:09:22 -04:00
spouliot 3eda91f170 Replace literal Unicode special chars with HTML entities across all 233 views
Sweeps em dashes, en dashes, multiplication signs, ellipses, and curly quotes
to their HTML entity equivalents (&mdash; &ndash; &times; &hellip; &lsquo; &rsquo;)
in all .cshtml files, skipping <script> blocks. Prevents encoding corruption
from AI tools and Windows encoding mismatches that caused recurring symbol bugs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:16:17 -04:00
spouliot cefdf3e35c Add remaining-weight input mode to inventory scan/usage page
Users can now toggle between 'Amount Used' and 'Remaining Weight' on the
QR scan page. In remaining-weight mode, usage is calculated as
(current stock - remaining) before submit — no controller changes needed.
Includes live hint showing calculated usage and new balance as they type,
with validation preventing negative usage or remaining > current stock.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 19:12:28 -04:00
spouliot f34ee749be Fix garbled encoding symbols in oven display, bill/invoice tooltips, and profile timezone dropdown
- Replace mojibake × with × in oven batch cost row across Jobs Create/Edit/EditItems and Quotes Create/Edit
- Fix Qty × Unit Price tooltip in Bills/Edit and Invoices/Edit
- Fix all â€" (garbled em dash) and São Paulo in Profile timezone dropdown option labels

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 18:54:35 -04:00
spouliot 357ef84001 Fix online users page always showing /InAppNotifications/Recent as current page
The notification bell polls /InAppNotifications/Recent (a JSON endpoint) every time
it loads. Because the middleware throttles updates to once per 60s, the update fired
on whichever request first arrived after the throttle expired — usually the bell poll
rather than a real page navigation. Fix: skip any response whose Content-Type is
application/json so only full page navigations update the current-page field.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 17:16:54 -04:00
spouliot 7a1a697dc2 Merge dev into master: fix oven batch conversion, invoice quantity, AI photo pricing, enforce pricing flag propagation 2026-05-14 16:56:26 -04:00
spouliot 539c6c2559 Fix oven batch conversion, invoice quantity, AI photo pricing, and enforce pricing flag propagation
- Carry OvenBatches/OvenCycleMinutes from Quote → Job entity (was missing fields; all job pricing recalcs hardcoded 1/null)
- Fix invoice creation from job always showing Quantity=1 (was using TotalPrice as UnitPrice with qty 1)
- Add IsAiItem to JobItem + migration; map in all 3 JobItemAssemblyService.CreateJobItem overloads so AI photo jobs no longer double-price on first edit after quote→job conversion
- Propagate IsAiItem through all existingItemsData JSON blocks in Jobs views (Edit, EditItems, Create) so the wizard preserves AI routing on re-edit
- Add PricingRoutingFlags_ExistOnBothQuoteItemAndJobItem structural test + 3 behavioral IsAiItem tests to JobItemAssemblyServiceTests
- Consolidate item wizard partials (_ItemWizardModal, _SqFtCalculatorModal) and item-wizard.css into shared locations
- Document pricing flag propagation checklist in CLAUDE.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 16:54:22 -04:00
spouliot a947494cbd Merge dev into master: churned account filter, powder catalog lookup fixes 2026-05-14 14:19:27 -04:00
spouliot 7e79a13cb1 Fix powder catalog lookup: exact match auto-fills, partials show picker modal
- CatalogLookup now returns all partial color name matches ranked by
  specificity (exact vendor+color first, same-vendor partial, cross-vendor)
  with isExact flag so JS can decide to auto-fill vs show modal
- Removed cross-vendor fallback that was silently overwriting manufacturer
  field with wrong brand when vendor-scoped search found nothing
- Picker modal now includes "Not listed — search online" option that
  triggers AI lookup as an escape hatch from the catalog results

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 14:18:52 -04:00
spouliot 2ad6df1195 Hide churned trial accounts from company/health screens by default
- Companies list and Company Health now hide Expired/Canceled accounts
  whose subscription ended 14+ days ago; show/hide toggle via banner
- KPI cards on Company Health exclude churned tenants when hidden
- showChurned param threads through sort, pagination, search, and filter forms
- Powder catalog: fix missing UnitPrice on user-contributed entries;
  add back-sync to fill catalog gaps on existing matches; wire
  AiAugmentFromUrl and manual inventory Create into catalog contribute path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-14 13:59:12 -04:00
spouliot dc3cd75ea4 Merge dev into master — prod deploy 2026-05-14
- Real-time SMS consent status update on customer record
- Fix kiosk SMS consent routing loop and stuck tablet
- Fix notification bell, SMS consent kiosk flow, and button alignment
- Add staff-presented SMS consent flow on customer record
- Customer intake kiosk (SignalR → polling, inactivity reset, signature pad, anonymous logo endpoint)
- Invoice SMS notifications
- Kiosk help article and AI knowledge base updates
2026-05-14 08:17:24 -04:00
spouliot a73f14fa7f Real-time SMS consent status update on customer record
When kiosk consent is completed, the staff-facing customer Details page
now updates the SMS badge instantly via SignalR — no page refresh needed.
Added customerId to the NewInAppNotification SignalR payload so the
KioskConsent handler can match the current URL and swap the badge in place.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 23:40:47 -04:00
spouliot 0af31c39b3 Fix kiosk SMS consent routing loop and stuck tablet
- Route param renamed customerId→id so /Kiosk/SmsConsent/15307 binds correctly
  (default MVC route uses {id}; mismatched name caused GetByIdAsync(0)→404→loop)
- Cache entry cleared in GET (not just POST) so returning to Welcome after seeing
  the form never redirects again
- Added POST /Kiosk/CancelSmsConsent for staff to free the kiosk if they pushed
  consent accidentally — Customer Details shows a Cancel button after pushing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 23:25:37 -04:00
spouliot e1256503be Fix notification bell, SMS consent kiosk flow, and button alignment
Notification bell:
- Bell now polls /InAppNotifications/Recent every 60s as a SignalR fallback
- Bell dropdown refresh on open so count is always current when staff looks at it

SMS consent → kiosk flow:
- Staff clicks "Get SMS Consent" on Customer Details → AJAX POST to
  /Kiosk/PushSmsConsent stores customer in IMemoryCache (10 min TTL)
- Kiosk PollSession returns smsConsentPending + customerId so tablet navigates
  to /Kiosk/SmsConsent/{customerId} automatically
- Customer reads TCPA consent on tablet, taps I Agree or No Thanks
- On agree: NotifyBySms/SmsConsentedAt/SmsConsentMethod set; in-app notification
  fires; cache cleared; tablet returns to Welcome
- Removed Customers/SmsConsent (staff-browser version); moved view to Kiosk/

Button alignment:
- kiosk.css: added display:flex + align-items:center + justify-content:center to
  all kiosk body buttons so content is centred vertically in tall button outlines

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 23:13:57 -04:00
spouliot b69ff6db3a Add staff-presented SMS consent flow on customer record
- New GET/POST Customers/SmsConsent/{id}: full-screen kiosk-layout page staff
  opens and hands to the customer to read TCPA consent language and tap I Agree
- On agreement: sets Customer.NotifyBySms, SmsConsentedAt (UTC), SmsConsentMethod
  = "InPerson", clears SmsOptedOutAt
- Redirects back if customer has already consented (no double-consent)
- Customer Details: "Get SMS Consent" badge link shown when NotifyBySms is false;
  SMS on badge shows consent date on hover when consented

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:50:49 -04:00
spouliot 66231822af Update kiosk help article and AI knowledge base for output setting
- Help article: new "Kiosk Output Setting" section explaining Quote vs Job modes and
  the Company Settings → Kiosk tab; Overview updated; Reviewing Submissions now lists
  "View Quote" and "View Job" separately; notification label corrected (Remote vs Walk-in)
- AI knowledge base: CUSTOMER INTAKE KIOSK section updated — output setting documented,
  submission outcome reflects Quote/Job branch, notification labels corrected, workflow
  entries split into Quote-mode and Job-mode variants, troubleshooting updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:39:39 -04:00
spouliot d5ad9fa073 Add KioskIntakeOutput company setting and fix kiosk submission bugs
- New CompanyPreferences.KioskIntakeOutput setting ("Quote" default / "Job"): controls
  what the kiosk creates on submission; shown as a card-style radio toggle in
  Company Settings → Kiosk tab
- KioskSession.LinkedQuoteId added so quote-first sessions link back to the draft quote
- Migration AddKioskIntakeOutputSetting applies both schema changes
- ProcessSubmissionAsync branches on setting: creates Draft quote (quote-first) or
  Pending job (job-first); save order fixed (CompleteAsync before using DB-assigned Id as FK)
- Terms.cshtml pricing paragraph is now dynamic: "subject to formal quote" for Quote mode,
  "team member will reach out about pricing" for Job mode
- Customer Intakes list: "View Quote" button appears when LinkedQuoteId is set
- Notification label fixed: Remote sessions now say "Remote Intake", not "Walk-in Intake"
- Inactivity reset shortened to 45 s on intake steps
- Signature pad: hosted locally (no CDN), canvas resize deferred via requestAnimationFrame
- AI photo upload: client-side compression to ≤1200px + AbortController 120 s timeout
- Help article and AI knowledge base updated with kiosk feature

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:35:37 -04:00
spouliot d134dd51e5 Add Customer Intake Kiosk help article and knowledge base entry
- New Help article at /Help/CustomerIntakeKiosk covering setup, in-person
  and remote intake flows, what happens on submission, reviewing intakes,
  and troubleshooting (signature pad, connection issues, seed data)
- Add kiosk entry to _HelpNav under Operations
- Update HelpKnowledgeBase: nav overview, full kiosk section, two new
  common workflow entries (walk-in kiosk and remote intake)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 22:02:34 -04:00
spouliot 1df7c13abd Sweep kiosk intake submission for FK/null bugs
- Fix Jobs.Id FK violation: save job first with CompleteAsync() to get
  its DB-assigned Id, THEN set session.LinkedJobId and save again.
  Previously job.Id was still 0 when written to the nullable FK column.
- Replace ?? 1 fallbacks on JobStatusId/JobPriorityId with explicit
  InvalidOperationException — hardcoded 1 may not exist in the company's
  lookup tables; now fails loud with a clear message instead of an FK error.
- Add ValidateSessionState check to Terms POST so expired/already-submitted
  sessions don't re-run ProcessSubmissionAsync and create duplicate jobs.
- Null-guard session.JobDescription before slicing for notification snippet.
- Tighten catch block: wrap the fallback CompleteAsync in its own try/catch
  so a secondary failure doesn't mask the original error in logs.
- Swap Job.Description / SpecialInstructions: Description now holds the
  actual job description text; SpecialInstructions records the intake source.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:53:10 -04:00
spouliot 4a8778504f Fix FK violation on kiosk intake submission: set JobPriorityId
ProcessSubmissionAsync was creating a Job without JobPriorityId, leaving
it as 0 which violates the FK to JobPriorityLookups. Look up the NORMAL
priority the same way JobsController does everywhere else.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:36:53 -04:00
spouliot f1d7054b3e Fix AI quote reliability on mobile: compress photos + add fetch timeout
- Compress photos client-side before uploading (1200px max, JPEG 85%):
  full-res phone photos (5-15 MB) → ~150-250 KB, dramatically reducing
  upload time on slow mobile connections and Anthropic processing time
- Add 120s AbortController to both AiAnalyzeItem fetch calls so a stalled
  mobile connection produces a clear 'timed out' error instead of spinning forever
- After 30s show 'Still analyzing… this can take a minute on mobile' to
  reassure users the request is in progress
- Reset loading text on retry so the slow-connection hint doesn't persist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:22:32 -04:00
spouliot 46b950baf2 Kiosk intake: 45-second inactivity reset to Welcome screen
_KioskLayout inactivity timer now reads ViewBag.InactivityTimeoutMs
(defaults to 5 min). PopulateKioskViewBagFromSession sets it to 45 s
on every intake step so an abandoned form auto-returns to the waiting
screen. Welcome screen and Confirmation page are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:16:34 -04:00
spouliot 4e9c9d321a Fix kiosk signature pad: host locally, fix canvas resize timing
- Download signature_pad 4.1.7 to wwwroot/lib/signature-pad/ to eliminate
  CDN SRI hash failures and network dependencies on the tablet
- Wrap resizeCanvas in requestAnimationFrame so offsetWidth is non-zero
  when measured (browser layout pass must complete first)
- Add guard for SignaturePad not defined (shows user-visible error instead
  of silent JS crash)
- Add scrollIntoView on signature validation error for better tablet UX

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 21:14:49 -04:00
spouliot 0c8723ef84 Fix sw.js: exclude /hubs/ and PollSession from SW interception
SW fetch() wraps SSE responses in a buffered Response, preventing SignalR
streaming — handshakes time out after 15s as a result. Exclude /hubs/ and
/Kiosk/PollSession so the browser handles them directly without SW wrapping.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 20:45:03 -04:00
spouliot 377bb1ce38 Replace kiosk SignalR with polling — Azure App Service blocks anonymous hub handshakes
SignalR WebSocket and SSE both receive immediate 'Handshake was canceled' from the
server-side hub context. The 15-second delay between negotiate and SSE connect
reveals the handshake timer has expired before the transport opens — caused by Azure
App Service's ingress proxy resetting anonymous long-lived connections.

Replacement: /Kiosk/PollSession (anonymous GET, no-cache) queried every 3 seconds.
Returns the most recent Active InPerson session created in the last 60 seconds.
The kiosk navigates when hasSession=true. Status dot: gray->green on first success,
yellow on network error, blue when navigating. Removed signalr.min.js from kiosk layout.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 20:37:28 -04:00
spouliot 2acf54e1a9 Fix kiosk SignalR: skip WebSocket transport, add hubs/Kiosk to subscription bypass
1. kiosk-welcome.js: force SSE|LongPolling transport on the kiosk hub.
   Azure App Service's ingress proxy cancels anonymous WebSocket handshakes
   before the SignalR protocol exchange completes. SSE and long polling
   work fine for the low-frequency StartIntake push this hub needs.

2. SubscriptionMiddleware: add /hubs/ and /Kiosk/ to SkipPaths so a
   subscription redirect can never fire on a hub or kiosk request and
   abort the connection mid-handshake.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 20:20:06 -04:00
spouliot 0b24c320cd Fix kiosk intake routing, view names, and SignalR diagnostics
Three bugs identified:
1. Routing: /Kiosk/Intake/{token}/{action} had no matching route — 4-segment
   URL fell through the default 3-segment {controller}/{action}/{id?} route.
   Added explicit kiosk_intake route in Program.cs.

2. View names: Contact/Job/Terms/Confirmation actions returned View(model)
   which resolved to Views/Kiosk/{Action}.cshtml — those files don't exist.
   Views live in Views/Kiosk/Intake/. Fixed all six return statements.

3. Diagnostics: conn dot now starts gray ("Connecting...") and turns green
   only when SignalR actually connects. Red + message if no company ID or
   connection fails. Makes it easy to confirm the hub connection is live.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 18:28:16 -04:00
spouliot 350f2d7658 Fix duplicate logo on kiosk Welcome screen; enlarge welcome logo
Layout rendered a small logo at top of every kiosk page. Welcome.cshtml
also rendered its own centered logo, resulting in two logos. Suppress
the layout logo on the Welcome screen via HideLayoutLogo ViewBag flag.
Bump kiosk-welcome-logo from 120x280 to 200x420 for better presence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:21:27 -04:00
spouliot 856d202b78 Fix kiosk SignalR group: set ViewBag.CompanyId so tablet joins correct hub group
Without this, data-company-id was empty and the JS connected to kiosk- (no ID),
so StartSession signals never reached the tablet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:18:39 -04:00
spouliot 8caaa84eac Hide Start Intake button when kiosk not activated; relabel remote link
- Start Intake button only shows when company has an active kiosk token
- Remote Link button renamed to "Send Intake Link" for clarity

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:00:09 -04:00
spouliot e70f7ee9f1 Fix kiosk logo: add anonymous Logo endpoint proxying blob storage
CompanySettings/Logo requires tenant context and fails on anonymous
kiosk pages. Added Kiosk/Logo which resolves the company from the
KioskDevice cookie and proxies the blob directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:55:44 -04:00
spouliot 6a918c2afc Add invoice SMS notifications and customer intake kiosk
Invoice SMS:
- Send Invoice modal now prompts Email/SMS/Both based on customer contact data
- New /invoice/{token} customer-facing view page with full line items and pay button
- PublicViewToken (permanent) added to Invoice; separate from expiring PaymentLinkToken
- InvoiceSent SMS default template added; customizable via Notification Templates settings
- {{viewUrl}} placeholder documented in template editor

Customer Intake Kiosk:
- Tablet kiosk flow: Contact → Job → Terms/Signature → Confirmation
- Remote link mode for off-site customers (lighter form, no signature)
- KioskHub (AllowAnonymous SignalR) for staff-to-tablet push without login
- Staff activates tablet via cookie; sends remote link manually
- Submitted sessions create Customer + Job automatically; fires in-app notification

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 16:25:27 -04:00
169 changed files with 105394 additions and 2880 deletions
+4
View File
@@ -129,3 +129,7 @@ DataProtection-Keys/
# Secrets # Secrets
appsettings.secrets.json appsettings.secrets.json
*.pfx *.pfx
# Local task tracking
TODO.txt
TODO.txt.bak
+21
View File
@@ -478,6 +478,27 @@ All modules below are fully implemented with controllers, views, and migrations
- In-stock inventory powder: charge for calculated usage only (surface area × lbs/sqft × unit cost) - 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 ★ - 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 ### Branding
- Application name: **Powder Coating Logix** - 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 - PCL logo: `wwwroot/images/pcl-logo.png` — used in sidebar header (when no tenant logo), login/register pages, sidebar footer
-226
View File
@@ -1,226 +0,0 @@
Shop Management App TO DO List
==============================
-Add feature to prep for events where we can generate coupons or gift certificates in bulk
Duplication refactor memory
C:/Users/spoul/.codex/memories/powdercoatingapp-refactor-plan-2026-05-07.md.
Current memory
C:/Users/spoul/.codex/memories/powdercoatingapp-quote-sync-extracted-2026-05-07.md
-Google review request email after a job
-Check my ChatGPT chat about surface area for a few solid ideas for the system
-Fix up approve/decline messages between customer and user on quote approval feature
Done and need testing
=====================
-Add sorting to all grids
-Add searching to all grids
-Add Workers to the system
-Allow jobs to be assigned to workers
-Add Shop Job Board display to show in the shop
-Added quick edits on a few pages
-Fix job page customer drop down. It's only showing business names and not individuals
-Add country drop down on customer edit and add pages
-Conver customer once quote accepted not complete
-Add Dashboard page
-Low Inventory Warnings display
-Overdue jobs
-Todays Jobs
-new quote button on customer page doesnt pre-select customer
-Add customer job history page
-Profiles can now change from a light theme to a dark theme as well as other appearance changes
-Date format can be customized per profile
-Timezone can now be changed per profile
-Have company logos stored in the database with the other company information
-Add Company Name under Logo in navbar
-Make logo bigger
-Update create quote page to show names of individual customers or company name depending on which type it is
-Validate that the company has entered operating costs before allowing the quote page to be loaded
-Make phone number and contact required on quotes for new prospects
-Move the create quote button to the right side of the screen to be consistent with other pages
-Add setting for tax exempt on customer
-Added tax certificate upload as well
-Add shop minimum to quoting system and company settings
-Add Rush Job Fee (customizable in company settings)
-Add ability to quick change the status on the job listing and record who changed the status.
-Deactivating company should NOT allow any users to login at all.
-Allow superadmins to create company users/managers
-Add a print quote button
-Add a download PDF button for quotes
-When adding users, also create worker records
-Add quick update to all view pages
-Add Mobile layouts
-Fix a few text pieces on the dashboard page that did not invert properly when dark mode was selected
-Add ability to upload job photos
-Allow photo uploads for jobs before and after photos
-Added Log Viewer
-Added Seed Data option for super admins that will assist during testing
-Add an item list with prices for repeat parts and such
-Add manual data seeding that super admins can use to seed a company one at a time if needed
-Add Log Viewer for Super Admins
-Quotes cleaned up quite a bit and calculations and style changed
-Approving a Quote will now auto-create a Job and link back to the quote it came from.
-Job Items now appear on the Job Screen with the line items from the quote
-Job items can be edited
-Add a way to convert a quote into a job
-Add multiple item types to add to a quote
1. Pre-Defined item that we can choose from our product list
2. Batch items where we enter the square footage manually as well as the quantity
-Add Quickbooks import for customers and price lists (Desktop and Online)
-Custom Order Powder not saving or displaying properly on quuote page
-Added ability for Companies to define their own Job Status, Job Priority, and Quote Status' via Company Settings > Data Lookups
-Add Randomizer Wheel
-Add Quickbooks format export for
-Customers
-Product Catalog
-Invoices
-Quote for Product Catalog Item is only selecting items from Powder Coating, need all items
-Add a Shop Supplies operating cost that will be used on quote calculations
-Fix Quote screen, only Powder showing in item dropdown. Need to get all items in an IsCoating category showing up.
-Update everywhere that uses tax rate to read and use this setting
-Add ability to export a full price list for known items
-Add tracking for all changes and show change history on view page. Possibly in a hidden grid or modal
-Update the inventory screen to not duplicate color name fields and the like
-Add option for metric system
-Add Bulk Upload for
-Powder
-Product Catalog
-Customer Data
-Add an Appointment engine and Calendar. Also show Maintenence tasks that are scheduled on it
-Allow shops to put employee days off on the calendar as well
-Fix and Verify user permissions are honored
-Run a full security check on the application
-Add support for multi stage coatings on an item
-Fix Seed Data routines to track errors better and continue past error imports
-Add ability to complete a job and enter actual time and materials used
-Add export for all data to CSV format
-Check calendar resizing with the browser. It's off a bit
-Add ability to apply discounts
-Remove powder from inventory when completeing a job
-Add color change ability for appointment types
-Add code to honor the rush charge on a quote
-Add options to quote for Sandblasting, Masking, Chemical Strip, Outgas, Phosphate Wash, Degrease
-Add ability to add sq ft to product catalog item for powder estimation
-Add better UX design for validation errors and such
Option 1: Change "ModelOnly" to "All" (1 line change) - Shows all validation errors at top of form in red alert box
- User would have seen: "The field Estimated Minutes must be between 0 and 10,000"
Option 2: Add inline validation (more complex)
- Show error messages right next to the problematic field
- Better UX but requires adding validation spans to dynamic fields
Option 3: Toast notifications (requires new library/code)
- Modern popup notifications for success/error messages
- Would need to add a toast library (like Toastr) and wire it up
-Add Import/Export for Company Settings
-Allow Super Admin to modify permissions for company admins in case we add any in the future, or if anything gets messed up we can fix it!
-Allow recurring scheduled maintenance
-Let's show scheduled maintenence on the job schedule as well. At the top of the screen
-Make sure maintenence shows on the calendar list view.
-Add viewing quotes on the customer details page so we can see all quotes/jobs for a given customer to make things easier to find.
-Add support for multiple ovens in operating costs
-Display oven selected on quote and job detail pages
-Allow user to choose an oven on a quote, and have it follow through to a job
-Check for any old and outdated code and DB fields!
-Add ability to email a quote
-Add email capabilities
-Add search on super admin companies screen
-Set limits on job photos per app tier
-Check subscription signup page to make sure the selected subscription is actually saved.
-Don't seed the product catalog on a new user
-Check to make sure subscription page has quotes and all fields on it
-Allow customizing of the quote sheets and invoices (If we do them)
-Add feature to allow username changes
-Fix quickbooks imports based on files Colton sent
-Add thicker border around input fields to signify they are text boxes
-Check to make sure emails get sent when a quote is created
-Add buttons to send emails manually if needed
-Modify price calculations to prompt for service times (ie... sandblasting, oven cure times, outgas times etc)
-Add ability to modify items on jobs
-Swap quoting page to use modals to add items to segregate it a bit better.
-Build account ledger/transaction summary view
-Add security for financial pages
-Allow opening balances for accounts
-Create P&L and other reports
-Allow receipet upload on expenses and bills
-Download PDF for invoices throws and error
-Emailing invoice doesn't seem to trigger
-When a customer record has email notifications turned off, disable any email buttons that may send one and alert the user that this customer is set to have notifications turned off.
-When doing anything that sends mail, prompt the user to alert them a message will be sent
-Create a setup wizard for new users that will walk through system setup. Allow re-running later.
-Check Workflow steps in wizard, might need adjusting
-Account Summary, use permanent alert for info message at bottom
-Add steps so that the new user can customize the data lookups and re-order them
-Reorder menu to work better
-Add ability to print a job invoice once completed
-Add ability to email a job invoice
-Integrate invoicing/billing/reports
-Add customer portal to approve quotes from a link for now. We can do a full login later.
-Need a complexity score for quoting parts (Simple, moderate, complex, extreme)
-Add tagging options for quotes and jobs (user driven)
-Can we also add this tag system to quotes and jobs to allow users to tag themselves and we can use that data later as well? We'd have to add a good
description of WHY the user should add some tags though.
-Inventory forecasting might be worth looking into
-Build some AI powder usage predictions into the system
-AI Production Scheduling - Batching enough parts together to fill the oven automagically
-Update dashboard to show some $$$ fields
-Update Setup Wizard
-Update the Setup Checklist
-Modify system to keep running balances of all accounts
- Make sure ALL job updates refresh the Shop Display
-Add multiple item types to add to a quote
AI Agent item where we upload a picture and it will calculate the approximate sq ft and quote from that
-Integration with stripe or square to accept online paymens from our users customers.
-AI Assistant for help
-Allow customer filtering on quotes and jobs
-New job page blanks when validation fails
-Can we keep track of which users have completed the setup wizard?
-Make sure we're tracking logins. I see a user logged on, but the company health page states they have never logged in.
-Allow printing blank work orders (model after the SCP Powder Coating blank work order)
-IDEA: Print powders to use on work order with their QR code so they can be scanned right from there and usage recorded.
-Add ability to save a quoted item to the product catalog either from an AI Photo Quote or from the calculated item
-Add images to product catalog items for easily identification of parts
-Look into possibly having AI scan a product catalog and suggest prices for items.
-Add Oven and Add Blasting Setup don't work in Setup Wizard
-When scanning inventory QR Code, there is no cancel button
-Bug: When scanning Inventory QR Code, if not logged in...it takes you to the dashboard after login, not our inventory scanning screen
-Add SMS capabilities
-Lookup not working 100% correct. If I type columbia as the manufacturer and a color name....it's finding blackmamba from prismatic incorrectly.
-Lookup Modal not showing ALL matches. Maybe make scrollable
-Pickup cure information from TDS Sheet if not found by AI Search
-ON AI Photo Quote page, when the AI info comes back we should scroll the modal window down so it's visible. It's not clear that new info has been added to the modal for all customers
-Inventory Lookup not always finding price for Columbia Coatings
-Logging powder usage and choosing a job doesn't record properly in the activity section of the powder itself
-Need to allow deleting of powder usage entries, or at least editing in case of a goof up
-Still random weird characters on a bunch of pages. Intake button for example on the jobs screen shows: Intake ✓
5/7/2026
-When editing a job/quote item from catalog, pre-select the item chosen please
-Move buttons to right side of job details page
-When completing a job, pull in powder usage already entered
-Fix invoice due date to match terms selected
-Invoice Status should not show on PDF unless PAID
-If we start with a job, shop supplies is not being added to the items
-If you delete an invoice attached to a job, the create invoice button keeps trying to go back to it
-Customer approval page doesn't show all charges (Oven time missing?)
-Time Logging default user to logged in user
-Add Print Invoice button or allow viewing the PDF
-If an invoice is voided, I cant create a new one from a job. Show voided invoice as history, but allow creating a new one.
-If a completed job is changed after an invoice is created, we need to update the invoice. Also need to be able to modify an invoice to add a discount or similar after it's created
-Add multiple email address for commercial customers (Accounting for invoices and contact for quotes)
-Support entering multiple email addresses (comma seperated) in each field
-If no email on file, then prompt for address to send to.
-When choosing a powder NOT in stock, can we incorporate our inventory lookup function to find a powder, link it to the quote, add it to the inventory with a 0lb balance and still put it on the "powder to order" list?
-When choosing a prospect for a quote, we need way to consent and enable SMS for them
Ideas Removed
=======================
-Add Deactivate Customer button on Customer Detail page
Logins:
rich@r2r.com/Ragz2Richs123!
rich@cannon.com/Cannon123!
-226
View File
@@ -1,226 +0,0 @@
Shop Management App TO DO List
==============================
-When editing a job/quote item from catalog, pre-select the item chosen please
-Move buttons to right side of job details page
-When completing a job, pull in powder usage already entered
-Fix invoice due date to match terms selected
-Invoice Status should not show on PDF unless PAID
-If we start with a job, shop supplies is not being added to the items
-If you delete an invoice attached to a job, the create invoice button keeps trying to go back to it
-Customer approval page doesn't show all charges (Oven time missing?)
-Time Logging default user to logged in user
-Add Print Invoice button or allow viewing the PDF
-If an invoice is voided, I cant create a new one from a job. Show voided invoice as history, but allow creating a new one.
-If a completed job is changed after an invoice is created, we need to update the invoice. Also need to be able to modify an invoice to add a discount or similar after it's created
-Add multiple email address for commercial customers (Accounting for invoices and contact for quotes)
-Support entering multiple email addresses (comma seperated) in each field
-If no email on file, then prompt for address to send to.
-When choosing a powder NOT in stock, can we incorporate our inventory lookup function to find a powder, link it to the quote, add it to the inventory with a 0lb balance and still put it on the "powder to order" list?
-When choosing a prospect for a quote, we need way to consent and enable SMS for them
Duplication refactor memory
C:/Users/spoul/.codex/memories/powdercoatingapp-refactor-plan-2026-05-07.md.
Current memory
C:/Users/spoul/.codex/memories/powdercoatingapp-quote-sync-extracted-2026-05-07.md
-Google review request email after a job
-Check my ChatGPT chat about surface area for a few solid ideas for the system
-Fix up approve/decline messages between customer and user on quote approval feature
Done and need testing
=====================
-Add sorting to all grids
-Add searching to all grids
-Add Workers to the system
-Allow jobs to be assigned to workers
-Add Shop Job Board display to show in the shop
-Added quick edits on a few pages
-Fix job page customer drop down. It's only showing business names and not individuals
-Add country drop down on customer edit and add pages
-Conver customer once quote accepted not complete
-Add Dashboard page
-Low Inventory Warnings display
-Overdue jobs
-Todays Jobs
-new quote button on customer page doesnt pre-select customer
-Add customer job history page
-Profiles can now change from a light theme to a dark theme as well as other appearance changes
-Date format can be customized per profile
-Timezone can now be changed per profile
-Have company logos stored in the database with the other company information
-Add Company Name under Logo in navbar
-Make logo bigger
-Update create quote page to show names of individual customers or company name depending on which type it is
-Validate that the company has entered operating costs before allowing the quote page to be loaded
-Make phone number and contact required on quotes for new prospects
-Move the create quote button to the right side of the screen to be consistent with other pages
-Add setting for tax exempt on customer
-Added tax certificate upload as well
-Add shop minimum to quoting system and company settings
-Add Rush Job Fee (customizable in company settings)
-Add ability to quick change the status on the job listing and record who changed the status.
-Deactivating company should NOT allow any users to login at all.
-Allow superadmins to create company users/managers
-Add a print quote button
-Add a download PDF button for quotes
-When adding users, also create worker records
-Add quick update to all view pages
-Add Mobile layouts
-Fix a few text pieces on the dashboard page that did not invert properly when dark mode was selected
-Add ability to upload job photos
-Allow photo uploads for jobs before and after photos
-Added Log Viewer
-Added Seed Data option for super admins that will assist during testing
-Add an item list with prices for repeat parts and such
-Add manual data seeding that super admins can use to seed a company one at a time if needed
-Add Log Viewer for Super Admins
-Quotes cleaned up quite a bit and calculations and style changed
-Approving a Quote will now auto-create a Job and link back to the quote it came from.
-Job Items now appear on the Job Screen with the line items from the quote
-Job items can be edited
-Add a way to convert a quote into a job
-Add multiple item types to add to a quote
1. Pre-Defined item that we can choose from our product list
2. Batch items where we enter the square footage manually as well as the quantity
-Add Quickbooks import for customers and price lists (Desktop and Online)
-Custom Order Powder not saving or displaying properly on quuote page
-Added ability for Companies to define their own Job Status, Job Priority, and Quote Status' via Company Settings > Data Lookups
-Add Randomizer Wheel
-Add Quickbooks format export for
-Customers
-Product Catalog
-Invoices
-Quote for Product Catalog Item is only selecting items from Powder Coating, need all items
-Add a Shop Supplies operating cost that will be used on quote calculations
-Fix Quote screen, only Powder showing in item dropdown. Need to get all items in an IsCoating category showing up.
-Update everywhere that uses tax rate to read and use this setting
-Add ability to export a full price list for known items
-Add tracking for all changes and show change history on view page. Possibly in a hidden grid or modal
-Update the inventory screen to not duplicate color name fields and the like
-Add option for metric system
-Add Bulk Upload for
-Powder
-Product Catalog
-Customer Data
-Add an Appointment engine and Calendar. Also show Maintenence tasks that are scheduled on it
-Allow shops to put employee days off on the calendar as well
-Fix and Verify user permissions are honored
-Run a full security check on the application
-Add support for multi stage coatings on an item
-Fix Seed Data routines to track errors better and continue past error imports
-Add ability to complete a job and enter actual time and materials used
-Add export for all data to CSV format
-Check calendar resizing with the browser. It's off a bit
-Add ability to apply discounts
-Remove powder from inventory when completeing a job
-Add color change ability for appointment types
-Add code to honor the rush charge on a quote
-Add options to quote for Sandblasting, Masking, Chemical Strip, Outgas, Phosphate Wash, Degrease
-Add ability to add sq ft to product catalog item for powder estimation
-Add better UX design for validation errors and such
Option 1: Change "ModelOnly" to "All" (1 line change) - Shows all validation errors at top of form in red alert box
- User would have seen: "The field Estimated Minutes must be between 0 and 10,000"
Option 2: Add inline validation (more complex)
- Show error messages right next to the problematic field
- Better UX but requires adding validation spans to dynamic fields
Option 3: Toast notifications (requires new library/code)
- Modern popup notifications for success/error messages
- Would need to add a toast library (like Toastr) and wire it up
-Add Import/Export for Company Settings
-Allow Super Admin to modify permissions for company admins in case we add any in the future, or if anything gets messed up we can fix it!
-Allow recurring scheduled maintenance
-Let's show scheduled maintenence on the job schedule as well. At the top of the screen
-Make sure maintenence shows on the calendar list view.
-Add viewing quotes on the customer details page so we can see all quotes/jobs for a given customer to make things easier to find.
-Add support for multiple ovens in operating costs
-Display oven selected on quote and job detail pages
-Allow user to choose an oven on a quote, and have it follow through to a job
-Check for any old and outdated code and DB fields!
-Add ability to email a quote
-Add email capabilities
-Add search on super admin companies screen
-Set limits on job photos per app tier
-Check subscription signup page to make sure the selected subscription is actually saved.
-Don't seed the product catalog on a new user
-Check to make sure subscription page has quotes and all fields on it
-Allow customizing of the quote sheets and invoices (If we do them)
-Add feature to allow username changes
-Fix quickbooks imports based on files Colton sent
-Add thicker border around input fields to signify they are text boxes
-Check to make sure emails get sent when a quote is created
-Add buttons to send emails manually if needed
-Modify price calculations to prompt for service times (ie... sandblasting, oven cure times, outgas times etc)
-Add ability to modify items on jobs
-Swap quoting page to use modals to add items to segregate it a bit better.
-Build account ledger/transaction summary view
-Add security for financial pages
-Allow opening balances for accounts
-Create P&L and other reports
-Allow receipet upload on expenses and bills
-Download PDF for invoices throws and error
-Emailing invoice doesn't seem to trigger
-When a customer record has email notifications turned off, disable any email buttons that may send one and alert the user that this customer is set to have notifications turned off.
-When doing anything that sends mail, prompt the user to alert them a message will be sent
-Create a setup wizard for new users that will walk through system setup. Allow re-running later.
-Check Workflow steps in wizard, might need adjusting
-Account Summary, use permanent alert for info message at bottom
-Add steps so that the new user can customize the data lookups and re-order them
-Reorder menu to work better
-Add ability to print a job invoice once completed
-Add ability to email a job invoice
-Integrate invoicing/billing/reports
-Add customer portal to approve quotes from a link for now. We can do a full login later.
-Need a complexity score for quoting parts (Simple, moderate, complex, extreme)
-Add tagging options for quotes and jobs (user driven)
-Can we also add this tag system to quotes and jobs to allow users to tag themselves and we can use that data later as well? We'd have to add a good
description of WHY the user should add some tags though.
-Inventory forecasting might be worth looking into
-Build some AI powder usage predictions into the system
-AI Production Scheduling - Batching enough parts together to fill the oven automagically
-Update dashboard to show some $$$ fields
-Update Setup Wizard
-Update the Setup Checklist
-Modify system to keep running balances of all accounts
- Make sure ALL job updates refresh the Shop Display
-Add multiple item types to add to a quote
AI Agent item where we upload a picture and it will calculate the approximate sq ft and quote from that
-Integration with stripe or square to accept online paymens from our users customers.
-AI Assistant for help
-Allow customer filtering on quotes and jobs
-New job page blanks when validation fails
-Can we keep track of which users have completed the setup wizard?
-Make sure we're tracking logins. I see a user logged on, but the company health page states they have never logged in.
-Allow printing blank work orders (model after the SCP Powder Coating blank work order)
-IDEA: Print powders to use on work order with their QR code so they can be scanned right from there and usage recorded.
-Add ability to save a quoted item to the product catalog either from an AI Photo Quote or from the calculated item
-Add images to product catalog items for easily identification of parts
-Look into possibly having AI scan a product catalog and suggest prices for items.
-Add Oven and Add Blasting Setup don't work in Setup Wizard
-When scanning inventory QR Code, there is no cancel button
-Bug: When scanning Inventory QR Code, if not logged in...it takes you to the dashboard after login, not our inventory scanning screen
-Add SMS capabilities
-Lookup not working 100% correct. If I type columbia as the manufacturer and a color name....it's finding blackmamba from prismatic incorrectly.
-Lookup Modal not showing ALL matches. Maybe make scrollable
-Pickup cure information from TDS Sheet if not found by AI Search
-ON AI Photo Quote page, when the AI info comes back we should scroll the modal window down so it's visible. It's not clear that new info has been added to the modal for all customers
-Inventory Lookup not always finding price for Columbia Coatings
-Logging powder usage and choosing a job doesn't record properly in the activity section of the powder itself
-Need to allow deleting of powder usage entries, or at least editing in case of a goof up
-Still random weird characters on a bunch of pages. Intake button for example on the jobs screen shows: Intake ✓
Ideas Removed
=======================
-Add Deactivate Customer button on Customer Detail page
Logins:
rich@r2r.com/Ragz2Richs123!
rich@cannon.com/Cannon123!
@@ -59,6 +59,9 @@ public class CompanyPreferencesDto
// Blank Work Order PDF Template // Blank Work Order PDF Template
public string WoAccentColor { get; set; } = "#374151"; public string WoAccentColor { get; set; } = "#374151";
public string? WoTerms { get; set; } public string? WoTerms { get; set; }
// Kiosk settings
public string KioskIntakeOutput { get; set; } = "Quote";
} }
public class UpdateAppDefaultsDto public class UpdateAppDefaultsDto
@@ -136,3 +139,11 @@ public class UpdateWorkOrderTemplateDto
public string WoAccentColor { get; set; } = "#374151"; public string WoAccentColor { get; set; } = "#374151";
[StringLength(2000)] public string? WoTerms { get; set; } [StringLength(2000)] public string? WoTerms { get; set; }
} }
public class UpdateKioskSettingsDto
{
/// <summary>"Quote" (default) or "Job" — what the kiosk creates on submission.</summary>
[Required]
public string KioskIntakeOutput { get; set; } = "Quote";
}
@@ -112,6 +112,7 @@ namespace PowderCoating.Application.DTOs.Company
// Labor Rates // Labor Rates
public decimal StandardLaborRate { get; set; } public decimal StandardLaborRate { get; set; }
public decimal? LaborCostPerHour { get; set; }
public decimal AdditionalCoatLaborPercent { get; set; } public decimal AdditionalCoatLaborPercent { get; set; }
// Equipment Operating Costs // Equipment Operating Costs
@@ -185,6 +186,10 @@ namespace PowderCoating.Application.DTOs.Company
[Display(Name = "Standard Labor Rate ($/hr)")] [Display(Name = "Standard Labor Rate ($/hr)")]
public decimal StandardLaborRate { get; set; } public decimal StandardLaborRate { get; set; }
[Range(0, 10000, ErrorMessage = "Labor cost rate must be between 0 and 10,000")]
[Display(Name = "Shop Labor Cost Rate ($/hr)")]
public decimal? LaborCostPerHour { get; set; }
[Range(0, 100, ErrorMessage = "Additional coat labor percent must be between 0 and 100")] [Range(0, 100, ErrorMessage = "Additional coat labor percent must be between 0 and 100")]
[Display(Name = "Additional Coat Labor (%)")] [Display(Name = "Additional Coat Labor (%)")]
public decimal AdditionalCoatLaborPercent { get; set; } = 30m; public decimal AdditionalCoatLaborPercent { get; set; } = 30m;
@@ -16,6 +16,7 @@ public class GiftCertificateListDto
public GiftCertificateStatus Status { get; set; } public GiftCertificateStatus Status { get; set; }
public DateTime IssueDate { get; set; } public DateTime IssueDate { get; set; }
public DateTime? ExpiryDate { get; set; } public DateTime? ExpiryDate { get; set; }
public Guid? BatchId { get; set; }
} }
public class GiftCertificateDto : GiftCertificateListDto public class GiftCertificateDto : GiftCertificateListDto
@@ -87,3 +88,27 @@ public class RedeemGiftCertificateDto
[Range(0.01, 9999.99)] [Range(0.01, 9999.99)]
public decimal Amount { get; set; } public decimal Amount { get; set; }
} }
public class BulkCreateGiftCertificateDto
{
[Required]
[Range(1, 500, ErrorMessage = "Quantity must be between 1 and 500.")]
[Display(Name = "Number of Certificates")]
public int Quantity { get; set; } = 25;
[Required]
[Range(1.00, 9999.99, ErrorMessage = "Amount must be between $1.00 and $9,999.99.")]
[Display(Name = "Face Value (each)")]
public decimal Amount { get; set; }
[Required]
[Display(Name = "Issued Reason")]
public GiftCertificateIssuedReason IssuedReason { get; set; } = GiftCertificateIssuedReason.Promotional;
[Display(Name = "Expiry Date (optional)")]
public DateTime? ExpiryDate { get; set; }
[StringLength(1000)]
[Display(Name = "Event / Notes (applied to all certificates)")]
public string? Notes { get; set; }
}
@@ -1,28 +0,0 @@
using CsvHelper.Configuration.Attributes;
namespace PowderCoating.Application.DTOs.Import;
/// <summary>
/// DTO for importing shop workers from CSV files.
/// Valid Role values: GeneralLabor, Sandblaster, Coater, Masker, QualityControl, OvenOperator, Supervisor, Maintenance
/// </summary>
public class ShopWorkerImportDto
{
[Name("Name")]
public string Name { get; set; } = string.Empty;
[Name("Role")]
public string Role { get; set; } = "GeneralLabor";
[Name("Phone")]
public string? Phone { get; set; }
[Name("Email")]
public string? Email { get; set; }
[Name("IsActive")]
public bool? IsActive { get; set; }
[Name("Notes")]
public string? Notes { get; set; }
}
@@ -32,7 +32,9 @@ public class InvoiceDto
public string CustomerName { get; set; } = string.Empty; public string CustomerName { get; set; } = string.Empty;
public string? CustomerEmail { get; set; } public string? CustomerEmail { get; set; }
public string? CustomerPhone { get; set; } public string? CustomerPhone { get; set; }
public string? CustomerMobilePhone { get; set; }
public bool CustomerNotifyByEmail { get; set; } public bool CustomerNotifyByEmail { get; set; }
public bool CustomerNotifyBySms { get; set; }
public string? PreparedById { get; set; } public string? PreparedById { get; set; }
public string? PreparedByName { get; set; } public string? PreparedByName { get; set; }
public InvoiceStatus Status { get; set; } public InvoiceStatus Status { get; set; }
@@ -515,6 +515,9 @@ public class JobEditItemsViewModel
public string JobNumber { get; set; } = string.Empty; public string JobNumber { get; set; } = string.Empty;
public int? CustomerId { get; set; } public int? CustomerId { get; set; }
public decimal TaxPercent { get; set; } public decimal TaxPercent { get; set; }
public int? OvenCostId { get; set; }
public int OvenBatches { get; set; } = 1;
public int? OvenCycleMinutes { get; set; }
public List<CreateQuoteItemDto> JobItems { get; set; } = new(); public List<CreateQuoteItemDto> JobItems { get; set; } = new();
} }
@@ -0,0 +1,88 @@
using System.ComponentModel.DataAnnotations;
using PowderCoating.Core.Enums;
namespace PowderCoating.Application.DTOs.Kiosk;
// ── Staff-facing ──────────────────────────────────────────────────────────────
/// <summary>Input for sending a remote intake link to a customer by email.</summary>
public class SendRemoteLinkDto
{
[Required, EmailAddress]
public string Email { get; set; } = string.Empty;
/// <summary>Optional — used to personalise the email greeting.</summary>
public string? CustomerName { get; set; }
}
// ── Customer-facing step DTOs ─────────────────────────────────────────────────
/// <summary>Step 1 — Contact information submitted by the customer.</summary>
public class SubmitKioskContactDto
{
[Required, MaxLength(100)]
public string FirstName { get; set; } = string.Empty;
[Required, MaxLength(100)]
public string LastName { get; set; } = string.Empty;
[Required, Phone]
public string Phone { get; set; } = string.Empty;
[Required, EmailAddress]
public string Email { get; set; } = string.Empty;
public bool IsReturningCustomer { get; set; }
}
/// <summary>Step 2 — Job description submitted by the customer.</summary>
public class SubmitKioskJobDto
{
[Required, MaxLength(2000)]
public string JobDescription { get; set; } = string.Empty;
public string? HowDidYouHearAboutUs { get; set; }
}
/// <summary>Step 3 — Terms agreement (+ optional drawn signature for in-person sessions).</summary>
public class SubmitKioskTermsDto
{
[Required]
[Range(typeof(bool), "true", "true", ErrorMessage = "You must agree to the terms to continue.")]
public bool AgreedToTerms { get; set; }
public bool SmsOptIn { get; set; }
/// <summary>Base-64 PNG from signature_pad; required for InPerson sessions, null for Remote.</summary>
public string? SignatureDataBase64 { get; set; }
}
// ── Staff review list ─────────────────────────────────────────────────────────
/// <summary>One row in the Kiosk Intakes staff review list.</summary>
public class KioskSessionListDto
{
public int Id { get; set; }
public Guid SessionToken { get; set; }
public KioskSessionType SessionType { get; set; }
public KioskSessionStatus Status { get; set; }
public string CustomerFirstName { get; set; } = string.Empty;
public string CustomerLastName { get; set; } = string.Empty;
public string CustomerEmail { get; set; } = string.Empty;
public string CustomerPhone { get; set; } = string.Empty;
public string JobDescription { get; set; } = string.Empty;
public bool SmsOptIn { get; set; }
public DateTime? SubmittedAt { get; set; }
public DateTime ExpiresAt { get; set; }
public int? LinkedCustomerId { get; set; }
public int? LinkedJobId { get; set; }
public int? LinkedQuoteId { get; set; }
public string? RemoteLinkEmail { get; set; }
public string CustomerFullName => $"{CustomerFirstName} {CustomerLastName}".Trim();
public string JobDescriptionSnippet =>
JobDescription.Length > 80 ? JobDescription[..80] + "…" : JobDescription;
public bool IsConverted => LinkedJobId.HasValue || LinkedQuoteId.HasValue;
public bool IsExpired => Status == KioskSessionStatus.Expired ||
(Status == KioskSessionStatus.Active && DateTime.UtcNow > ExpiresAt);
}
@@ -604,6 +604,11 @@ public class QuotePricingBreakdownDto
public decimal SubtotalBeforeDiscount { get; set; } public decimal SubtotalBeforeDiscount { get; set; }
public decimal PricingTierDiscountAmount { get; set; }
public decimal PricingTierDiscountPercent { get; set; }
public decimal QuoteDiscountAmount { get; set; }
public decimal QuoteDiscountPercent { get; set; }
public decimal DiscountAmount { get; set; } public decimal DiscountAmount { get; set; }
public decimal DiscountPercent { get; set; } public decimal DiscountPercent { get; set; }
@@ -1,27 +0,0 @@
using System.ComponentModel.DataAnnotations;
using PowderCoating.Core.Enums;
namespace PowderCoating.Application.DTOs.ShopWorker;
public class CreateShopWorkerDto
{
[Required(ErrorMessage = "Worker name is required")]
[StringLength(100, ErrorMessage = "Name cannot exceed 100 characters")]
public string Name { get; set; } = string.Empty;
[Required(ErrorMessage = "Role is required")]
public ShopWorkerRole Role { get; set; } = ShopWorkerRole.GeneralLabor;
[Phone(ErrorMessage = "Invalid phone number format")]
[StringLength(20, ErrorMessage = "Phone cannot exceed 20 characters")]
public string? Phone { get; set; }
[EmailAddress(ErrorMessage = "Invalid email address format")]
[StringLength(100, ErrorMessage = "Email cannot exceed 100 characters")]
public string? Email { get; set; }
public bool IsActive { get; set; } = true;
[StringLength(500, ErrorMessage = "Notes cannot exceed 500 characters")]
public string? Notes { get; set; }
}
@@ -1,16 +0,0 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Application.DTOs.ShopWorker;
public class ShopWorkerDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public ShopWorkerRole Role { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public bool IsActive { get; set; }
public string? Notes { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
}
@@ -1,29 +0,0 @@
using System.ComponentModel.DataAnnotations;
using PowderCoating.Core.Enums;
namespace PowderCoating.Application.DTOs.ShopWorker;
public class UpdateShopWorkerDto
{
public int Id { get; set; }
[Required(ErrorMessage = "Worker name is required")]
[StringLength(100, ErrorMessage = "Name cannot exceed 100 characters")]
public string Name { get; set; } = string.Empty;
[Required(ErrorMessage = "Role is required")]
public ShopWorkerRole Role { get; set; }
[Phone(ErrorMessage = "Invalid phone number format")]
[StringLength(20, ErrorMessage = "Phone cannot exceed 20 characters")]
public string? Phone { get; set; }
[EmailAddress(ErrorMessage = "Invalid email address format")]
[StringLength(100, ErrorMessage = "Email cannot exceed 100 characters")]
public string? Email { get; set; }
public bool IsActive { get; set; }
[StringLength(500, ErrorMessage = "Notes cannot exceed 500 characters")]
public string? Notes { get; set; }
}
@@ -217,6 +217,10 @@ public class UpdateCompanyUserDto
[Display(Name = "Active")] [Display(Name = "Active")]
public bool IsActive { get; set; } public bool IsActive { get; set; }
[Range(0, 10000, ErrorMessage = "Labor cost rate must be between 0 and 10,000")]
[Display(Name = "Labor Cost Rate ($/hr)")]
public decimal? LaborCostPerHour { get; set; }
[Required(ErrorMessage = "Hire date is required")] [Required(ErrorMessage = "Hire date is required")]
[Display(Name = "Hire Date")] [Display(Name = "Hire Date")]
public DateTime HireDate { get; set; } public DateTime HireDate { get; set; }
@@ -136,18 +136,7 @@ public interface ICsvImportService
/// </summary> /// </summary>
Task<CsvImportResultDto> ImportVendorsAsync(Stream csvStream, int companyId); Task<CsvImportResultDto> ImportVendorsAsync(Stream csvStream, int companyId);
/// <summary> /// <summary>
/// Generate a CSV template file for shop worker imports.
/// </summary>
byte[] GenerateShopWorkerTemplate();
/// <summary>
/// Import shop workers from a CSV stream.
/// Updates existing workers matched by Name; creates new ones otherwise.
/// </summary>
Task<CsvImportResultDto> ImportShopWorkersAsync(Stream csvStream, int companyId);
/// <summary>
/// Generate a CSV template file for prep service imports. /// Generate a CSV template file for prep service imports.
/// </summary> /// </summary>
byte[] GeneratePrepServiceTemplate(); byte[] GeneratePrepServiceTemplate();
@@ -58,7 +58,7 @@ public interface INotificationService
/// Notify customer when an invoice has been sent. /// Notify customer when an invoice has been sent.
/// Optionally includes an online payment link in the email body. /// Optionally includes an online payment link in the email body.
/// </summary> /// </summary>
Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null); Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null, bool sendSms = false, string? viewUrl = null);
/// <summary> /// <summary>
/// Notify customer (internal) when a payment has been recorded on an invoice. /// Notify customer (internal) when a payment has been recorded on an invoice.
@@ -51,4 +51,10 @@ public interface IPdfService
byte[]? companyLogo, byte[]? companyLogo,
string? companyLogoContentType, string? companyLogoContentType,
CompanyInfoDto companyInfo); CompanyInfoDto companyInfo);
Task<byte[]> GenerateBulkGiftCertificatePdfAsync(
IList<GiftCertificateDto> certs,
byte[]? companyLogo,
string? companyLogoContentType,
CompanyInfoDto companyInfo);
} }
@@ -54,5 +54,6 @@ public class CompanyProfile : Profile
CreateMap<UpdateQuoteTemplateDto, CompanyPreferences>(); CreateMap<UpdateQuoteTemplateDto, CompanyPreferences>();
CreateMap<UpdateInvoiceTemplateDto, CompanyPreferences>(); CreateMap<UpdateInvoiceTemplateDto, CompanyPreferences>();
CreateMap<UpdateWorkOrderTemplateDto, CompanyPreferences>(); CreateMap<UpdateWorkOrderTemplateDto, CompanyPreferences>();
CreateMap<UpdateKioskSettingsDto, CompanyPreferences>();
} }
} }
@@ -28,7 +28,9 @@ public class InvoiceProfile : Profile
? (s.Customer.BillingEmail ?? s.Customer.Email) ? (s.Customer.BillingEmail ?? s.Customer.Email)
: null)) : null))
.ForMember(d => d.CustomerPhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.Phone : 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.CustomerNotifyByEmail, o => o.MapFrom(s => s.Customer == null || s.Customer.NotifyByEmail)) .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 .ForMember(d => d.PreparedByName, o => o.MapFrom(s => s.PreparedBy != null
? $"{s.PreparedBy.FirstName} {s.PreparedBy.LastName}".Trim() ? $"{s.PreparedBy.FirstName} {s.PreparedBy.LastName}".Trim()
: null)) : null))
@@ -73,7 +73,7 @@ public class JobProfile : Profile
// JobTimeEntry → JobTimeEntryDto // JobTimeEntry → JobTimeEntryDto
CreateMap<JobTimeEntry, JobTimeEntryDto>() CreateMap<JobTimeEntry, JobTimeEntryDto>()
.ForMember(dest => dest.WorkerName, opt => opt.MapFrom(src => .ForMember(dest => dest.WorkerName, opt => opt.MapFrom(src =>
src.UserDisplayName ?? (src.Worker != null ? src.Worker.Name : string.Empty))); src.UserDisplayName ?? string.Empty));
// CreateJobDto to Job // CreateJobDto to Job
CreateMap<CreateJobDto, Job>() CreateMap<CreateJobDto, Job>()
@@ -1,23 +0,0 @@
using AutoMapper;
using PowderCoating.Application.DTOs.ShopWorker;
using PowderCoating.Core.Entities;
namespace PowderCoating.Application.Mappings;
public class ShopWorkerProfile : Profile
{
public ShopWorkerProfile()
{
// Entity to DTO
CreateMap<ShopWorker, ShopWorkerDto>();
// DTO to Entity
CreateMap<CreateShopWorkerDto, ShopWorker>();
CreateMap<UpdateShopWorkerDto, ShopWorker>();
// Reverse mappings
CreateMap<ShopWorkerDto, ShopWorker>();
CreateMap<ShopWorker, CreateShopWorkerDto>();
CreateMap<ShopWorker, UpdateShopWorkerDto>();
}
}
@@ -21,12 +21,13 @@ public class JobItemAssemblyService : IJobItemAssemblyService
IsGenericItem = source.IsGenericItem, IsGenericItem = source.IsGenericItem,
IsLaborItem = source.IsLaborItem, IsLaborItem = source.IsLaborItem,
IsSalesItem = source.IsSalesItem, IsSalesItem = source.IsSalesItem,
IsAiItem = source.IsAiItem,
Sku = source.Sku, Sku = source.Sku,
ManualUnitPrice = source.ManualUnitPrice, ManualUnitPrice = source.ManualUnitPrice,
PowderCostOverride = source.PowderCostOverride, PowderCostOverride = source.PowderCostOverride,
UnitPrice = pricing.UnitPrice, UnitPrice = pricing.UnitPrice,
TotalPrice = pricing.TotalPrice, TotalPrice = pricing.TotalPrice,
LaborCost = pricing.TotalPrice * 0.4m, LaborCost = pricing.LaborCost,
RequiresSandblasting = source.RequiresSandblasting, RequiresSandblasting = source.RequiresSandblasting,
RequiresMasking = source.RequiresMasking, RequiresMasking = source.RequiresMasking,
EstimatedMinutes = source.EstimatedMinutes, EstimatedMinutes = source.EstimatedMinutes,
@@ -106,12 +107,13 @@ public class JobItemAssemblyService : IJobItemAssemblyService
IsGenericItem = source.IsGenericItem, IsGenericItem = source.IsGenericItem,
IsLaborItem = source.IsLaborItem, IsLaborItem = source.IsLaborItem,
IsSalesItem = source.IsSalesItem, IsSalesItem = source.IsSalesItem,
IsAiItem = source.IsAiItem,
Sku = source.Sku, Sku = source.Sku,
ManualUnitPrice = source.ManualUnitPrice, ManualUnitPrice = source.ManualUnitPrice,
PowderCostOverride = source.PowderCostOverride, PowderCostOverride = source.PowderCostOverride,
UnitPrice = source.UnitPrice, UnitPrice = source.UnitPrice,
TotalPrice = source.TotalPrice, TotalPrice = source.TotalPrice,
LaborCost = source.TotalPrice * 0.4m, LaborCost = source.ItemLaborCost,
RequiresSandblasting = source.RequiresSandblasting, RequiresSandblasting = source.RequiresSandblasting,
RequiresMasking = source.RequiresMasking, RequiresMasking = source.RequiresMasking,
EstimatedMinutes = source.EstimatedMinutes, EstimatedMinutes = source.EstimatedMinutes,
@@ -191,6 +193,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
IsGenericItem = source.IsGenericItem, IsGenericItem = source.IsGenericItem,
IsLaborItem = source.IsLaborItem, IsLaborItem = source.IsLaborItem,
IsSalesItem = source.IsSalesItem, IsSalesItem = source.IsSalesItem,
IsAiItem = source.IsAiItem,
Sku = source.Sku, Sku = source.Sku,
ManualUnitPrice = source.ManualUnitPrice, ManualUnitPrice = source.ManualUnitPrice,
PowderCostOverride = source.PowderCostOverride, PowderCostOverride = source.PowderCostOverride,
@@ -270,6 +273,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
IsGenericItem = seed.IsGenericItem, IsGenericItem = seed.IsGenericItem,
IsLaborItem = seed.IsLaborItem, IsLaborItem = seed.IsLaborItem,
IsSalesItem = seed.IsSalesItem, IsSalesItem = seed.IsSalesItem,
IsAiItem = seed.IsAiItem,
Sku = seed.Sku, Sku = seed.Sku,
ManualUnitPrice = seed.ManualUnitPrice, ManualUnitPrice = seed.ManualUnitPrice,
PowderCostOverride = seed.PowderCostOverride, PowderCostOverride = seed.PowderCostOverride,
@@ -364,6 +368,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
public bool IsGenericItem { get; init; } public bool IsGenericItem { get; init; }
public bool IsLaborItem { get; init; } public bool IsLaborItem { get; init; }
public bool IsSalesItem { get; init; } public bool IsSalesItem { get; init; }
public bool IsAiItem { get; init; }
public string? Sku { get; init; } public string? Sku { get; init; }
public decimal? ManualUnitPrice { get; init; } public decimal? ManualUnitPrice { get; init; }
public decimal? PowderCostOverride { get; init; } public decimal? PowderCostOverride { get; init; }
@@ -1858,6 +1858,50 @@ public class PdfService : IPdfService
}); });
} }
/// <summary>
/// Generates a multi-page PDF containing one gift certificate per page, all using the same
/// branded layout as the single-certificate download. Used for bulk print runs (car shows,
/// promotions) so staff can hand-cut and distribute a full batch from one print job.
/// </summary>
public async Task<byte[]> GenerateBulkGiftCertificatePdfAsync(
IList<GiftCertificateDto> certs,
byte[]? companyLogo,
string? companyLogoContentType,
CompanyInfoDto companyInfo)
{
QuestPDF.Settings.License = LicenseType.Community;
const string accent = "#7c3aed";
const string gold = "#b45309";
return await Task.Run(() =>
{
var doc = Document.Create(container =>
{
foreach (var cert in certs)
{
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.Content().Element(c => ComposeGiftCertificateContent(c, cert, companyInfo, companyLogo, accent, gold));
page.Footer().AlignCenter().Text(text =>
{
text.Span(companyInfo.CompanyName).FontSize(8).FontColor(Colors.Grey.Darken1);
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
text.Span($" · {FormatPhoneNumber(companyInfo.Phone)}").FontSize(8).FontColor(Colors.Grey.Darken1);
});
});
}
});
return doc.GeneratePdf();
});
}
/// <summary> /// <summary>
/// Composes the gift certificate body with a decorative double-border frame (outer purple 3pt, /// Composes the gift certificate body with a decorative double-border frame (outer purple 3pt,
/// inner gold 1pt) that gives the document a premium printed-certificate appearance. Inside the /// inner gold 1pt) that gives the document a premium printed-certificate appearance. Inside the
@@ -35,6 +35,8 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
quote.EquipmentCosts = pricingResult.EquipmentCosts; quote.EquipmentCosts = pricingResult.EquipmentCosts;
quote.ItemsSubtotal = pricingResult.ItemsSubtotal; quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
quote.OvenBatchCost = pricingResult.OvenBatchCost; quote.OvenBatchCost = pricingResult.OvenBatchCost;
quote.FacilityOverheadCost = pricingResult.FacilityOverheadCost;
quote.FacilityOverheadRatePerHour = pricingResult.FacilityOverheadRatePerHour;
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount; quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent; quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
quote.OverheadAmount = pricingResult.OverheadCosts; quote.OverheadAmount = pricingResult.OverheadCosts;
@@ -42,8 +44,13 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
quote.ProfitMargin = pricingResult.ProfitMargin; quote.ProfitMargin = pricingResult.ProfitMargin;
quote.ProfitPercent = pricingResult.ProfitPercent; quote.ProfitPercent = pricingResult.ProfitPercent;
quote.SubTotal = pricingResult.SubtotalBeforeDiscount; quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
quote.PricingTierDiscountAmount = pricingResult.PricingTierDiscountAmount;
quote.PricingTierDiscountPercent = pricingResult.PricingTierDiscountPercent;
quote.QuoteDiscountAmount = pricingResult.QuoteDiscountAmount;
quote.QuoteDiscountPercent = pricingResult.QuoteDiscountPercent;
quote.DiscountPercent = pricingResult.DiscountPercent; quote.DiscountPercent = pricingResult.DiscountPercent;
quote.DiscountAmount = pricingResult.DiscountAmount; quote.DiscountAmount = pricingResult.DiscountAmount;
quote.SubtotalAfterDiscount = pricingResult.SubtotalAfterDiscount;
quote.RushFee = pricingResult.RushFee; quote.RushFee = pricingResult.RushFee;
quote.TaxAmount = pricingResult.TaxAmount; quote.TaxAmount = pricingResult.TaxAmount;
quote.Total = pricingResult.Total; quote.Total = pricingResult.Total;
@@ -59,6 +59,13 @@ public class ApplicationUser : IdentityUser
public string? SidebarColor { get; set; } = "ocean"; public string? SidebarColor { get; set; } = "ocean";
public string? Notes { get; set; } public string? Notes { get; set; }
/// <summary>
/// Per-worker labor cost per hour used for job costing profit/margin calculations.
/// Overrides the company-level LaborCostPerHour when set.
/// Leave null to use the company default.
/// </summary>
public decimal? LaborCostPerHour { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? UpdatedAt { get; set; } public DateTime? UpdatedAt { get; set; }
public DateTime? LastLoginDate { get; set; } public DateTime? LastLoginDate { get; set; }
+11 -2
View File
@@ -123,6 +123,16 @@ public class Company : BaseEntity
public byte[]? LogoData { get; set; } // Legacy - kept for backward compatibility public byte[]? LogoData { get; set; } // Legacy - kept for backward compatibility
public string? LogoContentType { get; set; } // Legacy - kept for backward compatibility public string? LogoContentType { get; set; } // Legacy - kept for backward compatibility
public string? LogoFilePath { get; set; } // Filesystem path: /media/{CompanyId}/company-logo.{ext} public string? LogoFilePath { get; set; } // Filesystem path: /media/{CompanyId}/company-logo.{ext}
// Kiosk
/// <summary>
/// Random token written to a long-lived HttpOnly cookie on the front-desk tablet when the
/// owner activates the kiosk. Kiosk routes validate this token against the cookie so the
/// tablet can serve the intake form without requiring a logged-in user.
/// Null = kiosk not activated. Regenerate to revoke the current device.
/// </summary>
public string? KioskActivationToken { get; set; }
// Navigation Properties // Navigation Properties
public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>(); public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>();
public virtual ICollection<Customer> Customers { get; set; } = new List<Customer>(); public virtual ICollection<Customer> Customers { get; set; } = new List<Customer>();
@@ -131,8 +141,7 @@ public class Company : BaseEntity
public virtual ICollection<Quote> Quotes { get; set; } = new List<Quote>(); public virtual ICollection<Quote> Quotes { get; set; } = new List<Quote>();
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>(); public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
public virtual ICollection<Vendor> Vendors { get; set; } = new List<Vendor>(); public virtual ICollection<Vendor> Vendors { get; set; } = new List<Vendor>();
public virtual ICollection<ShopWorker> ShopWorkers { get; set; } = new List<ShopWorker>(); public virtual ICollection<PricingTier> PricingTiers { get; set; } = new List<PricingTier>();
public virtual ICollection<PricingTier> PricingTiers { get; set; } = new List<PricingTier>();
public virtual CompanyOperatingCosts? OperatingCosts { get; set; } public virtual CompanyOperatingCosts? OperatingCosts { get; set; }
public virtual CompanyPreferences? Preferences { get; set; } public virtual CompanyPreferences? Preferences { get; set; }
} }
@@ -13,6 +13,14 @@ namespace PowderCoating.Core.Entities
[Range(0, 10000)] [Range(0, 10000)]
public decimal StandardLaborRate { get; set; } public decimal StandardLaborRate { get; set; }
/// <summary>
/// Actual labor cost per hour (wages + burden) used exclusively for internal job costing and profit/margin display.
/// This is NOT the billing rate — it should reflect what you actually pay workers.
/// When null, the costing engine defaults to 20% of StandardLaborRate.
/// </summary>
[Range(0, 10000)]
public decimal? LaborCostPerHour { get; set; }
// Additional Coat Labor Percentage (percentage of base labor for each additional coat beyond the first) // Additional Coat Labor Percentage (percentage of base labor for each additional coat beyond the first)
[Range(0, 100)] [Range(0, 100)]
public decimal AdditionalCoatLaborPercent { get; set; } = 30m; public decimal AdditionalCoatLaborPercent { get; set; } = 30m;
@@ -86,6 +86,14 @@ public class CompanyPreferences : BaseEntity
/// <summary>JSON blob persisting QB Migration Wizard step state across sessions.</summary> /// <summary>JSON blob persisting QB Migration Wizard step state across sessions.</summary>
public string? QbMigrationStateJson { get; set; } public string? QbMigrationStateJson { get; set; }
// Kiosk settings
/// <summary>
/// Controls what the kiosk creates on submission: "Quote" (default) or "Job".
/// Quote aligns with the default Terms text ("subject to a formal quote").
/// Job is for shops that price on the spot and want the work order ready immediately.
/// </summary>
public string KioskIntakeOutput { get; set; } = "Quote";
// Guided activation / first-workflow onboarding // Guided activation / first-workflow onboarding
/// <summary>Selected first-workflow path: quote_first or job_first. Null until chosen.</summary> /// <summary>Selected first-workflow path: quote_first or job_first. Null until chosen.</summary>
public string? OnboardingPath { get; set; } public string? OnboardingPath { get; set; }
@@ -32,6 +32,9 @@ public class GiftCertificate : BaseEntity
/// <summary>Set when this GC was sold via an invoice line item.</summary> /// <summary>Set when this GC was sold via an invoice line item.</summary>
public int? SourceInvoiceItemId { get; set; } public int? SourceInvoiceItemId { get; set; }
/// <summary>Groups all certificates created in a single bulk run. Null for individually issued certs.</summary>
public Guid? BatchId { get; set; }
// Navigation // Navigation
public virtual Customer? RecipientCustomer { get; set; } public virtual Customer? RecipientCustomer { get; set; }
public virtual Customer? PurchasingCustomer { get; set; } public virtual Customer? PurchasingCustomer { get; set; }
@@ -28,6 +28,13 @@ public class Invoice : BaseEntity
public decimal GiftCertificateRedeemed { get; set; } // Sum of gift certificate redemptions public decimal GiftCertificateRedeemed { get; set; } // Sum of gift certificate redemptions
public decimal BalanceDue => Total - AmountPaid - CreditApplied - GiftCertificateRedeemed; public decimal BalanceDue => Total - AmountPaid - CreditApplied - GiftCertificateRedeemed;
/// <summary>
/// Permanent public token for the customer-facing invoice view page (/invoice/{token}).
/// Generated when the invoice is first sent (regardless of Stripe status) and never expires.
/// Distinct from PaymentLinkToken which is Stripe-gated and expires in 5 days.
/// </summary>
public string? PublicViewToken { get; set; }
// Online payments (Stripe Connect) // Online payments (Stripe Connect)
public OnlinePaymentStatus OnlinePaymentStatus { get; set; } = OnlinePaymentStatus.NotApplicable; public OnlinePaymentStatus OnlinePaymentStatus { get; set; } = OnlinePaymentStatus.NotApplicable;
public string? PaymentLinkToken { get; set; } // Signed token for /pay/{token} public string? PaymentLinkToken { get; set; } // Signed token for /pay/{token}
+8
View File
@@ -25,6 +25,10 @@ public class Job : BaseEntity
// Selected oven (carried over from quote; null = company default rate) // Selected oven (carried over from quote; null = company default rate)
public int? OvenCostId { get; set; } public int? OvenCostId { get; set; }
// Oven scheduling (carried over from quote)
public int OvenBatches { get; set; } = 1;
public int? OvenCycleMinutes { get; set; }
// Pricing // Pricing
public decimal QuotedPrice { get; set; } public decimal QuotedPrice { get; set; }
public decimal FinalPrice { get; set; } public decimal FinalPrice { get; set; }
@@ -62,6 +66,10 @@ public class Job : BaseEntity
// Used to detect when the quote was subsequently edited so the job details page can warn the user. // Used to detect when the quote was subsequently edited so the job details page can warn the user.
public DateTime? QuoteSnapshotUpdatedAt { get; set; } public DateTime? QuoteSnapshotUpdatedAt { get; set; }
// Pricing snapshot — serialized QuotePricingBreakdownDto stored at save time so Details displays
// the breakdown that was actually calculated, not a re-run against current operating costs.
public string? PricingBreakdownJson { get; set; }
// Rework tracking // Rework tracking
public bool IsReworkJob { get; set; } public bool IsReworkJob { get; set; }
public int? OriginalJobId { get; set; } // Set when this job was created as a rework public int? OriginalJobId { get; set; } // Set when this job was created as a rework
@@ -41,6 +41,10 @@ public class JobItem : BaseEntity
// Values: "Simple" | "Moderate" | "Complex" | "Extreme" // Values: "Simple" | "Moderate" | "Complex" | "Extreme"
public string? Complexity { get; set; } public string? Complexity { get; set; }
// True when this item originated from an AI Photo Quote — ManualUnitPrice is used as-is
// and oven cost is not double-charged (it was excluded from the AI estimate at quote level).
public bool IsAiItem { get; set; }
// AI-generated standardized tags (comma-separated, e.g. "automotive,tubular") // AI-generated standardized tags (comma-separated, e.g. "automotive,tubular")
public string? AiTags { get; set; } public string? AiTags { get; set; }
@@ -3,7 +3,6 @@ namespace PowderCoating.Core.Entities;
public class JobTimeEntry : BaseEntity public class JobTimeEntry : BaseEntity
{ {
public int JobId { get; set; } public int JobId { get; set; }
public int? ShopWorkerId { get; set; } // legacy — kept for entries created before user migration
public string? UserId { get; set; } // FK to AspNetUsers public string? UserId { get; set; } // FK to AspNetUsers
public string? UserDisplayName { get; set; } // snapshot of worker name at entry creation time public string? UserDisplayName { get; set; } // snapshot of worker name at entry creation time
public DateTime WorkDate { get; set; } public DateTime WorkDate { get; set; }
@@ -13,5 +12,4 @@ public class JobTimeEntry : BaseEntity
// Navigation // Navigation
public virtual Job Job { get; set; } = null!; public virtual Job Job { get; set; } = null!;
public virtual ShopWorker? Worker { get; set; } // nullable — only populated for legacy entries
} }
@@ -0,0 +1,54 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
/// <summary>
/// Represents one customer self-service intake session — either completed on the front-desk tablet
/// (InPerson) or via an emailed link the customer fills out on their own device (Remote).
/// Sessions are tenant-scoped and soft-deletable. Load anonymous sessions with ignoreQueryFilters:true.
/// </summary>
public class KioskSession : BaseEntity
{
/// <summary>URL-safe GUID used in all kiosk routes; unique across the table.</summary>
public Guid SessionToken { get; set; } = Guid.NewGuid();
public KioskSessionType SessionType { get; set; }
public KioskSessionStatus Status { get; set; } = KioskSessionStatus.Active;
// ── Step 1 — Contact ─────────────────────────────────────────────────────
public string CustomerFirstName { get; set; } = string.Empty;
public string CustomerLastName { get; set; } = string.Empty;
public string CustomerPhone { get; set; } = string.Empty;
public string CustomerEmail { get; set; } = string.Empty;
public bool IsReturningCustomer { get; set; }
// ── Step 2 — Job Description ──────────────────────────────────────────────
public string JobDescription { get; set; } = string.Empty;
public string? HowDidYouHearAboutUs { get; set; }
// ── Step 3 — Terms & Consent ──────────────────────────────────────────────
public bool AgreedToTerms { get; set; }
public DateTime? AgreedToTermsAt { get; set; }
/// <summary>Customer opted in to SMS order updates; sets Customer.NotifyBySms on submission.</summary>
public bool SmsOptIn { get; set; }
/// <summary>Base-64 PNG from signature_pad; null for Remote sessions (no drawn signature required).</summary>
public string? SignatureDataBase64 { get; set; }
// ── Outcome ───────────────────────────────────────────────────────────────
public int? LinkedCustomerId { get; set; }
/// <summary>Set when KioskIntakeOutput = "Job". Null when a Quote was created instead.</summary>
public int? LinkedJobId { get; set; }
/// <summary>Set when KioskIntakeOutput = "Quote". Null when a Job was created instead.</summary>
public int? LinkedQuoteId { get; set; }
public DateTime? SubmittedAt { get; set; }
/// <summary>Sessions auto-expire 2 h after creation (InPerson) or 48 h (Remote). ExpiresAt is set at creation.</summary>
public DateTime ExpiresAt { get; set; }
// ── Remote-only ───────────────────────────────────────────────────────────
public string? RemoteLinkEmail { get; set; }
public DateTime? RemoteLinkSentAt { get; set; }
// ── Navigation ────────────────────────────────────────────────────────────
public virtual Customer? LinkedCustomer { get; set; }
public virtual Job? LinkedJob { get; set; }
}
+14 -7
View File
@@ -45,21 +45,28 @@ public class Quote : BaseEntity
public decimal EquipmentCosts { get; set; } // Sum of equipment costs across all items public decimal EquipmentCosts { get; set; } // Sum of equipment costs across all items
public decimal ItemsSubtotal { get; set; } // Sum of item prices before any quote-level costs public decimal ItemsSubtotal { get; set; } // Sum of item prices before any quote-level costs
public decimal OvenBatchCost { get; set; } // Oven batch charge applied at quote level public decimal OvenBatchCost { get; set; } // Oven batch charge applied at quote level
public decimal FacilityOverheadCost { get; set; } // Rent + utilities apportioned by estimated job hours
public decimal FacilityOverheadRatePerHour { get; set; }// Rate used for facility overhead ($/hr)
public decimal ShopSuppliesAmount { get; set; } // Shop supplies dollar amount public decimal ShopSuppliesAmount { get; set; } // Shop supplies dollar amount
public decimal ShopSuppliesPercent { get; set; } // Shop supplies percentage used public decimal ShopSuppliesPercent { get; set; } // Shop supplies percentage used
public decimal OverheadAmount { get; set; } // Overhead dollar amount public decimal OverheadAmount { get; set; } // Legacy overhead (now always 0; kept for migration safety)
public decimal OverheadPercent { get; set; } // Overhead percentage used public decimal OverheadPercent { get; set; } // Legacy overhead percent
public decimal ProfitMargin { get; set; } // Profit margin dollar amount public decimal ProfitMargin { get; set; } // Profit margin dollar amount (0 — baked into item prices)
public decimal ProfitPercent { get; set; } // Profit margin percentage used public decimal ProfitPercent { get; set; } // Markup % used (for display reference)
public decimal SubTotal { get; set; } // SubtotalBeforeDiscount (items + oven + overhead + profit + shop supplies) public decimal SubTotal { get; set; } // SubtotalBeforeDiscount (items + oven + facility overhead + shop supplies)
// Discount Information // Discount Information
public DiscountType DiscountType { get; set; } = DiscountType.None; public DiscountType DiscountType { get; set; } = DiscountType.None;
public decimal DiscountValue { get; set; } = 0; // Value entered by user (percentage or fixed amount) public decimal DiscountValue { get; set; } = 0; // Value entered by user (percentage or fixed amount)
public decimal DiscountPercent { get; set; } // Calculated: actual percentage applied public decimal PricingTierDiscountAmount { get; set; } // Discount from customer's pricing tier
public decimal DiscountAmount { get; set; } // Calculated: actual dollar amount deducted public decimal PricingTierDiscountPercent { get; set; } // Tier discount percentage
public decimal QuoteDiscountAmount { get; set; } // Manual quote-level discount amount
public decimal QuoteDiscountPercent { get; set; } // Manual quote-level discount percentage
public decimal DiscountPercent { get; set; } // Combined: actual percentage applied
public decimal DiscountAmount { get; set; } // Combined: actual dollar amount deducted
public string? DiscountReason { get; set; } // Why discount was applied public string? DiscountReason { get; set; } // Why discount was applied
public bool HideDiscountFromCustomer { get; set; } = false; // Show only total on PDFs/portal public bool HideDiscountFromCustomer { get; set; } = false; // Show only total on PDFs/portal
public decimal SubtotalAfterDiscount { get; set; } // SubTotal minus all discounts, before rush/tax
public decimal TaxPercent { get; set; } public decimal TaxPercent { get; set; }
public decimal TaxAmount { get; set; } public decimal TaxAmount { get; set; }
@@ -1,18 +0,0 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
public class ShopWorker : BaseEntity
{
public string Name { get; set; } = string.Empty;
public ShopWorkerRole Role { get; set; } = ShopWorkerRole.GeneralLabor;
public string? Phone { get; set; }
public string? Email { get; set; }
public bool IsActive { get; set; } = true;
public string? Notes { get; set; }
// Relationships
public virtual ICollection<Job> AssignedJobs { get; set; } = new List<Job>();
public virtual ICollection<MaintenanceRecord> AssignedMaintenanceTasks { get; set; } = new List<MaintenanceRecord>();
public virtual ICollection<JobTimeEntry> TimeEntries { get; set; } = new List<JobTimeEntry>();
}
@@ -1,15 +0,0 @@
using PowderCoating.Core.Enums;
namespace PowderCoating.Core.Entities;
/// <summary>
/// Optional per-role labor cost rate for job costing / profitability calculations.
/// If no rate is set for a role, the company's StandardLaborRate is used as fallback.
/// </summary>
public class ShopWorkerRoleCost : BaseEntity
{
public ShopWorkerRole Role { get; set; }
/// <summary>Cost (pay rate) per hour for this role — used in job costing, NOT billing.</summary>
public decimal HourlyRate { get; set; }
}
-11
View File
@@ -78,17 +78,6 @@ public enum EquipmentStatus
Retired = 4 Retired = 4
} }
public enum ShopWorkerRole
{
GeneralLabor = 0,
Sandblaster = 1,
Coater = 2,
Masker = 3,
QualityControl = 4,
OvenOperator = 5,
Supervisor = 6,
Maintenance = 7
}
public enum JobPhotoType public enum JobPhotoType
{ {
@@ -0,0 +1,15 @@
namespace PowderCoating.Core.Enums;
public enum KioskSessionType
{
InPerson = 0,
Remote = 1
}
public enum KioskSessionStatus
{
Active = 0,
Submitted = 1,
Expired = 2,
Cancelled = 3
}
@@ -54,9 +54,7 @@ public interface IUnitOfWork : IDisposable
IRepository<AppointmentStatusLookup> AppointmentStatusLookups { get; } IRepository<AppointmentStatusLookup> AppointmentStatusLookups { get; }
IRepository<AppointmentTypeLookup> AppointmentTypeLookups { get; } IRepository<AppointmentTypeLookup> AppointmentTypeLookups { get; }
IRepository<PrepService> PrepServices { get; } IRepository<PrepService> PrepServices { get; }
IRepository<ShopWorker> ShopWorkers { get; } IRepository<ReworkRecord> ReworkRecords { get; }
IRepository<ShopWorkerRoleCost> ShopWorkerRoleCosts { get; }
IRepository<ReworkRecord> ReworkRecords { get; }
IRepository<Refund> Refunds { get; } IRepository<Refund> Refunds { get; }
IRepository<CreditMemo> CreditMemos { get; } IRepository<CreditMemo> CreditMemos { get; }
IRepository<CreditMemoApplication> CreditMemoApplications { get; } IRepository<CreditMemoApplication> CreditMemoApplications { get; }
@@ -154,6 +152,9 @@ public interface IUnitOfWork : IDisposable
IRepository<GiftCertificate> GiftCertificates { get; } IRepository<GiftCertificate> GiftCertificates { get; }
IRepository<GiftCertificateRedemption> GiftCertificateRedemptions { get; } IRepository<GiftCertificateRedemption> GiftCertificateRedemptions { get; }
// Customer Intake Kiosk
IRepository<KioskSession> KioskSessions { get; }
Task<int> SaveChangesAsync(); Task<int> SaveChangesAsync();
Task<int> CompleteAsync(); // Alias for SaveChangesAsync Task<int> CompleteAsync(); // Alias for SaveChangesAsync
@@ -31,10 +31,13 @@ public interface ICompanyListService
{ {
/// <summary> /// <summary>
/// Returns a paged, searched, and sorted slice of non-deleted companies together with the /// Returns a paged, searched, and sorted slice of non-deleted companies together with the
/// total unfiltered count for pagination. /// total count for pagination and the count of churned accounts that are currently hidden.
/// When <paramref name="hideChurned"/> is true, Expired/Canceled companies whose subscription
/// ended more than 14 days ago are excluded from results (but still counted for the banner).
/// </summary> /// </summary>
Task<(List<Company> Companies, int TotalCount)> GetPagedAsync( Task<(List<Company> Companies, int TotalCount, int ChurnedCount)> GetPagedAsync(
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize); string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize,
bool hideChurned = true);
/// <summary> /// <summary>
/// Returns job, quote, customer, and wizard completion counts for each of the supplied /// Returns job, quote, customer, and wizard completion counts for each of the supplied
@@ -205,11 +205,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
public DbSet<MaintenanceRecord> MaintenanceRecords { get; set; } public DbSet<MaintenanceRecord> MaintenanceRecords { get; set; }
/// <summary>Supplier/vendor records used by Purchasing and Accounts Payable; tenant-filtered with soft delete.</summary> /// <summary>Supplier/vendor records used by Purchasing and Accounts Payable; tenant-filtered with soft delete.</summary>
public DbSet<Vendor> Vendors { get; set; } public DbSet<Vendor> Vendors { get; set; }
/// <summary>Shop worker profiles with role assignments; tenant-filtered with soft delete.</summary> /// <summary>Rework records tracking quality failures and remediation work against a job; tenant-filtered with soft delete.</summary>
public DbSet<ShopWorker> ShopWorkers { get; set; }
/// <summary>Per-role labour cost rates used in pricing calculations; unique index on (CompanyId, Role).</summary>
public DbSet<ShopWorkerRoleCost> ShopWorkerRoleCosts { get; set; }
/// <summary>Rework records tracking quality failures and remediation work against a job; tenant-filtered with soft delete.</summary>
public DbSet<ReworkRecord> ReworkRecords { get; set; } public DbSet<ReworkRecord> ReworkRecords { get; set; }
/// <summary>Customer refund records; tenant-filtered with soft delete.</summary> /// <summary>Customer refund records; tenant-filtered with soft delete.</summary>
public DbSet<Refund> Refunds { get; set; } public DbSet<Refund> Refunds { get; set; }
@@ -367,6 +363,10 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
/// <summary>Prep-service definitions within a job template item.</summary> /// <summary>Prep-service definitions within a job template item.</summary>
public DbSet<JobTemplateItemPrepService> JobTemplateItemPrepServices { get; set; } public DbSet<JobTemplateItemPrepService> JobTemplateItemPrepServices { get; set; }
// Customer Intake Kiosk
/// <summary>Customer self-service intake sessions (walk-in tablet or remote email link); tenant-filtered with soft delete.</summary>
public DbSet<KioskSession> KioskSessions { get; set; }
/// <summary> /// <summary>
/// Platform-wide audit log capturing who changed what and when, across all tenants. /// Platform-wide audit log capturing who changed what and when, across all tenants.
/// No global query filter — SuperAdmin controllers query this directly. /// No global query filter — SuperAdmin controllers query this directly.
@@ -526,11 +526,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId)); !e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e => modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId)); !e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<ShopWorker>().HasQueryFilter(e => modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<ShopWorkerRoleCost>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId)); !e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<Refund>().HasQueryFilter(e => modelBuilder.Entity<Refund>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId)); !e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
@@ -746,6 +742,24 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
modelBuilder.Entity<InAppNotification>().HasQueryFilter(e => modelBuilder.Entity<InAppNotification>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId)); !e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// Customer intake kiosk sessions — tenant-filtered + soft delete.
// Anonymous intake routes must use ignoreQueryFilters:true when loading by SessionToken.
modelBuilder.Entity<KioskSession>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<KioskSession>()
.HasIndex(e => e.SessionToken)
.IsUnique();
modelBuilder.Entity<KioskSession>()
.HasOne(k => k.LinkedCustomer)
.WithMany()
.HasForeignKey(k => k.LinkedCustomerId)
.OnDelete(DeleteBehavior.SetNull);
modelBuilder.Entity<KioskSession>()
.HasOne(k => k.LinkedJob)
.WithMany()
.HasForeignKey(k => k.LinkedJobId)
.OnDelete(DeleteBehavior.SetNull);
// Account self-referencing hierarchy // Account self-referencing hierarchy
modelBuilder.Entity<Account>() modelBuilder.Entity<Account>()
.HasOne(a => a.ParentAccount) .HasOne(a => a.ParentAccount)
@@ -1292,12 +1306,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
.HasForeignKey(m => m.PerformedById) .HasForeignKey(m => m.PerformedById)
.OnDelete(DeleteBehavior.SetNull); .OnDelete(DeleteBehavior.SetNull);
// ShopWorker relationships
modelBuilder.Entity<ShopWorker>()
.HasOne<Company>()
.WithMany(c => c.ShopWorkers)
.HasForeignKey(e => e.CompanyId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Job>() modelBuilder.Entity<Job>()
.HasOne(j => j.AssignedUser) .HasOne(j => j.AssignedUser)
@@ -1371,10 +1380,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
modelBuilder.Entity<PricingTier>() modelBuilder.Entity<PricingTier>()
.HasIndex(p => p.CompanyId); .HasIndex(p => p.CompanyId);
modelBuilder.Entity<ShopWorker>() modelBuilder.Entity<CatalogCategory>()
.HasIndex(w => w.CompanyId);
modelBuilder.Entity<CatalogCategory>()
.HasIndex(c => c.CompanyId); .HasIndex(c => c.CompanyId);
modelBuilder.Entity<CatalogCategory>() modelBuilder.Entity<CatalogCategory>()
@@ -1409,12 +1415,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
.IsUnique() .IsUnique()
.HasDatabaseName("IX_Jobs_CompanyId_JobNumber"); .HasDatabaseName("IX_Jobs_CompanyId_JobNumber");
modelBuilder.Entity<ShopWorkerRoleCost>() modelBuilder.Entity<Job>()
.HasIndex(r => new { r.CompanyId, r.Role })
.IsUnique()
.HasDatabaseName("IX_ShopWorkerRoleCosts_CompanyId_Role");
modelBuilder.Entity<Job>()
.Property(j => j.ShopAccessCode) .Property(j => j.ShopAccessCode)
.HasDefaultValueSql("NEWID()"); .HasDefaultValueSql("NEWID()");
@@ -21,7 +21,7 @@ public class AuditInterceptor : SaveChangesInterceptor
private static readonly HashSet<string> AuditedTypes = new(StringComparer.Ordinal) private static readonly HashSet<string> AuditedTypes = new(StringComparer.Ordinal)
{ {
nameof(Customer), nameof(Job), nameof(Quote), nameof(Equipment), nameof(Customer), nameof(Job), nameof(Quote), nameof(Equipment),
nameof(MaintenanceRecord), nameof(Vendor), nameof(ShopWorker), nameof(MaintenanceRecord), nameof(Vendor),
nameof(InventoryItem), nameof(Company), nameof(InventoryItem), nameof(Company),
// Financial entities // Financial entities
nameof(Invoice), nameof(Payment), nameof(Bill), nameof(BillPayment), nameof(Invoice), nameof(Payment), nameof(Bill), nameof(BillPayment),
@@ -967,6 +967,17 @@ New accounts walk through an 18-step setup wizard to configure company informati
CreatedAt = DateTime.UtcNow CreatedAt = DateTime.UtcNow
}, },
new NotificationTemplate new NotificationTemplate
{
NotificationType = NotificationType.InvoiceSent,
Channel = NotificationChannel.Sms,
DisplayName = "Invoice Sent (SMS)",
Subject = null,
Body = "{{companyName}}: Invoice {{invoiceNumber}} for {{invoiceTotal}} is ready. View your invoice: {{viewUrl}} Reply STOP to opt out.",
IsActive = true,
CompanyId = companyId,
CreatedAt = DateTime.UtcNow
},
new NotificationTemplate
{ {
NotificationType = NotificationType.PaymentReceived, NotificationType = NotificationType.PaymentReceived,
Channel = NotificationChannel.Email, Channel = NotificationChannel.Email,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,142 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddKioskIntakeSession : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "KioskActivationToken",
table: "Companies",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.CreateTable(
name: "KioskSessions",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
SessionToken = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SessionType = table.Column<int>(type: "int", nullable: false),
Status = table.Column<int>(type: "int", nullable: false),
CustomerFirstName = table.Column<string>(type: "nvarchar(max)", nullable: false),
CustomerLastName = table.Column<string>(type: "nvarchar(max)", nullable: false),
CustomerPhone = table.Column<string>(type: "nvarchar(max)", nullable: false),
CustomerEmail = table.Column<string>(type: "nvarchar(max)", nullable: false),
IsReturningCustomer = table.Column<bool>(type: "bit", nullable: false),
JobDescription = table.Column<string>(type: "nvarchar(max)", nullable: false),
HowDidYouHearAboutUs = table.Column<string>(type: "nvarchar(max)", nullable: true),
AgreedToTerms = table.Column<bool>(type: "bit", nullable: false),
AgreedToTermsAt = table.Column<DateTime>(type: "datetime2", nullable: true),
SmsOptIn = table.Column<bool>(type: "bit", nullable: false),
SignatureDataBase64 = table.Column<string>(type: "nvarchar(max)", nullable: true),
LinkedCustomerId = table.Column<int>(type: "int", nullable: true),
LinkedJobId = table.Column<int>(type: "int", nullable: true),
SubmittedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
ExpiresAt = table.Column<DateTime>(type: "datetime2", nullable: false),
RemoteLinkEmail = table.Column<string>(type: "nvarchar(max)", nullable: true),
RemoteLinkSentAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CompanyId = table.Column<int>(type: "int", nullable: false),
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_KioskSessions", x => x.Id);
table.ForeignKey(
name: "FK_KioskSessions_Customers_LinkedCustomerId",
column: x => x.LinkedCustomerId,
principalTable: "Customers",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_KioskSessions_Jobs_LinkedJobId",
column: x => x.LinkedJobId,
principalTable: "Jobs",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8207));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8213));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8215));
migrationBuilder.CreateIndex(
name: "IX_KioskSessions_LinkedCustomerId",
table: "KioskSessions",
column: "LinkedCustomerId");
migrationBuilder.CreateIndex(
name: "IX_KioskSessions_LinkedJobId",
table: "KioskSessions",
column: "LinkedJobId");
migrationBuilder.CreateIndex(
name: "IX_KioskSessions_SessionToken",
table: "KioskSessions",
column: "SessionToken",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "KioskSessions");
migrationBuilder.DropColumn(
name: "KioskActivationToken",
table: "Companies");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5641));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5655));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5656));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddInvoicePublicViewToken : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PublicViewToken",
table: "Invoices",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4259));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4264));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4266));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PublicViewToken",
table: "Invoices");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8207));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8213));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8215));
}
}
}
@@ -0,0 +1,82 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddKioskIntakeOutputSetting : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "LinkedQuoteId",
table: "KioskSessions",
type: "int",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "KioskIntakeOutput",
table: "CompanyPreferences",
type: "nvarchar(max)",
nullable: false,
defaultValue: "");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2349));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2366));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2367));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LinkedQuoteId",
table: "KioskSessions");
migrationBuilder.DropColumn(
name: "KioskIntakeOutput",
table: "CompanyPreferences");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4259));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4264));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4266));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,82 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddJobOvenBatchFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "OvenBatches",
table: "Jobs",
type: "int",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "OvenCycleMinutes",
table: "Jobs",
type: "int",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6420));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6425));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6426));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "OvenBatches",
table: "Jobs");
migrationBuilder.DropColumn(
name: "OvenCycleMinutes",
table: "Jobs");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2349));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2366));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2367));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,72 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddJobItemIsAiItem : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsAiItem",
table: "JobItems",
type: "bit",
nullable: false,
defaultValue: false);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7475));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7481));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7482));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsAiItem",
table: "JobItems");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6420));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6425));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6426));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddGiftCertificateBatchId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "BatchId",
table: "GiftCertificates",
type: "uniqueidentifier",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7656));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7662));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7664));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "BatchId",
table: "GiftCertificates");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7475));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7481));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7482));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,71 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddJobPricingSnapshot : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "PricingBreakdownJson",
table: "Jobs",
type: "nvarchar(max)",
nullable: true);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4618));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4623));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4625));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PricingBreakdownJson",
table: "Jobs");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2464));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2473));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2474));
}
}
}
@@ -0,0 +1,138 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddQuotePricingSnapshotFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "FacilityOverheadCost",
table: "Quotes",
type: "decimal(18,2)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "FacilityOverheadRatePerHour",
table: "Quotes",
type: "decimal(18,2)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "PricingTierDiscountAmount",
table: "Quotes",
type: "decimal(18,2)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "PricingTierDiscountPercent",
table: "Quotes",
type: "decimal(18,2)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "QuoteDiscountAmount",
table: "Quotes",
type: "decimal(18,2)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "QuoteDiscountPercent",
table: "Quotes",
type: "decimal(18,2)",
nullable: false,
defaultValue: 0m);
migrationBuilder.AddColumn<decimal>(
name: "SubtotalAfterDiscount",
table: "Quotes",
type: "decimal(18,2)",
nullable: false,
defaultValue: 0m);
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(845));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(850));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(852));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "FacilityOverheadCost",
table: "Quotes");
migrationBuilder.DropColumn(
name: "FacilityOverheadRatePerHour",
table: "Quotes");
migrationBuilder.DropColumn(
name: "PricingTierDiscountAmount",
table: "Quotes");
migrationBuilder.DropColumn(
name: "PricingTierDiscountPercent",
table: "Quotes");
migrationBuilder.DropColumn(
name: "QuoteDiscountAmount",
table: "Quotes");
migrationBuilder.DropColumn(
name: "QuoteDiscountPercent",
table: "Quotes");
migrationBuilder.DropColumn(
name: "SubtotalAfterDiscount",
table: "Quotes");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4618));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4623));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4625));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,81 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddLaborCostPerHour : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "LaborCostPerHour",
table: "CompanyOperatingCosts",
type: "decimal(18,2)",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "LaborCostPerHour",
table: "AspNetUsers",
type: "decimal(18,2)",
nullable: true);
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));
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LaborCostPerHour",
table: "CompanyOperatingCosts");
migrationBuilder.DropColumn(
name: "LaborCostPerHour",
table: "AspNetUsers");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(845));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(850));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(852));
}
}
}
@@ -556,6 +556,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("IsBanned") b.Property<bool>("IsBanned")
.HasColumnType("bit"); .HasColumnType("bit");
b.Property<decimal?>("LaborCostPerHour")
.HasColumnType("decimal(18,2)");
b.Property<DateTime?>("LastLoginDate") b.Property<DateTime?>("LastLoginDate")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
@@ -1812,6 +1815,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("IsDeleted") b.Property<bool>("IsDeleted")
.HasColumnType("bit"); .HasColumnType("bit");
b.Property<string>("KioskActivationToken")
.HasColumnType("nvarchar(max)");
b.Property<string>("LogoContentType") b.Property<string>("LogoContentType")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -2072,6 +2078,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("IsDeleted") b.Property<bool>("IsDeleted")
.HasColumnType("bit"); .HasColumnType("bit");
b.Property<decimal?>("LaborCostPerHour")
.HasColumnType("decimal(18,2)");
b.Property<int>("MonthlyBillableHours") b.Property<int>("MonthlyBillableHours")
.HasColumnType("int"); .HasColumnType("int");
@@ -2250,6 +2259,10 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int>("JobRetentionYears") b.Property<int>("JobRetentionYears")
.HasColumnType("int"); .HasColumnType("int");
b.Property<string>("KioskIntakeOutput")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int>("LogRetentionDays") b.Property<int>("LogRetentionDays")
.HasColumnType("int"); .HasColumnType("int");
@@ -3283,6 +3296,9 @@ namespace PowderCoating.Infrastructure.Migrations
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id")); SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<Guid?>("BatchId")
.HasColumnType("uniqueidentifier");
b.Property<string>("CertificateCode") b.Property<string>("CertificateCode")
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(450)"); .HasColumnType("nvarchar(450)");
@@ -3919,6 +3935,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("PreparedById") b.Property<string>("PreparedById")
.HasColumnType("nvarchar(450)"); .HasColumnType("nvarchar(450)");
b.Property<string>("PublicViewToken")
.HasColumnType("nvarchar(max)");
b.Property<int?>("SalesTaxAccountId") b.Property<int?>("SalesTaxAccountId")
.HasColumnType("int"); .HasColumnType("int");
@@ -4195,9 +4214,18 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<decimal>("OvenBatchCost") b.Property<decimal>("OvenBatchCost")
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
b.Property<int>("OvenBatches")
.HasColumnType("int");
b.Property<int?>("OvenCostId") b.Property<int?>("OvenCostId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<int?>("OvenCycleMinutes")
.HasColumnType("int");
b.Property<string>("PricingBreakdownJson")
.HasColumnType("nvarchar(max)");
b.Property<int?>("QuoteId") b.Property<int?>("QuoteId")
.HasColumnType("int"); .HasColumnType("int");
@@ -4466,6 +4494,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("IncludePrepCost") b.Property<bool>("IncludePrepCost")
.HasColumnType("bit"); .HasColumnType("bit");
b.Property<bool>("IsAiItem")
.HasColumnType("bit");
b.Property<bool>("IsDeleted") b.Property<bool>("IsDeleted")
.HasColumnType("bit"); .HasColumnType("bit");
@@ -5564,6 +5595,118 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("JournalEntryLines"); b.ToTable("JournalEntryLines");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.KioskSession", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<bool>("AgreedToTerms")
.HasColumnType("bit");
b.Property<DateTime?>("AgreedToTermsAt")
.HasColumnType("datetime2");
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("CustomerEmail")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("CustomerFirstName")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("CustomerLastName")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("CustomerPhone")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("datetime2");
b.Property<string>("HowDidYouHearAboutUs")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<bool>("IsReturningCustomer")
.HasColumnType("bit");
b.Property<string>("JobDescription")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<int?>("LinkedCustomerId")
.HasColumnType("int");
b.Property<int?>("LinkedJobId")
.HasColumnType("int");
b.Property<int?>("LinkedQuoteId")
.HasColumnType("int");
b.Property<string>("RemoteLinkEmail")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("RemoteLinkSentAt")
.HasColumnType("datetime2");
b.Property<Guid>("SessionToken")
.HasColumnType("uniqueidentifier");
b.Property<int>("SessionType")
.HasColumnType("int");
b.Property<string>("SignatureDataBase64")
.HasColumnType("nvarchar(max)");
b.Property<bool>("SmsOptIn")
.HasColumnType("bit");
b.Property<int>("Status")
.HasColumnType("int");
b.Property<DateTime?>("SubmittedAt")
.HasColumnType("datetime2");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("LinkedCustomerId");
b.HasIndex("LinkedJobId");
b.HasIndex("SessionToken")
.IsUnique();
b.ToTable("KioskSessions");
});
modelBuilder.Entity("PowderCoating.Core.Entities.MaintenanceRecord", b => modelBuilder.Entity("PowderCoating.Core.Entities.MaintenanceRecord", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -6577,7 +6720,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 1, Id = 1,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5641), CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3131),
Description = "Standard pricing for regular customers", Description = "Standard pricing for regular customers",
DiscountPercent = 0m, DiscountPercent = 0m,
IsActive = true, IsActive = true,
@@ -6588,7 +6731,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 2, Id = 2,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5655), CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3137),
Description = "5% discount for preferred customers", Description = "5% discount for preferred customers",
DiscountPercent = 5m, DiscountPercent = 5m,
IsActive = true, IsActive = true,
@@ -6599,7 +6742,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 3, Id = 3,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5656), CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3138),
Description = "10% discount for premium customers", Description = "10% discount for premium customers",
DiscountPercent = 10m, DiscountPercent = 10m,
IsActive = true, IsActive = true,
@@ -6846,6 +6989,12 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<DateTime?>("ExpirationDate") b.Property<DateTime?>("ExpirationDate")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
b.Property<decimal>("FacilityOverheadCost")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("FacilityOverheadRatePerHour")
.HasColumnType("decimal(18,2)");
b.Property<bool>("HideDiscountFromCustomer") b.Property<bool>("HideDiscountFromCustomer")
.HasColumnType("bit"); .HasColumnType("bit");
@@ -6891,6 +7040,12 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("PreparedById") b.Property<string>("PreparedById")
.HasColumnType("nvarchar(450)"); .HasColumnType("nvarchar(450)");
b.Property<decimal>("PricingTierDiscountAmount")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("PricingTierDiscountPercent")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("ProfitMargin") b.Property<decimal>("ProfitMargin")
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
@@ -6930,6 +7085,12 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<DateTime>("QuoteDate") b.Property<DateTime>("QuoteDate")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
b.Property<decimal>("QuoteDiscountAmount")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("QuoteDiscountPercent")
.HasColumnType("decimal(18,2)");
b.Property<string>("QuoteNumber") b.Property<string>("QuoteNumber")
.IsRequired() .IsRequired()
.HasColumnType("nvarchar(450)"); .HasColumnType("nvarchar(450)");
@@ -6955,6 +7116,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<decimal>("SubTotal") b.Property<decimal>("SubTotal")
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
b.Property<decimal>("SubtotalAfterDiscount")
.HasColumnType("decimal(18,2)");
b.Property<string>("Tags") b.Property<string>("Tags")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -9721,6 +9885,23 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("JournalEntry"); b.Navigation("JournalEntry");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.KioskSession", b =>
{
b.HasOne("PowderCoating.Core.Entities.Customer", "LinkedCustomer")
.WithMany()
.HasForeignKey("LinkedCustomerId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("PowderCoating.Core.Entities.Job", "LinkedJob")
.WithMany()
.HasForeignKey("LinkedJobId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("LinkedCustomer");
b.Navigation("LinkedJob");
});
modelBuilder.Entity("PowderCoating.Core.Entities.MaintenanceRecord", b => modelBuilder.Entity("PowderCoating.Core.Entities.MaintenanceRecord", b =>
{ {
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "AssignedUser") b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "AssignedUser")
@@ -171,7 +171,6 @@ public class JobRepository : Repository<Job>, IJobRepository
.Include(j => j.JobItems.Where(i => !i.IsDeleted)) .Include(j => j.JobItems.Where(i => !i.IsDeleted))
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted)) .ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
.Include(j => j.TimeEntries.Where(t => !t.IsDeleted)) .Include(j => j.TimeEntries.Where(t => !t.IsDeleted))
.ThenInclude(t => t.Worker)
.AsNoTracking() .AsNoTracking()
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
} }
@@ -81,7 +81,6 @@ public class UnitOfWork : IUnitOfWork
private IRepository<AppointmentStatusLookup>? _appointmentStatusLookups; private IRepository<AppointmentStatusLookup>? _appointmentStatusLookups;
private IRepository<AppointmentTypeLookup>? _appointmentTypeLookups; private IRepository<AppointmentTypeLookup>? _appointmentTypeLookups;
private IRepository<PrepService>? _prepServices; private IRepository<PrepService>? _prepServices;
private IRepository<ShopWorker>? _shopWorkers;
// Appointments // Appointments
private IRepository<Appointment>? _appointments; private IRepository<Appointment>? _appointments;
@@ -121,6 +120,9 @@ public class UnitOfWork : IUnitOfWork
private IRepository<GiftCertificate>? _giftCertificates; private IRepository<GiftCertificate>? _giftCertificates;
private IRepository<GiftCertificateRedemption>? _giftCertificateRedemptions; private IRepository<GiftCertificateRedemption>? _giftCertificateRedemptions;
// Customer Intake Kiosk
private IRepository<KioskSession>? _kioskSessions;
// Purchase Orders // Purchase Orders
private IPurchaseOrderRepository? _purchaseOrders; private IPurchaseOrderRepository? _purchaseOrders;
private IRepository<PurchaseOrderItem>? _purchaseOrderItems; private IRepository<PurchaseOrderItem>? _purchaseOrderItems;
@@ -347,16 +349,7 @@ public class UnitOfWork : IUnitOfWork
public IRepository<PrepService> PrepServices => public IRepository<PrepService> PrepServices =>
_prepServices ??= new Repository<PrepService>(_context); _prepServices ??= new Repository<PrepService>(_context);
/// <summary>Repository for <see cref="ShopWorker"/> profiles with role assignments; tenant-filtered with soft delete.</summary> /// <summary>Repository for <see cref="ReworkRecord"/> quality-failure and remediation records; tenant-filtered with soft delete.</summary>
public IRepository<ShopWorker> ShopWorkers =>
_shopWorkers ??= new Repository<ShopWorker>(_context);
/// <summary>Repository for <see cref="ShopWorkerRoleCost"/> per-role labour cost rates; unique on (CompanyId, Role).</summary>
private IRepository<ShopWorkerRoleCost>? _shopWorkerRoleCosts;
public IRepository<ShopWorkerRoleCost> ShopWorkerRoleCosts =>
_shopWorkerRoleCosts ??= new Repository<ShopWorkerRoleCost>(_context);
/// <summary>Repository for <see cref="ReworkRecord"/> quality-failure and remediation records; tenant-filtered with soft delete.</summary>
private IRepository<ReworkRecord>? _reworkRecords; private IRepository<ReworkRecord>? _reworkRecords;
public IRepository<ReworkRecord> ReworkRecords => public IRepository<ReworkRecord> ReworkRecords =>
_reworkRecords ??= new Repository<ReworkRecord>(_context); _reworkRecords ??= new Repository<ReworkRecord>(_context);
@@ -460,6 +453,10 @@ public class UnitOfWork : IUnitOfWork
public IRepository<GiftCertificateRedemption> GiftCertificateRedemptions => public IRepository<GiftCertificateRedemption> GiftCertificateRedemptions =>
_giftCertificateRedemptions ??= new Repository<GiftCertificateRedemption>(_context); _giftCertificateRedemptions ??= new Repository<GiftCertificateRedemption>(_context);
/// <summary>Repository for <see cref="KioskSession"/> customer self-service intake sessions; tenant-filtered with soft delete.</summary>
public IRepository<KioskSession> KioskSessions =>
_kioskSessions ??= new Repository<KioskSession>(_context);
// Job Templates // Job Templates
/// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary> /// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary>
public IJobTemplateRepository JobTemplates => public IJobTemplateRepository JobTemplates =>
@@ -73,7 +73,6 @@ public class CompanyDataPurgeService : ICompanyDataPurgeService
await _context.NotificationTemplates.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); await _context.NotificationTemplates.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Announcements.Where(x => x.TargetCompanyId == companyId).ExecuteDeleteAsync(); await _context.Announcements.Where(x => x.TargetCompanyId == companyId).ExecuteDeleteAsync();
await _context.BugReports.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); await _context.BugReports.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.ShopWorkers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
// ── Tier 4: Company configs and lookup tables ───────────────────────── // ── Tier 4: Company configs and lookup tables ─────────────────────────
@@ -137,7 +136,6 @@ public class CompanyDataPurgeService : ICompanyDataPurgeService
await _context.PurchaseOrderItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); await _context.PurchaseOrderItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.AiItemPredictions.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); await _context.AiItemPredictions.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.PowderUsageLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); await _context.PowderUsageLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.ShopWorkerRoleCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.OvenBatches.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); await _context.OvenBatches.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Refunds.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); await _context.Refunds.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.CreditMemos.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); await _context.CreditMemos.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
@@ -160,7 +158,6 @@ public class CompanyDataPurgeService : ICompanyDataPurgeService
await _context.OvenCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); await _context.OvenCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Accounts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); await _context.Accounts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.NotificationLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); await _context.NotificationLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.ShopWorkers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync(); await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities; using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces.Services; using PowderCoating.Core.Interfaces.Services;
using PowderCoating.Infrastructure.Data; using PowderCoating.Infrastructure.Data;
@@ -21,15 +22,34 @@ public class CompanyListService : ICompanyListService
} }
/// <inheritdoc/> /// <inheritdoc/>
public async Task<(List<Company> Companies, int TotalCount)> GetPagedAsync( public async Task<(List<Company> Companies, int TotalCount, int ChurnedCount)> GetPagedAsync(
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize) string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize,
bool hideChurned = true)
{ {
var cutoff = DateTime.UtcNow.AddDays(-14);
// Always count churned regardless of hideChurned so the banner can show a number.
var churnedCount = await _context.Companies
.AsNoTracking()
.IgnoreQueryFilters()
.Where(c => !c.IsDeleted
&& (c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled)
&& c.SubscriptionEndDate != null
&& c.SubscriptionEndDate < cutoff)
.CountAsync();
var query = _context.Companies var query = _context.Companies
.AsNoTracking() .AsNoTracking()
.IgnoreQueryFilters() .IgnoreQueryFilters()
.Where(c => !c.IsDeleted) .Where(c => !c.IsDeleted)
.AsQueryable(); .AsQueryable();
if (hideChurned)
query = query.Where(c =>
!((c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled)
&& c.SubscriptionEndDate != null
&& c.SubscriptionEndDate < cutoff));
if (!string.IsNullOrWhiteSpace(searchTerm)) if (!string.IsNullOrWhiteSpace(searchTerm))
{ {
var s = searchTerm.ToLower(); var s = searchTerm.ToLower();
@@ -61,7 +81,7 @@ public class CompanyListService : ICompanyListService
.Take(pageSize) .Take(pageSize)
.ToListAsync(); .ToListAsync();
return (companies, totalCount); return (companies, totalCount, churnedCount);
} }
/// <inheritdoc/> /// <inheritdoc/>
@@ -1,4 +1,4 @@
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
using CsvHelper; using CsvHelper;
using CsvHelper.Configuration; using CsvHelper.Configuration;
@@ -2164,168 +2164,6 @@ public class CsvImportService : ICsvImportService
} }
#endregion #endregion
#region Shop Worker Import
/// <summary>
/// Generates a downloadable CSV template with two example shop worker rows covering different roles.
/// Two rows help users see how Role values (Coater, Sandblaster, etc.) are expressed and remind
/// them that Role is optional — the importer will default to GeneralLabor when it is omitted.
/// </summary>
public byte[] GenerateShopWorkerTemplate()
{
using var memoryStream = new MemoryStream();
using var writer = new StreamWriter(memoryStream);
using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture));
csv.WriteHeader<ShopWorkerImportDto>();
csv.NextRecord();
csv.WriteRecord(new ShopWorkerImportDto
{
Name = "John Doe",
Role = "Coater",
Phone = "555-1234",
Email = "johndoe@example.com",
IsActive = true,
Notes = "Experienced powder coater"
});
csv.NextRecord();
csv.WriteRecord(new ShopWorkerImportDto
{
Name = "Jane Smith",
Role = "Sandblaster",
Phone = "555-5678",
Email = "janesmith@example.com",
IsActive = true,
Notes = ""
});
csv.NextRecord();
writer.Flush();
return memoryStream.ToArray();
}
/// <summary>
/// Imports shop workers from a CSV stream using an upsert strategy keyed on worker Name.
/// Like vendor import, this is intentionally an upsert rather than insert-only so that a
/// company can re-import their HR list to update phone/email/role details without worrying
/// about creating duplicates. Role is parsed case-insensitively with spaces stripped so that
/// "General Labor" and "GeneralLabor" are both accepted; an unrecognised role falls back to
/// GeneralLabor with a warning rather than failing the row.
/// </summary>
/// <param name="csvStream">Readable stream of CSV data (header row required).</param>
/// <param name="companyId">Tenant company that will own newly inserted worker records.</param>
public async Task<CsvImportResultDto> ImportShopWorkersAsync(Stream csvStream, int companyId)
{
var result = new CsvImportResultDto();
var rowNumber = 0;
try
{
using var reader = new StreamReader(csvStream);
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
{
HeaderValidated = null,
MissingFieldFound = null
});
var records = csv.GetRecords<ShopWorkerImportDto>().ToList();
result.TotalRows = records.Count;
_logger.LogInformation("Starting import of {Count} shop workers for company {CompanyId}", records.Count, companyId);
// Load existing workers for upsert matching by name
var existingWorkers = await _unitOfWork.ShopWorkers.GetAllAsync();
var workerDict = existingWorkers
.Where(w => !string.IsNullOrEmpty(w.Name))
.GroupBy(w => w.Name.Trim().ToUpperInvariant())
.ToDictionary(g => g.Key, g => g.First());
foreach (var record in records)
{
rowNumber++;
try
{
if (string.IsNullOrWhiteSpace(record.Name))
{
result.Errors.Add($"Row {rowNumber}: Name is required.");
result.ErrorCount++;
continue;
}
// Parse role
ShopWorkerRole role = ShopWorkerRole.GeneralLabor;
if (!string.IsNullOrEmpty(record.Role))
{
if (!Enum.TryParse<ShopWorkerRole>(record.Role.Replace(" ", ""), true, out role))
{
result.Warnings.Add($"Row {rowNumber}: Role '{record.Role}' not recognized. Valid values: GeneralLabor, Sandblaster, Coater, Masker, QualityControl, OvenOperator, Supervisor, Maintenance. Using 'GeneralLabor'.");
role = ShopWorkerRole.GeneralLabor;
}
}
var key = record.Name.Trim().ToUpperInvariant();
var now = DateTime.UtcNow;
if (workerDict.TryGetValue(key, out var existing))
{
// Update
existing.Role = role;
existing.Phone = record.Phone ?? existing.Phone;
existing.Email = record.Email ?? existing.Email;
if (record.IsActive.HasValue) existing.IsActive = record.IsActive.Value;
existing.Notes = record.Notes ?? existing.Notes;
existing.UpdatedAt = now;
await _unitOfWork.CompleteAsync();
result.Warnings.Add($"Row {rowNumber}: Updated existing shop worker '{record.Name}'.");
result.SuccessCount++;
}
else
{
var worker = new Core.Entities.ShopWorker
{
CompanyId = companyId,
Name = record.Name.Trim(),
Role = role,
Phone = record.Phone,
Email = record.Email,
IsActive = record.IsActive ?? true,
Notes = record.Notes,
CreatedAt = now,
UpdatedAt = now
};
await _unitOfWork.ShopWorkers.AddAsync(worker);
await _unitOfWork.CompleteAsync();
result.SuccessCount++;
}
}
catch (Exception ex)
{
result.Errors.Add($"Row {rowNumber}: Database error - {ex.Message}");
result.ErrorCount++;
_logger.LogError(ex, "Error saving shop worker at row {RowNumber}", rowNumber);
}
}
_logger.LogInformation("Shop worker import completed: {SuccessCount} succeeded, {ErrorCount} failed", result.SuccessCount, result.ErrorCount);
result.Success = result.SuccessCount > 0;
}
catch (Exception ex)
{
result.Errors.Add($"Fatal error: {ex.Message}");
result.Success = false;
_logger.LogError(ex, "Fatal error importing shop workers");
}
return result;
}
#endregion
#region Prep Service Import #region Prep Service Import
/// <summary> /// <summary>
@@ -621,7 +621,7 @@ public class NotificationService : INotificationService
/// (the <paramref name="paymentUrl"/> parameter). Without a payment URL the email is a /// (the <paramref name="paymentUrl"/> parameter). Without a payment URL the email is a
/// standard "here is your invoice" message with no payment CTA. /// standard "here is your invoice" message with no payment CTA.
/// </summary> /// </summary>
public async Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null) public async Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null, bool sendSms = false, string? viewUrl = null)
{ {
try try
{ {
@@ -705,6 +705,50 @@ public class NotificationService : INotificationService
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.InvoiceSent, await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.InvoiceSent,
customerName, string.Join(", ", invoiceEmails), invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id)); customerName, string.Join(", ", invoiceEmails), invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
} }
// SMS — only when explicitly requested by staff (sendSms=true), customer has opted in,
// and the company's SMS is active. Uses viewUrl (permanent) so customer can see the full
// invoice; paymentUrl (expiring Stripe link) is surfaced on the view page itself.
if (sendSms)
{
var smsAllowed = await IsSmsAllowedForCompanyAsync(company);
var smsPhone = customer.MobilePhone ?? customer.Phone;
if (smsAllowed && customer.NotifyBySms && !string.IsNullOrWhiteSpace(smsPhone))
{
var urlForSms = viewUrl ?? paymentUrl ?? string.Empty;
var values = new Dictionary<string, string>
{
["companyName"] = companyName,
["invoiceNumber"] = invoice.InvoiceNumber,
["invoiceTotal"] = invoice.Total.ToString("C"),
["viewUrl"] = urlForSms
};
var message = await GetRenderedSmsAsync(invoice.CompanyId, NotificationType.InvoiceSent, values,
$"{companyName}: Invoice {invoice.InvoiceNumber} for {invoice.Total:C} is ready. View your invoice: {urlForSms} Reply STOP to opt out.");
var (smsSent, smsError) = await _smsService.SendSmsAsync(smsPhone, message);
await WriteLog(new NotificationLog
{
Channel = NotificationChannel.Sms,
NotificationType = NotificationType.InvoiceSent,
Status = smsSent ? NotificationStatus.Sent : NotificationStatus.Failed,
RecipientName = customerName,
Recipient = smsPhone,
Message = message,
ErrorMessage = smsError,
SentAt = DateTime.UtcNow,
CustomerId = customer.Id,
InvoiceId = invoice.Id,
CompanyId = invoice.CompanyId
});
}
else if (!string.IsNullOrWhiteSpace(smsPhone))
{
await WriteLog(SkippedLog(NotificationChannel.Sms, NotificationType.InvoiceSent,
customerName, smsPhone, invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
}
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -1153,6 +1197,10 @@ public class NotificationService : INotificationService
"Invoice {{invoiceNumber}} from {{companyName}}", "Invoice {{invoiceNumber}} from {{companyName}}",
"<p>Dear {{customerName}},</p><p>Please find your invoice <strong>{{invoiceNumber}}</strong> for <strong>{{invoiceTotal}}</strong> attached.{{invoiceDueDate}}</p><p>Thank you for your business with {{companyName}}.</p>" "<p>Dear {{customerName}},</p><p>Please find your invoice <strong>{{invoiceNumber}}</strong> for <strong>{{invoiceTotal}}</strong> attached.{{invoiceDueDate}}</p><p>Thank you for your business with {{companyName}}.</p>"
), ),
[(NotificationType.InvoiceSent, NotificationChannel.Sms)] = (
null,
"{{companyName}}: Invoice {{invoiceNumber}} for {{invoiceTotal}} is ready. View your invoice: {{viewUrl}} Reply STOP to opt out."
),
[(NotificationType.PaymentReceived, NotificationChannel.Email)] = ( [(NotificationType.PaymentReceived, NotificationChannel.Email)] = (
"Payment Received — Invoice {{invoiceNumber}}", "Payment Received — Invoice {{invoiceNumber}}",
"<p>Dear {{customerName}},</p><p>We have received your payment of <strong>{{paymentAmount}}</strong> on {{paymentDate}} for invoice <strong>{{invoiceNumber}}</strong>.{{balanceDue}}</p><p>Thank you for your business with {{companyName}}.</p>" "<p>Dear {{customerName}},</p><p>We have received your payment of <strong>{{paymentAmount}}</strong> on {{paymentDate}} for invoice <strong>{{invoiceNumber}}</strong>.{{balanceDue}}</p><p>Thank you for your business with {{companyName}}.</p>"
@@ -133,7 +133,6 @@ public class AccountDataExportController : Controller
case "Inventory": await AddInventorySheet(package, companyId, headerColor); break; case "Inventory": await AddInventorySheet(package, companyId, headerColor); break;
case "Equipment": await AddEquipmentSheet(package, companyId, headerColor); break; case "Equipment": await AddEquipmentSheet(package, companyId, headerColor); break;
case "Vendors": await AddVendorsSheet(package, companyId, headerColor); break; case "Vendors": await AddVendorsSheet(package, companyId, headerColor); break;
case "ShopWorkers": await AddShopWorkersSheet(package, companyId, headerColor); break;
case "Users": await AddUsersSheet(package, companyId, headerColor); break; case "Users": await AddUsersSheet(package, companyId, headerColor); break;
} }
} }
@@ -182,7 +181,6 @@ public class AccountDataExportController : Controller
case "Inventory": WriteCsvEntry(zip, "Inventory.csv", await BuildInventoryCsv(companyId)); break; case "Inventory": WriteCsvEntry(zip, "Inventory.csv", await BuildInventoryCsv(companyId)); break;
case "Equipment": WriteCsvEntry(zip, "Equipment.csv", await BuildEquipmentCsv(companyId)); break; case "Equipment": WriteCsvEntry(zip, "Equipment.csv", await BuildEquipmentCsv(companyId)); break;
case "Vendors": WriteCsvEntry(zip, "Vendors.csv", await BuildVendorsCsv(companyId)); break; case "Vendors": WriteCsvEntry(zip, "Vendors.csv", await BuildVendorsCsv(companyId)); break;
case "ShopWorkers": WriteCsvEntry(zip, "ShopWorkers.csv", await BuildShopWorkersCsv(companyId)); break;
case "Users": WriteCsvEntry(zip, "Users.csv", await BuildUsersCsv(companyId)); break; case "Users": WriteCsvEntry(zip, "Users.csv", await BuildUsersCsv(companyId)); break;
} }
} }
@@ -268,12 +266,6 @@ public class AccountDataExportController : Controller
.Where(s => s.CompanyId == companyId && !s.IsDeleted) .Where(s => s.CompanyId == companyId && !s.IsDeleted)
.OrderBy(s => s.CompanyName).ToListAsync(); .OrderBy(s => s.CompanyName).ToListAsync();
/// <summary>Fetches all non-deleted shop workers for the company.</summary>
private Task<List<ShopWorker>> FetchShopWorkersAsync(int companyId) =>
_db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
.Where(w => w.CompanyId == companyId && !w.IsDeleted)
.OrderBy(w => w.Name).ToListAsync();
/// <summary> /// <summary>
/// Fetches all users for the company. <c>IsDeleted</c> is intentionally omitted because /// Fetches all users for the company. <c>IsDeleted</c> is intentionally omitted because
/// Identity users use <c>IsActive = false</c> for soft-deletion, not the base-entity flag. /// Identity users use <c>IsActive = false</c> for soft-deletion, not the base-entity flag.
@@ -462,23 +454,6 @@ public class AccountDataExportController : Controller
AutoFit(ws, headers.Length); AutoFit(ws, headers.Length);
} }
private async Task AddShopWorkersSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await FetchShopWorkersAsync(companyId);
var ws = pkg.Workbook.Worksheets.Add("Shop Workers");
var headers = new[] { "ID", "Name", "Role", "Phone", "Email", "Active", "Notes" };
WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++)
{
var r = i + 2; var w = data[i];
ws.Cells[r, 1].Value = w.Id; ws.Cells[r, 2].Value = w.Name;
ws.Cells[r, 3].Value = w.Role.ToString(); ws.Cells[r, 4].Value = w.Phone;
ws.Cells[r, 5].Value = w.Email; ws.Cells[r, 6].Value = w.IsActive ? "Yes" : "No";
ws.Cells[r, 7].Value = w.Notes;
}
AutoFit(ws, headers.Length);
}
/// <summary> /// <summary>
/// Adds a "Users" worksheet. All users (active and inactive) are included because Identity /// Adds a "Users" worksheet. All users (active and inactive) are included because Identity
/// uses <c>IsActive = false</c> for soft-deletion; <c>IsDeleted</c> is not applicable here. /// uses <c>IsActive = false</c> for soft-deletion; <c>IsDeleted</c> is not applicable here.
@@ -611,17 +586,6 @@ public class AccountDataExportController : Controller
return sb.ToString(); return sb.ToString();
} }
/// <summary>Column names match <c>ShopWorkerImportDto</c> exactly so the file can be re-imported.</summary>
private async Task<string> BuildShopWorkersCsv(int companyId)
{
var data = await FetchShopWorkersAsync(companyId);
var sb = new StringBuilder();
sb.AppendLine("Name,Role,Phone,Email,IsActive,Notes");
foreach (var w in data)
sb.AppendLine($"{CsvEscape(w.Name)},{w.Role},{CsvEscape(w.Phone)},{CsvEscape(w.Email)},{w.IsActive.ToString().ToLower()},{CsvEscape(w.Notes)}");
return sb.ToString();
}
/// <summary> /// <summary>
/// All users (active and inactive) are exported for completeness and compliance — mirrors /// All users (active and inactive) are exported for completeness and compliance — mirrors
/// the reasoning in <see cref="AddUsersSheet"/> and <see cref="FetchUsersAsync"/>. /// the reasoning in <see cref="AddUsersSheet"/> and <see cref="FetchUsersAsync"/>.
@@ -675,13 +639,13 @@ public class AccountDataExportController : Controller
/// <summary> /// <summary>
/// Returns the subset of selected sheet names reordered into the canonical export sequence /// Returns the subset of selected sheet names reordered into the canonical export sequence
/// (Customers → Jobs → Quotes → Invoices → Inventory → Equipment → Vendors → ShopWorkers → Users). /// (Customers → Jobs → Quotes → Invoices → Inventory → Equipment → Vendors → Users).
/// Guarantees consistent file layout regardless of the order check-boxes were ticked on the form. /// Guarantees consistent file layout regardless of the order check-boxes were ticked on the form.
/// Sheet names not in the canonical list are silently dropped. /// Sheet names not in the canonical list are silently dropped.
/// </summary> /// </summary>
private static string[] OrderSheets(string[] sheets) private static string[] OrderSheets(string[] sheets)
{ {
var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "ShopWorkers", "Users" }; var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "Users" };
return order.Where(sheets.Contains).ToArray(); return order.Where(sheets.Contains).ToArray();
} }
@@ -66,15 +66,16 @@ public class CompaniesController : Controller
string sortColumn = "CompanyName", string sortColumn = "CompanyName",
string sortDirection = "asc", string sortDirection = "asc",
int pageNumber = 1, int pageNumber = 1,
int pageSize = 25) int pageSize = 25,
bool showChurned = false)
{ {
try try
{ {
pageNumber = Math.Max(1, pageNumber); pageNumber = Math.Max(1, pageNumber);
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25; pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
var (companies, totalCount) = await _companyList.GetPagedAsync( var (companies, totalCount, churnedCount) = await _companyList.GetPagedAsync(
searchTerm, sortColumn, sortDirection, pageNumber, pageSize); searchTerm, sortColumn, sortDirection, pageNumber, pageSize, hideChurned: !showChurned);
var companyDtos = _mapper.Map<List<CompanyListDto>>(companies); var companyDtos = _mapper.Map<List<CompanyListDto>>(companies);
@@ -128,6 +129,8 @@ public class CompaniesController : Controller
ViewBag.PageSize = pageSize; ViewBag.PageSize = pageSize;
ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize); ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
ViewBag.ImpersonatingCompanyId = HttpContext.Session.GetInt32("ImpersonatingCompanyId"); ViewBag.ImpersonatingCompanyId = HttpContext.Session.GetInt32("ImpersonatingCompanyId");
ViewBag.ShowChurned = showChurned;
ViewBag.ChurnedCount = churnedCount;
return View(companyDtos); return View(companyDtos);
} }
@@ -45,18 +45,30 @@ public class CompanyHealthController : Controller
/// user's risk/search filters, so the KPI cards always show platform-wide totals. /// user's risk/search filters, so the KPI cards always show platform-wide totals.
/// </para> /// </para>
/// </summary> /// </summary>
public async Task<IActionResult> Index(string? risk, string? search, bool configIssuesOnly = false) public async Task<IActionResult> Index(string? risk, string? search, bool configIssuesOnly = false, bool showChurned = false)
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var d30 = now.AddDays(-30); var d30 = now.AddDays(-30);
var d90 = now.AddDays(-90); var d90 = now.AddDays(-90);
var churnedCutoff = now.AddDays(-14);
// One query per signal — all keyed by CompanyId // One query per signal — all keyed by CompanyId
var companies = await _db.Companies var allCompanies = await _db.Companies
.AsNoTracking().IgnoreQueryFilters() .AsNoTracking().IgnoreQueryFilters()
.Where(c => !c.IsDeleted) .Where(c => !c.IsDeleted)
.ToListAsync(); .ToListAsync();
var churnedCount = allCompanies.Count(c =>
(c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled)
&& c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value < churnedCutoff);
var companies = showChurned
? allCompanies
: allCompanies.Where(c =>
!((c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled)
&& c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value < churnedCutoff))
.ToList();
var lastLogins = await _db.Users var lastLogins = await _db.Users
.AsNoTracking().IgnoreQueryFilters() .AsNoTracking().IgnoreQueryFilters()
.Where(u => u.LastLoginDate != null) .Where(u => u.LastLoginDate != null)
@@ -163,6 +175,8 @@ public class CompanyHealthController : Controller
ViewBag.Risk = risk; ViewBag.Risk = risk;
ViewBag.Search = search; ViewBag.Search = search;
ViewBag.ConfigIssuesOnly = configIssuesOnly; ViewBag.ConfigIssuesOnly = configIssuesOnly;
ViewBag.ShowChurned = showChurned;
ViewBag.ChurnedCount = churnedCount;
if (!string.IsNullOrWhiteSpace(search)) if (!string.IsNullOrWhiteSpace(search))
all = all.Where(h => all = all.Where(h =>
@@ -543,6 +543,15 @@ public class CompanySettingsController : Controller
public Task<IActionResult> UpdateWorkOrderTemplate([FromBody] UpdateWorkOrderTemplateDto dto) => public Task<IActionResult> UpdateWorkOrderTemplate([FromBody] UpdateWorkOrderTemplateDto dto) =>
UpdatePreferences(dto, "Work order settings saved successfully."); UpdatePreferences(dto, "Work order settings saved successfully.");
/// <summary>
/// Saves kiosk intake output preference ("Quote" or "Job") to <see cref="CompanyPreferences"/>.
/// Delegates to <see cref="UpdatePreferences{TDto}"/>.
/// </summary>
// POST: CompanySettings/UpdateKioskSettings
[HttpPost]
public Task<IActionResult> UpdateKioskSettings([FromBody] UpdateKioskSettingsDto dto) =>
UpdatePreferences(dto, "Kiosk settings saved successfully.");
/// <summary> /// <summary>
/// Persists the company's pricing model parameters — labor rates, sandblasting/masking multipliers, /// Persists the company's pricing model parameters — labor rates, sandblasting/masking multipliers,
/// oven cost per hour, overhead admin/facility percentages, profit margin, and default tax rate — /// oven cost per hour, overhead admin/facility percentages, profit margin, and default tax rate —
@@ -747,7 +756,6 @@ public class CompanySettingsController : Controller
var costs = company.OperatingCosts; var costs = company.OperatingCosts;
var ovens = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive)).OrderBy(o => o.DisplayOrder).ToList(); var ovens = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive)).OrderBy(o => o.DisplayOrder).ToList();
var workers = (await _unitOfWork.ShopWorkers.FindAsync(w => w.IsActive)).ToList();
var coatingCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsCoating)).ToList(); var coatingCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsCoating)).ToList();
var sb = new System.Text.StringBuilder(); var sb = new System.Text.StringBuilder();
@@ -774,8 +782,7 @@ public class CompanySettingsController : Controller
ShopCapabilityTier.Large => "high-volume", ShopCapabilityTier.Large => "high-volume",
_ => "small" _ => "small"
}; };
sb.AppendLine($"We are a {tierLabel} operation" + sb.AppendLine($"We are a {tierLabel} operation.");
(workers.Count > 0 ? $" with {workers.Count} active shop worker{(workers.Count == 1 ? "" : "s")}." : "."));
} }
// Ovens // Ovens
@@ -818,32 +825,6 @@ public class CompanySettingsController : Controller
sb.AppendLine($"Powder categories we stock: {string.Join(", ", catNames)}."); sb.AppendLine($"Powder categories we stock: {string.Join(", ", catNames)}.");
} }
// Worker roles
if (workers.Any())
{
var roles = workers
.Select(w => w.Role)
.Distinct()
.Select(r => r switch
{
ShopWorkerRole.Sandblaster => "sandblasting",
ShopWorkerRole.Coater => "powder coating",
ShopWorkerRole.Masker => "masking",
ShopWorkerRole.QualityControl => "quality control",
ShopWorkerRole.OvenOperator => "oven operation",
ShopWorkerRole.Supervisor => "supervision",
ShopWorkerRole.Maintenance => "equipment maintenance",
_ => "general labor"
})
.Distinct()
.ToList();
if (roles.Count > 1)
{
sb.AppendLine();
sb.AppendLine($"Staff specialties on hand: {string.Join(", ", roles)}.");
}
}
// Rates hint // Rates hint
if (costs != null && costs.StandardLaborRate > 0) if (costs != null && costs.StandardLaborRate > 0)
{ {
@@ -2685,6 +2666,7 @@ public class CompanySettingsController : Controller
{ {
list.Add(("{{invoiceTotal}}", "Invoice total amount (formatted as currency)")); list.Add(("{{invoiceTotal}}", "Invoice total amount (formatted as currency)"));
list.Add(("{{invoiceDueDate}}", "Due date phrase, e.g. \" Due by January 1, 2026.\" — blank if no due date is set")); list.Add(("{{invoiceDueDate}}", "Due date phrase, e.g. \" Due by January 1, 2026.\" — blank if no due date is set"));
list.Add(("{{viewUrl}}", "Permanent link for the customer to view the invoice online (used in SMS)"));
} }
if (type == NotificationType.PaymentReceived) if (type == NotificationType.PaymentReceived)
@@ -2709,79 +2691,6 @@ public class CompanySettingsController : Controller
// ── Role-Based Labor Rates ──────────────────────────────────────────────── // ── Role-Based Labor Rates ────────────────────────────────────────────────
/// <summary>
/// Returns the per-role hourly labor rates configured for the current company, keyed by
/// <see cref="PowderCoating.Core.Enums.ShopWorkerRole"/> integer value. An empty list is returned
/// (rather than a 404) when no rates have been configured yet, so the UI can render the rate grid
/// without special-casing an empty state. The global multi-tenant filter on
/// <c>ShopWorkerRoleCosts</c> ensures only this company's rates are returned.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetRoleCosts()
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return Json(new List<object>());
var rates = await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId.Value);
var result = rates.Select(r => new { role = (int)r.Role, hourlyRate = r.HourlyRate }).ToList();
return Json(result);
}
/// <summary>
/// Upserts the per-role hourly labor rates for the current company. The operation handles three cases
/// per rate in a single pass: (1) rate cleared (≤ 0) — soft-delete the existing record; (2) rate set
/// but no existing record — insert new; (3) rate changed — update existing. This avoids full
/// table replace semantics that could cause audit log noise or trigger unintended EF change-tracking.
/// These rates are used by the pricing calculator when <c>UseRoleBasedLaborRates</c> is enabled in
/// <c>CompanyOperatingCosts</c>.
/// </summary>
[HttpPost]
public async Task<IActionResult> SaveRoleCosts([FromBody] List<SaveRoleCostDto> rates)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null) return Json(new { success = false, message = "No company found." });
var existing = (await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId.Value)).ToList();
foreach (var dto in rates)
{
var record = existing.FirstOrDefault(r => (int)r.Role == dto.Role);
if (dto.HourlyRate <= 0)
{
// Remove rate if cleared
if (record != null)
await _unitOfWork.ShopWorkerRoleCosts.SoftDeleteAsync(record.Id);
}
else if (record == null)
{
await _unitOfWork.ShopWorkerRoleCosts.AddAsync(new PowderCoating.Core.Entities.ShopWorkerRoleCost
{
CompanyId = companyId.Value,
Role = (PowderCoating.Core.Enums.ShopWorkerRole)dto.Role,
HourlyRate = dto.HourlyRate,
CreatedAt = DateTime.UtcNow
});
}
else
{
record.HourlyRate = dto.HourlyRate;
record.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.ShopWorkerRoleCosts.UpdateAsync(record);
}
}
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error saving role costs");
return Json(new { success = false, message = "An error occurred saving role rates." });
}
}
// ─── Stripe Connect ─────────────────────────────────────────────────────── // ─── Stripe Connect ───────────────────────────────────────────────────────
/// <summary> /// <summary>
@@ -3045,7 +2954,6 @@ public class CompanySettingsController : Controller
} }
public record SaveTemplateJsonRequest(int Id, string? Subject, string? Body); public record SaveTemplateJsonRequest(int Id, string? Subject, string? Body);
public record SaveRoleCostDto(int Role, decimal HourlyRate);
public record SaveOnlinePaymentSettingsDto( public record SaveOnlinePaymentSettingsDto(
OnlinePaymentSurchargeType SurchargeType, OnlinePaymentSurchargeType SurchargeType,
decimal SurchargeValue, decimal SurchargeValue,
@@ -226,11 +226,9 @@ public class CompanyUsersController : Controller
/// Creates a new company user, enforcing the subscription user-count limit and a whitelist /// Creates a new company user, enforcing the subscription user-count limit and a whitelist
/// of valid <c>CompanyRole</c> values (preventing callers from submitting a null role to /// of valid <c>CompanyRole</c> values (preventing callers from submitting a null role to
/// create a SuperAdmin-equivalent account). CompanyAdmin users automatically receive all /// create a SuperAdmin-equivalent account). CompanyAdmin users automatically receive all
/// per-feature permissions unless a SuperAdmin is explicitly customising them. Workers /// per-feature permissions unless a SuperAdmin is explicitly customising them. A legacy
/// additionally get an auto-created <see cref="ShopWorker"/> record so they appear in job /// ASP.NET Identity role (Administrator / Manager / Employee / ReadOnly) is also assigned
/// assignment dropdowns without a separate onboarding step. A legacy ASP.NET Identity role /// to satisfy policy checks that still reference the role system.
/// (Administrator / Manager / Employee / ReadOnly) is also assigned to satisfy policy
/// checks that still reference the role system.
/// </summary> /// </summary>
// POST: CompanyUsers/Create // POST: CompanyUsers/Create
[HttpPost] [HttpPost]
@@ -351,27 +349,7 @@ public class CompanyUsersController : Controller
await _userManager.AddToRoleAsync(user, legacyRole); await _userManager.AddToRoleAsync(user, legacyRole);
// If Worker role, automatically create a ShopWorker record _logger.LogInformation("User {Email} created successfully by {Admin}",
if (model.CompanyRole == AppConstants.CompanyRoles.Worker)
{
var shopWorker = new ShopWorker
{
Name = user.FullName,
Email = user.Email,
Phone = user.PhoneNumber,
IsActive = true,
Notes = $"Auto-created from user account: {user.Email}",
Role = Core.Enums.ShopWorkerRole.GeneralLabor, // Default role
CompanyId = companyId!.Value
};
await _unitOfWork.ShopWorkers.AddAsync(shopWorker);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("ShopWorker record created for user {Email}", user.Email);
}
_logger.LogInformation("User {Email} created successfully by {Admin}",
user.Email, User.Identity?.Name); user.Email, User.Identity?.Name);
TempData["Success"] = $"User '{user.FullName}' created successfully."; TempData["Success"] = $"User '{user.FullName}' created successfully.";
@@ -441,6 +419,7 @@ public class CompanyUsersController : Controller
CompanyRole = user.CompanyRole ?? AppConstants.CompanyRoles.Viewer, CompanyRole = user.CompanyRole ?? AppConstants.CompanyRoles.Viewer,
Department = user.Department, Department = user.Department,
Position = user.Position, Position = user.Position,
LaborCostPerHour = user.LaborCostPerHour,
Phone = user.PhoneNumber, Phone = user.PhoneNumber,
IsActive = user.IsActive, IsActive = user.IsActive,
HireDate = user.HireDate, HireDate = user.HireDate,
@@ -479,11 +458,9 @@ public class CompanyUsersController : Controller
/// Saves changes to an existing company user. Validates company isolation and role whitelist /// Saves changes to an existing company user. Validates company isolation and role whitelist
/// (same checks as <see cref="Edit(string, string)"/>). Prevents two dangerous deactivation /// (same checks as <see cref="Edit(string, string)"/>). Prevents two dangerous deactivation
/// scenarios: a user deactivating themselves, and deactivating the last active CompanyAdmin /// scenarios: a user deactivating themselves, and deactivating the last active CompanyAdmin
/// for a company (which would lock out the tenant). When the role changes to Worker and no /// for a company (which would lock out the tenant). Email changes are applied via
/// matching <see cref="ShopWorker"/> record exists, one is created automatically; if one /// <c>SetEmailAsync</c> / <c>SetUserNameAsync</c> after the main update so Identity's own
/// already exists, its name, email, and active status are kept in sync. Email changes are /// normalisation logic runs correctly.
/// applied via <c>SetEmailAsync</c> / <c>SetUserNameAsync</c> after the main update so
/// Identity's own normalisation logic runs correctly.
/// </summary> /// </summary>
// POST: CompanyUsers/Edit/id // POST: CompanyUsers/Edit/id
[HttpPost] [HttpPost]
@@ -596,6 +573,7 @@ public class CompanyUsersController : Controller
user.CompanyRole = model.CompanyRole; user.CompanyRole = model.CompanyRole;
user.Department = model.Department; user.Department = model.Department;
user.Position = model.Position; user.Position = model.Position;
user.LaborCostPerHour = model.LaborCostPerHour;
user.PhoneNumber = model.Phone; user.PhoneNumber = model.Phone;
user.IsActive = model.IsActive; user.IsActive = model.IsActive;
user.HireDate = model.HireDate; user.HireDate = model.HireDate;
@@ -632,60 +610,7 @@ public class CompanyUsersController : Controller
user.Id, oldEmail, model.Email, User.Identity?.Name); user.Id, oldEmail, model.Email, User.Identity?.Name);
} }
// If role changed to Worker, ensure ShopWorker record exists _logger.LogInformation("User {Email} updated successfully by {Admin}",
if (model.CompanyRole == AppConstants.CompanyRoles.Worker)
{
// Search by oldEmail so we find the record even when the email just changed
var lookupEmail = emailChanged ? oldEmail : user.Email;
var existingShopWorker = (await _unitOfWork.ShopWorkers.FindAsync(
sw => sw.Email == lookupEmail && sw.CompanyId == user.CompanyId)).ToList();
if (!existingShopWorker.Any())
{
var shopWorker = new ShopWorker
{
Name = user.FullName,
Email = user.Email,
Phone = user.PhoneNumber,
IsActive = user.IsActive,
Notes = $"Auto-created from user account: {user.Email}",
Role = Core.Enums.ShopWorkerRole.GeneralLabor, // Default role
CompanyId = user.CompanyId
};
await _unitOfWork.ShopWorkers.AddAsync(shopWorker);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("ShopWorker record created for user {Email}", user.Email);
}
else
{
// Update existing ShopWorker to ensure it's active
var shopWorker = existingShopWorker.First();
var shopWorkerDirty = false;
if (!shopWorker.IsActive && user.IsActive)
{
shopWorker.IsActive = true;
shopWorkerDirty = true;
_logger.LogInformation("ShopWorker record reactivated for user {Email}", user.Email);
}
if (emailChanged && shopWorker.Email == oldEmail)
{
shopWorker.Email = user.Email;
shopWorkerDirty = true;
}
shopWorker.Name = user.FullName;
shopWorker.Phone = user.PhoneNumber;
if (shopWorkerDirty)
await _unitOfWork.CompleteAsync();
}
}
_logger.LogInformation("User {Email} updated successfully by {Admin}",
user.Email, User.Identity?.Name); user.Email, User.Identity?.Name);
TempData["Success"] = "User updated successfully."; TempData["Success"] = "User updated successfully.";
@@ -368,6 +368,9 @@ public class DashboardController : Controller
ViewBag.GuidedActivationBanner = BuildGuidedActivationBanner(companyPrefs); ViewBag.GuidedActivationBanner = BuildGuidedActivationBanner(companyPrefs);
ViewBag.ShopProgressWidget = await BuildShopProgressWidgetAsync(currentCompanyId.Value, companyPrefs); ViewBag.ShopProgressWidget = await BuildShopProgressWidgetAsync(currentCompanyId.Value, companyPrefs);
var companyForKiosk = await _unitOfWork.Companies.GetByIdAsync(currentCompanyId.Value);
ViewBag.KioskActivated = !string.IsNullOrEmpty(companyForKiosk?.KioskActivationToken);
} }
return View(vm); return View(vm);
@@ -122,7 +122,6 @@ public class DataExportController : Controller
case "Inventory": await AddInventorySheet(package, companyId, headerColor); break; case "Inventory": await AddInventorySheet(package, companyId, headerColor); break;
case "Equipment": await AddEquipmentSheet(package, companyId, headerColor); break; case "Equipment": await AddEquipmentSheet(package, companyId, headerColor); break;
case "Vendors": await AddVendorsSheet(package, companyId, headerColor); break; case "Vendors": await AddVendorsSheet(package, companyId, headerColor); break;
case "ShopWorkers": await AddShopWorkersSheet(package, companyId, headerColor); break;
case "Users": await AddUsersSheet(package, companyId, headerColor); break; case "Users": await AddUsersSheet(package, companyId, headerColor); break;
} }
} }
@@ -172,7 +171,6 @@ public class DataExportController : Controller
case "Inventory": WriteCsvEntry(zip, "Inventory.csv", await BuildInventoryCsv(companyId)); break; case "Inventory": WriteCsvEntry(zip, "Inventory.csv", await BuildInventoryCsv(companyId)); break;
case "Equipment": WriteCsvEntry(zip, "Equipment.csv", await BuildEquipmentCsv(companyId)); break; case "Equipment": WriteCsvEntry(zip, "Equipment.csv", await BuildEquipmentCsv(companyId)); break;
case "Vendors": WriteCsvEntry(zip, "Vendors.csv", await BuildVendorsCsv(companyId)); break; case "Vendors": WriteCsvEntry(zip, "Vendors.csv", await BuildVendorsCsv(companyId)); break;
case "ShopWorkers": WriteCsvEntry(zip, "ShopWorkers.csv", await BuildShopWorkersCsv(companyId)); break;
case "Users": WriteCsvEntry(zip, "Users.csv", await BuildUsersCsv(companyId)); break; case "Users": WriteCsvEntry(zip, "Users.csv", await BuildUsersCsv(companyId)); break;
} }
} }
@@ -441,38 +439,6 @@ public class DataExportController : Controller
AutoFit(ws, headers.Length); AutoFit(ws, headers.Length);
} }
/// <summary>
/// Adds a "Shop Workers" worksheet with one row per non-deleted shop worker for the
/// specified company. <c>Role.ToString()</c> converts the enum to a string; the view
/// typically formats these with spaces (e.g. "QualityControl" → "Quality Control") but the
/// raw enum name is used here so the export value is round-trip parseable.
/// </summary>
private async Task AddShopWorkersSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
.Where(w => w.CompanyId == companyId && !w.IsDeleted)
.OrderBy(w => w.Name)
.ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("Shop Workers");
var headers = new[] { "ID", "Name", "Role", "Phone", "Email", "Active", "Notes" };
WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++)
{
var r = i + 2;
var w = data[i];
ws.Cells[r, 1].Value = w.Id;
ws.Cells[r, 2].Value = w.Name;
ws.Cells[r, 3].Value = w.Role.ToString();
ws.Cells[r, 4].Value = w.Phone;
ws.Cells[r, 5].Value = w.Email;
ws.Cells[r, 6].Value = w.IsActive ? "Yes" : "No";
ws.Cells[r, 7].Value = w.Notes;
}
AutoFit(ws, headers.Length);
}
/// <summary> /// <summary>
/// Adds an "Invoices" worksheet with one row per non-deleted invoice for the specified company. /// Adds an "Invoices" worksheet with one row per non-deleted invoice for the specified company.
/// The customer navigation is eagerly loaded so the customer name can be rendered; when a /// The customer navigation is eagerly loaded so the customer name can be rendered; when a
@@ -687,21 +653,6 @@ public class DataExportController : Controller
return sb.ToString(); return sb.ToString();
} }
/// <summary>
/// Builds the shop workers CSV string for the specified company, ordered alphabetically by name.
/// Column names match <see cref="ShopWorkerImportDto"/> exactly so the file can be re-imported.
/// </summary>
private async Task<string> BuildShopWorkersCsv(int companyId)
{
var data = await _db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
.Where(w => w.CompanyId == companyId && !w.IsDeleted).OrderBy(w => w.Name).ToListAsync();
var sb = new StringBuilder();
sb.AppendLine("Name,Role,Phone,Email,IsActive,Notes");
foreach (var w in data)
sb.AppendLine($"{CsvEscape(w.Name)},{w.Role},{CsvEscape(w.Phone)},{CsvEscape(w.Email)},{w.IsActive.ToString().ToLower()},{CsvEscape(w.Notes)}");
return sb.ToString();
}
/// <summary> /// <summary>
/// Builds the users CSV string for the specified company, ordered by last name. /// Builds the users CSV string for the specified company, ordered by last name.
/// Like <see cref="AddUsersSheet"/>, the <c>IsDeleted</c> filter is intentionally omitted /// Like <see cref="AddUsersSheet"/>, the <c>IsDeleted</c> filter is intentionally omitted
@@ -761,7 +712,7 @@ public class DataExportController : Controller
/// <summary> /// <summary>
/// Returns the requested sheet names sorted into the canonical export order /// Returns the requested sheet names sorted into the canonical export order
/// (Customers → Jobs → Quotes → Invoices → Inventory → Equipment → Vendors → ShopWorkers → Users). /// (Customers → Jobs → Quotes → Invoices → Inventory → Equipment → Vendors → Users).
/// This ensures that the workbook and ZIP archive always have a predictable, logical layout /// This ensures that the workbook and ZIP archive always have a predictable, logical layout
/// regardless of the order the administrator checked the boxes on the form. /// regardless of the order the administrator checked the boxes on the form.
/// Any sheet name not in the canonical list is silently ignored. /// Any sheet name not in the canonical list is silently ignored.
@@ -769,7 +720,7 @@ public class DataExportController : Controller
/// <param name="sheets">Raw sheet names from the form POST.</param> /// <param name="sheets">Raw sheet names from the form POST.</param>
private static string[] OrderSheets(string[] sheets) private static string[] OrderSheets(string[] sheets)
{ {
var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "ShopWorkers", "Users" }; var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "Users" };
return order.Where(sheets.Contains).ToArray(); return order.Where(sheets.Contains).ToArray();
} }
@@ -175,7 +175,6 @@ public class DataPurgeController : Controller
stats.Add(await Stat("Equipment", "Equipment", "bi-tools", "Inventory & Ops", _db.Equipment.Where(e => e.IsDeleted))); stats.Add(await Stat("Equipment", "Equipment", "bi-tools", "Inventory & Ops", _db.Equipment.Where(e => e.IsDeleted)));
stats.Add(await Stat("MaintenanceRecords","Maintenance Records", "bi-wrench", "Inventory & Ops", _db.MaintenanceRecords.Where(e => e.IsDeleted))); stats.Add(await Stat("MaintenanceRecords","Maintenance Records", "bi-wrench", "Inventory & Ops", _db.MaintenanceRecords.Where(e => e.IsDeleted)));
stats.Add(await Stat("Vendors", "Vendors", "bi-truck", "Inventory & Ops", _db.Vendors.Where(e => e.IsDeleted))); stats.Add(await Stat("Vendors", "Vendors", "bi-truck", "Inventory & Ops", _db.Vendors.Where(e => e.IsDeleted)));
stats.Add(await Stat("ShopWorkers", "Shop Workers", "bi-person-badge","Inventory & Ops", _db.ShopWorkers.Where(e => e.IsDeleted)));
return stats; return stats;
} }
@@ -204,7 +203,6 @@ public class DataPurgeController : Controller
"Equipment" => await QueryCount(_db.Equipment, cutoff), "Equipment" => await QueryCount(_db.Equipment, cutoff),
"MaintenanceRecords" => await QueryCount(_db.MaintenanceRecords, cutoff), "MaintenanceRecords" => await QueryCount(_db.MaintenanceRecords, cutoff),
"Vendors" => await QueryCount(_db.Vendors, cutoff), "Vendors" => await QueryCount(_db.Vendors, cutoff),
"ShopWorkers" => await QueryCount(_db.ShopWorkers, cutoff),
_ => (0, null) _ => (0, null)
}; };
} }
@@ -324,11 +322,6 @@ public class DataPurgeController : Controller
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync(); .Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break; break;
case "ShopWorkers":
count = await _db.ShopWorkers.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break;
default: default:
return 0; return 0;
} }
@@ -359,7 +352,7 @@ public class DataPurgeController : Controller
"MaintenanceRecords", "MaintenanceRecords",
"Jobs", "Customers", "Quotes", "Jobs", "Customers", "Quotes",
"InventoryItems", "Equipment", "InventoryItems", "Equipment",
"Vendors", "ShopWorkers" "Vendors"
}; };
return order.Where(entities.Contains).ToArray(); return order.Where(entities.Contains).ToArray();
} }
@@ -107,7 +107,8 @@ public class GiftCertificatesController : Controller
IssuedReason = gc.IssuedReason, IssuedReason = gc.IssuedReason,
Status = gc.Status, Status = gc.Status,
IssueDate = gc.IssueDate, IssueDate = gc.IssueDate,
ExpiryDate = gc.ExpiryDate ExpiryDate = gc.ExpiryDate,
BatchId = gc.BatchId
}) })
.ToList(); .ToList();
@@ -440,6 +441,183 @@ public class GiftCertificatesController : Controller
return acct?.Id; return acct?.Id;
} }
/// <summary>
/// Shows the bulk certificate creation form. Defaults to Promotional reason and 25 certificates
/// since the primary use case is car shows and events where a batch of same-value certificates
/// is distributed to attendees.
/// </summary>
public IActionResult BulkCreate()
{
return View(new BulkCreateGiftCertificateDto());
}
/// <summary>
/// Creates N gift certificates in a single batch, records GL entries for each, then redirects
/// to a confirmation page where the user can download the full batch as a single print-ready PDF.
/// Certificate codes are generated sequentially so the batch occupies a contiguous range (e.g.
/// GC-2506-0012 through GC-2506-0036), making it easy to audit which codes belong to each event.
/// GL treatment mirrors single-certificate issuance: Sold certs debit Checking, all others debit
/// Sales Discounts (4950) and credit GC Liability (2500).
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> BulkCreate(BulkCreateGiftCertificateDto dto)
{
if (!ModelState.IsValid)
return View(dto);
try
{
var currentUser = await _userManager.GetUserAsync(User);
var companyId = currentUser?.CompanyId ?? 0;
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(companyId);
int? checkingAcctId = null;
int? discountAcctId = null;
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && (a.AccountSubType == AccountSubTypeEnum.Checking
|| a.AccountSubType == AccountSubTypeEnum.Cash));
checkingAcctId = acct?.Id;
}
else
{
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
a => a.IsActive && a.AccountNumber == "4950");
discountAcctId = acct?.Id;
}
var batchId = Guid.NewGuid();
var now = DateTime.UtcNow;
for (int i = 0; i < dto.Quantity; i++)
{
var code = await GenerateCertificateCodeAsync(companyId);
var cert = new GiftCertificate
{
CertificateCode = code,
OriginalAmount = dto.Amount,
RedeemedAmount = 0,
IssuedReason = dto.IssuedReason,
Status = GiftCertificateStatus.Active,
IssueDate = now,
ExpiryDate = dto.ExpiryDate,
Notes = dto.Notes,
IssuedById = currentUser?.Id,
CompanyId = companyId,
CreatedAt = now,
CreatedBy = currentUser?.Email,
BatchId = batchId
};
await _unitOfWork.GiftCertificates.AddAsync(cert);
await _unitOfWork.CompleteAsync();
await _accountBalanceService.CreditAsync(gcLiabilityAcctId, cert.OriginalAmount);
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
await _accountBalanceService.DebitAsync(checkingAcctId, cert.OriginalAmount);
else
await _accountBalanceService.DebitAsync(discountAcctId, cert.OriginalAmount);
}
return RedirectToAction(nameof(BulkResult), new { batchId });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating bulk gift certificates");
this.ToastError("An error occurred creating the certificates.");
return View(dto);
}
}
/// <summary>
/// Displays the batch confirmation page. Driven by BatchId so it is bookmarkable and survives
/// browser back/refresh — the user can return here any time to re-download the batch PDF.
/// </summary>
public async Task<IActionResult> BulkResult(Guid batchId)
{
if (batchId == Guid.Empty)
return RedirectToAction(nameof(Index));
var certs = await _unitOfWork.GiftCertificates.FindAsync(
gc => gc.BatchId == batchId, false);
if (!certs.Any())
return RedirectToAction(nameof(Index));
return View(certs.OrderBy(c => c.CertificateCode).ToList());
}
/// <summary>
/// Streams a multi-page PDF for an entire batch identified by BatchId. GET endpoint so the
/// user can bookmark or re-open it at any time after the batch was originally created.
/// </summary>
public async Task<IActionResult> BatchDownloadPdf(Guid batchId)
{
if (batchId == Guid.Empty)
return BadRequest();
var currentUser = await _userManager.GetUserAsync(User);
var companyId = currentUser?.CompanyId ?? 0;
var company = await _unitOfWork.Companies.GetByIdAsync(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 certs = await _unitOfWork.GiftCertificates.FindAsync(
gc => gc.BatchId == batchId, false,
gc => gc.RecipientCustomer);
if (!certs.Any())
return NotFound();
var dtos = certs.OrderBy(c => c.CertificateCode).Select(cert => new GiftCertificateDto
{
Id = cert.Id,
CertificateCode = cert.CertificateCode,
OriginalAmount = cert.OriginalAmount,
RedeemedAmount = cert.RedeemedAmount,
RemainingBalance = cert.RemainingBalance,
RecipientName = cert.RecipientCustomer != null
? (cert.RecipientCustomer.CompanyName ?? $"{cert.RecipientCustomer.ContactFirstName} {cert.RecipientCustomer.ContactLastName}".Trim())
: cert.RecipientName,
RecipientEmail = cert.RecipientEmail,
IssuedReason = cert.IssuedReason,
Status = cert.Status,
IssueDate = cert.IssueDate,
ExpiryDate = cert.ExpiryDate,
Notes = cert.Notes
}).ToList();
try
{
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
var pdfBytes = await _pdfService.GenerateBulkGiftCertificatePdfAsync(dtos, logoData, logoContentType, companyInfo);
var first = dtos.First().CertificateCode;
var last = dtos.Last().CertificateCode;
var fileName = dtos.Count == 1
? $"GiftCertificate-{first}.pdf"
: $"GiftCertificates-{first}-to-{last}.pdf";
return File(pdfBytes, "application/pdf", fileName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error generating batch gift certificate PDF for batch {BatchId}", batchId);
TempData["Error"] = "Could not generate PDF.";
return RedirectToAction(nameof(BulkResult), new { batchId });
}
}
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company) private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
{ {
if (company == null) return (null, null); if (company == null) return (null, null);
@@ -38,14 +38,6 @@ namespace PowderCoating.Web.Controllers
return View(); return View();
} }
/// <summary>
/// Serves the Shop Workers help article describing roles, assignment to jobs, and maintenance tasks.
/// </summary>
public IActionResult ShopWorkers()
{
return View();
}
/// <summary> /// <summary>
/// Serves the Equipment help article explaining the equipment status lifecycle and maintenance scheduling. /// Serves the Equipment help article explaining the equipment status lifecycle and maintenance scheduling.
/// </summary> /// </summary>
@@ -125,5 +117,13 @@ namespace PowderCoating.Web.Controllers
{ {
return View(); return View();
} }
/// <summary>
/// Serves the Customer Intake Kiosk help article explaining the tablet kiosk setup, the staff-triggered intake flow, and the Intakes review page.
/// </summary>
public IActionResult CustomerIntakeKiosk()
{
return View();
}
} }
} }
@@ -304,6 +304,32 @@ public class InventoryController : Controller
await _unitOfWork.SaveChangesAsync(); await _unitOfWork.SaveChangesAsync();
} }
// Contribute/sync to the platform powder catalog if we have enough identity data.
// Runs silently — a failure here never blocks the inventory save.
if (!string.IsNullOrWhiteSpace(dto.Manufacturer) && !string.IsNullOrWhiteSpace(dto.ManufacturerPartNumber))
{
var catalogResult = new InventoryAiLookupResult
{
Manufacturer = dto.Manufacturer,
ManufacturerPartNumber = dto.ManufacturerPartNumber,
ColorName = dto.ColorName ?? item.Name,
Finish = dto.Finish,
CureTemperatureF = dto.CureTemperatureF,
CureTimeMinutes = dto.CureTimeMinutes,
ColorFamilies = dto.ColorFamilies,
RequiresClearCoat = dto.RequiresClearCoat ? true : (bool?)null,
CoverageSqFtPerLb = dto.CoverageSqFtPerLb,
SpecificGravity = dto.SpecificGravity,
TransferEfficiency = dto.TransferEfficiency,
UnitCostPerLb = dto.UnitCost > 0 ? dto.UnitCost : null,
SpecPageUrl = dto.SpecPageUrl,
ImageUrl = dto.ImageUrl,
SdsUrl = dto.SdsUrl,
TdsUrl = dto.TdsUrl,
};
await EnrichFromCatalogAsync(catalogResult, autoContribute: true);
}
TempData["Success"] = "Inventory item created successfully."; TempData["Success"] = "Inventory item created successfully.";
return RedirectToAction(nameof(Details), new { id = item.Id }); return RedirectToAction(nameof(Details), new { id = item.Id });
} }
@@ -704,6 +730,8 @@ public class InventoryController : Controller
return Json(new { success = false, errorMessage = "No product URL provided." }); return Json(new { success = false, errorMessage = "No product URL provided." });
var result = await _aiLookupService.LookupByUrlAsync(productUrl, colorName); var result = await _aiLookupService.LookupByUrlAsync(productUrl, colorName);
if (result.Success)
await EnrichFromCatalogAsync(result, autoContribute: true);
return Json(result); return Json(result);
} }
@@ -750,6 +778,39 @@ public class InventoryController : Controller
result.SdsUrl ??= match.SdsUrl; result.SdsUrl ??= match.SdsUrl;
result.TdsUrl ??= match.TdsUrl; result.TdsUrl ??= match.TdsUrl;
if (match.UnitPrice > 0) result.UnitCostPerLb ??= match.UnitPrice; if (match.UnitPrice > 0) result.UnitCostPerLb ??= match.UnitPrice;
// Back-sync: fill NULL catalog fields from the incoming result so the catalog
// gets richer over time without overwriting anything already stored.
bool catalogDirty = false;
if (match.Finish == null && !string.IsNullOrWhiteSpace(result.Finish)) { match.Finish = result.Finish; catalogDirty = true; }
if (match.CureTemperatureF == null && result.CureTemperatureF != null) { match.CureTemperatureF = result.CureTemperatureF; catalogDirty = true; }
if (match.CureTimeMinutes == null && result.CureTimeMinutes != null) { match.CureTimeMinutes = result.CureTimeMinutes; catalogDirty = true; }
if (match.ColorFamilies == null && !string.IsNullOrWhiteSpace(result.ColorFamilies)){ match.ColorFamilies = result.ColorFamilies; catalogDirty = true; }
if (match.RequiresClearCoat == null && result.RequiresClearCoat != null) { match.RequiresClearCoat = result.RequiresClearCoat; catalogDirty = true; }
if (match.CoverageSqFtPerLb == null && result.CoverageSqFtPerLb != null) { match.CoverageSqFtPerLb = result.CoverageSqFtPerLb; catalogDirty = true; }
if (match.SpecificGravity == null && result.SpecificGravity != null) { match.SpecificGravity = result.SpecificGravity; catalogDirty = true; }
if (match.TransferEfficiency == null && result.TransferEfficiency != null) { match.TransferEfficiency = result.TransferEfficiency; catalogDirty = true; }
if (string.IsNullOrWhiteSpace(match.ImageUrl) && !string.IsNullOrWhiteSpace(result.ImageUrl)) { match.ImageUrl = result.ImageUrl; catalogDirty = true; }
if (string.IsNullOrWhiteSpace(match.ProductUrl) && !string.IsNullOrWhiteSpace(result.SpecPageUrl)){ match.ProductUrl = result.SpecPageUrl; catalogDirty = true; }
if (string.IsNullOrWhiteSpace(match.SdsUrl) && !string.IsNullOrWhiteSpace(result.SdsUrl)) { match.SdsUrl = result.SdsUrl; catalogDirty = true; }
if (string.IsNullOrWhiteSpace(match.TdsUrl) && !string.IsNullOrWhiteSpace(result.TdsUrl)) { match.TdsUrl = result.TdsUrl; catalogDirty = true; }
if (match.UnitPrice == 0 && (result.UnitCostPerLb ?? 0) > 0) { match.UnitPrice = result.UnitCostPerLb!.Value; catalogDirty = true; }
if (catalogDirty)
{
match.UpdatedAt = DateTime.UtcNow;
try
{
await _unitOfWork.PowderCatalog.UpdateAsync(match);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Back-synced catalog gaps for {VendorName} {Sku}", match.VendorName, match.Sku);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to back-sync catalog entry {Id}", match.Id);
}
}
return (true, false); return (true, false);
} }
@@ -767,6 +828,7 @@ public class InventoryController : Controller
VendorName = manufacturer, VendorName = manufacturer,
Sku = sku, Sku = sku,
ColorName = colorName, ColorName = colorName,
UnitPrice = result.UnitCostPerLb ?? 0m,
CureTemperatureF = result.CureTemperatureF, CureTemperatureF = result.CureTemperatureF,
CureTimeMinutes = result.CureTimeMinutes, CureTimeMinutes = result.CureTimeMinutes,
Finish = result.Finish, Finish = result.Finish,
@@ -1050,61 +1112,50 @@ public class InventoryController : Controller
.Select(i => i.ManufacturerPartNumber!.Trim().ToLower()) .Select(i => i.ManufacturerPartNumber!.Trim().ToLower())
.ToHashSet(); .ToHashSet();
// When a vendor is specified, search vendor-scoped first. Only widen to all vendors // Single query — all partial color/SKU matches across all vendors.
// if the scoped search returns nothing — prevents a cross-vendor color match from // Results are ranked: exact vendor + exact color (isExact=true) sorts first and
// being returned as the only result when the user clearly intended a specific manufacturer. // triggers auto-fill in the JS. Everything else goes to the picker modal.
IEnumerable<PowderCatalogItem> matches; // This means a user who typed "Columbia Coatings" + "Lime Green" gets auto-fill
if (!string.IsNullOrEmpty(vendorTerm)) // only when that exact product is in the catalog; otherwise they see a ranked modal
{ // with same-vendor results at the top and a "Not Listed — Search Online" escape hatch.
matches = await _unitOfWork.PowderCatalog.FindAsync(p => var matches = await _unitOfWork.PowderCatalog.FindAsync(p =>
p.VendorName.ToLower().Contains(vendorTerm) && (
p.Sku.ToLower() == term ||
p.ColorName.ToLower().Contains(term) || p.ColorName.ToLower().Contains(term) ||
p.Sku.ToLower().Contains(term)));
// Fall back to all vendors only when the scoped search finds nothing
if (!matches.Any())
{
matches = await _unitOfWork.PowderCatalog.FindAsync(p =>
p.Sku.ToLower() == term || p.Sku.ToLower() == term ||
p.ColorName.ToLower().Contains(term) ||
p.Sku.ToLower().Contains(term)); p.Sku.ToLower().Contains(term));
}
}
else
{
matches = await _unitOfWork.PowderCatalog.FindAsync(p =>
p.Sku.ToLower() == term ||
p.ColorName.ToLower().Contains(term) ||
p.Sku.ToLower().Contains(term));
}
var results = matches var results = matches
.Where(p => !existingSkus.Contains(p.Sku.ToLower())) .Where(p => !existingSkus.Contains(p.Sku.ToLower()))
.OrderBy(p => p.Sku.ToLower() == term ? 0 : 1) .Select(p =>
.ThenBy(p => p.ColorName)
.Select(p => new
{ {
id = p.Id, var vendorMatch = string.IsNullOrEmpty(vendorTerm) || p.VendorName.ToLower().Contains(vendorTerm);
vendorName = p.VendorName, var colorExact = p.ColorName.ToLower() == term;
sku = p.Sku, return (p, isExact: vendorMatch && colorExact, vendorMatch, colorExact);
colorName = p.ColorName, })
description = p.Description, .OrderBy(x => x.isExact ? 0 : x.vendorMatch ? 1 : x.colorExact ? 2 : 3)
unitPrice = p.UnitPrice, .ThenBy(x => x.p.ColorName)
imageUrl = p.ImageUrl, .Select(x => new
sdsUrl = p.SdsUrl, {
tdsUrl = p.TdsUrl, id = x.p.Id,
applicationGuideUrl = p.ApplicationGuideUrl, vendorName = x.p.VendorName,
productUrl = p.ProductUrl, sku = x.p.Sku,
isDiscontinued = p.IsDiscontinued, colorName = x.p.ColorName,
cureTemperatureF = p.CureTemperatureF, description = x.p.Description,
cureTimeMinutes = p.CureTimeMinutes, unitPrice = x.p.UnitPrice,
finish = p.Finish, imageUrl = x.p.ImageUrl,
colorFamilies = p.ColorFamilies, sdsUrl = x.p.SdsUrl,
requiresClearCoat = p.RequiresClearCoat, tdsUrl = x.p.TdsUrl,
coverageSqFtPerLb = p.CoverageSqFtPerLb, applicationGuideUrl = x.p.ApplicationGuideUrl,
specificGravity = p.SpecificGravity, productUrl = x.p.ProductUrl,
transferEfficiency = GetEffectiveTransferEfficiency(p.TransferEfficiency) isDiscontinued = x.p.IsDiscontinued,
isExact = x.isExact,
cureTemperatureF = x.p.CureTemperatureF,
cureTimeMinutes = x.p.CureTimeMinutes,
finish = x.p.Finish,
colorFamilies = x.p.ColorFamilies,
requiresClearCoat = x.p.RequiresClearCoat,
coverageSqFtPerLb = x.p.CoverageSqFtPerLb,
specificGravity = x.p.SpecificGravity,
transferEfficiency = GetEffectiveTransferEfficiency(x.p.TransferEfficiency)
}) })
.ToList(); .ToList();
@@ -1,9 +1,11 @@
using System.Text.Json;
using AutoMapper; using AutoMapper;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.DTOs.Common; using PowderCoating.Application.DTOs.Common;
using PowderCoating.Application.DTOs.Invoice; using PowderCoating.Application.DTOs.Invoice;
using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Application.Interfaces; using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities; using PowderCoating.Core.Entities;
using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Rendering;
@@ -397,11 +399,13 @@ public class InvoicesController : Controller
dto.InvoiceItems.Add(new CreateInvoiceItemDto dto.InvoiceItems.Add(new CreateInvoiceItemDto
{ {
SourceJobItemId = item.Id, SourceJobItemId = item.Id,
CatalogItemId = item.CatalogItemId,
Description = item.Description ?? "Powder Coating", Description = item.Description ?? "Powder Coating",
Quantity = 1, Quantity = item.Quantity > 0 ? item.Quantity : 1,
UnitPrice = item.TotalPrice, UnitPrice = item.UnitPrice,
TotalPrice = item.TotalPrice, TotalPrice = item.TotalPrice,
ColorName = item.ColorName, ColorName = item.ColorName,
Notes = item.Notes,
DisplayOrder = order++, DisplayOrder = order++,
RevenueAccountId = revenueAccountId RevenueAccountId = revenueAccountId
}); });
@@ -437,7 +441,10 @@ public class InvoicesController : Controller
// because FinalPrice is recalculated on every item edit and can drift from the original quote. // because FinalPrice is recalculated on every item edit and can drift from the original quote.
if (sourceQuote != null) 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 var processingFees = sourceQuote.OvenBatchCost
+ sourceQuote.FacilityOverheadCost
+ sourceQuote.ShopSuppliesAmount + sourceQuote.ShopSuppliesAmount
+ sourceQuote.RushFee; + sourceQuote.RushFee;
@@ -460,15 +467,17 @@ public class InvoicesController : Controller
} }
else if (hadJobItems) else if (hadJobItems)
{ {
// Direct job — no source quote. Use the stored job-level fees rather than // Direct job — no source quote. Read all charges from the pricing snapshot so the
// recalculating, so the invoice always matches the total shown on the job page. // invoice always matches the total shown on the job's Pricing Summary card.
// OvenBatchCost and ShopSuppliesAmount are saved by the pricing engine (with QuotePricingBreakdownDto? jobBreakdown = null;
// OvenCostId) when job items are created or updated. if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
jobBreakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
if (job.OvenBatchCost > 0.01m) if (job.OvenBatchCost > 0.01m)
{ {
dto.InvoiceItems.Add(new CreateInvoiceItemDto dto.InvoiceItems.Add(new CreateInvoiceItemDto
{ {
Description = $"Oven Processing Fee", Description = "Oven Processing Fee",
Quantity = 1, Quantity = 1,
UnitPrice = Math.Round(job.OvenBatchCost, 2), UnitPrice = Math.Round(job.OvenBatchCost, 2),
TotalPrice = Math.Round(job.OvenBatchCost, 2), TotalPrice = Math.Round(job.OvenBatchCost, 2),
@@ -477,6 +486,20 @@ public class InvoicesController : Controller
}); });
} }
var facilityOverhead = jobBreakdown?.FacilityOverheadCost ?? 0m;
if (facilityOverhead > 0.01m)
{
dto.InvoiceItems.Add(new CreateInvoiceItemDto
{
Description = "Facility Overhead",
Quantity = 1,
UnitPrice = Math.Round(facilityOverhead, 2),
TotalPrice = Math.Round(facilityOverhead, 2),
DisplayOrder = order++,
RevenueAccountId = defaultRevenueAccount?.Id
});
}
if (job.ShopSuppliesAmount > 0.01m) if (job.ShopSuppliesAmount > 0.01m)
{ {
var suppliesDesc = job.ShopSuppliesPercent > 0 var suppliesDesc = job.ShopSuppliesPercent > 0
@@ -488,6 +511,20 @@ public class InvoicesController : Controller
Quantity = 1, Quantity = 1,
UnitPrice = Math.Round(job.ShopSuppliesAmount, 2), UnitPrice = Math.Round(job.ShopSuppliesAmount, 2),
TotalPrice = Math.Round(job.ShopSuppliesAmount, 2), TotalPrice = Math.Round(job.ShopSuppliesAmount, 2),
DisplayOrder = order++,
RevenueAccountId = defaultRevenueAccount?.Id
});
}
var rushFee = jobBreakdown?.RushFee ?? 0m;
if (rushFee > 0.01m)
{
dto.InvoiceItems.Add(new CreateInvoiceItemDto
{
Description = "Rush Fee",
Quantity = 1,
UnitPrice = Math.Round(rushFee, 2),
TotalPrice = Math.Round(rushFee, 2),
DisplayOrder = order, DisplayOrder = order,
RevenueAccountId = defaultRevenueAccount?.Id RevenueAccountId = defaultRevenueAccount?.Id
}); });
@@ -1003,11 +1040,18 @@ public class InvoicesController : Controller
try try
{ {
var currentUserForPdf = await _userManager.GetUserAsync(User); var currentUserForPdf = await _userManager.GetUserAsync(User);
if (string.IsNullOrEmpty(invoice.PublicViewToken))
{
invoice.PublicViewToken = Guid.NewGuid().ToString("N");
await _unitOfWork.Invoices.UpdateAsync(invoice);
await _unitOfWork.CompleteAsync();
}
var pdfBytes = await BuildInvoicePdfAsync(invoice, invoice.CompanyId); var pdfBytes = await BuildInvoicePdfAsync(invoice, invoice.CompanyId);
string? paymentUrl = null; string? paymentUrl = null;
if (!string.IsNullOrEmpty(invoice.PaymentLinkToken)) if (!string.IsNullOrEmpty(invoice.PaymentLinkToken))
paymentUrl = $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}"; paymentUrl = $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}";
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl); var viewUrl = $"{Request.Scheme}://{Request.Host}/invoice/{invoice.PublicViewToken}";
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl, viewUrl: viewUrl);
var notifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id); var notifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
this.SetNotificationResultToast(notifLog); this.SetNotificationResultToast(notifLog);
} }
@@ -1033,13 +1077,13 @@ public class InvoicesController : Controller
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
/// <summary> /// <summary>
/// Marks a Draft invoice as Sent, optionally generates a Stripe online-payment link, and /// Marks a Draft invoice as Sent, optionally generates a Stripe online-payment link, and
/// fires the customer notification with a PDF attachment. Notification failure is caught /// fires the customer notification. Staff can choose email, SMS, or both via the modal.
/// separately and logged as a warning — a failed email must not roll back the status change. /// PublicViewToken is always generated (permanent view link for SMS); PaymentLinkToken is
/// The payment URL is assembled from the generated token and the current request host so it /// only generated when Stripe Connect is active (expiring pay link for email/view page).
/// works identically in dev (localhost) and production without config changes. /// Notification failure is caught separately — a failed send must not roll back the status change.
/// </summary> /// </summary>
[HttpPost, ValidateAntiForgeryToken] [HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Send(int id, string? overrideEmail = null) public async Task<IActionResult> Send(int id, string? overrideEmail = null, bool sendEmail = true, bool sendSms = false)
{ {
try try
{ {
@@ -1058,27 +1102,39 @@ public class InvoicesController : Controller
invoice.UpdatedAt = DateTime.UtcNow; invoice.UpdatedAt = DateTime.UtcNow;
invoice.UpdatedBy = currentUser?.Email; invoice.UpdatedBy = currentUser?.Email;
// Permanent view token — always generate so SMS always has a link
if (string.IsNullOrEmpty(invoice.PublicViewToken))
invoice.PublicViewToken = Guid.NewGuid().ToString("N");
await TryGeneratePaymentTokenAsync(invoice); await TryGeneratePaymentTokenAsync(invoice);
await _unitOfWork.Invoices.UpdateAsync(invoice); await _unitOfWork.Invoices.UpdateAsync(invoice);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
// Generate PDF and send notification
string? paymentUrl = null; string? paymentUrl = null;
if (!string.IsNullOrEmpty(invoice.PaymentLinkToken)) if (!string.IsNullOrEmpty(invoice.PaymentLinkToken))
paymentUrl = $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}"; paymentUrl = $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}";
bool pdfAndNotifSucceeded = false; var viewUrl = $"{Request.Scheme}://{Request.Host}/invoice/{invoice.PublicViewToken}";
bool notifSucceeded = false;
try try
{ {
var pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId); byte[]? pdfBytes = null;
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl, overrideEmail: overrideEmail?.Trim()); if (sendEmail)
pdfAndNotifSucceeded = true; pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId);
await _notificationService.NotifyInvoiceSentAsync(
invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf",
paymentUrl, overrideEmail: overrideEmail?.Trim(),
sendSms: sendSms, viewUrl: viewUrl);
notifSucceeded = true;
} }
catch (Exception notifyEx) catch (Exception notifyEx)
{ {
_logger.LogError(notifyEx, _logger.LogError(notifyEx,
"Invoice {InvoiceId} ({InvoiceNumber}): PDF generation or email dispatch failed. " + "Invoice {InvoiceId} ({InvoiceNumber}): notification failed. " +
"Inner: {InnerMessage}. Invoice status was already saved as Sent.", "Inner: {InnerMessage}. Invoice status was already saved as Sent.",
id, invoice.InvoiceNumber, notifyEx.InnerException?.Message ?? "none"); id, invoice.InvoiceNumber, notifyEx.InnerException?.Message ?? "none");
} }
@@ -1087,8 +1143,8 @@ public class InvoicesController : Controller
this.SetNotificationResultToast(notifLog); this.SetNotificationResultToast(notifLog);
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} marked as sent."; TempData["Success"] = $"Invoice {invoice.InvoiceNumber} marked as sent.";
if (!pdfAndNotifSucceeded) if (!notifSucceeded)
TempData["WarningPermanent"] = "The invoice is marked as sent, but PDF generation or the customer email failed. Check the notification logs or your email configuration."; TempData["WarningPermanent"] = "The invoice is marked as sent, but the notification failed. Check the notification logs or your configuration.";
return RedirectToAction(nameof(Details), new { id }); return RedirectToAction(nameof(Details), new { id });
} }
catch (Exception ex) catch (Exception ex)
@@ -422,72 +422,24 @@ public class JobsController : Controller
// Populate Edit Items wizard data (inline modal on Details page) // Populate Edit Items wizard data (inline modal on Details page)
var wizardCosts = await _pricingService.GetOperatingCostsAsync(job.CompanyId); var wizardCosts = await _pricingService.GetOperatingCostsAsync(job.CompanyId);
await PopulateJobItemDropDownsAsync(job.CompanyId, wizardCosts?.OvenOperatingCostPerHour ?? 45m); await PopulateJobItemDropDownsAsync(job.CompanyId, wizardCosts?.OvenOperatingCostPerHour ?? 45m);
ViewBag.WizardTaxPercent = wizardCosts?.TaxPercent ?? 0m; ViewBag.WizardTaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, wizardCosts?.TaxPercent ?? 0m);
// Internal pricing breakdown (not printed — mirrors quote details breakdown) // Display the pricing snapshot stored when items were last saved.
var breakdownItems = job.JobItems // Never recalculate on load — operating cost changes must not retroactively alter existing jobs.
.Where(ji => !ji.IsDeleted) if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
.Select(ji => new CreateQuoteItemDto
{ {
Description = ji.Description, ViewBag.JobPricingBreakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
Quantity = ji.Quantity, }
SurfaceAreaSqFt = ji.SurfaceAreaSqFt, else if (job.FinalPrice > 0)
EstimatedMinutes = ji.EstimatedMinutes,
CatalogItemId = ji.CatalogItemId,
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && !ji.Coats.Any() && !ji.IsSalesItem),
IsLaborItem = ji.IsLaborItem,
IsSalesItem = ji.IsSalesItem,
ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null),
PowderCostOverride = ji.PowderCostOverride,
IncludePrepCost = ji.IncludePrepCost,
Complexity = ji.Complexity,
Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto
{ {
CoverageSqFtPerLb = c.CoverageSqFtPerLb, // Legacy job created before snapshot was introduced — show what we have stored
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb,
PowderToOrder = c.PowderToOrder
}).ToList(),
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
{
PrepServiceId = ps.PrepServiceId,
EstimatedMinutes = ps.EstimatedMinutes
}).ToList()
}).ToList();
if (breakdownItems.Any())
{
var pr = await _pricingService.CalculateQuoteTotalsAsync(
breakdownItems, job.CompanyId, job.CustomerId,
wizardCosts?.TaxPercent ?? 0m,
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
job.OvenCostId, 1, null);
ViewBag.JobPricingBreakdown = new QuotePricingBreakdownDto ViewBag.JobPricingBreakdown = new QuotePricingBreakdownDto
{ {
MaterialCosts = pr.MaterialCosts, OvenBatchCost = job.OvenBatchCost,
LaborCosts = pr.LaborCosts, OvenBatches = job.OvenBatches,
EquipmentCosts = pr.EquipmentCosts, ShopSuppliesAmount = job.ShopSuppliesAmount,
ItemsSubtotal = pr.ItemsSubtotal, ShopSuppliesPercent = job.ShopSuppliesPercent,
OvenBatchCost = pr.OvenBatchCost, Total = job.FinalPrice
OvenBatches = pr.OvenBatches,
OvenCycleMinutes = pr.OvenCycleMinutes > 0 ? pr.OvenCycleMinutes : (wizardCosts?.DefaultOvenCycleMinutes ?? 0),
FacilityOverheadCost = pr.FacilityOverheadCost,
FacilityOverheadRatePerHour = pr.FacilityOverheadRatePerHour,
ShopSuppliesAmount = pr.ShopSuppliesAmount,
ShopSuppliesPercent = pr.ShopSuppliesPercent,
OverheadCosts = pr.OverheadCosts,
OverheadPercent = pr.OverheadPercent,
ProfitMargin = pr.ProfitMargin,
ProfitPercent = pr.ProfitPercent,
SubtotalBeforeDiscount = pr.SubtotalBeforeDiscount,
DiscountAmount = pr.DiscountAmount,
DiscountPercent = pr.DiscountPercent,
SubtotalAfterDiscount = pr.SubtotalAfterDiscount,
RushFee = pr.RushFee,
TaxAmount = pr.TaxAmount,
TaxPercent = pr.TaxPercent,
Total = pr.Total
}; };
} }
ViewBag.ComplexitySimplePercent = wizardCosts?.ComplexitySimplePercent ?? 0m; ViewBag.ComplexitySimplePercent = wizardCosts?.ComplexitySimplePercent ?? 0m;
@@ -506,6 +458,7 @@ public class JobsController : Controller
isGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && !ji.Coats.Any() && !ji.IsSalesItem), isGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && !ji.Coats.Any() && !ji.IsSalesItem),
isLaborItem = ji.IsLaborItem, isLaborItem = ji.IsLaborItem,
isSalesItem = ji.IsSalesItem, isSalesItem = ji.IsSalesItem,
isAiItem = ji.IsAiItem,
sku = ji.Sku, sku = ji.Sku,
requiresSandblasting = ji.RequiresSandblasting, requiresSandblasting = ji.RequiresSandblasting,
requiresMasking = ji.RequiresMasking, requiresMasking = ji.RequiresMasking,
@@ -1106,6 +1059,7 @@ public class JobsController : Controller
CustomerId = dto.CustomerId, CustomerId = dto.CustomerId,
QuoteId = dto.QuoteId, QuoteId = dto.QuoteId,
AssignedUserId = dto.AssignedUserId, AssignedUserId = dto.AssignedUserId,
OvenCostId = dto.OvenCostId,
Description = dto.Description, Description = dto.Description,
JobPriorityId = dto.JobPriorityId, JobPriorityId = dto.JobPriorityId,
JobStatusId = pendingStatus?.Id ?? 1, JobStatusId = pendingStatus?.Id ?? 1,
@@ -1167,15 +1121,23 @@ public class JobsController : Controller
// Recalculate total from wizard items // Recalculate total from wizard items
var createCosts = await _pricingService.GetOperatingCostsAsync(companyId); var createCosts = await _pricingService.GetOperatingCostsAsync(companyId);
decimal? createOvenRate = null;
if (dto.OvenCostId.HasValue)
{
var createOven = await _unitOfWork.OvenCosts.GetByIdAsync(dto.OvenCostId.Value);
if (createOven != null && createOven.CompanyId == companyId)
createOvenRate = createOven.CostPerHour;
}
var totals = await _pricingService.CalculateQuoteTotalsAsync( var totals = await _pricingService.CalculateQuoteTotalsAsync(
dto.JobItems, companyId, dto.CustomerId, dto.JobItems, companyId, dto.CustomerId,
createCosts?.TaxPercent ?? 0m, await GetEffectiveTaxPercentAsync(dto.CustomerId, createCosts?.TaxPercent ?? 0m),
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, job.OvenCostId, 1, null); dto.DiscountType, dto.DiscountValue, dto.IsRushJob, createOvenRate, job.OvenBatches, job.OvenCycleMinutes);
job.FinalPrice = totals.Total; job.FinalPrice = totals.Total;
job.OvenBatchCost = totals.OvenBatchCost; job.OvenBatchCost = totals.OvenBatchCost;
job.ShopSuppliesAmount = totals.ShopSuppliesAmount; job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
job.ShopSuppliesPercent = totals.ShopSuppliesPercent; job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
job.UpdatedAt = DateTime.UtcNow; job.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Jobs.UpdateAsync(job); await _unitOfWork.Jobs.UpdateAsync(job);
await _unitOfWork.SaveChangesAsync(); await _unitOfWork.SaveChangesAsync();
@@ -1262,6 +1224,7 @@ public class JobsController : Controller
PowderCostOverride = ji.PowderCostOverride, PowderCostOverride = ji.PowderCostOverride,
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0), IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
IsLaborItem = ji.IsLaborItem, IsLaborItem = ji.IsLaborItem,
IsAiItem = ji.IsAiItem,
RequiresSandblasting = ji.RequiresSandblasting, RequiresSandblasting = ji.RequiresSandblasting,
RequiresMasking = ji.RequiresMasking, RequiresMasking = ji.RequiresMasking,
Notes = ji.Notes, Notes = ji.Notes,
@@ -1626,14 +1589,22 @@ public class JobsController : Controller
if (dto.JobItems.Any()) if (dto.JobItems.Any())
{ {
var editCosts = await _pricingService.GetOperatingCostsAsync(companyId); var editCosts = await _pricingService.GetOperatingCostsAsync(companyId);
decimal? editOvenRate = null;
if (job.OvenCostId.HasValue)
{
var editOven = await _unitOfWork.OvenCosts.GetByIdAsync(job.OvenCostId.Value);
if (editOven != null && editOven.CompanyId == companyId)
editOvenRate = editOven.CostPerHour;
}
var totals = await _pricingService.CalculateQuoteTotalsAsync( var totals = await _pricingService.CalculateQuoteTotalsAsync(
dto.JobItems, companyId, dto.CustomerId, dto.JobItems, companyId, dto.CustomerId,
editCosts?.TaxPercent ?? 0m, await GetEffectiveTaxPercentAsync(dto.CustomerId, editCosts?.TaxPercent ?? 0m),
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, job.OvenCostId, 1, null); dto.DiscountType, dto.DiscountValue, dto.IsRushJob, editOvenRate, job.OvenBatches, job.OvenCycleMinutes);
job.FinalPrice = totals.Total; job.FinalPrice = totals.Total;
job.OvenBatchCost = totals.OvenBatchCost; job.OvenBatchCost = totals.OvenBatchCost;
job.ShopSuppliesAmount = totals.ShopSuppliesAmount; job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
job.ShopSuppliesPercent = totals.ShopSuppliesPercent; job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
} }
// Save change history records // Save change history records
@@ -2926,6 +2897,7 @@ public class JobsController : Controller
PowderCostOverride = ji.PowderCostOverride, PowderCostOverride = ji.PowderCostOverride,
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0), IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
IsLaborItem = ji.IsLaborItem, IsLaborItem = ji.IsLaborItem,
IsAiItem = ji.IsAiItem,
RequiresSandblasting = ji.RequiresSandblasting, RequiresSandblasting = ji.RequiresSandblasting,
RequiresMasking = ji.RequiresMasking, RequiresMasking = ji.RequiresMasking,
Notes = ji.Notes, Notes = ji.Notes,
@@ -2958,7 +2930,10 @@ public class JobsController : Controller
JobId = job.Id, JobId = job.Id,
JobNumber = job.JobNumber, JobNumber = job.JobNumber,
CustomerId = job.CustomerId, CustomerId = job.CustomerId,
TaxPercent = costs?.TaxPercent ?? 0m, TaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m),
OvenCostId = job.OvenCostId,
OvenBatches = job.OvenBatches > 0 ? job.OvenBatches : 1,
OvenCycleMinutes = job.OvenCycleMinutes,
JobItems = existingItems JobItems = existingItems
}; };
@@ -2992,7 +2967,7 @@ public class JobsController : Controller
{ {
ModelState.AddModelError("", "Please add at least one job item."); ModelState.AddModelError("", "Please add at least one job item.");
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId); var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
model.TaxPercent = costs?.TaxPercent ?? 0m; model.TaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m);
await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m); await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m);
ViewBag.ComplexitySimplePercent = costs?.ComplexitySimplePercent ?? 0m; ViewBag.ComplexitySimplePercent = costs?.ComplexitySimplePercent ?? 0m;
ViewBag.ComplexityModeratePercent = costs?.ComplexityModeratePercent ?? 5m; ViewBag.ComplexityModeratePercent = costs?.ComplexityModeratePercent ?? 5m;
@@ -3037,15 +3012,26 @@ public class JobsController : Controller
} }
} }
// Calculate full total (overhead, margins, tax) to match what the wizard displays // Calculate full total (overhead, margins, tax) matching what Details shows
decimal? ovenRateOverride = null;
if (job.OvenCostId.HasValue)
{
var oven = await _unitOfWork.OvenCosts.GetByIdAsync(job.OvenCostId.Value);
if (oven != null && oven.CompanyId == currentUser.CompanyId)
ovenRateOverride = oven.CostPerHour;
}
var updateCosts = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
var totals = await _pricingService.CalculateQuoteTotalsAsync( var totals = await _pricingService.CalculateQuoteTotalsAsync(
model.JobItems, currentUser.CompanyId, job.CustomerId, model.JobItems, currentUser.CompanyId, job.CustomerId,
model.TaxPercent, "None", 0, false, job.OvenCostId, 1, null); await GetEffectiveTaxPercentAsync(job.CustomerId, updateCosts?.TaxPercent ?? 0m),
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
ovenRateOverride, job.OvenBatches, job.OvenCycleMinutes);
job.FinalPrice = totals.Total; job.FinalPrice = totals.Total;
job.OvenBatchCost = totals.OvenBatchCost; job.OvenBatchCost = totals.OvenBatchCost;
job.ShopSuppliesAmount = totals.ShopSuppliesAmount; job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
job.ShopSuppliesPercent = totals.ShopSuppliesPercent; job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
job.UpdatedAt = DateTime.UtcNow; job.UpdatedAt = DateTime.UtcNow;
job.UpdatedBy = currentUser.UserName; job.UpdatedBy = currentUser.UserName;
await _unitOfWork.Jobs.UpdateAsync(job); await _unitOfWork.Jobs.UpdateAsync(job);
@@ -3059,7 +3045,7 @@ public class JobsController : Controller
_logger.LogError(ex, "Error updating items for job {JobId}", job.Id); _logger.LogError(ex, "Error updating items for job {JobId}", job.Id);
TempData["Error"] = "An error occurred while saving job items."; TempData["Error"] = "An error occurred while saving job items.";
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId); var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
model.TaxPercent = costs?.TaxPercent ?? 0m; model.TaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m);
await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m); await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m);
return View("EditItems", model); return View("EditItems", model);
} }
@@ -3101,30 +3087,47 @@ public class JobsController : Controller
CatalogItemId = ji.CatalogItemId, CatalogItemId = ji.CatalogItemId,
IsGenericItem = ji.IsGenericItem, IsGenericItem = ji.IsGenericItem,
IsLaborItem = ji.IsLaborItem, IsLaborItem = ji.IsLaborItem,
ManualUnitPrice = ji.ManualUnitPrice, IsSalesItem = ji.IsSalesItem,
Coats = ji.Coats.Select(c => new CreateQuoteItemCoatDto IsAiItem = ji.IsAiItem,
ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null),
IncludePrepCost = ji.IncludePrepCost,
Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto
{ {
InventoryItemId = c.InventoryItemId,
CoverageSqFtPerLb = c.CoverageSqFtPerLb, CoverageSqFtPerLb = c.CoverageSqFtPerLb,
TransferEfficiency = c.TransferEfficiency, TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb PowderCostPerLb = c.PowderCostPerLb
}).ToList() }).ToList()
}).ToList(); }).ToList();
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
if (remainingDtos.Any()) if (remainingDtos.Any())
{ {
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId); decimal? deleteOvenRate = null;
if (job.OvenCostId.HasValue)
{
var deleteOven = await _unitOfWork.OvenCosts.GetByIdAsync(job.OvenCostId.Value);
if (deleteOven != null && deleteOven.CompanyId == currentUser.CompanyId)
deleteOvenRate = deleteOven.CostPerHour;
}
var totals = await _pricingService.CalculateQuoteTotalsAsync( var totals = await _pricingService.CalculateQuoteTotalsAsync(
remainingDtos, currentUser.CompanyId, job.CustomerId, remainingDtos, currentUser.CompanyId, job.CustomerId,
costs?.TaxPercent ?? 0m, "None", 0, false, null, 1, null); await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m),
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
deleteOvenRate, job.OvenBatches, job.OvenCycleMinutes);
job.FinalPrice = totals.Total; job.FinalPrice = totals.Total;
job.OvenBatchCost = totals.OvenBatchCost;
job.ShopSuppliesAmount = totals.ShopSuppliesAmount; job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
job.ShopSuppliesPercent = totals.ShopSuppliesPercent; job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
} }
else else
{ {
job.FinalPrice = 0; job.FinalPrice = 0;
job.OvenBatchCost = 0;
job.ShopSuppliesAmount = 0; job.ShopSuppliesAmount = 0;
job.ShopSuppliesPercent = 0; job.ShopSuppliesPercent = 0;
job.PricingBreakdownJson = null;
} }
job.UpdatedAt = DateTime.UtcNow; job.UpdatedAt = DateTime.UtcNow;
@@ -3234,6 +3237,57 @@ public class JobsController : Controller
return $"{string.Join(" > ", path)} > {item.Name}{sku} - {item.DefaultPrice:C}"; return $"{string.Join(" > ", path)} > {item.Name}{sku} - {item.DefaultPrice:C}";
} }
/// <summary>
/// Converts a <see cref="QuotePricingResult"/> into the DTO used for both display and JSON snapshot storage.
/// All save paths (Create, Edit, UpdateItems, DeleteJobItem) call this so the snapshot is always consistent.
/// </summary>
/// <summary>
/// Returns the effective tax rate for a job, respecting customer tax-exempt status.
/// Always call this instead of using costs.TaxPercent directly so tax-exempt customers
/// are never charged tax when a job is saved or recalculated.
/// </summary>
private async Task<decimal> GetEffectiveTaxPercentAsync(int? customerId, decimal companyDefaultRate)
{
if (customerId is > 0)
{
var customer = await _unitOfWork.Customers.GetByIdAsync(customerId.Value);
if (customer?.IsTaxExempt == true) return 0m;
}
return companyDefaultRate;
}
private static QuotePricingBreakdownDto BuildPricingSnapshotDto(QuotePricingResult pr) =>
new QuotePricingBreakdownDto
{
MaterialCosts = pr.MaterialCosts,
LaborCosts = pr.LaborCosts,
EquipmentCosts = pr.EquipmentCosts,
ItemsSubtotal = pr.ItemsSubtotal,
OvenBatchCost = pr.OvenBatchCost,
OvenBatches = pr.OvenBatches,
OvenCycleMinutes = pr.OvenCycleMinutes,
FacilityOverheadCost = pr.FacilityOverheadCost,
FacilityOverheadRatePerHour = pr.FacilityOverheadRatePerHour,
ShopSuppliesAmount = pr.ShopSuppliesAmount,
ShopSuppliesPercent = pr.ShopSuppliesPercent,
OverheadCosts = pr.OverheadCosts,
OverheadPercent = pr.OverheadPercent,
ProfitMargin = pr.ProfitMargin,
ProfitPercent = pr.ProfitPercent,
SubtotalBeforeDiscount = pr.SubtotalBeforeDiscount,
PricingTierDiscountAmount = pr.PricingTierDiscountAmount,
PricingTierDiscountPercent = pr.PricingTierDiscountPercent,
QuoteDiscountAmount = pr.QuoteDiscountAmount,
QuoteDiscountPercent = pr.QuoteDiscountPercent,
DiscountAmount = pr.DiscountAmount,
DiscountPercent = pr.DiscountPercent,
SubtotalAfterDiscount = pr.SubtotalAfterDiscount,
RushFee = pr.RushFee,
TaxAmount = pr.TaxAmount,
TaxPercent = pr.TaxPercent,
Total = pr.Total
};
#endregion #endregion
#region Item Pricing (AJAX) #region Item Pricing (AJAX)
@@ -3314,8 +3368,7 @@ public class JobsController : Controller
public async Task<IActionResult> GetTimeEntries(int jobId) public async Task<IActionResult> GetTimeEntries(int jobId)
{ {
var entries = await _unitOfWork.JobTimeEntries.FindAsync( var entries = await _unitOfWork.JobTimeEntries.FindAsync(
e => e.JobId == jobId, false, e => e.JobId == jobId, false);
e => e.Worker); // Worker nav loaded for display of legacy entries that pre-date user migration
var dtos = _mapper.Map<List<JobTimeEntryDto>>(entries.OrderByDescending(e => e.WorkDate).ToList()); var dtos = _mapper.Map<List<JobTimeEntryDto>>(entries.OrderByDescending(e => e.WorkDate).ToList());
return Json(dtos); return Json(dtos);
} }
@@ -3769,15 +3822,24 @@ public class JobsController : Controller
// Operating costs for fallback labor rate and oven rate // Operating costs for fallback labor rate and oven rate
var opCosts = (await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId)).FirstOrDefault(); var opCosts = (await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId)).FirstOrDefault();
var fallbackLaborRate = opCosts?.StandardLaborRate ?? 0m;
var effectiveOvenMinutes = (opCosts?.DefaultOvenCycleMinutes > 0 ? (int?)opCosts!.DefaultOvenCycleMinutes : null) ?? 45; var effectiveOvenMinutes = (opCosts?.DefaultOvenCycleMinutes > 0 ? (int?)opCosts!.DefaultOvenCycleMinutes : null) ?? 45;
var defaultOvenCycleHours = effectiveOvenMinutes / 60.0m; var defaultOvenCycleHours = effectiveOvenMinutes / 60.0m;
// Role cost rates map: role → hourly rate // Labor cost rate priority: per-user LaborCostPerHour → company LaborCostPerHour → 20% of StandardLaborRate
var roleCosts = await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId); var companyLaborCostRate = opCosts?.LaborCostPerHour ?? ((opCosts?.StandardLaborRate ?? 0m) * 0.20m);
var roleCostMap = roleCosts.ToDictionary(r => r.Role, r => r.HourlyRate); var companyUsers = await _userManager.Users
.Where(u => u.CompanyId == companyId && u.LaborCostPerHour != null)
.Select(u => new { u.Id, u.LaborCostPerHour })
.ToListAsync();
var userLaborCostMap = companyUsers.ToDictionary(u => u.Id, u => u.LaborCostPerHour!.Value);
// 1. Powder / Material cost // 1. Powder / Material cost
// Priority: PowderUsageLog actuals (sum per coat) > coat.ActualPowderUsedLbs > coat.PowderToOrder (estimated)
var usageLogs = await _unitOfWork.PowderUsageLogs.FindAsync(u => u.JobId == jobId);
var actualByCoat = usageLogs
.GroupBy(u => u.JobItemCoatId)
.ToDictionary(g => g.Key, g => g.Sum(u => u.ActualLbsUsed));
decimal powderCost = 0m; decimal powderCost = 0m;
var powderLines = new List<object>(); var powderLines = new List<object>();
bool hasCoatsWithRateButNoQty = false; bool hasCoatsWithRateButNoQty = false;
@@ -3785,7 +3847,19 @@ public class JobsController : Controller
{ {
foreach (var coat in item.Coats) foreach (var coat in item.Coats)
{ {
var lbs = coat.ActualPowderUsedLbs ?? coat.PowderToOrder ?? 0m; bool isActual;
decimal lbs;
if (actualByCoat.TryGetValue(coat.Id, out var loggedLbs) && loggedLbs > 0)
{
lbs = loggedLbs;
isActual = true;
}
else
{
lbs = coat.ActualPowderUsedLbs ?? coat.PowderToOrder ?? 0m;
isActual = coat.ActualPowderUsedLbs.HasValue;
}
var costPerLb = coat.PowderCostPerLb ?? 0m; var costPerLb = coat.PowderCostPerLb ?? 0m;
var lineCost = lbs * costPerLb; var lineCost = lbs * costPerLb;
powderCost += lineCost; powderCost += lineCost;
@@ -3796,7 +3870,7 @@ public class JobsController : Controller
lbs = Math.Round(lbs, 3), lbs = Math.Round(lbs, 3),
costPerLb = Math.Round(costPerLb, 4), costPerLb = Math.Round(costPerLb, 4),
total = Math.Round(lineCost, 2), total = Math.Round(lineCost, 2),
isActual = coat.ActualPowderUsedLbs.HasValue isActual
}); });
} }
else if (costPerLb > 0 && lbs == 0) else if (costPerLb > 0 && lbs == 0)
@@ -3808,20 +3882,23 @@ public class JobsController : Controller
} }
// 2. Labor cost // 2. Labor cost
// Priority: per-user LaborCostPerHour → company LaborCostPerHour → 20% of StandardLaborRate
decimal laborCost = 0m; decimal laborCost = 0m;
var laborLines = new List<object>(); var laborLines = new List<object>();
foreach (var entry in job.TimeEntries) foreach (var entry in job.TimeEntries)
{ {
var rate = entry.Worker != null && roleCostMap.TryGetValue(entry.Worker.Role, out var r) ? r : fallbackLaborRate; bool usingPerUser = entry.UserId != null && userLaborCostMap.TryGetValue(entry.UserId, out _);
var rate = usingPerUser
? userLaborCostMap[entry.UserId!]
: companyLaborCostRate;
var lineCost = entry.HoursWorked * rate; var lineCost = entry.HoursWorked * rate;
laborCost += lineCost; laborCost += lineCost;
laborLines.Add(new { laborLines.Add(new {
worker = entry.Worker?.Name ?? "Unknown", worker = entry.UserDisplayName ?? "Unknown",
role = entry.Worker != null ? System.Text.RegularExpressions.Regex.Replace(entry.Worker.Role.ToString(), "([a-z])([A-Z])", "$1 $2") : "",
hours = entry.HoursWorked, hours = entry.HoursWorked,
rate = Math.Round(rate, 2), rate = Math.Round(rate, 2),
total = Math.Round(lineCost, 2), total = Math.Round(lineCost, 2),
usingFallback = entry.Worker == null || !roleCostMap.ContainsKey(entry.Worker.Role), usingFallback = !usingPerUser,
stage = entry.Stage, stage = entry.Stage,
workDate = entry.WorkDate.ToString("MM/dd/yyyy") workDate = entry.WorkDate.ToString("MM/dd/yyyy")
}); });
@@ -3895,7 +3972,7 @@ public class JobsController : Controller
grossMargin, grossMargin,
quotedMargin, quotedMargin,
quotedPrice = Math.Round(job.QuotedPrice, 2), quotedPrice = Math.Round(job.QuotedPrice, 2),
fallbackLaborRate, companyLaborCostRate,
powderLines, powderLines,
laborLines, laborLines,
hasPowderData = powderLines.Count > 0, hasPowderData = powderLines.Count > 0,
@@ -0,0 +1,928 @@
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using PowderCoating.Application.DTOs.Kiosk;
using PowderCoating.Application.Interfaces;
using PowderCoating.Application.Services;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
using PowderCoating.Web.Hubs;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Handles the customer self-service intake kiosk — both the in-person tablet flow
/// (SignalR-triggered, activation-cookie-authenticated) and the remote email-link flow.
///
/// Anonymous intake routes use ignoreQueryFilters:true to load KioskSession by token
/// because the anonymous HTTP context has no CompanyId claim, so the global tenant
/// filter would return nothing without that flag.
///
/// When creating new Customer or Job records from the kiosk, CompanyId is set explicitly
/// from session.CompanyId so the EF SaveChanges interceptor doesn't override it with 0.
/// </summary>
public class KioskController : Controller
{
private const string CookieName = "KioskDevice";
private const int InPersonExpireHours = 2;
private const int RemoteExpireHours = 48;
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly ILookupCacheService _lookupCache;
private readonly IInAppNotificationService _inApp;
private readonly IEmailService _emailService;
private readonly IHubContext<KioskHub> _kioskHub;
private readonly ILogger<KioskController> _logger;
private readonly ICompanyLogoService _logoService;
private readonly IMemoryCache _cache;
private static string SmsConsentCacheKey(int companyId) => $"kiosk-sms-consent:{companyId}";
/// <summary>Initialises all dependencies for the kiosk controller.</summary>
public KioskController(
IUnitOfWork unitOfWork,
IMapper mapper,
ILookupCacheService lookupCache,
IInAppNotificationService inApp,
IEmailService emailService,
IHubContext<KioskHub> kioskHub,
ILogger<KioskController> logger,
ICompanyLogoService logoService,
IMemoryCache cache)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_lookupCache = lookupCache;
_inApp = inApp;
_emailService = emailService;
_kioskHub = kioskHub;
_logger = logger;
_logoService = logoService;
_cache = cache;
}
// =========================================================================
// WELCOME SCREEN (in-person tablet idle screen)
// =========================================================================
/// <summary>
/// Idle branded screen displayed on the front-desk tablet.
/// Validates the KioskDevice cookie; returns 403 if missing or token mismatch.
/// The view polls /Kiosk/PollSession every 3 seconds and navigates when staff
/// triggers a session via the Dashboard "Start Intake" button.
/// </summary>
[AllowAnonymous]
public async Task<IActionResult> Welcome()
{
var cookie = ReadKioskCookie();
if (cookie == null)
return View("KioskError", "This device is not activated as a kiosk. Ask a staff member to activate it at Settings → Kiosk.");
var company = await _unitOfWork.Companies.GetByIdAsync(cookie.Value.companyId, ignoreQueryFilters: true);
if (company == null || company.KioskActivationToken != cookie.Value.token)
return View("KioskError", "Kiosk activation token is invalid or has been revoked. Ask a staff member to re-activate this device.");
await PopulateKioskViewBag(company);
ViewBag.ShowInactivityTimer = false; // Welcome screen stays on indefinitely
return View();
}
/// <summary>
/// Lightweight polling endpoint called every 3 seconds by the kiosk Welcome screen.
/// Returns the most recent InPerson KioskSession created in the last 60 seconds so
/// the tablet can navigate without relying on SignalR (which Azure App Service blocks
/// for anonymous WebSocket/SSE connections through its ingress proxy).
/// </summary>
[AllowAnonymous, HttpGet]
[ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)]
public async Task<IActionResult> PollSession()
{
var cookie = ReadKioskCookie();
if (cookie == null) return Json(new { hasSession = false });
var company = await _unitOfWork.Companies.GetByIdAsync(cookie.Value.companyId, ignoreQueryFilters: true);
if (company == null || company.KioskActivationToken != cookie.Value.token)
return Json(new { hasSession = false });
// Check for a staff-pushed SMS consent request before checking for intake sessions.
if (_cache.TryGetValue(SmsConsentCacheKey(cookie.Value.companyId), out (int customerId, string customerName) pending))
return Json(new { hasSession = false, smsConsentPending = true, customerId = pending.customerId, customerName = pending.customerName });
var window = DateTime.UtcNow.AddSeconds(-60);
var session = await _unitOfWork.KioskSessions.FirstOrDefaultAsync(
s => s.CompanyId == cookie.Value.companyId
&& s.SessionType == KioskSessionType.InPerson
&& s.Status == KioskSessionStatus.Active
&& s.CreatedAt >= window,
ignoreQueryFilters: true);
if (session == null) return Json(new { hasSession = false });
return Json(new { hasSession = true, sessionToken = session.SessionToken });
}
// =========================================================================
// SMS CONSENT (staff pushes to kiosk; customer agrees on tablet)
// =========================================================================
/// <summary>
/// Staff calls this (authenticated) from the Customer Details page to push an SMS
/// consent request to the front-desk kiosk tablet. Stores the customer ID in
/// IMemoryCache under a company-scoped key; the kiosk's PollSession endpoint picks
/// it up and returns smsConsentPending so the tablet can navigate to the consent page.
/// The cache entry expires in 10 minutes in case the customer never approaches the tablet.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> PushSmsConsent(int customerId)
{
var customer = await _unitOfWork.Customers.GetByIdAsync(customerId);
if (customer == null) return Json(new { success = false, message = "Customer not found." });
if (customer.NotifyBySms)
return Json(new { success = false, message = "Customer has already given SMS consent." });
var companyId = customer.CompanyId;
var name = !string.IsNullOrWhiteSpace(customer.ContactFirstName)
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
: customer.CompanyName ?? "Customer";
_cache.Set(SmsConsentCacheKey(companyId), (customerId, name),
new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) });
_logger.LogInformation("SMS consent pushed to kiosk for customer {CustomerId} by staff", customerId);
return Json(new { success = true });
}
/// <summary>
/// Cancels a pending kiosk SMS consent request, freeing the kiosk to return to the Welcome
/// screen. Called by staff if they pushed consent accidentally or the customer isn't coming.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public IActionResult CancelSmsConsent()
{
var companyId = HttpContext.User.FindFirst("CompanyId")?.Value;
if (int.TryParse(companyId, out var cid))
_cache.Remove(SmsConsentCacheKey(cid));
return Json(new { success = true });
}
/// <summary>
/// Displays the full-screen SMS consent form on the kiosk tablet (anonymous, kiosk layout).
/// Loads the customer by ID with ignoreQueryFilters because the kiosk has no tenant context.
/// </summary>
[AllowAnonymous]
public async Task<IActionResult> SmsConsent(int id)
{
var cookie = ReadKioskCookie();
if (cookie == null) return Forbid();
// Clear the pending entry immediately — the kiosk is now showing the form,
// so Welcome must not redirect again if the customer cancels or navigates back.
_cache.Remove(SmsConsentCacheKey(cookie.Value.companyId));
var customer = await _unitOfWork.Customers.GetByIdAsync(id, ignoreQueryFilters: true);
if (customer == null) return NotFound();
var company = await _unitOfWork.Companies.GetByIdAsync(cookie.Value.companyId, ignoreQueryFilters: true);
ViewBag.CompanyName = company?.CompanyName;
ViewBag.CompanyLogoUrl = !string.IsNullOrEmpty(company?.LogoFilePath) ? Url.Action("Logo", "Kiosk") : null;
ViewBag.ShowInactivityTimer = false;
ViewBag.CustomerName = !string.IsNullOrWhiteSpace(customer.ContactFirstName)
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
: customer.CompanyName ?? "Customer";
return View(id);
}
/// <summary>
/// Records the customer's SMS consent from the kiosk tablet.
/// Sets NotifyBySms, SmsConsentedAt, SmsConsentMethod = "KioskInPerson" on the customer record.
/// Cache is already cleared by the GET; this handles the agree/decline outcome.
/// </summary>
[AllowAnonymous, HttpPost]
public async Task<IActionResult> SmsConsent(int id, bool agreed)
{
var cookie = ReadKioskCookie();
if (cookie == null) return Forbid();
if (agreed)
{
var customer = await _unitOfWork.Customers.GetByIdAsync(id, ignoreQueryFilters: true);
if (customer != null)
{
customer.NotifyBySms = true;
customer.SmsConsentedAt = DateTime.UtcNow;
customer.SmsConsentMethod = "KioskInPerson";
customer.SmsOptedOutAt = null;
await _unitOfWork.Customers.UpdateAsync(customer);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("SMS consent recorded via kiosk for customer {CustomerId}", id);
await _inApp.CreateAsync(
customer.CompanyId,
"SMS Consent Recorded",
$"{customer.ContactFirstName} {customer.ContactLastName} agreed to SMS notifications on the kiosk.",
"KioskConsent",
link: $"/Customers/Details/{id}",
customerId: id);
}
}
return Redirect("/Kiosk/Welcome");
}
/// <summary>
/// Serves the company logo for anonymous kiosk pages. Resolves the company from the
/// KioskDevice cookie so no tenant context is needed on the anonymous request.
/// </summary>
[AllowAnonymous]
[HttpGet, ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any)]
public async Task<IActionResult> Logo()
{
var cookie = ReadKioskCookie();
if (cookie == null) return NotFound();
var company = await _unitOfWork.Companies.GetByIdAsync(cookie.Value.companyId, ignoreQueryFilters: true);
if (company == null || string.IsNullOrEmpty(company.LogoFilePath)) return NotFound();
var (success, fileContent, contentType, _) = await _logoService.GetCompanyLogoAsync(company.LogoFilePath);
if (!success || fileContent.Length == 0) return NotFound();
return File(fileContent, contentType);
}
// =========================================================================
// DEVICE ACTIVATION (CompanyAdmin-only)
// =========================================================================
/// <summary>Shows the kiosk activation page with the current activation status.</summary>
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> Activate()
{
var companyId = GetCurrentCompanyId();
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
ViewBag.IsActivated = !string.IsNullOrEmpty(company?.KioskActivationToken);
return View();
}
/// <summary>
/// Generates a new activation token, saves it to the Company record,
/// and writes the KioskDevice cookie so the current browser session becomes the active tablet.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public async Task<IActionResult> Activate(string action)
{
var companyId = GetCurrentCompanyId();
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
if (company == null) return NotFound();
if (action == "deactivate")
{
company.KioskActivationToken = null;
DeleteKioskCookie();
TempData["Success"] = "Kiosk deactivated. The tablet will no longer accept intake sessions.";
}
else
{
var token = Guid.NewGuid().ToString("N");
company.KioskActivationToken = token;
WriteKioskCookie(companyId, token);
TempData["Success"] = "Kiosk activated. Open /Kiosk/Welcome on the tablet and bookmark it.";
}
await _unitOfWork.CompleteAsync();
return RedirectToAction(nameof(Activate));
}
// =========================================================================
// START IN-PERSON SESSION (any authenticated staff member)
// =========================================================================
/// <summary>
/// Creates an InPerson KioskSession and pushes a SignalR StartIntake event
/// to all connections in the company's kiosk group so the tablet navigates automatically.
/// Called via fetch from the Dashboard "Start Intake" button.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize]
public async Task<IActionResult> StartSession()
{
var companyId = GetCurrentCompanyId();
var session = new KioskSession
{
SessionType = KioskSessionType.InPerson,
ExpiresAt = DateTime.UtcNow.AddHours(InPersonExpireHours),
CompanyId = companyId
};
await _unitOfWork.KioskSessions.AddAsync(session);
await _unitOfWork.CompleteAsync();
await _kioskHub.Clients
.Group($"kiosk-{companyId}")
.SendAsync("StartIntake", session.SessionToken.ToString());
return Json(new { success = true, sessionToken = session.SessionToken });
}
// =========================================================================
// SEND REMOTE LINK (any authenticated staff member)
// =========================================================================
/// <summary>Form for staff to enter a customer's email address and send an intake link.</summary>
[Authorize]
public IActionResult SendRemoteLink() => View(new SendRemoteLinkDto());
/// <summary>
/// Creates a Remote KioskSession, sends the intake link by email, and redirects back
/// with a success message. The link contains the session token (GUID) — not guessable.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize]
public async Task<IActionResult> SendRemoteLink(SendRemoteLinkDto dto)
{
if (!ModelState.IsValid) return View(dto);
var companyId = GetCurrentCompanyId();
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
var session = new KioskSession
{
SessionType = KioskSessionType.Remote,
ExpiresAt = DateTime.UtcNow.AddHours(RemoteExpireHours),
RemoteLinkEmail = dto.Email,
RemoteLinkSentAt = DateTime.UtcNow,
CompanyId = companyId
};
await _unitOfWork.KioskSessions.AddAsync(session);
await _unitOfWork.CompleteAsync();
var link = $"{Request.Scheme}://{Request.Host}/Kiosk/Intake/{session.SessionToken}/Contact";
var recipientName = string.IsNullOrWhiteSpace(dto.CustomerName) ? "Valued Customer" : dto.CustomerName;
var companyName = company?.CompanyName ?? "Us";
var html = $@"
<div style='font-family:sans-serif;max-width:560px;margin:0 auto;padding:2rem;'>
<h2 style='color:#1e293b;'>Hi {System.Web.HttpUtility.HtmlEncode(recipientName)},</h2>
<p style='color:#475569;font-size:1rem;'>
{System.Web.HttpUtility.HtmlEncode(companyName)} has sent you a quick intake form to fill out before your visit.
It only takes a couple of minutes.
</p>
<a href='{link}' style='display:inline-block;margin:1.5rem 0;padding:1rem 2rem;background:#2563eb;
color:#fff;font-weight:600;border-radius:8px;text-decoration:none;font-size:1.1rem;'>
Start My Intake Form
</a>
<p style='color:#94a3b8;font-size:0.85rem;'>
This link expires in 48 hours. If you did not expect this email, you can ignore it.
</p>
</div>";
await _emailService.SendEmailAsync(
dto.Email, recipientName,
$"Your intake form from {companyName}",
$"Please visit this link to complete your intake form: {link}",
htmlBody: html);
TempData["Success"] = $"Intake link sent to {dto.Email}.";
return RedirectToAction(nameof(SendRemoteLink));
}
// =========================================================================
// INTAKE STEPS (anonymous — both InPerson and Remote)
// =========================================================================
// ── Step 1: Contact Info ──────────────────────────────────────────────────
/// <summary>Displays the contact-info form for the given session token.</summary>
[AllowAnonymous]
public async Task<IActionResult> Contact(Guid token)
{
var session = await LoadSessionAsync(token);
if (session == null) return View("KioskError", "This intake session could not be found. Please ask a staff member to start a new one.");
if (!await ValidateSessionState(session)) return RedirectToAction(nameof(Confirmation), new { token });
await PopulateKioskViewBagFromSession(session);
ViewBag.KioskStep = 1;
return View("Intake/Contact", new SubmitKioskContactDto
{
FirstName = session.CustomerFirstName,
LastName = session.CustomerLastName,
Phone = session.CustomerPhone,
Email = session.CustomerEmail,
IsReturningCustomer = session.IsReturningCustomer
});
}
/// <summary>Saves contact info to the session and advances to Step 2.</summary>
[HttpPost, ValidateAntiForgeryToken]
[AllowAnonymous]
public async Task<IActionResult> Contact(Guid token, SubmitKioskContactDto dto)
{
var session = await LoadSessionAsync(token);
if (session == null) return View("KioskError", "Session not found.");
if (!ModelState.IsValid)
{
await PopulateKioskViewBagFromSession(session);
ViewBag.KioskStep = 1;
return View("Intake/Contact", dto);
}
session.CustomerFirstName = dto.FirstName.Trim();
session.CustomerLastName = dto.LastName.Trim();
session.CustomerPhone = dto.Phone.Trim();
session.CustomerEmail = dto.Email.Trim().ToLowerInvariant();
session.IsReturningCustomer = dto.IsReturningCustomer;
await _unitOfWork.CompleteAsync();
return RedirectToAction(nameof(Job), new { token });
}
// ── Step 2: Job Description ───────────────────────────────────────────────
/// <summary>Displays the job-description form.</summary>
[AllowAnonymous]
public async Task<IActionResult> Job(Guid token)
{
var session = await LoadSessionAsync(token);
if (session == null) return View("KioskError", "Session not found.");
if (!await ValidateSessionState(session)) return RedirectToAction(nameof(Confirmation), new { token });
await PopulateKioskViewBagFromSession(session);
ViewBag.KioskStep = 2;
return View("Intake/Job", new SubmitKioskJobDto
{
JobDescription = session.JobDescription,
HowDidYouHearAboutUs = session.HowDidYouHearAboutUs
});
}
/// <summary>Saves the job description and advances to Step 3.</summary>
[HttpPost, ValidateAntiForgeryToken]
[AllowAnonymous]
public async Task<IActionResult> Job(Guid token, SubmitKioskJobDto dto)
{
var session = await LoadSessionAsync(token);
if (session == null) return View("KioskError", "Session not found.");
if (!ModelState.IsValid)
{
await PopulateKioskViewBagFromSession(session);
ViewBag.KioskStep = 2;
return View("Intake/Job", dto);
}
session.JobDescription = dto.JobDescription.Trim();
session.HowDidYouHearAboutUs = dto.HowDidYouHearAboutUs?.Trim();
await _unitOfWork.CompleteAsync();
return RedirectToAction(nameof(Terms), new { token });
}
// ── Step 3: Terms & Consent ───────────────────────────────────────────────
/// <summary>Displays the terms, SMS opt-in checkbox, and (for InPerson) signature pad.</summary>
[AllowAnonymous]
public async Task<IActionResult> Terms(Guid token)
{
var session = await LoadSessionAsync(token);
if (session == null) return View("KioskError", "Session not found.");
if (!await ValidateSessionState(session)) return RedirectToAction(nameof(Confirmation), new { token });
await PopulateKioskViewBagFromSession(session);
ViewBag.KioskStep = 3;
ViewBag.IsInPerson = session.SessionType == KioskSessionType.InPerson;
return View("Intake/Terms", new SubmitKioskTermsDto());
}
/// <summary>
/// Saves terms agreement, triggers customer/job auto-creation, fires staff notification,
/// and redirects to the Confirmation screen.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[AllowAnonymous]
public async Task<IActionResult> Terms(Guid token, SubmitKioskTermsDto dto)
{
var session = await LoadSessionAsync(token);
if (session == null) return View("KioskError", "Session not found.");
// Expired/already-submitted sessions go straight to Confirmation
if (!await ValidateSessionState(session)) return RedirectToAction(nameof(Confirmation), new { token });
// Require signature for in-person sessions
if (session.SessionType == KioskSessionType.InPerson &&
string.IsNullOrEmpty(dto.SignatureDataBase64))
{
ModelState.AddModelError("SignatureDataBase64", "Please sign above before continuing.");
}
if (!ModelState.IsValid)
{
await PopulateKioskViewBagFromSession(session);
ViewBag.KioskStep = 3;
ViewBag.IsInPerson = session.SessionType == KioskSessionType.InPerson;
return View("Intake/Terms", dto);
}
session.AgreedToTerms = true;
session.AgreedToTermsAt = DateTime.UtcNow;
session.SmsOptIn = dto.SmsOptIn;
session.SignatureDataBase64 = dto.SignatureDataBase64;
session.Status = KioskSessionStatus.Submitted;
session.SubmittedAt = DateTime.UtcNow;
try
{
await ProcessSubmissionAsync(session);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing kiosk submission for session {SessionToken}", token);
// Customer-facing page always succeeds — staff can convert the session manually.
// Persist the session's agreed/submitted state even if job creation failed.
try { await _unitOfWork.CompleteAsync(); } catch { /* best-effort */ }
}
return RedirectToAction(nameof(Confirmation), new { token });
}
// ── Confirmation ──────────────────────────────────────────────────────────
/// <summary>Thank-you screen shown after a successful submission.</summary>
[AllowAnonymous]
public async Task<IActionResult> Confirmation(Guid token)
{
var session = await LoadSessionAsync(token);
if (session == null) return View("KioskError", "Session not found.");
await PopulateKioskViewBagFromSession(session);
ViewBag.ShowInactivityTimer = false; // Handled by the countdown JS in the view
ViewBag.IsInPerson = session.SessionType == KioskSessionType.InPerson;
ViewBag.FirstName = session.CustomerFirstName;
return View("Intake/Confirmation");
}
// =========================================================================
// STAFF REVIEW (authenticated)
// =========================================================================
/// <summary>
/// Lists all kiosk intake sessions for the current company — submitted, active, and expired.
/// Manager or higher access required.
/// </summary>
[Authorize]
public async Task<IActionResult> Intakes(string? filter)
{
var sessions = await _unitOfWork.KioskSessions.GetAllAsync(false,
s => s.LinkedCustomer,
s => s.LinkedJob);
var dtos = sessions
.OrderByDescending(s => s.CreatedAt)
.Select(s => new KioskSessionListDto
{
Id = s.Id,
SessionToken = s.SessionToken,
SessionType = s.SessionType,
Status = s.Status,
CustomerFirstName = s.CustomerFirstName,
CustomerLastName = s.CustomerLastName,
CustomerEmail = s.CustomerEmail,
CustomerPhone = s.CustomerPhone,
JobDescription = s.JobDescription,
SmsOptIn = s.SmsOptIn,
SubmittedAt = s.SubmittedAt,
ExpiresAt = s.ExpiresAt,
LinkedCustomerId = s.LinkedCustomerId,
LinkedJobId = s.LinkedJobId,
LinkedQuoteId = s.LinkedQuoteId,
RemoteLinkEmail = s.RemoteLinkEmail
})
.ToList();
// Apply filter tab
dtos = filter switch
{
"submitted" => dtos.Where(d => d.Status == KioskSessionStatus.Submitted).ToList(),
"active" => dtos.Where(d => d.Status == KioskSessionStatus.Active && !d.IsExpired).ToList(),
"expired" => dtos.Where(d => d.IsExpired || d.Status == KioskSessionStatus.Expired).ToList(),
_ => dtos
};
ViewBag.ActiveFilter = filter ?? "all";
return View(dtos);
}
// =========================================================================
// PRIVATE HELPERS
// =========================================================================
/// <summary>
/// Loads a KioskSession by SessionToken using ignoreQueryFilters because anonymous requests
/// have no CompanyId claim, so the global tenant filter would return nothing without it.
/// </summary>
private async Task<KioskSession?> LoadSessionAsync(Guid token)
{
return await _unitOfWork.KioskSessions.FirstOrDefaultAsync(
s => s.SessionToken == token && !s.IsDeleted,
ignoreQueryFilters: true);
}
/// <summary>
/// Validates that the session is still in a usable state.
/// Returns false (and optionally updates status to Expired) if the session should not proceed.
/// </summary>
private async Task<bool> ValidateSessionState(KioskSession session)
{
if (session.Status == KioskSessionStatus.Submitted)
return false; // Already done — redirect to Confirmation (idempotent)
if (session.Status == KioskSessionStatus.Cancelled)
return false;
if (DateTime.UtcNow > session.ExpiresAt && session.Status == KioskSessionStatus.Active)
{
session.Status = KioskSessionStatus.Expired;
await _unitOfWork.CompleteAsync();
return false;
}
return session.Status == KioskSessionStatus.Active;
}
/// <summary>
/// Core submission logic: matches or creates a Customer, creates a Pending Job,
/// applies SMS consent, and fires a staff in-app notification.
/// CompanyId is set explicitly on new entities from session.CompanyId so the EF
/// SaveChanges interceptor does not override it with 0 (the anonymous tenant context).
/// </summary>
private async Task ProcessSubmissionAsync(KioskSession session)
{
var companyId = session.CompanyId;
// 1. Match or create Customer
Customer? customer = null;
if (!string.IsNullOrEmpty(session.CustomerEmail))
{
customer = await _unitOfWork.Customers.FirstOrDefaultAsync(
c => c.CompanyId == companyId && c.Email == session.CustomerEmail && !c.IsDeleted,
ignoreQueryFilters: true);
}
if (customer == null && !string.IsNullOrEmpty(session.CustomerPhone))
{
customer = await _unitOfWork.Customers.FirstOrDefaultAsync(
c => c.CompanyId == companyId && (c.Phone == session.CustomerPhone || c.MobilePhone == session.CustomerPhone) && !c.IsDeleted,
ignoreQueryFilters: true);
}
bool isNewCustomer = customer == null;
if (isNewCustomer)
{
customer = new Customer
{
CompanyId = companyId,
ContactFirstName = session.CustomerFirstName,
ContactLastName = session.CustomerLastName,
Phone = session.CustomerPhone,
Email = session.CustomerEmail,
IsActive = true,
IsCommercial = false
};
await _unitOfWork.Customers.AddAsync(customer);
await _unitOfWork.CompleteAsync(); // get Customer.Id
}
// 2. Apply SMS consent
if (session.SmsOptIn)
{
customer!.NotifyBySms = true;
customer.SmsConsentedAt = session.SubmittedAt ?? DateTime.UtcNow;
customer.SmsConsentMethod = session.SessionType == KioskSessionType.InPerson
? "KioskIntake"
: "RemoteIntake";
}
// 3. Resolve company preference: create a Quote (default) or a Job
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true);
var intakeOutput = prefs?.KioskIntakeOutput ?? "Quote";
var createQuote = !string.Equals(intakeOutput, "Job", StringComparison.OrdinalIgnoreCase);
session.LinkedCustomerId = customer!.Id;
if (createQuote)
{
// 3a. Create a Draft Quote so staff can price and send for approval
var quoteStatuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
var draftStatus = quoteStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Draft);
if (draftStatus == null)
throw new InvalidOperationException($"No Draft quote status found for company {companyId}. Run Seed Data from Platform Management.");
var quoteNumber = await GenerateQuoteNumberAsync(companyId);
var quote = new Quote
{
CompanyId = companyId,
CustomerId = customer.Id,
QuoteNumber = quoteNumber,
QuoteStatusId = draftStatus.Id,
Description = session.JobDescription,
Notes = $"Source: {session.SessionType} kiosk intake",
QuoteDate = DateTime.UtcNow,
ExpirationDate = DateTime.UtcNow.AddDays(prefs?.DefaultQuoteValidityDays ?? 30)
};
await _unitOfWork.Quotes.AddAsync(quote);
await _unitOfWork.CompleteAsync(); // quote.Id now valid
session.LinkedQuoteId = quote.Id;
}
else
{
// 3b. Create a Pending Job directly (for shops that price on the spot)
var jobStatuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
var pendingStatus = jobStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
if (pendingStatus == null)
throw new InvalidOperationException($"No Pending job status found for company {companyId}. Run Seed Data from Platform Management.");
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL")
?? priorities.FirstOrDefault();
if (normalPriority == null)
throw new InvalidOperationException($"No job priority rows found for company {companyId}. Run Seed Data from Platform Management.");
var jobNumber = await GenerateJobNumberAsync(companyId);
var job = new Job
{
CompanyId = companyId,
CustomerId = customer.Id,
JobNumber = jobNumber,
JobStatusId = pendingStatus.Id,
JobPriorityId = normalPriority.Id,
Description = session.JobDescription,
SpecialInstructions = $"Source: {session.SessionType} kiosk intake"
};
await _unitOfWork.Jobs.AddAsync(job);
await _unitOfWork.CompleteAsync(); // job.Id now valid
session.LinkedJobId = job.Id;
}
// 4. Persist session links
await _unitOfWork.CompleteAsync();
// 5. Fire staff notification
var jobDesc = session.JobDescription ?? "";
var snippet = jobDesc.Length > 60 ? jobDesc[..60] + "…" : jobDesc;
var fullName = $"{session.CustomerFirstName} {session.CustomerLastName}".Trim();
var intakeLabel = session.SessionType == KioskSessionType.Remote ? "Remote Intake" : "Walk-in Intake";
await _inApp.CreateAsync(
companyId,
$"{intakeLabel} Submitted",
$"{fullName} completed their intake form — {snippet}",
"KioskIntake",
link: $"/Kiosk/Intakes",
customerId: customer.Id);
}
/// <summary>
/// Generates the next sequential quote number using the company's configured prefix.
/// Mirrors GenerateQuoteNumberAsync in QuotesController — same format: PREFIX-YYMM-####.
/// Implemented here because KioskController processes anonymous requests and cannot
/// rely on ITenantContext to resolve the company ID.
/// </summary>
private async Task<string> GenerateQuoteNumberAsync(int companyId)
{
var now = DateTime.UtcNow;
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true);
var quotePrefix = !string.IsNullOrWhiteSpace(prefs?.QuoteNumberPrefix) ? prefs.QuoteNumberPrefix : "QT";
var prefix = $"{quotePrefix}-{now:yy}{now:MM}";
var lastQuoteNumber = await _unitOfWork.Quotes.GetLastQuoteNumberByPrefixAsync(companyId, prefix);
if (lastQuoteNumber != null)
{
var lastNumberStr = lastQuoteNumber[(prefix.Length + 1)..];
if (int.TryParse(lastNumberStr, out int lastNumber))
return $"{prefix}-{(lastNumber + 1):D4}";
}
return $"{prefix}-0001";
}
/// <summary>
/// Generates the next sequential job number using the company's configured prefix.
/// Mirrors the logic in JobsController.GenerateJobNumber() — same format: PREFIX-YYMM-####.
/// </summary>
private async Task<string> GenerateJobNumberAsync(int companyId)
{
var year = DateTime.Now.Year.ToString()[2..];
var month = DateTime.Now.Month.ToString("D2");
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true);
var jobPrefix = !string.IsNullOrWhiteSpace(prefs?.JobNumberPrefix) ? prefs.JobNumberPrefix : "JOB";
var prefix = $"{jobPrefix}-{year}{month}";
var lastJobNumber = await _unitOfWork.Jobs.GetLastJobNumberByPrefixAsync(companyId, prefix);
if (lastJobNumber != null)
{
var lastNumberStr = lastJobNumber[(prefix.Length + 1)..];
if (int.TryParse(lastNumberStr, out int lastNumber))
return $"{prefix}-{(lastNumber + 1):D4}";
}
return $"{prefix}-0001";
}
/// <summary>
/// Reads the KioskDevice cookie and parses the "{companyId}:{token}" value.
/// Returns null if the cookie is absent or malformed.
/// </summary>
private (int companyId, string token)? ReadKioskCookie()
{
if (!Request.Cookies.TryGetValue(CookieName, out var raw) || string.IsNullOrEmpty(raw))
return null;
var parts = raw.Split(':', 2);
if (parts.Length != 2 || !int.TryParse(parts[0], out int id))
return null;
return (id, parts[1]);
}
/// <summary>Writes a long-lived HttpOnly kiosk device cookie.</summary>
private void WriteKioskCookie(int companyId, string token)
{
Response.Cookies.Append(CookieName, $"{companyId}:{token}", new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Lax,
MaxAge = TimeSpan.FromDays(365)
});
}
/// <summary>Removes the kiosk device cookie (deactivation).</summary>
private void DeleteKioskCookie()
{
Response.Cookies.Delete(CookieName);
}
/// <summary>Returns the current authenticated user's CompanyId claim.</summary>
private int GetCurrentCompanyId()
{
var claim = User.FindFirst("CompanyId")?.Value;
return int.TryParse(claim, out int id) ? id : 0;
}
/// <summary>Sets ViewBag properties needed by _KioskLayout from a Company entity.</summary>
private async Task PopulateKioskViewBag(Company company)
{
ViewBag.CompanyId = company.Id;
ViewBag.CompanyName = company.CompanyName;
ViewBag.CompanyLogoUrl = !string.IsNullOrEmpty(company.LogoFilePath)
? Url.Action("Logo", "Kiosk")
: null;
ViewBag.WelcomeUrl = "/Kiosk/Welcome";
// Pass the intake output setting so Terms.cshtml can show matching wording
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
p => p.CompanyId == company.Id && !p.IsDeleted, ignoreQueryFilters: true);
ViewBag.KioskIntakeOutput = prefs?.KioskIntakeOutput ?? "Quote";
}
/// <summary>Loads the company from a session's CompanyId and populates ViewBag.</summary>
private async Task PopulateKioskViewBagFromSession(KioskSession session)
{
var company = await _unitOfWork.Companies.GetByIdAsync(session.CompanyId, ignoreQueryFilters: true);
if (company != null)
await PopulateKioskViewBag(company);
ViewBag.SessionToken = session.SessionToken;
ViewBag.SessionType = session.SessionType;
// In-person kiosk: reset to Welcome screen after 45 s of inactivity so an
// abandoned tablet doesn't stay on a customer's half-filled form indefinitely.
// Remote sessions: customer is on their own phone — never redirect; they may
// take several minutes between steps and have no KioskDevice cookie anyway.
if (session.SessionType == KioskSessionType.InPerson)
ViewBag.InactivityTimeoutMs = 45_000;
else
ViewBag.ShowInactivityTimer = false;
}
}
@@ -153,6 +153,86 @@ public class PaymentController : Controller
return Ok(new { clientSecret, surchargeAmount = surcharge }); return Ok(new { clientSecret, surchargeAmount = surcharge });
} }
// ─── GET /invoice/{token} ────────────────────────────────────────────────
/// <summary>
/// Customer-facing read-only invoice view page. Resolved via PublicViewToken (permanent, no expiry).
/// Shows full line items, totals, and company branding. If a valid PaymentLinkToken exists, renders
/// a "Pay Now" button linking to /pay/{paymentLinkToken}. This is the link sent in SMS messages
/// since SMS cannot attach a PDF.
/// </summary>
[HttpGet("/invoice/{token}")]
public async Task<IActionResult> InvoiceView(string token)
{
try
{
var invoice = await _context.Invoices
.AsNoTracking()
.Include(i => i.InvoiceItems)
.Include(i => i.Customer)
.Include(i => i.Job)
.IgnoreQueryFilters()
.FirstOrDefaultAsync(i => i.PublicViewToken == token && !i.IsDeleted);
if (invoice == null)
return View("PaymentError", "This invoice link is invalid or has been removed.");
var company = await _context.Companies.AsNoTracking()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(c => c.Id == invoice.CompanyId && !c.IsDeleted);
if (company == null)
return View("PaymentError", "Unable to load invoice details.");
var paymentUrl = (!string.IsNullOrEmpty(invoice.PaymentLinkToken)
&& invoice.PaymentLinkExpiresAt > DateTime.UtcNow
&& invoice.BalanceDue > 0)
? $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}"
: null;
var vm = new InvoiceViewViewModel
{
InvoiceNumber = invoice.InvoiceNumber,
InvoiceDate = invoice.InvoiceDate,
DueDate = invoice.DueDate,
CustomerName = invoice.Customer != null
? $"{invoice.Customer.ContactFirstName} {invoice.Customer.ContactLastName}".Trim()
: "Valued Customer",
CompanyName = company.CompanyName,
CompanyPhone = company.Phone,
CompanyAddress = string.Join(", ", new[] { company.Address, company.City, company.State, company.ZipCode }
.Where(s => !string.IsNullOrWhiteSpace(s))),
LogoFilePath = company.LogoFilePath,
SubTotal = invoice.SubTotal,
TaxPercent = invoice.TaxPercent,
TaxAmount = invoice.TaxAmount,
DiscountAmount = invoice.DiscountAmount,
Total = invoice.Total,
AmountPaid = invoice.AmountPaid,
BalanceDue = invoice.BalanceDue,
Status = invoice.Status,
Notes = invoice.Notes,
Terms = invoice.Terms,
JobNumber = invoice.Job?.JobNumber,
PaymentUrl = paymentUrl,
LineItems = invoice.InvoiceItems.Select(i => new InvoiceViewLineItem
{
Description = i.Description,
Quantity = i.Quantity,
UnitPrice = i.UnitPrice,
TotalPrice = i.TotalPrice
}).ToList()
};
return View(vm);
}
catch (Exception ex)
{
_logger.LogError(ex, "InvoiceView failed for token {Token}", token);
return View("PaymentError", "An error occurred loading this invoice.");
}
}
// ─── GET /pay/deposit/{token} ──────────────────────────────────────────── // ─── GET /pay/deposit/{token} ────────────────────────────────────────────
/// <summary> /// <summary>
@@ -897,6 +977,39 @@ public class DepositPaymentPageViewModel
public string StripeAccountId { get; set; } = string.Empty; public string StripeAccountId { get; set; } = string.Empty;
} }
public class InvoiceViewViewModel
{
public string InvoiceNumber { get; set; } = string.Empty;
public DateTime InvoiceDate { get; set; }
public DateTime? DueDate { get; set; }
public string CustomerName { get; set; } = string.Empty;
public string CompanyName { get; set; } = string.Empty;
public string? CompanyPhone { get; set; }
public string? CompanyAddress { get; set; }
public string? LogoFilePath { get; set; }
public decimal SubTotal { get; set; }
public decimal TaxPercent { get; set; }
public decimal TaxAmount { get; set; }
public decimal DiscountAmount { get; set; }
public decimal Total { get; set; }
public decimal AmountPaid { get; set; }
public decimal BalanceDue { get; set; }
public InvoiceStatus Status { get; set; }
public string? Notes { get; set; }
public string? Terms { get; set; }
public string? JobNumber { get; set; }
public string? PaymentUrl { get; set; }
public List<InvoiceViewLineItem> LineItems { get; set; } = new();
}
public class InvoiceViewLineItem
{
public string Description { get; set; } = string.Empty;
public decimal Quantity { get; set; }
public decimal UnitPrice { get; set; }
public decimal TotalPrice { get; set; }
}
public class CreateIntentRequest public class CreateIntentRequest
{ {
public decimal Amount { get; set; } public decimal Amount { get; set; }
@@ -1,4 +1,5 @@
using AutoMapper; using System.Text.Json;
using AutoMapper;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using PowderCoating.Shared.Constants; using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -2840,13 +2841,46 @@ public class QuotesController : Controller
CustomerId = quote.CustomerId ?? 0, // Should always have a customer by approval time CustomerId = quote.CustomerId ?? 0, // Should always have a customer by approval time
QuoteId = quote.Id, QuoteId = quote.Id,
OvenCostId = quote.OvenCostId, // Carry oven selection from quote OvenCostId = quote.OvenCostId, // Carry oven selection from quote
OvenBatches = quote.OvenBatches > 0 ? quote.OvenBatches : 1,
OvenCycleMinutes = quote.OvenCycleMinutes,
Description = quote.Description ?? $"Job from Quote {quote.QuoteNumber}", Description = quote.Description ?? $"Job from Quote {quote.QuoteNumber}",
JobStatusId = approvedStatus?.Id ?? 1, JobStatusId = approvedStatus?.Id ?? 1,
JobPriorityId = selectedPriority?.Id ?? 1, JobPriorityId = selectedPriority?.Id ?? 1,
QuotedPrice = quote.Total, QuotedPrice = quote.Total,
FinalPrice = quote.Total, FinalPrice = quote.Total,
OvenBatchCost = quote.OvenBatchCost,
ShopSuppliesAmount = quote.ShopSuppliesAmount, ShopSuppliesAmount = quote.ShopSuppliesAmount,
ShopSuppliesPercent = quote.ShopSuppliesPercent, ShopSuppliesPercent = quote.ShopSuppliesPercent,
PricingBreakdownJson = JsonSerializer.Serialize(new QuotePricingBreakdownDto
{
MaterialCosts = quote.MaterialCosts,
LaborCosts = quote.LaborCosts,
EquipmentCosts = quote.EquipmentCosts,
ItemsSubtotal = quote.ItemsSubtotal,
OvenBatchCost = quote.OvenBatchCost,
OvenBatches = quote.OvenBatches,
OvenCycleMinutes = quote.OvenCycleMinutes ?? 0,
FacilityOverheadCost = quote.FacilityOverheadCost,
FacilityOverheadRatePerHour = quote.FacilityOverheadRatePerHour,
ShopSuppliesAmount = quote.ShopSuppliesAmount,
ShopSuppliesPercent = quote.ShopSuppliesPercent,
OverheadCosts = quote.OverheadAmount,
OverheadPercent = quote.OverheadPercent,
ProfitMargin = quote.ProfitMargin,
ProfitPercent = quote.ProfitPercent,
SubtotalBeforeDiscount = quote.SubTotal,
PricingTierDiscountAmount = quote.PricingTierDiscountAmount,
PricingTierDiscountPercent = quote.PricingTierDiscountPercent,
QuoteDiscountAmount = quote.QuoteDiscountAmount,
QuoteDiscountPercent = quote.QuoteDiscountPercent,
DiscountAmount = quote.DiscountAmount,
DiscountPercent = quote.DiscountPercent,
SubtotalAfterDiscount = quote.SubtotalAfterDiscount,
RushFee = quote.RushFee,
TaxAmount = quote.TaxAmount,
TaxPercent = quote.TaxPercent,
Total = quote.Total
}),
CustomerPO = quote.CustomerPO, CustomerPO = quote.CustomerPO,
InternalNotes = quote.Notes, // Copy internal notes from quote InternalNotes = quote.Notes, // Copy internal notes from quote
IsCustomerApproved = true, IsCustomerApproved = true,
@@ -911,7 +911,7 @@ public class ToolsController : Controller
/// <c>CompanyId</c> provides the multi-tenant isolation that global query filters would /// <c>CompanyId</c> provides the multi-tenant isolation that global query filters would
/// normally enforce for other entity types. /// normally enforce for other entity types.
/// </summary> /// </summary>
// GET: Tools/GetShopWorkers - For randomizer wheel // GET: Tools/GetShopWorkers - Returns active company users for randomizer wheel
[HttpGet] [HttpGet]
public async Task<IActionResult> GetShopWorkers() public async Task<IActionResult> GetShopWorkers()
{ {
@@ -109,6 +109,7 @@ public static class HelpKnowledgeBase
- Job Priority Board /JobsPriority - Job Priority Board /JobsPriority
- Online Payments /Invoices/OnlinePayments - Online Payments /Invoices/OnlinePayments
- Gift Certificates /GiftCertificates - Gift Certificates /GiftCertificates
- Intake Sessions /Kiosk/Intakes (walk-in and remote intake sessions submitted via the kiosk tablet)
**Inventory section:** **Inventory section:**
- Catalog Items /CatalogItems - Catalog Items /CatalogItems
@@ -1218,7 +1219,6 @@ public static class HelpKnowledgeBase
- [Accounts Payable](/Help/AccountsPayable) - [Accounts Payable](/Help/AccountsPayable)
- [Equipment & Maintenance](/Help/Equipment) - [Equipment & Maintenance](/Help/Equipment)
- [Vendors](/Help/Vendors) - [Vendors](/Help/Vendors)
- [Shop Workers](/Help/ShopWorkers)
- [Reports](/Help/Reports) - [Reports](/Help/Reports)
- [Settings](/Help/Settings) - [Settings](/Help/Settings)
- [User Profile](/Help/UserProfile) - [User Profile](/Help/UserProfile)
@@ -1265,6 +1265,60 @@ public static class HelpKnowledgeBase
--- ---
## CUSTOMER INTAKE KIOSK
**Where:** Kiosk Setup [/Kiosk/Activate](/Kiosk/Activate) | Intake Sessions [/Kiosk/Intakes](/Kiosk/Intakes)
**What it does:** Lets walk-in customers fill out their own intake form on a front-desk tablet. On submission, a Customer record and either a Draft Quote or a Pending Job are auto-created (controlled by the Kiosk Output Setting), and staff receive an in-app notification. Also supports remote intake via email link so customers fill out the form on their own phone before arriving.
**Kiosk Output Setting (Company Settings Kiosk tab):**
- "Create a Quote" (default) creates a Draft quote on submission; terms shown to customer say "subject to a formal quote." Best for shops that price after seeing the parts.
- "Create a Job" creates a Pending job on submission; terms say "team member will reach out about pricing." Best for shops that price on the spot.
**Setup (one-time per device):**
1. Go to Settings Kiosk Setup (or /Kiosk/Activate)
2. Click Activate Kiosk generates a secure activation token and sets a device cookie (365-day lifespan)
3. On the tablet browser, navigate to /Kiosk/Welcome the tablet is now in kiosk mode
4. Add to Home Screen on iOS/Android for a full-screen PWA experience that preserves camera permissions
**Starting an in-person intake:**
1. Customer approaches the tablet it shows the Welcome screen with company logo and a green "Ready" dot
2. Staff member clicks "Start Intake" on the Dashboard (Kiosk card)
3. Tablet picks up the new session within 3 seconds and auto-navigates to the intake form
4. Customer completes 3 steps: Contact info Job description Terms & drawn signature
5. On submit: thank-you screen shown, kiosk returns to Welcome after 30 seconds
6. If idle for 45 seconds during any intake step, the form resets to the Welcome screen automatically
**Sending a remote intake link:**
- Click "Send Intake Link" on the Dashboard Kiosk card OR from /Kiosk/Intakes Send Intake Link
- Enter the customer's email they receive a link to complete the form on their own device
- Remote sessions use a checkbox agreement instead of a drawn signature
**What happens on submission:**
- Customer is matched by email (first), then phone; if no match, a new non-commercial customer is created
- A Draft Quote or Pending Job is created depending on the Kiosk Output Setting (see above)
- SMS opt-in updates the customer record with NotifyBySms = true and a TCPA-compliant consent timestamp
- In-app notification fires: "Walk-in Intake Submitted" (in-person) or "Remote Intake Submitted" (remote link) with a link to /Kiosk/Intakes
**Reviewing submissions (Intake Sessions page):**
- Filter tabs: All / Submitted / Pending / Expired
- Each row shows customer name, phone, email, job description snippet, session type badge, SMS opt-in icon
- "View Quote" button appears in Quote mode; opens the auto-created Draft quote for pricing and review
- "View Job" button appears in Job mode; opens the auto-created Pending job so staff can assign and progress it
- "Customer" button opens the matched/created customer record
- If submission failed (e.g. seed data not run), the session is still marked Submitted but buttons won't appear raw intake data is still visible so staff can create manually
**Dashboard Kiosk card:** Shows whether the kiosk is activated. Contains "Start Intake" (triggers in-person session) and "Send Intake Link" (opens email dialog) buttons. Both are disabled if the kiosk is not activated.
**Troubleshooting:**
- "Connection issue — retrying…" on tablet: Wi-Fi problem; dot auto-recovers when connectivity returns
- Tablet doesn't respond to Start Intake: waits up to 3 s; reload Welcome page if still stuck
- No View Quote/Job button after submission: Seed Data not run Platform Admin must run it from Platform Management Seed Data
- Signature pad not working: requires capacitive touch (finger or stylus); ensure "Request Desktop Site" is off in browser settings
- AI quote times out on mobile: photos are auto-compressed; "Still analyzing…" message appears after 30 s; retry on stronger connection
---
## COMMON WORKFLOWS ## COMMON WORKFLOWS
**New company first-time setup:** **New company first-time setup:**
@@ -1279,6 +1333,15 @@ public static class HelpKnowledgeBase
**Prospect to customer:** **Prospect to customer:**
Create Quote for prospect Quote Approved Convert Prospect to Customer Convert Quote to Job Create Quote for prospect Quote Approved Convert Prospect to Customer Convert Quote to Job
**Walk-in customer intake (kiosk Quote mode):**
Staff clicks "Start Intake" on Dashboard tablet navigates to intake form within 3 s customer fills out 3 steps (contact, job description, terms + signature) system creates Customer + Draft Quote "Walk-in Intake Submitted" notification fires staff reviews at /Kiosk/Intakes clicks "View Quote" to price and send the quote
**Walk-in customer intake (kiosk Job mode):**
Same flow as above, but system creates a Pending Job instead of a Quote staff clicks "View Job" to assign a worker and progress the job through the workflow
**Remote intake (customer fills out before arriving):**
Staff clicks "Send Intake Link" on Dashboard or Intakes page enters customer email customer receives link and completes form on their own device same auto-create flow as in-person; notification reads "Remote Intake Submitted"
**Walk-in / phone quote (quick estimate):** **Walk-in / phone quote (quick estimate):**
Click the AI Quick Quote button (dark-blue floating button, bottom-right) type description AI returns price estimate Save as draft under "Walk-In / Phone" open the quote reassign the Customer dropdown on Quote Details to the real customer record once you have their info Click the AI Quick Quote button (dark-blue floating button, bottom-right) type description AI returns price estimate Save as draft under "Walk-In / Phone" open the quote reassign the Customer dropdown on Quote Details to the real customer record once you have their info
+55
View File
@@ -0,0 +1,55 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace PowderCoating.Web.Hubs;
/// <summary>
/// SignalR hub that delivers "StartIntake" push events to the front-desk tablet.
/// Deliberately [AllowAnonymous] — the tablet runs without a logged-in user.
/// Security is enforced at the kiosk route level via the KioskActivationToken cookie.
///
/// On connect the tablet passes ?companyId=N in the hub URL query string; this hub
/// places that connection in the company-scoped group "kiosk-{companyId}" so that
/// KioskController.StartSession can push to exactly that company's tablet.
/// </summary>
[AllowAnonymous]
public class KioskHub : Hub
{
private readonly ILogger<KioskHub> _logger;
/// <summary>Initialises the hub with the required logger.</summary>
public KioskHub(ILogger<KioskHub> logger)
{
_logger = logger;
}
/// <summary>
/// Joins the connection to the company-scoped kiosk group on connect.
/// companyId is read from the ?companyId query param embedded in the hub URL by the Welcome view.
/// </summary>
public override async Task OnConnectedAsync()
{
try
{
var companyId = Context.GetHttpContext()?.Request.Query["companyId"].FirstOrDefault();
if (!string.IsNullOrEmpty(companyId))
await Groups.AddToGroupAsync(Context.ConnectionId, $"kiosk-{companyId}");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in KioskHub.OnConnectedAsync for connection {ConnectionId}", Context.ConnectionId);
}
await base.OnConnectedAsync();
}
/// <summary>Logs unexpected disconnects (e.g. tablet going to sleep).</summary>
public override async Task OnDisconnectedAsync(Exception? exception)
{
if (exception != null)
_logger.LogWarning(exception, "KioskHub client disconnected with error: {ConnectionId}", Context.ConnectionId);
await base.OnDisconnectedAsync(exception);
}
}
@@ -50,6 +50,12 @@ public class OnlineUserMiddleware
{ {
await _next(context); await _next(context);
// Skip AJAX/JSON responses — they are not page navigations and would
// cause the "current page" to show the polling endpoint (e.g. /InAppNotifications/Recent)
// rather than the actual page the user is on.
if (context.Response.ContentType?.Contains("application/json", StringComparison.OrdinalIgnoreCase) == true)
return;
// Only track authenticated, non-API, non-asset requests // Only track authenticated, non-API, non-asset requests
if (!context.User.Identity?.IsAuthenticated ?? true) return; if (!context.User.Identity?.IsAuthenticated ?? true) return;
var path = context.Request.Path.Value ?? string.Empty; var path = context.Request.Path.Value ?? string.Empty;
@@ -47,6 +47,8 @@ public class SubscriptionMiddleware
"/Billing", "/Billing",
"/api/", "/api/",
"/stripe/", "/stripe/",
"/hubs/",
"/Kiosk/",
"/Profile/Photo", "/Profile/Photo",
"/CompanyLogo", "/CompanyLogo",
"/AccountDataExport" "/AccountDataExport"
+8 -2
View File
@@ -270,8 +270,7 @@ builder.Services.AddSingleton<IMapper>(sp =>
cfg.AddProfile(new InventoryProfile()); cfg.AddProfile(new InventoryProfile());
cfg.AddProfile(new EquipmentProfile()); cfg.AddProfile(new EquipmentProfile());
cfg.AddProfile(new MaintenanceProfile()); cfg.AddProfile(new MaintenanceProfile());
cfg.AddProfile(new ShopWorkerProfile()); cfg.AddProfile(new CatalogProfile());
cfg.AddProfile(new CatalogProfile());
cfg.AddProfile(new VendorProfile()); cfg.AddProfile(new VendorProfile());
cfg.AddProfile(new LookupProfile()); cfg.AddProfile(new LookupProfile());
cfg.AddProfile(new AppointmentProfile()); cfg.AddProfile(new AppointmentProfile());
@@ -727,6 +726,12 @@ app.UseMiddleware<PowderCoating.Web.Middleware.MustChangePasswordMiddleware>();
// Track authenticated user presence (throttled, in-memory) // Track authenticated user presence (throttled, in-memory)
app.UseMiddleware<PowderCoating.Web.Middleware.OnlineUserMiddleware>(); app.UseMiddleware<PowderCoating.Web.Middleware.OnlineUserMiddleware>();
// Kiosk intake steps use /Kiosk/Intake/{token}/{action} so the token is a path segment
app.MapControllerRoute(
name: "kiosk_intake",
pattern: "Kiosk/Intake/{token}/{action}",
defaults: new { controller = "Kiosk" });
app.MapControllerRoute( app.MapControllerRoute(
name: "default", name: "default",
pattern: "{controller=Home}/{action=Index}/{id?}"); pattern: "{controller=Home}/{action=Index}/{id?}");
@@ -736,6 +741,7 @@ app.MapRazorPages();
// Map SignalR hubs // Map SignalR hubs
app.MapHub<PowderCoating.Web.Hubs.NotificationHub>("/hubs/notifications"); app.MapHub<PowderCoating.Web.Hubs.NotificationHub>("/hubs/notifications");
app.MapHub<PowderCoating.Web.Hubs.ShopHub>("/hubs/shop"); app.MapHub<PowderCoating.Web.Hubs.ShopHub>("/hubs/shop");
app.MapHub<PowderCoating.Web.Hubs.KioskHub>("/hubs/kiosk");
app.MapHealthChecks("/health"); app.MapHealthChecks("/health");
@@ -72,6 +72,7 @@ public class InAppNotificationService : IInAppNotificationService
message = notification.Message, message = notification.Message,
link = notification.Link, link = notification.Link,
notificationType = notification.NotificationType, notificationType = notification.NotificationType,
customerId = notification.CustomerId,
createdAt = now.ToString("o") createdAt = now.ToString("o")
}); });
} }
@@ -232,4 +232,3 @@
}); });
</script> </script>
} }
@@ -109,6 +109,69 @@
<span class="fw-semibold">Per-Company Breakdown</span> <span class="fw-semibold">Per-Company Breakdown</span>
<span class="text-muted small">@Model.Rows.Count companies total</span> <span class="text-muted small">@Model.Rows.Count companies total</span>
</div> </div>
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var row in Model.Rows)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%);">
<i class="bi bi-robot"></i>
</div>
<div class="mobile-card-title">
<h6>@row.CompanyName @if (!row.IsActive) { <span class="badge bg-secondary ms-1">Inactive</span> }</h6>
<small><span class="badge bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle">@row.Plan</span></small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Today</span>
<span class="mobile-card-value @(row.Today > 0 ? "fw-semibold" : "text-muted")">
@if (row.Today > 0) { @row.Today.ToString("N0") } else { <span>&mdash;</span> }
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">30 Days</span>
<span class="mobile-card-value @(row.Last30Days > 0 ? "fw-semibold" : "text-muted")">
@if (row.Last30Days > 0) { @row.Last30Days.ToString("N0") } else { <span>&mdash;</span> }
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">All Time</span>
<span class="mobile-card-value @(row.AllTime > 0 ? "" : "text-muted")">
@if (row.AllTime > 0) { @row.AllTime.ToString("N0") } else { <span>&mdash;</span> }
</span>
</div>
@if (row.TopFeature != null)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Top Feature</span>
<span class="mobile-card-value">
<i class="bi @FeatureIcon(row.TopFeature) me-1 text-muted"></i>@row.FeatureDisplayName(row.TopFeature)
</span>
</div>
}
<div class="mobile-card-row">
<span class="mobile-card-label">Tier</span>
<span class="mobile-card-value"><span class="badge @row.TierBadgeClass">@row.UsageTier</span></span>
</div>
</div>
<div class="mobile-card-footer">
<a asp-controller="Companies" asp-action="Details" asp-route-id="@row.CompanyId" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-building me-1"></i>Company
</a>
</div>
</div>
}
@if (!Model.Rows.Any())
{
<div class="text-center text-muted py-5">
<i class="bi bi-robot fs-1 d-block mb-2 opacity-25"></i>
No AI usage logged yet.
</div>
}
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0 align-middle" id="aiUsageTable"> <table class="table table-hover mb-0 align-middle" id="aiUsageTable">
<thead class="table-light"> <thead class="table-light">
@@ -176,6 +176,60 @@
<div class="card-body"> <div class="card-body">
@if (Model.Items.Any()) @if (Model.Items.Any())
{ {
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var appointment in Model.Items)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #0ea5e9 0%, #0284c7 100%);">
<i class="bi bi-calendar-event"></i>
</div>
<div class="mobile-card-title">
<h6>@appointment.Title</h6>
<small>@appointment.ScheduledStartTime.ToString("MMM dd, yyyy")<br />@(!appointment.IsAllDay ? $"{appointment.ScheduledStartTime:h:mm tt} &ndash; {appointment.ScheduledEndTime:h:mm tt}" : "All Day")</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value">
<span class="badge bg-@appointment.StatusColorClass">@appointment.StatusDisplayName</span>
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Type</span>
<span class="mobile-card-value">
<span class="badge bg-@appointment.TypeColorClass">@appointment.TypeDisplayName</span>
</span>
</div>
@if (!string.IsNullOrEmpty(appointment.CustomerName))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Customer</span>
<span class="mobile-card-value">@appointment.CustomerName</span>
</div>
}
@if (!string.IsNullOrEmpty(appointment.AssignedWorkerName))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Worker</span>
<span class="mobile-card-value">@appointment.AssignedWorkerName</span>
</div>
}
</div>
<div class="mobile-card-footer">
<a asp-action="Details" asp-route-id="@appointment.Id" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye me-1"></i>View
</a>
<a asp-action="Edit" asp-route-id="@appointment.Id" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil me-1"></i>Edit
</a>
</div>
</div>
}
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle"> <table class="table table-hover align-middle">
<thead> <thead>
@@ -21,6 +21,64 @@
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body p-0"> <div class="card-body p-0">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var br in Model)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #14b8a6 0%, #0f766e 100%);">
<i class="bi bi-bank"></i>
</div>
<div class="mobile-card-title">
<h6>@br.Account?.Name</h6>
<small>Statement: @br.StatementDate.ToString("MMM d, yyyy")</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value">
@if (br.Status == BankReconciliationStatus.Completed)
{
<span class="badge bg-success">Completed</span>
}
else
{
<span class="badge bg-warning text-dark">In Progress</span>
}
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Ending Balance</span>
<span class="mobile-card-value fw-semibold">@br.EndingBalance.ToString("C")</span>
</div>
@if (br.CompletedAt.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Completed By</span>
<span class="mobile-card-value">@br.CompletedBy</span>
</div>
}
</div>
<div class="mobile-card-footer">
@if (br.Status == BankReconciliationStatus.Completed)
{
<a asp-action="Report" asp-route-id="@br.Id" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-file-earmark-text me-1"></i>Report
</a>
}
else
{
<a asp-action="Reconcile" asp-route-id="@br.Id" class="btn btn-sm btn-outline-primary">
<i class="bi bi-check2-square me-1"></i>Continue
</a>
}
</div>
</div>
}
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead class="table-light"> <thead class="table-light">
@@ -60,6 +60,59 @@
<div class="card-body p-0"> <div class="card-body p-0">
@if (active.Any()) @if (active.Any())
{ {
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var ban in active)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #dc2626 0%, #991b1b 100%);">
<i class="bi bi-slash-circle"></i>
</div>
<div class="mobile-card-title">
<h6 class="font-monospace">@ban.IpAddress</h6>
<small class="text-muted">@(ban.Reason ?? "No reason given")</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Banned</span>
<span class="mobile-card-value">@ban.BannedAt.ToString("MMM d, yyyy HH:mm")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Expires</span>
<span class="mobile-card-value">
@if (ban.ExpiresAt.HasValue)
{
<span class="badge bg-warning text-dark">@ban.ExpiresAt.Value.ToString("MMM d, yyyy")</span>
}
else
{
<span class="badge bg-secondary">Permanent</span>
}
</span>
</div>
</div>
<div class="mobile-card-footer">
<form asp-action="Lift" asp-route-id="@ban.Id" method="post" class="d-inline"
onsubmit="return confirm('Lift the ban on @ban.IpAddress?')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-outline-success">
<i class="bi bi-check-circle me-1"></i>Lift
</button>
</form>
<form asp-action="Delete" asp-route-id="@ban.Id" method="post" class="d-inline"
onsubmit="return confirm('Delete ban record for @ban.IpAddress?')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
</div>
}
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <table class="table table-hover mb-0">
<thead class="table-light"> <thead class="table-light">
@@ -130,6 +183,55 @@
<h6 class="mb-0 text-muted"><i class="bi bi-clock-history"></i> Lifted / Expired Bans</h6> <h6 class="mb-0 text-muted"><i class="bi bi-clock-history"></i> Lifted / Expired Bans</h6>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var ban in inactive)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #6b7280 0%, #4b5563 100%);">
<i class="bi bi-clock-history"></i>
</div>
<div class="mobile-card-title">
<h6 class="font-monospace">@ban.IpAddress</h6>
<small>
@if (!ban.IsActive)
{
<span class="badge bg-success">Lifted</span>
}
else
{
<span class="badge bg-secondary">Expired</span>
}
</small>
</div>
</div>
<div class="mobile-card-body">
@if (!string.IsNullOrEmpty(ban.Reason))
{
<div class="mobile-card-row">
<span class="mobile-card-label">Reason</span>
<span class="mobile-card-value text-muted">@ban.Reason</span>
</div>
}
<div class="mobile-card-row">
<span class="mobile-card-label">Banned</span>
<span class="mobile-card-value text-muted">@ban.BannedAt.ToString("MMM d, yyyy")</span>
</div>
</div>
<div class="mobile-card-footer">
<form asp-action="Delete" asp-route-id="@ban.Id" method="post" class="d-inline"
onsubmit="return confirm('Delete ban record for @ban.IpAddress?')">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-sm btn-outline-danger">
<i class="bi bi-trash me-1"></i>Delete
</button>
</form>
</div>
</div>
}
</div>
</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm table-hover mb-0"> <table class="table table-sm table-hover mb-0">
<thead class="table-light"> <thead class="table-light">
@@ -4,7 +4,7 @@
ViewData["Title"] = "Edit Bill"; ViewData["Title"] = "Edit Bill";
ViewData["PageIcon"] = "bi-pencil-square"; ViewData["PageIcon"] = "bi-pencil-square";
ViewData["PageHelpTitle"] = "Edit Bill"; 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 Void the bill and recreate it if corrections are needed after confirmation.";
} }
<div class="d-flex justify-content-start mb-4"> <div class="d-flex justify-content-start mb-4">
@@ -24,7 +24,7 @@
<a tabindex="0" class="help-icon" role="button" <a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus" data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Bill Details" 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 drives overdue status. Vendor Invoice #: the vendor's own reference number for reconciliation.">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</div> </div>
@@ -34,8 +34,8 @@
<label asp-for="VendorId" class="form-label fw-medium">Vendor <span class="text-danger">*</span></label> <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" <select asp-for="VendorId" asp-items="ViewBag.Vendors" class="form-select"
data-quick-add-url="/Vendors/Create" data-quick-add-title="Add New Vendor"> data-quick-add-url="/Vendors/Create" data-quick-add-title="Add New Vendor">
<option value="">— Select Vendor —</option> <option value=""> Select Vendor </option>
<option value="__new__">+ Add New Vendor…</option> <option value="__new__">+ Add New Vendor</option>
</select> </select>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
@@ -87,7 +87,7 @@
} }
<input type="file" name="receiptFile" id="receiptFile" class="form-control" <input type="file" name="receiptFile" id="receiptFile" class="form-control"
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf" /> 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 up to 10 MB.</div>
</div> </div>
</div> </div>
</div> </div>
@@ -100,7 +100,7 @@
<a tabindex="0" class="help-icon" role="button" <a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus" data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Line Items" data-bs-title="Line Items"
data-bs-content="Each line maps to an expense account (e.g. Supplies, Materials, Subcontractors). Optionally link a line to a Job to track costs against specific work orders. Qty × Unit Price = Amount. Use multiple lines to split one bill across different expense categories."> data-bs-content="Each line maps to an expense account (e.g. Supplies, Materials, Subcontractors). Optionally link a line to a Job to track costs against specific work orders. Qty × Unit Price = Amount. Use multiple lines to split one bill across different expense categories.">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</div> </div>
@@ -134,7 +134,7 @@
<a tabindex="0" class="help-icon" role="button" <a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus" data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
data-bs-title="Bill Summary" 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 each payment recorded reduces the balance due until the bill is fully paid.">
<i class="bi bi-question-circle"></i> <i class="bi bi-question-circle"></i>
</a> </a>
</div> </div>
@@ -171,7 +171,7 @@
<tr class="line-item-row"> <tr class="line-item-row">
<td> <td>
<select class="form-select form-select-sm account-select" name="LineItems[INDEX].AccountId" required> <select class="form-select form-select-sm account-select" name="LineItems[INDEX].AccountId" required>
<option value="">— Account —</option> <option value=""> Account </option>
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.ExpenseAccounts) @foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.ExpenseAccounts)
{ {
<option value="@item.Value">@item.Text</option> <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><input type="text" class="form-control form-control-sm" name="LineItems[INDEX].Description" placeholder="Description" /></td>
<td> <td>
<select class="form-select form-select-sm" name="LineItems[INDEX].JobId"> <select class="form-select form-select-sm" name="LineItems[INDEX].JobId">
<option value="">—</option> <option value=""></option>
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.Jobs) @foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.Jobs)
{ {
<option value="@item.Value">@item.Text</option> <option value="@item.Value">@item.Text</option>

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