diff --git a/src/components/admin/autocall/AutocallExclusionsPanel.jsx b/src/components/admin/autocall/AutocallExclusionsPanel.jsx
new file mode 100644
index 0000000..aa7faee
--- /dev/null
+++ b/src/components/admin/autocall/AutocallExclusionsPanel.jsx
@@ -0,0 +1,226 @@
+/**
+ * @file AutocallExclusionsPanel.jsx
+ * @description Панель «Исключения автодозвона» в настройках коммуникаций.
+ * Логист/админ добавляет префиксы или точные номера, на которые робот не звонит
+ * (например, МТС-диапазон, пока не починят DTMF). Правило можно отключить галочкой
+ * или удалить — и звонки по нему возобновятся.
+ */
+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";
+
+const fmtPhone = (phone) => {
+ if (!phone) return "—";
+ const d = String(phone).replace(/\D/g, "");
+ if (d.length === 11 && d.startsWith("7")) {
+ return `+7 (${d.slice(1, 4)}) ${d.slice(4, 7)}-${d.slice(7, 9)}-${d.slice(9)}`;
+ }
+ return phone;
+};
+
+export const AutocallExclusionsPanel = () => {
+ const { user } = useAuth();
+ const canManage = ["mega_admin", "admin", "logistician"].includes(user?.role);
+ const [items, setItems] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [ruleType, setRuleType] = useState("prefix");
+ const [value, setValue] = useState("");
+ const [reason, setReason] = useState("");
+ const [saving, setSaving] = useState(false);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const { data, error } = await supabase
+ .from("autocall_exclusions")
+ .select("*")
+ .order("created_at", { ascending: false });
+ if (error) throw error;
+ setItems(data || []);
+ } catch (e) {
+ setError(e.message || String(e));
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => { load(); }, [load]);
+
+ const handleAdd = async () => {
+ const v = String(value || "").replace(/\D/g, "");
+ if (!v) { setError("Введите номер или префикс (только цифры)"); return; }
+ setSaving(true);
+ setError(null);
+ try {
+ const { error } = await supabase.from("autocall_exclusions").insert({
+ rule_type: ruleType,
+ value: v,
+ reason: reason.trim(),
+ active: true,
+ created_by: user?.email || null,
+ });
+ if (error) throw error;
+ setValue(""); setReason("");
+ await load();
+ } catch (e) {
+ setError(e.message || String(e));
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const handleToggle = async (item) => {
+ try {
+ const { error } = await supabase
+ .from("autocall_exclusions")
+ .update({ active: !item.active })
+ .eq("id", item.id);
+ if (error) throw error;
+ setItems((prev) => prev.map((i) => (i.id === item.id ? { ...i, active: !item.active } : i)));
+ } catch (e) {
+ setError(e.message || String(e));
+ }
+ };
+
+ const handleDelete = async (item) => {
+ try {
+ const { error } = await supabase.from("autocall_exclusions").delete().eq("id", item.id);
+ if (error) throw error;
+ setItems((prev) => prev.filter((i) => i.id !== item.id));
+ } catch (e) {
+ setError(e.message || String(e));
+ }
+ };
+
+ return (
+
+
+
+
🚫 Исключения автодозвона
+
+ Номера и префиксы, на которые робот не звонит. Отключите или удалите правило — звонки возобновятся.
+
+
+ {canManage && (
+
+ )}
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Добавление */}
+ {canManage && (
+
+
+ Тип
+
+
+
+
+ {ruleType === "prefix" ? "Префикс (напр. 7918)" : "Номер (напр. 79189521296)"}
+
+ setValue(e.target.value)}
+ placeholder={ruleType === "prefix" ? "7918" : "79189521296"}
+ className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
+ />
+
+
+ Причина (для карточки)
+ setReason(e.target.value)}
+ placeholder="МТС — кнопки не проходят"
+ className="w-full rounded-xl border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-xs text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
+ />
+
+
+
+ )}
+
+ {/* Список */}
+ {loading ? (
+ Загрузка…
+ ) : items.length === 0 ? (
+
+ Исключений нет — робот звонит на все номера.
+
+ ) : (
+
+ {items.map((item) => (
+
+
+
+
+
+ {item.rule_type === "prefix" ? `📡 ${item.value}…` : `📞 ${fmtPhone(item.value)}`}
+
+
+ {item.active ? "не звоним" : "выключено"}
+
+
+ {item.reason && (
+
{item.reason}
+ )}
+
+ {canManage && (
+
+ )}
+
+ ))}
+
+ )}
+
+ );
+};
diff --git a/src/components/orders/AutocallStatusCard.jsx b/src/components/orders/AutocallStatusCard.jsx
index a853bc7..5275116 100644
--- a/src/components/orders/AutocallStatusCard.jsx
+++ b/src/components/orders/AutocallStatusCard.jsx
@@ -50,6 +50,7 @@ export const AutocallStatusCard = ({ order, userRole }) => {
const canManage = ["mega_admin", "logistician"].includes(userRole);
const optOut = order.autocall_opt_out ?? order.autocallOptOut ?? false;
const isAutocall = order.delivery_date_source === "autocall" || order.deliveryDateSource === "autocall";
+ const exclusionReason = order.autocall_exclusion_reason || order.autocallExclusionReason || null;
const loadCalls = useCallback(async () => {
if (!order?.id) return;
@@ -122,6 +123,8 @@ export const AutocallStatusCard = ({ order, userRole }) => {
{optOut ? (
🚫 Отказ от звонков
+ ) : exclusionReason ? (
+ 🚫 Не звоним (исключение)
) : isAutocall ? (
🤖 Согласовано роботом
) : calls.length > 0 ? (
@@ -132,6 +135,13 @@ export const AutocallStatusCard = ({ order, userRole }) => {
{lastSlot && {lastSlot}}
+ {/* Пояснение исключения */}
+ {exclusionReason && !optOut && (
+
+ 🚫 Робот не звонит на этот номер по настройке: {exclusionReason}
+
+ )}
+
{/* Попытки */}
{calls.length > 0 ? (
diff --git a/src/services/supabase/orderGroupRepository.js b/src/services/supabase/orderGroupRepository.js
index 64dd5dd..148865c 100644
--- a/src/services/supabase/orderGroupRepository.js
+++ b/src/services/supabase/orderGroupRepository.js
@@ -294,7 +294,7 @@ export const mapOrderGroupRowToDeliveryGroup = (row) => {
};
const ORDER_GROUP_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, source_orders, order_list, order_list_structured, delivery_invitation_id, delivery_link, delivery_link_code, notification_status, sms_attempts, first_sms_sent_at, second_sms_sent_at, last_sms_error, next_notification_check_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, autocall_opt_out, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, delivery_invitation:delivery_invitations!order_groups_delivery_invitation_id_fkey(opened_at, access_count, last_accessed_at), called, called_at, called_comment, order_history(id, action, old_status, new_status, user_id, metadata, created_at)`;
-export const ORDER_GROUP_LIST_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, notification_status, sms_attempts, last_sms_error, first_sms_sent_at, second_sms_sent_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, autocall_opt_out, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, called, called_at, called_comment`;
+export const ORDER_GROUP_LIST_SELECT_FIELDS = `id, group_key, order_numbers, status, delivery_status, sms_sent_at, created_at, updated_at, created_from_exchange_at, source_key, town, customer_name, customer_phone, customer_phone_normalized, customer_date, orders_total, orders_ready, orders_not_ready, notification_status, sms_attempts, last_sms_error, first_sms_sent_at, second_sms_sent_at, delivery_date, delivery_time, delivery_address, customer_address, delivery_date_source, autocall_opt_out, manual_confirmation_at, paid_storage_at, assigned_driver_id, assigned_driver:users!order_groups_assigned_driver_id_fkey(id, name), driver_shipment_data, delivery_type, pickup_date, pickup_time_slot, has_delivery_problem, delivery_problem_note, pickup_code, synced_to_1c_at, manager_name, manager_email, manager_tel, payed_ship, is_payed_ship, called, called_at, called_comment, autocall_exclusion_reason`;
// Delivery statuses treated as "finished" — hidden by default in the board.
const COMPLETED_DELIVERY_STATUSES = ["delivered", "picked_up", "cancelled"];
diff --git a/supabase/migrations/20260908_autocall_exclusions.sql b/supabase/migrations/20260908_autocall_exclusions.sql
new file mode 100644
index 0000000..caca3a6
--- /dev/null
+++ b/supabase/migrations/20260908_autocall_exclusions.sql
@@ -0,0 +1,84 @@
+-- ============================================================
+-- SuperSam: Автодозвон — исключения номеров/префиксов (Фаза 8)
+-- 08.09.2026. Аддитивная, безопасна для прод-базы.
+-- Применять: docker exec supabase-db psql -U supabase_admin -d postgres -f
+-- ============================================================
+
+-- 1) Таблица исключений
+create table if not exists public.autocall_exclusions (
+ id bigint generated always as identity primary key,
+ rule_type text not null check (rule_type in ('prefix','phone')),
+ value text not null, -- '7918' или '79189521296'
+ reason text not null default '', -- 'МТС — кнопки не проходят'
+ active boolean not null default true,
+ created_by text, -- email логиста/админа, кто добавил
+ created_at timestamptz not null default now(),
+ unique (rule_type, value)
+);
+create index if not exists idx_autocall_excl_active on public.autocall_exclusions(active) where active;
+comment on table public.autocall_exclusions is 'Исключения автодозвона: префиксы/номера, на которые робот не звонит';
+
+grant select on public.autocall_exclusions to authenticated;
+-- запись только для админов/логистов через RLS (включаем RLS)
+alter table public.autocall_exclusions enable row level security;
+
+-- политики: читать все залогиненные; писать только admin/mega_admin/logistician
+do $$
+begin
+ if not exists (select 1 from pg_policies where tablename='autocall_exclusions' and policyname='autocall_excl_select') then
+ create policy autocall_excl_select on public.autocall_exclusions
+ for select to authenticated using (true);
+ end if;
+ if not exists (select 1 from pg_policies where tablename='autocall_exclusions' and policyname='autocall_excl_write') then
+ create policy autocall_excl_write on public.autocall_exclusions
+ for all to authenticated
+ using (
+ exists (
+ select 1 from public.users u
+ where u.id = auth.uid()
+ and u.role in ('mega_admin','admin','logistician')
+ )
+ )
+ with check (
+ exists (
+ select 1 from public.users u
+ where u.id = auth.uid()
+ and u.role in ('mega_admin','admin','logistician')
+ )
+ );
+ end if;
+end $$;
+
+-- 2) RPC: проверка номера по исключениям (нормализуем телефон до 11 цифр без +7/8)
+create or replace function public.autocall_exclusion_match(p_phone text)
+returns table(rule_type text, value text, reason text)
+language sql stable security definer
+set search_path = public
+as $$
+ with norm as (
+ select case
+ when length(regexp_replace(coalesce(p_phone,''), '\D', '', 'g')) = 11
+ then regexp_replace(coalesce(p_phone,''), '\D', '', 'g')
+ when length(regexp_replace(coalesce(p_phone,''), '\D', '', 'g')) = 10
+ then '7' || regexp_replace(coalesce(p_phone,''), '\D', '', 'g')
+ else regexp_replace(coalesce(p_phone,''), '\D', '', 'g')
+ end as phone
+ )
+ select e.rule_type, e.value, e.reason
+ from public.autocall_exclusions e, norm n
+ where e.active
+ and (
+ (e.rule_type = 'phone' and n.phone = e.value)
+ or
+ (e.rule_type = 'prefix' and n.phone like e.value || '%')
+ )
+ limit 1;
+$$;
+
+grant execute on function public.autocall_exclusion_match(text) to authenticated;
+grant execute on function public.autocall_exclusion_match(text) to service_role;
+
+-- 3) Стартовое правило — МТС диапазон, который мы точно знаем (неактивно по умолчанию? ставим активным — пользователь просил)
+insert into public.autocall_exclusions (rule_type, value, reason, active, created_by)
+values ('prefix', '7918952', 'МТС — DTMF не проходит (Miranda)', true, 'system')
+on conflict do nothing;
diff --git a/supabase/migrations/20260908_autocall_exclusions_view.sql b/supabase/migrations/20260908_autocall_exclusions_view.sql
new file mode 100644
index 0000000..0f8f46f
--- /dev/null
+++ b/supabase/migrations/20260908_autocall_exclusions_view.sql
@@ -0,0 +1,42 @@
+-- ============================================================
+-- SuperSam: Автодозвон — исключения в view реестра (Фаза 8b)
+-- 08.09.2026. Аддитивная. Дополняет order_group_comms_flags полями
+-- autocall_excluded / autocall_exclusion_reason, чтобы фронт показывал
+-- бейдж «не звоним (исключение)» без отдельного запроса.
+-- ============================================================
+
+drop view if exists public.order_group_comms_flags;
+create view public.order_group_comms_flags as
+select
+ og.id,
+ og.autocall_opt_out,
+ og.delivery_date_source,
+ og.notification_status,
+ og.delivery_status,
+ og.customer_phone_normalized,
+ (select l.outcome from public.autocall_log l where l.order_group_id = og.id order by l.created_at desc limit 1) as last_call_outcome,
+ (select l.changed from public.autocall_log l where l.order_group_id = og.id order by l.created_at desc limit 1) as last_call_changed,
+ (select l.created_at from public.autocall_log l where l.order_group_id = og.id order by l.created_at desc limit 1) as last_call_at,
+ (select count(*) from public.autocall_log l where l.order_group_id = og.id) as call_attempts,
+ (select count(*) from public.autocall_log l where l.order_group_id = og.id
+ and l.outcome in ('no_input_day','no_input_half')) as no_input_count,
+ -- Исключение автодозвона (префикс/номер)
+ (select e.reason from public.autocall_exclusions e
+ where e.active
+ and (
+ (e.rule_type = 'phone' and coalesce(og.customer_phone_normalized, regexp_replace(coalesce(og.customer_phone,''),'\D','','g')) = e.value)
+ or
+ (e.rule_type = 'prefix' and coalesce(og.customer_phone_normalized, regexp_replace(coalesce(og.customer_phone,''),'\D','','g')) like e.value || '%')
+ )
+ limit 1) as autocall_exclusion_reason,
+ (select e.value from public.autocall_exclusions e
+ where e.active
+ and (
+ (e.rule_type = 'phone' and coalesce(og.customer_phone_normalized, regexp_replace(coalesce(og.customer_phone,''),'\D','','g')) = e.value)
+ or
+ (e.rule_type = 'prefix' and coalesce(og.customer_phone_normalized, regexp_replace(coalesce(og.customer_phone,''),'\D','','g')) like e.value || '%')
+ )
+ limit 1) as autocall_exclusion_value
+from public.order_groups og;
+
+grant select on public.order_group_comms_flags to authenticated;