@hed-hog/operations 0.0.304 → 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 (81) hide show
  1. package/dist/controllers/operations-projects.controller.d.ts +15 -0
  2. package/dist/controllers/operations-projects.controller.d.ts.map +1 -1
  3. package/dist/controllers/operations-tasks.controller.d.ts +41 -10
  4. package/dist/controllers/operations-tasks.controller.d.ts.map +1 -1
  5. package/dist/controllers/operations-tasks.controller.js +11 -0
  6. package/dist/controllers/operations-tasks.controller.js.map +1 -1
  7. package/dist/controllers/operations-timesheets.controller.d.ts +21 -0
  8. package/dist/controllers/operations-timesheets.controller.d.ts.map +1 -1
  9. package/dist/controllers/operations-timesheets.controller.js +12 -0
  10. package/dist/controllers/operations-timesheets.controller.js.map +1 -1
  11. package/dist/dto/create-task.dto.d.ts +7 -1
  12. package/dist/dto/create-task.dto.d.ts.map +1 -1
  13. package/dist/dto/create-task.dto.js +38 -5
  14. package/dist/dto/create-task.dto.js.map +1 -1
  15. package/dist/dto/list-tasks.dto.d.ts +1 -1
  16. package/dist/dto/list-tasks.dto.d.ts.map +1 -1
  17. package/dist/dto/list-tasks.dto.js +2 -2
  18. package/dist/dto/list-tasks.dto.js.map +1 -1
  19. package/dist/dto/update-collaborator-type.dto.d.ts +3 -1
  20. package/dist/dto/update-collaborator-type.dto.d.ts.map +1 -1
  21. package/dist/dto/update-collaborator-type.dto.js +2 -1
  22. package/dist/dto/update-collaborator-type.dto.js.map +1 -1
  23. package/dist/dto/update-task.dto.d.ts +7 -1
  24. package/dist/dto/update-task.dto.d.ts.map +1 -1
  25. package/dist/dto/update-task.dto.js +38 -5
  26. package/dist/dto/update-task.dto.js.map +1 -1
  27. package/dist/operations.service.d.ts +90 -12
  28. package/dist/operations.service.d.ts.map +1 -1
  29. package/dist/operations.service.js +560 -148
  30. package/dist/operations.service.js.map +1 -1
  31. package/dist/operations.service.spec.js +73 -0
  32. package/dist/operations.service.spec.js.map +1 -1
  33. package/hedhog/data/menu.yaml +26 -26
  34. package/hedhog/data/operations_collaborator_type.yaml +76 -76
  35. package/hedhog/data/route.yaml +26 -0
  36. package/hedhog/frontend/app/_components/async-options-combobox.tsx.ejs +5 -3
  37. package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +44 -44
  38. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +168 -213
  39. package/hedhog/frontend/app/_components/collaborator-select-with-create.tsx.ejs +256 -256
  40. package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +7 -7
  41. package/hedhog/frontend/app/_components/contract-template-form-screen.tsx.ejs +306 -306
  42. package/hedhog/frontend/app/_components/contract-template-select-with-create.tsx.ejs +247 -247
  43. package/hedhog/frontend/app/_components/contract-wizard-sheet.tsx.ejs +3520 -3520
  44. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +1504 -52
  45. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +528 -403
  46. package/hedhog/frontend/app/_components/section-card.tsx.ejs +25 -18
  47. package/hedhog/frontend/app/_components/system-user-select-with-create.tsx.ejs +609 -0
  48. package/hedhog/frontend/app/_components/timesheet-task-create-sheet.tsx.ejs +1 -0
  49. package/hedhog/frontend/app/_lib/types.ts.ejs +5 -0
  50. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +7 -7
  51. package/hedhog/frontend/app/_lib/utils/forms.ts.ejs +48 -1
  52. package/hedhog/frontend/app/approvals/page.tsx.ejs +2 -2
  53. package/hedhog/frontend/app/collaborator-types/page.tsx.ejs +513 -502
  54. package/hedhog/frontend/app/collaborators/page.tsx.ejs +10 -7
  55. package/hedhog/frontend/app/contracts/page.tsx.ejs +938 -938
  56. package/hedhog/frontend/app/projects/[id]/edit/page.tsx.ejs +1 -1
  57. package/hedhog/frontend/app/projects/page.tsx.ejs +360 -133
  58. package/hedhog/frontend/app/schedule-adjustments/page.tsx.ejs +235 -72
  59. package/hedhog/frontend/app/timesheets/page.tsx.ejs +344 -134
  60. package/hedhog/frontend/messages/en.json +32 -4
  61. package/hedhog/frontend/messages/pt.json +34 -6
  62. package/hedhog/table/operations_collaborator.yaml +18 -18
  63. package/hedhog/table/operations_collaborator_equity_participation.yaml +43 -43
  64. package/hedhog/table/operations_collaborator_type.yaml +33 -33
  65. package/hedhog/table/operations_contract_document.yaml +33 -33
  66. package/hedhog/table/operations_project.yaml +9 -0
  67. package/hedhog/table/operations_task.yaml +43 -4
  68. package/package.json +6 -6
  69. package/src/controllers/operations-tasks.controller.ts +11 -0
  70. package/src/controllers/operations-timesheets.controller.ts +13 -0
  71. package/src/dto/create-collaborator-type.dto.ts +43 -43
  72. package/src/dto/create-collaborator.dto.ts +223 -223
  73. package/src/dto/create-task.dto.ts +47 -7
  74. package/src/dto/list-collaborator-types.dto.ts +15 -15
  75. package/src/dto/list-collaborators.dto.ts +30 -30
  76. package/src/dto/list-tasks.dto.ts +3 -3
  77. package/src/dto/update-collaborator-type.dto.ts +4 -3
  78. package/src/dto/update-collaborator.dto.ts +3 -3
  79. package/src/dto/update-task.dto.ts +47 -7
  80. package/src/operations.service.spec.ts +96 -0
  81. package/src/operations.service.ts +813 -135
