Add notes timeline, tasks, reassign, and audit log

Schema:
- New migration creates lead_notes, lead_tasks, and audit_log tables

Backend:
- /leads/:id/notes [GET, POST] and /leads/:id/notes/:noteId [DELETE]
  scoped to lead access (rop or owner). Notes are append-only history.
- /leads/:id/tasks [GET, POST] and /leads/:id/tasks/:taskId [PATCH, DELETE]
  with kind (call, meeting, proposal, other), title, due_date, and
  completed_at. Auto-assigns to lead owner; rop may override.
- /tasks/upcoming returns active tasks due today or earlier for the
  current user (rop sees all).
- /leads/:id/reassign (rop only) updates created_by and writes audit
  entry. Validates target is a known manager.
- /managers lists available managers.
- /audit (rop only) returns filtered audit log.
- New audit() helper writes entries fire-and-forget.

Frontend:
- NotesPanel: timeline of notes per lead with add and per-author delete
- TasksPanel: checkable to-do list with kind/due/title, expandable
  completed section, color-coded overdue/today
- ReassignWidget (rop): dropdown of managers and confirm
- BellMenu now combines tasks (overdue + today) with legacy lead-level
  reminders in one dropdown
- LeadCardView: 2-column grid for TasksPanel/NotesPanel, ReassignWidget
  in header, wider container (max-w-5xl)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
ask 2026-05-19 16:58:51 +05:00
parent 0950c9f755
commit 837f8e11bb
15 changed files with 1043 additions and 41 deletions

View File

@ -0,0 +1,19 @@
import { query } from './db.js';
export async function audit(
actor: string,
entity: string,
entityId: string,
action: string,
diff?: Record<string, unknown> | null,
): Promise<void> {
try {
await query(
`INSERT INTO audit_log (actor, entity, entity_id, action, diff)
VALUES ($1, $2, $3, $4, $5)`,
[actor, entity, entityId, action, diff ? JSON.stringify(diff) : null],
);
} catch (_err) {
// never let audit break a request
}
}

View File

@ -0,0 +1,49 @@
import type { FastifyInstance } from 'fastify';
import { query } from '../db.js';
import { requireAuth, requireRole } from '../auth.js';
interface AuditRow {
id: string;
actor: string;
entity: string;
entity_id: string;
action: string;
diff: unknown;
created_at: Date;
}
export async function auditRoutes(app: FastifyInstance) {
app.get<{
Querystring: { entity?: string; entity_id?: string; actor?: string; limit?: string };
}>('/audit', async (req) => {
const user = await requireAuth(app, req);
requireRole(user, 'rop');
const where: string[] = [];
const params: unknown[] = [];
if (req.query.entity) {
params.push(req.query.entity);
where.push(`entity = $${params.length}`);
}
if (req.query.entity_id) {
params.push(req.query.entity_id);
where.push(`entity_id = $${params.length}`);
}
if (req.query.actor) {
params.push(req.query.actor);
where.push(`actor = $${params.length}`);
}
const limit = Math.min(Math.max(Number(req.query.limit ?? 100), 1), 500);
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
const { rows } = await query<AuditRow>(
`SELECT id::text, actor, entity, entity_id, action, diff, created_at
FROM audit_log
${whereSql}
ORDER BY created_at DESC
LIMIT ${limit}`,
params,
);
return { data: rows };
});
}

View File

@ -2,7 +2,8 @@ import type { FastifyInstance } from 'fastify';
import { query } from '../db.js';
import { calculateScore } from '../scoring.js';
import { AppError } from '../errors.js';
import { requireAuth, requireRole } from '../auth.js';
import { listManagers, requireAuth, requireRole } from '../auth.js';
import { audit } from '../audit.js';
import {
DEAL_STAGES,
type DealStage,
@ -566,6 +567,49 @@ export async function leadRoutes(app: FastifyInstance) {
requireRole(user, 'rop');
const { rowCount } = await query(`DELETE FROM leads WHERE id = $1`, [req.params.id]);
if (!rowCount) throw AppError.notFound('Lead not found');
await audit(user.sub, 'lead', req.params.id, 'delete');
return reply.code(204).send();
});
// REASSIGN (rop only)
app.post<{ Params: { id: string }; Body: { assigned_to?: unknown } }>(
'/leads/:id/reassign',
async (req) => {
const user = await requireAuth(app, req);
requireRole(user, 'rop');
const target = typeof req.body?.assigned_to === 'string' ? req.body.assigned_to : '';
if (!target) throw AppError.validation('assigned_to required', { assigned_to: 'required' });
const managers = listManagers();
if (!managers.includes(target)) {
throw AppError.validation('unknown manager', { assigned_to: 'invalid' });
}
const { rows } = await query<LeadRow & { prev: string }>(
`WITH cur AS (
SELECT created_by AS prev FROM leads WHERE id = $1
)
UPDATE leads
SET created_by = $2, updated_at = NOW()
WHERE id = $1
RETURNING *, (SELECT prev FROM cur) AS prev`,
[req.params.id, target],
);
if (rows.length === 0) throw AppError.notFound('Lead not found');
const updated = rows[0];
await audit(user.sub, 'lead', req.params.id, 'reassign', {
from: updated.prev,
to: target,
});
return {
id: updated.id,
created_by: updated.created_by,
updated_at: updated.updated_at,
};
},
);
// MANAGERS LIST
app.get('/managers', async (req) => {
await requireAuth(app, req);
return { data: listManagers() };
});
}

