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:
@@ -107,11 +107,12 @@ Subsystem rules live in `docs/guides/` and lazy-load in Claude Code via `.claude
|
|||||||
|
|
||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
_**Box live at v0.1.0:94**; `main` ahead by mobile Phases 0–2 + P3a + drag-reorder views — **all committed, deploy-pending** (no s9pk built yet). **The fundraising grid + email capture is the canonical system of record.** Active thread: **mobile-first redesign** — P0–P3a done, **P4 Pipeline next**. Per-phase detail + backlog: `ROADMAP.md` / `EVALUATION.md`; history: git log + `start9/0.4/startos/versions/`._
|
_**Box live at v0.1.0:94**; `main` ahead by mobile Phases 0–2 + P3a + P4 + P5 + drag-reorder views — **P4+P5 uncommitted in the working tree, rest committed, all deploy-pending** (no s9pk built yet). **The fundraising grid + email capture is the canonical system of record.** Active thread: **mobile-first redesign** — **all 4 mobile surfaces done (P0–P5)**, **P6 light theme next** (then P3b name/pill edit + deploy). Per-phase detail + backlog: `ROADMAP.md` / `EVALUATION.md`; history: git log + `start9/0.4/startos/versions/`._
|
||||||
|
|
||||||
- **Mobile redesign — 3 of 4 surfaces built (Grid + Contacts done; Pipeline + Reminders to go).** Foundation shipped: bottom-tab bar + `:root` mobile vars (P1 `634fc42`); 4-stage enum + read-only derived grid signals `existing_investor`/`last_activity_at`/`staleness`/`opportunity_id` injected on GET, **stripped on write at both points** (P0 `e46dd36`, P3a `e34a6fc`). React primitives `<BottomSheet>`/`useIsMobile()`/`StageChip`; each surface is a rules-of-hooks-safe `useIsMobile()` wrapper → `Mobile*`/`Desktop*` pair (**desktop untouched**), re-authored against the real API (comps aren't drop-in). **Mobile writes use one-row endpoints only — never whole-grid PUT** (BRIEF §3a).
|
- **Mobile redesign — all 4 core surfaces built (Grid + Contacts + Pipeline + Reminders).** Foundation shipped: bottom-tab bar + `:root` mobile vars (P1 `634fc42`); 4-stage enum + read-only derived grid signals `existing_investor`/`last_activity_at`/`staleness`/`opportunity_id` injected on GET, **stripped on write at both points** (P0 `e46dd36`, P3a `e34a6fc`). React primitives `<BottomSheet>`/`useIsMobile()`/`StageChip`; each surface is a rules-of-hooks-safe `useIsMobile()` wrapper → `Mobile*`/`Desktop*` pair (**desktop untouched**), re-authored against the real API (comps aren't drop-in). **Mobile writes use one-row endpoints only — never whole-grid PUT** (BRIEF §3a).
|
||||||
- **Built this session:** **P2 Contacts** (`984b950`; read-only A–Z list→detail→sort-sheet) + **P3a Grid** (`e34a6fc`; card list→detail→edit sheets: log-note / stage / reminder / `+`create with dedup; name+pill edit = **P3b, deferred**, needs a narrow per-row PATCH + pill editor). Both verified by render-smoke + throwaway jsdom 375px interaction harnesses; a `reviewer` pass was applied (stage already-linked→PATCH enforce; earliest-opp determinism; search parity).
|
- **Built this session (both uncommitted; no backend change):** **P4 Pipeline** (`MobilePipeline` = swipe-between-stages via CSS scroll-snap + count-forward segmented control + dots, per-card ‹/› stage move + tap→detail w/ stage-picker sheet; opp-centric, shares `PATCH /api/opportunities/{id}/stage`; **view+advance-stage only**, removal stays on the Grid/desktop board) + **P5 Reminders** (`MobileReminders` = urgency-grouped list over `/api/reminders` w/ Active/Done/All filter; `ReminderRow` pointer-drag **swipe-left=done / swipe-right=snooze +7d**; tap → create/edit `BottomSheet`; `formatDueShort` fixes the future-date formatter). Each verified by render-smoke + a throwaway jsdom 375px harness (12/12 each); `reviewer` passes applied (P4: reset stage-sheet open-state on back, `moveStage` awaits; **P5: `pointercancel` no longer fires a spurious mark-done**, cancelled gets its own bucket).
|
||||||
|
- **Prior sessions:** **P2 Contacts** (`984b950`; read-only A–Z list→detail→sort-sheet) + **P3a Grid** (`e34a6fc`; card list→detail→edit sheets: log-note / stage / reminder / `+`create with dedup; name+pill edit = **P3b, deferred**, needs a narrow per-row PATCH + pill editor). Both verified by render-smoke + throwaway jsdom 375px interaction harnesses; a `reviewer` pass was applied (stage already-linked→PATCH enforce; earliest-opp determinism; search parity).
|
||||||
- **Live (deployed):** W2 NL query (v94; remaining: in-room smoke + web "Ask" box); W1 reminders (v93); grid Pipeline (v88); Matrix intake + Gmail capture (DWD) + daily digest; Thesis/Architect (dual-approval); outreach — all draft-only.
|
- **Live (deployed):** W2 NL query (v94; remaining: in-room smoke + web "Ask" box); W1 reminders (v93); grid Pipeline (v88); Matrix intake + Gmail capture (DWD) + daily digest; Thesis/Architect (dual-approval); outreach — all draft-only.
|
||||||
- **Tests:** **36/36 backend green** (`python3 backend/run_tests.py`), `py_compile` clean, render-smoke green, fresh-DB migrate clean.
|
- **Tests:** **36/36 backend green** (`python3 backend/run_tests.py`), `py_compile` clean, render-smoke green, fresh-DB migrate clean.
|
||||||
- **Next:** 1) **P4 Pipeline** (swipe-between-stages; reuses the opportunities endpoints + `<BottomSheet>`/`StageChip`); 2) **P5 Reminders → P6 light theme**; 3) **P3b** name/pill edit; 4) **deploy** P0–P3a + view-reorder in one s9pk (**authorize first**); 5) W2 web Ask box + smoke; 6) W3 bot grid-mutations; 7) W1b nurture-gap.
|
- **Next:** 1) **P6 light theme** (inline-hex→`var()` axis, 183 literals + ship `color.light` behind a `[data-theme]` toggle; dark stays default); 2) **P3b** name/pill edit (narrow per-row PATCH + pill editor); 3) **deploy** P0–P5 + view-reorder in one s9pk (**authorize first**) — verify on a real phone; 4) W2 web Ask box + smoke; 5) W3 bot grid-mutations; 6) W1b nurture-gap.
|
||||||
- **Open / risks:** all mobile work **built but never deployed or tested on a real phone** (render-smoke + jsdom-at-375px only — verify on a device); **P3b deferred**; W2 happy-path only; **Claude/Architect path unverified live on the box**; v2.0 reserve-asset spine **not canonical** (needs dual sign-off); doc drift — `crm-overview.md`/`EVALUATION.md` still call `lp_profiles` live.
|
- **Open / risks:** all mobile work **built but never deployed or tested on a real phone** (render-smoke + jsdom-at-375px only — verify on a device); **P3b deferred**; W2 happy-path only; **Claude/Architect path unverified live on the box**; v2.0 reserve-asset spine **not canonical** (needs dual sign-off); doc drift — `crm-overview.md`/`EVALUATION.md` still call `lp_profiles` live.
|
||||||
|
|||||||
+35
-4
@@ -377,10 +377,41 @@ migration into each surface's build, behind one shared foundation step. No upfro
|
|||||||
- **P3b (deferred):** `POST /api/fundraising/update-row` (version-safe single-row name/contacts mutation,
|
- **P3b (deferred):** `POST /api/fundraising/update-row` (version-safe single-row name/contacts mutation,
|
||||||
+test) + the bottom-sheet **pill editor** (add/edit/remove pills, client-side dedup). Then name + pills
|
+test) + the bottom-sheet **pill editor** (add/edit/remove pills, client-side dedup). Then name + pills
|
||||||
become editable on an existing investor, completing BRIEF §3a's editable set.
|
become editable on an existing investor, completing BRIEF §3a's editable set.
|
||||||
- **Phase 4 — Pipeline.** ~7 inline styles. Swipe-between-stages (snap-scroll + segmented control +
|
- **Phase 4 — Pipeline — BUILT 2026-06-19 (deploy pending).** Lean **`MobilePipeline`** (separate
|
||||||
dots), per-card stage move sharing the Grid detail's opportunities endpoints.
|
component; `PipelinePage` is now a `useIsMobile()` wrapper → `Desktop`/`Mobile`, desktop kanban
|
||||||
- **Phase 5 — Reminders.** ~18 inline styles. Urgency-grouped list, swipe complete/snooze, add/edit
|
untouched, just renamed `DesktopPipelinePage`). **Swipe-between-stages:** a count-forward segmented
|
||||||
sheets on `/api/reminders`.
|
stage control (`.pipeline-seg`) + a horizontal **CSS scroll-snap** container of four full-width stage
|
||||||
|
pages (`.pipeline-swipe`/`.pipeline-stage-page`) + page dots; tapping a segment scrolls to its page,
|
||||||
|
scrolling syncs the active segment/dots. Each card shows opp name · contact·org · expected $, with
|
||||||
|
per-card **‹/› stage move** (`PATCH /api/opportunities/{id}/stage`, disabled at the lead/commitment
|
||||||
|
boundaries) — the kanban "advance" without opening the detail. Tap a card → full-screen `.fs-detail`
|
||||||
|
(read-only `OpportunityDetailPanel`-equivalent fields via `MobileDetailRow` + a `StageChip`) with a
|
||||||
|
**stage-picker `BottomSheet`**. **Opp-centric** (operates on the same `opportunities` rows + stage
|
||||||
|
endpoint as the desktop board and the Grid detail's stage edit), amounts read-only; **no Existing-Investor
|
||||||
|
star** (opps carry `fundraising_investor_id` but not `total_invested`). Removal/deletion stays on the
|
||||||
|
desktop board + the Grid detail's "remove from pipeline" — the Pipeline tab is **view + advance-stage
|
||||||
|
only**. A `reviewer` pass was applied (reset the detail's stage-sheet open-state on back; `moveStage`
|
||||||
|
awaits the PATCH). Verified: render-smoke green + a throwaway jsdom 375px harness drove the real surface
|
||||||
|
(seg counts, stage pages, segment/dot sync, ‹/› move re-bucketing, detail + stage-sheet PATCH, back — 12/12).
|
||||||
|
No real-phone check yet (same deferral as P1–P3a). Reuses the P2/P3a primitives directly; **no backend
|
||||||
|
change.** **Deploy:** folds into the next s9pk.
|
||||||
|
- **Phase 5 — Reminders — BUILT 2026-06-19 (deploy pending).** Lean **`MobileReminders`**
|
||||||
|
(`RemindersPage` is now a `useIsMobile()` wrapper → `Desktop`/`Mobile`; the desktop page renamed
|
||||||
|
`DesktopRemindersPage`, otherwise untouched). **Urgency-grouped list** over `/api/reminders`
|
||||||
|
(Overdue → Due soon → Later → Done → Cancelled buckets via `reminderBucket`; group headers carry the
|
||||||
|
overdue-red/due-soon-amber tint) with a compact **Active/Done/All** segmented filter + **`+ New`**.
|
||||||
|
Each row is a **`ReminderRow`** pointer-drag swipe (own per-row drag state): **swipe-left → mark done**,
|
||||||
|
**swipe-right → snooze +7d** (threshold 70px; snooze keeps status `open` and pushes `due_date`, mirroring
|
||||||
|
the desktop's "no wake mechanism" rationale), a **tap → create/edit `BottomSheet`** (title · due date ·
|
||||||
|
investor *(create-only free-text label — PATCH can't change investor, matching the backend)* · assignee
|
||||||
|
*(if `/api/users` is readable)* · details · status *(edit-only)* · Delete). Vertical-dominant drags release
|
||||||
|
to list scroll; non-swipeable (done/cancelled) rows stay tap-to-edit. Added `formatDueShort`/`reminderDueDelta`
|
||||||
|
(local-midnight delta — the desktop `formatDate` mis-renders FUTURE dates). A `reviewer` pass was applied
|
||||||
|
(**`pointercancel` no longer fires a spurious mark-done** — the key fix; stray drag on a non-swipeable row
|
||||||
|
recovers as a tap; cancelled gets its own bucket header). **No backend change.** Verified: render-smoke
|
||||||
|
green + a throwaway jsdom 375px harness (grouping/counts, swipe done + snooze PATCH, tap→edit prefilled,
|
||||||
|
create POST, Done-filter reload — 12/12). No real-phone check yet (same deferral as P1–P4). **Deploy:**
|
||||||
|
folds into the next s9pk.
|
||||||
- **Phase 6 — Light theme + toggle (adopted as a planned feature, 2026-06-19).** The inline-hex→`var()`
|
- **Phase 6 — Light theme + toggle (adopted as a planned feature, 2026-06-19).** The inline-hex→`var()`
|
||||||
axis (183 literals) + ship the light palette (`tokens.tokens.json` `color.light`) behind a
|
axis (183 literals) + ship the light palette (`tokens.tokens.json` `color.light`) behind a
|
||||||
`[data-theme]` switch + a top-bar toggle; dark stays the default. Mechanical; co-lands after the
|
`[data-theme]` switch + a top-bar toggle; dark stays the default. Mechanical; co-lands after the
|
||||||
|
|||||||
+615
-2
@@ -2225,6 +2225,93 @@
|
|||||||
.dedup-box-title { font-size: 12px; color: var(--due-soon, #e0b341); margin-bottom: 4px; }
|
.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; }
|
.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. */
|
/* Visibility utilities — base = desktop; flipped under the breakpoint. */
|
||||||
.mobile-only { display: none; }
|
.mobile-only { display: none; }
|
||||||
|
|
||||||
@@ -4040,7 +4127,7 @@
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const RemindersPage = ({ token, onShowToast, user }) => {
|
const DesktopRemindersPage = ({ token, onShowToast, user }) => {
|
||||||
const [items, setItems] = useState([]);
|
const [items, setItems] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
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
|
// Desktop Contacts surface (table + slide-over). Unchanged; rendered on >768px via the
|
||||||
// ContactsPage switch below. Mobile (<768px) renders MobileContactsPage instead.
|
// ContactsPage switch below. Mobile (<768px) renders MobileContactsPage instead.
|
||||||
const DesktopContactsPage = ({ token, onShowToast }) => {
|
const DesktopContactsPage = ({ token, onShowToast }) => {
|
||||||
@@ -5004,7 +5400,7 @@
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const PipelinePage = ({ token, onShowToast }) => {
|
const DesktopPipelinePage = ({ token, onShowToast }) => {
|
||||||
const [opportunities, setOpportunities] = useState([]);
|
const [opportunities, setOpportunities] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedOpp, setSelectedOpp] = useState(null);
|
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 }) => {
|
const CommunicationsPage = ({ token, user, onShowToast }) => {
|
||||||
// Repurposed (v0.1.0:80): the Communications tab is now the admin-only
|
// Repurposed (v0.1.0:80): the Communications tab is now the admin-only
|
||||||
// email-activity panel over the captured email_* tables. The classic
|
// email-activity panel over the captured email_* tables. The classic
|
||||||
|
|||||||
Reference in New Issue
Block a user