Initial commit: MVP tidtakingssystem
- Backend: FastAPI, EXIF-parser, EasyOCR, SQLite - Frontend: React admin (startliste, passeringer, gjennomgang, resultater) - Docker: docker-compose med depot/processed/data-volumer Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api.js'
|
||||
|
||||
function fmtTs(ts) {
|
||||
if (!ts) return '—'
|
||||
return new Date(ts).toLocaleString('no-NO', { timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
function ReviewCard({ passage, athletes, onResolved, onDeleted }) {
|
||||
const [bib, setBib] = useState(passage.bib_number || '')
|
||||
const [note, setNote] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const matchedAthlete = athletes.find(a => a.bib_number === bib)
|
||||
|
||||
const handleResolve = async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.resolvePassage(passage.passage_id, {
|
||||
bib_number: bib || null,
|
||||
profile_id: matchedAthlete?.profile_id || null,
|
||||
review_note: note || null,
|
||||
})
|
||||
onResolved()
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm('Slett passering?')) return
|
||||
await api.deletePassage(passage.passage_id)
|
||||
onDeleted()
|
||||
}
|
||||
|
||||
// Bygg bildesti relativt til /api/images/
|
||||
const imgSrc = passage.source_image
|
||||
? `/api/images/${passage.source_image.replace(/^\/processed\//, '')}`
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1.5rem' }}>
|
||||
<div>
|
||||
{imgSrc ? (
|
||||
<img src={imgSrc} alt="Passeringsbilde" className="image-preview" />
|
||||
) : (
|
||||
<div className="empty-state" style={{ border: '1px dashed #ddd', borderRadius: 6 }}>
|
||||
Bilde ikke tilgjengelig
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<table style={{ marginBottom: '1rem' }}>
|
||||
<tbody>
|
||||
<tr><td><strong>Stasjon</strong></td><td>{passage.station}</td></tr>
|
||||
<tr><td><strong>Tidspunkt</strong></td><td>{fmtTs(passage.timestamp_utc)}</td></tr>
|
||||
<tr><td><strong>OCR-resultat</strong></td><td>{passage.bib_number || 'ingen'}</td></tr>
|
||||
<tr><td><strong>Konfidens</strong></td><td>{passage.confidence != null ? (passage.confidence * 100).toFixed(0) + '%' : '—'}</td></tr>
|
||||
<tr><td><strong>Merknad</strong></td><td>{passage.review_note || '—'}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div className="form-group" style={{ marginBottom: '0.75rem' }}>
|
||||
<label>Startnummer</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bib}
|
||||
onChange={e => setBib(e.target.value)}
|
||||
placeholder="Skriv inn riktig startnummer"
|
||||
/>
|
||||
{matchedAthlete && (
|
||||
<span style={{ fontSize: '0.8rem', color: '#2ecc71', marginTop: 2 }}>
|
||||
{matchedAthlete.name} {matchedAthlete.club ? `(${matchedAthlete.club})` : ''}
|
||||
</span>
|
||||
)}
|
||||
{bib && !matchedAthlete && (
|
||||
<span style={{ fontSize: '0.8rem', color: '#e67e22', marginTop: 2 }}>
|
||||
Startnummer ikke i startliste
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="form-group" style={{ marginBottom: '1rem' }}>
|
||||
<label>Notat (valgfritt)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={note}
|
||||
onChange={e => setNote(e.target.value)}
|
||||
placeholder="f.eks. startnummer skjult av arm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
||||
<button
|
||||
className="btn-success"
|
||||
onClick={handleResolve}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Lagrer...' : 'Bekreft'}
|
||||
</button>
|
||||
<button className="btn-danger" onClick={handleDelete}>
|
||||
Slett
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ReviewPage() {
|
||||
const [queue, setQueue] = useState([])
|
||||
const [athletes, setAthletes] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
const [q, a] = await Promise.all([api.getReviewQueue(), api.getAthletes()])
|
||||
setQueue(q)
|
||||
setAthletes(a)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const removeFromQueue = (passageId) => {
|
||||
setQueue(q => q.filter(p => p.passage_id !== passageId))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Manuell gjennomgang</h1>
|
||||
|
||||
{loading ? (
|
||||
<p>Laster...</p>
|
||||
) : queue.length === 0 ? (
|
||||
<div className="card">
|
||||
<p className="empty-state">Ingen passeringer venter på gjennomgang.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="alert alert-info" style={{ marginBottom: '1rem' }}>
|
||||
{queue.length} passering(er) venter på manuell behandling.
|
||||
</div>
|
||||
{queue.map(p => (
|
||||
<ReviewCard
|
||||
key={p.passage_id}
|
||||
passage={p}
|
||||
athletes={athletes}
|
||||
onResolved={() => removeFromQueue(p.passage_id)}
|
||||
onDeleted={() => removeFromQueue(p.passage_id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user