View File

@ -0,0 +1,82 @@
import type { FastifyInstance } from 'fastify';
import { query } from '../db.js';
import { requireAuth } from '../auth.js';
import { AppError } from '../errors.js';
interface NoteRow {
id: string;
lead_id: string;
body: string;
author: string;
created_at: Date;
}
async function checkLeadAccess(
app: FastifyInstance,
leadId: string,
user: { sub: string; role: string },
): Promise<void> {
const { rows } = await query<{ created_by: string }>(
`SELECT created_by FROM leads WHERE id = $1`,
[leadId],
);
if (rows.length === 0) throw AppError.notFound('Lead not found');
if (user.role !== 'rop' && rows[0].created_by !== user.sub) {
throw AppError.forbidden('Not your lead');
}
}
export async function noteRoutes(app: FastifyInstance) {
app.get<{ Params: { id: string } }>('/leads/:id/notes', async (req) => {
const user = await requireAuth(app, req);
await checkLeadAccess(app, req.params.id, user);
const { rows } = await query<NoteRow>(
`SELECT id, lead_id, body, author, created_at
FROM lead_notes
WHERE lead_id = $1
ORDER BY created_at DESC`,
[req.params.id],
);
return { data: rows };
});
app.post<{ Params: { id: string }; Body: { body?: unknown } }>(
'/leads/:id/notes',
async (req) => {
const user = await requireAuth(app, req);
await checkLeadAccess(app, req.params.id, user);
const body = typeof req.body?.body === 'string' ? req.body.body.trim() : '';
if (!body) throw AppError.validation('body is required', { body: 'required' });
const { rows } = await query<NoteRow>(
`INSERT INTO lead_notes (lead_id, body, author)
VALUES ($1, $2, $3)
RETURNING id, lead_id, body, author, created_at`,
[req.params.id, body, user.sub],
);
return rows[0];
},
);
app.delete<{ Params: { id: string; noteId: string } }>(
'/leads/:id/notes/:noteId',
async (req, reply) => {
const user = await requireAuth(app, req);
await checkLeadAccess(app, req.params.id, user);
const { rows } = await query<{ author: string }>(
`SELECT author FROM lead_notes WHERE id = $1 AND lead_id = $2`,
[req.params.noteId, req.params.id],
);
if (rows.length === 0) throw AppError.notFound('Note not found');
if (user.role !== 'rop' && rows[0].author !== user.sub) {
throw AppError.forbidden('Not your note');
}
await query(`DELETE FROM lead_notes WHERE id = $1`, [req.params.noteId]);
return reply.code(204).send();
},
);
}

View File

@ -0,0 +1,195 @@
import type { FastifyInstance } from 'fastify';
import { query } from '../db.js';
import { requireAuth } from '../auth.js';
import { AppError } from '../errors.js';
const TASK_KINDS = ['call', 'meeting', 'proposal', 'other'] as const;
type TaskKind = (typeof TASK_KINDS)[number];
interface TaskRow {
id: string;
lead_id: string;
kind: TaskKind;
title: string;
due_date: Date | string;
completed_at: Date | null;
assigned_to: string;
created_at: Date;
}
function formatDateOnly(d: Date | string | null): string | null {
if (!d) return null;
if (typeof d === 'string') return d.slice(0, 10);
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, '0');
const day = String(d.getUTCDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
function shape(row: TaskRow) {
return {
id: row.id,
lead_id: row.lead_id,
kind: row.kind,
title: row.title,
due_date: formatDateOnly(row.due_date),
completed_at: row.completed_at ? row.completed_at.toISOString() : null,
assigned_to: row.assigned_to,
created_at: row.created_at,
};
}
function parseKind(v: unknown): TaskKind {
if (typeof v !== 'string' || !(TASK_KINDS as readonly string[]).includes(v)) {
throw AppError.validation('kind invalid', { kind: 'invalid' });
}
return v as TaskKind;
}
function parseDate(v: unknown): string {
if (typeof v !== 'string' || !/^\d{4}-\d{2}-\d{2}/.test(v)) {
throw AppError.validation('due_date must be YYYY-MM-DD', { due_date: 'invalid' });
}
return v.slice(0, 10);
}
async function checkLeadAccess(
leadId: string,
user: { sub: string; role: string },
): Promise<{ created_by: string }> {
const { rows } = await query<{ created_by: string }>(
`SELECT created_by FROM leads WHERE id = $1`,
[leadId],
);
if (rows.length === 0) throw AppError.notFound('Lead not found');
if (user.role !== 'rop' && rows[0].created_by !== user.sub) {
throw AppError.forbidden('Not your lead');
}
return rows[0];
}
export async function taskRoutes(app: FastifyInstance) {
app.get<{ Params: { id: string } }>('/leads/:id/tasks', async (req) => {
const user = await requireAuth(app, req);
await checkLeadAccess(req.params.id, user);
const { rows } = await query<TaskRow>(
`SELECT id, lead_id, kind, title, due_date, completed_at, assigned_to, created_at
FROM lead_tasks
WHERE lead_id = $1
ORDER BY completed_at NULLS FIRST, due_date ASC`,
[req.params.id],
);
return { data: rows.map(shape) };
});
app.post<{
Params: { id: string };
Body: { kind?: unknown; title?: unknown; due_date?: unknown; assigned_to?: unknown };
}>('/leads/:id/tasks', async (req) => {
const user = await requireAuth(app, req);
const leadOwner = await checkLeadAccess(req.params.id, user);
const kind = parseKind(req.body?.kind);
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
if (!title) throw AppError.validation('title required', { title: 'required' });
const due_date = parseDate(req.body?.due_date);
const assigned =
typeof req.body?.assigned_to === 'string' && user.role === 'rop'
? req.body.assigned_to
: leadOwner.created_by;
const { rows } = await query<TaskRow>(
`INSERT INTO lead_tasks (lead_id, kind, title, due_date, assigned_to)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, lead_id, kind, title, due_date, completed_at, assigned_to, created_at`,
[req.params.id, kind, title, due_date, assigned],
);
return shape(rows[0]);
});
app.patch<{
Params: { id: string; taskId: string };
Body: { completed?: unknown; title?: unknown; due_date?: unknown; kind?: unknown };
}>('/leads/:id/tasks/:taskId', async (req) => {
const user = await requireAuth(app, req);
await checkLeadAccess(req.params.id, user);
const { rows: existing } = await query<TaskRow>(
`SELECT id, lead_id, kind, title, due_date, completed_at, assigned_to, created_at
FROM lead_tasks
WHERE id = $1 AND lead_id = $2`,
[req.params.taskId, req.params.id],
);
if (existing.length === 0) throw AppError.notFound('Task not found');
const sets: string[] = [];
const params: unknown[] = [];
function add(col: string, val: unknown) {
params.push(val);
sets.push(`${col} = $${params.length}`);
}
if (req.body?.completed !== undefined) {
add('completed_at', req.body.completed ? new Date().toISOString() : null);
}
if (typeof req.body?.title === 'string') add('title', req.body.title.trim());
if (req.body?.due_date !== undefined) add('due_date', parseDate(req.body.due_date));
if (req.body?.kind !== undefined) add('kind', parseKind(req.body.kind));
if (sets.length === 0) return shape(existing[0]);
params.push(req.params.taskId);
const { rows } = await query<TaskRow>(
`UPDATE lead_tasks SET ${sets.join(', ')}
WHERE id = $${params.length}
RETURNING id, lead_id, kind, title, due_date, completed_at, assigned_to, created_at`,
params,
);
return shape(rows[0]);
});
app.delete<{ Params: { id: string; taskId: string } }>(
'/leads/:id/tasks/:taskId',
async (req, reply) => {
const user = await requireAuth(app, req);
await checkLeadAccess(req.params.id, user);
await query(`DELETE FROM lead_tasks WHERE id = $1 AND lead_id = $2`, [
req.params.taskId,
req.params.id,
]);
return reply.code(204).send();
},
);
// upcoming tasks for current user (used by bell)
app.get('/tasks/upcoming', async (req) => {
const user = await requireAuth(app, req);
const params: unknown[] = [];
const where: string[] = [
`completed_at IS NULL`,
`due_date <= CURRENT_DATE`,
];
if (user.role !== 'rop') {
params.push(user.sub);
where.push(`assigned_to = $${params.length}`);
}
const { rows } = await query<
TaskRow & { contact_name: string; company_name: string }
>(
`SELECT t.id, t.lead_id, t.kind, t.title, t.due_date, t.completed_at,
t.assigned_to, t.created_at,
l.contact_name, l.company_name
FROM lead_tasks t
JOIN leads l ON l.id = t.lead_id
WHERE ${where.join(' AND ')}
ORDER BY t.due_date ASC
LIMIT 50`,
params,
);
return {
data: rows.map((r) => ({
...shape(r),
contact_name: r.contact_name,
company_name: r.company_name,
})),
};
});
}

View File

@ -8,6 +8,9 @@ import { healthRoutes } from './routes/health.js';
import { statsRoutes } from './routes/stats.js';
import { reminderRoutes } from './routes/reminders.js';
import { intakeRoutes } from './routes/intake.js';
import { noteRoutes } from './routes/notes.js';
import { taskRoutes } from './routes/tasks.js';
import { auditRoutes } from './routes/audit.js';
async function buildApp() {
const app = Fastify({
@ -42,6 +45,9 @@ async function buildApp() {
await app.register(statsRoutes);
await app.register(reminderRoutes);
await app.register(intakeRoutes);
await app.register(noteRoutes);
await app.register(taskRoutes);
await app.register(auditRoutes);
return app;
}

View File

@ -164,6 +164,20 @@ export function getLead(id: string) {
return apiFetch<Lead>(`/leads/${id}`);
}
export function reassignLead(id: string, assigned_to: string) {
return apiFetch<{ id: string; created_by: string; updated_at: string }>(
`/leads/${id}/reassign`,
{
method: 'POST',
body: JSON.stringify({ assigned_to }),
},
);
}
export function listManagers() {
return apiFetch<{ data: string[] }>('/managers');
}
export function createLead(body: CreateLeadBody) {
return apiFetch<Lead>('/leads', {
method: 'POST',

View File

@ -0,0 +1,24 @@
import { apiFetch } from './client';
export interface Note {
id: string;
lead_id: string;
body: string;
author: string;
created_at: string;
}
export function listNotes(leadId: string) {
return apiFetch<{ data: Note[] }>(`/leads/${leadId}/notes`);
}
export function createNote(leadId: string, body: string) {
return apiFetch<Note>(`/leads/${leadId}/notes`, {
method: 'POST',
body: JSON.stringify({ body }),
});
}
export function deleteNote(leadId: string, noteId: string) {
return apiFetch<void>(`/leads/${leadId}/notes/${noteId}`, { method: 'DELETE' });
}

View File

@ -0,0 +1,60 @@
import { apiFetch } from './client';
export const TASK_KINDS = ['call', 'meeting', 'proposal', 'other'] as const;
export type TaskKind = (typeof TASK_KINDS)[number];
export const TASK_KIND_LABELS: Record<TaskKind, string> = {
call: 'Звонок',
meeting: 'Встреча',
proposal: 'КП',
other: 'Другое',
};
export interface Task {
id: string;
lead_id: string;
kind: TaskKind;
title: string;
due_date: string | null;
completed_at: string | null;
assigned_to: string;
created_at: string;
}
export interface TaskWithLead extends Task {
contact_name: string;
company_name: string;
}
export function listTasks(leadId: string) {
return apiFetch<{ data: Task[] }>(`/leads/${leadId}/tasks`);
}
export function createTask(
leadId: string,
body: { kind: TaskKind; title: string; due_date: string; assigned_to?: string },
) {
return apiFetch<Task>(`/leads/${leadId}/tasks`, {
method: 'POST',
body: JSON.stringify(body),
});
}
export function updateTask(
leadId: string,
taskId: string,
body: Partial<{ completed: boolean; title: string; due_date: string; kind: TaskKind }>,
) {
return apiFetch<Task>(`/leads/${leadId}/tasks/${taskId}`, {
method: 'PATCH',
body: JSON.stringify(body),
});
}
export function deleteTask(leadId: string, taskId: string) {
return apiFetch<void>(`/leads/${leadId}/tasks/${taskId}`, { method: 'DELETE' });
}
export function getUpcomingTasks() {
return apiFetch<{ data: TaskWithLead[] }>('/tasks/upcoming');
}

View File

@ -1,10 +1,12 @@
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref } from 'vue';
import { computed, onMounted, onBeforeUnmount, ref } from 'vue';
import { useRouter } from 'vue-router';
import { getReminders, type RemindersResponse } from '@/api/reminders';
import { getReminders, type ReminderItem } from '@/api/reminders';
import { getUpcomingTasks, type TaskWithLead } from '@/api/tasks';
const router = useRouter();
const data = ref<RemindersResponse | null>(null);
const reminders = ref<ReminderItem[]>([]);
const tasks = ref<TaskWithLead[]>([]);
const open = ref(false);
const loading = ref(false);
const error = ref<string | null>(null);
@ -12,11 +14,22 @@ const root = ref<HTMLElement | null>(null);
let timer: number | null = null;
const todayIso = new Date().toISOString().slice(0, 10);
const totalCount = computed(() => reminders.value.length + tasks.value.length);
const overdueCount = computed(
() =>
reminders.value.filter((r) => r.is_overdue).length +
tasks.value.filter((t) => (t.due_date ?? '') < todayIso).length,
);
async function load() {
loading.value = true;
error.value = null;
try {
data.value = await getReminders();
const [r, t] = await Promise.all([getReminders(), getUpcomingTasks()]);
reminders.value = r.items;
tasks.value = t.data;
} catch (e) {
error.value = (e as Error).message;
} finally {
@ -29,7 +42,7 @@ function toggle() {
if (open.value) load();
}
function go(id: string) {
function goLead(id: string) {
open.value = false;
router.push({ name: 'lead-card', params: { id } });
}
@ -47,6 +60,13 @@ function formatDate(s: string | null) {
});
}
const kindIcon: Record<string, string> = {
call: '📞',
meeting: '🤝',
proposal: '📄',
other: '✦',
};
onMounted(() => {
load();
timer = window.setInterval(load, 60000);
@ -64,7 +84,7 @@ onBeforeUnmount(() => {
<button
type="button"
class="relative text-gray-600 hover:text-gray-900 p-1"
:title="'Напоминания: ' + (data?.count ?? 0)"
:title="'Напоминания: ' + totalCount"
@click.stop="toggle"
>
<svg
@ -82,28 +102,30 @@ onBeforeUnmount(() => {
/>
</svg>
<span
v-if="data && data.count > 0"
v-if="totalCount > 0"
class="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 text-[10px] font-bold rounded-full flex items-center justify-center"
:class="data.overdue > 0 ? 'bg-red-600 text-white' : 'bg-amber-500 text-white'"
:class="overdueCount > 0 ? 'bg-red-600 text-white' : 'bg-amber-500 text-white'"
>
{{ data.count }}
{{ totalCount }}
</span>
</button>
<div
v-if="open"
class="absolute right-0 mt-2 w-80 bg-white border border-gray-200 rounded-xl shadow-lg z-50 overflow-hidden"
class="absolute right-0 mt-2 w-96 bg-white border border-gray-200 rounded-xl shadow-lg z-50 overflow-hidden"
>
<div class="px-4 py-3 border-b border-gray-100 flex items-center justify-between">
<div>
<div class="font-semibold text-sm">Напоминания</div>
<div v-if="data" class="text-xs text-gray-500 mt-0.5">
<span v-if="data.overdue > 0" class="text-red-600">
просрочено: {{ data.overdue }}
<div class="text-xs text-gray-500 mt-0.5">
<span v-if="overdueCount > 0" class="text-red-600">
просрочено: {{ overdueCount }}
</span>
<span v-if="data.overdue > 0 && data.today > 0"> · </span>
<span v-if="data.today > 0">сегодня: {{ data.today }}</span>
<span v-if="data.count === 0">всё под контролем 🎉</span>
<span v-if="overdueCount > 0 && totalCount > overdueCount"> · </span>
<span v-if="totalCount > overdueCount">
на сегодня: {{ totalCount - overdueCount }}
</span>
<span v-if="totalCount === 0">всё под контролем 🎉</span>
</div>
</div>
<button
@ -115,20 +137,57 @@ onBeforeUnmount(() => {
</button>
</div>
<div v-if="loading && !data" class="px-4 py-6 text-sm text-gray-500">Загрузка…</div>
<div v-if="loading && totalCount === 0" class="px-4 py-6 text-sm text-gray-500">Загрузка…</div>
<div v-else-if="error" class="px-4 py-6 text-sm text-red-600">{{ error }}</div>
<div
v-else-if="data && data.items.length === 0"
v-else-if="totalCount === 0"
class="px-4 py-6 text-sm text-gray-500 text-center"
>
Нет просроченных касаний.
Нет активных напоминаний.
</div>
<ul v-else-if="data" class="max-h-96 overflow-y-auto">
<div v-else class="max-h-[28rem] overflow-y-auto">
<div v-if="tasks.length > 0">
<div class="px-4 pt-3 pb-1 text-[10px] font-semibold uppercase tracking-widest text-gray-400">
Задачи
</div>
<ul>
<li
v-for="r in data.items"
v-for="t in tasks"
:key="t.id"
class="px-4 py-2 border-t border-gray-100 hover:bg-gray-50 cursor-pointer text-sm"
@click="goLead(t.lead_id)"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="font-medium truncate">
{{ kindIcon[t.kind] }} {{ t.title }}
</div>
<div class="text-xs text-gray-500 truncate">
{{ t.contact_name }} · {{ t.company_name }}
</div>
</div>
<div
class="text-xs whitespace-nowrap tabular-nums"
:class="(t.due_date ?? '') < todayIso ? 'text-red-600 font-medium' : 'text-amber-600'"
>
{{ (t.due_date ?? '') < todayIso ? '⚠ ' : '' }}{{ formatDate(t.due_date) }}
</div>
</div>
</li>
</ul>
</div>
<div v-if="reminders.length > 0">
<div class="px-4 pt-3 pb-1 text-[10px] font-semibold uppercase tracking-widest text-gray-400">
Касания по лидам
</div>
<ul>
<li
v-for="r in reminders"
:key="r.id"
class="px-4 py-3 border-t border-gray-100 hover:bg-gray-50 cursor-pointer text-sm"
@click="go(r.id)"
class="px-4 py-2 border-t border-gray-100 hover:bg-gray-50 cursor-pointer text-sm"
@click="goLead(r.id)"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
@ -136,7 +195,7 @@ onBeforeUnmount(() => {
<div class="text-xs text-gray-500 truncate">{{ r.company_name }}</div>
</div>
<div
class="text-xs whitespace-nowrap"
class="text-xs whitespace-nowrap tabular-nums"
:class="r.is_overdue ? 'text-red-600 font-medium' : 'text-amber-600'"
>
{{ r.is_overdue ? '⚠ ' : '' }}{{ formatDate(r.next_action_date) }}
@ -146,4 +205,6 @@ onBeforeUnmount(() => {
</ul>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,120 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useAuthStore } from '@/stores/auth';
import { useToast } from '@/composables/useToast';
import { createNote, deleteNote, listNotes, type Note } from '@/api/notes';
const props = defineProps<{ leadId: string }>();
const auth = useAuthStore();
const toast = useToast();
const notes = ref<Note[]>([]);
const loading = ref(false);
const draft = ref('');
const submitting = ref(false);
async function load() {
loading.value = true;
try {
const res = await listNotes(props.leadId);
notes.value = res.data;
} catch (e) {
toast.error((e as Error).message);
} finally {
loading.value = false;
}
}
async function add() {
const text = draft.value.trim();
if (!text) return;
submitting.value = true;
try {
const created = await createNote(props.leadId, text);
notes.value.unshift(created);
draft.value = '';
} catch (e) {
toast.error((e as Error).message);
} finally {
submitting.value = false;
}
}
async function remove(id: string) {
if (!confirm('Удалить заметку?')) return;
try {
await deleteNote(props.leadId, id);
notes.value = notes.value.filter((n) => n.id !== id);
} catch (e) {
toast.error((e as Error).message);
}
}
function fmt(s: string) {
return new Date(s).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function canDelete(n: Note) {
return auth.isRop || n.author === auth.user?.username;
}
onMounted(load);
</script>
<template>
<div class="card-pad space-y-4">
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest">
Заметки
</h2>
<form class="space-y-2" @submit.prevent="add">
<textarea
v-model="draft"
rows="2"
placeholder="Добавить заметку…"
class="input-base !text-sm"
/>
<div class="flex justify-end">
<button
type="submit"
class="btn-primary !py-1.5 text-xs"
:disabled="!draft.trim() || submitting"
>
{{ submitting ? 'Сохраняем…' : 'Добавить' }}
</button>
</div>
</form>
<div v-if="loading" class="text-xs text-gray-400">Загрузка…</div>
<div v-else-if="notes.length === 0" class="text-xs text-gray-400 italic">
Заметок пока нет.
</div>
<ul v-else class="space-y-3">
<li
v-for="n in notes"
:key="n.id"
class="border-l-2 border-gray-200 pl-3 group"
>
<div class="flex items-center justify-between gap-2">
<div class="text-xs text-gray-500">
<span class="font-medium text-gray-700">{{ n.author }}</span>
· {{ fmt(n.created_at) }}
</div>
<button
v-if="canDelete(n)"
type="button"
class="text-[10px] text-gray-400 hover:text-red-600 opacity-0 group-hover:opacity-100 transition"
@click="remove(n.id)"
>
удалить
</button>
</div>
<p class="text-sm mt-1 whitespace-pre-wrap">{{ n.body }}</p>
</li>
</ul>
</div>
</template>

View File

@ -0,0 +1,54 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useToast } from '@/composables/useToast';
import { listManagers, reassignLead } from '@/api/leads';
const props = defineProps<{ leadId: string; currentOwner: string }>();
const emit = defineEmits<{ (e: 'reassigned', newOwner: string): void }>();
const managers = ref<string[]>([]);
const target = ref('');
const submitting = ref(false);
const toast = useToast();
onMounted(async () => {
try {
const res = await listManagers();
managers.value = res.data;
target.value = props.currentOwner;
} catch (e) {
toast.error((e as Error).message);
}
});
async function submit() {
if (!target.value || target.value === props.currentOwner) return;
submitting.value = true;
try {
await reassignLead(props.leadId, target.value);
toast.success(`Лид передан ${target.value}`);
emit('reassigned', target.value);
} catch (e) {
toast.error((e as Error).message);
} finally {
submitting.value = false;
}
}
</script>
<template>
<div class="flex items-center gap-2 text-sm">
<label class="text-xs text-gray-500">Передать:</label>
<select v-model="target" class="input-base !w-auto !py-1.5 text-xs">
<option v-for="m in managers" :key="m" :value="m">{{ m }}</option>
</select>
<button
type="button"
class="btn-secondary !py-1 text-xs"
:disabled="submitting || target === currentOwner"
@click="submit"
>
{{ submitting ? '…' : 'Переназначить' }}
</button>
</div>
</template>

View File

@ -0,0 +1,226 @@
<script setup lang="ts">
import { onMounted, reactive, ref, computed } from 'vue';
import { useToast } from '@/composables/useToast';
import {
TASK_KINDS,
TASK_KIND_LABELS,
createTask,
deleteTask,
listTasks,
updateTask,
type Task,
type TaskKind,
} from '@/api/tasks';
const props = defineProps<{ leadId: string }>();
const toast = useToast();
const tasks = ref<Task[]>([]);
const loading = ref(false);
const draft = reactive<{ kind: TaskKind; title: string; due_date: string }>({
kind: 'call',
title: '',
due_date: new Date().toISOString().slice(0, 10),
});
const showForm = ref(false);
async function load() {
loading.value = true;
try {
const res = await listTasks(props.leadId);
tasks.value = res.data;
} catch (e) {
toast.error((e as Error).message);
} finally {
loading.value = false;
}
}
async function add() {
if (!draft.title.trim()) return;
try {
const created = await createTask(props.leadId, {
kind: draft.kind,
title: draft.title.trim(),
due_date: draft.due_date,
});
tasks.value.push(created);
tasks.value.sort(sortTasks);
draft.title = '';
showForm.value = false;
toast.success('Задача добавлена');
} catch (e) {
toast.error((e as Error).message);
}
}
async function toggle(t: Task) {
try {
const updated = await updateTask(props.leadId, t.id, {
completed: !t.completed_at,
});
Object.assign(t, updated);
tasks.value.sort(sortTasks);
} catch (e) {
toast.error((e as Error).message);
}
}
async function remove(t: Task) {
if (!confirm(`Удалить задачу «${t.title}»?`)) return;
try {
await deleteTask(props.leadId, t.id);
tasks.value = tasks.value.filter((x) => x.id !== t.id);
} catch (e) {
toast.error((e as Error).message);
}
}
function sortTasks(a: Task, b: Task) {
if (!a.completed_at && b.completed_at) return -1;
if (a.completed_at && !b.completed_at) return 1;
return (a.due_date ?? '').localeCompare(b.due_date ?? '');
}
const todayIso = new Date().toISOString().slice(0, 10);
const kindIcon: Record<TaskKind, string> = {
call: '📞',
meeting: '🤝',
proposal: '📄',
other: '✦',
};
function fmtDue(s: string | null) {
if (!s) return '';
return new Date(s + 'T00:00:00').toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
});
}
const upcoming = computed(() => tasks.value.filter((t) => !t.completed_at));
const done = computed(() => tasks.value.filter((t) => t.completed_at));
onMounted(load);
</script>
<template>
<div class="card-pad space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest">Задачи</h2>
<button
type="button"
class="btn-ghost !py-1 text-xs"
@click="showForm = !showForm"
>
{{ showForm ? 'Отмена' : '+ Добавить' }}
</button>
</div>
<form
v-if="showForm"
class="space-y-2 border border-gray-200 rounded-lg p-3 bg-gray-50/40"
@submit.prevent="add"
>
<div class="flex gap-2">
<select v-model="draft.kind" class="input-base !w-auto !py-1.5 text-xs">
<option v-for="k in TASK_KINDS" :key="k" :value="k">
{{ kindIcon[k] }} {{ TASK_KIND_LABELS[k] }}
</option>
</select>
<input
v-model="draft.due_date"
type="date"
class="input-base !w-auto !py-1.5 text-xs"
/>
<input
v-model="draft.title"
type="text"
placeholder="Что сделать?"
class="input-base !py-1.5 text-sm flex-1"
/>
</div>
<div class="flex justify-end">
<button type="submit" class="btn-primary !py-1 text-xs" :disabled="!draft.title.trim()">
Сохранить
</button>
</div>
</form>
<div v-if="loading" class="text-xs text-gray-400">Загрузка…</div>
<div
v-else-if="tasks.length === 0"
class="text-xs text-gray-400 italic"
>
Задач пока нет.
</div>
<ul v-if="upcoming.length > 0" class="space-y-1.5">
<li
v-for="t in upcoming"
:key="t.id"
class="flex items-center gap-2 group"
>
<button
type="button"
class="w-4 h-4 rounded border border-gray-300 hover:border-gray-900 transition flex items-center justify-center"
@click="toggle(t)"
></button>
<span class="text-base">{{ kindIcon[t.kind] }}</span>
<span class="text-sm flex-1 truncate">{{ t.title }}</span>
<span
class="text-xs tabular-nums whitespace-nowrap"
:class="
t.due_date && t.due_date < todayIso
? 'text-red-600 font-medium'
: t.due_date === todayIso
? 'text-amber-600'
: 'text-gray-500'
"
>
{{ fmtDue(t.due_date) }}
</span>
<button
type="button"
class="text-[10px] text-gray-400 hover:text-red-600 opacity-0 group-hover:opacity-100 transition"
@click="remove(t)"
>
×
</button>
</li>
</ul>
<details v-if="done.length > 0" class="mt-2">
<summary class="text-xs text-gray-500 cursor-pointer hover:text-gray-700">
Завершённые ({{ done.length }})
</summary>
<ul class="mt-2 space-y-1.5">
<li
v-for="t in done"
:key="t.id"
class="flex items-center gap-2 group text-gray-400"
>
<button
type="button"
class="w-4 h-4 rounded bg-gray-900 text-white flex items-center justify-center text-[10px]"
@click="toggle(t)"
>
✓
</button>
<span class="text-base opacity-60">{{ kindIcon[t.kind] }}</span>
<span class="text-sm flex-1 truncate line-through">{{ t.title }}</span>
<button
type="button"
class="text-[10px] hover:text-red-600 opacity-0 group-hover:opacity-100 transition"
@click="remove(t)"
>
×
</button>
</li>
</ul>
</details>
</div>
</template>

View File

@ -4,6 +4,9 @@ import { useRouter } from 'vue-router';
import ScoreBadge from '@/components/ScoreBadge.vue';
import { useAuthStore } from '@/stores/auth';
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 {
deleteLead,
getLead,
@ -177,7 +180,7 @@ onMounted(load);
</script>
<template>
<div class="max-w-3xl mx-auto space-y-6">
<div class="max-w-5xl mx-auto space-y-6">
<button
type="button"
class="text-xs text-gray-500 hover:text-gray-900 transition inline-flex items-center gap-1"
@ -227,6 +230,12 @@ onMounted(load);
>
{{ DEAL_STAGE_LABELS[lead.deal_stage] }}
</span>
<ReassignWidget
v-if="auth.isRop"
:lead-id="lead.id"
:current-owner="lead.created_by"
@reassigned="(o) => lead && (lead.created_by = o)"
/>
</div>
</div>
@ -299,6 +308,11 @@ onMounted(load);
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<TasksPanel :lead-id="lead.id" />
<NotesPanel :lead-id="lead.id" />
</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">

View File

@ -0,0 +1,34 @@
-- Up Migration
CREATE TABLE lead_notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
lead_id UUID NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
body TEXT NOT NULL,
author TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_lead_notes_lead ON lead_notes(lead_id, created_at DESC);
CREATE TABLE lead_tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
lead_id UUID NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('call','meeting','proposal','other')),
title TEXT NOT NULL,
due_date DATE NOT NULL,
completed_at TIMESTAMPTZ,
assigned_to TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_lead_tasks_assignee_due ON lead_tasks(assigned_to, due_date) WHERE completed_at IS NULL;
CREATE INDEX idx_lead_tasks_lead ON lead_tasks(lead_id, due_date);
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
actor TEXT NOT NULL,
entity TEXT NOT NULL,
entity_id TEXT NOT NULL,
action TEXT NOT NULL,
diff JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_audit_log_entity ON audit_log(entity, entity_id, created_at DESC);
CREATE INDEX idx_audit_log_actor ON audit_log(actor, created_at DESC);