Compare commits

..

38 Commits

Author SHA1 Message Date
spouliot 8acbc8605d Harden multi-tenant isolation across all user-facing controllers
Added explicit CompanyId == companyId predicates to every tenant-scoped
query in 22 controllers so cross-tenant data leakage is impossible even
if EF Core global query filters are bypassed or misconfigured.

Also fixed ApplicationDbContext.IsPlatformAdmin to correctly return true
for SuperAdmins with no CompanyId claim (break-glass accounts) and when
no HTTP context is present (background services, unit tests), resolving
225 unit test failures that stemmed from the global filter blocking all
in-memory test data.

New MultiTenantIsolationTests class (8 tests) verifies the explicit
predicate layer independently of the global query filters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 18:04:22 -04:00
spouliot 485f0b69c8 Format Log Material dropdown as 'Manufacturer - Name (UoM)'
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:51:32 -04:00
spouliot f380c152ca Promote job powders to top of Log Material dropdown
Powders already assigned to this job's coats appear under a 'This Job'
section header, then a divider, then 'All Inventory' — so the most
relevant choices are always one click away.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:49:46 -04:00
spouliot 79c8c7e6a4 Add manufacturer to Log Material item combobox
Shows manufacturer name as muted secondary text in each dropdown row
and includes it in the search filter, so users can find a powder by
brand when multiple items share a similar name.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:46:48 -04:00
spouliot 6cf355071b Replace Log Material item dropdown with searchable combobox
Inventory lists grow over time; a plain <select> becomes unusable. The
new combobox filters as you type, supports keyboard navigation
(Arrow/Enter/Escape), and shows current stock on selection — matching
the pattern used by the powder picker in the item wizard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:41:14 -04:00
spouliot ebd474ae81 Fix log material dropdown showing undefined - camelCase JSON serialization
System.Text.Json defaults to PascalCase; JS reads camelCase. Add
JsonNamingPolicy.CamelCase to the InventoryItemsForModal serialization.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 21:15:23 -04:00
spouliot 3c390a2e05 Merge branch 'dev' - invoice fixes, log material modal, complete job UX 2026-05-16 15:38:05 -04:00
spouliot 0df2353d4f Complete Job modal: ask powder usage once per color, not per item/coat
The modal was showing one row per coat per item, so a job with 5 items
each with 2 coats of the same powder produced 10 identical input rows.

Now groups by unique InventoryItemId and shows one row per powder color
for the whole job. The controller distributes the entered total across
coats proportionally by their estimated PowderToOrder so per-coat
reporting data is preserved. A single inventory transaction is created
per powder (net of any pre-logged scan credit).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 12:30:30 -04:00
spouliot be0a5b26e2 Update AI assistant and help docs for invoice and material logging changes
- HelpKnowledgeBase: invoice-from-job now mentions discount carried over,
  Discount Applied display row, and negative line items; new entry for
  PC-based Log Material modal on job details
- Help/Invoices.cshtml: from-job steps updated with discount/terms/due date
  pre-fill detail; sending section corrects due date source (quote/customer)
- Help/Jobs.cshtml: new "Logging Material Usage from a PC" section documenting
  the Log Material modal alongside the existing QR scan instructions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 12:15:20 -04:00
spouliot 36680eced9 Add manual Log Material modal to job details page
PC users were blocked to QR scan only for logging material usage. Now a
"Log Material" button opens an inline modal with:
- Inventory item dropdown (name + unit of measure, current stock shown on select)
- Entry method toggle: "Amount Used" or "Amount Remaining" (computes used = onHand - remaining)
- Reason: Job Usage or Waste/Spillage
- Notes field
Submits via AJAX to Jobs/LogMaterial (new POST action) which mirrors the
InventoryController.LogUsage flow — updates QuantityOnHand, creates InventoryTransaction,
posts GL entries (DR COGS / CR Inventory). QR scan button retained as icon.

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 11:41:47 -04:00
spouliot b2d6fae400 Fix failing test: revert quote-based discount to use sourceQuote.DiscountAmount
The quote discount must come from the agreed quote price, not the job's pricing
snapshot (which may have DiscountAmount=0 for legacy or unset reasons). The job
snapshot fix only applies to direct jobs where no source quote exists.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 11:29:12 -04:00
spouliot 3a1928f9bf Fix invoice creation from job: discount ignored, wrong due date, wrong terms
- DueDate was computed from DefaultTurnaroundDays (a shop ops setting) instead
  of from the payment terms string; now uses PaymentTermsParser throughout
- Discount was never applied for direct jobs (PricingBreakdownJson was read for
  fees but DiscountAmount was silently skipped)
- Quote-based jobs used sourceQuote.DiscountAmount, ignoring any discount edits
  made to the job after quote conversion; now prefers the job's pricing snapshot
- Payment terms and due date now inherit from sourceQuote.Terms → customer.PaymentTerms
  → company default, so the invoice reflects the agreed or customer-specific terms
