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:
parent
0950c9f755
commit
837f8e11bb
19
leadscore/backend/src/audit.ts
Normal file
19
leadscore/backend/src/audit.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
49
leadscore/backend/src/routes/audit.ts
Normal file
49
leadscore/backend/src/routes/audit.ts
Normal 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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -2,7 +2,8 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import { query } from '../db.js';
|
import { query } from '../db.js';
|
||||||
import { calculateScore } from '../scoring.js';
|
import { calculateScore } from '../scoring.js';
|
||||||
import { AppError } from '../errors.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 {
|
import {
|
||||||
DEAL_STAGES,
|
DEAL_STAGES,
|
||||||
type DealStage,
|
type DealStage,
|
||||||
@ -566,6 +567,49 @@ export async function leadRoutes(app: FastifyInstance) {
|
|||||||
requireRole(user, 'rop');
|
requireRole(user, 'rop');
|
||||||
const { rowCount } = await query(`DELETE FROM leads WHERE id = $1`, [req.params.id]);
|
const { rowCount } = await query(`DELETE FROM leads WHERE id = $1`, [req.params.id]);
|
||||||
if (!rowCount) throw AppError.notFound('Lead not found');
|
if (!rowCount) throw AppError.notFound('Lead not found');
|
||||||
|
await audit(user.sub, 'lead', req.params.id, 'delete');
|
||||||
return reply.code(204).send();
|
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() };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
82
leadscore/backend/src/routes/notes.ts
Normal file
82
leadscore/backend/src/routes/notes.ts
Normal 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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
195
leadscore/backend/src/routes/tasks.ts
Normal file
195
leadscore/backend/src/routes/tasks.ts
Normal 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,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -8,6 +8,9 @@ import { healthRoutes } from './routes/health.js';
|
|||||||
import { statsRoutes } from './routes/stats.js';
|
import { statsRoutes } from './routes/stats.js';
|
||||||
import { reminderRoutes } from './routes/reminders.js';
|
import { reminderRoutes } from './routes/reminders.js';
|
||||||
import { intakeRoutes } from './routes/intake.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() {
|
async function buildApp() {
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
@ -42,6 +45,9 @@ async function buildApp() {
|
|||||||
await app.register(statsRoutes);
|
await app.register(statsRoutes);
|
||||||
await app.register(reminderRoutes);
|
await app.register(reminderRoutes);
|
||||||
await app.register(intakeRoutes);
|
await app.register(intakeRoutes);
|
||||||
|
await app.register(noteRoutes);
|
||||||
|
await app.register(taskRoutes);
|
||||||
|
await app.register(auditRoutes);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -164,6 +164,20 @@ export function getLead(id: string) {
|
|||||||
return apiFetch<Lead>(`/leads/${id}`);
|
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) {
|
export function createLead(body: CreateLeadBody) {
|
||||||
return apiFetch<Lead>('/leads', {
|
return apiFetch<Lead>('/leads', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
24
leadscore/frontend/src/api/notes.ts
Normal file
24
leadscore/frontend/src/api/notes.ts
Normal 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' });
|
||||||
|
}
|
||||||
60
leadscore/frontend/src/api/tasks.ts
Normal file
60
leadscore/frontend/src/api/tasks.ts
Normal 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');
|
||||||
|
}
|
||||||
@ -1,10 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onBeforeUnmount, ref } from 'vue';
|
import { computed, onMounted, onBeforeUnmount, ref } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
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 router = useRouter();
|
||||||
const data = ref<RemindersResponse | null>(null);
|
const reminders = ref<ReminderItem[]>([]);
|
||||||
|
const tasks = ref<TaskWithLead[]>([]);
|
||||||
const open = ref(false);
|
const open = ref(false);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
@ -12,11 +14,22 @@ const root = ref<HTMLElement | null>(null);
|
|||||||
|
|
||||||
let timer: number | 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() {
|
async function load() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
try {
|
try {
|
||||||
data.value = await getReminders();
|
const [r, t] = await Promise.all([getReminders(), getUpcomingTasks()]);
|
||||||
|
reminders.value = r.items;
|
||||||
|
tasks.value = t.data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = (e as Error).message;
|
error.value = (e as Error).message;
|
||||||
} finally {
|
} finally {
|
||||||
@ -29,7 +42,7 @@ function toggle() {
|
|||||||
if (open.value) load();
|
if (open.value) load();
|
||||||
}
|
}
|
||||||
|
|
||||||
function go(id: string) {
|
function goLead(id: string) {
|
||||||
open.value = false;
|
open.value = false;
|
||||||
router.push({ name: 'lead-card', params: { id } });
|
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(() => {
|
onMounted(() => {
|
||||||
load();
|
load();
|
||||||
timer = window.setInterval(load, 60000);
|
timer = window.setInterval(load, 60000);
|
||||||
@ -64,7 +84,7 @@ onBeforeUnmount(() => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="relative text-gray-600 hover:text-gray-900 p-1"
|
class="relative text-gray-600 hover:text-gray-900 p-1"
|
||||||
:title="'Напоминания: ' + (data?.count ?? 0)"
|
:title="'Напоминания: ' + totalCount"
|
||||||
@click.stop="toggle"
|
@click.stop="toggle"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@ -82,28 +102,30 @@ onBeforeUnmount(() => {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span
|
<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="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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="open"
|
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 class="px-4 py-3 border-b border-gray-100 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold text-sm">Напоминания</div>
|
<div class="font-semibold text-sm">Напоминания</div>
|
||||||
<div v-if="data" class="text-xs text-gray-500 mt-0.5">
|
<div class="text-xs text-gray-500 mt-0.5">
|
||||||
<span v-if="data.overdue > 0" class="text-red-600">
|
<span v-if="overdueCount > 0" class="text-red-600">
|
||||||
просрочено: {{ data.overdue }}
|
просрочено: {{ overdueCount }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="data.overdue > 0 && data.today > 0"> · </span>
|
<span v-if="overdueCount > 0 && totalCount > overdueCount"> · </span>
|
||||||
<span v-if="data.today > 0">сегодня: {{ data.today }}</span>
|
<span v-if="totalCount > overdueCount">
|
||||||
<span v-if="data.count === 0">всё под контролем 🎉</span>
|
на сегодня: {{ totalCount - overdueCount }}
|
||||||
|
</span>
|
||||||
|
<span v-if="totalCount === 0">всё под контролем 🎉</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@ -115,20 +137,57 @@ onBeforeUnmount(() => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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="error" class="px-4 py-6 text-sm text-red-600">{{ error }}</div>
|
||||||
<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"
|
class="px-4 py-6 text-sm text-gray-500 text-center"
|
||||||
>
|
>
|
||||||
Нет просроченных касаний.
|
Нет активных напоминаний.
|
||||||
</div>
|
</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
|
<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"
|
:key="r.id"
|
||||||
class="px-4 py-3 border-t border-gray-100 hover:bg-gray-50 cursor-pointer text-sm"
|
class="px-4 py-2 border-t border-gray-100 hover:bg-gray-50 cursor-pointer text-sm"
|
||||||
@click="go(r.id)"
|
@click="goLead(r.id)"
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between gap-2">
|
<div class="flex items-start justify-between gap-2">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
@ -136,7 +195,7 @@ onBeforeUnmount(() => {
|
|||||||
<div class="text-xs text-gray-500 truncate">{{ r.company_name }}</div>
|
<div class="text-xs text-gray-500 truncate">{{ r.company_name }}</div>
|
||||||
</div>
|
</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'"
|
:class="r.is_overdue ? 'text-red-600 font-medium' : 'text-amber-600'"
|
||||||
>
|
>
|
||||||
{{ r.is_overdue ? '⚠ ' : '' }}{{ formatDate(r.next_action_date) }}
|
{{ r.is_overdue ? '⚠ ' : '' }}{{ formatDate(r.next_action_date) }}
|
||||||
@ -146,4 +205,6 @@ onBeforeUnmount(() => {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
120
leadscore/frontend/src/components/NotesPanel.vue
Normal file
120
leadscore/frontend/src/components/NotesPanel.vue
Normal 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>
|
||||||
54
leadscore/frontend/src/components/ReassignWidget.vue
Normal file
54
leadscore/frontend/src/components/ReassignWidget.vue
Normal 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>
|
||||||
226
leadscore/frontend/src/components/TasksPanel.vue
Normal file
226
leadscore/frontend/src/components/TasksPanel.vue
Normal 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>
|
||||||
@ -4,6 +4,9 @@ import { useRouter } from 'vue-router';
|
|||||||
import ScoreBadge from '@/components/ScoreBadge.vue';
|
import ScoreBadge from '@/components/ScoreBadge.vue';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import MoneyInput from '@/components/MoneyInput.vue';
|
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 {
|
import {
|
||||||
deleteLead,
|
deleteLead,
|
||||||
getLead,
|
getLead,
|
||||||
@ -177,7 +180,7 @@ onMounted(load);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="max-w-3xl mx-auto space-y-6">
|
<div class="max-w-5xl mx-auto space-y-6">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="text-xs text-gray-500 hover:text-gray-900 transition inline-flex items-center gap-1"
|
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] }}
|
{{ DEAL_STAGE_LABELS[lead.deal_stage] }}
|
||||||
</span>
|
</span>
|
||||||
|
<ReassignWidget
|
||||||
|
v-if="auth.isRop"
|
||||||
|
:lead-id="lead.id"
|
||||||
|
:current-owner="lead.created_by"
|
||||||
|
@reassigned="(o) => lead && (lead.created_by = o)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -299,6 +308,11 @@ onMounted(load);
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="card-pad space-y-3">
|
||||||
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest">Детализация баллов</h2>
|
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-widest">Детализация баллов</h2>
|
||||||
<ul class="divide-y divide-gray-100">
|
<ul class="divide-y divide-gray-100">
|
||||||
|
|||||||
34
leadscore/migrations/1700000005000_add_notes_tasks_audit.sql
Normal file
34
leadscore/migrations/1700000005000_add_notes_tasks_audit.sql
Normal 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);
|
||||||
Reference in New Issue
Block a user