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
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user