diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index a0033bb..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -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)" - ] - } -} diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 0000000..bd722a6 --- /dev/null +++ b/.githooks/pre-commit @@ -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 diff --git a/.gitignore b/.gitignore index 95bb3c6..aebe630 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8774f5b --- /dev/null +++ b/AGENTS.md @@ -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` implementing `IRepository` +- `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` 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`) +```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 Index() + { + var entities = await _unitOfWork.Examples.GetAllAsync(); + var dtos = _mapper.Map>(entities); + return View(dtos); + } + + [HttpPost] + public async Task Create(CreateExampleDto dto) + { + var entity = _mapper.Map(dto); + await _unitOfWork.Examples.AddAsync(entity); + await _unitOfWork.CompleteAsync(); + return RedirectToAction(nameof(Index)); + } + + public async Task 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 _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("/hubpath")` +3. Use JavaScript client in views to connect + +### Adding API Endpoints + +1. Create controller in `Api/Controllers/` with `[ApiController]` attribute +2. Return `ActionResult` 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. \ No newline at end of file diff --git a/src/PowderCoating.Application/DTOs/Invoice/InvoiceDtos.cs b/src/PowderCoating.Application/DTOs/Invoice/InvoiceDtos.cs index e3699db..c3d8a13 100644 --- a/src/PowderCoating.Application/DTOs/Invoice/InvoiceDtos.cs +++ b/src/PowderCoating.Application/DTOs/Invoice/InvoiceDtos.cs @@ -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; } diff --git a/src/PowderCoating.Application/Interfaces/IPdfService.cs b/src/PowderCoating.Application/Interfaces/IPdfService.cs index f680c68..b377897 100644 --- a/src/PowderCoating.Application/Interfaces/IPdfService.cs +++ b/src/PowderCoating.Application/Interfaces/IPdfService.cs @@ -25,6 +25,12 @@ public interface IPdfService CompanyInfoDto companyInfo, QuoteTemplateSettingsDto? template = null); + Task GeneratePackingSlipPdfAsync( + InvoiceDto invoiceDto, + byte[]? companyLogo, + string? companyLogoContentType, + CompanyInfoDto companyInfo); + Task GeneratePurchaseOrderPdfAsync( PurchaseOrderDto po, byte[]? companyLogo, diff --git a/src/PowderCoating.Application/Mappings/InvoiceProfile.cs b/src/PowderCoating.Application/Mappings/InvoiceProfile.cs index 419b780..378bce7 100644 --- a/src/PowderCoating.Application/Mappings/InvoiceProfile.cs +++ b/src/PowderCoating.Application/Mappings/InvoiceProfile.cs @@ -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 diff --git a/src/PowderCoating.Application/Services/PdfService.cs b/src/PowderCoating.Application/Services/PdfService.cs index c4db5b0..1e3cae3 100644 --- a/src/PowderCoating.Application/Services/PdfService.cs +++ b/src/PowderCoating.Application/Services/PdfService.cs @@ -2753,4 +2753,187 @@ public class PdfService : IPdfService .FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black); } } + + // ----------------------------------------------------------------------- + // Packing Slip + // ----------------------------------------------------------------------- + + /// + /// 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. + /// + public async Task 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(); + }); + } + + /// + /// Header for the packing slip: company branding left, "PACKING SLIP" title + invoice/date info right. + /// + 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); + }); + } + + /// + /// Body of the packing slip: customer info block, optional PO number, and an items table + /// showing description, color, and quantity — no prices. + /// + 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); + }); + }); + }); + } } diff --git a/src/PowderCoating.Web/Controllers/InvoicesController.cs b/src/PowderCoating.Web/Controllers/InvoicesController.cs index 026b12e..6819a6f 100644 --- a/src/PowderCoating.Web/Controllers/InvoicesController.cs +++ b/src/PowderCoating.Web/Controllers/InvoicesController.cs @@ -355,6 +355,15 @@ public class InvoicesController : Controller var job = await _unitOfWork.Jobs.GetByIdAsync(jobId.Value, false, j => j.Customer, j => j.JobItems); if (job == null) return NotFound(); + // Pre-load coats so we can derive color names for invoice line items + var activeItemIds = job.JobItems.Where(ji => !ji.IsDeleted).Select(ji => ji.Id).ToList(); + var allCoats = activeItemIds.Any() + ? (await _unitOfWork.JobItemCoats.FindAsync(c => activeItemIds.Contains(c.JobItemId) && !c.IsDeleted)).ToList() + : new List(); + var coatsByItem = allCoats + .GroupBy(c => c.JobItemId) + .ToDictionary(g => g.Key, g => g.OrderBy(c => c.Sequence).ToList()); + // Validate no existing active invoice for this job (voided ones are kept as history) var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId.Value); if (existing != null && existing.Status != InvoiceStatus.Voided) @@ -404,6 +413,16 @@ public class InvoicesController : Controller revenueAccountId = ci.RevenueAccountId; revenueAccountId ??= defaultRevenueAccount?.Id; + // Derive color from coats when the item itself has no explicit color set + var derivedColor = item.ColorName; + if (string.IsNullOrEmpty(derivedColor) && coatsByItem.TryGetValue(item.Id, out var itemCoats)) + { + var coatColors = itemCoats + .Where(c => !string.IsNullOrEmpty(c.ColorName)) + .Select(c => c.ColorName!); + derivedColor = string.Join(" / ", coatColors); + } + dto.InvoiceItems.Add(new CreateInvoiceItemDto { SourceJobItemId = item.Id, @@ -412,7 +431,7 @@ public class InvoicesController : Controller Quantity = item.Quantity > 0 ? item.Quantity : 1, UnitPrice = item.UnitPrice, TotalPrice = item.TotalPrice, - ColorName = item.ColorName, + ColorName = derivedColor, Notes = item.Notes, DisplayOrder = order++, RevenueAccountId = revenueAccountId @@ -445,16 +464,22 @@ public class InvoicesController : Controller // If the job came from a quote, carry over the quote-level costs and agreed terms. // The quote SubTotal = sum(items) + oven batch cost + shop supplies. // Job items only capture per-item prices, so oven & shop supplies need a separate line. - // Read directly from the quote snapshot — never try to reverse-engineer from job.FinalPrice - // because FinalPrice is recalculated on every item edit and can drift from the original quote. + // For fee components, prefer the job's own breakdown snapshot (updated every time the job + // is saved) over the source quote — the quote's FacilityOverheadCost was only added in + // migration AddQuotePricingSnapshotFields (May 2026); older quotes have 0 there even though + // overhead was included in the quote total. Tax and discount still come from the quote + // because those represent the customer-approved agreed terms. if (sourceQuote != null) { - // Bundle all quote-level charges so the invoice subtotal matches the quote total. - // FacilityOverheadCost is included — it is a real cost baked into the quoted price. - var processingFees = sourceQuote.OvenBatchCost - + sourceQuote.FacilityOverheadCost - + sourceQuote.ShopSuppliesAmount - + sourceQuote.RushFee; + // Prefer job breakdown values for dynamic fee components; fall back to quote for + // compatibility with jobs that were never re-saved after the May 2026 migration. + var ovenCost = jobBreakdown != null ? jobBreakdown.OvenBatchCost : sourceQuote.OvenBatchCost; + var overhead = jobBreakdown != null ? jobBreakdown.FacilityOverheadCost : sourceQuote.FacilityOverheadCost; + var shopSupplies = jobBreakdown != null ? jobBreakdown.ShopSuppliesAmount : sourceQuote.ShopSuppliesAmount; + var rushFee = jobBreakdown != null ? jobBreakdown.RushFee : sourceQuote.RushFee; + + // Bundle all quote-level charges so the invoice subtotal matches the job total. + var processingFees = ovenCost + overhead + shopSupplies + rushFee; if (processingFees > 0.01m) { @@ -1763,6 +1788,59 @@ public class InvoicesController : Controller } } + // ----------------------------------------------------------------------- + // GET: /Invoices/DownloadPackingSlip/5 + // ----------------------------------------------------------------------- + /// + /// Generates a no-price packing slip PDF for physical pickup/delivery paperwork. + /// Reuses the same company branding and invoice data pipeline as DownloadPdf but + /// delegates to GeneratePackingSlipPdfAsync which omits all pricing columns. + /// + public async Task DownloadPackingSlip(int? id, bool inline = false) + { + if (id == null) return NotFound(); + + try + { + var invoice = await LoadInvoiceForViewAsync(id.Value); + if (invoice == null) return NotFound(); + + var currentUser = await _userManager.GetUserAsync(User); + if (currentUser == null) return Unauthorized(); + + var company = await _unitOfWork.Companies.GetByIdAsync(currentUser.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 (logoData, logoContentType) = await LoadCompanyLogoAsync(company); + var dto = await BuildInvoiceDtoAsync(invoice); + var pdfBytes = await _pdfService.GeneratePackingSlipPdfAsync(dto, logoData, logoContentType, companyInfo); + var fileName = $"PackingSlip-{invoice.InvoiceNumber}.pdf"; + + if (inline) + { + Response.Headers["Content-Disposition"] = $"inline; filename=\"{fileName}\""; + return File(pdfBytes, "application/pdf"); + } + + return File(pdfBytes, "application/pdf", fileName); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error generating packing slip for invoice {Id}", id); + TempData["ErrorPermanent"] = $"Packing slip generation failed: {ex.Message}"; + return RedirectToAction(nameof(Details), new { id }); + } + } + // ----------------------------------------------------------------------- // GET: /Invoices/ForJob/5 — redirect to existing or Create // ----------------------------------------------------------------------- @@ -3089,6 +3167,50 @@ public class InvoicesController : Controller return false; } + /// + /// Inline-edits description, quantity, and unit price on a single invoice line item. + /// Blocked on paid/voided invoices (same gate as the full Edit action). + /// Returns updated totals so the page can reflect the change without a reload. + /// + [HttpPost] + [ValidateAntiForgeryToken] + public async Task PatchItem([FromBody] PatchInvoiceItemRequest request) + { + var currentUser = await _userManager.GetUserAsync(User); + if (currentUser == null) return Unauthorized(); + + var item = await _unitOfWork.InvoiceItems.GetByIdAsync(request.ItemId); + if (item == null) return NotFound(); + + var invoice = await _unitOfWork.Invoices.GetByIdAsync(item.InvoiceId); + if (invoice == null || invoice.CompanyId != currentUser.CompanyId) return NotFound(); + + if (invoice.Status is not (InvoiceStatus.Draft or InvoiceStatus.Sent or InvoiceStatus.Overdue)) + return BadRequest(new { error = "Cannot edit items on a paid or voided invoice." }); + + item.Description = request.Description.Trim(); + item.Quantity = request.Quantity; + item.UnitPrice = request.UnitPrice; + item.TotalPrice = Math.Round(request.Quantity * request.UnitPrice, 2); + await _unitOfWork.InvoiceItems.UpdateAsync(item); + + var allItems = await _unitOfWork.InvoiceItems.FindAsync(ii => ii.InvoiceId == invoice.Id); + var newSubTotal = allItems.Sum(i => i.TotalPrice); + invoice.SubTotal = newSubTotal; + invoice.TaxAmount = Math.Round(newSubTotal * invoice.TaxPercent / 100m, 2); + invoice.Total = Math.Round(newSubTotal - invoice.DiscountAmount + invoice.TaxAmount, 2); + await _unitOfWork.Invoices.UpdateAsync(invoice); + await _unitOfWork.CompleteAsync(); + + return Json(new { + lineTotal = item.TotalPrice, + subtotal = invoice.SubTotal, + taxAmount = invoice.TaxAmount, + total = invoice.Total, + balanceDue = invoice.BalanceDue + }); + } + /// /// Returns logo bytes and content type for PDF generation. /// Prefers blob-stored logos (LogoFilePath) over the legacy DB column (LogoData). @@ -3104,3 +3226,11 @@ public class InvoicesController : Controller return (company.LogoData, company.LogoContentType); } } + +public class PatchInvoiceItemRequest +{ + public int ItemId { get; set; } + public string Description { get; set; } = string.Empty; + public decimal Quantity { get; set; } + public decimal UnitPrice { get; set; } +} diff --git a/src/PowderCoating.Web/Controllers/JobsController.cs b/src/PowderCoating.Web/Controllers/JobsController.cs index 393ae28..d9d7e39 100644 --- a/src/PowderCoating.Web/Controllers/JobsController.cs +++ b/src/PowderCoating.Web/Controllers/JobsController.cs @@ -110,6 +110,11 @@ public class JobsController : Controller { try { + // Default landing view: On Floor — redirect bare /Jobs to ?statusGroup=active + // so completed/cancelled jobs don't clutter the first screen. + if (string.IsNullOrEmpty(statusGroup) && string.IsNullOrEmpty(searchTerm) && string.IsNullOrEmpty(tagFilter)) + return RedirectToAction("Index", new { statusGroup = "active" }); + // Create and validate grid request var gridRequest = new GridRequest { @@ -141,6 +146,13 @@ public class JobsController : Controller && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled; } + else if (statusGroup == "completed") + { + filter = j => j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Completed + || j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup + || j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered; + } + // "all" or unknown group: no filter applied (show every status) } else if (!string.IsNullOrWhiteSpace(searchTerm)) { @@ -195,6 +207,27 @@ public class JobsController : Controller gridRequest, jobDtos, string.IsNullOrWhiteSpace(tagFilter) ? totalCount : jobDtos.Count); + // Pill badge counts — always global (not scoped to current filter/page) + var today = DateTime.Today; + ViewBag.AllJobCount = await _unitOfWork.Jobs.CountAsync(); + ViewBag.ActiveCount = await _unitOfWork.Jobs.CountAsync(j => + j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed + && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup + && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered + && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled); + ViewBag.OverdueCount = await _unitOfWork.Jobs.CountAsync(j => + j.DueDate < today + && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed + && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup + && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered + && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled); + ViewBag.CompletedCount = await _unitOfWork.Jobs.CountAsync(j => + j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Completed + || j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup + || j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered); + ViewBag.ReadyCount = await _unitOfWork.Jobs.CountAsync(j => + j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup); + // Set ViewBag for sorting ViewBag.SearchTerm = searchTerm; ViewBag.StatusGroup = statusGroup; @@ -3950,10 +3983,11 @@ public class JobsController : Controller ovenCost = opCosts.OvenOperatingCostPerHour * defaultOvenCycleHours; } - // 4. Revenue - decimal revenue = job.Invoice != null - ? job.Invoice.Total - : (job.FinalPrice > 0 ? job.FinalPrice : job.QuotedPrice); + // 4. Revenue — prefer FinalPrice (reflects inline edits and job-level changes); + // fall back to Invoice.Total only when FinalPrice is zero (voided/zeroed job). + decimal revenue = job.FinalPrice > 0 + ? job.FinalPrice + : (job.Invoice?.Total ?? job.QuotedPrice); // 5. Rework costs from linked rework jobs var reworkRecords = await _unitOfWork.ReworkRecords.FindAsync( @@ -3989,7 +4023,7 @@ public class JobsController : Controller return Json(new { revenue = Math.Round(revenue, 2), - revenueSource = job.Invoice != null ? "Invoice" : (job.FinalPrice > 0 ? "Final Price" : "Quoted Price"), + revenueSource = job.FinalPrice > 0 ? "Final Price" : (job.Invoice != null ? "Invoice" : "Quoted Price"), powderCost = Math.Round(powderCost, 2), laborCost = Math.Round(laborCost, 2), ovenCost = Math.Round(ovenCost, 2), @@ -4183,9 +4217,92 @@ public class JobsController : Controller return Json(new { success = false, message = "An error occurred. Please try again." }); } } + + /// + /// Inline-edits description, quantity, and unit price on a single job line item. + /// Adjusts FinalPrice and the stored PricingBreakdownJson snapshot by the price delta. + /// Returns updated totals so the page can reflect the change without a reload. + /// + [HttpPost] + [ValidateAntiForgeryToken] + public async Task PatchItem([FromBody] PatchJobItemRequest request) + { + var currentUser = await _userManager.GetUserAsync(User); + if (currentUser == null) return Unauthorized(); + + var item = await _unitOfWork.JobItems.GetByIdAsync(request.ItemId); + if (item == null) return NotFound(); + + var job = await _unitOfWork.Jobs.GetByIdAsync(item.JobId); + if (job == null || job.CompanyId != currentUser.CompanyId) return NotFound(); + + var oldTotal = item.TotalPrice; + item.Description = request.Description.Trim(); + item.Quantity = request.Quantity; + item.UnitPrice = request.UnitPrice; + item.TotalPrice = Math.Round(request.Quantity * request.UnitPrice, 2); + await _unitOfWork.JobItems.UpdateAsync(item); + + var delta = item.TotalPrice - oldTotal; + job.FinalPrice = Math.Round(job.FinalPrice + delta, 2); + + // Keep the stored pricing snapshot in sync so the breakdown panel stays consistent. + // Case-insensitive options handle JSON stored before PascalCase serialization was enforced. + QuotePricingBreakdownDto? pbFinal = null; + var jsonOpts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; + if (!string.IsNullOrEmpty(job.PricingBreakdownJson)) + { + var pb = JsonSerializer.Deserialize(job.PricingBreakdownJson, jsonOpts); + if (pb != null) + { + pb.ItemsSubtotal += delta; + pb.SubtotalBeforeDiscount += delta; + pb.SubtotalAfterDiscount = pb.SubtotalBeforeDiscount - pb.DiscountAmount; + pb.TaxAmount = Math.Round(pb.SubtotalAfterDiscount * pb.TaxPercent / 100m, 2); + pb.Total = Math.Round(pb.SubtotalAfterDiscount + pb.RushFee + pb.TaxAmount, 2); + job.FinalPrice = pb.Total; + job.PricingBreakdownJson = JsonSerializer.Serialize(pb); + pbFinal = pb; + } + } + + await _unitOfWork.Jobs.UpdateAsync(job); + await _unitOfWork.CompleteAsync(); + + // For legacy jobs without a stored snapshot, derive breakdown from live item totals. + if (pbFinal == null) + { + var allItems = await _unitOfWork.JobItems.FindAsync(ji => ji.JobId == job.Id && !ji.IsDeleted); + var itemsSubtotal = allItems.Sum(ji => ji.TotalPrice); + var subtotal = itemsSubtotal + job.OvenBatchCost + job.ShopSuppliesAmount; + pbFinal = new QuotePricingBreakdownDto + { + ItemsSubtotal = itemsSubtotal, + SubtotalBeforeDiscount = subtotal, + SubtotalAfterDiscount = subtotal, + Total = job.FinalPrice + }; + } + + return Json(new { + lineTotal = item.TotalPrice, + finalPrice = job.FinalPrice, + itemsSubtotal = pbFinal.ItemsSubtotal, + subtotalBeforeDiscount = pbFinal.SubtotalBeforeDiscount, + subtotalAfterDiscount = pbFinal.SubtotalAfterDiscount, + taxAmount = pbFinal.TaxAmount + }); + } } public class DeleteTimeEntryRequest { public int Id { get; set; } } +public class PatchJobItemRequest +{ + public int ItemId { get; set; } + public string Description { get; set; } = string.Empty; + public decimal Quantity { get; set; } + public decimal UnitPrice { get; set; } +} public class LogMaterialRequest { public int JobId { get; set; } diff --git a/src/PowderCoating.Web/Controllers/QuotesController.cs b/src/PowderCoating.Web/Controllers/QuotesController.cs index 986ace2..879cd47 100644 --- a/src/PowderCoating.Web/Controllers/QuotesController.cs +++ b/src/PowderCoating.Web/Controllers/QuotesController.cs @@ -3824,6 +3824,49 @@ public class QuotesController : Controller } return (company.LogoData, company.LogoContentType); } + + /// + /// Inline-edits description, quantity, and unit price on a single quote line item. + /// Adjusts stored quote totals by the price delta so the sidebar stays accurate. + /// Returns updated totals so the page can reflect the change without a reload. + /// + [HttpPost] + [ValidateAntiForgeryToken] + public async Task PatchItem([FromBody] PatchQuoteItemRequest request) + { + var currentUser = await _userManager.GetUserAsync(User); + if (currentUser == null) return Unauthorized(); + + var item = await _unitOfWork.QuoteItems.GetByIdAsync(request.ItemId); + if (item == null) return NotFound(); + + var quote = await _unitOfWork.Quotes.GetByIdAsync(item.QuoteId); + if (quote == null || quote.CompanyId != currentUser.CompanyId) return NotFound(); + + var oldTotal = item.TotalPrice; + item.Description = request.Description.Trim(); + item.Quantity = request.Quantity; + item.UnitPrice = request.UnitPrice; + item.TotalPrice = Math.Round(request.Quantity * request.UnitPrice, 2); + await _unitOfWork.QuoteItems.UpdateAsync(item); + + // Cascade delta through stored totals without re-running the pricing engine + var delta = item.TotalPrice - oldTotal; + quote.ItemsSubtotal += delta; + quote.SubTotal += delta; + quote.SubtotalAfterDiscount = quote.SubTotal - quote.DiscountAmount; + quote.TaxAmount = Math.Round(quote.SubtotalAfterDiscount * quote.TaxPercent / 100m, 2); + quote.Total = Math.Round(quote.SubtotalAfterDiscount + quote.RushFee + quote.TaxAmount, 2); + await _unitOfWork.Quotes.UpdateAsync(quote); + await _unitOfWork.CompleteAsync(); + + return Json(new { + lineTotal = item.TotalPrice, + subtotal = quote.SubTotal, + taxAmount = quote.TaxAmount, + total = quote.Total + }); + } } // Request model for AJAX pricing calculation @@ -3834,3 +3877,11 @@ public class UpdateQuoteStatusRequest public int QuoteId { get; set; } public int StatusId { get; set; } } + +public class PatchQuoteItemRequest +{ + public int ItemId { get; set; } + public string Description { get; set; } = string.Empty; + public decimal Quantity { get; set; } + public decimal UnitPrice { get; set; } +} diff --git a/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs b/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs index 409baec..ca0ef10 100644 --- a/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs +++ b/src/PowderCoating.Web/Helpers/HelpKnowledgeBase.cs @@ -215,6 +215,8 @@ public static class HelpKnowledgeBase **Per-item cost breakdown:** On the Quote Details page, each line item shows a collapsible cost breakdown — click the row to expand it and see how material, labor, equipment, complexity, and markup were calculated for that specific item. This is useful for spotting which items are underpriced or where costs are concentrated. + **Inline item editing on quotes:** On the Quote Details page, any unit price, quantity, or item description can be edited in-place by clicking the value directly. Press Enter or click away to save; press Escape to cancel. The pricing summary (subtotal, discount, tax, and total) updates immediately without reloading the page. + **Pricing Mode (Markup vs Margin):** In Company Settings → Operating Costs you can choose between two pricing modes: - *Markup on Materials* (default) — the General Markup % is applied as a markup on top of calculated costs: `price = cost × (1 + markup%)`. A 25% markup on a $100 cost = $125. - *Target Margin on Total Cost* — the markup % is treated as a target gross margin: `price = cost ÷ (1 − margin%)`. A 25% margin on a $100 cost = $133.33. The difference grows at higher percentages. @@ -265,12 +267,15 @@ public static class HelpKnowledgeBase **Job Priorities (color-coded):** - Low (grey), Normal (blue), High (orange), Urgent (red), Rush (purple) + **Jobs list default view:** The Jobs list opens on the **On Floor** filter by default — showing only active jobs (excludes Completed, Ready for Pickup, Delivered, Cancelled). Use the filter pills at the top to switch views: **All** shows every job regardless of status; **On Floor** shows active work; **Overdue** shows past-due active jobs; **Ready** shows jobs awaiting customer pickup; **Completed** shows all finished jobs (Completed + Ready for Pickup + Delivered). Each pill shows a live global count. + **How to create a job:** 1. Go to [Jobs](/Jobs) → "New Job" 2. Select customer 3. Add line items (same wizard as quotes: Calculated, Custom Work, or AI Photo) 4. Set priority, due date, assigned worker, special instructions - 5. Save + 5. Optionally set Oven & Batch Settings — select a named oven, number of batches, and cycle time. These affect the oven cost in pricing. + 6. Save **Job Priority Board:** [/JobsPriority](/JobsPriority) — Kanban-style view of all active jobs sorted by priority and status. @@ -302,7 +307,13 @@ public static class HelpKnowledgeBase **Changing the customer on a job:** On the Job Details page, the Customer field is an always-visible dropdown. Select a different customer — a confirmation banner appears. Click **Save** to apply or **Cancel** to revert. Use this to correct a misassigned job or to move a walk-in job to a customer's proper record after they've been added to the system. - **Creating an invoice from a job:** On the Job Details page, look for the Invoice section and click "Create Invoice." The system pre-fills all line items, pricing, discount, tax rate, payment terms, and due date from the job and customer automatically. Review the Totals panel on the right — if a discount was applied to the job it will show as a red "Discount Applied" line. Adjust anything you need, then save. + **Inline item price editing:** On the Job Details page, any unit price, quantity, or item description can be edited in-place without opening the full edit form. Click the value — it becomes an input field. Type the new value, then press Enter or click away to save (Escape cancels). The pricing summary card (Items Subtotal, Subtotal, Tax, and Total) and the Job Costing card both update immediately without a page reload. + + **Job Costing revenue:** The Job Costing card uses the job's Final Price as the revenue figure — not the linked invoice total. This means inline price edits are reflected in the profit margin estimate immediately, even before an invoice exists. + + **Completing a job:** When a job is ready to mark complete, click the **Complete Job** button on the Job Details page. A modal appears asking you to confirm the completion date, actual hours spent, and final price. If the job used powder from inventory, you will be asked to enter the actual lbs used — the modal groups all coats by unique powder color (not per coat or per item) so you fill in one quantity per powder. The system deducts the entered amounts from inventory, crediting any quantities already logged via QR scan. Once confirmed, the job advances to Completed status, and you are prompted to create the invoice if one does not exist. + + **Creating an invoice from a job:** On the Job Details page, look for the Invoice section and click "Create Invoice." The system pre-fills all line items, pricing, discount, tax rate, payment terms, and due date from the job and customer automatically. Line item descriptions include the coat color(s) for each item (e.g., "Color1 / Color2" if multiple coats), helping customers distinguish repeated items on the invoice. Review the Totals panel on the right — if a discount was applied to the job it will show as a red "Discount Applied" line. Adjust anything you need, then save. **Work Order QR Codes:** Every printed job work order includes two tiers of QR codes — one for viewing the job, and a separate set for taking action on it. All QR codes require the worker to be logged in. @@ -352,6 +363,8 @@ public static class HelpKnowledgeBase **Payment methods:** Cash, Check, Credit/Debit Card, Bank Transfer (ACH), Digital Payment, Store Credit + **Inline item editing on invoices:** On the Invoice Details page, unit prices, quantities, and item descriptions can be edited in-place while the invoice is in Draft status. Click the value — it becomes an input field. Press Enter or click away to save; press Escape to cancel. The invoice total updates immediately. Line items are locked once the invoice is Sent. + **Sending an invoice:** Invoice Details → "Send" — emails PDF to customer. **Online Payments:** [/Invoices/OnlinePayments](/Invoices/OnlinePayments) — Lists invoices with a shareable payment link the customer can pay without logging in. Requires Stripe Connect to be set up first (see below). diff --git a/src/PowderCoating.Web/Program.cs b/src/PowderCoating.Web/Program.cs index acb97e9..51f2d8a 100644 --- a/src/PowderCoating.Web/Program.cs +++ b/src/PowderCoating.Web/Program.cs @@ -634,7 +634,7 @@ app.Use(async (context, next) => : "'self' 'unsafe-inline' https://cdn.jsdelivr.net https://code.jquery.com https://js.stripe.com"; var cspConnectSrc = app.Environment.IsDevelopment() - ? "'self' wss://localhost:* https://cdn.jsdelivr.net https://api.stripe.com" // Allow hot reload WebSocket in dev + ? "'self' ws://localhost:* wss://localhost:* https://cdn.jsdelivr.net https://api.stripe.com" // Allow hot reload WebSocket in dev (ws:// for browser-refresh, wss:// for SignalR) : "'self' https://cdn.jsdelivr.net https://api.stripe.com"; context.Response.Headers.Append("Content-Security-Policy", diff --git a/src/PowderCoating.Web/Views/AccountDataExport/Index.cshtml b/src/PowderCoating.Web/Views/AccountDataExport/Index.cshtml index cfe22eb..1640c28 100644 --- a/src/PowderCoating.Web/Views/AccountDataExport/Index.cshtml +++ b/src/PowderCoating.Web/Views/AccountDataExport/Index.cshtml @@ -72,14 +72,14 @@
@@ -111,7 +111,7 @@ document.getElementById('exportForm').addEventListener('submit', function () { var btn = document.getElementById('exportBtn'); btn.disabled = true; - btn.innerHTML = 'Generating…'; + btn.innerHTML = 'Generating…'; // Re-enable after 10s in case browser blocks the download dialog setTimeout(function () { btn.disabled = false; diff --git a/src/PowderCoating.Web/Views/AccountingExport/Index.cshtml b/src/PowderCoating.Web/Views/AccountingExport/Index.cshtml index 6fa0b06..2472c1c 100644 --- a/src/PowderCoating.Web/Views/AccountingExport/Index.cshtml +++ b/src/PowderCoating.Web/Views/AccountingExport/Index.cshtml @@ -49,7 +49,7 @@
QuickBooks Desktop
-
IIF files — import directly via File > Utilities > Import
+
IIF files — import directly via File > Utilities > Import
customers.iif invoices_payments.iif @@ -146,7 +146,7 @@ }); } - // Init — mark CSV as selected by default + // Init — mark CSV as selected by default document.getElementById('fmt-csv').checked = true; updateFormatCards(); @@ -154,7 +154,7 @@ document.getElementById('exportForm').addEventListener('submit', function() { const btn = document.getElementById('exportBtn'); btn.disabled = true; - btn.innerHTML = 'Generating…'; + btn.innerHTML = 'Generating…'; setTimeout(function() { btn.disabled = false; btn.innerHTML = 'Download Export Package'; }, 8000); }); diff --git a/src/PowderCoating.Web/Views/Accounts/Create.cshtml b/src/PowderCoating.Web/Views/Accounts/Create.cshtml index b819cf2..f59330b 100644 --- a/src/PowderCoating.Web/Views/Accounts/Create.cshtml +++ b/src/PowderCoating.Web/Views/Accounts/Create.cshtml @@ -1,11 +1,11 @@ -@model PowderCoating.Application.DTOs.Accounting.CreateAccountDto +@model PowderCoating.Application.DTOs.Accounting.CreateAccountDto @using PowderCoating.Core.Enums @{ ViewData["Title"] = "New Account"; ViewData["PageIcon"] = "bi-journal-plus"; ViewData["PageHelpTitle"] = "New Account"; - ViewData["PageHelpContent"] = "Add a custom account to the Chart of Accounts. Select a Sub-Type first — it auto-sets the Account Type. Use conventional numbering: 1000s = Assets, 2000s = Liabilities, 3000s = Equity, 4000s = Revenue, 5000s = Cost of Goods, 6000s+ = Expenses."; + ViewData["PageHelpContent"] = "Add a custom account to the Chart of Accounts. Select a Sub-Type first — it auto-sets the Account Type. Use conventional numbering: 1000s = Assets, 2000s = Liabilities, 3000s = Equity, 4000s = Revenue, 5000s = Cost of Goods, 6000s+ = Expenses."; bool isInline = ViewBag.Inline == true; } @@ -31,7 +31,7 @@ + data-bs-content="A numeric code for sorting and organizing accounts. Convention: 1000–1999 Assets, 2000–2999 Liabilities, 3000–3999 Equity, 4000–4999 Revenue, 5000–5999 Cost of Goods, 6000–9999 Expenses. Must be unique. Sub-accounts can use decimals (e.g. 6100.1).">
@@ -55,7 +55,7 @@
@@ -70,7 +70,7 @@ @@ -152,7 +152,7 @@ diff --git a/src/PowderCoating.Web/Views/CompanySettings/EditTemplate.cshtml b/src/PowderCoating.Web/Views/CompanySettings/EditTemplate.cshtml index 0b877da..cd7a059 100644 --- a/src/PowderCoating.Web/Views/CompanySettings/EditTemplate.cshtml +++ b/src/PowderCoating.Web/Views/CompanySettings/EditTemplate.cshtml @@ -1,6 +1,6 @@ @model PowderCoating.Application.DTOs.Notification.NotificationTemplateDto @{ - ViewData["Title"] = $"Edit Template — {Model.DisplayName}"; + ViewData["Title"] = $"Edit Template — {Model.DisplayName}"; ViewData["PageIcon"] = "bi-envelope-gear"; var placeholders = ViewBag.Placeholders as List<(string Placeholder, string Description)> ?? new List<(string, string)>(); @@ -70,7 +70,7 @@ @if (isEmail) { - +
@@ -131,7 +131,7 @@
+ title="@description — click to copy"> @placeholder Copied! diff --git a/src/PowderCoating.Web/Views/CompanySettings/Index.cshtml b/src/PowderCoating.Web/Views/CompanySettings/Index.cshtml index 9480002..310aec8 100644 --- a/src/PowderCoating.Web/Views/CompanySettings/Index.cshtml +++ b/src/PowderCoating.Web/Views/CompanySettings/Index.cshtml @@ -1,4 +1,4 @@ -@model PowderCoating.Application.DTOs.Company.CompanySettingsDto +@model PowderCoating.Application.DTOs.Company.CompanySettingsDto @{ ViewData["Title"] = "Company Settings"; ViewData["PageIcon"] = "bi-building"; @@ -118,7 +118,7 @@ + data-bs-content="This information appears on every customer-facing document — quotes, invoices, and PDFs. Keep the company name, address, and email accurate so customers see the right details. The <strong>Primary Contact Email</strong> is used as the reply-to address on all outgoing notifications.<br><br><a href='/Help/Settings#company-information' target='_blank'>Learn more →</a>"> @@ -165,39 +165,39 @@ + placeholder="Examples: • We specialise in automotive restoration — wheels, frames, suspension brackets, and roll cages are our bread and butter. • Our customers expect premium pricing. We rarely work on items over 20 sqft. • Most items come to us already stripped; sandblasting adds roughly 15 min per item on average. • We use a 2-stage cure cycle — pre-heat 10 min, coat, cure 20 min at 400°F.">@(Model.OperatingCosts?.AiContextProfile)
- Plain language — write it as if briefing a new estimator on your shop. + Plain language — write it as if briefing a new estimator on your shop. @(Model.OperatingCosts?.AiContextProfile?.Length ?? 0)/2000
@@ -832,7 +832,7 @@ Save AI Profile @@ -843,9 +843,9 @@
How AI Learning Works
-

Layer 1 — Pricing config: Your operating costs (labor, equipment, markup) are always injected automatically.

-

Layer 2 — Your shop profile: The description you write here is added to every AI analysis, guiding estimates toward your typical work.

-

Layer 3 — Automatic learning: Each time your team accepts an AI estimate without changing it, that item is silently added as a calibration example. The AI improves on its own the more you use it.

+

Layer 1 — Pricing config: Your operating costs (labor, equipment, markup) are always injected automatically.

+

Layer 2 — Your shop profile: The description you write here is added to every AI analysis, guiding estimates toward your typical work.

+

Layer 3 — Automatic learning: Each time your team accepts an AI estimate without changing it, that item is silently added as a calibration example. The AI improves on its own the more you use it.

@@ -874,10 +874,10 @@
Used by the AI when estimating job complexity and throughput.
@@ -978,11 +978,11 @@
@@ -1046,7 +1046,7 @@ + data-bs-content="The prefix is combined with a date stamp and sequence number to form record IDs — for example prefix <strong>QT</strong> produces <em>QT-2603-0042</em>. Change the prefix to match your preferred numbering convention. Changing it only affects <strong>new</strong> records; existing numbers are not renamed."> @@ -1082,7 +1082,7 @@ + data-bs-content="Controls how jobs are created and flow through your shop. <strong>Require Customer PO</strong> enforces that a PO number is entered before a job can be saved — useful for commercial accounts. <strong>Allow Customer Approval</strong> enables the approval step in the job workflow — when a quote is approved, the job moves to an Approved status before work begins."> @@ -1141,7 +1141,7 @@ + data-bs-content="Controls which events send emails to your team and customers. Set the <strong>From Email Address</strong> to a domain you control — using a domain-verified address prevents emails landing in spam. If left blank, the system default address is used. Turn off notification types you don't need to avoid inbox noise."> @@ -1311,7 +1311,7 @@ + data-bs-content="Customise the subject and body of every automated email sent by the system — job status updates, quote approvals, invoice reminders, and more. Templates use <strong>{{placeholder}}</strong> tokens that are replaced with live data when the email is sent. Click <strong>Edit</strong> on any row to modify it; use <strong>Reset to Default</strong> to restore the original wording at any time.<br><br>Changes take effect immediately — the next triggered notification will use the updated template."> @@ -1371,7 +1371,7 @@ + data-bs-content="Controls how long records are kept. Most businesses set quote and job retention to <strong>7 years</strong> to satisfy tax and audit requirements. <strong>Deleted record retention</strong> is the grace period after a soft-delete before the record is permanently purged — useful if someone accidentally deletes something."> @@ -1433,7 +1433,7 @@ + data-bs-content="Lookups are the dropdown options that appear throughout the app — job statuses, priorities, quote statuses, and more. You can rename labels, change colours, and reorder them to match your shop's terminology. <strong>Status codes</strong> drive workflow logic and should not be changed unless you understand the impact."> @@ -1869,7 +1869,7 @@ -
Printed in italic at the bottom of every blank work order. Supports plain text — use * or ** for visual emphasis.
+
Printed in italic at the bottom of every blank work order. Supports plain text — use * or ** for visual emphasis.
@@ -2168,7 +2168,7 @@
- +
$ @@ -88,7 +88,7 @@ diff --git a/src/PowderCoating.Web/Views/Invoices/Create.cshtml b/src/PowderCoating.Web/Views/Invoices/Create.cshtml index fbc8ccc..042959c 100644 --- a/src/PowderCoating.Web/Views/Invoices/Create.cshtml +++ b/src/PowderCoating.Web/Views/Invoices/Create.cshtml @@ -5,7 +5,7 @@ ViewData["Title"] = "Create Invoice"; ViewData["PageIcon"] = "bi-receipt"; ViewData["PageHelpTitle"] = "Create Invoice"; - ViewData["PageHelpContent"] = "Invoices start as Drafts — you can freely edit them until you click Send. Once sent, the invoice is locked and the customer is emailed. Line items are pre-populated from the job's items but you can add, edit, or remove any line before sending. Partial payments are supported after sending."; + ViewData["PageHelpContent"] = "Invoices start as Drafts — you can freely edit them until you click Send. Once sent, the invoice is locked and the customer is emailed. Line items are pre-populated from the job's items but you can add, edit, or remove any line before sending. Partial payments are supported after sending."; var jobNumber = ViewBag.JobNumber as string; var customerName = ViewBag.CustomerName as string; var customers = ViewBag.Customers as List; @@ -130,7 +130,7 @@ + data-bs-content="Invoice Date is the date of issue — this is what appears on the printed invoice and determines when payment terms start counting. Due Date drives overdue status and A/R aging reports. Payment Terms is free text (e.g., 'Net 30') that prints on the invoice; it defaults from the customer's settings but you can override it here.">
@@ -143,7 +143,7 @@ + data-bs-content="The date the invoice is issued. This appears on the printed document and is the reference date for payment terms — e.g., Net 30 means payment is due 30 days after this date. Defaults to today.">
@@ -196,7 +196,7 @@ + data-bs-content="Each row is a billable line on the invoice. Pre-populated from the job's items. Qty × Unit Price = Total per line; you can override the Total directly too. Color is optional — it appears under the description on the printed invoice. Add manual lines for anything not in the job (e.g., pickup fee, rush charge)."> @@ -321,7 +321,7 @@ + data-bs-content="Customer Notes appear on the printed and emailed invoice — use these for payment instructions, thank-you messages, or job-specific reminders. Internal Notes are only visible to staff here in the app and never sent to the customer."> @@ -354,7 +354,7 @@ + data-bs-content="Subtotal = sum of all line item totals. Discount is a flat dollar amount deducted before tax — use it for customer-specific deals or courtesy adjustments. Tax % is applied to (Subtotal − Discount). Both default from the company settings but can be overridden per invoice."> @@ -584,7 +584,7 @@ onmousedown="event.preventDefault();merchComboSelect(this)" onmouseenter="this.style.background=document.documentElement.getAttribute('data-bs-theme')==='dark'?'#2c3a5a':'#f0f4ff'" onmouseleave="this.classList.contains('mc-active')?null:this.style.background=''"> - ${i.name}${i.sKU ? ' [' + i.sKU + ']' : ''} — ${formatCurrency(i.defaultPrice)} + ${i.name}${i.sKU ? ' [' + i.sKU + ']' : ''} — ${formatCurrency(i.defaultPrice)} ` ).join('') ).join(''); @@ -656,7 +656,7 @@ } function addGiftCertLineItem(btn) { - // Bootstrap teleports modals to — navigate relative to the button + // Bootstrap teleports modals to — navigate relative to the button const modalEl = btn ? btn.closest('.modal') : document.getElementById('gcModal'); const q = sel => modalEl ? modalEl.querySelector(sel) : document.querySelector(sel); diff --git a/src/PowderCoating.Web/Views/Invoices/Details.cshtml b/src/PowderCoating.Web/Views/Invoices/Details.cshtml index c0ea0c6..6a513f0 100644 --- a/src/PowderCoating.Web/Views/Invoices/Details.cshtml +++ b/src/PowderCoating.Web/Views/Invoices/Details.cshtml @@ -17,8 +17,8 @@ var emailOptedOut = hasEmail && !Model.CustomerNotifyByEmail; var smsPhone = !string.IsNullOrWhiteSpace(Model.CustomerMobilePhone) ? Model.CustomerMobilePhone : Model.CustomerPhone; var hasSms = !string.IsNullOrWhiteSpace(smsPhone) && Model.CustomerNotifyBySms; - var showSendModal = hasEmail && !emailOptedOut && hasSms; // both channels — show choice modal - var directSendSms = !hasEmail && hasSms; // SMS only — skip modal + var showSendModal = hasEmail && !emailOptedOut && hasSms; // both channels — show choice modal + var directSendSms = !hasEmail && hasSms; // SMS only — skip modal var hasAvailableCredits = ViewBag.AvailableCreditMemos != null && ((IEnumerable)ViewBag.AvailableCreditMemos).Any(); var canIssueRefund = !isDraft && !isVoided && Model.AmountPaid > 0; var canApplyCredit = !isVoided && Model.BalanceDue > 0 && hasAvailableCredits; @@ -51,6 +51,10 @@ PDF + + Packing Slip + Back @@ -76,7 +80,7 @@
- @Model.CustomerName has no email address on file — you'll be prompted to enter one when sending. + @Model.CustomerName has no email address on file — you'll be prompted to enter one when sending. Add one in customer settings.
@@ -175,12 +179,12 @@

- @(Model.DueDate.HasValue ? Model.DueDate.Value.ToString("MMMM d, yyyy") : "—") + @(Model.DueDate.HasValue ? Model.DueDate.Value.ToString("MMMM d, yyyy") : "—")

-

@(Model.SentDate.HasValue ? Model.SentDate.Value.ToString("MMMM d, yyyy") : "—")

+

@(Model.SentDate.HasValue ? Model.SentDate.Value.ToString("MMMM d, yyyy") : "—")

@if (!string.IsNullOrWhiteSpace(Model.CustomerPO)) { @@ -233,21 +237,21 @@ @foreach (var item in Model.InvoiceItems) { - + -
@item.Description
+ @item.Description @if (!string.IsNullOrWhiteSpace(item.ColorName)) { - @item.ColorName + @item.ColorName } @if (!string.IsNullOrWhiteSpace(item.Notes)) { @item.Notes } - @item.Quantity.ToString("G") - @item.UnitPrice.ToString("C") - @item.TotalPrice.ToString("C") + @item.Quantity.ToString("G") + @item.UnitPrice.ToString("C") + @item.TotalPrice.ToString("C") } @@ -260,7 +264,7 @@
Subtotal - @Model.SubTotal.ToString("C") + @Model.SubTotal.ToString("C")
@if (Model.DiscountAmount > 0) { @@ -279,12 +283,12 @@ @Model.SalesTaxAccountName } - @Model.TaxAmount.ToString("C") + @Model.TaxAmount.ToString("C")
}
Total - @Model.Total.ToString("C") + @Model.Total.ToString("C")
@if (Model.AmountPaid > 0) { @@ -294,7 +298,7 @@
Balance Due - @Model.BalanceDue.ToString("C") + @Model.BalanceDue.ToString("C")
} @@ -346,7 +350,7 @@ - @(gcItem.Description.Contains("for ") ? gcItem.Description.Substring(gcItem.Description.IndexOf("for ") + 4).TrimEnd(')') : "—") + @(gcItem.Description.Contains("for ") ? gcItem.Description.Substring(gcItem.Description.IndexOf("for ") + 4).TrimEnd(')') : "—") @gcItem.TotalPrice.ToString("C") @@ -392,7 +396,7 @@ @p.PaymentDate.ToString("MM/dd/yyyy") @p.PaymentMethodDisplay - @(p.Reference ?? "—") + @(p.Reference ?? "—") @if (!string.IsNullOrEmpty(p.DepositAccountName)) { @@ -400,10 +404,10 @@ } else { - + } - @(p.RecordedByName ?? "—") + @(p.RecordedByName ?? "—") @p.Amount.ToString("C") @if (!isVoided) @@ -459,7 +463,7 @@ @r.RefundDate.ToString("MM/dd/yyyy") @r.RefundMethodDisplay @r.Reason - @(r.Reference ?? "—") + @(r.Reference ?? "—") @r.Status (@r.Amount.ToString("C")) @@ -571,7 +575,7 @@ + data-bs-content="Workflow: Edit (Draft only) → Send Invoice (locks it, emails customer) → Record Payment. Partial payments are supported — record multiple payments until fully paid. Void cancels the invoice and reverses the customer balance without deleting history. Delete is only available for Drafts."> @@ -597,7 +601,7 @@ } else if (showSendModal) { - @* Both email + SMS available — let staff choose *@ + @* Both email + SMS available — let staff choose *@ } diff --git a/src/PowderCoating.Web/Views/Jobs/WorkOrder.cshtml b/src/PowderCoating.Web/Views/Jobs/WorkOrder.cshtml index 6347b79..4042f30 100644 --- a/src/PowderCoating.Web/Views/Jobs/WorkOrder.cshtml +++ b/src/PowderCoating.Web/Views/Jobs/WorkOrder.cshtml @@ -483,10 +483,10 @@ @coat.Sequence @coat.CoatName - @(coat.ColorName ?? "—") - @(coat.ColorCode ?? "—") - @(coat.Finish ?? "—") - @(coat.VendorName ?? "—") + @(coat.ColorName ?? "—") + @(coat.ColorCode ?? "—") + @(coat.Finish ?? "—") + @(coat.VendorName ?? "—") @if (coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0) { @@ -501,7 +501,7 @@ } else { - + } @@ -622,7 +622,7 @@ } - @* Powder usage QRs — one per unique inventory item *@ + @* Powder usage QRs — one per unique inventory item *@ @if (hasPowderQrs) { @foreach (var pqr in powderQrCodes!) diff --git a/src/PowderCoating.Web/Views/JobsPriority/Index.cshtml b/src/PowderCoating.Web/Views/JobsPriority/Index.cshtml index 533a643..853c112 100644 --- a/src/PowderCoating.Web/Views/JobsPriority/Index.cshtml +++ b/src/PowderCoating.Web/Views/JobsPriority/Index.cshtml @@ -1,4 +1,4 @@ -@model IEnumerable +@model IEnumerable @using PowderCoating.Application.DTOs.Job @using PowderCoating.Core.Entities @using PowderCoating.Core.Enums @@ -51,7 +51,7 @@ - @* ── Carried-Over (Overdue) Jobs ──────────────────────────────────────── *@ + @* -- Carried-Over (Overdue) Jobs ---------------------------------------- *@ @if (overdueJobs.Any()) {
@@ -59,7 +59,7 @@ } - @* ── Scheduled Maintenance for the Day ──────────────────────────────── *@ + @* -- Scheduled Maintenance for the Day -------------------------------- *@
@@ -561,7 +561,7 @@ - @(item.Equipment?.EquipmentName ?? "—") + @(item.Equipment?.EquipmentName ?? "—") @if (!string.IsNullOrEmpty(item.Equipment?.Location)) {
@item.Equipment.Location @@ -1110,14 +1110,14 @@ + + - +