edit time rezerv

This commit is contained in:
2026-05-25 20:05:41 +03:30
parent 27b73c981a
commit 2a25727ef1
6 changed files with 534 additions and 110 deletions
@@ -1,73 +1,161 @@
<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>
<section class="border-t border-border pt-4">
<button
type="button"
class="flex w-full items-center justify-between gap-3 rounded-2xl border border-border bg-surface-2 px-4 py-3 text-start transition hover:border-brand-500/30"
:aria-expanded="expanded"
@click="expanded = !expanded"
>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<AppIcon name="clock" size="sm" class="text-brand-600 dark:text-brand-500" />
<h2 class="text-base font-semibold text-text">ساعات پذیرش آنلاین</h2>
</div>
<p v-if="!expanded" class="mt-1 truncate text-xs text-muted">
{{ collapsedSummary }}
</p>
</div>
<AppIcon
:name="expanded ? 'chevron-up' : 'chevron-down'"
size="sm"
class="shrink-0 text-muted"
/>
</button>
<AppButton :loading="saving" @click="onSave">ذخیره ساعات پذیرش</AppButton>
<div v-show="expanded" class="mt-3 space-y-4">
<p class="text-xs leading-relaxed text-muted">
مشتری فقط در بازه‌های تعریف‌شده می‌تواند رزرو کند. ابتدا حالت کلی را انتخاب کنید.
</p>
<p v-if="error" class="rounded-xl bg-red-500/10 px-3 py-2 text-sm text-red-600">
{{ error }}
</p>
<p
v-if="message"
class="rounded-xl bg-brand-500/10 px-3 py-2 text-sm text-brand-700 dark:text-brand-500"
>
{{ message }}
</p>
<div v-if="loading" class="py-6 text-center text-sm text-muted">در حال بارگذاری…</div>
<template v-else>
<div class="flex rounded-xl bg-surface-3 p-1">
<button
type="button"
class="flex-1 rounded-lg px-3 py-2 text-sm font-medium transition"
:class="
mode === 'same'
? 'bg-surface-2 text-text shadow-sm'
: 'text-muted hover:text-text'
"
@click="setMode('same')"
>
همه روزها یکسان
</button>
<button
type="button"
class="flex-1 rounded-lg px-3 py-2 text-sm font-medium transition"
:class="
mode === 'custom'
? 'bg-surface-2 text-text shadow-sm'
: 'text-muted hover:text-text'
"
@click="setMode('custom')"
>
روزهای جداگانه
</button>
</div>
<!-- حالت یکسان (مثل تکرار آلارم) -->
<div v-if="mode === 'same'" class="space-y-4">
<div>
<p class="mb-2 text-xs font-medium text-muted">روزهای فعال</p>
<div class="flex flex-wrap gap-2">
<button
v-for="day in weekDays"
:key="day.dayOfWeek"
type="button"
class="flex h-10 w-10 items-center justify-center rounded-full text-xs font-semibold transition"
:class="
enabledDays[day.dayOfWeek]
? 'bg-brand-600 text-white shadow-sm'
: 'border border-border bg-surface-2 text-muted'
"
@click="toggleEnabledDay(day.dayOfWeek)"
>
{{ day.short }}
</button>
</div>
</div>
<TimeRangeList
:ranges="sameRanges"
@add="addSameRange"
@remove="(i) => removeSameRange(i)"
/>
</div>
<!-- حالت جداگانه با تب -->
<div v-else class="space-y-3">
<div
class="flex gap-1 overflow-x-auto pb-1 scrollbar-thin"
role="tablist"
>
<button
v-for="day in weekDays"
:key="day.dayOfWeek"
type="button"
role="tab"
class="shrink-0 rounded-xl px-3 py-2 text-sm font-medium transition"
:class="
activeDay === day.dayOfWeek
? 'bg-brand-500/15 text-brand-700 dark:text-brand-500'
: 'text-muted hover:bg-surface-3'
"
:aria-selected="activeDay === day.dayOfWeek"
@click="activeDay = day.dayOfWeek"
>
{{ day.label }}
<span
v-if="(slotsByDay[day.dayOfWeek]?.length ?? 0) > 0"
class="ms-1 inline-block h-1.5 w-1.5 rounded-full bg-brand-500"
/>
</button>
</div>
<div class="rounded-2xl border border-border bg-surface-2 p-4">
<label class="flex items-center justify-between gap-3">
<span class="text-sm text-text">پذیرش آنلاین این روز</span>
<input
type="checkbox"
class="h-5 w-5 rounded border-border text-brand-600 focus:ring-brand-500/30"
:checked="dayActive[activeDay]"
@change="toggleDayActive(activeDay)"
/>
</label>
<div v-if="dayActive[activeDay]" class="mt-4">
<TimeRangeList
:ranges="slotsByDay[activeDay] ?? []"
@add="(start, end) => addCustomRange(activeDay, start, end)"
@remove="(i) => removeSlot(activeDay, i)"
/>
</div>
<p v-else class="mt-3 text-center text-sm text-muted">
این روز غیرفعال است — رزرو آنلاین نمایش داده نمی‌شود.
</p>
</div>
</div>
<AppButton :loading="saving" @click="onSave">ذخیره ساعات</AppButton>
</template>
</div>
</section>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import {
fetchBusinessAvailability,
setBusinessAvailability,
@@ -75,44 +163,189 @@ import {
} from '@/api/availability';
import { ApiError } from '@/api/client';
import AppButton from '@/components/ui/AppButton.vue';
import AppIcon from '@/components/ui/AppIcon.vue';
import TimeRangeList from '@/components/owner/TimeRangeList.vue';
import { isHmRangeValid, normalizeHmTime } from '@/utils/format';
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: 'جمعه' },
{ dayOfWeek: 0, label: 'شنبه', short: 'ش' },
{ dayOfWeek: 1, label: 'یکشنبه', short: 'ی' },
{ dayOfWeek: 2, label: 'دوشنبه', short: 'د' },
{ dayOfWeek: 3, label: 'سه‌شنبه', short: 'س' },
{ dayOfWeek: 4, label: 'چهارشنبه', short: 'چ' },
{ dayOfWeek: 5, label: 'پنج‌شنبه', short: 'پ' },
{ dayOfWeek: 6, label: 'جمعه', short: 'ج' },
];
const slotsByDay = ref<Record<number, AvailabilitySlot[]>>({});
const draft = reactive<Record<number, { start: string; end: string }>>({});
type Range = { startTime: string; endTime: string };
const expanded = ref(false);
const mode = ref<'same' | 'custom'>('same');
const activeDay = ref(0);
const slotsByDay = ref<Record<number, Range[]>>({});
const sameRanges = ref<Range[]>([]);
const enabledDays = ref<Record<number, boolean>>({
0: true,
1: true,
2: true,
3: true,
4: true,
5: true,
6: true,
});
const dayActive = ref<Record<number, boolean>>({
0: true,
1: true,
2: true,
3: true,
4: true,
5: true,
6: true,
});
const loading = ref(true);
const saving = ref(false);
const error = ref('');
const message = ref('');
function initDraft() {
const collapsedSummary = computed(() => {
const total = Object.values(slotsByDay.value).reduce(
(n, arr) => n + (arr?.length ?? 0),
0,
);
if (total === 0) return 'تنظیم نشده — برای رزرو آنلاین لازم است';
const activeCount = weekDays.filter(
(d) => (slotsByDay.value[d.dayOfWeek]?.length ?? 0) > 0,
).length;
if (mode.value === 'same' || activeCount === 7) {
return `${total} بازه · همه روزها`;
}
return `${total} بازه · ${activeCount} روز فعال`;
});
function rangesKey(ranges: Range[]): string {
return JSON.stringify(
[...ranges].sort((a, b) => a.startTime.localeCompare(b.startTime)),
);
}
function detectMode(rows: AvailabilitySlot[]) {
const grouped: Record<number, Range[]> = {};
for (const d of weekDays) {
draft[d.dayOfWeek] = { start: '09:00', end: '17:00' };
if (!slotsByDay.value[d.dayOfWeek]) {
slotsByDay.value[d.dayOfWeek] = [];
}
grouped[d.dayOfWeek] = [];
dayActive.value[d.dayOfWeek] = false;
enabledDays.value[d.dayOfWeek] = false;
}
for (const row of rows) {
grouped[row.dayOfWeek].push(normalizeRange(row));
dayActive.value[row.dayOfWeek] = true;
enabledDays.value[row.dayOfWeek] = true;
}
slotsByDay.value = grouped;
const nonEmpty = weekDays
.map((d) => grouped[d.dayOfWeek])
.filter((r) => r.length > 0);
if (nonEmpty.length === 0) {
mode.value = 'same';
sameRanges.value = [{ startTime: '09:00', endTime: '17:00' }];
return;
}
const firstKey = rangesKey(nonEmpty[0]);
const allSame = nonEmpty.every((r) => rangesKey(r) === firstKey);
if (allSame && nonEmpty.length === 7) {
mode.value = 'same';
sameRanges.value = [...nonEmpty[0]];
} else {
mode.value = 'custom';
sameRanges.value = [...nonEmpty[0]];
}
}
function groupSlots(rows: AvailabilitySlot[]) {
const grouped: Record<number, AvailabilitySlot[]> = {};
for (const d of weekDays) {
grouped[d.dayOfWeek] = [];
function setMode(next: 'same' | 'custom') {
if (next === mode.value) return;
if (next === 'same') {
const firstWithSlots = weekDays.find(
(d) => (slotsByDay.value[d.dayOfWeek]?.length ?? 0) > 0,
);
if (firstWithSlots) {
sameRanges.value = [...(slotsByDay.value[firstWithSlots.dayOfWeek] ?? [])];
} else if (!sameRanges.value.length) {
sameRanges.value = [{ startTime: '09:00', endTime: '17:00' }];
}
for (const d of weekDays) {
enabledDays.value[d.dayOfWeek] = dayActive.value[d.dayOfWeek];
}
} else {
for (const d of weekDays) {
if (enabledDays.value[d.dayOfWeek]) {
slotsByDay.value[d.dayOfWeek] = sameRanges.value.map((r) => ({ ...r }));
dayActive.value[d.dayOfWeek] = true;
} else {
slotsByDay.value[d.dayOfWeek] = [];
dayActive.value[d.dayOfWeek] = false;
}
}
activeDay.value = weekDays.find((d) => dayActive.value[d.dayOfWeek])?.dayOfWeek ?? 0;
}
for (const row of rows) {
grouped[row.dayOfWeek]?.push(row);
mode.value = next;
error.value = '';
}
function toggleEnabledDay(dayOfWeek: number) {
enabledDays.value[dayOfWeek] = !enabledDays.value[dayOfWeek];
}
function toggleDayActive(dayOfWeek: number) {
dayActive.value[dayOfWeek] = !dayActive.value[dayOfWeek];
if (!dayActive.value[dayOfWeek]) {
slotsByDay.value[dayOfWeek] = [];
}
slotsByDay.value = grouped;
}
function normalizeRange(range: Range): Range {
return {
startTime: normalizeHmTime(range.startTime) ?? range.startTime,
endTime: normalizeHmTime(range.endTime) ?? range.endTime,
};
}
function addSameRange(start: string, end: string) {
if (!isHmRangeValid(start, end)) {
error.value = 'ساعت پایان باید بعد از شروع باشد.';
return;
}
error.value = '';
sameRanges.value.push({
startTime: normalizeHmTime(start)!,
endTime: normalizeHmTime(end)!,
});
}
function removeSameRange(index: number) {
sameRanges.value.splice(index, 1);
}
function addCustomRange(dayOfWeek: number, start: string, end: string) {
if (!isHmRangeValid(start, end)) {
error.value = 'ساعت پایان باید بعد از شروع باشد.';
return;
}
error.value = '';
if (!slotsByDay.value[dayOfWeek]) {
slotsByDay.value[dayOfWeek] = [];
}
slotsByDay.value[dayOfWeek].push({
startTime: normalizeHmTime(start)!,
endTime: normalizeHmTime(end)!,
});
}
function removeSlot(dayOfWeek: number, index: number) {
slotsByDay.value[dayOfWeek].splice(index, 1);
}
async function load() {
@@ -120,7 +353,7 @@ async function load() {
error.value = '';
try {
const rows = await fetchBusinessAvailability(props.businessId);
groupSlots(rows);
detectMode(rows);
} catch (e) {
error.value = e instanceof ApiError ? e.message : 'خطا در بارگذاری';
} finally {
@@ -128,35 +361,61 @@ async function load() {
}
}
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,
});
if (mode.value === 'same') {
if (!sameRanges.value.length) {
error.value = 'حداقل یک بازه زمانی اضافه کنید.';
saving.value = false;
return;
}
const anyDay = weekDays.some((d) => enabledDays.value[d.dayOfWeek]);
if (!anyDay) {
error.value = 'حداقل یک روز را فعال کنید.';
saving.value = false;
return;
}
for (const d of weekDays) {
if (!enabledDays.value[d.dayOfWeek]) continue;
for (const slot of sameRanges.value) {
const startTime = normalizeHmTime(slot.startTime);
const endTime = normalizeHmTime(slot.endTime);
if (!startTime || !endTime) {
error.value = 'فرمت ساعت نامعتبر است.';
saving.value = false;
return;
}
payload.push({
dayOfWeek: d.dayOfWeek,
startTime,
endTime,
});
}
}
} else {
for (const d of weekDays) {
if (!dayActive.value[d.dayOfWeek]) continue;
for (const slot of slotsByDay.value[d.dayOfWeek] ?? []) {
const startTime = normalizeHmTime(slot.startTime);
const endTime = normalizeHmTime(slot.endTime);
if (!startTime || !endTime) {
error.value = 'فرمت ساعت نامعتبر است.';
saving.value = false;
return;
}
payload.push({
dayOfWeek: d.dayOfWeek,
startTime,
endTime,
});
}
}
}
try {
await setBusinessAvailability(props.businessId, payload);
message.value = 'ساعات پذیرش ذخیره شد.';
@@ -168,8 +427,5 @@ async function onSave() {
}
}
onMounted(() => {
initDraft();
load();
});
onMounted(load);
</script>
+35
View File
@@ -0,0 +1,35 @@
<template>
<div class="flex min-w-0 flex-1 items-center gap-1 rounded-xl border border-border bg-surface-2 px-2 py-2">
<select
:value="hour"
class="min-w-0 flex-1 appearance-none bg-transparent text-center text-lg font-semibold tabular-nums outline-none"
@change="onHour(($event.target as HTMLSelectElement).value)"
>
<option v-for="h in hours" :key="h" :value="h">{{ h }}</option>
</select>
<span class="font-semibold text-muted">:</span>
<select
:value="minute"
class="min-w-0 flex-1 appearance-none bg-transparent text-center text-lg font-semibold tabular-nums outline-none"
@change="onMinute(($event.target as HTMLSelectElement).value)"
>
<option v-for="m in minutes" :key="m" :value="m">{{ m }}</option>
</select>
</div>
</template>
<script setup lang="ts">
const hours = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
const minutes = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0'));
const hour = defineModel<string>('hour', { required: true });
const minute = defineModel<string>('minute', { required: true });
function onHour(v: string) {
hour.value = v;
}
function onMinute(v: string) {
minute.value = v;
}
</script>
+92
View File
@@ -0,0 +1,92 @@
<template>
<div class="space-y-3">
<ul v-if="ranges.length" class="space-y-2">
<li
v-for="(range, idx) in ranges"
:key="idx"
class="flex items-center gap-3 rounded-2xl border border-border bg-surface-3 px-3 py-3"
>
<div class="flex flex-1 items-center justify-center gap-2" dir="ltr">
<span class="text-2xl font-semibold tabular-nums text-text">
{{ range.startTime }}
</span>
<span class="text-muted">–</span>
<span class="text-2xl font-semibold tabular-nums text-text">
{{ range.endTime }}
</span>
</div>
<button
type="button"
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-muted transition hover:bg-red-500/10 hover:text-red-600"
aria-label="حذف بازه"
@click="emit('remove', idx)"
>
<AppIcon name="x" size="sm" />
</button>
</li>
</ul>
<p v-else class="rounded-xl border border-dashed border-border py-6 text-center text-sm text-muted">
هنوز بازه‌ای ثبت نشده
</p>
<div class="rounded-2xl border border-dashed border-brand-500/40 bg-brand-500/5 p-3">
<p class="mb-2 text-xs font-medium text-muted">بازه جدید</p>
<div class="flex items-center gap-2" dir="ltr">
<HmTimePickers v-model:hour="startH" v-model:minute="startM" />
<span class="text-muted">تا</span>
<HmTimePickers v-model:hour="endH" v-model:minute="endM" />
</div>
<p v-if="localError" class="mt-2 text-xs text-red-600">{{ localError }}</p>
<button
type="button"
class="mt-3 flex w-full items-center justify-center gap-2 rounded-xl border border-brand-500/30 bg-surface-2 py-2.5 text-sm font-medium text-brand-700 transition hover:bg-brand-500/10 dark:text-brand-500"
@click="onAdd"
>
<AppIcon name="plus" size="sm" />
افزودن بازه
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import AppIcon from '@/components/ui/AppIcon.vue';
import HmTimePickers from '@/components/owner/HmTimePickers.vue';
import { isHmRangeValid, normalizeHmTime } from '@/utils/format';
defineProps<{
ranges: { startTime: string; endTime: string }[];
}>();
const emit = defineEmits<{
add: [start: string, end: string];
remove: [index: number];
}>();
const startH = ref('09');
const startM = ref('00');
const endH = ref('13');
const endM = ref('00');
const localError = ref('');
function toHm(h: string, m: string): string {
return `${h.padStart(2, '0')}:${m.padStart(2, '0')}`;
}
function onAdd() {
localError.value = '';
const start = normalizeHmTime(toHm(startH.value, startM.value));
const end = normalizeHmTime(toHm(endH.value, endM.value));
if (!start || !end) {
localError.value = 'فرمت ساعت نامعتبر است.';
return;
}
if (!isHmRangeValid(start, end)) {
localError.value = 'ساعت پایان باید بعد از شروع باشد.';
return;
}
emit('add', start, end);
}
</script>
+6
View File
@@ -14,7 +14,10 @@ import {
ArrowRight,
Calendar,
Camera,
ChevronDown,
ChevronUp,
CircleCheck,
Clock,
ClipboardList,
FolderOpen,
Home,
@@ -49,6 +52,9 @@ const iconMap: Record<AppIconName, Component> = {
camera: Camera,
'circle-check': CircleCheck,
search: Search,
'chevron-down': ChevronDown,
'chevron-up': ChevronUp,
clock: Clock,
};
const props = withDefaults(
+4 -1
View File
@@ -15,7 +15,10 @@ export type AppIconName =
| 'x'
| 'camera'
| 'circle-check'
| 'search';
| 'search'
| 'chevron-down'
| 'chevron-up'
| 'clock';
export type NavItem = {
to: string;
+32
View File
@@ -69,6 +69,38 @@ export function bookingStatusLabel(status: string): string {
return map[status] ?? status;
}
const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹';
const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩';
function toAsciiDigits(value: string): string {
return value.replace(/./g, (ch) => {
const pi = PERSIAN_DIGITS.indexOf(ch);
if (pi >= 0) return String(pi);
const ai = ARABIC_DIGITS.indexOf(ch);
if (ai >= 0) return String(ai);
return ch;
});
}
/** Normalizes browser/API time to HH:mm (24h) for availability API. */
export function normalizeHmTime(value: string): string | null {
const raw = toAsciiDigits(String(value ?? '').trim()).normalize('NFKC');
if (!raw) return null;
const match = /^(\d{1,2}):(\d{1,2})(?::\d{1,2})?$/.exec(raw);
if (!match) return null;
let h = Number.parseInt(match[1], 10);
const m = Number.parseInt(match[2], 10);
if (h === 24 && m === 0) return '23:59';
if (h < 0 || h > 23 || m < 0 || m > 59) return null;
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
}
export function isHmRangeValid(start: string, end: string): boolean {
const s = normalizeHmTime(start);
const e = normalizeHmTime(end);
return Boolean(s && e && s < e);
}
export function formatDuration(minutes: number | null, fallback?: number): string {
const m = minutes ?? fallback;
if (!m) {