Compare commits

..

12 Commits

Author SHA1 Message Date
root d8930925be fix: SMS city profiles — жёсткие цвета вместо CSS variables
- Карточки: #e8f0ec (светло-зелёный) / #0d1a16 (тёмный) — чётко виден на Panel
- Внутренние блоки: #ffffff / #142822 — контраст с карточкой
- Название города: #10211b / #ecf5f2 — тёмный/светлый, жирный
- Все цвета заданы через CSS классы с [data-theme=dark] — без var()
- Toggle: #22c55e / #c8d8d2 — виден в обеих темах
2026-07-22 09:54:36 +00:00
root 2103fe2afe fix: SMS city profiles — inline styles для надёжного контраста
- Карточки профиля: accent-soft (зелёный оттенок) — виден на Panel
- Внутренние блоки: surface-strong (непрозрачный) — контраст с карточкой
- Инпуты: color-base — отличается от карточек
- Все цвета через CSS variables — работают в обеих темах
- Никаких Tailwind классов с var() которые могут не разрешиться
2026-07-22 09:51:56 +00:00
root 69c261cc8f fix: SMS city profiles — контраст цветов для светлой и тёмной темы
- Внешние карточки: --color-surface (полупрозрачный, виден на фоне)
- Внутренние блоки: --color-surface-strong (непрозрачный, контраст)
- Inputs/select: --color-base (отличается от карточек)
- Option элементы: явный bg + color для тёмной темы
- Светлая: карточки голубовато-зелёные, внутри — белые блоки
- Тёмная: карточки чуть светлее фона, внутри — ещё светлее
2026-07-22 08:41:48 +00:00
root 1a60fe07e0 fix: backup-code-s3 retention bash comparison 2026-07-22 06:59:44 +00:00
root f67fe6432d feat: backup code to S3 after deploy
- scripts/backup-code-s3.sh — tar.gz (без node_modules/.git/dist) → S3
- deploy.sh: вызывает backup-code-s3.sh после деплоя
- Retention 30 дней, Beget Cloud Storage
- Также добавить в cron: 0 1 * * *
2026-07-22 06:58:15 +00:00
root 6c9e13d19a fix: SMS city profiles UI — контраст цветов, выравнивание
- bg-[var(--color-surface-strong)] вместо --color-bg внутри карточек
- Карточки профилей: --color-surface-strong, вложенные блоки: --color-surface
- Текст всегда var(--color-text) — виден в светлой и тёмной теме
- Toggle: компактнее (h-5 w-10)
- Выравнивание: flex-wrap, gap, border-t для разделения секций
- SMS ON/OFF badge вместо toggle в строке заголовка
2026-07-22 06:45:39 +00:00
root 484f9efcba feat: SMS city profiles — гибкая конфигурация SMS по городам
- DB: sms_city_profiles (delivery_mode, pickup_mode, second_sms_enabled, paid_storage_enabled, sms_enabled)
- Python first_sms: link mode (согласование) vs notification mode (без ссылки, без 2-й SMS)
- Python second_sms: проверка second_sms_enabled из профиля
- Python paid_storage: проверка paid_storage_enabled из профиля
- Frontend: SmsCityProfilesPanel — выбор режима, кастомный текст, toggle'ы
- Дефолты: Севастополь=link, Ялта=notification без ссылки
- Замена sms_city_rules на sms_city_profiles
2026-07-21 19:55:51 +00:00
root a4ac117b51 fix: paid_storage SMS — убрать фильтр по городам
- Убран sms_allowed_for_city из paid_storage скрипта
- Убрана paid_storage из UI (dropdown + labels)
- Описание обновлено: только 1-е и 2-е SMS
2026-07-21 19:08:22 +00:00
root e2dcf6db72 merge: server updates + sms city rules 2026-07-21 14:58:19 +00:00
root de4ecaad27 feat: SMS city rules — гибкое управление SMS по городам
- 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 по городам'
- Поддержка '*' (все города / все кампании)
2026-07-21 14:58:11 +00:00
root a3ffd0c030 sync from mkn8n 2026-07-16 12:43:02 +00:00
root 6ad969f297 merge: suggestions voting + logistician access from mkn8n 2026-07-16 12:40:43 +00:00
8 changed files with 845 additions and 16 deletions

View File

@ -4,3 +4,9 @@ cd /opt/supersam
git pull origin main
docker compose -f docker-compose.app.yml up -d --build
echo 'Deploy completed at' $(date)
# ── Backup code to S3 after deploy ─────────────────────────────────────────
if [ -x ./scripts/backup-code-s3.sh ]; then
echo "Running code backup to S3..."
./scripts/backup-code-s3.sh || echo "WARNING: code backup failed, deploy OK"
fi

65
scripts/backup-code-s3.sh Normal file
View File

