actual state

This commit is contained in:
2026-07-29 14:50:11 -05:00
parent c9202d0b80
commit 96a37a495e
10 changed files with 561 additions and 117 deletions

View File

@@ -113,9 +113,10 @@ export default function App() {
</main>
) : (
<main
// The inbox table carries eight columns and needs the extra width.
// The inbox table carries eight columns, and the buyer detail is a
// two-column grid — both need more than the default reading width.
className={`mx-auto w-full flex-1 overflow-auto px-6 py-6 ${
route.view === 'nda-inbox' ? 'max-w-7xl' : 'max-w-5xl'
route.view === 'nda-inbox' || route.view === 'buyer' ? 'max-w-7xl' : 'max-w-5xl'
}`}
>
{route.view === 'today' && (

View File

@@ -92,6 +92,7 @@ export interface Deal {
status: DealStatus;
follow_up_at: Day | null;
note_count: number;
todo_count: number;
business: { id: string; name: string; status: BusinessStatus };
}
@@ -287,10 +288,21 @@ export interface RefreshResult {
pages: number;
seen: number;
stored: number;
/** Of `stored`, how many used the pre-rename title format. */
legacy_stored: number;
}
export interface InboxCounts {
pending: number;
signed: number;
declined: number;
all: number;
}
export interface Inbox {
requests: InboxRow[];
/** Per status over the whole matching set, not just the rows shipped. */
counts: InboxCounts;
/** ISO timestamp of the last completed background refresh, null if never. */
last_refresh_at: string | null;
refresh_state: RefreshState;
@@ -300,6 +312,8 @@ export interface Inbox {
last_sync_at: string | null;
sync_state: RefreshState;
last_sync_result: SyncResult | null;
/** ISO date the mirror actually reaches back to, null if never walked. */
covers_from: string | null;
}
export interface ImportResult {
@@ -370,6 +384,8 @@ const qs = (params: Record<string, string | undefined>) =>
export const api = {
me: () => request<Staff>('/api/me'),
staff: () => request<Staff[]>('/api/staff'),
/** An existing name is reactivated rather than rejected. */
createStaff: (name: string) => post<Staff>('/api/staff', { name }),
login: (staffId: string) => post<{ ok: boolean; staff: Staff }>('/api/login', { staff_id: staffId }),
logout: () => post<{ ok: boolean }>('/api/logout'),
businesses: (status: BusinessStatus | '', search: string) =>
@@ -401,6 +417,7 @@ export const api = {
patch<NdaRound>(`/api/ndas/${id}`, body),
addDeal: (ndaId: string, businessId: string) =>
post<Deal>(`/api/ndas/${ndaId}/deals`, { business_id: businessId }),
deleteDeal: (id: string) => del<{ ok: boolean }>(`/api/deals/${id}`),
setDealStatus: (id: string, status: DealStatus, comment?: string) =>
post<{ id: string; status: DealStatus; follow_up_at: Day | null }>(
`/api/deals/${id}/status`,
@@ -439,7 +456,8 @@ export const api = {
}),
/** Reads the mirrored requests out of the database — never calls Dropbox. */
ndaInbox: (since: string) => request<Inbox>(`/api/nda-inbox?${qs({ since })}`),
ndaInbox: (since: string, status?: InboxStatus | '', q?: string) =>
request<Inbox>(`/api/nda-inbox?${qs({ since, status, q })}`),
/**
* Starts the background walk; resolves as soon as it is queued. Passing
* `since` makes the server re-read that whole window — leave it out for the

View File

@@ -167,75 +167,85 @@ export default function BuyerDetail({
</Dialog>
)}
<Panel title="Identity">
<div className="grid grid-cols-2 gap-x-6 gap-y-3">
<InlineField
label="Company name"
value={buyer.company_name}
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { company_name: next }))}
/>
<InlineField
label="State"
value={buyer.state}
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { state: next }))}
/>
<InlineField
label="Address"
value={buyer.address}
multiline
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { address: next }))}
/>
<InlineField
label="How they heard about us"
value={buyer.how_heard}
multiline
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { how_heard: next }))}
/>
<div className="col-span-2">
<InlineField
label="Background / experience"
value={buyer.background_experience}
multiline
onSave={(next) =>
guard(() => api.updateBuyer(buyer.id, { background_experience: next }))
}
/>
</div>
<div className="col-span-2">
<TriState
label="Interested in updates"
value={buyer.interested_in_updates}
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { interested_in_updates: next }))}
/>
</div>
{/* Two columns from lg up: who they are on the left, what is happening
with them on the right. Below lg it collapses to the old single
column. min-w-0 on both so long values wrap instead of forcing the
page to scroll sideways. */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div className="flex min-w-0 flex-col gap-4">
<Panel title="Identity">
<div className="grid grid-cols-2 gap-x-6 gap-y-3">
<InlineField
label="Company name"
value={buyer.company_name}
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { company_name: next }))}
/>
<InlineField
label="State"
value={buyer.state}
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { state: next }))}
/>
<InlineField
label="Address"
value={buyer.address}
multiline
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { address: next }))}
/>
<InlineField
label="How they heard about us"
value={buyer.how_heard}
multiline
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { how_heard: next }))}
/>
<div className="col-span-2">
<InlineField
label="Background / experience"
value={buyer.background_experience}
multiline
onSave={(next) =>
guard(() => api.updateBuyer(buyer.id, { background_experience: next }))
}
/>
</div>
<div className="col-span-2">
<TriState
label="Interested in updates"
value={buyer.interested_in_updates}
onSave={(next) => guard(() => api.updateBuyer(buyer.id, { interested_in_updates: next }))}
/>
</div>
</div>
</Panel>
<Contacts buyer={buyer} guard={guard} />
{/* Everything the buyer ever produced, including their rounds and deals. */}
<Panel title="Notes">
<NotesPanel target={{ buyer_id: buyer.id }} includeRelated reloadKey={noteEpoch} />
</Panel>
</div>
</Panel>
<Contacts buyer={buyer} guard={guard} />
<div className="flex min-w-0 flex-col gap-4">
<Panel title="NDA rounds">
<div className="flex flex-col gap-4">
{buyer.ndas.map((nda) => (
<Round
key={nda.id}
nda={nda}
guard={guard}
onOpenBusiness={onOpenBusiness}
noteEpoch={noteEpoch}
/>
))}
{buyer.ndas.length === 0 && <p className="text-sm text-gray-500">No NDA rounds yet.</p>}
</div>
</Panel>
{/* Everything the buyer ever produced, including their rounds and deals. */}
<Panel title="Notes">
<NotesPanel target={{ buyer_id: buyer.id }} includeRelated reloadKey={noteEpoch} />
</Panel>
<Panel title="Todos">
<TodosPanel target={{ buyer_id: buyer.id }} />
</Panel>
<Panel title="NDA rounds">
<div className="flex flex-col gap-4">
{buyer.ndas.map((nda) => (
<Round
key={nda.id}
nda={nda}
guard={guard}
onOpenBusiness={onOpenBusiness}
noteEpoch={noteEpoch}
/>
))}
{buyer.ndas.length === 0 && <p className="text-sm text-gray-500">No NDA rounds yet.</p>}
<Panel title="Todos">
<TodosPanel target={{ buyer_id: buyer.id }} />
</Panel>
</div>
</Panel>
</div>
</div>
);
}
@@ -517,6 +527,7 @@ function DealRow({
}) {
const [menuOpen, setMenuOpen] = useState(false);
const [pending, setPending] = useState<DealStatus | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [comment, setComment] = useState('');
const [busy, setBusy] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
@@ -583,6 +594,16 @@ function DealRow({
{DEAL_LABELS[status]}
</button>
))}
<div className="my-1 border-t border-gray-100" />
<button
onClick={() => {
setMenuOpen(false);
setConfirmDelete(true);
}}
className="block w-full px-3 py-1 text-left text-sm text-red-600 hover:bg-red-50"
>
Delete deal
</button>
</div>
)}
</div>
@@ -603,6 +624,27 @@ function DealRow({
)}
</div>
{confirmDelete && (
<Dialog
title="Delete this deal?"
confirmLabel="Delete"
busy={busy}
onCancel={() => setConfirmDelete(false)}
onConfirm={async () => {
setBusy(true);
await guard(() => api.deleteDeal(deal.id));
setBusy(false);
setConfirmDelete(false);
}}
>
<p className="text-sm text-gray-600">
This removes <span className="font-medium">{deal.business.name}</span> and its{' '}
{deal.note_count} note{deal.note_count === 1 ? '' : 's'} and {deal.todo_count} todo
{deal.todo_count === 1 ? '' : 's'}. The NDA round and the buyer stay.
</p>
</Dialog>
)}
{pending && (
<Dialog
title={DEAL_ACTIONS[pending]}

View File

@@ -1,16 +1,26 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { api, type Staff } from '../api.js';
import { StaffName } from '../staff-color.js';
export default function Login({ onLogin }: { onLogin: (staff: Staff) => void }) {
const [staff, setStaff] = useState<Staff[]>([]);
const [error, setError] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [name, setName] = useState('');
const [busy, setBusy] = useState(false);
const load = useCallback(
() =>
api
.staff()
.then((list) => setStaff(list.filter((s) => s.active)))
.catch((err: Error) => setError(err.message)),
[],
);
useEffect(() => {
api
.staff()
.then((list) => setStaff(list.filter((s) => s.active)))
.catch((err: Error) => setError(err.message));
}, []);
void load();
}, [load]);
async function pick(member: Staff) {
try {
@@ -21,6 +31,28 @@ export default function Login({ onLogin }: { onLogin: (staff: Staff) => void })
}
}
/**
* Adding a person here means a new colleague can sign in without anyone
* running curl. An existing name is not an error: the endpoint reactivates
* that person, which is exactly what you want when someone comes back.
*/
async function add() {
const trimmed = name.trim();
if (!trimmed || busy) return;
setBusy(true);
setError(null);
try {
await api.createStaff(trimmed);
setName('');
setAdding(false);
await load();
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50">
<div className="w-80 rounded-lg border border-gray-200 bg-white p-6 shadow-sm">
@@ -33,13 +65,45 @@ export default function Login({ onLogin }: { onLogin: (staff: Staff) => void })
className="rounded border border-gray-300 px-3 py-2 text-left text-sm hover:bg-gray-100"
onClick={() => pick(member)}
>
{member.name}
<StaffName id={member.id} name={member.name} />
</button>
))}
{staff.length === 0 && !error && (
<p className="text-sm text-gray-500">No staff members yet.</p>
)}
</div>
<div className="mt-4 border-t border-gray-200 pt-3">
{adding ? (
<div className="flex gap-2">
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void add();
if (e.key === 'Escape') setAdding(false);
}}
placeholder="Name"
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
/>
<button
onClick={add}
disabled={busy || name.trim() === ''}
className="rounded bg-gray-900 px-3 py-1 text-sm text-white disabled:opacity-40"
>
Add
</button>
</div>
) : (
<button
onClick={() => setAdding(true)}
className="text-sm text-blue-600 hover:underline"
>
Add person
</button>
)}
</div>
</div>
</div>
);

View File

@@ -41,6 +41,10 @@ export default function NdaInbox({
// minute of paging. A plain Refresh must not do that — only a date the user
// actually moved since the last run asks for the wide reload, once.
const [sinceChanged, setSinceChanged] = useState(false);
const [status, setStatus] = useState<InboxStatus | ''>('');
// Typed straight into the box; `search` is the debounced value that queries.
const [searchInput, setSearchInput] = useState('');
const [search, setSearch] = useState('');
const [inbox, setInbox] = useState<Inbox | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -66,11 +70,18 @@ export default function NdaInbox({
.find((state) => state?.startsWith('error:'))
?.slice('error:'.length);
// Debounce the search box: filtering is a DB query, not a client-side pass
// over rows that happen to be loaded.
useEffect(() => {
const timer = setTimeout(() => setSearch(searchInput.trim()), 300);
return () => clearTimeout(timer);
}, [searchInput]);
/** Reads the mirror out of the database — this never talks to Dropbox. */
const load = useCallback(async () => {
setLoading(true);
try {
const res = await api.ndaInbox(since);
const res = await api.ndaInbox(since, status, search);
setInbox(res);
setError(null);
// Deliberately no preselection: a suggestion is a guess, and an import
@@ -83,7 +94,7 @@ export default function NdaInbox({
} finally {
setLoading(false);
}
}, [since]);
}, [since, status, search]);
useEffect(() => {
void load();
@@ -94,10 +105,12 @@ export default function NdaInbox({
useEffect(() => {
if (!refreshing && watchingRefresh.current && inbox?.last_refresh_result) {
watchingRefresh.current = false;
const { pages, seen, stored } = inbox.last_refresh_result;
const { pages, seen, stored, legacy_stored: legacy } = inbox.last_refresh_result;
setRefreshNotice(
`Refreshed: ${stored} request${stored === 1 ? '' : 's'} stored ` +
`from ${seen} seen over ${pages} page${pages === 1 ? '' : 's'}.`,
`from ${seen} seen over ${pages} page${pages === 1 ? '' : 's'}` +
(legacy > 0 ? ` · ${legacy} in the pre-rename title format` : '') +
'.',
);
}
if (!syncing && watchingSync.current && inbox?.last_sync_result) {
@@ -241,6 +254,47 @@ export default function NdaInbox({
{refreshNotice && <p className="mb-3 text-sm text-gray-600">{refreshNotice}</p>}
{syncNotice && <p className="mb-3 text-sm text-gray-600">{syncNotice}</p>}
{/* The mirror can reach back less far than the picker asks for — say so
rather than showing a short list as if it were complete. */}
{inbox?.covers_from && inbox.covers_from.slice(0, 10) > since && (
<p className="mb-3 text-sm text-amber-700">
Showing data from {formatDayTimeParts(inbox.covers_from).day} the mirror does not reach
back to {formatDayTimeParts(since).day} yet. Change the date and press Reload window to
fetch the rest.
</p>
)}
<div className="mb-3 flex flex-wrap items-center gap-3">
<div className="flex gap-1">
{(
[
{ value: '', label: 'All' },
{ value: 'pending', label: 'Pending' },
{ value: 'signed', label: 'Signed' },
{ value: 'declined', label: 'Declined' },
] as { value: InboxStatus | ''; label: string }[]
).map((chip) => (
<button
key={chip.label}
onClick={() => setStatus(chip.value)}
className={`rounded px-3 py-1.5 text-sm ${
status === chip.value
? 'bg-gray-900 text-white'
: 'border border-gray-300 bg-white hover:bg-gray-100'
}`}
>
{chip.label} ({chip.value === '' ? (inbox?.counts.all ?? 0) : (inbox?.counts[chip.value] ?? 0)})
</button>
))}
</div>
<input
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
placeholder="Search signer or e-mail…"
className={`${INPUT} w-64`}
/>
</div>
{notice && <p className="mb-3 text-sm text-gray-600">{notice}</p>}
{error && <p className="mb-3 text-sm text-red-600">{error}</p>}