Phase 3: SuperAdmin-triggered sync. Adds a SyncColumbia POST action that runs
a full catalog sync on demand (bypassing the schedule) and reports the result
via TempData. The catalog index header gains a "Sync Columbia" button (with a
syncing spinner) and a status line showing the scheduled-sync on/off state,
last-synced time, and last-run summary, read from the platform settings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
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>
- 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>
- 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>
- 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>
- 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>
Root cause: company-settings-lookups.js had its own baseByCfm/multiplier
tables that were completely different from ShopCapabilityCalculator.cs,
so the UI showed an inflated rate (e.g. 82 sqft/hr) while the AI prompt
received the server-computed rate (e.g. 9 sqft/hr).
- Add CompanySettingsController.DeriveBlastRate endpoint — thin GET that
calls ShopCapabilityCalculator directly; now the single formula path
- Delete all client-side formula code (baseByCfm, multiplier tables,
deriveBlastRate) — ~30 lines removed
- Modal live preview calls /CompanySettings/DeriveBlastRate with 250ms
debounce instead of computing locally
- Blast setup table uses setup.derivedRate from GetBlastSetups (already
server-computed) instead of recalculating client-side
- QuotesController.AiAnalyzeItem: when no blast setup is explicitly
selected, fall back to the company's default blast setup so the
configured rate is always used
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace all hardcoded light-mode colors (#f0f4ff, #e8eeff, #fff8e1, #fff)
with Bootstrap CSS variables (--bs-secondary-bg, --bs-primary-bg-subtle,
--bs-warning-bg-subtle, --bs-body-bg) so dropdown containers and hover
states render correctly in both light and dark mode.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- InventoryController: extract RecordInventoryUsageAsync helper; both LogUsage
(scan page) and LogMaterial (jobs modal, moved from JobsController) call it —
no more duplicate save/GL logic across two controllers
- Log Material modal: replace radio buttons with prominent toggle buttons so the
active mode (Amount Used vs Amount Remaining) is always visually obvious; add
always-visible preview line showing exactly what will be logged before saving
- Edit Usage modal: add quantity field (pre-populated from existing transaction)
with delta adjustment to InventoryItem.QuantityOnHand on save; include
completed/terminal jobs in the dropdown so entries can be corrected after a
job is marked done
- Scan page job picker: include jobs completed within the last 7 days (marked
with '(completed)') so usage can be logged after a job is finished
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds CustomerPO to JobListDto (maps by convention), then builds a
Bootstrap tooltip per row with description · PO: xxx, skipping blank
fields. Rows with neither get no tooltip. Helps identify jobs at a
glance without opening the details page.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
type="email" triggers jQuery Validate's email rule which rejects commas,
blocking multi-address input despite the multiple attribute. Switching to
type="text" defers validation to the server-side SplitEmails/MailAddress
logic in the DTO which already handles comma-separated lists correctly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously FieldsJson was serialized as an escaped string in the export
file, which was fragile and unreadable. Now parsed into a JsonElement and
embedded as a proper JSON array under the key "fields". Import reads it
back with GetRawText() to reconstruct the stored string. This prevents
the null/empty fields bug caused by manually-edited or round-tripped files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
System.Text.Json defaults to PascalCase for anonymous types, producing
"Name"/"OutputMode" etc., while the import used TryGetProperty("name")
causing every template to fail with "no name". Adding CamelCase naming
policy aligns the export format with what the import expects.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Export: GET /CompanySettings/ExportCustomItemTemplates downloads all
company templates as an indented JSON backup (strips internal IDs/paths)
- Import: POST /CompanySettings/ImportCustomItemTemplates restores from
that file; runs full field + formula validation, skips name duplicates,
returns per-item results (imported / skipped / errors)
- Unsaved-changes guard: cfModal now intercepts backdrop/ESC/X when the
form is dirty and prompts before discarding work
- Export and Import buttons added to the Custom Formulas card header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Quotes help: expand Item Types section to all 7 types (was showing only 3)
- Quotes help: update Coatings section to document Flat-Rate coat spec feature
- Quotes help: add Color Name tip to Custom Powder Order section
- HelpKnowledgeBase: update item types list to all 7 types with accurate descriptions
- HelpKnowledgeBase: add Color Name / blur-autofill note to Custom Powder Order entry
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Flat-rate items now default coat type to Custom so Color Name field is immediately visible
- Catalog search blur copies typed text to Color Name when no catalog result was selected
- Item card shows 'No color specified' badge when coat has powder-to-order but no color name
- Color Name label marked required with '(shows on quote)' hint
- Coat name select min-width prevents text overlapping Bootstrap caret arrow
- Remove extra unbalanced </div> from renderSalesFields
- Fix literal — in quote simple-mode hint (textContent → innerHTML)
- Formula walkthrough modal fixed at 700px so all steps render at identical window size
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>