Compare commits

...

11 Commits

Author SHA1 Message Date
Дмитрий 345d14d285 docs(plan): Sprint 5B — markdownlint-fix плана (MD031/MD032)
markdownlint-cli2 --fix: blanks-around-lists/fences в плане 5B.
0 errors. Pre-existing 26 ошибок в планах Sprint 4/5A — вне scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 04:03:36 +03:00
Дмитрий bc24420ad4 style(ui): Sprint 5B — prettier-формат затронутых файлов
Регрессия full: prettier --check на 5 файлах, тронутых Sprint 5B
(T2/T3/T4). Whitespace-only, 0 изменений поведения — Vitest 67/67
на затронутых спеках. Pre-existing prettier-дрейф 28 НЕ-5B файлов
оставлен (вне scope спринта).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 04:03:36 +03:00
Дмитрий 788c7ab336 feat(ui): C6 — degradation-alert в NewDealDialog при провале загрузки списков 2026-05-17 03:48:39 +03:00
Дмитрий eb41b65dad fix(ui): C3 — сброс toast-текста + типизация теста (review-fixup) 2026-05-17 03:44:50 +03:00
Дмитрий 095032a231 feat(ui): C3 — кнопка «Экспорт» в шапке DealsView экспортирует весь список 2026-05-17 03:39:32 +03:00
Дмитрий adb5d87d1d fix(ui): B3 — ⌘K open-only + DOM-тесты палитры (review-fixup) 2026-05-17 03:33:51 +03:00
Дмитрий 8b3ea3ed2e feat(ui): B3 — минимальная ⌘K command-palette навигации 2026-05-17 03:28:05 +03:00
Дмитрий d3746406a6 docs(plan): Sprint 5B — Layout/views (B2/B3/C3/C6/C7)
План 6 задач портал-аудита Sprint 5B. T2 NAV_ITEMS поправлен 7→8
(добавлен «Импорт данных» /import — сверено с origin/main-сайдбаром).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 03:21:45 +03:00
Дмитрий 1a3a1df604 docs(ui): B2 — актуализация комментариев AppSidebar (review-fixup)
Code-quality review T1: stale JSDoc «Counts — mock» теперь ложный
(count live из API); +поясняющий комментарий к null→undefined цепочке.
Comment-only, 0 изменений поведения. Vitest 6/6 green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 03:20:02 +03:00
Дмитрий 4b0809a82d feat(ui): B2 — счётчик «Сделки» в сайдбаре из API вместо хардкода 2026-05-17 03:14:13 +03:00
Дмитрий cefb71f5fa feat(api): B2 — count_only параметр на GET /api/deals 2026-05-17 03:11:03 +03:00
17 changed files with 1567 additions and 26 deletions
@@ -62,6 +62,7 @@ class DealController extends Controller
$limit = max(1, min(500, (int) $request->query('limit', '100')));
$offset = max(0, (int) $request->query('offset', '0'));
$onlyDeleted = $request->boolean('only_deleted');
$countOnly = $request->boolean('count_only');
$cursorRaw = (string) $request->query('cursor', '');
// Sprint 4 Phase A (audit O-perf-04): keyset pagination через cursor.
@@ -80,7 +81,7 @@ class DealController extends Controller
$cursor = ['r' => (string) $parsed['r'], 'i' => (int) $parsed['i']];
}
[$deals, $total, $nextCursor] = DB::transaction(function () use ($tenantId, $statuses, $projectId, $managerId, $search, $limit, $offset, $onlyDeleted, $cursor) {
[$deals, $total, $nextCursor] = DB::transaction(function () use ($tenantId, $statuses, $projectId, $managerId, $search, $limit, $offset, $onlyDeleted, $cursor, $countOnly) {
DB::statement('SET LOCAL app.current_tenant_id = '.$tenantId);
// Defense-in-depth: явный where(tenant_id) поверх RLS — на тестах
@@ -115,6 +116,12 @@ class DealController extends Controller
});
}
// Audit B2: count_only — отдаём только COUNT(*), пропуская SELECT строк
// и cursor/offset-логику (лёгкий запрос для бейджа в сайдбаре).
if ($countOnly) {
return [collect(), $query->count(), null];
}
if ($cursor !== null) {
// Keyset: PG row constructor через индекс на (received_at DESC, id DESC).
// Не считаем total (дорого без COUNT(*); клиент при необходимости
@@ -159,6 +166,10 @@ class DealController extends Controller
return [$rows, $total, $next];
});
if ($countOnly) {
return response()->json(['total' => $total]);
}
$payload = [
'deals' => $deals->map(fn (Deal $d) => [
'id' => $d->id,
+11
View File
@@ -233,3 +233,14 @@ export async function listProjects(tenantId: number): Promise<ApiProject[]> {
});
return data.projects;
}
/**
* Лёгкий count-only запрос для бейджа «Сделки» в AppSidebar (audit B2).
* Backend пропускает SELECT строк — отдаёт только COUNT(*).
*/
export async function fetchDealsCount(tenantId: number): Promise<number> {
const { data } = await apiClient.get<{ total: number }>('/api/deals', {
params: { tenant_id: tenantId, count_only: 1 },
});
return data.total;
}
@@ -42,7 +42,8 @@ async function loadLookups(tenantId: number) {
managerIdByName.value = map;
}
} catch {
// Молчаливый fallback на mock — UI пользователь всё равно увидит.
// Audit C6: фиксируем провал — UI покажет degradation-alert.
lookupsFailed.value = true;
}
}
@@ -76,6 +77,9 @@ const errors = ref<Record<string, string>>({});
const submitError = ref<string | null>(null);
const busy = ref(false);
// Audit C6: loadLookups упал → показываем degradation-alert (списки = mock).
const lookupsFailed = ref(false);
// Регенерируем ID на каждое создание для local-mode. На API — backend SERIAL.
function nextId(): number {
return Math.floor(Date.now() / 1000) + Math.floor(Math.random() * 1000);
@@ -91,6 +95,7 @@ function reset() {
errors.value = {};
submitError.value = null;
busy.value = false;
lookupsFailed.value = false;
}
watch(
@@ -170,6 +175,8 @@ async function submit() {
}
}
defineExpose({ lookupsFailed });
function close() {
dialogOpen.value = false;
}
@@ -190,6 +197,17 @@ function close() {
>
{{ submitError }}
</v-alert>
<v-alert
v-if="lookupsFailed"
type="warning"
variant="tonal"
density="compact"
class="mb-3"
data-testid="lookups-error-alert"
>
Не удалось загрузить списки проектов и менеджеров показаны примерные значения. Проверьте выбор
перед сохранением.
</v-alert>
<v-row dense>
<v-col cols="12" md="6">
<v-text-field
@@ -5,11 +5,14 @@
* + active-marker pseudo-element + JetBrains Mono badges.
*
* Brand mark + nav-tree (3 группы: Работа, Финансы, Команда).
* Counts для «Сделки» — mock.
* Count для «Сделки» — live из API (dealsCount-store, audit B2).
*/
import { computed } from 'vue';
import { computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import Kbd from '../ui/Kbd.vue';
import { useAuthStore } from '../../stores/auth';
import { useDealsCountStore } from '../../stores/dealsCount';
import { useCommandPalette } from '../../composables/useCommandPalette';
interface NavItem {
title: string;
@@ -26,13 +29,28 @@ interface NavGroup {
const drawerOpen = defineModel<boolean>('drawerOpen', { default: true });
const route = useRoute();
const auth = useAuthStore();
const dealsCount = useDealsCountStore();
const { openPalette } = useCommandPalette();
onMounted(() => {
if (auth.user?.tenant_id) void dealsCount.load(auth.user.tenant_id);
});
const navGroups = computed<NavGroup[]>(() => [
{
eyebrow: 'Работа',
items: [
{ title: 'Проекты', icon: 'mdi-folder-multiple-outline', to: '/projects' },
{ title: 'Сделки', icon: 'mdi-format-list-bulleted', to: '/deals', count: 247 },
// B2: count из dealsCount-store; null → undefined (NavItem.count — number|undefined),
// resolveCount затем → 0 и v-if скрывает бейдж пока счётчик не загружен.
{
title: 'Сделки',
icon: 'mdi-format-list-bulleted',
to: '/deals',
count: dealsCount.count ?? undefined,
countKey: 'deals',
},
{ title: 'Канбан', icon: 'mdi-view-column-outline', to: '/kanban' },
{ title: 'Дашборд', icon: 'mdi-view-dashboard-outline', to: '/dashboard' },
{ title: 'Импорт данных', icon: 'mdi-database-import-outline', to: '/import' },
@@ -64,7 +82,15 @@ defineExpose({ navGroups });
<span class="ld-sidebar__brand-name">Лидерра<span class="ld-sidebar__brand-dot">.</span></span>
</div>
<div class="ld-cmdk-stub" role="button" tabindex="0">
<div
class="ld-cmdk-stub"
role="button"
tabindex="0"
data-testid="cmdk-stub"
@click="openPalette"
@keydown.enter="openPalette"
@keydown.space.prevent="openPalette"
>
<span class="ld-cmdk-stub__placeholder">Поиск, команды</span>
<Kbd dark>K</Kbd>
</div>
@@ -8,6 +8,7 @@ import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '../../stores/auth';
import { useNotificationsStore } from '../../stores/notifications';
import { useCommandPalette } from '../../composables/useCommandPalette';
defineProps<{
pageTitle: string;
@@ -20,6 +21,7 @@ const emit = defineEmits<{
const auth = useAuthStore();
const notifications = useNotificationsStore();
const router = useRouter();
const { openPalette } = useCommandPalette();
const unreadDisplay = computed(() => {
if (notifications.unreadCount === 0) return '';
@@ -87,11 +89,7 @@ async function handleLogout(): Promise<void> {
<template>
<v-app-bar :elevation="0" color="surface" class="app-topbar" :height="56">
<v-app-bar-nav-icon
class="d-md-none"
aria-label="Открыть меню навигации"
@click="emit('toggle-drawer')"
/>
<v-app-bar-nav-icon class="d-md-none" aria-label="Открыть меню навигации" @click="emit('toggle-drawer')" />
<div class="crumb">
<strong>{{ pageTitle }}</strong>
@@ -99,7 +97,14 @@ async function handleLogout(): Promise<void> {
<v-spacer />
<v-btn variant="outlined" size="small" prepend-icon="mdi-magnify" class="searchbar mr-2" disabled>
<v-btn
variant="outlined"
size="small"
prepend-icon="mdi-magnify"
class="searchbar mr-2"
data-testid="topbar-search-btn"
@click="openPalette"
>
Поиск
<template #append>
<kbd class="search-kbd">K</kbd>
@@ -0,0 +1,113 @@
<script setup lang="ts">
/**
* Минимальная command-palette (audit B3). Открывается по ⌘K / Ctrl+K, кликом
* на плашку в AppSidebar или кнопку «Поиск» в AppTopbar. Список — навигация
* по 8 разделам портала; фильтр по подстроке; Enter → первый результат.
* Монтируется один раз в AppLayout.
*/
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useCommandPalette } from '../../composables/useCommandPalette';
interface PaletteItem {
title: string;
icon: string;
to: string;
}
const NAV_ITEMS: PaletteItem[] = [
{ title: 'Проекты', icon: 'mdi-folder-multiple-outline', to: '/projects' },
{ title: 'Сделки', icon: 'mdi-format-list-bulleted', to: '/deals' },
{ title: 'Канбан', icon: 'mdi-view-column-outline', to: '/kanban' },
{ title: 'Дашборд', icon: 'mdi-view-dashboard-outline', to: '/dashboard' },
{ title: 'Импорт данных', icon: 'mdi-database-import-outline', to: '/import' },
{ title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/billing' },
{ title: 'Отчёты', icon: 'mdi-chart-box-outline', to: '/reports' },
{ title: 'Настройки', icon: 'mdi-cog-outline', to: '/settings' },
];
const { open, closePalette } = useCommandPalette();
const router = useRouter();
const query = ref('');
const filteredItems = computed<PaletteItem[]>(() => {
const q = query.value.trim().toLowerCase();
if (q === '') return NAV_ITEMS;
return NAV_ITEMS.filter((i) => i.title.toLowerCase().includes(q));
});
// Сброс query при каждом открытии.
watch(open, (isOpen) => {
if (isOpen) query.value = '';
});
function selectItem(item: PaletteItem): void {
closePalette();
void router.push(item.to);
}
function onSubmit(): void {
const first = filteredItems.value[0];
if (first) selectItem(first);
}
function onGlobalKeydown(e: KeyboardEvent): void {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
if (open.value) return;
e.preventDefault();
open.value = true;
}
}
onMounted(() => window.addEventListener('keydown', onGlobalKeydown));
onUnmounted(() => window.removeEventListener('keydown', onGlobalKeydown));
defineExpose({ query, filteredItems, selectItem, onSubmit });
</script>
<template>
<v-dialog v-model="open" :max-width="520" data-testid="command-palette">
<v-card class="cmdk-card">
<v-text-field
v-model="query"
autofocus
placeholder="Поиск разделов…"
variant="plain"
density="comfortable"
hide-details
prepend-inner-icon="mdi-magnify"
class="cmdk-input px-3 pt-2"
data-testid="command-palette-input"
@keydown.enter="onSubmit"
/>
<v-divider />
<v-list density="compact" class="cmdk-list" data-testid="command-palette-list">
<v-list-item
v-for="item in filteredItems"
:key="item.to"
:prepend-icon="item.icon"
:title="item.title"
data-testid="command-palette-item"
@click="selectItem(item)"
/>
<v-list-item
v-if="filteredItems.length === 0"
class="text-medium-emphasis"
title="Ничего не найдено"
data-testid="command-palette-empty"
/>
</v-list>
</v-card>
</v-dialog>
</template>
<style scoped>
.cmdk-card {
overflow: hidden;
}
.cmdk-list {
max-height: 320px;
overflow-y: auto;
}
</style>
@@ -0,0 +1,20 @@
import { ref } from 'vue';
/**
* Глобальное состояние command-palette (⌘K, audit B3). Module-level singleton
* ref — AppSidebar/AppTopbar открывают палитру без prop-drilling, CommandPalette
* (смонтирована один раз в AppLayout) использует тот же ref как v-model.
*/
const open = ref(false);
export function useCommandPalette() {
return {
open,
openPalette: (): void => {
open.value = true;
},
closePalette: (): void => {
open.value = false;
},
};
}
+2
View File
@@ -18,6 +18,7 @@ import { usePolling } from '../composables/usePolling';
import AppSidebar from '../components/layout/AppSidebar.vue';
import AppTopbar from '../components/layout/AppTopbar.vue';
import DevIndexBadge from '../components/DevIndexBadge.vue';
import CommandPalette from '../components/layout/CommandPalette.vue';
const auth = useAuthStore();
const notifications = useNotificationsStore();
@@ -73,6 +74,7 @@ usePolling(loadReminderCounts, { intervalMs: 60_000, enabled: true });
</RouterView>
</v-main>
<DevIndexBadge :index="route.meta.devIndex" :label="route.meta.devLabel" />
<CommandPalette />
</v-app>
</template>
+21
View File
@@ -0,0 +1,21 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { fetchDealsCount } from '../api/deals';
/**
* Счётчик сделок tenant'а для бейджа «Сделки» в AppSidebar (audit B2).
* count=null до загрузки или на fail → бейдж скрыт (resolveCount → 0).
*/
export const useDealsCountStore = defineStore('dealsCount', () => {
const count = ref<number | null>(null);
async function load(tenantId: number): Promise<void> {
try {
count.value = await fetchDealsCount(tenantId);
} catch {
count.value = null;
}
}
return { count, load };
});
+48 -12
View File
@@ -345,25 +345,42 @@ async function applyBulkExport(format: 'xlsx' | 'csv' = 'xlsx') {
exportToastOpen.value = true;
return;
}
await exportDealIds([...selected.value], format);
}
// С tenant_id — backend (RLS-фильтрация чужих id). На fail — fallback на
// local CSV (даже если запросили xlsx — без backend'а xlsx не построим).
// Audit C3: экспорт всех отфильтрованных сделок — кнопка «Экспорт» в page-head.
async function exportAllFiltered(format: 'xlsx' | 'csv' = 'xlsx') {
const ids = filteredDeals.value.map((d) => d.id);
if (ids.length === 0) {
exportToastText.value = 'Список пуст — нечего экспортировать.';
exportToastOpen.value = true;
return;
}
await exportDealIds(ids, format);
}
/**
* Общий экспорт по списку id. С tenant_id — backend (RLS-фильтрация чужих id).
* На fail / без tenant — fallback на локальный CSV.
*/
async function exportDealIds(ids: number[], format: 'xlsx' | 'csv') {
exportToastText.value = '';
if (auth.user?.tenant_id) {
try {
if (format === 'xlsx') {
const blob = await dealsApi.exportDealsXlsx({
tenant_id: auth.user.tenant_id,
ids: selected.value,
ids,
});
triggerBlobDownload(blob, `deals_export_${new Date().toISOString().slice(0, 10)}.xlsx`);
exportToastText.value = `Экспортировано ${selected.value.length} сделок в XLSX.`;
exportToastText.value = `Экспортировано ${ids.length} сделок в XLSX.`;
} else {
const csv = await dealsApi.exportDeals({
tenant_id: auth.user.tenant_id,
ids: selected.value,
ids,
});
triggerCsvDownload(csv, `deals_export_${new Date().toISOString().slice(0, 10)}.csv`);
exportToastText.value = `Экспортировано ${selected.value.length} сделок в CSV.`;
exportToastText.value = `Экспортировано ${ids.length} сделок в CSV.`;
}
exportToastOpen.value = true;
return;
@@ -372,11 +389,11 @@ async function applyBulkExport(format: 'xlsx' | 'csv' = 'xlsx') {
}
}
buildLocalCsv();
buildLocalCsv(ids);
}
function buildLocalCsv() {
const idSet = new Set(selected.value);
function buildLocalCsv(ids: number[]) {
const idSet = new Set(ids);
const rows = dealsState.filter((d) => idSet.has(d.id));
const headers = ['ID', 'Имя', 'Телефон', 'Статус', 'Проект', 'Менеджер', 'Стоимость', 'Получено мин назад'];
const csv = buildCsvString(
@@ -398,6 +415,7 @@ defineExpose({
applyBulkStatus,
applyBulkDelete,
applyBulkExport,
exportAllFiltered,
exportToastOpen,
exportToastText,
onDealCreated,
@@ -513,7 +531,15 @@ const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'wai
>
{{ trashMode ? 'К сделкам' : 'Корзина' }}
</v-btn>
<v-btn v-if="!trashMode" variant="outlined" prepend-icon="mdi-download">Экспорт</v-btn>
<v-btn
v-if="!trashMode"
variant="outlined"
prepend-icon="mdi-download"
data-testid="export-all-btn"
@click="exportAllFiltered()"
>
Экспорт
</v-btn>
<v-btn
v-if="!trashMode"
color="primary"
@@ -584,7 +610,12 @@ const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'wai
</v-list>
</v-card-text>
<v-card-actions class="px-3 pb-2">
<v-btn variant="text" size="small" data-testid="project-menu-clear" @click="clearProjectDraft">
<v-btn
variant="text"
size="small"
data-testid="project-menu-clear"
@click="clearProjectDraft"
>
Очистить
</v-btn>
<v-spacer />
@@ -631,7 +662,12 @@ const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'wai
</v-list>
</v-card-text>
<v-card-actions class="px-3 pb-2">
<v-btn variant="text" size="small" data-testid="manager-menu-clear" @click="clearManagerDraft">
<v-btn
variant="text"
size="small"
data-testid="manager-menu-clear"
@click="clearManagerDraft"
>
Очистить
</v-btn>
<v-spacer />
+29
View File
@@ -289,3 +289,32 @@ test('GET /api/deals возвращает next_cursor когда есть ещё
$r2->assertStatus(200);
expect($r2->json('next_cursor'))->toBeNull();
});
test('GET /api/deals?count_only=1 возвращает только total без массива deals', function () {
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'paid']);
$r = $this->getJson('/api/deals?count_only=1');
$r->assertStatus(200);
expect($r->json('total'))->toBe(2);
expect($r->json('deals'))->toBeNull();
});
test('GET /api/deals?count_only=1 учитывает фильтры (status_in)', function () {
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'paid']);
expect($this->getJson('/api/deals?count_only=1&status_in[]=new')->json('total'))->toBe(2);
});
test('GET /api/deals?count_only=1 изолирует чужой tenant (RLS)', function () {
Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
DB::statement('SET app.current_tenant_id = '.$this->otherTenant->id);
$foreignProject = Project::factory()->for($this->otherTenant)->create();
Deal::factory()->for($this->otherTenant)->for($foreignProject)->create(['status' => 'new']);
expect($this->getJson('/api/deals?count_only=1')->json('total'))->toBe(1);
});
+3
View File
@@ -27,6 +27,7 @@ import * as notificationsApi from '../../resources/js/api/notifications';
import AppLayout from '../../resources/js/layouts/AppLayout.vue';
import { useAuthStore } from '../../resources/js/stores/auth';
import { useNotificationsStore } from '../../resources/js/stores/notifications';
import { useDealsCountStore } from '../../resources/js/stores/dealsCount';
import type { AuthUser } from '../../resources/js/api/auth';
const mockUser: AuthUser = {
@@ -45,6 +46,8 @@ const mountAppLayout = async (path = '/dashboard', user: AuthUser | null = mockU
setActivePinia(createPinia());
const auth = useAuthStore();
auth.user = user;
// B2: init deals count so badge renders (replaces hardcoded 247 in AppSidebar).
useDealsCountStore().count = 247;
const router = createRouter({
history: createMemoryHistory(),
+34 -1
View File
@@ -1,12 +1,16 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, test } from 'vitest';
import { useCommandPalette } from '../../resources/js/composables/useCommandPalette';
import { mount, type VueWrapper } from '@vue/test-utils';
import { createMemoryHistory, createRouter, type Router } from 'vue-router';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import AppSidebar from '../../resources/js/components/layout/AppSidebar.vue';
import { useDealsCountStore } from '../../resources/js/stores/dealsCount';
async function setup(initialRoute = '/deals'): Promise<{ wrapper: VueWrapper; router: Router }> {
setActivePinia(createPinia());
// B2: default count=5 so badge renders in non-B2 tests (replaces hardcoded 247).
useDealsCountStore().count = 5;
const router = createRouter({
history: createMemoryHistory(),
routes: [
@@ -63,3 +67,32 @@ describe('AppSidebar — redesigned shell', () => {
expect(active).toBeDefined();
});
});
test('B2: бейдж «Сделки» рендерит count из dealsCount-store', async () => {
const { wrapper } = await setup();
const store = useDealsCountStore();
store.count = 42;
await wrapper.vm.$nextTick();
const badge = wrapper.find('[data-testid="nav-count-deals"]');
expect(badge.exists()).toBe(true);
expect(badge.text()).toBe('42');
});
test('B2: бейдж «Сделки» скрыт пока count=null', async () => {
const { wrapper } = await setup();
const store = useDealsCountStore();
store.count = null;
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="nav-count-deals"]').exists()).toBe(false);
});
test('B3: клик на ⌘K-плашку открывает command-palette', async () => {
const { open, closePalette } = useCommandPalette();
closePalette();
const { wrapper } = await setup();
await wrapper.find('[data-testid="cmdk-stub"]').trigger('click');
expect(open.value).toBe(true);
});
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import CommandPalette from '../../resources/js/components/layout/CommandPalette.vue';
import { useCommandPalette } from '../../resources/js/composables/useCommandPalette';
const pushMock = vi.fn();
vi.mock('vue-router', () => ({
useRouter: () => ({ push: pushMock }),
}));
const vuetify = createVuetify();
function mountPalette() {
return mount(CommandPalette, {
global: {
plugins: [vuetify],
stubs: { VDialog: { template: '<div><slot /></div>' } },
},
});
}
describe('CommandPalette (B3)', () => {
beforeEach(() => {
pushMock.mockClear();
useCommandPalette().closePalette(); // сброс singleton между тестами
});
test('по умолчанию показывает все 8 разделов', () => {
const wrapper = mountPalette();
expect(wrapper.vm.filteredItems).toHaveLength(8);
});
test('фильтрует по подстроке (case-insensitive)', () => {
const wrapper = mountPalette();
wrapper.vm.query = 'КАНБ';
expect(wrapper.vm.filteredItems).toHaveLength(1);
expect(wrapper.vm.filteredItems[0].to).toBe('/kanban');
});
test('пустой результат при отсутствии совпадений', () => {
const wrapper = mountPalette();
wrapper.vm.query = 'zzzнеттакого';
expect(wrapper.vm.filteredItems).toHaveLength(0);
});
test('selectItem навигирует и закрывает палитру', () => {
const { open } = useCommandPalette();
open.value = true;
const wrapper = mountPalette();
wrapper.vm.selectItem({ title: 'Сделки', icon: 'x', to: '/deals' });
expect(pushMock).toHaveBeenCalledWith('/deals');
expect(open.value).toBe(false);
});
test('onSubmit навигирует на первый отфильтрованный результат', () => {
const wrapper = mountPalette();
wrapper.vm.query = 'отч';
wrapper.vm.onSubmit();
expect(pushMock).toHaveBeenCalledWith('/reports');
});
test('рендерит 8 пунктов списка по умолчанию', () => {
const wrapper = mountPalette();
expect(wrapper.findAll('[data-testid="command-palette-item"]')).toHaveLength(8);
});
test('показывает пустое состояние при отсутствии совпадений', async () => {
const wrapper = mountPalette();
wrapper.vm.query = 'zzzнеттакого';
await wrapper.vm.$nextTick();
expect(wrapper.find('[data-testid="command-palette-empty"]').exists()).toBe(true);
});
});
+56 -1
View File
@@ -1,10 +1,13 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, test, expect, vi, afterEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { createRouter, createMemoryHistory } from 'vue-router';
import { createPinia, setActivePinia } from 'pinia';
import DealsView from '../../resources/js/views/DealsView.vue';
import { MOCK_DEALS } from '../../resources/js/composables/mockDeals';
import * as dealsApi from '../../resources/js/api/deals';
import { useAuthStore } from '../../resources/js/stores/auth';
import type { AuthUser } from '../../resources/js/api/auth';
// Smoke-тесты DealsView с mock-данными.
@@ -318,3 +321,55 @@ describe('DealsView.vue', () => {
expect(vm.selectedDeal?.id).toBe(openId);
});
});
test('C3: exportAllFiltered вызывает backend-экспорт со всеми отфильтрованными id', async () => {
const xlsxSpy = vi.spyOn(dealsApi, 'exportDealsXlsx').mockResolvedValue(new Blob());
const wrapper = await mountDeals();
await flushPromises();
// Установить auth.user с tenant_id чтобы exportDealIds пошёл в backend
const auth = useAuthStore();
auth.user = { id: 1, tenant_id: 42, email: 'test@test.com' } as AuthUser;
// activeTab по умолчанию 'active' — установить 'all' чтобы filteredDeals === dealsState
const vm = wrapper.vm as unknown as {
activeTab: string;
dealsState: Array<{ id: number }>;
exportAllFiltered: () => Promise<void>;
exportToastOpen: boolean;
};
vm.activeTab = 'all';
await flushPromises();
await vm.exportAllFiltered();
expect(xlsxSpy).toHaveBeenCalledTimes(1);
const callArg = xlsxSpy.mock.calls[0][0];
expect(callArg.ids).toEqual(vm.dealsState.map((d) => d.id));
expect(vm.exportToastOpen).toBe(true);
});
test('C3: exportAllFiltered на пустом списке показывает toast и не зовёт backend', async () => {
const xlsxSpy = vi.spyOn(dealsApi, 'exportDealsXlsx').mockResolvedValue(new Blob());
const wrapper = await mountDeals();
await flushPromises();
const vm = wrapper.vm as unknown as {
activeTab: string;
dealsState: Array<{ id: number }>;
exportAllFiltered: () => Promise<void>;
exportToastOpen: boolean;
exportToastText: string;
};
// Очистить список и поставить tab='all' чтобы filteredDeals тоже пустой
vm.activeTab = 'all';
vm.dealsState.splice(0, vm.dealsState.length);
await flushPromises();
await vm.exportAllFiltered();
expect(xlsxSpy).not.toHaveBeenCalled();
expect(vm.exportToastText).toBe('Список пуст — нечего экспортировать.');
});
afterEach(() => vi.restoreAllMocks());
+24
View File
@@ -287,4 +287,28 @@ describe('NewDealDialog.vue', () => {
const closeEmits = wrapper.emitted('update:modelValue');
expect(closeEmits === undefined || !closeEmits.some((e) => e[0] === false)).toBe(true);
});
it('C6: при провале loadLookups показывает degradation-alert', async () => {
vi.spyOn(dealsApi, 'listProjects').mockRejectedValue(new Error('network'));
vi.spyOn(dealsApi, 'listManagers').mockRejectedValue(new Error('network'));
const wrapper = factory({ modelValue: true, tenantId: 7 });
await flushPromises();
expect(wrapper.vm.lookupsFailed).toBe(true);
expect(wrapper.find('[data-testid="lookups-error-alert"]').exists()).toBe(true);
});
it('C6: при успешном loadLookups alert отсутствует', async () => {
vi.mocked(dealsApi.listProjects).mockResolvedValue([{ id: 1, name: 'P', tag: null, type: 'manual' }]);
vi.mocked(dealsApi.listManagers).mockResolvedValue([
{ id: 1, email: 'a@b.c', first_name: 'A', last_name: 'B', name: 'A B', initials: 'AB' },
]);
const wrapper = factory({ modelValue: true, tenantId: 7 });
await flushPromises();
expect(wrapper.vm.lookupsFailed).toBe(false);
expect(wrapper.find('[data-testid="lookups-error-alert"]').exists()).toBe(false);
});
});
File diff suppressed because it is too large Load Diff