Complete super admin panel with CRUD, bookings, profile, and dev API proxy.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-21 14:20:52 +03:30
co-authored by Cursor
parent 03f3994121
commit 4bc6c003f3
31 changed files with 1508 additions and 43 deletions
+151
View File
@@ -0,0 +1,151 @@
<template>
<AdminLayout>
<PageHeader title="رزروها" subtitle="مشاهده و حذف رزروها (قبل از حذف کسب‌وکار)" />
<div class="mt-4 grid grid-cols-2 gap-2">
<AppSelect
id="filterBiz"
v-model="filterBusinessId"
placeholder="همه کسب‌وکارها"
:options="businessFilterOptions"
/>
<AppSelect id="filterStatus" v-model="filterStatus" :options="statusFilterOptions" />
</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="bookings.length === 0" class="mt-4">
رزروی ثبت نشده است.
</EmptyState>
<ul v-else class="mt-4 space-y-2">
<li
v-for="b in bookings"
:key="b.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">
{{ b.business?.title ?? 'کسب‌وکار' }}
</p>
<p class="mt-0.5 text-xs text-muted">
{{ serviceTitle(b) }} — {{ formatDate(b.scheduledAt) }}
</p>
<p class="mt-1 text-xs text-muted" dir="ltr">
{{ b.customer?.mobile ?? b.customerId }}
</p>
<AppBadge class="mt-2" :tone="statusTone(b.status)">
{{ statusLabel(b.status) }}
</AppBadge>
</div>
<AppButton variant="danger" class="!w-auto shrink-0 px-3" @click="onDelete(b)">
حذف
</AppButton>
</div>
</li>
</ul>
</AdminLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { deleteBooking, fetchBookings } from '@/api/bookings';
import { fetchBusinesses } from '@/api/businesses';
import type { Booking, BookingStatus, Business } from '@/api/types';
import { ApiError } from '@/api/client';
import AppBadge from '@/components/ui/AppBadge.vue';
import AppButton from '@/components/ui/AppButton.vue';
import AppSelect from '@/components/ui/AppSelect.vue';
import EmptyState from '@/components/ui/EmptyState.vue';
import PageHeader from '@/components/ui/PageHeader.vue';
import AdminLayout from '@/layouts/AdminLayout.vue';
import { formatDate } from '@/utils/format';
const bookings = ref<Booking[]>([]);
const businesses = ref<Business[]>([]);
const loading = ref(true);
const error = ref('');
const filterBusinessId = ref('');
const filterStatus = ref('all');
const businessFilterOptions = computed(() => [
{ value: '', label: 'همه کسب‌وکارها' },
...businesses.value.map((b) => ({ value: b.id, label: b.title })),
]);
const statusFilterOptions = [
{ value: 'all', label: 'همه وضعیت‌ها' },
{ value: 'pending', label: 'در انتظار' },
{ value: 'confirmed', label: 'تأیید شده' },
{ value: 'completed', label: 'انجام شده' },
{ value: 'cancelled', label: 'لغو شده' },
];
function statusLabel(status: BookingStatus): string {
const map: Record<BookingStatus, string> = {
pending: 'در انتظار',
confirmed: 'تأیید شده',
completed: 'انجام شده',
cancelled: 'لغو شده',
};
return map[status] ?? status;
}
function statusTone(status: BookingStatus): 'default' | 'success' | 'warning' | 'danger' {
if (status === 'confirmed' || status === 'completed') return 'success';
if (status === 'pending') return 'warning';
if (status === 'cancelled') return 'danger';
return 'default';
}
function serviceTitle(b: Booking): string {
return b.businessService?.serviceTemplate?.title ?? 'خدمت';
}
async function load() {
loading.value = true;
error.value = '';
try {
const params: { businessId?: string; status?: BookingStatus } = {};
if (filterBusinessId.value) params.businessId = filterBusinessId.value;
if (filterStatus.value !== 'all') {
params.status = filterStatus.value as BookingStatus;
}
bookings.value = await fetchBookings(params);
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'خطا در بارگذاری';
} finally {
loading.value = false;
}
}
async function onDelete(b: Booking) {
if (!confirm('این رزرو حذف شود؟')) return;
error.value = '';
try {
await deleteBooking(b.id);
await load();
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'خطا در حذف';
}
}
watch([filterBusinessId, filterStatus], () => {
if (!loading.value) load();
});
onMounted(async () => {
try {
businesses.value = await fetchBusinesses();
} catch {
businesses.value = [];
}
await load();
});
</script>
+368 -5
View File
@@ -1,14 +1,377 @@
<template>
<AdminLayout>
<h1 class="text-xl font-bold text-text">کسب‌وکارها</h1>
<div
class="mt-4 rounded-2xl border border-dashed border-border bg-surface-2 p-6 text-center text-sm text-muted"
>
به‌زودی — CRUD و واگذاری مدیر کسب‌وکار
<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>
+180
View File
@@ -0,0 +1,180 @@
<template>
<AdminLayout>
<PageHeader title="دسته‌های کسب‌وکار" subtitle="دسته‌بندی خدمات">
<template #actions>
<AppButton class="!w-auto shrink-0 px-4" @click="openCreate">جدید</AppButton>
</template>
</PageHeader>
<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="categories.length === 0" class="mt-4">
دسته‌ای تعریف نشده. یک دسته جدید بسازید.
</EmptyState>
<ul v-else class="mt-4 space-y-2">
<li
v-for="cat in categories"
:key="cat.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">{{ cat.title }}</p>
<p class="mt-0.5 text-xs text-muted" dir="ltr">{{ cat.slug }}</p>
<AppBadge class="mt-2" :tone="cat.isActive ? 'success' : 'warning'">
{{ activeLabel(cat.isActive) }}
</AppBadge>
</div>
<div class="flex shrink-0 flex-col gap-1">
<AppButton variant="ghost" class="!w-auto px-3" @click="openEdit(cat)">ویرایش</AppButton>
<AppButton variant="danger" class="!w-auto px-3" @click="onDelete(cat)">حذف</AppButton>
</div>
</div>
</li>
</ul>
<AppModal
:open="modalOpen"
:title="editingId ? 'ویرایش دسته' : 'دسته جدید'"
@close="closeModal"
>
<form class="space-y-4" @submit.prevent="onSave">
<AppInput id="catTitle" v-model="form.title" label="عنوان" required @blur="maybeSlug" />
<AppInput
id="catSlug"
v-model="form.slug"
label="اسلاگ (انگلیسی)"
ltr
placeholder="car-wash"
required
/>
<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 { onMounted, ref } from 'vue';
import {
createCategory,
deleteCategory,
fetchCategories,
updateCategory,
} from '@/api/business-categories';
import type { BusinessCategory } 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 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 { isValidSlug, slugify } from '@/utils/slug';
const categories = ref<BusinessCategory[]>([]);
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: '', isActive: true });
async function load() {
loading.value = true;
error.value = '';
try {
categories.value = await fetchCategories();
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'خطا در بارگذاری';
} finally {
loading.value = false;
}
}
function openCreate() {
editingId.value = null;
slugTouched.value = false;
form.value = { title: '', slug: '', isActive: true };
modalOpen.value = true;
}
function openEdit(cat: BusinessCategory) {
editingId.value = cat.id;
slugTouched.value = true;
form.value = { title: cat.title, slug: cat.slug, isActive: cat.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 (!isValidSlug(form.value.slug)) {
error.value = 'اسلاگ فقط حروف کوچک انگلیسی و خط تیره باشد.';
return;
}
saving.value = true;
error.value = '';
try {
if (editingId.value) {
await updateCategory(editingId.value, {
title: form.value.title.trim(),
slug: form.value.slug.trim(),
isActive: form.value.isActive,
});
} else {
await createCategory({
title: form.value.title.trim(),
slug: form.value.slug.trim(),
isActive: form.value.isActive,
});
}
closeModal();
await load();
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'خطا در ذخیره';
} finally {
saving.value = false;
}
}
async function onDelete(cat: BusinessCategory) {
if (
!confirm(
`دسته «${cat.title}» و همه کسب‌وکارهای آن حذف شوند؟ این کار قابل بازگشت نیست.`,
)
) {
return;
}
error.value = '';
try {
await deleteCategory(cat.id);
await load();
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'حذف ممکن نیست (شاید کسب‌وکار دارد)';
}
}
onMounted(load);
</script>
+73 -11
View File
@@ -1,20 +1,82 @@
<template>
<AdminLayout>
<h1 class="text-xl font-bold text-text">داشبورد</h1>
<p class="mt-2 text-sm text-muted">سوپر ادمین — مون‌تایم</p>
<div class="mt-6 grid gap-3 sm:grid-cols-2">
<div class="rounded-2xl border border-border bg-surface-2 p-4">
<p class="text-xs text-muted">کسب‌وکارها</p>
<p class="mt-1 text-2xl font-bold text-text">—</p>
</div>
<div class="rounded-2xl border border-border bg-surface-2 p-4">
<p class="text-xs text-muted">کاربران</p>
<p class="mt-1 text-2xl font-bold text-text">—</p>
</div>
<PageHeader title="داشبورد" subtitle="خلاصه وضعیت سیستم" />
<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-6 text-sm text-muted">در حال بارگذاری…</div>
<div v-else class="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<RouterLink
v-for="card in cards"
:key="card.label"
:to="card.to"
class="rounded-2xl border border-border bg-surface-2 p-4 transition hover:border-brand-500/50"
>
<p class="text-xs text-muted">{{ card.label }}</p>
<p class="mt-1 text-2xl font-bold text-text">{{ card.value }}</p>
</RouterLink>
</div>
<div v-if="auth.user" class="mt-6 rounded-2xl border border-border bg-surface-2 p-4 text-sm">
<p class="text-xs text-muted">ورود شما</p>
<p class="mt-1 font-medium" dir="ltr">{{ auth.user.mobile }}</p>
<p class="mt-1 text-muted">{{ auth.user.fullName ?? 'بدون نام' }}</p>
</div>
</AdminLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { fetchBookings } from '@/api/bookings';
import { fetchCategories } from '@/api/business-categories';
import { fetchBusinesses } from '@/api/businesses';
import { fetchUsers } from '@/api/users';
import { ApiError } from '@/api/client';
import PageHeader from '@/components/ui/PageHeader.vue';
import AdminLayout from '@/layouts/AdminLayout.vue';
import { useAuthStore } from '@/stores/auth';
const auth = useAuthStore();
const loading = ref(true);
const error = ref('');
const counts = ref({
businesses: 0,
activeBusinesses: 0,
bookings: 0,
users: 0,
categories: 0,
});
const cards = computed(() => [
{ label: 'کسب‌وکارها', value: counts.value.businesses, to: '/businesses' },
{ label: 'کسب‌وکار فعال', value: counts.value.activeBusinesses, to: '/businesses' },
{ label: 'رزروها', value: counts.value.bookings, to: '/bookings' },
{ label: 'کاربران', value: counts.value.users, to: '/users' },
{ label: 'دسته‌ها', value: counts.value.categories, to: '/categories' },
]);
onMounted(async () => {
try {
const [businesses, users, categories, bookingList] = await Promise.all([
fetchBusinesses(),
fetchUsers(),
fetchCategories(),
fetchBookings().catch(() => []),
]);
counts.value = {
businesses: businesses.length,
activeBusinesses: businesses.filter((b) => b.isActive).length,
bookings: bookingList.length,
users: users.length,
categories: categories.length,
};
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'خطا در بارگذاری آمار';
} finally {
loading.value = false;
}
});
</script>
+8 -2
View File
@@ -28,6 +28,7 @@
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { requestOtp } from '@/api/auth';
import { isValidIranMobile, normalizeIranMobile } from '@/utils/mobile';
import { ApiError } from '@/api/client';
import AppButton from '@/components/ui/AppButton.vue';
import AppInput from '@/components/ui/AppInput.vue';
@@ -42,11 +43,16 @@ async function onSubmit() {
error.value = '';
loading.value = true;
try {
const res = await requestOtp(mobile.value.trim());
const normalized = normalizeIranMobile(mobile.value);
if (!isValidIranMobile(normalized)) {
error.value = 'شماره موبایل معتبر نیست. مثال: 09121234567';
return;
}
const res = await requestOtp(normalized);
await router.push({
name: 'verify',
query: {
mobile: mobile.value.trim(),
mobile: normalized,
expires: String(res.expiresInSeconds),
...(res.otpCode ? { devCode: res.otpCode } : {}),
},
+78
View File
@@ -0,0 +1,78 @@
<template>
<AdminLayout>
<PageHeader title="پروفایل" subtitle="اطلاعات حساب سوپر ادمین" />
<p
v-if="message"
class="mt-4 rounded-xl border border-brand-500/40 bg-brand-500/10 px-4 py-3 text-sm text-brand-700 dark:text-brand-500"
>
{{ message }}
</p>
<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>
<form v-if="auth.user" class="mt-6 space-y-4" @submit.prevent="onSave">
<div class="rounded-2xl border border-border bg-surface-2 p-4">
<p class="text-xs text-muted">موبایل</p>
<p class="mt-1 font-medium" dir="ltr">{{ auth.user.mobile }}</p>
</div>
<div class="rounded-2xl border border-border bg-surface-2 p-4">
<p class="text-xs text-muted">نقش</p>
<p class="mt-1 font-medium">{{ roleLabel(auth.user.role) }}</p>
</div>
<AppInput id="fullName" v-model="fullName" label="نام نمایشی" placeholder="نام شما" />
<AppButton type="submit" :loading="saving">ذخیره تغییرات</AppButton>
</form>
</AdminLayout>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { updateUser } from '@/api/users';
import { ApiError } from '@/api/client';
import AppButton from '@/components/ui/AppButton.vue';
import AppInput from '@/components/ui/AppInput.vue';
import PageHeader from '@/components/ui/PageHeader.vue';
import AdminLayout from '@/layouts/AdminLayout.vue';
import { useAuthStore } from '@/stores/auth';
import { roleLabel } from '@/utils/format';
const auth = useAuthStore();
const fullName = ref('');
const saving = ref(false);
const error = ref('');
const message = ref('');
watch(
() => auth.user,
(u) => {
fullName.value = u?.fullName ?? '';
},
{ immediate: true },
);
async function onSave() {
if (!auth.user) return;
error.value = '';
message.value = '';
saving.value = true;
try {
const updated = await updateUser(auth.user.id, {
fullName: fullName.value.trim() || null,
});
auth.setUser(updated);
message.value = 'پروفایل ذخیره شد.';
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'خطا در ذخیره';
} finally {
saving.value = false;
}
}
</script>
+132 -6
View File
@@ -1,14 +1,140 @@
<template>
<AdminLayout>
<h1 class="text-xl font-bold text-text">کاربران</h1>
<div
class="mt-4 rounded-2xl border border-dashed border-border bg-surface-2 p-6 text-center text-sm text-muted"
>
به‌زودی
</div>
<PageHeader title="کاربران" subtitle="مدیریت نقش و وضعیت کاربران" />
<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="users.length === 0" class="mt-4">کاربری ثبت نشده است.</EmptyState>
<ul v-else class="mt-4 space-y-2">
<li
v-for="user in users"
:key="user.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">{{ user.fullName ?? 'بدون نام' }}</p>
<p class="mt-0.5 text-sm text-muted" dir="ltr">{{ user.mobile }}</p>
<div class="mt-2 flex flex-wrap gap-2">
<AppBadge>{{ roleLabel(user.role) }}</AppBadge>
<AppBadge :tone="user.isActive ? 'success' : 'danger'">
{{ activeLabel(user.isActive) }}
</AppBadge>
</div>
</div>
<AppButton variant="ghost" class="!w-auto shrink-0 px-3" @click="openEdit(user)">
ویرایش
</AppButton>
</div>
</li>
</ul>
<AppModal :open="!!editing" title="ویرایش کاربر" @close="closeEdit">
<form v-if="editing" class="space-y-4" @submit.prevent="onSave">
<p class="text-sm text-muted" dir="ltr">{{ editing.mobile }}</p>
<AppInput id="userFullName" v-model="form.fullName" label="نام" />
<AppSelect
id="userRole"
v-model="form.role"
label="نقش"
:options="roleOptions"
/>
<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 { onMounted, ref } from 'vue';
import { fetchUsers, updateUser } from '@/api/users';
import type { User, UserRole } 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 EmptyState from '@/components/ui/EmptyState.vue';
import PageHeader from '@/components/ui/PageHeader.vue';
import AdminLayout from '@/layouts/AdminLayout.vue';
import { activeLabel, roleLabel } from '@/utils/format';
const users = ref<User[]>([]);
const loading = ref(true);
const error = ref('');
const editing = ref<User | null>(null);
const saving = ref(false);
const form = ref({
fullName: '',
role: 'customer' as UserRole,
isActive: true,
});
const roleOptions = [
{ value: 'customer', label: 'مشتری' },
{ value: 'business_owner', label: 'صاحب کسب‌وکار' },
{ value: 'admin', label: 'سوپر ادمین' },
];
async function load() {
loading.value = true;
error.value = '';
try {
users.value = await fetchUsers();
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'خطا در بارگذاری';
} finally {
loading.value = false;
}
}
function openEdit(user: User) {
editing.value = user;
form.value = {
fullName: user.fullName ?? '',
role: user.role,
isActive: user.isActive,
};
}
function closeEdit() {
editing.value = null;
}
async function onSave() {
if (!editing.value) return;
saving.value = true;
error.value = '';
try {
const updated = await updateUser(editing.value.id, {
fullName: form.value.fullName.trim() || null,
role: form.value.role,
isActive: form.value.isActive,
});
const idx = users.value.findIndex((u) => u.id === updated.id);
if (idx >= 0) users.value[idx] = updated;
closeEdit();
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'خطا در ذخیره';
} finally {
saving.value = false;
}
}
onMounted(load);
</script>