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 < 0 ? 'Penalty' : 'Premium'}
 
 
 
 
 
108
  </td>
109
- <td style={{ padding: '12px 0', textAlign: 'right', fontWeight: 700, color: L.depreciation < 0 ? '#ef4444' : '#22c55e' }}>
110
- {L.depreciation >= 0 ? '+' : ''}£{L.depreciation.toFixed(1)}m
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
- Adjusting for age and contract dynamics (£{L.depreciation.toFixed(1)}m) and live market NLP sentiment (×{L.external_multiplier.toFixed(3)}),
174
- the absolute financial ceiling (Fair Value) is strictly calculated at <strong>£{L.hard_cap.toFixed(1)}m</strong>.
175
  <br/><br/>
176
- <strong>Validation:</strong> {result.nlp_results.recency > 0 ? "Player's recent form commands a premium in the market." : "Recent form is suboptimal, creating leverage for a lower fee."} {L.depreciation < 0 ? "Age profile indicates significant depreciation risk—use this to negotiate down." : "Player's age and contract profile guarantees long-term asset retention, justifying a premium."}
 
 
 
 
 
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 &amp; 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 &amp; 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 &amp; 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
- const exactMatch = suggestions.find(s => s.toLowerCase() === cleanName.toLowerCase())
225
- if (exactMatch) {
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 = async () => {
259
- // Rely on native browser print for high-fidelity vectorized PDFs
 
 
 
 
 
 
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
- <button onClick={handleDownloadPdf} className="btn btn-secondary" style={{ padding: '0 24px' }} title="Download Professional PDF Report">
414
- <Download size={20} />
415
- Download Report
416
- </button>
 
 
 
 
 
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 {L.depreciation < 0 ? 'Penalty' : 'Premium'}</div>
436
- <div className="metric-value" style={{ color: L.depreciation < 0 ? 'var(--loss-color)' : 'var(--profit-color)' }}>
437
- {L.depreciation >= 0 ? '+' : ''}£{L.depreciation.toFixed(1)}m
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 &amp; Contract Impact &mdash; {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>