Add list search, compact business cards, registration approval queue, and Montime branding.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-23 18:13:24 +03:30
co-authored by Cursor
parent be26c6d89a
commit 5629213449
15 changed files with 519 additions and 67 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>مون‌تایم — پنل مدیریت</title>
<title>منتایم — پنل مدیریت</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+20
View File
@@ -0,0 +1,20 @@
import { api } from './client';
import type { BusinessRegistrationRequest } from './types';
export function fetchBusinessRegistrationRequests(status?: 'pending' | 'approved' | 'rejected') {
const query = status ? `?status=${status}` : '';
return api<BusinessRegistrationRequest[]>(`/business-registration-requests${query}`);
}
export function approveBusinessRegistration(id: string) {
return api<BusinessRegistrationRequest>(`/business-registration-requests/${id}/approve`, {
method: 'POST',
});
}
export function rejectBusinessRegistration(id: string, adminNote?: string) {
return api<BusinessRegistrationRequest>(`/business-registration-requests/${id}/reject`, {
method: 'POST',
body: JSON.stringify({ adminNote: adminNote || undefined }),
});
}
+19
View File
@@ -94,6 +94,25 @@ export type BookingStatus =
| 'cancelled'
| 'completed';
export type BusinessRegistrationStatus = 'pending' | 'approved' | 'rejected';
export type BusinessRegistrationRequest = {
id: string;
applicantId: string;
status: BusinessRegistrationStatus;
title: string;
slug: string;
categoryId: string;
description: string | null;
contactPhone: string | null;
address: string | null;
adminNote: string | null;
businessId: string | null;
createdAt: string;
category?: BusinessCategory;
applicant?: User;
};
export type Booking = {
id: string;
customerId: string;
+29 -27
View File
@@ -1,6 +1,32 @@
<template>
<article class="overflow-hidden rounded-2xl border border-border bg-surface-2 transition hover:border-brand-500/40 hover:shadow-md">
<div class="relative aspect-[16/10] bg-surface-3">
<article
class="flex items-center gap-3 rounded-xl border border-border bg-surface-2 p-2.5 transition hover:border-brand-500/40 hover:shadow-sm"
>
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<h3 class="truncate text-sm font-semibold text-text">{{ business.title }}</h3>
<p v-if="business.category" class="mt-0.5 truncate text-xs text-muted">
{{ business.category.title }}
</p>
<p v-if="ownerText" class="mt-0.5 truncate text-xs text-muted">{{ ownerText }}</p>
</div>
<AppBadge
class="shrink-0 !px-1.5 !py-0.5 text-[10px]"
:tone="business.isActive ? 'success' : 'warning'"
>
{{ activeLabel(business.isActive) }}
</AppBadge>
</div>
<div v-if="$slots.actions" class="mt-2 flex flex-wrap gap-1">
<slot name="actions" />
</div>
</div>
<div
class="relative h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-surface-3 sm:h-16 sm:w-16"
>
<img
v-if="cover"
:src="cover"
@@ -10,28 +36,10 @@
/>
<div
v-else
class="flex h-full w-full items-center justify-center text-4xl text-muted/50"
class="flex h-full w-full items-center justify-center text-xl text-muted/50"
>
🏪
</div>
<AppBadge
class="absolute left-2 top-2"
:tone="business.isActive ? 'success' : 'warning'"
>
{{ activeLabel(business.isActive) }}
</AppBadge>
</div>
<div class="p-3">
<h3 class="truncate font-semibold text-text">{{ business.title }}</h3>
<p v-if="business.category" class="mt-0.5 truncate text-xs text-muted">
{{ business.category.title }}
</p>
<p v-if="ownerText" class="mt-1 truncate text-xs text-muted">{{ ownerText }}</p>
<div v-if="$slots.actions" class="mt-3 grid gap-1" :class="actionGridClass">
<slot name="actions" />
</div>
</div>
</article>
</template>
@@ -46,13 +54,7 @@ import { businessCoverUrl } from '@/utils/media';
const props = defineProps<{
business: Business;
ownerText?: string | null;
actionCols?: number;
}>();
const cover = computed(() => businessCoverUrl(props.business.images));
const actionGridClass = computed(() => {
const cols = props.actionCols ?? 3;
return cols === 2 ? 'grid-cols-2' : 'grid-cols-3';
});
</script>
+29
View File
@@ -0,0 +1,29 @@
<template>
<AppInput
:id="id"
v-model="model"
type="search"
:label="label"
:placeholder="placeholder"
autocomplete="off"
enterkeyhint="search"
/>
</template>
<script setup lang="ts">
import AppInput from '@/components/ui/AppInput.vue';
const model = defineModel<string>({ default: '' });
withDefaults(
defineProps<{
id?: string;
label?: string;
placeholder?: string;
}>(),
{
id: 'search',
placeholder: 'جستجو…',
},
);
</script>
+4 -5
View File
@@ -4,10 +4,8 @@
class="sticky top-0 z-10 flex items-center justify-between border-b border-brand-500/30 bg-surface/95 px-4 py-3 backdrop-blur"
>
<RouterLink :to="{ name: 'dashboard' }" class="min-w-0">
<p class="font-bold text-text">پنل مدیریت</p>
<p class="truncate text-xs text-brand-600 dark:text-brand-500">
{{ auth.user?.fullName ?? auth.user?.mobile ?? 'adm.montime.ir' }}
</p>
<p class="font-bold text-text">منتایم</p>
<p class="truncate text-xs text-brand-600 dark:text-brand-500">پنل مدیریت</p>
</RouterLink>
<div class="flex items-center gap-1">
<RouterLink
@@ -33,7 +31,7 @@
</main>
<nav class="sticky bottom-0 border-t border-border bg-surface/95 px-1 py-2">
<div class="grid grid-cols-5 gap-0.5">
<div class="grid grid-cols-3 gap-0.5 sm:grid-cols-6">
<RouterLink
v-for="item in items"
:key="item.to"
@@ -67,6 +65,7 @@ const items = [
{ to: '/', name: 'dashboard', label: 'داشبورد', icon: '📊' },
{ to: '/categories', name: 'categories', label: 'دسته', icon: '📁' },
{ to: '/businesses', name: 'businesses', label: 'کسب‌وکار', icon: '🏪' },
{ to: '/business-requests', name: 'business-requests', label: 'درخواست', icon: '📋' },
{ to: '/bookings', name: 'bookings', label: 'رزرو', icon: '📅' },
{ to: '/users', name: 'users', label: 'کاربران', icon: '👥' },
];
+6
View File
@@ -40,6 +40,12 @@ const router = createRouter({
component: () => import('@/views/UsersView.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: '/business-requests',
name: 'business-requests',
component: () => import('@/views/BusinessRequestsView.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: '/bookings',
name: 'bookings',
+25
View File
@@ -0,0 +1,25 @@
export function normalizeSearchText(value: string): string {
return value.trim().toLowerCase().replace(/\s+/g, ' ');
}
export function matchesSearch(
query: string,
...parts: (string | null | undefined)[]
): boolean {
const q = normalizeSearchText(query);
if (!q) return true;
const haystack = parts
.filter((p): p is string => p != null && p !== '')
.join(' ')
.toLowerCase();
return haystack.includes(q);
}
export function filterBySearch<T>(
items: T[],
query: string,
getParts: (item: T) => (string | null | undefined)[],
): T[] {
if (!normalizeSearchText(query)) return items;
return items.filter((item) => matchesSearch(query, ...getParts(item)));
}
+28 -4
View File
@@ -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 = '';
+188
View File
@@ -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
View File
@@ -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() {
+24 -4
View File
@@ -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);
+1 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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);