Files
kooltab-backoffice/apps/dashboard/src/components/import-customers-dialog.tsx
T
brammie15 8b1e38cb4d feat: reminders, Excel customer import and security hardening
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
2026-10-07 23:18:37 +02:00

219 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}