Compare commits
3 Commits
2df01f4c75
...
7e4c212db8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e4c212db8 | ||
|
|
faf4b3821a | ||
|
|
d0c5a607d7 |
@ -184,10 +184,11 @@ export async function leadRoutes(app: FastifyInstance) {
|
|||||||
offset?: string;
|
offset?: string;
|
||||||
created_by?: string;
|
created_by?: string;
|
||||||
q?: string;
|
q?: string;
|
||||||
|
stale?: string;
|
||||||
};
|
};
|
||||||
}>('/leads', async (req) => {
|
}>('/leads', async (req) => {
|
||||||
const user = await requireAuth(app, req);
|
const user = await requireAuth(app, req);
|
||||||
const { status, lead_type, deal_stage, sort, limit, offset, created_by, q } = req.query;
|
const { status, lead_type, deal_stage, sort, limit, offset, created_by, q, stale } = req.query;
|
||||||
const where: string[] = [];
|
const where: string[] = [];
|
||||||
const params: unknown[] = [];
|
const params: unknown[] = [];
|
||||||
|
|
||||||
@ -226,6 +227,17 @@ export async function leadRoutes(app: FastifyInstance) {
|
|||||||
where.push(`deal_stage = $${params.length}`);
|
where.push(`deal_stage = $${params.length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isStale = stale === '1' || stale === 'true';
|
||||||
|
if (isStale) {
|
||||||
|
// Stale: no activity (lead update OR note OR task) in the last 14 days,
|
||||||
|
// and not yet closed. Uses LATERAL joins added on the SELECT below.
|
||||||
|
where.push(
|
||||||
|
`deal_stage NOT IN ('won','lost') AND ` +
|
||||||
|
`GREATEST(l.updated_at, COALESCE(n.last_at, l.updated_at), COALESCE(t.last_at, l.updated_at)) ` +
|
||||||
|
`< NOW() - INTERVAL '14 days'`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||||||
const orderMap: Record<string, string> = {
|
const orderMap: Record<string, string> = {
|
||||||
score_desc: 'score DESC',
|
score_desc: 'score DESC',
|
||||||
@ -241,12 +253,35 @@ 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 }>(
|
||||||
`SELECT COUNT(*)::text AS count FROM leads ${whereSql}`,
|
`SELECT COUNT(*)::text AS count
|
||||||
|
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}`,
|
||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -266,6 +301,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;
|
||||||
@ -121,6 +122,7 @@ export interface LeadListQuery {
|
|||||||
offset?: number;
|
offset?: number;
|
||||||
created_by?: string;
|
created_by?: string;
|
||||||
q?: string;
|
q?: string;
|
||||||
|
stale?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateLeadBody {
|
export interface CreateLeadBody {
|
||||||
@ -150,7 +152,7 @@ export type UpdateLeadBody = Partial<CreateLeadBody>;
|
|||||||
function qs(params: Record<string, unknown>): string {
|
function qs(params: Record<string, unknown>): string {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
for (const [k, v] of Object.entries(params)) {
|
for (const [k, v] of Object.entries(params)) {
|
||||||
if (v === undefined || v === null || v === '') continue;
|
if (v === undefined || v === null || v === '' || v === false) continue;
|
||||||
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
|
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
|
||||||
}
|
}
|
||||||
return parts.length ? `?${parts.join('&')}` : '';
|
return parts.length ? `?${parts.join('&')}` : '';
|
||||||
|
|||||||
@ -75,5 +75,19 @@ const pill = (active: boolean) =>
|
|||||||
{{ s.label }}
|
{{ s.label }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex gap-1.5 items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="px-2.5 py-1 rounded-full border text-xs font-medium transition inline-flex items-center gap-1"
|
||||||
|
:class="store.stale
|
||||||
|
? 'bg-red-600 text-white border-red-600'
|
||||||
|
: 'bg-white text-red-700 border-red-200 hover:border-red-400'"
|
||||||
|
:title="'Лиды без активности дольше 14 дней (не закрытые сделки)'"
|
||||||
|
@click="store.setStale(!store.stale)"
|
||||||
|
>
|
||||||
|
<span>⚠</span>
|
||||||
|
Забытые
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -12,6 +12,22 @@ const notes = ref<Note[]>([]);
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const draft = ref('');
|
const draft = ref('');
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
|
const draftRef = ref<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
|
const NOTE_TEMPLATES: string[] = [
|
||||||
|
'Дозвонился, договорились о следующем шаге',
|
||||||
|
'Не отвечает / телефон выключен',
|
||||||
|
'Назначили встречу',
|
||||||
|
'Отправил КП',
|
||||||
|
'Клиент думает, перезвоню позже',
|
||||||
|
'Перенесли созвон',
|
||||||
|
'Отказ — нет бюджета',
|
||||||
|
];
|
||||||
|
|
||||||
|
function applyTemplate(text: string) {
|
||||||
|
draft.value = draft.value ? draft.value + '\n' + text : text;
|
||||||
|
draftRef.value?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@ -72,10 +88,23 @@ onMounted(load);
|
|||||||
Заметки
|
Заметки
|
||||||
</h2>
|
</h2>
|
||||||
<form class="space-y-2" @submit.prevent="add">
|
<form class="space-y-2" @submit.prevent="add">
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<button
|
||||||
|
v-for="t in NOTE_TEMPLATES"
|
||||||
|
:key="t"
|
||||||
|
type="button"
|
||||||
|
class="px-2 py-0.5 rounded-full border border-gray-200 bg-white text-[11px] text-gray-600 hover:border-gray-400 hover:text-gray-900 transition"
|
||||||
|
:title="'Подставить: ' + t"
|
||||||
|
@click="applyTemplate(t)"
|
||||||
|
>
|
||||||
|
{{ t }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
|
ref="draftRef"
|
||||||
v-model="draft"
|
v-model="draft"
|
||||||
rows="2"
|
rows="2"
|
||||||
placeholder="Добавить заметку…"
|
placeholder="Добавить заметку или выбрать шаблон выше…"
|
||||||
class="input-base !text-sm"
|
class="input-base !text-sm"
|
||||||
/>
|
/>
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
|
|||||||
168
leadscore/frontend/src/components/QuickCallAction.vue
Normal file
168
leadscore/frontend/src/components/QuickCallAction.vue
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import { createNote } from '@/api/notes';
|
||||||
|
import {
|
||||||
|
createTask,
|
||||||
|
listTasks,
|
||||||
|
updateTask,
|
||||||
|
type Task,
|
||||||
|
} from '@/api/tasks';
|
||||||
|
|
||||||
|
const props = defineProps<{ leadId: string }>();
|
||||||
|
const emit = defineEmits<{ (e: 'done'): void }>();
|
||||||
|
|
||||||
|
const toast = useToast();
|
||||||
|
const open = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const details = ref('');
|
||||||
|
|
||||||
|
interface Outcome {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
noteText: string;
|
||||||
|
nextStep:
|
||||||
|
| { kind: 'call' | 'meeting' | 'proposal' | 'other'; title: string; inDays: number }
|
||||||
|
| null;
|
||||||
|
tone: 'success' | 'warning' | 'danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
const OUTCOMES: Outcome[] = [
|
||||||
|
{
|
||||||
|
key: 'reached',
|
||||||
|
label: 'Дозвонился',
|
||||||
|
noteText: 'Дозвонился, поговорил по делу',
|
||||||
|
nextStep: { kind: 'call', title: 'Перезвонить, уточнить решение', inDays: 7 },
|
||||||
|
tone: 'success',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'no_answer',
|
||||||
|
label: 'Не отвечает',
|
||||||
|
noteText: 'Не отвечает / телефон выключен',
|
||||||
|
nextStep: { kind: 'call', title: 'Повторный звонок', inDays: 3 },
|
||||||
|
tone: 'warning',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'postponed',
|
||||||
|
label: 'Перенесли',
|
||||||
|
noteText: 'Перенесли созвон',
|
||||||
|
nextStep: { kind: 'call', title: 'Перенесённый созвон', inDays: 7 },
|
||||||
|
tone: 'warning',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'refused',
|
||||||
|
label: 'Отказ',
|
||||||
|
noteText: 'Отказался от сделки',
|
||||||
|
nextStep: null,
|
||||||
|
tone: 'danger',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function dateOffset(days: number): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + days);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeTodayCallTasks(): Promise<void> {
|
||||||
|
// Best-effort: close any open `call` task whose due date is today or
|
||||||
|
// earlier, so the bell stops nagging about a call that just happened.
|
||||||
|
try {
|
||||||
|
const { data } = await listTasks(props.leadId);
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const open = (data as Task[]).filter(
|
||||||
|
(t) => !t.completed_at && t.kind === 'call' && (t.due_date ?? '') <= today,
|
||||||
|
);
|
||||||
|
await Promise.all(
|
||||||
|
open.map((t) => updateTask(props.leadId, t.id, { completed: true })),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* swallowed — UX shouldn't block on auto-close */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function record(outcome: Outcome) {
|
||||||
|
if (saving.value) return;
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
const text = details.value.trim()
|
||||||
|
? `${outcome.noteText}. ${details.value.trim()}`
|
||||||
|
: outcome.noteText;
|
||||||
|
await createNote(props.leadId, text);
|
||||||
|
if (outcome.nextStep) {
|
||||||
|
await createTask(props.leadId, {
|
||||||
|
kind: outcome.nextStep.kind,
|
||||||
|
title: outcome.nextStep.title,
|
||||||
|
due_date: dateOffset(outcome.nextStep.inDays),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await closeTodayCallTasks();
|
||||||
|
toast.success(`Записал звонок: ${outcome.label}`);
|
||||||
|
details.value = '';
|
||||||
|
open.value = false;
|
||||||
|
emit('done');
|
||||||
|
} catch (e) {
|
||||||
|
toast.error((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const TONE_CLS: Record<Outcome['tone'], string> = {
|
||||||
|
success: 'border-emerald-200 bg-emerald-50 text-emerald-800 hover:border-emerald-500',
|
||||||
|
warning: 'border-amber-200 bg-amber-50 text-amber-800 hover:border-amber-500',
|
||||||
|
danger: 'border-red-200 bg-red-50 text-red-700 hover:border-red-500',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<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>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-xs text-gray-500 hover:text-gray-900 transition"
|
||||||
|
@click="open = !open"
|
||||||
|
>
|
||||||
|
{{ open ? 'Скрыть' : 'Открыть' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="!open"
|
||||||
|
type="button"
|
||||||
|
class="w-full py-2.5 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-medium transition shadow-sm"
|
||||||
|
@click="open = true"
|
||||||
|
>
|
||||||
|
✓ Звонок проведён
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
Выбери исход — запишется заметка, создастся следующая задача и закроется сегодняшний звонок-задача (если был).
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
v-model="details"
|
||||||
|
rows="2"
|
||||||
|
placeholder="Детали разговора (необязательно)"
|
||||||
|
class="input-base !text-sm"
|
||||||
|
:disabled="saving"
|
||||||
|
/>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||||
|
<button
|
||||||
|
v-for="o in OUTCOMES"
|
||||||
|
:key="o.key"
|
||||||
|
type="button"
|
||||||
|
class="px-3 py-2 rounded-lg border text-sm font-medium transition disabled:opacity-50"
|
||||||
|
:class="TONE_CLS[o.tone]"
|
||||||
|
:disabled="saving"
|
||||||
|
@click="record(o)"
|
||||||
|
>
|
||||||
|
{{ o.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -26,6 +26,42 @@ const draft = reactive<{ kind: TaskKind; title: string; due_date: string }>({
|
|||||||
|
|
||||||
const showForm = ref(false);
|
const showForm = ref(false);
|
||||||
|
|
||||||
|
interface TaskTemplate {
|
||||||
|
label: string;
|
||||||
|
kind: TaskKind;
|
||||||
|
title: string;
|
||||||
|
inDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TASK_TEMPLATES: TaskTemplate[] = [
|
||||||
|
{ label: 'Перезвонить завтра', kind: 'call', title: 'Перезвонить', inDays: 1 },
|
||||||
|
{ label: 'Перезвонить через 3 дня', kind: 'call', title: 'Перезвонить', inDays: 3 },
|
||||||
|
{ label: 'Перезвонить через неделю', kind: 'call', title: 'Перезвонить', inDays: 7 },
|
||||||
|
{ label: 'Отправить КП завтра', kind: 'proposal', title: 'Отправить КП', inDays: 1 },
|
||||||
|
{ label: 'Назначить встречу', kind: 'meeting', title: 'Встреча с клиентом', inDays: 3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function dateOffset(days: number): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() + days);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addTemplate(tpl: TaskTemplate) {
|
||||||
|
try {
|
||||||
|
const created = await createTask(props.leadId, {
|
||||||
|
kind: tpl.kind,
|
||||||
|
title: tpl.title,
|
||||||
|
due_date: dateOffset(tpl.inDays),
|
||||||
|
});
|
||||||
|
tasks.value.push(created);
|
||||||
|
tasks.value.sort(sortTasks);
|
||||||
|
toast.success(tpl.label);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error((e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
@ -116,7 +152,20 @@ onMounted(load);
|
|||||||
class="btn-ghost !py-1 text-xs"
|
class="btn-ghost !py-1 text-xs"
|
||||||
@click="showForm = !showForm"
|
@click="showForm = !showForm"
|
||||||
>
|
>
|
||||||
{{ showForm ? 'Отмена' : '+ Добавить' }}
|
{{ showForm ? 'Отмена' : '+ Своя задача' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-1.5">
|
||||||
|
<button
|
||||||
|
v-for="tpl in TASK_TEMPLATES"
|
||||||
|
:key="tpl.label"
|
||||||
|
type="button"
|
||||||
|
class="px-2 py-0.5 rounded-full border border-gray-200 bg-white text-[11px] text-gray-600 hover:border-gray-400 hover:text-gray-900 transition"
|
||||||
|
:title="'Создать задачу: ' + tpl.label"
|
||||||
|
@click="addTemplate(tpl)"
|
||||||
|
>
|
||||||
|
{{ tpl.label }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
@ -18,6 +18,7 @@ interface State {
|
|||||||
sort: LeadSort;
|
sort: LeadSort;
|
||||||
createdBy: string | null;
|
createdBy: string | null;
|
||||||
q: string;
|
q: string;
|
||||||
|
stale: boolean;
|
||||||
filters: {
|
filters: {
|
||||||
status: LeadStatus | 'all';
|
status: LeadStatus | 'all';
|
||||||
lead_type: LeadType | 'all';
|
lead_type: LeadType | 'all';
|
||||||
@ -35,6 +36,7 @@ const INITIAL_STATE = (): State => ({
|
|||||||
sort: 'score_desc',
|
sort: 'score_desc',
|
||||||
createdBy: null,
|
createdBy: null,
|
||||||
q: '',
|
q: '',
|
||||||
|
stale: false,
|
||||||
filters: { status: 'all', lead_type: 'all', deal_stage: 'all' },
|
filters: { status: 'all', lead_type: 'all', deal_stage: 'all' },
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -62,6 +64,7 @@ export const useLeadsStore = defineStore('leads', {
|
|||||||
offset: (this.page - 1) * this.pageSize,
|
offset: (this.page - 1) * this.pageSize,
|
||||||
created_by: this.createdBy ?? undefined,
|
created_by: this.createdBy ?? undefined,
|
||||||
q: this.q.trim() || undefined,
|
q: this.q.trim() || undefined,
|
||||||
|
stale: this.stale,
|
||||||
});
|
});
|
||||||
this.items = res.data;
|
this.items = res.data;
|
||||||
this.total = res.total;
|
this.total = res.total;
|
||||||
@ -111,5 +114,10 @@ export const useLeadsStore = defineStore('leads', {
|
|||||||
this.page = 1;
|
this.page = 1;
|
||||||
return this.load();
|
return this.load();
|
||||||
},
|
},
|
||||||
|
setStale(stale: boolean) {
|
||||||
|
this.stale = stale;
|
||||||
|
this.page = 1;
|
||||||
|
return this.load();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,6 +8,7 @@ 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 LeadAnswersForm from '@/components/LeadAnswersForm.vue';
|
||||||
|
import QuickCallAction from '@/components/QuickCallAction.vue';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import {
|
import {
|
||||||
deleteLead,
|
deleteLead,
|
||||||
@ -350,6 +351,8 @@ onMounted(load);
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<QuickCallAction :lead-id="lead.id" @done="load" />
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
<TasksPanel :lead-id="lead.id" />
|
<TasksPanel :lead-id="lead.id" />
|
||||||
<NotesPanel :lead-id="lead.id" />
|
<NotesPanel :lead-id="lead.id" />
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -271,8 +271,8 @@ async function submit() {
|
|||||||
if (form.lead_type === 'inbound') body.source = form.source!;
|
if (form.lead_type === 'inbound') body.source = form.source!;
|
||||||
else body.prev_contact = form.prev_contact!;
|
else body.prev_contact = form.prev_contact!;
|
||||||
|
|
||||||
const lead = await createLead(body);
|
await createLead(body);
|
||||||
router.push({ name: 'lead-card', params: { id: lead.id } });
|
router.push({ name: 'leads' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ApiRequestError) {
|
if (e instanceof ApiRequestError) {
|
||||||
error.value = e.message;
|
error.value = e.message;
|
||||||
@ -295,9 +295,9 @@ async function submit() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sticky live score panel -->
|
<!-- Sticky live score panel — top offset clears the global header (h~64px) -->
|
||||||
<div
|
<div
|
||||||
class="sticky top-2 z-10 rounded-2xl border bg-white shadow-sm px-4 py-3 flex items-center gap-4"
|
class="sticky top-[72px] z-20 rounded-2xl border bg-white shadow-sm px-4 py-3 flex items-center gap-4"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="w-14 h-14 rounded-full text-white text-xl font-bold flex items-center justify-center tabular-nums shrink-0 transition-colors"
|
class="w-14 h-14 rounded-full text-white text-xl font-bold flex items-center justify-center tabular-nums shrink-0 transition-colors"
|
||||||
|
|||||||
Reference in New Issue
Block a user