- EarlyPaymentDiscount fields now populated from inherited terms

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 10:45:40 -04:00
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
139 changed files with 70332 additions and 3138 deletions
+4
View File
@@ -129,3 +129,7 @@ DataProtection-Keys/
# Secrets
appsettings.secrets.json
*.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)
- Tax exempt customers (`Customer.IsTaxExempt`): `TaxPercent` defaults to 0 on quote and invoice create; customer dropdown marks exempt customers with ★
### Pricing Routing Flags — Must Stay In Sync Across All Three Layers
`PricingCalculationService.CalculateQuoteItemPriceAsync` routes each item to the correct pricing path using boolean flags. **These flags MUST exist identically on `QuoteItem`, `JobItem`, and `CreateQuoteItemDto`, AND be mapped in all three `JobItemAssemblyService.CreateJobItem` overloads.**
| Flag | Effect if missing on JobItem |
|------|------------------------------|
| `IsAiItem` | Job repriced as calculated item; oven cost double-charged on every save |
| `IsGenericItem` | ManualUnitPrice ignored; price recalculated from surface area |
| `IsLaborItem` | Item repriced at surface-area rate instead of hours × labor rate |
| `IsSalesItem` | ManualUnitPrice ignored; item repriced using coat/surface math |
**Checklist when adding a new pricing routing flag:**
1. Add the property to `QuoteItem` (Core/Entities)
2. Add the property to `JobItem` (Core/Entities)
3. Add it to `CreateQuoteItemDto` (Application/DTOs)
4. Add it to `JobItemSeed` (private class in JobItemAssemblyService)
5. Map it in all three `JobItemAssemblyService.CreateJobItem` overloads
6. Include it in every `existingItemsData` JSON block in job views (`Edit.cshtml`, `EditItems.cshtml`) and in all job controller actions that build `CreateQuoteItemDto` from a `JobItem`
7. Add a migration if the field is new on a persisted entity
8. The structural test `PricingRoutingFlags_ExistOnBothQuoteItemAndJobItem` in `JobItemAssemblyServiceTests` will fail until steps 13 are done — this is intentional
### Branding
- Application name: **Powder Coating Logix**
- PCL logo: `wwwroot/images/pcl-logo.png` — used in sidebar header (when no tenant logo), login/register pages, sidebar footer
-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!
@@ -112,6 +112,7 @@ namespace PowderCoating.Application.DTOs.Company
// Labor Rates
public decimal StandardLaborRate { get; set; }
public decimal? LaborCostPerHour { get; set; }
public decimal AdditionalCoatLaborPercent { get; set; }
// Equipment Operating Costs
@@ -185,6 +186,10 @@ namespace PowderCoating.Application.DTOs.Company
[Display(Name = "Standard Labor Rate ($/hr)")]
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")]
[Display(Name = "Additional Coat Labor (%)")]
public decimal AdditionalCoatLaborPercent { get; set; } = 30m;
@@ -16,6 +16,7 @@ public class GiftCertificateListDto
public GiftCertificateStatus Status { get; set; }
public DateTime IssueDate { get; set; }
public DateTime? ExpiryDate { get; set; }
public Guid? BatchId { get; set; }
}
public class GiftCertificateDto : GiftCertificateListDto
@@ -87,3 +88,27 @@ public class RedeemGiftCertificateDto
[Range(0.01, 9999.99)]
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; }
}
@@ -389,7 +389,7 @@ public class CompleteJobDto
{
public int JobId { get; set; }
public decimal? ActualTimeSpentHours { get; set; }
public List<JobItemCoatUsageDto> CoatUsages { get; set; } = new();
public List<JobPowderUsageDto> PowderUsages { get; set; } = new();
public bool SendEmailToCustomer { get; set; } = false;
}
@@ -400,10 +400,10 @@ public class SendJobSmsRequest
public string Message { get; set; } = string.Empty;
}
// DTO for tracking actual powder usage per coat
public class JobItemCoatUsageDto
// DTO for tracking actual powder usage per inventory item (color) for the whole job
public class JobPowderUsageDto
{
public int JobItemCoatId { get; set; }
public int InventoryItemId { get; set; }
public decimal? ActualPowderUsedLbs { get; set; }
}
@@ -515,6 +515,9 @@ public class JobEditItemsViewModel
public string JobNumber { get; set; } = string.Empty;
public int? CustomerId { 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();
}
@@ -604,6 +604,11 @@ public class QuotePricingBreakdownDto
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 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")]
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")]
[Display(Name = "Hire Date")]
public DateTime HireDate { get; set; }
@@ -136,18 +136,7 @@ public interface ICsvImportService
/// </summary>
Task<CsvImportResultDto> ImportVendorsAsync(Stream csvStream, int companyId);
/// <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>
/// <summary>
/// Generate a CSV template file for prep service imports.
/// </summary>
byte[] GeneratePrepServiceTemplate();
@@ -51,4 +51,10 @@ public interface IPdfService
byte[]? companyLogo,
string? companyLogoContentType,
CompanyInfoDto companyInfo);
Task<byte[]> GenerateBulkGiftCertificatePdfAsync(
IList<GiftCertificateDto> certs,
byte[]? companyLogo,
string? companyLogoContentType,
CompanyInfoDto companyInfo);
}
@@ -73,7 +73,7 @@ public class JobProfile : Profile
// JobTimeEntry → JobTimeEntryDto
CreateMap<JobTimeEntry, JobTimeEntryDto>()
.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
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,
IsLaborItem = source.IsLaborItem,
IsSalesItem = source.IsSalesItem,
IsAiItem = source.IsAiItem,
Sku = source.Sku,
ManualUnitPrice = source.ManualUnitPrice,
PowderCostOverride = source.PowderCostOverride,
UnitPrice = pricing.UnitPrice,
TotalPrice = pricing.TotalPrice,
LaborCost = pricing.TotalPrice * 0.4m,
LaborCost = pricing.LaborCost,
RequiresSandblasting = source.RequiresSandblasting,
RequiresMasking = source.RequiresMasking,
EstimatedMinutes = source.EstimatedMinutes,
@@ -106,12 +107,13 @@ public class JobItemAssemblyService : IJobItemAssemblyService
IsGenericItem = source.IsGenericItem,
IsLaborItem = source.IsLaborItem,
IsSalesItem = source.IsSalesItem,
IsAiItem = source.IsAiItem,
Sku = source.Sku,
ManualUnitPrice = source.ManualUnitPrice,
PowderCostOverride = source.PowderCostOverride,
UnitPrice = source.UnitPrice,
TotalPrice = source.TotalPrice,
LaborCost = source.TotalPrice * 0.4m,
LaborCost = source.ItemLaborCost,
RequiresSandblasting = source.RequiresSandblasting,
RequiresMasking = source.RequiresMasking,
EstimatedMinutes = source.EstimatedMinutes,
@@ -191,6 +193,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
IsGenericItem = source.IsGenericItem,
IsLaborItem = source.IsLaborItem,
IsSalesItem = source.IsSalesItem,
IsAiItem = source.IsAiItem,
Sku = source.Sku,
ManualUnitPrice = source.ManualUnitPrice,
PowderCostOverride = source.PowderCostOverride,
@@ -270,6 +273,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
IsGenericItem = seed.IsGenericItem,
IsLaborItem = seed.IsLaborItem,
IsSalesItem = seed.IsSalesItem,
IsAiItem = seed.IsAiItem,
Sku = seed.Sku,
ManualUnitPrice = seed.ManualUnitPrice,
PowderCostOverride = seed.PowderCostOverride,
@@ -364,6 +368,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
public bool IsGenericItem { get; init; }
public bool IsLaborItem { get; init; }
public bool IsSalesItem { get; init; }
public bool IsAiItem { get; init; }
public string? Sku { get; init; }
public decimal? ManualUnitPrice { 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>
/// 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
@@ -30,23 +30,30 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
ArgumentNullException.ThrowIfNull(quote);
ArgumentNullException.ThrowIfNull(pricingResult);
quote.MaterialCosts = pricingResult.MaterialCosts;
quote.LaborCosts = pricingResult.LaborCosts;
quote.EquipmentCosts = pricingResult.EquipmentCosts;
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
quote.OvenBatchCost = pricingResult.OvenBatchCost;
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
quote.OverheadAmount = pricingResult.OverheadCosts;
quote.OverheadPercent = pricingResult.OverheadPercent;
quote.ProfitMargin = pricingResult.ProfitMargin;
quote.ProfitPercent = pricingResult.ProfitPercent;
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
quote.DiscountPercent = pricingResult.DiscountPercent;
quote.DiscountAmount = pricingResult.DiscountAmount;
quote.RushFee = pricingResult.RushFee;
quote.TaxAmount = pricingResult.TaxAmount;
quote.Total = pricingResult.Total;
quote.MaterialCosts = pricingResult.MaterialCosts;
quote.LaborCosts = pricingResult.LaborCosts;
quote.EquipmentCosts = pricingResult.EquipmentCosts;
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
quote.OvenBatchCost = pricingResult.OvenBatchCost;
quote.FacilityOverheadCost = pricingResult.FacilityOverheadCost;
quote.FacilityOverheadRatePerHour = pricingResult.FacilityOverheadRatePerHour;
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
quote.OverheadAmount = pricingResult.OverheadCosts;
quote.OverheadPercent = pricingResult.OverheadPercent;
quote.ProfitMargin = pricingResult.ProfitMargin;
quote.ProfitPercent = pricingResult.ProfitPercent;
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.DiscountAmount = pricingResult.DiscountAmount;
quote.SubtotalAfterDiscount = pricingResult.SubtotalAfterDiscount;
quote.RushFee = pricingResult.RushFee;
quote.TaxAmount = pricingResult.TaxAmount;
quote.Total = pricingResult.Total;
}
public async Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
@@ -58,7 +58,14 @@ public class ApplicationUser : IdentityUser
public string? SidebarColor { get; set; } = "ocean";
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? UpdatedAt { get; set; }
public DateTime? LastLoginDate { get; set; }
+1 -2
View File
@@ -141,8 +141,7 @@ public class Company : BaseEntity
public virtual ICollection<Quote> Quotes { get; set; } = new List<Quote>();
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
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 CompanyPreferences? Preferences { get; set; }
}
@@ -13,6 +13,14 @@ namespace PowderCoating.Core.Entities
[Range(0, 10000)]
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)
[Range(0, 100)]
public decimal AdditionalCoatLaborPercent { get; set; } = 30m;
@@ -32,6 +32,9 @@ public class GiftCertificate : BaseEntity
/// <summary>Set when this GC was sold via an invoice line item.</summary>
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
public virtual Customer? RecipientCustomer { get; set; }
public virtual Customer? PurchasingCustomer { get; set; }
+8
View File
@@ -25,6 +25,10 @@ public class Job : BaseEntity
// Selected oven (carried over from quote; null = company default rate)
public int? OvenCostId { get; set; }
// Oven scheduling (carried over from quote)
public int OvenBatches { get; set; } = 1;
public int? OvenCycleMinutes { get; set; }
// Pricing
public decimal QuotedPrice { 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.
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
public bool IsReworkJob { get; set; }
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"
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")
public string? AiTags { get; set; }
@@ -3,7 +3,6 @@ namespace PowderCoating.Core.Entities;
public class JobTimeEntry : BaseEntity
{
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? UserDisplayName { get; set; } // snapshot of worker name at entry creation time
public DateTime WorkDate { get; set; }
@@ -13,5 +12,4 @@ public class JobTimeEntry : BaseEntity
// Navigation
public virtual Job Job { get; set; } = null!;
public virtual ShopWorker? Worker { get; set; } // nullable — only populated for legacy entries
}
+23 -16
View File
@@ -40,26 +40,33 @@ public class Quote : BaseEntity
public DateTime? ApprovedDate { get; set; }
// Pricing — all values are snapshots captured at save time and must not be recalculated on load
public decimal MaterialCosts { get; set; } // Sum of powder/material costs across all items
public decimal LaborCosts { get; set; } // Sum of labor 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 OvenBatchCost { get; set; } // Oven batch charge applied at quote level
public decimal ShopSuppliesAmount { get; set; } // Shop supplies dollar amount
public decimal ShopSuppliesPercent { get; set; } // Shop supplies percentage used
public decimal OverheadAmount { get; set; } // Overhead dollar amount
public decimal OverheadPercent { get; set; } // Overhead percentage used
public decimal ProfitMargin { get; set; } // Profit margin dollar amount
public decimal ProfitPercent { get; set; } // Profit margin percentage used
public decimal SubTotal { get; set; } // SubtotalBeforeDiscount (items + oven + overhead + profit + shop supplies)
public decimal MaterialCosts { get; set; } // Sum of powder/material costs across all items
public decimal LaborCosts { get; set; } // Sum of labor 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 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 ShopSuppliesPercent { get; set; } // Shop supplies percentage used
public decimal OverheadAmount { get; set; } // Legacy overhead (now always 0; kept for migration safety)
public decimal OverheadPercent { get; set; } // Legacy overhead percent
public decimal ProfitMargin { get; set; } // Profit margin dollar amount (0 — baked into item prices)
public decimal ProfitPercent { get; set; } // Markup % used (for display reference)
public decimal SubTotal { get; set; } // SubtotalBeforeDiscount (items + oven + facility overhead + shop supplies)
// Discount Information
public DiscountType DiscountType { get; set; } = DiscountType.None;
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 DiscountAmount { get; set; } // Calculated: actual dollar amount deducted
public string? DiscountReason { get; set; } // Why discount was applied
public decimal DiscountValue { get; set; } = 0; // Value entered by user (percentage or fixed amount)
public decimal PricingTierDiscountAmount { get; set; } // Discount from customer's pricing tier
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 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 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
}
public enum ShopWorkerRole
{
GeneralLabor = 0,
Sandblaster = 1,
Coater = 2,
Masker = 3,
QualityControl = 4,
OvenOperator = 5,
Supervisor = 6,
Maintenance = 7
}
public enum JobPhotoType
{
@@ -54,9 +54,7 @@ public interface IUnitOfWork : IDisposable
IRepository<AppointmentStatusLookup> AppointmentStatusLookups { get; }
IRepository<AppointmentTypeLookup> AppointmentTypeLookups { get; }
IRepository<PrepService> PrepServices { get; }
IRepository<ShopWorker> ShopWorkers { get; }
IRepository<ShopWorkerRoleCost> ShopWorkerRoleCosts { get; }
IRepository<ReworkRecord> ReworkRecords { get; }
IRepository<ReworkRecord> ReworkRecords { get; }
IRepository<Refund> Refunds { get; }
IRepository<CreditMemo> CreditMemos { get; }
IRepository<CreditMemoApplication> CreditMemoApplications { get; }
@@ -92,7 +92,11 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
if (companyIdClaim != null && int.TryParse(companyIdClaim, out int companyId))
return companyId;
return null;
// Authenticated but CompanyId claim is missing or invalid.
// Return 0 (never a real company ID) so the global filter generates
// "CompanyId = 0" which matches nothing — prevents null-comparison
// ambiguity from leaking cross-tenant rows.
return 0;
}
}
@@ -129,8 +133,11 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
{
get
{
// No HTTP context means background service, hosted service, or unit test — bypass tenant filter
if (_httpContextAccessor?.HttpContext == null) return true;
if (!IsSuperAdmin) return false;
return CurrentCompanyId == null || CurrentCompanyId == 1;
// CompanyId == 0 means no claim was present (break-glass / test SuperAdmins) — treat as platform admin
return CurrentCompanyId == null || CurrentCompanyId == 0 || CurrentCompanyId == 1;
}
}
@@ -205,11 +212,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
public DbSet<MaintenanceRecord> MaintenanceRecords { get; set; }
/// <summary>Supplier/vendor records used by Purchasing and Accounts Payable; tenant-filtered with soft delete.</summary>
public DbSet<Vendor> Vendors { get; set; }
/// <summary>Shop worker profiles with role assignments; 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>
/// <summary>Rework records tracking quality failures and remediation work against a job; tenant-filtered with soft delete.</summary>
public DbSet<ReworkRecord> ReworkRecords { get; set; }
/// <summary>Customer refund records; tenant-filtered with soft delete.</summary>
public DbSet<Refund> Refunds { get; set; }
@@ -530,11 +533,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<ShopWorker>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<ShopWorkerRoleCost>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<Refund>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
@@ -1314,12 +1313,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
.HasForeignKey(m => m.PerformedById)
.OnDelete(DeleteBehavior.SetNull);
// ShopWorker relationships
modelBuilder.Entity<ShopWorker>()
.HasOne<Company>()
.WithMany(c => c.ShopWorkers)
.HasForeignKey(e => e.CompanyId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<Job>()
.HasOne(j => j.AssignedUser)
@@ -1393,10 +1387,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
modelBuilder.Entity<PricingTier>()
.HasIndex(p => p.CompanyId);
modelBuilder.Entity<ShopWorker>()
.HasIndex(w => w.CompanyId);
modelBuilder.Entity<CatalogCategory>()
modelBuilder.Entity<CatalogCategory>()
.HasIndex(c => c.CompanyId);
modelBuilder.Entity<CatalogCategory>()
@@ -1431,12 +1422,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
.IsUnique()
.HasDatabaseName("IX_Jobs_CompanyId_JobNumber");
modelBuilder.Entity<ShopWorkerRoleCost>()
.HasIndex(r => new { r.CompanyId, r.Role })
.IsUnique()
.HasDatabaseName("IX_ShopWorkerRoleCosts_CompanyId_Role");
modelBuilder.Entity<Job>()
modelBuilder.Entity<Job>()
.Property(j => j.ShopAccessCode)
.HasDefaultValueSql("NEWID()");
@@ -21,7 +21,7 @@ public class AuditInterceptor : SaveChangesInterceptor
private static readonly HashSet<string> AuditedTypes = new(StringComparer.Ordinal)
{
nameof(Customer), nameof(Job), nameof(Quote), nameof(Equipment),
nameof(MaintenanceRecord), nameof(Vendor), nameof(ShopWorker),
nameof(MaintenanceRecord), nameof(Vendor),
nameof(InventoryItem), nameof(Company),
// Financial entities
nameof(Invoice), nameof(Payment), nameof(Bill), nameof(BillPayment),
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")
.HasColumnType("bit");
b.Property<decimal?>("LaborCostPerHour")
.HasColumnType("decimal(18,2)");
b.Property<DateTime?>("LastLoginDate")
.HasColumnType("datetime2");
@@ -2075,6 +2078,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<decimal?>("LaborCostPerHour")
.HasColumnType("decimal(18,2)");
b.Property<int>("MonthlyBillableHours")
.HasColumnType("int");
@@ -3290,6 +3296,9 @@ namespace PowderCoating.Infrastructure.Migrations
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<Guid?>("BatchId")
.HasColumnType("uniqueidentifier");
b.Property<string>("CertificateCode")
.IsRequired()
.HasColumnType("nvarchar(450)");
@@ -4205,9 +4214,18 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<decimal>("OvenBatchCost")
.HasColumnType("decimal(18,2)");
b.Property<int>("OvenBatches")
.HasColumnType("int");
b.Property<int?>("OvenCostId")
.HasColumnType("int");
b.Property<int?>("OvenCycleMinutes")
.HasColumnType("int");
b.Property<string>("PricingBreakdownJson")
.HasColumnType("nvarchar(max)");
b.Property<int?>("QuoteId")
.HasColumnType("int");
@@ -4476,6 +4494,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<bool>("IncludePrepCost")
.HasColumnType("bit");
b.Property<bool>("IsAiItem")
.HasColumnType("bit");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
@@ -6699,7 +6720,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 1,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2349),
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3131),
Description = "Standard pricing for regular customers",
DiscountPercent = 0m,
IsActive = true,
@@ -6710,7 +6731,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 2,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2366),
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3137),
Description = "5% discount for preferred customers",
DiscountPercent = 5m,
IsActive = true,
@@ -6721,7 +6742,7 @@ namespace PowderCoating.Infrastructure.Migrations
{
Id = 3,
CompanyId = 0,
CreatedAt = new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2367),
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3138),
Description = "10% discount for premium customers",
DiscountPercent = 10m,
IsActive = true,
@@ -6968,6 +6989,12 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<DateTime?>("ExpirationDate")
.HasColumnType("datetime2");
b.Property<decimal>("FacilityOverheadCost")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("FacilityOverheadRatePerHour")
.HasColumnType("decimal(18,2)");
b.Property<bool>("HideDiscountFromCustomer")
.HasColumnType("bit");
@@ -7013,6 +7040,12 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("PreparedById")
.HasColumnType("nvarchar(450)");
b.Property<decimal>("PricingTierDiscountAmount")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("PricingTierDiscountPercent")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("ProfitMargin")
.HasColumnType("decimal(18,2)");
@@ -7052,6 +7085,12 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<DateTime>("QuoteDate")
.HasColumnType("datetime2");
b.Property<decimal>("QuoteDiscountAmount")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("QuoteDiscountPercent")
.HasColumnType("decimal(18,2)");
b.Property<string>("QuoteNumber")
.IsRequired()
.HasColumnType("nvarchar(450)");
@@ -7077,6 +7116,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<decimal>("SubTotal")
.HasColumnType("decimal(18,2)");
b.Property<decimal>("SubtotalAfterDiscount")
.HasColumnType("decimal(18,2)");
b.Property<string>("Tags")
.HasColumnType("nvarchar(max)");
@@ -171,7 +171,6 @@ public class JobRepository : Repository<Job>, IJobRepository
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
.Include(j => j.TimeEntries.Where(t => !t.IsDeleted))
.ThenInclude(t => t.Worker)
.AsNoTracking()
.FirstOrDefaultAsync();
}
@@ -81,7 +81,6 @@ public class UnitOfWork : IUnitOfWork
private IRepository<AppointmentStatusLookup>? _appointmentStatusLookups;
private IRepository<AppointmentTypeLookup>? _appointmentTypeLookups;
private IRepository<PrepService>? _prepServices;
private IRepository<ShopWorker>? _shopWorkers;
// Appointments
private IRepository<Appointment>? _appointments;
@@ -350,16 +349,7 @@ public class UnitOfWork : IUnitOfWork
public IRepository<PrepService> PrepServices =>
_prepServices ??= new Repository<PrepService>(_context);
/// <summary>Repository for <see cref="ShopWorker"/> profiles with role assignments; 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>
/// <summary>Repository for <see cref="ReworkRecord"/> quality-failure and remediation records; tenant-filtered with soft delete.</summary>
private IRepository<ReworkRecord>? _reworkRecords;
public IRepository<ReworkRecord> ReworkRecords =>
_reworkRecords ??= new Repository<ReworkRecord>(_context);
@@ -73,7 +73,6 @@ public class CompanyDataPurgeService : ICompanyDataPurgeService
await _context.NotificationTemplates.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.Announcements.Where(x => x.TargetCompanyId == 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();
// ── 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.AiItemPredictions.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.Refunds.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.Accounts.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.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
@@ -1,4 +1,4 @@
using System.Globalization;
using System.Globalization;
using System.Text;
using CsvHelper;
using CsvHelper.Configuration;
@@ -2164,168 +2164,6 @@ public class CsvImportService : ICsvImportService
}
#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
/// <summary>
@@ -133,7 +133,6 @@ public class AccountDataExportController : Controller
case "Inventory": await AddInventorySheet(package, companyId, headerColor); break;
case "Equipment": await AddEquipmentSheet(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;
}
}
@@ -182,7 +181,6 @@ public class AccountDataExportController : Controller
case "Inventory": WriteCsvEntry(zip, "Inventory.csv", await BuildInventoryCsv(companyId)); break;
case "Equipment": WriteCsvEntry(zip, "Equipment.csv", await BuildEquipmentCsv(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;
}
}
@@ -268,12 +266,6 @@ public class AccountDataExportController : Controller
.Where(s => s.CompanyId == companyId && !s.IsDeleted)
.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>
/// 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.
@@ -462,23 +454,6 @@ public class AccountDataExportController : Controller
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>
/// 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.
@@ -611,17 +586,6 @@ public class AccountDataExportController : Controller
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>
/// All users (active and inactive) are exported for completeness and compliance — mirrors
/// the reasoning in <see cref="AddUsersSheet"/> and <see cref="FetchUsersAsync"/>.
@@ -675,13 +639,13 @@ public class AccountDataExportController : Controller
/// <summary>
/// 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.
/// Sheet names not in the canonical list are silently dropped.
/// </summary>
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();
}
@@ -60,10 +60,11 @@ public class AccountingExportController : Controller
{
var start = startDate.Date;
var end = endDate.Date.AddDays(1).AddTicks(-1);
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// ── Load data ─────────────────────────────────────────────────────────
var invoices = (await _unitOfWork.Invoices.FindAsync(
i => i.InvoiceDate >= start && i.InvoiceDate <= end,
i => i.CompanyId == companyId && i.InvoiceDate >= start && i.InvoiceDate <= end,
false,
i => i.InvoiceItems,
i => i.Payments,
@@ -72,7 +73,7 @@ public class AccountingExportController : Controller
.ToList();
var expenses = (await _unitOfWork.Expenses.FindAsync(
e => e.Date >= start && e.Date <= end,
e => e.CompanyId == companyId && e.Date >= start && e.Date <= end,
false,
e => e.Vendor,
e => e.ExpenseAccount,
@@ -82,7 +83,7 @@ public class AccountingExportController : Controller
var bills = await _unitOfWork.Bills.GetForDateRangeAsync(start, end);
var customers = (await _unitOfWork.Customers.GetAllAsync())
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId))
.OrderBy(c => c.CompanyName ?? c.ContactFirstName)
.ToList();
@@ -486,9 +486,12 @@ public class AppointmentsController : Controller
try
{
var events = new List<CalendarEventDto>();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// 1. Fetch appointments in date range
var allAppointments = await _unitOfWork.Appointments.GetAllAsync(false,
var allAppointments = await _unitOfWork.Appointments.FindAsync(
a => a.CompanyId == companyId,
false,
a => a.Customer,
a => a.AppointmentType,
a => a.AppointmentStatus);
@@ -501,7 +504,9 @@ public class AppointmentsController : Controller
events.AddRange(appointmentEvents);
// 2. Fetch maintenance records in date range
var allMaintenanceRecords = await _unitOfWork.MaintenanceRecords.GetAllAsync(false,
var allMaintenanceRecords = await _unitOfWork.MaintenanceRecords.FindAsync(
m => m.CompanyId == companyId,
false,
m => m.Equipment);
var maintenanceRecords = allMaintenanceRecords
@@ -539,7 +544,9 @@ public class AppointmentsController : Controller
}
// 3. Fetch jobs and add as all-day events
var allJobs = await _unitOfWork.Jobs.GetAllAsync(false,
var allJobs = await _unitOfWork.Jobs.FindAsync(
j => j.CompanyId == companyId,
false,
j => j.Customer,
j => j.JobStatus);
@@ -746,13 +753,16 @@ public class AppointmentsController : Controller
try
{
var terminalCodes = new[] { AppConstants.StatusCodes.Job.Completed, AppConstants.StatusCodes.Job.Delivered, AppConstants.StatusCodes.Job.Cancelled };
var allJobs = await _unitOfWork.Jobs.GetAllAsync(false,
var calCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allJobs = await _unitOfWork.Jobs.FindAsync(
j => j.CompanyId == calCompanyId,
false,
j => j.Customer, j => j.JobStatus, j => j.JobItems);
// Load coats separately — filter by JobItemId using already-loaded item IDs
var jobItemIds = allJobs.SelectMany(j => j.JobItems.Select(i => i.Id)).ToList();
var allCoats = await _unitOfWork.JobItemCoats.FindAsync(
c => jobItemIds.Contains(c.JobItemId));
c => jobItemIds.Contains(c.JobItemId) && c.CompanyId == calCompanyId);
var coatsByItemId = allCoats
.Where(c => !c.IsDeleted)
@@ -891,7 +901,9 @@ public class AppointmentsController : Controller
/// </summary>
private async Task PopulateCreateDropdowns()
{
var customers = await _unitOfWork.Customers.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
var customerList = customers.Select(c => new
{
c.Id,
@@ -903,19 +915,16 @@ public class AppointmentsController : Controller
.ToList();
ViewBag.Customers = new SelectList(customerList, "Id", "DisplayName");
// Use cached appointment types
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var types = await _lookupCache.GetAppointmentTypeLookupsAsync(companyId);
ViewBag.AppointmentTypes = new SelectList(types.Where(t => t.IsActive).OrderBy(t => t.DisplayOrder), "Id", "DisplayName");
var companyIdForWorkers = _tenantContext.GetCurrentCompanyId() ?? 0;
var workers = await _userManager.Users
.Where(u => u.CompanyId == companyIdForWorkers && u.IsActive && u.CompanyRole != null)
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
.ToListAsync();
ViewBag.Workers = new SelectList(workers.Select(u => new { u.Id, FullName = u.FullName }), "Id", "FullName");
var jobs = await _unitOfWork.Jobs.GetAllAsync();
var jobs = await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId);
ViewBag.Jobs = new SelectList(jobs.OrderBy(j => j.JobNumber), "Id", "JobNumber");
}
@@ -27,15 +27,18 @@ namespace PowderCoating.Web.Controllers
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly ITenantContext _tenantContext;
private readonly ILogger<CatalogCategoriesController> _logger;
public CatalogCategoriesController(
IUnitOfWork unitOfWork,
IMapper mapper,
ITenantContext tenantContext,
ILogger<CatalogCategoriesController> logger)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_tenantContext = tenantContext;
_logger = logger;
}
@@ -52,8 +55,9 @@ namespace PowderCoating.Web.Controllers
{
try
{
var indexCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var categories = await _unitOfWork.CatalogCategories
.GetAllAsync(false,
.FindAsync(c => c.CompanyId == indexCompanyId, false,
c => c.ParentCategory,
c => c.SubCategories,
c => c.Items);
@@ -164,7 +168,8 @@ namespace PowderCoating.Web.Controllers
if (ModelState.IsValid)
{
// Check for duplicate category name under the same parent (case-insensitive)
var allCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allCategories = await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == companyId);
var existingCategory = allCategories.FirstOrDefault(c =>
c.Name.Equals(dto.Name.Trim(), StringComparison.OrdinalIgnoreCase) &&
c.ParentCategoryId == dto.ParentCategoryId);
@@ -272,7 +277,8 @@ namespace PowderCoating.Web.Controllers
if (nameChanged || parentChanged)
{
var allCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
var editCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allCategories = await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == editCompanyId);
var existingCategory = allCategories.FirstOrDefault(c =>
c.Id != id &&
c.Name.Equals(dto.Name.Trim(), StringComparison.OrdinalIgnoreCase) &&
@@ -444,7 +450,8 @@ namespace PowderCoating.Web.Controllers
var trimmedName = request.Name.Trim();
// Check for duplicate category name under the same parent (case-insensitive)
var allCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
var quickCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allCategories = await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == quickCompanyId);
var existingCategory = allCategories.FirstOrDefault(c =>
c.Name.Equals(trimmedName, StringComparison.OrdinalIgnoreCase) &&
c.ParentCategoryId == request.ParentCategoryId);
@@ -500,8 +507,9 @@ namespace PowderCoating.Web.Controllers
{
try
{
var treeCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var categories = await _unitOfWork.CatalogCategories
.GetAllAsync(false, c => c.SubCategories, c => c.Items);
.FindAsync(c => c.CompanyId == treeCompanyId, false, c => c.SubCategories, c => c.Items);
// Build tree from root categories
var rootCategories = categories
@@ -535,7 +543,8 @@ namespace PowderCoating.Web.Controllers
{
try
{
var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList();
var dropdownCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var categories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == dropdownCompanyId)).ToList();
// Build hierarchical list (parents before children)
var hierarchicalList = new List<CatalogCategory>();
@@ -573,7 +582,8 @@ namespace PowderCoating.Web.Controllers
/// </param>
private async Task PopulateParentCategoryDropdown(int? excludeCategoryId = null)
{
var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList();
var parentDropCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var categories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == parentDropCompanyId)).ToList();
// Exclude the current category and its descendants to prevent circular references
var excludedIds = new HashSet<int>();
@@ -700,7 +710,8 @@ namespace PowderCoating.Web.Controllers
if (categoryId == newParentId)
return true;
var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList();
var circleCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var categories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == circleCompanyId)).ToList();
var current = categories.FirstOrDefault(c => c.Id == newParentId);
while (current != null)
@@ -83,7 +83,8 @@ namespace PowderCoating.Web.Controllers
try
{
// Get all categories with their items
var allCategories = (await _unitOfWork.CatalogCategories.GetAllAsync(false, c => c.Items)).ToList();
var itemsCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allCategories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == itemsCompanyId, false, c => c.Items)).ToList();
var allItems = allCategories.SelectMany(c => c.Items).ToList();
// Apply search filter
@@ -578,7 +579,8 @@ namespace PowderCoating.Web.Controllers
return Json(new List<object>());
}
var allItems = await _unitOfWork.CatalogItems.GetAllAsync(false, i => i.Category);
var searchCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allItems = await _unitOfWork.CatalogItems.FindAsync(i => i.CompanyId == searchCompanyId, false, i => i.Category);
var search = searchTerm.ToLower();
var items = allItems
@@ -694,7 +696,8 @@ namespace PowderCoating.Web.Controllers
/// </summary>
private async Task PopulateCategoryDropdown()
{
var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var categories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == companyId)).ToList();
// Build hierarchical list (parents before children)
var hierarchicalList = new List<CatalogCategory>();
@@ -1045,7 +1048,7 @@ namespace PowderCoating.Web.Controllers
// Load all categories so we can build full paths (e.g. "Cerakote > Firearms").
// The full path gives Claude the coating-type context it needs — an item in
// "Firearms" under "Cerakote" costs very differently than one under "Powder Coat".
var allCategories = (await _unitOfWork.CatalogCategories.GetAllAsync())
var allCategories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == currentUser.CompanyId))
.ToDictionary(c => c.Id);
// Load company operating costs
@@ -142,10 +142,10 @@ public class CompanySettingsController : Controller
&& !connectClientId.Contains("your_connect_client_id_here", StringComparison.OrdinalIgnoreCase);
// Load notification templates for inline tab
var existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
var existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
if (seeded > 0)
existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
dto.NotificationTemplates = existing
.OrderBy(t => (int)t.NotificationType).ThenBy(t => (int)t.Channel)
@@ -755,9 +755,8 @@ public class CompanySettingsController : Controller
var costs = company.OperatingCosts;
var ovens = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive)).OrderBy(o => o.DisplayOrder).ToList();
var workers = (await _unitOfWork.ShopWorkers.FindAsync(w => w.IsActive)).ToList();
var coatingCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsCoating)).ToList();
var ovens = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive && o.CompanyId == companyId.Value)).OrderBy(o => o.DisplayOrder).ToList();
var coatingCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsCoating && c.CompanyId == companyId.Value)).ToList();
var sb = new System.Text.StringBuilder();
@@ -783,8 +782,7 @@ public class CompanySettingsController : Controller
ShopCapabilityTier.Large => "high-volume",
_ => "small"
};
sb.AppendLine($"We are a {tierLabel} operation" +
(workers.Count > 0 ? $" with {workers.Count} active shop worker{(workers.Count == 1 ? "" : "s")}." : "."));
sb.AppendLine($"We are a {tierLabel} operation.");
}
// Ovens
@@ -827,32 +825,6 @@ public class CompanySettingsController : Controller
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
if (costs != null && costs.StandardLaborRate > 0)
{
@@ -948,7 +920,8 @@ public class CompanySettingsController : Controller
{
try
{
var statuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var statuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == companyId);
var sortedStatuses = statuses.OrderBy(s => s.DisplayOrder).ToList();
var dtos = _mapper.Map<List<JobStatusLookupDto>>(sortedStatuses);
@@ -1099,7 +1072,8 @@ public class CompanySettingsController : Controller
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var statuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId();
var statuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == (companyId ?? 0));
for (int i = 0; i < dto.OrderedIds.Count; i++)
{
@@ -1112,7 +1086,6 @@ public class CompanySettingsController : Controller
}
await _unitOfWork.CompleteAsync();
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId.HasValue) _lookupCache.InvalidateCompanyCache(companyId.Value);
_logger.LogInformation("Job statuses reordered");
@@ -1141,7 +1114,8 @@ public class CompanySettingsController : Controller
{
try
{
var priorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId);
var sortedPriorities = priorities.OrderBy(p => p.DisplayOrder).ToList();
var dtos = _mapper.Map<List<JobPriorityLookupDto>>(sortedPriorities);
@@ -1286,7 +1260,8 @@ public class CompanySettingsController : Controller
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var priorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId);
for (int i = 0; i < dto.OrderedIds.Count; i++)
{
@@ -1325,7 +1300,8 @@ public class CompanySettingsController : Controller
{
try
{
var statuses = await _unitOfWork.QuoteStatusLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var statuses = await _unitOfWork.QuoteStatusLookups.FindAsync(s => s.CompanyId == companyId);
var sortedStatuses = statuses.OrderBy(s => s.DisplayOrder).ToList();
var dtos = _mapper.Map<List<QuoteStatusLookupDto>>(sortedStatuses);
@@ -1506,7 +1482,8 @@ public class CompanySettingsController : Controller
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var statuses = await _unitOfWork.QuoteStatusLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var statuses = await _unitOfWork.QuoteStatusLookups.FindAsync(s => s.CompanyId == companyId);
for (int i = 0; i < dto.OrderedIds.Count; i++)
{
@@ -1545,7 +1522,8 @@ public class CompanySettingsController : Controller
{
try
{
var services = await _unitOfWork.PrepServices.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var services = await _unitOfWork.PrepServices.FindAsync(s => s.CompanyId == companyId);
var sortedServices = services.OrderBy(s => s.DisplayOrder).ToList();
var dtos = _mapper.Map<List<PrepServiceDto>>(sortedServices);
@@ -1667,7 +1645,8 @@ public class CompanySettingsController : Controller
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var services = await _unitOfWork.PrepServices.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var services = await _unitOfWork.PrepServices.FindAsync(s => s.CompanyId == companyId);
for (int i = 0; i < dto.OrderedIds.Count; i++)
{
@@ -1840,7 +1819,8 @@ public class CompanySettingsController : Controller
{
try
{
var types = await _unitOfWork.AppointmentTypeLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var types = await _unitOfWork.AppointmentTypeLookups.FindAsync(t => t.CompanyId == companyId);
var sortedTypes = types.OrderBy(t => t.DisplayOrder).ToList();
var dtos = _mapper.Map<List<AppointmentTypeLookupDto>>(sortedTypes);
@@ -1984,7 +1964,8 @@ public class CompanySettingsController : Controller
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var types = await _unitOfWork.AppointmentTypeLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var types = await _unitOfWork.AppointmentTypeLookups.FindAsync(t => t.CompanyId == companyId);
for (int i = 0; i < dto.OrderedIds.Count; i++)
{
@@ -2024,7 +2005,8 @@ public class CompanySettingsController : Controller
{
try
{
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
var sortedCategories = categories.OrderBy(c => c.DisplayOrder).ToList();
var dtos = _mapper.Map<List<InventoryCategoryLookupDto>>(sortedCategories);
@@ -2160,7 +2142,8 @@ public class CompanySettingsController : Controller
if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" });
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
for (int i = 0; i < dto.OrderedIds.Count; i++)
{
@@ -2377,12 +2360,12 @@ public class CompanySettingsController : Controller
if (companyId == null) return RedirectToAction(nameof(Index));
// Load all existing templates for this company
var existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
var existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
// Auto-seed any missing canonical combinations
var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
if (seeded > 0)
existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
var dtos = existing.OrderBy(t => (int)t.NotificationType).ThenBy(t => (int)t.Channel)
.Select(t => new NotificationTemplateDto
@@ -2719,79 +2702,6 @@ public class CompanySettingsController : Controller
// ── 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 ───────────────────────────────────────────────────────
/// <summary>
@@ -3055,7 +2965,6 @@ public class CompanySettingsController : Controller
}
public record SaveTemplateJsonRequest(int Id, string? Subject, string? Body);
public record SaveRoleCostDto(int Role, decimal HourlyRate);
public record SaveOnlinePaymentSettingsDto(
OnlinePaymentSurchargeType SurchargeType,
decimal SurchargeValue,
@@ -226,11 +226,9 @@ public class CompanyUsersController : Controller
/// 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
/// create a SuperAdmin-equivalent account). CompanyAdmin users automatically receive all
/// per-feature permissions unless a SuperAdmin is explicitly customising them. Workers
/// additionally get an auto-created <see cref="ShopWorker"/> record so they appear in job
/// assignment dropdowns without a separate onboarding step. A legacy ASP.NET Identity role
/// (Administrator / Manager / Employee / ReadOnly) is also assigned to satisfy policy
/// checks that still reference the role system.
/// per-feature permissions unless a SuperAdmin is explicitly customising them. A legacy
/// ASP.NET Identity role (Administrator / Manager / Employee / ReadOnly) is also assigned
/// to satisfy policy checks that still reference the role system.
/// </summary>
// POST: CompanyUsers/Create
[HttpPost]
@@ -351,27 +349,7 @@ public class CompanyUsersController : Controller
await _userManager.AddToRoleAsync(user, legacyRole);
// If Worker role, automatically create a ShopWorker record
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}",
_logger.LogInformation("User {Email} created successfully by {Admin}",
user.Email, User.Identity?.Name);
TempData["Success"] = $"User '{user.FullName}' created successfully.";
@@ -441,6 +419,7 @@ public class CompanyUsersController : Controller
CompanyRole = user.CompanyRole ?? AppConstants.CompanyRoles.Viewer,
Department = user.Department,
Position = user.Position,
LaborCostPerHour = user.LaborCostPerHour,
Phone = user.PhoneNumber,
IsActive = user.IsActive,
HireDate = user.HireDate,
@@ -479,11 +458,9 @@ public class CompanyUsersController : Controller
/// 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
/// 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
/// matching <see cref="ShopWorker"/> record exists, one is created automatically; if one
/// already exists, its name, email, and active status are kept in sync. Email changes are
/// applied via <c>SetEmailAsync</c> / <c>SetUserNameAsync</c> after the main update so
/// Identity's own normalisation logic runs correctly.
/// for a company (which would lock out the tenant). Email changes are applied via
/// <c>SetEmailAsync</c> / <c>SetUserNameAsync</c> after the main update so Identity's own
/// normalisation logic runs correctly.
/// </summary>
// POST: CompanyUsers/Edit/id
[HttpPost]
@@ -596,6 +573,7 @@ public class CompanyUsersController : Controller
user.CompanyRole = model.CompanyRole;
user.Department = model.Department;
user.Position = model.Position;
user.LaborCostPerHour = model.LaborCostPerHour;
user.PhoneNumber = model.Phone;
user.IsActive = model.IsActive;
user.HireDate = model.HireDate;
@@ -632,60 +610,7 @@ public class CompanyUsersController : Controller
user.Id, oldEmail, model.Email, User.Identity?.Name);
}
// If role changed to Worker, ensure ShopWorker record exists
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}",
_logger.LogInformation("User {Email} updated successfully by {Admin}",
user.Email, User.Identity?.Name);
TempData["Success"] = "User updated successfully.";
@@ -315,7 +315,8 @@ public class CreditMemosController : Controller
private async Task PopulateCustomersAsync(int? selectedId)
{
var customers = await _unitOfWork.Customers.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
ViewBag.Customers = customers
.OrderBy(c => c.CompanyName ?? $"{c.ContactFirstName} {c.ContactLastName}".Trim())
.Select(c => new SelectListItem
@@ -342,14 +342,16 @@ public class DashboardController : Controller
TipOfTheDay = data.TipOfTheDay
};
// Resolve company once so all remaining queries are explicitly scoped
var currentCompanyId = _tenantContext.GetCurrentCompanyId();
var companyId = currentCompanyId ?? 0;
// Dropdowns for the "Add Custom Powder to Inventory" modal
var inventoryCategories = (await _unitOfWork.InventoryCategoryLookups.GetAllAsync())
.Where(c => c.IsActive)
var inventoryCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsActive && c.CompanyId == companyId))
.OrderBy(c => c.DisplayOrder)
.Select(c => new { c.Id, c.DisplayName })
.ToList();
var vendors = (await _unitOfWork.Vendors.GetAllAsync())
.Where(v => v.IsActive)
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.IsActive && v.CompanyId == companyId))
.OrderBy(v => v.CompanyName)
.Select(v => new { v.Id, v.CompanyName })
.ToList();
@@ -357,7 +359,6 @@ public class DashboardController : Controller
ViewBag.VendorList = vendors;
// Config health check — surface setup gaps to company admins
var currentCompanyId = _tenantContext.GetCurrentCompanyId();
if (currentCompanyId.HasValue)
{
ViewBag.ConfigHealth = await _configHealth.CheckAsync(currentCompanyId.Value);
@@ -711,8 +712,8 @@ public class DashboardController : Controller
i => i.Coats.Any(c => c.Id == coatId), false, i => i.Job);
var companyId = jobItem?.Job?.CompanyId ?? _tenantContext.GetCurrentCompanyId() ?? 0;
// Check SKU uniqueness
if (await _unitOfWork.InventoryItems.AnyAsync(i => i.SKU == sku.Trim()))
// Check SKU uniqueness within this company
if (await _unitOfWork.InventoryItems.AnyAsync(i => i.SKU == sku.Trim() && i.CompanyId == companyId))
return Json(new { success = false, message = $"SKU '{sku}' already exists in inventory." });
// Determine category display name for legacy field
@@ -122,7 +122,6 @@ public class DataExportController : Controller
case "Inventory": await AddInventorySheet(package, companyId, headerColor); break;
case "Equipment": await AddEquipmentSheet(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;
}
}
@@ -172,7 +171,6 @@ public class DataExportController : Controller
case "Inventory": WriteCsvEntry(zip, "Inventory.csv", await BuildInventoryCsv(companyId)); break;
case "Equipment": WriteCsvEntry(zip, "Equipment.csv", await BuildEquipmentCsv(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;
}
}
@@ -441,38 +439,6 @@ public class DataExportController : Controller
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>
/// 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
@@ -687,21 +653,6 @@ public class DataExportController : Controller
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>
/// 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
@@ -761,7 +712,7 @@ public class DataExportController : Controller
/// <summary>
/// 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
/// regardless of the order the administrator checked the boxes on the form.
/// 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>
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();
}
@@ -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("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("ShopWorkers", "Shop Workers", "bi-person-badge","Inventory & Ops", _db.ShopWorkers.Where(e => e.IsDeleted)));
return stats;
}
@@ -204,7 +203,6 @@ public class DataPurgeController : Controller
"Equipment" => await QueryCount(_db.Equipment, cutoff),
"MaintenanceRecords" => await QueryCount(_db.MaintenanceRecords, cutoff),
"Vendors" => await QueryCount(_db.Vendors, cutoff),
"ShopWorkers" => await QueryCount(_db.ShopWorkers, cutoff),
_ => (0, null)
};
}
@@ -324,11 +322,6 @@ public class DataPurgeController : Controller
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break;
case "ShopWorkers":
count = await _db.ShopWorkers.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break;
default:
return 0;
}
@@ -359,7 +352,7 @@ public class DataPurgeController : Controller
"MaintenanceRecords",
"Jobs", "Customers", "Quotes",
"InventoryItems", "Equipment",
"Vendors", "ShopWorkers"
"Vendors"
};
return order.Where(entities.Contains).ToArray();
}
@@ -107,7 +107,8 @@ public class GiftCertificatesController : Controller
IssuedReason = gc.IssuedReason,
Status = gc.Status,
IssueDate = gc.IssueDate,
ExpiryDate = gc.ExpiryDate
ExpiryDate = gc.ExpiryDate,
BatchId = gc.BatchId
})
.ToList();
@@ -440,6 +441,183 @@ public class GiftCertificatesController : Controller
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)
{
if (company == null) return (null, null);
@@ -38,14 +38,6 @@ namespace PowderCoating.Web.Controllers
return View();
}
/// <summary>
/// Serves the Shop Workers help article describing roles, assignment to jobs, and maintenance tasks.
/// </summary>
public IActionResult ShopWorkers()
{
return View();
}
/// <summary>
/// Serves the Equipment help article explaining the equipment status lifecycle and maintenance scheduling.
/// </summary>
@@ -160,7 +160,8 @@ public class InventoryController : Controller
var pagedResult = PagedResult<InventoryListDto>.From(gridRequest, itemDtos, totalCount);
// Load all items once to compute sidebar stats and category list in memory
var allItems = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allItems = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
ViewBag.Categories = allItems.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToList();
ViewBag.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
ViewBag.StatsActiveCount = allItems.Count(i => i.IsActive);
@@ -1106,7 +1107,8 @@ public class InventoryController : Controller
// Build a set of SKUs already in this company's inventory so we can exclude them.
// When editing, the current item's own SKU is re-included so its catalog entry still appears.
var existingItems = await _unitOfWork.InventoryItems.GetAllAsync();
var skuCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var existingItems = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == skuCompanyId);
var existingSkus = existingItems
.Where(i => !string.IsNullOrWhiteSpace(i.ManufacturerPartNumber) && i.Id != (currentId ?? 0))
.Select(i => i.ManufacturerPartNumber!.Trim().ToLower())
@@ -1182,7 +1184,7 @@ public class InventoryController : Controller
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Find the default coating category to assign
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
var coatingCategory = categories
.Where(c => c.IsActive && c.IsCoating)
.OrderBy(c => c.DisplayOrder)
@@ -1369,11 +1371,11 @@ public class InventoryController : Controller
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
ViewBag.AiInventoryAssistEnabled = await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId);
var vendors = await _unitOfWork.Vendors.GetAllAsync();
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId);
ViewBag.Vendors = new SelectList(vendors.Where(s => s.IsActive).OrderBy(s => s.CompanyName), "Id", "CompanyName");
// Load categories from lookup table
var allCategories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
var allCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
var categories = allCategories
.Where(c => c.IsActive)
.OrderBy(c => c.DisplayOrder)
@@ -1738,7 +1740,8 @@ public class InventoryController : Controller
DateTime? dateTo,
string? typeFilter)
{
var allItems = await _unitOfWork.InventoryItems.GetAllAsync();
var ledgerCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allItems = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == ledgerCompanyId);
var itemList = allItems
.Where(i => i.IsActive || i.QuantityOnHand > 0)
.OrderBy(i => i.Name)
@@ -1,9 +1,11 @@
using System.Text.Json;
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.DTOs.Common;
using PowderCoating.Application.DTOs.Invoice;
using PowderCoating.Application.DTOs.Quote;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using Microsoft.AspNetCore.Mvc.Rendering;
@@ -338,13 +340,14 @@ public class InvoicesController : Controller
var costs = await _unitOfWork.CompanyOperatingCosts
.FirstOrDefaultAsync(c => c.CompanyId == currentUser.CompanyId && !c.IsDeleted);
var defaultTerms = prefs?.DefaultPaymentTerms ?? "Net 30";
var dto = new CreateInvoiceDto
{
PreparedById = currentUser.Id,
InvoiceDate = DateTime.Today,
DueDate = DateTime.Today.AddDays(prefs?.DefaultTurnaroundDays ?? 30),
DueDate = PaymentTermsParser.CalculateDueDate(defaultTerms, DateTime.Today),
TaxPercent = costs?.TaxPercent ?? 0,
Terms = prefs?.DefaultPaymentTerms ?? "Net 30"
Terms = defaultTerms
};
if (jobId.HasValue)
@@ -376,6 +379,13 @@ public class InvoicesController : Controller
var defaultRevenueAccount = await _unitOfWork.Accounts
.FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.IsActive);
// Deserialize the job's pricing snapshot up front — it is authoritative for discount,
// tax, and fees for both quote-based and direct jobs, because it is recalculated on
// every save and reflects any edits made after quote conversion.
QuotePricingBreakdownDto? jobBreakdown = null;
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
jobBreakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
// If the job came from a quote, load it so we can use the agreed pricing.
// The quote stores the approved total including oven batch cost and shop supplies —
// these are quote-level charges that are NOT stored on individual job items.
@@ -396,13 +406,15 @@ public class InvoicesController : Controller
dto.InvoiceItems.Add(new CreateInvoiceItemDto
{
SourceJobItemId = item.Id,
Description = item.Description ?? "Powder Coating",
Quantity = 1,
UnitPrice = item.TotalPrice,
TotalPrice = item.TotalPrice,
ColorName = item.ColorName,
DisplayOrder = order++,
SourceJobItemId = item.Id,
CatalogItemId = item.CatalogItemId,
Description = item.Description ?? "Powder Coating",
Quantity = item.Quantity > 0 ? item.Quantity : 1,
UnitPrice = item.UnitPrice,
TotalPrice = item.TotalPrice,
ColorName = item.ColorName,
Notes = item.Notes,
DisplayOrder = order++,
RevenueAccountId = revenueAccountId
});
}
@@ -437,7 +449,10 @@ public class InvoicesController : Controller
// because FinalPrice is recalculated on every item edit and can drift from the original quote.
if (sourceQuote != null)
{
// Bundle all quote-level charges so the invoice subtotal matches the quote total.
// FacilityOverheadCost is included — it is a real cost baked into the quoted price.
var processingFees = sourceQuote.OvenBatchCost
+ sourceQuote.FacilityOverheadCost
+ sourceQuote.ShopSuppliesAmount
+ sourceQuote.RushFee;
@@ -454,21 +469,21 @@ public class InvoicesController : Controller
});
}
// Use the quote's agreed tax rate and discount — not current company defaults
dto.TaxPercent = sourceQuote.TaxPercent;
// Use the quote's agreed tax rate and discount — these represent the customer-approved
// price and must not be recomputed from the job's current state.
dto.TaxPercent = sourceQuote.TaxPercent;
dto.DiscountAmount = sourceQuote.DiscountAmount;
}
else if (hadJobItems)
{
// Direct job — no source quote. Use the stored job-level fees rather than
// recalculating, so the invoice always matches the total shown on the job page.
// OvenBatchCost and ShopSuppliesAmount are saved by the pricing engine (with
// OvenCostId) when job items are created or updated.
// Direct job — no source quote. Read all charges from the pricing snapshot so the
// invoice always matches the total shown on the job's Pricing Summary card.
if (job.OvenBatchCost > 0.01m)
{
dto.InvoiceItems.Add(new CreateInvoiceItemDto
{
Description = $"Oven Processing Fee",
Description = "Oven Processing Fee",
Quantity = 1,
UnitPrice = Math.Round(job.OvenBatchCost, 2),
TotalPrice = Math.Round(job.OvenBatchCost, 2),
@@ -477,6 +492,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)
{
var suppliesDesc = job.ShopSuppliesPercent > 0
@@ -488,10 +517,40 @@ public class InvoicesController : Controller
Quantity = 1,
UnitPrice = 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,
RevenueAccountId = defaultRevenueAccount?.Id
});
}
dto.DiscountAmount = jobBreakdown?.DiscountAmount ?? 0;
}
// Inherit payment terms from the source quote or the customer — more specific than
// the company-wide default set in the outer DTO. Quote terms take priority because
// they represent the agreed price; customer terms are next best for direct jobs.
var inheritedTerms = sourceQuote?.Terms ?? job.Customer?.PaymentTerms;
if (!string.IsNullOrWhiteSpace(inheritedTerms))
{
dto.Terms = inheritedTerms;
dto.DueDate = PaymentTermsParser.CalculateDueDate(inheritedTerms, DateTime.Today)
?? dto.DueDate;
var (discPct, discDays) = PaymentTermsParser.ParseEarlyPaymentDiscount(inheritedTerms);
dto.EarlyPaymentDiscountPercent = discPct;
dto.EarlyPaymentDiscountDays = discDays;
}
// Override tax to 0 for tax-exempt customers, regardless of company default or quote rate
@@ -2154,7 +2213,7 @@ public class InvoicesController : Controller
/// </summary>
private async Task PopulateCreateViewBagAsync(int companyId, string? selectedTerms = null)
{
var customers = await _unitOfWork.Customers.GetAllAsync();
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
ViewBag.Customers = customers.Where(c => c.IsActive).OrderBy(c => c.CompanyName ?? c.ContactLastName).ToList();
// Expose company default tax rate and exempt customer IDs for client-side tax handling
@@ -36,7 +36,9 @@ public class JobTemplatesController : Controller
/// </summary>
public async Task<IActionResult> Index()
{
var templates = await _unitOfWork.JobTemplates.GetAllAsync(
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var templates = await _unitOfWork.JobTemplates.FindAsync(
t => t.CompanyId == companyId,
false,
t => t.Customer,
t => t.Items);
@@ -422,72 +422,24 @@ public class JobsController : Controller
// Populate Edit Items wizard data (inline modal on Details page)
var wizardCosts = await _pricingService.GetOperatingCostsAsync(job.CompanyId);
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)
var breakdownItems = job.JobItems
.Where(ji => !ji.IsDeleted)
.Select(ji => new CreateQuoteItemDto
{
Description = ji.Description,
Quantity = ji.Quantity,
SurfaceAreaSqFt = ji.SurfaceAreaSqFt,
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,
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())
// Display the pricing snapshot stored when items were last saved.
// Never recalculate on load — operating cost changes must not retroactively alter existing jobs.
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
{
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 = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
}
else if (job.FinalPrice > 0)
{
// Legacy job created before snapshot was introduced — show what we have stored
ViewBag.JobPricingBreakdown = new QuotePricingBreakdownDto
{
MaterialCosts = pr.MaterialCosts,
LaborCosts = pr.LaborCosts,
EquipmentCosts = pr.EquipmentCosts,
ItemsSubtotal = pr.ItemsSubtotal,
OvenBatchCost = pr.OvenBatchCost,
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
OvenBatchCost = job.OvenBatchCost,
OvenBatches = job.OvenBatches,
ShopSuppliesAmount = job.ShopSuppliesAmount,
ShopSuppliesPercent = job.ShopSuppliesPercent,
Total = job.FinalPrice
};
}
ViewBag.ComplexitySimplePercent = wizardCosts?.ComplexitySimplePercent ?? 0m;
@@ -506,6 +458,7 @@ public class JobsController : Controller
isGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && !ji.Coats.Any() && !ji.IsSalesItem),
isLaborItem = ji.IsLaborItem,
isSalesItem = ji.IsSalesItem,
isAiItem = ji.IsAiItem,
sku = ji.Sku,
requiresSandblasting = ji.RequiresSandblasting,
requiresMasking = ji.RequiresMasking,
@@ -545,6 +498,23 @@ public class JobsController : Controller
.OrderByDescending(t => t.TransactionDate).ToList();
ViewBag.MaterialsUsed = allJobTransactions;
// Inventory items for the manual log-material modal
var inventoryItemsForModal = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == job.CompanyId))
.OrderBy(i => i.Name)
.Select(i => new { i.Id, i.Name, i.Manufacturer, i.UnitOfMeasure, i.QuantityOnHand })
.ToList();
var jsonOpts = new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase };
ViewBag.InventoryItemsForModal = System.Text.Json.JsonSerializer.Serialize(inventoryItemsForModal, jsonOpts);
// IDs of powders already assigned to this job's coats — shown at top of log-material dropdown
var jobPowderIds = (jobDto.Items ?? new List<PowderCoating.Application.DTOs.Job.JobItemDto>())
.SelectMany(i => i.Coats ?? new List<PowderCoating.Application.DTOs.Job.JobItemCoatDto>())
.Where(c => c.InventoryItemId.HasValue)
.Select(c => c.InventoryItemId!.Value)
.Distinct()
.ToList();
ViewBag.JobPowderIds = System.Text.Json.JsonSerializer.Serialize(jobPowderIds, jsonOpts);
// Pre-logged powder grouped by InventoryItemId (for Complete Job modal pre-fill)
ViewBag.PreLoggedPowder = allJobTransactions
.GroupBy(t => t.InventoryItemId)
@@ -558,7 +528,7 @@ public class JobsController : Controller
ViewBag.JobPhotoMax = photoMax;
// Customer list for inline customer-change dropdown
var allCustomers = await _unitOfWork.Customers.GetAllAsync();
var allCustomers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == job.CompanyId);
ViewBag.CustomerSelectList = allCustomers
.Where(c => c.IsActive)
.Select(c => new SelectListItem
@@ -664,7 +634,8 @@ public class JobsController : Controller
if (job == null) return NotFound();
var allStatuses = (await _unitOfWork.JobStatusLookups.GetAllAsync())
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allStatuses = (await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == companyId))
.OrderBy(s => s.DisplayOrder).ToList();
ViewBag.AllStatuses = allStatuses;
@@ -687,7 +658,7 @@ public class JobsController : Controller
if (job == null) return NotFound();
var allStatuses = (await _unitOfWork.JobStatusLookups.GetAllAsync()).ToList();
var allStatuses = (await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == job.CompanyId)).ToList();
var newStatus = allStatuses.FirstOrDefault(s => s.Id == newStatusId);
if (newStatus == null) return BadRequest("Invalid status.");
@@ -875,7 +846,7 @@ public class JobsController : Controller
// Optionally advance status to In Preparation
if (advanceToInPreparation && jobToUpdate.JobStatus.StatusCode != AppConstants.StatusCodes.Job.InPreparation)
{
var allStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
var allStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == jobToUpdate.CompanyId);
var inPrepStatus = allStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.InPreparation);
if (inPrepStatus != null)
{
@@ -932,7 +903,7 @@ public class JobsController : Controller
if (advanceToInPreparation && job.JobStatus.StatusCode != AppConstants.StatusCodes.Job.InPreparation && !job.JobStatus.IsTerminalStatus)
{
var allStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
var allStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == job.CompanyId);
var inPrepStatus = allStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.InPreparation);
if (inPrepStatus != null)
{
@@ -1106,6 +1077,7 @@ public class JobsController : Controller
CustomerId = dto.CustomerId,
QuoteId = dto.QuoteId,
AssignedUserId = dto.AssignedUserId,
OvenCostId = dto.OvenCostId,
Description = dto.Description,
JobPriorityId = dto.JobPriorityId,
JobStatusId = pendingStatus?.Id ?? 1,
@@ -1167,15 +1139,23 @@ public class JobsController : Controller
// Recalculate total from wizard items
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(
dto.JobItems, companyId, dto.CustomerId,
createCosts?.TaxPercent ?? 0m,
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, job.OvenCostId, 1, null);
await GetEffectiveTaxPercentAsync(dto.CustomerId, createCosts?.TaxPercent ?? 0m),
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, createOvenRate, job.OvenBatches, job.OvenCycleMinutes);
job.FinalPrice = totals.Total;
job.OvenBatchCost = totals.OvenBatchCost;
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
job.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.Jobs.UpdateAsync(job);
await _unitOfWork.SaveChangesAsync();
@@ -1262,6 +1242,7 @@ public class JobsController : Controller
PowderCostOverride = ji.PowderCostOverride,
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
IsLaborItem = ji.IsLaborItem,
IsAiItem = ji.IsAiItem,
RequiresSandblasting = ji.RequiresSandblasting,
RequiresMasking = ji.RequiresMasking,
Notes = ji.Notes,
@@ -1626,14 +1607,22 @@ public class JobsController : Controller
if (dto.JobItems.Any())
{
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(
dto.JobItems, companyId, dto.CustomerId,
editCosts?.TaxPercent ?? 0m,
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, job.OvenCostId, 1, null);
job.FinalPrice = totals.Total;
job.OvenBatchCost = totals.OvenBatchCost;
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
await GetEffectiveTaxPercentAsync(dto.CustomerId, editCosts?.TaxPercent ?? 0m),
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, editOvenRate, job.OvenBatches, job.OvenCycleMinutes);
job.FinalPrice = totals.Total;
job.OvenBatchCost = totals.OvenBatchCost;
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
}
// Save change history records
@@ -1821,7 +1810,7 @@ public class JobsController : Controller
ViewBag.AiPhotoQuotesEnabled = await _subscriptionService.CanUseAiPhotoQuoteAsync(companyId);
await PopulateDropdowns();
await PopulatePrepServicesAsync();
await PopulatePrepServicesAsync(companyId);
var costs = await _pricingService.GetOperatingCostsAsync(companyId);
await PopulateJobItemDropDownsAsync(companyId, costs?.OvenOperatingCostPerHour ?? 45m);
ViewBag.TaxPercent = costs?.TaxPercent ?? 0m;
@@ -1841,7 +1830,9 @@ public class JobsController : Controller
/// </summary>
private async Task PopulateDropdowns()
{
var customers = await _unitOfWork.Customers.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
ViewBag.Customers = new SelectList(
customers.Where(c => c.IsActive).Select(c => new
{
@@ -1852,8 +1843,6 @@ public class JobsController : Controller
}).OrderBy(c => c.DisplayName),
"Id",
"DisplayName");
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var users = await _userManager.Users
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
@@ -2235,13 +2224,13 @@ public class JobsController : Controller
/// Loads all active prep services into ViewBag for the item wizard's prep services step.
/// Prep services are ordered by DisplayOrder so they appear in the intended workflow sequence.
/// </summary>
private async Task PopulatePrepServicesAsync()
private async Task PopulatePrepServicesAsync(int companyId)
{
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive);
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive && ps.CompanyId == companyId);
ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList();
_logger.LogInformation("Populated {Count} active prep services", prepServices.Count());
var blastSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive);
var blastSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive && b.CompanyId == companyId);
ViewBag.BlastSetups = blastSetups.OrderBy(b => b.DisplayOrder)
.Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault })
.ToList();
@@ -2677,78 +2666,80 @@ public class JobsController : Controller
.GroupBy(t => t.InventoryItemId)
.ToDictionary(g => g.Key, g => Math.Abs(g.Sum(t => t.Quantity)));
// Update actual powder usage for each coat
foreach (var coatUsage in dto.CoatUsages)
// Process powder usage submitted per inventory item (color) for the whole job.
// Distribute entered lbs across coats sharing that InventoryItemId proportionally
// by estimated PowderToOrder so per-coat reporting stays meaningful.
// One inventory deduction per powder (net of pre-logged credit).
if (dto.PowderUsages.Any())
{
var jobItemCoat = await _unitOfWork.JobItemCoats.GetByIdAsync(
coatUsage.JobItemCoatId,
false,
jic => jic.InventoryItem);
// Load all coats for the job with their inventory items
var allCoats = (await _unitOfWork.JobItemCoats.FindAsync(
jic => jic.JobItem != null && jic.JobItem.JobId == dto.JobId,
false, jic => jic.InventoryItem, jic => jic.JobItem))
.ToList();
if (jobItemCoat != null)
foreach (var powderUsage in dto.PowderUsages)
{
jobItemCoat.ActualPowderUsedLbs = coatUsage.ActualPowderUsedLbs;
await _unitOfWork.JobItemCoats.UpdateAsync(jobItemCoat);
if (!powderUsage.ActualPowderUsedLbs.HasValue || powderUsage.ActualPowderUsedLbs.Value <= 0)
continue;
_logger.LogInformation("Updated JobItemCoat {CoatId} with {Lbs} lbs actual powder used",
coatUsage.JobItemCoatId, coatUsage.ActualPowderUsedLbs);
var invItemId = powderUsage.InventoryItemId;
var totalActualLbs = powderUsage.ActualPowderUsedLbs.Value;
// Deduct powder from inventory if using stock powder
if (jobItemCoat.InventoryItemId.HasValue &&
coatUsage.ActualPowderUsedLbs.HasValue &&
coatUsage.ActualPowderUsedLbs.Value > 0)
// Distribute across coats using this powder proportionally by estimated lbs
var coatsForPowder = allCoats.Where(c => c.InventoryItemId == invItemId).ToList();
if (coatsForPowder.Any())
{
var invItemId = jobItemCoat.InventoryItemId.Value;
var actualLbs = coatUsage.ActualPowderUsedLbs.Value;
// Apply available pre-logged credit so we don't double-deduct
var credit = preLoggedCredit.GetValueOrDefault(invItemId, 0m);
var deductNow = Math.Max(0m, actualLbs - credit);
// Consume credit (other coats sharing the same powder get whatever remains)
preLoggedCredit[invItemId] = Math.Max(0m, credit - actualLbs);
if (deductNow > 0)
var totalEstimated = coatsForPowder.Sum(c => c.PowderToOrder ?? 0m);
foreach (var coat in coatsForPowder)
{
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(invItemId);
if (inventoryItem != null)
{
var transaction = new InventoryTransaction
{
InventoryItemId = inventoryItem.Id,
TransactionType = InventoryTransactionType.JobUsage,
Quantity = -deductNow,
UnitCost = inventoryItem.UnitCost,
TotalCost = inventoryItem.UnitCost * deductNow,
TransactionDate = DateTime.UtcNow,
JobId = job.Id,
Reference = job.JobNumber,
Notes = $"Powder used for Job {job.JobNumber} - {jobItemCoat.CoatName} ({jobItemCoat.ColorName ?? "N/A"}) by {currentUser!.FirstName} {currentUser.LastName}",
BalanceAfter = inventoryItem.QuantityOnHand - deductNow,
CompanyId = job.CompanyId
};
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
inventoryItem.QuantityOnHand -= deductNow;
await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem);
// GL: DR COGS, CR Inventory Asset (accrual) — no-op if accounts not configured
if (inventoryItem.CogsAccountId.HasValue && inventoryItem.InventoryAccountId.HasValue)
{
var cost = deductNow * (inventoryItem.AverageCost > 0 ? inventoryItem.AverageCost : inventoryItem.UnitCost);
await _accountBalanceService.DebitAsync(inventoryItem.CogsAccountId, cost);
await _accountBalanceService.CreditAsync(inventoryItem.InventoryAccountId, cost);
}
_logger.LogInformation(
"Deducted {Lbs} lbs (net of pre-logged) of {Item} from inventory for Job {JobNumber}. New quantity: {NewQty}",
deductNow, inventoryItem.Name, job.JobNumber, inventoryItem.QuantityOnHand);
}
var share = totalEstimated > 0
? totalActualLbs * ((coat.PowderToOrder ?? 0m) / totalEstimated)
: totalActualLbs / coatsForPowder.Count;
coat.ActualPowderUsedLbs = Math.Round(share, 4);
await _unitOfWork.JobItemCoats.UpdateAsync(coat);
}
else
}
// Single inventory deduction for the whole powder, net of pre-logged credit
var credit = preLoggedCredit.GetValueOrDefault(invItemId, 0m);
var deductNow = Math.Max(0m, totalActualLbs - credit);
preLoggedCredit[invItemId] = 0m;
if (deductNow > 0)
{
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(invItemId);
if (inventoryItem != null)
{
inventoryItem.QuantityOnHand -= deductNow;
await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem);
var transaction = new InventoryTransaction
{
InventoryItemId = inventoryItem.Id,
TransactionType = InventoryTransactionType.JobUsage,
Quantity = -deductNow,
UnitCost = inventoryItem.UnitCost,
TotalCost = inventoryItem.UnitCost * deductNow,
TransactionDate = DateTime.UtcNow,
JobId = job.Id,
Reference = job.JobNumber,
Notes = $"Powder used for Job {job.JobNumber} by {currentUser!.FirstName} {currentUser.LastName}",
BalanceAfter = inventoryItem.QuantityOnHand,
CompanyId = job.CompanyId
};
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
if (inventoryItem.CogsAccountId.HasValue && inventoryItem.InventoryAccountId.HasValue)
{
var cost = deductNow * (inventoryItem.AverageCost > 0 ? inventoryItem.AverageCost : inventoryItem.UnitCost);
await _accountBalanceService.DebitAsync(inventoryItem.CogsAccountId, cost);
await _accountBalanceService.CreditAsync(inventoryItem.InventoryAccountId, cost);
}
_logger.LogInformation(
"Skipped inventory deduction for JobItemCoat {CoatId} — {Lbs} lbs already pre-logged for inventory item {InvItemId}",
coatUsage.JobItemCoatId, actualLbs, invItemId);
"Deducted {Lbs} lbs (net of pre-logged) of {Item} from inventory for Job {JobNumber}. New quantity: {NewQty}",
deductNow, inventoryItem.Name, job.JobNumber, inventoryItem.QuantityOnHand);
}
}
}
@@ -2926,6 +2917,7 @@ public class JobsController : Controller
PowderCostOverride = ji.PowderCostOverride,
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
IsLaborItem = ji.IsLaborItem,
IsAiItem = ji.IsAiItem,
RequiresSandblasting = ji.RequiresSandblasting,
RequiresMasking = ji.RequiresMasking,
Notes = ji.Notes,
@@ -2955,11 +2947,14 @@ public class JobsController : Controller
var viewModel = new JobEditItemsViewModel
{
JobId = job.Id,
JobNumber = job.JobNumber,
CustomerId = job.CustomerId,
TaxPercent = costs?.TaxPercent ?? 0m,
JobItems = existingItems
JobId = job.Id,
JobNumber = job.JobNumber,
CustomerId = job.CustomerId,
TaxPercent = await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m),
OvenCostId = job.OvenCostId,
OvenBatches = job.OvenBatches > 0 ? job.OvenBatches : 1,
OvenCycleMinutes = job.OvenCycleMinutes,
JobItems = existingItems
};
await PopulateJobItemDropDownsAsync(currentUser.CompanyId, costs?.OvenOperatingCostPerHour ?? 45m);
@@ -2992,7 +2987,7 @@ public class JobsController : Controller
{
ModelState.AddModelError("", "Please add at least one job item.");
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);
ViewBag.ComplexitySimplePercent = costs?.ComplexitySimplePercent ?? 0m;
ViewBag.ComplexityModeratePercent = costs?.ComplexityModeratePercent ?? 5m;
@@ -3037,15 +3032,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(
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.OvenBatchCost = totals.OvenBatchCost;
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
job.FinalPrice = totals.Total;
job.OvenBatchCost = totals.OvenBatchCost;
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
job.UpdatedAt = DateTime.UtcNow;
job.UpdatedBy = currentUser.UserName;
await _unitOfWork.Jobs.UpdateAsync(job);
@@ -3059,7 +3065,7 @@ public class JobsController : Controller
_logger.LogError(ex, "Error updating items for job {JobId}", job.Id);
TempData["Error"] = "An error occurred while saving job items.";
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);
return View("EditItems", model);
}
@@ -3101,30 +3107,47 @@ public class JobsController : Controller
CatalogItemId = ji.CatalogItemId,
IsGenericItem = ji.IsGenericItem,
IsLaborItem = ji.IsLaborItem,
ManualUnitPrice = ji.ManualUnitPrice,
Coats = ji.Coats.Select(c => new CreateQuoteItemCoatDto
IsSalesItem = ji.IsSalesItem,
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,
TransferEfficiency = c.TransferEfficiency,
PowderCostPerLb = c.PowderCostPerLb
}).ToList()
}).ToList();
var costs = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
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(
remainingDtos, currentUser.CompanyId, job.CustomerId,
costs?.TaxPercent ?? 0m, "None", 0, false, null, 1, null);
job.FinalPrice = totals.Total;
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
await GetEffectiveTaxPercentAsync(job.CustomerId, costs?.TaxPercent ?? 0m),
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
deleteOvenRate, job.OvenBatches, job.OvenCycleMinutes);
job.FinalPrice = totals.Total;
job.OvenBatchCost = totals.OvenBatchCost;
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
job.ShopSuppliesPercent = totals.ShopSuppliesPercent;
job.PricingBreakdownJson = JsonSerializer.Serialize(BuildPricingSnapshotDto(totals));
}
else
{
job.FinalPrice = 0;
job.ShopSuppliesAmount = 0;
job.ShopSuppliesPercent = 0;
job.FinalPrice = 0;
job.OvenBatchCost = 0;
job.ShopSuppliesAmount = 0;
job.ShopSuppliesPercent = 0;
job.PricingBreakdownJson = null;
}
job.UpdatedAt = DateTime.UtcNow;
@@ -3144,7 +3167,7 @@ public class JobsController : Controller
/// </summary>
private async Task PopulateJobItemDropDownsAsync(int companyId, decimal fallbackOvenRate)
{
var inventory = await _unitOfWork.InventoryItems.GetAllAsync(false, i => i.InventoryCategory);
var inventory = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId, false, i => i.InventoryCategory);
ViewBag.InventoryCoatings = inventory
.Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating)
.OrderBy(i => i.IsIncoming ? 1 : 0).ThenBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name)
@@ -3164,12 +3187,12 @@ public class JobsController : Controller
isIncoming = i.IsIncoming
}).ToList();
var vendors = await _unitOfWork.Vendors.GetAllAsync(false);
var vendors = await _unitOfWork.Vendors.FindAsync(s => s.CompanyId == companyId, false);
ViewBag.Vendors = vendors
.Where(s => s.IsActive).OrderBy(s => s.CompanyName)
.Select(s => new { value = s.Id.ToString(), text = s.CompanyName }).ToList();
var catalogItems = await _unitOfWork.CatalogItems.GetAllAsync(false, i => i.Category, i => i.Category.ParentCategory);
var catalogItems = await _unitOfWork.CatalogItems.FindAsync(i => i.CompanyId == companyId, false, i => i.Category, i => i.Category.ParentCategory);
ViewBag.CatalogItems = catalogItems
.Where(i => i.IsActive)
.OrderBy(i => i.Category.DisplayOrder).ThenBy(i => i.DisplayOrder)
@@ -3198,10 +3221,10 @@ public class JobsController : Controller
description = i.Description
}).ToList();
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive);
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive && ps.CompanyId == companyId);
ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList();
var blastSetupsForEditItems = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive);
var blastSetupsForEditItems = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive && b.CompanyId == companyId);
ViewBag.BlastSetups = blastSetupsForEditItems.OrderBy(b => b.DisplayOrder)
.Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault })
.ToList();
@@ -3234,6 +3257,57 @@ public class JobsController : Controller
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
#region Item Pricing (AJAX)
@@ -3314,8 +3388,7 @@ public class JobsController : Controller
public async Task<IActionResult> GetTimeEntries(int jobId)
{
var entries = await _unitOfWork.JobTimeEntries.FindAsync(
e => e.JobId == jobId, false,
e => e.Worker); // Worker nav loaded for display of legacy entries that pre-date user migration
e => e.JobId == jobId, false);
var dtos = _mapper.Map<List<JobTimeEntryDto>>(entries.OrderByDescending(e => e.WorkDate).ToList());
return Json(dtos);
}
@@ -3769,15 +3842,24 @@ public class JobsController : Controller
// Operating costs for fallback labor rate and oven rate
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 defaultOvenCycleHours = effectiveOvenMinutes / 60.0m;
// Role cost rates map: role → hourly rate
var roleCosts = await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId);
var roleCostMap = roleCosts.ToDictionary(r => r.Role, r => r.HourlyRate);
// Labor cost rate priority: per-user LaborCostPerHour → company LaborCostPerHour → 20% of StandardLaborRate
var companyLaborCostRate = opCosts?.LaborCostPerHour ?? ((opCosts?.StandardLaborRate ?? 0m) * 0.20m);
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
// 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;
var powderLines = new List<object>();
bool hasCoatsWithRateButNoQty = false;
@@ -3785,7 +3867,19 @@ public class JobsController : Controller
{
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 lineCost = lbs * costPerLb;
powderCost += lineCost;
@@ -3796,7 +3890,7 @@ public class JobsController : Controller
lbs = Math.Round(lbs, 3),
costPerLb = Math.Round(costPerLb, 4),
total = Math.Round(lineCost, 2),
isActual = coat.ActualPowderUsedLbs.HasValue
isActual
});
}
else if (costPerLb > 0 && lbs == 0)
@@ -3808,20 +3902,23 @@ public class JobsController : Controller
}
// 2. Labor cost
// Priority: per-user LaborCostPerHour → company LaborCostPerHour → 20% of StandardLaborRate
decimal laborCost = 0m;
var laborLines = new List<object>();
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;
laborCost += lineCost;
laborLines.Add(new {
worker = entry.Worker?.Name ?? "Unknown",
role = entry.Worker != null ? System.Text.RegularExpressions.Regex.Replace(entry.Worker.Role.ToString(), "([a-z])([A-Z])", "$1 $2") : "",
worker = entry.UserDisplayName ?? "Unknown",
hours = entry.HoursWorked,
rate = Math.Round(rate, 2),
total = Math.Round(lineCost, 2),
usingFallback = entry.Worker == null || !roleCostMap.ContainsKey(entry.Worker.Role),
usingFallback = !usingPerUser,
stage = entry.Stage,
workDate = entry.WorkDate.ToString("MM/dd/yyyy")
});
@@ -3895,7 +3992,7 @@ public class JobsController : Controller
grossMargin,
quotedMargin,
quotedPrice = Math.Round(job.QuotedPrice, 2),
fallbackLaborRate,
companyLaborCostRate,
powderLines,
laborLines,
hasPowderData = powderLines.Count > 0,
@@ -4003,9 +4100,87 @@ public class JobsController : Controller
_logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId);
}
/// <summary>
/// Logs manual material usage from the job details page. Mirrors the QR scan LogUsage
/// flow in InventoryController but returns JSON so the modal can close and refresh inline.
/// Quantity is always the amount USED (caller converts from remaining if needed).
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> LogMaterial([FromBody] LogMaterialRequest req)
{
try
{
if (req.QuantityUsed <= 0)
return Json(new { success = false, message = "Quantity used must be greater than zero." });
var item = await _unitOfWork.InventoryItems.GetByIdAsync(req.InventoryItemId);
if (item == null) return Json(new { success = false, message = "Inventory item not found." });
var job = await _unitOfWork.Jobs.GetByIdAsync(req.JobId);
if (job == null) return Json(new { success = false, message = "Job not found." });
var txnType = req.TransactionType == "Waste"
? InventoryTransactionType.Waste
: InventoryTransactionType.JobUsage;
item.QuantityOnHand -= req.QuantityUsed;
item.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.InventoryItems.UpdateAsync(item);
var txn = new PowderCoating.Core.Entities.InventoryTransaction
{
InventoryItemId = item.Id,
TransactionType = txnType,
Quantity = -req.QuantityUsed,
UnitCost = item.UnitCost,
TotalCost = req.QuantityUsed * item.UnitCost,
TransactionDate = DateTime.UtcNow,
BalanceAfter = item.QuantityOnHand,
JobId = req.JobId,
Reference = $"Job {job.JobNumber}",
Notes = req.Notes?.Trim(),
CompanyId = item.CompanyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.InventoryTransactions.AddAsync(txn);
await _unitOfWork.CompleteAsync();
// GL: DR COGS, CR Inventory Asset
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
{
var cost = req.QuantityUsed * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost);
await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
}
return Json(new
{
success = true,
message = $"Logged {req.QuantityUsed:N2} {item.UnitOfMeasure} of {item.Name}.",
newBalance = item.QuantityOnHand,
unitOfMeasure = item.UnitOfMeasure,
itemName = item.Name
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error logging material for job {JobId}", req.JobId);
return Json(new { success = false, message = "An error occurred. Please try again." });
}
}
}
public class DeleteTimeEntryRequest { public int Id { get; set; } }
public class LogMaterialRequest
{
public int JobId { get; set; }
public int InventoryItemId { get; set; }
public decimal QuantityUsed { get; set; }
public string TransactionType { get; set; } = "JobUsage";
public string? Notes { get; set; }
}
public class CreateReworkJobRequest { public int ReworkRecordId { get; set; } public string? Notes { get; set; } }
public class UpdateWorkerAssignmentRequest
@@ -90,8 +90,8 @@ public class JobsPriorityController : Controller
.ToList();
// Get priorities and workers for modal options
var priorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId);
var workers = await _userManager.Users
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
@@ -916,8 +916,13 @@ public class KioskController : Controller
ViewBag.SessionToken = session.SessionToken;
ViewBag.SessionType = session.SessionType;
// Reset to Welcome screen after 45 s of inactivity on any intake step.
// The Welcome screen itself stays on indefinitely (no timeout override there).
ViewBag.InactivityTimeoutMs = 45_000;
// 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;
}
}
@@ -16,15 +16,18 @@ public class MaintenanceController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly ITenantContext _tenantContext;
private readonly ILogger<MaintenanceController> _logger;
public MaintenanceController(
IUnitOfWork unitOfWork,
IMapper mapper,
ITenantContext tenantContext,
ILogger<MaintenanceController> logger)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_tenantContext = tenantContext;
_logger = logger;
}
@@ -740,7 +743,8 @@ public class MaintenanceController : Controller
/// </summary>
private async Task PopulateViewBagAsync(int? selectedEquipmentId = null)
{
var equipment = await _unitOfWork.Equipment.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var equipment = await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId);
ViewBag.EquipmentList = new SelectList(
equipment.Where(e => e.IsActive).OrderBy(e => e.EquipmentName),
"Id",
@@ -179,8 +179,9 @@ public class OvenSchedulerController : Controller
public async Task<IActionResult> Suggest([FromBody] SuggestRequest req)
{
var goal = req?.OptimizationGoal ?? "maximize_throughput";
var suggestCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var equipmentList = (await _unitOfWork.OvenCosts.GetAllAsync())
var equipmentList = (await _unitOfWork.OvenCosts.FindAsync(o => o.CompanyId == suggestCompanyId))
.Where(o => o.IsActive)
.OrderBy(o => o.DisplayOrder).ThenBy(o => o.Label)
.ToList();
@@ -188,10 +189,11 @@ public class OvenSchedulerController : Controller
if (!equipmentList.Any())
return Json(new { success = false, error = "No active ovens found. Add Named Ovens in Settings → Operating Costs." });
var companyCosts = await _unitOfWork.CompanyOperatingCosts.GetAllAsync();
var companyCosts = await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == suggestCompanyId);
var defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45;
var queueJobs = (await _unitOfWork.Jobs.GetAllAsync(
var queueJobs = (await _unitOfWork.Jobs.FindAsync(
j => j.CompanyId == suggestCompanyId,
false,
j => j.Customer,
j => j.JobStatus,
@@ -265,7 +267,8 @@ public class OvenSchedulerController : Controller
if (req?.Batches == null || !req.Batches.Any())
return Json(new { success = false, error = "No batches provided." });
var companyCosts = await _unitOfWork.CompanyOperatingCosts.GetAllAsync();
var acceptCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var companyCosts = await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == acceptCompanyId);
var defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45;
var createdBatches = new List<object>();
@@ -357,7 +360,8 @@ public class OvenSchedulerController : Controller
if (oven == null)
return Json(new { success = false, error = "Oven not found." });
var companyCosts = await _unitOfWork.CompanyOperatingCosts.GetAllAsync();
var createBatchCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var companyCosts = await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == createBatchCompanyId);
var defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45;
var batchNumber = await GenerateBatchNumberAsync();
@@ -651,7 +655,8 @@ public class OvenSchedulerController : Controller
if (inOvenStatus != null)
{
var jobIds = batch.Items.Select(i => i.JobId).Distinct().ToHashSet();
var jobs = (await _unitOfWork.Jobs.GetAllAsync()).Where(j => jobIds.Contains(j.Id));
var startBatchCid = _tenantContext.GetCurrentCompanyId() ?? 0;
var jobs = await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == startBatchCid && jobIds.Contains(j.Id));
foreach (var job in jobs)
job.JobStatusId = inOvenStatus.Id;
}
@@ -14,12 +14,14 @@ public class PricingTiersController : Controller
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
private readonly ILogger<PricingTiersController> _logger;
private readonly ITenantContext _tenantContext;
public PricingTiersController(IUnitOfWork unitOfWork, IMapper mapper, ILogger<PricingTiersController> logger)
public PricingTiersController(IUnitOfWork unitOfWork, IMapper mapper, ILogger<PricingTiersController> logger, ITenantContext tenantContext)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_logger = logger;
_tenantContext = tenantContext;
}
/// <summary>
@@ -27,8 +29,9 @@ public class PricingTiersController : Controller
/// </summary>
public async Task<IActionResult> Index()
{
var tiers = await _unitOfWork.PricingTiers.GetAllAsync();
var customers = await _unitOfWork.Customers.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var tiers = await _unitOfWork.PricingTiers.FindAsync(t => t.CompanyId == companyId);
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
var customerCountByTier = customers
.Where(c => c.PricingTierId.HasValue)
@@ -1,4 +1,5 @@
using AutoMapper;
using System.Text.Json;
using AutoMapper;
using Microsoft.Extensions.Configuration;
using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Authorization;
@@ -254,7 +255,7 @@ public class QuotesController : Controller
// Calibration nudge — suppress when named blast setups exist OR legacy CFM is set
var costs = (await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId)).FirstOrDefault();
var hasNamedSetups = (await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive)).Any();
var hasNamedSetups = (await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive && b.CompanyId == companyId)).Any();
ViewBag.QuotingNotCalibrated = costs != null
&& !hasNamedSetups
&& costs.CompressorCfm == 0
@@ -440,7 +441,7 @@ public class QuotesController : Controller
ViewBag.Deposits = quoteDeposits;
// Customer list for inline customer-change dropdown
var allCustomers = await _unitOfWork.Customers.GetAllAsync();
var allCustomers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == quote.CompanyId);
ViewBag.CustomerSelectList = allCustomers
.Where(c => c.IsActive)
.Select(c => new SelectListItem
@@ -2429,7 +2430,7 @@ public class QuotesController : Controller
ViewBag.QuotePhotosEnabled = quotePhotoMax != 0; // 0 = feature disabled for this plan
// Customers
var customers = await _unitOfWork.Customers.GetAllAsync();
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
ViewBag.Customers = customers
.Select(c => new SelectListItem
{
@@ -2470,7 +2471,7 @@ public class QuotesController : Controller
}
// Inventory coatings — include incoming items so they can be quoted while powder is in transit
var inventory = await _unitOfWork.InventoryItems.GetAllAsync(false, i => i.InventoryCategory);
var inventory = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId, false, i => i.InventoryCategory);
ViewBag.InventoryCoatings = inventory
.Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating)
.OrderBy(i => i.IsIncoming ? 1 : 0).ThenBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name)
@@ -2491,13 +2492,13 @@ public class QuotesController : Controller
}).ToList();
// Vendors
var vendors = await _unitOfWork.Vendors.GetAllAsync(false);
var vendors = await _unitOfWork.Vendors.FindAsync(s => s.CompanyId == companyId, false);
ViewBag.Vendors = vendors
.Where(s => s.IsActive).OrderBy(s => s.CompanyName)
.Select(s => new { value = s.Id.ToString(), text = s.CompanyName }).ToList();
// Catalog items
var catalogItems = await _unitOfWork.CatalogItems.GetAllAsync(false, i => i.Category, i => i.Category.ParentCategory);
var catalogItems = await _unitOfWork.CatalogItems.FindAsync(i => i.CompanyId == companyId, false, i => i.Category, i => i.Category.ParentCategory);
ViewBag.CatalogItems = catalogItems
.Where(i => i.IsActive)
.OrderBy(i => i.Category.DisplayOrder).ThenBy(i => i.DisplayOrder)
@@ -2527,11 +2528,11 @@ public class QuotesController : Controller
}).ToList();
// Prep services
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive);
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive && ps.CompanyId == companyId);
ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList();
// Blast setups for wizard dropdown
var blastSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive);
var blastSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive && b.CompanyId == companyId);
ViewBag.BlastSetups = blastSetups.OrderBy(b => b.DisplayOrder)
.Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault })
.ToList();
@@ -2598,7 +2599,8 @@ public class QuotesController : Controller
/// </summary>
private async Task PopulatePricingTiersDropDownAsync()
{
var pricingTiers = await _unitOfWork.PricingTiers.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var pricingTiers = await _unitOfWork.PricingTiers.FindAsync(pt => pt.CompanyId == companyId);
ViewBag.PricingTiers = pricingTiers.OrderBy(pt => pt.TierName)
.Select(pt => new SelectListItem
{
@@ -2824,9 +2826,9 @@ public class QuotesController : Controller
// Do NOT assign fullItems to quote.QuoteItems — quote is a tracked entity and assigning
// no-tracking children (which may share InventoryItem instances) causes EF identity conflicts.
// Get default job statuses and priorities
var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
// Get default job statuses and priorities — scope to quote's company for defense-in-depth
var jobStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == quote.CompanyId);
var jobPriorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == quote.CompanyId);
var approvedStatus = jobStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Approved);
var normalPriority = jobPriorities.FirstOrDefault(p => p.PriorityCode == "NORMAL");
var rushPriority = jobPriorities.FirstOrDefault(p => p.PriorityCode == "RUSH");
@@ -2839,14 +2841,47 @@ public class QuotesController : Controller
JobNumber = await GenerateJobNumberAsync(),
CustomerId = quote.CustomerId ?? 0, // Should always have a customer by approval time
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}",
JobStatusId = approvedStatus?.Id ?? 1,
JobPriorityId = selectedPriority?.Id ?? 1,
QuotedPrice = quote.Total,
FinalPrice = quote.Total,
OvenBatchCost = quote.OvenBatchCost,
ShopSuppliesAmount = quote.ShopSuppliesAmount,
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,
InternalNotes = quote.Notes, // Copy internal notes from quote
IsCustomerApproved = true,
@@ -3313,7 +3348,7 @@ public class QuotesController : Controller
CompanyBlastSetup? selectedBlastSetup = null;
if (request.BlastSetupId.HasValue)
{
var setups = await _unitOfWork.BlastSetups.FindAsync(b => b.Id == request.BlastSetupId.Value && b.IsActive);
var setups = await _unitOfWork.BlastSetups.FindAsync(b => b.Id == request.BlastSetupId.Value && b.IsActive && b.CompanyId == companyId);
selectedBlastSetup = setups.FirstOrDefault();
}
@@ -44,7 +44,8 @@ public class RecurringTemplatesController : Controller
/// <summary>Lists all recurring templates for the current company, active first then by name.</summary>
public async Task<IActionResult> Index()
{
var templates = await _unitOfWork.RecurringTemplates.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var templates = await _unitOfWork.RecurringTemplates.FindAsync(t => t.CompanyId == companyId);
return View(templates.OrderByDescending(t => t.IsActive).ThenBy(t => t.Name).ToList());
}
@@ -425,11 +426,12 @@ public class RecurringTemplatesController : Controller
/// <summary>Loads dropdowns for vendors, accounts, and payment methods into ViewBag.</summary>
private async Task PopulateDropDownsAsync()
{
var vendors = await _unitOfWork.Vendors.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId);
ViewBag.Vendors = vendors.OrderBy(v => v.CompanyName)
.Select(v => new SelectListItem(v.CompanyName, v.Id.ToString())).ToList();
var accounts = await _unitOfWork.Accounts.GetAllAsync();
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId);
ViewBag.APAccounts = accounts
.Where(a => a.AccountSubType == AccountSubType.AccountsPayable)
.OrderBy(a => a.AccountNumber)
@@ -11,6 +11,7 @@ using PowderCoating.Core.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Shared.Constants;
using PowderCoating.Web.ViewModels.Reports;
using System.Security.Claims;
namespace PowderCoating.Web.Controllers;
@@ -25,8 +26,9 @@ public class ReportsController : Controller
private readonly UserManager<ApplicationUser> _userManager;
private readonly IAccountingAiService _accountingAi;
private readonly IAiUsageLogger _usageLogger;
private readonly ITenantContext _tenantContext;
public ReportsController(IUnitOfWork unitOfWork, ILogger<ReportsController> logger, IFinancialReportService financialReports, IOperationalReportService operationalReports, IPdfService pdfService, UserManager<ApplicationUser> userManager, IAccountingAiService accountingAi, IAiUsageLogger usageLogger)
public ReportsController(IUnitOfWork unitOfWork, ILogger<ReportsController> logger, IFinancialReportService financialReports, IOperationalReportService operationalReports, IPdfService pdfService, UserManager<ApplicationUser> userManager, IAccountingAiService accountingAi, IAiUsageLogger usageLogger, ITenantContext tenantContext)
{
_unitOfWork = unitOfWork;
_logger = logger;
@@ -36,6 +38,7 @@ public class ReportsController : Controller
_userManager = userManager;
_accountingAi = accountingAi;
_usageLogger = usageLogger;
_tenantContext = tenantContext;
}
/// <summary>
@@ -79,27 +82,26 @@ public class ReportsController : Controller
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
"MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Load only necessary data - optimized with filtering and minimal eager loading
// Jobs: Load all jobs (we need various status filters and the collection is needed for job status distribution)
// Note: Date filtering would exclude data needed for jobsByStatus calculation
var jobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority, j => j.AssignedUser)).ToList();
// Load only necessary data — all explicitly scoped to this company
var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority, j => j.AssignedUser)).ToList();
// Quotes: Load all quotes (needed for quote status distribution and conversion funnel)
var quotes = (await _unitOfWork.Quotes.GetAllAsync(false, q => q.Customer, q => q.QuoteStatus)).ToList();
var quotes = (await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId, false, q => q.Customer, q => q.QuoteStatus)).ToList();
// Customers: Load all (needed for active count and customer creation trend across all months)
var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList();
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList();
// Equipment: Load all for status distribution
var equipment = (await _unitOfWork.Equipment.GetAllAsync()).ToList();
var equipment = (await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId)).ToList();
// Inventory: Load all for low stock analysis
var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
// Appointments: Filter to relevant date range at DB level
var appointments = (await _unitOfWork.Appointments.FindAsync(
a => a.ScheduledStartTime >= startDate,
a => a.CompanyId == companyId && a.ScheduledStartTime >= startDate,
false,
a => a.Customer,
a => a.AppointmentType,
@@ -108,7 +110,7 @@ public class ReportsController : Controller
// Users with assigned jobs/appointments will be loaded below when building worker stats
// CatalogItems: Load all for category distribution
var catalogItems = (await _unitOfWork.CatalogItems.GetAllAsync(false, c => c.Category)).ToList();
var catalogItems = (await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == companyId, false, c => c.Category)).ToList();
// === OVERVIEW METRICS ===
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
@@ -382,7 +384,7 @@ public class ReportsController : Controller
.ToDictionary(g => g.Key, g => g.Count());
// === FINANCIAL ANALYTICS ===
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
var totalInvoiced = activeInvoices.Sum(i => i.Total);
@@ -781,7 +783,7 @@ public class ReportsController : Controller
// === POWDER CONSUMPTION VS PURCHASE ===
var allInventoryTransactions = (await _unitOfWork.InventoryTransactions
.GetAllAsync(false, t => t.InventoryItem))
.FindAsync(t => t.CompanyId == companyId, false, t => t.InventoryItem))
.ToList();
var powderConsumptionItems = allInventoryTransactions
@@ -1309,14 +1311,15 @@ public class ReportsController : Controller
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
"MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var jobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).ToList();
var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList();
var quotes = (await _unitOfWork.Quotes.GetAllAsync(false, q => q.QuoteStatus)).ToList();
var equipment = (await _unitOfWork.Equipment.GetAllAsync()).ToList();
var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
var allAppointments = await _unitOfWork.Appointments.GetAllAsync(false, a => a.AppointmentStatus);
var appointments = allAppointments.Where(a => a.ScheduledStartTime >= startDate).ToList();
var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).ToList();
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList();
var quotes = (await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId, false, q => q.QuoteStatus)).ToList();
var equipment = (await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId)).ToList();
var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
var allAppointments = await _unitOfWork.Appointments.FindAsync(a => a.CompanyId == companyId && a.ScheduledStartTime >= startDate, false, a => a.AppointmentStatus);
var appointments = allAppointments.ToList();
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
var activeJobs = jobs.Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
@@ -1384,7 +1387,8 @@ public class ReportsController : Controller
var now = DateTime.UtcNow;
var startDate = now.AddMonths(-months);
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var jobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).ToList();
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
var inRange = completedJobs.Where(j => j.UpdatedAt >= startDate).ToList();
var byMonth = inRange.GroupBy(j => new DateTime(j.UpdatedAt!.Value.Year, j.UpdatedAt.Value.Month, 1)).ToDictionary(g => g.Key, g => g.ToList());
@@ -1430,12 +1434,13 @@ public class ReportsController : Controller
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
"MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var jobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.JobStatus, j => j.JobPriority, j => j.AssignedUser)).ToList();
var equipment = (await _unitOfWork.Equipment.GetAllAsync()).ToList();
var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
var allAppts = await _unitOfWork.Appointments.GetAllAsync(false, a => a.AppointmentType, a => a.AppointmentStatus);
var appointments = allAppts.Where(a => a.ScheduledStartTime >= startDate).ToList();
var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.JobStatus, j => j.JobPriority, j => j.AssignedUser)).ToList();
var equipment = (await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId)).ToList();
var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
var allAppts = await _unitOfWork.Appointments.FindAsync(a => a.CompanyId == companyId && a.ScheduledStartTime >= startDate, false, a => a.AppointmentType, a => a.AppointmentStatus);
var appointments = allAppts.ToList();
var activeJobs = jobs.Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
@@ -1483,10 +1488,11 @@ public class ReportsController : Controller
var now = DateTime.UtcNow;
var startDate = now.AddMonths(-months);
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList();
var quotes = (await _unitOfWork.Quotes.GetAllAsync(false, q => q.QuoteStatus)).ToList();
var catalogItems = (await _unitOfWork.CatalogItems.GetAllAsync(false, c => c.Category)).ToList();
var completedJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority))
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList();
var quotes = (await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId, false, q => q.QuoteStatus)).ToList();
var catalogItems = (await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == companyId, false, c => c.Category)).ToList();
var completedJobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority))
.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
var customersByMonth = customers.Where(c => c.CreatedAt >= startDate).GroupBy(c => new DateTime(c.CreatedAt.Year, c.CreatedAt.Month, 1)).ToDictionary(g => g.Key, g => g.Count());
@@ -1523,7 +1529,8 @@ public class ReportsController : Controller
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
var now = DateTime.UtcNow;
var today = DateTime.Today;
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
var outstandingInvoices = activeInvoices.Where(i => i.Status != InvoiceStatus.Paid && i.Total > i.AmountPaid).ToList();
var overdueInvoices = activeInvoices.Where(i => i.Status != InvoiceStatus.Paid && i.DueDate.HasValue && i.DueDate.Value < today).ToList();
@@ -1574,7 +1581,8 @@ public class ReportsController : Controller
var monthLabels = new List<string>(); var monthlyBillsPaid = new List<decimal>(); var monthlyDirectExpenses = new List<decimal>();
// Also load collected payments for P&L comparison
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Payments)).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Payments)).ToList();
var paymentsByMonth = allInvoices.SelectMany(i => i.Payments.Where(p => !p.IsDeleted)).GroupBy(p => new DateTime(p.PaymentDate.Year, p.PaymentDate.Month, 1)).ToDictionary(g => g.Key, g => g.ToList());
var plRevenue = new List<decimal>(); var plExpenses = new List<decimal>(); var plNet = new List<decimal>();
for (var i = months - 1; i >= 0; i--)
@@ -1609,8 +1617,10 @@ public class ReportsController : Controller
{
var now = DateTime.UtcNow;
var startDate = now.AddMonths(-months);
var powderTransactions = (await _unitOfWork.InventoryTransactions.GetAllAsync(false, t => t.InventoryItem))
.Where(t => t.TransactionType == InventoryTransactionType.JobUsage && t.TransactionDate >= startDate).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var powderTransactions = (await _unitOfWork.InventoryTransactions.FindAsync(
t => t.CompanyId == companyId && t.TransactionType == InventoryTransactionType.JobUsage && t.TransactionDate >= startDate, false, t => t.InventoryItem))
.ToList();
var topColors = powderTransactions.Where(t => t.InventoryItem != null).GroupBy(t => t.InventoryItemId)
.Select(g => new PowderUsageByColorItem { InventoryItemId = g.Key, ColorName = g.First().InventoryItem!.ColorName ?? g.First().InventoryItem.Name, ColorCode = g.First().InventoryItem!.ColorCode, SKU = g.First().InventoryItem!.SKU, Manufacturer = g.First().InventoryItem!.Manufacturer, TotalLbsUsed = g.Sum(t => Math.Abs(t.Quantity)), TotalCost = g.Sum(t => Math.Abs(t.TotalCost)), JobCount = g.Where(t => !string.IsNullOrEmpty(t.Reference)).Select(t => t.Reference).Distinct().Count() })
@@ -1631,7 +1641,8 @@ public class ReportsController : Controller
/// <summary>Sales by Customer report — all active (non-voided) invoices grouped by customer, sorted by total invoiced.</summary>
public async Task<IActionResult> SalesByCustomer(int months = 6)
{
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
var items = activeInvoices.Where(i => i.Customer != null)
.GroupBy(i => new { i.CustomerId, Name = i.Customer!.IsCommercial ? i.Customer.CompanyName : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(), i.Customer.IsCommercial })
@@ -1650,8 +1661,9 @@ public class ReportsController : Controller
{
var now = DateTime.UtcNow;
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList();
var completedJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList();
var completedJobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
var items = customers.Where(c => c.IsActive).Select(c =>
{
var cJobs = completedJobs.Where(j => j.CustomerId == c.Id).ToList();
@@ -1682,7 +1694,8 @@ public class ReportsController : Controller
{
var now = DateTime.UtcNow;
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var completedJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode) && j.CompletedDate.HasValue).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var completedJobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode) && j.CompletedDate.HasValue).ToList();
var allStatusHistory = await _operationalReports.GetAllJobStatusHistoryAsync();
var historyByJob = allStatusHistory.GroupBy(h => h.JobId).ToDictionary(g => g.Key, g => g.OrderBy(h => h.ChangedDate).ToList());
var statusDisplayOrder = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK" };
@@ -1720,7 +1733,8 @@ public class ReportsController : Controller
var now = DateTime.UtcNow;
var today = DateTime.Today;
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
var activeJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var activeJobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
var items = activeJobs.Select(j => new JobStatusAgingItem
{
JobId = j.Id, JobNumber = j.JobNumber, CustomerName = j.Customer?.IsCommercial == true ? j.Customer.CompanyName ?? "Unknown" : $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim(),
@@ -1740,7 +1754,8 @@ public class ReportsController : Controller
{
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
var today = DateTime.Today;
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
var items = allInvoices.Where(i => i.Customer != null && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff && i.Status != InvoiceStatus.Paid)
.Select(i =>
{
@@ -1758,7 +1773,8 @@ public class ReportsController : Controller
/// </summary>
public async Task<IActionResult> PowderConsumption(int months = 6)
{
var allTx = (await _unitOfWork.InventoryTransactions.GetAllAsync(false, t => t.InventoryItem)).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allTx = (await _unitOfWork.InventoryTransactions.FindAsync(t => t.CompanyId == companyId, false, t => t.InventoryItem)).ToList();
var items = allTx.Where(t => t.InventoryItem != null)
.GroupBy(t => new { t.InventoryItemId, t.InventoryItem!.Name, t.InventoryItem.SKU, t.InventoryItem.ColorName, t.InventoryItem.ColorCode, t.InventoryItem.Manufacturer })
.Select(g => new PowderConsumptionItem { InventoryItemId = g.Key.InventoryItemId, ItemName = g.Key.Name, SKU = g.Key.SKU, ColorName = g.Key.ColorName, ColorCode = g.Key.ColorCode, Manufacturer = g.Key.Manufacturer, TotalPurchasedLbs = g.Where(t => t.TransactionType == InventoryTransactionType.Purchase || t.TransactionType == InventoryTransactionType.Initial).Sum(t => t.Quantity), TotalConsumedLbs = g.Where(t => t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste).Sum(t => Math.Abs(t.Quantity)), PurchaseCount = g.Count(t => t.TransactionType == InventoryTransactionType.Purchase), UsageJobCount = g.Where(t => t.TransactionType == InventoryTransactionType.JobUsage && !string.IsNullOrEmpty(t.Reference)).Select(t => t.Reference).Distinct().Count() })
@@ -1776,8 +1792,9 @@ public class ReportsController : Controller
public async Task<IActionResult> InventoryTurnover(int months = 6)
{
var daysInPeriod = months * 30.0;
var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
var allTx = (await _unitOfWork.InventoryTransactions.GetAllAsync(false, t => t.InventoryItem)).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
var allTx = (await _unitOfWork.InventoryTransactions.FindAsync(t => t.CompanyId == companyId, false, t => t.InventoryItem)).ToList();
var items = inventory.Where(i => i.IsActive).Select(i =>
{
var iTx = allTx.Where(t => t.InventoryItemId == i.Id).ToList();
@@ -1835,8 +1852,9 @@ public class ReportsController : Controller
var now = DateTime.UtcNow;
var today = DateTime.Today;
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Load invoices for AR data
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
var outstandingInvoices = activeInvoices.Where(i => i.BalanceDue > 0 && i.Status != InvoiceStatus.Paid).ToList();
@@ -1930,13 +1948,14 @@ public class ReportsController : Controller
var companyName = await GetCompanyNameAsync();
var today = DateTime.Today;
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Open AR invoices
var openInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments))
var openInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments))
.Where(i => i.BalanceDue > 0 && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff && i.Status != InvoiceStatus.Paid)
.ToList();
// Compute avg days to pay per customer from paid invoices
var paidInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Payments))
var paidInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Payments))
.Where(i => i.Status == InvoiceStatus.Paid && i.InvoiceDate != default)
.ToList();
var avgDaysByCustomer = paidInvoices
@@ -2137,7 +2156,8 @@ public class ReportsController : Controller
var companyName = await GetCompanyNameAsync();
var today = DateTime.Today;
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
var activeInvoices = allInvoices.Where(i =>
i.Status != InvoiceStatus.Voided &&
i.Status != InvoiceStatus.WrittenOff).ToList();
@@ -2256,8 +2276,9 @@ public class ReportsController : Controller
var companyName = await GetCompanyNameAsync();
var now = DateTime.UtcNow;
var startOfYear = new DateTime(now.Year, 1, 1);
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments))
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments))
.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff)
.ToList();
@@ -15,11 +15,13 @@ namespace PowderCoating.Web.Controllers;
public class SmsConsentAuditController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ITenantContext _tenantContext;
private readonly ILogger<SmsConsentAuditController> _logger;
public SmsConsentAuditController(IUnitOfWork unitOfWork, ILogger<SmsConsentAuditController> logger)
public SmsConsentAuditController(IUnitOfWork unitOfWork, ITenantContext tenantContext, ILogger<SmsConsentAuditController> logger)
{
_unitOfWork = unitOfWork;
_tenantContext = tenantContext;
_logger = logger;
}
@@ -30,7 +32,8 @@ public class SmsConsentAuditController : Controller
{
try
{
var allCustomers = await _unitOfWork.Customers.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allCustomers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
if (!string.IsNullOrWhiteSpace(search))
{
@@ -98,7 +101,8 @@ public class SmsConsentAuditController : Controller
{
try
{
var customers = (await _unitOfWork.Customers.GetAllAsync())
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId))
.OrderBy(c => c.CompanyName ?? c.ContactLastName ?? c.ContactFirstName)
.ToList();
@@ -32,7 +32,8 @@ public class TaxRatesController : Controller
[HttpGet]
public async Task<IActionResult> Index()
{
var rates = await _unitOfWork.TaxRates.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var rates = await _unitOfWork.TaxRates.FindAsync(r => r.CompanyId == companyId);
return View(rates.OrderBy(r => r.Name).ToList());
}
@@ -87,7 +87,8 @@ public class ToolsController : Controller
[HttpGet]
public async Task<IActionResult> GetImportAccounts()
{
var allAccounts = await _unitOfWork.Accounts.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId);
var revenue = allAccounts
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive)
@@ -123,7 +124,8 @@ public class ToolsController : Controller
/// </summary>
private async Task PopulateImportAccountDropdownsAsync()
{
var allAccounts = await _unitOfWork.Accounts.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId);
var revenueAccounts = allAccounts
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive)
@@ -911,7 +913,7 @@ public class ToolsController : Controller
/// <c>CompanyId</c> provides the multi-tenant isolation that global query filters would
/// normally enforce for other entity types.
/// </summary>
// GET: Tools/GetShopWorkers - For randomizer wheel
// GET: Tools/GetShopWorkers - Returns active company users for randomizer wheel
[HttpGet]
public async Task<IActionResult> GetShopWorkers()
{
@@ -1102,7 +1104,7 @@ public class ToolsController : Controller
// Validate account IDs belong to this company — stale page load can produce IDs
// that were valid before a data reset but no longer exist.
var validAccountIds = (await _unitOfWork.Accounts.GetAllAsync())
var validAccountIds = (await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value))
.Select(a => a.Id).ToHashSet();
if (revenueAccountId.HasValue && !validAccountIds.Contains(revenueAccountId.Value))
revenueAccountId = null;
@@ -1167,7 +1169,7 @@ public class ToolsController : Controller
// Validate account IDs belong to this company — stale page load can produce IDs
// that were valid before a data reset but no longer exist.
var validAccountIds = (await _unitOfWork.Accounts.GetAllAsync())
var validAccountIds = (await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value))
.Select(a => a.Id).ToHashSet();
if (inventoryAccountId.HasValue && !validAccountIds.Contains(inventoryAccountId.Value))
inventoryAccountId = null;
@@ -1939,7 +1941,7 @@ public class ToolsController : Controller
using (var archive = new System.IO.Compression.ZipArchive(memoryStream, System.IO.Compression.ZipArchiveMode.Create, true))
{
// 1. Customers
var customers = await _unitOfWork.Customers.GetAllAsync();
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId.Value);
var customersCsv = GenerateCustomersCsv(customers);
var customersEntry = archive.CreateEntry($"customers_{timestamp}.csv");
using (var entryStream = customersEntry.Open())
@@ -1949,7 +1951,7 @@ public class ToolsController : Controller
}
// 2. Quotes
var quotes = await _unitOfWork.Quotes.GetAllAsync(false, q => q.Customer, q => q.QuoteStatus);
var quotes = await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId.Value, false, q => q.Customer, q => q.QuoteStatus);
var quotesCsv = GenerateQuotesCsv(quotes);
var quotesEntry = archive.CreateEntry($"quotes_{timestamp}.csv");
using (var entryStream = quotesEntry.Open())
@@ -1959,7 +1961,7 @@ public class ToolsController : Controller
}
// 3. Jobs
var jobs = await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority);
var jobs = await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId.Value, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority);
var jobsCsv = GenerateJobsCsv(jobs);
var jobsEntry = archive.CreateEntry($"jobs_{timestamp}.csv");
using (var entryStream = jobsEntry.Open())
@@ -1969,7 +1971,7 @@ public class ToolsController : Controller
}
// 4. Appointments
var appointments = await _unitOfWork.Appointments.GetAllAsync(false,
var appointments = await _unitOfWork.Appointments.FindAsync(a => a.CompanyId == companyId.Value, false,
a => a.Customer, a => a.AppointmentType, a => a.AppointmentStatus);
var appointmentsCsv = GenerateAppointmentsCsv(appointments);
var appointmentsEntry = archive.CreateEntry($"appointments_{timestamp}.csv");
@@ -1980,9 +1982,9 @@ public class ToolsController : Controller
}
// 5. Catalog
var catalogCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
var catalogCategories = await _unitOfWork.CatalogCategories.FindAsync(cc => cc.CompanyId == companyId.Value);
var catalogCategoryPaths = BuildCategoryPathMap(catalogCategories);
var catalog = await _unitOfWork.CatalogItems.GetAllAsync();
var catalog = await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == companyId.Value);
var catalogCsv = GenerateCatalogCsv(catalog, catalogCategoryPaths);
var catalogEntry = archive.CreateEntry($"catalog_{timestamp}.csv");
using (var entryStream = catalogEntry.Open())
@@ -1992,7 +1994,7 @@ public class ToolsController : Controller
}
// 6. Inventory
var inventory = await _unitOfWork.InventoryItems.GetAllAsync();
var inventory = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId.Value);
var inventoryCsv = GenerateInventoryCsv(inventory);
var inventoryEntry = archive.CreateEntry($"inventory_{timestamp}.csv");
using (var entryStream = inventoryEntry.Open())
@@ -2002,7 +2004,7 @@ public class ToolsController : Controller
}
// 7. Equipment
var equipment = await _unitOfWork.Equipment.GetAllAsync();
var equipment = await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId.Value);
var equipmentCsv = GenerateEquipmentCsv(equipment);
var equipmentEntry = archive.CreateEntry($"equipment_{timestamp}.csv");
using (var entryStream = equipmentEntry.Open())
@@ -2012,7 +2014,7 @@ public class ToolsController : Controller
}
// 8. Maintenance
var maintenance = await _unitOfWork.MaintenanceRecords.GetAllAsync(false, m => m.Equipment);
var maintenance = await _unitOfWork.MaintenanceRecords.FindAsync(m => m.CompanyId == companyId.Value, false, m => m.Equipment);
var maintenanceCsv = GenerateMaintenanceCsv(maintenance);
var maintenanceEntry = archive.CreateEntry($"maintenance_{timestamp}.csv");
using (var entryStream = maintenanceEntry.Open())
@@ -2022,7 +2024,7 @@ public class ToolsController : Controller
}
// 9. Vendors
var vendors = await _unitOfWork.Vendors.GetAllAsync();
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId.Value);
var vendorsCsv = GenerateVendorsCsv(vendors);
var vendorsEntry = archive.CreateEntry($"vendors_{timestamp}.csv");
using (var entryStream = vendorsEntry.Open())
@@ -2032,7 +2034,7 @@ public class ToolsController : Controller
}
// 10. Prep Services
var prepServices = await _unitOfWork.PrepServices.GetAllAsync();
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.CompanyId == companyId.Value);
var prepServicesCsv = GeneratePrepServicesCsv(prepServices);
var prepServicesEntry = archive.CreateEntry($"prep_services_{timestamp}.csv");
using (var entryStream = prepServicesEntry.Open())
@@ -2042,7 +2044,7 @@ public class ToolsController : Controller
}
// 11. Invoices
var invoices = await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Job);
var invoices = await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId.Value, false, i => i.Customer, i => i.Job);
var invoicesCsv = GenerateInvoicesCsv(invoices);
var invoicesEntry = archive.CreateEntry($"invoices_{timestamp}.csv");
using (var entryStream = invoicesEntry.Open())
@@ -2052,7 +2054,7 @@ public class ToolsController : Controller
}
// 12. Chart of Accounts
var accounts = await _unitOfWork.Accounts.GetAllAsync();
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value);
var accountsCsv = GenerateChartOfAccountsCsv(accounts);
var accountsEntry = archive.CreateEntry($"chart_of_accounts_{timestamp}.csv");
using (var entryStream = accountsEntry.Open())
@@ -2062,7 +2064,7 @@ public class ToolsController : Controller
}
// 13. Expenses
var expenses = await _unitOfWork.Expenses.GetAllAsync(false, e => e.ExpenseAccount, e => e.PaymentAccount, e => e.Vendor, e => e.Job);
var expenses = await _unitOfWork.Expenses.FindAsync(e => e.CompanyId == companyId.Value, false, e => e.ExpenseAccount, e => e.PaymentAccount, e => e.Vendor, e => e.Job);
var expensesCsv = GenerateExpensesCsv(expenses);
var expensesEntry = archive.CreateEntry($"expenses_{timestamp}.csv");
using (var entryStream = expensesEntry.Open())
@@ -2072,7 +2074,7 @@ public class ToolsController : Controller
}
// 14. Payments
var payments = await _unitOfWork.Payments.GetAllAsync(false, p => p.Invoice);
var payments = await _unitOfWork.Payments.FindAsync(p => p.CompanyId == companyId.Value, false, p => p.Invoice);
var paymentsCsv = GeneratePaymentsCsv(payments);
var paymentsEntry = archive.CreateEntry($"payments_{timestamp}.csv");
using (var entryStream = paymentsEntry.Open())
@@ -2258,9 +2260,9 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index));
}
var catalogCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
var catalogCategories = await _unitOfWork.CatalogCategories.FindAsync(cc => cc.CompanyId == companyId.Value);
var catalogCategoryPaths = BuildCategoryPathMap(catalogCategories);
var catalogItems = await _unitOfWork.CatalogItems.GetAllAsync();
var catalogItems = await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == companyId.Value);
var csv = GenerateCatalogCsv(catalogItems, catalogCategoryPaths);
var fileName = $"catalog_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2326,7 +2328,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index));
}
var equipment = await _unitOfWork.Equipment.GetAllAsync();
var equipment = await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId.Value);
var csv = GenerateEquipmentCsv(equipment);
var fileName = $"equipment_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2407,13 +2409,13 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index));
}
// Load all lookup tables
var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
var quoteStatuses = await _unitOfWork.QuoteStatusLookups.GetAllAsync();
var inventoryCategories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
var appointmentStatuses = await _unitOfWork.AppointmentStatusLookups.GetAllAsync();
var appointmentTypes = await _unitOfWork.AppointmentTypeLookups.GetAllAsync();
// Load all lookup tables — scoped to this company
var jobStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == companyId.Value);
var jobPriorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId.Value);
var quoteStatuses = await _unitOfWork.QuoteStatusLookups.FindAsync(s => s.CompanyId == companyId.Value);
var inventoryCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId.Value);
var appointmentStatuses = await _unitOfWork.AppointmentStatusLookups.FindAsync(s => s.CompanyId == companyId.Value);
var appointmentTypes = await _unitOfWork.AppointmentTypeLookups.FindAsync(t => t.CompanyId == companyId.Value);
var csv = GenerateCompanySettingsCsv(company, jobStatuses, jobPriorities,
quoteStatuses, inventoryCategories, appointmentStatuses, appointmentTypes);
@@ -4092,7 +4094,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index));
}
var prepServices = await _unitOfWork.PrepServices.GetAllAsync();
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.CompanyId == companyId.Value);
var csv = GeneratePrepServicesCsv(prepServices);
var fileName = $"prep_services_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -4124,7 +4126,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index));
}
var vendors = await _unitOfWork.Vendors.GetAllAsync();
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId.Value);
var csv = GenerateVendorsCsv(vendors);
var fileName = $"vendors_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -4156,7 +4158,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index));
}
var accounts = await _unitOfWork.Accounts.GetAllAsync();
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value);
var csv = GenerateChartOfAccountsCsv(accounts);
var fileName = $"chart_of_accounts_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -302,7 +302,7 @@ public static class HelpKnowledgeBase
**Changing the customer on a job:** On the Job Details page, the Customer field is an always-visible dropdown. Select a different customer a confirmation banner appears. Click **Save** to apply or **Cancel** to revert. Use this to correct a misassigned job or to move a walk-in job to a customer's proper record after they've been added to the system.
**Creating an invoice from a job:** On the Job Details page, look for the Invoice section and click "Create Invoice."
**Creating an invoice from a job:** On the Job Details page, look for the Invoice section and click "Create Invoice." The system pre-fills all line items, pricing, discount, tax rate, payment terms, and due date from the job and customer automatically. Review the Totals panel on the right if a discount was applied to the job it will show as a red "Discount Applied" line. Adjust anything you need, then save.
**Work Order QR Codes:** Every printed job work order includes two tiers of QR codes one for viewing the job, and a separate set for taking action on it. All QR codes require the worker to be logged in.
@@ -314,6 +314,8 @@ public static class HelpKnowledgeBase
All QR codes require login workers must have an active account. Logging in once on their phone is sufficient for the session.
**Logging material usage from a PC (without QR scan):** On the Job Details page, expand the Materials Used section and click **Log Material**. A modal opens where you can: select any inventory item from a dropdown (current stock level shown), choose whether to enter the amount used or the amount remaining (the system calculates usage automatically), pick a reason (Job Usage or Waste/Spillage), and add optional notes. Saves immediately and updates inventory on hand.
**Blank Work Order:** Print a pre-formatted paper work order to hand to a walk-in customer before creating a digital job record.
- Access: Jobs list page printer icon button "Blank Work Order" in the top-right toolbar. Or navigate directly to /WorkOrder/Blank.
- The PDF opens in a new tab ready to print. It includes: company logo and address, Drop Off Date field, Client Name / Client Phone / Due Date fields, 12-row parts table (Part Description / Color / Quote), Notes box, customizable Terms & Conditions text, and a Customer Signature line.
@@ -1219,7 +1221,6 @@ public static class HelpKnowledgeBase
- [Accounts Payable](/Help/AccountsPayable)
- [Equipment & Maintenance](/Help/Equipment)
- [Vendors](/Help/Vendors)
- [Shop Workers](/Help/ShopWorkers)
- [Reports](/Help/Reports)
- [Settings](/Help/Settings)
- [User Profile](/Help/UserProfile)
@@ -50,6 +50,12 @@ public class OnlineUserMiddleware
{
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
if (!context.User.Identity?.IsAuthenticated ?? true) return;
var path = context.Request.Path.Value ?? string.Empty;
+1 -2
View File
@@ -270,8 +270,7 @@ builder.Services.AddSingleton<IMapper>(sp =>
cfg.AddProfile(new InventoryProfile());
cfg.AddProfile(new EquipmentProfile());
cfg.AddProfile(new MaintenanceProfile());
cfg.AddProfile(new ShopWorkerProfile());
cfg.AddProfile(new CatalogProfile());
cfg.AddProfile(new CatalogProfile());
cfg.AddProfile(new VendorProfile());
cfg.AddProfile(new LookupProfile());
cfg.AddProfile(new AppointmentProfile());
@@ -232,4 +232,3 @@
});
</script>
}
@@ -109,6 +109,69 @@
<span class="fw-semibold">Per-Company Breakdown</span>
<span class="text-muted small">@Model.Rows.Count companies total</span>
</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">
<table class="table table-hover mb-0 align-middle" id="aiUsageTable">
<thead class="table-light">
@@ -176,6 +176,60 @@
<div class="card-body">
@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">
<table class="table table-hover align-middle">
<thead>
@@ -21,6 +21,64 @@
<div class="card shadow-sm">
<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">
<table class="table table-hover mb-0">
<thead class="table-light">
@@ -60,6 +60,59 @@
<div class="card-body p-0">
@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">
<table class="table table-hover mb-0">
<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>
</div>
<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">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
@@ -4,7 +4,7 @@
ViewData["Title"] = "Edit Bill";
ViewData["PageIcon"] = "bi-pencil-square";
ViewData["PageHelpTitle"] = "Edit Bill";
ViewData["PageHelpContent"] = "Bills can only be edited while in Draft status. Once marked Open, they are locked — Void the bill and recreate it if corrections are needed after confirmation.";
ViewData["PageHelpContent"] = "Bills can only be edited while in Draft status. Once marked Open, they are locked Void the bill and recreate it if corrections are needed after confirmation.";
}
<div class="d-flex justify-content-start mb-4">
@@ -24,7 +24,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
data-bs-title="Bill Details"
data-bs-content="Vendor: who you're paying. AP Account: the liability account this bill posts to (e.g. Accounts Payable). Bill Date: date on the vendor's invoice. Due Date: when payment is due — drives overdue status. Vendor Invoice #: the vendor's own reference number for reconciliation.">
data-bs-content="Vendor: who you're paying. AP Account: the liability account this bill posts to (e.g. Accounts Payable). Bill Date: date on the vendor's invoice. Due Date: when payment is due drives overdue status. Vendor Invoice #: the vendor's own reference number for reconciliation.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -34,8 +34,8 @@
<label asp-for="VendorId" class="form-label fw-medium">Vendor <span class="text-danger">*</span></label>
<select asp-for="VendorId" asp-items="ViewBag.Vendors" class="form-select"
data-quick-add-url="/Vendors/Create" data-quick-add-title="Add New Vendor">
<option value="">— Select Vendor —</option>
<option value="__new__">+ Add New Vendor…</option>
<option value=""> Select Vendor </option>
<option value="__new__">+ Add New Vendor</option>
</select>
</div>
<div class="col-md-6">
@@ -87,7 +87,7 @@
}
<input type="file" name="receiptFile" id="receiptFile" class="form-control"
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf" />
<div class="form-text">JPG, PNG, GIF, WebP, or PDF — up to 10 MB.</div>
<div class="form-text">JPG, PNG, GIF, WebP, or PDF up to 10 MB.</div>
</div>
</div>
</div>
@@ -100,7 +100,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
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>
</a>
</div>
@@ -134,7 +134,7 @@
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="left" data-bs-trigger="focus"
data-bs-title="Bill Summary"
data-bs-content="Tax % is applied to the line-item subtotal. The resulting Total is the full amount owed to the vendor. Partial payments are allowed — each payment recorded reduces the balance due until the bill is fully paid.">
data-bs-content="Tax % is applied to the line-item subtotal. The resulting Total is the full amount owed to the vendor. Partial payments are allowed each payment recorded reduces the balance due until the bill is fully paid.">
<i class="bi bi-question-circle"></i>
</a>
</div>
@@ -171,7 +171,7 @@
<tr class="line-item-row">
<td>
<select class="form-select form-select-sm account-select" name="LineItems[INDEX].AccountId" required>
<option value="">— Account —</option>
<option value=""> Account </option>
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.ExpenseAccounts)
{
<option value="@item.Value">@item.Text</option>
@@ -181,7 +181,7 @@
<td><input type="text" class="form-control form-control-sm" name="LineItems[INDEX].Description" placeholder="Description" /></td>
<td>
<select class="form-select form-select-sm" name="LineItems[INDEX].JobId">
<option value="">—</option>
<option value=""></option>
@foreach (var item in (IEnumerable<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem>)ViewBag.Jobs)
{
<option value="@item.Value">@item.Text</option>
@@ -1,4 +1,4 @@
@model PowderCoating.Application.DTOs.Company.CompanySettingsDto
@model PowderCoating.Application.DTOs.Company.CompanySettingsDto
@{
ViewData["Title"] = "Company Settings";
ViewData["PageIcon"] = "bi-building";
@@ -344,28 +344,35 @@
<!-- Operating Costs Tab -->
<div class="tab-pane fade" id="operating-costs" role="tabpanel">
<div class="card mt-3">
<div class="card-body">
<h5 class="card-title">Operating Costs Configuration
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Operating Costs"
data-bs-content="These are the rates the quoting engine uses to price every job automatically. Set them to your real shop costs and the system will produce accurate quotes without manual calculation. &lt;strong&gt;New quotes use the current rates&lt;/strong&gt; — changing a rate here does not retroactively reprice existing quotes.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Settings#pricing-configuration' target='_blank'&gt;Learn more →&lt;/a&gt;">
<i class="bi bi-question-circle"></i>
</a>
</h5>
<p class="text-muted">Configure your operating costs for accurate job quoting calculations.</p>
<form id="operatingCostsForm">
<form id="operatingCostsForm">
<!-- Rates & Costs -->
<h6 class="border-bottom pb-2 mb-3">Rates &amp; Costs
<!-- Header -->
<div class="card mt-3">
<div class="card-body">
<h5 class="card-title mb-1">Operating Costs Configuration
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Rates &amp; Costs"
data-bs-content="&lt;strong&gt;Standard Labor Rate&lt;/strong&gt; is the baseline $/hr for all coating work — sandblasting and masking are multiplied from this. &lt;strong&gt;Powder Coating Cost/sq ft&lt;/strong&gt; is the fallback material rate used when you don't select a specific powder inventory item on a quote item. &lt;strong&gt;Additional Coat Labor&lt;/strong&gt; is the percentage of the base labor cost charged for each coat after the first (e.g. 30% means a 2nd coat adds 30% more labor).">
data-bs-title="Operating Costs"
data-bs-content="These are the rates the quoting engine uses to price every job automatically. Set them to your real shop costs and the system will produce accurate quotes without manual calculation. &lt;strong&gt;New quotes use the current rates&lt;/strong&gt; — changing a rate here does not retroactively reprice existing quotes.&lt;br&gt;&lt;br&gt;&lt;a href='/Help/Settings#pricing-configuration' target='_blank'&gt;Learn more →&lt;/a&gt;">
<i class="bi bi-question-circle"></i>
</a>
</h6>
</h5>
<p class="text-muted mb-0">Configure your operating costs for accurate job quoting calculations.</p>
</div>
</div>
<!-- Rates & Costs -->
<div class="card mt-3 border-0 shadow-sm">
<div class="card-header bg-transparent fw-semibold">
<i class="bi bi-currency-dollar text-primary me-1"></i> Rates &amp; Costs
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Rates &amp; Costs"
data-bs-content="&lt;strong&gt;Standard Labor Rate&lt;/strong&gt; is the baseline $/hr for all coating work — sandblasting and masking are multiplied from this. &lt;strong&gt;Powder Coating Cost/sq ft&lt;/strong&gt; is the fallback material rate used when you don't select a specific powder inventory item on a quote item. &lt;strong&gt;Additional Coat Labor&lt;/strong&gt; is the percentage of the base labor cost charged for each coat after the first (e.g. 30% means a 2nd coat adds 30% more labor).">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3">
<div class="mb-3">
@@ -375,6 +382,18 @@
<input type="number" step="0.01" class="form-control" id="standardLaborRate" name="StandardLaborRate" value="@(Model.OperatingCosts?.StandardLaborRate ?? 0)" min="0" max="10000" required>
<span class="input-group-text">/hr</span>
</div>
<small class="text-muted">Billing rate used in quotes and pricing</small>
</div>
</div>
<div class="col-md-3">
<div class="mb-3">
<label for="laborCostPerHour" class="form-label">Shop Labor Cost Rate</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="number" step="0.01" class="form-control" id="laborCostPerHour" name="LaborCostPerHour" value="@(Model.OperatingCosts?.LaborCostPerHour?.ToString() ?? "")" min="0" max="10000" placeholder="@(((Model.OperatingCosts?.StandardLaborRate ?? 0) * 0.20m).ToString("0.00"))">
<span class="input-group-text">/hr</span>
</div>
<small class="text-muted">Actual wage cost for job costing &amp; profit display only &mdash; never shown to customers. Leave blank to default to 20% of billing rate.</small>
</div>
</div>
<div class="col-md-3">
@@ -418,16 +437,21 @@
</div>
</div>
</div>
</div>
</div>
<!-- Facility Overhead -->
<h6 class="border-bottom pb-2 mb-3 mt-3">Facility Overhead
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Facility Overhead"
data-bs-content="Enter your monthly shop rent and combined utility costs. The system divides these by your estimated billable hours to derive a per-hour overhead rate, which is then added to every quote proportionally to the estimated job time. This ensures fixed facility costs are recovered across all jobs rather than absorbed into your markup.">
<i class="bi bi-question-circle"></i>
</a>
</h6>
<!-- Facility Overhead -->
<div class="card mt-3 border-0 shadow-sm">
<div class="card-header bg-transparent fw-semibold">
<i class="bi bi-building text-primary me-1"></i> Facility Overhead
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Facility Overhead"
data-bs-content="Enter your monthly shop rent and combined utility costs. The system divides these by your estimated billable hours to derive a per-hour overhead rate, which is then added to every quote proportionally to the estimated job time. This ensures fixed facility costs are recovered across all jobs rather than absorbed into your markup.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="card-body">
<div class="row align-items-start">
<div class="col-md-3">
<div class="mb-3">
@@ -457,7 +481,7 @@
<input type="number" step="1" class="form-control facility-overhead-input" id="monthlyBillableHours" name="MonthlyBillableHours" value="@(Model.OperatingCosts?.MonthlyBillableHours ?? 160)" min="1" max="10000">
<span class="input-group-text">hrs</span>
</div>
<small class="text-muted">Typical: 160 hrs (4 wks × 40 hrs)</small>
<small class="text-muted">Typical: 160 hrs (4 wks &times; 40 hrs)</small>
</div>
</div>
<div class="col-md-3">
@@ -472,16 +496,21 @@
</div>
</div>
</div>
</div>
</div>
<!-- Equipment Operating Costs -->
<h6 class="border-bottom pb-2 mb-3 mt-3">Equipment Operating Costs
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Equipment Operating Costs"
data-bs-content="The hourly cost of running each piece of equipment, including energy and depreciation. These are added to quote items based on the prep services selected. The &lt;strong&gt;Default Oven Rate&lt;/strong&gt; is used on quotes where no named oven is chosen — add individual shop ovens below if you have multiple ovens with different capacities and costs.">
<i class="bi bi-question-circle"></i>
</a>
</h6>
<!-- Equipment Operating Costs -->
<div class="card mt-3 border-0 shadow-sm">
<div class="card-header bg-transparent fw-semibold">
<i class="bi bi-tools text-primary me-1"></i> Equipment Operating Costs
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Equipment Operating Costs"
data-bs-content="The hourly cost of running each piece of equipment, including energy and depreciation. These are added to quote items based on the prep services selected. The &lt;strong&gt;Default Oven Rate&lt;/strong&gt; is used on quotes where no named oven is chosen — add individual shop ovens below if you have multiple ovens with different capacities and costs.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4">
<div class="mb-3">
@@ -515,45 +544,21 @@
</div>
</div>
</div>
</div>
</div>
<!-- Role-Based Labor Rates -->
<h6 class="border-bottom pb-2 mb-3 mt-4">Role-Based Labor Cost Rates
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Role-Based Labor Cost Rates"
data-bs-content="Set an optional cost rate per worker role for job profitability calculations. These are your &lt;strong&gt;internal cost rates&lt;/strong&gt; (what you pay), not what you bill customers. If a rate is left blank, the &lt;strong&gt;Standard Labor Rate&lt;/strong&gt; above is used as the fallback.">
<i class="bi bi-question-circle"></i>
</a>
</h6>
<p class="text-muted small">Used for job costing only — not shown to customers. Leave blank to use the Standard Labor Rate.</p>
<div class="table-responsive mb-3">
<table class="table table-sm align-middle" id="roleCostTable">
<thead class="table-light">
<tr>
<th>Role</th>
<th style="width:180px;">Cost Rate / hr</th>
<th style="width:140px;" class="text-muted small">Fallback if blank</th>
</tr>
</thead>
<tbody id="roleCostBody">
<tr><td colspan="3" class="text-center text-muted py-2"><div class="spinner-border spinner-border-sm me-2"></div>Loading...</td></tr>
</tbody>
</table>
</div>
<button type="button" class="btn btn-sm btn-primary" onclick="saveRoleCosts()">
<i class="bi bi-floppy me-1"></i>Save Labor Rates
</button>
<span id="roleCostSaveStatus" class="ms-2 small"></span>
<!-- Pricing & Overhead -->
<h6 class="border-bottom pb-2 mb-3 mt-4">Pricing &amp; Profit
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Pricing &amp; Profit"
data-bs-content="&lt;strong&gt;Markup mode&lt;/strong&gt; adds a % on top of material costs only (labor and equipment pass through at cost). &lt;strong&gt;Margin mode&lt;/strong&gt; targets a gross margin % of the total selling price — e.g. 30% margin on a $100 cost base gives a $142.86 price. Note: margin % and markup % are not the same number. &lt;strong&gt;Shop Minimum&lt;/strong&gt; sets a floor price for any job.">
<i class="bi bi-question-circle"></i>
</a>
</h6>
<!-- Pricing & Profit -->
<div class="card mt-3 border-0 shadow-sm">
<div class="card-header bg-transparent fw-semibold">
<i class="bi bi-graph-up-arrow text-primary me-1"></i> Pricing &amp; Profit
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Pricing &amp; Profit"
data-bs-content="&lt;strong&gt;Markup mode&lt;/strong&gt; adds a % on top of material costs only (labor and equipment pass through at cost). &lt;strong&gt;Margin mode&lt;/strong&gt; targets a gross margin % of the total selling price — e.g. 30% margin on a $100 cost base gives a $142.86 price. Note: margin % and markup % are not the same number. &lt;strong&gt;Shop Minimum&lt;/strong&gt; sets a floor price for any job.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="card-body">
@{
var currentPricingMode = (int)(Model.OperatingCosts?.PricingMode ?? PowderCoating.Core.Enums.PricingMode.MarkupOnMaterial);
}
@@ -564,14 +569,14 @@
<input class="form-check-input" type="radio" name="pricingModeRadio" id="pricingModeMarkup" value="0"
@(currentPricingMode == 0 ? "checked" : "") onchange="onPricingModeChange()">
<label class="form-check-label" for="pricingModeMarkup">
<strong>Markup</strong> add % to material costs
<strong>Markup</strong> &mdash; add % to material costs
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="pricingModeRadio" id="pricingModeMargin" value="1"
@(currentPricingMode == 1 ? "checked" : "") onchange="onPricingModeChange()">
<label class="form-check-label" for="pricingModeMargin">
<strong>Margin</strong> target gross margin % of selling price
<strong>Margin</strong> &mdash; target gross margin % of selling price
</label>
</div>
</div>
@@ -609,16 +614,21 @@
</div>
</div>
</div>
</div>
</div>
<!-- Rush Charges -->
<h6 class="border-bottom pb-2 mb-3 mt-3">Rush Charges
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Rush Charges"
data-bs-content="When a quote is marked as a &lt;strong&gt;Rush Job&lt;/strong&gt;, this charge is automatically added to the total. Choose &lt;strong&gt;Percentage&lt;/strong&gt; to add a % of the subtotal (e.g. 25% rush surcharge) or &lt;strong&gt;Fixed Amount&lt;/strong&gt; to add a flat fee (e.g. $75 rush fee). The rush charge appears as its own line on the quote.">
<i class="bi bi-question-circle"></i>
</a>
</h6>
<!-- Rush Charges -->
<div class="card mt-3 border-0 shadow-sm">
<div class="card-header bg-transparent fw-semibold">
<i class="bi bi-lightning-charge text-primary me-1"></i> Rush Charges
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Rush Charges"
data-bs-content="When a quote is marked as a &lt;strong&gt;Rush Job&lt;/strong&gt;, this charge is automatically added to the total. Choose &lt;strong&gt;Percentage&lt;/strong&gt; to add a % of the subtotal (e.g. 25% rush surcharge) or &lt;strong&gt;Fixed Amount&lt;/strong&gt; to add a flat fee (e.g. $75 rush fee). The rush charge appears as its own line on the quote.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
@@ -628,7 +638,6 @@
<label class="btn btn-outline-primary" for="rushChargeTypePercentage">
<i class="bi bi-percent"></i> Percentage
</label>
<input type="radio" class="btn-check" name="rushChargeTypeRadio" id="rushChargeTypeFixed" value="FixedAmount" checked="@((Model.OperatingCosts?.RushChargeType) == "FixedAmount")">
<label class="btn btn-outline-primary" for="rushChargeTypeFixed">
<i class="bi bi-currency-dollar"></i> Fixed Amount
@@ -647,7 +656,6 @@
<small class="text-muted">Percentage of subtotal added for rush jobs</small>
</div>
</div>
<div id="rushChargeFixedInput" style="display: @((Model.OperatingCosts?.RushChargeType) == "FixedAmount" ? "block" : "none")">
<div class="mb-3">
<label for="rushChargeFixedAmount" class="form-label">Rush Charge Amount</label>
@@ -660,65 +668,66 @@
</div>
</div>
</div>
<!-- Part Complexity Multipliers -->
<div class="card mb-4 border-0 shadow-sm">
<div class="card-header bg-transparent fw-semibold">
<i class="bi bi-layers text-primary me-1"></i> Part Complexity Multipliers
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Part Complexity Multipliers"
data-bs-content="A percentage added to the price of &lt;strong&gt;calculated items&lt;/strong&gt; based on how intricate the part is. When adding an item in a quote, staff select a complexity level — the system then applies this multiplier to account for the extra time and care needed. &lt;em&gt;Simple&lt;/em&gt; = 0% (flat panels, basic shapes). &lt;em&gt;Extreme&lt;/em&gt; = highly detailed, tight recesses, masking-intensive parts.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="card-body">
<p class="text-muted small mb-3">Percentage added to the calculated item price based on part intricacy. Applied to calculated items only (not catalog, generic, or labor items).</p>
<div class="row g-3">
<div class="col-sm-6 col-md-3">
<label for="complexitySimplePercent" class="form-label">Simple (%)</label>
<div class="input-group">
<input type="number" step="0.1" class="form-control" id="complexitySimplePercent" name="ComplexitySimplePercent" value="@(Model.OperatingCosts?.ComplexitySimplePercent ?? 0)" min="0" max="500">
<span class="input-group-text">%</span>
</div>
<small class="text-muted">No added complexity</small>
</div>
<div class="col-sm-6 col-md-3">
<label for="complexityModeratePercent" class="form-label">Moderate (%)</label>
<div class="input-group">
<input type="number" step="0.1" class="form-control" id="complexityModeratePercent" name="ComplexityModeratePercent" value="@(Model.OperatingCosts?.ComplexityModeratePercent ?? 5)" min="0" max="500">
<span class="input-group-text">%</span>
</div>
<small class="text-muted">Some detail work</small>
</div>
<div class="col-sm-6 col-md-3">
<label for="complexityComplexPercent" class="form-label">Complex (%)</label>
<div class="input-group">
<input type="number" step="0.1" class="form-control" id="complexityComplexPercent" name="ComplexityComplexPercent" value="@(Model.OperatingCosts?.ComplexityComplexPercent ?? 15)" min="0" max="500">
<span class="input-group-text">%</span>
</div>
<small class="text-muted">Intricate parts</small>
</div>
<div class="col-sm-6 col-md-3">
<label for="complexityExtremePercent" class="form-label">Extreme (%)</label>
<div class="input-group">
<input type="number" step="0.1" class="form-control" id="complexityExtremePercent" name="ComplexityExtremePercent" value="@(Model.OperatingCosts?.ComplexityExtremePercent ?? 25)" min="0" max="500">
<span class="input-group-text">%</span>
</div>
<small class="text-muted">Highly detailed/difficult</small>
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-end mt-4">
<button type="submit" class="btn btn-primary" id="btnSaveOperatingCosts">
<i class="bi bi-save"></i> Save Operating Costs
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Part Complexity Multipliers -->
<div class="card mt-3 border-0 shadow-sm">
<div class="card-header bg-transparent fw-semibold">
<i class="bi bi-layers text-primary me-1"></i> Part Complexity Multipliers
<a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Part Complexity Multipliers"
data-bs-content="A percentage added to the price of &lt;strong&gt;calculated items&lt;/strong&gt; based on how intricate the part is. When adding an item in a quote, staff select a complexity level — the system then applies this multiplier to account for the extra time and care needed. &lt;em&gt;Simple&lt;/em&gt; = 0% (flat panels, basic shapes). &lt;em&gt;Extreme&lt;/em&gt; = highly detailed, tight recesses, masking-intensive parts.">
<i class="bi bi-question-circle"></i>
</a>
</div>
<div class="card-body">
<p class="text-muted small mb-3">Percentage added to the calculated item price based on part intricacy. Applied to calculated items only (not catalog, generic, or labor items).</p>
<div class="row g-3">
<div class="col-sm-6 col-md-3">
<label for="complexitySimplePercent" class="form-label">Simple (%)</label>
<div class="input-group">
<input type="number" step="0.1" class="form-control" id="complexitySimplePercent" name="ComplexitySimplePercent" value="@(Model.OperatingCosts?.ComplexitySimplePercent ?? 0)" min="0" max="500">
<span class="input-group-text">%</span>
</div>
<small class="text-muted">No added complexity</small>
</div>
<div class="col-sm-6 col-md-3">
<label for="complexityModeratePercent" class="form-label">Moderate (%)</label>
<div class="input-group">
<input type="number" step="0.1" class="form-control" id="complexityModeratePercent" name="ComplexityModeratePercent" value="@(Model.OperatingCosts?.ComplexityModeratePercent ?? 5)" min="0" max="500">
<span class="input-group-text">%</span>
</div>
<small class="text-muted">Some detail work</small>
</div>
<div class="col-sm-6 col-md-3">
<label for="complexityComplexPercent" class="form-label">Complex (%)</label>
<div class="input-group">
<input type="number" step="0.1" class="form-control" id="complexityComplexPercent" name="ComplexityComplexPercent" value="@(Model.OperatingCosts?.ComplexityComplexPercent ?? 15)" min="0" max="500">
<span class="input-group-text">%</span>
</div>
<small class="text-muted">Intricate parts</small>
</div>
<div class="col-sm-6 col-md-3">
<label for="complexityExtremePercent" class="form-label">Extreme (%)</label>
<div class="input-group">
<input type="number" step="0.1" class="form-control" id="complexityExtremePercent" name="ComplexityExtremePercent" value="@(Model.OperatingCosts?.ComplexityExtremePercent ?? 25)" min="0" max="500">
<span class="input-group-text">%</span>
</div>
<small class="text-muted">Highly detailed/difficult</small>
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-end mt-4 mb-2">
<button type="submit" class="btn btn-primary" id="btnSaveOperatingCosts">
<i class="bi bi-save"></i> Save Operating Costs
</button>
</div>
</form>
</div>
<!-- Oven Cost Add/Edit Modal (outside all forms to avoid form interaction issues) -->
<div class="modal fade" id="ovenModal" tabindex="-1" aria-labelledby="ovenModalTitle" aria-hidden="true">
@@ -2949,76 +2958,6 @@
loadOvenCosts();
});
// Reload role costs whenever the Operating Costs tab is shown
document.getElementById('operating-costs-tab')?.addEventListener('shown.bs.tab', () => {
loadRoleCosts();
});
// If Equipment Profile tab is already active on page load, load immediately
if (document.getElementById('quoting-calibration')?.classList.contains('show')) {
loadOvenCosts();
}
// If Operating Costs tab is already active on page load, load role costs immediately
if (document.getElementById('operating-costs')?.classList.contains('show')) {
loadRoleCosts();
}
// ── Role-Based Labor Cost Rates ────────────────────────────────────────
const ROLE_NAMES = ['General Labor','Sandblaster','Coater','Masker','Quality Control','Oven Operator','Supervisor','Maintenance'];
async function loadRoleCosts() {
const resp = await fetch('/CompanySettings/GetRoleCosts');
const saved = await resp.json(); // [{role, hourlyRate}]
const rateMap = {};
saved.forEach(r => rateMap[r.role] = r.hourlyRate);
const fallbackEl = document.getElementById('standardLaborRate');
const fallback = fallbackEl ? `$${parseFloat(fallbackEl.value || 0).toFixed(2)}/hr` : 'Standard Rate';
const tbody = document.getElementById('roleCostBody');
tbody.innerHTML = ROLE_NAMES.map((name, i) => `
<tr>
<td><span class="badge bg-secondary">${name}</span></td>
<td>
<div class="input-group input-group-sm">
<span class="input-group-text">$</span>
<input type="number" step="0.01" min="0" max="999"
class="form-control role-cost-input"
data-role="${i}"
value="${rateMap[i] > 0 ? rateMap[i] : ''}"
placeholder="(use default)">
</div>
</td>
<td class="text-muted small">${fallback}</td>
</tr>`).join('');
}
async function saveRoleCosts() {
const inputs = document.querySelectorAll('.role-cost-input');
const rates = Array.from(inputs).map(el => ({
role: parseInt(el.dataset.role),
hourlyRate: parseFloat(el.value) || 0
}));
const statusEl = document.getElementById('roleCostSaveStatus');
statusEl.textContent = 'Saving...';
statusEl.className = 'ms-2 small text-muted';
const resp = await fetch('/CompanySettings/SaveRoleCosts', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '' },
body: JSON.stringify(rates)
});
const result = await resp.json();
if (result.success) {
statusEl.textContent = '✓ Saved';
statusEl.className = 'ms-2 small text-success';
setTimeout(() => statusEl.textContent = '', 3000);
} else {
statusEl.textContent = result.message || 'Error saving';
statusEl.className = 'ms-2 small text-danger';
}
}
// ── Quote PDF Template ──────────────────────────────────────────────
function syncColorPicker(hex) {
if (/^#[0-9A-Fa-f]{6}$/.test(hex)) {
@@ -106,6 +106,16 @@
<input asp-for="Position" class="form-control" />
<span asp-validation-for="Position" class="text-danger"></span>
</div>
<div class="col-md-4">
<label asp-for="LaborCostPerHour" class="form-label">Labor Cost Rate</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input asp-for="LaborCostPerHour" type="number" step="0.01" min="0" max="10000" class="form-control" placeholder="Use company default" />
<span class="input-group-text">/hr</span>
</div>
<span asp-validation-for="LaborCostPerHour" class="text-danger"></span>
<small class="text-muted">Used for internal job costing only &mdash; never shown to customers. Overrides the company default when set. Leave blank to use the shop-wide rate.</small>
</div>
<div class="col-md-6">
<label asp-for="HireDate" class="form-label">Hire Date</label>
<input asp-for="HireDate" class="form-control" type="date" />
@@ -101,6 +101,73 @@
else
{
<div class="card">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var m in Model)
{
var expired2 = m.ExpiryDate.HasValue && m.ExpiryDate.Value < DateTime.UtcNow
&& m.Status != CreditMemoStatus.FullyApplied
&& m.Status != CreditMemoStatus.Voided;
var (cmBadge, cmLabel) = m.Status switch
{
CreditMemoStatus.Active => ("bg-success-subtle text-success", "Active"),
CreditMemoStatus.PartiallyApplied => ("bg-warning-subtle text-warning", "Partial"),
CreditMemoStatus.FullyApplied => ("bg-secondary-subtle text-secondary", "Applied"),
CreditMemoStatus.Voided => ("bg-danger-subtle text-danger", "Voided"),
_ => ("bg-secondary-subtle text-secondary", m.Status.ToString())
};
var cmCustomer = string.IsNullOrWhiteSpace(m.Customer?.CompanyName)
? $"{m.Customer?.ContactFirstName} {m.Customer?.ContactLastName}".Trim()
: m.Customer!.CompanyName;
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = m.Id })'">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);">
<i class="bi bi-journal-minus"></i>
</div>
<div class="mobile-card-title">
<h6>@m.MemoNumber</h6>
<small>@cmCustomer</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 @cmBadge">@cmLabel</span></span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Amount</span>
<span class="mobile-card-value">@m.Amount.ToString("C")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Remaining</span>
<span class="mobile-card-value @(m.RemainingBalance > 0 && m.Status != CreditMemoStatus.Voided ? "text-success fw-semibold" : "text-muted")">
@m.RemainingBalance.ToString("C")
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Issued</span>
<span class="mobile-card-value">@m.IssueDate.ToLocalTime().ToString("MM/dd/yy")</span>
</div>
@if (m.ExpiryDate.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Expires</span>
<span class="mobile-card-value @(expired2 ? "text-danger fw-semibold" : "")">
@m.ExpiryDate.Value.ToLocalTime().ToString("MM/dd/yy")
@if (expired2) { <small>(Expired)</small> }
</span>
</div>
}
</div>
<div class="mobile-card-footer">
<a asp-action="Details" asp-route-id="@m.Id" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
Details
</a>
</div>
</div>
}
</div>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
@@ -118,6 +118,63 @@
}
else
{
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var a in Model)
{
var fd = a.AccumulatedDepreciation >= (a.PurchaseCost - a.SalvageValue);
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = a.Id })'">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%);">
<i class="bi bi-building-gear"></i>
</div>
<div class="mobile-card-title">
<h6>@a.Name</h6>
<small>Purchased @a.PurchaseDate.ToLocalTime().ToString("MM/dd/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 (a.IsDisposed)
{
<span class="badge bg-secondary">Disposed</span>
}
else if (fd)
{
<span class="badge bg-light text-dark border">Fully Depreciated</span>
}
else
{
<span class="badge bg-success">Active</span>
}
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Cost</span>
<span class="mobile-card-value">@a.PurchaseCost.ToString("C")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Book Value</span>
<span class="mobile-card-value @(a.BookValue <= 0 ? "text-muted" : "text-success fw-semibold")">
@a.BookValue.ToString("C")
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Monthly Depr.</span>
<span class="mobile-card-value">@a.MonthlyDepreciation.ToString("C")</span>
</div>
</div>
<div class="mobile-card-footer">
<a asp-action="Details" asp-route-id="@a.Id" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
<i class="bi bi-eye me-1"></i>View
</a>
</div>
</div>
}
</div>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
@@ -0,0 +1,107 @@
@model PowderCoating.Application.DTOs.GiftCertificate.BulkCreateGiftCertificateDto
@using PowderCoating.Core.Enums
@{
ViewData["Title"] = "Bulk Create Gift Certificates";
ViewData["PageIcon"] = "bi-gift";
}
<div class="row justify-content-center">
<div class="col-lg-7">
<div class="card border-0 shadow-sm">
<div class="card-header bg-white border-bottom py-3">
<h5 class="mb-0">
<i class="bi bi-collection me-2 text-primary"></i>Bulk Gift Certificate Generator
</h5>
<p class="text-muted small mb-0 mt-1">
Create a batch of certificates for car shows, events, or promotions. All certificates will have the same
face value and be generated with sequential codes ready to print.
</p>
</div>
<div class="card-body p-4">
<form asp-action="BulkCreate" method="post">
<div asp-validation-summary="ModelOnly" class="alert alert-danger" role="alert"></div>
<div class="row g-3">
<div class="col-md-5">
<label asp-for="Quantity" class="form-label fw-semibold">
<i class="bi bi-123 me-1 text-muted"></i>@Html.DisplayNameFor(m => m.Quantity)
</label>
<input asp-for="Quantity" type="number" class="form-control form-control-lg"
min="1" max="500" placeholder="25" />
<span asp-validation-for="Quantity" class="text-danger small"></span>
<div class="form-text">Max 500 per batch.</div>
</div>
<div class="col-md-7">
<label asp-for="Amount" class="form-label fw-semibold">
<i class="bi bi-currency-dollar me-1 text-muted"></i>@Html.DisplayNameFor(m => m.Amount)
</label>
<div class="input-group input-group-lg">
<span class="input-group-text">$</span>
<input asp-for="Amount" type="number" class="form-control"
min="1" max="9999.99" step="0.01" placeholder="50.00" />
</div>
<span asp-validation-for="Amount" class="text-danger small"></span>
</div>
<div class="col-12">
<label asp-for="IssuedReason" class="form-label fw-semibold">
<i class="bi bi-tag me-1 text-muted"></i>@Html.DisplayNameFor(m => m.IssuedReason)
</label>
<select asp-for="IssuedReason" class="form-select">
@foreach (var reason in Enum.GetValues<GiftCertificateIssuedReason>())
{
<option value="@reason">@reason</option>
}
</select>
<span asp-validation-for="IssuedReason" class="text-danger small"></span>
</div>
<div class="col-12">
<label asp-for="ExpiryDate" class="form-label fw-semibold">
<i class="bi bi-calendar-x me-1 text-muted"></i>@Html.DisplayNameFor(m => m.ExpiryDate)
</label>
<input asp-for="ExpiryDate" type="date" class="form-control" />
<span asp-validation-for="ExpiryDate" class="text-danger small"></span>
<div class="form-text">Leave blank for no expiration.</div>
</div>
<div class="col-12">
<label asp-for="Notes" class="form-label fw-semibold">
<i class="bi bi-chat-left-text me-1 text-muted"></i>@Html.DisplayNameFor(m => m.Notes)
</label>
<textarea asp-for="Notes" class="form-control" rows="2"
placeholder="e.g. Awarded at the 2026 Summer Car Show &mdash; thanks for attending!"></textarea>
<span asp-validation-for="Notes" class="text-danger small"></span>
<div class="form-text">Printed on every certificate in the batch.</div>
</div>
</div>
<!-- Preview summary -->
<div id="batchPreview" class="alert alert-primary mt-4 mb-0" style="display:none">
<i class="bi bi-info-circle me-2"></i>
You are about to create <strong id="prevQty"></strong> certificates worth
<strong id="prevAmt"></strong> each &mdash; total face value
<strong id="prevTotal"></strong>.
</div>
<div class="d-flex justify-content-between align-items-center mt-4 pt-3 border-top">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Cancel
</a>
<button type="submit" class="btn btn-primary btn-lg" id="submitBtn">
<i class="bi bi-plus-circle me-2"></i>Create Certificates
</button>
</div>
</form>
</div>
</div>
</div>
</div>
@section Scripts {
<script src="~/js/gift-certificate-bulk.js" asp-append-version="true"></script>
}
@@ -0,0 +1,118 @@
@model List<PowderCoating.Core.Entities.GiftCertificate>
@using PowderCoating.Core.Enums
@{
ViewData["Title"] = "Batch Gift Certificates";
ViewData["PageIcon"] = "bi-gift";
var batchId = Model.FirstOrDefault()?.BatchId ?? Guid.Empty;
var count = Model.Count;
var amount = Model.FirstOrDefault()?.OriginalAmount ?? 0m;
}
<div class="alert alert-success alert-permanent mb-4">
<i class="bi bi-check-circle-fill me-2"></i>
<strong>@count gift certificates created</strong> &mdash; each worth @amount.ToString("C").
Download the PDF below to print the full batch. This page is bookmarkable &mdash; you can return here any time to re-download.
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-header bg-white border-bottom d-flex justify-content-between align-items-center py-3">
<h5 class="mb-0">
<i class="bi bi-collection me-2 text-primary"></i>Batch Certificates (@count)
<span class="text-muted small fw-normal ms-2 font-monospace">@batchId.ToString("N")[..8]&hellip;</span>
</h5>
<a asp-action="BatchDownloadPdf" asp-route-batchId="@batchId" class="btn btn-primary">
<i class="bi bi-file-pdf me-2"></i>Download All as PDF
</a>
</div>
<div class="card-body p-0">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var cert in Model)
{
<div class="mobile-data-card">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #10b981 0%, #059669 100%);">
<i class="bi bi-gift"></i>
</div>
<div class="mobile-card-title">
<h6 class="font-monospace">@cert.CertificateCode</h6>
<small>@cert.OriginalAmount.ToString("C")</small>
</div>
</div>
<div class="mobile-card-body">
<div class="mobile-card-row">
<span class="mobile-card-label">Issued</span>
<span class="mobile-card-value">@cert.IssueDate.ToLocalTime().ToString("MMM d, yyyy")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Expiry</span>
<span class="mobile-card-value">
@if (cert.ExpiryDate.HasValue) { @cert.ExpiryDate.Value.ToLocalTime().ToString("MMM d, yyyy") } else { <span class="text-muted">&mdash;</span> }
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Status</span>
<span class="mobile-card-value"><span class="badge bg-success">Active</span></span>
</div>
</div>
<div class="mobile-card-footer">
<a asp-action="Details" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-eye me-1"></i>View
</a>
<a asp-action="DownloadPdf" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-file-pdf me-1"></i>PDF
</a>
</div>
</div>
}
</div>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th class="ps-3">Certificate Code</th>
<th>Face Value</th>
<th>Issued</th>
<th>Expiry</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var cert in Model)
{
<tr>
<td class="ps-3 fw-semibold font-monospace">@cert.CertificateCode</td>
<td>@cert.OriginalAmount.ToString("C")</td>
<td>@cert.IssueDate.ToLocalTime().ToString("MMM d, yyyy")</td>
<td>
@(cert.ExpiryDate.HasValue
? cert.ExpiryDate.Value.ToLocalTime().ToString("MMM d, yyyy")
: "&mdash;")
</td>
<td><span class="badge bg-success">Active</span></td>
<td class="text-end">
<a asp-action="Details" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-secondary" title="View details">
<i class="bi bi-eye"></i>
</a>
<a asp-action="DownloadPdf" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-secondary" title="Download single PDF">
<i class="bi bi-file-pdf"></i>
</a>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
<div class="card-footer bg-white border-top d-flex justify-content-between align-items-center py-3">
<a asp-action="Index" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Back to Gift Certificates
</a>
<a asp-action="BatchDownloadPdf" asp-route-batchId="@batchId" class="btn btn-primary">
<i class="bi bi-printer me-2"></i>Print Batch PDF (@count pages)
</a>
</div>
</div>
@@ -28,7 +28,7 @@
</div>
</div>
<div class="alert alert-@statusClass alert-permanent d-flex align-items-center mb-4">
<div class="alert alert-@statusClass d-flex align-items-center mb-4">
<i class="bi bi-gift me-2" style="font-size:1.4rem;"></i>
<div>
<strong>@statusLabel</strong>
@@ -38,7 +38,7 @@
}
@if (Model.ExpiryDate.HasValue)
{
<span class="ms-2 small">&middot; Expires @Model.ExpiryDate.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MMMM d, yyyy")</span>
<span class="ms-2 small">· Expires @Model.ExpiryDate.Value.Tz(ViewBag.CompanyTimeZone as string).ToString("MMMM d, yyyy")</span>
}
</div>
</div>
@@ -7,10 +7,15 @@
}
<div class="d-flex justify-content-between align-items-center mb-4">
<p class="text-muted mb-0">@ViewBag.TotalActive active certificates @((ViewBag.TotalValue as decimal? ?? 0m).ToString("C")) outstanding value</p>
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>New Certificate
</a>
<p class="text-muted mb-0">@ViewBag.TotalActive active certificates &mdash; @((ViewBag.TotalValue as decimal? ?? 0m).ToString("C")) outstanding value</p>
<div class="d-flex gap-2">
<a asp-action="BulkCreate" class="btn btn-outline-primary">
<i class="bi bi-collection me-2"></i>Bulk Create
</a>
<a asp-action="Create" class="btn btn-primary">
<i class="bi bi-plus-circle me-2"></i>New Certificate
</a>
</div>
</div>
<!-- Filters -->
@@ -52,6 +57,73 @@ else
{
<div class="card border-0 shadow-sm">
<div class="card-body p-0">
<div class="mobile-card-view">
<div class="mobile-card-list">
@foreach (var cert in Model)
{
var (gcBadge, gcLabel) = cert.Status switch
{
GiftCertificateStatus.Active => ("bg-success", "Active"),
GiftCertificateStatus.PartiallyRedeemed => ("bg-info text-dark", "Partial"),
GiftCertificateStatus.FullyRedeemed => ("bg-secondary", "Used"),
GiftCertificateStatus.Expired => ("bg-warning text-dark", "Expired"),
GiftCertificateStatus.Voided => ("bg-danger", "Voided"),
_ => ("bg-secondary", cert.Status.ToString())
};
<div class="mobile-data-card" onclick="window.location='@Url.Action("Details", new { id = cert.Id })'">
<div class="mobile-card-header">
<div class="mobile-card-icon" style="background: linear-gradient(135deg, #a855f7 0%, #7c3aed 100%);">
<i class="bi bi-gift"></i>
</div>
<div class="mobile-card-title">
<h6 class="font-monospace">@cert.CertificateCode</h6>
<small>@(cert.RecipientName ?? cert.RecipientEmail ?? "No recipient")</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 @gcBadge">@gcLabel</span></span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Face Value</span>
<span class="mobile-card-value">@cert.OriginalAmount.ToString("C")</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Remaining</span>
<span class="mobile-card-value @(cert.RemainingBalance > 0 ? "text-success fw-semibold" : "text-muted")">
@cert.RemainingBalance.ToString("C")
</span>
</div>
<div class="mobile-card-row">
<span class="mobile-card-label">Issued</span>
<span class="mobile-card-value">@cert.IssueDate.Tz(ViewBag.CompanyTimeZone as string).ToString("MM/dd/yy")</span>
</div>
@if (cert.ExpiryDate.HasValue)
{
<div class="mobile-card-row">
<span class="mobile-card-label">Expires</span>
<span class="mobile-card-value @(cert.ExpiryDate.Value < DateTime.Now ? "text-danger" : "")">
@cert.ExpiryDate.Value.ToString("MM/dd/yy")
</span>
</div>
}
</div>
<div class="mobile-card-footer">
<a asp-action="Details" asp-route-id="@cert.Id" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation()">
<i class="bi bi-eye me-1"></i>View
</a>
@if (cert.BatchId.HasValue)
{
<a asp-action="BulkResult" asp-route-batchId="@cert.BatchId" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation()">
<i class="bi bi-collection me-1"></i>Batch
</a>
}
</div>
</div>
}
</div>
</div>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
@@ -75,6 +147,14 @@ else
<a asp-action="Details" asp-route-id="@cert.Id" class="fw-semibold text-decoration-none font-monospace">
@cert.CertificateCode
</a>
@if (cert.BatchId.HasValue)
{
<a asp-action="BulkResult" asp-route-batchId="@cert.BatchId"
class="badge bg-primary-subtle text-primary text-decoration-none ms-1"
title="View &amp; download batch">
<i class="bi bi-collection me-1"></i>Batch
</a>
}
</td>
<td>
@if (!string.IsNullOrEmpty(cert.RecipientName))
@@ -83,7 +163,7 @@ else
}
else
{
<span class="text-muted"></span>
<span class="text-muted">&mdash;</span>
}
@if (!string.IsNullOrEmpty(cert.RecipientEmail))
{
@@ -189,22 +189,6 @@
<!-- Shop Management -->
<h2 class="h6 fw-semibold mb-2 text-muted text-uppercase" style="letter-spacing:.05em; font-size:.7rem;">Shop Management</h2>
<div class="row g-3 mb-4">
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<div class="d-flex align-items-start gap-3">
<div class="rounded-3 bg-info bg-opacity-10 p-2 flex-shrink-0">
<i class="bi bi-person-badge text-info fs-4"></i>
</div>
<div>
<h5 class="card-title mb-1">Shop Workers</h5>
<p class="card-text text-muted small mb-2">Add floor staff, assign roles like Coater or Sandblaster, and link workers to jobs and maintenance tasks.</p>
<a asp-controller="Help" asp-action="ShopWorkers" class="btn btn-sm btn-outline-info">Read more <i class="bi bi-arrow-right ms-1"></i></a>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
@@ -48,8 +48,9 @@
<ol class="mb-3">
<li class="mb-2">Open the job from <strong>Operations &rsaquo; Jobs</strong> and go to its Details page.</li>
<li class="mb-2">Scroll to the <strong>Invoice</strong> section near the bottom of the page.</li>
<li class="mb-2">Click <strong>Create Invoice</strong>. The system generates an invoice pre-filled with all the job's line items and the final pricing.</li>
<li class="mb-2">Review the invoice — check line items, totals, and the due date — then click <strong>Save Invoice</strong>.</li>
<li class="mb-2">Click <strong>Create Invoice</strong>. The system pre-fills all line items, the discount, tax rate, payment terms, and due date from the job and customer automatically.</li>
<li class="mb-2">Review the <strong>Totals</strong> panel on the right &mdash; if a discount was applied to the job it shows as a red <em>Discount Applied</em> line below the subtotal. Negative line items are allowed if you need to apply a manual credit or price adjustment.</li>
<li class="mb-2">Adjust anything you need, then click <strong>Save Invoice</strong>.</li>
</ol>
<h3 class="h6 fw-semibold mt-3 mb-2">From the Invoices list (manual)</h3>
@@ -139,7 +140,7 @@
<li class="mb-2">Open the invoice from <strong>Operations &rsaquo; Invoices</strong> or from the job's Details page.</li>
<li class="mb-2">Click <strong>Send Invoice</strong>. The status changes from Draft to Sent.</li>
<li class="mb-2">If email notifications are configured, the customer receives an email with the invoice details and total due.</li>
<li class="mb-2">A due date is set automatically based on the customer's payment terms (e.g., Net 30 means the due date is 30 days from today).</li>
<li class="mb-2">The due date and payment terms are pre-filled from the source quote (if the job came from a quote) or the customer&rsquo;s payment terms &mdash; you can always override them before saving.</li>
</ol>
<p>
You can also click <strong>Download PDF</strong> on any invoice to generate a print-ready PDF
+16 -1
View File
@@ -607,13 +607,28 @@
no anonymous bumps.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-box-seam me-1"></i>Bottom QR Log Powder Usage</h3>
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-box-seam me-1"></i>Bottom QR &mdash; Log Powder Usage</h3>
<p>
One QR per unique powder on the job. Scanning opens the inventory usage log page pre-filled
with that powder and the job number, so you can record actual lbs used in seconds without
navigating through the app.
</p>
<h3 class="h6 fw-semibold mt-3 mb-2"><i class="bi bi-droplet-half me-1"></i>Logging Material Usage from a PC</h3>
<p>
You don&rsquo;t need a phone or QR code to log material usage. On the Job Details page, expand the
<strong>Materials Used</strong> section and click <strong>Log Material</strong>. A modal opens where you can:
</p>
<ul class="mb-2">
<li>Select any inventory item from a searchable dropdown &mdash; the item&rsquo;s current stock level is shown when you pick it.</li>
<li>Choose <strong>Amount Used</strong> (enter how much was consumed) or <strong>Amount Remaining</strong> (enter what&rsquo;s left in the bag &mdash; the system calculates the usage automatically).</li>
<li>Pick a reason: <em>Job Usage</em> or <em>Waste / Spillage</em>.</li>
<li>Add optional notes.</li>
</ul>
<p>
Saving immediately reduces the item&rsquo;s stock on hand and creates an entry in the Inventory Activity ledger, exactly like a QR scan would. The QR scan icon is still available next to the button for mobile workers.
</p>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lock flex-shrink-0 mt-1"></i>
<div>
@@ -1,226 +0,0 @@
@{
ViewData["Title"] = "Shop Workers";
}
<div class="d-flex align-items-center gap-2 mb-3">
<a asp-controller="Help" asp-action="Index" class="btn btn-sm btn-outline-secondary"><i class="bi bi-arrow-left"></i></a>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a asp-controller="Help" asp-action="Index">Help</a></li>
<li class="breadcrumb-item active">Shop Workers</li>
</ol>
</nav>
</div>
<div class="row g-4">
<div class="col-lg-9">
<section id="overview" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-info-circle text-primary me-2"></i>Overview
</h2>
<p>
Shop Workers are the people who do the hands-on work in your facility — sandblasters, coaters,
maskers, oven operators, and supervisors. Adding your workers to the system lets you assign them
to jobs and maintenance tasks, giving you a clear picture of who is working on what at any time.
</p>
<p>
Shop Workers are separate from system user accounts. A worker does not need to log into the
system — they are simply a record that can be assigned to work. If a worker also needs to log
in and update job statuses themselves, an Administrator can create a linked user account for
them with the <em>Shop Floor</em> role.
</p>
<p>
Find Shop Workers under <strong>Operations &rsaquo; Shop Workers</strong> in the sidebar.
</p>
</section>
<section id="adding-a-worker" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-person-plus text-primary me-2"></i>Adding a Worker
</h2>
<p>To add a new shop worker:</p>
<ol class="mb-3">
<li class="mb-2">Go to <strong>Operations &rsaquo; Shop Workers</strong> and click <strong>New Worker</strong>.</li>
<li class="mb-2">
Fill in the worker's details:
<ul class="mt-1">
<li><strong>Name</strong> — the worker's full name as it should appear on job assignments.</li>
<li><strong>Role</strong> — select the role that best describes their primary function (see below).</li>
<li><strong>Phone</strong> — optional, useful for supervisors to have on file.</li>
<li><strong>Email</strong> — optional, used if the worker also has a system login.</li>
<li><strong>Notes</strong> — any relevant information, such as certifications, shift preferences, or specialties.</li>
</ul>
</li>
<li class="mb-2">Ensure <strong>Active</strong> is checked (it is on by default).</li>
<li class="mb-2">Click <strong>Save Worker</strong>.</li>
</ol>
<p>
Once saved, the worker will appear in the assignment dropdowns on the Job Create and Edit forms.
</p>
</section>
<section id="worker-roles" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-tags text-primary me-2"></i>Worker Roles
</h2>
<p>
Each worker is assigned one of the following roles. The role is a label — it helps you pick the
right person for a job but does not restrict what a worker can be assigned to.
</p>
<div class="table-responsive">
<table class="table table-bordered align-middle">
<thead class="table-light">
<tr>
<th style="width:25%">Role</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><span class="badge bg-secondary">General Labor</span></td>
<td>
Versatile workers who assist across multiple areas of the shop — loading and unloading,
racking parts, clean-up, and general support tasks. Not specialized in a single process.
</td>
</tr>
<tr>
<td><span class="badge bg-warning text-dark">Sandblaster</span></td>
<td>
Operates the sandblasting or media-blasting equipment to prepare metal surfaces for
coating. Responsible for achieving the correct surface profile and ensuring all rust,
paint, and contamination is removed.
</td>
</tr>
<tr>
<td><span class="badge bg-primary">Coater</span></td>
<td>
Applies powder coating using an electrostatic spray gun. Responsible for even coverage,
correct mil thickness, and minimizing overspray and waste. Often the most skilled
technical role on the floor.
</td>
</tr>
<tr>
<td><span class="badge bg-info text-dark">Masker</span></td>
<td>
Applies masking tape, plugs, and caps to protect threads, bearing surfaces, and areas
that must not be coated. Attention to detail is critical — missed masking means rework.
</td>
</tr>
<tr>
<td><span class="badge bg-success">Quality Control</span></td>
<td>
Inspects finished parts for adhesion, color consistency, coverage, and surface defects
before the job is marked as complete. May also handle pre-coat inspection after
sandblasting.
</td>
</tr>
<tr>
<td><span class="badge bg-danger">Oven Operator</span></td>
<td>
Loads parts into the curing oven, sets correct temperatures and cure times for the
powder being used, monitors the cure cycle, and unloads parts safely after cooling.
</td>
</tr>
<tr>
<td><span class="badge bg-dark">Supervisor</span></td>
<td>
Oversees day-to-day shop floor operations, assigns tasks to other workers, ensures
jobs are progressing on schedule, and handles escalations. May also handle customer
communication for production updates.
</td>
</tr>
<tr>
<td><span class="badge bg-secondary">Maintenance</span></td>
<td>
Responsible for keeping equipment running — performing scheduled preventive maintenance,
troubleshooting breakdowns, and coordinating with external service technicians when
needed.
</td>
</tr>
</tbody>
</table>
</div>
</section>
<section id="assigning-to-jobs" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-briefcase text-primary me-2"></i>Assigning Workers to Jobs
</h2>
<p>
Each job can have one worker assigned to it as the primary responsible person. This is the
worker who owns the job from start to finish — typically a coater or supervisor.
</p>
<p>To assign a worker when creating or editing a job:</p>
<ol class="mb-3">
<li class="mb-1">Open the job's Create or Edit form.</li>
<li class="mb-1">Scroll down to the <strong>Assignment</strong> section.</li>
<li class="mb-1">Select a worker from the <strong>Assigned Worker</strong> dropdown. Only active workers are listed.</li>
<li class="mb-1">Save the job.</li>
</ol>
<p>
The assigned worker's name appears on the job list view, on the job detail page, and in any
reports filtered by worker.
</p>
<p>
Workers can also be assigned to <strong>maintenance tasks</strong> on equipment. See the
<a asp-controller="Help" asp-action="Equipment" class="text-decoration-none">Equipment &amp; Maintenance</a>
help page for details.
</p>
<div class="alert alert-permanent alert-info d-flex gap-2 mb-0" role="alert">
<i class="bi bi-lightbulb-fill flex-shrink-0 mt-1"></i>
<div>
If a worker you want to assign does not appear in the dropdown, check that their record is
marked as <strong>Active</strong>. Inactive workers are hidden from assignment lists.
</div>
</div>
</section>
<section id="deactivating-a-worker" class="mb-5">
<h2 class="h4 fw-bold border-bottom pb-2 mb-3">
<i class="bi bi-person-dash text-primary me-2"></i>Deactivating a Worker
</h2>
<p>
When a worker leaves the shop or is no longer available for assignment, deactivate their record
rather than deleting it. Deactivating preserves the history of all jobs they were assigned to,
while removing them from the active assignment dropdowns so they cannot be accidentally selected
for new work.
</p>
<p>To deactivate a worker:</p>
<ol class="mb-3">
<li class="mb-1">Open the worker's Details or Edit page.</li>
<li class="mb-1">Uncheck the <strong>Active</strong> checkbox.</li>
<li class="mb-1">Click <strong>Save</strong>.</li>
</ol>
<p>
Alternatively, use the <strong>Delete</strong> button on the Details page to perform a soft
delete, which has the same effect.
</p>
<div class="alert alert-permanent alert-secondary d-flex gap-2 mb-0" role="alert">
<i class="bi bi-info-circle flex-shrink-0 mt-1"></i>
<div>
If a worker currently has open jobs assigned to them, reassign those jobs first before
deactivating the worker — so the jobs remain clearly owned and nothing falls through the cracks.
</div>
</div>
</section>
</div>
<div class="col-lg-3 d-none d-lg-block">
@{ await Html.RenderPartialAsync("_HelpNav"); }
<div class="card border-0 shadow-sm sticky-top" style="top:80px">
<div class="card-header bg-transparent fw-semibold small text-muted text-uppercase" style="letter-spacing:.05em; font-size:.7rem;">On this page</div>
<div class="card-body p-0">
<nav class="nav flex-column">
<a class="nav-link py-1 px-3 small text-body" href="#overview">Overview</a>
<a class="nav-link py-1 px-3 small text-body" href="#adding-a-worker">Adding a Worker</a>
<a class="nav-link py-1 px-3 small text-body" href="#worker-roles">Worker Roles</a>
<a class="nav-link py-1 px-3 small text-body" href="#assigning-to-jobs">Assigning to Jobs</a>
<a class="nav-link py-1 px-3 small text-body" href="#deactivating-a-worker">Deactivating a Worker</a>
</nav>
</div>
</div>
</div>
</div>

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