378 lines
12 KiB
Vue
378 lines
12 KiB
Vue
<template>
|
|
<AdminLayout>
|
|
<PageHeader title="کسبوکارها" subtitle="ایجاد، ویرایش و واگذاری مدیر">
|
|
<template #actions>
|
|
<AppButton class="!w-auto shrink-0 px-4" @click="openCreate">جدید</AppButton>
|
|
</template>
|
|
</PageHeader>
|
|
|
|
<div class="mt-4 grid grid-cols-2 gap-2">
|
|
<AppSelect
|
|
id="filterCat"
|
|
v-model="filterCategoryId"
|
|
placeholder="همه دستهها"
|
|
:options="categoryFilterOptions"
|
|
/>
|
|
<AppSelect
|
|
id="filterActive"
|
|
v-model="filterActive"
|
|
:options="activeFilterOptions"
|
|
/>
|
|
</div>
|
|
|
|
<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="businesses.length === 0" class="mt-4">
|
|
کسبوکاری یافت نشد.
|
|
</EmptyState>
|
|
|
|
<ul v-else class="mt-4 space-y-2">
|
|
<li
|
|
v-for="biz in businesses"
|
|
:key="biz.id"
|
|
class="rounded-2xl border border-border bg-surface-2 p-4"
|
|
>
|
|
<div class="flex items-start justify-between gap-2">
|
|
<div class="min-w-0 flex-1">
|
|
<p class="font-semibold text-text">{{ biz.title }}</p>
|
|
<p class="mt-0.5 text-xs text-muted" dir="ltr">{{ biz.slug }}</p>
|
|
<p v-if="biz.category" class="mt-1 text-xs text-muted">{{ biz.category.title }}</p>
|
|
<div class="mt-2 flex flex-wrap gap-2">
|
|
<AppBadge :tone="biz.isActive ? 'success' : 'warning'">
|
|
{{ activeLabel(biz.isActive) }}
|
|
</AppBadge>
|
|
<AppBadge v-if="ownerLabel(biz)" tone="default">{{ ownerLabel(biz) }}</AppBadge>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="mt-3 grid grid-cols-3 gap-1">
|
|
<AppButton variant="ghost" class="!w-auto px-2 text-xs" @click="openEdit(biz)">
|
|
ویرایش
|
|
</AppButton>
|
|
<AppButton variant="ghost" class="!w-auto px-2 text-xs" @click="openAssign(biz)">
|
|
مدیر
|
|
</AppButton>
|
|
<AppButton variant="danger" class="!w-auto px-2 text-xs" @click="onDelete(biz)">
|
|
حذف
|
|
</AppButton>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
|
|
<!-- Create / Edit -->
|
|
<AppModal
|
|
:open="formOpen"
|
|
:title="editingId ? 'ویرایش کسبوکار' : 'کسبوکار جدید'"
|
|
@close="closeForm"
|
|
>
|
|
<form class="space-y-4" @submit.prevent="onSaveBusiness">
|
|
<AppInput id="bizTitle" v-model="form.title" label="عنوان" required @blur="maybeSlug" />
|
|
<AppInput id="bizSlug" v-model="form.slug" label="اسلاگ" ltr required />
|
|
<AppSelect
|
|
id="bizCategory"
|
|
v-model="form.categoryId"
|
|
label="دسته"
|
|
placeholder="انتخاب دسته"
|
|
:options="categoryOptions"
|
|
/>
|
|
<AppTextarea id="bizDesc" v-model="form.description" label="توضیحات" />
|
|
<AppInput id="bizPhone" v-model="form.contactPhone" label="تلفن" ltr inputmode="tel" />
|
|
<AppInput id="bizAddress" v-model="form.address" label="آدرس" />
|
|
<div class="grid grid-cols-2 gap-3">
|
|
<AppInput id="bizLat" v-model="form.latitude" label="عرض جغرافیایی" ltr type="text" />
|
|
<AppInput id="bizLng" v-model="form.longitude" label="طول جغرافیایی" ltr type="text" />
|
|
</div>
|
|
<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>
|
|
|
|
<!-- Assign owner -->
|
|
<AppModal
|
|
:open="assignOpen"
|
|
title="واگذاری مدیر کسبوکار"
|
|
subtitle="کاربر با این موبایل ساخته یا به نقش صاحب کسبوکار ارتقا مییابد"
|
|
@close="assignOpen = false"
|
|
>
|
|
<form v-if="assignTarget" class="space-y-4" @submit.prevent="onAssign">
|
|
<p class="text-sm font-medium text-text">{{ assignTarget.title }}</p>
|
|
<AppInput
|
|
id="ownerMobile"
|
|
v-model="assignForm.mobile"
|
|
label="موبایل مدیر"
|
|
ltr
|
|
inputmode="tel"
|
|
placeholder="09121234567"
|
|
required
|
|
/>
|
|
<AppInput id="ownerName" v-model="assignForm.fullName" 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 {
|
|
assignBusinessOwner,
|
|
createBusiness,
|
|
deleteBusiness,
|
|
fetchBusinesses,
|
|
updateBusiness,
|
|
} from '@/api/businesses';
|
|
import { fetchUsers } from '@/api/users';
|
|
import type { Business, BusinessCategory, User } 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 AdminLayout from '@/layouts/AdminLayout.vue';
|
|
import { activeLabel } from '@/utils/format';
|
|
import { isValidIranMobile, normalizeIranMobile } from '@/utils/mobile';
|
|
import { isValidSlug, slugify } from '@/utils/slug';
|
|
|
|
const businesses = ref<Business[]>([]);
|
|
const categories = ref<BusinessCategory[]>([]);
|
|
const usersById = ref<Record<string, User>>({});
|
|
const loading = ref(true);
|
|
const error = ref('');
|
|
const saving = ref(false);
|
|
|
|
const filterCategoryId = ref('');
|
|
const filterActive = ref('all');
|
|
|
|
const formOpen = ref(false);
|
|
const editingId = ref<string | null>(null);
|
|
const slugTouched = ref(false);
|
|
|
|
const assignOpen = ref(false);
|
|
const assignTarget = ref<Business | null>(null);
|
|
|
|
const form = ref({
|
|
title: '',
|
|
slug: '',
|
|
categoryId: '',
|
|
description: '',
|
|
contactPhone: '',
|
|
address: '',
|
|
latitude: '',
|
|
longitude: '',
|
|
isActive: true,
|
|
});
|
|
|
|
const assignForm = ref({ mobile: '', fullName: '' });
|
|
|
|
const categoryOptions = computed(() =>
|
|
categories.value.map((c) => ({ value: c.id, label: c.title })),
|
|
);
|
|
|
|
const categoryFilterOptions = computed(() => [
|
|
{ value: '', label: 'همه دستهها' },
|
|
...categoryOptions.value,
|
|
]);
|
|
|
|
const activeFilterOptions = [
|
|
{ value: 'all', label: 'همه' },
|
|
{ value: 'true', label: 'فعال' },
|
|
{ value: 'false', label: 'غیرفعال' },
|
|
];
|
|
|
|
function ownerLabel(biz: Business): string | null {
|
|
if (!biz.ownerId) return 'بدون مدیر';
|
|
const u = usersById.value[biz.ownerId];
|
|
return u ? `${u.fullName ?? u.mobile}` : `مدیر: ${biz.ownerId.slice(0, 8)}…`;
|
|
}
|
|
|
|
function parseCoord(value: string): number | undefined {
|
|
const n = parseFloat(value.trim());
|
|
return Number.isFinite(n) ? n : undefined;
|
|
}
|
|
|
|
async function load() {
|
|
loading.value = true;
|
|
error.value = '';
|
|
try {
|
|
const params: { categoryId?: string; isActive?: boolean } = {};
|
|
if (filterCategoryId.value) params.categoryId = filterCategoryId.value;
|
|
if (filterActive.value !== 'all') params.isActive = filterActive.value === 'true';
|
|
|
|
const [bizList, cats, userList] = await Promise.all([
|
|
fetchBusinesses(params),
|
|
fetchCategories(),
|
|
fetchUsers().catch(() => [] as User[]),
|
|
]);
|
|
businesses.value = bizList;
|
|
categories.value = cats;
|
|
usersById.value = Object.fromEntries(userList.map((u) => [u.id, u]));
|
|
} catch (e) {
|
|
error.value = e instanceof ApiError ? e.message : 'خطا در بارگذاری';
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
function openCreate() {
|
|
if (categories.value.length === 0) {
|
|
error.value = 'ابتدا یک دسته در بخش «دستهها» بسازید.';
|
|
return;
|
|
}
|
|
editingId.value = null;
|
|
slugTouched.value = false;
|
|
form.value = {
|
|
title: '',
|
|
slug: '',
|
|
categoryId: categories.value[0]?.id ?? '',
|
|
description: '',
|
|
contactPhone: '',
|
|
address: '',
|
|
latitude: '',
|
|
longitude: '',
|
|
isActive: true,
|
|
};
|
|
formOpen.value = true;
|
|
}
|
|
|
|
function openEdit(biz: Business) {
|
|
editingId.value = biz.id;
|
|
slugTouched.value = true;
|
|
form.value = {
|
|
title: biz.title,
|
|
slug: biz.slug,
|
|
categoryId: biz.categoryId,
|
|
description: biz.description ?? '',
|
|
contactPhone: biz.contactPhone ?? '',
|
|
address: biz.address ?? '',
|
|
latitude: biz.latitude != null ? String(biz.latitude) : '',
|
|
longitude: biz.longitude != null ? String(biz.longitude) : '',
|
|
isActive: biz.isActive,
|
|
};
|
|
formOpen.value = true;
|
|
}
|
|
|
|
function closeForm() {
|
|
formOpen.value = false;
|
|
}
|
|
|
|
function maybeSlug() {
|
|
if (!slugTouched.value && !editingId.value) {
|
|
form.value.slug = slugify(form.value.title);
|
|
}
|
|
}
|
|
|
|
function openAssign(biz: Business) {
|
|
assignTarget.value = biz;
|
|
assignForm.value = { mobile: '', fullName: '' };
|
|
assignOpen.value = true;
|
|
}
|
|
|
|
async function onSaveBusiness() {
|
|
if (!isValidSlug(form.value.slug)) {
|
|
error.value = 'اسلاگ نامعتبر است.';
|
|
return;
|
|
}
|
|
if (!form.value.categoryId) {
|
|
error.value = 'دسته را انتخاب کنید.';
|
|
return;
|
|
}
|
|
|
|
saving.value = true;
|
|
error.value = '';
|
|
const lat = parseCoord(form.value.latitude);
|
|
const lng = parseCoord(form.value.longitude);
|
|
|
|
try {
|
|
if (editingId.value) {
|
|
await updateBusiness(editingId.value, {
|
|
title: form.value.title.trim(),
|
|
slug: form.value.slug.trim(),
|
|
categoryId: form.value.categoryId,
|
|
description: form.value.description.trim() || null,
|
|
contactPhone: form.value.contactPhone.trim() || null,
|
|
address: form.value.address.trim() || null,
|
|
latitude: lat ?? null,
|
|
longitude: lng ?? null,
|
|
isActive: form.value.isActive,
|
|
});
|
|
} else {
|
|
await createBusiness({
|
|
title: form.value.title.trim(),
|
|
slug: form.value.slug.trim(),
|
|
categoryId: form.value.categoryId,
|
|
description: form.value.description.trim() || undefined,
|
|
contactPhone: form.value.contactPhone.trim() || undefined,
|
|
address: form.value.address.trim() || undefined,
|
|
latitude: lat,
|
|
longitude: lng,
|
|
isActive: form.value.isActive,
|
|
});
|
|
}
|
|
closeForm();
|
|
await load();
|
|
} catch (e) {
|
|
error.value = e instanceof ApiError ? e.message : 'خطا در ذخیره';
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
async function onAssign() {
|
|
if (!assignTarget.value) return;
|
|
const mobile = normalizeIranMobile(assignForm.value.mobile);
|
|
if (!isValidIranMobile(mobile)) {
|
|
error.value = 'شماره موبایل مدیر معتبر نیست.';
|
|
return;
|
|
}
|
|
saving.value = true;
|
|
error.value = '';
|
|
try {
|
|
await assignBusinessOwner(assignTarget.value.id, {
|
|
mobile,
|
|
fullName: assignForm.value.fullName.trim() || null,
|
|
});
|
|
assignOpen.value = false;
|
|
await load();
|
|
} catch (e) {
|
|
error.value = e instanceof ApiError ? e.message : 'خطا در واگذاری';
|
|
} finally {
|
|
saving.value = false;
|
|
}
|
|
}
|
|
|
|
async function onDelete(biz: Business) {
|
|
if (
|
|
!confirm(
|
|
`«${biz.title}» و رزروهای مرتبط حذف شوند؟ این کار قابل بازگشت نیست.`,
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
error.value = '';
|
|
try {
|
|
await deleteBusiness(biz.id);
|
|
await load();
|
|
} catch (e) {
|
|
error.value = e instanceof ApiError ? e.message : 'خطا در حذف';
|
|
}
|
|
}
|
|
|
|
watch([filterCategoryId, filterActive], () => {
|
|
if (!loading.value) load();
|
|
});
|
|
|
|
onMounted(load);
|
|
</script>
|