Files
webMontime/src/views/app/AppOwnerView.vue
T
2026-06-29 15:49:13 +03:30

993 lines
36 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<ResponsiveAppLayout>
<h1 class="mb-1 text-xl font-bold text-text">مدیریت کسب‌وکار</h1>
<p class="mb-6 text-sm text-muted">تنظیمات کسب‌وکار، خدمات و ساعات کاری</p>
<p
v-if="error"
class="mb-4 rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-600"
>
{{ error }}
</p>
<p
v-if="message"
class="mb-4 rounded-xl border border-brand-500/40 bg-brand-500/10 px-4 py-3 text-sm text-brand-700 dark:text-brand-500"
>
{{ message }}
</p>
<FormPageSkeleton v-if="loading" />
<div
v-else-if="!business"
class="rounded-2xl border border-dashed border-border p-6 text-center text-sm text-muted"
>
کسب‌وکاری به حساب شما وصل نیست.
</div>
<div v-else class="mx-auto max-w-3xl space-y-8">
<form class="space-y-4" @submit.prevent="onSave">
<h2 class="text-base font-semibold text-text">اطلاعات کسب‌وکار</h2>
<dl class="space-y-3 rounded-2xl border border-border bg-surface-2 p-4">
<div class="flex items-center justify-between gap-4">
<dt class="shrink-0 text-xs text-muted">دسته</dt>
<dd class="font-medium text-text">{{ business.category?.title ?? '—' }}</dd>
</div>
<div class="flex items-center justify-between gap-4">
<dt class="shrink-0 text-xs text-muted">وضعیت</dt>
<dd>
<span
class="inline-block rounded-lg px-2 py-0.5 text-xs"
:class="business.isActive ? 'bg-green-500/15 text-green-700' : 'bg-amber-500/15 text-amber-700'"
>
{{ business.isActive ? 'فعال' : 'غیرفعال' }}
</span>
</dd>
</div>
</dl>
<AppInput id="ownerTitle" v-model="form.title" label="نام کسب‌وکار" required />
<AppInput id="ownerSubtitle" v-model="form.subtitle" label="زیرعنوان" />
<div class="space-y-1.5">
<label for="ownerDesc" class="block text-sm font-medium text-muted">توضیحات</label>
<textarea
id="ownerDesc"
v-model="form.description"
rows="3"
class="w-full rounded-xl border border-border bg-surface-2 px-4 py-3 text-text outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
/>
</div>
<AppInput
id="ownerPhone"
v-model="form.contactPhone"
label="تلفن"
type="tel"
inputmode="tel"
ltr
/>
<AppInput id="ownerAddress" v-model="form.address" label="آدرس" />
<AppButton type="submit" :loading="saving">ذخیره تغییرات</AppButton>
</form>
<section class="border-t border-border pt-6">
<BusinessImageUpload
:existing="business.images ?? []"
:deleting-id="deletingImageId"
:setting-cover-id="settingCoverId"
:uploading="uploadingImages"
@files-picked="onUploadImages"
@remove-existing="onRemoveImage"
@set-cover="onSetCoverImage"
/>
</section>
<section class="border-t border-border pt-4">
<button
type="button"
class="flex w-full items-center justify-between gap-3 rounded-2xl border border-border bg-surface-2 px-4 py-3 text-start transition hover:border-brand-500/30"
:aria-expanded="bookingExpanded"
@click="bookingExpanded = !bookingExpanded"
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<AppIcon name="calendar" size="sm" class="text-brand-600 dark:text-brand-500" />
<h2 class="text-base font-semibold text-text">تنظیمات نوبت‌دهی</h2>
</div>
<p v-if="!bookingExpanded" class="mt-1 truncate text-xs text-muted">
{{ bookingSummary }}
</p>
</div>
<AppIcon
:name="bookingExpanded ? 'chevron-up' : 'chevron-down'"
size="sm"
class="shrink-0 text-muted"
/>
</button>
<div v-show="bookingExpanded" class="mt-3 space-y-4">
<AppInput
id="ownerConcurrentCapacity"
v-model="bookingForm.concurrentCapacity"
label="پذیرش همزمان"
type="number"
inputmode="numeric"
ltr
required
/>
<p class="-mt-2 text-xs text-muted">
حداکثر تعداد مشتری که همزمان می‌توانید پذیرش کنید. مثلاً ۳ یعنی هر ساعت تا ۳ رزرو هم‌پوشان.
</p>
<AppInput
id="ownerSlotStep"
v-model="bookingForm.slotStepMinutes"
label="گام نوبت‌دهی (دقیقه)"
type="number"
inputmode="numeric"
ltr
required
/>
<p class="-mt-2 text-xs text-muted">
فاصله بین ساعت‌های قابل رزرو. مثلاً ۳۰ یعنی ۱۰:۰۰، ۱۰:۳۰، ۱۱:۰۰ و …
</p>
<label class="flex cursor-pointer items-start gap-3 rounded-xl border border-border bg-surface px-4 py-3">
<input
v-model="bookingForm.autoApproveBookings"
type="checkbox"
class="mt-1"
/>
<span class="min-w-0">
<span class="block text-sm font-medium text-text">تأیید خودکار درخواست‌ها</span>
<span class="mt-1 block text-xs text-muted">
اگر فعال باشد، رزرو بلافاصله برای پرداخت آنلاین ارسال می‌شود. در غیر این صورت ابتدا
شما درخواست را تأیید می‌کنید و سپس لینک پرداخت برای مشتری ارسال می‌شود.
</span>
</span>
</label>
<AppButton :loading="savingBooking" @click="onSaveBookingSettings">
ذخیره تنظیمات نوبت‌دهی
</AppButton>
</div>
</section>
<BusinessAvailabilityEditor
v-if="business"
:business-id="business.id"
/>
<ServiceClassManager
v-if="business"
:business-id="business.id"
:enabled="business.serviceClassesEnabled === true"
@updated="onServiceClassesUpdated"
@update:enabled="onServiceClassesEnabledChange"
/>
<section class="border-t border-border pt-4">
<button
type="button"
class="flex w-full items-center justify-between gap-3 rounded-2xl border border-border bg-surface-2 px-4 py-3 text-start transition hover:border-brand-500/30"
:aria-expanded="servicesExpanded"
@click="servicesExpanded = !servicesExpanded"
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<AppIcon name="clipboard-list" size="sm" class="text-brand-600 dark:text-brand-500" />
<h2 class="text-base font-semibold text-text">خدمات و قیمت‌ها</h2>
</div>
<p v-if="!servicesExpanded" class="mt-1 truncate text-xs text-muted">
{{ servicesSummary }}
</p>
</div>
<AppIcon
:name="servicesExpanded ? 'chevron-up' : 'chevron-down'"
size="sm"
class="shrink-0 text-muted"
/>
</button>
<div v-show="servicesExpanded" class="mt-3 space-y-4">
<p class="text-xs text-muted">
خدماتی که اینجا ثبت می‌کنید در صفحه کسب‌وکار برای مشتریان و مقایسه نمایش داده می‌شود.
</p>
<ListCardSkeleton v-if="loadingServices" :count="3" />
<ul v-else-if="services.length" class="space-y-2">
<li
v-for="svc in services"
:key="svc.id"
class="rounded-xl border border-border bg-surface-2 p-3"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<p class="font-medium text-text">
{{ svc.serviceTemplate?.title ?? 'خدمت' }}
</p>
<p
v-if="svc.serviceTemplate?.description"
class="mt-0.5 text-xs text-muted"
>
{{ svc.serviceTemplate.description }}
</p>
</div>
<div class="flex shrink-0 items-center gap-1">
<label class="flex items-center gap-1 text-xs text-muted">
<input
type="checkbox"
:checked="svc.isActive"
@change="onToggleActive(svc)"
/>
فعال
</label>
<template v-if="editingId !== svc.id">
<AppIconButton icon="pencil" label="ویرایش خدمت" @click="startEdit(svc)" />
<AppIconButton icon="trash" label="حذف خدمت" variant="danger" @click="onDelete(svc)" />
</template>
</div>
</div>
<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
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">
<AppButton class="!w-auto flex-1" :loading="savingService" @click="onSaveEdit(svc)">
ذخیره
</AppButton>
<AppButton variant="ghost" class="!w-auto px-4" @click="cancelEdit">
انصراف
</AppButton>
</div>
</div>
<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>{{ servicePriceLabel(svc, true, serviceClasses, cls.id) }}</span>
<template v-if="getClassPriceForService(svc, cls.id)">
<span>·</span>
<span>{{ serviceDurationLabel(svc, true, serviceClasses, cls.id) }}</span>
</template>
</div>
</template>
<div v-else class="flex flex-wrap items-center gap-2">
<span>{{ formatPriceToman(svc.price) }} تومان</span>
<span>·</span>
<span>{{ serviceDurationLabel(svc, false, serviceClasses) }}</span>
</div>
</div>
</li>
</ul>
<p v-else class="text-sm text-muted">هنوز خدمتی ثبت نشده است.</p>
<div
v-if="!loadingServices && templates.length === 0"
class="rounded-xl border border-dashed border-border bg-surface-2 p-4 text-sm text-muted"
>
برای دسته «{{ business.category?.title }}» هنوز نوع خدمتی تعریف نشده. از مدیر سیستم
بخواهید قالب خدمت اضافه کند.
</div>
<form
v-else-if="availableTemplates.length"
class="space-y-3 rounded-2xl border border-border bg-surface-2 p-4"
@submit.prevent="onAddService"
>
<p class="text-sm font-medium text-text">افزودن خدمت</p>
<div class="space-y-1.5">
<label for="svcTemplate" class="block text-sm font-medium text-muted">نوع خدمت</label>
<select
id="svcTemplate"
v-model="serviceForm.serviceTemplateId"
required
class="w-full rounded-xl border border-border bg-surface px-4 py-3 text-text outline-none focus:border-brand-500"
>
<option value="" disabled>انتخاب کنید</option>
<option v-for="tpl in availableTemplates" :key="tpl.id" :value="tpl.id">
{{ tpl.title }}
</option>
</select>
</div>
<PriceInput
v-if="!hasServiceClasses"
id="svcPrice"
v-model="serviceForm.price"
label="قیمت (تومان)"
required
/>
<AppInput
v-if="!hasServiceClasses"
id="svcDuration"
v-model="serviceForm.durationMinutes"
label="مدت (دقیقه — اختیاری)"
type="number"
inputmode="numeric"
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>
</form>
<p
v-else-if="!loadingServices && templates.length && !availableTemplates.length"
class="text-sm text-muted"
>
همهٔ انواع خدمت این دسته را اضافه کرده‌اید.
</p>
<section class="space-y-3 rounded-2xl border border-dashed border-brand-500/40 bg-brand-500/5 p-4">
<h3 class="text-sm font-semibold text-text">خدمت سفارشی (خارج از لیست ادمین)</h3>
<p class="text-xs text-muted">
اگر خدمتی در لیست بالا نیست، اینجا پیشنهاد دهید. پس از تأیید مدیر، به خدمات کسب‌وکار شما
اضافه می‌شود.
</p>
<ul v-if="serviceRequests.length" class="space-y-2">
<li
v-for="req in serviceRequests"
:key="req.id"
class="rounded-xl border border-border bg-surface-2 px-3 py-2 text-sm"
>
<p class="font-medium text-text">{{ req.title }}</p>
<p class="mt-0.5 text-xs text-muted">
{{ formatPriceToman(req.price) }} تومان
<span v-if="req.durationMinutes"> · {{ req.durationMinutes }} دقیقه</span>
</p>
<span
class="mt-1 inline-block rounded-lg px-2 py-0.5 text-xs"
:class="requestStatusClass(req.status)"
>
{{ requestStatusLabel(req.status) }}
</span>
<p v-if="req.adminNote" class="mt-1 text-xs text-red-600">{{ req.adminNote }}</p>
</li>
</ul>
<form class="space-y-3" @submit.prevent="onAddCustomService">
<AppInput id="customTitle" v-model="customForm.title" label="نام خدمت" required />
<div class="space-y-1.5">
<label for="customDesc" class="block text-sm font-medium text-muted">توضیح (اختیاری)</label>
<textarea
id="customDesc"
v-model="customForm.description"
rows="2"
class="w-full rounded-xl border border-border bg-surface-2 px-4 py-3 text-text outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
/>
</div>
<PriceInput id="customPrice" v-model="customForm.price" label="قیمت پیشنهادی (تومان)" required />
<AppInput
id="customDuration"
v-model="customForm.durationMinutes"
label="مدت (دقیقه — اختیاری)"
type="number"
inputmode="numeric"
ltr
/>
<AppButton type="submit" :loading="addingCustom">ارسال برای تأیید</AppButton>
</form>
</section>
</div>
</section>
</div>
</ResponsiveAppLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { fetchMyBusinesses, updateBusiness, uploadBusinessImages, deleteBusinessImage, setBusinessCoverImage } from '@/api/businesses';
import {
createServiceRequest,
fetchMyServiceRequests,
} from '@/api/business-service-requests';
import {
createBusinessService,
deleteBusinessService,
fetchBusinessServices,
setBusinessServiceClassPrices,
updateBusinessService,
type ClassPriceItem,
} from '@/api/business-services';
import { fetchServiceTemplates } from '@/api/service-templates';
import type {
Business,
BusinessService,
BusinessServiceClass,
BusinessServiceRequest,
BusinessServiceRequestStatus,
} from '@/api/types';
import { ApiError } from '@/api/client';
import BusinessAvailabilityEditor from '@/components/owner/BusinessAvailabilityEditor.vue';
import BusinessImageUpload from '@/components/owner/BusinessImageUpload.vue';
import ServiceClassManager from '@/components/owner/ServiceClassManager.vue';
import FormPageSkeleton from '@/components/ui/FormPageSkeleton.vue';
import ListCardSkeleton from '@/components/ui/ListCardSkeleton.vue';
import AppButton from '@/components/ui/AppButton.vue';
import AppIcon from '@/components/ui/AppIcon.vue';
import AppIconButton from '@/components/ui/AppIconButton.vue';
import AppInput from '@/components/ui/AppInput.vue';
import PriceInput from '@/components/ui/PriceInput.vue';
import ResponsiveAppLayout from '@/layouts/ResponsiveAppLayout.vue';
import { useTenantStore } from '@/stores/tenant';
import {
formatPriceInputDisplay,
formatPriceToman,
parseOptionalMinutes,
parsePriceInput,
} from '@/utils/format';
import {
activeServiceClasses,
getClassPriceForService,
serviceDurationLabel,
servicePriceLabel,
} from '@/utils/service-pricing';
const tenant = useTenantStore();
const business = ref<Business | null>(null);
const services = ref<BusinessService[]>([]);
const serviceClasses = ref<BusinessServiceClass[]>([]);
const templates = ref<Awaited<ReturnType<typeof fetchServiceTemplates>>>([]);
const loading = ref(true);
const loadingServices = ref(false);
const saving = ref(false);
const savingBooking = ref(false);
const savingService = ref(false);
const uploadingImages = ref(false);
const deletingImageId = ref<string | null>(null);
const settingCoverId = ref<string | null>(null);
const addingService = ref(false);
const addingCustom = ref(false);
const serviceRequests = ref<BusinessServiceRequest[]>([]);
const error = ref('');
const message = ref('');
const editingId = ref<string | null>(null);
const servicesExpanded = ref(false);
const form = ref({
title: '',
subtitle: '',
description: '',
contactPhone: '',
address: '',
});
const bookingForm = ref({
concurrentCapacity: '1',
slotStepMinutes: '30',
autoApproveBookings: false,
});
const bookingExpanded = ref(false);
const serviceForm = ref({
serviceTemplateId: '',
price: '',
durationMinutes: '',
});
const editForm = ref({
price: '',
durationMinutes: '',
});
const classPriceForm = ref<Record<string, { price: string; durationMinutes: string }>>({});
const editClassPrices = ref<Record<string, { price: string; durationMinutes: string }>>({});
const customForm = ref({
title: '',
description: '',
price: '',
durationMinutes: '',
});
const availableTemplates = computed(() => {
const used = new Set(services.value.map((s) => s.serviceTemplateId));
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,
);
const servicesSummary = computed(() => {
if (loadingServices.value) return '…';
const count = services.value.length;
if (count === 0) return 'هنوز خدمتی ثبت نشده';
const activeCount = services.value.filter((s) => s.isActive).length;
return `${count} خدمت · ${activeCount} فعال`;
});
const bookingSummary = computed(() => {
const auto = bookingForm.value.autoApproveBookings ? 'تأیید خودکار' : 'تأیید دستی';
return `${bookingForm.value.concurrentCapacity} همزمان · گام ${bookingForm.value.slotStepMinutes} دقیقه · ${auto}`;
});
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,
});
tenant.syncSingleBusiness(business.value);
if (!value) {
serviceClasses.value = [];
classPriceForm.value = {};
}
message.value = value
? 'کلاس‌بندی فعال شد. کلاس‌های خود را تعریف کنید.'
: 'کلاس‌بندی غیرفعال شد. قیمت‌گذاری به حالت عادی برمی‌گردد.';
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'تغییر وضعیت کلاس‌بندی ناموفق بود';
}
}
async function loadServicesAndTemplates() {
if (!business.value) return;
loadingServices.value = true;
try {
const [svcList, tplList, reqs] = await Promise.all([
fetchBusinessServices(business.value.id),
fetchServiceTemplates(business.value.categoryId),
fetchMyServiceRequests(),
]);
services.value = svcList;
templates.value = tplList;
serviceRequests.value = reqs;
if (availableTemplates.value[0] && !serviceForm.value.serviceTemplateId) {
serviceForm.value.serviceTemplateId = availableTemplates.value[0].id;
}
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'خطا در بارگذاری خدمات';
} finally {
loadingServices.value = false;
}
}
onMounted(async () => {
loading.value = true;
error.value = '';
try {
const list = await fetchMyBusinesses();
business.value = list[0] ?? null;
if (business.value) {
form.value = {
title: business.value.title,
subtitle: business.value.subtitle ?? '',
description: business.value.description ?? '',
contactPhone: business.value.contactPhone ?? business.value.phone ?? '',
address: business.value.address ?? '',
};
bookingForm.value = {
concurrentCapacity: String(business.value.concurrentCapacity ?? 1),
slotStepMinutes: String(business.value.slotStepMinutes ?? 30),
autoApproveBookings: business.value.autoApproveBookings === true,
};
await loadServicesAndTemplates();
}
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'خطا در بارگذاری';
} finally {
loading.value = false;
}
});
async function refreshBusinessImages() {
if (!business.value) return;
const list = await fetchMyBusinesses();
const fresh = list.find((item) => item.id === business.value?.id) ?? list[0];
if (!fresh) return;
business.value = { ...business.value, images: fresh.images ?? [] };
tenant.syncSingleBusiness(business.value);
}
async function onUploadImages(files: File[]) {
if (!business.value || !files.length) return;
const currentCount = business.value.images?.length ?? 0;
if (currentCount + files.length > 10) {
error.value = 'حداکثر ۱۰ تصویر مجاز است.';
return;
}
uploadingImages.value = true;
error.value = '';
message.value = '';
try {
await uploadBusinessImages(files);
await refreshBusinessImages();
message.value = 'تصاویر با موفقیت آپلود شدند.';
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'آپلود تصویر ناموفق بود';
} finally {
uploadingImages.value = false;
}
}
async function onRemoveImage(imageId: string) {
if (!business.value) return;
if (!window.confirm('این تصویر حذف شود؟')) return;
deletingImageId.value = imageId;
error.value = '';
message.value = '';
try {
await deleteBusinessImage(imageId);
await refreshBusinessImages();
message.value = 'تصویر حذف شد.';
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'حذف تصویر ناموفق بود';
} finally {
deletingImageId.value = null;
}
}
async function onSetCoverImage(imageId: string) {
if (!business.value) return;
settingCoverId.value = imageId;
error.value = '';
message.value = '';
try {
await setBusinessCoverImage(imageId);
await refreshBusinessImages();
message.value = 'تصویر کاور به‌روزرسانی شد.';
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'تنظیم کاور ناموفق بود';
} finally {
settingCoverId.value = null;
}
}
async function onSave() {
if (!business.value) return;
saving.value = true;
error.value = '';
message.value = '';
try {
business.value = await updateBusiness(business.value.id, {
title: form.value.title.trim(),
subtitle: form.value.subtitle.trim() || null,
description: form.value.description.trim() || null,
contactPhone: form.value.contactPhone.trim() || null,
address: form.value.address.trim() || null,
});
tenant.syncSingleBusiness(business.value);
message.value = 'اطلاعات ذخیره شد.';
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'خطا در ذخیره';
} finally {
saving.value = false;
}
}
async function onSaveBookingSettings() {
if (!business.value) return;
const concurrentCapacity = Number.parseInt(bookingForm.value.concurrentCapacity, 10);
if (!Number.isFinite(concurrentCapacity) || concurrentCapacity < 1 || concurrentCapacity > 20) {
error.value = 'پذیرش همزمان باید عددی بین ۱ تا ۲۰ باشد.';
return;
}
const slotStepMinutes = Number.parseInt(bookingForm.value.slotStepMinutes, 10);
if (!Number.isFinite(slotStepMinutes) || slotStepMinutes < 5 || slotStepMinutes > 120) {
error.value = 'گام نوبت‌دهی باید عددی بین ۵ تا ۱۲۰ دقیقه باشد.';
return;
}
savingBooking.value = true;
error.value = '';
message.value = '';
try {
business.value = await updateBusiness(business.value.id, {
concurrentCapacity,
slotStepMinutes,
autoApproveBookings: bookingForm.value.autoApproveBookings,
});
tenant.syncSingleBusiness(business.value);
message.value = 'تنظیمات نوبت‌دهی ذخیره شد.';
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'خطا در ذخیره';
} finally {
savingBooking.value = false;
}
}
async function onAddService() {
if (!business.value || !serviceForm.value.serviceTemplateId) return;
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 = 'قیمت معتبر نیست.';
return;
}
price = parsedPrice;
duration = parseOptionalMinutes(serviceForm.value.durationMinutes);
if (duration === null) {
error.value = 'مدت زمان معتبر نیست.';
return;
}
}
addingService.value = true;
error.value = '';
message.value = '';
try {
const created = await createBusinessService({
businessId: business.value.id,
serviceTemplateId: serviceForm.value.serviceTemplateId,
price,
durationMinutes: duration && duration > 0 ? duration : undefined,
});
if (classPrices?.length) {
await setBusinessServiceClassPrices(created.id, classPrices);
}
serviceForm.value = { serviceTemplateId: '', price: '', durationMinutes: '' };
classPriceForm.value = emptyClassPriceForm();
await loadServicesAndTemplates();
if (availableTemplates.value[0]) {
serviceForm.value.serviceTemplateId = availableTemplates.value[0].id;
}
message.value = 'خدمت اضافه شد.';
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'خطا در افزودن خدمت';
} finally {
addingService.value = false;
}
}
function requestStatusLabel(status: BusinessServiceRequestStatus): string {
const map: Record<BusinessServiceRequestStatus, string> = {
pending: 'در انتظار تأیید',
approved: 'تأیید و اضافه شد',
rejected: 'رد شده',
};
return map[status];
}
function requestStatusClass(status: BusinessServiceRequestStatus): string {
if (status === 'approved') return 'bg-green-500/15 text-green-700';
if (status === 'rejected') return 'bg-red-500/15 text-red-600';
return 'bg-amber-500/15 text-amber-700';
}
async function onAddCustomService() {
const title = customForm.value.title.trim();
const price = parsePriceInput(customForm.value.price);
const duration = parseOptionalMinutes(customForm.value.durationMinutes);
if (!title || price === null) {
error.value = 'نام و قیمت معتبر را وارد کنید.';
return;
}
if (duration === null) {
error.value = 'مدت زمان معتبر نیست.';
return;
}
addingCustom.value = true;
error.value = '';
message.value = '';
try {
await createServiceRequest({
businessId: business.value!.id,
title,
description: customForm.value.description.trim() || undefined,
price,
durationMinutes: duration && duration > 0 ? duration : undefined,
});
customForm.value = { title: '', description: '', price: '', durationMinutes: '' };
await loadServicesAndTemplates();
message.value = 'درخواست ارسال شد. پس از تأیید مدیر در لیست خدمات نمایش داده می‌شود.';
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'ارسال درخواست ناموفق بود';
} finally {
addingCustom.value = false;
}
}
function startEdit(svc: BusinessService) {
editingId.value = svc.id;
editForm.value = {
price: formatPriceInputDisplay(Math.round(Number.parseFloat(svc.price))),
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() {
editingId.value = null;
}
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);
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;
await loadServicesAndTemplates();
message.value = 'خدمت به‌روزرسانی شد.';
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'خطا در ویرایش';
} finally {
savingService.value = false;
}
}
async function onToggleActive(svc: BusinessService) {
error.value = '';
try {
await updateBusinessService(svc.id, { isActive: !svc.isActive });
await loadServicesAndTemplates();
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'خطا در تغییر وضعیت';
}
}
async function onDelete(svc: BusinessService) {
const title = svc.serviceTemplate?.title ?? 'این خدمت';
if (!confirm(`«${title}» حذف شود؟`)) return;
error.value = '';
try {
await deleteBusinessService(svc.id);
await loadServicesAndTemplates();
message.value = 'خدمت حذف شد.';
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'خطا در حذف';
}
}
</script>