update rezerv
This commit is contained in:
+31
-3
@@ -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}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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 = '';
|
||||||
|
if (props.businessServiceIds.length) {
|
||||||
loadSlots();
|
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,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 />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -61,20 +61,52 @@
|
|||||||
{{ 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'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
|
<label
|
||||||
|
v-if="canBook"
|
||||||
|
class="flex w-full cursor-pointer flex-col items-center"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
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">
|
<p class="font-medium text-text">
|
||||||
{{ svc.serviceTemplate?.title ?? 'خدمت' }}
|
{{ svc.serviceTemplate?.title ?? 'خدمت' }}
|
||||||
</p>
|
</p>
|
||||||
@@ -85,34 +117,56 @@
|
|||||||
{{ svc.serviceTemplate.description }}
|
{{ svc.serviceTemplate.description }}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-auto space-y-0.5 pt-2 text-xs text-muted">
|
<div class="mt-auto space-y-0.5 pt-2 text-xs text-muted">
|
||||||
<p>{{ formatPriceToman(svc.price) }} تومان</p>
|
<p>{{ servicePriceLabel(svc, classesEnabled, serviceClasses) }}</p>
|
||||||
<p>{{
|
<p>{{ serviceDurationLabel(svc, classesEnabled, serviceClasses) }}</p>
|
||||||
formatDuration(
|
|
||||||
svc.durationMinutes,
|
|
||||||
svc.serviceTemplate?.defaultDurationMinutes,
|
|
||||||
)
|
|
||||||
}}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<AppButton
|
</label>
|
||||||
v-if="canBook"
|
<template v-else>
|
||||||
class="mt-3"
|
<p class="font-medium text-text">
|
||||||
variant="primary"
|
{{ svc.serviceTemplate?.title ?? 'خدمت' }}
|
||||||
@click="openBooking(svc)"
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="svc.serviceTemplate?.description"
|
||||||
|
class="mt-1 line-clamp-2 text-xs text-muted"
|
||||||
>
|
>
|
||||||
رزرو
|
{{ svc.serviceTemplate.description }}
|
||||||
</AppButton>
|
</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) {
|
||||||
|
|||||||
@@ -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,6 +114,31 @@
|
|||||||
</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">
|
||||||
|
<template v-if="hasServiceClasses">
|
||||||
|
<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="`edit-class-price-${svc.id}-${cls.id}`"
|
||||||
|
v-model="editClassPrices[cls.id].price"
|
||||||
|
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
|
<PriceInput
|
||||||
id="editPrice"
|
id="editPrice"
|
||||||
v-model="editForm.price"
|
v-model="editForm.price"
|
||||||
@@ -119,6 +152,7 @@
|
|||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
ltr
|
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,7 +163,33 @@
|
|||||||
</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">
|
||||||
|
<template v-if="hasServiceClasses">
|
||||||
|
<div
|
||||||
|
v-for="cls in activeClasses"
|
||||||
|
:key="cls.id"
|
||||||
|
class="flex flex-wrap items-center gap-2"
|
||||||
|
>
|
||||||
|
<span class="font-medium text-text">{{ cls.title }}:</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>{{ formatPriceToman(svc.price) }} تومان</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>{{
|
<span>{{
|
||||||
@@ -138,6 +198,8 @@
|
|||||||
svc.serviceTemplate?.defaultDurationMinutes,
|
svc.serviceTemplate?.defaultDurationMinutes,
|
||||||
)
|
)
|
||||||
}}</span>
|
}}</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>
|
||||||
@@ -145,6 +207,7 @@
|
|||||||
حذف
|
حذف
|
||||||
</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;
|
||||||
|
let duration: number | null | undefined;
|
||||||
|
let classPrices: ClassPriceItem[] | null = null;
|
||||||
|
|
||||||
|
if (hasServiceClasses.value) {
|
||||||
|
classPrices = buildClassPricesPayload(classPriceForm.value);
|
||||||
|
if (!classPrices?.length) {
|
||||||
|
error.value = 'قیمت همه کلاسها را وارد کنید.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
price = Math.min(...classPrices.map((item) => item.price));
|
||||||
|
} else {
|
||||||
|
const parsedPrice = parsePriceInput(serviceForm.value.price);
|
||||||
|
if (parsedPrice === null) {
|
||||||
error.value = 'قیمت معتبر نیست.';
|
error.value = 'قیمت معتبر نیست.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const duration = parseOptionalMinutes(serviceForm.value.durationMinutes);
|
price = parsedPrice;
|
||||||
|
duration = parseOptionalMinutes(serviceForm.value.durationMinutes);
|
||||||
if (duration === null) {
|
if (duration === null) {
|
||||||
error.value = 'مدت زمان معتبر نیست.';
|
error.value = 'مدت زمان معتبر نیست.';
|
||||||
return;
|
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,6 +692,17 @@ function cancelEdit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onSaveEdit(svc: BusinessService) {
|
async function onSaveEdit(svc: BusinessService) {
|
||||||
|
savingService.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
if (hasServiceClasses.value) {
|
||||||
|
const classPrices = buildClassPricesPayload(editClassPrices.value);
|
||||||
|
if (!classPrices?.length) {
|
||||||
|
error.value = 'قیمت همه کلاسها را وارد کنید.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await setBusinessServiceClassPrices(svc.id, classPrices);
|
||||||
|
} else {
|
||||||
const price = parsePriceInput(editForm.value.price);
|
const price = parsePriceInput(editForm.value.price);
|
||||||
if (price === null) {
|
if (price === null) {
|
||||||
error.value = 'قیمت معتبر نیست.';
|
error.value = 'قیمت معتبر نیست.';
|
||||||
@@ -508,13 +713,11 @@ async function onSaveEdit(svc: BusinessService) {
|
|||||||
error.value = 'مدت زمان معتبر نیست.';
|
error.value = 'مدت زمان معتبر نیست.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
savingService.value = true;
|
|
||||||
error.value = '';
|
|
||||||
try {
|
|
||||||
await updateBusinessService(svc.id, {
|
await updateBusinessService(svc.id, {
|
||||||
price,
|
price,
|
||||||
durationMinutes: duration ?? null,
|
durationMinutes: duration ?? null,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
editingId.value = null;
|
editingId.value = null;
|
||||||
await loadServicesAndTemplates();
|
await loadServicesAndTemplates();
|
||||||
message.value = 'خدمت بهروزرسانی شد.';
|
message.value = 'خدمت بهروزرسانی شد.';
|
||||||
|
|||||||
Reference in New Issue
Block a user