Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 345d14d285 | |||
| bc24420ad4 | |||
| 788c7ab336 | |||
| eb41b65dad | |||
| 095032a231 | |||
| adb5d87d1d | |||
| 8b3ea3ed2e | |||
| d3746406a6 | |||
| 1a3a1df604 | |||
| 4b0809a82d | |||
| cefb71f5fa |
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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 />
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user