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:
parent
6015dee57e
commit
af53bc77d5
@ -57,10 +57,17 @@ function userInitials(name: string | undefined): string {
|
|||||||
>
|
>
|
||||||
Пользователи
|
Пользователи
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
v-if="auth.isRop"
|
||||||
|
:to="{ name: 'audit' }"
|
||||||
|
class="nav-link"
|
||||||
|
>
|
||||||
|
Аудит
|
||||||
|
</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<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="flex items-center gap-2 pl-3 border-l border-gray-200">
|
||||||
<div
|
<div
|
||||||
class="w-7 h-7 rounded-full bg-gray-100 text-gray-700 flex items-center justify-center text-xs font-semibold"
|
class="w-7 h-7 rounded-full bg-gray-100 text-gray-700 flex items-center justify-center text-xs font-semibold"
|
||||||
|
|||||||
33
leadscore/frontend/src/api/audit.ts
Normal file
33
leadscore/frontend/src/api/audit.ts
Normal 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>)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,12 +1,25 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import { useLeadsStore } from '@/stores/leads';
|
import { useLeadsStore } from '@/stores/leads';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
import ScoreBadge from '@/components/ScoreBadge.vue';
|
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 store = useLeadsStore();
|
||||||
const router = useRouter();
|
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 columns = computed(() => {
|
||||||
const map: Record<DealStage, typeof store.items> = {
|
const map: Record<DealStage, typeof store.items> = {
|
||||||
@ -42,6 +55,57 @@ function money(v: number | null) {
|
|||||||
function stageTotal(items: typeof store.items): number {
|
function stageTotal(items: typeof store.items): number {
|
||||||
return items.reduce((acc, l) => acc + (l.expected_amount ?? 0), 0);
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -49,7 +113,14 @@ function stageTotal(items: typeof store.items): number {
|
|||||||
<div
|
<div
|
||||||
v-for="col in columns"
|
v-for="col in columns"
|
||||||
:key="col.stage"
|
: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
|
<div
|
||||||
class="px-3 py-2 border-b border-gray-100 flex items-center justify-between rounded-t-2xl"
|
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>
|
</div>
|
||||||
<button
|
<div
|
||||||
v-for="lead in col.items"
|
v-for="lead in col.items"
|
||||||
:key="lead.id"
|
:key="lead.id"
|
||||||
type="button"
|
: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"
|
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"
|
||||||
@click="router.push({ name: 'lead-card', params: { id: lead.id } })"
|
: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="flex items-start justify-between gap-2">
|
||||||
<div class="min-w-0 flex-1">
|
<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">
|
<div class="mt-1 text-[10px] uppercase tracking-wider text-gray-400">
|
||||||
{{ lead.created_by }}
|
{{ lead.created_by }}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
288
leadscore/frontend/src/components/LeadAnswersForm.vue
Normal file
288
leadscore/frontend/src/components/LeadAnswersForm.vue
Normal 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>
|
||||||
@ -45,6 +45,12 @@ const router = createRouter({
|
|||||||
component: () => import('@/views/AdminUsersView.vue'),
|
component: () => import('@/views/AdminUsersView.vue'),
|
||||||
meta: { ropOnly: true },
|
meta: { ropOnly: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/audit',
|
||||||
|
name: 'audit',
|
||||||
|
component: () => import('@/views/AuditView.vue'),
|
||||||
|
meta: { ropOnly: true },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
219
leadscore/frontend/src/views/AuditView.vue
Normal file
219
leadscore/frontend/src/views/AuditView.vue
Normal 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>
|
||||||
@ -7,6 +7,8 @@ import MoneyInput from '@/components/MoneyInput.vue';
|
|||||||
import NotesPanel from '@/components/NotesPanel.vue';
|
import NotesPanel from '@/components/NotesPanel.vue';
|
||||||
import TasksPanel from '@/components/TasksPanel.vue';
|
import TasksPanel from '@/components/TasksPanel.vue';
|
||||||
import ReassignWidget from '@/components/ReassignWidget.vue';
|
import ReassignWidget from '@/components/ReassignWidget.vue';
|
||||||
|
import LeadAnswersForm from '@/components/LeadAnswersForm.vue';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
import {
|
import {
|
||||||
deleteLead,
|
deleteLead,
|
||||||
getLead,
|
getLead,
|
||||||
@ -17,16 +19,52 @@ import {
|
|||||||
type DealStage,
|
type DealStage,
|
||||||
type HistoryEntry,
|
type HistoryEntry,
|
||||||
type Lead,
|
type Lead,
|
||||||
|
type UpdateLeadBody,
|
||||||
} from '@/api/leads';
|
} from '@/api/leads';
|
||||||
import { ApiRequestError } from '@/api/client';
|
import { ApiRequestError } from '@/api/client';
|
||||||
|
|
||||||
const props = defineProps<{ id: string }>();
|
const props = defineProps<{ id: string }>();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
const lead = ref<Lead | null>(null);
|
const lead = ref<Lead | null>(null);
|
||||||
const history = ref<HistoryEntry[]>([]);
|
const history = ref<HistoryEntry[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
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({
|
const pipelineForm = reactive({
|
||||||
deal_stage: 'new' as DealStage,
|
deal_stage: 'new' as DealStage,
|
||||||
@ -318,8 +356,24 @@ onMounted(load);
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card-pad space-y-3">
|
<div class="card-pad space-y-3">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest">Детализация баллов</h2>
|
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest">Детализация баллов</h2>
|
||||||
<ul class="divide-y divide-gray-100">
|
<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
|
<li
|
||||||
v-for="(pts, key) in lead.breakdown"
|
v-for="(pts, key) in lead.breakdown"
|
||||||
:key="key"
|
:key="key"
|
||||||
@ -332,7 +386,7 @@ onMounted(load);
|
|||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</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>Итого</span>
|
||||||
<span>{{ lead.score }} / {{ SCORE_MAX }}</span>
|
<span>{{ lead.score }} / {{ SCORE_MAX }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user