add cancellationReason
This commit is contained in:
+8
-2
@@ -46,8 +46,14 @@ export function createBookingBatch(payload: CreateBookingBatchPayload) {
|
||||
});
|
||||
}
|
||||
|
||||
export function cancelBooking(id: string) {
|
||||
return api<Booking>(`/bookings/${id}/cancel`, { method: 'POST' });
|
||||
export function cancelBooking(
|
||||
id: string,
|
||||
payload?: { cancellationReason?: string },
|
||||
) {
|
||||
return api<Booking>(`/bookings/${id}/cancel`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload ?? {}),
|
||||
});
|
||||
}
|
||||
|
||||
export function confirmBooking(id: string) {
|
||||
|
||||
@@ -97,6 +97,7 @@ export type Booking = {
|
||||
scheduledAt: string;
|
||||
status: BookingStatus;
|
||||
note?: string | null;
|
||||
cancellationReason?: string | null;
|
||||
business?: Business;
|
||||
businessService?: BusinessService;
|
||||
serviceClass?: BusinessServiceClass;
|
||||
|
||||
@@ -55,6 +55,13 @@
|
||||
<p v-if="primary.note" class="mt-2 line-clamp-2 text-xs text-muted">
|
||||
{{ primary.note }}
|
||||
</p>
|
||||
<p
|
||||
v-if="cancellationReason"
|
||||
class="mt-2 rounded-lg bg-red-500/10 px-2 py-1.5 text-xs leading-6 text-red-700 dark:text-red-400"
|
||||
>
|
||||
<span class="font-medium">علت لغو:</span>
|
||||
{{ cancellationReason }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -123,6 +130,11 @@ const statusLabel = computed(() => {
|
||||
return bookingStatusLabel(groupStatus.value);
|
||||
});
|
||||
|
||||
const cancellationReason = computed(() => {
|
||||
const booking = props.bookings.find((item) => item.cancellationReason?.trim());
|
||||
return booking?.cancellationReason?.trim() ?? null;
|
||||
});
|
||||
|
||||
function statusClassFor(status: BookingStatus | 'mixed') {
|
||||
if (status === 'confirmed' || status === 'completed') {
|
||||
return 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400';
|
||||
|
||||
@@ -1,44 +1,94 @@
|
||||
<template>
|
||||
<div class="relative -mx-4 overflow-hidden">
|
||||
<div
|
||||
class="relative overflow-hidden select-none"
|
||||
:class="variant === 'default' ? '-mx-4' : ''"
|
||||
@wheel="onWheel"
|
||||
@touchstart.passive="onTouchStart"
|
||||
@touchmove.passive="onTouchMove"
|
||||
@touchend="onTouchEnd"
|
||||
@touchcancel="onTouchEnd"
|
||||
>
|
||||
<div
|
||||
v-if="images.length"
|
||||
ref="trackRef"
|
||||
class="flex snap-x snap-mandatory overflow-x-auto scroll-smooth"
|
||||
dir="ltr"
|
||||
@scroll.passive="updateActiveIndex"
|
||||
class="overflow-hidden"
|
||||
:class="[frameClass, frameExtraClass]"
|
||||
>
|
||||
<img
|
||||
v-for="(src, index) in images"
|
||||
:key="`${src}-${index}`"
|
||||
:src="src"
|
||||
:alt="`${title} — تصویر ${index + 1}`"
|
||||
class="aspect-[4/3] w-full shrink-0 snap-center object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div
|
||||
class="flex"
|
||||
dir="ltr"
|
||||
:class="isDragging ? '' : 'transition-transform duration-300 ease-out'"
|
||||
:style="trackStyle"
|
||||
>
|
||||
<img
|
||||
v-for="(src, index) in images"
|
||||
:key="`${src}-${index}`"
|
||||
:src="src"
|
||||
:alt="`${title} — تصویر ${index + 1}`"
|
||||
class="w-full shrink-0 object-cover"
|
||||
:class="imageClass"
|
||||
draggable="false"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="flex aspect-[4/3] items-center justify-center bg-surface-3 text-muted/40"
|
||||
class="flex items-center justify-center bg-surface-3 text-muted/40"
|
||||
:class="[frameClass, frameExtraClass]"
|
||||
>
|
||||
<AppIcon name="store" size="lg" />
|
||||
<AppIcon
|
||||
:name="variant === 'card' || variant === 'hero' ? 'car' : 'store'"
|
||||
size="lg"
|
||||
:class="
|
||||
variant === 'card' || variant === 'hero'
|
||||
? 'h-16 w-16 text-brand-600/25 dark:text-brand-500/30'
|
||||
: ''
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="images.length > 1">
|
||||
<div
|
||||
class="pointer-events-none absolute inset-x-0 bottom-3 flex justify-center gap-1.5"
|
||||
<button
|
||||
type="button"
|
||||
class="absolute left-2 top-1/2 z-10 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-black/50 text-white shadow-sm backdrop-blur-sm transition hover:bg-black/70 disabled:pointer-events-none disabled:opacity-35"
|
||||
:disabled="activeIndex === 0"
|
||||
aria-label="تصویر قبلی"
|
||||
@click="goPrev"
|
||||
>
|
||||
<span
|
||||
<AppIcon name="chevron-left" size="sm" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 z-10 flex h-9 w-9 -translate-y-1/2 items-center justify-center rounded-full bg-black/50 text-white shadow-sm backdrop-blur-sm transition hover:bg-black/70 disabled:pointer-events-none disabled:opacity-35"
|
||||
:disabled="activeIndex === images.length - 1"
|
||||
aria-label="تصویر بعدی"
|
||||
@click="goNext"
|
||||
>
|
||||
<AppIcon name="chevron-right" size="sm" />
|
||||
</button>
|
||||
|
||||
<div class="absolute inset-x-0 bottom-3 z-10 flex justify-center gap-1.5">
|
||||
<button
|
||||
v-for="(_, index) in images"
|
||||
:key="index"
|
||||
class="h-1.5 rounded-full transition-all"
|
||||
type="button"
|
||||
class="rounded-full transition-all"
|
||||
:class="
|
||||
index === activeIndex
|
||||
? 'w-4 bg-white shadow-sm'
|
||||
: 'w-1.5 bg-white/60'
|
||||
? 'h-2 w-5 bg-white shadow-sm'
|
||||
: 'h-2 w-2 bg-white/55 hover:bg-white/80'
|
||||
"
|
||||
:aria-label="`تصویر ${index + 1}`"
|
||||
:aria-current="index === activeIndex ? 'true' : undefined"
|
||||
@click="goTo(index)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sr-only" aria-live="polite">
|
||||
تصویر {{ activeIndex + 1 }} از {{ images.length }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -49,37 +99,131 @@ import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import { businessGalleryUrls } from '@/utils/media';
|
||||
import type { BusinessImage } from '@/api/types';
|
||||
|
||||
const props = defineProps<{
|
||||
images?: BusinessImage[] | null;
|
||||
title?: string;
|
||||
}>();
|
||||
const SWIPE_THRESHOLD_PX = 48;
|
||||
const WHEEL_COOLDOWN_MS = 380;
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
images?: BusinessImage[] | null;
|
||||
title?: string;
|
||||
variant?: 'default' | 'card' | 'hero';
|
||||
frameExtraClass?: string;
|
||||
}>(),
|
||||
{ variant: 'default', title: '', frameExtraClass: '' },
|
||||
);
|
||||
|
||||
const trackRef = ref<HTMLElement | null>(null);
|
||||
const activeIndex = ref(0);
|
||||
const isDragging = ref(false);
|
||||
const touchStartX = ref(0);
|
||||
const touchDeltaX = ref(0);
|
||||
const wheelLocked = ref(false);
|
||||
|
||||
const images = computed(() => businessGalleryUrls(props.images));
|
||||
|
||||
function updateActiveIndex() {
|
||||
const track = trackRef.value;
|
||||
if (!track || !images.value.length) {
|
||||
return;
|
||||
const frameClass = computed(() => {
|
||||
if (props.variant === 'card') {
|
||||
return 'h-52 bg-gradient-to-br from-brand-50 via-slate-100 to-brand-100 dark:from-brand-500/10 dark:via-surface-3 dark:to-surface-2';
|
||||
}
|
||||
|
||||
const width = track.clientWidth;
|
||||
if (!width) {
|
||||
return;
|
||||
if (props.variant === 'hero') {
|
||||
return 'h-[320px] bg-slate-200 lg:h-[390px]';
|
||||
}
|
||||
return 'aspect-[4/3]';
|
||||
});
|
||||
|
||||
const imageClass = computed(() => {
|
||||
if (props.variant === 'card') {
|
||||
return 'h-52';
|
||||
}
|
||||
if (props.variant === 'hero') {
|
||||
return 'h-[320px] lg:h-[390px]';
|
||||
}
|
||||
return 'aspect-[4/3]';
|
||||
});
|
||||
|
||||
const trackStyle = computed(() => {
|
||||
const base = `translateX(calc(-${activeIndex.value * 100}% + ${isDragging.value ? touchDeltaX.value : 0}px))`;
|
||||
return { transform: base };
|
||||
});
|
||||
|
||||
function goTo(index: number) {
|
||||
activeIndex.value = Math.min(
|
||||
images.value.length - 1,
|
||||
Math.max(0, Math.round(track.scrollLeft / width)),
|
||||
Math.max(0, index),
|
||||
);
|
||||
}
|
||||
|
||||
function goPrev() {
|
||||
goTo(activeIndex.value - 1);
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
goTo(activeIndex.value + 1);
|
||||
}
|
||||
|
||||
function onWheel(event: WheelEvent) {
|
||||
if (images.value.length <= 1) return;
|
||||
|
||||
const delta =
|
||||
Math.abs(event.deltaX) > Math.abs(event.deltaY)
|
||||
? event.deltaX
|
||||
: event.deltaY;
|
||||
|
||||
if (Math.abs(delta) < 10) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (wheelLocked.value) return;
|
||||
wheelLocked.value = true;
|
||||
|
||||
if (delta > 0) {
|
||||
goNext();
|
||||
} else {
|
||||
goPrev();
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
wheelLocked.value = false;
|
||||
}, WHEEL_COOLDOWN_MS);
|
||||
}
|
||||
|
||||
function onTouchStart(event: TouchEvent) {
|
||||
if (images.value.length <= 1) return;
|
||||
touchStartX.value = event.touches[0]?.clientX ?? 0;
|
||||
touchDeltaX.value = 0;
|
||||
isDragging.value = true;
|
||||
}
|
||||
|
||||
function onTouchMove(event: TouchEvent) {
|
||||
if (!isDragging.value || images.value.length <= 1) return;
|
||||
|
||||
const currentX = event.touches[0]?.clientX ?? touchStartX.value;
|
||||
let delta = currentX - touchStartX.value;
|
||||
|
||||
if (activeIndex.value === 0 && delta > 0) {
|
||||
delta *= 0.35;
|
||||
} else if (activeIndex.value === images.value.length - 1 && delta < 0) {
|
||||
delta *= 0.35;
|
||||
}
|
||||
|
||||
touchDeltaX.value = delta;
|
||||
}
|
||||
|
||||
function onTouchEnd() {
|
||||
if (!isDragging.value) return;
|
||||
|
||||
if (touchDeltaX.value <= -SWIPE_THRESHOLD_PX) {
|
||||
goNext();
|
||||
} else if (touchDeltaX.value >= SWIPE_THRESHOLD_PX) {
|
||||
goPrev();
|
||||
}
|
||||
|
||||
isDragging.value = false;
|
||||
touchDeltaX.value = 0;
|
||||
}
|
||||
|
||||
watch(images, () => {
|
||||
activeIndex.value = 0;
|
||||
if (trackRef.value) {
|
||||
trackRef.value.scrollLeft = 0;
|
||||
}
|
||||
isDragging.value = false;
|
||||
touchDeltaX.value = 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -70,35 +70,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<div class="landing-hero-blob relative overflow-hidden bg-slate-200 shadow-[0_20px_50px_-12px_rgba(0,0,0,0.18)]">
|
||||
<img
|
||||
v-if="heroImage && !heroImageFailed"
|
||||
:key="heroImage"
|
||||
:src="heroImage"
|
||||
:alt="heroTitle"
|
||||
class="h-[320px] w-full object-cover lg:h-[390px]"
|
||||
@error="onHeroImageError"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-[320px] w-full items-center justify-center bg-gradient-to-br from-brand-50 via-slate-100 to-brand-100 lg:h-[390px]"
|
||||
>
|
||||
<AppIcon name="car" size="xl" class="h-20 w-20 text-brand-600/30" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative shadow-[0_20px_50px_-12px_rgba(0,0,0,0.18)]">
|
||||
<BusinessImageGallery
|
||||
:images="singleBusiness?.images"
|
||||
:title="heroTitle"
|
||||
variant="hero"
|
||||
frame-extra-class="landing-hero-blob bg-slate-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import BusinessImageGallery from '@/components/business/BusinessImageGallery.vue';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import { useTenantStore } from '@/stores/tenant';
|
||||
import type { AppIconName } from '@/types/icons';
|
||||
import { businessCoverUrl } from '@/utils/media';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -115,29 +105,15 @@ const emit = defineEmits<{
|
||||
const tenant = useTenantStore();
|
||||
const { singleBusiness } = storeToRefs(tenant);
|
||||
|
||||
const heroImageFailed = ref(false);
|
||||
|
||||
const heroTitle = computed(() => singleBusiness.value?.title ?? '');
|
||||
|
||||
const heroSubtitle = computed(() => singleBusiness.value?.subtitle?.trim() ?? '');
|
||||
|
||||
const heroDescription = computed(() => singleBusiness.value?.description?.trim() ?? '');
|
||||
|
||||
const heroImage = computed(() =>
|
||||
businessCoverUrl(singleBusiness.value?.images),
|
||||
);
|
||||
|
||||
watch(heroImage, () => {
|
||||
heroImageFailed.value = false;
|
||||
});
|
||||
|
||||
const highlights: { label: string; icon: AppIconName }[] = [
|
||||
{ label: 'رضایت مشتریان', icon: 'shield' },
|
||||
{ label: 'تجهیزات مدرن', icon: 'settings' },
|
||||
{ label: 'مواد شوینده با کیفیت', icon: 'droplet' },
|
||||
];
|
||||
|
||||
function onHeroImageError() {
|
||||
heroImageFailed.value = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -17,19 +17,11 @@
|
||||
<div
|
||||
class="overflow-hidden rounded-2xl bg-slate-100 shadow-[0_8px_30px_rgba(15,23,42,0.12)] dark:bg-surface-2 dark:shadow-black/30"
|
||||
>
|
||||
<img
|
||||
v-if="heroImage && !heroImageFailed"
|
||||
:src="heroImage"
|
||||
:alt="businessTitle"
|
||||
class="h-52 w-full object-cover"
|
||||
@error="heroImageFailed = true"
|
||||
<BusinessImageGallery
|
||||
:images="singleBusiness?.images"
|
||||
:title="businessTitle"
|
||||
variant="card"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-52 w-full items-center justify-center bg-gradient-to-br from-brand-50 via-slate-100 to-brand-100 dark:from-brand-500/10 dark:via-surface-3 dark:to-surface-2"
|
||||
>
|
||||
<AppIcon name="car" size="xl" class="h-16 w-16 text-brand-600/25 dark:text-brand-500/30" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -119,14 +111,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import BusinessImageGallery from '@/components/business/BusinessImageGallery.vue';
|
||||
import HomeServicesSection from '@/components/home/HomeServicesSection.vue';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import { useTenantStore } from '@/stores/tenant';
|
||||
import type { AppIconName } from '@/types/icons';
|
||||
import type { LandingServiceItem } from '@/types/landing';
|
||||
import { businessFirstImageUrl } from '@/utils/media';
|
||||
|
||||
const props = defineProps<{
|
||||
services: LandingServiceItem[];
|
||||
@@ -142,20 +134,10 @@ const emit = defineEmits<{
|
||||
const tenant = useTenantStore();
|
||||
const { singleBusiness } = storeToRefs(tenant);
|
||||
|
||||
const heroImageFailed = ref(false);
|
||||
|
||||
const businessTitle = computed(() => singleBusiness.value?.title ?? '');
|
||||
const businessSubtitle = computed(() => singleBusiness.value?.subtitle?.trim() ?? '');
|
||||
const aboutText = computed(() => singleBusiness.value?.description?.trim() ?? '');
|
||||
|
||||
const heroImage = computed(() =>
|
||||
businessFirstImageUrl(singleBusiness.value?.images),
|
||||
);
|
||||
|
||||
watch(heroImage, () => {
|
||||
heroImageFailed.value = false;
|
||||
});
|
||||
|
||||
const quickActions: {
|
||||
id: string;
|
||||
label: string;
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
<!-- حالت جداگانه با تب -->
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
class="flex gap-1 overflow-x-auto pb-1 scrollbar-thin"
|
||||
class="flex gap-1 overflow-x-auto overscroll-x-contain pb-1 [-webkit-overflow-scrolling:touch]"
|
||||
role="tablist"
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
Camera,
|
||||
Car,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
CircleCheck,
|
||||
Clock,
|
||||
@@ -83,6 +85,8 @@ const iconMap: Record<AppIconName, Component> = {
|
||||
'circle-check': CircleCheck,
|
||||
search: Search,
|
||||
'chevron-down': ChevronDown,
|
||||
'chevron-left': ChevronLeft,
|
||||
'chevron-right': ChevronRight,
|
||||
'chevron-up': ChevronUp,
|
||||
clock: Clock,
|
||||
pencil: Pencil,
|
||||
|
||||
@@ -26,6 +26,13 @@
|
||||
html {
|
||||
@apply antialiased font-sans;
|
||||
font-family: IRANYekanX, ui-sans-serif, system-ui, sans-serif;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body,
|
||||
@@ -38,6 +45,23 @@
|
||||
|
||||
body {
|
||||
@apply bg-surface-2 text-text;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
body::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
#app {
|
||||
|
||||
@@ -18,6 +18,8 @@ export type AppIconName =
|
||||
| 'search'
|
||||
| 'chevron-down'
|
||||
| 'chevron-up'
|
||||
| 'chevron-left'
|
||||
| 'chevron-right'
|
||||
| 'clock'
|
||||
| 'pencil'
|
||||
| 'trash'
|
||||
|
||||
@@ -41,6 +41,28 @@ export function groupBookingStatus(
|
||||
return 'mixed';
|
||||
}
|
||||
|
||||
export type OwnerBookingTab = 'pending' | 'confirmed' | 'cancelled';
|
||||
|
||||
export function entryMatchesOwnerTab(
|
||||
bookings: Booking[],
|
||||
tab: OwnerBookingTab,
|
||||
): boolean {
|
||||
const status = groupBookingStatus(bookings);
|
||||
|
||||
if (tab === 'pending') {
|
||||
return (
|
||||
status === 'pending' ||
|
||||
(status === 'mixed' && bookings.some((booking) => booking.status === 'pending'))
|
||||
);
|
||||
}
|
||||
|
||||
if (tab === 'confirmed') {
|
||||
return status === 'confirmed' || status === 'completed';
|
||||
}
|
||||
|
||||
return status === 'cancelled';
|
||||
}
|
||||
|
||||
export function serviceTitleForBooking(booking: Booking): string {
|
||||
return booking.businessService?.serviceTemplate?.title ?? 'خدمت';
|
||||
}
|
||||
|
||||
@@ -9,6 +9,35 @@
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div
|
||||
v-if="auth.isBusinessOwner"
|
||||
class="mb-4 flex gap-1 overflow-x-auto overscroll-x-contain [-webkit-overflow-scrolling:touch]"
|
||||
role="tablist"
|
||||
>
|
||||
<button
|
||||
v-for="tab in ownerTabs"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
role="tab"
|
||||
class="shrink-0 rounded-xl px-4 py-2.5 text-sm font-medium transition"
|
||||
:class="
|
||||
activeOwnerTab === tab.key
|
||||
? 'bg-brand-500/15 text-brand-700 dark:text-brand-500'
|
||||
: 'text-muted hover:bg-surface-3'
|
||||
"
|
||||
:aria-selected="activeOwnerTab === tab.key"
|
||||
@click="activeOwnerTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<span
|
||||
v-if="tabCount(tab.key)"
|
||||
class="ms-1.5 inline-flex min-w-[1.25rem] items-center justify-center rounded-full bg-surface-3 px-1.5 text-[11px] font-semibold text-muted"
|
||||
>
|
||||
{{ tabCount(tab.key) }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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 dark:text-red-400"
|
||||
@@ -23,10 +52,10 @@
|
||||
/>
|
||||
|
||||
<div
|
||||
v-else-if="!bookingEntries.length"
|
||||
v-else-if="!visibleEntries.length"
|
||||
class="rounded-2xl border border-dashed border-border bg-surface-2 p-6 text-center text-sm text-muted"
|
||||
>
|
||||
هنوز رزروی ندارید.
|
||||
{{ emptyMessage }}
|
||||
<RouterLink to="/app" class="mt-3 block text-brand-600 dark:text-brand-500">
|
||||
بازگشت به خانه
|
||||
</RouterLink>
|
||||
@@ -37,7 +66,7 @@
|
||||
class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
>
|
||||
<div
|
||||
v-for="entry in bookingEntries"
|
||||
v-for="entry in visibleEntries"
|
||||
:key="entry.key"
|
||||
class="flex h-full flex-col rounded-2xl border border-border bg-surface-2 p-4"
|
||||
>
|
||||
@@ -49,10 +78,55 @@
|
||||
:can-confirm="canConfirmEntry(entry.bookings)"
|
||||
:can-cancel="canCancelEntry(entry.bookings)"
|
||||
@confirm="onConfirm"
|
||||
@cancel="onCancel"
|
||||
@cancel="onCancelRequest"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppModal
|
||||
:open="cancelModalOpen"
|
||||
title="لغو رزرو"
|
||||
subtitle="علت لغو را برای مشتری بنویسید."
|
||||
@close="closeCancelModal"
|
||||
>
|
||||
<form class="space-y-4" @submit.prevent="submitCancel">
|
||||
<AppTextarea
|
||||
id="cancelReason"
|
||||
v-model="cancelReason"
|
||||
label="علت لغو"
|
||||
rows="4"
|
||||
placeholder="مثلاً: ظرفیت این بازه تکمیل شده است."
|
||||
required
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="cancelModalError"
|
||||
class="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-600"
|
||||
>
|
||||
{{ cancelModalError }}
|
||||
</p>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<AppButton
|
||||
type="submit"
|
||||
variant="danger"
|
||||
class="flex-1"
|
||||
:loading="cancelModalSubmitting"
|
||||
>
|
||||
تأیید لغو
|
||||
</AppButton>
|
||||
<AppButton
|
||||
type="button"
|
||||
variant="ghost"
|
||||
class="!w-auto px-4"
|
||||
:disabled="cancelModalSubmitting"
|
||||
@click="closeCancelModal"
|
||||
>
|
||||
انصراف
|
||||
</AppButton>
|
||||
</div>
|
||||
</form>
|
||||
</AppModal>
|
||||
</ResponsiveAppLayout>
|
||||
</template>
|
||||
|
||||
@@ -62,12 +136,17 @@ import { cancelBooking, confirmBooking, fetchBookings } from '@/api/bookings';
|
||||
import { ApiError } from '@/api/client';
|
||||
import type { Booking } from '@/api/types';
|
||||
import BookingItem from '@/components/booking/BookingItem.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppModal from '@/components/ui/AppModal.vue';
|
||||
import AppTextarea from '@/components/ui/AppTextarea.vue';
|
||||
import ListCardSkeleton from '@/components/ui/ListCardSkeleton.vue';
|
||||
import ResponsiveAppLayout from '@/layouts/ResponsiveAppLayout.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import {
|
||||
entryMatchesOwnerTab,
|
||||
groupBookingsForDisplay,
|
||||
type BookingListEntry,
|
||||
type OwnerBookingTab,
|
||||
} from '@/utils/booking-groups';
|
||||
|
||||
const auth = useAuthStore();
|
||||
@@ -75,9 +154,50 @@ const bookings = ref<Booking[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const actingKey = ref<string | null>(null);
|
||||
const activeOwnerTab = ref<OwnerBookingTab>('pending');
|
||||
|
||||
const cancelModalOpen = ref(false);
|
||||
const cancelModalSubmitting = ref(false);
|
||||
const cancelModalError = ref('');
|
||||
const cancelReason = ref('');
|
||||
const pendingCancelEntry = ref<Booking[] | null>(null);
|
||||
|
||||
const ownerTabs: { key: OwnerBookingTab; label: string }[] = [
|
||||
{ key: 'pending', label: 'در انتظار تأیید' },
|
||||
{ key: 'confirmed', label: 'تأیید شده' },
|
||||
{ key: 'cancelled', label: 'لغو شده' },
|
||||
];
|
||||
|
||||
const bookingEntries = computed(() => groupBookingsForDisplay(bookings.value));
|
||||
|
||||
const visibleEntries = computed(() => {
|
||||
if (!auth.isBusinessOwner) {
|
||||
return bookingEntries.value;
|
||||
}
|
||||
return bookingEntries.value.filter((entry) =>
|
||||
entryMatchesOwnerTab(entry.bookings, activeOwnerTab.value),
|
||||
);
|
||||
});
|
||||
|
||||
const emptyMessage = computed(() => {
|
||||
if (!auth.isBusinessOwner) {
|
||||
return 'هنوز رزروی ندارید.';
|
||||
}
|
||||
if (activeOwnerTab.value === 'pending') {
|
||||
return 'رزروی در انتظار تأیید نیست.';
|
||||
}
|
||||
if (activeOwnerTab.value === 'confirmed') {
|
||||
return 'رزرو تأییدشدهای نیست.';
|
||||
}
|
||||
return 'رزرو لغوشدهای نیست.';
|
||||
});
|
||||
|
||||
function tabCount(tab: OwnerBookingTab) {
|
||||
return bookingEntries.value.filter((entry) =>
|
||||
entryMatchesOwnerTab(entry.bookings, tab),
|
||||
).length;
|
||||
}
|
||||
|
||||
function canConfirm(booking: Booking) {
|
||||
return auth.isBusinessOwner && booking.status === 'pending';
|
||||
}
|
||||
@@ -136,6 +256,58 @@ async function onConfirm(entry: Booking[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function onCancelRequest(entry: Booking[]) {
|
||||
if (auth.isBusinessOwner) {
|
||||
pendingCancelEntry.value = entry;
|
||||
cancelReason.value = '';
|
||||
cancelModalError.value = '';
|
||||
cancelModalOpen.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
void onCancel(entry);
|
||||
}
|
||||
|
||||
function closeCancelModal() {
|
||||
if (cancelModalSubmitting.value) return;
|
||||
cancelModalOpen.value = false;
|
||||
pendingCancelEntry.value = null;
|
||||
cancelReason.value = '';
|
||||
cancelModalError.value = '';
|
||||
}
|
||||
|
||||
async function submitCancel() {
|
||||
const entry = pendingCancelEntry.value;
|
||||
const reason = cancelReason.value.trim();
|
||||
if (!entry?.length) return;
|
||||
if (!reason) {
|
||||
cancelModalError.value = 'علت لغو را وارد کنید.';
|
||||
return;
|
||||
}
|
||||
|
||||
cancelModalSubmitting.value = true;
|
||||
cancelModalError.value = '';
|
||||
actingKey.value = entry[0]?.bookingGroupId ?? entry[0]?.id ?? null;
|
||||
|
||||
try {
|
||||
const updated = await Promise.all(
|
||||
entry
|
||||
.filter((booking) => canCancel(booking))
|
||||
.map((booking) =>
|
||||
cancelBooking(booking.id, { cancellationReason: reason }),
|
||||
),
|
||||
);
|
||||
applyUpdatedBookings(updated);
|
||||
closeCancelModal();
|
||||
} catch (e) {
|
||||
cancelModalError.value =
|
||||
e instanceof ApiError ? e.message : 'لغو رزرو ناموفق بود.';
|
||||
} finally {
|
||||
cancelModalSubmitting.value = false;
|
||||
actingKey.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function onCancel(entry: Booking[]) {
|
||||
const cancellable = entry.filter((booking) => canCancel(booking));
|
||||
if (!cancellable.length) return;
|
||||
|
||||
Reference in New Issue
Block a user