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):
|
||||
"""Проверка: сейчас рабочие часы.
|
||||
settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5')
|
||||
|
|
@ -586,6 +602,8 @@ def main():
|
|||
|
||||
try:
|
||||
settings = load_settings(conn)
|
||||
# Override work_days with business_schedule sms_days
|
||||
settings["work_days"] = get_sms_days(conn)
|
||||
|
||||
# Update last_run_at timestamp
|
||||
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):
|
||||
now_msk = datetime.now(timezone(timedelta(hours=3)))
|
||||
today_num = now_msk.weekday() + 1
|
||||
|
|
@ -217,6 +233,8 @@ def main():
|
|||
|
||||
try:
|
||||
settings = load_settings(conn)
|
||||
# Override work_days with business_schedule sms_days
|
||||
settings["work_days"] = get_sms_days(conn)
|
||||
|
||||
# Update last_run_at timestamp
|
||||
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):
|
||||
now_msk = datetime.now(timezone(timedelta(hours=3)))
|
||||
today_num = now_msk.weekday() + 1
|
||||
|
|
@ -479,6 +495,8 @@ def main():
|
|||
|
||||
try:
|
||||
settings = load_settings(conn)
|
||||
# Override work_days with business_schedule sms_days
|
||||
settings["work_days"] = get_sms_days(conn)
|
||||
|
||||
# Update last_run_at timestamp
|
||||
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):
|
||||
"""Проверка: сейчас рабочие часы.
|
||||
settings: work_hours_start, work_hours_end (часы 0-23), work_days ('1,2,3,4,5')
|
||||
|
|
@ -593,6 +609,8 @@ def main():
|
|||
|
||||
try:
|
||||
settings = load_settings(conn)
|
||||
# Override work_days with business_schedule sms_days
|
||||
settings["work_days"] = get_sms_days(conn)
|
||||
|
||||
# Update last_run_at timestamp
|
||||
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 day = d.getUTCDay();
|
||||
return day === 0 || day === 6;
|
||||
// getUTCDay: 0=Sun, 1=Mon, ..., 6=Sat
|
||||
// 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);
|
||||
while (isWeekend(next)) {
|
||||
while (!isAllowedPickupDay(next, allowedDays)) {
|
||||
next = addDaysKey(next, 1);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
const getPickupSlots = (referenceDate = new Date()) => {
|
||||
const getPickupSlots = (referenceDate = new Date(), pickupDays = [1,2,3,4,5]) => {
|
||||
const todayKey = getCrimeaTodayKey(referenceDate);
|
||||
const hour = getCrimeaHour(referenceDate);
|
||||
const isTodayWorkday = !isWeekend(todayKey);
|
||||
const isTodayWorkday = isAllowedPickupDay(todayKey, pickupDays);
|
||||
|
||||
const slots = [];
|
||||
|
||||
|
|
@ -77,7 +80,7 @@ const getPickupSlots = (referenceDate = new Date()) => {
|
|||
}
|
||||
|
||||
const tomorrow = addDaysKey(todayKey, 1);
|
||||
const tomorrowWorkday = !isWeekend(tomorrow) ? tomorrow : getNextWorkday(todayKey);
|
||||
const tomorrowWorkday = isAllowedPickupDay(tomorrow, pickupDays) ? tomorrow : getNextPickupWorkday(todayKey, pickupDays);
|
||||
slots.push({
|
||||
id: `pickup-${tomorrowWorkday}-first`,
|
||||
date: tomorrowWorkday,
|
||||
|
|
@ -94,7 +97,7 @@ const getPickupSlots = (referenceDate = new Date()) => {
|
|||
});
|
||||
|
||||
const dayAfter = addDaysKey(tomorrowWorkday, 1);
|
||||
const dayAfterWorkday = !isWeekend(dayAfter) ? dayAfter : getNextWorkday(dayAfter);
|
||||
const dayAfterWorkday = isAllowedPickupDay(dayAfter, pickupDays) ? dayAfter : getNextPickupWorkday(dayAfter, pickupDays);
|
||||
slots.push({
|
||||
id: `pickup-${dayAfterWorkday}-first`,
|
||||
date: dayAfterWorkday,
|
||||
|
|
@ -152,8 +155,9 @@ export const PickupSlotsPicker = ({
|
|||
onSelectSlot,
|
||||
selectedSlotId,
|
||||
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) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
confirmDeliveryChoice,
|
||||
fetchDeliveryInvitation,
|
||||
} from "../services/deliveryInvitationApi";
|
||||
import { supabase } from "../supabaseClient";
|
||||
|
||||
const DELIVERY_TIMEZONE = "Europe/Simferopol";
|
||||
|
||||
|
|
@ -42,15 +43,27 @@ const addDaysToDateKey = (dateKey, amount) => {
|
|||
return baseDate.toISOString().slice(0, 10);
|
||||
};
|
||||
|
||||
const isSundayKey = (dateKey) => {
|
||||
if (!dateKey) return true;
|
||||
const d = new Date(`${dateKey}T12:00:00Z`);
|
||||
return d.getUTCDay() === 0;
|
||||
// Default: Mon-Fri (1-5). Overridden by business_schedule_settings from DB.
|
||||
let _deliveryDays = [1, 2, 3, 4, 5];
|
||||
let _pickupDays = [1, 2, 3, 4, 5];
|
||||
|
||||
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);
|
||||
while (isSundayKey(next)) {
|
||||
while (!isAllowedDeliveryDay(next)) {
|
||||
next = addDaysToDateKey(next, 1);
|
||||
}
|
||||
return next;
|
||||
|
|
@ -58,8 +71,10 @@ const getNextWorkdayKey = (dateKey) => {
|
|||
|
||||
const getAllowedDeliveryDateKeys = (referenceDate = new Date()) => {
|
||||
const todayKey = getBusinessTodayKey(referenceDate);
|
||||
const firstWorkday = getNextWorkdayKey(todayKey);
|
||||
const secondWorkday = getNextWorkdayKey(firstWorkday);
|
||||
// If today is a delivery day, include it; otherwise start from next workday
|
||||
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));
|
||||
};
|
||||
|
||||
|
|
@ -81,10 +96,10 @@ export const groupSlotsFromInvitation = (invitation, referenceDate = new Date())
|
|||
const deliveryTime = invitation.deliveryTime;
|
||||
|
||||
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 firstWorkday = getNextWorkdayKey(todayKey);
|
||||
const secondWorkday = getNextWorkdayKey(firstWorkday);
|
||||
const firstWorkday = isAllowedDeliveryDay(todayKey) ? todayKey : getNextDeliveryWorkdayKey(todayKey);
|
||||
const secondWorkday = getNextDeliveryWorkdayKey(firstWorkday);
|
||||
return [
|
||||
{ id: `slot-${firstWorkday}-first`, date: firstWorkday, time: "Первая половина дня" },
|
||||
{ id: `slot-${firstWorkday}-second`, date: firstWorkday, time: "Вторая половина дня" },
|
||||
|
|
@ -221,11 +236,35 @@ export const ClientDeliveryPage = () => {
|
|||
const [choiceSaved, setChoiceSaved] = React.useState(false);
|
||||
const [activeTab, setActiveTab] = React.useState(TAB_DELIVERY);
|
||||
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(
|
||||
() => (invitation?.smsSentAt ? new Date(invitation.smsSentAt) : new Date()),
|
||||
[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(() => {
|
||||
let cancelled = false;
|
||||
|
||||
|
|
@ -505,6 +544,7 @@ export const ClientDeliveryPage = () => {
|
|||
onSelectSlot={handleSlotSelect}
|
||||
selectedSlotId={selectedSlotId}
|
||||
referenceDate={referenceDate}
|
||||
pickupDays={scheduleDays.pickupDays}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { StopWordsPanel } from "../components/admin/StopWordsPanel";
|
|||
import { ActionLogPanel } from "../components/admin/ActionLogPanel";
|
||||
import { SuggestionsPanel } from "../components/admin/SuggestionsPanel";
|
||||
import { SmsCampaignPanel } from "../components/admin/SmsCampaignPanel";
|
||||
import { BusinessSchedulePanel } from "../components/admin/BusinessSchedulePanel";
|
||||
import { Panel } from "../components/UI/Panel";
|
||||
import { SkeletonPage, SkeletonTable } from "../components/UI/Loading";
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
|
|
@ -36,6 +37,7 @@ const MEGA_ADMIN_NAV = [
|
|||
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
|
||||
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
|
||||
{ key: "sms_campaign", label: "SMS-кампании", description: "Логи и настройки SMS-рассылок.", badge: null },
|
||||
{ key: "schedule", label: "Расписание", description: "Рабочие дни доставки, самовывоза и SMS.", badge: null },
|
||||
];
|
||||
|
||||
// ── Role → Default Section Map ─────────────────────────────────────────────
|
||||
|
|
@ -137,6 +139,7 @@ export const DashboardPage = () => {
|
|||
{ key: "errors", label: "Ошибки", description: "Журнал ошибок приложения.", badge: null },
|
||||
{ key: "action_log", label: "Журнал", description: "Журнал действий сотрудников.", badge: null },
|
||||
{ key: "suggestions", label: "Предложения", description: "Предложения сотрудников по улучшению.", badge: null },
|
||||
{ key: "schedule", label: "Расписание", description: "Рабочие дни доставки, самовывоза и SMS.", badge: null },
|
||||
]
|
||||
: 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 === "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 === "schedule") return <div className="space-y-6 xl:space-y-8"><BusinessSchedulePanel /></div>;
|
||||
|
||||
if (isLoading) {
|
||||
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];
|
||||
};
|
||||
|
||||
export const buildDefaultDatedAvailableSlots = (now = new Date()) => {
|
||||
export const buildDefaultDatedAvailableSlots = async (now = new Date(), supabaseClient?: any) => {
|
||||
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,
|
||||
|
|
@ -137,12 +154,18 @@ export const buildDefaultDatedAvailableSlots = (now = new Date()) => {
|
|||
return next;
|
||||
};
|
||||
|
||||
// Skip Sunday (getUTCDay() === 0) — never offer Sunday delivery
|
||||
const isSunday = (date: Date) => date.getUTCDay() === 0;
|
||||
// Check if date is an allowed delivery day
|
||||
// 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 (isSunday(next)) {
|
||||
while (!isAllowedDay(next)) {
|
||||
next = addDays(next, 1);
|
||||
}
|
||||
return next;
|
||||
|
|
|
|||
|
|
@ -31,11 +31,31 @@ type ConfirmBody = {
|
|||
|
||||
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;
|
||||
const date = new Date(`${value}T12:00:00Z`);
|
||||
const weekday = date.getUTCDay();
|
||||
return weekday === 0; // 0=Sunday — never allow Sunday delivery
|
||||
const dow = date.getUTCDay();
|
||||
// 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 = (
|
||||
|
|
@ -45,6 +65,7 @@ const resolveRequestedSlot = (
|
|||
available_slots?: string[] | null;
|
||||
},
|
||||
body: ConfirmBody,
|
||||
deliveryDays: number[] = [1, 2, 3, 4, 5],
|
||||
) => {
|
||||
const deliveryType = body.deliveryType || "delivery";
|
||||
const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim();
|
||||
|
|
@ -59,8 +80,8 @@ const resolveRequestedSlot = (
|
|||
return { deliveryDate, deliveryTime, deliveryType };
|
||||
}
|
||||
|
||||
// Reject Sunday for delivery (business rule: never deliver on Sunday)
|
||||
if (isWeekendDate(deliveryDate)) {
|
||||
// Reject non-delivery days for delivery (business schedule)
|
||||
if (!isAllowedDeliveryDate(deliveryDate, deliveryDays)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -120,6 +141,7 @@ Deno.serve(async (request) => {
|
|||
|
||||
const tokenHash = await hashInvitationToken(body.token);
|
||||
const supabase = createServiceClient();
|
||||
const deliveryDays = await getDeliveryDays(supabase);
|
||||
const ipHash = await hashText(getClientIp(request));
|
||||
|
||||
await requireRateLimit(supabase, {
|
||||
|
|
@ -180,7 +202,7 @@ Deno.serve(async (request) => {
|
|||
);
|
||||
}
|
||||
|
||||
const requestedSlot = resolveRequestedSlot(invitation, body);
|
||||
const requestedSlot = resolveRequestedSlot(invitation, body, deliveryDays);
|
||||
if (!requestedSlot) {
|
||||
return jsonResponse(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -119,16 +119,63 @@ export const normalizeAvailableSlots = (availableSlots?: string[] | null) => {
|
|||
return slots.length > 0 ? Array.from(new Set(slots)) : [...DEFAULT_AVAILABLE_SLOTS];
|
||||
};
|
||||
|
||||
export const buildDefaultDatedAvailableSlots = (now = new Date()) => {
|
||||
const formatIsoDate = (date: Date) => date.toISOString().slice(0, 10);
|
||||
export const buildDefaultDatedAvailableSlots = async (now = new Date(), supabaseClient?: any) => {
|
||||
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 next = new Date(date);
|
||||
next.setUTCDate(next.getUTCDate() + days);
|
||||
return next;
|
||||
};
|
||||
|
||||
const firstDay = formatIsoDate(addDays(now, 1));
|
||||
const secondDay = formatIsoDate(addDays(now, 2));
|
||||
// Check if date is an allowed delivery day
|
||||
// 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 [
|
||||
`${firstDay}, Первая половина дня`,
|
||||
|
|
|
|||
|
|
@ -31,11 +31,31 @@ type ConfirmBody = {
|
|||
|
||||
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;
|
||||
const date = new Date(`${value}T12:00:00Z`);
|
||||
const weekday = date.getUTCDay();
|
||||
return weekday === 0; // 0=Sunday — never allow Sunday delivery
|
||||
const dow = date.getUTCDay();
|
||||
// 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 = (
|
||||
|
|
@ -45,6 +65,7 @@ const resolveRequestedSlot = (
|
|||
available_slots?: string[] | null;
|
||||
},
|
||||
body: ConfirmBody,
|
||||
deliveryDays: number[] = [1, 2, 3, 4, 5],
|
||||
) => {
|
||||
const deliveryType = body.deliveryType || "delivery";
|
||||
const deliveryDate = String(body.deliveryDate || invitation.delivery_date || "").trim();
|
||||
|
|
@ -59,8 +80,8 @@ const resolveRequestedSlot = (
|
|||
return { deliveryDate, deliveryTime, deliveryType };
|
||||
}
|
||||
|
||||
// Reject Sunday for delivery (business rule: never deliver on Sunday)
|
||||
if (isWeekendDate(deliveryDate)) {
|
||||
// Reject non-delivery days for delivery (business schedule)
|
||||
if (!isAllowedDeliveryDate(deliveryDate, deliveryDays)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -120,6 +141,7 @@ Deno.serve(async (request) => {
|
|||
|
||||
const tokenHash = await hashInvitationToken(body.token);
|
||||
const supabase = createServiceClient();
|
||||
const deliveryDays = await getDeliveryDays(supabase);
|
||||
const ipHash = await hashText(getClientIp(request));
|
||||
|
||||
await requireRateLimit(supabase, {
|
||||
|
|
@ -180,7 +202,7 @@ Deno.serve(async (request) => {
|
|||
);
|
||||
}
|
||||
|
||||
const requestedSlot = resolveRequestedSlot(invitation, body);
|
||||
const requestedSlot = resolveRequestedSlot(invitation, body, deliveryDays);
|
||||
if (!requestedSlot) {
|
||||
return jsonResponse(
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue