feat(autocall): локации — переключатели звонков по городам в админке (авто-дозвон), гео-правила общие с SMS
This commit is contained in:
parent
f62a6b525b
commit
139c895290
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue