feat: SMS city profiles — гибкая конфигурация SMS по городам
- DB: sms_city_profiles (delivery_mode, pickup_mode, second_sms_enabled, paid_storage_enabled, sms_enabled) - Python first_sms: link mode (согласование) vs notification mode (без ссылки, без 2-й SMS) - Python second_sms: проверка second_sms_enabled из профиля - Python paid_storage: проверка paid_storage_enabled из профиля - Frontend: SmsCityProfilesPanel — выбор режима, кастомный текст, toggle'ы - Дефолты: Севастополь=link, Ялта=notification без ссылки - Замена sms_city_rules на sms_city_profiles
This commit is contained in:
parent
a4ac117b51
commit
484f9efcba
|
|
@ -173,19 +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'
|
||||||
AND sms_allowed_for_city(og.town, 'first_sms')
|
-- Профиль sms_enabled (замена старого sms_allowed_for_city)
|
||||||
-- Нет активной SMS в логе (sent/checking) за последние max_check_duration минут
|
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
|
||||||
|
|
@ -293,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:
|
||||||
|
|
@ -307,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
|
||||||
|
|
@ -329,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,13 +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())
|
||||||
AND sms_allowed_for_city(og.town, 'second_sms')
|
-- Профиль: 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,7 +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 { SmsCityRulesPanel } from "./SmsCityRulesPanel";
|
import { SmsCityProfilesPanel } from "./SmsCityProfilesPanel";
|
||||||
|
|
||||||
// ── Campaigns ───────────────────────────────────────────────────────────────
|
// ── Campaigns ───────────────────────────────────────────────────────────────
|
||||||
const CAMPAIGNS = [
|
const CAMPAIGNS = [
|
||||||
|
|
@ -423,8 +423,8 @@ export const SmsCampaignPanel = () => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── City rules panel ───────────────────────────────────────────── */}
|
{/* ── City profiles panel ────────────────────────────────────────── */}
|
||||||
{showCityRules && <SmsCityRulesPanel />}
|
{showCityRules && <SmsCityProfilesPanel />}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Panel className="p-3">
|
<Panel className="p-3">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,416 @@
|
||||||
|
/**
|
||||||
|
* @file SmsCityProfilesPanel.jsx
|
||||||
|
* @description Профили SMS по городам.
|
||||||
|
* Для каждого города настраивается:
|
||||||
|
* - Доставка: ссылка на согласование | уведомление без ссылки + кастомный текст
|
||||||
|
* - Самовывоз: ссылка на согласование | уведомление без ссылки + кастомный текст
|
||||||
|
* - 2-я 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";
|
||||||
|
|
||||||
|
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("");
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
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("");
|
||||||
|
|
||||||
|
// Edit state
|
||||||
|
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 = (profile) => {
|
||||||
|
setEditingId(profile.id);
|
||||||
|
setFormCity(profile.city);
|
||||||
|
setFormDeliveryMode(profile.delivery_mode || "link");
|
||||||
|
setFormDeliveryText(profile.delivery_text || "");
|
||||||
|
setFormPickupMode(profile.pickup_mode || "link");
|
||||||
|
setFormPickupText(profile.pickup_text || "");
|
||||||
|
setFormSecondSms(profile.second_sms_enabled ?? true);
|
||||||
|
setFormPaidStorage(profile.paid_storage_enabled ?? true);
|
||||||
|
setFormSmsEnabled(profile.sms_enabled ?? true);
|
||||||
|
setFormNote(profile.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 handleQuickToggle = async (profile, field) => {
|
||||||
|
try {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("sms_city_profiles")
|
||||||
|
.update({ [field]: !profile[field] })
|
||||||
|
.eq("id", profile.id);
|
||||||
|
if (error) throw error;
|
||||||
|
fetchProfiles();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("toggle error", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── UI helpers ──────────────────────────────────────────────────────────
|
||||||
|
const ModeBadge = ({ mode }) => {
|
||||||
|
if (mode === "notification") {
|
||||||
|
return <span className="rounded-lg px-2 py-0.5 text-[10px] font-semibold uppercase" style={{ backgroundColor: "rgba(59,130,246,0.12)", color: "#3b82f6" }}>Уведомление</span>;
|
||||||
|
}
|
||||||
|
return <span className="rounded-lg px-2 py-0.5 text-[10px] font-semibold uppercase" style={{ backgroundColor: "rgba(34,197,94,0.12)", color: "#22c55e" }}>Ссылка</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Toggle = ({ checked, onChange, label }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onChange}
|
||||||
|
className={`relative h-6 w-11 rounded-full transition shrink-0 ${checked ? "bg-[#22c55e]" : "bg-[var(--color-border)]"}`}
|
||||||
|
>
|
||||||
|
<span className={`absolute top-0.5 h-5 w-5 rounded-full bg-white transition-all ${checked ? "left-[22px]" : "left-0.5"}`} />
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-[var(--color-text-muted)]">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const modeLabel = (mode) => mode === "notification" ? "Уведомление без ссылки" : "Согласование по ссылке";
|
||||||
|
|
||||||
|
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 для каждого города: согласование по ссылке или уведомление.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setShowForm(!showForm); if (!showForm) resetForm(); }}
|
||||||
|
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/Edit form */}
|
||||||
|
{showForm && (
|
||||||
|
<Panel className="p-5 space-y-5">
|
||||||
|
<h3 className="text-sm font-semibold text-[var(--color-text)]">
|
||||||
|
{editingId ? "Редактировать профиль" : "Новый профиль города"}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* 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)}
|
||||||
|
disabled={!!editingId}
|
||||||
|
className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-2 text-sm text-[var(--color-text)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{CRIMEAN_CITIES.map((c) => (
|
||||||
|
<option key={c} value={c}>{c}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SMS enabled master toggle */}
|
||||||
|
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
|
||||||
|
<Toggle checked={formSmsEnabled} onChange={() => setFormSmsEnabled(!formSmsEnabled)} label="SMS включены для этого города" />
|
||||||
|
{!formSmsEnabled && (
|
||||||
|
<p className="text-xs text-[var(--color-danger)] mt-2">⚠ Все SMS в этот город отключены</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delivery */}
|
||||||
|
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-4 space-y-3">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">📦 Доставка</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormDeliveryMode("link")}
|
||||||
|
className={["flex-1 rounded-xl border px-3 py-2 text-sm font-medium transition",
|
||||||
|
formDeliveryMode === "link" ? "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={() => setFormDeliveryMode("notification")}
|
||||||
|
className={["flex-1 rounded-xl border px-3 py-2 text-sm font-medium transition",
|
||||||
|
formDeliveryMode === "notification" ? "border-[#3b82f6] bg-[rgba(59,130,246,0.12)] text-[#3b82f6]" : "border-[var(--color-border)] text-[var(--color-text-muted)]"].join(" ")}
|
||||||
|
>
|
||||||
|
📢 Уведомление без ссылки
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{formDeliveryMode === "notification" && (
|
||||||
|
<textarea
|
||||||
|
value={formDeliveryText}
|
||||||
|
onChange={(e) => setFormDeliveryText(e.target.value)}
|
||||||
|
placeholder="Ваш заказ готов. Доставим по графику."
|
||||||
|
rows={2}
|
||||||
|
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)] resize-none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
{formDeliveryMode === "link" ? "Клиент получит ссылку для выбора даты доставки" : "Клиент получит уведомление — без согласования, без 2-й SMS"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pickup */}
|
||||||
|
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-4 space-y-3">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">🏪 Самовывоз</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormPickupMode("link")}
|
||||||
|
className={["flex-1 rounded-xl border px-3 py-2 text-sm font-medium transition",
|
||||||
|
formPickupMode === "link" ? "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={() => setFormPickupMode("notification")}
|
||||||
|
className={["flex-1 rounded-xl border px-3 py-2 text-sm font-medium transition",
|
||||||
|
formPickupMode === "notification" ? "border-[#3b82f6] bg-[rgba(59,130,246,0.12)] text-[#3b82f6]" : "border-[var(--color-border)] text-[var(--color-text-muted)]"].join(" ")}
|
||||||
|
>
|
||||||
|
📢 Уведомление без ссылки
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{formPickupMode === "notification" && (
|
||||||
|
<textarea
|
||||||
|
value={formPickupText}
|
||||||
|
onChange={(e) => setFormPickupText(e.target.value)}
|
||||||
|
placeholder="Ваш заказ готов. Заберите со склада."
|
||||||
|
rows={2}
|
||||||
|
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)] resize-none"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toggles row */}
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
|
||||||
|
<Toggle checked={formSecondSms} onChange={() => setFormSecondSms(!formSecondSms)} label="2-я SMS (повторная отправка)" />
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] p-3">
|
||||||
|
<Toggle checked={formPaidStorage} onChange={() => setFormPaidStorage(!formPaidStorage)} label="SMS о платном хранении" />
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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 ? "Сохранение..." : editingId ? "Обновить профиль" : "Сохранить профиль"}
|
||||||
|
</button>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Profiles list */}
|
||||||
|
<Panel className="p-5 space-y-3">
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-[0.16em] text-[var(--color-text)]">
|
||||||
|
Профили ({profiles.length})
|
||||||
|
</h2>
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">Загрузка...</p>
|
||||||
|
) : profiles.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)]">
|
||||||
|
Профилей нет. Города без профиля используют стандартный флоу (ссылка на согласование).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{profiles.map((p) => (
|
||||||
|
<div key={p.id} className="rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] p-4 space-y-3">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-base font-semibold text-[var(--color-text)]">{p.city}</span>
|
||||||
|
<Toggle checked={p.sms_enabled} onChange={() => handleQuickToggle(p, "sms_enabled")} label="" />
|
||||||
|
{!p.sms_enabled && (
|
||||||
|
<span className="rounded-lg px-2 py-0.5 text-[10px] font-semibold uppercase" style={{ backgroundColor: "rgba(239,68,68,0.12)", color: "#ef4444" }}>SMS отключены</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleEdit(p)}
|
||||||
|
className="rounded-lg border border-[var(--color-border)] px-2 py-1 text-xs font-medium text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)]"
|
||||||
|
>
|
||||||
|
✏️ Изменить
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDelete(p.id)}
|
||||||
|
className="rounded-lg p-1.5 text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-danger)]"
|
||||||
|
title="Удалить"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Config grid */}
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2 text-xs">
|
||||||
|
{/* Delivery */}
|
||||||
|
<div className="rounded-lg bg-[var(--color-bg)] p-3 space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-[var(--color-text)]">📦 Доставка:</span>
|
||||||
|
<ModeBadge mode={p.delivery_mode} />
|
||||||
|
</div>
|
||||||
|
{p.delivery_text && (
|
||||||
|
<p className="text-[var(--color-text-muted)] italic">"{p.delivery_text}"</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Pickup */}
|
||||||
|
<div className="rounded-lg bg-[var(--color-bg)] p-3 space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium text-[var(--color-text)]">🏪 Самовывоз:</span>
|
||||||
|
<ModeBadge mode={p.pickup_mode} />
|
||||||
|
</div>
|
||||||
|
{p.pickup_text && (
|
||||||
|
<p className="text-[var(--color-text-muted)] italic">"{p.pickup_text}"</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toggles */}
|
||||||
|
<div className="flex items-center gap-4 text-xs">
|
||||||
|
<span className={`font-medium ${p.second_sms_enabled ? "text-[var(--color-text)]" : "text-[var(--color-text-muted)] line-through"}`}>
|
||||||
|
2-я SMS: {p.second_sms_enabled ? "✅" : "❌"}
|
||||||
|
</span>
|
||||||
|
<span className={`font-medium ${p.paid_storage_enabled ? "text-[var(--color-text)]" : "text-[var(--color-text-muted)] line-through"}`}>
|
||||||
|
Платное хранение: {p.paid_storage_enabled ? "✅" : "❌"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{p.note && (
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">💬 {p.note}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue