feat(fulfillment): state-trucking intake form + hazmat/emissions products
- Add StateTruckingIntakeStep.astro with slug-gated sections (IRP/IFTA, emissions, intrastate authority, OSOW, hazmat/PHMSA); wired into Wizard - Register hazmat-phmsa + state-emissions products & SERVICE_INFO - Add server-side bundle/mutual-exclusion enforcement + REQUIRED_FIELDS - State-trucking slugs now collect real intake data (were review-only) - Surface slug-specific intake fields in admin todo (_summarize_intake) - Remove state slugs from email ADMIN_ASSISTED set (now get intake links)
This commit is contained in:
parent
71b888f993
commit
9c6b8d95e0
3 changed files with 703 additions and 0 deletions
230
docs/plans/Plan.md
Normal file
230
docs/plans/Plan.md
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
# Plan
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make every trucker deficiency type we flag actually *fulfillable*: each flagged
|
||||||
|
deficiency must have (1) a service handler that can complete the work, (2) a
|
||||||
|
checkout/order path, and (3) an intake form that collects **all** information
|
||||||
|
that handler needs before the job runs. Only after fulfillment is complete and
|
||||||
|
verified do we extend the campaign builder to email those deficiency segments.
|
||||||
|
|
||||||
|
## Scope / affected areas
|
||||||
|
|
||||||
|
Deficiency flags in play (live counts):
|
||||||
|
for_hire (19,811), interstate_irp_ifta (19,761), intrastate_authority (14,081),
|
||||||
|
state_emissions (12,424), state_weight_tax (6,289), state_permit (3,418),
|
||||||
|
mcs150_overdue (4,539), hazmat (514), zero_fleet (134).
|
||||||
|
|
||||||
|
Files / systems:
|
||||||
|
- `scripts/workers/services/__init__.py` — handler registry
|
||||||
|
- `scripts/workers/services/state_trucking.py` — IRP/IFTA/weight-tax/permit/intrastate handler (admin-todo only today)
|
||||||
|
- `scripts/workers/services/mcs150_update.py` — MCS-150 + reactivation (real FMCSA filing)
|
||||||
|
- `scripts/workers/services/boc3_filing.py` — BOC-3 (Playwright)
|
||||||
|
- **NEW** `scripts/workers/services/hazmat_phmsa.py` — only fully-missing fulfillment path
|
||||||
|
- `site/src/lib/intake_manifest.ts` — per-service intake steps + pricing/meta
|
||||||
|
- `site/src/components/intake/steps/DOTIntakeStep.astro` — unified DOT intake (no state-trucking sections today)
|
||||||
|
- **NEW** `site/src/components/intake/steps/StateTruckingIntakeStep.astro` — state filing fields
|
||||||
|
- `api/src/routes/compliance-orders.ts` — `COMPLIANCE_SERVICES` (products) + `REQUIRED_FIELDS` (validation; **none defined for any DOT/state-trucking slug today**)
|
||||||
|
- `api/src/routes/checkout.ts` — slug allowlist
|
||||||
|
- `api/src/routes/dot-lookup.ts` — recommended-services mapping
|
||||||
|
- **NEW** `site/src/pages/order/*.astro` — landing pages for state-trucking + hazmat (none exist)
|
||||||
|
- `scripts/build_trucking_campaigns.py` — campaign builder (extend last)
|
||||||
|
|
||||||
|
## Key findings (grounding)
|
||||||
|
|
||||||
|
1. **Fulfillment handlers already exist** for ~98% of flags. The single fully
|
||||||
|
missing path is **hazmat / PHMSA registration** (no handler, product, page).
|
||||||
|
2. **State-trucking intake collects nothing.** All 13 state slugs map to
|
||||||
|
`["review"]` in `intake_manifest.ts` with a comment "info collected at
|
||||||
|
checkout" — but checkout collects no per-filing fields. So IRP has no
|
||||||
|
vehicle/weight/jurisdiction data, IFTA has no fleet/base-state, NY HUT has no
|
||||||
|
vehicle list, intrastate-authority has no insurance/authority-type, etc. The
|
||||||
|
handler's admin todo is therefore incomplete and an admin must chase the
|
||||||
|
customer for data.
|
||||||
|
3. **`REQUIRED_FIELDS` has zero entries** for any DOT or state-trucking slug, so
|
||||||
|
the API performs no intake validation for these orders.
|
||||||
|
4. **No dedicated order landing pages** for IRP/IFTA/state-tax/permit/intrastate
|
||||||
|
or hazmat. Checkout works by slug, but campaign emails have no clean LP to
|
||||||
|
drive conversions.
|
||||||
|
5. **State emissions** flags (NY/CO/MD/NJ/MA/etc., 12,424) only map to a product
|
||||||
|
for CA (CARB via `ca-mcp-carb`). Non-CA emissions have no product — decide
|
||||||
|
whether to build or fold into existing state DOT/permit service.
|
||||||
|
|
||||||
|
## Approach (concrete ordered steps)
|
||||||
|
|
||||||
|
### Phase 1 — Close the hazmat fulfillment gap (only fully-missing path)
|
||||||
|
1. Add `HazmatPHMSAHandler` in `scripts/workers/services/hazmat_phmsa.py`
|
||||||
|
(admin-assisted, mirrors `state_trucking.py`: creates admin_todo with PHMSA
|
||||||
|
registration steps, sends status email). Slug `hazmat-phmsa`.
|
||||||
|
2. Register `"hazmat-phmsa": HazmatPHMSAHandler` in `services/__init__.py`.
|
||||||
|
3. Add product to `COMPLIANCE_SERVICES`, meta to `intake_manifest.ts`, slug to
|
||||||
|
checkout allowlist.
|
||||||
|
|
||||||
|
### Phase 1.5 — Order-form incompatibility enforcement (bundles vs components, dupes)
|
||||||
|
Today the batch endpoint (`compliance-orders.ts` POST `/batch`) only dedupes and
|
||||||
|
hard-codes one case (drop standalone `fcc-499a` when `fcc-499a-499q` present).
|
||||||
|
There is no general rule preventing a customer from selecting a **bundle plus its
|
||||||
|
own components** (e.g. `dot-full-compliance` + `mcs150-update` + `boc3-filing`, or
|
||||||
|
`state-trucking-bundle` + `irp-registration`), or other incompatible combos. This
|
||||||
|
double-charges and creates duplicate filings.
|
||||||
|
|
||||||
|
Build a single source of truth for service relationships and enforce it:
|
||||||
|
- Add `BUNDLE_COMPONENTS` map (bundle slug -> component slugs) covering
|
||||||
|
`dot-full-compliance`, `state-trucking-bundle`, `new-carrier-bundle`,
|
||||||
|
`fcc-full-compliance`, plus the DB `service_bundles` rows.
|
||||||
|
- Add `INCOMPATIBLE_PAIRS` / mutually-exclusive groups (e.g. `usdot-reactivation`
|
||||||
|
vs `carrier-closeout`; `fcc-499a` vs `fcc-499a-499q`; emergency-temp-authority
|
||||||
|
vs mc-authority where applicable).
|
||||||
|
- Server-side (authoritative) in `/batch`: when a bundle is present, **drop any of
|
||||||
|
its components** from the cart (keep the bundle), reject hard-incompatible pairs
|
||||||
|
with a clear error, and keep dedup. Generalize the existing 499a special-case
|
||||||
|
into this map.
|
||||||
|
- Client-side (order form / cart UI): disable/grey out a component when its parent
|
||||||
|
bundle is selected (and vice-versa: selecting all components suggests the bundle),
|
||||||
|
and prevent selecting mutually-exclusive options, with an inline explanation.
|
||||||
|
Mirror the server map so UX matches enforcement.
|
||||||
|
|
||||||
|
### Phase 2 — Make state-trucking intake actually collect required data
|
||||||
|
4. Build `StateTruckingIntakeStep.astro` (one shared step, sections shown by
|
||||||
|
slug, mirroring `DOTIntakeStep.astro`'s section-gating pattern):
|
||||||
|
- Carrier identity: legal name, DOT#, MC#, base state, contact (prefill from DOT lookup).
|
||||||
|
- IRP/IFTA: power units w/ VIN+plate+gross-weight rows, operating jurisdictions, fuel type.
|
||||||
|
- Weight-distance (OR/NY/KY/NM/CT): vehicle list + gross weights + (OR) declared combined weight.
|
||||||
|
- CA MCP+CARB: fleet engine model-years for CARB Clean Truck Check, CA# if any.
|
||||||
|
- Intrastate authority: authority type, insurance carrier + policy#, cargo, BOC-3 on file?
|
||||||
|
- State DOT / OSOW: as needed.
|
||||||
|
5. Update `intake_manifest.ts`: replace `["review"]` with
|
||||||
|
`["state-trucking", "review"]` for the 13 slugs; wire the step into the Wizard.
|
||||||
|
6. Add `REQUIRED_FIELDS` entries in `compliance-orders.ts` for each state-trucking
|
||||||
|
slug + the DOT slugs (mcs150, ucr, boc3, dot-registration, mc-authority, etc.)
|
||||||
|
so intake is validated server-side. Mirror handler "Intake data needed" docstrings.
|
||||||
|
7. Update `state_trucking.py` handler to read + surface the new intake fields in
|
||||||
|
the admin todo (so admins get vehicle lists, jurisdictions, insurance, etc.).
|
||||||
|
|
||||||
|
### Phase 2.5 — Make BOC-3 authority-aware (preexisting authority handling)
|
||||||
|
The BOC-3 attaches to a carrier's operating authority (MC/FF/MX docket). Today
|
||||||
|
`boc3_filing.py` only reads `commonAuthorityStatus`/`contractAuthorityStatus`/
|
||||||
|
`brokerAuthorityStatus` to print a status string and otherwise always files a
|
||||||
|
fresh BOC-3. That can be wrong/wasteful depending on the preexisting authority.
|
||||||
|
Add branching off the live FMCSA authority state:
|
||||||
|
- **Active authority:** file/refresh BOC-3 only. (current behavior)
|
||||||
|
- **Pending authority:** file BOC-3 + flag that active insurance must be on file
|
||||||
|
for the authority to activate; create follow-up todo.
|
||||||
|
- **Revoked/inactive authority:** file BOC-3 **and** flag/upsell reinstatement
|
||||||
|
(OP-1 reinstatement + $80 gov fee, route via `usdot-reactivation`/`mc-authority`
|
||||||
|
reinstatement branch). BOC-3 alone does not reinstate.
|
||||||
|
- **No authority (USDOT only):** BOC-3 has nothing to attach to — flag that MC
|
||||||
|
authority (`mc-authority`) is likely needed first; do not silently file.
|
||||||
|
Implementation: have `process()/handle()` read full authority status (reuse
|
||||||
|
`_check_boc3_status`, expanded to return structured fields), select the branch,
|
||||||
|
adjust the admin-todo + customer email, and emit a `recommended_followups` list
|
||||||
|
the order timeline / upsell can surface. No automatic charge for the follow-up —
|
||||||
|
surface it for the customer/admin to approve.
|
||||||
|
|
||||||
|
### Phase 2.6 — Prerequisite/activation gating (wait for FMCSA "active", not just "submitted")
|
||||||
|
There are real FMCSA dependency chains where a downstream filing must wait for an
|
||||||
|
upstream item to be **active at the agency**, not merely submitted by us. The
|
||||||
|
existing `pipeline_orchestrator.py` models ordering via `wait_for`, but a step is
|
||||||
|
treated as satisfied when `pipeline_step_status == "completed"` (= our handler
|
||||||
|
finished), which is NOT the same as FMCSA showing it active.
|
||||||
|
|
||||||
|
True prerequisites to enforce:
|
||||||
|
- **MC Authority (OP-1)** requires an **active USDOT**.
|
||||||
|
- **Authority activation** requires **BOC-3 on file + insurance (BMC-91) on file**,
|
||||||
|
then a **mandatory ~21-day vetting/protest period** before it goes active.
|
||||||
|
BOC-3 + insurance CAN be filed while authority is pending (parallel OK).
|
||||||
|
- **IRP / IFTA / intrastate-authority / UCR** that depend on *active* authority
|
||||||
|
(or active USDOT) must wait for that activation, not just for the prior order to
|
||||||
|
be submitted.
|
||||||
|
|
||||||
|
Implementation:
|
||||||
|
- Add an "activation gate" to the orchestrator: for dependency edges flagged
|
||||||
|
`require_active: true`, poll FMCSA (mobile QC API for USDOT status; L&I for
|
||||||
|
authority/BOC-3/insurance) and only mark the dependency satisfied when the
|
||||||
|
agency reports active. Until then, hold the downstream step in a
|
||||||
|
`waiting_on_activation` state with a next-poll timestamp.
|
||||||
|
- Encode the 21-day authority vetting window as an expected-activation estimate so
|
||||||
|
the timeline/customer comms set correct expectations.
|
||||||
|
- Expand `PIPELINES` edges with `require_active` flags (USDOT→MC, USDOT→IRP/IFTA/
|
||||||
|
UCR/intrastate, authority-active→IRP-for-hire/intrastate).
|
||||||
|
- Standalone (non-bundle) orders: when a single service is ordered whose
|
||||||
|
prerequisite isn't active yet, surface a clear "blocked until X is active"
|
||||||
|
status + recommended prerequisite order rather than filing prematurely.
|
||||||
|
|
||||||
|
### Phase 3 — Decide + handle state emissions (non-CA)
|
||||||
|
8. Either: (a) add a generic `state-emissions` service handler+product covering
|
||||||
|
NY/CO/MD/NJ/MA clean-truck/ACT programs, or (b) map those flags to
|
||||||
|
`state-dot-registration` / advisory-only. (Open question — see below.)
|
||||||
|
|
||||||
|
### Phase 4 — Order landing pages
|
||||||
|
9. Create `site/src/pages/order/*.astro` for: irp-ifta (combined), state weight
|
||||||
|
taxes (one templated page per state or a single state-aware page),
|
||||||
|
ca-mcp-carb, intrastate-authority, hazmat-phmsa. Reuse existing order-page
|
||||||
|
layout (e.g. `boc3-filing.astro`, `ucr-registration.astro` as templates).
|
||||||
|
|
||||||
|
### Phase 5 — Extend campaign builder (only after 1-4 verified)
|
||||||
|
10. Add new campaign segments to `build_trucking_campaigns.py` keyed off
|
||||||
|
`deficiency_flags`: for-hire/BOC-3+UCR, IRP/IFTA, intrastate-authority,
|
||||||
|
state weight-tax (per-state), CA MCP/CARB, hazmat. Each links to its LP.
|
||||||
|
11. Create the corresponding Listmonk source campaigns (templates) and wire IDs.
|
||||||
|
|
||||||
|
## Validation (how each part is verified)
|
||||||
|
|
||||||
|
- **Handlers:** unit-invoke each handler with a synthetic `order_data` dict in a
|
||||||
|
throwaway script; assert an `admin_todos` row is created with the expected
|
||||||
|
fields and (dev mode) no live filing fires. Confirm `SERVICE_HANDLERS` resolves
|
||||||
|
every new slug.
|
||||||
|
- **Intake completeness (the core ask):** write a check that, for every slug,
|
||||||
|
cross-references `REQUIRED_FIELDS[slug]` against the fields the intake step
|
||||||
|
emits and against the keys the handler reads — fail if a handler-needed field
|
||||||
|
is never collected. This is the verifiable "we collect all needed info" metric.
|
||||||
|
- **Checkout/products:** assert every flagged slug exists in `COMPLIANCE_SERVICES`,
|
||||||
|
`SERVICE_META`, checkout allowlist, and `SERVICE_HANDLERS` (one consistency test).
|
||||||
|
- **Order pages:** build site (`astro build` / existing build script) and confirm
|
||||||
|
each new `/order/<slug>` route renders; smoke-load locally.
|
||||||
|
- **Campaign builder:** run `build_trucking_campaigns.py --dry-run` and assert
|
||||||
|
each new segment selects a nonzero, deduped audience pointing at a valid LP.
|
||||||
|
- **End-to-end:** place a test order per new slug through checkout in dev, verify
|
||||||
|
intake validation blocks missing fields, and the handler produces a complete
|
||||||
|
admin todo.
|
||||||
|
|
||||||
|
## Open questions / decisions
|
||||||
|
|
||||||
|
**RESOLVED (per user 2026-06-02):**
|
||||||
|
1. **BOC-3 follow-ups + prerequisite blockers → upsell-approve, advise pre-order
|
||||||
|
where possible.** Two layers:
|
||||||
|
- *Pre-order advisory (preferred):* extend the existing DOT Compliance Check
|
||||||
|
tool (`site/public/tools/dot-compliance-check/`) + `dot-lookup` recommended
|
||||||
|
services to be **prerequisite-aware**. When a recommended service needs an
|
||||||
|
active prerequisite (e.g. IRP needs active authority), show the dependency,
|
||||||
|
pre-select the prerequisite, order the cart correctly, and state the ~21-day
|
||||||
|
authority activation expectation — all before payment.
|
||||||
|
- *Post-order upsell-approve:* when a handler discovers a blocker mid-fulfillment
|
||||||
|
(e.g. BOC-3 ordered but authority revoked → needs reinstatement), write a
|
||||||
|
`recommended_followups` entry on the order and render a one-click, pre-filled
|
||||||
|
"Add this service" card on the order timeline/portal. Customer confirms + pays
|
||||||
|
via the existing checkout. **No auto-charge.**
|
||||||
|
- *Standalone checkout guard:* if a service is bought directly and its prereq
|
||||||
|
isn't active at FMCSA, warn + offer to add the prereq rather than filing
|
||||||
|
something unfulfillable.
|
||||||
|
2. **State emissions (non-CA): build a real product.** Add a `state-emissions`
|
||||||
|
service (handler + product + intake + page) covering NY/CO/MD/NJ/MA clean-truck
|
||||||
|
/ Advanced Clean Trucks programs (CA stays on `ca-mcp-carb`).
|
||||||
|
3. **Pre-order advisory: yes.** Covered by 1 above — reuse the compliance-check
|
||||||
|
tool as the advisory surface.
|
||||||
|
|
||||||
|
**STILL OPEN:**
|
||||||
|
4. Pricing for `hazmat-phmsa` and `state-emissions`.
|
||||||
|
**DEFAULT:** `hazmat-phmsa` = $149 (admin-assisted PHMSA registration; gov fee
|
||||||
|
$25 placardable-hazmat reg billed at cost). `state-emissions` = $199
|
||||||
|
(NY/CO/MD/NJ/MA clean-truck / ACT advisory + registration assist).
|
||||||
|
5. Vehicle-list intake fidelity.
|
||||||
|
**DEFAULT:** lightweight up front — collect fleet count + base/operating states
|
||||||
|
+ fuel type + gross-weight bracket at intake; collect full per-vehicle
|
||||||
|
VIN/plate/weight rows via a post-order follow-up form only when the specific
|
||||||
|
filing requires it (IRP, weight-distance taxes). Keeps conversion high.
|
||||||
|
6. Standalone-order prereq guard.
|
||||||
|
**DEFAULT:** warn + offer to add the prerequisite (pre-selected), allow override
|
||||||
|
("file anyway"). Never a hard block.
|
||||||
|
7. Long-term home for the reviewable plan (`docs/plans/` used here; no
|
||||||
|
`side_panel` tool in this harness).
|
||||||
177
scripts/workers/services/hazmat_phmsa.py
Normal file
177
scripts/workers/services/hazmat_phmsa.py
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
"""
|
||||||
|
Hazmat / PHMSA Registration Service Handler.
|
||||||
|
|
||||||
|
Carriers that transport placardable quantities of hazardous materials must
|
||||||
|
register annually with PHMSA (Pipeline and Hazardous Materials Safety
|
||||||
|
Administration) under 49 CFR Part 107 Subpart G. This is separate from the
|
||||||
|
USDOT/FMCSA registration and from any HM safety permit.
|
||||||
|
|
||||||
|
Service slug: hazmat-phmsa
|
||||||
|
Price: $149 (admin-assisted)
|
||||||
|
Gov fee: PHMSA registration fee (varies by carrier size; ~$25 + $250-$3,000
|
||||||
|
processing fee depending on revenue/size) — billed at cost.
|
||||||
|
|
||||||
|
This is admin-assisted: we collect the carrier's hazmat profile via the intake
|
||||||
|
form, then file the PHMSA registration (Form via https://hazmatonline.phmsa.dot.gov)
|
||||||
|
on the carrier's behalf and send the registration certificate.
|
||||||
|
|
||||||
|
Intake data needed:
|
||||||
|
- DOT number
|
||||||
|
- Legal name / DBA
|
||||||
|
- Business address + contact
|
||||||
|
- EIN
|
||||||
|
- Hazmat classes / divisions transported
|
||||||
|
- Whether they transport in bulk packaging
|
||||||
|
- Estimated annual gross revenue (drives the PHMSA fee bracket)
|
||||||
|
- Number of employees
|
||||||
|
- Whether a small business (SBA size standard)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
LOG = logging.getLogger("workers.services.hazmat_phmsa")
|
||||||
|
|
||||||
|
# PHMSA registration fee brackets (49 CFR 107.612). The processing fee depends on
|
||||||
|
# whether the registrant qualifies as a small business / not-for-profit.
|
||||||
|
PHMSA_FEE_INFO = {
|
||||||
|
"small_business": {"registration_fee_cents": 2500, "processing_fee_cents": 25000}, # $25 + $250
|
||||||
|
"not_small": {"registration_fee_cents": 2500, "processing_fee_cents": 300000}, # $25 + $3,000
|
||||||
|
"portal": "https://hazmatonline.phmsa.dot.gov",
|
||||||
|
"regulation": "49 CFR Part 107 Subpart G",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HazmatPHMSAHandler:
|
||||||
|
"""Handle PHMSA hazmat registration orders (admin-assisted)."""
|
||||||
|
|
||||||
|
SERVICE_SLUG = "hazmat-phmsa"
|
||||||
|
SERVICE_NAME = "PHMSA Hazmat Registration"
|
||||||
|
|
||||||
|
async def process(self, order_data: dict) -> list[str]:
|
||||||
|
"""Entry point called by job_server. Delegates to handle()."""
|
||||||
|
order_number = order_data.get("order_number", order_data.get("name", ""))
|
||||||
|
return self.handle(order_data, order_number)
|
||||||
|
|
||||||
|
def handle(self, order_data: dict, order_number: str) -> list[str]:
|
||||||
|
"""Process a PHMSA hazmat registration order."""
|
||||||
|
LOG.info("[%s] Processing PHMSA hazmat registration order", order_number)
|
||||||
|
|
||||||
|
intake = order_data.get("intake_data") or {}
|
||||||
|
if isinstance(intake, str):
|
||||||
|
intake = json.loads(intake)
|
||||||
|
|
||||||
|
dot_number = intake.get("dot_number", "")
|
||||||
|
entity_name = intake.get("entity_name", intake.get("legal_name",
|
||||||
|
order_data.get("customer_name", "")))
|
||||||
|
customer_email = order_data.get("customer_email", "")
|
||||||
|
|
||||||
|
# Determine fee bracket from small-business flag.
|
||||||
|
is_small = bool(intake.get("small_business"))
|
||||||
|
fee = PHMSA_FEE_INFO["small_business" if is_small else "not_small"]
|
||||||
|
|
||||||
|
hazmat_classes = intake.get("hazmat_classes", [])
|
||||||
|
bulk = bool(intake.get("bulk_packaging"))
|
||||||
|
|
||||||
|
steps = [
|
||||||
|
f"1. Log into PHMSA portal: {PHMSA_FEE_INFO['portal']}",
|
||||||
|
"2. Start a new registration (or renewal) under 49 CFR Part 107 Subpart G",
|
||||||
|
"3. Enter carrier identity (legal name, DOT#, EIN, address)",
|
||||||
|
f"4. Enter hazmat classes/divisions: {', '.join(hazmat_classes) if hazmat_classes else 'PER INTAKE'}",
|
||||||
|
f"5. Indicate bulk packaging: {'YES' if bulk else 'NO'}",
|
||||||
|
f"6. Select fee bracket: {'small business ($25 + $250)' if is_small else 'standard ($25 + $3,000)'}",
|
||||||
|
"7. Pay the PHMSA registration + processing fee (billed to client at cost)",
|
||||||
|
"8. Download the Certificate of Registration",
|
||||||
|
"9. Send certificate + registration number to client; set renewal reminder (annual)",
|
||||||
|
]
|
||||||
|
|
||||||
|
todo_data = {
|
||||||
|
"order_number": order_number,
|
||||||
|
"service": self.SERVICE_NAME,
|
||||||
|
"service_slug": self.SERVICE_SLUG,
|
||||||
|
"dot_number": dot_number,
|
||||||
|
"entity_name": entity_name,
|
||||||
|
"customer_email": customer_email,
|
||||||
|
"hazmat_classes": hazmat_classes,
|
||||||
|
"bulk_packaging": bulk,
|
||||||
|
"small_business": is_small,
|
||||||
|
"estimated_gov_fee_cents": fee["registration_fee_cents"] + fee["processing_fee_cents"],
|
||||||
|
"intake_data": intake,
|
||||||
|
"steps": steps,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
import psycopg2
|
||||||
|
conn = psycopg2.connect(os.environ.get("DATABASE_URL", ""))
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO admin_todos (
|
||||||
|
title, category, priority, order_number, service_slug,
|
||||||
|
description, data, status
|
||||||
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, 'pending')
|
||||||
|
""", (
|
||||||
|
f"PHMSA Hazmat Registration — {entity_name} (DOT {dot_number})"
|
||||||
|
if dot_number else f"PHMSA Hazmat Registration — {entity_name}",
|
||||||
|
"filing",
|
||||||
|
"high",
|
||||||
|
order_number,
|
||||||
|
self.SERVICE_SLUG,
|
||||||
|
f"Service: {self.SERVICE_NAME}\n"
|
||||||
|
f"DOT: {dot_number}\n"
|
||||||
|
f"Hazmat classes: {', '.join(hazmat_classes) if hazmat_classes else 'see intake'}\n"
|
||||||
|
f"Bulk packaging: {'Yes' if bulk else 'No'}\n"
|
||||||
|
f"Small business: {'Yes' if is_small else 'No'}\n"
|
||||||
|
f"Est. gov fee: ${(fee['registration_fee_cents'] + fee['processing_fee_cents']) / 100:,.2f}\n"
|
||||||
|
f"Customer: {customer_email}\n\n"
|
||||||
|
f"Steps:\n" + "\n".join(steps),
|
||||||
|
json.dumps(todo_data),
|
||||||
|
))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
LOG.info("[%s] Admin todo created for PHMSA hazmat registration", order_number)
|
||||||
|
except Exception as exc:
|
||||||
|
LOG.error("[%s] Failed to create admin todo: %s", order_number, exc)
|
||||||
|
|
||||||
|
self._send_status_email(order_number, entity_name, dot_number, customer_email)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _send_status_email(self, order_number, entity_name, dot_number, customer_email):
|
||||||
|
"""Send the client a status email."""
|
||||||
|
if not customer_email:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
import smtplib
|
||||||
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
|
dot_line = f" (DOT# {dot_number})" if dot_number else ""
|
||||||
|
body = (
|
||||||
|
f"Hi,\n\n"
|
||||||
|
f"We've received your PHMSA Hazmat Registration order for "
|
||||||
|
f"{entity_name}{dot_line}.\n\n"
|
||||||
|
f"Order: {order_number}\n\n"
|
||||||
|
f"Our team will prepare and file your PHMSA registration "
|
||||||
|
f"(49 CFR Part 107) and send you the Certificate of Registration "
|
||||||
|
f"once complete, typically within 1-2 business days. The PHMSA "
|
||||||
|
f"government fee is billed at cost and depends on your business size.\n\n"
|
||||||
|
f"Questions? Reply to this email or call (888) 411-0383.\n\n"
|
||||||
|
f"Performance West Inc.\n"
|
||||||
|
f"DOT Compliance Services\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = MIMEText(body)
|
||||||
|
msg["Subject"] = f"PHMSA Hazmat Registration In Progress — {entity_name}{dot_line}"
|
||||||
|
msg["From"] = "noreply@performancewest.net"
|
||||||
|
msg["To"] = customer_email
|
||||||
|
|
||||||
|
with smtplib.SMTP("localhost", 25) as s:
|
||||||
|
s.sendmail(msg["From"], [customer_email], msg.as_string())
|
||||||
|
|
||||||
|
LOG.info("[%s] Status email sent to %s", order_number, customer_email)
|
||||||
|
except Exception as exc:
|
||||||
|
LOG.warning("[%s] Failed to send status email: %s", order_number, exc)
|
||||||
296
site/src/components/intake/steps/StateTruckingIntakeStep.astro
Normal file
296
site/src/components/intake/steps/StateTruckingIntakeStep.astro
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
---
|
||||||
|
// State-level trucking + hazmat/emissions intake. Slug-gated sections, mirroring
|
||||||
|
// DOTIntakeStep.astro. Collects the carrier identity plus the specific data each
|
||||||
|
// state filing / hazmat / emissions handler needs.
|
||||||
|
---
|
||||||
|
|
||||||
|
<div class="pw-step" data-slug="state-trucking">
|
||||||
|
<h2>Filing Information</h2>
|
||||||
|
<p class="pw-help">
|
||||||
|
Provide your carrier details so we can prepare your state filing(s). We file on your behalf.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="pw-form-grid">
|
||||||
|
<!-- ═══ Carrier Identity (always shown) ═══ -->
|
||||||
|
<h3>Carrier Information</h3>
|
||||||
|
<div class="pw-row">
|
||||||
|
<label class="pw-field"><span>Legal Entity Name <em>*</em></span>
|
||||||
|
<input type="text" id="st-legal-name" required placeholder="As registered with FMCSA" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="pw-row-3">
|
||||||
|
<label class="pw-field"><span>USDOT Number <em>*</em></span>
|
||||||
|
<input type="text" id="st-dot" required placeholder="e.g. 1234567" /></label>
|
||||||
|
<label class="pw-field"><span>MC/MX/FF Number</span>
|
||||||
|
<input type="text" id="st-mc" placeholder="e.g. MC-123456" /></label>
|
||||||
|
<label class="pw-field"><span>Email <em>*</em></span>
|
||||||
|
<input type="email" id="st-email" required placeholder="you@company.com" /></label>
|
||||||
|
</div>
|
||||||
|
<div class="pw-row-2">
|
||||||
|
<label class="pw-field"><span>Base State <em>*</em></span>
|
||||||
|
<select id="st-base-state" required>
|
||||||
|
<option value="">--</option>
|
||||||
|
<option>AL</option><option>AK</option><option>AZ</option><option>AR</option><option>CA</option><option>CO</option><option>CT</option><option>DE</option><option>FL</option><option>GA</option><option>HI</option><option>ID</option><option>IL</option><option>IN</option><option>IA</option><option>KS</option><option>KY</option><option>LA</option><option>ME</option><option>MD</option><option>MA</option><option>MI</option><option>MN</option><option>MS</option><option>MO</option><option>MT</option><option>NE</option><option>NV</option><option>NH</option><option>NJ</option><option>NM</option><option>NY</option><option>NC</option><option>ND</option><option>OH</option><option>OK</option><option>OR</option><option>PA</option><option>RI</option><option>SC</option><option>SD</option><option>TN</option><option>TX</option><option>UT</option><option>VT</option><option>VA</option><option>WA</option><option>WV</option><option>WI</option><option>WY</option><option>DC</option>
|
||||||
|
</select></label>
|
||||||
|
<label class="pw-field"><span>Power Units (trucks) <em>*</em></span>
|
||||||
|
<input type="number" id="st-power-units" min="0" placeholder="e.g. 5" /></label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ IRP / IFTA (interstate registration + fuel tax) ═══ -->
|
||||||
|
<div id="st-sec-irp-ifta" hidden>
|
||||||
|
<h3>Interstate Operations</h3>
|
||||||
|
<div class="pw-row-2">
|
||||||
|
<label class="pw-field"><span>Primary Fuel Type</span>
|
||||||
|
<select id="st-fuel-type">
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<option value="diesel">Diesel</option>
|
||||||
|
<option value="gasoline">Gasoline</option>
|
||||||
|
<option value="propane">Propane / LPG</option>
|
||||||
|
<option value="cng">CNG / LNG</option>
|
||||||
|
<option value="electric">Electric</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select></label>
|
||||||
|
<label class="pw-field"><span>Gross Weight Bracket</span>
|
||||||
|
<select id="st-gross-weight">
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<option value="under_26k">Under 26,000 lbs</option>
|
||||||
|
<option value="26k_to_80k">26,001–80,000 lbs</option>
|
||||||
|
<option value="over_80k">Over 80,000 lbs</option>
|
||||||
|
</select></label>
|
||||||
|
</div>
|
||||||
|
<div class="pw-row">
|
||||||
|
<label class="pw-field"><span>Operating States (besides base) <em>*</em></span>
|
||||||
|
<input type="text" id="st-operating-states" placeholder="e.g. CA, NV, AZ, OR (comma-separated)" /></label>
|
||||||
|
</div>
|
||||||
|
<p class="pw-field-help">After your order, we'll send a short follow-up form to collect each vehicle's VIN, plate, and registered weight for the apportioned/IFTA filing.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ CA MCP + CARB / Emissions ═══ -->
|
||||||
|
<div id="st-sec-emissions" hidden>
|
||||||
|
<h3>Fleet Emissions Profile</h3>
|
||||||
|
<div class="pw-row-2">
|
||||||
|
<label class="pw-field"><span>CA Number (if any)</span>
|
||||||
|
<input type="text" id="st-ca-number" placeholder="CHP CA# if already issued" /></label>
|
||||||
|
<label class="pw-field"><span>Oldest Engine Model Year</span>
|
||||||
|
<input type="number" id="st-engine-year" min="1990" max="2030" placeholder="e.g. 2014" /></label>
|
||||||
|
</div>
|
||||||
|
<p class="pw-field-help">Clean-truck programs (CARB Clean Truck Check, NY/CO/MD/NJ/MA Advanced Clean Trucks) phase out older engines. We'll review your fleet's model years against your states' thresholds.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ Intrastate Authority ═══ -->
|
||||||
|
<div id="st-sec-intrastate" hidden>
|
||||||
|
<h3>Intrastate Operating Authority</h3>
|
||||||
|
<div class="pw-row-2">
|
||||||
|
<label class="pw-field"><span>Authority Type <em>*</em></span>
|
||||||
|
<select id="st-authority-type">
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<option value="common">Common Carrier (COA)</option>
|
||||||
|
<option value="contract">Contract Carrier</option>
|
||||||
|
<option value="cpcn">Certificate of Public Convenience & Necessity</option>
|
||||||
|
<option value="household_goods">Household Goods Mover</option>
|
||||||
|
<option value="unknown">Not sure — please advise</option>
|
||||||
|
</select></label>
|
||||||
|
<label class="pw-field"><span>BOC-3 already on file?</span>
|
||||||
|
<select id="st-boc3">
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<option value="yes">Yes</option>
|
||||||
|
<option value="no">No</option>
|
||||||
|
<option value="unknown">Not sure</option>
|
||||||
|
</select></label>
|
||||||
|
</div>
|
||||||
|
<div class="pw-row-2">
|
||||||
|
<label class="pw-field"><span>Insurance Carrier</span>
|
||||||
|
<input type="text" id="st-ins-carrier" placeholder="Your liability insurer" /></label>
|
||||||
|
<label class="pw-field"><span>Insurance Policy #</span>
|
||||||
|
<input type="text" id="st-ins-policy" placeholder="Policy number" /></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ OSOW Permit ═══ -->
|
||||||
|
<div id="st-sec-osow" hidden>
|
||||||
|
<h3>Oversize / Overweight Load</h3>
|
||||||
|
<div class="pw-row-2">
|
||||||
|
<label class="pw-field"><span>Load Dimensions (L×W×H)</span>
|
||||||
|
<input type="text" id="st-load-dims" placeholder="e.g. 75ft × 12ft × 14ft" /></label>
|
||||||
|
<label class="pw-field"><span>Gross Load Weight (lbs)</span>
|
||||||
|
<input type="number" id="st-load-weight" min="0" placeholder="e.g. 120000" /></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ Hazmat / PHMSA ═══ -->
|
||||||
|
<div id="st-sec-hazmat" hidden>
|
||||||
|
<h3>Hazmat Profile (PHMSA Registration)</h3>
|
||||||
|
<p class="pw-field-help">PHMSA registration is required for carriers transporting placardable quantities of hazardous materials (49 CFR Part 107).</p>
|
||||||
|
<div class="pw-hazmat-grid">
|
||||||
|
<label><input type="checkbox" data-hazmat="class1" /> Class 1 — Explosives</label>
|
||||||
|
<label><input type="checkbox" data-hazmat="class2" /> Class 2 — Gases</label>
|
||||||
|
<label><input type="checkbox" data-hazmat="class3" /> Class 3 — Flammable Liquids</label>
|
||||||
|
<label><input type="checkbox" data-hazmat="class4" /> Class 4 — Flammable Solids</label>
|
||||||
|
<label><input type="checkbox" data-hazmat="class5" /> Class 5 — Oxidizers</label>
|
||||||
|
<label><input type="checkbox" data-hazmat="class6" /> Class 6 — Toxic/Infectious</label>
|
||||||
|
<label><input type="checkbox" data-hazmat="class7" /> Class 7 — Radioactive</label>
|
||||||
|
<label><input type="checkbox" data-hazmat="class8" /> Class 8 — Corrosives</label>
|
||||||
|
<label><input type="checkbox" data-hazmat="class9" /> Class 9 — Misc.</label>
|
||||||
|
</div>
|
||||||
|
<div class="pw-row-2" style="margin-top:0.75rem">
|
||||||
|
<label class="pw-field"><span>Transport in bulk packaging?</span>
|
||||||
|
<select id="st-bulk">
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<option value="no">No</option>
|
||||||
|
<option value="yes">Yes</option>
|
||||||
|
</select></label>
|
||||||
|
<label class="pw-field"><span>Small business? (under SBA size standard)</span>
|
||||||
|
<select id="st-small-biz">
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<option value="yes">Yes (lower PHMSA fee)</option>
|
||||||
|
<option value="no">No</option>
|
||||||
|
</select></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pw-st-errors" class="pw-err" hidden></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.pw-step h2 { margin: 0 0 0.5rem; color: #1a2744; }
|
||||||
|
.pw-step h3 { color: #1a2744; margin: 1.25rem 0 0.5rem; font-size: 0.95rem; border-bottom: 1px solid #e2e8f0; padding-bottom: 0.3rem; }
|
||||||
|
.pw-help { color: #64748b; font-size: 0.9rem; margin-bottom: 1rem; }
|
||||||
|
.pw-form-grid { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 1rem 1.25rem; }
|
||||||
|
.pw-row, .pw-row-2, .pw-row-3 { margin-bottom: 0.75rem; }
|
||||||
|
.pw-row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; }
|
||||||
|
.pw-row-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.75rem; }
|
||||||
|
.pw-field { display: flex; flex-direction: column; gap: 0.2rem; }
|
||||||
|
.pw-field span { font-size: 0.8rem; font-weight: 600; color: #374151; }
|
||||||
|
.pw-field em { color: #dc2626; font-style: normal; }
|
||||||
|
.pw-field input, .pw-field select { padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.85rem; }
|
||||||
|
.pw-field input:focus, .pw-field select:focus { outline: none; border-color: #f97316; box-shadow: 0 0 0 2px rgba(249,115,22,0.2); }
|
||||||
|
.pw-field-help { font-size: 0.85rem; color: #64748b; margin: 0.25rem 0 0.5rem; }
|
||||||
|
.pw-hazmat-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0.3rem 1rem; font-size: 0.85rem; color: #374151; }
|
||||||
|
.pw-hazmat-grid label { display: flex; align-items: center; gap: 0.4rem; cursor: pointer; }
|
||||||
|
.pw-err { color: #b91c1c; margin-top: 0.75rem; font-size: 0.9rem; background: #fee2e2; padding: 0.5rem 0.75rem; border-radius: 6px; }
|
||||||
|
@media (max-width: 640px) { .pw-row-2, .pw-row-3 { grid-template-columns: 1fr; } .pw-hazmat-grid { grid-template-columns: 1fr 1fr; } }
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script is:inline>
|
||||||
|
if (!document.querySelector('[data-slug="state-trucking"], [data-step="state-trucking"]')) {
|
||||||
|
// Not a state-trucking intake page — skip
|
||||||
|
} else {
|
||||||
|
|
||||||
|
// Which sections to show per slug.
|
||||||
|
const ST_SECTIONS = {
|
||||||
|
"irp-registration": ["st-sec-irp-ifta"],
|
||||||
|
"ifta-application": ["st-sec-irp-ifta"],
|
||||||
|
"ifta-quarterly": ["st-sec-irp-ifta"],
|
||||||
|
"or-weight-mile-tax": ["st-sec-irp-ifta"],
|
||||||
|
"ny-hut-registration": ["st-sec-irp-ifta"],
|
||||||
|
"ky-kyu-registration": ["st-sec-irp-ifta"],
|
||||||
|
"nm-weight-distance": ["st-sec-irp-ifta"],
|
||||||
|
"ct-highway-use-fee": ["st-sec-irp-ifta"],
|
||||||
|
"ca-mcp-carb": ["st-sec-emissions"],
|
||||||
|
"state-emissions": ["st-sec-emissions"],
|
||||||
|
"state-dot-registration":[],
|
||||||
|
"intrastate-authority": ["st-sec-intrastate"],
|
||||||
|
"osow-permit": ["st-sec-osow"],
|
||||||
|
"state-trucking-bundle": ["st-sec-irp-ifta", "st-sec-emissions", "st-sec-intrastate"],
|
||||||
|
"hazmat-phmsa": ["st-sec-hazmat"],
|
||||||
|
};
|
||||||
|
const ALL_SECTIONS = ["st-sec-irp-ifta","st-sec-emissions","st-sec-intrastate","st-sec-osow","st-sec-hazmat"];
|
||||||
|
|
||||||
|
function showSections() {
|
||||||
|
const PW = window.PWIntake;
|
||||||
|
const state = PW?.get?.() || {};
|
||||||
|
const wizardEl = document.querySelector(".pw-wizard[data-service]");
|
||||||
|
const pageSlug = wizardEl?.getAttribute("data-service") || "";
|
||||||
|
const slugs = state.batch_slugs || [pageSlug || state.service_slug || ""];
|
||||||
|
const show = new Set();
|
||||||
|
for (const slug of slugs) for (const sec of (ST_SECTIONS[slug] || [])) show.add(sec);
|
||||||
|
for (const id of ALL_SECTIONS) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.hidden = !show.has(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSections() {
|
||||||
|
const wizardEl = document.querySelector(".pw-wizard[data-service]");
|
||||||
|
if (!wizardEl) { setTimeout(initSections, 100); return; }
|
||||||
|
showSections();
|
||||||
|
}
|
||||||
|
initSections();
|
||||||
|
|
||||||
|
window.addEventListener("pw:step-shown", (evt) => {
|
||||||
|
if (evt.detail.step !== "state-trucking") return;
|
||||||
|
showSections();
|
||||||
|
const s = window.PWIntake.get();
|
||||||
|
const d = s.intake_data || {};
|
||||||
|
const map = {
|
||||||
|
"st-legal-name": d.legal_name || d.entity_name || "", "st-dot": d.dot_number || "",
|
||||||
|
"st-mc": d.mc_number || "", "st-email": d.email || s.email || "",
|
||||||
|
"st-base-state": d.base_state || d.address_state || "", "st-power-units": d.power_units || "",
|
||||||
|
"st-fuel-type": d.fuel_type || "", "st-gross-weight": d.gross_weight_bracket || "",
|
||||||
|
"st-operating-states": Array.isArray(d.operating_states) ? d.operating_states.join(", ") : (d.operating_states || ""),
|
||||||
|
"st-ca-number": d.ca_number || "", "st-engine-year": d.engine_model_years || "",
|
||||||
|
"st-authority-type": d.authority_type || "", "st-boc3": d.boc3_on_file || "",
|
||||||
|
"st-ins-carrier": d.insurance_carrier || "", "st-ins-policy": d.insurance_policy || "",
|
||||||
|
"st-load-dims": d.load_dimensions || "", "st-load-weight": d.load_weight || "",
|
||||||
|
"st-bulk": d.bulk_packaging || "", "st-small-biz": d.small_business || "",
|
||||||
|
};
|
||||||
|
for (const [id, val] of Object.entries(map)) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el && val) el.value = val;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("pw:step-next", (evt) => {
|
||||||
|
const PW = window.PWIntake;
|
||||||
|
if (PW.steps[PW.get().step_index] !== "state-trucking") return;
|
||||||
|
const errDiv = document.getElementById("pw-st-errors");
|
||||||
|
errDiv.hidden = true;
|
||||||
|
const val = (id) => (document.getElementById(id))?.value?.trim() || "";
|
||||||
|
|
||||||
|
// Base required (always)
|
||||||
|
const required = ["st-legal-name","st-dot","st-email","st-base-state","st-power-units"];
|
||||||
|
const missing = [];
|
||||||
|
for (const id of required) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el || !el.value.trim()) missing.push(el?.parentElement?.querySelector("span")?.textContent || id);
|
||||||
|
}
|
||||||
|
// Section-specific required
|
||||||
|
const sectionRequired = {
|
||||||
|
"st-sec-irp-ifta": [["st-operating-states","Operating States"]],
|
||||||
|
"st-sec-intrastate": [["st-authority-type","Authority Type"]],
|
||||||
|
};
|
||||||
|
for (const [secId, fields] of Object.entries(sectionRequired)) {
|
||||||
|
const sec = document.getElementById(secId);
|
||||||
|
if (sec && !sec.hidden) {
|
||||||
|
for (const [id, label] of fields) {
|
||||||
|
if (!val(id)) missing.push(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Hazmat: at least one class
|
||||||
|
const hazSec = document.getElementById("st-sec-hazmat");
|
||||||
|
let hazmatClasses = [];
|
||||||
|
document.querySelectorAll("[data-hazmat]").forEach((cb) => { if (cb.checked) hazmatClasses.push(cb.dataset.hazmat); });
|
||||||
|
if (hazSec && !hazSec.hidden && hazmatClasses.length === 0) {
|
||||||
|
missing.push("At least one hazmat class");
|
||||||
|
}
|
||||||
|
if (missing.length) { evt.preventDefault(); errDiv.hidden = false; errDiv.textContent = "Please fill in: " + missing.join(", "); return; }
|
||||||
|
|
||||||
|
const opStates = val("st-operating-states").split(",").map(s => s.trim().toUpperCase()).filter(Boolean);
|
||||||
|
const state = PW.get();
|
||||||
|
PW.set({ ...state, intake_data: { ...state.intake_data,
|
||||||
|
legal_name: val("st-legal-name"), entity_name: val("st-legal-name"),
|
||||||
|
dot_number: val("st-dot"), mc_number: val("st-mc"), email: val("st-email"),
|
||||||
|
base_state: val("st-base-state"), power_units: val("st-power-units"),
|
||||||
|
fuel_type: val("st-fuel-type"), gross_weight_bracket: val("st-gross-weight"),
|
||||||
|
operating_states: opStates,
|
||||||
|
ca_number: val("st-ca-number"), engine_model_years: val("st-engine-year"),
|
||||||
|
authority_type: val("st-authority-type"), boc3_on_file: val("st-boc3"),
|
||||||
|
insurance_carrier: val("st-ins-carrier"), insurance_policy: val("st-ins-policy"),
|
||||||
|
load_dimensions: val("st-load-dims"), load_weight: val("st-load-weight"),
|
||||||
|
hazmat_classes: hazmatClasses, bulk_packaging: val("st-bulk"), small_business: val("st-small-biz") === "yes",
|
||||||
|
}});
|
||||||
|
});
|
||||||
|
|
||||||
|
} // end guard
|
||||||
|
</script>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue