diff --git a/cspell-words.txt b/cspell-words.txt index 0edc691d..4f3db0b1 100644 --- a/cspell-words.txt +++ b/cspell-words.txt @@ -1144,3 +1144,4 @@ qitem skreview юнит pdd +иммутабельны diff --git a/docs/superpowers/specs/2026-05-14-pdd-regions-field-design.md b/docs/superpowers/specs/2026-05-14-pdd-regions-field-design.md new file mode 100644 index 00000000..a465e25f --- /dev/null +++ b/docs/superpowers/specs/2026-05-14-pdd-regions-field-design.md @@ -0,0 +1,231 @@ +# Design Spec: ProjectDetailsDrawer — поле «Регионы» + +**Дата:** 2026-05-14 (вечер) +**Триггер:** Запрос заказчика после visual smoke ProjectDetailsDrawer (screenshot 14.05 вечер) — добавить редактирование региона в drawer-форму. +**Текущее состояние:** ProjectDetailsDrawer MVP shipped (commits `9d88955..0d7f505`), редактирует name/limit/days. `region_mask` + `region_mode` отправляются в PATCH payload (Task 5 уже их включает), но UI-контрола нет — поля иммутабельны для пользователя. +**Связано:** [PDD design spec §7](./2026-05-14-project-details-drawer-design.md) Out-of-plan пункт "Region multi-select autocomplete" — этой spec'ой закрывается. + +--- + +## 1. Поведение (UX-контракт) + +| Состояние project | `selectedRegions[]` в UI | `form.region_mask` | `form.region_mode` | +|---|---|---|---| +| `region_mask=0` (вся РФ по умолчанию) | пустой массив | 0 | `'include'` | +| `region_mask=6` (bits 1+2: Адыгея + Башкортостан) | `[1, 2]` → 2 chips | 6 | (legacy: сохраняем `include` или `exclude` как пришло) | +| User добавляет регион (например code:3) | `[1, 2, 3]` → 3 chips | encoded: 14 (bits 1+2+3) | `'exclude'` | +| User снимает все | `[]` → 0 chips | 0 | `'include'` | + +**Семантика бэкенда (legacy за пределы Task 5):** `region_mode='include'+mask=0` ≡ «принимать всю РФ»; `region_mode='exclude'+mask≠0` ≡ «принимать всю РФ, КРОМЕ перечисленных». Эта конвенция установлена в [NewProjectDialog.vue:141-153](../../../app/resources/js/views/projects/NewProjectDialog.vue#L141) и сохраняется без изменений. + +**Edge case:** project уже сохранён с `region_mode='include'+mask≠0` (legacy/inconsistent данные) — UI decompose'ит mask в codes, показывает chips. Если пользователь нажмёт «сохранить» — watch перепишет mode='exclude'. Это normalization (не deletion) — приемлемо. + +--- + +## 2. Архитектура + +**Подход A: In-place port** (утверждён 2026-05-14). Альтернативы (composable extraction, defer) отклонены — single use, YAGNI. + +**Изменения:** + +- [app/resources/js/components/projects/ProjectDetailsDrawer.vue](../../../app/resources/js/components/projects/ProjectDetailsDrawer.vue) — добавить: + - Import `REGIONS` из `../../constants/regions`. + - `const selectedRegions = ref([]);` + - `const selectableRegions = REGIONS.filter(r => r.code !== 0);` (исключить «Вся РФ» sentinel — fixes latent NewProjectDialog bug). + - Helper `maskToCodes(mask: number): number[]` — reverse-decompose bits 1..31. + - В `reseedFromProject(p)`: `selectedRegions.value = maskToCodes(p.region_mask ?? 0);` + - Watch `selectedRegions` → encode в `form.region_mask` + `form.region_mode`. + - Template: `` блок между «Лимит» и «Дни приёма». +- [app/tests/Frontend/ProjectDetailsDrawer.spec.ts](../../../app/tests/Frontend/ProjectDetailsDrawer.spec.ts) — +3 теста. + +**Backend — без изменений.** [UpdateProjectRequest.php](../../../app/app/Http/Requests/UpdateProjectRequest.php) уже validates `region_mask` + `region_mode`. Task 5 onSave уже их шлёт. + +--- + +## 3. Компоненты и интерфейсы + +### `ProjectDetailsDrawer.vue` — script additions + +```ts +import { REGIONS } from '../../constants/regions'; + +const selectedRegions = ref([]); +const selectableRegions = REGIONS.filter(r => r.code !== 0); + +function maskToCodes(mask: number): number[] { + const codes: number[] = []; + for (let i = 1; i <= 31; i++) if (mask & (1 << i)) codes.push(i); + return codes; +} + +// inside reseedFromProject(p), AFTER form.region_mask line: +selectedRegions.value = maskToCodes(p.region_mask ?? 0); + +watch(selectedRegions, (codes) => { + if (codes.length === 0) { + form.region_mask = 0; + form.region_mode = 'include'; + } else { + form.region_mask = codes.reduce((acc, c) => (c >= 1 && c <= 31 ? acc | (1 << c) : acc), 0); + form.region_mode = 'exclude'; + } +}); +``` + +### Template insertion (между Лимит и Дни) + +```vue +
+ Регионы (пусто = вся РФ) + +
+``` + +`` уже доступен через vite-plugin-vuetify auto-import. + +--- + +## 4. Data flow + +``` +Mount drawer / swap project + → reseedFromProject(p) + → form.region_mask = p.region_mask ?? 0 + → selectedRegions.value = maskToCodes(form.region_mask) + → autocomplete отображает chips + +User selects/deselects region in autocomplete + → selectedRegions reactive update + → watch fires + → form.region_mask + form.region_mode updated + +Save click + → onSave (existing, Task 5) + → payload.region_mask = form.region_mask + → payload.region_mode = form.region_mode + → axios.patch(/api/projects/{id}, payload) +``` + +--- + +## 5. Error handling + +- `p.region_mask` undefined / null → `?? 0` → empty codes → safe. +- `mask` с битом 0 (sentinel «вся РФ» mistakenly set от bugged NewProjectDialog) → `maskToCodes` skips bit 0 (loop `for i=1..31`) → бит игнорируется в UI. Не отображается как chip. Save затем перепишет mask без bit 0. +- `codes >31` — REGIONS array ограничен 1..31, поэтому this branch unreachable через UI. Defensive `c >= 1 && c <= 31` guard в watch на случай programmatic mutation. Plan 6: bigint upgrade. +- Network error при Save — обрабатывается existing onSave (silently caught, не часть этой spec'и). + +--- + +## 6. Тестирование + +### Vitest jsdom — расширить [app/tests/Frontend/ProjectDetailsDrawer.spec.ts](../../../app/tests/Frontend/ProjectDetailsDrawer.spec.ts) + +**Тест 1: `renders region chips when project has non-zero region_mask`** + +```ts +it('renders region chips when project has non-zero region_mask', async () => { + const withRegions: Project = { ...sampleProject, region_mask: 6, region_mode: 'exclude' }; // bits 1+2 + const wrapper = mount(ProjectDetailsDrawer, { props: { project: withRegions } }); + await wrapper.vm.$nextTick(); + const text = wrapper.text(); + expect(text).toContain('Адыгея'); // code 1 + expect(text).toContain('Башкортостан'); // code 2 +}); +``` + +**Тест 2: `selecting regions encodes mask + sets mode='exclude'`** + +```ts +it('selecting regions encodes mask + sets mode=exclude', async () => { + const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } }); + // sampleProject имеет region_mask=0, mode='include' начально + const autocomplete = wrapper.getComponent({ name: 'VAutocomplete' }); + await autocomplete.vm.$emit('update:model-value', [3, 5]); // 2 regions + await wrapper.vm.$nextTick(); + // Inspect via exposed form ref OR via PATCH payload after save click + // Easier path: spy axios + click save, assert payload.region_mask + (axios.patch as ReturnType).mockResolvedValueOnce({ data: { data: sampleProject } }); + await wrapper.get('[data-testid="pdd-save"]').trigger('click'); + await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); + expect(axios.patch).toHaveBeenCalledWith( + '/api/projects/42', + expect.objectContaining({ region_mask: 40, region_mode: 'exclude' }), // bits 3+5 = 8+32 = 40 + ); +}); +``` + +**Тест 3: `clearing all regions resets mask=0 + mode='include'`** + +```ts +it('clearing all regions resets mask=0 + mode=include', async () => { + const withRegions: Project = { ...sampleProject, region_mask: 6, region_mode: 'exclude' }; + const wrapper = mount(ProjectDetailsDrawer, { props: { project: withRegions } }); + const autocomplete = wrapper.getComponent({ name: 'VAutocomplete' }); + await autocomplete.vm.$emit('update:model-value', []); + await wrapper.vm.$nextTick(); + (axios.patch as ReturnType).mockResolvedValueOnce({ data: { data: withRegions } }); + await wrapper.get('[data-testid="pdd-save"]').trigger('click'); + await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); + expect(axios.patch).toHaveBeenCalledWith( + '/api/projects/42', + expect.objectContaining({ region_mask: 0, region_mode: 'include' }), + ); +}); +``` + +### Visual smoke (manual, заказчик) + +1. Открыть /projects → выбрать проект «Доставка еды (СМС)» → drawer открывается. +2. В drawer'е увидеть поле «Регионы (пусто = вся РФ)» между Лимитом и Днями. +3. Кликнуть в input → dropdown показывает 31 регион (без «Вся РФ»). +4. Выбрать Москва + СПб → появляются 2 chips. +5. Click Сохранить → toast, PATCH в Network tab содержит `region_mask` non-zero + `region_mode=exclude`. +6. Re-open drawer того же проекта → 2 chips сохранены (reverse-decompose работает). +7. Click ✕ на chip → chip убирается. +8. Кнопка clearable (X справа от input) → все chips удалены, mask=0 + mode=include на save. + +### Регрессии (must-not-break) + +- 14 PDD unit tests + 5 integration → все green. +- Vue-tsc / ESLint — 0 errors. +- Pre-commit hooks — 0 issues. + +--- + +## 7. Out of scope + +- NewProjectDialog reverse-decompose TODO (line 172) — отдельный sweep. +- Composable `useRegionsBitmask` extraction — Plan 6 если будет 3-й consumer. +- Bigint mask для region codes >31 (нужен schema change PostgreSQL) — Plan 6. +- Кастомный chip-design (currently Vuetify default) — Quiet Luxury polish. +- Mobile-адаптация autocomplete (touch keyboard / full-screen modal) — Plan 6. +- Server-side validation на duplicate codes — UI gateways через Set semantics в v-autocomplete. + +--- + +## 8. Open questions + +Нет. UX-вопрос (исключить «Вся РФ» из dropdown) закрыт заказчиком 2026-05-14. + +--- + +## 9. Связанные документы + +- [2026-05-14-project-details-drawer-design.md](./2026-05-14-project-details-drawer-design.md) — parent design spec, §7 Out-of-plan «Region multi-select autocomplete» закрывается этой spec'ой. +- [NewProjectDialog.vue](../../../app/resources/js/views/projects/NewProjectDialog.vue) — source recipe (lines 78-87 template + 141-153 watch). +- [constants/regions.ts](../../../app/resources/js/constants/regions.ts) — REGIONS array (32 entries). +- [UpdateProjectRequest.php](../../../app/app/Http/Requests/UpdateProjectRequest.php) — backend validation contract.