feat(autocall): локации — переключатели звонков по городам в админке (авто-дозвон), гео-правила общие с SMS

This commit is contained in:
Hermes 2026-09-11 11:59:44 +02:00
parent f62a6b525b
commit 139c895290
3 changed files with 169 additions and 0 deletions

View File

@ -135,6 +135,11 @@ def get_groups_to_call(conn, settings):
SELECT 1 FROM sms_city_profiles p SELECT 1 FROM sms_city_profiles p
WHERE p.city = og.town AND p.sms_enabled = false WHERE p.city = og.town AND p.sms_enabled = false
) )
-- Локация выключена для звонков в админке автодозвона
AND NOT EXISTS (
SELECT 1 FROM autocall_city_rules c
WHERE c.city = og.town AND c.calls_enabled = false
)
-- Самовывоз звоним только если разрешено настройкой -- Самовывоз звоним только если разрешено настройкой
AND (COALESCE(og.delivery_type, 'delivery') <> 'pickup' OR %s) AND (COALESCE(og.delivery_type, 'delivery') <> 'pickup' OR %s)
ORDER BY og.sms_sent_at ASC ORDER BY og.sms_sent_at ASC
@ -202,6 +207,15 @@ def get_city_exclusion(conn, city):
"source": "city_profile", "source": "city_profile",
"reason": (profile.get("note") or "").strip() or f"город {city}: канал выключен", "reason": (profile.get("note") or "").strip() or f"город {city}: канал выключен",
} }
# Только звонки: переключатель локации в админке автодозвона
# (на SMS не влияет — уведомления по городу продолжают уходить).
cur.execute("SELECT calls_enabled, note FROM autocall_city_rules WHERE city = %s LIMIT 1", (city,))
calls_rule = cur.fetchone()
if calls_rule is not None and calls_rule.get("calls_enabled") is False:
return {
"source": "city_calls_off",
"reason": (calls_rule.get("note") or "").strip() or f"звонки по {city} выключены в админке",
}
return None return None

View File

