feat(manager): note/task templates, quick call action, stale filter

NotesPanel
- Adds a row of seven preset chips above the textarea (Дозвонился,
  Не отвечает, Назначили встречу, Отправил КП, Клиент думает,
  Перенесли созвон, Отказ — нет бюджета). Click inserts text into
  the draft and refocuses the textarea, so a phone call note becomes
  a 1-second action instead of typing.

TasksPanel
- Adds five quick-create chips (Перезвонить завтра / +3 дня /
  +неделю, Отправить КП завтра, Назначить встречу). One click POSTs
  the task with the right kind and due_date offset.

QuickCallAction (new component on the lead card)
- Big "✓ Звонок проведён" button collapses an inline panel with an
  optional details field and four outcome buttons:
    Дозвонился / Не отвечает / Перенесли / Отказ
- Picking an outcome posts a note with the matching template phrase
  (+ details if provided), creates the next call task on a sensible
  default interval (3/7d, none for Отказ), and best-effort closes
  any still-open call task due today or earlier on the same lead.
- Parent LeadCardView reloads on @done.

"Забытые" filter chip (FilterBar)
- Backend GET /leads accepts ?stale=true|1 and filters with
  GREATEST(updated_at, MAX(notes.created_at), MAX(tasks.created_at))
  < NOW() - INTERVAL '14 days' AND deal_stage NOT IN ('won','lost').
  Count query uses the same LATERAL joins as the data query.
- Pinia store gains a stale flag and setStale action; FilterBar
  shows a red chip that toggles it.
- qs helper now drops `false` values so the URL stays clean when
  the filter is off.
This commit is contained in:
Telecom 2026-05-22 14:30:18 +05:00
parent d0c5a607d7
commit faf4b3821a
8 changed files with 297 additions and 5 deletions

View File

@ -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',
@ -261,7 +273,15 @@ export async function leadRoutes(app: FastifyInstance) {
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,
); );

View File

@ -122,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 {
@ -151,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('&')}` : '';

View File

@ -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>

View File

@ -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">

View 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>

View File

@ -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>

View File

@ -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();
},
}, },
}); });

View File

@ -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" />