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