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
|
||||
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"
|
||||
|
||||
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">
|
||||
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>
|
||||
|
||||
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'),
|
||||
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 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>
|
||||
|
||||
Reference in New Issue
Block a user