201 lines
10 KiB
TypeScript
201 lines
10 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { Loader2 } from 'lucide-react';
|
|
import { BulkUpdateField } from '../../wailsjs/go/main/App';
|
|
import {
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
|
} from '@/components/ui/dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import {
|
|
Select, SelectTrigger, SelectValue, SelectContent, SelectItem,
|
|
} from '@/components/ui/select';
|
|
import { useI18n } from '@/lib/i18n';
|
|
|
|
type FieldKind = 'status' | 'text';
|
|
type FieldDef = { id: string; label: string; group: string; kind: FieldKind; upper?: boolean };
|
|
|
|
// Fields a bulk edit may target. status → Y/N/R/I dropdown; text → free input.
|
|
// upper:true uppercases code-like values (callsign, grid, refs). The id matches
|
|
// the backend BulkUpdateField whitelist.
|
|
// label holds an i18n key (resolved with t() at render time).
|
|
const FIELDS: FieldDef[] = [
|
|
// QSL / upload status
|
|
{ id: 'lotw_sent', label: 'bulk.fLotwSent', group: 'QSL / upload', kind: 'status' },
|
|
{ id: 'lotw_rcvd', label: 'bulk.fLotwRcvd', group: 'QSL / upload', kind: 'status' },
|
|
{ id: 'eqsl_sent', label: 'bulk.fEqslSent', group: 'QSL / upload', kind: 'status' },
|
|
{ id: 'eqsl_rcvd', label: 'bulk.fEqslRcvd', group: 'QSL / upload', kind: 'status' },
|
|
{ id: 'qsl_sent', label: 'bulk.fQslSent', group: 'QSL / upload', kind: 'status' },
|
|
{ id: 'qsl_rcvd', label: 'bulk.fQslRcvd', group: 'QSL / upload', kind: 'status' },
|
|
{ id: 'qrz_upload', label: 'bulk.fQrzUpload', group: 'QSL / upload', kind: 'status' },
|
|
{ id: 'clublog_upload', label: 'bulk.fClublogUpload', group: 'QSL / upload', kind: 'status' },
|
|
{ id: 'hrdlog_upload', label: 'bulk.fHrdlogUpload', group: 'QSL / upload', kind: 'status' },
|
|
{ id: 'qsl_via', label: 'bulk.fQslVia', group: 'QSL / upload', kind: 'text' },
|
|
// My station / operator
|
|
{ id: 'station_callsign', label: 'bulk.fStationCall', group: 'My station', kind: 'text', upper: true },
|
|
{ id: 'operator', label: 'bulk.fOperator', group: 'My station', kind: 'text', upper: true },
|
|
{ id: 'my_grid', label: 'bulk.fMyGrid', group: 'My station', kind: 'text', upper: true },
|
|
{ id: 'my_antenna', label: 'bulk.fMyAntenna', group: 'My station', kind: 'text' },
|
|
{ id: 'my_rig', label: 'bulk.fMyRig', group: 'My station', kind: 'text' },
|
|
{ id: 'my_street', label: 'bulk.fMyStreet', group: 'My station', kind: 'text' },
|
|
{ id: 'my_city', label: 'bulk.fMyCity', group: 'My station', kind: 'text' },
|
|
{ id: 'my_postal_code', label: 'bulk.fMyPostal', group: 'My station', kind: 'text' },
|
|
{ id: 'my_country', label: 'bulk.fMyCountry', group: 'My station', kind: 'text' },
|
|
{ id: 'my_state', label: 'bulk.fMyState', group: 'My station', kind: 'text', upper: true },
|
|
{ id: 'my_cnty', label: 'bulk.fMyCounty', group: 'My station', kind: 'text' },
|
|
{ id: 'my_iota', label: 'bulk.fMyIota', group: 'My station', kind: 'text', upper: true },
|
|
{ id: 'my_sota_ref', label: 'bulk.fMySota', group: 'My station', kind: 'text', upper: true },
|
|
{ id: 'my_pota_ref', label: 'bulk.fMyPota', group: 'My station', kind: 'text', upper: true },
|
|
{ id: 'my_wwff_ref', label: 'bulk.fMyWwff', group: 'My station', kind: 'text', upper: true },
|
|
{ id: 'my_sig', label: 'bulk.fMySig', group: 'My station', kind: 'text' },
|
|
{ id: 'my_sig_info', label: 'bulk.fMySigInfo', group: 'My station', kind: 'text' },
|
|
// Contest
|
|
{ id: 'contest_id', label: 'bulk.fContestId', group: 'Contest', kind: 'text', upper: true },
|
|
{ id: 'srx_string', label: 'bulk.fSrxString', group: 'Contest', kind: 'text' },
|
|
{ id: 'stx_string', label: 'bulk.fStxString', group: 'Contest', kind: 'text' },
|
|
{ id: 'arrl_sect', label: 'bulk.fArrlSect', group: 'Contest', kind: 'text', upper: true },
|
|
{ id: 'precedence', label: 'bulk.fPrecedence', group: 'Contest', kind: 'text', upper: true },
|
|
{ id: 'class', label: 'bulk.fClass', group: 'Contest', kind: 'text', upper: true },
|
|
// Propagation / satellite
|
|
{ id: 'prop_mode', label: 'bulk.fPropMode', group: 'Propagation', kind: 'text', upper: true },
|
|
{ id: 'sat_name', label: 'bulk.fSatName', group: 'Propagation', kind: 'text', upper: true },
|
|
{ id: 'sat_mode', label: 'bulk.fSatMode', group: 'Propagation', kind: 'text', upper: true },
|
|
// Contacted station (activation refs / SIG)
|
|
{ id: 'pota_ref', label: 'bulk.fPotaRef', group: 'Contacted station', kind: 'text', upper: true },
|
|
{ id: 'sota_ref', label: 'bulk.fSotaRef', group: 'Contacted station', kind: 'text', upper: true },
|
|
{ id: 'wwff_ref', label: 'bulk.fWwffRef', group: 'Contacted station', kind: 'text', upper: true },
|
|
{ id: 'iota', label: 'bulk.fIota', group: 'Contacted station', kind: 'text', upper: true },
|
|
{ id: 'sig', label: 'bulk.fSig', group: 'Contacted station', kind: 'text' },
|
|
{ id: 'sig_info', label: 'bulk.fSigInfo', group: 'Contacted station', kind: 'text' },
|
|
// Misc
|
|
{ id: 'comment', label: 'bulk.fComment', group: 'Misc', kind: 'text' },
|
|
{ id: 'notes', label: 'bulk.fNotes', group: 'Misc', kind: 'text' },
|
|
{ id: 'rig', label: 'bulk.fRig', group: 'Misc', kind: 'text' },
|
|
{ id: 'ant', label: 'bulk.fAnt', group: 'Misc', kind: 'text' },
|
|
];
|
|
|
|
// label holds an i18n key.
|
|
const STATUS_VALUES: { v: string; label: string }[] = [
|
|
{ v: 'Y', label: 'bulk.statusY' },
|
|
{ v: 'N', label: 'bulk.statusN' },
|
|
{ v: 'R', label: 'bulk.statusR' },
|
|
{ v: 'I', label: 'bulk.statusI' },
|
|
{ v: '_', label: 'bulk.statusBlank' },
|
|
];
|
|
|
|
const GROUPS = ['QSL / upload', 'My station', 'Contacted station', 'Contest', 'Propagation', 'Misc'];
|
|
// Maps the internal group key → its i18n label key.
|
|
const GROUP_LABELS: Record<string, string> = {
|
|
'QSL / upload': 'bulk.groupQsl',
|
|
'My station': 'bulk.groupMyStation',
|
|
'Contacted station': 'bulk.groupContacted',
|
|
'Contest': 'bulk.groupContest',
|
|
'Propagation': 'bulk.groupPropagation',
|
|
'Misc': 'bulk.groupMisc',
|
|
};
|
|
|
|
type Props = {
|
|
open: boolean;
|
|
ids: number[];
|
|
onClose: () => void;
|
|
onApplied: (n: number) => void;
|
|
};
|
|
|
|
// BulkEditModal sets one QSL/upload status field to a chosen value across the
|
|
// selected QSOs — e.g. flip a filtered batch of imported contacts from N to R
|
|
// so they become eligible for upload.
|
|
export function BulkEditModal({ open, ids, onClose, onApplied }: Props) {
|
|
const { t } = useI18n();
|
|
const [field, setField] = useState('hrdlog_upload');
|
|
const [statusValue, setStatusValue] = useState('R');
|
|
const [textValue, setTextValue] = useState('');
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState('');
|
|
|
|
const def = useMemo(() => FIELDS.find((f) => f.id === field) ?? FIELDS[0], [field]);
|
|
const isStatus = def.kind === 'status';
|
|
const effectiveValue = isStatus
|
|
? (statusValue === '_' ? '' : statusValue)
|
|
: textValue.trim();
|
|
|
|
async function apply() {
|
|
setBusy(true);
|
|
setError('');
|
|
try {
|
|
const n = await BulkUpdateField(ids as any, field, effectiveValue);
|
|
onApplied(Number(n) || 0);
|
|
onClose();
|
|
} catch (e: any) {
|
|
setError(String(e?.message ?? e));
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle>{t('bulk.title')}</DialogTitle>
|
|
<DialogDescription>
|
|
{t('bulk.desc', { n: ids.length })}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="px-5 py-2 space-y-3">
|
|
<div className="grid grid-cols-[90px_1fr] gap-3 items-center">
|
|
<Label className="text-sm">{t('bulk.fieldLabel')}</Label>
|
|
<Select value={field} onValueChange={setField}>
|
|
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{GROUPS.map((g) => (
|
|
<div key={g}>
|
|
<div className="px-2 py-1 text-[10px] uppercase tracking-wider text-muted-foreground">{t(GROUP_LABELS[g])}</div>
|
|
{FIELDS.filter((f) => f.group === g)
|
|
.map((f) => ({ f, txt: t(f.label) }))
|
|
.sort((a, b) => a.txt.localeCompare(b.txt))
|
|
.map(({ f, txt }) => (
|
|
<SelectItem key={f.id} value={f.id}>{txt}</SelectItem>
|
|
))}
|
|
</div>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Label className="text-sm">{t('bulk.valueLabel')}</Label>
|
|
{isStatus ? (
|
|
<Select value={statusValue} onValueChange={setStatusValue}>
|
|
<SelectTrigger className="h-8"><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
{STATUS_VALUES.map((v) => <SelectItem key={v.v} value={v.v}>{t(v.label)}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<Input
|
|
className="h-8 text-xs"
|
|
value={textValue}
|
|
placeholder={t('bulk.clearPlaceholder')}
|
|
onChange={(e) => setTextValue(def.upper ? e.target.value.toUpperCase() : e.target.value)}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="text-[11px] text-muted-foreground">
|
|
{t('bulk.willSet')} <span className="font-semibold">{t(def.label)}</span> ={' '}
|
|
<span className="font-mono">{effectiveValue === '' ? t('bulk.blank') : effectiveValue}</span> {t('bulk.onQsos', { n: ids.length })}
|
|
</div>
|
|
{error && <div className="text-xs text-danger">{error}</div>}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={onClose} disabled={busy}>{t('bulk.cancel')}</Button>
|
|
<Button onClick={apply} disabled={busy || ids.length === 0}>
|
|
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
|
{t('bulk.applyTo', { n: ids.length })}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|