Mobile UX batch 1: clear buttons, tappable contacts, pipeline swipe + amounts, keyboard-safe sheets (v0.1.0:101)
Grant device feedback, frontend-only (CSS + React); no backend, schema,
migration, or dependency change.
- Clear (×) button on the Grid/Contacts search + reminder/quick-log investor
pickers (shared ClearableInput; the × shows only when there's text).
- Grid investor-detail contact pills are tappable: name deep-links to the
Contacts detail (new Grid→Contacts one-shot action, matched by email then
name), email opens the mail app (mailto:).
- Grid contact-name search already surfaced the investor — verified, no change.
- Mobile Pipeline is a full-height flex column so the whole area above the now
bottom-pinned dots is the swipe target; each stage page scrolls its cards.
- Expected-amount entry: optional amount when adding to the pipeline from the
Grid detail (feeds pipeline/link), and an editable amount on the Pipeline
card detail (PUT /api/opportunities/{id}).
- Bottom sheets lift above the on-screen keyboard (visualViewport) and cap
their height to the visible area, so the reminder picker results stay visible.
This commit is contained in:
+167
-17
@@ -2249,6 +2249,19 @@
|
||||
font-size: var(--mobile-font-body); font-family: inherit; padding: 0 12px;
|
||||
}
|
||||
.mobile-search:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-soft); }
|
||||
/* Inline clear (✕) for single-line search/picker fields (ClearableInput). The wrapper
|
||||
keeps the field full-width; the field reserves right padding so text never sits under
|
||||
the button, which only renders when there's text. */
|
||||
.input-clear-wrap { position: relative; display: block; width: 100%; }
|
||||
.input-clear-wrap input.has-clear { padding-right: 40px; }
|
||||
.input-clear-btn {
|
||||
position: absolute; top: 50%; right: 7px; transform: translateY(-50%);
|
||||
width: 26px; height: 26px; border-radius: 50%; border: none; padding: 0;
|
||||
background: var(--bg-hover); color: var(--text-muted);
|
||||
font-size: 17px; line-height: 1; cursor: pointer;
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.input-clear-btn:active { color: var(--text-primary); }
|
||||
.mobile-seg { display: flex; gap: 6px; }
|
||||
.mobile-seg-tab {
|
||||
flex: 1; min-height: var(--mobile-touch-target);
|
||||
@@ -2624,8 +2637,14 @@
|
||||
}
|
||||
.fs-action-btn:active { background: var(--bg-hover); }
|
||||
.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-name { font-size: var(--mobile-font-body); color: var(--text-primary); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.fs-pill-email { font-family: 'IBM Plex Mono', monospace; font-size: 12px; color: var(--text-muted); margin-top: 2px; word-break: break-word; }
|
||||
/* #2 — tappable contact pill: name button (→ contact detail) + mailto email link. */
|
||||
.fs-pill-name-btn { width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 10px; background: none; border: none; padding: 0; font-family: inherit; text-align: left; cursor: pointer; color: inherit; }
|
||||
.fs-pill-name-btn:active .fs-pill-name { color: var(--accent-light); }
|
||||
.fs-pill-go { flex: none; font-family: 'IBM Plex Mono', monospace; font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase; color: var(--accent-light); }
|
||||
.fs-pill-mail { display: inline-block; color: var(--accent-light); text-decoration: none; }
|
||||
.fs-pill-mail:active { text-decoration: underline; }
|
||||
/* G4/G5 — tappable detail cards (Grid full-screen detail): single-tap stage + reminder
|
||||
cards matching the dc stage/reminder anatomy (panel card, inline chip/note, chevron). */
|
||||
.detail-tap-card {
|
||||
@@ -2726,13 +2745,23 @@
|
||||
.pipeline-seg-tab.active { background: var(--seg-bg); border-color: var(--seg-border); color: var(--seg-text); }
|
||||
.pipeline-seg-tab.active .pipeline-seg-count { background: var(--seg-border); color: var(--seg-text); }
|
||||
|
||||
/* Full-height pipeline layout (#4a) — the screen is a flex column filling .content, so the
|
||||
swipe area (flex:1) grows to cover everything above the bottom-pinned dots; an entire
|
||||
stage page (including the empty space below its cards) becomes the horizontal-swipe target,
|
||||
and each page scrolls its own cards vertically. Scoped to the pipeline (other mobile
|
||||
surfaces keep their natural grow-and-let-.content-scroll behavior). */
|
||||
.pipeline-screen { height: 100%; display: flex; flex-direction: column; min-height: 0; }
|
||||
.pipeline-swipe {
|
||||
flex: 1 1 auto; min-height: 0;
|
||||
display: flex; overflow-x: auto; scroll-snap-type: x mandatory;
|
||||
-webkit-overflow-scrolling: touch; scroll-behavior: smooth;
|
||||
scrollbar-width: none; margin-top: 14px;
|
||||
}
|
||||
.pipeline-swipe::-webkit-scrollbar { display: none; }
|
||||
.pipeline-stage-page { flex: 0 0 100%; width: 100%; box-sizing: border-box; scroll-snap-align: start; padding: 0 1px; }
|
||||
.pipeline-stage-page {
|
||||
flex: 0 0 100%; width: 100%; box-sizing: border-box; scroll-snap-align: start;
|
||||
padding: 0 1px 8px; overflow-y: auto; -webkit-overflow-scrolling: touch;
|
||||
}
|
||||
/* Stage-column header (P6): stage chip + investor count + committed sum */
|
||||
.pipeline-page-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 4px 2px 12px; }
|
||||
.pipeline-page-head-left { display: flex; align-items: center; gap: 9px; min-width: 0; }
|
||||
@@ -2775,7 +2804,17 @@
|
||||
.stage-move-btn.back { border-right: 1px solid var(--divider); color: var(--text-muted); }
|
||||
.stage-move-btn:disabled { color: var(--text-subtle); opacity: 0.4; cursor: default; }
|
||||
.stage-move-btn:active:not(:disabled) { background: var(--bg-hover); }
|
||||
.pipeline-dots { display: flex; justify-content: center; align-items: center; gap: 9px; padding: 8px 0 4px; }
|
||||
.pipeline-dots { flex: none; display: flex; justify-content: center; align-items: center; gap: 9px; padding: 10px 0 2px; }
|
||||
/* #4b — inline amount editor (field + Save) in the pipeline card detail. */
|
||||
.amount-edit-row { display: flex; gap: 8px; align-items: stretch; }
|
||||
.amount-edit-row .sheet-input { flex: 1; min-width: 0; }
|
||||
.amount-save-btn {
|
||||
flex: none; padding: 0 18px; height: var(--mobile-input-h);
|
||||
border: none; border-radius: var(--mobile-control-radius);
|
||||
background: linear-gradient(180deg, var(--accent) 0%, var(--accent-strong) 100%);
|
||||
color: #fff; font-size: 14px; font-weight: 600; font-family: inherit; cursor: pointer;
|
||||
}
|
||||
.amount-save-btn:disabled { opacity: 0.5; cursor: default; }
|
||||
.pipeline-dot-btn { background: none; border: none; cursor: pointer; padding: 7px 3px; display: flex; align-items: center; justify-content: center; }
|
||||
.pipeline-dot { width: 6px; height: 6px; border-radius: 999px; background: var(--border-strong); transition: width 0.15s ease, background 0.15s ease; }
|
||||
.pipeline-dot.active { width: 22px; background: var(--accent); }
|
||||
@@ -4233,6 +4272,24 @@
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Lift the sheet above the on-screen keyboard (#5). iOS leaves a fixed bottom:0 element
|
||||
// behind the keyboard, hiding inputs/results near the sheet's bottom (the reminder
|
||||
// investor picker). visualViewport gives the keyboard height; we offset `bottom` by it
|
||||
// and cap max-height to the visible area so the sheet body scrolls instead of clipping.
|
||||
const [kb, setKb] = useState({ inset: 0, vh: 0 });
|
||||
useEffect(() => {
|
||||
const vv = window.visualViewport;
|
||||
if (!open || !vv) { setKb({ inset: 0, vh: 0 }); return undefined; }
|
||||
const onResize = () => {
|
||||
const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
|
||||
setKb({ inset, vh: vv.height });
|
||||
};
|
||||
onResize();
|
||||
vv.addEventListener('resize', onResize);
|
||||
vv.addEventListener('scroll', onResize);
|
||||
return () => { vv.removeEventListener('resize', onResize); vv.removeEventListener('scroll', onResize); };
|
||||
}, [open]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
const onPointerDown = (e) => {
|
||||
@@ -4252,7 +4309,10 @@
|
||||
if (shouldClose) onClose();
|
||||
};
|
||||
|
||||
const sheetStyle = dragY > 0 ? { transform: `translateY(${dragY}px)`, transition: 'none' } : undefined;
|
||||
const kbStyle = kb.inset > 0 ? { bottom: `${kb.inset}px`, maxHeight: `${Math.max(220, kb.vh - 8)}px` } : null;
|
||||
const sheetStyle = (kbStyle || dragY > 0)
|
||||
? { ...(kbStyle || {}), ...(dragY > 0 ? { transform: `translateY(${dragY}px)`, transition: 'none' } : {}) }
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -4300,6 +4360,22 @@
|
||||
);
|
||||
};
|
||||
|
||||
// Single-line input with an inline ✕ to clear it (mobile search + picker fields). The clear
|
||||
// button only renders when there's text; onMouseDown-preventDefault keeps the field focused
|
||||
// (and the keyboard up) so a clear-then-retype flow doesn't dismiss the keyboard. Reuses the
|
||||
// field's own className (.mobile-search / .sheet-input) for styling; passes the rest through.
|
||||
const ClearableInput = ({ value, onClear, className, ...rest }) => (
|
||||
<div className="input-clear-wrap">
|
||||
{/* has-clear (the right padding for the ✕) is added only when there's text, so an
|
||||
empty field keeps symmetric padding — the reservation appears with the button. */}
|
||||
<input className={`${className || ''}${value ? ' has-clear' : ''}`} value={value} {...rest} />
|
||||
{value ? (
|
||||
<button type="button" className="input-clear-btn" aria-label="Clear"
|
||||
tabIndex={-1} onMouseDown={(e) => e.preventDefault()} onClick={onClear}>×</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
/* ─── Shared grid helpers (Phase 3 — mobile Fundraising Grid) ─────────────────────
|
||||
Pure functions so the mobile card list filters rows the SAME way the desktop grid's
|
||||
@@ -5451,7 +5527,7 @@
|
||||
{/* 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 />
|
||||
<ClearableInput className="sheet-input" value={investorQuery} onChange={(e) => setInvestorQuery(e.target.value)} onClear={() => setInvestorQuery('')} placeholder="Search investor…" autoFocus />
|
||||
<div className="rem-investor-list">
|
||||
<button type="button" className="sheet-option" onClick={() => pickInvestor(null)}>
|
||||
<span>No investor — team task</span>
|
||||
@@ -5820,7 +5896,7 @@
|
||||
);
|
||||
};
|
||||
|
||||
const MobileContactsPage = ({ token, onShowToast, onOpenInGrid }) => {
|
||||
const MobileContactsPage = ({ token, onShowToast, onOpenInGrid, uiAction, onUiActionHandled }) => {
|
||||
const [contacts, setContacts] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
@@ -5888,6 +5964,21 @@
|
||||
return Array.from(m.entries());
|
||||
}, [filtered, sort]);
|
||||
|
||||
// Grid→Contacts deep-link (#2): once the directory has loaded, open the matching contact's
|
||||
// detail by email (preferred) or name; if there's no exact match, fall back to filtering the
|
||||
// list. One-shot — cleared via onUiActionHandled so it can't re-fire on later renders.
|
||||
useEffect(() => {
|
||||
if (!uiAction || uiAction.type !== 'open-contact' || loading) return;
|
||||
const email = (uiAction.email || '').trim().toLowerCase();
|
||||
const name = (uiAction.name || '').trim().toLowerCase();
|
||||
let match = null;
|
||||
if (email) match = contacts.find((c) => (c.email || '').trim().toLowerCase() === email);
|
||||
if (!match && name) match = contacts.find((c) => displayName(c).trim().toLowerCase() === name);
|
||||
if (match) setSelected(match);
|
||||
else setSearch(uiAction.email || uiAction.name || '');
|
||||
if (onUiActionHandled) onUiActionHandled();
|
||||
}, [uiAction, loading, contacts, onUiActionHandled]);
|
||||
|
||||
const renderCard = (c) => {
|
||||
const org = c.organization || c.organization_name || '';
|
||||
// Existing-LP ring + stage pill come from the grid signals the API injects (committed,
|
||||
@@ -5917,12 +6008,13 @@
|
||||
<div className="mobile-screen">
|
||||
<div className="mobile-caption">Read-only directory — people are added and edited from the Fundraising Grid.</div>
|
||||
<div className="mobile-toolbar">
|
||||
<input
|
||||
<ClearableInput
|
||||
className="mobile-search"
|
||||
type="text"
|
||||
placeholder="Search contacts…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onClear={() => setSearch('')}
|
||||
/>
|
||||
<div className="mobile-sortbar">
|
||||
<span className="mobile-count">{filtered.length} {filtered.length === 1 ? 'contact' : 'contacts'}</span>
|
||||
@@ -6520,6 +6612,7 @@
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [sortKey, setSortKey] = useState('name'); // PIPELINE_SORTS — applied within each stage
|
||||
const [sortOpen, setSortOpen] = useState(false);
|
||||
const [amountDraft, setAmountDraft] = useState(''); // #4b — editable expected amount in the detail
|
||||
const swipeRef = useRef(null);
|
||||
|
||||
const stages = PIPELINE_STAGES;
|
||||
@@ -6567,6 +6660,12 @@
|
||||
const openDetail = (oppId) => { setSelectedId(oppId); setContactDetail(null); setDetailOpen(true); };
|
||||
const closeDetail = () => { setDetailOpen(false); setLogOpen(false); setTimeout(() => setSelectedId(null), 280); };
|
||||
|
||||
// #4b — seed the amount field when a deal's detail opens. Keyed on selectedId so a
|
||||
// post-save opportunities refresh (same id) doesn't clobber what's typed; 0 shows blank.
|
||||
useEffect(() => {
|
||||
if (selectedOpp) setAmountDraft(selectedOpp.expected_amount ? String(selectedOpp.expected_amount) : '');
|
||||
}, [selectedId]);
|
||||
|
||||
// Pull the linked contact's communications (+ committed) for the detail sheet's
|
||||
// notes timeline / stat tiles. Non-fatal on failure — the opp fields still render.
|
||||
// A `cancelled` guard drops a stale response so rapidly opening card A then B can't
|
||||
@@ -6598,6 +6697,21 @@
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
// #4b — persist the edited expected amount via the shared opportunities PUT (the field
|
||||
// allowlist includes expected_amount; authenticated, not admin-only). Updates the local
|
||||
// list so the card amount + stage totals reflect it without a refetch.
|
||||
const saveAmount = async () => {
|
||||
const opp = selectedOpp; if (!opp) return;
|
||||
const val = parseNumericInput(amountDraft);
|
||||
setBusy(true);
|
||||
try {
|
||||
await api(`/api/opportunities/${opp.id}`, { method: 'PUT', body: JSON.stringify({ expected_amount: val }) }, token);
|
||||
setOpportunities((os) => os.map((o) => (o.id === opp.id ? { ...o, expected_amount: val } : o)));
|
||||
onShowToast('Amount updated', 'success');
|
||||
} catch (err) { onShowToast(getErrorMessage(err, 'Failed to update amount'), 'error'); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
// Log a communication against the open opp's contact (POST /api/communications), then
|
||||
// refresh the timeline. Same write the Contacts detail uses; carries opportunity_id here.
|
||||
const submitLog = async ({ type, subject, body }) => {
|
||||
@@ -6668,7 +6782,7 @@
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mobile-screen">
|
||||
<div className="mobile-screen pipeline-screen">
|
||||
{loading ? (
|
||||
<SkeletonBlock lines={8} />
|
||||
) : error ? (
|
||||
@@ -6764,6 +6878,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* #4b — editable expected amount (writes the shared opportunities row). */}
|
||||
<div className="sheet-field" style={{ marginTop: '18px' }}>
|
||||
<label className="sheet-field-label">Expected amount</label>
|
||||
<div className="amount-edit-row">
|
||||
<input className="sheet-input" type="text" inputMode="numeric" value={amountDraft}
|
||||
onChange={(e) => setAmountDraft(e.target.value)} placeholder="e.g. 250000" />
|
||||
<button className="amount-save-btn" disabled={busy || parseNumericInput(amountDraft) === (Number(opp.expected_amount) || 0)}
|
||||
onClick={saveAmount}>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fs-section-label" style={{ margin: '18px 0 9px' }}>Move stage</div>
|
||||
<div className="move-stage-list">
|
||||
{stages.map((st) => (
|
||||
@@ -6788,7 +6913,7 @@
|
||||
)}
|
||||
|
||||
{dealParts && <div className="sheet-footnote">{dealParts}</div>}
|
||||
<div className="sheet-footnote">Stage moves and logged communications both write the shared opportunities row — the same data the Grid edits. Amounts stay read-only on mobile.</div>
|
||||
<div className="sheet-footnote">Stage moves, amount edits, and logged communications all write the shared opportunities row — the same data the Grid edits.</div>
|
||||
|
||||
<LogCommunicationSheet open={logOpen} onClose={() => setLogOpen(false)} onSubmit={submitLog} busy={busy} forLabel={opp.name} />
|
||||
</BottomSheet>
|
||||
@@ -10208,7 +10333,7 @@
|
||||
// reminders), NEVER the whole-grid PUT (BRIEF §3a — that would race the 5-person live grid).
|
||||
// Editable here: create investor, edit name + contact pills (P3b, via update-row), log a
|
||||
// note, pipeline stage, set a reminder. Money amounts stay desktop-only (read-only here).
|
||||
const MobileFundraisingGrid = ({ user, token, onShowToast, views, activeView, setActiveView, uiAction, onUiActionHandled }) => {
|
||||
const MobileFundraisingGrid = ({ user, token, onShowToast, views, activeView, setActiveView, uiAction, onUiActionHandled, onOpenContact }) => {
|
||||
const [columns, setColumns] = useState([]);
|
||||
const [rows, setRows] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -10221,6 +10346,9 @@
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [createForm, setCreateForm] = useState({ name: '', contactName: '', contactEmail: '', note: '', priority: false, stage: '', reminderTitle: '', reminderDue: '' });
|
||||
const [reminderForm, setReminderForm] = useState({ title: '', due_date: '', details: '' });
|
||||
// #4b — optional expected amount captured when ADDING this investor to the pipeline
|
||||
// (the stage sheet). Existing deals edit their amount on the Pipeline card detail.
|
||||
const [stageAmount, setStageAmount] = useState('');
|
||||
// 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([]);
|
||||
@@ -10374,7 +10502,7 @@
|
||||
// stage — so enforce the picked stage with a follow-up PATCH on the returned opp.
|
||||
const resp = await api('/api/fundraising/pipeline/link', { method: 'POST', body: JSON.stringify({
|
||||
source_row_id: row.id, contact_index: 0, name: `${row.investor_name || 'Investor'} — Pipeline`,
|
||||
stage, expected_amount: 0, probability: row.priority ? 55 : 35, fund_name: '',
|
||||
stage, expected_amount: parseNumericInput(stageAmount), probability: row.priority ? 55 : 35, fund_name: '',
|
||||
}) }, token);
|
||||
const opp = resp && resp.data;
|
||||
if (opp && opp.id && opp.stage !== stage) {
|
||||
@@ -10571,7 +10699,7 @@
|
||||
</button>
|
||||
<button className="grid-new-btn" onClick={() => { setCreateForm({ name: '', contactName: '', contactEmail: '', note: '', priority: false, stage: '', reminderTitle: '', reminderDue: '' }); setSheet('create'); }}>+ New</button>
|
||||
</div>
|
||||
<input className="mobile-search" type="text" placeholder="Search investors…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||
<ClearableInput className="mobile-search" type="text" placeholder="Search investors…" value={search} onChange={(e) => setSearch(e.target.value)} onClear={() => setSearch('')} />
|
||||
<div className="mobile-sortbar">
|
||||
<span className="mobile-count">{displayed.length} {displayed.length === 1 ? 'investor' : 'investors'}</span>
|
||||
<SortPill label={sortPillLabel(GRID_SORTS, sortKey)} onClick={() => setSheet('sort')} />
|
||||
@@ -10686,7 +10814,7 @@
|
||||
stage chip inline (or "Not in pipeline") + chevron → stage sheet. */}
|
||||
<div className="fs-section">
|
||||
<div className="fs-section-label">Pipeline stage</div>
|
||||
<button className="detail-tap-card" onClick={() => setSheet('stage')}>
|
||||
<button className="detail-tap-card" onClick={() => { setStageAmount(''); setSheet('stage'); }}>
|
||||
<span className="detail-tap-card-left">
|
||||
{row.pipeline ? <StageChip stage={row.pipeline_stage} /> : <span className="detail-tap-card-empty">Not in pipeline</span>}
|
||||
</span>
|
||||
@@ -10707,8 +10835,12 @@
|
||||
<div className="fs-section-label">Contacts</div>
|
||||
{contacts.length ? contacts.map((c, i) => (
|
||||
<div className="fs-pill" key={i}>
|
||||
<div className="fs-pill-name">{c.name || '—'}</div>
|
||||
{c.email && <div className="fs-pill-email">{c.email}</div>}
|
||||
{/* #2 — name jumps to the contact's directory detail; email opens the mail app. */}
|
||||
<button type="button" className="fs-pill-name-btn" onClick={() => onOpenContact && onOpenContact(c)}>
|
||||
<span className="fs-pill-name">{c.name || '—'}</span>
|
||||
<span className="fs-pill-go">View ›</span>
|
||||
</button>
|
||||
{c.email && <a className="fs-pill-email fs-pill-mail" href={`mailto:${c.email}`}>{c.email}</a>}
|
||||
</div>
|
||||
)) : <div style={{ color: 'var(--text-subtle)', fontSize: '13px' }}>No contacts yet.</div>}
|
||||
</div>
|
||||
@@ -10761,6 +10893,14 @@
|
||||
<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">
|
||||
{!row.pipeline && (
|
||||
<div className="sheet-field">
|
||||
<label className="sheet-field-label">Expected amount (optional)</label>
|
||||
<input className="sheet-input" type="text" inputMode="numeric" value={stageAmount}
|
||||
onChange={(e) => setStageAmount(e.target.value)} placeholder="e.g. 250000" />
|
||||
<div className="sheet-subcaption" style={{ marginTop: '6px' }}>Pick a stage below to add this investor to the pipeline. You can edit the amount later from the Pipeline card.</div>
|
||||
</div>
|
||||
)}
|
||||
{PIPELINE_STAGES.map((st) => (
|
||||
<button key={st} className={`sheet-option ${row.pipeline_stage === st ? 'active' : ''}`} onClick={() => applyStage(st)} disabled={busy}>
|
||||
<span>{pipelineStageLabel(st)}</span>
|
||||
@@ -14522,7 +14662,7 @@
|
||||
{!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…" />
|
||||
<ClearableInput className="sheet-input" value={search} onChange={(e) => setSearch(e.target.value)} onClear={() => setSearch('')} 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>
|
||||
@@ -14594,6 +14734,7 @@
|
||||
const [gridViews, setGridViews] = useState(loadGridViews());
|
||||
const [activeGridView, setActiveGridView] = useState('view-main');
|
||||
const [gridUiAction, setGridUiAction] = useState(null);
|
||||
const [contactsUiAction, setContactsUiAction] = useState(null);
|
||||
// Open-in-Grid deep-link (8h) — shared by the Contacts, Pipeline, and Reminders detail
|
||||
// surfaces. Sets a one-shot grid action (an object the desktop grid's string-equality
|
||||
// branches ignore) and switches to the grid, which opens that investor's detail on mount.
|
||||
@@ -14601,6 +14742,14 @@
|
||||
if (rowId) setGridUiAction({ type: 'open-investor', rowId });
|
||||
setPage('fundraising-grid');
|
||||
};
|
||||
// Grid→Contacts deep-link (reverse of Open-in-Grid) — tapping a contact name in the mobile
|
||||
// Grid investor detail jumps to the Contacts surface and opens that person's detail, matched
|
||||
// by email (preferred) or name. One-shot; MobileContactsPage consumes it on load.
|
||||
const openContactDetail = (contact) => {
|
||||
if (!contact) return;
|
||||
setContactsUiAction({ type: 'open-contact', email: contact.email || '', name: contact.name || '' });
|
||||
setPage('contacts');
|
||||
};
|
||||
const [sidebarContextMenu, setSidebarContextMenu] = useState(null);
|
||||
const [draggingViewId, setDraggingViewId] = useState(null);
|
||||
const [dragOverViewId, setDragOverViewId] = useState(null);
|
||||
@@ -14902,10 +15051,11 @@
|
||||
setViews={setGridViews}
|
||||
uiAction={gridUiAction}
|
||||
onUiActionHandled={() => setGridUiAction(null)}
|
||||
onOpenContact={openContactDetail}
|
||||
/>
|
||||
)}
|
||||
{page === 'dashboard' && <DashboardPage token={token} />}
|
||||
{page === 'contacts' && <ContactsPage token={token} onShowToast={showToast} onOpenInGrid={openInvestorInGrid} />}
|
||||
{page === 'contacts' && <ContactsPage token={token} onShowToast={showToast} onOpenInGrid={openInvestorInGrid} uiAction={contactsUiAction} onUiActionHandled={() => setContactsUiAction(null)} />}
|
||||
{page === 'pipeline' && <PipelinePage token={token} onShowToast={showToast} onOpenInGrid={openInvestorInGrid} />}
|
||||
{page === 'reminders' && <RemindersPage token={token} user={user} onShowToast={showToast} onOpenInGrid={openInvestorInGrid} />}
|
||||
{page === 'communications' && <CommunicationsPage token={token} user={user} onShowToast={showToast} />}
|
||||
|
||||
@@ -65,8 +65,9 @@ export const PACKAGE_TITLE = 'Ten31 Database'
|
||||
// * 0.1.0:97 (mobile top-bar polish + native zoom behaviour. Viewport meta gains maximum-scale=1 + user-scalable=no: kills pinch-zoom AND the iOS auto-zoom-on-focus that jerked the page in on every <16px input tap [app-wide, not just login]; OS accessibility zoom still works. Top-bar account initial now flex-centered + dc-aligned [IBM Plex Mono, accent-light, 13px — was defaulting to inline/baseline, off-center]. Quick-log pencil bumped --text-muted→--text-secondary for real affordance [the dc t3 grey thin-outline read as empty next to the color sun emoji on-device]. CSS-only; no JS/schema change)
|
||||
// * 0.1.0:98 (business-card intake [Matrix bot] captures a contact's phone, mobile/cell, city + LinkedIn from a scanned card onto the contact record — cell to Mobile, office to Phone, fax skipped. Server half: _upsert_contact_from_fundraising now accepts phone+mobile on the contact dict [city+linkedin already worked]. The bot's transcription/extraction/card changes ship on the Spark [git pull + rebuild]. No schema change [contacts columns already exist]; no user-facing CRM change)
|
||||
// * 0.1.0:99 (Grant device-test round 2, CRM half: intake fuzzy match scores DISTINCTIVE tokens only [no more "Investment Group"/"Capital"/"Family Office" false look-alikes]; mobile grid "Last contact"/staleness sort is reversible; mobile Edit-investor prefills a contact's email [GET /api/fundraising/state heals a blank grid pill from the linked classic contact, fill-only]; mobile quick-log pencil icon renders [CSS sizing on the sole flex-child svg]. The Matrix intake thread-redaction change ships on the Spark, not here. No schema change; no migration)
|
||||
// * Current: 0.1.0:100 (In-app business-card intake [#7]: a mobile camera button [left of the quick-log pencil] takes/picks a card photo, downscales it client-side via <canvas> to JPEG, and POSTs to the new POST /api/intake/card — vision-transcribe + parse + fuzzy-match on the box [local VL via Spark Control, nothing to Claude], reusing the Matrix card flow's nio-free parse/spark core. An editable review sheet [proposal fields + existing-investor picker] writes via log-communication tagged source="app_card"; a human approves every write. No schema change; no migration; no new dependency)
|
||||
export const PACKAGE_VERSION = '0.1.0:100'
|
||||
// * 0.1.0:100 (In-app business-card intake [#7]: a mobile camera button [left of the quick-log pencil] takes/picks a card photo, downscales it client-side via <canvas> to JPEG, and POSTs to the new POST /api/intake/card — vision-transcribe + parse + fuzzy-match on the box [local VL via Spark Control, nothing to Claude], reusing the Matrix card flow's nio-free parse/spark core. An editable review sheet [proposal fields + existing-investor picker] writes via log-communication tagged source="app_card"; a human approves every write. No schema change; no migration; no new dependency)
|
||||
// * Current: 0.1.0:101 (Mobile UX batch 1 [Grant device feedback]: [1] inline ✕ clear button on the Grid/Contacts search + reminder/quick-log investor pickers [ClearableInput]; [2] Grid investor-detail contact pills are tappable — name deep-links to the Contacts detail [new Grid→Contacts one-shot action], email opens mailto; [4a] mobile Pipeline is a full-height flex column so the whole area above the now bottom-pinned dots is the swipe target, each stage page scrolling its cards; [4b] expected-amount entry — optional amount when adding to the pipeline from the Grid detail [feeds pipeline/link], editable amount on the Pipeline card detail [PUT /api/opportunities/{id}]; [5] bottom sheets lift above the on-screen keyboard [visualViewport] so the reminder investor-picker results stay visible. Grid contact-name search [#3] already worked. CSS+React only; no schema change; no migration; no new dependency)
|
||||
export const PACKAGE_VERSION = '0.1.0:101'
|
||||
|
||||
export const DATA_MOUNT_PATH = '/data'
|
||||
export const WEB_PORT = 8080
|
||||
|
||||
@@ -61,8 +61,9 @@ import { v_0_1_0_97 } from './v0.1.0.97'
|
||||
import { v_0_1_0_98 } from './v0.1.0.98'
|
||||
import { v_0_1_0_99 } from './v0.1.0.99'
|
||||
import { v_0_1_0_100 } from './v0.1.0.100'
|
||||
import { v_0_1_0_101 } from './v0.1.0.101'
|
||||
|
||||
export const versionGraph = VersionGraph.of({
|
||||
current: v_0_1_0_100,
|
||||
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72, v_0_1_0_73, v_0_1_0_74, v_0_1_0_75, v_0_1_0_76, v_0_1_0_77, v_0_1_0_78, v_0_1_0_79, v_0_1_0_80, v_0_1_0_81, v_0_1_0_82, v_0_1_0_83, v_0_1_0_84, v_0_1_0_85, v_0_1_0_86, v_0_1_0_87, v_0_1_0_88, v_0_1_0_89, v_0_1_0_90, v_0_1_0_91, v_0_1_0_92, v_0_1_0_93, v_0_1_0_94, v_0_1_0_95, v_0_1_0_96, v_0_1_0_97, v_0_1_0_98, v_0_1_0_99],
|
||||
current: v_0_1_0_101,
|
||||
other: [v_0_1_0_39, v_0_1_0_40, v_0_1_0_41, v_0_1_0_42, v_0_1_0_43, v_0_1_0_44, v_0_1_0_45, v_0_1_0_46, v_0_1_0_47, v_0_1_0_48, v_0_1_0_49, v_0_1_0_50, v_0_1_0_51, v_0_1_0_52, v_0_1_0_53, v_0_1_0_54, v_0_1_0_55, v_0_1_0_56, v_0_1_0_57, v_0_1_0_58, v_0_1_0_59, v_0_1_0_60, v_0_1_0_61, v_0_1_0_62, v_0_1_0_63, v_0_1_0_64, v_0_1_0_65, v_0_1_0_66, v_0_1_0_67, v_0_1_0_68, v_0_1_0_69, v_0_1_0_70, v_0_1_0_71, v_0_1_0_72, v_0_1_0_73, v_0_1_0_74, v_0_1_0_75, v_0_1_0_76, v_0_1_0_77, v_0_1_0_78, v_0_1_0_79, v_0_1_0_80, v_0_1_0_81, v_0_1_0_82, v_0_1_0_83, v_0_1_0_84, v_0_1_0_85, v_0_1_0_86, v_0_1_0_87, v_0_1_0_88, v_0_1_0_89, v_0_1_0_90, v_0_1_0_91, v_0_1_0_92, v_0_1_0_93, v_0_1_0_94, v_0_1_0_95, v_0_1_0_96, v_0_1_0_97, v_0_1_0_98, v_0_1_0_99, v_0_1_0_100],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { VersionInfo } from '@start9labs/start-sdk'
|
||||
|
||||
// Mobile UX batch 1 — Grant device feedback. Frontend-only (CSS + React); no backend, schema,
|
||||
// migration, or dependency change.
|
||||
// - [1] Inline ✕ clear button on the Grid/Contacts search + the reminder/quick-log investor
|
||||
// pickers (a shared ClearableInput; the ✕ shows only when there's text).
|
||||
// - [2] Grid investor-detail contact pills are tappable: the name deep-links to the Contacts
|
||||
// detail (a new Grid→Contacts one-shot action, matched by email then name), the email
|
||||
// opens the mail app (mailto:).
|
||||
// - [3] (already worked) Grid search already matches a contact's name/email, surfacing the
|
||||
// investor — verified, no change.
|
||||
// - [4a] Mobile Pipeline is a full-height flex column: the swipe area fills everything above the
|
||||
// now bottom-pinned page dots, so a horizontal swipe anywhere switches stages; each stage
|
||||
// page scrolls its own cards.
|
||||
// - [4b] Expected-amount entry: an optional amount when adding an investor to the pipeline from
|
||||
// the Grid detail (feeds pipeline/link), and an editable amount on the Pipeline card detail
|
||||
// (PUT /api/opportunities/{id} — authenticated, expected_amount is in the field allowlist).
|
||||
// - [5] Bottom sheets lift above the on-screen keyboard (visualViewport) and cap their height to
|
||||
// the visible area, so the reminder investor-picker results are no longer hidden.
|
||||
export const v_0_1_0_101 = VersionInfo.of({
|
||||
version: '0.1.0:101',
|
||||
releaseNotes: {
|
||||
en_US: [
|
||||
'Mobile polish: clear (✕) buttons on search fields, tappable contacts in the Grid detail',
|
||||
'(name → contact, email → mail app), a full-screen swipe area on the Pipeline with the dots',
|
||||
'pinned to the bottom, expected-amount entry when adding/viewing a pipeline deal, and',
|
||||
'investor-picker suggestions that stay visible above the keyboard.',
|
||||
].join(' '),
|
||||
},
|
||||
migrations: { up: async () => {}, down: async () => {} },
|
||||
})
|
||||
Reference in New Issue
Block a user