166 lines
7.9 KiB
TypeScript
166 lines
7.9 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';
|
|
|
|
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.
|
|
const FIELDS: FieldDef[] = [
|
|
// QSL / upload status
|
|
{ id: 'lotw_sent', label: 'LoTW sent', group: 'QSL / upload', kind: 'status' },
|
|
{ id: 'lotw_rcvd', label: 'LoTW received', group: 'QSL / upload', kind: 'status' },
|
|
{ id: 'eqsl_sent', label: 'eQSL sent', group: 'QSL / upload', kind: 'status' },
|
|
{ id: 'eqsl_rcvd', label: 'eQSL received', group: 'QSL / upload', kind: 'status' },
|
|
{ id: 'qsl_sent', label: 'Paper QSL sent', group: 'QSL / upload', kind: 'status' },
|
|
{ id: 'qsl_rcvd', label: 'Paper QSL received', group: 'QSL / upload', kind: 'status' },
|
|
{ id: 'qrz_upload', label: 'QRZ.com upload', group: 'QSL / upload', kind: 'status' },
|
|
{ id: 'clublog_upload', label: 'Club Log upload', group: 'QSL / upload', kind: 'status' },
|
|
{ id: 'hrdlog_upload', label: 'HRDLog upload', group: 'QSL / upload', kind: 'status' },
|
|
{ id: 'qsl_via', label: 'QSL via', group: 'QSL / upload', kind: 'text' },
|
|
// My station / operator
|
|
{ id: 'station_callsign', label: 'Station callsign', group: 'My station', kind: 'text', upper: true },
|
|
{ id: 'operator', label: 'Operator', group: 'My station', kind: 'text', upper: true },
|
|
{ id: 'my_grid', label: 'My grid', group: 'My station', kind: 'text', upper: true },
|
|
{ id: 'my_antenna', label: 'My antenna', group: 'My station', kind: 'text' },
|
|
{ id: 'my_rig', label: 'My rig', group: 'My station', kind: 'text' },
|
|
{ id: 'my_street', label: 'My street', group: 'My station', kind: 'text' },
|
|
{ id: 'my_city', label: 'My city', group: 'My station', kind: 'text' },
|
|
{ id: 'my_postal_code', label: 'My postal code', group: 'My station', kind: 'text' },
|
|
{ id: 'my_country', label: 'My country', group: 'My station', kind: 'text' },
|
|
{ id: 'my_state', label: 'My state', group: 'My station', kind: 'text', upper: true },
|
|
{ id: 'my_cnty', label: 'My county', group: 'My station', kind: 'text' },
|
|
{ id: 'my_iota', label: 'My IOTA', group: 'My station', kind: 'text', upper: true },
|
|
{ id: 'my_sota_ref', label: 'My SOTA ref', group: 'My station', kind: 'text', upper: true },
|
|
{ id: 'my_pota_ref', label: 'My POTA ref', group: 'My station', kind: 'text', upper: true },
|
|
{ id: 'my_wwff_ref', label: 'My WWFF ref', group: 'My station', kind: 'text', upper: true },
|
|
{ id: 'my_sig', label: 'My SIG', group: 'My station', kind: 'text' },
|
|
{ id: 'my_sig_info', label: 'My SIG info', group: 'My station', kind: 'text' },
|
|
// Misc
|
|
{ id: 'comment', label: 'Comment', group: 'Misc', kind: 'text' },
|
|
{ id: 'notes', label: 'Notes', group: 'Misc', kind: 'text' },
|
|
{ id: 'rig', label: 'Rig (contacted)', group: 'Misc', kind: 'text' },
|
|
{ id: 'ant', label: 'Antenna (contacted)', group: 'Misc', kind: 'text' },
|
|
];
|
|
|
|
const STATUS_VALUES: { v: string; label: string }[] = [
|
|
{ v: 'Y', label: 'Y — Yes / uploaded' },
|
|
{ v: 'N', label: 'N — No' },
|
|
{ v: 'R', label: 'R — Requested' },
|
|
{ v: 'I', label: 'I — Ignore' },
|
|
{ v: '_', label: '(blank — clear)' },
|
|
];
|
|
|
|
const GROUPS = ['QSL / upload', 'My station', 'Misc'];
|
|
|
|
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 [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>Bulk edit field</DialogTitle>
|
|
<DialogDescription>
|
|
Set one field on the {ids.length} selected QSO{ids.length > 1 ? 's' : ''}.
|
|
This overwrites the current value — there is no undo.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="grid grid-cols-[90px_1fr] gap-3 items-center py-2">
|
|
<Label className="text-sm">Field</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">{g}</div>
|
|
{FIELDS.filter((f) => f.group === g).map((f) => (
|
|
<SelectItem key={f.id} value={f.id}>{f.label}</SelectItem>
|
|
))}
|
|
</div>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<Label className="text-sm">Value</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}>{v.label}</SelectItem>)}
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<Input
|
|
className="h-8 text-xs"
|
|
value={textValue}
|
|
placeholder="leave empty to clear the field"
|
|
onChange={(e) => setTextValue(def.upper ? e.target.value.toUpperCase() : e.target.value)}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="text-[11px] text-muted-foreground">
|
|
Will set <span className="font-semibold">{def.label}</span> ={' '}
|
|
<span className="font-mono">{effectiveValue === '' ? '(blank)' : effectiveValue}</span> on {ids.length} QSO{ids.length > 1 ? 's' : ''}.
|
|
</div>
|
|
{error && <div className="text-xs text-rose-700">{error}</div>}
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={onClose} disabled={busy}>Cancel</Button>
|
|
<Button onClick={apply} disabled={busy || ids.length === 0}>
|
|
{busy ? <Loader2 className="size-3.5 animate-spin" /> : null}
|
|
Apply to {ids.length}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|