edit one business
This commit is contained in:
@@ -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}` : ''}`);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
<li
|
||||
v-for="b in bookings"
|
||||
:key="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-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 myBookings"
|
||||
:key="`mine-${b.id}`"
|
||||
class="rounded-2xl border border-border bg-surface-2 p-4"
|
||||
>
|
||||
<BookingItem
|
||||
:booking="b"
|
||||
:acting-id="actingId"
|
||||
:show-customer="false"
|
||||
:can-confirm="false"
|
||||
:can-cancel="canCancel(b)"
|
||||
@confirm="onConfirm"
|
||||
@cancel="onCancel"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<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)"
|
||||
<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"
|
||||
>
|
||||
تأیید
|
||||
</AppButton>
|
||||
<AppButton
|
||||
v-if="canCancel(b)"
|
||||
variant="danger"
|
||||
class="!w-auto flex-1 px-3"
|
||||
:loading="actingId === b.id"
|
||||
@click="onCancel(b)"
|
||||
>
|
||||
لغو
|
||||
</AppButton>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<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 {
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user