Spaces:
Running
Running
FairValue commited on
Commit ·
aa3c3cf
1
Parent(s): 7dff677
fix: correct depreciation labels, add counter-offer rows, fix NLP 0.00 display, fix ledger table layout
Browse files
fairvalue-webapp/src/components/ReportTemplate.tsx
CHANGED
|
@@ -97,6 +97,10 @@ export const ReportTemplate = forwardRef<HTMLDivElement, ReportProps>(({ form, r
|
|
| 97 |
AI Valuation Ledger
|
| 98 |
</h2>
|
| 99 |
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '14px' }}>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
<tbody>
|
| 101 |
<tr style={{ borderBottom: '1px solid #e2e8f0' }}>
|
| 102 |
<td style={{ padding: '12px 0', color: '#475569', fontWeight: 500 }}>Intrinsic Performance Value</td>
|
|
@@ -104,10 +108,15 @@ export const ReportTemplate = forwardRef<HTMLDivElement, ReportProps>(({ form, r
|
|
| 104 |
</tr>
|
| 105 |
<tr style={{ borderBottom: '1px solid #e2e8f0' }}>
|
| 106 |
<td style={{ padding: '12px 0', color: '#475569', fontWeight: 500 }}>
|
| 107 |
-
Age & Contract Impact (SHAP) — {L.depreciation
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
</td>
|
| 109 |
-
<td style={{ padding: '12px 0', textAlign: 'right', fontWeight: 700, color: L.depreciation
|
| 110 |
-
{L.depreciation >
|
| 111 |
</td>
|
| 112 |
</tr>
|
| 113 |
<tr style={{ borderBottom: '1px solid #e2e8f0' }}>
|
|
@@ -121,11 +130,27 @@ export const ReportTemplate = forwardRef<HTMLDivElement, ReportProps>(({ form, r
|
|
| 121 |
</td>
|
| 122 |
</tr>
|
| 123 |
<tr style={{ backgroundColor: '#f0fdf4', border: '1px solid #bbf7d0' }}>
|
| 124 |
-
<td style={{ padding: '16px 12px', color: '#166534', fontWeight: 700, fontSize: '16px' }}>Calculated Fair Value</td>
|
| 125 |
<td style={{ padding: '16px 12px', textAlign: 'right', fontWeight: 800, fontSize: '20px', color: '#16a34a' }}>
|
| 126 |
£{L.hard_cap.toFixed(1)}m
|
| 127 |
</td>
|
| 128 |
</tr>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
</tbody>
|
| 130 |
</table>
|
| 131 |
</div>
|
|
@@ -138,21 +163,21 @@ export const ReportTemplate = forwardRef<HTMLDivElement, ReportProps>(({ form, r
|
|
| 138 |
</h2>
|
| 139 |
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '16px', background: '#f8fafc', padding: '24px', borderRadius: '12px', border: '1px solid #e2e8f0' }}>
|
| 140 |
<div>
|
| 141 |
-
<div style={{ fontSize: '11px', color: '#64748b', textTransform: 'uppercase' }}>Recent Form & Impact</div>
|
| 142 |
-
<div style={{ fontSize: '18px', fontWeight: 700, color: result.nlp_results.recency < 0 ? '#ef4444' : '#22c55e' }}>
|
| 143 |
-
{result.nlp_results.recency > 0 ? '+' : ''}{result.nlp_results.recency.toFixed(2)}
|
| 144 |
</div>
|
| 145 |
</div>
|
| 146 |
<div>
|
| 147 |
<div style={{ fontSize: '11px', color: '#64748b', textTransform: 'uppercase' }}>Injury / Availability</div>
|
| 148 |
-
<div style={{ fontSize: '18px', fontWeight: 700, color: result.nlp_results.durability < 0 ? '#ef4444' : '#0f172a' }}>
|
| 149 |
-
{result.nlp_results.durability > 0 ? '+' : ''}{result.nlp_results.durability.toFixed(2)}
|
| 150 |
</div>
|
| 151 |
</div>
|
| 152 |
<div>
|
| 153 |
<div style={{ fontSize: '11px', color: '#64748b', textTransform: 'uppercase' }}>Transfer Speculation</div>
|
| 154 |
-
<div style={{ fontSize: '18px', fontWeight: 700, color: result.nlp_results.agent < 0 ? '#ef4444' : '#22c55e' }}>
|
| 155 |
-
{result.nlp_results.agent > 0 ? '+' : ''}{result.nlp_results.agent.toFixed(2)}
|
| 156 |
</div>
|
| 157 |
</div>
|
| 158 |
</div>
|
|
@@ -169,11 +194,16 @@ export const ReportTemplate = forwardRef<HTMLDivElement, ReportProps>(({ form, r
|
|
| 169 |
</h2>
|
| 170 |
<div style={{ padding: '24px', background: isOverpay ? '#fef2f2' : '#f0fdf4', border: `1px solid ${isOverpay ? '#fecaca' : '#bbf7d0'}`, borderRadius: '12px' }}>
|
| 171 |
<p style={{ fontSize: '15px', color: isOverpay ? '#991b1b' : '#166534', lineHeight: 1.6, marginBottom: '16px' }}>
|
| 172 |
-
<strong>Negotiation Intel:</strong> Based on the real-time evaluation engine, {form.selected_name}'s intrinsic value sits at £{L.intrinsic_performance_value.toFixed(1)}m.
|
| 173 |
-
|
| 174 |
-
|
| 175 |
<br/><br/>
|
| 176 |
-
<strong>Validation:</strong> {result.nlp_results.recency > 0 ? "Player's recent form commands a
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
</p>
|
| 178 |
<div style={{ fontSize: '18px', fontWeight: 800, color: isOverpay ? '#dc2626' : '#16a34a', borderTop: `1px solid ${isOverpay ? '#fca5a5' : '#86efac'}`, paddingTop: '16px' }}>
|
| 179 |
VERDICT: {isOverpay ? 'OVERPAY RISK' : 'FAIR DEAL - PROCEED WITH CONFIDENCE'}
|
|
|
|
| 97 |
AI Valuation Ledger
|
| 98 |
</h2>
|
| 99 |
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '14px' }}>
|
| 100 |
+
<colgroup>
|
| 101 |
+
<col style={{ width: '70%' }} />
|
| 102 |
+
<col style={{ width: '30%' }} />
|
| 103 |
+
</colgroup>
|
| 104 |
<tbody>
|
| 105 |
<tr style={{ borderBottom: '1px solid #e2e8f0' }}>
|
| 106 |
<td style={{ padding: '12px 0', color: '#475569', fontWeight: 500 }}>Intrinsic Performance Value</td>
|
|
|
|
| 108 |
</tr>
|
| 109 |
<tr style={{ borderBottom: '1px solid #e2e8f0' }}>
|
| 110 |
<td style={{ padding: '12px 0', color: '#475569', fontWeight: 500 }}>
|
| 111 |
+
Age & Contract Impact (SHAP) — {L.depreciation > 0 ? '📉 Depreciation' : '📈 Appreciation'}
|
| 112 |
+
{L.depreciation > 0 && form.age <= 23 && (
|
| 113 |
+
<div style={{ fontSize: '11px', color: '#94a3b8', marginTop: '2px' }}>
|
| 114 |
+
* Model reflects current output, not future potential
|
| 115 |
+
</div>
|
| 116 |
+
)}
|
| 117 |
</td>
|
| 118 |
+
<td style={{ padding: '12px 0', textAlign: 'right', fontWeight: 700, color: L.depreciation > 0 ? '#ef4444' : '#22c55e' }}>
|
| 119 |
+
{L.depreciation > 0 ? '-' : '+'}£{Math.abs(L.depreciation).toFixed(1)}m
|
| 120 |
</td>
|
| 121 |
</tr>
|
| 122 |
<tr style={{ borderBottom: '1px solid #e2e8f0' }}>
|
|
|
|
| 130 |
</td>
|
| 131 |
</tr>
|
| 132 |
<tr style={{ backgroundColor: '#f0fdf4', border: '1px solid #bbf7d0' }}>
|
| 133 |
+
<td style={{ padding: '16px 12px', color: '#166534', fontWeight: 700, fontSize: '16px' }}>Calculated Fair Value (Ceiling)</td>
|
| 134 |
<td style={{ padding: '16px 12px', textAlign: 'right', fontWeight: 800, fontSize: '20px', color: '#16a34a' }}>
|
| 135 |
£{L.hard_cap.toFixed(1)}m
|
| 136 |
</td>
|
| 137 |
</tr>
|
| 138 |
+
<tr style={{ backgroundColor: '#fefce8', border: '1px solid #fde68a' }}>
|
| 139 |
+
<td style={{ padding: '12px 12px', color: '#92400e', fontWeight: 600, fontSize: '13px' }}>
|
| 140 |
+
🎯 Recommended Opening Bid
|
| 141 |
+
</td>
|
| 142 |
+
<td style={{ padding: '12px 12px', textAlign: 'right', fontWeight: 700, fontSize: '15px', color: '#b45309' }}>
|
| 143 |
+
£{(L.hard_cap * 0.85).toFixed(1)}m
|
| 144 |
+
</td>
|
| 145 |
+
</tr>
|
| 146 |
+
<tr style={{ backgroundColor: '#fff7ed', border: '1px solid #fed7aa' }}>
|
| 147 |
+
<td style={{ padding: '12px 12px', color: '#7c2d12', fontWeight: 600, fontSize: '13px' }}>
|
| 148 |
+
⚠️ Walk-Away Ceiling
|
| 149 |
+
</td>
|
| 150 |
+
<td style={{ padding: '12px 12px', textAlign: 'right', fontWeight: 700, fontSize: '15px', color: '#dc2626' }}>
|
| 151 |
+
£{L.hard_cap.toFixed(1)}m
|
| 152 |
+
</td>
|
| 153 |
+
</tr>
|
| 154 |
</tbody>
|
| 155 |
</table>
|
| 156 |
</div>
|
|
|
|
| 163 |
</h2>
|
| 164 |
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '16px', background: '#f8fafc', padding: '24px', borderRadius: '12px', border: '1px solid #e2e8f0' }}>
|
| 165 |
<div>
|
| 166 |
+
<div style={{ fontSize: '11px', color: '#64748b', textTransform: 'uppercase' }}>Recent Form & Impact</div>
|
| 167 |
+
<div style={{ fontSize: '18px', fontWeight: 700, color: (result.nlp_results?.recency ?? 0) === 0 ? '#94a3b8' : (result.nlp_results?.recency ?? 0) < 0 ? '#ef4444' : '#22c55e' }}>
|
| 168 |
+
{(result.nlp_results?.recency ?? 0) === 0 ? 'No signals detected' : `${(result.nlp_results?.recency ?? 0) > 0 ? '+' : ''}${(result.nlp_results?.recency ?? 0).toFixed(2)}`}
|
| 169 |
</div>
|
| 170 |
</div>
|
| 171 |
<div>
|
| 172 |
<div style={{ fontSize: '11px', color: '#64748b', textTransform: 'uppercase' }}>Injury / Availability</div>
|
| 173 |
+
<div style={{ fontSize: '18px', fontWeight: 700, color: (result.nlp_results?.durability ?? 0) === 0 ? '#94a3b8' : (result.nlp_results?.durability ?? 0) < 0 ? '#ef4444' : '#0f172a' }}>
|
| 174 |
+
{(result.nlp_results?.durability ?? 0) === 0 ? 'No signals detected' : `${(result.nlp_results?.durability ?? 0) > 0 ? '+' : ''}${(result.nlp_results?.durability ?? 0).toFixed(2)}`}
|
| 175 |
</div>
|
| 176 |
</div>
|
| 177 |
<div>
|
| 178 |
<div style={{ fontSize: '11px', color: '#64748b', textTransform: 'uppercase' }}>Transfer Speculation</div>
|
| 179 |
+
<div style={{ fontSize: '18px', fontWeight: 700, color: (result.nlp_results?.agent ?? 0) === 0 ? '#94a3b8' : (result.nlp_results?.agent ?? 0) < 0 ? '#ef4444' : '#22c55e' }}>
|
| 180 |
+
{(result.nlp_results?.agent ?? 0) === 0 ? 'No signals detected' : `${(result.nlp_results?.agent ?? 0) > 0 ? '+' : ''}${(result.nlp_results?.agent ?? 0).toFixed(2)}`}
|
| 181 |
</div>
|
| 182 |
</div>
|
| 183 |
</div>
|
|
|
|
| 194 |
</h2>
|
| 195 |
<div style={{ padding: '24px', background: isOverpay ? '#fef2f2' : '#f0fdf4', border: `1px solid ${isOverpay ? '#fecaca' : '#bbf7d0'}`, borderRadius: '12px' }}>
|
| 196 |
<p style={{ fontSize: '15px', color: isOverpay ? '#991b1b' : '#166534', lineHeight: 1.6, marginBottom: '16px' }}>
|
| 197 |
+
<strong>Negotiation Intel:</strong> Based on the real-time evaluation engine, {form.selected_name}'s intrinsic value sits at £{L.intrinsic_performance_value.toFixed(1)}m.
|
| 198 |
+
Age & contract dynamics apply a {L.depreciation > 0 ? `depreciation of -£${Math.abs(L.depreciation).toFixed(1)}m` : `appreciation of +£${Math.abs(L.depreciation).toFixed(1)}m`},
|
| 199 |
+
with live NLP market sentiment at ×{L.external_multiplier.toFixed(3)}. The absolute financial ceiling (Fair Value) is <strong>£{L.hard_cap.toFixed(1)}m</strong>.
|
| 200 |
<br/><br/>
|
| 201 |
+
<strong>Validation:</strong> {(result.nlp_results?.recency ?? 0) > 0 ? "Player's recent form commands a market premium." : "Recent form is suboptimal — leverage this to negotiate a lower fee."} {L.depreciation > 0 ? `Age & contract profile adds £${Math.abs(L.depreciation).toFixed(1)}m in depreciation risk — use this as a negotiation lever.` : `Young age and contract security add £${Math.abs(L.depreciation).toFixed(1)}m in appreciation value, justifying a strong offer.`}
|
| 202 |
+
{L.depreciation > 0 && form.age <= 23 && (
|
| 203 |
+
<span style={{ display: 'block', marginTop: '8px', fontSize: '12px', color: '#64748b' }}>
|
| 204 |
+
ℹ️ Note: Depreciation reflects the player's current statistical output stage vs. their peak. Young players may command a future-value premium not captured in this model.
|
| 205 |
+
</span>
|
| 206 |
+
)}
|
| 207 |
</p>
|
| 208 |
<div style={{ fontSize: '18px', fontWeight: 800, color: isOverpay ? '#dc2626' : '#16a34a', borderTop: `1px solid ${isOverpay ? '#fca5a5' : '#86efac'}`, paddingTop: '16px' }}>
|
| 209 |
VERDICT: {isOverpay ? 'OVERPAY RISK' : 'FAIR DEAL - PROCEED WITH CONFIDENCE'}
|
fairvalue-webapp/src/pages/Estimator.tsx
CHANGED
|
@@ -221,13 +221,8 @@ export default function Estimator() {
|
|
| 221 |
|
| 222 |
// Auto-Format the Player Name so the PDF always looks professional
|
| 223 |
let cleanName = form.selected_name.trim()
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
cleanName = exactMatch // Snap to exact database spelling
|
| 227 |
-
} else {
|
| 228 |
-
// Apply Title Case for new/unrecognized players (e.g. "john doe" -> "John Doe")
|
| 229 |
-
cleanName = cleanName.replace(/\b\w/g, c => c.toUpperCase())
|
| 230 |
-
}
|
| 231 |
|
| 232 |
if (cleanName !== form.selected_name) {
|
| 233 |
setForm(prev => ({ ...prev, selected_name: cleanName }))
|
|
@@ -255,9 +250,20 @@ export default function Estimator() {
|
|
| 255 |
}
|
| 256 |
}
|
| 257 |
|
| 258 |
-
const handleDownloadPdf =
|
| 259 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
window.print()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
}
|
| 262 |
|
| 263 |
const L = result?.ledger
|
|
@@ -410,10 +416,15 @@ export default function Estimator() {
|
|
| 410 |
? `⚠️ OVERPAY RISK — Asking price £${form.asking_price}m exceeds our Fair Value ceiling of £${L.hard_cap.toFixed(1)}m by £${(form.asking_price - L.hard_cap).toFixed(1)}m.`
|
| 411 |
: `✅ FAIR DEAL — Asking price £${form.asking_price}m is within the £${L.hard_cap.toFixed(1)}m Fair Value. Proceed with confidence.`}
|
| 412 |
</div>
|
| 413 |
-
<
|
| 414 |
-
<
|
| 415 |
-
|
| 416 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
</div>
|
| 418 |
|
| 419 |
{/* Gauge + Ledger */}
|
|
@@ -432,9 +443,9 @@ export default function Estimator() {
|
|
| 432 |
</div>
|
| 433 |
<div className="divider" style={{ margin:'8px 0' }}/>
|
| 434 |
<div>
|
| 435 |
-
<div className="metric-label">Age & Contract Impact
|
| 436 |
-
<div className="metric-value" style={{ color: L.depreciation
|
| 437 |
-
{L.depreciation >
|
| 438 |
</div>
|
| 439 |
<span className="badge badge-blue" style={{ marginTop:4 }}>SHAP Calculated</span>
|
| 440 |
</div>
|
|
|
|
| 221 |
|
| 222 |
// Auto-Format the Player Name so the PDF always looks professional
|
| 223 |
let cleanName = form.selected_name.trim()
|
| 224 |
+
// Apply Title Case for unrecognized players (e.g. "john doe" -> "John Doe")
|
| 225 |
+
cleanName = cleanName.replace(/\b\w/g, c => c.toUpperCase())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
|
| 227 |
if (cleanName !== form.selected_name) {
|
| 228 |
setForm(prev => ({ ...prev, selected_name: cleanName }))
|
|
|
|
| 250 |
}
|
| 251 |
}
|
| 252 |
|
| 253 |
+
const handleDownloadPdf = () => {
|
| 254 |
+
// Temporarily change document title so the native Print/Save dialog
|
| 255 |
+
// automatically uses this as the default PDF file name.
|
| 256 |
+
const originalTitle = document.title
|
| 257 |
+
const safeName = form.selected_name.replace(/\s+/g, '_') || 'Player'
|
| 258 |
+
document.title = `FairValue_Report_${safeName}`
|
| 259 |
+
|
| 260 |
+
// Rely on native browser print for high-fidelity, copyable vectorized PDFs
|
| 261 |
window.print()
|
| 262 |
+
|
| 263 |
+
// Restore the original title after the print dialog resolves
|
| 264 |
+
setTimeout(() => {
|
| 265 |
+
document.title = originalTitle
|
| 266 |
+
}, 1000)
|
| 267 |
}
|
| 268 |
|
| 269 |
const L = result?.ledger
|
|
|
|
| 416 |
? `⚠️ OVERPAY RISK — Asking price £${form.asking_price}m exceeds our Fair Value ceiling of £${L.hard_cap.toFixed(1)}m by £${(form.asking_price - L.hard_cap).toFixed(1)}m.`
|
| 417 |
: `✅ FAIR DEAL — Asking price £${form.asking_price}m is within the £${L.hard_cap.toFixed(1)}m Fair Value. Proceed with confidence.`}
|
| 418 |
</div>
|
| 419 |
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
| 420 |
+
<button onClick={handleDownloadPdf} className="btn btn-secondary" style={{ padding: '0 24px' }} title="Download Professional PDF Report">
|
| 421 |
+
<Download size={20} />
|
| 422 |
+
Download PDF Report
|
| 423 |
+
</button>
|
| 424 |
+
<span style={{ fontSize: '0.68rem', color: 'var(--text-2)', textAlign: 'center', lineHeight: 1.4 }}>
|
| 425 |
+
💡 In the print dialog, set <strong style={{ color: 'var(--text-1)' }}>Destination → Save as PDF</strong>
|
| 426 |
+
</span>
|
| 427 |
+
</div>
|
| 428 |
</div>
|
| 429 |
|
| 430 |
{/* Gauge + Ledger */}
|
|
|
|
| 443 |
</div>
|
| 444 |
<div className="divider" style={{ margin:'8px 0' }}/>
|
| 445 |
<div>
|
| 446 |
+
<div className="metric-label">Age & Contract Impact — {L.depreciation > 0 ? '📉 Depreciation' : '📈 Appreciation'}</div>
|
| 447 |
+
<div className="metric-value" style={{ color: L.depreciation > 0 ? 'var(--loss-color)' : 'var(--profit-color)' }}>
|
| 448 |
+
{L.depreciation > 0 ? '-' : '+'}£{Math.abs(L.depreciation).toFixed(1)}m
|
| 449 |
</div>
|
| 450 |
<span className="badge badge-blue" style={{ marginTop:4 }}>SHAP Calculated</span>
|
| 451 |
</div>
|