Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec3df3415f | |||
| c63bced08b | |||
| 190ef5f58e |
@@ -1,99 +0,0 @@
|
|||||||
# Leads — Backend Changes Required (be-crm)
|
|
||||||
|
|
||||||
Follow-ups the **frontend now depends on**. The frontend side of each item below is
|
|
||||||
already implemented and shipped on branch `integration-lead-and-verification-be`; these
|
|
||||||
are the matching changes needed in **be-crm** (`crm.lead.*` / `crm.media.*`) for the
|
|
||||||
features to work end-to-end.
|
|
||||||
|
|
||||||
Companion to `LEADS_BACKEND_REQUIREMENTS.md` (the full contract). Section refs (§) point there.
|
|
||||||
|
|
||||||
**How this was found:** integration testing the live data door at `http://localhost:4010`
|
|
||||||
(trust-headers dev mode: `X-Tenant-ID: tnt_demo`, `X-Principal-ID: prn_owner`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Resolve assignee / creator IDs to member display names in `LeadDTO`
|
|
||||||
|
|
||||||
**Status:** IDs are stored correctly; **display names are not resolved.**
|
|
||||||
|
|
||||||
### What we observed
|
|
||||||
`crm.lead.create` with `assignment.assigneeId` → the id persists, and `crm.lead.get`
|
|
||||||
returns the assignee/creator — but the `name` is just the **raw principal id echoed back**,
|
|
||||||
and the dedicated `*Name` fields come back empty:
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
// crm.lead.get response (relevant fields)
|
|
||||||
"assignedToId": "prn_owner",
|
|
||||||
"assignedToName": "", // ← empty, not resolved
|
|
||||||
"assignee": { "id": "prn_owner", "name": "prn_owner", "initials": "P" }, // name = the id
|
|
||||||
"createdById": "prn_owner",
|
|
||||||
"createdByName": "", // ← empty, not resolved
|
|
||||||
"createdBy": { "id": "prn_owner", "name": "prn_owner", "initials": "P" } // name = the id
|
|
||||||
```
|
|
||||||
|
|
||||||
The contract (§6.2) says the detail projection must carry **"resolved assignee / canvasser /
|
|
||||||
creator display names."** Today they are unresolved, so the detail popup's **Assigned To** and
|
|
||||||
**Created By** show a raw id (or nothing) instead of a person's name.
|
|
||||||
|
|
||||||
### Required change
|
|
||||||
In the `crm.lead.get` (and any `LeadDTO`-returning command) projection, **join the member
|
|
||||||
FKs to the `members` table** and populate the real `displayName`:
|
|
||||||
|
|
||||||
- `assigned_to_id` → `assignedToName` **and** `assignee.name` = member `displayName`
|
|
||||||
- `created_by_id` → `createdByName` **and** `createdBy.name` = member `displayName`
|
|
||||||
- `canvasser_id` → `canvasserName` (same treatment, §5 note 5)
|
|
||||||
|
|
||||||
Fall back to the id only when the member can't be resolved. Note the dev tenant `tnt_demo`
|
|
||||||
returns **0 members** from `crm.team.member.search` — seed members there so assignment can be
|
|
||||||
exercised (§12 note 5 / "[Seed/link required]").
|
|
||||||
|
|
||||||
### Frontend interim (already shipped — remove once backend resolves names)
|
|
||||||
`src/lib/leads-api.ts` now resolves the returned id against the local reps list
|
|
||||||
(`crm.team.member.search`, principalId → name) as a safety net, and reads the `createdBy`
|
|
||||||
**object** (previously the reader only looked at the never-populated `createdByName`, so
|
|
||||||
"Created By" rendered blank). Backend resolution is still the correct long-term source of truth.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Site photos — NO backend change (uses existing `crm.lead.attachment.add`)
|
|
||||||
|
|
||||||
**Status:** No new backend work. The endpoints already exist; the frontend was wired to them.
|
|
||||||
|
|
||||||
### Decision: photos persist via `crm.lead.attachment.add` AFTER create (NOT in `crm.lead.create`)
|
|
||||||
Photos are **not** sent in the create payload. The frontend creates the lead first, then calls the
|
|
||||||
existing `crm.lead.attachment.add` command once per photo, keyed by the new lead's id (postman **#24**).
|
|
||||||
|
|
||||||
### Frontend flow (already shipped)
|
|
||||||
1. User picks images in the full form → Property step.
|
|
||||||
2. Each file is uploaded to storage via the shared Media module:
|
|
||||||
`crm.media.presignUpload { mime, sizeBytes }` → `{ objectKey, uploadUrl }`, then browser `PUT`s the bytes (§11.5).
|
|
||||||
3. On submit: `crm.lead.create` (WITHOUT photos) → then per photo:
|
|
||||||
```jsonc
|
|
||||||
{ "action": "crm.lead.attachment.add",
|
|
||||||
"variables": { "id": "<newLeadId>",
|
|
||||||
"attachment": { "contentRef": "<objectKey>", "mimeType": "image/jpeg", "sizeBytes": 12345, "filename": "roof.jpg" } } }
|
|
||||||
```
|
|
||||||
4. Detail popup reads `attachments[]` from `crm.lead.get` and renders each via `crm.media.presignDownload { contentRef }`.
|
|
||||||
|
|
||||||
### Backend expectations (should already hold — just confirm)
|
|
||||||
- `crm.lead.attachment.add` inserts a `lead_attachments` row and enforces the 25 MB cap → `413 file_too_large` (§4.4).
|
|
||||||
- `crm.lead.get` returns the stored photos as **`attachments: Attachment[]`**, each with at least
|
|
||||||
`{ id, contentRef, mimeType, sizeBytes, filename }` (postman #3 / #24 already reference `j.attachments`).
|
|
||||||
- `crm.media.presignUpload` / `crm.media.presignDownload` are registered for this app/tenant (shared Media module).
|
|
||||||
|
|
||||||
### Not verified live
|
|
||||||
Could **not** confirm against `:4010` (dev backend intermittently down during testing). When stable, run:
|
|
||||||
`crm.media.presignUpload` → `crm.lead.create` → `crm.lead.attachment.add` → `crm.lead.get` and confirm
|
|
||||||
`attachments[]` comes back.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick verification checklist (run against `:4010` when backend is up)
|
|
||||||
|
|
||||||
- [ ] Seed members in `tnt_demo`; `crm.team.member.search` returns > 0 items.
|
|
||||||
- [ ] `crm.lead.create` with `assignment.assigneeId = <member principalId>` → `crm.lead.get`
|
|
||||||
returns `assignedToName` = that member's real display name.
|
|
||||||
- [ ] `crm.lead.get` returns `createdByName` = the creating caller's display name.
|
|
||||||
- [ ] `crm.media.presignUpload` returns `{ objectKey, uploadUrl }`; PUT to `uploadUrl` succeeds.
|
|
||||||
- [ ] `crm.lead.attachment.add { id, attachment }` → `crm.lead.get` returns matching `attachments[]`.
|
|
||||||
- [ ] `crm.media.presignDownload { contentRef }` returns a working signed URL for a stored photo.
|
|
||||||
-340
@@ -1,340 +0,0 @@
|
|||||||
###############################################################################
|
|
||||||
# Leads & Lead Verification — data-door requests (VS Code "REST Client" / JetBrains HTTP).
|
|
||||||
# Install the REST Client extension, then click "Send Request" above each block.
|
|
||||||
# Requests are chained: run the "@name" reads/writes in order so the id variables
|
|
||||||
# below resolve. The cleanest end-to-end path is:
|
|
||||||
# #10 intake → #11 assign → #12 reassign → #15 verify (promotes to a Lead).
|
|
||||||
# Dev trust-headers mode — change @principal to test different callers.
|
|
||||||
###############################################################################
|
|
||||||
|
|
||||||
@baseUrl = http://localhost:4010
|
|
||||||
@tenant = tnt_demo
|
|
||||||
@principal = prn_owner
|
|
||||||
|
|
||||||
# Shared headers are repeated per request (the format has no header includes).
|
|
||||||
|
|
||||||
### Health — see every registered action (crm.lead.* + crm.leadVerification.*)
|
|
||||||
GET {{baseUrl}}/health
|
|
||||||
|
|
||||||
# ─────────────────────────────── LEADS: READS (POST /query) ─────────────────────
|
|
||||||
|
|
||||||
### 1. Lead stats (header stat strip: total + byStatus)
|
|
||||||
POST {{baseUrl}}/query
|
|
||||||
Authorization: Bearer dev
|
|
||||||
X-App-ID: crm-web
|
|
||||||
X-Tenant-ID: {{tenant}}
|
|
||||||
X-Principal-ID: {{principal}}
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{ "action": "crm.lead.stats", "variables": {} }
|
|
||||||
|
|
||||||
### 2. Lead search (RUN THIS — provides @leadId). Supports query/status/priority/source/assigneeId/page/perPage.
|
|
||||||
# @name leadSearch
|
|
||||||
POST {{baseUrl}}/query
|
|
||||||
Authorization: Bearer dev
|
|
||||||
X-App-ID: crm-web
|
|
||||||
X-Tenant-ID: {{tenant}}
|
|
||||||
X-Principal-ID: {{principal}}
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{ "action": "crm.lead.search", "variables": { "status": "new", "page": 1, "perPage": 50 } }
|
|
||||||
|
|
||||||
@leadId = {{leadSearch.response.body.$.items[0].id}}
|
|
||||||
|
|
||||||
### 3. Lead get (full detail incl. phones/emails/attachments + resolved names)
|
|
||||||
POST {{baseUrl}}/query
|
|
||||||
Authorization: Bearer dev
|
|
||||||
X-App-ID: crm-web
|
|
||||||
X-Tenant-ID: {{tenant}}
|
|
||||||
X-Principal-ID: {{principal}}
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{ "action": "crm.lead.get", "variables": { "id": "{{leadId}}" } }
|
|
||||||
|
|
||||||
# ────────────────────── LEAD VERIFICATION: READS (POST /query) ──────────────────
|
|
||||||
|
|
||||||
### 4. Verification stats (the clickable stat tiles)
|
|
||||||
POST {{baseUrl}}/query
|
|
||||||
Authorization: Bearer dev
|
|
||||||
X-App-ID: crm-web
|
|
||||||
X-Tenant-ID: {{tenant}}
|
|
||||||
X-Principal-ID: {{principal}}
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{ "action": "crm.leadVerification.stats", "variables": {} }
|
|
||||||
|
|
||||||
### 5. Verification search (RUN THIS — provides @verificationId)
|
|
||||||
# @name vSearch
|
|
||||||
POST {{baseUrl}}/query
|
|
||||||
Authorization: Bearer dev
|
|
||||||
X-App-ID: crm-web
|
|
||||||
X-Tenant-ID: {{tenant}}
|
|
||||||
X-Principal-ID: {{principal}}
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{ "action": "crm.leadVerification.search", "variables": { "page": 1, "perPage": 50 } }
|
|
||||||
|
|
||||||
@verificationId = {{vSearch.response.body.$.items[0].id}}
|
|
||||||
|
|
||||||
### 6. Verification get (detail + activity timeline)
|
|
||||||
POST {{baseUrl}}/query
|
|
||||||
Authorization: Bearer dev
|
|
||||||
X-App-ID: crm-web
|
|
||||||
X-Tenant-ID: {{tenant}}
|
|
||||||
X-Principal-ID: {{principal}}
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{ "action": "crm.leadVerification.get", "variables": { "id": "{{verificationId}}" } }
|
|
||||||
|
|
||||||
### 7. Verification activity list (also embedded in .get)
|
|
||||||
POST {{baseUrl}}/query
|
|
||||||
Authorization: Bearer dev
|
|
||||||
X-App-ID: crm-web
|
|
||||||
X-Tenant-ID: {{tenant}}
|
|
||||||
X-Principal-ID: {{principal}}
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{ "action": "crm.leadVerification.activity.list", "variables": { "id": "{{verificationId}}" } }
|
|
||||||
|
|
||||||
# ─────────────── VERIFICATION: WRITES (POST /command + unique key) ───────────────
|
|
||||||
# Commands require a UNIQUE X-Idempotency-Key each time (reuse ⇒ 409).
|
|
||||||
# Records "arrive" via intake (§12.11) — start the working flow at #10.
|
|
||||||
|
|
||||||
### 10. Intake — seed a verification record (RUN — provides @seedVerificationId)
|
|
||||||
# @name intake
|
|
||||||
POST {{baseUrl}}/command
|
|
||||||
Authorization: Bearer dev
|
|
||||||
X-App-ID: crm-web
|
|
||||||
X-Tenant-ID: {{tenant}}
|
|
||||||
X-Principal-ID: {{principal}}
|
|
||||||
X-Idempotency-Key: lv-intake-001
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{ "action": "crm.leadVerification.intake", "variables": {
|
|
||||||
"name": "Kevin Hartley",
|
|
||||||
"phone": "(972) 413-8902",
|
|
||||||
"email": "kevin.hartley@example.com",
|
|
||||||
"address": "2814 Ravenswood Dr, Plano, TX 75023",
|
|
||||||
"source": "Door Knock"
|
|
||||||
} }
|
|
||||||
|
|
||||||
@seedVerificationId = {{intake.response.body.$.id}}
|
|
||||||
|
|
||||||
### 11. Assign (Change Assignee) — pending → assigned
|
|
||||||
POST {{baseUrl}}/command
|
|
||||||
Authorization: Bearer dev
|
|
||||||
X-App-ID: crm-web
|
|
||||||
X-Tenant-ID: {{tenant}}
|
|
||||||
X-Principal-ID: {{principal}}
|
|
||||||
X-Idempotency-Key: lv-assign-001
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{ "action": "crm.leadVerification.assign", "variables": { "id": "{{seedVerificationId}}", "assigneeId": "{{principal}}" } }
|
|
||||||
|
|
||||||
### 12. Reassign → In Progress
|
|
||||||
POST {{baseUrl}}/command
|
|
||||||
Authorization: Bearer dev
|
|
||||||
X-App-ID: crm-web
|
|
||||||
X-Tenant-ID: {{tenant}}
|
|
||||||
X-Principal-ID: {{principal}}
|
|
||||||
X-Idempotency-Key: lv-reassign-001
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{ "action": "crm.leadVerification.reassign", "variables": { "id": "{{seedVerificationId}}", "assigneeId": "{{principal}}" } }
|
|
||||||
|
|
||||||
### 13. Update status + sub-status (generic; cannot set 'verified' here — use #15)
|
|
||||||
POST {{baseUrl}}/command
|
|
||||||
Authorization: Bearer dev
|
|
||||||
X-App-ID: crm-web
|
|
||||||
X-Tenant-ID: {{tenant}}
|
|
||||||
X-Principal-ID: {{principal}}
|
|
||||||
X-Idempotency-Key: lv-updatestatus-001
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{ "action": "crm.leadVerification.updateStatus", "variables": { "id": "{{seedVerificationId}}", "status": "in_progress", "subStatus": "Reviewing Insurance" } }
|
|
||||||
|
|
||||||
### 14. Add a verification note (appends + timeline entry)
|
|
||||||
POST {{baseUrl}}/command
|
|
||||||
Authorization: Bearer dev
|
|
||||||
X-App-ID: crm-web
|
|
||||||
X-Tenant-ID: {{tenant}}
|
|
||||||
X-Principal-ID: {{principal}}
|
|
||||||
X-Idempotency-Key: lv-note-001
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{ "action": "crm.leadVerification.note.add", "variables": { "id": "{{seedVerificationId}}", "text": "Ownership confirmed via county records." } }
|
|
||||||
|
|
||||||
### 15. Verify — PROMOTES to a new Lead (status = new). Returns { verification, lead }.
|
|
||||||
# @name verify
|
|
||||||
POST {{baseUrl}}/command
|
|
||||||
Authorization: Bearer dev
|
|
||||||
X-App-ID: crm-web
|
|
||||||
X-Tenant-ID: {{tenant}}
|
|
||||||
X-Principal-ID: {{principal}}
|
|
||||||
X-Idempotency-Key: lv-verify-001
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{ "action": "crm.leadVerification.verify", "variables": { "id": "{{seedVerificationId}}", "notes": "Verified — insurance + damage confirmed." } }
|
|
||||||
|
|
||||||
@promotedLeadId = {{verify.response.body.$.lead.id}}
|
|
||||||
|
|
||||||
### 16. Get the promoted Lead (created by #15)
|
|
||||||
POST {{baseUrl}}/query
|
|
||||||
Authorization: Bearer dev
|
|
||||||
X-App-ID: crm-web
|
|
||||||
X-Tenant-ID: {{tenant}}
|
|
||||||
X-Principal-ID: {{principal}}
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{ "action": "crm.lead.get", "variables": { "id": "{{promotedLeadId}}" } }
|
|
||||||
|
|
||||||
### 17. Move to Pending (send an assigned/in_progress record back). Seed a second record first if needed.
|
|
||||||
POST {{baseUrl}}/command
|
|
||||||
Authorization: Bearer dev
|
|
||||||
X-App-ID: crm-web
|
|
||||||
X-Tenant-ID: {{tenant}}
|
|
||||||
X-Principal-ID: {{principal}}
|
|
||||||
X-Idempotency-Key: lv-topending-001
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{ "action": "crm.leadVerification.moveToPending", "variables": { "id": "{{verificationId}}" } }
|
|
||||||
|
|
||||||
### 18. Mark Unverified (any non-terminal record)
|
|
||||||
POST {{baseUrl}}/command
|
|
||||||
Authorization: Bearer dev
|
|
||||||
X-App-ID: crm-web
|
|
||||||
X-Tenant-ID: {{tenant}}
|
|
||||||
X-Principal-ID: {{principal}}
|
|
||||||
X-Idempotency-Key: lv-unverified-001
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{ "action": "crm.leadVerification.markUnverified", "variables": { "id": "{{verificationId}}", "reason": "Could not confirm ownership." } }
|
|
||||||
|
|
||||||
# ─────────────────────── LEADS: WRITES (POST /command + unique key) ──────────────
|
|
||||||
|
|
||||||
### 20. Create Lead — Full Form payload (RUN — provides @createdLeadId)
|
|
||||||
# @name createLead
|
|
||||||
POST {{baseUrl}}/command
|
|
||||||
Authorization: Bearer dev
|
|
||||||
X-App-ID: crm-web
|
|
||||||
X-Tenant-ID: {{tenant}}
|
|
||||||
X-Principal-ID: {{principal}}
|
|
||||||
X-Idempotency-Key: lead-create-001
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{ "action": "crm.lead.create", "variables": {
|
|
||||||
"firstName": "John",
|
|
||||||
"lastName": "Martinez",
|
|
||||||
"phones": [
|
|
||||||
{ "number": "(469) 500-1000", "type": "Mobile", "primary": true },
|
|
||||||
{ "number": "(469) 500-2000", "type": "Home" }
|
|
||||||
],
|
|
||||||
"emails": [ { "address": "john.martinez@example.com", "primary": true } ],
|
|
||||||
"property": { "address": "4821 Spring Creek Pkwy", "city": "Plano", "state": "TX", "zip": "75023", "type": "Single Family" },
|
|
||||||
"job": { "source": "Door Knock", "canvasserId": "{{principal}}", "leadType": "Insurance", "workType": "Roof Replacement", "tradeType": "Roofing", "urgency": "High", "notes": "Significant granule loss on the south slope." },
|
|
||||||
"insurance": { "company": "State Farm", "claimStatus": "Filed", "claimNumber": "CLM-2026-1000", "policyNumber": "POL-080000", "adjusterName": "Marcus Powell", "adjusterPhone": "(972) 700-3000" },
|
|
||||||
"assignment": { "assigneeId": "{{principal}}", "priority": "High", "followUp": "2026-06-04" }
|
|
||||||
} }
|
|
||||||
|
|
||||||
@createdLeadId = {{createLead.response.body.$.id}}
|
|
||||||
|
|
||||||
### 21. Update Lead (partial patch; priority title-case is normalized to lowercase)
|
|
||||||
POST {{baseUrl}}/command
|
|
||||||
Authorization: Bearer dev
|
|
||||||
X-App-ID: crm-web
|
|
||||||
X-Tenant-ID: {{tenant}}
|
|
||||||
X-Principal-ID: {{principal}}
|
|
||||||
X-Idempotency-Key: lead-update-001
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{ "action": "crm.lead.update", "variables": { "id": "{{createdLeadId}}", "patch": { "priority": "Medium", "jobNotes": "Adjuster meeting scheduled." } } }
|
|
||||||
|
|
||||||
### 22. Update Status (audited → lead_status_history)
|
|
||||||
POST {{baseUrl}}/command
|
|
||||||
Authorization: Bearer dev
|
|
||||||
X-App-ID: crm-web
|
|
||||||
X-Tenant-ID: {{tenant}}
|
|
||||||
X-Principal-ID: {{principal}}
|
|
||||||
X-Idempotency-Key: lead-status-001
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{ "action": "crm.lead.updateStatus", "variables": { "id": "{{createdLeadId}}", "status": "contacted", "note": "Spoke with homeowner." } }
|
|
||||||
|
|
||||||
### 23. Assign a rep (or null for Unassigned)
|
|
||||||
POST {{baseUrl}}/command
|
|
||||||
Authorization: Bearer dev
|
|
||||||
X-App-ID: crm-web
|
|
||||||
X-Tenant-ID: {{tenant}}
|
|
||||||
X-Principal-ID: {{principal}}
|
|
||||||
X-Idempotency-Key: lead-assign-001
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{ "action": "crm.lead.assign", "variables": { "id": "{{createdLeadId}}", "assigneeId": "{{principal}}" } }
|
|
||||||
|
|
||||||
### 24. Attachment add (site photo) — contentRef comes from crm.media.presignUpload (§11.5)
|
|
||||||
POST {{baseUrl}}/command
|
|
||||||
Authorization: Bearer dev
|
|
||||||
X-App-ID: crm-web
|
|
||||||
X-Tenant-ID: {{tenant}}
|
|
||||||
X-Principal-ID: {{principal}}
|
|
||||||
X-Idempotency-Key: lead-attach-add-001
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{ "action": "crm.lead.attachment.add", "variables": { "id": "{{createdLeadId}}", "attachment": { "contentRef": "obj/site-photo-1.jpg", "mimeType": "image/jpeg", "sizeBytes": 1048576, "filename": "roof-south.jpg" } } }
|
|
||||||
|
|
||||||
### 25. Attachment remove (attachmentId from the lead's attachments[] in #3/#16)
|
|
||||||
POST {{baseUrl}}/command
|
|
||||||
Authorization: Bearer dev
|
|
||||||
X-App-ID: crm-web
|
|
||||||
X-Tenant-ID: {{tenant}}
|
|
||||||
X-Principal-ID: {{principal}}
|
|
||||||
X-Idempotency-Key: lead-attach-remove-001
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{ "action": "crm.lead.attachment.remove", "variables": { "id": "{{createdLeadId}}", "attachmentId": "REPLACE_WITH_ATTACHMENT_ID" } }
|
|
||||||
|
|
||||||
# ─────────────────────────── NEGATIVE / GUARD CHECKS ────────────────────────────
|
|
||||||
|
|
||||||
### Verify an already-verified record (expect 409 already_verified)
|
|
||||||
POST {{baseUrl}}/command
|
|
||||||
Authorization: Bearer dev
|
|
||||||
X-App-ID: crm-web
|
|
||||||
X-Tenant-ID: {{tenant}}
|
|
||||||
X-Principal-ID: {{principal}}
|
|
||||||
X-Idempotency-Key: guard-alreadyverified-001
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{ "action": "crm.leadVerification.verify", "variables": { "id": "{{seedVerificationId}}" } }
|
|
||||||
|
|
||||||
### Create with no name (expect 422 name_required)
|
|
||||||
POST {{baseUrl}}/command
|
|
||||||
Authorization: Bearer dev
|
|
||||||
X-App-ID: crm-web
|
|
||||||
X-Tenant-ID: {{tenant}}
|
|
||||||
X-Principal-ID: {{principal}}
|
|
||||||
X-Idempotency-Key: guard-noname-001
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{ "action": "crm.lead.create", "variables": { "firstName": "", "lastName": " " } }
|
|
||||||
|
|
||||||
### Create with a bad email (expect 400 validation)
|
|
||||||
POST {{baseUrl}}/command
|
|
||||||
Authorization: Bearer dev
|
|
||||||
X-App-ID: crm-web
|
|
||||||
X-Tenant-ID: {{tenant}}
|
|
||||||
X-Principal-ID: {{principal}}
|
|
||||||
X-Idempotency-Key: guard-bademail-001
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{ "action": "crm.lead.create", "variables": { "firstName": "Jane", "emails": [ { "address": "not-an-email" } ] } }
|
|
||||||
|
|
||||||
### Invalid state transition — verified is terminal via generic updateStatus (expect 409 / 422)
|
|
||||||
POST {{baseUrl}}/command
|
|
||||||
Authorization: Bearer dev
|
|
||||||
X-App-ID: crm-web
|
|
||||||
X-Tenant-ID: {{tenant}}
|
|
||||||
X-Principal-ID: {{principal}}
|
|
||||||
X-Idempotency-Key: guard-transition-001
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{ "action": "crm.leadVerification.updateStatus", "variables": { "id": "{{seedVerificationId}}", "status": "pending" } }
|
|
||||||
@@ -1,554 +0,0 @@
|
|||||||
{
|
|
||||||
"info": {
|
|
||||||
"name": "be-crm — Leads & Lead Verification (data door)",
|
|
||||||
"description": "All Leads (crm.lead.*) and Lead Verification (crm.leadVerification.*) data-door actions — for local testing against be-crm on :4010 (trust-headers mode).\n\nHOW TO USE:\n1. Import this file into Postman.\n2. Run 'Verification writes / 10. intake' FIRST — its test script captures the new record id into {{seedVerificationId}}, which the assign/verify chain references. The cleanest end-to-end path is: 10 intake → 11 assign → 12 reassign → 15 verify (promotes to a Lead, captured into {{promotedLeadId}}).\n3. The read folders ('Leads reads' / 'Verification reads') capture {{leadId}} / {{verificationId}} from search results when data exists.\n\nVERIFICATION STATE MACHINE (enforced server-side, §3.1): a record flows pending → assigned → in_progress, and ends at ONE terminal outcome — either verified (via 15. verify, which promotes a Lead) OR unverified (via 17. markUnverified). 'verified' is TERMINAL: markUnverified / moveToPending / updateStatus against a verified record return 409 invalid_state_transition. So run ONE terminal action per record. To exercise markUnverified or moveToPending, run 10. intake to seed a FRESH pending record (repoints {{verificationId}}) and act on it BEFORE verifying — e.g. intake → assign → moveToPending, or intake → markUnverified. Valid: markUnverified from pending/assigned/in_progress; moveToPending from assigned/in_progress.\n\nNOTE: everything is POST except GET /health. Reads hit /query, writes hit /command (action+data in the JSON body). Commands auto-generate a fresh X-Idempotency-Key ({{$guid}}). In dev, the identity headers stand in for what the Shell BFF injects in real environments — never ship them from the frontend. Both modules are gated by the leads.manage permission.",
|
|
||||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
|
||||||
},
|
|
||||||
"variable": [
|
|
||||||
{ "key": "baseUrl", "value": "http://localhost:4010" },
|
|
||||||
{ "key": "tenant", "value": "tnt_demo" },
|
|
||||||
{ "key": "principal", "value": "prn_owner" },
|
|
||||||
{ "key": "leadId", "value": "" },
|
|
||||||
{ "key": "verificationId", "value": "" },
|
|
||||||
{ "key": "seedVerificationId", "value": "" },
|
|
||||||
{ "key": "promotedLeadId", "value": "" },
|
|
||||||
{ "key": "createdLeadId", "value": "" },
|
|
||||||
{ "key": "attachmentId", "value": "" }
|
|
||||||
],
|
|
||||||
"item": [
|
|
||||||
{
|
|
||||||
"name": "0. Health (the only GET)",
|
|
||||||
"request": {
|
|
||||||
"method": "GET",
|
|
||||||
"header": [],
|
|
||||||
"url": { "raw": "{{baseUrl}}/health", "host": ["{{baseUrl}}"], "path": ["health"] },
|
|
||||||
"description": "Liveness + the full list of registered actions (crm.lead.* + crm.leadVerification.*). Works in a browser."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Leads reads (POST /query)",
|
|
||||||
"item": [
|
|
||||||
{
|
|
||||||
"name": "1. lead.stats (header stat strip)",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{ "key": "Authorization", "value": "Bearer dev" },
|
|
||||||
{ "key": "X-App-ID", "value": "crm-web" },
|
|
||||||
{ "key": "X-Tenant-ID", "value": "{{tenant}}" },
|
|
||||||
{ "key": "X-Principal-ID", "value": "{{principal}}" },
|
|
||||||
{ "key": "Content-Type", "value": "application/json" }
|
|
||||||
],
|
|
||||||
"body": { "mode": "raw", "raw": "{\n \"action\": \"crm.lead.stats\",\n \"variables\": {}\n}" },
|
|
||||||
"url": { "raw": "{{baseUrl}}/query", "host": ["{{baseUrl}}"], "path": ["query"] },
|
|
||||||
"description": "Returns { total, byStatus: { new, contacted, appointed, closed } }."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "2. lead.search (captures leadId)",
|
|
||||||
"event": [
|
|
||||||
{
|
|
||||||
"listen": "test",
|
|
||||||
"script": {
|
|
||||||
"type": "text/javascript",
|
|
||||||
"exec": [
|
|
||||||
"const j = pm.response.json();",
|
|
||||||
"if (j.items && j.items[0]) pm.collectionVariables.set('leadId', j.items[0].id);",
|
|
||||||
"pm.test('has paging meta', () => pm.expect(j.meta).to.have.property('total'));"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{ "key": "Authorization", "value": "Bearer dev" },
|
|
||||||
{ "key": "X-App-ID", "value": "crm-web" },
|
|
||||||
{ "key": "X-Tenant-ID", "value": "{{tenant}}" },
|
|
||||||
{ "key": "X-Principal-ID", "value": "{{principal}}" },
|
|
||||||
{ "key": "Content-Type", "value": "application/json" }
|
|
||||||
],
|
|
||||||
"body": { "mode": "raw", "raw": "{\n \"action\": \"crm.lead.search\",\n \"variables\": { \"status\": \"new\", \"page\": 1, \"perPage\": 50 }\n}" },
|
|
||||||
"url": { "raw": "{{baseUrl}}/query", "host": ["{{baseUrl}}"], "path": ["query"] },
|
|
||||||
"description": "Board list + search + status/priority/source/assigneeId filters. Returns { items: LeadCardDTO[], meta }."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "3. lead.get (full detail)",
|
|
||||||
"event": [
|
|
||||||
{
|
|
||||||
"listen": "test",
|
|
||||||
"script": {
|
|
||||||
"type": "text/javascript",
|
|
||||||
"exec": [
|
|
||||||
"const j = pm.response.json();",
|
|
||||||
"if (j.attachments && j.attachments[0]) pm.collectionVariables.set('attachmentId', j.attachments[0].id);"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{ "key": "Authorization", "value": "Bearer dev" },
|
|
||||||
{ "key": "X-App-ID", "value": "crm-web" },
|
|
||||||
{ "key": "X-Tenant-ID", "value": "{{tenant}}" },
|
|
||||||
{ "key": "X-Principal-ID", "value": "{{principal}}" },
|
|
||||||
{ "key": "Content-Type", "value": "application/json" }
|
|
||||||
],
|
|
||||||
"body": { "mode": "raw", "raw": "{\n \"action\": \"crm.lead.get\",\n \"variables\": { \"id\": \"{{leadId}}\" }\n}" },
|
|
||||||
"url": { "raw": "{{baseUrl}}/query", "host": ["{{baseUrl}}"], "path": ["query"] },
|
|
||||||
"description": "Full record incl. phones/emails/attachments + resolved assignee/canvasser/creator names + storm/property/job/insurance groupings."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Verification reads (POST /query)",
|
|
||||||
"item": [
|
|
||||||
{
|
|
||||||
"name": "4. leadVerification.stats (stat tiles)",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{ "key": "Authorization", "value": "Bearer dev" },
|
|
||||||
{ "key": "X-App-ID", "value": "crm-web" },
|
|
||||||
{ "key": "X-Tenant-ID", "value": "{{tenant}}" },
|
|
||||||
{ "key": "X-Principal-ID", "value": "{{principal}}" },
|
|
||||||
{ "key": "Content-Type", "value": "application/json" }
|
|
||||||
],
|
|
||||||
"body": { "mode": "raw", "raw": "{\n \"action\": \"crm.leadVerification.stats\",\n \"variables\": {}\n}" },
|
|
||||||
"url": { "raw": "{{baseUrl}}/query", "host": ["{{baseUrl}}"], "path": ["query"] },
|
|
||||||
"description": "Returns { verified, in_progress, assigned, pending, unverified }."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "5. leadVerification.search (captures verificationId)",
|
|
||||||
"event": [
|
|
||||||
{
|
|
||||||
"listen": "test",
|
|
||||||
"script": {
|
|
||||||
"type": "text/javascript",
|
|
||||||
"exec": [
|
|
||||||
"const j = pm.response.json();",
|
|
||||||
"if (j.items && j.items[0]) pm.collectionVariables.set('verificationId', j.items[0].id);",
|
|
||||||
"pm.test('has paging meta', () => pm.expect(j.meta).to.have.property('total'));"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{ "key": "Authorization", "value": "Bearer dev" },
|
|
||||||
{ "key": "X-App-ID", "value": "crm-web" },
|
|
||||||
{ "key": "X-Tenant-ID", "value": "{{tenant}}" },
|
|
||||||
{ "key": "X-Principal-ID", "value": "{{principal}}" },
|
|
||||||
{ "key": "Content-Type", "value": "application/json" }
|
|
||||||
],
|
|
||||||
"body": { "mode": "raw", "raw": "{\n \"action\": \"crm.leadVerification.search\",\n \"variables\": { \"page\": 1, \"perPage\": 50 }\n}" },
|
|
||||||
"url": { "raw": "{{baseUrl}}/query", "host": ["{{baseUrl}}"], "path": ["query"] },
|
|
||||||
"description": "Queue table + search + status/source/assignee filters. Returns { items: VerificationRowDTO[], meta }."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "6. leadVerification.get (detail + timeline)",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{ "key": "Authorization", "value": "Bearer dev" },
|
|
||||||
{ "key": "X-App-ID", "value": "crm-web" },
|
|
||||||
{ "key": "X-Tenant-ID", "value": "{{tenant}}" },
|
|
||||||
{ "key": "X-Principal-ID", "value": "{{principal}}" },
|
|
||||||
{ "key": "Content-Type", "value": "application/json" }
|
|
||||||
],
|
|
||||||
"body": { "mode": "raw", "raw": "{\n \"action\": \"crm.leadVerification.get\",\n \"variables\": { \"id\": \"{{verificationId}}\" }\n}" },
|
|
||||||
"url": { "raw": "{{baseUrl}}/query", "host": ["{{baseUrl}}"], "path": ["query"] }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "7. leadVerification.activity.list",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{ "key": "Authorization", "value": "Bearer dev" },
|
|
||||||
{ "key": "X-App-ID", "value": "crm-web" },
|
|
||||||
{ "key": "X-Tenant-ID", "value": "{{tenant}}" },
|
|
||||||
{ "key": "X-Principal-ID", "value": "{{principal}}" },
|
|
||||||
{ "key": "Content-Type", "value": "application/json" }
|
|
||||||
],
|
|
||||||
"body": { "mode": "raw", "raw": "{\n \"action\": \"crm.leadVerification.activity.list\",\n \"variables\": { \"id\": \"{{verificationId}}\" }\n}" },
|
|
||||||
"url": { "raw": "{{baseUrl}}/query", "host": ["{{baseUrl}}"], "path": ["query"] }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Verification writes (POST /command)",
|
|
||||||
"item": [
|
|
||||||
{
|
|
||||||
"name": "10. leadVerification.intake (RUN FIRST — captures seedVerificationId)",
|
|
||||||
"event": [
|
|
||||||
{
|
|
||||||
"listen": "test",
|
|
||||||
"script": {
|
|
||||||
"type": "text/javascript",
|
|
||||||
"exec": [
|
|
||||||
"const j = pm.response.json();",
|
|
||||||
"if (j && j.id) { pm.collectionVariables.set('seedVerificationId', j.id); pm.collectionVariables.set('verificationId', j.id); }",
|
|
||||||
"pm.test('starts pending', () => pm.expect(j.status).to.eql('pending'));"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{ "key": "Authorization", "value": "Bearer dev" },
|
|
||||||
{ "key": "X-App-ID", "value": "crm-web" },
|
|
||||||
{ "key": "X-Tenant-ID", "value": "{{tenant}}" },
|
|
||||||
{ "key": "X-Principal-ID", "value": "{{principal}}" },
|
|
||||||
{ "key": "X-Idempotency-Key", "value": "{{$guid}}" },
|
|
||||||
{ "key": "Content-Type", "value": "application/json" }
|
|
||||||
],
|
|
||||||
"body": { "mode": "raw", "raw": "{\n \"action\": \"crm.leadVerification.intake\",\n \"variables\": {\n \"name\": \"Kevin Hartley\",\n \"phone\": \"(972) 413-8902\",\n \"email\": \"kevin.hartley@example.com\",\n \"address\": \"2814 Ravenswood Dr, Plano, TX 75023\",\n \"source\": \"Door Knock\"\n }\n}" },
|
|
||||||
"url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] },
|
|
||||||
"description": "Records 'arrive' via intake (§12.11) — the ingestion path outside the two UI screens. Seeds the queue and captures {{seedVerificationId}} for the working flow below."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "11. leadVerification.assign (pending → assigned)",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{ "key": "Authorization", "value": "Bearer dev" },
|
|
||||||
{ "key": "X-App-ID", "value": "crm-web" },
|
|
||||||
{ "key": "X-Tenant-ID", "value": "{{tenant}}" },
|
|
||||||
{ "key": "X-Principal-ID", "value": "{{principal}}" },
|
|
||||||
{ "key": "X-Idempotency-Key", "value": "{{$guid}}" },
|
|
||||||
{ "key": "Content-Type", "value": "application/json" }
|
|
||||||
],
|
|
||||||
"body": { "mode": "raw", "raw": "{\n \"action\": \"crm.leadVerification.assign\",\n \"variables\": { \"id\": \"{{seedVerificationId}}\", \"assigneeId\": \"{{principal}}\" }\n}" },
|
|
||||||
"url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] },
|
|
||||||
"description": "Change Assignee. A pending record moves to 'assigned'; otherwise only the assignee changes."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "12. leadVerification.reassign (→ In Progress)",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{ "key": "Authorization", "value": "Bearer dev" },
|
|
||||||
{ "key": "X-App-ID", "value": "crm-web" },
|
|
||||||
{ "key": "X-Tenant-ID", "value": "{{tenant}}" },
|
|
||||||
{ "key": "X-Principal-ID", "value": "{{principal}}" },
|
|
||||||
{ "key": "X-Idempotency-Key", "value": "{{$guid}}" },
|
|
||||||
{ "key": "Content-Type", "value": "application/json" }
|
|
||||||
],
|
|
||||||
"body": { "mode": "raw", "raw": "{\n \"action\": \"crm.leadVerification.reassign\",\n \"variables\": { \"id\": \"{{seedVerificationId}}\", \"assigneeId\": \"{{principal}}\" }\n}" },
|
|
||||||
"url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "13. leadVerification.updateStatus (generic; not 'verified')",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{ "key": "Authorization", "value": "Bearer dev" },
|
|
||||||
{ "key": "X-App-ID", "value": "crm-web" },
|
|
||||||
{ "key": "X-Tenant-ID", "value": "{{tenant}}" },
|
|
||||||
{ "key": "X-Principal-ID", "value": "{{principal}}" },
|
|
||||||
{ "key": "X-Idempotency-Key", "value": "{{$guid}}" },
|
|
||||||
{ "key": "Content-Type", "value": "application/json" }
|
|
||||||
],
|
|
||||||
"body": { "mode": "raw", "raw": "{\n \"action\": \"crm.leadVerification.updateStatus\",\n \"variables\": { \"id\": \"{{seedVerificationId}}\", \"status\": \"in_progress\", \"subStatus\": \"Reviewing Insurance\" }\n}" },
|
|
||||||
"url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] },
|
|
||||||
"description": "Generic status + sub-status set. Setting 'verified' here → 422 (use 15. verify, which promotes a Lead). Disallowed transitions → 409."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "14. leadVerification.note.add",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{ "key": "Authorization", "value": "Bearer dev" },
|
|
||||||
{ "key": "X-App-ID", "value": "crm-web" },
|
|
||||||
{ "key": "X-Tenant-ID", "value": "{{tenant}}" },
|
|
||||||
{ "key": "X-Principal-ID", "value": "{{principal}}" },
|
|
||||||
{ "key": "X-Idempotency-Key", "value": "{{$guid}}" },
|
|
||||||
{ "key": "Content-Type", "value": "application/json" }
|
|
||||||
],
|
|
||||||
"body": { "mode": "raw", "raw": "{\n \"action\": \"crm.leadVerification.note.add\",\n \"variables\": { \"id\": \"{{seedVerificationId}}\", \"text\": \"Ownership confirmed via county records.\" }\n}" },
|
|
||||||
"url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "15. leadVerification.verify (PROMOTES to Lead — captures promotedLeadId)",
|
|
||||||
"event": [
|
|
||||||
{
|
|
||||||
"listen": "test",
|
|
||||||
"script": {
|
|
||||||
"type": "text/javascript",
|
|
||||||
"exec": [
|
|
||||||
"const j = pm.response.json();",
|
|
||||||
"if (j.lead && j.lead.id) pm.collectionVariables.set('promotedLeadId', j.lead.id);",
|
|
||||||
"pm.test('verification is verified', () => pm.expect(j.verification.status).to.eql('verified'));",
|
|
||||||
"pm.test('promoted lead is new', () => pm.expect(j.lead.status).to.eql('new'));"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{ "key": "Authorization", "value": "Bearer dev" },
|
|
||||||
{ "key": "X-App-ID", "value": "crm-web" },
|
|
||||||
{ "key": "X-Tenant-ID", "value": "{{tenant}}" },
|
|
||||||
{ "key": "X-Principal-ID", "value": "{{principal}}" },
|
|
||||||
{ "key": "X-Idempotency-Key", "value": "{{$guid}}" },
|
|
||||||
{ "key": "Content-Type", "value": "application/json" }
|
|
||||||
],
|
|
||||||
"body": { "mode": "raw", "raw": "{\n \"action\": \"crm.leadVerification.verify\",\n \"variables\": { \"id\": \"{{seedVerificationId}}\", \"notes\": \"Verified — insurance + damage confirmed.\" }\n}" },
|
|
||||||
"url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] },
|
|
||||||
"description": "The critical cross-module command: flips the record to verified and creates a new Lead (status=new) in one transaction, linking both records. Returns { verification, lead }. A second call → 409 already_verified."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "16. leadVerification.moveToPending",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{ "key": "Authorization", "value": "Bearer dev" },
|
|
||||||
{ "key": "X-App-ID", "value": "crm-web" },
|
|
||||||
{ "key": "X-Tenant-ID", "value": "{{tenant}}" },
|
|
||||||
{ "key": "X-Principal-ID", "value": "{{principal}}" },
|
|
||||||
{ "key": "X-Idempotency-Key", "value": "{{$guid}}" },
|
|
||||||
{ "key": "Content-Type", "value": "application/json" }
|
|
||||||
],
|
|
||||||
"body": { "mode": "raw", "raw": "{\n \"action\": \"crm.leadVerification.moveToPending\",\n \"variables\": { \"id\": \"{{verificationId}}\" }\n}" },
|
|
||||||
"url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] },
|
|
||||||
"description": "Send an assigned/in_progress record back to pending (keeps the assignee). Point at a non-verified {{verificationId}}."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "17. leadVerification.markUnverified",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{ "key": "Authorization", "value": "Bearer dev" },
|
|
||||||
{ "key": "X-App-ID", "value": "crm-web" },
|
|
||||||
{ "key": "X-Tenant-ID", "value": "{{tenant}}" },
|
|
||||||
{ "key": "X-Principal-ID", "value": "{{principal}}" },
|
|
||||||
{ "key": "X-Idempotency-Key", "value": "{{$guid}}" },
|
|
||||||
{ "key": "Content-Type", "value": "application/json" }
|
|
||||||
],
|
|
||||||
"body": { "mode": "raw", "raw": "{\n \"action\": \"crm.leadVerification.markUnverified\",\n \"variables\": { \"id\": \"{{verificationId}}\", \"reason\": \"Could not confirm ownership.\" }\n}" },
|
|
||||||
"url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] },
|
|
||||||
"description": "Terminal outcome. Allowed from pending/assigned/in_progress; a verified record → 409. Point {{verificationId}} at a NON-verified record (run 10. intake to seed a fresh pending one)."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Leads writes (POST /command)",
|
|
||||||
"item": [
|
|
||||||
{
|
|
||||||
"name": "20. lead.create (Full Form — captures createdLeadId)",
|
|
||||||
"event": [
|
|
||||||
{
|
|
||||||
"listen": "test",
|
|
||||||
"script": {
|
|
||||||
"type": "text/javascript",
|
|
||||||
"exec": [
|
|
||||||
"const j = pm.response.json();",
|
|
||||||
"if (j && j.id) { pm.collectionVariables.set('createdLeadId', j.id); pm.collectionVariables.set('leadId', j.id); }",
|
|
||||||
"pm.test('priority normalized to lowercase', () => pm.expect(j.priority).to.eql('high'));"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{ "key": "Authorization", "value": "Bearer dev" },
|
|
||||||
{ "key": "X-App-ID", "value": "crm-web" },
|
|
||||||
{ "key": "X-Tenant-ID", "value": "{{tenant}}" },
|
|
||||||
{ "key": "X-Principal-ID", "value": "{{principal}}" },
|
|
||||||
{ "key": "X-Idempotency-Key", "value": "{{$guid}}" },
|
|
||||||
{ "key": "Content-Type", "value": "application/json" }
|
|
||||||
],
|
|
||||||
"body": { "mode": "raw", "raw": "{\n \"action\": \"crm.lead.create\",\n \"variables\": {\n \"firstName\": \"John\",\n \"lastName\": \"Martinez\",\n \"phones\": [\n { \"number\": \"(469) 500-1000\", \"type\": \"Mobile\", \"primary\": true },\n { \"number\": \"(469) 500-2000\", \"type\": \"Home\" }\n ],\n \"emails\": [ { \"address\": \"john.martinez@example.com\", \"primary\": true } ],\n \"property\": { \"address\": \"4821 Spring Creek Pkwy\", \"city\": \"Plano\", \"state\": \"TX\", \"zip\": \"75023\", \"type\": \"Single Family\" },\n \"job\": { \"source\": \"Door Knock\", \"canvasserId\": \"{{principal}}\", \"leadType\": \"Insurance\", \"workType\": \"Roof Replacement\", \"tradeType\": \"Roofing\", \"urgency\": \"High\", \"notes\": \"Significant granule loss on the south slope.\" },\n \"insurance\": { \"company\": \"State Farm\", \"claimStatus\": \"Filed\", \"claimNumber\": \"CLM-2026-1000\", \"policyNumber\": \"POL-080000\", \"adjusterName\": \"Marcus Powell\", \"adjusterPhone\": \"(972) 700-3000\" },\n \"assignment\": { \"assigneeId\": \"{{principal}}\", \"priority\": \"High\", \"followUp\": \"2026-06-04\" }\n }\n}" },
|
|
||||||
"url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] },
|
|
||||||
"description": "Quick + Full form intake. Only a non-empty name (firstName OR lastName) is required (else 422 name_required). Title-case priority is normalized to lowercase; the first phone is marked primary when none is flagged. DUPLICATE GUARD: if an active (non-closed) lead in the tenant has BOTH the same normalized primary phone AND the same address, the create is REJECTED with 409 duplicate_lead (the response's duplicateOf lists the existing lead) and no lead is inserted."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "21. lead.update (partial patch)",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{ "key": "Authorization", "value": "Bearer dev" },
|
|
||||||
{ "key": "X-App-ID", "value": "crm-web" },
|
|
||||||
{ "key": "X-Tenant-ID", "value": "{{tenant}}" },
|
|
||||||
{ "key": "X-Principal-ID", "value": "{{principal}}" },
|
|
||||||
{ "key": "X-Idempotency-Key", "value": "{{$guid}}" },
|
|
||||||
{ "key": "Content-Type", "value": "application/json" }
|
|
||||||
],
|
|
||||||
"body": { "mode": "raw", "raw": "{\n \"action\": \"crm.lead.update\",\n \"variables\": { \"id\": \"{{createdLeadId}}\", \"patch\": { \"priority\": \"Medium\", \"jobNotes\": \"Adjuster meeting scheduled.\" } }\n}" },
|
|
||||||
"url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "22. lead.updateStatus (audited)",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{ "key": "Authorization", "value": "Bearer dev" },
|
|
||||||
{ "key": "X-App-ID", "value": "crm-web" },
|
|
||||||
{ "key": "X-Tenant-ID", "value": "{{tenant}}" },
|
|
||||||
{ "key": "X-Principal-ID", "value": "{{principal}}" },
|
|
||||||
{ "key": "X-Idempotency-Key", "value": "{{$guid}}" },
|
|
||||||
{ "key": "Content-Type", "value": "application/json" }
|
|
||||||
],
|
|
||||||
"body": { "mode": "raw", "raw": "{\n \"action\": \"crm.lead.updateStatus\",\n \"variables\": { \"id\": \"{{createdLeadId}}\", \"status\": \"contacted\", \"note\": \"Spoke with homeowner.\" }\n}" },
|
|
||||||
"url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] },
|
|
||||||
"description": "Appends a row to lead_status_history."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "23. lead.assign (rep; null for Unassigned)",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{ "key": "Authorization", "value": "Bearer dev" },
|
|
||||||
{ "key": "X-App-ID", "value": "crm-web" },
|
|
||||||
{ "key": "X-Tenant-ID", "value": "{{tenant}}" },
|
|
||||||
{ "key": "X-Principal-ID", "value": "{{principal}}" },
|
|
||||||
{ "key": "X-Idempotency-Key", "value": "{{$guid}}" },
|
|
||||||
{ "key": "Content-Type", "value": "application/json" }
|
|
||||||
],
|
|
||||||
"body": { "mode": "raw", "raw": "{\n \"action\": \"crm.lead.assign\",\n \"variables\": { \"id\": \"{{createdLeadId}}\", \"assigneeId\": \"{{principal}}\" }\n}" },
|
|
||||||
"url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "24. lead.attachment.add (site photo — captures attachmentId)",
|
|
||||||
"event": [
|
|
||||||
{
|
|
||||||
"listen": "test",
|
|
||||||
"script": {
|
|
||||||
"type": "text/javascript",
|
|
||||||
"exec": [
|
|
||||||
"const j = pm.response.json();",
|
|
||||||
"if (j.attachments && j.attachments.length) pm.collectionVariables.set('attachmentId', j.attachments[j.attachments.length - 1].id);"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{ "key": "Authorization", "value": "Bearer dev" },
|
|
||||||
{ "key": "X-App-ID", "value": "crm-web" },
|
|
||||||
{ "key": "X-Tenant-ID", "value": "{{tenant}}" },
|
|
||||||
{ "key": "X-Principal-ID", "value": "{{principal}}" },
|
|
||||||
{ "key": "X-Idempotency-Key", "value": "{{$guid}}" },
|
|
||||||
{ "key": "Content-Type", "value": "application/json" }
|
|
||||||
],
|
|
||||||
"body": { "mode": "raw", "raw": "{\n \"action\": \"crm.lead.attachment.add\",\n \"variables\": { \"id\": \"{{createdLeadId}}\", \"attachment\": { \"contentRef\": \"obj/site-photo-1.jpg\", \"mimeType\": \"image/jpeg\", \"sizeBytes\": 1048576, \"filename\": \"roof-south.jpg\" } }\n}" },
|
|
||||||
"url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] },
|
|
||||||
"description": "contentRef is the object key returned by crm.media.presignUpload after the browser PUTs the bytes (§11.5). Files over 25 MB → 413 file_too_large."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "25. lead.attachment.remove",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{ "key": "Authorization", "value": "Bearer dev" },
|
|
||||||
{ "key": "X-App-ID", "value": "crm-web" },
|
|
||||||
{ "key": "X-Tenant-ID", "value": "{{tenant}}" },
|
|
||||||
{ "key": "X-Principal-ID", "value": "{{principal}}" },
|
|
||||||
{ "key": "X-Idempotency-Key", "value": "{{$guid}}" },
|
|
||||||
{ "key": "Content-Type", "value": "application/json" }
|
|
||||||
],
|
|
||||||
"body": { "mode": "raw", "raw": "{\n \"action\": \"crm.lead.attachment.remove\",\n \"variables\": { \"id\": \"{{createdLeadId}}\", \"attachmentId\": \"{{attachmentId}}\" }\n}" },
|
|
||||||
"url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] },
|
|
||||||
"description": "Run 24 first so {{attachmentId}} is captured."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Negative / guard checks",
|
|
||||||
"item": [
|
|
||||||
{
|
|
||||||
"name": "Verify an already-verified record (expect 409 already_verified)",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{ "key": "Authorization", "value": "Bearer dev" },
|
|
||||||
{ "key": "X-App-ID", "value": "crm-web" },
|
|
||||||
{ "key": "X-Tenant-ID", "value": "{{tenant}}" },
|
|
||||||
{ "key": "X-Principal-ID", "value": "{{principal}}" },
|
|
||||||
{ "key": "X-Idempotency-Key", "value": "{{$guid}}" },
|
|
||||||
{ "key": "Content-Type", "value": "application/json" }
|
|
||||||
],
|
|
||||||
"body": { "mode": "raw", "raw": "{\n \"action\": \"crm.leadVerification.verify\",\n \"variables\": { \"id\": \"{{seedVerificationId}}\" }\n}" },
|
|
||||||
"url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] },
|
|
||||||
"description": "Run 15. verify first — a second verify on the same record is rejected (no duplicate Lead)."
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Create with no name (expect 422 name_required)",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{ "key": "Authorization", "value": "Bearer dev" },
|
|
||||||
{ "key": "X-App-ID", "value": "crm-web" },
|
|
||||||
{ "key": "X-Tenant-ID", "value": "{{tenant}}" },
|
|
||||||
{ "key": "X-Principal-ID", "value": "{{principal}}" },
|
|
||||||
{ "key": "X-Idempotency-Key", "value": "{{$guid}}" },
|
|
||||||
{ "key": "Content-Type", "value": "application/json" }
|
|
||||||
],
|
|
||||||
"body": { "mode": "raw", "raw": "{\n \"action\": \"crm.lead.create\",\n \"variables\": { \"firstName\": \"\", \"lastName\": \" \" }\n}" },
|
|
||||||
"url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Create with a bad email (expect 400 validation)",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{ "key": "Authorization", "value": "Bearer dev" },
|
|
||||||
{ "key": "X-App-ID", "value": "crm-web" },
|
|
||||||
{ "key": "X-Tenant-ID", "value": "{{tenant}}" },
|
|
||||||
{ "key": "X-Principal-ID", "value": "{{principal}}" },
|
|
||||||
{ "key": "X-Idempotency-Key", "value": "{{$guid}}" },
|
|
||||||
{ "key": "Content-Type", "value": "application/json" }
|
|
||||||
],
|
|
||||||
"body": { "mode": "raw", "raw": "{\n \"action\": \"crm.lead.create\",\n \"variables\": { \"firstName\": \"Jane\", \"emails\": [ { \"address\": \"not-an-email\" } ] }\n}" },
|
|
||||||
"url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Verify via generic updateStatus (expect 422 — use verify)",
|
|
||||||
"request": {
|
|
||||||
"method": "POST",
|
|
||||||
"header": [
|
|
||||||
{ "key": "Authorization", "value": "Bearer dev" },
|
|
||||||
{ "key": "X-App-ID", "value": "crm-web" },
|
|
||||||
{ "key": "X-Tenant-ID", "value": "{{tenant}}" },
|
|
||||||
{ "key": "X-Principal-ID", "value": "{{principal}}" },
|
|
||||||
{ "key": "X-Idempotency-Key", "value": "{{$guid}}" },
|
|
||||||
{ "key": "Content-Type", "value": "application/json" }
|
|
||||||
],
|
|
||||||
"body": { "mode": "raw", "raw": "{\n \"action\": \"crm.leadVerification.updateStatus\",\n \"variables\": { \"id\": \"{{verificationId}}\", \"status\": \"verified\" }\n}" },
|
|
||||||
"url": { "raw": "{{baseUrl}}/command", "host": ["{{baseUrl}}"], "path": ["command"] },
|
|
||||||
"description": "The generic status setter refuses 'verified' — verification must go through crm.leadVerification.verify (which promotes a Lead)."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
+248
-30
@@ -1278,23 +1278,8 @@
|
|||||||
.dash-root .nl-photos-s { font-size: 11px; }
|
.dash-root .nl-photos-s { font-size: 11px; }
|
||||||
.dash-root .nl-photo-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
.dash-root .nl-photo-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||||
.dash-root .nl-photo-chip { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 600; padding: 5px 10px; border-radius: 99px; background: color-mix(in srgb, var(--green) 15%, transparent); color: var(--green); }
|
.dash-root .nl-photo-chip { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 600; padding: 5px 10px; border-radius: 99px; background: color-mix(in srgb, var(--green) 15%, transparent); color: var(--green); }
|
||||||
.dash-root .nl-photo-chip-x { display: inline-flex; align-items: center; justify-content: center; margin-left: 2px; padding: 0; border: none; background: none; color: inherit; opacity: 0.65; cursor: pointer; }
|
|
||||||
.dash-root .nl-photo-chip-x:hover { opacity: 1; }
|
|
||||||
.dash-root .ds-select.is-placeholder { color: var(--faint, var(--muted)); }
|
.dash-root .ds-select.is-placeholder { color: var(--faint, var(--muted)); }
|
||||||
|
|
||||||
/* Detail popup — property photo gallery */
|
|
||||||
.dash-root .ld-photo-block { margin-bottom: 14px; }
|
|
||||||
.dash-root .ld-photo-block .ld-section-head { margin-bottom: 8px; }
|
|
||||||
.dash-root .ld-photos { display: grid; grid-template-columns: repeat(auto-fill, minmax(112px, 1fr)); gap: 8px; }
|
|
||||||
.dash-root .ld-photo { position: relative; display: block; aspect-ratio: 1; border-radius: 10px; overflow: hidden; border: 1px solid var(--border-2); background: var(--panel-2); cursor: pointer; }
|
|
||||||
.dash-root .ld-photo img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
||||||
.dash-root .ld-photo-ph { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: var(--muted); }
|
|
||||||
/* Edit mode — photo tile with a remove badge */
|
|
||||||
.dash-root .nl-edit-photos { margin-bottom: 8px; }
|
|
||||||
.dash-root .ld-photo-edit { position: relative; }
|
|
||||||
.dash-root .ld-photo-x { position: absolute; top: 4px; right: 4px; display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; border: none; border-radius: 99px; background: rgba(0,0,0,0.6); color: #fff; cursor: pointer; }
|
|
||||||
.dash-root .ld-photo-x:hover { background: var(--red, #e5484d); }
|
|
||||||
|
|
||||||
/* Canvasser search (Door Knock source) */
|
/* Canvasser search (Door Knock source) */
|
||||||
.dash-root .nl-canvasser { position: relative; }
|
.dash-root .nl-canvasser { position: relative; }
|
||||||
.dash-root .nl-canvasser-menu { position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 30; max-height: 220px; overflow-y: auto; border: 1px solid var(--border-2); border-radius: 12px; background: var(--panel); box-shadow: 0 12px 32px rgba(0,0,0,0.28); padding: 5px; }
|
.dash-root .nl-canvasser-menu { position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 30; max-height: 220px; overflow-y: auto; border: 1px solid var(--border-2); border-radius: 12px; background: var(--panel); box-shadow: 0 12px 32px rgba(0,0,0,0.28); padding: 5px; }
|
||||||
@@ -1373,21 +1358,6 @@
|
|||||||
.lv-menu-item svg { color: var(--muted, #8c8c8c); flex: 0 0 auto; }
|
.lv-menu-item svg { color: var(--muted, #8c8c8c); flex: 0 0 auto; }
|
||||||
.lv-menu-item:hover svg { color: var(--orange, #fda913); }
|
.lv-menu-item:hover svg { color: var(--orange, #fda913); }
|
||||||
|
|
||||||
/* ---- change-assignee popup ---- */
|
|
||||||
.dash-root .lv-assign { display: flex; flex-direction: column; gap: 12px; }
|
|
||||||
.dash-root .lv-assign-search { display: flex; align-items: center; gap: 8px; padding: 9px 11px; border-radius: 11px; border: 1px solid var(--border); background: var(--panel-2); }
|
|
||||||
.dash-root .lv-assign-search svg { color: var(--muted); flex: 0 0 auto; }
|
|
||||||
.dash-root .lv-assign-search input { flex: 1; min-width: 0; border: 0; background: none; color: var(--text); font-family: inherit; font-size: 13px; outline: none; }
|
|
||||||
.dash-root .lv-assign-x { display: inline-flex; border: 0; background: none; color: var(--muted); cursor: pointer; padding: 0; }
|
|
||||||
.dash-root .lv-assign-x:hover { color: var(--text); }
|
|
||||||
.dash-root .lv-assign-list { display: flex; flex-direction: column; gap: 2px; max-height: 320px; overflow-y: auto; margin: -4px; padding: 4px; }
|
|
||||||
.dash-root .lv-assign-opt { display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 10px; border: 0; border-radius: 10px; background: none; color: var(--text-2); font-family: inherit; font-size: 13px; font-weight: 600; cursor: pointer; text-align: left; }
|
|
||||||
.dash-root .lv-assign-opt:hover { background: var(--panel-3, #1b1b22); color: var(--text); }
|
|
||||||
.dash-root .lv-assign-opt.current { background: color-mix(in srgb, var(--orange) 12%, transparent); }
|
|
||||||
.dash-root .lv-assign-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.dash-root .lv-assign-opt.current > svg { color: var(--orange); flex: 0 0 auto; }
|
|
||||||
.dash-root .lv-assign-empty { padding: 20px 10px; text-align: center; color: var(--muted); font-size: 12.5px; }
|
|
||||||
|
|
||||||
/* ---- verification detail popup ---- */
|
/* ---- verification detail popup ---- */
|
||||||
.dash-root .lv-detail { display: flex; flex-direction: column; gap: 16px; }
|
.dash-root .lv-detail { display: flex; flex-direction: column; gap: 16px; }
|
||||||
.dash-root .lv-d-identity { display: flex; align-items: center; gap: 14px; }
|
.dash-root .lv-d-identity { display: flex; align-items: center; gap: 14px; }
|
||||||
@@ -1548,3 +1518,251 @@
|
|||||||
|
|
||||||
.dash-root .ds-toast.is-clickable .ds-toast-body { cursor: pointer; }
|
.dash-root .ds-toast.is-clickable .ds-toast-body { cursor: pointer; }
|
||||||
.dash-root .ds-toast.is-clickable .ds-toast-body:hover .ds-toast-title { color: var(--orange); }
|
.dash-root .ds-toast.is-clickable .ds-toast-body:hover .ds-toast-title { color: var(--orange); }
|
||||||
|
|
||||||
|
/* =======================================================================
|
||||||
|
Pipeline — kanban board
|
||||||
|
======================================================================= */
|
||||||
|
.dash-root .pl { display: flex; flex-direction: column; height: 100%; min-height: 0; }
|
||||||
|
|
||||||
|
/* tone accents (per column / drawer via data-tone) */
|
||||||
|
.dash-root [data-tone="blue"] { --pl-accent: #4f8cff; }
|
||||||
|
.dash-root [data-tone="purple"] { --pl-accent: #b07bf2; }
|
||||||
|
.dash-root [data-tone="magenta"] { --pl-accent: #db5fd0; }
|
||||||
|
.dash-root [data-tone="orange"] { --pl-accent: var(--orange); }
|
||||||
|
.dash-root [data-tone="green"] { --pl-accent: var(--green); }
|
||||||
|
.dash-root [data-tone="cyan"] { --pl-accent: #34c9d6; }
|
||||||
|
.dash-root [data-tone="red"] { --pl-accent: var(--red); }
|
||||||
|
|
||||||
|
/* ---- head ---- */
|
||||||
|
.dash-root .pl-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||||
|
.dash-root .pl-head-l { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.dash-root .pl-head-ic { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 12px; background: color-mix(in srgb, var(--orange) 14%, transparent); color: var(--orange); flex: 0 0 auto; }
|
||||||
|
.dash-root .pl-head-l h1 { font-size: 20px; font-weight: 700; letter-spacing: -0.01em; }
|
||||||
|
.dash-root .pl-head-l p { margin: 2px 0 0; font-size: 12.5px; color: var(--muted); }
|
||||||
|
.dash-root .pl-search { display: flex; align-items: center; gap: 8px; height: 40px; width: 300px; max-width: 46vw; padding: 0 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--panel-2); color: var(--muted); }
|
||||||
|
.dash-root .pl-search:focus-within { border-color: var(--orange); }
|
||||||
|
.dash-root .pl-search input { flex: 1 1 auto; border: 0; background: none; outline: none; color: var(--text); font-size: 13.5px; font-family: inherit; }
|
||||||
|
.dash-root .pl-search input::placeholder { color: var(--muted); }
|
||||||
|
.dash-root .pl-search-x { display: grid; place-items: center; border: 0; background: none; color: var(--muted); cursor: pointer; padding: 2px; }
|
||||||
|
.dash-root .pl-search-x:hover { color: var(--text); }
|
||||||
|
|
||||||
|
/* ---- board ---- */
|
||||||
|
.dash-root .pl-board { display: flex; gap: 14px; align-items: flex-start; overflow-x: auto; overflow-y: hidden; flex: 1 1 auto; min-height: 0; padding-bottom: 12px; scrollbar-width: thin; }
|
||||||
|
.dash-root .pl-board::-webkit-scrollbar { height: 9px; }
|
||||||
|
.dash-root .pl-board::-webkit-scrollbar-thumb { background: var(--border-2); border-radius: 99px; }
|
||||||
|
|
||||||
|
.dash-root .pl-col { flex: 0 0 288px; width: 288px; display: flex; flex-direction: column; min-height: 0; max-height: 100%; background: var(--panel-2); border: 1px solid var(--border); border-radius: 14px; padding: 12px 10px 4px; transition: border-color .14s, background .14s; }
|
||||||
|
.dash-root .pl-col.is-over { border-color: var(--pl-accent); background: color-mix(in srgb, var(--pl-accent) 7%, var(--panel-2)); }
|
||||||
|
.dash-root .pl-col.is-flag { background: color-mix(in srgb, var(--pl-accent) 5%, var(--panel-2)); }
|
||||||
|
|
||||||
|
.dash-root .pl-col-head { display: flex; align-items: center; gap: 8px; padding: 2px 4px 8px; }
|
||||||
|
.dash-root .pl-col-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--pl-accent); flex: 0 0 auto; box-shadow: 0 0 8px color-mix(in srgb, var(--pl-accent) 60%, transparent); }
|
||||||
|
.dash-root .pl-col-label { font-size: 11.5px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-2); }
|
||||||
|
.dash-root .pl-col-count { display: grid; place-items: center; min-width: 20px; height: 18px; padding: 0 6px; border-radius: 99px; background: color-mix(in srgb, var(--pl-accent) 20%, transparent); color: var(--pl-accent); font-size: 11px; font-weight: 700; }
|
||||||
|
.dash-root .pl-col-step { margin-left: auto; font-size: 10.5px; font-weight: 600; color: var(--faint); font-variant-numeric: tabular-nums; }
|
||||||
|
.dash-root .pl-col-bar { height: 3px; border-radius: 99px; background: var(--track); overflow: hidden; margin: 0 4px 10px; }
|
||||||
|
.dash-root .pl-col-bar > span { display: block; height: 100%; background: var(--pl-accent); border-radius: inherit; }
|
||||||
|
|
||||||
|
.dash-root .pl-col-body { display: flex; flex-direction: column; gap: 10px; overflow-y: auto; padding: 2px 4px 10px; min-height: 60px; scrollbar-width: thin; }
|
||||||
|
.dash-root .pl-col-body::-webkit-scrollbar { width: 6px; }
|
||||||
|
.dash-root .pl-col-body::-webkit-scrollbar-thumb { background: var(--border-2); border-radius: 99px; }
|
||||||
|
.dash-root .pl-col-empty { border: 1px dashed var(--border-2); border-radius: 10px; padding: 18px 10px; text-align: center; font-size: 12px; color: var(--faint); }
|
||||||
|
|
||||||
|
/* ---- card ---- */
|
||||||
|
.dash-root .pl-card { text-align: left; width: 100%; background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 11px 12px; cursor: pointer; transition: border-color .14s, transform .1s, box-shadow .14s; }
|
||||||
|
.dash-root .pl-card:hover { border-color: var(--border-2); box-shadow: var(--shadow); transform: translateY(-1px); }
|
||||||
|
.dash-root .pl-card.is-dragging { opacity: 0.5; }
|
||||||
|
.dash-root .pl-col.is-flag .pl-card { border-left: 3px solid var(--pl-accent); }
|
||||||
|
.dash-root .pl-card-top { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.dash-root .pl-card-id { min-width: 0; flex: 1; }
|
||||||
|
.dash-root .pl-card-name { font-size: 13.5px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dash-root .pl-card-addr { display: flex; align-items: center; gap: 4px; font-size: 11.5px; color: var(--muted); margin-top: 2px; }
|
||||||
|
.dash-root .pl-card-addr svg { flex: 0 0 auto; color: var(--faint); }
|
||||||
|
.dash-root .pl-card-tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0; }
|
||||||
|
.dash-root .pl-tag { font-size: 10.5px; font-weight: 600; padding: 3px 8px; border-radius: 7px; }
|
||||||
|
.dash-root .pl-tag.work { background: color-mix(in srgb, var(--orange) 15%, transparent); color: var(--orange); }
|
||||||
|
.dash-root .pl-tag.type { background: color-mix(in srgb, var(--blue) 18%, transparent); color: #6f9bff; }
|
||||||
|
.dash-root .pl-card-foot { display: flex; align-items: center; gap: 8px; padding-top: 9px; border-top: 1px solid var(--border); font-size: 11px; color: var(--muted); }
|
||||||
|
.dash-root .pl-card-foot svg { flex: 0 0 auto; }
|
||||||
|
.dash-root .pl-rep { display: inline-flex; align-items: center; gap: 4px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dash-root .pl-card-spacer { flex: 1; }
|
||||||
|
.dash-root .pl-storm { display: inline-flex; align-items: center; gap: 3px; color: var(--orange); font-weight: 600; flex: 0 0 auto; }
|
||||||
|
.dash-root .pl-age { display: inline-flex; align-items: center; gap: 3px; color: var(--faint); flex: 0 0 auto; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* ---- drawer ---- */
|
||||||
|
.dash-root .pl-scrim { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 79; animation: ds-fade .2s ease; }
|
||||||
|
.dash-root .pl-drawer { position: fixed; top: 0; right: 0; height: 100vh; width: 400px; max-width: 92vw; z-index: 80; display: flex; flex-direction: column; background: var(--panel); border-left: 1px solid var(--border-2); box-shadow: -24px 0 60px -20px rgba(0,0,0,0.6); animation: pl-slide .22s cubic-bezier(.2,.7,.3,1); }
|
||||||
|
@keyframes pl-slide { from { transform: translateX(24px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||||
|
.dash-root .pl-drawer-head { display: flex; align-items: center; gap: 12px; padding: 18px 18px 14px; border-bottom: 1px solid var(--border); }
|
||||||
|
.dash-root .pl-drawer-id { min-width: 0; flex: 1; }
|
||||||
|
.dash-root .pl-drawer-name { font-size: 16px; font-weight: 700; color: var(--text); }
|
||||||
|
.dash-root .pl-drawer-addr { font-size: 12px; color: var(--muted); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dash-root .pl-drawer-x { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 9px; border: 0; background: none; color: var(--muted); cursor: pointer; flex: 0 0 auto; }
|
||||||
|
.dash-root .pl-drawer-x:hover { background: var(--panel-2); color: var(--text); }
|
||||||
|
|
||||||
|
.dash-root .pl-drawer-status { padding: 14px 18px; border-bottom: 1px solid var(--border); }
|
||||||
|
.dash-root .pl-status-row { display: flex; align-items: baseline; justify-content: space-between; }
|
||||||
|
.dash-root .pl-status-label { font-size: 13px; font-weight: 600; color: var(--text); }
|
||||||
|
.dash-root .pl-status-pct { font-size: 13px; font-weight: 700; color: var(--pl-accent); font-variant-numeric: tabular-nums; }
|
||||||
|
.dash-root .pl-status-bar { height: 5px; border-radius: 99px; background: var(--track); overflow: hidden; margin: 8px 0 12px; }
|
||||||
|
.dash-root .pl-status-bar > span { display: block; height: 100%; background: linear-gradient(90deg, color-mix(in srgb, var(--pl-accent) 55%, transparent), var(--pl-accent)); border-radius: inherit; transition: width .3s ease; }
|
||||||
|
.dash-root .pl-steps { display: flex; flex-wrap: wrap; gap: 4px 12px; }
|
||||||
|
.dash-root .pl-step { display: inline-flex; align-items: center; gap: 5px; font-size: 10.5px; color: var(--faint); }
|
||||||
|
.dash-root .pl-step-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--border-2); }
|
||||||
|
.dash-root .pl-step.done { color: var(--text-2); }
|
||||||
|
.dash-root .pl-step.done .pl-step-dot { background: var(--pl-accent); }
|
||||||
|
|
||||||
|
.dash-root .pl-drawer-tabs { display: flex; gap: 22px; padding: 0 18px; border-bottom: 1px solid var(--border); }
|
||||||
|
.dash-root .pl-drawer-tabs button { position: relative; border: 0; background: none; padding: 12px 0; font-family: inherit; font-size: 13px; font-weight: 600; color: var(--muted); cursor: pointer; }
|
||||||
|
.dash-root .pl-drawer-tabs button.active { color: var(--text); }
|
||||||
|
.dash-root .pl-drawer-tabs button.active::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; border-radius: 2px; background: var(--orange); }
|
||||||
|
|
||||||
|
.dash-root .pl-drawer-body { flex: 1 1 auto; overflow-y: auto; padding: 16px 18px; }
|
||||||
|
.dash-root .pl-dsec { margin-bottom: 18px; }
|
||||||
|
.dash-root .pl-dsec-head { font-size: 10.5px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--faint); margin-bottom: 9px; }
|
||||||
|
.dash-root .pl-contact { display: flex; align-items: center; gap: 9px; font-size: 13px; color: var(--text-2); padding: 5px 0; }
|
||||||
|
.dash-root .pl-contact svg { color: var(--muted); flex: 0 0 auto; }
|
||||||
|
.dash-root .pl-jobgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||||
|
.dash-root .pl-jobbox { display: flex; flex-direction: column; gap: 3px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--panel-2); }
|
||||||
|
.dash-root .pl-jobk { font-size: 11px; color: var(--muted); }
|
||||||
|
.dash-root .pl-jobv { font-size: 13px; font-weight: 600; color: var(--text); }
|
||||||
|
.dash-root .pl-assign { display: inline-flex; align-items: center; gap: 9px; font-size: 13px; font-weight: 600; color: var(--text); padding: 9px 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--panel-2); }
|
||||||
|
.dash-root .pl-assign svg { color: var(--muted); }
|
||||||
|
.dash-root .pl-notes { font-size: 13px; line-height: 1.5; color: var(--text-2); padding: 11px 13px; border: 1px solid var(--border); border-radius: 10px; background: var(--panel-2); }
|
||||||
|
|
||||||
|
.dash-root .pl-activity { display: flex; flex-direction: column; }
|
||||||
|
.dash-root .pl-act { display: flex; gap: 12px; position: relative; padding-bottom: 18px; }
|
||||||
|
.dash-root .pl-act::before { content: ""; position: absolute; left: 4px; top: 14px; bottom: -4px; width: 2px; background: var(--border); }
|
||||||
|
.dash-root .pl-act:last-child { padding-bottom: 0; }
|
||||||
|
.dash-root .pl-act:last-child::before { display: none; }
|
||||||
|
.dash-root .pl-act-node { position: relative; z-index: 1; width: 10px; height: 10px; border-radius: 50%; background: var(--orange); margin-top: 4px; flex: 0 0 auto; box-shadow: 0 0 0 3px var(--panel); }
|
||||||
|
.dash-root .pl-act-body { flex: 1; min-width: 0; }
|
||||||
|
.dash-root .pl-act-head { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; color: var(--text); }
|
||||||
|
.dash-root .pl-act-head svg { color: var(--muted); }
|
||||||
|
.dash-root .pl-act-from { color: var(--muted); font-weight: 500; }
|
||||||
|
.dash-root .pl-act-meta { font-size: 11.5px; color: var(--muted); margin-top: 2px; }
|
||||||
|
.dash-root .pl-act-note { font-size: 12.5px; color: var(--text-2); margin-top: 7px; padding: 8px 11px; border-radius: 9px; background: var(--panel-2); border: 1px solid var(--border); }
|
||||||
|
|
||||||
|
.dash-root .pl-drawer-foot { padding: 14px 18px; border-top: 1px solid var(--border); }
|
||||||
|
.dash-root .pl-more { display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; height: 44px; border: 0; border-radius: 12px; background: var(--blue); color: #fff; font-family: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer; transition: filter .14s; }
|
||||||
|
.dash-root .pl-more:hover { filter: brightness(1.08); }
|
||||||
|
|
||||||
|
/* =======================================================================
|
||||||
|
Pipeline — full-page lead detail (from the drawer's "More Details")
|
||||||
|
======================================================================= */
|
||||||
|
.dash-root .pld { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
|
||||||
|
/* ---- breadcrumb ---- */
|
||||||
|
.dash-root .pld-crumbs { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--faint); }
|
||||||
|
.dash-root .pld-back { display: inline-flex; align-items: center; gap: 6px; border: 0; background: none; padding: 0; font-family: inherit; font-size: 12.5px; font-weight: 600; color: var(--muted); cursor: pointer; transition: color .14s; }
|
||||||
|
.dash-root .pld-back:hover { color: var(--orange); }
|
||||||
|
.dash-root .pld-back-ic { transform: rotate(180deg); }
|
||||||
|
.dash-root .pld-crumb-now { color: var(--text); font-weight: 600; }
|
||||||
|
.dash-root .pld-crumb-id { margin-left: 2px; padding: 2px 7px; border-radius: 6px; background: var(--panel-2); border: 1px solid var(--border); font-size: 11px; font-weight: 600; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* ---- hero ---- */
|
||||||
|
/* No `overflow: hidden` here — it would clip the Move Stage dropdown. The
|
||||||
|
accent bar rounds its own top corners instead. */
|
||||||
|
.dash-root .pld-hero { display: flex; align-items: flex-start; gap: 16px; padding: 18px 20px; border-radius: 16px; border: 1px solid var(--border); background: var(--panel); position: relative; }
|
||||||
|
.dash-root .pld-hero::before { content: ""; position: absolute; inset: 0 0 auto 0; height: 3px; border-radius: 16px 16px 0 0; background: linear-gradient(90deg, var(--pl-accent), transparent); }
|
||||||
|
.dash-root .pld-hero-id { flex: 1; min-width: 0; }
|
||||||
|
.dash-root .pld-hero-name { font-size: 22px; font-weight: 700; letter-spacing: -0.01em; color: var(--text); }
|
||||||
|
.dash-root .pld-hero-addr { display: flex; align-items: center; gap: 6px; margin-top: 4px; font-size: 13px; color: var(--muted); }
|
||||||
|
.dash-root .pld-hero-addr svg { flex: 0 0 auto; color: var(--faint); }
|
||||||
|
.dash-root .pld-hero-pills { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-top: 12px; }
|
||||||
|
.dash-root .pld-hero-actions { display: flex; gap: 8px; flex: 0 0 auto; }
|
||||||
|
|
||||||
|
/* ---- move stage dropdown ---- */
|
||||||
|
.dash-root .pld-move { position: relative; }
|
||||||
|
.dash-root .pld-move-scrim { position: fixed; inset: 0; z-index: 40; }
|
||||||
|
.dash-root .pld-move-menu { position: absolute; top: calc(100% + 8px); right: 0; z-index: 41; width: 232px; max-height: min(60vh, 420px); overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; padding: 6px; border-radius: 14px; border: 1px solid var(--border-2); background: var(--panel); box-shadow: var(--shadow-lg, 0 18px 44px -12px rgba(0,0,0,0.55)); animation: ds-fade .14s ease; }
|
||||||
|
.dash-root .pld-move-menu::-webkit-scrollbar { width: 6px; }
|
||||||
|
.dash-root .pld-move-menu::-webkit-scrollbar-thumb { background: var(--border-2); border-radius: 99px; }
|
||||||
|
.dash-root .pld-move-group + .pld-move-group { margin-top: 4px; padding-top: 4px; border-top: 1px solid var(--border); }
|
||||||
|
.dash-root .pld-move-grouphead { padding: 7px 10px 5px; font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--faint); }
|
||||||
|
.dash-root .pld-move-item { display: flex; align-items: center; gap: 9px; width: 100%; padding: 8px 10px; border: 0; border-radius: 9px; background: none; font-family: inherit; font-size: 13px; font-weight: 600; color: var(--text-2); text-align: left; cursor: pointer; transition: background .12s, color .12s; }
|
||||||
|
.dash-root .pld-move-item:hover:not(:disabled) { background: color-mix(in srgb, var(--pl-accent) 12%, transparent); color: var(--text); }
|
||||||
|
.dash-root .pld-move-item:disabled { cursor: default; color: var(--text); }
|
||||||
|
.dash-root .pld-move-item.is-current { background: var(--panel-2); }
|
||||||
|
.dash-root .pld-move-item svg { margin-left: auto; color: var(--pl-accent); flex: 0 0 auto; }
|
||||||
|
.dash-root .pld-move-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--pl-accent); flex: 0 0 auto; box-shadow: 0 0 8px color-mix(in srgb, var(--pl-accent) 55%, transparent); }
|
||||||
|
.dash-root .pld-move-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ---- stage progress ---- */
|
||||||
|
.dash-root .pld-progress { padding: 16px 20px; border-radius: 16px; border: 1px solid var(--border); background: var(--panel); }
|
||||||
|
.dash-root .pld-progress-row { display: flex; align-items: baseline; justify-content: space-between; }
|
||||||
|
.dash-root .pld-progress-label { font-size: 14px; font-weight: 700; color: var(--text); }
|
||||||
|
.dash-root .pld-progress-pct { font-size: 13px; font-weight: 700; color: var(--pl-accent); font-variant-numeric: tabular-nums; }
|
||||||
|
.dash-root .pld-progress-bar { height: 6px; border-radius: 99px; background: var(--track); overflow: hidden; margin: 10px 0 16px; }
|
||||||
|
.dash-root .pld-progress-bar > span { display: block; height: 100%; background: linear-gradient(90deg, color-mix(in srgb, var(--pl-accent) 55%, transparent), var(--pl-accent)); border-radius: inherit; transition: width .3s ease; }
|
||||||
|
|
||||||
|
.dash-root .pld-steps { display: flex; flex-wrap: wrap; gap: 10px 6px; list-style: none; margin: 0; padding: 0; }
|
||||||
|
.dash-root .pld-step { display: flex; align-items: center; gap: 7px; flex: 1 1 120px; min-width: 0; position: relative; }
|
||||||
|
.dash-root .pld-step::after { content: ""; position: absolute; left: 22px; right: 4px; top: 11px; height: 2px; background: var(--border); z-index: 0; }
|
||||||
|
.dash-root .pld-step:last-child::after { display: none; }
|
||||||
|
.dash-root .pld-step.done::after { background: var(--pl-accent); }
|
||||||
|
.dash-root .pld-step.now::after, .dash-root .pld-step.now ~ .pld-step::after { background: var(--border); }
|
||||||
|
.dash-root .pld-step-dot { position: relative; z-index: 1; display: grid; place-items: center; width: 22px; height: 22px; border-radius: 50%; flex: 0 0 auto; background: var(--panel-2); border: 1px solid var(--border-2); color: var(--faint); font-size: 10.5px; font-weight: 700; }
|
||||||
|
.dash-root .pld-step.done .pld-step-dot { background: var(--pl-accent); border-color: var(--pl-accent); color: #fff; }
|
||||||
|
.dash-root .pld-step.now .pld-step-dot { box-shadow: 0 0 0 4px color-mix(in srgb, var(--pl-accent) 20%, transparent); }
|
||||||
|
.dash-root .pld-step-label { font-size: 11.5px; font-weight: 600; color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; background: var(--panel); padding-right: 6px; position: relative; z-index: 1; }
|
||||||
|
.dash-root .pld-step.done .pld-step-label { color: var(--text-2); }
|
||||||
|
.dash-root .pld-step.now .pld-step-label { color: var(--text); }
|
||||||
|
|
||||||
|
/* ---- overview grid ---- */
|
||||||
|
.dash-root .pld-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||||
|
.dash-root .pld-sec { border: 1px solid var(--border); border-radius: 14px; background: var(--panel); overflow: hidden; }
|
||||||
|
.dash-root .pld-sec.wide { grid-column: 1 / -1; }
|
||||||
|
.dash-root .pld-sec-head { display: flex; align-items: center; gap: 8px; padding: 12px 16px; border-bottom: 1px solid var(--border); background: var(--panel-2); font-size: 11px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--text-2); }
|
||||||
|
.dash-root .pld-sec-head svg { color: var(--pl-accent); }
|
||||||
|
.dash-root .pld-sec-body { padding: 8px 16px 14px; }
|
||||||
|
|
||||||
|
.dash-root .pld-kv { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; padding: 9px 0; border-bottom: 1px dashed var(--border); }
|
||||||
|
.dash-root .pld-kv:last-child { border-bottom: 0; }
|
||||||
|
.dash-root .pld-kv-k { font-size: 12px; color: var(--muted); flex: 0 0 auto; }
|
||||||
|
.dash-root .pld-kv-v { font-size: 13px; font-weight: 600; color: var(--text); text-align: right; min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
|
||||||
|
.dash-root .pld-sublabel { font-size: 10.5px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--faint); margin: 12px 0 6px; }
|
||||||
|
.dash-root .pld-sec-body > .pld-sublabel:first-child { margin-top: 4px; }
|
||||||
|
.dash-root .pld-contact { display: flex; align-items: center; gap: 9px; padding: 7px 0; font-size: 13px; }
|
||||||
|
.dash-root .pld-contact svg { color: var(--muted); flex: 0 0 auto; }
|
||||||
|
.dash-root .pld-contact-val { font-weight: 600; color: var(--text); min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.dash-root .pld-contact-tag { font-size: 11px; color: var(--muted); padding: 2px 7px; border-radius: 6px; background: var(--panel-2); border: 1px solid var(--border); }
|
||||||
|
.dash-root .pld-notes { font-size: 13px; line-height: 1.55; color: var(--text-2); margin: 0; padding: 11px 13px; border: 1px solid var(--border); border-radius: 10px; background: var(--panel-2); }
|
||||||
|
|
||||||
|
.dash-root .pld-assign { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0 24px; }
|
||||||
|
.dash-root .pld-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 26px 12px; color: var(--faint); text-align: center; }
|
||||||
|
.dash-root .pld-empty p { margin: 0; font-size: 12.5px; }
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.dash-root .pld-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.dash-root .pl-search { width: 100%; max-width: none; }
|
||||||
|
.dash-root .pl-col { flex-basis: 82vw; width: 82vw; }
|
||||||
|
.dash-root .pld-hero { flex-wrap: wrap; }
|
||||||
|
.dash-root .pld-hero-actions { width: 100%; }
|
||||||
|
.dash-root .pld-hero-actions > * { flex: 1; }
|
||||||
|
.dash-root .pld-move > .ds-btn { width: 100%; }
|
||||||
|
.dash-root .pld-step { flex: 1 1 100%; }
|
||||||
|
.dash-root .pld-step::after { display: none; }
|
||||||
|
|
||||||
|
/* Phones: a bottom sheet can't be clipped or pushed off-screen the way an
|
||||||
|
anchored dropdown can, and every stage stays thumb-reachable. */
|
||||||
|
.dash-root .pld-move-scrim { background: rgba(0,0,0,0.5); z-index: 90; animation: ds-fade .16s ease; }
|
||||||
|
.dash-root .pld-move-menu {
|
||||||
|
position: fixed; z-index: 91;
|
||||||
|
top: auto; right: 12px; left: 12px; bottom: max(12px, env(safe-area-inset-bottom));
|
||||||
|
width: auto; max-height: 72vh; padding: 8px;
|
||||||
|
border-radius: 18px; box-shadow: 0 -10px 44px -12px rgba(0,0,0,0.6);
|
||||||
|
animation: pld-sheet .2s cubic-bezier(.2,.7,.3,1);
|
||||||
|
}
|
||||||
|
.dash-root .pld-move-item { padding: 11px 12px; font-size: 14px; }
|
||||||
|
}
|
||||||
|
@keyframes pld-sheet { from { transform: translateY(14px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||||||
|
|
||||||
|
/* Short viewports (landscape phones, small laptops): keep the anchored menu
|
||||||
|
inside the screen rather than letting it run past the fold. */
|
||||||
|
@media (min-width: 721px) and (max-height: 620px) {
|
||||||
|
.dash-root .pld-move-menu { max-height: 48vh; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { RealtimeProvider } from "@/lib/realtime";
|
|||||||
import { SmartGallery } from "./smart-gallery";
|
import { SmartGallery } from "./smart-gallery";
|
||||||
import { Leads } from "./leads";
|
import { Leads } from "./leads";
|
||||||
import { Verify } from "./verify";
|
import { Verify } from "./verify";
|
||||||
|
import { Pipeline } from "./pipeline";
|
||||||
import "../../app/dashboard/dashboard.css";
|
import "../../app/dashboard/dashboard.css";
|
||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
@@ -87,6 +88,7 @@ export function Dashboard() {
|
|||||||
: active === "gallery" ? <SmartGallery theme={theme} />
|
: active === "gallery" ? <SmartGallery theme={theme} />
|
||||||
: active === "leads" ? <Leads />
|
: active === "leads" ? <Leads />
|
||||||
: active === "verify" ? <Verify />
|
: active === "verify" ? <Verify />
|
||||||
|
: active === "pipeline" ? <Pipeline />
|
||||||
: active === "team" ? <TeamManagement />
|
: active === "team" ? <TeamManagement />
|
||||||
: <ComingSoon title={title} icon={item?.icon ?? "dashboard"} onGo={setActive} />}
|
: <ComingSoon title={title} icon={item?.icon ?? "dashboard"} onGo={setActive} />}
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
|
|||||||
@@ -12,16 +12,9 @@ export type LeadPriority = "high" | "medium" | "low";
|
|||||||
|
|
||||||
export type Phone = { number: string; type: "Mobile" | "Home" | "Work"; primary?: boolean };
|
export type Phone = { number: string; type: "Mobile" | "Home" | "Work"; primary?: boolean };
|
||||||
export type Email = { address: string; type?: string; primary?: boolean };
|
export type Email = { address: string; type?: string; primary?: boolean };
|
||||||
/** A site photo (or other file) stored via the media presign flow (§11.5).
|
|
||||||
* `contentRef` is the IIOS object key — render it through a signed download URL. */
|
|
||||||
export type Attachment = { id?: string; contentRef: string; mimeType: string; sizeBytes: number; filename: string };
|
|
||||||
|
|
||||||
export type Lead = {
|
export type Lead = {
|
||||||
id: string; // SAL-001 (display code)
|
id: string; // SAL-001
|
||||||
refId?: string; // opaque server id (live mode); commands target this — falls back to `id`
|
|
||||||
/** Set once the lead has been pushed to the verification queue — blocks a duplicate send.
|
|
||||||
* Populated from the backend (e.g. LeadDTO.verificationId) when available. */
|
|
||||||
verificationId?: string;
|
|
||||||
initials: string;
|
initials: string;
|
||||||
name: string;
|
name: string;
|
||||||
gradient: string;
|
gradient: string;
|
||||||
@@ -38,9 +31,6 @@ export type Lead = {
|
|||||||
phones: Phone[];
|
phones: Phone[];
|
||||||
emails: Email[];
|
emails: Email[];
|
||||||
|
|
||||||
// site photos (uploaded via the media presign flow; shown in the detail popup)
|
|
||||||
attachments?: Attachment[];
|
|
||||||
|
|
||||||
// property
|
// property
|
||||||
property: { address: string; city: string; state: string; zip: string; type: string };
|
property: { address: string; city: string; state: string; zip: string; type: string };
|
||||||
|
|
||||||
@@ -71,6 +61,7 @@ const G = {
|
|||||||
cyan: "linear-gradient(135deg,#34c9d6,#1f9aa4)",
|
cyan: "linear-gradient(135deg,#34c9d6,#1f9aa4)",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// leads data — a small sample of the full book (TOTAL_LEADS)
|
||||||
export const LEADS: Lead[] = [
|
export const LEADS: Lead[] = [
|
||||||
{
|
{
|
||||||
id: "SAL-001", initials: "JM", name: "John Martinez", gradient: G.orange,
|
id: "SAL-001", initials: "JM", name: "John Martinez", gradient: G.orange,
|
||||||
|
|||||||
+131
-500
@@ -11,15 +11,13 @@
|
|||||||
// Data comes from leads-data.ts (client-side mock).
|
// Data comes from leads-data.ts (client-side mock).
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
import { useMemo, useState, type ReactNode } from "react";
|
||||||
import { Avatar, Btn, Field, Icon, Modal, PageHead, Pill, Segmented, SegTabs, useToast } from "./ui";
|
import { Avatar, Btn, Field, Icon, Modal, PageHead, Pill, Segmented, SegTabs, useToast } from "./ui";
|
||||||
import {
|
import {
|
||||||
STATUS_META, PRIORITY_META,
|
LEADS, TOTAL_LEADS, STATUS_META, PRIORITY_META,
|
||||||
LEAD_SOURCES, LEAD_TYPES, PROPERTY_TYPES, WORK_TYPES, TRADE_TYPES, CLAIM_STATUSES,
|
REPS, LEAD_SOURCES, WORK_TYPES, TRADE_TYPES, CLAIM_STATUSES,
|
||||||
type Lead, type LeadStatus, type Rep, type Attachment,
|
type Lead, type LeadStatus,
|
||||||
} from "./leads-data";
|
} from "./leads-data";
|
||||||
import { useLeadsData, type CreateLeadInput, type LeadsData } from "@/lib/leads-api";
|
|
||||||
import { MAX_ATTACHMENT_BYTES, isImage, type UploadedAttachment } from "@/lib/media-api";
|
|
||||||
|
|
||||||
const STATUS_TABS: { value: "all" | LeadStatus; label: string }[] = [
|
const STATUS_TABS: { value: "all" | LeadStatus; label: string }[] = [
|
||||||
{ value: "all", label: "All" },
|
{ value: "all", label: "All" },
|
||||||
@@ -30,50 +28,44 @@ const STATUS_TABS: { value: "all" | LeadStatus; label: string }[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function Leads() {
|
export function Leads() {
|
||||||
const data = useLeadsData();
|
const toast = useToast();
|
||||||
const { leads, total, byStatus, loading, error } = data;
|
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [filter, setFilter] = useState<"all" | LeadStatus>("all");
|
const [filter, setFilter] = useState<"all" | LeadStatus>("all");
|
||||||
const [selected, setSelected] = useState<Lead | null>(null);
|
const [selected, setSelected] = useState<Lead | null>(null);
|
||||||
const [newOpen, setNewOpen] = useState(false);
|
const [newOpen, setNewOpen] = useState(false);
|
||||||
// Leads already pushed to the verification queue this session — blocks a second send
|
|
||||||
// (interim guard until the backend exposes a persistent verification status; see notes).
|
const countByStatus = useMemo(() => {
|
||||||
const [sentIds, setSentIds] = useState<Set<string>>(() => new Set());
|
const m: Record<string, number> = {};
|
||||||
const idOf = (l: Lead) => l.refId ?? l.id;
|
for (const l of LEADS) m[l.status] = (m[l.status] ?? 0) + 1;
|
||||||
|
return m;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
return leads.filter((l) => {
|
return LEADS.filter((l) => {
|
||||||
const matchStatus = filter === "all" || l.status === filter;
|
const matchStatus = filter === "all" || l.status === filter;
|
||||||
const hay = `${l.name} ${l.property.address} ${l.property.city} ${l.job.source} ${l.job.canvasser}`.toLowerCase();
|
const hay = `${l.name} ${l.property.address} ${l.property.city} ${l.job.source} ${l.job.canvasser}`.toLowerCase();
|
||||||
return matchStatus && (!q || hay.includes(q));
|
return matchStatus && (!q || hay.includes(q));
|
||||||
});
|
});
|
||||||
}, [leads, query, filter]);
|
}, [query, filter]);
|
||||||
|
|
||||||
// Open a card immediately with its list-level data, then hydrate the full detail (live mode
|
|
||||||
// list rows carry card fields only). Ignore hydration errors — the card data still renders.
|
|
||||||
const openLead = (l: Lead) => {
|
|
||||||
setSelected(l);
|
|
||||||
data.getLead(l).then((full) => setSelected((cur) => (cur && cur.id === l.id ? full : cur))).catch(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="view leads">
|
<div className="view leads">
|
||||||
<PageHead
|
<PageHead
|
||||||
eyebrow="Sales"
|
eyebrow="Sales"
|
||||||
title="Leads"
|
title="Leads"
|
||||||
subtitle={`${total} total leads`}
|
subtitle={`${TOTAL_LEADS} total leads · Plano hail zone · storm 2026-04-28`}
|
||||||
icon="leads"
|
icon="leads"
|
||||||
actions={<Btn icon="plus" onClick={() => setNewOpen(true)}>New Lead</Btn>}
|
actions={<Btn icon="plus" onClick={() => setNewOpen(true)}>New Lead</Btn>}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ---- stat strip ---------------------------------------- */}
|
{/* ---- stat strip ---------------------------------------- */}
|
||||||
<div className="leads-stats">
|
<div className="leads-stats">
|
||||||
<StatCard label="Total leads" value={total} icon="leads" tone="orange" />
|
<StatCard label="Total leads" value={TOTAL_LEADS} icon="leads" tone="orange" />
|
||||||
<StatCard label="New" value={byStatus.new} icon="star" tone="blue" />
|
<StatCard label="New" value={countByStatus.new ?? 0} icon="star" tone="blue" />
|
||||||
<StatCard label="Contacted" value={byStatus.contacted} icon="phone" tone="orange" />
|
<StatCard label="Contacted" value={countByStatus.contacted ?? 0} icon="phone" tone="orange" />
|
||||||
<StatCard label="Appointed" value={byStatus.appointed} icon="clock" tone="purple" />
|
<StatCard label="Appointed" value={countByStatus.appointed ?? 0} icon="clock" tone="purple" />
|
||||||
<StatCard label="Closed" value={byStatus.closed} icon="check-circle" tone="green" />
|
<StatCard label="Closed" value={countByStatus.closed ?? 0} icon="check-circle" tone="green" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ---- toolbar ------------------------------------------- */}
|
{/* ---- toolbar ------------------------------------------- */}
|
||||||
@@ -104,19 +96,7 @@ export function Leads() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ---- board --------------------------------------------- */}
|
{/* ---- board --------------------------------------------- */}
|
||||||
{error ? (
|
{filtered.length === 0 ? (
|
||||||
<div className="card leads-empty">
|
|
||||||
<Icon name="alert" size={30} />
|
|
||||||
<h3>Couldn’t load leads</h3>
|
|
||||||
<p>{error}</p>
|
|
||||||
</div>
|
|
||||||
) : loading && filtered.length === 0 ? (
|
|
||||||
<div className="card leads-empty">
|
|
||||||
<Icon name="refresh" size={30} />
|
|
||||||
<h3>Loading leads…</h3>
|
|
||||||
<p>Fetching the Plano pipeline.</p>
|
|
||||||
</div>
|
|
||||||
) : filtered.length === 0 ? (
|
|
||||||
<div className="card leads-empty">
|
<div className="card leads-empty">
|
||||||
<Icon name="search" size={30} />
|
<Icon name="search" size={30} />
|
||||||
<h3>No leads match</h3>
|
<h3>No leads match</h3>
|
||||||
@@ -125,21 +105,13 @@ export function Leads() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="leads-grid">
|
<div className="leads-grid">
|
||||||
{filtered.map((l) => (
|
{filtered.map((l) => (
|
||||||
<LeadCard key={l.id} lead={l} onOpen={() => openLead(l)} />
|
<LeadCard key={l.id} lead={l} onOpen={() => setSelected(l)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<LeadDetail
|
<LeadDetail lead={selected} onClose={() => setSelected(null)} />
|
||||||
lead={selected}
|
<NewLead open={newOpen} onClose={() => setNewOpen(false)} />
|
||||||
onClose={() => setSelected(null)}
|
|
||||||
data={data}
|
|
||||||
reps={data.reps}
|
|
||||||
onHydrate={setSelected}
|
|
||||||
sent={!!selected && sentIds.has(idOf(selected))}
|
|
||||||
onSent={() => selected && setSentIds((s) => new Set(s).add(idOf(selected)))}
|
|
||||||
/>
|
|
||||||
<NewLead open={newOpen} onClose={() => setNewOpen(false)} createLead={data.createLead} reps={data.reps} uploadPhoto={data.uploadPhoto} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -177,7 +149,7 @@ function LeadCard({ lead, onOpen }: { lead: Lead; onOpen: () => void }) {
|
|||||||
</span>
|
</span>
|
||||||
<div className="lead-card-id">
|
<div className="lead-card-id">
|
||||||
<div className="lead-card-name">{lead.name}</div>
|
<div className="lead-card-name">{lead.name}</div>
|
||||||
<div className="lead-card-sub"><span className="lead-code">{lead.id}</span></div>
|
<div className="lead-card-sub"><span className="lead-code">{lead.id}</span> · <Icon name="storm" size={12} /> {lead.tag}</div>
|
||||||
</div>
|
</div>
|
||||||
<Pill tone={priority.tone}><Icon name="alert" size={11} /> {priority.label}</Pill>
|
<Pill tone={priority.tone}><Icon name="alert" size={11} /> {priority.label}</Pill>
|
||||||
</div>
|
</div>
|
||||||
@@ -200,365 +172,116 @@ function LeadCard({ lead, onOpen }: { lead: Lead; onOpen: () => void }) {
|
|||||||
/* Lead detail popup */
|
/* Lead detail popup */
|
||||||
/* ---------------------------------------------------------- */
|
/* ---------------------------------------------------------- */
|
||||||
|
|
||||||
type LeadEdit = {
|
function LeadDetail({ lead, onClose }: { lead: Lead | null; onClose: () => void }) {
|
||||||
firstName: string; lastName: string;
|
|
||||||
phones: PhoneRow[]; emails: EmailRow[];
|
|
||||||
address: string; city: string; state: string; zip: string; propertyType: string;
|
|
||||||
source: string; leadType: string; workType: string; tradeType: string; urgency: string; notes: string;
|
|
||||||
insCompany: string; claimStatus: string; claimNumber: string; policyNumber: string; adjusterName: string; adjusterPhone: string;
|
|
||||||
assignRep: string; priority: string; followUp: string;
|
|
||||||
photos: Attachment[];
|
|
||||||
};
|
|
||||||
|
|
||||||
// Convert a pretty date ("Jun 4, 2026") — or "—"/"" — to a yyyy-mm-dd value for <input type=date>.
|
|
||||||
function toDateInput(s: string): string {
|
|
||||||
if (!s || s === "—") return "";
|
|
||||||
const t = Date.parse(s);
|
|
||||||
if (Number.isNaN(t)) return "";
|
|
||||||
const d = new Date(t);
|
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function initEdit(lead: Lead, reps: Rep[]): LeadEdit {
|
|
||||||
const [firstName, ...rest] = lead.name.split(/\s+/);
|
|
||||||
const rep = reps.find((r) => r.name === lead.assignment.assignedTo);
|
|
||||||
return {
|
|
||||||
firstName: firstName ?? "", lastName: rest.join(" "),
|
|
||||||
phones: lead.phones.length ? lead.phones.map((p) => ({ number: p.number, type: p.type })) : [{ number: "", type: "Mobile" }],
|
|
||||||
emails: lead.emails.map((e) => ({ address: e.address })),
|
|
||||||
address: lead.property.address, city: lead.property.city, state: lead.property.state, zip: lead.property.zip, propertyType: lead.property.type,
|
|
||||||
source: lead.job.source, leadType: lead.job.leadType, workType: lead.job.workType, tradeType: lead.job.tradeType, urgency: lead.job.urgency || "Standard", notes: lead.job.notes,
|
|
||||||
insCompany: lead.insurance.company, claimStatus: lead.insurance.claimStatus, claimNumber: lead.insurance.claimNumber,
|
|
||||||
policyNumber: lead.insurance.policyNumber, adjusterName: lead.insurance.adjusterName, adjusterPhone: lead.insurance.adjusterPhone,
|
|
||||||
assignRep: rep?.id ?? "", priority: lead.assignment.priority || "Medium", followUp: toDateInput(lead.assignment.followUp),
|
|
||||||
photos: lead.attachments ?? [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPatch(f: LeadEdit): import("@/lib/leads-api").LeadPatch {
|
|
||||||
return {
|
|
||||||
firstName: f.firstName.trim(), lastName: f.lastName.trim(),
|
|
||||||
phones: f.phones.filter((p) => p.number.trim()).map((p, i) => ({ number: p.number.trim(), type: p.type, primary: i === 0 })),
|
|
||||||
emails: f.emails.filter((e) => e.address.trim()).map((e, i) => ({ address: e.address.trim(), primary: i === 0 })),
|
|
||||||
propertyAddress: f.address, propertyCity: f.city, propertyState: f.state, propertyZip: f.zip, propertyType: f.propertyType || undefined,
|
|
||||||
source: f.source || undefined, leadType: f.leadType || undefined, workType: f.workType || undefined,
|
|
||||||
tradeType: f.tradeType || undefined, urgency: f.urgency || undefined, jobNotes: f.notes,
|
|
||||||
insuranceCompany: f.insCompany, insuranceClaimStatus: f.claimStatus || undefined, insuranceClaimNumber: f.claimNumber,
|
|
||||||
insurancePolicyNumber: f.policyNumber, insuranceAdjusterName: f.adjusterName, insuranceAdjusterPhone: f.adjusterPhone,
|
|
||||||
assignedToId: f.assignRep || null, followUpDate: f.followUp || undefined, priority: f.priority,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function LeadDetail({ lead, onClose, data, reps, onHydrate, sent, onSent }: { lead: Lead | null; onClose: () => void; data: LeadsData; reps: Rep[]; onHydrate: (l: Lead) => void; sent: boolean; onSent: () => void }) {
|
|
||||||
if (!lead) return null;
|
if (!lead) return null;
|
||||||
// Keyed by id so edit state resets when a different lead opens.
|
|
||||||
return <LeadDetailBody key={lead.refId ?? lead.id} lead={lead} onClose={onClose} data={data} reps={reps} onHydrate={onHydrate} sent={sent} onSent={onSent} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
function LeadDetailBody({ lead, onClose, data, reps, onHydrate, sent, onSent }: { lead: Lead; onClose: () => void; data: LeadsData; reps: Rep[]; onHydrate: (l: Lead) => void; sent: boolean; onSent: () => void }) {
|
|
||||||
const toast = useToast();
|
|
||||||
const [editing, setEditing] = useState(false);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [sending, setSending] = useState(false);
|
|
||||||
const [f, setF] = useState<LeadEdit | null>(null);
|
|
||||||
const [uploading, setUploading] = useState(0);
|
|
||||||
const editFileRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const status = STATUS_META[lead.status];
|
const status = STATUS_META[lead.status];
|
||||||
const priority = PRIORITY_META[lead.priority];
|
const priority = PRIORITY_META[lead.priority];
|
||||||
const primaryPhone = lead.phones.find((p) => p.primary) ?? lead.phones[0];
|
|
||||||
const primaryEmail = lead.emails.find((e) => e.primary) ?? lead.emails[0];
|
|
||||||
const repOptions = [{ id: "", initials: "—", name: "Unassigned", email: "" }, ...reps];
|
|
||||||
|
|
||||||
const call = () => {
|
|
||||||
if (!primaryPhone?.number) { toast.push({ tone: "error", title: "No phone", desc: "This lead has no phone number." }); return; }
|
|
||||||
window.location.href = `tel:${primaryPhone.number.replace(/[^\d+]/g, "")}`;
|
|
||||||
};
|
|
||||||
const email = () => {
|
|
||||||
if (!primaryEmail?.address) { toast.push({ tone: "error", title: "No email", desc: "This lead has no email address." }); return; }
|
|
||||||
window.location.href = `mailto:${primaryEmail.address}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const startEdit = () => { setF(initEdit(lead, reps)); setEditing(true); };
|
|
||||||
const cancelEdit = () => { setEditing(false); setF(null); };
|
|
||||||
const setField = (k: keyof LeadEdit) => (e: { target: { value: string } }) => setF((s) => (s ? { ...s, [k]: e.target.value } : s));
|
|
||||||
const addPhone = () => setF((s) => (s ? { ...s, phones: [...s.phones, { number: "", type: "Mobile" }] } : s));
|
|
||||||
const setPhone = (i: number, key: "number" | "type", v: string) => setF((s) => (s ? { ...s, phones: s.phones.map((p, j) => (j === i ? { ...p, [key]: v } : p)) } : s));
|
|
||||||
const removePhone = (i: number) => setF((s) => (s ? { ...s, phones: s.phones.filter((_, j) => j !== i) } : s));
|
|
||||||
const addEmail = () => setF((s) => (s ? { ...s, emails: [...s.emails, { address: "" }] } : s));
|
|
||||||
const setEmail = (i: number, v: string) => setF((s) => (s ? { ...s, emails: s.emails.map((e, j) => (j === i ? { address: v } : e)) } : s));
|
|
||||||
const removeEmail = (i: number) => setF((s) => (s ? { ...s, emails: s.emails.filter((_, j) => j !== i) } : s));
|
|
||||||
const removeEditPhoto = (contentRef: string) => setF((s) => (s ? { ...s, photos: s.photos.filter((p) => p.contentRef !== contentRef) } : s));
|
|
||||||
|
|
||||||
// Upload picked files to storage (presign → PUT), then stage them on the edit form. They're
|
|
||||||
// attached to the lead on Save via crm.lead.attachment.add (see the reconcile in save()).
|
|
||||||
async function onPickEditPhotos(e: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
const files = Array.from(e.target.files ?? []);
|
|
||||||
e.target.value = "";
|
|
||||||
for (const file of files) {
|
|
||||||
if (!isImage(file.type)) { toast.push({ tone: "error", title: "Not an image", desc: `${file.name} isn't an image file.` }); continue; }
|
|
||||||
if (file.size > MAX_ATTACHMENT_BYTES) { toast.push({ tone: "error", title: "Too large", desc: `${file.name} exceeds the 25 MB limit.` }); continue; }
|
|
||||||
setUploading((n) => n + 1);
|
|
||||||
try {
|
|
||||||
const up = await data.uploadPhoto(file);
|
|
||||||
setF((s) => (s ? { ...s, photos: [...s.photos, up] } : s));
|
|
||||||
} catch (err) {
|
|
||||||
toast.push({ tone: "error", title: "Upload failed", desc: err instanceof Error ? err.message : `Couldn't upload ${file.name}.` });
|
|
||||||
} finally {
|
|
||||||
setUploading((n) => n - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
if (!f) return;
|
|
||||||
if (!`${f.firstName} ${f.lastName}`.trim()) { toast.push({ tone: "error", title: "Name required", desc: "Enter the homeowner's first or last name." }); return; }
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
await data.updateLead(lead, buildPatch(f));
|
|
||||||
// Reconcile photos against what the lead had: attach the newly-added, detach the removed.
|
|
||||||
const orig = lead.attachments ?? [];
|
|
||||||
const toAdd = f.photos.filter((p) => !orig.some((o) => o.contentRef === p.contentRef));
|
|
||||||
const toRemove = orig.filter((o) => !f.photos.some((p) => p.contentRef === o.contentRef));
|
|
||||||
for (const a of toAdd) await data.addPhoto(lead, a);
|
|
||||||
for (const r of toRemove) await data.removePhoto(lead, r);
|
|
||||||
try { onHydrate(await data.getLead(lead)); } catch { /* keep current view if re-fetch fails */ }
|
|
||||||
toast.push({ tone: "success", title: "Lead updated", desc: `${`${f.firstName} ${f.lastName}`.trim()} saved.` });
|
|
||||||
setEditing(false); setF(null);
|
|
||||||
} catch (e) {
|
|
||||||
toast.push({ tone: "error", title: "Update failed", desc: e instanceof Error ? e.message : "Please try again." });
|
|
||||||
} finally { setSaving(false); }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Already in the verification queue if the backend says so (verificationId) or we sent it this session.
|
|
||||||
const alreadySent = sent || !!lead.verificationId;
|
|
||||||
|
|
||||||
async function sendForVerification() {
|
|
||||||
if (alreadySent) return;
|
|
||||||
setSending(true);
|
|
||||||
try {
|
|
||||||
await data.sendForVerification(lead);
|
|
||||||
onSent();
|
|
||||||
toast.push({ tone: "success", title: "Sent for verification", desc: `${lead.name} added to the verification queue.` });
|
|
||||||
} catch (e) {
|
|
||||||
toast.push({ tone: "error", title: "Couldn’t send", desc: e instanceof Error ? e.message : "Please try again." });
|
|
||||||
} finally { setSending(false); }
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
open
|
open={!!lead}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
size="lg"
|
size="lg"
|
||||||
title={lead.name}
|
title={lead.name}
|
||||||
subtitle={lead.id}
|
subtitle={`${lead.id} · ${lead.tag}`}
|
||||||
icon="leads"
|
icon="leads"
|
||||||
footer={editing ? (
|
footer={
|
||||||
<>
|
<>
|
||||||
<Btn variant="ghost" onClick={cancelEdit} disabled={saving}>Cancel</Btn>
|
<Btn variant="ghost" icon="phone">Call</Btn>
|
||||||
<Btn icon="check" onClick={save} disabled={saving}>{saving ? "Saving…" : "Save Changes"}</Btn>
|
<Btn variant="outline" icon="mail">Email</Btn>
|
||||||
|
<Btn icon="check-circle">Update Status</Btn>
|
||||||
</>
|
</>
|
||||||
) : (
|
}
|
||||||
<>
|
|
||||||
<Btn variant="ghost" icon="edit" onClick={startEdit}>Edit</Btn>
|
|
||||||
<Btn variant="ghost" icon="phone" onClick={call}>Call</Btn>
|
|
||||||
<Btn variant="outline" icon="mail" onClick={email}>Email</Btn>
|
|
||||||
<Btn icon={alreadySent ? "check-circle" : "verify"} onClick={sendForVerification} disabled={sending || alreadySent}>
|
|
||||||
{alreadySent ? "Sent for Verification" : sending ? "Sending…" : "Send for Verification"}
|
|
||||||
</Btn>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{editing && f ? (
|
<div className="lead-detail">
|
||||||
<div className="nl-form lead-edit">
|
{/* identity strip */}
|
||||||
<div className="ld-section-head"><Icon name="user" size={15} /> Contact</div>
|
<div className="ld-identity">
|
||||||
<div className="nl-grid">
|
<Avatar initials={lead.initials} gradient={lead.gradient} size={54} />
|
||||||
<Field label="First Name" required><input className="ds-input" value={f.firstName} onChange={setField("firstName")} placeholder="John" /></Field>
|
<div className="ld-identity-body">
|
||||||
<Field label="Last Name"><input className="ds-input" value={f.lastName} onChange={setField("lastName")} placeholder="Smith" /></Field>
|
<div className="ld-identity-name">{lead.name}</div>
|
||||||
<div className="nl-full">
|
<div className="ld-identity-pills">
|
||||||
<div className="ds-field-lbl">Phone Numbers</div>
|
<Pill tone={status.tone}>{status.label}</Pill>
|
||||||
{f.phones.map((p, i) => (
|
<Pill tone={priority.tone}><Icon name="alert" size={11} /> {priority.label}</Pill>
|
||||||
<div className="nl-multirow" key={i}>
|
|
||||||
<input className="ds-input" value={p.number} onChange={(e) => setPhone(i, "number", e.target.value)} placeholder="(555) 000-0000" />
|
|
||||||
<select className="ds-select nl-typesel" value={p.type} onChange={(e) => setPhone(i, "type", e.target.value)}>
|
|
||||||
{["Mobile", "Home", "Work"].map((t) => <option key={t} value={t}>{t}</option>)}
|
|
||||||
</select>
|
|
||||||
{f.phones.length > 1 && <button type="button" className="nl-rowx" aria-label="Remove phone" onClick={() => removePhone(i)}><Icon name="trash" size={15} /></button>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<button type="button" className="nl-add" onClick={addPhone}><Icon name="plus" size={14} /> Add Phone</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="nl-full">
|
|
||||||
<div className="ds-field-lbl">Email Addresses</div>
|
|
||||||
{f.emails.length === 0 && <div className="nl-empty">No emails added yet.</div>}
|
|
||||||
{f.emails.map((em, i) => (
|
|
||||||
<div className="nl-multirow" key={i}>
|
|
||||||
<input className="ds-input" type="email" value={em.address} onChange={(e) => setEmail(i, e.target.value)} placeholder="name@email.com" />
|
|
||||||
<button type="button" className="nl-rowx" aria-label="Remove email" onClick={() => removeEmail(i)}><Icon name="trash" size={15} /></button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<button type="button" className="nl-add" onClick={addEmail}><Icon name="plus" size={14} /> Add Email</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ld-section-head"><Icon name="owners" size={15} /> Property</div>
|
|
||||||
<div className="nl-grid">
|
|
||||||
<div className="nl-full"><Field label="Street Address"><input className="ds-input" value={f.address} onChange={setField("address")} placeholder="123 Main St" /></Field></div>
|
|
||||||
<Field label="City"><input className="ds-input" value={f.city} onChange={setField("city")} placeholder="Plano" /></Field>
|
|
||||||
<Field label="State"><input className="ds-input" value={f.state} onChange={setField("state")} placeholder="TX" /></Field>
|
|
||||||
<Field label="ZIP"><input className="ds-input" value={f.zip} onChange={setField("zip")} placeholder="75023" /></Field>
|
|
||||||
<Field label="Property Type"><Select value={f.propertyType} onChange={setField("propertyType")} options={PROPERTY_TYPE_OPTS} placeholder="Select type…" /></Field>
|
|
||||||
<div className="nl-full">
|
|
||||||
<div className="ds-field-lbl">Site Photos</div>
|
|
||||||
<input ref={editFileRef} type="file" accept="image/*" multiple hidden onChange={onPickEditPhotos} />
|
|
||||||
{f.photos.length > 0 && (
|
|
||||||
<div className="ld-photos nl-edit-photos">
|
|
||||||
{f.photos.map((p) => (
|
|
||||||
<div key={p.contentRef} className="ld-photo-edit">
|
|
||||||
<LeadPhoto att={p} getUrl={data.getPhotoUrl} />
|
|
||||||
<button type="button" className="ld-photo-x" aria-label={`Remove ${p.filename}`} onClick={() => removeEditPhoto(p.contentRef)}><Icon name="x" size={13} /></button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<button type="button" className="nl-add" onClick={() => editFileRef.current?.click()}>
|
|
||||||
<Icon name="camera" size={14} /> {uploading > 0 ? `Uploading ${uploading}…` : "Add Photos"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ld-section-head"><Icon name="projects" size={15} /> Job Details</div>
|
|
||||||
<div className="nl-grid">
|
|
||||||
<Field label="Lead Source"><Select value={f.source} onChange={setField("source")} options={LEAD_SOURCES} placeholder="Source…" /></Field>
|
|
||||||
<Field label="Lead Type"><Select value={f.leadType} onChange={setField("leadType")} options={LEAD_TYPE_OPTS} placeholder="Type…" /></Field>
|
|
||||||
<Field label="Work Type"><Select value={f.workType} onChange={setField("workType")} options={WORK_TYPES} placeholder="Work…" /></Field>
|
|
||||||
<Field label="Trade Type"><Select value={f.tradeType} onChange={setField("tradeType")} options={TRADE_TYPES} placeholder="Trade…" /></Field>
|
|
||||||
<Field label="Urgency"><Select value={f.urgency} onChange={setField("urgency")} options={["Standard", "High", "Emergency"]} /></Field>
|
|
||||||
<div className="nl-full"><Field label="Field Notes"><textarea className="ds-textarea" rows={3} value={f.notes} onChange={setField("notes")} /></Field></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ld-section-head"><Icon name="shield" size={15} /> Insurance</div>
|
|
||||||
<div className="nl-grid">
|
|
||||||
<div className="nl-full"><Field label="Insurance Company"><input className="ds-input" value={f.insCompany} onChange={setField("insCompany")} placeholder="State Farm" /></Field></div>
|
|
||||||
<Field label="Claim Number"><input className="ds-input" value={f.claimNumber} onChange={setField("claimNumber")} /></Field>
|
|
||||||
<Field label="Claim Status"><Select value={f.claimStatus} onChange={setField("claimStatus")} options={CLAIM_STATUSES} placeholder="Status…" /></Field>
|
|
||||||
<Field label="Adjuster Name"><input className="ds-input" value={f.adjusterName} onChange={setField("adjusterName")} /></Field>
|
|
||||||
<Field label="Adjuster Phone"><input className="ds-input" value={f.adjusterPhone} onChange={setField("adjusterPhone")} /></Field>
|
|
||||||
<div className="nl-full"><Field label="Policy Number"><input className="ds-input" value={f.policyNumber} onChange={setField("policyNumber")} /></Field></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ld-section-head"><Icon name="team" size={15} /> Assignment</div>
|
|
||||||
<div className="nl-grid">
|
|
||||||
<div className="nl-full"><Field label="Assign Rep"><RepSelect value={f.assignRep} onChange={setField("assignRep")} options={repOptions} /></Field></div>
|
|
||||||
<Field label="Priority"><Select value={f.priority} onChange={setField("priority")} options={["Low", "Medium", "High"]} /></Field>
|
|
||||||
<Field label="Follow-up Date"><input className="ds-input" type="date" value={f.followUp} onChange={setField("followUp")} /></Field>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="lead-detail">
|
|
||||||
{/* identity strip */}
|
|
||||||
<div className="ld-identity">
|
|
||||||
<Avatar initials={lead.initials} gradient={lead.gradient} size={54} />
|
|
||||||
<div className="ld-identity-body">
|
|
||||||
<div className="ld-identity-name">{lead.name}</div>
|
|
||||||
<div className="ld-identity-pills">
|
|
||||||
<Pill tone={status.tone}>{status.label}</Pill>
|
|
||||||
<Pill tone={priority.tone}><Icon name="alert" size={11} /> {priority.label}</Pill>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* storm banner — only when the lead actually carries storm data */}
|
{/* storm banner */}
|
||||||
{(lead.storm.zone || lead.storm.date || lead.storm.detail) && (
|
<div className="ld-storm">
|
||||||
<div className="ld-storm">
|
<span className="ld-storm-ic"><Icon name="storm" size={18} /></span>
|
||||||
<span className="ld-storm-ic"><Icon name="storm" size={18} /></span>
|
<div>
|
||||||
<div>
|
<div className="ld-storm-zone">{lead.storm.zone}</div>
|
||||||
<div className="ld-storm-zone">{lead.storm.zone}</div>
|
<div className="ld-storm-meta">{lead.storm.date} · {lead.storm.detail}</div>
|
||||||
<div className="ld-storm-meta">{[lead.storm.date, lead.storm.detail].filter(Boolean).join(" · ")}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Property photos */}
|
|
||||||
{(lead.attachments?.length ?? 0) > 0 && (
|
|
||||||
<div className="ld-photo-block">
|
|
||||||
<div className="ld-section-head"><Icon name="camera" size={15} /> Property Photos</div>
|
|
||||||
<div className="ld-photos">
|
|
||||||
{lead.attachments!.map((a) => (
|
|
||||||
<LeadPhoto key={a.contentRef} att={a} getUrl={data.getPhotoUrl} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="ld-grid">
|
|
||||||
{/* Contact */}
|
|
||||||
<Section title="Contact" icon="user">
|
|
||||||
<div className="ld-sublabel">Phone Numbers</div>
|
|
||||||
{lead.phones.map((p, i) => (
|
|
||||||
<div className="ld-contact-row" key={i}>
|
|
||||||
<Icon name="phone" size={14} />
|
|
||||||
<span className="ld-contact-val">{p.number}</span>
|
|
||||||
<span className="ld-contact-tag">{p.type}</span>
|
|
||||||
{p.primary && <Pill tone="green">Primary</Pill>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div className="ld-sublabel">Email Addresses</div>
|
|
||||||
{lead.emails.map((e, i) => (
|
|
||||||
<div className="ld-contact-row" key={i}>
|
|
||||||
<Icon name="mail" size={14} />
|
|
||||||
<span className="ld-contact-val">{e.address}</span>
|
|
||||||
{e.primary && <Pill tone="green">Primary</Pill>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* Property */}
|
|
||||||
<Section title="Property" icon="owners">
|
|
||||||
<Dl label="Address" value={lead.property.address} />
|
|
||||||
<Dl label="City" value={lead.property.city} />
|
|
||||||
<Dl label="State" value={lead.property.state} />
|
|
||||||
<Dl label="ZIP" value={lead.property.zip} />
|
|
||||||
<Dl label="Property Type" value={lead.property.type} />
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* Job Details */}
|
|
||||||
<Section title="Job Details" icon="projects">
|
|
||||||
<Dl label="Lead Source" value={lead.job.source} />
|
|
||||||
<Dl label="Lead Type" value={lead.job.leadType} />
|
|
||||||
<Dl label="Work Type" value={lead.job.workType} />
|
|
||||||
<Dl label="Trade Type" value={lead.job.tradeType} />
|
|
||||||
<Dl label="Urgency" value={lead.job.urgency} />
|
|
||||||
<Dl label="Canvasser" value={lead.job.canvasser} />
|
|
||||||
<div className="ld-notes">
|
|
||||||
<div className="ld-sublabel">Field Notes</div>
|
|
||||||
<p>{lead.job.notes}</p>
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* Insurance */}
|
|
||||||
<Section title="Insurance" icon="shield">
|
|
||||||
<Dl label="Insurance Company" value={lead.insurance.company} />
|
|
||||||
<Dl label="Claim Status" value={lead.insurance.claimStatus} />
|
|
||||||
<Dl label="Claim Number" value={lead.insurance.claimNumber} />
|
|
||||||
<Dl label="Policy Number" value={lead.insurance.policyNumber} />
|
|
||||||
<Dl label="Adjuster Name" value={lead.insurance.adjusterName} />
|
|
||||||
<Dl label="Adjuster Phone" value={lead.insurance.adjusterPhone} />
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* Assignment */}
|
|
||||||
<Section title="Assignment" icon="team" wide>
|
|
||||||
<div className="ld-assign">
|
|
||||||
<Dl label="Assigned To" value={lead.assignment.assignedTo} />
|
|
||||||
<Dl label="Priority" value={lead.assignment.priority} />
|
|
||||||
<Dl label="Follow-Up Date" value={lead.assignment.followUp} />
|
|
||||||
<Dl label="Created By" value={lead.assignment.createdBy} />
|
|
||||||
<Dl label="Created At" value={lead.assignment.createdAt} />
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
<div className="ld-grid">
|
||||||
|
{/* Contact */}
|
||||||
|
<Section title="Contact" icon="user">
|
||||||
|
<div className="ld-sublabel">Phone Numbers</div>
|
||||||
|
{lead.phones.map((p, i) => (
|
||||||
|
<div className="ld-contact-row" key={i}>
|
||||||
|
<Icon name="phone" size={14} />
|
||||||
|
<span className="ld-contact-val">{p.number}</span>
|
||||||
|
<span className="ld-contact-tag">{p.type}</span>
|
||||||
|
{p.primary && <Pill tone="green">Primary</Pill>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="ld-sublabel">Email Addresses</div>
|
||||||
|
{lead.emails.map((e, i) => (
|
||||||
|
<div className="ld-contact-row" key={i}>
|
||||||
|
<Icon name="mail" size={14} />
|
||||||
|
<span className="ld-contact-val">{e.address}</span>
|
||||||
|
{e.primary && <Pill tone="green">Primary</Pill>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Property */}
|
||||||
|
<Section title="Property" icon="owners">
|
||||||
|
<Dl label="Address" value={lead.property.address} />
|
||||||
|
<Dl label="City" value={lead.property.city} />
|
||||||
|
<Dl label="State" value={lead.property.state} />
|
||||||
|
<Dl label="ZIP" value={lead.property.zip} />
|
||||||
|
<Dl label="Property Type" value={lead.property.type} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Job Details */}
|
||||||
|
<Section title="Job Details" icon="projects">
|
||||||
|
<Dl label="Lead Source" value={lead.job.source} />
|
||||||
|
<Dl label="Lead Type" value={lead.job.leadType} />
|
||||||
|
<Dl label="Work Type" value={lead.job.workType} />
|
||||||
|
<Dl label="Trade Type" value={lead.job.tradeType} />
|
||||||
|
<Dl label="Urgency" value={lead.job.urgency} />
|
||||||
|
<Dl label="Canvasser" value={lead.job.canvasser} />
|
||||||
|
<div className="ld-notes">
|
||||||
|
<div className="ld-sublabel">Field Notes</div>
|
||||||
|
<p>{lead.job.notes}</p>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Insurance */}
|
||||||
|
<Section title="Insurance" icon="shield">
|
||||||
|
<Dl label="Insurance Company" value={lead.insurance.company} />
|
||||||
|
<Dl label="Claim Status" value={lead.insurance.claimStatus} />
|
||||||
|
<Dl label="Claim Number" value={lead.insurance.claimNumber} />
|
||||||
|
<Dl label="Policy Number" value={lead.insurance.policyNumber} />
|
||||||
|
<Dl label="Adjuster Name" value={lead.insurance.adjusterName} />
|
||||||
|
<Dl label="Adjuster Phone" value={lead.insurance.adjusterPhone} />
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* Assignment */}
|
||||||
|
<Section title="Assignment" icon="team" wide>
|
||||||
|
<div className="ld-assign">
|
||||||
|
<Dl label="Assigned To" value={lead.assignment.assignedTo} />
|
||||||
|
<Dl label="Priority" value={lead.assignment.priority} />
|
||||||
|
<Dl label="Follow-Up Date" value={lead.assignment.followUp} />
|
||||||
|
<Dl label="Created By" value={lead.assignment.createdBy} />
|
||||||
|
<Dl label="Created At" value={lead.assignment.createdAt} />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -581,28 +304,6 @@ function Dl({ label, value }: { label: string; value: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Renders a stored site photo. `contentRef` is an opaque object key, so we mint a short-lived
|
|
||||||
// signed URL on mount (mock mode passes the object URL straight through). Clicking opens full-size.
|
|
||||||
function LeadPhoto({ att, getUrl }: { att: Attachment; getUrl: (contentRef: string, mime?: string) => Promise<string> }) {
|
|
||||||
const [url, setUrl] = useState<string | null>(null);
|
|
||||||
const [failed, setFailed] = useState(false);
|
|
||||||
useEffect(() => {
|
|
||||||
let alive = true;
|
|
||||||
getUrl(att.contentRef, att.mimeType)
|
|
||||||
.then((u) => { if (alive) setUrl(u); })
|
|
||||||
.catch(() => { if (alive) setFailed(true); });
|
|
||||||
return () => { alive = false; };
|
|
||||||
}, [att.contentRef, att.mimeType, getUrl]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a className="ld-photo" href={url ?? undefined} target="_blank" rel="noreferrer" title={att.filename} onClick={(e) => { if (!url) e.preventDefault(); }}>
|
|
||||||
{url
|
|
||||||
? <img src={url} alt={att.filename} loading="lazy" />
|
|
||||||
: <span className="ld-photo-ph"><Icon name={failed ? "alert" : "camera"} size={18} /></span>}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------- */
|
/* ---------------------------------------------------------- */
|
||||||
/* New Lead — Quick / Full Form intake */
|
/* New Lead — Quick / Full Form intake */
|
||||||
/* ---------------------------------------------------------- */
|
/* ---------------------------------------------------------- */
|
||||||
@@ -620,29 +321,25 @@ const FULL_STEPS = [
|
|||||||
{ value: "assignment", label: "Assignment", icon: "team" },
|
{ value: "assignment", label: "Assignment", icon: "team" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Option lists must match the be-crm enums, or crm.lead.create rejects the value (§8.1–8.4).
|
const LEAD_TYPE_OPTS = ["Residential", "Commercial", "Multi-Family"];
|
||||||
const LEAD_TYPE_OPTS = LEAD_TYPES; // Insurance | Retail
|
const PROPERTY_TYPE_OPTS = ["Residential", "Commercial", "Multi-Family", "Industrial"];
|
||||||
const PROPERTY_TYPE_OPTS = PROPERTY_TYPES; // Single Family | Multi Family | Commercial
|
|
||||||
|
|
||||||
const BLANK = {
|
const BLANK = {
|
||||||
firstName: "", lastName: "",
|
firstName: "", lastName: "",
|
||||||
phones: [{ number: "", type: "Mobile" }] as PhoneRow[],
|
phones: [{ number: "", type: "Mobile" }] as PhoneRow[],
|
||||||
emails: [] as EmailRow[],
|
emails: [] as EmailRow[],
|
||||||
address: "", city: "", state: "TX", zip: "", propertyType: "",
|
address: "", city: "", state: "TX", zip: "", propertyType: "",
|
||||||
photos: [] as UploadedAttachment[],
|
photos: [] as string[],
|
||||||
source: "", referralNote: "", canvasser: "", leadType: "", workType: "", tradeType: "", urgency: "Standard" as Urgency, notes: "",
|
source: "", referralNote: "", canvasser: "", leadType: "", workType: "", tradeType: "", urgency: "Standard" as Urgency, notes: "",
|
||||||
insCompany: "", claimNumber: "", claimStatus: "", adjusterName: "", adjusterPhone: "", policyNumber: "",
|
insCompany: "", claimNumber: "", claimStatus: "", adjusterName: "", adjusterPhone: "", policyNumber: "",
|
||||||
assignRep: "", priority: "Medium" as Priority, followUp: "",
|
assignRep: "", priority: "Medium" as Priority, followUp: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
function NewLead({ open, onClose, createLead, reps, uploadPhoto }: { open: boolean; onClose: () => void; createLead: (input: CreateLeadInput) => Promise<void>; reps: Rep[]; uploadPhoto: (file: File) => Promise<UploadedAttachment> }) {
|
function NewLead({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [mode, setMode] = useState<"quick" | "full">("quick");
|
const [mode, setMode] = useState<"quick" | "full">("quick");
|
||||||
const [section, setSection] = useState("contact");
|
const [section, setSection] = useState("contact");
|
||||||
const [f, setF] = useState({ ...BLANK });
|
const [f, setF] = useState({ ...BLANK });
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [uploading, setUploading] = useState(0);
|
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const set = (k: string) => (e: { target: { value: string } }) =>
|
const set = (k: string) => (e: { target: { value: string } }) =>
|
||||||
setF((s) => ({ ...s, [k]: e.target.value }) as typeof BLANK);
|
setF((s) => ({ ...s, [k]: e.target.value }) as typeof BLANK);
|
||||||
@@ -654,72 +351,19 @@ function NewLead({ open, onClose, createLead, reps, uploadPhoto }: { open: boole
|
|||||||
const addEmail = () => setF((s) => ({ ...s, emails: [...s.emails, { address: "" }] }));
|
const addEmail = () => setF((s) => ({ ...s, emails: [...s.emails, { address: "" }] }));
|
||||||
const setEmail = (i: number, v: string) => setF((s) => ({ ...s, emails: s.emails.map((e, j) => (j === i ? { address: v } : e)) }));
|
const setEmail = (i: number, v: string) => setF((s) => ({ ...s, emails: s.emails.map((e, j) => (j === i ? { address: v } : e)) }));
|
||||||
const removeEmail = (i: number) => setF((s) => ({ ...s, emails: s.emails.filter((_, j) => j !== i) }));
|
const removeEmail = (i: number) => setF((s) => ({ ...s, emails: s.emails.filter((_, j) => j !== i) }));
|
||||||
const removePhoto = (i: number) => setF((s) => ({ ...s, photos: s.photos.filter((_, j) => j !== i) }));
|
const addPhoto = () => setF((s) => ({ ...s, photos: [...s.photos, `Photo ${s.photos.length + 1}`] }));
|
||||||
|
|
||||||
// Upload each picked file straight to storage (presign → PUT). The returned {contentRef,…}
|
|
||||||
// rides along in crm.lead.create's `photos`, so the photo persists with the lead itself.
|
|
||||||
async function onPickPhotos(e: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
const files = Array.from(e.target.files ?? []);
|
|
||||||
e.target.value = ""; // let the same file be re-picked after a remove
|
|
||||||
for (const file of files) {
|
|
||||||
if (!isImage(file.type)) { toast.push({ tone: "error", title: "Not an image", desc: `${file.name} isn't an image file.` }); continue; }
|
|
||||||
if (file.size > MAX_ATTACHMENT_BYTES) { toast.push({ tone: "error", title: "Too large", desc: `${file.name} exceeds the 25 MB limit.` }); continue; }
|
|
||||||
setUploading((n) => n + 1);
|
|
||||||
try {
|
|
||||||
const up = await uploadPhoto(file);
|
|
||||||
setF((s) => ({ ...s, photos: [...s.photos, up] }));
|
|
||||||
} catch (err) {
|
|
||||||
toast.push({ tone: "error", title: "Upload failed", desc: err instanceof Error ? err.message : `Couldn't upload ${file.name}.` });
|
|
||||||
} finally {
|
|
||||||
setUploading((n) => n - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() { setF({ ...BLANK, phones: [{ number: "", type: "Mobile" }], emails: [], photos: [] }); setMode("quick"); setSection("contact"); }
|
function reset() { setF({ ...BLANK, phones: [{ number: "", type: "Mobile" }], emails: [], photos: [] }); setMode("quick"); setSection("contact"); }
|
||||||
function close() { reset(); onClose(); }
|
function close() { reset(); onClose(); }
|
||||||
|
|
||||||
function buildPayload(): CreateLeadInput {
|
function submit() {
|
||||||
return {
|
|
||||||
firstName: f.firstName.trim(),
|
|
||||||
lastName: f.lastName.trim() || undefined,
|
|
||||||
phones: f.phones
|
|
||||||
.filter((p) => p.number.trim())
|
|
||||||
.map((p, i) => ({ number: p.number.trim(), type: (p.type as "Mobile" | "Home" | "Work"), primary: i === 0 })),
|
|
||||||
emails: f.emails.filter((e) => e.address.trim()).map((e, i) => ({ address: e.address.trim(), primary: i === 0 })),
|
|
||||||
property: { address: f.address, city: f.city, state: f.state, zip: f.zip, type: f.propertyType || undefined },
|
|
||||||
photos: f.photos.length ? f.photos : undefined,
|
|
||||||
job: {
|
|
||||||
source: f.source || undefined,
|
|
||||||
referralNote: f.source === "Referral" ? f.referralNote || undefined : undefined,
|
|
||||||
canvasserId: f.source === "Door Knock" ? f.canvasser || undefined : undefined,
|
|
||||||
leadType: f.leadType || undefined, workType: f.workType || undefined, tradeType: f.tradeType || undefined,
|
|
||||||
urgency: f.urgency, notes: f.notes || undefined,
|
|
||||||
},
|
|
||||||
insurance: {
|
|
||||||
company: f.insCompany || undefined, claimNumber: f.claimNumber || undefined, claimStatus: f.claimStatus || undefined,
|
|
||||||
adjusterName: f.adjusterName || undefined, adjusterPhone: f.adjusterPhone || undefined, policyNumber: f.policyNumber || undefined,
|
|
||||||
},
|
|
||||||
assignment: { assigneeId: f.assignRep || null, priority: f.priority, followUp: f.followUp || undefined },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
const name = `${f.firstName} ${f.lastName}`.trim();
|
const name = `${f.firstName} ${f.lastName}`.trim();
|
||||||
if (!name) { toast.push({ tone: "error", title: "Name required", desc: "Enter the homeowner's first or last name." }); return; }
|
if (!name) { toast.push({ tone: "error", title: "Name required", desc: "Enter the homeowner's first or last name." }); return; }
|
||||||
setSaving(true);
|
toast.push({ tone: "success", title: "Lead created", desc: `${name} added to the Plano pipeline.` });
|
||||||
try {
|
close();
|
||||||
await createLead(buildPayload());
|
|
||||||
toast.push({ tone: "success", title: "Lead created", desc: `${name} added to the Plano pipeline.` });
|
|
||||||
close();
|
|
||||||
} catch (e) {
|
|
||||||
toast.push({ tone: "error", title: "Couldn’t create lead", desc: e instanceof Error ? e.message : "Please try again." });
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const repOptions = [{ id: "", initials: "—", name: "Unassigned", email: "" }, ...reps];
|
const repOptions = [{ id: "", initials: "—", name: "Unassigned" }, ...REPS];
|
||||||
|
|
||||||
const stepIdx = FULL_STEPS.findIndex((s) => s.value === section);
|
const stepIdx = FULL_STEPS.findIndex((s) => s.value === section);
|
||||||
const isFirstStep = stepIdx <= 0;
|
const isFirstStep = stepIdx <= 0;
|
||||||
@@ -741,13 +385,13 @@ function NewLead({ open, onClose, createLead, reps, uploadPhoto }: { open: boole
|
|||||||
<Btn variant="ghost" onClick={close}>Cancel</Btn>
|
<Btn variant="ghost" onClick={close}>Cancel</Btn>
|
||||||
{!isFirstStep && <Btn variant="ghost" onClick={goBack}>Back</Btn>}
|
{!isFirstStep && <Btn variant="ghost" onClick={goBack}>Back</Btn>}
|
||||||
{isLastStep
|
{isLastStep
|
||||||
? <Btn icon="check" onClick={submit} disabled={saving}>{saving ? "Creating…" : "Create Lead"}</Btn>
|
? <Btn icon="check" onClick={submit}>Create Lead</Btn>
|
||||||
: <Btn icon="arrow" onClick={goNext}>Next</Btn>}
|
: <Btn icon="arrow" onClick={goNext}>Next</Btn>}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Btn variant="ghost" onClick={close}>Cancel</Btn>
|
<Btn variant="ghost" onClick={close}>Cancel</Btn>
|
||||||
<Btn icon="check" onClick={submit} disabled={saving}>{saving ? "Creating…" : "Create Lead"}</Btn>
|
<Btn icon="check" onClick={submit}>Create Lead</Btn>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -773,7 +417,7 @@ function NewLead({ open, onClose, createLead, reps, uploadPhoto }: { open: boole
|
|||||||
<div className="nl-full"><Field label="Referral note"><textarea className="ds-textarea" rows={3} value={f.referralNote} onChange={set("referralNote")} placeholder="Who referred this lead? Any details…" /></Field></div>
|
<div className="nl-full"><Field label="Referral note"><textarea className="ds-textarea" rows={3} value={f.referralNote} onChange={set("referralNote")} placeholder="Who referred this lead? Any details…" /></Field></div>
|
||||||
)}
|
)}
|
||||||
{f.source === "Door Knock" && (
|
{f.source === "Door Knock" && (
|
||||||
<div className="nl-full"><Field label="Canvasser"><CanvasserSearch value={f.canvasser} onChange={(v) => setF((s) => ({ ...s, canvasser: v }))} options={reps} /></Field></div>
|
<div className="nl-full"><Field label="Canvasser"><CanvasserSearch value={f.canvasser} onChange={(v) => setF((s) => ({ ...s, canvasser: v }))} options={REPS} /></Field></div>
|
||||||
)}
|
)}
|
||||||
<div className="nl-full"><PriorityPicker value={f.priority} onChange={(p) => setF((s) => ({ ...s, priority: p }))} /></div>
|
<div className="nl-full"><PriorityPicker value={f.priority} onChange={(p) => setF((s) => ({ ...s, priority: p }))} /></div>
|
||||||
<Field label="Follow-up date"><input className="ds-input" type="date" value={f.followUp} onChange={set("followUp")} /></Field>
|
<Field label="Follow-up date"><input className="ds-input" type="date" value={f.followUp} onChange={set("followUp")} /></Field>
|
||||||
@@ -828,27 +472,14 @@ function NewLead({ open, onClose, createLead, reps, uploadPhoto }: { open: boole
|
|||||||
<Field label="Property Type"><Select value={f.propertyType} onChange={set("propertyType")} options={PROPERTY_TYPE_OPTS} placeholder="Residential, Commercial…" /></Field>
|
<Field label="Property Type"><Select value={f.propertyType} onChange={set("propertyType")} options={PROPERTY_TYPE_OPTS} placeholder="Residential, Commercial…" /></Field>
|
||||||
<div className="nl-full">
|
<div className="nl-full">
|
||||||
<div className="ds-field-lbl">Site Photos</div>
|
<div className="ds-field-lbl">Site Photos</div>
|
||||||
<input
|
<button type="button" className="nl-photos" onClick={addPhoto}>
|
||||||
ref={fileRef}
|
|
||||||
type="file"
|
|
||||||
accept="image/*"
|
|
||||||
multiple
|
|
||||||
hidden
|
|
||||||
onChange={onPickPhotos}
|
|
||||||
/>
|
|
||||||
<button type="button" className="nl-photos" onClick={() => fileRef.current?.click()}>
|
|
||||||
<Icon name="camera" size={22} />
|
<Icon name="camera" size={22} />
|
||||||
<span className="nl-photos-t">{uploading > 0 ? `Uploading ${uploading}…` : "Tap to add photos"}</span>
|
<span className="nl-photos-t">Tap to add photos</span>
|
||||||
<span className="nl-photos-s">Camera · Gallery · Multiple allowed · max 25 MB</span>
|
<span className="nl-photos-s">Camera · Gallery · Multiple allowed</span>
|
||||||
</button>
|
</button>
|
||||||
{f.photos.length > 0 && (
|
{f.photos.length > 0 && (
|
||||||
<div className="nl-photo-chips">
|
<div className="nl-photo-chips">
|
||||||
{f.photos.map((p, i) => (
|
{f.photos.map((p, i) => <span key={i} className="nl-photo-chip"><Icon name="check" size={12} /> {p}</span>)}
|
||||||
<span key={p.contentRef} className="nl-photo-chip">
|
|
||||||
<Icon name="check" size={12} /> {p.filename}
|
|
||||||
<button type="button" className="nl-photo-chip-x" aria-label={`Remove ${p.filename}`} onClick={() => removePhoto(i)}><Icon name="x" size={12} /></button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,314 @@
|
|||||||
|
// ============================================================
|
||||||
|
// LynkedUp Pro — Pipeline (kanban) mock data.
|
||||||
|
// Mirrors the /owner/kanban board: seven progression stages
|
||||||
|
// (New Lead → Complete) plus two flag columns (Stuck,
|
||||||
|
// Follow-Up). Each lead carries the fields the detail drawer
|
||||||
|
// needs (contact, job, assignment, notes, timeline) and its
|
||||||
|
// activity trail is generated from the stage it has reached.
|
||||||
|
// All client-side so the board is fully interactive.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export type StageKey =
|
||||||
|
| "new" | "contacted" | "appt" | "estimate" | "signed" | "progress" | "complete"
|
||||||
|
| "stuck" | "followup";
|
||||||
|
|
||||||
|
export type Stage = { key: StageKey; label: string; tone: string; flag?: boolean };
|
||||||
|
|
||||||
|
export const STAGES: Stage[] = [
|
||||||
|
{ key: "new", label: "New Lead", tone: "blue" },
|
||||||
|
{ key: "contacted", label: "Contacted", tone: "purple" },
|
||||||
|
{ key: "appt", label: "Appt Scheduled", tone: "magenta" },
|
||||||
|
{ key: "estimate", label: "Estimate Sent", tone: "orange" },
|
||||||
|
{ key: "signed", label: "Signed", tone: "green" },
|
||||||
|
{ key: "progress", label: "In Progress", tone: "cyan" },
|
||||||
|
{ key: "complete", label: "Complete", tone: "green" },
|
||||||
|
{ key: "stuck", label: "Stuck", tone: "red", flag: true },
|
||||||
|
{ key: "followup", label: "Follow-Up", tone: "orange", flag: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
// The linear progression — these are the "7 stages" shown in the header.
|
||||||
|
export const PROGRESSION: StageKey[] = ["new", "contacted", "appt", "estimate", "signed", "progress", "complete"];
|
||||||
|
|
||||||
|
const STAGE_LABEL: Record<StageKey, string> = Object.fromEntries(STAGES.map((s) => [s.key, s.label])) as Record<StageKey, string>;
|
||||||
|
|
||||||
|
/** Percent-complete for the drawer progress bar (0–100), based on furthest progression reached. */
|
||||||
|
export function pctFor(reached: StageKey): number {
|
||||||
|
const i = PROGRESSION.indexOf(reached);
|
||||||
|
if (i < 0) return 0;
|
||||||
|
return Math.round(((i + 1) / PROGRESSION.length) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Phone = { number: string; type: string; primary?: boolean };
|
||||||
|
|
||||||
|
export type Insurance = {
|
||||||
|
company: string; claimStatus: string; claimNumber: string;
|
||||||
|
policyNumber: string; adjusterName: string; adjusterPhone: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Assignment = {
|
||||||
|
assignedTo: string; priority: string; followUp: string;
|
||||||
|
createdBy: string; createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PLead = {
|
||||||
|
id: string;
|
||||||
|
initials: string;
|
||||||
|
name: string;
|
||||||
|
gradient: string;
|
||||||
|
stage: StageKey; // which column it currently sits in
|
||||||
|
reached: StageKey; // furthest progression stage reached (== stage for progression columns)
|
||||||
|
address: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zip: string;
|
||||||
|
workType: string; // "Roof Replacement"
|
||||||
|
leadType: string; // "Insurance" | "Retail"
|
||||||
|
rep: string; // "Cody Tatum" | "Unassigned"
|
||||||
|
storm: boolean;
|
||||||
|
ageDays: number; // days in pipeline → shown as "59d"
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
notes: string;
|
||||||
|
createdAt: string; // "Feb 10, 2026"
|
||||||
|
|
||||||
|
// --- detail page only (the board + drawer ignore these) ---
|
||||||
|
propertyType: string; // "Single Family"
|
||||||
|
phones: Phone[]; // primary mobile + a secondary line
|
||||||
|
source: string; // "Door Knock"
|
||||||
|
tradeType: string; // derived from workType
|
||||||
|
urgency: string; // "Standard" | "High" | "Emergency"
|
||||||
|
insurance: Insurance | null; // null for Retail jobs — no claim to track
|
||||||
|
assignment: Assignment;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Activity = { from?: string; to: string; by: string; date: string; note: string };
|
||||||
|
|
||||||
|
const G = [
|
||||||
|
"linear-gradient(135deg,#fda913,#fd6d13)",
|
||||||
|
"linear-gradient(135deg,#4f8cff,#2c5cff)",
|
||||||
|
"linear-gradient(135deg,#b07bf2,#7b53e0)",
|
||||||
|
"linear-gradient(135deg,#33c98a,#1fa46c)",
|
||||||
|
"linear-gradient(135deg,#34c9d6,#1f9aa4)",
|
||||||
|
"linear-gradient(135deg,#f0563f,#c9372a)",
|
||||||
|
"linear-gradient(135deg,#d452c8,#a02f97)",
|
||||||
|
];
|
||||||
|
|
||||||
|
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
||||||
|
// Fixed reference "today" — deterministic (no Date.now) so server and client render identically.
|
||||||
|
const BASE = Date.UTC(2026, 6, 24);
|
||||||
|
const DAY = 86_400_000;
|
||||||
|
|
||||||
|
function fmt(ts: number): string {
|
||||||
|
const d = new Date(ts);
|
||||||
|
return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`;
|
||||||
|
}
|
||||||
|
function initialsOf(name: string): string {
|
||||||
|
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase();
|
||||||
|
}
|
||||||
|
function emailOf(name: string): string {
|
||||||
|
const [first, last] = name.toLowerCase().split(/\s+/);
|
||||||
|
return `${first[0]}${last}@gmail.com`;
|
||||||
|
}
|
||||||
|
const AREA = ["972", "469", "214"];
|
||||||
|
function phoneOf(i: number): string {
|
||||||
|
const area = AREA[i % AREA.length];
|
||||||
|
const mid = 200 + ((i * 37) % 700);
|
||||||
|
const end = 1000 + ((i * 53) % 8999);
|
||||||
|
return `(${area}) ${mid}-${end}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terse rows keep the file readable; full records are expanded below.
|
||||||
|
// [ name, stage, reached, address, workType, leadType, rep, storm, ageDays ]
|
||||||
|
type Row = [string, StageKey, StageKey, string, string, string, string, boolean, number];
|
||||||
|
|
||||||
|
const ROWS: Row[] = [
|
||||||
|
// New Lead
|
||||||
|
["Derek Holloway", "new", "new", "2814 Ravenswood Dr", "Roof Replacement", "Insurance", "Cody Tatum", true, 59],
|
||||||
|
["Brenda Castillo", "new", "new", "5501 Shady Brook Ln", "Roof Inspection", "Retail", "Unassigned", false, 58],
|
||||||
|
["Antonio Reyes", "new", "new", "1122 Custer Rd", "Gutter Repair", "Retail", "Hannah Reyes", false, 57],
|
||||||
|
["Sylvia Nguyen", "new", "new", "3308 Roundrock Trl", "Siding Repair", "Insurance", "Shelby Greer", true, 56],
|
||||||
|
["Raymond Osei", "new", "new", "4720 Preston Rd", "Roof Replacement", "Insurance", "Unassigned", false, 55],
|
||||||
|
["Carolyn Estrada", "new", "new", "6013 Ohio Dr", "Window Replacement","Retail", "Dalton Pruitt",false, 54],
|
||||||
|
// Contacted
|
||||||
|
["Marcus Tillman", "contacted", "contacted", "2201 Willow Bend Dr", "Roof Replacement", "Insurance", "Cody Tatum", true, 65],
|
||||||
|
["Diane Kowalski", "contacted", "contacted", "815 Independence Pkwy", "Roof Inspection", "Retail", "Hannah Reyes", false, 64],
|
||||||
|
["Joel Fernandez", "contacted", "contacted", "3901 Legacy Dr", "Siding Replacement","Insurance","Shelby Greer", true, 66],
|
||||||
|
["Patricia Yuen", "contacted", "contacted", "7742 Kings Rd", "Gutter Repair", "Retail", "Travis Boone", false, 63],
|
||||||
|
["Gerald Obi", "contacted", "contacted", "5220 Midway Rd", "Roof Replacement", "Insurance", "Cody Tatum", true, 68],
|
||||||
|
// Appt Scheduled
|
||||||
|
["Vanessa Obrien", "appt", "appt", "1450 Park Blvd", "Roof Replacement", "Insurance", "Shelby Greer", true, 72],
|
||||||
|
["Thomas Greer", "appt", "appt", "892 Mapleshade Ln", "Roof Inspection", "Retail", "Hannah Reyes", false, 70],
|
||||||
|
["Lashonda Webb", "appt", "appt", "3015 Plano Pkwy", "Siding + Gutters", "Insurance", "Cody Tatum", true, 75],
|
||||||
|
["Winston Park", "appt", "appt", "6601 Ohio Dr", "Roof Replacement", "Insurance", "Dalton Pruitt",true, 71],
|
||||||
|
["Ingrid Solis", "appt", "appt", "2244 Aster Ct", "Roof Replacement", "Retail", "Shelby Greer", false, 67],
|
||||||
|
["Darnell Brooks", "appt", "appt", "4406 Springbrook Dr", "Chimney + Roof Repair","Insurance","Hannah Reyes",false, 80],
|
||||||
|
// Estimate Sent
|
||||||
|
["Gloria Hutchins", "estimate", "estimate", "1738 Roundrock Trl", "Roof Replacement", "Insurance", "Cody Tatum", true, 85],
|
||||||
|
["Devon Mathis", "estimate", "estimate", "5902 Braewood Dr", "Siding Replacement","Insurance","Shelby Greer", true, 82],
|
||||||
|
["Karen Blankenship","estimate","estimate", "3127 Arbor Creek Dr","Roof Replacement", "Retail", "Hannah Reyes", false, 78],
|
||||||
|
["Lionel Chambers", "estimate", "estimate", "720 Coit Rd", "Gutters + Fascia", "Insurance", "Dalton Pruitt",false, 89],
|
||||||
|
["Ruthanne Patel", "estimate", "estimate", "4321 Hedgcoxe Rd", "Roof Replacement", "Insurance", "Cody Tatum", false, 98],
|
||||||
|
// Signed
|
||||||
|
["Charlotte Norris","signed", "signed", "2908 Roundabout Ln", "Roof Replacement", "Insurance", "Shelby Greer", true, 89],
|
||||||
|
["Henry Castaneda", "signed", "signed", "5614 Ridgewood Dr", "Siding Replacement","Insurance","Hannah Reyes", false, 90],
|
||||||
|
["Yvonne Blackwell","signed", "signed", "3401 Parker Rd", "Roof Replacement", "Retail", "Cody Tatum", false, 85],
|
||||||
|
["Emmanuel Okafor", "signed", "signed", "4812 Mapleshade Ln", "Roof + Gutters", "Insurance", "Dalton Pruitt",true, 87],
|
||||||
|
["Adriana Voss", "signed", "signed", "1820 Custer Rd", "Roof Replacement", "Insurance", "Shelby Greer", true, 103],
|
||||||
|
// In Progress
|
||||||
|
["Marvin Delgado", "progress", "progress", "6820 Windhaven Pkwy", "Roof Replacement", "Insurance", "Cody Tatum", true, 108],
|
||||||
|
["Sandra Tran", "progress", "progress", "2506 Springpark Dr", "Siding Replacement","Insurance","Hannah Reyes", false, 110],
|
||||||
|
["Reginald Hopper", "progress", "progress", "1504 Estates Dr", "Roof + Gutters", "Insurance", "Shelby Greer", false, 113],
|
||||||
|
["Nadia Thornton", "progress", "progress", "5150 Preston Rd", "Roof Replacement", "Retail", "Dalton Pruitt",false, 106],
|
||||||
|
["Clifford Aguilar","progress", "progress", "3622 Haverwood Ln", "Siding Repair", "Insurance", "Cody Tatum", true, 100],
|
||||||
|
["Tamara Owens", "progress", "progress", "2010 Oak Creek Blvd", "Roof Replacement", "Insurance", "Hannah Reyes", true, 117],
|
||||||
|
// Complete
|
||||||
|
["Craig Singleton", "complete", "complete", "4110 Lorimar Dr", "Roof Replacement", "Insurance", "Cody Tatum", false, 134],
|
||||||
|
["Bertha Fleming", "complete", "complete", "6230 West Parker Rd", "Siding Replacement","Insurance","Shelby Greer", false, 139],
|
||||||
|
["Oliver Stanton", "complete", "complete", "3750 Legacy Dr", "Roof Replacement", "Retail", "Hannah Reyes", false, 129],
|
||||||
|
["Daphne Garrett", "complete", "complete", "1912 Mira Vista Dr", "Gutters + Fascia", "Insurance", "Dalton Pruitt",false, 124],
|
||||||
|
["Nathaniel Cruz", "complete", "complete", "5808 Tennyson Pkwy", "Roof Replacement", "Insurance", "Shelby Greer", true, 144],
|
||||||
|
// Stuck (flag) — reached shows where they stalled
|
||||||
|
["Jasmine Fowler", "stuck", "estimate", "2730 Hedgcoxe Rd", "Roof Replacement", "Insurance", "Travis Boone", false, 93],
|
||||||
|
["Calvin Merritt", "stuck", "appt", "3640 Alma Dr", "Siding Replacement","Insurance","Hannah Reyes", false, 80],
|
||||||
|
["Harriet Simmons", "stuck", "contacted","4905 Ohio Dr", "Roof Replacement", "Retail", "Dalton Pruitt",false, 85],
|
||||||
|
// Follow-Up (flag)
|
||||||
|
["Floyd Saunders", "followup", "contacted","1344 Spring Creek Pkwy","Roof Replacement","Insurance","Cody Tatum", true, 75],
|
||||||
|
["Miriam Obando", "followup", "new", "2217 Independence Pkwy","Roof Inspection", "Retail", "Shelby Greer", false, 70],
|
||||||
|
["Jerome Watkins", "followup", "contacted","3908 Plano Pkwy", "Gutter Replacement","Insurance","Travis Boone",false, 82],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Per-lead note overrides keyed by name (the rest fall back to a stage template).
|
||||||
|
const NOTE_OVERRIDE: Record<string, string> = {
|
||||||
|
"Marvin Delgado": "Tear-off complete. New decking going on today.",
|
||||||
|
"Derek Holloway": "Homeowner reported hail damage after the April storm; door-knocked.",
|
||||||
|
"Jasmine Fowler": "Estimate sent but homeowner has gone quiet — three follow-ups, no reply.",
|
||||||
|
"Floyd Saunders": "Wants to wait until his insurance adjuster visits next week.",
|
||||||
|
};
|
||||||
|
|
||||||
|
function noteFor(reached: StageKey, storm: boolean): string {
|
||||||
|
switch (reached) {
|
||||||
|
case "new": return storm ? "Storm canvassing — fresh lead." : "Inbound lead — needs first contact.";
|
||||||
|
case "contacted": return "Spoke with homeowner; interest confirmed.";
|
||||||
|
case "appt": return "Inspection appointment scheduled.";
|
||||||
|
case "estimate": return "Estimate delivered; awaiting decision.";
|
||||||
|
case "signed": return "Contract signed; production queued.";
|
||||||
|
case "progress": return "Crew on site — job underway.";
|
||||||
|
case "complete": return "Job complete. Final invoice cleared.";
|
||||||
|
default: return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- detail-page derivations (deterministic, no Date.now) ---------- */
|
||||||
|
|
||||||
|
const INSURERS = ["State Farm", "Allstate", "Farmers", "USAA", "Liberty Mutual", "Travelers"];
|
||||||
|
const ADJUSTERS = ["Marcus Powell", "Dana Whitfield", "Renee Alvarez", "Curtis Boyd", "Priya Raman", "Gordon Leach"];
|
||||||
|
const SETTERS = ["Cody Tatum", "Shelby Greer", "Hannah Reyes", "Dalton Pruitt", "Travis Boone"];
|
||||||
|
const PROPERTY_TYPES = ["Single Family", "Single Family", "Single Family", "Townhome", "Multi-Family"];
|
||||||
|
|
||||||
|
// Claim state tracks how far the job has progressed.
|
||||||
|
const CLAIM_BY_STAGE: Record<StageKey, string> = {
|
||||||
|
new: "Not Filed", contacted: "Filed", appt: "Adjuster Scheduled",
|
||||||
|
estimate: "Under Review", signed: "Approved", progress: "Approved",
|
||||||
|
complete: "Paid", stuck: "Stalled", followup: "Filed",
|
||||||
|
};
|
||||||
|
|
||||||
|
function tradeOf(workType: string): string {
|
||||||
|
const hits = [
|
||||||
|
[/roof|chimney/i, "Roofing"],
|
||||||
|
[/siding/i, "Siding"],
|
||||||
|
[/gutter|fascia/i,"Gutters"],
|
||||||
|
[/window/i, "Windows"],
|
||||||
|
] as const;
|
||||||
|
const matched = hits.filter(([re]) => re.test(workType));
|
||||||
|
if (matched.length > 1) return "Multi-Trade";
|
||||||
|
return matched[0]?.[1] ?? "General";
|
||||||
|
}
|
||||||
|
|
||||||
|
function urgencyOf(storm: boolean, ageDays: number): string {
|
||||||
|
if (storm && ageDays > 100) return "Emergency";
|
||||||
|
return storm ? "High" : "Standard";
|
||||||
|
}
|
||||||
|
|
||||||
|
function priorityOf(storm: boolean, ageDays: number): string {
|
||||||
|
if (storm) return "High";
|
||||||
|
return ageDays >= 90 ? "Medium" : "Low";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LEADS: PLead[] = ROWS.map(([name, stage, reached, address, workType, leadType, rep, storm, ageDays], i) => ({
|
||||||
|
id: `SAL-${(101 + i).toString()}`,
|
||||||
|
initials: initialsOf(name),
|
||||||
|
name,
|
||||||
|
gradient: G[i % G.length],
|
||||||
|
stage,
|
||||||
|
reached,
|
||||||
|
address,
|
||||||
|
city: "Plano",
|
||||||
|
state: "TX",
|
||||||
|
zip: "750" + (21 + (i % 5)).toString().padStart(2, "0"),
|
||||||
|
workType,
|
||||||
|
leadType,
|
||||||
|
rep,
|
||||||
|
storm,
|
||||||
|
ageDays,
|
||||||
|
phone: phoneOf(i),
|
||||||
|
email: emailOf(name),
|
||||||
|
notes: NOTE_OVERRIDE[name] ?? noteFor(reached, storm),
|
||||||
|
createdAt: fmt(BASE - ageDays * DAY),
|
||||||
|
|
||||||
|
propertyType: PROPERTY_TYPES[i % PROPERTY_TYPES.length],
|
||||||
|
phones: [
|
||||||
|
{ number: phoneOf(i), type: "Mobile", primary: true },
|
||||||
|
{ number: phoneOf(i + 17), type: i % 3 === 0 ? "Work" : "Home" },
|
||||||
|
],
|
||||||
|
source: storm ? "Storm Canvass" : ["Door Knock", "Referral", "Inbound Call", "Web Form"][i % 4],
|
||||||
|
tradeType: tradeOf(workType),
|
||||||
|
urgency: urgencyOf(storm, ageDays),
|
||||||
|
insurance: leadType !== "Insurance" ? null : {
|
||||||
|
company: INSURERS[i % INSURERS.length],
|
||||||
|
claimStatus: CLAIM_BY_STAGE[stage],
|
||||||
|
claimNumber: `CLM-2026-${1100 + i}`,
|
||||||
|
policyNumber: `POL-08${1100 + i}`,
|
||||||
|
adjusterName: ADJUSTERS[i % ADJUSTERS.length],
|
||||||
|
adjusterPhone: phoneOf(i + 31),
|
||||||
|
},
|
||||||
|
assignment: {
|
||||||
|
assignedTo: rep,
|
||||||
|
priority: priorityOf(storm, ageDays),
|
||||||
|
followUp: fmt(BASE + (3 + (i % 7)) * DAY),
|
||||||
|
createdBy: SETTERS[i % SETTERS.length],
|
||||||
|
createdAt: fmt(BASE - ageDays * DAY),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** Build the drawer Activity trail from the stage a lead has reached. */
|
||||||
|
export function buildActivity(lead: PLead): Activity[] {
|
||||||
|
const by = lead.rep === "Unassigned" ? "System" : lead.rep;
|
||||||
|
const items: Activity[] = [];
|
||||||
|
const created = BASE - lead.ageDays * DAY;
|
||||||
|
items.push({ to: "Lead Created", by, date: fmt(created), note: lead.storm ? "Storm canvassing." : "Inbound lead." });
|
||||||
|
|
||||||
|
const reachedIdx = PROGRESSION.indexOf(lead.reached);
|
||||||
|
const stepNotes: Record<StageKey, string> = {
|
||||||
|
new: "", contacted: "Call completed.", appt: "Inspection scheduled.",
|
||||||
|
estimate: "Estimate sent.", signed: "Signed. Production team notified.",
|
||||||
|
progress: "Production started — crew on site.", complete: "Job complete. Final invoice cleared.",
|
||||||
|
stuck: "", followup: "",
|
||||||
|
};
|
||||||
|
const steps = Math.max(reachedIdx, 0);
|
||||||
|
for (let i = 0; i < steps; i++) {
|
||||||
|
const from = PROGRESSION[i];
|
||||||
|
const to = PROGRESSION[i + 1];
|
||||||
|
const when = created + Math.round(((i + 1) / (steps + 1)) * lead.ageDays) * DAY;
|
||||||
|
items.push({ from: STAGE_LABEL[from], to: STAGE_LABEL[to], by, date: fmt(when), note: stepNotes[to] });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lead.stage === "stuck") {
|
||||||
|
items.push({ from: STAGE_LABEL[lead.reached], to: "Stuck", by: "System", date: fmt(BASE - 3 * DAY), note: "Flagged — no response from homeowner." });
|
||||||
|
} else if (lead.stage === "followup") {
|
||||||
|
items.push({ from: STAGE_LABEL[lead.reached], to: "Follow-Up", by, date: fmt(BASE - 2 * DAY), note: "Moved to follow-up queue." });
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header stat — the loaded board is a slice of the full book.
|
||||||
|
export const TOTAL_LEADS = LEADS.length;
|
||||||
@@ -0,0 +1,510 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Pipeline — storm-restoration kanban board.
|
||||||
|
// · Head : title + counts, search
|
||||||
|
// · Board : horizontal stage columns (dot, count, progress
|
||||||
|
// bar). Cards are draggable between columns.
|
||||||
|
// · Card : avatar, name, address, work/lead-type pills,
|
||||||
|
// rep + storm badge + age.
|
||||||
|
// · Drawer : click a card → right slide-over with a stage
|
||||||
|
// stepper, Details (contact / job / assignment /
|
||||||
|
// notes / timeline) and an Activity trail.
|
||||||
|
// · Page : "More Details" in the drawer swaps the board for
|
||||||
|
// a full-width lead record (hero + Move Stage picker
|
||||||
|
// + stage stepper + Contact / Property / Job /
|
||||||
|
// Insurance / Assignment).
|
||||||
|
// Data comes from pipeline-data.ts (client-side mock).
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { useMemo, useState, type ReactNode } from "react";
|
||||||
|
import { Avatar, Btn, Icon, Pill, useToast } from "./ui";
|
||||||
|
import {
|
||||||
|
LEADS, STAGES, PROGRESSION, pctFor, buildActivity,
|
||||||
|
type PLead, type Stage, type StageKey,
|
||||||
|
} from "./pipeline-data";
|
||||||
|
|
||||||
|
export function Pipeline() {
|
||||||
|
const toast = useToast();
|
||||||
|
const [leads, setLeads] = useState<PLead[]>(LEADS);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [selected, setSelected] = useState<PLead | null>(null);
|
||||||
|
// Full-page detail is keyed by id (not a snapshot) so a stage move stays in sync.
|
||||||
|
const [detailId, setDetailId] = useState<string | null>(null);
|
||||||
|
const [dragId, setDragId] = useState<string | null>(null);
|
||||||
|
const [overStage, setOverStage] = useState<StageKey | null>(null);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return leads;
|
||||||
|
return leads.filter((l) =>
|
||||||
|
`${l.name} ${l.address} ${l.rep} ${l.workType} ${l.id}`.toLowerCase().includes(q));
|
||||||
|
}, [leads, query]);
|
||||||
|
|
||||||
|
const byStage = (key: StageKey) => filtered.filter((l) => l.stage === key);
|
||||||
|
|
||||||
|
// Single source of truth for a stage change — used by the board's drag-drop
|
||||||
|
// and by the detail page's "Move Stage" picker.
|
||||||
|
function setStage(id: string, stage: StageKey) {
|
||||||
|
const lead = leads.find((l) => l.id === id);
|
||||||
|
if (!lead || lead.stage === stage) return;
|
||||||
|
setLeads((prev) =>
|
||||||
|
prev.map((l) => {
|
||||||
|
if (l.id !== id) return l;
|
||||||
|
// Landing on a progression column advances "reached" if it's further along.
|
||||||
|
const inProg = PROGRESSION.indexOf(stage);
|
||||||
|
const reached = inProg > PROGRESSION.indexOf(l.reached) ? stage : l.reached;
|
||||||
|
return { ...l, stage, reached };
|
||||||
|
}));
|
||||||
|
const label = STAGES.find((s) => s.key === stage)?.label ?? stage;
|
||||||
|
toast.push({ tone: "success", title: "Lead moved", desc: `${lead.name} → ${label}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveTo(stage: StageKey) {
|
||||||
|
if (dragId) setStage(dragId, stage);
|
||||||
|
setDragId(null);
|
||||||
|
setOverStage(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stageCount = STAGES.filter((s) => !s.flag).length;
|
||||||
|
|
||||||
|
// Detail takes over the whole view — the board stays mounted behind it in state only.
|
||||||
|
const detail = detailId ? leads.find((l) => l.id === detailId) ?? null : null;
|
||||||
|
if (detail) {
|
||||||
|
return (
|
||||||
|
<LeadDetailPage
|
||||||
|
lead={detail}
|
||||||
|
onBack={() => setDetailId(null)}
|
||||||
|
onMoveStage={(stage) => setStage(detail.id, stage)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="view pl">
|
||||||
|
{/* ---- head -------------------------------------------- */}
|
||||||
|
<div className="pl-head">
|
||||||
|
<div className="pl-head-l">
|
||||||
|
<span className="pl-head-ic"><Icon name="pipeline" size={20} /></span>
|
||||||
|
<div>
|
||||||
|
<h1>Pipeline</h1>
|
||||||
|
<p>{leads.length} leads · {stageCount} stages</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pl-search">
|
||||||
|
<Icon name="search" size={16} />
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search leads…"
|
||||||
|
aria-label="Search leads"
|
||||||
|
/>
|
||||||
|
{query && <button className="pl-search-x" aria-label="Clear" onClick={() => setQuery("")}><Icon name="x" size={14} /></button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ---- board ------------------------------------------- */}
|
||||||
|
<div className="pl-board">
|
||||||
|
{STAGES.map((stage) => {
|
||||||
|
const items = byStage(stage.key);
|
||||||
|
const step = PROGRESSION.indexOf(stage.key);
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={stage.key}
|
||||||
|
className={`pl-col ${stage.flag ? "is-flag" : ""} ${overStage === stage.key ? "is-over" : ""}`}
|
||||||
|
data-tone={stage.tone}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); setOverStage(stage.key); }}
|
||||||
|
onDragLeave={() => setOverStage((s) => (s === stage.key ? null : s))}
|
||||||
|
onDrop={() => moveTo(stage.key)}
|
||||||
|
>
|
||||||
|
<header className="pl-col-head">
|
||||||
|
<span className="pl-col-dot" />
|
||||||
|
<span className="pl-col-label">{stage.label}</span>
|
||||||
|
<span className="pl-col-count">{items.length}</span>
|
||||||
|
{step >= 0 && <span className="pl-col-step">{step + 1}/{stageCount}</span>}
|
||||||
|
</header>
|
||||||
|
<div className="pl-col-bar"><span style={{ width: step >= 0 ? `${((step + 1) / stageCount) * 100}%` : "100%" }} /></div>
|
||||||
|
|
||||||
|
<div className="pl-col-body">
|
||||||
|
{items.map((l) => (
|
||||||
|
<PipelineCard
|
||||||
|
key={l.id}
|
||||||
|
lead={l}
|
||||||
|
onOpen={() => setSelected(l)}
|
||||||
|
onDragStart={() => setDragId(l.id)}
|
||||||
|
onDragEnd={() => { setDragId(null); setOverStage(null); }}
|
||||||
|
dragging={dragId === l.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{items.length === 0 && <div className="pl-col-empty">Drop leads here</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LeadDrawer
|
||||||
|
lead={selected}
|
||||||
|
onClose={() => setSelected(null)}
|
||||||
|
onMore={() => { setDetailId(selected?.id ?? null); setSelected(null); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------- */
|
||||||
|
/* Card */
|
||||||
|
/* ---------------------------------------------------------- */
|
||||||
|
|
||||||
|
function PipelineCard({ lead, onOpen, onDragStart, onDragEnd, dragging }: {
|
||||||
|
lead: PLead; onOpen: () => void; onDragStart: () => void; onDragEnd: () => void; dragging: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={`pl-card ${dragging ? "is-dragging" : ""}`}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => { e.dataTransfer.effectAllowed = "move"; onDragStart(); }}
|
||||||
|
onDragEnd={onDragEnd}
|
||||||
|
onClick={onOpen}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onOpen(); } }}
|
||||||
|
>
|
||||||
|
<div className="pl-card-top">
|
||||||
|
<Avatar initials={lead.initials} gradient={lead.gradient} size={38} square />
|
||||||
|
<div className="pl-card-id">
|
||||||
|
<div className="pl-card-name">{lead.name}</div>
|
||||||
|
<div className="pl-card-addr"><Icon name="pin" size={12} /> {lead.address}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pl-card-tags">
|
||||||
|
<span className="pl-tag work">{lead.workType}</span>
|
||||||
|
<span className="pl-tag type">{lead.leadType}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pl-card-foot">
|
||||||
|
<span className="pl-rep"><Icon name="user" size={12} /> {lead.rep}</span>
|
||||||
|
<span className="pl-card-spacer" />
|
||||||
|
{lead.storm && <span className="pl-storm"><Icon name="storm" size={12} /> Storm</span>}
|
||||||
|
<span className="pl-age"><Icon name="clock" size={12} /> {lead.ageDays}d</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------- */
|
||||||
|
/* Detail drawer */
|
||||||
|
/* ---------------------------------------------------------- */
|
||||||
|
|
||||||
|
function LeadDrawer({ lead, onClose, onMore }: { lead: PLead | null; onClose: () => void; onMore: () => void }) {
|
||||||
|
const [tab, setTab] = useState<"details" | "activity">("details");
|
||||||
|
if (!lead) return null;
|
||||||
|
|
||||||
|
const stage = STAGES.find((s) => s.key === lead.stage);
|
||||||
|
const pct = pctFor(lead.reached);
|
||||||
|
const reachedIdx = PROGRESSION.indexOf(lead.reached);
|
||||||
|
const progStages = STAGES.filter((s) => PROGRESSION.includes(s.key));
|
||||||
|
const activity = buildActivity(lead);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="pl-scrim" onClick={onClose} />
|
||||||
|
<aside className="pl-drawer" data-tone={stage?.tone} role="dialog" aria-label={`${lead.name} details`}>
|
||||||
|
<div className="pl-drawer-head">
|
||||||
|
<Avatar initials={lead.initials} gradient={lead.gradient} size={44} square />
|
||||||
|
<div className="pl-drawer-id">
|
||||||
|
<div className="pl-drawer-name">{lead.name}</div>
|
||||||
|
<div className="pl-drawer-addr">{lead.address}, {lead.city} {lead.state} {lead.zip}</div>
|
||||||
|
</div>
|
||||||
|
<button className="pl-drawer-x" aria-label="Close" onClick={onClose}><Icon name="x" size={18} /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* status + stepper */}
|
||||||
|
<div className="pl-drawer-status">
|
||||||
|
<div className="pl-status-row">
|
||||||
|
<span className="pl-status-label">{stage?.label}</span>
|
||||||
|
<span className="pl-status-pct">{pct}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="pl-status-bar"><span style={{ width: `${pct}%` }} /></div>
|
||||||
|
<div className="pl-steps">
|
||||||
|
{progStages.map((s, i) => (
|
||||||
|
<span key={s.key} className={`pl-step ${i <= reachedIdx ? "done" : ""}`} data-tone={s.tone}>
|
||||||
|
<i className="pl-step-dot" />
|
||||||
|
<span className="pl-step-label">{s.label}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pl-drawer-tabs" role="tablist">
|
||||||
|
<button role="tab" aria-selected={tab === "details"} className={tab === "details" ? "active" : ""} onClick={() => setTab("details")}>Details</button>
|
||||||
|
<button role="tab" aria-selected={tab === "activity"} className={tab === "activity" ? "active" : ""} onClick={() => setTab("activity")}>Activity</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pl-drawer-body">
|
||||||
|
{tab === "details" ? (
|
||||||
|
<>
|
||||||
|
<DSection title="Contact">
|
||||||
|
<div className="pl-contact"><Icon name="phone" size={14} /> {lead.phone}</div>
|
||||||
|
<div className="pl-contact"><Icon name="mail" size={14} /> {lead.email}</div>
|
||||||
|
<div className="pl-contact"><Icon name="pin" size={14} /> {lead.address}, {lead.city} {lead.state} {lead.zip}</div>
|
||||||
|
</DSection>
|
||||||
|
|
||||||
|
<DSection title="Job Details">
|
||||||
|
<div className="pl-jobgrid">
|
||||||
|
<div className="pl-jobbox"><span className="pl-jobk">Type</span><span className="pl-jobv">{lead.workType}</span></div>
|
||||||
|
<div className="pl-jobbox"><span className="pl-jobk">Category</span><span className="pl-jobv">{lead.leadType}</span></div>
|
||||||
|
</div>
|
||||||
|
</DSection>
|
||||||
|
|
||||||
|
<DSection title="Assignment">
|
||||||
|
<div className="pl-assign"><Icon name="user" size={14} /> {lead.rep}</div>
|
||||||
|
</DSection>
|
||||||
|
|
||||||
|
<DSection title="Notes">
|
||||||
|
<div className="pl-notes">{lead.notes}</div>
|
||||||
|
</DSection>
|
||||||
|
|
||||||
|
<DSection title="Timeline">
|
||||||
|
<div className="pl-contact"><Icon name="clock" size={14} /> Created {lead.createdAt}</div>
|
||||||
|
</DSection>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="pl-activity">
|
||||||
|
{activity.map((a, i) => (
|
||||||
|
<div className="pl-act" key={i}>
|
||||||
|
<span className="pl-act-node" />
|
||||||
|
<div className="pl-act-body">
|
||||||
|
<div className="pl-act-head">
|
||||||
|
{a.from ? <><span className="pl-act-from">{a.from}</span> <Icon name="arrow" size={12} /> <span className="pl-act-to">{a.to}</span></> : <span className="pl-act-to">{a.to}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="pl-act-meta">by {a.by} · {a.date}</div>
|
||||||
|
{a.note && <div className="pl-act-note">{a.note}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pl-drawer-foot">
|
||||||
|
<button className="pl-more" onClick={onMore}>
|
||||||
|
<Icon name="arrow" size={15} /> More Details
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DSection({ title, children }: { title: string; children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="pl-dsec">
|
||||||
|
<div className="pl-dsec-head">{title}</div>
|
||||||
|
<div className="pl-dsec-body">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------- */
|
||||||
|
/* Full-page lead detail — opened from the drawer's */
|
||||||
|
/* "More Details". Takes over the Pipeline view; the board */
|
||||||
|
/* is one "← Pipeline" click away. */
|
||||||
|
/* ---------------------------------------------------------- */
|
||||||
|
|
||||||
|
function LeadDetailPage({ lead, onBack, onMoveStage }: {
|
||||||
|
lead: PLead; onBack: () => void; onMoveStage: (stage: StageKey) => void;
|
||||||
|
}) {
|
||||||
|
const toast = useToast();
|
||||||
|
const [moveOpen, setMoveOpen] = useState(false);
|
||||||
|
const stage = STAGES.find((s) => s.key === lead.stage);
|
||||||
|
const pct = pctFor(lead.reached);
|
||||||
|
const reachedIdx = PROGRESSION.indexOf(lead.reached);
|
||||||
|
const progStages = STAGES.filter((s) => PROGRESSION.includes(s.key));
|
||||||
|
const primaryPhone = lead.phones.find((p) => p.primary) ?? lead.phones[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="view pld" data-tone={stage?.tone}>
|
||||||
|
{/* ---- breadcrumb -------------------------------------- */}
|
||||||
|
<nav className="pld-crumbs" aria-label="Breadcrumb">
|
||||||
|
<button className="pld-back" onClick={onBack}>
|
||||||
|
<Icon name="arrow" size={15} className="pld-back-ic" /> Pipeline
|
||||||
|
</button>
|
||||||
|
<Icon name="chevron-right" size={14} />
|
||||||
|
<span className="pld-crumb-now">{lead.name}</span>
|
||||||
|
<span className="pld-crumb-id">{lead.id}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* ---- hero -------------------------------------------- */}
|
||||||
|
<header className="pld-hero">
|
||||||
|
<Avatar initials={lead.initials} gradient={lead.gradient} size={64} square />
|
||||||
|
<div className="pld-hero-id">
|
||||||
|
<h1 className="pld-hero-name">{lead.name}</h1>
|
||||||
|
<div className="pld-hero-addr">
|
||||||
|
<Icon name="pin" size={13} /> {lead.address}, {lead.city} {lead.state} {lead.zip}
|
||||||
|
</div>
|
||||||
|
<div className="pld-hero-pills">
|
||||||
|
<Pill tone={stage?.tone ?? "muted"}>{stage?.label}</Pill>
|
||||||
|
<Pill tone="blue">{lead.leadType}</Pill>
|
||||||
|
<Pill tone="muted">{lead.workType}</Pill>
|
||||||
|
{lead.storm && <Pill tone="orange"><Icon name="storm" size={11} /> Storm</Pill>}
|
||||||
|
<Pill tone="muted"><Icon name="clock" size={11} /> {lead.ageDays}d in pipeline</Pill>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pld-hero-actions">
|
||||||
|
<StagePicker current={lead.stage} open={moveOpen} onToggle={setMoveOpen} onPick={onMoveStage} />
|
||||||
|
<Btn variant="outline" icon="phone" onClick={() => toast.push({ tone: "info", title: "Call", desc: `${lead.name} · ${primaryPhone?.number}` })}>Call</Btn>
|
||||||
|
<Btn variant="outline" icon="mail" onClick={() => toast.push({ tone: "info", title: "Email", desc: lead.email })}>Email</Btn>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ---- stage progress ---------------------------------- */}
|
||||||
|
<section className="pld-progress">
|
||||||
|
<div className="pld-progress-row">
|
||||||
|
<span className="pld-progress-label">{stage?.label}</span>
|
||||||
|
<span className="pld-progress-pct">{pct}% complete</span>
|
||||||
|
</div>
|
||||||
|
<div className="pld-progress-bar"><span style={{ width: `${pct}%` }} /></div>
|
||||||
|
<ol className="pld-steps">
|
||||||
|
{progStages.map((s, i) => (
|
||||||
|
<li
|
||||||
|
key={s.key}
|
||||||
|
className={`pld-step ${i <= reachedIdx ? "done" : ""} ${i === reachedIdx ? "now" : ""}`}
|
||||||
|
data-tone={s.tone}
|
||||||
|
>
|
||||||
|
<span className="pld-step-dot">{i < reachedIdx ? <Icon name="check" size={11} /> : i + 1}</span>
|
||||||
|
<span className="pld-step-label">{s.label}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ---- overview ---------------------------------------- */}
|
||||||
|
<div className="pld-grid">
|
||||||
|
<PSection title="Contact" icon="user">
|
||||||
|
<div className="pld-sublabel">Phone Numbers</div>
|
||||||
|
{lead.phones.map((p, i) => (
|
||||||
|
<div className="pld-contact" key={i}>
|
||||||
|
<Icon name="phone" size={14} />
|
||||||
|
<span className="pld-contact-val">{p.number}</span>
|
||||||
|
<span className="pld-contact-tag">{p.type}</span>
|
||||||
|
{p.primary && <Pill tone="green">Primary</Pill>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="pld-sublabel">Email Address</div>
|
||||||
|
<div className="pld-contact">
|
||||||
|
<Icon name="mail" size={14} />
|
||||||
|
<span className="pld-contact-val">{lead.email}</span>
|
||||||
|
<Pill tone="green">Primary</Pill>
|
||||||
|
</div>
|
||||||
|
</PSection>
|
||||||
|
|
||||||
|
<PSection title="Property" icon="owners">
|
||||||
|
<Kv label="Address" value={lead.address} />
|
||||||
|
<Kv label="City" value={lead.city} />
|
||||||
|
<Kv label="State" value={lead.state} />
|
||||||
|
<Kv label="ZIP" value={lead.zip} />
|
||||||
|
<Kv label="Property Type" value={lead.propertyType} />
|
||||||
|
</PSection>
|
||||||
|
|
||||||
|
<PSection title="Job Details" icon="projects">
|
||||||
|
<Kv label="Lead Source" value={lead.source} />
|
||||||
|
<Kv label="Lead Type" value={lead.leadType} />
|
||||||
|
<Kv label="Work Type" value={lead.workType} />
|
||||||
|
<Kv label="Trade Type" value={lead.tradeType} />
|
||||||
|
<Kv label="Urgency" value={lead.urgency} />
|
||||||
|
<Kv label="Sales Rep" value={lead.rep} />
|
||||||
|
<div className="pld-sublabel">Field Notes</div>
|
||||||
|
<p className="pld-notes">{lead.notes}</p>
|
||||||
|
</PSection>
|
||||||
|
|
||||||
|
<PSection title="Insurance" icon="shield">
|
||||||
|
{lead.insurance ? (
|
||||||
|
<>
|
||||||
|
<Kv label="Insurance Company" value={lead.insurance.company} />
|
||||||
|
<Kv label="Claim Status" value={lead.insurance.claimStatus} />
|
||||||
|
<Kv label="Claim Number" value={lead.insurance.claimNumber} />
|
||||||
|
<Kv label="Policy Number" value={lead.insurance.policyNumber} />
|
||||||
|
<Kv label="Adjuster Name" value={lead.insurance.adjusterName} />
|
||||||
|
<Kv label="Adjuster Phone" value={lead.insurance.adjusterPhone} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="pld-empty">
|
||||||
|
<Icon name="shield" size={22} />
|
||||||
|
<p>Retail job — no insurance claim on file.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PSection>
|
||||||
|
|
||||||
|
<PSection title="Assignment" icon="team" wide>
|
||||||
|
<div className="pld-assign">
|
||||||
|
<Kv label="Assigned To" value={lead.assignment.assignedTo} />
|
||||||
|
<Kv label="Priority" value={lead.assignment.priority} />
|
||||||
|
<Kv label="Follow-Up Date" value={lead.assignment.followUp} />
|
||||||
|
<Kv label="Created By" value={lead.assignment.createdBy} />
|
||||||
|
<Kv label="Created At" value={lead.assignment.createdAt} />
|
||||||
|
</div>
|
||||||
|
</PSection>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Move Stage" dropdown — the detail-page equivalent of dragging a card. */
|
||||||
|
function StagePicker({ current, open, onToggle, onPick }: {
|
||||||
|
current: StageKey; open: boolean; onToggle: (open: boolean) => void; onPick: (stage: StageKey) => void;
|
||||||
|
}) {
|
||||||
|
const groups: { title: string; stages: Stage[] }[] = [
|
||||||
|
{ title: "Pipeline stages", stages: STAGES.filter((s) => !s.flag) },
|
||||||
|
{ title: "Flags", stages: STAGES.filter((s) => s.flag) },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pld-move" onKeyDown={(e) => { if (e.key === "Escape") onToggle(false); }}>
|
||||||
|
<Btn icon="pipeline" iconRight="chevron" onClick={() => onToggle(!open)}>Move Stage</Btn>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<div className="pld-move-scrim" onClick={() => onToggle(false)} />
|
||||||
|
<div className="pld-move-menu" role="menu" aria-label="Move to stage">
|
||||||
|
{groups.map((g) => (
|
||||||
|
<div className="pld-move-group" key={g.title}>
|
||||||
|
<div className="pld-move-grouphead">{g.title}</div>
|
||||||
|
{g.stages.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.key}
|
||||||
|
role="menuitem"
|
||||||
|
data-tone={s.tone}
|
||||||
|
className={`pld-move-item ${s.key === current ? "is-current" : ""}`}
|
||||||
|
disabled={s.key === current}
|
||||||
|
onClick={() => { onPick(s.key); onToggle(false); }}
|
||||||
|
>
|
||||||
|
<span className="pld-move-dot" />
|
||||||
|
<span className="pld-move-label">{s.label}</span>
|
||||||
|
{s.key === current && <Icon name="check" size={14} />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PSection({ title, icon, children, wide }: { title: string; icon: string; children: ReactNode; wide?: boolean }) {
|
||||||
|
return (
|
||||||
|
<section className={`pld-sec ${wide ? "wide" : ""}`}>
|
||||||
|
<div className="pld-sec-head"><Icon name={icon} size={15} /> {title}</div>
|
||||||
|
<div className="pld-sec-body">{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Kv({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="pld-kv">
|
||||||
|
<span className="pld-kv-k">{label}</span>
|
||||||
|
<span className="pld-kv-v">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,14 +10,13 @@ export type VStatus = "verified" | "in_progress" | "assigned" | "pending" | "unv
|
|||||||
export type VActivity = { text: string; time: string; who: string };
|
export type VActivity = { text: string; time: string; who: string };
|
||||||
|
|
||||||
export type VLead = {
|
export type VLead = {
|
||||||
id: string; // LD-V-001 (display code)
|
id: string;
|
||||||
refId?: string; // opaque server id (live mode); commands target this — falls back to `id`
|
|
||||||
initials: string;
|
initials: string;
|
||||||
name: string;
|
name: string;
|
||||||
address: string;
|
address: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
source: string;
|
source: string;
|
||||||
assignee: { id?: string; initials: string; name: string } | null;
|
assignee: { initials: string; name: string } | null;
|
||||||
status: VStatus;
|
status: VStatus;
|
||||||
verification: string; // sub-status text
|
verification: string; // sub-status text
|
||||||
created: string;
|
created: string;
|
||||||
|
|||||||
@@ -16,8 +16,7 @@
|
|||||||
import { useMemo, useState, useEffect } from "react";
|
import { useMemo, useState, useEffect } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { Avatar, Btn, Icon, Modal, PageHead, Pill, useToast } from "./ui";
|
import { Avatar, Btn, Icon, Modal, PageHead, Pill, useToast } from "./ui";
|
||||||
import { V_STATUS_META, V_SOURCES, type VStatus, type VLead, type VActivity } from "./verify-data";
|
import { V_LEADS, V_STATUS_META, V_SOURCES, V_ASSIGNEES, type VStatus, type VLead, type VActivity } from "./verify-data";
|
||||||
import { useVerifyData, type Assignee } from "@/lib/verify-api";
|
|
||||||
|
|
||||||
const STAT_ORDER: VStatus[] = ["verified", "in_progress", "assigned", "pending", "unverified"];
|
const STAT_ORDER: VStatus[] = ["verified", "in_progress", "assigned", "pending", "unverified"];
|
||||||
const STAT_ICON: Record<VStatus, string> = {
|
const STAT_ICON: Record<VStatus, string> = {
|
||||||
@@ -43,71 +42,37 @@ function buildActivity(l: VLead): VActivity[] {
|
|||||||
|
|
||||||
type MenuState = { lead: VLead; x: number; y: number } | null;
|
type MenuState = { lead: VLead; x: number; y: number } | null;
|
||||||
|
|
||||||
// Which transitions the backend accepts from a given status. `verified` is re-openable now, so the
|
|
||||||
// only guard is "you can't move a record to the status it's already in" — every other transition is
|
|
||||||
// allowed and the backend has the final say (a rejected one surfaces a friendly 409 + auto-refresh).
|
|
||||||
function allowed(status: VStatus) {
|
|
||||||
return {
|
|
||||||
verify: status !== "verified",
|
|
||||||
unverify: status !== "unverified",
|
|
||||||
moveToPending: status !== "pending",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Verify() {
|
export function Verify() {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const data = useVerifyData();
|
|
||||||
const { leads, total, counts, assignees, loading, error } = data;
|
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [status, setStatus] = useState("all");
|
const [status, setStatus] = useState("all");
|
||||||
const [source, setSource] = useState("all");
|
const [source, setSource] = useState("all");
|
||||||
const [assignee, setAssignee] = useState("all");
|
const [assignee, setAssignee] = useState("all");
|
||||||
const [selected, setSelected] = useState<VLead | null>(null);
|
const [selected, setSelected] = useState<VLead | null>(null);
|
||||||
const [menu, setMenu] = useState<MenuState>(null);
|
const [menu, setMenu] = useState<MenuState>(null);
|
||||||
const [assignFor, setAssignFor] = useState<VLead | null>(null);
|
|
||||||
|
const counts = useMemo(() => {
|
||||||
|
const m: Record<string, number> = {};
|
||||||
|
for (const l of V_LEADS) m[l.status] = (m[l.status] ?? 0) + 1;
|
||||||
|
return m;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
return leads.filter((l) => {
|
return V_LEADS.filter((l) => {
|
||||||
const matchQ = !q || `${l.name} ${l.id} ${l.phone} ${l.source} ${l.address}`.toLowerCase().includes(q);
|
const matchQ = !q || `${l.name} ${l.id} ${l.phone} ${l.source} ${l.address}`.toLowerCase().includes(q);
|
||||||
const matchStatus = status === "all" || l.status === status;
|
const matchStatus = status === "all" || l.status === status;
|
||||||
const matchSource = source === "all" || l.source === source;
|
const matchSource = source === "all" || l.source === source;
|
||||||
const matchAssignee = assignee === "all" || l.assignee?.name === assignee;
|
const matchAssignee = assignee === "all" || l.assignee?.name === assignee;
|
||||||
return matchQ && matchStatus && matchSource && matchAssignee;
|
return matchQ && matchStatus && matchSource && matchAssignee;
|
||||||
});
|
});
|
||||||
}, [leads, query, status, source, assignee]);
|
}, [query, status, source, assignee]);
|
||||||
|
|
||||||
// Run a command, surface success/failure as a toast, and close any open menu.
|
function act(l: VLead, title: string, desc: string, tone: "success" | "info" = "info") {
|
||||||
async function run(work: Promise<void>, title: string, desc: string) {
|
|
||||||
setMenu(null);
|
setMenu(null);
|
||||||
try {
|
toast.push({ tone, title, desc });
|
||||||
await work;
|
|
||||||
toast.push({ tone: "success", title, desc });
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : "";
|
|
||||||
// 409 = the record's current state doesn't allow this transition (e.g. it's already
|
|
||||||
// verified/unverified but the list read was stale). Refetch so the row shows its real status.
|
|
||||||
const conflict = /\b409\b/.test(msg);
|
|
||||||
if (conflict) data.refetch();
|
|
||||||
const friendly = conflict ? "This lead's status already changed — refreshing the queue." : (msg || "Please try again.");
|
|
||||||
toast.push({ tone: "error", title: "Action failed", desc: friendly });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const doVerify = (l: VLead) => run(data.verify(l), "Verified", `${l.name} verified and pushed to New Leads.`);
|
|
||||||
// markUnverified (#17) carries a reason — send a default so the command satisfies a required field.
|
|
||||||
const doUnverify = (l: VLead) => run(data.markUnverified(l, "Marked unverified from the verification desk."), "Marked unverified", `${l.name} moved to Unverified.`);
|
|
||||||
const doPending = (l: VLead) => run(data.moveToPending(l), "Moved to pending", `${l.name} is now Pending review.`);
|
|
||||||
// assign (#11) requires an assigneeId — pick one, then run the command.
|
|
||||||
const doAssign = (l: VLead, a: Assignee) => run(data.assign(l, a.id), "Assignee changed", `${l.name} assigned to ${a.name}.`);
|
|
||||||
|
|
||||||
// Open a row with its list data, then hydrate detail (notes/activity) — ignore hydration errors.
|
|
||||||
const openDetail = (l: VLead) => {
|
|
||||||
setSelected(l);
|
|
||||||
setMenu(null);
|
|
||||||
data.getVerification(l).then((full) => setSelected((cur) => (cur && cur.id === l.id ? full : cur))).catch(() => {});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="view lv">
|
<div className="view lv">
|
||||||
<PageHead
|
<PageHead
|
||||||
@@ -115,7 +80,7 @@ export function Verify() {
|
|||||||
title="Lead Verification"
|
title="Lead Verification"
|
||||||
subtitle="Identity & insurance checks before a lead becomes a working deal."
|
subtitle="Identity & insurance checks before a lead becomes a working deal."
|
||||||
icon="verify"
|
icon="verify"
|
||||||
actions={<Btn variant="outline" icon="refresh" onClick={() => { data.refetch(); toast.push({ tone: "info", title: "Queue refreshed", desc: "Verification queue is up to date." }); }}>Refresh</Btn>}
|
actions={<Btn variant="outline" icon="refresh" onClick={() => toast.push({ tone: "info", title: "Queue refreshed", desc: "Verification queue is up to date." })}>Refresh</Btn>}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ---- stat tiles ---- */}
|
{/* ---- stat tiles ---- */}
|
||||||
@@ -149,7 +114,7 @@ export function Verify() {
|
|||||||
</select>
|
</select>
|
||||||
<select className="ds-select lv-filter" value={assignee} onChange={(e) => setAssignee(e.target.value)} aria-label="Filter by assignee">
|
<select className="ds-select lv-filter" value={assignee} onChange={(e) => setAssignee(e.target.value)} aria-label="Filter by assignee">
|
||||||
<option value="all">All assignees</option>
|
<option value="all">All assignees</option>
|
||||||
{assignees.map((a) => <option key={a.id} value={a.name}>{a.name}</option>)}
|
{V_ASSIGNEES.map((a) => <option key={a} value={a}>{a}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -164,11 +129,7 @@ export function Verify() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{error ? (
|
{rows.length === 0 ? (
|
||||||
<tr><td colSpan={9} className="lv-empty">Couldn’t load the queue: {error}</td></tr>
|
|
||||||
) : loading && rows.length === 0 ? (
|
|
||||||
<tr><td colSpan={9} className="lv-empty">Loading verification queue…</td></tr>
|
|
||||||
) : rows.length === 0 ? (
|
|
||||||
<tr><td colSpan={9} className="lv-empty">No leads match your filters.</td></tr>
|
<tr><td colSpan={9} className="lv-empty">No leads match your filters.</td></tr>
|
||||||
) : rows.map((l) => {
|
) : rows.map((l) => {
|
||||||
const meta = V_STATUS_META[l.status];
|
const meta = V_STATUS_META[l.status];
|
||||||
@@ -199,8 +160,8 @@ export function Verify() {
|
|||||||
<td className="lv-created">{l.created}</td>
|
<td className="lv-created">{l.created}</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="lv-rowacts">
|
<div className="lv-rowacts">
|
||||||
<button className="lv-act" aria-label="View details" title="View details" onClick={() => openDetail(l)}><Icon name="eye" size={15} /></button>
|
<button className="lv-act" aria-label="View details" title="View details" onClick={() => setSelected(l)}><Icon name="eye" size={15} /></button>
|
||||||
<button className="lv-act primary" aria-label="Verify" title={allowed(l.status).verify ? "Verify lead" : "Already resolved"} onClick={() => doVerify(l)} disabled={!allowed(l.status).verify}><Icon name="check-circle" size={15} /></button>
|
<button className="lv-act primary" aria-label="Verify" title="Verify lead" onClick={() => act(l, "Marked verified", `${l.name} moved to Verified.`, "success")}><Icon name="check-circle" size={15} /></button>
|
||||||
<button className="lv-act" aria-label="More actions" title="More actions" onClick={(e) => setMenu(menu?.lead.id === l.id ? null : { lead: l, x: e.clientX, y: e.clientY })}><Icon name="dots" size={15} /></button>
|
<button className="lv-act" aria-label="More actions" title="More actions" onClick={(e) => setMenu(menu?.lead.id === l.id ? null : { lead: l, x: e.clientX, y: e.clientY })}><Icon name="dots" size={15} /></button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -210,24 +171,10 @@ export function Verify() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div className="lv-count">{rows.length} of {total} leads</div>
|
<div className="lv-count">{rows.length} of {V_LEADS.length} leads</div>
|
||||||
|
|
||||||
<ActionsMenu
|
<ActionsMenu menu={menu} onClose={() => setMenu(null)} onAct={act} onView={(l) => { setSelected(l); setMenu(null); }} />
|
||||||
menu={menu}
|
<VerifyDetail lead={selected} onClose={() => setSelected(null)} />
|
||||||
onClose={() => setMenu(null)}
|
|
||||||
onView={openDetail}
|
|
||||||
onVerify={doVerify}
|
|
||||||
onUnverify={doUnverify}
|
|
||||||
onPending={doPending}
|
|
||||||
onOpenAssign={(l) => { setMenu(null); setAssignFor(l); }}
|
|
||||||
/>
|
|
||||||
<AssignModal
|
|
||||||
lead={assignFor}
|
|
||||||
assignees={assignees}
|
|
||||||
onClose={() => setAssignFor(null)}
|
|
||||||
onPick={(l, a) => { setAssignFor(null); doAssign(l, a); }}
|
|
||||||
/>
|
|
||||||
<VerifyDetail lead={selected} onClose={() => setSelected(null)} onVerify={doVerify} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -236,13 +183,10 @@ export function Verify() {
|
|||||||
/* Row actions dropdown (portalled, fixed-positioned) */
|
/* Row actions dropdown (portalled, fixed-positioned) */
|
||||||
/* ---------------------------------------------------------- */
|
/* ---------------------------------------------------------- */
|
||||||
|
|
||||||
function ActionsMenu({ menu, onClose, onView, onVerify, onUnverify, onPending, onOpenAssign }: {
|
function ActionsMenu({ menu, onClose, onAct, onView }: {
|
||||||
menu: MenuState; onClose: () => void;
|
menu: MenuState; onClose: () => void;
|
||||||
|
onAct: (l: VLead, title: string, desc: string, tone?: "success" | "info") => void;
|
||||||
onView: (l: VLead) => void;
|
onView: (l: VLead) => void;
|
||||||
onVerify: (l: VLead) => void;
|
|
||||||
onUnverify: (l: VLead) => void;
|
|
||||||
onPending: (l: VLead) => void;
|
|
||||||
onOpenAssign: (l: VLead) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
useEffect(() => { setMounted(true); }, []);
|
useEffect(() => { setMounted(true); }, []);
|
||||||
@@ -258,109 +202,44 @@ function ActionsMenu({ menu, onClose, onView, onVerify, onUnverify, onPending, o
|
|||||||
const left = Math.max(8, menu.x - 196);
|
const left = Math.max(8, menu.x - 196);
|
||||||
const top = Math.min(menu.y + 8, window.innerHeight - 260);
|
const top = Math.min(menu.y + 8, window.innerHeight - 260);
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ icon: "eye", label: "View Details", run: () => onView(l) },
|
||||||
|
{ icon: "check-circle", label: "Verify Lead", run: () => onAct(l, "Verified", `${l.name} marked as verified.`, "success") },
|
||||||
|
{ icon: "alert", label: "Mark Unverified", run: () => onAct(l, "Marked unverified", `${l.name} moved to Unverified.`) },
|
||||||
|
{ icon: "user", label: "Change Assignee", run: () => onAct(l, "Change assignee", `Pick a new rep for ${l.name}.`) },
|
||||||
|
{ icon: "refresh", label: "Reassign (In Progress)", run: () => onAct(l, "Reassigned", `${l.name} set to In Progress.`) },
|
||||||
|
{ icon: "clock", label: "Move to Pending", run: () => onAct(l, "Moved to pending", `${l.name} is now Pending review.`) },
|
||||||
|
];
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<>
|
<>
|
||||||
<div className="lv-menu-scrim" onClick={onClose} />
|
<div className="lv-menu-scrim" onClick={onClose} />
|
||||||
<MenuBody
|
<div className="lv-menu" style={{ left, top }} role="menu">
|
||||||
key={l.id} lead={l} left={left} top={top}
|
{items.map((it) => (
|
||||||
onView={onView} onVerify={onVerify} onUnverify={onUnverify}
|
<button key={it.label} className="lv-menu-item" role="menuitem" onClick={it.run}>
|
||||||
onPending={onPending} onOpenAssign={onOpenAssign}
|
<Icon name={it.icon} size={14} /> {it.label}
|
||||||
/>
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</>,
|
</>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MenuBody({ lead: l, left, top, onView, onVerify, onUnverify, onPending, onOpenAssign }: {
|
|
||||||
lead: VLead; left: number; top: number;
|
|
||||||
onView: (l: VLead) => void; onVerify: (l: VLead) => void; onUnverify: (l: VLead) => void;
|
|
||||||
onPending: (l: VLead) => void; onOpenAssign: (l: VLead) => void;
|
|
||||||
}) {
|
|
||||||
const can = allowed(l.status);
|
|
||||||
|
|
||||||
// Immediate-fire actions are gated by status (prevents a pointless 409). "Change Assignee" opens
|
|
||||||
// the assign popup (the command fires after you pick), so it always opens.
|
|
||||||
const items = [
|
|
||||||
{ icon: "eye", label: "View Details", enabled: true, run: () => onView(l) },
|
|
||||||
{ icon: "check-circle", label: "Verify Lead", enabled: can.verify, run: () => onVerify(l) },
|
|
||||||
{ icon: "alert", label: "Mark Unverified", enabled: can.unverify, run: () => onUnverify(l) },
|
|
||||||
{ icon: "user", label: "Change Assignee", enabled: true, run: () => onOpenAssign(l) },
|
|
||||||
{ icon: "clock", label: "Move to Pending", enabled: can.moveToPending, run: () => onPending(l) },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="lv-menu" style={{ left, top }} role="menu">
|
|
||||||
{items.map((it) => (
|
|
||||||
<button key={it.label} className="lv-menu-item" role="menuitem" onClick={it.run} disabled={!it.enabled}>
|
|
||||||
<Icon name={it.icon} size={14} /> {it.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------- */
|
|
||||||
/* Change-assignee popup (searchable, scrollable) */
|
|
||||||
/* ---------------------------------------------------------- */
|
|
||||||
|
|
||||||
function AssignModal({ lead, assignees, onClose, onPick }: {
|
|
||||||
lead: VLead | null; assignees: Assignee[]; onClose: () => void; onPick: (l: VLead, a: Assignee) => void;
|
|
||||||
}) {
|
|
||||||
const [q, setQ] = useState("");
|
|
||||||
if (!lead) return null;
|
|
||||||
const query = q.trim().toLowerCase();
|
|
||||||
const matches = query ? assignees.filter((a) => a.name.toLowerCase().includes(query)) : assignees;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal open={!!lead} onClose={onClose} size="sm" title="Change Assignee" subtitle={lead.name} icon="user">
|
|
||||||
<div className="lv-assign">
|
|
||||||
<div className="lv-assign-search">
|
|
||||||
<Icon name="search" size={15} />
|
|
||||||
<input autoFocus value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search team members…" aria-label="Search team members" />
|
|
||||||
{q && <button className="lv-assign-x" aria-label="Clear" onClick={() => setQ("")}><Icon name="x" size={14} /></button>}
|
|
||||||
</div>
|
|
||||||
<div className="lv-assign-list">
|
|
||||||
{assignees.length === 0 ? (
|
|
||||||
<div className="lv-assign-empty">No team members available.</div>
|
|
||||||
) : matches.length === 0 ? (
|
|
||||||
<div className="lv-assign-empty">No members match “{q}”.</div>
|
|
||||||
) : matches.map((a) => {
|
|
||||||
const current = lead.assignee?.id === a.id || lead.assignee?.name === a.name;
|
|
||||||
return (
|
|
||||||
<button key={a.id} className={`lv-assign-opt ${current ? "current" : ""}`} onClick={() => onPick(lead, a)}>
|
|
||||||
<Avatar initials={a.initials} size={30} gradient="linear-gradient(135deg,#4f8cff,#2c5cff)" />
|
|
||||||
<span className="lv-assign-name">{a.name}</span>
|
|
||||||
{current && <Icon name="check" size={16} />}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------------------------------------------------------- */
|
/* ---------------------------------------------------------- */
|
||||||
/* Verification detail popup */
|
/* Verification detail popup */
|
||||||
/* ---------------------------------------------------------- */
|
/* ---------------------------------------------------------- */
|
||||||
|
|
||||||
function VerifyDetail({ lead, onClose, onVerify }: { lead: VLead | null; onClose: () => void; onVerify: (l: VLead) => void }) {
|
function VerifyDetail({ lead, onClose }: { lead: VLead | null; onClose: () => void }) {
|
||||||
const toast = useToast();
|
|
||||||
if (!lead) return null;
|
if (!lead) return null;
|
||||||
const meta = V_STATUS_META[lead.status];
|
const meta = V_STATUS_META[lead.status];
|
||||||
const activity = buildActivity(lead);
|
const activity = buildActivity(lead);
|
||||||
|
|
||||||
const call = () => {
|
|
||||||
if (!lead.phone) { toast.push({ tone: "error", title: "No phone", desc: "This lead has no phone number." }); return; }
|
|
||||||
window.location.href = `tel:${lead.phone.replace(/[^\d+]/g, "")}`;
|
|
||||||
};
|
|
||||||
const verify = () => { onVerify(lead); onClose(); };
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal open={!!lead} onClose={onClose} size="lg" title={lead.name} subtitle={`${lead.id} · ${lead.verification}`} icon="verify"
|
<Modal open={!!lead} onClose={onClose} size="lg" title={lead.name} subtitle={`${lead.id} · ${lead.verification}`} icon="verify"
|
||||||
footer={<>
|
footer={<>
|
||||||
<Btn variant="ghost" icon="phone" onClick={call}>Call</Btn>
|
<Btn variant="ghost" icon="phone">Call</Btn>
|
||||||
<Btn icon="check-circle" onClick={verify} disabled={!allowed(lead.status).verify}>Verify Lead</Btn>
|
<Btn icon="check-circle">Verify Lead</Btn>
|
||||||
</>}
|
</>}
|
||||||
>
|
>
|
||||||
<div className="lv-detail">
|
<div className="lv-detail">
|
||||||
|
|||||||
@@ -1,568 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// Leads data layer. Serves EITHER the local mock (when the Shell isn't configured — the
|
|
||||||
// polished demo keeps working) OR the live be-crm data door (crm.lead.*), behind one
|
|
||||||
// interface so the Leads board is mode-agnostic. Mirrors team-api.ts exactly.
|
|
||||||
//
|
|
||||||
// Live contract (be-crm):
|
|
||||||
// query crm.lead.search { query?, status?, priority?, source?, assigneeId?, page?, perPage? }
|
|
||||||
// -> { items: LeadCardDTO[], meta }
|
|
||||||
// query crm.lead.get { id } -> LeadDTO (full detail)
|
|
||||||
// query crm.lead.stats {} -> { total, byStatus }
|
|
||||||
// cmd crm.lead.create <payload> -> LeadDTO
|
|
||||||
// cmd crm.lead.updateStatus { id, status, note? } -> LeadDTO
|
|
||||||
// cmd crm.lead.assign { id, assigneeId | null } -> LeadDTO
|
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
|
||||||
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
|
|
||||||
import { isShellConfigured } from "./appshell";
|
|
||||||
import { useUploadAttachment, useDownloadUrl, type UploadedAttachment } from "./media-api";
|
|
||||||
import {
|
|
||||||
LEADS as seedLeads, TOTAL_LEADS, REPS as seedReps,
|
|
||||||
type Lead, type LeadStatus, type LeadPriority, type Phone, type Email, type Rep, type Attachment,
|
|
||||||
} from "@/components/dashboard/leads-data";
|
|
||||||
|
|
||||||
/* ---- create payload (union of Quick + Full form; §6.3) ------------------ */
|
|
||||||
|
|
||||||
export interface CreateLeadInput {
|
|
||||||
firstName: string;
|
|
||||||
lastName?: string;
|
|
||||||
phones: { number: string; type: "Mobile" | "Home" | "Work"; primary?: boolean }[];
|
|
||||||
emails?: { address: string; primary?: boolean }[];
|
|
||||||
property?: { address?: string; city?: string; state?: string; zip?: string; type?: string };
|
|
||||||
photos?: { contentRef: string; mimeType: string; sizeBytes: number; filename: string }[];
|
|
||||||
job?: {
|
|
||||||
source?: string; referralNote?: string; canvasserId?: string;
|
|
||||||
leadType?: string; workType?: string; tradeType?: string;
|
|
||||||
urgency?: "Standard" | "High" | "Emergency"; notes?: string;
|
|
||||||
};
|
|
||||||
insurance?: {
|
|
||||||
company?: string; claimNumber?: string; claimStatus?: string;
|
|
||||||
adjusterName?: string; adjusterPhone?: string; policyNumber?: string;
|
|
||||||
};
|
|
||||||
assignment?: { assigneeId?: string | null; priority?: "Low" | "Medium" | "High"; followUp?: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Partial update patch — flat camelCase fields (matches the tested `crm.lead.update` #21). */
|
|
||||||
export interface LeadPatch {
|
|
||||||
firstName?: string; lastName?: string;
|
|
||||||
priority?: string;
|
|
||||||
propertyAddress?: string; propertyCity?: string; propertyState?: string; propertyZip?: string; propertyType?: string;
|
|
||||||
source?: string; leadType?: string; workType?: string; tradeType?: string; urgency?: string; jobNotes?: string;
|
|
||||||
insuranceCompany?: string; insuranceClaimStatus?: string; insuranceClaimNumber?: string;
|
|
||||||
insurancePolicyNumber?: string; insuranceAdjusterName?: string; insuranceAdjusterPhone?: string;
|
|
||||||
assignedToId?: string | null; followUpDate?: string;
|
|
||||||
phones?: { number: string; type: string; primary?: boolean }[];
|
|
||||||
emails?: { address: string; primary?: boolean }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LeadsData {
|
|
||||||
live: boolean; loading: boolean; error: string | null;
|
|
||||||
leads: Lead[];
|
|
||||||
total: number;
|
|
||||||
byStatus: Record<LeadStatus, number>;
|
|
||||||
/** Rep / canvasser / assignee picker options. In live mode `id` is the member's principalId
|
|
||||||
* (the value be-crm resolves assignee/canvasser references against), not the mock REP code. */
|
|
||||||
reps: Rep[];
|
|
||||||
/** Hydrate a single lead's full detail (list rows carry card-level fields only in live mode). */
|
|
||||||
getLead: (lead: Lead) => Promise<Lead>;
|
|
||||||
createLead: (input: CreateLeadInput) => Promise<void>;
|
|
||||||
/** Upload a site photo → {contentRef,…}. Feed the results into `createLead`'s `photos`. */
|
|
||||||
uploadPhoto: (file: File) => Promise<UploadedAttachment>;
|
|
||||||
/** Mint a short-lived signed URL to render a stored photo by its contentRef. */
|
|
||||||
getPhotoUrl: (contentRef: string, mime?: string) => Promise<string>;
|
|
||||||
/** Attach an already-uploaded photo to an existing lead (Edit mode) — crm.lead.attachment.add. */
|
|
||||||
addPhoto: (lead: Lead, attachment: UploadedAttachment) => Promise<void>;
|
|
||||||
/** Detach a stored photo from a lead (Edit mode) — crm.lead.attachment.remove. */
|
|
||||||
removePhoto: (lead: Lead, attachment: Attachment) => Promise<void>;
|
|
||||||
/** Edit an existing lead (Edit button on the detail popup). */
|
|
||||||
updateLead: (lead: Lead, patch: LeadPatch) => Promise<void>;
|
|
||||||
updateStatus: (lead: Lead, status: LeadStatus, note?: string) => Promise<void>;
|
|
||||||
assign: (lead: Lead, assigneeId: string | null) => Promise<void>;
|
|
||||||
/** Push the lead into the Lead Verification queue (crm.lead.sendForVerification). Idempotent server-side. */
|
|
||||||
sendForVerification: (lead: Lead) => Promise<void>;
|
|
||||||
refetch: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- helpers ------------------------------------------------------------ */
|
|
||||||
|
|
||||||
const GRADIENTS = [
|
|
||||||
"linear-gradient(135deg,#fda913,#fd6d13)", "linear-gradient(135deg,#4f8cff,#2c5cff)",
|
|
||||||
"linear-gradient(135deg,#b07bf2,#7b53e0)", "linear-gradient(135deg,#33c98a,#1fa46c)",
|
|
||||||
"linear-gradient(135deg,#34c9d6,#1f9aa4)",
|
|
||||||
];
|
|
||||||
const gradientFor = (id: string) => GRADIENTS[[...id].reduce((a, c) => a + c.charCodeAt(0), 0) % GRADIENTS.length];
|
|
||||||
const initialsOf = (name: string) =>
|
|
||||||
name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
|
|
||||||
const firstNameOf = (name: string) => name.split(/\s+/)[0] ?? "";
|
|
||||||
|
|
||||||
const relTime = (iso?: string | null): string => {
|
|
||||||
if (!iso) return "—";
|
|
||||||
const then = Date.parse(iso);
|
|
||||||
if (Number.isNaN(then)) return iso;
|
|
||||||
const diff = Date.now() - then;
|
|
||||||
const day = 86_400_000;
|
|
||||||
if (diff >= 0 && diff < 7 * day) {
|
|
||||||
const d = Math.floor(diff / day);
|
|
||||||
if (d <= 0) return "today";
|
|
||||||
return `${d}d ago`;
|
|
||||||
}
|
|
||||||
return new Date(then).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
|
|
||||||
};
|
|
||||||
const prettyDate = (iso?: string | null): string => {
|
|
||||||
if (!iso) return "—";
|
|
||||||
const then = Date.parse(iso);
|
|
||||||
if (Number.isNaN(then)) return iso;
|
|
||||||
return new Date(then).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
|
|
||||||
};
|
|
||||||
const lc = (p?: string | null): LeadPriority =>
|
|
||||||
(String(p ?? "medium").toLowerCase() as LeadPriority);
|
|
||||||
|
|
||||||
const EMPTY_BY_STATUS: Record<LeadStatus, number> = { new: 0, contacted: 0, appointed: 0, closed: 0 };
|
|
||||||
|
|
||||||
/* ---- be-crm DTO types (defensive: backend is greenfield) ---------------- */
|
|
||||||
|
|
||||||
interface LeadCardDTO {
|
|
||||||
id: string; code: string; name: string; initials?: string; gradient?: string;
|
|
||||||
priority?: string; status?: LeadStatus; tag?: string;
|
|
||||||
primaryPhone?: string; propertyAddress?: string; propertyCity?: string; propertyState?: string;
|
|
||||||
source?: string; canvasserName?: string; updatedAt?: string;
|
|
||||||
// Set once the lead has an associated verification record (blocks a duplicate send).
|
|
||||||
verificationId?: string | null; verificationStatus?: string | null;
|
|
||||||
}
|
|
||||||
interface LeadDTO extends LeadCardDTO {
|
|
||||||
phones?: Phone[]; emails?: Email[];
|
|
||||||
propertyZip?: string; propertyType?: string;
|
|
||||||
leadType?: string; workType?: string; tradeType?: string; urgency?: string; jobNotes?: string;
|
|
||||||
referralNote?: string; canvasserId?: string;
|
|
||||||
insuranceCompany?: string; insuranceClaimStatus?: string; insuranceClaimNumber?: string;
|
|
||||||
insurancePolicyNumber?: string; insuranceAdjusterName?: string; insuranceAdjusterPhone?: string;
|
|
||||||
assignedToName?: string; createdByName?: string; followUpDate?: string; createdAt?: string;
|
|
||||||
assignedToId?: string | null; createdById?: string | null;
|
|
||||||
stormZone?: string; stormDate?: string; stormDetail?: string;
|
|
||||||
// assignee / creator can come back under several field names depending on the DTO projection.
|
|
||||||
// The object form carries an `id` (be-crm currently echoes the principalId as `name`, unresolved).
|
|
||||||
assignedTo?: string | { id?: string; name?: string; displayName?: string } | null;
|
|
||||||
assignee?: string | { id?: string; name?: string; displayName?: string } | null;
|
|
||||||
createdBy?: string | { id?: string; name?: string; displayName?: string } | null;
|
|
||||||
creatorName?: string;
|
|
||||||
// site photos — LeadDTO detail projection carries attachments[] (§6.2)
|
|
||||||
attachments?: Attachment[];
|
|
||||||
// tolerate an already-grouped detail shape too
|
|
||||||
property?: Lead["property"]; job?: Partial<Lead["job"]>;
|
|
||||||
insurance?: Partial<Lead["insurance"]>; assignment?: Partial<Lead["assignment"]>;
|
|
||||||
storm?: Partial<Lead["storm"]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pull a display name out of whatever shape the backend returns the assignee in. */
|
|
||||||
function nameOf(v: string | { name?: string; displayName?: string } | null | undefined): string {
|
|
||||||
if (!v) return "";
|
|
||||||
if (typeof v === "string") return v;
|
|
||||||
return v.displayName?.trim() || v.name?.trim() || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Pull the member id out of an object-shaped member reference (string refs carry no id). */
|
|
||||||
function idOf(v: string | { id?: string } | null | undefined): string {
|
|
||||||
if (!v || typeof v === "string") return "";
|
|
||||||
return v.id?.trim() ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Resolve a member reference to a display name. be-crm currently echoes the raw principalId
|
|
||||||
* as the `name`, so prefer the local reps list (principalId → real name); fall back to a
|
|
||||||
* backend name only when it isn't just the id, then to the id itself. */
|
|
||||||
function resolveMemberName(id: string, backendName: string, reps: Rep[]): string {
|
|
||||||
const rep = id ? reps.find((r) => r.id === id) : undefined;
|
|
||||||
if (rep) return rep.name;
|
|
||||||
if (backendName && backendName !== id) return backendName;
|
|
||||||
return backendName || id;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- DTO → UI Lead ------------------------------------------------------ */
|
|
||||||
|
|
||||||
function cardToLead(d: LeadCardDTO): Lead {
|
|
||||||
const name = d.name ?? "";
|
|
||||||
return {
|
|
||||||
id: d.code || d.id,
|
|
||||||
refId: d.id,
|
|
||||||
verificationId: d.verificationId ?? (d.verificationStatus ? String(d.verificationStatus) : undefined) ?? undefined,
|
|
||||||
initials: d.initials || initialsOf(name),
|
|
||||||
name,
|
|
||||||
gradient: d.gradient || gradientFor(d.id),
|
|
||||||
priority: lc(d.priority),
|
|
||||||
status: (d.status ?? "new") as LeadStatus,
|
|
||||||
tag: d.tag ?? "Storm Zone",
|
|
||||||
updated: relTime(d.updatedAt),
|
|
||||||
setter: firstNameOf(d.canvasserName ?? ""),
|
|
||||||
storm: { zone: "", date: "", detail: "" },
|
|
||||||
phones: d.primaryPhone ? [{ number: d.primaryPhone, type: "Mobile", primary: true }] : [],
|
|
||||||
emails: [],
|
|
||||||
property: {
|
|
||||||
address: d.propertyAddress ?? "", city: d.propertyCity ?? "",
|
|
||||||
state: d.propertyState ?? "", zip: "", type: "",
|
|
||||||
},
|
|
||||||
job: {
|
|
||||||
source: d.source ?? "", leadType: "", workType: "", tradeType: "",
|
|
||||||
urgency: "", canvasser: d.canvasserName ?? "", notes: "",
|
|
||||||
},
|
|
||||||
insurance: { company: "", claimStatus: "", claimNumber: "", policyNumber: "", adjusterName: "", adjusterPhone: "" },
|
|
||||||
assignment: { assignedTo: "", priority: "", followUp: "", createdBy: "", createdAt: "" },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function detailToLead(d: LeadDTO, fallback: Lead, reps: Rep[]): Lead {
|
|
||||||
const base = cardToLead(d);
|
|
||||||
const assigneeId = d.assignedToId || idOf(d.assignedTo) || idOf(d.assignee) || "";
|
|
||||||
const assigneeName = d.assignedToName || nameOf(d.assignedTo) || nameOf(d.assignee) || "";
|
|
||||||
const creatorId = d.createdById || idOf(d.createdBy) || "";
|
|
||||||
const creatorName = d.createdByName || d.creatorName || nameOf(d.createdBy) || "";
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
storm: d.storm
|
|
||||||
? { zone: d.storm.zone ?? "", date: d.storm.date ?? "", detail: d.storm.detail ?? "" }
|
|
||||||
: { zone: d.stormZone ?? fallback.storm.zone, date: d.stormDate ?? fallback.storm.date, detail: d.stormDetail ?? fallback.storm.detail },
|
|
||||||
phones: d.phones?.length ? d.phones : base.phones.length ? base.phones : fallback.phones,
|
|
||||||
emails: d.emails ?? [],
|
|
||||||
attachments: d.attachments ?? fallback.attachments ?? [],
|
|
||||||
property: d.property ?? {
|
|
||||||
address: d.propertyAddress ?? "", city: d.propertyCity ?? "", state: d.propertyState ?? "",
|
|
||||||
zip: d.propertyZip ?? "", type: d.propertyType ?? "",
|
|
||||||
},
|
|
||||||
job: {
|
|
||||||
source: d.job?.source ?? d.source ?? "",
|
|
||||||
leadType: d.job?.leadType ?? d.leadType ?? "",
|
|
||||||
workType: d.job?.workType ?? d.workType ?? "",
|
|
||||||
tradeType: d.job?.tradeType ?? d.tradeType ?? "",
|
|
||||||
urgency: d.job?.urgency ?? d.urgency ?? "",
|
|
||||||
canvasser: d.job?.canvasser ?? d.canvasserName ?? "",
|
|
||||||
notes: d.job?.notes ?? d.jobNotes ?? "",
|
|
||||||
},
|
|
||||||
insurance: {
|
|
||||||
company: d.insurance?.company ?? d.insuranceCompany ?? "",
|
|
||||||
claimStatus: d.insurance?.claimStatus ?? d.insuranceClaimStatus ?? "",
|
|
||||||
claimNumber: d.insurance?.claimNumber ?? d.insuranceClaimNumber ?? "",
|
|
||||||
policyNumber: d.insurance?.policyNumber ?? d.insurancePolicyNumber ?? "",
|
|
||||||
adjusterName: d.insurance?.adjusterName ?? d.insuranceAdjusterName ?? "",
|
|
||||||
adjusterPhone: d.insurance?.adjusterPhone ?? d.insuranceAdjusterPhone ?? "",
|
|
||||||
},
|
|
||||||
assignment: {
|
|
||||||
assignedTo: d.assignment?.assignedTo
|
|
||||||
|| resolveMemberName(assigneeId, assigneeName, reps)
|
|
||||||
|| "Unassigned",
|
|
||||||
priority: d.assignment?.priority ?? (d.priority ?? ""),
|
|
||||||
followUp: d.assignment?.followUp ?? prettyDate(d.followUpDate),
|
|
||||||
createdBy: d.assignment?.createdBy
|
|
||||||
|| resolveMemberName(creatorId, creatorName, reps),
|
|
||||||
createdAt: d.assignment?.createdAt ?? prettyDate(d.createdAt),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================================================================== */
|
|
||||||
/* Mock implementation (no Shell configured) */
|
|
||||||
/* ======================================================================== */
|
|
||||||
|
|
||||||
function useMockLeads(): LeadsData {
|
|
||||||
const [leads, setLeads] = useState<Lead[]>(() => seedLeads);
|
|
||||||
|
|
||||||
const byStatus = useMemo(() => {
|
|
||||||
const m = { ...EMPTY_BY_STATUS };
|
|
||||||
for (const l of leads) m[l.status] += 1;
|
|
||||||
return m;
|
|
||||||
}, [leads]);
|
|
||||||
|
|
||||||
const getLead = useCallback(async (lead: Lead) => lead, []);
|
|
||||||
|
|
||||||
const createLead = useCallback(async (input: CreateLeadInput) => {
|
|
||||||
const name = `${input.firstName} ${input.lastName ?? ""}`.trim();
|
|
||||||
const phones: Phone[] = input.phones
|
|
||||||
.filter((p) => p.number.trim())
|
|
||||||
.map((p, i) => ({ number: p.number, type: p.type, primary: p.primary ?? i === 0 }));
|
|
||||||
const seq = seedLeads.length + leads.length + 1;
|
|
||||||
const lead: Lead = {
|
|
||||||
id: `SAL-${String(seq).padStart(3, "0")}`,
|
|
||||||
initials: initialsOf(name),
|
|
||||||
name,
|
|
||||||
gradient: gradientFor(name),
|
|
||||||
priority: lc(input.assignment?.priority),
|
|
||||||
status: "new",
|
|
||||||
tag: "Storm Zone",
|
|
||||||
updated: "today",
|
|
||||||
setter: firstNameOf(input.job?.canvasserId ?? ""),
|
|
||||||
storm: { zone: "", date: "", detail: "" },
|
|
||||||
phones,
|
|
||||||
emails: (input.emails ?? []).filter((e) => e.address.trim()).map((e, i) => ({ address: e.address, primary: e.primary ?? i === 0 })),
|
|
||||||
attachments: input.photos ?? [],
|
|
||||||
property: {
|
|
||||||
address: input.property?.address ?? "", city: input.property?.city ?? "",
|
|
||||||
state: input.property?.state ?? "TX", zip: input.property?.zip ?? "", type: input.property?.type ?? "",
|
|
||||||
},
|
|
||||||
job: {
|
|
||||||
source: input.job?.source ?? "", leadType: input.job?.leadType ?? "",
|
|
||||||
workType: input.job?.workType ?? "", tradeType: input.job?.tradeType ?? "",
|
|
||||||
urgency: input.job?.urgency ?? "Standard", canvasser: input.job?.canvasserId ?? "", notes: input.job?.notes ?? "",
|
|
||||||
},
|
|
||||||
insurance: {
|
|
||||||
company: input.insurance?.company ?? "", claimStatus: input.insurance?.claimStatus ?? "",
|
|
||||||
claimNumber: input.insurance?.claimNumber ?? "", policyNumber: input.insurance?.policyNumber ?? "",
|
|
||||||
adjusterName: input.insurance?.adjusterName ?? "", adjusterPhone: input.insurance?.adjusterPhone ?? "",
|
|
||||||
},
|
|
||||||
assignment: {
|
|
||||||
assignedTo: input.assignment?.assigneeId ?? "Unassigned",
|
|
||||||
priority: input.assignment?.priority ?? "Medium",
|
|
||||||
followUp: input.assignment?.followUp || "—",
|
|
||||||
createdBy: "You", createdAt: "today",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
setLeads((l) => [lead, ...l]);
|
|
||||||
}, [leads.length]);
|
|
||||||
|
|
||||||
const updateLead = useCallback(async (lead: Lead, patch: LeadPatch) => {
|
|
||||||
setLeads((l) => l.map((x) => (x.id === lead.id ? applyPatch(x, patch) : x)));
|
|
||||||
}, []);
|
|
||||||
const updateStatus = useCallback(async (lead: Lead, status: LeadStatus) => {
|
|
||||||
setLeads((l) => l.map((x) => (x.id === lead.id ? { ...x, status } : x)));
|
|
||||||
}, []);
|
|
||||||
const assign = useCallback(async (lead: Lead, assigneeId: string | null) => {
|
|
||||||
setLeads((l) => l.map((x) => (x.id === lead.id ? { ...x, assignment: { ...x.assignment, assignedTo: assigneeId ?? "Unassigned" } } : x)));
|
|
||||||
}, []);
|
|
||||||
// No shared store between the mock leads/verify hooks, so this is a no-op in demo mode
|
|
||||||
// (the component still toasts success). Live mode does the real intake.
|
|
||||||
const sendForVerification = useCallback(async (_lead: Lead) => {}, []);
|
|
||||||
|
|
||||||
// Demo mode has no storage door — keep the bytes in-browser via an object URL so the
|
|
||||||
// uploaded photo still renders in the detail popup. `getPhotoUrl` passes it straight through.
|
|
||||||
const uploadPhoto = useCallback(async (file: File): Promise<UploadedAttachment> => ({
|
|
||||||
contentRef: URL.createObjectURL(file), mimeType: file.type || "image/jpeg", sizeBytes: file.size, filename: file.name,
|
|
||||||
}), []);
|
|
||||||
const getPhotoUrl = useCallback(async (contentRef: string) => contentRef, []);
|
|
||||||
const addPhoto = useCallback(async (lead: Lead, attachment: UploadedAttachment) => {
|
|
||||||
setLeads((l) => l.map((x) => (x.id === lead.id ? { ...x, attachments: [...(x.attachments ?? []), attachment] } : x)));
|
|
||||||
}, []);
|
|
||||||
const removePhoto = useCallback(async (lead: Lead, attachment: Attachment) => {
|
|
||||||
setLeads((l) => l.map((x) => (x.id === lead.id ? { ...x, attachments: (x.attachments ?? []).filter((a) => a.contentRef !== attachment.contentRef) } : x)));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
live: false, loading: false, error: null,
|
|
||||||
leads, total: TOTAL_LEADS, byStatus, reps: seedReps,
|
|
||||||
getLead, createLead, updateLead, updateStatus, assign, sendForVerification,
|
|
||||||
uploadPhoto, getPhotoUrl, addPhoto, removePhoto, refetch: () => {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Apply a flat LeadPatch onto the nested UI Lead (mock mode only). */
|
|
||||||
function applyPatch(lead: Lead, p: LeadPatch): Lead {
|
|
||||||
const name = p.firstName !== undefined || p.lastName !== undefined
|
|
||||||
? `${p.firstName ?? lead.name.split(" ")[0] ?? ""} ${p.lastName ?? lead.name.split(" ").slice(1).join(" ")}`.trim()
|
|
||||||
: lead.name;
|
|
||||||
return {
|
|
||||||
...lead,
|
|
||||||
name,
|
|
||||||
initials: name ? initialsOf(name) : lead.initials,
|
|
||||||
priority: p.priority ? lc(p.priority) : lead.priority,
|
|
||||||
phones: p.phones?.length ? p.phones.map((x, i) => ({ number: x.number, type: x.type as Phone["type"], primary: x.primary ?? i === 0 })) : lead.phones,
|
|
||||||
emails: p.emails ? p.emails.map((x, i) => ({ address: x.address, primary: x.primary ?? i === 0 })) : lead.emails,
|
|
||||||
property: {
|
|
||||||
address: p.propertyAddress ?? lead.property.address,
|
|
||||||
city: p.propertyCity ?? lead.property.city,
|
|
||||||
state: p.propertyState ?? lead.property.state,
|
|
||||||
zip: p.propertyZip ?? lead.property.zip,
|
|
||||||
type: p.propertyType ?? lead.property.type,
|
|
||||||
},
|
|
||||||
job: {
|
|
||||||
...lead.job,
|
|
||||||
source: p.source ?? lead.job.source,
|
|
||||||
leadType: p.leadType ?? lead.job.leadType,
|
|
||||||
workType: p.workType ?? lead.job.workType,
|
|
||||||
tradeType: p.tradeType ?? lead.job.tradeType,
|
|
||||||
urgency: p.urgency ?? lead.job.urgency,
|
|
||||||
notes: p.jobNotes ?? lead.job.notes,
|
|
||||||
},
|
|
||||||
insurance: {
|
|
||||||
company: p.insuranceCompany ?? lead.insurance.company,
|
|
||||||
claimStatus: p.insuranceClaimStatus ?? lead.insurance.claimStatus,
|
|
||||||
claimNumber: p.insuranceClaimNumber ?? lead.insurance.claimNumber,
|
|
||||||
policyNumber: p.insurancePolicyNumber ?? lead.insurance.policyNumber,
|
|
||||||
adjusterName: p.insuranceAdjusterName ?? lead.insurance.adjusterName,
|
|
||||||
adjusterPhone: p.insuranceAdjusterPhone ?? lead.insurance.adjusterPhone,
|
|
||||||
},
|
|
||||||
assignment: {
|
|
||||||
...lead.assignment,
|
|
||||||
assignedTo: p.assignedToId !== undefined ? (p.assignedToId ?? "Unassigned") : lead.assignment.assignedTo,
|
|
||||||
priority: p.priority ?? lead.assignment.priority,
|
|
||||||
followUp: p.followUpDate ?? lead.assignment.followUp,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================================================================== */
|
|
||||||
/* Live implementation (be-crm data door) */
|
|
||||||
/* ======================================================================== */
|
|
||||||
|
|
||||||
interface Page<T> { items: T[]; meta?: { total?: number } }
|
|
||||||
interface MemberDTO { id: string; principalId?: string; displayName?: string | null; firstName?: string | null; lastName?: string | null; jobTitle?: string | null; email?: string | null }
|
|
||||||
|
|
||||||
/* ---- error surfacing ---------------------------------------------------- */
|
|
||||||
// appshell's DataClient throws an opaque "data command failed: <status>" and DROPS the response
|
|
||||||
// body, so the backend's real error (e.g. the duplicate_lead guard) never reaches the UI. For
|
|
||||||
// writes where the exact server message matters, `bffCommand` uses the same BFF transport but
|
|
||||||
// parses the error body and throws the backend's own message.
|
|
||||||
|
|
||||||
const BFF_BASE = (process.env.NEXT_PUBLIC_BFF_BASE_URL ?? "/shell").replace(/\/$/, "");
|
|
||||||
|
|
||||||
// Observed be-crm error body (Fastify style):
|
|
||||||
// { statusCode, error: "Conflict", message: "duplicate_lead",
|
|
||||||
// detail: "A lead with the same primary phone and address already exists (SAL-010).",
|
|
||||||
// duplicateOf: [{ id, code, name }] }
|
|
||||||
// i.e. `message` is a CODE slug, `detail` is the human sentence, `duplicateOf` is an array.
|
|
||||||
// Also tolerate a nested `{ error: { code, message } }` shape from other services.
|
|
||||||
type DupRef = { id?: string; code?: string; name?: string };
|
|
||||||
interface BackendError {
|
|
||||||
statusCode?: number; code?: string; error?: string | BackendError;
|
|
||||||
message?: string; detail?: string;
|
|
||||||
duplicateOf?: DupRef[] | DupRef | null;
|
|
||||||
details?: { duplicateOf?: DupRef[] | DupRef | null };
|
|
||||||
}
|
|
||||||
|
|
||||||
function firstDup(body: BackendError): DupRef | null {
|
|
||||||
const d = body.duplicateOf ?? body.details?.duplicateOf ?? null;
|
|
||||||
if (!d) return null;
|
|
||||||
return Array.isArray(d) ? (d[0] ?? null) : d;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Show the backend's own human sentence verbatim; map a bare code slug otherwise. */
|
|
||||||
function leadErrorText(code: string, human: string, body: BackendError, status: number): string {
|
|
||||||
if (human) return human; // e.g. `detail` — exactly what the server said
|
|
||||||
switch (code) { // slug with no human sentence
|
|
||||||
case "duplicate_lead": {
|
|
||||||
const who = firstDup(body)?.name || firstDup(body)?.code;
|
|
||||||
return who
|
|
||||||
? `A lead with the same primary phone and address already exists (${who}).`
|
|
||||||
: "A lead with the same primary phone and address already exists.";
|
|
||||||
}
|
|
||||||
case "name_required": return "Enter the homeowner's first or last name.";
|
|
||||||
case "invalid_assignee": return "The selected rep isn't a valid team member.";
|
|
||||||
case "file_too_large": return "That photo is too large (max 25 MB).";
|
|
||||||
default: return `Couldn’t complete the request (${status}).`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function bffCommand<T>(action: string, variables: Record<string, unknown>): Promise<T> {
|
|
||||||
const res = await fetch(`${BFF_BASE}/data/command`, {
|
|
||||||
method: "POST",
|
|
||||||
credentials: "include",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-Idempotency-Key": globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ action, variables }),
|
|
||||||
});
|
|
||||||
if (res.status === 401) throw new Error("SESSION_EXPIRED");
|
|
||||||
if (res.ok) return (await res.json()) as T;
|
|
||||||
let body: BackendError = {};
|
|
||||||
try { body = (await res.json()) as BackendError; } catch { /* non-JSON error body */ }
|
|
||||||
const inner = (body.error && typeof body.error === "object" ? body.error : body) as BackendError;
|
|
||||||
const raw = inner.message ?? "";
|
|
||||||
const isSlug = /^[a-z][a-z0-9_]*$/.test(raw); // "duplicate_lead" is a code, not a sentence
|
|
||||||
const code = inner.code ?? (isSlug ? raw : "");
|
|
||||||
const human = inner.detail ?? (isSlug ? "" : raw);
|
|
||||||
const e = new Error(leadErrorText(code, human, inner, res.status));
|
|
||||||
(e as unknown as { code?: string }).code = code;
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
function useLiveLeads(): LeadsData {
|
|
||||||
const { sdk } = useAppShell();
|
|
||||||
const uploadPhoto = useUploadAttachment(); // presignUpload → PUT bytes → { contentRef, … }
|
|
||||||
const getPhotoUrl = useDownloadUrl(); // presignDownload → signed URL
|
|
||||||
// useQuery re-runs only on `action` change, so fetch a broad page and let the
|
|
||||||
// board filter/search client-side (same shape team-api uses).
|
|
||||||
const listQ = useQuery<Page<LeadCardDTO>>("crm.lead.search", { perPage: 100 });
|
|
||||||
const statsQ = useQuery<{ total?: number; byStatus?: Partial<Record<LeadStatus, number>> }>("crm.lead.stats", {});
|
|
||||||
// Rep/canvasser/assignee picker source (§6.6). be-crm resolves assignee/canvasser refs by
|
|
||||||
// principalId, so that — not the opaque member id — is the value the form must send.
|
|
||||||
const membersQ = useQuery<{ items: MemberDTO[] }>("crm.team.member.search", { perPage: 100 });
|
|
||||||
|
|
||||||
const reps: Rep[] = useMemo(() => (membersQ.data?.items ?? []).map((m) => {
|
|
||||||
const name = m.displayName?.trim()
|
|
||||||
|| [m.firstName, m.lastName].filter(Boolean).join(" ").trim()
|
|
||||||
|| m.jobTitle?.trim()
|
|
||||||
|| `Member ${(m.principalId ?? m.id).replace(/^pp_/, "").slice(0, 6)}`;
|
|
||||||
return {
|
|
||||||
id: m.principalId ?? m.id,
|
|
||||||
initials: name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?",
|
|
||||||
name,
|
|
||||||
email: m.email ?? "",
|
|
||||||
};
|
|
||||||
}), [membersQ.data]);
|
|
||||||
|
|
||||||
const refetch = useCallback(() => { listQ.refetch(); statsQ.refetch(); }, [listQ, statsQ]);
|
|
||||||
const cmd = useCallback(async (action: string, variables: Record<string, unknown>) => {
|
|
||||||
await sdk.command(action, variables);
|
|
||||||
refetch();
|
|
||||||
}, [sdk, refetch]);
|
|
||||||
|
|
||||||
const leads = useMemo(() => (listQ.data?.items ?? []).map(cardToLead), [listQ.data]);
|
|
||||||
|
|
||||||
const byStatus = useMemo(() => {
|
|
||||||
if (statsQ.data?.byStatus) return { ...EMPTY_BY_STATUS, ...statsQ.data.byStatus };
|
|
||||||
const m = { ...EMPTY_BY_STATUS };
|
|
||||||
for (const l of leads) m[l.status] += 1;
|
|
||||||
return m;
|
|
||||||
}, [statsQ.data, leads]);
|
|
||||||
|
|
||||||
const getLead = useCallback(async (lead: Lead): Promise<Lead> => {
|
|
||||||
const dto = await sdk.query<LeadDTO>("crm.lead.get", { id: lead.refId ?? lead.id });
|
|
||||||
return detailToLead(dto, lead, reps);
|
|
||||||
}, [sdk, reps]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
live: true,
|
|
||||||
loading: listQ.loading || statsQ.loading,
|
|
||||||
error: (listQ.error ?? statsQ.error)?.message ?? null,
|
|
||||||
leads,
|
|
||||||
total: statsQ.data?.total ?? listQ.data?.meta?.total ?? leads.length,
|
|
||||||
byStatus,
|
|
||||||
reps,
|
|
||||||
getLead,
|
|
||||||
uploadPhoto,
|
|
||||||
getPhotoUrl,
|
|
||||||
addPhoto: async (lead, attachment) => {
|
|
||||||
await sdk.command("crm.lead.attachment.add", { id: lead.refId ?? lead.id, attachment });
|
|
||||||
},
|
|
||||||
removePhoto: async (lead, attachment) => {
|
|
||||||
await sdk.command("crm.lead.attachment.remove", { id: lead.refId ?? lead.id, attachmentId: attachment.id });
|
|
||||||
},
|
|
||||||
// Photos are NOT sent in the create payload — they persist via the dedicated
|
|
||||||
// crm.lead.attachment.add command, keyed by the freshly-created lead's id (§ postman #24).
|
|
||||||
createLead: async (input) => {
|
|
||||||
const { photos, ...rest } = input;
|
|
||||||
// bffCommand (not sdk.command) so the backend's duplicate_lead / validation message
|
|
||||||
// reaches the toast instead of the SDK's opaque "data command failed: 400".
|
|
||||||
const created = await bffCommand<{ id: string }>("crm.lead.create", rest);
|
|
||||||
for (const attachment of photos ?? []) {
|
|
||||||
await sdk.command("crm.lead.attachment.add", { id: created.id, attachment });
|
|
||||||
}
|
|
||||||
refetch();
|
|
||||||
},
|
|
||||||
updateLead: (lead, patch) => cmd("crm.lead.update", { id: lead.refId ?? lead.id, patch }),
|
|
||||||
updateStatus: (lead, status, note) => cmd("crm.lead.updateStatus", { id: lead.refId ?? lead.id, status, ...(note ? { note } : {}) }),
|
|
||||||
assign: (lead, assigneeId) => cmd("crm.lead.assign", { id: lead.refId ?? lead.id, assigneeId }),
|
|
||||||
// Push into the verification queue. Idempotent server-side (returns the existing verification
|
|
||||||
// if the lead was already sent). Refetch so the lead's verificationId lands and the guard persists.
|
|
||||||
sendForVerification: async (lead) => {
|
|
||||||
await sdk.command("crm.lead.sendForVerification", { id: lead.refId ?? lead.id });
|
|
||||||
refetch();
|
|
||||||
},
|
|
||||||
refetch,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- public hook: pick the implementation at module-config time --------- */
|
|
||||||
|
|
||||||
const SHELL = isShellConfigured();
|
|
||||||
|
|
||||||
export function useLeadsData(): LeadsData {
|
|
||||||
// SHELL is a build-time constant, so the same hook path runs every render (Rules-of-Hooks safe).
|
|
||||||
return SHELL ? useLiveLeads() : useMockLeads();
|
|
||||||
}
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
// Lead Verification data layer. Serves EITHER the local mock (when the Shell isn't configured —
|
|
||||||
// the demo keeps working) OR the live be-crm data door (crm.leadVerification.*), behind one
|
|
||||||
// interface so the verification desk is mode-agnostic. Mirrors team-api.ts / leads-api.ts.
|
|
||||||
//
|
|
||||||
// Live contract (be-crm):
|
|
||||||
// query crm.leadVerification.search { query?, status?, source?, assigneeId?, page?, perPage? }
|
|
||||||
// -> { items: VerificationRowDTO[], meta }
|
|
||||||
// query crm.leadVerification.get { id } -> VerificationDTO (+ activities)
|
|
||||||
// query crm.leadVerification.stats {} -> { verified, in_progress, assigned, pending, unverified }
|
|
||||||
// cmd crm.leadVerification.verify { id, notes? } -> { verification, lead } (promotes → Lead)
|
|
||||||
// cmd crm.leadVerification.markUnverified { id, reason? } -> VerificationDTO
|
|
||||||
// cmd crm.leadVerification.assign { id, assigneeId } -> VerificationDTO
|
|
||||||
// cmd crm.leadVerification.reassign { id, assigneeId? } -> VerificationDTO (→ in_progress)
|
|
||||||
// cmd crm.leadVerification.moveToPending { id } -> VerificationDTO
|
|
||||||
// query crm.team.member.search { perPage } (reused) -> assignee picker options
|
|
||||||
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
|
||||||
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
|
|
||||||
import { isShellConfigured } from "./appshell";
|
|
||||||
import {
|
|
||||||
V_LEADS as seedVLeads, V_ASSIGNEES,
|
|
||||||
type VLead, type VStatus, type VActivity,
|
|
||||||
} from "@/components/dashboard/verify-data";
|
|
||||||
|
|
||||||
export interface Assignee { id: string; name: string; initials: string }
|
|
||||||
|
|
||||||
export interface VerifyData {
|
|
||||||
live: boolean; loading: boolean; error: string | null;
|
|
||||||
leads: VLead[];
|
|
||||||
total: number;
|
|
||||||
counts: Record<VStatus, number>;
|
|
||||||
assignees: Assignee[];
|
|
||||||
/** Hydrate a single record's detail (notes, activity, verifiedAt) — synthesized in mock mode. */
|
|
||||||
getVerification: (lead: VLead) => Promise<VLead>;
|
|
||||||
verify: (lead: VLead, notes?: string) => Promise<void>;
|
|
||||||
markUnverified: (lead: VLead, reason?: string) => Promise<void>;
|
|
||||||
assign: (lead: VLead, assigneeId: string) => Promise<void>;
|
|
||||||
reassign: (lead: VLead, assigneeId?: string) => Promise<void>;
|
|
||||||
moveToPending: (lead: VLead) => Promise<void>;
|
|
||||||
refetch: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- helpers ------------------------------------------------------------ */
|
|
||||||
|
|
||||||
const initialsOf = (name: string) =>
|
|
||||||
name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
|
|
||||||
|
|
||||||
/** Resolve an assignee ref to a display name. be-crm echoes the raw principalId as the `name`,
|
|
||||||
* so prefer the local assignee list (principalId → real name); fall back to a backend name only
|
|
||||||
* when it isn't just the id, then to the id itself. Mirrors leads-api's resolveMemberName. */
|
|
||||||
function resolveAssigneeName(id: string, backendName: string, assignees: Assignee[]): string {
|
|
||||||
const a = id ? assignees.find((x) => x.id === id) : undefined;
|
|
||||||
if (a) return a.name;
|
|
||||||
if (backendName && backendName !== id) return backendName;
|
|
||||||
return backendName || id;
|
|
||||||
}
|
|
||||||
const prettyDate = (iso?: string | null): string => {
|
|
||||||
if (!iso) return "—";
|
|
||||||
const then = Date.parse(iso);
|
|
||||||
if (Number.isNaN(then)) return iso;
|
|
||||||
return new Date(then).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
|
|
||||||
};
|
|
||||||
const prettyDateTime = (iso?: string | null): string | undefined => {
|
|
||||||
if (!iso) return undefined;
|
|
||||||
const then = Date.parse(iso);
|
|
||||||
if (Number.isNaN(then)) return iso;
|
|
||||||
return new Date(then).toLocaleString(undefined, { day: "numeric", month: "short", year: "numeric", hour: "numeric", minute: "2-digit" });
|
|
||||||
};
|
|
||||||
|
|
||||||
const EMPTY_COUNTS: Record<VStatus, number> = { verified: 0, in_progress: 0, assigned: 0, pending: 0, unverified: 0 };
|
|
||||||
|
|
||||||
const SUB_STATUS_DEFAULT: Record<VStatus, string> = {
|
|
||||||
verified: "Verified", in_progress: "Verifying Identity", assigned: "Assigned",
|
|
||||||
pending: "Pending Review", unverified: "Unverified",
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ---- be-crm DTO types (defensive: backend is greenfield) ---------------- */
|
|
||||||
|
|
||||||
interface VerificationRowDTO {
|
|
||||||
id: string; code: string; name: string; initials?: string;
|
|
||||||
address?: string; phone?: string; source?: string;
|
|
||||||
assignee?: { id?: string; initials?: string; name: string } | null;
|
|
||||||
status?: VStatus; verification?: string; createdAt?: string;
|
|
||||||
}
|
|
||||||
interface ActivityDTO { text: string; occurredAt?: string; time?: string; actorLabel?: string; who?: string }
|
|
||||||
interface VerificationDTO extends VerificationRowDTO {
|
|
||||||
email?: string; notes?: string; verifiedAt?: string; activities?: ActivityDTO[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- DTO → UI VLead ----------------------------------------------------- */
|
|
||||||
|
|
||||||
function rowToVLead(d: VerificationRowDTO, assignees: Assignee[] = []): VLead {
|
|
||||||
const status = (d.status ?? "pending") as VStatus;
|
|
||||||
let assignee: VLead["assignee"] = null;
|
|
||||||
if (d.assignee) {
|
|
||||||
const id = d.assignee.id ?? "";
|
|
||||||
const name = resolveAssigneeName(id, d.assignee.name, assignees);
|
|
||||||
assignee = { id, initials: d.assignee.initials || initialsOf(name), name };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: d.code || d.id,
|
|
||||||
refId: d.id,
|
|
||||||
initials: d.initials || (d.name ? d.name[0].toUpperCase() : "?"),
|
|
||||||
name: d.name ?? "",
|
|
||||||
address: d.address ?? "",
|
|
||||||
phone: d.phone ?? "",
|
|
||||||
source: d.source ?? "",
|
|
||||||
assignee,
|
|
||||||
status,
|
|
||||||
verification: d.verification ?? SUB_STATUS_DEFAULT[status],
|
|
||||||
created: prettyDate(d.createdAt),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function detailToVLead(d: VerificationDTO, fallback: VLead, assignees: Assignee[] = []): VLead {
|
|
||||||
const base = rowToVLead(d, assignees);
|
|
||||||
return {
|
|
||||||
...base,
|
|
||||||
email: d.email,
|
|
||||||
notes: d.notes,
|
|
||||||
verifiedAt: prettyDateTime(d.verifiedAt),
|
|
||||||
createdAt: prettyDateTime(d.createdAt) ?? fallback.createdAt,
|
|
||||||
activity: d.activities?.length
|
|
||||||
? d.activities.map<VActivity>((a) => ({
|
|
||||||
text: a.text,
|
|
||||||
time: prettyDateTime(a.occurredAt) ?? a.time ?? "",
|
|
||||||
who: a.actorLabel ?? a.who ?? "System",
|
|
||||||
}))
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================================================================== */
|
|
||||||
/* Mock implementation (no Shell configured) */
|
|
||||||
/* ======================================================================== */
|
|
||||||
|
|
||||||
function useMockVerify(): VerifyData {
|
|
||||||
const [leads, setLeads] = useState<VLead[]>(() => seedVLeads);
|
|
||||||
|
|
||||||
const counts = useMemo(() => {
|
|
||||||
const m = { ...EMPTY_COUNTS };
|
|
||||||
for (const l of leads) m[l.status] += 1;
|
|
||||||
return m;
|
|
||||||
}, [leads]);
|
|
||||||
|
|
||||||
const assignees = useMemo<Assignee[]>(
|
|
||||||
() => V_ASSIGNEES.map((name) => ({ id: name, name, initials: initialsOf(name) })),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const patch = useCallback((lead: VLead, next: Partial<VLead>) => {
|
|
||||||
setLeads((l) => l.map((x) => (x.id === lead.id ? { ...x, ...next } : x)));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getVerification = useCallback(async (lead: VLead) => lead, []);
|
|
||||||
const verify = useCallback(async (lead: VLead) => {
|
|
||||||
patch(lead, { status: "verified", verification: "Verified" });
|
|
||||||
}, [patch]);
|
|
||||||
const markUnverified = useCallback(async (lead: VLead) => {
|
|
||||||
patch(lead, { status: "unverified", verification: "Unverified" });
|
|
||||||
}, [patch]);
|
|
||||||
const assign = useCallback(async (lead: VLead, assigneeId: string) => {
|
|
||||||
const a = assignees.find((x) => x.id === assigneeId);
|
|
||||||
patch(lead, { status: "assigned", verification: "Assigned", assignee: a ? { id: a.id, initials: a.initials, name: a.name } : lead.assignee });
|
|
||||||
}, [assignees, patch]);
|
|
||||||
const reassign = useCallback(async (lead: VLead, assigneeId?: string) => {
|
|
||||||
const a = assigneeId ? assignees.find((x) => x.id === assigneeId) : undefined;
|
|
||||||
patch(lead, { status: "in_progress", verification: "Verifying Identity", ...(a ? { assignee: { id: a.id, initials: a.initials, name: a.name } } : {}) });
|
|
||||||
}, [assignees, patch]);
|
|
||||||
const moveToPending = useCallback(async (lead: VLead) => {
|
|
||||||
patch(lead, { status: "pending", verification: "Pending Review" });
|
|
||||||
}, [patch]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
live: false, loading: false, error: null,
|
|
||||||
leads, total: leads.length, counts, assignees,
|
|
||||||
getVerification, verify, markUnverified, assign, reassign, moveToPending, refetch: () => {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ======================================================================== */
|
|
||||||
/* Live implementation (be-crm data door) */
|
|
||||||
/* ======================================================================== */
|
|
||||||
|
|
||||||
interface Page<T> { items: T[]; meta?: { total?: number } }
|
|
||||||
interface MemberDTO { id: string; principalId?: string; displayName?: string | null; firstName?: string | null; lastName?: string | null; jobTitle?: string | null }
|
|
||||||
|
|
||||||
function useLiveVerify(): VerifyData {
|
|
||||||
const { sdk } = useAppShell();
|
|
||||||
// A collection read may come back as { items, meta } OR as a bare array (§6) — tolerate both.
|
|
||||||
const listQ = useQuery<Page<VerificationRowDTO> | VerificationRowDTO[]>("crm.leadVerification.search", { perPage: 100 });
|
|
||||||
const statsQ = useQuery<Partial<Record<VStatus, number>>>("crm.leadVerification.stats", {});
|
|
||||||
const membersQ = useQuery<{ items: MemberDTO[] }>("crm.team.member.search", { perPage: 100 });
|
|
||||||
|
|
||||||
const refetch = useCallback(() => { listQ.refetch(); statsQ.refetch(); }, [listQ, statsQ]);
|
|
||||||
|
|
||||||
// be-crm resolves the assignee ref by principalId, so send that (not the opaque member id).
|
|
||||||
// This list also lets us resolve an assignee id → real name for display (be-crm echoes the id).
|
|
||||||
const assignees = useMemo<Assignee[]>(() => (membersQ.data?.items ?? []).map((m) => {
|
|
||||||
const name = m.displayName?.trim()
|
|
||||||
|| [m.firstName, m.lastName].filter(Boolean).join(" ").trim()
|
|
||||||
|| m.jobTitle?.trim()
|
|
||||||
|| `Member ${(m.principalId ?? m.id).slice(0, 6)}`;
|
|
||||||
return { id: m.principalId ?? m.id, name, initials: initialsOf(name) };
|
|
||||||
}), [membersQ.data]);
|
|
||||||
|
|
||||||
const rawItems = useMemo(
|
|
||||||
() => (Array.isArray(listQ.data) ? listQ.data : listQ.data?.items ?? []),
|
|
||||||
[listQ.data],
|
|
||||||
);
|
|
||||||
const base = useMemo(() => rawItems.map((d) => rowToVLead(d, assignees)), [rawItems, assignees]);
|
|
||||||
|
|
||||||
// Optimistic status overrides keyed by lead id. After a transition command we patch the row
|
|
||||||
// locally so the Status column updates immediately, even if the server's search read lags
|
|
||||||
// (read-after-write). An override always reflects the user's last action, so it stays correct
|
|
||||||
// whether the refetch is stale or fresh — no reconciliation needed.
|
|
||||||
const [overrides, setOverrides] = useState<Record<string, Partial<VLead>>>({});
|
|
||||||
|
|
||||||
const leads = useMemo(
|
|
||||||
() => base.map((l) => (overrides[l.id] ? { ...l, ...overrides[l.id] } : l)),
|
|
||||||
[base, overrides],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Run a transition command, then optimistically patch the row + refetch to reconcile.
|
|
||||||
const mutate = useCallback(async (lead: VLead, patch: Partial<VLead>, action: string, variables: Record<string, unknown>) => {
|
|
||||||
await sdk.command(action, variables);
|
|
||||||
setOverrides((o) => ({ ...o, [lead.id]: { ...o[lead.id], ...patch } }));
|
|
||||||
refetch();
|
|
||||||
}, [sdk, refetch]);
|
|
||||||
|
|
||||||
// Derive tile counts from the (optimistically patched) rows so they move in lockstep with the
|
|
||||||
// table. With perPage 100 this covers the loaded queue; a transition reflects immediately.
|
|
||||||
const counts = useMemo(() => {
|
|
||||||
const m = { ...EMPTY_COUNTS };
|
|
||||||
for (const l of leads) m[l.status] += 1;
|
|
||||||
return m;
|
|
||||||
}, [leads]);
|
|
||||||
|
|
||||||
const getVerification = useCallback(async (lead: VLead): Promise<VLead> => {
|
|
||||||
const dto = await sdk.query<VerificationDTO>("crm.leadVerification.get", { id: lead.refId ?? lead.id });
|
|
||||||
return detailToVLead(dto, lead, assignees);
|
|
||||||
}, [sdk, assignees]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
live: true,
|
|
||||||
loading: listQ.loading || statsQ.loading,
|
|
||||||
error: (listQ.error ?? statsQ.error)?.message ?? null,
|
|
||||||
leads,
|
|
||||||
total: (Array.isArray(listQ.data) ? undefined : listQ.data?.meta?.total) ?? leads.length,
|
|
||||||
counts,
|
|
||||||
assignees,
|
|
||||||
getVerification,
|
|
||||||
verify: (lead, notes) => mutate(lead, { status: "verified", verification: "Verified" },
|
|
||||||
"crm.leadVerification.verify", { id: lead.refId ?? lead.id, ...(notes ? { notes } : {}) }),
|
|
||||||
markUnverified: (lead, reason) => mutate(lead, { status: "unverified", verification: "Unverified" },
|
|
||||||
"crm.leadVerification.markUnverified", { id: lead.refId ?? lead.id, ...(reason ? { reason } : {}) }),
|
|
||||||
assign: (lead, assigneeId) => mutate(lead, { status: "assigned", verification: "Assigned" },
|
|
||||||
"crm.leadVerification.assign", { id: lead.refId ?? lead.id, assigneeId }),
|
|
||||||
reassign: (lead, assigneeId) => mutate(lead, { status: "in_progress", verification: "Verifying Identity" },
|
|
||||||
"crm.leadVerification.reassign", { id: lead.refId ?? lead.id, ...(assigneeId ? { assigneeId } : {}) }),
|
|
||||||
moveToPending: (lead) => mutate(lead, { status: "pending", verification: "Pending Review" },
|
|
||||||
"crm.leadVerification.moveToPending", { id: lead.refId ?? lead.id }),
|
|
||||||
refetch,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- public hook: pick the implementation at module-config time --------- */
|
|
||||||
|
|
||||||
const SHELL = isShellConfigured();
|
|
||||||
|
|
||||||
export function useVerifyData(): VerifyData {
|
|
||||||
// SHELL is a build-time constant, so the same hook path runs every render (Rules-of-Hooks safe).
|
|
||||||
return SHELL ? useLiveVerify() : useMockVerify();
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user