Add service templates admin UI per business category
This commit is contained in:
@@ -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',
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user