- 7 pre-seeded desks (Forex, Metals, Agri, Energy, Indices, Crypto, Bonds) each with default fundamental drivers, macro regime sensitivities and price delta thresholds - Global report catalogue (specialist_reports) fully manual — add any report including non-calendar ones (e.g. Cocoa Grinding Report, ICCO) - Many-to-many report ↔ desk linking (report_desk_links table) - 12 default reports pre-seeded (COT, EIA, WASDE, FOMC, ECB, CPI, NFP…) - AI scorer injects SPECIALIST DESK context block for asset classes present in each scoring batch (upcoming reports, key drivers, regime sensitivity) - /specialist-desks page: desk sidebar + fundamentals editor + macro sensitivity tag editor + reports tab + global reports catalogue + modal to create/edit any report with desk assignment Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
683 lines
34 KiB
TypeScript
683 lines
34 KiB
TypeScript
import { useState } from 'react'
|
|
import {
|
|
useAllDeskConfigs, useAllSpecialistReports,
|
|
useUpdateDeskConfig, useCreateSpecialistReport,
|
|
useUpdateSpecialistReport, useDeleteSpecialistReport,
|
|
useLinkReportDesk, useUnlinkReportDesk,
|
|
DeskConfig, SpecialistReport, MacroSensitivity,
|
|
} from '../hooks/useApi'
|
|
import {
|
|
Users, FileText, Plus, Trash2, Save, X, Edit2,
|
|
ChevronRight, Star, Link, ExternalLink, RefreshCw,
|
|
AlertCircle, Calendar, BookOpen,
|
|
} from 'lucide-react'
|
|
import clsx from 'clsx'
|
|
|
|
const CADENCE_OPTIONS = ['daily', 'weekly', 'bi-weekly', 'monthly', '6-weekly', 'quarterly', 'annual', 'irregular']
|
|
const DESK_ORDER = ['forex', 'metals', 'agri', 'energy', 'indices', 'crypto', 'bonds']
|
|
|
|
// ── Importance stars ───────────────────────────────────────────────────────────
|
|
|
|
function Stars({ n, onChange }: { n: number; onChange?: (v: number) => void }) {
|
|
return (
|
|
<span className="flex gap-0.5">
|
|
{[1, 2, 3].map(i => (
|
|
<Star
|
|
key={i}
|
|
onClick={() => onChange?.(i)}
|
|
className={clsx('w-3.5 h-3.5 transition-colors',
|
|
i <= n ? 'text-amber-400 fill-amber-400' : 'text-slate-700',
|
|
onChange && 'cursor-pointer hover:text-amber-300')}
|
|
/>
|
|
))}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
// ── Report modal ───────────────────────────────────────────────────────────────
|
|
|
|
function ReportModal({
|
|
initial, allDesks, onSave, onClose,
|
|
}: {
|
|
initial?: SpecialistReport & { desks?: string[] }
|
|
allDesks: DeskConfig[]
|
|
onSave: (data: any) => void
|
|
onClose: () => void
|
|
}) {
|
|
const [name, setName] = useState(initial?.name ?? '')
|
|
const [source, setSource] = useState(initial?.source ?? '')
|
|
const [cadence, setCadence] = useState(initial?.cadence ?? 'monthly')
|
|
const [nextDate, setNextDate] = useState(initial?.next_date ?? '')
|
|
const [lastDate, setLastDate] = useState(initial?.last_date ?? '')
|
|
const [importance, setImp] = useState(initial?.importance ?? 2)
|
|
const [notes, setNotes] = useState(initial?.notes ?? '')
|
|
const [url, setUrl] = useState(initial?.url ?? '')
|
|
const [desks, setDesks] = useState<string[]>(initial?.desks ?? [])
|
|
|
|
const toggleDesk = (ac: string) =>
|
|
setDesks(d => d.includes(ac) ? d.filter(x => x !== ac) : [...d, ac])
|
|
|
|
const handleSave = () => {
|
|
if (!name.trim()) return
|
|
onSave({ name: name.trim(), source, cadence, next_date: nextDate || null, last_date: lastDate || null, importance, notes, url, desks })
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
|
<div className="w-[520px] bg-dark-800 border border-slate-700/60 rounded-xl shadow-2xl overflow-hidden">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-700/40">
|
|
<div className="flex items-center gap-2">
|
|
<FileText className="w-4 h-4 text-violet-400" />
|
|
<span className="font-semibold text-slate-100 text-sm">
|
|
{initial ? 'Edit Report' : 'New Report'}
|
|
</span>
|
|
</div>
|
|
<button onClick={onClose} className="text-slate-500 hover:text-slate-300 transition-colors">
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="p-5 space-y-4 max-h-[70vh] overflow-y-auto">
|
|
{/* Name + Source */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="text-[10px] text-slate-500 uppercase tracking-wide mb-1 block">Report name *</label>
|
|
<input value={name} onChange={e => setName(e.target.value)}
|
|
placeholder="Cocoa Grinding Report"
|
|
className="w-full px-3 py-2 text-xs bg-dark-700 border border-slate-600/50 rounded text-slate-200 focus:outline-none focus:border-violet-500" />
|
|
</div>
|
|
<div>
|
|
<label className="text-[10px] text-slate-500 uppercase tracking-wide mb-1 block">Source / Issuer</label>
|
|
<input value={source} onChange={e => setSource(e.target.value)}
|
|
placeholder="ICCO, USDA, CFTC…"
|
|
className="w-full px-3 py-2 text-xs bg-dark-700 border border-slate-600/50 rounded text-slate-200 focus:outline-none focus:border-violet-500" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Cadence + Importance */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="text-[10px] text-slate-500 uppercase tracking-wide mb-1 block">Cadence</label>
|
|
<select value={cadence} onChange={e => setCadence(e.target.value)}
|
|
className="w-full px-3 py-2 text-xs bg-dark-700 border border-slate-600/50 rounded text-slate-200 focus:outline-none focus:border-violet-500">
|
|
{CADENCE_OPTIONS.map(c => <option key={c} value={c}>{c}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="text-[10px] text-slate-500 uppercase tracking-wide mb-1 block">Importance</label>
|
|
<div className="flex items-center gap-2 pt-2">
|
|
<Stars n={importance} onChange={setImp} />
|
|
<span className="text-[10px] text-slate-500">{importance === 3 ? 'High' : importance === 2 ? 'Medium' : 'Low'}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Dates */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="text-[10px] text-slate-500 uppercase tracking-wide mb-1 block">Next release date</label>
|
|
<input type="date" value={nextDate} onChange={e => setNextDate(e.target.value)}
|
|
className="w-full px-3 py-2 text-xs bg-dark-700 border border-slate-600/50 rounded text-slate-200 focus:outline-none focus:border-violet-500" />
|
|
</div>
|
|
<div>
|
|
<label className="text-[10px] text-slate-500 uppercase tracking-wide mb-1 block">Last release date</label>
|
|
<input type="date" value={lastDate} onChange={e => setLastDate(e.target.value)}
|
|
className="w-full px-3 py-2 text-xs bg-dark-700 border border-slate-600/50 rounded text-slate-200 focus:outline-none focus:border-violet-500" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* URL */}
|
|
<div>
|
|
<label className="text-[10px] text-slate-500 uppercase tracking-wide mb-1 block">URL (optional)</label>
|
|
<input value={url} onChange={e => setUrl(e.target.value)}
|
|
placeholder="https://…"
|
|
className="w-full px-3 py-2 text-xs bg-dark-700 border border-slate-600/50 rounded text-slate-200 focus:outline-none focus:border-violet-500" />
|
|
</div>
|
|
|
|
{/* Notes */}
|
|
<div>
|
|
<label className="text-[10px] text-slate-500 uppercase tracking-wide mb-1 block">Notes</label>
|
|
<textarea value={notes} onChange={e => setNotes(e.target.value)} rows={2}
|
|
placeholder="Market impact context, watch for…"
|
|
className="w-full px-3 py-2 text-xs bg-dark-700 border border-slate-600/50 rounded text-slate-300 focus:outline-none focus:border-violet-500 resize-none" />
|
|
</div>
|
|
|
|
{/* Desk assignment */}
|
|
<div>
|
|
<label className="text-[10px] text-slate-500 uppercase tracking-wide mb-2 block">Assign to desks</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
{DESK_ORDER.map(ac => {
|
|
const desk = allDesks.find(d => d.asset_class === ac)
|
|
if (!desk) return null
|
|
const active = desks.includes(ac)
|
|
return (
|
|
<button key={ac} onClick={() => toggleDesk(ac)}
|
|
className={clsx(
|
|
'flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs border transition-colors',
|
|
active
|
|
? 'bg-violet-700 border-violet-500 text-white'
|
|
: 'border-slate-700 text-slate-500 hover:text-slate-300 hover:border-slate-500'
|
|
)}>
|
|
<span>{desk.icon}</span>
|
|
<span>{desk.display_name}</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="px-5 py-3 border-t border-slate-700/40 flex justify-end gap-2">
|
|
<button onClick={onClose}
|
|
className="px-3 py-1.5 text-xs text-slate-400 hover:text-slate-200 transition-colors">
|
|
Cancel
|
|
</button>
|
|
<button onClick={handleSave} disabled={!name.trim()}
|
|
className="flex items-center gap-1.5 px-4 py-1.5 bg-violet-700 hover:bg-violet-600 disabled:opacity-40 text-white text-xs font-semibold rounded transition-colors">
|
|
<Save className="w-3.5 h-3.5" /> Save
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Macro sensitivity row editor ───────────────────────────────────────────────
|
|
|
|
function MacroSensitivityEditor({
|
|
items, onChange,
|
|
}: { items: MacroSensitivity[]; onChange: (v: MacroSensitivity[]) => void }) {
|
|
const [regime, setRegime] = useState('')
|
|
const [effect, setEffect] = useState('')
|
|
|
|
const add = () => {
|
|
if (!regime.trim() || !effect.trim()) return
|
|
onChange([...items, { regime: regime.trim(), effect: effect.trim() }])
|
|
setRegime(''); setEffect('')
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
{items.map((s, i) => (
|
|
<div key={i} className="flex items-center gap-2 bg-dark-700/60 rounded px-2 py-1.5 text-xs">
|
|
<span className="font-mono text-violet-300 min-w-[120px] truncate">{s.regime}</span>
|
|
<ChevronRight className="w-3 h-3 text-slate-600 flex-shrink-0" />
|
|
<span className="text-slate-300 flex-1 truncate">{s.effect}</span>
|
|
<button onClick={() => onChange(items.filter((_, j) => j !== i))}
|
|
className="text-slate-600 hover:text-red-400 transition-colors flex-shrink-0">
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
<div className="flex gap-2">
|
|
<input value={regime} onChange={e => setRegime(e.target.value)}
|
|
onKeyDown={e => e.key === 'Enter' && add()}
|
|
placeholder="regime (e.g. risk_off)"
|
|
className="w-36 px-2 py-1 text-xs bg-dark-700 border border-slate-700/50 rounded text-slate-300 placeholder-slate-600 focus:outline-none focus:border-violet-500 font-mono" />
|
|
<input value={effect} onChange={e => setEffect(e.target.value)}
|
|
onKeyDown={e => e.key === 'Enter' && add()}
|
|
placeholder="effect on this asset class"
|
|
className="flex-1 px-2 py-1 text-xs bg-dark-700 border border-slate-700/50 rounded text-slate-300 placeholder-slate-600 focus:outline-none focus:border-violet-500" />
|
|
<button onClick={add} disabled={!regime.trim() || !effect.trim()}
|
|
className="px-2 py-1 bg-violet-800 hover:bg-violet-700 disabled:opacity-30 text-white rounded text-xs transition-colors">
|
|
<Plus className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ── Main page ──────────────────────────────────────────────────────────────────
|
|
|
|
export default function SpecialistDesks() {
|
|
const { data: desks = [], isLoading: desksLoading } = useAllDeskConfigs()
|
|
const { data: allReports = [] } = useAllSpecialistReports()
|
|
const { mutateAsync: updateDesk, isPending: savingDesk } = useUpdateDeskConfig()
|
|
const { mutateAsync: createReport } = useCreateSpecialistReport()
|
|
const { mutateAsync: updateReport } = useUpdateSpecialistReport()
|
|
const { mutate: deleteReport } = useDeleteSpecialistReport()
|
|
const { mutate: linkReport } = useLinkReportDesk()
|
|
const { mutate: unlinkReport } = useUnlinkReportDesk()
|
|
|
|
const orderedDesks = DESK_ORDER
|
|
.map(ac => desks.find(d => d.asset_class === ac))
|
|
.filter(Boolean) as DeskConfig[]
|
|
|
|
const [activeDeskAc, setActiveDeskAc] = useState<string>(DESK_ORDER[0])
|
|
const [activeTab, setActiveTab] = useState<'config' | 'reports' | 'all-reports'>('config')
|
|
|
|
// Desk edit state
|
|
const [editFund, setEditFund] = useState('')
|
|
const [editMacro, setEditMacro] = useState<MacroSensitivity[]>([])
|
|
const [editThresh, setEditThresh] = useState({ significant_day: 1.0, extreme_day: 3.0, significant_week: 2.0, extreme_week: 5.0 })
|
|
const [editNotes, setEditNotes] = useState('')
|
|
const [dirty, setDirty] = useState(false)
|
|
|
|
// Report modal
|
|
const [reportModal, setReportModal] = useState<null | 'new' | (SpecialistReport & { desks?: string[] })>(null)
|
|
|
|
const activeDesk = desks.find(d => d.asset_class === activeDeskAc)
|
|
|
|
const selectDesk = (ac: string) => {
|
|
const d = desks.find(x => x.asset_class === ac)
|
|
if (!d) return
|
|
setActiveDeskAc(ac)
|
|
setEditFund(d.fundamentals)
|
|
setEditMacro(d.macro_sensitivity ?? [])
|
|
setEditThresh(d.price_delta_thresholds ?? { significant_day: 1, extreme_day: 3, significant_week: 2, extreme_week: 5 })
|
|
setEditNotes(d.notes ?? '')
|
|
setDirty(false)
|
|
setActiveTab('config')
|
|
}
|
|
|
|
// Init on first load
|
|
const [initialized, setInitialized] = useState(false)
|
|
if (!initialized && desks.length > 0) {
|
|
setInitialized(true)
|
|
selectDesk(DESK_ORDER[0])
|
|
}
|
|
|
|
const handleSaveDesk = async () => {
|
|
if (!activeDesk) return
|
|
await updateDesk({
|
|
asset_class: activeDeskAc,
|
|
body: {
|
|
display_name: activeDesk.display_name,
|
|
icon: activeDesk.icon,
|
|
fundamentals: editFund,
|
|
macro_sensitivity: editMacro,
|
|
price_delta_thresholds: editThresh,
|
|
notes: editNotes,
|
|
},
|
|
})
|
|
setDirty(false)
|
|
}
|
|
|
|
const handleCreateReport = async (data: any) => {
|
|
await createReport(data)
|
|
setReportModal(null)
|
|
}
|
|
|
|
const handleUpdateReport = async (data: any) => {
|
|
if (!reportModal || reportModal === 'new') return
|
|
await updateReport({ id: (reportModal as SpecialistReport).id, body: data })
|
|
setReportModal(null)
|
|
}
|
|
|
|
const deskReports = activeDesk?.reports ?? []
|
|
const today = new Date().toISOString().slice(0, 10)
|
|
|
|
if (desksLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-screen text-slate-500">
|
|
<RefreshCw className="w-5 h-5 animate-spin mr-2" /> Loading desks…
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-screen bg-dark-900 text-slate-200 overflow-hidden">
|
|
{/* ── Left: desk list ─────────────────────────────────────────────────── */}
|
|
<div className="w-52 flex-shrink-0 border-r border-slate-700/40 flex flex-col">
|
|
<div className="p-4 border-b border-slate-700/40">
|
|
<div className="flex items-center gap-2">
|
|
<Users className="w-4 h-4 text-violet-400" />
|
|
<span className="font-semibold text-sm text-slate-100">Specialist Desks</span>
|
|
</div>
|
|
<p className="text-[10px] text-slate-600 mt-1">Per asset-class fundamental configs + reports</p>
|
|
</div>
|
|
|
|
<nav className="flex-1 py-2 overflow-y-auto">
|
|
{orderedDesks.map(d => (
|
|
<button
|
|
key={d.asset_class}
|
|
onClick={() => selectDesk(d.asset_class)}
|
|
className={clsx(
|
|
'w-full text-left px-4 py-3 flex items-center gap-3 border-b border-slate-700/20 transition-colors',
|
|
d.asset_class === activeDeskAc
|
|
? 'bg-violet-900/30 border-l-2 border-l-violet-500'
|
|
: 'hover:bg-dark-700/50'
|
|
)}>
|
|
<span className="text-lg">{d.icon}</span>
|
|
<div>
|
|
<div className="text-xs font-medium text-slate-200">{d.display_name}</div>
|
|
<div className="text-[10px] text-slate-600">{d.reports.length} report{d.reports.length !== 1 ? 's' : ''}</div>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</nav>
|
|
|
|
{/* All reports link */}
|
|
<button
|
|
onClick={() => setActiveTab('all-reports')}
|
|
className={clsx(
|
|
'px-4 py-3 flex items-center gap-2 border-t border-slate-700/40 transition-colors text-xs',
|
|
activeTab === 'all-reports'
|
|
? 'bg-slate-700/50 text-slate-200'
|
|
: 'text-slate-500 hover:text-slate-300'
|
|
)}>
|
|
<FileText className="w-3.5 h-3.5" />
|
|
All Reports ({allReports.length})
|
|
</button>
|
|
</div>
|
|
|
|
{/* ── Right: desk detail / all reports ──────────────────────────────── */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
{activeTab === 'all-reports' ? (
|
|
/* ══ ALL REPORTS VIEW ════════════════════════════════════════════ */
|
|
<div className="p-6 space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-lg font-bold text-slate-100 flex items-center gap-2">
|
|
<FileText className="w-5 h-5 text-violet-400" />
|
|
Report Catalogue
|
|
</h1>
|
|
<p className="text-xs text-slate-500 mt-0.5">All reports — manual and auto-tracked</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setReportModal('new')}
|
|
className="flex items-center gap-1.5 bg-violet-700 hover:bg-violet-600 text-white px-3 py-2 rounded text-xs font-semibold transition-colors">
|
|
<Plus className="w-3.5 h-3.5" /> New Report
|
|
</button>
|
|
</div>
|
|
|
|
<div className="bg-dark-800 border border-slate-700/40 rounded-lg overflow-hidden">
|
|
<table className="w-full text-xs">
|
|
<thead>
|
|
<tr className="text-[10px] text-slate-500 uppercase tracking-wide border-b border-slate-700/40">
|
|
<th className="text-left px-4 py-2">Report</th>
|
|
<th className="text-left px-3 py-2">Source</th>
|
|
<th className="text-left px-3 py-2">Cadence</th>
|
|
<th className="text-left px-3 py-2">Next date</th>
|
|
<th className="text-left px-3 py-2">Desks</th>
|
|
<th className="text-left px-3 py-2">Imp.</th>
|
|
<th className="px-3 py-2"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{allReports.map(r => (
|
|
<tr key={r.id} className="border-b border-slate-700/20 hover:bg-dark-700/40 transition-colors">
|
|
<td className="px-4 py-2">
|
|
<div className="font-medium text-slate-200">{r.name}</div>
|
|
{r.notes && <div className="text-[10px] text-slate-600 truncate max-w-[200px]">{r.notes}</div>}
|
|
</td>
|
|
<td className="px-3 py-2 text-slate-400">{r.source}</td>
|
|
<td className="px-3 py-2">
|
|
<span className="bg-dark-700 rounded px-1.5 py-0.5 text-slate-400 font-mono">{r.cadence}</span>
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
{r.next_date ? (
|
|
<span className={clsx(
|
|
'font-mono',
|
|
r.next_date <= today ? 'text-orange-400' : 'text-slate-300'
|
|
)}>{r.next_date}</span>
|
|
) : <span className="text-slate-700">—</span>}
|
|
</td>
|
|
<td className="px-3 py-2">
|
|
<div className="flex flex-wrap gap-1">
|
|
{(r.desks ?? []).map(ac => {
|
|
const d = desks.find(x => x.asset_class === ac)
|
|
return d ? (
|
|
<span key={ac} className="text-[10px] bg-dark-700 rounded px-1 text-slate-400">
|
|
{d.icon}
|
|
</span>
|
|
) : null
|
|
})}
|
|
</div>
|
|
</td>
|
|
<td className="px-3 py-2"><Stars n={r.importance} /></td>
|
|
<td className="px-3 py-2">
|
|
<div className="flex items-center gap-1">
|
|
{r.url && (
|
|
<a href={r.url} target="_blank" rel="noopener noreferrer"
|
|
className="text-slate-600 hover:text-violet-400 transition-colors">
|
|
<ExternalLink className="w-3 h-3" />
|
|
</a>
|
|
)}
|
|
<button onClick={() => setReportModal(r as any)}
|
|
className="text-slate-600 hover:text-slate-300 transition-colors">
|
|
<Edit2 className="w-3 h-3" />
|
|
</button>
|
|
<button onClick={() => deleteReport(r.id)}
|
|
className="text-slate-600 hover:text-red-400 transition-colors">
|
|
<Trash2 className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{allReports.length === 0 && (
|
|
<tr><td colSpan={7} className="px-4 py-8 text-center text-slate-600">No reports yet</td></tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
) : activeDesk ? (
|
|
/* ══ DESK DETAIL ═════════════════════════════════════════════════ */
|
|
<div className="p-6 space-y-5">
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h1 className="text-xl font-bold text-slate-100 flex items-center gap-2">
|
|
<span className="text-2xl">{activeDesk.icon}</span>
|
|
{activeDesk.display_name}
|
|
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-normal ml-1">Specialist Desk</span>
|
|
</h1>
|
|
</div>
|
|
{dirty && (
|
|
<button onClick={handleSaveDesk} disabled={savingDesk}
|
|
className="flex items-center gap-1.5 bg-emerald-700 hover:bg-emerald-600 disabled:opacity-50 text-white px-4 py-2 rounded text-xs font-semibold transition-colors">
|
|
{savingDesk ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
|
Save changes
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex gap-1 bg-dark-800 rounded p-0.5 w-fit">
|
|
{(['config', 'reports'] as const).map(tab => (
|
|
<button key={tab} onClick={() => setActiveTab(tab)}
|
|
className={clsx('px-4 py-1.5 text-xs rounded transition-colors font-medium flex items-center gap-1.5', {
|
|
'bg-violet-700 text-white': activeTab === tab,
|
|
'text-slate-500 hover:text-slate-300': activeTab !== tab,
|
|
})}>
|
|
{tab === 'config' ? <BookOpen className="w-3 h-3" /> : <FileText className="w-3 h-3" />}
|
|
{tab === 'config' ? 'Fundamentals' : `Reports (${deskReports.length})`}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* ── CONFIG TAB ── */}
|
|
{activeTab === 'config' && (
|
|
<div className="space-y-5">
|
|
{/* Key drivers */}
|
|
<div className="bg-dark-800 border border-slate-700/40 rounded-lg p-4">
|
|
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-2 block">
|
|
Key Fundamental Drivers
|
|
</label>
|
|
<textarea
|
|
value={editFund}
|
|
onChange={e => { setEditFund(e.target.value); setDirty(true) }}
|
|
rows={4}
|
|
placeholder="Describe the primary price drivers for this asset class…"
|
|
className="w-full px-3 py-2 text-xs bg-dark-700 border border-slate-600/50 rounded text-slate-300 focus:outline-none focus:border-violet-500 resize-none leading-relaxed"
|
|
/>
|
|
<p className="text-[10px] text-slate-600 mt-1">This block is injected into the AI scorer prompt for relevant patterns.</p>
|
|
</div>
|
|
|
|
{/* Macro sensitivity */}
|
|
<div className="bg-dark-800 border border-slate-700/40 rounded-lg p-4">
|
|
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-2 block">
|
|
Macro Regime Sensitivity
|
|
</label>
|
|
<MacroSensitivityEditor
|
|
items={editMacro}
|
|
onChange={v => { setEditMacro(v); setDirty(true) }}
|
|
/>
|
|
<p className="text-[10px] text-slate-600 mt-2">Format: regime identifier → market effect. Injected as signal context for the scorer.</p>
|
|
</div>
|
|
|
|
{/* Price thresholds */}
|
|
<div className="bg-dark-800 border border-slate-700/40 rounded-lg p-4">
|
|
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-3 block">
|
|
Price Move Thresholds
|
|
</label>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{([
|
|
['significant_day', 'Significant (daily)'],
|
|
['extreme_day', 'Extreme (daily)'],
|
|
['significant_week', 'Significant (weekly)'],
|
|
['extreme_week', 'Extreme (weekly)'],
|
|
] as const).map(([key, label]) => (
|
|
<div key={key}>
|
|
<label className="text-[10px] text-slate-500 mb-1 block">{label}</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="number" step="0.1" min="0"
|
|
value={editThresh[key]}
|
|
onChange={e => { setEditThresh(t => ({ ...t, [key]: parseFloat(e.target.value) || 0 })); setDirty(true) }}
|
|
className="w-20 px-2 py-1.5 text-xs bg-dark-700 border border-slate-600/50 rounded text-slate-200 focus:outline-none focus:border-violet-500 font-mono"
|
|
/>
|
|
<span className="text-xs text-slate-500">%</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Notes */}
|
|
<div className="bg-dark-800 border border-slate-700/40 rounded-lg p-4">
|
|
<label className="text-xs font-semibold text-slate-400 uppercase tracking-wide mb-2 block">Notes</label>
|
|
<textarea
|
|
value={editNotes}
|
|
onChange={e => { setEditNotes(e.target.value); setDirty(true) }}
|
|
rows={2}
|
|
placeholder="Additional desk-specific notes…"
|
|
className="w-full px-3 py-2 text-xs bg-dark-700 border border-slate-600/50 rounded text-slate-300 focus:outline-none focus:border-violet-500 resize-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── REPORTS TAB ── */}
|
|
{activeTab === 'reports' && (
|
|
<div className="space-y-3">
|
|
<div className="flex justify-between items-center">
|
|
<p className="text-xs text-slate-500">
|
|
Reports assigned to the <span className="text-violet-300">{activeDesk.display_name}</span> desk.
|
|
They appear in the AI scorer's specialist context when relevant patterns are scored.
|
|
</p>
|
|
<button
|
|
onClick={() => setReportModal('new')}
|
|
className="flex items-center gap-1.5 bg-violet-700 hover:bg-violet-600 text-white px-3 py-1.5 rounded text-xs font-semibold transition-colors">
|
|
<Plus className="w-3 h-3" /> New report
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
{deskReports.map(r => {
|
|
const overdue = r.next_date && r.next_date <= today
|
|
return (
|
|
<div key={r.id}
|
|
className={clsx(
|
|
'flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors',
|
|
overdue ? 'border-orange-700/40 bg-orange-900/10' : 'border-slate-700/40 bg-dark-800'
|
|
)}>
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 mb-0.5">
|
|
<span className="text-xs font-semibold text-slate-100">{r.name}</span>
|
|
{overdue && (
|
|
<span className="flex items-center gap-1 text-[10px] text-orange-400">
|
|
<AlertCircle className="w-3 h-3" /> Due
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-3 text-[10px] text-slate-500">
|
|
<span>{r.source}</span>
|
|
<span className="bg-dark-700 rounded px-1 font-mono">{r.cadence}</span>
|
|
{r.next_date && (
|
|
<span className="flex items-center gap-1">
|
|
<Calendar className="w-3 h-3" />
|
|
Next: <span className={clsx('font-mono ml-0.5', overdue ? 'text-orange-400' : 'text-slate-300')}>{r.next_date}</span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
{r.notes && <p className="text-[10px] text-slate-600 mt-0.5 italic">{r.notes}</p>}
|
|
</div>
|
|
<Stars n={r.importance} />
|
|
<div className="flex items-center gap-1.5">
|
|
{r.url && (
|
|
<a href={r.url} target="_blank" rel="noopener noreferrer"
|
|
className="text-slate-600 hover:text-violet-400 transition-colors">
|
|
<ExternalLink className="w-3.5 h-3.5" />
|
|
</a>
|
|
)}
|
|
<button onClick={() => setReportModal(r as any)}
|
|
className="text-slate-600 hover:text-slate-300 transition-colors">
|
|
<Edit2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button
|
|
onClick={() => unlinkReport({ report_id: r.id, asset_class: activeDeskAc })}
|
|
title="Remove from this desk"
|
|
className="text-slate-600 hover:text-red-400 transition-colors">
|
|
<Link className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{deskReports.length === 0 && (
|
|
<div className="text-center py-12 text-slate-600">
|
|
<FileText className="w-8 h-8 mx-auto mb-2 opacity-30" />
|
|
<p className="text-sm">No reports assigned to this desk</p>
|
|
<p className="text-xs mt-1">Create one with "New report" or assign existing ones from the global catalogue</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Unassigned reports that could be linked */}
|
|
{(() => {
|
|
const unlinked = allReports.filter(
|
|
r => !(r.desks ?? []).includes(activeDeskAc)
|
|
)
|
|
if (unlinked.length === 0) return null
|
|
return (
|
|
<div className="border-t border-slate-700/30 pt-3">
|
|
<p className="text-[10px] text-slate-600 uppercase tracking-wide mb-2">Add existing report to this desk</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{unlinked.map(r => (
|
|
<button
|
|
key={r.id}
|
|
onClick={() => linkReport({ report_id: r.id, asset_class: activeDeskAc })}
|
|
className="flex items-center gap-1 px-2.5 py-1 text-xs border border-dashed border-slate-700 text-slate-500 hover:border-violet-600 hover:text-violet-300 rounded transition-colors">
|
|
<Plus className="w-3 h-3" /> {r.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
})()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
{/* ── Report modal ─────────────────────────────────────────────────────── */}
|
|
{reportModal && (
|
|
<ReportModal
|
|
initial={reportModal === 'new' ? undefined : reportModal}
|
|
allDesks={desks}
|
|
onSave={reportModal === 'new' ? handleCreateReport : handleUpdateReport}
|
|
onClose={() => setReportModal(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|