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> <template>
<section class="space-y-4 border-t border-border pt-6"> <section class="border-t border-border pt-4">
<div> <button
<h2 class="text-base font-semibold text-text">ساعات پذیرش آنلاین</h2> type="button"
<p class="mt-1 text-xs text-muted"> 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"
</p> @click="expanded = !expanded"
</div> >
<div class="min-w-0 flex-1">
<p v-if="error" class="text-sm text-red-600">{{ error }}</p> <div class="flex items-center gap-2">
<p v-if="message" class="text-sm text-brand-700 dark:text-brand-500">{{ message }}</p> <AppIcon name="clock" size="sm" class="text-brand-600 dark:text-brand-500" />
<h2 class="text-base font-semibold text-text">ساعات پذیرش آنلاین</h2>
<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>
<p v-if="!expanded" class="mt-1 truncate text-xs text-muted">
{{ collapsedSummary }}
</p>
</div> </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> </div>
</section> </section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { import {
fetchBusinessAvailability, fetchBusinessAvailability,
setBusinessAvailability, setBusinessAvailability,
@@ -75,44 +163,189 @@ import {
} from '@/api/availability'; } from '@/api/availability';
import { ApiError } from '@/api/client'; import { ApiError } from '@/api/client';
import AppButton from '@/components/ui/AppButton.vue'; 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 props = defineProps<{ businessId: string }>();
const weekDays = [ const weekDays = [
{ dayOfWeek: 0, label: 'شنبه' }, { dayOfWeek: 0, label: 'شنبه', short: 'ش' },
{ dayOfWeek: 1, label: 'یکشنبه' }, { dayOfWeek: 1, label: 'یکشنبه', short: 'ی' },
{ dayOfWeek: 2, label: 'دوشنبه' }, { dayOfWeek: 2, label: 'دوشنبه', short: 'د' },
{ dayOfWeek: 3, label: 'سه‌شنبه' }, { dayOfWeek: 3, label: 'سه‌شنبه', short: 'س' },
{ dayOfWeek: 4, label: 'چهارشنبه' }, { dayOfWeek: 4, label: 'چهارشنبه', short: 'چ' },
{ dayOfWeek: 5, label: 'پنج‌شنبه' }, { dayOfWeek: 5, label: 'پنج‌شنبه', short: 'پ' },
{ dayOfWeek: 6, label: 'جمعه' }, { dayOfWeek: 6, label: 'جمعه', short: 'ج' },
]; ];
const slotsByDay = ref<Record<number, AvailabilitySlot[]>>({}); type Range = { startTime: string; endTime: string };
const draft = reactive<Record<number, { start: string; end: 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 loading = ref(true);
const saving = ref(false); const saving = ref(false);
const error = ref(''); const error = ref('');
const message = 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) { for (const d of weekDays) {
draft[d.dayOfWeek] = { start: '09:00', end: '17:00' }; grouped[d.dayOfWeek] = [];
if (!slotsByDay.value[d.dayOfWeek]) { dayActive.value[d.dayOfWeek] = false;
slotsByDay.value[d.dayOfWeek] = []; 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[]) { function setMode(next: 'same' | 'custom') {
const grouped: Record<number, AvailabilitySlot[]> = {}; if (next === mode.value) return;
for (const d of weekDays) { if (next === 'same') {
grouped[d.dayOfWeek] = []; 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) { mode.value = next;
grouped[row.dayOfWeek]?.push(row); 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() { async function load() {
@@ -120,7 +353,7 @@ async function load() {
error.value = ''; error.value = '';
try { try {
const rows = await fetchBusinessAvailability(props.businessId); const rows = await fetchBusinessAvailability(props.businessId);
groupSlots(rows); detectMode(rows);
} catch (e) { } catch (e) {
error.value = e instanceof ApiError ? e.message : 'خطا در بارگذاری'; error.value = e instanceof ApiError ? e.message : 'خطا در بارگذاری';
} finally { } 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() { async function onSave() {
saving.value = true; saving.value = true;
error.value = ''; error.value = '';
message.value = ''; message.value = '';
const payload: Omit<AvailabilitySlot, 'id'>[] = []; const payload: Omit<AvailabilitySlot, 'id'>[] = [];
for (const d of weekDays) {
for (const slot of slotsByDay.value[d.dayOfWeek] ?? []) { if (mode.value === 'same') {
payload.push({ if (!sameRanges.value.length) {
dayOfWeek: slot.dayOfWeek, error.value = 'حداقل یک بازه زمانی اضافه کنید.';
startTime: slot.startTime, saving.value = false;
endTime: slot.endTime, 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 { try {
await setBusinessAvailability(props.businessId, payload); await setBusinessAvailability(props.businessId, payload);
message.value = 'ساعات پذیرش ذخیره شد.'; message.value = 'ساعات پذیرش ذخیره شد.';
@@ -168,8 +427,5 @@ async function onSave() {
} }
} }
onMounted(() => { onMounted(load);
initDraft();
load();
});
</script> </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, ArrowRight,
Calendar, Calendar,
Camera, Camera,
ChevronDown,
ChevronUp,
CircleCheck, CircleCheck,
Clock,
ClipboardList, ClipboardList,
FolderOpen, FolderOpen,
Home, Home,
@@ -49,6 +52,9 @@ const iconMap: Record<AppIconName, Component> = {
camera: Camera, camera: Camera,
'circle-check': CircleCheck, 'circle-check': CircleCheck,
search: Search, search: Search,
'chevron-down': ChevronDown,
'chevron-up': ChevronUp,
clock: Clock,
}; };
const props = withDefaults( const props = withDefaults(
+4 -1
View File
@@ -15,7 +15,10 @@ export type AppIconName =
| 'x' | 'x'
| 'camera' | 'camera'
| 'circle-check' | 'circle-check'
| 'search'; | 'search'
| 'chevron-down'
| 'chevron-up'
| 'clock';
export type NavItem = { export type NavItem = {
to: string; to: string;
+32
View File
@@ -69,6 +69,38 @@ export function bookingStatusLabel(status: string): string {
return map[status] ?? status; 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 { export function formatDuration(minutes: number | null, fallback?: number): string {
const m = minutes ?? fallback; const m = minutes ?? fallback;
if (!m) { if (!m) {