This commit is contained in:
2026-07-27 15:26:17 -05:00
parent 32c8ccc3ed
commit 6cb13650ed
16 changed files with 1924 additions and 148 deletions

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { ApiError, api, type Staff } from './api.js';
import Login from './views/Login.js';
import Today from './views/Today.js';
import Businesses from './views/Businesses.js';
import BusinessDetail from './views/BusinessDetail.js';
import Buyers from './views/Buyers.js';
@@ -9,6 +10,7 @@ import NewInquiry from './views/NewInquiry.js';
/** Hand-rolled routing: which view, and (for the detail views) which row. */
type Route =
| { view: 'today' }
| { view: 'businesses' }
| { view: 'business'; id: string }
| { view: 'buyers' }
@@ -16,18 +18,16 @@ type Route =
| { view: 'new-inquiry' };
const NAV: { label: string; route: Route; active: Route['view'][] }[] = [
{ label: 'Today', route: { view: 'today' }, active: ['today'] },
{ label: 'Businesses', route: { view: 'businesses' }, active: ['businesses', 'business'] },
{
label: 'Buyers',
route: { view: 'buyers' },
active: ['buyers', 'buyer', 'new-inquiry'],
},
{ label: 'Buyers', route: { view: 'buyers' }, active: ['buyers', 'buyer', 'new-inquiry'] },
];
export default function App() {
const [staff, setStaff] = useState<Staff | null>(null);
const [loading, setLoading] = useState(true);
const [route, setRoute] = useState<Route>({ view: 'businesses' });
const [route, setRoute] = useState<Route>({ view: 'today' });
const [badge, setBadge] = useState(0);
useEffect(() => {
api
@@ -39,10 +39,24 @@ export default function App() {
.finally(() => setLoading(false));
}, []);
// There is no polling in this app, so the badge is refreshed on mount, on
// every view change and after any action that can move an item off the list.
const refreshBadge = useCallback(() => {
if (!staff) return;
api
.today(staff.id)
.then((res) => setBadge(res.counts.overdue_todos + res.counts.due_todos))
.catch(() => setBadge(0));
}, [staff]);
useEffect(() => {
refreshBadge();
}, [refreshBadge, route.view]);
async function signOut() {
await api.logout();
setStaff(null);
setRoute({ view: 'businesses' });
setRoute({ view: 'today' });
}
if (loading) return <div className="p-8 text-sm text-gray-500">Loading</div>;
@@ -60,13 +74,16 @@ export default function App() {
<button
key={entry.label}
onClick={() => setRoute(entry.route)}
className={`rounded px-3 py-1 text-sm ${
className={`flex items-center gap-1.5 rounded px-3 py-1 text-sm ${
entry.active.includes(route.view)
? 'bg-gray-900 text-white'
: 'hover:bg-gray-100'
}`}
>
{entry.label}
{entry.label === 'Today' && badge > 0 && (
<span className="rounded-full bg-red-600 px-1.5 text-xs text-white">{badge}</span>
)}
</button>
))}
</nav>
@@ -92,6 +109,14 @@ export default function App() {
</main>
) : (
<main className="mx-auto w-full max-w-5xl flex-1 overflow-auto px-6 py-6">
{route.view === 'today' && (
<Today
staff={staff}
onOpenBuyer={(id) => setRoute({ view: 'buyer', id })}
onOpenBusiness={(id) => setRoute({ view: 'business', id })}
onChanged={refreshBadge}
/>
)}
{route.view === 'businesses' && (
<Businesses onOpen={(id) => setRoute({ view: 'business', id })} />
)}
@@ -112,6 +137,7 @@ export default function App() {
id={route.id}
onBack={() => setRoute({ view: 'buyers' })}
onOpenBusiness={(id) => setRoute({ view: 'business', id })}
onChanged={refreshBadge}
/>
)}
</main>

View File

@@ -164,6 +164,92 @@ export interface InquiryResult {
created: { buyer: boolean; contact: boolean };
}
// ------------------------------------------------- Notes / todos / Today
export type TodoKind = 'TASK' | 'REVIEW';
export type TodoStatus = 'OPEN' | 'DONE';
export interface Author {
id: string;
name: string;
}
/** What a note or todo hangs off, with the ids needed to link to it. */
export interface RefContext {
type: 'buyer' | 'deal' | 'business' | 'nda';
id: string;
label: string;
buyer_id: string | null;
business_id: string | null;
}
/** Exactly one of these for a note, at most one for a todo. */
export interface Ref {
buyer_id?: string;
deal_id?: string;
business_id?: string;
nda_id?: string;
}
export interface Note {
id: string;
text: string;
highlight: boolean;
created_at: string;
author: Author | null;
context: RefContext | null;
}
export interface Todo {
id: string;
text: string;
kind: TodoKind;
status: TodoStatus;
due_at: Day | null;
document_id: string | null;
document_name: string | null;
done_at: string | null;
done_by_name: string | null;
assigned_to: Author;
author: Author | null;
context: RefContext | null;
}
export interface FollowUp {
id: string;
status: DealStatus;
follow_up_at: Day;
buyer_id: string;
buyer_name: string;
business_id: string;
business_name: string;
created_by_id: string | null;
created_by_name: string | null;
}
export interface PendingNda {
id: string;
sent_at: Day;
buyer_id: string;
buyer_name: string;
created_by_id: string | null;
created_by_name: string | null;
}
export interface TodayCounts {
overdue_todos: number;
due_todos: number;
follow_ups: number;
pending_ndas: number;
total: number;
}
export interface Today {
todos: (Todo & { overdue: boolean })[];
follow_ups: FollowUp[];
pending_ndas: PendingNda[];
counts: TodayCounts;
}
export class ApiError extends Error {
constructor(readonly status: number, message: string) {
super(message);
@@ -189,9 +275,12 @@ function send<T>(method: 'POST' | 'PATCH', path: string, body?: unknown): Promis
const post = <T>(path: string, body?: unknown) => send<T>('POST', path, body);
const patch = <T>(path: string, body: unknown) => send<T>('PATCH', path, body);
const del = <T>(path: string) => request<T>(path, { method: 'DELETE' });
const qs = (params: Record<string, string>) =>
new URLSearchParams(params).toString();
const qs = (params: Record<string, string | undefined>) =>
new URLSearchParams(
Object.entries(params).filter(([, value]) => value) as [string, string][],
).toString();
export const api = {
me: () => request<Staff>('/api/me'),
@@ -233,6 +322,36 @@ export const api = {
{ status, comment },
),
dealNotes: (id: string) => request<DealNote[]>(`/api/deals/${id}/notes`),
notes: (ref: Ref, includeRelated = false) =>
request<Note[]>(
`/api/notes?${qs({ ...ref, include_related: includeRelated ? 'true' : undefined })}`,
),
createNote: (ref: Ref, text: string, highlight: boolean) =>
post<Note>('/api/notes', { ...ref, text, highlight }),
updateNote: (id: string, body: { text?: string; highlight?: boolean }) =>
patch<Note>(`/api/notes/${id}`, body),
deleteNote: (id: string) => del<{ ok: boolean }>(`/api/notes/${id}`),
todos: (params: Ref & { assigned_to?: string; status?: TodoStatus }) =>
request<Todo[]>(`/api/todos?${qs({ ...params })}`),
createTodo: (body: Ref & Record<string, unknown>) => post<Todo>('/api/todos', body),
updateTodo: (id: string, body: Record<string, unknown>) => patch<Todo>(`/api/todos/${id}`, body),
todoDone: (id: string) => post<Todo>(`/api/todos/${id}/done`),
todoReopen: (id: string) => post<Todo>(`/api/todos/${id}/reopen`),
today: (staffId?: string) => request<Today>(`/api/today?${qs({ staff_id: staffId })}`),
followUpSent: (dealId: string, comment: string, rearm: boolean) =>
post<{ id: string; status: DealStatus; follow_up_at: Day | null }>(
`/api/deals/${dealId}/follow-up-sent`,
{ comment, rearm },
),
/** Pins a business file so a REVIEW todo can point at it. */
createDocument: (businessId: string, filePath: string) =>
post<{ id: string; nas_path: string }>('/api/documents', {
business_id: businessId,
path: filePath,
}),
};
/** Same-origin streaming URL of one file inside a business directory. */

View File

@@ -8,6 +8,7 @@ import {
type BusinessFile,
} from '../api.js';
import { DEAL_LABELS, StatusBadge, formatDay } from '../components.js';
import { NotesPanel, TodosPanel } from '../workflow.js';
function formatSize(bytes: number): string {
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
@@ -26,6 +27,28 @@ function formatDate(iso: string): string {
const isPdf = (filePath: string) => filePath.toLowerCase().endsWith('.pdf');
/**
* Header panels stay collapsed and capped in height: this page's job is the
* file table plus the viewer, and they must keep the viewport.
*/
function Collapsible({ label, children }: { label: string; children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<div className="rounded border border-gray-200 bg-white">
<button
onClick={() => setOpen(!open)}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wide text-gray-500"
>
<span className="text-gray-400">{open ? '▾' : '▸'}</span>
{label}
</button>
{open && (
<div className="max-h-64 overflow-auto border-t border-gray-200 p-3">{children}</div>
)}
</div>
);
}
export default function BusinessDetail({
id,
onBack,
@@ -72,16 +95,17 @@ export default function BusinessDetail({
)}
{/* Collapsed by default so the master-detail split keeps the viewport. */}
<div className="mt-3 rounded border border-gray-200 bg-white">
<button
onClick={() => setDealsOpen(!dealsOpen)}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wide text-gray-500"
>
<span className="text-gray-400">{dealsOpen ? '▾' : '▸'}</span>
Buyer activity ({deals?.length ?? 0})
</button>
{dealsOpen && (
<div className="max-h-48 overflow-auto border-t border-gray-200">
<div className="mt-3 flex flex-col gap-2">
<div className="rounded border border-gray-200 bg-white">
<button
onClick={() => setDealsOpen(!dealsOpen)}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs font-medium uppercase tracking-wide text-gray-500"
>
<span className="text-gray-400">{dealsOpen ? '▾' : '▸'}</span>
Buyer activity ({deals?.length ?? 0})
</button>
{dealsOpen && (
<div className="max-h-48 overflow-auto border-t border-gray-200">
<table className="w-full border-collapse text-sm">
<thead className="sticky top-0 bg-white">
<tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500">
@@ -119,8 +143,16 @@ export default function BusinessDetail({
)}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
<Collapsible label="Notes">
<NotesPanel target={{ business_id: id }} />
</Collapsible>
<Collapsible label="Todos">
<TodosPanel target={{ business_id: id }} businessId={id} />
</Collapsible>
</div>
</div>

View File

@@ -5,10 +5,10 @@ import {
type BusinessListItem,
type Contact,
type Deal,
type DealNote,
type DealStatus,
type NdaRound,
} from '../api.js';
import { NotesPanel, TodosPanel } from '../workflow.js';
import {
BusinessPicker,
DEAL_ACTIONS,
@@ -20,7 +20,6 @@ import {
StatusBadge,
TriState,
formatDay,
formatStamp,
} from '../components.js';
const ALL_DEAL_STATUSES = Object.keys(DEAL_LABELS) as DealStatus[];
@@ -36,15 +35,20 @@ export default function BuyerDetail({
id,
onBack,
onOpenBusiness,
onChanged,
}: {
id: string;
onBack: () => void;
onOpenBusiness: (businessId: string) => void;
/** Deal changes here can add or remove follow-ups, so the nav badge follows. */
onChanged: () => void;
}) {
const [buyer, setBuyer] = useState<Buyer | null>(null);
const [error, setError] = useState<string | null>(null);
const [confirmDeactivate, setConfirmDeactivate] = useState(false);
const [endOpenDeals, setEndOpenDeals] = useState(true);
// Status changes and follow-ups write notes; bumping this refetches the panels.
const [noteEpoch, setNoteEpoch] = useState(0);
const reload = useCallback(
() =>
@@ -66,6 +70,8 @@ export default function BuyerDetail({
try {
await action();
await reload();
setNoteEpoch((n) => n + 1);
onChanged();
} catch (err) {
setError((err as Error).message);
}
@@ -205,6 +211,15 @@ export default function BuyerDetail({
<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>
<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) => (
@@ -213,7 +228,7 @@ export default function BuyerDetail({
nda={nda}
guard={guard}
onOpenBusiness={onOpenBusiness}
onError={setError}
noteEpoch={noteEpoch}
/>
))}
{buyer.ndas.length === 0 && <p className="text-sm text-gray-500">No NDA rounds yet.</p>}
@@ -328,12 +343,12 @@ function Round({
nda,
guard,
onOpenBusiness,
onError,
noteEpoch,
}: {
nda: NdaRound;
guard: (action: () => Promise<unknown>) => Promise<void>;
onOpenBusiness: (businessId: string) => void;
onError: (message: string) => void;
noteEpoch: number;
}) {
const [picking, setPicking] = useState(false);
const [business, setBusiness] = useState<BusinessListItem | null>(null);
@@ -411,7 +426,7 @@ function Round({
deal={deal}
guard={guard}
onOpenBusiness={onOpenBusiness}
onError={onError}
noteEpoch={noteEpoch}
/>
))}
{nda.deals.length === 0 && (
@@ -450,19 +465,18 @@ function DealRow({
deal,
guard,
onOpenBusiness,
onError,
noteEpoch,
}: {
deal: Deal;
guard: (action: () => Promise<unknown>) => Promise<void>;
onOpenBusiness: (businessId: string) => void;
onError: (message: string) => void;
noteEpoch: number;
}) {
const [menuOpen, setMenuOpen] = useState(false);
const [pending, setPending] = useState<DealStatus | null>(null);
const [comment, setComment] = useState('');
const [busy, setBusy] = useState(false);
const [notes, setNotes] = useState<DealNote[] | null>(null);
const [notesOpen, setNotesOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const forward = nextSteps(deal.status);
const corrections = ALL_DEAL_STATUSES.filter(
@@ -483,18 +497,6 @@ function DealRow({
setPending(null);
}
async function toggleNotes() {
const next = !notesOpen;
setNotesOpen(next);
if (next) {
try {
setNotes(await api.dealNotes(deal.id));
} catch (err) {
onError((err as Error).message);
}
}
}
return (
<div className="border-b border-gray-100 last:border-0">
<div className="flex items-center gap-3 px-3 py-2">
@@ -544,21 +546,17 @@ function DealRow({
</div>
<div className="px-3 pb-2">
<button onClick={toggleNotes} className="text-xs text-gray-500 hover:underline">
{notesOpen ? '▾' : '▸'} Notes ({notes ? notes.length : deal.note_count})
<button
onClick={() => setDetailOpen(!detailOpen)}
className="text-xs text-gray-500 hover:underline"
>
{detailOpen ? '▾' : '▸'} Notes ({deal.note_count}) &amp; todos
</button>
{notesOpen && (
<ul className="mt-1 flex flex-col gap-1 border-l-2 border-gray-200 pl-3">
{notes?.map((note) => (
<li key={note.id} className="text-sm">
<span className="whitespace-pre-wrap">{note.text}</span>
<span className="ml-2 text-xs text-gray-400">
{note.author ?? 'unknown'} · {formatStamp(note.created_at)}
</span>
</li>
))}
{notes && notes.length === 0 && <li className="text-sm text-gray-500">No notes.</li>}
</ul>
{detailOpen && (
<div className="mt-2 flex flex-col gap-3 border-l-2 border-gray-200 pl-3">
<NotesPanel target={{ deal_id: deal.id }} reloadKey={noteEpoch} />
<TodosPanel target={{ deal_id: deal.id }} businessId={deal.business.id} />
</div>
)}
</div>

384
web/src/views/Today.tsx Normal file
View File

@@ -0,0 +1,384 @@
import { useCallback, useEffect, useState } from 'react';
import {
api,
type FollowUp,
type PendingNda,
type Staff,
type Today as TodayData,
type Todo,
} from '../api.js';
import { DEAL_LABELS, Dialog, Panel, StatusBadge, formatDay } from '../components.js';
import { TodoRow } from '../workflow.js';
const INPUT = 'w-full rounded border border-gray-300 px-2 py-1 text-sm';
/** Groups anything with a person attached, for the team tab. */
function groupBy<T>(rows: T[], name: (row: T) => string): [string, T[]][] {
const groups = new Map<string, T[]>();
for (const row of rows) {
const key = name(row);
groups.set(key, [...(groups.get(key) ?? []), row]);
}
return [...groups.entries()].sort((a, b) => a[0].localeCompare(b[0]));
}
export default function Today({
staff,
onOpenBuyer,
onOpenBusiness,
onChanged,
}: {
staff: Staff;
onOpenBuyer: (id: string) => void;
onOpenBusiness: (id: string) => void;
/** Lets the nav badge follow along after every action. */
onChanged: () => void;
}) {
const [team, setTeam] = useState(false);
const [data, setData] = useState<TodayData | null>(null);
const [error, setError] = useState<string | null>(null);
const [followUp, setFollowUp] = useState<FollowUp | null>(null);
const [ending, setEnding] = useState<FollowUp | null>(null);
const load = useCallback(
() =>
api
.today(team ? undefined : staff.id)
.then((res) => {
setData(res);
setError(null);
})
.catch((err: Error) => setError(err.message)),
[team, staff.id],
);
useEffect(() => {
void load();
}, [load]);
async function refresh() {
await load();
onChanged();
}
const empty =
data &&
data.todos.length === 0 &&
data.follow_ups.length === 0 &&
data.pending_ndas.length === 0;
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex gap-1">
{[
{ label: 'My day', value: false },
{ label: 'Team', value: true },
].map((tab) => (
<button
key={tab.label}
onClick={() => setTeam(tab.value)}
className={`rounded px-3 py-1.5 text-sm ${
team === tab.value
? 'bg-gray-900 text-white'
: 'border border-gray-300 bg-white hover:bg-gray-100'
}`}
>
{tab.label}
</button>
))}
</div>
{data && (
<span className="text-sm text-gray-500">
{data.counts.overdue_todos > 0 && (
<span className="font-medium text-red-600">{data.counts.overdue_todos} overdue</span>
)}
{data.counts.overdue_todos > 0 && ' · '}
{data.counts.total} item{data.counts.total === 1 ? '' : 's'}
</span>
)}
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
{empty && (
<div className="rounded border border-gray-200 bg-white px-4 py-10 text-center text-sm text-gray-500">
Nothing due. Enjoy your coffee.
</div>
)}
{data && data.todos.length > 0 && (
<Panel title="Todos">
{team ? (
groupBy(data.todos, (todo) => todo.assigned_to.name).map(([name, todos]) => (
<div key={name} className="mb-2 last:mb-0">
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">{name}</p>
<TodoList todos={todos} onChanged={refresh} open={{ onOpenBuyer, onOpenBusiness }} />
</div>
))
) : (
<TodoList todos={data.todos} onChanged={refresh} open={{ onOpenBuyer, onOpenBusiness }} />
)}
</Panel>
)}
{data && data.follow_ups.length > 0 && (
<Panel title="Follow-ups due">
{team ? (
groupBy(data.follow_ups, (row) => row.created_by_name ?? 'unassigned').map(
([name, rows]) => (
<div key={name} className="mb-2 last:mb-0">
<p className="text-xs font-medium uppercase tracking-wide text-gray-500">{name}</p>
{rows.map((row) => (
<FollowUpRow
key={row.id}
row={row}
onOpenBuyer={onOpenBuyer}
onOpenBusiness={onOpenBusiness}
onFollowUp={setFollowUp}
onEnd={setEnding}
/>
))}
</div>
),
)
) : (
data.follow_ups.map((row) => (
<FollowUpRow
key={row.id}
row={row}
onOpenBuyer={onOpenBuyer}
onOpenBusiness={onOpenBusiness}
onFollowUp={setFollowUp}
onEnd={setEnding}
/>
))
)}
</Panel>
)}
{data && data.pending_ndas.length > 0 && (
<Panel title="NDA signatures pending">
{data.pending_ndas.map((row) => (
<PendingNdaRow key={row.id} row={row} team={team} onOpenBuyer={onOpenBuyer} />
))}
</Panel>
)}
{followUp && (
<FollowUpDialog
row={followUp}
onClose={() => setFollowUp(null)}
onDone={() => {
setFollowUp(null);
void refresh();
}}
/>
)}
{ending && (
<EndDealDialog
row={ending}
onClose={() => setEnding(null)}
onDone={() => {
setEnding(null);
void refresh();
}}
/>
)}
</div>
);
}
function TodoList({
todos,
onChanged,
open,
}: {
todos: (Todo & { overdue?: boolean })[];
onChanged: () => void;
open: { onOpenBuyer: (id: string) => void; onOpenBusiness: (id: string) => void };
}) {
return (
<ul className="flex flex-col">
{todos.map((todo) => (
<TodoRow
key={todo.id}
todo={todo}
onChanged={onChanged}
onOpenContext={(context) => {
// Deals and rounds live on the buyer page, so that wins when both exist.
if (context.buyer_id) open.onOpenBuyer(context.buyer_id);
else if (context.business_id) open.onOpenBusiness(context.business_id);
}}
/>
))}
</ul>
);
}
function FollowUpRow({
row,
onOpenBuyer,
onOpenBusiness,
onFollowUp,
onEnd,
}: {
row: FollowUp;
onOpenBuyer: (id: string) => void;
onOpenBusiness: (id: string) => void;
onFollowUp: (row: FollowUp) => void;
onEnd: (row: FollowUp) => void;
}) {
return (
<div className="flex flex-wrap items-center gap-2 border-b border-gray-100 py-2 text-sm last:border-0">
<span className="min-w-0 flex-1">
<button onClick={() => onOpenBuyer(row.buyer_id)} className="text-blue-600 hover:underline">
{row.buyer_name}
</button>{' '}
about{' '}
<button
onClick={() => onOpenBusiness(row.business_id)}
className="text-blue-600 hover:underline"
>
{row.business_name}
</button>
, info sent <span className="text-gray-500">{formatDay(row.follow_up_at)}</span>
</span>
<StatusBadge status={row.status} label={DEAL_LABELS[row.status]} />
<button
onClick={() => onFollowUp(row)}
className="rounded border border-gray-300 bg-white px-2 py-1 text-xs hover:bg-gray-100"
>
Follow-up sent
</button>
<button
onClick={() => onEnd(row)}
className="rounded border border-gray-300 bg-white px-2 py-1 text-xs hover:bg-gray-100"
>
End deal
</button>
</div>
);
}
function PendingNdaRow({
row,
team,
onOpenBuyer,
}: {
row: PendingNda;
team: boolean;
onOpenBuyer: (id: string) => void;
}) {
return (
<div className="flex items-center gap-2 border-b border-gray-100 py-2 text-sm last:border-0">
<span className="flex-1">
<button onClick={() => onOpenBuyer(row.buyer_id)} className="text-blue-600 hover:underline">
{row.buyer_name}
</button>
, sent <span className="text-gray-500">{formatDay(row.sent_at)}</span>
</span>
{team && <span className="text-xs text-gray-500">{row.created_by_name ?? '—'}</span>}
</div>
);
}
/** "Follow-up sent" always writes a note; the choice is whether to keep waiting. */
function FollowUpDialog({
row,
onClose,
onDone,
}: {
row: FollowUp;
onClose: () => void;
onDone: () => void;
}) {
const [comment, setComment] = useState('');
const [rearm, setRearm] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function confirm() {
setBusy(true);
try {
await api.followUpSent(row.id, comment, rearm);
onDone();
} catch (err) {
setError((err as Error).message);
setBusy(false);
}
}
return (
<Dialog title="Follow-up sent" confirmLabel="Save" busy={busy} onConfirm={confirm} onCancel={onClose}>
<p className="mb-2 text-sm text-gray-600">
{row.buyer_name} about {row.business_name}
</p>
<textarea
rows={3}
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Comment (optional) — saved as a note"
className={INPUT}
/>
<div className="mt-2 flex flex-col gap-1 text-sm">
{[
{ value: true, label: 'Wait another 14 days' },
{ value: false, label: 'Stop waiting' },
].map((option) => (
<label key={String(option.value)} className="flex items-center gap-2">
<input
type="radio"
checked={rearm === option.value}
onChange={() => setRearm(option.value)}
/>
{option.label}
</label>
))}
</div>
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
</Dialog>
);
}
/** The normal status change, preset to ENDED. */
function EndDealDialog({
row,
onClose,
onDone,
}: {
row: FollowUp;
onClose: () => void;
onDone: () => void;
}) {
const [comment, setComment] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function confirm() {
setBusy(true);
try {
await api.setDealStatus(row.id, 'ENDED', comment);
onDone();
} catch (err) {
setError((err as Error).message);
setBusy(false);
}
}
return (
<Dialog title="End deal" confirmLabel="Confirm" busy={busy} onConfirm={confirm} onCancel={onClose}>
<p className="mb-2 text-sm text-gray-600">
{row.business_name}: {DEAL_LABELS[row.status]} {DEAL_LABELS.ENDED}
</p>
<textarea
rows={3}
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder="Comment (optional) — saved as a note"
className={INPUT}
/>
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
</Dialog>
);
}

511
web/src/workflow.tsx Normal file
View File

@@ -0,0 +1,511 @@
import { useCallback, useEffect, useState } from 'react';
import {
api,
type BusinessFile,
type Note,
type Ref,
type Staff,
type Todo,
type TodoKind,
} from './api.js';
import { Dialog, formatDay, formatStamp } from './components.js';
const INPUT = 'rounded border border-gray-300 px-2 py-1 text-sm';
// ----------------------------------------------------------------- Notes
/** A note's own text, plus the "which round / which deal" label when related. */
function NoteRow({
note,
showContext,
onChanged,
onError,
}: {
note: Note;
showContext: boolean;
onChanged: () => void;
onError: (message: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(note.text);
const [confirmDelete, setConfirmDelete] = useState(false);
async function run(action: () => Promise<unknown>) {
try {
await action();
onChanged();
} catch (err) {
onError((err as Error).message);
}
}
return (
<li
className={`rounded border-l-2 px-2 py-1.5 ${
note.highlight ? 'border-red-500 bg-red-50' : 'border-gray-200 bg-white'
}`}
>
{editing ? (
<div>
<textarea
autoFocus
rows={3}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => e.key === 'Escape' && setEditing(false)}
className={`${INPUT} w-full`}
/>
<div className="mt-1 flex gap-2 text-xs">
<button
onClick={() =>
run(async () => {
await api.updateNote(note.id, { text: draft });
setEditing(false);
})
}
className="text-blue-600 hover:underline"
>
Save
</button>
<button onClick={() => setEditing(false)} className="text-gray-500 hover:underline">
Cancel
</button>
</div>
</div>
) : (
<>
<p className="whitespace-pre-wrap text-sm">{note.text}</p>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-gray-400">
{showContext && note.context && (
<span className="rounded bg-gray-100 px-1.5 py-0.5 text-gray-600">
{note.context.label}
</span>
)}
<span>
{note.author?.name ?? 'unknown'} · {formatStamp(note.created_at)}
</span>
<button
onClick={() => run(() => api.updateNote(note.id, { highlight: !note.highlight }))}
title={note.highlight ? 'Remove the flag' : 'Flag this note'}
className={note.highlight ? 'text-red-500' : 'hover:text-red-400'}
>
</button>
<button
onClick={() => {
setDraft(note.text);
setEditing(true);
}}
className="hover:text-gray-600 hover:underline"
>
Edit
</button>
{confirmDelete ? (
<>
<button
onClick={() => run(() => api.deleteNote(note.id))}
className="text-red-600 hover:underline"
>
Really delete
</button>
<button
onClick={() => setConfirmDelete(false)}
className="hover:text-gray-600 hover:underline"
>
Cancel
</button>
</>
) : (
<button
onClick={() => setConfirmDelete(true)}
className="hover:text-gray-600 hover:underline"
>
Delete
</button>
)}
</div>
</>
)}
</li>
);
}
/**
* Composer plus list for one reference object. `includeRelated` is only
* meaningful on a buyer, where it folds in the notes of that buyer's rounds
* and deals.
*/
export function NotesPanel({
target,
includeRelated,
reloadKey,
}: {
target: Ref;
includeRelated?: boolean;
/** Bump to refetch from the outside (e.g. after a status change wrote a note). */
reloadKey?: number;
}) {
const [notes, setNotes] = useState<Note[] | null>(null);
const [text, setText] = useState('');
const [highlight, setHighlight] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const key = JSON.stringify(target);
const load = useCallback(() => {
api
.notes(JSON.parse(key) as Ref, includeRelated)
.then((rows) => {
setNotes(rows);
setError(null);
})
.catch((err: Error) => setError(err.message));
}, [key, includeRelated]);
useEffect(() => {
load();
}, [load, reloadKey]);
async function add() {
if (!text.trim() || busy) return;
setBusy(true);
try {
await api.createNote(target, text.trim(), highlight);
setText('');
setHighlight(false);
load();
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
}
return (
<div>
<div className="flex items-start gap-2">
<textarea
rows={2}
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Add a note…"
className={`${INPUT} flex-1`}
/>
<div className="flex flex-col gap-1">
<button
onClick={() => setHighlight(!highlight)}
title="Flag this note as important"
className={`rounded border px-2 py-1 text-sm ${
highlight
? 'border-red-400 bg-red-50 text-red-600'
: 'border-gray-300 bg-white text-gray-400 hover:text-red-400'
}`}
>
</button>
<button
onClick={add}
disabled={busy || !text.trim()}
className="rounded bg-gray-900 px-2 py-1 text-xs text-white disabled:opacity-40"
>
Save
</button>
</div>
</div>
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
<ul className="mt-2 flex flex-col gap-1.5">
{notes?.map((note) => (
<NoteRow
key={note.id}
note={note}
showContext={Boolean(includeRelated)}
onChanged={load}
onError={setError}
/>
))}
{notes && notes.length === 0 && <li className="text-sm text-gray-500">No notes yet.</li>}
</ul>
</div>
);
}
// ----------------------------------------------------------------- Todos
const KIND_TONE: Record<TodoKind, string> = {
TASK: 'border-gray-300 bg-white text-gray-600',
REVIEW: 'border-purple-300 bg-purple-50 text-purple-800',
};
export function TodoRow({
todo,
onChanged,
onOpenContext,
}: {
todo: Todo & { overdue?: boolean };
onChanged: () => void;
onOpenContext?: (context: NonNullable<Todo['context']>) => void;
}) {
const [busy, setBusy] = useState(false);
const done = todo.status === 'DONE';
async function toggle() {
setBusy(true);
try {
await (done ? api.todoReopen(todo.id) : api.todoDone(todo.id));
onChanged();
} finally {
setBusy(false);
}
}
return (
<li className="flex items-start gap-2 border-b border-gray-100 py-1.5 last:border-0">
<input
type="checkbox"
checked={done}
disabled={busy}
onChange={toggle}
className="mt-1"
title={done ? `Done by ${todo.done_by_name ?? '?'}` : 'Mark as done'}
/>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className={`text-sm ${done ? 'text-gray-400 line-through' : ''}`}>{todo.text}</span>
{todo.kind === 'REVIEW' && (
<span className={`rounded-full border px-2 py-0.5 text-xs ${KIND_TONE.REVIEW}`}>
Review
</span>
)}
{todo.document_name && (
<span className="font-mono text-xs text-gray-500">{todo.document_name}</span>
)}
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-400">
<span>{todo.assigned_to.name}</span>
{todo.due_at && (
<span className={todo.overdue && !done ? 'font-medium text-red-600' : ''}>
due {formatDay(todo.due_at)}
</span>
)}
{todo.context && (
<button
onClick={() => todo.context && onOpenContext?.(todo.context)}
disabled={!onOpenContext}
className="rounded bg-gray-100 px-1.5 py-0.5 text-gray-600 enabled:hover:bg-gray-200"
>
{todo.context.label}
</button>
)}
{done && todo.done_at && (
<span title={`${todo.done_by_name ?? 'unknown'} · ${formatStamp(todo.done_at)}`}>
done {formatStamp(todo.done_at)}
</span>
)}
</div>
</div>
</li>
);
}
/** Scoped todo list with its own "Add todo" button. */
export function TodosPanel({
target,
businessId,
onOpenContext,
}: {
target: Ref;
/** Enables the REVIEW document picker; only known where a business is in play. */
businessId?: string;
onOpenContext?: (context: NonNullable<Todo['context']>) => void;
}) {
const [todos, setTodos] = useState<Todo[] | null>(null);
const [adding, setAdding] = useState(false);
const [error, setError] = useState<string | null>(null);
const key = JSON.stringify(target);
const load = useCallback(() => {
api
.todos(JSON.parse(key) as Ref)
.then((rows) => {
setTodos(rows);
setError(null);
})
.catch((err: Error) => setError(err.message));
}, [key]);
useEffect(() => {
load();
}, [load]);
const open = todos?.filter((todo) => todo.status === 'OPEN') ?? [];
const done = todos?.filter((todo) => todo.status === 'DONE') ?? [];
return (
<div>
<div className="mb-1 flex items-center justify-between">
<span className="text-xs text-gray-500">
{open.length} open{done.length > 0 ? ` · ${done.length} done` : ''}
</span>
<button onClick={() => setAdding(true)} className="text-xs text-blue-600 hover:underline">
+ Add todo
</button>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<ul className="flex flex-col">
{[...open, ...done].map((todo) => (
<TodoRow key={todo.id} todo={todo} onChanged={load} onOpenContext={onOpenContext} />
))}
{todos && todos.length === 0 && <li className="py-1 text-sm text-gray-500">No todos.</li>}
</ul>
{adding && (
<AddTodoDialog
target={target}
businessId={businessId}
onClose={() => setAdding(false)}
onCreated={() => {
setAdding(false);
load();
}}
/>
)}
</div>
);
}
export function AddTodoDialog({
target,
businessId,
onClose,
onCreated,
}: {
target: Ref;
businessId?: string;
onClose: () => void;
onCreated: () => void;
}) {
const [staff, setStaff] = useState<Staff[]>([]);
const [text, setText] = useState('');
const [assignee, setAssignee] = useState('');
const [dueAt, setDueAt] = useState('');
const [kind, setKind] = useState<TodoKind>('TASK');
const [files, setFiles] = useState<BusinessFile[]>([]);
const [documentPath, setDocumentPath] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
api
.staff()
.then((list) => {
const active = list.filter((member) => member.active);
setStaff(active);
setAssignee((current) => current || active[0]?.id || '');
})
.catch((err: Error) => setError(err.message));
}, []);
// Only fetched once REVIEW is picked — the listing walks the NAS.
useEffect(() => {
if (kind !== 'REVIEW' || !businessId || files.length > 0) return;
api.businessFiles(businessId).then(setFiles).catch(() => setFiles([]));
}, [kind, businessId, files.length]);
async function save() {
if (!text.trim() || !assignee) return setError('Text and assignee are required.');
setBusy(true);
setError(null);
try {
let documentId: string | undefined;
if (kind === 'REVIEW' && businessId && documentPath) {
documentId = (await api.createDocument(businessId, documentPath)).id;
}
await api.createTodo({
...target,
text: text.trim(),
kind,
assigned_to: assignee,
due_at: dueAt,
document_id: documentId,
});
onCreated();
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
}
return (
<Dialog title="Add todo" confirmLabel="Create" busy={busy} onConfirm={save} onCancel={onClose}>
<div className="flex flex-col gap-2">
<textarea
autoFocus
rows={2}
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="What needs to happen?"
className={`${INPUT} w-full`}
/>
<div className="flex gap-2">
<label className="flex-1 text-xs uppercase tracking-wide text-gray-500">
Assignee
<select
value={assignee}
onChange={(e) => setAssignee(e.target.value)}
className={`${INPUT} mt-0.5 w-full`}
>
{staff.map((member) => (
<option key={member.id} value={member.id}>
{member.name}
</option>
))}
</select>
</label>
<label className="flex-1 text-xs uppercase tracking-wide text-gray-500">
Due
<input
type="date"
value={dueAt}
onChange={(e) => setDueAt(e.target.value)}
className={`${INPUT} mt-0.5 w-full`}
/>
</label>
</div>
<label className="text-xs uppercase tracking-wide text-gray-500">
Kind
<select
value={kind}
onChange={(e) => setKind(e.target.value as TodoKind)}
className={`${INPUT} mt-0.5 w-full`}
>
<option value="TASK">Task</option>
<option value="REVIEW">Review</option>
</select>
</label>
{kind === 'REVIEW' && businessId && (
<label className="text-xs uppercase tracking-wide text-gray-500">
Document to review (optional)
<select
value={documentPath}
onChange={(e) => setDocumentPath(e.target.value)}
className={`${INPUT} mt-0.5 w-full`}
>
<option value=""> none </option>
{files.map((file) => (
<option key={file.path} value={file.path}>
{file.path}
</option>
))}
</select>
</label>
)}
{kind === 'REVIEW' && !businessId && (
<p className="text-xs text-gray-500">
Documents can only be picked where a business is in context.
</p>
)}
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
</Dialog>
);
}