From d397d0cb247f65585229100cf257555fd306502f Mon Sep 17 00:00:00 2001 From: root Date: Fri, 3 Jul 2026 12:45:02 +0000 Subject: [PATCH] feat: configurable business schedule (delivery/pickup/SMS days) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- scripts/sms_first_campaign.py | 18 ++ scripts/sms_manual_campaign.py | 18 ++ scripts/sms_paid_storage_campaign.py | 18 ++ scripts/sms_second_campaign.py | 18 ++ .../admin/BusinessSchedulePanel.jsx | 255 ++++++++++++++++++ src/components/client/PickupSlotsPicker.jsx | 24 +- src/pages/ClientDeliveryPage.jsx | 62 ++++- src/pages/DashboardPage.jsx | 4 + .../functions/_shared/delivery-invitations.ts | 31 ++- .../confirm-delivery-choice/index.ts | 34 ++- .../functions/_shared/delivery-invitations.ts | 55 +++- .../confirm-delivery-choice/index.ts | 34 ++- 12 files changed, 530 insertions(+), 41 deletions(-) create mode 100644 src/components/admin/BusinessSchedulePanel.jsx diff --git a/scripts/sms_first_campaign.py b/scripts/sms_first_campaign.py index f6fe219..0eb3a13 100644 --- a/scripts/sms_first_campaign.py +++ b/scripts/sms_first_campaign.py @@ -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: diff --git a/scripts/sms_manual_campaign.py b/scripts/sms_manual_campaign.py index 446fafe..0cb8190 100644 --- a/scripts/sms_manual_campaign.py +++ b/scripts/sms_manual_campaign.py @@ -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: diff --git a/scripts/sms_paid_storage_campaign.py b/scripts/sms_paid_storage_campaign.py index b22edaa..0d3ebd4 100644 --- a/scripts/sms_paid_storage_campaign.py +++ b/scripts/sms_paid_storage_campaign.py @@ -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: diff --git a/scripts/sms_second_campaign.py b/scripts/sms_second_campaign.py index 9e7e931..fdb3f29 100644 --- a/scripts/sms_second_campaign.py +++ b/scripts/sms_second_campaign.py @@ -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: diff --git a/src/components/admin/BusinessSchedulePanel.jsx b/src/components/admin/BusinessSchedulePanel.jsx new file mode 100644 index 0000000..76bc703 --- /dev/null +++ b/src/components/admin/BusinessSchedulePanel.jsx @@ -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 ( +
+ {DAY_LABELS.map((day) => { + const isSelected = selectedDays.includes(day.num); + return ( + + ); + })} +
+ ); +}; + +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 ( + +

Загрузка расписания…

+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Расписание

+

Рабочие дни

+

+ Настройте, в какие дни доступны доставка, самовывоз и отправка SMS-приглашений. +

+
+ {savedAt && !hasChanges && ( + Сохранено + )} +
+ + {/* Delivery days */} + +
+ 🚚 +
+

+ Доставка +

+

+ Дни, в которые клиент может выбрать доставку. В остальные дни доставка не предлагается. +

+
+
+ setSchedule((s) => ({ ...s, deliveryDays: days }))} + /> +

+ Выбрано: {formatDays(schedule.deliveryDays) || "—"} +

+
+ + {/* Pickup days */} + +
+ 🏪 +
+

+ Самовывоз +

+

+ Дни, в которые клиент может выбрать самовывоз. В остальные дни самовывоз не предлагается. +

+
+
+ setSchedule((s) => ({ ...s, pickupDays: days }))} + /> +

+ Выбрано: {formatDays(schedule.pickupDays) || "—"} +

+
+ + {/* SMS days */} + +
+ 📨 +
+

+ SMS-приглашения +

+

+ Дни, в которые отправляются SMS-приглашения клиентам. В нерабочие дни SMS не отправляются (проверка статусов работает круглосуточно). +

+
+
+ setSchedule((s) => ({ ...s, smsDays: days }))} + /> +

+ Выбрано: {formatDays(schedule.smsDays) || "—"} +

+
+ + {/* Save */} +
+ + {hasChanges && !isSaving && ( + + )} +
+ + {error && ( + + {error} + + )} +
+ ); +}; \ No newline at end of file diff --git a/src/components/client/PickupSlotsPicker.jsx b/src/components/client/PickupSlotsPicker.jsx index 7690bb8..c5976b6 100644 --- a/src/components/client/PickupSlotsPicker.jsx +++ b/src/components/client/PickupSlotsPicker.jsx @@ -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 ( diff --git a/src/pages/ClientDeliveryPage.jsx b/src/pages/ClientDeliveryPage.jsx index 6f4f4ee..00c8b77 100644 --- a/src/pages/ClientDeliveryPage.jsx +++ b/src/pages/ClientDeliveryPage.jsx @@ -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} diff --git a/src/pages/DashboardPage.jsx b/src/pages/DashboardPage.jsx index 990e665..974c5e3 100644 --- a/src/pages/DashboardPage.jsx +++ b/src/pages/DashboardPage.jsx @@ -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
; if (activeSection === "suggestions") return
; if (activeSection === "sms_campaign") return
; + if (activeSection === "schedule") return
; if (isLoading) { if (userRole === "driver") { diff --git a/supabase/functions/_shared/delivery-invitations.ts b/supabase/functions/_shared/delivery-invitations.ts index a7fc938..9458ce3 100644 --- a/supabase/functions/_shared/delivery-invitations.ts +++ b/supabase/functions/_shared/delivery-invitations.ts @@ -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; diff --git a/supabase/functions/confirm-delivery-choice/index.ts b/supabase/functions/confirm-delivery-choice/index.ts index 33b29eb..65a5d78 100644 --- a/supabase/functions/confirm-delivery-choice/index.ts +++ b/supabase/functions/confirm-delivery-choice/index.ts @@ -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 => { + 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( { diff --git a/volumes/functions/_shared/delivery-invitations.ts b/volumes/functions/_shared/delivery-invitations.ts index 70e7ade..9458ce3 100644 --- a/volumes/functions/_shared/delivery-invitations.ts +++ b/volumes/functions/_shared/delivery-invitations.ts @@ -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}, Первая половина дня`, diff --git a/volumes/functions/confirm-delivery-choice/index.ts b/volumes/functions/confirm-delivery-choice/index.ts index 33b29eb..65a5d78 100644 --- a/volumes/functions/confirm-delivery-choice/index.ts +++ b/volumes/functions/confirm-delivery-choice/index.ts @@ -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 => { + 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( {