feat(appearance): four QSL categories, each scoped to chosen channels

Replaces the four fixed rules with the model Logger32 uses and the operator
asked for: Worked / Confirmed / QSL sent / To be sent, and inside each, which
channels count — paper QSL, LoTW, eQSL, QRZ.com. No ticks means every channel,
because a rule the operator has not narrowed must not quietly become a rule
about nothing.

"To be sent" is FIRST in the order, and that is the substantive decision here.
A contact can be confirmed on LoTW and still owe a paper card; the colour an
operator scans for is the one meaning "something is still owed". Placed after
"confirmed" that row goes green and the card never gets printed.

"Worked" is the catch-all — nothing sent, nothing requested, nothing back — and
is off by default, since turning it on paints every remaining row.

R and Q both count as owed: ADIF says requested and queued, and both mean the
card has not gone out.
This commit is contained in:
2026-08-13 09:46:52 +02:00
parent 6aff322be6
commit 1605e30f60
7 changed files with 184 additions and 53 deletions
+31 -5
View File
@@ -17,10 +17,17 @@ const PALETTE = [
// The rule ids the backend orders; the labels live here so a translation never
// travels through the settings row.
const LABELS: Record<string, string> = {
confirmed_lotw: 'appr.confirmedLotw',
confirmed_paper: 'appr.confirmedPaper',
sent_waiting: 'appr.sentWaiting',
to_send: 'appr.toSend',
to_send: 'appr.ruleToSend',
confirmed: 'appr.ruleConfirmed',
sent: 'appr.ruleSent',
worked: 'appr.ruleWorked',
};
// The channels a rule can be scoped to. "worked" is the catch-all — it means
// nothing on ANY channel — so narrowing it would say nothing.
const CHANNELS = ['qsl', 'lotw', 'eqsl', 'qrz'] as const;
const CHANNEL_LABELS: Record<string, string> = {
qsl: 'appr.chQsl', lotw: 'LoTW', eqsl: 'eQSL', qrz: 'QRZ.com',
};
export function AppearancePanel() {
@@ -37,7 +44,7 @@ export function AppearancePanel() {
setCfg(next);
SaveRowColors(next as any).catch(() => {});
};
const patchRule = (id: string, patch: Partial<{ color: string; enabled: boolean }>) => {
const patchRule = (id: string, patch: Partial<{ color: string; enabled: boolean; channels: string[] }>) => {
if (!cfg) return;
save({ ...cfg, rules: cfg.rules.map((r) => (r.id === id ? { ...r, ...patch } : r)) });
};
@@ -104,6 +111,25 @@ export function AppearancePanel() {
<span className="font-mono text-xs text-muted-foreground">{i + 1}.</span>
<span className="font-medium">{t(LABELS[r.id] ?? r.id)}</span>
</label>
{/* Which channels this category looks at. None ticked = all of
them, which is what an unnarrowed rule should mean. */}
{r.enabled && r.id !== 'worked' && (
<div className="flex items-center gap-3 flex-wrap pl-6 text-xs">
{CHANNELS.map((c) => {
const on = !r.channels?.length || r.channels.includes(c);
return (
<label key={c} className="flex items-center gap-1.5 cursor-pointer">
<Checkbox checked={on} onCheckedChange={(v) => {
const cur = r.channels?.length ? r.channels : [...CHANNELS];
const next = v ? [...new Set([...cur, c])] : cur.filter((x) => x !== c);
patchRule(r.id, { channels: next });
}} />
{CHANNEL_LABELS[c]?.startsWith('appr.') ? t(CHANNEL_LABELS[c]) : CHANNEL_LABELS[c]}
</label>
);
})}
</div>
)}
{r.enabled && (
<div className="flex items-center gap-1.5 flex-wrap pl-6">
{PALETTE.map((c) => (