From de4ecaad27988e305bfeecb7674d58be6865dbeb Mon Sep 17 00:00:00 2001 From: root Date: Tue, 21 Jul 2026 14:58:11 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20SMS=20city=20rules=20=E2=80=94=20=D0=B3?= =?UTF-8?q?=D0=B8=D0=B1=D0=BA=D0=BE=D0=B5=20=D1=83=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20SMS=20=D0=BF=D0=BE=20=D0=B3?= =?UTF-8?q?=D0=BE=D1=80=D0=BE=D0=B4=D0=B0=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DB: sms_city_rules table + sms_allowed_for_city() function + RLS - Python: фильтр sms_allowed_for_city в first_sms, second_sms, paid_storage - Frontend: SmsCityRulesPanel — toggle on/off, период дат, комментарии - Интеграция в SmsCampaignPanel как вкладка 'SMS по городам' - Поддержка '*' (все города / все кампании) --- scripts/sms_first_campaign.py | 1 + scripts/sms_paid_storage_campaign.py | 1 + scripts/sms_second_campaign.py | 1 + src/components/admin/SmsCampaignPanel.jsx | 27 +- src/components/admin/SmsCityRulesPanel.jsx | 337 +++++++++++++++++++++ 5 files changed, 364 insertions(+), 3 deletions(-) create mode 100644 src/components/admin/SmsCityRulesPanel.jsx diff --git a/scripts/sms_first_campaign.py b/scripts/sms_first_campaign.py index 129e473..f495ace 100644 --- a/scripts/sms_first_campaign.py +++ b/scripts/sms_first_campaign.py @@ -184,6 +184,7 @@ def get_groups_to_send(conn): AND og.delivery_link IS NOT NULL AND og.delivery_link != '' AND COALESCE(og.notification_status, '') = 'link_ready' + AND sms_allowed_for_city(og.town, 'first_sms') -- Нет активной SMS в логе (sent/checking) за последние max_check_duration минут AND NOT EXISTS ( SELECT 1 FROM sms_campaign_log scl diff --git a/scripts/sms_paid_storage_campaign.py b/scripts/sms_paid_storage_campaign.py index 4abb2e9..c5df2e8 100644 --- a/scripts/sms_paid_storage_campaign.py +++ b/scripts/sms_paid_storage_campaign.py @@ -185,6 +185,7 @@ def get_groups_to_send(conn): AND COALESCE(og.notification_status, '') NOT IN ('paid_storage_sending', 'paid_storage_sent') AND og.delivery_link IS NOT NULL AND og.delivery_link != '' + AND sms_allowed_for_city(og.town, 'paid_storage') AND NOT EXISTS ( SELECT 1 FROM sms_campaign_log scl WHERE scl.order_group_id = og.id diff --git a/scripts/sms_second_campaign.py b/scripts/sms_second_campaign.py index 5d61ef3..e746318 100644 --- a/scripts/sms_second_campaign.py +++ b/scripts/sms_second_campaign.py @@ -206,6 +206,7 @@ def get_groups_to_send(conn): AND COALESCE(og.notification_status, '') = 'first_sms_sent' AND og.second_sms_sent_at IS NULL AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW()) + AND sms_allowed_for_city(og.town, 'second_sms') -- Нет активной второй SMS в логе AND NOT EXISTS ( SELECT 1 FROM sms_campaign_log scl diff --git a/src/components/admin/SmsCampaignPanel.jsx b/src/components/admin/SmsCampaignPanel.jsx index 8f0a1b8..e2ea79e 100644 --- a/src/components/admin/SmsCampaignPanel.jsx +++ b/src/components/admin/SmsCampaignPanel.jsx @@ -9,6 +9,7 @@ import { Panel } from "../UI/Panel"; import { Badge } from "../UI/Badge"; import { supabase } from "../../supabaseClient"; import { SmsCampaignStats } from "./SmsCampaignStats"; +import { SmsCityRulesPanel } from "./SmsCityRulesPanel"; // ── Campaigns ─────────────────────────────────────────────────────────────── const CAMPAIGNS = [ @@ -121,6 +122,7 @@ export const SmsCampaignPanel = () => { const [checkingIds, setCheckingIds] = useState(new Set()); const [autoRefresh, setAutoRefresh] = useState(true); const [togglingCampaign, setTogglingCampaign] = useState(null); + const [showCityRules, setShowCityRules] = useState(false); const refreshTimer = useRef(null); const handleOpenGroup = useCallback((groupId) => { @@ -405,6 +407,25 @@ export const SmsCampaignPanel = () => { })} + {/* ── City rules button ──────────────────────────────────────────── */} +
+ +
+ + {/* ── City rules panel ───────────────────────────────────────────── */} + {showCityRules && } + {error && (
{error}
@@ -413,10 +434,10 @@ export const SmsCampaignPanel = () => { )} {/* ── Statistics ────────────────────────────────────────────────────── */} - + {!showCityRules && } {/* ── Settings ──────────────────────────────────────────────────────── */} - {showSettings && settings && ( + {!showCityRules && showSettings && settings && (

@@ -567,7 +588,7 @@ export const SmsCampaignPanel = () => { )} {/* ── Not implemented ──────────────────────────────────────────────── */} - {!showSettings && ( + {!showCityRules && !showSettings && (
{CAMPAIGNS.find(c => c.key === activeCampaign)?.label} — в разработке diff --git a/src/components/admin/SmsCityRulesPanel.jsx b/src/components/admin/SmsCityRulesPanel.jsx new file mode 100644 index 0000000..fa46a9a --- /dev/null +++ b/src/components/admin/SmsCityRulesPanel.jsx @@ -0,0 +1,337 @@ +/** + * @file SmsCityRulesPanel.jsx + * @description Управление SMS-отправкой по городам. + * Админ может: + * - Включить/выключить SMS для города × кампания (постоянно) + * - Указать период (с/по дату) — временно + * - Добавить комментарий + * - «Все города» (*) или «Все кампании» (*) — массовое правило + */ +import React, { useState, useEffect, useCallback } from "react"; +import { Panel } from "../UI/Panel"; +import { supabase } from "../../supabaseClient"; +import { CRIMEAN_CITIES } from "../../constants/cities.js"; + +const CAMPAIGN_LABELS = { + first_sms: "1-е SMS", + second_sms: "2-е SMS", + paid_storage: "Платное хранение", + "*": "Все кампании", +}; + +const CITY_ALL = "*"; + +export const SmsCityRulesPanel = () => { + const [rules, setRules] = useState([]); + const [loading, setLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(""); + + // Form state + const [formCity, setFormCity] = useState("Ялта"); + const [formCampaign, setFormCampaign] = useState("*"); + const [formEnabled, setFormEnabled] = useState(false); + const [formFrom, setFormFrom] = useState(""); + const [formUntil, setFormUntil] = useState(""); + const [formNote, setFormNote] = useState(""); + + const fetchRules = useCallback(async () => { + setLoading(true); + try { + const { data, error } = await supabase + .from("sms_city_rules") + .select("*") + .order("city", { ascending: true }) + .order("campaign_type", { ascending: true }); + if (error) throw error; + setRules(data || []); + } catch (e) { + console.error("fetch sms_city_rules error", e); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { fetchRules(); }, [fetchRules]); + + const handleSave = async () => { + setSaving(true); + try { + const { error } = await supabase.from("sms_city_rules").insert({ + city: formCity, + campaign_type: formCampaign, + enabled: formEnabled, + valid_from: formFrom || null, + valid_until: formUntil || null, + note: formNote || "", + }); + if (error) throw error; + setShowForm(false); + setFormNote(""); + setFormFrom(""); + setFormUntil(""); + setMessage("✅ Правило добавлено"); + fetchRules(); + setTimeout(() => setMessage(""), 3000); + } catch (e) { + setMessage("❌ Ошибка: " + e.message); + } finally { + setSaving(false); + } + }; + + const handleToggle = async (rule) => { + try { + const { error } = await supabase + .from("sms_city_rules") + .update({ enabled: !rule.enabled }) + .eq("id", rule.id); + if (error) throw error; + fetchRules(); + } catch (e) { + console.error("toggle error", e); + } + }; + + const handleDelete = async (id) => { + try { + const { error } = await supabase + .from("sms_city_rules") + .delete() + .eq("id", id); + if (error) throw error; + fetchRules(); + } catch (e) { + console.error("delete error", e); + } + }; + + const fmtDate = (d) => { + if (!d) return "—"; + try { + return new Date(d).toLocaleDateString("ru-RU", { day: "2-digit", month: "short", year: "numeric" }); + } catch { return d; } + }; + + return ( +
+ {/* Header */} + +
+
+ 🏙️ +
+

+ SMS по городам +

+

+ Управление отправкой SMS по городам и кампаниям. Выключенное правило = SMS не отправляется. +

+
+
+ +
+ {message && {message}} +
+ + {/* Add rule form */} + {showForm && ( + +

Новое правило

+
+ {/* City */} +
+ + +
+ {/* Campaign */} +
+ + +
+ {/* Enabled toggle */} +
+ +
+ + +
+
+ {/* Period */} +
+
+ + setFormFrom(e.target.value)} + className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm text-[var(--color-text)]" + /> +
+
+ + setFormUntil(e.target.value)} + className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm text-[var(--color-text)]" + /> +
+
+
+ {/* Note */} +
+ + setFormNote(e.target.value)} + placeholder="Например: Ялта временно отключена" + className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm text-[var(--color-text)] placeholder:text-[var(--color-text-muted)]" + /> +
+

+ Пустые даты = правило действует постоянно. Указав даты, можно временно отключить SMS на период. +

+ +
+ )} + + {/* Rules table */} + +

+ Правила ({rules.length}) +

+ {loading ? ( +

Загрузка...

+ ) : rules.length === 0 ? ( +
+

+ Правил пока нет. SMS отправляются во все города. +

+

+ Нажмите «+ Правило» чтобы отключить SMS для конкретного города. +

+
+ ) : ( +
+ {rules.map((rule) => { + const isActive = rule.enabled && + (!rule.valid_from || new Date(rule.valid_from) <= new Date()) && + (!rule.valid_until || new Date(rule.valid_until) >= new Date()); + return ( +
+
+ {/* Toggle */} + + {/* Info */} +
+
+ + {rule.city === CITY_ALL ? "⭐ Все города" : rule.city} + + · + + {CAMPAIGN_LABELS[rule.campaign_type] || rule.campaign_type} + + + {isActive ? "Активно" : rule.enabled ? "Запланировано" : "Выключено"} + +
+ {(rule.valid_from || rule.valid_until) && ( +
+ 📅 {fmtDate(rule.valid_from)} — {fmtDate(rule.valid_until)} +
+ )} + {rule.note && ( +
+ 💬 {rule.note} +
+ )} +
+
+ {/* Delete */} + +
+ ); + })} +
+ )} +
+
+ ); +}; \ No newline at end of file