# Design Spec: ProjectDetailsDrawer (push-mode справа)

**Дата:** 2026-05-14
**Триггер:** Запрос заказчика — при выборе **одного** проекта на странице `/projects` показывать справа боковую панель с настройками и кнопкой «Сохранить»; BulkActionsBar (#869) должен появляться только при **2+ выбранных**.
**Текущее поведение (baseline):** [app/resources/js/views/ProjectsView.vue:78](../../../app/resources/js/views/ProjectsView.vue#L78) — `<BulkActionsBar v-if="store.selectedIds.size > 0" />` — bulk-bar показывается при ≥1 selected, никакого drawer'а нет.
**Mockup:** [`2026-05-14-project-details-drawer-mockup.html`](./2026-05-14-project-details-drawer-mockup.html) (статический, 3 состояния через toggle).

---

## 1. Поведение (UX-контракт)

| `store.selectedIds.size` | Что показывается | Что скрыто |
|---|---|---|
| `0` | Грид во всю ширину | Drawer ✕, BulkActionsBar ✕ |
| `1` | Грид сдвинут влево (`padding-right: 480px`) + drawer открыт справа | BulkActionsBar ✕ |
| `≥ 2` | Грид во всю ширину + BulkActionsBar внизу (как сейчас) | Drawer ✕ |

**Переходы:**

- Клик на чекбокс ProjectCard (1-й выбранный) → drawer въезжает справа за 240мс CSS-transition, грид сдвигается влево.
- Клик на чекбокс 2-го проекта → drawer мгновенно уезжает обратно (transform: translateX(100%)) + грид возвращается, BulkActionsBar появляется снизу.
- Снятие выбора до 0 → всё уходит (drawer и bulk-bar).
- Закрытие drawer (X, «Отмена», ESC, клик на чекбокс выбранного проекта) → `store.clearSelection()` + drawer закрывается + грид возвращается.

**Edge case:** если `singleSelectedProject` исчезает из `store.items` (после fetch — проект удалён/архивирован/выпал из фильтра), drawer закрывается автоматически через computed.

---

## 2. Архитектура

**Подход A: custom `<aside>` с CSS push.** (Утверждён 2026-05-14.) Альтернативы (Vuetify teleport / promote-to-AppLayout) отклонены — Vuetify push-mode требует drawer'а как sibling of `v-main`, что усложняет boundary; teleport-в-internals хрупок.

**Новые файлы:**

- `app/resources/js/components/projects/ProjectDetailsDrawer.vue` — компонент панели.
- `app/tests/Frontend/ProjectDetailsDrawer.spec.ts` — юнит-тесты (jsdom).

**Изменения существующих:**

- [app/resources/js/views/ProjectsView.vue](../../../app/resources/js/views/ProjectsView.vue) — условие BulkActionsBar `> 0` → `>= 2`; добавить `<ProjectDetailsDrawer />`; добавить computed `singleSelectedProject`; CSS-класс `.has-drawer` на `.projects-view` root.
- [app/resources/js/components/projects/BulkActionsBar.story.vue](../../../app/resources/js/components/projects/BulkActionsBar.story.vue) — без изменений (BulkActionsBar сам не меняется).

**Backend — без изменений.** Используем существующие endpoints:

- `PATCH /api/projects/{id}` через [UpdateProjectRequest.php](../../../app/app/Http/Requests/UpdateProjectRequest.php) — поля `name`, `daily_limit_target`, `region_mask`+`region_mode`, `delivery_days_mask`, `sms_senders`, `sms_keyword`.
- `PATCH /api/projects/{id}/toggle-active` — для кнопки «Приостановить»/«Возобновить».
- `DELETE /api/projects/{id}` — для кнопки «Удалить» (soft-archive).

**Pinia store ([projectsStore.ts](../../../app/resources/js/stores/projectsStore.ts)):** уже имеет `update(id, payload)`, `toggleActive(project)`, `archive(id)`, `clearSelection()`, `selectedIds: Set<number>`. Не требует изменений.

---

## 3. Компоненты и интерфейсы

### `ProjectDetailsDrawer.vue`

```ts
defineProps<{
  project: Project | null;  // null = drawer закрыт (v-if cleanup внутри)
}>()
defineEmits<{
  close: [];              // X / Отмена / ESC
  saved: [];              // после успешного PATCH
}>()
```

**ESC handler (на life-cycle компонента):**

```ts
function onKey(e: KeyboardEvent) {
  if (e.key === 'Escape' && props.project) emit('close');
}
onMounted(() => document.addEventListener('keydown', onKey));
onBeforeUnmount(() => document.removeEventListener('keydown', onKey));
```

**Локальный state (reactive form):**

```ts
const form = reactive({
  name: '',
  daily_limit_target: 50,
  region_mask: 0,
  region_mode: 'include' as 'include' | 'exclude',
  delivery_days_mask: 127,
  sms_senders: [] as string[],
  sms_keyword: '',
});
const saving = ref(false);
const errors = reactive<Record<string, string[]>>({});
```

`watch(() => props.project?.id, reseed)` — пересаживает form при смене проекта.

**Поля (порядок сверху вниз):**

1. **Header (read-only):** `name + signal_type + signal_identifier/sms_senders` (как метка).
2. **Название** (`v-text-field`) — `form.name`, 1-255.
3. **Лимит лидов в день** (`v-text-field type="number"`) — `form.daily_limit_target`, 1-10000.
4. **Регионы** (`v-autocomplete multiple chips`) — биндинг к `selectedRegions[]` → `region_mask` + `region_mode` (логика как в [NewProjectDialog.vue:141-153](../../../app/resources/js/views/projects/NewProjectDialog.vue#L141-L153)).
5. **Дни недели приёма** (`v-btn-toggle multiple`) — 7 кнопок Пн-Вс + пресеты «Будни» / «Все дни».
6. **SMS-поля** (только если `project.signal_type === 'sms'`): `sms_senders` (v-combobox), `sms_keyword` (v-text-field).

**Footer (две группы):**

- **Слева (destructive):**
  - `⏸ Приостановить` (`btn-warning`) — лейбл переключается на `▶ Возобновить` если `!project.is_active`. Применяется немедленно через `store.toggleActive(project)`. Без confirm. Form-state не сбрасывается.
  - `🗄 Удалить` (`btn-error`) — `window.confirm('Архивировать проект? Действие необратимо в Plan 5 (восстановление потребует ручного запроса).')` → `store.archive(project.id)` → `emit('close')`.
- **Справа (primary form actions):**
  - `Отмена` (`btn-text`) — `emit('close')` без сохранения.
  - `Сохранить` (`btn-primary`, `:loading="saving"`) — PATCH /api/projects/{id} с form-полями. На 422 → проставить `errors`. На успех → toast «Сохранено», `emit('saved')` (родитель делает `store.fetch()`). Drawer остаётся открытым (заказчик может продолжить редактирование).

### Изменения `ProjectsView.vue`

```vue
<script setup>
// добавить:
const singleSelectedProject = computed<Project | null>(() => {
  if (store.selectedIds.size !== 1) return null;
  const [id] = store.selectedIds;
  return store.items.find(p => p.id === id) ?? null;
});

function onDrawerClose() {
  store.clearSelection();
}
function onDrawerSaved() {
  store.fetch();
}
</script>

<template>
  <div class="projects-view" :class="{ 'has-drawer': singleSelectedProject !== null }">
    <!-- ... существующий header / filters / toolbar / grid ... -->

    <!-- условие меняется: > 0  →  >= 2 -->
    <BulkActionsBar v-if="store.selectedIds.size >= 2" />

    <!-- новый mount: -->
    <ProjectDetailsDrawer
      :project="singleSelectedProject"
      @close="onDrawerClose"
      @saved="onDrawerSaved"
    />

    <NewProjectDialog v-model="createOpen" mode="create" @saved="store.fetch()" />
    <EditProjectDialog v-model="editOpen" :project="editing" @saved="store.fetch()" />
  </div>
</template>

<style scoped>
.projects-view { transition: padding-right 240ms cubic-bezier(0.16, 1, 0.3, 1); }
.projects-view.has-drawer { padding-right: 480px; }
</style>
```

### `ProjectDetailsDrawer.vue` CSS-skeleton

```css
.project-details-drawer {
  position: fixed; top: 0; right: 0; bottom: 0;
  width: 480px;
  background: var(--liderra-surface, #ffffff);
  border-left: 1px solid var(--liderra-line, #e6e2d6);
  box-shadow: -4px 0 16px rgba(0, 0, 0, 0.06);
  transform: translateX(100%);
  transition: transform 240ms cubic-bezier(0.16, 1, 0.3, 1);
  display: flex; flex-direction: column;
  z-index: 5;
}
.project-details-drawer.open { transform: translateX(0); }
```

Внутренний `v-if="project"` — рендер контента только когда проект есть; transform отрабатывает на пустом контейнере для close-анимации.

---

## 4. Data flow

```
ProjectCard.checkbox → toggle-select emit
  → store.toggleSelect(id) — обновляет store.selectedIds
    → reactive: ProjectsView.singleSelectedProject computed
      → ProjectDetailsDrawer :project={...} получает обновление
        → watch(() => project?.id) → reseed form
        → CSS .has-drawer / .open классы меняются → 240мс transition

ProjectDetailsDrawer.Сохранить:
  → axios.patch(/api/projects/{id}, form) [через store.update или прямой axios]
  → 200: emit('saved') → ProjectsView.onDrawerSaved → store.fetch() → grid refreshes
  → 422: errors reactive проставляется, drawer не закрывается

ProjectDetailsDrawer.Приостановить:
  → store.toggleActive(project) → axios.patch(/api/projects/{id}/toggle-active)
  → store.fetch() — project.is_active обновлён → label кнопки меняется

ProjectDetailsDrawer.Удалить:
  → window.confirm
  → store.archive(project.id) → axios.delete(/api/projects/{id})
  → emit('close') → store.clearSelection() → drawer уходит

ProjectDetailsDrawer.X / Отмена / ESC:
  → emit('close') → store.clearSelection()
```

---

## 5. Error handling

- **422 validation:** `e.response.data.errors` → reactive `errors` объект, проставляется через `:error-messages="errors.field"` на v-text-field-ах. Drawer не закрывается, можно править и повторно сохранить.
- **Network error:** snackbar «Не удалось сохранить — попробуйте позже» (warning). Drawer остаётся открытым.
- **Toggle-active 4xx:** silent no-op (label не меняется — пользователь увидит что состояние не сменилось). Можно добавить toast в Plan 6 если потребуется.
- **Archive 4xx:** `window.alert('Не удалось удалить — попробуйте позже')`. Drawer не закрывается.
- **Form dirty + Cancel:** **отбрасывается без подтверждения** (MVP). Если пользователь жалуется на потерю — добавим confirm в Plan 6.

---

## 6. Тестирование

### Vitest (jsdom) — новый файл `app/tests/Frontend/ProjectDetailsDrawer.spec.ts`

Тест-кейсы:

1. **Renders nothing when project=null** — drawer DOM присутствует (для transition), но контент пуст / `.open` отсутствует.
2. **Renders project fields when project provided** — title, name input pre-filled, daily_limit pre-filled, days-mask pre-filled.
3. **Reseeds form on project.id change** — props.project меняется → form.name перезагружается.
4. **Emits 'close' on X click**.
5. **Emits 'close' on Cancel click**.
6. **Emits 'close' on ESC keydown**.
7. **Save: PATCH /api/projects/{id} с form-полями + emits 'saved' on 200**.
8. **Save: 422 → errors reactive проставляется, no emit 'saved', drawer DOM сохраняется**.
9. **Pause button: вызывает store.toggleActive(project) + меняет label при `is_active=false`**.
10. **Delete button: confirm=true → store.archive(id) + emit 'close'; confirm=false → no-op**.
11. **is_active=true → label «Приостановить»; is_active=false → label «Возобновить»**.

### Vitest integration — extend [app/tests/Frontend/ProjectsView.spec.ts](../../../app/tests/Frontend/ProjectsView.spec.ts) (файл уже есть)

1. **0 selected → no drawer, no bulk-bar**.
2. **1 selected → drawer открыт, bulk-bar скрыт, .has-drawer class на view**.
3. **2 selected → drawer закрыт, bulk-bar показан, no .has-drawer class**.
4. **Drawer close → store.clearSelection() вызывается → bulk-bar и drawer оба скрыты**.
5. **singleSelectedProject = null когда проект пропадает из items после fetch**.

### Pest (backend) — не требуется

Backend endpoints не меняются. Существующие тесты `tests/Feature/Api/ProjectControllerTest.php` покрывают PATCH/DELETE/toggle-active.

### Visual smoke — Playwright MCP / browser ([Pravila §4.6](../../../docs/Pravila_raboty_Claude_v1_1.md))

Запустить `npm run dev`, открыть /projects:

- Click 1 проект → проверить визуально что drawer въезжает, грид сдвигается.
- Click 2-й проект → drawer уезжает, bulk-bar появляется.
- ESC / X / Cancel → drawer закрывается, выбор снимается.
- Сохранить → toast «Сохранено», карточка обновляется в гриде.

### Регрессии (must-not-break)

- Pre-existing BulkActionsBar tests (`tests/Frontend/BulkActionsBar.spec.ts` если есть) → не должны падать.
- Pa11y на /projects authenticated route → не должно быть новых violations (color contrast на новых elementah уже выровнен под Forest palette).
- Vue-tsc / ESLint — 0 errors.

---

## 7. Out of scope (Plan 6+)

**Design-out-of-scope (deliberately deferred при brainstorm 2026-05-14):**

- Confirm dialog при close с dirty form (отбрасываем без подтверждения на MVP).
- Optimistic update form-полей в grid после Сохранить (сейчас полный fetch — приемлемая UX-задержка <200мс).
- Drawer на DealsView, KanbanView (только Projects сейчас).
- Mobile-адаптация (480px drawer + грид = на узких viewport'ах overlap). При <768px viewport — fallback на modal-edit (текущий «Редактировать» в card-menu).
- Keyboard navigation между полями drawer'а (Tab уже работает, но без shortcut'ов).
- Multi-edit для 2+ selected с общими полями (это уже частично делает BulkActionsBar — но не предмет этой задачи).

**Polish-debt накопленный за Tasks 1-9 (reviewer-flagged, user defer 2026-05-14):**

- **Token drift (4 hardcoded hex → CSS vars):**
  - `#0f6e56` × 4 в [ProjectDetailsDrawer.vue](../../../app/resources/js/components/projects/ProjectDetailsDrawer.vue) `.pdd-day.active` / `.pdd-btn-primary` → `var(--liderra-teal)` ([tokens.css:7](../../../app/resources/css/tokens.css)). Task 2 I1.
  - `#f59e0b` (Tailwind amber) в `.pdd-btn-warning` → `var(--liderra-saffron, #D9A441)`. NB: значение поменяется (saffron другой оттенок, brand-consistent). Task 6 Important.
  - `#dc2626` × 2 в `.pdd-btn-error` + `.pdd-error` → `var(--liderra-error, #B83A3A)`. Значение поменяется. Task 7 M1.
  - `480px` × 2 (ProjectsView CSS + ProjectDetailsDrawer CSS) → `var(--liderra-drawer-width: 480px)` в tokens.css. Task 8 M1.

- **UX gaps:**
  - **Snackbar для network errors (spec §5)** не реализован: `onSave`/`onPause`/`onDelete` silently swallow non-422 errors. Spec прямо это требует. Task 5 Important.
  - **Drawer a11y:** `<aside>` lacks `role="dialog"` / `aria-modal="true"` / `aria-labelledby="pdd-title-id"` / focus-trap при open / focus return при close. Task 8 M4.
  - **Lucide icons вместо emoji** ✕ / ⏸ / ▶ / 🗄 — CTO-19 mandate. Использовать `lucide-vue-next` components (X / Pause / Play / Archive). Task 2 M3 + Tasks 6/7 cross-task pattern.

- **Test hardening:**
  - `data-testid="pdd-error-limit"` symmetry (name-error has testid, limit-error does not). Task 5 M.
  - `vi.clearAllMocks()` в `beforeEach` для defensive isolation. Task 5 M.
  - `v-if="errors.name?.length"` вместо `v-if="errors.name"` (empty-array безопасность). Task 5 M.
  - Comment fix at [ProjectsView.spec.ts:232-233](../../../app/tests/Frontend/ProjectsView.spec.ts) — misleading description. Task 9 M.
  - `data-testid="pdd-delete"` confirm-args assertion (lock the prompt text). Task 7 M2.
  - `:disabled="saving"` на Delete во избежание double-action гонок. Task 7 M4.

**Cross-cutting debt (не только PDD):**

- **Silent error handling** в `void store.fetch()`, `store.toggleActive()`, `store.archive()` calls — same pattern на DealDetailDrawer + ProjectsView. App-wide toast/error-bus story.
- **Sentry breadcrumbs** для actions (pending Б-1 instance deployment per Tooling #34).

---

## 8. Open questions

Нет. Все UX-вопросы закрыты заказчиком 2026-05-14:

1. Mutual exclusion drawer ↔ bulk-bar: **да** (drawer при 1, bulk-bar при 2+).
2. Drawer mode: **push, не overlay** — грид сдвигается, drawer уезжает обратно при close.
3. Close = unselect: **да**.
4. Modal «Редактировать» в card-menu: **оставить как secondary path**.
5. Footer drawer'а: **Приостановить + Удалить (слева) + Отмена + Сохранить (справа)**.

---

## 9. Связанные документы

- [2026-05-14-project-details-drawer-mockup.html](./2026-05-14-project-details-drawer-mockup.html) — интерактивный mockup (3 состояния через toggle), утверждён заказчиком 2026-05-14.
- [BRANDBOOK_v2.md](../../../liderra_v8_handoff/docs/BRANDBOOK_v2.md) — цвета (Teal `#0F6E56`, ivory `#F6F3EC`, line `--liderra-line`).
- [DealDetailDrawer.vue](../../../app/resources/js/components/deals/DealDetailDrawer.vue) — prior art для `v-navigation-drawer` (overlay-вариант; здесь — push-вариант).
- [NewProjectDialog.vue](../../../app/resources/js/views/projects/NewProjectDialog.vue) — источник полей формы (название/лимит/регионы/дни).
- [BulkActionsBar.vue](../../../app/resources/js/components/projects/BulkActionsBar.vue) — компонент, условие отображения которого меняется (`> 0` → `>= 2`).
- [CLAUDE.md §3.3](../../../CLAUDE.md) #19 Superpowers + #30 Frontend Design (paired stack для UI-разработки).
- [Pravila_raboty_Claude_v1_1.md §4.6](../../../docs/Pravila_raboty_Claude_v1_1.md) — visual smoke для UI-refactor.
