actual state
This commit is contained in:
@@ -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' && (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user