edit time rezerv
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -15,7 +15,10 @@ export type AppIconName =
|
||||
| 'x'
|
||||
| 'camera'
|
||||
| 'circle-check'
|
||||
| 'search';
|
||||
| 'search'
|
||||
| 'chevron-down'
|
||||
| 'chevron-up'
|
||||
| 'clock';
|
||||
|
||||
export type NavItem = {
|
||||
to: string;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user