@hed-hog/operations 0.0.321 → 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 (31) hide show
  1. package/dist/controllers/operations-contracts.controller.d.ts +9 -9
  2. package/dist/controllers/operations-tasks.controller.d.ts +22 -0
  3. package/dist/controllers/operations-tasks.controller.d.ts.map +1 -1
  4. package/dist/controllers/operations-tasks.controller.js +37 -0
  5. package/dist/controllers/operations-tasks.controller.js.map +1 -1
  6. package/dist/dto/create-task.dto.d.ts.map +1 -1
  7. package/dist/dto/create-task.dto.js +0 -1
  8. package/dist/dto/create-task.dto.js.map +1 -1
  9. package/dist/dto/update-task.dto.d.ts.map +1 -1
  10. package/dist/dto/update-task.dto.js +0 -1
  11. package/dist/dto/update-task.dto.js.map +1 -1
  12. package/dist/operations.service.d.ts +22 -0
  13. package/dist/operations.service.d.ts.map +1 -1
  14. package/dist/operations.service.js +77 -22
  15. package/dist/operations.service.js.map +1 -1
  16. package/hedhog/data/route.yaml +39 -0
  17. package/hedhog/frontend/app/_components/person-select-with-create.tsx.ejs +49 -22
  18. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +2968 -624
  19. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +62 -68
  20. package/hedhog/frontend/app/_components/task-file-attachments.tsx.ejs +388 -0
  21. package/hedhog/frontend/app/_lib/types.ts.ejs +1 -0
  22. package/hedhog/frontend/app/my-tasks/page.tsx.ejs +121 -11
  23. package/hedhog/frontend/app/projects/page.tsx.ejs +105 -22
  24. package/hedhog/frontend/messages/en.json +143 -2
  25. package/hedhog/frontend/messages/pt.json +143 -2
  26. package/hedhog/table/operations_task_file.yaml +23 -0
  27. package/package.json +5 -5
  28. package/src/controllers/operations-tasks.controller.ts +43 -9
  29. package/src/dto/create-task.dto.ts +0 -1
  30. package/src/dto/update-task.dto.ts +0 -1
  31. package/src/operations.service.ts +144 -22
@@ -721,7 +721,69 @@
721
721
  "loggedHours": "Horas lançadas",
722
722
  "loggedHoursDescription": "Total de horas vinculadas a este projeto.",
723
723
  "allocation": "Alocação média",
724
- "allocationDescription": "Média de alocação das atribuições do projeto."
724
+ "allocationDescription": "Média de alocação das atribuições do projeto.",
725
+ "lateTasks": "Tasks atrasadas",
726
+ "lateTasksDescription": "Tarefas abertas com prazo vencido.",
727
+ "weeklyVelocity": "Velocidade semanal",
728
+ "weeklyVelocityDescription": "Horas recentes ou planejadas na semana.",
729
+ "projectHealth": "Saúde do projeto",
730
+ "pendingTasks": "Tasks pendentes",
731
+ "pendingTasksDescription": "{overdue} tarefa(s) atrasada(s) no momento.",
732
+ "activeCollaborators": "Colaboradores ativos",
733
+ "activeCollaboratorsDescription": "Pessoas com alocação ativa neste projeto."
734
+ },
735
+ "kpi": {
736
+ "indicator": "Indicador",
737
+ "subtitles": {
738
+ "health": "Composição visual de progresso, pendências e alocação."
739
+ },
740
+ "trends": {
741
+ "hours": "{count} timesheet(s)",
742
+ "health": {
743
+ "good": "Positivo",
744
+ "warning": "Warning",
745
+ "danger": "Crítico"
746
+ },
747
+ "velocity": {
748
+ "active": "Em movimento",
749
+ "empty": "Sem ritmo recente"
750
+ },
751
+ "allocation": {
752
+ "good": "Equilibrada",
753
+ "warning": "Alta",
754
+ "critical": "Sobrecarga"
755
+ },
756
+ "tasks": {
757
+ "good": "Sem pendências",
758
+ "warning": "Backlog ativo",
759
+ "critical": "{count} atrasada(s)"
760
+ },
761
+ "collaborators": {
762
+ "active": "Equipe ativa",
763
+ "empty": "Sem equipe"
764
+ }
765
+ }
766
+ },
767
+ "quickActions": {
768
+ "timesheet": "Timesheet",
769
+ "reports": "Relatórios",
770
+ "more": "Mais ações"
771
+ },
772
+ "executive": {
773
+ "team": "Equipe",
774
+ "activeTasks": "Tasks ativas",
775
+ "completedTasks": "Concluídas",
776
+ "fallbackDescription": "Acompanhe entrega, equipe, contrato e sinais operacionais deste projeto.",
777
+ "membersCount": "{count} membro(s)"
778
+ },
779
+ "breadcrumbTrail": {
780
+ "operations": "Operations",
781
+ "projects": "Projetos"
782
+ },
783
+ "health": {
784
+ "good": "Saudável",
785
+ "warning": "Atenção",
786
+ "danger": "Crítico"
725
787
  },
726
788
  "sections": {
727
789
  "overview": "Visão geral",
@@ -750,14 +812,92 @@
750
812
  "deliveryHealthDescription": "Leitura visual de alocação e ritmo operacional da equipe.",
751
813
  "quickRadar": "Radar rápido",
752
814
  "quickRadarDescription": "Sinais para tomada de decisão no curto prazo.",
815
+ "timeline": "Timeline operacional",
816
+ "timelineDescription": "Atividade recente agrupada por dia, preparada para filtros e carregamento incremental.",
753
817
  "taskBoard": "Quadro de tarefas",
754
818
  "taskBoardDescription": "Board estilo Kanban com arraste entre colunas e detalhe lateral da tarefa.",
755
819
  "archivedTasks": "Tarefas arquivadas",
756
820
  "archivedTasksDescription": "Consulte tarefas arquivadas do projeto, veja detalhes, desarquive ou exclua definitivamente."
757
821
  },
758
822
  "charts": {
823
+ "projectProgress": "Progresso",
824
+ "burnup": "Burnup / progresso",
825
+ "burnupDescription": "Evolução acumulada de horas lançadas contra o ritmo planejado.",
759
826
  "allocationByCollaborator": "Alocação por colaborador",
760
- "weeklyVelocity": "Velocidade semanal"
827
+ "allocationDescription": "Distribuição de capacidade entre os colaboradores do projeto.",
828
+ "weeklyVelocity": "Velocidade semanal",
829
+ "weeklyVelocityDescription": "Ritmo recente de horas registradas pela equipe.",
830
+ "taskDistribution": "Distribuição de tasks",
831
+ "taskDistributionDescription": "Volume de tarefas por etapa do quadro.",
832
+ "operationalHealth": "Saúde operacional",
833
+ "operationalHealthDescription": "Score visual calculado a partir de progresso, pendências e alocação.",
834
+ "healthScore": "Score operacional",
835
+ "start": "Início",
836
+ "current": "Atual",
837
+ "emptyTitle": "Sem dados suficientes",
838
+ "emptyBurnup": "O burnup aparecerá quando houver progresso ou horas registradas.",
839
+ "emptyVelocity": "Ainda não há velocidade semanal retornada para este projeto.",
840
+ "emptyAllocation": "A alocação aparecerá quando colaboradores forem vinculados.",
841
+ "emptyTasks": "As tarefas aparecerão quando o quadro tiver itens."
842
+ },
843
+ "kanban": {
844
+ "items": "itens",
845
+ "progress": "Progresso",
846
+ "searchPlaceholder": "Buscar task, tag, responsável...",
847
+ "filters": "Filtros",
848
+ "allPriorities": "Todas prioridades",
849
+ "groupStatus": "Agrupar por status",
850
+ "noEstimate": "Sem estim.",
851
+ "emptyColumn": "Nenhuma task nesta coluna.",
852
+ "noFilteredTasks": "Nenhuma task corresponde aos filtros."
853
+ },
854
+ "timeline": {
855
+ "projectStarted": "Projeto iniciado",
856
+ "projectStartedDescription": "{project} entrou em execução.",
857
+ "taskCreated": "Task criada",
858
+ "taskCompleted": "Task concluída",
859
+ "commentAdded": "Comentário registrado",
860
+ "timesheetLogged": "Timesheet lançado",
861
+ "timesheetLoggedDescription": "{count} timesheet(s), totalizando {hours}.",
862
+ "approvalPending": "Aprovação pendente",
863
+ "approvalPendingDescription": "{count} item(ns) aguardando revisão.",
864
+ "completedTasks": "Tarefas concluídas",
865
+ "completedTasksDescription": "{count} tarefa(s) já concluída(s)",
866
+ "pendingTimesheets": "Timesheets pendentes",
867
+ "pendingTimesheetsDescription": "{count} timesheet(s) aguardando revisão",
868
+ "targetDate": "Data alvo",
869
+ "targetDateDescription": "Deadline operacional do projeto.",
870
+ "loadMore": "Carregar mais eventos",
871
+ "emptyTitle": "Sem atividade para exibir",
872
+ "empty": "Ainda não há eventos operacionais suficientes para montar uma timeline.",
873
+ "filters": {
874
+ "all": "Todos eventos",
875
+ "task": "Tasks",
876
+ "status": "Status",
877
+ "timesheet": "Timesheets",
878
+ "approval": "Aprovações",
879
+ "comment": "Comentários"
880
+ },
881
+ "types": {
882
+ "task": "Task",
883
+ "timesheet": "Timesheet",
884
+ "approval": "Aprovação",
885
+ "comment": "Comentário",
886
+ "status": "Status"
887
+ }
888
+ },
889
+ "teamPanel": {
890
+ "available": "Disponíveis",
891
+ "highAllocation": "Alocação alta",
892
+ "overload": "Sobrecarga",
893
+ "usedHours": "Horas utilizadas",
894
+ "availability": "Disponibilidade",
895
+ "overloadWarning": "{value}% acima da capacidade recomendada.",
896
+ "status": {
897
+ "available": "Disponível",
898
+ "high": "Alta carga",
899
+ "overload": "Sobrecarga"
900
+ }
761
901
  },
