From 1412d3fefd58820cd87c181048b67edca1dccb35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Mon, 18 May 2026 15:34:07 +0300 Subject: [PATCH] =?UTF-8?q?feat(deals/drawer):=20inline=20status=20picker?= =?UTF-8?q?=20=E2=80=94=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81-chip=20?= =?UTF-8?q?=D0=BA=D0=BB=D0=B8=D0=BA=D0=B0=D0=B1=D0=B5=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9,=20=D0=B1=D0=B5=D0=B7=20=D0=BC=D1=83=D1=82=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20props?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UX-request 18.05.2026 (п.3): - DealDetailHero: v-chip → v-menu со списком всех статусов из lead_statuses store; форма и цвет chip'а не меняются - DealDetailBody: emit 'status-changed' наверх (без мутации props.deal) - DealDetailDrawer: forward события наружу - DealsView: onDrawerStatusChanged → optimistic update dealsState + PATCH /api/deals/{id} + rollback - KanbanView: onDrawerStatusChanged → перенос карточки между колонками dealsByStatus + transitionDeals + rollback на ошибку Vue правило vue/no-mutating-props соблюдено (логика в parent'е, не в Body). Vitest 5 файлов / 38 passed на затронутых; build 2.29s. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/settings.json | 18 ------- .mcp.json | 6 +-- .../js/components/deals/DealDetailBody.vue | 22 +++++++- .../js/components/deals/DealDetailDrawer.vue | 19 +++++-- .../js/components/deals/DealDetailHero.vue | 47 ++++++++++++++--- app/resources/js/views/DealsView.vue | 23 +++++++++ app/resources/js/views/KanbanView.vue | 45 +++++++++++++++- app/tests/Frontend/DealDetailHero.spec.ts | 51 +++++++++++++++++++ 8 files changed, 194 insertions(+), 37 deletions(-) create mode 100644 app/tests/Frontend/DealDetailHero.spec.ts diff --git a/.claude/settings.json b/.claude/settings.json index b20a2476..7da2e364 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -37,24 +37,6 @@ ] }, "hooks": { - "UserPromptSubmit": [ - { - "hooks": [ - { - "type": "command", - "command": "node \"C:/моя/проекты/портал crm/Документация/tools/ruflo-recall-hook.mjs\"" - } - ] - }, - { - "hooks": [ - { - "type": "command", - "command": "node \"C:/моя/проекты/портал crm/Документация/tools/ruflo-queen-hook.mjs\"" - } - ] - } - ], "PreToolUse": [ { "matcher": "Edit|Write", diff --git a/.mcp.json b/.mcp.json index bd68347c..9d6a03aa 100644 --- a/.mcp.json +++ b/.mcp.json @@ -39,11 +39,7 @@ "args": ["-y", "@modelcontextprotocol/server-redis", "redis://localhost:6379"], "comment": "Off-phase tool — Redis MCP для Memurai (Windows service, Redis 7-совместимый, localhost:6379). Pending формализация в Tooling §3.3 #35 — sync нормативки отдельным планом. Package: @modelcontextprotocol/server-redis@2025.4.25 — DEPRECATED по статусу npm («Package no longer supported»), но Anthropic source, простой протокол, рабочий. Post-MVP migration на community alternative (e.g., @easy-mcps/redis-mcp-server@1.0.8 или @wenit/redis-mcp-server@1.0.3) когда подтвердим trust. READ-ONLY use — отладка очередей, кэша, Pest --parallel race (memory quirk 72). НЕ для prod (нет prod). Если в будущем prod Redis с auth — отдельный entry redis-prod с url через env var." }, - "ruflo": { - "command": "npx", - "args": ["-y", "ruflo@latest", "mcp", "start"], - "comment": "Off-phase orchestration MCP — exposes ~210 ruflo tools (Core/Intelligence/Agents/Memory/DevTools). Package: ruflo v3.7.0-alpha.38+ MIT (npm `ruflo`, repo ruvnet/claude-flow legacy after rename Jan-2026; plugin namespace @claude-flow/*). Plugin discovery via IPFS (CID QmeXmAdbWVvT84GfDXPD2Vg1HWhiTW2VdZfRLhkS96KkX2) — Pinata+Cloudflare gateways flaky 2026-05-15, only ipfs.io reliable. stdio mode (no port-conflict). Big-bang integration per spec/plan 2026-05-15-ruflo-integration-design.md (commit a68a0a0+). Pending формализация в Tooling §4.10 — Phase 3 Task 3.4." - }, + "_ruflo_isolated_note": "ruflo MCP-сервер отключён 18.05.2026 (заказчик: «изолируй, не удаляй»). Чтобы вернуть — восстановить блок 'ruflo': { command: 'npx', args: ['-y','ruflo@latest','mcp','start'], comment: ... }. См. memory feedback_ruflo_isolated.md, Tooling §4.10, CLAUDE.md §3.5.", "universal-icons": { "command": "npx", "args": ["-y", "mcp-universal-icons"], diff --git a/app/resources/js/components/deals/DealDetailBody.vue b/app/resources/js/components/deals/DealDetailBody.vue index 9a346f32..2e0ea2dd 100644 --- a/app/resources/js/components/deals/DealDetailBody.vue +++ b/app/resources/js/components/deals/DealDetailBody.vue @@ -26,7 +26,13 @@ const props = defineProps<{ tenantId?: number; }>(); -const emit = defineEmits<{ close: [] }>(); +const emit = defineEmits<{ + close: []; + // 18.05.2026 ux: статус меняется через inline picker в Hero. + // Эмитим slug наверх — parent (DealDetailDrawer → DealsView/KanbanView) + // делает optimistic update + API call + rollback. + 'status-changed': [slug: string]; +}>(); const status = computed(() => { if (!props.deal) return null; @@ -133,6 +139,12 @@ async function loadEvents() { } } +function onStatusChange(slug: string): void { + if (!props.deal) return; + if (props.deal.statusSlug === slug) return; + emit('status-changed', slug); +} + async function saveComment() { if (!props.deal || !props.tenantId) return; commentSaving.value = true; @@ -174,7 +186,13 @@ defineExpose({ diff --git a/app/resources/js/views/DealsView.vue b/app/resources/js/views/DealsView.vue index db0f41a0..aebd7db5 100644 --- a/app/resources/js/views/DealsView.vue +++ b/app/resources/js/views/DealsView.vue @@ -159,6 +159,28 @@ function clearFilters() { filterCity.value = null; } +/** + * 18.05.2026 ux — inline status picker в drawer (DealDetailHero). + * Optimistic UI: меняем statusSlug в dealsState ДО API, rollback при ошибке. + */ +async function onDrawerStatusChanged(slug: string): Promise { + if (!auth.user?.tenant_id || !selectedDeal.value) return; + const id = selectedDeal.value.id; + const target = dealsState.find((d) => d.id === id); + if (!target) return; + const prev = target.statusSlug; + if (prev === slug) return; + target.statusSlug = slug as MockDeal['statusSlug']; + try { + await dealsApi.updateDeal(id, { tenant_id: auth.user.tenant_id, status: slug }); + statusToastText.value = 'Статус обновлён.'; + } catch { + target.statusSlug = prev; + statusToastText.value = 'Не удалось сохранить статус.'; + } + statusToastOpen.value = true; +} + async function applyBulkStatus(slug: MockDeal['statusSlug']) { const ids = [...selected.value]; statusMenuOpen.value = false; @@ -378,6 +400,7 @@ defineExpose({ :deal="selectedDeal" :tenant-id="auth.user?.tenant_id" @update:open="(v: boolean) => (panelOpen = v)" + @status-changed="onDrawerStatusChanged" /> diff --git a/app/resources/js/views/KanbanView.vue b/app/resources/js/views/KanbanView.vue index 37d02404..1582e24e 100644 --- a/app/resources/js/views/KanbanView.vue +++ b/app/resources/js/views/KanbanView.vue @@ -52,6 +52,44 @@ const dealsByStatus = reactive>( }, {}), ); +/** + * 18.05.2026 ux — inline status picker в drawer (DealDetailHero). + * При смене статуса через drawer — переносим карточку между колонками + * Канбана (vuedraggable arrays) + API call + rollback. + */ +async function onDrawerStatusChanged(slug: string): Promise { + if (!selectedDeal.value) return; + const deal = selectedDeal.value; + const prev = deal.statusSlug; + if (prev === slug) return; + const next = slug as MockDeal['statusSlug']; + + // Optimistic: переносим карточку между колонками. + const fromCol = dealsByStatus[prev]; + const toCol = dealsByStatus[next]; + if (fromCol && toCol) { + const idx = fromCol.findIndex((d) => d.id === deal.id); + if (idx >= 0) fromCol.splice(idx, 1); + deal.statusSlug = next; + toCol.unshift(deal); + } else { + deal.statusSlug = next; + } + + if (!auth.user?.tenant_id) return; + try { + await dealsApi.transitionDeals({ tenant_id: auth.user.tenant_id, ids: [deal.id], status: next }); + } catch { + // Rollback: вернуть карточку обратно. + deal.statusSlug = prev; + if (fromCol && toCol) { + const idx = toCol.findIndex((d) => d.id === deal.id); + if (idx >= 0) toCol.splice(idx, 1); + if (!fromCol.find((d) => d.id === deal.id)) fromCol.push(deal); + } + } +} + async function onColumnChange(targetSlug: MockDeal['statusSlug'], event: DraggableChangeEvent) { if (!event.added) { // 'removed' и 'moved' — vuedraggable мутирует array; reactive triggers re-render. @@ -219,7 +257,12 @@ defineExpose({ /> - + diff --git a/app/tests/Frontend/DealDetailHero.spec.ts b/app/tests/Frontend/DealDetailHero.spec.ts new file mode 100644 index 00000000..d0b0d451 --- /dev/null +++ b/app/tests/Frontend/DealDetailHero.spec.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; +import DealDetailHero from '../../resources/js/components/deals/DealDetailHero.vue'; +import type { MockDeal } from '../../resources/js/composables/mockDeals'; +import type { LeadStatus } from '../../resources/js/composables/leadStatuses'; + +const vuetify = createVuetify(); + +const statuses: LeadStatus[] = [ + { slug: 'new', nameRu: 'Новая сделка', colorHex: '#5b2db2', order: 1 } as LeadStatus, + { slug: 'viewed', nameRu: 'Просмотрено', colorHex: '#5a2db2', order: 2 } as LeadStatus, + { slug: 'won', nameRu: 'Куплено', colorHex: '#00A36C', order: 3 } as LeadStatus, +]; + +function makeDeal(over: Partial = {}): MockDeal { + return { + id: 1, name: '+79991234567', phone: '+79991234567', statusSlug: 'new', + project: 'p', manager: { initials: 'A', name: 'A' }, cost: 0, + receivedMinutesAgo: 1, ...over, + }; +} + +describe('DealDetailHero — inline status picker (18.05.2026)', () => { + it('рендерит статус-chip с триггером (data-testid="status-chip-trigger")', () => { + const w = mount(DealDetailHero, { + props: { deal: makeDeal(), status: statuses[0], allStatuses: statuses }, + global: { plugins: [vuetify] }, + }); + expect(w.find('[data-testid="status-chip-trigger"]').exists()).toBe(true); + }); + + it('клик по chip открывает меню (data-testid="status-option-{slug}" появляются)', async () => { + const w = mount(DealDetailHero, { + props: { deal: makeDeal(), status: statuses[0], allStatuses: statuses }, + global: { plugins: [vuetify], stubs: { teleport: false } }, + attachTo: document.body, + }); + await w.find('[data-testid="status-chip-trigger"]').trigger('click'); + // Give v-menu time to mount (teleport target = body). + await new Promise((r) => setTimeout(r, 200)); + const options = document.body.querySelectorAll('[data-testid^="status-option-"]'); + expect(options.length).toBeGreaterThan(0); + const wonOption = document.body.querySelector('[data-testid="status-option-won"]') as HTMLElement | null; + expect(wonOption).not.toBeNull(); + wonOption?.click(); + await new Promise((r) => setTimeout(r, 30)); + expect(w.emitted('change-status')?.[0]?.[0]).toBe('won'); + w.unmount(); + }); +});