Features - Payment reminders via Resend (candidates, send, history, test email) - Bulk import of the customer list from Excel/CSV in the dashboard - Dashboard i18n (en/nl) with language selector Security - Enforce venue isolation on all /venues/:venueId routes - Require admin for planned-tab changes and pairing codes - Rate-limit login and device registration; opt-in TRUST_PROXY - Require JWT_SECRET in production; stop leaking internal errors - Escape customer names in reminder emails Sync and commands - Remove tabs the terminal no longer reports; validate nested sync payload - Return all pending commands on every sync (fixes missed-command race) - First command ack wins; atomic registration code claim - Reject commands for revoked devices, stale versions or duplicates - Remind only unpaid balances, with a 7-day cooldown Dashboard - Share a single token refresh across concurrent 401s (random logouts) - Keep the session alive for the refresh token lifetime Deployment - Replace `db push --accept-data-loss` with migrations (baselines legacy DBs) - Set NODE_ENV=production; pass Resend and TRUST_PROXY env in prod compose
219 lines
8.8 KiB
TypeScript
219 lines
8.8 KiB
TypeScript
'use client';
|
||
|
||
import { useMemo, useState } from 'react';
|
||
import { Upload } from 'lucide-react';
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Label } from '@/components/ui/label';
|
||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||
import { toast } from '@/components/ui/toast';
|
||
import { plannedTabsApi } from '@/lib/api/client';
|
||
import { useLang } from '@/lib/i18n';
|
||
import { buildPreview, columnLabel, detectMapping, readSheet, type ColumnMapping, type SheetData } from '@/lib/customer-import';
|
||
|
||
const PREVIEW_ROWS = 8;
|
||
|
||
const selectClassName =
|
||
'flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring';
|
||
|
||
interface Props {
|
||
venueId: string;
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
onImported: () => void;
|
||
}
|
||
|
||
export function ImportCustomersDialog({ venueId, open, onOpenChange, onImported }: Props) {
|
||
const { t } = useLang();
|
||
const [fileName, setFileName] = useState<string | null>(null);
|
||
const [sheet, setSheet] = useState<SheetData | null>(null);
|
||
const [mapping, setMapping] = useState<ColumnMapping | null>(null);
|
||
const [mode, setMode] = useState<'merge' | 'replace'>('merge');
|
||
const [submitting, setSubmitting] = useState(false);
|
||
|
||
const preview = useMemo(() => (sheet && mapping ? buildPreview(sheet, mapping) : null), [sheet, mapping]);
|
||
|
||
function reset() {
|
||
setFileName(null);
|
||
setSheet(null);
|
||
setMapping(null);
|
||
setMode('merge');
|
||
}
|
||
|
||
function handleOpenChange(next: boolean) {
|
||
if (!next) reset();
|
||
onOpenChange(next);
|
||
}
|
||
|
||
async function handleFile(file: File | undefined) {
|
||
if (!file) return;
|
||
try {
|
||
const data = await readSheet(file);
|
||
if (data.rows.length === 0) {
|
||
toast({ title: t('import.failed'), description: t('import.empty_file'), variant: 'destructive' });
|
||
return;
|
||
}
|
||
setFileName(file.name);
|
||
setSheet(data);
|
||
setMapping(detectMapping(data));
|
||
} catch {
|
||
toast({ title: t('import.failed'), description: t('import.unreadable'), variant: 'destructive' });
|
||
}
|
||
}
|
||
|
||
async function submit() {
|
||
if (!preview || preview.entries.length === 0) return;
|
||
setSubmitting(true);
|
||
try {
|
||
const result = await plannedTabsApi.import(venueId, preview.entries, mode);
|
||
toast({ title: t('import.done'), description: t('import.result', result), variant: 'success' });
|
||
onImported();
|
||
handleOpenChange(false);
|
||
} catch (err: any) {
|
||
toast({ title: t('import.failed'), description: err.message, variant: 'destructive' });
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
}
|
||
|
||
const header = sheet && mapping?.hasHeader ? sheet.rows[0] : null;
|
||
const columnOptions = sheet
|
||
? Array.from({ length: sheet.columnCount }, (_, i) => ({
|
||
value: i,
|
||
label: header?.[i] ? `${columnLabel(i)} – ${header[i]}` : columnLabel(i),
|
||
}))
|
||
: [];
|
||
|
||
function columnSelect(value: number | null, onChange: (value: number | null) => void, optional: boolean) {
|
||
return (
|
||
<select
|
||
className={selectClassName}
|
||
value={value ?? ''}
|
||
onChange={(e) => onChange(e.target.value === '' ? null : Number(e.target.value))}
|
||
>
|
||
{optional && <option value="">{t('import.column_none')}</option>}
|
||
{columnOptions.map((c) => (
|
||
<option key={c.value} value={c.value}>{c.label}</option>
|
||
))}
|
||
</select>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||
<DialogHeader>
|
||
<DialogTitle>{t('import.title')}</DialogTitle>
|
||
<DialogDescription>{t('import.description')}</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-5">
|
||
<label className="flex flex-col items-center justify-center gap-2 rounded-md border border-dashed p-6 text-sm cursor-pointer hover:bg-accent">
|
||
<Upload className="h-5 w-5 text-muted-foreground" />
|
||
<span>{fileName ?? t('import.choose_file')}</span>
|
||
<span className="text-xs text-muted-foreground">.xlsx, .xls, .csv</span>
|
||
<input
|
||
type="file"
|
||
accept=".xlsx,.xls,.csv"
|
||
className="sr-only"
|
||
onChange={(e) => {
|
||
handleFile(e.target.files?.[0]);
|
||
e.target.value = '';
|
||
}}
|
||
/>
|
||
</label>
|
||
|
||
{sheet && mapping && preview && (
|
||
<>
|
||
<div className="grid gap-3 sm:grid-cols-3">
|
||
<div className="space-y-1">
|
||
<Label>{t('import.name_column')}</Label>
|
||
{columnSelect(mapping.nameColumn, (v) => setMapping({ ...mapping, nameColumn: v ?? 0 }), false)}
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('import.last_name_column')}</Label>
|
||
{columnSelect(mapping.lastNameColumn, (v) => setMapping({ ...mapping, lastNameColumn: v }), true)}
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label>{t('import.email_column')}</Label>
|
||
{columnSelect(mapping.emailColumn, (v) => setMapping({ ...mapping, emailColumn: v }), true)}
|
||
</div>
|
||
</div>
|
||
|
||
<label className="flex items-center gap-2 text-sm">
|
||
<input
|
||
type="checkbox"
|
||
checked={mapping.hasHeader}
|
||
onChange={(e) => setMapping({ ...mapping, hasHeader: e.target.checked })}
|
||
/>
|
||
{t('import.has_header')}
|
||
</label>
|
||
|
||
<div className="space-y-2">
|
||
<p className="text-sm">
|
||
{t('import.summary', {
|
||
count: preview.entries.length,
|
||
emails: preview.entries.filter((e) => e.email).length,
|
||
})}
|
||
</p>
|
||
{(preview.skippedRows > 0 || preview.invalidEmails > 0) && (
|
||
<p className="text-xs text-muted-foreground">
|
||
{t('import.warnings', { skipped: preview.skippedRows, invalid: preview.invalidEmails })}
|
||
</p>
|
||
)}
|
||
<div className="rounded-md border">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>{t('tabs.customer')}</TableHead>
|
||
<TableHead>{t('import.email')}</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{preview.entries.slice(0, PREVIEW_ROWS).map((entry, i) => (
|
||
<TableRow key={i}>
|
||
<TableCell className="font-medium">{entry.customerName}</TableCell>
|
||
<TableCell className="text-muted-foreground">{entry.email ?? '—'}</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
{preview.entries.length > PREVIEW_ROWS && (
|
||
<p className="text-xs text-muted-foreground">
|
||
{t('import.more', { count: preview.entries.length - PREVIEW_ROWS })}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<Label className="text-base font-semibold">{t('import.mode')}</Label>
|
||
<RadioGroup value={mode} onValueChange={(v) => setMode(v as 'merge' | 'replace')}>
|
||
<div className="flex items-start space-x-2">
|
||
<RadioGroupItem value="merge" id="import-merge" className="mt-0.5" />
|
||
<Label htmlFor="import-merge" className="font-normal">{t('import.mode_merge')}</Label>
|
||
</div>
|
||
<div className="flex items-start space-x-2">
|
||
<RadioGroupItem value="replace" id="import-replace" className="mt-0.5" />
|
||
<Label htmlFor="import-replace" className="font-normal">{t('import.mode_replace')}</Label>
|
||
</div>
|
||
</RadioGroup>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={submitting}>
|
||
{t('tabs.cancel')}
|
||
</Button>
|
||
<Button onClick={submit} disabled={submitting || !preview || preview.entries.length === 0}>
|
||
{submitting ? t('import.importing') : t('import.submit', { count: preview?.entries.length ?? 0 })}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|