feat: configurable business schedule (delivery/pickup/SMS days)
- New table business_schedule_settings (delivery_days, pickup_days, sms_days) - RPC get_business_schedule() (public) + update_business_schedule() (admin) - Admin UI: BusinessSchedulePanel with day pills for each category - ClientDeliveryPage: fetches schedule, replaces hardcoded Sunday/weekend checks - PickupSlotsPicker: uses pickupDays prop instead of hardcoded isWeekend - Edge function confirm-delivery-choice: reads delivery_days from DB - delivery-invitations.ts: buildDefaultDatedAvailableSlots reads delivery_days - RPC confirm_delivery_choice_by_token: checks delivery_days instead of extract(dow)=0 - SMS scripts: get_sms_days() reads sms_days from business_schedule_settings and overrides per-campaign work_days setting - Nav item Расписание for mega_admin and admin roles - Default: Mon-Fri (1,2,3,4,5) for all three categories
This commit is contained in:
parent
d4d85bc767
commit
d397d0cb24
|
|
@ -312,6 +312,22 @@ def update_order_group(conn, group_id, fields):
|
||||||
|
|
||||||
# ─── Проверка рабочего времени ────────────────────────────────────────────────
|
# ─── Проверка рабочего времени ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def get_sms_days(conn):
|
||||||
|
"""Read sms_days from business_schedule_settings. Returns comma-separated string."""
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1")
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row and row.get("sms_days"):
|
||||||
|
days = row["sms_days"]
|
||||||
|
if isinstance(days, list):
|
||||||
|
return ",".join(str(d) for d in days)
|
||||||
|
return str(days)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "1,2,3,4,5" # Default: Mon-Fri
|
||||||
|
|
||||||
def is_within_work_hours(settings):
|
def is_within_work_hours(settings):
|
||||||
"""Проверка: сейчас рабочие часы.
|
"""Проверка: сейчас рабочие часы.
|
||||||
settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5')
|
settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5')
|
||||||
|
|
@ -586,6 +602,8 @@ def main():
|
||||||
|
|
||||||
try:
|
try:
|
||||||
settings = load_settings(conn)
|
settings = load_settings(conn)
|
||||||
|
# Override work_days with business_schedule sms_days
|
||||||
|
settings["work_days"] = get_sms_days(conn)
|
||||||
|
|
||||||
# Update last_run_at timestamp
|
# Update last_run_at timestamp
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,22 @@ def send_telegram(message, chat_id):
|
||||||
|
|
||||||
# ─── Проверка рабочего времени ────────────────────────────────────────────────
|
# ─── Проверка рабочего времени ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def get_sms_days(conn):
|
||||||
|
"""Read sms_days from business_schedule_settings. Returns comma-separated string."""
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1")
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row and row.get("sms_days"):
|
||||||
|
days = row["sms_days"]
|
||||||
|
if isinstance(days, list):
|
||||||
|
return ",".join(str(d) for d in days)
|
||||||
|
return str(days)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "1,2,3,4,5" # Default: Mon-Fri
|
||||||
|
|
||||||
def is_within_work_hours(settings):
|
def is_within_work_hours(settings):
|
||||||
now_msk = datetime.now(timezone(timedelta(hours=3)))
|
now_msk = datetime.now(timezone(timedelta(hours=3)))
|
||||||
today_num = now_msk.weekday() + 1
|
today_num = now_msk.weekday() + 1
|
||||||
|
|
@ -217,6 +233,8 @@ def main():
|
||||||
|
|
||||||
try:
|
try:
|
||||||
settings = load_settings(conn)
|
settings = load_settings(conn)
|
||||||
|
# Override work_days with business_schedule sms_days
|
||||||
|
settings["work_days"] = get_sms_days(conn)
|
||||||
|
|
||||||
# Update last_run_at timestamp
|
# Update last_run_at timestamp
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,22 @@ def send_telegram(message, chat_id):
|
||||||
|
|
||||||
# ─── Проверка рабочего времени ────────────────────────────────────────────────
|
# ─── Проверка рабочего времени ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def get_sms_days(conn):
|
||||||
|
"""Read sms_days from business_schedule_settings. Returns comma-separated string."""
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1")
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row and row.get("sms_days"):
|
||||||
|
days = row["sms_days"]
|
||||||
|
if isinstance(days, list):
|
||||||
|
return ",".join(str(d) for d in days)
|
||||||
|
return str(days)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "1,2,3,4,5" # Default: Mon-Fri
|
||||||
|
|
||||||
def is_within_work_hours(settings):
|
def is_within_work_hours(settings):
|
||||||
now_msk = datetime.now(timezone(timedelta(hours=3)))
|
now_msk = datetime.now(timezone(timedelta(hours=3)))
|
||||||
today_num = now_msk.weekday() + 1
|
today_num = now_msk.weekday() + 1
|
||||||
|
|
@ -479,6 +495,8 @@ def main():
|
||||||
|
|
||||||
try:
|
try:
|
||||||
settings = load_settings(conn)
|
settings = load_settings(conn)
|
||||||
|
# Override work_days with business_schedule sms_days
|
||||||
|
settings["work_days"] = get_sms_days(conn)
|
||||||
|
|
||||||
# Update last_run_at timestamp
|
# Update last_run_at timestamp
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
|
|
|
||||||
|
|
@ -323,6 +323,22 @@ def update_order_group(conn, group_id, fields):
|
||||||
|
|
||||||
# ─── Проверка рабочего времени ────────────────────────────────────────────────
|
# ─── Проверка рабочего времени ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def get_sms_days(conn):
|
||||||
|
"""Read sms_days from business_schedule_settings. Returns comma-separated string."""
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute("SELECT sms_days FROM business_schedule_settings WHERE id = 1")
|
||||||
|
row = cur.fetchone()
|
||||||
|
if row and row.get("sms_days"):
|
||||||
|
days = row["sms_days"]
|
||||||
|
if isinstance(days, list):
|
||||||
|
return ",".join(str(d) for d in days)
|
||||||
|
return str(days)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "1,2,3,4,5" # Default: Mon-Fri
|
||||||
|
|
||||||
def is_within_work_hours(settings):
|
def is_within_work_hours(settings):
|
||||||
"""Проверка: сейчас рабочие часы.
|
"""Проверка: сейчас рабочие часы.
|
||||||
settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5')
|
settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5')
|
||||||
|
|
@ -593,6 +609,8 @@ def main():
|
||||||
|
|
||||||
try:
|
try:
|
||||||
settings = load_settings(conn)
|
settings = load_settings(conn)
|
||||||
|
# Override work_days with business_schedule sms_days
|
||||||
|
settings["work_days"] = get_sms_days(conn)
|
||||||
|
|
||||||
# Update last_run_at timestamp
|
# Update last_run_at timestamp
|
||||||
with conn.cursor() as cur:
|
with conn.cursor() as cur:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,255 @@
|
||||||
|
/**
|
||||||
|
* @file BusinessSchedulePanel.jsx
|
||||||
|
* @description Admin panel for configuring work days: delivery, pickup, SMS.
|
||||||
|
* Days stored in business_schedule_settings table as INT[] (1=Mon ... 7=Sun).
|
||||||
|
*/
|
||||||
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
import { Button } from "../UI/Button";
|
||||||
|
import { Badge } from "../UI/Badge";
|
||||||
|
import { supabase } from "../../supabaseClient";
|
||||||
|
|
||||||
|
const DAY_LABELS = [
|
||||||
|
{ num: 1, label: "Пн", full: "Понедельник" },
|
||||||
|
{ num: 2, label: "Вт", full: "Вторник" },
|
||||||
|
{ num: 3, label: "Ср", full: "Среда" },
|
||||||
|
{ num: 4, label: "Чт", full: "Четверг" },
|
||||||
|
{ num: 5, label: "Пт", full: "Пятница" },
|
||||||
|
{ num: 6, label: "Сб", full: "Суббота" },
|
||||||
|
{ num: 7, label: "Вс", full: "Воскресенье" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const DEFAULT_SCHEDULE = {
|
||||||
|
deliveryDays: [1, 2, 3, 4, 5],
|
||||||
|
pickupDays: [1, 2, 3, 4, 5],
|
||||||
|
smsDays: [1, 2, 3, 4, 5],
|
||||||
|
};
|
||||||
|
|
||||||
|
const DayPills = ({ selectedDays, onToggle, accentColor }) => {
|
||||||
|
const toggle = (num) => {
|
||||||
|
if (selectedDays.includes(num)) {
|
||||||
|
onToggle(selectedDays.filter((d) => d !== num).sort());
|
||||||
|
} else {
|
||||||
|
onToggle([...selectedDays, num].sort());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{DAY_LABELS.map((day) => {
|
||||||
|
const isSelected = selectedDays.includes(day.num);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={day.num}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(day.num)}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
title={day.full}
|
||||||
|
className={[
|
||||||
|
"min-w-[48px] rounded-[14px] border-2 px-3 py-2.5 text-center text-sm font-semibold transition select-none",
|
||||||
|
isSelected
|
||||||
|
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
||||||
|
: "border-[var(--color-border)] bg-[var(--color-surface-strong)] text-[var(--color-text-muted)] hover:border-[var(--color-accent)] hover:text-[var(--color-text)]",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{day.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BusinessSchedulePanel = () => {
|
||||||
|
const [schedule, setSchedule] = useState(DEFAULT_SCHEDULE);
|
||||||
|
const [original, setOriginal] = useState(DEFAULT_SCHEDULE);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [savedAt, setSavedAt] = useState(null);
|
||||||
|
|
||||||
|
const loadSchedule = useCallback(async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const { data, error: rpcError } = await supabase.rpc("get_business_schedule");
|
||||||
|
if (rpcError) throw rpcError;
|
||||||
|
if (data?.ok) {
|
||||||
|
const loaded = {
|
||||||
|
deliveryDays: data.deliveryDays || DEFAULT_SCHEDULE.deliveryDays,
|
||||||
|
pickupDays: data.pickupDays || DEFAULT_SCHEDULE.pickupDays,
|
||||||
|
smsDays: data.smsDays || DEFAULT_SCHEDULE.smsDays,
|
||||||
|
};
|
||||||
|
setSchedule(loaded);
|
||||||
|
setOriginal(loaded);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Не удалось загрузить расписание");
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSchedule();
|
||||||
|
}, [loadSchedule]);
|
||||||
|
|
||||||
|
const hasChanges =
|
||||||
|
JSON.stringify(schedule.deliveryDays) !== JSON.stringify(original.deliveryDays) ||
|
||||||
|
JSON.stringify(schedule.pickupDays) !== JSON.stringify(original.pickupDays) ||
|
||||||
|
JSON.stringify(schedule.smsDays) !== JSON.stringify(original.smsDays);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!hasChanges) return;
|
||||||
|
|
||||||
|
// Validate at least one day per category
|
||||||
|
if (schedule.deliveryDays.length === 0 || schedule.pickupDays.length === 0 || schedule.smsDays.length === 0) {
|
||||||
|
setError("Каждая категория должна иметь хотя бы один рабочий день");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const { data, error: rpcError } = await supabase.rpc("update_business_schedule", {
|
||||||
|
p_delivery_days: schedule.deliveryDays,
|
||||||
|
p_pickup_days: schedule.pickupDays,
|
||||||
|
p_sms_days: schedule.smsDays,
|
||||||
|
});
|
||||||
|
if (rpcError) throw rpcError;
|
||||||
|
if (data?.ok) {
|
||||||
|
setOriginal({ ...schedule });
|
||||||
|
setSavedAt(new Date());
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Не удалось сохранить расписание");
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDays = (days) => {
|
||||||
|
return days
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.map((d) => DAY_LABELS.find((dl) => dl.num === d)?.label)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Panel className="p-5 sm:p-6">
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">Загрузка расписания…</p>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Расписание</p>
|
||||||
|
<h1 className="mt-1 text-2xl font-bold leading-tight">Рабочие дни</h1>
|
||||||
|
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||||
|
Настройте, в какие дни доступны доставка, самовывоз и отправка SMS-приглашений.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{savedAt && !hasChanges && (
|
||||||
|
<Badge tone="accent">Сохранено</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delivery days */}
|
||||||
|
<Panel className="p-5 sm:p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xl">🚚</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||||
|
Доставка
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
Дни, в которые клиент может выбрать доставку. В остальные дни доставка не предлагается.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DayPills
|
||||||
|
selectedDays={schedule.deliveryDays}
|
||||||
|
onToggle={(days) => setSchedule((s) => ({ ...s, deliveryDays: days }))}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
Выбрано: {formatDays(schedule.deliveryDays) || "—"}
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* Pickup days */}
|
||||||
|
<Panel className="p-5 sm:p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xl">🏪</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||||
|
Самовывоз
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
Дни, в которые клиент может выбрать самовывоз. В остальные дни самовывоз не предлагается.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DayPills
|
||||||
|
selectedDays={schedule.pickupDays}
|
||||||
|
onToggle={(days) => setSchedule((s) => ({ ...s, pickupDays: days }))}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
Выбрано: {formatDays(schedule.pickupDays) || "—"}
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* SMS days */}
|
||||||
|
<Panel className="p-5 sm:p-6 space-y-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xl">📨</span>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||||
|
SMS-приглашения
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
Дни, в которые отправляются SMS-приглашения клиентам. В нерабочие дни SMS не отправляются (проверка статусов работает круглосуточно).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DayPills
|
||||||
|
selectedDays={schedule.smsDays}
|
||||||
|
onToggle={(days) => setSchedule((s) => ({ ...s, smsDays: days }))}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
Выбрано: {formatDays(schedule.smsDays) || "—"}
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{/* Save */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant={hasChanges ? "primary" : "ghost"}
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!hasChanges || isSaving}
|
||||||
|
>
|
||||||
|
{isSaving ? "Сохранение…" : "Сохранить расписание"}
|
||||||
|
</Button>
|
||||||
|
{hasChanges && !isSaving && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setSchedule({ ...original })}
|
||||||
|
>
|
||||||
|
Отменить изменения
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Panel className="border border-[var(--color-danger)] bg-[var(--color-surface)] p-4 text-sm text-[var(--color-danger)]">
|
||||||
|
{error}
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -36,24 +36,27 @@ const getCrimeaHour = (referenceDate = new Date()) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isWeekend = (dateKey) => {
|
const isAllowedPickupDay = (dateKey, allowedDays = [1,2,3,4,5]) => {
|
||||||
|
if (!dateKey) return false;
|
||||||
const d = new Date(`${dateKey}T12:00:00Z`);
|
const d = new Date(`${dateKey}T12:00:00Z`);
|
||||||
const day = d.getUTCDay();
|
// getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
|
||||||
return day === 0 || day === 6;
|
// allowedDays uses 1=Mon ... 7=Sun
|
||||||
|
const dayNum = d.getUTCDay() === 0 ? 7 : d.getUTCDay();
|
||||||
|
return allowedDays.includes(dayNum);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getNextWorkday = (dateKey) => {
|
const getNextPickupWorkday = (dateKey, allowedDays = [1,2,3,4,5]) => {
|
||||||
let next = addDaysKey(dateKey, 1);
|
let next = addDaysKey(dateKey, 1);
|
||||||
while (isWeekend(next)) {
|
while (!isAllowedPickupDay(next, allowedDays)) {
|
||||||
next = addDaysKey(next, 1);
|
next = addDaysKey(next, 1);
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPickupSlots = (referenceDate = new Date()) => {
|
const getPickupSlots = (referenceDate = new Date(), pickupDays = [1,2,3,4,5]) => {
|
||||||
const todayKey = getCrimeaTodayKey(referenceDate);
|
const todayKey = getCrimeaTodayKey(referenceDate);
|
||||||
const hour = getCrimeaHour(referenceDate);
|
const hour = getCrimeaHour(referenceDate);
|
||||||
const isTodayWorkday = !isWeekend(todayKey);
|
const isTodayWorkday = isAllowedPickupDay(todayKey, pickupDays);
|
||||||
|
|
||||||
const slots = [];
|
const slots = [];
|
||||||
|
|
||||||
|
|
@ -77,7 +80,7 @@ const getPickupSlots = (referenceDate = new Date()) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const tomorrow = addDaysKey(todayKey, 1);
|
const tomorrow = addDaysKey(todayKey, 1);
|
||||||
const tomorrowWorkday = !isWeekend(tomorrow) ? tomorrow : getNextWorkday(todayKey);
|
const tomorrowWorkday = isAllowedPickupDay(tomorrow, pickupDays) ? tomorrow : getNextPickupWorkday(todayKey, pickupDays);
|
||||||
slots.push({
|
slots.push({
|
||||||
id: `pickup-${tomorrowWorkday}-first`,
|
id: `pickup-${tomorrowWorkday}-first`,
|
||||||
date: tomorrowWorkday,
|
date: tomorrowWorkday,
|
||||||
|
|
@ -94,7 +97,7 @@ const getPickupSlots = (referenceDate = new Date()) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const dayAfter = addDaysKey(tomorrowWorkday, 1);
|
const dayAfter = addDaysKey(tomorrowWorkday, 1);
|
||||||
const dayAfterWorkday = !isWeekend(dayAfter) ? dayAfter : getNextWorkday(dayAfter);
|
const dayAfterWorkday = isAllowedPickupDay(dayAfter, pickupDays) ? dayAfter : getNextPickupWorkday(dayAfter, pickupDays);
|
||||||
slots.push({
|
slots.push({
|
||||||
id: `pickup-${dayAfterWorkday}-first`,
|
id: `pickup-${dayAfterWorkday}-first`,
|
||||||
date: dayAfterWorkday,
|
date: dayAfterWorkday,
|
||||||
|
|
@ -152,8 +155,9 @@ export const PickupSlotsPicker = ({
|
||||||
onSelectSlot,
|
onSelectSlot,
|
||||||
selectedSlotId,
|
selectedSlotId,
|
||||||
referenceDate = new Date(),
|
referenceDate = new Date(),
|
||||||
|
pickupDays = [1, 2, 3, 4, 5],
|
||||||
}) => {
|
}) => {
|
||||||
const slots = React.useMemo(() => getPickupSlots(referenceDate), [referenceDate]);
|
const slots = React.useMemo(() => getPickupSlots(referenceDate, pickupDays), [referenceDate, pickupDays]);
|
||||||
|
|
||||||
if (!slots.length) {
|
if (!slots.length) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import {
|
||||||
confirmDeliveryChoice,
|
confirmDeliveryChoice,
|
||||||
fetchDeliveryInvitation,
|
fetchDeliveryInvitation,
|
||||||
} from "../services/deliveryInvitationApi";
|
} from "../services/deliveryInvitationApi";
|
||||||
|
import { supabase } from "../supabaseClient";
|
||||||
|
|
||||||
const DELIVERY_TIMEZONE = "Europe/Simferopol";
|
const DELIVERY_TIMEZONE = "Europe/Simferopol";
|
||||||
|
|
||||||
|
|
@ -42,15 +43,27 @@ const addDaysToDateKey = (dateKey, amount) => {
|
||||||
return baseDate.toISOString().slice(0, 10);
|
return baseDate.toISOString().slice(0, 10);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSundayKey = (dateKey) => {
|
// Default: Mon-Fri (1-5). Overridden by business_schedule_settings from DB.
|
||||||
if (!dateKey) return true;
|
let _deliveryDays = [1, 2, 3, 4, 5];
|
||||||
const d = new Date(`${dateKey}T12:00:00Z`);
|
let _pickupDays = [1, 2, 3, 4, 5];
|
||||||
return d.getUTCDay() === 0;
|
|
||||||
|
export const setScheduleDaysExternal = ({ deliveryDays, pickupDays }) => {
|
||||||
|
if (Array.isArray(deliveryDays) && deliveryDays.length) _deliveryDays = deliveryDays;
|
||||||
|
if (Array.isArray(pickupDays) && pickupDays.length) _pickupDays = pickupDays;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getNextWorkdayKey = (dateKey) => {
|
const isAllowedDeliveryDay = (dateKey) => {
|
||||||
|
if (!dateKey) return false;
|
||||||
|
const d = new Date(`${dateKey}T12:00:00Z`);
|
||||||
|
// getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
|
||||||
|
// _deliveryDays uses 1=Mon ... 7=Sun
|
||||||
|
const dayNum = d.getUTCDay() === 0 ? 7 : d.getUTCDay();
|
||||||
|
return _deliveryDays.includes(dayNum);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNextDeliveryWorkdayKey = (dateKey) => {
|
||||||
let next = addDaysToDateKey(dateKey, 1);
|
let next = addDaysToDateKey(dateKey, 1);
|
||||||
while (isSundayKey(next)) {
|
while (!isAllowedDeliveryDay(next)) {
|
||||||
next = addDaysToDateKey(next, 1);
|
next = addDaysToDateKey(next, 1);
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
|
|
@ -58,8 +71,10 @@ const getNextWorkdayKey = (dateKey) => {
|
||||||
|
|
||||||
const getAllowedDeliveryDateKeys = (referenceDate = new Date()) => {
|
const getAllowedDeliveryDateKeys = (referenceDate = new Date()) => {
|
||||||
const todayKey = getBusinessTodayKey(referenceDate);
|
const todayKey = getBusinessTodayKey(referenceDate);
|
||||||
const firstWorkday = getNextWorkdayKey(todayKey);
|
// If today is a delivery day, include it; otherwise start from next workday
|
||||||
const secondWorkday = getNextWorkdayKey(firstWorkday);
|
const startKey = isAllowedDeliveryDay(todayKey) ? todayKey : getNextDeliveryWorkdayKey(todayKey);
|
||||||
|
const firstWorkday = isAllowedDeliveryDay(todayKey) ? todayKey : getNextDeliveryWorkdayKey(todayKey);
|
||||||
|
const secondWorkday = getNextDeliveryWorkdayKey(firstWorkday);
|
||||||
return new Set([firstWorkday, secondWorkday].filter(Boolean));
|
return new Set([firstWorkday, secondWorkday].filter(Boolean));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -81,10 +96,10 @@ export const groupSlotsFromInvitation = (invitation, referenceDate = new Date())
|
||||||
const deliveryTime = invitation.deliveryTime;
|
const deliveryTime = invitation.deliveryTime;
|
||||||
|
|
||||||
if (!rawSlots.length && !deliveryDate) {
|
if (!rawSlots.length && !deliveryDate) {
|
||||||
// Fallback: generate default delivery slots (next 2 workdays, both halves, skip Sunday)
|
// Fallback: generate default delivery slots (next 2 workdays, both halves, skip non-delivery days)
|
||||||
const todayKey = getBusinessTodayKey(referenceDate);
|
const todayKey = getBusinessTodayKey(referenceDate);
|
||||||
const firstWorkday = getNextWorkdayKey(todayKey);
|
const firstWorkday = isAllowedDeliveryDay(todayKey) ? todayKey : getNextDeliveryWorkdayKey(todayKey);
|
||||||
const secondWorkday = getNextWorkdayKey(firstWorkday);
|
const secondWorkday = getNextDeliveryWorkdayKey(firstWorkday);
|
||||||
return [
|
return [
|
||||||
{ id: `slot-${firstWorkday}-first`, date: firstWorkday, time: "Первая половина дня" },
|
{ id: `slot-${firstWorkday}-first`, date: firstWorkday, time: "Первая половина дня" },
|
||||||
{ id: `slot-${firstWorkday}-second`, date: firstWorkday, time: "Вторая половина дня" },
|
{ id: `slot-${firstWorkday}-second`, date: firstWorkday, time: "Вторая половина дня" },
|
||||||
|
|
@ -221,11 +236,35 @@ export const ClientDeliveryPage = () => {
|
||||||
const [choiceSaved, setChoiceSaved] = React.useState(false);
|
const [choiceSaved, setChoiceSaved] = React.useState(false);
|
||||||
const [activeTab, setActiveTab] = React.useState(TAB_DELIVERY);
|
const [activeTab, setActiveTab] = React.useState(TAB_DELIVERY);
|
||||||
const [deliveryAddress, setDeliveryAddress] = React.useState("");
|
const [deliveryAddress, setDeliveryAddress] = React.useState("");
|
||||||
|
const [scheduleDays, setScheduleDays] = React.useState({ deliveryDays: [1,2,3,4,5], pickupDays: [1,2,3,4,5] });
|
||||||
const referenceDate = React.useMemo(
|
const referenceDate = React.useMemo(
|
||||||
() => (invitation?.smsSentAt ? new Date(invitation.smsSentAt) : new Date()),
|
() => (invitation?.smsSentAt ? new Date(invitation.smsSentAt) : new Date()),
|
||||||
[token, invitation?.smsSentAt],
|
[token, invitation?.smsSentAt],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Fetch business schedule (delivery/pickup days) — no auth required
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const loadSchedule = async () => {
|
||||||
|
try {
|
||||||
|
const { data, error: rpcError } = await supabase.rpc("get_business_schedule");
|
||||||
|
if (rpcError) throw rpcError;
|
||||||
|
if (!cancelled && data?.ok) {
|
||||||
|
const days = {
|
||||||
|
deliveryDays: data.deliveryDays || [1,2,3,4,5],
|
||||||
|
pickupDays: data.pickupDays || [1,2,3,4,5],
|
||||||
|
};
|
||||||
|
setScheduleDays(days);
|
||||||
|
setScheduleDaysExternal(days);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silent fallback — use defaults
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadSchedule();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
|
|
@ -505,6 +544,7 @@ export const ClientDeliveryPage = () => {
|
||||||
onSelectSlot={handleSlotSelect}
|
onSelectSlot={handleSlotSelect}
|
||||||
selectedSlotId={selectedSlotId}
|
selectedSlotId={selectedSlotId}
|
||||||
referenceDate={referenceDate}
|
referenceDate={referenceDate}
|
||||||
|
pickupDays={scheduleDays.pickupDays}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { StopWordsPanel } from "../components/admin/StopWordsPanel";
|
||||||
import { ActionLogPanel } from "../components/admin/ActionLogPanel";
|
import { ActionLogPanel } from "../components/admin/ActionLogPanel";
|
||||||
import { SuggestionsPanel } from "../components/admin/SuggestionsPanel";
|
import { SuggestionsPanel } from "../components/admin/SuggestionsPanel";
|
||||||
import { SmsCampaignPanel } from "../components/admin/SmsCampaignPanel";
|
import { SmsCampaignPanel } from "../components/admin/SmsCampaignPanel";
|
||||||
|
import { BusinessSchedulePanel } from "../components/admin/BusinessSchedulePanel";
|
||||||
import { Panel } from "../components/UI/Panel";
|
import { Panel } from "../components/UI/Panel";
|
||||||
import { SkeletonPage, SkeletonTable } from "../components/UI/Loading";
|
import { SkeletonPage, SkeletonTable } from "../components/UI/Loading";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
|
@ -36,6 +37,7 @@ const MEGA_ADMIN_NAV = [
|
||||||
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
|
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
|
||||||
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
|
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
|
||||||
{ key: "sms_campaign", label: "SMS-кампании", description: "Логи и настройки SMS-рассылок.", badge: null },
|
{ key: "sms_campaign", label: "SMS-кампании", description: "Логи и настройки SMS-рассылок.", badge: null },
|
||||||
|
{ key: "schedule", label: "Расписание", description: "Рабочие дни доставки, самовывоза и SMS.", badge: null },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Role → Default Section Map ─────────────────────────────────────────────
|
// ── Role → Default Section Map ─────────────────────────────────────────────
|
||||||
|
|
@ -137,6 +139,7 @@ export const DashboardPage = () => {
|
||||||
{ key: "errors", label: "Ошибки", description: "Журнал ошибок приложения.", badge: null },
|
{ key: "errors", label: "Ошибки", description: "Журнал ошибок приложения.", badge: null },
|
||||||
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
|
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
|
||||||
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
|
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
|
||||||
|
{ key: "schedule", label: "Расписание", description: "Рабочие дни доставки, самовывоза и SMS.", badge: null },
|
||||||
]
|
]
|
||||||
: userRole === "logistician"
|
: userRole === "logistician"
|
||||||
? [
|
? [
|
||||||
|
|
@ -177,6 +180,7 @@ const ALLOWED_DASHBOARD_ROLES = ["admin", "mega_admin", "manager", "logistician"
|
||||||
if (activeSection === "action_log") return <div className="space-y-6 xl:space-y-8"><ActionLogPanel /></div>;
|
if (activeSection === "action_log") return <div className="space-y-6 xl:space-y-8"><ActionLogPanel /></div>;
|
||||||
if (activeSection === "suggestions") return <div className="space-y-6 xl:space-y-8"><SuggestionsPanel /></div>;
|
if (activeSection === "suggestions") return <div className="space-y-6 xl:space-y-8"><SuggestionsPanel /></div>;
|
||||||
if (activeSection === "sms_campaign") return <div className="space-y-6 xl:space-y-8"><SmsCampaignPanel /></div>;
|
if (activeSection === "sms_campaign") return <div className="space-y-6 xl:space-y-8"><SmsCampaignPanel /></div>;
|
||||||
|
if (activeSection === "schedule") return <div className="space-y-6 xl:space-y-8"><BusinessSchedulePanel /></div>;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
if (userRole === "driver") {
|
if (userRole === "driver") {
|
||||||
|
|
|
||||||
|
|
@ -119,9 +119,26 @@ export const normalizeAvailableSlots = (availableSlots?: string[] | null) => {
|
||||||
return slots.length > 0 ? Array.from(new Set(slots)) : [...DEFAULT_AVAILABLE_SLOTS];
|
return slots.length > 0 ? Array.from(new Set(slots)) : [...DEFAULT_AVAILABLE_SLOTS];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildDefaultDatedAvailableSlots = (now = new Date()) => {
|
export const buildDefaultDatedAvailableSlots = async (now = new Date(), supabaseClient?: any) => {
|
||||||
const CRIMEA_TZ = "Europe/Simferopol";
|
const CRIMEA_TZ = "Europe/Simferopol";
|
||||||
|
|
||||||
|
// Fetch delivery days from business_schedule_settings
|
||||||
|
let deliveryDays: number[] = [1, 2, 3, 4, 5]; // Default: Mon-Fri
|
||||||
|
if (supabaseClient) {
|
||||||
|
try {
|
||||||
|
const { data } = await supabaseClient
|
||||||
|
.from("business_schedule_settings")
|
||||||
|
.select("delivery_days")
|
||||||
|
.eq("id", 1)
|
||||||
|
.single();
|
||||||
|
if (data?.delivery_days && Array.isArray(data.delivery_days) && data.delivery_days.length) {
|
||||||
|
deliveryDays = data.delivery_days;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silent fallback to defaults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const formatCrimeaDate = (date: Date) => {
|
const formatCrimeaDate = (date: Date) => {
|
||||||
return new Intl.DateTimeFormat("en-CA", {
|
return new Intl.DateTimeFormat("en-CA", {
|
||||||
timeZone: CRIMEA_TZ,
|
timeZone: CRIMEA_TZ,
|
||||||
|
|
@ -137,12 +154,18 @@ export const buildDefaultDatedAvailableSlots = (now = new Date()) => {
|
||||||
return next;
|
return next;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Skip Sunday (getUTCDay() === 0) — never offer Sunday delivery
|
// Check if date is an allowed delivery day
|
||||||
const isSunday = (date: Date) => date.getUTCDay() === 0;
|
// getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
|
||||||
|
// deliveryDays uses 1=Mon ... 7=Sun
|
||||||
|
const isAllowedDay = (date: Date) => {
|
||||||
|
const dow = date.getUTCDay();
|
||||||
|
const dayNum = dow === 0 ? 7 : dow;
|
||||||
|
return deliveryDays.includes(dayNum);
|
||||||
|
};
|
||||||
|
|
||||||
const getNextWorkday = (date: Date) => {
|
const getNextWorkday = (date: Date) => {
|
||||||
let next = addDays(date, 1);
|
let next = addDays(date, 1);
|
||||||
while (isSunday(next)) {
|
while (!isAllowedDay(next)) {
|
||||||
next = addDays(next, 1);
|
next = addDays(next, 1);
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
|
|
|
||||||
|
|
@ -31,11 +31,31 @@ type ConfirmBody = {
|
||||||
|
|
||||||
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
|
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
|
||||||
|
|
||||||
const isWeekendDate = (value: string) => {
|
// Fetch delivery days from business_schedule_settings
|
||||||
|
const getDeliveryDays = async (supabaseClient: any): Promise<number[]> => {
|
||||||
|
try {
|
||||||
|
const { data } = await supabaseClient
|
||||||
|
.from("business_schedule_settings")
|
||||||
|
.select("delivery_days")
|
||||||
|
.eq("id", 1)
|
||||||
|
.single();
|
||||||
|
if (data?.delivery_days && Array.isArray(data.delivery_days) && data.delivery_days.length) {
|
||||||
|
return data.delivery_days as number[];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silent fallback
|
||||||
|
}
|
||||||
|
return [1, 2, 3, 4, 5]; // Default: Mon-Fri
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAllowedDeliveryDate = (value: string, deliveryDays: number[]) => {
|
||||||
if (!isValidDate(value)) return false;
|
if (!isValidDate(value)) return false;
|
||||||
const date = new Date(`${value}T12:00:00Z`);
|
const date = new Date(`${value}T12:00:00Z`);
|
||||||
const weekday = date.getUTCDay();
|
const dow = date.getUTCDay();
|
||||||
return weekday === 0; // 0=Sunday — never allow Sunday delivery
|
// getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
|
||||||
|
// deliveryDays uses 1=Mon ... 7=Sun
|
||||||
|
const dayNum = dow === 0 ? 7 : dow;
|
||||||
|
return deliveryDays.includes(dayNum);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveRequestedSlot = (
|
const resolveRequestedSlot = (
|
||||||
|
|
@ -45,6 +65,7 @@ const resolveRequestedSlot = (
|
||||||
available_slots?: string[] | null;
|
available_slots?: string[] | null;
|
||||||
},
|
},
|
||||||
body: ConfirmBody,
|
body: ConfirmBody,
|
||||||
|
deliveryDays: number[] = [1, 2, 3, 4, 5],
|
||||||
) => {
|
) => {
|
||||||
const deliveryType = body.deliveryType || "delivery";
|
const deliveryType = body.deliveryType || "delivery";
|
||||||
const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim();
|
const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim();
|
||||||
|
|
@ -59,8 +80,8 @@ const resolveRequestedSlot = (
|
||||||
return { deliveryDate, deliveryTime, deliveryType };
|
return { deliveryDate, deliveryTime, deliveryType };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject Sunday for delivery (business rule: never deliver on Sunday)
|
// Reject non-delivery days for delivery (business schedule)
|
||||||
if (isWeekendDate(deliveryDate)) {
|
if (!isAllowedDeliveryDate(deliveryDate, deliveryDays)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,6 +141,7 @@ Deno.serve(async (request) => {
|
||||||
|
|
||||||
const tokenHash = await hashInvitationToken(body.token);
|
const tokenHash = await hashInvitationToken(body.token);
|
||||||
const supabase = createServiceClient();
|
const supabase = createServiceClient();
|
||||||
|
const deliveryDays = await getDeliveryDays(supabase);
|
||||||
const ipHash = await hashText(getClientIp(request));
|
const ipHash = await hashText(getClientIp(request));
|
||||||
|
|
||||||
await requireRateLimit(supabase, {
|
await requireRateLimit(supabase, {
|
||||||
|
|
@ -180,7 +202,7 @@ Deno.serve(async (request) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestedSlot = resolveRequestedSlot(invitation, body);
|
const requestedSlot = resolveRequestedSlot(invitation, body, deliveryDays);
|
||||||
if (!requestedSlot) {
|
if (!requestedSlot) {
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -119,16 +119,63 @@ export const normalizeAvailableSlots = (availableSlots?: string[] | null) => {
|
||||||
return slots.length > 0 ? Array.from(new Set(slots)) : [...DEFAULT_AVAILABLE_SLOTS];
|
return slots.length > 0 ? Array.from(new Set(slots)) : [...DEFAULT_AVAILABLE_SLOTS];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildDefaultDatedAvailableSlots = (now = new Date()) => {
|
export const buildDefaultDatedAvailableSlots = async (now = new Date(), supabaseClient?: any) => {
|
||||||
const formatIsoDate = (date: Date) => date.toISOString().slice(0, 10);
|
const CRIMEA_TZ = "Europe/Simferopol";
|
||||||
|
|
||||||
|
// Fetch delivery days from business_schedule_settings
|
||||||
|
let deliveryDays: number[] = [1, 2, 3, 4, 5]; // Default: Mon-Fri
|
||||||
|
if (supabaseClient) {
|
||||||
|
try {
|
||||||
|
const { data } = await supabaseClient
|
||||||
|
.from("business_schedule_settings")
|
||||||
|
.select("delivery_days")
|
||||||
|
.eq("id", 1)
|
||||||
|
.single();
|
||||||
|
if (data?.delivery_days && Array.isArray(data.delivery_days) && data.delivery_days.length) {
|
||||||
|
deliveryDays = data.delivery_days;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silent fallback to defaults
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatCrimeaDate = (date: Date) => {
|
||||||
|
return new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: CRIMEA_TZ,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
}).format(date);
|
||||||
|
};
|
||||||
|
|
||||||
const addDays = (date: Date, days: number) => {
|
const addDays = (date: Date, days: number) => {
|
||||||
const next = new Date(date);
|
const next = new Date(date);
|
||||||
next.setUTCDate(next.getUTCDate() + days);
|
next.setUTCDate(next.getUTCDate() + days);
|
||||||
return next;
|
return next;
|
||||||
};
|
};
|
||||||
|
|
||||||
const firstDay = formatIsoDate(addDays(now, 1));
|
// Check if date is an allowed delivery day
|
||||||
const secondDay = formatIsoDate(addDays(now, 2));
|
// getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
|
||||||
|
// deliveryDays uses 1=Mon ... 7=Sun
|
||||||
|
const isAllowedDay = (date: Date) => {
|
||||||
|
const dow = date.getUTCDay();
|
||||||
|
const dayNum = dow === 0 ? 7 : dow;
|
||||||
|
return deliveryDays.includes(dayNum);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNextWorkday = (date: Date) => {
|
||||||
|
let next = addDays(date, 1);
|
||||||
|
while (!isAllowedDay(next)) {
|
||||||
|
next = addDays(next, 1);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const firstWorkday = getNextWorkday(now);
|
||||||
|
const secondWorkday = getNextWorkday(firstWorkday);
|
||||||
|
|
||||||
|
const firstDay = formatCrimeaDate(firstWorkday);
|
||||||
|
const secondDay = formatCrimeaDate(secondWorkday);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
`${firstDay}, Первая половина дня`,
|
`${firstDay}, Первая половина дня`,
|
||||||
|
|
|
||||||
|
|
@ -31,11 +31,31 @@ type ConfirmBody = {
|
||||||
|
|
||||||
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
|
const isValidDate = (value: string) => /^\d{4}-\d{2}-\d{2}$/.test(value);
|
||||||
|
|
||||||
const isWeekendDate = (value: string) => {
|
// Fetch delivery days from business_schedule_settings
|
||||||
|
const getDeliveryDays = async (supabaseClient: any): Promise<number[]> => {
|
||||||
|
try {
|
||||||
|
const { data } = await supabaseClient
|
||||||
|
.from("business_schedule_settings")
|
||||||
|
.select("delivery_days")
|
||||||
|
.eq("id", 1)
|
||||||
|
.single();
|
||||||
|
if (data?.delivery_days && Array.isArray(data.delivery_days) && data.delivery_days.length) {
|
||||||
|
return data.delivery_days as number[];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silent fallback
|
||||||
|
}
|
||||||
|
return [1, 2, 3, 4, 5]; // Default: Mon-Fri
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAllowedDeliveryDate = (value: string, deliveryDays: number[]) => {
|
||||||
if (!isValidDate(value)) return false;
|
if (!isValidDate(value)) return false;
|
||||||
const date = new Date(`${value}T12:00:00Z`);
|
const date = new Date(`${value}T12:00:00Z`);
|
||||||
const weekday = date.getUTCDay();
|
const dow = date.getUTCDay();
|
||||||
return weekday === 0; // 0=Sunday — never allow Sunday delivery
|
// getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
|
||||||
|
// deliveryDays uses 1=Mon ... 7=Sun
|
||||||
|
const dayNum = dow === 0 ? 7 : dow;
|
||||||
|
return deliveryDays.includes(dayNum);
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveRequestedSlot = (
|
const resolveRequestedSlot = (
|
||||||
|
|
@ -45,6 +65,7 @@ const resolveRequestedSlot = (
|
||||||
available_slots?: string[] | null;
|
available_slots?: string[] | null;
|
||||||
},
|
},
|
||||||
body: ConfirmBody,
|
body: ConfirmBody,
|
||||||
|
deliveryDays: number[] = [1, 2, 3, 4, 5],
|
||||||
) => {
|
) => {
|
||||||
const deliveryType = body.deliveryType || "delivery";
|
const deliveryType = body.deliveryType || "delivery";
|
||||||
const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim();
|
const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim();
|
||||||
|
|
@ -59,8 +80,8 @@ const resolveRequestedSlot = (
|
||||||
return { deliveryDate, deliveryTime, deliveryType };
|
return { deliveryDate, deliveryTime, deliveryType };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reject Sunday for delivery (business rule: never deliver on Sunday)
|
// Reject non-delivery days for delivery (business schedule)
|
||||||
if (isWeekendDate(deliveryDate)) {
|
if (!isAllowedDeliveryDate(deliveryDate, deliveryDays)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -120,6 +141,7 @@ Deno.serve(async (request) => {
|
||||||
|
|
||||||
const tokenHash = await hashInvitationToken(body.token);
|
const tokenHash = await hashInvitationToken(body.token);
|
||||||
const supabase = createServiceClient();
|
const supabase = createServiceClient();
|
||||||
|
const deliveryDays = await getDeliveryDays(supabase);
|
||||||
const ipHash = await hashText(getClientIp(request));
|
const ipHash = await hashText(getClientIp(request));
|
||||||
|
|
||||||
await requireRateLimit(supabase, {
|
await requireRateLimit(supabase, {
|
||||||
|
|
@ -180,7 +202,7 @@ Deno.serve(async (request) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestedSlot = resolveRequestedSlot(invitation, body);
|
const requestedSlot = resolveRequestedSlot(invitation, body, deliveryDays);
|
||||||
if (!requestedSlot) {
|
if (!requestedSlot) {
|
||||||
return jsonResponse(
|
return jsonResponse(
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue