Add business services management in admin edit form
This commit is contained in:
@@ -0,0 +1,48 @@
|
|||||||
|
import { api } from './client';
|
||||||
|
|
||||||
|
export type BusinessService = {
|
||||||
|
id: string;
|
||||||
|
businessId: string;
|
||||||
|
serviceTemplateId: string;
|
||||||
|
price: string;
|
||||||
|
durationMinutes: number | null;
|
||||||
|
isActive: boolean;
|
||||||
|
serviceTemplate?: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
defaultDurationMinutes: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function fetchBusinessServices(businessId: string) {
|
||||||
|
const search = new URLSearchParams({ businessId });
|
||||||
|
return api<BusinessService[]>(`/business-services?${search}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBusinessService(payload: {
|
||||||
|
businessId: string;
|
||||||
|
serviceTemplateId: string;
|
||||||
|
price: number;
|
||||||
|
durationMinutes?: number | null;
|
||||||
|
isActive?: boolean;
|
||||||
|
}) {
|
||||||
|
return api<BusinessService>('/business-services', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateBusinessService(
|
||||||
|
id: string,
|
||||||
|
payload: { price?: number; durationMinutes?: number | null; isActive?: boolean },
|
||||||
|
) {
|
||||||
|
return api<BusinessService>(`/business-services/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBusinessService(id: string) {
|
||||||
|
return api<{ success: true }>(`/business-services/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<template>
|
||||||
|
<section class="space-y-3 border-t border-border pt-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-semibold text-text">خدمات (برای نمایش در اپ)</h3>
|
||||||
|
<p class="mt-0.5 text-xs text-muted">
|
||||||
|
بدون ثبت خدمت، مشتری در اپ چیزی برای رزرو نمیبیند.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
|
||||||
|
|
||||||
|
<div v-if="loading" class="text-sm text-muted">در حال بارگذاری خدمات…</div>
|
||||||
|
|
||||||
|
<ul v-else-if="services.length" class="space-y-2">
|
||||||
|
<li
|
||||||
|
v-for="svc in services"
|
||||||
|
:key="svc.id"
|
||||||
|
class="flex items-center justify-between gap-2 rounded-xl border border-border bg-surface-3 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="font-medium text-text">{{ svc.serviceTemplate?.title ?? 'خدمت' }}</p>
|
||||||
|
<p class="text-xs text-muted">
|
||||||
|
{{ formatPrice(svc.price) }} تومان
|
||||||
|
<span v-if="svc.durationMinutes"> · {{ svc.durationMinutes }} دقیقه</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="shrink-0 text-xs text-red-600"
|
||||||
|
@click="onDelete(svc)"
|
||||||
|
>
|
||||||
|
حذف
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form
|
||||||
|
v-if="availableTemplates.length"
|
||||||
|
class="grid gap-2 sm:grid-cols-2"
|
||||||
|
@submit.prevent="onAdd"
|
||||||
|
>
|
||||||
|
<AppSelect
|
||||||
|
id="svcTpl"
|
||||||
|
v-model="form.serviceTemplateId"
|
||||||
|
label="نوع خدمت"
|
||||||
|
:options="templateOptions"
|
||||||
|
/>
|
||||||
|
<AppInput
|
||||||
|
id="svcPrice"
|
||||||
|
v-model="form.price"
|
||||||
|
label="قیمت (تومان)"
|
||||||
|
type="number"
|
||||||
|
inputmode="numeric"
|
||||||
|
ltr
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<AppButton type="submit" class="sm:col-span-2" :loading="adding">
|
||||||
|
افزودن خدمت
|
||||||
|
</AppButton>
|
||||||
|
</form>
|
||||||
|
<p v-else-if="!loading && templates.length" class="text-xs text-muted">
|
||||||
|
همه قالبهای این دسته اضافه شدهاند.
|
||||||
|
</p>
|
||||||
|
<p v-else-if="!loading" class="text-xs text-muted">
|
||||||
|
ابتدا برای این دسته «قالب خدمت» بسازید.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
import {
|
||||||
|
createBusinessService,
|
||||||
|
deleteBusinessService,
|
||||||
|
fetchBusinessServices,
|
||||||
|
type BusinessService,
|
||||||
|
} from '@/api/business-services';
|
||||||
|
import { fetchServiceTemplates } from '@/api/service-templates';
|
||||||
|
import type { ServiceTemplate } from '@/api/types';
|
||||||
|
import { ApiError } from '@/api/client';
|
||||||
|
import AppButton from '@/components/ui/AppButton.vue';
|
||||||
|
import AppInput from '@/components/ui/AppInput.vue';
|
||||||
|
import AppSelect from '@/components/ui/AppSelect.vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
businessId: string;
|
||||||
|
categoryId: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const services = ref<BusinessService[]>([]);
|
||||||
|
const templates = ref<ServiceTemplate[]>([]);
|
||||||
|
const loading = ref(true);
|
||||||
|
const adding = ref(false);
|
||||||
|
const error = ref('');
|
||||||
|
|
||||||
|
const form = ref({ serviceTemplateId: '', price: '' });
|
||||||
|
|
||||||
|
const availableTemplates = computed(() => {
|
||||||
|
const used = new Set(services.value.map((s) => s.serviceTemplateId));
|
||||||
|
return templates.value.filter((t) => t.isActive && !used.has(t.id));
|
||||||
|
});
|
||||||
|
|
||||||
|
const templateOptions = computed(() =>
|
||||||
|
availableTemplates.value.map((t) => ({ value: t.id, label: t.title })),
|
||||||
|
);
|
||||||
|
|
||||||
|
function formatPrice(price: string): string {
|
||||||
|
const n = Number.parseFloat(price);
|
||||||
|
if (!Number.isFinite(n)) return price;
|
||||||
|
return new Intl.NumberFormat('fa-IR').format(Math.round(n));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
const [svc, tpl] = await Promise.all([
|
||||||
|
fetchBusinessServices(props.businessId),
|
||||||
|
fetchServiceTemplates(props.categoryId),
|
||||||
|
]);
|
||||||
|
services.value = svc;
|
||||||
|
templates.value = tpl.filter((t) => t.isActive);
|
||||||
|
if (availableTemplates.value[0] && !form.value.serviceTemplateId) {
|
||||||
|
form.value.serviceTemplateId = availableTemplates.value[0].id;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof ApiError ? e.message : 'خطا در بارگذاری خدمات';
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onAdd() {
|
||||||
|
const price = Number.parseFloat(form.value.price.replace(/,/g, ''));
|
||||||
|
if (!form.value.serviceTemplateId || !Number.isFinite(price) || price < 0) {
|
||||||
|
error.value = 'نوع خدمت و قیمت معتبر را وارد کنید.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
adding.value = true;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
await createBusinessService({
|
||||||
|
businessId: props.businessId,
|
||||||
|
serviceTemplateId: form.value.serviceTemplateId,
|
||||||
|
price,
|
||||||
|
});
|
||||||
|
form.value.price = '';
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof ApiError ? e.message : 'افزودن خدمت ناموفق بود';
|
||||||
|
} finally {
|
||||||
|
adding.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(svc: BusinessService) {
|
||||||
|
const title = svc.serviceTemplate?.title ?? 'خدمت';
|
||||||
|
if (!confirm(`«${title}» حذف شود؟`)) return;
|
||||||
|
error.value = '';
|
||||||
|
try {
|
||||||
|
await deleteBusinessService(svc.id);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof ApiError ? e.message : 'حذف ناموفق بود';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.businessId, props.categoryId],
|
||||||
|
() => load(),
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
@@ -102,6 +102,12 @@
|
|||||||
@remove-existing="onRemoveImage"
|
@remove-existing="onRemoveImage"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<BusinessServicesSection
|
||||||
|
v-if="editingId && form.categoryId"
|
||||||
|
:business-id="editingId"
|
||||||
|
:category-id="form.categoryId"
|
||||||
|
/>
|
||||||
|
|
||||||
<AppButton type="submit" :loading="saving">ذخیره</AppButton>
|
<AppButton type="submit" :loading="saving">ذخیره</AppButton>
|
||||||
</form>
|
</form>
|
||||||
</AppModal>
|
</AppModal>
|
||||||
@@ -189,6 +195,7 @@ import { fetchUsers, fetchUser } from '@/api/users';
|
|||||||
import type { Business, BusinessCategory, User } from '@/api/types';
|
import type { Business, BusinessCategory, User } from '@/api/types';
|
||||||
import { ApiError } from '@/api/client';
|
import { ApiError } from '@/api/client';
|
||||||
import BusinessImageUpload from '@/components/admin/BusinessImageUpload.vue';
|
import BusinessImageUpload from '@/components/admin/BusinessImageUpload.vue';
|
||||||
|
import BusinessServicesSection from '@/components/admin/BusinessServicesSection.vue';
|
||||||
import BusinessListCard from '@/components/admin/BusinessListCard.vue';
|
import BusinessListCard from '@/components/admin/BusinessListCard.vue';
|
||||||
import AppBadge from '@/components/ui/AppBadge.vue';
|
import AppBadge from '@/components/ui/AppBadge.vue';
|
||||||
import AppButton from '@/components/ui/AppButton.vue';
|
import AppButton from '@/components/ui/AppButton.vue';
|
||||||
|
|||||||
Reference in New Issue
Block a user