Spaces:
Runtime error
Runtime error
| import React, { useState } from 'react' | |
| import { api } from '../api.js' | |
| const LEVEL_COLOR = { HIGH: '#c92a2a', MEDIUM: '#d9750b', LOW: '#5b6478' } | |
| function RiskBadge({ risk }) { | |
| return ( | |
| <div className="cmp-risk" style={{ color: LEVEL_COLOR[risk.level] }}> | |
| <span className="cmp-risk-dot" style={{ background: LEVEL_COLOR[risk.level] }} /> | |
| {risk.title} <span className="cmp-sev">({risk.severity})</span> | |
| </div> | |
| ) | |
| } | |
| function Side({ side }) { | |
| if (!side.clauses.length && !side.risks.length) { | |
| return <span className="cmp-absent">Not present</span> | |
| } | |
| return ( | |
| <div className="cmp-side"> | |
| {side.clauses.map((c, i) => ( | |
| <div key={i} className="cmp-clause-name"> | |
| {c.heading || `Clause ${c.number || c.id}`} | |
| </div> | |
| ))} | |
| {side.risks.map((r, i) => <RiskBadge key={i} risk={r} />)} | |
| </div> | |
| ) | |
| } | |
| export default function ContractCompare({ currentId, currentFilename, existing, onClose }) { | |
| const [otherId, setOtherId] = useState('') | |
| const [result, setResult] = useState(null) | |
| const [loading, setLoading] = useState(false) | |
| const [error, setError] = useState(null) | |
| const others = existing.filter((c) => c.contract_id !== currentId) | |
| async function runCompare() { | |
| if (!otherId) return | |
| setLoading(true) | |
| setError(null) | |
| setResult(null) | |
| try { | |
| const res = await api(`/api/compare?a=${currentId}&b=${otherId}`) | |
| if (!res.ok) { | |
| const body = await res.json().catch(() => ({})) | |
| throw new Error(body.detail || `Compare failed (${res.status})`) | |
| } | |
| setResult(await res.json()) | |
| } catch (e) { | |
| setError(e.message) | |
| } finally { | |
| setLoading(false) | |
| } | |
| } | |
| return ( | |
| <div className="compare-overlay"> | |
| <div className="compare-header"> | |
| <h2 className="compare-title">Contract Comparison</h2> | |
| <button className="compare-close" onClick={onClose}>✕ Close</button> | |
| </div> | |
| <div className="compare-controls"> | |
| <div className="compare-contract-a"> | |
| <span className="compare-label">A</span> | |
| <span className="compare-filename">{currentFilename}</span> | |
| </div> | |
| <span className="compare-vs">vs</span> | |
| <div className="compare-contract-b"> | |
| <span className="compare-label">B</span> | |
| <select | |
| className="compare-select" | |
| value={otherId} | |
| onChange={(e) => setOtherId(e.target.value)} | |
| > | |
| <option value="">Select contract…</option> | |
| {others.map((c) => ( | |
| <option key={c.contract_id} value={c.contract_id}> | |
| {c.filename} | |
| </option> | |
| ))} | |
| </select> | |
| </div> | |
| <button | |
| className="compare-run-btn" | |
| onClick={runCompare} | |
| disabled={!otherId || loading} | |
| > | |
| {loading ? 'Comparing…' : 'Compare →'} | |
| </button> | |
| </div> | |
| {others.length === 0 && ( | |
| <p className="muted pad">Upload at least one other contract to compare against.</p> | |
| )} | |
| {error && <div className="banner error">⚠ {error}</div>} | |
| {result && ( | |
| <div className="compare-results"> | |
| <div className="compare-summary-bar"> | |
| <span className="csb-item">{result.summary.both} shared categories</span> | |
| <span className="csb-item csb-only-a">{result.summary.only_a} only in A</span> | |
| <span className="csb-item csb-only-b">{result.summary.only_b} only in B</span> | |
| {result.summary.risk_increased > 0 && ( | |
| <span className="csb-item csb-worse">↑ {result.summary.risk_increased} higher risk in B</span> | |
| )} | |
| {result.summary.risk_decreased > 0 && ( | |
| <span className="csb-item csb-better">↓ {result.summary.risk_decreased} lower risk in B</span> | |
| )} | |
| </div> | |
| <div className="compare-table-wrap"> | |
| <table className="compare-table"> | |
| <thead> | |
| <tr> | |
| <th className="ct-cat-hdr">Category</th> | |
| <th className="ct-side-hdr">A — {result.a.filename}</th> | |
| <th className="ct-side-hdr">B — {result.b.filename}</th> | |
| <th className="ct-delta-hdr">Δ Risk</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {result.rows.map((row, i) => ( | |
| <tr | |
| key={i} | |
| className={ | |
| 'ct-row' + | |
| (row.status === 'only_a' ? ' ct-only-a' : '') + | |
| (row.status === 'only_b' ? ' ct-only-b' : '') | |
| } | |
| > | |
| <td className="ct-cat"> | |
| <b>{row.label}</b> | |
| {row.status !== 'both' && ( | |
| <span className="ct-only-badge"> | |
| {row.status === 'only_a' ? 'A only' : 'B only'} | |
| </span> | |
| )} | |
| </td> | |
| <td className="ct-side-cell"><Side side={row.a} /></td> | |
| <td className="ct-side-cell"><Side side={row.b} /></td> | |
| <td className="ct-delta"> | |
| {row.risk_delta != null ? ( | |
| <span | |
| className={ | |
| row.risk_delta > 0 | |
| ? 'delta-worse' | |
| : row.risk_delta < 0 | |
| ? 'delta-better' | |
| : 'delta-same' | |
| } | |
| > | |
| {row.risk_delta > 0 ? `+${row.risk_delta}` : row.risk_delta} | |
| </span> | |
| ) : ( | |
| <span className="ct-na">—</span> | |
| )} | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |