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

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>
);
}