diff --git a/app/app/Http/Controllers/Api/Sales/SalesProspectController.php b/app/app/Http/Controllers/Api/Sales/SalesProspectController.php index 7d64f56a..6c4ecd80 100644 --- a/app/app/Http/Controllers/Api/Sales/SalesProspectController.php +++ b/app/app/Http/Controllers/Api/Sales/SalesProspectController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\Sales; +use App\Http\Controllers\Concerns\ResolvesSalesPeriod; use App\Http\Controllers\Controller; use App\Models\SalesAdAudienceFirm; use App\Models\SalesAdAudienceWarmingEpisode; @@ -15,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; @@ -30,10 +32,15 @@ use Illuminate\Validation\ValidationException; */ class SalesProspectController extends Controller { + use ResolvesSalesPeriod; + + /** Стадии, по которым делать уже нечего — в фильтр «что надо сделать» не попадают. */ + private const DEAD_STAGES = ['rejected', 'trash']; + /** Все стадии в порядке колонок канбана. Должно совпадать с PROSPECT_STAGES (фронт). */ private const STAGES = [ 'new', 'in_work', 'negotiation', 'manual_testing', 'registered', 'testing', - 'topped_up', 'user', 'kp_sent', 'rejected', 'no_answer', + 'topped_up', 'user', 'kp_sent', 'rejected', 'no_answer', 'trash', ]; /** @@ -78,6 +85,8 @@ class SalesProspectController extends Controller $query->where('source', (string) $request->query('source')); } + $this->applyDateFilter($query, $request); + $all = $query->get(); $warmingByProspect = $this->warmingByProspect($all->pluck('id')->all()); $prospects = $all->map(fn (SalesProspect $p) => $this->row($p, $warmingByProspect))->all(); @@ -108,6 +117,57 @@ class SalesProspectController extends Controller ]); } + /** + * Фильтр доски по датам. Два режима, они взаимоисключающие по смыслу: + * + * todo — «что надо сделать»: созвон попадает в период ИЛИ уже просрочен. + * Просроченное показывается ВСЕГДА, какой бы период ни выбрали: + * забытый звонок недельной давности важнее завтрашнего. Отказ и + * корзина не показываются — по ним делать нечего. + * changed — «что менялось»: у карточки есть движение по стадиям ИЛИ запись + * в журнале разговоров за период. Движения включают автоматические + * (деньги → тестирование/пополнил/пользователь) — владелец просил + * считать ЛЮБОЕ движение, а не только разговоры. + * + * Без date_mode доска отдаёт всё, как раньше. + * + * @param Builder $query + */ + private function applyDateFilter(Builder $query, Request $request): void + { + $mode = (string) $request->query('date_mode', ''); + if (! in_array($mode, ['todo', 'changed'], true)) { + return; + } + + $range = $this->resolvePeriod($request); + + if ($mode === 'todo') { + $query->whereNotIn('stage', self::DEAD_STAGES) + ->whereNotNull('next_call_at') + ->where(function ($q) use ($range) { + $q->whereBetween('next_call_at', [$range->start, $range->end]) + ->orWhere('next_call_at', '<', now()); + }); + + return; + } + + $query->where(function ($q) use ($range) { + $q->whereExists(function ($sub) use ($range) { + $sub->selectRaw('1') + ->from('sales_prospect_moves') + ->whereColumn('sales_prospect_moves.prospect_id', 'sales_prospects.id') + ->whereBetween('sales_prospect_moves.created_at', [$range->start, $range->end]); + })->orWhereExists(function ($sub) use ($range) { + $sub->selectRaw('1') + ->from('sales_prospect_notes') + ->whereColumn('sales_prospect_notes.prospect_id', 'sales_prospects.id') + ->whereBetween('sales_prospect_notes.created_at', [$range->start, $range->end]); + }); + }); + } + /** * Менеджер заводит СВОЕГО кандидата (инициатива, не из поиска). * Карточка всегда создаётся автору (sales_user_id из тела игнорируется), @@ -416,19 +476,39 @@ class SalesProspectController extends Controller case 'kp_sent': $this->assertNotPaying($prospect); $data = $request->validate([ - 'next_call_at' => ['required', 'date'], + // Дата созвона НЕобязательна (правка 01.08.2026): бывает «пришлите, + // созвонимся через пару дней» — дата есть, и бывает «пришлите на + // почту, если интересно — перезвоню» — договорённости о созвоне нет. + 'next_call_at' => ['nullable', 'date'], 'kp_channel' => ['required', 'string', Rule::in(array_keys(self::KP_CHANNEL_TITLES))], // Обязательно и при «другое»: запись «КП ушло куда-то» бесполезна. 'kp_target' => ['required', 'string', 'max:500'], ]); $prospect->stage = 'kp_sent'; - $prospect->next_call_at = $data['next_call_at']; $prospect->kp_channel = $data['kp_channel']; // Телеграм-ник, почта и номер — разные вещи, к одному виду не приводим. $prospect->kp_target = trim($data['kp_target']); $prospect->reason = null; - $logEntry = 'Выслано КП: '.self::KP_CHANNEL_TITLES[$data['kp_channel']].' '.$prospect->kp_target - .'. Следующий созвон '.$prospect->next_call_at->format('d.m.Y H:i'); + $logEntry = 'Выслано КП: '.self::KP_CHANNEL_TITLES[$data['kp_channel']].' '.$prospect->kp_target; + if (! empty($data['next_call_at'])) { + $prospect->next_call_at = $data['next_call_at']; + $logEntry .= '. Следующий созвон '.$prospect->next_call_at->format('d.m.Y H:i'); + } + break; + + case 'trash': + // Карточка, с которой больше не работаем: мусор, дубль, не наш профиль. + // Платящего выбросить нельзя — тот же запрет, что у «Отказа». + $this->assertNotPaying($prospect); + $data = $request->validate([ + 'reason' => ['required', 'string', 'max:2000'], + ]); + $prospect->stage = 'trash'; + $prospect->reason = $data['reason']; + // Дату созвона стираем: иначе выброшенная карточка продолжала бы + // всплывать в фильтре «что надо сделать» и красным на доске. + $prospect->next_call_at = null; + $logEntry = 'В корзину: '.$data['reason']; break; case 'no_answer': @@ -697,6 +777,8 @@ class SalesProspectController extends Controller 'id' => $p->id, 'sales_user_id' => $p->sales_user_id, 'stage' => $p->stage, + // Откуда карточка приехала — из него доска считает дробь «69/1» у «Отказа». + 'prev_stage' => $p->prev_stage, 'source' => $p->source, 'firm_name' => $p->firm_name, 'legal_name' => $p->legal_name, diff --git a/app/app/Models/SalesProspect.php b/app/app/Models/SalesProspect.php index 012eafe3..3503ff56 100644 --- a/app/app/Models/SalesProspect.php +++ b/app/app/Models/SalesProspect.php @@ -34,6 +34,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; * @property string|null $rating_label * @property array $payload * @property Carbon|null $next_call_at + * @property string|null $prev_stage * @property string|null $reason * @property string|null $registered_email * @property string|null $test_channel @@ -56,7 +57,7 @@ class SalesProspect extends Model 'sales_user_id', 'assigned_by', 'stage', 'source', 'firm_name', 'legal_name', 'contacts', 'city', 'phone', 'site', 'inn', 'rating_label', 'payload', 'next_call_at', 'reason', 'registered_email', 'linked_tenant_id', 'notes', - 'test_channel', 'test_target', 'kp_channel', 'kp_target', + 'test_channel', 'test_target', 'kp_channel', 'kp_target', 'prev_stage', ]; /** Дефолты in-memory (совпадают с DB DEFAULT), чтобы поля были доступны до refresh. */ @@ -82,4 +83,54 @@ class SalesProspect extends Model { return $this->belongsTo(SalesUser::class, 'sales_user_id'); } + + /** + * Журнал движений пишется ЗДЕСЬ, а не в контроллере и джобе по отдельности. + * + * Стадию меняют два разных места: результат разговора менеджера + * (SalesProspectController) и автопереезд по деньгам (SalesProspectsAdvanceJob). + * Джоба раньше не оставляла следа вообще — половина движений была невидима. + * Событие модели ловит обе стороны и любую будущую третью: забыть его нельзя, + * потому что стадия без save() не меняется. + */ + protected static function booted(): void + { + // prev_stage снимаем ДО записи: после save() getOriginal('stage') уже + // равен новой стадии — прежнюю в событии `updated` взять было бы неоткуда. + static::saving(function (self $prospect): void { + if ($prospect->exists && $prospect->isDirty('stage')) { + $prospect->prev_stage = (string) $prospect->getOriginal('stage'); + } + }); + + // Заведение карточки — тоже движение: из ниоткуда в первую стадию. + static::created(fn (self $prospect) => $prospect->logMove(null)); + + // Именно `updated`, а не `saved`: у только что созданного объекта + // wasRecentlyCreated остаётся true до конца его жизни, и второе + // сохранение (даже без смены стадии) писало бы лишнее движение. + static::updated(function (self $prospect): void { + if ($prospect->wasChanged('stage')) { + $prospect->logMove($prospect->prev_stage); + } + }); + } + + /** + * Строка в журнале движений. + * + * Соединение берём у самой карточки: джоба живёт на pgsql_admin, портал — + * на дефолтном. Жёстко зашитое соединение сломало бы одну из сторон. + */ + private function logMove(?string $fromStage): void + { + $this->getConnection()->table('sales_prospect_moves')->insert([ + 'prospect_id' => $this->id, + // Двигал не человек (джоба по деньгам аккаунта) → автора нет. + 'sales_user_id' => auth('sales')->id(), + 'from_stage' => $fromStage, + 'to_stage' => $this->stage, + 'created_at' => now(), + ]); + } } diff --git a/app/app/Models/SalesProspectMove.php b/app/app/Models/SalesProspectMove.php new file mode 100644 index 00000000..d83c84b9 --- /dev/null +++ b/app/app/Models/SalesProspectMove.php @@ -0,0 +1,48 @@ + 'datetime']; + } + + /** @return BelongsTo */ + public function prospect(): BelongsTo + { + return $this->belongsTo(SalesProspect::class, 'prospect_id'); + } +} diff --git a/app/app/Services/Sales/AdAudienceScheduler.php b/app/app/Services/Sales/AdAudienceScheduler.php index 2e0af4cd..81ea9cba 100644 --- a/app/app/Services/Sales/AdAudienceScheduler.php +++ b/app/app/Services/Sales/AdAudienceScheduler.php @@ -54,6 +54,15 @@ class AdAudienceScheduler return $this->stopped('client_won'); } + // Корзина — карточку выбросили, греть некого. Стоп СРАЗУ, в отличие от + // «Отказа», который догревается ещё rejected_days: отказавший может + // передумать, а выброшенный мусор — нет. Ветка явная не для красоты: + // без неё стадия попала бы в 'unknown_stage' и реклама встала бы молча, + // по случайности, а не по решению. + if ($stage === 'trash') { + return $this->stopped('trashed'); + } + // Три стадии с обязательной датой созвона живут по одному правилу: крутим // до созвона, потом ещё negotiation_overdue_days, дальше стоп. Без этой // строки новые стадии попали бы в 'unknown_stage' и реклама МОЛЧА встала бы. diff --git a/app/app/Services/Sales/SalesPeriodResolver.php b/app/app/Services/Sales/SalesPeriodResolver.php index cee97054..fa70371a 100644 --- a/app/app/Services/Sales/SalesPeriodResolver.php +++ b/app/app/Services/Sales/SalesPeriodResolver.php @@ -12,6 +12,7 @@ use InvalidArgumentException; * * Поддерживаемые kind: * 'today' — сегодня. + * 'tomorrow' — завтра (нужен фильтру воронки «что надо сделать»). * 'yesterday' — вчера. * 'd7' — последние 7 дней, включая сегодня. * 'd30' — последние 30 дней, включая сегодня. @@ -40,6 +41,7 @@ final class SalesPeriodResolver return match ($kind) { 'today' => $this->lastDays(1), + 'tomorrow' => $this->dayRange(1), 'yesterday' => $this->dayRange(-1), 'd7' => $this->lastDays(7), 'd30' => $this->lastDays(30), diff --git a/app/database/migrations/2026_08_01_100000_add_trash_stage_and_prospect_moves.php b/app/database/migrations/2026_08_01_100000_add_trash_stage_and_prospect_moves.php new file mode 100644 index 00000000..6f4f2cd6 --- /dev/null +++ b/app/database/migrations/2026_08_01_100000_add_trash_stage_and_prospect_moves.php @@ -0,0 +1,94 @@ +statement('ALTER TABLE sales_prospects DROP CONSTRAINT IF EXISTS sales_prospects_stage_check'); + $db->statement(<<<'SQL' + ALTER TABLE sales_prospects ADD CONSTRAINT sales_prospects_stage_check + CHECK (stage IN ('new','in_work','negotiation','manual_testing','registered', + 'testing','topped_up','user','kp_sent','rejected','no_answer','trash')) + SQL); + + $db->statement('ALTER TABLE sales_prospects ADD COLUMN IF NOT EXISTS prev_stage VARCHAR(16) NULL'); + + $db->statement(<<<'SQL' + CREATE TABLE IF NOT EXISTS sales_prospect_moves ( + id BIGSERIAL PRIMARY KEY, + prospect_id BIGINT NOT NULL REFERENCES sales_prospects(id) ON DELETE CASCADE, + sales_user_id BIGINT REFERENCES sales_users(id), + from_stage VARCHAR(16), + to_stage VARCHAR(16) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + SQL); + + // История одной карточки — свежие сверху. + $db->statement(<<<'SQL' + CREATE INDEX IF NOT EXISTS idx_prospect_move_card + ON sales_prospect_moves (prospect_id, created_at DESC) + SQL); + + // Фильтр «что менялось за период» — выборка по всему отделу за диапазон дат. + $db->statement(<<<'SQL' + CREATE INDEX IF NOT EXISTS idx_prospect_move_when + ON sales_prospect_moves (created_at) + SQL); + + // 🔴 GRANTs обеим ролям admin-db (как в create_sales_prospect_notes_table). + // Без них на бою будет отказ прав, а на dev под superuser это невидимо. + $db->statement(<<<'SQL' + DO $$ + DECLARE target TEXT; + BEGIN + FOREACH target IN ARRAY ARRAY['crm_admin_user','crm_supplier_worker'] LOOP + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = target) THEN + EXECUTE format('GRANT SELECT, INSERT ON sales_prospect_moves TO %I', target); + EXECUTE format('GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO %I', target); + END IF; + END LOOP; + END $$ + SQL); + } + + public function down(): void + { + $db = DB::connection('pgsql_supplier'); + + $db->statement('DROP TABLE IF EXISTS sales_prospect_moves CASCADE'); + $db->statement('ALTER TABLE sales_prospects DROP COLUMN IF EXISTS prev_stage'); + + // Карточки из корзины — в «Отказ», иначе прежний CHECK не встанет. + $db->statement("UPDATE sales_prospects SET stage = 'rejected' WHERE stage = 'trash'"); + $db->statement('ALTER TABLE sales_prospects DROP CONSTRAINT IF EXISTS sales_prospects_stage_check'); + $db->statement(<<<'SQL' + ALTER TABLE sales_prospects ADD CONSTRAINT sales_prospects_stage_check + CHECK (stage IN ('new','in_work','negotiation','manual_testing','registered', + 'testing','topped_up','user','kp_sent','rejected','no_answer')) + SQL); + } +}; diff --git a/app/phpstan-baseline.neon b/app/phpstan-baseline.neon index 31bdbb6a..14e300cc 100644 --- a/app/phpstan-baseline.neon +++ b/app/phpstan-baseline.neon @@ -2682,6 +2682,36 @@ parameters: count: 1 path: tests/Feature/Bot/ProcessChatMessageJobTest.php + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' + identifier: property.notFound + count: 2 + path: tests/Feature/ClientTg/AdHeadlineTest.php + + - + message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#' + identifier: property.notFound + count: 1 + path: tests/Feature/ClientTg/AdHeadlineTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/ClientTg/AdHeadlineTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' + identifier: method.notFound + count: 7 + path: tests/Feature/ClientTg/AdHeadlineTest.php + + - + message: '#^Offset ''adHeadline'' does not exist on string\.$#' + identifier: offsetAccess.notFound + count: 1 + path: tests/Feature/ClientTg/AdHeadlineTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:getJson\(\)\.$#' identifier: method.notFound @@ -5019,7 +5049,7 @@ parameters: - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' identifier: method.notFound - count: 90 + count: 95 path: tests/Feature/Sales/SalesProspectApiTest.php - @@ -5034,6 +5064,18 @@ parameters: count: 1 path: tests/Feature/Sales/SalesProspectApiTest.php + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 9 + path: tests/Feature/Sales/SalesProspectDateFilterTest.php + + - + message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' + identifier: method.notFound + count: 1 + path: tests/Feature/Sales/SalesProspectMoveTest.php + - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:artisan\(\)\.$#' identifier: method.notFound @@ -5795,33 +5837,3 @@ parameters: identifier: argument.type count: 1 path: tests/Unit/Supplier/SupplierQuotaAllocatorTest.php - - - - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$tenant\.$#' - identifier: property.notFound - count: 2 - path: tests/Feature/ClientTg/AdHeadlineTest.php - - - - message: '#^Access to an undefined property Pest\\PendingCalls\\TestCall\:\:\$user\.$#' - identifier: property.notFound - count: 1 - path: tests/Feature/ClientTg/AdHeadlineTest.php - - - - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:actingAs\(\)\.$#' - identifier: method.notFound - count: 1 - path: tests/Feature/ClientTg/AdHeadlineTest.php - - - - message: '#^Call to an undefined method Pest\\PendingCalls\\TestCall\:\:postJson\(\)\.$#' - identifier: method.notFound - count: 7 - path: tests/Feature/ClientTg/AdHeadlineTest.php - - - - message: '#^Offset ''adHeadline'' does not exist on string\.$#' - identifier: offsetAccess.notFound - count: 1 - path: tests/Feature/ClientTg/AdHeadlineTest.php 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..7973e3bb --- /dev/null +++ b/app/resources/js/components/sales/ProspectDateFilter.vue @@ -0,0 +1,186 @@ + + + + + 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..75e9ad04 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,12 +93,22 @@ 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 });