Compare commits
96 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10f668fd73 | |||
| 19b7a9a473 | |||
| e443457139 | |||
| edf56c1164 | |||
| b9cd693421 | |||
| d77b3778ac | |||
| a7bf97a2df | |||
| 05935b110a | |||
| 64a9c1531b | |||
| f018653c18 | |||
| b7ab85ff92 | |||
| 15b070398b | |||
| 14f220347b | |||
| baec0b33f7 | |||
| ce7b00b68c | |||
| dfb1d34af3 | |||
| c5c1244177 | |||
| 8c86eba4f2 | |||
| d4dddfa727 | |||
| 1bb07162cd | |||
| ec925f9e08 | |||
| 600196f679 | |||
| eb13283e76 | |||
| 30c644a8ec | |||
| 0e480adbf6 | |||
| eaab0af51f | |||
| 51a5268bc2 | |||
| a0bdd2b5b4 | |||
| 21b39161a3 | |||
| b241daf15e | |||
| 25140554ad | |||
| 46cadea367 | |||
| cfe937c0c3 | |||
| 3ad6b0d08f | |||
| fdac0240d1 | |||
| 81dc34bab4 | |||
| b9e9449c8b | |||
| fd38785942 | |||
| 33277de727 | |||
| 4ac62551f4 | |||
| 7fa385aeb8 | |||
| 8452ea3fcd | |||
| 9b34ff564e | |||
| 24f3df1bbc | |||
| 551116d7e5 | |||
| 8768e9813b | |||
| 4a7087cc0c | |||
| 59b152c89f | |||
| 441898b52f | |||
| 3e30397302 | |||
| 31c5746e5b | |||
| 3f9ac27afa | |||
| df504674e9 | |||
| 07796b05c8 | |||
| 2bf8871892 | |||
| 8a0a564885 | |||
| dd4785b048 | |||
| e185e3b7e3 | |||
| 8acbc8605d | |||
| 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 |
@@ -1,180 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(dotnet build:*)",
|
||||
"Bash(dir:*)",
|
||||
"Bash(dotnet restore:*)",
|
||||
"Bash(dotnet clean:*)",
|
||||
"Bash(findstr:*)",
|
||||
"Bash(dotnet ef migrations add:*)",
|
||||
"Bash(dotnet ef migrations remove:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(dotnet ef database update:*)",
|
||||
"Bash(sqlcmd:*)",
|
||||
"Bash(dotnet ef migrations script:*)",
|
||||
"Bash(dotnet run:*)",
|
||||
"Bash(timeout /t 15 dotnet run:*)",
|
||||
"Bash(timeout /t 10 /nobreak)",
|
||||
"Bash(ping:*)",
|
||||
"Bash(start /B dotnet run:*)",
|
||||
"Bash(test:*)",
|
||||
"Bash(dotnet ef migrations:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(xargs -I {} bash -c 'echo \"\"=== {} ===\"\" && head -20 {} | grep -E \"\"class|Authorize\"\"')",
|
||||
"Bash(powershell:*)",
|
||||
"Bash(dotnet tool install:*)",
|
||||
"Bash(dotnet tool update:*)",
|
||||
"Bash(xargs:*)",
|
||||
"Bash(powershell -Command \"cd src\\\\PowderCoating.Web; dotnet ef migrations add UpdateQuoteForProspects --project ..\\\\PowderCoating.Infrastructure\")",
|
||||
"Bash(powershell -Command:*)",
|
||||
"Bash(taskkill:*)",
|
||||
"Bash(netstat:*)",
|
||||
"Bash(libman restore:*)",
|
||||
"Bash(./start-app.bat)",
|
||||
"Bash(dotnet-ef migrations add:*)",
|
||||
"Bash(dotnet-ef database update:*)",
|
||||
"Bash(./stop-app.bat)",
|
||||
"Bash(timeout /t 3 /nobreak)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(if [ -f \"stop-app.bat\" ])",
|
||||
"Bash(then cmd.exe /c stop-app.bat)",
|
||||
"Bash(else echo \"stop-app.bat not found\")",
|
||||
"Bash(fi)",
|
||||
"Bash(powershell.exe -Command \"Unblock-File -Path 'src/PowderCoating.Web/dotnet-tools.json'\":*)",
|
||||
"Bash(powershell.exe -Command \"Get-Process | Where-Object {$_ProcessName -like ''*PowderCoating*''} | Stop-Process -Force\")",
|
||||
"Bash(powershell.exe:*)",
|
||||
"Bash(Select-String -Pattern \"error|Error\")",
|
||||
"Bash(Select-String -NotMatch \"warning\")",
|
||||
"Bash(tasklist:*)",
|
||||
"Bash(dotnet add package:*)",
|
||||
"Bash(start-process dotnet run:*)",
|
||||
"Bash(Select-Object -ExpandProperty Id)",
|
||||
"Bash(find:*)",
|
||||
"Bash(cmd.exe:*)",
|
||||
"Bash(dotnet ef dbcontext:*)",
|
||||
"Bash(handle \"PowderCoating.Web.pdb\")",
|
||||
"Bash(timeout:*)",
|
||||
"Bash(del /F \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Web\\\\obj\\\\Debug\\\\net8.0\\\\PowderCoating.Web.pdb\")",
|
||||
"Bash(Select-String -Pattern \"Build succeeded|Build FAILED|error\")",
|
||||
"Bash(Select-Object -Last 10)",
|
||||
"Bash(del \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Infrastructure\\\\Migrations\\\\20260211031319_RemovePreexistingCatalogData.cs\" \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Infrastructure\\\\Migrations\\\\20260211031319_RemovePreexistingCatalogData.Designer.cs\")",
|
||||
"Bash(Select-String:*)",
|
||||
"Bash(Select-Object -Last 5)",
|
||||
"Bash(start-app.bat)",
|
||||
"Bash(dotnet script:*)",
|
||||
"Bash(dotnet list:*)",
|
||||
"Bash(dotnet new:*)",
|
||||
"Bash(stop-app.bat)",
|
||||
"Bash(dotnet watch run:*)",
|
||||
"Bash(cmd /c \"taskkill /F /PID 42108\")",
|
||||
"Bash(cmd /c start-app.bat)",
|
||||
"Bash(\"Y:/PCC/PowderCoatingApp/src/PowderCoating.Application/Services/PdfService.cs\":*)",
|
||||
"Bash(/y/PCC/PowderCoatingApp/src/PowderCoating.Application/Services/PdfService.cs:*)",
|
||||
"Bash(/tmp/remove_tempdata.pl:*)",
|
||||
"Bash(chmod:*)",
|
||||
"Bash(perl:*)",
|
||||
"Bash(done)",
|
||||
"Bash(cmd:*)",
|
||||
"Bash(tail:*)",
|
||||
"Bash(del:*)",
|
||||
"Bash(dotnet add:*)",
|
||||
"Bash(python3:*)",
|
||||
"Bash(Stop-Process:*)",
|
||||
"Bash(mv:*)",
|
||||
"Bash(dotnet tool:*)",
|
||||
"Bash(where libman:*)",
|
||||
"Bash(find \"Y:/PCC/PowderCoatingApp\" -type f \\\\\\( -name \"*template*\" -o -name \"*import*\" -o -name \"*export*\" \\\\\\) -iname \"*.csv\" -o -iname \"*.xlsx\" -o -iname \"*.xls\" 2>/dev/null | head -50)",
|
||||
"Bash(grep -n \"powderCostOverride\\\\|PowderCostOverride\\\\|pageMeta\\\\|quoteItems\\\\|existingItems\" \"Y:/PCC/PowderCoatingApp/src/PowderCoating.Web/Views/Quotes/Create.cshtml\" | head -20\ngrep -n \"powderCostOverride\\\\|PowderCostOverride\\\\|pageMeta\\\\|quoteItems\\\\|existingItems\" \"Y:/PCC/PowderCoatingApp/src/PowderCoating.Web/Views/Quotes/Edit.cshtml\" 2>/dev/null | head -20)",
|
||||
"Bash(cat /tmp/sdktest/Program.cs | xxd | head -20)",
|
||||
"Bash(cd /tmp/sdktest && rm -rf bin obj && cat Program.cs)",
|
||||
"Bash(cat /tmp/sdktest/Program.cs | xxd | head -5)",
|
||||
"WebSearch",
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:www.nuget.org)",
|
||||
"Bash(wmic process:*)",
|
||||
"Bash(grep -rn \"AI Photo\\\\|ai.*photo\\\\|photo.*quote\\\\|item-type\\\\|AiPhotoQuotes\\\\|ai_photo\" \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Web\\\\Views\\\\Quotes\\\\\" | grep -i \"photo\\\\|ai\" | head -20)",
|
||||
"Bash(sed -i 's|\"aiAnalyzeUrl\": \"@Url.Action\\(\\\\\"AiAnalyzeItem\\\\\", \\\\\"Quotes\\\\\"\\)\",|\"aiAnalyzeUrl\": \"@Url.Action\\(\\\\\"AiAnalyzeItem\\\\\", \\\\\"Quotes\\\\\"\\)\",\\\\n \"aiPhotoQuotesEnabled\": @Json.Serialize\\(\\(bool\\)\\(ViewBag.AiPhotoQuotesEnabled ?? true\\)\\),|g' \\\\\n \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Web\\\\Views\\\\Quotes\\\\Edit.cshtml\" \\\\\n \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Web\\\\Views\\\\Jobs\\\\Create.cshtml\" \\\\\n \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Web\\\\Views\\\\Jobs\\\\Edit.cshtml\")",
|
||||
"Bash(cp:*)",
|
||||
"Bash(dotnet fsi -e \":*)",
|
||||
"Read(//y/tmp/**)",
|
||||
"Bash(cp /c/Users/spoul/.nuget/packages/stripe.net/50.4.1/stripe.net.50.4.1.nupkg stripe.zip)",
|
||||
"Bash(unzip -o stripe.zip *.cs -d stripe_src)",
|
||||
"Bash(dotnet ef:*)",
|
||||
"Bash(Payment)",
|
||||
"Bash(Deposit \")",
|
||||
"Bash(node:*)",
|
||||
"WebFetch(domain:quickbooks.intuit.com)",
|
||||
"WebFetch(domain:www.saasant.com)",
|
||||
"WebFetch(domain:www.liveflow.com)",
|
||||
"WebFetch(domain:www.gentlefrog.com)",
|
||||
"WebFetch(domain:blog.coupler.io)",
|
||||
"WebFetch(domain:litextension.com)",
|
||||
"WebFetch(domain:www.dancingnumbers.com)",
|
||||
"WebFetch(domain:www.bizbooks.pro)",
|
||||
"WebFetch(domain:support.saasant.com)",
|
||||
"WebFetch(domain:support.getcount.com)",
|
||||
"WebFetch(domain:planergy.com)",
|
||||
"WebFetch(domain:www.wizxpert.com)",
|
||||
"WebFetch(domain:www.trykeep.com)",
|
||||
"WebFetch(domain:gentlefrog.com)",
|
||||
"WebFetch(domain:www.syscloud.com)",
|
||||
"WebFetch(domain:interopay.zendesk.com)",
|
||||
"WebFetch(domain:docs.d-tools.cloud)",
|
||||
"WebFetch(domain:paygration.com)",
|
||||
"Bash([ ! -d \"Y:/PCC/PowderCoatingApp/src/PowderCoating.Web/Views/$controller\" ])",
|
||||
"Bash(bash /tmp/check_actions.sh)",
|
||||
"Bash(bash /tmp/verify_endpoints.sh)",
|
||||
"Bash(bash /tmp/verify_services.sh)",
|
||||
"Read(//y/PCC/Deployments/**)",
|
||||
"Bash(mkdir -p \"Y:/PCC/Deployments\")",
|
||||
"Bash(dotnet-script -e \"using System.Reflection; var a = Assembly.LoadFrom\\(\\\\\"Anthropic.SDK.dll\\\\\"\\); var types = a.GetTypes\\(\\).Where\\(t => t.Name.Contains\\(\\\\\"Document\\\\\"\\) || t.Name.Contains\\(\\\\\"Content\\\\\"\\)\\).Select\\(t => t.Name\\).OrderBy\\(n => n\\); foreach\\(var t in types\\) Console.WriteLine\\(t\\);\")",
|
||||
"Bash(sort -t'-' -k3 -r)",
|
||||
"Bash(wsl grep:*)",
|
||||
"Bash(find src:*)",
|
||||
"Bash(dotnet csharp *)",
|
||||
"Read(//c/Users/spoul/.nuget/packages/stripe.net/50.4.1/lib/netstandard2.0/**)",
|
||||
"Bash(dotnet publish *)",
|
||||
"Bash(Compress-Archive -Path * -DestinationPath \"..\\\\deploy.zip\" -Force)",
|
||||
"Bash(az webapp *)",
|
||||
"Read(//y/PCC/**)",
|
||||
"Bash(Get-Date -Format 'yyyyMMdd_HHmmss')",
|
||||
"PowerShell(Get-Content *)",
|
||||
"PowerShell(dotnet build *)",
|
||||
"PowerShell(New-Item *)",
|
||||
"PowerShell(& \"Y:\\\\PCC\\\\PowderCoatingApp\\\\scripts\\\\generate-migration-script.ps1\")",
|
||||
"PowerShell(if \\(Test-Path \"Y:\\\\pcc\\\\deployment\\\\migrations.sql\"\\) { $f = Get-Item \"Y:\\\\pcc\\\\deployment\\\\migrations.sql\"; Write-Host \"File exists: $\\($f.Length\\) bytes\" } else { Write-Host \"File not created\" })",
|
||||
"Bash(git add *)",
|
||||
"Bash(git commit -m ' *)",
|
||||
"Bash(git push *)",
|
||||
"Bash(git commit *)",
|
||||
"Bash(git checkout *)",
|
||||
"Bash(git merge *)",
|
||||
"Bash(dotnet package *)",
|
||||
"Bash(dotnet test *)",
|
||||
"Bash(git rm *)",
|
||||
"Bash(git stash *)",
|
||||
"Bash(dotnet ef *)",
|
||||
"Bash(sqlcmd -S \".\\\\SQLEXPRESS\" -d PowderCoatingDb -Q \"SELECT Id, DisplayName, IsCoating, IsActive FROM InventoryCategoryLookups ORDER BY DisplayOrder\" -W)",
|
||||
"Skill(schedule)",
|
||||
"Bash(git -C \"//192.168.0.37/SCPSoftware/tmp/PowderCoatingApp-dev-perf\" log --oneline -10)",
|
||||
"Bash(git -C \"//192.168.0.37/SCPSoftware/tmp/PowderCoatingApp-dev-perf\" status --short)",
|
||||
"Bash(git *)",
|
||||
"Bash(get-childitem -Recurse -Filter \"QuotesController.cs\")",
|
||||
"Bash(Select-Object -ExpandProperty FullName)",
|
||||
"Bash(dotnet user-secrets *)",
|
||||
"Bash(Get-ChildItem -Path \"Y:\\\\PCC\\\\PowderCoatingApp\" -Directory)",
|
||||
"Bash(Select-Object Name)",
|
||||
"Bash(Get-Content *)",
|
||||
"Bash(python -c \"import json; data=json.load\\(open\\('prismatic_powders.json','r',encoding='utf-8'\\)\\); print\\(f'Total records: {len\\(data\\)}'\\); print\\('First record:'\\); print\\(json.dumps\\(data[0], indent=2\\)\\)\")",
|
||||
"Bash(python -c \"import json; data=json.load\\(open\\('prismatic_powders.json','r',encoding='utf-8'\\)\\); keys=list\\(data.keys\\(\\)\\); print\\('Top-level keys:', keys[:10]\\); first=data[keys[0]]; print\\('First record key:', keys[0]\\); print\\(json.dumps\\(first, indent=2\\)\\)\")",
|
||||
"PowerShell(Get-ChildItem *)",
|
||||
"PowerShell(Select-String *)",
|
||||
"Bash(Select-Object -First 20)",
|
||||
"PowerShell(node -e \"require\\('fs'\\).existsSync\\(require\\('path'\\).join\\(process.cwd\\(\\), 'node_modules', 'sharp'\\)\\) ? console.log\\('sharp ok'\\) : console.log\\('no sharp'\\)\")",
|
||||
"WebFetch(domain:www.powdercoatinglogix.com)",
|
||||
"PowerShell($bytes = [System.IO.File]::ReadAllBytes\\('src/PowderCoating.Web/Views/Jobs/Details.cshtml'\\); $text = [System.Text.Encoding]::UTF8.GetString\\($bytes\\); $idx = $text.IndexOf\\('hasPowderData'\\); $snippet = $text.Substring\\($idx - 20, 250\\); [System.Text.Encoding]::Unicode.GetBytes\\($snippet\\) | Format-Hex | Select-Object -First 30)",
|
||||
"PowerShell($dll = \"C:\\\\Users\\\\spoul\\\\.nuget\\\\packages\\\\questpdf\\\\2024.12.3\\\\lib\\\\net6.0\\\\QuestPDF.dll\"; $asm = [Reflection.Assembly]::LoadFile\\($dll\\); $asm.GetTypes\\(\\) | Where-Object { $_.Name -eq \"ContainerExtensions\" } | ForEach-Object { $_.GetMethods\\(\\) | Where-Object { $_.Name -match \"Canvas|Rotat|Layer\" } | Select-Object Name } | Sort-Object Name -Unique)",
|
||||
"PowerShell(Get-ChildItem \"C:\\\\Users\\\\spoul\\\\.nuget\\\\packages\\\\\" -ErrorAction SilentlyContinue | Where-Object { $_.Name -match \"quest|skia\" } | Select-Object Name)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
# Pre-commit hook: block commits containing corrupted Unicode in .cshtml files.
|
||||
#
|
||||
# All corruption variants start with the UTF-8 byte sequence for a-circumflex
|
||||
# followed by euro-sign (bytes C3 A2 E2 82 AC), which is the first two chars
|
||||
# of every known corruption pattern. Grep for that byte sequence in staged files.
|
||||
|
||||
STAGED=$(git diff --cached --name-only | grep '\.cshtml$')
|
||||
if [ -z "$STAGED" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# $'\xc3\xa2\xe2\x82\xac' = UTF-8 bytes for a-circumflex + euro-sign
|
||||
CORRUPT=$(echo "$STAGED" | xargs grep -l $'\xc3\xa2\xe2\x82\xac' 2>/dev/null)
|
||||
|
||||
if [ -n "$CORRUPT" ]; then
|
||||
echo ""
|
||||
echo "ERROR: Corrupted Unicode characters detected in staged .cshtml files:"
|
||||
echo "$CORRUPT" | sed 's/^/ /'
|
||||
echo ""
|
||||
echo "Fix by running: .\\tools\\Fix-Encoding.ps1"
|
||||
echo "Then re-stage the files and commit again."
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -1,6 +1,11 @@
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
|
||||
# Claude Code tool settings and build logs
|
||||
.claude/settings.local.json
|
||||
.claude/settings.json
|
||||
BuildLog*.txt
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
@@ -129,3 +134,7 @@ DataProtection-Keys/
|
||||
# Secrets
|
||||
appsettings.secrets.json
|
||||
*.pfx
|
||||
|
||||
# Local task tracking
|
||||
TODO.txt
|
||||
TODO.txt.bak
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
A production-ready ASP.NET Core 8.0 MVC application for managing powder coating business operations. The application implements Clean Architecture with six projects across three layers (Domain, Application, Infrastructure) plus two presentation layers (Web MVC, RESTful API).
|
||||
|
||||
## Essential Commands
|
||||
|
||||
### Building and Running
|
||||
|
||||
```bash
|
||||
# Build entire solution
|
||||
dotnet build
|
||||
|
||||
# Run web application (MVC)
|
||||
cd src/PowderCoating.Web
|
||||
dotnet run
|
||||
# Access at: https://localhost:58461
|
||||
|
||||
# Run web with auto-reload
|
||||
dotnet watch run
|
||||
|
||||
# Run API
|
||||
cd src/PowderCoating.Api
|
||||
dotnet run
|
||||
# Swagger UI at root URL
|
||||
|
||||
# Run tests
|
||||
dotnet test # All tests
|
||||
dotnet test tests/PowderCoating.UnitTests # Unit tests only
|
||||
dotnet test tests/PowderCoating.IntegrationTests # Integration tests only
|
||||
```
|
||||
|
||||
### Database Operations
|
||||
|
||||
```bash
|
||||
# All EF commands run from Web project directory
|
||||
cd src/PowderCoating.Web
|
||||
|
||||
# Create migration (must specify Infrastructure project)
|
||||
dotnet ef migrations add MigrationName --project ../PowderCoating.Infrastructure
|
||||
|
||||
# Apply migrations
|
||||
dotnet ef database update --project ../PowderCoating.Infrastructure
|
||||
|
||||
# Reset database (WARNING: deletes all data)
|
||||
dotnet ef database drop --project ../PowderCoating.Infrastructure
|
||||
dotnet ef database update --project ../PowderCoating.Infrastructure
|
||||
|
||||
# List migrations
|
||||
dotnet ef migrations list --project ../PowderCoating.Infrastructure
|
||||
|
||||
# Remove last migration (if not applied)
|
||||
dotnet ef migrations remove --project ../PowderCoating.Infrastructure
|
||||
```
|
||||
|
||||
### Default Credentials
|
||||
|
||||
```
|
||||
SuperAdmin (break glass): artemis@powdercoatinglogix.com / SuperAdmin123!
|
||||
SuperAdmin (seed): superadmin@powdercoatinglogix.com / SuperAdmin123!
|
||||
SuperAdmin (seed): spouliot@powdercoatinglogix.com / SuperAdmin123!
|
||||
Company Admin (seed): demo@powdercoatinglogix.com / CompanyAdmin123!
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Clean Architecture Layers
|
||||
|
||||
**Domain Layer (PowderCoating.Core)**
|
||||
- Contains business entities, enums, and repository interfaces
|
||||
- `BaseEntity` provides common properties for all entities (Id, CompanyId, CreatedAt, UpdatedAt, IsDeleted, audit fields)
|
||||
- All entities inherit from BaseEntity and support soft delete
|
||||
- No dependencies on other projects
|
||||
|
||||
**Application Layer (PowderCoating.Application)**
|
||||
- DTOs organized by domain (Customer, Job, Equipment, Inventory, Maintenance)
|
||||
- AutoMapper profiles with reverse mappings
|
||||
- Service interfaces (IFileService, etc.)
|
||||
- No UI or infrastructure dependencies
|
||||
|
||||
**Infrastructure Layer (PowderCoating.Infrastructure)**
|
||||
- `ApplicationDbContext` with global query filters for soft deletes and multi-tenancy
|
||||
- Generic `Repository<T>` implementing `IRepository<T>`
|
||||
- `UnitOfWork` implementing `IUnitOfWork` with lazy-loaded repositories
|
||||
- Seed data is triggered **manually** via Platform Management → Seed Data (not automatic on startup)
|
||||
|
||||
**Presentation Layers**
|
||||
- `PowderCoating.Web`: MVC application with Razor views, Bootstrap 5 UI
|
||||
- `PowderCoating.Api`: RESTful API with JWT authentication, Swagger documentation
|
||||
|
||||
### Key Design Patterns
|
||||
|
||||
**Repository Pattern**
|
||||
- Generic `Repository<T>` in Infrastructure
|
||||
- All CRUD operations, search, pagination, eager loading support
|
||||
- Soft delete with `SoftDeleteAsync()` method
|
||||
|
||||
**Unit of Work Pattern**
|
||||
- Coordinates multiple repositories
|
||||
- Transaction support: `BeginTransactionAsync()`, `CommitTransactionAsync()`, `RollbackTransactionAsync()`
|
||||
- Lazy instantiation of repositories
|
||||
- `SaveChangesAsync()` or `CompleteAsync()` to persist changes
|
||||
|
||||
**Dependency Injection**
|
||||
- All dependencies registered in `Program.cs`
|
||||
- Controllers inject `IUnitOfWork` and `IMapper`
|
||||
- Services are scoped to request lifetime
|
||||
|
||||
**Global Query Filters**
|
||||
- Soft deletes: All queries automatically filter `IsDeleted == false`
|
||||
- Multi-tenancy: Non-SuperAdmin users see only their company data
|
||||
- Bypass with `ignoreQueryFilters: true` parameter in repository methods
|
||||
|
||||
### Multi-Tenancy Implementation
|
||||
|
||||
- `CompanyId` foreign key on all business entities
|
||||
- `ITenantContext` injected into DbContext resolves current company
|
||||
- SuperAdmin role can view all companies
|
||||
- Global query filters enforce company isolation at database level
|
||||
- Users have both system role (SuperAdmin) and company role (CompanyAdmin, Manager, Worker, Viewer)
|
||||
|
||||
## Data Access Rules (ENFORCE THESE)
|
||||
|
||||
> **`ApplicationDbContext` is NEVER injected into a controller.**
|
||||
> All data access in controllers goes through `IUnitOfWork`. No exceptions outside the list below.
|
||||
> **This rule is enforced at startup:** `EnforceDataAccessArchitecture()` in `Program.cs` scans all
|
||||
> controllers at boot and throws if any non-exempt controller injects `ApplicationDbContext`.
|
||||
> Full rationale and permanent exceptions list: `docs/DATA_ACCESS_ARCHITECTURE.md`
|
||||
|
||||
### Three tiers — use the right one:
|
||||
|
||||
**Tier 1 — Simple CRUD** → `IUnitOfWork.EntityName` (generic `IRepository<T>`)
|
||||
```csharp
|
||||
var items = await _unitOfWork.CatalogItems.GetAllAsync();
|
||||
await _unitOfWork.Announcements.AddAsync(entity);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
```
|
||||
|
||||
**Tier 2 — Complex domain queries** → typed repositories on `IUnitOfWork`
|
||||
```csharp
|
||||
// Include chains and domain-specific queries belong in the repository, not the controller
|
||||
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id);
|
||||
var invoice = await _unitOfWork.Invoices.LoadForViewAsync(id);
|
||||
var quote = await _unitOfWork.Quotes.GetByApprovalTokenAsync(token);
|
||||
```
|
||||
Typed repositories: `IJobRepository`, `IInvoiceRepository`, `IQuoteRepository`,
|
||||
`ICustomerRepository`, `IBillRepository`, `IPurchaseOrderRepository`
|
||||
— defined in `Core/Interfaces/Repositories/`, implemented in `Infrastructure/Repositories/`
|
||||
|
||||
**Tier 3 — Aggregate/reporting queries** → injected read services
|
||||
```csharp
|
||||
// P&L, AR aging, cycle time, powder usage — shaped DTOs, never tracked entities
|
||||
var aging = await _financialReports.GetArAgingAsync(companyId);
|
||||
```
|
||||
Services: `IFinancialReportService`, `IOperationalReportService`
|
||||
— defined in `Core/Interfaces/Services/`, implemented in `Infrastructure/Services/`
|
||||
|
||||
### Permanent exceptions (ApplicationDbContext allowed — intentional, documented):
|
||||
`StripeWebhookController`, `WebhooksController`, `PaymentController`, `RegistrationController`,
|
||||
`DataExportController`, `AccountDataExportController`, `DataPurgeController`,
|
||||
`SystemInfoController`, `SystemLogsController`, `CompanyHealthController`
|
||||
|
||||
If you think you need a new exception, you almost certainly don't. Check the spec first.
|
||||
|
||||
---
|
||||
|
||||
## Data Access Patterns
|
||||
|
||||
### Common Controller Pattern
|
||||
|
||||
```csharp
|
||||
public class ExampleController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public ExampleController(IUnitOfWork unitOfWork, IMapper mapper)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var entities = await _unitOfWork.Examples.GetAllAsync();
|
||||
var dtos = _mapper.Map<List<ExampleDto>>(entities);
|
||||
return View(dtos);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(CreateExampleDto dto)
|
||||
{
|
||||
var entity = _mapper.Map<Example>(dto);
|
||||
await _unitOfWork.Examples.AddAsync(entity);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
await _unitOfWork.Examples.SoftDeleteAsync(id);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using Unit of Work Repositories
|
||||
|
||||
All entity repositories are available via `IUnitOfWork` properties:
|
||||
- `_unitOfWork.Customers`
|
||||
- `_unitOfWork.Jobs`
|
||||
- `_unitOfWork.JobItems`
|
||||
- `_unitOfWork.Quotes`
|
||||
- `_unitOfWork.InventoryItems`
|
||||
- `_unitOfWork.Equipment`
|
||||
- `_unitOfWork.MaintenanceRecords`
|
||||
- Plus additional entities (Suppliers, JobPhotos, JobNotes, etc.)
|
||||
|
||||
### Eager Loading Related Data
|
||||
|
||||
```csharp
|
||||
// Load customer with related data
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(
|
||||
id,
|
||||
c => c.Jobs,
|
||||
c => c.Quotes,
|
||||
c => c.PricingTier
|
||||
);
|
||||
|
||||
// Find with predicate and includes
|
||||
var activeJobs = await _unitOfWork.Jobs.FindAsync(
|
||||
j => j.Status != JobStatus.Completed,
|
||||
j => j.Customer,
|
||||
j => j.JobItems
|
||||
);
|
||||
```
|
||||
|
||||
### Pagination
|
||||
|
||||
```csharp
|
||||
var pagedJobs = await _unitOfWork.Jobs.GetPagedAsync(
|
||||
pageNumber: 1,
|
||||
pageSize: 25,
|
||||
j => j.Status == JobStatus.InPreparation,
|
||||
j => j.Customer
|
||||
);
|
||||
```
|
||||
|
||||
## Important Domain Concepts
|
||||
|
||||
### Job Lifecycle
|
||||
|
||||
Jobs progress through 16 statuses:
|
||||
1. **Pending** → Initial state
|
||||
2. **Quoted** → Quote generated
|
||||
3. **Approved** → Customer approved
|
||||
4. **InPreparation** → Job prep started
|
||||
5. **Sandblasting** → Surface prep
|
||||
6. **MaskingTaping** → Masking areas
|
||||
7. **Cleaning** → Pre-coat cleaning
|
||||
8. **InOven** → Pre-heating
|
||||
9. **Coating** → Applying powder
|
||||
10. **Curing** → Heat curing
|
||||
11. **QualityCheck** → Inspection
|
||||
12. **Completed** → Work finished
|
||||
13. **ReadyForPickup** → Awaiting customer
|
||||
14. **Delivered** → Job delivered
|
||||
15. **OnHold** → Paused
|
||||
16. **Cancelled** → Cancelled
|
||||
|
||||
**Job Priorities**: Low, Normal, High, Urgent, Rush (color-coded in UI)
|
||||
|
||||
### Customer Types
|
||||
|
||||
- **Commercial**: B2B customers with pricing tiers, credit limits
|
||||
- **Non-Commercial**: Individual customers, typically simpler pricing
|
||||
|
||||
### Inventory Management
|
||||
|
||||
**Transaction Types**: Purchase, Sale, Adjustment, Transfer, Return, Waste, Initial
|
||||
- All transactions tracked in `InventoryTransaction` entity
|
||||
- Reorder points trigger low-stock alerts
|
||||
|
||||
### Equipment & Maintenance
|
||||
|
||||
**Equipment Status**: Operational, NeedsMaintenance, UnderMaintenance, OutOfService, Retired
|
||||
**Maintenance Priority**: Low, Normal, High, Critical
|
||||
**Maintenance Status**: Scheduled, InProgress, Completed, Cancelled, Overdue
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### Web Application (src/PowderCoating.Web/appsettings.json)
|
||||
|
||||
```json
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Server=.\\SQLEXPRESS;Database=PowderCoatingDb;Trusted_Connection=true;MultipleActiveResultSets=true;TrustServerCertificate=true"
|
||||
},
|
||||
"AppSettings": {
|
||||
"CompanyName": "Powder Coating Logix",
|
||||
"DefaultQuoteValidityDays": 30,
|
||||
"DefaultPaymentTerms": "Net 30",
|
||||
"TaxRate": 0.0,
|
||||
"Currency": "USD",
|
||||
"TrialPeriodDays": 7,
|
||||
"QuoteApprovalTokenDays": 30
|
||||
},
|
||||
"AI": {
|
||||
"Anthropic": {
|
||||
"ApiKey": "your-anthropic-api-key-here"
|
||||
}
|
||||
},
|
||||
"SendGrid": { ... },
|
||||
"Stripe": { ... },
|
||||
"Storage": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**AI uses Anthropic Codex Sonnet 4.6** (`Codex-sonnet-4-6`) — NOT OpenAI. The `AI:Anthropic:ApiKey` config key is what the AI photo quoting and AI scheduling services read.
|
||||
|
||||
### API (src/PowderCoating.Api/appsettings.json)
|
||||
|
||||
```json
|
||||
{
|
||||
"JwtSettings": {
|
||||
"SecretKey": "CHANGE-THIS-TO-YOUR-OWN-SECRET-KEY-AT-LEAST-32-CHARACTERS",
|
||||
"Issuer": "PowderCoatingAPI",
|
||||
"Audience": "PowderCoatingMobileApp",
|
||||
"ExpirationMinutes": 1440
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Launch Settings (src/PowderCoating.Web/Properties/launchSettings.json)
|
||||
|
||||
Default ports:
|
||||
- HTTPS: 58461
|
||||
- HTTP: 58462
|
||||
|
||||
## Authentication & Authorization
|
||||
|
||||
### System Roles
|
||||
- **SuperAdmin**: Platform-wide access, sees all companies and deleted records
|
||||
- **Administrator**: Company admin
|
||||
- **Manager**: Operations management
|
||||
- **Employee**: Create/edit jobs and quotes
|
||||
- **ShopFloor**: Update job status
|
||||
- **ReadOnly**: View-only access
|
||||
|
||||
### Custom Authorization Policies
|
||||
|
||||
Defined in `PowderCoating.Shared/Constants/AppConstants.cs`:
|
||||
- `RequireAdministratorRole`
|
||||
- `CanManageJobs`
|
||||
- `CanManageInventory`
|
||||
- `CanManageUsers`
|
||||
- `CanViewData`
|
||||
|
||||
Apply with `[Authorize(Policy = "PolicyName")]` on controllers/actions.
|
||||
|
||||
### JWT Authentication (API Only)
|
||||
|
||||
API uses JWT Bearer tokens. Web uses cookie-based Identity authentication.
|
||||
|
||||
## AutoMapper Configuration
|
||||
|
||||
AutoMapper is registered as singleton in `Program.cs`:
|
||||
```csharp
|
||||
builder.Services.AddSingleton(provider => new MapperConfiguration(cfg =>
|
||||
{
|
||||
cfg.AddMaps(typeof(ApplicationAssemblyMarker).Assembly);
|
||||
}).CreateMapper());
|
||||
```
|
||||
|
||||
All profiles in `Application/Mappings/` are auto-discovered. Profiles include reverse mappings for entity ↔ DTO conversion.
|
||||
|
||||
## Logging
|
||||
|
||||
Serilog configured to write:
|
||||
- Console (structured logs)
|
||||
- File: `logs/powdercoating-{Date}.txt` (rolling daily)
|
||||
|
||||
Access via constructor injection:
|
||||
```csharp
|
||||
private readonly ILogger<ExampleController> _logger;
|
||||
```
|
||||
|
||||
## Common Development Tasks
|
||||
|
||||
### Adding a New Entity
|
||||
|
||||
1. Create entity class in `Core/Entities/` inheriting from `BaseEntity`
|
||||
2. Add DbSet to `ApplicationDbContext`
|
||||
3. Register repository property in `IUnitOfWork` interface
|
||||
4. Add lazy-loaded property in `UnitOfWork` implementation
|
||||
5. Create migration: `dotnet ef migrations add AddEntityName --project ../PowderCoating.Infrastructure`
|
||||
6. Apply migration: `dotnet ef database update --project ../PowderCoating.Infrastructure`
|
||||
|
||||
### Adding a New Controller
|
||||
|
||||
1. Create DTOs in `Application/DTOs/`
|
||||
2. Create AutoMapper profile in `Application/Mappings/`
|
||||
3. Create controller in `Web/Controllers/`
|
||||
4. Create views in `Web/Views/[ControllerName]/`
|
||||
5. Add navigation link in `Views/Shared/_Layout.cshtml`
|
||||
|
||||
### Working with Soft Deletes
|
||||
|
||||
```csharp
|
||||
// Soft delete (sets IsDeleted = true)
|
||||
await _unitOfWork.Customers.SoftDeleteAsync(id);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Physical delete (use sparingly)
|
||||
await _unitOfWork.Customers.DeleteAsync(entity);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Include deleted records in query
|
||||
var allCustomers = await _unitOfWork.Customers.GetAllAsync(ignoreQueryFilters: true);
|
||||
```
|
||||
|
||||
### Bypassing Multi-Tenancy Filters
|
||||
|
||||
Only for SuperAdmin users:
|
||||
```csharp
|
||||
// See all companies' data
|
||||
var allJobs = await _unitOfWork.Jobs.GetAllAsync(ignoreQueryFilters: true);
|
||||
```
|
||||
|
||||
## Implemented Modules
|
||||
|
||||
All modules below are fully implemented with controllers, views, and migrations applied.
|
||||
|
||||
### Operations
|
||||
- **Jobs** — full lifecycle (16 statuses), worker assignment, time entries, rework tracking, shop access codes, job templates
|
||||
- **Quotes** — multi-item pricing engine, AI Photo Quoting (Anthropic Codex Sonnet 4.6), quote-to-job conversion, customer approval portal, online payment
|
||||
- **Invoices** — create from job, partial payments, voids, PDF download, email send; 1:1 Job→Invoice enforced by unique index
|
||||
- **Deposits** — record against customer/job/quote; auto-applied to invoices on creation; receipt PDF via QuestPDF
|
||||
- **Customers** — commercial and non-commercial types, pricing tiers, tax exempt flag + certificate upload, credit limits
|
||||
- **Oven Scheduler** — batch jobs into named ovens, capacity planning, suggested batches
|
||||
|
||||
### Inventory & Purchasing
|
||||
- **Inventory** — stock tracking, transactions, reorder alerts, powder coverage/efficiency fields
|
||||
- **Vendors** — supplier management, payment terms, linked to inventory items
|
||||
- **Purchase Orders** — create/submit/receive POs, convert to vendor bills
|
||||
- **Accounts Payable** — vendor bills, AP ledger, payment tracking
|
||||
|
||||
### Shop Management
|
||||
- **Shop Workers** — roles (Coater, Sandblaster, etc.), assignment to jobs and maintenance tasks
|
||||
- **Equipment & Maintenance** — equipment status lifecycle, scheduled/completed maintenance records
|
||||
- **Catalog Items** — pre-priced service catalog with default prices
|
||||
- **Pricing Tiers** — customer discount tiers; use `CompanyAdminOnly` policy (not `RequireAdministratorRole`)
|
||||
|
||||
### Billing & Payments
|
||||
- **Stripe** — subscription plans, checkout sessions, customer portal, webhooks (`/stripe/webhook`)
|
||||
- **Stripe Connect** — embedded payments, OAuth flow for tenant onboarding
|
||||
- **Twilio SMS** — `ISmsService` fully implemented; webhook at `POST /Webhooks/TwilioSms`
|
||||
|
||||
### Platform (SuperAdmin only)
|
||||
- **Platform Users** — create/manage SuperAdmin accounts
|
||||
- **Companies** — view/manage all tenant companies
|
||||
- **Seed Data** — manual seeding via Platform Management UI (not automatic)
|
||||
- **Subscription Plans** — `SubscriptionPlanConfig` controls per-plan limits and pricing
|
||||
|
||||
### Other
|
||||
- **Help Center** — 14 fully-written articles at `Views/Help/`
|
||||
- **Setup Wizard** — 10-step onboarding wizard at `SetupWizardController`
|
||||
- **Reports** — 24 report actions including P&L, AR Aging, Powder Usage, Job Cycle Time, PDF exports
|
||||
- **Gift Certificates** — issue, redeem, track balance
|
||||
- **Announcements** — platform-wide announcements to tenants
|
||||
|
||||
### Key Pricing Rules
|
||||
- Custom powder (no inventory item + `PowderToOrder` > 0): charge for the **full ordered quantity**, not just calculated usage
|
||||
- In-stock inventory powder: charge for calculated usage only (surface area × lbs/sqft × unit cost)
|
||||
- Tax exempt customers (`Customer.IsTaxExempt`): `TaxPercent` defaults to 0 on quote and invoice create; customer dropdown marks exempt customers with ★
|
||||
|
||||
### Pricing Routing Flags — Must Stay In Sync Across All Three Layers
|
||||
|
||||
`PricingCalculationService.CalculateQuoteItemPriceAsync` routes each item to the correct pricing path using boolean flags. **These flags MUST exist identically on `QuoteItem`, `JobItem`, and `CreateQuoteItemDto`, AND be mapped in all three `JobItemAssemblyService.CreateJobItem` overloads.**
|
||||
|
||||
| Flag | Effect if missing on JobItem |
|
||||
|------|------------------------------|
|
||||
| `IsAiItem` | Job repriced as calculated item; oven cost double-charged on every save |
|
||||
| `IsGenericItem` | ManualUnitPrice ignored; price recalculated from surface area |
|
||||
| `IsLaborItem` | Item repriced at surface-area rate instead of hours × labor rate |
|
||||
| `IsSalesItem` | ManualUnitPrice ignored; item repriced using coat/surface math |
|
||||
|
||||
**Checklist when adding a new pricing routing flag:**
|
||||
1. Add the property to `QuoteItem` (Core/Entities)
|
||||
2. Add the property to `JobItem` (Core/Entities)
|
||||
3. Add it to `CreateQuoteItemDto` (Application/DTOs)
|
||||
4. Add it to `JobItemSeed` (private class in JobItemAssemblyService)
|
||||
5. Map it in all three `JobItemAssemblyService.CreateJobItem` overloads
|
||||
6. Include it in every `existingItemsData` JSON block in job views (`Edit.cshtml`, `EditItems.cshtml`) and in all job controller actions that build `CreateQuoteItemDto` from a `JobItem`
|
||||
7. Add a migration if the field is new on a persisted entity
|
||||
8. The structural test `PricingRoutingFlags_ExistOnBothQuoteItemAndJobItem` in `JobItemAssemblyServiceTests` will fail until steps 1–3 are done — this is intentional
|
||||
|
||||
### Branding
|
||||
- Application name: **Powder Coating Logix**
|
||||
- PCL logo: `wwwroot/images/pcl-logo.png` — used in sidebar header (when no tenant logo), login/register pages, sidebar footer
|
||||
- Sidebar footer always shows PCL logo linking to `http://www.powdercoatinglogix.com`
|
||||
- Tenant companies can upload their own logo (stored in Azure Blob `companylogos` container); it replaces the PCL logo in the sidebar header
|
||||
|
||||
## Known Issues
|
||||
|
||||
- Entity Framework warnings about global query filters on related entities (non-critical, informational only)
|
||||
|
||||
## File Upload Configuration
|
||||
|
||||
Limits defined in `AppConstants.cs`:
|
||||
- Max file size: 10 MB
|
||||
- Allowed extensions: jpg, jpeg, png, gif, pdf, doc, docx, xls, xlsx
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- **Unit Tests**: Test business logic in isolation
|
||||
- **Integration Tests**: Test full request pipeline with test database
|
||||
- Use xUnit framework
|
||||
- Mock `IUnitOfWork` in unit tests
|
||||
|
||||
## Extending the System
|
||||
|
||||
### Adding AI Features
|
||||
|
||||
AI uses Anthropic Codex Sonnet 4.6 via `IAiQuoteService`. Configure the key under `AI:Anthropic:ApiKey` in `appsettings.json`.
|
||||
1. Create service interface in `Application/Interfaces/`
|
||||
2. Implement in `Infrastructure/Services/` calling the Anthropic client
|
||||
3. Inject into controllers via DI
|
||||
|
||||
### SignalR Hubs
|
||||
|
||||
Two hubs are already implemented and mapped in `Program.cs`:
|
||||
- `NotificationHub` → `/hubs/notifications` (company-scoped push notifications)
|
||||
- `ShopHub` → `/hubs/shop` (real-time shop floor updates)
|
||||
|
||||
To add a new hub:
|
||||
1. Create hub class in `Web/Hubs/`
|
||||
2. Map hub in `Program.cs`: `app.MapHub<YourHub>("/hubpath")`
|
||||
3. Use JavaScript client in views to connect
|
||||
|
||||
### Adding API Endpoints
|
||||
|
||||
1. Create controller in `Api/Controllers/` with `[ApiController]` attribute
|
||||
2. Return `ActionResult<T>` types
|
||||
3. Use `[Authorize]` for protected endpoints
|
||||
4. Document with XML comments for Swagger
|
||||
|
||||
## Project Dependencies
|
||||
|
||||
Key NuGet packages:
|
||||
- **AutoMapper 16.0.0**: Entity-to-DTO mapping
|
||||
- **Entity Framework Core 8.0.11**: ORM and database access
|
||||
- **Serilog.AspNetCore 8.0.3**: Structured logging
|
||||
- **Microsoft.AspNetCore.Identity.UI 8.0.11**: Authentication
|
||||
- **Swashbuckle.AspNetCore 7.2.0**: API documentation (API project)
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Password requirements: 8+ chars, uppercase, lowercase, digit
|
||||
- HTTPS enforced in production
|
||||
- SQL injection prevented by EF Core parameterization
|
||||
- XSS protection via Razor encoding
|
||||
- CSRF tokens on all forms (automatic with ASP.NET Core)
|
||||
- Sensitive settings (connection strings, API keys) should use User Secrets in development and Azure Key Vault in production
|
||||
|
||||
## Active design work
|
||||
A visual redesign is in progress. If the user asks about UI changes, dashboard/jobs/board styling, or the new design tokens, read `design_handoff_pcl_redesign/README.md` and follow `design_handoff_pcl_redesign/AGENTS.md` for that work.
|
||||
@@ -478,6 +478,27 @@ All modules below are fully implemented with controllers, views, and migrations
|
||||
- In-stock inventory powder: charge for calculated usage only (surface area × lbs/sqft × unit cost)
|
||||
- Tax exempt customers (`Customer.IsTaxExempt`): `TaxPercent` defaults to 0 on quote and invoice create; customer dropdown marks exempt customers with ★
|
||||
|
||||
### Pricing Routing Flags — Must Stay In Sync Across All Three Layers
|
||||
|
||||
`PricingCalculationService.CalculateQuoteItemPriceAsync` routes each item to the correct pricing path using boolean flags. **These flags MUST exist identically on `QuoteItem`, `JobItem`, and `CreateQuoteItemDto`, AND be mapped in all three `JobItemAssemblyService.CreateJobItem` overloads.**
|
||||
|
||||
| Flag | Effect if missing on JobItem |
|
||||
|------|------------------------------|
|
||||
| `IsAiItem` | Job repriced as calculated item; oven cost double-charged on every save |
|
||||
| `IsGenericItem` | ManualUnitPrice ignored; price recalculated from surface area |
|
||||
| `IsLaborItem` | Item repriced at surface-area rate instead of hours × labor rate |
|
||||
| `IsSalesItem` | ManualUnitPrice ignored; item repriced using coat/surface math |
|
||||
|
||||
**Checklist when adding a new pricing routing flag:**
|
||||
1. Add the property to `QuoteItem` (Core/Entities)
|
||||
2. Add the property to `JobItem` (Core/Entities)
|
||||
3. Add it to `CreateQuoteItemDto` (Application/DTOs)
|
||||
4. Add it to `JobItemSeed` (private class in JobItemAssemblyService)
|
||||
5. Map it in all three `JobItemAssemblyService.CreateJobItem` overloads
|
||||
6. Include it in every `existingItemsData` JSON block in job views (`Edit.cshtml`, `EditItems.cshtml`) and in all job controller actions that build `CreateQuoteItemDto` from a `JobItem`
|
||||
7. Add a migration if the field is new on a persisted entity
|
||||
8. The structural test `PricingRoutingFlags_ExistOnBothQuoteItemAndJobItem` in `JobItemAssemblyServiceTests` will fail until steps 1–3 are done — this is intentional
|
||||
|
||||
### Branding
|
||||
- Application name: **Powder Coating Logix**
|
||||
- PCL logo: `wwwroot/images/pcl-logo.png` — used in sidebar header (when no tenant logo), login/register pages, sidebar footer
|
||||
|
||||
@@ -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!
|
||||
@@ -112,6 +112,7 @@ namespace PowderCoating.Application.DTOs.Company
|
||||
|
||||
// Labor Rates
|
||||
public decimal StandardLaborRate { get; set; }
|
||||
public decimal? LaborCostPerHour { get; set; }
|
||||
public decimal AdditionalCoatLaborPercent { get; set; }
|
||||
|
||||
// Equipment Operating Costs
|
||||
@@ -185,6 +186,10 @@ namespace PowderCoating.Application.DTOs.Company
|
||||
[Display(Name = "Standard Labor Rate ($/hr)")]
|
||||
public decimal StandardLaborRate { get; set; }
|
||||
|
||||
[Range(0, 10000, ErrorMessage = "Labor cost rate must be between 0 and 10,000")]
|
||||
[Display(Name = "Shop Labor Cost Rate ($/hr)")]
|
||||
public decimal? LaborCostPerHour { get; set; }
|
||||
|
||||
[Range(0, 100, ErrorMessage = "Additional coat labor percent must be between 0 and 100")]
|
||||
[Display(Name = "Additional Coat Labor (%)")]
|
||||
public decimal AdditionalCoatLaborPercent { get; set; } = 30m;
|
||||
|
||||
@@ -20,7 +20,7 @@ public class EquipmentDto
|
||||
public string StatusDisplay { get; set; } = string.Empty;
|
||||
public string? Location { get; set; }
|
||||
|
||||
public int RecommendedMaintenanceIntervalDays { get; set; }
|
||||
public int? RecommendedMaintenanceIntervalDays { get; set; }
|
||||
public DateTime? LastMaintenanceDate { get; set; }
|
||||
public DateTime? NextScheduledMaintenance { get; set; }
|
||||
public int? DaysUntilMaintenance { get; set; }
|
||||
@@ -101,7 +101,7 @@ public class CreateEquipmentDto
|
||||
|
||||
[Range(1, 3650, ErrorMessage = "Maintenance interval must be between 1 and 3650 days")]
|
||||
[Display(Name = "Recommended Maintenance Interval (Days)")]
|
||||
public int RecommendedMaintenanceIntervalDays { get; set; }
|
||||
public int? RecommendedMaintenanceIntervalDays { get; set; }
|
||||
|
||||
[Display(Name = "Last Maintenance Date")]
|
||||
public DateTime? LastMaintenanceDate { get; set; }
|
||||
|
||||
@@ -16,6 +16,7 @@ public class GiftCertificateListDto
|
||||
public GiftCertificateStatus Status { get; set; }
|
||||
public DateTime IssueDate { get; set; }
|
||||
public DateTime? ExpiryDate { get; set; }
|
||||
public Guid? BatchId { get; set; }
|
||||
}
|
||||
|
||||
public class GiftCertificateDto : GiftCertificateListDto
|
||||
@@ -87,3 +88,27 @@ public class RedeemGiftCertificateDto
|
||||
[Range(0.01, 9999.99)]
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
|
||||
public class BulkCreateGiftCertificateDto
|
||||
{
|
||||
[Required]
|
||||
[Range(1, 500, ErrorMessage = "Quantity must be between 1 and 500.")]
|
||||
[Display(Name = "Number of Certificates")]
|
||||
public int Quantity { get; set; } = 25;
|
||||
|
||||
[Required]
|
||||
[Range(1.00, 9999.99, ErrorMessage = "Amount must be between $1.00 and $9,999.99.")]
|
||||
[Display(Name = "Face Value (each)")]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
[Required]
|
||||
[Display(Name = "Issued Reason")]
|
||||
public GiftCertificateIssuedReason IssuedReason { get; set; } = GiftCertificateIssuedReason.Promotional;
|
||||
|
||||
[Display(Name = "Expiry Date (optional)")]
|
||||
public DateTime? ExpiryDate { get; set; }
|
||||
|
||||
[StringLength(1000)]
|
||||
[Display(Name = "Event / Notes (applied to all certificates)")]
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Import;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for importing shop workers from CSV files.
|
||||
/// Valid Role values: GeneralLabor, Sandblaster, Coater, Masker, QualityControl, OvenOperator, Supervisor, Maintenance
|
||||
/// </summary>
|
||||
public class ShopWorkerImportDto
|
||||
{
|
||||
[Name("Name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[Name("Role")]
|
||||
public string Role { get; set; } = "GeneralLabor";
|
||||
|
||||
[Name("Phone")]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
[Name("Email")]
|
||||
public string? Email { get; set; }
|
||||
|
||||
[Name("IsActive")]
|
||||
public bool? IsActive { get; set; }
|
||||
|
||||
[Name("Notes")]
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
@@ -68,6 +68,7 @@ public class InventoryListDto
|
||||
public string? CategoryName { get; set; }
|
||||
public string Category { get; set; } = string.Empty; // Legacy field
|
||||
public string? ColorName { get; set; }
|
||||
public string? Location { get; set; }
|
||||
public decimal QuantityOnHand { get; set; }
|
||||
public string UnitOfMeasure { get; set; } = "lbs";
|
||||
public decimal ReorderPoint { get; set; }
|
||||
|
||||
@@ -33,6 +33,10 @@ public class InvoiceDto
|
||||
public string? CustomerEmail { get; set; }
|
||||
public string? CustomerPhone { get; set; }
|
||||
public string? CustomerMobilePhone { get; set; }
|
||||
public string? CustomerAddress { get; set; }
|
||||
public string? CustomerCity { get; set; }
|
||||
public string? CustomerState { get; set; }
|
||||
public string? CustomerZipCode { get; set; }
|
||||
public bool CustomerNotifyByEmail { get; set; }
|
||||
public bool CustomerNotifyBySms { get; set; }
|
||||
public string? PreparedById { get; set; }
|
||||
|
||||
@@ -137,6 +137,13 @@ public class CreateJobDto
|
||||
[Display(Name = "Oven")]
|
||||
public int? OvenCostId { get; set; }
|
||||
|
||||
[Display(Name = "Batches")]
|
||||
[Range(1, 999)]
|
||||
public int OvenBatches { get; set; } = 1;
|
||||
|
||||
[Display(Name = "Cycle Time (min)")]
|
||||
public int? OvenCycleMinutes { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "Description is required")]
|
||||
[StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")]
|
||||
[Display(Name = "Description")]
|
||||
@@ -208,6 +215,16 @@ public class UpdateJobDto
|
||||
[Display(Name = "Assigned Worker")]
|
||||
public string? AssignedUserId { get; set; }
|
||||
|
||||
[Display(Name = "Oven")]
|
||||
public int? OvenCostId { get; set; }
|
||||
|
||||
[Display(Name = "Batches")]
|
||||
[Range(1, 999)]
|
||||
public int OvenBatches { get; set; } = 1;
|
||||
|
||||
[Display(Name = "Cycle Time (min)")]
|
||||
public int? OvenCycleMinutes { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "Description is required")]
|
||||
[StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")]
|
||||
[Display(Name = "Description")]
|
||||
@@ -381,6 +398,7 @@ public class JobItemCoatDto
|
||||
public decimal? PowderCostPerLb { get; set; }
|
||||
public decimal? PowderToOrder { get; set; }
|
||||
public decimal? ActualPowderUsedLbs { get; set; } // Filled during job completion
|
||||
public bool NoExtraLayerCharge { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
@@ -389,7 +407,7 @@ public class CompleteJobDto
|
||||
{
|
||||
public int JobId { get; set; }
|
||||
public decimal? ActualTimeSpentHours { get; set; }
|
||||
public List<JobItemCoatUsageDto> CoatUsages { get; set; } = new();
|
||||
public List<JobPowderUsageDto> PowderUsages { get; set; } = new();
|
||||
public bool SendEmailToCustomer { get; set; } = false;
|
||||
}
|
||||
|
||||
@@ -400,10 +418,10 @@ public class SendJobSmsRequest
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// DTO for tracking actual powder usage per coat
|
||||
public class JobItemCoatUsageDto
|
||||
// DTO for tracking actual powder usage per inventory item (color) for the whole job
|
||||
public class JobPowderUsageDto
|
||||
{
|
||||
public int JobItemCoatId { get; set; }
|
||||
public int InventoryItemId { get; set; }
|
||||
public decimal? ActualPowderUsedLbs { get; set; }
|
||||
}
|
||||
|
||||
@@ -468,6 +486,7 @@ public class ReworkRecordDto
|
||||
public decimal ActualReworkCost { get; set; }
|
||||
public bool IsBillableToCustomer { get; set; }
|
||||
public string? BillingNotes { get; set; }
|
||||
public PowderCoating.Core.Enums.ReworkPricingType? ReworkPricingType { get; set; }
|
||||
|
||||
public PowderCoating.Core.Enums.ReworkStatus Status { get; set; }
|
||||
public string StatusDisplay { get; set; } = string.Empty;
|
||||
@@ -493,6 +512,11 @@ public class CreateReworkRecordDto
|
||||
public decimal EstimatedReworkCost { get; set; }
|
||||
public bool IsBillableToCustomer { get; set; }
|
||||
public string? BillingNotes { get; set; }
|
||||
|
||||
// Rework job creation (opt-in)
|
||||
public bool CreateReworkJob { get; set; }
|
||||
public List<int>? ReworkJobItemIds { get; set; } // null = not creating a job
|
||||
public PowderCoating.Core.Enums.ReworkPricingType? ReworkPricingType { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateReworkRecordDto
|
||||
@@ -515,6 +539,9 @@ public class JobEditItemsViewModel
|
||||
public string JobNumber { get; set; } = string.Empty;
|
||||
public int? CustomerId { get; set; }
|
||||
public decimal TaxPercent { get; set; }
|
||||
public int? OvenCostId { get; set; }
|
||||
public int OvenBatches { get; set; } = 1;
|
||||
public int? OvenCycleMinutes { get; set; }
|
||||
public List<CreateQuoteItemDto> JobItems { get; set; } = new();
|
||||
}
|
||||
|
||||
|
||||
@@ -604,6 +604,11 @@ public class QuotePricingBreakdownDto
|
||||
|
||||
public decimal SubtotalBeforeDiscount { get; set; }
|
||||
|
||||
public decimal PricingTierDiscountAmount { get; set; }
|
||||
public decimal PricingTierDiscountPercent { get; set; }
|
||||
public decimal QuoteDiscountAmount { get; set; }
|
||||
public decimal QuoteDiscountPercent { get; set; }
|
||||
|
||||
public decimal DiscountAmount { get; set; }
|
||||
public decimal DiscountPercent { get; set; }
|
||||
|
||||
@@ -796,6 +801,7 @@ public class QuoteItemCoatDto
|
||||
public decimal CoatMaterialCost { get; set; }
|
||||
public decimal CoatLaborCost { get; set; }
|
||||
public decimal CoatTotalCost { get; set; }
|
||||
public bool NoExtraLayerCharge { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.ShopWorker;
|
||||
|
||||
public class CreateShopWorkerDto
|
||||
{
|
||||
[Required(ErrorMessage = "Worker name is required")]
|
||||
[StringLength(100, ErrorMessage = "Name cannot exceed 100 characters")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[Required(ErrorMessage = "Role is required")]
|
||||
public ShopWorkerRole Role { get; set; } = ShopWorkerRole.GeneralLabor;
|
||||
|
||||
[Phone(ErrorMessage = "Invalid phone number format")]
|
||||
[StringLength(20, ErrorMessage = "Phone cannot exceed 20 characters")]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
[EmailAddress(ErrorMessage = "Invalid email address format")]
|
||||
[StringLength(100, ErrorMessage = "Email cannot exceed 100 characters")]
|
||||
public string? Email { get; set; }
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
[StringLength(500, ErrorMessage = "Notes cannot exceed 500 characters")]
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.ShopWorker;
|
||||
|
||||
public class ShopWorkerDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public ShopWorkerRole Role { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.ShopWorker;
|
||||
|
||||
public class UpdateShopWorkerDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "Worker name is required")]
|
||||
[StringLength(100, ErrorMessage = "Name cannot exceed 100 characters")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[Required(ErrorMessage = "Role is required")]
|
||||
public ShopWorkerRole Role { get; set; }
|
||||
|
||||
[Phone(ErrorMessage = "Invalid phone number format")]
|
||||
[StringLength(20, ErrorMessage = "Phone cannot exceed 20 characters")]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
[EmailAddress(ErrorMessage = "Invalid email address format")]
|
||||
[StringLength(100, ErrorMessage = "Email cannot exceed 100 characters")]
|
||||
public string? Email { get; set; }
|
||||
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
[StringLength(500, ErrorMessage = "Notes cannot exceed 500 characters")]
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
@@ -217,6 +217,10 @@ public class UpdateCompanyUserDto
|
||||
[Display(Name = "Active")]
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
[Range(0, 10000, ErrorMessage = "Labor cost rate must be between 0 and 10,000")]
|
||||
[Display(Name = "Labor Cost Rate ($/hr)")]
|
||||
public decimal? LaborCostPerHour { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "Hire date is required")]
|
||||
[Display(Name = "Hire Date")]
|
||||
public DateTime HireDate { get; set; }
|
||||
|
||||
@@ -125,6 +125,8 @@ public class CreateVendorDto
|
||||
|
||||
[Display(Name = "Default Expense Account")]
|
||||
public int? DefaultExpenseAccountId { get; set; }
|
||||
|
||||
public List<int> CategoryIds { get; set; } = new();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -209,4 +211,6 @@ public class UpdateVendorDto
|
||||
|
||||
[Display(Name = "Default Expense Account")]
|
||||
public int? DefaultExpenseAccountId { get; set; }
|
||||
|
||||
public List<int> CategoryIds { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -136,18 +136,7 @@ public interface ICsvImportService
|
||||
/// </summary>
|
||||
Task<CsvImportResultDto> ImportVendorsAsync(Stream csvStream, int companyId);
|
||||
|
||||
/// <summary>
|
||||
/// Generate a CSV template file for shop worker imports.
|
||||
/// </summary>
|
||||
byte[] GenerateShopWorkerTemplate();
|
||||
|
||||
/// <summary>
|
||||
/// Import shop workers from a CSV stream.
|
||||
/// Updates existing workers matched by Name; creates new ones otherwise.
|
||||
/// </summary>
|
||||
Task<CsvImportResultDto> ImportShopWorkersAsync(Stream csvStream, int companyId);
|
||||
|
||||
/// <summary>
|
||||
/// <summary>
|
||||
/// Generate a CSV template file for prep service imports.
|
||||
/// </summary>
|
||||
byte[] GeneratePrepServiceTemplate();
|
||||
|
||||
@@ -91,4 +91,11 @@ public interface INotificationService
|
||||
/// Alert company staff when a Stripe chargeback (dispute) is opened on an invoice payment.
|
||||
/// </summary>
|
||||
Task NotifyChargebackAlertAsync(Invoice invoice, string disputeId, decimal amount, string reason);
|
||||
|
||||
/// <summary>
|
||||
/// Sends an appointment reminder email to the linked customer (if opted in) and writes a
|
||||
/// notification log row. Called by <see cref="PowderCoating.Web.BackgroundServices.AppointmentReminderBackgroundService"/>
|
||||
/// when the reminder window opens. In-app bell notification is handled by the caller.
|
||||
/// </summary>
|
||||
Task NotifyAppointmentReminderAsync(Appointment appointment);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,12 @@ public interface IPdfService
|
||||
CompanyInfoDto companyInfo,
|
||||
QuoteTemplateSettingsDto? template = null);
|
||||
|
||||
Task<byte[]> GeneratePackingSlipPdfAsync(
|
||||
InvoiceDto invoiceDto,
|
||||
byte[]? companyLogo,
|
||||
string? companyLogoContentType,
|
||||
CompanyInfoDto companyInfo);
|
||||
|
||||
Task<byte[]> GeneratePurchaseOrderPdfAsync(
|
||||
PurchaseOrderDto po,
|
||||
byte[]? companyLogo,
|
||||
@@ -51,4 +57,10 @@ public interface IPdfService
|
||||
byte[]? companyLogo,
|
||||
string? companyLogoContentType,
|
||||
CompanyInfoDto companyInfo);
|
||||
|
||||
Task<byte[]> GenerateBulkGiftCertificatePdfAsync(
|
||||
IList<GiftCertificateDto> certs,
|
||||
byte[]? companyLogo,
|
||||
string? companyLogoContentType,
|
||||
CompanyInfoDto companyInfo);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ public interface IStripeConnectService
|
||||
decimal invoiceTotal,
|
||||
decimal surchargeAmount,
|
||||
string currency,
|
||||
string customerEmail,
|
||||
string invoiceNumber,
|
||||
int invoiceId);
|
||||
|
||||
@@ -33,7 +32,6 @@ public interface IStripeConnectService
|
||||
decimal depositAmount,
|
||||
decimal surchargeAmount,
|
||||
string currency,
|
||||
string customerEmail,
|
||||
string quoteNumber,
|
||||
int quoteId);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,10 @@ public class InvoiceProfile : Profile
|
||||
: null))
|
||||
.ForMember(d => d.CustomerPhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.Phone : null))
|
||||
.ForMember(d => d.CustomerMobilePhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.MobilePhone : null))
|
||||
.ForMember(d => d.CustomerAddress, o => o.MapFrom(s => s.Customer != null ? s.Customer.Address : null))
|
||||
.ForMember(d => d.CustomerCity, o => o.MapFrom(s => s.Customer != null ? s.Customer.City : null))
|
||||
.ForMember(d => d.CustomerState, o => o.MapFrom(s => s.Customer != null ? s.Customer.State : null))
|
||||
.ForMember(d => d.CustomerZipCode, o => o.MapFrom(s => s.Customer != null ? s.Customer.ZipCode : null))
|
||||
.ForMember(d => d.CustomerNotifyByEmail, o => o.MapFrom(s => s.Customer == null || s.Customer.NotifyByEmail))
|
||||
.ForMember(d => d.CustomerNotifyBySms, o => o.MapFrom(s => s.Customer != null && s.Customer.NotifyBySms))
|
||||
.ForMember(d => d.PreparedByName, o => o.MapFrom(s => s.PreparedBy != null
|
||||
|
||||
@@ -73,7 +73,7 @@ public class JobProfile : Profile
|
||||
// JobTimeEntry → JobTimeEntryDto
|
||||
CreateMap<JobTimeEntry, JobTimeEntryDto>()
|
||||
.ForMember(dest => dest.WorkerName, opt => opt.MapFrom(src =>
|
||||
src.UserDisplayName ?? (src.Worker != null ? src.Worker.Name : string.Empty)));
|
||||
src.UserDisplayName ?? string.Empty));
|
||||
|
||||
// CreateJobDto to Job
|
||||
CreateMap<CreateJobDto, Job>()
|
||||
@@ -196,7 +196,9 @@ public class JobProfile : Profile
|
||||
.ForMember(dest => dest.JobItemDescription,
|
||||
opt => opt.MapFrom(src => src.JobItem != null ? src.JobItem.Description : null))
|
||||
.ForMember(dest => dest.ReworkJobNumber,
|
||||
opt => opt.MapFrom(src => src.ReworkJob != null ? src.ReworkJob.JobNumber : null));
|
||||
opt => opt.MapFrom(src => src.ReworkJob != null ? src.ReworkJob.JobNumber : null))
|
||||
.ForMember(dest => dest.ReworkPricingType,
|
||||
opt => opt.MapFrom(src => src.ReworkPricingType));
|
||||
|
||||
// Job → JobDto (rework fields)
|
||||
// (IsReworkJob and OriginalJobId map by convention; OriginalJobNumber needs explicit map — handled in controller)
|
||||
|
||||
@@ -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>();
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,26 @@ using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Converts quote/job data into persisted <see cref="JobItem"/>, <see cref="JobItemCoat"/>,
|
||||
/// and <see cref="JobItemPrepService"/> entities.
|
||||
///
|
||||
/// Three source types are supported, each with a matching overload:
|
||||
/// 1. <see cref="CreateQuoteItemDto"/> — quote wizard (new job from form data + fresh pricing result)
|
||||
/// 2. <see cref="QuoteItem"/> — quote-to-job conversion (copies a saved quote line)
|
||||
/// 3. <see cref="JobItem"/> — job duplication / template instantiation (copies an existing job line)
|
||||
///
|
||||
/// The private <see cref="JobItemSeed"/> / <see cref="JobItemCoatSeed"/> / <see cref="JobItemPrepServiceSeed"/>
|
||||
/// intermediary classes exist solely to give all three overload paths a single <see cref="BuildJobItem"/>
|
||||
/// construction site — avoiding subtle copy-paste drift where one overload forgets to copy a new field.
|
||||
/// </summary>
|
||||
public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a <see cref="JobItem"/> from a quote wizard DTO and a pre-calculated pricing result.
|
||||
/// Used when creating a job directly from the job form or from an approved quote via the wizard.
|
||||
/// Pricing is passed in separately because it was already computed upstream (CalculateQuoteItemPriceAsync).
|
||||
/// </summary>
|
||||
public JobItem CreateJobItem(CreateQuoteItemDto source, int jobId, int companyId, QuoteItemPricingResult pricing, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
@@ -21,12 +39,13 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
IsGenericItem = source.IsGenericItem,
|
||||
IsLaborItem = source.IsLaborItem,
|
||||
IsSalesItem = source.IsSalesItem,
|
||||
IsAiItem = source.IsAiItem,
|
||||
Sku = source.Sku,
|
||||
ManualUnitPrice = source.ManualUnitPrice,
|
||||
PowderCostOverride = source.PowderCostOverride,
|
||||
UnitPrice = pricing.UnitPrice,
|
||||
TotalPrice = pricing.TotalPrice,
|
||||
LaborCost = pricing.TotalPrice * 0.4m,
|
||||
LaborCost = pricing.LaborCost,
|
||||
RequiresSandblasting = source.RequiresSandblasting,
|
||||
RequiresMasking = source.RequiresMasking,
|
||||
EstimatedMinutes = source.EstimatedMinutes,
|
||||
@@ -41,6 +60,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds <see cref="JobItemCoat"/> records from the coat DTOs in the quote wizard form.
|
||||
/// PowderToOrder is recalculated server-side here (not trusted from the form) using surface area,
|
||||
/// quantity, coverage, and transfer efficiency — the wizard's displayed value is for UI only.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
@@ -61,7 +85,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
|
||||
Notes = c.Notes
|
||||
Notes = c.Notes,
|
||||
NoExtraLayerCharge = c.NoExtraLayerCharge
|
||||
},
|
||||
jobItemId,
|
||||
companyId,
|
||||
@@ -69,6 +94,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds <see cref="JobItemPrepService"/> records (sandblasting, masking, etc.) from the
|
||||
/// quote wizard DTO. These are per-item prep steps with individual time estimates that feed
|
||||
/// labor cost calculations and shop floor instructions.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
@@ -84,6 +114,13 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="JobItem"/> by copying a saved <see cref="QuoteItem"/> during quote-to-job conversion.
|
||||
/// Prices are taken directly from the quote snapshot — no repricing occurs — so the job starts with
|
||||
/// exactly the amounts that were approved by the customer.
|
||||
/// The first coat's color/finish is promoted to the job item's top-level fields for quick display
|
||||
/// (details remain in the coat records).
|
||||
/// </summary>
|
||||
public JobItem CreateJobItem(QuoteItem source, int jobId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
@@ -106,12 +143,13 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
IsGenericItem = source.IsGenericItem,
|
||||
IsLaborItem = source.IsLaborItem,
|
||||
IsSalesItem = source.IsSalesItem,
|
||||
IsAiItem = source.IsAiItem,
|
||||
Sku = source.Sku,
|
||||
ManualUnitPrice = source.ManualUnitPrice,
|
||||
PowderCostOverride = source.PowderCostOverride,
|
||||
UnitPrice = source.UnitPrice,
|
||||
TotalPrice = source.TotalPrice,
|
||||
LaborCost = source.TotalPrice * 0.4m,
|
||||
LaborCost = source.ItemLaborCost,
|
||||
RequiresSandblasting = source.RequiresSandblasting,
|
||||
RequiresMasking = source.RequiresMasking,
|
||||
EstimatedMinutes = source.EstimatedMinutes,
|
||||
@@ -126,6 +164,12 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds <see cref="JobItemCoat"/> records from a saved <see cref="QuoteItem"/> during quote-to-job conversion.
|
||||
/// Coat appearance (color name, code, finish) is resolved from the linked <see cref="InventoryItem"/> if available,
|
||||
/// because the inventory record is the canonical source of truth for a product's appearance —
|
||||
/// the values typed into the quote form may be incomplete or informal.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
@@ -149,7 +193,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
|
||||
Notes = c.Notes
|
||||
Notes = c.Notes,
|
||||
NoExtraLayerCharge = c.NoExtraLayerCharge
|
||||
},
|
||||
jobItemId,
|
||||
companyId,
|
||||
@@ -158,6 +203,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies prep service records from a <see cref="QuoteItem"/> to a new job item during quote-to-job conversion.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
@@ -173,6 +221,12 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="JobItem"/> by cloning an existing one — used for job templates
|
||||
/// and rework duplication where an existing job line is reused on a new job.
|
||||
/// Prices are copied as-is from the source; the job controller is responsible for repricing
|
||||
/// if operating costs have changed since the original job was created.
|
||||
/// </summary>
|
||||
public JobItem CreateJobItem(JobItem source, int jobId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
@@ -191,6 +245,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
IsGenericItem = source.IsGenericItem,
|
||||
IsLaborItem = source.IsLaborItem,
|
||||
IsSalesItem = source.IsSalesItem,
|
||||
IsAiItem = source.IsAiItem,
|
||||
Sku = source.Sku,
|
||||
ManualUnitPrice = source.ManualUnitPrice,
|
||||
PowderCostOverride = source.PowderCostOverride,
|
||||
@@ -211,6 +266,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clones coat records from an existing <see cref="JobItem"/> onto a new job item.
|
||||
/// PowderToOrder is copied verbatim (not recalculated) because the original job's powder
|
||||
/// quantities may have been manually adjusted after initial calculation.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
@@ -231,7 +291,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
PowderToOrder = c.PowderToOrder,
|
||||
Notes = c.Notes
|
||||
Notes = c.Notes,
|
||||
NoExtraLayerCharge = c.NoExtraLayerCharge
|
||||
},
|
||||
jobItemId,
|
||||
companyId,
|
||||
@@ -239,6 +300,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clones prep service records from an existing <see cref="JobItem"/> onto a new job item.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
@@ -254,6 +318,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single construction point for all <see cref="JobItem"/> creation paths.
|
||||
/// Centralised here so that adding a new field only requires one code change, not three.
|
||||
/// </summary>
|
||||
private static JobItem BuildJobItem(JobItemSeed seed, int jobId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return new JobItem
|
||||
@@ -270,6 +338,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
IsGenericItem = seed.IsGenericItem,
|
||||
IsLaborItem = seed.IsLaborItem,
|
||||
IsSalesItem = seed.IsSalesItem,
|
||||
IsAiItem = seed.IsAiItem,
|
||||
Sku = seed.Sku,
|
||||
ManualUnitPrice = seed.ManualUnitPrice,
|
||||
PowderCostOverride = seed.PowderCostOverride,
|
||||
@@ -289,6 +358,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single construction point for all <see cref="JobItemCoat"/> creation paths.
|
||||
/// </summary>
|
||||
private static JobItemCoat BuildJobItemCoat(JobItemCoatSeed seed, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return new JobItemCoat
|
||||
@@ -306,11 +378,17 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
PowderCostPerLb = seed.PowderCostPerLb,
|
||||
PowderToOrder = seed.PowderToOrder,
|
||||
Notes = seed.Notes,
|
||||
NoExtraLayerCharge = seed.NoExtraLayerCharge,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single construction point for all <see cref="JobItemPrepService"/> creation paths.
|
||||
/// Returns an empty list (not null) when <paramref name="seeds"/> is null so callers
|
||||
/// can safely iterate without a null check.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<JobItemPrepService> BuildJobItemPrepServices(IEnumerable<JobItemPrepServiceSeed>? seeds, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return seeds?
|
||||
@@ -326,6 +404,18 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the pounds of powder needed to coat a batch, preferring the pre-stored value
|
||||
/// (which the user may have manually adjusted in the wizard) over a fresh recalculation.
|
||||
///
|
||||
/// Formula: (surfaceAreaSqFt × quantity) ÷ (coverageSqFtPerLb × transferEfficiency)
|
||||
///
|
||||
/// Industry defaults are applied when catalog data is missing:
|
||||
/// - Coverage: 30 sqft/lb (typical for standard powder at 2–3 mil DFT)
|
||||
/// - Transfer efficiency: 65% (industry average for electrostatic spray)
|
||||
/// These are conservative defaults that slightly overestimate powder needed — intentional,
|
||||
/// so the shop doesn't run short on a job.
|
||||
/// </summary>
|
||||
private static decimal? CalculatePowderToOrder(decimal? storedPowderToOrder, decimal surfaceAreaSqFt, decimal quantity, decimal coverageSqFtPerLb, decimal transferEfficiency)
|
||||
{
|
||||
if (storedPowderToOrder.HasValue && storedPowderToOrder.Value > 0)
|
||||
@@ -339,6 +429,12 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
return Math.Round((surfaceAreaSqFt * quantity) / (coverage * efficiency), 2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the display appearance (color name, code, finish) for a coat, preferring the linked
|
||||
/// <see cref="InventoryItem"/>'s values over whatever was typed into the quote form.
|
||||
/// The inventory record is the canonical source of truth — the form values are used as a fallback
|
||||
/// only when no inventory item is linked (e.g. custom/one-off powder).
|
||||
/// </summary>
|
||||
private static (string? ColorName, string? ColorCode, string? Finish) ResolveCoatAppearance(
|
||||
string? colorName,
|
||||
string? colorCode,
|
||||
@@ -351,6 +447,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
return (inventoryItem.Name, inventoryItem.ColorCode, inventoryItem.Finish);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Intermediate value object that normalises the three different source types
|
||||
/// (DTO, QuoteItem, JobItem) into a single shape before the shared BuildJobItem factory method.
|
||||
/// Using a seed class prevents subtle bugs where an overload forgets to map a new field.
|
||||
/// </summary>
|
||||
private sealed class JobItemSeed
|
||||
{
|
||||
public string Description { get; init; } = string.Empty;
|
||||
@@ -364,6 +465,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
public bool IsGenericItem { get; init; }
|
||||
public bool IsLaborItem { get; init; }
|
||||
public bool IsSalesItem { get; init; }
|
||||
public bool IsAiItem { get; init; }
|
||||
public string? Sku { get; init; }
|
||||
public decimal? ManualUnitPrice { get; init; }
|
||||
public decimal? PowderCostOverride { get; init; }
|
||||
@@ -380,6 +482,7 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
public int? AiPredictionId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Intermediate value object for coat creation — see <see cref="JobItemSeed"/> for rationale.</summary>
|
||||
private sealed class JobItemCoatSeed
|
||||
{
|
||||
public string CoatName { get; init; } = string.Empty;
|
||||
@@ -394,8 +497,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
public decimal? PowderCostPerLb { get; init; }
|
||||
public decimal? PowderToOrder { get; init; }
|
||||
public string? Notes { get; init; }
|
||||
public bool NoExtraLayerCharge { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Intermediate value object for prep service creation — see <see cref="JobItemSeed"/> for rationale.</summary>
|
||||
private sealed class JobItemPrepServiceSeed
|
||||
{
|
||||
public int PrepServiceId { get; init; }
|
||||
|
||||
@@ -1858,6 +1858,50 @@ public class PdfService : IPdfService
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a multi-page PDF containing one gift certificate per page, all using the same
|
||||
/// branded layout as the single-certificate download. Used for bulk print runs (car shows,
|
||||
/// promotions) so staff can hand-cut and distribute a full batch from one print job.
|
||||
/// </summary>
|
||||
public async Task<byte[]> GenerateBulkGiftCertificatePdfAsync(
|
||||
IList<GiftCertificateDto> certs,
|
||||
byte[]? companyLogo,
|
||||
string? companyLogoContentType,
|
||||
CompanyInfoDto companyInfo)
|
||||
{
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
const string accent = "#7c3aed";
|
||||
const string gold = "#b45309";
|
||||
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
var doc = Document.Create(container =>
|
||||
{
|
||||
foreach (var cert in certs)
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.Letter);
|
||||
page.Margin(0.75f, Unit.Inch);
|
||||
page.PageColor(Colors.White);
|
||||
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
|
||||
|
||||
page.Content().Element(c => ComposeGiftCertificateContent(c, cert, companyInfo, companyLogo, accent, gold));
|
||||
|
||||
page.Footer().AlignCenter().Text(text =>
|
||||
{
|
||||
text.Span(companyInfo.CompanyName).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
|
||||
text.Span($" · {FormatPhoneNumber(companyInfo.Phone)}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return doc.GeneratePdf();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composes the gift certificate body with a decorative double-border frame (outer purple 3pt,
|
||||
/// inner gold 1pt) that gives the document a premium printed-certificate appearance. Inside the
|
||||
@@ -2709,4 +2753,187 @@ public class PdfService : IPdfService
|
||||
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Packing Slip
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Generates a no-price packing slip PDF for the given invoice. Lists job items with
|
||||
/// description, color, and quantity only — no unit prices or totals. Intended for
|
||||
/// physical pickup/delivery paperwork where pricing should not be visible.
|
||||
/// </summary>
|
||||
public async Task<byte[]> GeneratePackingSlipPdfAsync(
|
||||
InvoiceDto invoiceDto,
|
||||
byte[]? companyLogo,
|
||||
string? companyLogoContentType,
|
||||
CompanyInfoDto companyInfo)
|
||||
{
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
const string accentColor = "#1e40af"; // blue
|
||||
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
var document = Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.Letter);
|
||||
page.Margin(0.75f, Unit.Inch);
|
||||
page.PageColor(Colors.White);
|
||||
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
|
||||
|
||||
page.Header().Element(c => ComposePackingSlipHeader(c, companyLogo, companyInfo, accentColor, invoiceDto));
|
||||
page.Content().Element(c => ComposePackingSlipContent(c, invoiceDto, accentColor));
|
||||
page.Footer().AlignCenter().Text(text =>
|
||||
{
|
||||
text.Span("PACKING SLIP | ").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
text.Span(companyInfo.CompanyName).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
text.Span(" | Page ").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
text.CurrentPageNumber().FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
text.Span(" of ").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
text.TotalPages().FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return document.GeneratePdf();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Header for the packing slip: company branding left, "PACKING SLIP" title + invoice/date info right.
|
||||
/// </summary>
|
||||
private void ComposePackingSlipHeader(IContainer container, byte[]? companyLogo, CompanyInfoDto companyInfo, string accentColor, InvoiceDto invoice)
|
||||
{
|
||||
container.Column(col =>
|
||||
{
|
||||
col.Item().Row(row =>
|
||||
{
|
||||
row.RelativeItem().Column(column =>
|
||||
{
|
||||
if (companyLogo != null && companyLogo.Length > 0)
|
||||
column.Item().MaxHeight(60).Image(companyLogo);
|
||||
else
|
||||
column.Item().Text(companyInfo.CompanyName).FontSize(18).Bold().FontColor(accentColor);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(companyInfo.Address))
|
||||
column.Item().Text(companyInfo.Address).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
var cityLine = $"{companyInfo.City}{(!string.IsNullOrEmpty(companyInfo.City) && !string.IsNullOrEmpty(companyInfo.State) ? ", " : "")}{companyInfo.State} {companyInfo.ZipCode}".Trim();
|
||||
if (!string.IsNullOrWhiteSpace(cityLine))
|
||||
column.Item().Text(cityLine).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
|
||||
column.Item().Text(FormatPhoneNumber(companyInfo.Phone)).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
if (!string.IsNullOrWhiteSpace(companyInfo.PrimaryContactEmail))
|
||||
column.Item().Text(companyInfo.PrimaryContactEmail).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
|
||||
row.RelativeItem().AlignRight().Column(column =>
|
||||
{
|
||||
column.Item().Text("PACKING SLIP").FontSize(26).Bold().FontColor(accentColor);
|
||||
column.Item().Text($"Invoice #: {invoice.InvoiceNumber}").FontSize(9).Bold();
|
||||
column.Item().Text($"Date: {invoice.InvoiceDate:MMMM d, yyyy}").FontSize(9);
|
||||
if (!string.IsNullOrWhiteSpace(invoice.JobNumber))
|
||||
column.Item().Text($"Job #: {invoice.JobNumber}").FontSize(9);
|
||||
});
|
||||
});
|
||||
|
||||
col.Item().PaddingVertical(4).LineHorizontal(1).LineColor(accentColor);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Body of the packing slip: customer info block, optional PO number, and an items table
|
||||
/// showing description, color, and quantity — no prices.
|
||||
/// </summary>
|
||||
private void ComposePackingSlipContent(IContainer container, InvoiceDto invoice, string accentColor)
|
||||
{
|
||||
container.Column(col =>
|
||||
{
|
||||
// Customer info
|
||||
col.Item().PaddingTop(12).Row(row =>
|
||||
{
|
||||
row.RelativeItem().Column(c =>
|
||||
{
|
||||
c.Item().Text("PREPARED FOR").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
|
||||
c.Item().Text(invoice.CustomerName).Bold();
|
||||
if (!string.IsNullOrWhiteSpace(invoice.CustomerAddress))
|
||||
c.Item().Text(invoice.CustomerAddress).FontSize(9);
|
||||
var cityLine = $"{invoice.CustomerCity}{(!string.IsNullOrEmpty(invoice.CustomerCity) && !string.IsNullOrEmpty(invoice.CustomerState) ? ", " : "")}{invoice.CustomerState} {invoice.CustomerZipCode}".Trim();
|
||||
if (!string.IsNullOrWhiteSpace(cityLine))
|
||||
c.Item().Text(cityLine).FontSize(9);
|
||||
if (!string.IsNullOrWhiteSpace(invoice.CustomerPhone))
|
||||
c.Item().Text(FormatPhoneNumber(invoice.CustomerPhone)).FontSize(9);
|
||||
});
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(invoice.CustomerPO))
|
||||
{
|
||||
row.ConstantItem(160).AlignRight().Column(c =>
|
||||
{
|
||||
c.Item().Text("PURCHASE ORDER").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
|
||||
c.Item().Text(invoice.CustomerPO).Bold();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Items table
|
||||
col.Item().PaddingTop(16).Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(cols =>
|
||||
{
|
||||
cols.RelativeColumn(5);
|
||||
cols.RelativeColumn(3);
|
||||
cols.RelativeColumn(1);
|
||||
});
|
||||
|
||||
table.Header(h =>
|
||||
{
|
||||
h.Cell().Background(accentColor).Padding(5).Text("Description").FontColor(Colors.White).Bold().FontSize(9);
|
||||
h.Cell().Background(accentColor).Padding(5).Text("Color / Finish").FontColor(Colors.White).Bold().FontSize(9);
|
||||
h.Cell().Background(accentColor).Padding(5).AlignCenter().Text("Qty").FontColor(Colors.White).Bold().FontSize(9);
|
||||
});
|
||||
|
||||
var rowAlt = false;
|
||||
foreach (var item in invoice.InvoiceItems.OrderBy(i => i.DisplayOrder))
|
||||
{
|
||||
var bg = rowAlt ? Colors.Grey.Lighten4 : Colors.White;
|
||||
table.Cell().Background(bg).Padding(5).Column(c =>
|
||||
{
|
||||
c.Item().Text(item.Description).FontSize(9);
|
||||
if (!string.IsNullOrWhiteSpace(item.Notes))
|
||||
c.Item().Text(item.Notes).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
table.Cell().Background(bg).Padding(5).Text(item.ColorName ?? "—").FontSize(9);
|
||||
table.Cell().Background(bg).Padding(5).AlignCenter().Text(item.Quantity.ToString("G")).FontSize(9);
|
||||
rowAlt = !rowAlt;
|
||||
}
|
||||
});
|
||||
|
||||
// Notes (if any)
|
||||
if (!string.IsNullOrWhiteSpace(invoice.Notes))
|
||||
{
|
||||
col.Item().PaddingTop(16).Column(c =>
|
||||
{
|
||||
c.Item().Text("Notes").Bold().FontSize(9);
|
||||
c.Item().Text(invoice.Notes).FontSize(9).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
}
|
||||
|
||||
// Received by signature line
|
||||
col.Item().PaddingTop(32).Row(row =>
|
||||
{
|
||||
row.RelativeItem().Column(c =>
|
||||
{
|
||||
c.Item().BorderBottom(1).BorderColor(Colors.Grey.Darken1).PaddingBottom(2).Text(string.Empty);
|
||||
c.Item().PaddingTop(2).Text("Received by / Date").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
row.ConstantItem(24);
|
||||
row.RelativeItem().Column(c =>
|
||||
{
|
||||
c.Item().BorderBottom(1).BorderColor(Colors.Grey.Darken1).PaddingBottom(2).Text(string.Empty);
|
||||
c.Item().PaddingTop(2).Text("Condition noted").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,20 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates the full quote item assembly pipeline: pricing calculation, entity construction,
|
||||
/// AI prediction tracking, and automatic inventory record creation for incoming powder orders.
|
||||
///
|
||||
/// This service sits above <see cref="PricingCalculationService"/> — it knows HOW to build and
|
||||
/// persist quote entities, while PricingCalculationService knows HOW to compute dollar amounts.
|
||||
/// Keeping them separate means pricing logic can be unit-tested without any entity construction concerns.
|
||||
///
|
||||
/// Key responsibilities:
|
||||
/// - <see cref="ApplyPricingSnapshot"/> — stamps calculated totals onto the Quote entity so the
|
||||
/// displayed price is frozen at quote time and won't change if operating costs are updated later.
|
||||
/// - <see cref="CreateQuoteItemsAsync"/> — builds QuoteItem + coats + prep services for each DTO,
|
||||
/// records AI prediction overrides, and auto-creates incoming inventory records when needed.
|
||||
/// </summary>
|
||||
public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
@@ -25,30 +39,48 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the calculated pricing breakdown onto the <see cref="Quote"/> entity as a snapshot.
|
||||
/// Snapshots are critical: once a quote is sent to a customer, operating cost changes must NOT
|
||||
/// silently alter the quoted amounts — the snapshot preserves what was presented at the time.
|
||||
/// </summary>
|
||||
public void ApplyPricingSnapshot(Quote quote, QuotePricingResult pricingResult)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(quote);
|
||||
ArgumentNullException.ThrowIfNull(pricingResult);
|
||||
|
||||
quote.MaterialCosts = pricingResult.MaterialCosts;
|
||||
quote.LaborCosts = pricingResult.LaborCosts;
|
||||
quote.EquipmentCosts = pricingResult.EquipmentCosts;
|
||||
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
|
||||
quote.OvenBatchCost = pricingResult.OvenBatchCost;
|
||||
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
|
||||
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
|
||||
quote.OverheadAmount = pricingResult.OverheadCosts;
|
||||
quote.OverheadPercent = pricingResult.OverheadPercent;
|
||||
quote.ProfitMargin = pricingResult.ProfitMargin;
|
||||
quote.ProfitPercent = pricingResult.ProfitPercent;
|
||||
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
|
||||
quote.DiscountPercent = pricingResult.DiscountPercent;
|
||||
quote.DiscountAmount = pricingResult.DiscountAmount;
|
||||
quote.RushFee = pricingResult.RushFee;
|
||||
quote.TaxAmount = pricingResult.TaxAmount;
|
||||
quote.Total = pricingResult.Total;
|
||||
quote.MaterialCosts = pricingResult.MaterialCosts;
|
||||
quote.LaborCosts = pricingResult.LaborCosts;
|
||||
quote.EquipmentCosts = pricingResult.EquipmentCosts;
|
||||
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
|
||||
quote.OvenBatchCost = pricingResult.OvenBatchCost;
|
||||
quote.FacilityOverheadCost = pricingResult.FacilityOverheadCost;
|
||||
quote.FacilityOverheadRatePerHour = pricingResult.FacilityOverheadRatePerHour;
|
||||
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
|
||||
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
|
||||
quote.OverheadAmount = pricingResult.OverheadCosts;
|
||||
quote.OverheadPercent = pricingResult.OverheadPercent;
|
||||
quote.ProfitMargin = pricingResult.ProfitMargin;
|
||||
quote.ProfitPercent = pricingResult.ProfitPercent;
|
||||
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
|
||||
quote.PricingTierDiscountAmount = pricingResult.PricingTierDiscountAmount;
|
||||
quote.PricingTierDiscountPercent = pricingResult.PricingTierDiscountPercent;
|
||||
quote.QuoteDiscountAmount = pricingResult.QuoteDiscountAmount;
|
||||
quote.QuoteDiscountPercent = pricingResult.QuoteDiscountPercent;
|
||||
quote.DiscountPercent = pricingResult.DiscountPercent;
|
||||
quote.DiscountAmount = pricingResult.DiscountAmount;
|
||||
quote.SubtotalAfterDiscount = pricingResult.SubtotalAfterDiscount;
|
||||
quote.RushFee = pricingResult.RushFee;
|
||||
quote.TaxAmount = pricingResult.TaxAmount;
|
||||
quote.Total = pricingResult.Total;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds and prices all <see cref="QuoteItem"/> entities from the incoming DTOs.
|
||||
/// For each item: constructs the entity, calculates pricing, records whether the user overrode
|
||||
/// an AI estimate, then attaches coats (including auto-creating incoming inventory entries when
|
||||
/// the user selects a catalog powder not yet in their inventory) and prep services.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
|
||||
IEnumerable<CreateQuoteItemDto> itemDtos,
|
||||
int quoteId,
|
||||
@@ -73,6 +105,13 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
return items;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes a single item to the correct pricing path and stamps the result onto the entity.
|
||||
/// Priority order matches the routing table in <see cref="PricingCalculationService.CalculateQuoteItemPriceAsync"/>:
|
||||
/// AI items → Sales items → Catalog (no coats) → full calculation engine.
|
||||
/// Keeping pricing logic in PricingCalculationService means this method only decides WHICH
|
||||
/// path to take, never HOW to compute the price.
|
||||
/// </summary>
|
||||
private async Task ApplyPricingAsync(QuoteItem item, CreateQuoteItemDto itemDto, int companyId, decimal? ovenRateOverride)
|
||||
{
|
||||
if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0)
|
||||
@@ -120,6 +159,12 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
ApplyCalculatedPricing(item, pricing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds <see cref="QuoteItemCoat"/> entities for a single item, including per-coat pricing.
|
||||
/// If a coat has <c>AddAsIncoming = true</c> and references a catalog item but not an inventory
|
||||
/// item, an incoming <see cref="InventoryItem"/> is auto-created so the shop can track the powder
|
||||
/// order and receive it later — see <see cref="CreateIncomingInventoryItemAsync"/> for details.
|
||||
/// </summary>
|
||||
private async Task<List<QuoteItemCoat>> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
if (itemDto.Coats == null || itemDto.Coats.Count == 0)
|
||||
@@ -151,6 +196,7 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
return coats;
|
||||
}
|
||||
|
||||
/// <summary>Constructs <see cref="QuoteItemPrepService"/> entities from the item DTO's prep service list.</summary>
|
||||
private static List<QuoteItemPrepService> BuildQuoteItemPrepServices(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
if (itemDto.PrepServices == null || itemDto.PrepServices.Count == 0)
|
||||
@@ -168,6 +214,11 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constructs a bare <see cref="QuoteItem"/> entity from the DTO — no pricing or coats yet.
|
||||
/// Pricing is applied separately by <see cref="ApplyPricingAsync"/> to keep the construction
|
||||
/// and calculation steps distinct and individually testable.
|
||||
/// </summary>
|
||||
private static QuoteItem BuildQuoteItem(CreateQuoteItemDto itemDto, int quoteId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return new QuoteItem
|
||||
@@ -197,6 +248,7 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Constructs a <see cref="QuoteItemCoat"/> entity from the coat DTO. Per-coat pricing is applied by the caller.</summary>
|
||||
private static QuoteItemCoat BuildQuoteItemCoat(CreateQuoteItemCoatDto coatDto, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return new QuoteItemCoat
|
||||
@@ -212,12 +264,17 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
TransferEfficiency = coatDto.TransferEfficiency,
|
||||
PowderCostPerLb = coatDto.PowderCostPerLb,
|
||||
PowderToOrder = coatDto.PowderToOrder,
|
||||
NoExtraLayerCharge = coatDto.NoExtraLayerCharge,
|
||||
Notes = coatDto.Notes,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stamps the pricing result onto the quote item entity.
|
||||
/// Broken out as a separate method because it's called from multiple branches of ApplyPricingAsync.
|
||||
/// </summary>
|
||||
private static void ApplyCalculatedPricing(QuoteItem item, QuoteItemPricingResult pricing)
|
||||
{
|
||||
item.UnitPrice = pricing.UnitPrice;
|
||||
@@ -227,6 +284,13 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
item.ItemEquipmentCost = pricing.EquipmentCost;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the user changed the AI's surface area or price estimates before saving,
|
||||
/// and sets <c>UserOverrodeEstimate = true</c> on the prediction record if they did.
|
||||
/// This flag feeds the AI analytics reports — over time it reveals how accurate the AI is
|
||||
/// and whether certain item types consistently need manual correction.
|
||||
/// A tolerance of $0.01 / 0.01 sqft is used to ignore floating-point rounding noise.
|
||||
/// </summary>
|
||||
private async Task UpdateAiPredictionOverrideAsync(CreateQuoteItemDto itemDto, decimal finalUnitPrice)
|
||||
{
|
||||
if (!itemDto.AiPredictionId.HasValue) return;
|
||||
@@ -240,6 +304,23 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
prediction.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-creates an "incoming" <see cref="InventoryItem"/> when a user selects a powder from the
|
||||
/// platform catalog that doesn't yet exist in their company's inventory.
|
||||
///
|
||||
/// WHY this exists: shops often quote jobs using powders they haven't ordered yet. Rather than
|
||||
/// forcing the user to manually add the powder to inventory before quoting, we create an
|
||||
/// IsIncoming=true record on their behalf. The shop can then receive the actual order against
|
||||
/// this record later (updating quantity + receive date) without losing the link to the original quote.
|
||||
///
|
||||
/// The AI augmentation step (LookupByUrlAsync) fills in technical specs (cure temp/time, coverage,
|
||||
/// color families, etc.) that may be missing from the scraped catalog JSON. It is best-effort —
|
||||
/// if it fails, the item is still created with whatever data the catalog has.
|
||||
///
|
||||
/// After creation, <c>coatDto.PowderCostPerLb</c> is cleared so the pricing engine treats this
|
||||
/// as an inventory-linked coat (not a custom powder), ensuring future repricings use the
|
||||
/// inventory unit cost rather than the now-stale manual price from the quote form.
|
||||
/// </summary>
|
||||
private async Task<int?> CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -58,7 +58,14 @@ public class ApplicationUser : IdentityUser
|
||||
|
||||
public string? SidebarColor { get; set; } = "ocean";
|
||||
public string? Notes { get; set; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Per-worker labor cost per hour used for job costing profit/margin calculations.
|
||||
/// Overrides the company-level LaborCostPerHour when set.
|
||||
/// Leave null to use the company default.
|
||||
/// </summary>
|
||||
public decimal? LaborCostPerHour { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public DateTime? LastLoginDate { get; set; }
|
||||
|
||||
@@ -95,6 +95,12 @@ public class Appointment : BaseEntity
|
||||
/// </summary>
|
||||
public int ReminderMinutesBefore { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the reminder was dispatched. Null means it hasn't fired yet.
|
||||
/// The background service uses this as a deduplication guard to prevent double-sending.
|
||||
/// </summary>
|
||||
public DateTime? ReminderSentAt { get; set; }
|
||||
|
||||
// Navigation Properties
|
||||
public virtual Customer? Customer { get; set; }
|
||||
public virtual Job? Job { get; set; }
|
||||
|
||||
@@ -141,8 +141,7 @@ public class Company : BaseEntity
|
||||
public virtual ICollection<Quote> Quotes { get; set; } = new List<Quote>();
|
||||
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
|
||||
public virtual ICollection<Vendor> Vendors { get; set; } = new List<Vendor>();
|
||||
public virtual ICollection<ShopWorker> ShopWorkers { get; set; } = new List<ShopWorker>();
|
||||
public virtual ICollection<PricingTier> PricingTiers { get; set; } = new List<PricingTier>();
|
||||
public virtual ICollection<PricingTier> PricingTiers { get; set; } = new List<PricingTier>();
|
||||
public virtual CompanyOperatingCosts? OperatingCosts { get; set; }
|
||||
public virtual CompanyPreferences? Preferences { get; set; }
|
||||
}
|
||||
|
||||
@@ -13,6 +13,14 @@ namespace PowderCoating.Core.Entities
|
||||
[Range(0, 10000)]
|
||||
public decimal StandardLaborRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Actual labor cost per hour (wages + burden) used exclusively for internal job costing and profit/margin display.
|
||||
/// This is NOT the billing rate — it should reflect what you actually pay workers.
|
||||
/// When null, the costing engine defaults to 20% of StandardLaborRate.
|
||||
/// </summary>
|
||||
[Range(0, 10000)]
|
||||
public decimal? LaborCostPerHour { get; set; }
|
||||
|
||||
// Additional Coat Labor Percentage (percentage of base labor for each additional coat beyond the first)
|
||||
[Range(0, 100)]
|
||||
public decimal AdditionalCoatLaborPercent { get; set; } = 30m;
|
||||
|
||||
@@ -19,7 +19,7 @@ public class Equipment : BaseEntity
|
||||
public string? Location { get; set; }
|
||||
|
||||
// Maintenance Information
|
||||
public int RecommendedMaintenanceIntervalDays { get; set; }
|
||||
public int? RecommendedMaintenanceIntervalDays { get; set; }
|
||||
public DateTime? LastMaintenanceDate { get; set; }
|
||||
public DateTime? NextScheduledMaintenance { get; set; }
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@ public class GiftCertificate : BaseEntity
|
||||
/// <summary>Set when this GC was sold via an invoice line item.</summary>
|
||||
public int? SourceInvoiceItemId { get; set; }
|
||||
|
||||
/// <summary>Groups all certificates created in a single bulk run. Null for individually issued certs.</summary>
|
||||
public Guid? BatchId { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Customer? RecipientCustomer { get; set; }
|
||||
public virtual Customer? PurchasingCustomer { get; set; }
|
||||
|
||||
@@ -12,4 +12,5 @@ public class InventoryCategoryLookup : BaseEntity
|
||||
|
||||
// Relationships
|
||||
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
|
||||
public virtual ICollection<Vendor> Vendors { get; set; } = new List<Vendor>();
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ public class Job : BaseEntity
|
||||
// Selected oven (carried over from quote; null = company default rate)
|
||||
public int? OvenCostId { get; set; }
|
||||
|
||||
// Oven scheduling (carried over from quote)
|
||||
public int OvenBatches { get; set; } = 1;
|
||||
public int? OvenCycleMinutes { get; set; }
|
||||
|
||||
// Pricing
|
||||
public decimal QuotedPrice { get; set; }
|
||||
public decimal FinalPrice { get; set; }
|
||||
@@ -62,6 +66,10 @@ public class Job : BaseEntity
|
||||
// Used to detect when the quote was subsequently edited so the job details page can warn the user.
|
||||
public DateTime? QuoteSnapshotUpdatedAt { get; set; }
|
||||
|
||||
// Pricing snapshot — serialized QuotePricingBreakdownDto stored at save time so Details displays
|
||||
// the breakdown that was actually calculated, not a re-run against current operating costs.
|
||||
public string? PricingBreakdownJson { get; set; }
|
||||
|
||||
// Rework tracking
|
||||
public bool IsReworkJob { get; set; }
|
||||
public int? OriginalJobId { get; set; } // Set when this job was created as a rework
|
||||
|
||||
@@ -41,6 +41,10 @@ public class JobItem : BaseEntity
|
||||
// Values: "Simple" | "Moderate" | "Complex" | "Extreme"
|
||||
public string? Complexity { get; set; }
|
||||
|
||||
// True when this item originated from an AI Photo Quote — ManualUnitPrice is used as-is
|
||||
// and oven cost is not double-charged (it was excluded from the AI estimate at quote level).
|
||||
public bool IsAiItem { get; set; }
|
||||
|
||||
// AI-generated standardized tags (comma-separated, e.g. "automotive,tubular")
|
||||
public string? AiTags { get; set; }
|
||||
|
||||
|
||||
@@ -42,6 +42,13 @@ public class JobItemCoat : BaseEntity
|
||||
public string? PowderReceivedByUserId { get; set; }
|
||||
public decimal? PowderReceivedLbs { get; set; }
|
||||
|
||||
// Pricing flags
|
||||
/// <summary>
|
||||
/// When true, the additional layer labor charge is not applied for this coat even if it is
|
||||
/// not the first coat in the sequence. Used for clear coats, sealers, etc.
|
||||
/// </summary>
|
||||
public bool NoExtraLayerCharge { get; set; }
|
||||
|
||||
// Notes
|
||||
public string? Notes { get; set; }
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ namespace PowderCoating.Core.Entities;
|
||||
public class JobTimeEntry : BaseEntity
|
||||
{
|
||||
public int JobId { get; set; }
|
||||
public int? ShopWorkerId { get; set; } // legacy — kept for entries created before user migration
|
||||
public string? UserId { get; set; } // FK to AspNetUsers
|
||||
public string? UserDisplayName { get; set; } // snapshot of worker name at entry creation time
|
||||
public DateTime WorkDate { get; set; }
|
||||
@@ -13,5 +12,4 @@ public class JobTimeEntry : BaseEntity
|
||||
|
||||
// Navigation
|
||||
public virtual Job Job { get; set; } = null!;
|
||||
public virtual ShopWorker? Worker { get; set; } // nullable — only populated for legacy entries
|
||||
}
|
||||
|
||||
@@ -40,26 +40,33 @@ public class Quote : BaseEntity
|
||||
public DateTime? ApprovedDate { get; set; }
|
||||
|
||||
// Pricing — all values are snapshots captured at save time and must not be recalculated on load
|
||||
public decimal MaterialCosts { get; set; } // Sum of powder/material costs across all items
|
||||
public decimal LaborCosts { get; set; } // Sum of labor costs across all items
|
||||
public decimal EquipmentCosts { get; set; } // Sum of equipment costs across all items
|
||||
public decimal ItemsSubtotal { get; set; } // Sum of item prices before any quote-level costs
|
||||
public decimal OvenBatchCost { get; set; } // Oven batch charge applied at quote level
|
||||
public decimal ShopSuppliesAmount { get; set; } // Shop supplies dollar amount
|
||||
public decimal ShopSuppliesPercent { get; set; } // Shop supplies percentage used
|
||||
public decimal OverheadAmount { get; set; } // Overhead dollar amount
|
||||
public decimal OverheadPercent { get; set; } // Overhead percentage used
|
||||
public decimal ProfitMargin { get; set; } // Profit margin dollar amount
|
||||
public decimal ProfitPercent { get; set; } // Profit margin percentage used
|
||||
public decimal SubTotal { get; set; } // SubtotalBeforeDiscount (items + oven + overhead + profit + shop supplies)
|
||||
public decimal MaterialCosts { get; set; } // Sum of powder/material costs across all items
|
||||
public decimal LaborCosts { get; set; } // Sum of labor costs across all items
|
||||
public decimal EquipmentCosts { get; set; } // Sum of equipment costs across all items
|
||||
public decimal ItemsSubtotal { get; set; } // Sum of item prices before any quote-level costs
|
||||
public decimal OvenBatchCost { get; set; } // Oven batch charge applied at quote level
|
||||
public decimal FacilityOverheadCost { get; set; } // Rent + utilities apportioned by estimated job hours
|
||||
public decimal FacilityOverheadRatePerHour { get; set; }// Rate used for facility overhead ($/hr)
|
||||
public decimal ShopSuppliesAmount { get; set; } // Shop supplies dollar amount
|
||||
public decimal ShopSuppliesPercent { get; set; } // Shop supplies percentage used
|
||||
public decimal OverheadAmount { get; set; } // Legacy overhead (now always 0; kept for migration safety)
|
||||
public decimal OverheadPercent { get; set; } // Legacy overhead percent
|
||||
public decimal ProfitMargin { get; set; } // Profit margin dollar amount (0 — baked into item prices)
|
||||
public decimal ProfitPercent { get; set; } // Markup % used (for display reference)
|
||||
public decimal SubTotal { get; set; } // SubtotalBeforeDiscount (items + oven + facility overhead + shop supplies)
|
||||
|
||||
// Discount Information
|
||||
public DiscountType DiscountType { get; set; } = DiscountType.None;
|
||||
public decimal DiscountValue { get; set; } = 0; // Value entered by user (percentage or fixed amount)
|
||||
public decimal DiscountPercent { get; set; } // Calculated: actual percentage applied
|
||||
public decimal DiscountAmount { get; set; } // Calculated: actual dollar amount deducted
|
||||
public string? DiscountReason { get; set; } // Why discount was applied
|
||||
public decimal DiscountValue { get; set; } = 0; // Value entered by user (percentage or fixed amount)
|
||||
public decimal PricingTierDiscountAmount { get; set; } // Discount from customer's pricing tier
|
||||
public decimal PricingTierDiscountPercent { get; set; } // Tier discount percentage
|
||||
public decimal QuoteDiscountAmount { get; set; } // Manual quote-level discount amount
|
||||
public decimal QuoteDiscountPercent { get; set; } // Manual quote-level discount percentage
|
||||
public decimal DiscountPercent { get; set; } // Combined: actual percentage applied
|
||||
public decimal DiscountAmount { get; set; } // Combined: actual dollar amount deducted
|
||||
public string? DiscountReason { get; set; } // Why discount was applied
|
||||
public bool HideDiscountFromCustomer { get; set; } = false; // Show only total on PDFs/portal
|
||||
public decimal SubtotalAfterDiscount { get; set; } // SubTotal minus all discounts, before rush/tax
|
||||
|
||||
public decimal TaxPercent { get; set; }
|
||||
public decimal TaxAmount { get; set; }
|
||||
|
||||
@@ -33,6 +33,13 @@ public class QuoteItemCoat : BaseEntity
|
||||
public decimal CoatLaborCost { get; set; }
|
||||
public decimal CoatTotalCost { get; set; }
|
||||
|
||||
// Pricing flags
|
||||
/// <summary>
|
||||
/// When true, the additional layer labor charge is not applied for this coat even if it is
|
||||
/// not the first coat in the sequence. Used for clear coats, sealers, etc.
|
||||
/// </summary>
|
||||
public bool NoExtraLayerCharge { get; set; }
|
||||
|
||||
// Notes
|
||||
public string? Notes { get; set; }
|
||||
|
||||
|
||||
@@ -31,6 +31,9 @@ public class ReworkRecord : BaseEntity
|
||||
public bool IsBillableToCustomer { get; set; }
|
||||
public string? BillingNotes { get; set; }
|
||||
|
||||
// Pricing attribution for the linked rework job (null on pre-existing records)
|
||||
public ReworkPricingType? ReworkPricingType { get; set; }
|
||||
|
||||
// ── Resolution ────────────────────────────────────────────────────────────
|
||||
public ReworkStatus Status { get; set; } = ReworkStatus.Open;
|
||||
public ReworkResolution? Resolution { 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; }
|
||||
}
|
||||
@@ -45,6 +45,7 @@ public class Vendor : BaseEntity
|
||||
public virtual ICollection<BillPayment> BillPayments { get; set; } = new List<BillPayment>();
|
||||
public virtual ICollection<Expense> Expenses { get; set; } = new List<Expense>();
|
||||
public virtual Account? DefaultExpenseAccount { get; set; }
|
||||
public virtual ICollection<InventoryCategoryLookup> Categories { get; set; } = new List<InventoryCategoryLookup>();
|
||||
}
|
||||
|
||||
public class InventoryTransaction : BaseEntity
|
||||
|
||||
@@ -78,17 +78,6 @@ public enum EquipmentStatus
|
||||
Retired = 4
|
||||
}
|
||||
|
||||
public enum ShopWorkerRole
|
||||
{
|
||||
GeneralLabor = 0,
|
||||
Sandblaster = 1,
|
||||
Coater = 2,
|
||||
Masker = 3,
|
||||
QualityControl = 4,
|
||||
OvenOperator = 5,
|
||||
Supervisor = 6,
|
||||
Maintenance = 7
|
||||
}
|
||||
|
||||
public enum JobPhotoType
|
||||
{
|
||||
@@ -155,6 +144,14 @@ public enum ReworkResolution
|
||||
NoActionRequired = 4
|
||||
}
|
||||
|
||||
/// <summary>Who bears the cost of the rework job, recorded at the time the rework is logged.</summary>
|
||||
public enum ReworkPricingType
|
||||
{
|
||||
ShopFault = 0, // Redo is on the shop — rework job items priced at $0
|
||||
CustomerReduced = 1, // Customer caused it; we're helping — prices copied, user edits
|
||||
CustomerFull = 2 // Customer caused it; full original pricing applies
|
||||
}
|
||||
|
||||
public enum BugReportStatus
|
||||
{
|
||||
New = 0,
|
||||
|
||||
@@ -20,5 +20,7 @@ public enum NotificationType
|
||||
SmsInboundStop = 12,
|
||||
SmsInboundHelp = 13,
|
||||
AdminEmail = 14,
|
||||
SmsInboundStart = 15
|
||||
SmsInboundStart = 15,
|
||||
AppointmentReminder = 17,
|
||||
AppointmentReminderStaff = 18
|
||||
}
|
||||
|
||||
@@ -54,9 +54,7 @@ public interface IUnitOfWork : IDisposable
|
||||
IRepository<AppointmentStatusLookup> AppointmentStatusLookups { get; }
|
||||
IRepository<AppointmentTypeLookup> AppointmentTypeLookups { get; }
|
||||
IRepository<PrepService> PrepServices { get; }
|
||||
IRepository<ShopWorker> ShopWorkers { get; }
|
||||
IRepository<ShopWorkerRoleCost> ShopWorkerRoleCosts { get; }
|
||||
IRepository<ReworkRecord> ReworkRecords { get; }
|
||||
IRepository<ReworkRecord> ReworkRecords { get; }
|
||||
IRepository<Refund> Refunds { get; }
|
||||
IRepository<CreditMemo> CreditMemos { get; }
|
||||
IRepository<CreditMemoApplication> CreditMemoApplications { get; }
|
||||
|
||||
@@ -92,4 +92,10 @@ public interface IJobRepository : IRepository<Job>
|
||||
/// were never completed and rolled past their scheduled day.
|
||||
/// </summary>
|
||||
Task<List<Job>> GetOverdueScheduledJobsAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the count of rework jobs linked to <paramref name="originalJobId"/>
|
||||
/// (including soft-deleted) so the next rework suffix (R1, R2, …) can be determined.
|
||||
/// </summary>
|
||||
Task<int> GetReworkJobCountAsync(int originalJobId);
|
||||
}
|
||||
|
||||
@@ -92,7 +92,11 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
if (companyIdClaim != null && int.TryParse(companyIdClaim, out int companyId))
|
||||
return companyId;
|
||||
|
||||
return null;
|
||||
// Authenticated but CompanyId claim is missing or invalid.
|
||||
// Return 0 (never a real company ID) so the global filter generates
|
||||
// "CompanyId = 0" which matches nothing — prevents null-comparison
|
||||
// ambiguity from leaking cross-tenant rows.
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,8 +133,11 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
{
|
||||
get
|
||||
{
|
||||
// No HTTP context means background service, hosted service, or unit test — bypass tenant filter
|
||||
if (_httpContextAccessor?.HttpContext == null) return true;
|
||||
if (!IsSuperAdmin) return false;
|
||||
return CurrentCompanyId == null || CurrentCompanyId == 1;
|
||||
// CompanyId == 0 means no claim was present (break-glass / test SuperAdmins) — treat as platform admin
|
||||
return CurrentCompanyId == null || CurrentCompanyId == 0 || CurrentCompanyId == 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,11 +212,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
public DbSet<MaintenanceRecord> MaintenanceRecords { get; set; }
|
||||
/// <summary>Supplier/vendor records used by Purchasing and Accounts Payable; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<Vendor> Vendors { get; set; }
|
||||
/// <summary>Shop worker profiles with role assignments; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<ShopWorker> ShopWorkers { get; set; }
|
||||
/// <summary>Per-role labour cost rates used in pricing calculations; unique index on (CompanyId, Role).</summary>
|
||||
public DbSet<ShopWorkerRoleCost> ShopWorkerRoleCosts { get; set; }
|
||||
/// <summary>Rework records tracking quality failures and remediation work against a job; tenant-filtered with soft delete.</summary>
|
||||
/// <summary>Rework records tracking quality failures and remediation work against a job; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<ReworkRecord> ReworkRecords { get; set; }
|
||||
/// <summary>Customer refund records; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<Refund> Refunds { get; set; }
|
||||
@@ -530,11 +533,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<ShopWorker>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<ShopWorkerRoleCost>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
||||
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<Refund>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
@@ -810,6 +809,15 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
.HasForeignKey(s => s.DefaultExpenseAccountId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
// Vendor ↔ InventoryCategoryLookup (many-to-many supply categories)
|
||||
modelBuilder.Entity<Vendor>()
|
||||
.HasMany(v => v.Categories)
|
||||
.WithMany(c => c.Vendors)
|
||||
.UsingEntity<Dictionary<string, object>>(
|
||||
"VendorInventoryCategories",
|
||||
j => j.HasOne<InventoryCategoryLookup>().WithMany().HasForeignKey("InventoryCategoryLookupId"),
|
||||
j => j.HasOne<Vendor>().WithMany().HasForeignKey("VendorId"));
|
||||
|
||||
// Bill → APAccount (no cascade to avoid cycles)
|
||||
modelBuilder.Entity<Bill>()
|
||||
.HasOne(b => b.APAccount)
|
||||
@@ -1314,12 +1322,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
.HasForeignKey(m => m.PerformedById)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
// ShopWorker relationships
|
||||
modelBuilder.Entity<ShopWorker>()
|
||||
.HasOne<Company>()
|
||||
.WithMany(c => c.ShopWorkers)
|
||||
.HasForeignKey(e => e.CompanyId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
|
||||
modelBuilder.Entity<Job>()
|
||||
.HasOne(j => j.AssignedUser)
|
||||
@@ -1393,10 +1396,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
modelBuilder.Entity<PricingTier>()
|
||||
.HasIndex(p => p.CompanyId);
|
||||
|
||||
modelBuilder.Entity<ShopWorker>()
|
||||
.HasIndex(w => w.CompanyId);
|
||||
|
||||
modelBuilder.Entity<CatalogCategory>()
|
||||
modelBuilder.Entity<CatalogCategory>()
|
||||
.HasIndex(c => c.CompanyId);
|
||||
|
||||
modelBuilder.Entity<CatalogCategory>()
|
||||
@@ -1431,12 +1431,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_Jobs_CompanyId_JobNumber");
|
||||
|
||||
modelBuilder.Entity<ShopWorkerRoleCost>()
|
||||
.HasIndex(r => new { r.CompanyId, r.Role })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_ShopWorkerRoleCosts_CompanyId_Role");
|
||||
|
||||
modelBuilder.Entity<Job>()
|
||||
modelBuilder.Entity<Job>()
|
||||
.Property(j => j.ShopAccessCode)
|
||||
.HasDefaultValueSql("NEWID()");
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ public class AuditInterceptor : SaveChangesInterceptor
|
||||
private static readonly HashSet<string> AuditedTypes = new(StringComparer.Ordinal)
|
||||
{
|
||||
nameof(Customer), nameof(Job), nameof(Quote), nameof(Equipment),
|
||||
nameof(MaintenanceRecord), nameof(Vendor), nameof(ShopWorker),
|
||||
nameof(MaintenanceRecord), nameof(Vendor),
|
||||
nameof(InventoryItem), nameof(Company),
|
||||
// Financial entities
|
||||
nameof(Invoice), nameof(Payment), nameof(Bill), nameof(BillPayment),
|
||||
|
||||
+10633
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -11,26 +11,20 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7851));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7856));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7858));
|
||||
// These UpdateData calls were generated from an existing live database.
|
||||
// On a fresh install the PricingTiers table and its seed rows may not exist yet
|
||||
// (seeding is manual via Platform Management → Seed Data), so guard each update.
|
||||
migrationBuilder.Sql(@"
|
||||
IF OBJECT_ID(N'[PricingTiers]', N'U') IS NOT NULL
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 1)
|
||||
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377851Z' WHERE [Id] = 1;
|
||||
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 2)
|
||||
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377856Z' WHERE [Id] = 2;
|
||||
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 3)
|
||||
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377858Z' WHERE [Id] = 3;
|
||||
END
|
||||
");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10633
File diff suppressed because it is too large
Load Diff
+217
@@ -0,0 +1,217 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAppointmentReminderSentAt : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Use IF EXISTS guards for all ShopWorker drops — prod and dev diverged on whether
|
||||
// these objects exist, so unconditional drops would fail on whichever DB is missing them.
|
||||
migrationBuilder.Sql(@"
|
||||
IF EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_Jobs_ShopWorkers_ShopWorkerId')
|
||||
ALTER TABLE [Jobs] DROP CONSTRAINT [FK_Jobs_ShopWorkers_ShopWorkerId];
|
||||
IF EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_JobTimeEntries_ShopWorkers_ShopWorkerId')
|
||||
ALTER TABLE [JobTimeEntries] DROP CONSTRAINT [FK_JobTimeEntries_ShopWorkers_ShopWorkerId];
|
||||
IF EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_MaintenanceRecords_ShopWorkers_ShopWorkerId')
|
||||
ALTER TABLE [MaintenanceRecords] DROP CONSTRAINT [FK_MaintenanceRecords_ShopWorkers_ShopWorkerId];
|
||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ShopWorkerRoleCosts')
|
||||
DROP TABLE [ShopWorkerRoleCosts];
|
||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ShopWorkers')
|
||||
DROP TABLE [ShopWorkers];
|
||||
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_MaintenanceRecords_ShopWorkerId' AND object_id = OBJECT_ID('MaintenanceRecords'))
|
||||
DROP INDEX [IX_MaintenanceRecords_ShopWorkerId] ON [MaintenanceRecords];
|
||||
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_JobTimeEntries_ShopWorkerId' AND object_id = OBJECT_ID('JobTimeEntries'))
|
||||
DROP INDEX [IX_JobTimeEntries_ShopWorkerId] ON [JobTimeEntries];
|
||||
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Jobs_ShopWorkerId' AND object_id = OBJECT_ID('Jobs'))
|
||||
DROP INDEX [IX_Jobs_ShopWorkerId] ON [Jobs];
|
||||
IF EXISTS (SELECT 1 FROM sys.columns WHERE name = 'ShopWorkerId' AND object_id = OBJECT_ID('MaintenanceRecords'))
|
||||
ALTER TABLE [MaintenanceRecords] DROP COLUMN [ShopWorkerId];
|
||||
IF EXISTS (SELECT 1 FROM sys.columns WHERE name = 'ShopWorkerId' AND object_id = OBJECT_ID('JobTimeEntries'))
|
||||
ALTER TABLE [JobTimeEntries] DROP COLUMN [ShopWorkerId];
|
||||
IF EXISTS (SELECT 1 FROM sys.columns WHERE name = 'ShopWorkerId' AND object_id = OBJECT_ID('Jobs'))
|
||||
ALTER TABLE [Jobs] DROP COLUMN [ShopWorkerId];
|
||||
");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ReminderSentAt",
|
||||
table: "Appointments",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 15, 12, 57, 355, DateTimeKind.Utc).AddTicks(2970));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 15, 12, 57, 355, DateTimeKind.Utc).AddTicks(2976));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 15, 12, 57, 355, DateTimeKind.Utc).AddTicks(2977));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ReminderSentAt",
|
||||
table: "Appointments");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ShopWorkerId",
|
||||
table: "MaintenanceRecords",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ShopWorkerId",
|
||||
table: "JobTimeEntries",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ShopWorkerId",
|
||||
table: "Jobs",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ShopWorkerRoleCosts",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
HourlyRate = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
Role = table.Column<int>(type: "int", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ShopWorkerRoleCosts", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ShopWorkers",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Email = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Phone = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Role = table.Column<int>(type: "int", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ShopWorkers", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ShopWorkers_Companies_CompanyId",
|
||||
column: x => x.CompanyId,
|
||||
principalTable: "Companies",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3131));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3137));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3138));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MaintenanceRecords_ShopWorkerId",
|
||||
table: "MaintenanceRecords",
|
||||
column: "ShopWorkerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_JobTimeEntries_ShopWorkerId",
|
||||
table: "JobTimeEntries",
|
||||
column: "ShopWorkerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Jobs_ShopWorkerId",
|
||||
table: "Jobs",
|
||||
column: "ShopWorkerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ShopWorkerRoleCosts_CompanyId_Role",
|
||||
table: "ShopWorkerRoleCosts",
|
||||
columns: new[] { "CompanyId", "Role" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ShopWorkers_CompanyId",
|
||||
table: "ShopWorkers",
|
||||
column: "CompanyId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Jobs_ShopWorkers_ShopWorkerId",
|
||||
table: "Jobs",
|
||||
column: "ShopWorkerId",
|
||||
principalTable: "ShopWorkers",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_JobTimeEntries_ShopWorkers_ShopWorkerId",
|
||||
table: "JobTimeEntries",
|
||||
column: "ShopWorkerId",
|
||||
principalTable: "ShopWorkers",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_MaintenanceRecords_ShopWorkers_ShopWorkerId",
|
||||
table: "MaintenanceRecords",
|
||||
column: "ShopWorkerId",
|
||||
principalTable: "ShopWorkers",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10639
File diff suppressed because it is too large
Load Diff
+83
@@ -0,0 +1,83 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddNoExtraLayerChargeToCoats : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "NoExtraLayerCharge",
|
||||
table: "QuoteItemCoats",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "NoExtraLayerCharge",
|
||||
table: "JobItemCoats",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NoExtraLayerCharge",
|
||||
table: "QuoteItemCoats");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NoExtraLayerCharge",
|
||||
table: "JobItemCoats");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 15, 44, 18, 742, DateTimeKind.Utc).AddTicks(3960));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 15, 44, 18, 742, DateTimeKind.Utc).AddTicks(3966));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 15, 44, 18, 742, DateTimeKind.Utc).AddTicks(3967));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10642
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 AddReworkPricingType : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ReworkPricingType",
|
||||
table: "ReworkRecords",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8533));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8542));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8543));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ReworkPricingType",
|
||||
table: "ReworkRecords");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10672
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddVendorCategories : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VendorInventoryCategories",
|
||||
columns: table => new
|
||||
{
|
||||
InventoryCategoryLookupId = table.Column<int>(type: "int", nullable: false),
|
||||
VendorId = table.Column<int>(type: "int", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VendorInventoryCategories", x => new { x.InventoryCategoryLookupId, x.VendorId });
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorInventoryCategories_InventoryCategoryLookups_InventoryCategoryLookupId",
|
||||
column: x => x.InventoryCategoryLookupId,
|
||||
principalTable: "InventoryCategoryLookups",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorInventoryCategories_Vendors_VendorId",
|
||||
column: x => x.VendorId,
|
||||
principalTable: "Vendors",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4300));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4313));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4315));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorInventoryCategories_VendorId",
|
||||
table: "VendorInventoryCategories",
|
||||
column: "VendorId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "VendorInventoryCategories");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8533));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8542));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8543));
|
||||
}
|
||||
}
|
||||
}
|
||||
+10672
File diff suppressed because it is too large
Load Diff
+79
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class MakeMaintenanceIntervalNullable : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<int>(
|
||||
name: "RecommendedMaintenanceIntervalDays",
|
||||
table: "Equipment",
|
||||
type: "int",
|
||||
nullable: true,
|
||||
oldClrType: typeof(int),
|
||||
oldType: "int");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8197));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8203));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8204));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<int>(
|
||||
name: "RecommendedMaintenanceIntervalDays",
|
||||
table: "Equipment",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0,
|
||||
oldClrType: typeof(int),
|
||||
oldType: "int",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4300));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4313));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4315));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -556,6 +556,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<bool>("IsBanned")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<decimal?>("LaborCostPerHour")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<DateTime?>("LastLoginDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
@@ -713,6 +716,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<int>("ReminderMinutesBefore")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("ReminderSentAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<DateTime>("ScheduledEndTime")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
@@ -2075,6 +2081,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<decimal?>("LaborCostPerHour")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int>("MonthlyBillableHours")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -3036,7 +3045,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<decimal>("PurchasePrice")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int>("RecommendedMaintenanceIntervalDays")
|
||||
b.Property<int?>("RecommendedMaintenanceIntervalDays")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("SerialNumber")
|
||||
@@ -3290,6 +3299,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<Guid?>("BatchId")
|
||||
.HasColumnType("uniqueidentifier");
|
||||
|
||||
b.Property<string>("CertificateCode")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(450)");
|
||||
@@ -4205,9 +4217,18 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<decimal>("OvenBatchCost")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int>("OvenBatches")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int?>("OvenCostId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int?>("OvenCycleMinutes")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("PricingBreakdownJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("QuoteId")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -4234,9 +4255,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<decimal>("ShopSuppliesPercent")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<int?>("ShopWorkerId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("SpecialInstructions")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -4278,8 +4296,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("ScheduledDate");
|
||||
|
||||
b.HasIndex("ShopWorkerId");
|
||||
|
||||
b.HasIndex("CompanyId", "CustomerId")
|
||||
.HasDatabaseName("IX_Jobs_CompanyId_CustomerId");
|
||||
|
||||
@@ -4476,6 +4492,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<bool>("IncludePrepCost")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsAiItem")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
@@ -4599,6 +4618,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<int>("JobItemId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<bool>("NoExtraLayerCharge")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -5418,9 +5440,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("ShopWorkerId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Stage")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -5443,8 +5462,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("JobId");
|
||||
|
||||
b.HasIndex("ShopWorkerId");
|
||||
|
||||
b.ToTable("JobTimeEntries");
|
||||
});
|
||||
|
||||
@@ -5768,9 +5785,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<DateTime>("ScheduledDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<int?>("ShopWorkerId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -5801,8 +5815,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("ScheduledDate");
|
||||
|
||||
b.HasIndex("ShopWorkerId");
|
||||
|
||||
b.HasIndex("Status");
|
||||
|
||||
b.HasIndex("CompanyId", "ScheduledDate")
|
||||
@@ -6699,7 +6711,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2349),
|
||||
CreatedAt = new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8197),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -6710,7 +6722,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2366),
|
||||
CreatedAt = new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8203),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -6721,7 +6733,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 14, 2, 27, 27, 993, DateTimeKind.Utc).AddTicks(2367),
|
||||
CreatedAt = new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8204),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
@@ -6968,6 +6980,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<DateTime?>("ExpirationDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal>("FacilityOverheadCost")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("FacilityOverheadRatePerHour")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<bool>("HideDiscountFromCustomer")
|
||||
.HasColumnType("bit");
|
||||
|
||||
@@ -7013,6 +7031,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("PreparedById")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<decimal>("PricingTierDiscountAmount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("PricingTierDiscountPercent")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("ProfitMargin")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
@@ -7052,6 +7076,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<DateTime>("QuoteDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<decimal>("QuoteDiscountAmount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("QuoteDiscountPercent")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("QuoteNumber")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(450)");
|
||||
@@ -7077,6 +7107,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<decimal>("SubTotal")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<decimal>("SubtotalAfterDiscount")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("Tags")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -7375,6 +7408,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("NoExtraLayerCharge")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -7954,6 +7990,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<int?>("ReworkJobId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int?>("ReworkPricingType")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("ReworkType")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -7977,111 +8016,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.ToTable("ReworkRecords");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorker", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Phone")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CompanyId");
|
||||
|
||||
b.ToTable("ShopWorkers");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorkerRoleCost", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal>("HourlyRate")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CompanyId", "Role")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_ShopWorkerRoleCosts_CompanyId_Role");
|
||||
|
||||
b.ToTable("ShopWorkerRoleCosts");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.StripeWebhookEvent", b =>
|
||||
{
|
||||
b.Property<long>("Id")
|
||||
@@ -8700,6 +8634,21 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.ToTable("YearEndCloses");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("VendorInventoryCategories", b =>
|
||||
{
|
||||
b.Property<int>("InventoryCategoryLookupId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("VendorId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("InventoryCategoryLookupId", "VendorId");
|
||||
|
||||
b.HasIndex("VendorId");
|
||||
|
||||
b.ToTable("VendorInventoryCategories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
@@ -9499,10 +9448,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
.HasForeignKey("PowderCoating.Core.Entities.Job", "QuoteId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("PowderCoating.Core.Entities.ShopWorker", null)
|
||||
.WithMany("AssignedJobs")
|
||||
.HasForeignKey("ShopWorkerId");
|
||||
|
||||
b.Navigation("AssignedUser");
|
||||
|
||||
b.Navigation("Customer");
|
||||
@@ -9805,13 +9750,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("PowderCoating.Core.Entities.ShopWorker", "Worker")
|
||||
.WithMany("TimeEntries")
|
||||
.HasForeignKey("ShopWorkerId");
|
||||
|
||||
b.Navigation("Job");
|
||||
|
||||
b.Navigation("Worker");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.JournalEntry", b =>
|
||||
@@ -9882,10 +9821,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
.WithMany()
|
||||
.HasForeignKey("RecurrenceParentId");
|
||||
|
||||
b.HasOne("PowderCoating.Core.Entities.ShopWorker", null)
|
||||
.WithMany("AssignedMaintenanceTasks")
|
||||
.HasForeignKey("ShopWorkerId");
|
||||
|
||||
b.Navigation("AssignedUser");
|
||||
|
||||
b.Navigation("Equipment");
|
||||
@@ -10369,15 +10304,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Navigation("ReworkJob");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorker", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.Company", null)
|
||||
.WithMany("ShopWorkers")
|
||||
.HasForeignKey("CompanyId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.Company", null)
|
||||
@@ -10461,6 +10387,21 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Navigation("JournalEntry");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("VendorInventoryCategories", b =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.InventoryCategoryLookup", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("InventoryCategoryLookupId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("PowderCoating.Core.Entities.Vendor", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("VendorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.Account", b =>
|
||||
{
|
||||
b.Navigation("BillLineItems");
|
||||
@@ -10540,8 +10481,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
|
||||
b.Navigation("Quotes");
|
||||
|
||||
b.Navigation("ShopWorkers");
|
||||
|
||||
b.Navigation("Users");
|
||||
|
||||
b.Navigation("Vendors");
|
||||
@@ -10707,15 +10646,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Navigation("Quotes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorker", b =>
|
||||
{
|
||||
b.Navigation("AssignedJobs");
|
||||
|
||||
b.Navigation("AssignedMaintenanceTasks");
|
||||
|
||||
b.Navigation("TimeEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b =>
|
||||
{
|
||||
b.Navigation("BillPayments");
|
||||
|
||||
@@ -171,7 +171,6 @@ public class JobRepository : Repository<Job>, IJobRepository
|
||||
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
|
||||
.Include(j => j.TimeEntries.Where(t => !t.IsDeleted))
|
||||
.ThenInclude(t => t.Worker)
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
@@ -188,6 +187,14 @@ public class JobRepository : Repository<Job>, IJobRepository
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<int> GetReworkJobCountAsync(int originalJobId)
|
||||
{
|
||||
return await _context.Jobs
|
||||
.IgnoreQueryFilters()
|
||||
.CountAsync(j => j.OriginalJobId == originalJobId);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<Job>> GetOverdueScheduledJobsAsync()
|
||||
{
|
||||
|
||||
@@ -81,7 +81,6 @@ public class UnitOfWork : IUnitOfWork
|
||||
private IRepository<AppointmentStatusLookup>? _appointmentStatusLookups;
|
||||
private IRepository<AppointmentTypeLookup>? _appointmentTypeLookups;
|
||||
private IRepository<PrepService>? _prepServices;
|
||||
private IRepository<ShopWorker>? _shopWorkers;
|
||||
|
||||
// Appointments
|
||||
private IRepository<Appointment>? _appointments;
|
||||
@@ -350,16 +349,7 @@ public class UnitOfWork : IUnitOfWork
|
||||
public IRepository<PrepService> PrepServices =>
|
||||
_prepServices ??= new Repository<PrepService>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="ShopWorker"/> profiles with role assignments; tenant-filtered with soft delete.</summary>
|
||||
public IRepository<ShopWorker> ShopWorkers =>
|
||||
_shopWorkers ??= new Repository<ShopWorker>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="ShopWorkerRoleCost"/> per-role labour cost rates; unique on (CompanyId, Role).</summary>
|
||||
private IRepository<ShopWorkerRoleCost>? _shopWorkerRoleCosts;
|
||||
public IRepository<ShopWorkerRoleCost> ShopWorkerRoleCosts =>
|
||||
_shopWorkerRoleCosts ??= new Repository<ShopWorkerRoleCost>(_context);
|
||||
|
||||
/// <summary>Repository for <see cref="ReworkRecord"/> quality-failure and remediation records; tenant-filtered with soft delete.</summary>
|
||||
/// <summary>Repository for <see cref="ReworkRecord"/> quality-failure and remediation records; tenant-filtered with soft delete.</summary>
|
||||
private IRepository<ReworkRecord>? _reworkRecords;
|
||||
public IRepository<ReworkRecord> ReworkRecords =>
|
||||
_reworkRecords ??= new Repository<ReworkRecord>(_context);
|
||||
|
||||
@@ -73,7 +73,6 @@ public class CompanyDataPurgeService : ICompanyDataPurgeService
|
||||
await _context.NotificationTemplates.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.Announcements.Where(x => x.TargetCompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.BugReports.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.ShopWorkers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
|
||||
// ── Tier 4: Company configs and lookup tables ─────────────────────────
|
||||
@@ -137,7 +136,6 @@ public class CompanyDataPurgeService : ICompanyDataPurgeService
|
||||
await _context.PurchaseOrderItems.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.AiItemPredictions.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.PowderUsageLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.ShopWorkerRoleCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.OvenBatches.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.Refunds.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.CreditMemos.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
@@ -160,7 +158,6 @@ public class CompanyDataPurgeService : ICompanyDataPurgeService
|
||||
await _context.OvenCosts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.Accounts.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.NotificationLogs.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.ShopWorkers.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == companyId).ExecuteDeleteAsync();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using CsvHelper;
|
||||
using CsvHelper.Configuration;
|
||||
@@ -2164,168 +2164,6 @@ public class CsvImportService : ICsvImportService
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Shop Worker Import
|
||||
|
||||
/// <summary>
|
||||
/// Generates a downloadable CSV template with two example shop worker rows covering different roles.
|
||||
/// Two rows help users see how Role values (Coater, Sandblaster, etc.) are expressed and remind
|
||||
/// them that Role is optional — the importer will default to GeneralLabor when it is omitted.
|
||||
/// </summary>
|
||||
public byte[] GenerateShopWorkerTemplate()
|
||||
{
|
||||
using var memoryStream = new MemoryStream();
|
||||
using var writer = new StreamWriter(memoryStream);
|
||||
using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture));
|
||||
|
||||
csv.WriteHeader<ShopWorkerImportDto>();
|
||||
csv.NextRecord();
|
||||
|
||||
csv.WriteRecord(new ShopWorkerImportDto
|
||||
{
|
||||
Name = "John Doe",
|
||||
Role = "Coater",
|
||||
Phone = "555-1234",
|
||||
Email = "johndoe@example.com",
|
||||
IsActive = true,
|
||||
Notes = "Experienced powder coater"
|
||||
});
|
||||
csv.NextRecord();
|
||||
|
||||
csv.WriteRecord(new ShopWorkerImportDto
|
||||
{
|
||||
Name = "Jane Smith",
|
||||
Role = "Sandblaster",
|
||||
Phone = "555-5678",
|
||||
Email = "janesmith@example.com",
|
||||
IsActive = true,
|
||||
Notes = ""
|
||||
});
|
||||
csv.NextRecord();
|
||||
|
||||
writer.Flush();
|
||||
return memoryStream.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports shop workers from a CSV stream using an upsert strategy keyed on worker Name.
|
||||
/// Like vendor import, this is intentionally an upsert rather than insert-only so that a
|
||||
/// company can re-import their HR list to update phone/email/role details without worrying
|
||||
/// about creating duplicates. Role is parsed case-insensitively with spaces stripped so that
|
||||
/// "General Labor" and "GeneralLabor" are both accepted; an unrecognised role falls back to
|
||||
/// GeneralLabor with a warning rather than failing the row.
|
||||
/// </summary>
|
||||
/// <param name="csvStream">Readable stream of CSV data (header row required).</param>
|
||||
/// <param name="companyId">Tenant company that will own newly inserted worker records.</param>
|
||||
public async Task<CsvImportResultDto> ImportShopWorkersAsync(Stream csvStream, int companyId)
|
||||
{
|
||||
var result = new CsvImportResultDto();
|
||||
var rowNumber = 0;
|
||||
|
||||
try
|
||||
{
|
||||
using var reader = new StreamReader(csvStream);
|
||||
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||
{
|
||||
HeaderValidated = null,
|
||||
MissingFieldFound = null
|
||||
});
|
||||
|
||||
var records = csv.GetRecords<ShopWorkerImportDto>().ToList();
|
||||
result.TotalRows = records.Count;
|
||||
|
||||
_logger.LogInformation("Starting import of {Count} shop workers for company {CompanyId}", records.Count, companyId);
|
||||
|
||||
// Load existing workers for upsert matching by name
|
||||
var existingWorkers = await _unitOfWork.ShopWorkers.GetAllAsync();
|
||||
var workerDict = existingWorkers
|
||||
.Where(w => !string.IsNullOrEmpty(w.Name))
|
||||
.GroupBy(w => w.Name.Trim().ToUpperInvariant())
|
||||
.ToDictionary(g => g.Key, g => g.First());
|
||||
|
||||
foreach (var record in records)
|
||||
{
|
||||
rowNumber++;
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(record.Name))
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: Name is required.");
|
||||
result.ErrorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse role
|
||||
ShopWorkerRole role = ShopWorkerRole.GeneralLabor;
|
||||
if (!string.IsNullOrEmpty(record.Role))
|
||||
{
|
||||
if (!Enum.TryParse<ShopWorkerRole>(record.Role.Replace(" ", ""), true, out role))
|
||||
{
|
||||
result.Warnings.Add($"Row {rowNumber}: Role '{record.Role}' not recognized. Valid values: GeneralLabor, Sandblaster, Coater, Masker, QualityControl, OvenOperator, Supervisor, Maintenance. Using 'GeneralLabor'.");
|
||||
role = ShopWorkerRole.GeneralLabor;
|
||||
}
|
||||
}
|
||||
|
||||
var key = record.Name.Trim().ToUpperInvariant();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
if (workerDict.TryGetValue(key, out var existing))
|
||||
{
|
||||
// Update
|
||||
existing.Role = role;
|
||||
existing.Phone = record.Phone ?? existing.Phone;
|
||||
existing.Email = record.Email ?? existing.Email;
|
||||
if (record.IsActive.HasValue) existing.IsActive = record.IsActive.Value;
|
||||
existing.Notes = record.Notes ?? existing.Notes;
|
||||
existing.UpdatedAt = now;
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
result.Warnings.Add($"Row {rowNumber}: Updated existing shop worker '{record.Name}'.");
|
||||
result.SuccessCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
var worker = new Core.Entities.ShopWorker
|
||||
{
|
||||
CompanyId = companyId,
|
||||
Name = record.Name.Trim(),
|
||||
Role = role,
|
||||
Phone = record.Phone,
|
||||
Email = record.Email,
|
||||
IsActive = record.IsActive ?? true,
|
||||
Notes = record.Notes,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now
|
||||
};
|
||||
|
||||
await _unitOfWork.ShopWorkers.AddAsync(worker);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
result.SuccessCount++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Errors.Add($"Row {rowNumber}: Database error - {ex.Message}");
|
||||
result.ErrorCount++;
|
||||
_logger.LogError(ex, "Error saving shop worker at row {RowNumber}", rowNumber);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Shop worker import completed: {SuccessCount} succeeded, {ErrorCount} failed", result.SuccessCount, result.ErrorCount);
|
||||
result.Success = result.SuccessCount > 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
result.Errors.Add($"Fatal error: {ex.Message}");
|
||||
result.Success = false;
|
||||
_logger.LogError(ex, "Fatal error importing shop workers");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Prep Service Import
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1209,6 +1209,15 @@ Rules:
|
||||
sb.AppendLine("Page content:");
|
||||
sb.AppendLine(pageContent);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(fetchUrl))
|
||||
{
|
||||
// Page content unavailable (fetch failed or blocked) — still surface the URL so Claude
|
||||
// can use its training knowledge of the manufacturer URL structure (e.g. Prismatic SKU
|
||||
// in the path) to infer product identity rather than returning all-null fields.
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Product URL (page content could not be fetched): {fetchUrl}");
|
||||
sb.AppendLine("Use your training knowledge of this manufacturer and the URL to fill in as many fields as possible.");
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
@@ -1152,6 +1152,156 @@ public class NotificationService : INotificationService
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends appointment reminder emails when an appointment's reminder window opens.
|
||||
/// Two emails are dispatched independently:
|
||||
/// <list type="bullet">
|
||||
/// <item>Customer email — sent when a customer is linked, has an email address, and has
|
||||
/// email notifications enabled (<see cref="Customer.NotifyByEmail"/>).</item>
|
||||
/// <item>Staff email — sent to <see cref="BaseEntity.CreatedBy"/> (the user who created
|
||||
/// the appointment). This fires regardless of whether a customer is linked.</item>
|
||||
/// </list>
|
||||
/// Called exclusively by
|
||||
/// <see cref="PowderCoating.Web.BackgroundServices.AppointmentReminderBackgroundService"/>
|
||||
/// after it stamps <c>ReminderSentAt</c> — the caller owns deduplication.
|
||||
/// </summary>
|
||||
public async Task NotifyAppointmentReminderAsync(Appointment appointment)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (companyName, company) = await GetCompanyAsync(appointment.CompanyId);
|
||||
var (replyToEmail, replyToName) = await GetEmailFromAsync(appointment.CompanyId);
|
||||
var baseUrl = await GetBaseUrlAsync();
|
||||
|
||||
var locationLine = !string.IsNullOrWhiteSpace(appointment.Location)
|
||||
? $"<br/><strong>Location:</strong> {WebUtility.HtmlEncode(appointment.Location)}"
|
||||
: string.Empty;
|
||||
|
||||
var appointmentDate = appointment.ScheduledStartTime.ToString("dddd, MMMM d, yyyy");
|
||||
var appointmentTime = appointment.IsAllDay
|
||||
? "All Day"
|
||||
: appointment.ScheduledStartTime.ToString("h:mm tt");
|
||||
var defaultSubject = $"Appointment Reminder — {appointment.Title} on {appointment.ScheduledStartTime:MMMM d, yyyy}";
|
||||
|
||||
// ── Customer email ────────────────────────────────────────────────
|
||||
if (appointment.CustomerId != null)
|
||||
{
|
||||
var customer = appointment.Customer
|
||||
?? await _context.Customers.FindAsync(appointment.CustomerId.Value);
|
||||
|
||||
if (customer != null)
|
||||
{
|
||||
var customerName = GetCustomerDisplayName(customer);
|
||||
var reminderEmails = ParseEmailList(customer.Email);
|
||||
|
||||
if (!customer.NotifyByEmail || reminderEmails.Count == 0)
|
||||
{
|
||||
if (reminderEmails.Count > 0)
|
||||
{
|
||||
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.AppointmentReminder,
|
||||
customerName, string.Join(", ", reminderEmails), appointment.CompanyId,
|
||||
customerId: customer.Id));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var customerValues = new Dictionary<string, string>
|
||||
{
|
||||
["companyName"] = companyName,
|
||||
["customerName"] = customerName,
|
||||
["appointmentTitle"] = appointment.Title,
|
||||
["appointmentDate"] = appointmentDate,
|
||||
["appointmentTime"] = appointmentTime,
|
||||
["locationLine"] = locationLine
|
||||
};
|
||||
|
||||
var (custSubject, custHtml) = await GetRenderedEmailAsync(
|
||||
appointment.CompanyId, NotificationType.AppointmentReminder, customerValues, defaultSubject);
|
||||
|
||||
var custFullHtml = AppendUnsubscribeFooterHtml(custHtml, customer.UnsubscribeToken, company, baseUrl);
|
||||
var custPlainText = StripHtml(custFullHtml);
|
||||
|
||||
var (custOk, custErr, custLog) = await SendToEmailListAsync(
|
||||
customer.Email, customerName, custSubject, custPlainText, custFullHtml,
|
||||
replyToEmail: replyToEmail, replyToName: replyToName);
|
||||
|
||||
await WriteLog(new NotificationLog
|
||||
{
|
||||
Channel = NotificationChannel.Email,
|
||||
NotificationType = NotificationType.AppointmentReminder,
|
||||
Status = custOk ? NotificationStatus.Sent : NotificationStatus.Failed,
|
||||
RecipientName = customerName,
|
||||
Recipient = custLog,
|
||||
Subject = custSubject,
|
||||
Message = custPlainText,
|
||||
ErrorMessage = custErr,
|
||||
SentAt = DateTime.UtcNow,
|
||||
CustomerId = customer.Id,
|
||||
CompanyId = appointment.CompanyId
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Staff email ───────────────────────────────────────────────────
|
||||
// Send to whoever created the appointment so they get an out-of-app reminder.
|
||||
if (!string.IsNullOrWhiteSpace(appointment.CreatedBy))
|
||||
{
|
||||
// Look up the user's display name from Identity if available.
|
||||
var staffUser = await _context.Users
|
||||
.FirstOrDefaultAsync(u => u.Email == appointment.CreatedBy);
|
||||
var staffName = !string.IsNullOrWhiteSpace(staffUser?.FullName)
|
||||
? staffUser.FullName
|
||||
: appointment.CreatedBy;
|
||||
|
||||
// Include a customer line only when a customer is linked.
|
||||
var customerLine = appointment.Customer != null
|
||||
? $"<br/><strong>Customer:</strong> {WebUtility.HtmlEncode(GetCustomerDisplayName(appointment.Customer))}"
|
||||
: string.Empty;
|
||||
|
||||
var staffValues = new Dictionary<string, string>
|
||||
{
|
||||
["companyName"] = companyName,
|
||||
["staffName"] = staffName,
|
||||
["appointmentTitle"] = appointment.Title,
|
||||
["appointmentDate"] = appointmentDate,
|
||||
["appointmentTime"] = appointmentTime,
|
||||
["customerLine"] = customerLine,
|
||||
["locationLine"] = locationLine
|
||||
};
|
||||
|
||||
var staffDefaultSubject = $"[Reminder] {appointment.Title} — {appointment.ScheduledStartTime:MMMM d, yyyy 'at' h:mm tt}";
|
||||
|
||||
var (staffSubject, staffHtml) = await GetRenderedEmailAsync(
|
||||
appointment.CompanyId, NotificationType.AppointmentReminderStaff, staffValues, staffDefaultSubject);
|
||||
|
||||
var staffPlainText = StripHtml(staffHtml);
|
||||
|
||||
var (staffOk, staffErr, staffLog) = await SendToEmailListAsync(
|
||||
appointment.CreatedBy, staffName, staffSubject, staffPlainText, staffHtml,
|
||||
replyToEmail: replyToEmail, replyToName: replyToName);
|
||||
|
||||
await WriteLog(new NotificationLog
|
||||
{
|
||||
Channel = NotificationChannel.Email,
|
||||
NotificationType = NotificationType.AppointmentReminderStaff,
|
||||
Status = staffOk ? NotificationStatus.Sent : NotificationStatus.Failed,
|
||||
RecipientName = staffName,
|
||||
Recipient = staffLog,
|
||||
Subject = staffSubject,
|
||||
Message = staffPlainText,
|
||||
ErrorMessage = staffErr,
|
||||
SentAt = DateTime.UtcNow,
|
||||
CompanyId = appointment.CompanyId
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "NotifyAppointmentReminderAsync failed for appointment {AppointmentId}", appointment.Id);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Fallback default templates (used when company has no DB template)
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -1217,6 +1367,14 @@ public class NotificationService : INotificationService
|
||||
"Payment Reminder — Invoice {{invoiceNumber}} ({{daysOverdue}} days overdue)",
|
||||
"<p>Dear {{customerName}},</p><p>This is a friendly reminder that invoice <strong>{{invoiceNumber}}</strong> for <strong>{{invoiceTotal}}</strong> was due on <strong>{{dueDate}}</strong> and is now <strong>{{daysOverdue}} days overdue</strong>.</p><p>Outstanding balance: <strong>{{balanceDue}}</strong></p><p>Please arrange payment at your earliest convenience. If you have already sent payment, please disregard this notice.</p><p>Thank you for your business with {{companyName}}.</p>"
|
||||
),
|
||||
[(NotificationType.AppointmentReminder, NotificationChannel.Email)] = (
|
||||
"Appointment Reminder — {{appointmentTitle}} on {{appointmentDate}}",
|
||||
"<p>Dear {{customerName}},</p><p>This is a reminder that you have an upcoming appointment with <strong>{{companyName}}</strong>.</p><p><strong>Appointment:</strong> {{appointmentTitle}}<br/><strong>Date & Time:</strong> {{appointmentDate}} at {{appointmentTime}}{{locationLine}}</p><p>If you have any questions or need to reschedule, please contact us at your earliest convenience.</p><p>Thank you for choosing {{companyName}}.</p>"
|
||||
),
|
||||
[(NotificationType.AppointmentReminderStaff, NotificationChannel.Email)] = (
|
||||
"[Reminder] {{appointmentTitle}} — {{appointmentDate}}",
|
||||
"<p>Hi {{staffName}},</p><p>This is a reminder that you have an upcoming appointment.</p><p><strong>Appointment:</strong> {{appointmentTitle}}<br/><strong>Date & Time:</strong> {{appointmentDate}} at {{appointmentTime}}{{customerLine}}{{locationLine}}</p><p>— {{companyName}}</p>"
|
||||
),
|
||||
};
|
||||
|
||||
public static (string? Subject, string Body)? Get(NotificationType type, NotificationChannel channel)
|
||||
|
||||
@@ -162,7 +162,6 @@ public class StripeConnectService : IStripeConnectService
|
||||
decimal invoiceTotal,
|
||||
decimal surchargeAmount,
|
||||
string currency,
|
||||
string customerEmail,
|
||||
string invoiceNumber,
|
||||
int invoiceId)
|
||||
{
|
||||
@@ -175,7 +174,6 @@ public class StripeConnectService : IStripeConnectService
|
||||
{
|
||||
Amount = amountInCents,
|
||||
Currency = currency.ToLower(),
|
||||
ReceiptEmail = customerEmail,
|
||||
Description = $"Invoice {invoiceNumber}",
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
@@ -215,7 +213,6 @@ public class StripeConnectService : IStripeConnectService
|
||||
decimal depositAmount,
|
||||
decimal surchargeAmount,
|
||||
string currency,
|
||||
string customerEmail,
|
||||
string quoteNumber,
|
||||
int quoteId)
|
||||
{
|
||||
@@ -228,7 +225,6 @@ public class StripeConnectService : IStripeConnectService
|
||||
{
|
||||
Amount = amountInCents,
|
||||
Currency = currency.ToLower(),
|
||||
ReceiptEmail = customerEmail,
|
||||
Description = $"Deposit for quote {quoteNumber}",
|
||||
Metadata = new Dictionary<string, string>
|
||||
{
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
|
||||
namespace PowderCoating.Web.BackgroundServices;
|
||||
|
||||
/// <summary>
|
||||
/// Polls every 60 seconds for appointments whose reminder window has opened and dispatches
|
||||
/// an email to the linked customer plus an in-app bell notification to company staff.
|
||||
///
|
||||
/// Deduplication strategy: after selecting candidates the service immediately stamps
|
||||
/// <c>ReminderSentAt</c> on each appointment and saves before calling the notification
|
||||
/// methods. This prevents a second loop iteration from re-sending if notifications are slow
|
||||
/// or the application restarts mid-batch. A 24-hour lookback window caps the query so that
|
||||
/// appointments that slipped through (e.g., server downtime) are silently skipped rather
|
||||
/// than sending a stale reminder.
|
||||
/// </summary>
|
||||
public class AppointmentReminderBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<AppointmentReminderBackgroundService> _logger;
|
||||
|
||||
private static readonly TimeSpan PollingInterval = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Appointments whose scheduled start is more than this far in the past are ignored even
|
||||
/// if their reminder was never sent (server was down, etc.). We do not want to blast a
|
||||
/// customer with a "your appointment is in 30 minutes" email hours after it was due.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan MaxLookback = TimeSpan.FromHours(24);
|
||||
|
||||
public AppointmentReminderBackgroundService(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<AppointmentReminderBackgroundService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Long-running loop that wakes every <see cref="PollingInterval"/> (60 s) and calls
|
||||
/// <see cref="RunAsync"/>. Uses <see cref="Task.Delay"/> with the cancellation token so
|
||||
/// the service shuts down promptly when the application stops.
|
||||
/// </summary>
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("AppointmentReminderBackgroundService started.");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(PollingInterval, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (stoppingToken.IsCancellationRequested) break;
|
||||
|
||||
await RunAsync(stoppingToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("AppointmentReminderBackgroundService stopped.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One poll iteration: find all appointments whose reminder window has opened, stamp them,
|
||||
/// then dispatch email + in-app notifications. A fresh DI scope is created per poll so that
|
||||
/// the DbContext change tracker is clean each time.
|
||||
/// </summary>
|
||||
private async Task RunAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
var notificationService = scope.ServiceProvider.GetRequiredService<INotificationService>();
|
||||
var inAppService = scope.ServiceProvider.GetRequiredService<IInAppNotificationService>();
|
||||
|
||||
// ScheduledStartTime is stored as server-local time (no UTC conversion on form submit),
|
||||
// so compare against DateTime.Now rather than UtcNow to avoid a 4-hour EDT offset.
|
||||
var now = DateTime.Now;
|
||||
var lookback = now - MaxLookback;
|
||||
|
||||
// Find appointments where:
|
||||
// - Reminder is enabled and has not been sent yet
|
||||
// - The reminder window has opened: ScheduledStartTime - ReminderMinutesBefore <= now
|
||||
// - The appointment hasn't been sitting unprocessed for more than MaxLookback
|
||||
// - The appointment status is not terminal (not cancelled, completed, no-show, etc.)
|
||||
// IgnoreQueryFilters bypasses the tenant filter — no HTTP context in a background service.
|
||||
var candidates = await db.Appointments
|
||||
.IgnoreQueryFilters()
|
||||
.Include(a => a.Customer)
|
||||
.Include(a => a.AppointmentStatus)
|
||||
.Where(a =>
|
||||
!a.IsDeleted &&
|
||||
a.IsReminderEnabled &&
|
||||
a.ReminderSentAt == null &&
|
||||
a.ScheduledStartTime > lookback &&
|
||||
EF.Functions.DateDiffMinute(now, a.ScheduledStartTime) <= a.ReminderMinutesBefore &&
|
||||
!a.AppointmentStatus.IsTerminalStatus)
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (candidates.Count == 0) return;
|
||||
|
||||
_logger.LogInformation(
|
||||
"AppointmentReminderBackgroundService: {Count} appointment reminder(s) to dispatch.",
|
||||
candidates.Count);
|
||||
|
||||
// Stamp ReminderSentAt before sending — prevents a restart from re-sending.
|
||||
var stampedAt = now;
|
||||
foreach (var appt in candidates)
|
||||
appt.ReminderSentAt = stampedAt;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
// Now send notifications. Failures here don't roll back the stamp because we'd
|
||||
// rather skip one reminder than spam a customer on every restart.
|
||||
foreach (var appt in candidates)
|
||||
{
|
||||
if (ct.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
// Email to linked customer (no-ops internally if customer has opted out)
|
||||
await notificationService.NotifyAppointmentReminderAsync(appt);
|
||||
|
||||
// In-app bell notification for company staff
|
||||
var when = appt.IsAllDay
|
||||
? appt.ScheduledStartTime.ToString("MMMM d, yyyy")
|
||||
: appt.ScheduledStartTime.ToString("MMMM d, yyyy 'at' h:mm tt");
|
||||
|
||||
await inAppService.CreateAsync(
|
||||
companyId: appt.CompanyId,
|
||||
title: $"Appointment Reminder: {appt.Title}",
|
||||
message: $"{appt.AppointmentNumber} is scheduled for {when}.",
|
||||
notificationType: "AppointmentReminder",
|
||||
link: $"/Appointments/Details/{appt.Id}",
|
||||
customerId: appt.CustomerId);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Reminder dispatched for appointment {AppointmentNumber} (id {Id}, company {CompanyId}).",
|
||||
appt.AppointmentNumber, appt.Id, appt.CompanyId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"Failed to dispatch reminder for appointment {AppointmentId}.", appt.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "AppointmentReminderBackgroundService poll failed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,7 +133,6 @@ public class AccountDataExportController : Controller
|
||||
case "Inventory": await AddInventorySheet(package, companyId, headerColor); break;
|
||||
case "Equipment": await AddEquipmentSheet(package, companyId, headerColor); break;
|
||||
case "Vendors": await AddVendorsSheet(package, companyId, headerColor); break;
|
||||
case "ShopWorkers": await AddShopWorkersSheet(package, companyId, headerColor); break;
|
||||
case "Users": await AddUsersSheet(package, companyId, headerColor); break;
|
||||
}
|
||||
}
|
||||
@@ -182,7 +181,6 @@ public class AccountDataExportController : Controller
|
||||
case "Inventory": WriteCsvEntry(zip, "Inventory.csv", await BuildInventoryCsv(companyId)); break;
|
||||
case "Equipment": WriteCsvEntry(zip, "Equipment.csv", await BuildEquipmentCsv(companyId)); break;
|
||||
case "Vendors": WriteCsvEntry(zip, "Vendors.csv", await BuildVendorsCsv(companyId)); break;
|
||||
case "ShopWorkers": WriteCsvEntry(zip, "ShopWorkers.csv", await BuildShopWorkersCsv(companyId)); break;
|
||||
case "Users": WriteCsvEntry(zip, "Users.csv", await BuildUsersCsv(companyId)); break;
|
||||
}
|
||||
}
|
||||
@@ -268,12 +266,6 @@ public class AccountDataExportController : Controller
|
||||
.Where(s => s.CompanyId == companyId && !s.IsDeleted)
|
||||
.OrderBy(s => s.CompanyName).ToListAsync();
|
||||
|
||||
/// <summary>Fetches all non-deleted shop workers for the company.</summary>
|
||||
private Task<List<ShopWorker>> FetchShopWorkersAsync(int companyId) =>
|
||||
_db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(w => w.CompanyId == companyId && !w.IsDeleted)
|
||||
.OrderBy(w => w.Name).ToListAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Fetches all users for the company. <c>IsDeleted</c> is intentionally omitted because
|
||||
/// Identity users use <c>IsActive = false</c> for soft-deletion, not the base-entity flag.
|
||||
@@ -462,23 +454,6 @@ public class AccountDataExportController : Controller
|
||||
AutoFit(ws, headers.Length);
|
||||
}
|
||||
|
||||
private async Task AddShopWorkersSheet(ExcelPackage pkg, int companyId, Color hdr)
|
||||
{
|
||||
var data = await FetchShopWorkersAsync(companyId);
|
||||
var ws = pkg.Workbook.Worksheets.Add("Shop Workers");
|
||||
var headers = new[] { "ID", "Name", "Role", "Phone", "Email", "Active", "Notes" };
|
||||
WriteHeader(ws, headers, hdr);
|
||||
for (int i = 0; i < data.Count; i++)
|
||||
{
|
||||
var r = i + 2; var w = data[i];
|
||||
ws.Cells[r, 1].Value = w.Id; ws.Cells[r, 2].Value = w.Name;
|
||||
ws.Cells[r, 3].Value = w.Role.ToString(); ws.Cells[r, 4].Value = w.Phone;
|
||||
ws.Cells[r, 5].Value = w.Email; ws.Cells[r, 6].Value = w.IsActive ? "Yes" : "No";
|
||||
ws.Cells[r, 7].Value = w.Notes;
|
||||
}
|
||||
AutoFit(ws, headers.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a "Users" worksheet. All users (active and inactive) are included because Identity
|
||||
/// uses <c>IsActive = false</c> for soft-deletion; <c>IsDeleted</c> is not applicable here.
|
||||
@@ -611,17 +586,6 @@ public class AccountDataExportController : Controller
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>Column names match <c>ShopWorkerImportDto</c> exactly so the file can be re-imported.</summary>
|
||||
private async Task<string> BuildShopWorkersCsv(int companyId)
|
||||
{
|
||||
var data = await FetchShopWorkersAsync(companyId);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Name,Role,Phone,Email,IsActive,Notes");
|
||||
foreach (var w in data)
|
||||
sb.AppendLine($"{CsvEscape(w.Name)},{w.Role},{CsvEscape(w.Phone)},{CsvEscape(w.Email)},{w.IsActive.ToString().ToLower()},{CsvEscape(w.Notes)}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// All users (active and inactive) are exported for completeness and compliance — mirrors
|
||||
/// the reasoning in <see cref="AddUsersSheet"/> and <see cref="FetchUsersAsync"/>.
|
||||
@@ -675,13 +639,13 @@ public class AccountDataExportController : Controller
|
||||
|
||||
/// <summary>
|
||||
/// Returns the subset of selected sheet names reordered into the canonical export sequence
|
||||
/// (Customers → Jobs → Quotes → Invoices → Inventory → Equipment → Vendors → ShopWorkers → Users).
|
||||
/// (Customers → Jobs → Quotes → Invoices → Inventory → Equipment → Vendors → Users).
|
||||
/// Guarantees consistent file layout regardless of the order check-boxes were ticked on the form.
|
||||
/// Sheet names not in the canonical list are silently dropped.
|
||||
/// </summary>
|
||||
private static string[] OrderSheets(string[] sheets)
|
||||
{
|
||||
var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "ShopWorkers", "Users" };
|
||||
var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "Users" };
|
||||
return order.Where(sheets.Contains).ToArray();
|
||||
}
|
||||
|
||||
|
||||
@@ -60,10 +60,11 @@ public class AccountingExportController : Controller
|
||||
{
|
||||
var start = startDate.Date;
|
||||
var end = endDate.Date.AddDays(1).AddTicks(-1);
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
// ── Load data ─────────────────────────────────────────────────────────
|
||||
var invoices = (await _unitOfWork.Invoices.FindAsync(
|
||||
i => i.InvoiceDate >= start && i.InvoiceDate <= end,
|
||||
i => i.CompanyId == companyId && i.InvoiceDate >= start && i.InvoiceDate <= end,
|
||||
false,
|
||||
i => i.InvoiceItems,
|
||||
i => i.Payments,
|
||||
@@ -72,7 +73,7 @@ public class AccountingExportController : Controller
|
||||
.ToList();
|
||||
|
||||
var expenses = (await _unitOfWork.Expenses.FindAsync(
|
||||
e => e.Date >= start && e.Date <= end,
|
||||
e => e.CompanyId == companyId && e.Date >= start && e.Date <= end,
|
||||
false,
|
||||
e => e.Vendor,
|
||||
e => e.ExpenseAccount,
|
||||
@@ -82,7 +83,7 @@ public class AccountingExportController : Controller
|
||||
|
||||
var bills = await _unitOfWork.Bills.GetForDateRangeAsync(start, end);
|
||||
|
||||
var customers = (await _unitOfWork.Customers.GetAllAsync())
|
||||
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId))
|
||||
.OrderBy(c => c.CompanyName ?? c.ContactFirstName)
|
||||
.ToList();
|
||||
|
||||
|
||||
@@ -381,9 +381,15 @@ public class AppointmentsController : Controller
|
||||
return View(dto);
|
||||
}
|
||||
|
||||
// Map changes
|
||||
// Map changes — capture old start before overwrite so we can detect a reschedule.
|
||||
var previousStart = appointment.ScheduledStartTime;
|
||||
_mapper.Map(dto, appointment);
|
||||
|
||||
// If the appointment was rescheduled, clear the reminder stamp so the background
|
||||
// service will fire again at the new time.
|
||||
if (appointment.ScheduledStartTime != previousStart)
|
||||
appointment.ReminderSentAt = null;
|
||||
|
||||
// Update
|
||||
await _unitOfWork.Appointments.UpdateAsync(appointment);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
@@ -486,9 +492,12 @@ public class AppointmentsController : Controller
|
||||
try
|
||||
{
|
||||
var events = new List<CalendarEventDto>();
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
// 1. Fetch appointments in date range
|
||||
var allAppointments = await _unitOfWork.Appointments.GetAllAsync(false,
|
||||
var allAppointments = await _unitOfWork.Appointments.FindAsync(
|
||||
a => a.CompanyId == companyId,
|
||||
false,
|
||||
a => a.Customer,
|
||||
a => a.AppointmentType,
|
||||
a => a.AppointmentStatus);
|
||||
@@ -501,7 +510,9 @@ public class AppointmentsController : Controller
|
||||
events.AddRange(appointmentEvents);
|
||||
|
||||
// 2. Fetch maintenance records in date range
|
||||
var allMaintenanceRecords = await _unitOfWork.MaintenanceRecords.GetAllAsync(false,
|
||||
var allMaintenanceRecords = await _unitOfWork.MaintenanceRecords.FindAsync(
|
||||
m => m.CompanyId == companyId,
|
||||
false,
|
||||
m => m.Equipment);
|
||||
|
||||
var maintenanceRecords = allMaintenanceRecords
|
||||
@@ -539,7 +550,9 @@ public class AppointmentsController : Controller
|
||||
}
|
||||
|
||||
// 3. Fetch jobs and add as all-day events
|
||||
var allJobs = await _unitOfWork.Jobs.GetAllAsync(false,
|
||||
var allJobs = await _unitOfWork.Jobs.FindAsync(
|
||||
j => j.CompanyId == companyId,
|
||||
false,
|
||||
j => j.Customer,
|
||||
j => j.JobStatus);
|
||||
|
||||
@@ -718,6 +731,8 @@ public class AppointmentsController : Controller
|
||||
var duration = appointment.ScheduledEndTime - appointment.ScheduledStartTime;
|
||||
appointment.ScheduledStartTime = start;
|
||||
appointment.ScheduledEndTime = end;
|
||||
// Drag-drop always changes the time — reset so the reminder fires at the new time.
|
||||
appointment.ReminderSentAt = null;
|
||||
|
||||
await _unitOfWork.Appointments.UpdateAsync(appointment);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
@@ -746,13 +761,16 @@ public class AppointmentsController : Controller
|
||||
try
|
||||
{
|
||||
var terminalCodes = new[] { AppConstants.StatusCodes.Job.Completed, AppConstants.StatusCodes.Job.Delivered, AppConstants.StatusCodes.Job.Cancelled };
|
||||
var allJobs = await _unitOfWork.Jobs.GetAllAsync(false,
|
||||
var calCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var allJobs = await _unitOfWork.Jobs.FindAsync(
|
||||
j => j.CompanyId == calCompanyId,
|
||||
false,
|
||||
j => j.Customer, j => j.JobStatus, j => j.JobItems);
|
||||
|
||||
// Load coats separately — filter by JobItemId using already-loaded item IDs
|
||||
var jobItemIds = allJobs.SelectMany(j => j.JobItems.Select(i => i.Id)).ToList();
|
||||
var allCoats = await _unitOfWork.JobItemCoats.FindAsync(
|
||||
c => jobItemIds.Contains(c.JobItemId));
|
||||
c => jobItemIds.Contains(c.JobItemId) && c.CompanyId == calCompanyId);
|
||||
|
||||
var coatsByItemId = allCoats
|
||||
.Where(c => !c.IsDeleted)
|
||||
@@ -891,7 +909,9 @@ public class AppointmentsController : Controller
|
||||
/// </summary>
|
||||
private async Task PopulateCreateDropdowns()
|
||||
{
|
||||
var customers = await _unitOfWork.Customers.GetAllAsync();
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
|
||||
var customerList = customers.Select(c => new
|
||||
{
|
||||
c.Id,
|
||||
@@ -903,19 +923,16 @@ public class AppointmentsController : Controller
|
||||
.ToList();
|
||||
ViewBag.Customers = new SelectList(customerList, "Id", "DisplayName");
|
||||
|
||||
// Use cached appointment types
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var types = await _lookupCache.GetAppointmentTypeLookupsAsync(companyId);
|
||||
ViewBag.AppointmentTypes = new SelectList(types.Where(t => t.IsActive).OrderBy(t => t.DisplayOrder), "Id", "DisplayName");
|
||||
|
||||
var companyIdForWorkers = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var workers = await _userManager.Users
|
||||
.Where(u => u.CompanyId == companyIdForWorkers && u.IsActive && u.CompanyRole != null)
|
||||
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
|
||||
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
|
||||
.ToListAsync();
|
||||
ViewBag.Workers = new SelectList(workers.Select(u => new { u.Id, FullName = u.FullName }), "Id", "FullName");
|
||||
|
||||
var jobs = await _unitOfWork.Jobs.GetAllAsync();
|
||||
var jobs = await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId);
|
||||
ViewBag.Jobs = new SelectList(jobs.OrderBy(j => j.JobNumber), "Id", "JobNumber");
|
||||
}
|
||||
|
||||
|
||||
@@ -27,15 +27,18 @@ namespace PowderCoating.Web.Controllers
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IMapper _mapper;
|
||||
private readonly ITenantContext _tenantContext;
|
||||
private readonly ILogger<CatalogCategoriesController> _logger;
|
||||
|
||||
public CatalogCategoriesController(
|
||||
IUnitOfWork unitOfWork,
|
||||
IMapper mapper,
|
||||
ITenantContext tenantContext,
|
||||
ILogger<CatalogCategoriesController> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
_tenantContext = tenantContext;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -52,8 +55,9 @@ namespace PowderCoating.Web.Controllers
|
||||
{
|
||||
try
|
||||
{
|
||||
var indexCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var categories = await _unitOfWork.CatalogCategories
|
||||
.GetAllAsync(false,
|
||||
.FindAsync(c => c.CompanyId == indexCompanyId, false,
|
||||
c => c.ParentCategory,
|
||||
c => c.SubCategories,
|
||||
c => c.Items);
|
||||
@@ -164,7 +168,8 @@ namespace PowderCoating.Web.Controllers
|
||||
if (ModelState.IsValid)
|
||||
{
|
||||
// Check for duplicate category name under the same parent (case-insensitive)
|
||||
var allCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var allCategories = await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == companyId);
|
||||
var existingCategory = allCategories.FirstOrDefault(c =>
|
||||
c.Name.Equals(dto.Name.Trim(), StringComparison.OrdinalIgnoreCase) &&
|
||||
c.ParentCategoryId == dto.ParentCategoryId);
|
||||
@@ -272,7 +277,8 @@ namespace PowderCoating.Web.Controllers
|
||||
|
||||
if (nameChanged || parentChanged)
|
||||
{
|
||||
var allCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
|
||||
var editCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var allCategories = await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == editCompanyId);
|
||||
var existingCategory = allCategories.FirstOrDefault(c =>
|
||||
c.Id != id &&
|
||||
c.Name.Equals(dto.Name.Trim(), StringComparison.OrdinalIgnoreCase) &&
|
||||
@@ -444,7 +450,8 @@ namespace PowderCoating.Web.Controllers
|
||||
var trimmedName = request.Name.Trim();
|
||||
|
||||
// Check for duplicate category name under the same parent (case-insensitive)
|
||||
var allCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
|
||||
var quickCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var allCategories = await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == quickCompanyId);
|
||||
var existingCategory = allCategories.FirstOrDefault(c =>
|
||||
c.Name.Equals(trimmedName, StringComparison.OrdinalIgnoreCase) &&
|
||||
c.ParentCategoryId == request.ParentCategoryId);
|
||||
@@ -500,8 +507,9 @@ namespace PowderCoating.Web.Controllers
|
||||
{
|
||||
try
|
||||
{
|
||||
var treeCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var categories = await _unitOfWork.CatalogCategories
|
||||
.GetAllAsync(false, c => c.SubCategories, c => c.Items);
|
||||
.FindAsync(c => c.CompanyId == treeCompanyId, false, c => c.SubCategories, c => c.Items);
|
||||
|
||||
// Build tree from root categories
|
||||
var rootCategories = categories
|
||||
@@ -535,7 +543,8 @@ namespace PowderCoating.Web.Controllers
|
||||
{
|
||||
try
|
||||
{
|
||||
var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList();
|
||||
var dropdownCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var categories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == dropdownCompanyId)).ToList();
|
||||
|
||||
// Build hierarchical list (parents before children)
|
||||
var hierarchicalList = new List<CatalogCategory>();
|
||||
@@ -573,7 +582,8 @@ namespace PowderCoating.Web.Controllers
|
||||
/// </param>
|
||||
private async Task PopulateParentCategoryDropdown(int? excludeCategoryId = null)
|
||||
{
|
||||
var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList();
|
||||
var parentDropCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var categories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == parentDropCompanyId)).ToList();
|
||||
|
||||
// Exclude the current category and its descendants to prevent circular references
|
||||
var excludedIds = new HashSet<int>();
|
||||
@@ -700,7 +710,8 @@ namespace PowderCoating.Web.Controllers
|
||||
if (categoryId == newParentId)
|
||||
return true;
|
||||
|
||||
var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList();
|
||||
var circleCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var categories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == circleCompanyId)).ToList();
|
||||
var current = categories.FirstOrDefault(c => c.Id == newParentId);
|
||||
|
||||
while (current != null)
|
||||
|
||||
@@ -83,7 +83,8 @@ namespace PowderCoating.Web.Controllers
|
||||
try
|
||||
{
|
||||
// Get all categories with their items
|
||||
var allCategories = (await _unitOfWork.CatalogCategories.GetAllAsync(false, c => c.Items)).ToList();
|
||||
var itemsCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var allCategories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == itemsCompanyId, false, c => c.Items)).ToList();
|
||||
var allItems = allCategories.SelectMany(c => c.Items).ToList();
|
||||
|
||||
// Apply search filter
|
||||
@@ -578,7 +579,8 @@ namespace PowderCoating.Web.Controllers
|
||||
return Json(new List<object>());
|
||||
}
|
||||
|
||||
var allItems = await _unitOfWork.CatalogItems.GetAllAsync(false, i => i.Category);
|
||||
var searchCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var allItems = await _unitOfWork.CatalogItems.FindAsync(i => i.CompanyId == searchCompanyId, false, i => i.Category);
|
||||
var search = searchTerm.ToLower();
|
||||
|
||||
var items = allItems
|
||||
@@ -694,7 +696,8 @@ namespace PowderCoating.Web.Controllers
|
||||
/// </summary>
|
||||
private async Task PopulateCategoryDropdown()
|
||||
{
|
||||
var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList();
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var categories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == companyId)).ToList();
|
||||
|
||||
// Build hierarchical list (parents before children)
|
||||
var hierarchicalList = new List<CatalogCategory>();
|
||||
@@ -1045,7 +1048,7 @@ namespace PowderCoating.Web.Controllers
|
||||
// Load all categories so we can build full paths (e.g. "Cerakote > Firearms").
|
||||
// The full path gives Claude the coating-type context it needs — an item in
|
||||
// "Firearms" under "Cerakote" costs very differently than one under "Powder Coat".
|
||||
var allCategories = (await _unitOfWork.CatalogCategories.GetAllAsync())
|
||||
var allCategories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == currentUser.CompanyId))
|
||||
.ToDictionary(c => c.Id);
|
||||
|
||||
// Load company operating costs
|
||||
|
||||
@@ -142,10 +142,10 @@ public class CompanySettingsController : Controller
|
||||
&& !connectClientId.Contains("your_connect_client_id_here", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Load notification templates for inline tab
|
||||
var existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
|
||||
var existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
|
||||
var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
|
||||
if (seeded > 0)
|
||||
existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
|
||||
existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
|
||||
|
||||
dto.NotificationTemplates = existing
|
||||
.OrderBy(t => (int)t.NotificationType).ThenBy(t => (int)t.Channel)
|
||||
@@ -755,9 +755,8 @@ public class CompanySettingsController : Controller
|
||||
|
||||
var costs = company.OperatingCosts;
|
||||
|
||||
var ovens = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive)).OrderBy(o => o.DisplayOrder).ToList();
|
||||
var workers = (await _unitOfWork.ShopWorkers.FindAsync(w => w.IsActive)).ToList();
|
||||
var coatingCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsCoating)).ToList();
|
||||
var ovens = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive && o.CompanyId == companyId.Value)).OrderBy(o => o.DisplayOrder).ToList();
|
||||
var coatingCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsCoating && c.CompanyId == companyId.Value)).ToList();
|
||||
|
||||
var sb = new System.Text.StringBuilder();
|
||||
|
||||
@@ -783,8 +782,7 @@ public class CompanySettingsController : Controller
|
||||
ShopCapabilityTier.Large => "high-volume",
|
||||
_ => "small"
|
||||
};
|
||||
sb.AppendLine($"We are a {tierLabel} operation" +
|
||||
(workers.Count > 0 ? $" with {workers.Count} active shop worker{(workers.Count == 1 ? "" : "s")}." : "."));
|
||||
sb.AppendLine($"We are a {tierLabel} operation.");
|
||||
}
|
||||
|
||||
// Ovens
|
||||
@@ -827,32 +825,6 @@ public class CompanySettingsController : Controller
|
||||
sb.AppendLine($"Powder categories we stock: {string.Join(", ", catNames)}.");
|
||||
}
|
||||
|
||||
// Worker roles
|
||||
if (workers.Any())
|
||||
{
|
||||
var roles = workers
|
||||
.Select(w => w.Role)
|
||||
.Distinct()
|
||||
.Select(r => r switch
|
||||
{
|
||||
ShopWorkerRole.Sandblaster => "sandblasting",
|
||||
ShopWorkerRole.Coater => "powder coating",
|
||||
ShopWorkerRole.Masker => "masking",
|
||||
ShopWorkerRole.QualityControl => "quality control",
|
||||
ShopWorkerRole.OvenOperator => "oven operation",
|
||||
ShopWorkerRole.Supervisor => "supervision",
|
||||
ShopWorkerRole.Maintenance => "equipment maintenance",
|
||||
_ => "general labor"
|
||||
})
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (roles.Count > 1)
|
||||
{
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Staff specialties on hand: {string.Join(", ", roles)}.");
|
||||
}
|
||||
}
|
||||
|
||||
// Rates hint
|
||||
if (costs != null && costs.StandardLaborRate > 0)
|
||||
{
|
||||
@@ -948,7 +920,8 @@ public class CompanySettingsController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var statuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var statuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == companyId);
|
||||
var sortedStatuses = statuses.OrderBy(s => s.DisplayOrder).ToList();
|
||||
|
||||
var dtos = _mapper.Map<List<JobStatusLookupDto>>(sortedStatuses);
|
||||
@@ -1099,7 +1072,8 @@ public class CompanySettingsController : Controller
|
||||
if (!ModelState.IsValid)
|
||||
return Json(new { success = false, message = "Invalid data" });
|
||||
|
||||
var statuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
var statuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == (companyId ?? 0));
|
||||
|
||||
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
||||
{
|
||||
@@ -1112,7 +1086,6 @@ public class CompanySettingsController : Controller
|
||||
}
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId.HasValue) _lookupCache.InvalidateCompanyCache(companyId.Value);
|
||||
|
||||
_logger.LogInformation("Job statuses reordered");
|
||||
@@ -1141,7 +1114,8 @@ public class CompanySettingsController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var priorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId);
|
||||
var sortedPriorities = priorities.OrderBy(p => p.DisplayOrder).ToList();
|
||||
|
||||
var dtos = _mapper.Map<List<JobPriorityLookupDto>>(sortedPriorities);
|
||||
@@ -1286,7 +1260,8 @@ public class CompanySettingsController : Controller
|
||||
if (!ModelState.IsValid)
|
||||
return Json(new { success = false, message = "Invalid data" });
|
||||
|
||||
var priorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId);
|
||||
|
||||
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
||||
{
|
||||
@@ -1325,7 +1300,8 @@ public class CompanySettingsController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var statuses = await _unitOfWork.QuoteStatusLookups.GetAllAsync();
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var statuses = await _unitOfWork.QuoteStatusLookups.FindAsync(s => s.CompanyId == companyId);
|
||||
var sortedStatuses = statuses.OrderBy(s => s.DisplayOrder).ToList();
|
||||
|
||||
var dtos = _mapper.Map<List<QuoteStatusLookupDto>>(sortedStatuses);
|
||||
@@ -1506,7 +1482,8 @@ public class CompanySettingsController : Controller
|
||||
if (!ModelState.IsValid)
|
||||
return Json(new { success = false, message = "Invalid data" });
|
||||
|
||||
var statuses = await _unitOfWork.QuoteStatusLookups.GetAllAsync();
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var statuses = await _unitOfWork.QuoteStatusLookups.FindAsync(s => s.CompanyId == companyId);
|
||||
|
||||
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
||||
{
|
||||
@@ -1545,7 +1522,8 @@ public class CompanySettingsController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var services = await _unitOfWork.PrepServices.GetAllAsync();
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var services = await _unitOfWork.PrepServices.FindAsync(s => s.CompanyId == companyId);
|
||||
var sortedServices = services.OrderBy(s => s.DisplayOrder).ToList();
|
||||
|
||||
var dtos = _mapper.Map<List<PrepServiceDto>>(sortedServices);
|
||||
@@ -1667,7 +1645,8 @@ public class CompanySettingsController : Controller
|
||||
if (!ModelState.IsValid)
|
||||
return Json(new { success = false, message = "Invalid data" });
|
||||
|
||||
var services = await _unitOfWork.PrepServices.GetAllAsync();
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var services = await _unitOfWork.PrepServices.FindAsync(s => s.CompanyId == companyId);
|
||||
|
||||
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
||||
{
|
||||
@@ -1840,7 +1819,8 @@ public class CompanySettingsController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var types = await _unitOfWork.AppointmentTypeLookups.GetAllAsync();
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var types = await _unitOfWork.AppointmentTypeLookups.FindAsync(t => t.CompanyId == companyId);
|
||||
var sortedTypes = types.OrderBy(t => t.DisplayOrder).ToList();
|
||||
|
||||
var dtos = _mapper.Map<List<AppointmentTypeLookupDto>>(sortedTypes);
|
||||
@@ -1984,7 +1964,8 @@ public class CompanySettingsController : Controller
|
||||
if (!ModelState.IsValid)
|
||||
return Json(new { success = false, message = "Invalid data" });
|
||||
|
||||
var types = await _unitOfWork.AppointmentTypeLookups.GetAllAsync();
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var types = await _unitOfWork.AppointmentTypeLookups.FindAsync(t => t.CompanyId == companyId);
|
||||
|
||||
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
||||
{
|
||||
@@ -2024,7 +2005,8 @@ public class CompanySettingsController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
|
||||
var sortedCategories = categories.OrderBy(c => c.DisplayOrder).ToList();
|
||||
|
||||
var dtos = _mapper.Map<List<InventoryCategoryLookupDto>>(sortedCategories);
|
||||
@@ -2160,7 +2142,8 @@ public class CompanySettingsController : Controller
|
||||
if (!ModelState.IsValid)
|
||||
return Json(new { success = false, message = "Invalid data" });
|
||||
|
||||
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
|
||||
|
||||
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
||||
{
|
||||
@@ -2377,12 +2360,12 @@ public class CompanySettingsController : Controller
|
||||
if (companyId == null) return RedirectToAction(nameof(Index));
|
||||
|
||||
// Load all existing templates for this company
|
||||
var existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
|
||||
var existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
|
||||
|
||||
// Auto-seed any missing canonical combinations
|
||||
var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
|
||||
if (seeded > 0)
|
||||
existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
|
||||
existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
|
||||
|
||||
var dtos = existing.OrderBy(t => (int)t.NotificationType).ThenBy(t => (int)t.Channel)
|
||||
.Select(t => new NotificationTemplateDto
|
||||
@@ -2719,79 +2702,6 @@ public class CompanySettingsController : Controller
|
||||
|
||||
// ── Role-Based Labor Rates ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns the per-role hourly labor rates configured for the current company, keyed by
|
||||
/// <see cref="PowderCoating.Core.Enums.ShopWorkerRole"/> integer value. An empty list is returned
|
||||
/// (rather than a 404) when no rates have been configured yet, so the UI can render the rate grid
|
||||
/// without special-casing an empty state. The global multi-tenant filter on
|
||||
/// <c>ShopWorkerRoleCosts</c> ensures only this company's rates are returned.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetRoleCosts()
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null) return Json(new List<object>());
|
||||
|
||||
var rates = await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId.Value);
|
||||
var result = rates.Select(r => new { role = (int)r.Role, hourlyRate = r.HourlyRate }).ToList();
|
||||
return Json(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upserts the per-role hourly labor rates for the current company. The operation handles three cases
|
||||
/// per rate in a single pass: (1) rate cleared (≤ 0) — soft-delete the existing record; (2) rate set
|
||||
/// but no existing record — insert new; (3) rate changed — update existing. This avoids full
|
||||
/// table replace semantics that could cause audit log noise or trigger unintended EF change-tracking.
|
||||
/// These rates are used by the pricing calculator when <c>UseRoleBasedLaborRates</c> is enabled in
|
||||
/// <c>CompanyOperatingCosts</c>.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> SaveRoleCosts([FromBody] List<SaveRoleCostDto> rates)
|
||||
{
|
||||
try
|
||||
{
|
||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (companyId == null) return Json(new { success = false, message = "No company found." });
|
||||
|
||||
var existing = (await _unitOfWork.ShopWorkerRoleCosts.FindAsync(r => r.CompanyId == companyId.Value)).ToList();
|
||||
|
||||
foreach (var dto in rates)
|
||||
{
|
||||
var record = existing.FirstOrDefault(r => (int)r.Role == dto.Role);
|
||||
if (dto.HourlyRate <= 0)
|
||||
{
|
||||
// Remove rate if cleared
|
||||
if (record != null)
|
||||
await _unitOfWork.ShopWorkerRoleCosts.SoftDeleteAsync(record.Id);
|
||||
}
|
||||
else if (record == null)
|
||||
{
|
||||
await _unitOfWork.ShopWorkerRoleCosts.AddAsync(new PowderCoating.Core.Entities.ShopWorkerRoleCost
|
||||
{
|
||||
CompanyId = companyId.Value,
|
||||
Role = (PowderCoating.Core.Enums.ShopWorkerRole)dto.Role,
|
||||
HourlyRate = dto.HourlyRate,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
record.HourlyRate = dto.HourlyRate;
|
||||
record.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.ShopWorkerRoleCosts.UpdateAsync(record);
|
||||
}
|
||||
}
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return Json(new { success = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error saving role costs");
|
||||
return Json(new { success = false, message = "An error occurred saving role rates." });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Stripe Connect ───────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
@@ -3055,7 +2965,6 @@ public class CompanySettingsController : Controller
|
||||
}
|
||||
|
||||
public record SaveTemplateJsonRequest(int Id, string? Subject, string? Body);
|
||||
public record SaveRoleCostDto(int Role, decimal HourlyRate);
|
||||
public record SaveOnlinePaymentSettingsDto(
|
||||
OnlinePaymentSurchargeType SurchargeType,
|
||||
decimal SurchargeValue,
|
||||
|
||||
@@ -226,11 +226,9 @@ public class CompanyUsersController : Controller
|
||||
/// Creates a new company user, enforcing the subscription user-count limit and a whitelist
|
||||
/// of valid <c>CompanyRole</c> values (preventing callers from submitting a null role to
|
||||
/// create a SuperAdmin-equivalent account). CompanyAdmin users automatically receive all
|
||||
/// per-feature permissions unless a SuperAdmin is explicitly customising them. Workers
|
||||
/// additionally get an auto-created <see cref="ShopWorker"/> record so they appear in job
|
||||
/// assignment dropdowns without a separate onboarding step. A legacy ASP.NET Identity role
|
||||
/// (Administrator / Manager / Employee / ReadOnly) is also assigned to satisfy policy
|
||||
/// checks that still reference the role system.
|
||||
/// per-feature permissions unless a SuperAdmin is explicitly customising them. A legacy
|
||||
/// ASP.NET Identity role (Administrator / Manager / Employee / ReadOnly) is also assigned
|
||||
/// to satisfy policy checks that still reference the role system.
|
||||
/// </summary>
|
||||
// POST: CompanyUsers/Create
|
||||
[HttpPost]
|
||||
@@ -351,27 +349,7 @@ public class CompanyUsersController : Controller
|
||||
|
||||
await _userManager.AddToRoleAsync(user, legacyRole);
|
||||
|
||||
// If Worker role, automatically create a ShopWorker record
|
||||
if (model.CompanyRole == AppConstants.CompanyRoles.Worker)
|
||||
{
|
||||
var shopWorker = new ShopWorker
|
||||
{
|
||||
Name = user.FullName,
|
||||
Email = user.Email,
|
||||
Phone = user.PhoneNumber,
|
||||
IsActive = true,
|
||||
Notes = $"Auto-created from user account: {user.Email}",
|
||||
Role = Core.Enums.ShopWorkerRole.GeneralLabor, // Default role
|
||||
CompanyId = companyId!.Value
|
||||
};
|
||||
|
||||
await _unitOfWork.ShopWorkers.AddAsync(shopWorker);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("ShopWorker record created for user {Email}", user.Email);
|
||||
}
|
||||
|
||||
_logger.LogInformation("User {Email} created successfully by {Admin}",
|
||||
_logger.LogInformation("User {Email} created successfully by {Admin}",
|
||||
user.Email, User.Identity?.Name);
|
||||
|
||||
TempData["Success"] = $"User '{user.FullName}' created successfully.";
|
||||
@@ -441,6 +419,7 @@ public class CompanyUsersController : Controller
|
||||
CompanyRole = user.CompanyRole ?? AppConstants.CompanyRoles.Viewer,
|
||||
Department = user.Department,
|
||||
Position = user.Position,
|
||||
LaborCostPerHour = user.LaborCostPerHour,
|
||||
Phone = user.PhoneNumber,
|
||||
IsActive = user.IsActive,
|
||||
HireDate = user.HireDate,
|
||||
@@ -479,11 +458,9 @@ public class CompanyUsersController : Controller
|
||||
/// Saves changes to an existing company user. Validates company isolation and role whitelist
|
||||
/// (same checks as <see cref="Edit(string, string)"/>). Prevents two dangerous deactivation
|
||||
/// scenarios: a user deactivating themselves, and deactivating the last active CompanyAdmin
|
||||
/// for a company (which would lock out the tenant). When the role changes to Worker and no
|
||||
/// matching <see cref="ShopWorker"/> record exists, one is created automatically; if one
|
||||
/// already exists, its name, email, and active status are kept in sync. Email changes are
|
||||
/// applied via <c>SetEmailAsync</c> / <c>SetUserNameAsync</c> after the main update so
|
||||
/// Identity's own normalisation logic runs correctly.
|
||||
/// for a company (which would lock out the tenant). Email changes are applied via
|
||||
/// <c>SetEmailAsync</c> / <c>SetUserNameAsync</c> after the main update so Identity's own
|
||||
/// normalisation logic runs correctly.
|
||||
/// </summary>
|
||||
// POST: CompanyUsers/Edit/id
|
||||
[HttpPost]
|
||||
@@ -596,6 +573,7 @@ public class CompanyUsersController : Controller
|
||||
user.CompanyRole = model.CompanyRole;
|
||||
user.Department = model.Department;
|
||||
user.Position = model.Position;
|
||||
user.LaborCostPerHour = model.LaborCostPerHour;
|
||||
user.PhoneNumber = model.Phone;
|
||||
user.IsActive = model.IsActive;
|
||||
user.HireDate = model.HireDate;
|
||||
@@ -632,60 +610,7 @@ public class CompanyUsersController : Controller
|
||||
user.Id, oldEmail, model.Email, User.Identity?.Name);
|
||||
}
|
||||
|
||||
// If role changed to Worker, ensure ShopWorker record exists
|
||||
if (model.CompanyRole == AppConstants.CompanyRoles.Worker)
|
||||
{
|
||||
// Search by oldEmail so we find the record even when the email just changed
|
||||
var lookupEmail = emailChanged ? oldEmail : user.Email;
|
||||
var existingShopWorker = (await _unitOfWork.ShopWorkers.FindAsync(
|
||||
sw => sw.Email == lookupEmail && sw.CompanyId == user.CompanyId)).ToList();
|
||||
|
||||
if (!existingShopWorker.Any())
|
||||
{
|
||||
var shopWorker = new ShopWorker
|
||||
{
|
||||
Name = user.FullName,
|
||||
Email = user.Email,
|
||||
Phone = user.PhoneNumber,
|
||||
IsActive = user.IsActive,
|
||||
Notes = $"Auto-created from user account: {user.Email}",
|
||||
Role = Core.Enums.ShopWorkerRole.GeneralLabor, // Default role
|
||||
CompanyId = user.CompanyId
|
||||
};
|
||||
|
||||
await _unitOfWork.ShopWorkers.AddAsync(shopWorker);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
_logger.LogInformation("ShopWorker record created for user {Email}", user.Email);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Update existing ShopWorker to ensure it's active
|
||||
var shopWorker = existingShopWorker.First();
|
||||
var shopWorkerDirty = false;
|
||||
|
||||
if (!shopWorker.IsActive && user.IsActive)
|
||||
{
|
||||
shopWorker.IsActive = true;
|
||||
shopWorkerDirty = true;
|
||||
_logger.LogInformation("ShopWorker record reactivated for user {Email}", user.Email);
|
||||
}
|
||||
|
||||
if (emailChanged && shopWorker.Email == oldEmail)
|
||||
{
|
||||
shopWorker.Email = user.Email;
|
||||
shopWorkerDirty = true;
|
||||
}
|
||||
|
||||
shopWorker.Name = user.FullName;
|
||||
shopWorker.Phone = user.PhoneNumber;
|
||||
|
||||
if (shopWorkerDirty)
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("User {Email} updated successfully by {Admin}",
|
||||
_logger.LogInformation("User {Email} updated successfully by {Admin}",
|
||||
user.Email, User.Identity?.Name);
|
||||
|
||||
TempData["Success"] = "User updated successfully.";
|
||||
|
||||
@@ -315,7 +315,8 @@ public class CreditMemosController : Controller
|
||||
|
||||
private async Task PopulateCustomersAsync(int? selectedId)
|
||||
{
|
||||
var customers = await _unitOfWork.Customers.GetAllAsync();
|
||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
|
||||
ViewBag.Customers = customers
|
||||
.OrderBy(c => c.CompanyName ?? $"{c.ContactFirstName} {c.ContactLastName}".Trim())
|
||||
.Select(c => new SelectListItem
|
||||
|
||||
@@ -342,14 +342,16 @@ public class DashboardController : Controller
|
||||
TipOfTheDay = data.TipOfTheDay
|
||||
};
|
||||
|
||||
// Resolve company once so all remaining queries are explicitly scoped
|
||||
var currentCompanyId = _tenantContext.GetCurrentCompanyId();
|
||||
var companyId = currentCompanyId ?? 0;
|
||||
|
||||
// Dropdowns for the "Add Custom Powder to Inventory" modal
|
||||
var inventoryCategories = (await _unitOfWork.InventoryCategoryLookups.GetAllAsync())
|
||||
.Where(c => c.IsActive)
|
||||
var inventoryCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsActive && c.CompanyId == companyId))
|
||||
.OrderBy(c => c.DisplayOrder)
|
||||
.Select(c => new { c.Id, c.DisplayName })
|
||||
.ToList();
|
||||
var vendors = (await _unitOfWork.Vendors.GetAllAsync())
|
||||
.Where(v => v.IsActive)
|
||||
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.IsActive && v.CompanyId == companyId))
|
||||
.OrderBy(v => v.CompanyName)
|
||||
.Select(v => new { v.Id, v.CompanyName })
|
||||
.ToList();
|
||||
@@ -357,7 +359,6 @@ public class DashboardController : Controller
|
||||
ViewBag.VendorList = vendors;
|
||||
|
||||
// Config health check — surface setup gaps to company admins
|
||||
var currentCompanyId = _tenantContext.GetCurrentCompanyId();
|
||||
if (currentCompanyId.HasValue)
|
||||
{
|
||||
ViewBag.ConfigHealth = await _configHealth.CheckAsync(currentCompanyId.Value);
|
||||
@@ -711,8 +712,8 @@ public class DashboardController : Controller
|
||||
i => i.Coats.Any(c => c.Id == coatId), false, i => i.Job);
|
||||
var companyId = jobItem?.Job?.CompanyId ?? _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||
|
||||
// Check SKU uniqueness
|
||||
if (await _unitOfWork.InventoryItems.AnyAsync(i => i.SKU == sku.Trim()))
|
||||
// Check SKU uniqueness within this company
|
||||
if (await _unitOfWork.InventoryItems.AnyAsync(i => i.SKU == sku.Trim() && i.CompanyId == companyId))
|
||||
return Json(new { success = false, message = $"SKU '{sku}' already exists in inventory." });
|
||||
|
||||
// Determine category display name for legacy field
|
||||
|
||||
@@ -122,7 +122,6 @@ public class DataExportController : Controller
|
||||
case "Inventory": await AddInventorySheet(package, companyId, headerColor); break;
|
||||
case "Equipment": await AddEquipmentSheet(package, companyId, headerColor); break;
|
||||
case "Vendors": await AddVendorsSheet(package, companyId, headerColor); break;
|
||||
case "ShopWorkers": await AddShopWorkersSheet(package, companyId, headerColor); break;
|
||||
case "Users": await AddUsersSheet(package, companyId, headerColor); break;
|
||||
}
|
||||
}
|
||||
@@ -172,7 +171,6 @@ public class DataExportController : Controller
|
||||
case "Inventory": WriteCsvEntry(zip, "Inventory.csv", await BuildInventoryCsv(companyId)); break;
|
||||
case "Equipment": WriteCsvEntry(zip, "Equipment.csv", await BuildEquipmentCsv(companyId)); break;
|
||||
case "Vendors": WriteCsvEntry(zip, "Vendors.csv", await BuildVendorsCsv(companyId)); break;
|
||||
case "ShopWorkers": WriteCsvEntry(zip, "ShopWorkers.csv", await BuildShopWorkersCsv(companyId)); break;
|
||||
case "Users": WriteCsvEntry(zip, "Users.csv", await BuildUsersCsv(companyId)); break;
|
||||
}
|
||||
}
|
||||
@@ -441,38 +439,6 @@ public class DataExportController : Controller
|
||||
AutoFit(ws, headers.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a "Shop Workers" worksheet with one row per non-deleted shop worker for the
|
||||
/// specified company. <c>Role.ToString()</c> converts the enum to a string; the view
|
||||
/// typically formats these with spaces (e.g. "QualityControl" → "Quality Control") but the
|
||||
/// raw enum name is used here so the export value is round-trip parseable.
|
||||
/// </summary>
|
||||
private async Task AddShopWorkersSheet(ExcelPackage pkg, int companyId, Color hdr)
|
||||
{
|
||||
var data = await _db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(w => w.CompanyId == companyId && !w.IsDeleted)
|
||||
.OrderBy(w => w.Name)
|
||||
.ToListAsync();
|
||||
|
||||
var ws = pkg.Workbook.Worksheets.Add("Shop Workers");
|
||||
var headers = new[] { "ID", "Name", "Role", "Phone", "Email", "Active", "Notes" };
|
||||
WriteHeader(ws, headers, hdr);
|
||||
|
||||
for (int i = 0; i < data.Count; i++)
|
||||
{
|
||||
var r = i + 2;
|
||||
var w = data[i];
|
||||
ws.Cells[r, 1].Value = w.Id;
|
||||
ws.Cells[r, 2].Value = w.Name;
|
||||
ws.Cells[r, 3].Value = w.Role.ToString();
|
||||
ws.Cells[r, 4].Value = w.Phone;
|
||||
ws.Cells[r, 5].Value = w.Email;
|
||||
ws.Cells[r, 6].Value = w.IsActive ? "Yes" : "No";
|
||||
ws.Cells[r, 7].Value = w.Notes;
|
||||
}
|
||||
AutoFit(ws, headers.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an "Invoices" worksheet with one row per non-deleted invoice for the specified company.
|
||||
/// The customer navigation is eagerly loaded so the customer name can be rendered; when a
|
||||
@@ -687,21 +653,6 @@ public class DataExportController : Controller
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the shop workers CSV string for the specified company, ordered alphabetically by name.
|
||||
/// Column names match <see cref="ShopWorkerImportDto"/> exactly so the file can be re-imported.
|
||||
/// </summary>
|
||||
private async Task<string> BuildShopWorkersCsv(int companyId)
|
||||
{
|
||||
var data = await _db.ShopWorkers.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(w => w.CompanyId == companyId && !w.IsDeleted).OrderBy(w => w.Name).ToListAsync();
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Name,Role,Phone,Email,IsActive,Notes");
|
||||
foreach (var w in data)
|
||||
sb.AppendLine($"{CsvEscape(w.Name)},{w.Role},{CsvEscape(w.Phone)},{CsvEscape(w.Email)},{w.IsActive.ToString().ToLower()},{CsvEscape(w.Notes)}");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the users CSV string for the specified company, ordered by last name.
|
||||
/// Like <see cref="AddUsersSheet"/>, the <c>IsDeleted</c> filter is intentionally omitted
|
||||
@@ -761,7 +712,7 @@ public class DataExportController : Controller
|
||||
|
||||
/// <summary>
|
||||
/// Returns the requested sheet names sorted into the canonical export order
|
||||
/// (Customers → Jobs → Quotes → Invoices → Inventory → Equipment → Vendors → ShopWorkers → Users).
|
||||
/// (Customers → Jobs → Quotes → Invoices → Inventory → Equipment → Vendors → Users).
|
||||
/// This ensures that the workbook and ZIP archive always have a predictable, logical layout
|
||||
/// regardless of the order the administrator checked the boxes on the form.
|
||||
/// Any sheet name not in the canonical list is silently ignored.
|
||||
@@ -769,7 +720,7 @@ public class DataExportController : Controller
|
||||
/// <param name="sheets">Raw sheet names from the form POST.</param>
|
||||
private static string[] OrderSheets(string[] sheets)
|
||||
{
|
||||
var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "ShopWorkers", "Users" };
|
||||
var order = new[] { "Customers", "Jobs", "Quotes", "Invoices", "Inventory", "Equipment", "Vendors", "Users" };
|
||||
return order.Where(sheets.Contains).ToArray();
|
||||
}
|
||||
|
||||
|
||||
@@ -175,7 +175,6 @@ public class DataPurgeController : Controller
|
||||
stats.Add(await Stat("Equipment", "Equipment", "bi-tools", "Inventory & Ops", _db.Equipment.Where(e => e.IsDeleted)));
|
||||
stats.Add(await Stat("MaintenanceRecords","Maintenance Records", "bi-wrench", "Inventory & Ops", _db.MaintenanceRecords.Where(e => e.IsDeleted)));
|
||||
stats.Add(await Stat("Vendors", "Vendors", "bi-truck", "Inventory & Ops", _db.Vendors.Where(e => e.IsDeleted)));
|
||||
stats.Add(await Stat("ShopWorkers", "Shop Workers", "bi-person-badge","Inventory & Ops", _db.ShopWorkers.Where(e => e.IsDeleted)));
|
||||
|
||||
return stats;
|
||||
}
|
||||
@@ -204,7 +203,6 @@ public class DataPurgeController : Controller
|
||||
"Equipment" => await QueryCount(_db.Equipment, cutoff),
|
||||
"MaintenanceRecords" => await QueryCount(_db.MaintenanceRecords, cutoff),
|
||||
"Vendors" => await QueryCount(_db.Vendors, cutoff),
|
||||
"ShopWorkers" => await QueryCount(_db.ShopWorkers, cutoff),
|
||||
_ => (0, null)
|
||||
};
|
||||
}
|
||||
@@ -324,11 +322,6 @@ public class DataPurgeController : Controller
|
||||
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
|
||||
break;
|
||||
|
||||
case "ShopWorkers":
|
||||
count = await _db.ShopWorkers.IgnoreQueryFilters()
|
||||
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
|
||||
break;
|
||||
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
@@ -359,7 +352,7 @@ public class DataPurgeController : Controller
|
||||
"MaintenanceRecords",
|
||||
"Jobs", "Customers", "Quotes",
|
||||
"InventoryItems", "Equipment",
|
||||
"Vendors", "ShopWorkers"
|
||||
"Vendors"
|
||||
};
|
||||
return order.Where(entities.Contains).ToArray();
|
||||
}
|
||||
|
||||
@@ -107,7 +107,8 @@ public class GiftCertificatesController : Controller
|
||||
IssuedReason = gc.IssuedReason,
|
||||
Status = gc.Status,
|
||||
IssueDate = gc.IssueDate,
|
||||
ExpiryDate = gc.ExpiryDate
|
||||
ExpiryDate = gc.ExpiryDate,
|
||||
BatchId = gc.BatchId
|
||||
})
|
||||
.ToList();
|
||||
|
||||
@@ -440,6 +441,183 @@ public class GiftCertificatesController : Controller
|
||||
return acct?.Id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows the bulk certificate creation form. Defaults to Promotional reason and 25 certificates
|
||||
/// since the primary use case is car shows and events where a batch of same-value certificates
|
||||
/// is distributed to attendees.
|
||||
/// </summary>
|
||||
public IActionResult BulkCreate()
|
||||
{
|
||||
return View(new BulkCreateGiftCertificateDto());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates N gift certificates in a single batch, records GL entries for each, then redirects
|
||||
/// to a confirmation page where the user can download the full batch as a single print-ready PDF.
|
||||
/// Certificate codes are generated sequentially so the batch occupies a contiguous range (e.g.
|
||||
/// GC-2506-0012 through GC-2506-0036), making it easy to audit which codes belong to each event.
|
||||
/// GL treatment mirrors single-certificate issuance: Sold certs debit Checking, all others debit
|
||||
/// Sales Discounts (4950) and credit GC Liability (2500).
|
||||
/// </summary>
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> BulkCreate(BulkCreateGiftCertificateDto dto)
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
return View(dto);
|
||||
|
||||
try
|
||||
{
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
var companyId = currentUser?.CompanyId ?? 0;
|
||||
|
||||
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(companyId);
|
||||
int? checkingAcctId = null;
|
||||
int? discountAcctId = null;
|
||||
|
||||
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
|
||||
{
|
||||
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.IsActive && (a.AccountSubType == AccountSubTypeEnum.Checking
|
||||
|| a.AccountSubType == AccountSubTypeEnum.Cash));
|
||||
checkingAcctId = acct?.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||
a => a.IsActive && a.AccountNumber == "4950");
|
||||
discountAcctId = acct?.Id;
|
||||
}
|
||||
|
||||
var batchId = Guid.NewGuid();
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
for (int i = 0; i < dto.Quantity; i++)
|
||||
{
|
||||
var code = await GenerateCertificateCodeAsync(companyId);
|
||||
|
||||
var cert = new GiftCertificate
|
||||
{
|
||||
CertificateCode = code,
|
||||
OriginalAmount = dto.Amount,
|
||||
RedeemedAmount = 0,
|
||||
IssuedReason = dto.IssuedReason,
|
||||
Status = GiftCertificateStatus.Active,
|
||||
IssueDate = now,
|
||||
ExpiryDate = dto.ExpiryDate,
|
||||
Notes = dto.Notes,
|
||||
IssuedById = currentUser?.Id,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = now,
|
||||
CreatedBy = currentUser?.Email,
|
||||
BatchId = batchId
|
||||
};
|
||||
|
||||
await _unitOfWork.GiftCertificates.AddAsync(cert);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
await _accountBalanceService.CreditAsync(gcLiabilityAcctId, cert.OriginalAmount);
|
||||
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
|
||||
await _accountBalanceService.DebitAsync(checkingAcctId, cert.OriginalAmount);
|
||||
else
|
||||
await _accountBalanceService.DebitAsync(discountAcctId, cert.OriginalAmount);
|
||||
}
|
||||
|
||||
return RedirectToAction(nameof(BulkResult), new { batchId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error creating bulk gift certificates");
|
||||
this.ToastError("An error occurred creating the certificates.");
|
||||
return View(dto);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Displays the batch confirmation page. Driven by BatchId so it is bookmarkable and survives
|
||||
/// browser back/refresh — the user can return here any time to re-download the batch PDF.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> BulkResult(Guid batchId)
|
||||
{
|
||||
if (batchId == Guid.Empty)
|
||||
return RedirectToAction(nameof(Index));
|
||||
|
||||
var certs = await _unitOfWork.GiftCertificates.FindAsync(
|
||||
gc => gc.BatchId == batchId, false);
|
||||
|
||||
if (!certs.Any())
|
||||
return RedirectToAction(nameof(Index));
|
||||
|
||||
return View(certs.OrderBy(c => c.CertificateCode).ToList());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Streams a multi-page PDF for an entire batch identified by BatchId. GET endpoint so the
|
||||
/// user can bookmark or re-open it at any time after the batch was originally created.
|
||||
/// </summary>
|
||||
public async Task<IActionResult> BatchDownloadPdf(Guid batchId)
|
||||
{
|
||||
if (batchId == Guid.Empty)
|
||||
return BadRequest();
|
||||
|
||||
var currentUser = await _userManager.GetUserAsync(User);
|
||||
var companyId = currentUser?.CompanyId ?? 0;
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||
|
||||
var companyInfo = new Application.DTOs.Company.CompanyInfoDto
|
||||
{
|
||||
CompanyName = company?.CompanyName ?? string.Empty,
|
||||
Phone = company?.Phone,
|
||||
Address = company?.Address,
|
||||
City = company?.City,
|
||||
State = company?.State,
|
||||
ZipCode = company?.ZipCode,
|
||||
PrimaryContactEmail = company?.PrimaryContactEmail
|
||||
};
|
||||
|
||||
var certs = await _unitOfWork.GiftCertificates.FindAsync(
|
||||
gc => gc.BatchId == batchId, false,
|
||||
gc => gc.RecipientCustomer);
|
||||
|
||||
if (!certs.Any())
|
||||
return NotFound();
|
||||
|
||||
var dtos = certs.OrderBy(c => c.CertificateCode).Select(cert => new GiftCertificateDto
|
||||
{
|
||||
Id = cert.Id,
|
||||
CertificateCode = cert.CertificateCode,
|
||||
OriginalAmount = cert.OriginalAmount,
|
||||
RedeemedAmount = cert.RedeemedAmount,
|
||||
RemainingBalance = cert.RemainingBalance,
|
||||
RecipientName = cert.RecipientCustomer != null
|
||||
? (cert.RecipientCustomer.CompanyName ?? $"{cert.RecipientCustomer.ContactFirstName} {cert.RecipientCustomer.ContactLastName}".Trim())
|
||||
: cert.RecipientName,
|
||||
RecipientEmail = cert.RecipientEmail,
|
||||
IssuedReason = cert.IssuedReason,
|
||||
Status = cert.Status,
|
||||
IssueDate = cert.IssueDate,
|
||||
ExpiryDate = cert.ExpiryDate,
|
||||
Notes = cert.Notes
|
||||
}).ToList();
|
||||
|
||||
try
|
||||
{
|
||||
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
|
||||
var pdfBytes = await _pdfService.GenerateBulkGiftCertificatePdfAsync(dtos, logoData, logoContentType, companyInfo);
|
||||
var first = dtos.First().CertificateCode;
|
||||
var last = dtos.Last().CertificateCode;
|
||||
var fileName = dtos.Count == 1
|
||||
? $"GiftCertificate-{first}.pdf"
|
||||
: $"GiftCertificates-{first}-to-{last}.pdf";
|
||||
return File(pdfBytes, "application/pdf", fileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error generating batch gift certificate PDF for batch {BatchId}", batchId);
|
||||
TempData["Error"] = "Could not generate PDF.";
|
||||
return RedirectToAction(nameof(BulkResult), new { batchId });
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(byte[]? LogoData, string? LogoContentType)> LoadCompanyLogoAsync(Company? company)
|
||||
{
|
||||
if (company == null) return (null, null);
|
||||
|
||||
@@ -38,14 +38,6 @@ namespace PowderCoating.Web.Controllers
|
||||
return View();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serves the Shop Workers help article describing roles, assignment to jobs, and maintenance tasks.
|
||||
/// </summary>
|
||||
public IActionResult ShopWorkers()
|
||||
{
|
||||
return View();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serves the Equipment help article explaining the equipment status lifecycle and maintenance scheduling.
|
||||
/// </summary>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user