From ac3d6975c13c0ffb1cd6aaac1e5a9a66966c10ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Sat, 4 Jul 2026 11:12:16 +0300 Subject: [PATCH] =?UTF-8?q?feat(=D0=B0=D0=B2=D1=82=D0=BE=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=B1=D0=BE=D1=80/UI):=20=D0=BF=D0=BE=D0=BD=D1=8F=D1=82=D0=BD?= =?UTF-8?q?=D1=8B=D0=B5=20=D0=BC=D0=B5=D1=82=D0=BA=D0=B8=20=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2=20+=20=D0=B2?= =?UTF-8?q?=D0=BF=D0=B5=D1=87=D0=B0=D1=82=D1=8B=D0=B2=D0=B0=D0=B5=D0=BC?= =?UTF-8?q?=D1=8B=D0=B9=20=D1=80=D0=B5=D0=B3=D0=B8=D0=BE=D0=BD=20+=20?= =?UTF-8?q?=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D0=BE=D0=B2=D0=BE=D0=B5=20=D1=81?= =?UTF-8?q?=D0=BE=D0=B7=D0=B4=D0=B0=D0=BD=D0=B8=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FieldCompetitorScreen: 1) метки ✓ настоящий номер / 🎭 рекламный подменный + плейн-язык «где нашли» + легенда «как читать» (данные в БД и сверка с ЭТАЛОНом НЕ тронуты — перевод только в показе); 2) регион «впечатывается» — свой тайп-ахед в стиле экрана (без Vuetify, чтобы не ломать тесты) в окнах «Создать проект» и «Настройки проекта»; чипы для мультивыбора, подпись округа; 3) галка «Вся РФ (все регионы)» даёт баннер-объявление; 4) групповое создание проектов из всех выбранных источников без проекта (source_ids пачкой), заголовок/текст под «несколько». +тест на групповое создание. Фронт-спеки 24/24 зелёные. Co-Authored-By: Claude Opus 4.8 --- .../screens/FieldCompetitorScreen.vue | 226 ++++++++++++++++-- .../FieldCompetitorScreenCreate.spec.ts | 21 ++ 2 files changed, 224 insertions(+), 23 deletions(-) diff --git a/app/resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue b/app/resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue index 8b268711..bcaf9c47 100644 --- a/app/resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue +++ b/app/resources/js/views/autopodbor/screens/FieldCompetitorScreen.vue @@ -2,7 +2,7 @@ import { computed, inject, onMounted, reactive, ref } from 'vue'; import { useAutopodborStore } from '../../../stores/autopodborStore'; import { autopodborErrorMessage, type FieldSourceDto, type WhereFound } from '../../../api/autopodbor'; -import { REGIONS } from '../../../constants/regions'; +import { REGIONS, FEDERAL_DISTRICT_NAMES } from '../../../constants/regions'; import { useOverlayDismiss } from '../../../composables/useOverlayDismiss'; interface AutopodborNav { @@ -102,9 +102,22 @@ function phoneTypeLabel(s: FieldSourceDto) { if (s.signal_type !== 'call') return ''; return { city: 'городской', mobile: 'мобильный', tollfree: '8-800' }[s.phone_type ?? ''] ?? ''; } -function phoneBadge(s: FieldSourceDto) { - if (s.signal_type !== 'call') return ''; - return s.phone_kind === 'substitute' ? '🎭' : '✓'; +// Понятная клиенту метка вида номера: настоящий vs рекламный подменный. +function kindTag(s: FieldSourceDto): { text: string; cls: string } | null { + if (s.signal_type !== 'call') return null; + return s.phone_kind === 'substitute' + ? { text: '🎭 рекламный подменный', cls: 'ld-b-stop' } + : { text: '✓ настоящий номер', cls: 'ld-b-run' }; +} +// Технические метки «где нашли» → человеческий язык (данные в БД не трогаем — только показ). +const WF_LABELS: Record = { + 'в коде сайта': 'на сайте', + коллтрекинг: 'показан на сайте', + 'коллтрекинг (пул)': 'показан на сайте', + 'на сайте (код города?)': 'на сайте (проверить)', +}; +function wfLabel(l: string): string { + return WF_LABELS[l] ?? l; } function sourceValue(s: FieldSourceDto) { return s.project?.signal_identifier ?? s.identifier; @@ -289,9 +302,53 @@ async function saveAdd() { // ——— создать проект из источника ——— // regions=[] + vsyaRf=true → «Вся РФ» (как в обычных проектах: пустой массив = вся страна). -const create = reactive({ open: false, srcId: null as number | null, srcLabel: '', regions: [] as number[], vsyaRf: true, limit: 5, mask: 127 }); +const create = reactive({ open: false, srcIds: [] as number[], srcLabel: '', regions: [] as number[], vsyaRf: true, limit: 5, mask: 127 }); function openCreate(s: FieldSourceDto) { - Object.assign(create, { open: true, srcId: s.id, srcLabel: sourceValue(s), regions: [], vsyaRf: true, limit: 5, mask: 127 }); + regQuery.value = ''; + Object.assign(create, { open: true, srcIds: [s.id], srcLabel: sourceValue(s), regions: [], vsyaRf: true, limit: 5, mask: 127 }); +} +// Групповое создание: проекты для всех выбранных источников БЕЗ проекта — одним окном. +function openBulkCreate() { + const ids = selWork.value.filter((s) => !s.project).map((s) => s.id); + if (!ids.length) return; + regQuery.value = ''; + Object.assign(create, { open: true, srcIds: ids, srcLabel: `выбрано источников: ${ids.length}`, regions: [], vsyaRf: true, limit: 5, mask: 127 }); +} +function onCreateVsyaRf() { + if (create.vsyaRf) create.regions = []; +} + +// ——— свой тайп-ахед региона («впечатывается», как на слайде 1; в стиле экрана, без Vuetify) ——— +function regionName(code: number): string { + return regions.find((r) => r.code === code)?.name ?? String(code); +} +// создание — мультивыбор +const regQuery = ref(''); +const regOpen = ref(false); +const regFiltered = computed(() => { + const q = regQuery.value.trim().toLowerCase(); + const chosen = new Set(create.regions); + return regions.filter((r) => !chosen.has(r.code) && (q === '' || r.name.toLowerCase().includes(q))).slice(0, 8); +}); +function pickRegion(code: number) { + if (!create.regions.includes(code)) create.regions.push(code); + create.vsyaRf = false; + regQuery.value = ''; +} +function removeRegion(code: number) { + create.regions = create.regions.filter((c) => c !== code); +} +// настройки — одиночный +const setQuery = ref(''); +const setOpen = ref(false); +const setFiltered = computed(() => { + const q = setQuery.value.trim().toLowerCase(); + return regions.filter((r) => q === '' || r.name.toLowerCase().includes(q)).slice(0, 8); +}); +function pickSetRegion(code: number) { + psettings.regionCode = code; + setQuery.value = regionName(code); + setOpen.value = false; } function isDayOn(mask: number, i: number) { return (mask & (1 << i)) !== 0; @@ -303,24 +360,34 @@ function setCreateDays(all: boolean) { create.mask = all ? 127 : 31; } async function doCreate(launch: boolean) { - if (create.srcId === null) return; + if (create.srcIds.length === 0) return; // «Вся РФ» снята, но регион не выбран — не создаём молча, просим выбрать. if (!create.vsyaRf && create.regions.length === 0) { flash('Выберите хотя бы один регион или отметьте «Вся РФ».'); return; } + const many = create.srcIds.length > 1; busy.value = true; try { await store.makeProjects({ - source_ids: [create.srcId], + source_ids: create.srcIds, regions: create.vsyaRf ? [] : create.regions, daily_limit_target: create.limit, delivery_days_mask: create.mask, launch, }); create.open = false; + selected.value = []; await reload(); - flash(launch ? 'Проект создан, идёт сбор ▶' : 'Проект создан (на паузе — запустите, когда готовы)'); + flash( + many + ? launch + ? `Создано проектов: ${create.srcIds.length}, идёт сбор ▶` + : `Создано проектов: ${create.srcIds.length} (на паузе — запустите, когда готовы)` + : launch + ? 'Проект создан, идёт сбор ▶' + : 'Проект создан (на паузе — запустите, когда готовы)', + ); } catch (e) { flash(autopodborErrorMessage(e, 'Не удалось создать проект. Попробуйте ещё раз.')); } finally { @@ -337,6 +404,7 @@ function openSettings(s: FieldSourceDto) { regionCode: s.project.regions?.[0] ?? regions[0]?.code ?? 24, limit: s.project.daily_limit_target, mask: s.project.delivery_days_mask || 127, }); + setQuery.value = regionName(psettings.regionCode); } function toggleSetDay(i: number) { psettings.mask ^= 1 << i; @@ -445,7 +513,7 @@ onMounted(() => { void reload(); }); -defineExpose({ ctab, selected, inWork, props: props, toast, create, switchTab, toggle, openEdit, openCollect, openAdd, openCreate, doCreate, toggleProject, bulkWork, bulkMoveToWork, moveToWork, sourceValue }); +defineExpose({ ctab, selected, inWork, props: props, toast, create, switchTab, toggle, openEdit, openCollect, openAdd, openCreate, openBulkCreate, doCreate, toggleProject, bulkWork, bulkMoveToWork, moveToWork, sourceValue });