3 Commits

Author SHA1 Message Date
Mayur Shinde ec3df3415f comment added 2026-07-27 18:40:07 +05:30
Mayur Shinde c63bced08b feat(pipeline): full-page lead detail with Move Stage picker
Opens from the drawer's "More Details", replacing the placeholder toast.
The board swaps for a full-width lead record; "← Pipeline" goes back.

- Detail page: breadcrumb, hero (avatar, stage/type/storm/age pills,
  Call + Email), stage progress bar + 7-step stepper, and an overview
  grid of Contact / Property / Job Details / Insurance / Assignment.
- Move Stage dropdown so a lead can be re-staged from the detail page,
  not only by dragging on the kanban board. Stage changes now route
  through one setStage() used by both paths, so "reached" advancement
  and the toast behave identically.
- Detail is keyed by lead id rather than a snapshot, so a stage move
  updates the pills, progress, stepper and page accent live.
- pipeline-data: PLead gains phones, propertyType, source, tradeType,
  urgency, insurance (null for Retail jobs) and assignment. All derived
  deterministically from the existing rows — no Date.now, so SSR and
  client render identically.
- Dropdown is a bottom sheet under 720px and an anchored menu above it;
  dropped overflow:hidden on .pld-hero, which was clipping it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 18:27:55 +05:30
