Phase 2: the mapping and sync core.
- ColumbiaCatalogMapper (pure/static, unit-tested): maps an API product to a
PowderCatalogItem. Derives manufacturer (PPG/KP Pigments/Columbia) from
taxonomy+SKU; flags additives into the Powder Additives category; takes base
price from the top-level price with variant fallback; captures variation /
tiered pricing as JSON; parses the free-text cure schedule into all curves
(three degree glyphs, @/at, multi-curve in order, partial-cure -> none) with
the first as the primary temp/time; strips HTML descriptions; joins color
groups; normalizes chemistry; flags clear-coat powders.
- PowderCatalogUpsertService (IPowderCatalogUpsertService): single upsert path
matching on (VendorName, SKU). Copies only feed-sourced fields and leaves
enrichment fields (specific gravity, coverage, transfer efficiency, finish)
untouched so syncs never wipe lazily-enriched TDS/AI data.
- ColumbiaCatalogSyncService (IColumbiaCatalogSyncService): pulls the full
catalog, maps + de-dupes, upserts, then reconciles discontinuations ONLY on a
complete pull (a partial pull throws and aborts before the sweep). Reactivates
reappearing items; records last-synced/last-result platform settings.
- 25 mapper unit tests covering the cure parser, manufacturer derivation,
simple/variable pricing, chemistry, color, and HTML cases from real records.
Full suite green (261 passed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds GetProductBySkuAsync (GET /products?sku=) and GetProductByIdAsync
(GET /products/{id}) for ad-hoc / on-demand refresh of a single record
without pulling the full catalog. Extracts the shared 429-retry send loop
into SendWithRetryAsync, which now also treats 404 as not-found (null) so
single lookups don't throw on a missing SKU/ID.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 1b of the Columbia Coatings integration: the typed read client and
its configuration, ahead of the sync/mapper service.
- ColumbiaProductDtos: wire-shape models for GET /products. tiered_pricing
is captured as JsonElement because the API returns it as an object on
simple products but an empty array on variable ones — binding it raw
avoids a deserialization throw; the mapper interprets it.
- IColumbiaCoatingsApiClient / ColumbiaCoatingsApiClient: pages the catalog
via GET /products (NOT the export download_url, which is Cloudflare-blocked
for server clients). Sends X-API-Key from config, honors 429/Retry-After,
and THROWS on any page failure so a partial pull can never be mistaken for
the full catalog (protects the later discontinuation sweep).
- ColumbiaIntegrationConstants: single home for config keys, setting keys,
and the derived Source/manufacturer/category values.
- Config: Columbia:ApiKey (blank — secret supplied per environment) and
Columbia:BaseUrl in appsettings.
- SeedColumbiaSyncSettings migration: seeds SuperAdmin-managed platform
settings ColumbiaSyncEnabled (off by default), ColumbiaSyncIntervalDays
(7), and last-sync tracking, under a new "Integrations" group.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 1a of the Columbia Coatings API integration. Adds the persisted
fields the sync/mapper will need, ahead of the client and sync service:
PowderCatalogItem:
- Category: our product category (e.g. "Powder Additives" for gram-sold
pigments) derived from vendor taxonomy at import, not stored raw.
- Source: provenance (e.g. "Columbia Coatings API"), kept separate from
VendorName (= derived manufacturer) so a distributor's right-to-delete
can purge by feed regardless of manufacturer.
- ChemistryType: resin chemistry (Polyester/TGIC/Epoxy/...), distinct
from Finish.
- MilThickness: recommended film build as vendor free text.
- CureScheduleText: raw cure schedule verbatim (formats vary widely).
- CureCurvesJson: all parsed cure curves, so alternate low-temp curves
are preserved for heat-sensitive substrates, not just the primary.
- FormulationChanges: vendor reformulation log; a signal cure specs may
have changed.
InventoryItem:
- PowderCatalogItemId: loose link to the catalog row (matches the
QuoteItemCoat pattern) so inventory detail can show manufacturer-level
status (e.g. discontinued/cannot reorder) and future change flags.
Nulled, never cascaded, when source catalog data is purged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The wizard scroll-restore saved scroll position on form submit but never
cleared it if the server redirected to a success page. Next fresh visit
to the same URL found the stale sessionStorage key and jumped down.
Fix: track whether the page unload was caused by our own form submit.
On pagehide for any other reason (nav link, success redirect), remove
the key so it never fires on a clean page load.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wrap Line Items and Payment History tables in table-responsive so they
scroll horizontally rather than overflowing the viewport. Expenses detail
page uses a definition list layout and was not affected.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Wraps the desktop table in table-responsive to fix horizontal scrolling,
and adds a mobile-card-view section matching the pattern used on Invoices,
PurchaseOrders, and other list pages. Cards show type, number, vendor,
status, date, due date, amount, balance due, and memo/account.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds SuppressNotification to RecordPaymentDto and a checkbox to the
modal. When checked, the payment is fully recorded but NotifyPaymentReceivedAsync
is skipped — useful for historical imports or cases where the customer
should not receive an email.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bare /Invoices now redirects to statusGroup=unpaid (Draft, Sent, Overdue)
so the list is immediately actionable. Four pills — All, Unpaid, Partial,
Paid — mirror the Jobs page pattern with live badge counts. The existing
status dropdown and outstanding/thisMonth flags are preserved for
dashboard deep-links.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
textContent treats — as a plain string; replaced with innerHTML
for static dash placeholders, and — JS escape where user input
is concatenated. Also removed a dead textContent line in timeclock-kiosk.js
that was immediately overwritten by innerHTML on the next line.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Quotes Index stat strip (OPEN / APPROVED / TOTAL VALUE) summed every
non-deleted quote, while the default list hides Converted quotes. A quote
converted to a job (whose deletion is blocked by the linked job) therefore
stayed invisible in the list but kept inflating the cards -- e.g. a blank
list showing "1" and a non-zero total value.
GetIndexStatsAsync now excludes the Converted status so the cards reflect
the same population as the default list. Converted value is intentionally
dropped from the quote pipeline because it carries forward on the job
(counting it in both would double-count the same dollars).
Also adds an explicit CompanyId predicate to GetIndexStatsAsync (defense in
depth) -- it was the only Quote query in the typed repo relying solely on
the global tenant filter.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deletes the committed dotnet publish output folder (434 files: DLLs,
bundled static assets) plus 73 stray root files (old *_FIX/*_SUMMARY
docs, .bak files, loose .sql scripts, deploy.zip, screenshots) and a
few scripts/. Repo housekeeping to reclaim disk space; no src/ or
wwwroot/ files touched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Save button was type=submit, so HTML5 form validation silently blocked
the submit event and nothing happened on click. Switch to type=button
with an explicit click handler. Also replace AutoMapper Map() with
explicit property assignment so EF reliably detects the mutations, and
re-enable the button in showButtonSuccess() after a successful save.
Cherry-picked CompanySettings hunks from dev commit 0b839d0746 as a
targeted production patch off v2026.06.09.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
GHSA-37gx-xxp4-5rgx and GHSA-w3x6-4m5h-cxqf (XML signature vulns) affect
8.0.2 transitively. No patched version exists in the NuGet feed yet — 9.0.0
is also flagged. Tracked in Directory.Build.props for re-check when a fix ships.
System.Net.Http 4.1.0 and System.Security.Cryptography.X509Certificates 4.1.0
are false positives: same NCalc2 -> Antlr4 -> NETStandard.Library 1.6.0 chain
already documented; .NET 8 BCL provides the runtime versions.
Microsoft.Build / NuGet.* are build-tooling-only, not deployed to production.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ReleaseNotesController had [Authorize] only on Index(), leaving the class
unprotected at declaration level — any future unannotated action would be
publicly accessible.
KioskController had no class-level auth, meaning PushSmsConsent() and
CancelSmsConsent() (staff-only POST actions) were reachable by anonymous
callers. [AllowAnonymous] on the existing tablet/intake actions still
overrides correctly, so the customer-facing kiosk flow is unaffected.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Catalog, calculated, generic, formula, and AI item types now accept
decimal quantities (e.g. 0.25 for a quarter of a catalog set). Sales/
merchandise items remain whole-number only.
- Input min changed from 1 to 0.01; step="0.01" added where missing
- All parseInt reads on quantity inputs changed to parseFloat so values
like 0.25 aren't truncated to 0 before being stored in wz.data
- Server-side Quantity is already decimal on all relevant DTOs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Seed data fixes:
- Fix EF interceptor: no longer overwrites explicitly-set CreatedAt on Added
entities — root cause of all "same month" chart issues
- Customer seeder: generates 15 customers/month from Jan → current month;
keeps 10 commercial anchors in deterministic order for job seeder index map
- Invoice seeder: historical range bumped from 2→8 paid invoices/month so
P&L shows consistent profit (~$5,200 collected vs ~$4,200 monthly expenses)
- Month -1 bumped to 7 paid invoices to stay above expenses
- Jobs: set UpdatedAt to historical event date so analytics don't need null fallback
- Analytics (ReportsController): use CompletedDate ?? UpdatedAt ?? CreatedAt for
revenue chart grouping; fixes empty Revenue Trend charts on Overview/Revenue tabs
- SeedDataService: inject IAccountBalanceService; auto-recalculate account balances
after seeding; patch checking/savings opening balances unconditionally on reset
- Customer list: sort by CompanyName ?? ContactLastName so individuals and
commercial accounts interleave instead of appearing as two blocks
Invoice resend:
- ResendInvoice action now accepts sendEmail + sendSms parameters; SMS-only
resend no longer requires an email address on file
- Ensures PublicViewToken exists before SMS so the view link is always valid
- canResend in Details view now allows Paid invoices (removed != Paid guard)
- Resend button shows channel-choice modal when customer has both email + SMS,
direct SMS button when SMS only, or email button when email only
- New #resendChannelModal mirrors the Send channel modal but posts to ResendInvoice
- resendInvoice() JS updated to pass sendEmail/sendSms query params
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Root cause: fingerprint-based removal failed on databases seeded with
older code (different emails/SKUs); plus Vendors, Named Ovens, and
Appointments had no removal path at all.
- Add ForceRemoveAll flag to RemoveSeedDataOptions: when true, all
removal blocks delete by CompanyId instead of fingerprint matching
- Customers block: ForceRemoveAll deletes all company customers
- Workers block: ForceRemoveAll deletes all users with CompanyRole=Worker
- New Vendors block (triggered by options.Vendors || ForceRemoveAll)
- New NamedOvens (OvenCost) block (triggered by options.NamedOvens || ForceRemoveAll)
- New Appointments block (triggered by options.Appointments || ForceRemoveAll)
- ResetDemoCompany: set ForceRemoveAll=true and enable all new flags so
every re-seedable table is wiped clean before re-seeding
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Appointments: add ~25 past appointments (last 90 days) with Completed,
Cancelled, No Show, and Rescheduled statuses; completed records carry
ActualStartTime/ActualEndTime with realistic variance; cancel/no-show
notes explain why; customer label falls back to ContactFirst/LastName
for residential customers
- Fix future appointment title for residential customers (was always using
CompanyName which is null for individuals)
- New SeedDataService.AiPredictions.cs: seeds 8 AiItemPrediction records
(varied complexity/confidence/tags/reasoning) and attaches them to the
first 8 eligible QuoteItems, marking those items IsAiItem=true; 3 of 8
have UserOverrodeEstimate=true for AI Accuracy report demo
- SeedDataService.cs: wire SeedAiPredictionsAsync after Invoices
- Remove.cs: collect QuoteItem.AiPredictionId FKs before deleting items,
then delete orphaned AiItemPrediction records after quotes are removed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Bills.cs: replace aceHardware/fastenal lookups with grainger/harbor/localSupply
to match Phase 1 vendor renames; update all vendor invoice number prefixes
- Bills.cs: add 3 AP aging-bucket bills (30-60, 61-90, 90+ days overdue) so all
four AP aging buckets are populated for report demos
- Invoices.cs: add 3 more overdue invoices (31-60, 61-90, 90+ day AR buckets)
alongside the existing 21-day overdue; total now 29 invoices
- New SeedDataService.PurchaseOrders.cs: 7 POs — 3 Received (historical), 2
Submitted (in-flight), 2 Draft (pending approval); links to inventory items
where available
- SeedDataService.cs: wire SeedPurchaseOrdersAsync after Vendors seeder
- Remove.cs: add PO + POItem cleanup inside Bills removal block (two-step ID
fetch to avoid nested LINQ translation issues)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 5 named shop workers seeded as ApplicationUser (Employee role):
Mike Sanders (Coater), Jake Wilson (Sandblaster), Sarah Brooks (Inspector),
Tyler Green (General), Chris Mason (Lead) — @pcldemo.com fingerprint domain
- Job time entries seeded for all in-progress and completed jobs;
Worker Productivity report will have data from day one
- Maintenance history seeded per equipment: 2 completed records + 1 upcoming
scheduled + 1 overdue record on Pressure Pot for overdue alert demo
- Equipment renamed to spec names: Main Batch Oven, Small Batch Oven, Powder
Coating Booth, Blast Cabinet, Pressure Pot Blaster, Air Compressor, Wash
Station, Forklift (replaced Overhead Conveyor which wasn't in spec)
- RemoveSeedDataOptions.Workers added; Remove.cs cleans up workers + time
entries on Demo Reset; SeedDataController resets workers in ResetDemoCompany
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Customers: 100 → 27 (15 commercial across auto/industrial/architectural/
fitness/marine/energy, including 2 tax-exempt govts; 12 individuals)
Quotes: 75 → 20; date range extended to 4-6 months (was 90 days);
status distribution adjusted proportionally (2 draft, 3 sent, 10 approved,
3 rejected, 2 expired)
Jobs: fixed 50-loop → per-customer 0-5 jobs (~32 total); jobIdx cycles
all 16 statuses globally so every status is visible; creation dates spread
across 1-5 months for in-progress/early jobs, 2-6 months for completed jobs
SeededCustomerEmails updated to match new 27-customer set (added
gnelson@email.com and carol.evans@email.com)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add Bills and Expenses flags to RemoveSeedDataOptions
- RemoveSeedDataAsync: delete BillPayments + BillLineItems + Bills, then
Expenses for the company when those flags are set
- ResetDemoCompany action: enable Bills=true and Expenses=true so all
seeded AP data is cleared before re-seeding (was skipping on second reset)
- Fix apostrophe in success message (was ' in C# string, double-encoded
by Razor to literal ' on screen)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New ResetDemoCompany POST action wipes all seeded data (customers, jobs,
quotes, invoices, inventory, equipment, catalog, pricing tiers, operating
costs) from the DEMO company and immediately re-seeds with fresh records
dated relative to today. Seed data already used relative dates so every
reset produces a realistic, current-looking dataset.
View adds a red "Reset Demo Company" card at the top of the Seed Data page,
visible only when the DEMO company exists. Single button with confirm dialog;
shows exactly what will be wiped and what will be preserved (user accounts,
company settings, lookup tables).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Company Settings: switch save button from type=submit to type=button
to bypass HTML5 form validation blocking the submit event; replace
AutoMapper Map() with explicit property assignment so EF change
tracking reliably detects mutations; fix showButtonSuccess() never
re-enabling the button after a successful save
- Invoice PDF: move PAID stamp into the header row as a centered middle
column so it sits between the company and invoice blocks without
adding height to the document
- Purge script: use business-date fields instead of CreatedAt so
imported records (which all share today's CreatedAt) are correctly
filtered by actual transaction dates
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove watermark overlay layer; PAID badge now sits centered between
the header row and the accent rule so it never obscures line items
- Add PrimaryContactEmail to company info block in header
- Remove ComposePaidStamp helper (no longer needed)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- EmailService: add RedirectIfNonProd() mirroring SmsService pattern;
reads SendGrid:DevRedirectEmail and redirects all outbound email in
non-production so real customers are never contacted on local/dev
- appsettings.json: set DevRedirectEmail to spouliot@scppowdercoating.com
- PdfService: revert Opacity() (not in QuestPDF 2024.12.3); use
Colors.Green.Lighten2 for stamp + border to achieve lighter look
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Reduce font 80→52, border 5→3, add 35% opacity so stamp no longer
obscures line items on dense invoices.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Suppress ColorName line if it matches the item Name (powders use
color name as their item name, causing it to show twice)
- Suppress Finish if already contained in the item Name
- Always show Manufacturer regardless of whether it is populated
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The Ready pill passed searchTerm=ReadyForPickup which did a text search —
"readyforpickup" (no spaces) never matched the display name "Ready for Pickup".
Converted to statusGroup=ready and added the corresponding controller case.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Jobs: use AllJobCount (global total) to distinguish truly-empty from
filter-returned-nothing; show Clear Filters button in the latter case.
Quotes: expand the filter-active check to include tagFilter and statusCode,
which were missing from the condition.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Inline display:none!important on the results div blocked all CSS rules
from showing it, including the :not(:empty) trick. Switched to explicit
JS show/hide so the dropdown is reliably visible after typing 2+ chars.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New CustomerContact entity + migration (AddCustomerContactsAndCrmFields)
- Customer.LeadSource + ShipToAddress/City/State/ZipCode/Country fields
- Additional Contacts card on Customer Details with AJAX add/edit/delete
- Lead Source dropdown on Create/Edit; Ship-To section on Create/Edit
- Customer Details: side-by-side billing/ship-to when ship-to is set
- Help docs: Customers (contacts, ship-to, lead source, preferred powders, outstanding pickups)
- Help docs: Jobs (clone job, project name), Quotes (project name), Invoices (project name), Inventory (low stock clickable filter)
- HelpKnowledgeBase.cs updated for all features above
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Outstanding Pickups card on Customer Details shows jobs awaiting pickup with age badges
- Customer Notes log: inline add/delete notes with important flag, AJAX-backed
- Clone Job action on Jobs controller; Repeat Last Job button on Customer Details quick actions
- Preferred Powders per customer: typeahead inventory search, AJAX add/remove
- CustomerPreferredPowder entity + migration; unit tests for CRM stats/timeline logic
- Fix EF Core concurrency bug: parallel Task.WhenAll FindAsync replaced with sequential awaits
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Edit GET now falls back to job.ProjectName for invoices created before the
column was added. Details view shows Project Name alongside Customer PO.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Stores ProjectName on the Invoice entity (previously only inherited from the
linked job at display time). Pre-fills from the job when creating from a job.
Migration: AddInvoiceProjectName.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add ProjectName (nvarchar 100, nullable) to Quote and Job entities;
migration AddProjectNameToQuotesAndJobs applied
- Add ProjectName to all relevant DTOs: QuoteDto/Create/Update,
JobDto/List/Create/Update, InvoiceDto (mapped from Job.ProjectName
via AutoMapper so the invoice PDF picks it up without a separate column)
- Form field added after Customer PO in Quote Create/Edit and Job Create/Edit
- CreateJobFromQuote copies ProjectName from quote to job automatically
- Details views (Quote and Job) display Project when set
- Printable quote PDF: Project row in the quote details block
- Work order: Project row in customer/job info section
- Invoice PDF: Project shown in the Job Reference block alongside Job # and PO #
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
QuotesController — ConvertToCustomer POST was wrongly setting the quote
status to 'Converted' (which means a job exists) and redirecting to the
customer page with no job created. The quote then disappeared from the
default list filter and the user had no way to create the job without
hunting for it. Fix: leave the quote at 'Approved' after customer
creation and redirect back to the quote details page with a toast
prompting the next step. 'Converted' status is now set exclusively by
CreateJobFromQuote when a job actually exists.
NotificationService — add tenant reply-to email address as a visible
line in the email footer so customers who ignore or whose mail client
doesn't honour the Reply-To header still have a clear address to contact.
Also adds Warning-level logging when no reply-to is configured for a
company so future routing issues are diagnosable from app logs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>