12 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
Mayur7887 f380c7d465 Merge pull request 'Feat/leads' (#34) from feat/leads into goutamnextflow
Reviewed-on: #34
2026-07-23 13:31:45 +00:00
Mayur Shinde e10ffc25a1 Merge origin/goutamnextflow into feat/leads
Resolve conflicts in dashboard.tsx and dashboard.css:
- Keep goutamnextflow's SDK inbox/messenger, settings, notifications,
  realtime provider and smart gallery (the old messenger/inbox files
  were deleted on that branch)
- Graft the feat/leads additions (Leads, Verify views + their CSS) on top

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 18:50:27 +05:30
Mayur Shinde 704ea853e6 document for leads backend req 2026-07-23 18:40:00 +05:30
maaz519 041ad0e44a feat(dashboard): offline web push — service worker, subscribe flow, deep-link
Notifications phase 2: a /push-sw.js service worker shows a system notification
on push and, on click, focuses an open CRM tab (or opens one) and deep-links to
the thread. usePushNotifications registers the SW, requests permission, subscribes
with IIOS's VAPID key, and stores the subscription via the be-crm door. The topbar
bell becomes a real enable/disable control (hidden when push is unsupported/demo).
Dashboard listens for the SW's notif-click message + a ?thread= deep link.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:38:14 +05:30
maaz519 be50d53c50 feat(dashboard): live notifications phase 1 — presence, live unread, in-app toasts
Shared RealtimeProvider opens ONE dashboard-wide IIOS message socket reused by
the messenger tab and the new NotificationCenter. CrmMessagingAdapter gains
setFocus (drives presence/push suppression) and subscribeActivity (joins all
of the caller's threads, fans out incoming messages). NotificationCenter shows
a clickable toast on new messages when you're not on the messenger tab and
deep-links to the conversation. Pulls @insignia/iios-messaging-ui@0.1.7.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:26:17 +05:30
maaz519 904d003d32 feat(search): global conversation search in the topbar with deep-link nav
Topbar search box → debounced crm.search → dropdown of hits (chat/mail, highlighted
snippet). Clicking a hit switches to the right tab and focuses the exact thread:
mail → Inbox, chat → Messenger (via the SDK's new focusThreadId, 0.1.6). Snippet HTML
is escaped except the <em> highlight. Demo mode searches an in-memory set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 14:07:26 +05:30
maaz519 c874a726ef feat(settings): Email (SMTP) BYO card in Settings → Integrations
Replace the SMTP 'coming soon' stub with a real card: host/port/SSL/user/password/
from-address/from-name → crm.settings.smtp.configure, masked status from
crm.settings.smtp.status. Mirrors the Twilio SMS card; demo mode stores hints locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 13:57:45 +05:30
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
Goutam faade49b13 feat(leads): source-driven fields + full-form wizard
- Add Lead source dropdown options (Door Knock, Referral, Storm Chase,
  Mailer/Postcard, Sign Call, Insurance Agent Referral, Repeat Customer,
  Social Media, Other)
- Quick form: Referral shows a referral-note textarea; Door Knock shows a
  canvasser search (by name or email) with picker + chip
- Full form is now a Next/Back wizard ending in Create Lead
- Reps gain email for canvasser search

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 11:08:47 +05:30
Goutam 102b66dfca Add Leads pipeline and Lead Verification workspace pages
Leads: card board with status stats, search + status tabs, a rich
lead-detail popup (contact, property, job, insurance, assignment,
storm banner) and a New Lead intake form (Quick / Full Form) with
multi-phone/email rows, site-photo dropzone and urgency picker.

Lead Verification: clickable stat tiles, status/source/assignee
filters, a full verification table, a detail popup with an activity
timeline, and a per-row actions menu (verify / reassign / pending…).

Both wired into the dashboard view switcher and styled with the
existing orange brand + glass-card system (dark & light themes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 19:21:42 +05:30
32 changed files with 4797 additions and 150 deletions
+4
View File
@@ -42,3 +42,7 @@ next-env.d.ts
.vercel .vercel
.env*.local .env*.local
certificates
AI_ASSISTANT_BACKEND_PLAN.md
TEAM_MANAGEMENT_BACKEND_PLAN.md
+827
View File
@@ -0,0 +1,827 @@
# Leads & Lead Verification — Backend Requirements
**Project:** LynkedUp Pro CRM (`lynkeduppro-crm`)
**Domain service:** `be-crm` (the CRM "data door")
**Modules covered:** `Leads`, `Lead Verification`
**Document status:** Implementation-ready. Derived entirely from the current frontend.
**Author basis:** Reverse-engineered from the completed frontend + the existing backend integration conventions already used by the Team, Mail, Inbox, Messenger, Media and Account modules.
---
> ## ⚠️ Read this first — current state of the frontend
>
> Both modules are **fully built on the UI side but wired to client-side mock data only**. There is **no live backend call anywhere** in `leads.tsx`, `leads-data.ts`, `verify.tsx`, or `verify-data.ts`. Every "write" today (Create Lead, Verify, Mark Unverified, Change Assignee, Update Status, Call, Email, Refresh) only pushes a **toast notification** — no persistence, no network request.
>
> This means the **entire** Leads and Lead Verification backend is greenfield. However, the frontend precisely defines the required **fields, enums, filters, sub-statuses, actions and workflows**, so this document treats the mock shapes as the authoritative contract.
>
> Wherever a control exists visually but has no behaviour yet, it is tagged **`[Frontend Placeholder / Backend Pending]`** so you know it is a real requirement even though the UI does not call it yet.
>
> The design below deliberately **reuses the existing `be-crm` architecture** (query/command data door, not REST). Do **not** invent a REST API — see §11 (Existing Backend Integration) for the mandatory conventions.
---
## Table of Contents
1. [Module Overview](#1-module-overview)
2. [Existing Frontend Features (exhaustive)](#2-existing-frontend-features-exhaustive)
3. [User Flows](#3-user-flows)
4. [Database Models](#4-database-models)
5. [Entity Relationships](#5-entity-relationships)
6. [API Contract — Actions (Queries & Commands)](#6-api-contract--actions-queries--commands)
7. [Validation Rules](#7-validation-rules)
8. [Reference / Enum Data](#8-reference--enum-data)
9. [Backend Services](#9-backend-services)
10. [Error Handling](#10-error-handling)
11. [Existing Backend Integration (mandatory conventions)](#11-existing-backend-integration-mandatory-conventions)
12. [Gaps, Discrepancies & Backend-Pending Items](#12-gaps-discrepancies--backend-pending-items)
---
## 1. Module Overview
### 1.1 Leads module
**Purpose:** The **Leads** module is the storm-restoration sales pipeline board. It holds prospective roofing jobs (door-knocked, referred, storm-chased, etc.) inside a hail-storm territory (the demo is the Plano, TX hail zone, storm event `2026-04-28`). Each lead is a rich record combining **contact**, **property**, **job details**, **insurance** and **assignment** information, and moves through a sales status pipeline.
**Frontend surface:** `src/components/dashboard/leads.tsx` (+ `leads-data.ts`), rendered inside the dashboard shell when the `leads` nav key is active.
**Core capabilities visible in the UI:**
- A **stat strip** (Total / New / Contacted / Appointed / Closed counts).
- A **board of lead cards** (avatar, priority ring, status pill, address, primary phone, source, canvasser, "updated" relative time).
- **Search** (by name / address / city / source / canvasser) and **status filter tabs**.
- A **lead detail modal** (Contact, Property, Job Details, Insurance, Assignment, storm banner).
- A **New Lead intake** with two modes: **Quick** (single condensed form) and **Full Form** (a 5-step wizard: Contact → Property → Job Details → Insurance → Assignment).
### 1.2 Lead Verification module
**Purpose:** The **Lead Verification** module is the verification desk that sits **upstream of Leads**. Freshly captured leads (door-knock / web form / storm canvass / referral / call-in) must pass an **identity + insurance + ownership + damage** verification workflow before they become working sales leads. The desk's terminal success action is literally *"Verified and pushed to New Leads"* — i.e., a verified verification record becomes a Lead with status `new`.
**Frontend surface:** `src/components/dashboard/verify.tsx` (+ `verify-data.ts`), rendered when the `verify` nav key is active.
**Core capabilities visible in the UI:**
- **Clickable stat tiles** (Verified / In Progress / Assigned / Pending / Unverified) that also act as status filters.
- A **data table** (Lead ID · Customer · Phone · Source · Assigned To · Status · Verification sub-status · Created · Actions).
- **Search** + **status / source / assignee** dropdown filters.
- A **row action set** (View, Verify, and a `⋯` menu: View Details, Verify Lead, Mark Unverified, Change Assignee, Reassign → In Progress, Move to Pending).
- A **verification detail modal** (Contact, Assignment, Verification Notes, and an **Activity timeline**).
- A **Refresh** button.
### 1.3 Relationship between the two modules
```
Intake sources Lead Verification desk Leads pipeline
(door knock, web form, ─────► verify identity / ownership / ─────► status = "new"
storm canvass, referral, insurance / damage → contacted → appointed
call-in) (verified | in_progress | → closed
assigned | pending | unverified)
```
- A **Lead Verification** record is the *pre-lead*. When it reaches `verified`, the system **creates / promotes** it into a **Lead** (`status = "new"`). This is the single hard link between the two modules and the most important cross-module command to implement (see `crm.leadVerification.verify` in §6).
- Both modules are gated by the **same** CRM permission: **`leads.manage`** (see `NAV_PERMISSION` in `sidebar.tsx`). There is no separate verification permission in the frontend.
- Both are **tenant-scoped** — every record belongs to the signed-in user's organization/tenant (the demo boots as `tenant-acme-01`).
### 1.4 User journey (end-to-end)
1. A lead is **captured** at an intake source and lands in the **Verification** queue as `pending` (unassigned) or `assigned`.
2. A **verification specialist** (Wade Hollis, Darlene Brooks, Roy Schaefer in the mock) is assigned and works the record through sub-statuses (`Verifying Identity``Confirming Ownership``Reviewing Insurance``Confirming Damage`).
3. The specialist either **Verifies** it (→ becomes a Lead, `status = new`) or **Marks Unverified**.
4. In the **Leads** module, a sales rep / canvasser works the new lead: **Contacted****Appointed****Closed**, updating status, priority, follow-up date, insurance/claim details and assignment along the way.
---
## 2. Existing Frontend Features (exhaustive)
Only features actually present in the code are listed. Each is tagged **Wired** (has real logic, even if only client-side state) or **`[Placeholder / Backend Pending]`** (UI exists, no behaviour beyond a toast).
### 2.1 Leads module features
| # | Feature | State | Notes / source |
|---|---------|-------|----------------|
| L1 | Lead board / card list | Wired (mock) | `LEADS` array rendered as `LeadCard`s |
| L2 | Header stat strip (Total, New, Contacted, Appointed, Closed) | Wired (mock) | `countByStatus`; `TOTAL_LEADS = 35` shown as total |
| L3 | Text search (name, address, city, source, canvasser) | Wired (mock) | `filtered` memo, case-insensitive substring |
| L4 | Status filter tabs (All / New / Contacted / Appointed / Closed) | Wired (mock) | `STATUS_TABS` |
| L5 | Lead detail modal (Contact, Property, Job, Insurance, Assignment, storm banner) | Wired (mock, read-only) | `LeadDetail` |
| L6 | Create Lead — Quick mode | **Placeholder** | `submit()` only validates name then toasts; no persistence |
| L7 | Create Lead — Full Form wizard (5 steps) | **Placeholder** | steps: Contact / Property / Job / Insurance / Assignment |
| L8 | Multiple phone numbers (with type Mobile/Home/Work + primary) | Wired (form state) | `addPhone/setPhone/removePhone` |
| L9 | Multiple email addresses | Wired (form state) | `addEmail/setEmail/removeEmail` |
| L10 | Site photos upload | **Placeholder** | `addPhoto()` just appends a label string `"Photo N"` — no real upload |
| L11 | Conditional field: Referral note (source = Referral) | Wired (form state) | `f.source === "Referral"` |
| L12 | Conditional field: Canvasser search (source = Door Knock) | Wired (form state) | `CanvasserSearch` typeahead over `REPS` |
| L13 | Priority picker (Low / Medium / High) | Wired (form state) | `PriorityPicker` |
| L14 | Urgency picker (Standard / High / Emergency) | Wired (form state) | `UrgencyPicker` |
| L15 | Rep assignment select (incl. "Unassigned") | Wired (form state) | `RepSelect`, options from `REPS` |
| L16 | Detail action: **Update Status** | **Placeholder** | footer button, no handler |
| L17 | Detail action: **Call** / **Email** | **Placeholder** | footer buttons, no handler |
| L18 | Empty state ("No leads match") | Wired | shown when `filtered.length === 0` |
**Not present in Leads (do not build unless requested):** edit-existing-lead form, delete, archive, bulk actions, tags editor (only a single static `tag` string exists), notes/comments thread, per-lead activity timeline, attachments list, pagination controls, sorting controls, export.
### 2.2 Lead Verification module features
| # | Feature | State | Notes / source |
|---|---------|-------|----------------|
| V1 | Stat tiles (Verified / In Progress / Assigned / Pending / Unverified) | Wired (mock) | `STAT_ORDER`, counts from `V_LEADS` |
| V2 | Stat tile click = status filter toggle | Wired (mock) | `setStatus(status === s ? "all" : s)` |
| V3 | Verification table (9 columns) | Wired (mock) | see column list in §1.2 |
| V4 | Text search (name, lead ID, phone, source, address) | Wired (mock) | `rows` memo |
| V5 | Status filter dropdown | Wired (mock) | mirrors stat tiles |
| V6 | Source filter dropdown | Wired (mock) | `V_SOURCES` |
| V7 | Assignee filter dropdown | Wired (mock) | `V_ASSIGNEES` |
| V8 | Row action: **View details** | Wired (mock) | opens `VerifyDetail` |
| V9 | Row action: **Verify** (quick) | **Placeholder** | toast only |
| V10 | Row `⋯` menu | Wired (open/close) | portalled dropdown |
| V11 | Menu: Verify Lead | **Placeholder** | toast only |
| V12 | Menu: Mark Unverified | **Placeholder** | toast only |
| V13 | Menu: Change Assignee | **Placeholder** | toast only |
| V14 | Menu: Reassign (→ In Progress) | **Placeholder** | toast only |
| V15 | Menu: Move to Pending | **Placeholder** | toast only |
| V16 | Detail modal — Contact / Assignment sections | Wired (mock, read) | `VerifyDetail` |
| V17 | Detail modal — Verification Notes | Wired (mock, read) | shown when `notes` present |
| V18 | Detail modal — Activity timeline | Wired (mock, read + derived) | `buildActivity()` synthesizes when absent |
| V19 | Detail footer: Verify Lead / Call | **Placeholder** | buttons, no handler |
| V20 | Refresh button | **Placeholder** | toast only ("Queue refreshed") |
| V21 | Row count "X of Y leads" | Wired (mock) | implies server total vs filtered count |
| V22 | Derived email / created-at when absent | Wired (mock) | `deriveEmail`, `deriveCreatedAt` |
**Not present in Verification (do not build unless requested):** create-verification-from-UI (records arrive via intake, never created here — mirrors the Inbox "items are never created here" pattern), delete, bulk verify, attachments, document upload, editable contact fields.
---
## 3. User Flows
### 3.1 Lead Verification status flow
Statuses (`VStatus`): `unverified`, `pending`, `assigned`, `in_progress`, `verified`.
Each status also carries a human **sub-status** string (`verification`).
```
intake (door knock / web form / storm canvass / referral / call-in)
┌──────────── pending ("Pending Review", unassigned) ────────────┐
│ │ │
│ [Change Assignee] │
│ ▼ │
│ assigned ("Assigned") │
│ │ │
│ [Reassign / start work] │
│ ▼ │
│ in_progress ("Verifying Identity" / │
│ "Confirming Ownership" / │
│ "Reviewing Insurance" / │
│ "Confirming Damage") │
│ │ │ │
│ [Verify]│ │[Mark Unverified] │
│ ▼ ▼ │
│ verified unverified ◄─────────────────────┘
│ ("Verified") ("Unverified")
│ │
│ ▼
│ ┌───────────────────────────────┐
└────►│ PROMOTE → create Lead(status = │
│ "new") + activity "Verified │
│ and pushed to New Leads." │
└───────────────────────────────┘
[Move to Pending] can send an assigned/in_progress record back to pending.
```
**Allowed transitions (enforce server-side):**
| From | To | Trigger command |
|------|----|-----------------|
| `pending` | `assigned` | `assign` (set assignee) |
| `pending` / `assigned` / `in_progress` | `in_progress` | `reassign` / `setInProgress` |
| `assigned` / `in_progress` | `pending` | `moveToPending` |
| `pending` / `assigned` / `in_progress` | `verified` | `verify` (→ promotes to Lead) |
| `pending` / `assigned` / `in_progress` | `unverified` | `markUnverified` |
| any | (assignee change) | `assign` / `changeAssignee` |
> The frontend does not restrict transitions (every action is available on every row), so treat the table above as the **recommended** guard set; at minimum, forbid transitions out of a terminal `verified` record except via an explicit re-open (backend-pending, not in UI).
### 3.2 Lead sales status flow
Statuses (`LeadStatus`): `new`, `contacted`, `appointed`, `closed`.
```
(created directly OR promoted from a verified verification)
new
│ [Update Status] / rep works the lead
contacted
appointed (adjuster / inspection appointment set)
closed (won/installed/paid — mock leans "won")
```
`priority` (`high` / `medium` / `low`) and `job.urgency` (`Standard` / `High` / `Emergency`) are independent of status and set at creation / update.
### 3.3 Create Lead (Full Form wizard) flow
```
Step 1 Contact → firstName*, lastName, phones[] (type + primary), emails[]
Step 2 Property → address, city, state (default "TX"), zip, propertyType, photos[]
Step 3 Job → source, leadType, workType, tradeType, urgency, notes
(source=Referral → referralNote; source=Door Knock → canvasser)
Step 4 Insurance → insCompany, claimNumber, claimStatus, adjusterName,
adjusterPhone, policyNumber
Step 5 Assignment→ assignRep (or Unassigned), priority, followUp date
→ [Create Lead] → crm.lead.create
```
Quick mode collects a condensed subset (firstName*, lastName, phone[0], address, city, state, zip, source, [referralNote|canvasser], priority, followUp) and submits the same `crm.lead.create`.
`*` = the only field the frontend currently enforces is a non-empty **name** (first or last).
---
## 4. Database Models
Storage assumptions follow the existing `be-crm` domain service (relational, multi-tenant). **Every table carries `tenant_id`** and is always filtered by it. Primary keys are opaque server IDs; the human-facing **codes** (`SAL-001`, `LD-V-001`) are separate, per-tenant, monotonic display identifiers.
Timestamps are stored as `timestamptz` (UTC, ISO-8601 on the wire). Relative strings like `"3d ago"` and pretty dates like `"Jun 4, 2026"` seen in the mock are **presentation-only** — the API returns ISO timestamps and the client formats them (see `relTime()` in `team-api.ts`).
### 4.1 `leads`
**Purpose:** One row per sales lead (the Leads board card + detail modal).
| Field | Type | Null | Default | Notes |
|-------|------|------|---------|-------|
| `id` | uuid / string PK | no | gen | opaque server id |
| `tenant_id` | uuid/string FK → organizations | no | — | tenant scope; **indexed** |
| `code` | text | no | seq | display code `SAL-001`; **unique per tenant** |
| `first_name` | text | no | — | |
| `last_name` | text | yes | null | name = `first + last`, trimmed |
| `status` | enum `lead_status` | no | `'new'` | `new` \| `contacted` \| `appointed` \| `closed` |
| `priority` | enum `lead_priority` | no | `'medium'` | `high` \| `medium` \| `low` (UI Quick default = Medium) |
| `tag` | text | yes | `'Storm Zone'` | single label chip |
| `property_address` | text | yes | null | |
| `property_city` | text | yes | null | |
| `property_state` | text | yes | `'TX'` | 2-letter; UI default TX |
| `property_zip` | text | yes | null | |
| `property_type` | text | yes | null | see `PROPERTY_TYPES` enum |
| `source` | text | yes | null | see `LEAD_SOURCES` enum |
| `referral_note` | text | yes | null | only when `source = 'Referral'` |
| `lead_type` | text | yes | null | see §12 note on the enum conflict |
| `work_type` | text | yes | null | see `WORK_TYPES` |
| `trade_type` | text | yes | null | see `TRADE_TYPES` |
| `urgency` | enum `lead_urgency` | no | `'Standard'` | `Standard` \| `High` \| `Emergency` |
| `job_notes` | text | yes | null | "Field Notes" |
| `canvasser_id` | FK → members | yes | null | door-knock canvasser (REP id, e.g. `LUP-1040`) |
| `insurance_company` | text | yes | null | |
| `insurance_claim_status` | enum `claim_status` | yes | null | `Not Filed`\|`Filed`\|`Approved`\|`Paid`\|`Denied` |
| `insurance_claim_number` | text | yes | null | |
| `insurance_policy_number` | text | yes | null | |
| `insurance_adjuster_name` | text | yes | null | |
| `insurance_adjuster_phone` | text | yes | null | |
| `assigned_to_id` | FK → members | yes | null | rep working the lead |
| `follow_up_date` | date | yes | null | |
| `storm_zone` | text | yes | null | e.g. "E Plano / Spring Creek Pkwy" |
| `storm_date` | date | yes | null | e.g. `2026-04-28` |
| `storm_detail` | text | yes | null | e.g. `2.5" hail (severe)` |
| `source_verification_id` | FK → lead_verifications | yes | null | set when promoted from a verification |
| `created_by_id` | FK → members | yes | null | |
| `created_at` | timestamptz | no | now() | |
| `updated_at` | timestamptz | no | now() | drives "updated 3d ago" |
**Indexes:** `(tenant_id)`, `(tenant_id, status)`, `(tenant_id, code)` unique, `(tenant_id, assigned_to_id)`, `(tenant_id, source)`, `(tenant_id, updated_at desc)`.
**Search:** the board search covers name + property address + city + source + canvasser → back with a `tsvector` / trigram index over those columns.
**Example row (from `SAL-001`):**
```json
{
"id": "ld_9f2c…", "code": "SAL-001",
"firstName": "John", "lastName": "Martinez",
"status": "contacted", "priority": "high", "tag": "Storm Zone",
"propertyAddress": "4821 Spring Creek Pkwy", "propertyCity": "Plano",
"propertyState": "TX", "propertyZip": "75023", "propertyType": "Single Family",
"source": "Door Knock", "leadType": "Insurance",
"workType": "Roof Replacement", "tradeType": "Roofing", "urgency": "High",
"jobNotes": "Homeowner showed significant granule loss…",
"canvasserId": "LUP-1040",
"insuranceCompany": "State Farm", "insuranceClaimStatus": "Filed",
"insuranceClaimNumber": "CLM-2026-1000", "insurancePolicyNumber": "POL-080000",
"insuranceAdjusterName": "Marcus Powell", "insuranceAdjusterPhone": "(972) 700-3000",
"assignedToId": "LUP-…", "followUpDate": "2026-06-04",
"stormZone": "E Plano / Spring Creek Pkwy", "stormDate": "2026-04-28",
"stormDetail": "2.5\" hail (severe)",
"createdById": "LUP-1040", "createdAt": "2026-05-28T15:00:00Z", "updatedAt": "…"
}
```
### 4.2 `lead_phones`
**Purpose:** A lead has 1..N phone numbers, one primary.
| Field | Type | Null | Default | Notes |
|-------|------|------|---------|-------|
| `id` | PK | no | gen | |
| `tenant_id` | FK | no | — | |
| `lead_id` | FK → leads | no | — | **on delete cascade** |
| `number` | text | no | — | free-form display, e.g. `(469) 500-1000` |
| `type` | enum `phone_type` | no | `'Mobile'` | `Mobile` \| `Home` \| `Work` |
| `is_primary` | bool | no | false | exactly one true per lead (enforce) |
| `position` | int | no | 0 | display order |
**Indexes:** `(lead_id)`. **Constraint:** partial unique `(lead_id) where is_primary` to guarantee a single primary.
### 4.3 `lead_emails`
| Field | Type | Null | Default | Notes |
|-------|------|------|---------|-------|
| `id` | PK | no | gen | |
| `tenant_id` | FK | no | — | |
| `lead_id` | FK → leads | no | — | cascade |
| `address` | text | no | — | validate email format |
| `type` | text | yes | null | optional label |
| `is_primary` | bool | no | false | |
| `position` | int | no | 0 | |
### 4.4 `lead_attachments` (site photos) — `[Backend Pending]`
**Purpose:** Backs the "Site Photos" uploader (L10). The frontend currently only stores placeholder labels; the real implementation must use the existing media presign flow (§11.5).
| Field | Type | Null | Default | Notes |
|-------|------|------|---------|-------|
| `id` | PK | no | gen | |
| `tenant_id` | FK | no | — | |
| `lead_id` | FK → leads | no | — | cascade |
| `content_ref` | text | no | — | IIOS object key from `crm.media.presignUpload` |
| `mime_type` | text | no | — | |
| `size_bytes` | bigint | no | — | ≤ `26_214_400` (25 MB) |
| `filename` | text | yes | null | |
| `uploaded_by_id` | FK → members | yes | null | |
| `created_at` | timestamptz | no | now() | |
### 4.5 `lead_status_history` (audit) — recommended
**Purpose:** Backs "Update Status" auditing (no dedicated Leads timeline UI exists yet, but status changes must be auditable and this feeds a future timeline). Mirrors the verification activity concept.
| Field | Type | Null | Notes |
|-------|------|------|-------|
| `id` | PK | no | |
| `tenant_id` | FK | no | |
| `lead_id` | FK → leads | no | cascade |
| `from_status` | enum | yes | null on create |
| `to_status` | enum | no | |
| `actor_id` | FK → members | yes | |
| `note` | text | yes | |
| `created_at` | timestamptz | no | |
### 4.6 `lead_verifications`
**Purpose:** One row per verification-desk record (the Verification table row + detail).
| Field | Type | Null | Default | Notes |
|-------|------|------|---------|-------|
| `id` | PK | no | gen | |
| `tenant_id` | FK | no | — | indexed |
| `code` | text | no | seq | display code `LD-V-001`; **unique per tenant** |
| `name` | text | no | — | customer full name (single field in UI) |
| `phone` | text | yes | null | |
| `email` | text | yes | null | derived client-side when absent (see `deriveEmail`) |
| `address` | text | yes | null | single-line address string in UI |
| `source` | text | no | — | see `V_SOURCES` (`Door Knock`, `Web Form`, `Storm Canvass`, `Referral`, `Call-In`) |
| `status` | enum `verification_status` | no | `'pending'` | `verified`\|`in_progress`\|`assigned`\|`pending`\|`unverified` |
| `sub_status` | text | no | `'Pending Review'` | the `verification` label, see §8.6 |
| `assignee_id` | FK → members | yes | null | verification specialist |
| `notes` | text | yes | null | "Verification Notes" |
| `promoted_lead_id` | FK → leads | yes | null | set when verified → Lead created |
| `verified_at` | timestamptz | yes | null | |
| `created_at` | timestamptz | no | now() | drives "Created" column |
| `updated_at` | timestamptz | no | now() | |
**Indexes:** `(tenant_id)`, `(tenant_id, status)`, `(tenant_id, source)`, `(tenant_id, assignee_id)`, `(tenant_id, code)` unique, `(tenant_id, created_at desc)`.
**Search:** name + code + phone + source + address → trigram/tsvector.
**Example rows (from `LD-V-001` and a lean one):**
```json
{ "id":"lv_1…","code":"LD-V-001","name":"Kevin Hartley",
"phone":"(972) 413-8902","email":"kevin.hartley@gmail.com",
"address":"2814 Ravenswood Dr, Plano, TX 75023","source":"Door Knock",
"status":"verified","subStatus":"Verified","assigneeId":"…Wade…",
"notes":"Ownership confirmed via county records…",
"verifiedAt":"2026-05-16T19:55:00Z","createdAt":"2026-05-14T14:40:00Z" }
{ "id":"lv_11…","code":"LD-V-011","name":"Aaron Blake",
"phone":"(469) 471-3350","address":"1188 Alma Dr, Plano, TX 75075",
"source":"Door Knock","status":"pending","subStatus":"Pending Review",
"assigneeId":null,"createdAt":"2026-05-27T…" }
```
### 4.7 `lead_verification_activities`
**Purpose:** The Activity timeline in the verification detail (V18). The frontend synthesizes these when absent (`buildActivity`), but the backend should persist real ones.
| Field | Type | Null | Notes |
|-------|------|------|-------|
| `id` | PK | no | |
| `tenant_id` | FK | no | |
| `verification_id` | FK → lead_verifications | no | cascade |
| `text` | text | no | e.g. "Assigned to Wade Hollis." |
| `actor_id` | FK → members | yes | maps to `who` (name shown; "System" when null) |
| `actor_label` | text | yes | denormalized display name / "System" |
| `occurred_at` | timestamptz | no | maps to `time` |
| `kind` | text | yes | optional: `submitted`\|`assigned`\|`in_progress`\|`verified`\|`unverified`\|`note` |
**Index:** `(verification_id, occurred_at)`.
### 4.8 Referenced existing tables (do **not** recreate)
- **`organizations` / tenant** — tenant scope (`tenant_id`). Established at boot / registration (`crm.account.register`).
- **`members`** — CRM team members (reps, canvassers, verification specialists). Already served by `crm.team.member.search` and carry ids like `LUP-1040` plus `principalId`. Leads/verifications reference members for `assigned_to`, `canvasser`, `assignee`, `created_by`, and activity `actor`. In the mock these are free-text names — the backend must resolve them to member ids (see §12).
- **Account/permissions** — `crm.account.me` drives `leads.manage` gating.
---
## 5. Entity Relationships
```
organizations (tenant)
1 ──────────────< leads
1 ──────────────< lead_verifications
1 ──────────────< members
members
1 ──< leads.assigned_to_id
1 ──< leads.canvasser_id
1 ──< leads.created_by_id
1 ──< lead_verifications.assignee_id
1 ──< lead_verification_activities.actor_id
1 ──< lead_status_history.actor_id
leads
1 ──< lead_phones (cascade)
1 ──< lead_emails (cascade)
1 ──< lead_attachments (cascade) [backend-pending]
1 ──< lead_status_history (cascade)
lead_verifications
1 ──< lead_verification_activities (cascade)
1 ──0..1 leads (promotion: lead_verifications.promoted_lead_id
⇆ leads.source_verification_id)
```
**Cardinality summary**
| Relationship | Type | Notes |
|--------------|------|-------|
| tenant → lead | 1tomany | all leads tenant-scoped |
| tenant → verification | 1tomany | |
| lead → phones | 1tomany | ≥1, exactly one primary |
| lead → emails | 1tomany | 0..N |
| lead → attachments | 1tomany | 0..N (pending) |
| lead → status history | 1tomany | audit |
| verification → activities | 1tomany | timeline |
| verification → lead | 1to(0..1) | promotion link, bidirectional FK |
| member → lead (assigned/canvasser/creator) | manyto1 each | nullable |
| member → verification (assignee) | manyto1 | nullable |
**Embedded value objects (not separate tables — stored as columns on `leads`):** `storm { zone, date, detail }`, `property { … }`, `job { … }`, `insurance { … }`, `assignment { … }`. They are grouped only for UI sectioning; there is no reuse that justifies separate tables. Phones and emails **are** separate tables because they are 1tomany.
---
## 6. API Contract — Actions (Queries & Commands)
> **Transport & style (mandatory):** These are **not REST routes**. They are `be-crm` **data-door actions** invoked through the AppShell SDK exactly like every other module:
> - **Reads:** `useQuery<T>("<action>", variables)` → resolves to `T` (or `null` while loading). Errors surface as an `Error` with `.message`.
> - **Writes:** `sdk.command<T>("<action>", variables)` → resolves to `T`. Commands carry an auto-generated idempotency key.
> - **Action naming:** `crm.<entity>.<verb>` — dot-namespaced, camelCase segments (matches `crm.team.member.setRoles`, `crm.inbox.transition`, `crm.mail.reply`).
> - **Collection reads** return either a bare array (like `crm.mail.list`, `crm.inbox.list`) or `{ items: T[], meta }` (like `crm.team.member.search`). For Leads/Verification use **`{ items, meta }`** because the UI needs totals + pagination (§6.1).
### 6.1 Standard collection envelope
```ts
interface Meta { total: number; page: number; perPage: number; }
interface Page<T> { items: T[]; meta: Meta; }
```
- `verify.tsx` renders `"{rows.length} of {V_LEADS.length} leads"``rows.length` is the filtered page length, `meta.total` is the (filtered or overall) count. Return the **filtered** total so the "X of Y" reads correctly, plus the unfiltered stat counts via the dedicated `*.stats` action.
- `leads.tsx` shows `TOTAL_LEADS = 35` while only rendering 5 cards → the board is paginated/capped server-side; provide `perPage` (UI has no pager control yet, so default a sensible `perPage`, e.g. 50, and return `meta.total` for the header).
### 6.2 Leads — Queries
| Action | Variables | Returns | Backs |
|--------|-----------|---------|-------|
| `crm.lead.search` | `{ query?, status?, priority?, source?, assigneeId?, page?, perPage? }` | `Page<LeadCardDTO>` | board list L1, search L3, tabs L4 |
| `crm.lead.get` | `{ id }` | `LeadDTO` (full detail incl. phones/emails/attachments) | detail modal L5 |
| `crm.lead.stats` | `{}` | `{ total: number, byStatus: { new, contacted, appointed, closed } }` | stat strip L2 |
`LeadCardDTO` (list projection — only what the card needs):
```ts
{ id, code, name, initials, gradient, priority, status, tag,
primaryPhone, propertyAddress, propertyCity, propertyState,
source, canvasserName, updatedAt }
```
`LeadDTO` (detail projection): all §4.1 columns + `phones: Phone[]` + `emails: Email[]` + `attachments: Attachment[]` + resolved assignee/canvasser/creator display names + `storm`, `property`, `job`, `insurance`, `assignment` groupings.
### 6.3 Leads — Commands
| Action | Variables | Returns | Backs |
|--------|-----------|---------|-------|
| `crm.lead.create` | full create payload (below) | `LeadDTO` | Create Lead L6/L7 |
| `crm.lead.update` | `{ id, patch: Partial<LeadInput> }` | `LeadDTO` | edit (backend-pending; UI edit form not built) |
| `crm.lead.updateStatus` | `{ id, status, note? }` | `LeadDTO` | detail "Update Status" L16 |
| `crm.lead.assign` | `{ id, assigneeId \| null }` | `LeadDTO` | rep assignment L15 |
| `crm.lead.attachment.add` | `{ id, attachment: { contentRef, mimeType, sizeBytes, filename } }` | `LeadDTO` | site photos L10 (pending) |
| `crm.lead.attachment.remove` | `{ id, attachmentId }` | `LeadDTO` | pending |
| `crm.lead.archive` | `{ id }` | `{ id }` | **pending** — no UI yet; include only if requested |
| `crm.lead.delete` | `{ id }` | `{ id }` | **pending** — no UI yet; include only if requested |
**`crm.lead.create` payload** (union of Quick + Full form, all optional except `firstName`/`lastName` where at least one non-empty):
```ts
{
firstName: string, lastName?: string,
phones: { number: string, type: "Mobile"|"Home"|"Work", primary?: boolean }[],
emails?: { address: string, primary?: boolean }[],
property?: { address?, city?, state?, zip?, type? },
photos?: { contentRef, mimeType, sizeBytes, filename }[], // real uploads (pending)
job?: { source?, referralNote?, canvasserId?, leadType?, workType?, tradeType?,
urgency?: "Standard"|"High"|"Emergency", notes? },
insurance?: { company?, claimNumber?, claimStatus?, adjusterName?,
adjusterPhone?, policyNumber? },
assignment?: { assigneeId?: string|null, priority?: "Low"|"Medium"|"High",
followUp?: string /* ISO date */ }
}
```
> Note the **priority casing mismatch**: the create form emits `"Low"|"Medium"|"High"` (title-case) while list/detail data uses `"low"|"medium"|"high"`. Normalize to lowercase on write (see §12).
### 6.4 Lead Verification — Queries
| Action | Variables | Returns | Backs |
|--------|-----------|---------|-------|
| `crm.leadVerification.search` | `{ query?, status?, source?, assigneeId?, page?, perPage? }` | `Page<VerificationRowDTO>` | table V3, search V4, filters V5V7 |
| `crm.leadVerification.get` | `{ id }` | `VerificationDTO` (+ activities) | detail V16V18 |
| `crm.leadVerification.stats` | `{}` | `{ verified, in_progress, assigned, pending, unverified }` | stat tiles V1 |
| `crm.leadVerification.activity.list` | `{ id }` | `VerificationActivity[]` | timeline V18 (or embed in `.get`) |
`VerificationRowDTO`:
```ts
{ id, code, name, initials, address, phone, source,
assignee: { id, initials, name } | null,
status, verification /* sub_status */, createdAt }
```
`VerificationDTO`: adds `email`, `notes`, `verifiedAt`, `promotedLeadId`, `activities[]`.
### 6.5 Lead Verification — Commands
| Action | Variables | Returns | Backs |
|--------|-----------|---------|-------|
| `crm.leadVerification.verify` | `{ id, notes? }` | `{ verification: VerificationDTO, lead: LeadDTO }` | Verify V9/V11/V19 — **promotes to Lead** |
| `crm.leadVerification.markUnverified` | `{ id, reason? }` | `VerificationDTO` | Mark Unverified V12 |
| `crm.leadVerification.assign` | `{ id, assigneeId }` | `VerificationDTO` | Change Assignee V13 |
| `crm.leadVerification.reassign` | `{ id, assigneeId? }` | `VerificationDTO` | Reassign → In Progress V14 |
| `crm.leadVerification.moveToPending` | `{ id }` | `VerificationDTO` | Move to Pending V15 |
| `crm.leadVerification.updateStatus` | `{ id, status, subStatus? }` | `VerificationDTO` | generic status set (covers sub-status changes) |
| `crm.leadVerification.note.add` | `{ id, text }` | `VerificationDTO` | verification notes (pending edit UI) |
**`crm.leadVerification.verify` behaviour (the critical cross-module command):**
1. Load verification; guard status is not already `verified`.
2. Set `status = 'verified'`, `sub_status = 'Verified'`, `verified_at = now()`, persist `notes` if provided.
3. **Create a `leads` row** (`status = 'new'`) from the verification's contact/address/source, linking `leads.source_verification_id = verification.id` and `verification.promoted_lead_id = lead.id`.
4. Append activity: `"Verified and pushed to New Leads."` (actor = current user).
5. Return both records so the UI can refresh both queues.
6. Idempotent: a second call with the same idempotency key must not create a duplicate Lead.
### 6.6 Shared / reused actions (already exist — reuse, don't rebuild)
| Action | Use in Leads/Verification |
|--------|---------------------------|
| `crm.account.me` | `leads.manage` permission gate for both modules |
| `crm.team.member.search { perPage }` | rep / canvasser / assignee pickers (`REPS`, `V_ASSIGNEES`) |
| `crm.media.presignUpload { mime, sizeBytes }``{ objectKey, uploadUrl }` | site-photo upload (browser PUTs bytes directly) |
| `crm.media.presignDownload { contentRef }``{ url }` | render/download a lead photo |
---
## 7. Validation Rules
### 7.1 `crm.lead.create` / `crm.lead.update`
| Field | Required | Rule |
|-------|----------|------|
| name (`firstName`/`lastName`) | **Yes** (at least one non-empty after trim) | matches the only current UI guard: `` `${first} ${last}`.trim() `` must be non-empty → else error `name_required` |
| `phones[].number` | No | free-form; if present, strip to digits server-side for storage/dedupe (form already does `phone.replace(/\D/g,"")` in registration) |
| `phones[].type` | with phone | enum `Mobile\|Home\|Work` |
| `phones` primary | — | at most one `primary: true`; if none set, mark the first as primary |
| `emails[].address` | No | RFC-5322-ish email format when present |
| `property.state` | No | 2-letter US state; default `TX` |
| `property.zip` | No | 5-digit (or ZIP+4) when present |
| `property.type` | No | one of `PROPERTY_TYPES` (§8.4) |
| `source` | No | one of `LEAD_SOURCES` (§8.1) |
| `referralNote` | Conditional | accept only when `source = 'Referral'` (UI shows it only then) |
| `canvasserId` | Conditional | resolve to a member; UI surfaces the picker only when `source = 'Door Knock'`, but accept whenever provided |
| `leadType` | No | see §12 enum conflict — accept the UI's `Residential\|Commercial\|Multi-Family` **and** the data model's `Insurance\|Retail`; store raw, do not hard-reject |
| `workType` | No | one of `WORK_TYPES` |
| `tradeType` | No | one of `TRADE_TYPES` |
| `urgency` | No | enum `Standard\|High\|Emergency`; default `Standard` |
| `insurance.claimStatus` | No | enum `Not Filed\|Filed\|Approved\|Paid\|Denied` |
| `priority` | No | accept `Low\|Medium\|High` (form) → normalize to `low\|medium\|high`; default `medium` |
| `assignment.followUp` | No | valid ISO date; may be null/`"—"` |
| `assignment.assigneeId` | No | resolve to a member or `null` (Unassigned) |
**Business validations:**
- `code` (`SAL-###`) is server-generated, unique per tenant — never accepted from the client.
- Duplicate detection (recommended, not in UI): warn (not block) if an active lead exists with the same primary phone **or** same property address within the tenant.
- All member references (`assigneeId`, `canvasserId`) must belong to the same tenant.
### 7.2 Verification commands
| Command | Rule |
|---------|------|
| `verify` | reject if already `verified` (`already_verified`); require the record exists in tenant; create exactly one Lead (idempotent) |
| `assign` / `reassign` | `assigneeId` must be a tenant member; `assign` moves `pending → assigned` (or updates assignee); `reassign` sets `in_progress` |
| `markUnverified` | allowed from any non-terminal state; optional `reason` recorded as activity |
| `moveToPending` | allowed from `assigned` / `in_progress`; clears/keeps assignee per product choice (UI does not specify — keep assignee, set `status=pending`, `sub_status='Pending Review'`) |
| `updateStatus` | `status ∈ verification_status`; if `subStatus` omitted, default it from the status (§8.6 mapping) |
| `note.add` | `text` non-empty, trimmed, reasonable max length (e.g. 5 000 chars) |
**Enums must be validated server-side even though the frontend does not enforce them** — the frontend is permissive (free selects), so the backend is the source of truth.
---
## 8. Reference / Enum Data
Exact values, copied from `leads-data.ts` and `verify-data.ts`. Provide these either as DB enums or as a reference-data query (`crm.lead.options` — optional) so the UI selects stay in sync.
### 8.1 `LEAD_SOURCES`
`Door Knock`, `Referral`, `Storm Chase`, `Mailer / Postcard`, `Sign Call`, `Insurance Agent Referral`, `Repeat Customer`, `Social Media`, `Other`
### 8.2 `WORK_TYPES`
`Roof Replacement`, `Roof Repair`, `Inspection`, `Gutter Install`
### 8.3 `TRADE_TYPES`
`Roofing`, `Gutters`, `Siding`, `Windows`
### 8.4 `PROPERTY_TYPES`
`Single Family`, `Multi Family`, `Commercial`
*(the Full-form Property step also offers `Residential`, `Commercial`, `Multi-Family`, `Industrial` via `PROPERTY_TYPE_OPTS` — accept the superset; see §12)*
### 8.5 `CLAIM_STATUSES`
`Not Filed`, `Filed`, `Approved`, `Paid`, `Denied`
### 8.6 Verification statuses → default sub-status label
| `status` | default `sub_status` | other observed sub-status labels |
|----------|----------------------|----------------------------------|
| `verified` | `Verified` | — |
| `in_progress` | `Verifying Identity` | `Reviewing Insurance`, `Confirming Ownership`, `Confirming Damage` |
| `assigned` | `Assigned` | — |
| `pending` | `Pending Review` | — |
| `unverified` | `Unverified` | — |
### 8.7 Verification sources (`V_SOURCES`)
`Door Knock`, `Web Form`, `Storm Canvass`, `Referral`, `Call-In`
*(note: different set from Lead sources §8.1 — keep them as separate reference lists)*
### 8.8 Lead statuses / priorities / urgency
- `lead_status`: `new`, `contacted`, `appointed`, `closed`
- `lead_priority`: `high`, `medium`, `low` (create form emits title-case `Low`/`Medium`/`High`)
- `lead_urgency`: `Standard`, `High`, `Emergency`
- `phone_type`: `Mobile`, `Home`, `Work`
- `lead_type`: **conflicting** — `Insurance`/`Retail` (data) vs `Residential`/`Commercial`/`Multi-Family` (form). See §12.
### 8.9 Seed members (reps / canvassers / verification specialists)
Reps (`REPS`, ids are real member codes): `LUP-1040 Cody Tatum`, `LUP-1041 Hannah Reyes`, `LUP-1042 Travis Boone`, `LUP-1043 Shelby Greer`, `LUP-1044 Dalton Pruitt`.
Verification specialists (`V_ASSIGNEES`): `Wade Hollis`, `Darlene Brooks`, `Roy Schaefer`.
---
## 9. Backend Services
Break the implementation into services mirroring the existing `be-crm` module layout (one bounded context per data-door namespace).
| Service | Namespace | Responsibilities |
|---------|-----------|------------------|
| **LeadService** | `crm.lead.*` | CRUD + search + stats for leads; owns phones/emails child collections; code generation (`SAL-###`); status transitions; assignment. |
| **LeadVerificationService** | `crm.leadVerification.*` | Verification queue search/stats; status & sub-status transitions; assign/reassign/pending; note management; code generation (`LD-V-###`). |
| **LeadPromotionService** (or a method on LeadVerificationService) | part of `crm.leadVerification.verify` | The cross-module promotion: verified verification → new Lead, bidirectional linking, idempotency. Owns the transaction that touches both `leads` and `lead_verifications`. |
| **LeadActivityService** | activities/history | Append + list `lead_verification_activities` and `lead_status_history`; generates the timeline the UI reads (replaces the client-side `buildActivity` synthesis). |
| **LeadAssignmentService** | assignment concerns | Resolve member ids for `assignedTo` / `canvasser` / `assignee`; validate tenant membership; (future) round-robin. Can be a thin helper over the existing member/team module rather than a standalone service. |
| **MediaService (existing — reuse)** | `crm.media.*` | Presign upload/download for site photos. **Do not reimplement** — see media-api.ts. |
| **Account/AccessService (existing — reuse)** | `crm.account.me` | `leads.manage` authorization for every action here. |
| **NotificationService** — `[Backend Pending]` | — | The frontend today only toasts locally. No server notification is required for parity, but promotion/verify events are natural triggers if/when the Inbox projection (`crm.inbox.*`) should surface them. Do not build unless requested. |
**Layering convention (match existing modules):** Controller/handler (validates action variables, applies auth) → Service (business rules, transitions) → Repository (tenant-scoped data access). DTO projections are shaped for the UI (list vs detail) exactly as the current modules return purpose-built DTOs (e.g. `MemberDTO`, `MailThread`).
---
## 10. Error Handling
Errors propagate to the UI as a thrown `Error`; the SDK hooks expose `error.message` (see `q.error?.message ?? null` throughout the API layer). Permission failures use the SDK's `PermissionError` (OPA). Provide a **stable machine `code` + human `message`**; the UI currently shows the message in a toast/inline state.
| Scenario | When | Suggested code | HTTP-equivalent | UI effect |
|----------|------|----------------|-----------------|-----------|
| Unauthenticated | no/expired session | `unauthenticated` | 401 | `auth-gate` redirects to `/portal/login` |
| Forbidden | member lacks `leads.manage` | `forbidden` / `PermissionError` | 403 | nav item hidden; action rejected |
| Validation — name required | create with empty name | `name_required` | 422 | matches current client guard toast |
| Validation — bad enum | invalid status/source/type | `invalid_value` | 422 | inline/toast |
| Validation — bad email/zip/phone | format fails | `invalid_format` | 422 | |
| Not found | unknown `id` / wrong tenant | `not_found` | 404 | toast; row disappears on refetch |
| Conflict — already verified | `verify` on a verified record | `already_verified` | 409 | toast |
| Conflict — invalid transition | disallowed status change | `invalid_state_transition` | 409 | toast |
| Conflict — duplicate lead | dup phone/address (if enforced) | `duplicate_lead` | 409 | warn (prefer soft-warn, not block) |
| Conflict — duplicate code | code collision (should be internal) | `duplicate_code` | 409 | internal retry |
| Assignee invalid | assignee not a tenant member | `invalid_assignee` | 422 | toast |
| Upload too large | photo > 25 MB | `file_too_large` | 413 | matches media-api guard ("max 25 MB") |
| Upload failed | presigned PUT fails | `upload_failed` | 502 | toast |
| Rate / idempotency replay | duplicate command key | (return original result) | 200 | no-op, safe |
| Server error | unexpected | `internal_error` | 500 | generic toast |
**Error envelope (recommended, consistent with a thrown Error carrying structured data):**
```json
{ "error": { "code": "already_verified",
"message": "This lead has already been verified.",
"details": { "verificationId": "lv_1…" } } }
```
---
## 11. Existing Backend Integration (mandatory conventions)
The new backend **must** follow the patterns already used by Team, Mail, Inbox, Messenger, Media and Account. Never invent a different architecture.
### 11.1 Data door, not REST
The frontend never calls REST endpoints. It calls **actions** through the AppShell SDK:
```ts
// read
const q = useQuery<Page<LeadCardDTO>>("crm.lead.search", { status, query, page, perPage });
// write
await sdk.command("crm.leadVerification.verify", { id, notes });
```
`DataClient.query(action, variables)` and `DataClient.command(action, variables)` call the **Shell BFF's cookie-authed `/data` proxy**; the BFF attaches the session token server-side and forwards to `be-crm`. Apps never hold a token, set an `Authorization` header, or know the domain endpoint.
### 11.2 Transport path
```
browser (SDK) ──► Next rewrite /shell/:path* ──► BFF (BFF_ORIGIN, default http://localhost:4000)/api/:path* ──► be-crm domain API
```
(from `next.config.ts` `rewrites()` and `providers.tsx` `bffBaseUrl = "/shell"`). The session is an **HttpOnly cookie**; that is why the same-origin `/shell` prefix is used (never `/api`, to avoid clobbering the local `/api/geo` route).
### 11.3 Authentication & authorization
- Auth is Supabase-backed via the SDK (`appId: "crm-web"`); the BFF exchanges it for a domain session. When `NEXT_PUBLIC_SUPABASE_URL` is unset the app runs in **mock mode** (see §11.6).
- Authorization is **permission-based** via `crm.account.me` → `{ registered, isMember, roleSlugs, permissions, isSuperadmin }`. Both modules require **`leads.manage`** (already declared in `access.ts` `ALL_CRM_PERMISSIONS` and mapped in `sidebar.tsx` `NAV_PERMISSION`). Enforce it on **every** `crm.lead.*` and `crm.leadVerification.*` action — the frontend gate is UX-only ("be-crm still enforces every action").
- OPA/IIOS may additionally gate row visibility; keep everything **tenant-scoped**.
### 11.4 Response & naming conventions
- **Actions:** `crm.<entity>.<verb>` dot-namespaced; segments camelCase (e.g. `member.setRoles`, `invitation.create`, `inbox.transition`, `mail.reply`). Use `search`/`list` for reads, imperative verbs for writes.
- **DTOs:** server returns UI-shaped DTOs (list vs detail projections). Fields are **camelCase**. Timestamps are **ISO-8601 strings**; the client formats relative/pretty (`relTime`). Nullable fields are `null`, not omitted, where the UI reads them.
- **Collections:** `{ items: T[], meta: { total, page, perPage } }` for paginated sets (Team pattern) — used here for `search`. Bare arrays are acceptable for small always-full lists (Mail/Inbox pattern) but Leads/Verification use the envelope for totals.
- **Commands** are idempotent (idempotency key auto-attached); write-then-refetch is the client norm (`cmd(...)` then `refetch()`), so commands should return the updated entity to allow optimistic UI too.
### 11.5 Media / attachments (reuse exactly)
Site photos must use the existing two-step flow (from `media-api.ts`):
1. `sdk.command("crm.media.presignUpload", { mime, sizeBytes })` → `{ objectKey, uploadUrl }`.
2. Browser `PUT`s the file bytes **directly** to `uploadUrl` (IIOS storage) — be-crm only mints the URL.
3. Store `{ contentRef: objectKey, mimeType, sizeBytes, filename }` on the lead via `crm.lead.attachment.add`.
4. Display via `crm.media.presignDownload { contentRef }` → `{ url }`.
Enforce the **25 MB** cap (`MAX_ATTACHMENT_BYTES = 26_214_400`).
### 11.6 Mock-mode parity (important)
Every existing data-layer file (`team-api`, `mail-api`, `inbox-api`, `messenger-api`, `access`) chooses **mock vs live at module load** via `isShellConfigured()` (`Boolean(process.env.NEXT_PUBLIC_SUPABASE_URL)`). When you wire Leads/Verification to the live door, follow the same shape: create `src/lib/leads-api.ts` and `src/lib/verify-api.ts` exposing one hook per module that returns `{ live, loading, error, …data, …commands, refetch }`, serving the current mock (`LEADS`, `V_LEADS`) when the Shell isn't configured and the live `crm.lead.*` / `crm.leadVerification.*` actions when it is. This keeps the demo working and matches the established convention. *(Frontend wiring task — noted for completeness; the backend itself only needs to implement the live actions.)*
### 11.7 DTO / repository / service pattern summary
- **DTO:** dedicated interfaces per projection (`LeadCardDTO`, `LeadDTO`, `VerificationRowDTO`, `VerificationDTO`) — do not leak raw table rows.
- **Repository:** always parameterize by `tenantId`; never a query without tenant scope.
- **Service:** owns transitions, code generation, promotion transaction, activity emission.
- **Controller/handler:** maps the action name + variables to a service call, validates, applies auth. One handler per action, matching the `crm.<entity>.<verb>` registry the BFF forwards to.
---
## 12. Gaps, Discrepancies & Backend-Pending Items
Explicitly flagged so the backend developer is not surprised.
1. **Everything is mock today.** No `crm.lead.*` or `crm.leadVerification.*` action is called anywhere yet. All actions in §6 are new. Frontend wiring (§11.6) is a separate follow-up task.
2. **`leadType` enum conflict.** `leads-data.ts` defines `LEAD_TYPES = ["Insurance","Retail"]` and mock rows use `"Insurance"`, but the Full-form Job step's `LEAD_TYPE_OPTS = ["Residential","Commercial","Multi-Family"]`. **Recommendation:** store `lead_type` as free text (do not hard-reject), accept both sets, and raise with product which taxonomy is canonical. **[Needs product decision]**
3. **`propertyType` superset.** `PROPERTY_TYPES` (data) = `Single Family / Multi Family / Commercial`; the form offers `Residential / Commercial / Multi-Family / Industrial`. Accept the union.
4. **Priority casing.** Create form emits `Low/Medium/High`; list/detail use `low/medium/high`. **Normalize to lowercase** on write; return lowercase.
5. **Assignee/canvasser/creator are free-text names in the mock** (`"Jesus Gonzales"`, `"Cody Tatum"`, `"Wade Hollis"`). The backend must model these as **member FKs** and resolve display names in DTOs. The verification specialists (`Wade Hollis`, `Darlene Brooks`, `Roy Schaefer`) are not in the `REPS` list — they must exist as members/records too. **[Seed/link required]**
6. **Site photos are placeholders.** `addPhoto()` stores label strings (`"Photo 1"`). Real upload via the media presign flow (§11.5) is **backend-pending** but fully specified.
7. **Detail actions are inert.** Leads "Call / Email / Update Status" and Verification "Call / Verify" footer buttons have **no handlers**. `updateStatus`, `verify` etc. are specified in §6 but the buttons must be wired (frontend task).
8. **Activity timeline is client-synthesized.** `buildActivity()` fabricates timeline entries when a verification lacks `activity[]`. The backend should persist **real** activities (`lead_verification_activities`) and the client should stop synthesizing once live.
9. **No pagination controls in the UI**, yet `TOTAL_LEADS = 35` (only 5 loaded) and the verification footer shows "X of Y". Implement server pagination + `meta.total`; pick a sane default `perPage`. A pager UI is a future frontend task.
10. **No edit / delete / archive / bulk / tags-editor / sort UI.** Endpoints for update/archive/delete are included as **optional/pending** — implement `crm.lead.update` (needed for status/assignment) but treat `archive`/`delete`/bulk as **do-not-build-unless-requested**.
11. **Verification records are never created from the UI** (like the Inbox, they "arrive"). Provide an intake ingestion path (webhook / internal command) **outside** these two module screens — its exact source is not defined by the frontend. **[Needs definition]**
12. **Single-line vs structured address.** Leads store structured address (address/city/state/zip); verifications store a single `address` string (`"2814 Ravenswood Dr, Plano, TX 75023"`). When promoting a verification → lead, parse or carry the single string into `property_address` (best-effort parse; keep the raw string too). **[Parsing rule needed]**
---
*End of document.*
+1061 -26
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -14,7 +14,7 @@
"@huggingface/transformers": "^4.2.0", "@huggingface/transformers": "^4.2.0",
"@imgly/background-removal": "^1.7.0", "@imgly/background-removal": "^1.7.0",
"@insignia/iios-kernel-client": "^0.1.4", "@insignia/iios-kernel-client": "^0.1.4",
"@insignia/iios-messaging-ui": "^0.1.4", "@insignia/iios-messaging-ui": "^0.1.7",
"@photo-gallery/sdk": "file:./vendor/photo-gallery-sdk", "@photo-gallery/sdk": "file:./vendor/photo-gallery-sdk",
"@tensorflow-models/coco-ssd": "^2.2.3", "@tensorflow-models/coco-ssd": "^2.2.3",
"@tensorflow/tfjs": "^4.22.0", "@tensorflow/tfjs": "^4.22.0",
+50
View File
@@ -0,0 +1,50 @@
/* Web Push service worker for CRM offline notifications.
*
* Receives push payloads from IIOS (WebPushDelivery), shows a system notification, and on click
* focuses an open CRM tab (or opens one) and posts the thread to deep-link to. The payload shape is
* IIOS's NotificationPayload: { title, body, data: { threadId, interactionId? } }.
*
* Served from /public at /push-sw.js → root scope ('/'), so it controls the whole app.
*/
self.addEventListener("push", (event) => {
let payload = {};
try {
payload = event.data ? event.data.json() : {};
} catch {
payload = { title: "New notification", body: event.data ? event.data.text() : "" };
}
const title = payload.title || "New message";
const threadId = payload.data && payload.data.threadId;
event.waitUntil(
self.registration.showNotification(title, {
body: payload.body || "",
// Coalesce repeated pings for the same thread into one notification.
tag: threadId ? `thread:${threadId}` : undefined,
renotify: Boolean(threadId),
data: payload.data || {},
icon: "/icons/i_bell.svg",
}),
);
});
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const threadId = event.notification.data && event.notification.data.threadId;
event.waitUntil(
(async () => {
const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
// Prefer an already-open CRM tab: focus it and tell the app which thread to open.
for (const client of all) {
if ("focus" in client) {
await client.focus();
client.postMessage({ type: "notif-click", threadId: threadId || null });
return;
}
}
// No tab open — launch one deep-linked via the query string.
const url = threadId ? `/dashboard?thread=${encodeURIComponent(threadId)}` : "/dashboard";
if (self.clients.openWindow) await self.clients.openWindow(url);
})(),
);
});
+301 -2
View File
@@ -447,8 +447,10 @@
.dash-root .ds-modal.size-sm { max-width: 400px; } .dash-root .ds-modal.size-sm { max-width: 400px; }
.dash-root .ds-modal.size-md { max-width: 540px; } .dash-root .ds-modal.size-md { max-width: 540px; }
.dash-root .ds-modal.size-lg { max-width: 760px; } .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 { 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-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-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 h3 { font-size: 16px; font-weight: 700; }
.dash-root .ds-modal-head p { font-size: 12.5px; color: var(--muted); margin-top: 3px; } .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 { 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 svg { color: #fff; }
.dash-root .tm .ds-btn.v-primary:not(:disabled):hover { background: rgba(253, 169, 19, 1); } .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 { 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: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; } .dash-root .tm-search .ds-input { flex: 1; }
@@ -1154,8 +1156,283 @@
.dash-root .ai-caps { grid-template-columns: 1fr; } .dash-root .ai-caps { grid-template-columns: 1fr; }
.dash-root .ai-bubble { max-width: 86%; } .dash-root .ai-bubble { max-width: 86%; }
.dash-root .ai-view { height: calc(100vh - 150px); } .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 */
/* ========================================================== */
/* ---- stat strip ---- */
.dash-root .leads-stats { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-bottom: 18px; }
.dash-root .leads-stat { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 16px; border: 1px solid var(--border); background: var(--card-grad); box-shadow: var(--card-hi); position: relative; overflow: hidden; }
.dash-root .leads-stat::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--orange); }
.dash-root .leads-stat.tone-blue::before { background: var(--blue); }
.dash-root .leads-stat.tone-purple::before { background: var(--purple); }
.dash-root .leads-stat.tone-green::before { background: var(--green); }
.dash-root .leads-stat.tone-orange::before { background: var(--orange); }
.dash-root .leads-stat-ic { width: 38px; height: 38px; border-radius: 11px; display: grid; place-items: center; background: color-mix(in srgb, var(--orange) 14%, transparent); color: var(--orange); flex: 0 0 auto; }
.dash-root .leads-stat.tone-blue .leads-stat-ic { background: color-mix(in srgb, var(--blue) 16%, transparent); color: #6f9bff; }
.dash-root .leads-stat.tone-purple .leads-stat-ic { background: color-mix(in srgb, var(--purple) 16%, transparent); color: #b07bf2; }
.dash-root .leads-stat.tone-green .leads-stat-ic { background: color-mix(in srgb, var(--green) 16%, transparent); color: var(--green); }
.dash-root .leads-stat-val { font-size: 22px; font-weight: 800; line-height: 1; letter-spacing: -0.02em; }
.dash-root .leads-stat-lbl { font-size: 11.5px; color: var(--muted); font-weight: 600; margin-top: 4px; }
/* ---- toolbar ---- */
.dash-root .leads-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
.dash-root .leads-search { display: flex; align-items: center; gap: 9px; flex: 1 1 260px; min-width: 220px; height: 42px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--panel); color: var(--muted); }
.dash-root .leads-search:focus-within { border-color: color-mix(in srgb, var(--orange) 55%, var(--border)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--orange) 14%, transparent); }
.dash-root .leads-search input { flex: 1; border: 0; background: none; outline: none; color: var(--text); font-family: inherit; font-size: 13.5px; }
.dash-root .leads-search input::placeholder { color: var(--muted); }
.dash-root .leads-search-x { border: 0; background: none; color: var(--muted); cursor: pointer; display: grid; place-items: center; padding: 2px; border-radius: 6px; }
.dash-root .leads-search-x:hover { color: var(--text); background: var(--panel-3); }
.dash-root .leads-tabs { display: inline-flex; gap: 3px; padding: 4px; border-radius: 12px; border: 1px solid var(--border); background: var(--panel); }
.dash-root .leads-tab { border: 0; background: none; color: var(--muted); font-family: inherit; font-size: 12.5px; font-weight: 600; padding: 7px 13px; border-radius: 9px; cursor: pointer; transition: 0.14s; }
.dash-root .leads-tab:hover { color: var(--text-2); }
.dash-root .leads-tab.active { background: var(--orange); color: #1a1205; box-shadow: 0 4px 12px -4px color-mix(in srgb, var(--orange) 60%, transparent); }
/* ---- board ---- */
.dash-root .leads-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 14px; }
.dash-root .lead-card { text-align: left; width: 100%; cursor: pointer; display: flex; flex-direction: column; gap: 11px; padding: 16px 17px; border-radius: 18px; border: 1px solid var(--border); background: var(--card-grad); box-shadow: var(--card-hi), 0 1px 2px rgba(0,0,0,0.18); font-family: inherit; color: var(--text); transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease; }
.dash-root .lead-card:hover { transform: translateY(-3px); border-color: color-mix(in srgb, var(--orange) 40%, var(--border)); box-shadow: var(--shadow), 0 14px 34px -20px color-mix(in srgb, var(--orange) 50%, transparent); }
.dash-root .lead-card-top { display: flex; align-items: center; gap: 12px; }
.dash-root .lead-ava { position: relative; border-radius: 50%; padding: 3px; display: inline-flex; }
.dash-root .lead-ava.prio-high { box-shadow: 0 0 0 2px color-mix(in srgb, var(--red) 70%, transparent); }
.dash-root .lead-ava.prio-medium { box-shadow: 0 0 0 2px color-mix(in srgb, var(--orange) 70%, transparent); }
.dash-root .lead-ava.prio-low { box-shadow: 0 0 0 2px var(--border-2); }
.dash-root .lead-card-id { flex: 1; min-width: 0; }
.dash-root .lead-card-name { font-size: 15px; font-weight: 700; letter-spacing: -0.01em; }
.dash-root .lead-card-sub { display: flex; align-items: center; gap: 4px; font-size: 11.5px; color: var(--muted); margin-top: 2px; }
.dash-root .lead-card-sub svg { color: var(--orange); }
.dash-root .lead-code { font-family: ui-monospace, monospace; font-size: 11px; color: var(--text-2); }
.dash-root .lead-card-row { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--text-2); }
.dash-root .lead-card-row svg { color: var(--muted); flex: 0 0 auto; }
.dash-root .lead-card-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dash-root .lead-card-foot { display: flex; align-items: center; gap: 8px; margin-top: 3px; padding-top: 11px; border-top: 1px solid var(--border); }
.dash-root .lead-source { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; color: var(--muted); }
.dash-root .lead-card-spacer { flex: 1; }
.dash-root .lead-rep { font-size: 11.5px; color: var(--text-2); font-weight: 600; }
.dash-root .lead-updated { font-size: 10.5px; color: var(--faint, var(--muted)); }
.dash-root .leads-empty { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 6px; padding: 48px 20px; color: var(--muted); }
.dash-root .leads-empty svg { color: var(--muted); margin-bottom: 6px; }
.dash-root .leads-empty h3 { font-size: 16px; color: var(--text); }
/* ---- detail popup ---- */
.dash-root .lead-detail { display: flex; flex-direction: column; gap: 16px; }
.dash-root .ld-identity { display: flex; align-items: center; gap: 14px; }
.dash-root .ld-identity-name { font-size: 18px; font-weight: 800; letter-spacing: -0.01em; }
.dash-root .ld-identity-pills { display: flex; gap: 6px; margin-top: 6px; }
.dash-root .ld-storm { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-radius: 14px; border: 1px solid color-mix(in srgb, var(--orange) 30%, var(--border)); background: color-mix(in srgb, var(--orange) 9%, transparent); }
.dash-root .ld-storm-ic { width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; background: color-mix(in srgb, var(--orange) 18%, transparent); color: var(--orange); flex: 0 0 auto; }
.dash-root .ld-storm-zone { font-size: 13.5px; font-weight: 700; }
.dash-root .ld-storm-meta { font-size: 12px; color: var(--muted); margin-top: 2px; }
.dash-root .ld-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.dash-root .ld-section { border: 1px solid var(--border); border-radius: 14px; background: var(--panel-2); padding: 14px 15px; }
.dash-root .ld-section.wide { grid-column: 1 / -1; }
.dash-root .ld-section-head { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--orange); margin-bottom: 12px; }
.dash-root .ld-section-body { display: flex; flex-direction: column; gap: 3px; }
.dash-root .ld-dl { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; padding: 6px 0; border-bottom: 1px dashed var(--border); }
.dash-root .ld-dl:last-child { border-bottom: 0; }
.dash-root .ld-dl-k { font-size: 12px; color: var(--muted); flex: 0 0 auto; }
.dash-root .ld-dl-v { font-size: 12.5px; color: var(--text); font-weight: 600; text-align: right; }
.dash-root .ld-assign { display: grid; grid-template-columns: 1fr 1fr; gap: 0 18px; }
.dash-root .ld-sublabel { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--faint, var(--muted)); font-weight: 700; margin: 8px 0 6px; }
.dash-root .ld-sublabel:first-child { margin-top: 0; }
.dash-root .ld-contact-row { display: flex; align-items: center; gap: 8px; padding: 5px 0; font-size: 12.5px; color: var(--text); }
.dash-root .ld-contact-row svg { color: var(--muted); flex: 0 0 auto; }
.dash-root .ld-contact-val { font-weight: 600; }
.dash-root .ld-contact-tag { font-size: 10.5px; color: var(--muted); padding: 2px 7px; border-radius: 99px; background: var(--panel-3); }
.dash-root .ld-notes p { font-size: 12.5px; color: var(--text-2); line-height: 1.5; margin-top: 2px; }
/* ---- New Lead form ---- */
.dash-root .nl-form { display: flex; flex-direction: column; gap: 16px; }
.dash-root .nl-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 14px; }
.dash-root .nl-grid .ds-field { margin-bottom: 0; }
.dash-root .nl-grid > .ds-field, .dash-root .nl-grid > .nl-full { margin-bottom: 14px; }
.dash-root .nl-full { grid-column: 1 / -1; }
.dash-root .nl-full .ds-field { margin-bottom: 0; }
.dash-root .nl-prio { display: flex; gap: 8px; }
.dash-root .nl-prio-btn { flex: 1; height: 42px; border-radius: 11px; border: 1px solid var(--border-2); background: var(--panel-2); color: var(--muted); font-family: inherit; font-size: 13px; font-weight: 700; cursor: pointer; transition: 0.14s; }
.dash-root .nl-prio-btn:hover { color: var(--text-2); border-color: var(--muted); }
.dash-root .nl-prio-btn.active.low { background: color-mix(in srgb, var(--muted) 22%, transparent); color: var(--text); border-color: var(--muted); }
.dash-root .nl-prio-btn.active.medium { background: color-mix(in srgb, var(--orange) 18%, transparent); color: var(--orange); border-color: color-mix(in srgb, var(--orange) 55%, transparent); }
.dash-root .nl-prio-btn.active.high { background: color-mix(in srgb, var(--red) 18%, transparent); color: var(--red); border-color: color-mix(in srgb, var(--red) 55%, transparent); }
.dash-root .nl-prio-btn.urg.active.standard { background: color-mix(in srgb, var(--muted) 20%, transparent); color: var(--text); border-color: var(--muted); }
.dash-root .nl-prio-btn.urg.active.high { background: color-mix(in srgb, var(--orange) 18%, transparent); color: var(--orange); border-color: color-mix(in srgb, var(--orange) 55%, transparent); }
.dash-root .nl-prio-btn.urg.active.emergency { background: color-mix(in srgb, var(--red) 20%, transparent); color: var(--red); border-color: color-mix(in srgb, var(--red) 60%, transparent); }
/* multi-value rows (phones / emails) */
.dash-root .nl-multirow { display: flex; gap: 8px; margin-bottom: 8px; }
.dash-root .nl-multirow .ds-input { flex: 1; }
.dash-root .nl-typesel { flex: 0 0 108px; width: 108px; }
.dash-root .nl-rowx { flex: 0 0 auto; width: 42px; border-radius: 11px; border: 1px solid var(--border-2); background: var(--panel-2); color: var(--muted); cursor: pointer; display: grid; place-items: center; transition: 0.14s; }
.dash-root .nl-rowx:hover { color: var(--red); border-color: color-mix(in srgb, var(--red) 50%, transparent); }
.dash-root .nl-add { display: inline-flex; align-items: center; gap: 6px; margin-top: 2px; padding: 8px 13px; border-radius: 10px; border: 1px dashed var(--border-2); background: none; color: var(--orange); font-family: inherit; font-size: 12.5px; font-weight: 700; cursor: pointer; transition: 0.14s; }
.dash-root .nl-add:hover { background: color-mix(in srgb, var(--orange) 10%, transparent); border-color: color-mix(in srgb, var(--orange) 45%, transparent); }
.dash-root .nl-empty { font-size: 12.5px; color: var(--faint, var(--muted)); padding: 8px 0 10px; }
/* site photos dropzone */
.dash-root .nl-photos { width: 100%; display: flex; flex-direction: column; align-items: center; gap: 3px; padding: 22px; border-radius: 14px; border: 1.5px dashed var(--border-2); background: var(--panel-2); color: var(--muted); cursor: pointer; transition: 0.14s; }
.dash-root .nl-photos:hover { border-color: color-mix(in srgb, var(--orange) 50%, transparent); color: var(--orange); background: color-mix(in srgb, var(--orange) 7%, transparent); }
.dash-root .nl-photos-t { font-size: 13px; font-weight: 700; color: var(--text-2); }
.dash-root .nl-photos:hover .nl-photos-t { color: var(--orange); }
.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 .ds-select.is-placeholder { color: var(--faint, var(--muted)); }
/* 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; }
.dash-root .nl-canvasser-opt { display: flex; align-items: center; gap: 9px; width: 100%; padding: 7px 9px; border: none; border-radius: 9px; background: transparent; color: var(--text); font-family: inherit; font-size: 13px; text-align: left; cursor: pointer; transition: 0.12s; }
.dash-root .nl-canvasser-opt:hover { background: var(--panel-2); }
.dash-root .nl-canvasser-name { font-weight: 700; }
.dash-root .nl-canvasser-email { color: var(--muted); font-size: 12px; }
.dash-root .nl-canvasser-opt .nl-canvasser-email { margin-left: auto; }
.dash-root .nl-canvasser-empty { padding: 10px; color: var(--muted); font-size: 12.5px; text-align: center; }
.dash-root .nl-canvasser-chip { display: flex; align-items: center; gap: 9px; padding: 7px 10px; border: 1px solid var(--border-2); border-radius: 12px; background: var(--panel-2); }
.dash-root .nl-canvasser-chip .nl-canvasser-email { margin-left: 2px; }
.dash-root .nl-canvasser-clear { margin-left: auto; display: grid; place-items: center; width: 26px; height: 26px; border: none; border-radius: 8px; background: transparent; color: var(--muted); cursor: pointer; transition: 0.12s; }
.dash-root .nl-canvasser-clear:hover { background: color-mix(in srgb, var(--red) 15%, transparent); color: var(--red); }
/* ========================================================== */
/* Lead Verification — stat tiles + filters + table */
/* ========================================================== */
.dash-root .lv-stats { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-bottom: 18px; }
.dash-root .lv-stat { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; padding: 14px 16px; border-radius: 16px; border: 1px solid var(--border); background: var(--card-grad); box-shadow: var(--card-hi); cursor: pointer; text-align: left; font-family: inherit; transition: 0.15s; position: relative; }
.dash-root .lv-stat:hover { border-color: var(--border-2); transform: translateY(-2px); }
.dash-root .lv-stat.active { border-color: color-mix(in srgb, var(--accent, var(--orange)) 60%, transparent); box-shadow: var(--card-hi), 0 0 0 1px color-mix(in srgb, var(--accent, var(--orange)) 40%, transparent); }
.dash-root .lv-stat-ic { width: 32px; height: 32px; border-radius: 9px; display: grid; place-items: center; margin-bottom: 6px; }
.dash-root .lv-stat-val { font-size: 22px; font-weight: 800; line-height: 1; letter-spacing: -0.02em; }
.dash-root .lv-stat-lbl { font-size: 11.5px; color: var(--muted); font-weight: 600; }
.dash-root .lv-stat.tone-green { --accent: var(--green); } .dash-root .lv-stat.tone-green .lv-stat-ic { background: color-mix(in srgb, var(--green) 16%, transparent); color: var(--green); }
.dash-root .lv-stat.tone-orange { --accent: var(--orange); } .dash-root .lv-stat.tone-orange .lv-stat-ic { background: color-mix(in srgb, var(--orange) 16%, transparent); color: var(--orange); }
.dash-root .lv-stat.tone-blue { --accent: var(--blue); } .dash-root .lv-stat.tone-blue .lv-stat-ic { background: color-mix(in srgb, var(--blue) 18%, transparent); color: #6f9bff; }
.dash-root .lv-stat.tone-purple { --accent: var(--purple); } .dash-root .lv-stat.tone-purple .lv-stat-ic { background: color-mix(in srgb, var(--purple) 18%, transparent); color: #b07bf2; }
.dash-root .lv-stat.tone-red { --accent: var(--red); } .dash-root .lv-stat.tone-red .lv-stat-ic { background: color-mix(in srgb, var(--red) 16%, transparent); color: var(--red); }
.dash-root .lv-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
.dash-root .lv-search { display: flex; align-items: center; gap: 9px; flex: 1 1 240px; min-width: 200px; height: 42px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--panel); color: var(--muted); }
.dash-root .lv-search:focus-within { border-color: color-mix(in srgb, var(--orange) 55%, var(--border)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--orange) 14%, transparent); }
.dash-root .lv-search input { flex: 1; border: 0; background: none; outline: none; color: var(--text); font-family: inherit; font-size: 13.5px; }
.dash-root .lv-search input::placeholder { color: var(--muted); }
.dash-root .lv-search-x { border: 0; background: none; color: var(--muted); cursor: pointer; display: grid; place-items: center; padding: 2px; border-radius: 6px; }
.dash-root .lv-search-x:hover { color: var(--text); background: var(--panel-3); }
.dash-root .lv-filter { height: 42px; flex: 0 0 auto; width: auto; min-width: 150px; }
.dash-root .lv-tablewrap { border: 1px solid var(--border); border-radius: 18px; background: var(--card-grad); box-shadow: var(--card-hi); overflow-x: auto; }
.dash-root .lv-table { width: 100%; border-collapse: collapse; min-width: 940px; }
.dash-root .lv-table thead th { text-align: left; font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); padding: 14px 16px; border-bottom: 1px solid var(--border); white-space: nowrap; }
.dash-root .lv-table tbody td { padding: 13px 16px; border-bottom: 1px solid var(--border); font-size: 12.5px; vertical-align: middle; }
.dash-root .lv-table tbody tr:last-child td { border-bottom: 0; }
.dash-root .lv-table tbody tr { transition: background 0.12s; }
.dash-root .lv-table tbody tr:hover { background: color-mix(in srgb, var(--orange) 5%, transparent); }
.dash-root .lv-id { font-family: ui-monospace, monospace; font-size: 12px; font-weight: 700; color: var(--text); white-space: nowrap; }
.dash-root .lv-id-date { font-size: 10.5px; color: var(--faint, var(--muted)); margin-top: 2px; }
.dash-root .lv-cust { display: flex; align-items: center; gap: 10px; min-width: 210px; }
.dash-root .lv-cust-name { font-weight: 700; font-size: 13px; color: var(--text); }
.dash-root .lv-cust-addr { font-size: 11px; color: var(--muted); margin-top: 1px; }
.dash-root .lv-phone { color: var(--text-2); white-space: nowrap; }
.dash-root .lv-source { display: inline-block; font-size: 11.5px; font-weight: 600; color: var(--text-2); padding: 4px 10px; border-radius: 99px; background: var(--panel-3); white-space: nowrap; }
.dash-root .lv-assignee { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
.dash-root .lv-assignee span { font-weight: 600; color: var(--text-2); font-size: 12px; }
.dash-root .lv-unassigned { font-size: 11.5px; color: var(--faint, var(--muted)); }
.dash-root .lv-verif { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 600; white-space: nowrap; color: var(--muted); }
.dash-root .lv-verif.v-verified { color: var(--green); }
.dash-root .lv-verif.v-in_progress { color: var(--orange); }
.dash-root .lv-verif.v-assigned { color: #6f9bff; }
.dash-root .lv-verif.v-pending { color: #b07bf2; }
.dash-root .lv-verif.v-unverified { color: var(--red); }
.dash-root .lv-created { color: var(--muted); white-space: nowrap; }
.dash-root .lv-rowacts { display: flex; gap: 6px; }
.dash-root .lv-act { width: 32px; height: 32px; border-radius: 9px; border: 1px solid var(--border-2); background: var(--panel-2); color: var(--muted); cursor: pointer; display: grid; place-items: center; transition: 0.14s; }
.dash-root .lv-act:hover { color: var(--text); border-color: var(--muted); }
.dash-root .lv-act.primary:hover { color: var(--green); border-color: color-mix(in srgb, var(--green) 50%, transparent); background: color-mix(in srgb, var(--green) 10%, transparent); }
.dash-root .lv-empty { text-align: center; color: var(--muted); padding: 40px 16px; font-size: 13px; }
.dash-root .lv-count { font-size: 11.5px; color: var(--muted); margin-top: 12px; text-align: right; }
/* ---- row actions dropdown (portalled to body) ---- */
.lv-menu-scrim { position: fixed; inset: 0; z-index: 90; }
.lv-menu { position: fixed; z-index: 91; width: 188px; padding: 6px; border-radius: 12px; border: 1px solid var(--border-2, rgba(255,255,255,0.12)); background: var(--panel, #0e0e13); box-shadow: 0 18px 44px -18px rgba(0,0,0,0.7); animation: ds-rise 0.13s ease; }
.lv-menu-item { display: flex; align-items: center; gap: 9px; width: 100%; padding: 9px 10px; border: 0; border-radius: 9px; background: none; color: var(--text-2, #eaeaea); font-family: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; text-align: left; }
.lv-menu-item:hover { background: var(--panel-3, #1b1b22); color: var(--text, #fff); }
.lv-menu-item svg { color: var(--muted, #8c8c8c); flex: 0 0 auto; }
.lv-menu-item:hover svg { color: var(--orange, #fda913); }
/* ---- 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; }
.dash-root .lv-d-name { font-size: 18px; font-weight: 800; letter-spacing: -0.01em; }
.dash-root .lv-d-pills { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
.dash-root .lv-d-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.dash-root .lv-d-section, .dash-root .lv-d-notes, .dash-root .lv-d-activity { border: 1px solid var(--border); border-radius: 14px; background: var(--panel-2); padding: 14px 15px; }
.dash-root .lv-d-head { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--orange); margin-bottom: 12px; }
.dash-root .lv-d-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; padding: 6px 0; border-bottom: 1px dashed var(--border); }
.dash-root .lv-d-row:last-child { border-bottom: 0; }
.dash-root .lv-d-k { font-size: 12px; color: var(--muted); flex: 0 0 auto; }
.dash-root .lv-d-v { font-size: 12.5px; color: var(--text); font-weight: 600; text-align: right; }
.dash-root .lv-d-notes p { font-size: 12.5px; color: var(--text-2); line-height: 1.55; margin-top: 2px; }
.dash-root .lv-timeline { list-style: none; margin: 0; padding: 0; }
.dash-root .lv-tl-item { position: relative; display: flex; gap: 12px; padding: 0 0 16px 4px; }
.dash-root .lv-tl-item::before { content: ""; position: absolute; left: 8px; top: 14px; bottom: -2px; width: 1.5px; background: var(--border-2); }
.dash-root .lv-tl-item:last-child { padding-bottom: 0; }
.dash-root .lv-tl-item:last-child::before { display: none; }
.dash-root .lv-tl-dot { position: relative; z-index: 1; flex: 0 0 auto; width: 10px; height: 10px; margin-top: 4px; border-radius: 50%; background: var(--orange); box-shadow: 0 0 0 3px color-mix(in srgb, var(--orange) 20%, transparent); }
.dash-root .lv-tl-text { font-size: 12.5px; color: var(--text); font-weight: 600; }
.dash-root .lv-tl-meta { font-size: 11px; color: var(--muted); margin-top: 2px; }
@media (max-width: 900px) {
.dash-root .lv-d-grid { grid-template-columns: 1fr; }
}
@media (max-width: 900px) {
.dash-root .leads-stats { grid-template-columns: repeat(2, 1fr); }
.dash-root .ld-grid { grid-template-columns: 1fr; }
.dash-root .ld-assign { grid-template-columns: 1fr; }
.dash-root .lv-stats { grid-template-columns: repeat(3, 1fr); }
.dash-root .lv-filter { flex: 1 1 45%; }
}
@media (max-width: 560px) {
.dash-root .leads-stats { grid-template-columns: 1fr; }
.dash-root .leads-grid { grid-template-columns: 1fr; }
.dash-root .nl-grid { grid-template-columns: 1fr; }
.dash-root .lv-stats { grid-template-columns: repeat(2, 1fr); }
}
/* ========================================================================= /* =========================================================================
Smart Gallery — the embedded @photo-gallery/sdk surface. Smart Gallery — the embedded @photo-gallery/sdk surface.
The SDK is themed entirely through the token map in lib/gallery-api.ts The SDK is themed entirely through the token map in lib/gallery-api.ts
@@ -1253,3 +1530,25 @@
.dash-root .settings-kv dd { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; } .dash-root .settings-kv dd { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; }
.dash-root .settings-card-actions { display: flex; gap: 8px; align-items: center; margin-top: 2px; } .dash-root .settings-card-actions { display: flex; gap: 8px; align-items: center; margin-top: 2px; }
.dash-root .settings-card-note { font-size: 12px; color: var(--muted); background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; padding: 8px 12px; } .dash-root .settings-card-note { font-size: 12px; color: var(--muted); background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; padding: 8px 12px; }
.dash-root .settings-check { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; color: var(--muted); cursor: pointer; }
/* ---- Global conversation search (topbar) ---- */
.dash-root .gs-wrap { position: relative; }
.dash-root .gs-field { display: flex; align-items: center; gap: 8px; height: 40px; width: 300px; max-width: 42vw; padding: 0 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--panel-2); color: var(--muted); }
.dash-root .gs-field:focus-within { border-color: var(--orange); }
.dash-root .gs-input { flex: 1 1 auto; border: 0; background: none; outline: none; color: var(--text); font-size: 13.5px; }
.dash-root .gs-input::placeholder { color: var(--muted); }
.dash-root .gs-pop { position: absolute; top: calc(100% + 6px); right: 0; width: 420px; max-width: 90vw; max-height: 420px; overflow-y: auto; padding: 6px; border-radius: 14px; border: 1px solid var(--border); background: var(--panel); box-shadow: 0 24px 60px -20px rgba(0,0,0,0.55); z-index: 60; }
.dash-root .gs-empty { padding: 14px; font-size: 13px; color: var(--muted); text-align: center; }
.dash-root .gs-row { display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 11px; border: 0; background: none; border-radius: 10px; cursor: pointer; text-align: left; color: var(--text); }
.dash-root .gs-row:hover { background: var(--panel-2); }
.dash-root .gs-ic { flex: 0 0 auto; width: 28px; height: 28px; display: grid; place-items: center; border-radius: 8px; background: color-mix(in srgb, var(--orange) 14%, transparent); color: var(--orange); }
.dash-root .gs-main { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.dash-root .gs-title { font-size: 13px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dash-root .gs-snippet { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dash-root .gs-snippet em { color: var(--orange); font-style: normal; font-weight: 600; }
.dash-root .gs-surface { flex: 0 0 auto; font-size: 10.5px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: var(--faint); }
@media (max-width: 720px) { .dash-root .gs-field { width: 160px; } }
.dash-root .ds-toast.is-clickable .ds-toast-body { cursor: pointer; }
.dash-root .ds-toast.is-clickable .ds-toast-body:hover .ds-toast-title { color: var(--orange); }
+40 -3
View File
@@ -18,18 +18,49 @@ import { TeamManagement } from "./team-management";
import { MessengerSdk } from "./messenger-sdk"; import { MessengerSdk } from "./messenger-sdk";
import { InboxSdk } from "./inbox-sdk"; import { InboxSdk } from "./inbox-sdk";
import { Settings } from "./settings"; import { Settings } from "./settings";
import { Projects } from "./projects";
import { NotificationCenter } from "./notification-center";
import { RealtimeProvider } from "@/lib/realtime";
import { SmartGallery } from "./smart-gallery"; import { SmartGallery } from "./smart-gallery";
import { Leads } from "./leads";
import { Verify } from "./verify";
import "../../app/dashboard/dashboard.css"; import "../../app/dashboard/dashboard.css";
export function Dashboard() { export function Dashboard() {
const [theme, setTheme] = useState<"dark" | "light">("dark"); const [theme, setTheme] = useState<"dark" | "light">("dark");
const [active, setActive] = useState("dashboard"); const [active, setActive] = useState("dashboard");
// Deep link from global search: which conversation to focus once we switch tabs.
const [deepLink, setDeepLink] = useState<{ surface: "messenger" | "inbox"; threadId: string } | null>(null);
function navigateToConversation(surface: "messenger" | "inbox", threadId: string) {
setActive(surface);
setDeepLink({ surface, threadId });
}
useEffect(() => { useEffect(() => {
// Sync the persisted theme from localStorage (an external system) on mount. // Sync the persisted theme from localStorage (an external system) on mount.
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
try { const t = localStorage.getItem("lup_dash_theme"); if (t === "light" || t === "dark") setTheme(t); } catch {} try { const t = localStorage.getItem("lup_dash_theme"); if (t === "light" || t === "dark") setTheme(t); } catch {}
}, []); }, []);
// Deep-link from a clicked push notification. Two paths from the service worker:
// - a tab was already open → it postMessages { type: 'notif-click', threadId } to focus here
// - no tab was open → it opens /dashboard?thread=<id>, which we read once on mount
useEffect(() => {
try {
const t = new URLSearchParams(window.location.search).get("thread");
if (t) {
navigateToConversation("messenger", t);
window.history.replaceState({}, "", window.location.pathname);
}
} catch {}
if (!("serviceWorker" in navigator)) return;
const onMessage = (e: MessageEvent) => {
if (e.data?.type === "notif-click" && e.data.threadId) navigateToConversation("messenger", e.data.threadId);
};
navigator.serviceWorker.addEventListener("message", onMessage);
return () => navigator.serviceWorker.removeEventListener("message", onMessage);
}, []);
function toggle() { function toggle() {
setTheme((t) => { const n = t === "dark" ? "light" : "dark"; try { localStorage.setItem("lup_dash_theme", n); } catch {} return n; }); setTheme((t) => { const n = t === "dark" ? "light" : "dark"; try { localStorage.setItem("lup_dash_theme", n); } catch {} return n; });
} }
@@ -40,24 +71,30 @@ export function Dashboard() {
return ( return (
<div className="dash-root" data-theme={theme}> <div className="dash-root" data-theme={theme}>
<RealtimeProvider>
<Sidebar active={active} onSelect={setActive} /> <Sidebar active={active} onSelect={setActive} />
<div className="dash-main"> <div className="dash-main">
<Topbar theme={theme} onToggle={toggle} title={title} subtitle={subtitle} /> <Topbar theme={theme} onToggle={toggle} title={title} subtitle={subtitle} onNavigate={navigateToConversation} />
<div className="dash-content"> <div className="dash-content">
<ToastProvider> <ToastProvider>
<NotificationCenter active={active} onNavigate={navigateToConversation} />
{active === "profile" ? <Profile /> {active === "profile" ? <Profile />
: active === "support" ? <Support /> : active === "support" ? <Support />
: active === "rules" ? <Rules /> : active === "rules" ? <Rules />
: active === "ai" ? <AiAssistant /> : active === "ai" ? <AiAssistant />
: active === "messenger" ? <MessengerSdk /> : active === "messenger" ? <MessengerSdk focusThreadId={deepLink?.surface === "messenger" ? deepLink.threadId : null} />
: active === "inbox" ? <InboxSdk /> : active === "inbox" ? <InboxSdk focusThreadId={deepLink?.surface === "inbox" ? deepLink.threadId : null} />
: active === "settings" ? <Settings /> : active === "settings" ? <Settings />
: active === "gallery" ? <SmartGallery theme={theme} /> : active === "gallery" ? <SmartGallery theme={theme} />
: active === "leads" ? <Leads />
: active === "verify" ? <Verify />
: active === "team" ? <TeamManagement /> : active === "team" ? <TeamManagement />
: active === "projects" ? <Projects />
: <ComingSoon title={title} icon={item?.icon ?? "dashboard"} onGo={setActive} />} : <ComingSoon title={title} icon={item?.icon ?? "dashboard"} onGo={setActive} />}
</ToastProvider> </ToastProvider>
</div> </div>
</div> </div>
</RealtimeProvider>
</div> </div>
); );
} }
@@ -0,0 +1,86 @@
"use client";
// Global conversation search in the topbar: type → debounced crm.search → dropdown of hits; click a
// hit to deep-link to exactly where it lives (mail → Inbox, chat → Messenger, on that thread).
import { useEffect, useRef, useState } from "react";
import { Icon } from "./ui";
import { useGlobalSearch, type SearchResult } from "@/lib/search-api";
/** Escape HTML but keep the engine's <em> highlight tags — so a match snippet can't inject markup. */
function safeSnippet(s: string): string {
const esc = s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
return esc.replace(/&lt;em&gt;/g, "<em>").replace(/&lt;\/em&gt;/g, "</em>");
}
export function GlobalSearch({ onNavigate }: { onNavigate: (surface: "messenger" | "inbox", threadId: string) => void }) {
const search = useGlobalSearch();
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const term = q.trim();
if (!term) {
setResults([]);
return;
}
let alive = true;
setLoading(true);
const t = setTimeout(() => {
search(term)
.then((r) => { if (alive) setResults(r); })
.catch(() => { if (alive) setResults([]); })
.finally(() => { if (alive) setLoading(false); });
}, 220);
return () => { alive = false; clearTimeout(t); };
}, [q, search]);
useEffect(() => {
function onDown(e: MouseEvent) {
if (!wrapRef.current?.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, []);
function pick(r: SearchResult) {
onNavigate(r.surface, r.threadId);
setOpen(false);
setQ("");
}
return (
<div className="gs-wrap" ref={wrapRef}>
<div className="gs-field">
<Icon name="search" size={16} />
<input
className="gs-input"
placeholder="Search conversations…"
value={q}
onFocus={() => setOpen(true)}
onChange={(e) => { setQ(e.target.value); setOpen(true); }}
aria-label="Search conversations"
/>
</div>
{open && q.trim() ? (
<div className="gs-pop">
{loading && results.length === 0 ? <div className="gs-empty">Searching</div> : null}
{!loading && results.length === 0 ? <div className="gs-empty">No matches.</div> : null}
{results.map((r) => (
<button key={r.interactionId} type="button" className="gs-row" onClick={() => pick(r)}>
<span className="gs-ic"><Icon name={r.surface === "inbox" ? "mail" : "send"} size={14} /></span>
<span className="gs-main">
<span className="gs-title">{r.title}</span>
<span className="gs-snippet" dangerouslySetInnerHTML={{ __html: safeSnippet(r.snippet) }} />
</span>
<span className="gs-surface">{r.surface === "inbox" ? "Mail" : "Chat"}</span>
</button>
))}
</div>
) : null}
</div>
);
}
+6 -6
View File
@@ -15,7 +15,7 @@ import type { DataDoor } from "@/lib/crm-messaging-adapter";
const SHELL = isShellConfigured(); const SHELL = isShellConfigured();
export function InboxSdk() { export function InboxSdk({ focusThreadId }: { focusThreadId?: string | null } = {}) {
return ( return (
<div className="view"> <div className="view">
{!SHELL && ( {!SHELL && (
@@ -23,26 +23,26 @@ export function InboxSdk() {
Demo mode running on the SDK&apos;s mock inbox adapter. Demo mode running on the SDK&apos;s mock inbox adapter.
</div> </div>
)} )}
<div className="miu-host miu-host-inbox">{SHELL ? <LiveInbox /> : <DemoInbox />}</div> <div className="miu-host miu-host-inbox">{SHELL ? <LiveInbox focusThreadId={focusThreadId} /> : <DemoInbox focusThreadId={focusThreadId} />}</div>
</div> </div>
); );
} }
function DemoInbox() { function DemoInbox({ focusThreadId }: { focusThreadId?: string | null }) {
const adapter = useMemo<InboxAdapter>(() => new MockInboxAdapter(), []); const adapter = useMemo<InboxAdapter>(() => new MockInboxAdapter(), []);
return ( return (
<InboxProvider adapter={adapter}> <InboxProvider adapter={adapter}>
<SdkInbox /> <SdkInbox focusThreadId={focusThreadId} />
</InboxProvider> </InboxProvider>
); );
} }
function LiveInbox() { function LiveInbox({ focusThreadId }: { focusThreadId?: string | null }) {
const { sdk } = useAppShell(); const { sdk } = useAppShell();
const adapter = useMemo<InboxAdapter>(() => new CrmInboxAdapter(sdk as unknown as DataDoor), [sdk]); const adapter = useMemo<InboxAdapter>(() => new CrmInboxAdapter(sdk as unknown as DataDoor), [sdk]);
return ( return (
<InboxProvider adapter={adapter}> <InboxProvider adapter={adapter}>
<SdkInbox /> <SdkInbox focusThreadId={focusThreadId} />
</InboxProvider> </InboxProvider>
); );
} }
+220
View File
@@ -0,0 +1,220 @@
// ============================================================
// LynkedUp Pro — Leads mock data.
// A storm-restoration roofing pipeline: door-knocked leads in
// the Plano, TX hail zone. Each lead carries a rich detail
// record (contact, property, job, insurance, assignment) that
// powers the lead-detail popup. All client-side so the screen
// is fully interactive without a backend.
// ============================================================
export type LeadStatus = "new" | "contacted" | "appointed" | "closed";
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 };
export type Lead = {
id: string; // SAL-001
initials: string;
name: string;
gradient: string;
priority: LeadPriority;
status: LeadStatus;
tag: string; // "Storm Zone"
updated: string; // "3d ago"
setter: string; // compact chip name
// storm banner
storm: { zone: string; date: string; detail: string };
// contact
phones: Phone[];
emails: Email[];
// property
property: { address: string; city: string; state: string; zip: string; type: string };
// job details
job: {
source: string; leadType: string; workType: string; tradeType: string;
urgency: string; canvasser: string; notes: string;
};
// insurance
insurance: {
company: string; claimStatus: string; claimNumber: string;
policyNumber: string; adjusterName: string; adjusterPhone: string;
};
// assignment
assignment: {
assignedTo: string; priority: string; followUp: string;
createdBy: string; createdAt: string;
};
};
const G = {
orange: "linear-gradient(135deg,#fda913,#fd6d13)",
blue: "linear-gradient(135deg,#4f8cff,#2c5cff)",
purple: "linear-gradient(135deg,#b07bf2,#7b53e0)",
green: "linear-gradient(135deg,#33c98a,#1fa46c)",
cyan: "linear-gradient(135deg,#34c9d6,#1f9aa4)",
};
export const LEADS: Lead[] = [
{
id: "SAL-001", initials: "JM", name: "John Martinez", gradient: G.orange,
priority: "high", status: "contacted", tag: "Storm Zone", updated: "3d ago", setter: "Cody",
storm: { zone: "E Plano / Spring Creek Pkwy", date: "2026-04-28", detail: '2.5" hail (severe)' },
phones: [
{ number: "(469) 500-1000", type: "Mobile", primary: true },
{ number: "(214) 600-2000", type: "Home" },
],
emails: [{ address: "john.martinez@gmail.com", primary: true }],
property: { address: "4821 Spring Creek Pkwy", city: "Plano", state: "TX", zip: "75023", type: "Single Family" },
job: {
source: "Door Knock", leadType: "Insurance", workType: "Roof Replacement", tradeType: "Roofing",
urgency: "High", canvasser: "Cody Tatum",
notes: "Homeowner showed significant granule loss on south-facing slopes; agreed to inspection.",
},
insurance: {
company: "State Farm", claimStatus: "Filed", claimNumber: "CLM-2026-1000",
policyNumber: "POL-080000", adjusterName: "Marcus Powell", adjusterPhone: "(972) 700-3000",
},
assignment: {
assignedTo: "Jesus Gonzales", priority: "High", followUp: "Jun 4, 2026",
createdBy: "Cody Tatum", createdAt: "May 28, 2026",
},
},
{
id: "SAL-002", initials: "SK", name: "Sarah Kim", gradient: G.purple,
priority: "high", status: "appointed", tag: "Storm Zone", updated: "2d ago", setter: "Shelby",
storm: { zone: "E Plano / Custer Rd", date: "2026-04-28", detail: '2.5" hail (severe)' },
phones: [
{ number: "(972) 501-1037", type: "Mobile", primary: true },
{ number: "(214) 601-2044", type: "Home" },
],
emails: [{ address: "sarah.kim@outlook.com", primary: true }],
property: { address: "4905 Custer Rd", city: "Plano", state: "TX", zip: "75023", type: "Single Family" },
job: {
source: "Door Knock", leadType: "Insurance", workType: "Roof Replacement", tradeType: "Roofing",
urgency: "High", canvasser: "Hannah Reyes",
notes: "Visible mat exposure on rear elevation. Appointment set for adjuster meet.",
},
insurance: {
company: "Allstate", claimStatus: "Approved", claimNumber: "CLM-2026-1037",
policyNumber: "POL-081037", adjusterName: "Dana Whitfield", adjusterPhone: "(972) 700-3037",
},
assignment: {
assignedTo: "Hannah Reyes", priority: "High", followUp: "Jun 6, 2026",
createdBy: "Shelby Greer", createdAt: "May 29, 2026",
},
},
{
id: "SAL-003", initials: "RC", name: "Robert Chen", gradient: G.green,
priority: "high", status: "closed", tag: "Storm Zone", updated: "2d ago", setter: "Dalton",
storm: { zone: "E Plano / Independence Pkwy", date: "2026-04-28", detail: '2.5" hail (severe)' },
phones: [
{ number: "(469) 502-1074", type: "Mobile", primary: true },
],
emails: [{ address: "robert.chen@gmail.com", primary: true }],
property: { address: "5012 Independence Pkwy", city: "Plano", state: "TX", zip: "75023", type: "Single Family" },
job: {
source: "Door Knock", leadType: "Insurance", workType: "Roof Replacement", tradeType: "Roofing",
urgency: "High", canvasser: "Travis Boone",
notes: "Full replacement approved and installed. Final invoice cleared.",
},
insurance: {
company: "Farmers", claimStatus: "Paid", claimNumber: "CLM-2026-1074",
policyNumber: "POL-081074", adjusterName: "Leah Ortiz", adjusterPhone: "(972) 700-3074",
},
assignment: {
assignedTo: "Travis Boone", priority: "High", followUp: "—",
createdBy: "Dalton Pruitt", createdAt: "May 20, 2026",
},
},
{
id: "SAL-004", initials: "MG", name: "Maria Garcia", gradient: G.cyan,
priority: "high", status: "closed", tag: "Storm Zone", updated: "2d ago", setter: "Hannah",
storm: { zone: "E Plano / Alma Dr", date: "2026-04-28", detail: '2.5" hail (severe)' },
phones: [
{ number: "(972) 503-1111", type: "Mobile", primary: true },
],
emails: [{ address: "maria.garcia@gmail.com", primary: true }],
property: { address: "4720 Alma Dr", city: "Plano", state: "TX", zip: "75023", type: "Single Family" },
job: {
source: "Door Knock", leadType: "Insurance", workType: "Roof Replacement", tradeType: "Roofing",
urgency: "High", canvasser: "Shelby Greer",
notes: "Signed contract; build complete. Awaiting review request.",
},
insurance: {
company: "USAA", claimStatus: "Paid", claimNumber: "CLM-2026-1111",
policyNumber: "POL-081111", adjusterName: "Grant Mueller", adjusterPhone: "(972) 700-3111",
},
assignment: {
assignedTo: "Shelby Greer", priority: "High", followUp: "—",
createdBy: "Hannah Reyes", createdAt: "May 18, 2026",
},
},
{
id: "SAL-005", initials: "DT", name: "David Thompson", gradient: G.blue,
priority: "high", status: "appointed", tag: "Storm Zone", updated: "1d ago", setter: "Travis",
storm: { zone: "E Plano / Spring Creek Pkwy", date: "2026-04-28", detail: '2.5" hail (severe)' },
phones: [
{ number: "(469) 504-1148", type: "Mobile", primary: true },
{ number: "(214) 604-2148", type: "Work" },
],
emails: [{ address: "david.thompson@gmail.com", primary: true }],
property: { address: "5130 Spring Creek Pkwy", city: "Plano", state: "TX", zip: "75023", type: "Single Family" },
job: {
source: "Door Knock", leadType: "Insurance", workType: "Roof Replacement", tradeType: "Roofing",
urgency: "High", canvasser: "Dalton Pruitt",
notes: "Adjuster appointment confirmed for next week. Bring hail map + photos.",
},
insurance: {
company: "Liberty Mutual", claimStatus: "Filed", claimNumber: "CLM-2026-1148",
policyNumber: "POL-081148", adjusterName: "Priya Nair", adjusterPhone: "(972) 700-3148",
},
assignment: {
assignedTo: "Dalton Pruitt", priority: "High", followUp: "Jun 9, 2026",
createdBy: "Travis Boone", createdAt: "May 30, 2026",
},
},
];
// Header stat — the full book is larger than the loaded page.
export const TOTAL_LEADS = 35;
export const STATUS_META: Record<LeadStatus, { label: string; tone: string }> = {
new: { label: "New", tone: "blue" },
contacted: { label: "Contacted", tone: "orange" },
appointed: { label: "Appointed", tone: "purple" },
closed: { label: "Closed", tone: "green" },
};
export const PRIORITY_META: Record<LeadPriority, { label: string; tone: string }> = {
high: { label: "High", tone: "red" },
medium: { label: "Medium", tone: "orange" },
low: { label: "Low", tone: "muted" },
};
/* ---------------------------------------------------------- */
/* Reps + option lists — power the New Lead form */
/* ---------------------------------------------------------- */
export type Rep = { id: string; initials: string; name: string; email: string };
export const REPS: Rep[] = [
{ id: "LUP-1040", initials: "CT", name: "Cody Tatum", email: "cody.tatum@lynkeduppro.com" },
{ id: "LUP-1041", initials: "HR", name: "Hannah Reyes", email: "hannah.reyes@lynkeduppro.com" },
{ id: "LUP-1042", initials: "TB", name: "Travis Boone", email: "travis.boone@lynkeduppro.com" },
{ id: "LUP-1043", initials: "SG", name: "Shelby Greer", email: "shelby.greer@lynkeduppro.com" },
{ id: "LUP-1044", initials: "DP", name: "Dalton Pruitt", email: "dalton.pruitt@lynkeduppro.com" },
];
export const LEAD_SOURCES = ["Door Knock", "Referral", "Storm Chase", "Mailer / Postcard", "Sign Call", "Insurance Agent Referral", "Repeat Customer", "Social Media", "Other"];
export const LEAD_TYPES = ["Insurance", "Retail"];
export const WORK_TYPES = ["Roof Replacement", "Roof Repair", "Inspection", "Gutter Install"];
export const TRADE_TYPES = ["Roofing", "Gutters", "Siding", "Windows"];
export const PROPERTY_TYPES = ["Single Family", "Multi Family", "Commercial"];
export const CLAIM_STATUSES = ["Not Filed", "Filed", "Approved", "Paid", "Denied"];
+615
View File
@@ -0,0 +1,615 @@
"use client";
// ============================================================
// Leads — storm-restoration pipeline board.
// · Hero head : storm banner + headline stats (by status)
// · Toolbar : search + status filter tabs
// · Board : lead cards (avatar, priority ring, status,
// address, phone, source, rep, updated)
// · Detail : click a lead → rich popup with Contact,
// Property, Job Details, Insurance, Assignment
// Data comes from leads-data.ts (client-side mock).
// ============================================================
import { useMemo, useState, type ReactNode } from "react";
import { Avatar, Btn, Field, Icon, Modal, PageHead, Pill, Segmented, SegTabs, useToast } from "./ui";
import {
LEADS, TOTAL_LEADS, STATUS_META, PRIORITY_META,
REPS, LEAD_SOURCES, WORK_TYPES, TRADE_TYPES, CLAIM_STATUSES,
type Lead, type LeadStatus,
} from "./leads-data";
const STATUS_TABS: { value: "all" | LeadStatus; label: string }[] = [
{ value: "all", label: "All" },
{ value: "new", label: "New" },
{ value: "contacted", label: "Contacted" },
{ value: "appointed", label: "Appointed" },
{ value: "closed", label: "Closed" },
];
export function Leads() {
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);
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) => {
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));
});
}, [query, filter]);
return (
<div className="view leads">
<PageHead
eyebrow="Sales"
title="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_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 ------------------------------------------- */}
<div className="leads-toolbar">
<div className="leads-search">
<Icon name="search" size={16} />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by name, address, source…"
aria-label="Search leads"
/>
{query && <button className="leads-search-x" aria-label="Clear" onClick={() => setQuery("")}><Icon name="x" size={14} /></button>}
</div>
<div className="leads-tabs" role="tablist">
{STATUS_TABS.map((t) => (
<button
key={t.value}
role="tab"
aria-selected={filter === t.value}
className={`leads-tab ${filter === t.value ? "active" : ""}`}
onClick={() => setFilter(t.value)}
>
{t.label}
</button>
))}
</div>
</div>
{/* ---- board --------------------------------------------- */}
{filtered.length === 0 ? (
<div className="card leads-empty">
<Icon name="search" size={30} />
<h3>No leads match</h3>
<p>Try a different search or clear the status filter.</p>
</div>
) : (
<div className="leads-grid">
{filtered.map((l) => (
<LeadCard key={l.id} lead={l} onOpen={() => setSelected(l)} />
))}
</div>
)}
<LeadDetail lead={selected} onClose={() => setSelected(null)} />
<NewLead open={newOpen} onClose={() => setNewOpen(false)} />
</div>
);
}
/* ---------------------------------------------------------- */
/* Stat card */
/* ---------------------------------------------------------- */
function StatCard({ label, value, icon, tone }: { label: string; value: number; icon: string; tone: string }) {
return (
<div className={`leads-stat tone-${tone}`}>
<span className="leads-stat-ic"><Icon name={icon} size={17} /></span>
<div className="leads-stat-body">
<div className="leads-stat-val">{value}</div>
<div className="leads-stat-lbl">{label}</div>
</div>
</div>
);
}
/* ---------------------------------------------------------- */
/* Lead card */
/* ---------------------------------------------------------- */
function LeadCard({ lead, onOpen }: { lead: Lead; onOpen: () => void }) {
const status = STATUS_META[lead.status];
const priority = PRIORITY_META[lead.priority];
const primaryPhone = lead.phones.find((p) => p.primary) ?? lead.phones[0];
return (
<button className="lead-card" onClick={onOpen}>
<div className="lead-card-top">
<span className={`lead-ava prio-${lead.priority}`}>
<Avatar initials={lead.initials} gradient={lead.gradient} size={46} />
</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> · <Icon name="storm" size={12} /> {lead.tag}</div>
</div>
<Pill tone={priority.tone}><Icon name="alert" size={11} /> {priority.label}</Pill>
</div>
<div className="lead-card-row"><Icon name="pin" size={14} /><span>{lead.property.address}, {lead.property.city}, {lead.property.state}</span></div>
<div className="lead-card-row"><Icon name="phone" size={14} /><span>{primaryPhone?.number}</span></div>
<div className="lead-card-foot">
<Pill tone={status.tone}>{status.label}</Pill>
<span className="lead-source"><Icon name="pin" size={12} /> {lead.job.source}</span>
<span className="lead-card-spacer" />
<span className="lead-rep" title={`Canvasser: ${lead.job.canvasser}`}>{lead.job.canvasser}</span>
<span className="lead-updated">{lead.updated}</span>
</div>
</button>
);
}
/* ---------------------------------------------------------- */
/* Lead detail popup */
/* ---------------------------------------------------------- */
function LeadDetail({ lead, onClose }: { lead: Lead | null; onClose: () => void }) {
if (!lead) return null;
const status = STATUS_META[lead.status];
const priority = PRIORITY_META[lead.priority];
return (
<Modal
open={!!lead}
onClose={onClose}
size="lg"
title={lead.name}
subtitle={`${lead.id} · ${lead.tag}`}
icon="leads"
footer={
<>
<Btn variant="ghost" icon="phone">Call</Btn>
<Btn variant="outline" icon="mail">Email</Btn>
<Btn icon="check-circle">Update Status</Btn>
</>
}
>
<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 */}
<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>
);
}
function Section({ title, icon, children, wide }: { title: string; icon: string; children: ReactNode; wide?: boolean }) {
return (
<div className={`ld-section ${wide ? "wide" : ""}`}>
<div className="ld-section-head"><Icon name={icon} size={15} /> {title}</div>
<div className="ld-section-body">{children}</div>
</div>
);
}
function Dl({ label, value }: { label: string; value: string }) {
return (
<div className="ld-dl">
<span className="ld-dl-k">{label}</span>
<span className="ld-dl-v">{value}</span>
</div>
);
}
/* ---------------------------------------------------------- */
/* New Lead — Quick / Full Form intake */
/* ---------------------------------------------------------- */
type Priority = "Low" | "Medium" | "High";
type Urgency = "Standard" | "High" | "Emergency";
type PhoneRow = { number: string; type: string };
type EmailRow = { address: string };
const FULL_STEPS = [
{ value: "contact", label: "Contact", icon: "user" },
{ value: "property", label: "Property", icon: "owners" },
{ value: "job", label: "Job Details", icon: "projects" },
{ value: "insurance", label: "Insurance", icon: "shield" },
{ value: "assignment", label: "Assignment", icon: "team" },
];
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 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 }: { 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 set = (k: string) => (e: { target: { value: string } }) =>
setF((s) => ({ ...s, [k]: e.target.value }) as typeof BLANK);
// multi-value handlers
const addPhone = () => setF((s) => ({ ...s, phones: [...s.phones, { number: "", type: "Mobile" }] }));
const setPhone = (i: number, key: "number" | "type", v: string) => setF((s) => ({ ...s, phones: s.phones.map((p, j) => (j === i ? { ...p, [key]: v } : p)) }));
const removePhone = (i: number) => setF((s) => ({ ...s, phones: s.phones.filter((_, j) => j !== i) }));
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 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 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; }
toast.push({ tone: "success", title: "Lead created", desc: `${name} added to the Plano pipeline.` });
close();
}
const repOptions = [{ id: "", initials: "—", name: "Unassigned" }, ...REPS];
const stepIdx = FULL_STEPS.findIndex((s) => s.value === section);
const isFirstStep = stepIdx <= 0;
const isLastStep = stepIdx === FULL_STEPS.length - 1;
const goNext = () => { if (!isLastStep) setSection(FULL_STEPS[stepIdx + 1].value); };
const goBack = () => { if (!isFirstStep) setSection(FULL_STEPS[stepIdx - 1].value); };
return (
<Modal
open={open}
onClose={close}
size="lg"
title="New Lead"
subtitle="Full lead profile with insurance and assignment details."
icon="plus"
footer={
mode === "full" ? (
<>
<Btn variant="ghost" onClick={close}>Cancel</Btn>
{!isFirstStep && <Btn variant="ghost" onClick={goBack}>Back</Btn>}
{isLastStep
? <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}>Create Lead</Btn>
</>
)
}
>
<div className="nl-form">
<Segmented
value={mode}
onChange={(v) => setMode(v as "quick" | "full")}
options={[{ value: "quick", label: "Quick", icon: "star" }, { value: "full", label: "Full Form", icon: "edit" }]}
/>
{mode === "quick" ? (
<div className="nl-grid">
<Field label="First name" required><input className="ds-input" value={f.firstName} onChange={set("firstName")} placeholder="John" /></Field>
<Field label="Last name"><input className="ds-input" value={f.lastName} onChange={set("lastName")} placeholder="Smith" /></Field>
<Field label="Phone"><input className="ds-input" value={f.phones[0]?.number ?? ""} onChange={(e) => setPhone(0, "number", e.target.value)} placeholder="(555) 000-0000" /></Field>
<div className="nl-full"><Field label="Street address"><input className="ds-input" value={f.address} onChange={set("address")} placeholder="123 Main St" /></Field></div>
<Field label="City"><input className="ds-input" value={f.city} onChange={set("city")} placeholder="Plano" /></Field>
<Field label="State"><input className="ds-input" value={f.state} onChange={set("state")} /></Field>
<Field label="ZIP"><input className="ds-input" value={f.zip} onChange={set("zip")} placeholder="75023" /></Field>
<Field label="Lead source"><Select value={f.source} onChange={set("source")} options={LEAD_SOURCES} placeholder="How did you find this lead?" /></Field>
{f.source === "Referral" && (
<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"><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>
</div>
) : (
<>
<SegTabs
value={section}
onChange={setSection}
tabs={FULL_STEPS}
/>
{section === "contact" && (
<div className="nl-grid">
<Field label="First Name" required><input className="ds-input" value={f.firstName} onChange={set("firstName")} placeholder="John" /></Field>
<Field label="Last Name"><input className="ds-input" value={f.lastName} onChange={set("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>
<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>
)}
{section === "property" && (
<div className="nl-grid">
<div className="nl-full"><Field label="Street Address"><input className="ds-input" value={f.address} onChange={set("address")} placeholder="123 Main St" /></Field></div>
<Field label="City"><input className="ds-input" value={f.city} onChange={set("city")} placeholder="Plano" /></Field>
<Field label="State"><input className="ds-input" value={f.state} onChange={set("state")} placeholder="TX" /></Field>
<Field label="ZIP"><input className="ds-input" value={f.zip} onChange={set("zip")} placeholder="75023" /></Field>
<Field label="Property Type"><Select value={f.propertyType} onChange={set("propertyType")} options={PROPERTY_TYPE_OPTS} placeholder="Residential, Commercial…" /></Field>
<div className="nl-full">
<div className="ds-field-lbl">Site Photos</div>
<button type="button" className="nl-photos" onClick={addPhoto}>
<Icon name="camera" size={22} />
<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={i} className="nl-photo-chip"><Icon name="check" size={12} /> {p}</span>)}
</div>
)}
</div>
</div>
)}
{section === "job" && (
<div className="nl-grid">
<Field label="Lead Source"><Select value={f.source} onChange={set("source")} options={LEAD_SOURCES} placeholder="How did you find this lead?" /></Field>
<Field label="Lead Type"><Select value={f.leadType} onChange={set("leadType")} options={LEAD_TYPE_OPTS} placeholder="Residential, Commercial…" /></Field>
<Field label="Work Type"><Select value={f.workType} onChange={set("workType")} options={WORK_TYPES} placeholder="Roof Replacement, Repair…" /></Field>
<Field label="Trade Type"><Select value={f.tradeType} onChange={set("tradeType")} options={TRADE_TYPES} placeholder="Roofing, Gutter, Siding…" /></Field>
<div className="nl-full"><UrgencyPicker value={f.urgency} onChange={(u) => setF((s) => ({ ...s, urgency: u }))} /></div>
<div className="nl-full"><Field label="Notes"><textarea className="ds-textarea" rows={3} value={f.notes} onChange={set("notes")} placeholder="First impression, visible damage, special circumstances…" /></Field></div>
</div>
)}
{section === "insurance" && (
<div className="nl-grid">
<div className="nl-full"><Field label="Insurance Company"><input className="ds-input" value={f.insCompany} onChange={set("insCompany")} placeholder="State Farm" /></Field></div>
<Field label="Claim Number"><input className="ds-input" value={f.claimNumber} onChange={set("claimNumber")} placeholder="e.g. CLM-2026-00482" /></Field>
<Field label="Claim Status"><Select value={f.claimStatus} onChange={set("claimStatus")} options={CLAIM_STATUSES} placeholder="Select status…" /></Field>
<Field label="Adjuster Name"><input className="ds-input" value={f.adjusterName} onChange={set("adjusterName")} placeholder="Full name" /></Field>
<Field label="Adjuster Phone"><input className="ds-input" value={f.adjusterPhone} onChange={set("adjusterPhone")} placeholder="(555) 000-0000" /></Field>
<div className="nl-full"><Field label="Policy Number"><input className="ds-input" value={f.policyNumber} onChange={set("policyNumber")} placeholder="e.g. POL-7734892-A" /></Field></div>
</div>
)}
{section === "assignment" && (
<div className="nl-grid">
<div className="nl-full"><Field label="Assign Rep"><RepSelect value={f.assignRep} onChange={set("assignRep")} options={repOptions} /></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>
</div>
)}
</>
)}
</div>
</Modal>
);
}
function Select({ value, onChange, options, placeholder }: { value: string; onChange: (e: { target: { value: string } }) => void; options: string[]; placeholder?: string }) {
return (
<select className={`ds-select ${!value && placeholder ? "is-placeholder" : ""}`} value={value} onChange={onChange}>
{placeholder && <option value="" disabled>{placeholder}</option>}
{options.map((o) => <option key={o} value={o}>{o}</option>)}
</select>
);
}
function RepSelect({ value, onChange, options }: { value: string; onChange: (e: { target: { value: string } }) => void; options: { id: string; initials: string; name: string }[] }) {
return (
<select className="ds-select" value={value} onChange={onChange}>
{options.map((r) => <option key={r.id || "none"} value={r.id}>{r.id ? `${r.name} · ${r.id}` : "— Unassigned"}</option>)}
</select>
);
}
function CanvasserSearch({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: { id: string; initials: string; name: string; email: string }[] }) {
const [query, setQuery] = useState("");
const [open, setOpen] = useState(false);
const selected = options.find((o) => o.id === value);
const matches = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return options;
return options.filter((o) => o.name.toLowerCase().includes(q) || o.email.toLowerCase().includes(q));
}, [query, options]);
if (selected) {
return (
<div className="nl-canvasser-chip">
<Avatar initials={selected.initials} size={28} />
<span className="nl-canvasser-name">{selected.name}</span>
<span className="nl-canvasser-email">{selected.email}</span>
<button type="button" className="nl-canvasser-clear" onClick={() => { onChange(""); setQuery(""); }} aria-label="Clear canvasser"><Icon name="x" size={14} /></button>
</div>
);
}
return (
<div className="nl-canvasser">
<input
className="ds-input"
value={query}
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(() => setOpen(false), 150)}
placeholder="Search canvasser by name or email"
/>
{open && matches.length > 0 && (
<div className="nl-canvasser-menu">
{matches.map((o) => (
<button key={o.id} type="button" className="nl-canvasser-opt" onMouseDown={(e) => { e.preventDefault(); onChange(o.id); setQuery(""); setOpen(false); }}>
<Avatar initials={o.initials} size={26} />
<span className="nl-canvasser-name">{o.name}</span>
<span className="nl-canvasser-email">{o.email}</span>
</button>
))}
</div>
)}
{open && matches.length === 0 && (
<div className="nl-canvasser-menu"><div className="nl-canvasser-empty">No canvasser found</div></div>
)}
</div>
);
}
function PriorityPicker({ value, onChange }: { value: Priority; onChange: (p: Priority) => void }) {
return (
<div className="ds-field">
<span className="ds-field-lbl">Priority</span>
<div className="nl-prio">
{(["Low", "Medium", "High"] as Priority[]).map((p) => (
<button key={p} type="button" className={`nl-prio-btn ${value === p ? `active ${p.toLowerCase()}` : ""}`} onClick={() => onChange(p)}>{p}</button>
))}
</div>
</div>
);
}
function UrgencyPicker({ value, onChange }: { value: Urgency; onChange: (u: Urgency) => void }) {
return (
<div className="ds-field">
<span className="ds-field-lbl">Urgency</span>
<div className="nl-prio">
{(["Standard", "High", "Emergency"] as Urgency[]).map((u) => (
<button key={u} type="button" className={`nl-prio-btn urg ${value === u ? `active ${u.toLowerCase()}` : ""}`} onClick={() => onChange(u)}>{u}</button>
))}
</div>
</div>
);
}
+10 -31
View File
@@ -5,39 +5,18 @@
// UI + messaging logic lives in the SDK. Live path = the be-crm data door (CrmMessagingAdapter); // UI + messaging logic lives in the SDK. Live path = the be-crm data door (CrmMessagingAdapter);
// demo path = the SDK's own MockAdapter. // demo path = the SDK's own MockAdapter.
import { useEffect, useMemo, useState } from "react"; import { useMemo } from "react";
import { useAppShell, useAuth, useQuery } from "@abe-kap/appshell-sdk/react"; import { useAppShell, useAuth } from "@abe-kap/appshell-sdk/react";
import { MessageSocket } from "@insignia/iios-kernel-client";
import { MessagingProvider, Messenger as SdkMessenger, type MessagingAdapter } from "@insignia/iios-messaging-ui"; import { MessagingProvider, Messenger as SdkMessenger, type MessagingAdapter } from "@insignia/iios-messaging-ui";
import { MockAdapter } from "@insignia/iios-messaging-ui/adapters/mock"; import { MockAdapter } from "@insignia/iios-messaging-ui/adapters/mock";
import "@insignia/iios-messaging-ui/styles.css"; import "@insignia/iios-messaging-ui/styles.css";
import { isShellConfigured } from "@/lib/appshell"; import { isShellConfigured } from "@/lib/appshell";
import { useRealtime } from "@/lib/realtime";
import { CrmMessagingAdapter, type DataDoor } from "@/lib/crm-messaging-adapter"; import { CrmMessagingAdapter, type DataDoor } from "@/lib/crm-messaging-adapter";
interface RealtimeDTO { url: string; audience: string; token?: string }
/** Open the IIOS message socket with the delegated token the BFF mints (crm.messenger.realtime). */
function useRealtimeSocket(): MessageSocket | null {
const rt = useQuery<RealtimeDTO>("crm.messenger.realtime", {});
const [socket, setSocket] = useState<MessageSocket | null>(null);
const url = rt.data?.url;
const token = rt.data?.token;
useEffect(() => {
if (!url || !token) return;
const s = new MessageSocket({ serviceUrl: url, token, autoConnect: false });
s.connect();
setSocket(s);
return () => {
s.disconnect();
setSocket(null);
};
}, [url, token]);
return socket;
}
const SHELL = isShellConfigured(); const SHELL = isShellConfigured();
export function MessengerSdk() { export function MessengerSdk({ focusThreadId }: { focusThreadId?: string | null } = {}) {
return ( return (
<div className="view"> <div className="view">
{!SHELL && ( {!SHELL && (
@@ -55,26 +34,26 @@ export function MessengerSdk() {
Demo mode running on the SDK&apos;s mock adapter. Demo mode running on the SDK&apos;s mock adapter.
</div> </div>
)} )}
<div className="miu-host">{SHELL ? <LiveHost /> : <DemoHost />}</div> <div className="miu-host">{SHELL ? <LiveHost focusThreadId={focusThreadId} /> : <DemoHost focusThreadId={focusThreadId} />}</div>
</div> </div>
); );
} }
// SHELL is a build-time constant, so exactly one of these mounts for the life of the app // SHELL is a build-time constant, so exactly one of these mounts for the life of the app
// (Rules-of-Hooks safe — the other branch never renders). // (Rules-of-Hooks safe — the other branch never renders).
function DemoHost() { function DemoHost({ focusThreadId }: { focusThreadId?: string | null }) {
const adapter = useMemo<MessagingAdapter>(() => new MockAdapter(), []); const adapter = useMemo<MessagingAdapter>(() => new MockAdapter(), []);
return ( return (
<MessagingProvider adapter={adapter}> <MessagingProvider adapter={adapter}>
<SdkMessenger /> <SdkMessenger focusThreadId={focusThreadId} />
</MessagingProvider> </MessagingProvider>
); );
} }
function LiveHost() { function LiveHost({ focusThreadId }: { focusThreadId?: string | null }) {
const { sdk } = useAppShell(); const { sdk } = useAppShell();
const { user } = useAuth(); const { user } = useAuth();
const socket = useRealtimeSocket(); const socket = useRealtime();
// Rebuilds once the socket connects: the first adapter (no socket) polls; the second runs live. // Rebuilds once the socket connects: the first adapter (no socket) polls; the second runs live.
const adapter = useMemo<MessagingAdapter | null>( const adapter = useMemo<MessagingAdapter | null>(
() => (user?.id ? new CrmMessagingAdapter(sdk as unknown as DataDoor, user.id, socket ?? undefined) : null), () => (user?.id ? new CrmMessagingAdapter(sdk as unknown as DataDoor, user.id, socket ?? undefined) : null),
@@ -83,7 +62,7 @@ function LiveHost() {
if (!adapter) return <div className="miu-empty">Loading</div>; if (!adapter) return <div className="miu-empty">Loading</div>;
return ( return (
<MessagingProvider adapter={adapter}> <MessagingProvider adapter={adapter}>
<SdkMessenger /> <SdkMessenger focusThreadId={focusThreadId} />
</MessagingProvider> </MessagingProvider>
); );
} }
@@ -0,0 +1,74 @@
"use client";
// Topbar bell → offline-notification control. Click opens a small popover to enable/disable Web Push
// for this browser. The dot is lit when this browser is subscribed. Hidden entirely when push isn't
// available (demo mode, or a browser without ServiceWorker/PushManager).
import { useState } from "react";
import { Icon } from "./ui";
import { usePushNotifications } from "@/lib/push-notifications";
export function NotificationBell() {
const push = usePushNotifications();
const [open, setOpen] = useState(false);
if (!push.supported) return null;
const denied = push.permission === "denied";
return (
<div style={{ position: "relative" }}>
<button
className="ic-btn"
aria-label="Notifications"
aria-haspopup="menu"
aria-expanded={open}
style={{ position: "relative" }}
onClick={() => setOpen((o) => !o)}
>
<Icon name="bell" size={18} />
<span
style={{
position: "absolute",
top: 9,
right: 10,
width: 7,
height: 7,
borderRadius: 99,
background: push.subscribed ? "var(--orange)" : "var(--border)",
border: "2px solid var(--panel)",
}}
/>
</button>
{open && (
<>
<div className="tm-menu-scrim" onClick={() => setOpen(false)} />
<div className="tm-menu-pop" role="menu" style={{ width: 260, padding: 14 }}>
<div style={{ fontWeight: 700, fontSize: 13, marginBottom: 4 }}>Offline notifications</div>
<p style={{ fontSize: 12, color: "var(--muted)", margin: "0 0 12px", lineHeight: 1.4 }}>
{push.subscribed
? "You'll get push notifications for new direct messages and mentions, even when this tab is closed."
: "Get notified about direct messages and mentions when the CRM isn't open."}
</p>
{denied ? (
<p style={{ fontSize: 12, color: "var(--danger, #c0392b)", margin: 0 }}>
Notifications are blocked in your browser settings. Allow them for this site, then try again.
</p>
) : push.subscribed ? (
<button className="ds-btn v-ghost full" disabled={push.busy} onClick={() => push.disable()}>
{push.busy ? "Turning off…" : "Turn off notifications"}
</button>
) : (
<button className="ds-btn v-primary full" disabled={push.busy} onClick={() => push.enable()}>
{push.busy ? "Enabling…" : "Enable notifications"}
</button>
)}
{push.error && <p style={{ fontSize: 11.5, color: "var(--danger, #c0392b)", margin: "10px 0 0" }}>{push.error}</p>}
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,49 @@
"use client";
// App-wide in-app notifications. Uses the shared dashboard socket to watch activity across ALL of
// the user's threads (via the adapter's subscribeActivity) and shows a clickable toast when a new
// message arrives — unless you're already on the Messenger tab (you'd see it live there). Clicking
// deep-links to the conversation. Renders nothing.
import { useEffect, useMemo, useRef } from "react";
import { useAppShell, useAuth } from "@abe-kap/appshell-sdk/react";
import type { MessagingAdapter } from "@insignia/iios-messaging-ui";
import { isShellConfigured } from "@/lib/appshell";
import { useRealtime } from "@/lib/realtime";
import { CrmMessagingAdapter, type DataDoor } from "@/lib/crm-messaging-adapter";
import { useToast } from "./ui";
const SHELL = isShellConfigured();
export function NotificationCenter({ active, onNavigate }: { active: string; onNavigate: (surface: "messenger" | "inbox", threadId: string) => void }) {
const socket = useRealtime();
const { sdk } = useAppShell();
const { user } = useAuth();
const toast = useToast();
const me = user?.id;
// Keep the current tab readable inside the (stable) subscription callback.
const activeRef = useRef(active);
activeRef.current = active;
const adapter = useMemo<MessagingAdapter | null>(
() => (me && socket ? new CrmMessagingAdapter(sdk as unknown as DataDoor, me, socket) : null),
[sdk, me, socket],
);
useEffect(() => {
if (!SHELL || !adapter?.subscribeActivity) return;
return adapter.subscribeActivity(({ threadId, message }) => {
if (message.actorId === me) return; // never notify me about my own message
if (activeRef.current === "messenger") return; // already watching chat live
toast.push({
tone: "info",
title: "New message",
desc: message.text?.slice(0, 90) || "You have a new message",
onClick: () => onNavigate("messenger", threadId),
});
});
}, [adapter, me, toast, onNavigate]);
return null;
}
+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>
);
}
+91 -5
View File
@@ -12,9 +12,11 @@
import { useState } from "react"; import { useState } from "react";
import { Btn, Field, Icon, PageHead, Pill, useToast } from "./ui"; import { Btn, Field, Icon, PageHead, Pill, useToast } from "./ui";
import { useSmsSettings } from "@/lib/sms-settings-api"; import { useSmsSettings } from "@/lib/sms-settings-api";
import { useSmtpSettings } from "@/lib/smtp-settings-api";
const SID_RE = /^AC[0-9a-fA-F]{32}$/; const SID_RE = /^AC[0-9a-fA-F]{32}$/;
const E164_RE = /^\+[1-9]\d{6,14}$/; const E164_RE = /^\+[1-9]\d{6,14}$/;
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
export function Settings() { export function Settings() {
return ( return (
@@ -29,7 +31,7 @@ export function Settings() {
<h3 className="settings-section-title">Integrations</h3> <h3 className="settings-section-title">Integrations</h3>
<div className="settings-grid"> <div className="settings-grid">
<TwilioCard /> <TwilioCard />
<SmtpComingSoon /> <SmtpCard />
</div> </div>
</section> </section>
</div> </div>
@@ -120,17 +122,101 @@ function TwilioCard() {
); );
} }
function SmtpComingSoon() { function SmtpCard() {
const toast = useToast();
const { status, loading, live, configure } = useSmtpSettings();
const [editing, setEditing] = useState(false);
const [host, setHost] = useState("");
const [port, setPort] = useState("587");
const [secure, setSecure] = useState(false);
const [user, setUser] = useState("");
const [pass, setPass] = useState("");
const [fromEmail, setFromEmail] = useState("");
const [fromName, setFromName] = useState("");
const [errors, setErrors] = useState<Record<string, string>>({});
const [saving, setSaving] = useState(false);
const showForm = editing || (!loading && !status.configured);
function validate(): boolean {
const e: Record<string, string> = {};
if (!host.trim()) e.host = "SMTP host is required.";
const p = Number(port);
if (!Number.isInteger(p) || p < 1 || p > 65535) e.port = "Port must be 165535.";
if (!user.trim()) e.user = "Username is required.";
if (!pass.trim()) e.pass = "Password is required.";
if (!EMAIL_RE.test(fromEmail.trim())) e.fromEmail = "A valid from-address is required.";
setErrors(e);
return Object.keys(e).length === 0;
}
async function save() {
if (!validate()) return;
setSaving(true);
try {
await configure({ host: host.trim(), port: Number(port), secure, user: user.trim(), pass: pass.trim(), fromEmail: fromEmail.trim(), ...(fromName.trim() ? { fromName: fromName.trim() } : {}) });
toast.push({ tone: "success", title: "SMTP connected", desc: "Outbound email now sends from your server." });
setHost(""); setPort("587"); setSecure(false); setUser(""); setPass(""); setFromEmail(""); setFromName(""); setErrors({}); setEditing(false);
} catch (err) {
toast.push({ tone: "error", title: "Couldn't save SMTP settings", desc: (err as Error).message });
} finally {
setSaving(false);
}
}
return ( return (
<div className="settings-card is-soon"> <div className="settings-card">
<div className="settings-card-head"> <div className="settings-card-head">
<span className="settings-card-ic" aria-hidden="true"><Icon name="mail" size={20} /></span> <span className="settings-card-ic" aria-hidden="true"><Icon name="mail" size={20} /></span>
<div className="settings-card-titles"> <div className="settings-card-titles">
<div className="settings-card-name">Email <span className="settings-card-sub">· SMTP</span></div> <div className="settings-card-name">Email <span className="settings-card-sub">· SMTP</span></div>
<div className="settings-card-desc">Bring your own SMTP server for outbound email.</div> <div className="settings-card-desc">Send external email from your own mail server.</div>
</div> </div>
<Pill tone="muted">Coming soon</Pill> {status.configured ? <Pill tone="green">Connected</Pill> : <Pill tone="muted">Not connected</Pill>}
</div> </div>
{status.configured && !editing ? (
<div className="settings-card-body">
<dl className="settings-kv">
<div><dt>From</dt><dd>{status.fromName ? `${status.fromName} · ` : ""}{status.fromEmail ?? "—"}</dd></div>
<div><dt>Server</dt><dd>{status.host ?? "—"}{status.port ? `:${status.port}` : ""}</dd></div>
<div><dt>Status</dt><dd>{status.enabled ? "Active" : "Disabled"}</dd></div>
</dl>
<Btn variant="outline" icon="settings" onClick={() => setEditing(true)}>Update credentials</Btn>
</div>
) : null}
{showForm ? (
<div className="settings-card-body">
<Field label="SMTP host" required error={errors.host} hint="e.g. smtp.sendgrid.net or your mail server.">
<input className="ds-input" value={host} onChange={(e) => setHost(e.target.value)} placeholder="smtp.example.com" autoComplete="off" />
</Field>
<Field label="Port" required error={errors.port} hint="587 (STARTTLS) or 465 (SSL).">
<input className="ds-input" value={port} onChange={(e) => setPort(e.target.value)} placeholder="587" autoComplete="off" />
</Field>
<label className="settings-check">
<input type="checkbox" checked={secure} onChange={(e) => setSecure(e.target.checked)} /> Use SSL/TLS (port 465)
</label>
<Field label="Username" required error={errors.user} hint="Often your email or an API key.">
<input className="ds-input" value={user} onChange={(e) => setUser(e.target.value)} placeholder="apikey / user@example.com" autoComplete="off" />
</Field>
<Field label="Password" required error={errors.pass} hint="Encrypted on save and never shown again.">
<input className="ds-input" type="password" value={pass} onChange={(e) => setPass(e.target.value)} placeholder="••••••••••••" autoComplete="off" />
</Field>
<Field label="From address" required error={errors.fromEmail}>
<input className="ds-input" value={fromEmail} onChange={(e) => setFromEmail(e.target.value)} placeholder="no-reply@example.com" autoComplete="off" />
</Field>
<Field label="From name" hint="Optional display name on outgoing mail.">
<input className="ds-input" value={fromName} onChange={(e) => setFromName(e.target.value)} placeholder="Acme Roofing" autoComplete="off" />
</Field>
<div className="settings-card-actions">
<Btn icon="check-circle" onClick={save} disabled={saving}>{saving ? "Saving…" : status.configured ? "Update" : "Connect SMTP"}</Btn>
{status.configured ? <Btn variant="ghost" onClick={() => { setEditing(false); setErrors({}); }}>Cancel</Btn> : null}
</div>
</div>
) : null}
{!live ? <div className="settings-card-note">Demo mode credentials are stored locally and no email is sent.</div> : null}
</div> </div>
); );
} }
+1
View File
@@ -95,6 +95,7 @@ const NAV_PERMISSION: Record<string, string | undefined> = {
leads: "leads.manage", leads: "leads.manage",
verify: "leads.manage", verify: "leads.manage",
pipeline: "pipeline.manage", pipeline: "pipeline.manage",
projects: "pipeline.manage",
estimates: "estimates.create", estimates: "estimates.create",
procanvas: "estimates.create", procanvas: "estimates.create",
dispatch: "dispatch.manage", dispatch: "dispatch.manage",
+5 -7
View File
@@ -4,14 +4,15 @@ import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Sun, Moon, ChevronDown, LogOut } from "lucide-react"; import { Sun, Moon, ChevronDown, LogOut } from "lucide-react";
import { useAuth } from "@abe-kap/appshell-sdk/react"; import { useAuth } from "@abe-kap/appshell-sdk/react";
import { Icon } from "./ui"; import { GlobalSearch } from "./global-search";
import { NotificationBell } from "./notification-bell";
import { user } from "./account-data"; import { user } from "./account-data";
function initialsOf(name: string): string { function initialsOf(name: string): string {
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?"; return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
} }
export function Topbar({ theme, onToggle, title, subtitle }: { theme: "dark" | "light"; onToggle: () => void; title: string; subtitle: string }) { export function Topbar({ theme, onToggle, title, subtitle, onNavigate }: { theme: "dark" | "light"; onToggle: () => void; title: string; subtitle: string; onNavigate: (surface: "messenger" | "inbox", threadId: string) => void }) {
// When signed in through the Shell, show the real identity from the App Context // When signed in through the Shell, show the real identity from the App Context
// Envelope; otherwise fall back to the static demo user. // Envelope; otherwise fall back to the static demo user.
const router = useRouter(); const router = useRouter();
@@ -35,14 +36,11 @@ export function Topbar({ theme, onToggle, title, subtitle }: { theme: "dark" | "
<p>{subtitle}</p> <p>{subtitle}</p>
</div> </div>
<div className="top-actions"> <div className="top-actions">
<button className="ic-btn" aria-label="Search"><Icon name="search" size={18} /></button> <GlobalSearch onNavigate={onNavigate} />
<button className="ic-btn" aria-label="Toggle theme" onClick={onToggle}> <button className="ic-btn" aria-label="Toggle theme" onClick={onToggle}>
{theme === "dark" ? <Moon size={18} /> : <Sun size={18} />} {theme === "dark" ? <Moon size={18} /> : <Sun size={18} />}
</button> </button>
<button className="ic-btn" aria-label="Notifications" style={{ position: "relative" }}> <NotificationBell />
<Icon name="bell" size={18} />
<span style={{ position: "absolute", top: 9, right: 10, width: 7, height: 7, borderRadius: 99, background: "var(--orange)", border: "2px solid var(--panel)" }} />
</button>
<div className="top-user-wrap" style={{ position: "relative" }}> <div className="top-user-wrap" style={{ position: "relative" }}>
<button className="top-user" onClick={() => setMenuOpen((o) => !o)} aria-haspopup="menu" aria-expanded={menuOpen}> <button className="top-user" onClick={() => setMenuOpen((o) => !o)} aria-haspopup="menu" aria-expanded={menuOpen}>
<span className="av" style={{ background: user.avatarGradient, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{initials}</span> <span className="av" style={{ background: user.avatarGradient, display: "grid", placeItems: "center", color: "#fff", fontWeight: 700, fontSize: 12 }}>{initials}</span>
+13 -7
View File
@@ -222,9 +222,9 @@ export function OtpField({ length = 6, value, onChange, autoFocus = true }: { le
/* Modal */ /* Modal */
/* ---------------------------------------------------------- */ /* ---------------------------------------------------------- */
export function Modal({ open, onClose, title, subtitle, icon, children, footer, size = "md" }: { export function Modal({ open, onClose, title, subtitle, icon, children, footer, size = "md", headerExtra }: {
open: boolean; onClose: () => void; title: string; subtitle?: string; icon?: string; open: boolean; onClose: () => void; title: string; subtitle?: ReactNode; icon?: string;
children: ReactNode; footer?: ReactNode; size?: "sm" | "md" | "lg"; children: ReactNode; footer?: ReactNode; size?: "sm" | "md" | "lg" | "xl"; headerExtra?: ReactNode;
}) { }) {
const titleId = useId(); const titleId = useId();
// Portal the overlay up to `.dash-root` so its position:fixed anchors to the viewport, // 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>} {subtitle && <p>{subtitle}</p>}
</div> </div>
</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>
<div className="ds-modal-body">{children}</div> <div className="ds-modal-body">{children}</div>
{footer && <div className="ds-modal-foot">{footer}</div>} {footer && <div className="ds-modal-foot">{footer}</div>}
@@ -265,7 +268,7 @@ export function Modal({ open, onClose, title, subtitle, icon, children, footer,
/* Toast */ /* Toast */
/* ---------------------------------------------------------- */ /* ---------------------------------------------------------- */
type Toast = { id: number; tone: "success" | "info" | "error"; title: string; desc?: string }; type Toast = { id: number; tone: "success" | "info" | "error"; title: string; desc?: string; onClick?: () => void };
type ToastCtx = { push: (t: Omit<Toast, "id">) => void }; type ToastCtx = { push: (t: Omit<Toast, "id">) => void };
const ToastContext = createContext<ToastCtx | null>(null); const ToastContext = createContext<ToastCtx | null>(null);
@@ -288,9 +291,12 @@ export function ToastProvider({ children }: { children: ReactNode }) {
{children} {children}
<div className="ds-toasts"> <div className="ds-toasts">
{items.map((t) => ( {items.map((t) => (
<div key={t.id} className={`ds-toast tone-${t.tone}`}> <div key={t.id} className={`ds-toast tone-${t.tone}${t.onClick ? " is-clickable" : ""}`}>
<Icon name={t.tone === "success" ? "check-circle" : t.tone === "error" ? "alert" : "info"} size={18} /> <Icon name={t.tone === "success" ? "check-circle" : t.tone === "error" ? "alert" : "info"} size={18} />
<div className="ds-toast-body"> <div
className="ds-toast-body"
{...(t.onClick ? { role: "button", tabIndex: 0, onClick: () => { t.onClick?.(); setItems((s) => s.filter((x) => x.id !== t.id)); } } : {})}
>
<div className="ds-toast-title">{t.title}</div> <div className="ds-toast-title">{t.title}</div>
{t.desc && <div className="ds-toast-desc">{t.desc}</div>} {t.desc && <div className="ds-toast-desc">{t.desc}</div>}
</div> </div>
+72
View File
@@ -0,0 +1,72 @@
// ============================================================
// LynkedUp Pro — Lead Verification mock data.
// The verification desk: door-knocked / web leads move through
// an identity + insurance verification workflow before they
// become working deals. All client-side.
// ============================================================
export type VStatus = "verified" | "in_progress" | "assigned" | "pending" | "unverified";
export type VActivity = { text: string; time: string; who: string };
export type VLead = {
id: string;
initials: string;
name: string;
address: string;
phone: string;
source: string;
assignee: { initials: string; name: string } | null;
status: VStatus;
verification: string; // sub-status text
created: string;
// detail (optional — derived when absent)
email?: string;
createdAt?: string; // date + time
verifiedAt?: string;
notes?: string;
activity?: VActivity[];
};
export const V_STATUS_META: Record<VStatus, { label: string; tone: string }> = {
verified: { label: "Verified", tone: "green" },
in_progress: { label: "In Progress", tone: "orange" },
assigned: { label: "Assigned", tone: "blue" },
pending: { label: "Pending", tone: "purple" },
unverified: { label: "Unverified", tone: "red" },
};
const WADE = { initials: "WH", name: "Wade Hollis" };
const DARLENE = { initials: "DB", name: "Darlene Brooks" };
const ROY = { initials: "RS", name: "Roy Schaefer" };
export const V_LEADS: VLead[] = [
{
id: "LD-V-001", initials: "K", name: "Kevin Hartley", address: "2814 Ravenswood Dr, Plano, TX 75023", phone: "(972) 413-8902", source: "Door Knock", assignee: WADE, status: "verified", verification: "Verified", created: "May 14, 2026",
email: "kevin.hartley@gmail.com", createdAt: "May 14, 2026, 2:40 PM", verifiedAt: "May 16, 2026, 7:55 PM",
notes: "Ownership confirmed via county records. Hail damage from April 28 storm event verified.",
activity: [
{ text: "Lead submitted via door knock intake.", time: "May 14, 2026, 2:40 PM", who: "System" },
{ text: "Assigned to Wade Hollis.", time: "May 14, 2026, 3:10 PM", who: "Wade Hollis" },
{ text: "First contact made — homeowner confirmed damage.", time: "May 15, 2026, 2:00 PM", who: "Wade Hollis" },
{ text: "Verified and pushed to New Leads.", time: "May 16, 2026, 7:55 PM", who: "Wade Hollis" },
],
},
{ id: "LD-V-002", initials: "S", name: "Sandra Nguyen", address: "4817 Shady Brook Ln, Plano, TX 75093", phone: "(469) 551-7034", source: "Web Form", assignee: DARLENE, status: "verified", verification: "Verified", created: "May 17, 2026" },
{ id: "LD-V-003", initials: "M", name: "Marcus Trevino", address: "1234 Oak Creek Blvd, Plano, TX 75075", phone: "(214) 837-4561", source: "Storm Canvass", assignee: WADE, status: "in_progress", verification: "Verifying Identity", created: "May 22, 2026" },
{ id: "LD-V-004", initials: "B", name: "Brenda Kowalski", address: "890 Custer Rd, Plano, TX 75075", phone: "(972) 604-2817", source: "Referral", assignee: ROY, status: "in_progress", verification: "Reviewing Insurance", created: "May 20, 2026" },
{ id: "LD-V-005", initials: "J", name: "James Whitaker", address: "3320 Parkhaven Dr, Plano, TX 75075", phone: "(469) 720-1188", source: "Door Knock", assignee: WADE, status: "in_progress", verification: "Confirming Ownership", created: "May 23, 2026" },
{ id: "LD-V-006", initials: "P", name: "Priya Sharma", address: "5102 Mapleshade Ln, Plano, TX 75093", phone: "(972) 415-6620", source: "Web Form", assignee: DARLENE, status: "in_progress", verification: "Reviewing Insurance", created: "May 24, 2026" },
{ id: "LD-V-007", initials: "C", name: "Carlos Mendez", address: "1470 Coit Rd, Plano, TX 75075", phone: "(214) 902-5533", source: "Storm Canvass", assignee: ROY, status: "in_progress", verification: "Confirming Damage", created: "May 25, 2026" },
{ id: "LD-V-008", initials: "E", name: "Emily Carter", address: "2609 Rivercrest Dr, Plano, TX 75023", phone: "(469) 338-4471", source: "Door Knock", assignee: DARLENE, status: "assigned", verification: "Assigned", created: "May 26, 2026" },
{ id: "LD-V-009", initials: "T", name: "Tyrone Jackson", address: "744 Legacy Dr, Plano, TX 75023", phone: "(972) 551-9042", source: "Referral", assignee: WADE, status: "assigned", verification: "Assigned", created: "May 26, 2026" },
{ id: "LD-V-010", initials: "N", name: "Nicole Foster", address: "3901 Preston Meadow Dr, Plano, TX 75093", phone: "(214) 660-7719", source: "Web Form", assignee: ROY, status: "assigned", verification: "Assigned", created: "May 27, 2026" },
{ id: "LD-V-011", initials: "A", name: "Aaron Blake", address: "1188 Alma Dr, Plano, TX 75075", phone: "(469) 471-3350", source: "Door Knock", assignee: null, status: "pending", verification: "Pending Review", created: "May 27, 2026" },
{ id: "LD-V-012", initials: "G", name: "Grace Liu", address: "5540 Communications Pkwy, Plano, TX 75093", phone: "(972) 883-2201", source: "Web Form", assignee: null, status: "pending", verification: "Pending Review", created: "May 28, 2026" },
{ id: "LD-V-013", initials: "D", name: "Derek Olsen", address: "902 Independence Pkwy, Plano, TX 75075", phone: "(214) 774-6690", source: "Call-In", assignee: null, status: "pending", verification: "Pending Review", created: "May 28, 2026" },
{ id: "LD-V-014", initials: "M", name: "Monica Reyes", address: "3115 Rasor Blvd, Plano, TX 75093", phone: "(469) 205-8814", source: "Storm Canvass", assignee: null, status: "unverified", verification: "Unverified", created: "May 29, 2026" },
{ id: "LD-V-015", initials: "S", name: "Sam Patterson", address: "677 Spring Creek Pkwy, Plano, TX 75023", phone: "(972) 330-1247", source: "Call-In", assignee: null, status: "unverified", verification: "Unverified", created: "May 29, 2026" },
];
export const V_SOURCES = ["Door Knock", "Web Form", "Storm Canvass", "Referral", "Call-In"];
export const V_ASSIGNEES = ["Wade Hollis", "Darlene Brooks", "Roy Schaefer"];
+306
View File
@@ -0,0 +1,306 @@
"use client";
// ============================================================
// Lead Verification — the verification desk.
// · Stat tiles : Verified / In Progress / Assigned / Pending
// / Unverified counts (click to filter)
// · Toolbar : search + status / source / assignee filters
// · Table : Lead ID · Customer · Phone · Source ·
// Assigned To · Status · Verification · Created
// · Actions (view / verify / ⋯ menu)
// · Detail : view → popup with Contact, Assignment,
// Verification Notes and an Activity timeline
// Data comes from verify-data.ts (client-side mock).
// ============================================================
import { useMemo, useState, useEffect } from "react";
import { createPortal } from "react-dom";
import { Avatar, Btn, Icon, Modal, PageHead, Pill, useToast } from "./ui";
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> = {
verified: "check-circle", in_progress: "refresh", assigned: "user", pending: "clock", unverified: "alert",
};
/* ---- derive detail when the row doesn't carry it ---------- */
function deriveEmail(l: VLead) {
if (l.email) return l.email;
const [first, ...rest] = l.name.toLowerCase().split(" ");
return `${first}.${rest.join("")}@gmail.com`;
}
function deriveCreatedAt(l: VLead) { return l.createdAt ?? `${l.created}, 10:00 AM`; }
function buildActivity(l: VLead): VActivity[] {
if (l.activity) return l.activity;
const who = l.assignee?.name ?? "System";
const a: VActivity[] = [{ text: `Lead submitted via ${l.source.toLowerCase()} intake.`, time: deriveCreatedAt(l), who: "System" }];
if (l.assignee) a.push({ text: `Assigned to ${l.assignee.name}.`, time: l.created, who: l.assignee.name });
if (l.status === "in_progress") a.push({ text: `Verification in progress — ${l.verification.toLowerCase()}.`, time: l.created, who });
if (l.status === "verified") a.push({ text: "Verified and pushed to New Leads.", time: l.verifiedAt ?? l.created, who });
return a;
}
type MenuState = { lead: VLead; x: number; y: number } | null;
export function Verify() {
const toast = useToast();
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 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 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;
});
}, [query, status, source, assignee]);
function act(l: VLead, title: string, desc: string, tone: "success" | "info" = "info") {
setMenu(null);
toast.push({ tone, title, desc });
}
return (
<div className="view lv">
<PageHead
eyebrow="Sales"
title="Lead Verification"
subtitle="Identity & insurance checks before a lead becomes a working deal."
icon="verify"
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 ---- */}
<div className="lv-stats">
{STAT_ORDER.map((s) => {
const meta = V_STATUS_META[s];
return (
<button key={s} className={`lv-stat tone-${meta.tone} ${status === s ? "active" : ""}`} onClick={() => setStatus(status === s ? "all" : s)}>
<span className="lv-stat-ic"><Icon name={STAT_ICON[s]} size={16} /></span>
<span className="lv-stat-val">{counts[s] ?? 0}</span>
<span className="lv-stat-lbl">{meta.label}</span>
</button>
);
})}
</div>
{/* ---- toolbar ---- */}
<div className="lv-toolbar">
<div className="lv-search">
<Icon name="search" size={16} />
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search name, lead ID, phone, source…" aria-label="Search verification leads" />
{query && <button className="lv-search-x" aria-label="Clear" onClick={() => setQuery("")}><Icon name="x" size={14} /></button>}
</div>
<select className="ds-select lv-filter" value={status} onChange={(e) => setStatus(e.target.value)} aria-label="Filter by status">
<option value="all">All statuses</option>
{STAT_ORDER.map((s) => <option key={s} value={s}>{V_STATUS_META[s].label}</option>)}
</select>
<select className="ds-select lv-filter" value={source} onChange={(e) => setSource(e.target.value)} aria-label="Filter by source">
<option value="all">All sources</option>
{V_SOURCES.map((s) => <option key={s} value={s}>{s}</option>)}
</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>
{V_ASSIGNEES.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
</div>
{/* ---- table ---- */}
<div className="lv-tablewrap">
<table className="lv-table">
<thead>
<tr>
<th>Lead ID</th><th>Customer</th><th>Phone</th><th>Source</th>
<th>Assigned To</th><th>Status</th><th>Verification</th><th>Created</th>
<th className="lv-actions-h">Actions</th>
</tr>
</thead>
<tbody>
{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];
return (
<tr key={l.id}>
<td><div className="lv-id">{l.id}</div><div className="lv-id-date">{l.created}</div></td>
<td>
<div className="lv-cust">
<Avatar initials={l.initials} size={34} />
<div className="lv-cust-body">
<div className="lv-cust-name">{l.name}</div>
<div className="lv-cust-addr">{l.address}</div>
</div>
</div>
</td>
<td className="lv-phone">{l.phone}</td>
<td><span className="lv-source">{l.source}</span></td>
<td>
{l.assignee ? (
<div className="lv-assignee">
<Avatar initials={l.assignee.initials} size={26} gradient="linear-gradient(135deg,#4f8cff,#2c5cff)" />
<span>{l.assignee.name}</span>
</div>
) : <span className="lv-unassigned"> Unassigned</span>}
</td>
<td><Pill tone={meta.tone}>{meta.label}</Pill></td>
<td><span className={`lv-verif v-${l.status}`}><Icon name={STAT_ICON[l.status]} size={13} /> {l.verification}</span></td>
<td className="lv-created">{l.created}</td>
<td>
<div className="lv-rowacts">
<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>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="lv-count">{rows.length} of {V_LEADS.length} leads</div>
<ActionsMenu menu={menu} onClose={() => setMenu(null)} onAct={act} onView={(l) => { setSelected(l); setMenu(null); }} />
<VerifyDetail lead={selected} onClose={() => setSelected(null)} />
</div>
);
}
/* ---------------------------------------------------------- */
/* Row actions dropdown (portalled, fixed-positioned) */
/* ---------------------------------------------------------- */
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;
}) {
const [mounted, setMounted] = useState(false);
useEffect(() => { setMounted(true); }, []);
useEffect(() => {
if (!menu) return;
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [menu, onClose]);
if (!menu || !mounted) return null;
const l = menu.lead;
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} />
<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,
);
}
/* ---------------------------------------------------------- */
/* Verification detail popup */
/* ---------------------------------------------------------- */
function VerifyDetail({ lead, onClose }: { lead: VLead | null; onClose: () => void }) {
if (!lead) return null;
const meta = V_STATUS_META[lead.status];
const activity = buildActivity(lead);
return (
<Modal open={!!lead} onClose={onClose} size="lg" title={lead.name} subtitle={`${lead.id} · ${lead.verification}`} icon="verify"
footer={<>
<Btn variant="ghost" icon="phone">Call</Btn>
<Btn icon="check-circle">Verify Lead</Btn>
</>}
>
<div className="lv-detail">
<div className="lv-d-identity">
<Avatar initials={lead.initials} size={50} />
<div>
<div className="lv-d-name">{lead.name}</div>
<div className="lv-d-pills">
<Pill tone={meta.tone}>{meta.label}</Pill>
<span className={`lv-verif v-${lead.status}`}><Icon name={STAT_ICON[lead.status]} size={13} /> {lead.verification}</span>
</div>
</div>
</div>
<div className="lv-d-grid">
<div className="lv-d-section">
<div className="lv-d-head"><Icon name="user" size={15} /> Contact</div>
<Row label="Phone" value={lead.phone} />
<Row label="Email" value={deriveEmail(lead)} />
<Row label="Address" value={lead.address} />
<Row label="Source" value={lead.source} />
</div>
<div className="lv-d-section">
<div className="lv-d-head"><Icon name="team" size={15} /> Assignment</div>
<Row label="Assigned To" value={lead.assignee?.name ?? "Unassigned"} />
<Row label="Created" value={deriveCreatedAt(lead)} />
{lead.verifiedAt && <Row label="Verified At" value={lead.verifiedAt} />}
</div>
</div>
{lead.notes && (
<div className="lv-d-notes">
<div className="lv-d-head"><Icon name="edit" size={15} /> Verification Notes</div>
<p>{lead.notes}</p>
</div>
)}
<div className="lv-d-activity">
<div className="lv-d-head"><Icon name="clock" size={15} /> Activity</div>
<ul className="lv-timeline">
{activity.map((a, i) => (
<li key={i} className="lv-tl-item">
<span className="lv-tl-dot" />
<div className="lv-tl-body">
<div className="lv-tl-text">{a.text}</div>
<div className="lv-tl-meta">{a.time} · {a.who}</div>
</div>
</li>
))}
</ul>
</div>
</div>
</Modal>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="lv-d-row">
<span className="lv-d-k">{label}</span>
<span className="lv-d-v">{value}</span>
</div>
);
}
+37 -1
View File
@@ -45,6 +45,8 @@ export class CrmMessagingAdapter implements MessagingAdapter {
private readonly listeners = new Map<string, Set<(e: MessageEvent) => void>>(); private readonly listeners = new Map<string, Set<(e: MessageEvent) => void>>();
private readonly polls = new Map<string, Poll>(); private readonly polls = new Map<string, Poll>();
private readonly joined = new Set<string>(); private readonly joined = new Set<string>();
/** Cross-thread activity listeners (live unread + in-app notifications). */
private readonly activity = new Set<(e: { threadId: string; message: Message }) => void>();
/** messageId → emoji → userSet, so a single annotation delta can be re-emitted as a full set. */ /** messageId → emoji → userSet, so a single annotation delta can be re-emitted as a full set. */
private readonly reactions = new Map<string, Map<string, Set<string>>>(); private readonly reactions = new Map<string, Map<string, Set<string>>>();
@@ -59,7 +61,10 @@ export class CrmMessagingAdapter implements MessagingAdapter {
if (socket) { if (socket) {
socket.on("message", (m) => { socket.on("message", (m) => {
this.ingestReactions(m); this.ingestReactions(m);
void this.toKernelMessage(m).then((message) => this.emit(m.threadId, { kind: "message", message })); void this.toKernelMessage(m).then((message) => {
this.emit(m.threadId, { kind: "message", message });
for (const cb of this.activity) cb({ threadId: m.threadId, message });
});
}); });
socket.on("typing", (e) => this.emit(e.threadId, { kind: "typing", userId: e.userId })); socket.on("typing", (e) => this.emit(e.threadId, { kind: "typing", userId: e.userId }));
// Receipts carry no threadId → fan to all open threads; the UI filters by messageId. // Receipts carry no threadId → fan to all open threads; the UI filters by messageId.
@@ -79,6 +84,37 @@ export class CrmMessagingAdapter implements MessagingAdapter {
return this.me; return this.me;
} }
/** Report the foregrounded thread to IIOS presence (suppresses push for what you're viewing). */
setFocus(threadId: string | null): void {
this.socket?.focus(threadId);
}
/** Fire `cb` for every incoming message across ALL the caller's threads (live unread + toasts). */
subscribeActivity(cb: (e: { threadId: string; message: Message }) => void): Unsubscribe {
this.activity.add(cb);
void this.joinAllThreads();
return () => {
this.activity.delete(cb);
};
}
/** Join every thread the user belongs to so their messages arrive over the socket, not just the
* open one. Idempotent (the `joined` set guards re-joins). */
private async joinAllThreads(): Promise<void> {
if (!this.socket) return;
try {
const convs = await this.sdk.query<ConversationDTO[]>("crm.messenger.conversation.list", {});
for (const c of convs) {
if (!this.joined.has(c.threadId)) {
this.joined.add(c.threadId);
void this.socket.openThread(c.threadId).catch(() => this.joined.delete(c.threadId));
}
}
} catch {
/* best-effort — activity just won't cover un-joined threads */
}
}
async listConversations(): Promise<Conversation[]> { async listConversations(): Promise<Conversation[]> {
const [convs, names] = await Promise.all([ const [convs, names] = await Promise.all([
this.sdk.query<ConversationDTO[]>("crm.messenger.conversation.list", {}), this.sdk.query<ConversationDTO[]>("crm.messenger.conversation.list", {}),
+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();
}
+138
View File
@@ -0,0 +1,138 @@
"use client";
// Offline notifications data layer (Web Push). Registers the service worker, subscribes the browser
// with IIOS's VAPID key, and hands the subscription to the be-crm door so IIOS can reach this user
// while no CRM tab is open. Push needs a real backend (VAPID key + delivery), so it is only offered
// when the Shell is configured (live); demo mode reports it unsupported.
//
// Live contract (be-crm data door → IIOS /v1/notifications/*):
// query crm.messenger.push.vapidKey {} -> { key } ('' = push disabled server-side)
// cmd crm.messenger.push.subscribe { endpoint, keys, userAgent } -> { ok }
// cmd crm.messenger.push.unsubscribe { endpoint } -> { ok }
import { useCallback, useEffect, useState } from "react";
import { useAppShell } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "./appshell";
const SHELL = isShellConfigured();
const SW_URL = "/push-sw.js";
export type PushPermission = "default" | "granted" | "denied";
export interface PushState {
/** Browser can do Web Push AND we have a live backend to deliver it. */
supported: boolean;
/** OS/browser permission for notifications. */
permission: PushPermission;
/** This browser currently has an active push subscription registered with the backend. */
subscribed: boolean;
/** A subscribe/unsubscribe round-trip is in flight. */
busy: boolean;
error: string | null;
enable: () => Promise<void>;
disable: () => Promise<void>;
}
/** VAPID keys travel as URL-safe base64; PushManager wants raw bytes. */
function urlBase64ToUint8Array(base64: string): Uint8Array {
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
const normalized = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
const raw = atob(normalized);
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
}
function browserSupportsPush(): boolean {
return typeof window !== "undefined" && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
}
/** Serialize a PushSubscription into the door's { endpoint, keys } shape. */
function toSubscribeBody(sub: PushSubscription): { endpoint: string; keys: { p256dh: string; auth: string }; userAgent: string } {
const json = sub.toJSON();
return {
endpoint: sub.endpoint,
keys: { p256dh: json.keys?.p256dh ?? "", auth: json.keys?.auth ?? "" },
userAgent: typeof navigator !== "undefined" ? navigator.userAgent.slice(0, 400) : "",
};
}
export function usePushNotifications(): PushState {
const { sdk } = useAppShell();
const [supported] = useState<boolean>(() => SHELL && browserSupportsPush());
const [permission, setPermission] = useState<PushPermission>("default");
const [subscribed, setSubscribed] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reflect the current OS permission + whether a subscription already exists (e.g. across reloads).
useEffect(() => {
if (!supported) return;
setPermission(Notification.permission as PushPermission);
let cancelled = false;
navigator.serviceWorker.ready
.then((reg) => reg.pushManager.getSubscription())
.then((sub) => {
if (!cancelled) setSubscribed(Boolean(sub));
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [supported]);
const enable = useCallback(async () => {
if (!supported) return;
setBusy(true);
setError(null);
try {
const perm = await Notification.requestPermission();
setPermission(perm as PushPermission);
if (perm !== "granted") throw new Error("Notifications permission was not granted.");
const reg = await navigator.serviceWorker.register(SW_URL);
await navigator.serviceWorker.ready;
const { key } = await sdk.query<{ key: string }>("crm.messenger.push.vapidKey", {});
if (!key) throw new Error("Push is not enabled on the server (no VAPID key).");
const existing = await reg.pushManager.getSubscription();
const sub =
existing ??
(await reg.pushManager.subscribe({
userVisibleOnly: true,
// Cast: this lib's BufferSource type pins ArrayBuffer, but a plain Uint8Array is valid here.
applicationServerKey: urlBase64ToUint8Array(key) as BufferSource,
}));
await sdk.command("crm.messenger.push.subscribe", toSubscribeBody(sub));
setSubscribed(true);
} catch (e) {
setError(e instanceof Error ? e.message : "Could not enable notifications.");
} finally {
setBusy(false);
}
}, [supported, sdk]);
const disable = useCallback(async () => {
if (!supported) return;
setBusy(true);
setError(null);
try {
const reg = await navigator.serviceWorker.ready;
const sub = await reg.pushManager.getSubscription();
if (sub) {
// Tell the backend first (still has the endpoint), then drop the local subscription.
await sdk.command("crm.messenger.push.unsubscribe", { endpoint: sub.endpoint }).catch(() => {});
await sub.unsubscribe();
}
setSubscribed(false);
} catch (e) {
setError(e instanceof Error ? e.message : "Could not disable notifications.");
} finally {
setBusy(false);
}
}, [supported, sdk]);
return { supported, permission, subscribed, busy, error, enable, disable };
}
+43
View File
@@ -0,0 +1,43 @@
"use client";
// One shared IIOS message socket for the whole dashboard, so the messenger tab AND the app-wide
// notification center use a single connection (not one each). Opened at the dashboard level and
// kept alive across tab switches; the messenger tab reuses it via useRealtime().
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { useQuery } from "@abe-kap/appshell-sdk/react";
import { MessageSocket } from "@insignia/iios-kernel-client";
import { isShellConfigured } from "./appshell";
const RealtimeContext = createContext<MessageSocket | null>(null);
interface RealtimeDTO { url: string; audience: string; token?: string }
const SHELL = isShellConfigured();
function LiveRealtimeProvider({ children }: { children: ReactNode }) {
const rt = useQuery<RealtimeDTO>("crm.messenger.realtime", {});
const [socket, setSocket] = useState<MessageSocket | null>(null);
const url = rt.data?.url;
const token = rt.data?.token;
useEffect(() => {
if (!url || !token) return;
const s = new MessageSocket({ serviceUrl: url, token, autoConnect: false });
s.connect();
setSocket(s);
return () => {
s.disconnect();
setSocket(null);
};
}, [url, token]);
return <RealtimeContext.Provider value={socket}>{children}</RealtimeContext.Provider>;
}
export function RealtimeProvider({ children }: { children: ReactNode }) {
// SHELL is a build-time constant, so the same branch runs every render (Rules-of-Hooks safe).
if (!SHELL) return <>{children}</>;
return <LiveRealtimeProvider>{children}</LiveRealtimeProvider>;
}
/** The shared socket, or null (demo mode / not yet connected). */
export function useRealtime(): MessageSocket | null {
return useContext(RealtimeContext);
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
// Global conversation search. Live path calls the be-crm data door (crm.search → IIOS Meilisearch,
// permission-scoped there); demo path filters a small in-memory set. Search is imperative (the query
// changes on every keystroke), so it uses sdk.query directly rather than the cached useQuery hook.
import { useCallback } from "react";
import { useAppShell } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "./appshell";
export interface SearchResult {
interactionId: string;
threadId: string;
surface: "messenger" | "inbox";
title: string;
/** Snippet with <em>…</em> around the matched terms. */
snippet: string;
at: number;
}
export type SearchFn = (query: string) => Promise<SearchResult[]>;
const MOCK: SearchResult[] = [
{ interactionId: "s1", threadId: "th_mock_1", surface: "messenger", title: "Sofia Ramirez", snippet: "Can you confirm the <em>Henderson</em> scope?", at: Date.now() },
{ interactionId: "s2", threadId: "th_mock_2", surface: "messenger", title: "Storm response — East side", snippet: "Crew is <em>rolling</em> out at 7", at: Date.now() },
{ interactionId: "s3", threadId: "mt_invoice", surface: "inbox", title: "Invoice #1042 — Acme Roofing", snippet: "Attached is <em>invoice</em> #1042 for the East-side job", at: Date.now() },
];
const SHELL = isShellConfigured();
function useLiveSearch(): SearchFn {
const { sdk } = useAppShell();
return useCallback(async (query: string) => {
const q = query.trim();
if (!q) return [];
return sdk.query<SearchResult[]>("crm.search", { query: q, limit: 20 });
}, [sdk]);
}
function useMockSearch(): SearchFn {
return useCallback(async (query: string) => {
const q = query.trim().toLowerCase();
if (!q) return [];
return MOCK.filter((m) => (m.title + " " + m.snippet).toLowerCase().includes(q));
}, []);
}
export function useGlobalSearch(): SearchFn {
return SHELL ? useLiveSearch() : useMockSearch();
}
+89
View File
@@ -0,0 +1,89 @@
"use client";
// SMTP settings data layer — a tenant's own outbound email server (BYO). Serves the local mock
// (Shell not configured) or the live be-crm data door (crm.settings.smtp.*).
//
// Live contract (be-crm → IIOS BYO credential store):
// query crm.settings.smtp.status {} -> { configured, enabled?, hints? }
// cmd crm.settings.smtp.configure { host, port, secure, user, pass, fromEmail, fromName? } -> masked status
// The password is write-only: sealed in IIOS, never returned — status carries only non-secret hints.
import { useCallback, useState } from "react";
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "./appshell";
export interface SmtpCredentials {
host: string;
port: number;
secure: boolean;
user: string;
pass: string;
fromEmail: string;
fromName?: string;
}
export interface SmtpStatus {
configured: boolean;
enabled: boolean;
host?: string;
port?: number;
user?: string;
fromEmail?: string;
fromName?: string;
}
export interface SmtpSettingsData {
live: boolean;
loading: boolean;
error: string | null;
status: SmtpStatus;
configure: (input: SmtpCredentials) => Promise<void>;
refetch: () => void;
}
interface StatusDTO { configured: boolean; enabled?: boolean; hints?: { host?: string; port?: number; user?: string; fromEmail?: string; fromName?: string } }
function toStatus(dto?: StatusDTO | null): SmtpStatus {
return {
configured: !!dto?.configured,
enabled: dto?.enabled ?? false,
host: dto?.hints?.host,
port: dto?.hints?.port,
user: dto?.hints?.user,
fromEmail: dto?.hints?.fromEmail,
fromName: dto?.hints?.fromName,
};
}
/* ---- Mock (demo mode) — stores only the non-secret hints ---- */
function useMockSmtp(): SmtpSettingsData {
const [status, setStatus] = useState<SmtpStatus>({ configured: false, enabled: false });
const configure = useCallback(async ({ host, port, user, fromEmail, fromName }: SmtpCredentials) => {
setStatus({ configured: true, enabled: true, host, port, user, fromEmail, ...(fromName ? { fromName } : {}) });
}, []);
return { live: false, loading: false, error: null, status, configure, refetch: () => {} };
}
/* ---- Live (be-crm data door) ---- */
function useLiveSmtp(): SmtpSettingsData {
const { sdk } = useAppShell();
const q = useQuery<StatusDTO>("crm.settings.smtp.status", {});
const configure = useCallback(async (input: SmtpCredentials) => {
await sdk.command("crm.settings.smtp.configure", { ...input });
q.refetch();
}, [sdk, q]);
return {
live: true,
loading: q.loading,
error: q.error ? String(q.error) : null,
status: toStatus(q.data),
configure,
refetch: q.refetch,
};
}
const SHELL = isShellConfigured();
export function useSmtpSettings(): SmtpSettingsData {
return SHELL ? useLiveSmtp() : useMockSmtp();
}
-16
View File
@@ -1,16 +0,0 @@
#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
case `uname` in
*CYGWIN*|*MINGW*|*MSYS*)
if command -v cygpath > /dev/null 2>&1; then
basedir=`cygpath -w "$basedir"`
fi
;;
esac
if [ -x "$basedir/node" ]; then
exec "$basedir/node" "$basedir/../nanoid/bin/nanoid.js" "$@"
else
exec node "$basedir/../nanoid/bin/nanoid.js" "$@"
fi
+1
View File
@@ -0,0 +1 @@
../nanoid/bin/nanoid.js
-17
View File
@@ -1,17 +0,0 @@
@ECHO off
GOTO start
:find_dp0
SET dp0=%~dp0
EXIT /b
:start
SETLOCAL
CALL :find_dp0
IF EXIST "%dp0%\node.exe" (
SET "_prog=%dp0%\node.exe"
) ELSE (
SET "_prog=node"
SET PATHEXT=%PATHEXT:;.JS;=;%
)
endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\nanoid\bin\nanoid.js" %*
-28
View File
@@ -1,28 +0,0 @@
#!/usr/bin/env pwsh
$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent
$exe=""
if ($PSVersionTable.PSVersion -lt "6.0" -or $IsWindows) {
# Fix case when both the Windows and Linux builds of Node
# are installed in the same directory
$exe=".exe"
}
$ret=0
if (Test-Path "$basedir/node$exe") {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "$basedir/node$exe" "$basedir/../nanoid/bin/nanoid.js" $args
} else {
& "$basedir/node$exe" "$basedir/../nanoid/bin/nanoid.js" $args
}
$ret=$LASTEXITCODE
} else {
# Support pipeline input
if ($MyInvocation.ExpectingInput) {
$input | & "node$exe" "$basedir/../nanoid/bin/nanoid.js" $args
} else {
& "node$exe" "$basedir/../nanoid/bin/nanoid.js" $args
}
$ret=$LASTEXITCODE
}
exit $ret
Generated Vendored Regular → Executable
View File