Mobile Phase 8c: Grid-detail notes timeline + top-bar quick-log pencil
Grid detail (G6): replace the single row.notes blob with a NoteTimeline fed by a new investor-level read, GET /api/communications?source_row_id=<grid row id> (filter maps the grid row -> fundraising_investors.source_row_id -> fundraising_contacts.contact_id -> communications, soft-delete-respecting; cancelled-flag fetch + commsReload after a log). Note-logging now uses the shared LogCommunicationSheet, retiring the bespoke noteForm select sheet and the dead .fs-note-log style. New MobileQuickLog: a shell mobile-top-bar pencil reachable from every tab — two-step sheet (pick investor via search + recent-first pool -> inline log form) writing through the one-row /api/fundraising/log-communication path. source_row_id and contact_id are kept mutually exclusive in handle_list_communications so a future caller passing both can't get the empty intersection. Guarded by test_grid_comm_timeline.py (cross-contact aggregation, investor isolation, soft-delete); 39/39 backend green.
This commit is contained in:
@@ -107,12 +107,13 @@ Subsystem rules live in `docs/guides/` and lazy-load in Claude Code via `.claude
|
||||
|
||||
## Current state
|
||||
|
||||
_**Box live at v0.1.0:94**; `main` ahead by mobile Phases 0–7 + P3b + drag-reorder + **8a + 8b** — **all deploy-pending** (no s9pk built). **The fundraising grid + email capture is the canonical system of record.** Active thread: **mobile-first redesign → Phase 8**, building to `design/phase8-conformance.md` (the 8a–8i spec, anchored on each `*.dc.html` DEFAULT `data-props` — NOT the `screenshots/` PNGs). **Plan (Grant, 2026-06-19): finish features first → then Grant device-tests + deploys** (nothing verified on a real phone). History: git log + `start9/0.4/startos/versions/`._
|
||||
_**Box live at v0.1.0:94**; `main` ahead by mobile Phases 0–7 + P3b + drag-reorder + **8a + 8b + 8c** — **all deploy-pending** (no s9pk built). **The fundraising grid + email capture is the canonical system of record.** Active thread: **mobile-first redesign → Phase 8**, building to `design/phase8-conformance.md` (the 8a–8i spec, anchored on each `*.dc.html` DEFAULT `data-props` — NOT the `screenshots/` PNGs). **Plan (Grant, 2026-06-19): finish features first → then Grant device-tests + deploys** (nothing verified on a real phone). History: git log + `start9/0.4/startos/versions/`._
|
||||
|
||||
- **Mobile redesign — 4 core surfaces built (Grid · Contacts · Pipeline · Reminders), each a rules-of-hooks-safe `useIsMobile()` → `Mobile*`/`Desktop*` pair (desktop untouched).** Foundation: bottom-tab bar + `:root` mobile vars; 4-stage enum; derived grid signals injected-on-GET/stripped-on-write at both points; mobile writes use **one-row endpoints only** (log-communication, pipeline link/stage, reminders, `update-row`) — never whole-grid PUT.
|
||||
- **Phase 8a — Grid + Contacts cards re-authored (this session).** Grid card: existing-LP **earmark** corner-triangle (replaces left-border), right-side **PRIORITY pill** (replaces ★), 4-stage chip, zero-commit dim; detail ★→"Existing LP" pill. Contacts card: two-letter avatar initials + existing-LP **ring** + stage pill + recency; disposition badge dropped. Backend: `contact_grid_signals()` injects derived read-only `committed`/`pipeline_stage` on the contacts read path (see Design convention). `DESIGN.md` §4/§8 reconciled.
|
||||
- **Phase 8b — Contacts + Pipeline detail → drag-dismiss bottom sheets (this session).** Contacts: email-copy pill, Log/Email actions, Organization card (earmark·stage·committed·last-contact·last-note·Open-in-Grid). Pipeline: stat tiles, **inline move-stage list**, notes **timeline** + Log sheet. Both log via `POST /api/communications`; `<BottomSheet stacked>` layers the Log sheet over a detail. Reviewer pass applied: stale-fetch race guard (cancelled-flag effects + reload key), keyed single-contact signals query, dedup test.
|
||||
- **Phase 8c — Grid-detail notes timeline + top-bar quick-log pencil (this session).** Grid detail (G6): the single `row.notes` blob → a `<NoteTimeline>` fed by a new investor-level read `GET /api/communications?source_row_id=<grid row id>` (backend filter maps row → `fundraising_investors.source_row_id` → `fundraising_contacts.contact_id` → comms, soft-delete-respecting; cancelled-flag fetch + `commsReload` after a log); note-logging switched to the shared `<LogCommunicationSheet>` (dropped `noteForm` + dead `.fs-note-log`). New `MobileQuickLog` (shell `.mobile-only` top-bar pencil, all 4 tabs): two-step sheet — pick investor (search + recent-first pool) → inline log form → one-row `/api/fundraising/log-communication`. Reviewer pass: `contact_id`/`source_row_id` kept mutually exclusive (`elif`), re-open guard, dropped the rejected ★ from the picker. Guarded by `test_grid_comm_timeline.py`.
|
||||
- **Live (deployed):** W2 NL query (v94); W1 reminders (v93); grid Pipeline (v88); Matrix intake + Gmail capture (DWD) + daily digest; Thesis/Architect (dual-approval); outreach — all draft-only.
|
||||
- **Tests:** **38/38 backend green** (`python3 backend/run_tests.py`; +`test_contacts_grid_signals.py`), `py_compile` clean, render-smoke green; both mobile surfaces interaction-verified via throwaway 375px jsdom harnesses (deleted after).
|
||||
- **Next — Phase 8, in order, build to `design/phase8-conformance.md`:** **8c** quick-log pencil (dc top-bar) + Grid-detail notes timeline → **8d** sort (Grid+Pipeline sort sheet; Contacts = drop type tabs + add Priority sort) → **8e** reminders (due-chip + Overdue/Today/This-week/Later buckets + dots + snooze sheet + investor picker) → **8f** Pipeline card (earmark/Priority/recency + horizontal-scroll stage pills + dots) → **8g** add-investor stage+priority → **8h** loose ends (incl. Grid detail G4/G5/G6 stage-card/reminder-card/timeline; "Open-in-Grid" deep-link-to-investor) → **8i** shell SVG icons + `·Ten31·` wordmark. **Skip Pipeline accordion** (Grant). **Then (after feature-complete):** deploy P0–P8 + P3b in one s9pk (**authorize + version-bump first**) and device-test light/dark on a phone.
|
||||
- **Tests:** **39/39 backend green** (`python3 backend/run_tests.py`; +`test_grid_comm_timeline.py`), `py_compile` clean, render-smoke green; 8c surfaces (quick-log + grid timeline) interaction-verified via a throwaway 375px jsdom harness (deleted after).
|
||||
- **Next — Phase 8, in order, build to `design/phase8-conformance.md`:** **8d** sort (Grid+Pipeline sort sheet; Contacts = drop type tabs + add Priority sort) → **8e** reminders (due-chip + Overdue/Today/This-week/Later buckets + dots + snooze sheet + investor picker) → **8f** Pipeline card (earmark/Priority/recency + horizontal-scroll stage pills + dots) → **8g** add-investor stage+priority → **8h** loose ends (incl. Grid detail G4/G5/G6 stage-card/reminder-card/timeline; "Open-in-Grid" deep-link-to-investor) → **8i** shell SVG icons + `·Ten31·` wordmark. **Skip Pipeline accordion** (Grant). **Then (after feature-complete):** deploy P0–P8 + P3b in one s9pk (**authorize + version-bump first**) and device-test light/dark on a phone.
|
||||
- **Open / risks:** all mobile work + light theme **built but never deployed or device-tested** (smoke/jsdom only); `MobileDetailRow` now unused-but-retained (legacy-usage sweep); Pipeline detail "Committed" tile shows grid-committed not deal-expected (deal forecast in a footnote); W2 happy-path only; **Claude/Architect path unverified live on the box**; v2.0 reserve-asset spine **not canonical**; doc drift — `crm-overview.md`/`EVALUATION.md` still call `lp_profiles` live.
|
||||
|
||||
@@ -3238,6 +3238,20 @@ class CRMHandler(BaseHTTPRequestHandler):
|
||||
if params.get('contact_id'):
|
||||
query += " AND cm.contact_id = ?"
|
||||
args.append(params['contact_id'])
|
||||
# source_row_id is an investor-scope filter; contact_id is a contact-scope filter. They are
|
||||
# never combined by any caller (the /contacts/{id}/communications subpath sets contact_id;
|
||||
# the mobile Grid detail sets source_row_id) — keep them mutually exclusive so a future
|
||||
# caller passing both can't silently get the empty intersection.
|
||||
elif params.get('source_row_id'):
|
||||
# Investor-level timeline for a fundraising-grid row (the mobile Grid detail): map the
|
||||
# grid JSON row id → its canonical contacts via the relational mirror, then return every
|
||||
# communication across those contacts. cm.deleted_at is still filtered above (soft-delete).
|
||||
query += """ AND cm.contact_id IN (
|
||||
SELECT fc.contact_id FROM fundraising_contacts fc
|
||||
JOIN fundraising_investors fi ON fc.investor_id = fi.id
|
||||
WHERE fi.source_row_id = ? AND fc.contact_id IS NOT NULL
|
||||
)"""
|
||||
args.append(params['source_row_id'])
|
||||
if params.get('type'):
|
||||
query += " AND cm.type = ?"
|
||||
args.append(params['type'])
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression test for the Grid-detail communications timeline filter (Phase 8c, G6).
|
||||
|
||||
The mobile Grid detail's notes timeline pulls an investor-level communication stream
|
||||
via GET /api/communications?source_row_id=<grid row id>. That filter (added to
|
||||
handle_list_communications) maps the grid JSON row id → fundraising_investors.source_row_id
|
||||
→ fundraising_contacts.contact_id → communications, so it must:
|
||||
- return every communication across ALL the investor's contacts,
|
||||
- stay isolated (one investor's row id never returns another's comms),
|
||||
- respect soft-delete (cm.deleted_at IS NULL) through the join.
|
||||
|
||||
Boots the REAL server, seeds investors by driving the one-row log path (which creates the
|
||||
grid row + contact + communication AND syncs the relational mirror the filter joins on),
|
||||
then drives the live read path with a real token. Synthetic only (guardrail #9).
|
||||
|
||||
Run: cd backend && python3 test_grid_comm_timeline.py
|
||||
"""
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from http.server import ThreadingHTTPServer
|
||||
|
||||
_DATA = tempfile.mkdtemp()
|
||||
os.environ["CRM_DATA_DIR"] = _DATA
|
||||
os.environ["CRM_DB_PATH"] = os.path.join(_DATA, "crm.db")
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import server # noqa: E402
|
||||
|
||||
FAILS = []
|
||||
DEL = "2026-06-01T00:00:00"
|
||||
|
||||
|
||||
def check(cond, msg):
|
||||
print((" PASS " if cond else " FAIL ") + msg)
|
||||
if not cond:
|
||||
FAILS.append(msg)
|
||||
|
||||
|
||||
class _Quiet(server.CRMHandler):
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
def _req(method, port, path, token, body=None):
|
||||
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
|
||||
headers = {"Authorization": "Bearer " + token}
|
||||
payload = None
|
||||
if body is not None:
|
||||
payload = json.dumps(body)
|
||||
headers["Content-Type"] = "application/json"
|
||||
conn.request(method, path, body=payload, headers=headers)
|
||||
resp = conn.getresponse()
|
||||
raw = resp.read().decode("utf-8", "replace")
|
||||
conn.close()
|
||||
data = None
|
||||
if raw:
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except ValueError:
|
||||
pass
|
||||
return resp.status, data
|
||||
|
||||
|
||||
def _log_comm(port, token, investor_name, contact, subject, create=False):
|
||||
"""Drive the one-row log path; returns (status, grid_row_id)."""
|
||||
st, data = _req("POST", port, "/api/fundraising/log-communication", token, {
|
||||
"investor_name": investor_name,
|
||||
"create_investor_if_missing": create,
|
||||
"contact": contact,
|
||||
"type": "note",
|
||||
"subject": subject,
|
||||
"body": subject,
|
||||
"append_note": True,
|
||||
})
|
||||
row_id = ((data or {}).get("data", {}).get("row") or {}).get("id")
|
||||
return st, row_id
|
||||
|
||||
|
||||
def main():
|
||||
server.init_db()
|
||||
conn = sqlite3.connect(os.environ["CRM_DB_PATH"])
|
||||
# password_hash is intentionally a non-bcrypt placeholder — we mint the token directly via
|
||||
# create_token(), so the password-verify path is never exercised.
|
||||
conn.execute("INSERT INTO users (id,username,email,password_hash,full_name,role,is_active) "
|
||||
"VALUES ('u1','grant','grant@ten31.example','x','Grant','admin',1)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
token = server.create_token("u1", "grant", "admin")
|
||||
|
||||
httpd = ThreadingHTTPServer(("127.0.0.1", 0), _Quiet)
|
||||
port = httpd.server_address[1]
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
try:
|
||||
# Investor A: two contacts, one communication per contact. Create seeds the row with
|
||||
# Jane + logs "Intro call"; update-row adds John as a second pill (so the relational mirror
|
||||
# links BOTH contacts to A's row); then a comm is logged against John. The timeline must
|
||||
# aggregate across both contacts — the point of the source_row_id join over a single contact.
|
||||
st, rowA = _log_comm(port, token, "Acme Capital",
|
||||
{"name": "Jane Doe", "email": "jane@acme.example"}, "Intro call", create=True)
|
||||
check(st == 201 and bool(rowA), f"create investor A via log path -> 201 + row id (got {st}, {rowA})")
|
||||
st, _ = _req("POST", port, "/api/fundraising/update-row", token, {
|
||||
"row_id": rowA, "investor_name": "Acme Capital",
|
||||
"contacts": [{"name": "Jane Doe", "email": "jane@acme.example"},
|
||||
{"name": "John Roe", "email": "john@acme.example"}],
|
||||
})
|
||||
check(st == 200, f"add John as a second contact on A via update-row (got {st})")
|
||||
st, _ = _log_comm(port, token, "Acme Capital",
|
||||
{"name": "John Roe", "email": "john@acme.example"}, "Follow-up email")
|
||||
check(st == 201, f"second contact's comm logged on A (got {st})")
|
||||
|
||||
# Investor B: a separate investor, one communication (isolation control).
|
||||
st, rowB = _log_comm(port, token, "Beacon Ventures",
|
||||
{"name": "Sam Poe", "email": "sam@beacon.example"}, "Beacon note", create=True)
|
||||
check(st == 201 and bool(rowB), f"create investor B via log path -> 201 + row id (got {st}, {rowB})")
|
||||
|
||||
# ── source_row_id returns the whole investor (across contacts) ──
|
||||
print("\n[source_row_id timeline]")
|
||||
st, data = _req("GET", port, f"/api/communications?source_row_id={rowA}", token)
|
||||
subsA = {c.get("subject") for c in (data or {}).get("data", [])}
|
||||
check(st == 200, f"GET timeline for A -> 200 (got {st})")
|
||||
check(subsA == {"Intro call", "Follow-up email"},
|
||||
f"A's timeline spans both contacts' comms (got {subsA})")
|
||||
|
||||
# ── isolation: A's row id never returns B's comms ──
|
||||
print("\n[isolation]")
|
||||
check("Beacon note" not in subsA, "A's timeline excludes investor B's comm")
|
||||
st, dataB = _req("GET", port, f"/api/communications?source_row_id={rowB}", token)
|
||||
subsB = {c.get("subject") for c in (dataB or {}).get("data", [])}
|
||||
check(subsB == {"Beacon note"}, f"B's timeline is its own comm only (got {subsB})")
|
||||
|
||||
# ── soft-delete respected through the join ──
|
||||
print("\n[soft-delete]")
|
||||
c = sqlite3.connect(os.environ["CRM_DB_PATH"])
|
||||
c.execute("UPDATE communications SET deleted_at=? WHERE subject='Intro call'", (DEL,))
|
||||
c.commit()
|
||||
c.close()
|
||||
st, data2 = _req("GET", port, f"/api/communications?source_row_id={rowA}", token)
|
||||
subsA2 = {c.get("subject") for c in (data2 or {}).get("data", [])}
|
||||
check(subsA2 == {"Follow-up email"},
|
||||
f"soft-deleted comm filtered from A's timeline (got {subsA2})")
|
||||
|
||||
# ── unknown row id returns empty, not an error ──
|
||||
st, data3 = _req("GET", port, "/api/communications?source_row_id=does-not-exist", token)
|
||||
check(st == 200 and (data3 or {}).get("data") == [],
|
||||
f"unknown source_row_id -> 200 + empty (got {st}, {(data3 or {}).get('data')})")
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
|
||||
print()
|
||||
if FAILS:
|
||||
print(f"FAILED ({len(FAILS)}):")
|
||||
for f in FAILS:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("ALL PASS (grid comm timeline source_row_id filter)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+191
-29
@@ -2472,6 +2472,37 @@
|
||||
}
|
||||
.log-type-btn.active { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-light); }
|
||||
|
||||
/* Quick-log — top-bar pencil button + two-step picker sheet (dc GridApp:53-55). */
|
||||
.quicklog-btn {
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
border: 1px solid var(--border); background: var(--bg-panel-elevated);
|
||||
color: var(--text-muted); cursor: pointer;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.quicklog-btn:active { background: var(--bg-hover); }
|
||||
.quicklog-hint { font-size: 13px; color: var(--text-subtle); line-height: 1.5; margin: 0 0 12px; }
|
||||
.quicklog-pool { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
|
||||
.quicklog-empty { font-size: 13px; color: var(--text-subtle); padding: 16px 4px; }
|
||||
.quicklog-row {
|
||||
width: 100%; text-align: left; cursor: pointer; font-family: inherit;
|
||||
background: var(--bg-input); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 11px 13px; display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.quicklog-row:active { background: var(--bg-hover); }
|
||||
.quicklog-row-main { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||
.quicklog-row-name { font-size: 15px; font-weight: 500; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.quicklog-row-sub { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-subtle); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.quicklog-target {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 16px;
|
||||
background: var(--bg-input); border: 1px solid var(--border); border-radius: 10px; padding: 11px 13px;
|
||||
}
|
||||
.quicklog-target-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.quicklog-target-label { font-family: 'IBM Plex Mono', monospace; font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-subtle); }
|
||||
.quicklog-target-name { font-size: 15px; font-weight: 600; color: var(--text-primary); }
|
||||
.quicklog-change { flex: none; background: none; border: none; color: var(--accent-light); font-size: 13px; cursor: pointer; font-family: inherit; }
|
||||
.quicklog-warn { font-size: 13px; color: var(--text-subtle); line-height: 1.5; margin-bottom: 14px; }
|
||||
|
||||
/* Full-screen detail: read-only sections + edit-entry buttons. */
|
||||
.fs-detail-chips { flex: none; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
|
||||
.fs-action-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
@@ -2485,7 +2516,6 @@
|
||||
.fs-pill { background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--mobile-control-radius); padding: 9px 12px; margin-bottom: 8px; }
|
||||
.fs-pill-name { font-size: var(--mobile-font-body); color: var(--text-primary); }
|
||||
.fs-pill-email { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-muted); margin-top: 2px; word-break: break-word; }
|
||||
.fs-note-log { white-space: pre-wrap; font-size: 13px; color: var(--text-secondary); line-height: 1.5; }
|
||||
|
||||
/* Bottom-sheet form fields (shared by the log-note / stage / reminder / create sheets). */
|
||||
.sheet-field { margin-bottom: 14px; }
|
||||
@@ -9732,8 +9762,11 @@
|
||||
const [sheet, setSheet] = useState(null); // 'view' | 'create' | 'note' | 'stage' | 'reminder'
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [createForm, setCreateForm] = useState({ name: '', contactName: '', contactEmail: '', note: '' });
|
||||
const [noteForm, setNoteForm] = useState({ type: 'note', subject: '', body: '' });
|
||||
const [reminderForm, setReminderForm] = useState({ title: '', due_date: '', details: '' });
|
||||
// G6 — investor-level communications timeline for the open detail. Fetched on open
|
||||
// (source_row_id → canonical contacts → communications); commsReload re-runs it after a log.
|
||||
const [comms, setComms] = useState([]);
|
||||
const [commsReload, setCommsReload] = useState(0);
|
||||
// P3b edit sheet: investor name + the full contacts array (pills carry their other
|
||||
// fields — title/location/linkedin — through unedited; we only surface name/email/title).
|
||||
const [editForm, setEditForm] = useState({ name: '', contacts: [] });
|
||||
@@ -9755,6 +9788,21 @@
|
||||
|
||||
useEffect(() => { reload(); }, [reload]);
|
||||
|
||||
// G6 timeline fetch — keyed on the open investor (+ commsReload after a log). A cancelled
|
||||
// flag drops a stale response so opening row A then B can't leave B's detail showing A's
|
||||
// notes (the 8b reviewer race-guard pattern). Non-fatal: timeline stays empty on failure.
|
||||
useEffect(() => {
|
||||
if (!selectedId) { setComms([]); return undefined; }
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const r = await api(`/api/communications?source_row_id=${encodeURIComponent(selectedId)}&limit=50`, {}, token);
|
||||
if (!cancelled) setComms(Array.isArray(r.data) ? r.data : []);
|
||||
} catch (_) { if (!cancelled) setComms([]); }
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, [selectedId, token, commsReload]);
|
||||
|
||||
const fundColumnIds = useMemo(() => columns.filter((c) => c && c.isFund).map((c) => c.id), [columns]);
|
||||
const fundColumns = useMemo(() => columns.filter((c) => c && c.isFund), [columns]);
|
||||
const activeViewObj = useMemo(() => views.find((v) => v.id === activeView) || null, [views, activeView]);
|
||||
@@ -9777,20 +9825,22 @@
|
||||
const closeSheet = () => setSheet(null);
|
||||
|
||||
// ── writes (targeted one-row endpoints only) ──
|
||||
const submitNote = async () => {
|
||||
// Log a communication against the open investor's first contact via the one-row grid path
|
||||
// (creates a communications row + appends the notes blob). Refreshes the G6 timeline +
|
||||
// card recency. Payload shape matches the shared LogCommunicationSheet ({type,subject,body}).
|
||||
const submitNote = async ({ type, subject, body }) => {
|
||||
const row = selectedRow; if (!row) return;
|
||||
const contact = (Array.isArray(row.contacts) && row.contacts[0]) || null;
|
||||
if (!contact) { onShowToast('This investor has no contact yet — add one on desktop first', 'error'); return; }
|
||||
if (!String(noteForm.body || noteForm.subject || '').trim()) { onShowToast('Add a note', 'error'); return; }
|
||||
setBusy(true);
|
||||
try {
|
||||
await api('/api/fundraising/log-communication', { method: 'POST', body: JSON.stringify({
|
||||
row_id: row.id, investor_name: row.investor_name || '', contact,
|
||||
type: noteForm.type || 'note', subject: noteForm.subject || '', body: noteForm.body || '', append_note: true,
|
||||
type: type || 'note', subject: subject || '', body: body || '', append_note: true,
|
||||
}) }, token);
|
||||
onShowToast('Note logged', 'success');
|
||||
setNoteForm({ type: 'note', subject: '', body: '' });
|
||||
onShowToast('Communication logged', 'success');
|
||||
closeSheet();
|
||||
setCommsReload((n) => n + 1);
|
||||
await reload(true);
|
||||
} catch (err) { onShowToast(getErrorMessage(err, 'Failed to log note'), 'error'); }
|
||||
finally { setBusy(false); }
|
||||
@@ -10084,34 +10134,23 @@
|
||||
{row.reminder_status && (
|
||||
<div className="fs-row"><span className="fs-row-label">Reminder</span><span className="fs-row-value">{String(row.reminder_status).replace('_', ' ')}</span></div>
|
||||
)}
|
||||
{row.notes && <div className="fs-note-log" style={{ marginTop: '10px' }}>{row.notes}</div>}
|
||||
<div className="fs-action-row" style={{ marginTop: '12px' }}>
|
||||
<button className="fs-action-btn" onClick={() => { setNoteForm({ type: 'note', subject: '', body: '' }); setSheet('note'); }}>Log a note</button>
|
||||
<button className="fs-action-btn" onClick={() => { setReminderForm({ title: '', due_date: '', details: '' }); setSheet('reminder'); }}>Set a reminder</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* G6 — notes/communication timeline (dc GridApp:265-290): dot-and-line rail
|
||||
of the investor's logged communications, "+ Log" via the shared sheet. */}
|
||||
<div className="fs-section">
|
||||
<div className="sheet-section-head" style={{ margin: '0 0 10px' }}>
|
||||
<span className="fs-section-label" style={{ margin: 0 }}>Notes / communication</span>
|
||||
<button className="sheet-log-btn" onClick={() => setSheet('note')}>+ Log</button>
|
||||
</div>
|
||||
<NoteTimeline comms={comms} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BottomSheet open={sheet === 'note'} onClose={closeSheet} title="Log a note">
|
||||
<div className="sheet-field">
|
||||
<label className="sheet-field-label">Type</label>
|
||||
<select className="sheet-select" value={noteForm.type} onChange={(e) => setNoteForm((f) => ({ ...f, type: e.target.value }))}>
|
||||
<option value="note">Note</option>
|
||||
<option value="call">Call</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="meeting">Meeting</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="sheet-field">
|
||||
<label className="sheet-field-label">Summary (optional)</label>
|
||||
<input className="sheet-input" value={noteForm.subject} onChange={(e) => setNoteForm((f) => ({ ...f, subject: e.target.value }))} placeholder="e.g. Intro call" />
|
||||
</div>
|
||||
<div className="sheet-field">
|
||||
<label className="sheet-field-label">Note</label>
|
||||
<textarea className="sheet-textarea" value={noteForm.body} onChange={(e) => setNoteForm((f) => ({ ...f, body: e.target.value }))} placeholder="What happened…" />
|
||||
</div>
|
||||
<button className="sheet-submit" onClick={submitNote} disabled={busy}>{busy ? 'Saving…' : 'Log note'}</button>
|
||||
</BottomSheet>
|
||||
<LogCommunicationSheet open={sheet === 'note'} onClose={closeSheet} onSubmit={submitNote} busy={busy} forLabel={row.investor_name || 'this investor'} />
|
||||
|
||||
<BottomSheet open={sheet === 'stage'} onClose={closeSheet} title="Pipeline stage">
|
||||
{PIPELINE_STAGES.map((st) => (
|
||||
@@ -13555,6 +13594,128 @@
|
||||
);
|
||||
};
|
||||
|
||||
// Quick-log — the dc top-bar pencil (GridApp:53-55): log a communication against any investor
|
||||
// without first opening its detail. Two steps: pick an investor (search + recent-first pool) →
|
||||
// inline log form. Writes via the one-row /api/fundraising/log-communication path (same write
|
||||
// the Grid detail uses; resolves the investor's first contact server-side). Lives in the shell
|
||||
// mobile top bar so it's reachable from every mobile tab. Investors are fetched lazily on open.
|
||||
const MobileQuickLog = ({ token, onShowToast }) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [rows, setRows] = useState([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [targetId, setTargetId] = useState(null);
|
||||
const [form, setForm] = useState({ type: 'note', subject: '', body: '' });
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const openSheet = async () => {
|
||||
if (open) return; // already open — don't reset state or re-fetch on a second tap
|
||||
setTargetId(null); setSearch(''); setForm({ type: 'note', subject: '', body: '' });
|
||||
setBusy(false); setOpen(true); setLoading(true);
|
||||
try {
|
||||
const result = await api('/api/fundraising/state', {}, token);
|
||||
const grid = (result && result.data && result.data.grid) || {};
|
||||
setRows(Array.isArray(grid.rows) ? grid.rows : []);
|
||||
} catch (_) { onShowToast('Failed to load investors', 'error'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
const closeSheet = () => setOpen(false);
|
||||
|
||||
// Recent-first pool (most recently active surface without searching; no-activity last),
|
||||
// filtered by investor name or any contact name/email, capped at 8 like the dc.
|
||||
const pool = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
const base = rows.filter((r) => r && typeof r === 'object');
|
||||
const matched = !q ? base : base.filter((r) => {
|
||||
const contactText = (r.contacts || []).map((c) => `${c.name || ''} ${c.email || ''}`).join(' ');
|
||||
return `${r.investor_name || ''} ${contactText}`.toLowerCase().includes(q);
|
||||
});
|
||||
return [...matched]
|
||||
.sort((a, b) => String(b.last_activity_at || '').localeCompare(String(a.last_activity_at || '')))
|
||||
.slice(0, 8);
|
||||
}, [rows, search]);
|
||||
|
||||
const target = useMemo(() => rows.find((r) => r.id === targetId) || null, [rows, targetId]);
|
||||
const targetContact = (target && Array.isArray(target.contacts) && target.contacts[0]) || null;
|
||||
const canSubmit = !busy && targetContact && (form.subject.trim() || form.body.trim());
|
||||
|
||||
const submit = async () => {
|
||||
if (!target || !canSubmit) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api('/api/fundraising/log-communication', { method: 'POST', body: JSON.stringify({
|
||||
row_id: target.id, investor_name: target.investor_name || '', contact: targetContact,
|
||||
type: form.type || 'note', subject: form.subject.trim(), body: form.body.trim(), append_note: true,
|
||||
}) }, token);
|
||||
onShowToast(`Logged for ${target.investor_name || 'investor'}`, 'success');
|
||||
closeSheet();
|
||||
} catch (err) { onShowToast(getErrorMessage(err, 'Failed to log communication'), 'error'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className="quicklog-btn" onClick={openSheet} aria-label="Log communication" title="Log communication">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 20h9" /><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<BottomSheet open={open} onClose={closeSheet} title="Log communication">
|
||||
{!target ? (
|
||||
<>
|
||||
<div className="quicklog-hint">Pick an investor, then log the communication.</div>
|
||||
<input className="sheet-input" value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search investor or contact…" />
|
||||
<div className="quicklog-pool">
|
||||
{loading ? <div className="quicklog-empty">Loading…</div>
|
||||
: pool.length === 0 ? <div className="quicklog-empty">No matches.</div>
|
||||
: pool.map((r) => {
|
||||
const ct = (Array.isArray(r.contacts) && r.contacts[0]) || null;
|
||||
const more = (r.contacts || []).length - 1;
|
||||
const sub = ct ? `${ct.name || ct.email || 'contact'}${more > 0 ? ` +${more}` : ''}` : 'No contacts';
|
||||
return (
|
||||
<button key={r.id} className="quicklog-row" onClick={() => setTargetId(r.id)}>
|
||||
<span className="quicklog-row-main">
|
||||
<span className="quicklog-row-name">{r.investor_name || 'Unnamed investor'}</span>
|
||||
<span className="quicklog-row-sub">{sub}</span>
|
||||
</span>
|
||||
{r.pipeline && <StageChip stage={r.pipeline_stage} sm />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="quicklog-target">
|
||||
<span className="quicklog-target-main">
|
||||
<span className="quicklog-target-label">Logging for</span>
|
||||
<span className="quicklog-target-name">{target.investor_name || 'Investor'}</span>
|
||||
</span>
|
||||
<button type="button" className="quicklog-change" onClick={() => setTargetId(null)}>Change</button>
|
||||
</div>
|
||||
{!targetContact && <div className="quicklog-warn">This investor has no contact yet — add one on desktop before logging.</div>}
|
||||
<label className="sheet-field-label">Type</label>
|
||||
<div className="log-type-row">
|
||||
{LOG_TYPES.map((t) => (
|
||||
<button key={t} type="button" className={`log-type-btn ${form.type === t ? 'active' : ''}`} onClick={() => setForm((f) => ({ ...f, type: t }))}>{t}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="sheet-field" style={{ marginTop: '16px' }}>
|
||||
<label className="sheet-field-label">Summary</label>
|
||||
<input className="sheet-input" value={form.subject} onChange={(e) => setForm((f) => ({ ...f, subject: e.target.value }))} placeholder="Short headline" />
|
||||
</div>
|
||||
<div className="sheet-field">
|
||||
<label className="sheet-field-label">Details</label>
|
||||
<textarea className="sheet-textarea" value={form.body} onChange={(e) => setForm((f) => ({ ...f, body: e.target.value }))} placeholder="Full context kept in communications history" />
|
||||
</div>
|
||||
<button className="sheet-submit" onClick={submit} disabled={!canSubmit}>{busy ? 'Logging…' : 'Log communication'}</button>
|
||||
</>
|
||||
)}
|
||||
</BottomSheet>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
const { token, user, logout } = useAuth();
|
||||
const [page, setPage] = useState('fundraising-grid');
|
||||
@@ -13843,6 +14004,7 @@
|
||||
{user?.full_name || user?.username}{MOCK_MODE ? ' · Mock Mode' : ''}
|
||||
</div>
|
||||
<div className="mobile-only" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<MobileQuickLog token={token} onShowToast={showToast} />
|
||||
<ThemeToggle theme={theme} onToggle={toggleTheme} variant="icon" />
|
||||
<div style={{ position: 'relative' }}>
|
||||
<button className="account-btn" onClick={() => setAccountMenuOpen((o) => !o)} aria-label="Account menu">
|
||||
|
||||
Reference in New Issue
Block a user