module 4
This commit is contained in:
@@ -3,13 +3,31 @@ import { ApiError, api, type Staff } from './api.js';
|
||||
import Login from './views/Login.js';
|
||||
import Businesses from './views/Businesses.js';
|
||||
import BusinessDetail from './views/BusinessDetail.js';
|
||||
import Buyers from './views/Buyers.js';
|
||||
import BuyerDetail from './views/BuyerDetail.js';
|
||||
import NewInquiry from './views/NewInquiry.js';
|
||||
|
||||
type View = { name: 'businesses' } | { name: 'business'; id: string };
|
||||
/** Hand-rolled routing: which view, and (for the detail views) which row. */
|
||||
type Route =
|
||||
| { view: 'businesses' }
|
||||
| { view: 'business'; id: string }
|
||||
| { view: 'buyers' }
|
||||
| { view: 'buyer'; id: string }
|
||||
| { view: 'new-inquiry' };
|
||||
|
||||
const NAV: { label: string; route: Route; active: Route['view'][] }[] = [
|
||||
{ label: 'Businesses', route: { view: 'businesses' }, active: ['businesses', 'business'] },
|
||||
{
|
||||
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 [view, setView] = useState<View>({ name: 'businesses' });
|
||||
const [route, setRoute] = useState<Route>({ view: 'businesses' });
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
@@ -24,7 +42,7 @@ export default function App() {
|
||||
async function signOut() {
|
||||
await api.logout();
|
||||
setStaff(null);
|
||||
setView({ name: 'businesses' });
|
||||
setRoute({ view: 'businesses' });
|
||||
}
|
||||
|
||||
if (loading) return <div className="p-8 text-sm text-gray-500">Loading…</div>;
|
||||
@@ -35,12 +53,24 @@ export default function App() {
|
||||
// fill exactly the remaining viewport height.
|
||||
<div className="flex h-screen flex-col bg-gray-50 text-gray-900">
|
||||
<header className="flex shrink-0 items-center justify-between border-b border-gray-200 bg-white px-6 py-3">
|
||||
<button
|
||||
className="text-base font-semibold tracking-tight"
|
||||
onClick={() => setView({ name: 'businesses' })}
|
||||
>
|
||||
BizMatch
|
||||
</button>
|
||||
<div className="flex items-center gap-6">
|
||||
<span className="text-base font-semibold tracking-tight">BizMatch</span>
|
||||
<nav className="flex gap-1">
|
||||
{NAV.map((entry) => (
|
||||
<button
|
||||
key={entry.label}
|
||||
onClick={() => setRoute(entry.route)}
|
||||
className={`rounded px-3 py-1 text-sm ${
|
||||
entry.active.includes(route.view)
|
||||
? 'bg-gray-900 text-white'
|
||||
: 'hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{entry.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-gray-600">{staff.name}</span>
|
||||
<button
|
||||
@@ -52,13 +82,38 @@ export default function App() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{view.name === 'businesses' ? (
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 overflow-auto px-6 py-6">
|
||||
<Businesses onOpen={(id) => setView({ name: 'business', id })} />
|
||||
{route.view === 'business' ? (
|
||||
<main className="flex min-h-0 flex-1 flex-col px-6 py-4">
|
||||
<BusinessDetail
|
||||
id={route.id}
|
||||
onBack={() => setRoute({ view: 'businesses' })}
|
||||
onOpenBuyer={(id) => setRoute({ view: 'buyer', id })}
|
||||
/>
|
||||
</main>
|
||||
) : (
|
||||
<main className="flex min-h-0 flex-1 flex-col px-6 py-4">
|
||||
<BusinessDetail id={view.id} onBack={() => setView({ name: 'businesses' })} />
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 overflow-auto px-6 py-6">
|
||||
{route.view === 'businesses' && (
|
||||
<Businesses onOpen={(id) => setRoute({ view: 'business', id })} />
|
||||
)}
|
||||
{route.view === 'buyers' && (
|
||||
<Buyers
|
||||
onOpen={(id) => setRoute({ view: 'buyer', id })}
|
||||
onNewInquiry={() => setRoute({ view: 'new-inquiry' })}
|
||||
/>
|
||||
)}
|
||||
{route.view === 'new-inquiry' && (
|
||||
<NewInquiry
|
||||
onCreated={(id) => setRoute({ view: 'buyer', id })}
|
||||
onCancel={() => setRoute({ view: 'buyers' })}
|
||||
/>
|
||||
)}
|
||||
{route.view === 'buyer' && (
|
||||
<BuyerDetail
|
||||
id={route.id}
|
||||
onBack={() => setRoute({ view: 'buyers' })}
|
||||
onOpenBusiness={(id) => setRoute({ view: 'business', id })}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
)}
|
||||
</div>
|
||||
|
||||
172
web/src/api.ts
172
web/src/api.ts
@@ -38,6 +38,132 @@ export interface ScanResult {
|
||||
missing: number;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ Buyer side
|
||||
export type BuyerStatus = 'ACTIVE' | 'DEACTIVATED' | 'LEGACY';
|
||||
export type NdaStatus = 'SENT' | 'SIGNED';
|
||||
export type DealStatus = 'NEW' | 'INFO_SENT' | 'DUE_DILIGENCE' | 'LOI' | 'CLOSING' | 'ENDED';
|
||||
|
||||
/** Calendar day as 'YYYY-MM-DD' — the API never sends date columns as instants. */
|
||||
export type Day = string;
|
||||
|
||||
export interface PrimaryContact {
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
}
|
||||
|
||||
export interface BuyerListItem {
|
||||
id: string;
|
||||
company_name: string | null;
|
||||
status: BuyerStatus;
|
||||
primary_contact: PrimaryContact | null;
|
||||
nda_count: number;
|
||||
open_deal_count: number;
|
||||
}
|
||||
|
||||
export interface BuyerList {
|
||||
buyers: BuyerListItem[];
|
||||
counts: Record<BuyerStatus, number>;
|
||||
}
|
||||
|
||||
export interface DuplicateCandidate {
|
||||
buyer_id: string;
|
||||
company_name: string | null;
|
||||
buyer_status: BuyerStatus;
|
||||
/** Any of 'email' | 'name' | 'phone' — a buyer can match on several at once. */
|
||||
matched_on: string[];
|
||||
contact_name: string;
|
||||
contact_email: string | null;
|
||||
nda_count: number;
|
||||
last_date: Day | null;
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
cell: string | null;
|
||||
is_primary: boolean;
|
||||
}
|
||||
|
||||
export interface Deal {
|
||||
id: string;
|
||||
status: DealStatus;
|
||||
follow_up_at: Day | null;
|
||||
note_count: number;
|
||||
business: { id: string; name: string; status: BusinessStatus };
|
||||
}
|
||||
|
||||
export interface NdaRound {
|
||||
id: string;
|
||||
status: NdaStatus;
|
||||
nas_path: string | null;
|
||||
sent_at: Day | null;
|
||||
signed_at: Day | null;
|
||||
intro_date: Day | null;
|
||||
preferred_businesses_text: string | null;
|
||||
total_purchase_price: string | null;
|
||||
down_payment: string | null;
|
||||
deals: Deal[];
|
||||
}
|
||||
|
||||
export interface Buyer {
|
||||
id: string;
|
||||
company_name: string | null;
|
||||
status: BuyerStatus;
|
||||
address: string | null;
|
||||
state: string | null;
|
||||
background_experience: string | null;
|
||||
how_heard: string | null;
|
||||
/** null = not answered on the intake sheet, distinct from an explicit false. */
|
||||
interested_in_updates: boolean | null;
|
||||
contacts: Contact[];
|
||||
ndas: NdaRound[];
|
||||
/** Only present on the PATCH response. */
|
||||
open_deal_count?: number;
|
||||
}
|
||||
|
||||
export interface DealNote {
|
||||
id: string;
|
||||
text: string;
|
||||
highlight: boolean;
|
||||
created_at: string;
|
||||
author: string | null;
|
||||
}
|
||||
|
||||
export interface BusinessDeal {
|
||||
id: string;
|
||||
status: DealStatus;
|
||||
buyer_id: string;
|
||||
company_name: string | null;
|
||||
buyer_status: BuyerStatus;
|
||||
contact_name: string | null;
|
||||
intro_date: Day | null;
|
||||
signed_at: Day | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface InquiryInput {
|
||||
buyer_id?: string;
|
||||
company_name?: string;
|
||||
contact: { name: string; email?: string; phone?: string; cell?: string };
|
||||
business_id: string;
|
||||
backfill?: {
|
||||
deal_status: DealStatus;
|
||||
nda_status: NdaStatus;
|
||||
signed_at?: string;
|
||||
nda_nas_path?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface InquiryResult {
|
||||
buyer_id: string;
|
||||
nda_id: string;
|
||||
deal_id: string;
|
||||
created: { buyer: boolean; contact: boolean };
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(readonly status: number, message: string) {
|
||||
super(message);
|
||||
@@ -53,26 +179,60 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
function post<T>(path: string, body?: unknown): Promise<T> {
|
||||
function send<T>(method: 'POST' | 'PATCH', path: string, body?: unknown): Promise<T> {
|
||||
return request<T>(path, {
|
||||
method: 'POST',
|
||||
method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
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 qs = (params: Record<string, string>) =>
|
||||
new URLSearchParams(params).toString();
|
||||
|
||||
export const api = {
|
||||
me: () => request<Staff>('/api/me'),
|
||||
staff: () => request<Staff[]>('/api/staff'),
|
||||
login: (staffId: string) => post<{ ok: boolean; staff: Staff }>('/api/login', { staff_id: staffId }),
|
||||
logout: () => post<{ ok: boolean }>('/api/logout'),
|
||||
businesses: (status: BusinessStatus, search: string) =>
|
||||
request<BusinessList>(
|
||||
`/api/businesses?status=${encodeURIComponent(status)}&search=${encodeURIComponent(search)}`,
|
||||
),
|
||||
businesses: (status: BusinessStatus | '', search: string) =>
|
||||
request<BusinessList>(`/api/businesses?${qs({ status, search })}`),
|
||||
business: (id: string) => request<Business>(`/api/businesses/${id}`),
|
||||
businessFiles: (id: string) => request<BusinessFile[]>(`/api/businesses/${id}/files`),
|
||||
businessDeals: (id: string) => request<BusinessDeal[]>(`/api/businesses/${id}/deals`),
|
||||
scan: () => post<ScanResult>('/api/businesses/scan'),
|
||||
|
||||
buyers: (status: BuyerStatus | '', search: string) =>
|
||||
request<BuyerList>(`/api/buyers?${qs({ status, search })}`),
|
||||
buyer: (id: string) => request<Buyer>(`/api/buyers/${id}`),
|
||||
updateBuyer: (id: string, body: Record<string, unknown>) =>
|
||||
patch<Buyer>(`/api/buyers/${id}`, body),
|
||||
duplicates: (probe: { email?: string; name?: string; phone?: string }) =>
|
||||
request<{ candidates: DuplicateCandidate[] }>(
|
||||
`/api/buyers/duplicates?${qs({
|
||||
email: probe.email ?? '',
|
||||
name: probe.name ?? '',
|
||||
phone: probe.phone ?? '',
|
||||
})}`,
|
||||
),
|
||||
createInquiry: (body: InquiryInput) => post<InquiryResult>('/api/inquiries', body),
|
||||
addContact: (buyerId: string, body: Record<string, unknown>) =>
|
||||
post<Contact>(`/api/buyers/${buyerId}/contacts`, body),
|
||||
updateContact: (id: string, body: Record<string, unknown>) =>
|
||||
patch<Contact>(`/api/contacts/${id}`, body),
|
||||
updateNda: (id: string, body: Record<string, unknown>) =>
|
||||
patch<NdaRound>(`/api/ndas/${id}`, body),
|
||||
addDeal: (ndaId: string, businessId: string) =>
|
||||
post<Deal>(`/api/ndas/${ndaId}/deals`, { business_id: businessId }),
|
||||
setDealStatus: (id: string, status: DealStatus, comment?: string) =>
|
||||
post<{ id: string; status: DealStatus; follow_up_at: Day | null }>(
|
||||
`/api/deals/${id}/status`,
|
||||
{ status, comment },
|
||||
),
|
||||
dealNotes: (id: string) => request<DealNote[]>(`/api/deals/${id}/notes`),
|
||||
};
|
||||
|
||||
/** Same-origin streaming URL of one file inside a business directory. */
|
||||
|
||||
362
web/src/components.tsx
Normal file
362
web/src/components.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
api,
|
||||
type BusinessListItem,
|
||||
type BusinessStatus,
|
||||
type BuyerStatus,
|
||||
type Day,
|
||||
type DealStatus,
|
||||
type NdaStatus,
|
||||
} from './api.js';
|
||||
|
||||
/** 'YYYY-MM-DD' is parsed by hand — new Date('…') would shift the day by the UTC offset. */
|
||||
export function formatDay(day: Day | null | undefined): string {
|
||||
if (!day) return '—';
|
||||
const [year = 0, month = 1, dayOfMonth = 1] = day.split('-').map(Number);
|
||||
return new Date(year, month - 1, dayOfMonth).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export function formatStamp(iso: string): string {
|
||||
return new Date(iso).toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
export const DEAL_LABELS: Record<DealStatus, string> = {
|
||||
NEW: 'New',
|
||||
INFO_SENT: 'Info sent',
|
||||
DUE_DILIGENCE: 'Due diligence',
|
||||
LOI: 'LOI',
|
||||
CLOSING: 'Closing',
|
||||
ENDED: 'Ended',
|
||||
};
|
||||
|
||||
/** The label of the *action* that puts a deal into each status. */
|
||||
export const DEAL_ACTIONS: Record<DealStatus, string> = {
|
||||
NEW: 'Back to new',
|
||||
INFO_SENT: 'Send info',
|
||||
DUE_DILIGENCE: 'Start due diligence',
|
||||
LOI: 'LOI',
|
||||
CLOSING: 'Closing',
|
||||
ENDED: 'End deal',
|
||||
};
|
||||
|
||||
/** The happy path; ENDED is reachable from everywhere and therefore not in it. */
|
||||
export const DEAL_FLOW: DealStatus[] = ['NEW', 'INFO_SENT', 'DUE_DILIGENCE', 'LOI', 'CLOSING'];
|
||||
|
||||
const TONES: Record<string, string> = {
|
||||
ACTIVE: 'border-green-300 bg-green-50 text-green-800',
|
||||
SIGNED: 'border-green-300 bg-green-50 text-green-800',
|
||||
CLOSING: 'border-green-300 bg-green-50 text-green-800',
|
||||
SENT: 'border-amber-300 bg-amber-50 text-amber-800',
|
||||
NEW: 'border-blue-300 bg-blue-50 text-blue-800',
|
||||
INFO_SENT: 'border-blue-300 bg-blue-50 text-blue-800',
|
||||
DUE_DILIGENCE: 'border-indigo-300 bg-indigo-50 text-indigo-800',
|
||||
LOI: 'border-indigo-300 bg-indigo-50 text-indigo-800',
|
||||
DEACTIVATED: 'border-gray-300 bg-gray-100 text-gray-600',
|
||||
ENDED: 'border-gray-300 bg-gray-100 text-gray-600',
|
||||
LEGACY: 'border-gray-300 bg-gray-100 text-gray-600',
|
||||
};
|
||||
|
||||
export function StatusBadge({
|
||||
status,
|
||||
label,
|
||||
}: {
|
||||
status: BuyerStatus | NdaStatus | DealStatus | BusinessStatus | string;
|
||||
label?: string;
|
||||
}) {
|
||||
const tone = TONES[status] ?? 'border-gray-300 bg-white text-gray-600';
|
||||
return (
|
||||
<span className={`rounded-full border px-2 py-0.5 text-xs whitespace-nowrap ${tone}`}>
|
||||
{label ?? status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Panel({
|
||||
title,
|
||||
action,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
action?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded border border-gray-200 bg-white">
|
||||
<div className="flex items-center justify-between border-b border-gray-200 px-3 py-2">
|
||||
<h2 className="text-xs font-medium uppercase tracking-wide text-gray-500">{title}</h2>
|
||||
{action}
|
||||
</div>
|
||||
<div className="p-3">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const INPUT = 'rounded border border-gray-300 px-2 py-1 text-sm';
|
||||
|
||||
/**
|
||||
* Click the value to edit it: Enter (or blur) saves, Escape cancels. Multiline
|
||||
* fields get explicit buttons because Enter has to stay a newline there.
|
||||
*/
|
||||
export function InlineField({
|
||||
label,
|
||||
value,
|
||||
multiline,
|
||||
type = 'text',
|
||||
placeholder,
|
||||
onSave,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | null;
|
||||
multiline?: boolean;
|
||||
type?: 'text' | 'date';
|
||||
placeholder?: string;
|
||||
onSave: (next: string) => Promise<unknown> | void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value ?? '');
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
function start() {
|
||||
setDraft(value ?? '');
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (busy) return;
|
||||
if (draft === (value ?? '')) return setEditing(false);
|
||||
setBusy(true);
|
||||
try {
|
||||
await onSave(draft);
|
||||
setEditing(false);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const shown = type === 'date' ? formatDay(value) : value;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs uppercase tracking-wide text-gray-500">{label}</span>
|
||||
{editing ? (
|
||||
multiline ? (
|
||||
<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">
|
||||
<button onClick={save} disabled={busy} className="text-xs text-blue-600 hover:underline">
|
||||
Save
|
||||
</button>
|
||||
<button onClick={() => setEditing(false)} className="text-xs text-gray-500 hover:underline">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
autoFocus
|
||||
type={type}
|
||||
value={draft}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={save}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') e.currentTarget.blur();
|
||||
if (e.key === 'Escape') setEditing(false);
|
||||
}}
|
||||
className={`${INPUT} w-full`}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<button
|
||||
onClick={start}
|
||||
title="Click to edit"
|
||||
className="min-h-[1.5rem] whitespace-pre-wrap rounded px-1 py-0.5 text-left text-sm hover:bg-gray-100"
|
||||
>
|
||||
{shown && shown !== '—' ? shown : <span className="text-gray-400">— add —</span>}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Yes / No / not answered. The third state is not a nicety: the intake sheets
|
||||
* are often blank there, and "we never asked" must not read as "they said no".
|
||||
*/
|
||||
export function TriState({
|
||||
label,
|
||||
value,
|
||||
onSave,
|
||||
}: {
|
||||
label: string;
|
||||
value: boolean | null;
|
||||
onSave: (next: boolean | null) => Promise<unknown> | void;
|
||||
}) {
|
||||
const options: { value: boolean | null; label: string }[] = [
|
||||
{ value: true, label: 'Yes' },
|
||||
{ value: false, label: 'No' },
|
||||
{ value: null, label: 'not answered' },
|
||||
];
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs uppercase tracking-wide text-gray-500">{label}</span>
|
||||
<div className="flex overflow-hidden rounded border border-gray-300">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={String(option.value)}
|
||||
onClick={() => option.value !== value && onSave(option.value)}
|
||||
className={`border-r border-gray-300 px-2 py-0.5 text-xs last:border-r-0 ${
|
||||
option.value === value
|
||||
? 'bg-gray-900 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Searchable business picker over the normal business list endpoint. Defaults
|
||||
* to ACTIVE because that is what buyers get shown, but sold/inactive stay
|
||||
* reachable for backfilled paper records.
|
||||
*/
|
||||
export function BusinessPicker({
|
||||
value,
|
||||
onPick,
|
||||
}: {
|
||||
value: BusinessListItem | null;
|
||||
onPick: (business: BusinessListItem | null) => void;
|
||||
}) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [status, setStatus] = useState<BusinessStatus | ''>('ACTIVE');
|
||||
const [list, setList] = useState<BusinessListItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (value) return;
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => {
|
||||
api
|
||||
.businesses(status, search)
|
||||
.then((res) => !cancelled && setList(res.businesses))
|
||||
.catch(() => !cancelled && setList([]));
|
||||
}, 200);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [search, status, value]);
|
||||
|
||||
if (value) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded border border-gray-300 bg-gray-50 px-2 py-1.5 text-sm">
|
||||
<span className="flex-1 truncate">{value.name}</span>
|
||||
<StatusBadge status={value.status} />
|
||||
<button onClick={() => onPick(null)} className="text-xs text-blue-600 hover:underline">
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search business…"
|
||||
className={`${INPUT} flex-1`}
|
||||
/>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as BusinessStatus | '')}
|
||||
className={INPUT}
|
||||
>
|
||||
<option value="ACTIVE">Active</option>
|
||||
<option value="SOLD">Sold</option>
|
||||
<option value="INACTIVE">Inactive</option>
|
||||
<option value="">All</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="mt-1 max-h-48 overflow-auto rounded border border-gray-200">
|
||||
{list.map((business) => (
|
||||
<button
|
||||
key={business.id}
|
||||
onClick={() => onPick(business)}
|
||||
className="flex w-full items-center gap-2 border-b border-gray-100 px-2 py-1 text-left text-sm last:border-0 hover:bg-blue-50"
|
||||
>
|
||||
<span className="flex-1 truncate">{business.name}</span>
|
||||
<span className="text-xs text-gray-400">{business.status}</span>
|
||||
</button>
|
||||
))}
|
||||
{list.length === 0 && <p className="px-2 py-2 text-sm text-gray-500">No matches.</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Small centred modal — used for the status-change comment. */
|
||||
export function Dialog({
|
||||
title,
|
||||
confirmLabel,
|
||||
busy,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
confirmLabel: string;
|
||||
busy?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
box.current?.querySelector('textarea')?.focus();
|
||||
}, []);
|
||||
return (
|
||||
<div className="fixed inset-0 z-20 flex items-center justify-center bg-black/30 p-4">
|
||||
<div ref={box} className="w-96 rounded-lg border border-gray-200 bg-white p-4 shadow-lg">
|
||||
<h3 className="mb-3 text-sm font-semibold">{title}</h3>
|
||||
{children}
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="rounded border border-gray-300 px-3 py-1.5 text-sm hover:bg-gray-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
className="rounded bg-gray-900 px-3 py-1.5 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
businessFileUrl,
|
||||
viewerUrl,
|
||||
type Business,
|
||||
type BusinessDeal,
|
||||
type BusinessFile,
|
||||
} from '../api.js';
|
||||
import { DEAL_LABELS, StatusBadge, formatDay } from '../components.js';
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
@@ -24,16 +26,28 @@ function formatDate(iso: string): string {
|
||||
|
||||
const isPdf = (filePath: string) => filePath.toLowerCase().endsWith('.pdf');
|
||||
|
||||
export default function BusinessDetail({ id, onBack }: { id: string; onBack: () => void }) {
|
||||
export default function BusinessDetail({
|
||||
id,
|
||||
onBack,
|
||||
onOpenBuyer,
|
||||
}: {
|
||||
id: string;
|
||||
onBack: () => void;
|
||||
onOpenBuyer: (buyerId: string) => void;
|
||||
}) {
|
||||
const [business, setBusiness] = useState<Business | null>(null);
|
||||
const [files, setFiles] = useState<BusinessFile[] | null>(null);
|
||||
const [deals, setDeals] = useState<BusinessDeal[] | null>(null);
|
||||
const [dealsOpen, setDealsOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(null);
|
||||
setDealsOpen(false);
|
||||
api.business(id).then(setBusiness).catch((err: Error) => setError(err.message));
|
||||
api.businessFiles(id).then(setFiles).catch((err: Error) => setError(err.message));
|
||||
api.businessDeals(id).then(setDeals).catch((err: Error) => setError(err.message));
|
||||
}, [id]);
|
||||
|
||||
return (
|
||||
@@ -56,6 +70,58 @@ export default function BusinessDetail({ id, onBack }: { id: string; onBack: ()
|
||||
<p className="mt-1 font-mono text-xs text-gray-500">{business.nas_path}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<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">
|
||||
<th className="px-3 py-1.5 font-medium">Buyer</th>
|
||||
<th className="px-3 py-1.5 font-medium">Contact</th>
|
||||
<th className="w-32 px-3 py-1.5 font-medium">Deal</th>
|
||||
<th className="w-32 px-3 py-1.5 font-medium">Intro</th>
|
||||
<th className="w-32 px-3 py-1.5 font-medium">NDA signed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{deals?.map((deal) => (
|
||||
<tr
|
||||
key={deal.id}
|
||||
onClick={() => onOpenBuyer(deal.buyer_id)}
|
||||
className="cursor-pointer border-b border-gray-100 hover:bg-gray-50"
|
||||
>
|
||||
<td className="px-3 py-1.5 text-blue-600">
|
||||
{deal.company_name ?? deal.contact_name ?? 'Buyer'}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-gray-600">{deal.contact_name ?? '—'}</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<StatusBadge status={deal.status} label={DEAL_LABELS[deal.status]} />
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-gray-500">{formatDay(deal.intro_date)}</td>
|
||||
<td className="px-3 py-1.5 text-gray-500">{formatDay(deal.signed_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{deals && deals.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-3 py-3 text-gray-500">
|
||||
No buyer has been introduced to this business yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Master-detail: file table left, PDF viewer right, together filling the viewport. */}
|
||||
|
||||
587
web/src/views/BuyerDetail.tsx
Normal file
587
web/src/views/BuyerDetail.tsx
Normal file
@@ -0,0 +1,587 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
api,
|
||||
type Buyer,
|
||||
type BusinessListItem,
|
||||
type Contact,
|
||||
type Deal,
|
||||
type DealNote,
|
||||
type DealStatus,
|
||||
type NdaRound,
|
||||
} from '../api.js';
|
||||
import {
|
||||
BusinessPicker,
|
||||
DEAL_ACTIONS,
|
||||
DEAL_FLOW,
|
||||
DEAL_LABELS,
|
||||
Dialog,
|
||||
InlineField,
|
||||
Panel,
|
||||
StatusBadge,
|
||||
TriState,
|
||||
formatDay,
|
||||
formatStamp,
|
||||
} from '../components.js';
|
||||
|
||||
const ALL_DEAL_STATUSES = Object.keys(DEAL_LABELS) as DealStatus[];
|
||||
|
||||
/** The step the flow suggests next, plus "End deal" — everything else is a correction. */
|
||||
function nextSteps(status: DealStatus): DealStatus[] {
|
||||
const index = DEAL_FLOW.indexOf(status);
|
||||
const forward = index >= 0 ? DEAL_FLOW[index + 1] : undefined;
|
||||
return [...(forward ? [forward] : []), ...(status === 'ENDED' ? [] : ['ENDED' as DealStatus])];
|
||||
}
|
||||
|
||||
export default function BuyerDetail({
|
||||
id,
|
||||
onBack,
|
||||
onOpenBusiness,
|
||||
}: {
|
||||
id: string;
|
||||
onBack: () => void;
|
||||
onOpenBusiness: (businessId: string) => 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);
|
||||
|
||||
const reload = useCallback(
|
||||
() =>
|
||||
api
|
||||
.buyer(id)
|
||||
.then((res) => {
|
||||
setBuyer(res);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err: Error) => setError(err.message)),
|
||||
[id],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
async function guard(action: () => Promise<unknown>) {
|
||||
try {
|
||||
await action();
|
||||
await reload();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
if (!buyer) {
|
||||
return (
|
||||
<div>
|
||||
<button onClick={onBack} className="text-sm text-blue-600 hover:underline">
|
||||
← Back to buyers
|
||||
</button>
|
||||
{error ? (
|
||||
<p className="mt-2 text-sm text-red-600">{error}</p>
|
||||
) : (
|
||||
<p className="mt-2 text-sm text-gray-500">Loading…</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const primary = buyer.contacts.find((contact) => contact.is_primary) ?? buyer.contacts[0];
|
||||
const openDeals = buyer.ndas.reduce(
|
||||
(sum, nda) => sum + nda.deals.filter((deal) => deal.status !== 'ENDED').length,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<button onClick={onBack} className="text-sm text-blue-600 hover:underline">
|
||||
← Back to buyers
|
||||
</button>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<h1 className="text-lg font-semibold">
|
||||
{buyer.company_name ?? primary?.name ?? 'Buyer'}
|
||||
</h1>
|
||||
<StatusBadge status={buyer.status} />
|
||||
{buyer.company_name && primary && (
|
||||
<span className="text-sm text-gray-500">{primary.name}</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{buyer.status === 'ACTIVE' ? (
|
||||
<button
|
||||
onClick={() => setConfirmDeactivate(true)}
|
||||
className="rounded border border-gray-300 bg-white px-3 py-1.5 text-sm hover:bg-gray-100"
|
||||
>
|
||||
Deactivate
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => guard(() => api.updateBuyer(buyer.id, { status: 'ACTIVE' }))}
|
||||
className="rounded border border-gray-300 bg-white px-3 py-1.5 text-sm hover:bg-gray-100"
|
||||
>
|
||||
Reactivate
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{error && <p className="mt-2 text-sm text-red-600">{error}</p>}
|
||||
</div>
|
||||
|
||||
{confirmDeactivate && (
|
||||
<Dialog
|
||||
title="Deactivate this buyer?"
|
||||
confirmLabel="Deactivate"
|
||||
onCancel={() => setConfirmDeactivate(false)}
|
||||
onConfirm={() => {
|
||||
setConfirmDeactivate(false);
|
||||
void guard(() =>
|
||||
api.updateBuyer(buyer.id, {
|
||||
status: 'DEACTIVATED',
|
||||
end_open_deals: endOpenDeals,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-gray-600">
|
||||
{openDeals === 0
|
||||
? 'This buyer has no open deals.'
|
||||
: `${openDeals} open deal${openDeals === 1 ? '' : 's'} will be ended.`}
|
||||
</p>
|
||||
{openDeals > 0 && (
|
||||
<label className="mt-2 flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={endOpenDeals}
|
||||
onChange={(e) => setEndOpenDeals(e.target.checked)}
|
||||
/>
|
||||
End the open deals
|
||||
</label>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Contacts buyer={buyer} guard={guard} />
|
||||
|
||||
<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}
|
||||
onError={setError}
|
||||
/>
|
||||
))}
|
||||
{buyer.ndas.length === 0 && <p className="text-sm text-gray-500">No NDA rounds yet.</p>}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- Contacts
|
||||
function Contacts({
|
||||
buyer,
|
||||
guard,
|
||||
}: {
|
||||
buyer: Buyer;
|
||||
guard: (action: () => Promise<unknown>) => Promise<void>;
|
||||
}) {
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [draft, setDraft] = useState({ name: '', email: '', phone: '', cell: '' });
|
||||
|
||||
async function add() {
|
||||
if (!draft.name.trim()) return;
|
||||
await guard(() => api.addContact(buyer.id, draft));
|
||||
setDraft({ name: '', email: '', phone: '', cell: '' });
|
||||
setAdding(false);
|
||||
}
|
||||
|
||||
const field = (contact: Contact, key: 'name' | 'email' | 'phone' | 'cell') => (
|
||||
<InlineField
|
||||
label=""
|
||||
value={contact[key]}
|
||||
onSave={(next) => guard(() => api.updateContact(contact.id, { [key]: next }))}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title="Contacts"
|
||||
action={
|
||||
<button
|
||||
onClick={() => setAdding(!adding)}
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
{adding ? 'Cancel' : 'Add contact'}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500">
|
||||
<th className="w-8 px-2 py-1 font-medium" />
|
||||
<th className="px-2 py-1 font-medium">Name</th>
|
||||
<th className="px-2 py-1 font-medium">E-mail</th>
|
||||
<th className="px-2 py-1 font-medium">Phone</th>
|
||||
<th className="px-2 py-1 font-medium">Cell</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{buyer.contacts.map((contact) => (
|
||||
<tr key={contact.id} className="border-b border-gray-100 align-top">
|
||||
<td className="px-2 py-1">
|
||||
<button
|
||||
title={contact.is_primary ? 'Primary contact' : 'Make primary contact'}
|
||||
onClick={() =>
|
||||
!contact.is_primary &&
|
||||
guard(() => api.updateContact(contact.id, { is_primary: true }))
|
||||
}
|
||||
className={contact.is_primary ? 'text-amber-500' : 'text-gray-300 hover:text-amber-400'}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-2 py-1">{field(contact, 'name')}</td>
|
||||
<td className="px-2 py-1">{field(contact, 'email')}</td>
|
||||
<td className="px-2 py-1">{field(contact, 'phone')}</td>
|
||||
<td className="px-2 py-1">{field(contact, 'cell')}</td>
|
||||
</tr>
|
||||
))}
|
||||
{adding && (
|
||||
<tr className="border-b border-gray-100">
|
||||
<td className="px-2 py-1" />
|
||||
{(['name', 'email', 'phone', 'cell'] as const).map((key) => (
|
||||
<td key={key} className="px-2 py-1">
|
||||
<input
|
||||
autoFocus={key === 'name'}
|
||||
value={draft[key]}
|
||||
placeholder={key}
|
||||
onChange={(e) => setDraft({ ...draft, [key]: e.target.value })}
|
||||
onKeyDown={(e) => e.key === 'Enter' && add()}
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{adding && (
|
||||
<button
|
||||
onClick={add}
|
||||
className="mt-2 rounded bg-gray-900 px-3 py-1 text-xs text-white"
|
||||
>
|
||||
Save contact
|
||||
</button>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ NDA round
|
||||
function Round({
|
||||
nda,
|
||||
guard,
|
||||
onOpenBusiness,
|
||||
onError,
|
||||
}: {
|
||||
nda: NdaRound;
|
||||
guard: (action: () => Promise<unknown>) => Promise<void>;
|
||||
onOpenBusiness: (businessId: string) => void;
|
||||
onError: (message: string) => void;
|
||||
}) {
|
||||
const [picking, setPicking] = useState(false);
|
||||
const [business, setBusiness] = useState<BusinessListItem | null>(null);
|
||||
|
||||
async function addBusiness(picked: BusinessListItem) {
|
||||
setBusiness(picked);
|
||||
await guard(() => api.addDeal(nda.id, picked.id));
|
||||
setBusiness(null);
|
||||
setPicking(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded border border-gray-200">
|
||||
<div className="flex flex-wrap items-center gap-3 border-b border-gray-200 bg-gray-50 px-3 py-2">
|
||||
<StatusBadge status={nda.status} label={nda.status === 'SIGNED' ? 'NDA signed' : 'NDA sent'} />
|
||||
{nda.status === 'SIGNED' ? (
|
||||
<span className="text-sm text-gray-600">on {formatDay(nda.signed_at)}</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => guard(() => api.updateNda(nda.id, { status: 'SIGNED' }))}
|
||||
className="rounded border border-gray-300 bg-white px-2 py-1 text-xs hover:bg-gray-100"
|
||||
>
|
||||
Mark signed
|
||||
</button>
|
||||
)}
|
||||
<span className="text-xs text-gray-500">sent {formatDay(nda.sent_at)}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3 px-3 py-3 md:grid-cols-4">
|
||||
<InlineField
|
||||
label="Intro date"
|
||||
type="date"
|
||||
value={nda.intro_date}
|
||||
onSave={(next) => guard(() => api.updateNda(nda.id, { intro_date: next }))}
|
||||
/>
|
||||
<InlineField
|
||||
label="Signed date"
|
||||
type="date"
|
||||
value={nda.signed_at}
|
||||
onSave={(next) => guard(() => api.updateNda(nda.id, { signed_at: next }))}
|
||||
/>
|
||||
<InlineField
|
||||
label="Total purchase price"
|
||||
value={nda.total_purchase_price}
|
||||
onSave={(next) => guard(() => api.updateNda(nda.id, { total_purchase_price: next }))}
|
||||
/>
|
||||
<InlineField
|
||||
label="Down payment"
|
||||
value={nda.down_payment}
|
||||
onSave={(next) => guard(() => api.updateNda(nda.id, { down_payment: next }))}
|
||||
/>
|
||||
<div className="col-span-2">
|
||||
<InlineField
|
||||
label="Preferred businesses"
|
||||
value={nda.preferred_businesses_text}
|
||||
multiline
|
||||
onSave={(next) =>
|
||||
guard(() => api.updateNda(nda.id, { preferred_businesses_text: next }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<InlineField
|
||||
label="NDA PDF on the NAS"
|
||||
value={nda.nas_path}
|
||||
onSave={(next) => guard(() => api.updateNda(nda.id, { nas_path: next }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200">
|
||||
{nda.deals.map((deal) => (
|
||||
<DealRow
|
||||
key={deal.id}
|
||||
deal={deal}
|
||||
guard={guard}
|
||||
onOpenBusiness={onOpenBusiness}
|
||||
onError={onError}
|
||||
/>
|
||||
))}
|
||||
{nda.deals.length === 0 && (
|
||||
<p className="px-3 py-2 text-sm text-gray-500">No businesses in this round.</p>
|
||||
)}
|
||||
<div className="px-3 py-2">
|
||||
{picking ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<BusinessPicker
|
||||
value={business}
|
||||
onPick={(picked) => picked && addBusiness(picked)}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setPicking(false)}
|
||||
className="self-start text-xs text-gray-500 hover:underline"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setPicking(true)}
|
||||
className="text-xs text-blue-600 hover:underline"
|
||||
>
|
||||
+ Add business to this round
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- Deal
|
||||
function DealRow({
|
||||
deal,
|
||||
guard,
|
||||
onOpenBusiness,
|
||||
onError,
|
||||
}: {
|
||||
deal: Deal;
|
||||
guard: (action: () => Promise<unknown>) => Promise<void>;
|
||||
onOpenBusiness: (businessId: string) => void;
|
||||
onError: (message: string) => void;
|
||||
}) {
|
||||
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 forward = nextSteps(deal.status);
|
||||
const corrections = ALL_DEAL_STATUSES.filter(
|
||||
(status) => status !== deal.status && !forward.includes(status),
|
||||
);
|
||||
|
||||
function pick(status: DealStatus) {
|
||||
setMenuOpen(false);
|
||||
setComment('');
|
||||
setPending(status);
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
if (!pending) return;
|
||||
setBusy(true);
|
||||
await guard(() => api.setDealStatus(deal.id, pending, comment));
|
||||
setBusy(false);
|
||||
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">
|
||||
<button
|
||||
onClick={() => onOpenBusiness(deal.business.id)}
|
||||
className="flex-1 truncate text-left text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
{deal.business.name}
|
||||
</button>
|
||||
<StatusBadge status={deal.status} label={DEAL_LABELS[deal.status]} />
|
||||
{deal.follow_up_at && (
|
||||
<span className="text-xs text-gray-500">follow up {formatDay(deal.follow_up_at)}</span>
|
||||
)}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
className="rounded border border-gray-300 bg-white px-2 py-1 text-xs hover:bg-gray-100"
|
||||
>
|
||||
Actions ▾
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="absolute right-0 z-10 mt-1 w-48 rounded border border-gray-200 bg-white py-1 shadow-lg">
|
||||
{forward.map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => pick(status)}
|
||||
className="block w-full px-3 py-1 text-left text-sm hover:bg-gray-100"
|
||||
>
|
||||
{DEAL_ACTIONS[status]}
|
||||
</button>
|
||||
))}
|
||||
<div className="my-1 border-t border-gray-100 px-3 pt-1 text-xs text-gray-400">
|
||||
Correct to…
|
||||
</div>
|
||||
{corrections.map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
onClick={() => pick(status)}
|
||||
className="block w-full px-3 py-1 text-left text-sm text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
{DEAL_LABELS[status]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{pending && (
|
||||
<Dialog
|
||||
title={DEAL_ACTIONS[pending]}
|
||||
confirmLabel="Confirm"
|
||||
busy={busy}
|
||||
onCancel={() => setPending(null)}
|
||||
onConfirm={confirm}
|
||||
>
|
||||
<p className="mb-2 text-sm text-gray-600">
|
||||
{deal.business.name}: {DEAL_LABELS[deal.status]} → {DEAL_LABELS[pending]}
|
||||
</p>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={comment}
|
||||
onChange={(e) => setComment(e.target.value)}
|
||||
placeholder="Comment (optional) — saved as a note"
|
||||
className="w-full rounded border border-gray-300 px-2 py-1 text-sm"
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
web/src/views/Buyers.tsx
Normal file
118
web/src/views/Buyers.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, type BuyerList, type BuyerStatus } from '../api.js';
|
||||
import { StatusBadge } from '../components.js';
|
||||
|
||||
const CHIPS: { status: BuyerStatus; label: string }[] = [
|
||||
{ status: 'ACTIVE', label: 'Active' },
|
||||
{ status: 'DEACTIVATED', label: 'Deactivated' },
|
||||
{ status: 'LEGACY', label: 'Legacy' },
|
||||
];
|
||||
|
||||
export default function Buyers({
|
||||
onOpen,
|
||||
onNewInquiry,
|
||||
}: {
|
||||
onOpen: (id: string) => void;
|
||||
onNewInquiry: () => void;
|
||||
}) {
|
||||
// No filter = every buyer; clicking the active chip again clears it, because
|
||||
// the history of deactivated buyers matters as much as the active ones.
|
||||
const [filter, setFilter] = useState<BuyerStatus | ''>('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [data, setData] = useState<BuyerList | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.buyers(filter, search)
|
||||
.then((res) => {
|
||||
if (!cancelled) {
|
||||
setData(res);
|
||||
setError(null);
|
||||
}
|
||||
})
|
||||
.catch((err: Error) => !cancelled && setError(err.message));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [filter, search]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div className="flex gap-1">
|
||||
{CHIPS.map((chip) => (
|
||||
<button
|
||||
key={chip.status}
|
||||
onClick={() => setFilter(filter === chip.status ? '' : chip.status)}
|
||||
className={`rounded px-3 py-1.5 text-sm ${
|
||||
filter === chip.status
|
||||
? 'bg-gray-900 text-white'
|
||||
: 'border border-gray-300 bg-white hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{chip.label} ({data?.counts[chip.status] ?? 0})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search company, contact, e-mail…"
|
||||
className="w-64 rounded border border-gray-300 px-2 py-1.5 text-sm"
|
||||
/>
|
||||
<button
|
||||
onClick={onNewInquiry}
|
||||
className="rounded bg-gray-900 px-3 py-1.5 text-sm text-white"
|
||||
>
|
||||
New inquiry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="mb-3 text-sm text-red-600">{error}</p>}
|
||||
|
||||
<table className="w-full border-collapse bg-white text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-left text-xs uppercase tracking-wide text-gray-500">
|
||||
<th className="px-3 py-2 font-medium">Company / name</th>
|
||||
<th className="px-3 py-2 font-medium">Primary contact</th>
|
||||
<th className="px-3 py-2 font-medium">E-mail</th>
|
||||
<th className="w-24 px-3 py-2 text-right font-medium">NDA rounds</th>
|
||||
<th className="w-24 px-3 py-2 text-right font-medium">Open deals</th>
|
||||
<th className="w-32 px-3 py-2 font-medium">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.buyers.map((buyer) => (
|
||||
<tr
|
||||
key={buyer.id}
|
||||
onClick={() => onOpen(buyer.id)}
|
||||
className="cursor-pointer border-b border-gray-100 hover:bg-gray-50"
|
||||
>
|
||||
<td className="px-3 py-1.5">
|
||||
{buyer.company_name ?? buyer.primary_contact?.name ?? '—'}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-gray-600">{buyer.primary_contact?.name ?? '—'}</td>
|
||||
<td className="px-3 py-1.5 text-gray-600">{buyer.primary_contact?.email ?? '—'}</td>
|
||||
<td className="px-3 py-1.5 text-right text-gray-600">{buyer.nda_count}</td>
|
||||
<td className="px-3 py-1.5 text-right text-gray-600">{buyer.open_deal_count}</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<StatusBadge status={buyer.status} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{data && data.buyers.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-3 py-4 text-gray-500">
|
||||
No buyers.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
296
web/src/views/NewInquiry.tsx
Normal file
296
web/src/views/NewInquiry.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
api,
|
||||
type BusinessListItem,
|
||||
type DealStatus,
|
||||
type DuplicateCandidate,
|
||||
type InquiryInput,
|
||||
} from '../api.js';
|
||||
import { BusinessPicker, DEAL_LABELS, StatusBadge, formatDay } from '../components.js';
|
||||
|
||||
const INPUT = 'w-full rounded border border-gray-300 px-2 py-1.5 text-sm';
|
||||
const LABEL = 'mb-1 block text-xs uppercase tracking-wide text-gray-500';
|
||||
|
||||
/** A name shorter than this matches half the address book — not worth probing. */
|
||||
const MIN_NAME_PROBE = 3;
|
||||
|
||||
export default function NewInquiry({
|
||||
onCreated,
|
||||
onCancel,
|
||||
}: {
|
||||
onCreated: (buyerId: string) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [cell, setCell] = useState('');
|
||||
const [company, setCompany] = useState('');
|
||||
const [business, setBusiness] = useState<BusinessListItem | null>(null);
|
||||
|
||||
// The e-mail is only probed once the field is left — probing every keystroke
|
||||
// would fire on "a", "an", "ann@…" and never match anything useful.
|
||||
const [emailProbe, setEmailProbe] = useState('');
|
||||
const [candidates, setCandidates] = useState<DuplicateCandidate[]>([]);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [locked, setLocked] = useState<DuplicateCandidate | null>(null);
|
||||
|
||||
const [backfillOpen, setBackfillOpen] = useState(false);
|
||||
const [dealStatus, setDealStatus] = useState<DealStatus>('NEW');
|
||||
const [ndaSigned, setNdaSigned] = useState(false);
|
||||
const [signedAt, setSignedAt] = useState('');
|
||||
const [ndaPath, setNdaPath] = useState('');
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (locked) return;
|
||||
const nameProbe = name.trim().length >= MIN_NAME_PROBE ? name.trim() : '';
|
||||
const phoneProbe = phone.trim() || cell.trim();
|
||||
if (!nameProbe && !emailProbe && !phoneProbe) {
|
||||
setCandidates([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => {
|
||||
api
|
||||
.duplicates({ email: emailProbe, name: nameProbe, phone: phoneProbe })
|
||||
.then((res) => !cancelled && setCandidates(res.candidates))
|
||||
.catch(() => !cancelled && setCandidates([]));
|
||||
}, 350);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [name, emailProbe, phone, cell, locked]);
|
||||
|
||||
async function submit() {
|
||||
if (!name.trim()) return setError('A contact name is required.');
|
||||
if (!business) return setError('Pick a business.');
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
const body: InquiryInput = {
|
||||
contact: {
|
||||
name: name.trim(),
|
||||
email: email.trim(),
|
||||
phone: phone.trim(),
|
||||
cell: cell.trim(),
|
||||
},
|
||||
business_id: business.id,
|
||||
};
|
||||
if (locked) body.buyer_id = locked.buyer_id;
|
||||
else if (company.trim()) body.company_name = company.trim();
|
||||
if (backfillOpen) {
|
||||
body.backfill = {
|
||||
deal_status: dealStatus,
|
||||
nda_status: ndaSigned ? 'SIGNED' : 'SENT',
|
||||
signed_at: ndaSigned ? signedAt : '',
|
||||
nda_nas_path: ndaPath.trim(),
|
||||
};
|
||||
}
|
||||
try {
|
||||
const res = await api.createInquiry(body);
|
||||
onCreated(res.buyer_id);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const showWarning = !locked && !dismissed && candidates.length > 0;
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-2xl">
|
||||
<button onClick={onCancel} className="text-sm text-blue-600 hover:underline">
|
||||
← Back to buyers
|
||||
</button>
|
||||
<h1 className="mt-2 mb-4 text-lg font-semibold">New inquiry</h1>
|
||||
|
||||
{locked && (
|
||||
<div className="mb-3 flex items-center gap-2 rounded border border-blue-200 bg-blue-50 px-3 py-2 text-sm">
|
||||
<span className="rounded-full border border-blue-300 bg-white px-2 py-0.5 text-xs text-blue-800">
|
||||
existing buyer
|
||||
</span>
|
||||
<span className="flex-1">
|
||||
{locked.company_name ?? locked.contact_name}
|
||||
<span className="text-gray-500"> · {locked.nda_count} NDA round(s)</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setLocked(null)}
|
||||
className="text-xs text-blue-700 hover:underline"
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showWarning && (
|
||||
<div className="mb-3 rounded border border-amber-300 bg-amber-50 p-3">
|
||||
<p className="text-sm font-medium text-amber-900">
|
||||
{candidates.length === 1 ? 'A buyer already matches' : 'Existing buyers match'} this
|
||||
contact
|
||||
</p>
|
||||
<ul className="mt-2 flex flex-col gap-2">
|
||||
{candidates.map((candidate) => (
|
||||
<li
|
||||
key={candidate.buyer_id}
|
||||
className="flex items-center gap-2 rounded border border-amber-200 bg-white px-2 py-1.5 text-sm"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">
|
||||
{candidate.company_name ?? candidate.contact_name}
|
||||
</span>
|
||||
<StatusBadge status={candidate.buyer_status} />
|
||||
<span className="text-xs text-gray-500">
|
||||
matched on {candidate.matched_on.join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="truncate text-xs text-gray-500">
|
||||
{candidate.contact_name}
|
||||
{candidate.contact_email ? ` · ${candidate.contact_email}` : ''} ·{' '}
|
||||
{candidate.nda_count} round(s) · last {formatDay(candidate.last_date)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setLocked(candidate)}
|
||||
className="shrink-0 rounded border border-amber-400 bg-white px-2 py-1 text-xs hover:bg-amber-100"
|
||||
>
|
||||
Use this buyer
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<button
|
||||
onClick={() => setDismissed(true)}
|
||||
className="mt-2 text-xs text-amber-900 underline"
|
||||
>
|
||||
Create new buyer anyway
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 rounded border border-gray-200 bg-white p-3">
|
||||
<div className="col-span-2">
|
||||
<label className={LABEL}>Contact name *</label>
|
||||
<input
|
||||
autoFocus
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className={INPUT}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL}>E-mail</label>
|
||||
<input
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onBlur={() => setEmailProbe(email.trim())}
|
||||
className={INPUT}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL}>Company name</label>
|
||||
<input
|
||||
value={company}
|
||||
onChange={(e) => setCompany(e.target.value)}
|
||||
disabled={locked !== null}
|
||||
className={`${INPUT} disabled:bg-gray-100`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL}>Phone</label>
|
||||
<input value={phone} onChange={(e) => setPhone(e.target.value)} className={INPUT} />
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL}>Cell</label>
|
||||
<input value={cell} onChange={(e) => setCell(e.target.value)} className={INPUT} />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<label className={LABEL}>Business *</label>
|
||||
<BusinessPicker value={business} onPick={setBusiness} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded border border-gray-200 bg-white">
|
||||
<button
|
||||
onClick={() => setBackfillOpen(!backfillOpen)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm"
|
||||
>
|
||||
<span className="text-gray-400">{backfillOpen ? '▾' : '▸'}</span>
|
||||
Backfill existing deal (paper records)
|
||||
</button>
|
||||
{backfillOpen && (
|
||||
<div className="grid grid-cols-2 gap-3 border-t border-gray-200 p-3">
|
||||
<div>
|
||||
<label className={LABEL}>Deal status</label>
|
||||
<select
|
||||
value={dealStatus}
|
||||
onChange={(e) => setDealStatus(e.target.value as DealStatus)}
|
||||
className={INPUT}
|
||||
>
|
||||
{(Object.keys(DEAL_LABELS) as DealStatus[]).map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{DEAL_LABELS[status]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className={LABEL}>NDA</label>
|
||||
<label className="flex items-center gap-2 py-1.5 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={ndaSigned}
|
||||
onChange={(e) => setNdaSigned(e.target.checked)}
|
||||
/>
|
||||
already signed
|
||||
</label>
|
||||
</div>
|
||||
{ndaSigned && (
|
||||
<div>
|
||||
<label className={LABEL}>Signed date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={signedAt}
|
||||
onChange={(e) => setSignedAt(e.target.value)}
|
||||
className={INPUT}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Empty = today.</p>
|
||||
</div>
|
||||
)}
|
||||
<div className={ndaSigned ? '' : 'col-span-2'}>
|
||||
<label className={LABEL}>NDA PDF path on the NAS</label>
|
||||
<input
|
||||
value={ndaPath}
|
||||
onChange={(e) => setNdaPath(e.target.value)}
|
||||
placeholder="e.g. NDA/2024/smith-nda.pdf"
|
||||
className={`${INPUT} font-mono`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={submitting}
|
||||
className="rounded bg-gray-900 px-4 py-2 text-sm text-white disabled:opacity-50"
|
||||
>
|
||||
{submitting ? 'Creating…' : 'Create inquiry'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="rounded border border-gray-300 px-4 py-2 text-sm hover:bg-gray-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user