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:
root 2026-07-03 12:45:02 +00:00
parent d4d85bc767
commit d397d0cb24
12 changed files with 530 additions and 41 deletions

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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>
);
};

View File

@ -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 (

View File

@ -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}

View File

@ -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") {

View File

@ -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;

View File

@ -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(
{ {

View File

@ -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}, Первая половина дня`,

View File

@ -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(
{ {