@ -11,6 +11,7 @@ import { supabase } from "../../../supabaseClient";
import { useAuth } from "../../../context/AuthContext"; import { useAuth } from "../../../context/AuthContext";
import { TestCallPanel } from "./TestCallPanel"; import { TestCallPanel } from "./TestCallPanel";
import { AutocallExclusionsPanel } from "./AutocallExclusionsPanel"; import { AutocallExclusionsPanel } from "./AutocallExclusionsPanel";
import { AutocallLocationsPanel } from "./AutocallLocationsPanel";
// ── Кампании звонков ───────────────────────────────────────────────────────── // ── Кампании звонков ─────────────────────────────────────────────────────────
const CAMPAIGNS = [ const CAMPAIGNS = [
@ -368,6 +369,9 @@ export const AutocallChannelTab = () => {
{/* ── Тестовый звонок (только мегаадмин) ───────────────────────────── */} {/* ── Тестовый звонок (только мегаадмин) ───────────────────────────── */}
{isMegaAdmin && <TestCallPanel />} {isMegaAdmin && <TestCallPanel />}
{/* ── Локации: где звонит робот ──────────────────────────────────── */}
<AutocallLocationsPanel />
{/* ── Исключения автодозвона ─────────────────────────────────────── */} {/* ── Исключения автодозвона ─────────────────────────────────────── */}
<AutocallExclusionsPanel /> <AutocallExclusionsPanel />

View File

@ -0,0 +1,151 @@
/**
* @file AutocallLocationsPanel.jsx
* @description Панель «Локации» в настройках автодозвона.
* Логист/админ выключает звонки по конкретным городам (например, по Ялте звонки
* не делаем — там работают SMS-уведомления). Правила хранятся в общих городских
* настройках рассылок, но только для звонков (campaign_type='autocall_slot'),
* поэтому SMS по этому городу продолжают уходить.
*/
import React, { useState, useEffect, useCallback } from "react";
import { Panel } from "../../UI/Panel";
import { Badge } from "../../UI/Badge";
import { supabase } from "../../../supabaseClient";
import { useAuth } from "../../../context/AuthContext";
export const AutocallLocationsPanel = () => {
const { user } = useAuth();
const canManage = ["mega_admin", "admin", "logistician"].includes(user?.role);
const [cities, setCities] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [busy, setBusy] = useState(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const { data, error } = await supabase.rpc("autocall_city_options");
if (error) throw error;
setCities(data || []);
} catch (e) {
setError(e.message || String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const handleToggle = async (city) => {
const next = !city.calls_enabled;
setBusy(city.city);
setError(null);
try {
// Пишем в таблицу переключателей звонков: у SMS по этому городу
// своя настройка, и она не меняется.
const { error } = await supabase
.from("autocall_city_rules")
.upsert(
{
city: city.city,
calls_enabled: next,
note: next ? "" : `Звонки по ${city.city} выключены`,
updated_by: user?.email || null,
updated_at: new Date().toISOString(),
},
{ onConflict: "city" }
);
if (error) throw error;
setCities((prev) =>
prev.map((c) => (c.city === city.city ? { ...c, calls_enabled: next } : c))
);
} catch (e) {
setError(e.message || String(e));
} finally {
setBusy(null);
}
};
return (
<Panel className="p-5">
<div className="mb-3 flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold text-[var(--color-text)]">📍 Локации</h3>
<p className="mt-0.5 text-xs text-[var(--color-text-muted)]">
Где звонит робот. Выключенный город звонки не получает — на SMS это не влияет,
уведомления продолжают уходить.
</p>
</div>
{canManage && (
<button
type="button"
onClick={load}
className="rounded-xl border border-[var(--color-border)] px-3 py-1.5 text-xs font-medium text-[var(--color-text)] hover:bg-[var(--color-surface-strong)]"
>
Обновить
</button>
)}
</div>
{error && (
<div className="mb-3 rounded-xl border border-[var(--color-danger)] bg-[var(--color-surface-strong)] p-2.5 text-xs text-[var(--color-danger)]">
{error}
</div>
)}
{loading ? (
<div className="animate-pulse text-xs text-[var(--color-text-muted)]">Загрузка…</div>
) : cities.length === 0 ? (
<div className="rounded-xl border border-dashed border-[var(--color-border)] p-4 text-center text-xs text-[var(--color-text-muted)]">
Городов пока нет.
</div>
) : (
<div className="space-y-1.5">
{cities.map((c) => (
<div
key={c.city}
className={`flex items-center gap-2 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-xs ${
c.calls_enabled ? "" : "opacity-60"
}`}
>
<button
type="button"
onClick={() => handleToggle(c)}
disabled={!canManage || busy === c.city}
className={`relative h-5 w-9 shrink-0 rounded-full transition ${
c.calls_enabled ? "bg-[var(--color-accent)]" : "bg-[var(--color-border)]"
}`}
title={c.calls_enabled ? "Выключить звонки в этот город" : "Включить звонки в этот город"}
>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white shadow transition ${
c.calls_enabled ? "left-[18px]" : "left-0.5"
}`}
/>
</button>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-[var(--color-text)]">{c.city}</span>
<Badge tone={c.calls_enabled ? "accent" : "neutral"}>
{c.calls_enabled ? "звоним" : "не звоним"}
</Badge>
{c.global_blocked && (
<Badge tone="danger">город исключён полностью</Badge>
)}
</div>
{c.note && (
<div className="mt-0.5 truncate text-[11px] text-[var(--color-text-muted)]">{c.note}</div>
)}
{c.global_blocked && (
<div className="mt-0.5 text-[11px] text-[var(--color-danger)]">
В правилах города стоит полное исключение — SMS по нему тоже не уходят.
</div>
)}
</div>
</div>
))}
</div>
)}
</Panel>
);
};