Files
SadmMontime/src/views/ServiceTemplatesView.vue
T

267 lines
7.8 KiB
Vue
Raw Normal View History

<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)" />
</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="اختیاری — از عنوان ساخته می‌شود"
/>
<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';
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';
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)) {
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 } : {}),
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>