add&edit cancellationReason

This commit is contained in:
2026-06-13 15:37:58 +03:30
parent 2fd3f49b5c
commit a2de345112
9 changed files with 1193 additions and 12 deletions
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#d97706" />
<title>منتایم — پنل مدیریت</title>
<link rel="icon" type="image/png" sizes="32x32" href="/icons/icon-32.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" sizes="192x192" href="/icons/icon-192.png" />
<link rel="manifest" href="/site.webmanifest" />
<script type="module" src="/src/main.ts"></script>
</head>
<body>
<div id="app"></div>
</body>
</html>
+197 -1
View File
@@ -1,4 +1,200 @@
export type UserRole = 'customer' | 'business_owner' | 'admin';
export type User = {
id: string;
mobile: string;
fullName: string | null;
role: UserRole;
isActive: boolean;
hasPassword?: boolean;
createdAt?: string;
updatedAt?: string;
};
export type UpdateUserPayload = {
fullName?: string | null;
role?: UserRole;
isActive?: boolean;
};
export type AdminSetUserPasswordPayload = {
password: string;
confirmPassword: string;
};
export type BookingStatus = 'pending' | 'confirmed' | 'cancelled' | 'completed';
export type BusinessCategory = {
id: string;
slug: string;
title: string;
isActive: boolean;
};
export type CreateCategoryPayload = {
title: string;
slug?: string;
isActive?: boolean;
};
export type UpdateCategoryPayload = {
title?: string;
slug?: string;
isActive?: boolean;
};
export type BusinessImage = {
id: string;
url: string;
originalName?: string | null;
sortOrder: number;
isCover: boolean;
};
export type Business = {
id: string;
title: string;
slug: string;
subtitle: string | null;
description: string | null;
contactPhone: string | null;
address: string | null;
latitude: number | null;
longitude: number | null;
isActive: boolean;
categoryId: string;
ownerId?: string | null;
category?: BusinessCategory;
images?: BusinessImage[];
concurrentCapacity?: number;
slotStepMinutes?: number;
serviceClassesEnabled?: boolean;
createdAt?: string;
updatedAt?: string;
};
export type CreateBusinessPayload = {
title: string;
slug?: string;
categoryId: string;
subtitle?: string;
subtitle?: string | null;
description?: string;
contactPhone?: string;
address?: string;
latitude?: number;
longitude?: number;
isActive?: boolean;
};
export type UpdateBusinessPayload = {
title?: string;
slug?: string;
categoryId?: string;
subtitle?: string | null;
description?: string | null;
contactPhone?: string | null;
address?: string | null;
latitude?: number | null;
longitude?: number | null;
isActive?: boolean;
};
export type AssignOwnerPayload = {
mobile: string;
fullName?: string | null;
};
export type ServiceTemplate = {
id: string;
title: string;
slug: string;
description?: string | null;
defaultDurationMinutes?: number | null;
isActive: boolean;
categoryId: string;
};
export type CreateServiceTemplatePayload = {
title: string;
slug?: string;
description?: string;
defaultDurationMinutes?: number;
isActive?: boolean;
categoryId: string;
};
export type UpdateServiceTemplatePayload = {
title?: string;
slug?: string;
description?: string | null;
defaultDurationMinutes?: number | null;
isActive?: boolean;
};
export type BusinessService = {
id: string;
businessId: string;
serviceTemplateId: string;
price: string;
durationMinutes?: number | null;
isActive: boolean;
serviceTemplate?: ServiceTemplate;
};
export type Booking = {
id: string;
customerId: string;
businessId: string;
businessServiceId: string;
serviceClassId?: string | null;
bookingGroupId?: string | null;
scheduledAt: string;
status: BookingStatus;
note?: string | null;
cancellationReason?: string | null;
business?: Business;
businessService?: BusinessService;
customer?: {
id: string;
mobile: string;
fullName?: string | null;
};
};
export type BusinessRegistrationStatus = 'pending' | 'approved' | 'rejected';
export type BusinessRegistrationRequest = {
id: string;
status: BusinessRegistrationStatus;
title: string;
slug: string;
categoryId: string;
subtitle?: string | null;
description?: string | null;
contactPhone?: string | null;
address?: string | null;
adminNote?: string | null;
applicantId: string;
applicant?: {
id: string;
mobile: string;
fullName?: string | null;
};
category?: BusinessCategory;
createdAt?: string;
};
export type BusinessServiceRequestStatus = 'pending' | 'approved' | 'rejected';
export type BusinessServiceRequest = {
id: string;
businessId: string;
status: BusinessServiceRequestStatus;
title: string;
description?: string | null;
price: string;
durationMinutes?: number | null;
adminNote?: string | null;
business?: Business;
createdAt?: string;
};
+84 -2
View File
@@ -1,8 +1,90 @@
<template>
<AppShell>
<header
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 supports-[backdrop-filter]:bg-surface/80"
>
<RouterLink :to="{ name: 'dashboard' }" class="flex min-w-0 items-center gap-2">
<AppLogo variant="horizontal" class="h-10 shrink-0" />
<p class="truncate text-xs text-brand-600 dark:text-brand-500">پنل مدیریت</p>
</RouterLink>
<div class="flex items-center gap-1">
<RouterLink
:to="{ name: 'settings' }"
class="rounded-xl px-3 py-2 text-sm text-muted hover:bg-surface-3"
:class="route.name === 'settings' ? 'bg-brand-500/15 text-brand-700' : ''"
>
تنظیمات
</RouterLink>
</RouterLink>
<RouterLink
:to="{ name: 'profile' }"
class="rounded-xl px-3 py-2 text-sm text-muted hover:bg-surface-3"
:class="route.name === 'profile' ? 'bg-brand-500/15 text-brand-700' : ''"
>
پروفایل
</RouterLink>
<ThemeToggle />
<button
type="button"
class="rounded-xl px-3 py-2 text-sm text-muted hover:bg-surface-3"
@click="onLogout"
>
خروج
</button>
</div>
</header>
<main class="flex-1 space-y-4 px-4 py-4 pb-6">
<slot />
</main>
<nav
class="sticky bottom-0 z-10 shrink-0 border-t border-border bg-surface/95 px-1 py-2 backdrop-blur supports-[backdrop-filter]:bg-surface/80"
>
<div class="flex gap-0.5">
<RouterLink
v-for="item in navItems"
:key="item.to"
:to="item.to"
class="flex min-w-0 flex-1 flex-col items-center rounded-xl px-0.5 py-1.5 text-[9px] leading-tight sm:py-2 sm:text-xs"
:class="
route.name === item.name
? 'bg-brand-500/15 text-brand-700 dark:text-brand-500'
: 'text-muted hover:bg-surface-3'
"
>
<AppIcon :name="item.icon" size="lg" />
<span class="w-full truncate text-center">{{ item.label }}</span>
</RouterLink>
</div>
</nav>
</AppShell>
</template>
<script setup lang="ts">
import { useRoute, useRouter } from 'vue-router';
import AppLogo from '@/components/brand/AppLogo.vue';
import AppShell from '@/components/shell/AppShell.vue';
import AppIcon from '@/components/ui/AppIcon.vue';
import ThemeToggle from '@/components/ui/ThemeToggle.vue';
import { useAuthStore } from '@/stores/auth';
import type { AppIconName } from '@/types/icons';
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
const navItems: { to: string; name: string; label: string; icon: AppIconName }[] = [
{ to: '/', name: 'dashboard', label: 'داشبورد', icon: 'layout-dashboard' },
{ to: '/categories', name: 'categories', label: 'دسته', icon: 'folder' },
{ to: '/businesses', name: 'businesses', label: 'کسب‌وکار', icon: 'store' },
{ to: '/business-requests', name: 'business-requests', label: 'ثبت کسب', icon: 'clipboard-list' },
{ to: '/service-requests', name: 'service-requests', label: 'خدمت', icon: 'plus' },
{ to: '/bookings', name: 'bookings', label: 'رزرو', icon: 'calendar' },
{ to: '/users', name: 'users', label: 'کاربران', icon: 'users' },
];
function onLogout() {
auth.logout();
router.push({ name: 'login' });
}
</script>
+11 -1
View File
@@ -1,2 +1,12 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import router from './router';
import './styles/fonts.css';
import './styles/fonts.css';
import './styles/main.css';
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount('#app');
+97 -1
View File
@@ -1,7 +1,103 @@
import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
meta: { guest: true },
},
{
path: '/verify',
name: 'verify',
component: () => import('@/views/VerifyOtpView.vue'),
meta: { guest: true },
},
{
path: '/',
name: 'dashboard',
component: () => import('@/views/DashboardView.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: '/categories',
name: 'categories',
component: () => import('@/views/CategoriesView.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: '/service-templates',
name: 'service-templates',
component: () => import('@/views/ServiceTemplatesView.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: '/businesses',
name: 'businesses',
component: () => import('@/views/BusinessesView.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: '/users',
name: 'users',
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: '/service-requests',
name: 'service-requests',
component: () => import('@/views/ServiceRequestsView.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: '/bookings',
name: 'bookings',
component: () => import('@/views/BookingsView.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: '/profile',
name: 'profile',
component: () => import('@/views/ProfileView.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
{
path: '/settings',
name: 'settings',
component: () => import('@/views/PlatformSettingsView.vue'),
meta: { requiresAuth: true, requiresAdmin: true },
},
},
],
});
router.beforeEach(async (to) => {
const auth = useAuthStore();
if (auth.token && !auth.user) {
await auth.loadUser();
}
if (to.meta.requiresAuth && !auth.isLoggedIn) {
return { name: 'login', query: { redirect: to.fullPath } };
}
if (to.meta.requiresAdmin && !auth.isAdmin) {
auth.logout();
return { name: 'login' };
}
if (to.meta.guest && auth.isLoggedIn && auth.isAdmin) {
return { name: 'dashboard' };
}
});
export default router;
+36 -1
View File
@@ -1,4 +1,29 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--color-surface: #ffffff;
--color-surface-2: #f8fafc;
--color-surface-3: #f1f5f9;
--color-border: #e2e8f0;
--color-text: #0f172a;
--color-muted: #64748b;
--color-placeholder: #e2e8f0;
}
.dark {
--color-surface: #0f1419;
--color-surface-2: #1a2332;
--color-surface-3: #243044;
--color-border: #334155;
--color-text: #f1f5f9;
--color-muted: #94a3b8;
--color-placeholder: #3d4f63;
}
html {
@apply antialiased font-sans;
font-family: IRANYekanX, ui-sans-serif, system-ui, sans-serif;
}
@@ -8,4 +33,14 @@
input,
textarea,
select {
font-family: inherit;
font-family: inherit;
}
body {
@apply bg-surface-2 text-text;
}
#app {
min-height: 100dvh;
}
}
+549 -1
View File
@@ -1,12 +1,560 @@
<template>
<AdminLayout>
<PageHeader title="کسب‌وکارها" subtitle="ایجاد، ویرایش و واگذاری مدیر">
<template #actions>
<AppButton class="!w-auto shrink-0 px-4" @click="openCreate">جدید</AppButton>
</template>
</PageHeader>
<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"
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>
<BusinessCardSkeleton v-if="loading" size="lg" list-class="mt-4 space-y-2" />
<EmptyState v-else-if="filteredBusinesses.length === 0" class="mt-4">
{{
searchQuery.trim()
? 'نتیجه‌ای برای جستجوی شما یافت نشد.'
: 'کسب‌وکاری یافت نشد.'
}}
</EmptyState>
<div v-else class="mt-4 space-y-2">
<BusinessListCard
v-for="biz in filteredBusinesses"
:key="biz.id"
:business="biz"
:owner-text="ownerText(biz)"
>
<template #actions>
<AppIconButton icon="pencil" label="ویرایش" @click="openEdit(biz)" />
<AppIconButton icon="user-cog" label="مدیر" @click="openAssign(biz)" />
<AppIconButton icon="trash" label="حذف" variant="danger" @click="onDelete(biz)" />
</template>
</BusinessListCard>
</div>
<AppModal
:open="formOpen"
:title="editingId ? 'ویرایش کسب‌وکار' : 'کسب‌وکار جدید'"
@close="closeForm"
>
<form class="space-y-4" @submit.prevent="onSave">
<AppInput
id="bizTitle"
v-model="form.title"
label="عنوان"
required
@blur="onTitleBlur"
/>
<AppInput
id="bizSubtitle"
v-model="form.subtitle"
label="زیر عنوان"
placeholder="مثلاً: تمیزی که می‌درخشد"
/>
<AppInput
id="bizSlug"
v-model="form.slug"
label="اسلاگ (انگلیسی)"
ltr
placeholder="اختیاری — از عنوان ساخته می‌شود"
/>
<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>
<BusinessImageUpload
ref="imageUploadRef"
:existing="detailBusiness?.images ?? []"
:deleting-id="deletingImageId"
@remove-existing="onRemoveImage"
/>
<BusinessServicesSection
v-if="editingId && form.categoryId"
:business-id="editingId"
:category-id="form.categoryId"
/>
<AppButton type="submit" :loading="saving">ذخیره</AppButton>
</form>
</AppModal>
<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>
<InlineCardSkeleton v-if="assignLoading" />
<div
v-else-if="assignUser"
class="rounded-xl border border-border bg-surface-3 p-3"
>
<dl class="space-y-3">
<div class="flex items-center justify-between gap-4">
<dt class="shrink-0 text-xs text-muted">نام</dt>
<dd class="font-semibold text-text">{{ assignUser.fullName ?? 'بدون نام' }}</dd>
</div>
<div class="flex items-center justify-between gap-4">
<dt class="shrink-0 text-xs text-muted">موبایل</dt>
<dd class="text-sm text-muted" dir="ltr">{{ assignUser.mobile }}</dd>
</div>
<div class="flex items-center justify-between gap-4">
<dt class="shrink-0 text-xs text-muted">نقش</dt>
<dd>
<AppBadge>{{ roleLabel(assignUser.role) }}</AppBadge>
</dd>
</div>
</dl>
</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">
{{ assignUser ? 'تغییر مدیر' : 'واگذاری مدیر' }}
</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">
{{ assignUser ? 'به‌روزرسانی مدیر' : 'واگذاری مدیر' }}
</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,
deleteBusinessImage,
fetchBusiness,
fetchBusinesses,
updateBusiness,
uploadBusinessImages,
} from '@/api/businesses';
import { fetchUser, fetchUsers } 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 BusinessServicesSection from '@/components/admin/BusinessServicesSection.vue';
import AppBadge from '@/components/ui/AppBadge.vue';
import BusinessCardSkeleton from '@/components/ui/BusinessCardSkeleton.vue';
import InlineCardSkeleton from '@/components/ui/InlineCardSkeleton.vue';
import AppButton from '@/components/ui/AppButton.vue';
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 { 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 categories = ref<BusinessCategory[]>([]);
const usersById = ref<Record<string, User>>({});
const searchQuery = ref('');
const filterCategoryId = ref('');
const filterActive = ref('all');
const loading = ref(true);
const error = ref('');
const saving = ref(false);
const formOpen = ref(false);
const editingId = ref<string | null>(null);
const isEditMode = ref(false);
const detailBusiness = ref<Business | null>(null);
const imageUploadRef = ref<InstanceType<typeof BusinessImageUpload> | null>(null);
const deletingImageId = ref<string | null>(null);
const assignOpen = ref(false);
const assignTarget = ref<Business | null>(null);
const assignUser = ref<User | null>(null);
const assignLoading = ref(false);
const form = ref({
title: '',
subtitle: '',
slug: '',
categoryId: '',
description: '',
contactPhone: '',
address: '',
latitude: '',
longitude: '',
isActive: true,
});
const assignForm = ref({
mobile: '',
fullName: '',
});
const categoryOptions = computed(() =>
categories.value.map((cat) => ({ value: cat.id, label: cat.title })),
);
const categoryFilterOptions = computed(() => [
{ value: '', label: 'همه دسته‌ها' },
...categoryOptions.value,
]);
const activeFilterOptions = [
{ value: 'all', label: 'همه' },
{ value: 'true', label: 'فعال' },
{ value: 'false', label: 'غیرفعال' },
];
function ownerText(biz: Business): string {
if (!biz.ownerId) return 'بدون مدیر';
const owner = usersById.value[biz.ownerId];
return owner ? `${owner.fullName ?? owner.mobile}` : `مدیر: ${biz.ownerId.slice(0, 8)}…`;
}
const filteredBusinesses = computed(() =>
filterBySearch(businesses.value, searchQuery.value, (biz) => {
const category = categories.value.find((c) => c.id === biz.categoryId);
return [
biz.title,
biz.slug,
biz.address,
biz.contactPhone,
biz.description,
category?.title,
ownerText(biz),
];
}),
);
function parseCoord(value: string): number | undefined {
const num = parseFloat(value.trim());
return Number.isFinite(num) ? num : 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, users] = await Promise.all([
fetchBusinesses(params),
fetchCategories(),
fetchUsers().catch(() => []),
]);
businesses.value = bizList;
categories.value = cats;
usersById.value = Object.fromEntries(users.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;
detailBusiness.value = null;
isEditMode.value = false;
imageUploadRef.value?.clearPending();
form.value = {
title: '',
subtitle: '',
slug: '',
categoryId: categories.value[0]?.id ?? '',
description: '',
contactPhone: '',
address: '',
latitude: '',
longitude: '',
isActive: true,
};
formOpen.value = true;
}
async function openEdit(biz: Business) {
editingId.value = biz.id;
isEditMode.value = true;
try {
detailBusiness.value = await fetchBusiness(biz.id);
} catch {
detailBusiness.value = biz;
}
imageUploadRef.value?.clearPending();
form.value = {
title: biz.title,
subtitle: biz.subtitle ?? '',
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;
detailBusiness.value = null;
imageUploadRef.value?.clearPending();
}
async function onRemoveImage(imageId: string) {
if (!editingId.value) return;
deletingImageId.value = imageId;
error.value = '';
try {
await deleteBusinessImage(editingId.value, imageId);
detailBusiness.value = await fetchBusiness(editingId.value);
await load();
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'خطا در حذف تصویر';
} finally {
deletingImageId.value = null;
}
}
function onTitleBlur() {
if (!isEditMode.value && !editingId.value) {
form.value.slug = slugify(form.value.title);
}
}
async function onSave() {
const slug = form.value.slug.trim();
if (slug && !isValidSlug(slug)) {
error.value = 'اسلاگ نامعتبر است.';
return;
}
if (!form.value.categoryId) {
error.value = 'دسته را انتخاب کنید.';
return;
}
saving.value = true;
error.value = '';
const latitude = parseCoord(form.value.latitude);
const longitude = parseCoord(form.value.longitude);
try {
let businessId = editingId.value;
const basePayload = {
title: form.value.title.trim(),
categoryId: form.value.categoryId,
subtitle: form.value.subtitle.trim() || null,
subtitle: form.value.subtitle.trim() || undefined,
description: form.value.description.trim() || null,
contactPhone: form.value.contactPhone.trim() || null,
address: form.value.address.trim() || null,
latitude: latitude ?? null,
longitude: longitude ?? null,
isActive: form.value.isActive,
};
if (businessId) {
await updateBusiness(businessId, {
...basePayload,
...(slug ? { slug } : {}),
});
} else {
const created = await createBusiness({
...basePayload,
subtitle: form.value.subtitle.trim() || undefined,
description: form.value.description.trim() || undefined,
contactPhone: form.value.contactPhone.trim() || undefined,
address: form.value.address.trim() || undefined,
latitude,
longitude,
...(slug ? { slug } : {}),
});
businessId = created.id;
}
const pendingFiles = imageUploadRef.value?.getPendingFiles() ?? [];
if (businessId && pendingFiles.length > 0) {
await uploadBusinessImages(businessId, pendingFiles);
}
closeForm();
await load();
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'خطا در ذخیره';
} finally {
saving.value = false;
}
}
async function openAssign(biz: Business) {
assignTarget.value = biz;
assignOpen.value = true;
assignLoading.value = true;
assignUser.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;
}
}
}
assignUser.value = owner;
assignForm.value = {
mobile: owner?.mobile ?? '',
fullName: owner?.fullName ?? '',
};
} finally {
assignLoading.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>
+167 -1
View File
@@ -1,2 +1,168 @@
<template>
<AdminLayout>
<section
class="relative overflow-hidden rounded-2xl border border-brand-500/25 bg-gradient-to-br from-brand-500/15 via-surface-2 to-surface-2 p-5"
>
<div class="relative">
<p class="text-sm text-muted">خوش آمدید</p>
<h1 class="mt-1 text-xl font-bold text-text">
{{ auth.user?.fullName ?? 'سوپر ادمین' }}
</h1>
<p class="mt-1 text-sm text-muted" dir="ltr">{{ auth.user?.mobile }}</p>
</div>
</section>
{ label: 'تنظیمات', value: '—', icon: 'camera' as const, to: '/settings', tone: 'sky' as const },
<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 space-y-8">
<StatGridSkeleton />
<BusinessCardSkeleton size="lg" :count="3" />
</div>
<template v-else>
<div class="mt-6 grid grid-cols-2 gap-3 lg:grid-cols-3">
<StatCard
v-for="card in statCards"
:key="card.label"
:label="card.label"
:value="card.value"
:icon="card.icon"
:to="card.to"
:tone="card.tone"
/>
</div>
<section class="mt-8">
<div class="mb-3 flex items-center justify-between">
<h2 class="text-base font-bold text-text">آخرین کسب‌وکارها</h2>
<RouterLink to="/businesses" class="text-sm text-brand-700 dark:text-brand-500">
همه
</RouterLink>
</div>
<EmptyState v-if="recentBusinesses.length === 0">
هنوز کسب‌وکاری ثبت نشده.
<RouterLink to="/businesses" class="mt-2 inline-block text-brand-700">
افزودن کسب‌وکار
</RouterLink>
</EmptyState>
<div v-else class="space-y-2">
<RouterLink
v-for="biz in recentBusinesses"
:key="biz.id"
to="/businesses"
class="block"
>
<BusinessListCard :business="biz" />
</RouterLink>
</div>
</section>
</template>
</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 type { Business } from '@/api/types';
import { ApiError } from '@/api/client';
import BusinessListCard from '@/components/admin/BusinessListCard.vue';
import StatCard from '@/components/admin/StatCard.vue';
import BusinessCardSkeleton from '@/components/ui/BusinessCardSkeleton.vue';
import EmptyState from '@/components/ui/EmptyState.vue';
import StatGridSkeleton from '@/components/ui/StatGridSkeleton.vue';
import AdminLayout from '@/layouts/AdminLayout.vue';
import { useAuthStore } from '@/stores/auth';
import type { AppIconName } from '@/types/icons';
const auth = useAuthStore();
const loading = ref(true);
const error = ref('');
const businesses = ref<Business[]>([]);
const stats = ref({
businesses: 0,
activeBusinesses: 0,
bookings: 0,
users: 0,
categories: 0,
});
const statCards = computed(() => [
{
label: 'کسب‌وکار',
value: stats.value.businesses,
icon: 'store' as AppIconName,
to: '/businesses',
tone: 'amber' as const,
},
{
label: 'فعال',
value: stats.value.activeBusinesses,
icon: 'circle-check' as AppIconName,
to: '/businesses',
tone: 'emerald' as const,
},
{
label: 'رزرو',
value: stats.value.bookings,
icon: 'calendar' as AppIconName,
to: '/bookings',
tone: 'sky' as const,
},
{
label: 'کاربر',
value: stats.value.users,
icon: 'users' as AppIconName,
to: '/users',
tone: 'violet' as const,
},
{
label: 'دسته',
value: stats.value.categories,
icon: 'folder' as AppIconName,
to: '/categories',
tone: 'rose' as const,
},
{
label: 'تنظیمات',
value: '—',
icon: 'camera' as AppIconName,
to: '/settings',
tone: 'sky' as const,
},
]);
const recentBusinesses = computed(() => businesses.value.slice(0, 6));
onMounted(async () => {
try {
const [bizList, users, categories, bookings] = await Promise.all([
fetchBusinesses(),
fetchUsers(),
fetchCategories(),
fetchBookings().catch(() => []),
]);
businesses.value = bizList;
stats.value = {
businesses: bizList.length,
activeBusinesses: bizList.filter((b) => b.isActive).length,
bookings: bookings.length,
users: users.length,
categories: categories.length,
};
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'خطا در بارگذاری';
} finally {
loading.value = false;
}
});
</script>
+34 -4
View File
@@ -1,4 +1,34 @@
fontFamily: {
sans: ['IRANYekanX', 'ui-sans-serif', 'system-ui', 'sans-serif'],
},
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
darkMode: 'class',
theme: {
extend: {
fontFamily: {
sans: ['IRANYekanX', 'ui-sans-serif', 'system-ui', 'sans-serif'],
},
maxWidth: {
app: '768px',
},
colors: {
border: 'var(--color-border)',
text: 'var(--color-text)',
muted: 'var(--color-muted)',
surface: {
DEFAULT: 'var(--color-surface)',
2: 'var(--color-surface-2)',
3: 'var(--color-surface-3)',
},
brand: {
50: '#e6f7ee',
100: '#ccefdc',
400: '#22c55e',
500: '#00a651',
600: '#009640',
700: '#007a35',
},
},
},
},
plugins: [],
};