Mayur Shinde 190ef5f58e piplene page 2026-07-27 14:32:08 +05:30
10 changed files with 1080 additions and 652 deletions
+249 -35
View File
@@ -447,10 +447,8 @@
.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; }
@@ -924,7 +922,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; margin-bottom: 12px; }
.dash-root .tm-toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.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; }
@@ -1156,40 +1154,8 @@
.dash-root .ai-caps { grid-template-columns: 1fr; }
.dash-root .ai-bubble { max-width: 86%; }
.dash-root .ai-view { height: calc(100vh - 150px); }
.dash-root .proj-row { grid-template-columns: 1fr !important; row-gap: 8px; padding: 14px 16px; }
.dash-root .proj-head { display: none; }
.dash-root .proj-c-act { justify-self: end; }
}
/* ---- Projects (Construction + Pipeline Leads) ---- */
.dash-root .proj-toolbar .tm-tabs { flex: 0 0 auto; }
.dash-root .proj-toolbar .tm-chips { flex: 0 0 auto; margin-left: auto; }
.dash-root .proj-table { display: flex; flex-direction: column; }
.dash-root .proj-row { display: grid; align-items: center; gap: 14px; padding: 13px 18px; border-bottom: 1px solid var(--border); }
.dash-root .proj-row:last-child { border-bottom: 0; }
.dash-root .proj-head { color: var(--faint); font-size: 10.5px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; text-align: center; }
.dash-root .proj-row:not(.proj-head):hover { background: color-mix(in srgb, var(--panel-2) 60%, transparent); }
.dash-root .proj-lead { min-width: 0; }
.dash-root .proj-lead-name { font-size: 13.5px; font-weight: 700; }
.dash-root .proj-lead-sub { font-size: 12px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dash-root .proj-text { font-size: 13px; color: var(--text-2); text-align: center; }
.dash-root .proj-c-num { text-align: center; }
.dash-root .proj-row .ds-pill { justify-self: center; justify-content: center; }
.dash-root .proj-c-act { display: flex; justify-content: center; align-items: center; gap: 6px; position: relative; }
.dash-root .proj-cd-search { margin-bottom: 14px; }
.dash-root .proj-cd-table { margin-bottom: 14px; }
.dash-root .proj-progress { display: flex; align-items: center; gap: 8px; }
.dash-root .proj-progress-track { flex: 1; display: block; height: 6px; border-radius: 99px; background: var(--track); overflow: hidden; }
.dash-root .proj-progress-fill { display: block; height: 100%; border-radius: 99px; background: var(--orange); transition: width 0.5s cubic-bezier(0.2,0.7,0.2,1); }
.dash-root .proj-progress b { font-size: 12px; font-weight: 700; color: var(--text-2); flex: 0 0 auto; }
.dash-root .proj-health { font-weight: 800; text-align: center; }
.dash-root .proj-empty { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 54px 20px; color: var(--muted); text-align: center; }
.dash-root .proj-foot { display: flex; align-items: center; justify-content: space-between; padding: 16px 4px 0; font-size: 12.5px; color: var(--muted); }
.dash-root .proj-foot b { color: var(--text); font-size: 15px; font-weight: 800; }
.dash-root .proj-detail-row { display: flex; align-items: center; justify-content: space-between; padding: 9px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
.dash-root .proj-detail-row span { color: var(--muted); }
.dash-root .proj-detail-row b { color: var(--text); font-weight: 700; }
/* ========================================================== */
/* Leads — pipeline board + rich detail popup */
/* ========================================================== */
@@ -1552,3 +1518,251 @@
.dash-root .ds-toast.is-clickable .ds-toast-body { cursor: pointer; }
.dash-root .ds-toast.is-clickable .ds-toast-body:hover .ds-toast-title { color: var(--orange); }
/* =======================================================================
Pipeline — kanban board
======================================================================= */
.dash-root .pl { display: flex; flex-direction: column; height: 100%; min-height: 0; }
/* tone accents (per column / drawer via data-tone) */
.dash-root [data-tone="blue"] { --pl-accent: #4f8cff; }
.dash-root [data-tone="purple"] { --pl-accent: #b07bf2; }
.dash-root [data-tone="magenta"] { --pl-accent: #db5fd0; }
.dash-root [data-tone="orange"] { --pl-accent: var(--orange); }
.dash-root [data-tone="green"] { --pl-accent: var(--green); }
.dash-root [data-tone="cyan"] { --pl-accent: #34c9d6; }
.dash-root [data-tone="red"] { --pl-accent: var(--red); }
/* ---- head ---- */
.dash-root .pl-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; }
.dash-root .pl-head-l { display: flex; align-items: center; gap: 12px; }
.dash-root .pl-head-ic { display: grid; place-items: center; width: 40px; height: 40px; border-radius: 12px; background: color-mix(in srgb, var(--orange) 14%, transparent); color: var(--orange); flex: 0 0 auto; }
.dash-root .pl-head-l h1 { font-size: 20px; font-weight: 700; letter-spacing: -0.01em; }
.dash-root .pl-head-l p { margin: 2px 0 0; font-size: 12.5px; color: var(--muted); }
.dash-root .pl-search { display: flex; align-items: center; gap: 8px; height: 40px; width: 300px; max-width: 46vw; padding: 0 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--panel-2); color: var(--muted); }
.dash-root .pl-search:focus-within { border-color: var(--orange); }
.dash-root .pl-search input { flex: 1 1 auto; border: 0; background: none; outline: none; color: var(--text); font-size: 13.5px; font-family: inherit; }
.dash-root .pl-search input::placeholder { color: var(--muted); }
.dash-root .pl-search-x { display: grid; place-items: center; border: 0; background: none; color: var(--muted); cursor: pointer; padding: 2px; }
.dash-root .pl-search-x:hover { color: var(--text); }
/* ---- board ---- */
.dash-root .pl-board { display: flex; gap: 14px; align-items: flex-start; overflow-x: auto; overflow-y: hidden; flex: 1 1 auto; min-height: 0; padding-bottom: 12px; scrollbar-width: thin; }
.dash-root .pl-board::-webkit-scrollbar { height: 9px; }
.dash-root .pl-board::-webkit-scrollbar-thumb { background: var(--border-2); border-radius: 99px; }
.dash-root .pl-col { flex: 0 0 288px; width: 288px; display: flex; flex-direction: column; min-height: 0; max-height: 100%; background: var(--panel-2); border: 1px solid var(--border); border-radius: 14px; padding: 12px 10px 4px; transition: border-color .14s, background .14s; }
.dash-root .pl-col.is-over { border-color: var(--pl-accent); background: color-mix(in srgb, var(--pl-accent) 7%, var(--panel-2)); }
.dash-root .pl-col.is-flag { background: color-mix(in srgb, var(--pl-accent) 5%, var(--panel-2)); }
.dash-root .pl-col-head { display: flex; align-items: center; gap: 8px; padding: 2px 4px 8px; }
.dash-root .pl-col-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--pl-accent); flex: 0 0 auto; box-shadow: 0 0 8px color-mix(in srgb, var(--pl-accent) 60%, transparent); }
.dash-root .pl-col-label { font-size: 11.5px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-2); }
.dash-root .pl-col-count { display: grid; place-items: center; min-width: 20px; height: 18px; padding: 0 6px; border-radius: 99px; background: color-mix(in srgb, var(--pl-accent) 20%, transparent); color: var(--pl-accent); font-size: 11px; font-weight: 700; }
.dash-root .pl-col-step { margin-left: auto; font-size: 10.5px; font-weight: 600; color: var(--faint); font-variant-numeric: tabular-nums; }
.dash-root .pl-col-bar { height: 3px; border-radius: 99px; background: var(--track); overflow: hidden; margin: 0 4px 10px; }
.dash-root .pl-col-bar > span { display: block; height: 100%; background: var(--pl-accent); border-radius: inherit; }
.dash-root .pl-col-body { display: flex; flex-direction: column; gap: 10px; overflow-y: auto; padding: 2px 4px 10px; min-height: 60px; scrollbar-width: thin; }
.dash-root .pl-col-body::-webkit-scrollbar { width: 6px; }
.dash-root .pl-col-body::-webkit-scrollbar-thumb { background: var(--border-2); border-radius: 99px; }
.dash-root .pl-col-empty { border: 1px dashed var(--border-2); border-radius: 10px; padding: 18px 10px; text-align: center; font-size: 12px; color: var(--faint); }
/* ---- card ---- */
.dash-root .pl-card { text-align: left; width: 100%; background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 11px 12px; cursor: pointer; transition: border-color .14s, transform .1s, box-shadow .14s; }
.dash-root .pl-card:hover { border-color: var(--border-2); box-shadow: var(--shadow); transform: translateY(-1px); }
.dash-root .pl-card.is-dragging { opacity: 0.5; }
.dash-root .pl-col.is-flag .pl-card { border-left: 3px solid var(--pl-accent); }
.dash-root .pl-card-top { display: flex; align-items: center; gap: 10px; }
.dash-root .pl-card-id { min-width: 0; flex: 1; }
.dash-root .pl-card-name { font-size: 13.5px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dash-root .pl-card-addr { display: flex; align-items: center; gap: 4px; font-size: 11.5px; color: var(--muted); margin-top: 2px; }
.dash-root .pl-card-addr svg { flex: 0 0 auto; color: var(--faint); }
.dash-root .pl-card-tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0; }
.dash-root .pl-tag { font-size: 10.5px; font-weight: 600; padding: 3px 8px; border-radius: 7px; }
.dash-root .pl-tag.work { background: color-mix(in srgb, var(--orange) 15%, transparent); color: var(--orange); }
.dash-root .pl-tag.type { background: color-mix(in srgb, var(--blue) 18%, transparent); color: #6f9bff; }
.dash-root .pl-card-foot { display: flex; align-items: center; gap: 8px; padding-top: 9px; border-top: 1px solid var(--border); font-size: 11px; color: var(--muted); }
.dash-root .pl-card-foot svg { flex: 0 0 auto; }
.dash-root .pl-rep { display: inline-flex; align-items: center; gap: 4px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dash-root .pl-card-spacer { flex: 1; }
.dash-root .pl-storm { display: inline-flex; align-items: center; gap: 3px; color: var(--orange); font-weight: 600; flex: 0 0 auto; }
.dash-root .pl-age { display: inline-flex; align-items: center; gap: 3px; color: var(--faint); flex: 0 0 auto; font-variant-numeric: tabular-nums; }
/* ---- drawer ---- */
.dash-root .pl-scrim { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 79; animation: ds-fade .2s ease; }
.dash-root .pl-drawer { position: fixed; top: 0; right: 0; height: 100vh; width: 400px; max-width: 92vw; z-index: 80; display: flex; flex-direction: column; background: var(--panel); border-left: 1px solid var(--border-2); box-shadow: -24px 0 60px -20px rgba(0,0,0,0.6); animation: pl-slide .22s cubic-bezier(.2,.7,.3,1); }
@keyframes pl-slide { from { transform: translateX(24px); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
.dash-root .pl-drawer-head { display: flex; align-items: center; gap: 12px; padding: 18px 18px 14px; border-bottom: 1px solid var(--border); }
.dash-root .pl-drawer-id { min-width: 0; flex: 1; }
.dash-root .pl-drawer-name { font-size: 16px; font-weight: 700; color: var(--text); }
.dash-root .pl-drawer-addr { font-size: 12px; color: var(--muted); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dash-root .pl-drawer-x { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 9px; border: 0; background: none; color: var(--muted); cursor: pointer; flex: 0 0 auto; }
.dash-root .pl-drawer-x:hover { background: var(--panel-2); color: var(--text); }
.dash-root .pl-drawer-status { padding: 14px 18px; border-bottom: 1px solid var(--border); }
.dash-root .pl-status-row { display: flex; align-items: baseline; justify-content: space-between; }
.dash-root .pl-status-label { font-size: 13px; font-weight: 600; color: var(--text); }
.dash-root .pl-status-pct { font-size: 13px; font-weight: 700; color: var(--pl-accent); font-variant-numeric: tabular-nums; }
.dash-root .pl-status-bar { height: 5px; border-radius: 99px; background: var(--track); overflow: hidden; margin: 8px 0 12px; }
.dash-root .pl-status-bar > span { display: block; height: 100%; background: linear-gradient(90deg, color-mix(in srgb, var(--pl-accent) 55%, transparent), var(--pl-accent)); border-radius: inherit; transition: width .3s ease; }
.dash-root .pl-steps { display: flex; flex-wrap: wrap; gap: 4px 12px; }
.dash-root .pl-step { display: inline-flex; align-items: center; gap: 5px; font-size: 10.5px; color: var(--faint); }
.dash-root .pl-step-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--border-2); }
.dash-root .pl-step.done { color: var(--text-2); }
.dash-root .pl-step.done .pl-step-dot { background: var(--pl-accent); }
.dash-root .pl-drawer-tabs { display: flex; gap: 22px; padding: 0 18px; border-bottom: 1px solid var(--border); }
.dash-root .pl-drawer-tabs button { position: relative; border: 0; background: none; padding: 12px 0; font-family: inherit; font-size: 13px; font-weight: 600; color: var(--muted); cursor: pointer; }
.dash-root .pl-drawer-tabs button.active { color: var(--text); }
.dash-root .pl-drawer-tabs button.active::after { content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px; border-radius: 2px; background: var(--orange); }
.dash-root .pl-drawer-body { flex: 1 1 auto; overflow-y: auto; padding: 16px 18px; }
.dash-root .pl-dsec { margin-bottom: 18px; }
.dash-root .pl-dsec-head { font-size: 10.5px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: var(--faint); margin-bottom: 9px; }
.dash-root .pl-contact { display: flex; align-items: center; gap: 9px; font-size: 13px; color: var(--text-2); padding: 5px 0; }
.dash-root .pl-contact svg { color: var(--muted); flex: 0 0 auto; }
.dash-root .pl-jobgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.dash-root .pl-jobbox { display: flex; flex-direction: column; gap: 3px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--panel-2); }
.dash-root .pl-jobk { font-size: 11px; color: var(--muted); }
.dash-root .pl-jobv { font-size: 13px; font-weight: 600; color: var(--text); }
.dash-root .pl-assign { display: inline-flex; align-items: center; gap: 9px; font-size: 13px; font-weight: 600; color: var(--text); padding: 9px 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--panel-2); }
.dash-root .pl-assign svg { color: var(--muted); }
.dash-root .pl-notes { font-size: 13px; line-height: 1.5; color: var(--text-2); padding: 11px 13px; border: 1px solid var(--border); border-radius: 10px; background: var(--panel-2); }
.dash-root .pl-activity { display: flex; flex-direction: column; }
.dash-root .pl-act { display: flex; gap: 12px; position: relative; padding-bottom: 18px; }
.dash-root .pl-act::before { content: ""; position: absolute; left: 4px; top: 14px; bottom: -4px; width: 2px; background: var(--border); }
.dash-root .pl-act:last-child { padding-bottom: 0; }
.dash-root .pl-act:last-child::before { display: none; }
.dash-root .pl-act-node { position: relative; z-index: 1; width: 10px; height: 10px; border-radius: 50%; background: var(--orange); margin-top: 4px; flex: 0 0 auto; box-shadow: 0 0 0 3px var(--panel); }
.dash-root .pl-act-body { flex: 1; min-width: 0; }
.dash-root .pl-act-head { display: flex; align-items: center; gap: 6px; font-size: 13px; font-weight: 600; color: var(--text); }
.dash-root .pl-act-head svg { color: var(--muted); }
.dash-root .pl-act-from { color: var(--muted); font-weight: 500; }
.dash-root .pl-act-meta { font-size: 11.5px; color: var(--muted); margin-top: 2px; }
.dash-root .pl-act-note { font-size: 12.5px; color: var(--text-2); margin-top: 7px; padding: 8px 11px; border-radius: 9px; background: var(--panel-2); border: 1px solid var(--border); }
.dash-root .pl-drawer-foot { padding: 14px 18px; border-top: 1px solid var(--border); }
.dash-root .pl-more { display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; height: 44px; border: 0; border-radius: 12px; background: var(--blue); color: #fff; font-family: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer; transition: filter .14s; }
.dash-root .pl-more:hover { filter: brightness(1.08); }
/* =======================================================================
Pipeline — full-page lead detail (from the drawer's "More Details")
======================================================================= */
.dash-root .pld { display: flex; flex-direction: column; gap: 16px; }
/* ---- breadcrumb ---- */
.dash-root .pld-crumbs { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--faint); }
.dash-root .pld-back { display: inline-flex; align-items: center; gap: 6px; border: 0; background: none; padding: 0; font-family: inherit; font-size: 12.5px; font-weight: 600; color: var(--muted); cursor: pointer; transition: color .14s; }
.dash-root .pld-back:hover { color: var(--orange); }
.dash-root .pld-back-ic { transform: rotate(180deg); }
.dash-root .pld-crumb-now { color: var(--text); font-weight: 600; }
.dash-root .pld-crumb-id { margin-left: 2px; padding: 2px 7px; border-radius: 6px; background: var(--panel-2); border: 1px solid var(--border); font-size: 11px; font-weight: 600; color: var(--muted); font-variant-numeric: tabular-nums; }
/* ---- hero ---- */
/* No `overflow: hidden` here — it would clip the Move Stage dropdown. The
accent bar rounds its own top corners instead. */
.dash-root .pld-hero { display: flex; align-items: flex-start; gap: 16px; padding: 18px 20px; border-radius: 16px; border: 1px solid var(--border); background: var(--panel); position: relative; }
.dash-root .pld-hero::before { content: ""; position: absolute; inset: 0 0 auto 0; height: 3px; border-radius: 16px 16px 0 0; background: linear-gradient(90deg, var(--pl-accent), transparent); }
.dash-root .pld-hero-id { flex: 1; min-width: 0; }
.dash-root .pld-hero-name { font-size: 22px; font-weight: 700; letter-spacing: -0.01em; color: var(--text); }
.dash-root .pld-hero-addr { display: flex; align-items: center; gap: 6px; margin-top: 4px; font-size: 13px; color: var(--muted); }
.dash-root .pld-hero-addr svg { flex: 0 0 auto; color: var(--faint); }
.dash-root .pld-hero-pills { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-top: 12px; }
.dash-root .pld-hero-actions { display: flex; gap: 8px; flex: 0 0 auto; }
/* ---- move stage dropdown ---- */
.dash-root .pld-move { position: relative; }
.dash-root .pld-move-scrim { position: fixed; inset: 0; z-index: 40; }
.dash-root .pld-move-menu { position: absolute; top: calc(100% + 8px); right: 0; z-index: 41; width: 232px; max-height: min(60vh, 420px); overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; padding: 6px; border-radius: 14px; border: 1px solid var(--border-2); background: var(--panel); box-shadow: var(--shadow-lg, 0 18px 44px -12px rgba(0,0,0,0.55)); animation: ds-fade .14s ease; }
.dash-root .pld-move-menu::-webkit-scrollbar { width: 6px; }
.dash-root .pld-move-menu::-webkit-scrollbar-thumb { background: var(--border-2); border-radius: 99px; }
.dash-root .pld-move-group + .pld-move-group { margin-top: 4px; padding-top: 4px; border-top: 1px solid var(--border); }
.dash-root .pld-move-grouphead { padding: 7px 10px 5px; font-size: 10px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--faint); }
.dash-root .pld-move-item { display: flex; align-items: center; gap: 9px; width: 100%; padding: 8px 10px; border: 0; border-radius: 9px; background: none; font-family: inherit; font-size: 13px; font-weight: 600; color: var(--text-2); text-align: left; cursor: pointer; transition: background .12s, color .12s; }
.dash-root .pld-move-item:hover:not(:disabled) { background: color-mix(in srgb, var(--pl-accent) 12%, transparent); color: var(--text); }
.dash-root .pld-move-item:disabled { cursor: default; color: var(--text); }
.dash-root .pld-move-item.is-current { background: var(--panel-2); }
.dash-root .pld-move-item svg { margin-left: auto; color: var(--pl-accent); flex: 0 0 auto; }
.dash-root .pld-move-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--pl-accent); flex: 0 0 auto; box-shadow: 0 0 8px color-mix(in srgb, var(--pl-accent) 55%, transparent); }
.dash-root .pld-move-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ---- stage progress ---- */
.dash-root .pld-progress { padding: 16px 20px; border-radius: 16px; border: 1px solid var(--border); background: var(--panel); }
.dash-root .pld-progress-row { display: flex; align-items: baseline; justify-content: space-between; }
.dash-root .pld-progress-label { font-size: 14px; font-weight: 700; color: var(--text); }
.dash-root .pld-progress-pct { font-size: 13px; font-weight: 700; color: var(--pl-accent); font-variant-numeric: tabular-nums; }
.dash-root .pld-progress-bar { height: 6px; border-radius: 99px; background: var(--track); overflow: hidden; margin: 10px 0 16px; }
.dash-root .pld-progress-bar > span { display: block; height: 100%; background: linear-gradient(90deg, color-mix(in srgb, var(--pl-accent) 55%, transparent), var(--pl-accent)); border-radius: inherit; transition: width .3s ease; }
.dash-root .pld-steps { display: flex; flex-wrap: wrap; gap: 10px 6px; list-style: none; margin: 0; padding: 0; }
.dash-root .pld-step { display: flex; align-items: center; gap: 7px; flex: 1 1 120px; min-width: 0; position: relative; }
.dash-root .pld-step::after { content: ""; position: absolute; left: 22px; right: 4px; top: 11px; height: 2px; background: var(--border); z-index: 0; }
.dash-root .pld-step:last-child::after { display: none; }
.dash-root .pld-step.done::after { background: var(--pl-accent); }
.dash-root .pld-step.now::after, .dash-root .pld-step.now ~ .pld-step::after { background: var(--border); }
.dash-root .pld-step-dot { position: relative; z-index: 1; display: grid; place-items: center; width: 22px; height: 22px; border-radius: 50%; flex: 0 0 auto; background: var(--panel-2); border: 1px solid var(--border-2); color: var(--faint); font-size: 10.5px; font-weight: 700; }
.dash-root .pld-step.done .pld-step-dot { background: var(--pl-accent); border-color: var(--pl-accent); color: #fff; }
.dash-root .pld-step.now .pld-step-dot { box-shadow: 0 0 0 4px color-mix(in srgb, var(--pl-accent) 20%, transparent); }
.dash-root .pld-step-label { font-size: 11.5px; font-weight: 600; color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; background: var(--panel); padding-right: 6px; position: relative; z-index: 1; }
.dash-root .pld-step.done .pld-step-label { color: var(--text-2); }
.dash-root .pld-step.now .pld-step-label { color: var(--text); }
/* ---- overview grid ---- */
.dash-root .pld-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.dash-root .pld-sec { border: 1px solid var(--border); border-radius: 14px; background: var(--panel); overflow: hidden; }
.dash-root .pld-sec.wide { grid-column: 1 / -1; }
.dash-root .pld-sec-head { display: flex; align-items: center; gap: 8px; padding: 12px 16px; border-bottom: 1px solid var(--border); background: var(--panel-2); font-size: 11px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--text-2); }
.dash-root .pld-sec-head svg { color: var(--pl-accent); }
.dash-root .pld-sec-body { padding: 8px 16px 14px; }
.dash-root .pld-kv { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; padding: 9px 0; border-bottom: 1px dashed var(--border); }
.dash-root .pld-kv:last-child { border-bottom: 0; }
.dash-root .pld-kv-k { font-size: 12px; color: var(--muted); flex: 0 0 auto; }
.dash-root .pld-kv-v { font-size: 13px; font-weight: 600; color: var(--text); text-align: right; min-width: 0; overflow-wrap: anywhere; }
.dash-root .pld-sublabel { font-size: 10.5px; font-weight: 700; letter-spacing: 0.09em; text-transform: uppercase; color: var(--faint); margin: 12px 0 6px; }
.dash-root .pld-sec-body > .pld-sublabel:first-child { margin-top: 4px; }
.dash-root .pld-contact { display: flex; align-items: center; gap: 9px; padding: 7px 0; font-size: 13px; }
.dash-root .pld-contact svg { color: var(--muted); flex: 0 0 auto; }
.dash-root .pld-contact-val { font-weight: 600; color: var(--text); min-width: 0; overflow-wrap: anywhere; }
.dash-root .pld-contact-tag { font-size: 11px; color: var(--muted); padding: 2px 7px; border-radius: 6px; background: var(--panel-2); border: 1px solid var(--border); }
.dash-root .pld-notes { font-size: 13px; line-height: 1.55; color: var(--text-2); margin: 0; padding: 11px 13px; border: 1px solid var(--border); border-radius: 10px; background: var(--panel-2); }
.dash-root .pld-assign { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 0 24px; }
.dash-root .pld-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 26px 12px; color: var(--faint); text-align: center; }
.dash-root .pld-empty p { margin: 0; font-size: 12.5px; }
@media (max-width: 980px) {
.dash-root .pld-grid { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
.dash-root .pl-search { width: 100%; max-width: none; }
.dash-root .pl-col { flex-basis: 82vw; width: 82vw; }
.dash-root .pld-hero { flex-wrap: wrap; }
.dash-root .pld-hero-actions { width: 100%; }
.dash-root .pld-hero-actions > * { flex: 1; }
.dash-root .pld-move > .ds-btn { width: 100%; }
.dash-root .pld-step { flex: 1 1 100%; }
.dash-root .pld-step::after { display: none; }
/* Phones: a bottom sheet can't be clipped or pushed off-screen the way an
anchored dropdown can, and every stage stays thumb-reachable. */
.dash-root .pld-move-scrim { background: rgba(0,0,0,0.5); z-index: 90; animation: ds-fade .16s ease; }
.dash-root .pld-move-menu {
position: fixed; z-index: 91;
top: auto; right: 12px; left: 12px; bottom: max(12px, env(safe-area-inset-bottom));
width: auto; max-height: 72vh; padding: 8px;
border-radius: 18px; box-shadow: 0 -10px 44px -12px rgba(0,0,0,0.6);
animation: pld-sheet .2s cubic-bezier(.2,.7,.3,1);
}
.dash-root .pld-move-item { padding: 11px 12px; font-size: 14px; }
}
@keyframes pld-sheet { from { transform: translateY(14px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
/* Short viewports (landscape phones, small laptops): keep the anchored menu
inside the screen rather than letting it run past the fold. */
@media (min-width: 721px) and (max-height: 620px) {
.dash-root .pld-move-menu { max-height: 48vh; }
}
+2 -2
View File
@@ -18,12 +18,12 @@ import { TeamManagement } from "./team-management";
import { MessengerSdk } from "./messenger-sdk";
import { InboxSdk } from "./inbox-sdk";
import { Settings } from "./settings";
import { Projects } from "./projects";
import { NotificationCenter } from "./notification-center";
import { RealtimeProvider } from "@/lib/realtime";
import { SmartGallery } from "./smart-gallery";
import { Leads } from "./leads";
import { Verify } from "./verify";
import { Pipeline } from "./pipeline";
import "../../app/dashboard/dashboard.css";
export function Dashboard() {
@@ -88,8 +88,8 @@ export function Dashboard() {
: active === "gallery" ? <SmartGallery theme={theme} />
: active === "leads" ? <Leads />
: active === "verify" ? <Verify />
: active === "pipeline" ? <Pipeline />
: active === "team" ? <TeamManagement />
: active === "projects" ? <Projects />
: <ComingSoon title={title} icon={item?.icon ?? "dashboard"} onGo={setActive} />}
</ToastProvider>
</div>
+1
View File
@@ -61,6 +61,7 @@ const G = {
cyan: "linear-gradient(135deg,#34c9d6,#1f9aa4)",
};
// leads data — a small sample of the full book (TOTAL_LEADS)
export const LEADS: Lead[] = [
{
id: "SAL-001", initials: "JM", name: "John Martinez", gradient: G.orange,
+314
View File
@@ -0,0 +1,314 @@
// ============================================================
// LynkedUp Pro — Pipeline (kanban) mock data.
// Mirrors the /owner/kanban board: seven progression stages
// (New Lead → Complete) plus two flag columns (Stuck,
// Follow-Up). Each lead carries the fields the detail drawer
// needs (contact, job, assignment, notes, timeline) and its
// activity trail is generated from the stage it has reached.
// All client-side so the board is fully interactive.
// ============================================================
export type StageKey =
| "new" | "contacted" | "appt" | "estimate" | "signed" | "progress" | "complete"
| "stuck" | "followup";
export type Stage = { key: StageKey; label: string; tone: string; flag?: boolean };
export const STAGES: Stage[] = [
{ key: "new", label: "New Lead", tone: "blue" },
{ key: "contacted", label: "Contacted", tone: "purple" },
{ key: "appt", label: "Appt Scheduled", tone: "magenta" },
{ key: "estimate", label: "Estimate Sent", tone: "orange" },
{ key: "signed", label: "Signed", tone: "green" },
{ key: "progress", label: "In Progress", tone: "cyan" },
{ key: "complete", label: "Complete", tone: "green" },
{ key: "stuck", label: "Stuck", tone: "red", flag: true },
{ key: "followup", label: "Follow-Up", tone: "orange", flag: true },
];
// The linear progression — these are the "7 stages" shown in the header.
export const PROGRESSION: StageKey[] = ["new", "contacted", "appt", "estimate", "signed", "progress", "complete"];
const STAGE_LABEL: Record<StageKey, string> = Object.fromEntries(STAGES.map((s) => [s.key, s.label])) as Record<StageKey, string>;
/** Percent-complete for the drawer progress bar (0100), based on furthest progression reached. */
export function pctFor(reached: StageKey): number {
const i = PROGRESSION.indexOf(reached);
if (i < 0) return 0;
return Math.round(((i + 1) / PROGRESSION.length) * 100);
}
export type Phone = { number: string; type: string; primary?: boolean };
export type Insurance = {
company: string; claimStatus: string; claimNumber: string;
policyNumber: string; adjusterName: string; adjusterPhone: string;
};
export type Assignment = {
assignedTo: string; priority: string; followUp: string;
createdBy: string; createdAt: string;
};
export type PLead = {
id: string;
initials: string;
name: string;
gradient: string;
stage: StageKey; // which column it currently sits in
reached: StageKey; // furthest progression stage reached (== stage for progression columns)
address: string;
city: string;
state: string;
zip: string;
workType: string; // "Roof Replacement"
leadType: string; // "Insurance" | "Retail"
rep: string; // "Cody Tatum" | "Unassigned"
storm: boolean;
ageDays: number; // days in pipeline → shown as "59d"
phone: string;
email: string;
notes: string;
createdAt: string; // "Feb 10, 2026"
// --- detail page only (the board + drawer ignore these) ---
propertyType: string; // "Single Family"
phones: Phone[]; // primary mobile + a secondary line
source: string; // "Door Knock"
tradeType: string; // derived from workType
urgency: string; // "Standard" | "High" | "Emergency"
insurance: Insurance | null; // null for Retail jobs — no claim to track
assignment: Assignment;
};
export type Activity = { from?: string; to: string; by: string; date: string; note: string };
const G = [
"linear-gradient(135deg,#fda913,#fd6d13)",
"linear-gradient(135deg,#4f8cff,#2c5cff)",
"linear-gradient(135deg,#b07bf2,#7b53e0)",
"linear-gradient(135deg,#33c98a,#1fa46c)",
"linear-gradient(135deg,#34c9d6,#1f9aa4)",
"linear-gradient(135deg,#f0563f,#c9372a)",
"linear-gradient(135deg,#d452c8,#a02f97)",
];
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
// Fixed reference "today" — deterministic (no Date.now) so server and client render identically.
const BASE = Date.UTC(2026, 6, 24);
const DAY = 86_400_000;
function fmt(ts: number): string {
const d = new Date(ts);
return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`;
}
function initialsOf(name: string): string {
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase();
}
function emailOf(name: string): string {
const [first, last] = name.toLowerCase().split(/\s+/);
return `${first[0]}${last}@gmail.com`;
}
const AREA = ["972", "469", "214"];
function phoneOf(i: number): string {
const area = AREA[i % AREA.length];
const mid = 200 + ((i * 37) % 700);
const end = 1000 + ((i * 53) % 8999);
return `(${area}) ${mid}-${end}`;
}
// Terse rows keep the file readable; full records are expanded below.
// [ name, stage, reached, address, workType, leadType, rep, storm, ageDays ]
type Row = [string, StageKey, StageKey, string, string, string, string, boolean, number];
const ROWS: Row[] = [
// New Lead
["Derek Holloway", "new", "new", "2814 Ravenswood Dr", "Roof Replacement", "Insurance", "Cody Tatum", true, 59],
["Brenda Castillo", "new", "new", "5501 Shady Brook Ln", "Roof Inspection", "Retail", "Unassigned", false, 58],
["Antonio Reyes", "new", "new", "1122 Custer Rd", "Gutter Repair", "Retail", "Hannah Reyes", false, 57],
["Sylvia Nguyen", "new", "new", "3308 Roundrock Trl", "Siding Repair", "Insurance", "Shelby Greer", true, 56],
["Raymond Osei", "new", "new", "4720 Preston Rd", "Roof Replacement", "Insurance", "Unassigned", false, 55],
["Carolyn Estrada", "new", "new", "6013 Ohio Dr", "Window Replacement","Retail", "Dalton Pruitt",false, 54],
// Contacted
["Marcus Tillman", "contacted", "contacted", "2201 Willow Bend Dr", "Roof Replacement", "Insurance", "Cody Tatum", true, 65],
["Diane Kowalski", "contacted", "contacted", "815 Independence Pkwy", "Roof Inspection", "Retail", "Hannah Reyes", false, 64],
["Joel Fernandez", "contacted", "contacted", "3901 Legacy Dr", "Siding Replacement","Insurance","Shelby Greer", true, 66],
["Patricia Yuen", "contacted", "contacted", "7742 Kings Rd", "Gutter Repair", "Retail", "Travis Boone", false, 63],
["Gerald Obi", "contacted", "contacted", "5220 Midway Rd", "Roof Replacement", "Insurance", "Cody Tatum", true, 68],
// Appt Scheduled
["Vanessa Obrien", "appt", "appt", "1450 Park Blvd", "Roof Replacement", "Insurance", "Shelby Greer", true, 72],
["Thomas Greer", "appt", "appt", "892 Mapleshade Ln", "Roof Inspection", "Retail", "Hannah Reyes", false, 70],
["Lashonda Webb", "appt", "appt", "3015 Plano Pkwy", "Siding + Gutters", "Insurance", "Cody Tatum", true, 75],
["Winston Park", "appt", "appt", "6601 Ohio Dr", "Roof Replacement", "Insurance", "Dalton Pruitt",true, 71],
["Ingrid Solis", "appt", "appt", "2244 Aster Ct", "Roof Replacement", "Retail", "Shelby Greer", false, 67],
["Darnell Brooks", "appt", "appt", "4406 Springbrook Dr", "Chimney + Roof Repair","Insurance","Hannah Reyes",false, 80],
// Estimate Sent
["Gloria Hutchins", "estimate", "estimate", "1738 Roundrock Trl", "Roof Replacement", "Insurance", "Cody Tatum", true, 85],
["Devon Mathis", "estimate", "estimate", "5902 Braewood Dr", "Siding Replacement","Insurance","Shelby Greer", true, 82],
["Karen Blankenship","estimate","estimate", "3127 Arbor Creek Dr","Roof Replacement", "Retail", "Hannah Reyes", false, 78],
["Lionel Chambers", "estimate", "estimate", "720 Coit Rd", "Gutters + Fascia", "Insurance", "Dalton Pruitt",false, 89],
["Ruthanne Patel", "estimate", "estimate", "4321 Hedgcoxe Rd", "Roof Replacement", "Insurance", "Cody Tatum", false, 98],
// Signed
["Charlotte Norris","signed", "signed", "2908 Roundabout Ln", "Roof Replacement", "Insurance", "Shelby Greer", true, 89],
["Henry Castaneda", "signed", "signed", "5614 Ridgewood Dr", "Siding Replacement","Insurance","Hannah Reyes", false, 90],
["Yvonne Blackwell","signed", "signed", "3401 Parker Rd", "Roof Replacement", "Retail", "Cody Tatum", false, 85],
["Emmanuel Okafor", "signed", "signed", "4812 Mapleshade Ln", "Roof + Gutters", "Insurance", "Dalton Pruitt",true, 87],
["Adriana Voss", "signed", "signed", "1820 Custer Rd", "Roof Replacement", "Insurance", "Shelby Greer", true, 103],
// In Progress
["Marvin Delgado", "progress", "progress", "6820 Windhaven Pkwy", "Roof Replacement", "Insurance", "Cody Tatum", true, 108],
["Sandra Tran", "progress", "progress", "2506 Springpark Dr", "Siding Replacement","Insurance","Hannah Reyes", false, 110],
["Reginald Hopper", "progress", "progress", "1504 Estates Dr", "Roof + Gutters", "Insurance", "Shelby Greer", false, 113],
["Nadia Thornton", "progress", "progress", "5150 Preston Rd", "Roof Replacement", "Retail", "Dalton Pruitt",false, 106],
["Clifford Aguilar","progress", "progress", "3622 Haverwood Ln", "Siding Repair", "Insurance", "Cody Tatum", true, 100],
["Tamara Owens", "progress", "progress", "2010 Oak Creek Blvd", "Roof Replacement", "Insurance", "Hannah Reyes", true, 117],
// Complete
["Craig Singleton", "complete", "complete", "4110 Lorimar Dr", "Roof Replacement", "Insurance", "Cody Tatum", false, 134],
["Bertha Fleming", "complete", "complete", "6230 West Parker Rd", "Siding Replacement","Insurance","Shelby Greer", false, 139],
["Oliver Stanton", "complete", "complete", "3750 Legacy Dr", "Roof Replacement", "Retail", "Hannah Reyes", false, 129],
["Daphne Garrett", "complete", "complete", "1912 Mira Vista Dr", "Gutters + Fascia", "Insurance", "Dalton Pruitt",false, 124],
["Nathaniel Cruz", "complete", "complete", "5808 Tennyson Pkwy", "Roof Replacement", "Insurance", "Shelby Greer", true, 144],
// Stuck (flag) — reached shows where they stalled
["Jasmine Fowler", "stuck", "estimate", "2730 Hedgcoxe Rd", "Roof Replacement", "Insurance", "Travis Boone", false, 93],
["Calvin Merritt", "stuck", "appt", "3640 Alma Dr", "Siding Replacement","Insurance","Hannah Reyes", false, 80],
["Harriet Simmons", "stuck", "contacted","4905 Ohio Dr", "Roof Replacement", "Retail", "Dalton Pruitt",false, 85],
// Follow-Up (flag)
["Floyd Saunders", "followup", "contacted","1344 Spring Creek Pkwy","Roof Replacement","Insurance","Cody Tatum", true, 75],
["Miriam Obando", "followup", "new", "2217 Independence Pkwy","Roof Inspection", "Retail", "Shelby Greer", false, 70],
["Jerome Watkins", "followup", "contacted","3908 Plano Pkwy", "Gutter Replacement","Insurance","Travis Boone",false, 82],
];
// Per-lead note overrides keyed by name (the rest fall back to a stage template).
const NOTE_OVERRIDE: Record<string, string> = {
"Marvin Delgado": "Tear-off complete. New decking going on today.",
"Derek Holloway": "Homeowner reported hail damage after the April storm; door-knocked.",
"Jasmine Fowler": "Estimate sent but homeowner has gone quiet — three follow-ups, no reply.",
"Floyd Saunders": "Wants to wait until his insurance adjuster visits next week.",
};
function noteFor(reached: StageKey, storm: boolean): string {
switch (reached) {
case "new": return storm ? "Storm canvassing — fresh lead." : "Inbound lead — needs first contact.";
case "contacted": return "Spoke with homeowner; interest confirmed.";
case "appt": return "Inspection appointment scheduled.";
case "estimate": return "Estimate delivered; awaiting decision.";
case "signed": return "Contract signed; production queued.";
case "progress": return "Crew on site — job underway.";
case "complete": return "Job complete. Final invoice cleared.";
default: return "";
}
}
/* ---------- detail-page derivations (deterministic, no Date.now) ---------- */
const INSURERS = ["State Farm", "Allstate", "Farmers", "USAA", "Liberty Mutual", "Travelers"];
const ADJUSTERS = ["Marcus Powell", "Dana Whitfield", "Renee Alvarez", "Curtis Boyd", "Priya Raman", "Gordon Leach"];
const SETTERS = ["Cody Tatum", "Shelby Greer", "Hannah Reyes", "Dalton Pruitt", "Travis Boone"];
const PROPERTY_TYPES = ["Single Family", "Single Family", "Single Family", "Townhome", "Multi-Family"];
// Claim state tracks how far the job has progressed.
const CLAIM_BY_STAGE: Record<StageKey, string> = {
new: "Not Filed", contacted: "Filed", appt: "Adjuster Scheduled",
estimate: "Under Review", signed: "Approved", progress: "Approved",
complete: "Paid", stuck: "Stalled", followup: "Filed",
};
function tradeOf(workType: string): string {
const hits = [
[/roof|chimney/i, "Roofing"],
[/siding/i, "Siding"],
[/gutter|fascia/i,"Gutters"],
[/window/i, "Windows"],
] as const;
const matched = hits.filter(([re]) => re.test(workType));
if (matched.length > 1) return "Multi-Trade";
return matched[0]?.[1] ?? "General";
}
function urgencyOf(storm: boolean, ageDays: number): string {
if (storm && ageDays > 100) return "Emergency";
return storm ? "High" : "Standard";
}
function priorityOf(storm: boolean, ageDays: number): string {
if (storm) return "High";
return ageDays >= 90 ? "Medium" : "Low";
}
export const LEADS: PLead[] = ROWS.map(([name, stage, reached, address, workType, leadType, rep, storm, ageDays], i) => ({
id: `SAL-${(101 + i).toString()}`,
initials: initialsOf(name),
name,
gradient: G[i % G.length],
stage,
reached,
address,
city: "Plano",
state: "TX",
zip: "750" + (21 + (i % 5)).toString().padStart(2, "0"),
workType,
leadType,
rep,
storm,
ageDays,
phone: phoneOf(i),
email: emailOf(name),
notes: NOTE_OVERRIDE[name] ?? noteFor(reached, storm),
createdAt: fmt(BASE - ageDays * DAY),
propertyType: PROPERTY_TYPES[i % PROPERTY_TYPES.length],
phones: [
{ number: phoneOf(i), type: "Mobile", primary: true },
{ number: phoneOf(i + 17), type: i % 3 === 0 ? "Work" : "Home" },
],
source: storm ? "Storm Canvass" : ["Door Knock", "Referral", "Inbound Call", "Web Form"][i % 4],
tradeType: tradeOf(workType),
urgency: urgencyOf(storm, ageDays),
insurance: leadType !== "Insurance" ? null : {
company: INSURERS[i % INSURERS.length],
claimStatus: CLAIM_BY_STAGE[stage],
claimNumber: `CLM-2026-${1100 + i}`,
policyNumber: `POL-08${1100 + i}`,
adjusterName: ADJUSTERS[i % ADJUSTERS.length],
adjusterPhone: phoneOf(i + 31),
},
assignment: {
assignedTo: rep,
priority: priorityOf(storm, ageDays),
followUp: fmt(BASE + (3 + (i % 7)) * DAY),
createdBy: SETTERS[i % SETTERS.length],
createdAt: fmt(BASE - ageDays * DAY),
},
}));
/** Build the drawer Activity trail from the stage a lead has reached. */
export function buildActivity(lead: PLead): Activity[] {
const by = lead.rep === "Unassigned" ? "System" : lead.rep;
const items: Activity[] = [];
const created = BASE - lead.ageDays * DAY;
items.push({ to: "Lead Created", by, date: fmt(created), note: lead.storm ? "Storm canvassing." : "Inbound lead." });
const reachedIdx = PROGRESSION.indexOf(lead.reached);
const stepNotes: Record<StageKey, string> = {
new: "", contacted: "Call completed.", appt: "Inspection scheduled.",
estimate: "Estimate sent.", signed: "Signed. Production team notified.",
progress: "Production started — crew on site.", complete: "Job complete. Final invoice cleared.",
stuck: "", followup: "",
};
const steps = Math.max(reachedIdx, 0);
for (let i = 0; i < steps; i++) {
const from = PROGRESSION[i];
const to = PROGRESSION[i + 1];
const when = created + Math.round(((i + 1) / (steps + 1)) * lead.ageDays) * DAY;
items.push({ from: STAGE_LABEL[from], to: STAGE_LABEL[to], by, date: fmt(when), note: stepNotes[to] });
}
if (lead.stage === "stuck") {
items.push({ from: STAGE_LABEL[lead.reached], to: "Stuck", by: "System", date: fmt(BASE - 3 * DAY), note: "Flagged — no response from homeowner." });
} else if (lead.stage === "followup") {
items.push({ from: STAGE_LABEL[lead.reached], to: "Follow-Up", by, date: fmt(BASE - 2 * DAY), note: "Moved to follow-up queue." });
}
return items;
}
// Header stat — the loaded board is a slice of the full book.
export const TOTAL_LEADS = LEADS.length;
+510
View File
@@ -0,0 +1,510 @@
"use client";
// ============================================================
// Pipeline — storm-restoration kanban board.
// · Head : title + counts, search
// · Board : horizontal stage columns (dot, count, progress
// bar). Cards are draggable between columns.
// · Card : avatar, name, address, work/lead-type pills,
// rep + storm badge + age.
// · Drawer : click a card → right slide-over with a stage
// stepper, Details (contact / job / assignment /
// notes / timeline) and an Activity trail.
// · Page : "More Details" in the drawer swaps the board for
// a full-width lead record (hero + Move Stage picker
// + stage stepper + Contact / Property / Job /
// Insurance / Assignment).
// Data comes from pipeline-data.ts (client-side mock).
// ============================================================
import { useMemo, useState, type ReactNode } from "react";
import { Avatar, Btn, Icon, Pill, useToast } from "./ui";
import {
LEADS, STAGES, PROGRESSION, pctFor, buildActivity,
type PLead, type Stage, type StageKey,
} from "./pipeline-data";
export function Pipeline() {
const toast = useToast();
const [leads, setLeads] = useState<PLead[]>(LEADS);
const [query, setQuery] = useState("");
const [selected, setSelected] = useState<PLead | null>(null);
// Full-page detail is keyed by id (not a snapshot) so a stage move stays in sync.
const [detailId, setDetailId] = useState<string | null>(null);
const [dragId, setDragId] = useState<string | null>(null);
const [overStage, setOverStage] = useState<StageKey | null>(null);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return leads;
return leads.filter((l) =>
`${l.name} ${l.address} ${l.rep} ${l.workType} ${l.id}`.toLowerCase().includes(q));
}, [leads, query]);
const byStage = (key: StageKey) => filtered.filter((l) => l.stage === key);
// Single source of truth for a stage change — used by the board's drag-drop
// and by the detail page's "Move Stage" picker.
function setStage(id: string, stage: StageKey) {
const lead = leads.find((l) => l.id === id);
if (!lead || lead.stage === stage) return;
setLeads((prev) =>
prev.map((l) => {
if (l.id !== id) return l;
// Landing on a progression column advances "reached" if it's further along.
const inProg = PROGRESSION.indexOf(stage);
const reached = inProg > PROGRESSION.indexOf(l.reached) ? stage : l.reached;
return { ...l, stage, reached };
}));
const label = STAGES.find((s) => s.key === stage)?.label ?? stage;
toast.push({ tone: "success", title: "Lead moved", desc: `${lead.name}${label}` });
}
function moveTo(stage: StageKey) {
if (dragId) setStage(dragId, stage);
setDragId(null);
setOverStage(null);
}
const stageCount = STAGES.filter((s) => !s.flag).length;
// Detail takes over the whole view — the board stays mounted behind it in state only.
const detail = detailId ? leads.find((l) => l.id === detailId) ?? null : null;
if (detail) {
return (
<LeadDetailPage
lead={detail}
onBack={() => setDetailId(null)}
onMoveStage={(stage) => setStage(detail.id, stage)}
/>
);
}
return (
<div className="view pl">
{/* ---- head -------------------------------------------- */}
<div className="pl-head">
<div className="pl-head-l">
<span className="pl-head-ic"><Icon name="pipeline" size={20} /></span>
<div>
<h1>Pipeline</h1>
<p>{leads.length} leads · {stageCount} stages</p>
</div>
</div>
<div className="pl-search">
<Icon name="search" size={16} />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search leads…"
aria-label="Search leads"
/>
{query && <button className="pl-search-x" aria-label="Clear" onClick={() => setQuery("")}><Icon name="x" size={14} /></button>}
</div>
</div>
{/* ---- board ------------------------------------------- */}
<div className="pl-board">
{STAGES.map((stage) => {
const items = byStage(stage.key);
const step = PROGRESSION.indexOf(stage.key);
return (
<section
key={stage.key}
className={`pl-col ${stage.flag ? "is-flag" : ""} ${overStage === stage.key ? "is-over" : ""}`}
data-tone={stage.tone}
onDragOver={(e) => { e.preventDefault(); setOverStage(stage.key); }}
onDragLeave={() => setOverStage((s) => (s === stage.key ? null : s))}
onDrop={() => moveTo(stage.key)}
>
<header className="pl-col-head">
<span className="pl-col-dot" />
<span className="pl-col-label">{stage.label}</span>
<span className="pl-col-count">{items.length}</span>
{step >= 0 && <span className="pl-col-step">{step + 1}/{stageCount}</span>}
</header>
<div className="pl-col-bar"><span style={{ width: step >= 0 ? `${((step + 1) / stageCount) * 100}%` : "100%" }} /></div>
<div className="pl-col-body">
{items.map((l) => (
<PipelineCard
key={l.id}
lead={l}
onOpen={() => setSelected(l)}
onDragStart={() => setDragId(l.id)}
onDragEnd={() => { setDragId(null); setOverStage(null); }}
dragging={dragId === l.id}
/>
))}
{items.length === 0 && <div className="pl-col-empty">Drop leads here</div>}
</div>
</section>
);
})}
</div>
<LeadDrawer
lead={selected}
onClose={() => setSelected(null)}
onMore={() => { setDetailId(selected?.id ?? null); setSelected(null); }}
/>
</div>
);
}
/* ---------------------------------------------------------- */
/* Card */
/* ---------------------------------------------------------- */
function PipelineCard({ lead, onOpen, onDragStart, onDragEnd, dragging }: {
lead: PLead; onOpen: () => void; onDragStart: () => void; onDragEnd: () => void; dragging: boolean;
}) {
return (
<article
className={`pl-card ${dragging ? "is-dragging" : ""}`}
draggable
onDragStart={(e) => { e.dataTransfer.effectAllowed = "move"; onDragStart(); }}
onDragEnd={onDragEnd}
onClick={onOpen}
role="button"
tabIndex={0}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onOpen(); } }}
>
<div className="pl-card-top">
<Avatar initials={lead.initials} gradient={lead.gradient} size={38} square />
<div className="pl-card-id">
<div className="pl-card-name">{lead.name}</div>
<div className="pl-card-addr"><Icon name="pin" size={12} /> {lead.address}</div>
</div>
</div>
<div className="pl-card-tags">
<span className="pl-tag work">{lead.workType}</span>
<span className="pl-tag type">{lead.leadType}</span>
</div>
<div className="pl-card-foot">
<span className="pl-rep"><Icon name="user" size={12} /> {lead.rep}</span>
<span className="pl-card-spacer" />
{lead.storm && <span className="pl-storm"><Icon name="storm" size={12} /> Storm</span>}
<span className="pl-age"><Icon name="clock" size={12} /> {lead.ageDays}d</span>
</div>
</article>
);
}
/* ---------------------------------------------------------- */
/* Detail drawer */
/* ---------------------------------------------------------- */
function LeadDrawer({ lead, onClose, onMore }: { lead: PLead | null; onClose: () => void; onMore: () => void }) {
const [tab, setTab] = useState<"details" | "activity">("details");
if (!lead) return null;
const stage = STAGES.find((s) => s.key === lead.stage);
const pct = pctFor(lead.reached);
const reachedIdx = PROGRESSION.indexOf(lead.reached);
const progStages = STAGES.filter((s) => PROGRESSION.includes(s.key));
const activity = buildActivity(lead);
return (
<>
<div className="pl-scrim" onClick={onClose} />
<aside className="pl-drawer" data-tone={stage?.tone} role="dialog" aria-label={`${lead.name} details`}>
<div className="pl-drawer-head">
<Avatar initials={lead.initials} gradient={lead.gradient} size={44} square />
<div className="pl-drawer-id">
<div className="pl-drawer-name">{lead.name}</div>
<div className="pl-drawer-addr">{lead.address}, {lead.city} {lead.state} {lead.zip}</div>
</div>
<button className="pl-drawer-x" aria-label="Close" onClick={onClose}><Icon name="x" size={18} /></button>
</div>
{/* status + stepper */}
<div className="pl-drawer-status">
<div className="pl-status-row">
<span className="pl-status-label">{stage?.label}</span>
<span className="pl-status-pct">{pct}%</span>
</div>
<div className="pl-status-bar"><span style={{ width: `${pct}%` }} /></div>
<div className="pl-steps">
{progStages.map((s, i) => (
<span key={s.key} className={`pl-step ${i <= reachedIdx ? "done" : ""}`} data-tone={s.tone}>
<i className="pl-step-dot" />
<span className="pl-step-label">{s.label}</span>
</span>
))}
</div>
</div>
<div className="pl-drawer-tabs" role="tablist">
<button role="tab" aria-selected={tab === "details"} className={tab === "details" ? "active" : ""} onClick={() => setTab("details")}>Details</button>
<button role="tab" aria-selected={tab === "activity"} className={tab === "activity" ? "active" : ""} onClick={() => setTab("activity")}>Activity</button>
</div>
<div className="pl-drawer-body">
{tab === "details" ? (
<>
<DSection title="Contact">
<div className="pl-contact"><Icon name="phone" size={14} /> {lead.phone}</div>
<div className="pl-contact"><Icon name="mail" size={14} /> {lead.email}</div>
<div className="pl-contact"><Icon name="pin" size={14} /> {lead.address}, {lead.city} {lead.state} {lead.zip}</div>
</DSection>
<DSection title="Job Details">
<div className="pl-jobgrid">
<div className="pl-jobbox"><span className="pl-jobk">Type</span><span className="pl-jobv">{lead.workType}</span></div>
<div className="pl-jobbox"><span className="pl-jobk">Category</span><span className="pl-jobv">{lead.leadType}</span></div>
</div>
</DSection>
<DSection title="Assignment">
<div className="pl-assign"><Icon name="user" size={14} /> {lead.rep}</div>
</DSection>
<DSection title="Notes">
<div className="pl-notes">{lead.notes}</div>
</DSection>
<DSection title="Timeline">
<div className="pl-contact"><Icon name="clock" size={14} /> Created {lead.createdAt}</div>
</DSection>
</>
) : (
<div className="pl-activity">
{activity.map((a, i) => (
<div className="pl-act" key={i}>
<span className="pl-act-node" />
<div className="pl-act-body">
<div className="pl-act-head">
{a.from ? <><span className="pl-act-from">{a.from}</span> <Icon name="arrow" size={12} /> <span className="pl-act-to">{a.to}</span></> : <span className="pl-act-to">{a.to}</span>}
</div>
<div className="pl-act-meta">by {a.by} · {a.date}</div>
{a.note && <div className="pl-act-note">{a.note}</div>}
</div>
</div>
))}
</div>
)}
</div>
<div className="pl-drawer-foot">
<button className="pl-more" onClick={onMore}>
<Icon name="arrow" size={15} /> More Details
</button>
</div>
</aside>
</>
);
}
function DSection({ title, children }: { title: string; children: ReactNode }) {
return (
<div className="pl-dsec">
<div className="pl-dsec-head">{title}</div>
<div className="pl-dsec-body">{children}</div>
</div>
);
}
/* ---------------------------------------------------------- */
/* Full-page lead detail — opened from the drawer's */
/* "More Details". Takes over the Pipeline view; the board */
/* is one "← Pipeline" click away. */
/* ---------------------------------------------------------- */
function LeadDetailPage({ lead, onBack, onMoveStage }: {
lead: PLead; onBack: () => void; onMoveStage: (stage: StageKey) => void;
}) {
const toast = useToast();
const [moveOpen, setMoveOpen] = useState(false);
const stage = STAGES.find((s) => s.key === lead.stage);
const pct = pctFor(lead.reached);
const reachedIdx = PROGRESSION.indexOf(lead.reached);
const progStages = STAGES.filter((s) => PROGRESSION.includes(s.key));
const primaryPhone = lead.phones.find((p) => p.primary) ?? lead.phones[0];
return (
<div className="view pld" data-tone={stage?.tone}>
{/* ---- breadcrumb -------------------------------------- */}
<nav className="pld-crumbs" aria-label="Breadcrumb">
<button className="pld-back" onClick={onBack}>
<Icon name="arrow" size={15} className="pld-back-ic" /> Pipeline
</button>
<Icon name="chevron-right" size={14} />
<span className="pld-crumb-now">{lead.name}</span>
<span className="pld-crumb-id">{lead.id}</span>
</nav>
{/* ---- hero -------------------------------------------- */}
<header className="pld-hero">
<Avatar initials={lead.initials} gradient={lead.gradient} size={64} square />
<div className="pld-hero-id">
<h1 className="pld-hero-name">{lead.name}</h1>
<div className="pld-hero-addr">
<Icon name="pin" size={13} /> {lead.address}, {lead.city} {lead.state} {lead.zip}
</div>
<div className="pld-hero-pills">
<Pill tone={stage?.tone ?? "muted"}>{stage?.label}</Pill>
<Pill tone="blue">{lead.leadType}</Pill>
<Pill tone="muted">{lead.workType}</Pill>
{lead.storm && <Pill tone="orange"><Icon name="storm" size={11} /> Storm</Pill>}
<Pill tone="muted"><Icon name="clock" size={11} /> {lead.ageDays}d in pipeline</Pill>
</div>
</div>
<div className="pld-hero-actions">
<StagePicker current={lead.stage} open={moveOpen} onToggle={setMoveOpen} onPick={onMoveStage} />
<Btn variant="outline" icon="phone" onClick={() => toast.push({ tone: "info", title: "Call", desc: `${lead.name} · ${primaryPhone?.number}` })}>Call</Btn>
<Btn variant="outline" icon="mail" onClick={() => toast.push({ tone: "info", title: "Email", desc: lead.email })}>Email</Btn>
</div>
</header>
{/* ---- stage progress ---------------------------------- */}
<section className="pld-progress">
<div className="pld-progress-row">
<span className="pld-progress-label">{stage?.label}</span>
<span className="pld-progress-pct">{pct}% complete</span>
</div>
<div className="pld-progress-bar"><span style={{ width: `${pct}%` }} /></div>
<ol className="pld-steps">
{progStages.map((s, i) => (
<li
key={s.key}
className={`pld-step ${i <= reachedIdx ? "done" : ""} ${i === reachedIdx ? "now" : ""}`}
data-tone={s.tone}
>
<span className="pld-step-dot">{i < reachedIdx ? <Icon name="check" size={11} /> : i + 1}</span>
<span className="pld-step-label">{s.label}</span>
</li>
))}
</ol>
</section>
{/* ---- overview ---------------------------------------- */}
<div className="pld-grid">
<PSection title="Contact" icon="user">
<div className="pld-sublabel">Phone Numbers</div>
{lead.phones.map((p, i) => (
<div className="pld-contact" key={i}>
<Icon name="phone" size={14} />
<span className="pld-contact-val">{p.number}</span>
<span className="pld-contact-tag">{p.type}</span>
{p.primary && <Pill tone="green">Primary</Pill>}
</div>
))}
<div className="pld-sublabel">Email Address</div>
<div className="pld-contact">
<Icon name="mail" size={14} />
<span className="pld-contact-val">{lead.email}</span>
<Pill tone="green">Primary</Pill>
</div>
</PSection>
<PSection title="Property" icon="owners">
<Kv label="Address" value={lead.address} />
<Kv label="City" value={lead.city} />
<Kv label="State" value={lead.state} />
<Kv label="ZIP" value={lead.zip} />
<Kv label="Property Type" value={lead.propertyType} />
</PSection>
<PSection title="Job Details" icon="projects">
<Kv label="Lead Source" value={lead.source} />
<Kv label="Lead Type" value={lead.leadType} />
<Kv label="Work Type" value={lead.workType} />
<Kv label="Trade Type" value={lead.tradeType} />
<Kv label="Urgency" value={lead.urgency} />
<Kv label="Sales Rep" value={lead.rep} />
<div className="pld-sublabel">Field Notes</div>
<p className="pld-notes">{lead.notes}</p>
</PSection>
<PSection title="Insurance" icon="shield">
{lead.insurance ? (
<>
<Kv label="Insurance Company" value={lead.insurance.company} />
<Kv label="Claim Status" value={lead.insurance.claimStatus} />
<Kv label="Claim Number" value={lead.insurance.claimNumber} />
<Kv label="Policy Number" value={lead.insurance.policyNumber} />
<Kv label="Adjuster Name" value={lead.insurance.adjusterName} />
<Kv label="Adjuster Phone" value={lead.insurance.adjusterPhone} />
</>
) : (
<div className="pld-empty">
<Icon name="shield" size={22} />
<p>Retail job no insurance claim on file.</p>
</div>
)}
</PSection>
<PSection title="Assignment" icon="team" wide>
<div className="pld-assign">
<Kv label="Assigned To" value={lead.assignment.assignedTo} />
<Kv label="Priority" value={lead.assignment.priority} />
<Kv label="Follow-Up Date" value={lead.assignment.followUp} />
<Kv label="Created By" value={lead.assignment.createdBy} />
<Kv label="Created At" value={lead.assignment.createdAt} />
</div>
</PSection>
</div>
</div>
);
}
/** "Move Stage" dropdown — the detail-page equivalent of dragging a card. */
function StagePicker({ current, open, onToggle, onPick }: {
current: StageKey; open: boolean; onToggle: (open: boolean) => void; onPick: (stage: StageKey) => void;
}) {
const groups: { title: string; stages: Stage[] }[] = [
{ title: "Pipeline stages", stages: STAGES.filter((s) => !s.flag) },
{ title: "Flags", stages: STAGES.filter((s) => s.flag) },
];
return (
<div className="pld-move" onKeyDown={(e) => { if (e.key === "Escape") onToggle(false); }}>
<Btn icon="pipeline" iconRight="chevron" onClick={() => onToggle(!open)}>Move Stage</Btn>
{open && (
<>
<div className="pld-move-scrim" onClick={() => onToggle(false)} />
<div className="pld-move-menu" role="menu" aria-label="Move to stage">
{groups.map((g) => (
<div className="pld-move-group" key={g.title}>
<div className="pld-move-grouphead">{g.title}</div>
{g.stages.map((s) => (
<button
key={s.key}
role="menuitem"
data-tone={s.tone}
className={`pld-move-item ${s.key === current ? "is-current" : ""}`}
disabled={s.key === current}
onClick={() => { onPick(s.key); onToggle(false); }}
>
<span className="pld-move-dot" />
<span className="pld-move-label">{s.label}</span>
{s.key === current && <Icon name="check" size={14} />}
</button>
))}
</div>
))}
</div>
</>
)}
</div>
);
}
function PSection({ title, icon, children, wide }: { title: string; icon: string; children: ReactNode; wide?: boolean }) {
return (
<section className={`pld-sec ${wide ? "wide" : ""}`}>
<div className="pld-sec-head"><Icon name={icon} size={15} /> {title}</div>
<div className="pld-sec-body">{children}</div>
</section>
);
}
function Kv({ label, value }: { label: string; value: string }) {
return (
<div className="pld-kv">
<span className="pld-kv-k">{label}</span>
<span className="pld-kv-v">{value}</span>
</div>
);
}
-148
View File
@@ -1,148 +0,0 @@
// ============================================================
// 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
@@ -1,258 +0,0 @@
"use client";
// ============================================================
// Projects — Construction jobs + Pipeline leads.
// Data comes from useProjectsData(): the local mock when the
// Shell isn't configured, or the live be-crm data door
// (crm.project.*) when it is. be-crm's Project model is generic
// (name/status/value/owner/address/dates) — mock mode additionally
// carries stage, job type, progress and health, which the live
// table simply doesn't render (nothing backs them).
// ============================================================
import { useMemo, useState } from "react";
import { Btn, Icon, Modal, PageHead, Pill } from "./ui";
import {
QUICK_FILTERS, STATUS_LABEL, STATUS_TONE, useProjectsData,
type UiCollectionRecord, type UiProject, type UiProjectStatus,
} from "@/lib/projects-api";
const money = (n: number) => `$${Math.round(n).toLocaleString()}`;
// The collections ledger shows cents (matches invoice-style amounts); the rest of the page rounds to whole dollars.
const moneyExact = (n: number) => `$${n.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
const COLLECTION_STATUS_LABEL: Record<UiCollectionRecord["status"], string> = { paid: "Paid", pending: "Pending", overdue: "Overdue" };
const COLLECTION_STATUS_TONE: Record<UiCollectionRecord["status"], string> = { paid: "green", pending: "orange", overdue: "red" };
export function Projects() {
const data = useProjectsData();
const { projects, live } = data;
const [tab, setTab] = useState<"construction" | "pipeline">("construction");
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<"all" | UiProjectStatus>("all");
const [viewProject, setViewProject] = useState<UiProject | null>(null);
const construction = useMemo(() => projects.filter((p) => !p.isLead), [projects]);
const pipeline = useMemo(() => projects.filter((p) => p.isLead), [projects]);
const budgetTotal = useMemo(() => construction.reduce((s, p) => s + (p.value ?? 0), 0), [construction]);
const list = tab === "construction" ? construction : pipeline;
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return list.filter((p) => {
const matchesStatus = tab === "pipeline" || statusFilter === "all" || p.status === statusFilter;
const matchesQ = !q
|| p.name.toLowerCase().includes(q)
|| (p.address ?? "").toLowerCase().includes(q)
|| (p.jobType ?? "").toLowerCase().includes(q)
|| p.agent.toLowerCase().includes(q);
return matchesStatus && matchesQ;
});
}, [list, statusFilter, query, tab]);
// Mock construction (richest): Lead | Status | Stage | Job Type | Agent | Progress | Health | ⋯
// Mock pipeline: Lead | Job Type | Stage | Agent | Created | ⋯
// Live (either tab, leaner — only fields be-crm actually stores): Lead | Status | Owner | Value | Due date | ⋯
const template = live
? "minmax(220px,2.3fr) 110px 160px 120px 130px 48px"
: tab === "construction"
? "minmax(220px,2.2fr) 104px 130px 150px 130px 130px 76px 48px"
: "minmax(220px,2.2fr) 150px 140px 130px 120px 48px";
return (
<div className="view proj">
<PageHead
title="Projects"
subtitle={`${construction.length} construction · ${pipeline.length} pipeline leads · ${money(budgetTotal)} budget`}
/>
{data.error && <div className="card proj-empty" style={{ borderColor: "var(--red, #ef4444)" }}><p>Couldn&apos;t load projects: {data.error}</p></div>}
<div className="tm-toolbar proj-toolbar">
<div className="tm-tabs" role="tablist">
<button role="tab" aria-selected={tab === "construction"} className={`tm-tab ${tab === "construction" ? "active" : ""}`} onClick={() => setTab("construction")}>
<Icon name="owners" size={16} /> <span>Construction</span>
<span className="tm-tab-badge">{construction.length}</span>
</button>
<button role="tab" aria-selected={tab === "pipeline"} className={`tm-tab ${tab === "pipeline" ? "active" : ""}`} onClick={() => setTab("pipeline")}>
<Icon name="pipeline" size={16} /> <span>Pipeline Leads</span>
<span className="tm-tab-badge">{pipeline.length}</span>
</button>
</div>
<div className="tm-search">
<Icon name="search" size={16} />
<input className="ds-input flush" placeholder="Search leads by name, address, job type, agent…" value={query} onChange={(e) => setQuery(e.target.value)} />
{query && <button className="tm-search-x" aria-label="Clear" onClick={() => setQuery("")}><Icon name="x" size={14} /></button>}
</div>
{tab === "construction" && (
<div className="tm-chips">
<button className={`tm-chip ${statusFilter === "all" ? "on" : ""}`} onClick={() => setStatusFilter("all")}>
All <i>{construction.length}</i>
</button>
{QUICK_FILTERS.map((s) => (
<button key={s} className={`tm-chip ${statusFilter === s ? "on" : ""}`} style={{ ["--rc" as string]: `var(--${STATUS_TONE[s] === "muted" ? "faint" : STATUS_TONE[s]})` }} onClick={() => setStatusFilter(s)}>
{STATUS_LABEL[s]} <i>{construction.filter((p) => p.status === s).length}</i>
</button>
))}
</div>
)}
</div>
{data.live && data.loading && projects.length === 0 ? (
<div className="card proj-empty"><span className="tm-empty-ic"><Icon name="owners" size={24} /></span><p>Loading projects</p></div>
) : filtered.length === 0 ? (
<div className="card proj-empty">
<span className="tm-empty-ic"><Icon name="search" size={24} /></span>
<p>No {tab === "construction" ? "projects" : "leads"} match your search.</p>
<Btn variant="soft" size="sm" onClick={() => { setQuery(""); setStatusFilter("all"); }}>Clear filters</Btn>
</div>
) : (
<div className="card card-pad-0 proj-table">
<div className="proj-row proj-head" style={{ gridTemplateColumns: template }}>
<span>Lead</span>
{live ? (
<><span>Status</span><span>Owner</span><span className="proj-c-num">Value</span><span>Due date</span></>
) : tab === "construction" ? (
<><span>Status</span><span>Stage</span><span>Job type</span><span>Agent</span><span>Progress</span><span className="proj-c-num">Health</span></>
) : (
<><span>Job type</span><span>Stage</span><span>Agent</span><span>Created</span></>
)}
<span className="proj-c-act" />
</div>
{filtered.map((p) => (
<div className="proj-row" key={p.id} style={{ gridTemplateColumns: template }}>
<div className="proj-lead">
<div className="proj-lead-name">{p.name}</div>
{p.address && <div className="proj-lead-sub">{p.address}</div>}
</div>
{live ? (
<>
<Pill tone={STATUS_TONE[p.status]}>{STATUS_LABEL[p.status].toUpperCase()}</Pill>
<span className="proj-text">{p.agent}</span>
<span className="proj-c-num">{p.value != null ? money(p.value) : <span className="tm-dash"></span>}</span>
<span className="proj-text">{p.dueDate ? new Date(p.dueDate).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }) : <span className="tm-dash"></span>}</span>
</>
) : tab === "construction" ? (
<>
<Pill tone={STATUS_TONE[p.status]}>{STATUS_LABEL[p.status].toUpperCase()}</Pill>
<Pill tone="blue">{p.stage}</Pill>
<span className="proj-text">{p.jobType}</span>
<span className="proj-text">{p.agent}</span>
<div className="proj-progress" title={`${p.progress}%`}>
<span className="proj-progress-track"><span className="proj-progress-fill" style={{ width: `${p.progress ?? 0}%` }} /></span>
<b>{p.progress}%</b>
</div>
<span className="proj-c-num proj-health">{p.health}</span>
</>
) : (
<>
<span className="proj-text">{p.jobType}</span>
<Pill tone="blue">{p.stage}</Pill>
<span className="proj-text">{p.agent}</span>
<span className="proj-text">{p.createdAgo}</span>
</>
)}
<div className="proj-c-act">
<button className="ds-iconbtn" aria-label={`View collections for ${p.name}`} onClick={() => setViewProject(p)}><Icon name="eye" size={17} /></button>
</div>
</div>
))}
</div>
)}
<div className="proj-foot">
<span>Showing {filtered.length} of {list.length} {tab === "construction" ? "projects" : "leads"}</span>
{tab === "construction" && <b>{money(budgetTotal)} total</b>}
</div>
<CollectedDetailsModal project={viewProject} onClose={() => setViewProject(null)} />
</div>
);
}
/* ---------------------------------------------------------- */
/* Collected details modal — payment ledger for one project */
/* ---------------------------------------------------------- */
function CollectedDetailsModal({ project, onClose }: { project: UiProject | null; onClose: () => void }) {
const [query, setQuery] = useState("");
const records = project?.collections ?? [];
const total = useMemo(() => records.reduce((s, r) => s + r.amount, 0), [records]);
if (!project) return null;
const q = query.trim().toLowerCase();
const filtered = records.filter((r) =>
!q || r.name.toLowerCase().includes(q) || r.referenceId.toLowerCase().includes(q) || r.type.toLowerCase().includes(q));
const netTotal = filtered.reduce((s, r) => s + r.amount, 0);
const projectName = project.name;
function downloadCsv() {
const header = ["Name/Project", "Reference ID", "Date", "Type", "Status", "Amount"];
const rows = filtered.map((r) => [r.name, r.referenceId, r.date, r.type, COLLECTION_STATUS_LABEL[r.status], r.amount.toFixed(2)]);
const csv = [header, ...rows].map((row) => row.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${projectName.replace(/\s+/g, "-").toLowerCase()}-collections.csv`;
a.click();
URL.revokeObjectURL(url);
}
return (
<Modal
open={!!project}
onClose={onClose}
title="Total Collected Details"
subtitle={<>Total: <b>{moneyExact(total)}</b></>}
size="xl"
headerExtra={records.length > 0 && (
<button className="ds-iconbtn" aria-label="Download CSV" onClick={downloadCsv}><Icon name="download" size={18} /></button>
)}
>
{project.collections === null ? (
<div className="tm-empty"><span className="tm-empty-ic"><Icon name="card" size={24} /></span><p>Collections aren&apos;t available yet for live projects this needs a billing module in be-crm.</p></div>
) : records.length === 0 ? (
<div className="tm-empty"><span className="tm-empty-ic"><Icon name="card" size={24} /></span><p>No payments collected yet for {project.name}.</p></div>
) : (
<>
<div className="tm-search proj-cd-search">
<Icon name="search" size={16} />
<input className="ds-input flush" placeholder="Search leads by name, address, job type, agent…" value={query} onChange={(e) => setQuery(e.target.value)} />
{query && <button className="tm-search-x" aria-label="Clear" onClick={() => setQuery("")}><Icon name="x" size={14} /></button>}
</div>
<div className="card card-pad-0 proj-table proj-cd-table">
<div className="proj-row proj-head" style={{ gridTemplateColumns: "minmax(180px,2fr) 140px 100px 170px 100px 120px" }}>
<span>Name / Project</span><span>Reference ID</span><span>Date</span><span>Type</span><span>Status</span><span className="proj-c-num">Amount</span>
</div>
{filtered.map((r) => (
<div className="proj-row" key={r.id} style={{ gridTemplateColumns: "minmax(180px,2fr) 140px 100px 170px 100px 120px" }}>
<div className="proj-lead">
<div className="proj-lead-name">{r.name}</div>
<div className="proj-lead-sub">{r.address}</div>
</div>
<span className="proj-text">{r.referenceId}</span>
<span className="proj-text">{r.date}</span>
<span className="proj-text">{r.type}</span>
<Pill tone={COLLECTION_STATUS_TONE[r.status]}>{COLLECTION_STATUS_LABEL[r.status].toUpperCase()}</Pill>
<span className="proj-c-num">{moneyExact(r.amount)}</span>
</div>
))}
</div>
<div className="proj-foot">
<span>Showing {filtered.length} record{filtered.length === 1 ? "" : "s"}</span>
<b>Net Total: {moneyExact(netTotal)}</b>
</div>
</>
)}
</Modal>
);
}
-1
View File
@@ -95,7 +95,6 @@ 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",
+4 -7
View File
@@ -222,9 +222,9 @@ export function OtpField({ length = 6, value, onChange, autoFocus = true }: { le
/* Modal */
/* ---------------------------------------------------------- */
export function Modal({ open, onClose, title, subtitle, icon, children, footer, size = "md", headerExtra }: {
open: boolean; onClose: () => void; title: string; subtitle?: ReactNode; icon?: string;
children: ReactNode; footer?: ReactNode; size?: "sm" | "md" | "lg" | "xl"; headerExtra?: ReactNode;
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";
}) {
const titleId = useId();
// Portal the overlay up to `.dash-root` so its position:fixed anchors to the viewport,
@@ -251,10 +251,7 @@ export function Modal({ open, onClose, title, subtitle, icon, children, footer,
{subtitle && <p>{subtitle}</p>}
</div>
</div>
<div className="ds-modal-head-r">
{headerExtra}
<button className="ds-iconbtn" aria-label="Close" onClick={onClose}><Icon name="x" size={18} /></button>
</div>
<button className="ds-iconbtn" aria-label="Close" onClick={onClose}><Icon name="x" size={18} /></button>
</div>
<div className="ds-modal-body">{children}</div>
{footer && <div className="ds-modal-foot">{footer}</div>}
-201
View File
@@ -1,201 +0,0 @@
"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();
}