2026-05-24 11:41:13 +03:30
|
|
|
<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>
|
|
|
|
|
<AppBadge class="mt-2" :tone="tpl.isActive ? 'success' : 'warning'">
|
|
|
|
|
{{ activeLabel(tpl.isActive) }}
|
|
|
|
|
</AppBadge>
|
|
|
|
|
</div>
|
2026-06-02 11:35:29 +03:30
|
|
|
<div class="flex shrink-0 items-center gap-1">
|
|
|
|
|
<AppIconButton icon="pencil" label="ویرایش" @click="openEdit(tpl)" />
|
|
|
|
|
<AppIconButton icon="trash" label="حذف" variant="danger" @click="onDelete(tpl)" />
|
2026-05-24 11:41:13 +03:30
|
|
|
</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
|
2026-05-24 15:58:58 +03:30
|
|
|
placeholder="اختیاری — از عنوان ساخته میشود"
|
2026-05-24 11:41:13 +03:30
|
|
|
/>
|
|
|
|
|
<AppTextarea id="tplDesc" v-model="form.description" label="توضیحات" rows="2" />
|
|
|
|
|
<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';
|
2026-06-02 11:35:29 +03:30
|
|
|
import AppIconButton from '@/components/ui/AppIconButton.vue';
|
2026-05-24 11:41:13 +03:30
|
|
|
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';
|
2026-05-24 15:58:58 +03:30
|
|
|
import { isValidSlug, optionalSlug, slugify } from '@/utils/slug';
|
2026-05-24 11:41:13 +03:30
|
|
|
|
|
|
|
|
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: '',
|
|
|
|
|
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: '',
|
|
|
|
|
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 ?? '',
|
|
|
|
|
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;
|
2026-05-24 15:58:58 +03:30
|
|
|
const slug = optionalSlug(form.value.slug);
|
|
|
|
|
if (slug && !isValidSlug(slug)) {
|
2026-05-24 11:41:13 +03:30
|
|
|
error.value = 'اسلاگ فقط حروف کوچک انگلیسی و خط تیره باشد.';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
saving.value = true;
|
|
|
|
|
error.value = '';
|
|
|
|
|
try {
|
|
|
|
|
const payload = {
|
|
|
|
|
title: form.value.title.trim(),
|
2026-05-24 15:58:58 +03:30
|
|
|
...(slug ? { slug } : {}),
|
2026-05-24 11:41:13 +03:30
|
|
|
description: form.value.description.trim() || undefined,
|
|
|
|
|
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>
|