Compare commits

..

17 Commits

Author SHA1 Message Date
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 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
16 changed files with 805 additions and 734 deletions
+4
View File
@@ -129,3 +129,7 @@ DataProtection-Keys/
# Secrets # Secrets
appsettings.secrets.json appsettings.secrets.json
*.pfx *.pfx
# Local task tracking
TODO.txt
TODO.txt.bak
-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!
@@ -389,7 +389,7 @@ public class CompleteJobDto
{ {
public int JobId { get; set; } public int JobId { get; set; }
public decimal? ActualTimeSpentHours { 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; public bool SendEmailToCustomer { get; set; } = false;
} }
@@ -400,10 +400,10 @@ public class SendJobSmsRequest
public string Message { get; set; } = string.Empty; public string Message { get; set; } = string.Empty;
} }
// DTO for tracking actual powder usage per coat // DTO for tracking actual powder usage per inventory item (color) for the whole job
public class JobItemCoatUsageDto public class JobPowderUsageDto
{ {
public int JobItemCoatId { get; set; } public int InventoryItemId { get; set; }
public decimal? ActualPowderUsedLbs { get; set; } public decimal? ActualPowderUsedLbs { get; set; }
} }
@@ -712,7 +712,7 @@ public class DataExportController : Controller
/// <summary> /// <summary>
/// Returns the requested sheet names sorted into the canonical export order /// Returns the requested sheet names sorted into the canonical export order
/// (Customers → Jobs → Quotes → Invoices → Inventory → Equipment → Vendors → ShopWorkers → Users). /// (Customers → Jobs → Quotes → Invoices → Inventory → Equipment → Vendors → Users).
/// This ensures that the workbook and ZIP archive always have a predictable, logical layout /// This ensures that the workbook and ZIP archive always have a predictable, logical layout
/// regardless of the order the administrator checked the boxes on the form. /// regardless of the order the administrator checked the boxes on the form.
/// Any sheet name not in the canonical list is silently ignored. /// Any sheet name not in the canonical list is silently ignored.
@@ -352,7 +352,7 @@ public class DataPurgeController : Controller
"MaintenanceRecords", "MaintenanceRecords",
"Jobs", "Customers", "Quotes", "Jobs", "Customers", "Quotes",
"InventoryItems", "Equipment", "InventoryItems", "Equipment",
"Vendors", "ShopWorkers" "Vendors"
}; };
return order.Where(entities.Contains).ToArray(); return order.Where(entities.Contains).ToArray();
} }
@@ -340,13 +340,14 @@ public class InvoicesController : Controller
var costs = await _unitOfWork.CompanyOperatingCosts var costs = await _unitOfWork.CompanyOperatingCosts
.FirstOrDefaultAsync(c => c.CompanyId == currentUser.CompanyId && !c.IsDeleted); .FirstOrDefaultAsync(c => c.CompanyId == currentUser.CompanyId && !c.IsDeleted);
var defaultTerms = prefs?.DefaultPaymentTerms ?? "Net 30";
var dto = new CreateInvoiceDto var dto = new CreateInvoiceDto
{ {
PreparedById = currentUser.Id, PreparedById = currentUser.Id,
InvoiceDate = DateTime.Today, InvoiceDate = DateTime.Today,
DueDate = DateTime.Today.AddDays(prefs?.DefaultTurnaroundDays ?? 30), DueDate = PaymentTermsParser.CalculateDueDate(defaultTerms, DateTime.Today),
TaxPercent = costs?.TaxPercent ?? 0, TaxPercent = costs?.TaxPercent ?? 0,
Terms = prefs?.DefaultPaymentTerms ?? "Net 30" Terms = defaultTerms
}; };
if (jobId.HasValue) if (jobId.HasValue)
@@ -378,6 +379,13 @@ public class InvoicesController : Controller
var defaultRevenueAccount = await _unitOfWork.Accounts var defaultRevenueAccount = await _unitOfWork.Accounts
.FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.IsActive); .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. // 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 — // 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. // these are quote-level charges that are NOT stored on individual job items.
@@ -461,17 +469,15 @@ public class InvoicesController : Controller
}); });
} }
// Use the quote's agreed tax rate and discount — not current company defaults // Use the quote's agreed tax rate and discount — these represent the customer-approved
dto.TaxPercent = sourceQuote.TaxPercent; // price and must not be recomputed from the job's current state.
dto.TaxPercent = sourceQuote.TaxPercent;
dto.DiscountAmount = sourceQuote.DiscountAmount; dto.DiscountAmount = sourceQuote.DiscountAmount;
} }
else if (hadJobItems) else if (hadJobItems)
{ {
// Direct job — no source quote. Read all charges from the pricing snapshot so the // 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. // invoice always matches the total shown on the job's Pricing Summary card.
QuotePricingBreakdownDto? jobBreakdown = null;
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
jobBreakdown = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson);
if (job.OvenBatchCost > 0.01m) if (job.OvenBatchCost > 0.01m)
{ {
@@ -529,6 +535,22 @@ public class InvoicesController : Controller
RevenueAccountId = defaultRevenueAccount?.Id 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 // Override tax to 0 for tax-exempt customers, regardless of company default or quote rate
@@ -498,6 +498,23 @@ public class JobsController : Controller
.OrderByDescending(t => t.TransactionDate).ToList(); .OrderByDescending(t => t.TransactionDate).ToList();
ViewBag.MaterialsUsed = allJobTransactions; ViewBag.MaterialsUsed = allJobTransactions;
// Inventory items for the manual log-material modal
var inventoryItemsForModal = (await _unitOfWork.InventoryItems.GetAllAsync())
.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) // Pre-logged powder grouped by InventoryItemId (for Complete Job modal pre-fill)
ViewBag.PreLoggedPowder = allJobTransactions ViewBag.PreLoggedPowder = allJobTransactions
.GroupBy(t => t.InventoryItemId) .GroupBy(t => t.InventoryItemId)
@@ -2648,78 +2665,80 @@ public class JobsController : Controller
.GroupBy(t => t.InventoryItemId) .GroupBy(t => t.InventoryItemId)
.ToDictionary(g => g.Key, g => Math.Abs(g.Sum(t => t.Quantity))); .ToDictionary(g => g.Key, g => Math.Abs(g.Sum(t => t.Quantity)));
// Update actual powder usage for each coat // Process powder usage submitted per inventory item (color) for the whole job.
foreach (var coatUsage in dto.CoatUsages) // 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( // Load all coats for the job with their inventory items
coatUsage.JobItemCoatId, var allCoats = (await _unitOfWork.JobItemCoats.FindAsync(
false, jic => jic.JobItem != null && jic.JobItem.JobId == dto.JobId,
jic => jic.InventoryItem); false, jic => jic.InventoryItem, jic => jic.JobItem))
.ToList();
if (jobItemCoat != null) foreach (var powderUsage in dto.PowderUsages)
{ {
jobItemCoat.ActualPowderUsedLbs = coatUsage.ActualPowderUsedLbs; if (!powderUsage.ActualPowderUsedLbs.HasValue || powderUsage.ActualPowderUsedLbs.Value <= 0)
await _unitOfWork.JobItemCoats.UpdateAsync(jobItemCoat); continue;
_logger.LogInformation("Updated JobItemCoat {CoatId} with {Lbs} lbs actual powder used", var invItemId = powderUsage.InventoryItemId;
coatUsage.JobItemCoatId, coatUsage.ActualPowderUsedLbs); var totalActualLbs = powderUsage.ActualPowderUsedLbs.Value;
// Deduct powder from inventory if using stock powder // Distribute across coats using this powder proportionally by estimated lbs
if (jobItemCoat.InventoryItemId.HasValue && var coatsForPowder = allCoats.Where(c => c.InventoryItemId == invItemId).ToList();
coatUsage.ActualPowderUsedLbs.HasValue && if (coatsForPowder.Any())
coatUsage.ActualPowderUsedLbs.Value > 0)
{ {
var invItemId = jobItemCoat.InventoryItemId.Value; var totalEstimated = coatsForPowder.Sum(c => c.PowderToOrder ?? 0m);
var actualLbs = coatUsage.ActualPowderUsedLbs.Value; foreach (var coat in coatsForPowder)
// 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 inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(invItemId); var share = totalEstimated > 0
if (inventoryItem != null) ? totalActualLbs * ((coat.PowderToOrder ?? 0m) / totalEstimated)
{ : totalActualLbs / coatsForPowder.Count;
var transaction = new InventoryTransaction coat.ActualPowderUsedLbs = Math.Round(share, 4);
{ await _unitOfWork.JobItemCoats.UpdateAsync(coat);
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);
}
} }
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( _logger.LogInformation(
"Skipped inventory deduction for JobItemCoat {CoatId} — {Lbs} lbs already pre-logged for inventory item {InvItemId}", "Deducted {Lbs} lbs (net of pre-logged) of {Item} from inventory for Job {JobNumber}. New quantity: {NewQty}",
coatUsage.JobItemCoatId, actualLbs, invItemId); deductNow, inventoryItem.Name, job.JobNumber, inventoryItem.QuantityOnHand);
} }
} }
} }
@@ -4080,9 +4099,87 @@ public class JobsController : Controller
_logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId); _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 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 CreateReworkJobRequest { public int ReworkRecordId { get; set; } public string? Notes { get; set; } }
public class UpdateWorkerAssignmentRequest public class UpdateWorkerAssignmentRequest
@@ -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. **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. **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. 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. **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. - 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. - 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.
@@ -344,28 +344,35 @@
<!-- Operating Costs Tab --> <!-- Operating Costs Tab -->
<div class="tab-pane fade" id="operating-costs" role="tabpanel"> <div class="tab-pane fade" id="operating-costs" role="tabpanel">
<div class="card mt-3"> <form id="operatingCostsForm">
<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"> <!-- Header -->
<!-- Rates & Costs --> <div class="card mt-3">
<h6 class="border-bottom pb-2 mb-3">Rates &amp; Costs <div class="card-body">
<h5 class="card-title mb-1">Operating Costs Configuration
<a tabindex="0" class="help-icon" role="button" <a tabindex="0" class="help-icon" role="button"
data-bs-toggle="popover" data-bs-placement="right" data-bs-toggle="popover" data-bs-placement="right"
data-bs-title="Rates &amp; Costs" data-bs-title="Operating Costs"
data-bs-content="&lt;strong&gt;Standard Labor Rate&lt;/strong&gt; is the baseline $/hr for all coating work — sandblasting and masking are multiplied from this. &lt;strong&gt;Powder Coating Cost/sq ft&lt;/strong&gt; is the fallback material rate used when you don't select a specific powder inventory item on a quote item. &lt;strong&gt;Additional Coat Labor&lt;/strong&gt; is the percentage of the base labor cost charged for each coat after the first (e.g. 30% means a 2nd coat adds 30% more labor)."> data-bs-content="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> <i class="bi bi-question-circle"></i>
</a> </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="row">
<div class="col-md-3"> <div class="col-md-3">
<div class="mb-3"> <div class="mb-3">
@@ -430,16 +437,21 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
<!-- Facility Overhead --> <!-- Facility Overhead -->
<h6 class="border-bottom pb-2 mb-3 mt-3">Facility Overhead <div class="card mt-3 border-0 shadow-sm">
<a tabindex="0" class="help-icon" role="button" <div class="card-header bg-transparent fw-semibold">
data-bs-toggle="popover" data-bs-placement="right" <i class="bi bi-building text-primary me-1"></i> Facility Overhead
data-bs-title="Facility Overhead" <a tabindex="0" class="help-icon" role="button"
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."> data-bs-toggle="popover" data-bs-placement="right"
<i class="bi bi-question-circle"></i> data-bs-title="Facility Overhead"
</a> 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.">
</h6> <i class="bi bi-question-circle"></i>
</a>
</div>
<div class="card-body">
<div class="row align-items-start"> <div class="row align-items-start">
<div class="col-md-3"> <div class="col-md-3">
<div class="mb-3"> <div class="mb-3">
@@ -469,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"> <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> <span class="input-group-text">hrs</span>
</div> </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> </div>
<div class="col-md-3"> <div class="col-md-3">
@@ -484,16 +496,21 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
<!-- Equipment Operating Costs --> <!-- Equipment Operating Costs -->
<h6 class="border-bottom pb-2 mb-3 mt-3">Equipment Operating Costs <div class="card mt-3 border-0 shadow-sm">
<a tabindex="0" class="help-icon" role="button" <div class="card-header bg-transparent fw-semibold">
data-bs-toggle="popover" data-bs-placement="right" <i class="bi bi-tools text-primary me-1"></i> Equipment Operating Costs
data-bs-title="Equipment Operating Costs" <a tabindex="0" class="help-icon" role="button"
data-bs-content="The hourly cost of running each piece of equipment, including energy and depreciation. These are added to quote items based on the prep services selected. The &lt;strong&gt;Default Oven Rate&lt;/strong&gt; is used on quotes where no named oven is chosen — add individual shop ovens below if you have multiple ovens with different capacities and costs."> data-bs-toggle="popover" data-bs-placement="right"
<i class="bi bi-question-circle"></i> data-bs-title="Equipment Operating Costs"
</a> 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.">
</h6> <i class="bi bi-question-circle"></i>
</a>
</div>
<div class="card-body">
<div class="row"> <div class="row">
<div class="col-md-4"> <div class="col-md-4">
<div class="mb-3"> <div class="mb-3">
@@ -527,16 +544,21 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
<!-- Pricing & Overhead --> <!-- Pricing & Profit -->
<h6 class="border-bottom pb-2 mb-3 mt-4">Pricing &amp; Profit <div class="card mt-3 border-0 shadow-sm">
<a tabindex="0" class="help-icon" role="button" <div class="card-header bg-transparent fw-semibold">
data-bs-toggle="popover" data-bs-placement="right" <i class="bi bi-graph-up-arrow text-primary me-1"></i> Pricing &amp; Profit
data-bs-title="Pricing &amp; Profit" <a tabindex="0" class="help-icon" role="button"
data-bs-content="&lt;strong&gt;Markup mode&lt;/strong&gt; adds a % on top of material costs only (labor and equipment pass through at cost). &lt;strong&gt;Margin mode&lt;/strong&gt; targets a gross margin % of the total selling price — e.g. 30% margin on a $100 cost base gives a $142.86 price. Note: margin % and markup % are not the same number. &lt;strong&gt;Shop Minimum&lt;/strong&gt; sets a floor price for any job."> data-bs-toggle="popover" data-bs-placement="right"
<i class="bi bi-question-circle"></i> data-bs-title="Pricing &amp; Profit"
</a> 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.">
</h6> <i class="bi bi-question-circle"></i>
</a>
</div>
<div class="card-body">
@{ @{
var currentPricingMode = (int)(Model.OperatingCosts?.PricingMode ?? PowderCoating.Core.Enums.PricingMode.MarkupOnMaterial); var currentPricingMode = (int)(Model.OperatingCosts?.PricingMode ?? PowderCoating.Core.Enums.PricingMode.MarkupOnMaterial);
} }
@@ -547,14 +569,14 @@
<input class="form-check-input" type="radio" name="pricingModeRadio" id="pricingModeMarkup" value="0" <input class="form-check-input" type="radio" name="pricingModeRadio" id="pricingModeMarkup" value="0"
@(currentPricingMode == 0 ? "checked" : "") onchange="onPricingModeChange()"> @(currentPricingMode == 0 ? "checked" : "") onchange="onPricingModeChange()">
<label class="form-check-label" for="pricingModeMarkup"> <label class="form-check-label" for="pricingModeMarkup">
<strong>Markup</strong> add % to material costs <strong>Markup</strong> &mdash; add % to material costs
</label> </label>
</div> </div>
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="radio" name="pricingModeRadio" id="pricingModeMargin" value="1" <input class="form-check-input" type="radio" name="pricingModeRadio" id="pricingModeMargin" value="1"
@(currentPricingMode == 1 ? "checked" : "") onchange="onPricingModeChange()"> @(currentPricingMode == 1 ? "checked" : "") onchange="onPricingModeChange()">
<label class="form-check-label" for="pricingModeMargin"> <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> </label>
</div> </div>
</div> </div>
@@ -592,16 +614,21 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
<!-- Rush Charges --> <!-- Rush Charges -->
<h6 class="border-bottom pb-2 mb-3 mt-3">Rush Charges <div class="card mt-3 border-0 shadow-sm">
<a tabindex="0" class="help-icon" role="button" <div class="card-header bg-transparent fw-semibold">
data-bs-toggle="popover" data-bs-placement="right" <i class="bi bi-lightning-charge text-primary me-1"></i> Rush Charges
data-bs-title="Rush Charges" <a tabindex="0" class="help-icon" role="button"
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."> data-bs-toggle="popover" data-bs-placement="right"
<i class="bi bi-question-circle"></i> data-bs-title="Rush Charges"
</a> 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.">
</h6> <i class="bi bi-question-circle"></i>
</a>
</div>
<div class="card-body">
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<div class="mb-3"> <div class="mb-3">
@@ -611,7 +638,6 @@
<label class="btn btn-outline-primary" for="rushChargeTypePercentage"> <label class="btn btn-outline-primary" for="rushChargeTypePercentage">
<i class="bi bi-percent"></i> Percentage <i class="bi bi-percent"></i> Percentage
</label> </label>
<input type="radio" class="btn-check" name="rushChargeTypeRadio" id="rushChargeTypeFixed" value="FixedAmount" checked="@((Model.OperatingCosts?.RushChargeType) == "FixedAmount")"> <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"> <label class="btn btn-outline-primary" for="rushChargeTypeFixed">
<i class="bi bi-currency-dollar"></i> Fixed Amount <i class="bi bi-currency-dollar"></i> Fixed Amount
@@ -630,7 +656,6 @@
<small class="text-muted">Percentage of subtotal added for rush jobs</small> <small class="text-muted">Percentage of subtotal added for rush jobs</small>
</div> </div>
</div> </div>
<div id="rushChargeFixedInput" style="display: @((Model.OperatingCosts?.RushChargeType) == "FixedAmount" ? "block" : "none")"> <div id="rushChargeFixedInput" style="display: @((Model.OperatingCosts?.RushChargeType) == "FixedAmount" ? "block" : "none")">
<div class="mb-3"> <div class="mb-3">
<label for="rushChargeFixedAmount" class="form-label">Rush Charge Amount</label> <label for="rushChargeFixedAmount" class="form-label">Rush Charge Amount</label>
@@ -643,65 +668,66 @@
</div> </div>
</div> </div>
</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> </div>
<!-- Oven Cost Add/Edit Modal (outside all forms to avoid form interaction issues) --> <!-- 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"> <div class="modal fade" id="ovenModal" tabindex="-1" aria-labelledby="ovenModalTitle" aria-hidden="true">
@@ -48,8 +48,9 @@
<ol class="mb-3"> <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">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">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">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 invoice — check line items, totals, and the due date — then click <strong>Save Invoice</strong>.</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> </ol>
<h3 class="h6 fw-semibold mt-3 mb-2">From the Invoices list (manual)</h3> <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">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">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">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> </ol>
<p> <p>
You can also click <strong>Download PDF</strong> on any invoice to generate a print-ready PDF 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. no anonymous bumps.
</p> </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> <p>
One QR per unique powder on the job. Scanning opens the inventory usage log page pre-filled 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 with that powder and the job number, so you can record actual lbs used in seconds without
navigating through the app. navigating through the app.
</p> </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"> <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> <i class="bi bi-lock flex-shrink-0 mt-1"></i>
<div> <div>
@@ -283,13 +283,13 @@
<td class="text-end"> <td class="text-end">
<input type="number" name="InvoiceItems[@i].UnitPrice" <input type="number" name="InvoiceItems[@i].UnitPrice"
class="form-control form-control-sm text-end unit-price-input" class="form-control form-control-sm text-end unit-price-input"
value="@item.UnitPrice.ToString("F2")" min="0" step="0.01" value="@item.UnitPrice.ToString("F2")" step="0.01"
onchange="recalcRow(this)" oninput="recalcRow(this)" /> onchange="recalcRow(this)" oninput="recalcRow(this)" />
</td> </td>
<td class="text-end"> <td class="text-end">
<input type="number" name="InvoiceItems[@i].TotalPrice" <input type="number" name="InvoiceItems[@i].TotalPrice"
class="form-control form-control-sm text-end total-price-input" class="form-control form-control-sm text-end total-price-input"
value="@item.TotalPrice.ToString("F2")" min="0" step="0.01" value="@item.TotalPrice.ToString("F2")" step="0.01"
oninput="recalcTotals()" /> oninput="recalcTotals()" />
</td> </td>
<td class="text-center"> <td class="text-center">
@@ -371,6 +371,10 @@
<input asp-for="DiscountAmount" type="number" class="form-control form-control-sm text-end" <input asp-for="DiscountAmount" type="number" class="form-control form-control-sm text-end"
min="0" step="0.01" oninput="recalcTotals()" /> min="0" step="0.01" oninput="recalcTotals()" />
</div> </div>
<div id="discountRow" class="d-flex justify-content-between mb-1 d-none">
<span class="text-muted small">Discount Applied</span>
<span id="displayDiscount" class="small text-danger">&minus;$0.00</span>
</div>
<div class="mb-2"> <div class="mb-2">
<div class="d-flex justify-content-between align-items-center mb-1"> <div class="d-flex justify-content-between align-items-center mb-1">
<label class="form-label mb-0 text-muted">Tax (%)</label> <label class="form-label mb-0 text-muted">Tax (%)</label>
@@ -725,13 +729,13 @@
<td class="text-end"> <td class="text-end">
<input type="number" name="InvoiceItems[${idx}].UnitPrice" <input type="number" name="InvoiceItems[${idx}].UnitPrice"
class="form-control form-control-sm text-end unit-price-input" class="form-control form-control-sm text-end unit-price-input"
value="${unitPrice.toFixed(2)}" min="0" step="0.01" value="${unitPrice.toFixed(2)}" step="0.01"
onchange="recalcRow(this)" oninput="recalcRow(this)" /> onchange="recalcRow(this)" oninput="recalcRow(this)" />
</td> </td>
<td class="text-end"> <td class="text-end">
<input type="number" name="InvoiceItems[${idx}].TotalPrice" <input type="number" name="InvoiceItems[${idx}].TotalPrice"
class="form-control form-control-sm text-end total-price-input" class="form-control form-control-sm text-end total-price-input"
value="${total}" min="0" step="0.01" value="${total}" step="0.01"
oninput="recalcTotals()" /> oninput="recalcTotals()" />
</td> </td>
<td class="text-center"> <td class="text-center">
@@ -797,6 +801,15 @@
const total = taxableAmount + tax; const total = taxableAmount + tax;
document.getElementById('displaySubtotal').textContent = formatCurrency(subtotal); document.getElementById('displaySubtotal').textContent = formatCurrency(subtotal);
const discountRow = document.getElementById('discountRow');
if (discountRow) {
if (discount > 0) {
document.getElementById('displayDiscount').textContent = '' + formatCurrency(discount);
discountRow.classList.remove('d-none');
} else {
discountRow.classList.add('d-none');
}
}
document.getElementById('displayTax').textContent = formatCurrency(tax); document.getElementById('displayTax').textContent = formatCurrency(tax);
document.getElementById('displayTotal').textContent = formatCurrency(total); document.getElementById('displayTotal').textContent = formatCurrency(total);
} }
@@ -1016,9 +1016,12 @@
<span class="badge bg-primary rounded-pill ms-1">@materialsUsed.Count</span> <span class="badge bg-primary rounded-pill ms-1">@materialsUsed.Count</span>
} }
<i class="bi bi-chevron-down collapse-chevron ms-1" style="transition:transform .2s;"></i> <i class="bi bi-chevron-down collapse-chevron ms-1" style="transition:transform .2s;"></i>
<span class="ms-auto"> <span class="ms-auto d-flex gap-2">
<a asp-controller="Inventory" asp-action="Scan" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation();"> <button type="button" class="btn btn-sm btn-outline-primary" onclick="event.stopPropagation(); openLogMaterialModal();">
<i class="bi bi-qr-code-scan me-1"></i>Log Material <i class="bi bi-plus-circle me-1"></i>Log Material
</button>
<a asp-controller="Inventory" asp-action="Scan" class="btn btn-sm btn-outline-secondary" onclick="event.stopPropagation();" title="Scan QR code">
<i class="bi bi-qr-code-scan"></i>
</a> </a>
</span> </span>
</div> </div>
@@ -1028,7 +1031,7 @@
{ {
<div class="card-body text-muted text-center py-3 small"> <div class="card-body text-muted text-center py-3 small">
<i class="bi bi-droplet me-1"></i>No materials have been logged for this job yet. <i class="bi bi-droplet me-1"></i>No materials have been logged for this job yet.
Use the QR label on an inventory item to log usage. Click <strong>Log Material</strong> above or scan the QR label on an inventory item.
</div> </div>
} }
else else
@@ -1089,6 +1092,78 @@
</div><!-- /collapseMaterials --> </div><!-- /collapseMaterials -->
</div> </div>
<!-- Log Material Modal -->
<div class="modal fade" id="logMaterialModal" tabindex="-1" aria-labelledby="logMaterialModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="logMaterialModalLabel">
<i class="bi bi-droplet-half me-2 text-primary"></i>Log Material Usage
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label fw-semibold">Inventory Item <span class="text-danger">*</span></label>
<div class="position-relative">
<div class="input-group">
<input type="text" class="form-control" id="lmItemSearch"
placeholder="Search by name or manufacturer&hellip;" autocomplete="off"
oninput="lmComboInput()"
onfocus="lmComboOpen()"
onkeydown="lmComboKey(event)">
<button class="btn btn-outline-secondary" type="button" tabindex="-1"
id="lmItemDropdownToggle" onclick="lmComboToggle()">
<i class="bi bi-chevron-down" style="font-size:.75rem;"></i>
</button>
</div>
<div id="lmItemDropdown"
style="display:none;max-height:220px;overflow-y:auto;z-index:1070;background:#fff;border:1px solid rgba(0,0,0,.15);border-radius:.375rem;box-shadow:0 4px 12px rgba(0,0,0,.12);">
</div>
</div>
<div id="lmItemBalance" class="form-text text-muted d-none"></div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Entry Method</label>
<div class="d-flex gap-3">
<div class="form-check">
<input class="form-check-input" type="radio" name="lmEntryMethod" id="lmMethodUsed" value="used" checked onchange="lmUpdateQuantityLabel()">
<label class="form-check-label" for="lmMethodUsed">Amount Used</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="lmEntryMethod" id="lmMethodRemaining" value="remaining" onchange="lmUpdateQuantityLabel()">
<label class="form-check-label" for="lmMethodRemaining">Amount Remaining</label>
</div>
</div>
</div>
<div class="mb-3">
<label id="lmQtyLabel" class="form-label fw-semibold">Quantity Used <span class="text-danger">*</span></label>
<input type="number" id="lmQuantity" class="form-control" min="0" step="0.01" placeholder="0.00">
<div id="lmComputedUsed" class="form-text text-muted d-none"></div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Reason</label>
<select id="lmTransactionType" class="form-select">
<option value="JobUsage">Job Usage</option>
<option value="Waste">Waste / Spillage</option>
</select>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Notes</label>
<textarea id="lmNotes" class="form-control" rows="2" placeholder="Optional"></textarea>
</div>
<div id="lmAlert" class="alert alert-permanent d-none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="lmSaveBtn" onclick="lmSave()">
<i class="bi bi-check-circle me-1"></i>Log Usage
</button>
</div>
</div>
</div>
</div>
<!-- Part Intake Modal --> <!-- Part Intake Modal -->
@{ @{
var intakeExpectedCount = Model.Items?.Sum(i => (int)i.Quantity) ?? 0; var intakeExpectedCount = Model.Items?.Sum(i => (int)i.Quantity) ?? 0;
@@ -3082,6 +3157,19 @@
} }
} }
<!-- Log Material Modal JS -->
<script src="/js/log-material.js"></script>
<script>
(function () {
const inventoryItems = @Html.Raw(ViewBag.InventoryItemsForModal ?? "[]");
const jobPowderIds = @Html.Raw(ViewBag.JobPowderIds ?? "[]");
const jobId = @Model.Id;
const logUrl = '@Url.Action("LogMaterial", "Jobs")';
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value ?? '';
window.__logMaterial = { inventoryItems, jobPowderIds, jobId, logUrl, token };
})();
</script>
<!-- Save as Template Modal --> <!-- Save as Template Modal -->
<div class="modal fade" id="saveTemplateModal" tabindex="-1" aria-labelledby="saveTemplateModalLabel" aria-hidden="true"> <div class="modal fade" id="saveTemplateModal" tabindex="-1" aria-labelledby="saveTemplateModalLabel" aria-hidden="true">
<div class="modal-dialog"> <div class="modal-dialog">
@@ -2,8 +2,21 @@
@{ @{
var emailDefault = ViewBag.EmailDefaultOnComplete == true; var emailDefault = ViewBag.EmailDefaultOnComplete == true;
var preLoggedPowder = ViewBag.PreLoggedPowder as Dictionary<int, decimal> ?? new Dictionary<int, decimal>(); var preLoggedPowder = ViewBag.PreLoggedPowder as Dictionary<int, decimal> ?? new Dictionary<int, decimal>();
// Track remaining credit per InventoryItemId as we allocate it across coat rows
var remainingCredit = preLoggedPowder.ToDictionary(kv => kv.Key, kv => kv.Value); // Group all coats by inventory item so we ask once per powder color, not once per item/coat
var powderGroups = (Model.Items ?? new List<PowderCoating.Application.DTOs.Job.JobItemDto>())
.SelectMany(i => i.Coats ?? new List<PowderCoating.Application.DTOs.Job.JobItemCoatDto>())
.Where(c => c.InventoryItemId.HasValue)
.GroupBy(c => c.InventoryItemId!.Value)
.Select(g => new {
InventoryItemId = g.Key,
ColorName = g.First().ColorName,
ColorCode = g.First().ColorCode,
TotalEstimatedLbs = g.Sum(c => c.PowderToOrder ?? 0m),
PreLogged = preLoggedPowder.GetValueOrDefault(g.Key, 0m)
})
.OrderBy(g => g.ColorName)
.ToList();
} }
<div class="modal fade" id="completeJobModal" tabindex="-1"> <div class="modal fade" id="completeJobModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered modal-lg"> <div class="modal-dialog modal-dialog-centered modal-lg">
@@ -27,102 +40,59 @@
<div class="form-text">Enter the total time in hours (e.g., 2.5 for 2 hours 30 minutes)</div> <div class="form-text">Enter the total time in hours (e.g., 2.5 for 2 hours 30 minutes)</div>
</div> </div>
@if (Model.Items != null && Model.Items.Any()) @if (powderGroups.Any())
{ {
<div class="mb-3"> <div class="mb-3">
<h6 class="fw-semibold mb-3"> <h6 class="fw-semibold mb-1">
<i class="bi bi-palette me-1 text-primary"></i>Actual Powder Usage <i class="bi bi-palette me-1 text-primary"></i>Actual Powder Usage
</h6> </h6>
<p class="text-muted small mb-3">Enter total lbs used per powder color for the entire job.</p>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm table-hover"> <table class="table table-sm table-hover">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th>Item</th> <th>Color / Powder</th>
<th>Coat</th>
<th>Color</th>
<th class="text-end">Estimated (lbs)</th> <th class="text-end">Estimated (lbs)</th>
<th>Actual (lbs)</th> <th style="width:150px">Actual Used (lbs)</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@{ @for (int i = 0; i < powderGroups.Count; i++)
var coatIndex = 0;
}
@foreach (var item in Model.Items)
{ {
if (item.Coats != null && item.Coats.Any()) var pg = powderGroups[i];
{ <tr>
foreach (var coat in item.Coats.OrderBy(c => c.Sequence)) <td>
{ <span class="fw-semibold">@pg.ColorName</span>
<tr> @if (!string.IsNullOrEmpty(pg.ColorCode))
<td> {
<small>@item.Description</small> <small class="text-muted ms-1">(@pg.ColorCode)</small>
@if (item.Quantity > 1) }
{ </td>
<span class="badge bg-secondary ms-1">&times;@item.Quantity</span> <td class="text-end text-muted small align-middle">
} @pg.TotalEstimatedLbs.ToString("0.##")
</td> </td>
<td><span class="badge bg-secondary">@coat.CoatName</span></td> <td>
<td> <input type="hidden" name="PowderUsages[@i].InventoryItemId" value="@pg.InventoryItemId" />
@if (!string.IsNullOrEmpty(coat.ColorName)) <input type="number"
{ class="form-control form-control-sm"
<small> name="PowderUsages[@i].ActualPowderUsedLbs"
@coat.ColorName step="0.01" min="0" placeholder="0.00"
@if (!string.IsNullOrEmpty(coat.ColorCode)) value="@(pg.PreLogged > 0 ? pg.PreLogged.ToString("0.##") : "")">
{ @if (pg.PreLogged > 0)
<span class="text-muted">(@coat.ColorCode)</span> {
} <small class="text-success d-block mt-1">
</small> <i class="bi bi-check-circle me-1"></i>@pg.PreLogged.ToString("0.##") lbs already logged
}
</td>
<td class="text-end">
<small class="text-muted">@((coat.PowderToOrder ?? 0).ToString("0.##"))</small>
</td>
<td>
@{
decimal preFilledLbs = 0m;
if (coat.InventoryItemId.HasValue && remainingCredit.TryGetValue(coat.InventoryItemId.Value, out var availCredit) && availCredit > 0)
{
preFilledLbs = availCredit;
remainingCredit[coat.InventoryItemId.Value] = 0m;
}
}
<input type="hidden" name="CoatUsages[@coatIndex].JobItemCoatId" value="@coat.Id" />
<input type="number"
class="form-control form-control-sm"
name="CoatUsages[@coatIndex].ActualPowderUsedLbs"
step="0.01" min="0" placeholder="0.00"
value="@(preFilledLbs > 0 ? preFilledLbs.ToString("0.##") : "")"
style="max-width: 120px;">
@if (preFilledLbs > 0)
{
<small class="text-success d-block mt-1">
<i class="bi bi-check-circle me-1"></i>Already logged — inventory adjusted
</small>
}
</td>
</tr>
coatIndex++;
}
}
else
{
<tr class="table-secondary">
<td colspan="5">
<small class="text-muted fst-italic">
<i class="bi bi-info-circle me-1"></i>
@item.Description — No coat information available (legacy job item)
</small> </small>
</td> }
</tr> </td>
} </tr>
} }
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="alert alert-info alert-permanent mb-0"> <div class="alert alert-info alert-permanent mb-0">
<i class="bi bi-info-circle me-2"></i> <i class="bi bi-info-circle me-2"></i>
<small>Pre-filled values were already logged via scan inventory is already adjusted for those. You can edit the amount; only the difference will be applied to inventory.</small> <small>Pre-filled values were already logged via scan &mdash; inventory is already adjusted for those. You can edit the amount; only the difference will be applied.</small>
</div> </div>
</div> </div>
} }
@@ -0,0 +1,285 @@
/**
* Log Material Usage modal job details page.
* Reads config from window.__logMaterial injected inline by the view.
*/
(function () {
let _items = [];
let _jobPowderIds = new Set();
let _modal = null;
// ── Combobox state ────────────────────────────────────────────────────────
let _selectedItemId = 0;
function lmComboInput() {
const q = document.getElementById('lmItemSearch')?.value?.toLowerCase() || '';
lmComboRender(q);
lmComboShow();
_selectedItemId = 0;
document.getElementById('lmItemBalance').classList.add('d-none');
lmOnQtyInput();
}
function lmComboOpen() {
const q = document.getElementById('lmItemSearch')?.value?.toLowerCase() || '';
lmComboRender(q);
lmComboShow();
}
function lmComboToggle() {
const dd = document.getElementById('lmItemDropdown');
if (!dd) return;
if (dd.style.display === 'none' || !dd.style.display) {
lmComboOpen();
document.getElementById('lmItemSearch')?.focus();
} else {
lmComboClose();
}
}
function lmMakeRow(it) {
const display = (it.manufacturer ? escLm(it.manufacturer) + ' &ndash; ' : '') +
escLm(it.name) +
(it.unitOfMeasure ? ' <span class="text-muted" style="font-size:.82rem;">(' + escLm(it.unitOfMeasure) + ')</span>' : '');
const label = (it.manufacturer ? it.manufacturer + ' - ' : '') +
it.name +
(it.unitOfMeasure ? ' (' + it.unitOfMeasure + ')' : '');
return `<div class="lm-item-opt" style="padding:.35rem .75rem;font-size:.875rem;cursor:pointer;"
data-id="${it.id}"
data-qty="${it.quantityOnHand}"
data-uom="${escLm(it.unitOfMeasure || '')}"
data-label="${escLm(label)}"
onmousedown="event.preventDefault(); lmComboSelect(this)"
onmouseenter="this.style.background='#f0f4ff'"
onmouseleave="this.classList.contains('lm-active') ? null : this.style.background=''">
${display}
</div>`;
}
function lmComboRender(query) {
const dd = document.getElementById('lmItemDropdown');
if (!dd) return;
const filtered = query
? _items.filter(it => it.name.toLowerCase().includes(query) ||
(it.manufacturer && it.manufacturer.toLowerCase().includes(query)) ||
(it.unitOfMeasure && it.unitOfMeasure.toLowerCase().includes(query)))
: _items;
if (filtered.length === 0) {
dd.innerHTML = '<div class="px-3 py-2 text-muted small">No items match.</div>';
return;
}
const jobItems = filtered.filter(it => _jobPowderIds.has(it.id));
const otherItems = filtered.filter(it => !_jobPowderIds.has(it.id));
let html = '';
if (jobItems.length > 0) {
html += '<div class="px-3 py-1 text-muted" style="font-size:.72rem;letter-spacing:.04em;text-transform:uppercase;background:#f8f9fa;border-bottom:1px solid #dee2e6;">This Job</div>';
html += jobItems.map(lmMakeRow).join('');
if (otherItems.length > 0) {
html += '<div style="height:1px;background:#dee2e6;margin:.25rem 0;"></div>';
html += '<div class="px-3 py-1 text-muted" style="font-size:.72rem;letter-spacing:.04em;text-transform:uppercase;background:#f8f9fa;border-bottom:1px solid #dee2e6;">All Inventory</div>';
}
}
html += otherItems.map(lmMakeRow).join('');
dd.innerHTML = html;
}
function lmComboShow() {
const dd = document.getElementById('lmItemDropdown');
const anchor = document.getElementById('lmItemSearch');
if (!dd || !anchor) return;
const rect = anchor.closest('.input-group').getBoundingClientRect();
dd.style.position = 'fixed';
dd.style.top = (rect.bottom + 2) + 'px';
dd.style.left = rect.left + 'px';
dd.style.width = rect.width + 'px';
dd.style.display = 'block';
}
function lmComboClose() {
const dd = document.getElementById('lmItemDropdown');
if (dd) dd.style.display = 'none';
}
window.lmComboSelect = function (el) {
_selectedItemId = parseInt(el.dataset.id) || 0;
document.getElementById('lmItemSearch').value = el.dataset.label;
lmComboClose();
const qty = parseFloat(el.dataset.qty) || 0;
const uom = el.dataset.uom;
const balDiv = document.getElementById('lmItemBalance');
balDiv.textContent = 'Current stock: ' + qty.toFixed(2) + (uom ? ' ' + uom : '');
balDiv.classList.remove('d-none');
lmOnQtyInput();
};
window.lmComboInput = lmComboInput;
window.lmComboOpen = lmComboOpen;
window.lmComboToggle = lmComboToggle;
window.lmComboKey = function (event) {
const dd = document.getElementById('lmItemDropdown');
if (!dd || dd.style.display === 'none') {
if (event.key === 'ArrowDown' || event.key === 'Enter') {
event.preventDefault();
lmComboOpen();
}
return;
}
const opts = Array.from(dd.querySelectorAll('.lm-item-opt'));
let idx = opts.findIndex(o => o.classList.contains('lm-active'));
if (event.key === 'ArrowDown') {
event.preventDefault();
idx = Math.min(idx + 1, opts.length - 1);
opts.forEach(o => { o.classList.remove('lm-active'); o.style.background = ''; });
if (opts[idx]) { opts[idx].classList.add('lm-active'); opts[idx].style.background = '#e8eeff'; opts[idx].scrollIntoView({ block: 'nearest' }); }
} else if (event.key === 'ArrowUp') {
event.preventDefault();
idx = Math.max(idx - 1, 0);
opts.forEach(o => { o.classList.remove('lm-active'); o.style.background = ''; });
if (opts[idx]) { opts[idx].classList.add('lm-active'); opts[idx].style.background = '#e8eeff'; opts[idx].scrollIntoView({ block: 'nearest' }); }
} else if (event.key === 'Enter') {
event.preventDefault();
const active = dd.querySelector('.lm-active') || opts[0];
if (active) active.dispatchEvent(new MouseEvent('mousedown'));
} else if (event.key === 'Escape') {
lmComboClose();
}
};
function escLm(s) {
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── Quantity / label logic ────────────────────────────────────────────────
function lmOnQtyInput() {
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
if (method !== 'remaining') {
document.getElementById('lmComputedUsed').classList.add('d-none');
return;
}
if (!_selectedItemId) {
document.getElementById('lmComputedUsed').classList.add('d-none');
return;
}
const item = _items.find(it => it.id === _selectedItemId);
const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0;
const remaining = parseFloat(document.getElementById('lmQuantity').value) || 0;
const used = onHand - remaining;
const computedDiv = document.getElementById('lmComputedUsed');
computedDiv.textContent = 'Usage = ' + onHand.toFixed(2) + ' ' + remaining.toFixed(2) + ' = ' + used.toFixed(2) + (item?.unitOfMeasure ? ' ' + item.unitOfMeasure : '');
computedDiv.classList.remove('d-none');
}
window.lmUpdateQuantityLabel = function () {
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
document.getElementById('lmQtyLabel').innerHTML =
(method === 'remaining' ? 'Quantity Remaining' : 'Quantity Used') +
' <span class="text-danger">*</span>';
lmOnQtyInput();
};
// ── Modal open / save ─────────────────────────────────────────────────────
window.openLogMaterialModal = function () {
_selectedItemId = 0;
document.getElementById('lmItemSearch').value = '';
document.getElementById('lmItemBalance').classList.add('d-none');
document.getElementById('lmQuantity').value = '';
document.getElementById('lmComputedUsed').classList.add('d-none');
document.getElementById('lmTransactionType').value = 'JobUsage';
document.getElementById('lmNotes').value = '';
document.getElementById('lmAlert').classList.add('d-none');
document.getElementById('lmSaveBtn').disabled = false;
document.getElementById('lmMethodUsed').checked = true;
window.lmUpdateQuantityLabel();
lmComboClose();
if (_modal) _modal.show();
};
window.lmSave = async function () {
const cfg = window.__logMaterial;
const alertEl = document.getElementById('lmAlert');
function showError(msg) {
alertEl.className = 'alert alert-danger alert-permanent';
alertEl.textContent = msg;
alertEl.classList.remove('d-none');
}
if (!_selectedItemId) { showError('Please select an inventory item.'); return; }
const qtyInput = parseFloat(document.getElementById('lmQuantity').value) || 0;
if (qtyInput <= 0) { showError('Please enter a quantity greater than zero.'); return; }
const method = document.querySelector('input[name="lmEntryMethod"]:checked')?.value;
let quantityUsed = qtyInput;
if (method === 'remaining') {
const item = _items.find(it => it.id === _selectedItemId);
const onHand = item ? (parseFloat(item.quantityOnHand) || 0) : 0;
quantityUsed = onHand - qtyInput;
if (quantityUsed <= 0) {
showError('Remaining quantity cannot be equal to or greater than the current stock (' + onHand.toFixed(2) + ').');
return;
}
}
const btn = document.getElementById('lmSaveBtn');
btn.disabled = true;
alertEl.classList.add('d-none');
try {
const resp = await fetch(cfg.logUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'RequestVerificationToken': cfg.token
},
body: JSON.stringify({
jobId: cfg.jobId,
inventoryItemId: _selectedItemId,
quantityUsed: quantityUsed,
transactionType: document.getElementById('lmTransactionType').value,
notes: document.getElementById('lmNotes').value.trim() || null
})
});
const data = await resp.json();
if (data.success) {
if (_modal) _modal.hide();
window.location.reload();
} else {
showError(data.message || 'An error occurred.');
btn.disabled = false;
}
} catch {
showError('Network error. Please try again.');
btn.disabled = false;
}
};
// ── Init ──────────────────────────────────────────────────────────────────
function init() {
const cfg = window.__logMaterial;
if (!cfg) return;
_items = cfg.inventoryItems || [];
_jobPowderIds = new Set(cfg.jobPowderIds || []);
_modal = new bootstrap.Modal(document.getElementById('logMaterialModal'));
document.getElementById('lmQuantity').addEventListener('input', lmOnQtyInput);
// Close dropdown when clicking outside
document.addEventListener('click', function (e) {
if (!e.target.closest('#lmItemSearch') &&
!e.target.closest('#lmItemDropdown') &&
!e.target.closest('#lmItemDropdownToggle')) {
lmComboClose();
}
});
}
document.addEventListener('DOMContentLoaded', init);
})();