feat: Complete translation in French

This commit is contained in:
2026-07-05 10:44:40 +02:00
parent 3a6afc28ac
commit 2d742be7df
32 changed files with 1841 additions and 1421 deletions
+61 -58
View File
@@ -7,6 +7,7 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui/select';
import { cn } from '@/lib/utils';
import { AwardEditor } from '@/components/AwardEditor';
import { useI18n } from '@/lib/i18n';
type BandCount = { band: string; worked: number; confirmed: number };
type AwardRef = {
@@ -61,6 +62,7 @@ function ProgressBar({ worked, confirmed, total }: { worked: number; confirmed:
type AwardListItem = { code: string; name: string; valid?: boolean; bands?: string[]; emission?: string[] };
export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void } = {}) {
const { t } = useI18n();
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
// Computed results are cached per award code — each award is scanned only the
// first time it's selected (or when explicitly rescanned).
@@ -207,15 +209,15 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
<div className="w-72 shrink-0 border-r border-border flex flex-col min-h-0">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60">
<AwardIcon className="size-4 text-primary" />
<span className="text-sm font-semibold">Awards</span>
<span className="text-sm font-semibold">{t('awp.awards')}</span>
<div className="flex-1" />
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => setEditing(true)} title="Edit awards">
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => setEditing(true)} title={t('awp.editAwards')}>
<Pencil className="size-3.5" />
</Button>
<Button variant="outline" size="sm" className="h-7 px-2" onClick={rescan} disabled={loading || !selected}
title="Re-pull the logbook and recompute (picks up new LoTW/QRZ confirmations)">
title={t('awp.rescanTitle')}>
{loading ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
Rescan
{t('awp.rescan')}
</Button>
</div>
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={() => { setByCode({}); loadList(); }} />
@@ -223,7 +225,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
{awardList.length > 0 && (
<div className="px-2 py-2 border-b border-border/40">
<Select value={selected} onValueChange={(code) => { setRefSearch(''); compute(code); }}>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder="Select an award…" /></SelectTrigger>
<SelectTrigger className="h-8 text-xs"><SelectValue placeholder={t('awp.selectAward')} /></SelectTrigger>
<SelectContent className="max-h-80">
{awardList.map((a) => (
<SelectItem key={a.code} value={a.code} className="text-xs">
@@ -255,7 +257,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
<span className="text-[11px] font-mono text-muted-foreground">
<span className="text-emerald-600">{r.confirmed}</span>
/<span className="text-foreground">{r.worked}</span>
{r.total > 0 && <span className="text-muted-foreground/70"> of {r.total}</span>}
{r.total > 0 && <span className="text-muted-foreground/70"> {t('awp.of')} {r.total}</span>}
</span>
) : (
<span className="text-[11px] font-mono text-muted-foreground/50">{selected === a.code && loading ? '…' : '—'}</span>
@@ -273,7 +275,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
<div className="flex-1 flex flex-col min-h-0">
{!current ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
{loading ? 'Computing' : 'No data'}
{loading ? t('awp.computing') : t('awp.noData')}
</div>
) : (
<>
@@ -283,11 +285,11 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
<span className="text-sm text-muted-foreground">{current.name}</span>
</div>
<div className="mt-1 flex items-center gap-4 text-sm">
<span><span className="font-bold text-foreground">{current.worked}</span> <span className="text-muted-foreground">worked</span></span>
<span><span className="font-bold text-emerald-600">{current.confirmed}</span> <span className="text-muted-foreground">confirmed</span></span>
<span><span className="font-bold text-sky-600">{current.validated}</span> <span className="text-muted-foreground">validated</span></span>
<span><span className="font-bold text-foreground">{current.worked}</span> <span className="text-muted-foreground">{t('awp.worked')}</span></span>
<span><span className="font-bold text-emerald-600">{current.confirmed}</span> <span className="text-muted-foreground">{t('awp.confirmed')}</span></span>
<span><span className="font-bold text-sky-600">{current.validated}</span> <span className="text-muted-foreground">{t('awp.validated')}</span></span>
{current.total > 0 && (
<span className="text-muted-foreground">of {current.total} · {pct(current.confirmed, current.total)}% confirmed</span>
<span className="text-muted-foreground">{t('awp.ofConfirmed', { total: current.total, pct: pct(current.confirmed, current.total) })}</span>
)}
</div>
<div className="mt-2 max-w-md"><ProgressBar worked={current.worked} confirmed={current.confirmed} total={current.total} /></div>
@@ -296,7 +298,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
{/* Band breakdown */}
{(current.bands ?? []).length > 0 && (
<div className="px-4 py-2 border-b border-border/60">
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-1.5">By band (confirmed / worked)</div>
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-1.5">{t('awp.byBand')}</div>
<div className="flex flex-wrap gap-1.5">
{(current.bands ?? []).map((b) => (
<div key={b.band} className="rounded-md border border-border bg-card px-2.5 py-1 text-sm">
@@ -313,23 +315,23 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
<div className="flex items-center gap-2 px-4 py-2 flex-wrap">
<div className="relative">
<Search className="size-3.5 absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input className="h-8 w-56 pl-7 text-sm" placeholder="Filter references…" value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
<Input className="h-8 w-56 pl-7 text-sm" placeholder={t('awp.filterReferences')} value={refSearch} onChange={(e) => setRefSearch(e.target.value)} />
</div>
<div className="flex items-center rounded-md border border-border overflow-hidden text-sm">
{([['all', 'All'], ['worked', 'Wkd'], ['notworked', 'Not wkd'], ['worked_notconf', 'Wkd not cfmd']] as const).map(([k, label]) => (
{([['all', t('awp.filterAll')], ['worked', t('awp.filterWkd')], ['notworked', t('awp.filterNotWkd')], ['worked_notconf', t('awp.filterWkdNotCfmd')]] as const).map(([k, label]) => (
<button key={k} onClick={() => setRefFilter(k)}
className={cn('px-2 py-1', refFilter === k ? 'bg-accent font-medium' : 'hover:bg-accent/50 text-muted-foreground')}>
{label}
</button>
))}
</div>
<span className="text-xs text-muted-foreground">{filteredRefs.length} ref{filteredRefs.length > 1 ? 's' : ''}</span>
<span className="text-xs text-muted-foreground">{filteredRefs.length} {t('awp.refs')}</span>
<button
onClick={() => setShowMissing(true)}
className="flex items-center gap-1 text-xs text-amber-700 hover:text-amber-900 border border-amber-300 bg-amber-50 rounded px-2 py-1"
title="Contacts in this award's scope (right DXCC/band/mode) but with no reference — they're excluded until you add it"
title={t('awp.missingRefsTitle')}
>
<AlertTriangle className="size-3" /> Missing refs
<AlertTriangle className="size-3" /> {t('awp.missingRefs')}
</button>
<div className="flex-1" />
{/* Legend */}
@@ -339,9 +341,9 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
<span className="inline-flex items-center gap-1"><span className="size-3 rounded-sm bg-emerald-500" />V</span>
</div>
<div className="flex items-center rounded-md border border-border overflow-hidden">
<button className={cn('px-1.5 py-1', view === 'grid' ? 'bg-accent' : 'hover:bg-accent/50')} title="Grid view" onClick={() => setView('grid')}><Grid3x3 className="size-3.5" /></button>
<button className={cn('px-1.5 py-1', view === 'list' ? 'bg-accent' : 'hover:bg-accent/50')} title="List view" onClick={() => setView('list')}><List className="size-3.5" /></button>
<button className={cn('px-1.5 py-1', view === 'stats' ? 'bg-accent' : 'hover:bg-accent/50')} title="Statistics" onClick={() => setView('stats')}><BarChart3 className="size-3.5" /></button>
<button className={cn('px-1.5 py-1', view === 'grid' ? 'bg-accent' : 'hover:bg-accent/50')} title={t('awp.gridView')} onClick={() => setView('grid')}><Grid3x3 className="size-3.5" /></button>
<button className={cn('px-1.5 py-1', view === 'list' ? 'bg-accent' : 'hover:bg-accent/50')} title={t('awp.listView')} onClick={() => setView('list')}><List className="size-3.5" /></button>
<button className={cn('px-1.5 py-1', view === 'stats' ? 'bg-accent' : 'hover:bg-accent/50')} title={t('awp.statistics')} onClick={() => setView('stats')}><BarChart3 className="size-3.5" /></button>
</div>
</div>
@@ -349,15 +351,15 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
{view === 'stats' ? (
<div className="flex-1 overflow-auto px-4 pb-3">
{statsLoading || !stats ? (
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Computing</div>
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> {t('awp.computing')}</div>
) : (
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
<thead className="sticky top-0 z-10">
<tr className="bg-card">
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-3 font-medium border-b border-border">Statistic</th>
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-3 font-medium border-b border-border">{t('awp.statistic')}</th>
{statsBandIdx.map((i) => <th key={stats.bands[i]} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{stats.bands[i]}</th>)}
<th className="py-1.5 px-2 font-medium border-b border-border text-center">Total</th>
<th className="py-1.5 px-2 font-medium border-b border-border text-center">Grand</th>
<th className="py-1.5 px-2 font-medium border-b border-border text-center">{t('awp.total')}</th>
<th className="py-1.5 px-2 font-medium border-b border-border text-center">{t('awp.grand')}</th>
</tr>
</thead>
<tbody>
@@ -384,8 +386,8 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
<table className="text-sm border-separate" style={{ borderSpacing: 0 }}>
<thead className="sticky top-0 z-10">
<tr className="bg-card">
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">Ref</th>
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">Description</th>
<th className="sticky left-0 z-20 bg-card text-left py-1.5 pr-2 font-medium border-b border-border w-24">{t('awp.ref')}</th>
<th className="text-left py-1.5 pr-3 font-medium border-b border-border">{t('awp.description')}</th>
{gridBands.map((b) => (
<th key={b} className="py-1.5 px-1 font-mono font-medium border-b border-border text-center w-11">{b}</th>
))}
@@ -405,7 +407,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
{s === 'none' ? <span className="block w-11 h-7" /> : (
<button
className={cn('block w-11 h-7 text-[11px] font-bold', CELL_STYLE[s], 'hover:brightness-110')}
title={`${r.ref} · ${b} — click to view QSOs`}
title={t('awp.cellTitle', { ref: r.ref, band: b })}
onClick={() => setCell({ ref: r.ref, band: b, name: r.name })}
>{CELL_LABEL[s]}</button>
)}
@@ -422,11 +424,11 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
<table className="w-full text-sm">
<thead className="sticky top-0 bg-card">
<tr className="text-left text-muted-foreground border-b border-border">
<th className="py-1.5 pr-2 font-medium w-24">Ref</th>
<th className="py-1.5 pr-2 font-medium">Name</th>
<th className="py-1.5 pr-2 font-medium w-40">Group</th>
<th className="py-1.5 pr-2 font-medium w-24">Status</th>
<th className="py-1.5 font-medium">Bands</th>
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.ref')}</th>
<th className="py-1.5 pr-2 font-medium">{t('awp.name')}</th>
<th className="py-1.5 pr-2 font-medium w-40">{t('awp.groupCol')}</th>
<th className="py-1.5 pr-2 font-medium w-24">{t('awp.status')}</th>
<th className="py-1.5 font-medium">{t('awp.bands')}</th>
</tr>
</thead>
<tbody>
@@ -436,10 +438,10 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[340px]">{r.name}</td>
<td className="py-1 pr-2 text-muted-foreground truncate max-w-[200px]">{r.group}</td>
<td className="py-1 pr-2">
{!r.worked ? <span className="text-muted-foreground/70"> missing</span>
: r.validated ? <span className="text-emerald-600">validated</span>
: r.confirmed ? <span className="text-amber-600">confirmed</span>
: <span className="text-stone-500">worked</span>}
{!r.worked ? <span className="text-muted-foreground/70">{t('awp.missing')}</span>
: r.validated ? <span className="text-emerald-600">{t('awp.validated')}</span>
: r.confirmed ? <span className="text-amber-600">{t('awp.confirmed')}</span>
: <span className="text-stone-500">{t('awp.worked')}</span>}
</td>
<td className="py-1 font-mono text-muted-foreground">
{r.bands.map((b) => (
@@ -472,6 +474,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
type MissingSortKey = 'qso_date' | 'callsign' | 'band' | 'mode' | 'country' | 'qth';
function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; name: string; onClose: () => void; onEditQSO?: (id: number) => void }) {
const { t } = useI18n();
const [qsos, setQsos] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [sel, setSel] = useState<Set<number>>(new Set());
@@ -534,7 +537,7 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
const done = new Set(assignedIds);
setQsos((list) => list.filter((q) => !done.has(q.id as number)));
setSel(new Set());
setMsg(`Assigned ${code}@${assignRef} to ${n} contact${n > 1 ? 's' : ''}.`);
setMsg(t('awp.assignedMsg', { code, ref: assignRef, n }));
} catch (e: any) {
setMsg(String(e?.message ?? e));
} finally { setBusy(false); }
@@ -556,26 +559,25 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
<div className="bg-card border border-border rounded-lg shadow-xl w-[860px] max-h-[80vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-2 px-4 py-2.5 border-b">
<AlertTriangle className="size-4 text-amber-600" />
<span className="font-semibold text-sm">{code} contacts missing a reference</span>
<span className="font-semibold text-sm">{code} {t('awp.contactsMissingRef')}</span>
{name && <span className="text-xs text-muted-foreground truncate">{name}</span>}
<div className="flex-1" />
<button onClick={load} disabled={loading}
className="flex items-center gap-1 text-[11px] border border-border rounded px-2 py-1 hover:bg-accent/50 disabled:opacity-50"
title="Recompute now — contacts you've fixed drop off the list">
{loading ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />} Refresh
title={t('awp.recomputeTitle')}>
{loading ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />} {t('awp.refresh')}
</button>
<button onClick={onClose} className="text-muted-foreground hover:text-foreground"><X className="size-4" /></button>
</div>
<div className="px-4 py-2 text-[11px] text-muted-foreground border-b border-border/50">
In this award's scope (DXCC / band / mode / dates) but no reference was found — so they don't count yet.
Sort by a column, tick the matching contacts, then assign the reference below.{onEditQSO && ' (Or click a row to open the QSO.)'}
{t('awp.missingScopeHelp')}{onEditQSO && ` ${t('awp.orClickRow')}`}
</div>
{/* Bulk-assign toolbar */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-border/50 bg-muted/20">
<span className="text-xs text-muted-foreground">{sel.size} selected </span>
<span className="text-xs text-muted-foreground">{t('awp.selectedArrow', { n: sel.size })}</span>
<Select value={assignRef} onValueChange={setAssignRef}>
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue placeholder="Choose a reference to assign…" /></SelectTrigger>
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue placeholder={t('awp.chooseReference')} /></SelectTrigger>
<SelectContent className="max-h-72">
{refs.map((r) => (
<SelectItem key={r.code} value={r.code}>
@@ -585,29 +587,29 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
</SelectContent>
</Select>
<Button size="sm" disabled={!assignRef || sel.size === 0 || busy} onClick={applyAssign}>
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null} Assign to {sel.size} selected
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null} {t('awp.assignToSelected', { n: sel.size })}
</Button>
{msg && <span className="text-[11px] text-emerald-700">{msg}</span>}
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Scanning</div>
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> {t('awp.scanning')}</div>
) : qsos.length === 0 ? (
<div className="p-4 text-xs text-muted-foreground">
No gaps found. (Missing-reference detection applies to awards scoped to a DXCC entity e.g. DDFM, WAS, RAC, WAJA.)
{t('awp.noGaps')}
</div>
) : (
<table className="w-full text-xs">
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border z-10">
<tr>
<th className="py-1 px-3 w-8"><Checkbox checked={allSelected} onCheckedChange={toggleAll} /></th>
<SortTh k="qso_date" label="Date (UTC)" className="pl-0" />
<SortTh k="callsign" label="Callsign" />
<SortTh k="band" label="Band" />
<SortTh k="mode" label="Mode" />
<SortTh k="country" label="Country" />
<SortTh k="qth" label="QTH / Note" />
<SortTh k="qso_date" label={t('awp.dateUtc')} className="pl-0" />
<SortTh k="callsign" label={t('awp.callsign')} />
<SortTh k="band" label={t('awp.band')} />
<SortTh k="mode" label={t('awp.mode')} />
<SortTh k="country" label={t('awp.country')} />
<SortTh k="qth" label={t('awp.qthNote')} />
</tr>
</thead>
<tbody>
@@ -633,8 +635,8 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
)}
</div>
<div className="px-4 py-2 border-t text-[11px] text-muted-foreground">
<span className="font-semibold text-foreground">{stations}</span> station{stations > 1 ? 's' : ''} ·{' '}
{qsos.length} contact{qsos.length > 1 ? 's' : ''} without a reference
<span className="font-semibold text-foreground">{stations}</span> {t('awp.stations')} ·{' '}
{qsos.length} {t('awp.contactsWithoutRef')}
</div>
</div>
</div>
@@ -643,6 +645,7 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
// CellQSOModal lists the QSOs behind one award-grid cell (reference × band).
function CellQSOModal({ code, cell, onClose }: { code: string; cell: { ref: string; band: string; name?: string }; onClose: () => void }) {
const { t } = useI18n();
const [qsos, setQsos] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
@@ -666,13 +669,13 @@ function CellQSOModal({ code, cell, onClose }: { code: string; cell: { ref: stri
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> Loading</div>
<div className="p-4 text-xs text-muted-foreground flex items-center gap-2"><Loader2 className="size-3.5 animate-spin" /> {t('awp.loading')}</div>
) : qsos.length === 0 ? (
<div className="p-4 text-xs text-muted-foreground">No QSOs.</div>
<div className="p-4 text-xs text-muted-foreground">{t('awp.noQsos')}</div>
) : (
<table className="w-full text-xs">
<thead className="sticky top-0 bg-card text-left text-muted-foreground border-b border-border">
<tr><th className="py-1 px-3 font-medium">Date (UTC)</th><th className="py-1 pr-2 font-medium">Callsign</th><th className="py-1 pr-2 font-medium">Band</th><th className="py-1 pr-2 font-medium">Mode</th><th className="py-1 pr-3 font-medium">QSL</th></tr>
<tr><th className="py-1 px-3 font-medium">{t('awp.dateUtc')}</th><th className="py-1 pr-2 font-medium">{t('awp.callsign')}</th><th className="py-1 pr-2 font-medium">{t('awp.band')}</th><th className="py-1 pr-2 font-medium">{t('awp.mode')}</th><th className="py-1 pr-3 font-medium">QSL</th></tr>
</thead>
<tbody>
{qsos.map((q, i) => (