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:
2026-03-20 15:01:33 +01:00
commit 330ba7a93d
35 changed files with 5038 additions and 0 deletions
+159
View File
@@ -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 gjennomgang.</p>
</div>
) : (
<>
<div className="alert alert-info" style={{ marginBottom: '1rem' }}>
{queue.length} passering(er) venter 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)}
/>
))}
</>
)}
</>
)
}