Add list search, compact business cards, registration approval queue, and Montime branding.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,7 +2,13 @@
|
||||
<AdminLayout>
|
||||
<PageHeader title="رزروها" subtitle="مشاهده و حذف رزروها (قبل از حذف کسبوکار)" />
|
||||
|
||||
<div class="mt-4 grid grid-cols-2 gap-2">
|
||||
<SearchInput
|
||||
v-model="searchQuery"
|
||||
class="mt-4 block"
|
||||
placeholder="جستجو در کسبوکار، مشتری، خدمت…"
|
||||
/>
|
||||
|
||||
<div class="mt-3 grid grid-cols-2 gap-2">
|
||||
<AppSelect
|
||||
id="filterBiz"
|
||||
v-model="filterBusinessId"
|
||||
@@ -18,13 +24,17 @@
|
||||
|
||||
<div v-if="loading" class="mt-4 text-sm text-muted">در حال بارگذاری…</div>
|
||||
|
||||
<EmptyState v-else-if="bookings.length === 0" class="mt-4">
|
||||
رزروی ثبت نشده است.
|
||||
<EmptyState v-else-if="filteredBookings.length === 0" class="mt-4">
|
||||
{{
|
||||
searchQuery.trim()
|
||||
? 'نتیجهای برای جستجوی شما یافت نشد.'
|
||||
: 'رزروی ثبت نشده است.'
|
||||
}}
|
||||
</EmptyState>
|
||||
|
||||
<ul v-else class="mt-4 space-y-2">
|
||||
<li
|
||||
v-for="b in bookings"
|
||||
v-for="b in filteredBookings"
|
||||
:key="b.id"
|
||||
class="rounded-2xl border border-border bg-surface-2 p-4"
|
||||
>
|
||||
@@ -63,10 +73,13 @@ 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 SearchInput from '@/components/ui/SearchInput.vue';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import { formatDate } from '@/utils/format';
|
||||
import { filterBySearch } from '@/utils/search';
|
||||
|
||||
const bookings = ref<Booking[]>([]);
|
||||
const searchQuery = ref('');
|
||||
const businesses = ref<Business[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
@@ -108,6 +121,17 @@ function serviceTitle(b: Booking): string {
|
||||
return b.businessService?.serviceTemplate?.title ?? 'خدمت';
|
||||
}
|
||||
|
||||
const filteredBookings = computed(() =>
|
||||
filterBySearch(bookings.value, searchQuery.value, (b) => [
|
||||
b.business?.title,
|
||||
b.customer?.mobile,
|
||||
b.customer?.fullName,
|
||||
serviceTitle(b),
|
||||
statusLabel(b.status),
|
||||
b.note,
|
||||
]),
|
||||
);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
<template>
|
||||
<AdminLayout>
|
||||
<PageHeader
|
||||
title="درخواستهای ثبت کسبوکار"
|
||||
subtitle="بررسی و تأیید درخواست کاربران"
|
||||
/>
|
||||
|
||||
<div class="mt-4 flex gap-2">
|
||||
<AppButton
|
||||
variant="ghost"
|
||||
class="!w-auto px-3"
|
||||
:class="filterStatus === 'pending' ? 'bg-brand-500/15' : ''"
|
||||
@click="setFilter('pending')"
|
||||
>
|
||||
در انتظار
|
||||
</AppButton>
|
||||
<AppButton
|
||||
variant="ghost"
|
||||
class="!w-auto px-3"
|
||||
:class="filterStatus === 'all' ? 'bg-brand-500/15' : ''"
|
||||
@click="setFilter('all')"
|
||||
>
|
||||
همه
|
||||
</AppButton>
|
||||
</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="requests.length === 0" class="mt-4">
|
||||
درخواستی یافت نشد.
|
||||
</EmptyState>
|
||||
|
||||
<ul v-else class="mt-4 space-y-2">
|
||||
<li
|
||||
v-for="req in requests"
|
||||
:key="req.id"
|
||||
class="rounded-xl border border-border bg-surface-2 p-3"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-semibold text-text">{{ req.title }}</p>
|
||||
<p class="mt-0.5 text-xs text-muted">{{ req.category?.title ?? '—' }}</p>
|
||||
<p class="mt-1 text-xs text-muted" dir="ltr">
|
||||
{{ req.applicant?.fullName ?? 'بدون نام' }} — {{ req.applicant?.mobile }}
|
||||
</p>
|
||||
<p v-if="req.address" class="mt-1 truncate text-xs text-muted">{{ req.address }}</p>
|
||||
<AppBadge class="mt-2" :tone="statusTone(req.status)">
|
||||
{{ statusLabel(req.status) }}
|
||||
</AppBadge>
|
||||
<p v-if="req.adminNote" class="mt-2 text-xs text-red-600">{{ req.adminNote }}</p>
|
||||
</div>
|
||||
<div v-if="req.status === 'pending'" class="flex shrink-0 flex-col gap-1">
|
||||
<AppButton
|
||||
class="!w-auto px-3 text-xs"
|
||||
:loading="actingId === req.id && actingType === 'approve'"
|
||||
@click="onApprove(req)"
|
||||
>
|
||||
تأیید
|
||||
</AppButton>
|
||||
<AppButton
|
||||
variant="danger"
|
||||
class="!w-auto px-3 text-xs"
|
||||
@click="openReject(req)"
|
||||
>
|
||||
رد
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<AppModal :open="!!rejectTarget" title="رد درخواست" @close="rejectTarget = null">
|
||||
<form v-if="rejectTarget" class="space-y-4" @submit.prevent="onReject">
|
||||
<p class="text-sm text-text">{{ rejectTarget.title }}</p>
|
||||
<AppTextarea id="rejectNote" v-model="rejectNote" label="دلیل (اختیاری)" />
|
||||
<AppButton type="submit" variant="danger" :loading="actingId === rejectTarget.id">
|
||||
رد درخواست
|
||||
</AppButton>
|
||||
</form>
|
||||
</AppModal>
|
||||
</AdminLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import {
|
||||
approveBusinessRegistration,
|
||||
fetchBusinessRegistrationRequests,
|
||||
rejectBusinessRegistration,
|
||||
} from '@/api/business-registrations';
|
||||
import type { BusinessRegistrationRequest, BusinessRegistrationStatus } from '@/api/types';
|
||||
import { ApiError } from '@/api/client';
|
||||
import AppBadge from '@/components/ui/AppBadge.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppModal from '@/components/ui/AppModal.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';
|
||||
|
||||
const requests = ref<BusinessRegistrationRequest[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const filterStatus = ref<'pending' | 'all'>('pending');
|
||||
const actingId = ref<string | null>(null);
|
||||
const actingType = ref<'approve' | 'reject' | null>(null);
|
||||
const rejectTarget = ref<BusinessRegistrationRequest | null>(null);
|
||||
const rejectNote = ref('');
|
||||
|
||||
function statusLabel(status: BusinessRegistrationStatus): string {
|
||||
const map = {
|
||||
pending: 'در انتظار',
|
||||
approved: 'تأیید شده',
|
||||
rejected: 'رد شده',
|
||||
};
|
||||
return map[status];
|
||||
}
|
||||
|
||||
function statusTone(status: BusinessRegistrationStatus): 'default' | 'success' | 'warning' | 'danger' {
|
||||
if (status === 'approved') return 'success';
|
||||
if (status === 'pending') return 'warning';
|
||||
if (status === 'rejected') return 'danger';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
requests.value = await fetchBusinessRegistrationRequests(
|
||||
filterStatus.value === 'pending' ? 'pending' : undefined,
|
||||
);
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'خطا در بارگذاری';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setFilter(status: 'pending' | 'all') {
|
||||
filterStatus.value = status;
|
||||
load();
|
||||
}
|
||||
|
||||
async function onApprove(req: BusinessRegistrationRequest) {
|
||||
if (!confirm(`«${req.title}» تأیید شود؟ کاربر به صاحب کسبوکار ارتقا مییابد.`)) return;
|
||||
actingId.value = req.id;
|
||||
actingType.value = 'approve';
|
||||
error.value = '';
|
||||
try {
|
||||
await approveBusinessRegistration(req.id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'خطا در تأیید';
|
||||
} finally {
|
||||
actingId.value = null;
|
||||
actingType.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function openReject(req: BusinessRegistrationRequest) {
|
||||
rejectTarget.value = req;
|
||||
rejectNote.value = '';
|
||||
}
|
||||
|
||||
async function onReject() {
|
||||
if (!rejectTarget.value) return;
|
||||
actingId.value = rejectTarget.value.id;
|
||||
actingType.value = 'reject';
|
||||
error.value = '';
|
||||
try {
|
||||
await rejectBusinessRegistration(rejectTarget.value.id, rejectNote.value.trim() || undefined);
|
||||
rejectTarget.value = null;
|
||||
await load();
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : 'خطا در رد';
|
||||
} finally {
|
||||
actingId.value = null;
|
||||
actingType.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
+118
-21
@@ -6,7 +6,13 @@
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="mt-4 grid grid-cols-2 gap-2">
|
||||
<SearchInput
|
||||
v-model="searchQuery"
|
||||
class="mt-4 block"
|
||||
placeholder="جستجو در عنوان، اسلاگ، آدرس، تلفن، مدیر…"
|
||||
/>
|
||||
|
||||
<div class="mt-3 grid grid-cols-2 gap-2">
|
||||
<AppSelect
|
||||
id="filterCat"
|
||||
v-model="filterCategoryId"
|
||||
@@ -26,13 +32,17 @@
|
||||
|
||||
<div v-if="loading" class="mt-4 text-sm text-muted">در حال بارگذاری…</div>
|
||||
|
||||
<EmptyState v-else-if="businesses.length === 0" class="mt-4">
|
||||
کسبوکاری یافت نشد.
|
||||
<EmptyState v-else-if="filteredBusinesses.length === 0" class="mt-4">
|
||||
{{
|
||||
searchQuery.trim()
|
||||
? 'نتیجهای برای جستجوی شما یافت نشد.'
|
||||
: 'کسبوکاری یافت نشد.'
|
||||
}}
|
||||
</EmptyState>
|
||||
|
||||
<div v-else class="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div v-else class="mt-4 space-y-2">
|
||||
<BusinessListCard
|
||||
v-for="biz in businesses"
|
||||
v-for="biz in filteredBusinesses"
|
||||
:key="biz.id"
|
||||
:business="biz"
|
||||
:owner-text="ownerLabel(biz)"
|
||||
@@ -93,23 +103,64 @@
|
||||
<!-- Assign owner -->
|
||||
<AppModal
|
||||
:open="assignOpen"
|
||||
title="واگذاری مدیر کسبوکار"
|
||||
subtitle="کاربر با این موبایل ساخته یا به نقش صاحب کسبوکار ارتقا مییابد"
|
||||
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>
|
||||
|
||||
<div
|
||||
v-if="assignOwnerLoading"
|
||||
class="rounded-xl border border-border bg-surface-3 px-4 py-3 text-sm text-muted"
|
||||
>
|
||||
در حال بارگذاری اطلاعات مدیر…
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="assignCurrentOwner"
|
||||
class="rounded-xl border border-border bg-surface-3 p-3"
|
||||
>
|
||||
<p class="text-xs font-medium text-muted">مدیر فعلی</p>
|
||||
<p class="mt-1 font-semibold text-text">
|
||||
{{ assignCurrentOwner.fullName ?? 'بدون نام' }}
|
||||
</p>
|
||||
<p class="mt-0.5 text-sm text-muted" dir="ltr">{{ assignCurrentOwner.mobile }}</p>
|
||||
<AppBadge class="mt-2">{{ roleLabel(assignCurrentOwner.role) }}</AppBadge>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="rounded-xl border border-dashed border-border bg-surface-3 px-4 py-3 text-sm text-muted"
|
||||
>
|
||||
هنوز مدیر برای این کسبوکار تعیین نشده است.
|
||||
</div>
|
||||
|
||||
<div class="border-t border-border pt-4">
|
||||
<p class="mb-3 text-sm font-medium text-text">
|
||||
{{ assignCurrentOwner ? 'تغییر مدیر' : 'واگذاری مدیر' }}
|
||||
</p>
|
||||
<AppInput
|
||||
id="ownerMobile"
|
||||
v-model="assignForm.mobile"
|
||||
label="موبایل مدیر"
|
||||
ltr
|
||||
inputmode="tel"
|
||||
placeholder="09121234567"
|
||||
required
|
||||
/>
|
||||
<div class="mt-3">
|
||||
<AppInput
|
||||
id="ownerName"
|
||||
v-model="assignForm.fullName"
|
||||
label="نام (اختیاری)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppButton type="submit" :loading="saving">
|
||||
{{ assignCurrentOwner ? 'بهروزرسانی مدیر' : 'واگذاری مدیر' }}
|
||||
</AppButton>
|
||||
</form>
|
||||
</AppModal>
|
||||
</AdminLayout>
|
||||
@@ -128,11 +179,12 @@ import {
|
||||
updateBusiness,
|
||||
uploadBusinessImages,
|
||||
} from '@/api/businesses';
|
||||
import { fetchUsers } from '@/api/users';
|
||||
import { fetchUsers, fetchUser } from '@/api/users';
|
||||
import type { Business, BusinessCategory, User } from '@/api/types';
|
||||
import { ApiError } from '@/api/client';
|
||||
import BusinessImageUpload from '@/components/admin/BusinessImageUpload.vue';
|
||||
import BusinessListCard from '@/components/admin/BusinessListCard.vue';
|
||||
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';
|
||||
@@ -140,11 +192,15 @@ 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 { roleLabel } from '@/utils/format';
|
||||
import { isValidIranMobile, normalizeIranMobile } from '@/utils/mobile';
|
||||
import { filterBySearch } from '@/utils/search';
|
||||
import { isValidSlug, slugify } from '@/utils/slug';
|
||||
|
||||
const businesses = ref<Business[]>([]);
|
||||
const searchQuery = ref('');
|
||||
const categories = ref<BusinessCategory[]>([]);
|
||||
const usersById = ref<Record<string, User>>({});
|
||||
const loading = ref(true);
|
||||
@@ -160,6 +216,8 @@ const slugTouched = ref(false);
|
||||
|
||||
const assignOpen = ref(false);
|
||||
const assignTarget = ref<Business | null>(null);
|
||||
const assignCurrentOwner = ref<User | null>(null);
|
||||
const assignOwnerLoading = ref(false);
|
||||
const editingBusiness = ref<Business | null>(null);
|
||||
const imageUploadRef = ref<InstanceType<typeof BusinessImageUpload> | null>(null);
|
||||
const deletingImageId = ref<string | null>(null);
|
||||
@@ -199,6 +257,21 @@ function ownerLabel(biz: Business): string | null {
|
||||
return u ? `${u.fullName ?? u.mobile}` : `مدیر: ${biz.ownerId.slice(0, 8)}…`;
|
||||
}
|
||||
|
||||
const filteredBusinesses = computed(() =>
|
||||
filterBySearch(businesses.value, searchQuery.value, (biz) => {
|
||||
const cat = categories.value.find((c) => c.id === biz.categoryId);
|
||||
return [
|
||||
biz.title,
|
||||
biz.slug,
|
||||
biz.address,
|
||||
biz.contactPhone,
|
||||
biz.description,
|
||||
cat?.title,
|
||||
ownerLabel(biz),
|
||||
];
|
||||
}),
|
||||
);
|
||||
|
||||
function parseCoord(value: string): number | undefined {
|
||||
const n = parseFloat(value.trim());
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
@@ -300,10 +373,34 @@ function maybeSlug() {
|
||||
}
|
||||
}
|
||||
|
||||
function openAssign(biz: Business) {
|
||||
async function openAssign(biz: Business) {
|
||||
assignTarget.value = biz;
|
||||
assignForm.value = { mobile: '', fullName: '' };
|
||||
assignOpen.value = true;
|
||||
assignOwnerLoading.value = true;
|
||||
assignCurrentOwner.value = null;
|
||||
assignForm.value = { mobile: '', fullName: '' };
|
||||
|
||||
try {
|
||||
let owner: User | null = null;
|
||||
if (biz.ownerId) {
|
||||
owner = usersById.value[biz.ownerId] ?? null;
|
||||
if (!owner) {
|
||||
try {
|
||||
owner = await fetchUser(biz.ownerId);
|
||||
usersById.value = { ...usersById.value, [owner.id]: owner };
|
||||
} catch {
|
||||
owner = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
assignCurrentOwner.value = owner;
|
||||
assignForm.value = {
|
||||
mobile: owner?.mobile ?? '',
|
||||
fullName: owner?.fullName ?? '',
|
||||
};
|
||||
} finally {
|
||||
assignOwnerLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSaveBusiness() {
|
||||
|
||||
@@ -6,19 +6,29 @@
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<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="categories.length === 0" class="mt-4">
|
||||
دستهای تعریف نشده. یک دسته جدید بسازید.
|
||||
<EmptyState v-else-if="filteredCategories.length === 0" class="mt-4">
|
||||
{{
|
||||
searchQuery.trim()
|
||||
? 'نتیجهای برای جستجوی شما یافت نشد.'
|
||||
: 'دستهای تعریف نشده. یک دسته جدید بسازید.'
|
||||
}}
|
||||
</EmptyState>
|
||||
|
||||
<ul v-else class="mt-4 space-y-2">
|
||||
<li
|
||||
v-for="cat in categories"
|
||||
v-for="cat in filteredCategories"
|
||||
:key="cat.id"
|
||||
class="rounded-2xl border border-border bg-surface-2 p-4"
|
||||
>
|
||||
@@ -64,7 +74,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import {
|
||||
createCategory,
|
||||
deleteCategory,
|
||||
@@ -79,11 +89,21 @@ 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 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 searchQuery = ref('');
|
||||
|
||||
const filteredCategories = computed(() =>
|
||||
filterBySearch(categories.value, searchQuery.value, (cat) => [
|
||||
cat.title,
|
||||
cat.slug,
|
||||
]),
|
||||
);
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const modalOpen = ref(false);
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
</RouterLink>
|
||||
</EmptyState>
|
||||
|
||||
<div v-else class="grid grid-cols-2 gap-3">
|
||||
<div v-else class="space-y-2">
|
||||
<RouterLink
|
||||
v-for="biz in recentBusinesses"
|
||||
:key="biz.id"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<AuthLayout title="پنل مدیریت" subtitle="ورود سوپر ادمین">
|
||||
<AuthLayout title="منتایم" subtitle="پنل مدیریت — ورود سوپر ادمین">
|
||||
<p
|
||||
v-if="error"
|
||||
class="mb-4 rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-600 dark:text-red-400"
|
||||
|
||||
+26
-3
@@ -2,17 +2,29 @@
|
||||
<AdminLayout>
|
||||
<PageHeader title="کاربران" subtitle="مدیریت نقش و وضعیت کاربران" />
|
||||
|
||||
<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="users.length === 0" class="mt-4">کاربری ثبت نشده است.</EmptyState>
|
||||
<EmptyState v-else-if="filteredUsers.length === 0" class="mt-4">
|
||||
{{
|
||||
searchQuery.trim()
|
||||
? 'نتیجهای برای جستجوی شما یافت نشد.'
|
||||
: 'کاربری ثبت نشده است.'
|
||||
}}
|
||||
</EmptyState>
|
||||
|
||||
<ul v-else class="mt-4 space-y-2">
|
||||
<li
|
||||
v-for="user in users"
|
||||
v-for="user in filteredUsers"
|
||||
:key="user.id"
|
||||
class="rounded-2xl border border-border bg-surface-2 p-4"
|
||||
>
|
||||
@@ -59,7 +71,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { fetchUsers, updateUser } from '@/api/users';
|
||||
import type { User, UserRole } from '@/api/types';
|
||||
import { ApiError } from '@/api/client';
|
||||
@@ -70,10 +82,21 @@ 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 SearchInput from '@/components/ui/SearchInput.vue';
|
||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||
import { activeLabel, roleLabel } from '@/utils/format';
|
||||
import { filterBySearch } from '@/utils/search';
|
||||
|
||||
const users = ref<User[]>([]);
|
||||
const searchQuery = ref('');
|
||||
|
||||
const filteredUsers = computed(() =>
|
||||
filterBySearch(users.value, searchQuery.value, (user) => [
|
||||
user.mobile,
|
||||
user.fullName,
|
||||
roleLabel(user.role),
|
||||
]),
|
||||
);
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const editing = ref<User | null>(null);
|
||||
|
||||
Reference in New Issue
Block a user