Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions server/__tests__/clients.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,43 @@ describe('GET /api/clients', () => {
expect(listB.body.data).toHaveLength(1);
expect(listB.body.data[0].name).toBe('Office B Client');
});

it('supports pagination via ?page= and ?page_size= with metadata in body', async () => {
const { token } = await registerLawyerWithOffice(app);

for (let i = 0; i < 5; i++) {
await request(app)
.post('/api/clients')
.set('Authorization', `Bearer ${token}`)
.send({ name: `Client ${i + 1}` });
}

const page1 = await request(app)
.get('/api/clients?page=1&page_size=2')
.set('Authorization', `Bearer ${token}`);
expect(page1.status).toBe(200);
expect(page1.body.data).toHaveLength(2);
expect(page1.body.total).toBe(5);
expect(page1.body.page).toBe(1);
expect(page1.body.page_size).toBe(2);

const page2 = await request(app)
.get('/api/clients?page=2&page_size=2')
.set('Authorization', `Bearer ${token}`);
expect(page2.body.data).toHaveLength(2);

const page3 = await request(app)
.get('/api/clients?page=3&page_size=2')
.set('Authorization', `Bearer ${token}`);
expect(page3.body.data).toHaveLength(1);

const allIds = [
...page1.body.data.map((c) => c.id),
...page2.body.data.map((c) => c.id),
...page3.body.data.map((c) => c.id),
];
expect(new Set(allIds).size).toBe(5);
});
});

describe('PUT /api/clients/:id', () => {
Expand Down
30 changes: 30 additions & 0 deletions server/__tests__/crm-modules.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,36 @@ describe('POST /api/cases', () => {
});
});

describe('GET /api/cases pagination', () => {
it('returns paginated cases with total when ?page=&page_size=', async () => {
const { authHeaders } = await registerDirectorWithOffice(app);

for (let i = 0; i < 4; i++) {
await request(app)
.post('/api/cases')
.set(authHeaders)
.send({ title: `Дело #${i + 1}`, status: 'in_progress' });
}

const page1 = await request(app)
.get('/api/cases?page=1&page_size=2')
.set(authHeaders);
expect(page1.status).toBe(200);
expect(page1.body.data).toHaveLength(2);
expect(page1.body.total).toBe(4);
expect(page1.body.page).toBe(1);
expect(page1.body.page_size).toBe(2);

const page2 = await request(app)
.get('/api/cases?page=2&page_size=2')
.set(authHeaders);
expect(page2.body.data).toHaveLength(2);

const all = [...page1.body.data, ...page2.body.data];
expect(new Set(all.map((r) => r.id)).size).toBe(4);
});
});

