@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.
- package/dist/controllers/operations-tasks.controller.d.ts +22 -0
- package/dist/controllers/operations-tasks.controller.d.ts.map +1 -1
- package/dist/controllers/operations-tasks.controller.js +37 -0
- package/dist/controllers/operations-tasks.controller.js.map +1 -1
- package/dist/dto/create-task.dto.d.ts.map +1 -1
- package/dist/dto/create-task.dto.js +0 -1
- package/dist/dto/create-task.dto.js.map +1 -1
- package/dist/dto/update-task.dto.d.ts.map +1 -1
- package/dist/dto/update-task.dto.js +0 -1
- package/dist/dto/update-task.dto.js.map +1 -1
- package/dist/operations.service.d.ts +22 -0
- package/dist/operations.service.d.ts.map +1 -1
- package/dist/operations.service.js +187 -132
- package/dist/operations.service.js.map +1 -1
- package/hedhog/data/operations_cost_type.yaml +95 -95
- package/hedhog/data/route.yaml +39 -0
- package/hedhog/frontend/app/_components/collaborator-costs-section.tsx.ejs +884 -884
- package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +23 -23
- package/hedhog/frontend/app/_components/person-select-with-create.tsx.ejs +49 -22
- package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +2968 -624
- package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +62 -68
- package/hedhog/frontend/app/_components/task-file-attachments.tsx.ejs +388 -0
- package/hedhog/frontend/app/_lib/types.ts.ejs +179 -178
- package/hedhog/frontend/app/my-tasks/page.tsx.ejs +121 -11
- package/hedhog/frontend/app/projects/page.tsx.ejs +105 -22
- package/hedhog/frontend/app/reports/collaborators/page.tsx.ejs +771 -771
- package/hedhog/frontend/app/reports/projects/page.tsx.ejs +809 -809
- package/hedhog/frontend/messages/en.json +143 -2
- package/hedhog/frontend/messages/pt.json +143 -2
- package/hedhog/table/operations_task_file.yaml +23 -0
- package/package.json +5 -5
- package/src/controllers/operations-reports.controller.ts +32 -32
- package/src/controllers/operations-tasks.controller.ts +43 -9
- package/src/dto/create-task.dto.ts +0 -1
- package/src/dto/list-reports.dto.ts +51 -51
- package/src/dto/update-task.dto.ts +0 -1
- package/src/operations.module.ts +5 -5
- 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
|
-
|
|
2931
|
-
this.
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
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
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
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 ??
|
|
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
|
|