Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 485f0b69c8 | |||
| f380c152ca | |||
| 79c8c7e6a4 | |||
| 6cf355071b | |||
| ebd474ae81 | |||
| 3c390a2e05 | |||
| 0df2353d4f | |||
| be0a5b26e2 | |||
| 36680eced9 | |||
| 27aa4e0ea6 | |||
| b2d6fae400 | |||
| 3a1928f9bf | |||
| df9863a0bb | |||
| 6cefdff18c | |||
| 91a5dbe30c | |||
| b2a1b9a0be | |||
| 1a44133a63 | |||
| 7020797a25 | |||
| 3b5511a703 | |||
| 8df37ca760 | |||
| 7239f55308 | |||
| 09e077897b | |||
| 051c86810e | |||
| 6721de91e4 | |||
| 226a6237a6 | |||
| cf6acc125f | |||
| f467862877 | |||
| 7ad7d84016 | |||
| 75b0a8afe2 | |||
| 38748c2152 | |||
| 4ec55e7290 | |||
| 3eda91f170 | |||
| cefdf3e35c | |||
| f34ee749be | |||
| 357ef84001 | |||
| 7a1a697dc2 | |||
| 539c6c2559 | |||
| a947494cbd | |||
| 7e79a13cb1 | |||
| 2ad6df1195 | |||
| dc3cd75ea4 | |||
| a73f14fa7f | |||
| 0af31c39b3 | |||
| e1256503be | |||
| b69ff6db3a | |||
| 66231822af | |||
| d5ad9fa073 | |||
| d134dd51e5 | |||
| 1df7c13abd | |||
| 4a8778504f | |||
| f1d7054b3e | |||
| 46b950baf2 | |||
| 4e9c9d321a | |||
| 0c8723ef84 | |||
| 377bb1ce38 | |||
| 2acf54e1a9 | |||
| 0b24c320cd | |||
| 350f2d7658 | |||
| 856d202b78 | |||
| 8caaa84eac | |||
| e70f7ee9f1 | |||
| 6a918c2afc | |||
| 27bfd4db4d | |||
| 787d1504ef | |||
| 726bebdce9 | |||
| 786b78e502 | |||
| cb1b6dceb6 | |||
| fb31fa7eb3 | |||
| 637be701ea | |||
| e9cd67f5d9 | |||
| 433090effd |
@@ -129,3 +129,7 @@ DataProtection-Keys/
|
|||||||
# Secrets
|
# Secrets
|
||||||
appsettings.secrets.json
|
appsettings.secrets.json
|
||||||
*.pfx
|
*.pfx
|
||||||
|
|
||||||
|
# Local task tracking
|
||||||
|
TODO.txt
|
||||||
|
TODO.txt.bak
|
||||||
|
|||||||
@@ -478,6 +478,27 @@ All modules below are fully implemented with controllers, views, and migrations
|
|||||||
- In-stock inventory powder: charge for calculated usage only (surface area × lbs/sqft × unit cost)
|
- In-stock inventory powder: charge for calculated usage only (surface area × lbs/sqft × unit cost)
|
||||||
- Tax exempt customers (`Customer.IsTaxExempt`): `TaxPercent` defaults to 0 on quote and invoice create; customer dropdown marks exempt customers with ★
|
- Tax exempt customers (`Customer.IsTaxExempt`): `TaxPercent` defaults to 0 on quote and invoice create; customer dropdown marks exempt customers with ★
|
||||||
|
|
||||||
|
### Pricing Routing Flags — Must Stay In Sync Across All Three Layers
|
||||||
|
|
||||||
|
`PricingCalculationService.CalculateQuoteItemPriceAsync` routes each item to the correct pricing path using boolean flags. **These flags MUST exist identically on `QuoteItem`, `JobItem`, and `CreateQuoteItemDto`, AND be mapped in all three `JobItemAssemblyService.CreateJobItem` overloads.**
|
||||||
|
|
||||||
|
| Flag | Effect if missing on JobItem |
|
||||||
|
|------|------------------------------|
|
||||||
|
| `IsAiItem` | Job repriced as calculated item; oven cost double-charged on every save |
|
||||||
|
| `IsGenericItem` | ManualUnitPrice ignored; price recalculated from surface area |
|
||||||
|
| `IsLaborItem` | Item repriced at surface-area rate instead of hours × labor rate |
|
||||||
|
| `IsSalesItem` | ManualUnitPrice ignored; item repriced using coat/surface math |
|
||||||
|
|
||||||
|
**Checklist when adding a new pricing routing flag:**
|
||||||
|
1. Add the property to `QuoteItem` (Core/Entities)
|
||||||
|
2. Add the property to `JobItem` (Core/Entities)
|
||||||
|
3. Add it to `CreateQuoteItemDto` (Application/DTOs)
|
||||||
|
4. Add it to `JobItemSeed` (private class in JobItemAssemblyService)
|
||||||
|
5. Map it in all three `JobItemAssemblyService.CreateJobItem` overloads
|
||||||
|
6. Include it in every `existingItemsData` JSON block in job views (`Edit.cshtml`, `EditItems.cshtml`) and in all job controller actions that build `CreateQuoteItemDto` from a `JobItem`
|
||||||
|
7. Add a migration if the field is new on a persisted entity
|
||||||
|
8. The structural test `PricingRoutingFlags_ExistOnBothQuoteItemAndJobItem` in `JobItemAssemblyServiceTests` will fail until steps 1–3 are done — this is intentional
|
||||||
|
|
||||||
### Branding
|
### Branding
|
||||||
- Application name: **Powder Coating Logix**
|
- Application name: **Powder Coating Logix**
|
||||||
- PCL logo: `wwwroot/images/pcl-logo.png` — used in sidebar header (when no tenant logo), login/register pages, sidebar footer
|
- PCL logo: `wwwroot/images/pcl-logo.png` — used in sidebar header (when no tenant logo), login/register pages, sidebar footer
|
||||||
|
|||||||
@@ -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
@@ -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!
|
|
||||||
@@ -71,6 +71,11 @@ public class CompanyListDto
|
|||||||
public bool WizardCompleted { get; set; }
|
public bool WizardCompleted { get; set; }
|
||||||
public DateTime? WizardCompletedAt { get; set; }
|
public DateTime? WizardCompletedAt { get; set; }
|
||||||
public string? WizardCompletedByName { get; set; }
|
public string? WizardCompletedByName { get; set; }
|
||||||
|
|
||||||
|
// Health signals — populated by CompaniesController.Index after the count summary query
|
||||||
|
public int HealthScore { get; set; }
|
||||||
|
public string HealthRisk { get; set; } = "Healthy";
|
||||||
|
public DateTime? LastLoginDate { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ public class CompanyPreferencesDto
|
|||||||
// Blank Work Order PDF Template
|
// Blank Work Order PDF Template
|
||||||
public string WoAccentColor { get; set; } = "#374151";
|
public string WoAccentColor { get; set; } = "#374151";
|
||||||
public string? WoTerms { get; set; }
|
public string? WoTerms { get; set; }
|
||||||
|
|
||||||
|
// Kiosk settings
|
||||||
|
public string KioskIntakeOutput { get; set; } = "Quote";
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UpdateAppDefaultsDto
|
public class UpdateAppDefaultsDto
|
||||||
@@ -136,3 +139,11 @@ public class UpdateWorkOrderTemplateDto
|
|||||||
public string WoAccentColor { get; set; } = "#374151";
|
public string WoAccentColor { get; set; } = "#374151";
|
||||||
[StringLength(2000)] public string? WoTerms { get; set; }
|
[StringLength(2000)] public string? WoTerms { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public class UpdateKioskSettingsDto
|
||||||
|
{
|
||||||
|
/// <summary>"Quote" (default) or "Job" — what the kiosk creates on submission.</summary>
|
||||||
|
[Required]
|
||||||
|
public string KioskIntakeOutput { get; set; } = "Quote";
|
||||||
|
}
|
||||||
|
|||||||
@@ -112,6 +112,7 @@ namespace PowderCoating.Application.DTOs.Company
|
|||||||
|
|
||||||
// Labor Rates
|
// Labor Rates
|
||||||
public decimal StandardLaborRate { get; set; }
|
public decimal StandardLaborRate { get; set; }
|
||||||
|
public decimal? LaborCostPerHour { get; set; }
|
||||||
public decimal AdditionalCoatLaborPercent { get; set; }
|
public decimal AdditionalCoatLaborPercent { get; set; }
|
||||||
|
|
||||||
// Equipment Operating Costs
|
// Equipment Operating Costs
|
||||||
@@ -185,6 +186,10 @@ namespace PowderCoating.Application.DTOs.Company
|
|||||||
[Display(Name = "Standard Labor Rate ($/hr)")]
|
[Display(Name = "Standard Labor Rate ($/hr)")]
|
||||||
public decimal StandardLaborRate { get; set; }
|
public decimal StandardLaborRate { get; set; }
|
||||||
|
|
||||||
|
[Range(0, 10000, ErrorMessage = "Labor cost rate must be between 0 and 10,000")]
|
||||||
|
[Display(Name = "Shop Labor Cost Rate ($/hr)")]
|
||||||
|
public decimal? LaborCostPerHour { get; set; }
|
||||||
|
|
||||||
[Range(0, 100, ErrorMessage = "Additional coat labor percent must be between 0 and 100")]
|
[Range(0, 100, ErrorMessage = "Additional coat labor percent must be between 0 and 100")]
|
||||||
[Display(Name = "Additional Coat Labor (%)")]
|
[Display(Name = "Additional Coat Labor (%)")]
|
||||||
public decimal AdditionalCoatLaborPercent { get; set; } = 30m;
|
public decimal AdditionalCoatLaborPercent { get; set; } = 30m;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public class GiftCertificateListDto
|
|||||||
public GiftCertificateStatus Status { get; set; }
|
public GiftCertificateStatus Status { get; set; }
|
||||||
public DateTime IssueDate { get; set; }
|
public DateTime IssueDate { get; set; }
|
||||||
public DateTime? ExpiryDate { get; set; }
|
public DateTime? ExpiryDate { get; set; }
|
||||||
|
public Guid? BatchId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class GiftCertificateDto : GiftCertificateListDto
|
public class GiftCertificateDto : GiftCertificateListDto
|
||||||
@@ -87,3 +88,27 @@ public class RedeemGiftCertificateDto
|
|||||||
[Range(0.01, 9999.99)]
|
[Range(0.01, 9999.99)]
|
||||||
public decimal Amount { get; set; }
|
public decimal Amount { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class BulkCreateGiftCertificateDto
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[Range(1, 500, ErrorMessage = "Quantity must be between 1 and 500.")]
|
||||||
|
[Display(Name = "Number of Certificates")]
|
||||||
|
public int Quantity { get; set; } = 25;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Range(1.00, 9999.99, ErrorMessage = "Amount must be between $1.00 and $9,999.99.")]
|
||||||
|
[Display(Name = "Face Value (each)")]
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Display(Name = "Issued Reason")]
|
||||||
|
public GiftCertificateIssuedReason IssuedReason { get; set; } = GiftCertificateIssuedReason.Promotional;
|
||||||
|
|
||||||
|
[Display(Name = "Expiry Date (optional)")]
|
||||||
|
public DateTime? ExpiryDate { get; set; }
|
||||||
|
|
||||||
|
[StringLength(1000)]
|
||||||
|
[Display(Name = "Event / Notes (applied to all certificates)")]
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
using CsvHelper.Configuration.Attributes;
|
|
||||||
|
|
||||||
namespace PowderCoating.Application.DTOs.Import;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// DTO for importing shop workers from CSV files.
|
|
||||||
/// Valid Role values: GeneralLabor, Sandblaster, Coater, Masker, QualityControl, OvenOperator, Supervisor, Maintenance
|
|
||||||
/// </summary>
|
|
||||||
public class ShopWorkerImportDto
|
|
||||||
{
|
|
||||||
[Name("Name")]
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[Name("Role")]
|
|
||||||
public string Role { get; set; } = "GeneralLabor";
|
|
||||||
|
|
||||||
[Name("Phone")]
|
|
||||||
public string? Phone { get; set; }
|
|
||||||
|
|
||||||
[Name("Email")]
|
|
||||||
public string? Email { get; set; }
|
|
||||||
|
|
||||||
[Name("IsActive")]
|
|
||||||
public bool? IsActive { get; set; }
|
|
||||||
|
|
||||||
[Name("Notes")]
|
|
||||||
public string? Notes { get; set; }
|
|
||||||
}
|
|
||||||
@@ -32,7 +32,9 @@ public class InvoiceDto
|
|||||||
public string CustomerName { get; set; } = string.Empty;
|
public string CustomerName { get; set; } = string.Empty;
|
||||||
public string? CustomerEmail { get; set; }
|
public string? CustomerEmail { get; set; }
|
||||||
public string? CustomerPhone { get; set; }
|
public string? CustomerPhone { get; set; }
|
||||||
|
public string? CustomerMobilePhone { get; set; }
|
||||||
public bool CustomerNotifyByEmail { get; set; }
|
public bool CustomerNotifyByEmail { get; set; }
|
||||||
|
public bool CustomerNotifyBySms { get; set; }
|
||||||
public string? PreparedById { get; set; }
|
public string? PreparedById { get; set; }
|
||||||
public string? PreparedByName { get; set; }
|
public string? PreparedByName { get; set; }
|
||||||
public InvoiceStatus Status { get; set; }
|
public InvoiceStatus Status { get; set; }
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ public class IssueRefundDto
|
|||||||
public decimal Amount { get; set; }
|
public decimal Amount { get; set; }
|
||||||
public DateTime RefundDate { get; set; } = DateTime.Today;
|
public DateTime RefundDate { get; set; } = DateTime.Today;
|
||||||
public PaymentMethod RefundMethod { get; set; }
|
public PaymentMethod RefundMethod { get; set; }
|
||||||
|
/// <summary>Bank/cash account money leaves when issuing a cash/card refund. Null for store credit.</summary>
|
||||||
|
public int? DepositAccountId { get; set; }
|
||||||
public string Reason { get; set; } = string.Empty;
|
public string Reason { get; set; } = string.Empty;
|
||||||
public string? Reference { get; set; }
|
public string? Reference { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,6 +515,9 @@ public class JobEditItemsViewModel
|
|||||||
public string JobNumber { get; set; } = string.Empty;
|
public string JobNumber { get; set; } = string.Empty;
|
||||||
public int? CustomerId { get; set; }
|
public int? CustomerId { get; set; }
|
||||||
public decimal TaxPercent { get; set; }
|
public decimal TaxPercent { get; set; }
|
||||||
|
public int? OvenCostId { get; set; }
|
||||||
|
public int OvenBatches { get; set; } = 1;
|
||||||
|
public int? OvenCycleMinutes { get; set; }
|
||||||
public List<CreateQuoteItemDto> JobItems { get; set; } = new();
|
public List<CreateQuoteItemDto> JobItems { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using PowderCoating.Core.Enums;
|
||||||
|
|
||||||
|
namespace PowderCoating.Application.DTOs.Kiosk;
|
||||||
|
|
||||||
|
// ── Staff-facing ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Input for sending a remote intake link to a customer by email.</summary>
|
||||||
|
public class SendRemoteLinkDto
|
||||||
|
{
|
||||||
|
[Required, EmailAddress]
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Optional — used to personalise the email greeting.</summary>
|
||||||
|
public string? CustomerName { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Customer-facing step DTOs ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Step 1 — Contact information submitted by the customer.</summary>
|
||||||
|
public class SubmitKioskContactDto
|
||||||
|
{
|
||||||
|
[Required, MaxLength(100)]
|
||||||
|
public string FirstName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required, MaxLength(100)]
|
||||||
|
public string LastName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required, Phone]
|
||||||
|
public string Phone { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required, EmailAddress]
|
||||||
|
public string Email { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public bool IsReturningCustomer { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Step 2 — Job description submitted by the customer.</summary>
|
||||||
|
public class SubmitKioskJobDto
|
||||||
|
{
|
||||||
|
[Required, MaxLength(2000)]
|
||||||
|
public string JobDescription { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string? HowDidYouHearAboutUs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Step 3 — Terms agreement (+ optional drawn signature for in-person sessions).</summary>
|
||||||
|
public class SubmitKioskTermsDto
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[Range(typeof(bool), "true", "true", ErrorMessage = "You must agree to the terms to continue.")]
|
||||||
|
public bool AgreedToTerms { get; set; }
|
||||||
|
|
||||||
|
public bool SmsOptIn { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Base-64 PNG from signature_pad; required for InPerson sessions, null for Remote.</summary>
|
||||||
|
public string? SignatureDataBase64 { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Staff review list ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>One row in the Kiosk Intakes staff review list.</summary>
|
||||||
|
public class KioskSessionListDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public Guid SessionToken { get; set; }
|
||||||
|
public KioskSessionType SessionType { get; set; }
|
||||||
|
public KioskSessionStatus Status { get; set; }
|
||||||
|
public string CustomerFirstName { get; set; } = string.Empty;
|
||||||
|
public string CustomerLastName { get; set; } = string.Empty;
|
||||||
|
public string CustomerEmail { get; set; } = string.Empty;
|
||||||
|
public string CustomerPhone { get; set; } = string.Empty;
|
||||||
|
public string JobDescription { get; set; } = string.Empty;
|
||||||
|
public bool SmsOptIn { get; set; }
|
||||||
|
public DateTime? SubmittedAt { get; set; }
|
||||||
|
public DateTime ExpiresAt { get; set; }
|
||||||
|
public int? LinkedCustomerId { get; set; }
|
||||||
|
public int? LinkedJobId { get; set; }
|
||||||
|
public int? LinkedQuoteId { get; set; }
|
||||||
|
public string? RemoteLinkEmail { get; set; }
|
||||||
|
|
||||||
|
public string CustomerFullName => $"{CustomerFirstName} {CustomerLastName}".Trim();
|
||||||
|
public string JobDescriptionSnippet =>
|
||||||
|
JobDescription.Length > 80 ? JobDescription[..80] + "…" : JobDescription;
|
||||||
|
public bool IsConverted => LinkedJobId.HasValue || LinkedQuoteId.HasValue;
|
||||||
|
public bool IsExpired => Status == KioskSessionStatus.Expired ||
|
||||||
|
(Status == KioskSessionStatus.Active && DateTime.UtcNow > ExpiresAt);
|
||||||
|
}
|
||||||
@@ -604,6 +604,11 @@ public class QuotePricingBreakdownDto
|
|||||||
|
|
||||||
public decimal SubtotalBeforeDiscount { get; set; }
|
public decimal SubtotalBeforeDiscount { get; set; }
|
||||||
|
|
||||||
|
public decimal PricingTierDiscountAmount { get; set; }
|
||||||
|
public decimal PricingTierDiscountPercent { get; set; }
|
||||||
|
public decimal QuoteDiscountAmount { get; set; }
|
||||||
|
public decimal QuoteDiscountPercent { get; set; }
|
||||||
|
|
||||||
public decimal DiscountAmount { get; set; }
|
public decimal DiscountAmount { get; set; }
|
||||||
public decimal DiscountPercent { get; set; }
|
public decimal DiscountPercent { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using PowderCoating.Core.Enums;
|
|
||||||
|
|
||||||
namespace PowderCoating.Application.DTOs.ShopWorker;
|
|
||||||
|
|
||||||
public class CreateShopWorkerDto
|
|
||||||
{
|
|
||||||
[Required(ErrorMessage = "Worker name is required")]
|
|
||||||
[StringLength(100, ErrorMessage = "Name cannot exceed 100 characters")]
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[Required(ErrorMessage = "Role is required")]
|
|
||||||
public ShopWorkerRole Role { get; set; } = ShopWorkerRole.GeneralLabor;
|
|
||||||
|
|
||||||
[Phone(ErrorMessage = "Invalid phone number format")]
|
|
||||||
[StringLength(20, ErrorMessage = "Phone cannot exceed 20 characters")]
|
|
||||||
public string? Phone { get; set; }
|
|
||||||
|
|
||||||
[EmailAddress(ErrorMessage = "Invalid email address format")]
|
|
||||||
[StringLength(100, ErrorMessage = "Email cannot exceed 100 characters")]
|
|
||||||
public string? Email { get; set; }
|
|
||||||
|
|
||||||
public bool IsActive { get; set; } = true;
|
|
||||||
|
|
||||||
[StringLength(500, ErrorMessage = "Notes cannot exceed 500 characters")]
|
|
||||||
public string? Notes { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
using PowderCoating.Core.Enums;
|
|
||||||
|
|
||||||
namespace PowderCoating.Application.DTOs.ShopWorker;
|
|
||||||
|
|
||||||
public class ShopWorkerDto
|
|
||||||
{
|
|
||||||
public int Id { get; set; }
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
public ShopWorkerRole Role { get; set; }
|
|
||||||
public string? Phone { get; set; }
|
|
||||||
public string? Email { get; set; }
|
|
||||||
public bool IsActive { get; set; }
|
|
||||||
public string? Notes { get; set; }
|
|
||||||
public DateTime CreatedAt { get; set; }
|
|
||||||
public DateTime? UpdatedAt { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using PowderCoating.Core.Enums;
|
|
||||||
|
|
||||||
namespace PowderCoating.Application.DTOs.ShopWorker;
|
|
||||||
|
|
||||||
public class UpdateShopWorkerDto
|
|
||||||
{
|
|
||||||
public int Id { get; set; }
|
|
||||||
|
|
||||||
[Required(ErrorMessage = "Worker name is required")]
|
|
||||||
[StringLength(100, ErrorMessage = "Name cannot exceed 100 characters")]
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[Required(ErrorMessage = "Role is required")]
|
|
||||||
public ShopWorkerRole Role { get; set; }
|
|
||||||
|
|
||||||
[Phone(ErrorMessage = "Invalid phone number format")]
|
|
||||||
[StringLength(20, ErrorMessage = "Phone cannot exceed 20 characters")]
|
|
||||||
public string? Phone { get; set; }
|
|
||||||
|
|
||||||
[EmailAddress(ErrorMessage = "Invalid email address format")]
|
|
||||||
[StringLength(100, ErrorMessage = "Email cannot exceed 100 characters")]
|
|
||||||
public string? Email { get; set; }
|
|
||||||
|
|
||||||
public bool IsActive { get; set; }
|
|
||||||
|
|
||||||
[StringLength(500, ErrorMessage = "Notes cannot exceed 500 characters")]
|
|
||||||
public string? Notes { get; set; }
|
|
||||||
}
|
|
||||||
@@ -217,6 +217,10 @@ public class UpdateCompanyUserDto
|
|||||||
[Display(Name = "Active")]
|
[Display(Name = "Active")]
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
|
|
||||||
|
[Range(0, 10000, ErrorMessage = "Labor cost rate must be between 0 and 10,000")]
|
||||||
|
[Display(Name = "Labor Cost Rate ($/hr)")]
|
||||||
|
public decimal? LaborCostPerHour { get; set; }
|
||||||
|
|
||||||
[Required(ErrorMessage = "Hire date is required")]
|
[Required(ErrorMessage = "Hire date is required")]
|
||||||
[Display(Name = "Hire Date")]
|
[Display(Name = "Hire Date")]
|
||||||
public DateTime HireDate { get; set; }
|
public DateTime HireDate { get; set; }
|
||||||
|
|||||||
@@ -136,18 +136,7 @@ public interface ICsvImportService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
Task<CsvImportResultDto> ImportVendorsAsync(Stream csvStream, int companyId);
|
Task<CsvImportResultDto> ImportVendorsAsync(Stream csvStream, int companyId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generate a CSV template file for shop worker imports.
|
|
||||||
/// </summary>
|
|
||||||
byte[] GenerateShopWorkerTemplate();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Import shop workers from a CSV stream.
|
|
||||||
/// Updates existing workers matched by Name; creates new ones otherwise.
|
|
||||||
/// </summary>
|
|
||||||
Task<CsvImportResultDto> ImportShopWorkersAsync(Stream csvStream, int companyId);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generate a CSV template file for prep service imports.
|
/// Generate a CSV template file for prep service imports.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
byte[] GeneratePrepServiceTemplate();
|
byte[] GeneratePrepServiceTemplate();
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ public interface INotificationService
|
|||||||
/// Notify customer when an invoice has been sent.
|
/// Notify customer when an invoice has been sent.
|
||||||
/// Optionally includes an online payment link in the email body.
|
/// Optionally includes an online payment link in the email body.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null);
|
Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null, bool sendSms = false, string? viewUrl = null);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Notify customer (internal) when a payment has been recorded on an invoice.
|
/// Notify customer (internal) when a payment has been recorded on an invoice.
|
||||||
|
|||||||
@@ -51,4 +51,10 @@ public interface IPdfService
|
|||||||
byte[]? companyLogo,
|
byte[]? companyLogo,
|
||||||
string? companyLogoContentType,
|
string? companyLogoContentType,
|
||||||
CompanyInfoDto companyInfo);
|
CompanyInfoDto companyInfo);
|
||||||
|
|
||||||
|
Task<byte[]> GenerateBulkGiftCertificatePdfAsync(
|
||||||
|
IList<GiftCertificateDto> certs,
|
||||||
|
byte[]? companyLogo,
|
||||||
|
string? companyLogoContentType,
|
||||||
|
CompanyInfoDto companyInfo);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,5 +54,6 @@ public class CompanyProfile : Profile
|
|||||||
CreateMap<UpdateQuoteTemplateDto, CompanyPreferences>();
|
CreateMap<UpdateQuoteTemplateDto, CompanyPreferences>();
|
||||||
CreateMap<UpdateInvoiceTemplateDto, CompanyPreferences>();
|
CreateMap<UpdateInvoiceTemplateDto, CompanyPreferences>();
|
||||||
CreateMap<UpdateWorkOrderTemplateDto, CompanyPreferences>();
|
CreateMap<UpdateWorkOrderTemplateDto, CompanyPreferences>();
|
||||||
|
CreateMap<UpdateKioskSettingsDto, CompanyPreferences>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ public class InvoiceProfile : Profile
|
|||||||
? (s.Customer.BillingEmail ?? s.Customer.Email)
|
? (s.Customer.BillingEmail ?? s.Customer.Email)
|
||||||
: null))
|
: null))
|
||||||
.ForMember(d => d.CustomerPhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.Phone : null))
|
.ForMember(d => d.CustomerPhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.Phone : null))
|
||||||
|
.ForMember(d => d.CustomerMobilePhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.MobilePhone : null))
|
||||||
.ForMember(d => d.CustomerNotifyByEmail, o => o.MapFrom(s => s.Customer == null || s.Customer.NotifyByEmail))
|
.ForMember(d => d.CustomerNotifyByEmail, o => o.MapFrom(s => s.Customer == null || s.Customer.NotifyByEmail))
|
||||||
|
.ForMember(d => d.CustomerNotifyBySms, o => o.MapFrom(s => s.Customer != null && s.Customer.NotifyBySms))
|
||||||
.ForMember(d => d.PreparedByName, o => o.MapFrom(s => s.PreparedBy != null
|
.ForMember(d => d.PreparedByName, o => o.MapFrom(s => s.PreparedBy != null
|
||||||
? $"{s.PreparedBy.FirstName} {s.PreparedBy.LastName}".Trim()
|
? $"{s.PreparedBy.FirstName} {s.PreparedBy.LastName}".Trim()
|
||||||
: null))
|
: null))
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ public class JobProfile : Profile
|
|||||||
// JobTimeEntry → JobTimeEntryDto
|
// JobTimeEntry → JobTimeEntryDto
|
||||||
CreateMap<JobTimeEntry, JobTimeEntryDto>()
|
CreateMap<JobTimeEntry, JobTimeEntryDto>()
|
||||||
.ForMember(dest => dest.WorkerName, opt => opt.MapFrom(src =>
|
.ForMember(dest => dest.WorkerName, opt => opt.MapFrom(src =>
|
||||||
src.UserDisplayName ?? (src.Worker != null ? src.Worker.Name : string.Empty)));
|
src.UserDisplayName ?? string.Empty));
|
||||||
|
|
||||||
// CreateJobDto to Job
|
// CreateJobDto to Job
|
||||||
CreateMap<CreateJobDto, Job>()
|
CreateMap<CreateJobDto, Job>()
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
using AutoMapper;
|
|
||||||
using PowderCoating.Application.DTOs.ShopWorker;
|
|
||||||
using PowderCoating.Core.Entities;
|
|
||||||
|
|
||||||
namespace PowderCoating.Application.Mappings;
|
|
||||||
|
|
||||||
public class ShopWorkerProfile : Profile
|
|
||||||
{
|
|
||||||
public ShopWorkerProfile()
|
|
||||||
{
|
|
||||||
// Entity to DTO
|
|
||||||
CreateMap<ShopWorker, ShopWorkerDto>();
|
|
||||||
|
|
||||||
// DTO to Entity
|
|
||||||
CreateMap<CreateShopWorkerDto, ShopWorker>();
|
|
||||||
CreateMap<UpdateShopWorkerDto, ShopWorker>();
|
|
||||||
|
|
||||||
// Reverse mappings
|
|
||||||
CreateMap<ShopWorkerDto, ShopWorker>();
|
|
||||||
CreateMap<ShopWorker, CreateShopWorkerDto>();
|
|
||||||
CreateMap<ShopWorker, UpdateShopWorkerDto>();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,12 +21,13 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
IsGenericItem = source.IsGenericItem,
|
IsGenericItem = source.IsGenericItem,
|
||||||
IsLaborItem = source.IsLaborItem,
|
IsLaborItem = source.IsLaborItem,
|
||||||
IsSalesItem = source.IsSalesItem,
|
IsSalesItem = source.IsSalesItem,
|
||||||
|
IsAiItem = source.IsAiItem,
|
||||||
Sku = source.Sku,
|
Sku = source.Sku,
|
||||||
ManualUnitPrice = source.ManualUnitPrice,
|
ManualUnitPrice = source.ManualUnitPrice,
|
||||||
PowderCostOverride = source.PowderCostOverride,
|
PowderCostOverride = source.PowderCostOverride,
|
||||||
UnitPrice = pricing.UnitPrice,
|
UnitPrice = pricing.UnitPrice,
|
||||||
TotalPrice = pricing.TotalPrice,
|
TotalPrice = pricing.TotalPrice,
|
||||||
LaborCost = pricing.TotalPrice * 0.4m,
|
LaborCost = pricing.LaborCost,
|
||||||
RequiresSandblasting = source.RequiresSandblasting,
|
RequiresSandblasting = source.RequiresSandblasting,
|
||||||
RequiresMasking = source.RequiresMasking,
|
RequiresMasking = source.RequiresMasking,
|
||||||
EstimatedMinutes = source.EstimatedMinutes,
|
EstimatedMinutes = source.EstimatedMinutes,
|
||||||
@@ -106,12 +107,13 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
IsGenericItem = source.IsGenericItem,
|
IsGenericItem = source.IsGenericItem,
|
||||||
IsLaborItem = source.IsLaborItem,
|
IsLaborItem = source.IsLaborItem,
|
||||||
IsSalesItem = source.IsSalesItem,
|
IsSalesItem = source.IsSalesItem,
|
||||||
|
IsAiItem = source.IsAiItem,
|
||||||
Sku = source.Sku,
|
Sku = source.Sku,
|
||||||
ManualUnitPrice = source.ManualUnitPrice,
|
ManualUnitPrice = source.ManualUnitPrice,
|
||||||
PowderCostOverride = source.PowderCostOverride,
|
PowderCostOverride = source.PowderCostOverride,
|
||||||
UnitPrice = source.UnitPrice,
|
UnitPrice = source.UnitPrice,
|
||||||
TotalPrice = source.TotalPrice,
|
TotalPrice = source.TotalPrice,
|
||||||
LaborCost = source.TotalPrice * 0.4m,
|
LaborCost = source.ItemLaborCost,
|
||||||
RequiresSandblasting = source.RequiresSandblasting,
|
RequiresSandblasting = source.RequiresSandblasting,
|
||||||
RequiresMasking = source.RequiresMasking,
|
RequiresMasking = source.RequiresMasking,
|
||||||
EstimatedMinutes = source.EstimatedMinutes,
|
EstimatedMinutes = source.EstimatedMinutes,
|
||||||
@@ -191,6 +193,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
IsGenericItem = source.IsGenericItem,
|
IsGenericItem = source.IsGenericItem,
|
||||||
IsLaborItem = source.IsLaborItem,
|
IsLaborItem = source.IsLaborItem,
|
||||||
IsSalesItem = source.IsSalesItem,
|
IsSalesItem = source.IsSalesItem,
|
||||||
|
IsAiItem = source.IsAiItem,
|
||||||
Sku = source.Sku,
|
Sku = source.Sku,
|
||||||
ManualUnitPrice = source.ManualUnitPrice,
|
ManualUnitPrice = source.ManualUnitPrice,
|
||||||
PowderCostOverride = source.PowderCostOverride,
|
PowderCostOverride = source.PowderCostOverride,
|
||||||
@@ -270,6 +273,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
IsGenericItem = seed.IsGenericItem,
|
IsGenericItem = seed.IsGenericItem,
|
||||||
IsLaborItem = seed.IsLaborItem,
|
IsLaborItem = seed.IsLaborItem,
|
||||||
IsSalesItem = seed.IsSalesItem,
|
IsSalesItem = seed.IsSalesItem,
|
||||||
|
IsAiItem = seed.IsAiItem,
|
||||||
Sku = seed.Sku,
|
Sku = seed.Sku,
|
||||||
ManualUnitPrice = seed.ManualUnitPrice,
|
ManualUnitPrice = seed.ManualUnitPrice,
|
||||||
PowderCostOverride = seed.PowderCostOverride,
|
PowderCostOverride = seed.PowderCostOverride,
|
||||||
@@ -364,6 +368,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
public bool IsGenericItem { get; init; }
|
public bool IsGenericItem { get; init; }
|
||||||
public bool IsLaborItem { get; init; }
|
public bool IsLaborItem { get; init; }
|
||||||
public bool IsSalesItem { get; init; }
|
public bool IsSalesItem { get; init; }
|
||||||
|
public bool IsAiItem { get; init; }
|
||||||
public string? Sku { get; init; }
|
public string? Sku { get; init; }
|
||||||
public decimal? ManualUnitPrice { get; init; }
|
public decimal? ManualUnitPrice { get; init; }
|
||||||
public decimal? PowderCostOverride { get; init; }
|
public decimal? PowderCostOverride { get; init; }
|
||||||
|
|||||||
@@ -1858,6 +1858,50 @@ public class PdfService : IPdfService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a multi-page PDF containing one gift certificate per page, all using the same
|
||||||
|
/// branded layout as the single-certificate download. Used for bulk print runs (car shows,
|
||||||
|
/// promotions) so staff can hand-cut and distribute a full batch from one print job.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<byte[]> GenerateBulkGiftCertificatePdfAsync(
|
||||||
|
IList<GiftCertificateDto> certs,
|
||||||
|
byte[]? companyLogo,
|
||||||
|
string? companyLogoContentType,
|
||||||
|
CompanyInfoDto companyInfo)
|
||||||
|
{
|
||||||
|
QuestPDF.Settings.License = LicenseType.Community;
|
||||||
|
const string accent = "#7c3aed";
|
||||||
|
const string gold = "#b45309";
|
||||||
|
|
||||||
|
return await Task.Run(() =>
|
||||||
|
{
|
||||||
|
var doc = Document.Create(container =>
|
||||||
|
{
|
||||||
|
foreach (var cert in certs)
|
||||||
|
{
|
||||||
|
container.Page(page =>
|
||||||
|
{
|
||||||
|
page.Size(PageSizes.Letter);
|
||||||
|
page.Margin(0.75f, Unit.Inch);
|
||||||
|
page.PageColor(Colors.White);
|
||||||
|
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
|
||||||
|
|
||||||
|
page.Content().Element(c => ComposeGiftCertificateContent(c, cert, companyInfo, companyLogo, accent, gold));
|
||||||
|
|
||||||
|
page.Footer().AlignCenter().Text(text =>
|
||||||
|
{
|
||||||
|
text.Span(companyInfo.CompanyName).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||||
|
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
|
||||||
|
text.Span($" · {FormatPhoneNumber(companyInfo.Phone)}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return doc.GeneratePdf();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Composes the gift certificate body with a decorative double-border frame (outer purple 3pt,
|
/// Composes the gift certificate body with a decorative double-border frame (outer purple 3pt,
|
||||||
/// inner gold 1pt) that gives the document a premium printed-certificate appearance. Inside the
|
/// inner gold 1pt) that gives the document a premium printed-certificate appearance. Inside the
|
||||||
|
|||||||
@@ -30,23 +30,30 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
ArgumentNullException.ThrowIfNull(quote);
|
ArgumentNullException.ThrowIfNull(quote);
|
||||||
ArgumentNullException.ThrowIfNull(pricingResult);
|
ArgumentNullException.ThrowIfNull(pricingResult);
|
||||||
|
|
||||||
quote.MaterialCosts = pricingResult.MaterialCosts;
|
quote.MaterialCosts = pricingResult.MaterialCosts;
|
||||||
quote.LaborCosts = pricingResult.LaborCosts;
|
quote.LaborCosts = pricingResult.LaborCosts;
|
||||||
quote.EquipmentCosts = pricingResult.EquipmentCosts;
|
quote.EquipmentCosts = pricingResult.EquipmentCosts;
|
||||||
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
|
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
|
||||||
quote.OvenBatchCost = pricingResult.OvenBatchCost;
|
quote.OvenBatchCost = pricingResult.OvenBatchCost;
|
||||||
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
|
quote.FacilityOverheadCost = pricingResult.FacilityOverheadCost;
|
||||||
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
|
quote.FacilityOverheadRatePerHour = pricingResult.FacilityOverheadRatePerHour;
|
||||||
quote.OverheadAmount = pricingResult.OverheadCosts;
|
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
|
||||||
quote.OverheadPercent = pricingResult.OverheadPercent;
|
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
|
||||||
quote.ProfitMargin = pricingResult.ProfitMargin;
|
quote.OverheadAmount = pricingResult.OverheadCosts;
|
||||||
quote.ProfitPercent = pricingResult.ProfitPercent;
|
quote.OverheadPercent = pricingResult.OverheadPercent;
|
||||||
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
|
quote.ProfitMargin = pricingResult.ProfitMargin;
|
||||||
quote.DiscountPercent = pricingResult.DiscountPercent;
|
quote.ProfitPercent = pricingResult.ProfitPercent;
|
||||||
quote.DiscountAmount = pricingResult.DiscountAmount;
|
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
|
||||||
quote.RushFee = pricingResult.RushFee;
|
quote.PricingTierDiscountAmount = pricingResult.PricingTierDiscountAmount;
|
||||||
quote.TaxAmount = pricingResult.TaxAmount;
|
quote.PricingTierDiscountPercent = pricingResult.PricingTierDiscountPercent;
|
||||||
quote.Total = pricingResult.Total;
|
quote.QuoteDiscountAmount = pricingResult.QuoteDiscountAmount;
|
||||||
|
quote.QuoteDiscountPercent = pricingResult.QuoteDiscountPercent;
|
||||||
|
quote.DiscountPercent = pricingResult.DiscountPercent;
|
||||||
|
quote.DiscountAmount = pricingResult.DiscountAmount;
|
||||||
|
quote.SubtotalAfterDiscount = pricingResult.SubtotalAfterDiscount;
|
||||||
|
quote.RushFee = pricingResult.RushFee;
|
||||||
|
quote.TaxAmount = pricingResult.TaxAmount;
|
||||||
|
quote.Total = pricingResult.Total;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
|
public async Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
|
||||||
|
|||||||
@@ -246,6 +246,8 @@ public class VendorCredit : BaseEntity
|
|||||||
public decimal Total { get; set; }
|
public decimal Total { get; set; }
|
||||||
public decimal RemainingAmount { get; set; }
|
public decimal RemainingAmount { get; set; }
|
||||||
public string? Memo { get; set; }
|
public string? Memo { get; set; }
|
||||||
|
/// <summary>Set by Post() when GL entries are made (DR AP / CR expense lines). Null = unposted.</summary>
|
||||||
|
public DateTime? PostedDate { get; set; }
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
public virtual Vendor Vendor { get; set; } = null!;
|
public virtual Vendor Vendor { get; set; } = null!;
|
||||||
|
|||||||
@@ -58,7 +58,14 @@ public class ApplicationUser : IdentityUser
|
|||||||
|
|
||||||
public string? SidebarColor { get; set; } = "ocean";
|
public string? SidebarColor { get; set; } = "ocean";
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Per-worker labor cost per hour used for job costing profit/margin calculations.
|
||||||
|
/// Overrides the company-level LaborCostPerHour when set.
|
||||||
|
/// Leave null to use the company default.
|
||||||
|
/// </summary>
|
||||||
|
public decimal? LaborCostPerHour { get; set; }
|
||||||
|
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
public DateTime? UpdatedAt { get; set; }
|
public DateTime? UpdatedAt { get; set; }
|
||||||
public DateTime? LastLoginDate { get; set; }
|
public DateTime? LastLoginDate { get; set; }
|
||||||
|
|||||||
@@ -123,6 +123,16 @@ public class Company : BaseEntity
|
|||||||
public byte[]? LogoData { get; set; } // Legacy - kept for backward compatibility
|
public byte[]? LogoData { get; set; } // Legacy - kept for backward compatibility
|
||||||
public string? LogoContentType { get; set; } // Legacy - kept for backward compatibility
|
public string? LogoContentType { get; set; } // Legacy - kept for backward compatibility
|
||||||
public string? LogoFilePath { get; set; } // Filesystem path: /media/{CompanyId}/company-logo.{ext}
|
public string? LogoFilePath { get; set; } // Filesystem path: /media/{CompanyId}/company-logo.{ext}
|
||||||
|
|
||||||
|
// Kiosk
|
||||||
|
/// <summary>
|
||||||
|
/// Random token written to a long-lived HttpOnly cookie on the front-desk tablet when the
|
||||||
|
/// owner activates the kiosk. Kiosk routes validate this token against the cookie so the
|
||||||
|
/// tablet can serve the intake form without requiring a logged-in user.
|
||||||
|
/// Null = kiosk not activated. Regenerate to revoke the current device.
|
||||||
|
/// </summary>
|
||||||
|
public string? KioskActivationToken { get; set; }
|
||||||
|
|
||||||
// Navigation Properties
|
// Navigation Properties
|
||||||
public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>();
|
public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>();
|
||||||
public virtual ICollection<Customer> Customers { get; set; } = new List<Customer>();
|
public virtual ICollection<Customer> Customers { get; set; } = new List<Customer>();
|
||||||
@@ -131,8 +141,7 @@ public class Company : BaseEntity
|
|||||||
public virtual ICollection<Quote> Quotes { get; set; } = new List<Quote>();
|
public virtual ICollection<Quote> Quotes { get; set; } = new List<Quote>();
|
||||||
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
|
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
|
||||||
public virtual ICollection<Vendor> Vendors { get; set; } = new List<Vendor>();
|
public virtual ICollection<Vendor> Vendors { get; set; } = new List<Vendor>();
|
||||||
public virtual ICollection<ShopWorker> ShopWorkers { get; set; } = new List<ShopWorker>();
|
public virtual ICollection<PricingTier> PricingTiers { get; set; } = new List<PricingTier>();
|
||||||
public virtual ICollection<PricingTier> PricingTiers { get; set; } = new List<PricingTier>();
|
|
||||||
public virtual CompanyOperatingCosts? OperatingCosts { get; set; }
|
public virtual CompanyOperatingCosts? OperatingCosts { get; set; }
|
||||||
public virtual CompanyPreferences? Preferences { get; set; }
|
public virtual CompanyPreferences? Preferences { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ namespace PowderCoating.Core.Entities
|
|||||||
[Range(0, 10000)]
|
[Range(0, 10000)]
|
||||||
public decimal StandardLaborRate { get; set; }
|
public decimal StandardLaborRate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Actual labor cost per hour (wages + burden) used exclusively for internal job costing and profit/margin display.
|
||||||
|
/// This is NOT the billing rate — it should reflect what you actually pay workers.
|
||||||
|
/// When null, the costing engine defaults to 20% of StandardLaborRate.
|
||||||
|
/// </summary>
|
||||||
|
[Range(0, 10000)]
|
||||||
|
public decimal? LaborCostPerHour { get; set; }
|
||||||
|
|
||||||
// Additional Coat Labor Percentage (percentage of base labor for each additional coat beyond the first)
|
// Additional Coat Labor Percentage (percentage of base labor for each additional coat beyond the first)
|
||||||
[Range(0, 100)]
|
[Range(0, 100)]
|
||||||
public decimal AdditionalCoatLaborPercent { get; set; } = 30m;
|
public decimal AdditionalCoatLaborPercent { get; set; } = 30m;
|
||||||
|
|||||||
@@ -86,6 +86,14 @@ public class CompanyPreferences : BaseEntity
|
|||||||
/// <summary>JSON blob persisting QB Migration Wizard step state across sessions.</summary>
|
/// <summary>JSON blob persisting QB Migration Wizard step state across sessions.</summary>
|
||||||
public string? QbMigrationStateJson { get; set; }
|
public string? QbMigrationStateJson { get; set; }
|
||||||
|
|
||||||
|
// Kiosk settings
|
||||||
|
/// <summary>
|
||||||
|
/// Controls what the kiosk creates on submission: "Quote" (default) or "Job".
|
||||||
|
/// Quote aligns with the default Terms text ("subject to a formal quote").
|
||||||
|
/// Job is for shops that price on the spot and want the work order ready immediately.
|
||||||
|
/// </summary>
|
||||||
|
public string KioskIntakeOutput { get; set; } = "Quote";
|
||||||
|
|
||||||
// Guided activation / first-workflow onboarding
|
// Guided activation / first-workflow onboarding
|
||||||
/// <summary>Selected first-workflow path: quote_first or job_first. Null until chosen.</summary>
|
/// <summary>Selected first-workflow path: quote_first or job_first. Null until chosen.</summary>
|
||||||
public string? OnboardingPath { get; set; }
|
public string? OnboardingPath { get; set; }
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ public class Deposit : BaseEntity
|
|||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public string? RecordedById { get; set; }
|
public string? RecordedById { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Bank/checking account this deposit was deposited into. Set at recording time
|
||||||
|
/// so the Trial Balance can immediately debit the correct bank account.</summary>
|
||||||
|
public int? DepositAccountId { get; set; }
|
||||||
|
|
||||||
// Applied to invoice when invoice is created
|
// Applied to invoice when invoice is created
|
||||||
public int? AppliedToInvoiceId { get; set; }
|
public int? AppliedToInvoiceId { get; set; }
|
||||||
public DateTime? AppliedDate { get; set; }
|
public DateTime? AppliedDate { get; set; }
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ public class GiftCertificate : BaseEntity
|
|||||||
/// <summary>Set when this GC was sold via an invoice line item.</summary>
|
/// <summary>Set when this GC was sold via an invoice line item.</summary>
|
||||||
public int? SourceInvoiceItemId { get; set; }
|
public int? SourceInvoiceItemId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Groups all certificates created in a single bulk run. Null for individually issued certs.</summary>
|
||||||
|
public Guid? BatchId { get; set; }
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
public virtual Customer? RecipientCustomer { get; set; }
|
public virtual Customer? RecipientCustomer { get; set; }
|
||||||
public virtual Customer? PurchasingCustomer { get; set; }
|
public virtual Customer? PurchasingCustomer { get; set; }
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ public class Invoice : BaseEntity
|
|||||||
public decimal GiftCertificateRedeemed { get; set; } // Sum of gift certificate redemptions
|
public decimal GiftCertificateRedeemed { get; set; } // Sum of gift certificate redemptions
|
||||||
public decimal BalanceDue => Total - AmountPaid - CreditApplied - GiftCertificateRedeemed;
|
public decimal BalanceDue => Total - AmountPaid - CreditApplied - GiftCertificateRedeemed;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Permanent public token for the customer-facing invoice view page (/invoice/{token}).
|
||||||
|
/// Generated when the invoice is first sent (regardless of Stripe status) and never expires.
|
||||||
|
/// Distinct from PaymentLinkToken which is Stripe-gated and expires in 5 days.
|
||||||
|
/// </summary>
|
||||||
|
public string? PublicViewToken { get; set; }
|
||||||
|
|
||||||
// Online payments (Stripe Connect)
|
// Online payments (Stripe Connect)
|
||||||
public OnlinePaymentStatus OnlinePaymentStatus { get; set; } = OnlinePaymentStatus.NotApplicable;
|
public OnlinePaymentStatus OnlinePaymentStatus { get; set; } = OnlinePaymentStatus.NotApplicable;
|
||||||
public string? PaymentLinkToken { get; set; } // Signed token for /pay/{token}
|
public string? PaymentLinkToken { get; set; } // Signed token for /pay/{token}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ public class Job : BaseEntity
|
|||||||
// Selected oven (carried over from quote; null = company default rate)
|
// Selected oven (carried over from quote; null = company default rate)
|
||||||
public int? OvenCostId { get; set; }
|
public int? OvenCostId { get; set; }
|
||||||
|
|
||||||
|
// Oven scheduling (carried over from quote)
|
||||||
|
public int OvenBatches { get; set; } = 1;
|
||||||
|
public int? OvenCycleMinutes { get; set; }
|
||||||
|
|
||||||
// Pricing
|
// Pricing
|
||||||
public decimal QuotedPrice { get; set; }
|
public decimal QuotedPrice { get; set; }
|
||||||
public decimal FinalPrice { get; set; }
|
public decimal FinalPrice { get; set; }
|
||||||
@@ -62,6 +66,10 @@ public class Job : BaseEntity
|
|||||||
// Used to detect when the quote was subsequently edited so the job details page can warn the user.
|
// Used to detect when the quote was subsequently edited so the job details page can warn the user.
|
||||||
public DateTime? QuoteSnapshotUpdatedAt { get; set; }
|
public DateTime? QuoteSnapshotUpdatedAt { get; set; }
|
||||||
|
|
||||||
|
// Pricing snapshot — serialized QuotePricingBreakdownDto stored at save time so Details displays
|
||||||
|
// the breakdown that was actually calculated, not a re-run against current operating costs.
|
||||||
|
public string? PricingBreakdownJson { get; set; }
|
||||||
|
|
||||||
// Rework tracking
|
// Rework tracking
|
||||||
public bool IsReworkJob { get; set; }
|
public bool IsReworkJob { get; set; }
|
||||||
public int? OriginalJobId { get; set; } // Set when this job was created as a rework
|
public int? OriginalJobId { get; set; } // Set when this job was created as a rework
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ public class JobItem : BaseEntity
|
|||||||
// Values: "Simple" | "Moderate" | "Complex" | "Extreme"
|
// Values: "Simple" | "Moderate" | "Complex" | "Extreme"
|
||||||
public string? Complexity { get; set; }
|
public string? Complexity { get; set; }
|
||||||
|
|
||||||
|
// True when this item originated from an AI Photo Quote — ManualUnitPrice is used as-is
|
||||||
|
// and oven cost is not double-charged (it was excluded from the AI estimate at quote level).
|
||||||
|
public bool IsAiItem { get; set; }
|
||||||
|
|
||||||
// AI-generated standardized tags (comma-separated, e.g. "automotive,tubular")
|
// AI-generated standardized tags (comma-separated, e.g. "automotive,tubular")
|
||||||
public string? AiTags { get; set; }
|
public string? AiTags { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ namespace PowderCoating.Core.Entities;
|
|||||||
public class JobTimeEntry : BaseEntity
|
public class JobTimeEntry : BaseEntity
|
||||||
{
|
{
|
||||||
public int JobId { get; set; }
|
public int JobId { get; set; }
|
||||||
public int? ShopWorkerId { get; set; } // legacy — kept for entries created before user migration
|
|
||||||
public string? UserId { get; set; } // FK to AspNetUsers
|
public string? UserId { get; set; } // FK to AspNetUsers
|
||||||
public string? UserDisplayName { get; set; } // snapshot of worker name at entry creation time
|
public string? UserDisplayName { get; set; } // snapshot of worker name at entry creation time
|
||||||
public DateTime WorkDate { get; set; }
|
public DateTime WorkDate { get; set; }
|
||||||
@@ -13,5 +12,4 @@ public class JobTimeEntry : BaseEntity
|
|||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
public virtual Job Job { get; set; } = null!;
|
public virtual Job Job { get; set; } = null!;
|
||||||
public virtual ShopWorker? Worker { get; set; } // nullable — only populated for legacy entries
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using PowderCoating.Core.Enums;
|
||||||
|
|
||||||
|
namespace PowderCoating.Core.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents one customer self-service intake session — either completed on the front-desk tablet
|
||||||
|
/// (InPerson) or via an emailed link the customer fills out on their own device (Remote).
|
||||||
|
/// Sessions are tenant-scoped and soft-deletable. Load anonymous sessions with ignoreQueryFilters:true.
|
||||||
|
/// </summary>
|
||||||
|
public class KioskSession : BaseEntity
|
||||||
|
{
|
||||||
|
/// <summary>URL-safe GUID used in all kiosk routes; unique across the table.</summary>
|
||||||
|
public Guid SessionToken { get; set; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
public KioskSessionType SessionType { get; set; }
|
||||||
|
public KioskSessionStatus Status { get; set; } = KioskSessionStatus.Active;
|
||||||
|
|
||||||
|
// ── Step 1 — Contact ─────────────────────────────────────────────────────
|
||||||
|
public string CustomerFirstName { get; set; } = string.Empty;
|
||||||
|
public string CustomerLastName { get; set; } = string.Empty;
|
||||||
|
public string CustomerPhone { get; set; } = string.Empty;
|
||||||
|
public string CustomerEmail { get; set; } = string.Empty;
|
||||||
|
public bool IsReturningCustomer { get; set; }
|
||||||
|
|
||||||
|
// ── Step 2 — Job Description ──────────────────────────────────────────────
|
||||||
|
public string JobDescription { get; set; } = string.Empty;
|
||||||
|
public string? HowDidYouHearAboutUs { get; set; }
|
||||||
|
|
||||||
|
// ── Step 3 — Terms & Consent ──────────────────────────────────────────────
|
||||||
|
public bool AgreedToTerms { get; set; }
|
||||||
|
public DateTime? AgreedToTermsAt { get; set; }
|
||||||
|
/// <summary>Customer opted in to SMS order updates; sets Customer.NotifyBySms on submission.</summary>
|
||||||
|
public bool SmsOptIn { get; set; }
|
||||||
|
/// <summary>Base-64 PNG from signature_pad; null for Remote sessions (no drawn signature required).</summary>
|
||||||
|
public string? SignatureDataBase64 { get; set; }
|
||||||
|
|
||||||
|
// ── Outcome ───────────────────────────────────────────────────────────────
|
||||||
|
public int? LinkedCustomerId { get; set; }
|
||||||
|
/// <summary>Set when KioskIntakeOutput = "Job". Null when a Quote was created instead.</summary>
|
||||||
|
public int? LinkedJobId { get; set; }
|
||||||
|
/// <summary>Set when KioskIntakeOutput = "Quote". Null when a Job was created instead.</summary>
|
||||||
|
public int? LinkedQuoteId { get; set; }
|
||||||
|
public DateTime? SubmittedAt { get; set; }
|
||||||
|
/// <summary>Sessions auto-expire 2 h after creation (InPerson) or 48 h (Remote). ExpiresAt is set at creation.</summary>
|
||||||
|
public DateTime ExpiresAt { get; set; }
|
||||||
|
|
||||||
|
// ── Remote-only ───────────────────────────────────────────────────────────
|
||||||
|
public string? RemoteLinkEmail { get; set; }
|
||||||
|
public DateTime? RemoteLinkSentAt { get; set; }
|
||||||
|
|
||||||
|
// ── Navigation ────────────────────────────────────────────────────────────
|
||||||
|
public virtual Customer? LinkedCustomer { get; set; }
|
||||||
|
public virtual Job? LinkedJob { get; set; }
|
||||||
|
}
|
||||||
@@ -40,26 +40,33 @@ public class Quote : BaseEntity
|
|||||||
public DateTime? ApprovedDate { get; set; }
|
public DateTime? ApprovedDate { get; set; }
|
||||||
|
|
||||||
// Pricing — all values are snapshots captured at save time and must not be recalculated on load
|
// Pricing — all values are snapshots captured at save time and must not be recalculated on load
|
||||||
public decimal MaterialCosts { get; set; } // Sum of powder/material costs across all items
|
public decimal MaterialCosts { get; set; } // Sum of powder/material costs across all items
|
||||||
public decimal LaborCosts { get; set; } // Sum of labor costs across all items
|
public decimal LaborCosts { get; set; } // Sum of labor costs across all items
|
||||||
public decimal EquipmentCosts { get; set; } // Sum of equipment costs across all items
|
public decimal EquipmentCosts { get; set; } // Sum of equipment costs across all items
|
||||||
public decimal ItemsSubtotal { get; set; } // Sum of item prices before any quote-level costs
|
public decimal ItemsSubtotal { get; set; } // Sum of item prices before any quote-level costs
|
||||||
public decimal OvenBatchCost { get; set; } // Oven batch charge applied at quote level
|
public decimal OvenBatchCost { get; set; } // Oven batch charge applied at quote level
|
||||||
public decimal ShopSuppliesAmount { get; set; } // Shop supplies dollar amount
|
public decimal FacilityOverheadCost { get; set; } // Rent + utilities apportioned by estimated job hours
|
||||||
public decimal ShopSuppliesPercent { get; set; } // Shop supplies percentage used
|
public decimal FacilityOverheadRatePerHour { get; set; }// Rate used for facility overhead ($/hr)
|
||||||
public decimal OverheadAmount { get; set; } // Overhead dollar amount
|
public decimal ShopSuppliesAmount { get; set; } // Shop supplies dollar amount
|
||||||
public decimal OverheadPercent { get; set; } // Overhead percentage used
|
public decimal ShopSuppliesPercent { get; set; } // Shop supplies percentage used
|
||||||
public decimal ProfitMargin { get; set; } // Profit margin dollar amount
|
public decimal OverheadAmount { get; set; } // Legacy overhead (now always 0; kept for migration safety)
|
||||||
public decimal ProfitPercent { get; set; } // Profit margin percentage used
|
public decimal OverheadPercent { get; set; } // Legacy overhead percent
|
||||||
public decimal SubTotal { get; set; } // SubtotalBeforeDiscount (items + oven + overhead + profit + shop supplies)
|
public decimal ProfitMargin { get; set; } // Profit margin dollar amount (0 — baked into item prices)
|
||||||
|
public decimal ProfitPercent { get; set; } // Markup % used (for display reference)
|
||||||
|
public decimal SubTotal { get; set; } // SubtotalBeforeDiscount (items + oven + facility overhead + shop supplies)
|
||||||
|
|
||||||
// Discount Information
|
// Discount Information
|
||||||
public DiscountType DiscountType { get; set; } = DiscountType.None;
|
public DiscountType DiscountType { get; set; } = DiscountType.None;
|
||||||
public decimal DiscountValue { get; set; } = 0; // Value entered by user (percentage or fixed amount)
|
public decimal DiscountValue { get; set; } = 0; // Value entered by user (percentage or fixed amount)
|
||||||
public decimal DiscountPercent { get; set; } // Calculated: actual percentage applied
|
public decimal PricingTierDiscountAmount { get; set; } // Discount from customer's pricing tier
|
||||||
public decimal DiscountAmount { get; set; } // Calculated: actual dollar amount deducted
|
public decimal PricingTierDiscountPercent { get; set; } // Tier discount percentage
|
||||||
public string? DiscountReason { get; set; } // Why discount was applied
|
public decimal QuoteDiscountAmount { get; set; } // Manual quote-level discount amount
|
||||||
|
public decimal QuoteDiscountPercent { get; set; } // Manual quote-level discount percentage
|
||||||
|
public decimal DiscountPercent { get; set; } // Combined: actual percentage applied
|
||||||
|
public decimal DiscountAmount { get; set; } // Combined: actual dollar amount deducted
|
||||||
|
public string? DiscountReason { get; set; } // Why discount was applied
|
||||||
public bool HideDiscountFromCustomer { get; set; } = false; // Show only total on PDFs/portal
|
public bool HideDiscountFromCustomer { get; set; } = false; // Show only total on PDFs/portal
|
||||||
|
public decimal SubtotalAfterDiscount { get; set; } // SubTotal minus all discounts, before rush/tax
|
||||||
|
|
||||||
public decimal TaxPercent { get; set; }
|
public decimal TaxPercent { get; set; }
|
||||||
public decimal TaxAmount { get; set; }
|
public decimal TaxAmount { get; set; }
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ public class Refund : BaseEntity
|
|||||||
public DateTime? IssuedDate { get; set; }
|
public DateTime? IssuedDate { get; set; }
|
||||||
public string? IssuedById { get; set; }
|
public string? IssuedById { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Bank/checking account the refund was paid from. Mirrors Payment.DepositAccountId so
|
||||||
|
/// the Trial Balance can credit this account when computing bank balance.</summary>
|
||||||
|
public int? DepositAccountId { get; set; }
|
||||||
|
|
||||||
// For store-credit refunds: the CreditMemo created on their behalf
|
// For store-credit refunds: the CreditMemo created on their behalf
|
||||||
public int? CreditMemoId { get; set; }
|
public int? CreditMemoId { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
using PowderCoating.Core.Enums;
|
|
||||||
|
|
||||||
namespace PowderCoating.Core.Entities;
|
|
||||||
|
|
||||||
public class ShopWorker : BaseEntity
|
|
||||||
{
|
|
||||||
public string Name { get; set; } = string.Empty;
|
|
||||||
public ShopWorkerRole Role { get; set; } = ShopWorkerRole.GeneralLabor;
|
|
||||||
public string? Phone { get; set; }
|
|
||||||
public string? Email { get; set; }
|
|
||||||
public bool IsActive { get; set; } = true;
|
|
||||||
public string? Notes { get; set; }
|
|
||||||
|
|
||||||
// Relationships
|
|
||||||
public virtual ICollection<Job> AssignedJobs { get; set; } = new List<Job>();
|
|
||||||
public virtual ICollection<MaintenanceRecord> AssignedMaintenanceTasks { get; set; } = new List<MaintenanceRecord>();
|
|
||||||
public virtual ICollection<JobTimeEntry> TimeEntries { get; set; } = new List<JobTimeEntry>();
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
using PowderCoating.Core.Enums;
|
|
||||||
|
|
||||||
namespace PowderCoating.Core.Entities;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Optional per-role labor cost rate for job costing / profitability calculations.
|
|
||||||
/// If no rate is set for a role, the company's StandardLaborRate is used as fallback.
|
|
||||||
/// </summary>
|
|
||||||
public class ShopWorkerRoleCost : BaseEntity
|
|
||||||
{
|
|
||||||
public ShopWorkerRole Role { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Cost (pay rate) per hour for this role — used in job costing, NOT billing.</summary>
|
|
||||||
public decimal HourlyRate { get; set; }
|
|
||||||
}
|
|
||||||
@@ -78,17 +78,6 @@ public enum EquipmentStatus
|
|||||||
Retired = 4
|
Retired = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum ShopWorkerRole
|
|
||||||
{
|
|
||||||
GeneralLabor = 0,
|
|
||||||
Sandblaster = 1,
|
|
||||||
Coater = 2,
|
|
||||||
Masker = 3,
|
|
||||||
QualityControl = 4,
|
|
||||||
OvenOperator = 5,
|
|
||||||
Supervisor = 6,
|
|
||||||
Maintenance = 7
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum JobPhotoType
|
public enum JobPhotoType
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
namespace PowderCoating.Core.Enums;
|
||||||
|
|
||||||
|
public enum KioskSessionType
|
||||||
|
{
|
||||||
|
InPerson = 0,
|
||||||
|
Remote = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum KioskSessionStatus
|
||||||
|
{
|
||||||
|
Active = 0,
|
||||||
|
Submitted = 1,
|
||||||
|
Expired = 2,
|
||||||
|
Cancelled = 3
|
||||||
|
}
|
||||||
@@ -54,9 +54,7 @@ public interface IUnitOfWork : IDisposable
|
|||||||
IRepository<AppointmentStatusLookup> AppointmentStatusLookups { get; }
|
IRepository<AppointmentStatusLookup> AppointmentStatusLookups { get; }
|
||||||
IRepository<AppointmentTypeLookup> AppointmentTypeLookups { get; }
|
IRepository<AppointmentTypeLookup> AppointmentTypeLookups { get; }
|
||||||
IRepository<PrepService> PrepServices { get; }
|
IRepository<PrepService> PrepServices { get; }
|
||||||
IRepository<ShopWorker> ShopWorkers { get; }
|
IRepository<ReworkRecord> ReworkRecords { get; }
|
||||||
IRepository<ShopWorkerRoleCost> ShopWorkerRoleCosts { get; }
|
|
||||||
IRepository<ReworkRecord> ReworkRecords { get; }
|
|
||||||
IRepository<Refund> Refunds { get; }
|
IRepository<Refund> Refunds { get; }
|
||||||
IRepository<CreditMemo> CreditMemos { get; }
|
IRepository<CreditMemo> CreditMemos { get; }
|
||||||
IRepository<CreditMemoApplication> CreditMemoApplications { get; }
|
IRepository<CreditMemoApplication> CreditMemoApplications { get; }
|
||||||
@@ -154,6 +152,9 @@ public interface IUnitOfWork : IDisposable
|
|||||||
IRepository<GiftCertificate> GiftCertificates { get; }
|
IRepository<GiftCertificate> GiftCertificates { get; }
|
||||||
IRepository<GiftCertificateRedemption> GiftCertificateRedemptions { get; }
|
IRepository<GiftCertificateRedemption> GiftCertificateRedemptions { get; }
|
||||||
|
|
||||||
|
// Customer Intake Kiosk
|
||||||
|
IRepository<KioskSession> KioskSessions { get; }
|
||||||
|
|
||||||
Task<int> SaveChangesAsync();
|
Task<int> SaveChangesAsync();
|
||||||
Task<int> CompleteAsync(); // Alias for SaveChangesAsync
|
Task<int> CompleteAsync(); // Alias for SaveChangesAsync
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,17 @@ public record CompanyWizardInfo(bool Completed, DateTime? CompletedAt, string? C
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Per-company entity count summary used to populate the Index list without N+1 round-trips.
|
/// Per-company entity count summary used to populate the Index list without N+1 round-trips.
|
||||||
|
/// Also carries health-signal data (jobs30, jobs90, last login) so callers can compute a
|
||||||
|
/// <c>ChurnRisk</c> badge without a separate round-trip.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record CompanyCountSummary(
|
public record CompanyCountSummary(
|
||||||
IReadOnlyDictionary<int, int> JobCounts,
|
IReadOnlyDictionary<int, int> JobCounts,
|
||||||
IReadOnlyDictionary<int, int> QuoteCounts,
|
IReadOnlyDictionary<int, int> QuoteCounts,
|
||||||
IReadOnlyDictionary<int, int> CustomerCounts,
|
IReadOnlyDictionary<int, int> CustomerCounts,
|
||||||
IReadOnlyDictionary<int, CompanyWizardInfo> WizardInfo
|
IReadOnlyDictionary<int, CompanyWizardInfo> WizardInfo,
|
||||||
|
IReadOnlyDictionary<int, int> Jobs30Counts,
|
||||||
|
IReadOnlyDictionary<int, int> Jobs90Counts,
|
||||||
|
IReadOnlyDictionary<int, DateTime?> LastLoginDates
|
||||||
);
|
);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -26,10 +31,13 @@ public interface ICompanyListService
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns a paged, searched, and sorted slice of non-deleted companies together with the
|
/// Returns a paged, searched, and sorted slice of non-deleted companies together with the
|
||||||
/// total unfiltered count for pagination.
|
/// total count for pagination and the count of churned accounts that are currently hidden.
|
||||||
|
/// When <paramref name="hideChurned"/> is true, Expired/Canceled companies whose subscription
|
||||||
|
/// ended more than 14 days ago are excluded from results (but still counted for the banner).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<(List<Company> Companies, int TotalCount)> GetPagedAsync(
|
Task<(List<Company> Companies, int TotalCount, int ChurnedCount)> GetPagedAsync(
|
||||||
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize);
|
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize,
|
||||||
|
bool hideChurned = true);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns job, quote, customer, and wizard completion counts for each of the supplied
|
/// Returns job, quote, customer, and wizard completion counts for each of the supplied
|
||||||
|
|||||||
@@ -205,11 +205,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
public DbSet<MaintenanceRecord> MaintenanceRecords { get; set; }
|
public DbSet<MaintenanceRecord> MaintenanceRecords { get; set; }
|
||||||
/// <summary>Supplier/vendor records used by Purchasing and Accounts Payable; tenant-filtered with soft delete.</summary>
|
/// <summary>Supplier/vendor records used by Purchasing and Accounts Payable; tenant-filtered with soft delete.</summary>
|
||||||
public DbSet<Vendor> Vendors { get; set; }
|
public DbSet<Vendor> Vendors { get; set; }
|
||||||
/// <summary>Shop worker profiles with role assignments; tenant-filtered with soft delete.</summary>
|
/// <summary>Rework records tracking quality failures and remediation work against a job; tenant-filtered with soft delete.</summary>
|
||||||
public DbSet<ShopWorker> ShopWorkers { get; set; }
|
|
||||||
/// <summary>Per-role labour cost rates used in pricing calculations; unique index on (CompanyId, Role).</summary>
|
|
||||||
public DbSet<ShopWorkerRoleCost> ShopWorkerRoleCosts { get; set; }
|
|
||||||
/// <summary>Rework records tracking quality failures and remediation work against a job; tenant-filtered with soft delete.</summary>
|
|
||||||
public DbSet<ReworkRecord> ReworkRecords { get; set; }
|
public DbSet<ReworkRecord> ReworkRecords { get; set; }
|
||||||
/// <summary>Customer refund records; tenant-filtered with soft delete.</summary>
|
/// <summary>Customer refund records; tenant-filtered with soft delete.</summary>
|
||||||
public DbSet<Refund> Refunds { get; set; }
|
public DbSet<Refund> Refunds { get; set; }
|
||||||
@@ -367,6 +363,10 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
/// <summary>Prep-service definitions within a job template item.</summary>
|
/// <summary>Prep-service definitions within a job template item.</summary>
|
||||||
public DbSet<JobTemplateItemPrepService> JobTemplateItemPrepServices { get; set; }
|
public DbSet<JobTemplateItemPrepService> JobTemplateItemPrepServices { get; set; }
|
||||||
|
|
||||||
|
// Customer Intake Kiosk
|
||||||
|
/// <summary>Customer self-service intake sessions (walk-in tablet or remote email link); tenant-filtered with soft delete.</summary>
|
||||||
|
public DbSet<KioskSession> KioskSessions { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Platform-wide audit log capturing who changed what and when, across all tenants.
|
/// Platform-wide audit log capturing who changed what and when, across all tenants.
|
||||||
/// No global query filter — SuperAdmin controllers query this directly.
|
/// No global query filter — SuperAdmin controllers query this directly.
|
||||||
@@ -526,11 +526,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e =>
|
modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e =>
|
||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
modelBuilder.Entity<ShopWorker>().HasQueryFilter(e =>
|
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
|
||||||
modelBuilder.Entity<ShopWorkerRoleCost>().HasQueryFilter(e =>
|
|
||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
|
||||||
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
|
||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
modelBuilder.Entity<Refund>().HasQueryFilter(e =>
|
modelBuilder.Entity<Refund>().HasQueryFilter(e =>
|
||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
@@ -746,6 +742,24 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
modelBuilder.Entity<InAppNotification>().HasQueryFilter(e =>
|
modelBuilder.Entity<InAppNotification>().HasQueryFilter(e =>
|
||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
|
|
||||||
|
// Customer intake kiosk sessions — tenant-filtered + soft delete.
|
||||||
|
// Anonymous intake routes must use ignoreQueryFilters:true when loading by SessionToken.
|
||||||
|
modelBuilder.Entity<KioskSession>().HasQueryFilter(e =>
|
||||||
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
|
modelBuilder.Entity<KioskSession>()
|
||||||
|
.HasIndex(e => e.SessionToken)
|
||||||
|
.IsUnique();
|
||||||
|
modelBuilder.Entity<KioskSession>()
|
||||||
|
.HasOne(k => k.LinkedCustomer)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(k => k.LinkedCustomerId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
modelBuilder.Entity<KioskSession>()
|
||||||
|
.HasOne(k => k.LinkedJob)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(k => k.LinkedJobId)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
// Account self-referencing hierarchy
|
// Account self-referencing hierarchy
|
||||||
modelBuilder.Entity<Account>()
|
modelBuilder.Entity<Account>()
|
||||||
.HasOne(a => a.ParentAccount)
|
.HasOne(a => a.ParentAccount)
|
||||||
@@ -1292,12 +1306,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
.HasForeignKey(m => m.PerformedById)
|
.HasForeignKey(m => m.PerformedById)
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
// ShopWorker relationships
|
|
||||||
modelBuilder.Entity<ShopWorker>()
|
|
||||||
.HasOne<Company>()
|
|
||||||
.WithMany(c => c.ShopWorkers)
|
|
||||||
.HasForeignKey(e => e.CompanyId)
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
modelBuilder.Entity<Job>()
|
modelBuilder.Entity<Job>()
|
||||||
.HasOne(j => j.AssignedUser)
|
.HasOne(j => j.AssignedUser)
|
||||||
@@ -1371,10 +1380,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
modelBuilder.Entity<PricingTier>()
|
modelBuilder.Entity<PricingTier>()
|
||||||
.HasIndex(p => p.CompanyId);
|
.HasIndex(p => p.CompanyId);
|
||||||
|
|
||||||
modelBuilder.Entity<ShopWorker>()
|
modelBuilder.Entity<CatalogCategory>()
|
||||||
.HasIndex(w => w.CompanyId);
|
|
||||||
|
|
||||||
modelBuilder.Entity<CatalogCategory>()
|
|
||||||
.HasIndex(c => c.CompanyId);
|
.HasIndex(c => c.CompanyId);
|
||||||
|
|
||||||
modelBuilder.Entity<CatalogCategory>()
|
modelBuilder.Entity<CatalogCategory>()
|
||||||
@@ -1409,12 +1415,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasDatabaseName("IX_Jobs_CompanyId_JobNumber");
|
.HasDatabaseName("IX_Jobs_CompanyId_JobNumber");
|
||||||
|
|
||||||
modelBuilder.Entity<ShopWorkerRoleCost>()
|
modelBuilder.Entity<Job>()
|
||||||
.HasIndex(r => new { r.CompanyId, r.Role })
|
|
||||||
.IsUnique()
|
|
||||||
.HasDatabaseName("IX_ShopWorkerRoleCosts_CompanyId_Role");
|
|
||||||
|
|
||||||
modelBuilder.Entity<Job>()
|
|
||||||
.Property(j => j.ShopAccessCode)
|
.Property(j => j.ShopAccessCode)
|
||||||
.HasDefaultValueSql("NEWID()");
|
.HasDefaultValueSql("NEWID()");
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ public class AuditInterceptor : SaveChangesInterceptor
|
|||||||
private static readonly HashSet<string> AuditedTypes = new(StringComparer.Ordinal)
|
private static readonly HashSet<string> AuditedTypes = new(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
nameof(Customer), nameof(Job), nameof(Quote), nameof(Equipment),
|
nameof(Customer), nameof(Job), nameof(Quote), nameof(Equipment),
|
||||||
nameof(MaintenanceRecord), nameof(Vendor), nameof(ShopWorker),
|
nameof(MaintenanceRecord), nameof(Vendor),
|
||||||
nameof(InventoryItem), nameof(Company),
|
nameof(InventoryItem), nameof(Company),
|
||||||
// Financial entities
|
// Financial entities
|
||||||
nameof(Invoice), nameof(Payment), nameof(Bill), nameof(BillPayment),
|
nameof(Invoice), nameof(Payment), nameof(Bill), nameof(BillPayment),
|
||||||
|
|||||||
@@ -967,6 +967,17 @@ New accounts walk through an 18-step setup wizard to configure company informati
|
|||||||
CreatedAt = DateTime.UtcNow
|
CreatedAt = DateTime.UtcNow
|
||||||
},
|
},
|
||||||
new NotificationTemplate
|
new NotificationTemplate
|
||||||
|
{
|
||||||
|
NotificationType = NotificationType.InvoiceSent,
|
||||||
|
Channel = NotificationChannel.Sms,
|
||||||
|
DisplayName = "Invoice Sent (SMS)",
|
||||||
|
Subject = null,
|
||||||
|
Body = "{{companyName}}: Invoice {{invoiceNumber}} for {{invoiceTotal}} is ready. View your invoice: {{viewUrl}} Reply STOP to opt out.",
|
||||||
|
IsActive = true,
|
||||||
|
CompanyId = companyId,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
},
|
||||||
|
new NotificationTemplate
|
||||||
{
|
{
|
||||||
NotificationType = NotificationType.PaymentReceived,
|
NotificationType = NotificationType.PaymentReceived,
|
||||||
Channel = NotificationChannel.Email,
|
Channel = NotificationChannel.Email,
|
||||||
|
|||||||
Generated
+10594
File diff suppressed because it is too large
Load Diff
+88
@@ -0,0 +1,88 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddMissingPlatformSettings : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// Conditional inserts — safe to run against a DB that already has some of these keys set manually.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'SmsEnabled')
|
||||||
|
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
|
||||||
|
VALUES ('SmsEnabled','false','SMS Enabled','Platform-level switch for outbound SMS. When off, no SMS messages are sent regardless of company settings.','Notifications');
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'TrialsEnabled')
|
||||||
|
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
|
||||||
|
VALUES ('TrialsEnabled','true','Trials Enabled','Allow new companies to register with a free trial period. When off, registration requires a paid plan immediately.','Subscriptions');
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'GracePeriodDays')
|
||||||
|
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
|
||||||
|
VALUES ('GracePeriodDays','14','Grace Period (days)','Days after subscription expiry before access is fully cut off. Gives companies time to renew without an abrupt lockout.','Subscriptions');
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'GracePeriodAppliesToTrials')
|
||||||
|
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
|
||||||
|
VALUES ('GracePeriodAppliesToTrials','false','Grace Period Applies to Trials','When enabled, trial companies also receive the grace period after expiry rather than being cut off immediately.','Subscriptions');
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'MaxTenants')
|
||||||
|
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
|
||||||
|
VALUES ('MaxTenants','-1','Max Tenants','Maximum number of active tenant companies allowed on the platform. Set to -1 for no limit.','Subscriptions');
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'AiCatalogPriceCheckEnabled')
|
||||||
|
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
|
||||||
|
VALUES ('AiCatalogPriceCheckEnabled','true','AI Catalog Price Check','Platform-level switch for the AI catalog price review feature. When off, the feature is disabled for all companies regardless of their settings.','AI');
|
||||||
|
");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8377));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8383));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8385));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5837));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5846));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5847));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10594
File diff suppressed because it is too large
Load Diff
+95
@@ -0,0 +1,95 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class SeedSalesDiscountsAccount : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// Insert the 4950 Sales Discounts contra-revenue account for every company that does
|
||||||
|
// not already have it. The account is credit-normal (AccountType=4 Revenue,
|
||||||
|
// AccountSubType=32 OtherIncome) and is debited when invoice discounts are applied so
|
||||||
|
// the GL balances (DR Sales Discounts / gap between CR Revenue and DR AR).
|
||||||
|
// Idempotent: the WHERE NOT EXISTS guard means re-running the migration is safe.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
INSERT INTO Accounts
|
||||||
|
(AccountNumber, Name, AccountType, AccountSubType,
|
||||||
|
IsSystem, IsActive, Description,
|
||||||
|
CompanyId, CreatedAt, IsDeleted,
|
||||||
|
CurrentBalance, OpeningBalance)
|
||||||
|
SELECT
|
||||||
|
'4950',
|
||||||
|
'Sales Discounts',
|
||||||
|
4, -- AccountType.Revenue
|
||||||
|
32, -- AccountSubType.OtherIncome
|
||||||
|
1, -- IsSystem = true
|
||||||
|
1, -- IsActive = true
|
||||||
|
'Contra-revenue for invoice discounts granted to customers',
|
||||||
|
c.Id,
|
||||||
|
GETUTCDATE(),
|
||||||
|
0, -- IsDeleted = false
|
||||||
|
0, -- CurrentBalance
|
||||||
|
0 -- OpeningBalance
|
||||||
|
FROM Companies c
|
||||||
|
WHERE c.IsDeleted = 0
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM Accounts a
|
||||||
|
WHERE a.CompanyId = c.Id
|
||||||
|
AND a.AccountNumber = '4950'
|
||||||
|
AND a.IsDeleted = 0
|
||||||
|
);
|
||||||
|
");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 13, 39, 22, 61, DateTimeKind.Utc).AddTicks(8475));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 13, 39, 22, 61, DateTimeKind.Utc).AddTicks(8484));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 13, 39, 22, 61, DateTimeKind.Utc).AddTicks(8486));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8377));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8383));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 1, 34, 45, 450, DateTimeKind.Utc).AddTicks(8385));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10600
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,113 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AccountingGapsPhase2 : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "PostedDate",
|
||||||
|
table: "VendorCredits",
|
||||||
|
type: "datetime2",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "DepositAccountId",
|
||||||
|
table: "Refunds",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
// Seed the Gift Certificate Liability account (2500) for every company that doesn't
|
||||||
|
// already have it. Credit-normal OtherCurrentLiability account; credited when a GC is
|
||||||
|
// issued and debited when redeemed or voided. Idempotent guard prevents double-seeding.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
INSERT INTO Accounts
|
||||||
|
(AccountNumber, Name, AccountType, AccountSubType,
|
||||||
|
IsSystem, IsActive, Description,
|
||||||
|
CompanyId, CreatedAt, IsDeleted,
|
||||||
|
CurrentBalance, OpeningBalance)
|
||||||
|
SELECT
|
||||||
|
'2500',
|
||||||
|
'Gift Certificate Liability',
|
||||||
|
2, -- AccountType.Liability
|
||||||
|
12, -- AccountSubType.OtherCurrentLiability
|
||||||
|
1, -- IsSystem = true
|
||||||
|
1, -- IsActive = true
|
||||||
|
'Outstanding gift certificate obligations owed to certificate holders',
|
||||||
|
c.Id,
|
||||||
|
GETUTCDATE(),
|
||||||
|
0, -- IsDeleted = false
|
||||||
|
0, -- CurrentBalance
|
||||||
|
0 -- OpeningBalance
|
||||||
|
FROM Companies c
|
||||||
|
WHERE c.IsDeleted = 0
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM Accounts a
|
||||||
|
WHERE a.CompanyId = c.Id
|
||||||
|
AND a.AccountNumber = '2500'
|
||||||
|
AND a.IsDeleted = 0
|
||||||
|
);
|
||||||
|
");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 14, 24, 44, 715, DateTimeKind.Utc).AddTicks(9166));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 14, 24, 44, 715, DateTimeKind.Utc).AddTicks(9172));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 14, 24, 44, 715, DateTimeKind.Utc).AddTicks(9174));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PostedDate",
|
||||||
|
table: "VendorCredits");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DepositAccountId",
|
||||||
|
table: "Refunds");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 13, 39, 22, 61, DateTimeKind.Utc).AddTicks(8475));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 13, 39, 22, 61, DateTimeKind.Utc).AddTicks(8484));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 13, 39, 22, 61, DateTimeKind.Utc).AddTicks(8486));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10603
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,103 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AccountingDepositsGL : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "DepositAccountId",
|
||||||
|
table: "Deposits",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
// Seed account 2300 "Customer Deposits" (Liability / OtherCurrentLiability) for every
|
||||||
|
// company that doesn't already have it. Credited when a deposit is taken; debited when
|
||||||
|
// the deposit is applied to an invoice. Idempotent guard prevents double-seeding.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
INSERT INTO Accounts
|
||||||
|
(AccountNumber, Name, AccountType, AccountSubType,
|
||||||
|
IsSystem, IsActive, Description,
|
||||||
|
CompanyId, CreatedAt, IsDeleted,
|
||||||
|
CurrentBalance, OpeningBalance)
|
||||||
|
SELECT
|
||||||
|
'2300',
|
||||||
|
'Customer Deposits',
|
||||||
|
2, -- AccountType.Liability
|
||||||
|
12, -- AccountSubType.OtherCurrentLiability
|
||||||
|
1, -- IsSystem = true
|
||||||
|
1, -- IsActive = true
|
||||||
|
'Deposits received from customers before an invoice is created; cleared when deposit is applied to invoice',
|
||||||
|
c.Id,
|
||||||
|
GETUTCDATE(),
|
||||||
|
0, -- IsDeleted = false
|
||||||
|
0, -- CurrentBalance
|
||||||
|
0 -- OpeningBalance
|
||||||
|
FROM Companies c
|
||||||
|
WHERE c.IsDeleted = 0
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM Accounts a
|
||||||
|
WHERE a.CompanyId = c.Id
|
||||||
|
AND a.AccountNumber = '2300'
|
||||||
|
AND a.IsDeleted = 0
|
||||||
|
);
|
||||||
|
");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5641));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5655));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5656));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DepositAccountId",
|
||||||
|
table: "Deposits");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 14, 24, 44, 715, DateTimeKind.Utc).AddTicks(9166));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 14, 24, 44, 715, DateTimeKind.Utc).AddTicks(9172));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 14, 24, 44, 715, DateTimeKind.Utc).AddTicks(9174));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10732
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,142 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddKioskIntakeSession : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "KioskActivationToken",
|
||||||
|
table: "Companies",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "KioskSessions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
SessionToken = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
|
||||||
|
SessionType = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Status = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CustomerFirstName = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
CustomerLastName = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
CustomerPhone = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
CustomerEmail = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
IsReturningCustomer = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
JobDescription = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
HowDidYouHearAboutUs = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
AgreedToTerms = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
AgreedToTermsAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
SmsOptIn = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
SignatureDataBase64 = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
LinkedCustomerId = table.Column<int>(type: "int", nullable: true),
|
||||||
|
LinkedJobId = table.Column<int>(type: "int", nullable: true),
|
||||||
|
SubmittedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
ExpiresAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
RemoteLinkEmail = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
RemoteLinkSentAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_KioskSessions", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_KioskSessions_Customers_LinkedCustomerId",
|
||||||
|
column: x => x.LinkedCustomerId,
|
||||||
|
principalTable: "Customers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_KioskSessions_Jobs_LinkedJobId",
|
||||||
|
column: x => x.LinkedJobId,
|
||||||
|
principalTable: "Jobs",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8207));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8213));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8215));
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_KioskSessions_LinkedCustomerId",
|
||||||
|
table: "KioskSessions",
|
||||||
|
column: "LinkedCustomerId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_KioskSessions_LinkedJobId",
|
||||||
|
table: "KioskSessions",
|
||||||
|
column: "LinkedJobId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_KioskSessions_SessionToken",
|
||||||
|
table: "KioskSessions",
|
||||||
|
column: "SessionToken",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "KioskSessions");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "KioskActivationToken",
|
||||||
|
table: "Companies");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5641));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5655));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 14, 57, 30, 15, DateTimeKind.Utc).AddTicks(5656));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10735
File diff suppressed because it is too large
Load Diff
+71
@@ -0,0 +1,71 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddInvoicePublicViewToken : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "PublicViewToken",
|
||||||
|
table: "Invoices",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4259));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4264));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4266));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PublicViewToken",
|
||||||
|
table: "Invoices");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8207));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8213));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 18, 40, 15, 633, DateTimeKind.Utc).AddTicks(8215));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10742
File diff suppressed because it is too large
Load Diff
+82
@@ -0,0 +1,82 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddKioskIntakeOutputSetting : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "LinkedQuoteId",
|
||||||
|
table: "KioskSessions",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "KioskIntakeOutput",
|
||||||
|
table: "CompanyPreferences",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2349));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2366));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2367));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LinkedQuoteId",
|
||||||
|
table: "KioskSessions");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "KioskIntakeOutput",
|
||||||
|
table: "CompanyPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4259));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4264));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 13, 19, 28, 44, 26, DateTimeKind.Utc).AddTicks(4266));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10748
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddJobOvenBatchFields : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "OvenBatches",
|
||||||
|
table: "Jobs",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "OvenCycleMinutes",
|
||||||
|
table: "Jobs",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6420));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6425));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6426));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "OvenBatches",
|
||||||
|
table: "Jobs");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "OvenCycleMinutes",
|
||||||
|
table: "Jobs");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2349));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2366));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2367));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10751
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddJobItemIsAiItem : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsAiItem",
|
||||||
|
table: "JobItems",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7475));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7481));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7482));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsAiItem",
|
||||||
|
table: "JobItems");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6420));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6425));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 5, 39, 939, DateTimeKind.Utc).AddTicks(6426));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10754
File diff suppressed because it is too large
Load Diff
+71
@@ -0,0 +1,71 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddGiftCertificateBatchId : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "BatchId",
|
||||||
|
table: "GiftCertificates",
|
||||||
|
type: "uniqueidentifier",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7656));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7662));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 0, 30, 26, 297, DateTimeKind.Utc).AddTicks(7664));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "BatchId",
|
||||||
|
table: "GiftCertificates");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7475));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7481));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 14, 20, 43, 43, 116, DateTimeKind.Utc).AddTicks(7482));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10757
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddJobPricingSnapshot : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "PricingBreakdownJson",
|
||||||
|
table: "Jobs",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4618));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4623));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4625));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PricingBreakdownJson",
|
||||||
|
table: "Jobs");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2464));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2473));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 0, 30, 26, 273, DateTimeKind.Utc).AddTicks(2474));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
src/PowderCoating.Infrastructure/Migrations/20260515194344_AddQuotePricingSnapshotFields.Designer.cs
Generated
+10778
File diff suppressed because it is too large
Load Diff
+138
@@ -0,0 +1,138 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddQuotePricingSnapshotFields : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "FacilityOverheadCost",
|
||||||
|
table: "Quotes",
|
||||||
|
type: "decimal(18,2)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "FacilityOverheadRatePerHour",
|
||||||
|
table: "Quotes",
|
||||||
|
type: "decimal(18,2)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "PricingTierDiscountAmount",
|
||||||
|
table: "Quotes",
|
||||||
|
type: "decimal(18,2)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "PricingTierDiscountPercent",
|
||||||
|
table: "Quotes",
|
||||||
|
type: "decimal(18,2)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "QuoteDiscountAmount",
|
||||||
|
table: "Quotes",
|
||||||
|
type: "decimal(18,2)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "QuoteDiscountPercent",
|
||||||
|
table: "Quotes",
|
||||||
|
type: "decimal(18,2)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "SubtotalAfterDiscount",
|
||||||
|
table: "Quotes",
|
||||||
|
type: "decimal(18,2)",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0m);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(845));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(850));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(852));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "FacilityOverheadCost",
|
||||||
|
table: "Quotes");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "FacilityOverheadRatePerHour",
|
||||||
|
table: "Quotes");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PricingTierDiscountAmount",
|
||||||
|
table: "Quotes");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PricingTierDiscountPercent",
|
||||||
|
table: "Quotes");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "QuoteDiscountAmount",
|
||||||
|
table: "Quotes");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "QuoteDiscountPercent",
|
||||||
|
table: "Quotes");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SubtotalAfterDiscount",
|
||||||
|
table: "Quotes");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4618));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4623));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 16, 29, 32, 589, DateTimeKind.Utc).AddTicks(4625));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10784
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddLaborCostPerHour : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "LaborCostPerHour",
|
||||||
|
table: "CompanyOperatingCosts",
|
||||||
|
type: "decimal(18,2)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "LaborCostPerHour",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "decimal(18,2)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3131));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3137));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3138));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LaborCostPerHour",
|
||||||
|
table: "CompanyOperatingCosts");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LaborCostPerHour",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(845));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(850));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 15, 19, 43, 40, 586, DateTimeKind.Utc).AddTicks(852));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -556,6 +556,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<bool>("IsBanned")
|
b.Property<bool>("IsBanned")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<decimal?>("LaborCostPerHour")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<DateTime?>("LastLoginDate")
|
b.Property<DateTime?>("LastLoginDate")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
@@ -1812,6 +1815,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<bool>("IsDeleted")
|
b.Property<bool>("IsDeleted")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("KioskActivationToken")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("LogoContentType")
|
b.Property<string>("LogoContentType")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -2072,6 +2078,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<bool>("IsDeleted")
|
b.Property<bool>("IsDeleted")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<decimal?>("LaborCostPerHour")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<int>("MonthlyBillableHours")
|
b.Property<int>("MonthlyBillableHours")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -2250,6 +2259,10 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<int>("JobRetentionYears")
|
b.Property<int>("JobRetentionYears")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("KioskIntakeOutput")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<int>("LogRetentionDays")
|
b.Property<int>("LogRetentionDays")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -2892,6 +2905,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("DeletedBy")
|
b.Property<string>("DeletedBy")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("DepositAccountId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<bool>("IsDeleted")
|
b.Property<bool>("IsDeleted")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
@@ -3280,6 +3296,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<Guid?>("BatchId")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
b.Property<string>("CertificateCode")
|
b.Property<string>("CertificateCode")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(450)");
|
.HasColumnType("nvarchar(450)");
|
||||||
@@ -3916,6 +3935,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("PreparedById")
|
b.Property<string>("PreparedById")
|
||||||
.HasColumnType("nvarchar(450)");
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("PublicViewToken")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<int?>("SalesTaxAccountId")
|
b.Property<int?>("SalesTaxAccountId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -4192,9 +4214,18 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<decimal>("OvenBatchCost")
|
b.Property<decimal>("OvenBatchCost")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<int>("OvenBatches")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<int?>("OvenCostId")
|
b.Property<int?>("OvenCostId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int?>("OvenCycleMinutes")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("PricingBreakdownJson")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<int?>("QuoteId")
|
b.Property<int?>("QuoteId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -4463,6 +4494,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<bool>("IncludePrepCost")
|
b.Property<bool>("IncludePrepCost")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("IsAiItem")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<bool>("IsDeleted")
|
b.Property<bool>("IsDeleted")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
@@ -5561,6 +5595,118 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("JournalEntryLines");
|
b.ToTable("JournalEntryLines");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.KioskSession", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<bool>("AgreedToTerms")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("AgreedToTermsAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<int>("CompanyId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("CustomerEmail")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("CustomerFirstName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("CustomerLastName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("CustomerPhone")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("DeletedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ExpiresAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("HowDidYouHearAboutUs")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("IsReturningCustomer")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("JobDescription")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("LinkedCustomerId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int?>("LinkedJobId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int?>("LinkedQuoteId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("RemoteLinkEmail")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("RemoteLinkSentAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<Guid>("SessionToken")
|
||||||
|
.HasColumnType("uniqueidentifier");
|
||||||
|
|
||||||
|
b.Property<int>("SessionType")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("SignatureDataBase64")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("SmsOptIn")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("SubmittedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("LinkedCustomerId");
|
||||||
|
|
||||||
|
b.HasIndex("LinkedJobId");
|
||||||
|
|
||||||
|
b.HasIndex("SessionToken")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("KioskSessions");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.MaintenanceRecord", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.MaintenanceRecord", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -6574,7 +6720,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5837),
|
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3131),
|
||||||
Description = "Standard pricing for regular customers",
|
Description = "Standard pricing for regular customers",
|
||||||
DiscountPercent = 0m,
|
DiscountPercent = 0m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6585,7 +6731,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5846),
|
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3137),
|
||||||
Description = "5% discount for preferred customers",
|
Description = "5% discount for preferred customers",
|
||||||
DiscountPercent = 5m,
|
DiscountPercent = 5m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6596,7 +6742,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5847),
|
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3138),
|
||||||
Description = "10% discount for premium customers",
|
Description = "10% discount for premium customers",
|
||||||
DiscountPercent = 10m,
|
DiscountPercent = 10m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6843,6 +6989,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<DateTime?>("ExpirationDate")
|
b.Property<DateTime?>("ExpirationDate")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<decimal>("FacilityOverheadCost")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("FacilityOverheadRatePerHour")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<bool>("HideDiscountFromCustomer")
|
b.Property<bool>("HideDiscountFromCustomer")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
@@ -6888,6 +7040,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("PreparedById")
|
b.Property<string>("PreparedById")
|
||||||
.HasColumnType("nvarchar(450)");
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<decimal>("PricingTierDiscountAmount")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("PricingTierDiscountPercent")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<decimal>("ProfitMargin")
|
b.Property<decimal>("ProfitMargin")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
@@ -6927,6 +7085,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<DateTime>("QuoteDate")
|
b.Property<DateTime>("QuoteDate")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<decimal>("QuoteDiscountAmount")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("QuoteDiscountPercent")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<string>("QuoteNumber")
|
b.Property<string>("QuoteNumber")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(450)");
|
.HasColumnType("nvarchar(450)");
|
||||||
@@ -6952,6 +7116,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<decimal>("SubTotal")
|
b.Property<decimal>("SubTotal")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<decimal>("SubtotalAfterDiscount")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<string>("Tags")
|
b.Property<string>("Tags")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -7654,6 +7821,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("DeletedBy")
|
b.Property<string>("DeletedBy")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("DepositAccountId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<int>("InvoiceId")
|
b.Property<int>("InvoiceId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -8384,6 +8554,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("Memo")
|
b.Property<string>("Memo")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("PostedDate")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
b.Property<decimal>("RemainingAmount")
|
b.Property<decimal>("RemainingAmount")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
@@ -9712,6 +9885,23 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("JournalEntry");
|
b.Navigation("JournalEntry");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.KioskSession", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.Customer", "LinkedCustomer")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LinkedCustomerId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.Job", "LinkedJob")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("LinkedJobId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("LinkedCustomer");
|
||||||
|
|
||||||
|
b.Navigation("LinkedJob");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.MaintenanceRecord", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.MaintenanceRecord", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "AssignedUser")
|
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "AssignedUser")
|
||||||
|
|||||||
@@ -171,7 +171,6 @@ public class JobRepository : Repository<Job>, IJobRepository
|
|||||||
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
||||||
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
|
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
|
||||||
.Include(j => j.TimeEntries.Where(t => !t.IsDeleted))
|
.Include(j => j.TimeEntries.Where(t => !t.IsDeleted))
|
||||||
.ThenInclude(t => t.Worker)
|
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
private IRepository<AppointmentStatusLookup>? _appointmentStatusLookups;
|
private IRepository<AppointmentStatusLookup>? _appointmentStatusLookups;
|
||||||
private IRepository<AppointmentTypeLookup>? _appointmentTypeLookups;
|
private IRepository<AppointmentTypeLookup>? _appointmentTypeLookups;
|
||||||
private IRepository<PrepService>? _prepServices;
|
private IRepository<PrepService>? _prepServices;
|
||||||
private IRepository<ShopWorker>? _shopWorkers;
|
|
||||||
|
|
||||||
// Appointments
|
// Appointments
|
||||||
private IRepository<Appointment>? _appointments;
|
private IRepository<Appointment>? _appointments;
|
||||||
@@ -121,6 +120,9 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
private IRepository<GiftCertificate>? _giftCertificates;
|
private IRepository<GiftCertificate>? _giftCertificates;
|
||||||
private IRepository<GiftCertificateRedemption>? _giftCertificateRedemptions;
|
private IRepository<GiftCertificateRedemption>? _giftCertificateRedemptions;
|
||||||
|
|
||||||
|
// Customer Intake Kiosk
|
||||||
|
private IRepository<KioskSession>? _kioskSessions;
|
||||||
|
|
||||||
// Purchase Orders
|
// Purchase Orders
|
||||||
private IPurchaseOrderRepository? _purchaseOrders;
|
private IPurchaseOrderRepository? _purchaseOrders;
|
||||||
private IRepository<PurchaseOrderItem>? _purchaseOrderItems;
|
private IRepository<PurchaseOrderItem>? _purchaseOrderItems;
|
||||||
@@ -347,16 +349,7 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
public IRepository<PrepService> PrepServices =>
|
public IRepository<PrepService> PrepServices =>
|
||||||
_prepServices ??= new Repository<PrepService>(_context);
|
_prepServices ??= new Repository<PrepService>(_context);
|
||||||
|
|
||||||
/// <summary>Repository for <see cref="ShopWorker"/> profiles with role assignments; tenant-filtered with soft delete.</summary>
|
/// <summary>Repository for <see cref="ReworkRecord"/> quality-failure and remediation records; tenant-filtered with soft delete.</summary>
|
||||||
public IRepository<ShopWorker> ShopWorkers =>
|
|
||||||
_shopWorkers ??= new Repository<ShopWorker>(_context);
|
|
||||||
|
|
||||||
/// <summary>Repository for <see cref="ShopWorkerRoleCost"/> per-role labour cost rates; unique on (CompanyId, Role).</summary>
|
|
||||||
private IRepository<ShopWorkerRoleCost>? _shopWorkerRoleCosts;
|
|
||||||
public IRepository<ShopWorkerRoleCost> ShopWorkerRoleCosts =>
|
|
||||||
_shopWorkerRoleCosts ??= new Repository<ShopWorkerRoleCost>(_context);
|
|
||||||
|
|
||||||
/// <summary>Repository for <see cref="ReworkRecord"/> quality-failure and remediation records; tenant-filtered with soft delete.</summary>
|
|
||||||
private IRepository<ReworkRecord>? _reworkRecords;
|
private IRepository<ReworkRecord>? _reworkRecords;
|
||||||
public IRepository<ReworkRecord> ReworkRecords =>
|
public IRepository<ReworkRecord> ReworkRecords =>
|
||||||
_reworkRecords ??= new Repository<ReworkRecord>(_context);
|
_reworkRecords ??= new Repository<ReworkRecord>(_context);
|
||||||
@@ -460,6 +453,10 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
public IRepository<GiftCertificateRedemption> GiftCertificateRedemptions =>
|
public IRepository<GiftCertificateRedemption> GiftCertificateRedemptions =>
|
||||||
_giftCertificateRedemptions ??= new Repository<GiftCertificateRedemption>(_context);
|
_giftCertificateRedemptions ??= new Repository<GiftCertificateRedemption>(_context);
|
||||||
|
|
||||||
|
/// <summary>Repository for <see cref="KioskSession"/> customer self-service intake sessions; tenant-filtered with soft delete.</summary>
|
||||||
|
public IRepository<KioskSession> KioskSessions =>
|
||||||
|
_kioskSessions ??= new Repository<KioskSession>(_context);
|
||||||
|
|
||||||
// Job Templates
|
// Job Templates
|
||||||
/// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary>
|
/// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary>
|
||||||
public IJobTemplateRepository JobTemplates =>
|
public IJobTemplateRepository JobTemplates =>
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ public class CompanyDataPurgeService : ICompanyDataPurgeService
|
|||||||
await _context.NotificationTemplates.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
await _context.NotificationTemplates.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.Announcements.Where(x => x.TargetCompanyId == companyId).ExecuteDeleteAsync();
|
await _context.Announcements.Where(x => x.TargetCompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.BugReports.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
await _context.BugReports.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.ShopWorkers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
|
||||||
await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
|
|
||||||
// ── Tier 4: Company configs and lookup tables ─────────────────────────
|
// ── Tier 4: Company configs and lookup tables ─────────────────────────
|
||||||
@@ -137,7 +136,6 @@ public class CompanyDataPurgeService : ICompanyDataPurgeService
|
|||||||
await _context.PurchaseOrderItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
await _context.PurchaseOrderItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.AiItemPredictions.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
await _context.AiItemPredictions.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.PowderUsageLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
await _context.PowderUsageLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.ShopWorkerRoleCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
|
||||||
await _context.OvenBatches.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
await _context.OvenBatches.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.Refunds.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
await _context.Refunds.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.CreditMemos.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
await _context.CreditMemos.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
@@ -160,7 +158,6 @@ public class CompanyDataPurgeService : ICompanyDataPurgeService
|
|||||||
await _context.OvenCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
await _context.OvenCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.Accounts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
await _context.Accounts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.NotificationLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
await _context.NotificationLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.ShopWorkers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
|
||||||
await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using PowderCoating.Core.Entities;
|
using PowderCoating.Core.Entities;
|
||||||
|
using PowderCoating.Core.Enums;
|
||||||
using PowderCoating.Core.Interfaces.Services;
|
using PowderCoating.Core.Interfaces.Services;
|
||||||
using PowderCoating.Infrastructure.Data;
|
using PowderCoating.Infrastructure.Data;
|
||||||
|
|
||||||
@@ -21,15 +22,34 @@ public class CompanyListService : ICompanyListService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<(List<Company> Companies, int TotalCount)> GetPagedAsync(
|
public async Task<(List<Company> Companies, int TotalCount, int ChurnedCount)> GetPagedAsync(
|
||||||
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize)
|
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize,
|
||||||
|
bool hideChurned = true)
|
||||||
{
|
{
|
||||||
|
var cutoff = DateTime.UtcNow.AddDays(-14);
|
||||||
|
|
||||||
|
// Always count churned regardless of hideChurned so the banner can show a number.
|
||||||
|
var churnedCount = await _context.Companies
|
||||||
|
.AsNoTracking()
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(c => !c.IsDeleted
|
||||||
|
&& (c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled)
|
||||||
|
&& c.SubscriptionEndDate != null
|
||||||
|
&& c.SubscriptionEndDate < cutoff)
|
||||||
|
.CountAsync();
|
||||||
|
|
||||||
var query = _context.Companies
|
var query = _context.Companies
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(c => !c.IsDeleted)
|
.Where(c => !c.IsDeleted)
|
||||||
.AsQueryable();
|
.AsQueryable();
|
||||||
|
|
||||||
|
if (hideChurned)
|
||||||
|
query = query.Where(c =>
|
||||||
|
!((c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled)
|
||||||
|
&& c.SubscriptionEndDate != null
|
||||||
|
&& c.SubscriptionEndDate < cutoff));
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||||
{
|
{
|
||||||
var s = searchTerm.ToLower();
|
var s = searchTerm.ToLower();
|
||||||
@@ -61,12 +81,16 @@ public class CompanyListService : ICompanyListService
|
|||||||
.Take(pageSize)
|
.Take(pageSize)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
return (companies, totalCount);
|
return (companies, totalCount, churnedCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<CompanyCountSummary> GetCountSummaryAsync(IReadOnlyList<int> companyIds)
|
public async Task<CompanyCountSummary> GetCountSummaryAsync(IReadOnlyList<int> companyIds)
|
||||||
{
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var d30 = now.AddDays(-30);
|
||||||
|
var d90 = now.AddDays(-90);
|
||||||
|
|
||||||
var jobCounts = await _context.Jobs
|
var jobCounts = await _context.Jobs
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(j => companyIds.Contains(j.CompanyId) && !j.IsDeleted)
|
.Where(j => companyIds.Contains(j.CompanyId) && !j.IsDeleted)
|
||||||
@@ -98,6 +122,32 @@ public class CompanyListService : ICompanyListService
|
|||||||
x => x.CompanyId,
|
x => x.CompanyId,
|
||||||
x => new CompanyWizardInfo(true, x.SetupWizardCompletedAt, x.SetupWizardCompletedByName));
|
x => new CompanyWizardInfo(true, x.SetupWizardCompletedAt, x.SetupWizardCompletedByName));
|
||||||
|
|
||||||
return new CompanyCountSummary(jobCounts, quoteCounts, customerCounts, wizardInfo);
|
var jobs30 = await _context.Jobs
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(j => companyIds.Contains(j.CompanyId) && !j.IsDeleted && j.CreatedAt >= d30)
|
||||||
|
.GroupBy(j => j.CompanyId)
|
||||||
|
.Select(g => new { g.Key, Count = g.Count() })
|
||||||
|
.ToDictionaryAsync(x => x.Key, x => x.Count);
|
||||||
|
|
||||||
|
var jobs90 = await _context.Jobs
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(j => companyIds.Contains(j.CompanyId) && !j.IsDeleted && j.CreatedAt >= d90)
|
||||||
|
.GroupBy(j => j.CompanyId)
|
||||||
|
.Select(g => new { g.Key, Count = g.Count() })
|
||||||
|
.ToDictionaryAsync(x => x.Key, x => x.Count);
|
||||||
|
|
||||||
|
var lastLoginRaw = await _context.Users
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(u => companyIds.Contains(u.CompanyId) && u.LastLoginDate != null)
|
||||||
|
.GroupBy(u => u.CompanyId)
|
||||||
|
.Select(g => new { CompanyId = g.Key, Last = g.Max(u => u.LastLoginDate) })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
var lastLogins = lastLoginRaw.ToDictionary(
|
||||||
|
x => x.CompanyId,
|
||||||
|
x => x.Last);
|
||||||
|
|
||||||
|
return new CompanyCountSummary(jobCounts, quoteCounts, customerCounts, wizardInfo,
|
||||||
|
jobs30, jobs90, lastLogins);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using CsvHelper;
|
using CsvHelper;
|
||||||
using CsvHelper.Configuration;
|
using CsvHelper.Configuration;
|
||||||
@@ -2164,168 +2164,6 @@ public class CsvImportService : ICsvImportService
|
|||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Shop Worker Import
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Generates a downloadable CSV template with two example shop worker rows covering different roles.
|
|
||||||
/// Two rows help users see how Role values (Coater, Sandblaster, etc.) are expressed and remind
|
|
||||||
/// them that Role is optional — the importer will default to GeneralLabor when it is omitted.
|
|
||||||
/// </summary>
|
|
||||||
public byte[] GenerateShopWorkerTemplate()
|
|
||||||
{
|
|
||||||
using var memoryStream = new MemoryStream();
|
|
||||||
using var writer = new StreamWriter(memoryStream);
|
|
||||||
using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture));
|
|
||||||
|
|
||||||
csv.WriteHeader<ShopWorkerImportDto>();
|
|
||||||
csv.NextRecord();
|
|
||||||
|
|
||||||
csv.WriteRecord(new ShopWorkerImportDto
|
|
||||||
{
|
|
||||||
Name = "John Doe",
|
|
||||||
Role = "Coater",
|
|
||||||
Phone = "555-1234",
|
|
||||||
Email = "johndoe@example.com",
|
|
||||||
IsActive = true,
|
|
||||||
Notes = "Experienced powder coater"
|
|
||||||
});
|
|
||||||
csv.NextRecord();
|
|
||||||
|
|
||||||
csv.WriteRecord(new ShopWorkerImportDto
|
|
||||||
{
|
|
||||||
Name = "Jane Smith",
|
|
||||||
Role = "Sandblaster",
|
|
||||||
Phone = "555-5678",
|
|
||||||
Email = "janesmith@example.com",
|
|
||||||
IsActive = true,
|
|
||||||
Notes = ""
|
|
||||||
});
|
|
||||||
csv.NextRecord();
|
|
||||||
|
|
||||||
writer.Flush();
|
|
||||||
return memoryStream.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Imports shop workers from a CSV stream using an upsert strategy keyed on worker Name.
|
|
||||||
/// Like vendor import, this is intentionally an upsert rather than insert-only so that a
|
|
||||||
/// company can re-import their HR list to update phone/email/role details without worrying
|
|
||||||
/// about creating duplicates. Role is parsed case-insensitively with spaces stripped so that
|
|
||||||
/// "General Labor" and "GeneralLabor" are both accepted; an unrecognised role falls back to
|
|
||||||
/// GeneralLabor with a warning rather than failing the row.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="csvStream">Readable stream of CSV data (header row required).</param>
|
|
||||||
/// <param name="companyId">Tenant company that will own newly inserted worker records.</param>
|
|
||||||
public async Task<CsvImportResultDto> ImportShopWorkersAsync(Stream csvStream, int companyId)
|
|
||||||
{
|
|
||||||
var result = new CsvImportResultDto();
|
|
||||||
var rowNumber = 0;
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var reader = new StreamReader(csvStream);
|
|
||||||
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
|
||||||
{
|
|
||||||
HeaderValidated = null,
|
|
||||||
MissingFieldFound = null
|
|
||||||
});
|
|
||||||
|
|
||||||
var records = csv.GetRecords<ShopWorkerImportDto>().ToList();
|
|
||||||
result.TotalRows = records.Count;
|
|
||||||
|
|
||||||
_logger.LogInformation("Starting import of {Count} shop workers for company {CompanyId}", records.Count, companyId);
|
|
||||||
|
|
||||||
// Load existing workers for upsert matching by name
|
|
||||||
var existingWorkers = await _unitOfWork.ShopWorkers.GetAllAsync();
|
|
||||||
var workerDict = existingWorkers
|
|
||||||
.Where(w => !string.IsNullOrEmpty(w.Name))
|
|
||||||
.GroupBy(w => w.Name.Trim().ToUpperInvariant())
|
|
||||||
.ToDictionary(g => g.Key, g => g.First());
|
|
||||||
|
|
||||||
foreach (var record in records)
|
|
||||||
{
|
|
||||||
rowNumber++;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(record.Name))
|
|
||||||
{
|
|
||||||
result.Errors.Add($"Row {rowNumber}: Name is required.");
|
|
||||||
result.ErrorCount++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse role
|
|
||||||
ShopWorkerRole role = ShopWorkerRole.GeneralLabor;
|
|
||||||
if (!string.IsNullOrEmpty(record.Role))
|
|
||||||
{
|
|
||||||
if (!Enum.TryParse<ShopWorkerRole>(record.Role.Replace(" ", ""), true, out role))
|
|
||||||
{
|
|
||||||
result.Warnings.Add($"Row {rowNumber}: Role '{record.Role}' not recognized. Valid values: GeneralLabor, Sandblaster, Coater, Masker, QualityControl, OvenOperator, Supervisor, Maintenance. Using 'GeneralLabor'.");
|
|
||||||
role = ShopWorkerRole.GeneralLabor;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var key = record.Name.Trim().ToUpperInvariant();
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
|
|
||||||
if (workerDict.TryGetValue(key, out var existing))
|
|
||||||
{
|
|
||||||
// Update
|
|
||||||
existing.Role = role;
|
|
||||||
existing.Phone = record.Phone ?? existing.Phone;
|
|
||||||
existing.Email = record.Email ?? existing.Email;
|
|
||||||
if (record.IsActive.HasValue) existing.IsActive = record.IsActive.Value;
|
|
||||||
existing.Notes = record.Notes ?? existing.Notes;
|
|
||||||
existing.UpdatedAt = now;
|
|
||||||
|
|
||||||
await _unitOfWork.CompleteAsync();
|
|
||||||
result.Warnings.Add($"Row {rowNumber}: Updated existing shop worker '{record.Name}'.");
|
|
||||||
result.SuccessCount++;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var worker = new Core.Entities.ShopWorker
|
|
||||||
{
|
|
||||||
CompanyId = companyId,
|
|
||||||
Name = record.Name.Trim(),
|
|
||||||
Role = role,
|
|
||||||
Phone = record.Phone,
|
|
||||||
Email = record.Email,
|
|
||||||
IsActive = record.IsActive ?? true,
|
|
||||||
Notes = record.Notes,
|
|
||||||
CreatedAt = now,
|
|
||||||
UpdatedAt = now
|
|
||||||
};
|
|
||||||
|
|
||||||
await _unitOfWork.ShopWorkers.AddAsync(worker);
|
|
||||||
await _unitOfWork.CompleteAsync();
|
|
||||||
result.SuccessCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
result.Errors.Add($"Row {rowNumber}: Database error - {ex.Message}");
|
|
||||||
result.ErrorCount++;
|
|
||||||
_logger.LogError(ex, "Error saving shop worker at row {RowNumber}", rowNumber);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Shop worker import completed: {SuccessCount} succeeded, {ErrorCount} failed", result.SuccessCount, result.ErrorCount);
|
|
||||||
result.Success = result.SuccessCount > 0;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
result.Errors.Add($"Fatal error: {ex.Message}");
|
|
||||||
result.Success = false;
|
|
||||||
_logger.LogError(ex, "Fatal error importing shop workers");
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
|
||||||
|
|
||||||
#region Prep Service Import
|
#region Prep Service Import
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -79,6 +79,53 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
||||||
if (unlinkedRevenue > 0)
|
if (unlinkedRevenue > 0)
|
||||||
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Other Sales (unclassified)", Amount = unlinkedRevenue });
|
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Other Sales (unclassified)", Amount = unlinkedRevenue });
|
||||||
|
|
||||||
|
// Contra-revenue: discounts granted and credit memos applied reduce gross revenue.
|
||||||
|
var periodDiscounts = await _context.Invoices
|
||||||
|
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||||
|
&& i.DiscountAmount > 0 && i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
|
||||||
|
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
|
||||||
|
var periodCredits = await _context.CreditMemoApplications
|
||||||
|
.Where(a => a.AppliedDate >= from && a.AppliedDate <= toEnd
|
||||||
|
&& a.Invoice.Status != InvoiceStatus.Voided)
|
||||||
|
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m;
|
||||||
|
var totalDeductions = periodDiscounts + periodCredits;
|
||||||
|
if (totalDeductions > 0)
|
||||||
|
revenueLines.Add(new FinancialReportLine
|
||||||
|
{
|
||||||
|
AccountNumber = "4950",
|
||||||
|
AccountName = "Less: Sales Discounts & Credits",
|
||||||
|
Amount = -totalDeductions
|
||||||
|
});
|
||||||
|
|
||||||
|
// GC sales are deferred to GC Liability at issuance; revenue is recognized on redemption.
|
||||||
|
var periodGcReclassified = await _context.InvoiceItems
|
||||||
|
.Where(ii => ii.IsGiftCertificate
|
||||||
|
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||||
|
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||||
|
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
|
||||||
|
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0m;
|
||||||
|
if (periodGcReclassified > 0)
|
||||||
|
revenueLines.Add(new FinancialReportLine
|
||||||
|
{
|
||||||
|
AccountNumber = "2500",
|
||||||
|
AccountName = "Less: Gift Certificates Issued (Deferred Revenue)",
|
||||||
|
Amount = -periodGcReclassified
|
||||||
|
});
|
||||||
|
|
||||||
|
// Voided GCs with remaining balance are breakage income (liability extinguished).
|
||||||
|
var periodGcBreakage = await _context.GiftCertificates
|
||||||
|
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||||
|
&& gc.UpdatedAt >= from && gc.UpdatedAt <= toEnd
|
||||||
|
&& gc.OriginalAmount > gc.RedeemedAmount)
|
||||||
|
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m;
|
||||||
|
if (periodGcBreakage > 0)
|
||||||
|
revenueLines.Add(new FinancialReportLine
|
||||||
|
{
|
||||||
|
AccountNumber = "—",
|
||||||
|
AccountName = "Gift Certificate Breakage Income",
|
||||||
|
Amount = periodGcBreakage
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// COGS & Expenses — cash basis: expenses paid in period; accrual: by bill/expense date
|
// COGS & Expenses — cash basis: expenses paid in period; accrual: by bill/expense date
|
||||||
@@ -200,6 +247,13 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
|
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
|
||||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||||
|
|
||||||
|
// AP: vendor credit applications reduce AP (DR side) when matched against specific bills.
|
||||||
|
var vcByApAcctBs = await _context.VendorCreditApplications
|
||||||
|
.Where(vca => vca.AppliedDate <= asOfEnd)
|
||||||
|
.GroupBy(vca => vca.VendorCredit.APAccountId)
|
||||||
|
.Select(g => new { Id = g.Key, Amount = g.Sum(vca => vca.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||||
|
|
||||||
var taxByAcct = await _context.Invoices
|
var taxByAcct = await _context.Invoices
|
||||||
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0
|
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0
|
||||||
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||||
@@ -216,18 +270,131 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||||
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
||||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||||
|
// Credit memo applications reduce open AR (CR AR when a credit is applied to an invoice).
|
||||||
|
arCredits += await _context.CreditMemoApplications
|
||||||
|
.Where(a => a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided)
|
||||||
|
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
|
||||||
|
// Refunds reverse collected payments — they re-open AR so reduce net AR credits.
|
||||||
|
arCredits -= await _context.Refunds
|
||||||
|
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted)
|
||||||
|
.SumAsync(r => (decimal?)r.Amount) ?? 0m;
|
||||||
|
|
||||||
// Retained earnings = net P&L from inception through asOf
|
// Refunds by bank account: money that left the account (CR to checking/bank).
|
||||||
|
var refundsByAcctBs = await _context.Refunds
|
||||||
|
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted && r.DepositAccountId != null)
|
||||||
|
.GroupBy(r => r.DepositAccountId!.Value)
|
||||||
|
.Select(g => new { Id = g.Key, Amount = g.Sum(r => r.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||||
|
|
||||||
|
// Deposits by bank account: cash received at deposit recording time (DR bank).
|
||||||
|
var depositsByAcctDepBs = await _context.Deposits
|
||||||
|
.Where(d => !d.IsDeleted && d.DepositAccountId != null && d.ReceivedDate <= asOfEnd)
|
||||||
|
.GroupBy(d => d.DepositAccountId!.Value)
|
||||||
|
.Select(g => new { Id = g.Key, Amount = g.Sum(d => d.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||||
|
|
||||||
|
// Customer Deposits liability (2300): credits = all deposits taken; debits = deposits applied to invoices.
|
||||||
|
var custDepositsAcctIdBs = await _context.Accounts
|
||||||
|
.Where(a => a.CompanyId == companyId && a.AccountNumber == "2300" && a.IsActive && !a.IsDeleted)
|
||||||
|
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
||||||
|
var custDepositsCreditsBs = custDepositsAcctIdBs.HasValue
|
||||||
|
? (await _context.Deposits
|
||||||
|
.Where(d => !d.IsDeleted && d.ReceivedDate <= asOfEnd)
|
||||||
|
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
|
||||||
|
var custDepositsDebitsBs = custDepositsAcctIdBs.HasValue
|
||||||
|
? (await _context.Deposits
|
||||||
|
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate <= asOfEnd)
|
||||||
|
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
|
||||||
|
|
||||||
|
// Gift Certificate Liability (2500): balance driven by GC issuances, redemptions, and voids.
|
||||||
|
var gcLiabilityAcctIdBs = await _context.Accounts
|
||||||
|
.Where(a => a.CompanyId == companyId && a.AccountNumber == "2500" && a.IsActive && !a.IsDeleted)
|
||||||
|
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
||||||
|
var gcLiabilityCreditsBs = gcLiabilityAcctIdBs.HasValue
|
||||||
|
? (await _context.GiftCertificates
|
||||||
|
.Where(gc => !gc.IsDeleted && gc.IssueDate <= asOfEnd)
|
||||||
|
.SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0m) : 0m;
|
||||||
|
var gcLiabilityDebitsBs = gcLiabilityAcctIdBs.HasValue
|
||||||
|
? ((await _context.GiftCertificateRedemptions
|
||||||
|
.Where(r => !r.IsDeleted && r.RedeemedDate <= asOfEnd)
|
||||||
|
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m)
|
||||||
|
+ (await _context.GiftCertificates
|
||||||
|
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||||
|
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
|
||||||
|
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m)) : 0m;
|
||||||
|
|
||||||
|
// Retained earnings = net P&L from inception through asOf, covering four sources:
|
||||||
|
// (1) invoice revenue, (2) invoice discounts, (3) direct expenses, (4) vendor bill costs,
|
||||||
|
// plus (5) the net effect of any posted journal entries on revenue/expense/COGS accounts
|
||||||
|
// (accruals, depreciation, year-end closes, and other adjustments not in the tables above).
|
||||||
var lifetimeRevenue = await _context.InvoiceItems
|
var lifetimeRevenue = await _context.InvoiceItems
|
||||||
.Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd)
|
.Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd)
|
||||||
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
||||||
var lifetimeCogs = await _context.Expenses
|
var lifetimeDiscounts = isCash ? 0m
|
||||||
|
: (await _context.Invoices
|
||||||
|
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||||
|
&& i.DiscountAmount > 0 && i.InvoiceDate <= asOfEnd)
|
||||||
|
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m);
|
||||||
|
// Credit memos applied to invoices reduce net revenue (contra-revenue, same as discounts).
|
||||||
|
var lifetimeCreditMemos = isCash ? 0m
|
||||||
|
: (await _context.CreditMemoApplications
|
||||||
|
.Where(a => a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided)
|
||||||
|
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m);
|
||||||
|
var lifetimeDirectExp = await _context.Expenses
|
||||||
.Where(e => e.Date <= asOfEnd)
|
.Where(e => e.Date <= asOfEnd)
|
||||||
.SumAsync(e => (decimal?)e.Amount) ?? 0;
|
.SumAsync(e => (decimal?)e.Amount) ?? 0;
|
||||||
var lifetimeBillCosts = await _context.BillLineItems
|
var lifetimeBillCosts = await _context.BillLineItems
|
||||||
.Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd)
|
.Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd)
|
||||||
.SumAsync(bli => (decimal?)bli.Amount) ?? 0;
|
.SumAsync(bli => (decimal?)bli.Amount) ?? 0;
|
||||||
var retainedEarnings = lifetimeRevenue - lifetimeCogs - lifetimeBillCosts;
|
|
||||||
|
// JE net effect on revenue accounts (positive = additional revenue recognised via manual JE)
|
||||||
|
var revenueAcctIds = await _context.Accounts
|
||||||
|
.Where(a => a.CompanyId == companyId && a.AccountType == AccountType.Revenue && !a.IsDeleted)
|
||||||
|
.Select(a => a.Id).ToListAsync();
|
||||||
|
var expCogsAcctIds = await _context.Accounts
|
||||||
|
.Where(a => a.CompanyId == companyId
|
||||||
|
&& (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods)
|
||||||
|
&& !a.IsDeleted)
|
||||||
|
.Select(a => a.Id).ToListAsync();
|
||||||
|
|
||||||
|
var jeRevNet = revenueAcctIds.Count > 0
|
||||||
|
? (await _context.JournalEntryLines
|
||||||
|
.Where(l => revenueAcctIds.Contains(l.AccountId)
|
||||||
|
&& l.JournalEntry.Status == JournalEntryStatus.Posted
|
||||||
|
&& l.JournalEntry.EntryDate <= asOfEnd)
|
||||||
|
.SumAsync(l => (decimal?)(l.CreditAmount - l.DebitAmount)) ?? 0m)
|
||||||
|
: 0m;
|
||||||
|
|
||||||
|
// JE net effect on expense/COGS accounts (positive = additional expense recognised via manual JE)
|
||||||
|
var jeExpNet = expCogsAcctIds.Count > 0
|
||||||
|
? (await _context.JournalEntryLines
|
||||||
|
.Where(l => expCogsAcctIds.Contains(l.AccountId)
|
||||||
|
&& l.JournalEntry.Status == JournalEntryStatus.Posted
|
||||||
|
&& l.JournalEntry.EntryDate <= asOfEnd)
|
||||||
|
.SumAsync(l => (decimal?)(l.DebitAmount - l.CreditAmount)) ?? 0m)
|
||||||
|
: 0m;
|
||||||
|
|
||||||
|
// GC items sold via invoices are reclassified to GC Liability and not yet earned income.
|
||||||
|
var lifetimeGcReclassified = await _context.InvoiceItems
|
||||||
|
.Where(ii => ii.IsGiftCertificate
|
||||||
|
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||||
|
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||||
|
&& ii.Invoice.InvoiceDate <= asOfEnd)
|
||||||
|
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0m;
|
||||||
|
// Voided GCs with remaining balance become breakage income (the liability is extinguished).
|
||||||
|
var lifetimeGcBreakage = await _context.GiftCertificates
|
||||||
|
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||||
|
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
|
||||||
|
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m;
|
||||||
|
|
||||||
|
var retainedEarnings = lifetimeRevenue + jeRevNet
|
||||||
|
- lifetimeDiscounts
|
||||||
|
- lifetimeCreditMemos
|
||||||
|
- lifetimeGcReclassified // deferred to GC Liability, not earned yet
|
||||||
|
+ lifetimeGcBreakage // breakage income when GC voided with balance
|
||||||
|
- lifetimeDirectExp
|
||||||
|
- lifetimeBillCosts
|
||||||
|
- jeExpNet;
|
||||||
|
|
||||||
var accounts = await _context.Accounts
|
var accounts = await _context.Accounts
|
||||||
.Where(a => a.IsActive)
|
.Where(a => a.IsActive)
|
||||||
@@ -246,8 +413,9 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
}
|
}
|
||||||
else if (a.AccountSubType == AccountSubType.AccountsPayable)
|
else if (a.AccountSubType == AccountSubType.AccountsPayable)
|
||||||
{
|
{
|
||||||
credits = billsByApAcct.GetValueOrDefault(a.Id);
|
credits = billsByApAcct.GetValueOrDefault(a.Id);
|
||||||
debits = bpByApAcct.GetValueOrDefault(a.Id);
|
debits = bpByApAcct.GetValueOrDefault(a.Id);
|
||||||
|
debits += vcByApAcctBs.GetValueOrDefault(a.Id); // vendor credit applications reduce AP
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -255,6 +423,18 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
credits += expFromByAcct.GetValueOrDefault(a.Id);
|
credits += expFromByAcct.GetValueOrDefault(a.Id);
|
||||||
credits += bpFromByAcct.GetValueOrDefault(a.Id);
|
credits += bpFromByAcct.GetValueOrDefault(a.Id);
|
||||||
credits += taxByAcct.GetValueOrDefault(a.Id);
|
credits += taxByAcct.GetValueOrDefault(a.Id);
|
||||||
|
credits += refundsByAcctBs.GetValueOrDefault(a.Id); // refunds reduce bank balance
|
||||||
|
debits += depositsByAcctDepBs.GetValueOrDefault(a.Id); // deposits increase bank balance
|
||||||
|
if (gcLiabilityAcctIdBs.HasValue && a.Id == gcLiabilityAcctIdBs.Value)
|
||||||
|
{
|
||||||
|
credits += gcLiabilityCreditsBs; // GC issued → CR liability
|
||||||
|
debits += gcLiabilityDebitsBs; // redeemed/voided → DR liability
|
||||||
|
}
|
||||||
|
if (custDepositsAcctIdBs.HasValue && a.Id == custDepositsAcctIdBs.Value)
|
||||||
|
{
|
||||||
|
credits += custDepositsCreditsBs; // deposits taken → CR liability
|
||||||
|
debits += custDepositsDebitsBs; // deposits applied → DR liability
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOf)
|
decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOf)
|
||||||
@@ -652,20 +832,277 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
|
/// <remarks>
|
||||||
|
/// Balances are computed dynamically from transaction tables using the same pre-computed
|
||||||
|
/// dictionary approach as <see cref="GetBalanceSheetAsync"/>, so the <paramref name="asOf"/>
|
||||||
|
/// date is respected. This replaces the previous implementation that read the denormalised
|
||||||
|
/// <c>Account.CurrentBalance</c> field, which always reflected the current date regardless of
|
||||||
|
/// what date was selected.
|
||||||
|
/// </remarks>
|
||||||
public async Task<TrialBalanceDto> GetTrialBalanceAsync(int companyId, DateTime asOf)
|
public async Task<TrialBalanceDto> GetTrialBalanceAsync(int companyId, DateTime asOf)
|
||||||
{
|
{
|
||||||
|
var asOfEnd = asOf.AddDays(1).AddTicks(-1);
|
||||||
var companyName = await GetCompanyNameAsync(companyId);
|
var companyName = await GetCompanyNameAsync(companyId);
|
||||||
|
|
||||||
|
// ── Pre-compute per-account contribution dictionaries (batch GROUP BY, no N+1) ──────
|
||||||
|
|
||||||
|
// Bank/cash: customer payments deposited here (DR)
|
||||||
|
var depositsByAcct = await _context.Payments
|
||||||
|
.Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null
|
||||||
|
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||||
|
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
||||||
|
.GroupBy(p => p.DepositAccountId!.Value)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(p => p.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// AP: vendor credit applications reduce AP (DR) — credits are applied when a vendor
|
||||||
|
// issues a credit note and it is matched against a specific bill.
|
||||||
|
var vcByApAcct = await _context.VendorCreditApplications
|
||||||
|
.Where(vca => vca.AppliedDate <= asOfEnd)
|
||||||
|
.GroupBy(vca => vca.VendorCredit.APAccountId)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(vca => vca.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// Bank/cash: expenses paid from here (CR)
|
||||||
|
var expFromByAcct = await _context.Expenses
|
||||||
|
.Where(e => e.Date <= asOfEnd)
|
||||||
|
.GroupBy(e => e.PaymentAccountId)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(e => e.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// Bank/cash: bill payments made from here (CR)
|
||||||
|
var bpFromByAcct = await _context.BillPayments
|
||||||
|
.Where(bp => bp.PaymentDate <= asOfEnd)
|
||||||
|
.GroupBy(bp => bp.BankAccountId)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(bp => bp.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// AP: bills increase AP (CR)
|
||||||
|
var billsByApAcct = await _context.Bills
|
||||||
|
.Where(b => b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd)
|
||||||
|
.GroupBy(b => b.APAccountId)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(b => b.Total) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// AP: bill payments reduce AP (DR)
|
||||||
|
var bpByApAcct = await _context.BillPayments
|
||||||
|
.Where(bp => bp.PaymentDate <= asOfEnd)
|
||||||
|
.GroupBy(bp => bp.Bill.APAccountId)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(bp => bp.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// Tax liability: sales tax collected (CR)
|
||||||
|
var taxByAcct = await _context.Invoices
|
||||||
|
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0
|
||||||
|
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||||
|
&& i.InvoiceDate <= asOfEnd)
|
||||||
|
.GroupBy(i => i.SalesTaxAccountId!.Value)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(i => i.TaxAmount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// Revenue accounts: invoice line items (CR)
|
||||||
|
var revenueByAcct = await _context.InvoiceItems
|
||||||
|
.Where(ii => ii.RevenueAccountId != null
|
||||||
|
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||||
|
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||||
|
&& ii.Invoice.InvoiceDate <= asOfEnd)
|
||||||
|
.GroupBy(ii => ii.RevenueAccountId!.Value)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(ii => ii.TotalPrice) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// Expense accounts: direct expenses (DR)
|
||||||
|
var expenseByAcct = await _context.Expenses
|
||||||
|
.Where(e => e.Date <= asOfEnd)
|
||||||
|
.GroupBy(e => e.ExpenseAccountId)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(e => e.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// Expense/COGS accounts: vendor bill line items (DR)
|
||||||
|
var billLinesByAcct = await _context.BillLineItems
|
||||||
|
.Where(bli => bli.AccountId != null
|
||||||
|
&& bli.Bill.Status != BillStatus.Draft
|
||||||
|
&& bli.Bill.Status != BillStatus.Voided
|
||||||
|
&& bli.Bill.BillDate <= asOfEnd)
|
||||||
|
.GroupBy(bli => bli.AccountId!.Value)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(bli => bli.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// Sales Discounts contra-revenue account: invoice discounts and credit memo applications (DR).
|
||||||
|
// Both reduce net revenue and are attributed to account 4950 as contra-revenue debits.
|
||||||
|
// Credit memo applications are also added to AR credits below so the double-entry balances.
|
||||||
|
var discountAcctId = await _context.Accounts
|
||||||
|
.Where(a => a.CompanyId == companyId && a.AccountNumber == "4950" && a.IsActive && !a.IsDeleted)
|
||||||
|
.Select(a => (int?)a.Id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
discountAcctId ??= await _context.Accounts
|
||||||
|
.Where(a => a.CompanyId == companyId && a.AccountType == AccountType.Revenue
|
||||||
|
&& a.IsActive && !a.IsDeleted && a.Name.ToLower().Contains("discount"))
|
||||||
|
.Select(a => (int?)a.Id)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
var cmApplied = await _context.CreditMemoApplications
|
||||||
|
.Where(a => a.AppliedDate <= asOfEnd
|
||||||
|
&& a.Invoice.Status != InvoiceStatus.Voided)
|
||||||
|
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m;
|
||||||
|
|
||||||
|
var discountsByAcct = new Dictionary<int, decimal>();
|
||||||
|
if (discountAcctId.HasValue)
|
||||||
|
{
|
||||||
|
var totalDiscounts = await _context.Invoices
|
||||||
|
.Where(i => i.DiscountAmount > 0
|
||||||
|
&& i.Status != InvoiceStatus.Draft
|
||||||
|
&& i.Status != InvoiceStatus.Voided
|
||||||
|
&& i.InvoiceDate <= asOfEnd)
|
||||||
|
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
|
||||||
|
if (totalDiscounts + cmApplied > 0)
|
||||||
|
discountsByAcct[discountAcctId.Value] = totalDiscounts + cmApplied;
|
||||||
|
}
|
||||||
|
|
||||||
|
// JE lines: posted entries debit/credit all account types
|
||||||
|
var jeDebitsByAcct = await _context.JournalEntryLines
|
||||||
|
.Where(l => l.JournalEntry.Status == JournalEntryStatus.Posted
|
||||||
|
&& l.JournalEntry.EntryDate <= asOfEnd)
|
||||||
|
.GroupBy(l => l.AccountId)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(l => l.DebitAmount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
var jeCreditsByAcct = await _context.JournalEntryLines
|
||||||
|
.Where(l => l.JournalEntry.Status == JournalEntryStatus.Posted
|
||||||
|
&& l.JournalEntry.EntryDate <= asOfEnd)
|
||||||
|
.GroupBy(l => l.AccountId)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(l => l.CreditAmount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// AR totals (single AR account assumed per standard small-business chart of accounts).
|
||||||
|
// Credits include both cash payments and credit memo applications (which reduce open AR
|
||||||
|
// when a customer credit is applied against a specific invoice).
|
||||||
|
var arTotalDebits = await _context.Invoices
|
||||||
|
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||||
|
&& i.InvoiceDate <= asOfEnd)
|
||||||
|
.SumAsync(i => (decimal?)i.Total) ?? 0m;
|
||||||
|
var arTotalCredits = await _context.Payments
|
||||||
|
.Where(p => p.PaymentDate <= asOfEnd
|
||||||
|
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||||
|
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
||||||
|
.SumAsync(p => (decimal?)p.Amount) ?? 0m;
|
||||||
|
arTotalCredits += cmApplied; // credit memo applications reduce AR balance
|
||||||
|
|
||||||
|
// Refunds reverse collected payments — reduce net AR credits (re-opens the receivable).
|
||||||
|
var refundTotal = await _context.Refunds
|
||||||
|
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted)
|
||||||
|
.SumAsync(r => (decimal?)r.Amount) ?? 0m;
|
||||||
|
arTotalCredits -= refundTotal;
|
||||||
|
|
||||||
|
// Refunds by bank account: money leaving the account (CR to checking/bank).
|
||||||
|
var refundsByAcct = await _context.Refunds
|
||||||
|
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted && r.DepositAccountId != null)
|
||||||
|
.GroupBy(r => r.DepositAccountId!.Value)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(r => r.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// Deposits by bank account: cash received at deposit recording time (DR bank).
|
||||||
|
// Deposit-sourced Payments have DepositAccountId = null, so there is no double-count with depositsByAcct.
|
||||||
|
var depositsByAcctDep = await _context.Deposits
|
||||||
|
.Where(d => !d.IsDeleted && d.DepositAccountId != null && d.ReceivedDate <= asOfEnd)
|
||||||
|
.GroupBy(d => d.DepositAccountId!.Value)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(d => d.Amount) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// Customer Deposits liability (2300): credits = all deposits taken; debits = deposits applied to invoices.
|
||||||
|
var custDepositsAcctId = await _context.Accounts
|
||||||
|
.Where(a => a.CompanyId == companyId && a.AccountNumber == "2300" && a.IsActive && !a.IsDeleted)
|
||||||
|
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
||||||
|
var custDepositsCredits = custDepositsAcctId.HasValue
|
||||||
|
? (await _context.Deposits
|
||||||
|
.Where(d => !d.IsDeleted && d.ReceivedDate <= asOfEnd)
|
||||||
|
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
|
||||||
|
var custDepositsDebits = custDepositsAcctId.HasValue
|
||||||
|
? (await _context.Deposits
|
||||||
|
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate <= asOfEnd)
|
||||||
|
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
|
||||||
|
|
||||||
|
// Gift Certificate Liability (2500): balance driven by GC issuances, redemptions, and voids.
|
||||||
|
var gcLiabilityAcctId = await _context.Accounts
|
||||||
|
.Where(a => a.CompanyId == companyId && a.AccountNumber == "2500" && a.IsActive && !a.IsDeleted)
|
||||||
|
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
||||||
|
var gcLiabilityCredits = gcLiabilityAcctId.HasValue
|
||||||
|
? (await _context.GiftCertificates
|
||||||
|
.Where(gc => !gc.IsDeleted && gc.IssueDate <= asOfEnd)
|
||||||
|
.SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0m) : 0m;
|
||||||
|
var gcLiabilityDebits = gcLiabilityAcctId.HasValue
|
||||||
|
? ((await _context.GiftCertificateRedemptions
|
||||||
|
.Where(r => !r.IsDeleted && r.RedeemedDate <= asOfEnd)
|
||||||
|
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m)
|
||||||
|
+ (await _context.GiftCertificates
|
||||||
|
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||||
|
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
|
||||||
|
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m)) : 0m;
|
||||||
|
|
||||||
|
// ── Per-account balance computation ─────────────────────────────────────────────────
|
||||||
|
|
||||||
var accounts = await _context.Accounts
|
var accounts = await _context.Accounts
|
||||||
.Where(a => a.CompanyId == companyId && a.IsActive)
|
.Where(a => a.CompanyId == companyId && a.IsActive)
|
||||||
.OrderBy(a => a.AccountNumber)
|
.OrderBy(a => a.AccountNumber)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var lines = new List<TrialBalanceLine>();
|
decimal ComputeAsOfBalance(Account a)
|
||||||
|
{
|
||||||
|
bool isDebitNormal = AccountingRules.IsNormalDebitBalance(a.AccountSubType);
|
||||||
|
decimal debits = 0m, credits = 0m;
|
||||||
|
|
||||||
|
if (a.AccountSubType == AccountSubType.AccountsReceivable)
|
||||||
|
{
|
||||||
|
debits = arTotalDebits;
|
||||||
|
credits = arTotalCredits;
|
||||||
|
}
|
||||||
|
else if (a.AccountSubType == AccountSubType.AccountsPayable)
|
||||||
|
{
|
||||||
|
credits = billsByApAcct.GetValueOrDefault(a.Id);
|
||||||
|
debits = bpByApAcct.GetValueOrDefault(a.Id);
|
||||||
|
debits += vcByApAcct.GetValueOrDefault(a.Id); // vendor credit applications reduce AP
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// All other accounts: sum contributions from each transaction source that can
|
||||||
|
// post to this account. Dictionaries only contain entries for relevant account IDs,
|
||||||
|
// so GetValueOrDefault returns 0 for sources that do not apply to this account type.
|
||||||
|
debits += depositsByAcct.GetValueOrDefault(a.Id);
|
||||||
|
credits += expFromByAcct.GetValueOrDefault(a.Id);
|
||||||
|
credits += bpFromByAcct.GetValueOrDefault(a.Id);
|
||||||
|
credits += taxByAcct.GetValueOrDefault(a.Id);
|
||||||
|
credits += revenueByAcct.GetValueOrDefault(a.Id);
|
||||||
|
debits += expenseByAcct.GetValueOrDefault(a.Id);
|
||||||
|
debits += billLinesByAcct.GetValueOrDefault(a.Id);
|
||||||
|
debits += discountsByAcct.GetValueOrDefault(a.Id);
|
||||||
|
credits += refundsByAcct.GetValueOrDefault(a.Id); // refunds reduce bank balance
|
||||||
|
debits += depositsByAcctDep.GetValueOrDefault(a.Id); // deposits increase bank balance
|
||||||
|
if (gcLiabilityAcctId.HasValue && a.Id == gcLiabilityAcctId.Value)
|
||||||
|
{
|
||||||
|
credits += gcLiabilityCredits; // GC issued → CR liability
|
||||||
|
debits += gcLiabilityDebits; // redeemed/voided → DR liability
|
||||||
|
}
|
||||||
|
if (custDepositsAcctId.HasValue && a.Id == custDepositsAcctId.Value)
|
||||||
|
{
|
||||||
|
credits += custDepositsCredits; // deposits taken → CR liability
|
||||||
|
debits += custDepositsDebits; // deposits applied → DR liability
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual JEs apply to all account types (including AR/AP for unusual adjustments)
|
||||||
|
debits += jeDebitsByAcct.GetValueOrDefault(a.Id);
|
||||||
|
credits += jeCreditsByAcct.GetValueOrDefault(a.Id);
|
||||||
|
|
||||||
|
decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOf)
|
||||||
|
? a.OpeningBalance : 0m;
|
||||||
|
decimal net = isDebitNormal ? debits - credits : credits - debits;
|
||||||
|
return opening + net;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines = new List<TrialBalanceLine>();
|
||||||
foreach (var acct in accounts)
|
foreach (var acct in accounts)
|
||||||
{
|
{
|
||||||
if (acct.CurrentBalance == 0) continue;
|
var balance = ComputeAsOfBalance(acct);
|
||||||
|
if (balance == 0m) continue;
|
||||||
|
|
||||||
var isDebitNormal = AccountingRules.IsNormalDebitBalance(acct.AccountSubType);
|
var isDebitNormal = AccountingRules.IsNormalDebitBalance(acct.AccountSubType);
|
||||||
var line = new TrialBalanceLine
|
var line = new TrialBalanceLine
|
||||||
@@ -679,14 +1116,14 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
if (isDebitNormal)
|
if (isDebitNormal)
|
||||||
{
|
{
|
||||||
// Normal debit: positive balance → Debit column; negative → Credit column (abnormal)
|
// Normal debit: positive balance → Debit column; negative → Credit column (abnormal)
|
||||||
if (acct.CurrentBalance >= 0) line.DebitBalance = acct.CurrentBalance;
|
if (balance >= 0m) line.DebitBalance = balance;
|
||||||
else line.CreditBalance = -acct.CurrentBalance;
|
else line.CreditBalance = -balance;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Normal credit: positive balance → Credit column; negative → Debit column (abnormal)
|
// Normal credit: positive balance → Credit column; negative → Debit column (abnormal)
|
||||||
if (acct.CurrentBalance >= 0) line.CreditBalance = acct.CurrentBalance;
|
if (balance >= 0m) line.CreditBalance = balance;
|
||||||
else line.DebitBalance = -acct.CurrentBalance;
|
else line.DebitBalance = -balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.Add(line);
|
lines.Add(line);
|
||||||
|
|||||||
@@ -72,6 +72,45 @@ public class LedgerService : ILedgerService
|
|||||||
LinkId = p.InvoiceId
|
LinkId = p.InvoiceId
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Customer deposits recorded to this account (DEBIT — cash received at deposit time)
|
||||||
|
var depositedDeposits = await _context.Deposits
|
||||||
|
.Where(d => d.DepositAccountId == accountId
|
||||||
|
&& d.ReceivedDate >= fromDate && d.ReceivedDate <= toDate)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var d in depositedDeposits)
|
||||||
|
entries.Add(new LedgerEntryDto
|
||||||
|
{
|
||||||
|
Date = d.ReceivedDate,
|
||||||
|
Reference = d.ReceiptNumber,
|
||||||
|
Source = "Customer Deposit",
|
||||||
|
Description = d.Notes ?? d.Reference,
|
||||||
|
Debit = d.Amount,
|
||||||
|
Credit = 0,
|
||||||
|
LinkController = "Jobs",
|
||||||
|
LinkId = d.JobId
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refunds paid FROM this account (CREDIT — cash leaves)
|
||||||
|
var refundsPaidFrom = await _context.Refunds
|
||||||
|
.Include(r => r.Invoice)
|
||||||
|
.Where(r => r.DepositAccountId == accountId
|
||||||
|
&& r.RefundDate >= fromDate && r.RefundDate <= toDate)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var r in refundsPaidFrom)
|
||||||
|
entries.Add(new LedgerEntryDto
|
||||||
|
{
|
||||||
|
Date = r.RefundDate,
|
||||||
|
Reference = r.Reference ?? $"REF-{r.Id}",
|
||||||
|
Source = "Refund",
|
||||||
|
Description = r.Reason,
|
||||||
|
Debit = 0,
|
||||||
|
Credit = r.Amount,
|
||||||
|
LinkController = "Invoices",
|
||||||
|
LinkId = r.InvoiceId
|
||||||
|
});
|
||||||
|
|
||||||
// ── 2. Direct expenses paid FROM this account (CREDIT) ────────────────
|
// ── 2. Direct expenses paid FROM this account (CREDIT) ────────────────
|
||||||
// e.g. Checking account used to pay an expense
|
// e.g. Checking account used to pay an expense
|
||||||
var expensesPaidFrom = await _context.Expenses
|
var expensesPaidFrom = await _context.Expenses
|
||||||
@@ -251,6 +290,46 @@ public class LedgerService : ILedgerService
|
|||||||
LinkController = "Invoices",
|
LinkController = "Invoices",
|
||||||
LinkId = p.InvoiceId
|
LinkId = p.InvoiceId
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Credit memo applications reduce open AR (CREDIT)
|
||||||
|
var arCreditMemos = await _context.CreditMemoApplications
|
||||||
|
.Include(a => a.Invoice)
|
||||||
|
.Include(a => a.CreditMemo)
|
||||||
|
.Where(a => a.AppliedDate >= fromDate && a.AppliedDate <= toDate
|
||||||
|
&& a.Invoice.Status != InvoiceStatus.Voided)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var cm in arCreditMemos)
|
||||||
|
entries.Add(new LedgerEntryDto
|
||||||
|
{
|
||||||
|
Date = cm.AppliedDate,
|
||||||
|
Reference = cm.CreditMemo?.MemoNumber ?? $"CM-{cm.Id}",
|
||||||
|
Source = "Credit Memo",
|
||||||
|
Description = $"Credit applied to {cm.Invoice?.InvoiceNumber}",
|
||||||
|
Debit = 0,
|
||||||
|
Credit = cm.AmountApplied,
|
||||||
|
LinkController = "Invoices",
|
||||||
|
LinkId = cm.InvoiceId
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refunds re-open AR (DEBIT — customer owes again after refund)
|
||||||
|
var arRefunds = await _context.Refunds
|
||||||
|
.Include(r => r.Invoice)
|
||||||
|
.Where(r => r.RefundDate >= fromDate && r.RefundDate <= toDate && !r.IsDeleted)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var r in arRefunds)
|
||||||
|
entries.Add(new LedgerEntryDto
|
||||||
|
{
|
||||||
|
Date = r.RefundDate,
|
||||||
|
Reference = r.Reference ?? $"REF-{r.Id}",
|
||||||
|
Source = "Refund",
|
||||||
|
Description = r.Reason,
|
||||||
|
Debit = r.Amount,
|
||||||
|
Credit = 0,
|
||||||
|
LinkController = "Invoices",
|
||||||
|
LinkId = r.InvoiceId
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 9. Accounts Payable ────────────────────────────────────────────────
|
// ── 9. Accounts Payable ────────────────────────────────────────────────
|
||||||
@@ -296,6 +375,102 @@ public class LedgerService : ILedgerService
|
|||||||
LinkController = "Bills",
|
LinkController = "Bills",
|
||||||
LinkId = bp.BillId
|
LinkId = bp.BillId
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Vendor credit applications reduce AP (DEBIT — offset against what we owe)
|
||||||
|
var apVendorCredits = await _context.VendorCreditApplications
|
||||||
|
.Include(vca => vca.VendorCredit)
|
||||||
|
.Include(vca => vca.Bill)
|
||||||
|
.Where(vca => vca.VendorCredit.APAccountId == accountId
|
||||||
|
&& vca.AppliedDate >= fromDate && vca.AppliedDate <= toDate)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var vca in apVendorCredits)
|
||||||
|
entries.Add(new LedgerEntryDto
|
||||||
|
{
|
||||||
|
Date = vca.AppliedDate,
|
||||||
|
Reference = vca.VendorCredit?.CreditNumber ?? $"VC-{vca.VendorCreditId}",
|
||||||
|
Source = "Vendor Credit",
|
||||||
|
Description = $"Credit applied to {vca.Bill?.BillNumber}",
|
||||||
|
Debit = vca.Amount,
|
||||||
|
Credit = 0,
|
||||||
|
LinkController = "VendorCredits",
|
||||||
|
LinkId = vca.VendorCreditId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 11. Gift Certificate Liability (account 2500) ─────────────────────
|
||||||
|
// CR when GC is issued; DR when redeemed or voided with remaining balance.
|
||||||
|
if (account.AccountNumber == "2500")
|
||||||
|
{
|
||||||
|
var gcIssued = await _context.GiftCertificates
|
||||||
|
.Where(gc => !gc.IsDeleted && gc.IssueDate >= fromDate && gc.IssueDate <= toDate)
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var gc in gcIssued)
|
||||||
|
entries.Add(new LedgerEntryDto
|
||||||
|
{
|
||||||
|
Date = gc.IssueDate, Reference = gc.CertificateCode,
|
||||||
|
Source = "Gift Certificate", Description = "GC issued",
|
||||||
|
Debit = 0, Credit = gc.OriginalAmount,
|
||||||
|
LinkController = "GiftCertificates", LinkId = gc.Id
|
||||||
|
});
|
||||||
|
|
||||||
|
var gcRedemptions = await _context.GiftCertificateRedemptions
|
||||||
|
.Include(r => r.GiftCertificate)
|
||||||
|
.Where(r => !r.IsDeleted && r.RedeemedDate >= fromDate && r.RedeemedDate <= toDate)
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var r in gcRedemptions)
|
||||||
|
entries.Add(new LedgerEntryDto
|
||||||
|
{
|
||||||
|
Date = r.RedeemedDate, Reference = r.GiftCertificate?.CertificateCode ?? $"GC-{r.GiftCertificateId}",
|
||||||
|
Source = "GC Redemption", Description = "GC applied to invoice",
|
||||||
|
Debit = r.AmountRedeemed, Credit = 0,
|
||||||
|
LinkController = "GiftCertificates", LinkId = r.GiftCertificateId
|
||||||
|
});
|
||||||
|
|
||||||
|
var gcVoided = await _context.GiftCertificates
|
||||||
|
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||||
|
&& gc.UpdatedAt >= fromDate && gc.UpdatedAt <= toDate
|
||||||
|
&& gc.OriginalAmount > gc.RedeemedAmount)
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var gc in gcVoided)
|
||||||
|
entries.Add(new LedgerEntryDto
|
||||||
|
{
|
||||||
|
Date = gc.UpdatedAt.GetValueOrDefault(), Reference = gc.CertificateCode,
|
||||||
|
Source = "GC Voided", Description = "Breakage income",
|
||||||
|
Debit = gc.OriginalAmount - gc.RedeemedAmount, Credit = 0,
|
||||||
|
LinkController = "GiftCertificates", LinkId = gc.Id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 12. Customer Deposits liability (account 2300) ────────────────────
|
||||||
|
// CR when deposit is recorded; DR when deposit is applied to an invoice.
|
||||||
|
if (account.AccountNumber == "2300")
|
||||||
|
{
|
||||||
|
var depositsRecorded = await _context.Deposits
|
||||||
|
.Where(d => !d.IsDeleted && d.ReceivedDate >= fromDate && d.ReceivedDate <= toDate)
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var d in depositsRecorded)
|
||||||
|
entries.Add(new LedgerEntryDto
|
||||||
|
{
|
||||||
|
Date = d.ReceivedDate, Reference = d.ReceiptNumber,
|
||||||
|
Source = "Customer Deposit", Description = d.Notes ?? d.Reference,
|
||||||
|
Debit = 0, Credit = d.Amount,
|
||||||
|
LinkController = "Jobs", LinkId = d.JobId
|
||||||
|
});
|
||||||
|
|
||||||
|
var depositsApplied = await _context.Deposits
|
||||||
|
.Include(d => d.AppliedToInvoice)
|
||||||
|
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null
|
||||||
|
&& d.AppliedDate >= fromDate && d.AppliedDate <= toDate)
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var d in depositsApplied)
|
||||||
|
entries.Add(new LedgerEntryDto
|
||||||
|
{
|
||||||
|
Date = d.AppliedDate!.Value, Reference = d.AppliedToInvoice?.InvoiceNumber ?? d.ReceiptNumber,
|
||||||
|
Source = "Deposit Applied", Description = $"Deposit {d.ReceiptNumber} applied to invoice",
|
||||||
|
Debit = d.Amount, Credit = 0,
|
||||||
|
LinkController = "Invoices", LinkId = d.AppliedToInvoiceId
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 10. Journal Entry lines touching this account ──────────────────
|
// ── 10. Journal Entry lines touching this account ──────────────────
|
||||||
@@ -382,6 +557,16 @@ public class LedgerService : ILedgerService
|
|||||||
.Where(p => p.DepositAccountId == accountId && p.PaymentDate < beforeDate)
|
.Where(p => p.DepositAccountId == accountId && p.PaymentDate < beforeDate)
|
||||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||||
|
|
||||||
|
// Customer deposits recorded to this account (DEBIT — cash received at deposit time)
|
||||||
|
debits += await _context.Deposits
|
||||||
|
.Where(d => !d.IsDeleted && d.DepositAccountId == accountId && d.ReceivedDate < beforeDate)
|
||||||
|
.SumAsync(d => (decimal?)d.Amount) ?? 0;
|
||||||
|
|
||||||
|
// Refunds paid FROM this account (CREDIT — cash leaves)
|
||||||
|
credits += await _context.Refunds
|
||||||
|
.Where(r => !r.IsDeleted && r.DepositAccountId == accountId && r.RefundDate < beforeDate)
|
||||||
|
.SumAsync(r => (decimal?)r.Amount) ?? 0;
|
||||||
|
|
||||||
// 2. Direct expenses paid FROM this account (CREDIT)
|
// 2. Direct expenses paid FROM this account (CREDIT)
|
||||||
credits += await _context.Expenses
|
credits += await _context.Expenses
|
||||||
.Where(e => e.PaymentAccountId == accountId && e.Date < beforeDate)
|
.Where(e => e.PaymentAccountId == accountId && e.Date < beforeDate)
|
||||||
@@ -434,6 +619,14 @@ public class LedgerService : ILedgerService
|
|||||||
credits += await _context.Payments
|
credits += await _context.Payments
|
||||||
.Where(p => p.PaymentDate < beforeDate)
|
.Where(p => p.PaymentDate < beforeDate)
|
||||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||||
|
|
||||||
|
credits += await _context.CreditMemoApplications
|
||||||
|
.Where(a => a.AppliedDate < beforeDate && a.Invoice.Status != InvoiceStatus.Voided)
|
||||||
|
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
|
||||||
|
|
||||||
|
debits += await _context.Refunds
|
||||||
|
.Where(r => !r.IsDeleted && r.RefundDate < beforeDate)
|
||||||
|
.SumAsync(r => (decimal?)r.Amount) ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. Accounts Payable
|
// 9. Accounts Payable
|
||||||
@@ -449,6 +642,36 @@ public class LedgerService : ILedgerService
|
|||||||
debits += await _context.BillPayments
|
debits += await _context.BillPayments
|
||||||
.Where(bp => bp.Bill.APAccountId == accountId && bp.PaymentDate < beforeDate)
|
.Where(bp => bp.Bill.APAccountId == accountId && bp.PaymentDate < beforeDate)
|
||||||
.SumAsync(bp => (decimal?)bp.Amount) ?? 0;
|
.SumAsync(bp => (decimal?)bp.Amount) ?? 0;
|
||||||
|
|
||||||
|
debits += await _context.VendorCreditApplications
|
||||||
|
.Where(vca => vca.VendorCredit.APAccountId == accountId && vca.AppliedDate < beforeDate)
|
||||||
|
.SumAsync(vca => (decimal?)vca.Amount) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. GC Liability (account 2500)
|
||||||
|
if (account.AccountNumber == "2500")
|
||||||
|
{
|
||||||
|
credits += await _context.GiftCertificates
|
||||||
|
.Where(gc => !gc.IsDeleted && gc.IssueDate < beforeDate)
|
||||||
|
.SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0;
|
||||||
|
debits += await _context.GiftCertificateRedemptions
|
||||||
|
.Where(r => !r.IsDeleted && r.RedeemedDate < beforeDate)
|
||||||
|
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0;
|
||||||
|
debits += await _context.GiftCertificates
|
||||||
|
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||||
|
&& gc.UpdatedAt < beforeDate && gc.OriginalAmount > gc.RedeemedAmount)
|
||||||
|
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 12. Customer Deposits liability (account 2300)
|
||||||
|
if (account.AccountNumber == "2300")
|
||||||
|
{
|
||||||
|
credits += await _context.Deposits
|
||||||
|
.Where(d => !d.IsDeleted && d.ReceivedDate < beforeDate)
|
||||||
|
.SumAsync(d => (decimal?)d.Amount) ?? 0;
|
||||||
|
debits += await _context.Deposits
|
||||||
|
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate < beforeDate)
|
||||||
|
.SumAsync(d => (decimal?)d.Amount) ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 10. Posted journal entry lines touching this account (prior to period)
|
// 10. Posted journal entry lines touching this account (prior to period)
|
||||||
|
|||||||
@@ -621,7 +621,7 @@ public class NotificationService : INotificationService
|
|||||||
/// (the <paramref name="paymentUrl"/> parameter). Without a payment URL the email is a
|
/// (the <paramref name="paymentUrl"/> parameter). Without a payment URL the email is a
|
||||||
/// standard "here is your invoice" message with no payment CTA.
|
/// standard "here is your invoice" message with no payment CTA.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null)
|
public async Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null, bool sendSms = false, string? viewUrl = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -705,6 +705,50 @@ public class NotificationService : INotificationService
|
|||||||
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.InvoiceSent,
|
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.InvoiceSent,
|
||||||
customerName, string.Join(", ", invoiceEmails), invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
|
customerName, string.Join(", ", invoiceEmails), invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SMS — only when explicitly requested by staff (sendSms=true), customer has opted in,
|
||||||
|
// and the company's SMS is active. Uses viewUrl (permanent) so customer can see the full
|
||||||
|
// invoice; paymentUrl (expiring Stripe link) is surfaced on the view page itself.
|
||||||
|
if (sendSms)
|
||||||
|
{
|
||||||
|
var smsAllowed = await IsSmsAllowedForCompanyAsync(company);
|
||||||
|
var smsPhone = customer.MobilePhone ?? customer.Phone;
|
||||||
|
if (smsAllowed && customer.NotifyBySms && !string.IsNullOrWhiteSpace(smsPhone))
|
||||||
|
{
|
||||||
|
var urlForSms = viewUrl ?? paymentUrl ?? string.Empty;
|
||||||
|
var values = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["companyName"] = companyName,
|
||||||
|
["invoiceNumber"] = invoice.InvoiceNumber,
|
||||||
|
["invoiceTotal"] = invoice.Total.ToString("C"),
|
||||||
|
["viewUrl"] = urlForSms
|
||||||
|
};
|
||||||
|
|
||||||
|
var message = await GetRenderedSmsAsync(invoice.CompanyId, NotificationType.InvoiceSent, values,
|
||||||
|
$"{companyName}: Invoice {invoice.InvoiceNumber} for {invoice.Total:C} is ready. View your invoice: {urlForSms} Reply STOP to opt out.");
|
||||||
|
var (smsSent, smsError) = await _smsService.SendSmsAsync(smsPhone, message);
|
||||||
|
|
||||||
|
await WriteLog(new NotificationLog
|
||||||
|
{
|
||||||
|
Channel = NotificationChannel.Sms,
|
||||||
|
NotificationType = NotificationType.InvoiceSent,
|
||||||
|
Status = smsSent ? NotificationStatus.Sent : NotificationStatus.Failed,
|
||||||
|
RecipientName = customerName,
|
||||||
|
Recipient = smsPhone,
|
||||||
|
Message = message,
|
||||||
|
ErrorMessage = smsError,
|
||||||
|
SentAt = DateTime.UtcNow,
|
||||||
|
CustomerId = customer.Id,
|
||||||
|
InvoiceId = invoice.Id,
|
||||||
|
CompanyId = invoice.CompanyId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(smsPhone))
|
||||||
|
{
|
||||||
|
await WriteLog(SkippedLog(NotificationChannel.Sms, NotificationType.InvoiceSent,
|
||||||
|
customerName, smsPhone, invoice.CompanyId, customerId: customer.Id, invoiceId: invoice.Id));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -1153,6 +1197,10 @@ public class NotificationService : INotificationService
|
|||||||
"Invoice {{invoiceNumber}} from {{companyName}}",
|
"Invoice {{invoiceNumber}} from {{companyName}}",
|
||||||
"<p>Dear {{customerName}},</p><p>Please find your invoice <strong>{{invoiceNumber}}</strong> for <strong>{{invoiceTotal}}</strong> attached.{{invoiceDueDate}}</p><p>Thank you for your business with {{companyName}}.</p>"
|
"<p>Dear {{customerName}},</p><p>Please find your invoice <strong>{{invoiceNumber}}</strong> for <strong>{{invoiceTotal}}</strong> attached.{{invoiceDueDate}}</p><p>Thank you for your business with {{companyName}}.</p>"
|
||||||
),
|
),
|
||||||
|
[(NotificationType.InvoiceSent, NotificationChannel.Sms)] = (
|
||||||
|
null,
|
||||||
|
"{{companyName}}: Invoice {{invoiceNumber}} for {{invoiceTotal}} is ready. View your invoice: {{viewUrl}} Reply STOP to opt out."
|
||||||
|
),
|
||||||
[(NotificationType.PaymentReceived, NotificationChannel.Email)] = (
|
[(NotificationType.PaymentReceived, NotificationChannel.Email)] = (
|
||||||
"Payment Received — Invoice {{invoiceNumber}}",
|
"Payment Received — Invoice {{invoiceNumber}}",
|
||||||
"<p>Dear {{customerName}},</p><p>We have received your payment of <strong>{{paymentAmount}}</strong> on {{paymentDate}} for invoice <strong>{{invoiceNumber}}</strong>.{{balanceDue}}</p><p>Thank you for your business with {{companyName}}.</p>"
|
"<p>Dear {{customerName}},</p><p>We have received your payment of <strong>{{paymentAmount}}</strong> on {{paymentDate}} for invoice <strong>{{invoiceNumber}}</strong>.{{balanceDue}}</p><p>Thank you for your business with {{companyName}}.</p>"
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ public partial class SeedDataService
|
|||||||
new Account { AccountNumber = "4100", Name = "Sandblasting Revenue", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.ServiceRevenue, IsSystem = false, IsActive = true, Description = "Revenue from sandblasting services", CompanyId = company.Id, CreatedAt = now },
|
new Account { AccountNumber = "4100", Name = "Sandblasting Revenue", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.ServiceRevenue, IsSystem = false, IsActive = true, Description = "Revenue from sandblasting services", CompanyId = company.Id, CreatedAt = now },
|
||||||
new Account { AccountNumber = "4200", Name = "Other Service Revenue", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.ServiceRevenue, IsSystem = false, IsActive = true, Description = "Revenue from other shop services", CompanyId = company.Id, CreatedAt = now },
|
new Account { AccountNumber = "4200", Name = "Other Service Revenue", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.ServiceRevenue, IsSystem = false, IsActive = true, Description = "Revenue from other shop services", CompanyId = company.Id, CreatedAt = now },
|
||||||
new Account { AccountNumber = "4900", Name = "Other Income", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.OtherIncome, IsSystem = false, IsActive = true, Description = "Miscellaneous income", CompanyId = company.Id, CreatedAt = now },
|
new Account { AccountNumber = "4900", Name = "Other Income", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.OtherIncome, IsSystem = false, IsActive = true, Description = "Miscellaneous income", CompanyId = company.Id, CreatedAt = now },
|
||||||
|
// Contra-revenue: debited when invoice discounts are applied so the GL balances.
|
||||||
|
// A credit-normal account with a debit balance appears in the Trial Balance debit column,
|
||||||
|
// reducing net revenue to match the discounted AR amount that was posted.
|
||||||
|
new Account { AccountNumber = "4950", Name = "Sales Discounts", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.OtherIncome, IsSystem = true, IsActive = true, Description = "Contra-revenue for invoice discounts granted to customers", CompanyId = company.Id, CreatedAt = now },
|
||||||
|
|
||||||
// ── COST OF GOODS SOLD ────────────────────────────────────────────
|
// ── COST OF GOODS SOLD ────────────────────────────────────────────
|
||||||
new Account { AccountNumber = "5000", Name = "Cost of Goods Sold", AccountType = AccountType.CostOfGoods, AccountSubType = AccountSubType.CostOfGoodsSold, IsSystem = false, IsActive = true, Description = "Direct cost of services delivered", CompanyId = company.Id, CreatedAt = now },
|
new Account { AccountNumber = "5000", Name = "Cost of Goods Sold", AccountType = AccountType.CostOfGoods, AccountSubType = AccountSubType.CostOfGoodsSold, IsSystem = false, IsActive = true, Description = "Direct cost of services delivered", CompanyId = company.Id, CreatedAt = now },
|
||||||
@@ -96,4 +100,44 @@ public partial class SeedDataService
|
|||||||
|
|
||||||
return accounts.Count;
|
return accounts.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensures system accounts introduced after the initial chart-of-accounts seed exist for the
|
||||||
|
/// given company. Idempotent: each account is only inserted when absent, so this is safe to
|
||||||
|
/// call repeatedly from the "Seed Lookup Tables" flow.
|
||||||
|
/// Call this after <see cref="SeedDefaultChartOfAccountsAsync"/> so that newly onboarded
|
||||||
|
/// companies get all accounts in one pass while existing companies receive only the missing ones.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Number of accounts inserted (0 if all are already present).</returns>
|
||||||
|
private async Task<int> EnsureSystemAccountsAsync(Company company)
|
||||||
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
int added = 0;
|
||||||
|
|
||||||
|
// 4950 Sales Discounts — contra-revenue account introduced to balance the GL when
|
||||||
|
// invoice discounts are applied (DR Sales Discounts / CR Revenue gap fixed).
|
||||||
|
var has4950 = await _context.Set<Account>()
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.AnyAsync(a => a.CompanyId == company.Id && a.AccountNumber == "4950" && !a.IsDeleted);
|
||||||
|
|
||||||
|
if (!has4950)
|
||||||
|
{
|
||||||
|
_context.Set<Account>().Add(new Account
|
||||||
|
{
|
||||||
|
AccountNumber = "4950",
|
||||||
|
Name = "Sales Discounts",
|
||||||
|
AccountType = AccountType.Revenue,
|
||||||
|
AccountSubType = AccountSubType.OtherIncome,
|
||||||
|
IsSystem = true,
|
||||||
|
IsActive = true,
|
||||||
|
Description = "Contra-revenue for invoice discounts granted to customers",
|
||||||
|
CompanyId = company.Id,
|
||||||
|
CreatedAt = now
|
||||||
|
});
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return added;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -283,6 +283,14 @@ public partial class SeedDataService : ISeedDataService
|
|||||||
result.ItemsSeeded += accountsSeeded;
|
result.ItemsSeeded += accountsSeeded;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Backfill any system accounts added after the initial seed (idempotent).
|
||||||
|
var systemAccountsAdded = await EnsureSystemAccountsAsync(company);
|
||||||
|
if (systemAccountsAdded > 0)
|
||||||
|
{
|
||||||
|
details.Add($"✓ {systemAccountsAdded} missing system account(s) added");
|
||||||
|
result.ItemsSeeded += systemAccountsAdded;
|
||||||
|
}
|
||||||
|
|
||||||
result.Message = $"Lookup tables initialized for {company.CompanyName}";
|
result.Message = $"Lookup tables initialized for {company.CompanyName}";
|
||||||
result.Details = details;
|
result.Details = details;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,7 +133,6 @@ public class AccountDataExportController : Controller
|
|||||||
case "Inventory": await AddInventorySheet(package, companyId, headerColor); break;
|
case "Inventory": await AddInventorySheet(package, companyId, headerColor); break;
|
||||||
case "Equipment": await AddEquipmentSheet(package, companyId, headerColor); break;
|
case "Equipment": await AddEquipmentSheet(package, companyId, headerColor); break;
|
||||||
case "Vendors": await AddVendorsSheet(package, companyId, headerColor); break;
|
case "Vendors": await AddVendorsSheet(package, companyId, headerColor); break;
|
||||||
case "ShopWorkers": await AddShopWorkersSheet(package, companyId, headerColor); break;
|
|
||||||
case "Users": await AddUsersSheet(package, companyId, headerColor); break;
|
case "Users": await AddUsersSheet(package, companyId, headerColor); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,7 +181,6 @@ public class AccountDataExportController : Controller
|
|||||||
case "Inventory": WriteCsvEntry(zip, "Inventory.csv", await BuildInventoryCsv(companyId)); break;
|
case "Inventory": WriteCsvEntry(zip, "Inventory.csv", await BuildInventoryCsv(companyId)); break;
|
||||||
case "Equipment": WriteCsvEntry(zip, "Equipment.csv", await BuildEquipmentCsv(companyId)); break;
|
case "Equipment": WriteCsvEntry(zip, "Equipment.csv", await BuildEquipmentCsv(companyId)); break;
|
||||||
case "Vendors": WriteCsvEntry(zip, "Vendors.csv", await BuildVendorsCsv(companyId)); break;
|
case "Vendors": WriteCsvEntry(zip, "Vendors.csv", await BuildVendorsCsv(companyId)); break;
|
||||||
case "ShopWorkers": WriteCsvEntry(zip, "ShopWorkers.csv", await BuildShopWorkersCsv(companyId)); break;
|
|
||||||
case "Users": WriteCsvEntry(zip, "Users.csv", await BuildUsersCsv(companyId)); break;
|
case "Users": WriteCsvEntry(zip, "Users.csv", await BuildUsersCsv(companyId)); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,12 +266,6 @@ public class AccountDataExportController : Controller
|
|||||||
.Where(s => s.CompanyId == companyId && !s.IsDeleted)
|
.Where(s => s.CompanyId == companyId && !s.IsDeleted)
|
||||||
.OrderBy(s => s.CompanyName).ToListAsync();
|
.OrderBy(s => s.CompanyName).ToListAsync();
|
||||||
|
|
||||||
/// <summary>Fetches all non-deleted shop workers for the company.</summary>
|
|
||||||
private Task<List<ShopWorker>> FetchShopWorkersAsync(int companyId) =>
|
|
||||||
_db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
|
|
||||||
.Where(w => w.CompanyId == companyId && !w.IsDeleted)
|
|
||||||
.OrderBy(w => w.Name).ToListAsync();
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fetches all users for the company. <c>IsDeleted</c> is intentionally omitted because
|
/// Fetches all users for the company. <c>IsDeleted</c> is intentionally omitted because
|
||||||
/// Identity users use <c>IsActive = false</c> for soft-deletion, not the base-entity flag.
|
/// Identity users use <c>IsActive = false</c> for soft-deletion, not the base-entity flag.
|
||||||
@@ -462,23 +454,6 @@ public class AccountDataExportController : Controller
|
|||||||
AutoFit(ws, headers.Length);
|
AutoFit(ws, headers.Length);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AddShopWorkersSheet(ExcelPackage pkg, int companyId, Color hdr)
|
|
||||||
{
|
|
||||||
var data = await FetchShopWorkersAsync(companyId);
|
|
||||||
var ws = pkg.Workbook.Worksheets.Add("Shop Workers");
|
|
||||||
var headers = new[] { "ID", "Name", "Role", "Phone", "Email", "Active", "Notes" };
|
|
||||||
WriteHeader(ws, headers, hdr);
|
|
||||||
for (int i = 0; i < data.Count; i++)
|
|
||||||
{
|
|
||||||
var r = i + 2; var w = data[i];
|
|
||||||
ws.Cells[r, 1].Value = w.Id; ws.Cells[r, 2].Value = w.Name;
|
|
||||||
ws.Cells[r, 3].Value = w.Role.ToString(); ws.Cells[r, 4].Value = w.Phone;
|
|
||||||
ws.Cells[r, 5].Value = w.Email; ws.Cells[r, 6].Value = w.IsActive ? "Yes" : "No";
|
|
||||||
ws.Cells[r, 7].Value = w.Notes;
|
|
||||||
}
|
|
||||||
AutoFit(ws, headers.Length);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adds a "Users" worksheet. All users (active and inactive) are included because Identity
|
/// Adds a "Users" worksheet. All users (active and inactive) are included because Identity
|
||||||
/// uses <c>IsActive = false</c> for soft-deletion; <c>IsDeleted</c> is not applicable here.
|
/// uses <c>IsActive = false</c> for soft-deletion; <c>IsDeleted</c> is not applicable here.
|
||||||
@@ -611,17 +586,6 @@ public class AccountDataExportController : Controller
|
|||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Column names match <c>ShopWorkerImportDto</c> exactly so the file can be re-imported.</summary>
|
|
||||||
private async Task<string> BuildShopWorkersCsv(int companyId)
|
|
||||||
{
|
|
||||||
var data = await FetchShopWorkersAsync(companyId);
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
sb.AppendLine("Name,Role,Phone,Email,IsActive,Notes");
|
|
||||||
foreach (var w in data)
|
|
||||||
sb.AppendLine($"{CsvEscape(w.Name)},{w.Role},{CsvEscape(w.Phone)},{CsvEscape(w.Email)},{w.IsActive.ToString().ToLower()},{CsvEscape(w.Notes)}");
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// All users (active and inactive) are exported for completeness and compliance — mirrors
|
/// All users (active and inactive) are exported for completeness and compliance — mirrors
|
||||||
/// the reasoning in <see cref="AddUsersSheet"/> and <see cref="FetchUsersAsync"/>.
|
/// the reasoning in <see cref="AddUsersSheet"/> and <see cref="FetchUsersAsync"/>.
|
||||||
@@ -675,13 +639,13 @@ public class AccountDataExportController : Controller
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the subset of selected sheet names reordered into the canonical export sequence
|
/// Returns the subset of selected sheet names reordered into the canonical export sequence
|
||||||
/// (Customers → Jobs → Quotes → Invoices → Inventory → Equipment → Vendors → ShopWorkers → Users).
|
/// (Customers → Jobs → Quotes → Invoices → Inventory → Equipment → Vendors → Users).
|
||||||
/// Guarantees consistent file layout regardless of the order check-boxes were ticked on the form.
|
/// Guarantees consistent file layout regardless of the order check-boxes were ticked on the form.
|
||||||
/// Sheet names not in the canonical list are silently dropped.
|
/// Sheet names not in the canonical list are silently dropped.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string[] OrderSheets(string[] sheets)
|
private static string[] OrderSheets(string[] sheets)
|
||||||
{
|
{
|
||||||
var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "ShopWorkers", "Users" };
|
var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "Users" };
|
||||||
return order.Where(sheets.Contains).ToArray();
|
return order.Where(sheets.Contains).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,15 +66,16 @@ public class CompaniesController : Controller
|
|||||||
string sortColumn = "CompanyName",
|
string sortColumn = "CompanyName",
|
||||||
string sortDirection = "asc",
|
string sortDirection = "asc",
|
||||||
int pageNumber = 1,
|
int pageNumber = 1,
|
||||||
int pageSize = 25)
|
int pageSize = 25,
|
||||||
|
bool showChurned = false)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
pageNumber = Math.Max(1, pageNumber);
|
pageNumber = Math.Max(1, pageNumber);
|
||||||
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
|
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
|
||||||
|
|
||||||
var (companies, totalCount) = await _companyList.GetPagedAsync(
|
var (companies, totalCount, churnedCount) = await _companyList.GetPagedAsync(
|
||||||
searchTerm, sortColumn, sortDirection, pageNumber, pageSize);
|
searchTerm, sortColumn, sortDirection, pageNumber, pageSize, hideChurned: !showChurned);
|
||||||
|
|
||||||
var companyDtos = _mapper.Map<List<CompanyListDto>>(companies);
|
var companyDtos = _mapper.Map<List<CompanyListDto>>(companies);
|
||||||
|
|
||||||
@@ -82,19 +83,38 @@ public class CompaniesController : Controller
|
|||||||
{
|
{
|
||||||
var ids = companyDtos.Select(c => c.Id).ToList();
|
var ids = companyDtos.Select(c => c.Id).ToList();
|
||||||
var summary = await _companyList.GetCountSummaryAsync(ids);
|
var summary = await _companyList.GetCountSummaryAsync(ids);
|
||||||
|
var companyById = companies.ToDictionary(c => c.Id);
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
foreach (var dto in companyDtos)
|
foreach (var dto in companyDtos)
|
||||||
{
|
{
|
||||||
dto.JobCount = summary.JobCounts.GetValueOrDefault(dto.Id, 0);
|
dto.JobCount = summary.JobCounts.GetValueOrDefault(dto.Id, 0);
|
||||||
dto.QuoteCount = summary.QuoteCounts.GetValueOrDefault(dto.Id, 0);
|
dto.QuoteCount = summary.QuoteCounts.GetValueOrDefault(dto.Id, 0);
|
||||||
dto.CustomerCount = summary.CustomerCounts.GetValueOrDefault(dto.Id, 0);
|
dto.CustomerCount = summary.CustomerCounts.GetValueOrDefault(dto.Id, 0);
|
||||||
|
|
||||||
if (summary.WizardInfo.TryGetValue(dto.Id, out var w))
|
if (summary.WizardInfo.TryGetValue(dto.Id, out var w))
|
||||||
{
|
{
|
||||||
dto.WizardCompleted = true;
|
dto.WizardCompleted = true;
|
||||||
dto.WizardCompletedAt = w.CompletedAt;
|
dto.WizardCompletedAt = w.CompletedAt;
|
||||||
dto.WizardCompletedByName = w.CompletedByName;
|
dto.WizardCompletedByName = w.CompletedByName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Health badge
|
||||||
|
var lastLogin = summary.LastLoginDates.TryGetValue(dto.Id, out var ll) ? ll : null;
|
||||||
|
var daysSince = lastLogin.HasValue ? (int)(now - lastLogin.Value).TotalDays : -1;
|
||||||
|
var j30 = summary.Jobs30Counts.GetValueOrDefault(dto.Id, 0);
|
||||||
|
var j90 = summary.Jobs90Counts.GetValueOrDefault(dto.Id, 0);
|
||||||
|
|
||||||
|
if (companyById.TryGetValue(dto.Id, out var co))
|
||||||
|
{
|
||||||
|
var (score, _) = CompanyHealthHelper.ComputeHealth(co, daysSince, j30, j90, dto.JobCount, now);
|
||||||
|
var neverActivated = dto.JobCount == 0 && dto.CustomerCount == 0 && dto.QuoteCount == 0
|
||||||
|
&& dto.CreatedAt < now.AddDays(-7);
|
||||||
|
dto.HealthScore = score;
|
||||||
|
dto.HealthRisk = CompanyHealthHelper.ToRiskLevel(score, neverActivated).ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
dto.LastLoginDate = lastLogin;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,6 +129,8 @@ public class CompaniesController : Controller
|
|||||||
ViewBag.PageSize = pageSize;
|
ViewBag.PageSize = pageSize;
|
||||||
ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
||||||
ViewBag.ImpersonatingCompanyId = HttpContext.Session.GetInt32("ImpersonatingCompanyId");
|
ViewBag.ImpersonatingCompanyId = HttpContext.Session.GetInt32("ImpersonatingCompanyId");
|
||||||
|
ViewBag.ShowChurned = showChurned;
|
||||||
|
ViewBag.ChurnedCount = churnedCount;
|
||||||
|
|
||||||
return View(companyDtos);
|
return View(companyDtos);
|
||||||
}
|
}
|
||||||
@@ -183,7 +205,8 @@ public class CompaniesController : Controller
|
|||||||
.GetByIdAsync(id, ignoreQueryFilters: true,
|
.GetByIdAsync(id, ignoreQueryFilters: true,
|
||||||
c => c.Users,
|
c => c.Users,
|
||||||
c => c.Customers,
|
c => c.Customers,
|
||||||
c => c.Jobs);
|
c => c.Jobs,
|
||||||
|
c => c.Preferences!);
|
||||||
|
|
||||||
if (company == null)
|
if (company == null)
|
||||||
{
|
{
|
||||||
@@ -196,6 +219,51 @@ public class CompaniesController : Controller
|
|||||||
ViewBag.PlanConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
|
ViewBag.PlanConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
|
||||||
c => c.IsActive, ignoreQueryFilters: true)).OrderBy(c => c.SortOrder).ToList();
|
c => c.IsActive, ignoreQueryFilters: true)).OrderBy(c => c.SortOrder).ToList();
|
||||||
|
|
||||||
|
// Health data
|
||||||
|
var summary = await _companyList.GetCountSummaryAsync(new[] { id });
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var lastLogin = summary.LastLoginDates.TryGetValue(id, out var ll) ? ll : null;
|
||||||
|
var daysSince = lastLogin.HasValue ? (int)(now - lastLogin.Value).TotalDays : -1;
|
||||||
|
var j30 = summary.Jobs30Counts.GetValueOrDefault(id, 0);
|
||||||
|
var j90 = summary.Jobs90Counts.GetValueOrDefault(id, 0);
|
||||||
|
var totalJobs = companyDto.JobCount;
|
||||||
|
var totalCust = companyDto.CustomerCount;
|
||||||
|
var totalQuotes = summary.QuoteCounts.GetValueOrDefault(id, 0);
|
||||||
|
|
||||||
|
var (healthScore, healthSignals) = CompanyHealthHelper.ComputeHealth(company, daysSince, j30, j90, totalJobs, now);
|
||||||
|
var neverActivated = totalJobs == 0 && totalCust == 0 && totalQuotes == 0
|
||||||
|
&& company.CreatedAt < now.AddDays(-7);
|
||||||
|
var riskLevel = CompanyHealthHelper.ToRiskLevel(healthScore, neverActivated);
|
||||||
|
|
||||||
|
ViewBag.HealthScore = healthScore;
|
||||||
|
ViewBag.HealthRisk = riskLevel.ToString();
|
||||||
|
ViewBag.HealthSignals = healthSignals;
|
||||||
|
ViewBag.Jobs30 = j30;
|
||||||
|
ViewBag.Jobs90 = j90;
|
||||||
|
ViewBag.LastLoginDate = lastLogin;
|
||||||
|
|
||||||
|
// Onboarding data (from Preferences)
|
||||||
|
var prefs = company.Preferences;
|
||||||
|
int steps = 0;
|
||||||
|
if (prefs?.FirstJobCreatedAt.HasValue == true || prefs?.FirstQuoteCreatedAt.HasValue == true) steps++;
|
||||||
|
if (prefs?.FirstInvoiceCreatedAt.HasValue == true) steps++;
|
||||||
|
if (prefs?.FirstWorkflowCompletedAt.HasValue == true) steps++;
|
||||||
|
|
||||||
|
ViewBag.Onboarding = new PowderCoating.Web.ViewModels.Platform.OnboardingProgressRowViewModel
|
||||||
|
{
|
||||||
|
CompanyId = company.Id,
|
||||||
|
CompanyName = company.CompanyName ?? "",
|
||||||
|
WizardCompleted = prefs?.SetupWizardCompleted ?? false,
|
||||||
|
OnboardingPath = prefs?.OnboardingPath,
|
||||||
|
StepsCompleted = steps,
|
||||||
|
TotalSteps = 3,
|
||||||
|
FirstJobCreatedAt = prefs?.FirstJobCreatedAt,
|
||||||
|
FirstQuoteCreatedAt = prefs?.FirstQuoteCreatedAt,
|
||||||
|
FirstInvoiceCreatedAt = prefs?.FirstInvoiceCreatedAt,
|
||||||
|
FirstWorkflowCompletedAt = prefs?.FirstWorkflowCompletedAt,
|
||||||
|
GuidedActivationDismissedAt = prefs?.GuidedActivationDismissedAt,
|
||||||
|
};
|
||||||
|
|
||||||
return View(companyDto);
|
return View(companyDto);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -45,18 +45,30 @@ public class CompanyHealthController : Controller
|
|||||||
/// user's risk/search filters, so the KPI cards always show platform-wide totals.
|
/// user's risk/search filters, so the KPI cards always show platform-wide totals.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<IActionResult> Index(string? risk, string? search, bool configIssuesOnly = false)
|
public async Task<IActionResult> Index(string? risk, string? search, bool configIssuesOnly = false, bool showChurned = false)
|
||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var d30 = now.AddDays(-30);
|
var d30 = now.AddDays(-30);
|
||||||
var d90 = now.AddDays(-90);
|
var d90 = now.AddDays(-90);
|
||||||
|
var churnedCutoff = now.AddDays(-14);
|
||||||
|
|
||||||
// One query per signal — all keyed by CompanyId
|
// One query per signal — all keyed by CompanyId
|
||||||
var companies = await _db.Companies
|
var allCompanies = await _db.Companies
|
||||||
.AsNoTracking().IgnoreQueryFilters()
|
.AsNoTracking().IgnoreQueryFilters()
|
||||||
.Where(c => !c.IsDeleted)
|
.Where(c => !c.IsDeleted)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
var churnedCount = allCompanies.Count(c =>
|
||||||
|
(c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled)
|
||||||
|
&& c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value < churnedCutoff);
|
||||||
|
|
||||||
|
var companies = showChurned
|
||||||
|
? allCompanies
|
||||||
|
: allCompanies.Where(c =>
|
||||||
|
!((c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled)
|
||||||
|
&& c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value < churnedCutoff))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
var lastLogins = await _db.Users
|
var lastLogins = await _db.Users
|
||||||
.AsNoTracking().IgnoreQueryFilters()
|
.AsNoTracking().IgnoreQueryFilters()
|
||||||
.Where(u => u.LastLoginDate != null)
|
.Where(u => u.LastLoginDate != null)
|
||||||
@@ -118,15 +130,12 @@ public class CompanyHealthController : Controller
|
|||||||
var tquotes = totalQuotes.TryGetValue(c.Id, out var tq) ? tq : 0;
|
var tquotes = totalQuotes.TryGetValue(c.Id, out var tq) ? tq : 0;
|
||||||
var planName = planNames.TryGetValue(c.SubscriptionPlan, out var pn) ? pn : c.SubscriptionPlan.ToString();
|
var planName = planNames.TryGetValue(c.SubscriptionPlan, out var pn) ? pn : c.SubscriptionPlan.ToString();
|
||||||
|
|
||||||
var (score, signals) = ComputeHealth(c, daysSince, j30v, j90v, tjobs, now);
|
var (score, signals) = CompanyHealthHelper.ComputeHealth(c, daysSince, j30v, j90v, tjobs, now);
|
||||||
|
|
||||||
var neverActivated = tjobs == 0 && tcust == 0 && tquotes == 0
|
var neverActivated = tjobs == 0 && tcust == 0 && tquotes == 0
|
||||||
&& c.CreatedAt < now.AddDays(-7);
|
&& c.CreatedAt < now.AddDays(-7);
|
||||||
|
|
||||||
var riskLevel = neverActivated ? ChurnRisk.NeverActivated
|
var riskLevel = CompanyHealthHelper.ToRiskLevel(score, neverActivated);
|
||||||
: score >= 75 ? ChurnRisk.Healthy
|
|
||||||
: score >= 45 ? ChurnRisk.AtRisk
|
|
||||||
: ChurnRisk.Critical;
|
|
||||||
|
|
||||||
var configHealth = configHealthMap.TryGetValue(c.Id, out var ch)
|
var configHealth = configHealthMap.TryGetValue(c.Id, out var ch)
|
||||||
? ch : new CompanyConfigHealth { CompanyId = c.Id };
|
? ch : new CompanyConfigHealth { CompanyId = c.Id };
|
||||||
@@ -166,6 +175,8 @@ public class CompanyHealthController : Controller
|
|||||||
ViewBag.Risk = risk;
|
ViewBag.Risk = risk;
|
||||||
ViewBag.Search = search;
|
ViewBag.Search = search;
|
||||||
ViewBag.ConfigIssuesOnly = configIssuesOnly;
|
ViewBag.ConfigIssuesOnly = configIssuesOnly;
|
||||||
|
ViewBag.ShowChurned = showChurned;
|
||||||
|
ViewBag.ChurnedCount = churnedCount;
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(search))
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
all = all.Where(h =>
|
all = all.Where(h =>
|
||||||
@@ -187,112 +198,10 @@ public class CompanyHealthController : Controller
|
|||||||
return View(all);
|
return View(all);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Health score algorithm ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Computes a 0–100 health score and a list of human-readable risk signals for a
|
|
||||||
/// single company based on its subscription status, login recency, and job activity.
|
|
||||||
/// <para>
|
|
||||||
/// Scoring rules (penalties are cumulative, floor is 0):
|
|
||||||
/// <list type="bullet">
|
|
||||||
/// <item>Disabled account: score immediately set to 0, no further evaluation.</item>
|
|
||||||
/// <item>Subscription expired past the grace period: −50 pts.</item>
|
|
||||||
/// <item>Subscription within grace period: −30 pts.</item>
|
|
||||||
/// <item>Subscription expiring within 7 days: −20 pts; within 14 days: −10 pts.</item>
|
|
||||||
/// <item>Comped companies skip subscription checks entirely.</item>
|
|
||||||
/// <item>Never logged in: −30 pts; no login in 90+ days: −30; 60+d: −20; 30+d: −10.</item>
|
|
||||||
/// <item>No jobs ever: −20 pts; no jobs in last 90 days: −10; no jobs in 30d: −5.</item>
|
|
||||||
/// </list>
|
|
||||||
/// A <c>daysSinceLogin</c> value of −1 means "never logged in" and is distinct
|
|
||||||
/// from "logged in exactly 0 days ago" (i.e. today).
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
private static (int score, List<string> signals) ComputeHealth(
|
|
||||||
PowderCoating.Core.Entities.Company c, int daysSinceLogin,
|
|
||||||
int j30, int j90, int totalJobs, DateTime now)
|
|
||||||
{
|
|
||||||
var score = 100;
|
|
||||||
var signals = new List<string>();
|
|
||||||
|
|
||||||
if (!c.IsActive)
|
|
||||||
{
|
|
||||||
signals.Add("Account disabled");
|
|
||||||
return (0, signals);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscription health (skip for comped)
|
|
||||||
if (!c.IsComped && c.SubscriptionEndDate.HasValue)
|
|
||||||
{
|
|
||||||
var daysUntil = (int)(c.SubscriptionEndDate.Value.Date - now.Date).TotalDays;
|
|
||||||
if (daysUntil < -AppConstants.SubscriptionConstants.GracePeriodDays)
|
|
||||||
{
|
|
||||||
score -= 50;
|
|
||||||
signals.Add("Subscription expired");
|
|
||||||
}
|
|
||||||
else if (daysUntil < 0)
|
|
||||||
{
|
|
||||||
score -= 30;
|
|
||||||
signals.Add("In grace period");
|
|
||||||
}
|
|
||||||
else if (daysUntil <= 7)
|
|
||||||
{
|
|
||||||
score -= 20;
|
|
||||||
signals.Add($"Expires in {daysUntil}d");
|
|
||||||
}
|
|
||||||
else if (daysUntil <= 14)
|
|
||||||
{
|
|
||||||
score -= 10;
|
|
||||||
signals.Add($"Expires in {daysUntil}d");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Login activity
|
|
||||||
if (daysSinceLogin == -1)
|
|
||||||
{
|
|
||||||
score -= 30;
|
|
||||||
signals.Add("Never logged in");
|
|
||||||
}
|
|
||||||
else if (daysSinceLogin >= 90)
|
|
||||||
{
|
|
||||||
score -= 30;
|
|
||||||
signals.Add($"No login {daysSinceLogin}d");
|
|
||||||
}
|
|
||||||
else if (daysSinceLogin >= 60)
|
|
||||||
{
|
|
||||||
score -= 20;
|
|
||||||
signals.Add($"No login {daysSinceLogin}d");
|
|
||||||
}
|
|
||||||
else if (daysSinceLogin >= 30)
|
|
||||||
{
|
|
||||||
score -= 10;
|
|
||||||
signals.Add($"No login {daysSinceLogin}d");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Job activity
|
|
||||||
if (totalJobs == 0)
|
|
||||||
{
|
|
||||||
score -= 20;
|
|
||||||
signals.Add("No jobs ever");
|
|
||||||
}
|
|
||||||
else if (j90 == 0)
|
|
||||||
{
|
|
||||||
score -= 10;
|
|
||||||
signals.Add("No jobs in 90d");
|
|
||||||
}
|
|
||||||
else if (j30 == 0)
|
|
||||||
{
|
|
||||||
score -= 5;
|
|
||||||
signals.Add("No jobs in 30d");
|
|
||||||
}
|
|
||||||
|
|
||||||
return (Math.Max(0, score), signals);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── View models ────────────────────────────────────────────────────────────────
|
// ── View models ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public enum ChurnRisk { Healthy, AtRisk, Critical, NeverActivated }
|
|
||||||
|
|
||||||
public class CompanyHealthDto
|
public class CompanyHealthDto
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
using PowderCoating.Core.Entities;
|
||||||
|
using PowderCoating.Shared.Constants;
|
||||||
|
|
||||||
|
namespace PowderCoating.Web.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Risk bucket for a tenant company, derived from its health score.</summary>
|
||||||
|
public enum ChurnRisk { Healthy, AtRisk, Critical, NeverActivated }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared health-score logic used by both <see cref="CompanyHealthController"/> (dashboard)
|
||||||
|
/// and <see cref="CompaniesController"/> (list + detail badges).
|
||||||
|
/// </summary>
|
||||||
|
public static class CompanyHealthHelper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Computes a 0–100 health score and a list of human-readable risk signals for a single
|
||||||
|
/// company based on its subscription status, login recency, and job activity.
|
||||||
|
/// See <see cref="CompanyHealthController"/> XML doc for scoring rules.
|
||||||
|
/// </summary>
|
||||||
|
public static (int Score, List<string> Signals) ComputeHealth(
|
||||||
|
Company c, int daysSinceLogin, int j30, int j90, int totalJobs, DateTime now)
|
||||||
|
{
|
||||||
|
var score = 100;
|
||||||
|
var signals = new List<string>();
|
||||||
|
|
||||||
|
if (!c.IsActive)
|
||||||
|
{
|
||||||
|
signals.Add("Account disabled");
|
||||||
|
return (0, signals);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!c.IsComped && c.SubscriptionEndDate.HasValue)
|
||||||
|
{
|
||||||
|
var daysUntil = (int)(c.SubscriptionEndDate.Value.Date - now.Date).TotalDays;
|
||||||
|
if (daysUntil < -AppConstants.SubscriptionConstants.GracePeriodDays)
|
||||||
|
{
|
||||||
|
score -= 50;
|
||||||
|
signals.Add("Subscription expired");
|
||||||
|
}
|
||||||
|
else if (daysUntil < 0)
|
||||||
|
{
|
||||||
|
score -= 30;
|
||||||
|
signals.Add("In grace period");
|
||||||
|
}
|
||||||
|
else if (daysUntil <= 7)
|
||||||
|
{
|
||||||
|
score -= 20;
|
||||||
|
signals.Add($"Expires in {daysUntil}d");
|
||||||
|
}
|
||||||
|
else if (daysUntil <= 14)
|
||||||
|
{
|
||||||
|
score -= 10;
|
||||||
|
signals.Add($"Expires in {daysUntil}d");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (daysSinceLogin == -1)
|
||||||
|
{
|
||||||
|
score -= 30;
|
||||||
|
signals.Add("Never logged in");
|
||||||
|
}
|
||||||
|
else if (daysSinceLogin >= 90)
|
||||||
|
{
|
||||||
|
score -= 30;
|
||||||
|
signals.Add($"No login {daysSinceLogin}d");
|
||||||
|
}
|
||||||
|
else if (daysSinceLogin >= 60)
|
||||||
|
{
|
||||||
|
score -= 20;
|
||||||
|
signals.Add($"No login {daysSinceLogin}d");
|
||||||
|
}
|
||||||
|
else if (daysSinceLogin >= 30)
|
||||||
|
{
|
||||||
|
score -= 10;
|
||||||
|
signals.Add($"No login {daysSinceLogin}d");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalJobs == 0)
|
||||||
|
{
|
||||||
|
score -= 20;
|
||||||
|
signals.Add("No jobs ever");
|
||||||
|
}
|
||||||
|
else if (j90 == 0)
|
||||||
|
{
|
||||||
|
score -= 10;
|
||||||
|
signals.Add("No jobs in 90d");
|
||||||
|
}
|
||||||
|
else if (j30 == 0)
|
||||||
|
{
|
||||||
|
score -= 5;
|
||||||
|
signals.Add("No jobs in 30d");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (Math.Max(0, score), signals);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Derives a <see cref="ChurnRisk"/> bucket from a pre-computed score and activity flags.
|
||||||
|
/// </summary>
|
||||||
|
public static ChurnRisk ToRiskLevel(int score, bool neverActivated) =>
|
||||||
|
neverActivated ? ChurnRisk.NeverActivated
|
||||||
|
: score >= 75 ? ChurnRisk.Healthy
|
||||||
|
: score >= 45 ? ChurnRisk.AtRisk
|
||||||
|
: ChurnRisk.Critical;
|
||||||
|
}
|
||||||
@@ -543,6 +543,15 @@ public class CompanySettingsController : Controller
|
|||||||
public Task<IActionResult> UpdateWorkOrderTemplate([FromBody] UpdateWorkOrderTemplateDto dto) =>
|
public Task<IActionResult> UpdateWorkOrderTemplate([FromBody] UpdateWorkOrderTemplateDto dto) =>
|
||||||
UpdatePreferences(dto, "Work order settings saved successfully.");
|
UpdatePreferences(dto, "Work order settings saved successfully.");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves kiosk intake output preference ("Quote" or "Job") to <see cref="CompanyPreferences"/>.
|
||||||
|
/// Delegates to <see cref="UpdatePreferences{TDto}"/>.
|
||||||
|
/// </summary>
|
||||||
|
// POST: CompanySettings/UpdateKioskSettings
|
||||||
|
[HttpPost]
|
||||||
|
public Task<IActionResult> UpdateKioskSettings([FromBody] UpdateKioskSettingsDto dto) =>
|
||||||
|
UpdatePreferences(dto, "Kiosk settings saved successfully.");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Persists the company's pricing model parameters — labor rates, sandblasting/masking multipliers,
|
/// Persists the company's pricing model parameters — labor rates, sandblasting/masking multipliers,
|
||||||
/// oven cost per hour, overhead admin/facility percentages, profit margin, and default tax rate —
|
/// oven cost per hour, overhead admin/facility percentages, profit margin, and default tax rate —
|
||||||
@@ -747,7 +756,6 @@ public class CompanySettingsController : Controller
|
|||||||
var costs = company.OperatingCosts;
|
var costs = company.OperatingCosts;
|
||||||
|
|
||||||
var ovens = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive)).OrderBy(o => o.DisplayOrder).ToList();
|
var ovens = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive)).OrderBy(o => o.DisplayOrder).ToList();
|
||||||
var workers = (await _unitOfWork.ShopWorkers.FindAsync(w => w.IsActive)).ToList();
|
|
||||||
var coatingCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsCoating)).ToList();
|
var coatingCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsCoating)).ToList();
|
||||||
|
|
||||||
var sb = new System.Text.StringBuilder();
|
var sb = new System.Text.StringBuilder();
|
||||||
@@ -774,8 +782,7 @@ public class CompanySettingsController : Controller
|
|||||||
ShopCapabilityTier.Large => "high-volume",
|
ShopCapabilityTier.Large => "high-volume",
|
||||||
_ => "small"
|
_ => "small"
|
||||||
};
|
};
|
||||||
sb.AppendLine($"We are a {tierLabel} operation" +
|
sb.AppendLine($"We are a {tierLabel} operation.");
|
||||||
(workers.Count > 0 ? $" with {workers.Count} active shop worker{(workers.Count == 1 ? "" : "s")}." : "."));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ovens
|
// Ovens
|
||||||
@@ -818,32 +825,6 @@ public class CompanySettingsController : Controller
|
|||||||
sb.AppendLine($"Powder categories we stock: {string.Join(", ", catNames)}.");
|
sb.AppendLine($"Powder categories we stock: {string.Join(", ", catNames)}.");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Worker roles
|
|
||||||
if (workers.Any())
|
|
||||||
{
|
|
||||||
var roles = workers
|
|
||||||
.Select(w => w.Role)
|
|
||||||
.Distinct()
|
|
||||||
.Select(r => r switch
|
|
||||||
{
|
|
||||||
ShopWorkerRole.Sandblaster => "sandblasting",
|
|
||||||
ShopWorkerRole.Coater => "powder coating",
|
|
||||||
ShopWorkerRole.Masker => "masking",
|
|
||||||
ShopWorkerRole.QualityControl => "quality control",
|
|
||||||
ShopWorkerRole.OvenOperator => "oven operation",
|
|
||||||
ShopWorkerRole.Supervisor => "supervision",
|
|
||||||
ShopWorkerRole.Maintenance => "equipment maintenance",
|
|
||||||
_ => "general labor"
|
|
||||||
})
|
|
||||||
.Distinct()
|
|
||||||
.ToList();
|
|
||||||
if (roles.Count > 1)
|
|
||||||
{
|
|
||||||
sb.AppendLine();
|
|
||||||
sb.AppendLine($"Staff specialties on hand: {string.Join(", ", roles)}.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rates hint
|
// Rates hint
|
||||||
if (costs != null && costs.StandardLaborRate > 0)
|
if (costs != null && costs.StandardLaborRate > 0)
|
||||||
{
|
{
|
||||||
@@ -2685,6 +2666,7 @@ public class CompanySettingsController : Controller
|
|||||||
{
|
{
|
||||||
list.Add(("{{invoiceTotal}}", "Invoice total amount (formatted as currency)"));
|
list.Add(("{{invoiceTotal}}", "Invoice total amount (formatted as currency)"));
|
||||||
list.Add(("{{invoiceDueDate}}", "Due date phrase, e.g. \" Due by January 1, 2026.\" — blank if no due date is set"));
|
list.Add(("{{invoiceDueDate}}", "Due date phrase, e.g. \" Due by January 1, 2026.\" — blank if no due date is set"));
|
||||||
|
list.Add(("{{viewUrl}}", "Permanent link for the customer to view the invoice online (used in SMS)"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type == NotificationType.PaymentReceived)
|
if (type == NotificationType.PaymentReceived)
|
||||||
@@ -2709,79 +2691,6 @@ public class CompanySettingsController : Controller
|
|||||||
|
|
||||||
// ── Role-Based Labor Rates ────────────────────────────────────────────────
|
// ── Role-Based Labor Rates ────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Returns the per-role hourly labor rates configured for the current company, keyed by
|
|
||||||
/// <see cref="PowderCoating.Core.Enums.ShopWorkerRole"/> integer value. An empty list is returned
|
|
||||||
/// (rather than a 404) when no rates have been configured yet, so the UI can render the rate grid
|
|
||||||
/// without special-casing an empty state. The global multi-tenant filter on
|
|
||||||
/// <c>ShopWorkerRoleCosts</c> ensures only this company's rates are returned.
|
|
||||||
/// </summary>
|
|
||||||
[HttpGet]
|
|
||||||
public async Task<IActionResult> GetRoleCosts()
|
|
||||||
{
|
|
||||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
||||||
if (companyId == null) return Json(new List<object>());
|
|
||||||
|
|
||||||
var rates = await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId.Value);
|
|
||||||
var result = rates.Select(r => new { role = (int)r.Role, hourlyRate = r.HourlyRate }).ToList();
|
|
||||||
return Json(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Upserts the per-role hourly labor rates for the current company. The operation handles three cases
|
|
||||||
/// per rate in a single pass: (1) rate cleared (≤ 0) — soft-delete the existing record; (2) rate set
|
|
||||||
/// but no existing record — insert new; (3) rate changed — update existing. This avoids full
|
|
||||||
/// table replace semantics that could cause audit log noise or trigger unintended EF change-tracking.
|
|
||||||
/// These rates are used by the pricing calculator when <c>UseRoleBasedLaborRates</c> is enabled in
|
|
||||||
/// <c>CompanyOperatingCosts</c>.
|
|
||||||
/// </summary>
|
|
||||||
[HttpPost]
|
|
||||||
public async Task<IActionResult> SaveRoleCosts([FromBody] List<SaveRoleCostDto> rates)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
||||||
if (companyId == null) return Json(new { success = false, message = "No company found." });
|
|
||||||
|
|
||||||
var existing = (await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId.Value)).ToList();
|
|
||||||
|
|
||||||
foreach (var dto in rates)
|
|
||||||
{
|
|
||||||
var record = existing.FirstOrDefault(r => (int)r.Role == dto.Role);
|
|
||||||
if (dto.HourlyRate <= 0)
|
|
||||||
{
|
|
||||||
// Remove rate if cleared
|
|
||||||
if (record != null)
|
|
||||||
await _unitOfWork.ShopWorkerRoleCosts.SoftDeleteAsync(record.Id);
|
|
||||||
}
|
|
||||||
else if (record == null)
|
|
||||||
{
|
|
||||||
await _unitOfWork.ShopWorkerRoleCosts.AddAsync(new PowderCoating.Core.Entities.ShopWorkerRoleCost
|
|
||||||
{
|
|
||||||
CompanyId = companyId.Value,
|
|
||||||
Role = (PowderCoating.Core.Enums.ShopWorkerRole)dto.Role,
|
|
||||||
HourlyRate = dto.HourlyRate,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
record.HourlyRate = dto.HourlyRate;
|
|
||||||
record.UpdatedAt = DateTime.UtcNow;
|
|
||||||
await _unitOfWork.ShopWorkerRoleCosts.UpdateAsync(record);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await _unitOfWork.CompleteAsync();
|
|
||||||
return Json(new { success = true });
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error saving role costs");
|
|
||||||
return Json(new { success = false, message = "An error occurred saving role rates." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Stripe Connect ───────────────────────────────────────────────────────
|
// ─── Stripe Connect ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -3045,7 +2954,6 @@ public class CompanySettingsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
public record SaveTemplateJsonRequest(int Id, string? Subject, string? Body);
|
public record SaveTemplateJsonRequest(int Id, string? Subject, string? Body);
|
||||||
public record SaveRoleCostDto(int Role, decimal HourlyRate);
|
|
||||||
public record SaveOnlinePaymentSettingsDto(
|
public record SaveOnlinePaymentSettingsDto(
|
||||||
OnlinePaymentSurchargeType SurchargeType,
|
OnlinePaymentSurchargeType SurchargeType,
|
||||||
decimal SurchargeValue,
|
decimal SurchargeValue,
|
||||||
|
|||||||
@@ -226,11 +226,9 @@ public class CompanyUsersController : Controller
|
|||||||
/// Creates a new company user, enforcing the subscription user-count limit and a whitelist
|
/// Creates a new company user, enforcing the subscription user-count limit and a whitelist
|
||||||
/// of valid <c>CompanyRole</c> values (preventing callers from submitting a null role to
|
/// of valid <c>CompanyRole</c> values (preventing callers from submitting a null role to
|
||||||
/// create a SuperAdmin-equivalent account). CompanyAdmin users automatically receive all
|
/// create a SuperAdmin-equivalent account). CompanyAdmin users automatically receive all
|
||||||
/// per-feature permissions unless a SuperAdmin is explicitly customising them. Workers
|
/// per-feature permissions unless a SuperAdmin is explicitly customising them. A legacy
|
||||||
/// additionally get an auto-created <see cref="ShopWorker"/> record so they appear in job
|
/// ASP.NET Identity role (Administrator / Manager / Employee / ReadOnly) is also assigned
|
||||||
/// assignment dropdowns without a separate onboarding step. A legacy ASP.NET Identity role
|
/// to satisfy policy checks that still reference the role system.
|
||||||
/// (Administrator / Manager / Employee / ReadOnly) is also assigned to satisfy policy
|
|
||||||
/// checks that still reference the role system.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
// POST: CompanyUsers/Create
|
// POST: CompanyUsers/Create
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
@@ -351,27 +349,7 @@ public class CompanyUsersController : Controller
|
|||||||
|
|
||||||
await _userManager.AddToRoleAsync(user, legacyRole);
|
await _userManager.AddToRoleAsync(user, legacyRole);
|
||||||
|
|
||||||
// If Worker role, automatically create a ShopWorker record
|
_logger.LogInformation("User {Email} created successfully by {Admin}",
|
||||||
if (model.CompanyRole == AppConstants.CompanyRoles.Worker)
|
|
||||||
{
|
|
||||||
var shopWorker = new ShopWorker
|
|
||||||
{
|
|
||||||
Name = user.FullName,
|
|
||||||
Email = user.Email,
|
|
||||||
Phone = user.PhoneNumber,
|
|
||||||
IsActive = true,
|
|
||||||
Notes = $"Auto-created from user account: {user.Email}",
|
|
||||||
Role = Core.Enums.ShopWorkerRole.GeneralLabor, // Default role
|
|
||||||
CompanyId = companyId!.Value
|
|
||||||
};
|
|
||||||
|
|
||||||
await _unitOfWork.ShopWorkers.AddAsync(shopWorker);
|
|
||||||
await _unitOfWork.CompleteAsync();
|
|
||||||
|
|
||||||
_logger.LogInformation("ShopWorker record created for user {Email}", user.Email);
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("User {Email} created successfully by {Admin}",
|
|
||||||
user.Email, User.Identity?.Name);
|
user.Email, User.Identity?.Name);
|
||||||
|
|
||||||
TempData["Success"] = $"User '{user.FullName}' created successfully.";
|
TempData["Success"] = $"User '{user.FullName}' created successfully.";
|
||||||
@@ -441,6 +419,7 @@ public class CompanyUsersController : Controller
|
|||||||
CompanyRole = user.CompanyRole ?? AppConstants.CompanyRoles.Viewer,
|
CompanyRole = user.CompanyRole ?? AppConstants.CompanyRoles.Viewer,
|
||||||
Department = user.Department,
|
Department = user.Department,
|
||||||
Position = user.Position,
|
Position = user.Position,
|
||||||
|
LaborCostPerHour = user.LaborCostPerHour,
|
||||||
Phone = user.PhoneNumber,
|
Phone = user.PhoneNumber,
|
||||||
IsActive = user.IsActive,
|
IsActive = user.IsActive,
|
||||||
HireDate = user.HireDate,
|
HireDate = user.HireDate,
|
||||||
@@ -479,11 +458,9 @@ public class CompanyUsersController : Controller
|
|||||||
/// Saves changes to an existing company user. Validates company isolation and role whitelist
|
/// Saves changes to an existing company user. Validates company isolation and role whitelist
|
||||||
/// (same checks as <see cref="Edit(string, string)"/>). Prevents two dangerous deactivation
|
/// (same checks as <see cref="Edit(string, string)"/>). Prevents two dangerous deactivation
|
||||||
/// scenarios: a user deactivating themselves, and deactivating the last active CompanyAdmin
|
/// scenarios: a user deactivating themselves, and deactivating the last active CompanyAdmin
|
||||||
/// for a company (which would lock out the tenant). When the role changes to Worker and no
|
/// for a company (which would lock out the tenant). Email changes are applied via
|
||||||
/// matching <see cref="ShopWorker"/> record exists, one is created automatically; if one
|
/// <c>SetEmailAsync</c> / <c>SetUserNameAsync</c> after the main update so Identity's own
|
||||||
/// already exists, its name, email, and active status are kept in sync. Email changes are
|
/// normalisation logic runs correctly.
|
||||||
/// applied via <c>SetEmailAsync</c> / <c>SetUserNameAsync</c> after the main update so
|
|
||||||
/// Identity's own normalisation logic runs correctly.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
// POST: CompanyUsers/Edit/id
|
// POST: CompanyUsers/Edit/id
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
@@ -596,6 +573,7 @@ public class CompanyUsersController : Controller
|
|||||||
user.CompanyRole = model.CompanyRole;
|
user.CompanyRole = model.CompanyRole;
|
||||||
user.Department = model.Department;
|
user.Department = model.Department;
|
||||||
user.Position = model.Position;
|
user.Position = model.Position;
|
||||||
|
user.LaborCostPerHour = model.LaborCostPerHour;
|
||||||
user.PhoneNumber = model.Phone;
|
user.PhoneNumber = model.Phone;
|
||||||
user.IsActive = model.IsActive;
|
user.IsActive = model.IsActive;
|
||||||
user.HireDate = model.HireDate;
|
user.HireDate = model.HireDate;
|
||||||
@@ -632,60 +610,7 @@ public class CompanyUsersController : Controller
|
|||||||
user.Id, oldEmail, model.Email, User.Identity?.Name);
|
user.Id, oldEmail, model.Email, User.Identity?.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If role changed to Worker, ensure ShopWorker record exists
|
_logger.LogInformation("User {Email} updated successfully by {Admin}",
|
||||||
if (model.CompanyRole == AppConstants.CompanyRoles.Worker)
|
|
||||||
{
|
|
||||||
// Search by oldEmail so we find the record even when the email just changed
|
|
||||||
var lookupEmail = emailChanged ? oldEmail : user.Email;
|
|
||||||
var existingShopWorker = (await _unitOfWork.ShopWorkers.FindAsync(
|
|
||||||
sw => sw.Email == lookupEmail && sw.CompanyId == user.CompanyId)).ToList();
|
|
||||||
|
|
||||||
if (!existingShopWorker.Any())
|
|
||||||
{
|
|
||||||
var shopWorker = new ShopWorker
|
|
||||||
{
|
|
||||||
Name = user.FullName,
|
|
||||||
Email = user.Email,
|
|
||||||
Phone = user.PhoneNumber,
|
|
||||||
IsActive = user.IsActive,
|
|
||||||
Notes = $"Auto-created from user account: {user.Email}",
|
|
||||||
Role = Core.Enums.ShopWorkerRole.GeneralLabor, // Default role
|
|
||||||
CompanyId = user.CompanyId
|
|
||||||
};
|
|
||||||
|
|
||||||
await _unitOfWork.ShopWorkers.AddAsync(shopWorker);
|
|
||||||
await _unitOfWork.CompleteAsync();
|
|
||||||
|
|
||||||
_logger.LogInformation("ShopWorker record created for user {Email}", user.Email);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// Update existing ShopWorker to ensure it's active
|
|
||||||
var shopWorker = existingShopWorker.First();
|
|
||||||
var shopWorkerDirty = false;
|
|
||||||
|
|
||||||
if (!shopWorker.IsActive && user.IsActive)
|
|
||||||
{
|
|
||||||
shopWorker.IsActive = true;
|
|
||||||
shopWorkerDirty = true;
|
|
||||||
_logger.LogInformation("ShopWorker record reactivated for user {Email}", user.Email);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (emailChanged && shopWorker.Email == oldEmail)
|
|
||||||
{
|
|
||||||
shopWorker.Email = user.Email;
|
|
||||||
shopWorkerDirty = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
shopWorker.Name = user.FullName;
|
|
||||||
shopWorker.Phone = user.PhoneNumber;
|
|
||||||
|
|
||||||
if (shopWorkerDirty)
|
|
||||||
await _unitOfWork.CompleteAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("User {Email} updated successfully by {Admin}",
|
|
||||||
user.Email, User.Identity?.Name);
|
user.Email, User.Identity?.Name);
|
||||||
|
|
||||||
TempData["Success"] = "User updated successfully.";
|
TempData["Success"] = "User updated successfully.";
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Authorization;
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
|
using PowderCoating.Application.Interfaces;
|
||||||
using PowderCoating.Core.Entities;
|
using PowderCoating.Core.Entities;
|
||||||
using PowderCoating.Core.Enums;
|
using PowderCoating.Core.Enums;
|
||||||
using PowderCoating.Core.Interfaces;
|
using PowderCoating.Core.Interfaces;
|
||||||
@@ -15,6 +16,9 @@ namespace PowderCoating.Web.Controllers;
|
|||||||
/// balance and can be issued standalone (goodwill, billing correction) or linked to an original
|
/// balance and can be issued standalone (goodwill, billing correction) or linked to an original
|
||||||
/// invoice (price dispute, rework resolution). Applied portions reduce invoice BalanceDue and
|
/// invoice (price dispute, rework resolution). Applied portions reduce invoice BalanceDue and
|
||||||
/// customer.CreditBalance atomically inside a transaction.
|
/// customer.CreditBalance atomically inside a transaction.
|
||||||
|
/// GL entries on Apply: DR 4950 Sales Discounts (contra-revenue) / CR AR — mirrors the treatment
|
||||||
|
/// of invoice discounts so the Trial Balance and Balance Sheet reflect the applied credit as both
|
||||||
|
/// a revenue deduction and an AR reduction.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||||
public class CreditMemosController : Controller
|
public class CreditMemosController : Controller
|
||||||
@@ -23,17 +27,20 @@ public class CreditMemosController : Controller
|
|||||||
private readonly ITenantContext _tenantContext;
|
private readonly ITenantContext _tenantContext;
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
private readonly ILogger<CreditMemosController> _logger;
|
private readonly ILogger<CreditMemosController> _logger;
|
||||||
|
private readonly IAccountBalanceService _accountBalanceService;
|
||||||
|
|
||||||
public CreditMemosController(
|
public CreditMemosController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
ITenantContext tenantContext,
|
ITenantContext tenantContext,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
ILogger<CreditMemosController> logger)
|
ILogger<CreditMemosController> logger,
|
||||||
|
IAccountBalanceService accountBalanceService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_tenantContext = tenantContext;
|
_tenantContext = tenantContext;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_accountBalanceService = accountBalanceService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Lists all credit memos for the current company with optional status and text filters.</summary>
|
/// <summary>Lists all credit memos for the current company with optional status and text filters.</summary>
|
||||||
@@ -245,6 +252,20 @@ public class CreditMemosController : Controller
|
|||||||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GL: DR 4950 Sales Discounts (contra-revenue) / CR AR.
|
||||||
|
// The dynamic report computation attributes credit memo applications to both
|
||||||
|
// accounts already; this call keeps Account.CurrentBalance in sync for
|
||||||
|
// RecalculateAllAsync and any tools that read it directly.
|
||||||
|
var arAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
|
a => a.AccountSubType == AccountSubType.AccountsReceivable && a.IsActive);
|
||||||
|
var discountAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
|
a => a.AccountNumber == "4950" && a.IsActive)
|
||||||
|
?? await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
|
a => a.AccountType == AccountType.Revenue && a.IsActive
|
||||||
|
&& a.Name.ToLower().Contains("discount"));
|
||||||
|
await _accountBalanceService.DebitAsync(discountAcct?.Id, applyAmount);
|
||||||
|
await _accountBalanceService.CreditAsync(arAcct?.Id, applyAmount);
|
||||||
|
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -368,6 +368,9 @@ public class DashboardController : Controller
|
|||||||
|
|
||||||
ViewBag.GuidedActivationBanner = BuildGuidedActivationBanner(companyPrefs);
|
ViewBag.GuidedActivationBanner = BuildGuidedActivationBanner(companyPrefs);
|
||||||
ViewBag.ShopProgressWidget = await BuildShopProgressWidgetAsync(currentCompanyId.Value, companyPrefs);
|
ViewBag.ShopProgressWidget = await BuildShopProgressWidgetAsync(currentCompanyId.Value, companyPrefs);
|
||||||
|
|
||||||
|
var companyForKiosk = await _unitOfWork.Companies.GetByIdAsync(currentCompanyId.Value);
|
||||||
|
ViewBag.KioskActivated = !string.IsNullOrEmpty(companyForKiosk?.KioskActivationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
return View(vm);
|
return View(vm);
|
||||||
|
|||||||
@@ -122,7 +122,6 @@ public class DataExportController : Controller
|
|||||||
case "Inventory": await AddInventorySheet(package, companyId, headerColor); break;
|
case "Inventory": await AddInventorySheet(package, companyId, headerColor); break;
|
||||||
case "Equipment": await AddEquipmentSheet(package, companyId, headerColor); break;
|
case "Equipment": await AddEquipmentSheet(package, companyId, headerColor); break;
|
||||||
case "Vendors": await AddVendorsSheet(package, companyId, headerColor); break;
|
case "Vendors": await AddVendorsSheet(package, companyId, headerColor); break;
|
||||||
case "ShopWorkers": await AddShopWorkersSheet(package, companyId, headerColor); break;
|
|
||||||
case "Users": await AddUsersSheet(package, companyId, headerColor); break;
|
case "Users": await AddUsersSheet(package, companyId, headerColor); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,7 +171,6 @@ public class DataExportController : Controller
|
|||||||
case "Inventory": WriteCsvEntry(zip, "Inventory.csv", await BuildInventoryCsv(companyId)); break;
|
case "Inventory": WriteCsvEntry(zip, "Inventory.csv", await BuildInventoryCsv(companyId)); break;
|
||||||
case "Equipment": WriteCsvEntry(zip, "Equipment.csv", await BuildEquipmentCsv(companyId)); break;
|
case "Equipment": WriteCsvEntry(zip, "Equipment.csv", await BuildEquipmentCsv(companyId)); break;
|
||||||
case "Vendors": WriteCsvEntry(zip, "Vendors.csv", await BuildVendorsCsv(companyId)); break;
|
case "Vendors": WriteCsvEntry(zip, "Vendors.csv", await BuildVendorsCsv(companyId)); break;
|
||||||
case "ShopWorkers": WriteCsvEntry(zip, "ShopWorkers.csv", await BuildShopWorkersCsv(companyId)); break;
|
|
||||||
case "Users": WriteCsvEntry(zip, "Users.csv", await BuildUsersCsv(companyId)); break;
|
case "Users": WriteCsvEntry(zip, "Users.csv", await BuildUsersCsv(companyId)); break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -441,38 +439,6 @@ public class DataExportController : Controller
|
|||||||
AutoFit(ws, headers.Length);
|
AutoFit(ws, headers.Length);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Adds a "Shop Workers" worksheet with one row per non-deleted shop worker for the
|
|
||||||
/// specified company. <c>Role.ToString()</c> converts the enum to a string; the view
|
|
||||||
/// typically formats these with spaces (e.g. "QualityControl" → "Quality Control") but the
|
|
||||||
/// raw enum name is used here so the export value is round-trip parseable.
|
|
||||||
/// </summary>
|
|
||||||
private async Task AddShopWorkersSheet(ExcelPackage pkg, int companyId, Color hdr)
|
|
||||||
{
|
|
||||||
var data = await _db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
|
|
||||||
.Where(w => w.CompanyId == companyId && !w.IsDeleted)
|
|
||||||
.OrderBy(w => w.Name)
|
|
||||||
.ToListAsync();
|
|
||||||
|
|
||||||
var ws = pkg.Workbook.Worksheets.Add("Shop Workers");
|
|
||||||
var headers = new[] { "ID", "Name", "Role", "Phone", "Email", "Active", "Notes" };
|
|
||||||
WriteHeader(ws, headers, hdr);
|
|
||||||
|
|
||||||
for (int i = 0; i < data.Count; i++)
|
|
||||||
{
|
|
||||||
var r = i + 2;
|
|
||||||
var w = data[i];
|
|
||||||
ws.Cells[r, 1].Value = w.Id;
|
|
||||||
ws.Cells[r, 2].Value = w.Name;
|
|
||||||
ws.Cells[r, 3].Value = w.Role.ToString();
|
|
||||||
ws.Cells[r, 4].Value = w.Phone;
|
|
||||||
ws.Cells[r, 5].Value = w.Email;
|
|
||||||
ws.Cells[r, 6].Value = w.IsActive ? "Yes" : "No";
|
|
||||||
ws.Cells[r, 7].Value = w.Notes;
|
|
||||||
}
|
|
||||||
AutoFit(ws, headers.Length);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adds an "Invoices" worksheet with one row per non-deleted invoice for the specified company.
|
/// Adds an "Invoices" worksheet with one row per non-deleted invoice for the specified company.
|
||||||
/// The customer navigation is eagerly loaded so the customer name can be rendered; when a
|
/// The customer navigation is eagerly loaded so the customer name can be rendered; when a
|
||||||
@@ -687,21 +653,6 @@ public class DataExportController : Controller
|
|||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Builds the shop workers CSV string for the specified company, ordered alphabetically by name.
|
|
||||||
/// Column names match <see cref="ShopWorkerImportDto"/> exactly so the file can be re-imported.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<string> BuildShopWorkersCsv(int companyId)
|
|
||||||
{
|
|
||||||
var data = await _db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
|
|
||||||
.Where(w => w.CompanyId == companyId && !w.IsDeleted).OrderBy(w => w.Name).ToListAsync();
|
|
||||||
var sb = new StringBuilder();
|
|
||||||
sb.AppendLine("Name,Role,Phone,Email,IsActive,Notes");
|
|
||||||
foreach (var w in data)
|
|
||||||
sb.AppendLine($"{CsvEscape(w.Name)},{w.Role},{CsvEscape(w.Phone)},{CsvEscape(w.Email)},{w.IsActive.ToString().ToLower()},{CsvEscape(w.Notes)}");
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds the users CSV string for the specified company, ordered by last name.
|
/// Builds the users CSV string for the specified company, ordered by last name.
|
||||||
/// Like <see cref="AddUsersSheet"/>, the <c>IsDeleted</c> filter is intentionally omitted
|
/// Like <see cref="AddUsersSheet"/>, the <c>IsDeleted</c> filter is intentionally omitted
|
||||||
@@ -761,7 +712,7 @@ public class DataExportController : Controller
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the requested sheet names sorted into the canonical export order
|
/// Returns the requested sheet names sorted into the canonical export order
|
||||||
/// (Customers → Jobs → Quotes → Invoices → Inventory → Equipment → Vendors → ShopWorkers → Users).
|
/// (Customers → Jobs → Quotes → Invoices → Inventory → Equipment → Vendors → Users).
|
||||||
/// This ensures that the workbook and ZIP archive always have a predictable, logical layout
|
/// This ensures that the workbook and ZIP archive always have a predictable, logical layout
|
||||||
/// regardless of the order the administrator checked the boxes on the form.
|
/// regardless of the order the administrator checked the boxes on the form.
|
||||||
/// Any sheet name not in the canonical list is silently ignored.
|
/// Any sheet name not in the canonical list is silently ignored.
|
||||||
@@ -769,7 +720,7 @@ public class DataExportController : Controller
|
|||||||
/// <param name="sheets">Raw sheet names from the form POST.</param>
|
/// <param name="sheets">Raw sheet names from the form POST.</param>
|
||||||
private static string[] OrderSheets(string[] sheets)
|
private static string[] OrderSheets(string[] sheets)
|
||||||
{
|
{
|
||||||
var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "ShopWorkers", "Users" };
|
var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "Users" };
|
||||||
return order.Where(sheets.Contains).ToArray();
|
return order.Where(sheets.Contains).ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -175,7 +175,6 @@ public class DataPurgeController : Controller
|
|||||||
stats.Add(await Stat("Equipment", "Equipment", "bi-tools", "Inventory & Ops", _db.Equipment.Where(e => e.IsDeleted)));
|
stats.Add(await Stat("Equipment", "Equipment", "bi-tools", "Inventory & Ops", _db.Equipment.Where(e => e.IsDeleted)));
|
||||||
stats.Add(await Stat("MaintenanceRecords","Maintenance Records", "bi-wrench", "Inventory & Ops", _db.MaintenanceRecords.Where(e => e.IsDeleted)));
|
stats.Add(await Stat("MaintenanceRecords","Maintenance Records", "bi-wrench", "Inventory & Ops", _db.MaintenanceRecords.Where(e => e.IsDeleted)));
|
||||||
stats.Add(await Stat("Vendors", "Vendors", "bi-truck", "Inventory & Ops", _db.Vendors.Where(e => e.IsDeleted)));
|
stats.Add(await Stat("Vendors", "Vendors", "bi-truck", "Inventory & Ops", _db.Vendors.Where(e => e.IsDeleted)));
|
||||||
stats.Add(await Stat("ShopWorkers", "Shop Workers", "bi-person-badge","Inventory & Ops", _db.ShopWorkers.Where(e => e.IsDeleted)));
|
|
||||||
|
|
||||||
return stats;
|
return stats;
|
||||||
}
|
}
|
||||||
@@ -204,7 +203,6 @@ public class DataPurgeController : Controller
|
|||||||
"Equipment" => await QueryCount(_db.Equipment, cutoff),
|
"Equipment" => await QueryCount(_db.Equipment, cutoff),
|
||||||
"MaintenanceRecords" => await QueryCount(_db.MaintenanceRecords, cutoff),
|
"MaintenanceRecords" => await QueryCount(_db.MaintenanceRecords, cutoff),
|
||||||
"Vendors" => await QueryCount(_db.Vendors, cutoff),
|
"Vendors" => await QueryCount(_db.Vendors, cutoff),
|
||||||
"ShopWorkers" => await QueryCount(_db.ShopWorkers, cutoff),
|
|
||||||
_ => (0, null)
|
_ => (0, null)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -324,11 +322,6 @@ public class DataPurgeController : Controller
|
|||||||
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
|
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "ShopWorkers":
|
|
||||||
count = await _db.ShopWorkers.IgnoreQueryFilters()
|
|
||||||
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -359,7 +352,7 @@ public class DataPurgeController : Controller
|
|||||||
"MaintenanceRecords",
|
"MaintenanceRecords",
|
||||||
"Jobs", "Customers", "Quotes",
|
"Jobs", "Customers", "Quotes",
|
||||||
"InventoryItems", "Equipment",
|
"InventoryItems", "Equipment",
|
||||||
"Vendors", "ShopWorkers"
|
"Vendors"
|
||||||
};
|
};
|
||||||
return order.Where(entities.Contains).ToArray();
|
return order.Where(entities.Contains).ToArray();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ using PowderCoating.Shared.Constants;
|
|||||||
using QuestPDF.Fluent;
|
using QuestPDF.Fluent;
|
||||||
using QuestPDF.Helpers;
|
using QuestPDF.Helpers;
|
||||||
using QuestPDF.Infrastructure;
|
using QuestPDF.Infrastructure;
|
||||||
|
using AccountSubTypeEnum = PowderCoating.Core.Enums.AccountSubType;
|
||||||
|
|
||||||
namespace PowderCoating.Web.Controllers;
|
namespace PowderCoating.Web.Controllers;
|
||||||
|
|
||||||
@@ -22,17 +23,20 @@ public class DepositsController : Controller
|
|||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
private readonly ILogger<DepositsController> _logger;
|
private readonly ILogger<DepositsController> _logger;
|
||||||
private readonly ICompanyLogoService _logoService;
|
private readonly ICompanyLogoService _logoService;
|
||||||
|
private readonly IAccountBalanceService _accountBalanceService;
|
||||||
|
|
||||||
public DepositsController(
|
public DepositsController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
ILogger<DepositsController> logger,
|
ILogger<DepositsController> logger,
|
||||||
ICompanyLogoService logoService)
|
ICompanyLogoService logoService,
|
||||||
|
IAccountBalanceService accountBalanceService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_logoService = logoService;
|
_logoService = logoService;
|
||||||
|
_accountBalanceService = accountBalanceService;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -76,27 +80,34 @@ public class DepositsController : Controller
|
|||||||
if (currentUser == null) return Unauthorized();
|
if (currentUser == null) return Unauthorized();
|
||||||
|
|
||||||
var receiptNumber = await GenerateReceiptNumberAsync(currentUser.CompanyId);
|
var receiptNumber = await GenerateReceiptNumberAsync(currentUser.CompanyId);
|
||||||
|
var checkingAcctId = await GetCheckingAccountIdAsync(currentUser.CompanyId);
|
||||||
|
|
||||||
var deposit = new Deposit
|
var deposit = new Deposit
|
||||||
{
|
{
|
||||||
ReceiptNumber = receiptNumber,
|
ReceiptNumber = receiptNumber,
|
||||||
CustomerId = customerId,
|
CustomerId = customerId,
|
||||||
JobId = jobId,
|
JobId = jobId,
|
||||||
QuoteId = quoteId,
|
QuoteId = quoteId,
|
||||||
Amount = amount,
|
Amount = amount,
|
||||||
PaymentMethod = method,
|
PaymentMethod = method,
|
||||||
ReceivedDate = receivedDate,
|
ReceivedDate = receivedDate,
|
||||||
Reference = reference,
|
Reference = reference,
|
||||||
Notes = notes,
|
Notes = notes,
|
||||||
RecordedById = currentUser.Id,
|
DepositAccountId = checkingAcctId,
|
||||||
CompanyId = currentUser.CompanyId,
|
RecordedById = currentUser.Id,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CompanyId = currentUser.CompanyId,
|
||||||
CreatedBy = currentUser.Email
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
CreatedBy = currentUser.Email
|
||||||
};
|
};
|
||||||
|
|
||||||
await _unitOfWork.Deposits.AddAsync(deposit);
|
await _unitOfWork.Deposits.AddAsync(deposit);
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
// GL: DR Checking (cash received) / CR Customer Deposits 2300 (liability until applied to invoice).
|
||||||
|
var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(currentUser.CompanyId);
|
||||||
|
await _accountBalanceService.DebitAsync(checkingAcctId, deposit.Amount);
|
||||||
|
await _accountBalanceService.CreditAsync(custDepositsAcctId, deposit.Amount);
|
||||||
|
|
||||||
return Json(new
|
return Json(new
|
||||||
{
|
{
|
||||||
success = true,
|
success = true,
|
||||||
@@ -137,6 +148,11 @@ public class DepositsController : Controller
|
|||||||
if (deposit.AppliedToInvoiceId != null)
|
if (deposit.AppliedToInvoiceId != null)
|
||||||
return Json(new { success = false, message = "This deposit has already been applied to an invoice and cannot be deleted." });
|
return Json(new { success = false, message = "This deposit has already been applied to an invoice and cannot be deleted." });
|
||||||
|
|
||||||
|
// Reverse the GL entry made at recording time: CR Checking / DR Customer Deposits 2300.
|
||||||
|
var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(deposit.CompanyId);
|
||||||
|
await _accountBalanceService.CreditAsync(deposit.DepositAccountId, deposit.Amount);
|
||||||
|
await _accountBalanceService.DebitAsync(custDepositsAcctId, deposit.Amount);
|
||||||
|
|
||||||
await _unitOfWork.Deposits.SoftDeleteAsync(id);
|
await _unitOfWork.Deposits.SoftDeleteAsync(id);
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
@@ -419,6 +435,24 @@ public class DepositsController : Controller
|
|||||||
return hex.StartsWith("#") ? hex : fallback;
|
return hex.StartsWith("#") ? hex : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the first active Checking or Cash account for the company, or null.</summary>
|
||||||
|
private async Task<int?> GetCheckingAccountIdAsync(int companyId)
|
||||||
|
{
|
||||||
|
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
|
a => a.CompanyId == companyId && a.IsActive
|
||||||
|
&& (a.AccountSubType == AccountSubTypeEnum.Checking
|
||||||
|
|| a.AccountSubType == AccountSubTypeEnum.Cash));
|
||||||
|
return acct?.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns account 2300 "Customer Deposits" liability for the company, or null.</summary>
|
||||||
|
private async Task<int?> GetCustomerDepositsAccountIdAsync(int companyId)
|
||||||
|
{
|
||||||
|
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
|
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "2300");
|
||||||
|
return acct?.Id;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
|
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
|
||||||
{
|
{
|
||||||
if (company == null) return (null, null);
|
if (company == null) return (null, null);
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using PowderCoating.Shared.Constants;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Security.Principal;
|
using System.Security.Principal;
|
||||||
|
|
||||||
namespace PowderCoating.Web.Controllers;
|
namespace PowderCoating.Web.Controllers;
|
||||||
|
|
||||||
[Authorize(Roles = "SuperAdmin,Administrator")]
|
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||||
public class DiagnosticsController : Controller
|
public class DiagnosticsController : Controller
|
||||||
{
|
{
|
||||||
private readonly ILogger<DiagnosticsController> _logger;
|
private readonly ILogger<DiagnosticsController> _logger;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user