@hed-hog/operations 0.0.319 → 0.0.321
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/controllers/operations-contracts.controller.d.ts +9 -9
- package/dist/operations.service.js +110 -110
- package/hedhog/data/operations_cost_type.yaml +95 -95
- package/hedhog/frontend/app/_components/collaborator-costs-section.tsx.ejs +884 -884
- package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +23 -23
- package/hedhog/frontend/app/_lib/types.ts.ejs +178 -178
- package/hedhog/frontend/app/reports/collaborators/page.tsx.ejs +771 -771
- package/hedhog/frontend/app/reports/projects/page.tsx.ejs +809 -809
- package/package.json +4 -4
- package/src/controllers/operations-reports.controller.ts +32 -32
- package/src/dto/list-reports.dto.ts +51 -51
- package/src/operations.module.ts +5 -5
- package/src/operations.service.ts +610 -610
|
@@ -2179,9 +2179,9 @@ export class OperationsService {
|
|
|
2179
2179
|
LEFT JOIN "user" u ON u.id = h.actor_user_id
|
|
2180
2180
|
WHERE h.collaborator_id = $1
|
|
2181
2181
|
ORDER BY h.created_at DESC`,
|
|
2182
|
-
[collaboratorId]
|
|
2183
|
-
);
|
|
2184
|
-
}
|
|
2182
|
+
[collaboratorId]
|
|
2183
|
+
);
|
|
2184
|
+
}
|
|
2185
2185
|
|
|
2186
2186
|
async listDepartments(
|
|
2187
2187
|
userId: number,
|
|
@@ -11254,9 +11254,9 @@ export class OperationsService {
|
|
|
11254
11254
|
);
|
|
11255
11255
|
}
|
|
11256
11256
|
|
|
11257
|
-
async getCollaboratorCostsSummary(userId: number, collaboratorId: number) {
|
|
11258
|
-
await this.getActorContext(userId);
|
|
11259
|
-
const costs = await this.listCollaboratorCosts(userId, collaboratorId);
|
|
11257
|
+
async getCollaboratorCostsSummary(userId: number, collaboratorId: number) {
|
|
11258
|
+
await this.getActorContext(userId);
|
|
11259
|
+
const costs = await this.listCollaboratorCosts(userId, collaboratorId);
|
|
11260
11260
|
|
|
11261
11261
|
let monthlyTotal = 0;
|
|
11262
11262
|
let allocatableTotal = 0;
|
|
@@ -11285,610 +11285,610 @@ export class OperationsService {
|
|
|
11285
11285
|
monthlyTotal: Math.round(monthlyTotal * 100) / 100,
|
|
11286
11286
|
allocatableTotal: Math.round(allocatableTotal * 100) / 100,
|
|
11287
11287
|
nonAllocatableTotal: Math.round((monthlyTotal - allocatableTotal) * 100) / 100,
|
|
11288
|
-
count: costs.length,
|
|
11289
|
-
};
|
|
11290
|
-
}
|
|
11291
|
-
|
|
11292
|
-
async getProjectsReport(
|
|
11293
|
-
userId: number,
|
|
11294
|
-
filters: {
|
|
11295
|
-
from?: string;
|
|
11296
|
-
to?: string;
|
|
11297
|
-
status?: string;
|
|
11298
|
-
client?: string;
|
|
11299
|
-
scenario?: string;
|
|
11300
|
-
} = {}
|
|
11301
|
-
) {
|
|
11302
|
-
const actor = await this.getActorContext(userId);
|
|
11303
|
-
this.ensureDirector(actor);
|
|
11304
|
-
|
|
11305
|
-
const currentYear = new Date().getFullYear();
|
|
11306
|
-
const from = /^\d{4}-\d{2}-\d{2}$/.test(String(filters.from ?? ''))
|
|
11307
|
-
? String(filters.from)
|
|
11308
|
-
: `${currentYear}-01-01`;
|
|
11309
|
-
const to = /^\d{4}-\d{2}-\d{2}$/.test(String(filters.to ?? ''))
|
|
11310
|
-
? String(filters.to)
|
|
11311
|
-
: `${currentYear}-12-31`;
|
|
11312
|
-
const scenario =
|
|
11313
|
-
filters.scenario === 'growth' || filters.scenario === 'conservative'
|
|
11314
|
-
? filters.scenario
|
|
11315
|
-
: 'base';
|
|
11316
|
-
const multiplier =
|
|
11317
|
-
scenario === 'growth'
|
|
11318
|
-
? { revenue: 1.16, cost: 1.09, backlog: 1.22 }
|
|
11319
|
-
: scenario === 'conservative'
|
|
11320
|
-
? { revenue: 0.9, cost: 0.96, backlog: 0.82 }
|
|
11321
|
-
: { revenue: 1, cost: 1, backlog: 1 };
|
|
11322
|
-
|
|
11323
|
-
const params: unknown[] = [from, to];
|
|
11324
|
-
const where = [
|
|
11325
|
-
'p.deleted_at IS NULL',
|
|
11326
|
-
'(p.end_date IS NULL OR p.end_date >= $1::date)',
|
|
11327
|
-
'(p.start_date IS NULL OR p.start_date <= $2::date)',
|
|
11328
|
-
];
|
|
11329
|
-
if (filters.client && filters.client !== 'all') {
|
|
11330
|
-
where.push(
|
|
11331
|
-
`COALESCE(NULLIF(p.client_name, ''), NULLIF(contract_record.client_name, ''), '-') = ${this.param(params, filters.client)}`
|
|
11332
|
-
);
|
|
11333
|
-
}
|
|
11334
|
-
|
|
11335
|
-
const dbRows = await this.queryRows<{
|
|
11336
|
-
id: number;
|
|
11337
|
-
name: string;
|
|
11338
|
-
client: string | null;
|
|
11339
|
-
manager: string | null;
|
|
11340
|
-
squad: string | null;
|
|
11341
|
-
status: string;
|
|
11342
|
-
contractType: string | null;
|
|
11343
|
-
startDate: string | null;
|
|
11344
|
-
endDate: string | null;
|
|
11345
|
-
contractedRevenue: string | null;
|
|
11346
|
-
progressPercent: string | null;
|
|
11347
|
-
weeklyHours: string | null;
|
|
11348
|
-
actualHours: string | null;
|
|
11349
|
-
billableHours: string | null;
|
|
11350
|
-
openTasks: string | null;
|
|
11351
|
-
backlogHours: string | null;
|
|
11352
|
-
futureDeliveries: string | null;
|
|
11353
|
-
}>(
|
|
11354
|
-
`SELECT p.id,
|
|
11355
|
-
p.name,
|
|
11356
|
-
COALESCE(NULLIF(p.client_name, ''), NULLIF(contract_record.client_name, ''), '-') AS client,
|
|
11357
|
-
COALESCE(NULLIF(manager_record.display_name, ''), '-') AS manager,
|
|
11358
|
-
COALESCE(NULLIF(p.delivery_model::text, ''), '-') AS squad,
|
|
11359
|
-
p.status::text AS status,
|
|
11360
|
-
COALESCE(contract_record.billing_model::text, p.delivery_model::text, 'fixed_price') AS "contractType",
|
|
11361
|
-
TO_CHAR(p.start_date, 'YYYY-MM-DD') AS "startDate",
|
|
11362
|
-
TO_CHAR(p.end_date, 'YYYY-MM-DD') AS "endDate",
|
|
11363
|
-
COALESCE(p.budget_amount, contract_record.budget_amount, 0)::text AS "contractedRevenue",
|
|
11364
|
-
COALESCE(p.progress_percent, 0)::text AS "progressPercent",
|
|
11365
|
-
COALESCE(assignment_stats.weekly_hours, 0)::text AS "weeklyHours",
|
|
11366
|
-
COALESCE(time_stats.actual_hours, 0)::text AS "actualHours",
|
|
11367
|
-
COALESCE(time_stats.billable_hours, 0)::text AS "billableHours",
|
|
11368
|
-
COALESCE(task_stats.open_tasks, 0)::text AS "openTasks",
|
|
11369
|
-
COALESCE(task_stats.backlog_hours, 0)::text AS "backlogHours",
|
|
11370
|
-
COALESCE(task_stats.future_deliveries, 0)::text AS "futureDeliveries"
|
|
11371
|
-
FROM operations_project p
|
|
11372
|
-
LEFT JOIN operations_contract contract_record
|
|
11373
|
-
ON contract_record.id = p.contract_id
|
|
11374
|
-
AND contract_record.deleted_at IS NULL
|
|
11375
|
-
LEFT JOIN operations_collaborator manager_record
|
|
11376
|
-
ON manager_record.id = p.manager_collaborator_id
|
|
11377
|
-
AND manager_record.deleted_at IS NULL
|
|
11378
|
-
LEFT JOIN LATERAL (
|
|
11379
|
-
SELECT COALESCE(SUM(pa.weekly_hours), 0) AS weekly_hours
|
|
11380
|
-
FROM operations_project_assignment pa
|
|
11381
|
-
WHERE pa.project_id = p.id
|
|
11382
|
-
AND pa.deleted_at IS NULL
|
|
11383
|
-
AND pa.status IN ('planned', 'active')
|
|
11384
|
-
) assignment_stats ON TRUE
|
|
11385
|
-
LEFT JOIN LATERAL (
|
|
11386
|
-
SELECT COALESCE(SUM(entry.hours), 0) AS actual_hours,
|
|
11387
|
-
COALESCE(SUM(entry.hours) FILTER (WHERE pa.is_billable = true), 0) AS billable_hours
|
|
11388
|
-
FROM operations_timesheet_entry entry
|
|
11389
|
-
JOIN operations_project_assignment pa
|
|
11390
|
-
ON pa.id = entry.project_assignment_id
|
|
11391
|
-
AND pa.deleted_at IS NULL
|
|
11392
|
-
WHERE pa.project_id = p.id
|
|
11393
|
-
AND entry.deleted_at IS NULL
|
|
11394
|
-
AND entry.work_date BETWEEN $1::date AND $2::date
|
|
11395
|
-
) time_stats ON TRUE
|
|
11396
|
-
LEFT JOIN LATERAL (
|
|
11397
|
-
SELECT COUNT(*) FILTER (WHERE task.status IN ('todo', 'doing', 'review')) AS open_tasks,
|
|
11398
|
-
COALESCE(SUM(task.estimate_hours) FILTER (WHERE task.status IN ('todo', 'doing', 'review')), 0) AS backlog_hours,
|
|
11399
|
-
COUNT(*) FILTER (WHERE task.due_date > $2::date AND task.status IN ('todo', 'doing', 'review')) AS future_deliveries
|
|
11400
|
-
FROM operations_task task
|
|
11401
|
-
WHERE task.project_id = p.id
|
|
11402
|
-
AND task.deleted_at IS NULL
|
|
11403
|
-
) task_stats ON TRUE
|
|
11404
|
-
WHERE ${where.join(' AND ')}
|
|
11405
|
-
ORDER BY p.name ASC`,
|
|
11406
|
-
params
|
|
11407
|
-
);
|
|
11408
|
-
|
|
11409
|
-
const fromDate = new Date(`${from}T00:00:00`);
|
|
11410
|
-
const toDate = new Date(`${to}T00:00:00`);
|
|
11411
|
-
const periodWeeks = Math.max(
|
|
11412
|
-
1,
|
|
11413
|
-
Math.ceil((toDate.getTime() - fromDate.getTime()) / 604800000)
|
|
11414
|
-
);
|
|
11415
|
-
const rows = dbRows
|
|
11416
|
-
.map((row) => {
|
|
11417
|
-
const progress = Number(row.progressPercent ?? 0);
|
|
11418
|
-
const contractedRevenue = Number(row.contractedRevenue ?? 0);
|
|
11419
|
-
const recognizedRevenue = contractedRevenue * (progress / 100);
|
|
11420
|
-
const actualHours = Number(row.actualHours ?? 0);
|
|
11421
|
-
const plannedHours = Math.max(Number(row.weeklyHours ?? 0) * periodWeeks, actualHours);
|
|
11422
|
-
const realizedCost = 0;
|
|
11423
|
-
const reportStatus =
|
|
11424
|
-
row.status === 'paused'
|
|
11425
|
-
? 'paused'
|
|
11426
|
-
: row.status === 'at_risk'
|
|
11427
|
-
? 'attention'
|
|
11428
|
-
: row.endDate && new Date(`${row.endDate}T00:00:00`) < new Date() && progress < 100
|
|
11429
|
-
? 'late'
|
|
11430
|
-
: 'on_track';
|
|
11431
|
-
const risk =
|
|
11432
|
-
reportStatus === 'late' || (plannedHours && actualHours / plannedHours > 1)
|
|
11433
|
-
? 'alto'
|
|
11434
|
-
: reportStatus === 'attention'
|
|
11435
|
-
? 'médio'
|
|
11436
|
-
: 'baixo';
|
|
11437
|
-
|
|
11438
|
-
return {
|
|
11439
|
-
id: Number(row.id),
|
|
11440
|
-
name: row.name,
|
|
11441
|
-
client: row.client ?? '-',
|
|
11442
|
-
manager: row.manager ?? '-',
|
|
11443
|
-
squad: String(row.squad ?? '-').replace(/_/g, ' '),
|
|
11444
|
-
status: reportStatus,
|
|
11445
|
-
contractType:
|
|
11446
|
-
row.contractType === 'monthly_retainer'
|
|
11447
|
-
? 'retainer'
|
|
11448
|
-
: row.contractType === 'time_and_material'
|
|
11449
|
-
? 'time_materials'
|
|
11450
|
-
: 'fixed_price',
|
|
11451
|
-
priority: risk === 'alto' ? 'alta' : risk === 'médio' ? 'média' : 'baixa',
|
|
11452
|
-
startDate: row.startDate,
|
|
11453
|
-
endDate: row.endDate,
|
|
11454
|
-
contractedRevenue,
|
|
11455
|
-
recognizedRevenue,
|
|
11456
|
-
realizedCost,
|
|
11457
|
-
forecastCost: realizedCost,
|
|
11458
|
-
teamCost: realizedCost,
|
|
11459
|
-
infraCost: 0,
|
|
11460
|
-
licenseCost: 0,
|
|
11461
|
-
thirdPartyCost: 0,
|
|
11462
|
-
reworkCost: 0,
|
|
11463
|
-
plannedHours,
|
|
11464
|
-
actualHours,
|
|
11465
|
-
billableHours: Number(row.billableHours ?? 0),
|
|
11466
|
-
reworkHours: 0,
|
|
11467
|
-
internalHours: Math.max(actualHours - Number(row.billableHours ?? 0), 0),
|
|
11468
|
-
allocatedCapacity: plannedHours ? (actualHours / plannedHours) * 100 : 0,
|
|
11469
|
-
physicalProgress: progress,
|
|
11470
|
-
financialProgress: contractedRevenue ? (recognizedRevenue / contractedRevenue) * 100 : 0,
|
|
11471
|
-
backlogValue: Math.max(contractedRevenue - recognizedRevenue, 0),
|
|
11472
|
-
futureDeliveries: Number(row.futureDeliveries ?? 0),
|
|
11473
|
-
risk,
|
|
11474
|
-
recommendation:
|
|
11475
|
-
risk === 'alto'
|
|
11476
|
-
? 'Revisar escopo, prazo ou capacidade alocada.'
|
|
11477
|
-
: risk === 'médio'
|
|
11478
|
-
? 'Acompanhar margem, entregas e consumo de horas.'
|
|
11479
|
-
: 'Manter ritmo e proteger capacidade planejada.',
|
|
11480
|
-
};
|
|
11481
|
-
})
|
|
11482
|
-
.filter((row) => !filters.status || filters.status === 'all' || row.status === filters.status);
|
|
11483
|
-
|
|
11484
|
-
const summary = rows.reduce(
|
|
11485
|
-
(acc, row) => {
|
|
11486
|
-
acc.contractedRevenue += row.contractedRevenue;
|
|
11487
|
-
acc.recognizedRevenue += row.recognizedRevenue;
|
|
11488
|
-
acc.realizedCost += row.realizedCost;
|
|
11489
|
-
acc.forecastCost += row.forecastCost;
|
|
11490
|
-
acc.plannedHours += row.plannedHours;
|
|
11491
|
-
acc.actualHours += row.actualHours;
|
|
11492
|
-
acc.billableHours += row.billableHours;
|
|
11493
|
-
acc.reworkHours += row.reworkHours;
|
|
11494
|
-
acc.backlogValue += row.backlogValue;
|
|
11495
|
-
acc.avgDeadline += row.physicalProgress;
|
|
11496
|
-
acc.avgAllocation += row.allocatedCapacity;
|
|
11497
|
-
acc.atRisk += row.risk === 'alto' ? 1 : 0;
|
|
11498
|
-
return acc;
|
|
11499
|
-
},
|
|
11500
|
-
{
|
|
11501
|
-
contractedRevenue: 0,
|
|
11502
|
-
recognizedRevenue: 0,
|
|
11503
|
-
realizedCost: 0,
|
|
11504
|
-
forecastCost: 0,
|
|
11505
|
-
profit: 0,
|
|
11506
|
-
margin: 0,
|
|
11507
|
-
plannedHours: 0,
|
|
11508
|
-
actualHours: 0,
|
|
11509
|
-
billableHours: 0,
|
|
11510
|
-
reworkHours: 0,
|
|
11511
|
-
backlogValue: 0,
|
|
11512
|
-
avgDeadline: 0,
|
|
11513
|
-
avgAllocation: 0,
|
|
11514
|
-
atRisk: 0,
|
|
11515
|
-
burnRate: 0,
|
|
11516
|
-
}
|
|
11517
|
-
);
|
|
11518
|
-
summary.profit = summary.recognizedRevenue - summary.realizedCost;
|
|
11519
|
-
summary.margin = summary.recognizedRevenue ? (summary.profit / summary.recognizedRevenue) * 100 : 0;
|
|
11520
|
-
summary.avgDeadline = rows.length ? summary.avgDeadline / rows.length : 0;
|
|
11521
|
-
summary.avgAllocation = rows.length ? summary.avgAllocation / rows.length : 0;
|
|
11522
|
-
summary.burnRate = summary.plannedHours ? (summary.actualHours / summary.plannedHours) * 100 : 0;
|
|
11523
|
-
|
|
11524
|
-
const forecast = Array.from({ length: 12 }, (_, index) => {
|
|
11525
|
-
const monthDate = new Date(fromDate);
|
|
11526
|
-
monthDate.setMonth(fromDate.getMonth() + index);
|
|
11527
|
-
const revenue = Math.round((summary.recognizedRevenue / 12) * multiplier.revenue);
|
|
11528
|
-
const cost = Math.round((summary.realizedCost / 12) * multiplier.cost);
|
|
11529
|
-
const profit = revenue - cost;
|
|
11530
|
-
return {
|
|
11531
|
-
month: monthDate.toLocaleDateString('pt-BR', { month: 'short' }).replace('.', ''),
|
|
11532
|
-
revenue,
|
|
11533
|
-
cost,
|
|
11534
|
-
profit,
|
|
11535
|
-
backlog: Math.round(Math.max(profit, 0) * multiplier.backlog),
|
|
11536
|
-
planned: Math.round(summary.plannedHours / 12),
|
|
11537
|
-
actual: Math.round(summary.actualHours / 12),
|
|
11538
|
-
};
|
|
11539
|
-
});
|
|
11540
|
-
|
|
11541
|
-
return {
|
|
11542
|
-
filters: {
|
|
11543
|
-
from,
|
|
11544
|
-
to,
|
|
11545
|
-
status: filters.status ?? 'all',
|
|
11546
|
-
client: filters.client ?? 'all',
|
|
11547
|
-
scenario,
|
|
11548
|
-
clients: [...new Set(dbRows.map((row) => row.client ?? '-'))].sort(),
|
|
11549
|
-
},
|
|
11550
|
-
summary,
|
|
11551
|
-
forecast,
|
|
11552
|
-
costComposition: [
|
|
11553
|
-
{ name: 'Equipe', value: summary.realizedCost },
|
|
11554
|
-
{ name: 'Infraestrutura', value: 0 },
|
|
11555
|
-
{ name: 'Licenças', value: 0 },
|
|
11556
|
-
{ name: 'Terceiros', value: 0 },
|
|
11557
|
-
{ name: 'Retrabalho', value: 0 },
|
|
11558
|
-
],
|
|
11559
|
-
hoursByProject: rows.map((row) => ({
|
|
11560
|
-
project: row.name.split(' ').slice(0, 2).join(' '),
|
|
11561
|
-
Faturável: row.billableHours,
|
|
11562
|
-
Interno: row.internalHours,
|
|
11563
|
-
Retrabalho: row.reworkHours,
|
|
11564
|
-
Livre: Math.max(row.plannedHours - row.actualHours, 0),
|
|
11565
|
-
})),
|
|
11566
|
-
health: rows.map((row) => ({
|
|
11567
|
-
project: row.name.split(' ').slice(0, 2).join(' '),
|
|
11568
|
-
margem: Math.max(row.recognizedRevenue ? ((row.recognizedRevenue - row.realizedCost) / row.recognizedRevenue) * 100 : 0, 0),
|
|
11569
|
-
prazo: row.physicalProgress,
|
|
11570
|
-
alocacao: row.allocatedCapacity,
|
|
11571
|
-
saude: Math.max(100 - (row.risk === 'alto' ? 28 : row.risk === 'médio' ? 14 : 0), 35),
|
|
11572
|
-
})),
|
|
11573
|
-
ranking: rows
|
|
11574
|
-
.map((row) => ({
|
|
11575
|
-
name: row.name.split(' ').slice(0, 2).join(' '),
|
|
11576
|
-
Lucro: row.recognizedRevenue - row.realizedCost,
|
|
11577
|
-
Custo: row.realizedCost,
|
|
11578
|
-
}))
|
|
11579
|
-
.sort((a, b) => b.Lucro - a.Lucro),
|
|
11580
|
-
progress: forecast.map((row) => ({
|
|
11581
|
-
month: row.month,
|
|
11582
|
-
Planejado: row.planned,
|
|
11583
|
-
Realizado: row.actual,
|
|
11584
|
-
})),
|
|
11585
|
-
planningCards: [
|
|
11586
|
-
{ title: 'Acelerar críticas', value: String(summary.atRisk), description: 'Projetos com risco alto devem receber revisão de prazo e capacidade.' },
|
|
11587
|
-
{ title: 'Renegociar escopo', value: Math.round(summary.backlogValue).toString(), description: 'Backlog financeiro ainda não reconhecido no período.' },
|
|
11588
|
-
{ title: 'Realocar equipe', value: Math.round(Math.max(summary.plannedHours - summary.actualHours, 0)).toString(), description: 'Horas planejadas ainda não consumidas.' },
|
|
11589
|
-
{ title: 'Priorizar margem', value: rows.filter((row) => row.recognizedRevenue > row.realizedCost).length.toString(), description: 'Projetos com contribuição positiva no recorte.' },
|
|
11590
|
-
],
|
|
11591
|
-
rows,
|
|
11592
|
-
};
|
|
11593
|
-
}
|
|
11594
|
-
|
|
11595
|
-
async getCollaboratorsReport(
|
|
11596
|
-
userId: number,
|
|
11597
|
-
filters: {
|
|
11598
|
-
from?: string;
|
|
11599
|
-
to?: string;
|
|
11600
|
-
department?: string;
|
|
11601
|
-
contractType?: string;
|
|
11602
|
-
scenario?: string;
|
|
11603
|
-
} = {}
|
|
11604
|
-
) {
|
|
11605
|
-
const actor = await this.getActorContext(userId);
|
|
11606
|
-
this.ensureDirector(actor);
|
|
11607
|
-
|
|
11608
|
-
const currentYear = new Date().getFullYear();
|
|
11609
|
-
const from = /^\d{4}-\d{2}-\d{2}$/.test(String(filters.from ?? ''))
|
|
11610
|
-
? String(filters.from)
|
|
11611
|
-
: `${currentYear}-01-01`;
|
|
11612
|
-
const to = /^\d{4}-\d{2}-\d{2}$/.test(String(filters.to ?? ''))
|
|
11613
|
-
? String(filters.to)
|
|
11614
|
-
: `${currentYear}-12-31`;
|
|
11615
|
-
const scenario =
|
|
11616
|
-
filters.scenario === 'growth' || filters.scenario === 'conservative'
|
|
11617
|
-
? filters.scenario
|
|
11618
|
-
: 'base';
|
|
11619
|
-
const multiplier =
|
|
11620
|
-
scenario === 'growth'
|
|
11621
|
-
? { revenue: 1.18, cost: 1.11, capacity: 1.08 }
|
|
11622
|
-
: scenario === 'conservative'
|
|
11623
|
-
? { revenue: 0.9, cost: 0.96, capacity: 0.94 }
|
|
11624
|
-
: { revenue: 1, cost: 1, capacity: 1 };
|
|
11625
|
-
const params: unknown[] = [from, to];
|
|
11626
|
-
const where = [
|
|
11627
|
-
'c.deleted_at IS NULL',
|
|
11628
|
-
'(c.joined_at IS NULL OR c.joined_at <= $2::date)',
|
|
11629
|
-
'(c.left_at IS NULL OR c.left_at >= $1::date)',
|
|
11630
|
-
];
|
|
11631
|
-
|
|
11632
|
-
if (filters.department && filters.department !== 'all') {
|
|
11633
|
-
where.push(`COALESCE(department_record.name, '-') = ${this.param(params, filters.department)}`);
|
|
11634
|
-
}
|
|
11635
|
-
|
|
11636
|
-
if (filters.contractType && filters.contractType !== 'all') {
|
|
11637
|
-
where.push(`COALESCE(collaborator_type.name, collaborator_type.slug, '-') = ${this.param(params, filters.contractType)}`);
|
|
11638
|
-
}
|
|
11639
|
-
|
|
11640
|
-
const dbRows = await this.queryRows<{
|
|
11641
|
-
id: number;
|
|
11642
|
-
name: string;
|
|
11643
|
-
role: string | null;
|
|
11644
|
-
seniority: string | null;
|
|
11645
|
-
department: string | null;
|
|
11646
|
-
contractType: string | null;
|
|
11647
|
-
startDate: string | null;
|
|
11648
|
-
endDate: string | null;
|
|
11649
|
-
weeklyCapacityHours: string | null;
|
|
11650
|
-
salaryCost: string | null;
|
|
11651
|
-
benefitsCost: string | null;
|
|
11652
|
-
taxesCost: string | null;
|
|
11653
|
-
toolsCost: string | null;
|
|
11654
|
-
billableValue: string | null;
|
|
11655
|
-
allocatedHours: string | null;
|
|
11656
|
-
billableHours: string | null;
|
|
11657
|
-
projects: string | null;
|
|
11658
|
-
}>(
|
|
11659
|
-
`SELECT c.id,
|
|
11660
|
-
COALESCE(NULLIF(c.display_name, ''), person_record.name, c.code) AS name,
|
|
11661
|
-
COALESCE(job_title_record.name, c.title, '-') AS role,
|
|
11662
|
-
COALESCE(c.level_label, '-') AS seniority,
|
|
11663
|
-
COALESCE(department_record.name, '-') AS department,
|
|
11664
|
-
COALESCE(collaborator_type.name, collaborator_type.slug, '-') AS "contractType",
|
|
11665
|
-
TO_CHAR(c.joined_at, 'YYYY-MM-DD') AS "startDate",
|
|
11666
|
-
TO_CHAR(c.left_at, 'YYYY-MM-DD') AS "endDate",
|
|
11667
|
-
COALESCE(c.weekly_capacity_hours, 40)::text AS "weeklyCapacityHours",
|
|
11668
|
-
COALESCE(cost_stats.salary_cost, 0)::text AS "salaryCost",
|
|
11669
|
-
COALESCE(cost_stats.benefits_cost, 0)::text AS "benefitsCost",
|
|
11670
|
-
COALESCE(cost_stats.taxes_cost, 0)::text AS "taxesCost",
|
|
11671
|
-
COALESCE(cost_stats.tools_cost, 0)::text AS "toolsCost",
|
|
11672
|
-
COALESCE(value_stats.billable_value, 0)::text AS "billableValue",
|
|
11673
|
-
COALESCE(value_stats.allocated_hours, 0)::text AS "allocatedHours",
|
|
11674
|
-
COALESCE(value_stats.billable_hours, 0)::text AS "billableHours",
|
|
11675
|
-
COALESCE(project_stats.projects, 0)::text AS projects
|
|
11676
|
-
FROM operations_collaborator c
|
|
11677
|
-
LEFT JOIN person person_record ON person_record.id = c.person_id
|
|
11678
|
-
LEFT JOIN operations_department department_record
|
|
11679
|
-
ON department_record.id = c.department_id
|
|
11680
|
-
AND department_record.deleted_at IS NULL
|
|
11681
|
-
LEFT JOIN operations_job_title job_title_record
|
|
11682
|
-
ON job_title_record.id = c.job_title_id
|
|
11683
|
-
AND job_title_record.deleted_at IS NULL
|
|
11684
|
-
LEFT JOIN operations_collaborator_type collaborator_type
|
|
11685
|
-
ON collaborator_type.id = c.collaborator_type_id
|
|
11686
|
-
AND collaborator_type.deleted_at IS NULL
|
|
11687
|
-
LEFT JOIN LATERAL (
|
|
11688
|
-
SELECT COALESCE(SUM(cost.amount) FILTER (WHERE cost.recurrence::text = 'monthly' AND cost_type.slug IN ('salario-base', 'pro-labore')), 0) AS salary_cost,
|
|
11689
|
-
COALESCE(SUM(cost.amount) FILTER (WHERE cost.recurrence::text = 'monthly' AND cost_type.slug IN ('vale-refeicao', 'vale-alimentacao', 'vale-transporte', 'plano-saude', 'plano-odontologico', 'seguro-vida')), 0) AS benefits_cost,
|
|
11690
|
-
COALESCE(SUM(cost.amount) FILTER (WHERE cost.recurrence::text = 'monthly' AND cost_type.slug IN ('inss-patronal', 'fgts', 'rat-fap', 'terceiros-sistema-s', 'provisao-decimo-terceiro', 'provisao-ferias')), 0) AS taxes_cost,
|
|
11691
|
-
COALESCE(SUM(cost.amount) FILTER (WHERE cost.recurrence::text = 'monthly' AND cost_type.slug IN ('software-licenca', 'equipamento')), 0) AS tools_cost
|
|
11692
|
-
FROM operations_collaborator_cost cost
|
|
11693
|
-
LEFT JOIN operations_cost_type cost_type ON cost_type.id = cost.cost_type_id
|
|
11694
|
-
WHERE cost.collaborator_id = c.id
|
|
11695
|
-
AND (cost.start_date IS NULL OR cost.start_date <= $2::date)
|
|
11696
|
-
AND (cost.end_date IS NULL OR cost.end_date >= $1::date)
|
|
11697
|
-
) cost_stats ON TRUE
|
|
11698
|
-
LEFT JOIN LATERAL (
|
|
11699
|
-
SELECT COALESCE(SUM(entry.hours), 0) AS allocated_hours,
|
|
11700
|
-
COALESCE(SUM(entry.hours) FILTER (WHERE pa.is_billable = true), 0) AS billable_hours,
|
|
11701
|
-
COALESCE(SUM(entry.hours) FILTER (WHERE pa.is_billable = true) * 120, 0) AS billable_value
|
|
11702
|
-
FROM operations_timesheet_entry entry
|
|
11703
|
-
JOIN operations_project_assignment pa
|
|
11704
|
-
ON pa.id = entry.project_assignment_id
|
|
11705
|
-
AND pa.deleted_at IS NULL
|
|
11706
|
-
WHERE pa.collaborator_id = c.id
|
|
11707
|
-
AND entry.deleted_at IS NULL
|
|
11708
|
-
AND entry.work_date BETWEEN $1::date AND $2::date
|
|
11709
|
-
) value_stats ON TRUE
|
|
11710
|
-
LEFT JOIN LATERAL (
|
|
11711
|
-
SELECT COUNT(DISTINCT pa.project_id) AS projects
|
|
11712
|
-
FROM operations_project_assignment pa
|
|
11713
|
-
WHERE pa.collaborator_id = c.id
|
|
11714
|
-
AND pa.deleted_at IS NULL
|
|
11715
|
-
AND pa.status IN ('planned', 'active')
|
|
11716
|
-
) project_stats ON TRUE
|
|
11717
|
-
WHERE ${where.join(' AND ')}
|
|
11718
|
-
ORDER BY name ASC`,
|
|
11719
|
-
params
|
|
11720
|
-
);
|
|
11721
|
-
|
|
11722
|
-
const fromDate = new Date(`${from}T00:00:00`);
|
|
11723
|
-
const toDate = new Date(`${to}T00:00:00`);
|
|
11724
|
-
const periodWeeks = Math.max(
|
|
11725
|
-
1,
|
|
11726
|
-
Math.ceil((toDate.getTime() - fromDate.getTime()) / 604800000)
|
|
11727
|
-
);
|
|
11728
|
-
const rows = dbRows.map((row) => {
|
|
11729
|
-
const salaryCost = Number(row.salaryCost ?? 0);
|
|
11730
|
-
const benefitsCost = Number(row.benefitsCost ?? 0);
|
|
11731
|
-
const taxesCost = Number(row.taxesCost ?? 0);
|
|
11732
|
-
const toolsCost = Number(row.toolsCost ?? 0);
|
|
11733
|
-
const availableHours = Number(row.weeklyCapacityHours ?? 40) * periodWeeks;
|
|
11734
|
-
const allocatedHours = Number(row.allocatedHours ?? 0);
|
|
11735
|
-
const billableHours = Number(row.billableHours ?? 0);
|
|
11736
|
-
const allocation = availableHours ? (allocatedHours / availableHours) * 100 : 0;
|
|
11737
|
-
const risk = allocation >= 98 ? 'alto' : allocation < 75 ? 'médio' : 'baixo';
|
|
11738
|
-
return {
|
|
11739
|
-
id: Number(row.id),
|
|
11740
|
-
name: row.name,
|
|
11741
|
-
role: row.role ?? '-',
|
|
11742
|
-
seniority: row.seniority ?? '-',
|
|
11743
|
-
department: row.department ?? '-',
|
|
11744
|
-
contractType: row.contractType ?? '-',
|
|
11745
|
-
startDate: row.startDate,
|
|
11746
|
-
endDate: row.endDate,
|
|
11747
|
-
salaryCost,
|
|
11748
|
-
benefitsCost,
|
|
11749
|
-
taxesCost,
|
|
11750
|
-
toolsCost,
|
|
11751
|
-
billableValue: Number(row.billableValue ?? 0),
|
|
11752
|
-
availableHours,
|
|
11753
|
-
allocatedHours,
|
|
11754
|
-
billableHours,
|
|
11755
|
-
internalHours: Math.max(allocatedHours - billableHours, 0),
|
|
11756
|
-
overtimeHours: Math.max(allocatedHours - availableHours, 0),
|
|
11757
|
-
projects: Number(row.projects ?? 0),
|
|
11758
|
-
risk,
|
|
11759
|
-
recommendation:
|
|
11760
|
-
risk === 'alto'
|
|
11761
|
-
? 'Reduzir sobrecarga ou redistribuir entregas.'
|
|
11762
|
-
: risk === 'médio'
|
|
11763
|
-
? 'Acompanhar alocação e buscar maior aproveitamento faturável.'
|
|
11764
|
-
: 'Manter alocação e proteger margem.',
|
|
11765
|
-
};
|
|
11766
|
-
});
|
|
11767
|
-
|
|
11768
|
-
const summary = rows.reduce(
|
|
11769
|
-
(acc, row) => {
|
|
11770
|
-
acc.cost += row.salaryCost + row.benefitsCost + row.taxesCost + row.toolsCost;
|
|
11771
|
-
acc.salary += row.salaryCost;
|
|
11772
|
-
acc.benefits += row.benefitsCost;
|
|
11773
|
-
acc.taxes += row.taxesCost;
|
|
11774
|
-
acc.tools += row.toolsCost;
|
|
11775
|
-
acc.billableValue += row.billableValue;
|
|
11776
|
-
acc.availableHours += row.availableHours;
|
|
11777
|
-
acc.allocatedHours += row.allocatedHours;
|
|
11778
|
-
acc.billableHours += row.billableHours;
|
|
11779
|
-
acc.internalHours += row.internalHours;
|
|
11780
|
-
acc.overtimeHours += row.overtimeHours;
|
|
11781
|
-
acc.overloadCount += row.risk === 'alto' ? 1 : 0;
|
|
11782
|
-
return acc;
|
|
11783
|
-
},
|
|
11784
|
-
{
|
|
11785
|
-
cost: 0,
|
|
11786
|
-
salary: 0,
|
|
11787
|
-
benefits: 0,
|
|
11788
|
-
taxes: 0,
|
|
11789
|
-
tools: 0,
|
|
11790
|
-
billableValue: 0,
|
|
11791
|
-
profit: 0,
|
|
11792
|
-
margin: 0,
|
|
11793
|
-
availableHours: 0,
|
|
11794
|
-
allocatedHours: 0,
|
|
11795
|
-
billableHours: 0,
|
|
11796
|
-
internalHours: 0,
|
|
11797
|
-
overtimeHours: 0,
|
|
11798
|
-
freeHours: 0,
|
|
11799
|
-
allocation: 0,
|
|
11800
|
-
utilization: 0,
|
|
11801
|
-
overloadCount: 0,
|
|
11802
|
-
hourlyCost: 0,
|
|
11803
|
-
}
|
|
11804
|
-
);
|
|
11805
|
-
summary.profit = summary.billableValue - summary.cost;
|
|
11806
|
-
summary.margin = summary.billableValue ? (summary.profit / summary.billableValue) * 100 : 0;
|
|
11807
|
-
summary.freeHours = Math.max(summary.availableHours - summary.allocatedHours, 0);
|
|
11808
|
-
summary.allocation = summary.availableHours ? (summary.allocatedHours / summary.availableHours) * 100 : 0;
|
|
11809
|
-
summary.utilization = summary.availableHours ? (summary.billableHours / summary.availableHours) * 100 : 0;
|
|
11810
|
-
summary.hourlyCost = summary.allocatedHours ? summary.cost / summary.allocatedHours : 0;
|
|
11811
|
-
|
|
11812
|
-
const forecast = Array.from({ length: 12 }, (_, index) => {
|
|
11813
|
-
const monthDate = new Date(fromDate);
|
|
11814
|
-
monthDate.setMonth(fromDate.getMonth() + index);
|
|
11815
|
-
const revenue = Math.round((summary.billableValue / 12) * multiplier.revenue);
|
|
11816
|
-
const cost = Math.round((summary.cost / 12) * multiplier.cost);
|
|
11817
|
-
const profit = revenue - cost;
|
|
11818
|
-
return {
|
|
11819
|
-
month: monthDate.toLocaleDateString('pt-BR', { month: 'short' }).replace('.', ''),
|
|
11820
|
-
revenue,
|
|
11821
|
-
cost,
|
|
11822
|
-
profit,
|
|
11823
|
-
margin: revenue ? Math.round((profit / revenue) * 100) : 0,
|
|
11824
|
-
capacity: Math.round((summary.allocatedHours / 12) * multiplier.capacity),
|
|
11825
|
-
};
|
|
11826
|
-
});
|
|
11827
|
-
const departments = [...new Set(dbRows.map((row) => row.department ?? '-'))].sort();
|
|
11828
|
-
|
|
11829
|
-
return {
|
|
11830
|
-
filters: {
|
|
11831
|
-
from,
|
|
11832
|
-
to,
|
|
11833
|
-
department: filters.department ?? 'all',
|
|
11834
|
-
contractType: filters.contractType ?? 'all',
|
|
11835
|
-
scenario,
|
|
11836
|
-
departments,
|
|
11837
|
-
contractTypes: [...new Set(dbRows.map((row) => row.contractType ?? '-'))].sort(),
|
|
11838
|
-
},
|
|
11839
|
-
summary,
|
|
11840
|
-
forecast,
|
|
11841
|
-
costComposition: [
|
|
11842
|
-
{ name: 'Salários / Contratos', value: summary.salary },
|
|
11843
|
-
{ name: 'Benefícios', value: summary.benefits },
|
|
11844
|
-
{ name: 'Encargos', value: summary.taxes },
|
|
11845
|
-
{ name: 'Ferramentas', value: summary.tools },
|
|
11846
|
-
],
|
|
11847
|
-
capacityByDepartment: departments.map((department) => {
|
|
11848
|
-
const departmentRows = rows.filter((row) => row.department === department);
|
|
11849
|
-
const available = departmentRows.reduce((sum, row) => sum + row.availableHours, 0);
|
|
11850
|
-
const allocated = departmentRows.reduce((sum, row) => sum + row.allocatedHours, 0);
|
|
11851
|
-
return {
|
|
11852
|
-
department,
|
|
11853
|
-
Faturável: departmentRows.reduce((sum, row) => sum + row.billableHours, 0),
|
|
11854
|
-
Interno: departmentRows.reduce((sum, row) => sum + row.internalHours, 0),
|
|
11855
|
-
Livre: Math.max(available - allocated, 0),
|
|
11856
|
-
Sobrecarga: Math.max(allocated - available, 0),
|
|
11857
|
-
};
|
|
11858
|
-
}),
|
|
11859
|
-
health: departments.map((department) => {
|
|
11860
|
-
const departmentRows = rows.filter((row) => row.department === department);
|
|
11861
|
-
const value = departmentRows.reduce((sum, row) => sum + row.billableValue, 0);
|
|
11862
|
-
const cost = departmentRows.reduce((sum, row) => sum + row.salaryCost + row.benefitsCost + row.taxesCost + row.toolsCost, 0);
|
|
11863
|
-
const available = departmentRows.reduce((sum, row) => sum + row.availableHours, 0);
|
|
11864
|
-
const allocated = departmentRows.reduce((sum, row) => sum + row.allocatedHours, 0);
|
|
11865
|
-
const billable = departmentRows.reduce((sum, row) => sum + row.billableHours, 0);
|
|
11866
|
-
return {
|
|
11867
|
-
department,
|
|
11868
|
-
margem: Math.max(value ? ((value - cost) / value) * 100 : 0, 0),
|
|
11869
|
-
alocacao: available ? (allocated / available) * 100 : 0,
|
|
11870
|
-
utilizacao: available ? (billable / available) * 100 : 0,
|
|
11871
|
-
saude: Math.max(100 - departmentRows.filter((row) => row.risk === 'alto').length * 12, 45),
|
|
11872
|
-
};
|
|
11873
|
-
}),
|
|
11874
|
-
ranking: rows
|
|
11875
|
-
.map((row) => ({
|
|
11876
|
-
name: row.name.split(' ')[0],
|
|
11877
|
-
Custo: row.salaryCost + row.benefitsCost + row.taxesCost + row.toolsCost,
|
|
11878
|
-
Lucro: row.billableValue - (row.salaryCost + row.benefitsCost + row.taxesCost + row.toolsCost),
|
|
11879
|
-
}))
|
|
11880
|
-
.sort((a, b) => b.Lucro - a.Lucro),
|
|
11881
|
-
planningCards: [
|
|
11882
|
-
{ title: 'Contratar', value: String(summary.overloadCount), description: 'Pessoas em sobrecarga no recorte.' },
|
|
11883
|
-
{ title: 'Realocar', value: Math.round(summary.freeHours).toString(), description: 'Horas livres estimadas no período.' },
|
|
11884
|
-
{ title: 'Reduzir sobrecarga', value: Math.round(summary.overtimeHours).toString(), description: 'Horas acima da capacidade cadastrada.' },
|
|
11885
|
-
{ title: 'Aumentar venda', value: Math.round(summary.utilization).toString(), description: 'Percentual de utilização faturável.' },
|
|
11886
|
-
],
|
|
11887
|
-
rows,
|
|
11888
|
-
};
|
|
11889
|
-
}
|
|
11890
|
-
|
|
11891
|
-
async deleteCollaboratorCostById(userId: number, costId: number) {
|
|
11288
|
+
count: costs.length,
|
|
11289
|
+
};
|
|
11290
|
+
}
|
|
11291
|
+
|
|
11292
|
+
async getProjectsReport(
|
|
11293
|
+
userId: number,
|
|
11294
|
+
filters: {
|
|
11295
|
+
from?: string;
|
|
11296
|
+
to?: string;
|
|
11297
|
+
status?: string;
|
|
11298
|
+
client?: string;
|
|
11299
|
+
scenario?: string;
|
|
11300
|
+
} = {}
|
|
11301
|
+
) {
|
|
11302
|
+
const actor = await this.getActorContext(userId);
|
|
11303
|
+
this.ensureDirector(actor);
|
|
11304
|
+
|
|
11305
|
+
const currentYear = new Date().getFullYear();
|
|
11306
|
+
const from = /^\d{4}-\d{2}-\d{2}$/.test(String(filters.from ?? ''))
|
|
11307
|
+
? String(filters.from)
|
|
11308
|
+
: `${currentYear}-01-01`;
|
|
11309
|
+
const to = /^\d{4}-\d{2}-\d{2}$/.test(String(filters.to ?? ''))
|
|
11310
|
+
? String(filters.to)
|
|
11311
|
+
: `${currentYear}-12-31`;
|
|
11312
|
+
const scenario =
|
|
11313
|
+
filters.scenario === 'growth' || filters.scenario === 'conservative'
|
|
11314
|
+
? filters.scenario
|
|
11315
|
+
: 'base';
|
|
11316
|
+
const multiplier =
|
|
11317
|
+
scenario === 'growth'
|
|
11318
|
+
? { revenue: 1.16, cost: 1.09, backlog: 1.22 }
|
|
11319
|
+
: scenario === 'conservative'
|
|
11320
|
+
? { revenue: 0.9, cost: 0.96, backlog: 0.82 }
|
|
11321
|
+
: { revenue: 1, cost: 1, backlog: 1 };
|
|
11322
|
+
|
|
11323
|
+
const params: unknown[] = [from, to];
|
|
11324
|
+
const where = [
|
|
11325
|
+
'p.deleted_at IS NULL',
|
|
11326
|
+
'(p.end_date IS NULL OR p.end_date >= $1::date)',
|
|
11327
|
+
'(p.start_date IS NULL OR p.start_date <= $2::date)',
|
|
11328
|
+
];
|
|
11329
|
+
if (filters.client && filters.client !== 'all') {
|
|
11330
|
+
where.push(
|
|
11331
|
+
`COALESCE(NULLIF(p.client_name, ''), NULLIF(contract_record.client_name, ''), '-') = ${this.param(params, filters.client)}`
|
|
11332
|
+
);
|
|
11333
|
+
}
|
|
11334
|
+
|
|
11335
|
+
const dbRows = await this.queryRows<{
|
|
11336
|
+
id: number;
|
|
11337
|
+
name: string;
|
|
11338
|
+
client: string | null;
|
|
11339
|
+
manager: string | null;
|
|
11340
|
+
squad: string | null;
|
|
11341
|
+
status: string;
|
|
11342
|
+
contractType: string | null;
|
|
11343
|
+
startDate: string | null;
|
|
11344
|
+
endDate: string | null;
|
|
11345
|
+
contractedRevenue: string | null;
|
|
11346
|
+
progressPercent: string | null;
|
|
11347
|
+
weeklyHours: string | null;
|
|
11348
|
+
actualHours: string | null;
|
|
11349
|
+
billableHours: string | null;
|
|
11350
|
+
openTasks: string | null;
|
|
11351
|
+
backlogHours: string | null;
|
|
11352
|
+
futureDeliveries: string | null;
|
|
11353
|
+
}>(
|
|
11354
|
+
`SELECT p.id,
|
|
11355
|
+
p.name,
|
|
11356
|
+
COALESCE(NULLIF(p.client_name, ''), NULLIF(contract_record.client_name, ''), '-') AS client,
|
|
11357
|
+
COALESCE(NULLIF(manager_record.display_name, ''), '-') AS manager,
|
|
11358
|
+
COALESCE(NULLIF(p.delivery_model::text, ''), '-') AS squad,
|
|
11359
|
+
p.status::text AS status,
|
|
11360
|
+
COALESCE(contract_record.billing_model::text, p.delivery_model::text, 'fixed_price') AS "contractType",
|
|
11361
|
+
TO_CHAR(p.start_date, 'YYYY-MM-DD') AS "startDate",
|
|
11362
|
+
TO_CHAR(p.end_date, 'YYYY-MM-DD') AS "endDate",
|
|
11363
|
+
COALESCE(p.budget_amount, contract_record.budget_amount, 0)::text AS "contractedRevenue",
|
|
11364
|
+
COALESCE(p.progress_percent, 0)::text AS "progressPercent",
|
|
11365
|
+
COALESCE(assignment_stats.weekly_hours, 0)::text AS "weeklyHours",
|
|
11366
|
+
COALESCE(time_stats.actual_hours, 0)::text AS "actualHours",
|
|
11367
|
+
COALESCE(time_stats.billable_hours, 0)::text AS "billableHours",
|
|
11368
|
+
COALESCE(task_stats.open_tasks, 0)::text AS "openTasks",
|
|
11369
|
+
COALESCE(task_stats.backlog_hours, 0)::text AS "backlogHours",
|
|
11370
|
+
COALESCE(task_stats.future_deliveries, 0)::text AS "futureDeliveries"
|
|
11371
|
+
FROM operations_project p
|
|
11372
|
+
LEFT JOIN operations_contract contract_record
|
|
11373
|
+
ON contract_record.id = p.contract_id
|
|
11374
|
+
AND contract_record.deleted_at IS NULL
|
|
11375
|
+
LEFT JOIN operations_collaborator manager_record
|
|
11376
|
+
ON manager_record.id = p.manager_collaborator_id
|
|
11377
|
+
AND manager_record.deleted_at IS NULL
|
|
11378
|
+
LEFT JOIN LATERAL (
|
|
11379
|
+
SELECT COALESCE(SUM(pa.weekly_hours), 0) AS weekly_hours
|
|
11380
|
+
FROM operations_project_assignment pa
|
|
11381
|
+
WHERE pa.project_id = p.id
|
|
11382
|
+
AND pa.deleted_at IS NULL
|
|
11383
|
+
AND pa.status IN ('planned', 'active')
|
|
11384
|
+
) assignment_stats ON TRUE
|
|
11385
|
+
LEFT JOIN LATERAL (
|
|
11386
|
+
SELECT COALESCE(SUM(entry.hours), 0) AS actual_hours,
|
|
11387
|
+
COALESCE(SUM(entry.hours) FILTER (WHERE pa.is_billable = true), 0) AS billable_hours
|
|
11388
|
+
FROM operations_timesheet_entry entry
|
|
11389
|
+
JOIN operations_project_assignment pa
|
|
11390
|
+
ON pa.id = entry.project_assignment_id
|
|
11391
|
+
AND pa.deleted_at IS NULL
|
|
11392
|
+
WHERE pa.project_id = p.id
|
|
11393
|
+
AND entry.deleted_at IS NULL
|
|
11394
|
+
AND entry.work_date BETWEEN $1::date AND $2::date
|
|
11395
|
+
) time_stats ON TRUE
|
|
11396
|
+
LEFT JOIN LATERAL (
|
|
11397
|
+
SELECT COUNT(*) FILTER (WHERE task.status IN ('todo', 'doing', 'review')) AS open_tasks,
|
|
11398
|
+
COALESCE(SUM(task.estimate_hours) FILTER (WHERE task.status IN ('todo', 'doing', 'review')), 0) AS backlog_hours,
|
|
11399
|
+
COUNT(*) FILTER (WHERE task.due_date > $2::date AND task.status IN ('todo', 'doing', 'review')) AS future_deliveries
|
|
11400
|
+
FROM operations_task task
|
|
11401
|
+
WHERE task.project_id = p.id
|
|
11402
|
+
AND task.deleted_at IS NULL
|
|
11403
|
+
) task_stats ON TRUE
|
|
11404
|
+
WHERE ${where.join(' AND ')}
|
|
11405
|
+
ORDER BY p.name ASC`,
|
|
11406
|
+
params
|
|
11407
|
+
);
|
|
11408
|
+
|
|
11409
|
+
const fromDate = new Date(`${from}T00:00:00`);
|
|
11410
|
+
const toDate = new Date(`${to}T00:00:00`);
|
|
11411
|
+
const periodWeeks = Math.max(
|
|
11412
|
+
1,
|
|
11413
|
+
Math.ceil((toDate.getTime() - fromDate.getTime()) / 604800000)
|
|
11414
|
+
);
|
|
11415
|
+
const rows = dbRows
|
|
11416
|
+
.map((row) => {
|
|
11417
|
+
const progress = Number(row.progressPercent ?? 0);
|
|
11418
|
+
const contractedRevenue = Number(row.contractedRevenue ?? 0);
|
|
11419
|
+
const recognizedRevenue = contractedRevenue * (progress / 100);
|
|
11420
|
+
const actualHours = Number(row.actualHours ?? 0);
|
|
11421
|
+
const plannedHours = Math.max(Number(row.weeklyHours ?? 0) * periodWeeks, actualHours);
|
|
11422
|
+
const realizedCost = 0;
|
|
11423
|
+
const reportStatus =
|
|
11424
|
+
row.status === 'paused'
|
|
11425
|
+
? 'paused'
|
|
11426
|
+
: row.status === 'at_risk'
|
|
11427
|
+
? 'attention'
|
|
11428
|
+
: row.endDate && new Date(`${row.endDate}T00:00:00`) < new Date() && progress < 100
|
|
11429
|
+
? 'late'
|
|
11430
|
+
: 'on_track';
|
|
11431
|
+
const risk =
|
|
11432
|
+
reportStatus === 'late' || (plannedHours && actualHours / plannedHours > 1)
|
|
11433
|
+
? 'alto'
|
|
11434
|
+
: reportStatus === 'attention'
|
|
11435
|
+
? 'médio'
|
|
11436
|
+
: 'baixo';
|
|
11437
|
+
|
|
11438
|
+
return {
|
|
11439
|
+
id: Number(row.id),
|
|
11440
|
+
name: row.name,
|
|
11441
|
+
client: row.client ?? '-',
|
|
11442
|
+
manager: row.manager ?? '-',
|
|
11443
|
+
squad: String(row.squad ?? '-').replace(/_/g, ' '),
|
|
11444
|
+
status: reportStatus,
|
|
11445
|
+
contractType:
|
|
11446
|
+
row.contractType === 'monthly_retainer'
|
|
11447
|
+
? 'retainer'
|
|
11448
|
+
: row.contractType === 'time_and_material'
|
|
11449
|
+
? 'time_materials'
|
|
11450
|
+
: 'fixed_price',
|
|
11451
|
+
priority: risk === 'alto' ? 'alta' : risk === 'médio' ? 'média' : 'baixa',
|
|
11452
|
+
startDate: row.startDate,
|
|
11453
|
+
endDate: row.endDate,
|
|
11454
|
+
contractedRevenue,
|
|
11455
|
+
recognizedRevenue,
|
|
11456
|
+
realizedCost,
|
|
11457
|
+
forecastCost: realizedCost,
|
|
11458
|
+
teamCost: realizedCost,
|
|
11459
|
+
infraCost: 0,
|
|
11460
|
+
licenseCost: 0,
|
|
11461
|
+
thirdPartyCost: 0,
|
|
11462
|
+
reworkCost: 0,
|
|
11463
|
+
plannedHours,
|
|
11464
|
+
actualHours,
|
|
11465
|
+
billableHours: Number(row.billableHours ?? 0),
|
|
11466
|
+
reworkHours: 0,
|
|
11467
|
+
internalHours: Math.max(actualHours - Number(row.billableHours ?? 0), 0),
|
|
11468
|
+
allocatedCapacity: plannedHours ? (actualHours / plannedHours) * 100 : 0,
|
|
11469
|
+
physicalProgress: progress,
|
|
11470
|
+
financialProgress: contractedRevenue ? (recognizedRevenue / contractedRevenue) * 100 : 0,
|
|
11471
|
+
backlogValue: Math.max(contractedRevenue - recognizedRevenue, 0),
|
|
11472
|
+
futureDeliveries: Number(row.futureDeliveries ?? 0),
|
|
11473
|
+
risk,
|
|
11474
|
+
recommendation:
|
|
11475
|
+
risk === 'alto'
|
|
11476
|
+
? 'Revisar escopo, prazo ou capacidade alocada.'
|
|
11477
|
+
: risk === 'médio'
|
|
11478
|
+
? 'Acompanhar margem, entregas e consumo de horas.'
|
|
11479
|
+
: 'Manter ritmo e proteger capacidade planejada.',
|
|
11480
|
+
};
|
|
11481
|
+
})
|
|
11482
|
+
.filter((row) => !filters.status || filters.status === 'all' || row.status === filters.status);
|
|
11483
|
+
|
|
11484
|
+
const summary = rows.reduce(
|
|
11485
|
+
(acc, row) => {
|
|
11486
|
+
acc.contractedRevenue += row.contractedRevenue;
|
|
11487
|
+
acc.recognizedRevenue += row.recognizedRevenue;
|
|
11488
|
+
acc.realizedCost += row.realizedCost;
|
|
11489
|
+
acc.forecastCost += row.forecastCost;
|
|
11490
|
+
acc.plannedHours += row.plannedHours;
|
|
11491
|
+
acc.actualHours += row.actualHours;
|
|
11492
|
+
acc.billableHours += row.billableHours;
|
|
11493
|
+
acc.reworkHours += row.reworkHours;
|
|
11494
|
+
acc.backlogValue += row.backlogValue;
|
|
11495
|
+
acc.avgDeadline += row.physicalProgress;
|
|
11496
|
+
acc.avgAllocation += row.allocatedCapacity;
|
|
11497
|
+
acc.atRisk += row.risk === 'alto' ? 1 : 0;
|
|
11498
|
+
return acc;
|
|
11499
|
+
},
|
|
11500
|
+
{
|
|
11501
|
+
contractedRevenue: 0,
|
|
11502
|
+
recognizedRevenue: 0,
|
|
11503
|
+
realizedCost: 0,
|
|
11504
|
+
forecastCost: 0,
|
|
11505
|
+
profit: 0,
|
|
11506
|
+
margin: 0,
|
|
11507
|
+
plannedHours: 0,
|
|
11508
|
+
actualHours: 0,
|
|
11509
|
+
billableHours: 0,
|
|
11510
|
+
reworkHours: 0,
|
|
11511
|
+
backlogValue: 0,
|
|
11512
|
+
avgDeadline: 0,
|
|
11513
|
+
avgAllocation: 0,
|
|
11514
|
+
atRisk: 0,
|
|
11515
|
+
burnRate: 0,
|
|
11516
|
+
}
|
|
11517
|
+
);
|
|
11518
|
+
summary.profit = summary.recognizedRevenue - summary.realizedCost;
|
|
11519
|
+
summary.margin = summary.recognizedRevenue ? (summary.profit / summary.recognizedRevenue) * 100 : 0;
|
|
11520
|
+
summary.avgDeadline = rows.length ? summary.avgDeadline / rows.length : 0;
|
|
11521
|
+
summary.avgAllocation = rows.length ? summary.avgAllocation / rows.length : 0;
|
|
11522
|
+
summary.burnRate = summary.plannedHours ? (summary.actualHours / summary.plannedHours) * 100 : 0;
|
|
11523
|
+
|
|
11524
|
+
const forecast = Array.from({ length: 12 }, (_, index) => {
|
|
11525
|
+
const monthDate = new Date(fromDate);
|
|
11526
|
+
monthDate.setMonth(fromDate.getMonth() + index);
|
|
11527
|
+
const revenue = Math.round((summary.recognizedRevenue / 12) * multiplier.revenue);
|
|
11528
|
+
const cost = Math.round((summary.realizedCost / 12) * multiplier.cost);
|
|
11529
|
+
const profit = revenue - cost;
|
|
11530
|
+
return {
|
|
11531
|
+
month: monthDate.toLocaleDateString('pt-BR', { month: 'short' }).replace('.', ''),
|
|
11532
|
+
revenue,
|
|
11533
|
+
cost,
|
|
11534
|
+
profit,
|
|
11535
|
+
backlog: Math.round(Math.max(profit, 0) * multiplier.backlog),
|
|
11536
|
+
planned: Math.round(summary.plannedHours / 12),
|
|
11537
|
+
actual: Math.round(summary.actualHours / 12),
|
|
11538
|
+
};
|
|
11539
|
+
});
|
|
11540
|
+
|
|
11541
|
+
return {
|
|
11542
|
+
filters: {
|
|
11543
|
+
from,
|
|
11544
|
+
to,
|
|
11545
|
+
status: filters.status ?? 'all',
|
|
11546
|
+
client: filters.client ?? 'all',
|
|
11547
|
+
scenario,
|
|
11548
|
+
clients: [...new Set(dbRows.map((row) => row.client ?? '-'))].sort(),
|
|
11549
|
+
},
|
|
11550
|
+
summary,
|
|
11551
|
+
forecast,
|
|
11552
|
+
costComposition: [
|
|
11553
|
+
{ name: 'Equipe', value: summary.realizedCost },
|
|
11554
|
+
{ name: 'Infraestrutura', value: 0 },
|
|
11555
|
+
{ name: 'Licenças', value: 0 },
|
|
11556
|
+
{ name: 'Terceiros', value: 0 },
|
|
11557
|
+
{ name: 'Retrabalho', value: 0 },
|
|
11558
|
+
],
|
|
11559
|
+
hoursByProject: rows.map((row) => ({
|
|
11560
|
+
project: row.name.split(' ').slice(0, 2).join(' '),
|
|
11561
|
+
Faturável: row.billableHours,
|
|
11562
|
+
Interno: row.internalHours,
|
|
11563
|
+
Retrabalho: row.reworkHours,
|
|
11564
|
+
Livre: Math.max(row.plannedHours - row.actualHours, 0),
|
|
11565
|
+
})),
|
|
11566
|
+
health: rows.map((row) => ({
|
|
11567
|
+
project: row.name.split(' ').slice(0, 2).join(' '),
|
|
11568
|
+
margem: Math.max(row.recognizedRevenue ? ((row.recognizedRevenue - row.realizedCost) / row.recognizedRevenue) * 100 : 0, 0),
|
|
11569
|
+
prazo: row.physicalProgress,
|
|
11570
|
+
alocacao: row.allocatedCapacity,
|
|
11571
|
+
saude: Math.max(100 - (row.risk === 'alto' ? 28 : row.risk === 'médio' ? 14 : 0), 35),
|
|
11572
|
+
})),
|
|
11573
|
+
ranking: rows
|
|
11574
|
+
.map((row) => ({
|
|
11575
|
+
name: row.name.split(' ').slice(0, 2).join(' '),
|
|
11576
|
+
Lucro: row.recognizedRevenue - row.realizedCost,
|
|
11577
|
+
Custo: row.realizedCost,
|
|
11578
|
+
}))
|
|
11579
|
+
.sort((a, b) => b.Lucro - a.Lucro),
|
|
11580
|
+
progress: forecast.map((row) => ({
|
|
11581
|
+
month: row.month,
|
|
11582
|
+
Planejado: row.planned,
|
|
11583
|
+
Realizado: row.actual,
|
|
11584
|
+
})),
|
|
11585
|
+
planningCards: [
|
|
11586
|
+
{ title: 'Acelerar críticas', value: String(summary.atRisk), description: 'Projetos com risco alto devem receber revisão de prazo e capacidade.' },
|
|
11587
|
+
{ title: 'Renegociar escopo', value: Math.round(summary.backlogValue).toString(), description: 'Backlog financeiro ainda não reconhecido no período.' },
|
|
11588
|
+
{ title: 'Realocar equipe', value: Math.round(Math.max(summary.plannedHours - summary.actualHours, 0)).toString(), description: 'Horas planejadas ainda não consumidas.' },
|
|
11589
|
+
{ title: 'Priorizar margem', value: rows.filter((row) => row.recognizedRevenue > row.realizedCost).length.toString(), description: 'Projetos com contribuição positiva no recorte.' },
|
|
11590
|
+
],
|
|
11591
|
+
rows,
|
|
11592
|
+
};
|
|
11593
|
+
}
|
|
11594
|
+
|
|
11595
|
+
async getCollaboratorsReport(
|
|
11596
|
+
userId: number,
|
|
11597
|
+
filters: {
|
|
11598
|
+
from?: string;
|
|
11599
|
+
to?: string;
|
|
11600
|
+
department?: string;
|
|
11601
|
+
contractType?: string;
|
|
11602
|
+
scenario?: string;
|
|
11603
|
+
} = {}
|
|
11604
|
+
) {
|
|
11605
|
+
const actor = await this.getActorContext(userId);
|
|
11606
|
+
this.ensureDirector(actor);
|
|
11607
|
+
|
|
11608
|
+
const currentYear = new Date().getFullYear();
|
|
11609
|
+
const from = /^\d{4}-\d{2}-\d{2}$/.test(String(filters.from ?? ''))
|
|
11610
|
+
? String(filters.from)
|
|
11611
|
+
: `${currentYear}-01-01`;
|
|
11612
|
+
const to = /^\d{4}-\d{2}-\d{2}$/.test(String(filters.to ?? ''))
|
|
11613
|
+
? String(filters.to)
|
|
11614
|
+
: `${currentYear}-12-31`;
|
|
11615
|
+
const scenario =
|
|
11616
|
+
filters.scenario === 'growth' || filters.scenario === 'conservative'
|
|
11617
|
+
? filters.scenario
|
|
11618
|
+
: 'base';
|
|
11619
|
+
const multiplier =
|
|
11620
|
+
scenario === 'growth'
|
|
11621
|
+
? { revenue: 1.18, cost: 1.11, capacity: 1.08 }
|
|
11622
|
+
: scenario === 'conservative'
|
|
11623
|
+
? { revenue: 0.9, cost: 0.96, capacity: 0.94 }
|
|
11624
|
+
: { revenue: 1, cost: 1, capacity: 1 };
|
|
11625
|
+
const params: unknown[] = [from, to];
|
|
11626
|
+
const where = [
|
|
11627
|
+
'c.deleted_at IS NULL',
|
|
11628
|
+
'(c.joined_at IS NULL OR c.joined_at <= $2::date)',
|
|
11629
|
+
'(c.left_at IS NULL OR c.left_at >= $1::date)',
|
|
11630
|
+
];
|
|
11631
|
+
|
|
11632
|
+
if (filters.department && filters.department !== 'all') {
|
|
11633
|
+
where.push(`COALESCE(department_record.name, '-') = ${this.param(params, filters.department)}`);
|
|
11634
|
+
}
|
|
11635
|
+
|
|
11636
|
+
if (filters.contractType && filters.contractType !== 'all') {
|
|
11637
|
+
where.push(`COALESCE(collaborator_type.name, collaborator_type.slug, '-') = ${this.param(params, filters.contractType)}`);
|
|
11638
|
+
}
|
|
11639
|
+
|
|
11640
|
+
const dbRows = await this.queryRows<{
|
|
11641
|
+
id: number;
|
|
11642
|
+
name: string;
|
|
11643
|
+
role: string | null;
|
|
11644
|
+
seniority: string | null;
|
|
11645
|
+
department: string | null;
|
|
11646
|
+
contractType: string | null;
|
|
11647
|
+
startDate: string | null;
|
|
11648
|
+
endDate: string | null;
|
|
11649
|
+
weeklyCapacityHours: string | null;
|
|
11650
|
+
salaryCost: string | null;
|
|
11651
|
+
benefitsCost: string | null;
|
|
11652
|
+
taxesCost: string | null;
|
|
11653
|
+
toolsCost: string | null;
|
|
11654
|
+
billableValue: string | null;
|
|
11655
|
+
allocatedHours: string | null;
|
|
11656
|
+
billableHours: string | null;
|
|
11657
|
+
projects: string | null;
|
|
11658
|
+
}>(
|
|
11659
|
+
`SELECT c.id,
|
|
11660
|
+
COALESCE(NULLIF(c.display_name, ''), person_record.name, c.code) AS name,
|
|
11661
|
+
COALESCE(job_title_record.name, c.title, '-') AS role,
|
|
11662
|
+
COALESCE(c.level_label, '-') AS seniority,
|
|
11663
|
+
COALESCE(department_record.name, '-') AS department,
|
|
11664
|
+
COALESCE(collaborator_type.name, collaborator_type.slug, '-') AS "contractType",
|
|
11665
|
+
TO_CHAR(c.joined_at, 'YYYY-MM-DD') AS "startDate",
|
|
11666
|
+
TO_CHAR(c.left_at, 'YYYY-MM-DD') AS "endDate",
|
|
11667
|
+
COALESCE(c.weekly_capacity_hours, 40)::text AS "weeklyCapacityHours",
|
|
11668
|
+
COALESCE(cost_stats.salary_cost, 0)::text AS "salaryCost",
|
|
11669
|
+
COALESCE(cost_stats.benefits_cost, 0)::text AS "benefitsCost",
|
|
11670
|
+
COALESCE(cost_stats.taxes_cost, 0)::text AS "taxesCost",
|
|
11671
|
+
COALESCE(cost_stats.tools_cost, 0)::text AS "toolsCost",
|
|
11672
|
+
COALESCE(value_stats.billable_value, 0)::text AS "billableValue",
|
|
11673
|
+
COALESCE(value_stats.allocated_hours, 0)::text AS "allocatedHours",
|
|
11674
|
+
COALESCE(value_stats.billable_hours, 0)::text AS "billableHours",
|
|
11675
|
+
COALESCE(project_stats.projects, 0)::text AS projects
|
|
11676
|
+
FROM operations_collaborator c
|
|
11677
|
+
LEFT JOIN person person_record ON person_record.id = c.person_id
|
|
11678
|
+
LEFT JOIN operations_department department_record
|
|
11679
|
+
ON department_record.id = c.department_id
|
|
11680
|
+
AND department_record.deleted_at IS NULL
|
|
11681
|
+
LEFT JOIN operations_job_title job_title_record
|
|
11682
|
+
ON job_title_record.id = c.job_title_id
|
|
11683
|
+
AND job_title_record.deleted_at IS NULL
|
|
11684
|
+
LEFT JOIN operations_collaborator_type collaborator_type
|
|
11685
|
+
ON collaborator_type.id = c.collaborator_type_id
|
|
11686
|
+
AND collaborator_type.deleted_at IS NULL
|
|
11687
|
+
LEFT JOIN LATERAL (
|
|
11688
|
+
SELECT COALESCE(SUM(cost.amount) FILTER (WHERE cost.recurrence::text = 'monthly' AND cost_type.slug IN ('salario-base', 'pro-labore')), 0) AS salary_cost,
|
|
11689
|
+
COALESCE(SUM(cost.amount) FILTER (WHERE cost.recurrence::text = 'monthly' AND cost_type.slug IN ('vale-refeicao', 'vale-alimentacao', 'vale-transporte', 'plano-saude', 'plano-odontologico', 'seguro-vida')), 0) AS benefits_cost,
|
|
11690
|
+
COALESCE(SUM(cost.amount) FILTER (WHERE cost.recurrence::text = 'monthly' AND cost_type.slug IN ('inss-patronal', 'fgts', 'rat-fap', 'terceiros-sistema-s', 'provisao-decimo-terceiro', 'provisao-ferias')), 0) AS taxes_cost,
|
|
11691
|
+
COALESCE(SUM(cost.amount) FILTER (WHERE cost.recurrence::text = 'monthly' AND cost_type.slug IN ('software-licenca', 'equipamento')), 0) AS tools_cost
|
|
11692
|
+
FROM operations_collaborator_cost cost
|
|
11693
|
+
LEFT JOIN operations_cost_type cost_type ON cost_type.id = cost.cost_type_id
|
|
11694
|
+
WHERE cost.collaborator_id = c.id
|
|
11695
|
+
AND (cost.start_date IS NULL OR cost.start_date <= $2::date)
|
|
11696
|
+
AND (cost.end_date IS NULL OR cost.end_date >= $1::date)
|
|
11697
|
+
) cost_stats ON TRUE
|
|
11698
|
+
LEFT JOIN LATERAL (
|
|
11699
|
+
SELECT COALESCE(SUM(entry.hours), 0) AS allocated_hours,
|
|
11700
|
+
COALESCE(SUM(entry.hours) FILTER (WHERE pa.is_billable = true), 0) AS billable_hours,
|
|
11701
|
+
COALESCE(SUM(entry.hours) FILTER (WHERE pa.is_billable = true) * 120, 0) AS billable_value
|
|
11702
|
+
FROM operations_timesheet_entry entry
|
|
11703
|
+
JOIN operations_project_assignment pa
|
|
11704
|
+
ON pa.id = entry.project_assignment_id
|
|
11705
|
+
AND pa.deleted_at IS NULL
|
|
11706
|
+
WHERE pa.collaborator_id = c.id
|
|
11707
|
+
AND entry.deleted_at IS NULL
|
|
11708
|
+
AND entry.work_date BETWEEN $1::date AND $2::date
|
|
11709
|
+
) value_stats ON TRUE
|
|
11710
|
+
LEFT JOIN LATERAL (
|
|
11711
|
+
SELECT COUNT(DISTINCT pa.project_id) AS projects
|
|
11712
|
+
FROM operations_project_assignment pa
|
|
11713
|
+
WHERE pa.collaborator_id = c.id
|
|
11714
|
+
AND pa.deleted_at IS NULL
|
|
11715
|
+
AND pa.status IN ('planned', 'active')
|
|
11716
|
+
) project_stats ON TRUE
|
|
11717
|
+
WHERE ${where.join(' AND ')}
|
|
11718
|
+
ORDER BY name ASC`,
|
|
11719
|
+
params
|
|
11720
|
+
);
|
|
11721
|
+
|
|
11722
|
+
const fromDate = new Date(`${from}T00:00:00`);
|
|
11723
|
+
const toDate = new Date(`${to}T00:00:00`);
|
|
11724
|
+
const periodWeeks = Math.max(
|
|
11725
|
+
1,
|
|
11726
|
+
Math.ceil((toDate.getTime() - fromDate.getTime()) / 604800000)
|
|
11727
|
+
);
|
|
11728
|
+
const rows = dbRows.map((row) => {
|
|
11729
|
+
const salaryCost = Number(row.salaryCost ?? 0);
|
|
11730
|
+
const benefitsCost = Number(row.benefitsCost ?? 0);
|
|
11731
|
+
const taxesCost = Number(row.taxesCost ?? 0);
|
|
11732
|
+
const toolsCost = Number(row.toolsCost ?? 0);
|
|
11733
|
+
const availableHours = Number(row.weeklyCapacityHours ?? 40) * periodWeeks;
|
|
11734
|
+
const allocatedHours = Number(row.allocatedHours ?? 0);
|
|
11735
|
+
const billableHours = Number(row.billableHours ?? 0);
|
|
11736
|
+
const allocation = availableHours ? (allocatedHours / availableHours) * 100 : 0;
|
|
11737
|
+
const risk = allocation >= 98 ? 'alto' : allocation < 75 ? 'médio' : 'baixo';
|
|
11738
|
+
return {
|
|
11739
|
+
id: Number(row.id),
|
|
11740
|
+
name: row.name,
|
|
11741
|
+
role: row.role ?? '-',
|
|
11742
|
+
seniority: row.seniority ?? '-',
|
|
11743
|
+
department: row.department ?? '-',
|
|
11744
|
+
contractType: row.contractType ?? '-',
|
|
11745
|
+
startDate: row.startDate,
|
|
11746
|
+
endDate: row.endDate,
|
|
11747
|
+
salaryCost,
|
|
11748
|
+
benefitsCost,
|
|
11749
|
+
taxesCost,
|
|
11750
|
+
toolsCost,
|
|
11751
|
+
billableValue: Number(row.billableValue ?? 0),
|
|
11752
|
+
availableHours,
|
|
11753
|
+
allocatedHours,
|
|
11754
|
+
billableHours,
|
|
11755
|
+
internalHours: Math.max(allocatedHours - billableHours, 0),
|
|
11756
|
+
overtimeHours: Math.max(allocatedHours - availableHours, 0),
|
|
11757
|
+
projects: Number(row.projects ?? 0),
|
|
11758
|
+
risk,
|
|
11759
|
+
recommendation:
|
|
11760
|
+
risk === 'alto'
|
|
11761
|
+
? 'Reduzir sobrecarga ou redistribuir entregas.'
|
|
11762
|
+
: risk === 'médio'
|
|
11763
|
+
? 'Acompanhar alocação e buscar maior aproveitamento faturável.'
|
|
11764
|
+
: 'Manter alocação e proteger margem.',
|
|
11765
|
+
};
|
|
11766
|
+
});
|
|
11767
|
+
|
|
11768
|
+
const summary = rows.reduce(
|
|
11769
|
+
(acc, row) => {
|
|
11770
|
+
acc.cost += row.salaryCost + row.benefitsCost + row.taxesCost + row.toolsCost;
|
|
11771
|
+
acc.salary += row.salaryCost;
|
|
11772
|
+
acc.benefits += row.benefitsCost;
|
|
11773
|
+
acc.taxes += row.taxesCost;
|
|
11774
|
+
acc.tools += row.toolsCost;
|
|
11775
|
+
acc.billableValue += row.billableValue;
|
|
11776
|
+
acc.availableHours += row.availableHours;
|
|
11777
|
+
acc.allocatedHours += row.allocatedHours;
|
|
11778
|
+
acc.billableHours += row.billableHours;
|
|
11779
|
+
acc.internalHours += row.internalHours;
|
|
11780
|
+
acc.overtimeHours += row.overtimeHours;
|
|
11781
|
+
acc.overloadCount += row.risk === 'alto' ? 1 : 0;
|
|
11782
|
+
return acc;
|
|
11783
|
+
},
|
|
11784
|
+
{
|
|
11785
|
+
cost: 0,
|
|
11786
|
+
salary: 0,
|
|
11787
|
+
benefits: 0,
|
|
11788
|
+
taxes: 0,
|
|
11789
|
+
tools: 0,
|
|
11790
|
+
billableValue: 0,
|
|
11791
|
+
profit: 0,
|
|
11792
|
+
margin: 0,
|
|
11793
|
+
availableHours: 0,
|
|
11794
|
+
allocatedHours: 0,
|
|
11795
|
+
billableHours: 0,
|
|
11796
|
+
internalHours: 0,
|
|
11797
|
+
overtimeHours: 0,
|
|
11798
|
+
freeHours: 0,
|
|
11799
|
+
allocation: 0,
|
|
11800
|
+
utilization: 0,
|
|
11801
|
+
overloadCount: 0,
|
|
11802
|
+
hourlyCost: 0,
|
|
11803
|
+
}
|
|
11804
|
+
);
|
|
11805
|
+
summary.profit = summary.billableValue - summary.cost;
|
|
11806
|
+
summary.margin = summary.billableValue ? (summary.profit / summary.billableValue) * 100 : 0;
|
|
11807
|
+
summary.freeHours = Math.max(summary.availableHours - summary.allocatedHours, 0);
|
|
11808
|
+
summary.allocation = summary.availableHours ? (summary.allocatedHours / summary.availableHours) * 100 : 0;
|
|
11809
|
+
summary.utilization = summary.availableHours ? (summary.billableHours / summary.availableHours) * 100 : 0;
|
|
11810
|
+
summary.hourlyCost = summary.allocatedHours ? summary.cost / summary.allocatedHours : 0;
|
|
11811
|
+
|
|
11812
|
+
const forecast = Array.from({ length: 12 }, (_, index) => {
|
|
11813
|
+
const monthDate = new Date(fromDate);
|
|
11814
|
+
monthDate.setMonth(fromDate.getMonth() + index);
|
|
11815
|
+
const revenue = Math.round((summary.billableValue / 12) * multiplier.revenue);
|
|
11816
|
+
const cost = Math.round((summary.cost / 12) * multiplier.cost);
|
|
11817
|
+
const profit = revenue - cost;
|
|
11818
|
+
return {
|
|
11819
|
+
month: monthDate.toLocaleDateString('pt-BR', { month: 'short' }).replace('.', ''),
|
|
11820
|
+
revenue,
|
|
11821
|
+
cost,
|
|
11822
|
+
profit,
|
|
11823
|
+
margin: revenue ? Math.round((profit / revenue) * 100) : 0,
|
|
11824
|
+
capacity: Math.round((summary.allocatedHours / 12) * multiplier.capacity),
|
|
11825
|
+
};
|
|
11826
|
+
});
|
|
11827
|
+
const departments = [...new Set(dbRows.map((row) => row.department ?? '-'))].sort();
|
|
11828
|
+
|
|
11829
|
+
return {
|
|
11830
|
+
filters: {
|
|
11831
|
+
from,
|
|
11832
|
+
to,
|
|
11833
|
+
department: filters.department ?? 'all',
|
|
11834
|
+
contractType: filters.contractType ?? 'all',
|
|
11835
|
+
scenario,
|
|
11836
|
+
departments,
|
|
11837
|
+
contractTypes: [...new Set(dbRows.map((row) => row.contractType ?? '-'))].sort(),
|
|
11838
|
+
},
|
|
11839
|
+
summary,
|
|
11840
|
+
forecast,
|
|
11841
|
+
costComposition: [
|
|
11842
|
+
{ name: 'Salários / Contratos', value: summary.salary },
|
|
11843
|
+
{ name: 'Benefícios', value: summary.benefits },
|
|
11844
|
+
{ name: 'Encargos', value: summary.taxes },
|
|
11845
|
+
{ name: 'Ferramentas', value: summary.tools },
|
|
11846
|
+
],
|
|
11847
|
+
capacityByDepartment: departments.map((department) => {
|
|
11848
|
+
const departmentRows = rows.filter((row) => row.department === department);
|
|
11849
|
+
const available = departmentRows.reduce((sum, row) => sum + row.availableHours, 0);
|
|
11850
|
+
const allocated = departmentRows.reduce((sum, row) => sum + row.allocatedHours, 0);
|
|
11851
|
+
return {
|
|
11852
|
+
department,
|
|
11853
|
+
Faturável: departmentRows.reduce((sum, row) => sum + row.billableHours, 0),
|
|
11854
|
+
Interno: departmentRows.reduce((sum, row) => sum + row.internalHours, 0),
|
|
11855
|
+
Livre: Math.max(available - allocated, 0),
|
|
11856
|
+
Sobrecarga: Math.max(allocated - available, 0),
|
|
11857
|
+
};
|
|
11858
|
+
}),
|
|
11859
|
+
health: departments.map((department) => {
|
|
11860
|
+
const departmentRows = rows.filter((row) => row.department === department);
|
|
11861
|
+
const value = departmentRows.reduce((sum, row) => sum + row.billableValue, 0);
|
|
11862
|
+
const cost = departmentRows.reduce((sum, row) => sum + row.salaryCost + row.benefitsCost + row.taxesCost + row.toolsCost, 0);
|
|
11863
|
+
const available = departmentRows.reduce((sum, row) => sum + row.availableHours, 0);
|
|
11864
|
+
const allocated = departmentRows.reduce((sum, row) => sum + row.allocatedHours, 0);
|
|
11865
|
+
const billable = departmentRows.reduce((sum, row) => sum + row.billableHours, 0);
|
|
11866
|
+
return {
|
|
11867
|
+
department,
|
|
11868
|
+
margem: Math.max(value ? ((value - cost) / value) * 100 : 0, 0),
|
|
11869
|
+
alocacao: available ? (allocated / available) * 100 : 0,
|
|
11870
|
+
utilizacao: available ? (billable / available) * 100 : 0,
|
|
11871
|
+
saude: Math.max(100 - departmentRows.filter((row) => row.risk === 'alto').length * 12, 45),
|
|
11872
|
+
};
|
|
11873
|
+
}),
|
|
11874
|
+
ranking: rows
|
|
11875
|
+
.map((row) => ({
|
|
11876
|
+
name: row.name.split(' ')[0],
|
|
11877
|
+
Custo: row.salaryCost + row.benefitsCost + row.taxesCost + row.toolsCost,
|
|
11878
|
+
Lucro: row.billableValue - (row.salaryCost + row.benefitsCost + row.taxesCost + row.toolsCost),
|
|
11879
|
+
}))
|
|
11880
|
+
.sort((a, b) => b.Lucro - a.Lucro),
|
|
11881
|
+
planningCards: [
|
|
11882
|
+
{ title: 'Contratar', value: String(summary.overloadCount), description: 'Pessoas em sobrecarga no recorte.' },
|
|
11883
|
+
{ title: 'Realocar', value: Math.round(summary.freeHours).toString(), description: 'Horas livres estimadas no período.' },
|
|
11884
|
+
{ title: 'Reduzir sobrecarga', value: Math.round(summary.overtimeHours).toString(), description: 'Horas acima da capacidade cadastrada.' },
|
|
11885
|
+
{ title: 'Aumentar venda', value: Math.round(summary.utilization).toString(), description: 'Percentual de utilização faturável.' },
|
|
11886
|
+
],
|
|
11887
|
+
rows,
|
|
11888
|
+
};
|
|
11889
|
+
}
|
|
11890
|
+
|
|
11891
|
+
async deleteCollaboratorCostById(userId: number, costId: number) {
|
|
11892
11892
|
const actor = await this.getActorContext(userId);
|
|
11893
11893
|
this.ensureDirector(actor);
|
|
11894
11894
|
|