@hed-hog/operations 0.0.305 → 0.0.306

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 (39) hide show
  1. package/dist/controllers/operations-timesheets.controller.d.ts +21 -0
  2. package/dist/controllers/operations-timesheets.controller.d.ts.map +1 -1
  3. package/dist/controllers/operations-timesheets.controller.js +12 -0
  4. package/dist/controllers/operations-timesheets.controller.js.map +1 -1
  5. package/dist/dto/update-collaborator-type.dto.d.ts +3 -1
  6. package/dist/dto/update-collaborator-type.dto.d.ts.map +1 -1
  7. package/dist/dto/update-collaborator-type.dto.js +2 -1
  8. package/dist/dto/update-collaborator-type.dto.js.map +1 -1
  9. package/dist/operations.service.d.ts +22 -0
  10. package/dist/operations.service.d.ts.map +1 -1
  11. package/dist/operations.service.js +180 -47
  12. package/dist/operations.service.js.map +1 -1
  13. package/dist/operations.service.spec.js +73 -0
  14. package/dist/operations.service.spec.js.map +1 -1
  15. package/hedhog/data/menu.yaml +26 -26
  16. package/hedhog/data/operations_collaborator_type.yaml +76 -76
  17. package/hedhog/data/route.yaml +13 -0
  18. package/hedhog/frontend/app/_components/async-options-combobox.tsx.ejs +5 -3
  19. package/hedhog/frontend/app/_components/timesheet-task-create-sheet.tsx.ejs +1 -0
  20. package/hedhog/frontend/app/approvals/page.tsx.ejs +2 -2
  21. package/hedhog/frontend/app/collaborator-types/page.tsx.ejs +26 -15
  22. package/hedhog/frontend/app/schedule-adjustments/page.tsx.ejs +235 -72
  23. package/hedhog/frontend/app/timesheets/page.tsx.ejs +344 -134
  24. package/hedhog/frontend/messages/en.json +5 -0
  25. package/hedhog/frontend/messages/pt.json +7 -2
  26. package/hedhog/table/operations_collaborator.yaml +18 -18
  27. package/hedhog/table/operations_collaborator_equity_participation.yaml +43 -43
  28. package/hedhog/table/operations_collaborator_type.yaml +33 -33
  29. package/hedhog/table/operations_contract_document.yaml +33 -33
  30. package/package.json +4 -4
  31. package/src/controllers/operations-timesheets.controller.ts +13 -0
  32. package/src/dto/create-collaborator-type.dto.ts +43 -43
  33. package/src/dto/create-collaborator.dto.ts +223 -223
  34. package/src/dto/list-collaborator-types.dto.ts +15 -15
  35. package/src/dto/list-collaborators.dto.ts +30 -30
  36. package/src/dto/update-collaborator-type.dto.ts +4 -3
  37. package/src/dto/update-collaborator.dto.ts +3 -3
  38. package/src/operations.service.spec.ts +96 -0
  39. package/src/operations.service.ts +257 -47
