landing desktop
This commit is contained in:
-24
@@ -1,24 +0,0 @@
|
|||||||
<!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" />
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
||||||
<link
|
|
||||||
href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@400;500;600;700&display=swap"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
<html><body>404</body></html>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { api, apiFormData } from './client';
|
||||||
|
|
||||||
|
export type PlatformSettings = {
|
||||||
|
loginHeroImageUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchPlatformSettings() {
|
||||||
|
return api<PlatformSettings>('/platform-settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uploadLoginHeroImage(file: File) {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
return apiFormData<PlatformSettings>('/platform-settings/login-hero-image', form);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeLoginHeroImage() {
|
||||||
|
return api<PlatformSettings>('/platform-settings/login-hero-image', {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
+3
-177
@@ -1,178 +1,4 @@
|
|||||||
export type UserRole = 'customer' | 'business_owner' | 'admin';
|
|
||||||
|
|
||||||
export type User = {
|
subtitle: string | null;
|
||||||
id: string;
|
subtitle?: string;
|
||||||
mobile: string;
|
subtitle?: string | null;
|
||||||
fullName: string | null;
|
|
||||||
role: UserRole;
|
|
||||||
isActive: boolean;
|
|
||||||
hasPassword?: boolean;
|
|
||||||
createdAt?: string;
|
|
||||||
updatedAt?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BusinessCategory = {
|
|
||||||
id: string;
|
|
||||||
slug: string;
|
|
||||||
title: string;
|
|
||||||
isActive: boolean;
|
|
||||||
createdAt?: string;
|
|
||||||
updatedAt?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BusinessImage = {
|
|
||||||
id: string;
|
|
||||||
businessId: string;
|
|
||||||
url: string;
|
|
||||||
originalName: string | null;
|
|
||||||
sortOrder: number;
|
|
||||||
isCover: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Business = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
slug: string;
|
|
||||||
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[];
|
|
||||||
owner?: User | null;
|
|
||||||
createdAt?: string;
|
|
||||||
updatedAt?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CreateBusinessPayload = {
|
|
||||||
title: string;
|
|
||||||
slug?: string;
|
|
||||||
categoryId: string;
|
|
||||||
description?: string;
|
|
||||||
contactPhone?: string;
|
|
||||||
address?: string;
|
|
||||||
latitude?: number;
|
|
||||||
longitude?: number;
|
|
||||||
isActive?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdateBusinessPayload = Partial<CreateBusinessPayload> & {
|
|
||||||
description?: string | null;
|
|
||||||
contactPhone?: string | null;
|
|
||||||
address?: string | null;
|
|
||||||
latitude?: number | null;
|
|
||||||
longitude?: number | null;
|
|
||||||
ownerId?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CreateCategoryPayload = {
|
|
||||||
title: string;
|
|
||||||
slug?: string;
|
|
||||||
isActive?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdateCategoryPayload = Partial<CreateCategoryPayload>;
|
|
||||||
|
|
||||||
export type ServiceTemplate = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
slug: string;
|
|
||||||
description: string | null;
|
|
||||||
defaultDurationMinutes: number | null;
|
|
||||||
isActive: boolean;
|
|
||||||
categoryId: string;
|
|
||||||
category?: BusinessCategory;
|
|
||||||
createdAt?: string;
|
|
||||||
updatedAt?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CreateServiceTemplatePayload = {
|
|
||||||
title: string;
|
|
||||||
slug?: string;
|
|
||||||
categoryId: string;
|
|
||||||
description?: string;
|
|
||||||
isActive?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdateServiceTemplatePayload = Partial<
|
|
||||||
Omit<CreateServiceTemplatePayload, 'categoryId'>
|
|
||||||
>;
|
|
||||||
|
|
||||||
export type UpdateUserPayload = {
|
|
||||||
fullName?: string | null;
|
|
||||||
role?: UserRole;
|
|
||||||
isActive?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AdminSetUserPasswordPayload = {
|
|
||||||
password: string;
|
|
||||||
confirmPassword: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AssignOwnerPayload = {
|
|
||||||
mobile: string;
|
|
||||||
fullName?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BookingStatus =
|
|
||||||
| 'pending'
|
|
||||||
| 'confirmed'
|
|
||||||
| '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 BusinessServiceRequestStatus = 'pending' | 'approved' | 'rejected';
|
|
||||||
|
|
||||||
export type BusinessServiceRequest = {
|
|
||||||
id: string;
|
|
||||||
businessId: string;
|
|
||||||
requestedById: string;
|
|
||||||
status: BusinessServiceRequestStatus;
|
|
||||||
title: string;
|
|
||||||
description: string | null;
|
|
||||||
price: string;
|
|
||||||
durationMinutes: number | null;
|
|
||||||
adminNote: string | null;
|
|
||||||
serviceTemplateId: string | null;
|
|
||||||
businessServiceId: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
business?: Business;
|
|
||||||
requestedBy?: User;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Booking = {
|
|
||||||
id: string;
|
|
||||||
customerId: string;
|
|
||||||
businessId: string;
|
|
||||||
businessServiceId: string;
|
|
||||||
scheduledAt: string;
|
|
||||||
status: BookingStatus;
|
|
||||||
note: string | null;
|
|
||||||
customer?: User;
|
|
||||||
business?: Business;
|
|
||||||
businessService?: {
|
|
||||||
serviceTemplate?: { title: string };
|
|
||||||
};
|
|
||||||
};
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,83 +1,8 @@
|
|||||||
<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
|
<RouterLink
|
||||||
:to="{ name: 'profile' }"
|
:to="{ name: 'settings' }"
|
||||||
class="rounded-xl px-3 py-2 text-sm text-muted hover:bg-surface-3"
|
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' : ''"
|
:class="route.name === 'settings' ? 'bg-brand-500/15 text-brand-700' : ''"
|
||||||
>
|
>
|
||||||
پروفایل
|
تنظیمات
|
||||||
</RouterLink>
|
</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 items"
|
|
||||||
: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 AppShell from '@/components/shell/AppShell.vue';
|
|
||||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
|
||||||
import AppLogo from '@/components/brand/AppLogo.vue';
|
|
||||||
import ThemeToggle from '@/components/ui/ThemeToggle.vue';
|
|
||||||
import type { AdminNavItem } from '@/types/icons';
|
|
||||||
import { useAuthStore } from '@/stores/auth';
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const router = useRouter();
|
|
||||||
const auth = useAuthStore();
|
|
||||||
|
|
||||||
const items: AdminNavItem[] = [
|
|
||||||
{ 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>
|
|
||||||
|
|||||||
+1
-9
@@ -1,10 +1,2 @@
|
|||||||
import { createApp } from 'vue';
|
|
||||||
import { createPinia } from 'pinia';
|
|
||||||
import App from './App.vue';
|
|
||||||
import router from './router';
|
|
||||||
import './styles/main.css';
|
|
||||||
|
|
||||||
const app = createApp(App);
|
import './styles/fonts.css';
|
||||||
app.use(createPinia());
|
|
||||||
app.use(router);
|
|
||||||
app.mount('#app');
|
|
||||||
+3
-93
@@ -1,97 +1,7 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router';
|
|
||||||
import { useAuthStore } from '@/stores/auth';
|
|
||||||
|
|
||||||
const router = createRouter({
|
|
||||||
history: createWebHistory(),
|
|
||||||
routes: [
|
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: '/settings',
|
||||||
name: 'login',
|
name: 'settings',
|
||||||
component: () => import('@/views/LoginView.vue'),
|
component: () => import('@/views/PlatformSettingsView.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 },
|
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 },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
router.beforeEach(async (to) => {
|
|
||||||
const auth = useAuthStore();
|
|
||||||
|
|
||||||
if (auth.token && !auth.user) {
|
|
||||||
await auth.loadUser();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (to.meta.requiresAuth && !auth.isLoggedIn) {
|
|
||||||
return { name: 'login' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (to.meta.requiresAdmin && auth.isLoggedIn && !auth.isAdmin) {
|
|
||||||
auth.logout();
|
|
||||||
return { name: 'login' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (to.meta.guest && auth.isLoggedIn && auth.isAdmin) {
|
|
||||||
return { name: 'dashboard' };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: IRANYekanX;
|
||||||
|
src: url('../assets/fonts/IRANYekanXFaNum-Light.woff2') format('woff2');
|
||||||
|
font-weight: 300;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: IRANYekanX;
|
||||||
|
src: url('../assets/fonts/IRANYekanXFaNum-Regular.woff2') format('woff2');
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: IRANYekanX;
|
||||||
|
src: url('../assets/fonts/IRANYekanXFaNum-Regular.woff2') format('woff2');
|
||||||
|
font-weight: 500;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: IRANYekanX;
|
||||||
|
src: url('../assets/fonts/IRANYekanXFaNum-Bold.woff2') format('woff2');
|
||||||
|
font-weight: 600;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: IRANYekanX;
|
||||||
|
src: url('../assets/fonts/IRANYekanXFaNum-Bold.woff2') format('woff2');
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: IRANYekanX;
|
||||||
|
src: url('../assets/fonts/IRANYekanXFaNum-Black.woff2') format('woff2');
|
||||||
|
font-weight: 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: IRANYekanX;
|
||||||
|
src: url('../assets/fonts/IRANYekanXFaNum-ExtraBlack.woff2') format('woff2');
|
||||||
|
font-weight: 950;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
+8
-34
@@ -1,37 +1,11 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
:root {
|
@apply antialiased font-sans;
|
||||||
--color-surface: #ffffff;
|
font-family: IRANYekanX, ui-sans-serif, system-ui, sans-serif;
|
||||||
--color-surface-2: #fffbeb;
|
|
||||||
--color-surface-3: #fef3c7;
|
|
||||||
--color-border: #fde68a;
|
|
||||||
--color-text: #0f172a;
|
|
||||||
--color-muted: #64748b;
|
|
||||||
--color-placeholder: rgb(100 116 139 / 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
|
||||||
--color-surface: #0f1419;
|
|
||||||
--color-surface-2: #1a2332;
|
|
||||||
--color-surface-3: #243044;
|
|
||||||
--color-border: #334155;
|
|
||||||
--color-text: #f1f5f9;
|
|
||||||
--color-muted: #94a3b8;
|
|
||||||
--color-placeholder: rgb(148 163 184 / 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
html {
|
|
||||||
@apply antialiased;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body,
|
||||||
@apply bg-surface-2 font-sans text-text;
|
button,
|
||||||
}
|
input,
|
||||||
|
textarea,
|
||||||
#app {
|
select {
|
||||||
@apply min-h-dvh;
|
font-family: inherit;
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,528 +1,12 @@
|
|||||||
<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="ownerLabel(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>
|
|
||||||
|
|
||||||
<!-- 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
|
<AppInput
|
||||||
id="bizSlug"
|
id="bizSubtitle"
|
||||||
v-model="form.slug"
|
v-model="form.subtitle"
|
||||||
label="اسلاگ (انگلیسی)"
|
label="زیر عنوان"
|
||||||
ltr
|
placeholder="مثلاً: تمیزی که میدرخشد"
|
||||||
placeholder="اختیاری — از عنوان ساخته میشود"
|
|
||||||
/>
|
/>
|
||||||
<AppSelect
|
subtitle: '',
|
||||||
id="bizCategory"
|
subtitle: '',
|
||||||
v-model="form.categoryId"
|
subtitle: biz.subtitle ?? '',
|
||||||
label="دسته"
|
subtitle: form.value.subtitle.trim() || null,
|
||||||
placeholder="انتخاب دسته"
|
subtitle: form.value.subtitle.trim() || undefined,
|
||||||
: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="editingBusiness?.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>
|
|
||||||
|
|
||||||
<!-- 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>
|
|
||||||
|
|
||||||
<InlineCardSkeleton v-if="assignOwnerLoading" />
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else-if="assignCurrentOwner"
|
|
||||||
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">
|
|
||||||
{{ assignCurrentOwner.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">{{ assignCurrentOwner.mobile }}</dd>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<dt class="shrink-0 text-xs text-muted">نقش</dt>
|
|
||||||
<dd>
|
|
||||||
<AppBadge>{{ roleLabel(assignCurrentOwner.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">
|
|
||||||
{{ 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>
|
|
||||||
</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 { 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 BusinessServicesSection from '@/components/admin/BusinessServicesSection.vue';
|
|
||||||
import BusinessListCard from '@/components/admin/BusinessListCard.vue';
|
|
||||||
import BusinessCardSkeleton from '@/components/ui/BusinessCardSkeleton.vue';
|
|
||||||
import InlineCardSkeleton from '@/components/ui/InlineCardSkeleton.vue';
|
|
||||||
import AppBadge from '@/components/ui/AppBadge.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, optionalSlug, 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);
|
|
||||||
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 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);
|
|
||||||
|
|
||||||
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)}…`;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
editingBusiness.value = null;
|
|
||||||
slugTouched.value = false;
|
|
||||||
imageUploadRef.value?.clearPending();
|
|
||||||
form.value = {
|
|
||||||
title: '',
|
|
||||||
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;
|
|
||||||
slugTouched.value = true;
|
|
||||||
try {
|
|
||||||
editingBusiness.value = await fetchBusiness(biz.id);
|
|
||||||
} catch {
|
|
||||||
editingBusiness.value = biz;
|
|
||||||
}
|
|
||||||
imageUploadRef.value?.clearPending();
|
|
||||||
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;
|
|
||||||
editingBusiness.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);
|
|
||||||
editingBusiness.value = await fetchBusiness(editingId.value);
|
|
||||||
await load();
|
|
||||||
} catch (e) {
|
|
||||||
error.value = e instanceof ApiError ? e.message : 'خطا در حذف تصویر';
|
|
||||||
} finally {
|
|
||||||
deletingImageId.value = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function maybeSlug() {
|
|
||||||
if (!slugTouched.value && !editingId.value) {
|
|
||||||
form.value.slug = slugify(form.value.title);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openAssign(biz: Business) {
|
|
||||||
assignTarget.value = biz;
|
|
||||||
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() {
|
|
||||||
const slug = optionalSlug(form.value.slug);
|
|
||||||
if (slug && !isValidSlug(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 {
|
|
||||||
let businessId = editingId.value;
|
|
||||||
const basePayload = {
|
|
||||||
title: form.value.title.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,
|
|
||||||
};
|
|
||||||
if (businessId) {
|
|
||||||
await updateBusiness(businessId, {
|
|
||||||
...basePayload,
|
|
||||||
...(slug ? { slug } : {}),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const created = await createBusiness({
|
|
||||||
...basePayload,
|
|
||||||
description: form.value.description.trim() || undefined,
|
|
||||||
contactPhone: form.value.contactPhone.trim() || undefined,
|
|
||||||
address: form.value.address.trim() || undefined,
|
|
||||||
latitude: lat,
|
|
||||||
longitude: lng,
|
|
||||||
...(slug ? { slug } : {}),
|
|
||||||
});
|
|
||||||
businessId = created.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pending = imageUploadRef.value?.getPendingFiles() ?? [];
|
|
||||||
if (businessId && pending.length > 0) {
|
|
||||||
await uploadBusinessImages(businessId, pending);
|
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
|
||||||
+1
-126
@@ -1,127 +1,2 @@
|
|||||||
<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>
|
|
||||||
|
|
||||||
<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">
|
{ label: 'تنظیمات', value: '—', icon: 'camera' as const, to: '/settings', tone: 'sky' as const },
|
||||||
{{ 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 BusinessCardSkeleton from '@/components/ui/BusinessCardSkeleton.vue';
|
|
||||||
import StatGridSkeleton from '@/components/ui/StatGridSkeleton.vue';
|
|
||||||
import BusinessListCard from '@/components/admin/BusinessListCard.vue';
|
|
||||||
import StatCard from '@/components/admin/StatCard.vue';
|
|
||||||
import EmptyState from '@/components/ui/EmptyState.vue';
|
|
||||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
|
||||||
import { useAuthStore } from '@/stores/auth';
|
|
||||||
|
|
||||||
const auth = useAuthStore();
|
|
||||||
const loading = ref(true);
|
|
||||||
const error = ref('');
|
|
||||||
const businesses = ref<Business[]>([]);
|
|
||||||
const counts = ref({
|
|
||||||
businesses: 0,
|
|
||||||
activeBusinesses: 0,
|
|
||||||
bookings: 0,
|
|
||||||
users: 0,
|
|
||||||
categories: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const statCards = computed(() => [
|
|
||||||
{ label: 'کسبوکار', value: counts.value.businesses, icon: 'store' as const, to: '/businesses', tone: 'amber' as const },
|
|
||||||
{ label: 'فعال', value: counts.value.activeBusinesses, icon: 'circle-check' as const, to: '/businesses', tone: 'emerald' as const },
|
|
||||||
{ label: 'رزرو', value: counts.value.bookings, icon: 'calendar' as const, to: '/bookings', tone: 'sky' as const },
|
|
||||||
{ label: 'کاربر', value: counts.value.users, icon: 'users' as const, to: '/users', tone: 'violet' as const },
|
|
||||||
{ label: 'دسته', value: counts.value.categories, icon: 'folder' as const, to: '/categories', tone: 'rose' as const },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const recentBusinesses = computed(() => businesses.value.slice(0, 6));
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
|
||||||
const [bizList, users, categories, bookingList] = await Promise.all([
|
|
||||||
fetchBusinesses(),
|
|
||||||
fetchUsers(),
|
|
||||||
fetchCategories(),
|
|
||||||
fetchBookings().catch(() => []),
|
|
||||||
]);
|
|
||||||
businesses.value = bizList;
|
|
||||||
counts.value = {
|
|
||||||
businesses: bizList.length,
|
|
||||||
activeBusinesses: bizList.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>
|
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<template>
|
||||||
|
<AdminLayout>
|
||||||
|
<section class="space-y-2">
|
||||||
|
<h1 class="text-xl font-bold text-text">تنظیمات پلتفرم</h1>
|
||||||
|
<p class="text-sm text-muted">
|
||||||
|
تصویر پسزمینه صفحه ورود دسکتاپ را از اینجا مدیریت کنید.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="success"
|
||||||
|
class="mt-4 rounded-xl border border-brand-500/30 bg-brand-500/10 px-4 py-3 text-sm text-brand-700 dark:text-brand-500"
|
||||||
|
>
|
||||||
|
{{ success }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section class="mt-6 rounded-2xl border border-border bg-surface-2 p-4 lg:p-6">
|
||||||
|
<h2 class="text-base font-semibold text-text">تصویر صفحه ورود (دسکتاپ)</h2>
|
||||||
|
<p class="mt-1 text-xs text-muted">
|
||||||
|
JPG، PNG یا WebP — حداکثر ۵ مگابایت. پیشنهاد: تصویر افقی با کیفیت بالا.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="loading"
|
||||||
|
class="mt-4 flex aspect-[16/10] items-center justify-center rounded-xl border border-dashed border-border bg-surface-3 text-sm text-muted"
|
||||||
|
>
|
||||||
|
در حال بارگذاری...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="mt-4 overflow-hidden rounded-xl border border-border bg-surface-3">
|
||||||
|
<img
|
||||||
|
:src="previewSrc"
|
||||||
|
alt="پیشنمایش تصویر ورود"
|
||||||
|
class="aspect-[16/10] w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="mt-2 text-xs text-muted">
|
||||||
|
{{
|
||||||
|
settings?.loginHeroImageUrl
|
||||||
|
? 'تصویر سفارشی فعال است.'
|
||||||
|
: 'در حال حاضر تصویر پیشفرض نمایش داده میشود.'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-4 flex flex-wrap gap-3">
|
||||||
|
<label
|
||||||
|
class="inline-flex cursor-pointer items-center gap-2 rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-brand-700"
|
||||||
|
>
|
||||||
|
<AppIcon name="camera" size="sm" />
|
||||||
|
<span>{{ uploading ? 'در حال آپلود...' : 'آپلود تصویر جدید' }}</span>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||||
|
class="hidden"
|
||||||
|
:disabled="uploading"
|
||||||
|
@change="onPick"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<AppButton
|
||||||
|
v-if="settings?.loginHeroImageUrl"
|
||||||
|
variant="ghost"
|
||||||
|
:loading="removing"
|
||||||
|
@click="onRemove"
|
||||||
|
>
|
||||||
|
حذف تصویر سفارشی
|
||||||
|
</AppButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</AdminLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import {
|
||||||
|
fetchPlatformSettings,
|
||||||
|
removeLoginHeroImage,
|
||||||
|
uploadLoginHeroImage,
|
||||||
|
type PlatformSettings,
|
||||||
|
} from '@/api/platform-settings';
|
||||||
|
import { ApiError } from '@/api/client';
|
||||||
|
import AppButton from '@/components/ui/AppButton.vue';
|
||||||
|
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||||
|
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||||
|
import { uploadUrl } from '@/utils/media';
|
||||||
|
|
||||||
|
const DEFAULT_PREVIEW = '/landing/hero-car-wash.jpg';
|
||||||
|
|
||||||
|
const settings = ref<PlatformSettings | null>(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const uploading = ref(false);
|
||||||
|
const removing = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
const success = ref('');
|
||||||
|
|
||||||
|
const previewSrc = computed(
|
||||||
|
() => uploadUrl(settings.value?.loginHeroImageUrl) || DEFAULT_PREVIEW,
|
||||||
|
);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
settings.value = await fetchPlatformSettings();
|
||||||
|
} catch (e) {
|
||||||
|
error.value =
|
||||||
|
e instanceof ApiError ? e.message : 'بارگذاری تنظیمات ناموفق بود.';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPick(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const file = input.files?.[0];
|
||||||
|
input.value = '';
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
uploading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
success.value = '';
|
||||||
|
try {
|
||||||
|
settings.value = await uploadLoginHeroImage(file);
|
||||||
|
success.value = 'تصویر صفحه ورود با موفقیت بهروزرسانی شد.';
|
||||||
|
} catch (e) {
|
||||||
|
error.value =
|
||||||
|
e instanceof ApiError ? e.message : 'آپلود تصویر ناموفق بود.';
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRemove() {
|
||||||
|
if (!settings.value?.loginHeroImageUrl) return;
|
||||||
|
|
||||||
|
removing.value = true;
|
||||||
|
error.value = '';
|
||||||
|
success.value = '';
|
||||||
|
try {
|
||||||
|
settings.value = await removeLoginHeroImage();
|
||||||
|
success.value = 'تصویر سفارشی حذف شد. تصویر پیشفرض نمایش داده میشود.';
|
||||||
|
} catch (e) {
|
||||||
|
error.value =
|
||||||
|
e instanceof ApiError ? e.message : 'حذف تصویر ناموفق بود.';
|
||||||
|
} finally {
|
||||||
|
removing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void load();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
+3
-32
@@ -1,33 +1,4 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
fontFamily: {
|
||||||
darkMode: 'class',
|
sans: ['IRANYekanX', 'ui-sans-serif', 'system-ui', 'sans-serif'],
|
||||||
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
fontFamily: {
|
|
||||||
sans: ['Vazirmatn', 'ui-sans-serif', 'system-ui', 'sans-serif'],
|
|
||||||
},
|
|
||||||
colors: {
|
|
||||||
brand: {
|
|
||||||
50: '#fffbeb',
|
|
||||||
100: '#fef3c7',
|
|
||||||
500: '#f59e0b',
|
|
||||||
600: '#d97706',
|
|
||||||
700: '#b45309',
|
|
||||||
},
|
|
||||||
surface: {
|
|
||||||
DEFAULT: 'var(--color-surface)',
|
|
||||||
2: 'var(--color-surface-2)',
|
|
||||||
3: 'var(--color-surface-3)',
|
|
||||||
},
|
|
||||||
border: 'var(--color-border)',
|
|
||||||
text: 'var(--color-text)',
|
|
||||||
muted: 'var(--color-muted)',
|
|
||||||
},
|
|
||||||
maxWidth: {
|
|
||||||
app: '768px',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
||||||
|
|||||||
Reference in New Issue
Block a user