@hed-hog/operations 0.0.319 → 0.0.322

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.
Files changed (38) hide show
  1. package/dist/controllers/operations-tasks.controller.d.ts +22 -0
  2. package/dist/controllers/operations-tasks.controller.d.ts.map +1 -1
  3. package/dist/controllers/operations-tasks.controller.js +37 -0
  4. package/dist/controllers/operations-tasks.controller.js.map +1 -1
  5. package/dist/dto/create-task.dto.d.ts.map +1 -1
  6. package/dist/dto/create-task.dto.js +0 -1
  7. package/dist/dto/create-task.dto.js.map +1 -1
  8. package/dist/dto/update-task.dto.d.ts.map +1 -1
  9. package/dist/dto/update-task.dto.js +0 -1
  10. package/dist/dto/update-task.dto.js.map +1 -1
  11. package/dist/operations.service.d.ts +22 -0
  12. package/dist/operations.service.d.ts.map +1 -1
  13. package/dist/operations.service.js +187 -132
  14. package/dist/operations.service.js.map +1 -1
  15. package/hedhog/data/operations_cost_type.yaml +95 -95
  16. package/hedhog/data/route.yaml +39 -0
  17. package/hedhog/frontend/app/_components/collaborator-costs-section.tsx.ejs +884 -884
  18. package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +23 -23
  19. package/hedhog/frontend/app/_components/person-select-with-create.tsx.ejs +49 -22
  20. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +2968 -624
  21. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +62 -68
  22. package/hedhog/frontend/app/_components/task-file-attachments.tsx.ejs +388 -0
  23. package/hedhog/frontend/app/_lib/types.ts.ejs +179 -178
  24. package/hedhog/frontend/app/my-tasks/page.tsx.ejs +121 -11
  25. package/hedhog/frontend/app/projects/page.tsx.ejs +105 -22
  26. package/hedhog/frontend/app/reports/collaborators/page.tsx.ejs +771 -771
  27. package/hedhog/frontend/app/reports/projects/page.tsx.ejs +809 -809
  28. package/hedhog/frontend/messages/en.json +143 -2
  29. package/hedhog/frontend/messages/pt.json +143 -2
  30. package/hedhog/table/operations_task_file.yaml +23 -0
  31. package/package.json +5 -5
  32. package/src/controllers/operations-reports.controller.ts +32 -32
  33. package/src/controllers/operations-tasks.controller.ts +43 -9
  34. package/src/dto/create-task.dto.ts +0 -1
  35. package/src/dto/list-reports.dto.ts +51 -51
  36. package/src/dto/update-task.dto.ts +0 -1
  37. package/src/operations.module.ts +5 -5
  38. package/src/operations.service.ts +754 -632
