Add time rezerv

This commit is contained in:
2026-05-25 19:13:28 +03:30
parent 0b2defd50c
commit 27b73c981a
12 changed files with 803 additions and 45 deletions
@@ -0,0 +1,193 @@
<template>
<div class="space-y-3">
<label v-if="label" class="block text-sm font-medium text-muted">{{ label }}</label>
<div class="grid grid-cols-3 gap-2">
<div class="space-y-1">
<span class="text-xs text-muted">سال</span>
<select
v-model.number="dateParts.jy"
class="w-full rounded-xl border border-border bg-surface-2 px-3 py-2.5 text-sm text-text outline-none focus:border-brand-500"
@change="onDateChange"
>
<option v-for="y in yearOptions" :key="y" :value="y">{{ y }}</option>
</select>
</div>
<div class="space-y-1">
<span class="text-xs text-muted">ماه</span>
<select
v-model.number="dateParts.jm"
class="w-full rounded-xl border border-border bg-surface-2 px-3 py-2.5 text-sm text-text outline-none focus:border-brand-500"
@change="onMonthChange"
>
<option v-for="(name, idx) in JALALI_MONTH_NAMES" :key="idx + 1" :value="idx + 1">
{{ name }}
</option>
</select>
</div>
<div class="space-y-1">
<span class="text-xs text-muted">روز</span>
<select
v-model.number="dateParts.jd"
class="w-full rounded-xl border border-border bg-surface-2 px-3 py-2.5 text-sm text-text outline-none focus:border-brand-500"
@change="onDateChange"
>
<option v-for="d in dayOptions" :key="d" :value="d">{{ d }}</option>
</select>
</div>
</div>
<div v-if="loadingSlots" class="text-sm text-muted">در حال بارگذاری ساعات…</div>
<p v-else-if="slotsError" class="text-sm text-red-600">{{ slotsError }}</p>
<p
v-else-if="!slots.length"
class="rounded-xl border border-dashed border-border px-4 py-3 text-center text-sm text-muted"
>
برای این روز ساعت خالی وجود ندارد. روز دیگری انتخاب کنید یا با کسب‌وکار هماهنگ کنید.
</p>
<div v-else class="space-y-2">
<p class="text-xs text-muted">ساعت‌های قابل رزرو (شمسی):</p>
<div class="grid grid-cols-3 gap-2 sm:grid-cols-4">
<button
v-for="slot in slots"
:key="slot"
type="button"
class="rounded-xl border px-2 py-2 text-sm transition"
:class="
model === slot
? 'border-brand-500 bg-brand-500/15 text-brand-700 dark:text-brand-500'
: 'border-border bg-surface-2 text-text hover:border-brand-500/40'
"
@click="selectSlot(slot)"
>
{{ formatSlotLabel(slot) }}
</button>
</div>
</div>
<p v-if="model && selectedLabel" class="text-xs text-muted">
انتخاب شده: {{ selectedLabel }}
</p>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { fetchAvailableSlots } from '@/api/bookings';
import { ApiError } from '@/api/client';
import {
clampJalaliDay,
daysInJalaliMonth,
firstAllowedJalaliDate,
formatJalaliDateTime,
gregorianToJalaliParts,
isoToJalaliParts,
isJalaliBeforeToday,
JALALI_MONTH_NAMES,
jalaliToGregorianYmd,
type JalaliDateTimeParts,
} from '@/utils/jalali';
const model = defineModel<string>({ default: '' });
const props = defineProps<{
label?: string;
businessId: string;
businessServiceId: string;
}>();
const dateParts = ref<Pick<JalaliDateTimeParts, 'jy' | 'jm' | 'jd'>>({
jy: firstAllowedJalaliDate().jy,
jm: firstAllowedJalaliDate().jm,
jd: firstAllowedJalaliDate().jd,
});
const slots = ref<string[]>([]);
const loadingSlots = ref(false);
const slotsError = ref('');
const currentJy = computed(() => gregorianToJalaliParts(new Date()).jy);
const yearOptions = computed(() => [currentJy.value, currentJy.value + 1]);
const dayOptions = computed(() => {
const max = daysInJalaliMonth(dateParts.value.jy, dateParts.value.jm);
return Array.from({ length: max }, (_, i) => i + 1);
});
const selectedLabel = computed(() => {
if (!model.value) return '';
const parsed = isoToJalaliParts(model.value);
return parsed ? formatJalaliDateTime(parsed) : '';
});
function formatSlotLabel(iso: string): string {
const parsed = isoToJalaliParts(iso);
if (!parsed) return '—';
return `${String(parsed.hour).padStart(2, '0')}:${String(parsed.minute).padStart(2, '0')}`;
}
function ensureDateNotPast() {
if (isJalaliBeforeToday(dateParts.value)) {
const min = firstAllowedJalaliDate();
dateParts.value = { jy: min.jy, jm: min.jm, jd: min.jd };
}
dateParts.value = clampJalaliDay({
...dateParts.value,
hour: 0,
minute: 0,
});
}
async function loadSlots() {
loadingSlots.value = true;
slotsError.value = '';
slots.value = [];
try {
ensureDateNotPast();
const ymd = jalaliToGregorianYmd(dateParts.value);
const res = await fetchAvailableSlots(
props.businessId,
props.businessServiceId,
ymd,
);
slots.value = res.slots;
if (model.value && !slots.value.includes(model.value)) {
model.value = slots.value[0] ?? '';
}
} catch (e) {
slotsError.value =
e instanceof ApiError ? e.message : 'بارگذاری ساعات ناموفق بود';
} finally {
loadingSlots.value = false;
}
}
function onDateChange() {
ensureDateNotPast();
model.value = '';
loadSlots();
}
function onMonthChange() {
dateParts.value = clampJalaliDay({
...dateParts.value,
hour: 0,
minute: 0,
});
onDateChange();
}
function selectSlot(iso: string) {
model.value = iso;
}
watch(
() => [props.businessId, props.businessServiceId],
() => {
model.value = '';
loadSlots();
},
);
onMounted(loadSlots);
</script>
@@ -0,0 +1,175 @@
<template>
<section class="space-y-4 border-t border-border pt-6">
<div>
<h2 class="text-base font-semibold text-text">ساعات پذیرش آنلاین</h2>
<p class="mt-1 text-xs text-muted">
مشتری فقط در بازه‌های زیر می‌تواند رزرو کند. برای هر روز می‌توانید چند بازه تعریف کنید.
</p>
</div>
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
<p v-if="message" class="text-sm text-brand-700 dark:text-brand-500">{{ message }}</p>
<div v-if="loading" class="text-sm text-muted">در حال بارگذاری…</div>
<div v-else class="space-y-3">
<div
v-for="day in weekDays"
:key="day.dayOfWeek"
class="rounded-xl border border-border bg-surface-2 p-3"
>
<p class="mb-2 text-sm font-medium text-text">{{ day.label }}</p>
<ul v-if="slotsByDay[day.dayOfWeek]?.length" class="mb-2 space-y-1">
<li
v-for="(slot, idx) in slotsByDay[day.dayOfWeek]"
:key="idx"
class="flex items-center justify-between rounded-lg bg-surface-3 px-2 py-1.5 text-sm"
>
<span dir="ltr">{{ slot.startTime }} – {{ slot.endTime }}</span>
<button
type="button"
class="text-xs text-red-600"
@click="removeSlot(day.dayOfWeek, idx)"
>
حذف
</button>
</li>
</ul>
<p v-else class="mb-2 text-xs text-muted">بازه‌ای ثبت نشده</p>
<div class="flex flex-wrap items-end gap-2">
<div class="space-y-1">
<span class="text-xs text-muted">از</span>
<input
v-model="draft[day.dayOfWeek].start"
type="time"
class="rounded-lg border border-border bg-surface px-2 py-1.5 text-sm"
/>
</div>
<div class="space-y-1">
<span class="text-xs text-muted">تا</span>
<input
v-model="draft[day.dayOfWeek].end"
type="time"
class="rounded-lg border border-border bg-surface px-2 py-1.5 text-sm"
/>
</div>
<AppButton variant="ghost" class="!w-auto px-3 text-xs" @click="addSlot(day.dayOfWeek)">
افزودن بازه
</AppButton>
</div>
</div>
<AppButton :loading="saving" @click="onSave">ذخیره ساعات پذیرش</AppButton>
</div>
</section>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import {
fetchBusinessAvailability,
setBusinessAvailability,
type AvailabilitySlot,
} from '@/api/availability';
import { ApiError } from '@/api/client';
import AppButton from '@/components/ui/AppButton.vue';
const props = defineProps<{ businessId: string }>();
const weekDays = [
{ dayOfWeek: 0, label: 'شنبه' },
{ dayOfWeek: 1, label: 'یکشنبه' },
{ dayOfWeek: 2, label: 'دوشنبه' },
{ dayOfWeek: 3, label: 'سه‌شنبه' },
{ dayOfWeek: 4, label: 'چهارشنبه' },
{ dayOfWeek: 5, label: 'پنج‌شنبه' },
{ dayOfWeek: 6, label: 'جمعه' },
];
const slotsByDay = ref<Record<number, AvailabilitySlot[]>>({});
const draft = reactive<Record<number, { start: string; end: string }>>({});
const loading = ref(true);
const saving = ref(false);
const error = ref('');
const message = ref('');
function initDraft() {
for (const d of weekDays) {
draft[d.dayOfWeek] = { start: '09:00', end: '17:00' };
if (!slotsByDay.value[d.dayOfWeek]) {
slotsByDay.value[d.dayOfWeek] = [];
}
}
}
function groupSlots(rows: AvailabilitySlot[]) {
const grouped: Record<number, AvailabilitySlot[]> = {};
for (const d of weekDays) {
grouped[d.dayOfWeek] = [];
}
for (const row of rows) {
grouped[row.dayOfWeek]?.push(row);
}
slotsByDay.value = grouped;
}
async function load() {
loading.value = true;
error.value = '';
try {
const rows = await fetchBusinessAvailability(props.businessId);
groupSlots(rows);
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'خطا در بارگذاری';
} finally {
loading.value = false;
}
}
function addSlot(dayOfWeek: number) {
const start = draft[dayOfWeek].start;
const end = draft[dayOfWeek].end;
if (!start || !end || start >= end) {
error.value = 'ساعت پایان باید بعد از شروع باشد.';
return;
}
error.value = '';
slotsByDay.value[dayOfWeek].push({ dayOfWeek, startTime: start, endTime: end });
}
function removeSlot(dayOfWeek: number, index: number) {
slotsByDay.value[dayOfWeek].splice(index, 1);
}
async function onSave() {
saving.value = true;
error.value = '';
message.value = '';
const payload: Omit<AvailabilitySlot, 'id'>[] = [];
for (const d of weekDays) {
for (const slot of slotsByDay.value[d.dayOfWeek] ?? []) {
payload.push({
dayOfWeek: slot.dayOfWeek,
startTime: slot.startTime,
endTime: slot.endTime,
});
}
}
try {
await setBusinessAvailability(props.businessId, payload);
message.value = 'ساعات پذیرش ذخیره شد.';
await load();
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'خطا در ذخیره';
} finally {
saving.value = false;
}
}
onMounted(() => {
initDraft();
load();
});
</script>
+165
View File
@@ -0,0 +1,165 @@
<template>
<div class="space-y-2">
<label v-if="label" class="block text-sm font-medium text-muted">{{ label }}</label>
<div class="grid grid-cols-3 gap-2">
<div class="space-y-1">
<span class="text-xs text-muted">سال</span>
<select
v-model.number="parts.jy"
class="w-full rounded-xl border border-border bg-surface-2 px-3 py-2.5 text-sm text-text outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
@change="onChange"
>
<option v-for="y in yearOptions" :key="y" :value="y">{{ y }}</option>
</select>
</div>
<div class="space-y-1">
<span class="text-xs text-muted">ماه</span>
<select
v-model.number="parts.jm"
class="w-full rounded-xl border border-border bg-surface-2 px-3 py-2.5 text-sm text-text outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
@change="onMonthChange"
>
<option v-for="(name, idx) in JALALI_MONTH_NAMES" :key="idx + 1" :value="idx + 1">
{{ name }}
</option>
</select>
</div>
<div class="space-y-1">
<span class="text-xs text-muted">روز</span>
<select
v-model.number="parts.jd"
class="w-full rounded-xl border border-border bg-surface-2 px-3 py-2.5 text-sm text-text outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
@change="onChange"
>
<option v-for="d in dayOptions" :key="d" :value="d">{{ d }}</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-2">
<div class="space-y-1">
<span class="text-xs text-muted">ساعت</span>
<select
v-model.number="parts.hour"
class="w-full rounded-xl border border-border bg-surface-2 px-3 py-2.5 text-sm text-text outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
@change="onChange"
>
<option v-for="h in hourOptions" :key="h" :value="h">
{{ String(h).padStart(2, '0') }}
</option>
</select>
</div>
<div class="space-y-1">
<span class="text-xs text-muted">دقیقه</span>
<select
v-model.number="parts.minute"
class="w-full rounded-xl border border-border bg-surface-2 px-3 py-2.5 text-sm text-text outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
@change="onChange"
>
<option v-for="m in minuteOptions" :key="m" :value="m">
{{ String(m).padStart(2, '0') }}
</option>
</select>
</div>
</div>
<p class="text-xs text-muted">
{{ preview }}
<span v-if="!isValid" class="text-red-600 dark:text-red-400">
— زمان باید حداقل {{ minLeadMinutes }} دقیقه بعد از الان باشد
</span>
</p>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import {
BOOKING_MIN_LEAD_MINUTES,
clampJalaliDay,
daysInJalaliMonth,
defaultBookingDate,
formatJalaliDateTime,
gregorianToJalaliParts,
isBookingTimeValid,
isoToJalaliParts,
JALALI_MONTH_NAMES,
jalaliPartsToDate,
jalaliPartsToIso,
type JalaliDateTimeParts,
} from '@/utils/jalali';
const model = defineModel<string>({ default: '' });
const props = withDefaults(
defineProps<{
label?: string;
minLeadMinutes?: number;
}>(),
{
minLeadMinutes: BOOKING_MIN_LEAD_MINUTES,
},
);
const parts = ref<JalaliDateTimeParts>(defaultBookingDate(props.minLeadMinutes));
const currentJy = computed(() => gregorianToJalaliParts(new Date()).jy);
const yearOptions = computed(() => [currentJy.value, currentJy.value + 1]);
const dayOptions = computed(() => {
const max = daysInJalaliMonth(parts.value.jy, parts.value.jm);
return Array.from({ length: max }, (_, i) => i + 1);
});
const hourOptions = Array.from({ length: 24 }, (_, i) => i);
const minuteOptions = Array.from({ length: 12 }, (_, i) => i * 5);
const preview = computed(() => formatJalaliDateTime(parts.value));
const isValid = computed(() =>
isBookingTimeValid(jalaliPartsToDate(parts.value), props.minLeadMinutes),
);
function onChange() {
parts.value = clampJalaliDay(parts.value);
model.value = jalaliPartsToIso(parts.value);
}
function onMonthChange() {
parts.value = clampJalaliDay(parts.value);
onChange();
}
watch(
() => model.value,
(iso) => {
if (!iso) return;
const parsed = isoToJalaliParts(iso);
if (!parsed) return;
const currentIso = jalaliPartsToIso(parts.value);
if (iso !== currentIso) {
parts.value = clampJalaliDay(parsed);
}
},
{ immediate: true },
);
watch(
() => props.minLeadMinutes,
() => {
if (!model.value) {
parts.value = defaultBookingDate(props.minLeadMinutes);
model.value = jalaliPartsToIso(parts.value);
}
},
);
if (!model.value) {
parts.value = defaultBookingDate(props.minLeadMinutes);
model.value = jalaliPartsToIso(parts.value);
}
defineExpose({ isValid });
</script>