| 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()}`; |
| } |
|
|
| |
| 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<ThreadItem[]>([]); |
| const [draft, setDraft] = useState(""); |
| const [editingUserId, setEditingUserId] = useState<string | null>(null); |
| const [editDraft, setEditDraft] = useState(""); |
| const [busy, setBusy] = useState(false); |
| const [accumulatedPrRows, setAccumulatedPrRows] = useState<PrRow[]>([]); |
| const [formSnapshots, setFormSnapshots] = useState<PurchaseFormSnapshot[]>( |
| [] |
| ); |
| 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<string | null>(null); |
| const [payloadModalOpen, setPayloadModalOpen] = useState(false); |
| const scrollRef = useRef<HTMLDivElement>(null); |
| const followUpRef = useRef<HTMLDivElement>(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 ( |
| <div className="bg-background text-on-background font-body-md overflow-hidden flex flex-col h-screen"> |
| <Header onNewRequisition={resetSession} /> |
| <main |
| id="chat" |
| className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-surface-container-lowest" |
| > |
| {showLanding ? ( |
| <div |
| ref={scrollRef} |
| className="mx-auto flex min-h-0 w-full max-w-6xl flex-1 flex-col overflow-y-auto px-gutter pb-8 pt-2" |
| > |
| <LandingHero /> |
| <MainChatComposer |
| value={draft} |
| onChange={setDraft} |
| onSend={send} |
| /> |
| <SuggestionCards onPick={(text) => setDraft(text)} /> |
| </div> |
| ) : ( |
| <div className="flex min-h-0 flex-1 flex-col lg:flex-row"> |
| <div |
| ref={scrollRef} |
| className="flex min-h-0 min-w-0 flex-1 flex-col gap-6 overflow-y-auto border-outline-variant px-gutter py-4 pb-8 lg:border-r lg:py-6" |
| > |
| {thread.map((item) => { |
| if (item.type === "user") { |
| const isEditing = editingUserId === item.id; |
| return ( |
| <UserBubble |
| key={item.id} |
| text={item.text} |
| isEditing={isEditing} |
| editValue={isEditing ? editDraft : ""} |
| onEditChange={setEditDraft} |
| onBeginEdit={() => beginEdit(item.id, item.text)} |
| onSubmitEdit={submitEdit} |
| onCancelEdit={cancelEdit} |
| /> |
| ); |
| } |
| if (item.type === "analysis") { |
| return ( |
| <AnalysisCard key={item.id} rows={item.rows} /> |
| ); |
| } |
| if (item.type === "pick") { |
| return ( |
| <DisambiguationCard |
| key={item.id} |
| summary={item.summary} |
| candidates={item.candidates} |
| disabled={busy} |
| onPick={onPick} |
| /> |
| ); |
| } |
| return ( |
| <ProcurementFormBlock |
| key={item.id} |
| commodityCode={item.commodityCode} |
| intro={item.intro} |
| catalogPath={item.catalogPath} |
| codeSummary={item.codeSummary} |
| onCommitPurchase={handleCommitPurchase} |
| onAddNew={openAddLineComposer} |
| /> |
| ); |
| })} |
| {busy ? <LoadingCard /> : null} |
| {showFollowUpComposer ? ( |
| <div |
| ref={followUpRef} |
| className="mt-2 rounded-2xl border border-outline-variant bg-surface-container-low p-4 shadow-sm" |
| > |
| <p className="mb-3 font-body-sm text-secondary"> |
| Add another catalogue line to this same purchase request. |
| Your chat continues here—this is not a new request. |
| </p> |
| <MainChatComposer |
| value={followUpDraft} |
| onChange={setFollowUpDraft} |
| onSend={sendFollowUp} |
| /> |
| </div> |
| ) : null} |
| <p className="text-center font-body-sm text-xs text-secondary/60"> |
| Procure AI can make mistakes. Check important procurement |
| details before approval. |
| </p> |
| </div> |
| |
| <aside className="flex min-h-0 min-w-0 flex-1 flex-col gap-2 overflow-hidden bg-surface-container-low/60 px-gutter py-3 lg:py-4"> |
| <RequestSummaryPanel |
| requestNumber={requestNumber} |
| requestedBy={requestedBy} |
| department={department} |
| budgetCode={budgetCode} |
| requestedDate={requestedDate} |
| onRequestedByChange={setRequestedBy} |
| onDepartmentChange={setDepartment} |
| onBudgetCodeChange={setBudgetCode} |
| onRequestedDateChange={setRequestedDate} |
| /> |
| <div className="flex min-h-0 flex-1 flex-col gap-2"> |
| {accumulatedPrRows.length > 0 ? ( |
| <PrLineItemsTable rows={accumulatedPrRows} /> |
| ) : ( |
| <div className="flex min-h-[120px] flex-1 items-center justify-center rounded-xl border border-dashed border-outline-variant bg-white px-4 py-6 text-center text-xs text-secondary shadow-sm"> |
| No line items yet. Confirm selections in the chat to populate |
| this table. |
| </div> |
| )} |
| </div> |
| <div className="shrink-0 space-y-2 pt-1"> |
| <div className="flex gap-2"> |
| <button |
| type="button" |
| disabled={accumulatedPrRows.length === 0} |
| onClick={handleSubmitRequest} |
| className="min-w-0 flex-1 rounded-xl bg-primary py-2.5 text-sm font-bold text-on-primary shadow-sm transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40" |
| > |
| Submit Request |
| </button> |
| <button |
| type="button" |
| title="Show payload JSON" |
| aria-label="Show payload JSON viewer" |
| onClick={() => setPayloadModalOpen(true)} |
| className="flex shrink-0 flex-col items-center justify-center gap-0.5 rounded-xl border border-outline-variant bg-white px-2.5 py-1.5 text-secondary shadow-sm transition-colors hover:bg-surface-container-high sm:flex-row sm:gap-1 sm:px-3 sm:py-2" |
| > |
| <Icon name="data_object" className="text-lg sm:text-xl" /> |
| <span className="max-w-[4.5rem] text-center font-label-caps text-[8px] font-bold uppercase leading-tight tracking-wide"> |
| Show Payload |
| </span> |
| </button> |
| </div> |
| {submitFeedback ? ( |
| <p className="text-center text-xs text-primary">{submitFeedback}</p> |
| ) : null} |
| </div> |
| </aside> |
| </div> |
| )} |
| </main> |
| <PayloadViewerModal |
| open={payloadModalOpen} |
| title="Request payload (JSON)" |
| jsonText={exportPayloadJson} |
| onClose={() => setPayloadModalOpen(false)} |
| /> |
| </div> |
| ); |
| } |
|
|