forked from Goutam/lynkeduppro-crm
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>
This commit is contained in:
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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<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),
|
||||
@@ -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. */
|
||||
|
||||
@@ -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<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);
|
||||
|
||||
@@ -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 (
|
||||
<LeadDetailPage
|
||||
lead={detail}
|
||||
onBack={() => setDetailId(null)}
|
||||
onMoveStage={(stage) => setStage(detail.id, stage)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="view pl">
|
||||
{/* ---- head -------------------------------------------- */}
|
||||
@@ -119,7 +143,11 @@ export function Pipeline() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<LeadDrawer lead={selected} onClose={() => setSelected(null)} />
|
||||
<LeadDrawer
|
||||
lead={selected}
|
||||
onClose={() => setSelected(null)}
|
||||
onMore={() => { setDetailId(selected?.id ?? null); setSelected(null); }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
</div>
|
||||
|
||||
<div className="pl-drawer-foot">
|
||||
<button className="pl-more" onClick={() => toast.push({ tone: "info", title: "Full lead record", desc: `${lead.id} · ${lead.name}` })}>
|
||||
<button className="pl-more" onClick={onMore}>
|
||||
<Icon name="arrow" size={15} /> More Details
|
||||
</button>
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div className="pl-dsec">
|
||||
<div className="pl-dsec-head">{title}</div>
|
||||
@@ -277,3 +304,207 @@ function DSection({ title, children }: { title: string; children: React.ReactNod
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user