feat(p1): edit answers, kanban drag, audit viewer, bell for rop

- LeadAnswersForm: extracted reusable scoring form. LeadCardView gets
  "Изменить ответы" toggle that swaps the breakdown list for the form;
  saving PATCHes the lead, reloads lead + history, shows toast.
  Unlocks PRD §3 "Кнопка редактирования" and brings score_history to
  life (was always 1 row because UI had no way to re-score).
- KanbanBoard: HTML5 drag-and-drop. Dragging a card to another stage
  PATCHes deal_stage, reloads board, toast on result. Source column
  not highlighted on hover; visual opacity + ring on drop target.
  Errors (e.g. 403 IDOR for non-owner) surface as toast.
- AuditView (+ api/audit.ts): rop-only /audit page. Filters by entity,
  entity_id, actor. Expand row to see diff JSON. Lead rows link to
  lead card. Added to nav and router (ropOnly).
- BellMenu: now shown for any authenticated user (was manager-only).
  Backend /reminders and /tasks/upcoming already return rop-wide
  results for the rop role.
This commit is contained in:
Telecom 2026-05-22 12:59:31 +05:00
parent 6015dee57e
commit af53bc77d5
7 changed files with 696 additions and 12 deletions

View File

@ -57,10 +57,17 @@ function userInitials(name: string | undefined): string {
>
Пользователи
</RouterLink>
<RouterLink
v-if="auth.isRop"
:to="{ name: 'audit' }"
class="nav-link"
>
Аудит
</RouterLink>
</nav>
</div>
<div class="flex items-center gap-3">
<BellMenu v-if="auth.user?.role === 'manager'" />
<BellMenu v-if="auth.isAuthenticated" />
<div class="flex items-center gap-2 pl-3 border-l border-gray-200">
<div
class="w-7 h-7 rounded-full bg-gray-100 text-gray-700 flex items-center justify-center text-xs font-semibold"

View File

@ -0,0 +1,33 @@
import { apiFetch } from './client';
export interface AuditEntry {
id: string;
actor: string;
entity: string;
entity_id: string;
action: string;
diff: unknown;
created_at: string;
}
export interface AuditQuery {
entity?: string;
entity_id?: string;
actor?: string;
limit?: number;
}
function qs(params: Record<string, unknown>): string {
const parts: string[] = [];
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null || v === '') continue;
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
}
return parts.length ? `?${parts.join('&')}` : '';
}
export function listAudit(query: AuditQuery = {}) {
return apiFetch<{ data: AuditEntry[] }>(
`/audit${qs(query as Record<string, unknown>)}`,
);
}

View File

