247 lines
7.1 KiB
TypeScript
247 lines
7.1 KiB
TypeScript
export interface Staff {
|
|
id: string;
|
|
name: string;
|
|
active: boolean;
|
|
}
|
|
|
|
export type BusinessStatus = 'ACTIVE' | 'SOLD' | 'INACTIVE';
|
|
|
|
export interface BusinessListItem {
|
|
id: string;
|
|
name: string;
|
|
status: BusinessStatus;
|
|
}
|
|
|
|
export interface BusinessList {
|
|
businesses: BusinessListItem[];
|
|
counts: Record<BusinessStatus, number>;
|
|
}
|
|
|
|
export interface Business {
|
|
id: string;
|
|
name: string;
|
|
nas_path: string;
|
|
status: BusinessStatus;
|
|
}
|
|
|
|
export interface BusinessFile {
|
|
/** Relative to the business directory, posix separators. */
|
|
path: string;
|
|
size: number;
|
|
mtime: string;
|
|
}
|
|
|
|
export interface ScanResult {
|
|
scanned: number;
|
|
inserted: number;
|
|
updated: number;
|
|
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);
|
|
}
|
|
}
|
|
|
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|
const res = await fetch(path, { credentials: 'same-origin', ...init });
|
|
if (!res.ok) {
|
|
const body = (await res.json().catch(() => null)) as { error?: string } | null;
|
|
throw new ApiError(res.status, body?.error ?? `Request failed (${res.status})`);
|
|
}
|
|
return (await res.json()) as T;
|
|
}
|
|
|
|
function send<T>(method: 'POST' | 'PATCH', path: string, body?: unknown): Promise<T> {
|
|
return request<T>(path, {
|
|
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?${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. */
|
|
export function businessFileUrl(id: string, filePath: string): string {
|
|
return `/api/businesses/${id}/file?path=${encodeURIComponent(filePath)}`;
|
|
}
|
|
|
|
/** The standalone (unbundled) pdf.js viewer page, pointed at a business file. */
|
|
export function viewerUrl(id: string, filePath: string): string {
|
|
return `/viewer/index.html?file=${encodeURIComponent(businessFileUrl(id, filePath))}`;
|
|
}
|