edit one business

This commit is contained in:
2026-05-31 16:42:32 +03:30
parent 40abab5fde
commit 4eae73159a
6 changed files with 213 additions and 116 deletions
+2
View File
@@ -12,10 +12,12 @@ export type CreateBookingPayload = {
export function fetchBookings(params?: {
businessId?: string;
status?: BookingStatus;
scope?: 'mine';
}) {
const search = new URLSearchParams();
if (params?.businessId) search.set('businessId', params.businessId);
if (params?.status) search.set('status', params.status);
if (params?.scope) search.set('scope', params.scope);
const query = search.toString();
return api<Booking[]>(`/bookings${query ? `?${query}` : ''}`);
}
+92
View File
@@ -0,0 +1,92 @@
<template>
<div>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<p class="font-semibold text-text">
{{ booking.business?.title ?? 'کسب‌وکار' }}
</p>
<p class="mt-0.5 text-sm text-muted">
{{ serviceTitle }}
</p>
<p class="mt-1 text-xs text-muted">
{{ formatDate(booking.scheduledAt) }}
</p>
<p
v-if="showCustomer && booking.customer?.mobile"
class="mt-1 text-xs text-muted"
dir="ltr"
>
{{ booking.customer.fullName ? `${booking.customer.fullName} · ` : '' }}{{ booking.customer.mobile }}
</p>
<p v-if="booking.note" class="mt-2 text-xs text-muted">{{ booking.note }}</p>
<span
class="mt-2 inline-block rounded-lg px-2 py-0.5 text-xs"
:class="statusClass"
>
{{ bookingStatusLabel(booking.status) }}
</span>
</div>
</div>
<div
v-if="canConfirm || canCancel"
class="mt-3 flex flex-wrap gap-2 border-t border-border pt-3"
>
<AppButton
v-if="canConfirm"
class="!w-auto flex-1 px-3"
:loading="actingId === booking.id"
@click="emit('confirm', booking)"
>
تأیید
</AppButton>
<AppButton
v-if="canCancel"
variant="danger"
class="!w-auto flex-1 px-3"
:loading="actingId === booking.id"
@click="emit('cancel', booking)"
>
لغو
</AppButton>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import type { Booking } from '@/api/types';
import AppButton from '@/components/ui/AppButton.vue';
import { bookingStatusLabel, formatDate } from '@/utils/format';
const props = defineProps<{
booking: Booking;
actingId: string | null;
showCustomer: boolean;
canConfirm: boolean;
canCancel: boolean;
}>();
const emit = defineEmits<{
confirm: [booking: Booking];
cancel: [booking: Booking];
}>();
const serviceTitle = computed(
() => props.booking.businessService?.serviceTemplate?.title ?? 'خدمت',
);
const statusClass = computed(() => {
const status = props.booking.status;
if (status === 'confirmed' || status === 'completed') {
return 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400';
}
if (status === 'pending') {
return 'bg-amber-500/15 text-amber-700 dark:text-amber-400';
}
if (status === 'cancelled') {
return 'bg-red-500/15 text-red-600 dark:text-red-400';
}
return 'bg-surface-3 text-muted';
});
</script>
+7
View File
@@ -13,6 +13,12 @@ export const useAuthStore = defineStore('auth', () => {
const isAdmin = computed(() => user.value?.role === 'admin');
const isBusinessOwner = computed(() => user.value?.role === 'business_owner');
const isCustomer = computed(() => user.value?.role === 'customer');
const canBookAsCustomer = computed(
() =>
user.value?.role === 'customer' ||
user.value?.role === 'business_owner' ||
user.value?.role === 'admin',
);
function setSession(accessToken: string, profile: User) {
token.value = accessToken;
@@ -51,6 +57,7 @@ export const useAuthStore = defineStore('auth', () => {
isAdmin,
isBusinessOwner,
isCustomer,
canBookAsCustomer,
setSession,
logout,
loadUser,
+54 -89
View File
@@ -1,12 +1,10 @@
<template>
<WebAppLayout>
<h1 class="mb-1 text-xl font-bold text-text">
{{ auth.isBusinessOwner ? 'رزروهای کسب‌وکار' : 'رزروهای من' }}
</h1>
<h1 class="mb-1 text-xl font-bold text-text">رزروها</h1>
<p class="mb-4 text-sm text-muted">
{{
auth.isBusinessOwner
? 'درخواست‌های مشتریان برای خدمات شما'
? 'رزروهای شما و درخواست‌های مشتریان برای کسب‌وکار شما'
: 'وضعیت رزروهای ثبت‌شده'
}}
</p>
@@ -24,13 +22,8 @@
v-else-if="!bookings.length"
class="rounded-2xl border border-dashed border-border bg-surface-2 p-6 text-center text-sm text-muted"
>
{{
auth.isBusinessOwner
? 'هنوز رزروی برای کسب‌وکار شما ثبت نشده است.'
: 'هنوز رزروی ندارید. از صفحه کسب‌وکارها یک خدمت را رزرو کنید.'
}}
هنوز رزروی ندارید. از صفحه کسب‌وکارها یک خدمت را رزرو کنید.
<RouterLink
v-if="auth.isCustomer"
to="/app"
class="mt-3 inline-block text-brand-600 dark:text-brand-500"
>
@@ -38,76 +31,60 @@
</RouterLink>
</div>
<ul v-else class="space-y-3">
<div v-else class="space-y-6">
<section v-if="myBookings.length">
<h2 class="mb-3 text-sm font-semibold text-text">رزروهای من</h2>
<ul class="space-y-3">
<li
v-for="b in bookings"
:key="b.id"
v-for="b in myBookings"
:key="`mine-${b.id}`"
class="rounded-2xl border border-border bg-surface-2 p-4"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<p class="font-semibold text-text">
{{ b.business?.title ?? 'کسب‌وکار' }}
</p>
<p class="mt-0.5 text-sm text-muted">
{{ serviceTitle(b) }}
</p>
<p class="mt-1 text-xs text-muted">
{{ formatDate(b.scheduledAt) }}
</p>
<p
v-if="auth.isBusinessOwner && b.customer?.mobile"
class="mt-1 text-xs text-muted"
dir="ltr"
>
{{ b.customer.fullName ? `${b.customer.fullName} · ` : '' }}{{ b.customer.mobile }}
</p>
<p v-if="b.note" class="mt-2 text-xs text-muted">{{ b.note }}</p>
<span
class="mt-2 inline-block rounded-lg px-2 py-0.5 text-xs"
:class="statusClass(b.status)"
>
{{ bookingStatusLabel(b.status) }}
</span>
</div>
</div>
<div
v-if="canActOn(b)"
class="mt-3 flex flex-wrap gap-2 border-t border-border pt-3"
>
<AppButton
v-if="auth.isBusinessOwner && b.status === 'pending'"
class="!w-auto flex-1 px-3"
:loading="actingId === b.id"
@click="onConfirm(b)"
>
تأیید
</AppButton>
<AppButton
v-if="canCancel(b)"
variant="danger"
class="!w-auto flex-1 px-3"
:loading="actingId === b.id"
@click="onCancel(b)"
>
لغو
</AppButton>
</div>
<BookingItem
:booking="b"
:acting-id="actingId"
:show-customer="false"
:can-confirm="false"
:can-cancel="canCancel(b)"
@confirm="onConfirm"
@cancel="onCancel"
/>
</li>
</ul>
</section>
<section v-if="incomingBookings.length">
<h2 class="mb-3 text-sm font-semibold text-text">رزروهای کسب‌وکار من</h2>
<ul class="space-y-3">
<li
v-for="b in incomingBookings"
:key="`incoming-${b.id}`"
class="rounded-2xl border border-border bg-surface-2 p-4"
>
<BookingItem
:booking="b"
:acting-id="actingId"
:show-customer="true"
:can-confirm="b.status === 'pending'"
:can-cancel="canCancel(b)"
@confirm="onConfirm"
@cancel="onCancel"
/>
</li>
</ul>
</section>
</div>
</WebAppLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { cancelBooking, confirmBooking, fetchBookings } from '@/api/bookings';
import { ApiError } from '@/api/client';
import type { Booking, BookingStatus } from '@/api/types';
import AppButton from '@/components/ui/AppButton.vue';
import type { Booking } from '@/api/types';
import BookingItem from '@/components/booking/BookingItem.vue';
import WebAppLayout from '@/layouts/WebAppLayout.vue';
import { useAuthStore } from '@/stores/auth';
import { bookingStatusLabel, formatDate } from '@/utils/format';
const auth = useAuthStore();
const bookings = ref<Booking[]>([]);
@@ -115,38 +92,26 @@ const loading = ref(true);
const error = ref('');
const actingId = ref<string | null>(null);
function serviceTitle(b: Booking): string {
return b.businessService?.serviceTemplate?.title ?? 'خدمت';
}
const myBookings = computed(() =>
bookings.value.filter((b) => b.customerId === auth.user?.id),
);
function statusClass(status: BookingStatus): string {
if (status === 'confirmed' || status === 'completed') {
return 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400';
}
if (status === 'pending') {
return 'bg-amber-500/15 text-amber-700 dark:text-amber-400';
}
if (status === 'cancelled') {
return 'bg-red-500/15 text-red-600 dark:text-red-400';
}
return 'bg-surface-3 text-muted';
}
const incomingBookings = computed(() =>
bookings.value.filter(
(b) =>
b.business?.ownerId === auth.user?.id && b.customerId !== auth.user?.id,
),
);
function canCancel(b: Booking): boolean {
return b.status === 'pending' || b.status === 'confirmed';
}
function canActOn(b: Booking): boolean {
if (b.status === 'cancelled' || b.status === 'completed') return false;
if (auth.isBusinessOwner && b.status === 'pending') return true;
return canCancel(b);
}
async function load() {
loading.value = true;
error.value = '';
try {
bookings.value = await fetchBookings();
bookings.value = await fetchBookings({ scope: 'mine' });
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'بارگذاری رزروها ناموفق بود.';
} finally {
+19 -8
View File
@@ -9,6 +9,7 @@
</p>
<section v-else-if="business" class="space-y-6">
<button
v-if="showBackLink"
type="button"
class="inline-flex items-center gap-1 text-sm text-brand-600 dark:text-brand-500"
@click="router.back()"
@@ -159,7 +160,7 @@ import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { createBooking } from '@/api/bookings';
import { fetchBusinessServices } from '@/api/business-services';
import { fetchBusiness } from '@/api/businesses';
import { fetchBusiness, fetchBusinesses } from '@/api/businesses';
import { ApiError } from '@/api/client';
import type { Business, BusinessService } from '@/api/types';
import AppButton from '@/components/ui/AppButton.vue';
@@ -190,12 +191,17 @@ const bookingForm = ref({
note: '',
});
const canBook = computed(
() =>
auth.isCustomer &&
business.value?.isActive === true &&
services.value.length > 0,
);
const showBackLink = ref(false);
const canBook = computed(() => {
if (!auth.user || business.value?.isActive !== true || !services.value.length) {
return false;
}
if (business.value.ownerId && business.value.ownerId === auth.user.id) {
return false;
}
return auth.canBookAsCustomer;
});
function serviceLabel(svc: BusinessService): string {
return svc.serviceTemplate?.title ?? 'خدمت';
@@ -220,7 +226,12 @@ async function load() {
loading.value = true;
error.value = '';
try {
business.value = await fetchBusiness(id);
const [businessResult, activeBusinesses] = await Promise.all([
fetchBusiness(id),
fetchBusinesses({ isActive: true }),
]);
business.value = businessResult;
showBackLink.value = activeBusinesses.length > 1;
await loadServices(id);
} catch (e) {
error.value =
+36 -16
View File
@@ -1,12 +1,16 @@
<template>
<WebAppLayout>
<section class="space-y-5">
<div v-if="bootstrapping" class="py-12 text-center text-sm text-muted">
در حال بارگذاری…
</div>
<section v-else class="space-y-5">
<div>
<h1 class="text-xl font-bold text-text">کشف خدمات</h1>
<p class="mt-1 text-sm text-muted">کسب‌وکارهای محلی نزدیک شما</p>
</div>
<div class="relative">
<div v-if="showSearch" class="relative">
<label class="sr-only" for="search">جستجو</label>
<AppIcon
name="search"
@@ -22,11 +26,8 @@
/>
</div>
<div v-if="loadingCategories" class="text-center text-sm text-muted">
در حال بارگذاری دسته‌ها…
</div>
<CategoryFilter
v-else-if="categories.length"
v-if="showCategoryFilter"
:categories="activeCategories"
:selected-id="selectedCategoryId"
@select="onCategorySelect"
@@ -58,6 +59,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { fetchBusinessCategories } from '@/api/business-categories';
import { fetchBusinesses } from '@/api/businesses';
import { ApiError } from '@/api/client';
@@ -67,18 +69,24 @@ import AppIcon from '@/components/ui/AppIcon.vue';
import CategoryFilter from '@/components/business/CategoryFilter.vue';
import WebAppLayout from '@/layouts/WebAppLayout.vue';
const router = useRouter();
const categories = ref<BusinessCategory[]>([]);
const businesses = ref<Business[]>([]);
const selectedCategoryId = ref<string | null>(null);
const searchQuery = ref('');
const loadingCategories = ref(true);
const loadingBusinesses = ref(true);
const bootstrapping = ref(true);
const loadingBusinesses = ref(false);
const error = ref('');
const activeCategories = computed(() =>
categories.value.filter((c) => c.isActive),
);
const showCategoryFilter = computed(() => activeCategories.value.length > 1);
const showSearch = computed(() => businesses.value.length > 1);
const filteredBusinesses = computed(() => {
const q = searchQuery.value.trim().toLowerCase();
if (!q) {
@@ -94,15 +102,14 @@ const filteredBusinesses = computed(() => {
});
async function loadCategories() {
loadingCategories.value = true;
try {
const all = await fetchBusinessCategories();
categories.value = all;
categories.value = await fetchBusinessCategories();
if (activeCategories.value.length === 1) {
selectedCategoryId.value = activeCategories.value[0].id;
}
} catch (e) {
error.value =
e instanceof ApiError ? e.message : 'بارگذاری دسته‌ها ناموفق بود.';
} finally {
loadingCategories.value = false;
}
}
@@ -127,10 +134,23 @@ function onCategorySelect(id: string | null) {
}
watch(selectedCategoryId, () => {
loadBusinesses();
if (!bootstrapping.value) {
void loadBusinesses();
}
});
onMounted(() => {
void Promise.all([loadCategories(), loadBusinesses()]);
onMounted(async () => {
await loadCategories();
await loadBusinesses();
if (businesses.value.length === 1) {
await router.replace({
name: 'app-business-detail',
params: { id: businesses.value[0].id },
});
return;
}
bootstrapping.value = false;
});
</script>