17 Commits

Author SHA1 Message Date
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
maaz519 e04f1e6086 chore(crm): pull SDK 0.1.2 (uploading-progress chip) 2026-07-23 01:04:52 +05:30
maaz519 ae27830893 fix(inbox/messenger): directory limit 100 (was 400ing) + attachment download
- crm.messenger.directory limit 200 -> 100 (be-crm caps at 100, was 400 Bad
  Request) in both the inbox + messaging adapters.
- CrmInboxAdapter.downloadAttachment via crm.media.presignDownload, so mail
  attachments are downloadable. Pull SDK 0.1.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:53:08 +05:30
maaz519 84cb5c66b5 Merge pull request 'Feat/adopt messaging sdk' (#31) from feat/adopt-messaging-sdk into goutamnextflow
Reviewed-on: #31
2026-07-22 19:06:48 +00:00
maaz519 1fc1a28d96 chore(deps): consume @insignia/iios-messaging-ui from the Gitea registry
The SDK is now published to git.lynkedup.cloud (@insignia scope); switch from the
local file: tarball to ^0.1.0 so prod/CI installs resolve it. Install auth uses
GITEA_TOKEN (read:package), same as @insignia/iios-kernel-client.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 00:29:18 +05:30
maaz519 0c7b3231ef chore(crm): pull SDK build with un-stretched compose/create buttons 2026-07-22 19:27:27 +05:30
maaz519 7a312a1ace feat(messenger): expose adapter directory() for DM/group picker + pull SDK build
CrmMessagingAdapter.directory() now returns the org people list (Person[]) for the
new 'New message' picker; internal name-resolution uses directoryMap(). The DM/group
create path rides the existing crm.messenger.conversation.open door (dedupe + group).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 19:23:57 +05:30
maaz519 eb7e1f7995 chore(crm): pull SDK build with body-portaled compose modal 2026-07-22 18:19:25 +05:30
maaz519 988b46f6e7 refactor(messaging): drop bespoke messenger/inbox/mail + wrapper page headers
Delete the now-dead pre-SDK stack (messenger/inbox/mail components + their
*-api libs + messenger-socket) — both tabs render the @insignia/iios-messaging-ui
SDK. Remove the redundant <PageHead> from the messenger/inbox SDK wrappers (the
Topbar already shows the title); the SDK itself renders no header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 17:55:10 +05:30
maaz519 8e2fe315ce feat(settings): Org Settings → Integrations with BYO Twilio SMS
New Settings tab renders an Integrations section; the SMS (Twilio) card lets a
tenant admin connect their own Twilio account (Account SID / write-only Auth Token
/ E.164 from-number) via crm.settings.sms.configure, and shows the masked status
(from-number + SID last-4) from crm.settings.sms.status. Demo mode stores hints
locally. Email (SMTP) shown as the next provider on the same registry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 17:43:53 +05:30
maaz519 462eb7f036 feat(inbox): wire mail attachments through CrmInboxAdapter
uploadAttachment presigns via crm.media.presignUpload + PUTs bytes to IIOS
storage; mailReply/composeInternal/composeExternal forward attachment refs
to crm.mail.reply/internal/send (server already supported them).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 01:05:14 +05:30
maaz519 facc50e82f feat(inbox): migrate CRM Inbox to @insignia/iios-messaging-ui SDK
Replace the bespoke in-CRM inbox with the SDK's <Inbox>, driven by a new
CrmInboxAdapter over the be-crm data door (crm.inbox.* + crm.mail.*).
Demo mode falls back to the SDK MockInboxAdapter. Maps --miu-* tokens for
.miu-inbox to the CRM theme.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 00:54:03 +05:30
maaz519 413f8d43b6 feat(messenger): channels + @mentions in the CRM SDK adapter
CrmMessagingAdapter now implements the full SDK contract:
- browseChannels/createChannel/leaveChannel over the be-crm channel door
  (crm.messenger.channel.*); joinChannel is a governed public self-join over
  the socket (openThread) — the BFF has no join verb, OPA enforces it
- listMembers over crm.messenger.members → drives @mention autocomplete
- send forwards mentions[] on the socket path (→ IIOS MENTION inbox items)

So the CRM messenger (rendered by the SDK) now gets sectioned Channels/DMs,
browse+join+create, and @mention autocomplete/highlight — no embedded code.
(SDK re-packed to the local tarball; publish for deploy.) tsc + next build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 23:35:41 +05:30
maaz519 cd8015ada8 feat(messenger): socket realtime in the CRM SDK adapter (parity with old messenger)
CrmMessagingAdapter is now hybrid: BFF for the conversation list, thread
creation, and directory (server-side tenancy); the IIOS MessageSocket
(delegated crm.messenger.realtime token) for everything live — history+join,
send, typing, read receipts, and reaction annotations. Falls back to the 4s
poll only when no socket is available.

- messenger-sdk useRealtimeSocket() opens the MessageSocket from the BFF token
  and passes it to the adapter (adapter rebuilds live once connected)
- socket path maps senderId (userId space) → actorId so 'mine'/seen work
  consistently, killing the actor-vs-user ambiguity in the poll fallback
- reaction annotations tracked per message and re-emitted as full sets

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 19:49:43 +05:30
maaz519 e70904a219 feat(messenger): CRM consumes @insignia/iios-messaging-ui instead of embedding
The messenger tab now renders the shared SDK's <Messenger>, not the bespoke
in-CRM implementation. The CRM only supplies transport + theme:

- CrmMessagingAdapter implements the SDK's MessagingAdapter over the be-crm
  data door (crm.messenger.conversation.list/open, history, send, directory);
  live updates via a 4s poll for now (socket realtime is a follow-up that
  reuses messenger-socket.tsx). Tenancy/auth stay server-side.
- messenger-sdk.tsx wires MessagingProvider + adapter (live = CrmMessagingAdapter,
  demo = the SDK MockAdapter), themed by mapping --miu-* tokens to the CRM
  design system.
- dashboard renders <MessengerSdk/>; the old messenger.tsx is left in place
  for rollback and will be deleted once this is proven live.

NOTE: the SDK is installed from a local tarball (file:) for verification since
it isn't published yet. For deploy: publish @insignia/iios-messaging-ui to the
registry and change the dep to a version range. transpilePackages covers ESM.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 19:37:47 +05:30
maaz519 036ed84255 feat(media): infer MIME from extension for empty-type uploads (.md, .html)
Browsers report an empty file.type for files with no OS-registered MIME (esp.
.md), which then went up as application/octet-stream and got rejected. Infer
the type from the extension for the text types IIOS allows (md/markdown/html/
htm/txt/csv) so those attachments upload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 14:29:21 +05:30
29 changed files with 1742 additions and 3206 deletions
@@ -0,0 +1,240 @@
# Messaging UI SDK — Design
**Date:** 2026-07-17
**Status:** Approved, pending implementation plan
## Problem
Messaging UI is rewritten from scratch in every app that needs it. `lynkeduppro-crm`
has a rich, working messenger — conversation list, thread view, bubbles, reactions,
reply threading, typing, read receipts — built as 352 lines in
`src/components/dashboard/messenger.tsx` over 371 lines in `src/lib/messenger-api.ts`.
None of it is reusable.
### Why not just use `@insignia/iios-message-web`?
Because it does not solve this problem, and the CRM already rejected it.
`iios-message-web` is 109 lines across 2 files. It is fully headless: its only JSX is
the context provider element itself. It exports `MessageProvider`, `useThread`,
`useMessages` and a `Message` type — nothing more. It ships no components, no CSS, no
theming.
It also guessed its API wrong. `useMessages.send` narrows the options bag to
`{ contentRef? }`, while the underlying `MessageSocket.sendMessage` accepts
`parentInteractionId`, `mentions`, and `attachment`. Threading, mentions and
attachments are unreachable through its public API. The socket is held in a
module-private context with no escape hatch. It has zero consumers outside the iios
repo, and its own docs reference a `useSendMessage` hook that does not exist.
The CRM consequently bypassed it and depends on `@insignia/iios-kernel-client`
directly.
**The lesson drives this design:** the headless layer is already an SDK
(`iios-kernel-client` — sockets, threads, receipts, typing, published, consumed). A
second headless package saves no app any work. The unsolved part is the UI.
### Why tower is not a consumer
Tower's messaging is a WhatsApp group ingest → moderate → forward pipeline, not chat.
There is no `Conversation` model; `Message` is a captured group post keyed by
`senderJid` + `sourceGroupId` with a moderation `status` enum
(`RAW/PENDING/APPROVED/...`) — no recipient, no delivery state. "Send" is a BullMQ job
rate-limited to 20 forwards/minute to avoid WhatsApp bans. There is no
socket.io/websocket/SSE in the browser anywhere in the repo. Its `threads` and
`drafts` mean different things than a chat SDK's would.
Tower would pay the abstraction cost for realtime machinery it never turns on. It is
explicitly out of scope.
## Constraint: one real consumer
`lynkeduppro-crm` is the only consumer. Genericity is not achievable by intent — it is
forced by a second consumer. This design therefore ports only what is already proven
in production and refuses to invent abstraction for imagined needs. `iios-message-web`
is the cautionary example of the opposite approach.
## Architecture
One package, `@insignia/messaging-ui`, published to the existing Gitea registry
(`https://git.lynkedup.cloud/api/packages/insignia/npm/`). React as a peer dependency.
```
@insignia/messaging-ui
. → components + provider + hooks
./styles.css → structural CSS + token defaults
./adapters/kernel → optional iios-kernel-client adapter
./adapters/mock → in-memory adapter for demos/tests
```
**The core has zero transport knowledge.** `iios-kernel-client` is reachable only via
the optional `./adapters/kernel` subpath, so an app on a different backend never pulls
socket code. This is the specific mistake `iios-message-web` made by welding itself to
`MessageSocket`.
This boundary is load-bearing for the actual consumer: the CRM does **not** talk to
iios directly. It routes messaging through be-crm's data door (`crm.messenger.*`) via
`@abe-kap/appshell-sdk`, socket-primary with a 4s REST poll fallback. An SDK that
hardcoded `iios-kernel-client` could not be adopted by the only app that wants it.
## The adapter contract
Lifted from the existing `MessengerData`/`ThreadData` interfaces in
`src/lib/messenger-api.ts`, which already survived two implementations (live + mock).
Two implementations is the minimum real evidence that a seam is genuine rather than
imagined. This contract was not designed for an SDK — it earned its shape.
```ts
interface MessagingAdapter {
listConversations(): Promise<Conversation[]>;
openThread(p: { participantIds: string[]; subject?: string }): Promise<{ threadId: string }>;
history(threadId: string): Promise<Message[]>;
send(threadId: string, content: string, opts?: SendOpts): Promise<Message>;
subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe;
sendTyping(threadId: string): void;
markRead(threadId: string, messageId: string): Promise<void>;
react?(messageId: string, emoji: string): Promise<void>;
upload?(file: File): Promise<{ url: string; mime: string; name: string }>;
currentActorId(): string | null;
}
interface SendOpts {
parentInteractionId?: string;
attachment?: { url: string; mime: string; name: string };
}
```
### Graceful degradation
`react` and `upload` are optional. When an adapter omits them the UI hides the
reaction picker or the attach button respectively. This is how one component set
serves both a full CRM messenger and a stripped-down widget without a `mode` prop.
### `currentActorId` fixes a live bug
Today the CRM infers the current actor id by scanning for a message you sent:
```ts
// src/lib/messenger-socket.tsx — current behaviour
const mine = socketMsgs.find((m) => m.mine && m.actorId);
if (mine?.actorId && mine.actorId !== myActorId) setMyActorId(mine.actorId);
```
Until you have sent a message in a thread, `myActorId` is `null`. Because the REST
poll fallback computes `mine: !!myActorId && m.actorId === myActorId`, **every message
renders as not-yours** in that state. The root cause is that the kernel's receipt
event carries no `threadId`, making it a global stream the CRM compensates for.
Making identity an explicit adapter responsibility eliminates this class of bug rather
than porting it. The two-tier socket/poll fallback stays in the adapter, not the SDK —
the CRM's adapter keeps its 4s poll; a socket-only app implements `subscribe` and
never polls.
## Components
Composable primitives plus one all-in-one for drop-in use:
```tsx
<MessagingProvider adapter={adapter}>
<Messenger onNewChat={openPicker} /> {/* all-in-one: list + thread */}
{/* ...or compose: */}
<ConversationList onSelect={setId} renderRow={custom} />
<ThreadView threadId={id} />
<Composer threadId={id} />
</MessagingProvider>
```
Hooks remain exported (`useConversations`, `useThread`, `useMessages`) so a host
wanting entirely custom UI can use the SDK headlessly. This makes `iios-message-web`'s
use case a strict subset of this package rather than a competitor.
### Explicitly out of scope
- **Inbox.** Coupled to iios semantics, not chat transport. Items are projected
server-side by iios from domain events (`MENTION`, `NEEDS_REPLY`, `SUPPORT_UPDATE`,
`CRM_OWNER_INTEREST`); authz is OPA policy. A chat SDK cannot own this.
- **People picker / directory.** Fed by `crm.messenger.directory`. "Who exists and who
may I message" is host and tenant territory. `<Messenger>` takes an `onNewChat`
callback; the host renders its own picker.
- **Presence.** No consumer needs it.
## Theming
Structural CSS with token defaults, overridden by the host. No Tailwind, no CSS-in-JS,
no build coupling — the CRM has no shadcn and near-zero Tailwind (its real styling is
1142 lines of hand-rolled `dashboard.css` plus inline style objects), so a
Tailwind-based SDK would force a restyle of the only consumer.
```css
:root {
--msg-font; --msg-radius; --msg-gap;
--msg-bubble-own-bg; --msg-bubble-other-bg;
--msg-accent; --msg-muted; --msg-surface; --msg-border;
}
```
Every component accepts `className`; `<Messenger>` accepts a `classNames` slot map for
per-part overrides. The CRM's existing `#6366f1 → #8b5cf6` group-avatar gradient
becomes a token value rather than a hardcode.
## Attachments
The SDK renders attachments (image thumbnail, file chip, download) and calls
`adapter.upload(file)`, passing the result into `send`. **Storage, auth, and
size/mime limits are host concerns** — baking in an upload target would break the next
app. The attach button is hidden when `upload` is absent.
`MessageSocket.sendMessage` already accepts an `attachment` field, so this exercises
an existing wire contract rather than inventing one. No consumer has exercised it yet;
the CRM has no file upload anywhere today.
## Data flow
1. Host constructs an adapter (CRM: wrapping appshell data door + socket).
2. `MessagingProvider` holds the adapter in context.
3. `useConversations` calls `listConversations`; `useMessages(threadId)` calls
`history` then `subscribe`.
4. `Composer` calls `send` with optimistic append; on rejection the optimistic message
is rolled back and the input text restored (matching current CRM behaviour).
5. `subscribe` events reconcile against optimistic state by message id.
## Error handling
- Adapter method rejection surfaces via hook `error` state; components render an
inline error affordance, never throw.
- Optimistic send failure restores composer text — the CRM's current behaviour, kept.
- `subscribe` disconnect is the adapter's problem, not the SDK's. The SDK renders a
`connected: boolean` from the adapter as a banner (the CRM's existing "Demo mode"
banner generalises to this).
## Validation
The migration is the validation. There is no second app, so the honest bar is:
1. Rewrite the CRM's `messenger.tsx` to consume the SDK; its data-door implementation
becomes `CrmMessagingAdapter`. **Success = identical behaviour with the 352-line
component deleted**, and the mock adapter preserving demo-mode fallback.
2. Then `support.tsx`'s `MessageCenter` — currently pure `setTimeout` theatre with no
backend — becomes a zero-risk second surface.
Two surfaces in one app is not a true second consumer. It is the best honest test
available of the adapter boundary, and it should be understood as such. **The design
should be revisited when a genuine second app appears** rather than treated as settled.
## Testing
- **Component tests against the mock adapter** — no network. This is the payoff of the
injected seam.
- **`CrmMessagingAdapter` tested against the contract** independently of UI.
- **A shared adapter conformance suite** any adapter can run, so the kernel and CRM
adapters are verified against one definition of correct.
- Explicit regression test for the `currentActorId` bug: messages render as own before
the user has sent anything in the thread.
## Open questions
- Package name: `@insignia/messaging-ui` assumed, not confirmed.
- Whether `CrmMessagingAdapter` lives in the CRM repo or ships as
`./adapters/crm`. Preference: the CRM repo — it depends on appshell-sdk, which the
SDK must not.
+2 -2
View File
@@ -1,8 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// The SDK ships ESM/TS; let Next transpile it.
transpilePackages: ["@abe-kap/appshell-sdk"],
// The SDKs ship ESM; let Next transpile them.
transpilePackages: ["@abe-kap/appshell-sdk", "@insignia/iios-messaging-ui"],
// The browser calls the Shell BFF same-origin under /shell (so the HttpOnly
// session cookie flows). We deliberately use /shell (NOT /api) to avoid
// clobbering the existing /api/geo route. Point BFF_ORIGIN at the deployed BFF.
+16
View File
@@ -10,6 +10,7 @@
"dependencies": {
"@abe-kap/appshell-sdk": "^0.2.6",
"@insignia/iios-kernel-client": "^0.1.4",
"@insignia/iios-messaging-ui": "^0.1.2",
"clsx": "^2.1.1",
"lucide-react": "^1.21.0",
"next": "16.2.9",
@@ -1026,6 +1027,21 @@
"socket.io-client": "^4.8.1"
}
},
"node_modules/@insignia/iios-messaging-ui": {
"version": "0.1.2",
"resolved": "https://git.lynkedup.cloud/api/packages/insignia/npm/%40insignia%2Fiios-messaging-ui/-/0.1.2/iios-messaging-ui-0.1.2.tgz",
"integrity": "sha512-YsoTM9vmjQ+dk6Hch5nXuqMFAdybd3luAZepfaliOw727ylc8FPAtwTMMcIWQ3WOQPa2eKeGLiEpo61QCO0GrA==",
"peerDependencies": {
"@insignia/iios-kernel-client": "*",
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"@insignia/iios-kernel-client": {
"optional": true
}
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+1
View File
@@ -11,6 +11,7 @@
"dependencies": {
"@abe-kap/appshell-sdk": "^0.2.6",
"@insignia/iios-kernel-client": "^0.1.4",
"@insignia/iios-messaging-ui": "^0.1.2",
"clsx": "^2.1.1",
"lucide-react": "^1.21.0",
"next": "16.2.9",
+69 -243
View File
@@ -133,6 +133,21 @@
.dash-content { padding: 22px 28px 40px; width: 100%; }
.sec-title { font-size: 15px; font-weight: 700; margin: 6px 0 14px; }
/* Host for @insignia/iios-messaging-ui: a fixed-height card that maps the SDK's --miu-* tokens
onto the CRM design system, so the drop-in SDK matches the rest of the app. */
.dash-root .miu-host { height: 620px; border: 1px solid var(--border); border-radius: 16px; overflow: hidden; }
.dash-root .miu-host .miu-messenger,
.dash-root .miu-host .miu-inbox {
--miu-bg: var(--bg);
--miu-panel: var(--panel);
--miu-panel-2: var(--panel-2);
--miu-border: var(--border);
--miu-text: var(--text);
--miu-muted: var(--muted);
--miu-accent: var(--orange);
--miu-accent-text: #1a1206;
}
/* ---- grid helpers ---- */
.grid { display: grid; gap: 16px; }
@@ -432,8 +447,10 @@
.dash-root .ds-modal.size-sm { max-width: 400px; }
.dash-root .ds-modal.size-md { max-width: 540px; }
.dash-root .ds-modal.size-lg { max-width: 760px; }
.dash-root .ds-modal.size-xl { max-width: 980px; }
.dash-root .ds-modal-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; padding: 20px 20px 14px; border-bottom: 1px solid var(--border); }
.dash-root .ds-modal-head-l { display: flex; gap: 12px; align-items: center; }
.dash-root .ds-modal-head-r { display: flex; gap: 8px; align-items: center; flex: 0 0 auto; }
.dash-root .ds-modal-ic { width: 38px; height: 38px; border-radius: 11px; flex: 0 0 auto; display: grid; place-items: center; color: var(--orange); background: color-mix(in srgb, var(--orange) 14%, transparent); }
.dash-root .ds-modal-head h3 { font-size: 16px; font-weight: 700; }
.dash-root .ds-modal-head p { font-size: 12.5px; color: var(--muted); margin-top: 3px; }
@@ -907,7 +924,7 @@
.dash-root .tm .ds-btn.v-primary { background: rgba(253, 169, 19, 0.92); color: #fff; border-radius: 9.132px; box-shadow: 0 8px 20px -10px rgba(253, 169, 19, 0.75); }
.dash-root .tm .ds-btn.v-primary svg { color: #fff; }
.dash-root .tm .ds-btn.v-primary:not(:disabled):hover { background: rgba(253, 169, 19, 1); }
.dash-root .tm-toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.dash-root .tm-toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
.dash-root .tm-search { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 220px; height: 44px; padding: 0 13px; border-radius: 13px; border: 1px solid var(--border-2); background: var(--panel-2); color: var(--muted); transition: 0.14s; }
.dash-root .tm-search:focus-within { border-color: var(--orange); box-shadow: 0 0 0 3px var(--ring); color: var(--orange); }
.dash-root .tm-search .ds-input { flex: 1; }
@@ -1139,248 +1156,57 @@
.dash-root .ai-caps { grid-template-columns: 1fr; }
.dash-root .ai-bubble { max-width: 86%; }
.dash-root .ai-view { height: calc(100vh - 150px); }
.dash-root .proj-row { grid-template-columns: 1fr !important; row-gap: 8px; padding: 14px 16px; }
.dash-root .proj-head { display: none; }
.dash-root .proj-c-act { justify-self: end; }
}
/* ========================================================== */
/* Leads — pipeline board + rich detail popup */
/* ========================================================== */
/* ---- 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; }
/* ---- 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); }
}
/* ---- Org Settings → Integrations ---- */
.dash-root .settings-section { margin-top: 8px; }
.dash-root .settings-section-title { font-size: 13px; font-weight: 700; letter-spacing: 0.02em; text-transform: uppercase; color: var(--muted); margin: 0 0 14px; }
.dash-root .settings-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 16px; }
.dash-root .settings-card { border: 1px solid var(--border); background: var(--panel); border-radius: 16px; padding: 18px; display: flex; flex-direction: column; gap: 16px; }
.dash-root .settings-card.is-soon { opacity: 0.6; }
.dash-root .settings-card-head { display: flex; align-items: flex-start; gap: 12px; }
.dash-root .settings-card-ic { flex: 0 0 auto; width: 38px; height: 38px; display: grid; place-items: center; border-radius: 11px; background: color-mix(in srgb, var(--orange) 14%, transparent); color: var(--orange); }
.dash-root .settings-card-titles { flex: 1 1 auto; min-width: 0; }
.dash-root .settings-card-name { font-size: 15px; font-weight: 700; color: var(--text); }
.dash-root .settings-card-sub { font-weight: 500; color: var(--muted); }
.dash-root .settings-card-desc { font-size: 12.5px; color: var(--muted); margin-top: 2px; }
.dash-root .settings-card-body { display: flex; flex-direction: column; gap: 12px; }
.dash-root .settings-kv { display: grid; gap: 10px; margin: 0; }
.dash-root .settings-kv > div { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; border-bottom: 1px solid var(--border); padding-bottom: 8px; }
.dash-root .settings-kv > div:last-child { border-bottom: 0; padding-bottom: 0; }
.dash-root .settings-kv dt { font-size: 12.5px; color: var(--muted); }
.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-note { font-size: 12px; color: var(--muted); background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; padding: 8px 12px; }
+8 -8
View File
@@ -15,10 +15,10 @@ import { Support } from "./support";
import { Rules } from "./rules";
import { AiAssistant } from "./ai-assistant";
import { TeamManagement } from "./team-management";
import { Messenger } from "./messenger";
import { Inbox } from "./inbox";
import { Leads } from "./leads";
import { Verify } from "./verify";
import { MessengerSdk } from "./messenger-sdk";
import { InboxSdk } from "./inbox-sdk";
import { Settings } from "./settings";
import { Projects } from "./projects";
import "../../app/dashboard/dashboard.css";
export function Dashboard() {
@@ -49,11 +49,11 @@ export function Dashboard() {
: active === "support" ? <Support />
: active === "rules" ? <Rules />
: active === "ai" ? <AiAssistant />
: active === "messenger" ? <Messenger />
: active === "inbox" ? <Inbox />
: active === "leads" ? <Leads />
: active === "verify" ? <Verify />
: active === "messenger" ? <MessengerSdk />
: active === "inbox" ? <InboxSdk />
: active === "settings" ? <Settings />
: active === "team" ? <TeamManagement />
: active === "projects" ? <Projects />
: <ComingSoon title={title} icon={item?.icon ?? "dashboard"} onGo={setActive} />}
</ToastProvider>
</div>
+48
View File
@@ -0,0 +1,48 @@
"use client";
// The CRM Inbox, rendered by @insignia/iios-messaging-ui instead of the bespoke in-CRM inbox.
// Live = the be-crm data door (CrmInboxAdapter over crm.inbox.* + crm.mail.*); demo = the SDK's
// MockInboxAdapter.
import { useMemo } from "react";
import { useAppShell } from "@abe-kap/appshell-sdk/react";
import { InboxProvider, Inbox as SdkInbox, type InboxAdapter } from "@insignia/iios-messaging-ui";
import { MockInboxAdapter } from "@insignia/iios-messaging-ui/adapters/mock-inbox";
import "@insignia/iios-messaging-ui/styles.css";
import { isShellConfigured } from "@/lib/appshell";
import { CrmInboxAdapter } from "@/lib/crm-inbox-adapter";
import type { DataDoor } from "@/lib/crm-messaging-adapter";
const SHELL = isShellConfigured();
export function InboxSdk() {
return (
<div className="view">
{!SHELL && (
<div style={{ margin: "0 0 14px", padding: "8px 14px", borderRadius: 10, background: "var(--panel-2)", color: "var(--muted)", fontSize: 13, border: "1px solid var(--border)" }}>
Demo mode running on the SDK&apos;s mock inbox adapter.
</div>
)}
<div className="miu-host miu-host-inbox">{SHELL ? <LiveInbox /> : <DemoInbox />}</div>
</div>
);
}
function DemoInbox() {
const adapter = useMemo<InboxAdapter>(() => new MockInboxAdapter(), []);
return (
<InboxProvider adapter={adapter}>
<SdkInbox />
</InboxProvider>
);
}
function LiveInbox() {
const { sdk } = useAppShell();
const adapter = useMemo<InboxAdapter>(() => new CrmInboxAdapter(sdk as unknown as DataDoor), [sdk]);
return (
<InboxProvider adapter={adapter}>
<SdkInbox />
</InboxProvider>
);
}
-152
View File
@@ -1,152 +0,0 @@
"use client";
// ============================================================
// Inbox — the ONE unified communication surface. It lists everything
// IIOS surfaces for you (mentions, needs-reply, system alerts, support
// updates, …) AND the mail behind them: click an item tied to a thread
// and its conversation opens on the right to read + reply. Compose new
// mail from here too. Items come from crm.inbox.*; threads from crm.mail.*.
// ============================================================
import { useEffect, useState } from "react";
import { Btn, Icon, PageHead, Pill, useToast } from "./ui";
import { useInboxData, type InboxState, type UiInboxItem } from "@/lib/inbox-api";
import { MailReader, NewMailModal } from "./mail";
const KIND_LABEL: Record<string, string> = {
MAIL: "Mail",
MENTION: "Mention", NEEDS_REPLY: "Needs reply", NEEDS_REVIEW: "Needs review", NEEDS_APPROVAL: "Needs approval",
SUPPORT_UPDATE: "Support", MEETING_FOLLOWUP: "Meeting", DIGEST: "Digest", SYSTEM_ALERT: "Alert", CRM_OWNER_INTEREST: "Owner",
};
const FILTERS: { value: InboxState; label: string }[] = [
{ value: "OPEN", label: "Open" }, { value: "SNOOZED", label: "Snoozed" }, { value: "DONE", label: "Done" }, { value: "ARCHIVED", label: "Archived" },
];
export function Inbox() {
const [filter, setFilter] = useState<InboxState>("OPEN");
const inbox = useInboxData(filter);
const toast = useToast();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [newOpen, setNewOpen] = useState(false);
useEffect(() => {
if ((!selectedId || !inbox.items.some((i) => i.id === selectedId)) && inbox.items[0]) setSelectedId(inbox.items[0].id);
}, [inbox.items, selectedId]);
const selected = inbox.items.find((i) => i.id === selectedId) ?? null;
return (
<div className="view">
<PageHead
eyebrow="Communication" title="Inbox" subtitle="Mentions, messages, system alerts and mail — all in one place" icon="bell"
actions={<Btn icon="plus" onClick={() => setNewOpen(true)}>New mail</Btn>}
/>
{!inbox.live && (
<div style={{ margin: "0 0 14px", padding: "8px 14px", borderRadius: 10, background: "var(--panel-2)", color: "var(--muted)", fontSize: 13, border: "1px solid var(--border)" }}>
Demo mode running on mock data. It goes live once the Shell + be-crm are connected.
</div>
)}
<div style={{ display: "flex", gap: 6, marginBottom: 14, flexWrap: "wrap" }}>
{FILTERS.map((f) => (
<Btn key={f.value} variant={filter === f.value ? "primary" : "outline"} onClick={() => setFilter(f.value)}>{f.label}</Btn>
))}
</div>
<div className="card" style={{ display: "flex", height: 620, padding: 0, overflow: "hidden" }}>
{/* Left — the unified item list */}
<aside style={{ width: 360, borderRight: "1px solid var(--border)", overflowY: "auto", background: "var(--panel)" }}>
{inbox.loading && <div style={{ padding: 20, color: "var(--muted)" }}>Loading</div>}
{!inbox.loading && inbox.items.length === 0 && (
<div style={{ padding: 28, color: "var(--muted)", textAlign: "center" }}>Nothing here you&apos;re all caught up 🎉</div>
)}
{inbox.items.map((it) => (
<ItemRow key={it.id} it={it} active={it.id === selectedId} onClick={() => setSelectedId(it.id)} />
))}
</aside>
{/* Right — read the mail behind the item, or the item detail */}
<section style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0, background: "var(--bg)" }}>
{selected ? (
<Detail
it={selected}
onError={(m) => toast.push({ tone: "error", title: "Failed", desc: m })}
onDone={() => inbox.transition(selected.id, "DONE")}
onSnooze={() => inbox.transition(selected.id, "SNOOZED")}
onArchive={() => inbox.transition(selected.id, "ARCHIVED")}
/>
) : (
<div style={{ margin: "auto", color: "var(--muted)", textAlign: "center" }}>
<Icon name="bell" size={38} /><p>Select an item to read</p>
</div>
)}
</section>
</div>
<NewMailModal
open={newOpen} onClose={() => setNewOpen(false)}
onSent={() => { setNewOpen(false); inbox.refetch(); toast.push({ tone: "success", title: "Sent" }); }}
onError={(m) => toast.push({ tone: "error", title: "Couldn't send", desc: m })}
/>
</div>
);
}
function ItemRow({ it, active, onClick }: { it: UiInboxItem; active: boolean; onClick: () => void }) {
const isMention = it.kind === "MENTION";
return (
<button
onClick={onClick}
style={{
display: "flex", gap: 10, alignItems: "flex-start", width: "100%", textAlign: "left",
padding: "13px 16px", border: "none", borderBottom: "1px solid var(--border)", cursor: "pointer",
background: active ? "var(--panel-2)" : "transparent", color: "var(--text)",
}}
>
<span style={{ marginTop: 2, color: isMention ? "var(--orange)" : "var(--text-2)", flexShrink: 0 }}>
<Icon name={it.kind === "MAIL" ? "mail" : it.threadId ? "chat" : isMention ? "chat" : "bell"} size={18} />
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}>
<Pill tone={isMention ? "warn" : "muted"}>{KIND_LABEL[it.kind] ?? it.kind}</Pill>
<span style={{ fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{it.title}</span>
</div>
{it.summary && <div style={{ color: "var(--muted)", fontSize: 12.5, marginTop: 3, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{it.summary}</div>}
</div>
{it.state !== "OPEN" && <Pill tone="muted">{it.state.toLowerCase()}</Pill>}
</button>
);
}
function Detail({ it, onError, onDone, onSnooze, onArchive }: {
it: UiInboxItem; onError: (m: string) => void; onDone: () => void; onSnooze: () => void; onArchive: () => void;
}) {
return (
<>
{/* Item actions bar only for real inbox work-items. Mail isn't an inbox item
(no crm.inbox.transition), so it gets read/reply only, no Done/Snooze/Archive. */}
{it.state === "OPEN" && it.kind !== "MAIL" && (
<div style={{ display: "flex", gap: 6, padding: "10px 16px", borderBottom: "1px solid var(--border)", justifyContent: "flex-end" }}>
<Btn variant="ghost" icon="clock" onClick={onSnooze}>Snooze</Btn>
<Btn variant="outline" icon="check" onClick={onDone}>Done</Btn>
<Btn variant="ghost" icon="x" onClick={onArchive}>Archive</Btn>
</div>
)}
{it.threadId ? (
// A message/mail item → open the conversation to read + reply.
// key by threadId: the SDK's useQuery only refetches when the ACTION changes, not the
// variables — so switching items must remount MailReader to load the new thread's history.
<div style={{ flex: 1, minHeight: 0 }}>
<MailReader key={it.threadId} threadId={it.threadId} subject={it.title} onError={onError} />
</div>
) : (
// A non-threaded item (e.g. a system alert) → show its detail.
<div style={{ flex: 1, overflowY: "auto", padding: 22 }}>
<div style={{ fontWeight: 700, fontSize: 16, marginBottom: 6 }}>{it.title}</div>
{it.summary && <div style={{ color: "var(--muted)", fontSize: 14, lineHeight: 1.55 }}>{it.summary}</div>}
</div>
)}
</>
);
}
-220
View File
@@ -1,220 +0,0 @@
// ============================================================
// 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
@@ -1,615 +0,0 @@
"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>
);
}
-231
View File
@@ -1,231 +0,0 @@
"use client";
// ============================================================
// Mail components used INSIDE the Inbox (not a separate tab).
// The Inbox is the one unified surface — mentions, system messages
// and mail all live there. These render the mail body + reply, and
// compose a new message. HTML bodies render in a sandboxed iframe.
// ============================================================
import { type CSSProperties, useEffect, useRef, useState } from "react";
import { Avatar, Btn, Field, Icon, Modal, Pill } from "./ui";
import { useMailThread, useMailCompose, type MailAttachment, type MailPerson } from "@/lib/mail-api";
import { useUploadAttachment, useDownloadUrl, isImage, type UploadedAttachment } from "@/lib/media-api";
const timeOf = (iso?: string) => {
if (!iso) return "";
const d = new Date(iso);
return Number.isNaN(+d) ? "" : d.toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" });
};
const CUSTOMER_GRAD = "linear-gradient(135deg,#10b981,#059669)";
const inputStyle: CSSProperties = {
width: "100%", padding: "9px 12px", borderRadius: 10, border: "1px solid var(--border)",
background: "var(--panel)", color: "var(--text)", fontSize: 14, outline: "none",
};
function fmtBytes(n: number): string {
if (!n) return "";
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
/** Resolves a signed URL for a stored attachment and renders it inline (image) or as a file chip. */
function MailAttachmentView({ att }: { att: MailAttachment }) {
const getUrl = useDownloadUrl();
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
let alive = true;
getUrl(att.contentRef, att.mimeType).then((u) => { if (alive) setUrl(u); }).catch(() => {});
return () => { alive = false; };
}, [att.contentRef, att.mimeType, getUrl]);
const label = att.filename || "Attachment";
if (isImage(att.mimeType)) {
return url
? <a href={url} target="_blank" rel="noreferrer" style={{ display: "inline-block" }}><img src={url} alt={label} style={{ maxWidth: 320, maxHeight: 240, borderRadius: 8, border: "1px solid var(--border)" }} /></a>
: <div style={{ color: "var(--muted)", fontSize: 13 }}>Loading image</div>;
}
return (
<a href={url ?? "#"} target="_blank" rel="noreferrer"
style={{ display: "inline-flex", alignItems: "center", gap: 8, padding: "8px 12px", borderRadius: 10, border: "1px solid var(--border)", background: "var(--panel-2)", color: "var(--text)", textDecoration: "none", maxWidth: 320 }}>
<Icon name="paperclip" size={18} />
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{label}</span>
{att.sizeBytes > 0 && <span style={{ color: "var(--muted)", fontSize: 12 }}>{fmtBytes(att.sizeBytes)}</span>}
</a>
);
}
/** A small staged-file chip shown in a composer before send, with a remove button. */
function StagedChip({ file, onRemove }: { file: UploadedAttachment; onRemove: () => void }) {
return (
<div style={{ display: "inline-flex", alignItems: "center", gap: 8, padding: "5px 10px", borderRadius: 999, background: "var(--panel-2)", border: "1px solid var(--border)", fontSize: 13 }}>
<Icon name="paperclip" size={14} />
<span style={{ maxWidth: 160, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{file.filename}</span>
<span style={{ color: "var(--muted)" }}>{fmtBytes(file.sizeBytes)}</span>
<button onClick={onRemove} title="Remove" style={{ background: "none", border: "none", color: "var(--muted)", cursor: "pointer", padding: 0, lineHeight: 1 }}></button>
</div>
);
}
/** Reader + reply for one mail thread. Used in the Inbox detail pane when an item has a threadId. */
export function MailReader({ threadId, subject, onError }: { threadId: string; subject: string; onError: (m: string) => void }) {
const t = useMailThread(threadId);
const upload = useUploadAttachment();
const [draft, setDraft] = useState("");
const [sending, setSending] = useState(false);
const [staged, setStaged] = useState<UploadedAttachment | null>(null);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
async function onPickFile(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setUploading(true);
try { setStaged(await upload(file)); }
catch (err) { onError((err as Error).message); }
finally { setUploading(false); }
}
async function reply() {
const text = draft.trim();
if ((!text && !staged) || sending) return;
const att = staged ?? undefined;
setDraft(""); setStaged(null); setSending(true);
try { await t.reply(text, att); }
catch (e) { setDraft(text); setStaged(att ?? null); onError((e as Error).message); }
finally { setSending(false); }
}
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }}>
<header style={{ padding: "12px 18px", borderBottom: "1px solid var(--border)" }}>
<div style={{ fontWeight: 700, fontSize: 15 }}>{subject || "(no subject)"}</div>
</header>
<div style={{ flex: 1, overflowY: "auto", padding: 16, display: "flex", flexDirection: "column", gap: 12 }}>
{t.loading && t.messages.length === 0 && <div style={{ margin: "auto", color: "var(--muted)" }}>Loading</div>}
{!t.loading && t.messages.length === 0 && <div style={{ margin: "auto", color: "var(--muted)" }}>No messages.</div>}
{t.messages.map((m) => (
<div key={m.interactionId} style={{ border: "1px solid var(--border)", borderRadius: 12, background: "var(--panel)", overflow: "hidden" }}>
<div style={{ padding: "7px 12px", borderBottom: "1px solid var(--border)", display: "flex", justifyContent: "space-between", fontSize: 12, color: "var(--muted)" }}>
<span>{m.kind === "EMAIL" ? "Email" : "Reply"}{m.actorId ? ` · ${m.actorId.replace(/^(pp_|cust_)/, "").slice(0, 8)}` : ""}</span>
<span>{timeOf(m.occurredAt)}</span>
</div>
{m.html
? <iframe sandbox="" srcDoc={m.html} title="mail body" style={{ width: "100%", height: 200, border: "none", background: "#fff" }} />
: m.text
? <div style={{ padding: 12, whiteSpace: "pre-wrap", wordBreak: "break-word", fontSize: 14 }}>{m.text}</div>
: null}
{m.attachment && <div style={{ padding: 12, paddingTop: m.html || m.text ? 0 : 12 }}><MailAttachmentView att={m.attachment} /></div>}
</div>
))}
</div>
<footer style={{ display: "flex", flexDirection: "column", gap: 8, padding: 12, borderTop: "1px solid var(--border)" }}>
{staged && <div><StagedChip file={staged} onRemove={() => setStaged(null)} /></div>}
<div style={{ display: "flex", gap: 8 }}>
<input ref={fileRef} type="file" style={{ display: "none" }} onChange={onPickFile} />
<Btn variant="ghost" icon="paperclip" onClick={() => fileRef.current?.click()} disabled={uploading}>{uploading ? "…" : ""}</Btn>
<input
value={draft} onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void reply(); } }}
placeholder="Reply…" style={inputStyle}
/>
<Btn icon="send" onClick={() => void reply()} disabled={sending || (!draft.trim() && !staged)}>Reply</Btn>
</div>
</footer>
</div>
);
}
/** Compose a new message — in-app (to a person) or external (to an email). */
export function NewMailModal({ open, onClose, onSent, onError }: { open: boolean; onClose: () => void; onSent: () => void; onError: (m: string) => void }) {
const compose = useMailCompose(onSent);
const upload = useUploadAttachment();
const [mode, setMode] = useState<"internal" | "external">("internal");
const [recipient, setRecipient] = useState("");
const [subject, setSubject] = useState("");
const [body, setBody] = useState("");
const [q, setQ] = useState("");
const [busy, setBusy] = useState(false);
const [staged, setStaged] = useState<UploadedAttachment[]>([]);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
useEffect(() => { if (!open) { setMode("internal"); setRecipient(""); setSubject(""); setBody(""); setQ(""); setBusy(false); setStaged([]); setUploading(false); } }, [open]);
async function onPickFile(e: React.ChangeEvent<HTMLInputElement>) {
const files = Array.from(e.target.files ?? []);
e.target.value = "";
if (!files.length) return;
setUploading(true);
try {
const uploaded = await Promise.all(files.map((f) => upload(f)));
setStaged((s) => [...s, ...uploaded].slice(0, 10));
} catch (err) { onError((err as Error).message); }
finally { setUploading(false); }
}
const filtered = compose.directory.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase()));
const canSend = !!recipient && !!subject.trim() && !!body.trim() && !busy && !uploading;
async function send() {
if (!canSend) return;
setBusy(true);
try {
if (mode === "internal") await compose.sendInternal(recipient, subject.trim(), body.trim(), staged.length ? staged : undefined);
else await compose.sendExternal(recipient.trim(), subject.trim(), body.trim(), staged.length ? { attachments: staged } : undefined);
} catch (e) { onError((e as Error).message); }
finally { setBusy(false); }
}
return (
<Modal
open={open} onClose={onClose} title="New message" subtitle={mode === "internal" ? "To a team member or client (in-app)" : "To an email address"} icon="chat"
footer={<>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn icon="send" onClick={() => void send()} disabled={!canSend}>{busy ? "Sending…" : "Send"}</Btn>
</>}
>
<div style={{ display: "flex", gap: 6, marginBottom: 12 }}>
<Btn variant={mode === "internal" ? "primary" : "outline"} onClick={() => { setMode("internal"); setRecipient(""); }}>In-app</Btn>
<Btn variant={mode === "external" ? "primary" : "outline"} onClick={() => { setMode("external"); setRecipient(""); }}>Email</Btn>
</div>
{mode === "internal" ? (
<Field label="To (person)">
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" style={{ ...inputStyle, marginBottom: 8 }} />
<div style={{ maxHeight: 180, overflowY: "auto", display: "flex", flexDirection: "column", gap: 2 }}>
{filtered.length === 0 && <div style={{ color: "var(--muted)", padding: 8 }}>No people found.</div>}
{filtered.map((p: MailPerson) => (
<label key={p.id} style={{ display: "flex", alignItems: "center", gap: 10, padding: "7px 10px", borderRadius: 10, cursor: "pointer", background: recipient === p.id ? "var(--panel-2)" : "transparent" }}>
<input type="radio" checked={recipient === p.id} onChange={() => setRecipient(p.id)} />
<Avatar initials={(p.name.split(/\s+/).map((s) => s[0]).join("").slice(0, 2) || "?").toUpperCase()} size={26} gradient={p.kind === "customer" ? CUSTOMER_GRAD : undefined} />
<span style={{ flex: 1 }}>{p.name}</span>
<Pill tone="muted">{p.kind}</Pill>
</label>
))}
</div>
</Field>
) : (
<Field label="To (email)">
<input value={recipient} onChange={(e) => setRecipient(e.target.value)} placeholder="name@company.com" style={inputStyle} />
</Field>
)}
<Field label="Subject"><input value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="Subject" style={inputStyle} /></Field>
<Field label="Message"><textarea value={body} onChange={(e) => setBody(e.target.value)} placeholder="Write your message…" rows={6} style={{ ...inputStyle, resize: "vertical" }} /></Field>
{/* NOT a <Field> (which is a <label>): a label wrapping the file input would hijack the
Attach button's click via labelinput association and open the picker erratically. */}
<div className="ds-field">
<span className="ds-field-lbl">Attachments</span>
<input ref={fileRef} type="file" multiple style={{ display: "none" }} onChange={onPickFile} />
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, alignItems: "center" }}>
<Btn variant="outline" icon="paperclip" onClick={() => fileRef.current?.click()} disabled={uploading || staged.length >= 10}>{uploading ? "Uploading…" : "Attach"}</Btn>
{staged.map((f, i) => <StagedChip key={`${f.contentRef}_${i}`} file={f} onRemove={() => setStaged((s) => s.filter((_, j) => j !== i))} />)}
</div>
</div>
</Modal>
);
}
@@ -0,0 +1,89 @@
"use client";
// The CRM messenger, now rendered by the shared @insignia/iios-messaging-ui SDK instead of a
// bespoke in-CRM implementation. The CRM only supplies an adapter (transport) + theming; all the
// UI + messaging logic lives in the SDK. Live path = the be-crm data door (CrmMessagingAdapter);
// demo path = the SDK's own MockAdapter.
import { useEffect, useMemo, useState } from "react";
import { useAppShell, useAuth, useQuery } 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 { MockAdapter } from "@insignia/iios-messaging-ui/adapters/mock";
import "@insignia/iios-messaging-ui/styles.css";
import { isShellConfigured } from "@/lib/appshell";
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();
export function MessengerSdk() {
return (
<div className="view">
{!SHELL && (
<div
style={{
margin: "0 0 14px",
padding: "8px 14px",
borderRadius: 10,
background: "var(--panel-2)",
color: "var(--muted)",
fontSize: 13,
border: "1px solid var(--border)",
}}
>
Demo mode running on the SDK&apos;s mock adapter.
</div>
)}
<div className="miu-host">{SHELL ? <LiveHost /> : <DemoHost />}</div>
</div>
);
}
// 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).
function DemoHost() {
const adapter = useMemo<MessagingAdapter>(() => new MockAdapter(), []);
return (
<MessagingProvider adapter={adapter}>
<SdkMessenger />
</MessagingProvider>
);
}
function LiveHost() {
const { sdk } = useAppShell();
const { user } = useAuth();
const socket = useRealtimeSocket();
// Rebuilds once the socket connects: the first adapter (no socket) polls; the second runs live.
const adapter = useMemo<MessagingAdapter | null>(
() => (user?.id ? new CrmMessagingAdapter(sdk as unknown as DataDoor, user.id, socket ?? undefined) : null),
[sdk, user?.id, socket],
);
if (!adapter) return <div className="miu-empty">Loading</div>;
return (
<MessagingProvider adapter={adapter}>
<SdkMessenger />
</MessagingProvider>
);
}
-550
View File
@@ -1,550 +0,0 @@
"use client";
// ============================================================
// Messenger — internal team + client chat, powered by IIOS via
// the be-crm data door (crm.messenger.*). Conversation list ⇄
// thread view + composer, with a "new chat" people picker that
// creates a DM (1 person) or group (2+). DM-vs-group and who-can-
// chat are enforced server-side by IIOS/OPA; this is just UI.
// Live messages, typing, read receipts and reactions come over the
// IIOS socket (Shell mode); mock keeps the demo working offline.
// ============================================================
import { type CSSProperties, useEffect, useMemo, useRef, useState } from "react";
import { Avatar, Btn, Field, Icon, Modal, PageHead, Pill, useToast } from "./ui";
import { useMessengerData, useThread, useGroupSettings, type Membership, type UiAttachment, type UiConversation, type UiMember, type UiMessage, type UiPerson } from "@/lib/messenger-api";
import { MessengerSocketProvider, useMessengerSocket } from "@/lib/messenger-socket";
import { useUploadAttachment, useDownloadUrl, isImage, type UploadedAttachment } from "@/lib/media-api";
const fmtBytes = (n: number) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1048576).toFixed(1)} MB`);
/** Renders a message attachment — an inline image thumbnail, or a downloadable file chip. */
function AttachmentView({ att }: { att: UiAttachment }) {
const getUrl = useDownloadUrl();
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
let alive = true;
getUrl(att.contentRef, att.mimeType).then((u) => { if (alive) setUrl(u); }).catch(() => {});
return () => { alive = false; };
}, [att.contentRef, att.mimeType, getUrl]);
if (isImage(att.mimeType)) {
return url ? (
// eslint-disable-next-line @next/next/no-img-element
<a href={url} target="_blank" rel="noreferrer"><img src={url} alt="attachment" style={{ maxWidth: 240, maxHeight: 240, borderRadius: 10, display: "block", marginTop: 6, border: "1px solid var(--border)" }} /></a>
) : <div style={{ marginTop: 6, color: "var(--muted)", fontSize: 12 }}>Loading image</div>;
}
return (
<a href={url ?? "#"} target={url ? "_blank" : undefined} rel="noreferrer"
style={{ display: "flex", alignItems: "center", gap: 8, marginTop: 6, padding: "8px 12px", borderRadius: 10, background: "var(--panel-2)", border: "1px solid var(--border)", textDecoration: "none", color: "var(--text)", maxWidth: 240 }}>
<Icon name="paperclip" size={18} />
<span style={{ flex: 1, minWidth: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", fontSize: 13 }}>Attachment</span>
<span style={{ color: "var(--muted)", fontSize: 12, flexShrink: 0 }}>{fmtBytes(att.sizeBytes)}</span>
</a>
);
}
const initialsOf = (name: string) =>
name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase() || "?";
const timeOf = (iso?: string) => {
if (!iso) return "";
const d = new Date(iso);
return Number.isNaN(+d) ? "" : d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
};
const GROUP_GRAD = "linear-gradient(135deg,#6366f1,#8b5cf6)";
const CUSTOMER_GRAD = "linear-gradient(135deg,#10b981,#059669)";
const REACTION_EMOJIS = ["👍", "❤️", "😂", "😮", "😢", "🎉"];
const inputStyle: CSSProperties = {
width: "100%", padding: "9px 12px", borderRadius: 10, border: "1px solid var(--border)",
background: "var(--panel)", color: "var(--text)", fontSize: 14, outline: "none",
};
export function Messenger() {
// One shared IIOS socket for the whole panel (live in Shell mode; no-op in mock).
return (
<MessengerSocketProvider>
<MessengerPanel />
</MessengerSocketProvider>
);
}
function MessengerPanel() {
const m = useMessengerData();
const toast = useToast();
const [selected, setSelected] = useState<string | null>(null);
const [newOpen, setNewOpen] = useState(false);
useEffect(() => {
if ((!selected || !m.conversations.some((c) => c.threadId === selected)) && m.conversations[0]) {
setSelected(m.conversations[0].threadId);
}
}, [m.conversations, selected]);
const current = m.conversations.find((c) => c.threadId === selected) ?? null;
return (
<div className="view">
<PageHead
eyebrow="Communication" title="Messenger" subtitle="Chat with your team and clients — direct or in groups" icon="send"
actions={<Btn icon="plus" onClick={() => setNewOpen(true)}>New chat</Btn>}
/>
{!m.live && (
<div style={{ margin: "0 0 14px", padding: "8px 14px", borderRadius: 10, background: "var(--panel-2)", color: "var(--muted)", fontSize: 13, border: "1px solid var(--border)" }}>
Demo mode running on mock data. It goes live once the Shell + be-crm are connected.
</div>
)}
<div className="card" style={{ display: "flex", height: 620, padding: 0, overflow: "hidden" }}>
<aside style={{ width: 296, borderRight: "1px solid var(--border)", overflowY: "auto", background: "var(--panel)" }}>
{m.loading && <div style={{ padding: 16, color: "var(--muted)" }}>Loading</div>}
{!m.loading && m.conversations.length === 0 && (
<div style={{ padding: 16, color: "var(--muted)" }}>No conversations yet. Start a new chat.</div>
)}
{m.conversations.map((c) => (
<ConversationRow key={c.threadId} c={c} active={c.threadId === selected} onClick={() => setSelected(c.threadId)} />
))}
</aside>
<section style={{ flex: 1, display: "flex", flexDirection: "column", minWidth: 0 }}>
{current ? (
<ThreadView key={current.threadId} conv={current} nameOf={m.nameOf} directory={m.directory} onError={(msg) => toast.push({ tone: "error", title: "Message failed", desc: msg })} />
) : (
<div style={{ margin: "auto", color: "var(--muted)", textAlign: "center" }}>
<Icon name="send" size={38} />
<p>Select or start a conversation</p>
</div>
)}
</section>
</div>
<NewChatModal
open={newOpen} onClose={() => setNewOpen(false)} directory={m.directory}
onCreate={async (ids, opts) => {
try {
const id = await m.openConversation(ids, opts);
setSelected(id);
setNewOpen(false);
} catch (e) {
toast.push({ tone: "error", title: "Couldn't start chat", desc: (e as Error).message });
}
}}
/>
</div>
);
}
function ConversationRow({ c, active, onClick }: { c: UiConversation; active: boolean; onClick: () => void }) {
return (
<button
onClick={onClick}
style={{
display: "flex", gap: 10, alignItems: "center", width: "100%", textAlign: "left",
padding: "10px 14px", border: "none", borderBottom: "1px solid var(--border)", cursor: "pointer",
background: active ? "var(--panel-2)" : "transparent", color: "var(--text)",
}}
>
<Avatar initials={initialsOf(c.title)} size={38} gradient={c.membership === "group" ? GROUP_GRAD : undefined} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 8, alignItems: "center" }}>
<span style={{ fontWeight: 600, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{c.title}</span>
<span style={{ color: "var(--muted)", fontSize: 11, flexShrink: 0 }}>{timeOf(c.lastAt)}</span>
</div>
<div style={{ display: "flex", justifyContent: "space-between", gap: 8, alignItems: "center" }}>
<span style={{ color: "var(--muted)", fontSize: 12.5, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
{c.lastMessage ?? "No messages yet"}
</span>
{c.unread > 0 && (
<span style={{ background: "var(--orange)", color: "#fff", borderRadius: 999, fontSize: 11, padding: "1px 7px", flexShrink: 0 }}>{c.unread}</span>
)}
</div>
</div>
</button>
);
}
function ThreadView({ conv, nameOf, directory, onError }: { conv: UiConversation; nameOf: (id: string) => string; directory: UiPerson[]; onError: (m: string) => void }) {
const t = useThread(conv.threadId);
const socket = useMessengerSocket();
const [draft, setDraft] = useState("");
const [sending, setSending] = useState(false);
const [replyTo, setReplyTo] = useState<UiMessage | null>(null);
const [flashId, setFlashId] = useState<string | null>(null);
const endRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const msgRefs = useRef<Map<string, HTMLElement>>(new Map());
const typingSentAt = useRef(0);
useEffect(() => { endRef.current?.scrollIntoView({ behavior: "smooth" }); }, [t.messages.length]);
const byId = useMemo(() => Object.fromEntries(t.messages.map((m) => [m.id, m])), [t.messages]);
const lastMineId = useMemo(() => [...t.messages].reverse().find((m) => m.mine)?.id ?? null, [t.messages]);
// Reply → focus the composer (bug: it didn't focus, forcing a manual click).
function startReply(msg: UiMessage) {
setReplyTo(msg);
requestAnimationFrame(() => inputRef.current?.focus());
}
// Click a quoted message → scroll to the original and flash it.
function jumpTo(id: string) {
const el = msgRefs.current.get(id);
if (!el) return;
el.scrollIntoView({ behavior: "smooth", block: "center" });
setFlashId(id);
setTimeout(() => setFlashId((f) => (f === id ? null : f)), 1200);
}
const uploadAttachment = useUploadAttachment();
const [staged, setStaged] = useState<UploadedAttachment | null>(null);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
function onDraftChange(v: string) {
setDraft(v);
const now = Date.now();
if (socket && now - typingSentAt.current > 2000) { socket.sendTyping(conv.threadId); typingSentAt.current = now; }
}
async function onPickFile(file: File | undefined) {
if (!file) return;
setUploading(true);
try { setStaged(await uploadAttachment(file)); }
catch (e) { onError((e as Error).message); }
finally { setUploading(false); if (fileRef.current) fileRef.current.value = ""; }
}
async function submit() {
const text = draft.trim();
if ((!text && !staged) || sending) return; // allow an attachment with no text
const parent = replyTo?.id;
const att = staged;
setDraft(""); setReplyTo(null); setStaged(null); setSending(true);
try {
await t.send(text, {
...(parent ? { parentInteractionId: parent } : {}),
...(att ? { attachment: { contentRef: att.contentRef, mimeType: att.mimeType, sizeBytes: att.sizeBytes } } : {}),
});
} catch (e) { setDraft(text); setStaged(att); onError((e as Error).message); }
finally { setSending(false); }
}
const [settingsOpen, setSettingsOpen] = useState(false);
const typingLabel = t.typingUserIds.length === 1
? `${nameOf(t.typingUserIds[0])} is typing…`
: t.typingUserIds.length > 1 ? "Several people are typing…" : "";
return (
<>
<header style={{ display: "flex", alignItems: "center", gap: 10, padding: "12px 18px", borderBottom: "1px solid var(--border)" }}>
<Avatar initials={initialsOf(conv.title)} size={34} gradient={conv.membership === "group" ? GROUP_GRAD : undefined} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 600 }}>{conv.title}</div>
<div style={{ color: "var(--muted)", fontSize: 12 }}>
{conv.membership === "group" ? `${conv.participants.length} people` : "Direct message"}
</div>
</div>
{conv.membership === "group" && (
<button onClick={() => setSettingsOpen(true)} title="Group settings" style={{ ...actionBtnStyle, width: 34, height: 34 }}>
<Icon name="settings" size={18} />
</button>
)}
</header>
{conv.membership === "group" && settingsOpen && (
<GroupSettingsModal conv={conv} directory={directory} onClose={() => setSettingsOpen(false)} onError={onError} />
)}
<div style={{ flex: 1, overflowY: "auto", padding: 18, display: "flex", flexDirection: "column", gap: 10, background: "var(--bg)" }}>
{t.loading && t.messages.length === 0 && <div style={{ margin: "auto", color: "var(--muted)" }}>Loading messages</div>}
{!t.loading && t.messages.length === 0 && <div style={{ margin: "auto", color: "var(--muted)" }}>No messages yet say hello 👋</div>}
{t.messages.map((msg) => (
<MessageBubble
key={msg.id} msg={msg}
parent={msg.parentInteractionId ? byId[msg.parentInteractionId] : undefined}
seen={msg.id === lastMineId && t.seenIds.has(msg.id)}
showStatus={msg.id === lastMineId}
flash={flashId === msg.id}
registerRef={(el) => { if (el) msgRefs.current.set(msg.id, el); else msgRefs.current.delete(msg.id); }}
onReact={(emoji) => t.react(msg.id, emoji)}
onReply={() => startReply(msg)}
onQuoteClick={jumpTo}
/>
))}
<div ref={endRef} />
</div>
<div style={{ minHeight: 18, padding: "0 18px", color: "var(--muted)", fontSize: 12, fontStyle: "italic" }}>{typingLabel}</div>
{replyTo && (
<div style={{ display: "flex", alignItems: "center", gap: 10, margin: "0 14px", padding: "8px 12px", borderRadius: 10, background: "var(--panel-2)", borderLeft: "3px solid var(--orange)" }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 11, color: "var(--orange)", fontWeight: 600 }}>Replying to {replyTo.mine ? "yourself" : nameOf(replyTo.senderId ?? "")}</div>
<div style={{ fontSize: 12.5, color: "var(--muted)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>{replyTo.text}</div>
</div>
<button onClick={() => setReplyTo(null)} style={{ background: "none", border: "none", color: "var(--muted)", cursor: "pointer", fontSize: 16 }} aria-label="Cancel reply">×</button>
</div>
)}
{staged && (
<div style={{ display: "flex", alignItems: "center", gap: 8, margin: "0 14px", padding: "8px 12px", borderRadius: 10, background: "var(--panel-2)", border: "1px solid var(--border)" }}>
<Icon name={isImage(staged.mimeType) ? "image" : "file"} size={16} />
<span style={{ flex: 1, minWidth: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", fontSize: 13 }}>{staged.filename}</span>
<span style={{ color: "var(--muted)", fontSize: 12 }}>{fmtBytes(staged.sizeBytes)}</span>
<button onClick={() => setStaged(null)} style={{ background: "none", border: "none", color: "var(--muted)", cursor: "pointer", fontSize: 16 }} aria-label="Remove attachment">×</button>
</div>
)}
<footer style={{ display: "flex", gap: 8, padding: 14, borderTop: "1px solid var(--border)", alignItems: "center" }}>
<input ref={fileRef} type="file" hidden onChange={(e) => void onPickFile(e.target.files?.[0])} />
<button onClick={() => fileRef.current?.click()} disabled={uploading} title="Attach a file" style={{ ...actionBtnStyle, width: 38, height: 38, flexShrink: 0, opacity: uploading ? 0.5 : 1 }}>
{uploading ? "…" : "📎"}
</button>
<input
ref={inputRef}
value={draft} onChange={(e) => onDraftChange(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void submit(); } }}
placeholder="Type a message…" style={inputStyle}
/>
<Btn icon="send" onClick={() => void submit()} disabled={sending || (!draft.trim() && !staged)}>Send</Btn>
</footer>
</>
);
}
function MessageBubble({
msg, parent, seen, showStatus, flash, registerRef, onReact, onReply, onQuoteClick,
}: {
msg: UiMessage; parent?: UiMessage; seen: boolean; showStatus: boolean; flash?: boolean;
registerRef?: (el: HTMLElement | null) => void;
onReact: (emoji: string) => void; onReply: () => void; onQuoteClick?: (id: string) => void;
}) {
const [hover, setHover] = useState(false);
const [picker, setPicker] = useState(false);
return (
<div
ref={registerRef}
onMouseEnter={() => setHover(true)}
onMouseLeave={() => { setHover(false); setPicker(false); }}
style={{
alignSelf: msg.mine ? "flex-end" : "flex-start", maxWidth: "72%", display: "flex", flexDirection: "column",
alignItems: msg.mine ? "flex-end" : "flex-start", position: "relative",
borderRadius: 14, padding: 2, transition: "background 0.4s",
background: flash ? "rgba(253,169,19,0.22)" : "transparent",
}}
>
{parent && (
<button
type="button"
onClick={() => parent.id && onQuoteClick?.(parent.id)}
title="Go to message"
style={{ maxWidth: "100%", padding: "4px 10px", marginBottom: 3, borderRadius: 8, background: "var(--panel-2)", borderLeft: "3px solid var(--orange)", border: "none", borderLeftWidth: 3, fontSize: 12, color: "var(--muted)", whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", cursor: "pointer", textAlign: "left" }}
>
<span style={{ opacity: 0.8 }}> {parent.text}</span>
</button>
)}
<div style={{ display: "flex", alignItems: "center", gap: 6, flexDirection: msg.mine ? "row-reverse" : "row" }}>
{(msg.text || !msg.attachment) && (
<div style={{
background: msg.mine ? "var(--grad-brand)" : "var(--panel)", color: msg.mine ? "#fff" : "var(--text)",
padding: "8px 12px", borderRadius: 14,
borderBottomRightRadius: msg.mine ? 4 : 14, borderBottomLeftRadius: msg.mine ? 14 : 4,
border: msg.mine ? "none" : "1px solid var(--border)",
whiteSpace: "pre-wrap", wordBreak: "break-word", fontSize: 14,
}}>
{msg.text}
</div>
)}
{hover && (
<div style={{ display: "flex", gap: 2, position: "relative" }}>
<button onClick={() => setPicker((p) => !p)} title="React" style={actionBtnStyle}>🙂</button>
<button onClick={onReply} title="Reply" style={actionBtnStyle}></button>
{picker && (
<div style={{ position: "absolute", bottom: "100%", [msg.mine ? "right" : "left"]: 0, marginBottom: 4, display: "flex", gap: 2, padding: 4, borderRadius: 999, background: "var(--panel)", border: "1px solid var(--border)", boxShadow: "0 6px 20px rgba(0,0,0,0.35)", zIndex: 5 }}>
{REACTION_EMOJIS.map((e) => (
<button key={e} onClick={() => { onReact(e); setPicker(false); }} style={{ ...actionBtnStyle, fontSize: 16 }}>{e}</button>
))}
</div>
)}
</div>
)}
</div>
{msg.attachment && (
<div style={{ marginTop: 4, display: "flex", justifyContent: msg.mine ? "flex-end" : "flex-start" }}>
<AttachmentView att={msg.attachment} />
</div>
)}
{msg.reactions && msg.reactions.length > 0 && (
<div style={{ display: "flex", gap: 4, marginTop: 3, flexWrap: "wrap" }}>
{msg.reactions.map((r) => (
<button
key={r.emoji} onClick={() => onReact(r.emoji)}
style={{
display: "inline-flex", alignItems: "center", gap: 3, padding: "1px 7px", borderRadius: 999, fontSize: 12, cursor: "pointer",
background: r.mine ? "rgba(253,169,19,0.18)" : "var(--panel-2)",
border: `1px solid ${r.mine ? "var(--orange)" : "var(--border)"}`, color: "var(--text)",
}}
>
<span>{r.emoji}</span><span style={{ color: "var(--muted)" }}>{r.count}</span>
</button>
))}
</div>
)}
<div style={{ fontSize: 10.5, color: "var(--muted)", marginTop: 2 }}>
{timeOf(msg.at)}{showStatus && msg.mine ? ` · ${seen ? "Seen" : "Sent"}` : ""}
</div>
</div>
);
}
const actionBtnStyle: CSSProperties = {
background: "var(--panel-2)", border: "1px solid var(--border)", borderRadius: 8,
width: 26, height: 26, display: "grid", placeItems: "center", cursor: "pointer", fontSize: 13, color: "var(--text)", padding: 0,
};
function NewChatModal({
open, onClose, directory, onCreate,
}: {
open: boolean; onClose: () => void; directory: UiPerson[];
onCreate: (ids: string[], opts: { membership: Membership; subject?: string }) => Promise<void>;
}) {
const [picked, setPicked] = useState<string[]>([]);
const [subject, setSubject] = useState("");
const [q, setQ] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => { if (!open) { setPicked([]); setSubject(""); setQ(""); setBusy(false); } }, [open]);
const membership: Membership = picked.length > 1 ? "group" : "dm";
const filtered = directory.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase()));
const toggle = (id: string) => setPicked((l) => (l.includes(id) ? l.filter((x) => x !== id) : [...l, id]));
async function create() {
if (!picked.length || busy) return;
setBusy(true);
await onCreate(picked, { membership, ...(membership === "group" && subject.trim() ? { subject: subject.trim() } : {}) });
setBusy(false);
}
return (
<Modal
open={open} onClose={onClose} title="New conversation"
subtitle={membership === "group" ? "Group chat" : "Direct message"} icon="send"
footer={<>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn icon="send" onClick={() => void create()} disabled={!picked.length || busy}>{busy ? "Starting…" : "Start chat"}</Btn>
</>}
>
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" style={{ ...inputStyle, marginBottom: 10 }} />
{membership === "group" && (
<Field label="Group name (optional)">
<input value={subject} onChange={(e) => setSubject(e.target.value)} placeholder="e.g. Storm response" style={inputStyle} />
</Field>
)}
<div style={{ maxHeight: 320, overflowY: "auto", marginTop: 8, display: "flex", flexDirection: "column", gap: 2 }}>
{filtered.length === 0 && <div style={{ color: "var(--muted)", padding: 10 }}>No people found.</div>}
{filtered.map((p) => (
<label key={p.id} style={{
display: "flex", alignItems: "center", gap: 10, padding: "8px 10px", borderRadius: 10, cursor: "pointer",
background: picked.includes(p.id) ? "var(--panel-2)" : "transparent",
}}>
<input type="checkbox" checked={picked.includes(p.id)} onChange={() => toggle(p.id)} />
<Avatar initials={initialsOf(p.name)} size={30} gradient={p.kind === "customer" ? CUSTOMER_GRAD : undefined} />
<span style={{ flex: 1 }}>{p.name}</span>
<Pill tone="muted">{p.kind}</Pill>
</label>
))}
</div>
</Modal>
);
}
/** Group settings: rename, member list with roles, add/remove — admin-gated (IIOS/OPA re-enforces). */
function GroupSettingsModal({ conv, directory, onClose, onError }: {
conv: UiConversation; directory: UiPerson[]; onClose: () => void; onError: (m: string) => void;
}) {
const g = useGroupSettings(conv.threadId);
const [name, setName] = useState(conv.subject ?? "");
const [savingName, setSavingName] = useState(false);
const [q, setQ] = useState("");
const [pendingId, setPendingId] = useState<string | null>(null);
useEffect(() => { setName(conv.subject ?? ""); }, [conv.subject]);
const memberIds = useMemo(() => new Set(g.members.map((m) => m.userId)), [g.members]);
const nameChanged = name.trim() && name.trim() !== (conv.subject ?? "").trim();
const addable = directory.filter((p) => !memberIds.has(p.id) && p.name.toLowerCase().includes(q.trim().toLowerCase()));
async function saveName() {
if (!nameChanged || savingName) return;
setSavingName(true);
try { await g.rename(name.trim()); }
catch (e) { onError((e as Error).message); }
finally { setSavingName(false); }
}
async function add(userId: string) {
setPendingId(userId);
try { await g.addMember(userId); }
catch (e) { onError((e as Error).message); }
finally { setPendingId(null); }
}
async function remove(userId: string) {
setPendingId(userId);
try { await g.removeMember(userId); }
catch (e) { onError((e as Error).message); }
finally { setPendingId(null); }
}
return (
<Modal
open onClose={onClose} title="Group settings" subtitle={conv.title} icon="settings"
footer={<Btn variant="ghost" onClick={onClose}>Done</Btn>}
>
<Field label="Group name">
<div style={{ display: "flex", gap: 8 }}>
<input value={name} onChange={(e) => setName(e.target.value)} disabled={!g.isAdmin}
placeholder="Group name" style={{ ...inputStyle, opacity: g.isAdmin ? 1 : 0.6 }} />
{g.isAdmin && <Btn onClick={() => void saveName()} disabled={!nameChanged || savingName}>{savingName ? "…" : "Save"}</Btn>}
</div>
{!g.isAdmin && <div style={{ color: "var(--muted)", fontSize: 12, marginTop: 4 }}>Only a group admin can rename the group.</div>}
</Field>
<Field label={`Members (${g.members.length})`}>
<div style={{ display: "flex", flexDirection: "column", gap: 2, maxHeight: 200, overflowY: "auto" }}>
{g.loading && g.members.length === 0 && <div style={{ color: "var(--muted)", padding: 8 }}>Loading</div>}
{g.members.map((mem: UiMember) => (
<div key={mem.userId} style={{ display: "flex", alignItems: "center", gap: 10, padding: "7px 10px", borderRadius: 10 }}>
<Avatar initials={initialsOf(mem.displayName)} size={28} gradient={GROUP_GRAD} />
<span style={{ flex: 1 }}>{mem.displayName}</span>
{mem.role === "ADMIN" && <Pill tone="purple">admin</Pill>}
{g.isAdmin && mem.role !== "ADMIN" && (
<button onClick={() => void remove(mem.userId)} disabled={pendingId === mem.userId} title="Remove"
style={{ ...actionBtnStyle, width: 28, height: 28 }}><Icon name="trash" size={15} /></button>
)}
</div>
))}
</div>
</Field>
{g.isAdmin && (
<Field label="Add member">
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search people…" style={{ ...inputStyle, marginBottom: 8 }} />
<div style={{ display: "flex", flexDirection: "column", gap: 2, maxHeight: 180, overflowY: "auto" }}>
{addable.length === 0 && <div style={{ color: "var(--muted)", padding: 8 }}>No one to add.</div>}
{addable.map((p) => (
<button key={p.id} onClick={() => void add(p.id)} disabled={pendingId === p.id}
style={{ display: "flex", alignItems: "center", gap: 10, padding: "7px 10px", borderRadius: 10, cursor: "pointer", background: "transparent", border: "none", color: "var(--text)", textAlign: "left" }}>
<Avatar initials={initialsOf(p.name)} size={28} gradient={p.kind === "customer" ? CUSTOMER_GRAD : undefined} />
<span style={{ flex: 1 }}>{p.name}</span>
<Icon name="plus" size={16} />
</button>
))}
</div>
</Field>
)}
</Modal>
);
}
+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>
);
}
+136
View File
@@ -0,0 +1,136 @@
"use client";
// ============================================================
// Org Settings → Integrations. Today: SMS (Twilio) — a tenant
// brings its OWN Twilio credentials, which be-crm seals in IIOS
// (per-scope) and resolves at send time. The auth token is
// write-only: sealed in IIOS, never read back, so status shows
// only masked hints (from-number + SID last-4). Email (SMTP) is
// the next provider on the same generic credential registry.
// ============================================================
import { useState } from "react";
import { Btn, Field, Icon, PageHead, Pill, useToast } from "./ui";
import { useSmsSettings } from "@/lib/sms-settings-api";
const SID_RE = /^AC[0-9a-fA-F]{32}$/;
const E164_RE = /^\+[1-9]\d{6,14}$/;
export function Settings() {
return (
<div className="view">
<PageHead
eyebrow="Configuration"
title="Org Settings"
subtitle="Integrations and workspace configuration"
icon="settings"
/>
<section className="settings-section">
<h3 className="settings-section-title">Integrations</h3>
<div className="settings-grid">
<TwilioCard />
<SmtpComingSoon />
</div>
</section>
</div>
);
}
function TwilioCard() {
const toast = useToast();
const { status, loading, live, configure } = useSmsSettings();
const [editing, setEditing] = useState(false);
const [accountSid, setAccountSid] = useState("");
const [authToken, setAuthToken] = useState("");
const [fromNumber, setFromNumber] = useState("");
const [errors, setErrors] = useState<{ accountSid?: string; authToken?: string; fromNumber?: string }>({});
const [saving, setSaving] = useState(false);
const showForm = editing || (!loading && !status.configured);
function validate(): boolean {
const e: typeof errors = {};
if (!SID_RE.test(accountSid.trim())) e.accountSid = "Must be a Twilio Account SID (AC + 32 hex chars).";
if (!authToken.trim()) e.authToken = "Auth token is required.";
if (!E164_RE.test(fromNumber.trim())) e.fromNumber = "Must be E.164, e.g. +15551234567.";
setErrors(e);
return Object.keys(e).length === 0;
}
async function save() {
if (!validate()) return;
setSaving(true);
try {
await configure({ accountSid: accountSid.trim(), authToken: authToken.trim(), fromNumber: fromNumber.trim() });
toast.push({ tone: "success", title: "Twilio connected", desc: "Your SMS credentials are saved and encrypted." });
setAccountSid(""); setAuthToken(""); setFromNumber(""); setErrors({}); setEditing(false);
} catch (err) {
toast.push({ tone: "error", title: "Couldn't save credentials", desc: (err as Error).message });
} finally {
setSaving(false);
}
}
return (
<div className="settings-card">
<div className="settings-card-head">
<span className="settings-card-ic" aria-hidden="true"><Icon name="send" size={20} /></span>
<div className="settings-card-titles">
<div className="settings-card-name">
SMS <span className="settings-card-sub">· Twilio</span>
</div>
<div className="settings-card-desc">Send texts from your own Twilio number.</div>
</div>
{status.configured
? <Pill tone="green">Connected</Pill>
: <Pill tone="muted">Not connected</Pill>}
</div>
{status.configured && !editing ? (
<div className="settings-card-body">
<dl className="settings-kv">
<div><dt>From number</dt><dd>{status.fromNumber ?? "—"}</dd></div>
<div><dt>Account SID</dt><dd>{status.sidLast4 ? `AC ···· ${status.sidLast4}` : "—"}</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="Account SID" required error={errors.accountSid} hint="Twilio Console → Account Info.">
<input className="ds-input" value={accountSid} onChange={(e) => setAccountSid(e.target.value)} placeholder="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" autoComplete="off" />
</Field>
<Field label="Auth Token" required error={errors.authToken} hint="Encrypted on save and never shown again.">
<input className="ds-input" type="password" value={authToken} onChange={(e) => setAuthToken(e.target.value)} placeholder="••••••••••••••••••••••••••••••••" autoComplete="off" />
</Field>
<Field label="From number" required error={errors.fromNumber} hint="A Twilio number in E.164 format.">
<input className="ds-input" value={fromNumber} onChange={(e) => setFromNumber(e.target.value)} placeholder="+15551234567" autoComplete="off" />
</Field>
<div className="settings-card-actions">
<Btn icon="check-circle" onClick={save} disabled={saving}>{saving ? "Saving…" : status.configured ? "Update" : "Connect Twilio"}</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 not sent to Twilio.</div> : null}
</div>
);
}
function SmtpComingSoon() {
return (
<div className="settings-card is-soon">
<div className="settings-card-head">
<span className="settings-card-ic" aria-hidden="true"><Icon name="mail" size={20} /></span>
<div className="settings-card-titles">
<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>
<Pill tone="muted">Coming soon</Pill>
</div>
</div>
);
}
+1
View File
@@ -86,6 +86,7 @@ const NAV_PERMISSION: Record<string, string | undefined> = {
leads: "leads.manage",
verify: "leads.manage",
pipeline: "pipeline.manage",
projects: "pipeline.manage",
estimates: "estimates.create",
procanvas: "estimates.create",
dispatch: "dispatch.manage",
+9 -6
View File
@@ -24,7 +24,7 @@ import {
LayoutDashboard, Building2, FolderKanban, UserPlus, BadgeCheck, Filter,
Truck, CloudLightning, Map as MapIcon, PenTool, Calculator, CalendarDays,
Trophy, ListChecks, Users, Settings, Sparkles, MoreHorizontal,
UsersRound, type LucideIcon,
UsersRound, Download, type LucideIcon,
} from "lucide-react";
/* ---------------------------------------------------------- */
@@ -49,7 +49,7 @@ const ICONS: Record<string, LucideIcon> = {
storm: CloudLightning, territory: MapIcon, procanvas: PenTool,
estimates: Calculator, schedule: CalendarDays, leaderboard: Trophy,
subtasks: ListChecks, people: Users, settings: Settings, ai: Sparkles,
team: UsersRound, dots: MoreHorizontal,
team: UsersRound, dots: MoreHorizontal, download: Download,
};
export function Icon({ name, size = 18, className, strokeWidth = 2 }: { name: string; size?: number; className?: string; strokeWidth?: number }) {
@@ -217,9 +217,9 @@ export function OtpField({ length = 6, value, onChange, autoFocus = true }: { le
/* Modal */
/* ---------------------------------------------------------- */
export function Modal({ open, onClose, title, subtitle, icon, children, footer, size = "md" }: {
open: boolean; onClose: () => void; title: string; subtitle?: string; icon?: string;
children: ReactNode; footer?: ReactNode; size?: "sm" | "md" | "lg";
export function Modal({ open, onClose, title, subtitle, icon, children, footer, size = "md", headerExtra }: {
open: boolean; onClose: () => void; title: string; subtitle?: ReactNode; icon?: string;
children: ReactNode; footer?: ReactNode; size?: "sm" | "md" | "lg" | "xl"; headerExtra?: ReactNode;
}) {
const titleId = useId();
// Portal the overlay up to `.dash-root` so its position:fixed anchors to the viewport,
@@ -246,7 +246,10 @@ export function Modal({ open, onClose, title, subtitle, icon, children, footer,
{subtitle && <p>{subtitle}</p>}
</div>
</div>
<button className="ds-iconbtn" aria-label="Close" onClick={onClose}><Icon name="x" size={18} /></button>
<div className="ds-modal-head-r">
{headerExtra}
<button className="ds-iconbtn" aria-label="Close" onClick={onClose}><Icon name="x" size={18} /></button>
</div>
</div>
<div className="ds-modal-body">{children}</div>
{footer && <div className="ds-modal-foot">{footer}</div>}
-72
View File
@@ -1,72 +0,0 @@
// ============================================================
// 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
@@ -1,306 +0,0 @@
"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>
);
}
+121
View File
@@ -0,0 +1,121 @@
// The CRM's InboxAdapter — the SDK <Inbox> rendered over the be-crm data door
// (crm.inbox.* + crm.mail.*). Folds mail threads into the unified inbox exactly as the old
// inbox-api did; the CRM keeps auth/tenancy server-side.
import type {
InboxAdapter,
InboxItem,
InboxState,
MailAttachment,
MailMessage,
MailPerson,
} from "@insignia/iios-messaging-ui";
import type { DataDoor } from "./crm-messaging-adapter";
const MAX_ATTACHMENT_BYTES = 26 * 1024 * 1024; // matches IIOS's cap
// Some types (notably .md) have no OS-registered MIME, so the browser reports an empty file.type.
const EXT_MIME: Record<string, string> = {
md: "text/markdown", markdown: "text/markdown", html: "text/html", htm: "text/html", txt: "text/plain", csv: "text/csv",
};
function mimeForFile(file: File): string {
if (file.type) return file.type;
const ext = file.name.toLowerCase().split(".").pop() ?? "";
return EXT_MIME[ext] ?? "application/octet-stream";
}
interface InboxItemDTO {
id: string; kind: string; state: InboxState; title: string; summary?: string; priority: string; threadId?: string; createdAt: string;
}
interface MailThreadDTO { threadId: string; subject: string | null; participants: string[]; unread: number; lastMessage?: string; lastAt?: string }
interface MailMessageDTO {
interactionId: string; actorId: string | null; kind: string; occurredAt: string;
html: string | null; text: string | null;
attachment: { contentRef: string; mimeType: string; sizeBytes: number; filename: string | null } | null;
}
interface DirectoryDTO { id: string; displayName: string; kind: "staff" | "customer" }
const escapeHtml = (s: string): string => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
export class CrmInboxAdapter implements InboxAdapter {
constructor(private readonly sdk: DataDoor) {}
async listInbox(state?: InboxState): Promise<InboxItem[]> {
const showMail = !state || state === "OPEN";
const [items, mail] = await Promise.all([
this.sdk.query<InboxItemDTO[]>("crm.inbox.list", state ? { state } : {}),
showMail ? this.sdk.query<MailThreadDTO[]>("crm.mail.list", {}) : Promise.resolve([] as MailThreadDTO[]),
]);
const mailItems: InboxItem[] = mail.map((t) => ({
id: `mail:${t.threadId}`,
kind: "MAIL",
state: "OPEN",
title: t.subject || "(no subject)",
...(t.lastMessage ? { summary: t.lastMessage } : {}),
priority: t.unread > 0 ? "HIGH" : "LOW",
threadId: t.threadId,
createdAt: t.lastAt ?? "",
}));
return [...mailItems, ...items].sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
}
async transition(id: string, state: InboxState): Promise<void> {
await this.sdk.command("crm.inbox.transition", { id, state });
}
async mailHistory(threadId: string): Promise<MailMessage[]> {
const rows = await this.sdk.query<MailMessageDTO[]>("crm.mail.history", { threadId });
return rows.map((m) => ({
id: m.interactionId,
actorId: m.actorId,
kind: m.kind,
at: m.occurredAt,
html: m.html,
text: m.text,
attachment: m.attachment,
}));
}
async mailReply(threadId: string, content: string, attachment?: MailAttachment): Promise<void> {
await this.sdk.command("crm.mail.reply", {
threadId,
content,
...(attachment ? { attachment: { filename: attachment.filename ?? "attachment", contentRef: attachment.contentRef, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes } } : {}),
});
}
async uploadAttachment(file: File): Promise<MailAttachment> {
if (file.size > MAX_ATTACHMENT_BYTES) throw new Error("File is too large (max 25 MB).");
const mime = mimeForFile(file);
const { objectKey, uploadUrl } = await this.sdk.command<{ objectKey: string; uploadUrl: string }>("crm.media.presignUpload", { mime, sizeBytes: file.size });
const res = await fetch(uploadUrl, { method: "PUT", body: file });
if (!res.ok) throw new Error(`Upload failed (${res.status}).`);
return { contentRef: objectKey, mimeType: mime, sizeBytes: file.size, filename: file.name };
}
async downloadAttachment(attachment: MailAttachment): Promise<string> {
const { url } = await this.sdk.command<{ url: string }>("crm.media.presignDownload", {
contentRef: attachment.contentRef,
...(attachment.mimeType ? { mime: attachment.mimeType } : {}),
});
return url;
}
async directory(): Promise<MailPerson[]> {
const rows = await this.sdk.query<DirectoryDTO[]>("crm.messenger.directory", { kind: "all", limit: 100 });
return rows.map((d) => ({ id: d.id, name: d.displayName, kind: d.kind }));
}
async composeInternal(recipientUserId: string, subject: string, text: string, attachments?: MailAttachment[]): Promise<void> {
await this.sdk.command("crm.mail.internal", { recipientUserId, subject, text, html: `<p>${escapeHtml(text)}</p>`, ...attachmentsVar(attachments) });
}
async composeExternal(target: string, subject: string, text: string, attachments?: MailAttachment[]): Promise<void> {
await this.sdk.command("crm.mail.send", { target, subject, text, html: `<p>${escapeHtml(text)}</p>`, ...attachmentsVar(attachments) });
}
}
function attachmentsVar(attachments?: MailAttachment[]): { attachments?: Array<{ filename: string; contentRef: string; mimeType: string; sizeBytes: number }> } {
if (!attachments || attachments.length === 0) return {};
return { attachments: attachments.map((a) => ({ filename: a.filename ?? "attachment", contentRef: a.contentRef, mimeType: a.mimeType, sizeBytes: a.sizeBytes })) };
}
+303
View File
@@ -0,0 +1,303 @@
// The CRM's implementation of the SDK's MessagingAdapter. HYBRID transport:
// • BFF (appshell crm.messenger.*) for the conversation list, thread creation, and directory
// — these need server-side tenancy/auth.
// • IIOS MessageSocket (delegated token from crm.messenger.realtime) for everything live:
// history+join, send, typing, read receipts, reactions.
// When no socket is available (token failed / demo), it degrades to a 4s history poll.
import type {
ChannelSummary,
ChannelVisibility,
Conversation,
CreateChannelInput,
Membership,
Message,
MessageEvent,
MessagingAdapter,
Person,
Reaction,
SendOpts,
Unsubscribe,
} from "@insignia/iios-messaging-ui";
import type { MessageSocket, Message as KernelMessage } from "@insignia/iios-kernel-client";
/** The imperative appshell data door (useAppShell().sdk). Typed structurally, not to its class. */
export interface DataDoor {
query<T>(action: string, variables?: Record<string, unknown>): Promise<T>;
command<T>(action: string, variables?: Record<string, unknown>): Promise<T>;
}
interface DirectoryDTO { id: string; displayName: string; kind: "staff" | "customer" }
interface ConversationDTO {
threadId: string; subject: string | null; membership: Membership | null;
participants: string[]; unread: number; lastMessage?: string; lastAt?: string;
}
interface MessageDTO { interactionId: string; actorId: string | null; kind: string; occurredAt: string; text: string | null }
const POLL_MS = 4000;
const REACTION = "reaction";
interface Poll { seen: Set<string>; primed: boolean; timer: ReturnType<typeof setInterval> | null }
export class CrmMessagingAdapter implements MessagingAdapter {
private names: Map<string, string> | null = null;
private readonly listeners = new Map<string, Set<(e: MessageEvent) => void>>();
private readonly polls = new Map<string, Poll>();
private readonly joined = new Set<string>();
/** 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>>>();
/** Only present with a socket — the UI hides the reaction affordance without it. */
react?: (threadId: string, messageId: string, emoji: string) => Promise<void>;
constructor(
private readonly sdk: DataDoor,
private readonly me: string,
private readonly socket?: MessageSocket,
) {
if (socket) {
socket.on("message", (m) => {
this.ingestReactions(m);
this.emit(m.threadId, { kind: "message", message: this.fromKernel(m) });
});
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.
socket.on("receipt", (e) => this.broadcast({ kind: "receipt", messageId: e.interactionId, actorId: e.actorId }));
socket.on("annotation", (e) => {
if (e.type !== REACTION) return;
this.setReactionUsers(e.interactionId, e.value, e.users);
this.emit(e.threadId, { kind: "reaction", messageId: e.interactionId, reactions: this.reactionsOf(e.interactionId) });
});
this.react = async (threadId, messageId, emoji) => {
await socket.react(threadId, messageId, emoji);
};
}
}
currentActorId(): string {
return this.me;
}
async listConversations(): Promise<Conversation[]> {
const [convs, names] = await Promise.all([
this.sdk.query<ConversationDTO[]>("crm.messenger.conversation.list", {}),
this.directoryMap(),
]);
return convs.map((c) => this.toConversation(c, names));
}
async openThread(p: { participantIds: string[]; membership?: Membership; subject?: string }): Promise<{ threadId: string }> {
const res = await this.sdk.command<{ threadId: string }>("crm.messenger.conversation.open", {
participantIds: p.participantIds,
...(p.membership ? { membership: p.membership } : {}),
...(p.subject ? { subject: p.subject } : {}),
});
return { threadId: res.threadId };
}
async history(threadId: string): Promise<Message[]> {
if (this.socket) {
const res = await this.socket.openThread(threadId); // joins so live events flow
this.joined.add(threadId);
return res.history.map((m) => {
this.ingestReactions(m);
return this.fromKernel(m);
});
}
const msgs = await this.sdk.query<MessageDTO[]>("crm.messenger.history", { threadId });
return msgs.map((m) => this.fromDto(m));
}
async send(threadId: string, content: string, opts?: SendOpts): Promise<Message> {
if (this.socket) {
const sendOpts = {
...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
...(opts?.mentions && opts.mentions.length ? { mentions: opts.mentions } : {}),
};
const m = await this.socket.sendMessage(threadId, content, Object.keys(sendOpts).length ? sendOpts : undefined);
return this.fromKernel(m);
}
const m = await this.sdk.command<MessageDTO>("crm.messenger.send", { threadId, content });
const msg = this.fromDto(m);
this.polls.get(threadId)?.seen.add(msg.id);
return msg;
}
subscribe(threadId: string, cb: (e: MessageEvent) => void): Unsubscribe {
if (!this.listeners.has(threadId)) this.listeners.set(threadId, new Set());
this.listeners.get(threadId)!.add(cb);
if (this.socket) {
if (!this.joined.has(threadId)) {
this.joined.add(threadId);
void this.socket.openThread(threadId).catch(() => this.joined.delete(threadId));
}
} else {
this.startPoll(threadId);
}
return () => {
const set = this.listeners.get(threadId);
set?.delete(cb);
if (set && set.size === 0) {
this.listeners.delete(threadId);
const poll = this.polls.get(threadId);
if (poll?.timer) clearInterval(poll.timer);
this.polls.delete(threadId);
}
};
}
sendTyping(threadId: string): void {
this.socket?.typing(threadId);
}
async markRead(threadId: string, messageId: string): Promise<void> {
if (this.socket) await this.socket.markRead(threadId, messageId);
}
// ── channels + members (BFF, except join which is a governed socket self-join) ──
async browseChannels(): Promise<ChannelSummary[]> {
const rows = await this.sdk.query<Array<{ threadId: string; name: string; topic: string | null; visibility: string; memberCount: number; joined: boolean }>>(
"crm.messenger.channel.browse",
{},
);
return rows.map((c) => ({
threadId: c.threadId,
name: c.name,
topic: c.topic,
visibility: (c.visibility === "private" ? "private" : "public") as ChannelVisibility,
memberCount: c.memberCount,
joined: c.joined,
}));
}
async createChannel(input: CreateChannelInput): Promise<{ threadId: string }> {
return this.sdk.command<{ threadId: string }>("crm.messenger.channel.create", {
name: input.name,
...(input.topic ? { topic: input.topic } : {}),
visibility: input.visibility,
});
}
async joinChannel(threadId: string): Promise<void> {
// Governed public self-join over the socket (the BFF has no join verb; OPA enforces it).
if (!this.socket) throw new Error("joining a channel needs a live connection");
await this.socket.openThread(threadId);
this.joined.add(threadId);
}
async leaveChannel(threadId: string): Promise<void> {
await this.sdk.command("crm.messenger.channel.leave", { threadId });
}
async listMembers(threadId: string): Promise<Person[]> {
const rows = await this.sdk.query<Array<{ userId: string; displayName: string; role: string }>>("crm.messenger.members", { threadId });
return rows.map((r) => ({ id: r.userId, name: r.displayName, kind: "staff" as const }));
}
// ── polling fallback (no socket) ───────────────────────────────
private startPoll(threadId: string): void {
if (this.polls.has(threadId)) return;
const poll: Poll = { seen: new Set(), primed: false, timer: null };
this.polls.set(threadId, poll);
const tick = async (): Promise<void> => {
if (!this.polls.has(threadId)) return;
try {
const msgs = await this.sdk.query<MessageDTO[]>("crm.messenger.history", { threadId });
for (const m of msgs) {
if (poll.seen.has(m.interactionId)) continue;
poll.seen.add(m.interactionId);
if (poll.primed) this.emit(threadId, { kind: "message", message: this.fromDto(m) });
}
poll.primed = true;
} catch {
/* transient — retry next tick */
}
};
void tick();
poll.timer = setInterval(tick, POLL_MS);
}
/** The org directory — people you can start a DM/group with. Drives the "New message" picker. */
async directory(): Promise<Person[]> {
const dir = await this.sdk.query<DirectoryDTO[]>("crm.messenger.directory", { kind: "all", limit: 100 });
return dir.map((d) => ({ id: d.id, name: d.displayName, kind: d.kind }));
}
// ── mapping ────────────────────────────────────────────────────
private async directoryMap(): Promise<Map<string, string>> {
if (!this.names) {
this.names = new Map((await this.directory()).map((p) => [p.id, p.name]));
}
return this.names;
}
private toConversation(c: ConversationDTO, names: Map<string, string>): Conversation {
const others = c.participants.filter((p) => p !== this.me);
const title = c.subject?.trim() || others.map((id) => names.get(id) ?? id).join(", ") || "Conversation";
return {
threadId: c.threadId,
title,
subject: c.subject,
membership: c.membership,
participants: c.participants,
unread: c.unread,
...(c.lastMessage ? { lastMessage: c.lastMessage } : {}),
...(c.lastAt ? { lastAt: c.lastAt } : {}),
};
}
/** Kernel Message (socket) → SDK Message. actorId = senderId (userId space), matching currentActorId. */
private fromKernel(m: KernelMessage): Message {
return {
id: m.id,
actorId: m.senderId ?? null,
text: m.content ?? "",
at: m.createdAt,
parentInteractionId: m.parentInteractionId ?? null,
reactions: this.reactionsOf(m.id),
};
}
/** BFF DTO (poll fallback) → SDK Message. Note: actorId is IIOS actor-id space here. */
private fromDto(m: MessageDTO): Message {
return { id: m.interactionId, actorId: m.actorId, text: m.text ?? "", at: m.occurredAt };
}
// ── reaction state ─────────────────────────────────────────────
private ingestReactions(m: KernelMessage): void {
for (const a of m.annotations ?? []) {
if (a.type === REACTION) this.setReactionUsers(m.id, a.value, a.users);
}
}
private setReactionUsers(messageId: string, emoji: string, users: string[]): void {
let byEmoji = this.reactions.get(messageId);
if (!byEmoji) {
byEmoji = new Map();
this.reactions.set(messageId, byEmoji);
}
if (users.length === 0) byEmoji.delete(emoji);
else byEmoji.set(emoji, new Set(users));
}
private reactionsOf(messageId: string): Reaction[] {
const byEmoji = this.reactions.get(messageId);
if (!byEmoji) return [];
const out: Reaction[] = [];
for (const [emoji, users] of byEmoji) {
if (users.size > 0) out.push({ emoji, count: users.size, mine: users.has(this.me) });
}
return out;
}
// ── event fan-out ──────────────────────────────────────────────
private emit(threadId: string, e: MessageEvent): void {
this.listeners.get(threadId)?.forEach((cb) => cb(e));
}
private broadcast(e: MessageEvent): void {
for (const set of this.listeners.values()) set.forEach((cb) => cb(e));
}
}
-95
View File
@@ -1,95 +0,0 @@
"use client";
// Inbox data layer. The inbox is a personalized work/awareness feed IIOS projects from events
// (NEEDS_REPLY, MENTION, …). The CRM lists it and transitions item state; items are never created
// here. Mock when the Shell isn't configured; live via the be-crm data door (crm.inbox.*) otherwise.
//
// Live contract (be-crm):
// query crm.inbox.list { state? } -> InboxItem[]
// cmd crm.inbox.transition { id, state, reason? } -> InboxItem
import { useCallback, useEffect, useMemo, useState } from "react";
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "./appshell";
import type { MailThread } from "./mail-api";
export type InboxState = "OPEN" | "SNOOZED" | "DONE" | "ARCHIVED" | "CANCELLED" | "STALE";
export interface UiInboxItem {
id: string; kind: string; state: InboxState; title: string; summary?: string;
priority: string; threadId?: string; createdAt: string;
}
const SHELL = isShellConfigured();
export interface InboxData {
live: boolean; loading: boolean; error: string | null;
items: UiInboxItem[];
transition: (id: string, state: InboxState) => Promise<void>;
refetch: () => void;
}
export function useInboxData(state?: InboxState): InboxData {
return SHELL ? useLiveInbox(state) : useMockInbox(state);
}
function useLiveInbox(state?: InboxState): InboxData {
const { sdk } = useAppShell();
const q = useQuery<UiInboxItem[]>("crm.inbox.list", state ? { state } : {});
// Mail lives in crm-mail threads, NOT the inbox projection — fold it into the one unified
// surface. Mail has no inbox work-item state, so it only shows in the Open (or unfiltered) view.
const showMail = !state || state === "OPEN";
const mq = useQuery<MailThread[]>("crm.mail.list", {});
// The SDK's useQuery only refetches when the ACTION changes, not the variables — so a filter
// change (same action, new { state }) wouldn't reload. Force a refetch when the filter changes.
const refetchInbox = q.refetch;
useEffect(() => { refetchInbox(); }, [state, refetchInbox]);
const items = useMemo<UiInboxItem[]>(() => {
const inboxItems = q.data ?? [];
const mailItems: UiInboxItem[] = showMail
? (mq.data ?? []).map((t) => ({
id: `mail:${t.threadId}`,
kind: "MAIL",
state: "OPEN" as InboxState,
title: t.subject || "(no subject)",
...(t.lastMessage ? { summary: t.lastMessage } : {}),
priority: t.unread > 0 ? "HIGH" : "LOW",
threadId: t.threadId,
createdAt: t.lastAt ?? "",
}))
: [];
// Newest first; mail and inbox items interleave by time.
return [...mailItems, ...inboxItems].sort((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""));
}, [q.data, mq.data, showMail]);
const transition = useCallback(async (id: string, next: InboxState) => {
await sdk.command("crm.inbox.transition", { id, state: next });
q.refetch();
}, [sdk, q]);
return {
live: true,
loading: q.loading || (showMail && mq.loading),
// Don't let a mail-list hiccup blank the whole inbox — surface only the inbox error.
error: q.error?.message ?? null,
items,
transition,
refetch: () => { q.refetch(); mq.refetch(); },
};
}
const MOCK_ITEMS: UiInboxItem[] = [
{ id: "in_1", kind: "MENTION", state: "OPEN", title: "Sofia mentioned you", summary: "@you — can you confirm the Henderson scope?", priority: "HIGH", threadId: "th_mock_1", createdAt: new Date().toISOString() },
{ id: "in_2", kind: "NEEDS_REPLY", state: "OPEN", title: "Reply needed — Storm response", summary: "Dan: Crew is rolling out at 7.", priority: "MEDIUM", threadId: "th_mock_2", createdAt: new Date().toISOString() },
{ id: "in_3", kind: "SUPPORT_UPDATE", state: "OPEN", title: "Ticket TK-204 updated", summary: "Customer replied on the roof-leak case.", priority: "LOW", createdAt: new Date().toISOString() },
];
function useMockInbox(state?: InboxState): InboxData {
const [items, setItems] = useState<UiInboxItem[]>(MOCK_ITEMS);
const filtered = useMemo(() => (state ? items.filter((i) => i.state === state) : items), [items, state]);
const transition = useCallback(async (id: string, next: InboxState) => {
setItems((l) => l.map((i) => (i.id === id ? { ...i, state: next } : i)));
}, []);
return { live: false, loading: false, error: null, items: filtered, transition, refetch: () => {} };
}
-121
View File
@@ -1,121 +0,0 @@
"use client";
// Mail data layer. A dedicated Mail reader over the be-crm data door (crm.mail.*), distinct from
// the Messenger chat and from the work-item Inbox. Live via the AppShell SDK; a small mock keeps the
// demo working before the Shell + be-crm are connected.
//
// Live contract (be-crm):
// query crm.mail.list {} -> MailThread[]
// query crm.mail.history { threadId } -> MailMessage[]
// cmd crm.mail.reply { threadId, content } -> { interactionId, threadId }
// cmd crm.mail.internal { recipientUserId, subject?, text?, html? } -> { threadId }
// cmd crm.mail.send { target, subject?, text?, html?, mirrorToUserId? } -> { commandId }
// query crm.messenger.directory { kind, limit } -> people to compose to (reused)
import { useCallback, useMemo, useState } from "react";
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "./appshell";
export interface MailThread {
threadId: string; subject: string | null; participants: string[]; unread: number; lastMessage?: string; lastAt?: string;
}
export interface MailAttachment { contentRef: string; mimeType: string; sizeBytes: number; filename: string | null }
export interface MailMessage {
interactionId: string; actorId: string | null; kind: string; occurredAt: string; html: string | null; text: string | null; attachment: MailAttachment | null;
}
export interface MailPerson { id: string; name: string; kind: "staff" | "customer" }
/** Shape produced by media-api's useUploadAttachment, passed into a reply/compose. */
export interface OutgoingAttachment { contentRef: string; mimeType: string; sizeBytes: number; filename: string }
const SHELL = isShellConfigured();
/* ============================ Thread list ============================ */
export interface MailListData {
live: boolean; loading: boolean; error: string | null; threads: MailThread[]; refetch: () => void;
}
export function useMailThreads(): MailListData {
if (SHELL) {
const q = useQuery<MailThread[]>("crm.mail.list", {});
return { live: true, loading: q.loading, error: q.error?.message ?? null, threads: q.data ?? [], refetch: q.refetch };
}
return { live: false, loading: false, error: null, threads: MOCK_THREADS, refetch: () => {} };
}
/* ============================ One thread ============================ */
export interface MailThreadData {
loading: boolean; error: string | null; messages: MailMessage[]; reply: (content: string, attachment?: OutgoingAttachment) => Promise<void>; refetch: () => void;
}
export function useMailThread(threadId: string | null): MailThreadData {
if (SHELL) return useLiveThread(threadId);
return useMockThread(threadId);
}
function useLiveThread(threadId: string | null): MailThreadData {
const { sdk } = useAppShell();
const q = useQuery<MailMessage[]>("crm.mail.history", threadId ? { threadId } : { threadId: "" });
const reply = useCallback(async (content: string, attachment?: OutgoingAttachment) => {
if (!threadId) return;
await sdk.command("crm.mail.reply", { threadId, content, ...(attachment ? { attachment } : {}) });
q.refetch();
}, [sdk, threadId, q]);
return { loading: q.loading, error: q.error?.message ?? null, messages: threadId ? (q.data ?? []) : [], reply, refetch: q.refetch };
}
/* ============================ Compose ============================ */
export interface ComposeData {
directory: MailPerson[];
sendInternal: (recipientUserId: string, subject: string, text: string, attachments?: OutgoingAttachment[]) => Promise<void>;
sendExternal: (target: string, subject: string, text: string, opts?: { mirrorToUserId?: string; attachments?: OutgoingAttachment[] }) => Promise<void>;
}
export function useMailCompose(onSent: () => void): ComposeData {
if (SHELL) {
const { sdk } = useAppShell();
const dirQ = useQuery<MailPerson[]>("crm.messenger.directory", { kind: "all", limit: 100 });
const directory = useMemo(() => (dirQ.data ?? []).map((d) => ({ id: (d as unknown as { id: string }).id, name: (d as unknown as { displayName?: string; name?: string }).displayName ?? (d as unknown as { name?: string }).name ?? "", kind: (d as MailPerson).kind })), [dirQ.data]);
const sendInternal = useCallback(async (recipientUserId: string, subject: string, text: string, attachments?: OutgoingAttachment[]) => {
await sdk.command("crm.mail.internal", { recipientUserId, subject, text, html: `<p>${escapeHtml(text)}</p>`, ...(attachments && attachments.length ? { attachments } : {}) });
onSent();
}, [sdk, onSent]);
const sendExternal = useCallback(async (target: string, subject: string, text: string, opts?: { mirrorToUserId?: string; attachments?: OutgoingAttachment[] }) => {
await sdk.command("crm.mail.send", { target, subject, text, html: `<p>${escapeHtml(text)}</p>`, ...(opts?.mirrorToUserId ? { mirrorToUserId: opts.mirrorToUserId } : {}), ...(opts?.attachments && opts.attachments.length ? { attachments: opts.attachments } : {}) });
onSent();
}, [sdk, onSent]);
return { directory, sendInternal, sendExternal };
}
return { directory: MOCK_PEOPLE, sendInternal: async () => onSent(), sendExternal: async () => onSent() };
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
/* ============================ Mock (demo mode) ============================ */
const now = () => new Date().toISOString();
const MOCK_PEOPLE: MailPerson[] = [
{ id: "pp_sofia", name: "Sofia Ramirez", kind: "staff" },
{ id: "cust_acme", name: "Acme Roofing (Client)", kind: "customer" },
];
const MOCK_THREADS: MailThread[] = [
{ threadId: "mt_1", subject: "Welcome to the Founders Club", participants: ["you", "system"], unread: 1, lastMessage: "Thanks for joining…", lastAt: now() },
{ threadId: "mt_2", subject: "Storm response — East side", participants: ["you", "pp_sofia"], unread: 0, lastMessage: "Crew rolling out at 7", lastAt: now() },
];
function useMockThread(threadId: string | null): MailThreadData {
const [extra, setExtra] = useState<MailMessage[]>([]);
const base: MailMessage[] = threadId === "mt_1"
? [{ interactionId: "m1", actorId: "system", kind: "EMAIL", occurredAt: now(), html: "<p>Thanks for joining the <b>Founders Club</b>. Set up your account to get started.</p>", text: "Thanks for joining the Founders Club.", attachment: null }]
: threadId === "mt_2"
? [{ interactionId: "m2", actorId: "pp_sofia", kind: "EMAIL", occurredAt: now(), html: "<p>Crew is rolling out at 7. Confirm the Henderson scope?</p>", text: "Crew rolling out at 7.", attachment: null }]
: [];
const reply = useCallback(async (content: string, attachment?: OutgoingAttachment) => {
setExtra((l) => [...l, { interactionId: `r_${l.length}`, actorId: "you", kind: "MESSAGE", occurredAt: now(), html: null, text: content, attachment: attachment ? { contentRef: attachment.contentRef, mimeType: attachment.mimeType, sizeBytes: attachment.sizeBytes, filename: attachment.filename } : null }]);
}, []);
return { loading: false, error: null, messages: threadId ? [...base, ...extra] : [], reply, refetch: () => {} };
}
+14 -1
View File
@@ -14,12 +14,25 @@ export function isImage(mime?: string | null): boolean {
return !!mime && mime.startsWith("image/");
}
// Some types (notably .md) have no OS-registered MIME, so the browser reports an empty file.type.
// Fall back to the extension for the text types IIOS allows, else a generic binary.
const EXT_MIME: Record<string, string> = {
md: "text/markdown", markdown: "text/markdown",
html: "text/html", htm: "text/html",
txt: "text/plain", csv: "text/csv",
};
function mimeForFile(file: File): string {
if (file.type) return file.type;
const ext = file.name.toLowerCase().split(".").pop() ?? "";
return EXT_MIME[ext] ?? "application/octet-stream";
}
/** Upload a File → { contentRef, mimeType, sizeBytes, filename }. Throws on oversize / failure. */
export function useUploadAttachment() {
const { sdk } = useAppShell();
return useCallback(async (file: File): Promise<UploadedAttachment> => {
if (file.size > MAX_ATTACHMENT_BYTES) throw new Error("File is too large (max 25 MB).");
const mime = file.type || "application/octet-stream";
const mime = mimeForFile(file);
const { objectKey, uploadUrl } = (await sdk.command("crm.media.presignUpload", { mime, sizeBytes: file.size })) as { objectKey: string; uploadUrl: string };
const res = await fetch(uploadUrl, { method: "PUT", body: file });
if (!res.ok) throw new Error(`Upload failed (${res.status}).`);
-435
View File
@@ -1,435 +0,0 @@
"use client";
// Messenger data layer. Serves EITHER a local mock (when the Shell isn't configured — the demo
// keeps working) OR the live be-crm data door (crm.messenger.*), behind one interface so the UI is
// mode-agnostic. DM-vs-group + who-can-chat are enforced server-side by IIOS/OPA; this is just glue.
//
// Live contract (be-crm):
// query crm.messenger.directory { kind, query?, limit } -> DirectoryEntry[]
// query crm.messenger.conversation.list {} -> ConversationSummary[]
// cmd crm.messenger.conversation.open { participantIds[], membership?, subject? } -> { threadId, ... }
// query crm.messenger.history { threadId } -> MessengerMessage[]
// cmd crm.messenger.send { threadId, content } -> MessengerMessage
// cmd crm.messenger.participant.add { threadId, userId }
//
// v1 uses REST + polling for the live stream; v2 layers the IIOS MessageSocket (messenger-socket.tsx)
// on top for live messages, typing, read receipts, and reactions.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useAppShell, useAuth, useQuery } from "@abe-kap/appshell-sdk/react";
import type { AnnotationEvent, AnnotationGroup } from "@insignia/iios-kernel-client";
import { isShellConfigured } from "./appshell";
import { useMessengerSocket } from "./messenger-socket";
export type Membership = "dm" | "group";
export interface UiPerson { id: string; name: string; kind: "staff" | "customer" }
export interface UiConversation {
threadId: string; title: string; subject: string | null; membership: Membership | null;
participants: string[]; unread: number; lastMessage?: string; lastAt?: string;
}
export interface UiReaction { emoji: string; count: number; mine: boolean }
export interface UiAttachment { contentRef: string; mimeType: string; sizeBytes: number }
export interface UiMessage {
id: string; actorId: string | null; senderId?: string | null; text: string; at: string; mine: boolean;
parentInteractionId?: string | null;
attachment?: UiAttachment;
reactions?: UiReaction[];
}
interface DirectoryDTO { id: string; displayName: string; kind: "staff" | "customer" }
interface ConversationDTO {
threadId: string; subject: string | null; membership: Membership | null;
participants: string[]; unread: number; lastMessage?: string; lastAt?: string;
}
interface MessageDTO { interactionId: string; actorId: string | null; kind: string; occurredAt: string; text: string | null }
const SHELL = isShellConfigured();
const POLL_MS = 4000;
const TYPING_TTL_MS = 3500;
const shortId = (id: string) => id.replace(/^(pp_|cust_)/, "").slice(0, 6);
/** Turn the kernel's generic annotation aggregates into reaction chips. `users` may hold user or
* actor ids depending on the source, so `mine` is best-effort; a fresh annotation event corrects it. */
export function toReactions(annotations: AnnotationGroup[] | undefined, myId?: string): UiReaction[] {
if (!annotations) return [];
return annotations
.filter((a) => a.type === "reaction" && a.users.length > 0)
.map((a) => ({ emoji: a.value, count: a.users.length, mine: !!myId && a.users.includes(myId) }));
}
function applyAnnotation(prev: UiReaction[] | undefined, e: AnnotationEvent, myId?: string): UiReaction[] {
const base = (prev ?? []).filter((r) => r.emoji !== e.value);
if (e.type !== "reaction" || e.users.length === 0) return base;
return [...base, { emoji: e.value, count: e.users.length, mine: !!myId && e.users.includes(myId) }];
}
/* ======================================================================== */
/* Public hooks */
/* ======================================================================== */
export interface MessengerData {
live: boolean; loading: boolean; error: string | null;
directory: UiPerson[];
conversations: UiConversation[];
nameOf: (id: string) => string;
openConversation: (participantIds: string[], opts?: { membership?: Membership; subject?: string }) => Promise<string>;
refetch: () => void;
}
export interface ThreadData {
loading: boolean; error: string | null;
messages: UiMessage[];
send: (content: string, opts?: { parentInteractionId?: string; attachment?: UiAttachment }) => Promise<void>;
react: (interactionId: string, emoji: string) => void;
typingUserIds: string[];
seenIds: Set<string>;
refetch: () => void;
}
export interface UiMember { userId: string; displayName: string; role: string }
export interface GroupSettingsData {
loading: boolean; error: string | null;
members: UiMember[];
isAdmin: boolean;
rename: (subject: string) => Promise<void>;
addMember: (userId: string) => Promise<void>;
removeMember: (userId: string) => Promise<void>;
refetch: () => void;
}
export function useMessengerData(): MessengerData {
return SHELL ? useLiveMessenger() : useMockMessenger();
}
export function useThread(threadId: string): ThreadData {
return SHELL ? useLiveThread(threadId) : useMockThread(threadId);
}
export function useGroupSettings(threadId: string): GroupSettingsData {
return SHELL ? useLiveGroupSettings(threadId) : useMockGroupSettings(threadId);
}
/* ======================================================================== */
/* Live implementation (be-crm data door + IIOS socket) */
/* ======================================================================== */
function useLiveMessenger(): MessengerData {
const { sdk } = useAppShell();
const { user } = useAuth();
const socket = useMessengerSocket();
const myId = user?.id;
const dirQ = useQuery<DirectoryDTO[]>("crm.messenger.directory", { kind: "all", limit: 100 });
const convQ = useQuery<ConversationDTO[]>("crm.messenger.conversation.list", {});
const directory: UiPerson[] = useMemo(
() => (dirQ.data ?? []).map((d) => ({ id: d.id, name: d.displayName, kind: d.kind })),
[dirQ.data],
);
const nameById = useMemo(() => Object.fromEntries(directory.map((p) => [p.id, p.name])), [directory]);
const nameOf = useCallback((id: string) => nameById[id] ?? `User ${shortId(id)}`, [nameById]);
// Live sidebar previews: patch lastMessage/lastAt the instant a message arrives on any thread,
// then reconcile authoritative unread/order with a debounced refetch.
const [previews, setPreviews] = useState<Record<string, { lastMessage: string; lastAt: string }>>({});
const refetchRef = useRef(convQ.refetch);
refetchRef.current = convQ.refetch;
useEffect(() => {
if (!socket) return;
let timer: ReturnType<typeof setTimeout> | null = null;
const off = socket.onAnyMessage((threadId, m) => {
setPreviews((p) => ({ ...p, [threadId]: { lastMessage: m.text, lastAt: m.at } }));
if (timer) clearTimeout(timer);
timer = setTimeout(() => refetchRef.current(), 600);
});
return () => { off(); if (timer) clearTimeout(timer); };
}, [socket]);
const conversations: UiConversation[] = useMemo(
() => (convQ.data ?? []).map((c) => shape(c, nameOf, myId, previews[c.threadId])),
[convQ.data, nameOf, myId, previews],
);
const refetch = useCallback(() => { dirQ.refetch(); convQ.refetch(); }, [dirQ, convQ]);
const openConversation = useCallback(async (participantIds: string[], opts?: { membership?: Membership; subject?: string }) => {
const res = (await sdk.command("crm.messenger.conversation.open", {
participantIds, ...(opts?.membership ? { membership: opts.membership } : {}), ...(opts?.subject ? { subject: opts.subject } : {}),
})) as { threadId: string };
convQ.refetch();
return res.threadId;
}, [sdk, convQ]);
return {
live: true,
loading: dirQ.loading || convQ.loading,
error: (dirQ.error ?? convQ.error)?.message ?? null,
directory, conversations, nameOf, openConversation, refetch,
};
}
function useLiveThread(threadId: string): ThreadData {
const { sdk } = useAppShell();
const socket = useMessengerSocket();
const socketReady = socket?.ready ?? false;
const q = useQuery<MessageDTO[]>("crm.messenger.history", { threadId });
const [socketMsgs, setSocketMsgs] = useState<UiMessage[]>([]);
const [myActorId, setMyActorId] = useState<string | null>(null);
const myActorIdRef = useRef<string | null>(null);
myActorIdRef.current = myActorId;
const [typing, setTyping] = useState<Record<string, number>>({}); // userId -> expiry ts
const [seenIds, setSeenIds] = useState<Set<string>>(new Set());
const myId = socket?.myUserId;
// REST poll — the fallback whenever the live socket isn't connected.
const refetchRef = useRef(q.refetch);
refetchRef.current = q.refetch;
useEffect(() => {
if (socketReady) return;
const t = setInterval(() => refetchRef.current(), POLL_MS);
return () => clearInterval(t);
}, [socketReady, threadId]);
// Socket (primary): load history + subscribe to live messages, typing, receipts, reactions.
useEffect(() => {
if (!socket || !socketReady) return;
let alive = true;
setSocketMsgs([]); setSeenIds(new Set()); setTyping({});
void socket.openThread(threadId).then((hist) => { if (alive) setSocketMsgs(hist); }).catch(() => {});
const offMsg = socket.subscribe(threadId, (m) =>
setSocketMsgs((l) => (l.some((x) => x.id === m.id) ? l : [...l, m])),
);
const offTyping = socket.onTyping(threadId, (userId) =>
setTyping((t) => ({ ...t, [userId]: Date.now() + TYPING_TTL_MS })),
);
// Receipts are a global stream (no threadId). Count only reads by the OTHER side; seenMine then
// narrows to my messages in this thread.
const offReceipt = socket.onReceipt((e) => {
if (e.actorId === myActorIdRef.current) return;
setSeenIds((s) => (s.has(e.interactionId) ? s : new Set(s).add(e.interactionId)));
});
const offAnn = socket.onAnnotation(threadId, (e) =>
setSocketMsgs((l) => l.map((m) => (m.id === e.interactionId ? { ...m, reactions: applyAnnotation(m.reactions, e, myId) } : m))),
);
return () => { alive = false; offMsg(); offTyping(); offReceipt(); offAnn(); };
}, [socket, socketReady, threadId, myId]);
// Learn my own actor id from a message I sent, so receipts from OTHER actors read as "seen".
useEffect(() => {
const mine = socketMsgs.find((m) => m.mine && m.actorId);
if (mine?.actorId && mine.actorId !== myActorId) setMyActorId(mine.actorId);
}, [socketMsgs, myActorId]);
// Tell the server I've read the latest message (drives the other side's "seen" tick).
useEffect(() => {
if (!socket || !socketReady || socketMsgs.length === 0) return;
socket.markRead(threadId, socketMsgs[socketMsgs.length - 1].id);
}, [socket, socketReady, threadId, socketMsgs]);
// Expire stale typing entries.
const typingUserIds = useMemo(() => {
const now = Date.now();
return Object.entries(typing).filter(([, exp]) => exp > now).map(([u]) => u);
}, [typing]);
useEffect(() => {
if (typingUserIds.length === 0) return;
const t = setTimeout(() => setTyping((p) => ({ ...p })), TYPING_TTL_MS);
return () => clearTimeout(t);
}, [typingUserIds.length, typing]);
const restMsgs: UiMessage[] = useMemo(
() => (q.data ?? []).map((m) => ({
id: m.interactionId, actorId: m.actorId, senderId: null, text: m.text ?? "", at: m.occurredAt,
mine: !!myActorId && m.actorId === myActorId, reactions: [],
})),
[q.data, myActorId],
);
const messages = socketReady ? socketMsgs : restMsgs;
// My messages the other side has read (receipts carry the other actor's id).
const seenMine = useMemo(() => {
const out = new Set<string>();
for (const id of seenIds) if (messages.some((m) => m.id === id && m.mine)) out.add(id);
return out;
}, [seenIds, messages]);
const send = useCallback(async (content: string, opts?: { parentInteractionId?: string; attachment?: UiAttachment }) => {
if (socket && socketReady) {
await socket.send(threadId, content, opts); // echoes back over the socket as a 'message' event
} else {
// REST fallback carries the attachment ref too; a socket reconnect will replace with the live copy.
const m = (await sdk.command("crm.messenger.send", { threadId, content, ...(opts?.attachment ? { attachment: opts.attachment } : {}) })) as MessageDTO;
if (m.actorId) setMyActorId(m.actorId);
q.refetch();
}
}, [socket, socketReady, threadId, sdk, q]);
const react = useCallback((interactionId: string, emoji: string) => {
if (socket && socketReady) socket.react(threadId, interactionId, emoji);
}, [socket, socketReady, threadId]);
return {
loading: q.loading && !socketReady, error: q.error?.message ?? null,
messages, send, react, typingUserIds, seenIds: seenMine, refetch: q.refetch,
};
}
function useLiveGroupSettings(threadId: string): GroupSettingsData {
const { sdk } = useAppShell();
const { user } = useAuth();
const q = useQuery<UiMember[]>("crm.messenger.members", { threadId });
const members = useMemo(() => q.data ?? [], [q.data]);
const isAdmin = useMemo(() => members.some((m) => m.userId === user?.id && m.role === "ADMIN"), [members, user?.id]);
const rename = useCallback(async (subject: string) => {
await sdk.command("crm.messenger.group.rename", { threadId, subject });
q.refetch();
}, [sdk, threadId, q]);
const addMember = useCallback(async (userId: string) => {
await sdk.command("crm.messenger.participant.add", { threadId, userId });
q.refetch();
}, [sdk, threadId, q]);
const removeMember = useCallback(async (userId: string) => {
await sdk.command("crm.messenger.participant.remove", { threadId, userId });
q.refetch();
}, [sdk, threadId, q]);
return { loading: q.loading, error: q.error?.message ?? null, members, isAdmin, rename, addMember, removeMember, refetch: q.refetch };
}
function shape(
c: ConversationDTO,
nameOf: (id: string) => string,
myId: string | undefined,
overlay?: { lastMessage: string; lastAt: string },
): UiConversation {
// A DM's title is the OTHER person — never yourself, and never the raw unknown-id fallback for both.
const others = myId ? c.participants.filter((p) => p !== myId) : c.participants;
const title = c.subject?.trim()
|| (c.membership === "group"
? `Group · ${c.participants.length}`
: (others.map(nameOf).join(", ") || nameOf(c.participants[0] ?? "") || "Conversation"));
const lastMessage = overlay?.lastMessage ?? c.lastMessage;
const lastAt = overlay?.lastAt ?? c.lastAt;
return {
threadId: c.threadId, title, subject: c.subject, membership: c.membership,
participants: c.participants, unread: c.unread,
...(lastMessage ? { lastMessage } : {}), ...(lastAt ? { lastAt } : {}),
};
}
/* ======================================================================== */
/* Mock implementation (no Shell configured — the demo keeps working) */
/* ======================================================================== */
const MOCK_PEOPLE: UiPerson[] = [
{ id: "pp_sofia", name: "Sofia Ramirez", kind: "staff" },
{ id: "pp_dan", name: "Dan Whitaker", kind: "staff" },
{ id: "pp_priya", name: "Priya Nair", kind: "staff" },
{ id: "cust_acme", name: "Acme Roofing (Client)", kind: "customer" },
{ id: "cust_globex", name: "Globex Homes (Client)", kind: "customer" },
];
interface MockThread { threadId: string; membership: Membership; subject: string | null; participants: string[]; messages: UiMessage[] }
const now = () => new Date().toISOString();
let MOCK_SEQ = 100;
// A tiny module-level store both mock hooks share, with a subscribe-on-change so the
// conversation list and the open thread stay in sync (no globalThis, no render writes).
const MOCK_STORE = new Map<string, MockThread>([
["th_mock_1", { threadId: "th_mock_1", membership: "dm", subject: null, participants: ["me", "pp_sofia"],
messages: [{ id: "m1", actorId: "pp_sofia", text: "Can you review the Henderson estimate?", at: now(), mine: false, reactions: [] }] }],
["th_mock_2", { threadId: "th_mock_2", membership: "group", subject: "Storm response — East side", participants: ["me", "pp_dan", "pp_priya"],
messages: [{ id: "m2", actorId: "pp_dan", text: "Crew is rolling out at 7.", at: now(), mine: false, reactions: [] }] }],
]);
const mockListeners = new Set<() => void>();
const notifyMock = () => mockListeners.forEach((l) => l());
function useMockSubscription(): void {
const [, setV] = useState(0);
useEffect(() => {
const l = () => setV((n) => n + 1);
mockListeners.add(l);
return () => { mockListeners.delete(l); };
}, []);
}
function useMockMessenger(): MessengerData {
useMockSubscription();
const nameById = useMemo(() => Object.fromEntries(MOCK_PEOPLE.map((p) => [p.id, p.name])), []);
const nameOf = useCallback((id: string) => nameById[id] ?? `User ${shortId(id)}`, [nameById]);
const conversations: UiConversation[] = [...MOCK_STORE.values()].map((t) => {
const last = t.messages[t.messages.length - 1];
return {
threadId: t.threadId,
title: t.subject || t.participants.filter((p) => p !== "me").map(nameOf).join(", ") || "Conversation",
subject: t.subject, membership: t.membership, participants: t.participants, unread: 0,
...(last ? { lastMessage: last.text, lastAt: last.at } : {}),
};
});
const openConversation = useCallback(async (participantIds: string[], opts?: { membership?: Membership; subject?: string }) => {
const membership = opts?.membership ?? (participantIds.length === 1 ? "dm" : "group");
const threadId = `th_mock_${MOCK_SEQ++}`;
MOCK_STORE.set(threadId, { threadId, membership, subject: opts?.subject ?? null, participants: ["me", ...participantIds], messages: [] });
notifyMock();
return threadId;
}, []);
return { live: false, loading: false, error: null, directory: MOCK_PEOPLE, conversations, nameOf, openConversation, refetch: () => {} };
}
function useMockThread(threadId: string): ThreadData {
useMockSubscription();
const thread = MOCK_STORE.get(threadId);
const send = useCallback(async (content: string, opts?: { parentInteractionId?: string }) => {
const t = MOCK_STORE.get(threadId);
if (t) {
t.messages = [...t.messages, {
id: `m_${MOCK_SEQ++}`, actorId: "me", text: content, at: now(), mine: true, reactions: [],
...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
}];
notifyMock();
}
}, [threadId]);
const react = useCallback((interactionId: string, emoji: string) => {
const t = MOCK_STORE.get(threadId);
if (!t) return;
t.messages = t.messages.map((m) => {
if (m.id !== interactionId) return m;
const has = (m.reactions ?? []).find((r) => r.emoji === emoji);
const reactions = has
? (m.reactions ?? []).filter((r) => r.emoji !== emoji)
: [...(m.reactions ?? []), { emoji, count: 1, mine: true }];
return { ...m, reactions };
});
notifyMock();
}, [threadId]);
return {
loading: false, error: null, messages: thread?.messages ?? [], send, react,
typingUserIds: [], seenIds: new Set(), refetch: notifyMock,
};
}
function useMockGroupSettings(threadId: string): GroupSettingsData {
useMockSubscription();
const nameById = useMemo(() => Object.fromEntries(MOCK_PEOPLE.map((p) => [p.id, p.name])), []);
const t = MOCK_STORE.get(threadId);
const members: UiMember[] = (t?.participants ?? []).map((id) => ({
userId: id,
displayName: id === "me" ? "You" : (nameById[id] ?? `User ${shortId(id)}`),
role: id === "me" ? "ADMIN" : "MEMBER",
}));
const rename = useCallback(async (subject: string) => {
const th = MOCK_STORE.get(threadId);
if (th) { th.subject = subject; notifyMock(); }
}, [threadId]);
const addMember = useCallback(async (userId: string) => {
const th = MOCK_STORE.get(threadId);
if (th && !th.participants.includes(userId)) { th.participants = [...th.participants, userId]; notifyMock(); }
}, [threadId]);
const removeMember = useCallback(async (userId: string) => {
const th = MOCK_STORE.get(threadId);
if (th) { th.participants = th.participants.filter((p) => p !== userId); notifyMock(); }
}, [threadId]);
return { loading: false, error: null, members, isAdmin: true, rename, addMember, removeMember, refetch: notifyMock };
}
-149
View File
@@ -1,149 +0,0 @@
"use client";
// v2 live stream: one IIOS MessageSocket for the whole Messenger panel, using the SDK
// (@insignia/iios-kernel-client) — not raw socket.io. The delegated realtime token comes from
// the be-crm data door (crm.messenger.realtime). Threads subscribe through a context; the socket
// re-opens every joined thread on reconnect (handled inside the SDK). In mock mode this is a no-op
// passthrough and the thread hook falls back to the REST poll.
//
// Beyond plain messages, the kernel exposes typing, read receipts, and reactions (generic
// annotations). This provider fans each server event out to per-thread listeners so the UI can
// render typing indicators, "seen" ticks, and emoji reactions live.
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react";
import { MessageSocket, type Message, type AnnotationEvent } from "@insignia/iios-kernel-client";
import { useAuth, useQuery } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "./appshell";
import { toReactions, type UiMessage } from "./messenger-api";
interface RealtimeDTO { url: string; audience: string; token?: string }
export interface ReceiptHit { interactionId: string; actorId: string }
export interface MessengerSocket {
ready: boolean;
myUserId?: string;
openThread: (threadId: string) => Promise<UiMessage[]>;
send: (threadId: string, content: string, opts?: { parentInteractionId?: string; attachment?: { contentRef: string; mimeType: string; sizeBytes: number } }) => Promise<void>;
subscribe: (threadId: string, cb: (m: UiMessage) => void) => () => void;
/** Fires for EVERY inbound message regardless of thread — drives live sidebar previews. */
onAnyMessage: (cb: (threadId: string, m: UiMessage) => void) => () => void;
sendTyping: (threadId: string) => void;
onTyping: (threadId: string, cb: (userId: string) => void) => () => void;
markRead: (threadId: string, interactionId: string) => void;
/** The kernel's receipt event carries no threadId, so this is a global stream; the thread hook
* filters to receipts for its own (mine) messages. */
onReceipt: (cb: (e: ReceiptHit) => void) => () => void;
react: (threadId: string, interactionId: string, emoji: string) => void;
onAnnotation: (threadId: string, cb: (e: AnnotationEvent) => void) => () => void;
}
const Ctx = createContext<MessengerSocket | null>(null);
export function useMessengerSocket(): MessengerSocket | null { return useContext(Ctx); }
const SHELL = isShellConfigured();
const toUi = (m: Message, myUserId?: string): UiMessage => ({
id: m.id, actorId: m.senderActorId ?? null, senderId: m.senderId ?? null, text: m.content ?? "", at: m.createdAt,
mine: !!myUserId && m.senderId === myUserId,
...(m.parentInteractionId ? { parentInteractionId: m.parentInteractionId } : {}),
...(m.attachment ? { attachment: { contentRef: m.attachment.contentRef, mimeType: m.attachment.mimeType, sizeBytes: m.attachment.sizeBytes } } : {}),
reactions: toReactions(m.annotations, myUserId),
});
export function MessengerSocketProvider({ children }: { children: ReactNode }) {
// SHELL is a build-time constant, so the branch is stable across renders (Rules-of-Hooks safe).
if (!SHELL) return <>{children}</>;
return <LiveSocketProvider>{children}</LiveSocketProvider>;
}
// A tiny per-thread listener registry, reused for messages / typing / receipts / annotations.
function makeRegistry<T>() {
const map = new Map<string, Set<(v: T) => void>>();
const add = (key: string, cb: (v: T) => void) => {
if (!map.has(key)) map.set(key, new Set());
map.get(key)!.add(cb);
return () => { map.get(key)?.delete(cb); };
};
const emit = (key: string, v: T) => map.get(key)?.forEach((cb) => cb(v));
return { add, emit };
}
function LiveSocketProvider({ children }: { children: ReactNode }) {
const { user } = useAuth();
const rt = useQuery<RealtimeDTO>("crm.messenger.realtime", {});
const [ready, setReady] = useState(false);
const socketRef = useRef<MessageSocket | null>(null);
const myRef = useRef<string | undefined>(user?.id);
myRef.current = user?.id;
// One registry per event kind, keyed by threadId (plus a global message fan-out).
const msgReg = useRef(makeRegistry<UiMessage>()).current;
const anyMsg = useRef(new Set<(threadId: string, m: UiMessage) => void>()).current;
const typingReg = useRef(makeRegistry<string>()).current;
const receiptSet = useRef(new Set<(e: ReceiptHit) => void>()).current;
const annReg = useRef(makeRegistry<AnnotationEvent>()).current;
const url = rt.data?.url;
const token = rt.data?.token;
useEffect(() => {
if (!url || !token) return;
const socket = new MessageSocket({ serviceUrl: url, token, autoConnect: false });
socketRef.current = socket;
const offConnected = socket.onConnected(() => setReady(true));
const offMessage = socket.on("message", (m) => {
const ui = toUi(m, myRef.current);
msgReg.emit(m.threadId, ui);
anyMsg.forEach((cb) => cb(m.threadId, ui));
});
const offTyping = socket.on("typing", (e) => { if (e.userId !== myRef.current) typingReg.emit(e.threadId, e.userId); });
const offReceipt = socket.on("receipt", (e) => receiptSet.forEach((cb) => cb({ interactionId: e.interactionId, actorId: e.actorId })));
const offAnn = socket.on("annotation", (e) => annReg.emit(e.threadId, e));
socket.connect();
return () => {
offConnected(); offMessage(); offTyping(); offReceipt(); offAnn();
socket.disconnect(); socketRef.current = null; setReady(false);
};
}, [url, token, msgReg, anyMsg, typingReg, receiptSet, annReg]);
const openThread = useCallback(async (threadId: string): Promise<UiMessage[]> => {
const s = socketRef.current;
if (!s) return [];
const res = await s.openThread(threadId);
return res.history.map((m) => toUi(m, myRef.current));
}, []);
const send = useCallback(async (threadId: string, content: string, opts?: { parentInteractionId?: string; attachment?: { contentRef: string; mimeType: string; sizeBytes: number } }) => {
const s = socketRef.current;
if (!s) throw new Error("Not connected");
const sendOpts = {
...(opts?.parentInteractionId ? { parentInteractionId: opts.parentInteractionId } : {}),
...(opts?.attachment ? { attachment: opts.attachment } : {}),
};
await s.sendMessage(threadId, content, Object.keys(sendOpts).length ? sendOpts : undefined);
}, []);
const subscribe = useCallback((threadId: string, cb: (m: UiMessage) => void) => msgReg.add(threadId, cb), [msgReg]);
const onAnyMessage = useCallback((cb: (threadId: string, m: UiMessage) => void) => {
anyMsg.add(cb); return () => { anyMsg.delete(cb); };
}, [anyMsg]);
const onTyping = useCallback((threadId: string, cb: (userId: string) => void) => typingReg.add(threadId, cb), [typingReg]);
const onReceipt = useCallback((cb: (e: ReceiptHit) => void) => {
receiptSet.add(cb); return () => { receiptSet.delete(cb); };
}, [receiptSet]);
const onAnnotation = useCallback((threadId: string, cb: (e: AnnotationEvent) => void) => annReg.add(threadId, cb), [annReg]);
const sendTyping = useCallback((threadId: string) => socketRef.current?.typing(threadId), []);
const markRead = useCallback((threadId: string, interactionId: string) => { void socketRef.current?.markRead(threadId, interactionId); }, []);
const react = useCallback((threadId: string, interactionId: string, emoji: string) => { void socketRef.current?.react(threadId, interactionId, emoji); }, []);
return (
<Ctx.Provider value={{
ready, myUserId: user?.id, openThread, send, subscribe, onAnyMessage,
sendTyping, onTyping, markRead, onReceipt, react, onAnnotation,
}}>
{children}
</Ctx.Provider>
);
}
+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();
}
+78
View File
@@ -0,0 +1,78 @@
"use client";
// SMS settings data layer. Serves EITHER the local mock (Shell not configured — the demo keeps
// working) OR the live be-crm data door (crm.settings.sms.*), behind one interface.
//
// Live contract (be-crm → IIOS BYO credential store):
// query crm.settings.sms.status {} -> { configured, enabled?, hints? }
// cmd crm.settings.sms.configure { accountSid, authToken, fromNumber } -> masked status
// The auth token is write-only: it is sealed in IIOS and NEVER returned — status carries only
// non-secret hints (from-number + SID last-4).
import { useCallback, useState } from "react";
import { useAppShell, useQuery } from "@abe-kap/appshell-sdk/react";
import { isShellConfigured } from "./appshell";
export interface SmsCredentials { accountSid: string; authToken: string; fromNumber: string }
export interface SmsStatus {
configured: boolean;
enabled: boolean;
fromNumber?: string;
sidLast4?: string;
}
export interface SmsSettingsData {
live: boolean;
loading: boolean;
error: string | null;
status: SmsStatus;
configure: (input: SmsCredentials) => Promise<void>;
refetch: () => void;
}
interface StatusDTO { configured: boolean; enabled?: boolean; hints?: { fromNumber?: string; sidLast4?: string } }
function toStatus(dto?: StatusDTO | null): SmsStatus {
return {
configured: !!dto?.configured,
enabled: dto?.enabled ?? false,
fromNumber: dto?.hints?.fromNumber,
sidLast4: dto?.hints?.sidLast4,
};
}
/* ---- Mock (demo mode) — stores only the non-secret hints, mirroring the masked live status ---- */
function useMockSms(): SmsSettingsData {
const [status, setStatus] = useState<SmsStatus>({ configured: false, enabled: false });
const configure = useCallback(async ({ accountSid, fromNumber }: SmsCredentials) => {
setStatus({ configured: true, enabled: true, fromNumber, sidLast4: accountSid.slice(-4) });
}, []);
return { live: false, loading: false, error: null, status, configure, refetch: () => {} };
}
/* ---- Live (be-crm data door) ---- */
function useLiveSms(): SmsSettingsData {
const { sdk } = useAppShell();
const q = useQuery<StatusDTO>("crm.settings.sms.status", {});
const configure = useCallback(async (input: SmsCredentials) => {
await sdk.command("crm.settings.sms.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 useSmsSettings(): SmsSettingsData {
// SHELL is constant for the bundle's life (NEXT_PUBLIC_* is build-time), so the same hook path
// runs every render — Rules-of-Hooks safe.
return SHELL ? useLiveSms() : useMockSms();
}