Files
quote-judge-studio-v1/frontend/src/App.jsx
2026-07-18 03:22:33 +00:00

341 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from "react";
import { callSkill, fileToBrowserDocument, loadAgentConfig, loadSession, signInUrl } from "./a2a.js";
const DEFAULT_QUOTES = [
{ vendor: "Acme Bearings", quantity: 500, unit_price: 10.75, delivery_days: 12, warranty_months: 12 },
{ vendor: "Beta Industrial", quantity: 500, unit_price: 9.1, delivery_days: 8, warranty_months: 18 },
];
const DEFAULT_WEIGHTS = { price: 50, delivery: 30, warranty: 20 };
function blankQuote() {
return { vendor: "", quantity: 1, unit_price: 0, delivery_days: 0, warranty_months: 0 };
}
function normalizeQuote(quote) {
return {
vendor: String(quote.vendor || "").trim(),
quantity: Number(quote.quantity),
unit_price: Number(quote.unit_price),
delivery_days: Number(quote.delivery_days),
warranty_months: Number(quote.warranty_months),
};
}
function parsePastedQuotes(text) {
const trimmed = text.trim();
if (!trimmed) return [];
try {
const parsed = JSON.parse(trimmed);
const rows = Array.isArray(parsed) ? parsed : parsed.quotes;
if (Array.isArray(rows)) return rows.map(normalizeQuote);
} catch {
// Fall through to CSV-ish parsing.
}
const lines = trimmed.split(/\r?\n/).filter(Boolean);
const hasHeader = /vendor|supplier/i.test(lines[0] || "");
const rows = hasHeader ? lines.slice(1) : lines;
return rows.map((line) => {
const parts = line.split(/[,;|\t]/).map((part) => part.trim());
return normalizeQuote({
vendor: parts[0],
quantity: parts[1],
unit_price: parts[2],
delivery_days: parts[3],
warranty_months: parts[4],
});
});
}
function validateQuotes(quotes, weights) {
const complete = quotes.map(normalizeQuote).filter((quote) => quote.vendor);
if (complete.length < 2) return "Add at least two vendor quotes before comparing.";
for (const quote of complete) {
if (!Number.isFinite(quote.quantity) || quote.quantity <= 0) return `${quote.vendor}: quantity must be greater than 0.`;
if (!Number.isFinite(quote.unit_price) || quote.unit_price < 0) return `${quote.vendor}: unit price must be 0 or greater.`;
if (!Number.isFinite(quote.delivery_days) || quote.delivery_days < 0) return `${quote.vendor}: delivery days must be 0 or greater.`;
if (!Number.isFinite(quote.warranty_months) || quote.warranty_months < 0) return `${quote.vendor}: warranty must be 0 or greater.`;
}
const totalWeight = Number(weights.price) + Number(weights.delivery) + Number(weights.warranty);
if (!Number.isFinite(totalWeight) || totalWeight <= 0) return "Set at least one positive weight.";
return null;
}
export function App() {
const [config, setConfig] = useState(null);
const [session, setSession] = useState(null);
const [loadError, setLoadError] = useState(null);
const [comparisonId, setComparisonId] = useState("studio-quote-v1");
const [quotes, setQuotes] = useState(DEFAULT_QUOTES);
const [weights, setWeights] = useState(DEFAULT_WEIGHTS);
const [pasteText, setPasteText] = useState("");
const [result, setResult] = useState(null);
const [history, setHistory] = useState([]);
const [status, setStatus] = useState("");
const [error, setError] = useState("");
const [running, setRunning] = useState(false);
useEffect(() => {
loadAgentConfig()
.then(async (data) => {
setConfig(data);
try {
setSession(await loadSession(data));
} catch {
setSession({ authenticated: false });
}
})
.catch((err) => setLoadError(err.message || String(err)));
}, []);
useEffect(() => {
try {
const saved = JSON.parse(window.localStorage.getItem("quotejudge.history") || "[]");
if (Array.isArray(saved)) setHistory(saved.slice(0, 8));
} catch {
setHistory([]);
}
}, []);
const needsSignIn = Boolean(config?.auth?.invokeRequiresSession && session && !session.authenticated);
const authHref = useMemo(() => (config ? signInUrl(config) : null), [config]);
const validation = validateQuotes(quotes, weights);
const totalWeight = Number(weights.price) + Number(weights.delivery) + Number(weights.warranty);
function updateQuote(index, field, value) {
setQuotes((current) => current.map((quote, i) => (i === index ? { ...quote, [field]: value } : quote)));
}
function remember(id) {
const next = [id, ...history.filter((item) => item !== id)].slice(0, 8);
setHistory(next);
window.localStorage.setItem("quotejudge.history", JSON.stringify(next));
}
function applyPastedQuotes() {
setError("");
try {
const parsed = parsePastedQuotes(pasteText);
if (parsed.length < 2) {
setError("Paste JSON, CSV, or pipe-separated text with at least two quotes.");
return;
}
setQuotes(parsed);
setStatus(`Loaded ${parsed.length} pasted quotes.`);
} catch (err) {
setError(err.message || "Could not parse pasted quotes.");
}
}
async function compare(event) {
event.preventDefault();
if (!config) return;
setError("");
setStatus("");
const message = validateQuotes(quotes, weights);
if (message) {
setError(message);
return;
}
setRunning(true);
try {
const data = await callSkill(config, "compare_quotes", {
comparison_id: comparisonId,
quotes: quotes.map(normalizeQuote).filter((quote) => quote.vendor),
weights: {
price: Number(weights.price),
delivery: Number(weights.delivery),
warranty: Number(weights.warranty),
},
});
setResult(data);
if (data.ok) {
remember(data.comparison_id);
setStatus("Comparison saved. You can refresh and reopen it by ID.");
} else {
setError(data.message || data.code || "Comparison failed.");
}
} catch (err) {
if (err.status === 401 && authHref) setError("Session expired. Sign in again to run QuoteJudge.");
else setError(err.message || String(err));
} finally {
setRunning(false);
}
}
async function reopen(id = comparisonId) {
if (!config || !id) return;
setRunning(true);
setError("");
setStatus("");
try {
const data = await callSkill(config, "get_comparison", { comparison_id: id });
setResult(data);
setComparisonId(id);
if (data.ok) {
remember(id);
setStatus("Saved comparison reopened from managed Postgres.");
} else {
setError(data.message || "No saved comparison found for this ID.");
}
} catch (err) {
if (err.status === 401 && authHref) setError("Session expired. Sign in again to reopen comparisons.");
else setError(err.message || String(err));
} finally {
setRunning(false);
}
}
async function handleFiles(event) {
if (!config) return;
const files = Array.from(event.target.files || []);
if (!files.length) return;
setRunning(true);
setError("");
setStatus("");
try {
const documents = await Promise.all(files.map((file) => fileToBrowserDocument(file)));
const data = await callSkill(config, "compare_uploaded_quotes_browser", {
comparison_id: comparisonId,
documents,
weights: {
price: Number(weights.price),
delivery: Number(weights.delivery),
warranty: Number(weights.warranty),
},
});
setResult(data);
if (data.ok) {
setQuotes(data.quotes.map(({ vendor, quantity, unit_price, delivery_days, warranty_months }) => ({ vendor, quantity, unit_price, delivery_days, warranty_months })));
remember(data.comparison_id);
setStatus("Uploaded quotes parsed, compared, and saved.");
} else {
setError(data.message || data.code || "Upload comparison failed.");
}
} catch (err) {
setError(err.message || String(err));
} finally {
event.target.value = "";
setRunning(false);
}
}
if (!config) {
return <main className="loading"><p>{loadError || "Loading QuoteJudge…"}</p></main>;
}
return (
<main className="app-shell">
<section className="hero card">
<div>
<p className="eyebrow">QuoteJudge Studio</p>
<h1>Pick the best vendor quote with weighted, auditable scoring.</h1>
<p className="lede">Upload or paste quotes, tune price/delivery/warranty importance, save the comparison, and reopen it after refresh. Results are calculated by typed A2A backend tools and stored per signed-in user.</p>
</div>
<div className="session-box">
<span className={session?.authenticated ? "dot ok" : "dot warn"} />
<div>
<strong>{session?.authenticated ? "Signed in" : "Sign-in required to run"}</strong>
<p>{session?.user?.email || session?.org?.slug || "Platform auth protects saved comparisons."}</p>
</div>
{needsSignIn && authHref ? <a className="button secondary" href={authHref}>Sign in</a> : null}
</div>
</section>
<form className="grid" onSubmit={compare}>
<section className="card editor">
<div className="section-head">
<div>
<p className="eyebrow">Inputs</p>
<h2>Vendor quotes</h2>
</div>
<button type="button" className="button ghost" onClick={() => setQuotes((rows) => [...rows, blankQuote()])}>Add quote</button>
</div>
<label className="field id-field">Comparison ID
<input value={comparisonId} onChange={(e) => setComparisonId(e.target.value)} placeholder="studio-quote-v1" />
</label>
<div className="quote-list">
{quotes.map((quote, index) => (
<article className="quote-row" key={index}>
<label>Vendor<input value={quote.vendor} onChange={(e) => updateQuote(index, "vendor", e.target.value)} /></label>
<label>Qty<input type="number" min="1" value={quote.quantity} onChange={(e) => updateQuote(index, "quantity", e.target.value)} /></label>
<label>Unit price<input type="number" min="0" step="0.01" value={quote.unit_price} onChange={(e) => updateQuote(index, "unit_price", e.target.value)} /></label>
<label>Delivery days<input type="number" min="0" value={quote.delivery_days} onChange={(e) => updateQuote(index, "delivery_days", e.target.value)} /></label>
<label>Warranty months<input type="number" min="0" value={quote.warranty_months} onChange={(e) => updateQuote(index, "warranty_months", e.target.value)} /></label>
<button type="button" aria-label={`Remove quote ${index + 1}`} onClick={() => setQuotes((rows) => rows.filter((_, i) => i !== index))}>×</button>
</article>
))}
</div>
<div className="paste-upload">
<label>Paste JSON, CSV, or vendor|qty|price|delivery|warranty text
<textarea value={pasteText} onChange={(e) => setPasteText(e.target.value)} placeholder="vendor,quantity,unit_price,delivery_days,warranty_months…" />
</label>
<div className="actions">
<button type="button" className="button secondary" onClick={applyPastedQuotes}>Use pasted quotes</button>
<label className="button secondary upload">Upload CSV/JSON/TXT
<input type="file" multiple accept=".csv,.json,.txt,text/csv,text/plain,application/json" onChange={handleFiles} />
</label>
</div>
</div>
</section>
<aside className="card controls">
<p className="eyebrow">Scoring</p>
<h2>Weights</h2>
{Object.entries(weights).map(([key, value]) => (
<label className="slider" key={key}>{key}<strong>{value}</strong>
<input type="range" min="0" max="100" value={value} onChange={(e) => setWeights((w) => ({ ...w, [key]: Number(e.target.value) }))} />
</label>
))}
<p className="hint">Total weight: {totalWeight}. Scores normalize each dimension from 0 to 1, then apply these weights.</p>
{validation ? <p className="validation">{validation}</p> : null}
{error ? <p className="error">{error}</p> : null}
{status ? <p className="status">{status}</p> : null}
<button className="button primary" disabled={running || needsSignIn || Boolean(validation)} type="submit">{running ? "Running…" : "Compare & save"}</button>
<button className="button secondary" disabled={running || needsSignIn} type="button" onClick={() => reopen()}>Reopen by ID</button>
{needsSignIn && authHref ? <a className="signin-note" href={authHref}>Sign in with A2A Cloud to call backend tools.</a> : null}
<div className="history">
<h3>Recent IDs</h3>
{history.length ? history.map((id) => <button key={id} type="button" onClick={() => reopen(id)}>{id}</button>) : <p>No saved comparisons opened in this browser yet.</p>}
</div>
</aside>
</form>
<section className="card result-card">
<div className="section-head">
<div>
<p className="eyebrow">Backend result</p>
<h2>{result?.ok ? `Recommended: ${result.recommendation.vendor}` : "No recommendation yet"}</h2>
</div>
{result?.ok ? <span className="score">Score {result.recommendation.score.toFixed(2)}</span> : null}
</div>
{result?.ok ? (
<>
<p className="rationale">{result.recommendation.rationale}</p>
<div className="table-wrap">
<table>
<thead><tr><th>Vendor</th><th>Total</th><th>Delivery</th><th>Warranty</th><th>Weighted score</th></tr></thead>
<tbody>
{result.quotes.map((quote) => (
<tr key={quote.vendor} className={quote.vendor === result.recommendation.vendor ? "winner" : ""}>
<td>{quote.vendor}</td>
<td>{quote.subtotal.toLocaleString(undefined, { style: "currency", currency: "BRL" })}</td>
<td>{quote.delivery_days} days</td>
<td>{quote.warranty_months} months</td>
<td>{quote.scores.weighted_total.toFixed(2)}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
) : (
<p className="empty">Run a comparison or reopen a saved ID. Fewer than two quotes are blocked before calling the backend and also rejected by the typed tool.</p>
)}
</section>
</main>
);
}