diff --git a/app/app/Http/Controllers/Api/Sales/SalesProspectController.php b/app/app/Http/Controllers/Api/Sales/SalesProspectController.php index 980f7026..6c4ecd80 100644 --- a/app/app/Http/Controllers/Api/Sales/SalesProspectController.php +++ b/app/app/Http/Controllers/Api/Sales/SalesProspectController.php @@ -16,6 +16,7 @@ use App\Services\DaData\PartyLookup; use App\Services\Sales\SalesAttachmentService; use App\Support\InnValidator; use App\Support\PhoneNormalizer; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Validation\Rule; @@ -130,9 +131,9 @@ class SalesProspectController extends Controller * * Без date_mode доска отдаёт всё, как раньше. * - * @param \Illuminate\Database\Eloquent\Builder $query + * @param Builder $query */ - private function applyDateFilter(\Illuminate\Database\Eloquent\Builder $query, Request $request): void + private function applyDateFilter(Builder $query, Request $request): void { $mode = (string) $request->query('date_mode', ''); if (! in_array($mode, ['todo', 'changed'], true)) { diff --git a/app/resources/js/api/sales.ts b/app/resources/js/api/sales.ts index ac4d5166..13100ae3 100644 --- a/app/resources/js/api/sales.ts +++ b/app/resources/js/api/sales.ts @@ -1251,6 +1251,8 @@ export interface SalesProspect { id: number; sales_user_id: number; stage: string; + /** Откуда карточка приехала на текущую стадию — из него считается дробь «69/1». */ + prev_stage: string | null; source: ProspectSource; /** Бренд/вывеска — его показываем на карточке канбана. */ firm_name: string; @@ -1298,15 +1300,43 @@ export interface ProspectsResponse { manager_counts?: Record; } +/** + * Фильтр доски по датам: + * todo — что надо сделать в этот срок (плюс всё просроченное); + * changed — что менялось в этот срок (любое движение карточки). + */ +export type ProspectDateMode = 'todo' | 'changed'; + +/** Вид периода — те же, что понимает SalesPeriodResolver на сервере. */ +export type ProspectPeriodKind = 'today' | 'tomorrow' | 'yesterday' | 'd7' | 'd30' | 'custom'; + +export interface ProspectDateFilterValue { + mode: ProspectDateMode | null; + period: ProspectPeriodKind; + from?: string | null; + to?: string | null; +} + export async function listProspects( managerId?: number, source?: ProspectSource, scope?: 'department', + dateFilter?: ProspectDateFilterValue | null, ): Promise { const params: Record = {}; if (scope) params.scope = scope; if (managerId) params.manager_id = managerId; if (source) params.source = source; + if (dateFilter?.mode) { + params.date_mode = dateFilter.mode; + params.period = dateFilter.period; + // Произвольный период уходит только парой дат — сервер на половинчатом + // ответит 422, и доска дёрнулась бы ошибкой на полпути выбора. + if (dateFilter.period === 'custom' && dateFilter.from && dateFilter.to) { + params.from = dateFilter.from; + params.to = dateFilter.to; + } + } const { data } = await axios.get('/api/sales/prospects', { headers: authHeaders(), params, @@ -1326,6 +1356,7 @@ export interface ProspectResultPayload { | 'registered' | 'manual_testing' | 'kp_sent' + | 'trash' | 'opened' | 'back_to_new'; next_call_at?: string; diff --git a/app/resources/js/components/sales/ProspectDateFilter.vue b/app/resources/js/components/sales/ProspectDateFilter.vue new file mode 100644 index 00000000..0bda6851 --- /dev/null +++ b/app/resources/js/components/sales/ProspectDateFilter.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/app/resources/js/components/sales/SalesProspectBoard.vue b/app/resources/js/components/sales/SalesProspectBoard.vue index 1c839412..419cab77 100644 --- a/app/resources/js/components/sales/SalesProspectBoard.vue +++ b/app/resources/js/components/sales/SalesProspectBoard.vue @@ -56,7 +56,13 @@ const columns = computed(() => if (stage.key === 'negotiation') { cards.sort((a, b) => callTime(a) - callTime(b)); } - return { ...stage, cards }; + // «Отказ» показывает второе число через дробь: сколько отказов пришло + // ПРЯМО из ручного тестирования — «69/1». Ноль не рисуем: «69/0» — шум. + const fromTesting = + stage.key === 'rejected' + ? cards.filter((c) => c.prev_stage === 'manual_testing').length + : 0; + return { ...stage, cards, subCount: fromTesting > 0 ? fromTesting : null }; }), ); @@ -72,7 +78,12 @@ const columns = computed(() =>
{{ col.title }} - {{ col.cards.length }} + + {{ col.cards.length }} +
(); -const action = ref<'' | 'negotiation' | 'no_answer' | 'rejected' | 'registered' | 'manual_testing' | 'kp_sent'>(''); +const action = ref< + '' | 'negotiation' | 'no_answer' | 'rejected' | 'registered' | 'manual_testing' | 'kp_sent' | 'trash' +>(''); const nextCallAt = ref(''); const reason = ref(''); /** Краткое содержание разговора — уходит вместе с результатом и ложится в журнал (§20). */ @@ -232,14 +234,20 @@ const availableActions = computed(() => { base.push({ value: 'kp_sent', title: 'Выслано КП' }); } // «Зарегистрировался» — пока клиент ещё не привязан (до денег): new/переговоры/недозвон/отказ - // плюс обе новые ручные стадии, иначе из них не выбраться дальше по воронке. - if (['new', 'negotiation', 'manual_testing', 'kp_sent', 'no_answer', 'rejected'].includes(props.prospect.stage)) { + // плюс обе новые ручные стадии и корзина, иначе из них не выбраться дальше по воронке + // (карточку могли выбросить по ошибке — путь назад должен остаться). + if (['new', 'negotiation', 'manual_testing', 'kp_sent', 'no_answer', 'rejected', 'trash'] + .includes(props.prospect.stage)) { base.push({ value: 'registered', title: 'Зарегистрировался' }); } // Отказ — на любой стадии, кроме user. if (props.prospect.stage !== 'user') { base.push({ value: 'rejected', title: 'Отказ' }); } + // Корзина — как и отказ, но платящего клиента выбросить нельзя. + if (! ['topped_up', 'user'].includes(props.prospect.stage)) { + base.push({ value: 'trash', title: 'В корзину' }); + } return base; }); @@ -285,6 +293,14 @@ async function submit(): Promise { return; } emit('save', { action: 'rejected', reason: reason.value, summary: summary.value }); + } else if (action.value === 'trash') { + if (!reason.value.trim()) { + error.value = 'Укажите причину.'; + return; + } + // Дату созвона намеренно не шлём, даже если менеджер её набрал до + // переключения результата: у выброшенной карточки её быть не должно. + emit('save', { action: 'trash', reason: reason.value, summary: summary.value }); } else if (action.value === 'registered') { if (!email.value.trim()) { error.value = 'Укажите e-mail аккаунта клиента.'; @@ -320,15 +336,13 @@ async function submit(): Promise { error.value = 'Укажите адрес или номер, куда ушло КП.'; return; } - if (!nextCallAt.value) { - error.value = 'Укажите дату и время следующего созвона.'; - return; - } + // Дата созвона НЕобязательна: бывает «пришлите, созвонимся через пару + // дней», а бывает «пришлите на почту, если интересно — перезвоню». emit('save', { action: 'kp_sent', kp_channel: kpChannel.value, kp_target: kpTarget.value.trim(), - next_call_at: nextCallAt.value, + ...(nextCallAt.value ? { next_call_at: nextCallAt.value } : {}), summary: summary.value, }); } else { @@ -485,15 +499,18 @@ defineExpose({ v-if="action === 'negotiation' || action === 'manual_testing' || action === 'kp_sent'" v-model="nextCallAt" type="datetime-local" - label="Следующий созвон" + :label="action === 'kp_sent' ? 'Следующий созвон (если договорились)' : 'Следующий созвон'" + :hint="action === 'kp_sent' ? 'можно оставить пустым — клиент перезвонит сам' : undefined" + :persistent-hint="action === 'kp_sent'" density="comfortable" variant="outlined" hide-details="auto" /> = Object.fromEntries(PROSPECT_STAGES.map((s) => [s.key, s])); diff --git a/app/resources/js/views/sales/SalesProspectsBoardView.vue b/app/resources/js/views/sales/SalesProspectsBoardView.vue index a0735a9c..3f635599 100644 --- a/app/resources/js/views/sales/SalesProspectsBoardView.vue +++ b/app/resources/js/views/sales/SalesProspectsBoardView.vue @@ -9,10 +9,12 @@ import { listProspects, listSalesManagers, updateProspect, + type ProspectDateFilterValue, type ProspectResultPayload, type SalesManagerRow, type SalesProspect, } from '../../api/sales'; +import ProspectDateFilter from '../../components/sales/ProspectDateFilter.vue'; import SalesProspectBoard from '../../components/sales/SalesProspectBoard.vue'; import SalesProspectDialog from '../../components/sales/SalesProspectDialog.vue'; @@ -22,6 +24,8 @@ const managerCounts = ref>({}); const managerId = ref(null); /** Фильтр происхождения: все / отдал начальник из поиска / инициатива менеджера. */ const source = ref<'search' | 'manager' | null>(null); +/** Фильтр по датам: «что надо сделать» / «что менялось». Пусто — вся доска. */ +const dateFilter = ref(null); const sourceItems = [ { value: null, title: 'Все карточки' }, { value: 'search', title: 'От начальника (из поиска)' }, @@ -49,7 +53,12 @@ async function load(): Promise { error.value = ''; try { // scope=department — это экран НАЧАЛЬНИКА: весь отдел, а не личные карточки. - const res = await listProspects(managerId.value ?? undefined, source.value ?? undefined, 'department'); + const res = await listProspects( + managerId.value ?? undefined, + source.value ?? undefined, + 'department', + dateFilter.value, + ); prospects.value = res.prospects; if (res.manager_counts) managerCounts.value = res.manager_counts; } catch (e) { @@ -84,7 +93,13 @@ onMounted(async () => { await load(); }); -defineExpose({ managerId, source, load, managerItems }); +/** Фильтр сменился — перезапрашиваем доску: выборку делает сервер. */ +async function applyDateFilter(value: ProspectDateFilterValue): Promise { + dateFilter.value = value.mode ? value : null; + await load(); +} + +defineExpose({ managerId, source, load, managerItems, applyDateFilter, dateFilter });