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
+7
View File
@@ -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",
+1
View File
@@ -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"
+22
View File
@@ -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 }),
});
}
+15
View File
@@ -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>
+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>
+43 -10
View File
@@ -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) {
+128
View File
@@ -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),
};
}
+15 -21
View File
@@ -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;
}
+25 -14
View File
@@ -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();
+14
View File
@@ -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;
}