Mobile Phase 8f: Pipeline card → dc anatomy (earmark/Priority/recency, scroll pills, dots)
Bring the mobile Pipeline surface to the PipelineApp.dc.html default anatomy: - Segmented control → horizontal-scroll pills with label + count badge; the active pill tints to its own stage color via --seg-* (aliased to --chip-*, so it flips in light). - Card → earmark corner + name + Priority pill / $amount · dot · recency / labeled ‹ Prev · Next › move footer (was name + contact·org sub + bare chevrons). Compact amount. - Stage-column header → StageChip + investor count + committed sum. - Page dots → tappable, active = 22px accent bar. Backend: the opportunities list injects two derived read-only fields (mirroring the Contacts-list pattern; opp writes use a field allowlist so neither round-trips): - existing_investor (contact_grid_signals committed>0) so the card earmark agrees with the detail's "Existing LP" pill. - last_contact_date (MAX communication_date on the deal's contact, deleted_at-filtered) → card recency line + the Staleness sort (replaces the updated_at proxy). Guarded by new soft-delete assertions in test_soft_delete_reads.py. 39/39 green.
This commit is contained in:
+90
-46
@@ -2579,23 +2579,32 @@
|
||||
|
||||
/* ─── Phase 4 — Pipeline mobile surface (swipe-between-stages) ─────────────────────
|
||||
JS-gated to MobilePipeline; reuses the .fs-detail / .sheet / .stage-chip patterns.
|
||||
Stage segmented control (count-forward) → horizontal scroll-snap stage pages → dots;
|
||||
per-card ‹/› stage move shares PATCH /api/opportunities/{id}/stage (DESIGN §8 / BRIEF §3c). */
|
||||
.pipeline-seg { display: flex; gap: 6px; }
|
||||
Horizontal-scroll stage pills (label + count badge) → horizontal scroll-snap stage pages → tappable dots;
|
||||
per-card ‹ Prev / Next › stage move shares PATCH /api/opportunities/{id}/stage (DESIGN §8 / BRIEF §3c). */
|
||||
/* Stage segmented control (P1): horizontal-scroll pills, label + count badge; the active
|
||||
pill tints to its own stage color via --seg-* (set per .pipeline-seg-tab--{stage}). */
|
||||
.pipeline-seg { display: flex; gap: 8px; overflow-x: auto; scrollbar-width: none; -ms-overflow-style: none; padding-bottom: 2px; }
|
||||
.pipeline-seg::-webkit-scrollbar { display: none; }
|
||||
.pipeline-seg-tab {
|
||||
flex: 1; min-width: 0; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center; gap: 2px;
|
||||
min-height: var(--mobile-touch-target); padding: 5px 2px;
|
||||
background: var(--bg-panel); border: 1px solid var(--border);
|
||||
border-radius: var(--mobile-control-radius);
|
||||
flex: none; display: inline-flex; align-items: center; gap: 8px;
|
||||
height: 36px; padding: 0 14px; border-radius: 999px;
|
||||
background: var(--bg-input); border: 1px solid var(--border);
|
||||
color: var(--text-subtle); font-family: inherit; cursor: pointer;
|
||||
}
|
||||
.pipeline-seg-tab.active { background: var(--accent-soft); border-color: var(--accent); color: var(--accent-light); }
|
||||
.pipeline-seg-count { font-family: 'IBM Plex Mono', monospace; font-size: 15px; font-weight: 600; line-height: 1; }
|
||||
.pipeline-seg-label {
|
||||
font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; line-height: 1;
|
||||
max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
.pipeline-seg-label { font-family: 'IBM Plex Mono', monospace; font-size: 12px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; line-height: 1; }
|
||||
.pipeline-seg-count {
|
||||
font-family: 'IBM Plex Mono', monospace; font-size: 11px; font-weight: 600;
|
||||
min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
background: var(--border); color: var(--text-subtle);
|
||||
}
|
||||
.pipeline-seg-tab--lead { --seg-bg: var(--chip-lead-bg); --seg-text: var(--chip-lead-text); --seg-border: var(--chip-lead-border); }
|
||||
.pipeline-seg-tab--engaged { --seg-bg: var(--chip-engaged-bg); --seg-text: var(--chip-engaged-text); --seg-border: var(--chip-engaged-border); }
|
||||
.pipeline-seg-tab--diligence { --seg-bg: var(--chip-diligence-bg); --seg-text: var(--chip-diligence-text); --seg-border: var(--chip-diligence-border); }
|
||||
.pipeline-seg-tab--commitment { --seg-bg: var(--chip-commitment-bg); --seg-text: var(--chip-commitment-text); --seg-border: var(--chip-commitment-border); }
|
||||
.pipeline-seg-tab.active { background: var(--seg-bg); border-color: var(--seg-border); color: var(--seg-text); }
|
||||
.pipeline-seg-tab.active .pipeline-seg-count { background: var(--seg-border); color: var(--seg-text); }
|
||||
|
||||
.pipeline-swipe {
|
||||
display: flex; overflow-x: auto; scroll-snap-type: x mandatory;
|
||||
-webkit-overflow-scrolling: touch; scroll-behavior: smooth;
|
||||
@@ -2603,34 +2612,52 @@
|
||||
}
|
||||
.pipeline-swipe::-webkit-scrollbar { display: none; }
|
||||
.pipeline-stage-page { flex: 0 0 100%; width: 100%; box-sizing: border-box; scroll-snap-align: start; padding: 0 1px; }
|
||||
.pipeline-page-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
|
||||
.pipeline-page-title { font-size: 15px; font-weight: 600; color: var(--text-primary); }
|
||||
.pipeline-page-total { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-muted); flex: none; }
|
||||
/* Stage-column header (P6): stage chip + investor count + committed sum */
|
||||
.pipeline-page-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 4px 2px 12px; }
|
||||
.pipeline-page-head-left { display: flex; align-items: center; gap: 9px; min-width: 0; }
|
||||
.pipeline-page-count { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-subtle); white-space: nowrap; }
|
||||
.pipeline-page-sum { font-family: 'IBM Plex Mono', monospace; font-size: 13px; font-weight: 600; color: var(--money); flex: none; }
|
||||
.pipeline-page-sum.zero { color: var(--text-subtle); }
|
||||
/* Card (P2): earmark corner + name + Priority pill / amount · dot · recency / labeled move footer (dc PipelineApp:97-116) */
|
||||
.pipeline-card {
|
||||
display: flex; align-items: stretch; overflow: hidden;
|
||||
position: relative; overflow: hidden;
|
||||
background: var(--bg-panel); border: 1px solid var(--border);
|
||||
border-radius: var(--mobile-card-radius); margin-bottom: var(--mobile-card-gap);
|
||||
box-shadow: 0 14px 26px rgba(2,12,24,0.28), inset 0 1px 0 #ffffff07;
|
||||
}
|
||||
.pipeline-card-tap {
|
||||
flex: 1; min-width: 0; text-align: left; background: transparent; border: none;
|
||||
color: inherit; font-family: inherit; cursor: pointer; padding: 12px 14px;
|
||||
width: 100%; text-align: left; background: transparent; border: none; color: inherit;
|
||||
font-family: inherit; cursor: pointer; display: flex; flex-direction: column; gap: 9px;
|
||||
padding: 13px 14px 11px;
|
||||
}
|
||||
.pipeline-card-tap:active { background: var(--bg-hover); }
|
||||
.pipeline-card-name { font-size: var(--mobile-font-card-title); font-weight: 600; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pipeline-card-sub { font-size: 13px; color: var(--text-muted); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pipeline-card-amount { font-family: 'IBM Plex Mono', monospace; font-size: 13px; font-weight: 600; color: var(--money); margin-top: 8px; }
|
||||
.pipeline-card-amount.zero { color: var(--text-subtle); }
|
||||
.pipeline-card-move { flex: none; display: flex; align-items: stretch; }
|
||||
.stage-move-btn {
|
||||
width: 42px; background: transparent; border: none; border-left: 1px solid var(--border);
|
||||
color: var(--accent); font-size: 22px; line-height: 1; font-family: inherit; cursor: pointer;
|
||||
.pipeline-card-row1 { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
|
||||
.pipeline-card-name {
|
||||
flex: 1; min-width: 0; font-size: var(--mobile-font-card-title); font-weight: 600;
|
||||
color: var(--text-primary); line-height: 1.25; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.pipeline-card-row2 { display: flex; align-items: center; gap: 10px; }
|
||||
.pipeline-card-amount { font-family: 'IBM Plex Mono', monospace; font-size: var(--mobile-font-body); font-weight: 600; color: var(--money); flex: none; }
|
||||
.pipeline-card-amount.zero { color: var(--text-subtle); }
|
||||
.pipeline-card-dot { flex: none; width: 3px; height: 3px; border-radius: 999px; background: var(--border-strong); }
|
||||
.pipeline-card-recency { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-subtle); white-space: nowrap; }
|
||||
.pipeline-card-recency.recency-aging { color: var(--recency-aging); }
|
||||
.pipeline-card-recency.recency-stale { color: var(--recency-stale); }
|
||||
.pipeline-card-move { display: flex; border-top: 1px solid var(--divider); }
|
||||
.stage-move-btn {
|
||||
flex: 1; height: 40px; min-width: 0; background: transparent; border: none;
|
||||
color: var(--accent-light); font-family: 'IBM Plex Mono', monospace; font-size: 11px;
|
||||
letter-spacing: 0.04em; text-transform: uppercase; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center; gap: 5px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.stage-move-btn.back { border-right: 1px solid var(--divider); color: var(--text-muted); }
|
||||
.stage-move-btn:disabled { color: var(--text-subtle); opacity: 0.4; cursor: default; }
|
||||
.stage-move-btn:active:not(:disabled) { background: var(--bg-hover); }
|
||||
.pipeline-dots { display: flex; justify-content: center; gap: 7px; padding: 14px 0 2px; }
|
||||
.pipeline-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--border-strong); transition: background 0.15s ease; }
|
||||
.pipeline-dot.active { background: var(--accent); }
|
||||
.pipeline-dots { display: flex; justify-content: center; align-items: center; gap: 9px; padding: 8px 0 4px; }
|
||||
.pipeline-dot-btn { background: none; border: none; cursor: pointer; padding: 7px 3px; display: flex; align-items: center; justify-content: center; }
|
||||
.pipeline-dot { width: 6px; height: 6px; border-radius: 999px; background: var(--border-strong); transition: width 0.15s ease, background 0.15s ease; }
|
||||
.pipeline-dot.active { width: 22px; background: var(--accent); }
|
||||
|
||||
/* ─── Reminders mobile surface (8e: urgency-bucketed list + due-chips + swipe actions) ───
|
||||
JS-gated to MobileReminders. Header = title + summary line + gradient add (dc :56-62);
|
||||
@@ -6369,14 +6396,14 @@
|
||||
stages.forEach((s) => { out[s] = []; });
|
||||
opportunities.forEach((o) => { if (out[o.stage]) out[o.stage].push(o); });
|
||||
// Sort within each stage by the active key (dc PipelineApp sortCards). Name is the
|
||||
// tiebreak. "staleness" uses updated_at (oldest activity first) as the recency proxy
|
||||
// until the Pipeline card wires true last-contact recency (8f); missing date = stalest.
|
||||
// tiebreak. "staleness" sorts by last_contact_date (oldest contact first) — the same
|
||||
// server-injected recency the card shows; a missing date sorts as the stalest.
|
||||
const byName = (a, b) => String(a.name || '').localeCompare(String(b.name || ''), undefined, { sensitivity: 'base' });
|
||||
const updatedTs = (o) => { const t = o.updated_at ? new Date(o.updated_at).getTime() : NaN; return isNaN(t) ? -Infinity : t; };
|
||||
const lastTs = (o) => { const t = o.last_contact_date ? new Date(o.last_contact_date).getTime() : NaN; return isNaN(t) ? -Infinity : t; };
|
||||
const cmp = {
|
||||
name: byName,
|
||||
amount: (a, b) => ((Number(b.expected_amount) || 0) - (Number(a.expected_amount) || 0)) || byName(a, b),
|
||||
staleness: (a, b) => (updatedTs(a) - updatedTs(b)) || byName(a, b), // older ts first = most stale first
|
||||
staleness: (a, b) => (lastTs(a) - lastTs(b)) || byName(a, b), // oldest contact first = most stale first
|
||||
priority: (a, b) => ((b.priority === 'high' ? 1 : 0) - (a.priority === 'high' ? 1 : 0)) || byName(a, b), // opp.priority: 'high'|'medium'|'low'
|
||||
};
|
||||
stages.forEach((s) => { out[s].sort(cmp[sortKey] || byName); });
|
||||
@@ -6465,17 +6492,27 @@
|
||||
const renderCard = (opp) => {
|
||||
const idx = stages.indexOf(opp.stage);
|
||||
const amount = Number(opp.expected_amount) || 0;
|
||||
const sub = [contactName(opp), opp.organization_name].filter((x) => x && x !== '-').join(' · ');
|
||||
const days = daysSince(opp.last_contact_date);
|
||||
const recCls = recencyClassForDays(days);
|
||||
const prevLabel = idx > 0 ? pipelineStageLabel(stages[idx - 1]) : 'Start';
|
||||
const nextLabel = idx < stages.length - 1 ? pipelineStageLabel(stages[idx + 1]) : 'End';
|
||||
return (
|
||||
<div className="pipeline-card" key={opp.id}>
|
||||
{opp.existing_investor && <EarmarkCorner />}
|
||||
<button className="pipeline-card-tap" onClick={() => openDetail(opp.id)}>
|
||||
<div className="pipeline-card-name">{opp.name}</div>
|
||||
{sub && <div className="pipeline-card-sub">{sub}</div>}
|
||||
<div className={`pipeline-card-amount${amount > 0 ? '' : ' zero'}`}>{formatCurrencyLong(amount)}</div>
|
||||
<div className="pipeline-card-row1">
|
||||
<span className="pipeline-card-name">{opp.name}</span>
|
||||
{opp.priority === 'high' && <span className="priority-pill">Priority</span>}
|
||||
</div>
|
||||
<div className="pipeline-card-row2">
|
||||
<span className={`pipeline-card-amount${amount > 0 ? '' : ' zero'}`}>{formatMoneyMobile(amount)}</span>
|
||||
<span className="pipeline-card-dot" />
|
||||
<span className={`pipeline-card-recency ${recCls}`}>{days == null ? 'no activity' : formatAgeShort(days) + (days <= 0 ? '' : ' ago')}</span>
|
||||
</div>
|
||||
</button>
|
||||
<div className="pipeline-card-move">
|
||||
<button className="stage-move-btn" aria-label="Move back a stage" disabled={busy || idx <= 0} onClick={() => moveStage(opp, -1)}>‹</button>
|
||||
<button className="stage-move-btn" aria-label="Move forward a stage" disabled={busy || idx >= stages.length - 1} onClick={() => moveStage(opp, 1)}>›</button>
|
||||
<button className="stage-move-btn back" aria-label={`Move back to ${prevLabel}`} disabled={busy || idx <= 0} onClick={() => moveStage(opp, -1)}>‹ {prevLabel}</button>
|
||||
<button className="stage-move-btn" aria-label={`Move forward to ${nextLabel}`} disabled={busy || idx >= stages.length - 1} onClick={() => moveStage(opp, 1)}>{nextLabel} ›</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -6501,11 +6538,11 @@
|
||||
key={s}
|
||||
role="tab"
|
||||
aria-selected={i === activeStage}
|
||||
className={`pipeline-seg-tab ${i === activeStage ? 'active' : ''}`}
|
||||
className={`pipeline-seg-tab pipeline-seg-tab--${s} ${i === activeStage ? 'active' : ''}`}
|
||||
onClick={() => goToStage(i)}
|
||||
>
|
||||
<span className="pipeline-seg-count">{byStage[s].length}</span>
|
||||
<span className="pipeline-seg-label">{pipelineStageLabel(s)}</span>
|
||||
<span className="pipeline-seg-count">{byStage[s].length}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -6514,8 +6551,11 @@
|
||||
{stages.map((s, i) => (
|
||||
<section className="pipeline-stage-page" key={s} aria-label={pipelineStageLabel(s)}>
|
||||
<div className="pipeline-page-head">
|
||||
<span className="pipeline-page-title">{pipelineStageLabel(s)}</span>
|
||||
<span className="pipeline-page-total">{byStage[s].length} {byStage[s].length === 1 ? 'deal' : 'deals'} · {formatCurrencyLong(stageTotals[i])}</span>
|
||||
<span className="pipeline-page-head-left">
|
||||
<StageChip stage={s} />
|
||||
<span className="pipeline-page-count">{byStage[s].length} {byStage[s].length === 1 ? 'investor' : 'investors'}</span>
|
||||
</span>
|
||||
<span className={`pipeline-page-sum${stageTotals[i] > 0 ? '' : ' zero'}`}>{formatMoneyMobile(stageTotals[i])}</span>
|
||||
</div>
|
||||
{byStage[s].length === 0
|
||||
? <div className="empty-state" style={{ padding: '24px 0' }}>No deals in this stage</div>
|
||||
@@ -6524,8 +6564,12 @@
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pipeline-dots" aria-hidden="true">
|
||||
{stages.map((s, i) => <span key={s} className={`pipeline-dot ${i === activeStage ? 'active' : ''}`} />)}
|
||||
<div className="pipeline-dots">
|
||||
{stages.map((s, i) => (
|
||||
<button key={s} className="pipeline-dot-btn" aria-label={`Go to ${pipelineStageLabel(s)}`} onClick={() => goToStage(i)}>
|
||||
<span className={`pipeline-dot ${i === activeStage ? 'active' : ''}`} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user