diff --git a/docs/superpowers/plans/2026-07-07-portal-mobile-adaptive-etap1.md b/docs/superpowers/plans/2026-07-07-portal-mobile-adaptive-etap1.md new file mode 100644 index 00000000..0ce9e244 --- /dev/null +++ b/docs/superpowers/plans/2026-07-07-portal-mobile-adaptive-etap1.md @@ -0,0 +1,709 @@ +# План внедрения — Адаптив портала, Этап 1 (каркас + первая таблица) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Сделать общий мобильный каркас основного портала (breakpoints, мобильное меню, снятие жёсткого отступа) и первую таблицу-пруф («Сделки» → карточки на телефоне), не сломав десктоп. + +**Architecture:** Ярусная адаптация через один composable `useLayoutTier` (телефон/планшет/десктоп на базе Vuetify `useDisplay`). Каркас (`AppLayout`/`AppSidebar`/`AppTopbar`) реагирует на ярус: десктоп — как сейчас, планшет — rail-иконки, телефон — нижняя панель-вкладки + выезжающее «Ещё». Таблицы адаптируются переиспользуемой обёрткой `ResponsiveTable` (десктоп/планшет — `v-data-table`, телефон — карточки). + +**Tech Stack:** Vue 3 + Vuetify 3, Vitest (юнит-тесты компонентов), Playwright (визуальная приёмка на 390/820/1280px). Палитра Forest (`--liderra-noir/ivory/teal`). + +**Спека:** [../specs/2026-07-07-portal-mobile-adaptive-design.md](../specs/2026-07-07-portal-mobile-adaptive-design.md) +**Аудит реальных экранов:** [../findings/2026-07-07-portal-mobile-audit/](../findings/2026-07-07-portal-mobile-audit/) + +--- + +## ⚠️ Правила исполнения (прочитать первым) + +1. **База и изоляция.** Работаем в **новом worktree от ветки `worktree-avtopodbor`** (там есть «Поле»), + созданном через `superpowers:using-git-worktrees` (см. Task 0). **НЕ трогать `main`** (им пользуются + другие сессии при выкатах) и **сам `worktree-avtopodbor`** (там идёт работа по автоподбору). +2. **Реальный файл — источник истины.** Пути и фрагменты ниже сверены на ветке `feat/sales-portal-demo`. + На ветке `worktree-avtopodbor` файлы могут чуть отличаться → **перед каждой правкой открыть реальный + файл в worktree** (`Read`) и применять правку к тому, что там есть, а не вслепую по номеру строки. +3. **Десктоп не ломать.** После каждого экрана — снимок десктопа (≥1280px) до/после, сверить. +4. **Бутстрап worktree** — по рецепту `feedback-worktree-test-bootstrap-recipe` (своя тест-БД, `npm ci + --legacy-peer-deps`, миграции, тесты ТОЛЬКО последовательно). Прод-БД `liderra` не трогать. +5. Ничего на боевой без отдельного разрешения владельца. + +--- + +## Карта файлов + +**Создаются:** +- `app/resources/js/composables/useLayoutTier.ts` — ярус экрана (phone/tablet/desktop). +- `app/resources/js/composables/__tests__/useLayoutTier.spec.ts` — тест яруса. +- `app/resources/js/components/layout/AppBottomNav.vue` — нижняя панель-вкладки (телефон). +- `app/resources/js/components/layout/__tests__/AppBottomNav.spec.ts` — тест панели. +- `app/resources/js/components/layout/AppMoreDrawer.vue` — выезжающее «Ещё» (телефон). +- `app/resources/js/components/common/ResponsiveTable.vue` — обёртка таблица↔карточки. +- `app/resources/js/components/common/__tests__/ResponsiveTable.spec.ts` — тест обёртки. + +**Модифицируются:** +- `app/resources/js/layouts/AppLayout.vue` — ярус-зависимый отступ + рендер BottomNav/MoreDrawer. +- `app/resources/js/components/layout/AppSidebar.vue` — rail на планшете, скрытие на телефоне. +- `app/resources/js/components/layout/AppTopbar.vue` — гамбургер открывает «Ещё» на телефоне. +- `app/resources/js/components/deals/DealsTable.vue` — обернуть в `ResponsiveTable` (пруф). + +--- + +## Task 0: Изолированный worktree от `worktree-avtopodbor` + +**Files:** — (git-инфраструктура) + +- [ ] **Step 1: Создать worktree новым skill'ом** + +Вызвать `superpowers:using-git-worktrees`. Базовая ветка — `worktree-avtopodbor`. Имя новой ветки: +`feat/portal-mobile-adaptive`. Директория worktree — по умолчанию skill'а. + +- [ ] **Step 2: Бутстрап (рецепт worktree-test-bootstrap)** + +```bash +# в корне нового worktree +cd app && composer install && npm ci --legacy-peer-deps && cd .. +# своя тест-БД (НЕ прод liderra): +createdb -U postgres liderra_testing_mobile 2>/dev/null || true +php app/artisan migrate --env=testing --database=pgsql_testing --force +php app/artisan partitions:create-months --env=testing 2>/dev/null || true +``` +Expected: composer/npm без ошибок; миграции применились. + +- [ ] **Step 3: Убедиться, что «Поле» на месте** + +Run: `grep -c "/autopodbor" app/resources/js/router/index.ts` +Expected: `1` (маршрут «Конкурентное поле» присутствует — значит база верная). + +- [ ] **Step 4: Зелёная базовая линия фронта** + +Run: `npm --prefix app run type-check && npm --prefix app run test:vue` +Expected: PASS (фиксируем исходную зелёную линию до правок). + +--- + +## Task 1: Composable `useLayoutTier` + +**Files:** +- Create: `app/resources/js/composables/useLayoutTier.ts` +- Test: `app/resources/js/composables/__tests__/useLayoutTier.spec.ts` + +- [ ] **Step 1: Написать падающий тест** + +```ts +// app/resources/js/composables/__tests__/useLayoutTier.spec.ts +import { describe, it, expect } from 'vitest'; +import { defineComponent } from 'vue'; +import { mount } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; +import { useLayoutTier } from '../useLayoutTier'; + +function tierAtWidth(width: number): string { + // Vuetify display читает window.innerWidth + Object.defineProperty(window, 'innerWidth', { configurable: true, value: width }); + window.dispatchEvent(new Event('resize')); + const vuetify = createVuetify(); + let captured = ''; + const Harness = defineComponent({ + setup() { + const { tier } = useLayoutTier(); + captured = tier.value; + return () => null; + }, + }); + mount(Harness, { global: { plugins: [vuetify] } }); + return captured; +} + +describe('useLayoutTier', () => { + it('телефон при ширине < 600', () => { + expect(tierAtWidth(390)).toBe('phone'); + }); + it('планшет при 600..1279', () => { + expect(tierAtWidth(820)).toBe('tablet'); + }); + it('десктоп при >= 1280', () => { + expect(tierAtWidth(1440)).toBe('desktop'); + }); +}); +``` + +- [ ] **Step 2: Прогнать — упасть** + +Run: `npm --prefix app run test:vue -- useLayoutTier` +Expected: FAIL — «Cannot find module '../useLayoutTier'». + +- [ ] **Step 3: Реализовать composable** + +```ts +// app/resources/js/composables/useLayoutTier.ts +import { computed, type ComputedRef } from 'vue'; +import { useDisplay } from 'vuetify'; + +export type LayoutTier = 'phone' | 'tablet' | 'desktop'; + +/** + * Единый источник «яруса» экрана для адаптива портала. + * phone < 600px (нижнее меню-вкладки) + * tablet 600..1279px (узкая колонка-иконки / rail) + * desktop >= 1280px (полный сайдбар 232px, как сейчас) + */ +export function useLayoutTier(): { + tier: ComputedRef; + isPhone: ComputedRef; + isTablet: ComputedRef; + isDesktop: ComputedRef; +} { + const display = useDisplay(); + const tier = computed(() => { + const w = display.width.value; + if (w < 600) return 'phone'; + if (w < 1280) return 'tablet'; + return 'desktop'; + }); + return { + tier, + isPhone: computed(() => tier.value === 'phone'), + isTablet: computed(() => tier.value === 'tablet'), + isDesktop: computed(() => tier.value === 'desktop'), + }; +} +``` + +- [ ] **Step 4: Прогнать — пройти** + +Run: `npm --prefix app run test:vue -- useLayoutTier` +Expected: PASS (3 теста). + +- [ ] **Step 5: Коммит** + +```bash +git add app/resources/js/composables/useLayoutTier.ts app/resources/js/composables/__tests__/useLayoutTier.spec.ts +git commit -F +``` +Сообщение: `feat(портал-мобилка): composable useLayoutTier — ярус телефон/планшет/десктоп`. + +--- + +## Task 2: Нижняя панель-вкладки `AppBottomNav` + +**Files:** +- Create: `app/resources/js/components/layout/AppBottomNav.vue` +- Test: `app/resources/js/components/layout/__tests__/AppBottomNav.spec.ts` + +Иконки — из уже используемого набора проекта (Lucide). Проверить фактический способ подключения иконок +в реальном `AppSidebar.vue`/`AppTopbar.vue` worktree и использовать тот же (компонент-иконка или ``). + +- [ ] **Step 1: Написать падающий тест** + +```ts +// app/resources/js/components/layout/__tests__/AppBottomNav.spec.ts +import { describe, it, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; +import { createRouter, createMemoryHistory } from 'vue-router'; +import AppBottomNav from '../AppBottomNav.vue'; + +const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/autopodbor', component: { template: '
' } }, + { path: '/projects', component: { template: '
' } }, + { path: '/deals', component: { template: '
' } }, + { path: '/billing', component: { template: '
' } }, + { path: '/kanban', component: { template: '
' } }, + ], +}); + +describe('AppBottomNav', () => { + it('рендерит ровно 5 вкладок в порядке Поле/Проекты/Сделки/Биллинг/Канбан', async () => { + const vuetify = createVuetify(); + router.push('/deals'); + await router.isReady(); + const wrapper = mount(AppBottomNav, { global: { plugins: [vuetify, router] } }); + const labels = wrapper.findAll('[data-testid="bottomnav-item"]').map((n) => n.text()); + expect(labels).toEqual(['Поле', 'Проекты', 'Сделки', 'Биллинг', 'Канбан']); + }); +}); +``` + +- [ ] **Step 2: Прогнать — упасть** + +Run: `npm --prefix app run test:vue -- AppBottomNav` +Expected: FAIL — компонент не найден. + +- [ ] **Step 3: Реализовать компонент** + +```vue + + + + + + +``` + +> На шаге реализации: если в проекте принято рисовать иконки (Lucide) — добавить иконку над `__label` +> тем же способом, что в реальном `AppSidebar.vue` worktree (см. правило исполнения №2). + +- [ ] **Step 4: Прогнать — пройти** + +Run: `npm --prefix app run test:vue -- AppBottomNav` +Expected: PASS. + +- [ ] **Step 5: Коммит** + +Сообщение: `feat(портал-мобилка): AppBottomNav — нижнее меню-вкладки на телефоне`. + +--- + +## Task 3: Выезжающее «Ещё» `AppMoreDrawer` + +**Files:** +- Create: `app/resources/js/components/layout/AppMoreDrawer.vue` + +Разделы, не влезшие в 5 вкладок: **Дашборд, Отчёты, Настройки, Импорт, Помощь** + профиль/выход. + +- [ ] **Step 1: Реализовать компонент** + +```vue + + + + +``` + +- [ ] **Step 2: Проверка сборки/типов** + +Run: `npm --prefix app run type-check` +Expected: PASS (0 ошибок). + +- [ ] **Step 3: Коммит** + +Сообщение: `feat(портал-мобилка): AppMoreDrawer — выезжающее «Ещё» на телефоне`. + +--- + +## Task 4: `AppSidebar` — rail на планшете, скрытие на телефоне + +**Files:** +- Modify: `app/resources/js/components/layout/AppSidebar.vue` (реальный файл в worktree — открыть Read) + +Сейчас: `.ld-sidebar { position: fixed; width: 232px }`, `data-open` **ни на что не влияет** (нет CSS). +Задача: ввести ярус и по нему менять ширину/видимость; десктоп не трогать. + +- [ ] **Step 1: Подключить ярус в ` + + +``` + +- [ ] **Step 4: Прогнать — пройти** + +Run: `npm --prefix app run test:vue -- ResponsiveTable` +Expected: PASS (2 теста). + +- [ ] **Step 5: Коммит** + +Сообщение: `feat(портал-мобилка): ResponsiveTable — таблица на десктопе, карточки на телефоне`. + +--- + +## Task 8: Применить `ResponsiveTable` к «Сделкам» (пруф) + +**Files:** +- Modify: `app/resources/js/components/deals/DealsTable.vue` (реальный файл — открыть Read) + +Реальные колонки (сверено): Телефон, Источник, Город, Статус, Комментарий, Поставлен. `item-value="id"`, +есть чекбоксы выбора (`selectedIds`). + +- [ ] **Step 1: Обернуть существующий `v-data-table` в `ResponsiveTable`** + +Импорт: `import ResponsiveTable from '../common/ResponsiveTable.vue';` +Обернуть текущую таблицу в слот `#table` (без изменений в самой таблице), добавить слот `#card`: +```html + + + + +``` +> `DealRow` — использовать реальный тип строки из компонента (открыть файл; тип уже определён рядом с +> `headers`). Имена полей брать из реальных `key`: `phone`, `project`, `city`, `statusSlug`, `comment`, +> `receivedAt`. Статус показать существующей статус-плашкой проекта, если она есть (`useStatusPill`). + +- [ ] **Step 2: Стиль карточек (в `