module5
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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}) & 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
384
web/src/views/Today.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user