Add time rezerv
This commit is contained in:
Generated
+7
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@lucide/vue": "^1.16.0",
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"jalaali-js": "^1.2.8",
|
||||
"pinia": "^2.3.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
@@ -1795,6 +1796,12 @@
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jalaali-js": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://mirror-npm.runflare.com/jalaali-js/-/jalaali-js-1.2.8.tgz",
|
||||
"integrity": "sha512-Jl/EwY84JwjW2wsWqeU4pNd22VNQ7EkjI36bDuLw31wH98WQW4fPjD0+mG7cdCK+Y8D6s9R3zLiQ3LaKu6bD8A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://mirror-npm.runflare.com/jiti/-/jiti-2.7.0.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@lucide/vue": "^1.16.0",
|
||||
"@vueuse/core": "^12.8.2",
|
||||
"jalaali-js": "^1.2.8",
|
||||
"pinia": "^2.3.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { api } from './client';
|
||||
|
||||
export type AvailabilitySlot = {
|
||||
id?: string;
|
||||
dayOfWeek: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
};
|
||||
|
||||
export function fetchBusinessAvailability(businessId: string) {
|
||||
return api<AvailabilitySlot[]>(`/businesses/${businessId}/availability`);
|
||||
}
|
||||
|
||||
export function setBusinessAvailability(
|
||||
businessId: string,
|
||||
slots: Omit<AvailabilitySlot, 'id'>[],
|
||||
) {
|
||||
return api<AvailabilitySlot[]>(`/businesses/${businessId}/availability`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ slots }),
|
||||
});
|
||||
}
|
||||
@@ -34,3 +34,18 @@ export function cancelBooking(id: string) {
|
||||
export function confirmBooking(id: string) {
|
||||
return api<Booking>(`/bookings/${id}/confirm`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export function fetchAvailableSlots(
|
||||
businessId: string,
|
||||
businessServiceId: string,
|
||||
dateYmd: string,
|
||||
) {
|
||||
const search = new URLSearchParams({
|
||||
businessId,
|
||||
businessServiceId,
|
||||
date: dateYmd,
|
||||
});
|
||||
return api<{ date: string; slots: string[] }>(
|
||||
`/bookings/available-slots?${search}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
+43
-10
@@ -1,3 +1,44 @@
|
||||
/** Parses price from form input (string or number from type="number"). */
|
||||
export function parsePriceInput(
|
||||
value: string | number | null | undefined,
|
||||
): number | null {
|
||||
if (value == null || value === '') {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) && value >= 0 ? value : null;
|
||||
}
|
||||
const n = Number.parseFloat(String(value).replace(/,/g, '').trim());
|
||||
if (!Number.isFinite(n) || n < 0) {
|
||||
return null;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Parses optional duration in minutes from form input. */
|
||||
export function parseOptionalMinutes(
|
||||
value: string | number | null | undefined,
|
||||
): number | null | undefined {
|
||||
if (value == null || value === '') {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.trunc(value);
|
||||
}
|
||||
const raw = String(value).trim();
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const n = Number.parseInt(raw, 10);
|
||||
if (!Number.isFinite(n) || n <= 0) {
|
||||
return null;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Formats API numeric price string (e.g. "150000.00") for display. */
|
||||
export function formatPriceToman(price: string): string {
|
||||
const value = Number.parseFloat(price);
|
||||
@@ -12,7 +53,8 @@ export function formatDate(value?: string | Date): string {
|
||||
const date = typeof value === 'string' ? new Date(value) : value;
|
||||
if (Number.isNaN(date.getTime())) return '—';
|
||||
return new Intl.DateTimeFormat('fa-IR', {
|
||||
dateStyle: 'short',
|
||||
calendar: 'persian',
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(date);
|
||||
}
|
||||
@@ -27,15 +69,6 @@ export function bookingStatusLabel(status: string): string {
|
||||
return map[status] ?? status;
|
||||
}
|
||||
|
||||
/** Default value for datetime-local (at least ~90 min ahead). */
|
||||
export function defaultBookingDateTimeLocal(): string {
|
||||
const d = new Date();
|
||||
d.setMinutes(d.getMinutes() + 90);
|
||||
d.setSeconds(0, 0);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function formatDuration(minutes: number | null, fallback?: number): string {
|
||||
const m = minutes ?? fallback;
|
||||
if (!m) {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { jalaaliMonthLength, toGregorian, toJalaali } from 'jalaali-js';
|
||||
|
||||
/** Must match API default BOOKING_MIN_LEAD_MINUTES (60). */
|
||||
export const BOOKING_MIN_LEAD_MINUTES = 60;
|
||||
|
||||
export const JALALI_MONTH_NAMES = [
|
||||
'فروردین',
|
||||
'اردیبهشت',
|
||||
'خرداد',
|
||||
'تیر',
|
||||
'مرداد',
|
||||
'شهریور',
|
||||
'مهر',
|
||||
'آبان',
|
||||
'آذر',
|
||||
'دی',
|
||||
'بهمن',
|
||||
'اسفند',
|
||||
] as const;
|
||||
|
||||
export type JalaliDateTimeParts = {
|
||||
jy: number;
|
||||
jm: number;
|
||||
jd: number;
|
||||
hour: number;
|
||||
minute: number;
|
||||
};
|
||||
|
||||
export function gregorianToJalaliParts(date: Date): JalaliDateTimeParts {
|
||||
const { jy, jm, jd } = toJalaali(
|
||||
date.getFullYear(),
|
||||
date.getMonth() + 1,
|
||||
date.getDate(),
|
||||
);
|
||||
return {
|
||||
jy,
|
||||
jm,
|
||||
jd,
|
||||
hour: date.getHours(),
|
||||
minute: date.getMinutes(),
|
||||
};
|
||||
}
|
||||
|
||||
export function jalaliPartsToDate(parts: JalaliDateTimeParts): Date {
|
||||
const { gy, gm, gd } = toGregorian(parts.jy, parts.jm, parts.jd);
|
||||
return new Date(gy, gm - 1, gd, parts.hour, parts.minute, 0, 0);
|
||||
}
|
||||
|
||||
export function jalaliPartsToIso(parts: JalaliDateTimeParts): string {
|
||||
return jalaliPartsToDate(parts).toISOString();
|
||||
}
|
||||
|
||||
export function jalaliToGregorianYmd(parts: {
|
||||
jy: number;
|
||||
jm: number;
|
||||
jd: number;
|
||||
}): string {
|
||||
const { gy, gm, gd } = toGregorian(parts.jy, parts.jm, parts.jd);
|
||||
return `${gy}-${String(gm).padStart(2, '0')}-${String(gd).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function isJalaliBeforeToday(parts: {
|
||||
jy: number;
|
||||
jm: number;
|
||||
jd: number;
|
||||
}): boolean {
|
||||
const today = gregorianToJalaliParts(new Date());
|
||||
if (parts.jy < today.jy) return true;
|
||||
if (parts.jy > today.jy) return false;
|
||||
if (parts.jm < today.jm) return true;
|
||||
if (parts.jm > today.jm) return false;
|
||||
return parts.jd < today.jd;
|
||||
}
|
||||
|
||||
export function firstAllowedJalaliDate(): JalaliDateTimeParts {
|
||||
return gregorianToJalaliParts(minBookingDate());
|
||||
}
|
||||
|
||||
export function isoToJalaliParts(iso: string): JalaliDateTimeParts | null {
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return gregorianToJalaliParts(date);
|
||||
}
|
||||
|
||||
export function daysInJalaliMonth(jy: number, jm: number): number {
|
||||
return jalaaliMonthLength(jy, jm);
|
||||
}
|
||||
|
||||
export function minBookingDate(minLeadMinutes = BOOKING_MIN_LEAD_MINUTES): Date {
|
||||
return new Date(Date.now() + minLeadMinutes * 60 * 1000);
|
||||
}
|
||||
|
||||
/** Earliest allowed booking moment (default: now + 1 hour). */
|
||||
export function defaultBookingDate(
|
||||
minLeadMinutes = BOOKING_MIN_LEAD_MINUTES,
|
||||
): JalaliDateTimeParts {
|
||||
const d = minBookingDate(minLeadMinutes);
|
||||
const minutes = d.getMinutes();
|
||||
const roundUp = (15 - (minutes % 15)) % 15;
|
||||
if (roundUp > 0) {
|
||||
d.setMinutes(minutes + roundUp);
|
||||
}
|
||||
d.setSeconds(0, 0);
|
||||
return gregorianToJalaliParts(d);
|
||||
}
|
||||
|
||||
export function isBookingTimeValid(
|
||||
scheduled: Date,
|
||||
minLeadMinutes = BOOKING_MIN_LEAD_MINUTES,
|
||||
): boolean {
|
||||
return scheduled.getTime() >= minBookingDate(minLeadMinutes).getTime();
|
||||
}
|
||||
|
||||
export function formatJalaliDateTime(parts: JalaliDateTimeParts): string {
|
||||
const month = JALALI_MONTH_NAMES[parts.jm - 1] ?? String(parts.jm);
|
||||
const time = `${String(parts.hour).padStart(2, '0')}:${String(parts.minute).padStart(2, '0')}`;
|
||||
return `${parts.jd} ${month} ${parts.jy} — ${time}`;
|
||||
}
|
||||
|
||||
export function clampJalaliDay(parts: JalaliDateTimeParts): JalaliDateTimeParts {
|
||||
const maxDay = daysInJalaliMonth(parts.jy, parts.jm);
|
||||
return {
|
||||
...parts,
|
||||
jd: Math.min(Math.max(1, parts.jd), maxDay),
|
||||
};
|
||||
}
|
||||
@@ -127,19 +127,12 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="scheduledAt" class="block text-sm font-medium text-muted">
|
||||
زمان مراجعه
|
||||
</label>
|
||||
<input
|
||||
id="scheduledAt"
|
||||
v-model="bookingForm.scheduledAt"
|
||||
type="datetime-local"
|
||||
required
|
||||
class="w-full rounded-xl border border-border bg-surface-2 px-4 py-3 text-text outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
|
||||
/>
|
||||
<p class="text-xs text-muted">حداقل یک ساعت بعد از الان</p>
|
||||
</div>
|
||||
<BookingSlotPicker
|
||||
v-model="bookingForm.scheduledAt"
|
||||
label="تاریخ و ساعت مراجعه (شمسی)"
|
||||
:business-id="business.id"
|
||||
:business-service-id="bookingService.id"
|
||||
/>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="bookingNote" class="block text-sm font-medium text-muted">
|
||||
@@ -171,14 +164,11 @@ import { ApiError } from '@/api/client';
|
||||
import type { Business, BusinessService } from '@/api/types';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import BookingSlotPicker from '@/components/booking/BookingSlotPicker.vue';
|
||||
import AppModal from '@/components/ui/AppModal.vue';
|
||||
import WebAppLayout from '@/layouts/WebAppLayout.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import {
|
||||
defaultBookingDateTimeLocal,
|
||||
formatDuration,
|
||||
formatPriceToman,
|
||||
} from '@/utils/format';
|
||||
import { formatDuration, formatPriceToman } from '@/utils/format';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -196,7 +186,7 @@ const bookingService = ref<BusinessService | null>(null);
|
||||
const bookingSaving = ref(false);
|
||||
const bookingError = ref('');
|
||||
const bookingForm = ref({
|
||||
scheduledAt: defaultBookingDateTimeLocal(),
|
||||
scheduledAt: '',
|
||||
note: '',
|
||||
});
|
||||
|
||||
@@ -244,7 +234,7 @@ function openBooking(svc: BusinessService) {
|
||||
if (!auth.user) return;
|
||||
bookingService.value = svc;
|
||||
bookingForm.value = {
|
||||
scheduledAt: defaultBookingDateTimeLocal(),
|
||||
scheduledAt: '',
|
||||
note: '',
|
||||
};
|
||||
bookingError.value = '';
|
||||
@@ -262,7 +252,11 @@ async function onSubmitBooking() {
|
||||
|
||||
const scheduled = new Date(bookingForm.value.scheduledAt);
|
||||
if (Number.isNaN(scheduled.getTime())) {
|
||||
bookingError.value = 'زمان انتخابشده معتبر نیست.';
|
||||
bookingError.value = 'تاریخ یا ساعت انتخابشده معتبر نیست.';
|
||||
return;
|
||||
}
|
||||
if (!bookingForm.value.scheduledAt) {
|
||||
bookingError.value = 'لطفاً یک ساعت از لیست انتخاب کنید.';
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,11 @@
|
||||
<AppButton type="submit" :loading="saving">ذخیره تغییرات</AppButton>
|
||||
</form>
|
||||
|
||||
<BusinessAvailabilityEditor
|
||||
v-if="business"
|
||||
:business-id="business.id"
|
||||
/>
|
||||
|
||||
<section class="space-y-4 border-t border-border pt-6">
|
||||
<h2 class="text-base font-semibold text-text">خدمات و قیمتها</h2>
|
||||
<p class="text-xs text-muted">
|
||||
@@ -222,10 +227,16 @@ import {
|
||||
import { fetchServiceTemplates } from '@/api/service-templates';
|
||||
import type { Business, BusinessService } from '@/api/types';
|
||||
import { ApiError } from '@/api/client';
|
||||
import BusinessAvailabilityEditor from '@/components/owner/BusinessAvailabilityEditor.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppInput from '@/components/ui/AppInput.vue';
|
||||
import WebAppLayout from '@/layouts/WebAppLayout.vue';
|
||||
import { formatDuration, formatPriceToman } from '@/utils/format';
|
||||
import {
|
||||
formatDuration,
|
||||
formatPriceToman,
|
||||
parseOptionalMinutes,
|
||||
parsePriceInput,
|
||||
} from '@/utils/format';
|
||||
|
||||
const business = ref<Business | null>(null);
|
||||
const services = ref<BusinessService[]>([]);
|
||||
@@ -324,25 +335,22 @@ async function onSave() {
|
||||
}
|
||||
}
|
||||
|
||||
function parsePrice(value: string): number | null {
|
||||
const n = Number.parseFloat(value.replace(/,/g, ''));
|
||||
if (!Number.isFinite(n) || n < 0) return null;
|
||||
return n;
|
||||
}
|
||||
|
||||
async function onAddService() {
|
||||
if (!business.value || !serviceForm.value.serviceTemplateId) return;
|
||||
const price = parsePrice(serviceForm.value.price);
|
||||
const price = parsePriceInput(serviceForm.value.price);
|
||||
if (price === null) {
|
||||
error.value = 'قیمت معتبر نیست.';
|
||||
return;
|
||||
}
|
||||
const duration = parseOptionalMinutes(serviceForm.value.durationMinutes);
|
||||
if (duration === null) {
|
||||
error.value = 'مدت زمان معتبر نیست.';
|
||||
return;
|
||||
}
|
||||
addingService.value = true;
|
||||
error.value = '';
|
||||
message.value = '';
|
||||
try {
|
||||
const durationRaw = serviceForm.value.durationMinutes.trim();
|
||||
const duration = durationRaw ? Number.parseInt(durationRaw, 10) : undefined;
|
||||
await createBusinessService({
|
||||
businessId: business.value.id,
|
||||
serviceTemplateId: serviceForm.value.serviceTemplateId,
|
||||
@@ -375,19 +383,22 @@ function cancelEdit() {
|
||||
}
|
||||
|
||||
async function onSaveEdit(svc: BusinessService) {
|
||||
const price = parsePrice(editForm.value.price);
|
||||
const price = parsePriceInput(editForm.value.price);
|
||||
if (price === null) {
|
||||
error.value = 'قیمت معتبر نیست.';
|
||||
return;
|
||||
}
|
||||
const duration = parseOptionalMinutes(editForm.value.durationMinutes);
|
||||
if (duration === null) {
|
||||
error.value = 'مدت زمان معتبر نیست.';
|
||||
return;
|
||||
}
|
||||
savingService.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const durationRaw = editForm.value.durationMinutes.trim();
|
||||
const duration = durationRaw ? Number.parseInt(durationRaw, 10) : null;
|
||||
await updateBusinessService(svc.id, {
|
||||
price,
|
||||
durationMinutes: duration && duration > 0 ? duration : null,
|
||||
durationMinutes: duration ?? null,
|
||||
});
|
||||
editingId.value = null;
|
||||
await loadServicesAndTemplates();
|
||||
|
||||
Vendored
+14
@@ -1,5 +1,19 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module 'jalaali-js' {
|
||||
export function toJalaali(gy: number, gm: number, gd: number): {
|
||||
jy: number;
|
||||
jm: number;
|
||||
jd: number;
|
||||
};
|
||||
export function toGregorian(jy: number, jm: number, jd: number): {
|
||||
gy: number;
|
||||
gm: number;
|
||||
gd: number;
|
||||
};
|
||||
export function jalaaliMonthLength(jy: number, jm: number): number;
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user