From 8f11e00a0adfcc93c05436198ebe6b5faabb42f2 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Sat, 13 Jun 2026 12:59:10 -0400 Subject: [PATCH] Merge duplicate powder lines on dashboard order queue When multiple jobs need the same powder, the 'Powder in Queue to be Ordered' panel now collapses them into a single line (summed lbs) rather than showing one row per coat. 'Mark as Ordered' marks all contributing coats at once and injects each into the 'Awaiting Receipt' panel individually so per-coat receiving still works unchanged. - Add PowderOrderJobRefDto; PowderOrderLineDto gains CoatIds + Jobs lists (scalar CoatId/JobId/etc. become computed accessors for backward compat) - MapPowderOrderGroupsMerged: secondary GroupBy on (ColorName, ColorCode, Finish, SKU) within vendor group for the 'needed' panel - MapPowderOrderGroups kept per-coat for the 'awaiting receipt' panel - MarkPowderOrdered accepts comma-separated coatIds, returns coats array - Dashboard view: Customer column loops job refs for merged rows; JS posts coatIds and iterates data.coats to populate awaiting-receipt panel Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 601 ++++-------------- .../DTOs/Dashboard/DashboardDtos.cs | 23 +- .../Controllers/DashboardController.cs | 144 ++++- .../Views/Dashboard/Index.cshtml | 39 +- 4 files changed, 282 insertions(+), 525 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8c550f9..52b5310 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,134 +1,72 @@ # CLAUDE.md -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. +Guidance for Claude Code when working 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). +ASP.NET Core 8.0 MVC application for powder coating business operations. Clean Architecture: +**Core** (entities/interfaces) → **Application** (DTOs/profiles) → **Infrastructure** (EF/repos/services) + **Web** (Razor MVC) + **Api** (REST/JWT). ## Essential Commands -### Building and Running - ```bash -# Build entire solution +# Build dotnet build -# Run web application (MVC) -cd src/PowderCoating.Web -dotnet run -# Access at: https://localhost:58461 +# Web MVC — https://localhost:58461 +cd src/PowderCoating.Web && dotnet run -# Run web with auto-reload -dotnet watch run +# API — Swagger at root URL +cd src/PowderCoating.Api && dotnet 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 +# Tests +dotnet test +dotnet test tests/PowderCoating.UnitTests +dotnet test tests/PowderCoating.IntegrationTests ``` -### Database Operations +### Database (EF Core) + +Run from `src/PowderCoating.Web`. **Always include `--context ApplicationDbContext`** — multiple DbContexts exist; omitting it throws. ```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 +dotnet ef migrations add --project ../PowderCoating.Infrastructure --context ApplicationDbContext +dotnet ef database update --project ../PowderCoating.Infrastructure --context ApplicationDbContext +dotnet ef migrations remove --project ../PowderCoating.Infrastructure --context ApplicationDbContext +dotnet ef migrations list --project ../PowderCoating.Infrastructure --context ApplicationDbContext +dotnet ef database drop --project ../PowderCoating.Infrastructure --context ApplicationDbContext ``` ### 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! +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 +## Architecture -### Clean Architecture Layers +### Layers +- **Core** — Entities, enums, repository + service interfaces. `BaseEntity` provides `Id`, `CompanyId`, `CreatedAt`, `UpdatedAt`, `IsDeleted`, audit fields on every entity. +- **Application** — DTOs, AutoMapper profiles (auto-discovered via `cfg.AddMaps()`; `PricingTierProfile` is an exception — registered manually in `Program.cs`), service interfaces. No UI/infra deps. +- **Infrastructure** — `ApplicationDbContext`, `Repository`, `UnitOfWork`. Seed data is **manual only** via Platform Management → Seed Data. +- **Web** — Razor MVC + Bootstrap 5. **Api** — JWT Bearer, Swagger. -**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 +### Global Query Filters (always active) +- Soft deletes: `IsDeleted == false` +- Multi-tenancy: non-SuperAdmin sees only their `CompanyId` +- Bypass: `ignoreQueryFilters: true` on repository methods -**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) +**Critical:** global filters are not sufficient on their own. Every `FindAsync`/`GetAllAsync` in a controller must also include an explicit `CompanyId == currentCompanyId` predicate — defense in depth. ## 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` +> All data access goes through `IUnitOfWork`. Enforced at startup by `EnforceDataAccessArchitecture()` in `Program.cs`. +> Full rationale + permanent exceptions: `docs/DATA_ACCESS_ARCHITECTURE.md` ### Three tiers — use the right one: @@ -141,346 +79,57 @@ 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/` +Typed repos: `IJobRepository`, `IInvoiceRepository`, `IQuoteRepository`, `ICustomerRepository`, `IBillRepository`, `IPurchaseOrderRepository` +— defined in `Core/Interfaces/Repositories/`, implemented in `Infrastructure/Repositories/`. -**Tier 3 — Aggregate/reporting queries** → injected read services +**Tier 3 — Aggregate/reporting** → 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/` +Services: `IFinancialReportService`, `IOperationalReportService`. -### Permanent exceptions (ApplicationDbContext allowed — intentional, documented): +### Permanent exceptions (ApplicationDbContext allowed — intentional): `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. +--- + +## Domain Concepts + +### Job Lifecycle +16 statuses in `JobStatusLookup` **table — NOT an enum**: Pending → Quoted → Approved → InPreparation → Sandblasting → MaskingTaping → Cleaning → InOven → Coating → Curing → QualityCheck → Completed → ReadyForPickup → Delivered | OnHold | Cancelled. +Use `.Include(j => j.JobStatus)` and filter on `!j.JobStatus.IsTerminalStatus`. + +**Priorities**: Low, Normal, High, Urgent, Rush (color-coded in UI). + +### Customers +- **Commercial**: B2B, pricing tiers, credit limits +- **Non-Commercial**: individual/residential + +### Inventory +Transactions tracked in `InventoryTransaction` (Purchase, Sale, Adjustment, Transfer, Return, Waste, Initial). Reorder points trigger alerts. + +### Equipment & Maintenance +Equipment: Operational, NeedsMaintenance, UnderMaintenance, OutOfService, Retired. +Maintenance priority: Low/Normal/High/Critical. Status: Scheduled/InProgress/Completed/Cancelled/Overdue. --- -## Data Access Patterns +## Pricing -### 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 Claude Sonnet 4.6** (`claude-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 Claude 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 ★ +### Key Rules +- Custom powder (no inventory item + `PowderToOrder > 0`): charge for the **full ordered quantity** +- In-stock powder: charge for calculated usage only (surface area × lbs/sqft × unit cost) +- Tax-exempt customers (`Customer.IsTaxExempt`): `TaxPercent` defaults to 0 on quote/invoice create; marked ★ in dropdowns ### 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.** +`PricingCalculationService.CalculateQuoteItemPriceAsync` routes via boolean flags. **Must exist identically on `QuoteItem`, `JobItem`, and `CreateQuoteItemDto`, mapped in all three `JobItemAssemblyService.CreateJobItem` overloads.** | Flag | Effect if missing on JobItem | |------|------------------------------| @@ -489,83 +138,77 @@ All modules below are fully implemented with controllers, views, and migrations | `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 +**Checklist when adding a new flag:** +1. Add to `QuoteItem` (Core/Entities) +2. Add to `JobItem` (Core/Entities) +3. Add to `CreateQuoteItemDto` (Application/DTOs) +4. Add to `JobItemSeed` (private class in `JobItemAssemblyService`) +5. Map in all three `JobItemAssemblyService.CreateJobItem` overloads +6. Include in every `existingItemsData` JSON block in `Edit.cshtml`, `EditItems.cshtml`, and all controller actions that build `CreateQuoteItemDto` from a `JobItem` +7. Add migration if field is new on a persisted entity +8. Structural test `PricingRoutingFlags_ExistOnBothQuoteItemAndJobItem` fails until steps 1–3 are done — 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 +## Configuration -- Entity Framework warnings about global query filters on related entities (non-critical, informational only) +### Key Settings (`src/PowderCoating.Web/appsettings.json`) +- DB: `ConnectionStrings:DefaultConnection` (SQL Server Express) +- AI: `AI:Anthropic:ApiKey` — **Anthropic Claude `claude-sonnet-4-6`, NOT OpenAI** +- Ports: HTTPS 58461 / HTTP 58462 -## File Upload Configuration +### Auth & Roles +- Web: cookie-based ASP.NET Identity. API: JWT Bearer. +- System roles: SuperAdmin, Administrator, Manager, Employee, ShopFloor, ReadOnly +- Policies in `AppConstants.cs`: `RequireAdministratorRole`, `CanManageJobs`, `CanManageInventory`, `CanManageUsers`, `CanViewData`, `CompanyAdminOnly` +- **PricingTiers use `CompanyAdminOnly` — NOT `RequireAdministratorRole`** (that policy is unregistered and will throw) -Limits defined in `AppConstants.cs`: -- Max file size: 10 MB -- Allowed extensions: jpg, jpeg, png, gif, pdf, doc, docx, xls, xlsx +### File Uploads +Limits in `AppConstants.cs`: 10 MB max, allowed: 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 +## UI Rules -## Extending the System +- **HTML entities in `.cshtml`** — `—` not `—`, `×` not `×`, `…` not `…`. Literal Unicode gets corrupted by AI tools + Windows file encoding. +- **External JS files only** — put scripts in `wwwroot/js/*.js`, reference via `src=`. Inline `@section Scripts` blocks can silently fail with SyntaxErrors from layout HTML context. +- **`alert-permanent` CSS class** — `_Layout` auto-dismisses `.alert:not(.alert-permanent)` after ~5s. Any non-toast alert that must persist needs this class. +- **SignalR hubs already in place**: `NotificationHub` → `/hubs/notifications` (company-scoped), `ShopHub` → `/hubs/shop` (shop floor). -### Adding AI Features +--- -AI uses Anthropic Claude 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 +## Gotchas -### SignalR Hubs +- **Two data export controllers**: `DataExportController` (SuperAdmin) and `AccountDataExportController` (company self-service). When changing CSV columns, fix **both**. +- **Help docs**: when a feature changes, update both `HelpKnowledgeBase.cs` (AI assistant knowledge) and the matching article in `Views/Help/` (human-readable help center). +- **Demo reset**: `DemoController.ResetDemoData` is gated on `company.CompanyCode == "DEMO"` — only the demo tenant can trigger a reset. ForceRemoveAll wipes all company data before reseeding. +- **artemis@ account**: the "break glass" root SuperAdmin — guards in `PlatformUsersController` protecting it are intentional, never remove them. -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 +## Implemented Modules -### Adding API Endpoints +All fully implemented with controllers, views, and migrations applied. -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 +**Operations**: Jobs (16 statuses, worker assignment, time entries, rework, shop codes, templates) · Quotes (AI Photo Quoting via Anthropic, quote→job conversion, customer approval portal) · Invoices (1:1 Job→Invoice unique index; partial payments, void, PDF, email) · Deposits (auto-applied on invoice create; QuestPDF receipt) · Customers (commercial/non-commercial, pricing tiers, tax-exempt + cert upload) · Oven Scheduler (named ovens, capacity, suggested batches) -## Project Dependencies +**Inventory & Purchasing**: Inventory (transactions, reorder alerts, powder coverage/efficiency) · Vendors · Purchase Orders (create/submit/receive, convert to bills) · Accounts Payable (bills, AP ledger, payment tracking) -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) +**Shop Management**: Shop Workers (roles, job/maintenance assignment) · Equipment & Maintenance · Catalog Items · Pricing Tiers -## Security Considerations +**Billing**: Stripe (subscriptions, checkout sessions, webhooks `/stripe/webhook`) · Stripe Connect (embedded payments, OAuth) · Twilio SMS (`ISmsService`; webhook `POST /Webhooks/TwilioSms`) -- 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 +**Platform (SuperAdmin)**: Platform Users · Companies · Seed Data (manual only) · Subscription Plans (`SubscriptionPlanConfig`) -## 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/CLAUDE.md` for that work. \ No newline at end of file +**Other**: Help Center (14 articles at `Views/Help/`) · Setup Wizard (10-step, `SetupWizardController`) · Reports (24 actions: P&L, AR Aging, Powder Usage, Cycle Time, PDF exports) · Gift Certificates · Announcements · In-App Notification Bell · Passkey/Biometric Login (WebAuthn, Fido2NetLib) · Customer Intake Kiosk (iPad, SignalR push, `KioskSession`) · AI Accounting Features (receipt scan, AR follow-up, smart categorization, cash flow forecast, anomaly detection) + +--- + +## Branding +- App name: **Powder Coating Logix** +- PCL logo: `wwwroot/images/pcl-logo.png` — sidebar header (when no tenant logo), login/register, sidebar footer (always) +- Sidebar footer always links to `http://www.powdercoatinglogix.com` +- Tenant logos: Azure Blob `companylogos` container; replaces PCL logo in sidebar header only + +## Active Design Work +A visual redesign is in progress. For UI changes, dashboard/jobs/board styling, or design tokens: read `design_handoff_pcl_redesign/README.md` and follow `design_handoff_pcl_redesign/CLAUDE.md`. diff --git a/src/PowderCoating.Application/DTOs/Dashboard/DashboardDtos.cs b/src/PowderCoating.Application/DTOs/Dashboard/DashboardDtos.cs index 49b3b48..2330e57 100644 --- a/src/PowderCoating.Application/DTOs/Dashboard/DashboardDtos.cs +++ b/src/PowderCoating.Application/DTOs/Dashboard/DashboardDtos.cs @@ -228,12 +228,31 @@ public class PowderOrderVendorGroupDto public List Lines { get; set; } = new(); } -public class PowderOrderLineDto +/// +/// One job's contribution to a merged powder order line. +/// +public class PowderOrderJobRefDto { - public int CoatId { get; set; } public int JobId { get; set; } public string JobNumber { get; set; } = string.Empty; public string CustomerName { get; set; } = string.Empty; + public decimal LbsToOrder { get; set; } +} + +public class PowderOrderLineDto +{ + /// All coat IDs contributing to this line (>1 when multiple jobs need the same powder). + public List CoatIds { get; set; } = new(); + + /// Per-job breakdown; parallel to CoatIds. + public List Jobs { get; set; } = new(); + + // Convenience accessors for single-coat scenarios (the "placed" panel is always per-coat). + public int CoatId => CoatIds.FirstOrDefault(); + public int JobId => Jobs.FirstOrDefault()?.JobId ?? 0; + public string JobNumber => Jobs.FirstOrDefault()?.JobNumber ?? string.Empty; + public string CustomerName => Jobs.FirstOrDefault()?.CustomerName ?? string.Empty; + public string CoatName { get; set; } = string.Empty; public string? ColorName { get; set; } public string? ColorCode { get; set; } diff --git a/src/PowderCoating.Web/Controllers/DashboardController.cs b/src/PowderCoating.Web/Controllers/DashboardController.cs index 6ff55dc..123b9f9 100644 --- a/src/PowderCoating.Web/Controllers/DashboardController.cs +++ b/src/PowderCoating.Web/Controllers/DashboardController.cs @@ -266,7 +266,7 @@ public class DashboardController : Controller // --------------------------------------------------------------- // Powder orders needed // --------------------------------------------------------------- - var powderOrderGroups = MapPowderOrderGroups(data.PowderOrdersNeeded); + var powderOrderGroups = MapPowderOrderGroupsMerged(data.PowderOrdersNeeded); // --------------------------------------------------------------- // Powder orders placed @@ -385,44 +385,53 @@ public class DashboardController : Controller } /// - /// Marks a job-item coat as having its powder ordered. Called via AJAX from the Powder Orders - /// Needed panel. Verifies company ownership through the parent job (JobItemCoat has no direct - /// CompanyId) before updating PowderOrdered, PowderOrderedAt, and - /// PowderOrderedByUserId on the coat record. + /// Marks one or more job-item coats as having their powder ordered. Called via AJAX from + /// the "Powder in Queue to be Ordered" panel. Accepts a comma-separated list of coat IDs + /// so that a merged line (multiple jobs needing the same powder) can be marked in one click. + /// Verifies company ownership for each coat via its parent job before updating + /// PowderOrdered, PowderOrderedAt, and PowderOrderedByUserId. /// [HttpPost] [ValidateAntiForgeryToken] - public async Task MarkPowderOrdered(int coatId) + public async Task MarkPowderOrdered(string coatIds) { try { - var coat = await _unitOfWork.JobItemCoats.LoadForOrderMarkingAsync(coatId); + var ids = coatIds? + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(s => int.TryParse(s.Trim(), out var id) ? id : 0) + .Where(id => id > 0) + .ToList() ?? new List(); - if (coat == null) - return Json(new { success = false, message = "Coat not found." }); + if (ids.Count == 0) + return Json(new { success = false, message = "No valid coat IDs provided." }); - // JobItemCoat has no CompanyId — verify ownership via parent job - var parentCompanyId = coat.JobItem?.Job?.CompanyId; - if (!_tenantContext.IsSuperAdmin() && parentCompanyId != _tenantContext.GetCurrentCompanyId()) - return Json(new { success = false, message = "Access denied." }); + var currentUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; + var results = new List(); - coat.PowderOrdered = true; - coat.PowderOrderedAt = DateTime.UtcNow; - coat.PowderOrderedByUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; - await _unitOfWork.CompleteAsync(); - - var vendor = coat.Vendor ?? coat.InventoryItem?.PrimaryVendor; - var job = coat.JobItem?.Job; - - return Json(new + foreach (var coatId in ids) { - success = true, - coat = new + var coat = await _unitOfWork.JobItemCoats.LoadForOrderMarkingAsync(coatId); + if (coat == null) continue; + + // JobItemCoat has no CompanyId — verify ownership via parent job + var parentCompanyId = coat.JobItem?.Job?.CompanyId; + if (!_tenantContext.IsSuperAdmin() && parentCompanyId != _tenantContext.GetCurrentCompanyId()) + continue; + + coat.PowderOrdered = true; + coat.PowderOrderedAt = DateTime.UtcNow; + coat.PowderOrderedByUserId = currentUserId; + + var vendor = coat.Vendor ?? coat.InventoryItem?.PrimaryVendor; + var job = coat.JobItem?.Job; + + results.Add(new { coatId = coat.Id, jobId = job?.Id, jobNumber = job?.JobNumber, - customerName = job?.Customer?.CompanyName, + customerName = job?.Customer?.CompanyName ?? job?.Customer?.ContactFirstName ?? "Unknown", colorName = coat.ColorName, colorCode = coat.ColorCode, finish = coat.Finish, @@ -434,12 +443,16 @@ public class DashboardController : Controller vendorId = vendor?.Id, vendorName = vendor?.CompanyName ?? "No Vendor Assigned", vendorPhone = vendor?.Phone - } - }); + }); + } + + await _unitOfWork.CompleteAsync(); + + return Json(new { success = true, coats = results }); } catch (Exception ex) { - _logger.LogError(ex, "Error marking coat {CoatId} as powder ordered", coatId); + _logger.LogError(ex, "Error marking coats {CoatIds} as powder ordered", coatIds); return Json(new { success = false, message = "An error occurred." }); } } @@ -876,6 +889,10 @@ public class DashboardController : Controller } } + /// + /// Projects per-coat rows into vendor-grouped DTOs one-line-per-coat. + /// Used for the "Awaiting Receipt" panel where each coat is received individually. + /// private static List MapPowderOrderGroups( IEnumerable lines) => lines.GroupBy(l => l.VendorId) @@ -892,10 +909,11 @@ public class DashboardController : Controller TotalEstCost = g.Sum(l => l.CostPerLb.HasValue ? l.LbsToOrder * l.CostPerLb.Value : 0m), Lines = g.Select(l => new PowderOrderLineDto { - CoatId = l.CoatId, - JobId = l.JobId, - JobNumber = l.JobNumber, - CustomerName = l.CustomerName, + CoatIds = new List { l.CoatId }, + Jobs = new List + { + new() { JobId = l.JobId, JobNumber = l.JobNumber, CustomerName = l.CustomerName, LbsToOrder = l.LbsToOrder } + }, CoatName = l.CoatName, ColorName = l.ColorName, ColorCode = l.ColorCode, @@ -916,6 +934,68 @@ public class DashboardController : Controller .OrderBy(g => g.VendorName) .ToList(); + /// + /// Like but collapses coats for the same powder within + /// a vendor group into one line, summing lbs and accumulating coat IDs and job refs. + /// Used for the "Powder in Queue to be Ordered" panel so you order one batch per color. + /// Two coats are considered the same powder when ColorName, ColorCode, Finish, and SKU + /// all match (case- and whitespace-insensitive). + /// + private static List MapPowderOrderGroupsMerged( + IEnumerable lines) => + lines.GroupBy(l => l.VendorId) + .Select(vendorGrp => + { + var first = vendorGrp.First(); + var mergedLines = vendorGrp + .GroupBy(l => ( + ColorName: l.ColorName?.Trim().ToLowerInvariant() ?? "", + ColorCode: l.ColorCode?.Trim().ToLowerInvariant() ?? "", + Finish: l.Finish?.Trim().ToLowerInvariant() ?? "", + SKU: l.SKU?.Trim().ToLowerInvariant() ?? "" + )) + .Select(powderGrp => + { + var p = powderGrp.First(); + return new PowderOrderLineDto + { + CoatIds = powderGrp.Select(l => l.CoatId).ToList(), + Jobs = powderGrp.Select(l => new PowderOrderJobRefDto + { + JobId = l.JobId, + JobNumber = l.JobNumber, + CustomerName = l.CustomerName, + LbsToOrder = l.LbsToOrder + }).ToList(), + CoatName = p.CoatName, + ColorName = p.ColorName, + ColorCode = p.ColorCode, + Finish = p.Finish, + SKU = p.SKU, + LbsToOrder = powderGrp.Sum(l => l.LbsToOrder), + CostPerLb = p.CostPerLb, + HasInventoryItem = p.HasInventoryItem, + VendorId = p.VendorId + }; + }) + .OrderBy(l => l.ColorName) + .ThenBy(l => l.CoatName) + .ToList(); + + return new PowderOrderVendorGroupDto + { + VendorId = vendorGrp.Key, + VendorName = first.VendorName ?? "No Vendor Assigned", + VendorPhone = first.VendorPhone, + VendorEmail = first.VendorEmail, + TotalLbsNeeded = vendorGrp.Sum(l => l.LbsToOrder), + TotalEstCost = vendorGrp.Sum(l => l.CostPerLb.HasValue ? l.LbsToOrder * l.CostPerLb.Value : 0m), + Lines = mergedLines + }; + }) + .OrderBy(g => g.VendorName) + .ToList(); + /// /// Projects a into a lightweight /// for use in dashboard job lists. Centralising the mapping in one static helper ensures that diff --git a/src/PowderCoating.Web/Views/Dashboard/Index.cshtml b/src/PowderCoating.Web/Views/Dashboard/Index.cshtml index 39d75ab..b54a2c9 100644 --- a/src/PowderCoating.Web/Views/Dashboard/Index.cshtml +++ b/src/PowderCoating.Web/Views/Dashboard/Index.cshtml @@ -528,11 +528,25 @@ @foreach (var line in vendorGroup.Lines) { - + - @line.CustomerName - (@line.JobNumber) + @if (line.Jobs.Count == 1) + { + @line.Jobs[0].CustomerName + (@line.Jobs[0].JobNumber) + } + else + { + @foreach (var jobRef in line.Jobs) + { +
+ @jobRef.CustomerName + (@jobRef.JobNumber · @jobRef.LbsToOrder.ToString("N2") lbs) +
+ } + } @if (!string.IsNullOrEmpty(line.ColorName)) @@ -551,7 +565,7 @@ @@ -875,10 +889,11 @@ // Powder Orders - Mark as Ordered document.querySelectorAll('.mark-ordered-btn').forEach(btn => { btn.addEventListener('click', async function () { - const coatId = this.dataset.coatId; - const row = document.getElementById('powder-line-' + coatId); - const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value - ?? document.querySelector('meta[name="__RequestVerificationToken"]')?.content; + const coatIds = this.dataset.coatIds; + const firstId = coatIds.split(',')[0]; + const row = document.getElementById('powder-line-' + firstId); + const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value + ?? document.querySelector('meta[name="__RequestVerificationToken"]')?.content; this.disabled = true; this.innerHTML = ''; @@ -887,7 +902,7 @@ const resp = await fetch('@Url.Action("MarkPowderOrdered", "Dashboard")', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': token }, - body: 'coatId=' + coatId + body: 'coatIds=' + encodeURIComponent(coatIds) }); const data = await resp.json(); if (data.success) { @@ -905,8 +920,8 @@ badge.textContent = n; } } - // Inject into Awaiting Receipt widget - addToAwaitingReceipt(data.coat); + // Inject each marked coat into the Awaiting Receipt widget + (data.coats || []).forEach(c => addToAwaitingReceipt(c)); }, 400); } else { alert(data.message || 'Could not update. Please try again.');