/** * API client for the FastAPI backend (Ops-Core). * Base URL is read from NEXT_PUBLIC_API_BASE (default http://localhost:8000). */ const getBase = (): string => { if (typeof window !== "undefined") { return process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000"; } return process.env.NEXT_PUBLIC_API_BASE ?? "http://localhost:8000"; }; export const apiBase = getBase; // --------------- Types (mirror FastAPI schemas) --------------- export interface CustomerRead { id: number; name: string; contact_info: string | null; tags: string | null; created_at: string; } export interface CustomerCreate { name: string; contact_info?: string | null; tags?: string | null; } export interface CustomerUpdate { name?: string; contact_info?: string | null; tags?: string | null; } export interface RequirementAnalyzeRequest { customer_id: number; raw_text: string; } export interface RequirementAnalyzeResponse { project_id: number; ai_solution_md: string; ai_solution_json: Record; } export interface QuoteGenerateResponse { quote_id: number; project_id: number; total_amount: number; excel_path: string; pdf_path: string; } export interface ContractGenerateRequest { delivery_date: string; extra_placeholders?: Record; } export interface ContractGenerateResponse { project_id: number; contract_path: string; } export interface FinanceSyncResult { id: number; month: string; type: string; file_name: string; file_path: string; } export interface FinanceSyncResponse { status: string; new_files: number; details: FinanceSyncResult[]; } export interface FinanceRecordRead { id: number; month: string; type: string; file_name: string; file_path: string; amount: number | null; billing_date: string | null; created_at: string; } export interface TemplateInfo { name: string; type: "excel" | "word"; size: number; uploaded_at: number; } export interface AIConfig { id?: string; name?: string; provider: string; api_key: string; base_url: string; model_name: string; temperature: number; system_prompt_override: string; } export interface AIConfigListItem { id: string; name: string; provider: string; model_name: string; base_url: string; api_key_configured: boolean; is_active: boolean; } export interface AIConfigCreate { name?: string; provider?: string; api_key?: string; base_url?: string; model_name?: string; temperature?: number; system_prompt_override?: string; } export interface AIConfigUpdate { name?: string; provider?: string; api_key?: string; base_url?: string; model_name?: string; temperature?: number; system_prompt_override?: string; } export interface ProjectRead { id: number; customer_id: number; raw_requirement: string; ai_solution_md: string | null; status: string; created_at: string; customer?: CustomerRead; quotes?: { id: number; total_amount: string; file_path: string }[]; } export interface ProjectUpdate { raw_requirement?: string | null; ai_solution_md?: string | null; status?: string; } // --------------- Request helper --------------- async function request( path: string, options: RequestInit & { params?: Record } = {} ): Promise { const { params, ...init } = options; const base = apiBase(); let url = `${base}${path}`; if (params && Object.keys(params).length > 0) { const search = new URLSearchParams(params).toString(); url += (path.includes("?") ? "&" : "?") + search; } const res = await fetch(url, { ...init, headers: { "Content-Type": "application/json", ...init.headers, }, }); if (!res.ok) { const text = await res.text(); let detail: string = text; try { const j = JSON.parse(text); const raw = j.detail ?? text; if (Array.isArray(raw) && raw.length > 0) { detail = raw.map((x: { msg?: string }) => x.msg ?? JSON.stringify(x)).join("; "); } else { detail = typeof raw === "string" ? raw : JSON.stringify(raw); } } catch { // keep text } throw new Error(detail); } const contentType = res.headers.get("content-type"); if (contentType?.includes("application/json")) { return res.json() as Promise; } return undefined as T; } /** Download a file from the backend and return a Blob (for Excel/PDF/Zip). */ export async function downloadBlob(path: string): Promise { const base = apiBase(); const url = path.startsWith("http") ? path : `${base}${path.startsWith("/") ? "" : "/"}${path}`; const res = await fetch(url, { credentials: "include" }); if (!res.ok) throw new Error(`Download failed: ${res.status}`); return res.blob(); } /** * Trigger download in the browser (Excel, PDF, or Zip). * path: backend-returned path e.g. "data/quotes/quote_project_1.xlsx" -> we request /data/quotes/quote_project_1.xlsx */ export function downloadFile(path: string, filename: string): void { const base = apiBase(); const normalized = path.startsWith("/") ? path : `/${path}`; const url = `${base}${normalized}`; const a = document.createElement("a"); a.href = url; a.download = filename; a.rel = "noopener noreferrer"; a.target = "_blank"; document.body.appendChild(a); a.click(); document.body.removeChild(a); } /** Same as downloadFile but using fetch + Blob for consistent CORS and filename control. */ export async function downloadFileAsBlob( path: string, filename: string, mime?: string ): Promise { const blob = await downloadBlob(path); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } // --------------- Customers --------------- export const customersApi = { list: (params?: { q?: string }) => request( params?.q ? `/customers/?q=${encodeURIComponent(params.q)}` : "/customers/" ), get: (id: number) => request(`/customers/${id}`), create: (body: CustomerCreate) => request("/customers/", { method: "POST", body: JSON.stringify(body), }), update: (id: number, body: CustomerUpdate) => request(`/customers/${id}`, { method: "PUT", body: JSON.stringify(body), }), delete: (id: number) => request(`/customers/${id}`, { method: "DELETE" }), }; // --------------- Projects --------------- export const projectsApi = { analyze: (body: RequirementAnalyzeRequest) => request("/projects/analyze", { method: "POST", body: JSON.stringify(body), }), update: (projectId: number, body: ProjectUpdate) => request(`/projects/${projectId}`, { method: "PATCH", body: JSON.stringify(body), }), generateQuote: (projectId: number, template?: string | null) => request( `/projects/${projectId}/generate_quote${template ? `?template=${encodeURIComponent(template)}` : ""}`, { method: "POST" } ), generateContract: (projectId: number, body: ContractGenerateRequest) => request( `/projects/${projectId}/generate_contract`, { method: "POST", body: JSON.stringify(body), } ), }; export async function listProjects(params?: { customer_tag?: string; }): Promise { const searchParams: Record = {}; if (params?.customer_tag?.trim()) searchParams.customer_tag = params.customer_tag.trim(); return request("/projects/", { params: searchParams, }); } export async function getProject(projectId: number): Promise { return request(`/projects/${projectId}`); } // --------------- Settings / Templates --------------- export const templatesApi = { list: () => request("/settings/templates"), upload: async (file: File): Promise<{ name: string; path: string }> => { const base = apiBase(); const formData = new FormData(); formData.append("file", file); const res = await fetch(`${base}/settings/templates/upload`, { method: "POST", body: formData, }); if (!res.ok) { const text = await res.text(); let detail = text; try { const j = JSON.parse(text); detail = j.detail ?? text; } catch { // keep text } throw new Error(detail); } return res.json(); }, }; // --------------- AI Settings --------------- export const aiSettingsApi = { get: () => request("/settings/ai"), list: () => request("/settings/ai/list"), getById: (id: string) => request(`/settings/ai/${id}`), create: (body: AIConfigCreate) => request("/settings/ai", { method: "POST", body: JSON.stringify(body), }), update: (id: string, body: AIConfigUpdate) => request(`/settings/ai/${id}`, { method: "PUT", body: JSON.stringify(body), }), delete: (id: string) => request(`/settings/ai/${id}`, { method: "DELETE" }), activate: (id: string) => request(`/settings/ai/${id}/activate`, { method: "POST" }), test: (configId?: string) => request<{ status: string; message: string }>( configId ? `/settings/ai/test?config_id=${encodeURIComponent(configId)}` : "/settings/ai/test", { method: "POST" } ), }; // --------------- Email Configs (multi-account sync) --------------- export interface EmailConfigRead { id: string; host: string; port: number; user: string; mailbox: string; active: boolean; } export interface EmailConfigCreate { host: string; port?: number; user: string; password: string; mailbox?: string; active?: boolean; } export interface EmailConfigUpdate { host?: string; port?: number; user?: string; password?: string; mailbox?: string; active?: boolean; } export interface EmailFolder { raw: string; decoded: string; } export const emailConfigsApi = { list: () => request("/settings/email"), create: (body: EmailConfigCreate) => request("/settings/email", { method: "POST", body: JSON.stringify(body), }), update: (id: string, body: EmailConfigUpdate) => request(`/settings/email/${id}`, { method: "PUT", body: JSON.stringify(body), }), delete: (id: string) => request(`/settings/email/${id}`, { method: "DELETE" }), /** List mailbox folders for an account (to pick custom label). Use decoded as mailbox value. */ listFolders: (configId: string) => request<{ folders: EmailFolder[] }>(`/settings/email/${configId}/folders`), }; // --------------- Cloud Docs (快捷入口) --------------- export interface CloudDocLinkRead { id: string; name: string; url: string; } export interface CloudDocLinkCreate { name: string; url: string; } export interface CloudDocLinkUpdate { name?: string; url?: string; } export const cloudDocsApi = { list: () => request("/settings/cloud-docs"), create: (body: CloudDocLinkCreate) => request("/settings/cloud-docs", { method: "POST", body: JSON.stringify(body), }), update: (id: string, body: CloudDocLinkUpdate) => request(`/settings/cloud-docs/${id}`, { method: "PUT", body: JSON.stringify(body), }), delete: (id: string) => request(`/settings/cloud-docs/${id}`, { method: "DELETE" }), }; // --------------- Cloud Doc Config (API 凭证) --------------- export interface CloudDocConfigRead { feishu: { app_id: string; app_secret_configured: boolean }; yuque: { token_configured: boolean; default_repo: string }; tencent: { client_id: string; client_secret_configured: boolean }; } export interface CloudDocConfigUpdate { feishu?: { app_id?: string; app_secret?: string }; yuque?: { token?: string; default_repo?: string }; tencent?: { client_id?: string; client_secret?: string }; } export const cloudDocConfigApi = { get: () => request("/settings/cloud-doc-config"), update: (body: CloudDocConfigUpdate) => request("/settings/cloud-doc-config", { method: "PUT", body: JSON.stringify(body), }), }; // --------------- Push to Cloud --------------- export interface PushToCloudRequest { platform: "feishu" | "yuque" | "tencent"; title?: string; body_md?: string; } export interface PushToCloudResponse { url: string; cloud_doc_id: string; } export function pushProjectToCloud( projectId: number, body: PushToCloudRequest ): Promise { return request(`/projects/${projectId}/push-to-cloud`, { method: "POST", body: JSON.stringify(body), }); } // --------------- Portal Links (快捷门户) --------------- export interface PortalLinkRead { id: string; name: string; url: string; } export interface PortalLinkCreate { name: string; url: string; } export interface PortalLinkUpdate { name?: string; url?: string; } export const portalLinksApi = { list: () => request("/settings/portal-links"), create: (body: PortalLinkCreate) => request("/settings/portal-links", { method: "POST", body: JSON.stringify(body), }), update: (id: string, body: PortalLinkUpdate) => request(`/settings/portal-links/${id}`, { method: "PUT", body: JSON.stringify(body), }), delete: (id: string) => request(`/settings/portal-links/${id}`, { method: "DELETE" }), }; // --------------- Finance --------------- export const financeApi = { sync: () => request("/finance/sync", { method: "POST" }), /** Upload invoice (PDF/image); returns created record with AI-extracted amount/date. */ uploadInvoice: async (file: File): Promise => { const base = apiBase(); const formData = new FormData(); formData.append("file", file); const res = await fetch(`${base}/finance/upload`, { method: "POST", body: formData, }); if (!res.ok) { const text = await res.text(); let detail = text; try { const j = JSON.parse(text); detail = j.detail ?? text; } catch { // keep text } throw new Error(detail); } return res.json(); }, /** Update amount/billing_date of a record (e.g. after manual review). */ updateRecord: (id: number, body: { amount?: number | null; billing_date?: string | null }) => request(`/finance/records/${id}`, { method: "PATCH", body: JSON.stringify(body), }), /** List distinct months with records (YYYY-MM). */ listMonths: () => request("/finance/months"), /** List records for a month (YYYY-MM). */ listRecords: (month: string) => request(`/finance/records?month=${encodeURIComponent(month)}`), /** Returns the URL to download the zip (or use downloadFile with the path). */ getDownloadUrl: (month: string) => `${apiBase()}/finance/download/${month}`, /** Download monthly zip as blob and trigger save. */ downloadMonth: async (month: string): Promise => { const path = `/finance/download/${month}`; await downloadFileAsBlob(path, `finance_${month}.zip`, "application/zip"); }, };