206 lines
8.9 KiB
TypeScript
206 lines
8.9 KiB
TypeScript
'use client'
|
||
import { useState, useEffect } from 'react'
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||
import type { ScoringRule } from '@/lib/types'
|
||
import { fetchScoringRules, createScoringRule, updateScoringRule, deleteScoringRule } from '@/lib/api/scoring-rules'
|
||
import { fetchConfig, saveConfig } from '@/lib/api/config'
|
||
import { cn } from '@/lib/utils'
|
||
|
||
export default function ScoringRulesPanel() {
|
||
const qc = useQueryClient()
|
||
const { data: rules = [], isLoading } = useQuery<ScoringRule[]>({
|
||
queryKey: ['scoring-rules'],
|
||
queryFn: fetchScoringRules,
|
||
})
|
||
|
||
const [scoringEnabled, setScoringEnabled] = useState(true)
|
||
const [toggleLoading, setToggleLoading] = useState(false)
|
||
|
||
useEffect(() => {
|
||
fetchConfig().then(cfg => {
|
||
setScoringEnabled((cfg['scoring_enabled'] ?? 'true') !== 'false')
|
||
})
|
||
}, [])
|
||
|
||
const handleScoringToggle = async () => {
|
||
setToggleLoading(true)
|
||
const newVal = !scoringEnabled
|
||
await saveConfig({ scoring_enabled: String(newVal) })
|
||
setScoringEnabled(newVal)
|
||
setToggleLoading(false)
|
||
}
|
||
|
||
const [newSignal, setNewSignal] = useState('')
|
||
const [newDelta, setNewDelta] = useState('')
|
||
const [newNotes, setNewNotes] = useState('')
|
||
const [error, setError] = useState('')
|
||
const [editId, setEditId] = useState<number | null>(null)
|
||
const [editSignal, setEditSignal] = useState('')
|
||
const [editDelta, setEditDelta] = useState('')
|
||
|
||
const invalidate = () => qc.invalidateQueries({ queryKey: ['scoring-rules'] })
|
||
|
||
const createMut = useMutation({
|
||
mutationFn: () => createScoringRule(newSignal.trim(), parseInt(newDelta), newNotes.trim() || undefined),
|
||
onSuccess: () => { setNewSignal(''); setNewDelta(''); setNewNotes(''); setError(''); invalidate() },
|
||
onError: (e: Error) => setError(e.message),
|
||
})
|
||
const updateMut = useMutation({
|
||
mutationFn: () => updateScoringRule(editId!, { signal: editSignal.trim(), delta: parseInt(editDelta) }),
|
||
onSuccess: () => { setEditId(null); invalidate() },
|
||
})
|
||
const deleteMut = useMutation({
|
||
mutationFn: (id: number) => deleteScoringRule(id),
|
||
onSuccess: invalidate,
|
||
})
|
||
|
||
const positives = rules.filter(r => r.delta > 0)
|
||
const negatives = rules.filter(r => r.delta < 0)
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* Header */}
|
||
<div className="flex items-start justify-between gap-4">
|
||
<div>
|
||
<h2 className="text-sm font-semibold text-g-text">Scoring Rules</h2>
|
||
<p className="text-xs text-g-faint mt-0.5">Token signals that boost (+) or penalise (−) lot scores</p>
|
||
</div>
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
<span className="text-xs text-g-faint">Scoring</span>
|
||
<button
|
||
onClick={handleScoringToggle}
|
||
disabled={toggleLoading}
|
||
className={cn(
|
||
'g-btn text-xs h-7',
|
||
scoringEnabled && '!border-g-green/40 !text-g-green !bg-g-green/8'
|
||
)}
|
||
>
|
||
{scoringEnabled ? 'On' : 'Off'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* AI-first banner */}
|
||
{!scoringEnabled && (
|
||
<div className="g-card border-g-green/20 bg-g-green/4 px-4 py-3 text-xs text-g-green space-y-1">
|
||
<div className="font-semibold">AI-first mode active</div>
|
||
<div className="text-g-muted leading-relaxed">
|
||
Score signals are disabled. The AI description on each target is the sole judge.
|
||
Set an AI Description on every target and enable AI Filter in Settings.
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className={cn(scoringEnabled ? '' : 'opacity-40 pointer-events-none select-none', 'space-y-4')}>
|
||
{/* Add new */}
|
||
<form
|
||
className="g-card p-4 flex gap-2 flex-wrap items-end"
|
||
onSubmit={e => { e.preventDefault(); if (newSignal && newDelta) createMut.mutate() }}
|
||
>
|
||
<div className="space-y-1">
|
||
<label className="text-[10px] uppercase tracking-widest text-g-faint">Signal</label>
|
||
<input className="g-input h-8 w-32 text-sm" placeholder="RTX" value={newSignal} onChange={e => setNewSignal(e.target.value)} />
|
||
</div>
|
||
<div className="space-y-1">
|
||
<label className="text-[10px] uppercase tracking-widest text-g-faint">Delta</label>
|
||
<input type="number" className="g-input h-8 w-20 text-sm font-mono" placeholder="+10" value={newDelta} onChange={e => setNewDelta(e.target.value)} />
|
||
</div>
|
||
<div className="space-y-1 flex-1 min-w-36">
|
||
<label className="text-[10px] uppercase tracking-widest text-g-faint">Notes</label>
|
||
<input className="g-input h-8 text-sm" placeholder="GPU keyword" value={newNotes} onChange={e => setNewNotes(e.target.value)} />
|
||
</div>
|
||
<button
|
||
type="submit"
|
||
disabled={!newSignal || !newDelta || createMut.isPending}
|
||
className="g-btn-primary h-8 text-xs disabled:opacity-40"
|
||
>
|
||
+ Add rule
|
||
</button>
|
||
{error && <span className="text-xs text-g-red w-full">{error}</span>}
|
||
</form>
|
||
|
||
{isLoading && <p className="text-xs text-g-faint">Loading…</p>}
|
||
|
||
{/* Rules tables */}
|
||
{positives.length > 0 && (
|
||
<div className="g-card overflow-hidden">
|
||
<div className="px-4 py-2.5 border-b border-g-border/50 flex items-center gap-2">
|
||
<span className="w-1.5 h-1.5 rounded-full bg-g-green" />
|
||
<span className="text-xs font-medium text-g-green uppercase tracking-wider">Boosts</span>
|
||
</div>
|
||
<table className="g-table">
|
||
<thead><tr><th>Signal</th><th>Delta</th><th>Notes</th><th className="w-10"></th></tr></thead>
|
||
<tbody>
|
||
{positives.map(r => (
|
||
<RuleRow key={r.id} rule={r} editId={editId} editSignal={editSignal} editDelta={editDelta}
|
||
setEditId={setEditId} setEditSignal={setEditSignal} setEditDelta={setEditDelta}
|
||
onSave={() => updateMut.mutate()} onDelete={() => deleteMut.mutate(r.id)} />
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{negatives.length > 0 && (
|
||
<div className="g-card overflow-hidden">
|
||
<div className="px-4 py-2.5 border-b border-g-border/50 flex items-center gap-2">
|
||
<span className="w-1.5 h-1.5 rounded-full bg-g-red" />
|
||
<span className="text-xs font-medium text-g-red uppercase tracking-wider">Penalties</span>
|
||
</div>
|
||
<table className="g-table">
|
||
<thead><tr><th>Signal</th><th>Delta</th><th>Notes</th><th className="w-10"></th></tr></thead>
|
||
<tbody>
|
||
{negatives.map(r => (
|
||
<RuleRow key={r.id} rule={r} editId={editId} editSignal={editSignal} editDelta={editDelta}
|
||
setEditId={setEditId} setEditSignal={setEditSignal} setEditDelta={setEditDelta}
|
||
onSave={() => updateMut.mutate()} onDelete={() => deleteMut.mutate(r.id)} />
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function RuleRow({ rule, editId, editSignal, editDelta, setEditId, setEditSignal, setEditDelta, onSave, onDelete }: {
|
||
rule: ScoringRule; editId: number | null
|
||
editSignal: string; editDelta: string
|
||
setEditId: (id: number | null) => void; setEditSignal: (s: string) => void; setEditDelta: (s: string) => void
|
||
onSave: () => void; onDelete: () => void
|
||
}) {
|
||
const isEditing = editId === rule.id
|
||
if (isEditing) return (
|
||
<tr>
|
||
<td><input className="g-input h-7 text-sm w-28" value={editSignal} onChange={e => setEditSignal(e.target.value)} /></td>
|
||
<td><input type="number" className="g-input h-7 text-sm font-mono w-16" value={editDelta} onChange={e => setEditDelta(e.target.value)} /></td>
|
||
<td className="text-xs text-g-faint">{rule.notes || '—'}</td>
|
||
<td className="flex gap-2 items-center">
|
||
<button onClick={onSave} className="g-btn text-xs h-7">Save</button>
|
||
<button onClick={() => setEditId(null)} className="text-g-faint hover:text-g-text text-xs">×</button>
|
||
</td>
|
||
</tr>
|
||
)
|
||
return (
|
||
<tr className="group">
|
||
<td>
|
||
<button className="text-sm text-g-text hover:text-g-green transition-colors font-medium"
|
||
onClick={() => { setEditId(rule.id); setEditSignal(rule.signal); setEditDelta(String(rule.delta)) }}>
|
||
{rule.signal}
|
||
</button>
|
||
</td>
|
||
<td>
|
||
<span className={cn('font-mono text-sm font-bold', rule.delta > 0 ? 'text-g-green' : 'text-g-red')}>
|
||
{rule.delta > 0 ? '+' : ''}{rule.delta}
|
||
</span>
|
||
</td>
|
||
<td className="text-xs text-g-faint">{rule.notes || '—'}</td>
|
||
<td>
|
||
<button onClick={onDelete}
|
||
className="text-g-faint hover:text-g-red transition-colors text-xs opacity-0 group-hover:opacity-100">✕</button>
|
||
</td>
|
||
</tr>
|
||
)
|
||
}
|