@ -0,0 +1,65 @@
#!/bin/bash
# Supersam app code backup to S3 (Beget Cloud Storage)
# Archives /opt/supersam source code (excluding node_modules, .git, dist)
# and uploads to S3. Retention: keeps last 30 days.
#
# Cron: 0 1 * * * /opt/supersam/scripts/backup-code-s3.sh >> /var/log/supersam-backup-code.log 2>&1
# Also triggered after deploy via deploy.sh
set -euo pipefail
# ── Config ────────────────────────────────────────────────────────────────
AWS_ACCESS_KEY_ID="YG4MQNKAPNL65200MBUY"
AWS_SECRET_ACCESS_KEY="8mXkFM2VRQ3pN1Nx4mhmJ2jrZoB5YTPUa4CaZh43"
S3_BUCKET="02f162ff4a18-supersam-s3"
S3_PREFIX="backups"
SOURCE_DIR="/opt/supersam"
RETENTION_DAYS=30
# ── Runtime ───────────────────────────────────────────────────────────────
TIMESTAMP=$(date +%Y-%m-%d_%H%M)
ARCHIVE_FILE="/tmp/supersam-code-${TIMESTAMP}.tar.gz"
export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY
echo "[$(date)] Starting code backup..."
# ── Archive ──────────────────────────────────────────────────────────────
echo "[$(date)] Archiving ${SOURCE_DIR}..."
tar -czf "${ARCHIVE_FILE}" \
--directory="${SOURCE_DIR}" \
--exclude="node_modules" \
--exclude=".git" \
--exclude="dist" \
--exclude="volumes" \
--exclude="__pycache__" \
. 2>/dev/null
ARCHIVE_SIZE=$(du -h "${ARCHIVE_FILE}" | cut -f1)
echo "[$(date)] Archive created: ${ARCHIVE_FILE} (${ARCHIVE_SIZE})"
# ── Upload via mc ─────────────────────────────────────────────────────────
echo "[$(date)] Uploading to S3..."
mc cp "${ARCHIVE_FILE}" "beget/${S3_BUCKET}/${S3_PREFIX}/code/supersam-code-${TIMESTAMP}.tar.gz" --quiet 2>&1
echo "[$(date)] Uploaded to s3://${S3_BUCKET}/${S3_PREFIX}/code/supersam-code-${TIMESTAMP}.tar.gz"
# ── Cleanup local ─────────────────────────────────────────────────────────
rm -f "${ARCHIVE_FILE}"
echo "[$(date)] Local cleanup done"
# ── Retention: delete S3 code backups older than RETENTION_DAYS ───────────
echo "[$(date)] Cleaning S3 code backups older than ${RETENTION_DAYS} days..."
CUTOFF=$(date -d "-${RETENTION_DAYS} days" +%Y-%m-%d)
mc ls "beget/${S3_BUCKET}/${S3_PREFIX}/code/" 2>/dev/null \
| awk '{print $NF}' \
| while read -r fname; do
file_date=$(echo "$fname" | grep -oP '\d{4}-\d{2}-\d{2}' || echo "")
if [ -n "$file_date" ] && [[ "$file_date" < "$CUTOFF" ]]; then
mc rm "beget/${S3_BUCKET}/${S3_PREFIX}/code/${fname}" --quiet 2>/dev/null
echo "[$(date)] Deleted old backup: ${fname}"
fi
done
echo "[$(date)] Code backup complete."

View File

