Compare commits
No commits in common. "d8930925be57f707f1abc8edd9a97b68ade10e88" and "319761a24c88f39d9f57c8f00db85cdb6b0dbdfb" have entirely different histories.
d8930925be
...
319761a24c
|
|
@ -4,9 +4,3 @@ 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
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
#!/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,44 +173,18 @@ def send_telegram(message, chat_id):
|
|||
# ─── State Machine ───────────────────────────────────────────────────────────
|
||||
|
||||
def get_groups_to_send(conn):
|
||||
"""Группы, которым ещё не отправили SMS.
|
||||
|
||||
Режимы (из sms_city_profiles):
|
||||
- link: отправка ссылки на согласование (старый флоу)
|
||||
- notification: уведомление без ссылки (для доставки/самовывоза без согласования)
|
||||
"""
|
||||
"""Группы, которым ещё не отправили SMS (или отправили очень давно)"""
|
||||
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.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
|
||||
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.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_enabled (замена старого sms_allowed_for_city)
|
||||
AND COALESCE(scp.sms_enabled, true) = true
|
||||
-- Нет активной SMS в логе (sent/checking) за последние 2 часа
|
||||
-- Нет активной SMS в логе (sent/checking) за последние max_check_duration минут
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM sms_campaign_log scl
|
||||
WHERE scl.order_group_id = og.id
|
||||
|
|
@ -318,10 +292,6 @@ 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:
|
||||
|
|
@ -336,15 +306,7 @@ def step_send_new(conn, settings):
|
|||
log.info(f"Group {group_id}: already has recent SMS in log, skipping")
|
||||
continue
|
||||
|
||||
# Формируем текст 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})")
|
||||
sms_text = f"Ваш заказ готов. Согласуйте дату доставки по ссылке: {delivery_link}"
|
||||
|
||||
# ТЕСТОВЫЙ РЕЖИМ: подменяем номер на тестовый
|
||||
send_phone = phone
|
||||
|
|
@ -366,23 +328,13 @@ def step_send_new(conn, settings):
|
|||
sms_code=code,
|
||||
attempts=1,
|
||||
)
|
||||
# ДВОЙНАЯ ЗАЩИТА: сразу меняем 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")
|
||||
# ДВОЙНАЯ ЗАЩИТА: сразу меняем 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")
|
||||
sent_count += 1
|
||||
else:
|
||||
# Ошибка отправки
|
||||
|
|
|
|||
|
|
@ -180,14 +180,11 @@ 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
|
||||
|
|
|
|||
|
|
@ -200,15 +200,12 @@ 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
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ 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 = [
|
||||
|
|
@ -122,7 +121,6 @@ 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) => {
|
||||
|
|
@ -407,25 +405,6 @@ 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>
|
||||
|
|
@ -434,10 +413,10 @@ export const SmsCampaignPanel = () => {
|
|||
)}
|
||||
|
||||
{/* ── Statistics ────────────────────────────────────────────────────── */}
|
||||
{!showCityRules && <SmsCampaignStats campaignType={activeCampaign} />}
|
||||
<SmsCampaignStats campaignType={activeCampaign} />
|
||||
|
||||
{/* ── Settings ──────────────────────────────────────────────────────── */}
|
||||
{!showCityRules && showSettings && settings && (
|
||||
{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)]">
|
||||
|
|
@ -588,7 +567,7 @@ export const SmsCampaignPanel = () => {
|
|||
)}
|
||||
|
||||
{/* ── Not implemented ──────────────────────────────────────────────── */}
|
||||
{!showCityRules && !showSettings && (
|
||||
{!showSettings && (
|
||||
<Panel className="p-5">
|
||||
<div className="text-sm text-[var(--color-text-muted)]">
|
||||
{CAMPAIGNS.find(c => c.key === activeCampaign)?.label} — в разработке
|
||||
|
|
|
|||
|
|
@ -1,348 +0,0 @@
|
|||
/**
|
||||
* @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>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,335 +0,0 @@
|
|||
/**
|
||||
* @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