206 lines
8 KiB
Vue
206 lines
8 KiB
Vue
<script setup lang="ts">
|
|
defineProps<{ canWrite: boolean; canDelete: boolean }>();
|
|
|
|
type Customer = { id: string; name: string; email: string; company: string | null; status: string; createdAt: string };
|
|
type ListResponse = { items: Customer[]; total: number; page: number; pageSize: number; pages: number };
|
|
|
|
const STATUSES = ['active', 'lead', 'churned'];
|
|
const statusTone = (status: string) => (status === 'active' ? 'success' : status === 'churned' ? 'danger' : 'warning');
|
|
|
|
const items = ref<Customer[]>([]);
|
|
const meta = ref({ page: 1, pages: 1, total: 0 });
|
|
const searchInput = ref('');
|
|
const search = ref('');
|
|
const status = ref('');
|
|
const sort = ref('newest');
|
|
const page = ref(1);
|
|
const loading = ref(true);
|
|
const error = ref<string | null>(null);
|
|
const form = ref<{ name: string; email: string; company: string; status: string } | null>(null);
|
|
const editingId = ref<string | null>(null);
|
|
const formError = ref<string | null>(null);
|
|
const busy = ref(false);
|
|
|
|
async function load() {
|
|
loading.value = true;
|
|
error.value = null;
|
|
const params = new URLSearchParams({ page: String(page.value), pageSize: '10', sort: sort.value });
|
|
if (search.value) params.set('q', search.value);
|
|
if (status.value) params.set('status', status.value);
|
|
try {
|
|
const data = await $fetch<ListResponse>(`/api/admin/customers?${params.toString()}`);
|
|
items.value = data.items;
|
|
meta.value = { page: data.page, pages: data.pages, total: data.total };
|
|
} catch {
|
|
error.value = t('common.error');
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
watch([page, sort, search, status], load);
|
|
onMounted(load);
|
|
|
|
function openCreate() {
|
|
editingId.value = null;
|
|
formError.value = null;
|
|
form.value = { name: '', email: '', company: '', status: 'active' };
|
|
}
|
|
|
|
function openEdit(c: Customer) {
|
|
editingId.value = c.id;
|
|
formError.value = null;
|
|
form.value = { name: c.name, email: c.email, company: c.company ?? '', status: c.status };
|
|
}
|
|
|
|
function applySearch() {
|
|
page.value = 1;
|
|
search.value = searchInput.value.trim();
|
|
}
|
|
|
|
async function submit() {
|
|
if (!form.value) return;
|
|
busy.value = true;
|
|
formError.value = null;
|
|
try {
|
|
await $fetch(editingId.value ? `/api/admin/customers/${editingId.value}` : '/api/admin/customers', {
|
|
method: editingId.value ? 'PATCH' : 'POST',
|
|
body: form.value,
|
|
});
|
|
form.value = null;
|
|
editingId.value = null;
|
|
await load();
|
|
} catch (e) {
|
|
const err = e as { data?: { statusMessage?: string }; statusMessage?: string };
|
|
formError.value = err.data?.statusMessage || err.statusMessage || t('common.error');
|
|
} finally {
|
|
busy.value = false;
|
|
}
|
|
}
|
|
|
|
async function remove(c: Customer) {
|
|
if (!window.confirm(`${t('admin.delete')} “${c.name}”? This cannot be undone.`)) return;
|
|
busy.value = true;
|
|
await $fetch(`/api/admin/customers/${c.id}`, { method: 'DELETE' }).catch(() => undefined);
|
|
busy.value = false;
|
|
await load();
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="space-y-6">
|
|
<header class="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<h1 class="text-2xl font-bold">{{ t('admin.customers') }}</h1>
|
|
<p class="muted mt-1 text-sm">{{ meta.total }} records</p>
|
|
</div>
|
|
<button v-if="canWrite" type="button" class="btn-primary" @click="openCreate">{{ t('admin.newCustomer') }}</button>
|
|
</header>
|
|
|
|
<p v-if="!canWrite" class="muted text-sm">{{ t('admin.readOnly') }}</p>
|
|
|
|
<form class="card flex flex-wrap items-end gap-3" @submit.prevent="applySearch">
|
|
<label class="text-sm">
|
|
{{ t('admin.search') }}
|
|
<input v-model="searchInput" class="input mt-1" :placeholder="t('admin.searchPlaceholder')" >
|
|
</label>
|
|
<label class="text-sm">
|
|
{{ t('admin.status') }}
|
|
<select v-model="status" class="input mt-1" @change="page = 1">
|
|
<option value="">{{ t('admin.all') }}</option>
|
|
<option v-for="s in STATUSES" :key="s" :value="s">{{ t(`admin.${s}`) }}</option>
|
|
</select>
|
|
</label>
|
|
<label class="text-sm">
|
|
{{ t('admin.sort') }}
|
|
<select v-model="sort" class="input mt-1" @change="page = 1">
|
|
<option value="newest">{{ t('admin.sortNewest') }}</option>
|
|
<option value="name">{{ t('admin.sortName') }}</option>
|
|
<option value="company">{{ t('admin.sortCompany') }}</option>
|
|
</select>
|
|
</label>
|
|
<button type="submit" class="btn-secondary">{{ t('admin.search') }}</button>
|
|
</form>
|
|
|
|
<form v-if="form" class="card space-y-3" @submit.prevent="submit">
|
|
<h2 class="text-lg font-semibold">{{ editingId ? t('admin.editCustomer') : t('admin.newCustomer') }}</h2>
|
|
<div class="grid gap-3 sm:grid-cols-2">
|
|
<label class="text-sm">
|
|
{{ t('admin.name') }}
|
|
<input v-model="form.name" class="input mt-1" required >
|
|
</label>
|
|
<label class="text-sm">
|
|
{{ t('admin.email') }}
|
|
<input v-model="form.email" type="email" class="input mt-1" required >
|
|
</label>
|
|
<label class="text-sm">
|
|
{{ t('admin.company') }}
|
|
<input v-model="form.company" class="input mt-1" >
|
|
</label>
|
|
<label class="text-sm">
|
|
{{ t('admin.status') }}
|
|
<select v-model="form.status" class="input mt-1">
|
|
<option v-for="s in STATUSES" :key="s" :value="s">{{ t(`admin.${s}`) }}</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<p v-if="formError" class="text-sm text-red-600">{{ formError }}</p>
|
|
<div class="flex gap-2">
|
|
<button type="submit" class="btn-primary" :disabled="busy">{{ t('admin.save') }}</button>
|
|
<button
|
|
type="button"
|
|
class="btn-secondary"
|
|
@click="
|
|
form = null;
|
|
editingId = null;
|
|
"
|
|
>
|
|
{{ t('admin.cancel') }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
|
|
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
|
|
<p v-if="loading" class="muted text-sm">{{ t('common.loading') }}</p>
|
|
<p v-else-if="items.length === 0" class="muted py-6 text-center text-sm">{{ t('admin.empty') }}</p>
|
|
<template v-else>
|
|
<div 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-3">{{ t('admin.name') }}</th>
|
|
<th class="px-4 py-3">{{ t('admin.email') }}</th>
|
|
<th class="px-4 py-3">{{ t('admin.company') }}</th>
|
|
<th class="px-4 py-3">{{ t('admin.status') }}</th>
|
|
<th class="px-4 py-3">{{ t('admin.created') }}</th>
|
|
<th v-if="canWrite || canDelete" class="px-4 py-3 text-right">{{ t('admin.actions') }}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="c in items" :key="c.id" class="border-t">
|
|
<td class="px-4 py-3 font-medium">{{ c.name }}</td>
|
|
<td class="px-4 py-3">{{ c.email }}</td>
|
|
<td class="px-4 py-3">{{ c.company || t('admin.none') }}</td>
|
|
<td class="px-4 py-3"><UiBadge :tone="statusTone(c.status)">{{ t(`admin.${c.status}`) }}</UiBadge></td>
|
|
<td class="px-4 py-3">{{ formatDate(c.createdAt) }}</td>
|
|
<td v-if="canWrite || canDelete" class="px-4 py-3">
|
|
<div class="flex justify-end gap-2">
|
|
<button v-if="canWrite" type="button" class="btn-secondary" @click="openEdit(c)">{{ t('admin.edit') }}</button>
|
|
<button v-if="canDelete" type="button" class="btn-danger" :disabled="busy" @click="remove(c)">
|
|
{{ t('admin.delete') }}
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="flex items-center justify-between text-sm">
|
|
<button type="button" class="btn-secondary" :disabled="page <= 1" @click="page--">{{ t('admin.prev') }}</button>
|
|
<span class="muted">{{ t('admin.pageOf', { page: meta.page, pages: meta.pages }) }}</span>
|
|
<button type="button" class="btn-secondary" :disabled="page >= meta.pages" @click="page++">{{ t('admin.next') }}</button>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|