docs(spec): ProjectDetailsDrawer push-mode design + mockup
Design spec + интерактивный HTML mockup для side-panel редактирования
проекта при выборе одного проекта на /projects.
Поведение:
- selectedIds.size === 1 → drawer справа (480px, push-mode, grid сдвигается)
- selectedIds.size >= 2 → BulkActionsBar внизу (условие в ProjectsView.vue:78
меняется > 0 → >= 2)
- 0 selected → ни drawer, ни bulk-bar
Footer drawer:
- Слева (destructive): Приостановить (toggle-active) + Удалить (soft-archive)
- Справа (form actions): Отмена (close+clearSelection) + Сохранить
(PATCH /api/projects/{id})
Backend без изменений — используются существующие endpoints PATCH/DELETE/
toggle-active. Pinia store useProjectsStore уже имеет update/toggleActive/
archive методы.
Прецеденты: DealDetailDrawer.vue (overlay-вариант); push-mode здесь — custom
aside + CSS transform/padding-right, без Vuetify teleport.
Mockup: 3 состояния через JS-toggle (0/1/2+ selected), Forest palette
(Teal #0F6E56, ivory #F6F3EC, noir #012019). Phone masked под 152-FZ ПДн.
cspell-words.txt +1 (юнит) — для упоминания юнит-тестов в spec §6.
Open questions: 0 (все 5 UX-решений утверждены заказчиком 2026-05-14).
This commit is contained in:
@@ -1142,3 +1142,4 @@ COV
|
||||
cdesc
|
||||
qitem
|
||||
skreview
|
||||
юнит
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
# 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+)
|
||||
|
||||
- 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 — но не предмет этой задачи).
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,392 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Mockup: ProjectDetailsDrawer (push-mode справа)</title>
|
||||
<style>
|
||||
:root {
|
||||
--teal: #0f6e56;
|
||||
--teal-soft: rgba(15, 110, 86, 0.10);
|
||||
--ivory: #f6f3ec;
|
||||
--noir: #012019;
|
||||
--line: #e6e2d6;
|
||||
--line-strong: #c9c2af;
|
||||
--text: #081319;
|
||||
--muted: #6b6f72;
|
||||
--paper: #ffffff;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; font-family: 'Inter', system-ui, sans-serif; color: var(--text); background: var(--ivory); }
|
||||
body { display: flex; min-height: 100vh; overflow-x: hidden; }
|
||||
|
||||
/* === Sidebar === */
|
||||
.sidebar {
|
||||
width: 232px; flex: 0 0 232px;
|
||||
background: var(--noir); color: #d4e0db;
|
||||
padding: 20px 16px;
|
||||
display: flex; flex-direction: column; gap: 4px;
|
||||
position: sticky; top: 0; height: 100vh; align-self: flex-start;
|
||||
}
|
||||
.brand { font-size: 18px; font-weight: 700; color: #ffffff; margin-bottom: 24px; letter-spacing: 0.02em; }
|
||||
.brand::after { content: '.'; color: var(--teal); }
|
||||
.nav-section { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: #6b8079; margin: 14px 0 6px; }
|
||||
.nav-item { padding: 8px 12px; border-radius: 6px; font-size: 14px; cursor: pointer; transition: background 150ms; }
|
||||
.nav-item:hover { background: rgba(255,255,255,0.04); }
|
||||
.nav-item.active { background: var(--teal); color: #ffffff; }
|
||||
|
||||
/* === Main area (this is the part that shifts) === */
|
||||
.main {
|
||||
flex: 1; min-width: 0;
|
||||
padding: 28px 32px;
|
||||
padding-right: 32px;
|
||||
transition: padding-right 240ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.main.has-drawer { padding-right: 512px; }
|
||||
|
||||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
||||
.page-header h1 { margin: 0; font-size: 28px; font-weight: 600; }
|
||||
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 16px; border-radius: 6px; font-size: 14px; cursor: pointer; border: none; font-family: inherit; transition: all 150ms; }
|
||||
.btn-primary { background: var(--teal); color: #ffffff; }
|
||||
.btn-primary:hover { background: #0c5a47; }
|
||||
.btn-outline { background: transparent; color: var(--teal); border: 1px solid var(--teal); }
|
||||
.btn-outline:hover { background: var(--teal-soft); }
|
||||
.btn-text { background: transparent; color: var(--text); }
|
||||
.btn-text:hover { background: var(--line); }
|
||||
.btn-warning { background: #f59e0b; color: #ffffff; }
|
||||
.btn-success { background: #16a34a; color: #ffffff; }
|
||||
.btn-error { background: #dc2626; color: #ffffff; }
|
||||
|
||||
/* === Mode toggle (mockup-only) === */
|
||||
.mode-toggle {
|
||||
display: inline-flex; gap: 8px; padding: 6px 10px; background: #fff8d6; border: 1px dashed #c4a435; border-radius: 8px; margin-bottom: 20px; align-items: center;
|
||||
}
|
||||
.mode-toggle b { color: #6b4d00; }
|
||||
.mode-btn { padding: 6px 12px; border-radius: 4px; border: 1px solid var(--line-strong); background: #ffffff; cursor: pointer; font-size: 13px; }
|
||||
.mode-btn.active { background: var(--teal); color: #ffffff; border-color: var(--teal); }
|
||||
|
||||
/* === Toolbar (select-all) === */
|
||||
.toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
|
||||
.toolbar-check {
|
||||
display: inline-flex; align-items: center; cursor: pointer;
|
||||
width: 20px; height: 20px; border: 2px solid var(--noir); border-radius: 4px; background: #ffffff;
|
||||
position: relative;
|
||||
}
|
||||
.toolbar-check.partial { background: var(--teal); border-color: var(--teal); }
|
||||
.toolbar-check.partial::after { content: ''; position: absolute; left: 3px; top: 7px; width: 10px; height: 2px; background: #ffffff; }
|
||||
.toolbar-text { font-size: 14px; color: var(--text); }
|
||||
|
||||
/* === Grid === */
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
|
||||
.card {
|
||||
background: var(--paper); border: 1px solid var(--line); border-radius: 8px;
|
||||
padding: 16px; position: relative; cursor: pointer; transition: all 200ms;
|
||||
}
|
||||
.card:hover { border-color: var(--line-strong); box-shadow: 0 4px 12px rgba(0,0,0,0.04); }
|
||||
.card.selected { border-color: var(--teal); box-shadow: 0 0 0 1px var(--teal); }
|
||||
.card-head { display: flex; gap: 10px; align-items: flex-start; }
|
||||
.card-check {
|
||||
width: 16px; height: 16px; border: 1px solid var(--line-strong); border-radius: 3px; background: #ffffff;
|
||||
flex-shrink: 0; margin-top: 3px; position: relative; cursor: pointer;
|
||||
}
|
||||
.card.selected .card-check { background: var(--teal-soft); border-color: var(--teal); }
|
||||
.card.selected .card-check::after {
|
||||
content: ''; position: absolute; left: 4px; top: 0; width: 5px; height: 9px;
|
||||
border: solid var(--teal); border-width: 0 1.5px 1.5px 0; transform: rotate(45deg);
|
||||
}
|
||||
.card-title { font-weight: 600; font-size: 15px; flex: 1; }
|
||||
.card-chip { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; background: #e8f1ef; color: var(--teal); margin-left: 6px; }
|
||||
.card-id { font-size: 12px; color: var(--muted); margin-top: 4px; font-family: 'JetBrains Mono', monospace; }
|
||||
.card-meta { display: flex; justify-content: space-between; font-size: 12px; color: var(--muted); margin-top: 14px; }
|
||||
.progress-bar { height: 4px; background: var(--line); border-radius: 2px; margin-top: 4px; overflow: hidden; }
|
||||
.progress-bar::before { content: ''; display: block; height: 100%; width: 0%; background: var(--teal); }
|
||||
.card-sync { display: inline-flex; gap: 4px; align-items: center; padding: 2px 8px; border-radius: 10px; background: #fff5e6; color: #b87502; font-size: 11px; margin-top: 10px; }
|
||||
|
||||
/* === Drawer (right side, push-mode) === */
|
||||
.drawer {
|
||||
position: fixed; top: 0; right: 0; bottom: 0;
|
||||
width: 480px; background: var(--paper);
|
||||
border-left: 1px solid var(--line);
|
||||
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;
|
||||
}
|
||||
.drawer.open { transform: translateX(0); }
|
||||
.drawer-head { padding: 20px 24px 16px; border-bottom: 1px solid var(--line); }
|
||||
.drawer-title-row { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
|
||||
.drawer-title { font-size: 18px; font-weight: 600; line-height: 1.3; }
|
||||
.drawer-close { background: transparent; border: none; cursor: pointer; padding: 4px; color: var(--muted); font-size: 20px; line-height: 1; }
|
||||
.drawer-close:hover { color: var(--text); }
|
||||
.drawer-meta { font-size: 12px; color: var(--muted); margin-top: 6px; font-family: 'JetBrains Mono', monospace; }
|
||||
.drawer-body { padding: 20px 24px; flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 18px; }
|
||||
.field-label { font-size: 12px; color: var(--muted); margin-bottom: 6px; display: block; }
|
||||
.field-input {
|
||||
width: 100%; padding: 10px 12px; border: 1px solid var(--line); border-radius: 6px;
|
||||
font-size: 14px; font-family: inherit; background: var(--paper); transition: border-color 150ms;
|
||||
}
|
||||
.field-input:focus { outline: none; border-color: var(--teal); }
|
||||
.chips { display: flex; gap: 6px; flex-wrap: wrap; padding: 8px; border: 1px solid var(--line); border-radius: 6px; min-height: 38px; }
|
||||
.chip { display: inline-flex; align-items: center; gap: 4px; padding: 3px 8px; border-radius: 14px; background: var(--teal-soft); color: var(--teal); font-size: 12px; }
|
||||
.chip .x { cursor: pointer; opacity: 0.6; }
|
||||
.day-toggle { display: flex; gap: 4px; }
|
||||
.day-btn {
|
||||
padding: 6px 10px; border: 1px solid var(--line); background: var(--paper);
|
||||
border-radius: 4px; font-size: 12px; cursor: pointer; min-width: 34px;
|
||||
}
|
||||
.day-btn.active { background: var(--teal); color: #ffffff; border-color: var(--teal); }
|
||||
.day-presets { display: flex; gap: 8px; margin-top: 6px; }
|
||||
.day-presets button { font-size: 11px; padding: 4px 8px; background: transparent; border: none; color: var(--teal); cursor: pointer; }
|
||||
.drawer-foot { padding: 16px 24px; border-top: 1px solid var(--line); display: flex; justify-content: space-between; gap: 8px; align-items: center; }
|
||||
.drawer-foot-left { display: flex; gap: 8px; }
|
||||
.drawer-foot-right { display: flex; gap: 8px; }
|
||||
|
||||
/* === BulkActionsBar (bottom) === */
|
||||
.bulk {
|
||||
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
|
||||
background: var(--paper); border-radius: 8px; box-shadow: 0 8px 32px rgba(0,0,0,0.16);
|
||||
padding: 12px 16px; display: none; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
z-index: 4;
|
||||
max-width: calc(100vw - 280px);
|
||||
}
|
||||
.bulk.show { display: inline-flex; }
|
||||
.bulk strong { font-size: 14px; }
|
||||
.bulk .sep { width: 1px; height: 24px; background: var(--line); }
|
||||
.bulk .btn { font-size: 13px; padding: 6px 12px; }
|
||||
|
||||
/* === Helper === */
|
||||
.legend-note { margin-top: 24px; padding: 12px 16px; background: #eef5f2; border-left: 3px solid var(--teal); font-size: 13px; color: var(--text); border-radius: 4px; }
|
||||
.legend-note b { color: var(--teal); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<aside class="sidebar">
|
||||
<div class="brand">Лидерра</div>
|
||||
<div class="nav-section">РАБОТА</div>
|
||||
<div class="nav-item active">Проекты</div>
|
||||
<div class="nav-item">Сделки</div>
|
||||
<div class="nav-item">Канбан</div>
|
||||
<div class="nav-item">Дашборд</div>
|
||||
<div class="nav-section">ФИНАНСЫ</div>
|
||||
<div class="nav-item">Биллинг</div>
|
||||
<div class="nav-item">Отчёты</div>
|
||||
<div class="nav-section">КОМАНДА</div>
|
||||
<div class="nav-item">Настройки</div>
|
||||
</aside>
|
||||
|
||||
<main class="main" id="main">
|
||||
|
||||
<div class="page-header">
|
||||
<h1>Проекты</h1>
|
||||
<button class="btn btn-primary">+ Создать проект</button>
|
||||
</div>
|
||||
|
||||
<div class="mode-toggle">
|
||||
<b>Mockup:</b>
|
||||
<button class="mode-btn active" data-mode="zero">0 выбрано</button>
|
||||
<button class="mode-btn" data-mode="one">1 выбран → drawer</button>
|
||||
<button class="mode-btn" data-mode="multi">2+ выбрано → bulk-bar</button>
|
||||
<span style="font-size:12px;color:#6b4d00;margin-left:8px;">переключай чтобы увидеть поведение</span>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-check" id="selectAll"></div>
|
||||
<span class="toolbar-text" id="selectInfo">Выбрано: 0 из 3 (по текущим фильтрам)</span>
|
||||
</div>
|
||||
|
||||
<div class="grid" id="grid">
|
||||
<div class="card" data-id="1">
|
||||
<div class="card-head">
|
||||
<div class="card-check"></div>
|
||||
<div style="flex:1;">
|
||||
<div class="card-title">Натяжные потолки (звонки) <span class="card-chip">Звонок</span></div>
|
||||
<div class="card-id">7916XXXXXXX</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<span>0 / 30 лидов</span>
|
||||
<span>0%</span>
|
||||
</div>
|
||||
<div class="progress-bar"></div>
|
||||
<span class="card-sync">⏱ Sync pending</span>
|
||||
</div>
|
||||
|
||||
<div class="card" data-id="2">
|
||||
<div class="card-head">
|
||||
<div class="card-check"></div>
|
||||
<div style="flex:1;">
|
||||
<div class="card-title">Окна СПб (сайт) <span class="card-chip">Сайт</span></div>
|
||||
<div class="card-id">okna-konkurent.ru</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<span>0 / 50 лидов</span>
|
||||
<span>0%</span>
|
||||
</div>
|
||||
<div class="progress-bar"></div>
|
||||
<span class="card-sync">⏱ Sync pending</span>
|
||||
</div>
|
||||
|
||||
<div class="card" data-id="3">
|
||||
<div class="card-head">
|
||||
<div class="card-check"></div>
|
||||
<div style="flex:1;">
|
||||
<div class="card-title">Доставка еды (СМС) <span class="card-chip">СМС</span></div>
|
||||
<div class="card-id">EDA-PROMO, YAEDA · скидка</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<span>0 / 20 лидов</span>
|
||||
<span>0%</span>
|
||||
</div>
|
||||
<div class="progress-bar"></div>
|
||||
<span class="card-sync">⏱ Sync pending</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legend-note" id="legend">
|
||||
<b>Сейчас (0 выбрано):</b> грид во всю ширину, никаких панелей. Кликни «1 выбран» сверху чтобы увидеть drawer.
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- === Drawer (panel справа) === -->
|
||||
<aside class="drawer" id="drawer">
|
||||
<div class="drawer-head">
|
||||
<div class="drawer-title-row">
|
||||
<div>
|
||||
<div class="drawer-title">Натяжные потолки (звонки)</div>
|
||||
<div class="drawer-meta">Звонок · 7916XXXXXXX</div>
|
||||
</div>
|
||||
<button class="drawer-close" id="closeDrawer">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-body">
|
||||
<div>
|
||||
<label class="field-label">Название</label>
|
||||
<input class="field-input" value="Натяжные потолки (звонки)" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="field-label">Лимит лидов в день</label>
|
||||
<input class="field-input" type="number" value="30" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="field-label">Регионы (пусто = вся РФ)</label>
|
||||
<div class="chips">
|
||||
<span class="chip">Москва <span class="x">✕</span></span>
|
||||
<span class="chip">Санкт-Петербург <span class="x">✕</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="field-label">Дни недели приёма</label>
|
||||
<div class="day-toggle">
|
||||
<button class="day-btn active">Пн</button>
|
||||
<button class="day-btn active">Вт</button>
|
||||
<button class="day-btn active">Ср</button>
|
||||
<button class="day-btn active">Чт</button>
|
||||
<button class="day-btn active">Пт</button>
|
||||
<button class="day-btn">Сб</button>
|
||||
<button class="day-btn">Вс</button>
|
||||
</div>
|
||||
<div class="day-presets">
|
||||
<button>Будни</button>
|
||||
<button>Все дни</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-foot">
|
||||
<div class="drawer-foot-left">
|
||||
<button class="btn btn-warning" id="pauseBtn">⏸ Приостановить</button>
|
||||
<button class="btn btn-error" id="deleteBtn">🗄 Удалить</button>
|
||||
</div>
|
||||
<div class="drawer-foot-right">
|
||||
<button class="btn btn-text" id="cancelDrawer">Отмена</button>
|
||||
<button class="btn btn-primary">Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- === BulkActionsBar (внизу) === -->
|
||||
<div class="bulk" id="bulk">
|
||||
<strong>Выбрано: <span id="bulkCount">2</span></strong>
|
||||
<span class="sep"></span>
|
||||
<button class="btn btn-outline">🌍 Регионы…</button>
|
||||
<button class="btn btn-outline">📅 Дни сбора…</button>
|
||||
<button class="btn btn-outline">🎯 Лимит лидов…</button>
|
||||
<span class="sep"></span>
|
||||
<button class="btn btn-warning">⏸ Приостановить</button>
|
||||
<button class="btn btn-success">▶ Возобновить</button>
|
||||
<span class="sep"></span>
|
||||
<button class="btn btn-error">🗄 Архивировать</button>
|
||||
<button class="btn btn-text" style="margin-left:6px;">Снять выбор</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const main = document.getElementById('main');
|
||||
const drawer = document.getElementById('drawer');
|
||||
const bulk = document.getElementById('bulk');
|
||||
const bulkCount = document.getElementById('bulkCount');
|
||||
const grid = document.getElementById('grid');
|
||||
const selectAll = document.getElementById('selectAll');
|
||||
const selectInfo = document.getElementById('selectInfo');
|
||||
const legend = document.getElementById('legend');
|
||||
|
||||
function setMode(mode) {
|
||||
grid.querySelectorAll('.card').forEach(c => c.classList.remove('selected'));
|
||||
|
||||
if (mode === 'zero') {
|
||||
drawer.classList.remove('open');
|
||||
main.classList.remove('has-drawer');
|
||||
bulk.classList.remove('show');
|
||||
selectAll.classList.remove('partial');
|
||||
selectInfo.textContent = 'Выбрано: 0 из 3 (по текущим фильтрам)';
|
||||
legend.innerHTML = '<b>Сейчас (0 выбрано):</b> грид во всю ширину, никаких панелей.';
|
||||
}
|
||||
else if (mode === 'one') {
|
||||
grid.querySelector('[data-id="1"]').classList.add('selected');
|
||||
drawer.classList.add('open');
|
||||
main.classList.add('has-drawer');
|
||||
bulk.classList.remove('show');
|
||||
selectAll.classList.add('partial');
|
||||
selectInfo.textContent = 'Выбрано: 1 из 3 (по текущим фильтрам)';
|
||||
legend.innerHTML = '<b>1 выбран:</b> грид сдвинулся влево (padding-right: 480px). Drawer выехал справа за 240мс. BulkActionsBar внизу <u>скрыт</u>. Поля в drawer — те же, что в текущем "Редактировать" modal. Footer drawer\'а — две группы: слева <b>⏸ Приостановить</b> (PATCH /toggle-active, применяется сразу, label меняется на «Возобновить» если is_active=false) + <b>🗄 Удалить</b> (confirm → DELETE /api/projects/{id} = soft-archive, drawer закрывается); справа <b>Отмена</b> (снимает выбор + закрывает) / <b>Сохранить</b> (PATCH /api/projects/{id} с form-полями + toast).';
|
||||
}
|
||||
else if (mode === 'multi') {
|
||||
grid.querySelector('[data-id="1"]').classList.add('selected');
|
||||
grid.querySelector('[data-id="2"]').classList.add('selected');
|
||||
drawer.classList.remove('open');
|
||||
main.classList.remove('has-drawer');
|
||||
bulk.classList.add('show');
|
||||
bulkCount.textContent = '2';
|
||||
selectAll.classList.add('partial');
|
||||
selectInfo.textContent = 'Выбрано: 2 из 3 (по текущим фильтрам)';
|
||||
legend.innerHTML = '<b>2+ выбрано:</b> drawer уехал обратно за правый край. Грид вернулся к полной ширине. Внизу — BulkActionsBar (как сейчас, без изменений). При снятии до 1 — снова drawer.';
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.mode-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
setMode(btn.dataset.mode);
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('closeDrawer').addEventListener('click', () => {
|
||||
document.querySelector('.mode-btn[data-mode="zero"]').click();
|
||||
});
|
||||
document.getElementById('cancelDrawer').addEventListener('click', () => {
|
||||
document.querySelector('.mode-btn[data-mode="zero"]').click();
|
||||
});
|
||||
|
||||
setMode('zero');
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user