140 lines
5.5 KiB
Vue
140 lines
5.5 KiB
Vue
<script setup lang="ts">
|
|
defineProps<{ canImport: boolean }>();
|
|
|
|
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';
|
|
|
|
const csv = ref('');
|
|
const preview = ref<Preview | null>(null);
|
|
const committed = ref<Committed | null>(null);
|
|
const busy = ref(false);
|
|
const error = ref<string | null>(null);
|
|
|
|
async function post(commit: boolean) {
|
|
busy.value = true;
|
|
error.value = null;
|
|
try {
|
|
const data = await $fetch<Record<string, unknown>>('/api/admin/import/customers', {
|
|
method: 'POST',
|
|
body: { csv: csv.value, commit },
|
|
});
|
|
if (commit) {
|
|
preview.value = null;
|
|
committed.value = data as unknown as Committed;
|
|
} else {
|
|
committed.value = null;
|
|
preview.value = data as unknown as Preview;
|
|
}
|
|
} catch (e) {
|
|
const err = e as { data?: { statusMessage?: string }; statusMessage?: string };
|
|
error.value = err.data?.statusMessage || err.statusMessage || t('common.error');
|
|
} finally {
|
|
busy.value = false;
|
|
}
|
|
}
|
|
|
|
async function onFile(event: Event) {
|
|
const file = (event.target as HTMLInputElement).files?.[0];
|
|
if (file) csv.value = await file.text();
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="space-y-6">
|
|
<h1 class="text-2xl font-bold">{{ t('admin.importTitle') }}</h1>
|
|
<p v-if="!canImport" class="muted text-sm">{{ t('admin.readOnly') }}</p>
|
|
<template v-else>
|
|
<header class="flex flex-wrap items-center justify-between gap-3">
|
|
<p class="muted text-sm">{{ t('admin.importHint') }}</p>
|
|
<a class="btn-secondary" href="/api/admin/export/customers" download>{{ t('admin.export') }}</a>
|
|
</header>
|
|
|
|
<div class="card space-y-3">
|
|
<label class="block text-sm">
|
|
{{ t('admin.chooseFile') }}
|
|
<input type="file" accept=".csv,text/csv" class="mt-1 block text-sm" @change="onFile" >
|
|
</label>
|
|
<label class="block text-sm">
|
|
CSV
|
|
<textarea v-model="csv" class="input mt-1 min-h-40 font-mono" :placeholder="SAMPLE"/>
|
|
</label>
|
|
<div class="flex flex-wrap gap-2">
|
|
<button type="button" class="btn-secondary" :disabled="busy || !csv.trim()" @click="post(false)">
|
|
{{ t('admin.preview') }}
|
|
</button>
|
|
<button type="button" class="btn-primary" :disabled="busy || !preview || preview.valid === 0" @click="post(true)">
|
|
{{ busy ? t('admin.importing') : t('admin.commit', { count: preview?.valid ?? 0 }) }}
|
|
</button>
|
|
</div>
|
|
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
|
|
</div>
|
|
|
|
<div v-if="committed" class="card">
|
|
<p class="text-sm font-medium">
|
|
{{ t('admin.imported', { imported: committed.imported, updated: committed.updated }) }}
|
|
</p>
|
|
</div>
|
|
|
|
<div v-if="preview" class="card space-y-4">
|
|
<p class="text-sm">
|
|
{{
|
|
t('admin.previewResult', {
|
|
total: preview.total,
|
|
valid: preview.valid,
|
|
errors: preview.errors.length,
|
|
duplicates: preview.duplicatesInFile.length,
|
|
})
|
|
}}
|
|
</p>
|
|
<div>
|
|
<h2 class="mb-2 text-sm font-semibold">{{ t('admin.sample') }}</h2>
|
|
<p v-if="preview.sample.length === 0" class="muted text-sm">{{ t('admin.empty') }}</p>
|
|
<div v-else class="overflow-x-auto rounded-xl border">
|
|
<table class="w-full text-sm">
|
|
<thead class="muted text-left text-xs uppercase">
|
|
<tr>
|
|
<th class="px-4 py-2">{{ t('admin.name') }}</th>
|
|
<th class="px-4 py-2">{{ t('admin.email') }}</th>
|
|
<th class="px-4 py-2">{{ t('admin.company') }}</th>
|
|
<th class="px-4 py-2">{{ t('admin.status') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="r in preview.sample" :key="r.email" class="border-t">
|
|
<td class="px-4 py-2">{{ r.name }}</td>
|
|
<td class="px-4 py-2">{{ r.email }}</td>
|
|
<td class="px-4 py-2">{{ r.company || t('admin.none') }}</td>
|
|
<td class="px-4 py-2">{{ t(`admin.${r.status}`) }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h2 class="mb-2 text-sm font-semibold">{{ t('admin.perRowErrors') }}</h2>
|
|
<p v-if="preview.errors.length === 0" class="muted text-sm">{{ t('admin.noErrors') }}</p>
|
|
<div v-else class="overflow-x-auto rounded-xl border">
|
|
<table class="w-full text-sm">
|
|
<thead class="muted text-left text-xs uppercase">
|
|
<tr>
|
|
<th class="px-4 py-2">{{ t('admin.row') }}</th>
|
|
<th class="px-4 py-2">{{ t('admin.message') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="e in preview.errors" :key="`${e.row}-${e.message}`" class="border-t">
|
|
<td class="px-4 py-2">{{ e.row }}</td>
|
|
<td class="px-4 py-2 text-red-600">{{ e.message }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|