Compare commits
12 Commits
319761a24c
...
d8930925be
| Author | SHA1 | Date |
|---|---|---|
|
|
d8930925be | |
|
|
2103fe2afe | |
|
|
69c261cc8f | |
|
|
1a60fe07e0 | |
|
|
f67fe6432d | |
|
|
6c9e13d19a | |
|
|
484f9efcba | |
|
|
a4ac117b51 | |
|
|
e2dcf6db72 | |
|
|
de4ecaad27 | |
|
|
a3ffd0c030 | |
|
|
6ad969f297 |
|
|
@ -4,3 +4,9 @@ cd /opt/supersam
|
||||||
git pull origin main
|
git pull origin main
|
||||||
docker compose -f docker-compose.app.yml up -d --build
|
docker compose -f docker-compose.app.yml up -d --build
|
||||||
echo 'Deploy completed at' $(date)
|
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
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
@ -173,18 +173,44 @@ def send_telegram(message, chat_id):
|
||||||
# ─── State Machine ───────────────────────────────────────────────────────────
|
# ─── State Machine ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def get_groups_to_send(conn):
|
def get_groups_to_send(conn):
|
||||||
"""Группы, которым ещё не отправили SMS (или отправили очень давно)"""
|
"""Группы, которым ещё не отправили SMS.
|
||||||
|
|
||||||
|
Режимы (из sms_city_profiles):
|
||||||
|
- link: отправка ссылки на согласование (старый флоу)
|
||||||
|
- notification: уведомление без ссылки (для доставки/самовывоза без согласования)
|
||||||
|
"""
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
|
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
|
FROM order_groups og
|
||||||
|
LEFT JOIN sms_city_profiles scp ON scp.city = og.town
|
||||||
WHERE og.status = 'ready_to_launch'
|
WHERE og.status = 'ready_to_launch'
|
||||||
AND og.delivery_status = 'pending_confirmation'
|
AND og.delivery_status = 'pending_confirmation'
|
||||||
AND og.delivery_link IS NOT NULL
|
|
||||||
AND og.delivery_link != ''
|
|
||||||
AND COALESCE(og.notification_status, '') = 'link_ready'
|
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 (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM sms_campaign_log scl
|
SELECT 1 FROM sms_campaign_log scl
|
||||||
WHERE scl.order_group_id = og.id
|
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", "—")
|
name = group.get("customer_name") or group.get("group_key", "—")
|
||||||
phone = group.get("customer_phone_normalized") or group.get("customer_phone", "")
|
phone = group.get("customer_phone_normalized") or group.get("customer_phone", "")
|
||||||
delivery_link = group.get("delivery_link", "")
|
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 этой группе сегодня (защита от повторной отправки)
|
# Проверяем: была ли уже SMS этой группе сегодня (защита от повторной отправки)
|
||||||
with conn.cursor() as cur:
|
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")
|
log.info(f"Group {group_id}: already has recent SMS in log, skipping")
|
||||||
continue
|
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
|
send_phone = phone
|
||||||
|
|
@ -328,13 +366,23 @@ def step_send_new(conn, settings):
|
||||||
sms_code=code,
|
sms_code=code,
|
||||||
attempts=1,
|
attempts=1,
|
||||||
)
|
)
|
||||||
# ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status,
|
# ДВОЙНАЯ ЗАЩИТА: сразу меняем notification_status
|
||||||
# чтобы get_groups_to_send не нашёл эту группу при следующем запуске
|
if sms_mode == "notification":
|
||||||
update_order_group(conn, group_id, {
|
# Notification: нет 2-й SMS, сразу first_sms_sent
|
||||||
"notification_status": "sms_sending",
|
update_order_group(conn, group_id, {
|
||||||
"sms_sent_at": "NOW()",
|
"notification_status": "first_sms_sent",
|
||||||
})
|
"sms_sent_at": "NOW()",
|
||||||
log.info(f"Group {group_id}: SMS sent, sms_id={sms_id}, log_id={log_id}, notification_status→sms_sending")
|
"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
|
sent_count += 1
|
||||||
else:
|
else:
|
||||||
# Ошибка отправки
|
# Ошибка отправки
|
||||||
|
|
|
||||||
|
|
@ -180,11 +180,14 @@ def get_groups_to_send(conn):
|
||||||
og.customer_phone_normalized, og.delivery_link, og.notification_status,
|
og.customer_phone_normalized, og.delivery_link, og.notification_status,
|
||||||
og.paid_storage_at
|
og.paid_storage_at
|
||||||
FROM order_groups og
|
FROM order_groups og
|
||||||
|
LEFT JOIN sms_city_profiles scp ON scp.city = og.town
|
||||||
WHERE og.delivery_status = 'paid_storage'
|
WHERE og.delivery_status = 'paid_storage'
|
||||||
AND og.paid_storage_at IS NOT NULL
|
AND og.paid_storage_at IS NOT NULL
|
||||||
AND COALESCE(og.notification_status, '') NOT IN ('paid_storage_sending', 'paid_storage_sent')
|
AND COALESCE(og.notification_status, '') NOT IN ('paid_storage_sending', 'paid_storage_sent')
|
||||||
AND og.delivery_link IS NOT NULL
|
AND og.delivery_link IS NOT NULL
|
||||||
AND og.delivery_link != ''
|
AND og.delivery_link != ''
|
||||||
|
-- Профиль: paid_storage_enabled (если false — SMS о платном хранении не отправляется)
|
||||||
|
AND COALESCE(scp.paid_storage_enabled, true) = true
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM sms_campaign_log scl
|
SELECT 1 FROM sms_campaign_log scl
|
||||||
WHERE scl.order_group_id = og.id
|
WHERE scl.order_group_id = og.id
|
||||||
|
|
|
||||||
|
|
@ -200,12 +200,15 @@ def get_groups_to_send(conn):
|
||||||
SELECT og.id, og.group_key, og.customer_name, og.customer_phone,
|
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
|
||||||
FROM order_groups og
|
FROM order_groups og
|
||||||
|
LEFT JOIN sms_city_profiles scp ON scp.city = og.town
|
||||||
WHERE og.delivery_status = 'pending_confirmation'
|
WHERE og.delivery_status = 'pending_confirmation'
|
||||||
AND og.delivery_link IS NOT NULL
|
AND og.delivery_link IS NOT NULL
|
||||||
AND og.delivery_link != ''
|
AND og.delivery_link != ''
|
||||||
AND COALESCE(og.notification_status, '') = 'first_sms_sent'
|
AND COALESCE(og.notification_status, '') = 'first_sms_sent'
|
||||||
AND og.second_sms_sent_at IS NULL
|
AND og.second_sms_sent_at IS NULL
|
||||||
AND (og.next_notification_check_at IS NULL OR og.next_notification_check_at <= NOW())
|
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 в логе
|
-- Нет активной второй SMS в логе
|
||||||
AND NOT EXISTS (
|
AND NOT EXISTS (
|
||||||
SELECT 1 FROM sms_campaign_log scl
|
SELECT 1 FROM sms_campaign_log scl
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { Panel } from "../UI/Panel";
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
import { supabase } from "../../supabaseClient";
|
import { supabase } from "../../supabaseClient";
|
||||||
import { SmsCampaignStats } from "./SmsCampaignStats";
|
import { SmsCampaignStats } from "./SmsCampaignStats";
|
||||||
|
import { SmsCityProfilesPanel } from "./SmsCityProfilesPanel";
|
||||||
|
|
||||||
// ── Campaigns ───────────────────────────────────────────────────────────────
|
// ── Campaigns ───────────────────────────────────────────────────────────────
|
||||||
const CAMPAIGNS = [
|
const CAMPAIGNS = [
|
||||||
|
|
@ -121,6 +122,7 @@ export const SmsCampaignPanel = () => {
|
||||||
const [checkingIds, setCheckingIds] = useState(new Set());
|
const [checkingIds, setCheckingIds] = useState(new Set());
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||||
const [togglingCampaign, setTogglingCampaign] = useState(null);
|
const [togglingCampaign, setTogglingCampaign] = useState(null);
|
||||||
|
const [showCityRules, setShowCityRules] = useState(false);
|
||||||
const refreshTimer = useRef(null);
|
const refreshTimer = useRef(null);
|
||||||
|
|
||||||
const handleOpenGroup = useCallback((groupId) => {
|
const handleOpenGroup = useCallback((groupId) => {
|
||||||
|
|
@ -405,6 +407,25 @@ export const SmsCampaignPanel = () => {
|
||||||
})}
|
})}
|
||||||
</div>
|
</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 && (
|
{error && (
|
||||||
<Panel className="p-3">
|
<Panel className="p-3">
|
||||||
<div className="text-xs text-[var(--color-danger)]">{error}</div>
|
<div className="text-xs text-[var(--color-danger)]">{error}</div>
|
||||||
|
|
@ -413,10 +434,10 @@ export const SmsCampaignPanel = () => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Statistics ────────────────────────────────────────────────────── */}
|
{/* ── Statistics ────────────────────────────────────────────────────── */}
|
||||||
<SmsCampaignStats campaignType={activeCampaign} />
|
{!showCityRules && <SmsCampaignStats campaignType={activeCampaign} />}
|
||||||
|
|
||||||
{/* ── Settings ──────────────────────────────────────────────────────── */}
|
{/* ── Settings ──────────────────────────────────────────────────────── */}
|
||||||
{showSettings && settings && (
|
{!showCityRules && showSettings && settings && (
|
||||||
<Panel className="p-5">
|
<Panel className="p-5">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold text-[var(--color-text)]">
|
<h3 className="text-sm font-semibold text-[var(--color-text)]">
|
||||||
|
|
@ -567,7 +588,7 @@ export const SmsCampaignPanel = () => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Not implemented ──────────────────────────────────────────────── */}
|
{/* ── Not implemented ──────────────────────────────────────────────── */}
|
||||||
{!showSettings && (
|
{!showCityRules && !showSettings && (
|
||||||
<Panel className="p-5">
|
<Panel className="p-5">
|
||||||
<div className="text-sm text-[var(--color-text-muted)]">
|
<div className="text-sm text-[var(--color-text-muted)]">
|
||||||
{CAMPAIGNS.find(c => c.key === activeCampaign)?.label} — в разработке
|
{CAMPAIGNS.find(c => c.key === activeCampaign)?.label} — в разработке
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue