feat(labels): print the labels — queue, address review, PDF, log update
The printing session in three steps. The worklist is the paper queue (ADIF qsl_sent R/Q), grouped by callsign since several QSOs of one station share a card. Each recipient is reviewed before anything prints: routing first — via manager when qsl_via says so, direct when an address is known, bureau otherwise — then the address itself, editable and fetchable from QRZ (the MANAGER's address when routing says via). What is printed is the reviewed text verbatim, not a re-resolution that could differ from what was checked. One PDF per label kind, never one file for all: a roll printer holds one stock at a time, and a file mixing 29 mm addresses with 62 mm QSO labels could not be printed at all. Pages are rasterised by the designer's own renderer at the stock's dpi and carried into the PDF untouched — internal/pdf is a hand-written image-page writer (DeviceGray + flate: monochrome, lossless, no cgo) because that is the entire need. Bureau stations get no address label (no envelope); return labels are printed one per envelope. Finishing offers the log update: QSL_SENT=Y, the chosen date, via B or D per routing — through the same BulkUpdateQSL the paper view uses.
This commit is contained in:
@@ -71,6 +71,7 @@ import { APP_VERSION, APP_AUTHOR } from '@/version';
|
||||
import { QSLManagerPanel } from '@/components/QSLManagerModal';
|
||||
import { QslDesignerModal } from '@/components/qsl/QslDesignerModal';
|
||||
import { LabelDesignerModal } from '@/components/labels/LabelDesignerModal';
|
||||
import { LabelPrintModal } from '@/components/labels/LabelPrintModal';
|
||||
import { SendEQSLModal } from '@/components/qsl/SendEQSLModal';
|
||||
import { AutoEQSL } from '@/components/qsl/AutoEQSL';
|
||||
import { ConfirmDialog } from '@/components/ConfirmDialog';
|
||||
@@ -1269,6 +1270,7 @@ export default function App() {
|
||||
}
|
||||
const [qslDesignerOpen, setQslDesignerOpen] = useState(false);
|
||||
const [labelDesignerOpen, setLabelDesignerOpen] = useState(false);
|
||||
const [labelPrintOpen, setLabelPrintOpen] = useState(false);
|
||||
const [eqslQsoId, setEqslQsoId] = useState<number | null>(null); // QSO being sent as eQSL
|
||||
function closeQslTab() {
|
||||
setQslTabOpen(false);
|
||||
@@ -5001,6 +5003,7 @@ export default function App() {
|
||||
{ type: 'item', label: t('gsm.title'), action: 'tools.grids' },
|
||||
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
|
||||
{ type: 'item', label: t('tools.labelDesigner'), action: 'tools.labeldesigner' },
|
||||
{ type: 'item', label: t('tools.labelPrint'), action: 'tools.labelprint' },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
|
||||
{ type: 'item', label: (dvkEnabled ? '✓ ' : '') + t('tools.dvk'), action: 'tools.dvk' },
|
||||
@@ -5055,6 +5058,7 @@ export default function App() {
|
||||
case 'tools.grids': openGridsTab(); break;
|
||||
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
||||
case 'tools.labeldesigner': setLabelDesignerOpen(true); break;
|
||||
case 'tools.labelprint': setLabelPrintOpen(true); break;
|
||||
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
||||
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
||||
case 'tools.cwdecoder': toggleCwDecoder(); break;
|
||||
@@ -8717,6 +8721,7 @@ export default function App() {
|
||||
/>
|
||||
<QslDesignerModal open={qslDesignerOpen} onClose={() => setQslDesignerOpen(false)} />
|
||||
<LabelDesignerModal open={labelDesignerOpen} onClose={() => setLabelDesignerOpen(false)} />
|
||||
<LabelPrintModal open={labelPrintOpen} onClose={() => setLabelPrintOpen(false)} />
|
||||
<SendEQSLModal
|
||||
open={eqslQsoId !== null}
|
||||
qsoId={eqslQsoId}
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
// Label printing — the paper-QSL labelling session, in three steps:
|
||||
//
|
||||
// 1. PICK the contacts (preloaded with the R/Q paper queue), grouped by
|
||||
// callsign — several QSOs of the same station share one card.
|
||||
// 2. REVIEW each recipient: routing (direct / bureau / via manager), the
|
||||
// address checked and edited by hand, a QRZ fetch to fill it.
|
||||
// 3. PRINT one PDF per label kind — a roll printer holds ONE stock at a
|
||||
// time, so QSO labels, addresses and return labels are separate
|
||||
// files — then mark the contacts sent (date + via) in the log.
|
||||
//
|
||||
// The pages are rasterised by the designer's own renderer at the stock's dpi:
|
||||
// what the designer previewed is what the PDF carries.
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { X, Printer, RefreshCw, ChevronRight, ChevronLeft, Check, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import {
|
||||
LabelPaperQueue, LabelListStocks, LabelListTemplates, LabelGetTemplate,
|
||||
LabelExportPDF, LookupCallsignFresh, BulkUpdateQSL, GetActiveProfile,
|
||||
} from '../../../wailsjs/go/main/App';
|
||||
import type { LabelSample, LabelStock, LabelTemplate } from './labelTypes';
|
||||
import { rasterize } from './labelRender';
|
||||
|
||||
interface Props { open: boolean; onClose: () => void }
|
||||
|
||||
type Routing = 'direct' | 'bureau' | 'via';
|
||||
|
||||
interface Station {
|
||||
call: string;
|
||||
qsos: any[]; // raw QSO rows, newest first
|
||||
checked: boolean;
|
||||
routing: Routing;
|
||||
via: string; // manager callsign when routing = via
|
||||
address: string; // multiline, the text that will be printed — verbatim
|
||||
fetching?: boolean;
|
||||
}
|
||||
|
||||
interface TplInfo { id: number; name: string; kind: string; stock_id: number; is_default: boolean }
|
||||
|
||||
// One label's worth of QSO rows, in the designer's sample shape.
|
||||
function toSample(q: any): LabelSample {
|
||||
const d = new Date(q.qso_date);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return {
|
||||
callsign: q.callsign ?? '',
|
||||
qso_date: `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`,
|
||||
time_on: `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`,
|
||||
band: q.band ?? '', mode: q.mode ?? '',
|
||||
freq: q.freq_hz ? (q.freq_hz / 1e6).toFixed(3) : '',
|
||||
rst_sent: q.rst_sent ?? '', rst_rcvd: q.rst_rcvd ?? '',
|
||||
name: q.name ?? '', qth: q.qth ?? '', country: q.country ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function initialAddress(q: any): string {
|
||||
const lines = [q.name, q.address, q.qth, q.country]
|
||||
.map((x: any) => String(x ?? '').trim()).filter(Boolean);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function LabelPrintModal({ open, onClose }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [step, setStep] = useState<'pick' | 'review' | 'print'>('pick');
|
||||
const [stations, setStations] = useState<Station[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [stocks, setStocks] = useState<LabelStock[]>([]);
|
||||
const [tpls, setTpls] = useState<TplInfo[]>([]);
|
||||
const [doQso, setDoQso] = useState(true);
|
||||
const [doAddr, setDoAddr] = useState(true);
|
||||
const [doReturn, setDoReturn] = useState(false);
|
||||
const [qsoTplId, setQsoTplId] = useState(0);
|
||||
const [addrTplId, setAddrTplId] = useState(0);
|
||||
const [retTplId, setRetTplId] = useState(0);
|
||||
const [myAddress, setMyAddress] = useState('');
|
||||
const [myVars, setMyVars] = useState<Record<string, string>>({});
|
||||
|
||||
// Rasterised pages per kind, built when entering step 3.
|
||||
const [pages, setPages] = useState<{ qso: string[]; addr: string[]; ret: string[] }>({ qso: [], addr: [], ret: [] });
|
||||
const [building, setBuilding] = useState(false);
|
||||
const [exported, setExported] = useState<Record<string, string>>({});
|
||||
const [markDate, setMarkDate] = useState(() => new Date().toISOString().slice(0, 10));
|
||||
const [marked, setMarked] = useState(0);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const rows: any[] = (await LabelPaperQueue()) ?? [];
|
||||
// Group by callsign, newest first inside each group.
|
||||
const by = new Map<string, any[]>();
|
||||
for (const q of rows) {
|
||||
const c = String(q.callsign ?? '').toUpperCase();
|
||||
if (!by.has(c)) by.set(c, []);
|
||||
by.get(c)!.push(q);
|
||||
}
|
||||
setStations([...by.entries()].map(([call, qsos]) => {
|
||||
const via = String(qsos[0]?.qsl_via ?? '').trim();
|
||||
const address = initialAddress(qsos[0]);
|
||||
return {
|
||||
call, qsos, checked: true,
|
||||
routing: via ? 'via' as Routing : (address.split('\n').length >= 3 ? 'direct' as Routing : 'bureau' as Routing),
|
||||
via, address,
|
||||
};
|
||||
}));
|
||||
const st = (await LabelListStocks()) as any as LabelStock[];
|
||||
setStocks(st);
|
||||
const tl = ((await LabelListTemplates()) ?? []) as any as TplInfo[];
|
||||
setTpls(tl);
|
||||
const def = (kind: string) => tl.find((x) => x.kind === kind && x.is_default) ?? tl.find((x) => x.kind === kind);
|
||||
setQsoTplId(def('qso')?.id ?? 0);
|
||||
setAddrTplId(def('address')?.id ?? 0);
|
||||
setRetTplId(def('address')?.id ?? 0);
|
||||
const p: any = await GetActiveProfile().catch(() => null);
|
||||
const mv = {
|
||||
MYCALL: p?.callsign ?? '', MYNAME: p?.op_name ?? p?.operator ?? '',
|
||||
MYSTREET: p?.my_street ?? '', MYZIP: p?.my_postal_code ?? '',
|
||||
MYCITY: p?.my_city ?? '', MYCOUNTRY: p?.my_country ?? '',
|
||||
};
|
||||
setMyVars(mv);
|
||||
setMyAddress([mv.MYNAME && `${mv.MYNAME} · ${mv.MYCALL}` || mv.MYCALL, mv.MYSTREET,
|
||||
[mv.MYZIP, mv.MYCITY].filter(Boolean).join(' '), mv.MYCOUNTRY]
|
||||
.map((x) => String(x ?? '').trim()).filter(Boolean).join('\n'));
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setStep('pick'); setPages({ qso: [], addr: [], ret: [] }); setExported({}); setMarked(0);
|
||||
void load();
|
||||
}, [open, load]);
|
||||
|
||||
const patchStation = (i: number, p: Partial<Station>) =>
|
||||
setStations((l) => l.map((s, j) => (j === i ? { ...s, ...p } : s)));
|
||||
|
||||
async function fetchAddress(i: number) {
|
||||
const s = stations[i];
|
||||
const target = s.routing === 'via' && s.via.trim() ? s.via.trim().toUpperCase() : s.call;
|
||||
patchStation(i, { fetching: true });
|
||||
try {
|
||||
const r: any = await LookupCallsignFresh(target, '');
|
||||
const lines = [
|
||||
r?.name, r?.address,
|
||||
[r?.zip, r?.qth].filter(Boolean).join(' '),
|
||||
r?.country,
|
||||
].map((x: any) => String(x ?? '').trim()).filter(Boolean);
|
||||
if (lines.length) patchStation(i, { address: lines.join('\n'), fetching: false });
|
||||
else { patchStation(i, { fetching: false }); setError(t('lpr.noAddress', { call: target })); }
|
||||
} catch (e: any) {
|
||||
patchStation(i, { fetching: false });
|
||||
setError(String(e?.message ?? e));
|
||||
}
|
||||
}
|
||||
|
||||
const picked = stations.filter((s) => s.checked);
|
||||
const needAddress = picked.filter((s) => s.routing !== 'bureau');
|
||||
|
||||
// ── step 3: build the pages ───────────────────────────────────────────
|
||||
async function buildPages() {
|
||||
setBuilding(true); setError('');
|
||||
try {
|
||||
const getTpl = async (id: number): Promise<{ tpl: LabelTemplate; stock: LabelStock } | null> => {
|
||||
if (!id) return null;
|
||||
const tpl = JSON.parse(await LabelGetTemplate(id)) as LabelTemplate;
|
||||
const stock = stocks.find((x) => x.id === tpl.stock_id) ?? stocks[0];
|
||||
return stock ? { tpl, stock } : null;
|
||||
};
|
||||
const out = { qso: [] as string[], addr: [] as string[], ret: [] as string[] };
|
||||
if (doQso) {
|
||||
const got = await getTpl(qsoTplId);
|
||||
if (!got) throw new Error(t('lpr.noQsoTpl'));
|
||||
const table = got.tpl.elements.find((e) => e.type === 'qso_table');
|
||||
const per = Math.max(1, table?.rows_max ?? 4);
|
||||
for (const s of picked) {
|
||||
const vars = { CALL: s.call, NAME: s.qsos[0]?.name ?? '', QTH: s.qsos[0]?.qth ?? '', COUNTRY: s.qsos[0]?.country ?? '', VIA: s.via, ...myVars };
|
||||
for (let i = 0; i < s.qsos.length; i += per) {
|
||||
out.qso.push(rasterize(got.tpl, got.stock, { vars, qsos: s.qsos.slice(i, i + per).map(toSample) }));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (doAddr) {
|
||||
const got = await getTpl(addrTplId);
|
||||
if (!got) throw new Error(t('lpr.noAddrTpl'));
|
||||
for (const s of needAddress) {
|
||||
// The REVIEWED text wins: the template's address block prints these
|
||||
// lines verbatim — that is what "check the address first" means.
|
||||
const tpl: LabelTemplate = {
|
||||
...got.tpl,
|
||||
elements: got.tpl.elements.map((e) =>
|
||||
e.type === 'addr_block' ? { ...e, lines: s.address.split('\n') } : e),
|
||||
};
|
||||
const vars = { CALL: s.call, VIA: s.via, ...myVars };
|
||||
out.addr.push(rasterize(tpl, got.stock, { vars, qsos: [] }));
|
||||
}
|
||||
}
|
||||
if (doReturn) {
|
||||
const got = await getTpl(retTplId);
|
||||
if (!got) throw new Error(t('lpr.noAddrTpl'));
|
||||
const tpl: LabelTemplate = {
|
||||
...got.tpl,
|
||||
elements: got.tpl.elements.map((e) =>
|
||||
e.type === 'addr_block' ? { ...e, lines: myAddress.split('\n') } : e),
|
||||
};
|
||||
// One per envelope that needs a return slip — the direct/via ones.
|
||||
const n = Math.max(1, needAddress.length);
|
||||
const page = rasterize(tpl, got.stock, { vars: { ...myVars, CALL: myVars.MYCALL }, qsos: [] });
|
||||
for (let i = 0; i < n; i++) out.ret.push(page);
|
||||
}
|
||||
setPages(out);
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
setBuilding(false);
|
||||
}
|
||||
|
||||
async function exportKind(kind: 'qso' | 'addr' | 'ret') {
|
||||
const tplId = kind === 'qso' ? qsoTplId : kind === 'addr' ? addrTplId : retTplId;
|
||||
const info = tpls.find((x) => x.id === tplId);
|
||||
const stock = stocks.find((x) => x.id === info?.stock_id) ?? stocks[0];
|
||||
if (!stock) return;
|
||||
try {
|
||||
const path = await LabelExportPDF(
|
||||
kind === 'qso' ? 'qso-labels' : kind === 'addr' ? 'address-labels' : 'return-labels',
|
||||
stock.w_mm, stock.h_mm, pages[kind]);
|
||||
if (path) setExported((m) => ({ ...m, [kind]: path as string }));
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
async function markSent() {
|
||||
try {
|
||||
const date = markDate.replace(/-/g, '');
|
||||
let n = 0;
|
||||
const groups: Array<[Station[], string]> = [
|
||||
[picked.filter((s) => s.routing === 'bureau'), 'B'],
|
||||
[picked.filter((s) => s.routing !== 'bureau'), 'D'],
|
||||
];
|
||||
for (const [list, via] of groups) {
|
||||
const ids = list.flatMap((s) => s.qsos.map((q) => q.id));
|
||||
if (ids.length === 0) continue;
|
||||
n += (await BulkUpdateQSL(ids, { sent_status: 'Y', sent_date: date, via, rcvd_status: '', rcvd_date: '', rcvd_via: '', notes: '', comment: '' } as any)) as number;
|
||||
}
|
||||
setMarked(n);
|
||||
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||
}
|
||||
|
||||
const kindBlock = (kind: 'qso' | 'addr' | 'ret', title: string) => {
|
||||
const pg = pages[kind];
|
||||
if (pg.length === 0) return null;
|
||||
return (
|
||||
<div className="rounded-lg border border-border p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold">{title}</span>
|
||||
<span className="text-xs text-muted-foreground">{t('lpr.pageCount', { n: pg.length })}</span>
|
||||
<div className="flex-1" />
|
||||
{exported[kind]
|
||||
? <span className="text-xs text-success flex items-center gap-1"><Check className="size-3.5" />{exported[kind]}</span>
|
||||
: <Button size="sm" className="h-7 text-xs" onClick={() => void exportKind(kind)}>
|
||||
<Printer className="size-3.5" /> {t('lpr.savePdf')}
|
||||
</Button>}
|
||||
</div>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{pg.slice(0, 8).map((p, i) => (
|
||||
<img key={i} src={p} className="h-20 border border-border rounded-sm bg-white shrink-0" />
|
||||
))}
|
||||
{pg.length > 8 && <span className="text-xs text-muted-foreground self-center">+{pg.length - 8}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
const sumQsos = picked.reduce((a, s) => a + s.qsos.length, 0);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4">
|
||||
<div className="bg-card border border-border rounded-xl shadow-2xl w-full max-w-5xl h-[90vh] flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-border shrink-0">
|
||||
<Printer className="size-4 text-primary" />
|
||||
<span className="font-semibold text-sm">{t('lpr.title')}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{step === 'pick' ? t('lpr.step1') : step === 'review' ? t('lpr.step2') : t('lpr.step3')}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" className="h-8" onClick={onClose}><X className="size-4" /></Button>
|
||||
</div>
|
||||
{error && <div className="px-4 py-1.5 text-xs text-danger border-b border-border/60">{error}</div>}
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground gap-2">
|
||||
<Loader2 className="size-4 animate-spin" /> …
|
||||
</div>
|
||||
) : step === 'pick' ? (
|
||||
stations.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground text-center pt-16 max-w-md mx-auto leading-relaxed">
|
||||
{t('lpr.emptyQueue')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 pb-1 text-xs text-muted-foreground">
|
||||
<button type="button" className="underline underline-offset-2" onClick={() => setStations((l) => l.map((s) => ({ ...s, checked: true })))}>{t('lpr.all')}</button>
|
||||
<button type="button" className="underline underline-offset-2" onClick={() => setStations((l) => l.map((s) => ({ ...s, checked: false })))}>{t('lpr.none')}</button>
|
||||
<div className="flex-1" />
|
||||
<span>{t('lpr.pickSummary', { s: picked.length, q: sumQsos })}</span>
|
||||
</div>
|
||||
{stations.map((s, i) => (
|
||||
<label key={s.call} className="flex items-center gap-2.5 rounded-md border border-border/60 px-2.5 py-1.5 text-sm cursor-pointer hover:bg-muted/40">
|
||||
<Checkbox checked={s.checked} onCheckedChange={(c) => patchStation(i, { checked: !!c })} />
|
||||
<span className="font-mono font-bold w-28">{s.call}</span>
|
||||
<span className="text-xs text-muted-foreground w-16">{s.qsos.length} QSO{s.qsos.length > 1 ? 's' : ''}</span>
|
||||
<span className="text-xs text-muted-foreground flex-1 truncate">
|
||||
{s.qsos.map((q) => `${q.band ?? ''} ${q.mode ?? ''}`).slice(0, 5).join(' · ')}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate max-w-40">{s.qsos[0]?.country ?? ''}</span>
|
||||
{s.via && <span className="text-[10px] px-1.5 rounded bg-info-muted text-info-muted-foreground">via {s.via}</span>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : step === 'review' ? (
|
||||
<div className="space-y-2">
|
||||
{picked.map((s) => {
|
||||
const i = stations.indexOf(s);
|
||||
return (
|
||||
<div key={s.call} className="rounded-lg border border-border p-2.5 flex gap-3">
|
||||
<div className="w-64 shrink-0 space-y-1.5">
|
||||
<div className="font-mono font-bold">{s.call}
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">{s.qsos.length} QSO{s.qsos.length > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
<Select value={s.routing} onValueChange={(v) => patchStation(i, { routing: v as Routing })}>
|
||||
<SelectTrigger className="h-7 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="direct">{t('lpr.direct')}</SelectItem>
|
||||
<SelectItem value="bureau">{t('lpr.bureau')}</SelectItem>
|
||||
<SelectItem value="via">{t('lpr.viaMgr')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{s.routing === 'via' && (
|
||||
<Input className="h-7 text-xs font-mono uppercase" placeholder={t('lpr.mgrPh')}
|
||||
value={s.via} onChange={(ev) => patchStation(i, { via: ev.target.value })} />
|
||||
)}
|
||||
{s.routing !== 'bureau' && (
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs w-full"
|
||||
disabled={s.fetching}
|
||||
onClick={() => void fetchAddress(i)}>
|
||||
{s.fetching ? <Loader2 className="size-3 animate-spin" /> : <RefreshCw className="size-3" />}
|
||||
{t('lpr.fetchQrz')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{s.routing === 'bureau' ? (
|
||||
<div className="flex-1 text-xs text-muted-foreground self-center">{t('lpr.bureauHint')}</div>
|
||||
) : (
|
||||
<textarea
|
||||
className={cn('flex-1 rounded-md border bg-background px-2 py-1 text-sm font-mono',
|
||||
s.address.trim() ? 'border-input' : 'border-danger')}
|
||||
rows={4}
|
||||
placeholder={t('lpr.addressPh')}
|
||||
value={s.address}
|
||||
onChange={(ev) => patchStation(i, { address: ev.target.value })} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{/* what to print */}
|
||||
<div className="rounded-lg border border-border p-3 space-y-2">
|
||||
<div className="text-sm font-semibold">{t('lpr.whatToPrint')}</div>
|
||||
{([
|
||||
['qso', doQso, setDoQso, qsoTplId, setQsoTplId, 'qso', t('lpr.kQso')],
|
||||
['addr', doAddr, setDoAddr, addrTplId, setAddrTplId, 'address', t('lpr.kAddr')],
|
||||
['ret', doReturn, setDoReturn, retTplId, setRetTplId, 'address', t('lpr.kRet')],
|
||||
] as const).map(([key, on, setOn, tplId, setTplId, kind, label]) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-1.5 text-sm cursor-pointer w-64">
|
||||
<Checkbox checked={on} onCheckedChange={(c) => (setOn as any)(!!c)} /> {label}
|
||||
</label>
|
||||
<Select value={String(tplId || '')} onValueChange={(v) => (setTplId as any)(parseInt(v, 10))}>
|
||||
<SelectTrigger className="h-7 text-xs w-72"><SelectValue placeholder={t('lpr.pickTpl')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{tpls.filter((x) => x.kind === kind).map((x) => (
|
||||
<SelectItem key={x.id} value={String(x.id)}>{x.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
))}
|
||||
{doReturn && (
|
||||
<div className="flex items-start gap-2 pl-6">
|
||||
<span className="text-xs text-muted-foreground pt-1 w-24">{t('lpr.myAddress')}</span>
|
||||
<textarea className="flex-1 max-w-96 rounded-md border border-input bg-background px-2 py-1 text-xs font-mono" rows={4}
|
||||
value={myAddress} onChange={(ev) => setMyAddress(ev.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
<Button size="sm" className="h-8" onClick={() => void buildPages()} disabled={building || (!doQso && !doAddr && !doReturn)}>
|
||||
{building ? <Loader2 className="size-3.5 animate-spin" /> : <RefreshCw className="size-3.5" />}
|
||||
{t('lpr.build')}
|
||||
</Button>
|
||||
</div>
|
||||
{kindBlock('qso', t('lpr.kQso'))}
|
||||
{kindBlock('addr', t('lpr.kAddr'))}
|
||||
{kindBlock('ret', t('lpr.kRet'))}
|
||||
{/* mark as sent */}
|
||||
{(pages.qso.length > 0 || pages.addr.length > 0) && (
|
||||
<div className="rounded-lg border border-border p-3 space-y-2">
|
||||
<div className="text-sm font-semibold">{t('lpr.markTitle')}</div>
|
||||
<div className="text-xs text-muted-foreground">{t('lpr.markHint')}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="date" className="h-8 rounded-md border border-input bg-background px-2 text-xs"
|
||||
value={markDate} onChange={(ev) => setMarkDate(ev.target.value)} />
|
||||
<Button size="sm" className="h-8" onClick={() => void markSent()} disabled={marked > 0}>
|
||||
<Check className="size-3.5" /> {t('lpr.markBtn', { n: sumQsos })}
|
||||
</Button>
|
||||
{marked > 0 && <span className="text-xs text-success">{t('lpr.markDone', { n: marked })}</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* footer nav */}
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 border-t border-border shrink-0">
|
||||
{step !== 'pick' && (
|
||||
<Button variant="outline" size="sm" className="h-8"
|
||||
onClick={() => setStep(step === 'print' ? 'review' : 'pick')}>
|
||||
<ChevronLeft className="size-3.5" /> {t('lpr.back')}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{step === 'pick' && (
|
||||
<Button size="sm" className="h-8" disabled={picked.length === 0} onClick={() => setStep('review')}>
|
||||
{t('lpr.next')} <ChevronRight className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{step === 'review' && (
|
||||
<Button size="sm" className="h-8"
|
||||
disabled={needAddress.some((s) => !s.address.trim())}
|
||||
title={needAddress.some((s) => !s.address.trim()) ? t('lpr.missingAddr') : undefined}
|
||||
onClick={() => setStep('print')}>
|
||||
{t('lpr.next')} <ChevronRight className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -173,3 +173,18 @@ function drawElement(
|
||||
}
|
||||
return { x: e.x_mm, y: e.y_mm, w: 10, h: 4 };
|
||||
}
|
||||
|
||||
// rasterize renders one label at the stock's dpi and returns a PNG data URL —
|
||||
// the pages handed to the PDF exporter. Same renderer as the preview, only the
|
||||
// scale changes, which is the whole guarantee of the feature.
|
||||
export function rasterize(
|
||||
t: LabelTemplate, stock: LabelStock, data: RenderData,
|
||||
): string {
|
||||
const scale = (stock.dpi || 300) / 25.4; // px per mm
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = Math.round(stock.w_mm * scale);
|
||||
cv.height = Math.round(stock.h_mm * scale);
|
||||
const ctx = cv.getContext('2d')!;
|
||||
render(ctx, t, stock, data, scale);
|
||||
return cv.toDataURL('image/png');
|
||||
}
|
||||
|
||||
@@ -132,6 +132,27 @@ const en: Dict = {
|
||||
'wk.tciHint': "Keying goes through the radio's own macro keyer, over the link already open — no WinKeyer and no second serial port. The radio can stop a message but cannot un-type one, so there is no type-ahead correction here.",
|
||||
'gen.miles': 'Distances in miles', 'gen.milesHint': '(instead of kilometres)',
|
||||
'tools.labelDesigner': "Label Designer…",
|
||||
'tools.labelPrint': "Print QSL labels…",
|
||||
'lpr.title': "Print QSL labels",
|
||||
'lpr.step1': "1 · pick the contacts", 'lpr.step2': "2 · check the addresses", 'lpr.step3': "3 · print and record",
|
||||
'lpr.emptyQueue': "No paper QSL is waiting. Mark contacts as Requested or Queued (QSL sent status R/Q) — in the QSL Manager's Paper QSL view or the QSO editor — and they will appear here.",
|
||||
'lpr.all': "all", 'lpr.none': "none", 'lpr.pickSummary': "{s} station(s) · {q} QSO(s)",
|
||||
'lpr.direct': "Direct", 'lpr.bureau': "Bureau", 'lpr.viaMgr': "Via manager", 'lpr.mgrPh': "Manager callsign",
|
||||
'lpr.fetchQrz': "Fetch address (QRZ)", 'lpr.noAddress': "No address found for {call}.",
|
||||
'lpr.bureauHint': "Bureau: no envelope, so no address label — the QSO label is enough.",
|
||||
'lpr.addressPh': "Name\nStreet\nZIP City\nCountry",
|
||||
'lpr.missingAddr': "A direct or via-manager station still has an empty address.",
|
||||
'lpr.whatToPrint': "Labels to print", 'lpr.pickTpl': "Pick a template…",
|
||||
'lpr.kQso': "QSO labels", 'lpr.kAddr': "Address labels", 'lpr.kRet': "My return-address labels",
|
||||
'lpr.myAddress': "My address", 'lpr.build': "Build the labels",
|
||||
'lpr.noQsoTpl': "No QSO label template — create one in the Label Designer first.",
|
||||
'lpr.noAddrTpl': "No address label template — create one in the Label Designer first.",
|
||||
'lpr.pageCount': "{n} label(s) — one PDF page each",
|
||||
'lpr.savePdf': "Save PDF & open",
|
||||
'lpr.markTitle': "Record in the log", 'lpr.markBtn': "Mark {n} QSO(s) sent",
|
||||
'lpr.markHint': "Sets QSL sent = Y with this date; via becomes B for bureau and D for direct or manager.",
|
||||
'lpr.markDone': "{n} QSO(s) updated.",
|
||||
'lpr.back': "Back", 'lpr.next': "Next",
|
||||
'lbl.title': "Label Designer",
|
||||
'lbl.newQso': "QSO label", 'lbl.newAddr': "Address label",
|
||||
'lbl.newQsoName': "QSO label", 'lbl.newAddrName': "Address label",
|
||||
@@ -655,6 +676,27 @@ const fr: Dict = {
|
||||
'wk.tciHint': "La manipulation passe par le keyer à macros de la radio, sur la liaison déjà ouverte — ni WinKeyer ni second port série. La radio sait interrompre un message mais pas en effacer la fin, donc pas de correction en frappe anticipée ici.",
|
||||
'gen.miles': 'Distances en miles', 'gen.milesHint': '(au lieu des kilomètres)',
|
||||
'tools.labelDesigner': "Créateur d'étiquettes…",
|
||||
'tools.labelPrint': "Imprimer les étiquettes QSL…",
|
||||
'lpr.title': "Imprimer les étiquettes QSL",
|
||||
'lpr.step1': "1 · choisir les contacts", 'lpr.step2': "2 · vérifier les adresses", 'lpr.step3': "3 · imprimer et enregistrer",
|
||||
'lpr.emptyQueue': "Aucune QSL papier en attente. Marquez des contacts Demandée ou En file (statut QSL envoyée R/Q) — dans la vue QSL papier du gestionnaire ou l'éditeur de QSO — et ils apparaîtront ici.",
|
||||
'lpr.all': "tout", 'lpr.none': "aucun", 'lpr.pickSummary': "{s} station(s) · {q} QSO",
|
||||
'lpr.direct': "Direct", 'lpr.bureau': "Bureau", 'lpr.viaMgr': "Via manager", 'lpr.mgrPh': "Indicatif du manager",
|
||||
'lpr.fetchQrz': "Récupérer l'adresse (QRZ)", 'lpr.noAddress': "Aucune adresse trouvée pour {call}.",
|
||||
'lpr.bureauHint': "Bureau : pas d'enveloppe, donc pas d'étiquette adresse — l'étiquette QSO suffit.",
|
||||
'lpr.addressPh': "Nom\nRue\nCP Ville\nPays",
|
||||
'lpr.missingAddr': "Une station en direct ou via manager n'a pas encore d'adresse.",
|
||||
'lpr.whatToPrint': "Étiquettes à imprimer", 'lpr.pickTpl': "Choisir un modèle…",
|
||||
'lpr.kQso': "Étiquettes QSO", 'lpr.kAddr': "Étiquettes adresse", 'lpr.kRet': "Mes étiquettes adresse retour",
|
||||
'lpr.myAddress': "Mon adresse", 'lpr.build': "Composer les étiquettes",
|
||||
'lpr.noQsoTpl': "Aucun modèle d'étiquette QSO — créez-en un dans le Créateur d'étiquettes.",
|
||||
'lpr.noAddrTpl': "Aucun modèle d'étiquette adresse — créez-en un dans le Créateur d'étiquettes.",
|
||||
'lpr.pageCount': "{n} étiquette(s) — une page PDF chacune",
|
||||
'lpr.savePdf': "Enregistrer le PDF et ouvrir",
|
||||
'lpr.markTitle': "Enregistrer dans le log", 'lpr.markBtn': "Marquer {n} QSO envoyés",
|
||||
'lpr.markHint': "Passe QSL envoyée = Y à cette date ; le moyen devient B pour bureau et D pour direct ou manager.",
|
||||
'lpr.markDone': "{n} QSO mis à jour.",
|
||||
'lpr.back': "Retour", 'lpr.next': "Suivant",
|
||||
'lbl.title': "Créateur d'étiquettes",
|
||||
'lbl.newQso': "Étiquette QSO", 'lbl.newAddr': "Étiquette adresse",
|
||||
'lbl.newQsoName': "Étiquette QSO", 'lbl.newAddrName': "Étiquette adresse",
|
||||
|
||||
Vendored
+4
@@ -730,12 +730,16 @@ export function LabelDeleteStock(arg1:number):Promise<void>;
|
||||
|
||||
export function LabelDeleteTemplate(arg1:number):Promise<void>;
|
||||
|
||||
export function LabelExportPDF(arg1:string,arg2:number,arg3:number,arg4:Array<string>):Promise<string>;
|
||||
|
||||
export function LabelGetTemplate(arg1:number):Promise<string>;
|
||||
|
||||
export function LabelListStocks():Promise<Array<labels.Stock>>;
|
||||
|
||||
export function LabelListTemplates():Promise<Array<main.LabelTemplateInfo>>;
|
||||
|
||||
export function LabelPaperQueue():Promise<Array<qso.QSO>>;
|
||||
|
||||
export function LabelSampleQSOs(arg1:number):Promise<Array<main.LabelSampleQSO>>;
|
||||
|
||||
export function LabelSaveStock(arg1:labels.Stock):Promise<number>;
|
||||
|
||||
@@ -1398,6 +1398,10 @@ export function LabelDeleteTemplate(arg1) {
|
||||
return window['go']['main']['App']['LabelDeleteTemplate'](arg1);
|
||||
}
|
||||
|
||||
export function LabelExportPDF(arg1, arg2, arg3, arg4) {
|
||||
return window['go']['main']['App']['LabelExportPDF'](arg1, arg2, arg3, arg4);
|
||||
}
|
||||
|
||||
export function LabelGetTemplate(arg1) {
|
||||
return window['go']['main']['App']['LabelGetTemplate'](arg1);
|
||||
}
|
||||
@@ -1410,6 +1414,10 @@ export function LabelListTemplates() {
|
||||
return window['go']['main']['App']['LabelListTemplates']();
|
||||
}
|
||||
|
||||
export function LabelPaperQueue() {
|
||||
return window['go']['main']['App']['LabelPaperQueue']();
|
||||
}
|
||||
|
||||
export function LabelSampleQSOs(arg1) {
|
||||
return window['go']['main']['App']['LabelSampleQSOs'](arg1);
|
||||
}
|
||||
|
||||
@@ -5227,6 +5227,7 @@ export namespace qso {
|
||||
band?: string;
|
||||
mode?: string;
|
||||
station_callsign?: string;
|
||||
qsl_sent_in?: string[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
|
||||
@@ -5240,6 +5241,7 @@ export namespace qso {
|
||||
this.band = source["band"];
|
||||
this.mode = source["mode"];
|
||||
this.station_callsign = source["station_callsign"];
|
||||
this.qsl_sent_in = source["qsl_sent_in"];
|
||||
this.limit = source["limit"];
|
||||
this.offset = source["offset"];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user