Mobile Phase 8e: Reminders due-chips, buckets, snooze sheet + investor picker
Re-author the mobile Reminders surface to the dc anatomy: title + urgency summary line + gradient add (drop Active/Done/All tabs); four urgency buckets (Overdue/Today/This week/Later) with colored dots; urgency-colored DueChip pill (new --due-* theme vars, dark+light); collapsible Completed section (done+cancelled, tap-check reopens). Load all statuses in one call, split client-side. Swipe-right opens a snooze preset sheet (+1/+3/+7/+14d) replacing the fixed +7d. The add flow gains a stacked searchable investor picker over the canonical grid that writes source_row_id -> a real server-resolved investor_id link (replacing the free-text label that never linked; team-task = no investor). Edit stays read-only for the investor (PATCH can't reassign).
This commit is contained in:
+287
-102
@@ -93,6 +93,11 @@
|
||||
/* reminder status badges (open/snoozed/done/cancelled) — light values derived,
|
||||
since the comp models reminder URGENCY rather than these four statuses */
|
||||
--rem-open: #7fb0d3; --rem-snoozed: #b08fd3; --rem-done: #7fd3a3; --rem-cancelled: #70859b;
|
||||
/* reminder due-chips — bg / text / border, by urgency bucket (comp RemindersApp urgency, :228-240) */
|
||||
--due-overdue-bg: #f8717118; --due-overdue-text: #f87171; --due-overdue-border: #f8717140;
|
||||
--due-today-bg: #e0b3411f; --due-today-text: #e0b341; --due-today-border: #e0b3413d;
|
||||
--due-week-bg: #3b82c422; --due-week-text: #93c5fd; --due-week-border: #3b82c44d;
|
||||
--due-later-bg: #1b2a3a; --due-later-text: #8ea2b7; --due-later-border: #263548;
|
||||
/* --- Phase 7 (2026-06-19): leftover literals that didn't flip in P6 (DESIGN §8). --- */
|
||||
--nav-bg: rgba(17, 26, 39, 0.92); /* mobile bottom-tab-bar */
|
||||
--panel-grad-end: #101926; /* sidebar / header gradient bottom stop */
|
||||
@@ -159,6 +164,10 @@
|
||||
--chip-commitment-bg: #10b98118; --chip-commitment-text: #057a55; --chip-commitment-border: #a9ddca;
|
||||
--chip-default-bg: #5a6b7d12; --chip-default-text: #84909e; --chip-default-border: #d6dde7;
|
||||
--rem-open: #2266a0; --rem-snoozed: #7a4fa8; --rem-done: #057a55; --rem-cancelled: #84909e;
|
||||
--due-overdue-bg: #c0322f14; --due-overdue-text: #c0322f; --due-overdue-border: #e3b4b2;
|
||||
--due-today-bg: #e0b34122; --due-today-text: #8a6c12; --due-today-border: #e4d29a;
|
||||
--due-week-bg: #3b82c416; --due-week-text: #1f6fb8; --due-week-border: #bcd2ea;
|
||||
--due-later-bg: #5a6b7d12; --due-later-text: #5a6b7d; --due-later-border: #d6dde7;
|
||||
/* Phase 7 light overrides (accent-grad-end is theme-stable → dark only). */
|
||||
--nav-bg: #ffffffd9;
|
||||
--panel-grad-end: #f4f7fb;
|
||||
@@ -2623,16 +2632,27 @@
|
||||
.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). */
|
||||
/* ─── Reminders mobile surface (8e: urgency-bucketed list + due-chips + swipe actions) ───
|
||||
JS-gated to MobileReminders. Header = title + summary line + gradient add (dc :56-62);
|
||||
four buckets Overdue/Today/This week/Later, each with a colored dot (dc :317-318); each
|
||||
row is a pointer-drag swipe (left reveals Complete, right reveals Snooze, threshold 70px,
|
||||
tap opens the edit sheet); terminal items live in a collapsible Completed section. */
|
||||
.rem-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; margin-bottom: 14px; }
|
||||
.rem-header-titles { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||
.rem-title { font-size: var(--mobile-font-screen-title); font-weight: 600; letter-spacing: -0.01em; color: var(--text-primary); }
|
||||
.rem-summary { font-family: 'IBM Plex Mono', monospace; font-size: 12px; } /* color set inline by urgency */
|
||||
.rem-add-btn {
|
||||
flex: none; width: 44px; height: 44px; border-radius: 10px; border: none;
|
||||
background: linear-gradient(180deg, var(--accent) 0%, var(--accent-strong) 100%);
|
||||
color: #fff; font-size: 22px; font-weight: 500; line-height: 1; cursor: pointer;
|
||||
}
|
||||
.reminder-group-header {
|
||||
display: flex; align-items: baseline; gap: 8px; margin: 16px 2px 8px;
|
||||
display: flex; align-items: center; gap: 8px; margin: 16px 2px 8px; color: var(--text-subtle);
|
||||
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-dot { flex: none; width: 7px; height: 7px; border-radius: 999px; }
|
||||
.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 {
|
||||
@@ -2642,18 +2662,72 @@
|
||||
.reminder-action.done { justify-content: flex-end; background: rgba(16,185,129,0.16); color: var(--money); }
|
||||
.reminder-action.snooze { justify-content: flex-start; background: rgba(224,179,65,0.14); color: var(--due-soon); }
|
||||
.reminder-fg {
|
||||
position: relative; z-index: 1; display: block; width: 100%; text-align: left; color: inherit;
|
||||
position: relative; z-index: 1; display: flex; flex-direction: column; gap: 5px;
|
||||
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;
|
||||
border-radius: var(--mobile-card-radius); padding: 13px 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; }
|
||||
.reminder-title { font-size: var(--mobile-font-body); font-weight: 500; line-height: 1.3; color: var(--text-primary); }
|
||||
.reminder-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.reminder-org { font-size: 12px; color: var(--text-muted); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* Urgency-colored due-chip pill (dc RemindersApp:91, urgency :228-240). */
|
||||
.due-chip {
|
||||
flex: none; font-family: 'IBM Plex Mono', monospace; font-size: 10px; font-weight: 600;
|
||||
letter-spacing: 0.04em; text-transform: uppercase; padding: 2px 8px; border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.due-chip--overdue { background: var(--due-overdue-bg); color: var(--due-overdue-text); border-color: var(--due-overdue-border); }
|
||||
.due-chip--today { background: var(--due-today-bg); color: var(--due-today-text); border-color: var(--due-today-border); }
|
||||
.due-chip--week { background: var(--due-week-bg); color: var(--due-week-text); border-color: var(--due-week-border); }
|
||||
.due-chip--later { background: var(--due-later-bg); color: var(--due-later-text); border-color: var(--due-later-border); }
|
||||
.reminder-details { font-size: 12px; color: var(--text-secondary); white-space: pre-wrap; line-height: 1.45; }
|
||||
/* Collapsible Completed section at list end (dc :109-130). Done/cancelled rows, dimmed. */
|
||||
.rem-completed-toggle {
|
||||
width: 100%; background: none; border: none; cursor: pointer; margin-top: 8px;
|
||||
display: flex; align-items: center; gap: 8px; padding: 0 2px 9px; color: var(--text-subtle);
|
||||
font-family: 'IBM Plex Mono', monospace; font-size: 11px; font-weight: 600;
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
}
|
||||
.rem-completed-caret { flex: none; width: 12px; font-size: 12px; transition: transform 0.15s ease; }
|
||||
.rem-completed-caret.open { transform: rotate(90deg); }
|
||||
.rem-done-row {
|
||||
display: flex; align-items: center; gap: 12px; opacity: 0.6;
|
||||
background: var(--bg-panel); border: 1px solid var(--border);
|
||||
border-radius: var(--mobile-card-radius); padding: 12px 13px; margin-bottom: var(--mobile-card-gap);
|
||||
}
|
||||
.rem-done-check {
|
||||
flex: none; width: 24px; height: 24px; border-radius: 999px; cursor: pointer; line-height: 1;
|
||||
border: 2px solid var(--accent); background: var(--accent); color: #fff;
|
||||
display: flex; align-items: center; justify-content: center; font-size: 12px;
|
||||
}
|
||||
.rem-done-check.cancelled { background: transparent; border-color: var(--danger-soft); color: var(--danger-soft); }
|
||||
.rem-done-body { flex: 1; min-width: 0; text-align: left; background: none; border: none; cursor: pointer; display: flex; flex-direction: column; gap: 4px; color: inherit; }
|
||||
.rem-done-note { font-size: var(--mobile-font-body); font-weight: 500; line-height: 1.3; text-decoration: line-through; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.rem-done-org { font-size: 12px; color: var(--text-subtle); }
|
||||
/* Add-flow investor picker field (tap → stacked picker sheet, dc :416-428). Styled like an input. */
|
||||
.rem-investor-pick {
|
||||
width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 8px;
|
||||
height: var(--mobile-input-h); padding: 0 12px; cursor: pointer; text-align: left;
|
||||
background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--mobile-control-radius);
|
||||
color: var(--text-primary); font-size: var(--mobile-font-body); font-family: inherit;
|
||||
}
|
||||
.rem-investor-pick > span:first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rem-investor-pick-empty { color: var(--text-subtle); }
|
||||
.rem-investor-pick-caret { flex: none; color: var(--text-muted); }
|
||||
.rem-investor-list { margin-top: 12px; max-height: 46vh; overflow-y: auto; }
|
||||
.rem-investor-hint { font-size: 13px; color: var(--text-subtle); padding: 16px 4px; }
|
||||
/* Snooze preset rows (dc :408-412): label left, resolved date right. */
|
||||
.snooze-list { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
|
||||
.snooze-row {
|
||||
width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
||||
min-height: 50px; padding: 0 16px; cursor: pointer; font-family: inherit;
|
||||
background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--mobile-control-radius);
|
||||
color: var(--text-primary); font-size: var(--mobile-font-body); font-weight: 500;
|
||||
}
|
||||
.snooze-row:active { background: var(--bg-hover); }
|
||||
.snooze-date { font-family: 'IBM Plex Mono', monospace; font-size: 13px; color: var(--text-subtle); }
|
||||
|
||||
/* Visibility utilities — base = desktop; flipped under the breakpoint. */
|
||||
.mobile-only { display: none; }
|
||||
@@ -4837,39 +4911,55 @@
|
||||
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';
|
||||
// Short "Jun 24" month-day for a YYYY-MM-DD string (chip 'later' label + snooze/toast dates).
|
||||
const reminderMonthDay = (iso) => {
|
||||
if (!iso) return '';
|
||||
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}`;
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
};
|
||||
// Urgency bucket: done/cancelled collapse to 'done'; open/snoozed split by due-date delta.
|
||||
// Due-chip: urgency bucket + short label for the card pill (dc RemindersApp urgency, :228-240).
|
||||
// key drives the .due-chip--{key} color; text is "3d overdue" / "Today" / "Tomorrow" / "in 4d" / "Jun 24".
|
||||
const reminderDueChip = (iso) => {
|
||||
const d = reminderDueDelta(iso);
|
||||
if (d == null) return { key: 'later', text: 'No date' };
|
||||
if (d < 0) return { key: 'overdue', text: `${-d}d overdue` };
|
||||
if (d === 0) return { key: 'today', text: 'Today' };
|
||||
if (d === 1) return { key: 'week', text: 'Tomorrow' };
|
||||
if (d <= 7) return { key: 'week', text: `in ${d}d` };
|
||||
return { key: 'later', text: reminderMonthDay(iso) };
|
||||
};
|
||||
const DueChip = ({ iso }) => {
|
||||
const { key, text } = reminderDueChip(iso);
|
||||
return <span className={`due-chip due-chip--${key}`}>{text}</span>;
|
||||
};
|
||||
// Snooze presets relative to today (dc snoozePresets, :371-373) → [label, YYYY-MM-DD].
|
||||
const reminderSnoozePresets = () => {
|
||||
const mk = (days) => { const d = new Date(); d.setHours(0, 0, 0, 0); d.setDate(d.getDate() + days); return d.toISOString().slice(0, 10); };
|
||||
return [['Tomorrow', mk(1)], ['In 3 days', mk(3)], ['1 week', mk(7)], ['2 weeks', mk(14)]];
|
||||
};
|
||||
// Active-reminder bucket (Overdue/Today/This week/Later, dc :318) — split by due-date delta.
|
||||
// Terminal items (done/cancelled) are filtered out before bucketing and rendered separately.
|
||||
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';
|
||||
if (delta === 0) return 'today';
|
||||
if (delta <= 7) return 'week';
|
||||
return 'later';
|
||||
};
|
||||
// Bucket dots (dc :317): red / amber / accent-blue / grey. Theme-bound so they flip in light.
|
||||
const REMINDER_BUCKETS = [
|
||||
{ key: 'overdue', label: 'Overdue' },
|
||||
{ key: 'soon', label: 'Due soon' },
|
||||
{ key: 'later', label: 'Later' },
|
||||
{ key: 'done', label: 'Done' },
|
||||
{ key: 'cancelled', label: 'Cancelled' },
|
||||
{ key: 'overdue', label: 'Overdue', dot: 'var(--due-overdue-text)' },
|
||||
{ key: 'today', label: 'Today', dot: 'var(--due-today-text)' },
|
||||
{ key: 'week', label: 'This week', dot: 'var(--accent)' },
|
||||
{ key: 'later', label: 'Later', dot: 'var(--text-subtle)' },
|
||||
];
|
||||
const REMINDER_STATUS_COLOR = { open: 'var(--rem-open)', snoozed: 'var(--rem-snoozed)', done: 'var(--rem-done)', cancelled: 'var(--rem-cancelled)' };
|
||||
|
||||
// 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 ReminderRow = ({ r, onTap, onDone, onSnooze }) => {
|
||||
const [dx, setDx] = useState(0);
|
||||
const drag = useRef(null);
|
||||
const onPointerDown = (e) => {
|
||||
@@ -4885,7 +4975,7 @@
|
||||
d.active = true;
|
||||
}
|
||||
d.moved = true;
|
||||
if (canSwipe) setDx(Math.max(-120, Math.min(120, ddx)));
|
||||
setDx(Math.max(-120, Math.min(120, ddx)));
|
||||
};
|
||||
const end = (e) => {
|
||||
const d = drag.current; drag.current = null; setDx(0);
|
||||
@@ -4894,34 +4984,23 @@
|
||||
// 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 ? 'var(--danger-soft)' : dueDelta != null && dueDelta <= 7 ? 'var(--due-soon)' : '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>}
|
||||
{dx < 0 && <div className="reminder-action done">Complete ✓</div>}
|
||||
{dx > 0 && <div className="reminder-action snooze">⏰ Snooze</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>
|
||||
<span className="reminder-title">{r.title}</span>
|
||||
<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}
|
||||
{r.investor_name ? <span className="reminder-org">{r.investor_name}</span> : null}
|
||||
<DueChip iso={r.due_date} />
|
||||
</div>
|
||||
{r.details ? <div className="reminder-details">{r.details}</div> : null}
|
||||
</div>
|
||||
@@ -4929,27 +5008,31 @@
|
||||
);
|
||||
};
|
||||
|
||||
// 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.
|
||||
// Mobile Reminders: an urgency-bucketed tickler over /api/reminders. Snooze keeps status
|
||||
// 'open' and just pushes due_date (the 'snoozed' status has no wake mechanism — see
|
||||
// DesktopRemindersPage); the snooze preset sheet offers +1/+3/+7/+14d. Create links a real
|
||||
// investor via the canonical grid picker (source_row_id → server-resolved investor_id);
|
||||
// PATCH still can't reassign the investor, so the edit sheet shows it read-only.
|
||||
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 [completedOpen, setCompletedOpen] = useState(false);
|
||||
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 [form, setForm] = useState({ title: '', due_date: '', details: '', investor_name: '', investor_source_row_id: '', assignee_id: '', status: 'open' });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [snoozing, setSnoozing] = useState(null); // reminder being snoozed → preset sheet
|
||||
const [investorPicker, setInvestorPicker] = useState(false);
|
||||
const [investorQuery, setInvestorQuery] = useState('');
|
||||
const [investors, setInvestors] = useState(null); // null = not loaded yet; [] = loaded/empty
|
||||
|
||||
// Load every reminder (no status param → all statuses) and split client-side: active
|
||||
// (open/snoozed) into the four urgency buckets, terminal (done/cancelled) into Completed.
|
||||
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);
|
||||
const res = await api('/api/reminders', {}, token);
|
||||
setItems(Array.isArray(res?.data) ? res.data : []);
|
||||
setError('');
|
||||
} catch (err) {
|
||||
@@ -4957,7 +5040,7 @@
|
||||
} finally {
|
||||
if (!silent) setLoading(false);
|
||||
}
|
||||
}, [token, statusFilter]);
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
useEffect(() => {
|
||||
@@ -4969,42 +5052,76 @@
|
||||
return () => { cancelled = true; };
|
||||
}, [token]);
|
||||
|
||||
const active = useMemo(() => items.filter((r) => r.status === 'open' || r.status === 'snoozed'), [items]);
|
||||
const completed = useMemo(() => items.filter((r) => r.status === 'done' || r.status === 'cancelled'), [items]);
|
||||
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); });
|
||||
active.forEach((r) => { const k = reminderBucket(r); (out[k] || out.later).push(r); });
|
||||
return out;
|
||||
}, [items]);
|
||||
}, [active]);
|
||||
const { summary, summaryColor } = useMemo(() => {
|
||||
if (active.length === 0) return { summary: 'All clear', summaryColor: 'var(--money)' };
|
||||
const overdue = active.filter((r) => { const d = reminderDueDelta(r.due_date); return d != null && d < 0; }).length;
|
||||
const today = active.filter((r) => reminderDueDelta(r.due_date) === 0).length;
|
||||
return {
|
||||
summary: `${overdue} overdue · ${today} today · ${active.length} open`,
|
||||
summaryColor: overdue ? 'var(--danger-soft)' : 'var(--text-subtle)',
|
||||
};
|
||||
}, [active]);
|
||||
|
||||
const markDone = async (r) => {
|
||||
setItems((xs) => xs.filter((x) => x.id !== r.id)); // optimistic: drop from the active list
|
||||
// Optimistically move the row to its new status (so it leaves the active list / lands in
|
||||
// Completed immediately), then PATCH and reconcile with a silent reload.
|
||||
const setStatus = async (r, patch, toastMsg) => {
|
||||
setItems((xs) => xs.map((x) => (x.id === r.id ? { ...x, ...patch } : x)));
|
||||
try {
|
||||
await api(`/api/reminders/${r.id}`, { method: 'PATCH', body: JSON.stringify({ status: 'done' }) }, token);
|
||||
onShowToast('Marked done', 'success');
|
||||
await api(`/api/reminders/${r.id}`, { method: 'PATCH', body: JSON.stringify(patch) }, token);
|
||||
onShowToast(toastMsg, '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);
|
||||
const markDone = (r) => setStatus(r, { status: 'done' }, 'Marked done');
|
||||
const reopen = (r) => setStatus(r, { status: 'open' }, 'Reopened');
|
||||
const snooze = (r) => setSnoozing(r); // swipe-right → preset sheet (dc :286)
|
||||
const snoozeTo = (r, iso) => { setSnoozing(null); setStatus(r, { status: 'open', due_date: iso }, 'Snoozed to ' + reminderMonthDay(iso)); };
|
||||
|
||||
// Investor picker (add flow): list the canonical grid investors (row id = source_row_id,
|
||||
// resolved server-side to a real investor_id on create). Loaded once, lazily, on first open.
|
||||
const ensureInvestors = useCallback(async () => {
|
||||
if (investors) return;
|
||||
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 res = await api('/api/fundraising/state', {}, token);
|
||||
const grid = (res && res.data && res.data.grid) || {};
|
||||
const list = (Array.isArray(grid.rows) ? grid.rows : [])
|
||||
.filter((row) => row && row.id && String(row.investor_name || '').trim())
|
||||
.map((row) => ({ id: row.id, name: String(row.investor_name).trim() }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
setInvestors(list);
|
||||
} catch (_) { setInvestors([]); }
|
||||
}, [investors, token]);
|
||||
const openInvestorPicker = () => { setInvestorQuery(''); setInvestorPicker(true); ensureInvestors(); };
|
||||
const pickInvestor = (inv) => {
|
||||
setForm((f) => ({ ...f, investor_source_row_id: inv ? inv.id : '', investor_name: inv ? inv.name : '' }));
|
||||
setInvestorPicker(false);
|
||||
};
|
||||
const filteredInvestors = useMemo(() => {
|
||||
const q = investorQuery.trim().toLowerCase();
|
||||
const list = investors || [];
|
||||
return q ? list.filter((i) => i.name.toLowerCase().includes(q)) : list;
|
||||
}, [investors, investorQuery]);
|
||||
|
||||
const openCreate = () => {
|
||||
setForm({ title: '', due_date: '', details: '', investor_name: '', assignee_id: '', status: 'open' });
|
||||
setForm({ title: '', due_date: '', details: '', investor_name: '', investor_source_row_id: '', 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',
|
||||
investor_name: r.investor_name || '', investor_source_row_id: '', assignee_id: r.assignee_id || '', status: r.status || 'open',
|
||||
});
|
||||
setEditing(r);
|
||||
};
|
||||
const closeSheet = () => setEditing(null);
|
||||
const closeSheet = () => { setEditing(null); setInvestorPicker(false); }; // also drop a stacked picker
|
||||
|
||||
const submit = async () => {
|
||||
const title = (form.title || '').trim();
|
||||
@@ -5012,9 +5129,11 @@
|
||||
setBusy(true);
|
||||
try {
|
||||
if (editing && editing.create) {
|
||||
// source_row_id (a grid row id) → backend resolves it to the canonical
|
||||
// investor_id + name; blank = a team task (no investor linkage).
|
||||
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 || '',
|
||||
source_row_id: form.investor_source_row_id || '', assignee_id: form.assignee_id || '',
|
||||
}) }, token);
|
||||
onShowToast('Reminder created', 'success');
|
||||
} else {
|
||||
@@ -5044,42 +5163,63 @@
|
||||
|
||||
const isCreate = !!(editing && editing.create);
|
||||
|
||||
const activeBuckets = REMINDER_BUCKETS.filter((b) => groups[b.key].length);
|
||||
|
||||
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 className="rem-header">
|
||||
<div className="rem-header-titles">
|
||||
<span className="rem-title">Reminders</span>
|
||||
<span className="rem-summary" style={{ color: summaryColor }}>{summary}</span>
|
||||
</div>
|
||||
<button className="rem-add-btn" onClick={openCreate} aria-label="Add reminder">+</button>
|
||||
</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' ? 'var(--danger-soft)' : b.key === 'soon' ? 'var(--due-soon)' : 'var(--text-subtle)' }}>
|
||||
{b.label}<span className="reminder-group-count">{groups[b.key].length}</span>
|
||||
<>
|
||||
{activeBuckets.length === 0 && (
|
||||
<div className="empty-state">{completed.length ? 'Inbox zero — no open reminders.' : 'No reminders — tap + to add one.'}</div>
|
||||
)}
|
||||
{activeBuckets.map((b) => (
|
||||
<div key={b.key}>
|
||||
<div className="reminder-group-header">
|
||||
<span className="reminder-group-dot" style={{ background: b.dot }} />
|
||||
{b.label}<span className="reminder-group-count">{groups[b.key].length}</span>
|
||||
</div>
|
||||
{groups[b.key].map((r) => (
|
||||
<ReminderRow
|
||||
key={r.id} r={r}
|
||||
onTap={() => openEdit(r)}
|
||||
onDone={() => markDone(r)}
|
||||
onSnooze={() => snooze(r)}
|
||||
/>
|
||||
))}
|
||||
</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>
|
||||
))
|
||||
))}
|
||||
{completed.length > 0 && (
|
||||
<div>
|
||||
<button className="rem-completed-toggle" onClick={() => setCompletedOpen((o) => !o)}>
|
||||
<span className={`rem-completed-caret ${completedOpen ? 'open' : ''}`}>▸</span>
|
||||
Completed<span className="reminder-group-count">{completed.length}</span>
|
||||
</button>
|
||||
{completedOpen && completed.map((r) => (
|
||||
<div className="rem-done-row" key={r.id}>
|
||||
<button className={`rem-done-check ${r.status === 'cancelled' ? 'cancelled' : ''}`} onClick={() => reopen(r)} aria-label="Reopen">
|
||||
{r.status === 'cancelled' ? '✕' : '✓'}
|
||||
</button>
|
||||
<button className="rem-done-body" onClick={() => openEdit(r)}>
|
||||
<span className="rem-done-note">{r.title}</span>
|
||||
{r.investor_name ? <span className="rem-done-org">{r.investor_name}</span> : null}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<BottomSheet open={!!editing} onClose={closeSheet} title={isCreate ? 'New reminder' : 'Edit reminder'}>
|
||||
@@ -5094,7 +5234,12 @@
|
||||
{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" />
|
||||
<button type="button" className="rem-investor-pick" onClick={openInvestorPicker}>
|
||||
<span className={form.investor_name ? '' : 'rem-investor-pick-empty'}>
|
||||
{form.investor_name || 'Choose investor — or leave as a team task'}
|
||||
</span>
|
||||
<span className="rem-investor-pick-caret">›</span>
|
||||
</button>
|
||||
</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>
|
||||
@@ -5126,6 +5271,46 @@
|
||||
<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>
|
||||
|
||||
{/* Investor picker — stacked over the add sheet (dc add-flow :416-428). Selecting
|
||||
sets a canonical grid row id; "team task" clears it. */}
|
||||
<BottomSheet open={investorPicker} onClose={() => setInvestorPicker(false)} title="Choose investor" stacked>
|
||||
<input className="sheet-input" value={investorQuery} onChange={(e) => setInvestorQuery(e.target.value)} placeholder="Search investor…" autoFocus />
|
||||
<div className="rem-investor-list">
|
||||
<button type="button" className="sheet-option" onClick={() => pickInvestor(null)}>
|
||||
<span>No investor — team task</span>
|
||||
{!form.investor_source_row_id && <span className="sheet-option-check">✓</span>}
|
||||
</button>
|
||||
{investors == null ? (
|
||||
<div className="rem-investor-hint">Loading investors…</div>
|
||||
) : filteredInvestors.length ? filteredInvestors.map((i) => (
|
||||
<button key={i.id} type="button" className="sheet-option" onClick={() => pickInvestor(i)}>
|
||||
<span>{i.name}</span>
|
||||
{form.investor_source_row_id === i.id && <span className="sheet-option-check">✓</span>}
|
||||
</button>
|
||||
)) : (
|
||||
<div className="rem-investor-hint">No matches.</div>
|
||||
)}
|
||||
</div>
|
||||
</BottomSheet>
|
||||
|
||||
{/* Snooze preset sheet — opened on swipe-right (dc snooze sheet :404-414). */}
|
||||
<BottomSheet open={!!snoozing} onClose={() => setSnoozing(null)} title="Snooze reminder">
|
||||
{snoozing && (
|
||||
<>
|
||||
{snoozing.investor_name && <div className="sheet-subcaption">For {snoozing.investor_name}</div>}
|
||||
<label className="sheet-field-label">Snooze until</label>
|
||||
<div className="snooze-list">
|
||||
{reminderSnoozePresets().map(([label, iso]) => (
|
||||
<button key={iso} type="button" className="snooze-row" onClick={() => snoozeTo(snoozing, iso)}>
|
||||
<span>{label}</span>
|
||||
<span className="snooze-date">{reminderMonthDay(iso)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</BottomSheet>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user