3 Commits

Author SHA1 Message Date
abe-kap b8cdebd96a Merge remote-tracking branch 'origin/goutamnextflow' into feat/projects
# Conflicts:
#	src/app/dashboard/dashboard.css
#	src/components/dashboard/dashboard.tsx
#	src/components/dashboard/ui.tsx
2026-07-23 12:52:13 -04:00
abe-kap 948b38a016 Merge remote-tracking branch 'origin/goutamnextflow' into feat/projects
# Conflicts:
#	src/app/dashboard/dashboard.css
#	src/components/dashboard/dashboard.tsx
2026-07-22 17:26:45 -04:00
abe-kap d44cacb778 feat(projects): add the Projects CRM module 2026-07-22 17:19:23 -04:00
16 changed files with 826 additions and 2547 deletions
-99
View File
@@ -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
View File
@@ -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" } }
-554
View File
@@ -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)."
}
}
]
}
]
}
+35 -31
View File
@@ -447,8 +447,10 @@
.dash-root .ds-modal.size-sm { max-width: 400px; }
.dash-root .ds-modal.size-md { max-width: 540px; }
.dash-root .ds-modal.size-lg { max-width: 760px; }
.dash-root .ds-modal.size-xl { max-width: 980px; }
.dash-root .ds-modal-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 20px 20px 14px; border-bottom: 1px solid var(--border); }
.dash-root .ds-modal-head-l { display: flex; gap: 12px; align-items: center; }
.dash-root .ds-modal-head-r { display: flex; gap: 8px; align-items: center; flex: 0 0 auto; }
.dash-root .ds-modal-ic { width: 38px; height: 38px; border-radius: 11px; flex: 0 0 auto; display: grid; place-items: center; color: var(--orange); background: color-mix(in srgb, var(--orange) 14%, transparent); }
.dash-root .ds-modal-head h3 { font-size: 16px; font-weight: 700; }
.dash-root .ds-modal-head p { font-size: 12.5px; color: var(--muted); margin-top: 3px; }
@@ -922,7 +924,7 @@
.dash-root .tm .ds-btn.v-primary { background: rgba(253, 169, 19, 0.92); color: #fff; border-radius: 9.132px; box-shadow: 0 8px 20px -10px rgba(253, 169, 19, 0.75); }
.dash-root .tm .ds-btn.v-primary svg { color: #fff; }
.dash-root .tm .ds-btn.v-primary:not(:disabled):hover { background: rgba(253, 169, 19, 1); }
.dash-root .tm-toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.dash-root .tm-toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
.dash-root .tm-search { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 220px; height: 44px; padding: 0 13px; border-radius: 13px; border: 1px solid var(--border-2); background: var(--panel-2); color: var(--muted); transition: 0.14s; }
.dash-root .tm-search:focus-within { border-color: var(--orange); box-shadow: 0 0 0 3px var(--ring); color: var(--orange); }
.dash-root .tm-search .ds-input { flex: 1; }
@@ -1154,8 +1156,40 @@
.dash-root .ai-caps { grid-template-columns: 1fr; }
.dash-root .ai-bubble { max-width: 86%; }
.dash-root .ai-view { height: calc(100vh - 150px); }
.dash-root .proj-row { grid-template-columns: 1fr !important; row-gap: 8px; padding: 14px 16px; }
.dash-root .proj-head { display: none; }
.dash-root .proj-c-act { justify-self: end; }
}
/* ---- Projects (Construction + Pipeline Leads) ---- */
.dash-root .proj-toolbar .tm-tabs { flex: 0 0 auto; }
.dash-root .proj-toolbar .tm-chips { flex: 0 0 auto; margin-left: auto; }
.dash-root .proj-table { display: flex; flex-direction: column; }
.dash-root .proj-row { display: grid; align-items: center; gap: 14px; padding: 13px 18px; border-bottom: 1px solid var(--border); }
.dash-root .proj-row:last-child { border-bottom: 0; }
.dash-root .proj-head { color: var(--faint); font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; text-align: center; }
.dash-root .proj-row:not(.proj-head):hover { background: color-mix(in srgb, var(--panel-2) 60%, transparent); }
.dash-root .proj-lead { min-width: 0; }
.dash-root .proj-lead-name { font-size: 13.5px; font-weight: 700; }
.dash-root .proj-lead-sub { font-size: 12px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dash-root .proj-text { font-size: 13px; color: var(--text-2); text-align: center; }
.dash-root .proj-c-num { text-align: center; }
.dash-root .proj-row .ds-pill { justify-self: center; justify-content: center; }
.dash-root .proj-c-act { display: flex; justify-content: center; align-items: center; gap: 6px; position: relative; }
.dash-root .proj-cd-search { margin-bottom: 14px; }
.dash-root .proj-cd-table { margin-bottom: 14px; }
.dash-root .proj-progress { display: flex; align-items: center; gap: 8px; }
.dash-root .proj-progress-track { flex: 1; display: block; height: 6px; border-radius: 99px; background: var(--track); overflow: hidden; }
.dash-root .proj-progress-fill { display: block; height: 100%; border-radius: 99px; background: var(--orange); transition: width 0.5s cubic-bezier(0.2,0.7,0.2,1); }
.dash-root .proj-progress b { font-size: 12px; font-weight: 700; color: var(--text-2); flex: 0 0 auto; }
.dash-root .proj-health { font-weight: 800; text-align: center; }
.dash-root .proj-empty { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 54px 20px; color: var(--muted); text-align: center; }
.dash-root .proj-foot { display: flex; align-items: center; justify-content: space-between; padding: 16px 4px 0; font-size: 12.5px; color: var(--muted); }
.dash-root .proj-foot b { color: var(--text); font-size: 15px; font-weight: 800; }
.dash-root .proj-detail-row { display: flex; align-items: center; justify-content: space-between; padding: 9px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
.dash-root .proj-detail-row span { color: var(--muted); }
.dash-root .proj-detail-row b { color: var(--text); font-weight: 700; }
/* ========================================================== */
/* Leads — pipeline board + rich detail popup */
/* ========================================================== */
@@ -1278,23 +1312,8 @@
.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-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)); }
/* 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) */
.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; }
@@ -1373,21 +1392,6 @@
.lv-menu-item svg { color: var(--muted, #8c8c8c); flex: 0 0 auto; }
.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 ---- */
.dash-root .lv-detail { display: flex; flex-direction: column; gap: 16px; }
.dash-root .lv-d-identity { display: flex; align-items: center; gap: 14px; }
+2
View File
@@ -18,6 +18,7 @@ import { TeamManagement } from "./team-management";
import { MessengerSdk } from "./messenger-sdk";
import { InboxSdk } from "./inbox-sdk";
import { Settings } from "./settings";
import { Projects } from "./projects";
import { NotificationCenter } from "./notification-center";
import { RealtimeProvider } from "@/lib/realtime";
import { SmartGallery } from "./smart-gallery";
@@ -88,6 +89,7 @@ export function Dashboard() {
: active === "leads" ? <Leads />
: active === "verify" ? <Verify />
: active === "team" ? <TeamManagement />
: active === "projects" ? <Projects />
: <ComingSoon title={title} icon={item?.icon ?? "dashboard"} onGo={setActive} />}
</ToastProvider>
</div>
+1 -11
View File
@@ -12,16 +12,9 @@ export type LeadPriority = "high" | "medium" | "low";
export type Phone = { number: string; type: "Mobile" | "Home" | "Work"; 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 = {
id: string; // SAL-001 (display code)
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;
id: string; // SAL-001
initials: string;
name: string;
gradient: string;
@@ -38,9 +31,6 @@ export type Lead = {
phones: Phone[];
emails: Email[];
// site photos (uploaded via the media presign flow; shown in the detail popup)
attachments?: Attachment[];
// property
property: { address: string; city: string; state: string; zip: string; type: string };
+131 -500
View File
@@ -11,15 +11,13 @@
// 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 {
STATUS_META, PRIORITY_META,
LEAD_SOURCES, LEAD_TYPES, PROPERTY_TYPES, WORK_TYPES, TRADE_TYPES, CLAIM_STATUSES,
type Lead, type LeadStatus, type Rep, type Attachment,
LEADS, TOTAL_LEADS, STATUS_META, PRIORITY_META,
REPS, LEAD_SOURCES, WORK_TYPES, TRADE_TYPES, CLAIM_STATUSES,
type Lead, type LeadStatus,
} 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 }[] = [
{ value: "all", label: "All" },
@@ -30,50 +28,44 @@ const STATUS_TABS: { value: "all" | LeadStatus; label: string }[] = [
];
export function Leads() {
const data = useLeadsData();
const { leads, total, byStatus, loading, error } = data;
const toast = useToast();
const [query, setQuery] = useState("");
const [filter, setFilter] = useState<"all" | LeadStatus>("all");
const [selected, setSelected] = useState<Lead | null>(null);
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 [sentIds, setSentIds] = useState<Set<string>>(() => new Set());
const idOf = (l: Lead) => l.refId ?? l.id;
const countByStatus = useMemo(() => {
const m: Record<string, number> = {};
for (const l of LEADS) m[l.status] = (m[l.status] ?? 0) + 1;
return m;
}, []);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return leads.filter((l) => {
return LEADS.filter((l) => {
const matchStatus = filter === "all" || l.status === filter;
const hay = `${l.name} ${l.property.address} ${l.property.city} ${l.job.source} ${l.job.canvasser}`.toLowerCase();
return matchStatus && (!q || hay.includes(q));
});
}, [leads, 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(() => {});
};
}, [query, filter]);
return (
<div className="view leads">
<PageHead
eyebrow="Sales"
title="Leads"
subtitle={`${total} total leads`}
subtitle={`${TOTAL_LEADS} total leads · Plano hail zone · storm 2026-04-28`}
icon="leads"
actions={<Btn icon="plus" onClick={() => setNewOpen(true)}>New Lead</Btn>}
/>
{/* ---- stat strip ---------------------------------------- */}
<div className="leads-stats">
<StatCard label="Total leads" value={total} icon="leads" tone="orange" />
<StatCard label="New" value={byStatus.new} icon="star" tone="blue" />
<StatCard label="Contacted" value={byStatus.contacted} icon="phone" tone="orange" />
<StatCard label="Appointed" value={byStatus.appointed} icon="clock" tone="purple" />
<StatCard label="Closed" value={byStatus.closed} icon="check-circle" tone="green" />
<StatCard label="Total leads" value={TOTAL_LEADS} icon="leads" tone="orange" />
<StatCard label="New" value={countByStatus.new ?? 0} icon="star" tone="blue" />
<StatCard label="Contacted" value={countByStatus.contacted ?? 0} icon="phone" tone="orange" />
<StatCard label="Appointed" value={countByStatus.appointed ?? 0} icon="clock" tone="purple" />
<StatCard label="Closed" value={countByStatus.closed ?? 0} icon="check-circle" tone="green" />
</div>
{/* ---- toolbar ------------------------------------------- */}
@@ -104,19 +96,7 @@ export function Leads() {
</div>
{/* ---- board --------------------------------------------- */}
{error ? (
<div className="card leads-empty">
<Icon name="alert" size={30} />
<h3>Couldnt 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 ? (
{filtered.length === 0 ? (
<div className="card leads-empty">
<Icon name="search" size={30} />
<h3>No leads match</h3>
@@ -125,21 +105,13 @@ export function Leads() {
) : (
<div className="leads-grid">
{filtered.map((l) => (
<LeadCard key={l.id} lead={l} onOpen={() => openLead(l)} />
<LeadCard key={l.id} lead={l} onOpen={() => setSelected(l)} />
))}
</div>
)}
<LeadDetail
lead={selected}
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} />
<LeadDetail lead={selected} onClose={() => setSelected(null)} />
<NewLead open={newOpen} onClose={() => setNewOpen(false)} />
</div>
);
}
@@ -177,7 +149,7 @@ function LeadCard({ lead, onOpen }: { lead: Lead; onOpen: () => void }) {
</span>
<div className="lead-card-id">
<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>
<Pill tone={priority.tone}><Icon name="alert" size={11} /> {priority.label}</Pill>
</div>
@@ -200,365 +172,116 @@ function LeadCard({ lead, onOpen }: { lead: Lead; onOpen: () => void }) {
/* Lead detail popup */
/* ---------------------------------------------------------- */
type LeadEdit = {
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 }) {
function LeadDetail({ lead, onClose }: { lead: Lead | null; onClose: () => void }) {
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 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: "Couldnt send", desc: e instanceof Error ? e.message : "Please try again." });
} finally { setSending(false); }
}
return (
<Modal
open
open={!!lead}
onClose={onClose}
size="lg"
title={lead.name}
subtitle={lead.id}
subtitle={`${lead.id} · ${lead.tag}`}
icon="leads"
footer={editing ? (
footer={
<>
<Btn variant="ghost" onClick={cancelEdit} disabled={saving}>Cancel</Btn>
<Btn icon="check" onClick={save} disabled={saving}>{saving ? "Saving…" : "Save Changes"}</Btn>
<Btn variant="ghost" icon="phone">Call</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="nl-form lead-edit">
<div className="ld-section-head"><Icon name="user" size={15} /> Contact</div>
<div className="nl-grid">
<Field label="First Name" required><input className="ds-input" value={f.firstName} onChange={setField("firstName")} placeholder="John" /></Field>
<Field label="Last Name"><input className="ds-input" value={f.lastName} onChange={setField("lastName")} placeholder="Smith" /></Field>
<div className="nl-full">
<div className="ds-field-lbl">Phone Numbers</div>
{f.phones.map((p, i) => (
<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 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 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 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 */}
{(lead.storm.zone || lead.storm.date || lead.storm.detail) && (
<div className="ld-storm">
<span className="ld-storm-ic"><Icon name="storm" size={18} /></span>
<div>
<div className="ld-storm-zone">{lead.storm.zone}</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>
{/* storm banner */}
<div className="ld-storm">
<span className="ld-storm-ic"><Icon name="storm" size={18} /></span>
<div>
<div className="ld-storm-zone">{lead.storm.zone}</div>
<div className="ld-storm-meta">{lead.storm.date} · {lead.storm.detail}</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>
);
}
@@ -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 */
/* ---------------------------------------------------------- */
@@ -620,29 +321,25 @@ const FULL_STEPS = [
{ value: "assignment", label: "Assignment", icon: "team" },
];
// Option lists must match the be-crm enums, or crm.lead.create rejects the value (§8.18.4).
const LEAD_TYPE_OPTS = LEAD_TYPES; // Insurance | Retail
const PROPERTY_TYPE_OPTS = PROPERTY_TYPES; // Single Family | Multi Family | Commercial
const LEAD_TYPE_OPTS = ["Residential", "Commercial", "Multi-Family"];
const PROPERTY_TYPE_OPTS = ["Residential", "Commercial", "Multi-Family", "Industrial"];
const BLANK = {
firstName: "", lastName: "",
phones: [{ number: "", type: "Mobile" }] as PhoneRow[],
emails: [] as EmailRow[],
address: "", city: "", state: "TX", zip: "", propertyType: "",
photos: [] as UploadedAttachment[],
photos: [] as string[],
source: "", referralNote: "", canvasser: "", leadType: "", workType: "", tradeType: "", urgency: "Standard" as Urgency, notes: "",
insCompany: "", claimNumber: "", claimStatus: "", adjusterName: "", adjusterPhone: "", policyNumber: "",
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 [mode, setMode] = useState<"quick" | "full">("quick");
const [section, setSection] = useState("contact");
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 } }) =>
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 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 removePhoto = (i: number) => setF((s) => ({ ...s, photos: s.photos.filter((_, j) => j !== i) }));
// 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);
}
}
}
const addPhoto = () => setF((s) => ({ ...s, photos: [...s.photos, `Photo ${s.photos.length + 1}`] }));
function reset() { setF({ ...BLANK, phones: [{ number: "", type: "Mobile" }], emails: [], photos: [] }); setMode("quick"); setSection("contact"); }
function close() { reset(); onClose(); }
function buildPayload(): CreateLeadInput {
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() {
function submit() {
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; }
setSaving(true);
try {
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: "Couldnt create lead", desc: e instanceof Error ? e.message : "Please try again." });
} finally {
setSaving(false);
}
toast.push({ tone: "success", title: "Lead created", desc: `${name} added to the Plano pipeline.` });
close();
}
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 isFirstStep = stepIdx <= 0;
@@ -741,13 +385,13 @@ function NewLead({ open, onClose, createLead, reps, uploadPhoto }: { open: boole
<Btn variant="ghost" onClick={close}>Cancel</Btn>
{!isFirstStep && <Btn variant="ghost" onClick={goBack}>Back</Btn>}
{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 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>
)}
{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>
<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>
<div className="nl-full">
<div className="ds-field-lbl">Site Photos</div>
<input
ref={fileRef}
type="file"
accept="image/*"
multiple
hidden
onChange={onPickPhotos}
/>
<button type="button" className="nl-photos" onClick={() => fileRef.current?.click()}>
<button type="button" className="nl-photos" onClick={addPhoto}>
<Icon name="camera" size={22} />
<span className="nl-photos-t">{uploading > 0 ? `Uploading ${uploading}` : "Tap to add photos"}</span>
<span className="nl-photos-s">Camera · Gallery · Multiple allowed · max 25 MB</span>
<span className="nl-photos-t">Tap to add photos</span>
<span className="nl-photos-s">Camera · Gallery · Multiple allowed</span>
</button>
{f.photos.length > 0 && (
<div className="nl-photo-chips">
{f.photos.map((p, i) => (
<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>
))}
{f.photos.map((p, i) => <span key={i} className="nl-photo-chip"><Icon name="check" size={12} /> {p}</span>)}
</div>
)}
</div>
+148
View File
@@ -0,0 +1,148 @@
// ============================================================
// LynkedUp Pro — Projects mock data (Construction jobs + Pipeline
// leads). Used only when the Shell isn't configured, so the demo
// stays fully interactive without a backend — mirrors team-data.ts.
// ============================================================
export type ConstructionStatus = "active" | "complete" | "stuck" | "followup";
export type ConstructionLead = {
id: string;
name: string;
address: string;
status: ConstructionStatus;
stage: string;
jobType: string;
agent: string;
progress: number; // 0-100
health: number; // 0-100
value: number; // dollars
};
// The first 8 rows mirror the reference design exactly (name, address, job
// type, stage, status, progress and health). The rest extend the set to 15
// construction jobs with a realistic status/stage spread so the quick
// filters (Active/Complete/Stuck/Follow-up) all have something to show.
export const constructionLeads: ConstructionLead[] = [
{ id: "c1", name: "Derek Holloway", address: "2814 Ravenswood Dr, Plano TX 75023", status: "active", stage: "New Lead", jobType: "Roof Replacement", agent: "Cody Tatum", progress: 14, health: 65, value: 28500 },
{ id: "c2", name: "Brenda Castillo", address: "5501 Shady Brook Ln, Plano TX 75093", status: "active", stage: "New Lead", jobType: "Roof Inspection", agent: "Cody Tatum", progress: 14, health: 65, value: 15400 },
{ id: "c3", name: "Antonio Reyes", address: "1122 Custer Rd, Plano TX 75075", status: "active", stage: "New Lead", jobType: "Gutter Replacement", agent: "Cody Tatum", progress: 14, health: 65, value: 19200 },
{ id: "c4", name: "Sylvia Nguyen", address: "3308 Roundrock Trl, Plano TX 75023", status: "active", stage: "New Lead", jobType: "Siding Repair", agent: "Cody Tatum", progress: 14, health: 65, value: 21900 },
{ id: "c5", name: "Raymond Osei", address: "2814 Ravenswood Dr, Plano TX 75023", status: "active", stage: "New Lead", jobType: "Roof Replacement", agent: "Cody Tatum", progress: 14, health: 65, value: 31200 },
{ id: "c6", name: "Carolyn Estrada", address: "2814 Ravenswood Dr, Plano TX 75023", status: "active", stage: "New Lead", jobType: "Window Replacement", agent: "Cody Tatum", progress: 14, health: 65, value: 22400 },
{ id: "c7", name: "Marcus Tillman", address: "2814 Ravenswood Dr, Plano TX 75023", status: "active", stage: "New Lead", jobType: "Roof Replacement", agent: "Cody Tatum", progress: 14, health: 65, value: 26800 },
{ id: "c8", name: "Diane Kowalski", address: "815 Independence Pkwy, Plano TX 75023", status: "active", stage: "New Lead", jobType: "Roof Replacement", agent: "Cody Tatum", progress: 14, health: 65, value: 29500 },
{ id: "c9", name: "Felicia Grant", address: "9021 Legacy Dr, Plano TX 75024", status: "complete", stage: "Completed", jobType: "Gutter Replacement", agent: "Emma Wilson", progress: 100, health: 92, value: 18600 },
{ id: "c10", name: "Harold Jennings", address: "4477 Coit Rd, Plano TX 75075", status: "stuck", stage: "Permit Hold", jobType: "Siding Repair", agent: "Liam Foster", progress: 42, health: 38, value: 24800 },
{ id: "c11", name: "Yolanda Brooks", address: "6650 Parker Rd, Plano TX 75093", status: "followup", stage: "Awaiting Customer", jobType: "Window Replacement", agent: "Sophie Turner", progress: 55, health: 51, value: 20700 },
{ id: "c12", name: "Preston Wallace", address: "3312 Alma Dr, Plano TX 75075", status: "active", stage: "Scheduled", jobType: "Roof Repair", agent: "Cody Tatum", progress: 30, health: 70, value: 27500 },
{ id: "c13", name: "Nadia Ferreira", address: "8890 Independence Pkwy, Plano TX 75025", status: "complete", stage: "Completed", jobType: "Roof Inspection", agent: "Emma Wilson", progress: 100, health: 95, value: 16200 },
{ id: "c14", name: "Louis Abernathy", address: "2200 K Ave, Plano TX 75074", status: "stuck", stage: "Material Delay", jobType: "Roof Replacement", agent: "Liam Foster", progress: 60, health: 44, value: 24900 },
{ id: "c15", name: "Grace Delgado", address: "7301 Ohio Dr, Plano TX 75093", status: "active", stage: "In Progress", jobType: "Roof Replacement", agent: "Cody Tatum", progress: 68, health: 78, value: 51600 },
];
export type CollectionStatus = "paid" | "pending" | "overdue";
export type CollectionRecord = {
id: string;
name: string;
address: string;
referenceId: string;
date: string; // YYYY-MM-DD
type: string; // "Deposit — 30%", "Progress Payment — 40%", "Final Payment — 30%"
status: CollectionStatus;
amount: number; // dollars
};
// Deterministic 3-installment payment schedule per construction job (deposit /
// progress / final), so "Total Collected" has something real to show without a
// billing backend. Which installments are PAID vs PENDING/OVERDUE follows the
// job's own status — a completed job is fully paid, a stuck job has an overdue
// progress payment, etc.
const INSTALLMENTS = [
{ label: "Deposit — 30%", pct: 0.3 },
{ label: "Progress Payment — 40%", pct: 0.4 },
{ label: "Final Payment — 30%", pct: 0.3 },
] as const;
function statusFor(jobStatus: ConstructionStatus, i: number): CollectionStatus {
if (jobStatus === "complete") return "paid";
if (i === 0) return "paid"; // deposit is always collected up front
if (jobStatus === "stuck" && i === 1) return "overdue";
return "pending";
}
export function collectionsFor(lead: ConstructionLead, leadIndex: number): CollectionRecord[] {
const deposit = Math.round(lead.value * INSTALLMENTS[0].pct);
const progress = Math.round(lead.value * INSTALLMENTS[1].pct);
const final = lead.value - deposit - progress; // remainder avoids rounding drift
const amounts = [deposit, progress, final];
const ref = `PRJ-2026-${String(leadIndex + 1).padStart(3, "0")}`;
return INSTALLMENTS.map((inst, i) => {
const month = ((leadIndex * 2 + i * 2) % 12) + 1;
const day = ((leadIndex * 5 + i * 7) % 27) + 1;
return {
id: `${lead.id}-r${i + 1}`,
name: lead.name,
address: lead.address,
referenceId: `${ref}-R${i + 1}`,
date: `2026-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`,
type: inst.label,
status: statusFor(lead.status, i),
amount: amounts[i],
};
});
}
export type PipelineLead = {
id: string;
name: string;
address: string;
jobType: string;
stage: string;
agent: string;
createdAgo: string;
};
const FIRST_NAMES = [
"Wesley", "Ivy", "Corey", "Renata", "Miles", "Paula", "Jasper", "Dana",
"Terrence", "Alina", "Grant", "Bethany", "Owen", "Marisol", "Kurt",
"Tanya", "Reggie", "Selena", "Blake", "Vivian", "Colton", "Priya",
"Gerald", "Fiona",
];
const LAST_NAMES = [
"Whitfield", "Caldwell", "Rourke", "Sanborn", "Delacroix", "Winters",
"Blackwood", "Herrera", "Sweeney", "Okafor", "Lindgren", "Pruitt",
"Castellano", "Marsh", "Yaeger", "Doyle", "Kowalczyk", "Beaumont",
"Ashworth", "Nakamura", "Villanueva", "Prescott", "Hutchins",
"Loomis", "Stanhope", "Everly", "Boone",
];
const STREETS = [
"Independence Pkwy", "Legacy Dr", "Coit Rd", "Parker Rd", "Alma Dr",
"K Ave", "Ohio Dr", "Preston Rd", "Spring Creek Pkwy", "Custer Rd",
"Shady Brook Ln", "Roundrock Trl", "Ridgeview Dr", "Chisholm Trl",
"Los Rios Blvd",
];
const ZIPS = ["75023", "75024", "75025", "75074", "75075", "75093"];
const LEAD_JOB_TYPES = ["Roof Replacement", "Roof Inspection", "Gutter Replacement", "Siding Repair", "Window Replacement", "Roof Repair"];
const LEAD_STAGES = ["New Inquiry", "Contacted", "Qualifying", "Quote Requested", "Nurture"];
const LEAD_AGENTS = ["Cody Tatum", "Emma Wilson", "Liam Foster", "Sophie Turner", "Chloe Adams", "Unassigned"];
// Deterministic (no Math.random/Date.now) so server- and client-render match.
export const pipelineLeads: PipelineLead[] = Array.from({ length: 45 }, (_, i) => {
const first = FIRST_NAMES[i % FIRST_NAMES.length];
const last = LAST_NAMES[(i * 7) % LAST_NAMES.length];
const streetNum = 1000 + ((i * 137) % 8900);
const street = STREETS[(i * 3) % STREETS.length];
const zip = ZIPS[i % ZIPS.length];
const daysAgo = ((i * 3) % 21) + 1;
return {
id: `p${i + 1}`,
name: `${first} ${last}`,
address: `${streetNum} ${street}, Plano TX ${zip}`,
jobType: LEAD_JOB_TYPES[i % LEAD_JOB_TYPES.length],
stage: LEAD_STAGES[(i * 2) % LEAD_STAGES.length],
agent: LEAD_AGENTS[(i * 5) % LEAD_AGENTS.length],
createdAgo: daysAgo === 1 ? "1 day ago" : `${daysAgo} days ago`,
};
});
+258
View File
@@ -0,0 +1,258 @@
"use client";
// ============================================================
// Projects — Construction jobs + Pipeline leads.
// Data comes from useProjectsData(): the local mock when the
// Shell isn't configured, or the live be-crm data door
// (crm.project.*) when it is. be-crm's Project model is generic
// (name/status/value/owner/address/dates) — mock mode additionally
// carries stage, job type, progress and health, which the live
// table simply doesn't render (nothing backs them).
// ============================================================
import { useMemo, useState } from "react";
import { Btn, Icon, Modal, PageHead, Pill } from "./ui";
import {
QUICK_FILTERS, STATUS_LABEL, STATUS_TONE, useProjectsData,
type UiCollectionRecord, type UiProject, type UiProjectStatus,
} from "@/lib/projects-api";
const money = (n: number) => `$${Math.round(n).toLocaleString()}`;
// The collections ledger shows cents (matches invoice-style amounts); the rest of the page rounds to whole dollars.
const moneyExact = (n: number) => `$${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const COLLECTION_STATUS_LABEL: Record<UiCollectionRecord["status"], string> = { paid: "Paid", pending: "Pending", overdue: "Overdue" };
const COLLECTION_STATUS_TONE: Record<UiCollectionRecord["status"], string> = { paid: "green", pending: "orange", overdue: "red" };
export function Projects() {
const data = useProjectsData();
const { projects, live } = data;
const [tab, setTab] = useState<"construction" | "pipeline">("construction");
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | UiProjectStatus>("all");
const [viewProject, setViewProject] = useState<UiProject | null>(null);
const construction = useMemo(() => projects.filter((p) => !p.isLead), [projects]);
const pipeline = useMemo(() => projects.filter((p) => p.isLead), [projects]);
const budgetTotal = useMemo(() => construction.reduce((s, p) => s + (p.value ?? 0), 0), [construction]);
const list = tab === "construction" ? construction : pipeline;
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return list.filter((p) => {
const matchesStatus = tab === "pipeline" || statusFilter === "all" || p.status === statusFilter;
const matchesQ = !q
|| p.name.toLowerCase().includes(q)
|| (p.address ?? "").toLowerCase().includes(q)
|| (p.jobType ?? "").toLowerCase().includes(q)
|| p.agent.toLowerCase().includes(q);
return matchesStatus && matchesQ;
});
}, [list, statusFilter, query, tab]);
// Mock construction (richest): Lead | Status | Stage | Job Type | Agent | Progress | Health | ⋯
// Mock pipeline: Lead | Job Type | Stage | Agent | Created | ⋯
// Live (either tab, leaner — only fields be-crm actually stores): Lead | Status | Owner | Value | Due date | ⋯
const template = live
? "minmax(220px,2.3fr) 110px 160px 120px 130px 48px"
: tab === "construction"
? "minmax(220px,2.2fr) 104px 130px 150px 130px 130px 76px 48px"
: "minmax(220px,2.2fr) 150px 140px 130px 120px 48px";
return (
<div className="view proj">
<PageHead
title="Projects"
subtitle={`${construction.length} construction · ${pipeline.length} pipeline leads · ${money(budgetTotal)} budget`}
/>
{data.error && <div className="card proj-empty" style={{ borderColor: "var(--red, #ef4444)" }}><p>Couldn&apos;t load projects: {data.error}</p></div>}
<div className="tm-toolbar proj-toolbar">
<div className="tm-tabs" role="tablist">
<button role="tab" aria-selected={tab === "construction"} className={`tm-tab ${tab === "construction" ? "active" : ""}`} onClick={() => setTab("construction")}>
<Icon name="owners" size={16} /> <span>Construction</span>
<span className="tm-tab-badge">{construction.length}</span>
</button>
<button role="tab" aria-selected={tab === "pipeline"} className={`tm-tab ${tab === "pipeline" ? "active" : ""}`} onClick={() => setTab("pipeline")}>
<Icon name="pipeline" size={16} /> <span>Pipeline Leads</span>
<span className="tm-tab-badge">{pipeline.length}</span>
</button>
</div>
<div className="tm-search">
<Icon name="search" size={16} />
<input className="ds-input flush" placeholder="Search leads by name, address, job type, agent…" value={query} onChange={(e) => setQuery(e.target.value)} />
{query && <button className="tm-search-x" aria-label="Clear" onClick={() => setQuery("")}><Icon name="x" size={14} /></button>}
</div>
{tab === "construction" && (
<div className="tm-chips">
<button className={`tm-chip ${statusFilter === "all" ? "on" : ""}`} onClick={() => setStatusFilter("all")}>
All <i>{construction.length}</i>
</button>
{QUICK_FILTERS.map((s) => (
<button key={s} className={`tm-chip ${statusFilter === s ? "on" : ""}`} style={{ ["--rc" as string]: `var(--${STATUS_TONE[s] === "muted" ? "faint" : STATUS_TONE[s]})` }} onClick={() => setStatusFilter(s)}>
{STATUS_LABEL[s]} <i>{construction.filter((p) => p.status === s).length}</i>
</button>
))}
</div>
)}
</div>
{data.live && data.loading && projects.length === 0 ? (
<div className="card proj-empty"><span className="tm-empty-ic"><Icon name="owners" size={24} /></span><p>Loading projects</p></div>
) : filtered.length === 0 ? (
<div className="card proj-empty">
<span className="tm-empty-ic"><Icon name="search" size={24} /></span>
<p>No {tab === "construction" ? "projects" : "leads"} match your search.</p>
<Btn variant="soft" size="sm" onClick={() => { setQuery(""); setStatusFilter("all"); }}>Clear filters</Btn>
</div>
) : (
<div className="card card-pad-0 proj-table">
<div className="proj-row proj-head" style={{ gridTemplateColumns: template }}>
<span>Lead</span>
{live ? (
<><span>Status</span><span>Owner</span><span className="proj-c-num">Value</span><span>Due date</span></>
) : tab === "construction" ? (
<><span>Status</span><span>Stage</span><span>Job type</span><span>Agent</span><span>Progress</span><span className="proj-c-num">Health</span></>
) : (
<><span>Job type</span><span>Stage</span><span>Agent</span><span>Created</span></>
)}
<span className="proj-c-act" />
</div>
{filtered.map((p) => (
<div className="proj-row" key={p.id} style={{ gridTemplateColumns: template }}>
<div className="proj-lead">
<div className="proj-lead-name">{p.name}</div>
{p.address && <div className="proj-lead-sub">{p.address}</div>}
</div>
{live ? (
<>
<Pill tone={STATUS_TONE[p.status]}>{STATUS_LABEL[p.status].toUpperCase()}</Pill>
<span className="proj-text">{p.agent}</span>
<span className="proj-c-num">{p.value != null ? money(p.value) : <span className="tm-dash"></span>}</span>
<span className="proj-text">{p.dueDate ? new Date(p.dueDate).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }) : <span className="tm-dash"></span>}</span>
</>
) : tab === "construction" ? (
<>
<Pill tone={STATUS_TONE[p.status]}>{STATUS_LABEL[p.status].toUpperCase()}</Pill>
<Pill tone="blue">{p.stage}</Pill>
<span className="proj-text">{p.jobType}</span>
<span className="proj-text">{p.agent}</span>
<div className="proj-progress" title={`${p.progress}%`}>
<span className="proj-progress-track"><span className="proj-progress-fill" style={{ width: `${p.progress ?? 0}%` }} /></span>
<b>{p.progress}%</b>
</div>
<span className="proj-c-num proj-health">{p.health}</span>
</>
) : (
<>
<span className="proj-text">{p.jobType}</span>
<Pill tone="blue">{p.stage}</Pill>
<span className="proj-text">{p.agent}</span>
<span className="proj-text">{p.createdAgo}</span>
</>
)}
<div className="proj-c-act">
<button className="ds-iconbtn" aria-label={`View collections for ${p.name}`} onClick={() => setViewProject(p)}><Icon name="eye" size={17} /></button>
</div>
</div>
))}
</div>
)}
<div className="proj-foot">
<span>Showing {filtered.length} of {list.length} {tab === "construction" ? "projects" : "leads"}</span>
{tab === "construction" && <b>{money(budgetTotal)} total</b>}
</div>
<CollectedDetailsModal project={viewProject} onClose={() => setViewProject(null)} />
</div>
);
}
/* ---------------------------------------------------------- */
/* Collected details modal — payment ledger for one project */
/* ---------------------------------------------------------- */
function CollectedDetailsModal({ project, onClose }: { project: UiProject | null; onClose: () => void }) {
const [query, setQuery] = useState("");
const records = project?.collections ?? [];
const total = useMemo(() => records.reduce((s, r) => s + r.amount, 0), [records]);
if (!project) return null;
const q = query.trim().toLowerCase();
const filtered = records.filter((r) =>
!q || r.name.toLowerCase().includes(q) || r.referenceId.toLowerCase().includes(q) || r.type.toLowerCase().includes(q));
const netTotal = filtered.reduce((s, r) => s + r.amount, 0);
const projectName = project.name;
function downloadCsv() {
const header = ["Name/Project", "Reference ID", "Date", "Type", "Status", "Amount"];
const rows = filtered.map((r) => [r.name, r.referenceId, r.date, r.type, COLLECTION_STATUS_LABEL[r.status], r.amount.toFixed(2)]);
const csv = [header, ...rows].map((row) => row.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${projectName.replace(/\s+/g, "-").toLowerCase()}-collections.csv`;
a.click();
URL.revokeObjectURL(url);
}
return (
<Modal
open={!!project}
onClose={onClose}
title="Total Collected Details"
subtitle={<>Total: <b>{moneyExact(total)}</b></>}
size="xl"
headerExtra={records.length > 0 && (
<button className="ds-iconbtn" aria-label="Download CSV" onClick={downloadCsv}><Icon name="download" size={18} /></button>
)}
>
{project.collections === null ? (
<div className="tm-empty"><span className="tm-empty-ic"><Icon name="card" size={24} /></span><p>Collections aren&apos;t available yet for live projects this needs a billing module in be-crm.</p></div>
) : records.length === 0 ? (
<div className="tm-empty"><span className="tm-empty-ic"><Icon name="card" size={24} /></span><p>No payments collected yet for {project.name}.</p></div>
) : (
<>
<div className="tm-search proj-cd-search">
<Icon name="search" size={16} />
<input className="ds-input flush" placeholder="Search leads by name, address, job type, agent…" value={query} onChange={(e) => setQuery(e.target.value)} />
{query && <button className="tm-search-x" aria-label="Clear" onClick={() => setQuery("")}><Icon name="x" size={14} /></button>}
</div>
<div className="card card-pad-0 proj-table proj-cd-table">
<div className="proj-row proj-head" style={{ gridTemplateColumns: "minmax(180px,2fr) 140px 100px 170px 100px 120px" }}>
<span>Name / Project</span><span>Reference ID</span><span>Date</span><span>Type</span><span>Status</span><span className="proj-c-num">Amount</span>
</div>
{filtered.map((r) => (
<div className="proj-row" key={r.id} style={{ gridTemplateColumns: "minmax(180px,2fr) 140px 100px 170px 100px 120px" }}>
<div className="proj-lead">
<div className="proj-lead-name">{r.name}</div>
<div className="proj-lead-sub">{r.address}</div>
</div>
<span className="proj-text">{r.referenceId}</span>
<span className="proj-text">{r.date}</span>
<span className="proj-text">{r.type}</span>
<Pill tone={COLLECTION_STATUS_TONE[r.status]}>{COLLECTION_STATUS_LABEL[r.status].toUpperCase()}</Pill>
<span className="proj-c-num">{moneyExact(r.amount)}</span>
</div>
))}
</div>
<div className="proj-foot">
<span>Showing {filtered.length} record{filtered.length === 1 ? "" : "s"}</span>
<b>Net Total: {moneyExact(netTotal)}</b>
</div>
</>
)}
</Modal>
);
}
+1
View File
@@ -95,6 +95,7 @@ const NAV_PERMISSION: Record<string, string | undefined> = {
leads: "leads.manage",
verify: "leads.manage",
pipeline: "pipeline.manage",
projects: "pipeline.manage",
estimates: "estimates.create",
procanvas: "estimates.create",
dispatch: "dispatch.manage",
+7 -4
View File
@@ -222,9 +222,9 @@ export function OtpField({ length = 6, value, onChange, autoFocus = true }: { le
/* Modal */
/* ---------------------------------------------------------- */
export function Modal({ open, onClose, title, subtitle, icon, children, footer, size = "md" }: {
open: boolean; onClose: () => void; title: string; subtitle?: string; icon?: string;
children: ReactNode; footer?: ReactNode; size?: "sm" | "md" | "lg";
export function Modal({ open, onClose, title, subtitle, icon, children, footer, size = "md", headerExtra }: {
open: boolean; onClose: () => void; title: string; subtitle?: ReactNode; icon?: string;
children: ReactNode; footer?: ReactNode; size?: "sm" | "md" | "lg" | "xl"; headerExtra?: ReactNode;
}) {
const titleId = useId();
// Portal the overlay up to `.dash-root` so its position:fixed anchors to the viewport,
@@ -251,7 +251,10 @@ export function Modal({ open, onClose, title, subtitle, icon, children, footer,
{subtitle && <p>{subtitle}</p>}
</div>
</div>
<button className="ds-iconbtn" aria-label="Close" onClick={onClose}><Icon name="x" size={18} /></button>
<div className="ds-modal-head-r">
{headerExtra}
<button className="ds-iconbtn" aria-label="Close" onClick={onClose}><Icon name="x" size={18} /></button>
</div>
</div>
<div className="ds-modal-body">{children}</div>
{footer && <div className="ds-modal-foot">{footer}</div>}
+2 -3
View File
@@ -10,14 +10,13 @@ export type VStatus = "verified" | "in_progress" | "assigned" | "pending" | "unv
export type VActivity = { text: string; time: string; who: string };
export type VLead = {
id: string; // LD-V-001 (display code)
refId?: string; // opaque server id (live mode); commands target this — falls back to `id`
id: string;
initials: string;
name: string;
address: string;
phone: string;
source: string;
assignee: { id?: string; initials: string; name: string } | null;
assignee: { initials: string; name: string } | null;
status: VStatus;
verification: string; // sub-status text
created: string;
+40 -161
View File
@@ -16,8 +16,7 @@
import { useMemo, useState, useEffect } from "react";
import { createPortal } from "react-dom";
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 { useVerifyData, type Assignee } from "@/lib/verify-api";
import { V_LEADS, V_STATUS_META, V_SOURCES, V_ASSIGNEES, type VStatus, type VLead, type VActivity } from "./verify-data";
const STAT_ORDER: VStatus[] = ["verified", "in_progress", "assigned", "pending", "unverified"];
const STAT_ICON: Record<VStatus, string> = {
@@ -43,71 +42,37 @@ function buildActivity(l: VLead): VActivity[] {
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() {
const toast = useToast();
const data = useVerifyData();
const { leads, total, counts, assignees, loading, error } = data;
const [query, setQuery] = useState("");
const [status, setStatus] = useState("all");
const [source, setSource] = useState("all");
const [assignee, setAssignee] = useState("all");
const [selected, setSelected] = useState<VLead | null>(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 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 matchStatus = status === "all" || l.status === status;
const matchSource = source === "all" || l.source === source;
const matchAssignee = assignee === "all" || l.assignee?.name === assignee;
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.
async function run(work: Promise<void>, title: string, desc: string) {
function act(l: VLead, title: string, desc: string, tone: "success" | "info" = "info") {
setMenu(null);
try {
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 });
}
toast.push({ tone, title, desc });
}
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 (
<div className="view lv">
<PageHead
@@ -115,7 +80,7 @@ export function Verify() {
title="Lead Verification"
subtitle="Identity & insurance checks before a lead becomes a working deal."
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 ---- */}
@@ -149,7 +114,7 @@ export function Verify() {
</select>
<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>
{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>
</div>
@@ -164,11 +129,7 @@ export function Verify() {
</tr>
</thead>
<tbody>
{error ? (
<tr><td colSpan={9} className="lv-empty">Couldnt 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 ? (
{rows.length === 0 ? (
<tr><td colSpan={9} className="lv-empty">No leads match your filters.</td></tr>
) : rows.map((l) => {
const meta = V_STATUS_META[l.status];
@@ -199,8 +160,8 @@ export function Verify() {
<td className="lv-created">{l.created}</td>
<td>
<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 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" 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="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>
</div>
</td>
@@ -210,24 +171,10 @@ export function Verify() {
</tbody>
</table>
</div>
<div className="lv-count">{rows.length} of {total} leads</div>
<div className="lv-count">{rows.length} of {V_LEADS.length} leads</div>
<ActionsMenu
menu={menu}
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} />
<ActionsMenu menu={menu} onClose={() => setMenu(null)} onAct={act} onView={(l) => { setSelected(l); setMenu(null); }} />
<VerifyDetail lead={selected} onClose={() => setSelected(null)} />
</div>
);
}
@@ -236,13 +183,10 @@ export function Verify() {
/* 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;
onAct: (l: VLead, title: string, desc: string, tone?: "success" | "info") => 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);
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 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(
<>
<div className="lv-menu-scrim" onClick={onClose} />
<MenuBody
key={l.id} lead={l} left={left} top={top}
onView={onView} onVerify={onVerify} onUnverify={onUnverify}
onPending={onPending} onOpenAssign={onOpenAssign}
/>
<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}>
<Icon name={it.icon} size={14} /> {it.label}
</button>
))}
</div>
</>,
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 */
/* ---------------------------------------------------------- */
function VerifyDetail({ lead, onClose, onVerify }: { lead: VLead | null; onClose: () => void; onVerify: (l: VLead) => void }) {
const toast = useToast();
function VerifyDetail({ lead, onClose }: { lead: VLead | null; onClose: () => void }) {
if (!lead) return null;
const meta = V_STATUS_META[lead.status];
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 (
<Modal open={!!lead} onClose={onClose} size="lg" title={lead.name} subtitle={`${lead.id} · ${lead.verification}`} icon="verify"
footer={<>
<Btn variant="ghost" icon="phone" onClick={call}>Call</Btn>
<Btn icon="check-circle" onClick={verify} disabled={!allowed(lead.status).verify}>Verify Lead</Btn>
<Btn variant="ghost" icon="phone">Call</Btn>
<Btn icon="check-circle">Verify Lead</Btn>
</>}
>
<div className="lv-detail">
-568
View File
@@ -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 `Couldnt 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();
}
+201
View File
@@ -0,0 +1,201 @@
"use client";
// Projects 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.project.*), behind one interface so the component is mode-agnostic.
// Mirrors team-api.ts.
//
// Live contract (be-crm):
// query crm.project.list { perPage } -> { items: ProjectDTO[], meta }
// cmd crm.project.create { name, status?, value?, address?, dueDate?, ... }
// cmd crm.project.setStatus { id, status }
// cmd crm.project.remove { id }
//
// be-crm's Project model is generic (name/status/value/owner/address/dates) —
// it has no stage, job type, progress or health score. Those are mock-only
// fields (undefined in live mode); the Projects screen renders a leaner
// column set when `live` is true.
import { useCallback, useMemo, useState } from "react";
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "./appshell";
import {
collectionsFor, constructionLeads, pipelineLeads,
type CollectionRecord, type ConstructionStatus,
} from "@/components/dashboard/projects-data";
export type UiCollectionRecord = CollectionRecord;
/* ---- UI-facing shapes ---------------------------------------------------- */
// "lead" / "archived" round out the mock's four (active/complete/stuck/followup)
// so every be-crm ProjectStatus has somewhere to map to.
export type UiProjectStatus = ConstructionStatus | "lead" | "archived";
export interface UiProject {
id: string;
name: string;
address: string | null;
status: UiProjectStatus;
stage: string | null; // mock only
jobType: string | null; // mock only
agent: string;
progress: number | null; // mock only
health: number | null; // mock only
value: number | null; // dollars
dueDate: string | null;
createdAgo: string | null; // mock pipeline leads only
collections: UiCollectionRecord[] | null; // mock only — no billing backend yet
isLead: boolean; // true → "Pipeline Leads" tab
}
export interface NewProjectInput {
name: string;
status: UiProjectStatus;
value?: number; // dollars
}
export interface ProjectsData {
live: boolean;
loading: boolean;
error: string | null;
projects: UiProject[];
create: (p: NewProjectInput) => Promise<void>;
setStatus: (id: string, status: UiProjectStatus) => Promise<void>;
remove: (id: string) => Promise<void>;
refetch: () => void;
}
export const STATUS_LABEL: Record<UiProjectStatus, string> = {
lead: "Lead", active: "Active", complete: "Complete", stuck: "Stuck", followup: "Follow-up", archived: "Archived",
};
export const STATUS_TONE: Record<UiProjectStatus, string> = {
lead: "purple", active: "green", complete: "blue", stuck: "red", followup: "orange", archived: "muted",
};
// Quick-filter chips only cover the four "in-flight job" statuses (mirrors the design).
export const QUICK_FILTERS: UiProjectStatus[] = ["active", "complete", "stuck", "followup"];
/* ---- mock → UI ------------------------------------------------------------ */
function mockConstruction(c: (typeof constructionLeads)[number], index: number): UiProject {
return {
id: c.id, name: c.name, address: c.address, status: c.status, stage: c.stage, jobType: c.jobType,
agent: c.agent, progress: c.progress, health: c.health, value: c.value, dueDate: null, createdAgo: null,
collections: collectionsFor(c, index), isLead: false,
};
}
function mockPipeline(p: (typeof pipelineLeads)[number]): UiProject {
return {
id: p.id, name: p.name, address: p.address, status: "lead", stage: p.stage, jobType: p.jobType,
agent: p.agent, progress: null, health: null, value: null, dueDate: null, createdAgo: p.createdAgo,
collections: [], isLead: true,
};
}
/* ======================================================================== */
/* Mock implementation (no Shell configured) */
/* ======================================================================== */
let mockSeq = 1;
function useMockProjects(): ProjectsData {
const [projects, setProjects] = useState<UiProject[]>(() => [
...constructionLeads.map(mockConstruction),
...pipelineLeads.map(mockPipeline),
]);
const create = useCallback(async (p: NewProjectInput) => {
const isLead = p.status === "lead";
const next: UiProject = {
id: `new_${mockSeq++}`, name: p.name, address: null, status: p.status,
stage: isLead ? "New Inquiry" : "New Lead", jobType: null, agent: "Unassigned",
progress: isLead ? null : 0, health: isLead ? null : 70, value: isLead ? null : (p.value ?? null),
dueDate: null, createdAgo: isLead ? "Just now" : null, collections: [], isLead,
};
setProjects((l) => [next, ...l]);
}, []);
const setStatus = useCallback(async (id: string, status: UiProjectStatus) => {
setProjects((l) => l.map((p) => (p.id === id ? { ...p, status, isLead: status === "lead" } : p)));
}, []);
const remove = useCallback(async (id: string) => { setProjects((l) => l.filter((p) => p.id !== id)); }, []);
return { live: false, loading: false, error: null, projects, create, setStatus, remove, refetch: () => {} };
}
/* ======================================================================== */
/* Live implementation (be-crm data door) */
/* ======================================================================== */
type BackendStatus = "lead" | "active" | "on_hold" | "won" | "lost" | "archived";
const BACKEND_TO_UI: Record<BackendStatus, UiProjectStatus> = {
lead: "lead", active: "active", on_hold: "stuck", won: "complete", lost: "followup", archived: "archived",
};
const UI_TO_BACKEND: Record<UiProjectStatus, BackendStatus> = {
lead: "lead", active: "active", stuck: "on_hold", complete: "won", followup: "lost", archived: "archived",
};
interface ProjectAddressDTO { line1?: string | null; city?: string | null; state?: string | null; postalCode?: string | null }
interface ProjectDTO {
id: string; name: string; status: BackendStatus; ownerPrincipalId: string | null;
value: number | null; address: ProjectAddressDTO | null; dueDate: string | null;
}
interface MemberRef { principalId: string; displayName: string | null; jobTitle: string | null }
const fmtAddress = (a: ProjectAddressDTO | null): string | null => {
if (!a) return null;
const cityState = [a.city, a.state].filter(Boolean).join(" ");
const parts = [a.line1, [cityState, a.postalCode].filter(Boolean).join(" ")].filter(Boolean);
return parts.length ? parts.join(", ") : null;
};
function useLiveProjects(): ProjectsData {
const { sdk } = useAppShell();
const listQ = useQuery<{ items: ProjectDTO[] }>("crm.project.list", { perPage: 100 });
// Resolve ownerPrincipalId → a real name for the "Owner" column (crm.project.list
// only returns the raw id).
const membersQ = useQuery<{ items: MemberRef[] }>("crm.team.member.search", { perPage: 100 });
const refetch = useCallback(() => { listQ.refetch(); membersQ.refetch(); }, [listQ, membersQ]);
const cmd = useCallback(async (action: string, variables: Record<string, unknown>) => {
await sdk.command(action, variables);
refetch();
}, [sdk, refetch]);
const nameByPrincipal = useMemo(() => {
const m = new Map<string, string>();
for (const it of membersQ.data?.items ?? []) if (it.principalId) m.set(it.principalId, it.displayName || it.jobTitle || it.principalId);
return m;
}, [membersQ.data]);
const projects: UiProject[] = useMemo(() => (listQ.data?.items ?? []).map((d) => ({
id: d.id, name: d.name, address: fmtAddress(d.address),
status: BACKEND_TO_UI[d.status] ?? "active",
stage: null, jobType: null,
agent: d.ownerPrincipalId ? (nameByPrincipal.get(d.ownerPrincipalId) ?? "Unassigned") : "Unassigned",
progress: null, health: null,
value: d.value != null ? d.value / 100 : null, // minor units → dollars
dueDate: d.dueDate, createdAgo: null, collections: null, isLead: d.status === "lead",
})), [listQ.data, nameByPrincipal]);
return {
live: true,
loading: listQ.loading || membersQ.loading,
error: (listQ.error ?? membersQ.error)?.message ?? null,
projects,
create: (p) => cmd("crm.project.create", {
name: p.name, status: UI_TO_BACKEND[p.status],
...(p.value != null ? { value: Math.round(p.value * 100) } : {}),
}),
setStatus: (id, status) => cmd("crm.project.setStatus", { id, status: UI_TO_BACKEND[status] }),
remove: (id) => cmd("crm.project.remove", { id }),
refetch,
};
}
/* ---- public hook: pick the implementation at module-config time --------- */
const SHELL = isShellConfigured();
export function useProjectsData(): ProjectsData {
return SHELL ? useLiveProjects() : useMockProjects();
}
-276
View File
@@ -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();
}