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,108 @@
|
||||
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' })
|
||||
}
|
||||
|
||||
export default function PassagesPage() {
|
||||
const [passages, setPassages] = useState([])
|
||||
const [filter, setFilter] = useState({ station: '', needs_review: '' })
|
||||
|
||||
const load = async () => {
|
||||
const params = {}
|
||||
if (filter.station) params.station = filter.station
|
||||
if (filter.needs_review !== '') params.needs_review = filter.needs_review === 'true'
|
||||
const data = await api.getPassages(params)
|
||||
setPassages(data)
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [filter])
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('Slett passering?')) return
|
||||
await api.deletePassage(id)
|
||||
await load()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Passeringer</h1>
|
||||
|
||||
<div className="card">
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label>Stasjon</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="f.eks. cp1"
|
||||
style={{ width: 150 }}
|
||||
value={filter.station}
|
||||
onChange={e => setFilter(f => ({ ...f, station: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label>Status</label>
|
||||
<select
|
||||
style={{ width: 180 }}
|
||||
value={filter.needs_review}
|
||||
onChange={e => setFilter(f => ({ ...f, needs_review: e.target.value }))}
|
||||
>
|
||||
<option value="">Alle</option>
|
||||
<option value="false">Bekreftet</option>
|
||||
<option value="true">Trenger gjennomgang</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>Passeringer ({passages.length})</h2>
|
||||
{passages.length === 0 ? (
|
||||
<p className="empty-state">Ingen passeringer funnet.</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Startnr</th>
|
||||
<th>Navn</th>
|
||||
<th>Stasjon</th>
|
||||
<th>Tidspunkt (UTC)</th>
|
||||
<th>Konfidens</th>
|
||||
<th>Metode</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{passages.map(p => (
|
||||
<tr key={p.passage_id}>
|
||||
<td><strong>{p.bib_number || '?'}</strong></td>
|
||||
<td>{p.name || '—'}</td>
|
||||
<td>{p.station}</td>
|
||||
<td style={{ whiteSpace: 'nowrap' }}>{fmtTs(p.timestamp_utc)}</td>
|
||||
<td>{p.confidence != null ? (p.confidence * 100).toFixed(0) + '%' : '—'}</td>
|
||||
<td><code style={{ fontSize: '0.75rem' }}>{p.id_method}</code></td>
|
||||
<td>
|
||||
{p.needs_review
|
||||
? <span className="badge badge-warning">Gjennomgang</span>
|
||||
: <span className="badge badge-success">OK</span>}
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn-danger btn-sm"
|
||||
onClick={() => handleDelete(p.passage_id)}
|
||||
>
|
||||
Slett
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api.js'
|
||||
|
||||
function fmtTs(ts) {
|
||||
if (!ts) return '—'
|
||||
return new Date(ts).toLocaleTimeString('no-NO', { timeZone: 'UTC', hour12: false })
|
||||
}
|
||||
|
||||
export default function ResultsPage() {
|
||||
const [results, setResults] = useState([])
|
||||
const [expanded, setExpanded] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
api.getResults().then(data => {
|
||||
setResults(data)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleExpand = (id) => setExpanded(e => e === id ? null : id)
|
||||
|
||||
if (loading) return <p>Laster...</p>
|
||||
|
||||
const finished = results.filter(r => !r.dns && !r.dnf)
|
||||
const dnf = results.filter(r => r.dnf)
|
||||
const dns = results.filter(r => r.dns)
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Resultater</h1>
|
||||
|
||||
{results.length === 0 ? (
|
||||
<div className="card">
|
||||
<p className="empty-state">Ingen resultater tilgjengelig ennå.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '2.5rem' }}>Pl.</th>
|
||||
<th>Startnr</th>
|
||||
<th>Navn</th>
|
||||
<th>Klubb</th>
|
||||
<th>Start</th>
|
||||
<th>Mål</th>
|
||||
<th>Totaltid</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{finished.map((r, i) => (
|
||||
<>
|
||||
<tr key={r.profile_id} onClick={() => toggleExpand(r.profile_id)} style={{ cursor: 'pointer' }}>
|
||||
<td className={`rank rank-${i + 1}`}>{i + 1}.</td>
|
||||
<td><strong>{r.bib_number}</strong></td>
|
||||
<td>{r.name}</td>
|
||||
<td>{r.club || '—'}</td>
|
||||
<td>{fmtTs(r.start_time)}</td>
|
||||
<td>{fmtTs(r.finish_time)}</td>
|
||||
<td><strong>{r.total_formatted}</strong></td>
|
||||
<td style={{ color: '#aaa', fontSize: '0.8rem' }}>
|
||||
{expanded === r.profile_id ? '▲' : '▼'}
|
||||
</td>
|
||||
</tr>
|
||||
{expanded === r.profile_id && r.splits.length > 0 && (
|
||||
<tr key={r.profile_id + '_splits'}>
|
||||
<td colSpan={8} style={{ padding: '0.5rem 1rem 1rem' }}>
|
||||
<table className="split-table" style={{ width: 'auto', minWidth: 300 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Fra</th>
|
||||
<th>Til</th>
|
||||
<th>Tid</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{r.splits.map((s, j) => (
|
||||
<tr key={j}>
|
||||
<td>{s.from}</td>
|
||||
<td>{s.to}</td>
|
||||
<td>{s.split_formatted || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
|
||||
{dnf.map(r => (
|
||||
<tr key={r.profile_id} style={{ color: '#999' }}>
|
||||
<td className="rank">DNF</td>
|
||||
<td>{r.bib_number}</td>
|
||||
<td>{r.name}</td>
|
||||
<td>{r.club || '—'}</td>
|
||||
<td>{fmtTs(r.start_time)}</td>
|
||||
<td>—</td>
|
||||
<td>—</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{dns.map(r => (
|
||||
<tr key={r.profile_id} style={{ color: '#bbb' }}>
|
||||
<td className="rank">DNS</td>
|
||||
<td>{r.bib_number}</td>
|
||||
<td>{r.name}</td>
|
||||
<td>{r.club || '—'}</td>
|
||||
<td>—</td>
|
||||
<td>—</td>
|
||||
<td>—</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api } from '../api.js'
|
||||
|
||||
export default function StartlistPage() {
|
||||
const [athletes, setAthletes] = useState([])
|
||||
const [status, setStatus] = useState(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const fileRef = useRef()
|
||||
|
||||
const load = async () => {
|
||||
const data = await api.getAthletes()
|
||||
setAthletes(data)
|
||||
}
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
const handleImport = async (e) => {
|
||||
e.preventDefault()
|
||||
const file = fileRef.current.files[0]
|
||||
if (!file) return
|
||||
setLoading(true)
|
||||
setStatus(null)
|
||||
try {
|
||||
const res = await api.importCsv(file)
|
||||
setStatus({
|
||||
type: 'success',
|
||||
msg: `Importert ${res.imported} utøver(e).` +
|
||||
(res.errors.length ? ` Feil: ${res.errors.join(', ')}` : ''),
|
||||
})
|
||||
await load()
|
||||
} catch (err) {
|
||||
setStatus({ type: 'error', msg: err.message })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
fileRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!confirm('Slett utøver?')) return
|
||||
await api.deleteAthlete(id)
|
||||
await load()
|
||||
}
|
||||
|
||||
const handleClear = async () => {
|
||||
if (!confirm('Slett hele startlisten?')) return
|
||||
await api.clearAthletes()
|
||||
setAthletes([])
|
||||
setStatus({ type: 'success', msg: 'Startliste slettet.' })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Startliste</h1>
|
||||
|
||||
<div className="card">
|
||||
<h2>Last opp CSV</h2>
|
||||
<p style={{ fontSize: '0.85rem', color: '#666', marginBottom: '0.75rem' }}>
|
||||
Påkrevde kolonner: <code>bib_number</code> (eller <code>bib</code>), <code>name</code>.
|
||||
Valgfritt: <code>club</code>.
|
||||
</p>
|
||||
<form onSubmit={handleImport} className="form-row">
|
||||
<div className="form-group" style={{ flex: 1 }}>
|
||||
<label>CSV-fil</label>
|
||||
<input type="file" accept=".csv" ref={fileRef} required />
|
||||
</div>
|
||||
<button type="submit" className="btn-primary" disabled={loading}>
|
||||
{loading ? 'Laster...' : 'Importer'}
|
||||
</button>
|
||||
</form>
|
||||
{status && (
|
||||
<div className={`alert alert-${status.type === 'error' ? 'error' : 'success'}`}>
|
||||
{status.msg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.75rem' }}>
|
||||
<h2>Utøvere ({athletes.length})</h2>
|
||||
{athletes.length > 0 && (
|
||||
<button className="btn-danger btn-sm" onClick={handleClear}>Slett alle</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{athletes.length === 0 ? (
|
||||
<p className="empty-state">Ingen utøvere registrert. Last opp en startliste.</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Startnr</th>
|
||||
<th>Navn</th>
|
||||
<th>Klubb</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{athletes.map((a) => (
|
||||
<tr key={a.profile_id}>
|
||||
<td><strong>{a.bib_number}</strong></td>
|
||||
<td>{a.name}</td>
|
||||
<td>{a.club || '—'}</td>
|
||||
<td>
|
||||
<button
|
||||
className="btn-danger btn-sm"
|
||||
onClick={() => handleDelete(a.profile_id)}
|
||||
>
|
||||
Slett
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user