Mobile Phase 8g: add-investor sheet — optional stage picker + Priority toggle + reminder
The mobile "New investor" sheet now captures three optional fields beyond name/contact/note, matching the dc (GridApp.dc.html:737): - Initial pipeline stage — a .stage-pick chip picker, defaulting to "Not in pipeline" so a plain directory add never auto-creates an opportunity row (Grant's call). - A framed "Flag as Priority" toggle. - An optional reminder (title + a progressive due-date field). submitCreate orchestrates one-row calls in order: create (log-communication create_investor_if_missing, now carrying priority) -> if a stage was picked, link to the pipeline at that stage (reusing applyStage's idempotent link-then-PATCH) -> if a reminder title was given, POST /api/reminders keyed on the new row's source_row_id. The link and reminder steps are non-fatal: a failure toasts but never loses the created investor, and a create that returns no row id warns instead of a clean success. Backend: handle_log_fundraising_communication honors an optional priority flag only on its create-if-missing branch (an existing-row log never touches priority). Guarded by test_grid_add_investor.py (priority-on-create, defaults-False, the create-branch- only invariant, and the create->link / create->reminder handshakes on a freshly-synced row). 40/40 backend green; the create sheet was interaction-verified in a throwaway jsdom harness.
This commit is contained in:
+4
-1
@@ -3341,6 +3341,9 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
contact_in = body.get('contact')
|
contact_in = body.get('contact')
|
||||||
append_note = bool(body.get('append_note', True))
|
append_note = bool(body.get('append_note', True))
|
||||||
create_investor_if_missing = bool(body.get('create_investor_if_missing', False))
|
create_investor_if_missing = bool(body.get('create_investor_if_missing', False))
|
||||||
|
# Optional initial Priority flag for the mobile add-investor sheet (8g). Honored ONLY on the
|
||||||
|
# create branch below — an existing-row log-communication never touches its priority.
|
||||||
|
create_priority = bool(body.get('priority', False))
|
||||||
# Provenance: where this logged communication originated (grid UI vs the Matrix
|
# Provenance: where this logged communication originated (grid UI vs the Matrix
|
||||||
# intake bot). Default preserves prior behavior; callers may override.
|
# intake bot). Default preserves prior behavior; callers may override.
|
||||||
comm_source = (str(body.get('source') or 'fundraising_grid').strip() or 'fundraising_grid')[:64]
|
comm_source = (str(body.get('source') or 'fundraising_grid').strip() or 'fundraising_grid')[:64]
|
||||||
@@ -3391,7 +3394,7 @@ class CRMHandler(BaseHTTPRequestHandler):
|
|||||||
"notes_last_modified": "",
|
"notes_last_modified": "",
|
||||||
"last_communication_date": "",
|
"last_communication_date": "",
|
||||||
"lead": "",
|
"lead": "",
|
||||||
"priority": False,
|
"priority": create_priority,
|
||||||
"follow_up": False,
|
"follow_up": False,
|
||||||
"graveyard": False
|
"graveyard": False
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Tests for the mobile add-investor flow (Phase 8g).
|
||||||
|
|
||||||
|
Boots the REAL server against a temp DB and exercises the create path the mobile
|
||||||
|
"New investor" sheet drives:
|
||||||
|
- POST /api/fundraising/log-communication with create_investor_if_missing honors an
|
||||||
|
optional initial `priority` flag on the NEW row (and defaults it to False when omitted);
|
||||||
|
- the brand-new row's source_row_id resolves immediately for the follow-on
|
||||||
|
POST /api/fundraising/pipeline/link (the relational sync runs inside the create), so the
|
||||||
|
create -> link-at-stage handshake the UI does works end to end;
|
||||||
|
- a follow-on POST /api/reminders with the new row's source_row_id resolves to the synced
|
||||||
|
investor (the create -> reminder handshake).
|
||||||
|
Synthetic data only.
|
||||||
|
|
||||||
|
Run: cd backend && python3 test_grid_add_investor.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 = []
|
||||||
|
|
||||||
|
|
||||||
|
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(port, method, path, token=None, body=None):
|
||||||
|
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10)
|
||||||
|
headers = {}
|
||||||
|
if token:
|
||||||
|
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 _db():
|
||||||
|
return sqlite3.connect(os.environ["CRM_DB_PATH"])
|
||||||
|
|
||||||
|
|
||||||
|
def seed():
|
||||||
|
c = _db()
|
||||||
|
c.execute("INSERT INTO users (id,username,email,password_hash,full_name,role,is_active) "
|
||||||
|
"VALUES ('u1','grant','grant@ten31.example','x','Grant','admin',1)")
|
||||||
|
c.commit()
|
||||||
|
c.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _create(port, token, name, contact_name, **extra):
|
||||||
|
body = {
|
||||||
|
"investor_name": name, "create_investor_if_missing": True,
|
||||||
|
"contact": {"name": contact_name, "email": contact_name.split(" ")[0].lower() + "@firm.com"},
|
||||||
|
"type": "note", "body": extra.pop("note", ""), "append_note": bool(extra.pop("note_append", False)),
|
||||||
|
}
|
||||||
|
body.update(extra)
|
||||||
|
return _req(port, "POST", "/api/fundraising/log-communication", token, body)
|
||||||
|
|
||||||
|
|
||||||
|
def _grid_rows(port, token):
|
||||||
|
st, d = _req(port, "GET", "/api/fundraising/state", token)
|
||||||
|
return {r["id"]: r for r in (d or {}).get("data", {}).get("grid", {}).get("rows", [])}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
server.init_db()
|
||||||
|
seed()
|
||||||
|
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:
|
||||||
|
# ── create with priority:true seeds the row's Priority flag ──
|
||||||
|
print("\n[create: optional initial priority flag honored]")
|
||||||
|
st, d = _create(port, token, "Acme Capital", "Jane Doe", priority=True, note="Intro call", note_append=True)
|
||||||
|
row = (d or {}).get("data", {}).get("row") or {}
|
||||||
|
acme_id = row.get("id")
|
||||||
|
check(st == 201, f"create -> 201 (got {st})")
|
||||||
|
check(row.get("priority") is True, f"returned row carries priority=true (got {row.get('priority')!r})")
|
||||||
|
rows = _grid_rows(port, token)
|
||||||
|
check(rows.get(acme_id, {}).get("priority") is True,
|
||||||
|
f"GET /state shows the new row priority=true (got {rows.get(acme_id, {}).get('priority')!r})")
|
||||||
|
check(len(rows.get(acme_id, {}).get("contacts", [])) == 1,
|
||||||
|
f"new row has its first contact (got {rows.get(acme_id, {}).get('contacts')})")
|
||||||
|
|
||||||
|
# ── create without priority defaults to False (no accidental flag) ──
|
||||||
|
print("\n[create: priority defaults False when omitted]")
|
||||||
|
st, d = _create(port, token, "Beta Partners", "Pat Roe") # no note, no priority
|
||||||
|
beta = (d or {}).get("data", {}).get("row") or {}
|
||||||
|
beta_id = beta.get("id")
|
||||||
|
check(st == 201, f"no-note create -> 201 (got {st})")
|
||||||
|
check(beta.get("priority") is False, f"omitted priority -> False (got {beta.get('priority')!r})")
|
||||||
|
|
||||||
|
# ── priority is honored ONLY on the create branch: logging against an EXISTING row
|
||||||
|
# with priority:true must not flip its flag (Beta was created without priority) ──
|
||||||
|
print("\n[invariant: priority on an existing-row log does NOT change its flag]")
|
||||||
|
st, _ = _req(port, "POST", "/api/fundraising/log-communication", token, {
|
||||||
|
"row_id": beta_id, "type": "note", "body": "follow-up", "append_note": True, "priority": True,
|
||||||
|
})
|
||||||
|
check(st in (200, 201), f"log against existing Beta -> ok (got {st})")
|
||||||
|
rows = _grid_rows(port, token)
|
||||||
|
check(rows.get(beta_id, {}).get("priority") is False,
|
||||||
|
f"existing-row priority untouched by the log's priority flag (got {rows.get(beta_id, {}).get('priority')!r})")
|
||||||
|
|
||||||
|
# ── create -> link handshake: the brand-new row links at the chosen stage ──
|
||||||
|
print("\n[create -> link: freshly-created row resolves for pipeline link at stage]")
|
||||||
|
st, d = _req(port, "POST", "/api/fundraising/pipeline/link", token, {
|
||||||
|
"source_row_id": acme_id, "contact_index": 0, "name": "Acme Capital — Pipeline",
|
||||||
|
"stage": "engaged", "expected_amount": 0, "probability": 55, "fund_name": "",
|
||||||
|
})
|
||||||
|
opp = (d or {}).get("data") or {}
|
||||||
|
check(st == 201 and opp.get("stage") == "engaged",
|
||||||
|
f"link new row @engaged -> 201 (got {st}, stage={opp.get('stage')})")
|
||||||
|
rows = _grid_rows(port, token)
|
||||||
|
check(rows.get(acme_id, {}).get("pipeline") is True
|
||||||
|
and rows.get(acme_id, {}).get("pipeline_stage") == "engaged",
|
||||||
|
f"new row now in pipeline @engaged (got {rows.get(acme_id, {}).get('pipeline')}, "
|
||||||
|
f"{rows.get(acme_id, {}).get('pipeline_stage')})")
|
||||||
|
|
||||||
|
# ── create -> reminder handshake: source_row_id resolves to the synced investor ──
|
||||||
|
print("\n[create -> reminder: source_row_id resolves to the new investor]")
|
||||||
|
st, d = _req(port, "POST", "/api/reminders", token, {
|
||||||
|
"source_row_id": acme_id, "investor_name": "Acme Capital",
|
||||||
|
"title": "Send Fund III deck", "due_date": "2026-07-01", "details": "",
|
||||||
|
})
|
||||||
|
rem = (d or {}).get("data") or {}
|
||||||
|
check(st == 201, f"reminder create -> 201 (got {st})")
|
||||||
|
check(bool(rem.get("investor_id")) and rem.get("investor_name") == "Acme Capital",
|
||||||
|
f"reminder linked to the new investor (got id={rem.get('investor_id')!r}, "
|
||||||
|
f"name={rem.get('investor_name')!r})")
|
||||||
|
|
||||||
|
# ── unknown source_row_id is refused (guard) ──
|
||||||
|
print("\n[guard: reminder on an unknown source_row_id -> 404]")
|
||||||
|
st, _ = _req(port, "POST", "/api/reminders", token, {
|
||||||
|
"source_row_id": "nope", "title": "x",
|
||||||
|
})
|
||||||
|
check(st == 404, f"unknown source_row_id -> 404 (got {st})")
|
||||||
|
finally:
|
||||||
|
httpd.shutdown()
|
||||||
|
|
||||||
|
print("\n" + ("ALL PASS" if not FAILS else f"{len(FAILS)} FAILURE(S):"))
|
||||||
|
for f in FAILS:
|
||||||
|
print(" - " + f)
|
||||||
|
sys.exit(1 if FAILS else 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+90
-6
@@ -2567,6 +2567,26 @@
|
|||||||
.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; }
|
||||||
|
|
||||||
|
/* 8g add-investor: optional initial-stage chip picker + framed Priority toggle (dc GridApp:737).
|
||||||
|
Stage tint comes from <StageChip>; the framed button just carries the selection ring. */
|
||||||
|
.stage-pick { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||||
|
.stage-pick-btn {
|
||||||
|
flex: 0 0 auto; min-height: var(--mobile-touch-target); padding: 6px 12px;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--mobile-control-radius);
|
||||||
|
color: var(--text-secondary); font-size: 13px; font-family: inherit; cursor: pointer;
|
||||||
|
}
|
||||||
|
/* StageChip carries its own fill, so lean on a clear accent ring (not just bg) for the selected state. */
|
||||||
|
.stage-pick-btn.active { border-color: var(--accent); background: var(--accent-soft); box-shadow: 0 0 0 1px var(--accent); }
|
||||||
|
.sheet-toggle-opt {
|
||||||
|
width: 100%; min-height: var(--mobile-touch-target); gap: 12px; padding: 0 14px;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--mobile-control-radius);
|
||||||
|
color: var(--text-primary); font-size: 14px; font-family: inherit; cursor: pointer;
|
||||||
|
}
|
||||||
|
.sheet-toggle-opt.on { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-light); }
|
||||||
|
.sheet-toggle-opt .check { flex: none; width: 16px; text-align: center; color: var(--accent); font-size: 15px; }
|
||||||
|
|
||||||
/* P3b — mobile contact-pill editor (the 'edit' sheet: investor name + add/edit/remove pills). */
|
/* P3b — mobile contact-pill editor (the 'edit' sheet: investor name + add/edit/remove pills). */
|
||||||
.fs-detail-edit { margin-left: auto; background: transparent; border: none; color: var(--accent); font-size: 15px; font-family: inherit; cursor: pointer; padding: 6px 0 6px 8px; }
|
.fs-detail-edit { margin-left: auto; background: transparent; border: none; color: var(--accent); font-size: 15px; font-family: inherit; cursor: pointer; padding: 6px 0 6px 8px; }
|
||||||
.pill-edit { background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--mobile-control-radius); padding: 10px 12px; margin-bottom: 10px; }
|
.pill-edit { background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--mobile-control-radius); padding: 10px 12px; margin-bottom: 10px; }
|
||||||
@@ -10059,7 +10079,7 @@
|
|||||||
const [sheet, setSheet] = useState(null); // 'view' | 'create' | 'note' | 'stage' | 'reminder' | 'sort'
|
const [sheet, setSheet] = useState(null); // 'view' | 'create' | 'note' | 'stage' | 'reminder' | 'sort'
|
||||||
const [sortKey, setSortKey] = useState('name'); // GRID_SORTS
|
const [sortKey, setSortKey] = useState('name'); // GRID_SORTS
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [createForm, setCreateForm] = useState({ name: '', contactName: '', contactEmail: '', note: '' });
|
const [createForm, setCreateForm] = useState({ name: '', contactName: '', contactEmail: '', note: '', priority: false, stage: '', reminderTitle: '', reminderDue: '' });
|
||||||
const [reminderForm, setReminderForm] = useState({ title: '', due_date: '', details: '' });
|
const [reminderForm, setReminderForm] = useState({ title: '', due_date: '', details: '' });
|
||||||
// G6 — investor-level communications timeline for the open detail. Fetched on open
|
// 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.
|
// (source_row_id → canonical contacts → communications); commsReload re-runs it after a log.
|
||||||
@@ -10235,15 +10255,50 @@
|
|||||||
try {
|
try {
|
||||||
// The one-row create path: log-communication finds-or-creates the investor + first
|
// The one-row create path: log-communication finds-or-creates the investor + first
|
||||||
// contact (no whole-grid PUT). append_note only if a first note was given (else the
|
// contact (no whole-grid PUT). append_note only if a first note was given (else the
|
||||||
// create just seeds name + contact).
|
// create just seeds name + contact). priority is honored on the create branch (8g).
|
||||||
const hasNote = !!String(createForm.note || '').trim();
|
const hasNote = !!String(createForm.note || '').trim();
|
||||||
await api('/api/fundraising/log-communication', { method: 'POST', body: JSON.stringify({
|
const resp = await api('/api/fundraising/log-communication', { method: 'POST', body: JSON.stringify({
|
||||||
investor_name: name, create_investor_if_missing: true,
|
investor_name: name, create_investor_if_missing: true,
|
||||||
contact: { name: cName, email: createForm.contactEmail.trim() },
|
contact: { name: cName, email: createForm.contactEmail.trim() },
|
||||||
type: 'note', body: createForm.note || '', append_note: hasNote,
|
type: 'note', body: createForm.note || '', append_note: hasNote,
|
||||||
|
priority: !!createForm.priority,
|
||||||
}) }, token);
|
}) }, token);
|
||||||
onShowToast('Investor added', 'success');
|
const newRowId = resp && resp.data && resp.data.row && resp.data.row.id;
|
||||||
setCreateForm({ name: '', contactName: '', contactEmail: '', note: '' });
|
|
||||||
|
// Optional: drop the brand-new investor into the pipeline at the chosen stage (8g).
|
||||||
|
// Reuses the link-then-enforce flow applyStage uses (link is idempotent + may keep an
|
||||||
|
// existing stage, so a follow-up PATCH pins the picked stage). The relational sync ran
|
||||||
|
// inside the create above, so source_row_id already resolves for the link. Non-fatal:
|
||||||
|
// a link failure must not lose the just-created investor.
|
||||||
|
if (newRowId && createForm.stage) {
|
||||||
|
try {
|
||||||
|
const linkResp = await api('/api/fundraising/pipeline/link', { method: 'POST', body: JSON.stringify({
|
||||||
|
source_row_id: newRowId, contact_index: 0, name: `${name} — Pipeline`,
|
||||||
|
stage: createForm.stage, expected_amount: 0, probability: createForm.priority ? 55 : 35, fund_name: '',
|
||||||
|
}) }, token);
|
||||||
|
const opp = linkResp && linkResp.data;
|
||||||
|
if (opp && opp.id && opp.stage !== createForm.stage) {
|
||||||
|
await api(`/api/opportunities/${opp.id}/stage`, { method: 'PATCH', body: JSON.stringify({ stage: createForm.stage }) }, token);
|
||||||
|
}
|
||||||
|
} catch (_) { onShowToast('Investor added — but adding it to the pipeline failed', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional: set a follow-up reminder linked to the new investor row (8g, Grant).
|
||||||
|
if (newRowId && createForm.reminderTitle.trim()) {
|
||||||
|
try {
|
||||||
|
await api('/api/reminders', { method: 'POST', body: JSON.stringify({
|
||||||
|
source_row_id: newRowId, investor_name: name,
|
||||||
|
title: createForm.reminderTitle.trim(), due_date: createForm.reminderDue || '', details: '',
|
||||||
|
}) }, token);
|
||||||
|
} catch (_) { onShowToast('Investor added — but setting the reminder failed', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// The create itself succeeded; if its response carried no row id we couldn't run the
|
||||||
|
// optional stage/reminder steps — say so rather than a clean success the user would trust.
|
||||||
|
const incomplete = !newRowId && (!!createForm.stage || !!createForm.reminderTitle.trim());
|
||||||
|
onShowToast(incomplete ? 'Investor added — reopen it to set the stage / reminder' : 'Investor added',
|
||||||
|
incomplete ? 'error' : 'success');
|
||||||
|
setCreateForm({ name: '', contactName: '', contactEmail: '', note: '', priority: false, stage: '', reminderTitle: '', reminderDue: '' });
|
||||||
closeSheet();
|
closeSheet();
|
||||||
await reload(true);
|
await reload(true);
|
||||||
} catch (err) { onShowToast(getErrorMessage(err, 'Failed to add investor'), 'error'); }
|
} catch (err) { onShowToast(getErrorMessage(err, 'Failed to add investor'), 'error'); }
|
||||||
@@ -10326,7 +10381,7 @@
|
|||||||
<span className="vp-name">{activeViewName}</span>
|
<span className="vp-name">{activeViewName}</span>
|
||||||
<span className="vp-caret">▾</span>
|
<span className="vp-caret">▾</span>
|
||||||
</button>
|
</button>
|
||||||
<button className="grid-new-btn" onClick={() => { setCreateForm({ name: '', contactName: '', contactEmail: '', note: '' }); setSheet('create'); }}>+ New</button>
|
<button className="grid-new-btn" onClick={() => { setCreateForm({ name: '', contactName: '', contactEmail: '', note: '', priority: false, stage: '', reminderTitle: '', reminderDue: '' }); setSheet('create'); }}>+ New</button>
|
||||||
</div>
|
</div>
|
||||||
<input className="mobile-search" type="text" placeholder="Search investors…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
<input className="mobile-search" type="text" placeholder="Search investors…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
<div className="mobile-sortbar">
|
<div className="mobile-sortbar">
|
||||||
@@ -10380,6 +10435,35 @@
|
|||||||
<label className="sheet-field-label">First note (optional)</label>
|
<label className="sheet-field-label">First note (optional)</label>
|
||||||
<textarea className="sheet-textarea" value={createForm.note} onChange={(e) => setCreateForm((f) => ({ ...f, note: e.target.value }))} placeholder="How you met, context…" />
|
<textarea className="sheet-textarea" value={createForm.note} onChange={(e) => setCreateForm((f) => ({ ...f, note: e.target.value }))} placeholder="How you met, context…" />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="sheet-field">
|
||||||
|
<label className="sheet-field-label">Initial pipeline stage (optional)</label>
|
||||||
|
<div className="stage-pick">
|
||||||
|
<button type="button" className={`stage-pick-btn ${createForm.stage === '' ? 'active' : ''}`} onClick={() => setCreateForm((f) => ({ ...f, stage: '' }))}>Not in pipeline</button>
|
||||||
|
{PIPELINE_STAGES.map((st) => (
|
||||||
|
<button type="button" key={st} className={`stage-pick-btn ${createForm.stage === st ? 'active' : ''}`} onClick={() => setCreateForm((f) => ({ ...f, stage: st }))}>
|
||||||
|
<StageChip stage={st} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sheet-field">
|
||||||
|
<label className="sheet-field-label">Disposition</label>
|
||||||
|
<button type="button" className={`sheet-toggle-opt ${createForm.priority ? 'on' : ''}`} aria-pressed={createForm.priority ? 'true' : 'false'}
|
||||||
|
onClick={() => setCreateForm((f) => ({ ...f, priority: !f.priority }))}>
|
||||||
|
<span>Flag as Priority</span>
|
||||||
|
<span className="check">{createForm.priority ? '✓' : ''}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="sheet-field">
|
||||||
|
<label className="sheet-field-label">Reminder (optional)</label>
|
||||||
|
<input className="sheet-input" value={createForm.reminderTitle} onChange={(e) => setCreateForm((f) => ({ ...f, reminderTitle: e.target.value }))} placeholder="e.g. Send Fund III deck" />
|
||||||
|
</div>
|
||||||
|
{createForm.reminderTitle.trim() && (
|
||||||
|
<div className="sheet-field">
|
||||||
|
<label className="sheet-field-label">Reminder due date</label>
|
||||||
|
<input className="sheet-input" type="date" value={createForm.reminderDue} onChange={(e) => setCreateForm((f) => ({ ...f, reminderDue: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<button className="sheet-submit" onClick={submitCreate} disabled={busy}>{busy ? 'Adding…' : 'Add investor'}</button>
|
<button className="sheet-submit" onClick={submitCreate} disabled={busy}>{busy ? 'Adding…' : 'Add investor'}</button>
|
||||||
</BottomSheet>
|
</BottomSheet>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user