import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ChatResponse } from "./api/chat"; import { chatApi } from "./api/chat"; import type { PrRow } from "./api/form"; import type { ThreadItem } from "./types"; import type { PurchaseFormSnapshot } from "./types/purchasePayload"; import { AnalysisCard } from "./components/AnalysisCard"; import { DisambiguationCard } from "./components/DisambiguationCard"; import { Header } from "./components/Header"; import { LandingHero } from "./components/LandingHero"; import { LoadingCard } from "./components/LoadingCard"; import { MainChatComposer } from "./components/MainChatComposer"; import { PayloadViewerModal } from "./components/PayloadViewerModal"; import { PrLineItemsTable } from "./components/PrLineItemsTable"; import { ProcurementFormBlock } from "./components/ProcurementFormBlock"; import { RequestSummaryPanel } from "./components/RequestSummaryPanel"; import { SuggestionCards } from "./components/SuggestionCards"; import { UserBubble } from "./components/UserBubble"; import { Icon } from "./components/Icon"; function uid(): string { return crypto.randomUUID(); } function isoDateToday(): string { const d = new Date(); const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return `${y}-${m}-${day}`; } function makeRequestNumber(): string { return `PR-${new Date().getFullYear()}-${crypto.randomUUID().replace(/-/g, "").slice(0, 6).toUpperCase()}`; } /** Demo defaults so Request details are not empty on load / after New Requisition. */ const DEFAULT_REQUEST_AUTOFILL: { requestedBy: string; department: string; budgetCode: string; } = { requestedBy: "Alex Morgan (Corporate Procurement)", department: "Facilities & Workplace — NY Headquarters", budgetCode: "GL-6600-CAPEX-OFFICE", }; function itemsFromResponse(res: ChatResponse): ThreadItem[] { const out: ThreadItem[] = []; let rows = res.analysis_rows ?? []; if (!rows.length && res.summary) { rows = [ { icon: "info", left: res.summary, right: res.status === "not_found" ? "—" : "OK", right_style: "muted", }, ]; } if (res.error) { rows = [ ...rows, { icon: "error", left: `Configuration / API: ${res.error}`, right: "—", right_style: "muted", }, ]; } if (rows.length) { out.push({ id: uid(), type: "analysis", rows }); } if (res.status === "choose" && res.candidates?.length) { out.push({ id: uid(), type: "pick", summary: res.summary, candidates: res.candidates, }); } if (res.status === "found" && res.commodity_code) { const det = res.selected_details; const codeSummary = det ? `Seg ${det.segment_code ?? "—"} · Fam ${det.family_code ?? "—"} · Class ${det.class_code ?? "—"} · Commodity ${det.commodity_code}` : `Commodity ${res.commodity_code}`; const cc = res.commodity_code ?? det?.commodity_code; if (cc != null) { out.push({ id: uid(), type: "form", commodityCode: cc, intro: res.form_intro || "Confirm specifications for this catalogue item.", catalogPath: det?.path, codeSummary, selected: det, }); } } return out; } export default function App() { const [thread, setThread] = useState([]); const [draft, setDraft] = useState(""); const [editingUserId, setEditingUserId] = useState(null); const [editDraft, setEditDraft] = useState(""); const [busy, setBusy] = useState(false); const [accumulatedPrRows, setAccumulatedPrRows] = useState([]); const [formSnapshots, setFormSnapshots] = useState( [] ); const [showFollowUpComposer, setShowFollowUpComposer] = useState(false); const [followUpDraft, setFollowUpDraft] = useState(""); const [requestNumber, setRequestNumber] = useState(makeRequestNumber); const [requestedBy, setRequestedBy] = useState( DEFAULT_REQUEST_AUTOFILL.requestedBy ); const [department, setDepartment] = useState( DEFAULT_REQUEST_AUTOFILL.department ); const [budgetCode, setBudgetCode] = useState( DEFAULT_REQUEST_AUTOFILL.budgetCode ); const [requestedDate, setRequestedDate] = useState(isoDateToday); const [submitFeedback, setSubmitFeedback] = useState(null); const [payloadModalOpen, setPayloadModalOpen] = useState(false); const scrollRef = useRef(null); const followUpRef = useRef(null); const scrollToBottom = useCallback(() => { requestAnimationFrame(() => { const el = scrollRef.current; if (!el) return; el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); }); }, []); useEffect(() => { scrollToBottom(); }, [thread.length, busy, accumulatedPrRows.length, scrollToBottom]); useEffect(() => { if (!showFollowUpComposer) return; const id = window.setTimeout(() => { followUpRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest", }); }, 120); return () => window.clearTimeout(id); }, [showFollowUpComposer]); const handleCommitPurchase = useCallback( ({ rows, snapshot, }: { rows: PrRow[]; snapshot: PurchaseFormSnapshot; }) => { if (!rows.length) return; setFormSnapshots((prev) => [...prev, snapshot]); setAccumulatedPrRows((prev) => { const prior = [...prev]; const base = prior.length ? Math.max(...prior.map((r) => Number(r.pr_line_item) || 0)) : 0; const next = rows.map((r, i) => ({ ...r, pr_line_item: base + 10 * (i + 1), })); return [...prior, ...next]; }); }, [] ); const resetSession = useCallback(() => { setThread([]); setDraft(""); setEditingUserId(null); setEditDraft(""); setBusy(false); setAccumulatedPrRows([]); setFormSnapshots([]); setShowFollowUpComposer(false); setFollowUpDraft(""); setSubmitFeedback(null); setPayloadModalOpen(false); setRequestNumber(makeRequestNumber()); setRequestedBy(DEFAULT_REQUEST_AUTOFILL.requestedBy); setDepartment(DEFAULT_REQUEST_AUTOFILL.department); setBudgetCode(DEFAULT_REQUEST_AUTOFILL.budgetCode); setRequestedDate(isoDateToday()); }, []); const exportPayloadJson = useMemo(() => { return JSON.stringify( { requestDetails: { requestNumber, requestedBy, department, budgetCode, requestedDate, }, purchaseForms: formSnapshots, lineItems: accumulatedPrRows, }, null, 2 ); }, [ requestNumber, requestedBy, department, budgetCode, requestedDate, formSnapshots, accumulatedPrRows, ]); const openAddLineComposer = useCallback(() => { setShowFollowUpComposer(true); }, []); const appendAssistant = useCallback(async (messageText: string, pick?: number | null) => { setBusy(true); try { const res = await chatApi({ message: messageText, selected_commodity_code: pick ?? undefined, }); const tail = itemsFromResponse(res); setThread((prev) => [...prev, ...tail]); } catch (e) { const msg = e instanceof Error ? e.message : String(e); setThread((prev) => [ ...prev, { id: uid(), type: "analysis", rows: [ { icon: "error", left: msg, right: "ERROR", right_style: "muted", }, ], }, ]); } finally { setBusy(false); } }, []); const sendFollowUp = useCallback(async () => { const text = followUpDraft.trim(); if (!text || busy) return; setFollowUpDraft(""); setShowFollowUpComposer(false); const user: ThreadItem = { id: uid(), type: "user", text }; setThread((prev) => [...prev, user]); await appendAssistant(text, null); }, [followUpDraft, busy, appendAssistant]); const beginEdit = useCallback((id: string, currentText: string) => { setEditingUserId(id); setEditDraft(currentText); }, []); const cancelEdit = useCallback(() => { setEditingUserId(null); setEditDraft(""); }, []); const send = useCallback(async () => { const text = draft.trim(); if (!text || busy) return; setDraft(""); const user: ThreadItem = { id: uid(), type: "user", text }; setThread((prev) => [...prev, user]); await appendAssistant(text, null); }, [draft, busy, appendAssistant]); const submitEdit = useCallback(async () => { const text = editDraft.trim(); if (!text || !editingUserId || busy) return; const idx = thread.findIndex((t) => t.id === editingUserId); if (idx === -1) return; setEditingUserId(null); setEditDraft(""); const prefix = thread.slice(0, idx); const user: ThreadItem = { id: uid(), type: "user", text }; setThread([...prefix, user]); await appendAssistant(text, null); }, [editDraft, editingUserId, busy, thread, appendAssistant]); const onPick = useCallback( async (code: number) => { if (busy) return; await appendAssistant("", code); }, [busy, appendAssistant] ); const showLanding = thread.length === 0 && !busy; const handleSubmitRequest = useCallback(() => { if (accumulatedPrRows.length === 0) return; setSubmitFeedback( `Request ${requestNumber} is ready to submit (${accumulatedPrRows.length} line item${ accumulatedPrRows.length === 1 ? "" : "s" }). Connect your approval workflow to finalize.` ); window.setTimeout(() => setSubmitFeedback(null), 8000); }, [accumulatedPrRows.length, requestNumber]); return (
{showLanding ? (
setDraft(text)} />
) : (
{thread.map((item) => { if (item.type === "user") { const isEditing = editingUserId === item.id; return ( beginEdit(item.id, item.text)} onSubmitEdit={submitEdit} onCancelEdit={cancelEdit} /> ); } if (item.type === "analysis") { return ( ); } if (item.type === "pick") { return ( ); } return ( ); })} {busy ? : null} {showFollowUpComposer ? (

Add another catalogue line to this same purchase request. Your chat continues here—this is not a new request.

) : null}

Procure AI can make mistakes. Check important procurement details before approval.

)}
setPayloadModalOpen(false)} />
); }