PR-AGENT / src /App.tsx
Seth
Update
0d1d7a2
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<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>
);
}