templates2.Admin.Nuxt/app/components/CustomersManager.vue
w4c-templates 914988e187
Some checks failed
CI / quality (push) Has been cancelled
chore(template): publish Admin.Nuxt
2026-10-11 23:25:56 +03:00

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>