feat(manager): TodayWidget + last-activity badge
- TodayWidget: a single panel above the summary tiles on the main page. Groups items into four sections — overdue tasks, today's tasks, overdue touches (next_action_date passed) and today's touches. Each row routes to the lead card on click. Hidden when there is nothing to do (zero overhead). - Backend leads list now returns last_activity_at, computed as the GREATEST of leads.updated_at, MAX(lead_notes.created_at) and MAX(lead_tasks.created_at) via LATERAL joins. No DB migration — derived in the query. - LeadsTable renders a small "Nд тишины" badge under the contact name when last_activity_at is older than 7 / 14 / 30 days (amber / orange / red bold). Tooltip shows the exact timestamp. Lets managers spot leads going cold without having to filter. Both features rely only on existing endpoints (/reminders, /tasks/upcoming) plus the enriched /leads response.
This commit is contained in:
parent
2df01f4c75
commit
d0c5a607d7
@ -241,8 +241,23 @@ export async function leadRoutes(app: FastifyInstance) {
|
|||||||
const lim = Math.min(Math.max(Number(limit ?? 50), 1), 500);
|
const lim = Math.min(Math.max(Number(limit ?? 50), 1), 500);
|
||||||
const off = Math.max(Number(offset ?? 0), 0);
|
const off = Math.max(Number(offset ?? 0), 0);
|
||||||
|
|
||||||
const dataQ = await query<LeadRow>(
|
const dataQ = await query<LeadRow & { last_activity_at: Date }>(
|
||||||
`SELECT * FROM leads ${whereSql} ORDER BY ${orderSql}, created_at DESC LIMIT ${lim} OFFSET ${off}`,
|
`SELECT l.*,
|
||||||
|
GREATEST(
|
||||||
|
l.updated_at,
|
||||||
|
COALESCE(n.last_at, l.updated_at),
|
||||||
|
COALESCE(t.last_at, l.updated_at)
|
||||||
|
) AS last_activity_at
|
||||||
|
FROM leads l
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT MAX(created_at) AS last_at FROM lead_notes WHERE lead_id = l.id
|
||||||
|
) n ON true
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT MAX(created_at) AS last_at FROM lead_tasks WHERE lead_id = l.id
|
||||||
|
) t ON true
|
||||||
|
${whereSql}
|
||||||
|
ORDER BY ${orderSql}, l.created_at DESC
|
||||||
|
LIMIT ${lim} OFFSET ${off}`,
|
||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
const countQ = await query<{ count: string }>(
|
const countQ = await query<{ count: string }>(
|
||||||
@ -266,6 +281,7 @@ export async function leadRoutes(app: FastifyInstance) {
|
|||||||
deal_stage: row.deal_stage,
|
deal_stage: row.deal_stage,
|
||||||
expected_amount: row.expected_amount,
|
expected_amount: row.expected_amount,
|
||||||
next_action_date: formatDateOnly(row.next_action_date),
|
next_action_date: formatDateOnly(row.next_action_date),
|
||||||
|
last_activity_at: row.last_activity_at,
|
||||||
created_at: row.created_at,
|
created_at: row.created_at,
|
||||||
updated_at: row.updated_at,
|
updated_at: row.updated_at,
|
||||||
created_by: row.created_by,
|
created_by: row.created_by,
|
||||||
|
|||||||
@ -47,6 +47,7 @@ export interface LeadListItem {
|
|||||||
deal_stage: DealStage;
|
deal_stage: DealStage;
|
||||||
expected_amount: number | null;
|
expected_amount: number | null;
|
||||||
next_action_date: string | null;
|
next_action_date: string | null;
|
||||||
|
last_activity_at: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
created_by: string;
|
created_by: string;
|
||||||
|
|||||||
@ -69,6 +69,18 @@ function formatDate(s: string | null) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function daysSince(iso: string): number {
|
||||||
|
return Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function staleLabel(iso: string): { label: string; cls: string } | null {
|
||||||
|
const d = daysSince(iso);
|
||||||
|
if (d < 7) return null;
|
||||||
|
if (d < 14) return { label: `${d}д тишины`, cls: 'text-amber-600' };
|
||||||
|
if (d < 30) return { label: `${d}д тишины`, cls: 'text-orange-700' };
|
||||||
|
return { label: `${d}д тишины`, cls: 'text-red-700 font-semibold' };
|
||||||
|
}
|
||||||
|
|
||||||
function formatMoney(v: number | null) {
|
function formatMoney(v: number | null) {
|
||||||
if (v === null) return '—';
|
if (v === null) return '—';
|
||||||
if (v >= 1_000_000) {
|
if (v >= 1_000_000) {
|
||||||
@ -240,6 +252,14 @@ function exportCsv() {
|
|||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div class="font-medium text-gray-900">{{ lead.contact_name }}</div>
|
<div class="font-medium text-gray-900">{{ lead.contact_name }}</div>
|
||||||
<div v-if="lead.position" class="text-xs text-gray-500">{{ lead.position }}</div>
|
<div v-if="lead.position" class="text-xs text-gray-500">{{ lead.position }}</div>
|
||||||
|
<div
|
||||||
|
v-if="staleLabel(lead.last_activity_at)"
|
||||||
|
class="text-[10px] mt-0.5"
|
||||||
|
:class="staleLabel(lead.last_activity_at)!.cls"
|
||||||
|
:title="'Последняя активность: ' + new Date(lead.last_activity_at).toLocaleString('ru-RU')"
|
||||||
|
>
|
||||||
|
⚠ {{ staleLabel(lead.last_activity_at)!.label }}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div class="text-gray-900">{{ lead.company_name }}</div>
|
<div class="text-gray-900">{{ lead.company_name }}</div>
|
||||||
|
|||||||
206
leadscore/frontend/src/components/TodayWidget.vue
Normal file
206
leadscore/frontend/src/components/TodayWidget.vue
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { getReminders, type ReminderItem } from '@/api/reminders';
|
||||||
|
import {
|
||||||
|
getUpcomingTasks,
|
||||||
|
TASK_KIND_LABELS,
|
||||||
|
type TaskWithLead,
|
||||||
|
} from '@/api/tasks';
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const reminders = ref<ReminderItem[]>([]);
|
||||||
|
const tasks = ref<TaskWithLead[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const todayIso = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
const overdueTasks = computed(() =>
|
||||||
|
tasks.value.filter((t) => (t.due_date ?? '') < todayIso),
|
||||||
|
);
|
||||||
|
const todayTasks = computed(() =>
|
||||||
|
tasks.value.filter((t) => (t.due_date ?? '') === todayIso),
|
||||||
|
);
|
||||||
|
const overdueReminders = computed(() => reminders.value.filter((r) => r.is_overdue));
|
||||||
|
const todayReminders = computed(() => reminders.value.filter((r) => !r.is_overdue));
|
||||||
|
|
||||||
|
const totalItems = computed(
|
||||||
|
() =>
|
||||||
|
overdueTasks.value.length +
|
||||||
|
todayTasks.value.length +
|
||||||
|
overdueReminders.value.length +
|
||||||
|
todayReminders.value.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
const overdueTotal = computed(() => overdueTasks.value.length + overdueReminders.value.length);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const [r, t] = await Promise.all([getReminders(), getUpcomingTasks()]);
|
||||||
|
reminders.value = r.items;
|
||||||
|
tasks.value = t.data;
|
||||||
|
} catch {
|
||||||
|
/* silent — bell-menu surfaces the error */
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openLead(id: string) {
|
||||||
|
router.push({ name: 'lead-card', params: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso: string | null): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
return new Date(iso + 'T00:00:00').toLocaleDateString('ru-RU', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
v-if="loading || totalItems > 0"
|
||||||
|
class="card overflow-hidden"
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
class="px-5 py-3 flex items-center justify-between border-b border-gray-100"
|
||||||
|
:class="overdueTotal > 0 ? 'bg-red-50/50' : 'bg-emerald-50/40'"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
class="w-2 h-2 rounded-full"
|
||||||
|
:class="overdueTotal > 0 ? 'bg-red-500' : 'bg-emerald-500'"
|
||||||
|
/>
|
||||||
|
<h2 class="text-sm font-semibold tracking-tight">
|
||||||
|
Фокус дня
|
||||||
|
</h2>
|
||||||
|
<span class="text-xs text-gray-500 tabular-nums">
|
||||||
|
{{ totalItems }} задач<span v-if="overdueTotal > 0" class="text-red-600 ml-1">
|
||||||
|
· {{ overdueTotal }} просрочено
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="loading" class="px-5 py-4 text-xs text-gray-400">Загрузка…</div>
|
||||||
|
<div v-else class="divide-y divide-gray-100">
|
||||||
|
<!-- Overdue tasks -->
|
||||||
|
<div v-if="overdueTasks.length > 0" class="px-5 py-3 space-y-1.5">
|
||||||
|
<div class="text-[10px] font-semibold uppercase tracking-[0.18em] text-red-600 mb-1">
|
||||||
|
Просроченные задачи
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-for="t in overdueTasks"
|
||||||
|
:key="t.id"
|
||||||
|
type="button"
|
||||||
|
class="w-full flex items-center gap-3 text-left text-sm hover:bg-gray-50 rounded px-1.5 py-1 -mx-1.5"
|
||||||
|
@click="openLead(t.lead_id)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 shrink-0"
|
||||||
|
>
|
||||||
|
{{ TASK_KIND_LABELS[t.kind] }}
|
||||||
|
</span>
|
||||||
|
<span class="flex-1 truncate">{{ t.title }}</span>
|
||||||
|
<span class="text-xs text-gray-500 truncate max-w-[180px]">
|
||||||
|
{{ t.contact_name }} · {{ t.company_name }}
|
||||||
|
</span>
|
||||||
|
<time class="text-[11px] text-red-600 tabular-nums shrink-0">
|
||||||
|
до {{ fmtDate(t.due_date) }}
|
||||||
|
</time>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Today tasks -->
|
||||||
|
<div v-if="todayTasks.length > 0" class="px-5 py-3 space-y-1.5">
|
||||||
|
<div class="text-[10px] font-semibold uppercase tracking-[0.18em] text-gray-500 mb-1">
|
||||||
|
Задачи на сегодня
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-for="t in todayTasks"
|
||||||
|
:key="t.id"
|
||||||
|
type="button"
|
||||||
|
class="w-full flex items-center gap-3 text-left text-sm hover:bg-gray-50 rounded px-1.5 py-1 -mx-1.5"
|
||||||
|
@click="openLead(t.lead_id)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 shrink-0"
|
||||||
|
>
|
||||||
|
{{ TASK_KIND_LABELS[t.kind] }}
|
||||||
|
</span>
|
||||||
|
<span class="flex-1 truncate">{{ t.title }}</span>
|
||||||
|
<span class="text-xs text-gray-500 truncate max-w-[180px]">
|
||||||
|
{{ t.contact_name }} · {{ t.company_name }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Overdue reminders (next_action_date passed) -->
|
||||||
|
<div v-if="overdueReminders.length > 0" class="px-5 py-3 space-y-1.5">
|
||||||
|
<div class="text-[10px] font-semibold uppercase tracking-[0.18em] text-red-600 mb-1">
|
||||||
|
Просроченные касания
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-for="r in overdueReminders"
|
||||||
|
:key="r.id"
|
||||||
|
type="button"
|
||||||
|
class="w-full flex items-center gap-3 text-left text-sm hover:bg-gray-50 rounded px-1.5 py-1 -mx-1.5"
|
||||||
|
@click="openLead(r.id)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="w-1.5 h-1.5 rounded-full shrink-0"
|
||||||
|
:class="
|
||||||
|
r.status === 'hot'
|
||||||
|
? 'bg-emerald-500'
|
||||||
|
: r.status === 'warm'
|
||||||
|
? 'bg-amber-500'
|
||||||
|
: 'bg-sky-400'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<span class="font-medium truncate">{{ r.contact_name }}</span>
|
||||||
|
<span class="text-xs text-gray-500 truncate flex-1">
|
||||||
|
{{ r.company_name }}
|
||||||
|
</span>
|
||||||
|
<time class="text-[11px] text-red-600 tabular-nums shrink-0">
|
||||||
|
{{ fmtDate(r.next_action_date) }}
|
||||||
|
</time>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Today reminders -->
|
||||||
|
<div v-if="todayReminders.length > 0" class="px-5 py-3 space-y-1.5">
|
||||||
|
<div class="text-[10px] font-semibold uppercase tracking-[0.18em] text-gray-500 mb-1">
|
||||||
|
Касания на сегодня
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-for="r in todayReminders"
|
||||||
|
:key="r.id"
|
||||||
|
type="button"
|
||||||
|
class="w-full flex items-center gap-3 text-left text-sm hover:bg-gray-50 rounded px-1.5 py-1 -mx-1.5"
|
||||||
|
@click="openLead(r.id)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="w-1.5 h-1.5 rounded-full shrink-0"
|
||||||
|
:class="
|
||||||
|
r.status === 'hot'
|
||||||
|
? 'bg-emerald-500'
|
||||||
|
: r.status === 'warm'
|
||||||
|
? 'bg-amber-500'
|
||||||
|
: 'bg-sky-400'
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<span class="font-medium truncate">{{ r.contact_name }}</span>
|
||||||
|
<span class="text-xs text-gray-500 truncate flex-1">
|
||||||
|
{{ r.company_name }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@ -4,6 +4,7 @@ import { RouterLink } from 'vue-router';
|
|||||||
import { useLeadsStore } from '@/stores/leads';
|
import { useLeadsStore } from '@/stores/leads';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import LeadsTable from '@/components/LeadsTable.vue';
|
import LeadsTable from '@/components/LeadsTable.vue';
|
||||||
|
import TodayWidget from '@/components/TodayWidget.vue';
|
||||||
import { listLeads, type LeadStatus } from '@/api/leads';
|
import { listLeads, type LeadStatus } from '@/api/leads';
|
||||||
|
|
||||||
const store = useLeadsStore();
|
const store = useLeadsStore();
|
||||||
@ -102,6 +103,8 @@ onMounted(async () => {
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<TodayWidget />
|
||||||
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
Reference in New Issue
Block a user