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:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user