@@ -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,
@@ -2570,6 +2570,7 @@ export class OperationsService {
2570
2570
  p.code,
2571
2571
  p.name,
2572
2572
  p.client_name AS "clientName",
2573
+ cp.avatar_id AS "clientAvatarId",
2573
2574
  p.summary,
2574
2575
  p.status,
2575
2576
  p.progress_percent AS "progressPercent",
@@ -2580,17 +2581,20 @@ export class OperationsService {
2580
2581
  c.name AS "contractName",
2581
2582
  c.status AS "contractStatus",
2582
2583
  m.display_name AS "managerName",
2584
+ mp.avatar_id AS "managerAvatarId",
2583
2585
  ${ownAssignmentSelect}
2584
2586
  COUNT(DISTINCT pa.id)::int AS "teamSize"
2585
2587
  FROM operations_project p
2586
2588
  LEFT JOIN operations_contract c ON c.id = p.contract_id
2587
2589
  LEFT JOIN operations_collaborator m ON m.id = p.manager_collaborator_id
2590
+ LEFT JOIN person cp ON cp.id = p.client_person_id
2591
+ LEFT JOIN person mp ON mp.id = m.person_id
2588
2592
  LEFT JOIN operations_project_assignment pa
2589
2593
  ON pa.project_id = p.id
2590
2594
  AND pa.deleted_at IS NULL
2591
2595
  AND pa.status IN ('planned', 'active')
2592
2596
  WHERE ${whereClause}
2593
- GROUP BY p.id, c.id, m.id`;
2597
+ GROUP BY p.id, c.id, m.id, cp.id, mp.id`;
2594
2598
 
2595
2599
  if (!pagination) {
2596
2600
  return this.queryRows(`${baseQuery} ORDER BY p.name ASC`, params);
@@ -2924,29 +2928,39 @@ export class OperationsService {
2924
2928
  this.requireFields(data as Record<string, unknown>, ['name']);
2925
2929
 
2926
2930
  let assignmentId: number | null = null;
2927
- let projectId: number | null = null;
2931
+ let projectId: number | null = data.projectId ?? null;
2928
2932
 
2929
2933
  if (data.projectId || data.projectAssignmentId) {
2930
- const assignment = await this.resolveProjectAssignmentForActor(
2931
- this.prisma,
2932
- actor,
2933
- {
2934
- projectId: data.projectId ?? null,
2935
- projectAssignmentId: data.projectAssignmentId ?? null,
2934
+ if (actor.isCollaborator && !actor.isDirector && !actor.isSupervisor) {
2935
+ const assignment = await this.resolveProjectAssignmentForActor(
2936
+ this.prisma,
2937
+ actor,
2938
+ {
2939
+ projectId: data.projectId ?? null,
2940
+ projectAssignmentId: data.projectAssignmentId ?? null,
2941
+ }
2942
+ );
2943
+ await this.assertProjectAccess(actor, assignment.projectId);
2944
+ assignmentId = assignment.id;
2945
+ projectId = assignment.projectId;
2946
+ } else {
2947
+ if (data.projectId) {
2948
+ await this.assertProjectAccess(actor, data.projectId);
2949
+ projectId = data.projectId;
2936
2950
  }
2937
- );
2938
- await this.assertProjectAccess(actor, assignment.projectId);
2939
- assignmentId = assignment.id;
2940
- projectId = assignment.projectId;
2941
- } else if (data.projectId) {
2942
- projectId = data.projectId;
2943
- await this.assertProjectAccess(actor, projectId);
2944
- } else {
2945
- throw new BadRequestException('Either projectId or projectAssignmentId is required.');
2946
- }
2947
-
2948
- if (!projectId) {
2949
- projectId = data.projectId ?? null;
2951
+ if (data.projectAssignmentId) {
2952
+ const assignment = await this.resolveProjectAssignmentForActor(
2953
+ this.prisma,
2954
+ actor,
2955
+ {
2956
+ projectId: data.projectId ?? null,
2957
+ projectAssignmentId: data.projectAssignmentId,
2958
+ }
2959
+ );
2960
+ assignmentId = assignment.id;
2961
+ projectId = assignment.projectId;
2962
+ }
2963
+ }
2950
2964
  }
2951
2965
 
2952
2966
  const name = this.normalizeOptionalText(data.name);
@@ -2989,7 +3003,7 @@ export class OperationsService {
2989
3003
  [
2990
3004
  projectId,
2991
3005
  assignmentId,
2992
- data.assigneeCollaboratorId ?? actor.collaboratorId ?? null,
3006
+ data.assigneeCollaboratorId ?? null,
2993
3007
  name,
2994
3008
  this.normalizeOptionalText(data.description),
2995
3009
  data.priority ?? 'medium',
@@ -3140,6 +3154,114 @@ export class OperationsService {
3140
3154
  return { success: true };
3141
3155
  }
3142
3156
 
3157
+ async listTaskFiles(userId: number, taskId: number) {
3158
+ const actor = await this.getActorContext(userId);
3159
+ if (!actor.isCollaborator && !actor.isDirector && !actor.isSupervisor) {
3160
+ throw new ForbiddenException(
3161
+ 'Operations collaborator access is required.'
3162
+ );
3163
+ }
3164
+
3165
+ const current = await this.getTaskRecordForActor(
3166
+ this.prisma,
3167
+ actor,
3168
+ taskId
3169
+ );
3170
+ await this.assertProjectAccess(actor, current.projectId);
3171
+
3172
+ const rows = (await (this.prisma as any).$queryRawUnsafe(
3173
+ `SELECT tf.id, tf.file_id, f.filename, f.size, m.name AS mimetype, tf.created_at
3174
+ FROM operations_task_file tf
3175
+ JOIN file f ON f.id = tf.file_id
3176
+ JOIN file_mimetype m ON m.id = f.mimetype_id
3177
+ WHERE tf.operations_task_id = $1
3178
+ ORDER BY tf.created_at ASC`,
3179
+ taskId
3180
+ )) as Array<{
3181
+ id: number;
3182
+ file_id: number;
3183
+ filename: string;
3184
+ size: number;
3185
+ mimetype: string;
3186
+ created_at: Date;
3187
+ }>;
3188
+
3189
+ return rows;
3190
+ }
3191
+
3192
+ async addTaskFile(
3193
+ userId: number,
3194
+ taskId: number,
3195
+ file: MulterFile
3196
+ ) {
3197
+ const actor = await this.getActorContext(userId);
3198
+ if (!actor.isCollaborator && !actor.isDirector && !actor.isSupervisor) {
3199
+ throw new ForbiddenException(
3200
+ 'Operations collaborator access is required.'
3201
+ );
3202
+ }
3203
+
3204
+ const current = await this.getTaskRecordForActor(
3205
+ this.prisma,
3206
+ actor,
3207
+ taskId
3208
+ );
3209
+ await this.assertProjectAccess(actor, current.projectId);
3210
+
3211
+ const uploaded = await this.fileService.upload(
3212
+ `operations/tasks/${taskId}`,
3213
+ file
3214
+ );
3215
+
3216
+ await (this.prisma as any).$executeRawUnsafe(
3217
+ `INSERT INTO operations_task_file (operations_task_id, file_id, created_at, updated_at)
3218
+ VALUES ($1, $2, NOW(), NOW())`,
3219
+ taskId,
3220
+ uploaded.id
3221
+ );
3222
+
3223
+ return uploaded;
3224
+ }
3225
+
3226
+ async removeTaskFile(userId: number, taskId: number, fileRelationId: number) {
3227
+ const actor = await this.getActorContext(userId);
3228
+ if (!actor.isCollaborator && !actor.isDirector && !actor.isSupervisor) {
3229
+ throw new ForbiddenException(
3230
+ 'Operations collaborator access is required.'
3231
+ );
3232
+ }
3233
+
3234
+ const current = await this.getTaskRecordForActor(
3235
+ this.prisma,
3236
+ actor,
3237
+ taskId
3238
+ );
3239
+ await this.assertProjectAccess(actor, current.projectId);
3240
+
3241
+ const rows = (await (this.prisma as any).$queryRawUnsafe(
3242
+ `SELECT file_id FROM operations_task_file WHERE id = $1 AND operations_task_id = $2`,
3243
+ fileRelationId,
3244
+ taskId
3245
+ )) as Array<{ file_id: number }>;
3246
+
3247
+ if (!rows.length) {
3248
+ throw new NotFoundException('Task file attachment not found.');
3249
+ }
3250
+
3251
+ const fileId = rows[0].file_id;
3252
+
3253
+ await (this.prisma as any).$executeRawUnsafe(
3254
+ `DELETE FROM operations_task_file WHERE id = $1`,
3255
+ fileRelationId
3256
+ );
3257
+
3258
+ if (fileId) {
3259
+ await this.fileService.delete('en', { ids: [fileId] });
3260
+ }
3261
+
3262
+ return { success: true };
3263
+ }
3264
+
3143
3265
  async listTimesheetEntries(
3144
3266
  userId: number,
3145
3267
  paginationParams: {
@@ -11254,9 +11376,9 @@ export class OperationsService {
11254
11376
  );
11255
11377
  }
11256
11378
 
11257
- async getCollaboratorCostsSummary(userId: number, collaboratorId: number) {
11258
- await this.getActorContext(userId);
11259
- const costs = await this.listCollaboratorCosts(userId, collaboratorId);
11379
+ async getCollaboratorCostsSummary(userId: number, collaboratorId: number) {
11380
+ await this.getActorContext(userId);
11381
+ const costs = await this.listCollaboratorCosts(userId, collaboratorId);
11260
11382
 
11261
11383
  let monthlyTotal = 0;
11262
11384
  let allocatableTotal = 0;
@@ -11285,610 +11407,610 @@ export class OperationsService {
11285
11407
  monthlyTotal: Math.round(monthlyTotal * 100) / 100,
11286
11408
  allocatableTotal: Math.round(allocatableTotal * 100) / 100,
11287
11409
  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) {
11410
+ count: costs.length,
11411
+ };
11412
+ }
11413
+
11414
+ async getProjectsReport(
11415
+ userId: number,
11416
+ filters: {
11417
+ from?: string;
11418
+ to?: string;
11419
+ status?: string;
11420
+ client?: string;
11421
+ scenario?: string;
11422
+ } = {}
11423
+ ) {
11424
+ const actor = await this.getActorContext(userId);
11425
+ this.ensureDirector(actor);
11426
+
11427
+ const currentYear = new Date().getFullYear();
11428
+ const from = /^\d{4}-\d{2}-\d{2}$/.test(String(filters.from ?? ''))
11429
+ ? String(filters.from)
11430
+ : `${currentYear}-01-01`;
11431
+ const to = /^\d{4}-\d{2}-\d{2}$/.test(String(filters.to ?? ''))
11432
+ ? String(filters.to)
11433
+ : `${currentYear}-12-31`;
11434
+ const scenario =
11435
+ filters.scenario === 'growth' || filters.scenario === 'conservative'
11436
+ ? filters.scenario
11437
+ : 'base';
11438
+ const multiplier =
11439
+ scenario === 'growth'
11440
+ ? { revenue: 1.16, cost: 1.09, backlog: 1.22 }
11441
+ : scenario === 'conservative'
11442
+ ? { revenue: 0.9, cost: 0.96, backlog: 0.82 }
11443
+ : { revenue: 1, cost: 1, backlog: 1 };
11444
+
11445
+ const params: unknown[] = [from, to];
11446
+ const where = [
11447
+ 'p.deleted_at IS NULL',
11448
+ '(p.end_date IS NULL OR p.end_date >= $1::date)',
11449
+ '(p.start_date IS NULL OR p.start_date <= $2::date)',
11450
+ ];
11451
+ if (filters.client && filters.client !== 'all') {
11452
+ where.push(
11453
+ `COALESCE(NULLIF(p.client_name, ''), NULLIF(contract_record.client_name, ''), '-') = ${this.param(params, filters.client)}`
11454
+ );
11455
+ }
11456
+
11457
+ const dbRows = await this.queryRows<{
11458
+ id: number;
11459
+ name: string;
11460
+ client: string | null;
11461
+ manager: string | null;
11462
+ squad: string | null;
11463
+ status: string;
11464
+ contractType: string | null;
11465
+ startDate: string | null;
11466
+ endDate: string | null;
11467
+ contractedRevenue: string | null;
11468
+ progressPercent: string | null;
11469
+ weeklyHours: string | null;
11470
+ actualHours: string | null;
11471
+ billableHours: string | null;
11472
+ openTasks: string | null;
11473
+ backlogHours: string | null;
11474
+ futureDeliveries: string | null;
11475
+ }>(
11476
+ `SELECT p.id,
11477
+ p.name,
11478
+ COALESCE(NULLIF(p.client_name, ''), NULLIF(contract_record.client_name, ''), '-') AS client,
11479
+ COALESCE(NULLIF(manager_record.display_name, ''), '-') AS manager,
11480
+ COALESCE(NULLIF(p.delivery_model::text, ''), '-') AS squad,
11481
+ p.status::text AS status,
11482
+ COALESCE(contract_record.billing_model::text, p.delivery_model::text, 'fixed_price') AS "contractType",
11483
+ TO_CHAR(p.start_date, 'YYYY-MM-DD') AS "startDate",
11484
+ TO_CHAR(p.end_date, 'YYYY-MM-DD') AS "endDate",
11485
+ COALESCE(p.budget_amount, contract_record.budget_amount, 0)::text AS "contractedRevenue",
11486
+ COALESCE(p.progress_percent, 0)::text AS "progressPercent",
11487
+ COALESCE(assignment_stats.weekly_hours, 0)::text AS "weeklyHours",
11488
+ COALESCE(time_stats.actual_hours, 0)::text AS "actualHours",
11489
+ COALESCE(time_stats.billable_hours, 0)::text AS "billableHours",
11490
+ COALESCE(task_stats.open_tasks, 0)::text AS "openTasks",
11491
+ COALESCE(task_stats.backlog_hours, 0)::text AS "backlogHours",
11492
+ COALESCE(task_stats.future_deliveries, 0)::text AS "futureDeliveries"
11493
+ FROM operations_project p
11494
+ LEFT JOIN operations_contract contract_record
11495
+ ON contract_record.id = p.contract_id
11496
+ AND contract_record.deleted_at IS NULL
11497
+ LEFT JOIN operations_collaborator manager_record
11498
+ ON manager_record.id = p.manager_collaborator_id
11499
+ AND manager_record.deleted_at IS NULL
11500
+ LEFT JOIN LATERAL (
11501
+ SELECT COALESCE(SUM(pa.weekly_hours), 0) AS weekly_hours
11502
+ FROM operations_project_assignment pa
11503
+ WHERE pa.project_id = p.id
11504
+ AND pa.deleted_at IS NULL
11505
+ AND pa.status IN ('planned', 'active')
11506
+ ) assignment_stats ON TRUE
11507
+ LEFT JOIN LATERAL (
11508
+ SELECT COALESCE(SUM(entry.hours), 0) AS actual_hours,
11509
+ COALESCE(SUM(entry.hours) FILTER (WHERE pa.is_billable = true), 0) AS billable_hours
11510
+ FROM operations_timesheet_entry entry
11511
+ JOIN operations_project_assignment pa
11512
+ ON pa.id = entry.project_assignment_id
11513
+ AND pa.deleted_at IS NULL
11514
+ WHERE pa.project_id = p.id
11515
+ AND entry.deleted_at IS NULL
11516
+ AND entry.work_date BETWEEN $1::date AND $2::date
11517
+ ) time_stats ON TRUE
11518
+ LEFT JOIN LATERAL (
11519
+ SELECT COUNT(*) FILTER (WHERE task.status IN ('todo', 'doing', 'review')) AS open_tasks,
11520
+ COALESCE(SUM(task.estimate_hours) FILTER (WHERE task.status IN ('todo', 'doing', 'review')), 0) AS backlog_hours,
11521
+ COUNT(*) FILTER (WHERE task.due_date > $2::date AND task.status IN ('todo', 'doing', 'review')) AS future_deliveries
11522
+ FROM operations_task task
11523
+ WHERE task.project_id = p.id
11524
+ AND task.deleted_at IS NULL
11525
+ ) task_stats ON TRUE
11526
+ WHERE ${where.join(' AND ')}
11527
+ ORDER BY p.name ASC`,
11528
+ params
11529
+ );
11530
+
11531
+ const fromDate = new Date(`${from}T00:00:00`);
11532
+ const toDate = new Date(`${to}T00:00:00`);
11533
+ const periodWeeks = Math.max(
11534
+ 1,
11535
+ Math.ceil((toDate.getTime() - fromDate.getTime()) / 604800000)
11536
+ );
11537
+ const rows = dbRows
11538
+ .map((row) => {
11539
+ const progress = Number(row.progressPercent ?? 0);
11540
+ const contractedRevenue = Number(row.contractedRevenue ?? 0);
11541
+ const recognizedRevenue = contractedRevenue * (progress / 100);
11542
+ const actualHours = Number(row.actualHours ?? 0);
11543
+ const plannedHours = Math.max(Number(row.weeklyHours ?? 0) * periodWeeks, actualHours);
11544
+ const realizedCost = 0;
11545
+ const reportStatus =
11546
+ row.status === 'paused'
11547
+ ? 'paused'
11548
+ : row.status === 'at_risk'
11549
+ ? 'attention'
11550
+ : row.endDate && new Date(`${row.endDate}T00:00:00`) < new Date() && progress < 100
11551
+ ? 'late'
11552
+ : 'on_track';
11553
+ const risk =
11554
+ reportStatus === 'late' || (plannedHours && actualHours / plannedHours > 1)
11555
+ ? 'alto'
11556
+ : reportStatus === 'attention'
11557
+ ? 'médio'
11558
+ : 'baixo';
11559
+
11560
+ return {
11561
+ id: Number(row.id),
11562
+ name: row.name,
11563
+ client: row.client ?? '-',
11564
+ manager: row.manager ?? '-',
11565
+ squad: String(row.squad ?? '-').replace(/_/g, ' '),
11566
+ status: reportStatus,
11567
+ contractType:
11568
+ row.contractType === 'monthly_retainer'
11569
+ ? 'retainer'
11570
+ : row.contractType === 'time_and_material'
11571
+ ? 'time_materials'
11572
+ : 'fixed_price',
11573
+ priority: risk === 'alto' ? 'alta' : risk === 'médio' ? 'média' : 'baixa',
11574
+ startDate: row.startDate,
11575
+ endDate: row.endDate,
11576
+ contractedRevenue,
11577
+ recognizedRevenue,
11578
+ realizedCost,
11579
+ forecastCost: realizedCost,
11580
+ teamCost: realizedCost,
11581
+ infraCost: 0,
11582
+ licenseCost: 0,
11583
+ thirdPartyCost: 0,
11584
+ reworkCost: 0,
11585
+ plannedHours,
11586
+ actualHours,
11587
+ billableHours: Number(row.billableHours ?? 0),
11588
+ reworkHours: 0,
11589
+ internalHours: Math.max(actualHours - Number(row.billableHours ?? 0), 0),
11590
+ allocatedCapacity: plannedHours ? (actualHours / plannedHours) * 100 : 0,
11591
+ physicalProgress: progress,
11592
+ financialProgress: contractedRevenue ? (recognizedRevenue / contractedRevenue) * 100 : 0,
11593
+ backlogValue: Math.max(contractedRevenue - recognizedRevenue, 0),
11594
+ futureDeliveries: Number(row.futureDeliveries ?? 0),
11595
+ risk,
11596
+ recommendation:
11597
+ risk === 'alto'
11598
+ ? 'Revisar escopo, prazo ou capacidade alocada.'
11599
+ : risk === 'médio'
11600
+ ? 'Acompanhar margem, entregas e consumo de horas.'
11601
+ : 'Manter ritmo e proteger capacidade planejada.',
11602
+ };
11603
+ })
11604
+ .filter((row) => !filters.status || filters.status === 'all' || row.status === filters.status);
11605
+
11606
+ const summary = rows.reduce(
11607
+ (acc, row) => {
11608
+ acc.contractedRevenue += row.contractedRevenue;
11609
+ acc.recognizedRevenue += row.recognizedRevenue;
11610
+ acc.realizedCost += row.realizedCost;
11611
+ acc.forecastCost += row.forecastCost;
11612
+ acc.plannedHours += row.plannedHours;
11613
+ acc.actualHours += row.actualHours;
11614
+ acc.billableHours += row.billableHours;
11615
+ acc.reworkHours += row.reworkHours;
11616
+ acc.backlogValue += row.backlogValue;
11617
+ acc.avgDeadline += row.physicalProgress;
11618
+ acc.avgAllocation += row.allocatedCapacity;
11619
+ acc.atRisk += row.risk === 'alto' ? 1 : 0;
11620
+ return acc;
11621
+ },
11622
+ {
11623
+ contractedRevenue: 0,
11624
+ recognizedRevenue: 0,
11625
+ realizedCost: 0,
11626
+ forecastCost: 0,
11627
+ profit: 0,
11628
+ margin: 0,
11629
+ plannedHours: 0,
11630
+ actualHours: 0,
11631
+ billableHours: 0,
11632
+ reworkHours: 0,
11633
+ backlogValue: 0,
11634
+ avgDeadline: 0,
11635
+ avgAllocation: 0,
11636
+ atRisk: 0,
11637
+ burnRate: 0,
11638
+ }
11639
+ );
11640
+ summary.profit = summary.recognizedRevenue - summary.realizedCost;
11641
+ summary.margin = summary.recognizedRevenue ? (summary.profit / summary.recognizedRevenue) * 100 : 0;
11642
+ summary.avgDeadline = rows.length ? summary.avgDeadline / rows.length : 0;
11643
+ summary.avgAllocation = rows.length ? summary.avgAllocation / rows.length : 0;
11644
+ summary.burnRate = summary.plannedHours ? (summary.actualHours / summary.plannedHours) * 100 : 0;
11645
+
11646
+ const forecast = Array.from({ length: 12 }, (_, index) => {
11647
+ const monthDate = new Date(fromDate);
11648
+ monthDate.setMonth(fromDate.getMonth() + index);
11649
+ const revenue = Math.round((summary.recognizedRevenue / 12) * multiplier.revenue);
11650
+ const cost = Math.round((summary.realizedCost / 12) * multiplier.cost);
11651
+ const profit = revenue - cost;
11652
+ return {
11653
+ month: monthDate.toLocaleDateString('pt-BR', { month: 'short' }).replace('.', ''),
11654
+ revenue,
11655
+ cost,
11656
+ profit,
11657
+ backlog: Math.round(Math.max(profit, 0) * multiplier.backlog),
11658
+ planned: Math.round(summary.plannedHours / 12),
11659
+ actual: Math.round(summary.actualHours / 12),
11660
+ };
11661
+ });
11662
+
11663
+ return {
11664
+ filters: {
11665
+ from,
11666
+ to,
11667
+ status: filters.status ?? 'all',
11668
+ client: filters.client ?? 'all',
11669
+ scenario,
11670
+ clients: [...new Set(dbRows.map((row) => row.client ?? '-'))].sort(),
11671
+ },
11672
+ summary,
11673
+ forecast,
11674
+ costComposition: [
11675
+ { name: 'Equipe', value: summary.realizedCost },
11676
+ { name: 'Infraestrutura', value: 0 },
11677
+ { name: 'Licenças', value: 0 },
11678
+ { name: 'Terceiros', value: 0 },
11679
+ { name: 'Retrabalho', value: 0 },
11680
+ ],
11681
+ hoursByProject: rows.map((row) => ({
11682
+ project: row.name.split(' ').slice(0, 2).join(' '),
11683
+ Faturável: row.billableHours,
11684
+ Interno: row.internalHours,
11685
+ Retrabalho: row.reworkHours,
11686
+ Livre: Math.max(row.plannedHours - row.actualHours, 0),
11687
+ })),
11688
+ health: rows.map((row) => ({
11689
+ project: row.name.split(' ').slice(0, 2).join(' '),
11690
+ margem: Math.max(row.recognizedRevenue ? ((row.recognizedRevenue - row.realizedCost) / row.recognizedRevenue) * 100 : 0, 0),
11691
+ prazo: row.physicalProgress,
11692
+ alocacao: row.allocatedCapacity,
11693
+ saude: Math.max(100 - (row.risk === 'alto' ? 28 : row.risk === 'médio' ? 14 : 0), 35),
11694
+ })),
11695
+ ranking: rows
11696
+ .map((row) => ({
11697
+ name: row.name.split(' ').slice(0, 2).join(' '),
11698
+ Lucro: row.recognizedRevenue - row.realizedCost,
11699
+ Custo: row.realizedCost,
11700
+ }))
11701
+ .sort((a, b) => b.Lucro - a.Lucro),
11702
+ progress: forecast.map((row) => ({
11703
+ month: row.month,
11704
+ Planejado: row.planned,
11705
+ Realizado: row.actual,
11706
+ })),
11707
+ planningCards: [
11708
+ { title: 'Acelerar críticas', value: String(summary.atRisk), description: 'Projetos com risco alto devem receber revisão de prazo e capacidade.' },
11709
+ { title: 'Renegociar escopo', value: Math.round(summary.backlogValue).toString(), description: 'Backlog financeiro ainda não reconhecido no período.' },
11710
+ { title: 'Realocar equipe', value: Math.round(Math.max(summary.plannedHours - summary.actualHours, 0)).toString(), description: 'Horas planejadas ainda não consumidas.' },
11711
+ { title: 'Priorizar margem', value: rows.filter((row) => row.recognizedRevenue > row.realizedCost).length.toString(), description: 'Projetos com contribuição positiva no recorte.' },
11712
+ ],
11713
+ rows,
11714
+ };
11715
+ }
11716
+
11717
+ async getCollaboratorsReport(
11718
+ userId: number,
11719
+ filters: {
11720
+ from?: string;
11721
+ to?: string;
11722
+ department?: string;
11723
+ contractType?: string;
11724
+ scenario?: string;
11725
+ } = {}
11726
+ ) {
11727
+ const actor = await this.getActorContext(userId);
11728
+ this.ensureDirector(actor);
11729
+
11730
+ const currentYear = new Date().getFullYear();
11731
+ const from = /^\d{4}-\d{2}-\d{2}$/.test(String(filters.from ?? ''))
11732
+ ? String(filters.from)
11733
+ : `${currentYear}-01-01`;
11734
+ const to = /^\d{4}-\d{2}-\d{2}$/.test(String(filters.to ?? ''))
11735
+ ? String(filters.to)
11736
+ : `${currentYear}-12-31`;
11737
+ const scenario =
11738
+ filters.scenario === 'growth' || filters.scenario === 'conservative'
11739
+ ? filters.scenario
11740
+ : 'base';
11741
+ const multiplier =
11742
+ scenario === 'growth'
11743
+ ? { revenue: 1.18, cost: 1.11, capacity: 1.08 }
11744
+ : scenario === 'conservative'
11745
+ ? { revenue: 0.9, cost: 0.96, capacity: 0.94 }
11746
+ : { revenue: 1, cost: 1, capacity: 1 };
11747
+ const params: unknown[] = [from, to];
11748
+ const where = [
11749
+ 'c.deleted_at IS NULL',
11750
+ '(c.joined_at IS NULL OR c.joined_at <= $2::date)',
11751
+ '(c.left_at IS NULL OR c.left_at >= $1::date)',
11752
+ ];
11753
+
11754
+ if (filters.department && filters.department !== 'all') {
11755
+ where.push(`COALESCE(department_record.name, '-') = ${this.param(params, filters.department)}`);
11756
+ }
11757
+
11758
+ if (filters.contractType && filters.contractType !== 'all') {
11759
+ where.push(`COALESCE(collaborator_type.name, collaborator_type.slug, '-') = ${this.param(params, filters.contractType)}`);
11760
+ }
11761
+
11762
+ const dbRows = await this.queryRows<{
11763
+ id: number;
11764
+ name: string;
11765
+ role: string | null;
11766
+ seniority: string | null;
11767
+ department: string | null;
11768
+ contractType: string | null;
11769
+ startDate: string | null;
11770
+ endDate: string | null;
11771
+ weeklyCapacityHours: string | null;
11772
+ salaryCost: string | null;
11773
+ benefitsCost: string | null;
11774
+ taxesCost: string | null;
11775
+ toolsCost: string | null;
11776
+ billableValue: string | null;
11777
+ allocatedHours: string | null;
11778
+ billableHours: string | null;
11779
+ projects: string | null;
11780
+ }>(
11781
+ `SELECT c.id,
11782
+ COALESCE(NULLIF(c.display_name, ''), person_record.name, c.code) AS name,
11783
+ COALESCE(job_title_record.name, c.title, '-') AS role,
11784
+ COALESCE(c.level_label, '-') AS seniority,
11785
+ COALESCE(department_record.name, '-') AS department,
11786
+ COALESCE(collaborator_type.name, collaborator_type.slug, '-') AS "contractType",
11787
+ TO_CHAR(c.joined_at, 'YYYY-MM-DD') AS "startDate",
11788
+ TO_CHAR(c.left_at, 'YYYY-MM-DD') AS "endDate",
11789
+ COALESCE(c.weekly_capacity_hours, 40)::text AS "weeklyCapacityHours",
11790
+ COALESCE(cost_stats.salary_cost, 0)::text AS "salaryCost",
11791
+ COALESCE(cost_stats.benefits_cost, 0)::text AS "benefitsCost",
11792
+ COALESCE(cost_stats.taxes_cost, 0)::text AS "taxesCost",
11793
+ COALESCE(cost_stats.tools_cost, 0)::text AS "toolsCost",
11794
+ COALESCE(value_stats.billable_value, 0)::text AS "billableValue",
11795
+ COALESCE(value_stats.allocated_hours, 0)::text AS "allocatedHours",
11796
+ COALESCE(value_stats.billable_hours, 0)::text AS "billableHours",
11797
+ COALESCE(project_stats.projects, 0)::text AS projects
11798
+ FROM operations_collaborator c
11799
+ LEFT JOIN person person_record ON person_record.id = c.person_id
11800
+ LEFT JOIN operations_department department_record
11801
+ ON department_record.id = c.department_id
11802
+ AND department_record.deleted_at IS NULL
11803
+ LEFT JOIN operations_job_title job_title_record
11804
+ ON job_title_record.id = c.job_title_id
11805
+ AND job_title_record.deleted_at IS NULL
11806
+ LEFT JOIN operations_collaborator_type collaborator_type
11807
+ ON collaborator_type.id = c.collaborator_type_id
11808
+ AND collaborator_type.deleted_at IS NULL
11809
+ LEFT JOIN LATERAL (
11810
+ SELECT COALESCE(SUM(cost.amount) FILTER (WHERE cost.recurrence::text = 'monthly' AND cost_type.slug IN ('salario-base', 'pro-labore')), 0) AS salary_cost,
11811
+ 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,
11812
+ 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,
11813
+ COALESCE(SUM(cost.amount) FILTER (WHERE cost.recurrence::text = 'monthly' AND cost_type.slug IN ('software-licenca', 'equipamento')), 0) AS tools_cost
11814
+ FROM operations_collaborator_cost cost
11815
+ LEFT JOIN operations_cost_type cost_type ON cost_type.id = cost.cost_type_id
11816
+ WHERE cost.collaborator_id = c.id
11817
+ AND (cost.start_date IS NULL OR cost.start_date <= $2::date)
11818
+ AND (cost.end_date IS NULL OR cost.end_date >= $1::date)
11819
+ ) cost_stats ON TRUE
11820
+ LEFT JOIN LATERAL (
11821
+ SELECT COALESCE(SUM(entry.hours), 0) AS allocated_hours,
11822
+ COALESCE(SUM(entry.hours) FILTER (WHERE pa.is_billable = true), 0) AS billable_hours,
11823
+ COALESCE(SUM(entry.hours) FILTER (WHERE pa.is_billable = true) * 120, 0) AS billable_value
11824
+ FROM operations_timesheet_entry entry
11825
+ JOIN operations_project_assignment pa
11826
+ ON pa.id = entry.project_assignment_id
11827
+ AND pa.deleted_at IS NULL
11828
+ WHERE pa.collaborator_id = c.id
11829
+ AND entry.deleted_at IS NULL
11830
+ AND entry.work_date BETWEEN $1::date AND $2::date
11831
+ ) value_stats ON TRUE
11832
+ LEFT JOIN LATERAL (
11833
+ SELECT COUNT(DISTINCT pa.project_id) AS projects
11834
+ FROM operations_project_assignment pa
11835
+ WHERE pa.collaborator_id = c.id
11836
+ AND pa.deleted_at IS NULL
11837
+ AND pa.status IN ('planned', 'active')
11838
+ ) project_stats ON TRUE
11839
+ WHERE ${where.join(' AND ')}
11840
+ ORDER BY name ASC`,
11841
+ params
11842
+ );
11843
+
11844
+ const fromDate = new Date(`${from}T00:00:00`);
11845
+ const toDate = new Date(`${to}T00:00:00`);
11846
+ const periodWeeks = Math.max(
11847
+ 1,
11848
+ Math.ceil((toDate.getTime() - fromDate.getTime()) / 604800000)
11849
+ );
11850
+ const rows = dbRows.map((row) => {
11851
+ const salaryCost = Number(row.salaryCost ?? 0);
11852
+ const benefitsCost = Number(row.benefitsCost ?? 0);
11853
+ const taxesCost = Number(row.taxesCost ?? 0);
11854
+ const toolsCost = Number(row.toolsCost ?? 0);
11855
+ const availableHours = Number(row.weeklyCapacityHours ?? 40) * periodWeeks;
11856
+ const allocatedHours = Number(row.allocatedHours ?? 0);
11857
+ const billableHours = Number(row.billableHours ?? 0);
11858
+ const allocation = availableHours ? (allocatedHours / availableHours) * 100 : 0;
11859
+ const risk = allocation >= 98 ? 'alto' : allocation < 75 ? 'médio' : 'baixo';
11860
+ return {
11861
+ id: Number(row.id),
11862
+ name: row.name,
11863
+ role: row.role ?? '-',
11864
+ seniority: row.seniority ?? '-',
11865
+ department: row.department ?? '-',
11866
+ contractType: row.contractType ?? '-',
11867
+ startDate: row.startDate,
11868
+ endDate: row.endDate,
11869
+ salaryCost,
11870
+ benefitsCost,
11871
+ taxesCost,
11872
+ toolsCost,
11873
+ billableValue: Number(row.billableValue ?? 0),
11874
+ availableHours,
11875
+ allocatedHours,
11876
+ billableHours,
11877
+ internalHours: Math.max(allocatedHours - billableHours, 0),
11878
+ overtimeHours: Math.max(allocatedHours - availableHours, 0),
11879
+ projects: Number(row.projects ?? 0),
11880
+ risk,
11881
+ recommendation:
11882
+ risk === 'alto'
11883
+ ? 'Reduzir sobrecarga ou redistribuir entregas.'
11884
+ : risk === 'médio'
11885
+ ? 'Acompanhar alocação e buscar maior aproveitamento faturável.'
11886
+ : 'Manter alocação e proteger margem.',
11887
+ };
11888
+ });
11889
+
11890
+ const summary = rows.reduce(
11891
+ (acc, row) => {
11892
+ acc.cost += row.salaryCost + row.benefitsCost + row.taxesCost + row.toolsCost;
11893
+ acc.salary += row.salaryCost;
11894
+ acc.benefits += row.benefitsCost;
11895
+ acc.taxes += row.taxesCost;
11896
+ acc.tools += row.toolsCost;
11897
+ acc.billableValue += row.billableValue;
11898
+ acc.availableHours += row.availableHours;
11899
+ acc.allocatedHours += row.allocatedHours;
11900
+ acc.billableHours += row.billableHours;
11901
+ acc.internalHours += row.internalHours;
11902
+ acc.overtimeHours += row.overtimeHours;
11903
+ acc.overloadCount += row.risk === 'alto' ? 1 : 0;
11904
+ return acc;
11905
+ },
11906
+ {
11907
+ cost: 0,
11908
+ salary: 0,
11909
+ benefits: 0,
11910
+ taxes: 0,
11911
+ tools: 0,
11912
+ billableValue: 0,
11913
+ profit: 0,
11914
+ margin: 0,
11915
+ availableHours: 0,
11916
+ allocatedHours: 0,
11917
+ billableHours: 0,
11918
+ internalHours: 0,
11919
+ overtimeHours: 0,
11920
+ freeHours: 0,
11921
+ allocation: 0,
11922
+ utilization: 0,
11923
+ overloadCount: 0,
11924
+ hourlyCost: 0,
11925
+ }
11926
+ );
11927
+ summary.profit = summary.billableValue - summary.cost;
11928
+ summary.margin = summary.billableValue ? (summary.profit / summary.billableValue) * 100 : 0;
11929
+ summary.freeHours = Math.max(summary.availableHours - summary.allocatedHours, 0);
11930
+ summary.allocation = summary.availableHours ? (summary.allocatedHours / summary.availableHours) * 100 : 0;
11931
+ summary.utilization = summary.availableHours ? (summary.billableHours / summary.availableHours) * 100 : 0;
11932
+ summary.hourlyCost = summary.allocatedHours ? summary.cost / summary.allocatedHours : 0;
11933
+
11934
+ const forecast = Array.from({ length: 12 }, (_, index) => {
11935
+ const monthDate = new Date(fromDate);
11936
+ monthDate.setMonth(fromDate.getMonth() + index);
11937
+ const revenue = Math.round((summary.billableValue / 12) * multiplier.revenue);
11938
+ const cost = Math.round((summary.cost / 12) * multiplier.cost);
11939
+ const profit = revenue - cost;
11940
+ return {
11941
+ month: monthDate.toLocaleDateString('pt-BR', { month: 'short' }).replace('.', ''),
11942
+ revenue,
11943
+ cost,
11944
+ profit,
11945
+ margin: revenue ? Math.round((profit / revenue) * 100) : 0,
11946
+ capacity: Math.round((summary.allocatedHours / 12) * multiplier.capacity),
11947
+ };
11948
+ });
11949
+ const departments = [...new Set(dbRows.map((row) => row.department ?? '-'))].sort();
11950
+
11951
+ return {
11952
+ filters: {
11953
+ from,
11954
+ to,
11955
+ department: filters.department ?? 'all',
11956
+ contractType: filters.contractType ?? 'all',
11957
+ scenario,
11958
+ departments,
11959
+ contractTypes: [...new Set(dbRows.map((row) => row.contractType ?? '-'))].sort(),
11960
+ },
11961
+ summary,
11962
+ forecast,
11963
+ costComposition: [
11964
+ { name: 'Salários / Contratos', value: summary.salary },
11965
+ { name: 'Benefícios', value: summary.benefits },
11966
+ { name: 'Encargos', value: summary.taxes },
11967
+ { name: 'Ferramentas', value: summary.tools },
11968
+ ],
11969
+ capacityByDepartment: departments.map((department) => {
11970
+ const departmentRows = rows.filter((row) => row.department === department);
11971
+ const available = departmentRows.reduce((sum, row) => sum + row.availableHours, 0);
11972
+ const allocated = departmentRows.reduce((sum, row) => sum + row.allocatedHours, 0);
11973
+ return {
11974
+ department,
11975
+ Faturável: departmentRows.reduce((sum, row) => sum + row.billableHours, 0),
11976
+ Interno: departmentRows.reduce((sum, row) => sum + row.internalHours, 0),
11977
+ Livre: Math.max(available - allocated, 0),
11978
+ Sobrecarga: Math.max(allocated - available, 0),
11979
+ };
11980
+ }),
11981
+ health: departments.map((department) => {
11982
+ const departmentRows = rows.filter((row) => row.department === department);
11983
+ const value = departmentRows.reduce((sum, row) => sum + row.billableValue, 0);
11984
+ const cost = departmentRows.reduce((sum, row) => sum + row.salaryCost + row.benefitsCost + row.taxesCost + row.toolsCost, 0);
11985
+ const available = departmentRows.reduce((sum, row) => sum + row.availableHours, 0);
11986
+ const allocated = departmentRows.reduce((sum, row) => sum + row.allocatedHours, 0);
11987
+ const billable = departmentRows.reduce((sum, row) => sum + row.billableHours, 0);
11988
+ return {
11989
+ department,
11990
+ margem: Math.max(value ? ((value - cost) / value) * 100 : 0, 0),
11991
+ alocacao: available ? (allocated / available) * 100 : 0,
11992
+ utilizacao: available ? (billable / available) * 100 : 0,
11993
+ saude: Math.max(100 - departmentRows.filter((row) => row.risk === 'alto').length * 12, 45),
11994
+ };
11995
+ }),
11996
+ ranking: rows
11997
+ .map((row) => ({
11998
+ name: row.name.split(' ')[0],
11999
+ Custo: row.salaryCost + row.benefitsCost + row.taxesCost + row.toolsCost,
12000
+ Lucro: row.billableValue - (row.salaryCost + row.benefitsCost + row.taxesCost + row.toolsCost),
12001
+ }))
12002
+ .sort((a, b) => b.Lucro - a.Lucro),
12003
+ planningCards: [
12004
+ { title: 'Contratar', value: String(summary.overloadCount), description: 'Pessoas em sobrecarga no recorte.' },
12005
+ { title: 'Realocar', value: Math.round(summary.freeHours).toString(), description: 'Horas livres estimadas no período.' },
12006
+ { title: 'Reduzir sobrecarga', value: Math.round(summary.overtimeHours).toString(), description: 'Horas acima da capacidade cadastrada.' },
12007
+ { title: 'Aumentar venda', value: Math.round(summary.utilization).toString(), description: 'Percentual de utilização faturável.' },
12008
+ ],
12009
+ rows,
12010
+ };
12011
+ }
12012
+
12013
+ async deleteCollaboratorCostById(userId: number, costId: number) {
11892
12014
  const actor = await this.getActorContext(userId);
11893
12015
  this.ensureDirector(actor);
11894
12016