fix: bug when deleting an award reference which was not saved.

This commit is contained in:
2026-07-07 21:08:26 +02:00
parent daf38554a5
commit 128364260b
4 changed files with 45 additions and 8 deletions
+8 -4
View File
@@ -1007,11 +1007,15 @@ export default function App() {
// list once, then compute each shown QSO's reference per award and attach it
// to the rows (the grids render one hideable column per award).
const [awardCols, setAwardCols] = useState<{ code: string; name: string }[]>([]);
// Bumped whenever award definitions are saved so the grid columns AND the
// per-QSO refs re-fetch — the ref/name display choice is computed live, so
// changing it updates ALL contacts (old included) with no restart.
const [awardsVersion, setAwardsVersion] = useState(0);
useEffect(() => {
GetAwardDefs().then((defs: any[]) =>
setAwardCols(((defs ?? []) as any[]).map((d) => ({ code: d.code, name: d.name })).sort((a, b) => a.code.localeCompare(b.code))),
).catch(() => {});
}, []);
}, [awardsVersion]);
const [qsoAwardRefs, setQsoAwardRefs] = useState<Record<string, Record<string, string>>>({});
useEffect(() => {
const ids = (qsos as any[]).map((q) => q.id).filter(Boolean);
@@ -1019,7 +1023,7 @@ export default function App() {
let alive = true;
AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setQsoAwardRefs(m ?? {}); }).catch(() => {});
return () => { alive = false; };
}, [qsos, awardCols.length]);
}, [qsos, awardCols.length, awardsVersion]);
const qsosWithAwards = useMemo(
() => (qsos as any[]).map((q) => ({ ...q, award_refs: qsoAwardRefs[String(q.id)] })),
[qsos, qsoAwardRefs],
@@ -1031,7 +1035,7 @@ export default function App() {
let alive = true;
AwardRefsForQSOs(ids as any).then((m: any) => { if (alive) setWbAwardRefs(m ?? {}); }).catch(() => {});
return () => { alive = false; };
}, [wb, awardCols.length]);
}, [wb, awardCols.length, awardsVersion]);
const wbWithAwards = useMemo(
() => (wb ? { ...wb, entries: ((wb.entries ?? []) as any[]).map((e) => ({ ...e, award_refs: wbAwardRefs[String(e.id)] })) } : null),
[wb, wbAwardRefs],
@@ -4169,7 +4173,7 @@ export default function App() {
</TabsContent>
<TabsContent value="awards" className="flex-1 min-h-0 p-0">
<AwardsPanel onEditQSO={openEdit} />
<AwardsPanel onEditQSO={openEdit} onAwardsChanged={() => setAwardsVersion((v) => v + 1)} />
</TabsContent>
{contestTabEnabled && (
+2 -2
View File
@@ -61,7 +61,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 } = {}) {
export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: number) => void; onAwardsChanged?: () => void } = {}) {
const { t } = useI18n();
const [awardList, setAwardList] = useState<AwardListItem[]>([]);
// Computed results are cached per award code — each award is scanned only the
@@ -220,7 +220,7 @@ export function AwardsPanel({ onEditQSO }: { onEditQSO?: (id: number) => void }
{t('awp.rescan')}
</Button>
</div>
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={() => { setByCode({}); loadList(); }} />
<AwardEditor open={editing} onClose={() => setEditing(false)} onSaved={() => { setByCode({}); loadList(); onAwardsChanged?.(); }} />
{/* Quick selector — scales when there are many awards. */}
{awardList.length > 0 && (
<div className="px-2 py-2 border-b border-border/40">
+5 -1
View File
@@ -187,6 +187,9 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
// WPX, …) are derived from the QSO by the backend and shown read-only.
const awardFieldRef = useRef<Record<string, string>>({});
const [awardRefs, setAwardRefs] = useState('');
// The refs present when the editor opened, so on save we can tell which ones
// were REMOVED and strip their in-field tokens (see applyAwardRefs).
const seedAwardRefsRef = useRef('');
const [computedRefs, setComputedRefs] = useState<Array<{ code: string; ref: string; name?: string }>>([]);
// Load award definitions once, then seed the editable manual refs from the QSO.
@@ -207,6 +210,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
.filter((r: any) => r.pickable)
.map((r: any) => `${String(r.code).toUpperCase()}@${String(r.ref).toUpperCase()}`)
.join(';');
seedAwardRefsRef.current = seed;
setAwardRefs(seed);
} catch { /* leave manual refs empty on failure */ }
})
@@ -327,7 +331,7 @@ export function QSOEditModal({ qso, onSave, onDelete, onClose, countries = [], b
// the dedicated columns, then route the picked refs back onto the payload
// (POTA/SOTA/IOTA → columns, WWFF/custom → extras).
out.iota = ''; out.sota_ref = ''; out.pota_ref = '';
applyAwardRefs(out, awardRefs, awardFieldRef.current);
applyAwardRefs(out, awardRefs, awardFieldRef.current, seedAwardRefsRef.current);
onSave(out);
}
+30 -1
View File
@@ -33,6 +33,19 @@ function appendTokens(existing: string | undefined, refs: string): string {
return out;
}
// removeTokens strips space/word-delimited tokens (a "A,B" ref string) from a
// text field — the inverse of appendTokens. Used when a picked reference is
// REMOVED so an in-field award (DDFM finds "D72" in the note) stops matching it;
// otherwise the ref is re-derived from the field and reappears on reopen.
function removeTokens(existing: string | undefined, refs: string): string {
let out = existing ?? '';
for (const tok of refs.split(',').map((s) => s.trim()).filter(Boolean)) {
const re = new RegExp(`\\b${tok.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'ig');
out = out.replace(re, ' ');
}
return out.replace(/\s{2,}/g, ' ').trim();
}
// MANUAL_REFS_KEY mirrors award.ManualRefsKey (Go): the ADIF extras key holding
// the operator's per-QSO award-reference assignments as "CODE@REF;CODE@REF".
// The award engine honours these regardless of how the award matches, so a
@@ -47,8 +60,24 @@ const MANUAL_REFS_KEY = 'APP_OPSLOG_AWARDREFS';
// data lives in its conventional place and exports correctly. Awards that match
// a free-text field by description/pattern (address/qth/name/custom) rely solely
// on the override — we don't pollute the text field with a code.
export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<string, string>) {
export function applyAwardRefs(payload: any, awardRefs: string, fieldOf: Record<string, string>, prevAwardRefs?: string) {
const byCode = parseAwardRefs(awardRefs);
// References the operator REMOVED since the editor opened: for in-field awards
// (note/comment) strip the token from the field too, so a deleted DDFM/etc.
// ref doesn't get re-derived from the leftover token and reappear on reopen.
if (prevAwardRefs != null) {
const prev = parseAwardRefs(prevAwardRefs);
for (const [code, prevRef] of Object.entries(prev)) {
const field = fieldOf[code] || code.toLowerCase();
if (field !== 'note' && field !== 'notes' && field !== 'comment') continue;
const now = new Set((byCode[code] ?? '').split(',').map((s) => s.trim().toUpperCase()).filter(Boolean));
const removed = prevRef.split(',').map((s) => s.trim()).filter(Boolean).filter((r) => !now.has(r.toUpperCase()));
if (removed.length === 0) continue;
const rm = removed.join(',');
if (field === 'comment') payload.comment = removeTokens(payload.comment, rm);
else payload.notes = removeTokens(payload.notes, rm);
}
}
const extras: Record<string, string> = { ...(payload.extras ?? {}) };
const overrides: string[] = [];
for (const [code, ref] of Object.entries(byCode)) {