Complete super admin panel with CRUD, bookings, profile, and dev API proxy.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+3
-1
@@ -1 +1,3 @@
|
|||||||
VITE_API_URL=https://api.montime.ir
|
# Dev (npm run dev): /api — proxied to api.montime.ir by Vite
|
||||||
|
# Build (npm run build): use .env.production → https://api.montime.ir
|
||||||
|
VITE_API_URL=/api
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_URL=https://api.montime.ir
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<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.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||||
|
<rect width="32" height="32" rx="8" fill="#d97706"/>
|
||||||
|
<text x="16" y="22" text-anchor="middle" font-family="system-ui,sans-serif" font-size="14" font-weight="700" fill="white">A</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 268 B |
+5
-9
@@ -1,12 +1,8 @@
|
|||||||
|
import { normalizeIranMobile } from '@/utils/mobile';
|
||||||
import { api } from './client';
|
import { api } from './client';
|
||||||
|
import type { User } from './types';
|
||||||
|
|
||||||
export type User = {
|
export type { User };
|
||||||
id: string;
|
|
||||||
mobile: string;
|
|
||||||
fullName: string | null;
|
|
||||||
role: string;
|
|
||||||
isActive: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type RequestOtpResponse = {
|
export type RequestOtpResponse = {
|
||||||
expiresInSeconds: number;
|
expiresInSeconds: number;
|
||||||
@@ -21,14 +17,14 @@ export type VerifyOtpResponse = {
|
|||||||
export function requestOtp(mobile: string) {
|
export function requestOtp(mobile: string) {
|
||||||
return api<RequestOtpResponse>('/auth/request-otp', {
|
return api<RequestOtpResponse>('/auth/request-otp', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ mobile }),
|
body: JSON.stringify({ mobile: normalizeIranMobile(mobile) }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function verifyOtp(mobile: string, code: string) {
|
export function verifyOtp(mobile: string, code: string) {
|
||||||
return api<VerifyOtpResponse>('/auth/verify-otp', {
|
return api<VerifyOtpResponse>('/auth/verify-otp', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ mobile, code }),
|
body: JSON.stringify({ mobile: normalizeIranMobile(mobile), code }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { api } from './client';
|
||||||
|
import type { Booking, BookingStatus } from './types';
|
||||||
|
|
||||||
|
export function fetchBookings(params?: {
|
||||||
|
businessId?: string;
|
||||||
|
status?: BookingStatus;
|
||||||
|
}) {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (params?.businessId) search.set('businessId', params.businessId);
|
||||||
|
if (params?.status) search.set('status', params.status);
|
||||||
|
const query = search.toString();
|
||||||
|
return api<Booking[]>(`/bookings${query ? `?${query}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBooking(id: string) {
|
||||||
|
return api<{ success: true }>(`/bookings/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { api } from './client';
|
||||||
|
import type {
|
||||||
|
BusinessCategory,
|
||||||
|
CreateCategoryPayload,
|
||||||
|
UpdateCategoryPayload,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export function fetchCategories() {
|
||||||
|
return api<BusinessCategory[]>('/business-categories');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchCategory(id: string) {
|
||||||
|
return api<BusinessCategory>(`/business-categories/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCategory(payload: CreateCategoryPayload) {
|
||||||
|
return api<BusinessCategory>('/business-categories', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCategory(id: string, payload: UpdateCategoryPayload) {
|
||||||
|
return api<BusinessCategory>(`/business-categories/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteCategory(id: string) {
|
||||||
|
return api<{ success: true }>(`/business-categories/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { api } from './client';
|
||||||
|
import type {
|
||||||
|
AssignOwnerPayload,
|
||||||
|
Business,
|
||||||
|
CreateBusinessPayload,
|
||||||
|
UpdateBusinessPayload,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export function fetchBusinesses(params?: {
|
||||||
|
categoryId?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
}) {
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
if (params?.categoryId) search.set('categoryId', params.categoryId);
|
||||||
|
if (params?.isActive !== undefined) search.set('isActive', String(params.isActive));
|
||||||
|
const query = search.toString();
|
||||||
|
return api<Business[]>(`/businesses${query ? `?${query}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchBusiness(id: string) {
|
||||||
|
return api<Business>(`/businesses/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBusiness(payload: CreateBusinessPayload) {
|
||||||
|
return api<Business>('/businesses', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBusiness(id: string, payload: UpdateBusinessPayload) {
|
||||||
|
return api<Business>(`/businesses/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBusiness(id: string) {
|
||||||
|
return api<{ success: true }>(`/businesses/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assignBusinessOwner(id: string, payload: AssignOwnerPayload) {
|
||||||
|
return api<Business>(`/businesses/${id}/assign-owner`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
export type UserRole = 'customer' | 'business_owner' | 'admin';
|
||||||
|
|
||||||
|
export type User = {
|
||||||
|
id: string;
|
||||||
|
mobile: string;
|
||||||
|
fullName: string | null;
|
||||||
|
role: UserRole;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BusinessCategory = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
isActive: boolean;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
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 UpdateUserPayload = {
|
||||||
|
fullName?: string | null;
|
||||||
|
role?: UserRole;
|
||||||
|
isActive?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssignOwnerPayload = {
|
||||||
|
mobile: string;
|
||||||
|
fullName?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BookingStatus =
|
||||||
|
| 'pending'
|
||||||
|
| 'confirmed'
|
||||||
|
| 'cancelled'
|
||||||
|
| 'completed';
|
||||||
|
|
||||||
|
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 };
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { api } from './client';
|
||||||
|
import type { UpdateUserPayload, User } from './types';
|
||||||
|
|
||||||
|
export function fetchUsers() {
|
||||||
|
return api<User[]>('/users');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchUser(id: string) {
|
||||||
|
return api<User>(`/users/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateUser(id: string, payload: UpdateUserPayload) {
|
||||||
|
return api<User>(`/users/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<span
|
||||||
|
class="inline-flex rounded-lg px-2 py-0.5 text-xs font-medium"
|
||||||
|
:class="toneClass"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{ tone?: 'default' | 'success' | 'warning' | 'danger' }>(),
|
||||||
|
{ tone: 'default' },
|
||||||
|
);
|
||||||
|
|
||||||
|
const toneClass = computed(() => {
|
||||||
|
switch (props.tone) {
|
||||||
|
case 'success':
|
||||||
|
return 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400';
|
||||||
|
case 'warning':
|
||||||
|
return 'bg-amber-500/15 text-amber-700 dark:text-amber-400';
|
||||||
|
case 'danger':
|
||||||
|
return 'bg-red-500/15 text-red-600 dark:text-red-400';
|
||||||
|
default:
|
||||||
|
return 'bg-surface-3 text-muted';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
:id="id"
|
:id="id"
|
||||||
|
v-model="model"
|
||||||
class="w-full rounded-xl border border-border bg-surface-2 px-4 py-3 text-text outline-none transition placeholder:text-muted focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
|
class="w-full rounded-xl border border-border bg-surface-2 px-4 py-3 text-text outline-none transition placeholder:text-muted focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
|
||||||
:class="ltr ? 'text-left' : ''"
|
:class="ltr ? 'text-left' : ''"
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
@@ -15,6 +16,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineOptions({ inheritAttrs: false });
|
defineOptions({ inheritAttrs: false });
|
||||||
|
|
||||||
|
const model = defineModel<string>({ default: '' });
|
||||||
|
|
||||||
withDefaults(
|
withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
label?: string;
|
label?: string;
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="open"
|
||||||
|
class="fixed inset-0 z-50 flex items-end justify-center bg-black/50 p-0 sm:items-center sm:p-4"
|
||||||
|
@click.self="emit('close')"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="max-h-[90dvh] w-full max-w-app overflow-y-auto rounded-t-2xl border border-border bg-surface p-4 shadow-xl sm:rounded-2xl"
|
||||||
|
role="dialog"
|
||||||
|
>
|
||||||
|
<div class="mb-4 flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 v-if="title" class="text-lg font-bold text-text">{{ title }}</h2>
|
||||||
|
<p v-if="subtitle" class="mt-0.5 text-sm text-muted">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg px-2 py-1 text-muted hover:bg-surface-3"
|
||||||
|
aria-label="بستن"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
open: boolean;
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{ close: [] }>();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label v-if="label" :for="id" class="block text-sm font-medium text-muted">
|
||||||
|
{{ label }}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
:id="id"
|
||||||
|
:value="modelValue"
|
||||||
|
class="w-full rounded-xl border border-border bg-surface-2 px-4 py-3 text-text outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
|
||||||
|
:class="ltr ? 'text-left' : ''"
|
||||||
|
@change="onChange"
|
||||||
|
>
|
||||||
|
<option v-if="placeholder" disabled value="">{{ placeholder }}</option>
|
||||||
|
<option v-for="opt in options" :key="opt.value" :value="opt.value">
|
||||||
|
{{ opt.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
label?: string;
|
||||||
|
id?: string;
|
||||||
|
modelValue: string;
|
||||||
|
options: { value: string; label: string }[];
|
||||||
|
placeholder?: string;
|
||||||
|
ltr?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: string] }>();
|
||||||
|
|
||||||
|
function onChange(e: Event) {
|
||||||
|
emit('update:modelValue', (e.target as HTMLSelectElement).value);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label v-if="label" :for="id" class="block text-sm font-medium text-muted">
|
||||||
|
{{ label }}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
:id="id"
|
||||||
|
v-model="model"
|
||||||
|
:rows="rows"
|
||||||
|
class="w-full resize-y rounded-xl border border-border bg-surface-2 px-4 py-3 text-text outline-none transition placeholder:text-muted focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
|
||||||
|
v-bind="$attrs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineOptions({ inheritAttrs: false });
|
||||||
|
|
||||||
|
const model = defineModel<string>({ default: '' });
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
label?: string;
|
||||||
|
id?: string;
|
||||||
|
rows?: number;
|
||||||
|
}>(),
|
||||||
|
{ rows: 3 },
|
||||||
|
);
|
||||||
|
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="rounded-2xl border border-dashed border-border bg-surface-2 px-4 py-8 text-center text-sm text-muted"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-bold text-text">{{ title }}</h1>
|
||||||
|
<p v-if="subtitle" class="mt-1 text-sm text-muted">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ title: string; subtitle?: string }>();
|
||||||
|
</script>
|
||||||
@@ -3,11 +3,20 @@
|
|||||||
<header
|
<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"
|
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"
|
||||||
>
|
>
|
||||||
<div>
|
<RouterLink :to="{ name: 'dashboard' }" class="min-w-0">
|
||||||
<p class="font-bold text-text">پنل مدیریت</p>
|
<p class="font-bold text-text">پنل مدیریت</p>
|
||||||
<p class="text-xs text-brand-600 dark:text-brand-500">adm.montime.ir</p>
|
<p class="truncate text-xs text-brand-600 dark:text-brand-500">
|
||||||
</div>
|
{{ auth.user?.fullName ?? auth.user?.mobile ?? 'adm.montime.ir' }}
|
||||||
<div class="flex items-center gap-2">
|
</p>
|
||||||
|
</RouterLink>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<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 />
|
<ThemeToggle />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -23,20 +32,20 @@
|
|||||||
<slot />
|
<slot />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<nav class="sticky bottom-0 border-t border-border bg-surface/95 px-2 py-2">
|
<nav class="sticky bottom-0 border-t border-border bg-surface/95 px-1 py-2">
|
||||||
<div class="grid grid-cols-3 gap-1">
|
<div class="grid grid-cols-5 gap-0.5">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="item in items"
|
v-for="item in items"
|
||||||
:key="item.to"
|
:key="item.to"
|
||||||
:to="item.to"
|
:to="item.to"
|
||||||
class="flex flex-col items-center rounded-xl py-2 text-xs"
|
class="flex flex-col items-center rounded-xl py-1.5 text-[9px] leading-tight sm:py-2 sm:text-xs"
|
||||||
:class="
|
:class="
|
||||||
route.name === item.name
|
route.name === item.name
|
||||||
? 'bg-brand-500/15 text-brand-700 dark:text-brand-500'
|
? 'bg-brand-500/15 text-brand-700 dark:text-brand-500'
|
||||||
: 'text-muted hover:bg-surface-3'
|
: 'text-muted hover:bg-surface-3'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<span class="text-lg">{{ item.icon }}</span>
|
<span class="text-base sm:text-lg">{{ item.icon }}</span>
|
||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,15 +61,18 @@ import { useAuthStore } from '@/stores/auth';
|
|||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ to: '/', name: 'dashboard', label: 'داشبورد', icon: '📊' },
|
{ to: '/', name: 'dashboard', label: 'داشبورد', icon: '📊' },
|
||||||
|
{ to: '/categories', name: 'categories', label: 'دسته', icon: '📁' },
|
||||||
{ to: '/businesses', name: 'businesses', label: 'کسبوکار', icon: '🏪' },
|
{ to: '/businesses', name: 'businesses', label: 'کسبوکار', icon: '🏪' },
|
||||||
|
{ to: '/bookings', name: 'bookings', label: 'رزرو', icon: '📅' },
|
||||||
{ to: '/users', name: 'users', label: 'کاربران', icon: '👥' },
|
{ to: '/users', name: 'users', label: 'کاربران', icon: '👥' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function onLogout() {
|
function onLogout() {
|
||||||
useAuthStore().logout();
|
auth.logout();
|
||||||
router.push({ name: 'login' });
|
router.push({ name: 'login' });
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ const router = createRouter({
|
|||||||
component: () => import('@/views/DashboardView.vue'),
|
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: '/businesses',
|
path: '/businesses',
|
||||||
name: 'businesses',
|
name: 'businesses',
|
||||||
@@ -34,6 +40,18 @@ const router = createRouter({
|
|||||||
component: () => import('@/views/UsersView.vue'),
|
component: () => import('@/views/UsersView.vue'),
|
||||||
meta: { requiresAuth: true, requiresAdmin: true },
|
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 },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setUser(profile: User) {
|
||||||
|
user.value = profile;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
token,
|
token,
|
||||||
user,
|
user,
|
||||||
@@ -42,5 +46,6 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
setSession,
|
setSession,
|
||||||
logout,
|
logout,
|
||||||
loadUser,
|
loadUser,
|
||||||
|
setUser,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { UserRole } from '@/api/types';
|
||||||
|
|
||||||
|
export const ROLE_LABELS: Record<UserRole, string> = {
|
||||||
|
admin: 'سوپر ادمین',
|
||||||
|
business_owner: 'صاحب کسبوکار',
|
||||||
|
customer: 'مشتری',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function roleLabel(role: UserRole | string): string {
|
||||||
|
return ROLE_LABELS[role as UserRole] ?? role;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(value?: string | Date): string {
|
||||||
|
if (!value) return '—';
|
||||||
|
const date = typeof value === 'string' ? new Date(value) : value;
|
||||||
|
return new Intl.DateTimeFormat('fa-IR', {
|
||||||
|
dateStyle: 'short',
|
||||||
|
timeStyle: 'short',
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activeLabel(isActive: boolean): string {
|
||||||
|
return isActive ? 'فعال' : 'غیرفعال';
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
|
||||||
|
const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩';
|
||||||
|
const INVISIBLE_CHARS = /[\u200c\u200d\u202a-\u202e\ufeff]/g;
|
||||||
|
|
||||||
|
function toAsciiDigits(value: string): string {
|
||||||
|
return value.replace(/[۰-۹٠-٩]/g, (ch) => {
|
||||||
|
const persian = PERSIAN_DIGITS.indexOf(ch);
|
||||||
|
if (persian >= 0) return String(persian);
|
||||||
|
const arabic = ARABIC_DIGITS.indexOf(ch);
|
||||||
|
if (arabic >= 0) return String(arabic);
|
||||||
|
return ch;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalize Iranian mobile to 09xxxxxxxxx (11 digits). */
|
||||||
|
export function normalizeIranMobile(raw: string): string {
|
||||||
|
let mobile = toAsciiDigits(String(raw ?? '').trim());
|
||||||
|
mobile = mobile.replace(INVISIBLE_CHARS, '');
|
||||||
|
|
||||||
|
let digits = mobile.replace(/\D/g, '');
|
||||||
|
if (!digits) return '';
|
||||||
|
|
||||||
|
if (digits.startsWith('0098')) {
|
||||||
|
digits = digits.slice(2);
|
||||||
|
}
|
||||||
|
if (digits.startsWith('98') && digits.length >= 12) {
|
||||||
|
digits = `0${digits.slice(2)}`;
|
||||||
|
} else if (digits.startsWith('9') && digits.length === 10) {
|
||||||
|
digits = `0${digits}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return digits;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidIranMobile(mobile: string): boolean {
|
||||||
|
return /^09\d{9}$/.test(mobile);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/** Simple slug for API (latin, hyphen-separated). */
|
||||||
|
export function slugify(value: string): string {
|
||||||
|
return value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/[^a-z0-9-]/g, '')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||||
|
|
||||||
|
export function isValidSlug(slug: string): boolean {
|
||||||
|
return SLUG_PATTERN.test(slug);
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
<template>
|
||||||
|
<AdminLayout>
|
||||||
|
<PageHeader title="رزروها" subtitle="مشاهده و حذف رزروها (قبل از حذف کسبوکار)" />
|
||||||
|
|
||||||
|
<div class="mt-4 grid grid-cols-2 gap-2">
|
||||||
|
<AppSelect
|
||||||
|
id="filterBiz"
|
||||||
|
v-model="filterBusinessId"
|
||||||
|
placeholder="همه کسبوکارها"
|
||||||
|
:options="businessFilterOptions"
|
||||||
|
/>
|
||||||
|
<AppSelect id="filterStatus" v-model="filterStatus" :options="statusFilterOptions" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="mt-4 rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-600">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="loading" class="mt-4 text-sm text-muted">در حال بارگذاری…</div>
|
||||||
|
|
||||||
|
<EmptyState v-else-if="bookings.length === 0" class="mt-4">
|
||||||
|
رزروی ثبت نشده است.
|
||||||
|
</EmptyState>
|
||||||
|
|
||||||
|
<ul v-else class="mt-4 space-y-2">
|
||||||
|
<li
|
||||||
|
v-for="b in bookings"
|
||||||
|
:key="b.id"
|
||||||
|
class="rounded-2xl border border-border bg-surface-2 p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="font-semibold text-text">
|
||||||
|
{{ b.business?.title ?? 'کسبوکار' }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-0.5 text-xs text-muted">
|
||||||
|
{{ serviceTitle(b) }} — {{ formatDate(b.scheduledAt) }}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-muted" dir="ltr">
|
||||||
|
{{ b.customer?.mobile ?? b.customerId }}
|
||||||
|
</p>
|
||||||
|
<AppBadge class="mt-2" :tone="statusTone(b.status)">
|
||||||
|
{{ statusLabel(b.status) }}
|
||||||
|
</AppBadge>
|
||||||
|
</div>
|
||||||
|
<AppButton variant="danger" class="!w-auto shrink-0 px-3" @click="onDelete(b)">
|
||||||
|
حذف
|
||||||
|
</AppButton>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</AdminLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
import { deleteBooking, fetchBookings } from '@/api/bookings';
|
||||||
|
import { fetchBusinesses } from '@/api/businesses';
|
||||||
|
import type { Booking, BookingStatus, Business } from '@/api/types';
|
||||||
|
import { ApiError } from '@/api/client';
|
||||||
|
import AppBadge from '@/components/ui/AppBadge.vue';
|
||||||
|
import AppButton from '@/components/ui/AppButton.vue';
|
||||||
|
import AppSelect from '@/components/ui/AppSelect.vue';
|
||||||
|
import EmptyState from '@/components/ui/EmptyState.vue';
|
||||||
|
import PageHeader from '@/components/ui/PageHeader.vue';
|
||||||
|
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||||
|
import { formatDate } from '@/utils/format';
|
||||||
|
|
||||||
|
const bookings = ref<Booking[]>([]);
|
||||||
|
const businesses = ref<Business[]>([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
const error = ref('');
|
||||||
|
|
||||||
|
const filterBusinessId = ref('');
|
||||||
|
const filterStatus = ref('all');
|
||||||
|
|
||||||
|
const businessFilterOptions = computed(() => [
|
||||||
|
{ value: '', label: 'همه کسبوکارها' },
|
||||||
|
...businesses.value.map((b) => ({ value: b.id, label: b.title })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const statusFilterOptions = [
|
||||||
|
{ value: 'all', label: 'همه وضعیتها' },
|
||||||
|
{ value: 'pending', label: 'در انتظار' },
|
||||||
|
{ value: 'confirmed', label: 'تأیید شده' },
|
||||||
|
{ value: 'completed', label: 'انجام شده' },
|
||||||
|
{ value: 'cancelled', label: 'لغو شده' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function statusLabel(status: BookingStatus): string {
|
||||||
|
const map: Record<BookingStatus, string> = {
|
||||||
|
pending: 'در انتظار',
|
||||||
|
confirmed: 'تأیید شده',
|
||||||
|
completed: 'انجام شده',
|
||||||
|
cancelled: 'لغو شده',
|
||||||
|
};
|
||||||
|
return map[status] ?? status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusTone(status: BookingStatus): 'default' | 'success' | 'warning' | 'danger' {
|
||||||
|
if (status === 'confirmed' || status === 'completed') return 'success';
|
||||||
|
if (status === 'pending') return 'warning';
|
||||||
|
if (status === 'cancelled') return 'danger';
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
function serviceTitle(b: Booking): string {
|
||||||
|
return b.businessService?.serviceTemplate?.title ?? 'خدمت';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
const params: { businessId?: string; status?: BookingStatus } = {};
|
||||||
|
if (filterBusinessId.value) params.businessId = filterBusinessId.value;
|
||||||
|
if (filterStatus.value !== 'all') {
|
||||||
|
params.status = filterStatus.value as BookingStatus;
|
||||||
|
}
|
||||||
|
bookings.value = await fetchBookings(params);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof ApiError ? e.message : 'خطا در بارگذاری';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(b: Booking) {
|
||||||
|
if (!confirm('این رزرو حذف شود؟')) return;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
await deleteBooking(b.id);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof ApiError ? e.message : 'خطا در حذف';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([filterBusinessId, filterStatus], () => {
|
||||||
|
if (!loading.value) load();
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
businesses.value = await fetchBusinesses();
|
||||||
|
} catch {
|
||||||
|
businesses.value = [];
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -1,14 +1,377 @@
|
|||||||
<template>
|
<template>
|
||||||
<AdminLayout>
|
<AdminLayout>
|
||||||
<h1 class="text-xl font-bold text-text">کسبوکارها</h1>
|
<PageHeader title="کسبوکارها" subtitle="ایجاد، ویرایش و واگذاری مدیر">
|
||||||
<div
|
<template #actions>
|
||||||
class="mt-4 rounded-2xl border border-dashed border-border bg-surface-2 p-6 text-center text-sm text-muted"
|
<AppButton class="!w-auto shrink-0 px-4" @click="openCreate">جدید</AppButton>
|
||||||
>
|
</template>
|
||||||
بهزودی — CRUD و واگذاری مدیر کسبوکار
|
</PageHeader>
|
||||||
|
|
||||||
|
<div class="mt-4 grid grid-cols-2 gap-2">
|
||||||
|
<AppSelect
|
||||||
|
id="filterCat"
|
||||||
|
v-model="filterCategoryId"
|
||||||
|
placeholder="همه دستهها"
|
||||||
|
:options="categoryFilterOptions"
|
||||||
|
/>
|
||||||
|
<AppSelect
|
||||||
|
id="filterActive"
|
||||||
|
v-model="filterActive"
|
||||||
|
:options="activeFilterOptions"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="mt-4 rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-600">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="loading" class="mt-4 text-sm text-muted">در حال بارگذاری…</div>
|
||||||
|
|
||||||
|
<EmptyState v-else-if="businesses.length === 0" class="mt-4">
|
||||||
|
کسبوکاری یافت نشد.
|
||||||
|
</EmptyState>
|
||||||
|
|
||||||
|
<ul v-else class="mt-4 space-y-2">
|
||||||
|
<li
|
||||||
|
v-for="biz in businesses"
|
||||||
|
:key="biz.id"
|
||||||
|
class="rounded-2xl border border-border bg-surface-2 p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="font-semibold text-text">{{ biz.title }}</p>
|
||||||
|
<p class="mt-0.5 text-xs text-muted" dir="ltr">{{ biz.slug }}</p>
|
||||||
|
<p v-if="biz.category" class="mt-1 text-xs text-muted">{{ biz.category.title }}</p>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<AppBadge :tone="biz.isActive ? 'success' : 'warning'">
|
||||||
|
{{ activeLabel(biz.isActive) }}
|
||||||
|
</AppBadge>
|
||||||
|
<AppBadge v-if="ownerLabel(biz)" tone="default">{{ ownerLabel(biz) }}</AppBadge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 grid grid-cols-3 gap-1">
|
||||||
|
<AppButton variant="ghost" class="!w-auto px-2 text-xs" @click="openEdit(biz)">
|
||||||
|
ویرایش
|
||||||
|
</AppButton>
|
||||||
|
<AppButton variant="ghost" class="!w-auto px-2 text-xs" @click="openAssign(biz)">
|
||||||
|
مدیر
|
||||||
|
</AppButton>
|
||||||
|
<AppButton variant="danger" class="!w-auto px-2 text-xs" @click="onDelete(biz)">
|
||||||
|
حذف
|
||||||
|
</AppButton>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Create / Edit -->
|
||||||
|
<AppModal
|
||||||
|
:open="formOpen"
|
||||||
|
:title="editingId ? 'ویرایش کسبوکار' : 'کسبوکار جدید'"
|
||||||
|
@close="closeForm"
|
||||||
|
>
|
||||||
|
<form class="space-y-4" @submit.prevent="onSaveBusiness">
|
||||||
|
<AppInput id="bizTitle" v-model="form.title" label="عنوان" required @blur="maybeSlug" />
|
||||||
|
<AppInput id="bizSlug" v-model="form.slug" label="اسلاگ" ltr required />
|
||||||
|
<AppSelect
|
||||||
|
id="bizCategory"
|
||||||
|
v-model="form.categoryId"
|
||||||
|
label="دسته"
|
||||||
|
placeholder="انتخاب دسته"
|
||||||
|
:options="categoryOptions"
|
||||||
|
/>
|
||||||
|
<AppTextarea id="bizDesc" v-model="form.description" label="توضیحات" />
|
||||||
|
<AppInput id="bizPhone" v-model="form.contactPhone" label="تلفن" ltr inputmode="tel" />
|
||||||
|
<AppInput id="bizAddress" v-model="form.address" label="آدرس" />
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<AppInput id="bizLat" v-model="form.latitude" label="عرض جغرافیایی" ltr type="text" />
|
||||||
|
<AppInput id="bizLng" v-model="form.longitude" label="طول جغرافیایی" ltr type="text" />
|
||||||
|
</div>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-text">
|
||||||
|
<input v-model="form.isActive" type="checkbox" class="rounded border-border" />
|
||||||
|
فعال
|
||||||
|
</label>
|
||||||
|
<AppButton type="submit" :loading="saving">ذخیره</AppButton>
|
||||||
|
</form>
|
||||||
|
</AppModal>
|
||||||
|
|
||||||
|
<!-- Assign owner -->
|
||||||
|
<AppModal
|
||||||
|
:open="assignOpen"
|
||||||
|
title="واگذاری مدیر کسبوکار"
|
||||||
|
subtitle="کاربر با این موبایل ساخته یا به نقش صاحب کسبوکار ارتقا مییابد"
|
||||||
|
@close="assignOpen = false"
|
||||||
|
>
|
||||||
|
<form v-if="assignTarget" class="space-y-4" @submit.prevent="onAssign">
|
||||||
|
<p class="text-sm font-medium text-text">{{ assignTarget.title }}</p>
|
||||||
|
<AppInput
|
||||||
|
id="ownerMobile"
|
||||||
|
v-model="assignForm.mobile"
|
||||||
|
label="موبایل مدیر"
|
||||||
|
ltr
|
||||||
|
inputmode="tel"
|
||||||
|
placeholder="09121234567"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<AppInput id="ownerName" v-model="assignForm.fullName" label="نام (اختیاری)" />
|
||||||
|
<AppButton type="submit" :loading="saving">واگذاری</AppButton>
|
||||||
|
</form>
|
||||||
|
</AppModal>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
import { fetchCategories } from '@/api/business-categories';
|
||||||
|
import {
|
||||||
|
assignBusinessOwner,
|
||||||
|
createBusiness,
|
||||||
|
deleteBusiness,
|
||||||
|
fetchBusinesses,
|
||||||
|
updateBusiness,
|
||||||
|
} from '@/api/businesses';
|
||||||
|
import { fetchUsers } from '@/api/users';
|
||||||
|
import type { Business, BusinessCategory, User } from '@/api/types';
|
||||||
|
import { ApiError } from '@/api/client';
|
||||||
|
import AppBadge from '@/components/ui/AppBadge.vue';
|
||||||
|
import AppButton from '@/components/ui/AppButton.vue';
|
||||||
|
import AppInput from '@/components/ui/AppInput.vue';
|
||||||
|
import AppModal from '@/components/ui/AppModal.vue';
|
||||||
|
import AppSelect from '@/components/ui/AppSelect.vue';
|
||||||
|
import AppTextarea from '@/components/ui/AppTextarea.vue';
|
||||||
|
import EmptyState from '@/components/ui/EmptyState.vue';
|
||||||
|
import PageHeader from '@/components/ui/PageHeader.vue';
|
||||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||||
|
import { activeLabel } from '@/utils/format';
|
||||||
|
import { isValidIranMobile, normalizeIranMobile } from '@/utils/mobile';
|
||||||
|
import { isValidSlug, slugify } from '@/utils/slug';
|
||||||
|
|
||||||
|
const businesses = ref<Business[]>([]);
|
||||||
|
const categories = ref<BusinessCategory[]>([]);
|
||||||
|
const usersById = ref<Record<string, User>>({});
|
||||||
|
const loading = ref(true);
|
||||||
|
const error = ref('');
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
|
const filterCategoryId = ref('');
|
||||||
|
const filterActive = ref('all');
|
||||||
|
|
||||||
|
const formOpen = ref(false);
|
||||||
|
const editingId = ref<string | null>(null);
|
||||||
|
const slugTouched = ref(false);
|
||||||
|
|
||||||
|
const assignOpen = ref(false);
|
||||||
|
const assignTarget = ref<Business | null>(null);
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
title: '',
|
||||||
|
slug: '',
|
||||||
|
categoryId: '',
|
||||||
|
description: '',
|
||||||
|
contactPhone: '',
|
||||||
|
address: '',
|
||||||
|
latitude: '',
|
||||||
|
longitude: '',
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const assignForm = ref({ mobile: '', fullName: '' });
|
||||||
|
|
||||||
|
const categoryOptions = computed(() =>
|
||||||
|
categories.value.map((c) => ({ value: c.id, label: c.title })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const categoryFilterOptions = computed(() => [
|
||||||
|
{ value: '', label: 'همه دستهها' },
|
||||||
|
...categoryOptions.value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const activeFilterOptions = [
|
||||||
|
{ value: 'all', label: 'همه' },
|
||||||
|
{ value: 'true', label: 'فعال' },
|
||||||
|
{ value: 'false', label: 'غیرفعال' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function ownerLabel(biz: Business): string | null {
|
||||||
|
if (!biz.ownerId) return 'بدون مدیر';
|
||||||
|
const u = usersById.value[biz.ownerId];
|
||||||
|
return u ? `${u.fullName ?? u.mobile}` : `مدیر: ${biz.ownerId.slice(0, 8)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCoord(value: string): number | undefined {
|
||||||
|
const n = parseFloat(value.trim());
|
||||||
|
return Number.isFinite(n) ? n : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
const params: { categoryId?: string; isActive?: boolean } = {};
|
||||||
|
if (filterCategoryId.value) params.categoryId = filterCategoryId.value;
|
||||||
|
if (filterActive.value !== 'all') params.isActive = filterActive.value === 'true';
|
||||||
|
|
||||||
|
const [bizList, cats, userList] = await Promise.all([
|
||||||
|
fetchBusinesses(params),
|
||||||
|
fetchCategories(),
|
||||||
|
fetchUsers().catch(() => [] as User[]),
|
||||||
|
]);
|
||||||
|
businesses.value = bizList;
|
||||||
|
categories.value = cats;
|
||||||
|
usersById.value = Object.fromEntries(userList.map((u) => [u.id, u]));
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof ApiError ? e.message : 'خطا در بارگذاری';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
if (categories.value.length === 0) {
|
||||||
|
error.value = 'ابتدا یک دسته در بخش «دستهها» بسازید.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editingId.value = null;
|
||||||
|
slugTouched.value = false;
|
||||||
|
form.value = {
|
||||||
|
title: '',
|
||||||
|
slug: '',
|
||||||
|
categoryId: categories.value[0]?.id ?? '',
|
||||||
|
description: '',
|
||||||
|
contactPhone: '',
|
||||||
|
address: '',
|
||||||
|
latitude: '',
|
||||||
|
longitude: '',
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
formOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(biz: Business) {
|
||||||
|
editingId.value = biz.id;
|
||||||
|
slugTouched.value = true;
|
||||||
|
form.value = {
|
||||||
|
title: biz.title,
|
||||||
|
slug: biz.slug,
|
||||||
|
categoryId: biz.categoryId,
|
||||||
|
description: biz.description ?? '',
|
||||||
|
contactPhone: biz.contactPhone ?? '',
|
||||||
|
address: biz.address ?? '',
|
||||||
|
latitude: biz.latitude != null ? String(biz.latitude) : '',
|
||||||
|
longitude: biz.longitude != null ? String(biz.longitude) : '',
|
||||||
|
isActive: biz.isActive,
|
||||||
|
};
|
||||||
|
formOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeForm() {
|
||||||
|
formOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeSlug() {
|
||||||
|
if (!slugTouched.value && !editingId.value) {
|
||||||
|
form.value.slug = slugify(form.value.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAssign(biz: Business) {
|
||||||
|
assignTarget.value = biz;
|
||||||
|
assignForm.value = { mobile: '', fullName: '' };
|
||||||
|
assignOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSaveBusiness() {
|
||||||
|
if (!isValidSlug(form.value.slug)) {
|
||||||
|
error.value = 'اسلاگ نامعتبر است.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.value.categoryId) {
|
||||||
|
error.value = 'دسته را انتخاب کنید.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true;
|
||||||
|
error.value = '';
|
||||||
|
const lat = parseCoord(form.value.latitude);
|
||||||
|
const lng = parseCoord(form.value.longitude);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editingId.value) {
|
||||||
|
await updateBusiness(editingId.value, {
|
||||||
|
title: form.value.title.trim(),
|
||||||
|
slug: form.value.slug.trim(),
|
||||||
|
categoryId: form.value.categoryId,
|
||||||
|
description: form.value.description.trim() || null,
|
||||||
|
contactPhone: form.value.contactPhone.trim() || null,
|
||||||
|
address: form.value.address.trim() || null,
|
||||||
|
latitude: lat ?? null,
|
||||||
|
longitude: lng ?? null,
|
||||||
|
isActive: form.value.isActive,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await createBusiness({
|
||||||
|
title: form.value.title.trim(),
|
||||||
|
slug: form.value.slug.trim(),
|
||||||
|
categoryId: form.value.categoryId,
|
||||||
|
description: form.value.description.trim() || undefined,
|
||||||
|
contactPhone: form.value.contactPhone.trim() || undefined,
|
||||||
|
address: form.value.address.trim() || undefined,
|
||||||
|
latitude: lat,
|
||||||
|
longitude: lng,
|
||||||
|
isActive: form.value.isActive,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
closeForm();
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof ApiError ? e.message : 'خطا در ذخیره';
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onAssign() {
|
||||||
|
if (!assignTarget.value) return;
|
||||||
|
const mobile = normalizeIranMobile(assignForm.value.mobile);
|
||||||
|
if (!isValidIranMobile(mobile)) {
|
||||||
|
error.value = 'شماره موبایل مدیر معتبر نیست.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
await assignBusinessOwner(assignTarget.value.id, {
|
||||||
|
mobile,
|
||||||
|
fullName: assignForm.value.fullName.trim() || null,
|
||||||
|
});
|
||||||
|
assignOpen.value = false;
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof ApiError ? e.message : 'خطا در واگذاری';
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(biz: Business) {
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
`«${biz.title}» و رزروهای مرتبط حذف شوند؟ این کار قابل بازگشت نیست.`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
await deleteBusiness(biz.id);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof ApiError ? e.message : 'خطا در حذف';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([filterCategoryId, filterActive], () => {
|
||||||
|
if (!loading.value) load();
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<template>
|
||||||
|
<AdminLayout>
|
||||||
|
<PageHeader title="دستههای کسبوکار" subtitle="دستهبندی خدمات">
|
||||||
|
<template #actions>
|
||||||
|
<AppButton class="!w-auto shrink-0 px-4" @click="openCreate">جدید</AppButton>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<p v-if="error" class="mt-4 rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-600">
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="loading" class="mt-4 text-sm text-muted">در حال بارگذاری…</div>
|
||||||
|
|
||||||
|
<EmptyState v-else-if="categories.length === 0" class="mt-4">
|
||||||
|
دستهای تعریف نشده. یک دسته جدید بسازید.
|
||||||
|
</EmptyState>
|
||||||
|
|
||||||
|
<ul v-else class="mt-4 space-y-2">
|
||||||
|
<li
|
||||||
|
v-for="cat in categories"
|
||||||
|
:key="cat.id"
|
||||||
|
class="rounded-2xl border border-border bg-surface-2 p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-text">{{ cat.title }}</p>
|
||||||
|
<p class="mt-0.5 text-xs text-muted" dir="ltr">{{ cat.slug }}</p>
|
||||||
|
<AppBadge class="mt-2" :tone="cat.isActive ? 'success' : 'warning'">
|
||||||
|
{{ activeLabel(cat.isActive) }}
|
||||||
|
</AppBadge>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 flex-col gap-1">
|
||||||
|
<AppButton variant="ghost" class="!w-auto px-3" @click="openEdit(cat)">ویرایش</AppButton>
|
||||||
|
<AppButton variant="danger" class="!w-auto px-3" @click="onDelete(cat)">حذف</AppButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<AppModal
|
||||||
|
:open="modalOpen"
|
||||||
|
:title="editingId ? 'ویرایش دسته' : 'دسته جدید'"
|
||||||
|
@close="closeModal"
|
||||||
|
>
|
||||||
|
<form class="space-y-4" @submit.prevent="onSave">
|
||||||
|
<AppInput id="catTitle" v-model="form.title" label="عنوان" required @blur="maybeSlug" />
|
||||||
|
<AppInput
|
||||||
|
id="catSlug"
|
||||||
|
v-model="form.slug"
|
||||||
|
label="اسلاگ (انگلیسی)"
|
||||||
|
ltr
|
||||||
|
placeholder="car-wash"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-text">
|
||||||
|
<input v-model="form.isActive" type="checkbox" class="rounded border-border" />
|
||||||
|
فعال
|
||||||
|
</label>
|
||||||
|
<AppButton type="submit" :loading="saving">ذخیره</AppButton>
|
||||||
|
</form>
|
||||||
|
</AppModal>
|
||||||
|
</AdminLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import {
|
||||||
|
createCategory,
|
||||||
|
deleteCategory,
|
||||||
|
fetchCategories,
|
||||||
|
updateCategory,
|
||||||
|
} from '@/api/business-categories';
|
||||||
|
import type { BusinessCategory } from '@/api/types';
|
||||||
|
import { ApiError } from '@/api/client';
|
||||||
|
import AppBadge from '@/components/ui/AppBadge.vue';
|
||||||
|
import AppButton from '@/components/ui/AppButton.vue';
|
||||||
|
import AppInput from '@/components/ui/AppInput.vue';
|
||||||
|
import AppModal from '@/components/ui/AppModal.vue';
|
||||||
|
import EmptyState from '@/components/ui/EmptyState.vue';
|
||||||
|
import PageHeader from '@/components/ui/PageHeader.vue';
|
||||||
|
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||||
|
import { activeLabel } from '@/utils/format';
|
||||||
|
import { isValidSlug, slugify } from '@/utils/slug';
|
||||||
|
|
||||||
|
const categories = ref<BusinessCategory[]>([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
const error = ref('');
|
||||||
|
const modalOpen = ref(false);
|
||||||
|
const editingId = ref<string | null>(null);
|
||||||
|
const saving = ref(false);
|
||||||
|
const slugTouched = ref(false);
|
||||||
|
|
||||||
|
const form = ref({ title: '', slug: '', isActive: true });
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
categories.value = await fetchCategories();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof ApiError ? e.message : 'خطا در بارگذاری';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editingId.value = null;
|
||||||
|
slugTouched.value = false;
|
||||||
|
form.value = { title: '', slug: '', isActive: true };
|
||||||
|
modalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(cat: BusinessCategory) {
|
||||||
|
editingId.value = cat.id;
|
||||||
|
slugTouched.value = true;
|
||||||
|
form.value = { title: cat.title, slug: cat.slug, isActive: cat.isActive };
|
||||||
|
modalOpen.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
modalOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeSlug() {
|
||||||
|
if (!slugTouched.value && !editingId.value) {
|
||||||
|
form.value.slug = slugify(form.value.title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
if (!isValidSlug(form.value.slug)) {
|
||||||
|
error.value = 'اسلاگ فقط حروف کوچک انگلیسی و خط تیره باشد.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
if (editingId.value) {
|
||||||
|
await updateCategory(editingId.value, {
|
||||||
|
title: form.value.title.trim(),
|
||||||
|
slug: form.value.slug.trim(),
|
||||||
|
isActive: form.value.isActive,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await createCategory({
|
||||||
|
title: form.value.title.trim(),
|
||||||
|
slug: form.value.slug.trim(),
|
||||||
|
isActive: form.value.isActive,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
closeModal();
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof ApiError ? e.message : 'خطا در ذخیره';
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(cat: BusinessCategory) {
|
||||||
|
if (
|
||||||
|
!confirm(
|
||||||
|
`دسته «${cat.title}» و همه کسبوکارهای آن حذف شوند؟ این کار قابل بازگشت نیست.`,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
await deleteCategory(cat.id);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof ApiError ? e.message : 'حذف ممکن نیست (شاید کسبوکار دارد)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
+73
-11
@@ -1,20 +1,82 @@
|
|||||||
<template>
|
<template>
|
||||||
<AdminLayout>
|
<AdminLayout>
|
||||||
<h1 class="text-xl font-bold text-text">داشبورد</h1>
|
<PageHeader title="داشبورد" subtitle="خلاصه وضعیت سیستم" />
|
||||||
<p class="mt-2 text-sm text-muted">سوپر ادمین — مونتایم</p>
|
|
||||||
<div class="mt-6 grid gap-3 sm:grid-cols-2">
|
<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">
|
||||||
<div class="rounded-2xl border border-border bg-surface-2 p-4">
|
{{ error }}
|
||||||
<p class="text-xs text-muted">کسبوکارها</p>
|
</p>
|
||||||
<p class="mt-1 text-2xl font-bold text-text">—</p>
|
|
||||||
</div>
|
<div v-if="loading" class="mt-6 text-sm text-muted">در حال بارگذاری…</div>
|
||||||
<div class="rounded-2xl border border-border bg-surface-2 p-4">
|
|
||||||
<p class="text-xs text-muted">کاربران</p>
|
<div v-else class="mt-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<p class="mt-1 text-2xl font-bold text-text">—</p>
|
<RouterLink
|
||||||
</div>
|
v-for="card in cards"
|
||||||
|
:key="card.label"
|
||||||
|
:to="card.to"
|
||||||
|
class="rounded-2xl border border-border bg-surface-2 p-4 transition hover:border-brand-500/50"
|
||||||
|
>
|
||||||
|
<p class="text-xs text-muted">{{ card.label }}</p>
|
||||||
|
<p class="mt-1 text-2xl font-bold text-text">{{ card.value }}</p>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="auth.user" class="mt-6 rounded-2xl border border-border bg-surface-2 p-4 text-sm">
|
||||||
|
<p class="text-xs text-muted">ورود شما</p>
|
||||||
|
<p class="mt-1 font-medium" dir="ltr">{{ auth.user.mobile }}</p>
|
||||||
|
<p class="mt-1 text-muted">{{ auth.user.fullName ?? 'بدون نام' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { fetchBookings } from '@/api/bookings';
|
||||||
|
import { fetchCategories } from '@/api/business-categories';
|
||||||
|
import { fetchBusinesses } from '@/api/businesses';
|
||||||
|
import { fetchUsers } from '@/api/users';
|
||||||
|
import { ApiError } from '@/api/client';
|
||||||
|
import PageHeader from '@/components/ui/PageHeader.vue';
|
||||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const loading = ref(true);
|
||||||
|
const error = ref('');
|
||||||
|
const counts = ref({
|
||||||
|
businesses: 0,
|
||||||
|
activeBusinesses: 0,
|
||||||
|
bookings: 0,
|
||||||
|
users: 0,
|
||||||
|
categories: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cards = computed(() => [
|
||||||
|
{ label: 'کسبوکارها', value: counts.value.businesses, to: '/businesses' },
|
||||||
|
{ label: 'کسبوکار فعال', value: counts.value.activeBusinesses, to: '/businesses' },
|
||||||
|
{ label: 'رزروها', value: counts.value.bookings, to: '/bookings' },
|
||||||
|
{ label: 'کاربران', value: counts.value.users, to: '/users' },
|
||||||
|
{ label: 'دستهها', value: counts.value.categories, to: '/categories' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const [businesses, users, categories, bookingList] = await Promise.all([
|
||||||
|
fetchBusinesses(),
|
||||||
|
fetchUsers(),
|
||||||
|
fetchCategories(),
|
||||||
|
fetchBookings().catch(() => []),
|
||||||
|
]);
|
||||||
|
counts.value = {
|
||||||
|
businesses: businesses.length,
|
||||||
|
activeBusinesses: businesses.filter((b) => b.isActive).length,
|
||||||
|
bookings: bookingList.length,
|
||||||
|
users: users.length,
|
||||||
|
categories: categories.length,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof ApiError ? e.message : 'خطا در بارگذاری آمار';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { requestOtp } from '@/api/auth';
|
import { requestOtp } from '@/api/auth';
|
||||||
|
import { isValidIranMobile, normalizeIranMobile } from '@/utils/mobile';
|
||||||
import { ApiError } from '@/api/client';
|
import { ApiError } from '@/api/client';
|
||||||
import AppButton from '@/components/ui/AppButton.vue';
|
import AppButton from '@/components/ui/AppButton.vue';
|
||||||
import AppInput from '@/components/ui/AppInput.vue';
|
import AppInput from '@/components/ui/AppInput.vue';
|
||||||
@@ -42,11 +43,16 @@ async function onSubmit() {
|
|||||||
error.value = '';
|
error.value = '';
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await requestOtp(mobile.value.trim());
|
const normalized = normalizeIranMobile(mobile.value);
|
||||||
|
if (!isValidIranMobile(normalized)) {
|
||||||
|
error.value = 'شماره موبایل معتبر نیست. مثال: 09121234567';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const res = await requestOtp(normalized);
|
||||||
await router.push({
|
await router.push({
|
||||||
name: 'verify',
|
name: 'verify',
|
||||||
query: {
|
query: {
|
||||||
mobile: mobile.value.trim(),
|
mobile: normalized,
|
||||||
expires: String(res.expiresInSeconds),
|
expires: String(res.expiresInSeconds),
|
||||||
...(res.otpCode ? { devCode: res.otpCode } : {}),
|
...(res.otpCode ? { devCode: res.otpCode } : {}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<template>
|
||||||
|
<AdminLayout>
|
||||||
|
<PageHeader title="پروفایل" subtitle="اطلاعات حساب سوپر ادمین" />
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="message"
|
||||||
|
class="mt-4 rounded-xl border border-brand-500/40 bg-brand-500/10 px-4 py-3 text-sm text-brand-700 dark:text-brand-500"
|
||||||
|
>
|
||||||
|
{{ message }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="error"
|
||||||
|
class="mt-4 rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-600"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form v-if="auth.user" class="mt-6 space-y-4" @submit.prevent="onSave">
|
||||||
|
<div class="rounded-2xl border border-border bg-surface-2 p-4">
|
||||||
|
<p class="text-xs text-muted">موبایل</p>
|
||||||
|
<p class="mt-1 font-medium" dir="ltr">{{ auth.user.mobile }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-border bg-surface-2 p-4">
|
||||||
|
<p class="text-xs text-muted">نقش</p>
|
||||||
|
<p class="mt-1 font-medium">{{ roleLabel(auth.user.role) }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AppInput id="fullName" v-model="fullName" label="نام نمایشی" placeholder="نام شما" />
|
||||||
|
|
||||||
|
<AppButton type="submit" :loading="saving">ذخیره تغییرات</AppButton>
|
||||||
|
</form>
|
||||||
|
</AdminLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
import { updateUser } from '@/api/users';
|
||||||
|
import { ApiError } from '@/api/client';
|
||||||
|
import AppButton from '@/components/ui/AppButton.vue';
|
||||||
|
import AppInput from '@/components/ui/AppInput.vue';
|
||||||
|
import PageHeader from '@/components/ui/PageHeader.vue';
|
||||||
|
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { roleLabel } from '@/utils/format';
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const fullName = ref('');
|
||||||
|
const saving = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
const message = ref('');
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => auth.user,
|
||||||
|
(u) => {
|
||||||
|
fullName.value = u?.fullName ?? '';
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
if (!auth.user) return;
|
||||||
|
error.value = '';
|
||||||
|
message.value = '';
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const updated = await updateUser(auth.user.id, {
|
||||||
|
fullName: fullName.value.trim() || null,
|
||||||
|
});
|
||||||
|
auth.setUser(updated);
|
||||||
|
message.value = 'پروفایل ذخیره شد.';
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof ApiError ? e.message : 'خطا در ذخیره';
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
+132
-6
@@ -1,14 +1,140 @@
|
|||||||
<template>
|
<template>
|
||||||
<AdminLayout>
|
<AdminLayout>
|
||||||
<h1 class="text-xl font-bold text-text">کاربران</h1>
|
<PageHeader title="کاربران" subtitle="مدیریت نقش و وضعیت کاربران" />
|
||||||
<div
|
|
||||||
class="mt-4 rounded-2xl border border-dashed border-border bg-surface-2 p-6 text-center text-sm text-muted"
|
<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>
|
|
||||||
|
<div v-if="loading" class="mt-4 text-sm text-muted">در حال بارگذاری…</div>
|
||||||
|
|
||||||
|
<EmptyState v-else-if="users.length === 0" class="mt-4">کاربری ثبت نشده است.</EmptyState>
|
||||||
|
|
||||||
|
<ul v-else class="mt-4 space-y-2">
|
||||||
|
<li
|
||||||
|
v-for="user in users"
|
||||||
|
:key="user.id"
|
||||||
|
class="rounded-2xl border border-border bg-surface-2 p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="font-semibold text-text">{{ user.fullName ?? 'بدون نام' }}</p>
|
||||||
|
<p class="mt-0.5 text-sm text-muted" dir="ltr">{{ user.mobile }}</p>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<AppBadge>{{ roleLabel(user.role) }}</AppBadge>
|
||||||
|
<AppBadge :tone="user.isActive ? 'success' : 'danger'">
|
||||||
|
{{ activeLabel(user.isActive) }}
|
||||||
|
</AppBadge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<AppButton variant="ghost" class="!w-auto shrink-0 px-3" @click="openEdit(user)">
|
||||||
|
ویرایش
|
||||||
|
</AppButton>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<AppModal :open="!!editing" title="ویرایش کاربر" @close="closeEdit">
|
||||||
|
<form v-if="editing" class="space-y-4" @submit.prevent="onSave">
|
||||||
|
<p class="text-sm text-muted" dir="ltr">{{ editing.mobile }}</p>
|
||||||
|
|
||||||
|
<AppInput id="userFullName" v-model="form.fullName" label="نام" />
|
||||||
|
|
||||||
|
<AppSelect
|
||||||
|
id="userRole"
|
||||||
|
v-model="form.role"
|
||||||
|
label="نقش"
|
||||||
|
:options="roleOptions"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 text-sm text-text">
|
||||||
|
<input v-model="form.isActive" type="checkbox" class="rounded border-border" />
|
||||||
|
حساب فعال است
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<AppButton type="submit" :loading="saving">ذخیره</AppButton>
|
||||||
|
</form>
|
||||||
|
</AppModal>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import { fetchUsers, updateUser } from '@/api/users';
|
||||||
|
import type { User, UserRole } from '@/api/types';
|
||||||
|
import { ApiError } from '@/api/client';
|
||||||
|
import AppBadge from '@/components/ui/AppBadge.vue';
|
||||||
|
import AppButton from '@/components/ui/AppButton.vue';
|
||||||
|
import AppInput from '@/components/ui/AppInput.vue';
|
||||||
|
import AppModal from '@/components/ui/AppModal.vue';
|
||||||
|
import AppSelect from '@/components/ui/AppSelect.vue';
|
||||||
|
import EmptyState from '@/components/ui/EmptyState.vue';
|
||||||
|
import PageHeader from '@/components/ui/PageHeader.vue';
|
||||||
import AdminLayout from '@/layouts/AdminLayout.vue';
|
import AdminLayout from '@/layouts/AdminLayout.vue';
|
||||||
|
import { activeLabel, roleLabel } from '@/utils/format';
|
||||||
|
|
||||||
|
const users = ref<User[]>([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
const error = ref('');
|
||||||
|
const editing = ref<User | null>(null);
|
||||||
|
const saving = ref(false);
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
fullName: '',
|
||||||
|
role: 'customer' as UserRole,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const roleOptions = [
|
||||||
|
{ value: 'customer', label: 'مشتری' },
|
||||||
|
{ value: 'business_owner', label: 'صاحب کسبوکار' },
|
||||||
|
{ value: 'admin', label: 'سوپر ادمین' },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
users.value = await fetchUsers();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof ApiError ? e.message : 'خطا در بارگذاری';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(user: User) {
|
||||||
|
editing.value = user;
|
||||||
|
form.value = {
|
||||||
|
fullName: user.fullName ?? '',
|
||||||
|
role: user.role,
|
||||||
|
isActive: user.isActive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEdit() {
|
||||||
|
editing.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSave() {
|
||||||
|
if (!editing.value) return;
|
||||||
|
saving.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
const updated = await updateUser(editing.value.id, {
|
||||||
|
fullName: form.value.fullName.trim() || null,
|
||||||
|
role: form.value.role,
|
||||||
|
isActive: form.value.isActive,
|
||||||
|
});
|
||||||
|
const idx = users.value.findIndex((u) => u.id === updated.id);
|
||||||
|
if (idx >= 0) users.value[idx] = updated;
|
||||||
|
closeEdit();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof ApiError ? e.message : 'خطا در ذخیره';
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { defineConfig } from 'vite';
|
|||||||
import vue from '@vitejs/plugin-vue';
|
import vue from '@vitejs/plugin-vue';
|
||||||
import { fileURLToPath, URL } from 'node:url';
|
import { fileURLToPath, URL } from 'node:url';
|
||||||
|
|
||||||
|
const API_TARGET = 'https://api.montime.ir';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
resolve: {
|
resolve: {
|
||||||
@@ -11,5 +13,13 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5174,
|
port: 5174,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: API_TARGET,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user