@@ -2065,22 +2065,19 @@ export class OperationsService {
2065
2065
  const actor = await this.getActorContext(userId);
2066
2066
  this.ensureCollaborator(actor);
2067
2067
 
2068
- if (!actor.collaboratorId) {
2069
- throw new BadRequestException('Collaborator context is required.');
2070
- }
2071
-
2072
2068
  const pagination = this.normalizePaginationParams(paginationParams, {
2073
2069
  defaultSortField: 'name',
2074
2070
  defaultSortOrder: 'asc',
2075
2071
  allowedSortFields: ['name', 'code', 'clientName', 'startDate', 'endDate'],
2076
2072
  });
2077
2073
 
2078
- const params: unknown[] = [actor.collaboratorId];
2074
+ const filter = this.buildIdFilter(actor.visibleProjectIds, 'p.id', actor.isDirector);
2075
+ const params: unknown[] = [...filter.params];
2079
2076
  const filters = [
2080
2077
  'p.deleted_at IS NULL',
2081
2078
  'pa.deleted_at IS NULL',
2082
- `pa.collaborator_id = $1`,
2083
2079
  `pa.status IN ('planned', 'active')`,
2080
+ filter.clause,
2084
2081
  ];
2085
2082
 
2086
2083
  if (pagination.search) {
@@ -2174,23 +2171,30 @@ export class OperationsService {
2174
2171
  const actor = await this.getActorContext(userId);
2175
2172
  this.ensureCollaborator(actor);
2176
2173
 
2177
- if (!actor.collaboratorId) {
2178
- throw new BadRequestException('Collaborator context is required.');
2179
- }
2180
-
2181
2174
  const pagination = this.normalizePaginationParams(paginationParams, {
2182
2175
  defaultSortField: 'name',
2183
2176
  defaultSortOrder: 'asc',
2184
2177
  allowedSortFields: ['name', 'projectName', 'status', 'createdAt'],
2185
2178
  });
2186
2179
 
2187
- const params: unknown[] = [actor.collaboratorId];
2180
+ const projectFilter = this.buildIdFilter(
2181
+ actor.visibleProjectIds,
2182
+ 'COALESCE(t.project_id, pa.project_id)',
2183
+ actor.isDirector
2184
+ );
2185
+ const params: unknown[] = [...projectFilter.params];
2188
2186
  const filters = [
2189
2187
  't.deleted_at IS NULL',
2190
- 'pa.deleted_at IS NULL',
2191
2188
  'p.deleted_at IS NULL',
2192
- `pa.collaborator_id = $1`,
2193
- `pa.status IN ('planned', 'active')`,
2189
+ projectFilter.clause,
2190
+ `(
2191
+ t.project_id IS NOT NULL
2192
+ OR (
2193
+ pa.id IS NOT NULL
2194
+ AND pa.deleted_at IS NULL
2195
+ AND pa.status IN ('planned', 'active')
2196
+ )
2197
+ )`,
2194
2198
  ];
2195
2199
 
2196
2200
  if (pagination.search) {
@@ -2208,7 +2212,12 @@ export class OperationsService {
2208
2212
  }
2209
2213
 
2210
2214
  if (paginationParams.projectId) {
2211
- filters.push(`pa.project_id = ${this.param(params, paginationParams.projectId)}`);
2215
+ filters.push(
2216
+ `COALESCE(t.project_id, pa.project_id) = ${this.param(
2217
+ params,
2218
+ paginationParams.projectId
2219
+ )}`
2220
+ );
2212
2221
  }
2213
2222
 
2214
2223
  if (paginationParams.status) {
@@ -2219,10 +2228,10 @@ export class OperationsService {
2219
2228
  const totalRow = await this.querySingle<{ total: string }>(
2220
2229
  `SELECT COUNT(*)::text AS total
2221
2230
  FROM operations_task t
2222
- JOIN operations_project_assignment pa
2231
+ LEFT JOIN operations_project_assignment pa
2223
2232
  ON pa.id = t.project_assignment_id
2224
2233
  JOIN operations_project p
2225
- ON p.id = pa.project_id
2234
+ ON p.id = COALESCE(t.project_id, pa.project_id)
2226
2235
  WHERE ${whereClause}`,
2227
2236
  params
2228
2237
  );
@@ -2254,16 +2263,16 @@ export class OperationsService {
2254
2263
  t.name,
2255
2264
  t.description,
2256
2265
  t.status,
2257
- pa.project_id AS "projectId",
2266
+ COALESCE(t.project_id, pa.project_id) AS "projectId",
2258
2267
  pa.id AS "projectAssignmentId",
2259
2268
  p.name AS "projectName",
2260
2269
  p.code AS "projectCode",
2261
2270
  t.created_at AS "createdAt"
2262
2271
  FROM operations_task t
2263
- JOIN operations_project_assignment pa
2272
+ LEFT JOIN operations_project_assignment pa
2264
2273
  ON pa.id = t.project_assignment_id
2265
2274
  JOIN operations_project p
2266
- ON p.id = pa.project_id
2275
+ ON p.id = COALESCE(t.project_id, pa.project_id)
2267
2276
  WHERE ${whereClause}
2268
2277
  ORDER BY ${sortColumn} ${pagination.sortOrder.toUpperCase()}, t.id ASC
2269
2278
  LIMIT ${limitPlaceholder}
@@ -2699,9 +2708,9 @@ export class OperationsService {
2699
2708
  ? await this.getOwnedTaskRecord(tx as any, actor.collaboratorId as number, data.taskId)
2700
2709
  : null;
2701
2710
 
2702
- if (resolvedTask && resolvedTask.projectAssignmentId !== assignment.id) {
2711
+ if (resolvedTask && resolvedTask.projectId !== assignment.projectId) {
2703
2712
  throw new BadRequestException(
2704
- 'The selected task does not belong to the chosen project assignment.'
2713
+ 'The selected task does not belong to the chosen project.'
2705
2714
  );
2706
2715
  }
2707
2716
 
@@ -2760,6 +2769,116 @@ export class OperationsService {
2760
2769
  return this.getTimesheetEntryByIdForActor(actor, createdEntryId);
2761
2770
  }
2762
2771
 
2772
+ async updateTimesheetEntry(
2773
+ userId: number,
2774
+ entryId: number,
2775
+ data: QuickTimesheetEntryPayload
2776
+ ) {
2777
+ const actor = await this.getActorContext(userId);
2778
+ this.ensureCollaborator(actor);
2779
+ this.requireFields(data as Record<string, unknown>, ['workDate', 'duration']);
2780
+
2781
+ if (!actor.collaboratorId && !actor.isDirector) {
2782
+ throw new BadRequestException('Collaborator context is required.');
2783
+ }
2784
+
2785
+ const entry = await this.getTimesheetEntryByIdForActor(actor, entryId);
2786
+
2787
+ if (!actor.isDirector && entry.collaboratorId !== actor.collaboratorId) {
2788
+ throw new ForbiddenException(
2789
+ 'Only the entry owner can update this timesheet entry.'
2790
+ );
2791
+ }
2792
+
2793
+ if (!['draft', 'rejected'].includes(entry.status)) {
2794
+ throw new BadRequestException(
2795
+ 'Only draft or rejected timesheet entries can be edited.'
2796
+ );
2797
+ }
2798
+
2799
+ const collaboratorId = actor.isDirector
2800
+ ? entry.collaboratorId
2801
+ : (actor.collaboratorId as number);
2802
+ const durationMinutes = this.normalizeDurationMinutes(data.duration, data.unit);
2803
+ const taskLabel =
2804
+ this.normalizeOptionalText(data.taskName) ??
2805
+ this.normalizeOptionalText(data.activityLabel);
2806
+ const targetWeek = this.getWorkWeekRange(data.workDate);
2807
+ const isSameWeek =
2808
+ entry.weekStartDate === targetWeek.weekStartDate &&
2809
+ entry.weekEndDate === targetWeek.weekEndDate;
2810
+
2811
+ await this.prisma.$transaction(async (tx) => {
2812
+ const assignment = await this.resolveOwnedProjectAssignment(
2813
+ tx as any,
2814
+ collaboratorId,
2815
+ {
2816
+ projectId: data.projectId ?? null,
2817
+ projectAssignmentId: data.projectAssignmentId ?? null,
2818
+ }
2819
+ );
2820
+
2821
+ const resolvedTask = data.taskId
2822
+ ? await this.getOwnedTaskRecord(tx as any, collaboratorId, data.taskId)
2823
+ : null;
2824
+
2825
+ if (resolvedTask && resolvedTask.projectId !== assignment.projectId) {
2826
+ throw new BadRequestException(
2827
+ 'The selected task does not belong to the chosen project.'
2828
+ );
2829
+ }
2830
+
2831
+ const activityLabel =
2832
+ resolvedTask?.name ?? taskLabel ?? assignment.roleLabel ?? assignment.projectName;
2833
+
2834
+ if (!activityLabel) {
2835
+ throw new BadRequestException('A task is required for the timesheet entry.');
2836
+ }
2837
+
2838
+ const targetTimesheetId = isSameWeek
2839
+ ? entry.timesheetId
2840
+ : await this.getOrCreateTimesheetForWorkDate(
2841
+ tx as any,
2842
+ collaboratorId,
2843
+ data.workDate
2844
+ );
2845
+
2846
+ await (tx as any).$executeRawUnsafe(
2847
+ `UPDATE operations_timesheet_entry
2848
+ SET timesheet_id = $1,
2849
+ project_assignment_id = $2,
2850
+ task_id = $3,
2851
+ activity_label = $4,
2852
+ work_date = $5::date,
2853
+ duration_minutes = $6,
2854
+ hours = $7,
2855
+ description = $8,
2856
+ updated_at = NOW()
2857
+ WHERE id = $9
2858
+ AND deleted_at IS NULL`,
2859
+ targetTimesheetId,
2860
+ assignment.id,
2861
+ resolvedTask?.id ?? data.taskId ?? null,
2862
+ activityLabel,
2863
+ data.workDate,
2864
+ durationMinutes,
2865
+ Number((durationMinutes / 60).toFixed(2)),
2866
+ this.normalizeOptionalText(data.description),
2867
+ entryId
2868
+ );
2869
+
2870
+ await this.refreshTimesheetTotal(tx as any, entry.timesheetId);
2871
+
2872
+ if (targetTimesheetId !== entry.timesheetId) {
2873
+ await this.refreshTimesheetTotal(tx as any, targetTimesheetId);
2874
+ }
2875
+
2876
+ await this.cleanupEmptyEditableTimesheet(tx as any, entry.timesheetId);
2877
+ });
2878
+
2879
+ return this.getTimesheetEntryByIdForActor(actor, entryId);
2880
+ }
2881
+
2763
2882
  async removeTimesheetEntry(userId: number, entryId: number) {
2764
2883
  const actor = await this.getActorContext(userId);
2765
2884
  this.ensureCollaborator(actor);
@@ -2793,6 +2912,7 @@ export class OperationsService {
2793
2912
  );
2794
2913
 
2795
2914
  await this.refreshTimesheetTotal(tx as any, entry.timesheetId);
2915
+ await this.cleanupEmptyEditableTimesheet(tx as any, entry.timesheetId);
2796
2916
  });
2797
2917
 
2798
2918
  return { success: true };
@@ -3129,7 +3249,11 @@ export class OperationsService {
3129
3249
  updated_at
3130
3250
  ) VALUES (
3131
3251
  $1, $2, $3, $4,
3132
- $5, $6, $7, $8, $9, $10, $11,
3252
+ $5::text::operations_contract_template_contract_category_49bb07a713_enum,
3253
+ $6::text::operations_contract_template_contract_type_3962dbda6a_enum,
3254
+ $7::text::operations_contract_template_billing_model_384a7c60e2_enum,
3255
+ $8::text::operations_contract_template_signature_status_56cb6d625b_enum,
3256
+ $9, $10::text::operations_contract_template_status_c9d2e90231_enum, $11,
3133
3257
  NOW(), NOW()
3134
3258
  )
3135
3259
  RETURNING id`,
@@ -3218,12 +3342,12 @@ export class OperationsService {
3218
3342
  code = $2,
3219
3343
  name = $3,
3220
3344
  description = $4,
3221
- contract_category = $5,
3222
- contract_type = $6,
3223
- billing_model = $7,
3224
- signature_status = $8,
3345
+ contract_category = $5::text::operations_contract_template_contract_category_49bb07a713_enum,
3346
+ contract_type = $6::text::operations_contract_template_contract_type_3962dbda6a_enum,
3347
+ billing_model = $7::text::operations_contract_template_billing_model_384a7c60e2_enum,
3348
+ signature_status = $8::text::operations_contract_template_signature_status_56cb6d625b_enum,
3225
3349
  is_active = $9,
3226
- status = $10,
3350
+ status = $10::text::operations_contract_template_status_c9d2e90231_enum,
3227
3351
  content_html = $11,
3228
3352
  updated_at = NOW()
3229
3353
  WHERE id = $12`,
@@ -5126,8 +5250,26 @@ export class OperationsService {
5126
5250
  }
5127
5251
 
5128
5252
  const collaborator = await this.getCollaboratorById(current.collaboratorId);
5253
+
5254
+ const projectManagerRow = await this.querySingle<{
5255
+ managerCollaboratorId: number | null;
5256
+ }>(
5257
+ `SELECT p.manager_collaborator_id AS "managerCollaboratorId"
5258
+ FROM operations_timesheet_entry e
5259
+ LEFT JOIN operations_project_assignment pa ON pa.id = e.project_assignment_id
5260
+ LEFT JOIN operations_project p ON p.id = pa.project_id
5261
+ WHERE e.timesheet_id = $1
5262
+ AND e.deleted_at IS NULL
5263
+ AND p.manager_collaborator_id IS NOT NULL
5264
+ LIMIT 1`,
5265
+ [timesheetId]
5266
+ );
5267
+
5129
5268
  const approverId =
5130
- current.approverCollaboratorId ?? collaborator.supervisorId ?? null;
5269
+ projectManagerRow?.managerCollaboratorId ??
5270
+ current.approverCollaboratorId ??
5271
+ collaborator.supervisorId ??
5272
+ null;
5131
5273
 
5132
5274
  if (!approverId) {
5133
5275
  throw new BadRequestException(
@@ -5360,7 +5502,8 @@ export class OperationsService {
5360
5502
  endTime: string | null;
5361
5503
  breakMinutes: number | null;
5362
5504
  }>(
5363
- `SELECT collaborator_id AS "collaboratorId",
5505
+ `SELECT DISTINCT ON (collaborator_id, weekday)
5506
+ collaborator_id AS "collaboratorId",
5364
5507
  weekday,
5365
5508
  is_working_day AS "isWorkingDay",
5366
5509
  start_time AS "startTime",
@@ -5368,7 +5511,7 @@ export class OperationsService {
5368
5511
  break_minutes AS "breakMinutes"
5369
5512
  FROM operations_collaborator_schedule_day
5370
5513
  WHERE collaborator_id = ANY($1::int[])
5371
- ORDER BY id ASC`,
5514
+ ORDER BY collaborator_id, weekday, id DESC`,
5372
5515
  [this.uniqueNumbers(requests.map((item) => item.collaboratorId))]
5373
5516
  );
5374
5517
 
@@ -5821,21 +5964,51 @@ export class OperationsService {
5821
5964
  private async getCollaboratorByUserId(userId: number) {
5822
5965
  return this.querySingle<{
5823
5966
  id: number;
5967
+ userId: number | null;
5968
+ personId: number | null;
5824
5969
  displayName: string;
5825
5970
  supervisorId: number | null;
5826
5971
  supervisorName: string | null;
5972
+ activeAssignments: number;
5827
5973
  }>(
5828
- `SELECT c.id,
5974
+ `WITH linked_collaborator AS (
5975
+ SELECT person_id
5976
+ FROM operations_collaborator
5977
+ WHERE user_id = $1
5978
+ AND deleted_at IS NULL
5979
+ ORDER BY id DESC
5980
+ LIMIT 1
5981
+ )
5982
+ SELECT c.id,
5983
+ c.user_id AS "userId",
5984
+ c.person_id AS "personId",
5829
5985
  COALESCE(NULLIF(c.display_name, ''), person_record.name) AS "displayName",
5830
5986
  s.id AS "supervisorId",
5831
- s.display_name AS "supervisorName"
5987
+ s.display_name AS "supervisorName",
5988
+ COUNT(pa.id) FILTER (
5989
+ WHERE pa.deleted_at IS NULL
5990
+ AND pa.status IN ('planned', 'active')
5991
+ )::int AS "activeAssignments"
5832
5992
  FROM operations_collaborator c
5833
5993
  LEFT JOIN person person_record
5834
5994
  ON person_record.id = c.person_id
5835
5995
  LEFT JOIN operations_collaborator s
5836
5996
  ON s.id = c.supervisor_collaborator_id
5837
- WHERE c.user_id = $1
5838
- AND c.deleted_at IS NULL`,
5997
+ LEFT JOIN operations_project_assignment pa
5998
+ ON pa.collaborator_id = c.id
5999
+ WHERE c.deleted_at IS NULL
6000
+ AND (
6001
+ c.user_id = $1
6002
+ OR (
6003
+ c.person_id IS NOT NULL
6004
+ AND c.person_id = (SELECT person_id FROM linked_collaborator)
6005
+ )
6006
+ )
6007
+ GROUP BY c.id, person_record.id, s.id
6008
+ ORDER BY "activeAssignments" DESC,
6009
+ CASE WHEN c.user_id = $1 THEN 0 ELSE 1 END,
6010
+ c.id ASC
6011
+ LIMIT 1`,
5839
6012
  [userId]
5840
6013
  );
5841
6014
  }
@@ -7705,23 +7878,26 @@ export class OperationsService {
7705
7878
  const assignmentIds = entries
7706
7879
  .map((entry) => entry.projectAssignmentId)
7707
7880
  .filter((value): value is number => typeof value === 'number');
7881
+ const assignmentMap = new Map<
7882
+ number,
7883
+ { id: number; collaboratorId: number; projectId: number }
7884
+ >();
7708
7885
 
7709
7886
  if (assignmentIds.length) {
7710
7887
  const assignments = (await client.$queryRawUnsafe(
7711
- `SELECT id, collaborator_id AS "collaboratorId"
7888
+ `SELECT id,
7889
+ collaborator_id AS "collaboratorId",
7890
+ project_id AS "projectId"
7712
7891
  FROM operations_project_assignment
7713
7892
  WHERE id = ANY($1::int[])
7714
7893
  AND deleted_at IS NULL`,
7715
7894
  assignmentIds
7716
- )) as { id: number; collaboratorId: number }[];
7717
- const assignmentMap = new Map(
7718
- assignments.map((assignment) => [
7719
- assignment.id,
7720
- assignment.collaboratorId,
7721
- ])
7722
- );
7895
+ )) as { id: number; collaboratorId: number; projectId: number }[];
7896
+ assignments.forEach((assignment) => {
7897
+ assignmentMap.set(assignment.id, assignment);
7898
+ });
7723
7899
  for (const assignmentId of assignmentIds) {
7724
- if (assignmentMap.get(assignmentId) !== collaboratorId) {
7900
+ if (assignmentMap.get(assignmentId)?.collaboratorId !== collaboratorId) {
7725
7901
  throw new ForbiddenException(
7726
7902
  'Timesheet entries must use assignments owned by the target collaborator.'
7727
7903
  );
@@ -7735,14 +7911,17 @@ export class OperationsService {
7735
7911
  const resolvedTask = entry.taskId
7736
7912
  ? await this.getOwnedTaskRecord(client, collaboratorId, entry.taskId)
7737
7913
  : null;
7914
+ const selectedAssignment = entry.projectAssignmentId
7915
+ ? assignmentMap.get(entry.projectAssignmentId) ?? null
7916
+ : null;
7738
7917
 
7739
7918
  if (
7740
7919
  resolvedTask &&
7741
- entry.projectAssignmentId &&
7742
- resolvedTask.projectAssignmentId !== entry.projectAssignmentId
7920
+ selectedAssignment &&
7921
+ resolvedTask.projectId !== selectedAssignment.projectId
7743
7922
  ) {
7744
7923
  throw new BadRequestException(
7745
- 'The selected task does not belong to the chosen project assignment.'
7924
+ 'The selected task does not belong to the chosen project.'
7746
7925
  );
7747
7926
  }
7748
7927
 
@@ -7816,6 +7995,37 @@ export class OperationsService {
7816
7995
  );
7817
7996
  }
7818
7997
 
7998
+ private async cleanupEmptyEditableTimesheet(client: any, timesheetId: number) {
7999
+ const candidate = (await client.$queryRawUnsafe(
8000
+ `SELECT t.id
8001
+ FROM operations_timesheet t
8002
+ WHERE t.id = $1
8003
+ AND t.deleted_at IS NULL
8004
+ AND t.status IN ('draft', 'rejected')
8005
+ AND NOT EXISTS (
8006
+ SELECT 1
8007
+ FROM operations_timesheet_entry e
8008
+ WHERE e.timesheet_id = t.id
8009
+ AND e.deleted_at IS NULL
8010
+ )
8011
+ LIMIT 1`,
8012
+ timesheetId
8013
+ )) as Array<{ id: number }>;
8014
+
8015
+ if (!candidate[0]?.id) {
8016
+ return;
8017
+ }
8018
+
8019
+ await client.$executeRawUnsafe(
8020
+ `UPDATE operations_timesheet
8021
+ SET deleted_at = NOW(),
8022
+ updated_at = NOW()
8023
+ WHERE id = $1
8024
+ AND deleted_at IS NULL`,
8025
+ timesheetId
8026
+ );
8027
+ }
8028
+
7819
8029
  private async upsertApproval(
7820
8030
  client: any,
7821
8031
  input: {