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:
@@ -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` —
|
||||
использованы одинаково во всех задачах.
|
||||
Reference in New Issue
Block a user