183 lines
6.7 KiB
TypeScript
183 lines
6.7 KiB
TypeScript
'use client';
|
|
|
|
import { useState } from 'react';
|
|
import { t } from '@/lib/i18n';
|
|
|
|
type Row = { name: string; email: string; company: string; status: string };
|
|
type Preview = { total: number; valid: number; errors: { row: number; message: string }[]; duplicatesInFile: number[]; sample: Row[] };
|
|
type Committed = { imported: number; updated: number; errors: { row: number; message: string }[]; duplicatesInFile: number[] };
|
|
|
|
const SAMPLE = 'name,email,company,status\nJane Doe,jane@example.com,Acme,lead\nBad Row,not-an-email,Globex,active';
|
|
|
|
export function ImportWizard({ canImport }: { canImport: boolean }) {
|
|
const [csv, setCsv] = useState('');
|
|
const [preview, setPreview] = useState<Preview | null>(null);
|
|
const [committed, setCommitted] = useState<Committed | null>(null);
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
async function post(commit: boolean) {
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
const res = await fetch('/api/admin/import/customers', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ csv, commit }),
|
|
});
|
|
const data = (await res.json().catch(() => ({}))) as Record<string, unknown>;
|
|
if (!res.ok) {
|
|
setError((data.error as string) || t('common.error'));
|
|
return;
|
|
}
|
|
if (commit) {
|
|
setPreview(null);
|
|
setCommitted(data as unknown as Committed);
|
|
} else {
|
|
setCommitted(null);
|
|
setPreview(data as unknown as Preview);
|
|
}
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
async function onFile(event: React.ChangeEvent<HTMLInputElement>) {
|
|
const file = event.target.files?.[0];
|
|
if (file) setCsv(await file.text());
|
|
}
|
|
|
|
if (!canImport) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<h1 className="text-2xl font-bold">{t('admin.importTitle')}</h1>
|
|
<p className="muted text-sm">{t('admin.readOnly')}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<header className="flex flex-wrap items-center justify-between gap-3">
|
|
<h1 className="text-2xl font-bold">{t('admin.importTitle')}</h1>
|
|
<a className="btn-secondary" href="/api/admin/export/customers" download>
|
|
{t('admin.export')}
|
|
</a>
|
|
</header>
|
|
<p className="muted text-sm">{t('admin.importHint')}</p>
|
|
|
|
<div className="card space-y-3">
|
|
<label className="block text-sm">
|
|
{t('admin.chooseFile')}
|
|
<input type="file" accept=".csv,text/csv" className="mt-1 block text-sm" onChange={onFile} />
|
|
</label>
|
|
<label className="block text-sm">
|
|
CSV
|
|
<textarea
|
|
className="input mt-1 min-h-40 font-mono"
|
|
value={csv}
|
|
onChange={(e) => setCsv(e.target.value)}
|
|
placeholder={SAMPLE}
|
|
/>
|
|
</label>
|
|
<div className="flex flex-wrap gap-2">
|
|
<button type="button" className="btn-secondary" disabled={busy || !csv.trim()} onClick={() => post(false)}>
|
|
{t('admin.preview')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn-primary"
|
|
disabled={busy || !preview || preview.valid === 0}
|
|
onClick={() => post(true)}
|
|
>
|
|
{busy ? t('admin.importing') : t('admin.commit', { count: preview?.valid ?? 0 })}
|
|
</button>
|
|
</div>
|
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
|
</div>
|
|
|
|
{committed && (
|
|
<div className="card">
|
|
<p className="text-sm font-medium">
|
|
{t('admin.imported', { imported: committed.imported, updated: committed.updated })}
|
|
</p>
|
|
{committed.errors.length > 0 && <RowErrors errors={committed.errors} />}
|
|
</div>
|
|
)}
|
|
|
|
{preview && (
|
|
<div className="card space-y-4">
|
|
<p className="text-sm">
|
|
{t('admin.previewResult', {
|
|
total: preview.total,
|
|
valid: preview.valid,
|
|
errors: preview.errors.length,
|
|
duplicates: preview.duplicatesInFile.length,
|
|
})}
|
|
</p>
|
|
<div>
|
|
<h2 className="mb-2 text-sm font-semibold">{t('admin.sample')}</h2>
|
|
{preview.sample.length === 0 ? (
|
|
<p className="muted text-sm">{t('admin.empty')}</p>
|
|
) : (
|
|
<div className="overflow-x-auto rounded-xl border" style={{ borderColor: 'var(--border)' }}>
|
|
<table className="w-full text-sm">
|
|
<thead className="muted text-left text-xs uppercase">
|
|
<tr>
|
|
<th className="px-4 py-2">{t('admin.name')}</th>
|
|
<th className="px-4 py-2">{t('admin.email')}</th>
|
|
<th className="px-4 py-2">{t('admin.company')}</th>
|
|
<th className="px-4 py-2">{t('admin.status')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{preview.sample.map((r) => (
|
|
<tr key={r.email} className="border-t" style={{ borderColor: 'var(--border)' }}>
|
|
<td className="px-4 py-2">{r.name}</td>
|
|
<td className="px-4 py-2">{r.email}</td>
|
|
<td className="px-4 py-2">{r.company || t('admin.none')}</td>
|
|
<td className="px-4 py-2">{t(`admin.${r.status}`)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<h2 className="mb-2 text-sm font-semibold">{t('admin.perRowErrors')}</h2>
|
|
{preview.errors.length === 0 ? (
|
|
<p className="muted text-sm">{t('admin.noErrors')}</p>
|
|
) : (
|
|
<RowErrors errors={preview.errors} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RowErrors({ errors }: { errors: { row: number; message: string }[] }) {
|
|
return (
|
|
<div className="overflow-x-auto rounded-xl border" style={{ borderColor: 'var(--border)' }}>
|
|
<table className="w-full text-sm">
|
|
<thead className="muted text-left text-xs uppercase">
|
|
<tr>
|
|
<th className="px-4 py-2">{t('admin.row')}</th>
|
|
<th className="px-4 py-2">{t('admin.message')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{errors.map((e) => (
|
|
<tr key={`${e.row}-${e.message}`} className="border-t" style={{ borderColor: 'var(--border)' }}>
|
|
<td className="px-4 py-2">{e.row}</td>
|
|
<td className="px-4 py-2 text-red-600">{e.message}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|