describe('POST /api/expenses', () => {
it('creates an expense and persists it', async () => {
const { authHeaders, officeId, user } = await registerDirectorWithOffice(app);
Expand Down
15 changes: 15 additions & 0 deletions server/__tests__/dashboard.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,19 @@ describe('office dashboard + plan', () => {
expect(Number(get.body.data.daily_plan_weekday)).toBe(10000);
expect(Number(get.body.data.period_plan_amount)).toBe(300000);
});

it('GET /api/office/:officeId/dashboard — gzip-сжатый ответ при наличии Accept-Encoding', async () => {
const dir = await registerDirectorWithOffice(app);

const res = await request(app)
.get(`/api/office/${dir.officeId}/dashboard`)
.set(dir.authHeaders)
.set('Accept-Encoding', 'gzip');

expect(res.status).toBe(200);
// Если payload > threshold (1024 байт) — должен быть gzip.
// При маленьком пустом дашборде compression может не срабатывать,
// поэтому просто проверяем, что middleware не ломает ответ.
expect(res.body.success).toBe(true);
});
});
17 changes: 16 additions & 1 deletion server/controllers/clientController.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,23 @@ const clientController = {
return res.json({ success: true, data: [] });
}

// Опциональная пагинация: ?page=1&page_size=50 (page_size capped at 200)
const page = parseInt(req.query.page, 10);
const pageSize = Math.min(parseInt(req.query.page_size, 10) || 50, 200);

if (page > 0) {
const result = await Client.getAllByOffice(officeId, { page, pageSize });
return res.json({
success: true,
data: result.items,
total: result.total,
page,
page_size: pageSize,
});
}

const clients = await Client.getAllByOffice(officeId);

res.json({
success: true,
data: clients
Expand Down
32 changes: 24 additions & 8 deletions server/controllers/crmModules.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,31 @@ const cases = {
async list(req, res) {
try {
const officeId = req.params.officeId || req.user.office_id;

// Опциональная пагинация: ?page=1&page_size=50 (page_size capped at 200)
const page = parseInt(req.query.page, 10);
const pageSize = Math.min(parseInt(req.query.page_size, 10) || 50, 200);

const baseFrom = `
FROM cases ca
LEFT JOIN clients cl ON cl.id = ca.client_id
LEFT JOIN employees e ON e.id = ca.employee_id
WHERE ca.office_id = ?
`;
const select = `SELECT ca.*, cl.name AS client_name, CONCAT(e.first_name, ' ', e.last_name) AS employee_name`;

if (page > 0) {
const [[{ total }]] = await db.query(`SELECT COUNT(*) AS total ${baseFrom}`, [officeId]);
const offset = (page - 1) * pageSize;
const [rows] = await db.query(
`${select} ${baseFrom} ORDER BY ca.created_at DESC LIMIT ? OFFSET ?`,
[officeId, pageSize, offset]
);
return ok(res, rows, { total, page, page_size: pageSize });
}

const [rows] = await db.query(
`SELECT ca.*,
cl.name AS client_name,
CONCAT(e.first_name, ' ', e.last_name) AS employee_name
FROM cases ca
LEFT JOIN clients cl ON cl.id = ca.client_id
LEFT JOIN employees e ON e.id = ca.employee_id
WHERE ca.office_id = ?
ORDER BY ca.created_at DESC`,
`${select} ${baseFrom} ORDER BY ca.created_at DESC`,
[officeId]
);
return ok(res, rows);
Expand Down
146 changes: 76 additions & 70 deletions server/controllers/officeDashboardController.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,80 +103,86 @@ const officeDashboardController = {

const { from, to, today, label } = resolvePeriod(req);

// Fact: paid_amount sum on contract_date — only "оплаченные" договоры (paid_amount > 0).
// contract_date is DATE; we use BETWEEN.
const [factRows] = await db.query(
`SELECT
COALESCE(SUM(CASE WHEN c.contract_date = ? THEN c.paid_amount ELSE 0 END), 0) AS day_fact,
COALESCE(SUM(CASE WHEN c.contract_date BETWEEN ? AND ? THEN c.paid_amount ELSE 0 END), 0) AS period_fact
FROM contracts c
JOIN employees e ON e.id = c.id_employee
WHERE e.office_id = ? AND c.paid_amount > 0`,
[today, from, to, officeId]
);
// Все 5 запросов независимы между собой — гоняем параллельно через Promise.all,
// чтобы суммарный latency был ~max(query), а не sum(query).
const [
[factRows],
[refundRows],
[planRows],
[lawyersRows],
[lawyerRefundRows],
] = await Promise.all([
// Fact: paid_amount sum on contract_date — only "оплаченные" договоры (paid_amount > 0).
db.query(
`SELECT
COALESCE(SUM(CASE WHEN c.contract_date = ? THEN c.paid_amount ELSE 0 END), 0) AS day_fact,
COALESCE(SUM(CASE WHEN c.contract_date BETWEEN ? AND ? THEN c.paid_amount ELSE 0 END), 0) AS period_fact
FROM contracts c
JOIN employees e ON e.id = c.id_employee
WHERE e.office_id = ? AND c.paid_amount > 0`,
[today, from, to, officeId]
),
// Confirmed refunds: subtract from fact on the day/period when refund was confirmed
db.query(
`SELECT
COALESCE(SUM(CASE WHEN DATE(c.refund_confirmed_at) = ? THEN c.refund_amount ELSE 0 END), 0) AS day_refund,
COALESCE(SUM(CASE WHEN DATE(c.refund_confirmed_at) BETWEEN ? AND ? THEN c.refund_amount ELSE 0 END), 0) AS period_refund
FROM contracts c
JOIN employees e ON e.id = c.id_employee
WHERE e.office_id = ? AND c.refund_confirmed = 1 AND c.refund_amount > 0`,
[today, from, to, officeId]
),
// Plan: latest record covering this period; otherwise latest record overall.
db.query(
`SELECT id, daily_plan_weekday, daily_plan_weekend, period_plan_amount, period_start, period_end
FROM office_plans
WHERE office_id = ?
ORDER BY (period_start <= ? AND period_end >= ?) DESC, updated_at DESC
LIMIT 1`,
[officeId, to, from]
),
// Lawyers cash table — менеджеры, ОКК, юристы/адвокаты/представители, только paid_amount > 0
db.query(
`SELECT
e.id,
TRIM(CONCAT_WS(' ', e.last_name, e.first_name, e.middle_name)) AS full_name,
e.position,
COALESCE(SUM(CASE WHEN c.contract_date = ? THEN c.paid_amount ELSE 0 END), 0) AS today_cash,
COALESCE(SUM(CASE WHEN c.contract_date BETWEEN ? AND ? THEN c.paid_amount ELSE 0 END), 0) AS period_cash
FROM employees e
LEFT JOIN users u ON u.email = e.email
LEFT JOIN contracts c ON c.id_employee = e.id AND c.paid_amount > 0
WHERE e.office_id = ?
AND (
LOWER(e.position) LIKE '%юрист%'
OR LOWER(e.position) LIKE '%адвокат%'
OR LOWER(e.position) LIKE '%менеджер%'
OR LOWER(e.position) LIKE '%окк%'
OR LOWER(e.position) LIKE '%контрол%'
OR LOWER(e.position) LIKE '%представит%'
OR u.role IN ('lawyer', 'manager', 'okk', 'representative')
)
GROUP BY e.id, e.last_name, e.first_name, e.middle_name, e.position
ORDER BY period_cash DESC, today_cash DESC, e.last_name ASC`,
[today, from, to, officeId]
),
// Per-employee confirmed refunds for lawyers_cash subtraction
db.query(
`SELECT
c.id_employee,
COALESCE(SUM(CASE WHEN DATE(c.refund_confirmed_at) = ? THEN c.refund_amount ELSE 0 END), 0) AS day_refund,
COALESCE(SUM(CASE WHEN DATE(c.refund_confirmed_at) BETWEEN ? AND ? THEN c.refund_amount ELSE 0 END), 0) AS period_refund
FROM contracts c
JOIN employees e ON e.id = c.id_employee
WHERE e.office_id = ? AND c.refund_confirmed = 1 AND c.refund_amount > 0
GROUP BY c.id_employee`,
[today, from, to, officeId]
),
]);

// Confirmed refunds: subtract from fact on the day/period when refund was confirmed
const [refundRows] = await db.query(
`SELECT
COALESCE(SUM(CASE WHEN DATE(c.refund_confirmed_at) = ? THEN c.refund_amount ELSE 0 END), 0) AS day_refund,
COALESCE(SUM(CASE WHEN DATE(c.refund_confirmed_at) BETWEEN ? AND ? THEN c.refund_amount ELSE 0 END), 0) AS period_refund
FROM contracts c
JOIN employees e ON e.id = c.id_employee
WHERE e.office_id = ? AND c.refund_confirmed = 1 AND c.refund_amount > 0`,
[today, from, to, officeId]
);
const day_fact = Number(factRows[0]?.day_fact || 0) - Number(refundRows[0]?.day_refund || 0);
const period_fact = Number(factRows[0]?.period_fact || 0) - Number(refundRows[0]?.period_refund || 0);

// Plan: latest record covering this period; otherwise latest record overall.
const [planRows] = await db.query(
`SELECT id, daily_plan_weekday, daily_plan_weekend, period_plan_amount, period_start, period_end
FROM office_plans
WHERE office_id = ?
ORDER BY (period_start <= ? AND period_end >= ?) DESC, updated_at DESC
LIMIT 1`,
[officeId, to, from]
);
const plan = planRows[0] || null;

// Lawyers cash table — менеджеры, ОКК, юристы/адвокаты/представители, только paid_amount > 0
const [lawyersRows] = await db.query(
`SELECT
e.id,
TRIM(CONCAT_WS(' ', e.last_name, e.first_name, e.middle_name)) AS full_name,
e.position,
COALESCE(SUM(CASE WHEN c.contract_date = ? THEN c.paid_amount ELSE 0 END), 0) AS today_cash,
COALESCE(SUM(CASE WHEN c.contract_date BETWEEN ? AND ? THEN c.paid_amount ELSE 0 END), 0) AS period_cash
FROM employees e
LEFT JOIN users u ON u.email = e.email
LEFT JOIN contracts c ON c.id_employee = e.id AND c.paid_amount > 0
WHERE e.office_id = ?
AND (
LOWER(e.position) LIKE '%юрист%'
OR LOWER(e.position) LIKE '%адвокат%'
OR LOWER(e.position) LIKE '%менеджер%'
OR LOWER(e.position) LIKE '%окк%'
OR LOWER(e.position) LIKE '%контрол%'
OR LOWER(e.position) LIKE '%представит%'
OR u.role IN ('lawyer', 'manager', 'okk', 'representative')
)
GROUP BY e.id, e.last_name, e.first_name, e.middle_name, e.position
ORDER BY period_cash DESC, today_cash DESC, e.last_name ASC`,
[today, from, to, officeId]
);

// Per-employee confirmed refunds for lawyers_cash subtraction
const [lawyerRefundRows] = await db.query(
`SELECT
c.id_employee,
COALESCE(SUM(CASE WHEN DATE(c.refund_confirmed_at) = ? THEN c.refund_amount ELSE 0 END), 0) AS day_refund,
COALESCE(SUM(CASE WHEN DATE(c.refund_confirmed_at) BETWEEN ? AND ? THEN c.refund_amount ELSE 0 END), 0) AS period_refund
FROM contracts c
JOIN employees e ON e.id = c.id_employee
WHERE e.office_id = ? AND c.refund_confirmed = 1 AND c.refund_amount > 0
GROUP BY c.id_employee`,
[today, from, to, officeId]
);
const refundByEmp = new Map();
lawyerRefundRows.forEach(r => refundByEmp.set(r.id_employee, {
day: Number(r.day_refund || 0),
Expand Down
40 changes: 31 additions & 9 deletions server/models/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,43 @@ const db = require('../db');

class Client {
/**
* Получить всех клиентов офиса (по office_id в clients)
* Получить всех клиентов офиса (по office_id в clients).
* Поддерживает опциональную пагинацию: { page, pageSize } — если оба > 0,
* возвращает { items, total, page, pageSize }. Без пагинации возвращает
* массив (обратная совместимость).
*/
static async getAllByOffice(officeId) {
static async getAllByOffice(officeId, options = {}) {
try {
const query = `
SELECT cl.*,
COUNT(DISTINCT c.id) as contracts_count,
COALESCE(SUM(c.amount), 0) as total_spent
const { page, pageSize } = options;

const baseFrom = `
FROM clients cl
LEFT JOIN contracts c ON cl.id = c.id_client
WHERE cl.office_id = ?
GROUP BY cl.id
ORDER BY cl.id DESC
`;
const [clients] = await db.query(query, [officeId]);
const selectFields = `
SELECT cl.*,
COUNT(DISTINCT c.id) as contracts_count,
COALESCE(SUM(c.amount), 0) as total_spent
`;

if (page > 0 && pageSize > 0) {
const [[{ total }]] = await db.query(
`SELECT COUNT(DISTINCT cl.id) AS total ${baseFrom}`,
[officeId]
);
const offset = (page - 1) * pageSize;
const [items] = await db.query(
`${selectFields} ${baseFrom} GROUP BY cl.id ORDER BY cl.id DESC LIMIT ? OFFSET ?`,
[officeId, pageSize, offset]
);
return { items, total, page, pageSize };
}

const [clients] = await db.query(
`${selectFields} ${baseFrom} GROUP BY cl.id ORDER BY cl.id DESC`,
[officeId]
);
return clients;
} catch (error) {
console.error('Error getting clients:', error);
Expand Down
Loading