@ -173,18 +173,44 @@ def send_telegram(message, chat_id):
# ─── State Machine ───────────────────────────────────────────────────────────
def get_groups_to_send(conn):
"""Группы, которым ещё не отправили SMS (или отправили очень давно)"""
"""Группы, которым ещё не отправили SMS.
Режимы (из sms_city_profiles):
- link: отправка ссылки на согласование (старый флоу)
- notification: уведомление без ссылки (для доставки/самовывоза без согласования)
"""
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("""
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
og.customer_phone_normalized, og.delivery_link, og.notification_status
og.customer_phone_normalized, og.delivery_link, og.notification_status,
og.delivery_type, og.town,
-- Профиль города (fallback на дефолтные значения если нет записи)
COALESCE(scp.delivery_mode, 'link') as delivery_mode,
COALESCE(scp.delivery_text, '') as delivery_text,
COALESCE(scp.pickup_mode, 'link') as pickup_mode,
COALESCE(scp.pickup_text, '') as pickup_text,
COALESCE(scp.sms_enabled, true) as sms_enabled,
-- Выбор режима и текста по типу доставки
CASE
WHEN og.delivery_type = 'pickup'
THEN COALESCE(scp.pickup_mode, 'link')
ELSE
COALESCE(scp.delivery_mode, 'link')
END as sms_mode,
CASE
WHEN og.delivery_type = 'pickup'
THEN COALESCE(scp.pickup_text, '')
ELSE
COALESCE(scp.delivery_text, '')
END as sms_text_custom
FROM order_groups og
LEFT JOIN sms_city_profiles scp ON scp.city = og.town
WHERE og.status = 'ready_to_launch'
AND og.delivery_status = 'pending_confirmation'
AND og.delivery_link IS NOT NULL
AND og.delivery_link != ''
AND COALESCE(og.notification_status, '') = 'link_ready'
-- Нет активной SMS в логе (sent/checking) за последние max_check_duration минут
-- Профиль sms_enabled (замена старого sms_allowed_for_city)
AND COALESCE(scp.sms_enabled, true) = true
-- Нет активной SMS в логе (sent/checking) за последние 2 часа
AND NOT EXISTS (
SELECT 1 FROM sms_campaign_log scl
WHERE scl.order_group_id = og.id
@ -292,6 +318,10 @@ def step_send_new(conn, settings):
name = group.get("customer_name") or group.get("group_key", "—")
phone = group.get("customer_phone_normalized") or group.get("customer_phone", "")
delivery_link = group.get("delivery_link", "")
delivery_type = group.get("delivery_type") or "delivery"
town = group.get("town") or "—"
sms_mode = group.get("sms_mode") or "link"
custom_text = group.get("sms_text_custom") or ""
# Проверяем: была ли уже SMS этой группе сегодня (защита от повторной отправки)
with conn.cursor() as cur:
@ -306,7 +336,15 @@ def step_send_new(conn, settings):
log.info(f"Group {group_id}: already has recent SMS in log, skipping")
continue
sms_text = f"Ваш заказ готов. Согласуйте дату доставки по ссылке: {delivery_link}"
# Формируем текст SMS по режиму
if sms_mode == "notification":
# Уведомление без ссылки на согласование
sms_text = custom_text if custom_text else "Ваш заказ готов."
log.info(f"Group {group_id}: NOTIFICATION mode (town={town}, type={delivery_type}), no link")
else:
# Стандартный флоу — ссылка на согласование
sms_text = f"Ваш заказ готов. Согласуйте дату доставки по ссылке: {delivery_link}"
log.info(f"Group {group_id}: LINK mode (town={town}, type={delivery_type})")
# ТЕСТОВЫЙ РЕЖИМ: подменяем номер на тестовый
send_phone = phone
@ -328,13 +366,23 @@ def step_send_new(conn, settings):
sms_code=code,
attempts=1,
)
# ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status,
# чтобы get_groups_to_send не нашёл эту группу при следующем запуске
update_order_group(conn, group_id, {
"notification_status": "sms_sending",
"sms_sent_at": "NOW()",
})
log.info(f"Group {group_id}: SMS sent, sms_id={sms_id}, log_id={log_id}, notification_status→sms_sending")
# ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status
if sms_mode == "notification":
# Notification: нет 2-й SMS, сразу first_sms_sent
update_order_group(conn, group_id, {
"notification_status": "first_sms_sent",
"sms_sent_at": "NOW()",
"first_sms_sent_at": "NOW()",
"status": "first_sms_sent",
})
log.info(f"Group {group_id}: NOTIFICATION SMS sent, status→first_sms_sent (no second SMS)")
else:
# Link: ждём доставки SMS, потом 2-я SMS
update_order_group(conn, group_id, {
"notification_status": "sms_sending",
"sms_sent_at": "NOW()",
})
log.info(f"Group {group_id}: LINK SMS sent, status→sms_sending")
sent_count += 1
else:
# Ошибка отправки

View File

@ -180,11 +180,14 @@ def get_groups_to_send(conn):
og.customer_phone_normalized, og.delivery_link, og.notification_status,
og.paid_storage_at
FROM order_groups og
LEFT JOIN sms_city_profiles scp ON scp.city = og.town
WHERE og.delivery_status = 'paid_storage'
AND og.paid_storage_at IS NOT NULL
AND COALESCE(og.notification_status, '') NOT IN ('paid_storage_sending', 'paid_storage_sent')
AND og.delivery_link IS NOT NULL
AND og.delivery_link != ''
-- Профиль: paid_storage_enabled (если false — SMS о платном хранении не отправляется)
AND COALESCE(scp.paid_storage_enabled, true) = true
AND NOT EXISTS (
SELECT 1 FROM sms_campaign_log scl
WHERE scl.order_group_id = og.id

View File

@ -200,12 +200,15 @@ def get_groups_to_send(conn):
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
og.customer_phone_normalized, og.delivery_link, og.notification_status
FROM order_groups og
LEFT JOIN sms_city_profiles scp ON scp.city = og.town
WHERE og.delivery_status = 'pending_confirmation'
AND og.delivery_link IS NOT NULL
AND og.delivery_link != ''
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())
-- Профиль: second_sms_enabled (если false — 2-я SMS не отправляется)
AND COALESCE(scp.second_sms_enabled, true) = true
-- Нет активной второй SMS в логе
AND NOT EXISTS (
SELECT 1 FROM sms_campaign_log scl

View File

@ -9,6 +9,7 @@ import { Panel } from "../UI/Panel";
import { Badge } from "../UI/Badge";
import { supabase } from "../../supabaseClient";
import { SmsCampaignStats } from "./SmsCampaignStats";
import { SmsCityProfilesPanel } from "./SmsCityProfilesPanel";
// ── 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 = () => {
})}
</div>
{/* ── City rules button ──────────────────────────────────────────── */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setShowCityRules(!showCityRules)}
className={[
"rounded-2xl border px-4 py-2.5 text-sm font-medium transition flex items-center gap-2",
showCityRules
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)] text-[var(--color-accent)]"
: "border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
].join(" ")}
>
🏙️ SMS по городам
</button>
</div>
{/* ── City profiles panel ────────────────────────────────────────── */}
{showCityRules && <SmsCityProfilesPanel />}
{error && (
<Panel className="p-3">
<div className="text-xs text-[var(--color-danger)]">{error}</div>
@ -413,10 +434,10 @@ export const SmsCampaignPanel = () => {
)}
{/* ── Statistics ────────────────────────────────────────────────────── */}
<SmsCampaignStats campaignType={activeCampaign} />
{!showCityRules && <SmsCampaignStats campaignType={activeCampaign} />}
{/* ── Settings ──────────────────────────────────────────────────────── */}
{showSettings && settings && (
{!showCityRules && showSettings && settings && (
<Panel className="p-5">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-semibold text-[var(--color-text)]">
@ -567,7 +588,7 @@ export const SmsCampaignPanel = () => {
)}
{/* ── Not implemented ──────────────────────────────────────────────── */}
{!showSettings && (
{!showCityRules && !showSettings && (
<Panel className="p-5">
<div className="text-sm text-[var(--color-text-muted)]">
{CAMPAIGNS.find(c => c.key === activeCampaign)?.label} — в разработке

View File

@ -0,0 +1,348 @@
/**
* @file SmsCityProfilesPanel.jsx
* @description Профили 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 THEME_STYLES = `
.sms-card {
background: #e8f0ec;
border: 1px solid #c8d8d2;
border-radius: 16px;
padding: 16px;
}
[data-theme="dark"] .sms-card {
background: #0d1a16;
border: 1px solid rgba(236,245,242,0.12);
}
.sms-inner {
background: #ffffff;
border: 1px solid #d0dad6;
border-radius: 10px;
padding: 12px;
}
[data-theme="dark"] .sms-inner {
background: #142822;
border: 1px solid rgba(236,245,242,0.08);
}
.sms-input {
background: #f4f7f5;
border: 1px solid #c8d8d2;
border-radius: 10px;
padding: 8px 12px;
color: #10211b;
width: 100%;
font-size: 14px;
font-family: inherit;
}
[data-theme="dark"] .sms-input {
background: #09110f;
border: 1px solid rgba(236,245,242,0.12);
color: #ecf5f2;
}
.sms-input::placeholder { color: #5d6d66; }
[data-theme="dark"] .sms-input::placeholder { color: #9eb0aa; }
.sms-label { font-size: 12px; font-weight: 500; color: #5d6d66; }
[data-theme="dark"] .sms-label { color: #9eb0aa; }
.sms-city-name { font-size: 16px; font-weight: 700; color: #10211b; }
[data-theme="dark"] .sms-city-name { color: #ecf5f2; }
.sms-section-title { font-size: 14px; font-weight: 600; color: #10211b; }
[data-theme="dark"] .sms-section-title { color: #ecf5f2; }
.sms-muted { font-size: 12px; color: #5d6d66; }
[data-theme="dark"] .sms-muted { color: #9eb0aa; }
.sms-text { font-size: 13px; color: #10211b; }
[data-theme="dark"] .sms-text { color: #ecf5f2; }
.sms-italic { font-size: 12px; color: #5d6d66; font-style: italic; line-height: 1.4; margin-top: 6px; }
[data-theme="dark"] .sms-italic { color: #9eb0aa; }
.sms-toggle-text { font-size: 13px; color: #10211b; }
[data-theme="dark"] .sms-toggle-text { color: #ecf5f2; }
.sms-btn {
border-radius: 12px; padding: 8px 16px; font-size: 12px; font-weight: 600;
color: #fff; border: none; cursor: pointer; background: #12805c;
}
[data-theme="dark"] .sms-btn { background: #57d8a9; color: #06120d; }
.sms-btn-sm {
border-radius: 8px; border: 1px solid #c8d8d2; padding: 4px 10px;
font-size: 12px; font-weight: 500; color: #10211b; cursor: pointer; background: transparent;
}
[data-theme="dark"] .sms-btn-sm { border: 1px solid rgba(236,245,242,0.12); color: #ecf5f2; }
.sms-btn-del {
border-radius: 8px; padding: 4px 8px; font-size: 14px;
color: #5d6d66; cursor: pointer; background: transparent; border: none;
}
[data-theme="dark"] .sms-btn-del { color: #9eb0aa; }
.sms-mode-btn-active-g {
flex: 1; border-radius: 10px; border: 1px solid #22c55e;
background: rgba(34,197,94,0.1); color: #22c55e;
padding: 8px 12px; font-size: 14px; font-weight: 500; cursor: pointer;
}
.sms-mode-btn-inactive {
flex: 1; border-radius: 10px; border: 1px solid #c8d8d2;
background: transparent; color: #5d6d66;
padding: 8px 12px; font-size: 14px; font-weight: 500; cursor: pointer;
}
[data-theme="dark"] .sms-mode-btn-inactive {
border: 1px solid rgba(236,245,242,0.12); color: #9eb0aa;
}
.sms-mode-btn-active-b {
flex: 1; border-radius: 10px; border: 1px solid #3b82f6;
background: rgba(59,130,246,0.1); color: #3b82f6;
padding: 8px 12px; font-size: 14px; font-weight: 500; cursor: pointer;
}
`;
export const SmsCityProfilesPanel = () => {
const [profiles, setProfiles] = useState([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState("");
const [formCity, setFormCity] = useState("Ялта");
const [formDeliveryMode, setFormDeliveryMode] = useState("notification");
const [formDeliveryText, setFormDeliveryText] = useState("");
const [formPickupMode, setFormPickupMode] = useState("notification");
const [formPickupText, setFormPickupText] = useState("");
const [formSecondSms, setFormSecondSms] = useState(true);
const [formPaidStorage, setFormPaidStorage] = useState(true);
const [formSmsEnabled, setFormSmsEnabled] = useState(true);
const [formNote, setFormNote] = useState("");
const [editingId, setEditingId] = useState(null);
const fetchProfiles = useCallback(async () => {
setLoading(true);
try {
const { data, error } = await supabase
.from("sms_city_profiles")
.select("*")
.order("city", { ascending: true });
if (error) throw error;
setProfiles(data || []);
} catch (e) {
console.error("fetch sms_city_profiles error", e);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchProfiles(); }, [fetchProfiles]);
const resetForm = () => {
setFormCity("Ялта"); setFormDeliveryMode("notification"); setFormDeliveryText("");
setFormPickupMode("notification"); setFormPickupText("");
setFormSecondSms(true); setFormPaidStorage(true); setFormSmsEnabled(true);
setFormNote(""); setEditingId(null);
};
const handleSave = async () => {
setSaving(true);
try {
const payload = {
city: formCity, delivery_mode: formDeliveryMode, delivery_text: formDeliveryText || null,
pickup_mode: formPickupMode, pickup_text: formPickupText || null,
second_sms_enabled: formSecondSms, paid_storage_enabled: formPaidStorage,
sms_enabled: formSmsEnabled, note: formNote || "",
};
if (editingId) {
const { error } = await supabase.from("sms_city_profiles").update(payload).eq("id", editingId);
if (error) throw error;
setMessage("✅ Профиль обновлён");
} else {
const { error } = await supabase.from("sms_city_profiles").insert(payload);
if (error) throw error;
setMessage("✅ Профиль добавлен");
}
setShowForm(false); resetForm(); fetchProfiles();
setTimeout(() => setMessage(""), 3000);
} catch (e) {
setMessage("❌ Ошибка: " + e.message);
} finally { setSaving(false); }
};
const handleEdit = (p) => {
setEditingId(p.id); setFormCity(p.city);
setFormDeliveryMode(p.delivery_mode || "link"); setFormDeliveryText(p.delivery_text || "");
setFormPickupMode(p.pickup_mode || "link"); setFormPickupText(p.pickup_text || "");
setFormSecondSms(p.second_sms_enabled ?? true); setFormPaidStorage(p.paid_storage_enabled ?? true);
setFormSmsEnabled(p.sms_enabled ?? true); setFormNote(p.note || "");
setShowForm(true);
};
const handleDelete = async (id) => {
try { const { error } = await supabase.from("sms_city_profiles").delete().eq("id", id); if (error) throw error; fetchProfiles(); }
catch (e) { console.error("delete error", e); }
};
const ModeBadge = ({ mode }) => mode === "notification"
? <span style={{ backgroundColor: "rgba(59,130,246,0.15)", color: "#3b82f6", borderRadius: "6px", padding: "2px 8px", fontSize: "10px", fontWeight: 600 }}>Уведомление</span>
: <span style={{ backgroundColor: "rgba(34,197,94,0.15)", color: "#22c55e", borderRadius: "6px", padding: "2px 8px", fontSize: "10px", fontWeight: 600 }}>Ссылка</span>;
const Toggle = ({ checked, onChange, label }) => (
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<button type="button" onClick={onChange} style={{
position: "relative", height: "20px", width: "40px", borderRadius: "9999px",
transition: "all 0.2s", flexShrink: 0, border: "none", cursor: "pointer",
backgroundColor: checked ? "#22c55e" : "#c8d8d2",
}}>
<span style={{ position: "absolute", top: "2px", height: "16px", width: "16px", borderRadius: "9999px", backgroundColor: "#fff", transition: "all 0.2s", left: checked ? "20px" : "2px" }} />
</button>
{label && <span className="sms-toggle-text">{label}</span>}
</div>
);
return (
<div style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
<style>{THEME_STYLES}</style>
{/* Header */}
<Panel className="p-5">
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<span style={{ fontSize: "20px" }}>🏙️</span>
<div>
<h2 className="sms-section-title" style={{ textTransform: "uppercase", letterSpacing: "0.16em" }}>
Профили SMS по городам
</h2>
<p className="sms-muted" style={{ marginTop: "4px" }}>
Настройка поведения SMS для каждого города.
</p>
</div>
</div>
<button type="button" className="sms-btn" onClick={() => { setShowForm(!showForm); if (!showForm) resetForm(); }}>
{showForm ? "Отмена" : "+ Профиль"}
</button>
</div>
{message && <span className="sms-text" style={{ display: "block", marginTop: "8px" }}>{message}</span>}
</Panel>
{/* Form */}
{showForm && (
<Panel className="p-5" style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
<h3 className="sms-section-title">{editingId ? "Редактировать профиль" : "Новый профиль города"}</h3>
<div style={{ display: "grid", gap: "16px", gridTemplateColumns: "1fr 1fr" }}>
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
<label className="sms-label">Город</label>
<select value={formCity} onChange={(e) => setFormCity(e.target.value)} disabled={!!editingId}
className="sms-input" style={{ opacity: editingId ? 0.5 : 1 }}>
{CRIMEAN_CITIES.map((c) => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div className="sms-inner">
<Toggle checked={formSmsEnabled} onChange={() => setFormSmsEnabled(!formSmsEnabled)} label="SMS включены для города" />
{!formSmsEnabled && <p style={{ fontSize: "12px", color: "#c93d3d", marginTop: "8px" }}>⚠ Все SMS в этот город отключены</p>}
</div>
</div>
{/* Delivery */}
<div className="sms-inner">
<div className="sms-muted" style={{ fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: "12px" }}>📦 Доставка</div>
<div style={{ display: "flex", gap: "8px" }}>
<button type="button" className={formDeliveryMode === "link" ? "sms-mode-btn-active-g" : "sms-mode-btn-inactive"} onClick={() => setFormDeliveryMode("link")}>🔗 Ссылка</button>
<button type="button" className={formDeliveryMode === "notification" ? "sms-mode-btn-active-b" : "sms-mode-btn-inactive"} onClick={() => setFormDeliveryMode("notification")}>📢 Уведомление</button>
</div>
{formDeliveryMode === "notification" && (
<textarea value={formDeliveryText} onChange={(e) => setFormDeliveryText(e.target.value)} placeholder="Ваш заказ готов. Доставим по графику." rows={2} className="sms-input" style={{ resize: "none", marginTop: "8px" }} />
)}
<p className="sms-muted" style={{ marginTop: "8px" }}>
{formDeliveryMode === "link" ? "Клиент получит ссылку для выбора даты" : "Уведомление без ссылки, без 2-й SMS"}
</p>
</div>
{/* Pickup */}
<div className="sms-inner">
<div className="sms-muted" style={{ fontWeight: 600, textTransform: "uppercase", letterSpacing: "0.05em", marginBottom: "12px" }}>🏪 Самовывоз</div>
<div style={{ display: "flex", gap: "8px" }}>
<button type="button" className={formPickupMode === "link" ? "sms-mode-btn-active-g" : "sms-mode-btn-inactive"} onClick={() => setFormPickupMode("link")}>🔗 Ссылка</button>
<button type="button" className={formPickupMode === "notification" ? "sms-mode-btn-active-b" : "sms-mode-btn-inactive"} onClick={() => setFormPickupMode("notification")}>📢 Уведомление</button>
</div>
{formPickupMode === "notification" && (
<textarea value={formPickupText} onChange={(e) => setFormPickupText(e.target.value)} placeholder="Ваш заказ готов. Заберите со склада." rows={2} className="sms-input" style={{ resize: "none", marginTop: "8px" }} />
)}
</div>
<div style={{ display: "grid", gap: "12px", gridTemplateColumns: "1fr 1fr" }}>
<div className="sms-inner"><Toggle checked={formSecondSms} onChange={() => setFormSecondSms(!formSecondSms)} label="2-я SMS (повтор)" /></div>
<div className="sms-inner"><Toggle checked={formPaidStorage} onChange={() => setFormPaidStorage(!formPaidStorage)} label="SMS: платное хранение" /></div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
<label className="sms-label">Комментарий</label>
<input type="text" value={formNote} onChange={(e) => setFormNote(e.target.value)} placeholder="Например: Ялта — доставка без согласования" className="sms-input" />
</div>
<button type="button" disabled={saving} onClick={handleSave} className="sms-btn" style={{ padding: "10px 20px", fontSize: "14px", opacity: saving ? 0.4 : 1 }}>
{saving ? "Сохранение..." : editingId ? "Обновить профиль" : "Сохранить профиль"}
</button>
</Panel>
)}
{/* Profiles list */}
<Panel className="p-5" style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
<h2 className="sms-section-title" style={{ textTransform: "uppercase", letterSpacing: "0.16em" }}>
Профили ({profiles.length})
</h2>
{loading ? (
<p className="sms-muted">Загрузка...</p>
) : profiles.length === 0 ? (
<div style={{ border: "1px dashed #c8d8d2", borderRadius: "12px", padding: "24px", textAlign: "center" }}>
<p className="sms-muted">Профилей нет. Города без профиля используют стандартный флоу (ссылка на согласование).</p>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
{profiles.map((p) => (
<div key={p.id} className="sms-card">
{/* Header */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "12px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<span className="sms-city-name">{p.city}</span>
{!p.sms_enabled
? <span style={{ backgroundColor: "rgba(239,68,68,0.15)", color: "#ef4444", borderRadius: "6px", padding: "2px 8px", fontSize: "10px", fontWeight: 600 }}>SMS OFF</span>
: <span style={{ backgroundColor: "rgba(34,197,94,0.15)", color: "#22c55e", borderRadius: "6px", padding: "2px 8px", fontSize: "10px", fontWeight: 600 }}>SMS ON</span>}
</div>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<button type="button" className="sms-btn-sm" onClick={() => handleEdit(p)}>✏️ Изменить</button>
<button type="button" className="sms-btn-del" onClick={() => handleDelete(p.id)} title="Удалить">✕</button>
</div>
</div>
{/* Config */}
<div style={{ display: "grid", gap: "8px", gridTemplateColumns: "1fr 1fr", marginBottom: "12px" }}>
<div className="sms-inner">
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}>
<span className="sms-text" style={{ fontSize: "12px", fontWeight: 500 }}>📦 Доставка</span>
<ModeBadge mode={p.delivery_mode} />
</div>
{p.delivery_text && <p className="sms-italic">"{p.delivery_text}"</p>}
</div>
<div className="sms-inner">
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexWrap: "wrap" }}>
<span className="sms-text" style={{ fontSize: "12px", fontWeight: 500 }}>🏪 Самовывоз</span>
<ModeBadge mode={p.pickup_mode} />
</div>
{p.pickup_text && <p className="sms-italic">"{p.pickup_text}"</p>}
</div>
</div>
{/* Toggles */}
<div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "16px", paddingTop: "8px", borderTop: "1px solid #c8d8d2" }}>
<span className="sms-text" style={{ fontSize: "12px", fontWeight: 500, opacity: p.second_sms_enabled ? 1 : 0.5 }}>
2-я SMS: {p.second_sms_enabled ? "✅" : "❌"}
</span>
<span className="sms-text" style={{ fontSize: "12px", fontWeight: 500, opacity: p.paid_storage_enabled ? 1 : 0.5 }}>
Платное хранение: {p.paid_storage_enabled ? "✅" : "❌"}
</span>
</div>
{p.note && <p className="sms-muted" style={{ paddingTop: "8px" }}>💬 {p.note}</p>}
</div>
))}
</div>
)}
</Panel>
</div>
);
};

View File

@ -0,0 +1,335 @@
/**
* @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",
"*": "Все кампании",
};
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 (
<div className="space-y-4">
{/* Header */}
<Panel className="p-5 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-xl">🏙️</span>
<div>
<h2 className="text-sm font-semibold uppercase tracking-[0.16em] text-[var(--color-text)]">
SMS по городам
</h2>
<p className="text-xs text-[var(--color-text-muted)] mt-1">
Управление отправкой SMS по городам. Выключенное правило = SMS не отправляется. Только для 1-го и 2-го SMS (согласование доставки). Платное хранение не регулируется.
</p>
</div>
</div>
<button
type="button"
onClick={() => setShowForm(!showForm)}
className="rounded-xl bg-[var(--color-accent)] px-4 py-2 text-xs font-semibold text-white transition hover:opacity-90"
>
{showForm ? "Отмена" : "+ Правило"}
</button>
</div>
{message && <span className="text-sm text-[var(--color-text-muted)]">{message}</span>}
</Panel>
{/* Add rule form */}
{showForm && (
<Panel className="p-5 space-y-4">
<h3 className="text-sm font-semibold text-[var(--color-text)]">Новое правило</h3>
<div className="grid gap-3 sm:grid-cols-2">
{/* City */}
<div className="space-y-1">
<label className="text-xs font-medium text-[var(--color-text-muted)]">Город</label>
<select
value={formCity}
onChange={(e) => setFormCity(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)]"
>
<option value={CITY_ALL}>⭐ Все города</option>
{CRIMEAN_CITIES.map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
</div>
{/* Campaign */}
<div className="space-y-1">
<label className="text-xs font-medium text-[var(--color-text-muted)]">Кампания</label>
<select
value={formCampaign}
onChange={(e) => setFormCampaign(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)]"
>
<option value="*">⭐ Все кампании</option>
<option value="first_sms">1-е SMS</option>
<option value="second_sms">2-е SMS</option>
</select>
</div>
{/* Enabled toggle */}
<div className="space-y-1">
<label className="text-xs font-medium text-[var(--color-text-muted)]">Статус</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setFormEnabled(true)}
className={["rounded-xl border px-4 py-2 text-sm font-medium transition",
formEnabled ? "border-[#22c55e] bg-[rgba(34,197,94,0.12)] text-[#22c55e]" : "border-[var(--color-border)] text-[var(--color-text-muted)]"].join(" ")}
>
✅ Включить
</button>
<button
type="button"
onClick={() => setFormEnabled(false)}
className={["rounded-xl border px-4 py-2 text-sm font-medium transition",
!formEnabled ? "border-[#ef4444] bg-[rgba(239,68,68,0.12)] text-[#ef4444]" : "border-[var(--color-border)] text-[var(--color-text-muted)]"].join(" ")}
>
❌ Выключить
</button>
</div>
</div>
{/* Period */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<label className="text-xs font-medium text-[var(--color-text-muted)]">С даты</label>
<input
type="date"
value={formFrom}
onChange={(e) => 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)]"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-[var(--color-text-muted)]">По дату</label>
<input
type="date"
value={formUntil}
onChange={(e) => 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)]"
/>
</div>
</div>
</div>
{/* Note */}
<div className="space-y-1">
<label className="text-xs font-medium text-[var(--color-text-muted)]">Комментарий</label>
<input
type="text"
value={formNote}
onChange={(e) => 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)]"
/>
</div>
<p className="text-xs text-[var(--color-text-muted)]">
Пустые даты = правило действует постоянно. Указав даты, можно временно отключить SMS на период.
</p>
<button
type="button"
disabled={saving}
onClick={handleSave}
className="rounded-xl bg-[var(--color-accent)] px-5 py-2.5 text-sm font-semibold text-white transition hover:opacity-90 disabled:opacity-40"
>
{saving ? "Сохранение..." : "Сохранить правило"}
</button>
</Panel>
)}
{/* Rules table */}
<Panel className="p-5 space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-[0.16em] text-[var(--color-text)]">
Правила ({rules.length})
</h2>
{loading ? (
<p className="text-sm text-[var(--color-text-muted)]">Загрузка...</p>
) : rules.length === 0 ? (
<div className="rounded-xl border border-dashed border-[var(--color-border)] p-6 text-center">
<p className="text-sm text-[var(--color-text-muted)]">
Правил пока нет. SMS отправляются во все города.
</p>
<p className="text-xs text-[var(--color-text-muted)] mt-1">
Нажмите «+ Правило» чтобы отключить SMS для конкретного города.
</p>
</div>
) : (
<div className="space-y-2">
{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 (
<div
key={rule.id}
className="flex items-center justify-between gap-3 rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-4"
>
<div className="flex items-center gap-3 flex-1">
{/* Toggle */}
<button
type="button"
onClick={() => handleToggle(rule)}
className={[
"relative h-6 w-11 rounded-full transition shrink-0",
rule.enabled ? "bg-[#22c55e]" : "bg-[var(--color-border)]"
].join(" ")}
>
<span className={[
"absolute top-0.5 h-5 w-5 rounded-full bg-white transition-all",
rule.enabled ? "left-[22px]" : "left-0.5"
].join(" ")} />
</button>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-[var(--color-text)]">
{rule.city === CITY_ALL ? "⭐ Все города" : rule.city}
</span>
<span className="text-xs text-[var(--color-text-muted)]">·</span>
<span className="text-xs font-medium text-[var(--color-text-muted)]">
{CAMPAIGN_LABELS[rule.campaign_type] || rule.campaign_type}
</span>
<span
className="rounded-lg px-2 py-0.5 text-[10px] font-semibold uppercase"
style={{
backgroundColor: isActive ? "rgba(34,197,94,0.12)" : "rgba(239,68,68,0.12)",
color: isActive ? "#22c55e" : "#ef4444",
}}
>
{isActive ? "Активно" : rule.enabled ? "Запланировано" : "Выключено"}
</span>
</div>
{(rule.valid_from || rule.valid_until) && (
<div className="text-xs text-[var(--color-text-muted)] mt-1">
📅 {fmtDate(rule.valid_from)} — {fmtDate(rule.valid_until)}
</div>
)}
{rule.note && (
<div className="text-xs text-[var(--color-text-muted)] mt-0.5">
💬 {rule.note}
</div>
)}
</div>
</div>
{/* Delete */}
<button
type="button"
onClick={() => handleDelete(rule.id)}
className="rounded-lg p-1.5 text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-danger)] transition shrink-0"
title="Удалить правило"
>
✕
</button>
</div>
);
})}
</div>
)}
</Panel>
</div>
);
};