762
902
  "quickRadar": {
763
903
  "activeAssignments": "Atribuições ativas",
@@ -777,6 +917,7 @@
777
917
  "estimateLabel": "Estimativa (h)",
778
918
  "tagsLabel": "Etiquetas",
779
919
  "tagsPlaceholder": "planejamento, cliente, design (separadas por vírgula)",
920
+ "attachmentsLabel": "Anexos",
780
921
  "saving": "Salvando..."
781
922
  },
782
923
  "dialogs": {
@@ -0,0 +1,23 @@
1
+ columns:
2
+ - type: pk
3
+ - name: operations_task_id
4
+ type: fk
5
+ references:
6
+ table: operations_task
7
+ column: id
8
+ onDelete: CASCADE
9
+ onUpdate: CASCADE
10
+ - name: file_id
11
+ type: fk
12
+ isNullable: true
13
+ references:
14
+ table: file
15
+ column: id
16
+ onDelete: SET NULL
17
+ onUpdate: CASCADE
18
+ - type: created_at
19
+ - type: updated_at
20
+
21
+ indices:
22
+ - columns: [operations_task_id]
23
+ - columns: [file_id]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hed-hog/operations",
3
- "version": "0.0.321",
3
+ "version": "0.0.322",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "dependencies": {
@@ -10,12 +10,12 @@
10
10
  "@nestjs/jwt": "^11",
11
11
  "@nestjs/mapped-types": "*",
12
12
  "@hed-hog/api": "0.0.8",
13
- "@hed-hog/api-prisma": "0.0.6",
14
- "@hed-hog/api-pagination": "0.0.7",
15
13
  "@hed-hog/api-locale": "0.0.14",
16
- "@hed-hog/core": "0.0.321",
14
+ "@hed-hog/api-prisma": "0.0.6",
17
15
  "@hed-hog/api-types": "0.0.1",
18
- "@hed-hog/contact": "0.0.321"
16
+ "@hed-hog/api-pagination": "0.0.7",
17
+ "@hed-hog/core": "0.0.322",
18
+ "@hed-hog/contact": "0.0.322"
19
19
  },
20
20
  "exports": {
21
21
  ".": {
@@ -1,15 +1,18 @@
1
1
  import { Role, User } from '@hed-hog/api';
2
2
  import {
3
- Body,
4
- Controller,
5
- Delete,
6
- Get,
7
- Param,
8
- ParseIntPipe,
9
- Patch,
10
- Post,
11
- Query,
3
+ Body,
4
+ Controller,
5
+ Delete,
6
+ Get,
7
+ Param,
8
+ ParseIntPipe,
9
+ Patch,
10
+ Post,
11
+ Query,
12
+ UploadedFile,
13
+ UseInterceptors,
12
14
  } from '@nestjs/common';
15
+ import { FileInterceptor } from '@nestjs/platform-express';
13
16
  import { CreateOperationsTaskDto } from '../dto/create-task.dto';
14
17
  import { ListMyTasksDto } from '../dto/list-my-tasks.dto';
15
18
  import { ListOperationsTasksDto } from '../dto/list-tasks.dto';
@@ -80,4 +83,35 @@ export class OperationsTasksController {
80
83
  permanent === 'true'
81
84
  );
82
85
  }
86
+
87
+ @Get('tasks/:id/files')
88
+ listTaskFiles(
89
+ @User() user,
90
+ @Param('id', ParseIntPipe) id: number,
91
+ ) {
92
+ return this.operationsService.listTaskFiles(Number(user?.id || 0), id);
93
+ }
94
+
95
+ @Post('tasks/:id/files')
96
+ @UseInterceptors(FileInterceptor('file'))
97
+ addTaskFile(
98
+ @User() user,
99
+ @Param('id', ParseIntPipe) id: number,
100
+ @UploadedFile() file: MulterFile,
101
+ ) {
102
+ return this.operationsService.addTaskFile(Number(user?.id || 0), id, file);
103
+ }
104
+
105
+ @Delete('tasks/:id/files/:fileRelationId')
106
+ removeTaskFile(
107
+ @User() user,
108
+ @Param('id', ParseIntPipe) id: number,
109
+ @Param('fileRelationId', ParseIntPipe) fileRelationId: number,
110
+ ) {
111
+ return this.operationsService.removeTaskFile(
112
+ Number(user?.id || 0),
113
+ id,
114
+ fileRelationId
115
+ );
116
+ }
83
117
  }
@@ -36,7 +36,6 @@ export class CreateOperationsTaskDto {
36
36
 
37
37
  @IsOptional()
38
38
  @IsString({ message: 'description must be a string' })
39
- @MaxLength(2000, { message: 'description must have at most 2000 characters' })
40
39
  description?: string;
41
40
 
42
41
  @IsOptional()
@@ -38,7 +38,6 @@ export class UpdateOperationsTaskDto {
38
38
 
39
39
  @IsOptional()
40
40
  @IsString({ message: 'description must be a string' })
41
- @MaxLength(2000, { message: 'description must have at most 2000 characters' })
42
41
  description?: string;
43
42
 
44
43
  @IsOptional()
@@ -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: {