@ -1,12 +1,25 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useLeadsStore } from '@/stores/leads';
import { useToast } from '@/composables/useToast';
import ScoreBadge from '@/components/ScoreBadge.vue';
import { DEAL_STAGES, DEAL_STAGE_LABELS, type DealStage } from '@/api/leads';
import {
DEAL_STAGES,
DEAL_STAGE_LABELS,
updateLead,
type DealStage,
} from '@/api/leads';
import { ApiRequestError } from '@/api/client';
const store = useLeadsStore();
const router = useRouter();
const toast = useToast();
const draggingId = ref<string | null>(null);
const draggingFrom = ref<DealStage | null>(null);
const dragOverStage = ref<DealStage | null>(null);
const savingId = ref<string | null>(null);
const columns = computed(() => {
const map: Record<DealStage, typeof store.items> = {
@ -42,6 +55,57 @@ function money(v: number | null) {
function stageTotal(items: typeof store.items): number {
return items.reduce((acc, l) => acc + (l.expected_amount ?? 0), 0);
}
function onDragStart(e: DragEvent, leadId: string, fromStage: DealStage) {
draggingId.value = leadId;
draggingFrom.value = fromStage;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', leadId);
}
}
function onDragEnd() {
draggingId.value = null;
draggingFrom.value = null;
dragOverStage.value = null;
}
function onDragOver(e: DragEvent, stage: DealStage) {
if (!draggingId.value) return;
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
dragOverStage.value = stage;
}
function onDragLeave(stage: DealStage) {
if (dragOverStage.value === stage) dragOverStage.value = null;
}
async function onDrop(targetStage: DealStage) {
const id = draggingId.value;
const from = draggingFrom.value;
draggingId.value = null;
draggingFrom.value = null;
dragOverStage.value = null;
if (!id || !from || from === targetStage) return;
savingId.value = id;
try {
await updateLead(id, { deal_stage: targetStage });
await store.load();
toast.success(`Этап: ${DEAL_STAGE_LABELS[targetStage]}`);
} catch (e) {
const msg = e instanceof ApiRequestError ? e.message : 'Ошибка сохранения';
toast.error(msg);
} finally {
savingId.value = null;
}
}
function openLead(id: string) {
router.push({ name: 'lead-card', params: { id } });
}
</script>
<template>
@ -49,7 +113,14 @@ function stageTotal(items: typeof store.items): number {
<div
v-for="col in columns"
:key="col.stage"
class="card min-w-[220px] flex flex-col"
class="card min-w-[220px] flex flex-col transition"
:class="{
'ring-2 ring-gray-900 ring-offset-1':
dragOverStage === col.stage && draggingFrom !== col.stage,
}"
@dragover="onDragOver($event, col.stage)"
@dragleave="onDragLeave(col.stage)"
@drop="onDrop(col.stage)"
>
<div
class="px-3 py-2 border-b border-gray-100 flex items-center justify-between rounded-t-2xl"
@ -69,12 +140,18 @@ function stageTotal(items: typeof store.items): number {
>
—
</div>
<button
<div
v-for="lead in col.items"
:key="lead.id"
type="button"
class="w-full text-left bg-white border border-gray-100 rounded-lg p-2.5 hover:border-gray-300 transition shadow-sm"
@click="router.push({ name: 'lead-card', params: { id: lead.id } })"
:draggable="savingId !== lead.id"
class="w-full text-left bg-white border border-gray-100 rounded-lg p-2.5 hover:border-gray-300 transition shadow-sm cursor-grab active:cursor-grabbing select-none"
:class="{
'opacity-40': draggingId === lead.id,
'opacity-60 pointer-events-none': savingId === lead.id,
}"
@dragstart="onDragStart($event, lead.id, col.stage)"
@dragend="onDragEnd"
@click="openLead(lead.id)"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
@ -92,7 +169,7 @@ function stageTotal(items: typeof store.items): number {
<div class="mt-1 text-[10px] uppercase tracking-wider text-gray-400">
{{ lead.created_by }}
</div>
</button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,288 @@
<script setup lang="ts">
import { reactive, ref, computed, watch } from 'vue';
import QuestionButton from '@/components/QuestionButton.vue';
import {
BUDGET_WEIGHT,
COMPANY_SIZE_WEIGHT,
DM_CONTACT_WEIGHT,
PREV_CONTACT_WEIGHT,
SOURCE_WEIGHT,
URGENCY_WEIGHT,
classifyEmail,
previewScore,
} from '@/scoring';
import type {
Budget,
CompanySize,
DmContact,
LeadType,
PrevContact,
Source,
UpdateLeadBody,
Urgency,
} from '@/api/leads';
interface InitialAnswers {
lead_type: LeadType;
company_size: CompanySize;
budget: Budget;
urgency: Urgency;
dm_contact: DmContact;
source?: Source;
prev_contact?: PrevContact;
email: string;
}
const props = defineProps<{
initial: InitialAnswers;
saving?: boolean;
}>();
const emit = defineEmits<{
(e: 'save', body: UpdateLeadBody): void;
(e: 'cancel'): void;
}>();
const form = reactive<{
lead_type: LeadType;
company_size: CompanySize | null;
budget: Budget | null;
urgency: Urgency | null;
dm_contact: DmContact | null;
source: Source | null;
prev_contact: PrevContact | null;
}>({
lead_type: props.initial.lead_type,
company_size: props.initial.company_size ?? null,
budget: props.initial.budget ?? null,
urgency: props.initial.urgency ?? null,
dm_contact: props.initial.dm_contact ?? null,
source: props.initial.source ?? null,
prev_contact: props.initial.prev_contact ?? null,
});
watch(
() => props.initial,
(next) => {
form.lead_type = next.lead_type;
form.company_size = next.company_size ?? null;
form.budget = next.budget ?? null;
form.urgency = next.urgency ?? null;
form.dm_contact = next.dm_contact ?? null;
form.source = next.source ?? null;
form.prev_contact = next.prev_contact ?? null;
},
);
const leadTypeOpts: { value: LeadType; label: string }[] = [
{ value: 'inbound', label: 'Входящий' },
{ value: 'outbound', label: 'Исходящий' },
];
const companySizeOpts: { value: CompanySize; label: string; points: number }[] = [
{ value: '1-10', label: '1–10', points: COMPANY_SIZE_WEIGHT['1-10'] },
{ value: '11-50', label: '11–50', points: COMPANY_SIZE_WEIGHT['11-50'] },
{ value: '51-200', label: '51–200', points: COMPANY_SIZE_WEIGHT['51-200'] },
{ value: '200+', label: '200+', points: COMPANY_SIZE_WEIGHT['200+'] },
];
const budgetOpts: { value: Budget; label: string; points: number }[] = [
{ value: 'no', label: 'Нет', points: BUDGET_WEIGHT.no },
{ value: 'maybe', label: 'Возможно', points: BUDGET_WEIGHT.maybe },
{ value: 'confirmed', label: 'Подтверждён', points: BUDGET_WEIGHT.confirmed },
];
const urgencyOpts: { value: Urgency; label: string; points: number }[] = [
{ value: 'none', label: 'Нет', points: URGENCY_WEIGHT.none },
{ value: '6m-plus', label: 'Больше 6 мес', points: URGENCY_WEIGHT['6m-plus'] },
{ value: '3-6m', label: '3–6 мес', points: URGENCY_WEIGHT['3-6m'] },
{ value: '1-3m', label: '1–3 мес', points: URGENCY_WEIGHT['1-3m'] },
{ value: 'under-1m', label: 'Меньше месяца', points: URGENCY_WEIGHT['under-1m'] },
];
const dmContactOpts: { value: DmContact; label: string; points: number; tip: string }[] = [
{ value: 'unknown', label: 'Не выяснено', points: DM_CONTACT_WEIGHT.unknown, tip: 'Кто решает — пока не знаем' },
{ value: 'user', label: 'Пользователь', points: DM_CONTACT_WEIGHT.user, tip: 'Будет пользоваться продуктом, но решение не принимает' },
{ value: 'influencer', label: 'Влияет на выбор', points: DM_CONTACT_WEIGHT.influencer, tip: 'Не подписывает, но мнение слушают' },
{ value: 'champion', label: 'Внутренний адвокат', points: DM_CONTACT_WEIGHT.champion, tip: 'Продаёт за тебя внутри компании' },
{ value: 'economic-buyer', label: 'Распорядитель бюджета', points: DM_CONTACT_WEIGHT['economic-buyer'], tip: 'Подписывает счёт, окончательное «да» за ним' },
];
const sourceOpts: { value: Source; label: string; points: number; tip?: string }[] = [
{ value: 'cold', label: 'Холодный список', points: SOURCE_WEIGHT.cold },
{ value: 'ad', label: 'Реклама', points: SOURCE_WEIGHT.ad },
{ value: 'content', label: 'Скачал материал', points: SOURCE_WEIGHT.content, tip: 'White-paper, кейс, гайд' },
{ value: 'referral', label: 'Рекомендация', points: SOURCE_WEIGHT.referral },
{ value: 'website', label: 'Сайт / форма', points: SOURCE_WEIGHT.website },
{ value: 'demo-request', label: 'Запросил демо', points: SOURCE_WEIGHT['demo-request'] },
];
const prevContactOpts: { value: PrevContact; label: string; points: number }[] = [
{ value: 'none', label: 'Не было', points: PREV_CONTACT_WEIGHT.none },
{ value: 'once', label: 'Встречались один раз', points: PREV_CONTACT_WEIGHT.once },
{ value: 'well-known', label: 'Знакомы хорошо', points: PREV_CONTACT_WEIGHT['well-known'] },
];
const preview = computed(() =>
previewScore({
lead_type: form.lead_type,
company_size: form.company_size,
budget: form.budget,
urgency: form.urgency,
dm_contact: form.dm_contact,
source: form.source,
prev_contact: form.prev_contact,
email: props.initial.email,
}),
);
const breakdown = computed(() => preview.value.breakdown);
const statusLabel = computed(() => {
const s = preview.value.status;
if (s === 'hot') return 'HOT';
if (s === 'warm') return 'WARM';
if (s === 'cold') return 'COLD';
return '—';
});
const statusColor = computed(() => {
const s = preview.value.status;
if (s === 'hot') return 'bg-emerald-500';
if (s === 'warm') return 'bg-amber-500';
if (s === 'cold') return 'bg-sky-500';
return 'bg-gray-300';
});
const emailClass = computed(() => classifyEmail(props.initial.email));
const emailMod = computed(() => {
if (emailClass.value === 'corporate') return 5;
if (emailClass.value === 'free') return -5;
return 0;
});
const canSave = computed(() => {
if (!form.company_size || !form.budget || !form.urgency || !form.dm_contact) return false;
if (form.lead_type === 'inbound' && !form.source) return false;
if (form.lead_type === 'outbound' && !form.prev_contact) return false;
return true;
});
function submit() {
if (!canSave.value) return;
const body: UpdateLeadBody = {
lead_type: form.lead_type,
company_size: form.company_size!,
budget: form.budget!,
urgency: form.urgency!,
dm_contact: form.dm_contact!,
};
if (form.lead_type === 'inbound') {
body.source = form.source!;
body.prev_contact = undefined;
} else {
body.prev_contact = form.prev_contact!;
body.source = undefined;
}
emit('save', body);
}
</script>
<template>
<form
class="card-pad space-y-5"
@submit.prevent="submit"
>
<div class="flex items-center gap-3">
<div
class="w-12 h-12 rounded-full text-white text-lg font-bold flex items-center justify-center tabular-nums transition-colors shrink-0"
:class="statusColor"
>
{{ preview.total }}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-baseline justify-between gap-2">
<span class="text-sm font-semibold text-gray-900">
{{ statusLabel }}
<span class="text-gray-400 font-normal">
· {{ preview.filled }}/{{ preview.required }} вопросов
</span>
</span>
<span v-if="emailMod !== 0" class="text-xs tabular-nums" :class="emailMod > 0 ? 'text-emerald-600' : 'text-red-600'">
email {{ emailMod > 0 ? '+5' : '−5' }}
</span>
</div>
<div class="mt-1.5 h-2 rounded-full bg-gray-100 overflow-hidden">
<div
class="h-full transition-all duration-200"
:class="statusColor"
:style="{ width: preview.total + '%' }"
/>
</div>
</div>
</div>
<QuestionButton label="Тип лида" v-model="form.lead_type" :options="leadTypeOpts" />
<QuestionButton
label="Размер компании"
v-model="form.company_size"
:options="companySizeOpts"
:max="20"
:earned="breakdown.company_size ?? 0"
/>
<QuestionButton
label="Кто наш контакт?"
hint="MEDDIC — наведи на вариант для пояснения"
v-model="form.dm_contact"
:options="dmContactOpts"
:max="25"
:earned="breakdown.dm_contact ?? 0"
/>
<QuestionButton
label="Бюджет"
v-model="form.budget"
:options="budgetOpts"
:max="20"
:earned="breakdown.budget ?? 0"
/>
<QuestionButton
label="Срочность"
v-model="form.urgency"
:options="urgencyOpts"
:max="20"
:earned="breakdown.urgency ?? 0"
/>
<QuestionButton
v-if="form.lead_type === 'inbound'"
label="Источник лида"
v-model="form.source"
:options="sourceOpts"
:max="10"
:earned="breakdown.source ?? 0"
/>
<QuestionButton
v-else
label="Предыдущий контакт"
v-model="form.prev_contact"
:options="prevContactOpts"
:max="10"
:earned="breakdown.prev_contact ?? 0"
/>
<div class="flex gap-3 pt-2">
<button
type="submit"
class="btn-primary text-sm"
:disabled="!canSave || saving"
>
{{ saving ? 'Сохраняем…' : 'Сохранить ответы' }}
</button>
<button
type="button"
class="btn-ghost text-sm"
:disabled="saving"
@click="emit('cancel')"
>
Отмена
</button>
</div>
</form>
</template>

View File

@ -45,6 +45,12 @@ const router = createRouter({
component: () => import('@/views/AdminUsersView.vue'),
meta: { ropOnly: true },
},
{
path: '/audit',
name: 'audit',
component: () => import('@/views/AuditView.vue'),
meta: { ropOnly: true },
},
],
});

View File

@ -0,0 +1,219 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import Skeleton from '@/components/Skeleton.vue';
import { listAudit, type AuditEntry } from '@/api/audit';
import { useToast } from '@/composables/useToast';
const toast = useToast();
const router = useRouter();
const filters = reactive<{ entity: string; entity_id: string; actor: string }>({
entity: '',
entity_id: '',
actor: '',
});
const entries = ref<AuditEntry[]>([]);
const loading = ref(false);
const ENTITY_LABELS: Record<string, string> = {
lead: 'Лид',
user: 'Пользователь',
system: 'Система',
};
const ACTION_LABELS: Record<string, string> = {
create: 'создан',
delete: 'удалён',
reassign: 'переназначен',
reset_password: 'сменён пароль',
change_role: 'изменена роль',
recalc: 'пересчёт score',
};
const expanded = ref<Set<string>>(new Set());
function toggleExpand(id: string) {
if (expanded.value.has(id)) expanded.value.delete(id);
else expanded.value.add(id);
expanded.value = new Set(expanded.value);
}
async function load() {
loading.value = true;
try {
const r = await listAudit({
entity: filters.entity || undefined,
entity_id: filters.entity_id || undefined,
actor: filters.actor || undefined,
limit: 200,
});
entries.value = r.data;
} catch (e) {
toast.error((e as Error).message);
} finally {
loading.value = false;
}
}
function reset() {
filters.entity = '';
filters.entity_id = '';
filters.actor = '';
load();
}
function fmtTime(s: string): string {
return new Date(s).toLocaleString('ru-RU');
}
function hasDiff(e: AuditEntry): boolean {
if (e.diff === null || e.diff === undefined) return false;
if (typeof e.diff === 'object' && Object.keys(e.diff as object).length === 0) return false;
return true;
}
function diffString(e: AuditEntry): string {
try {
return JSON.stringify(e.diff, null, 2);
} catch {
return String(e.diff);
}
}
function gotoEntity(e: AuditEntry) {
if (e.entity === 'lead') {
router.push({ name: 'lead-card', params: { id: e.entity_id } });
}
}
const empty = computed(() => !loading.value && entries.value.length === 0);
onMounted(load);
</script>
<template>
<div class="max-w-5xl mx-auto space-y-6">
<div>
<h1 class="text-2xl font-semibold tracking-tight">Аудит</h1>
<p class="text-sm text-gray-500 mt-1">
Журнал изменений: кто, что, когда. Последние 200 событий.
</p>
</div>
<form
class="card-pad grid grid-cols-1 md:grid-cols-4 gap-3"
@submit.prevent="load"
>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Сущность</label>
<select v-model="filters.entity" class="input-base text-sm">
<option value="">Любая</option>
<option value="lead">Лид</option>
<option value="user">Пользователь</option>
<option value="system">Система</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">ID сущности</label>
<input
v-model="filters.entity_id"
type="text"
placeholder="uuid или username"
class="input-base text-sm"
/>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Кто (actor)</label>
<input
v-model="filters.actor"
type="text"
placeholder="username"
class="input-base text-sm"
/>
</div>
<div class="flex items-end gap-2">
<button type="submit" class="btn-primary text-sm">Фильтр</button>
<button type="button" class="btn-ghost text-sm" @click="reset">
Сброс
</button>
</div>
</form>
<div v-if="loading" class="card p-4">
<Skeleton :rows="6" height="36px" />
</div>
<div
v-else-if="empty"
class="card-pad text-sm text-gray-500 text-center py-10"
>
Событий не найдено.
</div>
<div v-else class="card overflow-hidden">
<table class="table-base">
<thead>
<tr>
<th class="w-44">Когда</th>
<th class="w-28">Кто</th>
<th class="w-24">Сущность</th>
<th>Действие · ID</th>
<th class="w-24 text-right">Diff</th>
</tr>
</thead>
<tbody>
<template v-for="e in entries" :key="e.id">
<tr class="hover:bg-gray-50/60">
<td class="px-4 py-2 text-xs text-gray-600 tabular-nums">
{{ fmtTime(e.created_at) }}
</td>
<td class="px-4 py-2 text-sm font-medium">{{ e.actor }}</td>
<td class="px-4 py-2 text-xs">
<span class="badge bg-gray-100 text-gray-700">
{{ ENTITY_LABELS[e.entity] ?? e.entity }}
</span>
</td>
<td class="px-4 py-2 text-sm">
<span class="text-gray-900">
{{ ACTION_LABELS[e.action] ?? e.action }}
</span>
<button
v-if="e.entity === 'lead'"
type="button"
class="ml-2 text-xs text-sky-600 hover:underline font-mono"
@click="gotoEntity(e)"
>
{{ e.entity_id.slice(0, 8) }}…
</button>
<span
v-else
class="ml-2 text-xs text-gray-500 font-mono"
>
{{ e.entity_id }}
</span>
</td>
<td class="px-4 py-2 text-right">
<button
v-if="hasDiff(e)"
type="button"
class="text-xs text-gray-500 hover:text-gray-900"
@click="toggleExpand(e.id)"
>
{{ expanded.has(e.id) ? 'Скрыть' : 'Показать' }}
</button>
<span v-else class="text-xs text-gray-300">—</span>
</td>
</tr>
<tr v-if="expanded.has(e.id) && hasDiff(e)">
<td colspan="5" class="px-4 py-2 bg-gray-50 border-b border-gray-100">
<pre
class="text-[11px] text-gray-700 whitespace-pre-wrap font-mono"
>{{ diffString(e) }}</pre>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>

View File

@ -7,6 +7,8 @@ import MoneyInput from '@/components/MoneyInput.vue';
import NotesPanel from '@/components/NotesPanel.vue';
import TasksPanel from '@/components/TasksPanel.vue';
import ReassignWidget from '@/components/ReassignWidget.vue';
import LeadAnswersForm from '@/components/LeadAnswersForm.vue';
import { useToast } from '@/composables/useToast';
import {
deleteLead,
getLead,
@ -17,16 +19,52 @@ import {
type DealStage,
type HistoryEntry,
type Lead,
type UpdateLeadBody,
} from '@/api/leads';
import { ApiRequestError } from '@/api/client';
const props = defineProps<{ id: string }>();
const auth = useAuthStore();
const toast = useToast();
const lead = ref<Lead | null>(null);
const history = ref<HistoryEntry[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const editingAnswers = ref(false);
const savingAnswers = ref(false);
const answersInitial = computed(() => {
if (!lead.value) return null;
return {
lead_type: lead.value.lead_type,
company_size: lead.value.answers.company_size,
budget: lead.value.answers.budget,
urgency: lead.value.answers.urgency,
dm_contact: lead.value.answers.dm_contact,
source: lead.value.answers.source,
prev_contact: lead.value.answers.prev_contact,
email: lead.value.email,
};
});
async function saveAnswers(body: UpdateLeadBody) {
if (!lead.value) return;
savingAnswers.value = true;
try {
const updated = await updateLead(lead.value.id, body);
lead.value = updated;
const h = await getLeadHistory(lead.value.id);
history.value = h.data;
editingAnswers.value = false;
toast.success('Ответы обновлены');
} catch (e) {
const msg = e instanceof ApiRequestError ? e.message : 'Ошибка сохранения';
toast.error(msg);
} finally {
savingAnswers.value = false;
}
}
const pipelineForm = reactive({
deal_stage: 'new' as DealStage,
@ -318,8 +356,24 @@ onMounted(load);
</div>
<div class="card-pad space-y-3">
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest">Детализация баллов</h2>
<ul class="divide-y divide-gray-100">
<div class="flex items-center justify-between gap-3">
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest">Детализация баллов</h2>
<button
type="button"
class="text-xs text-gray-500 hover:text-gray-900 transition"
@click="editingAnswers = !editingAnswers"
>
{{ editingAnswers ? 'Отменить' : 'Изменить ответы' }}
</button>
</div>
<LeadAnswersForm
v-if="editingAnswers && answersInitial"
:initial="answersInitial"
:saving="savingAnswers"
@save="saveAnswers"
@cancel="editingAnswers = false"
/>
<ul v-else class="divide-y divide-gray-100">
<li
v-for="(pts, key) in lead.breakdown"
:key="key"
@ -332,7 +386,7 @@ onMounted(load);
</span>
</li>
</ul>
<div class="flex justify-between pt-2 border-t border-gray-200 font-semibold">
<div v-if="!editingAnswers" class="flex justify-between pt-2 border-t border-gray-200 font-semibold">
<span>Итого</span>
<span>{{ lead.score }} / {{ SCORE_MAX }}</span>
</div>