add cancellationReason
This commit is contained in:
+8
-2
@@ -46,8 +46,14 @@ export function createBookingBatch(payload: CreateBookingBatchPayload) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function cancelBooking(id: string) {
|
export function cancelBooking(
|
||||||
return api<Booking>(`/bookings/${id}/cancel`, { method: 'POST' });
|
id: string,
|
||||||
|
payload?: { cancellationReason?: string },
|
||||||
|
) {
|
||||||
|
return api<Booking>(`/bookings/${id}/cancel`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload ?? {}),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function confirmBooking(id: string) {
|
export function confirmBooking(id: string) {
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export type Booking = {
|
|||||||
scheduledAt: string;
|
scheduledAt: string;
|
||||||
status: BookingStatus;
|
status: BookingStatus;
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
|
cancellationReason?: string | null;
|
||||||
business?: Business;
|
business?: Business;
|
||||||
businessService?: BusinessService;
|
businessService?: BusinessService;
|
||||||
serviceClass?: BusinessServiceClass;
|
serviceClass?: BusinessServiceClass;
|
||||||
|
|||||||
@@ -55,6 +55,13 @@
|
|||||||
<p v-if="primary.note" class="mt-2 line-clamp-2 text-xs text-muted">
|
<p v-if="primary.note" class="mt-2 line-clamp-2 text-xs text-muted">
|
||||||
{{ primary.note }}
|
{{ primary.note }}
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -123,6 +130,11 @@ const statusLabel = computed(() => {
|
|||||||
return bookingStatusLabel(groupStatus.value);
|
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') {
|
function statusClassFor(status: BookingStatus | 'mixed') {
|
||||||
if (status === 'confirmed' || status === 'completed') {
|
if (status === 'confirmed' || status === 'completed') {
|
||||||
return 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400';
|
return 'bg-emerald-500/15 text-emerald-700 dark:text-emerald-400';
|
||||||
|
|||||||
@@ -1,44 +1,94 @@
|
|||||||
<template>
|
<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
|
<div
|
||||||
v-if="images.length"
|
v-if="images.length"
|
||||||
ref="trackRef"
|
class="overflow-hidden"
|
||||||
class="flex snap-x snap-mandatory overflow-x-auto scroll-smooth"
|
:class="[frameClass, frameExtraClass]"
|
||||||
dir="ltr"
|
|
||||||
@scroll.passive="updateActiveIndex"
|
|
||||||
>
|
>
|
||||||
<img
|
<div
|
||||||
v-for="(src, index) in images"
|
class="flex"
|
||||||
:key="`${src}-${index}`"
|
dir="ltr"
|
||||||
:src="src"
|
:class="isDragging ? '' : 'transition-transform duration-300 ease-out'"
|
||||||
:alt="`${title} — تصویر ${index + 1}`"
|
:style="trackStyle"
|
||||||
class="aspect-[4/3] w-full shrink-0 snap-center object-cover"
|
>
|
||||||
loading="lazy"
|
<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>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else
|
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>
|
</div>
|
||||||
|
|
||||||
<template v-if="images.length > 1">
|
<template v-if="images.length > 1">
|
||||||
<div
|
<button
|
||||||
class="pointer-events-none absolute inset-x-0 bottom-3 flex justify-center gap-1.5"
|
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"
|
v-for="(_, index) in images"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="h-1.5 rounded-full transition-all"
|
type="button"
|
||||||
|
class="rounded-full transition-all"
|
||||||
:class="
|
:class="
|
||||||
index === activeIndex
|
index === activeIndex
|
||||||
? 'w-4 bg-white shadow-sm'
|
? 'h-2 w-5 bg-white shadow-sm'
|
||||||
: 'w-1.5 bg-white/60'
|
: '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>
|
||||||
|
|
||||||
|
<div class="sr-only" aria-live="polite">
|
||||||
|
تصویر {{ activeIndex + 1 }} از {{ images.length }}
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -49,37 +99,131 @@ import AppIcon from '@/components/ui/AppIcon.vue';
|
|||||||
import { businessGalleryUrls } from '@/utils/media';
|
import { businessGalleryUrls } from '@/utils/media';
|
||||||
import type { BusinessImage } from '@/api/types';
|
import type { BusinessImage } from '@/api/types';
|
||||||
|
|
||||||
const props = defineProps<{
|
const SWIPE_THRESHOLD_PX = 48;
|
||||||
images?: BusinessImage[] | null;
|
const WHEEL_COOLDOWN_MS = 380;
|
||||||
title?: string;
|
|
||||||
}>();
|
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 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));
|
const images = computed(() => businessGalleryUrls(props.images));
|
||||||
|
|
||||||
function updateActiveIndex() {
|
const frameClass = computed(() => {
|
||||||
const track = trackRef.value;
|
if (props.variant === 'card') {
|
||||||
if (!track || !images.value.length) {
|
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';
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
if (props.variant === 'hero') {
|
||||||
const width = track.clientWidth;
|
return 'h-[320px] bg-slate-200 lg:h-[390px]';
|
||||||
if (!width) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
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(
|
activeIndex.value = Math.min(
|
||||||
images.value.length - 1,
|
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, () => {
|
watch(images, () => {
|
||||||
activeIndex.value = 0;
|
activeIndex.value = 0;
|
||||||
if (trackRef.value) {
|
isDragging.value = false;
|
||||||
trackRef.value.scrollLeft = 0;
|
touchDeltaX.value = 0;
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -70,35 +70,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative shadow-[0_20px_50px_-12px_rgba(0,0,0,0.18)]">
|
||||||
<div class="landing-hero-blob relative overflow-hidden bg-slate-200 shadow-[0_20px_50px_-12px_rgba(0,0,0,0.18)]">
|
<BusinessImageGallery
|
||||||
<img
|
:images="singleBusiness?.images"
|
||||||
v-if="heroImage && !heroImageFailed"
|
:title="heroTitle"
|
||||||
:key="heroImage"
|
variant="hero"
|
||||||
:src="heroImage"
|
frame-extra-class="landing-hero-blob bg-slate-200"
|
||||||
: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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
|
import BusinessImageGallery from '@/components/business/BusinessImageGallery.vue';
|
||||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||||
import { useTenantStore } from '@/stores/tenant';
|
import { useTenantStore } from '@/stores/tenant';
|
||||||
import type { AppIconName } from '@/types/icons';
|
import type { AppIconName } from '@/types/icons';
|
||||||
import { businessCoverUrl } from '@/utils/media';
|
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -115,29 +105,15 @@ const emit = defineEmits<{
|
|||||||
const tenant = useTenantStore();
|
const tenant = useTenantStore();
|
||||||
const { singleBusiness } = storeToRefs(tenant);
|
const { singleBusiness } = storeToRefs(tenant);
|
||||||
|
|
||||||
const heroImageFailed = ref(false);
|
|
||||||
|
|
||||||
const heroTitle = computed(() => singleBusiness.value?.title ?? '');
|
const heroTitle = computed(() => singleBusiness.value?.title ?? '');
|
||||||
|
|
||||||
const heroSubtitle = computed(() => singleBusiness.value?.subtitle?.trim() ?? '');
|
const heroSubtitle = computed(() => singleBusiness.value?.subtitle?.trim() ?? '');
|
||||||
|
|
||||||
const heroDescription = computed(() => singleBusiness.value?.description?.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 }[] = [
|
const highlights: { label: string; icon: AppIconName }[] = [
|
||||||
{ label: 'رضایت مشتریان', icon: 'shield' },
|
{ label: 'رضایت مشتریان', icon: 'shield' },
|
||||||
{ label: 'تجهیزات مدرن', icon: 'settings' },
|
{ label: 'تجهیزات مدرن', icon: 'settings' },
|
||||||
{ label: 'مواد شوینده با کیفیت', icon: 'droplet' },
|
{ label: 'مواد شوینده با کیفیت', icon: 'droplet' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function onHeroImageError() {
|
|
||||||
heroImageFailed.value = true;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -17,19 +17,11 @@
|
|||||||
<div
|
<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"
|
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
|
<BusinessImageGallery
|
||||||
v-if="heroImage && !heroImageFailed"
|
:images="singleBusiness?.images"
|
||||||
:src="heroImage"
|
:title="businessTitle"
|
||||||
:alt="businessTitle"
|
variant="card"
|
||||||
class="h-52 w-full object-cover"
|
|
||||||
@error="heroImageFailed = true"
|
|
||||||
/>
|
/>
|
||||||
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -119,14 +111,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { storeToRefs } from 'pinia';
|
import { storeToRefs } from 'pinia';
|
||||||
|
import BusinessImageGallery from '@/components/business/BusinessImageGallery.vue';
|
||||||
import HomeServicesSection from '@/components/home/HomeServicesSection.vue';
|
import HomeServicesSection from '@/components/home/HomeServicesSection.vue';
|
||||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||||
import { useTenantStore } from '@/stores/tenant';
|
import { useTenantStore } from '@/stores/tenant';
|
||||||
import type { AppIconName } from '@/types/icons';
|
import type { AppIconName } from '@/types/icons';
|
||||||
import type { LandingServiceItem } from '@/types/landing';
|
import type { LandingServiceItem } from '@/types/landing';
|
||||||
import { businessFirstImageUrl } from '@/utils/media';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
services: LandingServiceItem[];
|
services: LandingServiceItem[];
|
||||||
@@ -142,20 +134,10 @@ const emit = defineEmits<{
|
|||||||
const tenant = useTenantStore();
|
const tenant = useTenantStore();
|
||||||
const { singleBusiness } = storeToRefs(tenant);
|
const { singleBusiness } = storeToRefs(tenant);
|
||||||
|
|
||||||
const heroImageFailed = ref(false);
|
|
||||||
|
|
||||||
const businessTitle = computed(() => singleBusiness.value?.title ?? '');
|
const businessTitle = computed(() => singleBusiness.value?.title ?? '');
|
||||||
const businessSubtitle = computed(() => singleBusiness.value?.subtitle?.trim() ?? '');
|
const businessSubtitle = computed(() => singleBusiness.value?.subtitle?.trim() ?? '');
|
||||||
const aboutText = computed(() => singleBusiness.value?.description?.trim() ?? '');
|
const aboutText = computed(() => singleBusiness.value?.description?.trim() ?? '');
|
||||||
|
|
||||||
const heroImage = computed(() =>
|
|
||||||
businessFirstImageUrl(singleBusiness.value?.images),
|
|
||||||
);
|
|
||||||
|
|
||||||
watch(heroImage, () => {
|
|
||||||
heroImageFailed.value = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
const quickActions: {
|
const quickActions: {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -109,7 +109,7 @@
|
|||||||
<!-- حالت جداگانه با تب -->
|
<!-- حالت جداگانه با تب -->
|
||||||
<div v-else class="space-y-3">
|
<div v-else class="space-y-3">
|
||||||
<div
|
<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"
|
role="tablist"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import {
|
|||||||
Camera,
|
Camera,
|
||||||
Car,
|
Car,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
CircleCheck,
|
CircleCheck,
|
||||||
Clock,
|
Clock,
|
||||||
@@ -83,6 +85,8 @@ const iconMap: Record<AppIconName, Component> = {
|
|||||||
'circle-check': CircleCheck,
|
'circle-check': CircleCheck,
|
||||||
search: Search,
|
search: Search,
|
||||||
'chevron-down': ChevronDown,
|
'chevron-down': ChevronDown,
|
||||||
|
'chevron-left': ChevronLeft,
|
||||||
|
'chevron-right': ChevronRight,
|
||||||
'chevron-up': ChevronUp,
|
'chevron-up': ChevronUp,
|
||||||
clock: Clock,
|
clock: Clock,
|
||||||
pencil: Pencil,
|
pencil: Pencil,
|
||||||
|
|||||||
@@ -26,6 +26,13 @@
|
|||||||
html {
|
html {
|
||||||
@apply antialiased font-sans;
|
@apply antialiased font-sans;
|
||||||
font-family: IRANYekanX, ui-sans-serif, system-ui, sans-serif;
|
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,
|
body,
|
||||||
@@ -38,6 +45,23 @@
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-surface-2 text-text;
|
@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 {
|
#app {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export type AppIconName =
|
|||||||
| 'search'
|
| 'search'
|
||||||
| 'chevron-down'
|
| 'chevron-down'
|
||||||
| 'chevron-up'
|
| 'chevron-up'
|
||||||
|
| 'chevron-left'
|
||||||
|
| 'chevron-right'
|
||||||
| 'clock'
|
| 'clock'
|
||||||
| 'pencil'
|
| 'pencil'
|
||||||
| 'trash'
|
| 'trash'
|
||||||
|
|||||||
@@ -41,6 +41,28 @@ export function groupBookingStatus(
|
|||||||
return 'mixed';
|
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 {
|
export function serviceTitleForBooking(booking: Booking): string {
|
||||||
return booking.businessService?.serviceTemplate?.title ?? 'خدمت';
|
return booking.businessService?.serviceTemplate?.title ?? 'خدمت';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,35 @@
|
|||||||
}}
|
}}
|
||||||
</p>
|
</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
|
<p
|
||||||
v-if="error"
|
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"
|
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
|
<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"
|
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 to="/app" class="mt-3 block text-brand-600 dark:text-brand-500">
|
||||||
بازگشت به خانه
|
بازگشت به خانه
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
@@ -37,7 +66,7 @@
|
|||||||
class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="entry in bookingEntries"
|
v-for="entry in visibleEntries"
|
||||||
:key="entry.key"
|
:key="entry.key"
|
||||||
class="flex h-full flex-col rounded-2xl border border-border bg-surface-2 p-4"
|
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-confirm="canConfirmEntry(entry.bookings)"
|
||||||
:can-cancel="canCancelEntry(entry.bookings)"
|
:can-cancel="canCancelEntry(entry.bookings)"
|
||||||
@confirm="onConfirm"
|
@confirm="onConfirm"
|
||||||
@cancel="onCancel"
|
@cancel="onCancelRequest"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</ResponsiveAppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -62,12 +136,17 @@ import { cancelBooking, confirmBooking, fetchBookings } from '@/api/bookings';
|
|||||||
import { ApiError } from '@/api/client';
|
import { ApiError } from '@/api/client';
|
||||||
import type { Booking } from '@/api/types';
|
import type { Booking } from '@/api/types';
|
||||||
import BookingItem from '@/components/booking/BookingItem.vue';
|
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 ListCardSkeleton from '@/components/ui/ListCardSkeleton.vue';
|
||||||
import ResponsiveAppLayout from '@/layouts/ResponsiveAppLayout.vue';
|
import ResponsiveAppLayout from '@/layouts/ResponsiveAppLayout.vue';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import {
|
import {
|
||||||
|
entryMatchesOwnerTab,
|
||||||
groupBookingsForDisplay,
|
groupBookingsForDisplay,
|
||||||
type BookingListEntry,
|
type BookingListEntry,
|
||||||
|
type OwnerBookingTab,
|
||||||
} from '@/utils/booking-groups';
|
} from '@/utils/booking-groups';
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
@@ -75,9 +154,50 @@ const bookings = ref<Booking[]>([]);
|
|||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const error = ref('');
|
const error = ref('');
|
||||||
const actingKey = ref<string | null>(null);
|
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 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) {
|
function canConfirm(booking: Booking) {
|
||||||
return auth.isBusinessOwner && booking.status === 'pending';
|
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[]) {
|
async function onCancel(entry: Booking[]) {
|
||||||
const cancellable = entry.filter((booking) => canCancel(booking));
|
const cancellable = entry.filter((booking) => canCancel(booking));
|
||||||
if (!cancellable.length) return;
|
if (!cancellable.length) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user