Add service templates admin UI per business category

This commit is contained in:
2026-05-24 11:41:13 +03:30
parent 5629213449
commit cca999baf9
5 changed files with 360 additions and 1 deletions
+35
View File
@@ -0,0 +1,35 @@
import { api } from './client';
import type {
CreateServiceTemplatePayload,
ServiceTemplate,
UpdateServiceTemplatePayload,
} from './types';
export function fetchServiceTemplates(categoryId?: string) {
const search = new URLSearchParams();
if (categoryId) {
search.set('categoryId', categoryId);
}
const qs = search.toString();
return api<ServiceTemplate[]>(`/service-templates${qs ? `?${qs}` : ''}`);
}
export function createServiceTemplate(payload: CreateServiceTemplatePayload) {
return api<ServiceTemplate>('/service-templates', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updateServiceTemplate(id: string, payload: UpdateServiceTemplatePayload) {
return api<ServiceTemplate>(`/service-templates/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
}
export function deleteServiceTemplate(id: string) {
return api<{ success: true }>(`/service-templates/${id}`, {
method: 'DELETE',
});
}
+26
View File
@@ -77,6 +77,32 @@ export type CreateCategoryPayload = {
export type UpdateCategoryPayload = Partial<CreateCategoryPayload>;
export type ServiceTemplate = {
id: string;
title: string;
slug: string;
description: string | null;
defaultDurationMinutes: number;
isActive: boolean;
categoryId: string;
category?: BusinessCategory;
createdAt?: string;
updatedAt?: string;
};
export type CreateServiceTemplatePayload = {
title: string;
slug: string;
categoryId: string;
description?: string;
defaultDurationMinutes?: number;
isActive?: boolean;
};
export type UpdateServiceTemplatePayload = Partial<
Omit<CreateServiceTemplatePayload, 'categoryId'>
>;
export type UpdateUserPayload = {
fullName?: string | null;
role?: UserRole;
+6
View File
@@ -28,6 +28,12 @@ const router = createRouter({
component: () => import('@/views/CategoriesView.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: '/service-templates',
name: 'service-templates',
component: () => import('@/views/ServiceTemplatesView.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: '/businesses',
name: 'businesses',
+10 -1
View File
@@ -2,7 +2,15 @@
<AdminLayout>
<PageHeader title="دسته‌های کسب‌وکار" subtitle="دسته‌بندی خدمات">
<template #actions>
<AppButton class="!w-auto shrink-0 px-4" @click="openCreate">جدید</AppButton>
<div class="flex shrink-0 items-center gap-2">
<RouterLink
:to="{ name: 'service-templates' }"
class="rounded-xl border border-border px-4 py-2 text-sm text-muted hover:bg-surface-3"
>
قالب خدمات
</RouterLink>
<AppButton class="!w-auto px-4" @click="openCreate">جدید</AppButton>
</div>
</template>
</PageHeader>
@@ -75,6 +83,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { RouterLink } from 'vue-router';
import {
createCategory,
deleteCategory,
+283
View File
@@ -0,0 +1,283 @@
<template>
<AdminLayout>
<PageHeader title="قالب‌های خدمت" subtitle="انواع خدمت قابل مقایسه در هر دسته">
<template #actions>
<AppButton
class="!w-auto shrink-0 px-4"
:disabled="!selectedCategoryId"
@click="openCreate"
>
جدید
</AppButton>
</template>
</PageHeader>
<AppSelect
v-model="selectedCategoryId"
class="mt-4"
label="دسته"
placeholder="انتخاب دسته"
:options="categoryOptions"
/>
<SearchInput
v-model="searchQuery"
class="mt-4 block"
placeholder="جستجو در عنوان یا اسلاگ…"
/>
<p v-if="error" class="mt-4 rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-600">
{{ error }}
</p>
<div v-if="loading" class="mt-4 text-sm text-muted">در حال بارگذاری…</div>
<EmptyState v-else-if="!selectedCategoryId" class="mt-4">
ابتدا یک دسته انتخاب کنید.
</EmptyState>
<EmptyState v-else-if="filteredTemplates.length === 0" class="mt-4">
{{
searchQuery.trim()
? 'نتیجه‌ای برای جستجوی شما یافت نشد.'
: 'قالب خدمتی برای این دسته تعریف نشده.'
}}
</EmptyState>
<ul v-else class="mt-4 space-y-2">
<li
v-for="tpl in filteredTemplates"
:key="tpl.id"
class="rounded-2xl border border-border bg-surface-2 p-4"
>
<div class="flex items-start justify-between gap-2">
<div>
<p class="font-semibold text-text">{{ tpl.title }}</p>
<p class="mt-0.5 text-xs text-muted" dir="ltr">{{ tpl.slug }}</p>
<p v-if="tpl.description" class="mt-1 text-sm text-muted">{{ tpl.description }}</p>
<p class="mt-1 text-xs text-muted">مدت پیش‌فرض: {{ tpl.defaultDurationMinutes }} دقیقه</p>
<AppBadge class="mt-2" :tone="tpl.isActive ? 'success' : 'warning'">
{{ activeLabel(tpl.isActive) }}
</AppBadge>
</div>
<div class="flex shrink-0 flex-col gap-1">
<AppButton variant="ghost" class="!w-auto px-3" @click="openEdit(tpl)">ویرایش</AppButton>
<AppButton variant="danger" class="!w-auto px-3" @click="onDelete(tpl)">حذف</AppButton>
</div>
</div>
</li>
</ul>
<AppModal
:open="modalOpen"
:title="editingId ? 'ویرایش قالب خدمت' : 'قالب خدمت جدید'"
@close="closeModal"
>
<form class="space-y-4" @submit.prevent="onSave">
<AppInput id="tplTitle" v-model="form.title" label="عنوان" required @blur="maybeSlug" />
<AppInput
id="tplSlug"
v-model="form.slug"
label="اسلاگ (انگلیسی)"
ltr
placeholder="haircut"
required
/>
<AppTextarea id="tplDesc" v-model="form.description" label="توضیحات" rows="2" />
<AppInput
id="tplDuration"
v-model="form.defaultDurationMinutes"
label="مدت پیش‌فرض (دقیقه)"
type="number"
inputmode="numeric"
ltr
/>
<label class="flex items-center gap-2 text-sm text-text">
<input v-model="form.isActive" type="checkbox" class="rounded border-border" />
فعال
</label>
<AppButton type="submit" :loading="saving">ذخیره</AppButton>
</form>
</AppModal>
</AdminLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { fetchCategories } from '@/api/business-categories';
import {
createServiceTemplate,
deleteServiceTemplate,
fetchServiceTemplates,
updateServiceTemplate,
} from '@/api/service-templates';
import type { BusinessCategory, ServiceTemplate } from '@/api/types';
import { ApiError } from '@/api/client';
import AppBadge from '@/components/ui/AppBadge.vue';
import AppButton from '@/components/ui/AppButton.vue';
import AppInput from '@/components/ui/AppInput.vue';
import AppModal from '@/components/ui/AppModal.vue';
import AppSelect from '@/components/ui/AppSelect.vue';
import AppTextarea from '@/components/ui/AppTextarea.vue';
import EmptyState from '@/components/ui/EmptyState.vue';
import PageHeader from '@/components/ui/PageHeader.vue';
import SearchInput from '@/components/ui/SearchInput.vue';
import AdminLayout from '@/layouts/AdminLayout.vue';
import { activeLabel } from '@/utils/format';
import { filterBySearch } from '@/utils/search';
import { isValidSlug, slugify } from '@/utils/slug';
const categories = ref<BusinessCategory[]>([]);
const templates = ref<ServiceTemplate[]>([]);
const selectedCategoryId = ref('');
const searchQuery = ref('');
const loading = ref(true);
const error = ref('');
const modalOpen = ref(false);
const editingId = ref<string | null>(null);
const saving = ref(false);
const slugTouched = ref(false);
const form = ref({
title: '',
slug: '',
description: '',
defaultDurationMinutes: '30',
isActive: true,
});
const categoryOptions = computed(() =>
categories.value.map((cat) => ({ value: cat.id, label: cat.title })),
);
const filteredTemplates = computed(() =>
filterBySearch(templates.value, searchQuery.value, (tpl) => [
tpl.title,
tpl.slug,
tpl.description ?? '',
]),
);
async function loadCategories() {
categories.value = await fetchCategories();
if (!selectedCategoryId.value && categories.value[0]) {
selectedCategoryId.value = categories.value[0].id;
}
}
async function loadTemplates() {
if (!selectedCategoryId.value) {
templates.value = [];
return;
}
loading.value = true;
error.value = '';
try {
templates.value = await fetchServiceTemplates(selectedCategoryId.value);
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'خطا در بارگذاری';
} finally {
loading.value = false;
}
}
watch(selectedCategoryId, loadTemplates);
function openCreate() {
if (!selectedCategoryId.value) return;
editingId.value = null;
slugTouched.value = false;
form.value = {
title: '',
slug: '',
description: '',
defaultDurationMinutes: '30',
isActive: true,
};
modalOpen.value = true;
}
function openEdit(tpl: ServiceTemplate) {
editingId.value = tpl.id;
slugTouched.value = true;
form.value = {
title: tpl.title,
slug: tpl.slug,
description: tpl.description ?? '',
defaultDurationMinutes: String(tpl.defaultDurationMinutes),
isActive: tpl.isActive,
};
modalOpen.value = true;
}
function closeModal() {
modalOpen.value = false;
}
function maybeSlug() {
if (!slugTouched.value && !editingId.value) {
form.value.slug = slugify(form.value.title);
}
}
async function onSave() {
if (!selectedCategoryId.value) return;
if (!isValidSlug(form.value.slug)) {
error.value = 'اسلاگ فقط حروف کوچک انگلیسی و خط تیره باشد.';
return;
}
const duration = Number.parseInt(form.value.defaultDurationMinutes, 10);
if (!Number.isFinite(duration) || duration < 1) {
error.value = 'مدت پیش‌فرض معتبر نیست.';
return;
}
saving.value = true;
error.value = '';
try {
const payload = {
title: form.value.title.trim(),
slug: form.value.slug.trim(),
description: form.value.description.trim() || undefined,
defaultDurationMinutes: duration,
isActive: form.value.isActive,
};
if (editingId.value) {
await updateServiceTemplate(editingId.value, payload);
} else {
await createServiceTemplate({
...payload,
categoryId: selectedCategoryId.value,
});
}
closeModal();
await loadTemplates();
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'خطا در ذخیره';
} finally {
saving.value = false;
}
}
async function onDelete(tpl: ServiceTemplate) {
if (!confirm(`قالب «${tpl.title}» حذف شود؟`)) return;
error.value = '';
try {
await deleteServiceTemplate(tpl.id);
await loadTemplates();
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'حذف ممکن نیست';
}
}
onMounted(async () => {
loading.value = true;
try {
await loadCategories();
await loadTemplates();
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'خطا در بارگذاری';
} finally {
loading.value = false;
}
});
</script>