update rezerv

This commit is contained in:
2026-05-31 21:38:09 +03:30
parent f36a4af379
commit 5eb5bd233c
11 changed files with 1056 additions and 128 deletions
+31 -3
View File
@@ -5,6 +5,16 @@ export type CreateBookingPayload = {
customerId: string; customerId: string;
businessId: string; businessId: string;
businessServiceId: string; businessServiceId: string;
serviceClassId?: string | null;
scheduledAt: string;
note?: string | null;
};
export type CreateBookingBatchPayload = {
customerId: string;
businessId: string;
items: { businessServiceId: string }[];
serviceClassId?: string | null;
scheduledAt: string; scheduledAt: string;
note?: string | null; note?: string | null;
}; };
@@ -29,6 +39,13 @@ export function createBooking(payload: CreateBookingPayload) {
}); });
} }
export function createBookingBatch(payload: CreateBookingBatchPayload) {
return api<Booking[]>('/bookings/batch', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function cancelBooking(id: string) { export function cancelBooking(id: string) {
return api<Booking>(`/bookings/${id}/cancel`, { method: 'POST' }); return api<Booking>(`/bookings/${id}/cancel`, { method: 'POST' });
} }
@@ -39,15 +56,26 @@ export function confirmBooking(id: string) {
export function fetchAvailableSlots( export function fetchAvailableSlots(
businessId: string, businessId: string,
businessServiceId: string,
dateYmd: string, dateYmd: string,
options: {
businessServiceId?: string;
businessServiceIds?: string[];
serviceClassId?: string;
},
) { ) {
const search = new URLSearchParams({ const search = new URLSearchParams({
businessId, businessId,
businessServiceId,
date: dateYmd, date: dateYmd,
}); });
return api<{ date: string; slots: string[] }>( if (options.businessServiceIds?.length) {
search.set('businessServiceIds', options.businessServiceIds.join(','));
} else if (options.businessServiceId) {
search.set('businessServiceId', options.businessServiceId);
}
if (options.serviceClassId) {
search.set('serviceClassId', options.serviceClassId);
}
return api<{ date: string; slots: string[]; totalDurationMinutes?: number }>(
`/bookings/available-slots?${search}`, `/bookings/available-slots?${search}`,
); );
} }
+51
View File
@@ -0,0 +1,51 @@
import { api } from './client';
import type { BusinessServiceClass } from './types';
export type CreateBusinessServiceClassPayload = {
businessId: string;
title: string;
description?: string | null;
sortOrder?: number;
isActive?: boolean;
};
export type UpdateBusinessServiceClassPayload = {
title?: string;
description?: string | null;
sortOrder?: number;
isActive?: boolean;
};
export function fetchBusinessServiceClasses(
businessId: string,
options?: { activeOnly?: boolean },
) {
const search = new URLSearchParams({ businessId });
if (options?.activeOnly) {
search.set('isActive', 'true');
}
return api<BusinessServiceClass[]>(`/business-service-classes?${search}`);
}
export function createBusinessServiceClass(payload: CreateBusinessServiceClassPayload) {
return api<BusinessServiceClass>('/business-service-classes', {
method: 'POST',
body: JSON.stringify(payload),
});
}
export function updateBusinessServiceClass(
id: string,
payload: UpdateBusinessServiceClassPayload,
) {
return api<BusinessServiceClass>(`/business-service-classes/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
}
export function deleteBusinessServiceClass(id: string) {
return api<{ success: true }>(`/business-service-classes/${id}`, {
method: 'DELETE',
});
}
+17
View File
@@ -45,3 +45,20 @@ export function deleteBusinessService(id: string) {
method: 'DELETE', method: 'DELETE',
}); });
} }
export type ClassPriceItem = {
classId: string;
price: number;
durationMinutes?: number | null;
isActive?: boolean;
};
export function setBusinessServiceClassPrices(
id: string,
prices: ClassPriceItem[],
) {
return api<BusinessService>(`/business-services/${id}/class-prices`, {
method: 'PUT',
body: JSON.stringify({ prices }),
});
}
+25
View File
@@ -22,6 +22,7 @@ export type Business = {
latitude: number | null; latitude: number | null;
longitude: number | null; longitude: number | null;
isActive: boolean; isActive: boolean;
serviceClassesEnabled?: boolean;
categoryId: string; categoryId: string;
ownerId?: string | null; ownerId?: string | null;
category?: BusinessCategory; category?: BusinessCategory;
@@ -63,6 +64,26 @@ export type UpdateBusinessPayload = {
address?: string | null; address?: string | null;
latitude?: number | null; latitude?: number | null;
longitude?: number | null; longitude?: number | null;
serviceClassesEnabled?: boolean;
};
export type BusinessServiceClass = {
id: string;
businessId: string;
title: string;
description: string | null;
sortOrder: number;
isActive: boolean;
};
export type BusinessServiceClassPrice = {
id: string;
businessServiceId: string;
classId: string;
price: string;
durationMinutes: number | null;
isActive: boolean;
serviceClass?: BusinessServiceClass;
}; };
export type ServiceTemplate = { export type ServiceTemplate = {
@@ -81,6 +102,7 @@ export type BusinessService = {
durationMinutes: number | null; durationMinutes: number | null;
isActive: boolean; isActive: boolean;
serviceTemplate?: ServiceTemplate; serviceTemplate?: ServiceTemplate;
classPrices?: BusinessServiceClassPrice[];
}; };
export type BusinessServiceRequestStatus = 'pending' | 'approved' | 'rejected'; export type BusinessServiceRequestStatus = 'pending' | 'approved' | 'rejected';
@@ -119,6 +141,8 @@ export type Booking = {
customerId: string; customerId: string;
businessId: string; businessId: string;
businessServiceId: string; businessServiceId: string;
serviceClassId?: string | null;
bookingGroupId?: string | null;
scheduledAt: string; scheduledAt: string;
status: BookingStatus; status: BookingStatus;
note: string | null; note: string | null;
@@ -131,4 +155,5 @@ export type Booking = {
}; };
business?: Business; business?: Business;
businessService?: BusinessService; businessService?: BusinessService;
serviceClass?: BusinessServiceClass;
}; };
+13 -2
View File
@@ -8,6 +8,9 @@
<p class="mt-0.5 text-sm text-muted"> <p class="mt-0.5 text-sm text-muted">
{{ serviceTitle }} {{ serviceTitle }}
</p> </p>
<p v-if="groupHint" class="mt-0.5 text-xs text-brand-600 dark:text-brand-500">
{{ groupHint }}
</p>
<p class="mt-1 text-xs text-muted"> <p class="mt-1 text-xs text-muted">
{{ formatDate(booking.scheduledAt) }} {{ formatDate(booking.scheduledAt) }}
</p> </p>
@@ -72,8 +75,16 @@ const emit = defineEmits<{
cancel: [booking: Booking]; cancel: [booking: Booking];
}>(); }>();
const serviceTitle = computed( const serviceTitle = computed(() => {
() => props.booking.businessService?.serviceTemplate?.title ?? 'خدمت', const title = props.booking.businessService?.serviceTemplate?.title ?? 'خدمت';
if (props.booking.serviceClass?.title) {
return `${title} · ${props.booking.serviceClass.title}`;
}
return title;
});
const groupHint = computed(() =>
props.booking.bookingGroupId ? 'بخشی از رزرو چندخدمتی' : '',
); );
const statusClass = computed(() => { const statusClass = computed(() => {
+15 -9
View File
@@ -93,7 +93,8 @@ const model = defineModel<string>({ default: '' });
const props = defineProps<{ const props = defineProps<{
label?: string; label?: string;
businessId: string; businessId: string;
businessServiceId: string; businessServiceIds: string[];
serviceClassId?: string | null;
}>(); }>();
const dateParts = ref<Pick<JalaliDateTimeParts, 'jy' | 'jm' | 'jd'>>({ const dateParts = ref<Pick<JalaliDateTimeParts, 'jy' | 'jm' | 'jd'>>({
@@ -145,11 +146,10 @@ async function loadSlots() {
try { try {
ensureDateNotPast(); ensureDateNotPast();
const ymd = jalaliToGregorianYmd(dateParts.value); const ymd = jalaliToGregorianYmd(dateParts.value);
const res = await fetchAvailableSlots( const res = await fetchAvailableSlots(props.businessId, ymd, {
props.businessId, businessServiceIds: props.businessServiceIds,
props.businessServiceId, serviceClassId: props.serviceClassId ?? undefined,
ymd, });
);
slots.value = res.slots; slots.value = res.slots;
if (model.value && !slots.value.includes(model.value)) { if (model.value && !slots.value.includes(model.value)) {
model.value = slots.value[0] ?? ''; model.value = slots.value[0] ?? '';
@@ -182,12 +182,18 @@ function selectSlot(iso: string) {
} }
watch( watch(
() => [props.businessId, props.businessServiceId], () => [props.businessId, props.businessServiceIds.join(','), props.serviceClassId],
() => { () => {
model.value = ''; model.value = '';
loadSlots(); if (props.businessServiceIds.length) {
loadSlots();
}
}, },
); );
onMounted(loadSlots); onMounted(() => {
if (props.businessServiceIds.length) {
loadSlots();
}
});
</script> </script>
@@ -0,0 +1,230 @@
<template>
<section class="space-y-3 rounded-2xl border border-border bg-surface-2 p-4">
<div class="flex items-start justify-between gap-3">
<div>
<h2 class="text-base font-semibold text-text">کلاس‌بندی خدمات</h2>
<p class="mt-1 text-xs text-muted">
اختیاری — برای مثال کارواش می‌تواند بر اساس اندازه خودرو قیمت‌گذاری کند.
</p>
</div>
<label class="flex shrink-0 items-center gap-2 text-sm text-text">
<input
type="checkbox"
:checked="enabled"
@change="onToggleEnabled"
/>
فعال
</label>
</div>
<p v-if="!enabled" class="text-sm text-muted">
در حالت غیرفعال، هر خدمت یک قیمت ثابت دارد. با فعال‌کردن، می‌توانید کلاس‌ها را تعریف کنید.
</p>
<template v-else>
<p v-if="error" class="rounded-xl border border-red-500/30 bg-red-500/10 px-3 py-2 text-sm text-red-600">
{{ error }}
</p>
<div v-if="loading" class="text-sm text-muted">در حال بارگذاری…</div>
<ul v-else-if="classes.length" class="space-y-2">
<li
v-for="item in classes"
:key="item.id"
class="rounded-xl border border-border bg-surface px-3 py-2"
>
<div v-if="editingId === item.id" class="space-y-2">
<AppInput id="editClassTitle" v-model="editForm.title" label="عنوان کلاس" required />
<div class="space-y-1.5">
<label for="editClassDesc" class="block text-sm font-medium text-muted">توضیح (اختیاری)</label>
<textarea
id="editClassDesc"
v-model="editForm.description"
rows="2"
class="w-full rounded-xl border border-border bg-surface-2 px-4 py-3 text-sm text-text outline-none focus:border-brand-500"
/>
</div>
<label class="flex items-center gap-2 text-xs text-muted">
<input v-model="editForm.isActive" type="checkbox" />
فعال
</label>
<div class="flex gap-2">
<AppButton class="!w-auto flex-1" :loading="saving" @click="saveEdit(item.id)">
ذخیره
</AppButton>
<AppButton variant="ghost" class="!w-auto px-4" @click="cancelEdit">انصراف</AppButton>
</div>
</div>
<div v-else class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="font-medium text-text">{{ item.title }}</p>
<p v-if="item.description" class="mt-0.5 text-xs text-muted">{{ item.description }}</p>
<span
v-if="!item.isActive"
class="mt-1 inline-block rounded-lg bg-surface-3 px-2 py-0.5 text-xs text-muted"
>
غیرفعال
</span>
</div>
<div class="flex shrink-0 gap-1">
<AppButton variant="ghost" class="!w-auto px-2 text-xs" @click="startEdit(item)">
ویرایش
</AppButton>
<AppButton variant="danger" class="!w-auto px-2 text-xs" @click="onDelete(item)">
حذف
</AppButton>
</div>
</div>
</li>
</ul>
<p v-else class="text-sm text-muted">هنوز کلاسی تعریف نشده است.</p>
<form class="space-y-3 border-t border-border pt-3" @submit.prevent="onAdd">
<p class="text-sm font-medium text-text">افزودن کلاس</p>
<AppInput id="classTitle" v-model="form.title" label="عنوان کلاس" required />
<div class="space-y-1.5">
<label for="classDesc" class="block text-sm font-medium text-muted">توضیح (اختیاری)</label>
<textarea
id="classDesc"
v-model="form.description"
rows="2"
class="w-full rounded-xl border border-border bg-surface-2 px-4 py-3 text-sm text-text outline-none focus:border-brand-500"
/>
</div>
<AppButton type="submit" :loading="adding">افزودن کلاس</AppButton>
</form>
</template>
</section>
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
import {
createBusinessServiceClass,
deleteBusinessServiceClass,
fetchBusinessServiceClasses,
updateBusinessServiceClass,
} from '@/api/business-service-classes';
import { ApiError } from '@/api/client';
import type { BusinessServiceClass } from '@/api/types';
import AppButton from '@/components/ui/AppButton.vue';
import AppInput from '@/components/ui/AppInput.vue';
const props = defineProps<{
businessId: string;
enabled: boolean;
}>();
const emit = defineEmits<{
updated: [classes: BusinessServiceClass[]];
'update:enabled': [value: boolean];
}>();
const classes = ref<BusinessServiceClass[]>([]);
const loading = ref(false);
const adding = ref(false);
const saving = ref(false);
const error = ref('');
const editingId = ref<string | null>(null);
const form = ref({ title: '', description: '' });
const editForm = ref({ title: '', description: '', isActive: true });
async function load() {
if (!props.enabled) {
classes.value = [];
emit('updated', []);
return;
}
loading.value = true;
error.value = '';
try {
classes.value = await fetchBusinessServiceClasses(props.businessId);
emit('updated', classes.value);
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'بارگذاری کلاس‌ها ناموفق بود';
} finally {
loading.value = false;
}
}
async function onAdd() {
const title = form.value.title.trim();
if (!title) return;
adding.value = true;
error.value = '';
try {
await createBusinessServiceClass({
businessId: props.businessId,
title,
description: form.value.description.trim() || null,
});
form.value = { title: '', description: '' };
await load();
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'افزودن کلاس ناموفق بود';
} finally {
adding.value = false;
}
}
function startEdit(item: BusinessServiceClass) {
editingId.value = item.id;
editForm.value = {
title: item.title,
description: item.description ?? '',
isActive: item.isActive,
};
}
function cancelEdit() {
editingId.value = null;
}
async function saveEdit(id: string) {
const title = editForm.value.title.trim();
if (!title) return;
saving.value = true;
error.value = '';
try {
await updateBusinessServiceClass(id, {
title,
description: editForm.value.description.trim() || null,
isActive: editForm.value.isActive,
});
editingId.value = null;
await load();
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'ذخیره ناموفق بود';
} finally {
saving.value = false;
}
}
async function onDelete(item: BusinessServiceClass) {
if (!confirm(`«${item.title}» حذف شود؟`)) return;
error.value = '';
try {
await deleteBusinessServiceClass(item.id);
await load();
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'حذف ناموفق بود';
}
}
function onToggleEnabled(event: Event) {
const checked = (event.target as HTMLInputElement).checked;
emit('update:enabled', checked);
}
watch(
() => [props.businessId, props.enabled],
() => {
if (props.businessId) load();
},
);
onMounted(load);
</script>
+3 -3
View File
@@ -3,9 +3,9 @@
<header <header
class="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-surface/95 px-4 py-3 backdrop-blur" class="sticky top-0 z-10 flex items-center justify-between border-b border-border bg-surface/95 px-4 py-3 backdrop-blur"
> >
<div class="min-w-0"> <div class="flex min-w-0 items-center gap-3">
<AppLogo variant="horizontal" class="h-8" /> <AppLogo variant="horizontal" class="h-8 shrink-0" />
<p v-if="auth.user" class="text-xs text-muted">{{ auth.user.mobile }}</p> <p v-if="auth.user" class="text-xs text-muted" dir="ltr">{{ auth.user.mobile }}</p>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<ThemeToggle /> <ThemeToggle />
+179
View File
@@ -0,0 +1,179 @@
import type { BusinessService, BusinessServiceClass } from '@/api/types';
import { formatDuration, formatPriceToman } from '@/utils/format';
export function activeServiceClasses(classes: BusinessServiceClass[]): BusinessServiceClass[] {
return [...classes]
.filter((item) => item.isActive)
.sort((a, b) => a.sortOrder - b.sortOrder || a.title.localeCompare(b.title, 'fa'));
}
export function usesServiceClasses(
classesEnabled: boolean,
classes: BusinessServiceClass[],
): boolean {
return classesEnabled && activeServiceClasses(classes).length > 0;
}
export function getClassPriceForService(
service: BusinessService,
classId: string,
) {
return service.classPrices?.find(
(item) => item.classId === classId && item.isActive,
);
}
export function serviceHasActiveClassPrices(
service: BusinessService,
classes: BusinessServiceClass[],
): boolean {
const activeIds = new Set(activeServiceClasses(classes).map((item) => item.id));
return (service.classPrices ?? []).some(
(item) => item.isActive && activeIds.has(item.classId),
);
}
export function isServiceBookable(
service: BusinessService,
classesEnabled: boolean,
classes: BusinessServiceClass[],
): boolean {
if (!service.isActive) return false;
if (!usesServiceClasses(classesEnabled, classes)) return true;
return serviceHasActiveClassPrices(service, classes);
}
export function servicePriceLabel(
service: BusinessService,
classesEnabled: boolean,
classes: BusinessServiceClass[],
): string {
if (!usesServiceClasses(classesEnabled, classes)) {
return `${formatPriceToman(service.price)} تومان`;
}
const activeClasses = activeServiceClasses(classes);
const prices = activeClasses
.map((item) => getClassPriceForService(service, item.id))
.filter(Boolean)
.map((item) => Number.parseFloat(item!.price));
if (!prices.length) return 'قیمت تعیین نشده';
const min = Math.min(...prices);
const max = Math.max(...prices);
if (min === max) {
return `${formatPriceToman(String(min))} تومان`;
}
return `از ${formatPriceToman(String(min))} تومان`;
}
export function serviceDurationLabel(
service: BusinessService,
classesEnabled: boolean,
classes: BusinessServiceClass[],
classId?: string | null,
): string {
if (classId) {
const classPrice = getClassPriceForService(service, classId);
return formatDuration(
classPrice?.durationMinutes ?? service.durationMinutes,
service.serviceTemplate?.defaultDurationMinutes,
);
}
if (!usesServiceClasses(classesEnabled, classes)) {
return formatDuration(
service.durationMinutes,
service.serviceTemplate?.defaultDurationMinutes,
);
}
const activeClasses = activeServiceClasses(classes);
const durations = activeClasses
.map((item) => getClassPriceForService(service, item.id))
.filter(Boolean)
.map((item) =>
item!.durationMinutes ?? service.durationMinutes ?? service.serviceTemplate?.defaultDurationMinutes,
)
.filter((value): value is number => value != null && value > 0);
if (!durations.length) {
return formatDuration(
service.durationMinutes,
service.serviceTemplate?.defaultDurationMinutes,
);
}
const min = Math.min(...durations);
const max = Math.max(...durations);
if (min === max) return `${min} دقیقه`;
return `${min}–${max} دقیقه`;
}
export function resolvedServicePrice(
service: BusinessService,
classId: string,
): string | null {
const classPrice = getClassPriceForService(service, classId);
return classPrice?.price ?? null;
}
export function resolveServiceDurationMinutes(
service: BusinessService,
classesEnabled: boolean,
classes: BusinessServiceClass[],
classId?: string | null,
): number {
if (classId) {
const classPrice = getClassPriceForService(service, classId);
const fromClass =
classPrice?.durationMinutes ?? service.durationMinutes ?? service.serviceTemplate?.defaultDurationMinutes;
if (fromClass != null && fromClass > 0) return fromClass;
}
if (usesServiceClasses(classesEnabled, classes) && classId) {
const classPrice = getClassPriceForService(service, classId);
const value =
classPrice?.durationMinutes ??
service.durationMinutes ??
service.serviceTemplate?.defaultDurationMinutes;
if (value != null && value > 0) return value;
}
const fallback =
service.durationMinutes ?? service.serviceTemplate?.defaultDurationMinutes;
return fallback != null && fallback > 0 ? fallback : 30;
}
export function totalDurationForServices(
services: BusinessService[],
classesEnabled: boolean,
classes: BusinessServiceClass[],
classId?: string | null,
): number {
return services.reduce(
(sum, service) =>
sum + resolveServiceDurationMinutes(service, classesEnabled, classes, classId),
0,
);
}
export function totalPriceForServices(
services: BusinessService[],
classesEnabled: boolean,
classes: BusinessServiceClass[],
classId?: string | null,
): number | null {
if (usesServiceClasses(classesEnabled, classes)) {
if (!classId) return null;
let total = 0;
for (const service of services) {
const price = resolvedServicePrice(service, classId);
if (!price) return null;
total += Number.parseFloat(price);
}
return total;
}
return services.reduce((sum, service) => sum + Number.parseFloat(service.price), 0);
}
+243 -65
View File
@@ -61,58 +61,112 @@
{{ servicesError }} {{ servicesError }}
</p> </p>
<div <div
v-else-if="!services.length" v-else-if="!bookableServices.length"
class="rounded-xl border border-dashed border-border p-4 text-center text-sm text-muted" class="rounded-xl border border-dashed border-border p-4 text-center text-sm text-muted"
> >
هنوز خدمتی برای این کسب‌وکار ثبت نشده است. {{
services.length
? 'خدمات این کسب‌وکار هنوز برای رزرو آماده نیست.'
: 'هنوز خدمتی برای این کسب‌وکار ثبت نشده است.'
}}
<span v-if="auth.isBusinessOwner" class="mt-2 block"> <span v-if="auth.isBusinessOwner" class="mt-2 block">
از بخش «کسب‌وکار» در منو، خدمات و قیمت را اضافه کنید. از بخش «کسب‌وکار» در منو، خدمات و قیمت را اضافه کنید.
</span> </span>
</div> </div>
<ul v-else class="grid grid-cols-2 gap-3"> <p v-else-if="canBook" class="mb-3 text-xs text-muted">
خدمات مورد نظر را انتخاب کنید، سپس زمان مراجعه را تعیین کنید.
</p>
<ul v-if="bookableServices.length" class="grid grid-cols-2 gap-3">
<li <li
v-for="svc in services" v-for="svc in bookableServices"
:key="svc.id" :key="svc.id"
class="flex flex-col items-center rounded-2xl border border-border bg-surface-2 p-3 text-center" class="flex flex-col items-center rounded-2xl border p-3 text-center transition"
:class="
canBook && isServiceSelected(svc.id)
? 'border-brand-500 bg-brand-500/10'
: 'border-border bg-surface-2'
"
> >
<p class="font-medium text-text"> <label
{{ svc.serviceTemplate?.title ?? 'خدمت' }}
</p>
<p
v-if="svc.serviceTemplate?.description"
class="mt-1 line-clamp-2 text-xs text-muted"
>
{{ svc.serviceTemplate.description }}
</p>
<div class="mt-auto space-y-0.5 pt-2 text-xs text-muted">
<p>{{ formatPriceToman(svc.price) }} تومان</p>
<p>{{
formatDuration(
svc.durationMinutes,
svc.serviceTemplate?.defaultDurationMinutes,
)
}}</p>
</div>
<AppButton
v-if="canBook" v-if="canBook"
class="mt-3" class="flex w-full cursor-pointer flex-col items-center"
variant="primary"
@click="openBooking(svc)"
> >
رزرو <input
</AppButton> type="checkbox"
class="sr-only"
:checked="isServiceSelected(svc.id)"
@change="toggleService(svc.id)"
/>
<span
class="mb-2 flex h-5 w-5 items-center justify-center rounded-md border text-xs"
:class="
isServiceSelected(svc.id)
? 'border-brand-500 bg-brand-500 text-white'
: 'border-border bg-surface'
"
>
{{ isServiceSelected(svc.id) ? '✓' : '' }}
</span>
<p class="font-medium text-text">
{{ svc.serviceTemplate?.title ?? 'خدمت' }}
</p>
<p
v-if="svc.serviceTemplate?.description"
class="mt-1 line-clamp-2 text-xs text-muted"
>
{{ svc.serviceTemplate.description }}
</p>
<div class="mt-auto space-y-0.5 pt-2 text-xs text-muted">
<p>{{ servicePriceLabel(svc, classesEnabled, serviceClasses) }}</p>
<p>{{ serviceDurationLabel(svc, classesEnabled, serviceClasses) }}</p>
</div>
</label>
<template v-else>
<p class="font-medium text-text">
{{ svc.serviceTemplate?.title ?? 'خدمت' }}
</p>
<p
v-if="svc.serviceTemplate?.description"
class="mt-1 line-clamp-2 text-xs text-muted"
>
{{ svc.serviceTemplate.description }}
</p>
<div class="mt-auto space-y-0.5 pt-2 text-xs text-muted">
<p>{{ servicePriceLabel(svc, classesEnabled, serviceClasses) }}</p>
<p>{{ serviceDurationLabel(svc, classesEnabled, serviceClasses) }}</p>
</div>
</template>
</li> </li>
</ul> </ul>
</div> </div>
<div
v-if="canBook && selectedServices.length"
class="sticky bottom-2 z-10 rounded-2xl border border-brand-500/40 bg-surface/95 p-4 shadow-lg backdrop-blur"
>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0 text-sm">
<p class="font-medium text-text">
{{ selectedServices.length }} خدمت انتخاب شده
</p>
<p class="text-xs text-muted">
مدت تقریبی: {{ totalDurationLabel }}
</p>
</div>
<AppButton class="!w-auto shrink-0 px-5" @click="openBooking">
انتخاب زمان
</AppButton>
</div>
</div>
</section> </section>
<AppModal <AppModal
:open="bookingOpen" :open="bookingOpen"
title="رزرو خدمت" title="رزرو خدمات"
:subtitle="bookingService ? serviceLabel(bookingService) : ''" :subtitle="selectedServices.length ? `${selectedServices.length} خدمت` : ''"
@close="closeBooking" @close="closeBooking"
> >
<form v-if="bookingService && business" class="space-y-4" @submit.prevent="onSubmitBooking"> <form v-if="selectedServices.length && business" class="space-y-4" @submit.prevent="onSubmitBooking">
<p <p
v-if="bookingError" v-if="bookingError"
class="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-600" class="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-600"
@@ -123,18 +177,65 @@
<div class="rounded-xl border border-border bg-surface-2 p-3 text-sm"> <div class="rounded-xl border border-border bg-surface-2 p-3 text-sm">
<p class="text-muted">کسب‌وکار</p> <p class="text-muted">کسب‌وکار</p>
<p class="font-medium text-text">{{ business.title }}</p> <p class="font-medium text-text">{{ business.title }}</p>
<p class="mt-2 text-muted">مبلغ تقریبی</p> <ul class="mt-3 space-y-2 border-t border-border pt-3">
<p class="font-medium text-text"> <li
{{ formatPriceToman(bookingService.price) }} تومان v-for="svc in selectedServices"
</p> :key="svc.id"
class="flex items-center justify-between gap-2 text-sm"
>
<span class="text-text">{{ svc.serviceTemplate?.title ?? 'خدمت' }}</span>
<span class="shrink-0 text-muted">
{{
requiresServiceClass && bookingForm.serviceClassId
? `${formatPriceToman(resolvedServicePrice(svc, bookingForm.serviceClassId) ?? svc.price)} تومان`
: `${formatPriceToman(svc.price)} تومان`
}}
</span>
</li>
</ul>
<p class="mt-3 text-muted">جمع</p>
<p class="font-medium text-text">{{ bookingPriceLabel }}</p>
<p class="mt-2 text-muted">مدت تقریبی</p>
<p class="font-medium text-text">{{ totalDurationLabel }}</p>
</div>
<div v-if="requiresServiceClass" class="space-y-1.5">
<label for="bookingClass" class="block text-sm font-medium text-muted">
کلاس خودرو / دسته
</label>
<select
id="bookingClass"
v-model="bookingForm.serviceClassId"
required
class="w-full rounded-xl border border-border bg-surface-2 px-4 py-3 text-sm text-text outline-none focus:border-brand-500"
@change="bookingForm.scheduledAt = ''"
>
<option value="" disabled>انتخاب کنید</option>
<option
v-for="cls in activeClasses"
:key="cls.id"
:value="cls.id"
:disabled="!selectedServices.every((svc) => getClassPriceForService(svc, cls.id))"
>
{{ cls.title }}
</option>
</select>
</div> </div>
<BookingSlotPicker <BookingSlotPicker
v-if="!requiresServiceClass || bookingForm.serviceClassId"
v-model="bookingForm.scheduledAt" v-model="bookingForm.scheduledAt"
label="تاریخ و ساعت مراجعه (شمسی)" label="تاریخ و ساعت مراجعه (شمسی)"
:business-id="business.id" :business-id="business.id"
:business-service-id="bookingService.id" :business-service-ids="selectedServiceIds"
:service-class-id="bookingForm.serviceClassId || null"
/> />
<p
v-else
class="rounded-xl border border-dashed border-border px-4 py-3 text-center text-sm text-muted"
>
ابتدا کلاس خودرو را انتخاب کنید.
</p>
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="bookingNote" class="block text-sm font-medium text-muted"> <label for="bookingNote" class="block text-sm font-medium text-muted">
@@ -159,11 +260,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { createBooking } from '@/api/bookings'; import { createBookingBatch } from '@/api/bookings';
import { fetchBusinessServiceClasses } from '@/api/business-service-classes';
import { fetchBusinessServices } from '@/api/business-services'; import { fetchBusinessServices } from '@/api/business-services';
import { fetchBusiness, fetchBusinesses } from '@/api/businesses'; import { fetchBusiness, fetchBusinesses } from '@/api/businesses';
import { ApiError } from '@/api/client'; import { ApiError } from '@/api/client';
import type { Business, BusinessService } from '@/api/types'; import type { Business, BusinessService, BusinessServiceClass } from '@/api/types';
import AppButton from '@/components/ui/AppButton.vue'; import AppButton from '@/components/ui/AppButton.vue';
import AppIcon from '@/components/ui/AppIcon.vue'; import AppIcon from '@/components/ui/AppIcon.vue';
import BusinessImageGallery from '@/components/business/BusinessImageGallery.vue'; import BusinessImageGallery from '@/components/business/BusinessImageGallery.vue';
@@ -171,7 +273,18 @@ import BookingSlotPicker from '@/components/booking/BookingSlotPicker.vue';
import AppModal from '@/components/ui/AppModal.vue'; import AppModal from '@/components/ui/AppModal.vue';
import WebAppLayout from '@/layouts/WebAppLayout.vue'; import WebAppLayout from '@/layouts/WebAppLayout.vue';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { formatDuration, formatPriceToman } from '@/utils/format'; import { formatPriceToman } from '@/utils/format';
import {
activeServiceClasses,
getClassPriceForService,
isServiceBookable,
resolvedServicePrice,
serviceDurationLabel,
servicePriceLabel,
totalDurationForServices,
totalPriceForServices,
usesServiceClasses,
} from '@/utils/service-pricing';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -179,24 +292,65 @@ const auth = useAuthStore();
const business = ref<Business | null>(null); const business = ref<Business | null>(null);
const services = ref<BusinessService[]>([]); const services = ref<BusinessService[]>([]);
const serviceClasses = ref<BusinessServiceClass[]>([]);
const loading = ref(true); const loading = ref(true);
const loadingServices = ref(true); const loadingServices = ref(true);
const error = ref(''); const error = ref('');
const servicesError = ref(''); const servicesError = ref('');
const bookingOpen = ref(false); const bookingOpen = ref(false);
const bookingService = ref<BusinessService | null>(null); const selectedServiceIds = ref<string[]>([]);
const bookingSaving = ref(false); const bookingSaving = ref(false);
const bookingError = ref(''); const bookingError = ref('');
const bookingForm = ref({ const bookingForm = ref({
serviceClassId: '',
scheduledAt: '', scheduledAt: '',
note: '', note: '',
}); });
const showBackLink = ref(false); const showBackLink = ref(false);
const activeClasses = computed(() => activeServiceClasses(serviceClasses.value));
const classesEnabled = computed(() => business.value?.serviceClassesEnabled === true);
const requiresServiceClass = computed(() =>
usesServiceClasses(classesEnabled.value, serviceClasses.value),
);
const bookableServices = computed(() =>
services.value.filter((svc) =>
isServiceBookable(svc, classesEnabled.value, serviceClasses.value),
),
);
const selectedServices = computed(() =>
bookableServices.value.filter((svc) => selectedServiceIds.value.includes(svc.id)),
);
const totalDurationLabel = computed(() => {
const minutes = totalDurationForServices(
selectedServices.value,
classesEnabled.value,
serviceClasses.value,
bookingForm.value.serviceClassId || null,
);
return `${minutes} دقیقه`;
});
const bookingPriceLabel = computed(() => {
if (!selectedServices.value.length) return '—';
if (requiresServiceClass.value && !bookingForm.value.serviceClassId) {
return 'کلاس را انتخاب کنید';
}
const total = totalPriceForServices(
selectedServices.value,
classesEnabled.value,
serviceClasses.value,
bookingForm.value.serviceClassId || null,
);
return total != null ? `${formatPriceToman(String(total))} تومان` : '—';
});
const canBook = computed(() => { const canBook = computed(() => {
if (!auth.user || business.value?.isActive !== true || !services.value.length) { if (!auth.user || business.value?.isActive !== true || !bookableServices.value.length) {
return false; return false;
} }
if (business.value.ownerId && business.value.ownerId === auth.user.id) { if (business.value.ownerId && business.value.ownerId === auth.user.id) {
@@ -205,17 +359,50 @@ const canBook = computed(() => {
return auth.canBookAsCustomer; return auth.canBookAsCustomer;
}); });
function serviceLabel(svc: BusinessService): string { function isServiceSelected(id: string): boolean {
return svc.serviceTemplate?.title ?? 'خدمت'; return selectedServiceIds.value.includes(id);
}
function toggleService(id: string) {
if (isServiceSelected(id)) {
selectedServiceIds.value = selectedServiceIds.value.filter((item) => item !== id);
} else {
selectedServiceIds.value = [...selectedServiceIds.value, id];
}
}
function openBooking() {
if (!auth.user || !selectedServices.value.length) return;
bookingForm.value = {
serviceClassId: '',
scheduledAt: '',
note: '',
};
bookingError.value = '';
bookingOpen.value = true;
}
function closeBooking() {
bookingOpen.value = false;
bookingError.value = '';
} }
async function loadServices(businessId: string) { async function loadServices(businessId: string) {
loadingServices.value = true; loadingServices.value = true;
servicesError.value = ''; servicesError.value = '';
try { try {
services.value = await fetchBusinessServices(businessId, { activeOnly: true }); const svcList = await fetchBusinessServices(businessId, { activeOnly: true });
services.value = svcList;
if (business.value?.serviceClassesEnabled) {
serviceClasses.value = await fetchBusinessServiceClasses(businessId, {
activeOnly: true,
});
} else {
serviceClasses.value = [];
}
} catch (e) { } catch (e) {
services.value = []; services.value = [];
serviceClasses.value = [];
servicesError.value = servicesError.value =
e instanceof ApiError ? e.message : 'بارگذاری خدمات ناموفق بود.'; e instanceof ApiError ? e.message : 'بارگذاری خدمات ناموفق بود.';
} finally { } finally {
@@ -243,25 +430,8 @@ async function load() {
} }
} }
function openBooking(svc: BusinessService) {
if (!auth.user) return;
bookingService.value = svc;
bookingForm.value = {
scheduledAt: '',
note: '',
};
bookingError.value = '';
bookingOpen.value = true;
}
function closeBooking() {
bookingOpen.value = false;
bookingService.value = null;
bookingError.value = '';
}
async function onSubmitBooking() { async function onSubmitBooking() {
if (!auth.user || !business.value || !bookingService.value) return; if (!auth.user || !business.value || !selectedServices.value.length) return;
const scheduled = new Date(bookingForm.value.scheduledAt); const scheduled = new Date(bookingForm.value.scheduledAt);
if (Number.isNaN(scheduled.getTime())) { if (Number.isNaN(scheduled.getTime())) {
@@ -272,17 +442,25 @@ async function onSubmitBooking() {
bookingError.value = 'لطفاً یک ساعت از لیست انتخاب کنید.'; bookingError.value = 'لطفاً یک ساعت از لیست انتخاب کنید.';
return; return;
} }
if (requiresServiceClass.value && !bookingForm.value.serviceClassId) {
bookingError.value = 'لطفاً کلاس خودرو را انتخاب کنید.';
return;
}
bookingSaving.value = true; bookingSaving.value = true;
bookingError.value = ''; bookingError.value = '';
try { try {
await createBooking({ await createBookingBatch({
customerId: auth.user.id, customerId: auth.user.id,
businessId: business.value.id, businessId: business.value.id,
businessServiceId: bookingService.value.id, items: selectedServices.value.map((svc) => ({
businessServiceId: svc.id,
})),
serviceClassId: bookingForm.value.serviceClassId || null,
scheduledAt: scheduled.toISOString(), scheduledAt: scheduled.toISOString(),
note: bookingForm.value.note.trim() || null, note: bookingForm.value.note.trim() || null,
}); });
selectedServiceIds.value = [];
closeBooking(); closeBooking();
router.push({ name: 'app-bookings' }); router.push({ name: 'app-bookings' });
} catch (e) { } catch (e) {
+249 -46
View File
@@ -69,6 +69,14 @@
:business-id="business.id" :business-id="business.id"
/> />
<ServiceClassManager
v-if="business"
:business-id="business.id"
:enabled="business.serviceClassesEnabled === true"
@updated="onServiceClassesUpdated"
@update:enabled="onServiceClassesEnabledChange"
/>
<section class="space-y-4 border-t border-border pt-6"> <section class="space-y-4 border-t border-border pt-6">
<h2 class="text-base font-semibold text-text">خدمات و قیمت‌ها</h2> <h2 class="text-base font-semibold text-text">خدمات و قیمت‌ها</h2>
<p class="text-xs text-muted"> <p class="text-xs text-muted">
@@ -106,19 +114,45 @@
</div> </div>
<div v-if="editingId === svc.id" class="mt-3 space-y-2"> <div v-if="editingId === svc.id" class="mt-3 space-y-2">
<PriceInput <template v-if="hasServiceClasses">
id="editPrice" <p class="text-sm font-medium text-text">قیمت بر اساس کلاس</p>
v-model="editForm.price" <div
label="قیمت (تومان)" v-for="cls in activeClasses"
/> :key="cls.id"
<AppInput class="space-y-2 rounded-xl border border-border bg-surface px-3 py-3"
id="editDuration" >
v-model="editForm.durationMinutes" <p class="text-sm font-medium text-text">{{ cls.title }}</p>
label="مدت (دقیقه)" <PriceInput
type="number" :id="`edit-class-price-${svc.id}-${cls.id}`"
inputmode="numeric" v-model="editClassPrices[cls.id].price"
ltr label="قیمت (تومان)"
/> required
/>
<AppInput
:id="`edit-class-duration-${svc.id}-${cls.id}`"
v-model="editClassPrices[cls.id].durationMinutes"
label="مدت (دقیقه — اختیاری)"
type="number"
inputmode="numeric"
ltr
/>
</div>
</template>
<template v-else>
<PriceInput
id="editPrice"
v-model="editForm.price"
label="قیمت (تومان)"
/>
<AppInput
id="editDuration"
v-model="editForm.durationMinutes"
label="مدت (دقیقه)"
type="number"
inputmode="numeric"
ltr
/>
</template>
<div class="flex gap-2"> <div class="flex gap-2">
<AppButton class="!w-auto flex-1" :loading="savingService" @click="onSaveEdit(svc)"> <AppButton class="!w-auto flex-1" :loading="savingService" @click="onSaveEdit(svc)">
ذخیره ذخیره
@@ -129,21 +163,50 @@
</div> </div>
</div> </div>
<div v-else class="mt-2 flex flex-wrap items-center gap-2 text-sm text-muted"> <div v-else class="mt-2 space-y-2 text-sm text-muted">
<span>{{ formatPriceToman(svc.price) }} تومان</span> <template v-if="hasServiceClasses">
<span>·</span> <div
<span>{{ v-for="cls in activeClasses"
formatDuration( :key="cls.id"
svc.durationMinutes, class="flex flex-wrap items-center gap-2"
svc.serviceTemplate?.defaultDurationMinutes, >
) <span class="font-medium text-text">{{ cls.title }}:</span>
}}</span> <span>
{{
getClassPriceForService(svc, cls.id)
? `${formatPriceToman(getClassPriceForService(svc, cls.id)!.price)} تومان`
: '—'
}}
</span>
<span v-if="getClassPriceForService(svc, cls.id)">·</span>
<span v-if="getClassPriceForService(svc, cls.id)">
{{
formatDuration(
getClassPriceForService(svc, cls.id)!.durationMinutes,
svc.durationMinutes ?? svc.serviceTemplate?.defaultDurationMinutes,
)
}}
</span>
</div>
</template>
<div v-else class="flex flex-wrap items-center gap-2">
<span>{{ formatPriceToman(svc.price) }} تومان</span>
<span>·</span>
<span>{{
formatDuration(
svc.durationMinutes,
svc.serviceTemplate?.defaultDurationMinutes,
)
}}</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<AppButton variant="ghost" class="!w-auto px-2 text-xs" @click="startEdit(svc)"> <AppButton variant="ghost" class="!w-auto px-2 text-xs" @click="startEdit(svc)">
ویرایش ویرایش
</AppButton> </AppButton>
<AppButton variant="danger" class="!w-auto px-2 text-xs" @click="onDelete(svc)"> <AppButton variant="danger" class="!w-auto px-2 text-xs" @click="onDelete(svc)">
حذف حذف
</AppButton> </AppButton>
</div>
</div> </div>
</li> </li>
</ul> </ul>
@@ -181,12 +244,14 @@
</div> </div>
<PriceInput <PriceInput
v-if="!hasServiceClasses"
id="svcPrice" id="svcPrice"
v-model="serviceForm.price" v-model="serviceForm.price"
label="قیمت (تومان)" label="قیمت (تومان)"
required required
/> />
<AppInput <AppInput
v-if="!hasServiceClasses"
id="svcDuration" id="svcDuration"
v-model="serviceForm.durationMinutes" v-model="serviceForm.durationMinutes"
label="مدت (دقیقه — اختیاری)" label="مدت (دقیقه — اختیاری)"
@@ -195,6 +260,31 @@
ltr ltr
/> />
<div v-if="hasServiceClasses" class="space-y-2">
<p class="text-sm font-medium text-text">قیمت بر اساس کلاس</p>
<div
v-for="cls in activeClasses"
:key="cls.id"
class="space-y-2 rounded-xl border border-border bg-surface px-3 py-3"
>
<p class="text-sm font-medium text-text">{{ cls.title }}</p>
<PriceInput
:id="`add-class-price-${cls.id}`"
v-model="classPriceForm[cls.id].price"
label="قیمت (تومان)"
required
/>
<AppInput
:id="`add-class-duration-${cls.id}`"
v-model="classPriceForm[cls.id].durationMinutes"
label="مدت (دقیقه — اختیاری)"
type="number"
inputmode="numeric"
ltr
/>
</div>
</div>
<AppButton type="submit" :loading="addingService">افزودن</AppButton> <AppButton type="submit" :loading="addingService">افزودن</AppButton>
</form> </form>
@@ -272,17 +362,21 @@ import {
createBusinessService, createBusinessService,
deleteBusinessService, deleteBusinessService,
fetchBusinessServices, fetchBusinessServices,
setBusinessServiceClassPrices,
updateBusinessService, updateBusinessService,
type ClassPriceItem,
} from '@/api/business-services'; } from '@/api/business-services';
import { fetchServiceTemplates } from '@/api/service-templates'; import { fetchServiceTemplates } from '@/api/service-templates';
import type { import type {
Business, Business,
BusinessService, BusinessService,
BusinessServiceClass,
BusinessServiceRequest, BusinessServiceRequest,
BusinessServiceRequestStatus, BusinessServiceRequestStatus,
} from '@/api/types'; } from '@/api/types';
import { ApiError } from '@/api/client'; import { ApiError } from '@/api/client';
import BusinessAvailabilityEditor from '@/components/owner/BusinessAvailabilityEditor.vue'; import BusinessAvailabilityEditor from '@/components/owner/BusinessAvailabilityEditor.vue';
import ServiceClassManager from '@/components/owner/ServiceClassManager.vue';
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';
import PriceInput from '@/components/ui/PriceInput.vue'; import PriceInput from '@/components/ui/PriceInput.vue';
@@ -294,9 +388,14 @@ import {
parseOptionalMinutes, parseOptionalMinutes,
parsePriceInput, parsePriceInput,
} from '@/utils/format'; } from '@/utils/format';
import {
activeServiceClasses,
getClassPriceForService,
} from '@/utils/service-pricing';
const business = ref<Business | null>(null); const business = ref<Business | null>(null);
const services = ref<BusinessService[]>([]); const services = ref<BusinessService[]>([]);
const serviceClasses = ref<BusinessServiceClass[]>([]);
const templates = ref<Awaited<ReturnType<typeof fetchServiceTemplates>>>([]); const templates = ref<Awaited<ReturnType<typeof fetchServiceTemplates>>>([]);
const loading = ref(true); const loading = ref(true);
const loadingServices = ref(false); const loadingServices = ref(false);
@@ -327,6 +426,9 @@ const editForm = ref({
durationMinutes: '', durationMinutes: '',
}); });
const classPriceForm = ref<Record<string, { price: string; durationMinutes: string }>>({});
const editClassPrices = ref<Record<string, { price: string; durationMinutes: string }>>({});
const customForm = ref({ const customForm = ref({
title: '', title: '',
description: '', description: '',
@@ -339,6 +441,67 @@ const availableTemplates = computed(() => {
return templates.value.filter((t) => !used.has(t.id)); return templates.value.filter((t) => !used.has(t.id));
}); });
const activeClasses = computed(() => activeServiceClasses(serviceClasses.value));
const hasServiceClasses = computed(
() => business.value?.serviceClassesEnabled === true && activeClasses.value.length > 0,
);
function emptyClassPriceForm() {
const result: Record<string, { price: string; durationMinutes: string }> = {};
for (const cls of activeClasses.value) {
result[cls.id] = { price: '', durationMinutes: '' };
}
return result;
}
function buildClassPricesPayload(
form: Record<string, { price: string; durationMinutes: string }>,
): ClassPriceItem[] | null {
const prices: ClassPriceItem[] = [];
for (const cls of activeClasses.value) {
const row = form[cls.id];
const price = parsePriceInput(row?.price ?? '');
if (price === null) {
return null;
}
const duration = parseOptionalMinutes(row?.durationMinutes ?? '');
if (duration === null) {
return null;
}
prices.push({
classId: cls.id,
price,
durationMinutes: duration && duration > 0 ? duration : null,
});
}
return prices;
}
function onServiceClassesUpdated(classes: BusinessServiceClass[]) {
serviceClasses.value = classes;
classPriceForm.value = emptyClassPriceForm();
}
async function onServiceClassesEnabledChange(value: boolean) {
if (!business.value) return;
error.value = '';
message.value = '';
try {
business.value = await updateBusiness(business.value.id, {
serviceClassesEnabled: value,
});
if (!value) {
serviceClasses.value = [];
classPriceForm.value = {};
}
message.value = value
? 'کلاس‌بندی فعال شد. کلاس‌های خود را تعریف کنید.'
: 'کلاس‌بندی غیرفعال شد. قیمت‌گذاری به حالت عادی برمی‌گردد.';
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'تغییر وضعیت کلاس‌بندی ناموفق بود';
}
}
async function loadServicesAndTemplates() { async function loadServicesAndTemplates() {
if (!business.value) return; if (!business.value) return;
loadingServices.value = true; loadingServices.value = true;
@@ -405,27 +568,47 @@ async function onSave() {
async function onAddService() { async function onAddService() {
if (!business.value || !serviceForm.value.serviceTemplateId) return; if (!business.value || !serviceForm.value.serviceTemplateId) return;
const price = parsePriceInput(serviceForm.value.price);
if (price === null) { let price = 0;
error.value = 'قیمت معتبر نیست.'; let duration: number | null | undefined;
return; let classPrices: ClassPriceItem[] | null = null;
}
const duration = parseOptionalMinutes(serviceForm.value.durationMinutes); if (hasServiceClasses.value) {
if (duration === null) { classPrices = buildClassPricesPayload(classPriceForm.value);
error.value = 'مدت زمان معتبر نیست.'; if (!classPrices?.length) {
return; error.value = 'قیمت همه کلاس‌ها را وارد کنید.';
return;
}
price = Math.min(...classPrices.map((item) => item.price));
} else {
const parsedPrice = parsePriceInput(serviceForm.value.price);
if (parsedPrice === null) {
error.value = 'قیمت معتبر نیست.';
return;
}
price = parsedPrice;
duration = parseOptionalMinutes(serviceForm.value.durationMinutes);
if (duration === null) {
error.value = 'مدت زمان معتبر نیست.';
return;
}
} }
addingService.value = true; addingService.value = true;
error.value = ''; error.value = '';
message.value = ''; message.value = '';
try { try {
await createBusinessService({ const created = await createBusinessService({
businessId: business.value.id, businessId: business.value.id,
serviceTemplateId: serviceForm.value.serviceTemplateId, serviceTemplateId: serviceForm.value.serviceTemplateId,
price, price,
durationMinutes: duration && duration > 0 ? duration : undefined, durationMinutes: duration && duration > 0 ? duration : undefined,
}); });
if (classPrices?.length) {
await setBusinessServiceClassPrices(created.id, classPrices);
}
serviceForm.value = { serviceTemplateId: '', price: '', durationMinutes: '' }; serviceForm.value = { serviceTemplateId: '', price: '', durationMinutes: '' };
classPriceForm.value = emptyClassPriceForm();
await loadServicesAndTemplates(); await loadServicesAndTemplates();
if (availableTemplates.value[0]) { if (availableTemplates.value[0]) {
serviceForm.value.serviceTemplateId = availableTemplates.value[0].id; serviceForm.value.serviceTemplateId = availableTemplates.value[0].id;
@@ -491,6 +674,17 @@ function startEdit(svc: BusinessService) {
price: formatPriceInputDisplay(Math.round(Number.parseFloat(svc.price))), price: formatPriceInputDisplay(Math.round(Number.parseFloat(svc.price))),
durationMinutes: svc.durationMinutes != null ? String(svc.durationMinutes) : '', durationMinutes: svc.durationMinutes != null ? String(svc.durationMinutes) : '',
}; };
const form = emptyClassPriceForm();
for (const classPrice of svc.classPrices ?? []) {
if (form[classPrice.classId]) {
form[classPrice.classId] = {
price: formatPriceInputDisplay(Math.round(Number.parseFloat(classPrice.price))),
durationMinutes:
classPrice.durationMinutes != null ? String(classPrice.durationMinutes) : '',
};
}
}
editClassPrices.value = form;
} }
function cancelEdit() { function cancelEdit() {
@@ -498,23 +692,32 @@ function cancelEdit() {
} }
async function onSaveEdit(svc: BusinessService) { async function onSaveEdit(svc: BusinessService) {
const price = parsePriceInput(editForm.value.price);
if (price === null) {
error.value = 'قیمت معتبر نیست.';
return;
}
const duration = parseOptionalMinutes(editForm.value.durationMinutes);
if (duration === null) {
error.value = 'مدت زمان معتبر نیست.';
return;
}
savingService.value = true; savingService.value = true;
error.value = ''; error.value = '';
try { try {
await updateBusinessService(svc.id, { if (hasServiceClasses.value) {
price, const classPrices = buildClassPricesPayload(editClassPrices.value);
durationMinutes: duration ?? null, if (!classPrices?.length) {
}); error.value = 'قیمت همه کلاس‌ها را وارد کنید.';
return;
}
await setBusinessServiceClassPrices(svc.id, classPrices);
} else {
const price = parsePriceInput(editForm.value.price);
if (price === null) {
error.value = 'قیمت معتبر نیست.';
return;
}
const duration = parseOptionalMinutes(editForm.value.durationMinutes);
if (duration === null) {
error.value = 'مدت زمان معتبر نیست.';
return;
}
await updateBusinessService(svc.id, {
price,
durationMinutes: duration ?? null,
});
}
editingId.value = null; editingId.value = null;
await loadServicesAndTemplates(); await loadServicesAndTemplates();
message.value = 'خدمت به‌روزرسانی شد.'; message.value = 'خدمت به‌روزرسانی شد.';