feat(deals/drawer): inline status picker — статус-chip кликабельный, без мутации props

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) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-18 15:34:07 +03:00
parent 9fcefa3ab9
commit 1412d3fefd
8 changed files with 194 additions and 37 deletions
-18
View File
@@ -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",
+1 -5
View File
@@ -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"],
@@ -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({
<template>
<div v-if="deal" class="drawer-content">
<DealDetailHero :deal="deal" :status="status" @close="emit('close')" />
<DealDetailHero
:deal="deal"
:status="status"
:all-statuses="leadStatusesStore.statuses"
@close="emit('close')"
@change-status="onStatusChange"
/>
<v-divider />
@@ -19,7 +19,10 @@ const props = withDefaults(
{ inline: false },
);
const emit = defineEmits<{ 'update:open': [value: boolean] }>();
const emit = defineEmits<{
'update:open': [value: boolean];
'status-changed': [slug: string];
}>();
const drawerOpen = computed({
get: () => props.open,
@@ -33,7 +36,12 @@ function close() {
<template>
<aside v-if="inline" v-show="open" class="deal-detail-inline" data-testid="deal-detail-panel">
<DealDetailBody :deal="deal" :tenant-id="tenantId" @close="close" />
<DealDetailBody
:deal="deal"
:tenant-id="tenantId"
@close="close"
@status-changed="(s: string) => emit('status-changed', s)"
/>
</aside>
<v-navigation-drawer
v-else
@@ -43,7 +51,12 @@ function close() {
:width="480"
class="deal-drawer"
>
<DealDetailBody :deal="deal" :tenant-id="tenantId" @close="close" />
<DealDetailBody
:deal="deal"
:tenant-id="tenantId"
@close="close"
@status-changed="(s: string) => emit('status-changed', s)"
/>
</v-navigation-drawer>
</template>
@@ -8,13 +8,20 @@
import type { MockDeal } from '../../composables/mockDeals';
import type { LeadStatus } from '../../composables/leadStatuses';
defineProps<{
deal: MockDeal;
status: LeadStatus | null;
}>();
withDefaults(
defineProps<{
deal: MockDeal;
status: LeadStatus | null;
// 18.05.2026 ux: inline status picker — кликабельный chip с выпадающим
// списком всех статусов. Если allStatuses не передан — chip read-only.
allStatuses?: LeadStatus[];
}>(),
{ allStatuses: () => [] },
);
defineEmits<{
close: [];
'change-status': [slug: string];
}>();
function formatRelative(minutes: number): string {
@@ -41,10 +48,34 @@ function formatRelative(minutes: number): string {
</div>
<div v-if="status" class="status-row mt-3">
<v-chip size="small" variant="tonal" :style="{ color: status.colorHex, borderColor: status.colorHex }">
<span class="status-dot" :style="{ background: status.colorHex }" />
{{ status.nameRu }}
</v-chip>
<v-menu :disabled="(allStatuses?.length ?? 0) === 0">
<template #activator="{ props: a }">
<v-chip
v-bind="a"
data-testid="status-chip-trigger"
size="small"
variant="tonal"
:style="{ color: status.colorHex, borderColor: status.colorHex, cursor: (allStatuses?.length ?? 0) > 0 ? 'pointer' : 'default' }"
>
<span class="status-dot" :style="{ background: status.colorHex }" />
{{ status.nameRu }}
<v-icon v-if="(allStatuses?.length ?? 0) > 0" size="14" class="ml-1">mdi-menu-down</v-icon>
</v-chip>
</template>
<v-list density="compact">
<v-list-item
v-for="s in allStatuses"
:key="s.slug"
:data-testid="`status-option-${s.slug}`"
@click="$emit('change-status', s.slug)"
>
<template #prepend>
<span class="status-dot" :style="{ background: s.colorHex }" />
</template>
<v-list-item-title>{{ s.nameRu }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</div>
</header>
</template>
+23
View File
@@ -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<void> {
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"
/>
</div>
+44 -1
View File
@@ -52,6 +52,44 @@ const dealsByStatus = reactive<Record<string, MockDeal[]>>(
}, {}),
);
/**
* 18.05.2026 ux — inline status picker в drawer (DealDetailHero).
* При смене статуса через drawer — переносим карточку между колонками
* Канбана (vuedraggable arrays) + API call + rollback.
*/
async function onDrawerStatusChanged(slug: string): Promise<void> {
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({
/>
</div>
<DealDetailDrawer v-model:open="drawerOpen" :deal="selectedDeal" :tenant-id="auth.user?.tenant_id" />
<DealDetailDrawer
v-model:open="drawerOpen"
:deal="selectedDeal"
:tenant-id="auth.user?.tenant_id"
@status-changed="onDrawerStatusChanged"
/>
<NewDealDialog v-model="newDealOpen" :tenant-id="auth.user?.tenant_id" @created="onDealCreated" />
+51
View File
@@ -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> = {}): 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();
});
});