Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| faade49b13 | |||
| 102b66dfca |
@@ -1140,4 +1140,247 @@
|
||||
.dash-root .ai-bubble { max-width: 86%; }
|
||||
.dash-root .ai-view { height: calc(100vh - 150px); }
|
||||
}
|
||||
|
||||
/* ========================================================== */
|
||||
/* Leads — pipeline board + rich detail popup */
|
||||
/* ========================================================== */
|
||||
|
||||
/* ---- stat strip ---- */
|
||||
.dash-root .leads-stats { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-bottom: 18px; }
|
||||
.dash-root .leads-stat { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 16px; border: 1px solid var(--border); background: var(--card-grad); box-shadow: var(--card-hi); position: relative; overflow: hidden; }
|
||||
.dash-root .leads-stat::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--orange); }
|
||||
.dash-root .leads-stat.tone-blue::before { background: var(--blue); }
|
||||
.dash-root .leads-stat.tone-purple::before { background: var(--purple); }
|
||||
.dash-root .leads-stat.tone-green::before { background: var(--green); }
|
||||
.dash-root .leads-stat.tone-orange::before { background: var(--orange); }
|
||||
.dash-root .leads-stat-ic { width: 38px; height: 38px; border-radius: 11px; display: grid; place-items: center; background: color-mix(in srgb, var(--orange) 14%, transparent); color: var(--orange); flex: 0 0 auto; }
|
||||
.dash-root .leads-stat.tone-blue .leads-stat-ic { background: color-mix(in srgb, var(--blue) 16%, transparent); color: #6f9bff; }
|
||||
.dash-root .leads-stat.tone-purple .leads-stat-ic { background: color-mix(in srgb, var(--purple) 16%, transparent); color: #b07bf2; }
|
||||
.dash-root .leads-stat.tone-green .leads-stat-ic { background: color-mix(in srgb, var(--green) 16%, transparent); color: var(--green); }
|
||||
.dash-root .leads-stat-val { font-size: 22px; font-weight: 800; line-height: 1; letter-spacing: -0.02em; }
|
||||
.dash-root .leads-stat-lbl { font-size: 11.5px; color: var(--muted); font-weight: 600; margin-top: 4px; }
|
||||
|
||||
/* ---- toolbar ---- */
|
||||
.dash-root .leads-toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.dash-root .leads-search { display: flex; align-items: center; gap: 9px; flex: 1 1 260px; min-width: 220px; height: 42px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--panel); color: var(--muted); }
|
||||
.dash-root .leads-search:focus-within { border-color: color-mix(in srgb, var(--orange) 55%, var(--border)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--orange) 14%, transparent); }
|
||||
.dash-root .leads-search input { flex: 1; border: 0; background: none; outline: none; color: var(--text); font-family: inherit; font-size: 13.5px; }
|
||||
.dash-root .leads-search input::placeholder { color: var(--muted); }
|
||||
.dash-root .leads-search-x { border: 0; background: none; color: var(--muted); cursor: pointer; display: grid; place-items: center; padding: 2px; border-radius: 6px; }
|
||||
.dash-root .leads-search-x:hover { color: var(--text); background: var(--panel-3); }
|
||||
.dash-root .leads-tabs { display: inline-flex; gap: 3px; padding: 4px; border-radius: 12px; border: 1px solid var(--border); background: var(--panel); }
|
||||
.dash-root .leads-tab { border: 0; background: none; color: var(--muted); font-family: inherit; font-size: 12.5px; font-weight: 600; padding: 7px 13px; border-radius: 9px; cursor: pointer; transition: 0.14s; }
|
||||
.dash-root .leads-tab:hover { color: var(--text-2); }
|
||||
.dash-root .leads-tab.active { background: var(--orange); color: #1a1205; box-shadow: 0 4px 12px -4px color-mix(in srgb, var(--orange) 60%, transparent); }
|
||||
|
||||
/* ---- board ---- */
|
||||
.dash-root .leads-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 14px; }
|
||||
.dash-root .lead-card { text-align: left; width: 100%; cursor: pointer; display: flex; flex-direction: column; gap: 11px; padding: 16px 17px; border-radius: 18px; border: 1px solid var(--border); background: var(--card-grad); box-shadow: var(--card-hi), 0 1px 2px rgba(0,0,0,0.18); font-family: inherit; color: var(--text); transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease; }
|
||||
.dash-root .lead-card:hover { transform: translateY(-3px); border-color: color-mix(in srgb, var(--orange) 40%, var(--border)); box-shadow: var(--shadow), 0 14px 34px -20px color-mix(in srgb, var(--orange) 50%, transparent); }
|
||||
.dash-root .lead-card-top { display: flex; align-items: center; gap: 12px; }
|
||||
.dash-root .lead-ava { position: relative; border-radius: 50%; padding: 3px; display: inline-flex; }
|
||||
.dash-root .lead-ava.prio-high { box-shadow: 0 0 0 2px color-mix(in srgb, var(--red) 70%, transparent); }
|
||||
.dash-root .lead-ava.prio-medium { box-shadow: 0 0 0 2px color-mix(in srgb, var(--orange) 70%, transparent); }
|
||||
.dash-root .lead-ava.prio-low { box-shadow: 0 0 0 2px var(--border-2); }
|
||||
.dash-root .lead-card-id { flex: 1; min-width: 0; }
|
||||
.dash-root .lead-card-name { font-size: 15px; font-weight: 700; letter-spacing: -0.01em; }
|
||||
.dash-root .lead-card-sub { display: flex; align-items: center; gap: 4px; font-size: 11.5px; color: var(--muted); margin-top: 2px; }
|
||||
.dash-root .lead-card-sub svg { color: var(--orange); }
|
||||
.dash-root .lead-code { font-family: ui-monospace, monospace; font-size: 11px; color: var(--text-2); }
|
||||
.dash-root .lead-card-row { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--text-2); }
|
||||
.dash-root .lead-card-row svg { color: var(--muted); flex: 0 0 auto; }
|
||||
.dash-root .lead-card-row span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dash-root .lead-card-foot { display: flex; align-items: center; gap: 8px; margin-top: 3px; padding-top: 11px; border-top: 1px solid var(--border); }
|
||||
.dash-root .lead-source { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; color: var(--muted); }
|
||||
.dash-root .lead-card-spacer { flex: 1; }
|
||||
.dash-root .lead-rep { font-size: 11.5px; color: var(--text-2); font-weight: 600; }
|
||||
.dash-root .lead-updated { font-size: 10.5px; color: var(--faint, var(--muted)); }
|
||||
|
||||
.dash-root .leads-empty { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 6px; padding: 48px 20px; color: var(--muted); }
|
||||
.dash-root .leads-empty svg { color: var(--muted); margin-bottom: 6px; }
|
||||
.dash-root .leads-empty h3 { font-size: 16px; color: var(--text); }
|
||||
|
||||
/* ---- detail popup ---- */
|
||||
.dash-root .lead-detail { display: flex; flex-direction: column; gap: 16px; }
|
||||
.dash-root .ld-identity { display: flex; align-items: center; gap: 14px; }
|
||||
.dash-root .ld-identity-name { font-size: 18px; font-weight: 800; letter-spacing: -0.01em; }
|
||||
.dash-root .ld-identity-pills { display: flex; gap: 6px; margin-top: 6px; }
|
||||
.dash-root .ld-storm { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-radius: 14px; border: 1px solid color-mix(in srgb, var(--orange) 30%, var(--border)); background: color-mix(in srgb, var(--orange) 9%, transparent); }
|
||||
.dash-root .ld-storm-ic { width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; background: color-mix(in srgb, var(--orange) 18%, transparent); color: var(--orange); flex: 0 0 auto; }
|
||||
.dash-root .ld-storm-zone { font-size: 13.5px; font-weight: 700; }
|
||||
.dash-root .ld-storm-meta { font-size: 12px; color: var(--muted); margin-top: 2px; }
|
||||
|
||||
.dash-root .ld-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.dash-root .ld-section { border: 1px solid var(--border); border-radius: 14px; background: var(--panel-2); padding: 14px 15px; }
|
||||
.dash-root .ld-section.wide { grid-column: 1 / -1; }
|
||||
.dash-root .ld-section-head { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--orange); margin-bottom: 12px; }
|
||||
.dash-root .ld-section-body { display: flex; flex-direction: column; gap: 3px; }
|
||||
.dash-root .ld-dl { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; padding: 6px 0; border-bottom: 1px dashed var(--border); }
|
||||
.dash-root .ld-dl:last-child { border-bottom: 0; }
|
||||
.dash-root .ld-dl-k { font-size: 12px; color: var(--muted); flex: 0 0 auto; }
|
||||
.dash-root .ld-dl-v { font-size: 12.5px; color: var(--text); font-weight: 600; text-align: right; }
|
||||
.dash-root .ld-assign { display: grid; grid-template-columns: 1fr 1fr; gap: 0 18px; }
|
||||
.dash-root .ld-sublabel { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--faint, var(--muted)); font-weight: 700; margin: 8px 0 6px; }
|
||||
.dash-root .ld-sublabel:first-child { margin-top: 0; }
|
||||
.dash-root .ld-contact-row { display: flex; align-items: center; gap: 8px; padding: 5px 0; font-size: 12.5px; color: var(--text); }
|
||||
.dash-root .ld-contact-row svg { color: var(--muted); flex: 0 0 auto; }
|
||||
.dash-root .ld-contact-val { font-weight: 600; }
|
||||
.dash-root .ld-contact-tag { font-size: 10.5px; color: var(--muted); padding: 2px 7px; border-radius: 99px; background: var(--panel-3); }
|
||||
.dash-root .ld-notes p { font-size: 12.5px; color: var(--text-2); line-height: 1.5; margin-top: 2px; }
|
||||
|
||||
/* ---- New Lead form ---- */
|
||||
.dash-root .nl-form { display: flex; flex-direction: column; gap: 16px; }
|
||||
.dash-root .nl-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 14px; }
|
||||
.dash-root .nl-grid .ds-field { margin-bottom: 0; }
|
||||
.dash-root .nl-grid > .ds-field, .dash-root .nl-grid > .nl-full { margin-bottom: 14px; }
|
||||
.dash-root .nl-full { grid-column: 1 / -1; }
|
||||
.dash-root .nl-full .ds-field { margin-bottom: 0; }
|
||||
.dash-root .nl-prio { display: flex; gap: 8px; }
|
||||
.dash-root .nl-prio-btn { flex: 1; height: 42px; border-radius: 11px; border: 1px solid var(--border-2); background: var(--panel-2); color: var(--muted); font-family: inherit; font-size: 13px; font-weight: 700; cursor: pointer; transition: 0.14s; }
|
||||
.dash-root .nl-prio-btn:hover { color: var(--text-2); border-color: var(--muted); }
|
||||
.dash-root .nl-prio-btn.active.low { background: color-mix(in srgb, var(--muted) 22%, transparent); color: var(--text); border-color: var(--muted); }
|
||||
.dash-root .nl-prio-btn.active.medium { background: color-mix(in srgb, var(--orange) 18%, transparent); color: var(--orange); border-color: color-mix(in srgb, var(--orange) 55%, transparent); }
|
||||
.dash-root .nl-prio-btn.active.high { background: color-mix(in srgb, var(--red) 18%, transparent); color: var(--red); border-color: color-mix(in srgb, var(--red) 55%, transparent); }
|
||||
.dash-root .nl-prio-btn.urg.active.standard { background: color-mix(in srgb, var(--muted) 20%, transparent); color: var(--text); border-color: var(--muted); }
|
||||
.dash-root .nl-prio-btn.urg.active.high { background: color-mix(in srgb, var(--orange) 18%, transparent); color: var(--orange); border-color: color-mix(in srgb, var(--orange) 55%, transparent); }
|
||||
.dash-root .nl-prio-btn.urg.active.emergency { background: color-mix(in srgb, var(--red) 20%, transparent); color: var(--red); border-color: color-mix(in srgb, var(--red) 60%, transparent); }
|
||||
|
||||
/* multi-value rows (phones / emails) */
|
||||
.dash-root .nl-multirow { display: flex; gap: 8px; margin-bottom: 8px; }
|
||||
.dash-root .nl-multirow .ds-input { flex: 1; }
|
||||
.dash-root .nl-typesel { flex: 0 0 108px; width: 108px; }
|
||||
.dash-root .nl-rowx { flex: 0 0 auto; width: 42px; border-radius: 11px; border: 1px solid var(--border-2); background: var(--panel-2); color: var(--muted); cursor: pointer; display: grid; place-items: center; transition: 0.14s; }
|
||||
.dash-root .nl-rowx:hover { color: var(--red); border-color: color-mix(in srgb, var(--red) 50%, transparent); }
|
||||
.dash-root .nl-add { display: inline-flex; align-items: center; gap: 6px; margin-top: 2px; padding: 8px 13px; border-radius: 10px; border: 1px dashed var(--border-2); background: none; color: var(--orange); font-family: inherit; font-size: 12.5px; font-weight: 700; cursor: pointer; transition: 0.14s; }
|
||||
.dash-root .nl-add:hover { background: color-mix(in srgb, var(--orange) 10%, transparent); border-color: color-mix(in srgb, var(--orange) 45%, transparent); }
|
||||
.dash-root .nl-empty { font-size: 12.5px; color: var(--faint, var(--muted)); padding: 8px 0 10px; }
|
||||
|
||||
/* site photos dropzone */
|
||||
.dash-root .nl-photos { width: 100%; display: flex; flex-direction: column; align-items: center; gap: 3px; padding: 22px; border-radius: 14px; border: 1.5px dashed var(--border-2); background: var(--panel-2); color: var(--muted); cursor: pointer; transition: 0.14s; }
|
||||
.dash-root .nl-photos:hover { border-color: color-mix(in srgb, var(--orange) 50%, transparent); color: var(--orange); background: color-mix(in srgb, var(--orange) 7%, transparent); }
|
||||
.dash-root .nl-photos-t { font-size: 13px; font-weight: 700; color: var(--text-2); }
|
||||
.dash-root .nl-photos:hover .nl-photos-t { color: var(--orange); }
|
||||
.dash-root .nl-photos-s { font-size: 11px; }
|
||||
.dash-root .nl-photo-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||
.dash-root .nl-photo-chip { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 600; padding: 5px 10px; border-radius: 99px; background: color-mix(in srgb, var(--green) 15%, transparent); color: var(--green); }
|
||||
.dash-root .ds-select.is-placeholder { color: var(--faint, var(--muted)); }
|
||||
|
||||
/* Canvasser search (Door Knock source) */
|
||||
.dash-root .nl-canvasser { position: relative; }
|
||||
.dash-root .nl-canvasser-menu { position: absolute; top: calc(100% + 4px); left: 0; right: 0; z-index: 30; max-height: 220px; overflow-y: auto; border: 1px solid var(--border-2); border-radius: 12px; background: var(--panel); box-shadow: 0 12px 32px rgba(0,0,0,0.28); padding: 5px; }
|
||||
.dash-root .nl-canvasser-opt { display: flex; align-items: center; gap: 9px; width: 100%; padding: 7px 9px; border: none; border-radius: 9px; background: transparent; color: var(--text); font-family: inherit; font-size: 13px; text-align: left; cursor: pointer; transition: 0.12s; }
|
||||
.dash-root .nl-canvasser-opt:hover { background: var(--panel-2); }
|
||||
.dash-root .nl-canvasser-name { font-weight: 700; }
|
||||
.dash-root .nl-canvasser-email { color: var(--muted); font-size: 12px; }
|
||||
.dash-root .nl-canvasser-opt .nl-canvasser-email { margin-left: auto; }
|
||||
.dash-root .nl-canvasser-empty { padding: 10px; color: var(--muted); font-size: 12.5px; text-align: center; }
|
||||
.dash-root .nl-canvasser-chip { display: flex; align-items: center; gap: 9px; padding: 7px 10px; border: 1px solid var(--border-2); border-radius: 12px; background: var(--panel-2); }
|
||||
.dash-root .nl-canvasser-chip .nl-canvasser-email { margin-left: 2px; }
|
||||
.dash-root .nl-canvasser-clear { margin-left: auto; display: grid; place-items: center; width: 26px; height: 26px; border: none; border-radius: 8px; background: transparent; color: var(--muted); cursor: pointer; transition: 0.12s; }
|
||||
.dash-root .nl-canvasser-clear:hover { background: color-mix(in srgb, var(--red) 15%, transparent); color: var(--red); }
|
||||
|
||||
/* ========================================================== */
|
||||
/* Lead Verification — stat tiles + filters + table */
|
||||
/* ========================================================== */
|
||||
.dash-root .lv-stats { display: grid; grid-template-columns: repeat(5, 1fr); gap: 12px; margin-bottom: 18px; }
|
||||
.dash-root .lv-stat { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; padding: 14px 16px; border-radius: 16px; border: 1px solid var(--border); background: var(--card-grad); box-shadow: var(--card-hi); cursor: pointer; text-align: left; font-family: inherit; transition: 0.15s; position: relative; }
|
||||
.dash-root .lv-stat:hover { border-color: var(--border-2); transform: translateY(-2px); }
|
||||
.dash-root .lv-stat.active { border-color: color-mix(in srgb, var(--accent, var(--orange)) 60%, transparent); box-shadow: var(--card-hi), 0 0 0 1px color-mix(in srgb, var(--accent, var(--orange)) 40%, transparent); }
|
||||
.dash-root .lv-stat-ic { width: 32px; height: 32px; border-radius: 9px; display: grid; place-items: center; margin-bottom: 6px; }
|
||||
.dash-root .lv-stat-val { font-size: 22px; font-weight: 800; line-height: 1; letter-spacing: -0.02em; }
|
||||
.dash-root .lv-stat-lbl { font-size: 11.5px; color: var(--muted); font-weight: 600; }
|
||||
.dash-root .lv-stat.tone-green { --accent: var(--green); } .dash-root .lv-stat.tone-green .lv-stat-ic { background: color-mix(in srgb, var(--green) 16%, transparent); color: var(--green); }
|
||||
.dash-root .lv-stat.tone-orange { --accent: var(--orange); } .dash-root .lv-stat.tone-orange .lv-stat-ic { background: color-mix(in srgb, var(--orange) 16%, transparent); color: var(--orange); }
|
||||
.dash-root .lv-stat.tone-blue { --accent: var(--blue); } .dash-root .lv-stat.tone-blue .lv-stat-ic { background: color-mix(in srgb, var(--blue) 18%, transparent); color: #6f9bff; }
|
||||
.dash-root .lv-stat.tone-purple { --accent: var(--purple); } .dash-root .lv-stat.tone-purple .lv-stat-ic { background: color-mix(in srgb, var(--purple) 18%, transparent); color: #b07bf2; }
|
||||
.dash-root .lv-stat.tone-red { --accent: var(--red); } .dash-root .lv-stat.tone-red .lv-stat-ic { background: color-mix(in srgb, var(--red) 16%, transparent); color: var(--red); }
|
||||
|
||||
.dash-root .lv-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap; }
|
||||
.dash-root .lv-search { display: flex; align-items: center; gap: 9px; flex: 1 1 240px; min-width: 200px; height: 42px; padding: 0 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--panel); color: var(--muted); }
|
||||
.dash-root .lv-search:focus-within { border-color: color-mix(in srgb, var(--orange) 55%, var(--border)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--orange) 14%, transparent); }
|
||||
.dash-root .lv-search input { flex: 1; border: 0; background: none; outline: none; color: var(--text); font-family: inherit; font-size: 13.5px; }
|
||||
.dash-root .lv-search input::placeholder { color: var(--muted); }
|
||||
.dash-root .lv-search-x { border: 0; background: none; color: var(--muted); cursor: pointer; display: grid; place-items: center; padding: 2px; border-radius: 6px; }
|
||||
.dash-root .lv-search-x:hover { color: var(--text); background: var(--panel-3); }
|
||||
.dash-root .lv-filter { height: 42px; flex: 0 0 auto; width: auto; min-width: 150px; }
|
||||
|
||||
.dash-root .lv-tablewrap { border: 1px solid var(--border); border-radius: 18px; background: var(--card-grad); box-shadow: var(--card-hi); overflow-x: auto; }
|
||||
.dash-root .lv-table { width: 100%; border-collapse: collapse; min-width: 940px; }
|
||||
.dash-root .lv-table thead th { text-align: left; font-size: 10.5px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); padding: 14px 16px; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
.dash-root .lv-table tbody td { padding: 13px 16px; border-bottom: 1px solid var(--border); font-size: 12.5px; vertical-align: middle; }
|
||||
.dash-root .lv-table tbody tr:last-child td { border-bottom: 0; }
|
||||
.dash-root .lv-table tbody tr { transition: background 0.12s; }
|
||||
.dash-root .lv-table tbody tr:hover { background: color-mix(in srgb, var(--orange) 5%, transparent); }
|
||||
.dash-root .lv-id { font-family: ui-monospace, monospace; font-size: 12px; font-weight: 700; color: var(--text); white-space: nowrap; }
|
||||
.dash-root .lv-id-date { font-size: 10.5px; color: var(--faint, var(--muted)); margin-top: 2px; }
|
||||
.dash-root .lv-cust { display: flex; align-items: center; gap: 10px; min-width: 210px; }
|
||||
.dash-root .lv-cust-name { font-weight: 700; font-size: 13px; color: var(--text); }
|
||||
.dash-root .lv-cust-addr { font-size: 11px; color: var(--muted); margin-top: 1px; }
|
||||
.dash-root .lv-phone { color: var(--text-2); white-space: nowrap; }
|
||||
.dash-root .lv-source { display: inline-block; font-size: 11.5px; font-weight: 600; color: var(--text-2); padding: 4px 10px; border-radius: 99px; background: var(--panel-3); white-space: nowrap; }
|
||||
.dash-root .lv-assignee { display: flex; align-items: center; gap: 8px; white-space: nowrap; }
|
||||
.dash-root .lv-assignee span { font-weight: 600; color: var(--text-2); font-size: 12px; }
|
||||
.dash-root .lv-unassigned { font-size: 11.5px; color: var(--faint, var(--muted)); }
|
||||
.dash-root .lv-verif { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 600; white-space: nowrap; color: var(--muted); }
|
||||
.dash-root .lv-verif.v-verified { color: var(--green); }
|
||||
.dash-root .lv-verif.v-in_progress { color: var(--orange); }
|
||||
.dash-root .lv-verif.v-assigned { color: #6f9bff; }
|
||||
.dash-root .lv-verif.v-pending { color: #b07bf2; }
|
||||
.dash-root .lv-verif.v-unverified { color: var(--red); }
|
||||
.dash-root .lv-created { color: var(--muted); white-space: nowrap; }
|
||||
.dash-root .lv-rowacts { display: flex; gap: 6px; }
|
||||
.dash-root .lv-act { width: 32px; height: 32px; border-radius: 9px; border: 1px solid var(--border-2); background: var(--panel-2); color: var(--muted); cursor: pointer; display: grid; place-items: center; transition: 0.14s; }
|
||||
.dash-root .lv-act:hover { color: var(--text); border-color: var(--muted); }
|
||||
.dash-root .lv-act.primary:hover { color: var(--green); border-color: color-mix(in srgb, var(--green) 50%, transparent); background: color-mix(in srgb, var(--green) 10%, transparent); }
|
||||
.dash-root .lv-empty { text-align: center; color: var(--muted); padding: 40px 16px; font-size: 13px; }
|
||||
.dash-root .lv-count { font-size: 11.5px; color: var(--muted); margin-top: 12px; text-align: right; }
|
||||
|
||||
/* ---- row actions dropdown (portalled to body) ---- */
|
||||
.lv-menu-scrim { position: fixed; inset: 0; z-index: 90; }
|
||||
.lv-menu { position: fixed; z-index: 91; width: 188px; padding: 6px; border-radius: 12px; border: 1px solid var(--border-2, rgba(255,255,255,0.12)); background: var(--panel, #0e0e13); box-shadow: 0 18px 44px -18px rgba(0,0,0,0.7); animation: ds-rise 0.13s ease; }
|
||||
.lv-menu-item { display: flex; align-items: center; gap: 9px; width: 100%; padding: 9px 10px; border: 0; border-radius: 9px; background: none; color: var(--text-2, #eaeaea); font-family: inherit; font-size: 12.5px; font-weight: 600; cursor: pointer; text-align: left; }
|
||||
.lv-menu-item:hover { background: var(--panel-3, #1b1b22); color: var(--text, #fff); }
|
||||
.lv-menu-item svg { color: var(--muted, #8c8c8c); flex: 0 0 auto; }
|
||||
.lv-menu-item:hover svg { color: var(--orange, #fda913); }
|
||||
|
||||
/* ---- verification detail popup ---- */
|
||||
.dash-root .lv-detail { display: flex; flex-direction: column; gap: 16px; }
|
||||
.dash-root .lv-d-identity { display: flex; align-items: center; gap: 14px; }
|
||||
.dash-root .lv-d-name { font-size: 18px; font-weight: 800; letter-spacing: -0.01em; }
|
||||
.dash-root .lv-d-pills { display: flex; align-items: center; gap: 8px; margin-top: 6px; }
|
||||
.dash-root .lv-d-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.dash-root .lv-d-section, .dash-root .lv-d-notes, .dash-root .lv-d-activity { border: 1px solid var(--border); border-radius: 14px; background: var(--panel-2); padding: 14px 15px; }
|
||||
.dash-root .lv-d-head { display: flex; align-items: center; gap: 8px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; color: var(--orange); margin-bottom: 12px; }
|
||||
.dash-root .lv-d-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; padding: 6px 0; border-bottom: 1px dashed var(--border); }
|
||||
.dash-root .lv-d-row:last-child { border-bottom: 0; }
|
||||
.dash-root .lv-d-k { font-size: 12px; color: var(--muted); flex: 0 0 auto; }
|
||||
.dash-root .lv-d-v { font-size: 12.5px; color: var(--text); font-weight: 600; text-align: right; }
|
||||
.dash-root .lv-d-notes p { font-size: 12.5px; color: var(--text-2); line-height: 1.55; margin-top: 2px; }
|
||||
|
||||
.dash-root .lv-timeline { list-style: none; margin: 0; padding: 0; }
|
||||
.dash-root .lv-tl-item { position: relative; display: flex; gap: 12px; padding: 0 0 16px 4px; }
|
||||
.dash-root .lv-tl-item::before { content: ""; position: absolute; left: 8px; top: 14px; bottom: -2px; width: 1.5px; background: var(--border-2); }
|
||||
.dash-root .lv-tl-item:last-child { padding-bottom: 0; }
|
||||
.dash-root .lv-tl-item:last-child::before { display: none; }
|
||||
.dash-root .lv-tl-dot { position: relative; z-index: 1; flex: 0 0 auto; width: 10px; height: 10px; margin-top: 4px; border-radius: 50%; background: var(--orange); box-shadow: 0 0 0 3px color-mix(in srgb, var(--orange) 20%, transparent); }
|
||||
.dash-root .lv-tl-text { font-size: 12.5px; color: var(--text); font-weight: 600; }
|
||||
.dash-root .lv-tl-meta { font-size: 11px; color: var(--muted); margin-top: 2px; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.dash-root .lv-d-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.dash-root .leads-stats { grid-template-columns: repeat(2, 1fr); }
|
||||
.dash-root .ld-grid { grid-template-columns: 1fr; }
|
||||
.dash-root .ld-assign { grid-template-columns: 1fr; }
|
||||
.dash-root .lv-stats { grid-template-columns: repeat(3, 1fr); }
|
||||
.dash-root .lv-filter { flex: 1 1 45%; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.dash-root .leads-stats { grid-template-columns: 1fr; }
|
||||
.dash-root .leads-grid { grid-template-columns: 1fr; }
|
||||
.dash-root .nl-grid { grid-template-columns: 1fr; }
|
||||
.dash-root .lv-stats { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import { AiAssistant } from "./ai-assistant";
|
||||
import { TeamManagement } from "./team-management";
|
||||
import { Messenger } from "./messenger";
|
||||
import { Inbox } from "./inbox";
|
||||
import { Leads } from "./leads";
|
||||
import { Verify } from "./verify";
|
||||
import "../../app/dashboard/dashboard.css";
|
||||
|
||||
export function Dashboard() {
|
||||
@@ -49,6 +51,8 @@ export function Dashboard() {
|
||||
: active === "ai" ? <AiAssistant />
|
||||
: active === "messenger" ? <Messenger />
|
||||
: active === "inbox" ? <Inbox />
|
||||
: active === "leads" ? <Leads />
|
||||
: active === "verify" ? <Verify />
|
||||
: active === "team" ? <TeamManagement />
|
||||
: <ComingSoon title={title} icon={item?.icon ?? "dashboard"} onGo={setActive} />}
|
||||
</ToastProvider>
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
// ============================================================
|
||||
// LynkedUp Pro — Leads mock data.
|
||||
// A storm-restoration roofing pipeline: door-knocked leads in
|
||||
// the Plano, TX hail zone. Each lead carries a rich detail
|
||||
// record (contact, property, job, insurance, assignment) that
|
||||
// powers the lead-detail popup. All client-side so the screen
|
||||
// is fully interactive without a backend.
|
||||
// ============================================================
|
||||
|
||||
export type LeadStatus = "new" | "contacted" | "appointed" | "closed";
|
||||
export type LeadPriority = "high" | "medium" | "low";
|
||||
|
||||
export type Phone = { number: string; type: "Mobile" | "Home" | "Work"; primary?: boolean };
|
||||
export type Email = { address: string; type?: string; primary?: boolean };
|
||||
|
||||
export type Lead = {
|
||||
id: string; // SAL-001
|
||||
initials: string;
|
||||
name: string;
|
||||
gradient: string;
|
||||
priority: LeadPriority;
|
||||
status: LeadStatus;
|
||||
tag: string; // "Storm Zone"
|
||||
updated: string; // "3d ago"
|
||||
setter: string; // compact chip name
|
||||
|
||||
// storm banner
|
||||
storm: { zone: string; date: string; detail: string };
|
||||
|
||||
// contact
|
||||
phones: Phone[];
|
||||
emails: Email[];
|
||||
|
||||
// property
|
||||
property: { address: string; city: string; state: string; zip: string; type: string };
|
||||
|
||||
// job details
|
||||
job: {
|
||||
source: string; leadType: string; workType: string; tradeType: string;
|
||||
urgency: string; canvasser: string; notes: string;
|
||||
};
|
||||
|
||||
// insurance
|
||||
insurance: {
|
||||
company: string; claimStatus: string; claimNumber: string;
|
||||
policyNumber: string; adjusterName: string; adjusterPhone: string;
|
||||
};
|
||||
|
||||
// assignment
|
||||
assignment: {
|
||||
assignedTo: string; priority: string; followUp: string;
|
||||
createdBy: string; createdAt: string;
|
||||
};
|
||||
};
|
||||
|
||||
const G = {
|
||||
orange: "linear-gradient(135deg,#fda913,#fd6d13)",
|
||||
blue: "linear-gradient(135deg,#4f8cff,#2c5cff)",
|
||||
purple: "linear-gradient(135deg,#b07bf2,#7b53e0)",
|
||||
green: "linear-gradient(135deg,#33c98a,#1fa46c)",
|
||||
cyan: "linear-gradient(135deg,#34c9d6,#1f9aa4)",
|
||||
};
|
||||
|
||||
export const LEADS: Lead[] = [
|
||||
{
|
||||
id: "SAL-001", initials: "JM", name: "John Martinez", gradient: G.orange,
|
||||
priority: "high", status: "contacted", tag: "Storm Zone", updated: "3d ago", setter: "Cody",
|
||||
storm: { zone: "E Plano / Spring Creek Pkwy", date: "2026-04-28", detail: '2.5" hail (severe)' },
|
||||
phones: [
|
||||
{ number: "(469) 500-1000", type: "Mobile", primary: true },
|
||||
{ number: "(214) 600-2000", type: "Home" },
|
||||
],
|
||||
emails: [{ address: "john.martinez@gmail.com", primary: true }],
|
||||
property: { address: "4821 Spring Creek Pkwy", city: "Plano", state: "TX", zip: "75023", type: "Single Family" },
|
||||
job: {
|
||||
source: "Door Knock", leadType: "Insurance", workType: "Roof Replacement", tradeType: "Roofing",
|
||||
urgency: "High", canvasser: "Cody Tatum",
|
||||
notes: "Homeowner showed significant granule loss on south-facing slopes; agreed to inspection.",
|
||||
},
|
||||
insurance: {
|
||||
company: "State Farm", claimStatus: "Filed", claimNumber: "CLM-2026-1000",
|
||||
policyNumber: "POL-080000", adjusterName: "Marcus Powell", adjusterPhone: "(972) 700-3000",
|
||||
},
|
||||
assignment: {
|
||||
assignedTo: "Jesus Gonzales", priority: "High", followUp: "Jun 4, 2026",
|
||||
createdBy: "Cody Tatum", createdAt: "May 28, 2026",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "SAL-002", initials: "SK", name: "Sarah Kim", gradient: G.purple,
|
||||
priority: "high", status: "appointed", tag: "Storm Zone", updated: "2d ago", setter: "Shelby",
|
||||
storm: { zone: "E Plano / Custer Rd", date: "2026-04-28", detail: '2.5" hail (severe)' },
|
||||
phones: [
|
||||
{ number: "(972) 501-1037", type: "Mobile", primary: true },
|
||||
{ number: "(214) 601-2044", type: "Home" },
|
||||
],
|
||||
emails: [{ address: "sarah.kim@outlook.com", primary: true }],
|
||||
property: { address: "4905 Custer Rd", city: "Plano", state: "TX", zip: "75023", type: "Single Family" },
|
||||
job: {
|
||||
source: "Door Knock", leadType: "Insurance", workType: "Roof Replacement", tradeType: "Roofing",
|
||||
urgency: "High", canvasser: "Hannah Reyes",
|
||||
notes: "Visible mat exposure on rear elevation. Appointment set for adjuster meet.",
|
||||
},
|
||||
insurance: {
|
||||
company: "Allstate", claimStatus: "Approved", claimNumber: "CLM-2026-1037",
|
||||
policyNumber: "POL-081037", adjusterName: "Dana Whitfield", adjusterPhone: "(972) 700-3037",
|
||||
},
|
||||
assignment: {
|
||||
assignedTo: "Hannah Reyes", priority: "High", followUp: "Jun 6, 2026",
|
||||
createdBy: "Shelby Greer", createdAt: "May 29, 2026",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "SAL-003", initials: "RC", name: "Robert Chen", gradient: G.green,
|
||||
priority: "high", status: "closed", tag: "Storm Zone", updated: "2d ago", setter: "Dalton",
|
||||
storm: { zone: "E Plano / Independence Pkwy", date: "2026-04-28", detail: '2.5" hail (severe)' },
|
||||
phones: [
|
||||
{ number: "(469) 502-1074", type: "Mobile", primary: true },
|
||||
],
|
||||
emails: [{ address: "robert.chen@gmail.com", primary: true }],
|
||||
property: { address: "5012 Independence Pkwy", city: "Plano", state: "TX", zip: "75023", type: "Single Family" },
|
||||
job: {
|
||||
source: "Door Knock", leadType: "Insurance", workType: "Roof Replacement", tradeType: "Roofing",
|
||||
urgency: "High", canvasser: "Travis Boone",
|
||||
notes: "Full replacement approved and installed. Final invoice cleared.",
|
||||
},
|
||||
insurance: {
|
||||
company: "Farmers", claimStatus: "Paid", claimNumber: "CLM-2026-1074",
|
||||
policyNumber: "POL-081074", adjusterName: "Leah Ortiz", adjusterPhone: "(972) 700-3074",
|
||||
},
|
||||
assignment: {
|
||||
assignedTo: "Travis Boone", priority: "High", followUp: "—",
|
||||
createdBy: "Dalton Pruitt", createdAt: "May 20, 2026",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "SAL-004", initials: "MG", name: "Maria Garcia", gradient: G.cyan,
|
||||
priority: "high", status: "closed", tag: "Storm Zone", updated: "2d ago", setter: "Hannah",
|
||||
storm: { zone: "E Plano / Alma Dr", date: "2026-04-28", detail: '2.5" hail (severe)' },
|
||||
phones: [
|
||||
{ number: "(972) 503-1111", type: "Mobile", primary: true },
|
||||
],
|
||||
emails: [{ address: "maria.garcia@gmail.com", primary: true }],
|
||||
property: { address: "4720 Alma Dr", city: "Plano", state: "TX", zip: "75023", type: "Single Family" },
|
||||
job: {
|
||||
source: "Door Knock", leadType: "Insurance", workType: "Roof Replacement", tradeType: "Roofing",
|
||||
urgency: "High", canvasser: "Shelby Greer",
|
||||
notes: "Signed contract; build complete. Awaiting review request.",
|
||||
},
|
||||
insurance: {
|
||||
company: "USAA", claimStatus: "Paid", claimNumber: "CLM-2026-1111",
|
||||
policyNumber: "POL-081111", adjusterName: "Grant Mueller", adjusterPhone: "(972) 700-3111",
|
||||
},
|
||||
assignment: {
|
||||
assignedTo: "Shelby Greer", priority: "High", followUp: "—",
|
||||
createdBy: "Hannah Reyes", createdAt: "May 18, 2026",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "SAL-005", initials: "DT", name: "David Thompson", gradient: G.blue,
|
||||
priority: "high", status: "appointed", tag: "Storm Zone", updated: "1d ago", setter: "Travis",
|
||||
storm: { zone: "E Plano / Spring Creek Pkwy", date: "2026-04-28", detail: '2.5" hail (severe)' },
|
||||
phones: [
|
||||
{ number: "(469) 504-1148", type: "Mobile", primary: true },
|
||||
{ number: "(214) 604-2148", type: "Work" },
|
||||
],
|
||||
emails: [{ address: "david.thompson@gmail.com", primary: true }],
|
||||
property: { address: "5130 Spring Creek Pkwy", city: "Plano", state: "TX", zip: "75023", type: "Single Family" },
|
||||
job: {
|
||||
source: "Door Knock", leadType: "Insurance", workType: "Roof Replacement", tradeType: "Roofing",
|
||||
urgency: "High", canvasser: "Dalton Pruitt",
|
||||
notes: "Adjuster appointment confirmed for next week. Bring hail map + photos.",
|
||||
},
|
||||
insurance: {
|
||||
company: "Liberty Mutual", claimStatus: "Filed", claimNumber: "CLM-2026-1148",
|
||||
policyNumber: "POL-081148", adjusterName: "Priya Nair", adjusterPhone: "(972) 700-3148",
|
||||
},
|
||||
assignment: {
|
||||
assignedTo: "Dalton Pruitt", priority: "High", followUp: "Jun 9, 2026",
|
||||
createdBy: "Travis Boone", createdAt: "May 30, 2026",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Header stat — the full book is larger than the loaded page.
|
||||
export const TOTAL_LEADS = 35;
|
||||
|
||||
export const STATUS_META: Record<LeadStatus, { label: string; tone: string }> = {
|
||||
new: { label: "New", tone: "blue" },
|
||||
contacted: { label: "Contacted", tone: "orange" },
|
||||
appointed: { label: "Appointed", tone: "purple" },
|
||||
closed: { label: "Closed", tone: "green" },
|
||||
};
|
||||
|
||||
export const PRIORITY_META: Record<LeadPriority, { label: string; tone: string }> = {
|
||||
high: { label: "High", tone: "red" },
|
||||
medium: { label: "Medium", tone: "orange" },
|
||||
low: { label: "Low", tone: "muted" },
|
||||
};
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* Reps + option lists — power the New Lead form */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
export type Rep = { id: string; initials: string; name: string; email: string };
|
||||
|
||||
export const REPS: Rep[] = [
|
||||
{ id: "LUP-1040", initials: "CT", name: "Cody Tatum", email: "cody.tatum@lynkeduppro.com" },
|
||||
{ id: "LUP-1041", initials: "HR", name: "Hannah Reyes", email: "hannah.reyes@lynkeduppro.com" },
|
||||
{ id: "LUP-1042", initials: "TB", name: "Travis Boone", email: "travis.boone@lynkeduppro.com" },
|
||||
{ id: "LUP-1043", initials: "SG", name: "Shelby Greer", email: "shelby.greer@lynkeduppro.com" },
|
||||
{ id: "LUP-1044", initials: "DP", name: "Dalton Pruitt", email: "dalton.pruitt@lynkeduppro.com" },
|
||||
];
|
||||
|
||||
export const LEAD_SOURCES = ["Door Knock", "Referral", "Storm Chase", "Mailer / Postcard", "Sign Call", "Insurance Agent Referral", "Repeat Customer", "Social Media", "Other"];
|
||||
export const LEAD_TYPES = ["Insurance", "Retail"];
|
||||
export const WORK_TYPES = ["Roof Replacement", "Roof Repair", "Inspection", "Gutter Install"];
|
||||
export const TRADE_TYPES = ["Roofing", "Gutters", "Siding", "Windows"];
|
||||
export const PROPERTY_TYPES = ["Single Family", "Multi Family", "Commercial"];
|
||||
export const CLAIM_STATUSES = ["Not Filed", "Filed", "Approved", "Paid", "Denied"];
|
||||
@@ -0,0 +1,615 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================
|
||||
// Leads — storm-restoration pipeline board.
|
||||
// · Hero head : storm banner + headline stats (by status)
|
||||
// · Toolbar : search + status filter tabs
|
||||
// · Board : lead cards (avatar, priority ring, status,
|
||||
// address, phone, source, rep, updated)
|
||||
// · Detail : click a lead → rich popup with Contact,
|
||||
// Property, Job Details, Insurance, Assignment
|
||||
// Data comes from leads-data.ts (client-side mock).
|
||||
// ============================================================
|
||||
|
||||
import { useMemo, useState, type ReactNode } from "react";
|
||||
import { Avatar, Btn, Field, Icon, Modal, PageHead, Pill, Segmented, SegTabs, useToast } from "./ui";
|
||||
import {
|
||||
LEADS, TOTAL_LEADS, STATUS_META, PRIORITY_META,
|
||||
REPS, LEAD_SOURCES, WORK_TYPES, TRADE_TYPES, CLAIM_STATUSES,
|
||||
type Lead, type LeadStatus,
|
||||
} from "./leads-data";
|
||||
|
||||
const STATUS_TABS: { value: "all" | LeadStatus; label: string }[] = [
|
||||
{ value: "all", label: "All" },
|
||||
{ value: "new", label: "New" },
|
||||
{ value: "contacted", label: "Contacted" },
|
||||
{ value: "appointed", label: "Appointed" },
|
||||
{ value: "closed", label: "Closed" },
|
||||
];
|
||||
|
||||
export function Leads() {
|
||||
const toast = useToast();
|
||||
const [query, setQuery] = useState("");
|
||||
const [filter, setFilter] = useState<"all" | LeadStatus>("all");
|
||||
const [selected, setSelected] = useState<Lead | null>(null);
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
|
||||
const countByStatus = useMemo(() => {
|
||||
const m: Record<string, number> = {};
|
||||
for (const l of LEADS) m[l.status] = (m[l.status] ?? 0) + 1;
|
||||
return m;
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return LEADS.filter((l) => {
|
||||
const matchStatus = filter === "all" || l.status === filter;
|
||||
const hay = `${l.name} ${l.property.address} ${l.property.city} ${l.job.source} ${l.job.canvasser}`.toLowerCase();
|
||||
return matchStatus && (!q || hay.includes(q));
|
||||
});
|
||||
}, [query, filter]);
|
||||
|
||||
return (
|
||||
<div className="view leads">
|
||||
<PageHead
|
||||
eyebrow="Sales"
|
||||
title="Leads"
|
||||
subtitle={`${TOTAL_LEADS} total leads · Plano hail zone · storm 2026-04-28`}
|
||||
icon="leads"
|
||||
actions={<Btn icon="plus" onClick={() => setNewOpen(true)}>New Lead</Btn>}
|
||||
/>
|
||||
|
||||
{/* ---- stat strip ---------------------------------------- */}
|
||||
<div className="leads-stats">
|
||||
<StatCard label="Total leads" value={TOTAL_LEADS} icon="leads" tone="orange" />
|
||||
<StatCard label="New" value={countByStatus.new ?? 0} icon="star" tone="blue" />
|
||||
<StatCard label="Contacted" value={countByStatus.contacted ?? 0} icon="phone" tone="orange" />
|
||||
<StatCard label="Appointed" value={countByStatus.appointed ?? 0} icon="clock" tone="purple" />
|
||||
<StatCard label="Closed" value={countByStatus.closed ?? 0} icon="check-circle" tone="green" />
|
||||
</div>
|
||||
|
||||
{/* ---- toolbar ------------------------------------------- */}
|
||||
<div className="leads-toolbar">
|
||||
<div className="leads-search">
|
||||
<Icon name="search" size={16} />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search by name, address, source…"
|
||||
aria-label="Search leads"
|
||||
/>
|
||||
{query && <button className="leads-search-x" aria-label="Clear" onClick={() => setQuery("")}><Icon name="x" size={14} /></button>}
|
||||
</div>
|
||||
<div className="leads-tabs" role="tablist">
|
||||
{STATUS_TABS.map((t) => (
|
||||
<button
|
||||
key={t.value}
|
||||
role="tab"
|
||||
aria-selected={filter === t.value}
|
||||
className={`leads-tab ${filter === t.value ? "active" : ""}`}
|
||||
onClick={() => setFilter(t.value)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---- board --------------------------------------------- */}
|
||||
{filtered.length === 0 ? (
|
||||
<div className="card leads-empty">
|
||||
<Icon name="search" size={30} />
|
||||
<h3>No leads match</h3>
|
||||
<p>Try a different search or clear the status filter.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="leads-grid">
|
||||
{filtered.map((l) => (
|
||||
<LeadCard key={l.id} lead={l} onOpen={() => setSelected(l)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LeadDetail lead={selected} onClose={() => setSelected(null)} />
|
||||
<NewLead open={newOpen} onClose={() => setNewOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* Stat card */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
function StatCard({ label, value, icon, tone }: { label: string; value: number; icon: string; tone: string }) {
|
||||
return (
|
||||
<div className={`leads-stat tone-${tone}`}>
|
||||
<span className="leads-stat-ic"><Icon name={icon} size={17} /></span>
|
||||
<div className="leads-stat-body">
|
||||
<div className="leads-stat-val">{value}</div>
|
||||
<div className="leads-stat-lbl">{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* Lead card */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
function LeadCard({ lead, onOpen }: { lead: Lead; onOpen: () => void }) {
|
||||
const status = STATUS_META[lead.status];
|
||||
const priority = PRIORITY_META[lead.priority];
|
||||
const primaryPhone = lead.phones.find((p) => p.primary) ?? lead.phones[0];
|
||||
|
||||
return (
|
||||
<button className="lead-card" onClick={onOpen}>
|
||||
<div className="lead-card-top">
|
||||
<span className={`lead-ava prio-${lead.priority}`}>
|
||||
<Avatar initials={lead.initials} gradient={lead.gradient} size={46} />
|
||||
</span>
|
||||
<div className="lead-card-id">
|
||||
<div className="lead-card-name">{lead.name}</div>
|
||||
<div className="lead-card-sub"><span className="lead-code">{lead.id}</span> · <Icon name="storm" size={12} /> {lead.tag}</div>
|
||||
</div>
|
||||
<Pill tone={priority.tone}><Icon name="alert" size={11} /> {priority.label}</Pill>
|
||||
</div>
|
||||
|
||||
<div className="lead-card-row"><Icon name="pin" size={14} /><span>{lead.property.address}, {lead.property.city}, {lead.property.state}</span></div>
|
||||
<div className="lead-card-row"><Icon name="phone" size={14} /><span>{primaryPhone?.number}</span></div>
|
||||
|
||||
<div className="lead-card-foot">
|
||||
<Pill tone={status.tone}>{status.label}</Pill>
|
||||
<span className="lead-source"><Icon name="pin" size={12} /> {lead.job.source}</span>
|
||||
<span className="lead-card-spacer" />
|
||||
<span className="lead-rep" title={`Canvasser: ${lead.job.canvasser}`}>{lead.job.canvasser}</span>
|
||||
<span className="lead-updated">{lead.updated}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* Lead detail popup */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
function LeadDetail({ lead, onClose }: { lead: Lead | null; onClose: () => void }) {
|
||||
if (!lead) return null;
|
||||
const status = STATUS_META[lead.status];
|
||||
const priority = PRIORITY_META[lead.priority];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={!!lead}
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
title={lead.name}
|
||||
subtitle={`${lead.id} · ${lead.tag}`}
|
||||
icon="leads"
|
||||
footer={
|
||||
<>
|
||||
<Btn variant="ghost" icon="phone">Call</Btn>
|
||||
<Btn variant="outline" icon="mail">Email</Btn>
|
||||
<Btn icon="check-circle">Update Status</Btn>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="lead-detail">
|
||||
{/* identity strip */}
|
||||
<div className="ld-identity">
|
||||
<Avatar initials={lead.initials} gradient={lead.gradient} size={54} />
|
||||
<div className="ld-identity-body">
|
||||
<div className="ld-identity-name">{lead.name}</div>
|
||||
<div className="ld-identity-pills">
|
||||
<Pill tone={status.tone}>{status.label}</Pill>
|
||||
<Pill tone={priority.tone}><Icon name="alert" size={11} /> {priority.label}</Pill>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* storm banner */}
|
||||
<div className="ld-storm">
|
||||
<span className="ld-storm-ic"><Icon name="storm" size={18} /></span>
|
||||
<div>
|
||||
<div className="ld-storm-zone">{lead.storm.zone}</div>
|
||||
<div className="ld-storm-meta">{lead.storm.date} · {lead.storm.detail}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ld-grid">
|
||||
{/* Contact */}
|
||||
<Section title="Contact" icon="user">
|
||||
<div className="ld-sublabel">Phone Numbers</div>
|
||||
{lead.phones.map((p, i) => (
|
||||
<div className="ld-contact-row" key={i}>
|
||||
<Icon name="phone" size={14} />
|
||||
<span className="ld-contact-val">{p.number}</span>
|
||||
<span className="ld-contact-tag">{p.type}</span>
|
||||
{p.primary && <Pill tone="green">Primary</Pill>}
|
||||
</div>
|
||||
))}
|
||||
<div className="ld-sublabel">Email Addresses</div>
|
||||
{lead.emails.map((e, i) => (
|
||||
<div className="ld-contact-row" key={i}>
|
||||
<Icon name="mail" size={14} />
|
||||
<span className="ld-contact-val">{e.address}</span>
|
||||
{e.primary && <Pill tone="green">Primary</Pill>}
|
||||
</div>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
{/* Property */}
|
||||
<Section title="Property" icon="owners">
|
||||
<Dl label="Address" value={lead.property.address} />
|
||||
<Dl label="City" value={lead.property.city} />
|
||||
<Dl label="State" value={lead.property.state} />
|
||||
<Dl label="ZIP" value={lead.property.zip} />
|
||||
<Dl label="Property Type" value={lead.property.type} />
|
||||
</Section>
|
||||
|
||||
{/* Job Details */}
|
||||
<Section title="Job Details" icon="projects">
|
||||
<Dl label="Lead Source" value={lead.job.source} />
|
||||
<Dl label="Lead Type" value={lead.job.leadType} />
|
||||
<Dl label="Work Type" value={lead.job.workType} />
|
||||
<Dl label="Trade Type" value={lead.job.tradeType} />
|
||||
<Dl label="Urgency" value={lead.job.urgency} />
|
||||
<Dl label="Canvasser" value={lead.job.canvasser} />
|
||||
<div className="ld-notes">
|
||||
<div className="ld-sublabel">Field Notes</div>
|
||||
<p>{lead.job.notes}</p>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Insurance */}
|
||||
<Section title="Insurance" icon="shield">
|
||||
<Dl label="Insurance Company" value={lead.insurance.company} />
|
||||
<Dl label="Claim Status" value={lead.insurance.claimStatus} />
|
||||
<Dl label="Claim Number" value={lead.insurance.claimNumber} />
|
||||
<Dl label="Policy Number" value={lead.insurance.policyNumber} />
|
||||
<Dl label="Adjuster Name" value={lead.insurance.adjusterName} />
|
||||
<Dl label="Adjuster Phone" value={lead.insurance.adjusterPhone} />
|
||||
</Section>
|
||||
|
||||
{/* Assignment */}
|
||||
<Section title="Assignment" icon="team" wide>
|
||||
<div className="ld-assign">
|
||||
<Dl label="Assigned To" value={lead.assignment.assignedTo} />
|
||||
<Dl label="Priority" value={lead.assignment.priority} />
|
||||
<Dl label="Follow-Up Date" value={lead.assignment.followUp} />
|
||||
<Dl label="Created By" value={lead.assignment.createdBy} />
|
||||
<Dl label="Created At" value={lead.assignment.createdAt} />
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, icon, children, wide }: { title: string; icon: string; children: ReactNode; wide?: boolean }) {
|
||||
return (
|
||||
<div className={`ld-section ${wide ? "wide" : ""}`}>
|
||||
<div className="ld-section-head"><Icon name={icon} size={15} /> {title}</div>
|
||||
<div className="ld-section-body">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Dl({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="ld-dl">
|
||||
<span className="ld-dl-k">{label}</span>
|
||||
<span className="ld-dl-v">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* New Lead — Quick / Full Form intake */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
type Priority = "Low" | "Medium" | "High";
|
||||
type Urgency = "Standard" | "High" | "Emergency";
|
||||
type PhoneRow = { number: string; type: string };
|
||||
type EmailRow = { address: string };
|
||||
|
||||
const FULL_STEPS = [
|
||||
{ value: "contact", label: "Contact", icon: "user" },
|
||||
{ value: "property", label: "Property", icon: "owners" },
|
||||
{ value: "job", label: "Job Details", icon: "projects" },
|
||||
{ value: "insurance", label: "Insurance", icon: "shield" },
|
||||
{ value: "assignment", label: "Assignment", icon: "team" },
|
||||
];
|
||||
|
||||
const LEAD_TYPE_OPTS = ["Residential", "Commercial", "Multi-Family"];
|
||||
const PROPERTY_TYPE_OPTS = ["Residential", "Commercial", "Multi-Family", "Industrial"];
|
||||
|
||||
const BLANK = {
|
||||
firstName: "", lastName: "",
|
||||
phones: [{ number: "", type: "Mobile" }] as PhoneRow[],
|
||||
emails: [] as EmailRow[],
|
||||
address: "", city: "", state: "TX", zip: "", propertyType: "",
|
||||
photos: [] as string[],
|
||||
source: "", referralNote: "", canvasser: "", leadType: "", workType: "", tradeType: "", urgency: "Standard" as Urgency, notes: "",
|
||||
insCompany: "", claimNumber: "", claimStatus: "", adjusterName: "", adjusterPhone: "", policyNumber: "",
|
||||
assignRep: "", priority: "Medium" as Priority, followUp: "",
|
||||
};
|
||||
|
||||
function NewLead({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const toast = useToast();
|
||||
const [mode, setMode] = useState<"quick" | "full">("quick");
|
||||
const [section, setSection] = useState("contact");
|
||||
const [f, setF] = useState({ ...BLANK });
|
||||
|
||||
const set = (k: string) => (e: { target: { value: string } }) =>
|
||||
setF((s) => ({ ...s, [k]: e.target.value }) as typeof BLANK);
|
||||
|
||||
// multi-value handlers
|
||||
const addPhone = () => setF((s) => ({ ...s, phones: [...s.phones, { number: "", type: "Mobile" }] }));
|
||||
const setPhone = (i: number, key: "number" | "type", v: string) => setF((s) => ({ ...s, phones: s.phones.map((p, j) => (j === i ? { ...p, [key]: v } : p)) }));
|
||||
const removePhone = (i: number) => setF((s) => ({ ...s, phones: s.phones.filter((_, j) => j !== i) }));
|
||||
const addEmail = () => setF((s) => ({ ...s, emails: [...s.emails, { address: "" }] }));
|
||||
const setEmail = (i: number, v: string) => setF((s) => ({ ...s, emails: s.emails.map((e, j) => (j === i ? { address: v } : e)) }));
|
||||
const removeEmail = (i: number) => setF((s) => ({ ...s, emails: s.emails.filter((_, j) => j !== i) }));
|
||||
const addPhoto = () => setF((s) => ({ ...s, photos: [...s.photos, `Photo ${s.photos.length + 1}`] }));
|
||||
|
||||
function reset() { setF({ ...BLANK, phones: [{ number: "", type: "Mobile" }], emails: [], photos: [] }); setMode("quick"); setSection("contact"); }
|
||||
function close() { reset(); onClose(); }
|
||||
|
||||
function submit() {
|
||||
const name = `${f.firstName} ${f.lastName}`.trim();
|
||||
if (!name) { toast.push({ tone: "error", title: "Name required", desc: "Enter the homeowner's first or last name." }); return; }
|
||||
toast.push({ tone: "success", title: "Lead created", desc: `${name} added to the Plano pipeline.` });
|
||||
close();
|
||||
}
|
||||
|
||||
const repOptions = [{ id: "", initials: "—", name: "Unassigned" }, ...REPS];
|
||||
|
||||
const stepIdx = FULL_STEPS.findIndex((s) => s.value === section);
|
||||
const isFirstStep = stepIdx <= 0;
|
||||
const isLastStep = stepIdx === FULL_STEPS.length - 1;
|
||||
const goNext = () => { if (!isLastStep) setSection(FULL_STEPS[stepIdx + 1].value); };
|
||||
const goBack = () => { if (!isFirstStep) setSection(FULL_STEPS[stepIdx - 1].value); };
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={close}
|
||||
size="lg"
|
||||
title="New Lead"
|
||||
subtitle="Full lead profile with insurance and assignment details."
|
||||
icon="plus"
|
||||
footer={
|
||||
mode === "full" ? (
|
||||
<>
|
||||
<Btn variant="ghost" onClick={close}>Cancel</Btn>
|
||||
{!isFirstStep && <Btn variant="ghost" onClick={goBack}>Back</Btn>}
|
||||
{isLastStep
|
||||
? <Btn icon="check" onClick={submit}>Create Lead</Btn>
|
||||
: <Btn icon="arrow" onClick={goNext}>Next</Btn>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Btn variant="ghost" onClick={close}>Cancel</Btn>
|
||||
<Btn icon="check" onClick={submit}>Create Lead</Btn>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="nl-form">
|
||||
<Segmented
|
||||
value={mode}
|
||||
onChange={(v) => setMode(v as "quick" | "full")}
|
||||
options={[{ value: "quick", label: "Quick", icon: "star" }, { value: "full", label: "Full Form", icon: "edit" }]}
|
||||
/>
|
||||
|
||||
{mode === "quick" ? (
|
||||
<div className="nl-grid">
|
||||
<Field label="First name" required><input className="ds-input" value={f.firstName} onChange={set("firstName")} placeholder="John" /></Field>
|
||||
<Field label="Last name"><input className="ds-input" value={f.lastName} onChange={set("lastName")} placeholder="Smith" /></Field>
|
||||
<Field label="Phone"><input className="ds-input" value={f.phones[0]?.number ?? ""} onChange={(e) => setPhone(0, "number", e.target.value)} placeholder="(555) 000-0000" /></Field>
|
||||
<div className="nl-full"><Field label="Street address"><input className="ds-input" value={f.address} onChange={set("address")} placeholder="123 Main St" /></Field></div>
|
||||
<Field label="City"><input className="ds-input" value={f.city} onChange={set("city")} placeholder="Plano" /></Field>
|
||||
<Field label="State"><input className="ds-input" value={f.state} onChange={set("state")} /></Field>
|
||||
<Field label="ZIP"><input className="ds-input" value={f.zip} onChange={set("zip")} placeholder="75023" /></Field>
|
||||
<Field label="Lead source"><Select value={f.source} onChange={set("source")} options={LEAD_SOURCES} placeholder="How did you find this lead?" /></Field>
|
||||
{f.source === "Referral" && (
|
||||
<div className="nl-full"><Field label="Referral note"><textarea className="ds-textarea" rows={3} value={f.referralNote} onChange={set("referralNote")} placeholder="Who referred this lead? Any details…" /></Field></div>
|
||||
)}
|
||||
{f.source === "Door Knock" && (
|
||||
<div className="nl-full"><Field label="Canvasser"><CanvasserSearch value={f.canvasser} onChange={(v) => setF((s) => ({ ...s, canvasser: v }))} options={REPS} /></Field></div>
|
||||
)}
|
||||
<div className="nl-full"><PriorityPicker value={f.priority} onChange={(p) => setF((s) => ({ ...s, priority: p }))} /></div>
|
||||
<Field label="Follow-up date"><input className="ds-input" type="date" value={f.followUp} onChange={set("followUp")} /></Field>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SegTabs
|
||||
value={section}
|
||||
onChange={setSection}
|
||||
tabs={FULL_STEPS}
|
||||
/>
|
||||
|
||||
{section === "contact" && (
|
||||
<div className="nl-grid">
|
||||
<Field label="First Name" required><input className="ds-input" value={f.firstName} onChange={set("firstName")} placeholder="John" /></Field>
|
||||
<Field label="Last Name"><input className="ds-input" value={f.lastName} onChange={set("lastName")} placeholder="Smith" /></Field>
|
||||
|
||||
<div className="nl-full">
|
||||
<div className="ds-field-lbl">Phone Numbers</div>
|
||||
{f.phones.map((p, i) => (
|
||||
<div className="nl-multirow" key={i}>
|
||||
<input className="ds-input" value={p.number} onChange={(e) => setPhone(i, "number", e.target.value)} placeholder="(555) 000-0000" />
|
||||
<select className="ds-select nl-typesel" value={p.type} onChange={(e) => setPhone(i, "type", e.target.value)}>
|
||||
{["Mobile", "Home", "Work"].map((t) => <option key={t} value={t}>{t}</option>)}
|
||||
</select>
|
||||
{f.phones.length > 1 && <button type="button" className="nl-rowx" aria-label="Remove phone" onClick={() => removePhone(i)}><Icon name="trash" size={15} /></button>}
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="nl-add" onClick={addPhone}><Icon name="plus" size={14} /> Add Phone</button>
|
||||
</div>
|
||||
|
||||
<div className="nl-full">
|
||||
<div className="ds-field-lbl">Email Addresses</div>
|
||||
{f.emails.length === 0 && <div className="nl-empty">No emails added yet.</div>}
|
||||
{f.emails.map((em, i) => (
|
||||
<div className="nl-multirow" key={i}>
|
||||
<input className="ds-input" type="email" value={em.address} onChange={(e) => setEmail(i, e.target.value)} placeholder="name@email.com" />
|
||||
<button type="button" className="nl-rowx" aria-label="Remove email" onClick={() => removeEmail(i)}><Icon name="trash" size={15} /></button>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="nl-add" onClick={addEmail}><Icon name="plus" size={14} /> Add Email</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === "property" && (
|
||||
<div className="nl-grid">
|
||||
<div className="nl-full"><Field label="Street Address"><input className="ds-input" value={f.address} onChange={set("address")} placeholder="123 Main St" /></Field></div>
|
||||
<Field label="City"><input className="ds-input" value={f.city} onChange={set("city")} placeholder="Plano" /></Field>
|
||||
<Field label="State"><input className="ds-input" value={f.state} onChange={set("state")} placeholder="TX" /></Field>
|
||||
<Field label="ZIP"><input className="ds-input" value={f.zip} onChange={set("zip")} placeholder="75023" /></Field>
|
||||
<Field label="Property Type"><Select value={f.propertyType} onChange={set("propertyType")} options={PROPERTY_TYPE_OPTS} placeholder="Residential, Commercial…" /></Field>
|
||||
<div className="nl-full">
|
||||
<div className="ds-field-lbl">Site Photos</div>
|
||||
<button type="button" className="nl-photos" onClick={addPhoto}>
|
||||
<Icon name="camera" size={22} />
|
||||
<span className="nl-photos-t">Tap to add photos</span>
|
||||
<span className="nl-photos-s">Camera · Gallery · Multiple allowed</span>
|
||||
</button>
|
||||
{f.photos.length > 0 && (
|
||||
<div className="nl-photo-chips">
|
||||
{f.photos.map((p, i) => <span key={i} className="nl-photo-chip"><Icon name="check" size={12} /> {p}</span>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === "job" && (
|
||||
<div className="nl-grid">
|
||||
<Field label="Lead Source"><Select value={f.source} onChange={set("source")} options={LEAD_SOURCES} placeholder="How did you find this lead?" /></Field>
|
||||
<Field label="Lead Type"><Select value={f.leadType} onChange={set("leadType")} options={LEAD_TYPE_OPTS} placeholder="Residential, Commercial…" /></Field>
|
||||
<Field label="Work Type"><Select value={f.workType} onChange={set("workType")} options={WORK_TYPES} placeholder="Roof Replacement, Repair…" /></Field>
|
||||
<Field label="Trade Type"><Select value={f.tradeType} onChange={set("tradeType")} options={TRADE_TYPES} placeholder="Roofing, Gutter, Siding…" /></Field>
|
||||
<div className="nl-full"><UrgencyPicker value={f.urgency} onChange={(u) => setF((s) => ({ ...s, urgency: u }))} /></div>
|
||||
<div className="nl-full"><Field label="Notes"><textarea className="ds-textarea" rows={3} value={f.notes} onChange={set("notes")} placeholder="First impression, visible damage, special circumstances…" /></Field></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === "insurance" && (
|
||||
<div className="nl-grid">
|
||||
<div className="nl-full"><Field label="Insurance Company"><input className="ds-input" value={f.insCompany} onChange={set("insCompany")} placeholder="State Farm" /></Field></div>
|
||||
<Field label="Claim Number"><input className="ds-input" value={f.claimNumber} onChange={set("claimNumber")} placeholder="e.g. CLM-2026-00482" /></Field>
|
||||
<Field label="Claim Status"><Select value={f.claimStatus} onChange={set("claimStatus")} options={CLAIM_STATUSES} placeholder="Select status…" /></Field>
|
||||
<Field label="Adjuster Name"><input className="ds-input" value={f.adjusterName} onChange={set("adjusterName")} placeholder="Full name" /></Field>
|
||||
<Field label="Adjuster Phone"><input className="ds-input" value={f.adjusterPhone} onChange={set("adjusterPhone")} placeholder="(555) 000-0000" /></Field>
|
||||
<div className="nl-full"><Field label="Policy Number"><input className="ds-input" value={f.policyNumber} onChange={set("policyNumber")} placeholder="e.g. POL-7734892-A" /></Field></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === "assignment" && (
|
||||
<div className="nl-grid">
|
||||
<div className="nl-full"><Field label="Assign Rep"><RepSelect value={f.assignRep} onChange={set("assignRep")} options={repOptions} /></Field></div>
|
||||
<div className="nl-full"><PriorityPicker value={f.priority} onChange={(p) => setF((s) => ({ ...s, priority: p }))} /></div>
|
||||
<Field label="Follow-up Date"><input className="ds-input" type="date" value={f.followUp} onChange={set("followUp")} /></Field>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Select({ value, onChange, options, placeholder }: { value: string; onChange: (e: { target: { value: string } }) => void; options: string[]; placeholder?: string }) {
|
||||
return (
|
||||
<select className={`ds-select ${!value && placeholder ? "is-placeholder" : ""}`} value={value} onChange={onChange}>
|
||||
{placeholder && <option value="" disabled>{placeholder}</option>}
|
||||
{options.map((o) => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function RepSelect({ value, onChange, options }: { value: string; onChange: (e: { target: { value: string } }) => void; options: { id: string; initials: string; name: string }[] }) {
|
||||
return (
|
||||
<select className="ds-select" value={value} onChange={onChange}>
|
||||
{options.map((r) => <option key={r.id || "none"} value={r.id}>{r.id ? `${r.name} · ${r.id}` : "— Unassigned"}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function CanvasserSearch({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: { id: string; initials: string; name: string; email: string }[] }) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const selected = options.find((o) => o.id === value);
|
||||
const matches = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return options;
|
||||
return options.filter((o) => o.name.toLowerCase().includes(q) || o.email.toLowerCase().includes(q));
|
||||
}, [query, options]);
|
||||
|
||||
if (selected) {
|
||||
return (
|
||||
<div className="nl-canvasser-chip">
|
||||
<Avatar initials={selected.initials} size={28} />
|
||||
<span className="nl-canvasser-name">{selected.name}</span>
|
||||
<span className="nl-canvasser-email">{selected.email}</span>
|
||||
<button type="button" className="nl-canvasser-clear" onClick={() => { onChange(""); setQuery(""); }} aria-label="Clear canvasser"><Icon name="x" size={14} /></button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="nl-canvasser">
|
||||
<input
|
||||
className="ds-input"
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
placeholder="Search canvasser by name or email"
|
||||
/>
|
||||
{open && matches.length > 0 && (
|
||||
<div className="nl-canvasser-menu">
|
||||
{matches.map((o) => (
|
||||
<button key={o.id} type="button" className="nl-canvasser-opt" onMouseDown={(e) => { e.preventDefault(); onChange(o.id); setQuery(""); setOpen(false); }}>
|
||||
<Avatar initials={o.initials} size={26} />
|
||||
<span className="nl-canvasser-name">{o.name}</span>
|
||||
<span className="nl-canvasser-email">{o.email}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{open && matches.length === 0 && (
|
||||
<div className="nl-canvasser-menu"><div className="nl-canvasser-empty">No canvasser found</div></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PriorityPicker({ value, onChange }: { value: Priority; onChange: (p: Priority) => void }) {
|
||||
return (
|
||||
<div className="ds-field">
|
||||
<span className="ds-field-lbl">Priority</span>
|
||||
<div className="nl-prio">
|
||||
{(["Low", "Medium", "High"] as Priority[]).map((p) => (
|
||||
<button key={p} type="button" className={`nl-prio-btn ${value === p ? `active ${p.toLowerCase()}` : ""}`} onClick={() => onChange(p)}>{p}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UrgencyPicker({ value, onChange }: { value: Urgency; onChange: (u: Urgency) => void }) {
|
||||
return (
|
||||
<div className="ds-field">
|
||||
<span className="ds-field-lbl">Urgency</span>
|
||||
<div className="nl-prio">
|
||||
{(["Standard", "High", "Emergency"] as Urgency[]).map((u) => (
|
||||
<button key={u} type="button" className={`nl-prio-btn urg ${value === u ? `active ${u.toLowerCase()}` : ""}`} onClick={() => onChange(u)}>{u}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// ============================================================
|
||||
// LynkedUp Pro — Lead Verification mock data.
|
||||
// The verification desk: door-knocked / web leads move through
|
||||
// an identity + insurance verification workflow before they
|
||||
// become working deals. All client-side.
|
||||
// ============================================================
|
||||
|
||||
export type VStatus = "verified" | "in_progress" | "assigned" | "pending" | "unverified";
|
||||
|
||||
export type VActivity = { text: string; time: string; who: string };
|
||||
|
||||
export type VLead = {
|
||||
id: string;
|
||||
initials: string;
|
||||
name: string;
|
||||
address: string;
|
||||
phone: string;
|
||||
source: string;
|
||||
assignee: { initials: string; name: string } | null;
|
||||
status: VStatus;
|
||||
verification: string; // sub-status text
|
||||
created: string;
|
||||
// detail (optional — derived when absent)
|
||||
email?: string;
|
||||
createdAt?: string; // date + time
|
||||
verifiedAt?: string;
|
||||
notes?: string;
|
||||
activity?: VActivity[];
|
||||
};
|
||||
|
||||
export const V_STATUS_META: Record<VStatus, { label: string; tone: string }> = {
|
||||
verified: { label: "Verified", tone: "green" },
|
||||
in_progress: { label: "In Progress", tone: "orange" },
|
||||
assigned: { label: "Assigned", tone: "blue" },
|
||||
pending: { label: "Pending", tone: "purple" },
|
||||
unverified: { label: "Unverified", tone: "red" },
|
||||
};
|
||||
|
||||
const WADE = { initials: "WH", name: "Wade Hollis" };
|
||||
const DARLENE = { initials: "DB", name: "Darlene Brooks" };
|
||||
const ROY = { initials: "RS", name: "Roy Schaefer" };
|
||||
|
||||
export const V_LEADS: VLead[] = [
|
||||
{
|
||||
id: "LD-V-001", initials: "K", name: "Kevin Hartley", address: "2814 Ravenswood Dr, Plano, TX 75023", phone: "(972) 413-8902", source: "Door Knock", assignee: WADE, status: "verified", verification: "Verified", created: "May 14, 2026",
|
||||
email: "kevin.hartley@gmail.com", createdAt: "May 14, 2026, 2:40 PM", verifiedAt: "May 16, 2026, 7:55 PM",
|
||||
notes: "Ownership confirmed via county records. Hail damage from April 28 storm event verified.",
|
||||
activity: [
|
||||
{ text: "Lead submitted via door knock intake.", time: "May 14, 2026, 2:40 PM", who: "System" },
|
||||
{ text: "Assigned to Wade Hollis.", time: "May 14, 2026, 3:10 PM", who: "Wade Hollis" },
|
||||
{ text: "First contact made — homeowner confirmed damage.", time: "May 15, 2026, 2:00 PM", who: "Wade Hollis" },
|
||||
{ text: "Verified and pushed to New Leads.", time: "May 16, 2026, 7:55 PM", who: "Wade Hollis" },
|
||||
],
|
||||
},
|
||||
{ id: "LD-V-002", initials: "S", name: "Sandra Nguyen", address: "4817 Shady Brook Ln, Plano, TX 75093", phone: "(469) 551-7034", source: "Web Form", assignee: DARLENE, status: "verified", verification: "Verified", created: "May 17, 2026" },
|
||||
{ id: "LD-V-003", initials: "M", name: "Marcus Trevino", address: "1234 Oak Creek Blvd, Plano, TX 75075", phone: "(214) 837-4561", source: "Storm Canvass", assignee: WADE, status: "in_progress", verification: "Verifying Identity", created: "May 22, 2026" },
|
||||
{ id: "LD-V-004", initials: "B", name: "Brenda Kowalski", address: "890 Custer Rd, Plano, TX 75075", phone: "(972) 604-2817", source: "Referral", assignee: ROY, status: "in_progress", verification: "Reviewing Insurance", created: "May 20, 2026" },
|
||||
{ id: "LD-V-005", initials: "J", name: "James Whitaker", address: "3320 Parkhaven Dr, Plano, TX 75075", phone: "(469) 720-1188", source: "Door Knock", assignee: WADE, status: "in_progress", verification: "Confirming Ownership", created: "May 23, 2026" },
|
||||
{ id: "LD-V-006", initials: "P", name: "Priya Sharma", address: "5102 Mapleshade Ln, Plano, TX 75093", phone: "(972) 415-6620", source: "Web Form", assignee: DARLENE, status: "in_progress", verification: "Reviewing Insurance", created: "May 24, 2026" },
|
||||
{ id: "LD-V-007", initials: "C", name: "Carlos Mendez", address: "1470 Coit Rd, Plano, TX 75075", phone: "(214) 902-5533", source: "Storm Canvass", assignee: ROY, status: "in_progress", verification: "Confirming Damage", created: "May 25, 2026" },
|
||||
{ id: "LD-V-008", initials: "E", name: "Emily Carter", address: "2609 Rivercrest Dr, Plano, TX 75023", phone: "(469) 338-4471", source: "Door Knock", assignee: DARLENE, status: "assigned", verification: "Assigned", created: "May 26, 2026" },
|
||||
{ id: "LD-V-009", initials: "T", name: "Tyrone Jackson", address: "744 Legacy Dr, Plano, TX 75023", phone: "(972) 551-9042", source: "Referral", assignee: WADE, status: "assigned", verification: "Assigned", created: "May 26, 2026" },
|
||||
{ id: "LD-V-010", initials: "N", name: "Nicole Foster", address: "3901 Preston Meadow Dr, Plano, TX 75093", phone: "(214) 660-7719", source: "Web Form", assignee: ROY, status: "assigned", verification: "Assigned", created: "May 27, 2026" },
|
||||
{ id: "LD-V-011", initials: "A", name: "Aaron Blake", address: "1188 Alma Dr, Plano, TX 75075", phone: "(469) 471-3350", source: "Door Knock", assignee: null, status: "pending", verification: "Pending Review", created: "May 27, 2026" },
|
||||
{ id: "LD-V-012", initials: "G", name: "Grace Liu", address: "5540 Communications Pkwy, Plano, TX 75093", phone: "(972) 883-2201", source: "Web Form", assignee: null, status: "pending", verification: "Pending Review", created: "May 28, 2026" },
|
||||
{ id: "LD-V-013", initials: "D", name: "Derek Olsen", address: "902 Independence Pkwy, Plano, TX 75075", phone: "(214) 774-6690", source: "Call-In", assignee: null, status: "pending", verification: "Pending Review", created: "May 28, 2026" },
|
||||
{ id: "LD-V-014", initials: "M", name: "Monica Reyes", address: "3115 Rasor Blvd, Plano, TX 75093", phone: "(469) 205-8814", source: "Storm Canvass", assignee: null, status: "unverified", verification: "Unverified", created: "May 29, 2026" },
|
||||
{ id: "LD-V-015", initials: "S", name: "Sam Patterson", address: "677 Spring Creek Pkwy, Plano, TX 75023", phone: "(972) 330-1247", source: "Call-In", assignee: null, status: "unverified", verification: "Unverified", created: "May 29, 2026" },
|
||||
];
|
||||
|
||||
export const V_SOURCES = ["Door Knock", "Web Form", "Storm Canvass", "Referral", "Call-In"];
|
||||
export const V_ASSIGNEES = ["Wade Hollis", "Darlene Brooks", "Roy Schaefer"];
|
||||
@@ -0,0 +1,306 @@
|
||||
"use client";
|
||||
|
||||
// ============================================================
|
||||
// Lead Verification — the verification desk.
|
||||
// · Stat tiles : Verified / In Progress / Assigned / Pending
|
||||
// / Unverified counts (click to filter)
|
||||
// · Toolbar : search + status / source / assignee filters
|
||||
// · Table : Lead ID · Customer · Phone · Source ·
|
||||
// Assigned To · Status · Verification · Created
|
||||
// · Actions (view / verify / ⋯ menu)
|
||||
// · Detail : view → popup with Contact, Assignment,
|
||||
// Verification Notes and an Activity timeline
|
||||
// Data comes from verify-data.ts (client-side mock).
|
||||
// ============================================================
|
||||
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Avatar, Btn, Icon, Modal, PageHead, Pill, useToast } from "./ui";
|
||||
import { V_LEADS, V_STATUS_META, V_SOURCES, V_ASSIGNEES, type VStatus, type VLead, type VActivity } from "./verify-data";
|
||||
|
||||
const STAT_ORDER: VStatus[] = ["verified", "in_progress", "assigned", "pending", "unverified"];
|
||||
const STAT_ICON: Record<VStatus, string> = {
|
||||
verified: "check-circle", in_progress: "refresh", assigned: "user", pending: "clock", unverified: "alert",
|
||||
};
|
||||
|
||||
/* ---- derive detail when the row doesn't carry it ---------- */
|
||||
function deriveEmail(l: VLead) {
|
||||
if (l.email) return l.email;
|
||||
const [first, ...rest] = l.name.toLowerCase().split(" ");
|
||||
return `${first}.${rest.join("")}@gmail.com`;
|
||||
}
|
||||
function deriveCreatedAt(l: VLead) { return l.createdAt ?? `${l.created}, 10:00 AM`; }
|
||||
function buildActivity(l: VLead): VActivity[] {
|
||||
if (l.activity) return l.activity;
|
||||
const who = l.assignee?.name ?? "System";
|
||||
const a: VActivity[] = [{ text: `Lead submitted via ${l.source.toLowerCase()} intake.`, time: deriveCreatedAt(l), who: "System" }];
|
||||
if (l.assignee) a.push({ text: `Assigned to ${l.assignee.name}.`, time: l.created, who: l.assignee.name });
|
||||
if (l.status === "in_progress") a.push({ text: `Verification in progress — ${l.verification.toLowerCase()}.`, time: l.created, who });
|
||||
if (l.status === "verified") a.push({ text: "Verified and pushed to New Leads.", time: l.verifiedAt ?? l.created, who });
|
||||
return a;
|
||||
}
|
||||
|
||||
type MenuState = { lead: VLead; x: number; y: number } | null;
|
||||
|
||||
export function Verify() {
|
||||
const toast = useToast();
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState("all");
|
||||
const [source, setSource] = useState("all");
|
||||
const [assignee, setAssignee] = useState("all");
|
||||
const [selected, setSelected] = useState<VLead | null>(null);
|
||||
const [menu, setMenu] = useState<MenuState>(null);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const m: Record<string, number> = {};
|
||||
for (const l of V_LEADS) m[l.status] = (m[l.status] ?? 0) + 1;
|
||||
return m;
|
||||
}, []);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return V_LEADS.filter((l) => {
|
||||
const matchQ = !q || `${l.name} ${l.id} ${l.phone} ${l.source} ${l.address}`.toLowerCase().includes(q);
|
||||
const matchStatus = status === "all" || l.status === status;
|
||||
const matchSource = source === "all" || l.source === source;
|
||||
const matchAssignee = assignee === "all" || l.assignee?.name === assignee;
|
||||
return matchQ && matchStatus && matchSource && matchAssignee;
|
||||
});
|
||||
}, [query, status, source, assignee]);
|
||||
|
||||
function act(l: VLead, title: string, desc: string, tone: "success" | "info" = "info") {
|
||||
setMenu(null);
|
||||
toast.push({ tone, title, desc });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="view lv">
|
||||
<PageHead
|
||||
eyebrow="Sales"
|
||||
title="Lead Verification"
|
||||
subtitle="Identity & insurance checks before a lead becomes a working deal."
|
||||
icon="verify"
|
||||
actions={<Btn variant="outline" icon="refresh" onClick={() => toast.push({ tone: "info", title: "Queue refreshed", desc: "Verification queue is up to date." })}>Refresh</Btn>}
|
||||
/>
|
||||
|
||||
{/* ---- stat tiles ---- */}
|
||||
<div className="lv-stats">
|
||||
{STAT_ORDER.map((s) => {
|
||||
const meta = V_STATUS_META[s];
|
||||
return (
|
||||
<button key={s} className={`lv-stat tone-${meta.tone} ${status === s ? "active" : ""}`} onClick={() => setStatus(status === s ? "all" : s)}>
|
||||
<span className="lv-stat-ic"><Icon name={STAT_ICON[s]} size={16} /></span>
|
||||
<span className="lv-stat-val">{counts[s] ?? 0}</span>
|
||||
<span className="lv-stat-lbl">{meta.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ---- toolbar ---- */}
|
||||
<div className="lv-toolbar">
|
||||
<div className="lv-search">
|
||||
<Icon name="search" size={16} />
|
||||
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search name, lead ID, phone, source…" aria-label="Search verification leads" />
|
||||
{query && <button className="lv-search-x" aria-label="Clear" onClick={() => setQuery("")}><Icon name="x" size={14} /></button>}
|
||||
</div>
|
||||
<select className="ds-select lv-filter" value={status} onChange={(e) => setStatus(e.target.value)} aria-label="Filter by status">
|
||||
<option value="all">All statuses</option>
|
||||
{STAT_ORDER.map((s) => <option key={s} value={s}>{V_STATUS_META[s].label}</option>)}
|
||||
</select>
|
||||
<select className="ds-select lv-filter" value={source} onChange={(e) => setSource(e.target.value)} aria-label="Filter by source">
|
||||
<option value="all">All sources</option>
|
||||
{V_SOURCES.map((s) => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
<select className="ds-select lv-filter" value={assignee} onChange={(e) => setAssignee(e.target.value)} aria-label="Filter by assignee">
|
||||
<option value="all">All assignees</option>
|
||||
{V_ASSIGNEES.map((a) => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* ---- table ---- */}
|
||||
<div className="lv-tablewrap">
|
||||
<table className="lv-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Lead ID</th><th>Customer</th><th>Phone</th><th>Source</th>
|
||||
<th>Assigned To</th><th>Status</th><th>Verification</th><th>Created</th>
|
||||
<th className="lv-actions-h">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr><td colSpan={9} className="lv-empty">No leads match your filters.</td></tr>
|
||||
) : rows.map((l) => {
|
||||
const meta = V_STATUS_META[l.status];
|
||||
return (
|
||||
<tr key={l.id}>
|
||||
<td><div className="lv-id">{l.id}</div><div className="lv-id-date">{l.created}</div></td>
|
||||
<td>
|
||||
<div className="lv-cust">
|
||||
<Avatar initials={l.initials} size={34} />
|
||||
<div className="lv-cust-body">
|
||||
<div className="lv-cust-name">{l.name}</div>
|
||||
<div className="lv-cust-addr">{l.address}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="lv-phone">{l.phone}</td>
|
||||
<td><span className="lv-source">{l.source}</span></td>
|
||||
<td>
|
||||
{l.assignee ? (
|
||||
<div className="lv-assignee">
|
||||
<Avatar initials={l.assignee.initials} size={26} gradient="linear-gradient(135deg,#4f8cff,#2c5cff)" />
|
||||
<span>{l.assignee.name}</span>
|
||||
</div>
|
||||
) : <span className="lv-unassigned">— Unassigned</span>}
|
||||
</td>
|
||||
<td><Pill tone={meta.tone}>{meta.label}</Pill></td>
|
||||
<td><span className={`lv-verif v-${l.status}`}><Icon name={STAT_ICON[l.status]} size={13} /> {l.verification}</span></td>
|
||||
<td className="lv-created">{l.created}</td>
|
||||
<td>
|
||||
<div className="lv-rowacts">
|
||||
<button className="lv-act" aria-label="View details" title="View details" onClick={() => setSelected(l)}><Icon name="eye" size={15} /></button>
|
||||
<button className="lv-act primary" aria-label="Verify" title="Verify lead" onClick={() => act(l, "Marked verified", `${l.name} moved to Verified.`, "success")}><Icon name="check-circle" size={15} /></button>
|
||||
<button className="lv-act" aria-label="More actions" title="More actions" onClick={(e) => setMenu(menu?.lead.id === l.id ? null : { lead: l, x: e.clientX, y: e.clientY })}><Icon name="dots" size={15} /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="lv-count">{rows.length} of {V_LEADS.length} leads</div>
|
||||
|
||||
<ActionsMenu menu={menu} onClose={() => setMenu(null)} onAct={act} onView={(l) => { setSelected(l); setMenu(null); }} />
|
||||
<VerifyDetail lead={selected} onClose={() => setSelected(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* Row actions dropdown (portalled, fixed-positioned) */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
function ActionsMenu({ menu, onClose, onAct, onView }: {
|
||||
menu: MenuState; onClose: () => void;
|
||||
onAct: (l: VLead, title: string, desc: string, tone?: "success" | "info") => void;
|
||||
onView: (l: VLead) => void;
|
||||
}) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => { setMounted(true); }, []);
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); };
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [menu, onClose]);
|
||||
if (!menu || !mounted) return null;
|
||||
|
||||
const l = menu.lead;
|
||||
const left = Math.max(8, menu.x - 196);
|
||||
const top = Math.min(menu.y + 8, window.innerHeight - 260);
|
||||
|
||||
const items = [
|
||||
{ icon: "eye", label: "View Details", run: () => onView(l) },
|
||||
{ icon: "check-circle", label: "Verify Lead", run: () => onAct(l, "Verified", `${l.name} marked as verified.`, "success") },
|
||||
{ icon: "alert", label: "Mark Unverified", run: () => onAct(l, "Marked unverified", `${l.name} moved to Unverified.`) },
|
||||
{ icon: "user", label: "Change Assignee", run: () => onAct(l, "Change assignee", `Pick a new rep for ${l.name}.`) },
|
||||
{ icon: "refresh", label: "Reassign (In Progress)", run: () => onAct(l, "Reassigned", `${l.name} set to In Progress.`) },
|
||||
{ icon: "clock", label: "Move to Pending", run: () => onAct(l, "Moved to pending", `${l.name} is now Pending review.`) },
|
||||
];
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<div className="lv-menu-scrim" onClick={onClose} />
|
||||
<div className="lv-menu" style={{ left, top }} role="menu">
|
||||
{items.map((it) => (
|
||||
<button key={it.label} className="lv-menu-item" role="menuitem" onClick={it.run}>
|
||||
<Icon name={it.icon} size={14} /> {it.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- */
|
||||
/* Verification detail popup */
|
||||
/* ---------------------------------------------------------- */
|
||||
|
||||
function VerifyDetail({ lead, onClose }: { lead: VLead | null; onClose: () => void }) {
|
||||
if (!lead) return null;
|
||||
const meta = V_STATUS_META[lead.status];
|
||||
const activity = buildActivity(lead);
|
||||
|
||||
return (
|
||||
<Modal open={!!lead} onClose={onClose} size="lg" title={lead.name} subtitle={`${lead.id} · ${lead.verification}`} icon="verify"
|
||||
footer={<>
|
||||
<Btn variant="ghost" icon="phone">Call</Btn>
|
||||
<Btn icon="check-circle">Verify Lead</Btn>
|
||||
</>}
|
||||
>
|
||||
<div className="lv-detail">
|
||||
<div className="lv-d-identity">
|
||||
<Avatar initials={lead.initials} size={50} />
|
||||
<div>
|
||||
<div className="lv-d-name">{lead.name}</div>
|
||||
<div className="lv-d-pills">
|
||||
<Pill tone={meta.tone}>{meta.label}</Pill>
|
||||
<span className={`lv-verif v-${lead.status}`}><Icon name={STAT_ICON[lead.status]} size={13} /> {lead.verification}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="lv-d-grid">
|
||||
<div className="lv-d-section">
|
||||
<div className="lv-d-head"><Icon name="user" size={15} /> Contact</div>
|
||||
<Row label="Phone" value={lead.phone} />
|
||||
<Row label="Email" value={deriveEmail(lead)} />
|
||||
<Row label="Address" value={lead.address} />
|
||||
<Row label="Source" value={lead.source} />
|
||||
</div>
|
||||
<div className="lv-d-section">
|
||||
<div className="lv-d-head"><Icon name="team" size={15} /> Assignment</div>
|
||||
<Row label="Assigned To" value={lead.assignee?.name ?? "Unassigned"} />
|
||||
<Row label="Created" value={deriveCreatedAt(lead)} />
|
||||
{lead.verifiedAt && <Row label="Verified At" value={lead.verifiedAt} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lead.notes && (
|
||||
<div className="lv-d-notes">
|
||||
<div className="lv-d-head"><Icon name="edit" size={15} /> Verification Notes</div>
|
||||
<p>{lead.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="lv-d-activity">
|
||||
<div className="lv-d-head"><Icon name="clock" size={15} /> Activity</div>
|
||||
<ul className="lv-timeline">
|
||||
{activity.map((a, i) => (
|
||||
<li key={i} className="lv-tl-item">
|
||||
<span className="lv-tl-dot" />
|
||||
<div className="lv-tl-body">
|
||||
<div className="lv-tl-text">{a.text}</div>
|
||||
<div className="lv-tl-meta">{a.time} · {a.who}</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="lv-d-row">
|
||||
<span className="lv-d-k">{label}</span>
|
||||
<span className="lv-d-v">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user