docs(портал-мобилка): план Этапа 1 — каркас + карточки Сделок

TDD-план на 9 задач: worktree от worktree-avtopodbor (там есть «Поле»),
useLayoutTier, AppBottomNav, AppMoreDrawer, rail-сайдбар, ярус-отступ AppLayout,
ResponsiveTable и пруф на «Сделках». Экраны Этапов 2-3 — отдельными планами.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-07-07 17:03:31 +03:00
parent 4575abaaf3
commit 459cea74a2
@@ -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<LayoutTier>;
isPhone: ComputedRef<boolean>;
isTablet: ComputedRef<boolean>;
isDesktop: ComputedRef<boolean>;
} {
const display = useDisplay();
const tier = computed<LayoutTier>(() => {
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 <msg-файл>
```
Сообщение: `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 и использовать тот же (компонент-иконка или `<svg>`).
- [ ] **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: '<div/>' } },
{ path: '/projects', component: { template: '<div/>' } },
{ path: '/deals', component: { template: '<div/>' } },
{ path: '/billing', component: { template: '<div/>' } },
{ path: '/kanban', component: { template: '<div/>' } },
],
});
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
<!-- app/resources/js/components/layout/AppBottomNav.vue -->
<script setup lang="ts">
/** Нижняя панель-вкладки для телефона (ярус phone). 5 главных разделов; остальное — в «Ещё». */
import { RouterLink, useRoute } from 'vue-router';
import { computed } from 'vue';
interface Tab { title: string; to: string; }
const tabs: Tab[] = [
{ title: 'Поле', to: '/autopodbor' },
{ title: 'Проекты', to: '/projects' },
{ title: 'Сделки', to: '/deals' },
{ title: 'Биллинг', to: '/billing' },
{ title: 'Канбан', to: '/kanban' },
];
const route = useRoute();
const activeTo = computed(() => tabs.find((t) => route.path === t.to)?.to ?? '');
</script>
<template>
<nav class="ld-bottomnav" aria-label="Основная навигация">
<RouterLink
v-for="t in tabs"
:key="t.to"
:to="t.to"
data-testid="bottomnav-item"
class="ld-bottomnav__item"
:class="{ 'ld-bottomnav__item--active': activeTo === t.to }"
>
<span class="ld-bottomnav__label">{{ t.title }}</span>
</RouterLink>
</nav>
</template>
<style scoped>
.ld-bottomnav {
position: fixed;
left: 0; right: 0; bottom: 0;
z-index: 1010;
display: flex;
height: 64px;
padding-bottom: env(safe-area-inset-bottom);
background: #ffffff;
border-top: 1px solid rgba(1, 32, 25, 0.1);
}
.ld-bottomnav__item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
min-height: 56px;
text-decoration: none;
color: rgba(1, 32, 25, 0.55);
font-size: 11px;
font-weight: 600;
}
.ld-bottomnav__item--active { color: var(--liderra-teal); }
.ld-bottomnav__label { line-height: 1; }
</style>
```
> На шаге реализации: если в проекте принято рисовать иконки (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
<!-- app/resources/js/components/layout/AppMoreDrawer.vue -->
<script setup lang="ts">
/** Выезжающее «Ещё» для телефона: разделы вне нижних вкладок + профиль. */
import { RouterLink } from 'vue-router';
const open = defineModel<boolean>('open', { default: false });
interface Item { title: string; to: string; }
const items: Item[] = [
{ title: 'Дашборд', to: '/dashboard' },
{ title: 'Отчёты', to: '/reports' },
{ title: 'Настройки', to: '/settings' },
{ title: 'Импорт данных', to: '/import' },
{ title: 'Помощь', to: '/help' },
];
</script>
<template>
<v-navigation-drawer v-model="open" temporary location="right" width="280">
<v-list nav>
<v-list-item
v-for="i in items"
:key="i.to"
:to="i.to"
:title="i.title"
data-testid="more-item"
@click="open = false"
/>
</v-list>
</v-navigation-drawer>
</template>
```
- [ ] **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: Подключить ярус в `<script setup>`**
Добавить импорт и использование:
```ts
import { useLayoutTier } from '../../composables/useLayoutTier';
const { tier } = useLayoutTier();
```
Пробросить ярус в корневой `<aside>` атрибутом:
```html
<aside class="ld-sidebar" :data-tier="tier" :data-open="drawerOpen">
```
- [ ] **Step 2: Добавить ярус-CSS в конец `<style scoped>`**
```css
/* Планшет: узкая колонка-иконки (rail). Скрываем текстовые подписи и поиск. */
.ld-sidebar[data-tier='tablet'] {
width: 64px;
padding: 20px 8px;
}
.ld-sidebar[data-tier='tablet'] .ld-sidebar__brand-name,
.ld-sidebar[data-tier='tablet'] .ld-cmdk-stub,
.ld-sidebar[data-tier='tablet'] .ld-nav-group__eyebrow,
.ld-sidebar[data-tier='tablet'] .ld-nav-item__title {
display: none;
}
/* Телефон: сайдбара нет — навигацию берёт нижняя панель. */
.ld-sidebar[data-tier='phone'] {
display: none;
}
```
> ⚠️ На планшете (rail) у пунктов остаются только иконки. Если в реальном `AppSidebar` иконок у пунктов
> нет (только `__title`), на шаге реализации добавить иконку в `.ld-nav-item` (тот же набор Lucide),
> иначе rail будет пустым. Проверить реальный шаблон.
- [ ] **Step 3: Визуальная проверка (Playwright, вручную при исполнении)**
Поднять портал, снять `/dashboard` на 820px (планшет) и 1440px (десктоп). Ожидание: планшет — узкая
колонка-иконки, контент шире; десктоп — как было.
- [ ] **Step 4: Коммит**
Сообщение: `feat(портал-мобилка): AppSidebar rail на планшете и скрытие на телефоне`.
---
## Task 5: `AppLayout` — ярус-зависимый отступ + мобильное меню
**Files:**
- Modify: `app/resources/js/layouts/AppLayout.vue`
Сейчас: `.app-main { padding-left: 232px }` — жёстко. `drawerOpen = ref(true)`.
- [ ] **Step 1: Подключить ярус и новые компоненты в `<script setup>`**
```ts
import { useLayoutTier } from '../composables/useLayoutTier';
import AppBottomNav from '../components/layout/AppBottomNav.vue';
import AppMoreDrawer from '../components/layout/AppMoreDrawer.vue';
const { tier } = useLayoutTier();
const moreOpen = ref(false);
```
- [ ] **Step 2: В шаблоне — рендерить мобильные элементы и пробросить ярус на `v-main`**
```html
<v-main class="app-main" :data-tier="tier">
... (существующее содержимое без изменений) ...
</v-main>
<AppBottomNav v-if="tier === 'phone'" />
<AppMoreDrawer v-if="tier === 'phone'" v-model:open="moreOpen" />
```
Топбар связать с «Ещё»: заменить обработчик у существующего `@toggle-drawer` так, чтобы на телефоне он
открывал `moreOpen`, а на планшете/десктопе — как раньше `drawerOpen`:
```html
<AppTopbar :page-title="currentPageTitle" @toggle-drawer="tier === 'phone' ? (moreOpen = !moreOpen) : (drawerOpen = !drawerOpen)" />
```
- [ ] **Step 3: Ярус-зависимый отступ в `<style scoped>`**
Заменить жёсткое правило:
```css
/* было: .app-main { padding-left: 232px } */
.app-main { background: #f6f3ec; }
.app-main[data-tier='desktop'] { padding-left: 232px; }
.app-main[data-tier='tablet'] { padding-left: 64px; }
.app-main[data-tier='phone'] { padding-left: 0; padding-bottom: 64px; } /* место под нижнюю панель */
```
- [ ] **Step 4: Тип-чек + сборка**
Run: `npm --prefix app run type-check && npm --prefix app run build`
Expected: PASS.
- [ ] **Step 5: Визуальная приёмка (Playwright)**
Снять `/dashboard` на 390 / 820 / 1440px. Ожидание: телефон — нет меню слева, снизу панель-вкладки,
контент не обрезан; планшет — rail; десктоп — без изменений (сверить со снимком «до»).
- [ ] **Step 6: Коммит**
Сообщение: `feat(портал-мобилка): AppLayout ярус-отступ + нижнее меню и «Ещё» на телефоне`.
---
## Task 6: `AppTopbar` — гамбургер открывает «Ещё» на телефоне
**Files:**
- Modify: `app/resources/js/components/layout/AppTopbar.vue`
Сейчас: `<v-app-bar-nav-icon class="d-md-none" @click="emit('toggle-drawer')">`. Обработчик уже
проброшен в Task 5. Здесь — убедиться, что гамбургер виден именно на телефоне и подписан.
- [ ] **Step 1: Оставить эмит как есть, проверить видимость**
Гамбургер должен показываться на телефоне (`d-md-none` = скрыт на md+, т.е. виден на sm/xs — подходит).
Убедиться `aria-label="Открыть меню"` присутствует.
- [ ] **Step 2: Визуальная проверка**
Снять `/dashboard` 390px, тапнуть гамбургер → выезжает «Ещё» справа со списком Дашборд/Отчёты/Настройки/
Импорт/Помощь. Тап по пункту — переход, drawer закрывается.
- [ ] **Step 3: Коммит (если были правки)**
Сообщение: `fix(портал-мобилка): гамбургер топбара открывает «Ещё» на телефоне`.
---
## Task 7: Обёртка `ResponsiveTable` (таблица ↔ карточки)
**Files:**
- Create: `app/resources/js/components/common/ResponsiveTable.vue`
- Test: `app/resources/js/components/common/__tests__/ResponsiveTable.spec.ts`
- [ ] **Step 1: Написать падающий тест**
```ts
// app/resources/js/components/common/__tests__/ResponsiveTable.spec.ts
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import ResponsiveTable from '../ResponsiveTable.vue';
function mountAt(width: number) {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width });
window.dispatchEvent(new Event('resize'));
const vuetify = createVuetify();
return mount(ResponsiveTable, {
global: { plugins: [vuetify] },
props: { items: [{ id: 1, phone: '+7 900 000' }] },
slots: {
table: '<div data-testid="as-table">TABLE</div>',
card: '<div data-testid="as-card">CARD</div>',
},
});
}
describe('ResponsiveTable', () => {
it('на телефоне (<600) показывает слот card', () => {
const w = mountAt(390);
expect(w.find('[data-testid="as-card"]').exists()).toBe(true);
expect(w.find('[data-testid="as-table"]').exists()).toBe(false);
});
it('на десктопе (>=1280) показывает слот table', () => {
const w = mountAt(1440);
expect(w.find('[data-testid="as-table"]').exists()).toBe(true);
expect(w.find('[data-testid="as-card"]').exists()).toBe(false);
});
});
```
- [ ] **Step 2: Прогнать — упасть**
Run: `npm --prefix app run test:vue -- ResponsiveTable`
Expected: FAIL — компонент не найден.
- [ ] **Step 3: Реализовать обёртку**
```vue
<!-- app/resources/js/components/common/ResponsiveTable.vue -->
<script setup lang="ts">
/**
* Обёртка: десктоп/планшет — слот #table (обычный v-data-table),
* телефон — слот #card (список карточек по каждому item).
* Данные прокидываются через слот-пропсы, чтобы вызывающий сам рисовал карточку.
*/
import { useLayoutTier } from '../../composables/useLayoutTier';
defineProps<{ items: unknown[] }>();
const { isPhone } = useLayoutTier();
</script>
<template>
<div>
<template v-if="isPhone">
<slot name="card" :items="items" />
</template>
<template v-else>
<slot name="table" :items="items" />
</template>
</div>
</template>
```
- [ ] **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
<ResponsiveTable :items="items">
<template #table>
<!-- существующий <v-data-table ...> целиком, без изменений -->
</template>
<template #card="{ items: rows }">
<div class="deals-cards">
<div v-for="row in (rows as DealRow[])" :key="row.id" class="deal-card">
<div class="deal-card__top">
<span class="deal-card__phone">{{ row.phone }}</span>
<span class="deal-card__status">{{ row.statusSlug }}</span>
</div>
<div class="deal-card__meta">{{ row.project }} · {{ row.city || '—' }}</div>
<div v-if="row.comment" class="deal-card__comment">{{ row.comment }}</div>
<div class="deal-card__foot">{{ row.receivedAt }}</div>
</div>
</div>
</template>
</ResponsiveTable>
```
> `DealRow` — использовать реальный тип строки из компонента (открыть файл; тип уже определён рядом с
> `headers`). Имена полей брать из реальных `key`: `phone`, `project`, `city`, `statusSlug`, `comment`,
> `receivedAt`. Статус показать существующей статус-плашкой проекта, если она есть (`useStatusPill`).
- [ ] **Step 2: Стиль карточек (в `<style scoped>` DealsTable)**
```css
.deals-cards { display: flex; flex-direction: column; gap: 12px; }
.deal-card {
border: 1px solid rgba(1, 32, 25, 0.1);
border-radius: 14px;
padding: 14px 16px;
background: #fff;
}
.deal-card__top { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.deal-card__phone { font-weight: 700; font-variant-numeric: tabular-nums; }
.deal-card__meta { color: rgba(1, 32, 25, 0.6); font-size: 13px; margin-top: 4px; }
.deal-card__comment { font-size: 13px; margin-top: 6px; }
.deal-card__foot { font-size: 12px; color: rgba(1, 32, 25, 0.5); margin-top: 10px; }
```
- [ ] **Step 3: Тип-чек + существующие тесты «Сделок»**
Run: `npm --prefix app run type-check && npm --prefix app run test:vue -- Deals`
Expected: PASS (существующие тесты «Сделок» не сломаны).
- [ ] **Step 4: Визуальная приёмка (Playwright)**
`/deals` на 390 / 820 / 1440px. Ожидание: телефон — карточки лидов, ничего не уезжает вбок; планшет/
десктоп — прежняя таблица.
- [ ] **Step 5: Коммит**
Сообщение: `feat(портал-мобилка): «Сделки» — карточки на телефоне через ResponsiveTable`.
---
## Task 9: Регрессия Этапа 1 и приёмочный прогон
**Files:** —
- [ ] **Step 1: Полная фронт-регрессия**
Run: `npm --prefix app run type-check && npm --prefix app run test:vue && npm --prefix app run build`
Expected: всё зелёное, сборка успешна.
- [ ] **Step 2: Визуальный прогон 3 экранов × 3 ширины (Playwright)**
Снять `/dashboard`, `/deals`, `/autopodbor` на 390 / 820 / 1440px. Приёмка: телефон — нет горизонтального
скролла страницы, меню-вкладки работают, «Ещё» открывается; планшет — rail; десктоп — не отличается от «до».
- [ ] **Step 3: Pa11y (доступность), если настроен для SPA-роутов**
Run: `npm run a11y` (или по проектной инструкции)
Expected: без новых нарушений.
- [ ] **Step 4: Итоговый коммит-веха + запрос эскейпа на push у владельца**
Сообщение: `chore(портал-мобилка): Этап 1 (каркас + Сделки) завершён, регрессия зелёная`.
Push — по команде владельца (эскейп для смешанного коммита).
---
## Что дальше (отдельные планы)
- **Этап 2:** Дашборд (плитки/графики в столбик), Проекты, Настройки, Отчёты, Помощь.
- **Этап 3:** Канбан (одна колонка + переключатель этапов, «переместить в этап» вместо drag), Импорт (визард),
доводка «Конкурентного поля».
Каждый — свой план после того, как каркас Этапа 1 ляжет (их код зависит от реальных компонентов каркаса).
На каждом экране держать принцип **мобилка = урезанная, десктоп = полная** и показывать результат владельцу.
---
## Самопроверка плана
- **Покрытие спеки:** §3 breakpoints → Task 1; §4 навигация (нижние вкладки/«Ещё»/rail) → Tasks 2,3,4,5,6;
§5 таблицы→карточки → Tasks 7,8; §7 проверка (390/820/1280, десктоп, Pa11y, регрессия) → Tasks 5,8,9;
§8 изоляция/база с «Полем» → Task 0. Экраны Дашборд/Биллинг Этапа 1 из спеки — вынесены в план Этапа 2
(каркас важнее и разблокирует их; «Сделки» взяты пруфом таблицы). **Отклонение от спеки:** спека
относила Дашборд/Сделки/Биллинг к Этапу 1 — в этом плане Этап 1 = каркас + «Сделки»-пруф, Дашборд/Биллинг
переехали в план Этапа 2 ради размера плана. Согласовать при исполнении.
- **Плейсхолдеры:** явных TODO/TBD нет; помеченные «⚠️ проверить реальный файл» — намеренные, из-за
кросс-веточной работы (правило исполнения №2), а не пропуски.
- **Согласованность типов:** `LayoutTier`, `useLayoutTier().{tier,isPhone,...}`, слоты `#table/#card`,
`data-tier` на `aside`/`v-main`, поля строки `phone/project/city/statusSlug/comment/receivedAt` —
использованы одинаково во всех задачах.