templates2.Admin.Next/components/admin/ImportWizard.tsx
w4c-templates c64f7ffa9e
Some checks failed
CI / quality (push) Has been cancelled
chore(template): publish Admin.Next
2026-10-11 23:25:34 +03:00

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>
);
}