@@ -107,7 +107,7 @@ const FINANCIAL_TERM_TYPE_VALUES = ['value', 'payment', 'revenue', 'fine', 'othe
107
107
  const RECURRENCE_VALUES = ['one_time', 'monthly', 'quarterly', 'yearly', 'other'] as const;
108
108
  const REVISION_TYPE_VALUES = ['amendment', 'renewal', 'revision', 'addendum', 'other'] as const;
109
109
  const REVISION_STATUS_VALUES = ['draft', 'active', 'completed', 'cancelled'] as const;
110
- const TASK_STATUS_VALUES = ['active', 'completed', 'archived'] as const;
110
+ const TASK_STATUS_VALUES = ['todo', 'doing', 'review', 'done'] as const;
111
111
 
112
112
  type ApprovalAction = 'approve' | 'reject';
113
113
  type ApprovalTargetType =
@@ -548,6 +548,7 @@ type ProjectPayload = {
548
548
  contractId?: number | null;
549
549
  contractTemplateId?: number | null;
550
550
  managerCollaboratorId?: number | null;
551
+ clientPersonId?: number | null;
551
552
  code: string;
552
553
  name: string;
553
554
  clientName?: string | null;
@@ -606,9 +607,15 @@ type TimesheetEntryPayload = {
606
607
  type TaskPayload = {
607
608
  projectId?: number | null;
608
609
  projectAssignmentId?: number | null;
610
+ assigneeCollaboratorId?: number | null;
609
611
  name?: string | null;
610
612
  description?: string | null;
613
+ priority?: 'low' | 'medium' | 'high';
611
614
  status?: (typeof TASK_STATUS_VALUES)[number];
615
+ dueDate?: string | null;
616
+ estimateHours?: number | null;
617
+ position?: number;
618
+ tags?: string | null;
612
619
  };
613
620
 
614
621
  type QuickTimesheetEntryPayload = {
@@ -1743,6 +1750,28 @@ export class OperationsService {
1743
1750
  data.equityParticipation
1744
1751
  );
1745
1752
  }
1753
+
1754
+ if (
1755
+ data.compensationAmount !== undefined ||
1756
+ data.contractDescription !== undefined ||
1757
+ data.autoGenerateContractDraft !== undefined ||
1758
+ data.joinedAt !== undefined ||
1759
+ data.weeklyCapacityHours !== undefined ||
1760
+ data.supervisorCollaboratorId !== undefined ||
1761
+ data.collaboratorType !== undefined ||
1762
+ data.collaboratorTypeId !== undefined ||
1763
+ data.collaboratorTypeSlug !== undefined ||
1764
+ data.code !== undefined ||
1765
+ data.personId !== undefined ||
1766
+ data.displayName !== undefined
1767
+ ) {
1768
+ await this.syncHiringContractDraft(
1769
+ tx as any,
1770
+ actor.userId,
1771
+ collaboratorId,
1772
+ data
1773
+ );
1774
+ }
1746
1775
  });
1747
1776
 
1748
1777
  return this.getCollaboratorByIdForUser(userId, collaboratorId);
@@ -2002,8 +2031,8 @@ export class OperationsService {
2002
2031
  p.progress_percent AS "progressPercent",
2003
2032
  p.delivery_model AS "deliveryModel",
2004
2033
  p.budget_amount AS "budgetAmount",
2005
- p.start_date AS "startDate",
2006
- p.end_date AS "endDate",
2034
+ TO_CHAR(p.start_date, 'YYYY-MM-DD') AS "startDate",
2035
+ TO_CHAR(p.end_date, 'YYYY-MM-DD') AS "endDate",
2007
2036
  c.name AS "contractName",
2008
2037
  c.status AS "contractStatus",
2009
2038
  m.display_name AS "managerName",
@@ -2036,22 +2065,19 @@ export class OperationsService {
2036
2065
  const actor = await this.getActorContext(userId);
2037
2066
  this.ensureCollaborator(actor);
2038
2067
 
2039
- if (!actor.collaboratorId) {
2040
- throw new BadRequestException('Collaborator context is required.');
2041
- }
2042
-
2043
2068
  const pagination = this.normalizePaginationParams(paginationParams, {
2044
2069
  defaultSortField: 'name',
2045
2070
  defaultSortOrder: 'asc',
2046
2071
  allowedSortFields: ['name', 'code', 'clientName', 'startDate', 'endDate'],
2047
2072
  });
2048
2073
 
2049
- const params: unknown[] = [actor.collaboratorId];
2074
+ const filter = this.buildIdFilter(actor.visibleProjectIds, 'p.id', actor.isDirector);
2075
+ const params: unknown[] = [...filter.params];
2050
2076
  const filters = [
2051
2077
  'p.deleted_at IS NULL',
2052
2078
  'pa.deleted_at IS NULL',
2053
- `pa.collaborator_id = $1`,
2054
2079
  `pa.status IN ('planned', 'active')`,
2080
+ filter.clause,
2055
2081
  ];
2056
2082
 
2057
2083
  if (pagination.search) {
@@ -2105,8 +2131,8 @@ export class OperationsService {
2105
2131
  MAX(pa.id)::int AS "projectAssignmentId",
2106
2132
  MAX(pa.role_label) AS "roleLabel",
2107
2133
  p.status,
2108
- p.start_date AS "startDate",
2109
- p.end_date AS "endDate"
2134
+ TO_CHAR(p.start_date, 'YYYY-MM-DD') AS "startDate",
2135
+ TO_CHAR(p.end_date, 'YYYY-MM-DD') AS "endDate"
2110
2136
  FROM operations_project_assignment pa
2111
2137
  JOIN operations_project p
2112
2138
  ON p.id = pa.project_id
@@ -2145,23 +2171,30 @@ export class OperationsService {
2145
2171
  const actor = await this.getActorContext(userId);
2146
2172
  this.ensureCollaborator(actor);
2147
2173
 
2148
- if (!actor.collaboratorId) {
2149
- throw new BadRequestException('Collaborator context is required.');
2150
- }
2151
-
2152
2174
  const pagination = this.normalizePaginationParams(paginationParams, {
2153
2175
  defaultSortField: 'name',
2154
2176
  defaultSortOrder: 'asc',
2155
2177
  allowedSortFields: ['name', 'projectName', 'status', 'createdAt'],
2156
2178
  });
2157
2179
 
2158
- 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];
2159
2186
  const filters = [
2160
2187
  't.deleted_at IS NULL',
2161
- 'pa.deleted_at IS NULL',
2162
2188
  'p.deleted_at IS NULL',
2163
- `pa.collaborator_id = $1`,
2164
- `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
+ )`,
2165
2198
  ];
2166
2199
 
2167
2200
  if (pagination.search) {
@@ -2179,7 +2212,12 @@ export class OperationsService {
2179
2212
  }
2180
2213
 
2181
2214
  if (paginationParams.projectId) {
2182
- 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
+ );
2183
2221
  }
2184
2222
 
2185
2223
  if (paginationParams.status) {
@@ -2190,10 +2228,10 @@ export class OperationsService {
2190
2228
  const totalRow = await this.querySingle<{ total: string }>(
2191
2229
  `SELECT COUNT(*)::text AS total
2192
2230
  FROM operations_task t
2193
- JOIN operations_project_assignment pa
2231
+ LEFT JOIN operations_project_assignment pa
2194
2232
  ON pa.id = t.project_assignment_id
2195
2233
  JOIN operations_project p
2196
- ON p.id = pa.project_id
2234
+ ON p.id = COALESCE(t.project_id, pa.project_id)
2197
2235
  WHERE ${whereClause}`,
2198
2236
  params
2199
2237
  );
@@ -2225,16 +2263,16 @@ export class OperationsService {
2225
2263
  t.name,
2226
2264
  t.description,
2227
2265
  t.status,
2228
- pa.project_id AS "projectId",
2266
+ COALESCE(t.project_id, pa.project_id) AS "projectId",
2229
2267
  pa.id AS "projectAssignmentId",
2230
2268
  p.name AS "projectName",
2231
2269
  p.code AS "projectCode",
2232
2270
  t.created_at AS "createdAt"
2233
2271
  FROM operations_task t
2234
- JOIN operations_project_assignment pa
2272
+ LEFT JOIN operations_project_assignment pa
2235
2273
  ON pa.id = t.project_assignment_id
2236
2274
  JOIN operations_project p
2237
- ON p.id = pa.project_id
2275
+ ON p.id = COALESCE(t.project_id, pa.project_id)
2238
2276
  WHERE ${whereClause}
2239
2277
  ORDER BY ${sortColumn} ${pagination.sortOrder.toUpperCase()}, t.id ASC
2240
2278
  LIMIT ${limitPlaceholder}
@@ -2255,54 +2293,92 @@ export class OperationsService {
2255
2293
 
2256
2294
  async createTask(userId: number, data: TaskPayload) {
2257
2295
  const actor = await this.getActorContext(userId);
2258
- this.ensureCollaborator(actor);
2296
+ if (!actor.isCollaborator && !actor.isDirector && !actor.isSupervisor) {
2297
+ throw new ForbiddenException(
2298
+ 'Operations collaborator access is required.'
2299
+ );
2300
+ }
2259
2301
  this.requireFields(data as Record<string, unknown>, ['name']);
2260
2302
 
2261
- if (!actor.collaboratorId) {
2262
- throw new BadRequestException('Collaborator context is required.');
2303
+ let assignmentId: number | null = null;
2304
+ let projectId: number | null = null;
2305
+
2306
+ if (data.projectId || data.projectAssignmentId) {
2307
+ const assignment = await this.resolveProjectAssignmentForActor(
2308
+ this.prisma,
2309
+ actor,
2310
+ {
2311
+ projectId: data.projectId ?? null,
2312
+ projectAssignmentId: data.projectAssignmentId ?? null,
2313
+ }
2314
+ );
2315
+ await this.assertProjectAccess(actor, assignment.projectId);
2316
+ assignmentId = assignment.id;
2317
+ projectId = assignment.projectId;
2318
+ } else if (data.projectId) {
2319
+ projectId = data.projectId;
2320
+ await this.assertProjectAccess(actor, projectId);
2321
+ } else {
2322
+ throw new BadRequestException('Either projectId or projectAssignmentId is required.');
2263
2323
  }
2264
2324
 
2265
- const assignment = await this.resolveOwnedProjectAssignment(
2266
- this.prisma,
2267
- actor.collaboratorId,
2268
- {
2269
- projectId: data.projectId ?? null,
2270
- projectAssignmentId: data.projectAssignmentId ?? null,
2271
- }
2272
- );
2273
- await this.assertProjectAccess(actor, assignment.projectId);
2325
+ if (!projectId) {
2326
+ projectId = data.projectId ?? null;
2327
+ }
2274
2328
 
2275
2329
  const name = this.normalizeOptionalText(data.name);
2276
2330
  if (!name) {
2277
2331
  throw new BadRequestException('Field "name" is required.');
2278
2332
  }
2279
2333
 
2334
+ const maxPositionRow = await this.querySingle<{ max_pos: number | null }>(
2335
+ `SELECT MAX(position) AS max_pos
2336
+ FROM operations_task
2337
+ WHERE project_id = $1
2338
+ AND status = $2::operations_task_status_574c143dbe_enum
2339
+ AND deleted_at IS NULL`,
2340
+ [projectId, data.status ?? 'todo']
2341
+ );
2342
+ const nextPosition = ((maxPositionRow?.max_pos ?? -1) as number) + 1;
2343
+
2280
2344
  const created = await this.querySingle<{ id: number }>(
2281
2345
  `INSERT INTO operations_task (
2346
+ project_id,
2282
2347
  project_assignment_id,
2348
+ assignee_collaborator_id,
2283
2349
  name,
2284
2350
  description,
2351
+ priority,
2285
2352
  status,
2353
+ due_date,
2354
+ estimate_hours,
2355
+ position,
2356
+ tags,
2286
2357
  created_at,
2287
2358
  updated_at
2288
2359
  ) VALUES (
2289
- $1,
2290
- $2,
2291
- $3,
2292
- $4,
2293
- NOW(),
2294
- NOW()
2360
+ $1, $2, $3, $4, $5,
2361
+ $6::operations_task_priority_394ab327eb_enum,
2362
+ $7::operations_task_status_574c143dbe_enum,
2363
+ $8::date, $9::decimal, $10, $11, NOW(), NOW()
2295
2364
  )
2296
2365
  RETURNING id`,
2297
2366
  [
2298
- assignment.id,
2367
+ projectId,
2368
+ assignmentId,
2369
+ data.assigneeCollaboratorId ?? null,
2299
2370
  name,
2300
2371
  this.normalizeOptionalText(data.description),
2301
- data.status ?? 'active',
2372
+ data.priority ?? 'medium',
2373
+ data.status ?? 'todo',
2374
+ data.dueDate ?? null,
2375
+ data.estimateHours ?? null,
2376
+ data.position ?? nextPosition,
2377
+ data.tags ?? null,
2302
2378
  ]
2303
2379
  );
2304
2380
 
2305
- return this.getTaskOptionById(actor.collaboratorId, created?.id ?? 0);
2381
+ return this.getProjectBoardTask(created?.id ?? 0);
2306
2382
  }
2307
2383
 
2308
2384
  async updateTask(
@@ -2311,15 +2387,15 @@ export class OperationsService {
2311
2387
  data: Partial<TaskPayload>
2312
2388
  ) {
2313
2389
  const actor = await this.getActorContext(userId);
2314
- this.ensureCollaborator(actor);
2315
-
2316
- if (!actor.collaboratorId) {
2317
- throw new BadRequestException('Collaborator context is required.');
2390
+ if (!actor.isCollaborator && !actor.isDirector && !actor.isSupervisor) {
2391
+ throw new ForbiddenException(
2392
+ 'Operations collaborator access is required.'
2393
+ );
2318
2394
  }
2319
2395
 
2320
- const current = await this.getOwnedTaskRecord(
2396
+ const current = await this.getTaskRecordForActor(
2321
2397
  this.prisma,
2322
- actor.collaboratorId,
2398
+ actor,
2323
2399
  taskId
2324
2400
  );
2325
2401
  await this.assertProjectAccess(actor, current.projectId);
@@ -2334,56 +2410,72 @@ export class OperationsService {
2334
2410
  }
2335
2411
 
2336
2412
  await this.prisma.$transaction(async (tx) => {
2337
- const nextAssignment =
2338
- data.projectId !== undefined || data.projectAssignmentId !== undefined
2339
- ? await this.resolveOwnedProjectAssignment(
2340
- tx as any,
2341
- actor.collaboratorId as number,
2342
- {
2343
- projectId: data.projectId ?? null,
2344
- projectAssignmentId: data.projectAssignmentId ?? null,
2345
- }
2346
- )
2347
- : {
2348
- id: current.projectAssignmentId,
2349
- projectId: current.projectId,
2350
- };
2413
+ let nextAssignmentId = current.projectAssignmentId;
2414
+ let nextProjectId = current.projectId;
2351
2415
 
2352
- await this.assertProjectAccess(actor, nextAssignment.projectId);
2416
+ if (data.projectId !== undefined || data.projectAssignmentId !== undefined) {
2417
+ const nextAssignment = await this.resolveProjectAssignmentForActor(
2418
+ tx as any,
2419
+ actor,
2420
+ {
2421
+ projectId: data.projectId ?? null,
2422
+ projectAssignmentId: data.projectAssignmentId ?? null,
2423
+ }
2424
+ );
2425
+ await this.assertProjectAccess(actor, nextAssignment.projectId);
2426
+ nextAssignmentId = nextAssignment.id;
2427
+ nextProjectId = nextAssignment.projectId;
2428
+ }
2353
2429
 
2354
2430
  await (tx as any).$executeRawUnsafe(
2355
2431
  `UPDATE operations_task
2356
- SET project_assignment_id = $1,
2357
- name = $2,
2358
- description = $3,
2359
- status = $4,
2432
+ SET project_id = $1,
2433
+ project_assignment_id = $2,
2434
+ assignee_collaborator_id = $3,
2435
+ name = $4,
2436
+ description = $5,
2437
+ priority = $6::operations_task_priority_394ab327eb_enum,
2438
+ status = $7::operations_task_status_574c143dbe_enum,
2439
+ due_date = $8::date,
2440
+ estimate_hours = $9::decimal,
2441
+ position = $10,
2442
+ tags = $11,
2360
2443
  updated_at = NOW()
2361
- WHERE id = $5
2444
+ WHERE id = $12
2362
2445
  AND deleted_at IS NULL`,
2363
- nextAssignment.id,
2446
+ nextProjectId,
2447
+ nextAssignmentId,
2448
+ data.assigneeCollaboratorId !== undefined
2449
+ ? (data.assigneeCollaboratorId ?? null)
2450
+ : current.assigneeCollaboratorId,
2364
2451
  nextName,
2365
2452
  data.description !== undefined
2366
2453
  ? this.normalizeOptionalText(data.description)
2367
2454
  : (current.description ?? null),
2455
+ data.priority ?? current.priority,
2368
2456
  data.status ?? current.status,
2457
+ data.dueDate !== undefined ? (data.dueDate ?? null) : current.dueDate,
2458
+ data.estimateHours !== undefined ? (data.estimateHours ?? null) : current.estimateHours,
2459
+ data.position !== undefined ? data.position : current.position,
2460
+ data.tags !== undefined ? (data.tags ?? null) : current.tags,
2369
2461
  taskId
2370
2462
  );
2371
2463
  });
2372
2464
 
2373
- return this.getTaskOptionById(actor.collaboratorId, taskId);
2465
+ return this.getProjectBoardTask(taskId);
2374
2466
  }
2375
2467
 
2376
2468
  async removeTask(userId: number, taskId: number) {
2377
2469
  const actor = await this.getActorContext(userId);
2378
- this.ensureCollaborator(actor);
2379
-
2380
- if (!actor.collaboratorId) {
2381
- throw new BadRequestException('Collaborator context is required.');
2470
+ if (!actor.isCollaborator && !actor.isDirector && !actor.isSupervisor) {
2471
+ throw new ForbiddenException(
2472
+ 'Operations collaborator access is required.'
2473
+ );
2382
2474
  }
2383
2475
 
2384
- const current = await this.getOwnedTaskRecord(
2476
+ const current = await this.getTaskRecordForActor(
2385
2477
  this.prisma,
2386
- actor.collaboratorId,
2478
+ actor,
2387
2479
  taskId
2388
2480
  );
2389
2481
  await this.assertProjectAccess(actor, current.projectId);
@@ -2392,7 +2484,6 @@ export class OperationsService {
2392
2484
  await (tx as any).$executeRawUnsafe(
2393
2485
  `UPDATE operations_task
2394
2486
  SET deleted_at = COALESCE(deleted_at, NOW()),
2395
- status = 'archived',
2396
2487
  updated_at = NOW()
2397
2488
  WHERE id = $1
2398
2489
  AND deleted_at IS NULL`,
@@ -2617,9 +2708,9 @@ export class OperationsService {
2617
2708
  ? await this.getOwnedTaskRecord(tx as any, actor.collaboratorId as number, data.taskId)
2618
2709
  : null;
2619
2710
 
2620
- if (resolvedTask && resolvedTask.projectAssignmentId !== assignment.id) {
2711
+ if (resolvedTask && resolvedTask.projectId !== assignment.projectId) {
2621
2712
  throw new BadRequestException(
2622
- 'The selected task does not belong to the chosen project assignment.'
2713
+ 'The selected task does not belong to the chosen project.'
2623
2714
  );
2624
2715
  }
2625
2716
 
@@ -2678,6 +2769,116 @@ export class OperationsService {
2678
2769
  return this.getTimesheetEntryByIdForActor(actor, createdEntryId);
2679
2770
  }
2680
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
+
2681
2882
  async removeTimesheetEntry(userId: number, entryId: number) {
2682
2883
  const actor = await this.getActorContext(userId);
2683
2884
  this.ensureCollaborator(actor);
@@ -2711,6 +2912,7 @@ export class OperationsService {
2711
2912
  );
2712
2913
 
2713
2914
  await this.refreshTimesheetTotal(tx as any, entry.timesheetId);
2915
+ await this.cleanupEmptyEditableTimesheet(tx as any, entry.timesheetId);
2714
2916
  });
2715
2917
 
2716
2918
  return { success: true };
@@ -2732,6 +2934,7 @@ export class OperationsService {
2732
2934
  `INSERT INTO operations_project (
2733
2935
  contract_id,
2734
2936
  manager_collaborator_id,
2937
+ client_person_id,
2735
2938
  code,
2736
2939
  name,
2737
2940
  client_name,
@@ -2745,15 +2948,16 @@ export class OperationsService {
2745
2948
  created_at,
2746
2949
  updated_at
2747
2950
  ) VALUES (
2748
- $1, $2, $3, $4, $5, $6,
2749
- $7::operations_project_status_965e8d4b2d_enum,
2750
- $8,
2751
- $9::operations_project_delivery_model_75ee11b3b7_enum,
2752
- $10, $11::date, $12::date, NOW(), NOW()
2951
+ $1, $2, $3, $4, $5, $6, $7,
2952
+ $8::operations_project_status_965e8d4b2d_enum,
2953
+ $9,
2954
+ $10::operations_project_delivery_model_75ee11b3b7_enum,
2955
+ $11, $12::date, $13::date, NOW(), NOW()
2753
2956
  )
2754
2957
  RETURNING id`,
2755
2958
  data.contractId ?? null,
2756
2959
  data.managerCollaboratorId ?? null,
2960
+ data.clientPersonId ?? null,
2757
2961
  data.code,
2758
2962
  data.name,
2759
2963
  data.clientName ?? null,
@@ -2823,6 +3027,7 @@ export class OperationsService {
2823
3027
  const params: unknown[] = [];
2824
3028
  this.pushUpdate(updates, params, 'contract_id', data.contractId);
2825
3029
  this.pushUpdate(updates, params, 'manager_collaborator_id', data.managerCollaboratorId);
3030
+ this.pushUpdate(updates, params, 'client_person_id', data.clientPersonId);
2826
3031
  this.pushUpdate(updates, params, 'code', data.code);
2827
3032
  this.pushUpdate(updates, params, 'name', data.name);
2828
3033
  this.pushUpdate(updates, params, 'client_name', data.clientName);
@@ -2839,7 +3044,7 @@ export class OperationsService {
2839
3044
  updates,
2840
3045
  params,
2841
3046
  'delivery_model',
2842
- data.deliveryModel,
3047
+ (data.deliveryModel as string | null | undefined) === '' ? null : data.deliveryModel,
2843
3048
  'operations_project_delivery_model_75ee11b3b7_enum'
2844
3049
  );
2845
3050
  this.pushUpdate(updates, params, 'budget_amount', data.budgetAmount);
@@ -2869,7 +3074,7 @@ export class OperationsService {
2869
3074
  const nextContractId =
2870
3075
  data.contractId !== undefined
2871
3076
  ? data.contractId
2872
- : (currentProject.contractId ?? null);
3077
+ : (currentProject.relatedContract?.id ?? null);
2873
3078
  const shouldGenerateDraft =
2874
3079
  !nextContractId && data.autoGenerateContractDraft === true;
2875
3080
 
@@ -2922,6 +3127,39 @@ export class OperationsService {
2922
3127
  contractId,
2923
3128
  projectId
2924
3129
  );
3130
+ } else if (
3131
+ nextContractId &&
3132
+ (data.monthlyHourCap !== undefined || data.billingModel !== undefined)
3133
+ ) {
3134
+ const contractUpdates: string[] = [];
3135
+ const contractParams: unknown[] = [];
3136
+
3137
+ this.pushUpdate(
3138
+ contractUpdates,
3139
+ contractParams,
3140
+ 'monthly_hour_cap',
3141
+ data.monthlyHourCap
3142
+ );
3143
+ this.pushUpdate(
3144
+ contractUpdates,
3145
+ contractParams,
3146
+ 'billing_model',
3147
+ (data.billingModel as string | null | undefined) === ''
3148
+ ? null
3149
+ : data.billingModel,
3150
+ 'operations_contract_billing_model_409dc7fea2_enum'
3151
+ );
3152
+
3153
+ if (contractUpdates.length) {
3154
+ contractParams.push(nextContractId);
3155
+ await (tx as any).$executeRawUnsafe(
3156
+ `UPDATE operations_contract
3157
+ SET ${contractUpdates.join(', ')},
3158
+ updated_at = NOW()
3159
+ WHERE id = $${contractParams.length}`,
3160
+ ...contractParams
3161
+ );
3162
+ }
2925
3163
  }
2926
3164
  });
2927
3165
 
@@ -3011,7 +3249,11 @@ export class OperationsService {
3011
3249
  updated_at
3012
3250
  ) VALUES (
3013
3251
  $1, $2, $3, $4,
3014
- $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,
3015
3257
  NOW(), NOW()
3016
3258
  )
3017
3259
  RETURNING id`,
@@ -3100,12 +3342,12 @@ export class OperationsService {
3100
3342
  code = $2,
3101
3343
  name = $3,
3102
3344
  description = $4,
3103
- contract_category = $5,
3104
- contract_type = $6,
3105
- billing_model = $7,
3106
- 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,
3107
3349
  is_active = $9,
3108
- status = $10,
3350
+ status = $10::text::operations_contract_template_status_c9d2e90231_enum,
3109
3351
  content_html = $11,
3110
3352
  updated_at = NOW()
3111
3353
  WHERE id = $12`,
@@ -5008,8 +5250,26 @@ export class OperationsService {
5008
5250
  }
5009
5251
 
5010
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
+
5011
5268
  const approverId =
5012
- current.approverCollaboratorId ?? collaborator.supervisorId ?? null;
5269
+ projectManagerRow?.managerCollaboratorId ??
5270
+ current.approverCollaboratorId ??
5271
+ collaborator.supervisorId ??
5272
+ null;
5013
5273
 
5014
5274
  if (!approverId) {
5015
5275
  throw new BadRequestException(
@@ -5242,7 +5502,8 @@ export class OperationsService {
5242
5502
  endTime: string | null;
5243
5503
  breakMinutes: number | null;
5244
5504
  }>(
5245
- `SELECT collaborator_id AS "collaboratorId",
5505
+ `SELECT DISTINCT ON (collaborator_id, weekday)
5506
+ collaborator_id AS "collaboratorId",
5246
5507
  weekday,
5247
5508
  is_working_day AS "isWorkingDay",
5248
5509
  start_time AS "startTime",
@@ -5250,7 +5511,7 @@ export class OperationsService {
5250
5511
  break_minutes AS "breakMinutes"
5251
5512
  FROM operations_collaborator_schedule_day
5252
5513
  WHERE collaborator_id = ANY($1::int[])
5253
- ORDER BY id ASC`,
5514
+ ORDER BY collaborator_id, weekday, id DESC`,
5254
5515
  [this.uniqueNumbers(requests.map((item) => item.collaboratorId))]
5255
5516
  );
5256
5517
 
@@ -5703,21 +5964,51 @@ export class OperationsService {
5703
5964
  private async getCollaboratorByUserId(userId: number) {
5704
5965
  return this.querySingle<{
5705
5966
  id: number;
5967
+ userId: number | null;
5968
+ personId: number | null;
5706
5969
  displayName: string;
5707
5970
  supervisorId: number | null;
5708
5971
  supervisorName: string | null;
5972
+ activeAssignments: number;
5709
5973
  }>(
5710
- `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",
5711
5985
  COALESCE(NULLIF(c.display_name, ''), person_record.name) AS "displayName",
5712
5986
  s.id AS "supervisorId",
5713
- 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"
5714
5992
  FROM operations_collaborator c
5715
5993
  LEFT JOIN person person_record
5716
5994
  ON person_record.id = c.person_id
5717
5995
  LEFT JOIN operations_collaborator s
5718
5996
  ON s.id = c.supervisor_collaborator_id
5719
- WHERE c.user_id = $1
5720
- 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`,
5721
6012
  [userId]
5722
6013
  );
5723
6014
  }
@@ -6608,6 +6899,8 @@ export class OperationsService {
6608
6899
  id: number;
6609
6900
  contractId: number | null;
6610
6901
  managerCollaboratorId: number | null;
6902
+ clientPersonId: number | null;
6903
+ clientAvatarId: number | null;
6611
6904
  code: string;
6612
6905
  name: string;
6613
6906
  clientName: string | null;
@@ -6629,6 +6922,8 @@ export class OperationsService {
6629
6922
  `SELECT p.id,
6630
6923
  p.contract_id AS "contractId",
6631
6924
  p.manager_collaborator_id AS "managerCollaboratorId",
6925
+ p.client_person_id AS "clientPersonId",
6926
+ client_person.avatar_id AS "clientAvatarId",
6632
6927
  p.code,
6633
6928
  p.name,
6634
6929
  p.client_name AS "clientName",
@@ -6637,8 +6932,8 @@ export class OperationsService {
6637
6932
  p.progress_percent AS "progressPercent",
6638
6933
  p.delivery_model AS "deliveryModel",
6639
6934
  p.budget_amount AS "budgetAmount",
6640
- p.start_date AS "startDate",
6641
- p.end_date AS "endDate",
6935
+ TO_CHAR(p.start_date, 'YYYY-MM-DD') AS "startDate",
6936
+ TO_CHAR(p.end_date, 'YYYY-MM-DD') AS "endDate",
6642
6937
  c.name AS "contractName",
6643
6938
  c.status AS "contractStatus",
6644
6939
  c.contract_category AS "contractCategory",
@@ -6649,6 +6944,7 @@ export class OperationsService {
6649
6944
  FROM operations_project p
6650
6945
  LEFT JOIN operations_contract c ON c.id = p.contract_id
6651
6946
  LEFT JOIN operations_collaborator m ON m.id = p.manager_collaborator_id
6947
+ LEFT JOIN person client_person ON client_person.id = p.client_person_id
6652
6948
  LEFT JOIN operations_project_assignment pa
6653
6949
  ON pa.project_id = p.id
6654
6950
  AND pa.deleted_at IS NULL
@@ -6664,7 +6960,7 @@ export class OperationsService {
6664
6960
  ) project_role_locale ON TRUE
6665
6961
  WHERE p.id = $1
6666
6962
  AND p.deleted_at IS NULL
6667
- GROUP BY p.id, c.id, m.id`,
6963
+ GROUP BY p.id, c.id, m.id, client_person.id`,
6668
6964
  [projectId, actorCollaboratorId ?? null]
6669
6965
  );
6670
6966
 
@@ -6677,6 +6973,9 @@ export class OperationsService {
6677
6973
  this.queryRows<{
6678
6974
  id: number;
6679
6975
  collaboratorId: number;
6976
+ userId: number | null;
6977
+ personAvatarId: number | null;
6978
+ userPhotoId: number | null;
6680
6979
  collaboratorName: string;
6681
6980
  projectRoleId: number | null;
6682
6981
  roleLabel: string | null;
@@ -6689,17 +6988,22 @@ export class OperationsService {
6689
6988
  }>(
6690
6989
  `SELECT pa.id,
6691
6990
  pa.collaborator_id AS "collaboratorId",
6991
+ c.user_id AS "userId",
6992
+ person_record.avatar_id AS "personAvatarId",
6993
+ collaborator_user.photo_id AS "userPhotoId",
6692
6994
  c.display_name AS "collaboratorName",
6693
6995
  pa.project_role_id AS "projectRoleId",
6694
6996
  COALESCE(project_role_locale.name, pa.role_label) AS "roleLabel",
6695
6997
  pa.allocation_percent AS "allocationPercent",
6696
6998
  pa.weekly_hours AS "weeklyHours",
6697
6999
  pa.is_billable AS "isBillable",
6698
- pa.start_date AS "startDate",
6699
- pa.end_date AS "endDate",
7000
+ TO_CHAR(pa.start_date, 'YYYY-MM-DD') AS "startDate",
7001
+ TO_CHAR(pa.end_date, 'YYYY-MM-DD') AS "endDate",
6700
7002
  pa.status
6701
7003
  FROM operations_project_assignment pa
6702
7004
  JOIN operations_collaborator c ON c.id = pa.collaborator_id
7005
+ LEFT JOIN person person_record ON person_record.id = c.person_id
7006
+ LEFT JOIN "user" collaborator_user ON collaborator_user.id = c.user_id
6703
7007
  LEFT JOIN operations_project_role project_role
6704
7008
  ON project_role.id = pa.project_role_id
6705
7009
  AND project_role.deleted_at IS NULL
@@ -6739,8 +7043,8 @@ export class OperationsService {
6739
7043
  contract_category AS "contractCategory",
6740
7044
  billing_model AS "billingModel",
6741
7045
  status,
6742
- start_date AS "startDate",
6743
- end_date AS "endDate",
7046
+ TO_CHAR(start_date, 'YYYY-MM-DD') AS "startDate",
7047
+ TO_CHAR(end_date, 'YYYY-MM-DD') AS "endDate",
6744
7048
  budget_amount AS "budgetAmount",
6745
7049
  monthly_hour_cap AS "monthlyHourCap",
6746
7050
  description,
@@ -6951,7 +7255,8 @@ export class OperationsService {
6951
7255
  FROM operations_contract c
6952
7256
  WHERE c.related_collaborator_id = $1
6953
7257
  AND c.deleted_at IS NULL
6954
- ORDER BY c.created_at DESC`,
7258
+ ORDER BY CASE WHEN c.origin_type = 'employee_hiring' THEN 0 ELSE 1 END,
7259
+ c.created_at DESC`,
6955
7260
  [collaboratorId]
6956
7261
  ),
6957
7262
  this.queryRows(
@@ -7137,6 +7442,69 @@ export class OperationsService {
7137
7442
  return assignment[0];
7138
7443
  }
7139
7444
 
7445
+ private async resolveProjectAssignmentForActor(
7446
+ client: any,
7447
+ actor: ActorContext,
7448
+ input: {
7449
+ projectId?: number | null;
7450
+ projectAssignmentId?: number | null;
7451
+ }
7452
+ ) {
7453
+ if (actor.collaboratorId && !actor.isDirector && !actor.isSupervisor) {
7454
+ return this.resolveOwnedProjectAssignment(
7455
+ client,
7456
+ actor.collaboratorId,
7457
+ input
7458
+ );
7459
+ }
7460
+
7461
+ if (!input.projectId && !input.projectAssignmentId) {
7462
+ throw new BadRequestException(
7463
+ 'Either projectId or projectAssignmentId is required.'
7464
+ );
7465
+ }
7466
+
7467
+ const params: unknown[] = [];
7468
+ const filters = ['pa.deleted_at IS NULL', 'p.deleted_at IS NULL'];
7469
+
7470
+ if (input.projectAssignmentId) {
7471
+ filters.push(`pa.id = ${this.param(params, input.projectAssignmentId)}`);
7472
+ }
7473
+
7474
+ if (input.projectId) {
7475
+ filters.push(`pa.project_id = ${this.param(params, input.projectId)}`);
7476
+ }
7477
+
7478
+ const assignment = (await client.$queryRawUnsafe(
7479
+ `SELECT pa.id,
7480
+ pa.project_id AS "projectId",
7481
+ p.name AS "projectName",
7482
+ p.code AS "projectCode",
7483
+ pa.role_label AS "roleLabel"
7484
+ FROM operations_project_assignment pa
7485
+ JOIN operations_project p
7486
+ ON p.id = pa.project_id
7487
+ WHERE ${filters.join(' AND ')}
7488
+ ORDER BY CASE WHEN pa.status = 'active' THEN 0 ELSE 1 END,
7489
+ pa.start_date DESC NULLS LAST,
7490
+ pa.id DESC
7491
+ LIMIT 1`,
7492
+ ...params
7493
+ )) as Array<{
7494
+ id: number;
7495
+ projectId: number;
7496
+ projectName: string;
7497
+ projectCode: string | null;
7498
+ roleLabel: string | null;
7499
+ }>;
7500
+
7501
+ if (!assignment[0]) {
7502
+ throw new NotFoundException('Project assignment not found.');
7503
+ }
7504
+
7505
+ return assignment[0];
7506
+ }
7507
+
7140
7508
  private async getOwnedTaskRecord(
7141
7509
  client: any,
7142
7510
  collaboratorId: number,
@@ -7146,21 +7514,33 @@ export class OperationsService {
7146
7514
  `SELECT t.id,
7147
7515
  t.name,
7148
7516
  t.description,
7517
+ t.priority,
7149
7518
  t.status,
7519
+ t.due_date AS "dueDate",
7520
+ t.estimate_hours AS "estimateHours",
7521
+ t.position,
7522
+ t.tags,
7523
+ t.assignee_collaborator_id AS "assigneeCollaboratorId",
7150
7524
  t.project_assignment_id AS "projectAssignmentId",
7151
- pa.project_id AS "projectId",
7525
+ COALESCE(t.project_id, pa.project_id) AS "projectId",
7152
7526
  p.name AS "projectName",
7153
7527
  p.code AS "projectCode"
7154
7528
  FROM operations_task t
7155
- JOIN operations_project_assignment pa
7529
+ LEFT JOIN operations_project_assignment pa
7156
7530
  ON pa.id = t.project_assignment_id
7157
7531
  AND pa.deleted_at IS NULL
7158
- JOIN operations_project p
7159
- ON p.id = pa.project_id
7532
+ LEFT JOIN operations_project p
7533
+ ON p.id = COALESCE(t.project_id, pa.project_id)
7160
7534
  AND p.deleted_at IS NULL
7161
7535
  WHERE t.id = $1
7162
7536
  AND t.deleted_at IS NULL
7163
- AND pa.collaborator_id = $2
7537
+ AND (
7538
+ pa.collaborator_id = $2
7539
+ OR t.project_id IN (
7540
+ SELECT pa2.project_id FROM operations_project_assignment pa2
7541
+ WHERE pa2.collaborator_id = $2 AND pa2.deleted_at IS NULL
7542
+ )
7543
+ )
7164
7544
  LIMIT 1`,
7165
7545
  taskId,
7166
7546
  collaboratorId
@@ -7168,8 +7548,14 @@ export class OperationsService {
7168
7548
  id: number;
7169
7549
  name: string;
7170
7550
  description: string | null;
7551
+ priority: string;
7171
7552
  status: string;
7172
- projectAssignmentId: number;
7553
+ dueDate: string | null;
7554
+ estimateHours: number | null;
7555
+ position: number;
7556
+ tags: string | null;
7557
+ assigneeCollaboratorId: number | null;
7558
+ projectAssignmentId: number | null;
7173
7559
  projectId: number;
7174
7560
  projectName: string;
7175
7561
  projectCode: string | null;
@@ -7184,12 +7570,127 @@ export class OperationsService {
7184
7570
  return task[0];
7185
7571
  }
7186
7572
 
7187
- private async getTaskOptionById(collaboratorId: number, taskId: number) {
7188
- const task = await this.getOwnedTaskRecord(this.prisma, collaboratorId, taskId);
7189
- return {
7190
- ...task,
7191
- label: [task.name, task.projectName].filter(Boolean).join(' • '),
7192
- };
7573
+ private async getTaskRecordForActor(
7574
+ client: any,
7575
+ actor: ActorContext,
7576
+ taskId: number
7577
+ ) {
7578
+ if (actor.collaboratorId && !actor.isDirector && !actor.isSupervisor) {
7579
+ return this.getOwnedTaskRecord(client, actor.collaboratorId, taskId);
7580
+ }
7581
+
7582
+ const task = await this.getProjectBoardTask(taskId);
7583
+ if (!task?.projectId) {
7584
+ throw new NotFoundException('Task not found.');
7585
+ }
7586
+
7587
+ return task;
7588
+ }
7589
+
7590
+ async listProjectBoardTasks(userId: number, projectId: number) {
7591
+ const actor = await this.getActorContext(userId);
7592
+ this.ensureCollaborator(actor);
7593
+ await this.assertProjectAccess(actor, projectId);
7594
+
7595
+ const rows = await this.queryRows<{
7596
+ id: number;
7597
+ name: string;
7598
+ description: string | null;
7599
+ priority: string;
7600
+ status: string;
7601
+ dueDate: string | null;
7602
+ estimateHours: number | null;
7603
+ position: number;
7604
+ tags: string | null;
7605
+ assigneeCollaboratorId: number | null;
7606
+ assigneeName: string | null;
7607
+ assigneeUserPhotoId: number | null;
7608
+ assigneePersonAvatarId: number | null;
7609
+ projectAssignmentId: number | null;
7610
+ createdAt: string;
7611
+ }>(
7612
+ `SELECT t.id,
7613
+ t.name,
7614
+ t.description,
7615
+ t.priority,
7616
+ t.status,
7617
+ t.due_date AS "dueDate",
7618
+ t.estimate_hours AS "estimateHours",
7619
+ t.position,
7620
+ t.tags,
7621
+ t.assignee_collaborator_id AS "assigneeCollaboratorId",
7622
+ ac.display_name AS "assigneeName",
7623
+ au.photo_id AS "assigneeUserPhotoId",
7624
+ ap.avatar_id AS "assigneePersonAvatarId",
7625
+ t.project_assignment_id AS "projectAssignmentId",
7626
+ t.created_at AS "createdAt"
7627
+ FROM operations_task t
7628
+ LEFT JOIN operations_collaborator ac
7629
+ ON ac.id = t.assignee_collaborator_id AND ac.deleted_at IS NULL
7630
+ LEFT JOIN "user" au
7631
+ ON au.id = ac.user_id
7632
+ LEFT JOIN person ap
7633
+ ON ap.id = ac.person_id
7634
+ WHERE COALESCE(t.project_id, (
7635
+ SELECT pa.project_id FROM operations_project_assignment pa
7636
+ WHERE pa.id = t.project_assignment_id AND pa.deleted_at IS NULL
7637
+ LIMIT 1
7638
+ )) = $1
7639
+ AND t.deleted_at IS NULL
7640
+ ORDER BY t.status ASC, t.position ASC, t.id ASC`,
7641
+ [projectId]
7642
+ );
7643
+
7644
+ return rows;
7645
+ }
7646
+
7647
+ private async getProjectBoardTask(taskId: number) {
7648
+ const rows = await this.queryRows<{
7649
+ id: number;
7650
+ name: string;
7651
+ description: string | null;
7652
+ priority: string;
7653
+ status: string;
7654
+ dueDate: string | null;
7655
+ estimateHours: number | null;
7656
+ position: number;
7657
+ tags: string | null;
7658
+ assigneeCollaboratorId: number | null;
7659
+ assigneeName: string | null;
7660
+ assigneeUserPhotoId: number | null;
7661
+ assigneePersonAvatarId: number | null;
7662
+ projectAssignmentId: number | null;
7663
+ projectId: number | null;
7664
+ createdAt: string;
7665
+ }>(
7666
+ `SELECT t.id,
7667
+ t.name,
7668
+ t.description,
7669
+ t.priority,
7670
+ t.status,
7671
+ t.due_date AS "dueDate",
7672
+ t.estimate_hours AS "estimateHours",
7673
+ t.position,
7674
+ t.tags,
7675
+ t.assignee_collaborator_id AS "assigneeCollaboratorId",
7676
+ ac.display_name AS "assigneeName",
7677
+ au.photo_id AS "assigneeUserPhotoId",
7678
+ ap.avatar_id AS "assigneePersonAvatarId",
7679
+ t.project_assignment_id AS "projectAssignmentId",
7680
+ COALESCE(t.project_id, pa.project_id) AS "projectId",
7681
+ t.created_at AS "createdAt"
7682
+ FROM operations_task t
7683
+ LEFT JOIN operations_collaborator ac
7684
+ ON ac.id = t.assignee_collaborator_id AND ac.deleted_at IS NULL
7685
+ LEFT JOIN "user" au ON au.id = ac.user_id
7686
+ LEFT JOIN person ap ON ap.id = ac.person_id
7687
+ LEFT JOIN operations_project_assignment pa
7688
+ ON pa.id = t.project_assignment_id AND pa.deleted_at IS NULL
7689
+ WHERE t.id = $1`,
7690
+ [taskId]
7691
+ );
7692
+
7693
+ return rows[0] ?? null;
7193
7694
  }
7194
7695
 
7195
7696
  private async getOrCreateTimesheetForWorkDate(
@@ -7377,23 +7878,26 @@ export class OperationsService {
7377
7878
  const assignmentIds = entries
7378
7879
  .map((entry) => entry.projectAssignmentId)
7379
7880
  .filter((value): value is number => typeof value === 'number');
7881
+ const assignmentMap = new Map<
7882
+ number,
7883
+ { id: number; collaboratorId: number; projectId: number }
7884
+ >();
7380
7885
 
7381
7886
  if (assignmentIds.length) {
7382
7887
  const assignments = (await client.$queryRawUnsafe(
7383
- `SELECT id, collaborator_id AS "collaboratorId"
7888
+ `SELECT id,
7889
+ collaborator_id AS "collaboratorId",
7890
+ project_id AS "projectId"
7384
7891
  FROM operations_project_assignment
7385
7892
  WHERE id = ANY($1::int[])
7386
7893
  AND deleted_at IS NULL`,
7387
7894
  assignmentIds
7388
- )) as { id: number; collaboratorId: number }[];
7389
- const assignmentMap = new Map(
7390
- assignments.map((assignment) => [
7391
- assignment.id,
7392
- assignment.collaboratorId,
7393
- ])
7394
- );
7895
+ )) as { id: number; collaboratorId: number; projectId: number }[];
7896
+ assignments.forEach((assignment) => {
7897
+ assignmentMap.set(assignment.id, assignment);
7898
+ });
7395
7899
  for (const assignmentId of assignmentIds) {
7396
- if (assignmentMap.get(assignmentId) !== collaboratorId) {
7900
+ if (assignmentMap.get(assignmentId)?.collaboratorId !== collaboratorId) {
7397
7901
  throw new ForbiddenException(
7398
7902
  'Timesheet entries must use assignments owned by the target collaborator.'
7399
7903
  );
@@ -7407,14 +7911,17 @@ export class OperationsService {
7407
7911
  const resolvedTask = entry.taskId
7408
7912
  ? await this.getOwnedTaskRecord(client, collaboratorId, entry.taskId)
7409
7913
  : null;
7914
+ const selectedAssignment = entry.projectAssignmentId
7915
+ ? assignmentMap.get(entry.projectAssignmentId) ?? null
7916
+ : null;
7410
7917
 
7411
7918
  if (
7412
7919
  resolvedTask &&
7413
- entry.projectAssignmentId &&
7414
- resolvedTask.projectAssignmentId !== entry.projectAssignmentId
7920
+ selectedAssignment &&
7921
+ resolvedTask.projectId !== selectedAssignment.projectId
7415
7922
  ) {
7416
7923
  throw new BadRequestException(
7417
- 'The selected task does not belong to the chosen project assignment.'
7924
+ 'The selected task does not belong to the chosen project.'
7418
7925
  );
7419
7926
  }
7420
7927
 
@@ -7488,6 +7995,37 @@ export class OperationsService {
7488
7995
  );
7489
7996
  }
7490
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
+
7491
8029
  private async upsertApproval(
7492
8030
  client: any,
7493
8031
  input: {
@@ -8055,6 +8593,146 @@ export class OperationsService {
8055
8593
  );
8056
8594
  }
8057
8595
 
8596
+ private async syncHiringContractDraft(
8597
+ client: any,
8598
+ updatedByUserId: number,
8599
+ collaboratorId: number,
8600
+ data: Partial<CollaboratorPayload>
8601
+ ) {
8602
+ const collaborator = await client.$queryRawUnsafe(
8603
+ `SELECT c.id,
8604
+ c.code,
8605
+ COALESCE(NULLIF(c.display_name, ''), person_record.name) AS "displayName",
8606
+ collaborator_type.slug AS "collaboratorTypeSlug",
8607
+ c.supervisor_collaborator_id AS "supervisorCollaboratorId",
8608
+ c.joined_at AS "joinedAt",
8609
+ c.weekly_capacity_hours AS "weeklyCapacityHours"
8610
+ FROM operations_collaborator c
8611
+ LEFT JOIN person person_record
8612
+ ON person_record.id = c.person_id
8613
+ LEFT JOIN operations_collaborator_type collaborator_type
8614
+ ON collaborator_type.id = c.collaborator_type_id
8615
+ AND collaborator_type.deleted_at IS NULL
8616
+ WHERE c.id = $1
8617
+ AND c.deleted_at IS NULL
8618
+ LIMIT 1`,
8619
+ collaboratorId
8620
+ ) as Array<{
8621
+ id: number;
8622
+ code: string | null;
8623
+ displayName: string | null;
8624
+ collaboratorTypeSlug: string | null;
8625
+ supervisorCollaboratorId: number | null;
8626
+ joinedAt: string | null;
8627
+ weeklyCapacityHours: number | null;
8628
+ }>;
8629
+
8630
+ const currentCollaborator = collaborator[0] ?? null;
8631
+
8632
+ if (!currentCollaborator) {
8633
+ throw new NotFoundException('Collaborator not found.');
8634
+ }
8635
+
8636
+ const hiringContracts = (await client.$queryRawUnsafe(
8637
+ `SELECT id,
8638
+ budget_amount AS "budgetAmount",
8639
+ description
8640
+ FROM operations_contract
8641
+ WHERE related_collaborator_id = $1
8642
+ AND origin_type = 'employee_hiring'
8643
+ AND deleted_at IS NULL
8644
+ ORDER BY created_at DESC
8645
+ LIMIT 1`,
8646
+ collaboratorId
8647
+ )) as Array<{
8648
+ id: number;
8649
+ budgetAmount: number | null;
8650
+ description: string | null;
8651
+ }>;
8652
+
8653
+ const hiringContract = hiringContracts[0] ?? null;
8654
+ const collaboratorCode =
8655
+ this.normalizeOptionalText(currentCollaborator.code) ??
8656
+ `COL-${collaboratorId}`;
8657
+ const displayName =
8658
+ this.normalizeOptionalText(currentCollaborator.displayName) ??
8659
+ `Collaborator ${collaboratorId}`;
8660
+ const collaboratorTypeSlug =
8661
+ this.normalizeOptionalText(currentCollaborator.collaboratorTypeSlug) ??
8662
+ 'other';
8663
+ const startDate =
8664
+ data.joinedAt !== undefined
8665
+ ? data.joinedAt ?? null
8666
+ : currentCollaborator.joinedAt ?? null;
8667
+ const weeklyCapacityHours =
8668
+ data.weeklyCapacityHours !== undefined
8669
+ ? data.weeklyCapacityHours ?? null
8670
+ : currentCollaborator.weeklyCapacityHours ?? null;
8671
+ const compensationAmount =
8672
+ data.compensationAmount !== undefined
8673
+ ? data.compensationAmount ?? null
8674
+ : hiringContract?.budgetAmount ?? null;
8675
+ const description =
8676
+ data.contractDescription !== undefined
8677
+ ? this.normalizeOptionalText(data.contractDescription)
8678
+ : hiringContract?.description ?? null;
8679
+ const supervisorCollaboratorId =
8680
+ data.supervisorCollaboratorId !== undefined
8681
+ ? data.supervisorCollaboratorId ?? null
8682
+ : currentCollaborator.supervisorCollaboratorId ?? null;
8683
+
8684
+ if (!hiringContract) {
8685
+ if (data.autoGenerateContractDraft === false) {
8686
+ return;
8687
+ }
8688
+
8689
+ await this.createHiringContractDraft(client, updatedByUserId, {
8690
+ collaboratorId,
8691
+ collaboratorCode,
8692
+ displayName,
8693
+ collaboratorType: collaboratorTypeSlug,
8694
+ supervisorCollaboratorId,
8695
+ startDate,
8696
+ weeklyCapacityHours,
8697
+ compensationAmount,
8698
+ description,
8699
+ });
8700
+ return;
8701
+ }
8702
+
8703
+ await client.$executeRawUnsafe(
8704
+ `UPDATE operations_contract
8705
+ SET code = $1,
8706
+ name = $2,
8707
+ contract_category = $3::operations_contract_contract_category_70d553ea09_enum,
8708
+ contract_type = $4::operations_contract_contract_type_48331e2ebf_enum,
8709
+ client_name = $5,
8710
+ billing_model = $6::operations_contract_billing_model_409dc7fea2_enum,
8711
+ account_manager_collaborator_id = $7,
8712
+ start_date = $8::date,
8713
+ effective_date = $8::date,
8714
+ budget_amount = $9,
8715
+ monthly_hour_cap = $10,
8716
+ description = $11,
8717
+ updated_by_user_id = $12,
8718
+ updated_at = NOW()
8719
+ WHERE id = $13`,
8720
+ `HIR-${collaboratorCode}`,
8721
+ this.buildHiringContractName(displayName, collaboratorTypeSlug),
8722
+ this.mapContractCategoryForCollaboratorType(collaboratorTypeSlug),
8723
+ this.mapContractTypeForCollaboratorType(collaboratorTypeSlug),
8724
+ displayName,
8725
+ this.mapBillingModelForCollaboratorType(collaboratorTypeSlug),
8726
+ supervisorCollaboratorId,
8727
+ startDate ?? new Date().toISOString().slice(0, 10),
8728
+ compensationAmount,
8729
+ weeklyCapacityHours ? Math.round(Number(weeklyCapacityHours) * 4) : null,
8730
+ description,
8731
+ updatedByUserId,
8732
+ hiringContract.id
8733
+ );
8734
+ }
8735
+
8058
8736
  private async createProjectContractDraft(
8059
8737
  client: any,
8060
8738
  createdByUserId: number,