Compare commits

..

No commits in common. "2df01f4c753212c694f85bc49852a2f60bfcf22e" and "b79e99246eaf4ba10d0ccf8e6bda64c056be98f5" have entirely different histories.

6 changed files with 113 additions and 200 deletions

View File

@ -19,8 +19,8 @@ const statuses: { value: LeadStatus | 'all'; label: string }[] = [
const types: { value: LeadType | 'all'; label: string }[] = [ const types: { value: LeadType | 'all'; label: string }[] = [
{ value: 'all', label: 'Все типы' }, { value: 'all', label: 'Все типы' },
{ value: 'inbound', label: 'Входящие' }, { value: 'inbound', label: 'Inbound' },
{ value: 'outbound', label: 'Исходящие' }, { value: 'outbound', label: 'Outbound' },
]; ];
const stages: { value: DealStage | 'all'; label: string }[] = [ const stages: { value: DealStage | 'all'; label: string }[] = [

View File

@ -137,7 +137,7 @@ function exportCsv() {
ref="searchRef" ref="searchRef"
v-model="searchInput" v-model="searchInput"
type="text" type="text"
placeholder="Поиск" placeholder="Поиск: имя, компания, телефон, email… (/)"
class="input-base pl-8 !py-1.5 text-sm" class="input-base pl-8 !py-1.5 text-sm"
/> />
<span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400"> <span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-gray-400">
@ -260,7 +260,7 @@ function exportCsv() {
{{ formatDate(lead.next_action_date) }} {{ formatDate(lead.next_action_date) }}
</td> </td>
<td class="px-4 py-3 text-gray-500 text-xs uppercase tracking-wider"> <td class="px-4 py-3 text-gray-500 text-xs uppercase tracking-wider">
{{ lead.lead_type === 'inbound' ? 'Входящий' : 'Исходящий' }} {{ lead.lead_type === 'inbound' ? 'In' : 'Out' }}
</td> </td>
<td class="px-4 py-3 text-gray-600 text-xs">{{ lead.created_by }}</td> <td class="px-4 py-3 text-gray-600 text-xs">{{ lead.created_by }}</td>
<td class="px-4 py-3 text-right"> <td class="px-4 py-3 text-right">

View File

@ -16,48 +16,38 @@ const filters = reactive<{ entity: string; entity_id: string }>({
const entries = ref<AuditEntry[]>([]); const entries = ref<AuditEntry[]>([]);
const loading = ref(false); const loading = ref(false);
const ENTITY_LABELS_SINGULAR: Record<string, string> = { const ENTITY_LABELS: Record<string, string> = {
lead: 'лида', lead: 'Лида',
user: 'пользователя', user: 'Пользователя',
system: 'систему', system: 'Систему',
}; };
const ENTITY_LABELS_PLURAL: Record<string, [string, string, string]> = {
// [1, 2..4, 5..]
lead: ['лида', 'лида', 'лидов'],
user: ['пользователя', 'пользователей', 'пользователей'],
system: ['систему', 'системы', 'систем'],
};
function pluralEntity(entity: string, n: number): string {
const forms = ENTITY_LABELS_PLURAL[entity] ?? [entity, entity, entity];
const mod10 = n % 10;
const mod100 = n % 100;
if (mod100 >= 11 && mod100 <= 19) return forms[2];
if (mod10 === 1) return forms[0];
if (mod10 >= 2 && mod10 <= 4) return forms[1];
return forms[2];
}
interface ActionMeta { interface ActionMeta {
label: string; label: string;
dot: string; tone: 'danger' | 'success' | 'neutral' | 'warning';
text: string; icon: string;
} }
const ACTIONS: Record<string, ActionMeta> = { const ACTIONS: Record<string, ActionMeta> = {
create: { label: 'создал', dot: 'bg-emerald-500', text: 'text-emerald-700' }, create: { label: 'создал', tone: 'success', icon: '+' },
delete: { label: 'удалил', dot: 'bg-red-500', text: 'text-red-700' }, delete: { label: 'удалил', tone: 'danger', icon: '×' },
reassign: { label: 'переназначил', dot: 'bg-amber-500', text: 'text-amber-700' }, reassign: { label: 'переназначил', tone: 'warning', icon: '↻' },
reset_password: { label: 'сменил пароль', dot: 'bg-amber-500', text: 'text-amber-700' }, reset_password: { label: 'сменил пароль', tone: 'warning', icon: '⚿' },
change_role: { label: 'изменил роль', dot: 'bg-amber-500', text: 'text-amber-700' }, change_role: { label: 'изменил роль', tone: 'warning', icon: '⚙' },
recalc: { label: 'пересчитал score', dot: 'bg-gray-500', text: 'text-gray-700' }, recalc: { label: 'пересчитал score', tone: 'neutral', icon: '∑' },
}; };
function actionMeta(a: string): ActionMeta { function actionMeta(a: string): ActionMeta {
return ACTIONS[a] ?? { label: a, dot: 'bg-gray-400', text: 'text-gray-600' }; return ACTIONS[a] ?? { label: a, tone: 'neutral', icon: '•' };
} }
const TONE_CLASSES: Record<ActionMeta['tone'], { ring: string; bg: string; text: string }> = {
danger: { ring: 'ring-red-200', bg: 'bg-red-50', text: 'text-red-600' },
success: { ring: 'ring-emerald-200', bg: 'bg-emerald-50', text: 'text-emerald-600' },
warning: { ring: 'ring-amber-200', bg: 'bg-amber-50', text: 'text-amber-700' },
neutral: { ring: 'ring-gray-200', bg: 'bg-gray-50', text: 'text-gray-600' },
};
async function load() { async function load() {
loading.value = true; loading.value = true;
try { try {
@ -93,8 +83,23 @@ function fmtFullTime(s: string): string {
return new Date(s).toLocaleString('ru-RU'); return new Date(s).toLocaleString('ru-RU');
} }
function fmtClock(s: string): string { function relativeTime(s: string): string {
return new Date(s).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); const now = Date.now();
const t = new Date(s).getTime();
const diff = Math.max(0, now - t);
const sec = Math.floor(diff / 1000);
if (sec < 60) return 'только что';
const min = Math.floor(sec / 60);
if (min < 60) return `${min} мин назад`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr} ч назад`;
const day = Math.floor(hr / 24);
if (day < 7) return `${day} дн назад`;
return new Date(s).toLocaleDateString('ru-RU', {
day: '2-digit',
month: 'short',
year: hr > 24 * 365 ? '2-digit' : undefined,
});
} }
function dayBucket(s: string): string { function dayBucket(s: string): string {
@ -113,73 +118,28 @@ function dayBucket(s: string): string {
}); });
} }
interface TimelineItem { const grouped = computed(() => {
// Group key: actor + action + entity. Holds 1..N audit entries const groups: { day: string; items: AuditEntry[] }[] = [];
// collapsed into a single row. let cur: { day: string; items: AuditEntry[] } | null = null;
actor: string;
action: string;
entity: string;
items: AuditEntry[];
}
interface DayGroup {
day: string;
rows: TimelineItem[];
}
const grouped = computed<DayGroup[]>(() => {
const days: DayGroup[] = [];
let curDay: DayGroup | null = null;
let curRow: TimelineItem | null = null;
for (const e of entries.value) { for (const e of entries.value) {
const d = dayBucket(e.created_at); const d = dayBucket(e.created_at);
if (!curDay || curDay.day !== d) { if (!cur || cur.day !== d) {
curDay = { day: d, rows: [] }; cur = { day: d, items: [] };
days.push(curDay); groups.push(cur);
curRow = null;
} }
if ( cur.items.push(e);
curRow &&
curRow.actor === e.actor &&
curRow.action === e.action &&
curRow.entity === e.entity
) {
curRow.items.push(e);
} else {
curRow = {
actor: e.actor,
action: e.action,
entity: e.entity,
items: [e],
};
curDay.rows.push(curRow);
} }
} return groups;
return days;
}); });
const expanded = ref<Set<string>>(new Set());
function rowKey(g: DayGroup, idx: number): string {
return g.day + '#' + idx;
}
function toggle(key: string) {
if (expanded.value.has(key)) expanded.value.delete(key);
else expanded.value.add(key);
expanded.value = new Set(expanded.value);
}
function shortId(id: string): string { function shortId(id: string): string {
if (id.length <= 10) return id; return id.length > 10 ? id.slice(0, 8) + '…' : id;
// UUID-looking? Take 8.
if (/^[0-9a-f-]{20,}$/i.test(id)) return id.slice(0, 8) + '…';
return id;
} }
function gotoLead(id: string) { function gotoEntity(e: AuditEntry) {
router.push({ name: 'lead-card', params: { id } }); if (e.entity === 'lead') {
router.push({ name: 'lead-card', params: { id: e.entity_id } });
}
} }
const empty = computed(() => !loading.value && entries.value.length === 0); const empty = computed(() => !loading.value && entries.value.length === 0);
@ -221,7 +181,7 @@ onMounted(load);
</div> </div>
<div v-if="loading" class="card p-4"> <div v-if="loading" class="card p-4">
<Skeleton :rows="8" height="28px" /> <Skeleton :rows="6" height="44px" />
</div> </div>
<div <div
v-else-if="empty" v-else-if="empty"
@ -229,109 +189,65 @@ onMounted(load);
> >
Событий не найдено. Событий не найдено.
</div> </div>
<div v-else class="space-y-6">
<div v-else class="space-y-8"> <section
<section v-for="group in grouped" :key="group.day"> v-for="group in grouped"
:key="group.day"
class="space-y-2"
>
<h2 <h2
class="text-[10px] font-semibold uppercase tracking-[0.18em] text-gray-400 mb-3 px-1" class="text-[10px] font-semibold uppercase tracking-[0.18em] text-gray-400 px-1"
> >
{{ group.day }} {{ group.day }}
</h2> </h2>
<div class="relative"> <div class="card divide-y divide-gray-100">
<div <div
class="absolute left-[88px] top-0 bottom-0 w-px bg-gray-100" v-for="e in group.items"
aria-hidden="true" :key="e.id"
/> class="flex items-center gap-3 px-4 py-3 hover:bg-gray-50/70 transition"
<ul class="space-y-1">
<li
v-for="(row, idx) in group.rows"
:key="rowKey(group, idx)"
class="relative pl-[112px] pr-2 py-1.5 group"
> >
<time <div
class="absolute left-0 top-2 w-[80px] text-right text-[11px] text-gray-400 tabular-nums leading-snug" class="w-8 h-8 rounded-full flex items-center justify-center text-sm font-semibold ring-1 shrink-0 leading-none"
:title="fmtFullTime(row.items[0].created_at)" :class="[
TONE_CLASSES[actionMeta(e.action).tone].bg,
TONE_CLASSES[actionMeta(e.action).tone].ring,
TONE_CLASSES[actionMeta(e.action).tone].text,
]"
:title="actionMeta(e.action).label"
> >
{{ fmtClock(row.items[0].created_at) }} {{ actionMeta(e.action).icon }}
</time> </div>
<span <p
class="absolute left-[84px] top-[10px] w-2 h-2 rounded-full ring-2 ring-white" class="flex-1 min-w-0 text-sm text-gray-700 leading-snug truncate"
:class="actionMeta(row.action).dot"
aria-hidden="true"
/>
<div class="flex items-baseline gap-1.5 flex-wrap">
<span class="font-semibold text-gray-900 text-sm">
{{ row.actor }}
</span>
<span
class="text-sm"
:class="actionMeta(row.action).text"
> >
{{ actionMeta(row.action).label }} <span class="font-semibold text-gray-900">{{ e.actor }}</span>
</span> <span class="mx-1.5">{{ actionMeta(e.action).label }}</span>
<template v-if="row.items.length === 1"> <span class="text-gray-500">{{ ENTITY_LABELS[e.entity] ?? e.entity }}</span>
<span class="text-sm text-gray-500">
{{ ENTITY_LABELS_SINGULAR[row.entity] ?? row.entity }}
</span>
<button <button
v-if="row.entity === 'lead'" v-if="e.entity === 'lead'"
type="button" type="button"
class="font-mono text-xs text-sky-600 hover:underline" class="ml-1.5 font-mono text-xs text-sky-600 hover:underline align-middle"
@click="gotoLead(row.items[0].entity_id)" @click="gotoEntity(e)"
> >
{{ shortId(row.items[0].entity_id) }} {{ shortId(e.entity_id) }}
</button> </button>
<span <span
v-else v-else
class="font-mono text-xs text-gray-600" class="ml-1.5 font-mono text-xs text-gray-500 align-middle"
> >
{{ row.items[0].entity_id }} {{ e.entity_id }}
</span> </span>
</template> </p>
<template v-else>
<span class="text-sm text-gray-500">
{{ row.items.length }} {{ pluralEntity(row.entity, row.items.length) }}
</span>
<button
type="button"
class="text-[11px] text-gray-400 hover:text-gray-700 ml-1"
@click="toggle(rowKey(group, idx))"
>
{{ expanded.has(rowKey(group, idx)) ? 'свернуть' : 'показать' }}
</button>
</template>
</div>
<ul <time
v-if="row.items.length > 1 && expanded.has(rowKey(group, idx))" class="text-[11px] text-gray-400 tabular-nums shrink-0 leading-none"
class="mt-2 ml-1 space-y-1 border-l-2 border-gray-100 pl-3" :title="fmtFullTime(e.created_at)"
> >
<li {{ relativeTime(e.created_at) }}
v-for="it in row.items" </time>
:key="it.id" </div>
class="flex items-center gap-2 text-xs"
>
<span class="text-gray-400 tabular-nums w-12 shrink-0">
{{ fmtClock(it.created_at) }}
</span>
<button
v-if="it.entity === 'lead'"
type="button"
class="font-mono text-sky-600 hover:underline"
@click="gotoLead(it.entity_id)"
>
{{ shortId(it.entity_id) }}
</button>
<span v-else class="font-mono text-gray-600">
{{ it.entity_id }}
</span>
</li>
</ul>
</li>
</ul>
</div> </div>
</section> </section>
</div> </div>

View File

@ -260,7 +260,7 @@ onMounted(load);
</p> </p>
</div> </div>
<p class="text-xs text-gray-500 mt-3"> <p class="text-xs text-gray-500 mt-3">
{{ lead.lead_type === 'inbound' ? 'Входящий' : 'Исходящий' }} · создан {{ lead.lead_type === 'inbound' ? 'Inbound' : 'Outbound' }} · создан
{{ lead.created_by }} · {{ formatDateTime(lead.created_at) }} {{ lead.created_by }} · {{ formatDateTime(lead.created_at) }}
</p> </p>
</div> </div>

View File

@ -116,7 +116,7 @@ onMounted(async () => {
<div class="mt-2 text-3xl font-bold text-emerald-600 tabular-nums"> <div class="mt-2 text-3xl font-bold text-emerald-600 tabular-nums">
{{ summary.hot }} {{ summary.hot }}
</div> </div>
<div class="mt-1 text-[11px] text-gray-400">Срочно</div> <div class="mt-1 text-[11px] text-gray-400">Звонить сегодня</div>
</button> </button>
<button <button
@ -132,7 +132,7 @@ onMounted(async () => {
<div class="mt-2 text-3xl font-bold text-amber-600 tabular-nums"> <div class="mt-2 text-3xl font-bold text-amber-600 tabular-nums">
{{ summary.warm }} {{ summary.warm }}
</div> </div>
<div class="mt-1 text-[11px] text-gray-400">Прогрев</div> <div class="mt-1 text-[11px] text-gray-400">Прогрев / nurture</div>
</button> </button>
<button <button

View File

@ -86,13 +86,10 @@ const stageBg: Record<string, string> = {
}; };
const sourceLabels: Record<string, string> = { const sourceLabels: Record<string, string> = {
cold: 'Холодный список', cold: 'Холодный',
ad: 'Реклама', referral: 'Реферал',
content: 'Скачал материал', website: 'Сайт',
referral: 'Рекомендация', 'n/a': 'Outbound',
website: 'Сайт / форма',
'demo-request': 'Запросил демо',
'n/a': 'Исходящие',
}; };
const funnelMax = computed(() => const funnelMax = computed(() =>
@ -363,14 +360,14 @@ function tickX(date: string, i: number): { x: number; label: string } | null {
<div class="card p-5"> <div class="card p-5">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest"> <h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest">
Входящие vs Исходящие Inbound vs Outbound
</h2> </h2>
</div> </div>
<div v-if="types.length === 0" class="text-xs text-gray-400 mt-4">—</div> <div v-if="types.length === 0" class="text-xs text-gray-400 mt-4">—</div>
<div v-else class="mt-3 space-y-3"> <div v-else class="mt-3 space-y-3">
<div v-for="t in types" :key="t.lead_type" class="text-sm"> <div v-for="t in types" :key="t.lead_type" class="text-sm">
<div class="flex items-center justify-between mb-1"> <div class="flex items-center justify-between mb-1">
<span class="font-medium">{{ t.lead_type === 'inbound' ? 'Входящие' : 'Исходящие' }}</span> <span class="font-medium capitalize">{{ t.lead_type }}</span>
<span class="text-xs text-gray-500 tabular-nums"> <span class="text-xs text-gray-500 tabular-nums">
{{ t.total }} лидов · конв. {{ t.conversion_rate }}% {{ t.total }} лидов · конв. {{ t.conversion_rate }}%
</span> </span>