Compare commits

..

No commits in common. "d8930925be57f707f1abc8edd9a97b68ade10e88" and "319761a24c88f39d9f57c8f00db85cdb6b0dbdfb" have entirely different histories.

8 changed files with 16 additions and 845 deletions

View File

@ -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

View File

@ -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."

View File

@ -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:
# Ошибка отправки

View File

@ -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

View File

@ -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

View File

@ -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} — в разработке

View File

@ -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>
);
};

View File

@ -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>
);
};