Mobile Phases 4–5: Pipeline (swipe-between-stages) + Reminders
Completes the four mobile-first surfaces. Both phases follow the established
useIsMobile() wrapper → Mobile*/Desktop* pattern; desktop is untouched (only
renamed Desktop*). No backend change.
P4 Pipeline (MobilePipeline): CSS scroll-snap swipe between the four stages +
count-forward segmented control + page dots; per-card ‹/› stage move and
tap → full-screen opp detail with a stage-picker sheet. Opp-centric — shares
PATCH /api/opportunities/{id}/stage with the desktop board and the Grid's
stage edit; view + advance-stage only (removal stays on the Grid/desktop board).
P5 Reminders (MobileReminders): urgency-grouped list over /api/reminders
(Overdue/Due soon/Later/Done/Cancelled) with an Active/Done/All filter; each
row is a pointer-drag swipe (left = mark done, right = snooze +7d, keeping
status open and pushing due_date, per the desktop rationale); tap → create/edit
BottomSheet (investor is create-only, matching the backend PATCH field set).
formatDueShort/reminderDueDelta fix the desktop formatter mis-rendering future
due dates.
Verified: render-smoke + throwaway jsdom 375px interaction harnesses (12/12
each); reviewer passes applied — notably P5 pointercancel no longer fires a
spurious mark-done. Deploy-pending (no s9pk built); not yet tested on a phone.
This commit is contained in:
+615
-2
@@ -2225,6 +2225,93 @@
|
||||
.dedup-box-title { font-size: 12px; color: var(--due-soon, #e0b341); margin-bottom: 4px; }
|
||||
.dedup-match { font-size: 13px; color: var(--text-secondary); padding: 3px 0; }
|
||||
|
||||
/* ─── 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; }
|
||||
.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);
|
||||
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-swipe {
|
||||
display: flex; overflow-x: auto; scroll-snap-type: x mandatory;
|
||||
-webkit-overflow-scrolling: touch; scroll-behavior: smooth;
|
||||
scrollbar-width: none; margin-top: 14px;
|
||||
}
|
||||
.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; }
|
||||
.pipeline-card {
|
||||
display: flex; align-items: stretch; 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;
|
||||
}
|
||||
.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: #6ee7b7; 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;
|
||||
}
|
||||
.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); }
|
||||
|
||||
/* ─── Phase 5 — Reminders mobile surface (urgency-grouped list + swipe actions) ───────
|
||||
JS-gated to MobileReminders; reuses the .mobile-toolbar / .mobile-seg / .grid-new-btn /
|
||||
BottomSheet patterns. Each row is a pointer-drag swipe: left reveals Done, right reveals
|
||||
Snooze (threshold 70px); a tap (no drag) opens the edit sheet (BRIEF §3d). */
|
||||
.reminder-group-header {
|
||||
display: flex; align-items: baseline; gap: 8px; margin: 16px 2px 8px;
|
||||
font-family: 'IBM Plex Mono', monospace; font-size: 11px; font-weight: 600;
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
}
|
||||
.reminder-group-header:first-child { margin-top: 4px; }
|
||||
.reminder-group-count { color: var(--text-subtle); font-weight: 500; }
|
||||
.reminder-row { position: relative; overflow: hidden; border-radius: var(--mobile-card-radius); margin-bottom: var(--mobile-card-gap); }
|
||||
.reminder-action {
|
||||
position: absolute; inset: 0; display: flex; align-items: center; padding: 0 18px;
|
||||
font-size: 13px; font-weight: 600; font-family: inherit;
|
||||
}
|
||||
.reminder-action.done { justify-content: flex-end; background: rgba(16,185,129,0.16); color: #6ee7b7; }
|
||||
.reminder-action.snooze { justify-content: flex-start; background: rgba(224,179,65,0.14); color: #e0b341; }
|
||||
.reminder-fg {
|
||||
position: relative; z-index: 1; display: block; width: 100%; text-align: left; color: inherit;
|
||||
background: var(--bg-panel); border: 1px solid var(--border);
|
||||
border-radius: var(--mobile-card-radius); padding: 12px 14px; cursor: pointer;
|
||||
box-shadow: 0 14px 26px rgba(2,12,24,0.28), inset 0 1px 0 #ffffff07;
|
||||
transition: transform 0.18s ease; touch-action: pan-y;
|
||||
}
|
||||
.reminder-fg-head { display: flex; align-items: baseline; gap: 8px; }
|
||||
.reminder-title { font-size: var(--mobile-font-card-title); font-weight: 600; color: var(--text-primary); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.reminder-status-tag { flex: none; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.reminder-meta { font-size: 12px; color: var(--text-muted); margin-top: 4px; }
|
||||
.reminder-due { font-family: 'IBM Plex Mono', monospace; }
|
||||
.reminder-details { font-size: 12px; color: var(--text-secondary); margin-top: 5px; white-space: pre-wrap; line-height: 1.45; }
|
||||
|
||||
/* Visibility utilities — base = desktop; flipped under the breakpoint. */
|
||||
.mobile-only { display: none; }
|
||||
|
||||
@@ -4040,7 +4127,7 @@
|
||||
);
|
||||
};
|
||||
|
||||
const RemindersPage = ({ token, onShowToast, user }) => {
|
||||
const DesktopRemindersPage = ({ token, onShowToast, user }) => {
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
@@ -4269,6 +4356,315 @@
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Phase 5 — mobile Reminders (BRIEF §3d) ──────────────────────────────────────
|
||||
// Date-only delta vs local midnight (formatDate mis-handles FUTURE dates — it returns
|
||||
// "-3 days ago" — so due dates need their own formatter).
|
||||
const reminderDueDelta = (iso) => {
|
||||
if (!iso) return null;
|
||||
const due = new Date(String(iso).slice(0, 10) + 'T00:00:00');
|
||||
if (Number.isNaN(due.getTime())) return null;
|
||||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||||
return Math.round((due - today) / 86400000);
|
||||
};
|
||||
const formatDueShort = (iso) => {
|
||||
const delta = reminderDueDelta(iso);
|
||||
if (delta == null) return 'No due date';
|
||||
const d = new Date(String(iso).slice(0, 10) + 'T00:00:00');
|
||||
const label = d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
if (delta === 0) return 'Due today';
|
||||
if (delta < 0) return `${label} · ${-delta}d overdue`;
|
||||
if (delta <= 7) return `${label} · in ${delta}d`;
|
||||
return `Due ${label}`;
|
||||
};
|
||||
// Urgency bucket: done/cancelled collapse to 'done'; open/snoozed split by due-date delta.
|
||||
const reminderBucket = (r) => {
|
||||
if (r.status === 'cancelled') return 'cancelled';
|
||||
if (r.status === 'done') return 'done';
|
||||
const delta = reminderDueDelta(r.due_date);
|
||||
if (delta == null) return 'later';
|
||||
if (delta < 0) return 'overdue';
|
||||
if (delta <= 7) return 'soon';
|
||||
return 'later';
|
||||
};
|
||||
const REMINDER_BUCKETS = [
|
||||
{ key: 'overdue', label: 'Overdue' },
|
||||
{ key: 'soon', label: 'Due soon' },
|
||||
{ key: 'later', label: 'Later' },
|
||||
{ key: 'done', label: 'Done' },
|
||||
{ key: 'cancelled', label: 'Cancelled' },
|
||||
];
|
||||
const REMINDER_STATUS_COLOR = { open: '#7fb0d3', snoozed: '#b08fd3', done: '#7fd3a3', cancelled: '#70859b' };
|
||||
|
||||
// One reminder row: a pointer-drag swipe over a tappable foreground. Encapsulates its own
|
||||
// drag state so rows don't share a translate. Tap (no drag) → onTap; swipe-left past 70px →
|
||||
// onDone; swipe-right → onSnooze. Vertical-dominant drags are released to the list scroll.
|
||||
const ReminderRow = ({ r, canSwipe, onTap, onDone, onSnooze }) => {
|
||||
const [dx, setDx] = useState(0);
|
||||
const drag = useRef(null);
|
||||
const onPointerDown = (e) => {
|
||||
drag.current = { x: e.clientX, y: e.clientY, active: false, moved: false };
|
||||
try { e.currentTarget.setPointerCapture(e.pointerId); } catch (_) {}
|
||||
};
|
||||
const onPointerMove = (e) => {
|
||||
const d = drag.current; if (!d) return;
|
||||
const ddx = e.clientX - d.x, ddy = e.clientY - d.y;
|
||||
if (!d.active) {
|
||||
if (Math.abs(ddx) < 6) return;
|
||||
if (Math.abs(ddy) > Math.abs(ddx)) { drag.current = null; setDx(0); return; } // vertical → scroll
|
||||
d.active = true;
|
||||
}
|
||||
d.moved = true;
|
||||
if (canSwipe) setDx(Math.max(-120, Math.min(120, ddx)));
|
||||
};
|
||||
const end = (e) => {
|
||||
const d = drag.current; drag.current = null; setDx(0);
|
||||
if (!d) return;
|
||||
// pointercancel (OS interrupt, multi-touch) reports clientX=0 — never treat it as a
|
||||
// gesture (a 0-x would read as a big left-swipe and spuriously mark-done). Just snap back.
|
||||
if (e && e.type === 'pointercancel') return;
|
||||
if (!d.moved) { onTap(); return; }
|
||||
// non-swipeable rows (done/cancelled) have no swipe action — recover a stray drag as a
|
||||
// tap so the edit sheet (the only way to reopen them) stays reachable.
|
||||
if (!canSwipe) { onTap(); return; }
|
||||
const ddx = e && typeof e.clientX === 'number' ? e.clientX - d.x : 0;
|
||||
if (ddx <= -70) onDone();
|
||||
else if (ddx >= 70) onSnooze();
|
||||
};
|
||||
const dueDelta = reminderDueDelta(r.due_date);
|
||||
const dueColor = (r.status === 'open' || r.status === 'snoozed')
|
||||
? (dueDelta != null && dueDelta < 0 ? '#e06c6c' : dueDelta != null && dueDelta <= 7 ? '#e0b341' : 'var(--text-subtle)')
|
||||
: 'var(--text-subtle)';
|
||||
return (
|
||||
<div className="reminder-row">
|
||||
{dx < 0 && <div className="reminder-action done">Done ✓</div>}
|
||||
{dx > 0 && <div className="reminder-action snooze">⏰ Snooze +7d</div>}
|
||||
<div
|
||||
className="reminder-fg" role="button" tabIndex={0}
|
||||
style={dx ? { transform: `translateX(${dx}px)`, transition: 'none' } : undefined}
|
||||
onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={end} onPointerCancel={end}
|
||||
>
|
||||
<div className="reminder-fg-head">
|
||||
<span className="reminder-title">{r.title}</span>
|
||||
{r.status !== 'open' && <span className="reminder-status-tag" style={{ color: REMINDER_STATUS_COLOR[r.status] || 'var(--text-subtle)' }}>{r.status}</span>}
|
||||
</div>
|
||||
<div className="reminder-meta">
|
||||
{r.investor_name ? <span>{r.investor_name} · </span> : null}
|
||||
<span className="reminder-due" style={{ color: dueColor }}>{formatDueShort(r.due_date)}</span>
|
||||
{r.assignee_name ? <span> · {r.assignee_name}</span> : null}
|
||||
</div>
|
||||
{r.details ? <div className="reminder-details">{r.details}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Mobile Reminders: an urgency-grouped tickler over /api/reminders. Reuses the desktop
|
||||
// endpoints + snooze semantics (snooze = keep status open, push due_date +7d, since the
|
||||
// 'snoozed' status has no wake mechanism — see DesktopRemindersPage). Investor linkage is
|
||||
// grid-only (the create field is a free-text label; PATCH can't change it), matching desktop.
|
||||
const MobileReminders = ({ token, user, onShowToast }) => {
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('active'); // 'active' | 'done' | 'all'
|
||||
const [users, setUsers] = useState([]);
|
||||
const [editing, setEditing] = useState(null); // null | reminder | { create:true }
|
||||
const [form, setForm] = useState({ title: '', due_date: '', details: '', investor_name: '', assignee_id: '', status: 'open' });
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = useCallback(async (silent) => {
|
||||
if (!silent) setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (statusFilter === 'active') params.set('status', 'active');
|
||||
else if (statusFilter === 'done') params.set('status', 'done');
|
||||
const res = await api(`/api/reminders?${params.toString()}`, {}, token);
|
||||
setItems(Array.isArray(res?.data) ? res.data : []);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err, 'Failed to load reminders'));
|
||||
} finally {
|
||||
if (!silent) setLoading(false);
|
||||
}
|
||||
}, [token, statusFilter]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try { const r = await api('/api/users', {}, token); if (!cancelled) setUsers(Array.isArray(r?.data) ? r.data : []); }
|
||||
catch (_) { /* assignee dropdown is optional (members may lack /api/users) */ }
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [token]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const out = {};
|
||||
REMINDER_BUCKETS.forEach((b) => { out[b.key] = []; });
|
||||
items.forEach((r) => { const k = reminderBucket(r); (out[k] || out.later).push(r); });
|
||||
return out;
|
||||
}, [items]);
|
||||
|
||||
const markDone = async (r) => {
|
||||
setItems((xs) => xs.filter((x) => x.id !== r.id)); // optimistic: drop from the active list
|
||||
try {
|
||||
await api(`/api/reminders/${r.id}`, { method: 'PATCH', body: JSON.stringify({ status: 'done' }) }, token);
|
||||
onShowToast('Marked done', 'success');
|
||||
await load(true);
|
||||
} catch (err) { onShowToast(getErrorMessage(err, 'Update failed'), 'error'); await load(true); }
|
||||
};
|
||||
const snooze = async (r) => {
|
||||
const d = new Date(Date.now() + 7 * 864e5).toISOString().slice(0, 10);
|
||||
try {
|
||||
await api(`/api/reminders/${r.id}`, { method: 'PATCH', body: JSON.stringify({ status: 'open', due_date: d }) }, token);
|
||||
onShowToast('Snoozed 7 days', 'success');
|
||||
await load(true);
|
||||
} catch (err) { onShowToast(getErrorMessage(err, 'Update failed'), 'error'); await load(true); }
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setForm({ title: '', due_date: '', details: '', investor_name: '', assignee_id: '', status: 'open' });
|
||||
setEditing({ create: true });
|
||||
};
|
||||
const openEdit = (r) => {
|
||||
setForm({
|
||||
title: r.title || '', due_date: (r.due_date || '').slice(0, 10), details: r.details || '',
|
||||
investor_name: r.investor_name || '', assignee_id: r.assignee_id || '', status: r.status || 'open',
|
||||
});
|
||||
setEditing(r);
|
||||
};
|
||||
const closeSheet = () => setEditing(null);
|
||||
|
||||
const submit = async () => {
|
||||
const title = (form.title || '').trim();
|
||||
if (!title) { onShowToast('A reminder needs a title', 'error'); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
if (editing && editing.create) {
|
||||
await api('/api/reminders', { method: 'POST', body: JSON.stringify({
|
||||
title, due_date: form.due_date || '', details: form.details || '',
|
||||
investor_name: form.investor_name || '', assignee_id: form.assignee_id || '',
|
||||
}) }, token);
|
||||
onShowToast('Reminder created', 'success');
|
||||
} else {
|
||||
await api(`/api/reminders/${editing.id}`, { method: 'PATCH', body: JSON.stringify({
|
||||
title, due_date: form.due_date || '', details: form.details || '',
|
||||
status: form.status, assignee_id: form.assignee_id || '',
|
||||
}) }, token);
|
||||
onShowToast('Reminder updated', 'success');
|
||||
}
|
||||
closeSheet();
|
||||
await load(true);
|
||||
} catch (err) { onShowToast(getErrorMessage(err, 'Save failed'), 'error'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
const removeReminder = async () => {
|
||||
if (!editing || editing.create) return;
|
||||
if (!window.confirm('Delete this reminder?')) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api(`/api/reminders/${editing.id}`, { method: 'DELETE' }, token);
|
||||
onShowToast('Reminder deleted', 'success');
|
||||
closeSheet();
|
||||
await load(true);
|
||||
} catch (err) { onShowToast(getErrorMessage(err, 'Delete failed'), 'error'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const isCreate = !!(editing && editing.create);
|
||||
|
||||
return (
|
||||
<div className="mobile-screen">
|
||||
<div className="mobile-toolbar">
|
||||
<div className="grid-toolbar-row">
|
||||
<div className="mobile-seg" style={{ flex: 1 }}>
|
||||
{[['active', 'Active'], ['done', 'Done'], ['all', 'All']].map(([k, label]) => (
|
||||
<button key={k} className={`mobile-seg-tab ${statusFilter === k ? 'active' : ''}`} onClick={() => setStatusFilter(k)}>{label}</button>
|
||||
))}
|
||||
</div>
|
||||
<button className="grid-new-btn" onClick={openCreate}>+ New</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<SkeletonBlock lines={7} />
|
||||
) : error ? (
|
||||
<div className="empty-state">{error}</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="empty-state">{statusFilter === 'done' ? 'No completed reminders' : 'No reminders — tap “+ New” to add one.'}</div>
|
||||
) : (
|
||||
REMINDER_BUCKETS.filter((b) => groups[b.key].length).map((b) => (
|
||||
<div key={b.key}>
|
||||
<div className="reminder-group-header" style={{ color: b.key === 'overdue' ? '#e06c6c' : b.key === 'soon' ? '#e0b341' : 'var(--text-subtle)' }}>
|
||||
{b.label}<span className="reminder-group-count">{groups[b.key].length}</span>
|
||||
</div>
|
||||
{groups[b.key].map((r) => (
|
||||
<ReminderRow
|
||||
key={r.id} r={r}
|
||||
canSwipe={r.status === 'open' || r.status === 'snoozed'}
|
||||
onTap={() => openEdit(r)}
|
||||
onDone={() => markDone(r)}
|
||||
onSnooze={() => snooze(r)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
<BottomSheet open={!!editing} onClose={closeSheet} title={isCreate ? 'New reminder' : 'Edit reminder'}>
|
||||
<div className="sheet-field">
|
||||
<label className="sheet-field-label">Title</label>
|
||||
<input className="sheet-input" value={form.title} onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))} placeholder="What needs doing?" />
|
||||
</div>
|
||||
<div className="sheet-field">
|
||||
<label className="sheet-field-label">Due date</label>
|
||||
<input className="sheet-input" type="date" value={form.due_date} onChange={(e) => setForm((f) => ({ ...f, due_date: e.target.value }))} />
|
||||
</div>
|
||||
{isCreate ? (
|
||||
<div className="sheet-field">
|
||||
<label className="sheet-field-label">Investor (optional)</label>
|
||||
<input className="sheet-input" value={form.investor_name} onChange={(e) => setForm((f) => ({ ...f, investor_name: e.target.value }))} placeholder="Name label, or blank for a team task" />
|
||||
</div>
|
||||
) : (form.investor_name ? (
|
||||
<div className="fs-row"><span className="fs-row-label">Investor</span><span className="fs-row-value">{form.investor_name}</span></div>
|
||||
) : null)}
|
||||
{users.length > 0 && (
|
||||
<div className="sheet-field">
|
||||
<label className="sheet-field-label">Assignee</label>
|
||||
<select className="sheet-select" value={form.assignee_id} onChange={(e) => setForm((f) => ({ ...f, assignee_id: e.target.value }))}>
|
||||
<option value="">Unassigned (team)</option>
|
||||
{users.map((u) => <option key={u.id} value={u.id}>{u.full_name || u.username}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{!isCreate && (
|
||||
<div className="sheet-field">
|
||||
<label className="sheet-field-label">Status</label>
|
||||
<select className="sheet-select" value={form.status} onChange={(e) => setForm((f) => ({ ...f, status: e.target.value }))}>
|
||||
<option value="open">Open</option>
|
||||
<option value="snoozed">Snoozed</option>
|
||||
<option value="done">Done</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="sheet-field">
|
||||
<label className="sheet-field-label">Details (optional)</label>
|
||||
<textarea className="sheet-textarea" value={form.details} onChange={(e) => setForm((f) => ({ ...f, details: e.target.value }))} />
|
||||
</div>
|
||||
<button className="sheet-submit" onClick={submit} disabled={busy}>{busy ? 'Saving…' : (isCreate ? 'Create reminder' : 'Save')}</button>
|
||||
{!isCreate && <button className="sheet-remove" onClick={removeReminder} disabled={busy}>Delete reminder</button>}
|
||||
</BottomSheet>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Switch by viewport (rules-of-hooks-safe — only useIsMobile() runs here).
|
||||
const RemindersPage = (props) => {
|
||||
const isMobile = useIsMobile();
|
||||
return isMobile ? <MobileReminders {...props} /> : <DesktopRemindersPage {...props} />;
|
||||
};
|
||||
|
||||
// Desktop Contacts surface (table + slide-over). Unchanged; rendered on >768px via the
|
||||
// ContactsPage switch below. Mobile (<768px) renders MobileContactsPage instead.
|
||||
const DesktopContactsPage = ({ token, onShowToast }) => {
|
||||
@@ -5004,7 +5400,7 @@
|
||||
);
|
||||
};
|
||||
|
||||
const PipelinePage = ({ token, onShowToast }) => {
|
||||
const DesktopPipelinePage = ({ token, onShowToast }) => {
|
||||
const [opportunities, setOpportunities] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedOpp, setSelectedOpp] = useState(null);
|
||||
@@ -5266,6 +5662,223 @@
|
||||
);
|
||||
};
|
||||
|
||||
// Phase 4 — mobile Pipeline (BRIEF §3c): swipe between full-width stage columns
|
||||
// (CSS scroll-snap) with a count-forward segmented control + page dots, per-card ‹/›
|
||||
// stage move, and tap → opp detail with a stage-picker sheet. Operates on the same
|
||||
// `opportunities` rows + PATCH /api/opportunities/{id}/stage as the desktop board and the
|
||||
// grid detail's stage edit — opp-centric (matches DesktopPipelinePage), read-only amounts.
|
||||
// Removal/deletion stays on the desktop board + the Grid detail's "remove from pipeline";
|
||||
// the Pipeline tab is view + advance-stage only.
|
||||
const MobilePipeline = ({ token, onShowToast }) => {
|
||||
const [opportunities, setOpportunities] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [activeStage, setActiveStage] = useState(0);
|
||||
const [selectedId, setSelectedId] = useState(null);
|
||||
const [sheetOpen, setSheetOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const swipeRef = useRef(null);
|
||||
|
||||
const stages = PIPELINE_STAGES;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await api('/api/opportunities?limit=1000', {}, token);
|
||||
if (!cancelled) { setOpportunities(result.data || []); setError(''); }
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(getErrorMessage(err, 'Failed to load pipeline'));
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [token]);
|
||||
|
||||
const byStage = useMemo(() => {
|
||||
const out = {};
|
||||
stages.forEach((s) => { out[s] = []; });
|
||||
opportunities.forEach((o) => { if (out[o.stage]) out[o.stage].push(o); });
|
||||
return out;
|
||||
}, [opportunities]);
|
||||
|
||||
const stageTotals = useMemo(() => stages.map((s) =>
|
||||
byStage[s].reduce((sum, o) => sum + (Number(o.expected_amount) || 0), 0)), [byStage]);
|
||||
|
||||
const selectedOpp = useMemo(() => opportunities.find((o) => o.id === selectedId) || null, [opportunities, selectedId]);
|
||||
|
||||
const patchStage = async (oppId, stage) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await api(`/api/opportunities/${oppId}/stage`, { method: 'PATCH', body: JSON.stringify({ stage }) }, token);
|
||||
setOpportunities((os) => os.map((o) => (o.id === oppId ? { ...o, stage } : o)));
|
||||
onShowToast('Stage updated', 'success');
|
||||
return true;
|
||||
} catch (err) {
|
||||
onShowToast(getErrorMessage(err, 'Failed to update stage'), 'error');
|
||||
return false;
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
// ‹/› on a card: advance/retreat one stage (kanban move without opening the detail).
|
||||
const moveStage = async (opp, dir) => {
|
||||
if (busy) return;
|
||||
const next = stages.indexOf(opp.stage) + dir;
|
||||
if (next < 0 || next >= stages.length) return;
|
||||
await patchStage(opp.id, stages[next]);
|
||||
};
|
||||
|
||||
const goToStage = (i) => {
|
||||
setActiveStage(i);
|
||||
const el = swipeRef.current;
|
||||
if (el && el.clientWidth) {
|
||||
try { el.scrollTo({ left: i * el.clientWidth, behavior: 'smooth' }); }
|
||||
catch (_) { el.scrollLeft = i * el.clientWidth; }
|
||||
}
|
||||
};
|
||||
const onSwipeScroll = () => {
|
||||
const el = swipeRef.current;
|
||||
if (!el || !el.clientWidth) return;
|
||||
const i = Math.round(el.scrollLeft / el.clientWidth);
|
||||
if (i !== activeStage && i >= 0 && i < stages.length) setActiveStage(i);
|
||||
};
|
||||
|
||||
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(' · ');
|
||||
return (
|
||||
<div className="pipeline-card" key={opp.id}>
|
||||
<button className="pipeline-card-tap" onClick={() => setSelectedId(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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mobile-screen">
|
||||
{loading ? (
|
||||
<SkeletonBlock lines={8} />
|
||||
) : error ? (
|
||||
<div className="empty-state">{error}</div>
|
||||
) : opportunities.length === 0 ? (
|
||||
<div className="empty-state">No deals in the pipeline yet. Add them from the Fundraising Grid — open an investor and tap “Add to pipeline.”</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="pipeline-seg" role="tablist" aria-label="Pipeline stages">
|
||||
{stages.map((s, i) => (
|
||||
<button
|
||||
key={s}
|
||||
role="tab"
|
||||
aria-selected={i === activeStage}
|
||||
className={`pipeline-seg-tab ${i === activeStage ? 'active' : ''}`}
|
||||
onClick={() => goToStage(i)}
|
||||
>
|
||||
<span className="pipeline-seg-count">{byStage[s].length}</span>
|
||||
<span className="pipeline-seg-label">{pipelineStageLabel(s)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pipeline-swipe" ref={swipeRef} onScroll={onSwipeScroll}>
|
||||
{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>
|
||||
</div>
|
||||
{byStage[s].length === 0
|
||||
? <div className="empty-state" style={{ padding: '24px 0' }}>No deals in this stage</div>
|
||||
: byStage[s].map(renderCard)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pipeline-dots" aria-hidden="true">
|
||||
{stages.map((s, i) => <span key={s} className={`pipeline-dot ${i === activeStage ? 'active' : ''}`} />)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedOpp && (() => {
|
||||
const opp = selectedOpp;
|
||||
const prob = (Number(opp.probability) || 0) > 1
|
||||
? `${opp.probability}%`
|
||||
: `${Math.round((Number(opp.probability) || 0) * 100)}%`;
|
||||
return (
|
||||
<div className="fs-detail" role="dialog" aria-modal="true">
|
||||
<div className="fs-detail-header">
|
||||
<button className="fs-detail-back" onClick={() => { setSelectedId(null); setSheetOpen(false); }}>‹ Pipeline</button>
|
||||
</div>
|
||||
<div className="fs-detail-body">
|
||||
<div className="fs-detail-id">
|
||||
<span style={{ minWidth: 0, flex: 1 }}>
|
||||
<div className="fs-detail-title">{opp.name}</div>
|
||||
<div className="fs-detail-subtitle">{formatCurrencyLong(opp.expected_amount)} expected · {prob}</div>
|
||||
</span>
|
||||
{opp.priority === 'high' && <span className="badge" style={{ background: '#fcd34d22', color: '#fcd34d' }}>Priority</span>}
|
||||
</div>
|
||||
|
||||
<div className="fs-section">
|
||||
<div className="fs-section-label">Pipeline</div>
|
||||
<div className="fs-row">
|
||||
<span className="fs-row-label">Stage</span>
|
||||
<span className="fs-row-value"><StageChip stage={opp.stage} /></span>
|
||||
</div>
|
||||
<div className="fs-action-row" style={{ marginTop: '10px' }}>
|
||||
<button className="fs-action-btn" onClick={() => setSheetOpen(true)}>Change stage</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fs-section">
|
||||
<div className="fs-section-label">Deal</div>
|
||||
<MobileDetailRow label="Contact" value={contactName(opp) === '-' ? '' : contactName(opp)} />
|
||||
<MobileDetailRow label="Organization" value={opp.organization_name} />
|
||||
<MobileDetailRow label="Expected amount" value={formatCurrencyLong(opp.expected_amount)} mono />
|
||||
<MobileDetailRow label="Probability" value={prob} mono />
|
||||
<MobileDetailRow label="Fund" value={opp.fund_name} />
|
||||
<MobileDetailRow label="Expected close" value={formatDateLong(opp.expected_close_date)} mono />
|
||||
<MobileDetailRow label="Owner" value={opp.owner_name} />
|
||||
<div style={{ fontSize: '12px', color: 'var(--text-subtle)', marginTop: '8px' }}>Amounts are read-only on mobile — edit on desktop.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BottomSheet open={sheetOpen} onClose={() => setSheetOpen(false)} title="Pipeline stage">
|
||||
{stages.map((st) => (
|
||||
<button
|
||||
key={st}
|
||||
className={`sheet-option ${opp.stage === st ? 'active' : ''}`}
|
||||
disabled={busy}
|
||||
onClick={async () => { if (await patchStage(opp.id, st)) setSheetOpen(false); }}
|
||||
>
|
||||
<span>{pipelineStageLabel(st)}</span>
|
||||
{opp.stage === st && <span className="sheet-option-check">✓</span>}
|
||||
</button>
|
||||
))}
|
||||
</BottomSheet>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Switch by viewport (rules-of-hooks-safe — only useIsMobile() runs here; the surfaces
|
||||
// mount/unmount on a breakpoint cross, each owning its own hooks).
|
||||
const PipelinePage = (props) => {
|
||||
const isMobile = useIsMobile();
|
||||
return isMobile ? <MobilePipeline {...props} /> : <DesktopPipelinePage {...props} />;
|
||||
};
|
||||
|
||||
const CommunicationsPage = ({ token, user, onShowToast }) => {
|
||||
// Repurposed (v0.1.0:80): the Communications tab is now the admin-only
|
||||
// email-activity panel over the captured email_* tables. The classic
|
||||
|
||||
Reference in New Issue
Block a user