From 190ef5f58e2277d01e8d07d7536d0e4b2444b32c Mon Sep 17 00:00:00 2001 From: Mayur Shinde Date: Mon, 27 Jul 2026 14:32:08 +0530 Subject: [PATCH 1/3] piplene page --- src/app/dashboard/dashboard.css | 133 +++++++++++ src/components/dashboard/dashboard.tsx | 2 + src/components/dashboard/pipeline-data.ts | 254 ++++++++++++++++++++ src/components/dashboard/pipeline.tsx | 279 ++++++++++++++++++++++ 4 files changed, 668 insertions(+) create mode 100644 src/components/dashboard/pipeline-data.ts create mode 100644 src/components/dashboard/pipeline.tsx diff --git a/src/app/dashboard/dashboard.css b/src/app/dashboard/dashboard.css index 434eef4..86930a6 100644 --- a/src/app/dashboard/dashboard.css +++ b/src/app/dashboard/dashboard.css @@ -1518,3 +1518,136 @@ .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); } + + @media (max-width: 720px) { + .dash-root .pl-search { width: 100%; max-width: none; } + .dash-root .pl-col { flex-basis: 82vw; width: 82vw; } + } diff --git a/src/components/dashboard/dashboard.tsx b/src/components/dashboard/dashboard.tsx index e821bbe..76971d1 100644 --- a/src/components/dashboard/dashboard.tsx +++ b/src/components/dashboard/dashboard.tsx @@ -23,6 +23,7 @@ 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() { @@ -87,6 +88,7 @@ export function Dashboard() { : active === "gallery" ? : active === "leads" ? : active === "verify" ? + : active === "pipeline" ? : active === "team" ? : } diff --git a/src/components/dashboard/pipeline-data.ts b/src/components/dashboard/pipeline-data.ts new file mode 100644 index 0000000..c43ab83 --- /dev/null +++ b/src/components/dashboard/pipeline-data.ts @@ -0,0 +1,254 @@ +// ============================================================ +// 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 = Object.fromEntries(STAGES.map((s) => [s.key, s.label])) as Record; + +/** Percent-complete for the drawer progress bar (0–100), based on furthest progression reached. */ +export function pctFor(reached: StageKey): number { + const i = PROGRESSION.indexOf(reached); + if (i < 0) return 0; + return Math.round(((i + 1) / PROGRESSION.length) * 100); +} + +export type Phone = { number: string; type: string; primary?: boolean }; + +export type Insurance = { + company: string; claimStatus: string; claimNumber: string; + policyNumber: string; adjusterName: string; adjusterPhone: string; +}; + +export type Assignment = { + assignedTo: string; priority: string; followUp: string; + createdBy: string; createdAt: string; +}; + +export type PLead = { + id: string; + initials: string; + name: string; + gradient: string; + stage: StageKey; // which column it currently sits in + reached: StageKey; // furthest progression stage reached (== stage for progression columns) + address: string; + city: string; + state: string; + zip: string; + workType: string; // "Roof Replacement" + leadType: string; // "Insurance" | "Retail" + rep: string; // "Cody Tatum" | "Unassigned" + storm: boolean; + ageDays: number; // days in pipeline → shown as "59d" + phone: string; + email: string; + notes: string; + createdAt: string; // "Feb 10, 2026" + + // --- detail page only (the board + drawer ignore these) --- + propertyType: string; // "Single Family" + phones: Phone[]; // primary mobile + a secondary line + source: string; // "Door Knock" + tradeType: string; // derived from workType + urgency: string; // "Standard" | "High" | "Emergency" + insurance: Insurance | null; // null for Retail jobs — no claim to track + assignment: Assignment; +}; + +export type Activity = { from?: string; to: string; by: string; date: string; note: string }; + +const G = [ + "linear-gradient(135deg,#fda913,#fd6d13)", + "linear-gradient(135deg,#4f8cff,#2c5cff)", + "linear-gradient(135deg,#b07bf2,#7b53e0)", + "linear-gradient(135deg,#33c98a,#1fa46c)", + "linear-gradient(135deg,#34c9d6,#1f9aa4)", + "linear-gradient(135deg,#f0563f,#c9372a)", + "linear-gradient(135deg,#d452c8,#a02f97)", +]; + +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; +// Fixed reference "today" — deterministic (no Date.now) so server and client render identically. +const BASE = Date.UTC(2026, 6, 24); +const DAY = 86_400_000; + +function fmt(ts: number): string { + const d = new Date(ts); + return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()}`; +} +function initialsOf(name: string): string { + return name.split(/\s+/).filter(Boolean).slice(0, 2).map((p) => p[0]).join("").toUpperCase(); +} +function emailOf(name: string): string { + const [first, last] = name.toLowerCase().split(/\s+/); + return `${first[0]}${last}@gmail.com`; +} +const AREA = ["972", "469", "214"]; +function phoneOf(i: number): string { + const area = AREA[i % AREA.length]; + const mid = 200 + ((i * 37) % 700); + const end = 1000 + ((i * 53) % 8999); + return `(${area}) ${mid}-${end}`; +} + +// Terse rows keep the file readable; full records are expanded below. +// [ name, stage, reached, address, workType, leadType, rep, storm, ageDays ] +type Row = [string, StageKey, StageKey, string, string, string, string, boolean, number]; + +const ROWS: Row[] = [ + // New Lead + ["Derek Holloway", "new", "new", "2814 Ravenswood Dr", "Roof Replacement", "Insurance", "Cody Tatum", true, 59], + ["Brenda Castillo", "new", "new", "5501 Shady Brook Ln", "Roof Inspection", "Retail", "Unassigned", false, 58], + ["Antonio Reyes", "new", "new", "1122 Custer Rd", "Gutter Repair", "Retail", "Hannah Reyes", false, 57], + ["Sylvia Nguyen", "new", "new", "3308 Roundrock Trl", "Siding Repair", "Insurance", "Shelby Greer", true, 56], + ["Raymond Osei", "new", "new", "4720 Preston Rd", "Roof Replacement", "Insurance", "Unassigned", false, 55], + ["Carolyn Estrada", "new", "new", "6013 Ohio Dr", "Window Replacement","Retail", "Dalton Pruitt",false, 54], + // Contacted + ["Marcus Tillman", "contacted", "contacted", "2201 Willow Bend Dr", "Roof Replacement", "Insurance", "Cody Tatum", true, 65], + ["Diane Kowalski", "contacted", "contacted", "815 Independence Pkwy", "Roof Inspection", "Retail", "Hannah Reyes", false, 64], + ["Joel Fernandez", "contacted", "contacted", "3901 Legacy Dr", "Siding Replacement","Insurance","Shelby Greer", true, 66], + ["Patricia Yuen", "contacted", "contacted", "7742 Kings Rd", "Gutter Repair", "Retail", "Travis Boone", false, 63], + ["Gerald Obi", "contacted", "contacted", "5220 Midway Rd", "Roof Replacement", "Insurance", "Cody Tatum", true, 68], + // Appt Scheduled + ["Vanessa Obrien", "appt", "appt", "1450 Park Blvd", "Roof Replacement", "Insurance", "Shelby Greer", true, 72], + ["Thomas Greer", "appt", "appt", "892 Mapleshade Ln", "Roof Inspection", "Retail", "Hannah Reyes", false, 70], + ["Lashonda Webb", "appt", "appt", "3015 Plano Pkwy", "Siding + Gutters", "Insurance", "Cody Tatum", true, 75], + ["Winston Park", "appt", "appt", "6601 Ohio Dr", "Roof Replacement", "Insurance", "Dalton Pruitt",true, 71], + ["Ingrid Solis", "appt", "appt", "2244 Aster Ct", "Roof Replacement", "Retail", "Shelby Greer", false, 67], + ["Darnell Brooks", "appt", "appt", "4406 Springbrook Dr", "Chimney + Roof Repair","Insurance","Hannah Reyes",false, 80], + // Estimate Sent + ["Gloria Hutchins", "estimate", "estimate", "1738 Roundrock Trl", "Roof Replacement", "Insurance", "Cody Tatum", true, 85], + ["Devon Mathis", "estimate", "estimate", "5902 Braewood Dr", "Siding Replacement","Insurance","Shelby Greer", true, 82], + ["Karen Blankenship","estimate","estimate", "3127 Arbor Creek Dr","Roof Replacement", "Retail", "Hannah Reyes", false, 78], + ["Lionel Chambers", "estimate", "estimate", "720 Coit Rd", "Gutters + Fascia", "Insurance", "Dalton Pruitt",false, 89], + ["Ruthanne Patel", "estimate", "estimate", "4321 Hedgcoxe Rd", "Roof Replacement", "Insurance", "Cody Tatum", false, 98], + // Signed + ["Charlotte Norris","signed", "signed", "2908 Roundabout Ln", "Roof Replacement", "Insurance", "Shelby Greer", true, 89], + ["Henry Castaneda", "signed", "signed", "5614 Ridgewood Dr", "Siding Replacement","Insurance","Hannah Reyes", false, 90], + ["Yvonne Blackwell","signed", "signed", "3401 Parker Rd", "Roof Replacement", "Retail", "Cody Tatum", false, 85], + ["Emmanuel Okafor", "signed", "signed", "4812 Mapleshade Ln", "Roof + Gutters", "Insurance", "Dalton Pruitt",true, 87], + ["Adriana Voss", "signed", "signed", "1820 Custer Rd", "Roof Replacement", "Insurance", "Shelby Greer", true, 103], + // In Progress + ["Marvin Delgado", "progress", "progress", "6820 Windhaven Pkwy", "Roof Replacement", "Insurance", "Cody Tatum", true, 108], + ["Sandra Tran", "progress", "progress", "2506 Springpark Dr", "Siding Replacement","Insurance","Hannah Reyes", false, 110], + ["Reginald Hopper", "progress", "progress", "1504 Estates Dr", "Roof + Gutters", "Insurance", "Shelby Greer", false, 113], + ["Nadia Thornton", "progress", "progress", "5150 Preston Rd", "Roof Replacement", "Retail", "Dalton Pruitt",false, 106], + ["Clifford Aguilar","progress", "progress", "3622 Haverwood Ln", "Siding Repair", "Insurance", "Cody Tatum", true, 100], + ["Tamara Owens", "progress", "progress", "2010 Oak Creek Blvd", "Roof Replacement", "Insurance", "Hannah Reyes", true, 117], + // Complete + ["Craig Singleton", "complete", "complete", "4110 Lorimar Dr", "Roof Replacement", "Insurance", "Cody Tatum", false, 134], + ["Bertha Fleming", "complete", "complete", "6230 West Parker Rd", "Siding Replacement","Insurance","Shelby Greer", false, 139], + ["Oliver Stanton", "complete", "complete", "3750 Legacy Dr", "Roof Replacement", "Retail", "Hannah Reyes", false, 129], + ["Daphne Garrett", "complete", "complete", "1912 Mira Vista Dr", "Gutters + Fascia", "Insurance", "Dalton Pruitt",false, 124], + ["Nathaniel Cruz", "complete", "complete", "5808 Tennyson Pkwy", "Roof Replacement", "Insurance", "Shelby Greer", true, 144], + // Stuck (flag) — reached shows where they stalled + ["Jasmine Fowler", "stuck", "estimate", "2730 Hedgcoxe Rd", "Roof Replacement", "Insurance", "Travis Boone", false, 93], + ["Calvin Merritt", "stuck", "appt", "3640 Alma Dr", "Siding Replacement","Insurance","Hannah Reyes", false, 80], + ["Harriet Simmons", "stuck", "contacted","4905 Ohio Dr", "Roof Replacement", "Retail", "Dalton Pruitt",false, 85], + // Follow-Up (flag) + ["Floyd Saunders", "followup", "contacted","1344 Spring Creek Pkwy","Roof Replacement","Insurance","Cody Tatum", true, 75], + ["Miriam Obando", "followup", "new", "2217 Independence Pkwy","Roof Inspection", "Retail", "Shelby Greer", false, 70], + ["Jerome Watkins", "followup", "contacted","3908 Plano Pkwy", "Gutter Replacement","Insurance","Travis Boone",false, 82], +]; + +// Per-lead note overrides keyed by name (the rest fall back to a stage template). +const NOTE_OVERRIDE: Record = { + "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 ""; + } +} + +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), +})); + +/** 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 = { + 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; diff --git a/src/components/dashboard/pipeline.tsx b/src/components/dashboard/pipeline.tsx new file mode 100644 index 0000000..4e721c3 --- /dev/null +++ b/src/components/dashboard/pipeline.tsx @@ -0,0 +1,279 @@ +"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. +// Data comes from pipeline-data.ts (client-side mock). +// ============================================================ + +import { useMemo, useState, type DragEvent } from "react"; +import { Avatar, Icon, useToast } from "./ui"; +import { + LEADS, STAGES, PROGRESSION, pctFor, buildActivity, + type PLead, type StageKey, +} from "./pipeline-data"; + +export function Pipeline() { + const toast = useToast(); + const [leads, setLeads] = useState(LEADS); + const [query, setQuery] = useState(""); + const [selected, setSelected] = useState(null); + const [dragId, setDragId] = useState(null); + const [overStage, setOverStage] = useState(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); + + function moveTo(stage: StageKey) { + if (!dragId) return; + setLeads((prev) => + prev.map((l) => { + if (l.id !== dragId) 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; + const lead = leads.find((l) => l.id === dragId); + if (lead && lead.stage !== stage) toast.push({ tone: "success", title: "Lead moved", desc: `${lead.name} → ${label}` }); + setDragId(null); + setOverStage(null); + } + + const stageCount = STAGES.filter((s) => !s.flag).length; + + return ( +
+ {/* ---- head -------------------------------------------- */} +
+
+ +
+

Pipeline

+

{leads.length} leads · {stageCount} stages

+
+
+
+ + setQuery(e.target.value)} + placeholder="Search leads…" + aria-label="Search leads" + /> + {query && } +
+
+ + {/* ---- board ------------------------------------------- */} +
+ {STAGES.map((stage) => { + const items = byStage(stage.key); + const step = PROGRESSION.indexOf(stage.key); + return ( +
{ e.preventDefault(); setOverStage(stage.key); }} + onDragLeave={() => setOverStage((s) => (s === stage.key ? null : s))} + onDrop={() => moveTo(stage.key)} + > +
+ + {stage.label} + {items.length} + {step >= 0 && {step + 1}/{stageCount}} +
+
= 0 ? `${((step + 1) / stageCount) * 100}%` : "100%" }} />
+ +
+ {items.map((l) => ( + setSelected(l)} + onDragStart={() => setDragId(l.id)} + onDragEnd={() => { setDragId(null); setOverStage(null); }} + dragging={dragId === l.id} + /> + ))} + {items.length === 0 &&
Drop leads here
} +
+
+ ); + })} +
+ + setSelected(null)} /> +
+ ); +} + +/* ---------------------------------------------------------- */ +/* Card */ +/* ---------------------------------------------------------- */ + +function PipelineCard({ lead, onOpen, onDragStart, onDragEnd, dragging }: { + lead: PLead; onOpen: () => void; onDragStart: () => void; onDragEnd: () => void; dragging: boolean; +}) { + return ( +
{ e.dataTransfer.effectAllowed = "move"; onDragStart(); }} + onDragEnd={onDragEnd} + onClick={onOpen} + role="button" + tabIndex={0} + onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onOpen(); } }} + > +
+ +
+
{lead.name}
+
{lead.address}
+
+
+
+ {lead.workType} + {lead.leadType} +
+
+ {lead.rep} + + {lead.storm && Storm} + {lead.ageDays}d +
+
+ ); +} + +/* ---------------------------------------------------------- */ +/* Detail drawer */ +/* ---------------------------------------------------------- */ + +function LeadDrawer({ lead, onClose }: { lead: PLead | null; onClose: () => void }) { + const toast = useToast(); + 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 ( + <> +
+ + + ); +} + +function DSection({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+
{title}
+
{children}
+
+ ); +} -- 2.52.0 From c63bced08b49ae80b8dc5f61ed01e952e444adbe Mon Sep 17 00:00:00 2001 From: Mayur Shinde Date: Mon, 27 Jul 2026 18:27:55 +0530 Subject: [PATCH 2/3] feat(pipeline): full-page lead detail with Move Stage picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/app/dashboard/dashboard.css | 115 ++++++++++ src/components/dashboard/pipeline-data.ts | 60 +++++ src/components/dashboard/pipeline.tsx | 257 ++++++++++++++++++++-- 3 files changed, 419 insertions(+), 13 deletions(-) diff --git a/src/app/dashboard/dashboard.css b/src/app/dashboard/dashboard.css index 86930a6..a7eccdc 100644 --- a/src/app/dashboard/dashboard.css +++ b/src/app/dashboard/dashboard.css @@ -1647,7 +1647,122 @@ .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; } } diff --git a/src/components/dashboard/pipeline-data.ts b/src/components/dashboard/pipeline-data.ts index c43ab83..ac0f7bd 100644 --- a/src/components/dashboard/pipeline-data.ts +++ b/src/components/dashboard/pipeline-data.ts @@ -198,6 +198,42 @@ function noteFor(reached: StageKey, storm: boolean): string { } } +/* ---------- 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 = { + 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), @@ -218,6 +254,30 @@ export const LEADS: PLead[] = ROWS.map(([name, stage, reached, address, workType 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. */ diff --git a/src/components/dashboard/pipeline.tsx b/src/components/dashboard/pipeline.tsx index 4e721c3..89d2aa2 100644 --- a/src/components/dashboard/pipeline.tsx +++ b/src/components/dashboard/pipeline.tsx @@ -10,14 +10,18 @@ // · 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 DragEvent } from "react"; -import { Avatar, Icon, useToast } from "./ui"; +import { useMemo, useState, type ReactNode } from "react"; +import { Avatar, Btn, Icon, Pill, useToast } from "./ui"; import { LEADS, STAGES, PROGRESSION, pctFor, buildActivity, - type PLead, type StageKey, + type PLead, type Stage, type StageKey, } from "./pipeline-data"; export function Pipeline() { @@ -25,6 +29,8 @@ export function Pipeline() { const [leads, setLeads] = useState(LEADS); const [query, setQuery] = useState(""); const [selected, setSelected] = useState(null); + // Full-page detail is keyed by id (not a snapshot) so a stage move stays in sync. + const [detailId, setDetailId] = useState(null); const [dragId, setDragId] = useState(null); const [overStage, setOverStage] = useState(null); @@ -37,25 +43,43 @@ export function Pipeline() { const byStage = (key: StageKey) => filtered.filter((l) => l.stage === key); - function moveTo(stage: StageKey) { - if (!dragId) return; + // 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 !== dragId) return 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; - const lead = leads.find((l) => l.id === dragId); - if (lead && lead.stage !== stage) toast.push({ tone: "success", title: "Lead moved", desc: `${lead.name} → ${label}` }); + 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 ( + setDetailId(null)} + onMoveStage={(stage) => setStage(detail.id, stage)} + /> + ); + } + return (
{/* ---- head -------------------------------------------- */} @@ -119,7 +143,11 @@ export function Pipeline() { })}
- setSelected(null)} /> + setSelected(null)} + onMore={() => { setDetailId(selected?.id ?? null); setSelected(null); }} + />
); } @@ -167,8 +195,7 @@ function PipelineCard({ lead, onOpen, onDragStart, onDragEnd, dragging }: { /* Detail drawer */ /* ---------------------------------------------------------- */ -function LeadDrawer({ lead, onClose }: { lead: PLead | null; onClose: () => void }) { - const toast = useToast(); +function LeadDrawer({ lead, onClose, onMore }: { lead: PLead | null; onClose: () => void; onMore: () => void }) { const [tab, setTab] = useState<"details" | "activity">("details"); if (!lead) return null; @@ -260,7 +287,7 @@ function LeadDrawer({ lead, onClose }: { lead: PLead | null; onClose: () => void
-
@@ -269,7 +296,7 @@ function LeadDrawer({ lead, onClose }: { lead: PLead | null; onClose: () => void ); } -function DSection({ title, children }: { title: string; children: React.ReactNode }) { +function DSection({ title, children }: { title: string; children: ReactNode }) { return (
{title}
@@ -277,3 +304,207 @@ function DSection({ title, children }: { title: string; children: React.ReactNod
); } + +/* ---------------------------------------------------------- */ +/* 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 ( +
+ {/* ---- breadcrumb -------------------------------------- */} + + + {/* ---- hero -------------------------------------------- */} +
+ +
+

{lead.name}

+
+ {lead.address}, {lead.city} {lead.state} {lead.zip} +
+
+ {stage?.label} + {lead.leadType} + {lead.workType} + {lead.storm && Storm} + {lead.ageDays}d in pipeline +
+
+
+ + toast.push({ tone: "info", title: "Call", desc: `${lead.name} · ${primaryPhone?.number}` })}>Call + toast.push({ tone: "info", title: "Email", desc: lead.email })}>Email +
+
+ + {/* ---- stage progress ---------------------------------- */} +
+
+ {stage?.label} + {pct}% complete +
+
+
    + {progStages.map((s, i) => ( +
  1. + {i < reachedIdx ? : i + 1} + {s.label} +
  2. + ))} +
+
+ + {/* ---- overview ---------------------------------------- */} +
+ +
Phone Numbers
+ {lead.phones.map((p, i) => ( +
+ + {p.number} + {p.type} + {p.primary && Primary} +
+ ))} +
Email Address
+
+ + {lead.email} + Primary +
+
+ + + + + + + + + + + + + + + + +
Field Notes
+

{lead.notes}

+
+ + + {lead.insurance ? ( + <> + + + + + + + + ) : ( +
+ +

Retail job — no insurance claim on file.

+
+ )} +
+ + +
+ + + + + +
+
+
+
+ ); +} + +/** "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 ( +
{ if (e.key === "Escape") onToggle(false); }}> + onToggle(!open)}>Move Stage + {open && ( + <> +
onToggle(false)} /> +
+ {groups.map((g) => ( +
+
{g.title}
+ {g.stages.map((s) => ( + + ))} +
+ ))} +
+ + )} +
+ ); +} + +function PSection({ title, icon, children, wide }: { title: string; icon: string; children: ReactNode; wide?: boolean }) { + return ( +
+
{title}
+
{children}
+
+ ); +} + +function Kv({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} -- 2.52.0 From ec3df3415f25b9dcefa5fc2f75e97ddad89043cd Mon Sep 17 00:00:00 2001 From: Mayur Shinde Date: Mon, 27 Jul 2026 18:40:07 +0530 Subject: [PATCH 3/3] comment added --- src/components/dashboard/leads-data.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/dashboard/leads-data.ts b/src/components/dashboard/leads-data.ts index a4a5a82..4fc6d72 100644 --- a/src/components/dashboard/leads-data.ts +++ b/src/components/dashboard/leads-data.ts @@ -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, -- 2.52.0