@hed-hog/operations 0.0.330 → 0.0.331

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 (281) hide show
  1. package/README.md +5 -5
  2. package/dist/controllers/operations-collaborators.controller.d.ts +7 -216
  3. package/dist/controllers/operations-collaborators.controller.d.ts.map +1 -1
  4. package/dist/controllers/operations-contracts.controller.d.ts +6 -6
  5. package/dist/controllers/operations-projects.controller.d.ts +25 -0
  6. package/dist/controllers/operations-projects.controller.d.ts.map +1 -1
  7. package/dist/controllers/operations-projects.controller.js +48 -0
  8. package/dist/controllers/operations-projects.controller.js.map +1 -1
  9. package/dist/controllers/operations-reports.controller.d.ts +1 -1
  10. package/dist/controllers/operations-tasks.controller.d.ts +34 -9
  11. package/dist/controllers/operations-tasks.controller.d.ts.map +1 -1
  12. package/dist/controllers/operations-tasks.controller.js +43 -32
  13. package/dist/controllers/operations-tasks.controller.js.map +1 -1
  14. package/dist/controllers/operations-timesheets.controller.d.ts +9 -9
  15. package/dist/dashboard/components/DashboardLayout.d.ts +30 -0
  16. package/dist/dashboard/components/DashboardLayout.d.ts.map +1 -0
  17. package/dist/dashboard/components/DashboardLayout.js +87 -0
  18. package/dist/dashboard/components/DashboardLayout.js.map +1 -0
  19. package/dist/dashboard/components/widget-registry.d.ts +23 -0
  20. package/dist/dashboard/components/widget-registry.d.ts.map +1 -0
  21. package/dist/dashboard/components/widget-registry.js +245 -0
  22. package/dist/dashboard/components/widget-registry.js.map +1 -0
  23. package/dist/dashboard/hooks/useDashboardData.d.ts +20 -0
  24. package/dist/dashboard/hooks/useDashboardData.d.ts.map +1 -0
  25. package/dist/dashboard/hooks/useDashboardData.js +24 -0
  26. package/dist/dashboard/hooks/useDashboardData.js.map +1 -0
  27. package/dist/dashboard/types/widgets.types.d.ts +233 -0
  28. package/dist/dashboard/types/widgets.types.d.ts.map +1 -0
  29. package/dist/dashboard/types/widgets.types.js +6 -0
  30. package/dist/dashboard/types/widgets.types.js.map +1 -0
  31. package/dist/dashboard/widgets/CapacityDistribution.d.ts +23 -0
  32. package/dist/dashboard/widgets/CapacityDistribution.d.ts.map +1 -0
  33. package/dist/dashboard/widgets/CapacityDistribution.js +11 -0
  34. package/dist/dashboard/widgets/CapacityDistribution.js.map +1 -0
  35. package/dist/dashboard/widgets/EffortByProject.d.ts +22 -0
  36. package/dist/dashboard/widgets/EffortByProject.d.ts.map +1 -0
  37. package/dist/dashboard/widgets/EffortByProject.js +11 -0
  38. package/dist/dashboard/widgets/EffortByProject.js.map +1 -0
  39. package/dist/dashboard/widgets/HeadcountByArea.d.ts +24 -0
  40. package/dist/dashboard/widgets/HeadcountByArea.d.ts.map +1 -0
  41. package/dist/dashboard/widgets/HeadcountByArea.js +11 -0
  42. package/dist/dashboard/widgets/HeadcountByArea.js.map +1 -0
  43. package/dist/dashboard/widgets/ManagedProjectsStatus.d.ts +18 -0
  44. package/dist/dashboard/widgets/ManagedProjectsStatus.d.ts.map +1 -0
  45. package/dist/dashboard/widgets/ManagedProjectsStatus.js +12 -0
  46. package/dist/dashboard/widgets/ManagedProjectsStatus.js.map +1 -0
  47. package/dist/dashboard/widgets/MyHoursPeriodKpi.d.ts +22 -0
  48. package/dist/dashboard/widgets/MyHoursPeriodKpi.d.ts.map +1 -0
  49. package/dist/dashboard/widgets/MyHoursPeriodKpi.js +12 -0
  50. package/dist/dashboard/widgets/MyHoursPeriodKpi.js.map +1 -0
  51. package/dist/dashboard/widgets/MyOpenRequestsKpi.d.ts +19 -0
  52. package/dist/dashboard/widgets/MyOpenRequestsKpi.d.ts.map +1 -0
  53. package/dist/dashboard/widgets/MyOpenRequestsKpi.js +17 -0
  54. package/dist/dashboard/widgets/MyOpenRequestsKpi.js.map +1 -0
  55. package/dist/dashboard/widgets/MyPendingRequestsList.d.ts +23 -0
  56. package/dist/dashboard/widgets/MyPendingRequestsList.d.ts.map +1 -0
  57. package/dist/dashboard/widgets/MyPendingRequestsList.js +14 -0
  58. package/dist/dashboard/widgets/MyPendingRequestsList.js.map +1 -0
  59. package/dist/dashboard/widgets/MyProjectAllocationsKpi.d.ts +22 -0
  60. package/dist/dashboard/widgets/MyProjectAllocationsKpi.d.ts.map +1 -0
  61. package/dist/dashboard/widgets/MyProjectAllocationsKpi.js +11 -0
  62. package/dist/dashboard/widgets/MyProjectAllocationsKpi.js.map +1 -0
  63. package/dist/dashboard/widgets/MyQuickActions.d.ts +23 -0
  64. package/dist/dashboard/widgets/MyQuickActions.d.ts.map +1 -0
  65. package/dist/dashboard/widgets/MyQuickActions.js +18 -0
  66. package/dist/dashboard/widgets/MyQuickActions.js.map +1 -0
  67. package/dist/dashboard/widgets/MyRelevantDeadlines.d.ts +23 -0
  68. package/dist/dashboard/widgets/MyRelevantDeadlines.d.ts.map +1 -0
  69. package/dist/dashboard/widgets/MyRelevantDeadlines.js +22 -0
  70. package/dist/dashboard/widgets/MyRelevantDeadlines.js.map +1 -0
  71. package/dist/dashboard/widgets/MyTimesheetStatusKpi.d.ts +17 -0
  72. package/dist/dashboard/widgets/MyTimesheetStatusKpi.d.ts.map +1 -0
  73. package/dist/dashboard/widgets/MyTimesheetStatusKpi.js +11 -0
  74. package/dist/dashboard/widgets/MyTimesheetStatusKpi.js.map +1 -0
  75. package/dist/dashboard/widgets/MyWeeklyJourney.d.ts +21 -0
  76. package/dist/dashboard/widgets/MyWeeklyJourney.d.ts.map +1 -0
  77. package/dist/dashboard/widgets/MyWeeklyJourney.js +19 -0
  78. package/dist/dashboard/widgets/MyWeeklyJourney.js.map +1 -0
  79. package/dist/dashboard/widgets/PortfolioCostsKpi.d.ts +19 -0
  80. package/dist/dashboard/widgets/PortfolioCostsKpi.d.ts.map +1 -0
  81. package/dist/dashboard/widgets/PortfolioCostsKpi.js +12 -0
  82. package/dist/dashboard/widgets/PortfolioCostsKpi.js.map +1 -0
  83. package/dist/dashboard/widgets/PortfolioEffortKpi.d.ts +18 -0
  84. package/dist/dashboard/widgets/PortfolioEffortKpi.d.ts.map +1 -0
  85. package/dist/dashboard/widgets/PortfolioEffortKpi.js +8 -0
  86. package/dist/dashboard/widgets/PortfolioEffortKpi.js.map +1 -0
  87. package/dist/dashboard/widgets/PortfolioProjectsKpi.d.ts +22 -0
  88. package/dist/dashboard/widgets/PortfolioProjectsKpi.d.ts.map +1 -0
  89. package/dist/dashboard/widgets/PortfolioProjectsKpi.js +56 -0
  90. package/dist/dashboard/widgets/PortfolioProjectsKpi.js.map +1 -0
  91. package/dist/dashboard/widgets/PortfolioRiskKpi.d.ts +19 -0
  92. package/dist/dashboard/widgets/PortfolioRiskKpi.d.ts.map +1 -0
  93. package/dist/dashboard/widgets/PortfolioRiskKpi.js +11 -0
  94. package/dist/dashboard/widgets/PortfolioRiskKpi.js.map +1 -0
  95. package/dist/dashboard/widgets/ProjectStatusOverview.d.ts +19 -0
  96. package/dist/dashboard/widgets/ProjectStatusOverview.d.ts.map +1 -0
  97. package/dist/dashboard/widgets/ProjectStatusOverview.js +18 -0
  98. package/dist/dashboard/widgets/ProjectStatusOverview.js.map +1 -0
  99. package/dist/dashboard/widgets/StrategicDeadlines.d.ts +24 -0
  100. package/dist/dashboard/widgets/StrategicDeadlines.d.ts.map +1 -0
  101. package/dist/dashboard/widgets/StrategicDeadlines.js +22 -0
  102. package/dist/dashboard/widgets/StrategicDeadlines.js.map +1 -0
  103. package/dist/dashboard/widgets/TeamApprovalQueue.d.ts +24 -0
  104. package/dist/dashboard/widgets/TeamApprovalQueue.d.ts.map +1 -0
  105. package/dist/dashboard/widgets/TeamApprovalQueue.js +12 -0
  106. package/dist/dashboard/widgets/TeamApprovalQueue.js.map +1 -0
  107. package/dist/dashboard/widgets/TeamCapacityKpi.d.ts +18 -0
  108. package/dist/dashboard/widgets/TeamCapacityKpi.d.ts.map +1 -0
  109. package/dist/dashboard/widgets/TeamCapacityKpi.js +19 -0
  110. package/dist/dashboard/widgets/TeamCapacityKpi.js.map +1 -0
  111. package/dist/dashboard/widgets/TeamHeadcountKpi.d.ts +22 -0
  112. package/dist/dashboard/widgets/TeamHeadcountKpi.d.ts.map +1 -0
  113. package/dist/dashboard/widgets/TeamHeadcountKpi.js +56 -0
  114. package/dist/dashboard/widgets/TeamHeadcountKpi.js.map +1 -0
  115. package/dist/dashboard/widgets/TeamHoursKpi.d.ts +19 -0
  116. package/dist/dashboard/widgets/TeamHoursKpi.d.ts.map +1 -0
  117. package/dist/dashboard/widgets/TeamHoursKpi.js +13 -0
  118. package/dist/dashboard/widgets/TeamHoursKpi.js.map +1 -0
  119. package/dist/dashboard/widgets/TeamPendingApprovalsKpi.d.ts +20 -0
  120. package/dist/dashboard/widgets/TeamPendingApprovalsKpi.d.ts.map +1 -0
  121. package/dist/dashboard/widgets/TeamPendingApprovalsKpi.js +11 -0
  122. package/dist/dashboard/widgets/TeamPendingApprovalsKpi.js.map +1 -0
  123. package/dist/dashboard/widgets/TeamUtilizationOverview.d.ts +18 -0
  124. package/dist/dashboard/widgets/TeamUtilizationOverview.d.ts.map +1 -0
  125. package/dist/dashboard/widgets/TeamUtilizationOverview.js +17 -0
  126. package/dist/dashboard/widgets/TeamUtilizationOverview.js.map +1 -0
  127. package/dist/dashboard/widgets/TeamWorkloadAlerts.d.ts +24 -0
  128. package/dist/dashboard/widgets/TeamWorkloadAlerts.d.ts.map +1 -0
  129. package/dist/dashboard/widgets/TeamWorkloadAlerts.js +19 -0
  130. package/dist/dashboard/widgets/TeamWorkloadAlerts.js.map +1 -0
  131. package/dist/dashboard/widgets/index.d.ts +24 -0
  132. package/dist/dashboard/widgets/index.d.ts.map +1 -0
  133. package/dist/dashboard/widgets/index.js +54 -0
  134. package/dist/dashboard/widgets/index.js.map +1 -0
  135. package/dist/dto/create-collaborator.dto.d.ts +0 -1
  136. package/dist/dto/create-collaborator.dto.d.ts.map +1 -1
  137. package/dist/dto/create-collaborator.dto.js +0 -6
  138. package/dist/dto/create-collaborator.dto.js.map +1 -1
  139. package/dist/index.d.ts +2 -0
  140. package/dist/index.d.ts.map +1 -1
  141. package/dist/index.js +2 -0
  142. package/dist/index.js.map +1 -1
  143. package/dist/operations.controller.d.ts +42 -0
  144. package/dist/operations.controller.d.ts.map +1 -1
  145. package/dist/operations.service.d.ts +182 -268
  146. package/dist/operations.service.d.ts.map +1 -1
  147. package/dist/operations.service.js +2147 -1337
  148. package/dist/operations.service.js.map +1 -1
  149. package/dist/operations.service.spec.js +345 -174
  150. package/dist/operations.service.spec.js.map +1 -1
  151. package/hedhog/data/dashboard_component.yaml +66 -0
  152. package/hedhog/data/dashboard_item.yaml +25 -25
  153. package/hedhog/data/route.yaml +61 -0
  154. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +39 -99
  155. package/hedhog/frontend/app/_components/collaborator-picker.tsx.ejs +158 -0
  156. package/hedhog/frontend/app/_components/my-project-summary-screen.tsx.ejs +314 -116
  157. package/hedhog/frontend/app/_components/project-assignments-tab.tsx.ejs +434 -449
  158. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +289 -412
  159. package/hedhog/frontend/app/_components/project-file-attachments.tsx.ejs +371 -0
  160. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +426 -374
  161. package/hedhog/frontend/app/_components/task-detail-sheet.tsx.ejs +803 -581
  162. package/hedhog/frontend/app/_components/task-file-attachments.tsx.ejs +4 -1
  163. package/hedhog/frontend/app/_components/task-form-fields.tsx.ejs +406 -0
  164. package/hedhog/frontend/app/_components/task-form-sheet.tsx.ejs +629 -784
  165. package/hedhog/frontend/app/_components/task-info-display.tsx.ejs +137 -0
  166. package/hedhog/frontend/app/_components/timesheet-entry-create-sheet.tsx.ejs +306 -0
  167. package/hedhog/frontend/app/_lib/api.ts.ejs +480 -476
  168. package/hedhog/frontend/app/_lib/types.ts.ejs +66 -5
  169. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +0 -2
  170. package/hedhog/frontend/app/_lib/utils/task-ui.ts.ejs +43 -0
  171. package/hedhog/frontend/app/approvals/page.tsx.ejs +6 -1
  172. package/hedhog/frontend/app/collaborator-types/page.tsx.ejs +6 -1
  173. package/hedhog/frontend/app/collaborators/page.tsx.ejs +59 -8
  174. package/hedhog/frontend/app/contracts/page.tsx.ejs +29 -8
  175. package/hedhog/frontend/app/dashboard/widgets/CapacityDistribution.tsx.ejs +84 -0
  176. package/hedhog/frontend/app/dashboard/widgets/EffortByProject.tsx.ejs +85 -0
  177. package/hedhog/frontend/app/dashboard/widgets/HeadcountByArea.tsx.ejs +101 -0
  178. package/hedhog/frontend/app/dashboard/widgets/ManagedProjectsStatus.tsx.ejs +113 -0
  179. package/hedhog/frontend/app/dashboard/widgets/MyHoursPeriodKpi.tsx.ejs +87 -0
  180. package/hedhog/frontend/app/dashboard/widgets/MyOpenRequestsKpi.tsx.ejs +97 -0
  181. package/hedhog/frontend/app/dashboard/widgets/MyPendingRequestsList.tsx.ejs +99 -0
  182. package/hedhog/frontend/app/dashboard/widgets/MyProjectAllocationsKpi.tsx.ejs +78 -0
  183. package/hedhog/frontend/app/dashboard/widgets/MyQuickActions.tsx.ejs +130 -0
  184. package/hedhog/frontend/app/dashboard/widgets/MyRelevantDeadlines.tsx.ejs +144 -0
  185. package/hedhog/frontend/app/dashboard/widgets/MyTimesheetStatusKpi.tsx.ejs +78 -0
  186. package/hedhog/frontend/app/dashboard/widgets/MyWeeklyJourney.tsx.ejs +99 -0
  187. package/hedhog/frontend/app/dashboard/widgets/PortfolioCostsKpi.tsx.ejs +112 -0
  188. package/hedhog/frontend/app/dashboard/widgets/PortfolioEffortKpi.tsx.ejs +93 -0
  189. package/hedhog/frontend/app/dashboard/widgets/PortfolioProjectsKpi.tsx.ejs +96 -0
  190. package/hedhog/frontend/app/dashboard/widgets/PortfolioRiskKpi.tsx.ejs +115 -0
  191. package/hedhog/frontend/app/dashboard/widgets/ProjectStatusOverview.tsx.ejs +120 -0
  192. package/hedhog/frontend/app/dashboard/widgets/StrategicDeadlines.tsx.ejs +146 -0
  193. package/hedhog/frontend/app/dashboard/widgets/TeamApprovalQueue.tsx.ejs +108 -0
  194. package/hedhog/frontend/app/dashboard/widgets/TeamCapacityKpi.tsx.ejs +97 -0
  195. package/hedhog/frontend/app/dashboard/widgets/TeamHeadcountKpi.tsx.ejs +100 -0
  196. package/hedhog/frontend/app/dashboard/widgets/TeamHoursKpi.tsx.ejs +104 -0
  197. package/hedhog/frontend/app/dashboard/widgets/TeamPendingApprovalsKpi.tsx.ejs +110 -0
  198. package/hedhog/frontend/app/dashboard/widgets/TeamUtilizationOverview.tsx.ejs +115 -0
  199. package/hedhog/frontend/app/dashboard/widgets/TeamWorkloadAlerts.tsx.ejs +117 -0
  200. package/hedhog/frontend/app/dashboard/widgets/index.ts.ejs +26 -0
  201. package/hedhog/frontend/app/departments/page.tsx.ejs +6 -1
  202. package/hedhog/frontend/app/my-projects/page.tsx.ejs +14 -10
  203. package/hedhog/frontend/app/my-tasks/page.tsx.ejs +328 -105
  204. package/hedhog/frontend/app/project-cost-categories/page.tsx.ejs +58 -52
  205. package/hedhog/frontend/app/project-cost-types/page.tsx.ejs +58 -51
  206. package/hedhog/frontend/app/projects/page.tsx.ejs +376 -30
  207. package/hedhog/frontend/app/schedule-adjustments/page.tsx.ejs +6 -1
  208. package/hedhog/frontend/app/time-off/page.tsx.ejs +6 -1
  209. package/hedhog/frontend/app/timesheets/page.tsx.ejs +10 -4
  210. package/hedhog/frontend/messages/en.json +238 -46
  211. package/hedhog/frontend/messages/operations/en.json +61 -52
  212. package/hedhog/frontend/messages/operations/pt.json +59 -43
  213. package/hedhog/frontend/messages/pt.json +238 -46
  214. package/hedhog/frontend/widgets/capacity-distribution.tsx.ejs +17 -0
  215. package/hedhog/frontend/widgets/effort-by-project.tsx.ejs +17 -0
  216. package/hedhog/frontend/widgets/headcount-by-area.tsx.ejs +17 -0
  217. package/hedhog/frontend/widgets/index.ts.ejs +25 -0
  218. package/hedhog/frontend/widgets/managed-projects-status.tsx.ejs +17 -0
  219. package/hedhog/frontend/widgets/my-hours-period-kpi.tsx.ejs +17 -0
  220. package/hedhog/frontend/widgets/my-open-requests-kpi.tsx.ejs +17 -0
  221. package/hedhog/frontend/widgets/my-pending-requests-list.tsx.ejs +17 -0
  222. package/hedhog/frontend/widgets/my-project-allocations-kpi.tsx.ejs +17 -0
  223. package/hedhog/frontend/widgets/my-quick-actions.tsx.ejs +17 -0
  224. package/hedhog/frontend/widgets/my-relevant-deadlines.tsx.ejs +17 -0
  225. package/hedhog/frontend/widgets/my-timesheet-status-kpi.tsx.ejs +17 -0
  226. package/hedhog/frontend/widgets/my-weekly-journey.tsx.ejs +17 -0
  227. package/hedhog/frontend/widgets/portfolio-costs-kpi.tsx.ejs +17 -0
  228. package/hedhog/frontend/widgets/portfolio-effort-kpi.tsx.ejs +17 -0
  229. package/hedhog/frontend/widgets/portfolio-projects-kpi.tsx.ejs +17 -0
  230. package/hedhog/frontend/widgets/portfolio-risk-kpi.tsx.ejs +17 -0
  231. package/hedhog/frontend/widgets/project-status-overview.tsx.ejs +17 -0
  232. package/hedhog/frontend/widgets/shared-operations-widget.tsx.ejs +170 -0
  233. package/hedhog/frontend/widgets/strategic-deadlines.tsx.ejs +17 -0
  234. package/hedhog/frontend/widgets/team-approval-queue.tsx.ejs +17 -0
  235. package/hedhog/frontend/widgets/team-capacity-kpi.tsx.ejs +17 -0
  236. package/hedhog/frontend/widgets/team-headcount-kpi.tsx.ejs +17 -0
  237. package/hedhog/frontend/widgets/team-hours-kpi.tsx.ejs +17 -0
  238. package/hedhog/frontend/widgets/team-pending-approvals-kpi.tsx.ejs +17 -0
  239. package/hedhog/frontend/widgets/team-utilization-overview.tsx.ejs +17 -0
  240. package/hedhog/frontend/widgets/team-workload-alerts.tsx.ejs +17 -0
  241. package/hedhog/table/operations_collaborator.yaml +8 -13
  242. package/hedhog/table/operations_project.yaml +1 -1
  243. package/hedhog/table/operations_project_file.yaml +23 -0
  244. package/hedhog/table/operations_task.yaml +76 -69
  245. package/hedhog/table/operations_task_activity.yaml +51 -0
  246. package/package.json +6 -5
  247. package/src/controllers/operations-projects.controller.ts +41 -8
  248. package/src/controllers/operations-tasks.controller.ts +156 -166
  249. package/src/dashboard/README.md +214 -0
  250. package/src/dashboard/components/DashboardLayout.tsx +131 -0
  251. package/src/dashboard/components/widget-registry.ts +255 -0
  252. package/src/dashboard/hooks/useDashboardData.ts +29 -0
  253. package/src/dashboard/types/widgets.types.ts +237 -0
  254. package/src/dashboard/widgets/CapacityDistribution.tsx +56 -0
  255. package/src/dashboard/widgets/EffortByProject.tsx +51 -0
  256. package/src/dashboard/widgets/HeadcountByArea.tsx +57 -0
  257. package/src/dashboard/widgets/ManagedProjectsStatus.tsx +53 -0
  258. package/src/dashboard/widgets/MyHoursPeriodKpi.tsx +87 -0
  259. package/src/dashboard/widgets/MyOpenRequestsKpi.tsx +51 -0
  260. package/src/dashboard/widgets/MyPendingRequestsList.tsx +63 -0
  261. package/src/dashboard/widgets/MyProjectAllocationsKpi.tsx +57 -0
  262. package/src/dashboard/widgets/MyQuickActions.tsx +62 -0
  263. package/src/dashboard/widgets/MyRelevantDeadlines.tsx +84 -0
  264. package/src/dashboard/widgets/MyTimesheetStatusKpi.tsx +65 -0
  265. package/src/dashboard/widgets/MyWeeklyJourney.tsx +57 -0
  266. package/src/dashboard/widgets/PortfolioCostsKpi.tsx +48 -0
  267. package/src/dashboard/widgets/PortfolioEffortKpi.tsx +41 -0
  268. package/src/dashboard/widgets/PortfolioRiskKpi.tsx +50 -0
  269. package/src/dashboard/widgets/ProjectStatusOverview.tsx +52 -0
  270. package/src/dashboard/widgets/StrategicDeadlines.tsx +93 -0
  271. package/src/dashboard/widgets/TeamApprovalQueue.tsx +70 -0
  272. package/src/dashboard/widgets/TeamCapacityKpi.tsx +50 -0
  273. package/src/dashboard/widgets/TeamHoursKpi.tsx +51 -0
  274. package/src/dashboard/widgets/TeamPendingApprovalsKpi.tsx +53 -0
  275. package/src/dashboard/widgets/TeamUtilizationOverview.tsx +62 -0
  276. package/src/dashboard/widgets/TeamWorkloadAlerts.tsx +81 -0
  277. package/src/dashboard/widgets/index.ts +26 -0
  278. package/src/dto/create-collaborator.dto.ts +4 -11
  279. package/src/index.ts +3 -0
  280. package/src/operations.service.spec.ts +988 -764
  281. package/src/operations.service.ts +4277 -2535
@@ -1,581 +1,803 @@
1
- 'use client';
2
-
3
- import { RichTextEditor } from '@/components/rich-text-editor';
4
- import { Button } from '@/components/ui/button';
5
- import { CommentContent } from '@/components/ui/comment-rich-editor';
6
- import {
7
- Sheet,
8
- SheetContent,
9
- SheetHeader,
10
- SheetTitle,
11
- } from '@/components/ui/sheet';
12
- import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
13
- import { useApp, useQuery } from '@hed-hog/next-app-provider';
14
- import {
15
- AlarmClock,
16
- Calendar,
17
- MessageSquare,
18
- Pencil,
19
- Tag,
20
- Trash2,
21
- User,
22
- X,
23
- } from 'lucide-react';
24
- import { useTranslations } from 'next-intl';
25
- import type { ReactNode } from 'react';
26
- import { useEffect, useRef, useState } from 'react';
27
- import {
28
- createTaskComment,
29
- deleteTaskComment,
30
- fetchTaskComments,
31
- updateTaskComment,
32
- } from '../_lib/api';
33
- import { useMentionItems } from '../_lib/hooks/use-mention-items';
34
- import type { OperationsTaskComment } from '../_lib/types';
35
- import { formatDate, getStatusBadgeClass } from '../_lib/utils/format';
36
- import { StatusBadge } from './status-badge';
37
-
38
- export type TaskDetailSheetData = {
39
- id: number;
40
- name: string;
41
- description?: string | null;
42
- status: string;
43
- priority?: string | null;
44
- dueDate?: string | null;
45
- estimateHours?: number | null;
46
- tags?: string | null;
47
- projectName?: string | null;
48
- projectCode?: string | null;
49
- assigneeName?: string | null;
50
- assigneeUserPhotoId?: number | null;
51
- assigneePersonAvatarId?: number | null;
52
- };
53
-
54
- type Props = {
55
- task: TaskDetailSheetData | null;
56
- open: boolean;
57
- onOpenChange: (open: boolean) => void;
58
- statusLabel?: (status: string) => string;
59
- footer?: ReactNode;
60
- defaultTab?: 'info' | 'comments';
61
- };
62
-
63
- function getPriorityLabel(value?: string | null) {
64
- const labels: Record<string, string> = {
65
- low: 'Baixa',
66
- medium: 'Média',
67
- high: 'Alta',
68
- };
69
- return labels[value ?? ''] ?? value ?? '—';
70
- }
71
-
72
- function getPriorityClassName(value?: string | null) {
73
- if (value === 'high') return 'bg-rose-100 text-rose-700 border-rose-200';
74
- if (value === 'medium') return 'bg-amber-100 text-amber-700 border-amber-200';
75
- return 'bg-emerald-100 text-emerald-700 border-emerald-200';
76
- }
77
-
78
- function getAvatarSrc(task: TaskDetailSheetData | null) {
79
- if (!task) return null;
80
- if (
81
- typeof task.assigneeUserPhotoId === 'number' &&
82
- task.assigneeUserPhotoId > 0
83
- )
84
- return `${process.env.NEXT_PUBLIC_API_BASE_URL}/file/open/${task.assigneeUserPhotoId}`;
85
- if (
86
- typeof task.assigneePersonAvatarId === 'number' &&
87
- task.assigneePersonAvatarId > 0
88
- )
89
- return `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${task.assigneePersonAvatarId}`;
90
- return null;
91
- }
92
-
93
- function getCommentAvatarSrc(comment: OperationsTaskComment) {
94
- if (
95
- typeof comment.actorUserPhotoId === 'number' &&
96
- comment.actorUserPhotoId > 0
97
- )
98
- return `${process.env.NEXT_PUBLIC_API_BASE_URL}/file/open/${comment.actorUserPhotoId}`;
99
- if (
100
- typeof comment.actorPersonAvatarId === 'number' &&
101
- comment.actorPersonAvatarId > 0
102
- )
103
- return `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${comment.actorPersonAvatarId}`;
104
- return null;
105
- }
106
-
107
- function getInitials(value?: string | null) {
108
- const parts = String(value ?? '')
109
- .trim()
110
- .split(/\s+/)
111
- .filter(Boolean)
112
- .slice(0, 2);
113
- if (!parts.length) return '??';
114
- return parts.map((p) => p[0]?.toUpperCase() ?? '').join('');
115
- }
116
-
117
- function formatCommentDate(value?: string | null) {
118
- if (!value) return '';
119
- const date = new Date(value);
120
- if (Number.isNaN(date.getTime())) return '';
121
- const now = new Date();
122
- const diffMs = now.getTime() - date.getTime();
123
- const diffMins = Math.floor(diffMs / 60000);
124
- if (diffMins < 1) return 'agora';
125
- if (diffMins < 60) return `${diffMins}min atrás`;
126
- const diffHours = Math.floor(diffMins / 60);
127
- if (diffHours < 24) return `${diffHours}h atrás`;
128
- const diffDays = Math.floor(diffHours / 24);
129
- if (diffDays < 7) return `${diffDays}d atrás`;
130
- return date.toLocaleDateString('pt-BR', { day: '2-digit', month: 'short' });
131
- }
132
-
133
- export type TaskCommentsSectionProps = {
134
- taskId: number;
135
- };
136
-
137
- export function TaskCommentsSection({ taskId }: TaskCommentsSectionProps) {
138
- const { request, showToastHandler, getSettingValue } = useApp();
139
- const ct = useTranslations('operations.ProjectDetailsPage.commentsSection');
140
- const editWindowMinutes = Number(
141
- getSettingValue('operations.comment-edit-window') ?? 5
142
- );
143
- const [newComment, setNewComment] = useState('');
144
- const [submitting, setSubmitting] = useState(false);
145
- const [editingId, setEditingId] = useState<number | null>(null);
146
- const [editContent, setEditContent] = useState('');
147
- const [savingEditId, setSavingEditId] = useState<number | null>(null);
148
- const [deletingId, setDeletingId] = useState<number | null>(null);
149
- const [localComments, setLocalComments] = useState<
150
- OperationsTaskComment[] | null
151
- >(null);
152
-
153
- // Force re-render precisely when each comment's edit window expires
154
- const [, setTick] = useState(0);
155
- const comments_ref = useRef<OperationsTaskComment[]>([]);
156
-
157
- const mentionItems = useMentionItems(request);
158
-
159
- const { data: fetchedComments = [], refetch } = useQuery<
160
- OperationsTaskComment[]
161
- >({
162
- queryKey: ['task-comments', taskId],
163
- queryFn: () =>
164
- fetchTaskComments(request, taskId) as Promise<OperationsTaskComment[]>,
165
- });
166
-
167
- const comments = localComments ?? fetchedComments;
168
-
169
- // Keep ref in sync so the timeout effect always sees current comments
170
- comments_ref.current = comments;
171
-
172
- // Schedule one precise re-render for each comment that is still within the
173
- // edit window, so the buttons disappear at the exact moment they expire.
174
- useEffect(() => {
175
- if (editWindowMinutes <= 0) return;
176
- const windowMs = editWindowMinutes * 60_000;
177
- const timers: ReturnType<typeof setTimeout>[] = [];
178
- for (const c of comments_ref.current) {
179
- const ageMs = Date.now() - new Date(c.createdAt).getTime();
180
- const msUntilExpiry = windowMs - ageMs;
181
- if (msUntilExpiry > 0) {
182
- timers.push(setTimeout(() => setTick((t) => t + 1), msUntilExpiry));
183
- }
184
- }
185
- return () => timers.forEach(clearTimeout);
186
- // Re-schedule whenever the comment list or the window setting changes
187
- // eslint-disable-next-line react-hooks/exhaustive-deps
188
- }, [comments, editWindowMinutes]);
189
-
190
- const handleSubmit = async () => {
191
- const stripped = newComment.replace(/<[^>]*>/g, '').trim();
192
- if (!stripped) return;
193
- setSubmitting(true);
194
- try {
195
- const created = (await createTaskComment(
196
- request,
197
- taskId,
198
- newComment
199
- )) as OperationsTaskComment;
200
- setLocalComments([...(localComments ?? fetchedComments), created]);
201
- setNewComment('');
202
- } catch {
203
- showToastHandler?.('error', ct('errors.addComment'));
204
- } finally {
205
- setSubmitting(false);
206
- }
207
- };
208
-
209
- const handleStartEdit = (comment: OperationsTaskComment) => {
210
- setEditingId(comment.id);
211
- setEditContent(comment.content);
212
- };
213
-
214
- const handleCancelEdit = () => {
215
- setEditingId(null);
216
- setEditContent('');
217
- };
218
-
219
- const handleSaveEdit = async (comment: OperationsTaskComment) => {
220
- const trimmed = editContent.replace(/<[^>]*>/g, '').trim();
221
- if (!trimmed) return;
222
- setSavingEditId(comment.id);
223
- try {
224
- const updated = (await updateTaskComment(
225
- request,
226
- taskId,
227
- comment.id,
228
- editContent
229
- )) as OperationsTaskComment;
230
- if (updated) {
231
- setLocalComments(
232
- (localComments ?? fetchedComments).map((c) =>
233
- c.id === comment.id ? updated : c
234
- )
235
- );
236
- }
237
- setEditingId(null);
238
- setEditContent('');
239
- } catch {
240
- showToastHandler?.('error', ct('errors.editComment'));
241
- } finally {
242
- setSavingEditId(null);
243
- }
244
- };
245
-
246
- const handleDelete = async (comment: OperationsTaskComment) => {
247
- setDeletingId(comment.id);
248
- try {
249
- await deleteTaskComment(request, taskId, comment.id);
250
- setLocalComments(
251
- (localComments ?? fetchedComments).filter((c) => c.id !== comment.id)
252
- );
253
- } catch {
254
- showToastHandler?.('error', ct('errors.deleteComment'));
255
- } finally {
256
- setDeletingId(null);
257
- }
258
- };
259
-
260
- return (
261
- <div className="flex flex-col gap-3">
262
- <div className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
263
- <MessageSquare className="size-3" />
264
- {ct('title')}
265
- {comments.length > 0 ? (
266
- <span className="ml-0.5 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground">
267
- {comments.length}
268
- </span>
269
- ) : null}
270
- </div>
271
-
272
- {comments.length > 0 ? (
273
- <div className="flex flex-col gap-3">
274
- {comments.map((comment) => {
275
- const avatarSrc = getCommentAvatarSrc(comment);
276
- const isEditing = editingId === comment.id;
277
- const isDeleting = deletingId === comment.id;
278
- const isSaving = savingEditId === comment.id;
279
- const ageMinutes =
280
- (Date.now() - new Date(comment.createdAt).getTime()) / 60000;
281
- const canModify =
282
- editWindowMinutes === 0 || ageMinutes < editWindowMinutes;
283
-
284
- return (
285
- <div key={comment.id} className="flex gap-2.5">
286
- <div className="mt-0.5 flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted text-[10px] font-semibold uppercase text-muted-foreground ring-1 ring-border">
287
- {avatarSrc ? (
288
- // eslint-disable-next-line @next/next/no-img-element
289
- <img
290
- src={avatarSrc}
291
- alt={comment.actorName ?? ''}
292
- className="size-full object-cover"
293
- />
294
- ) : (
295
- getInitials(comment.actorName)
296
- )}
297
- </div>
298
- <div className="min-w-0 flex-1">
299
- <div className="flex items-center justify-between gap-2">
300
- <span className="truncate text-xs font-semibold">
301
- {comment.actorName ?? ct('defaultUser')}
302
- </span>
303
- <div className="flex shrink-0 items-center gap-1">
304
- <span className="text-[10px] text-muted-foreground">
305
- {formatCommentDate(comment.createdAt)}
306
- </span>
307
- {!isEditing ? (
308
- <>
309
- {canModify ? (
310
- <>
311
- <button
312
- type="button"
313
- aria-label={ct('ariaEditComment')}
314
- disabled={isDeleting}
315
- onClick={() => handleStartEdit(comment)}
316
- className="rounded p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"
317
- >
318
- <Pencil className="size-3" />
319
- </button>
320
- <button
321
- type="button"
322
- aria-label={ct('ariaDeleteComment')}
323
- disabled={isDeleting}
324
- onClick={() => void handleDelete(comment)}
325
- className="rounded p-0.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
326
- >
327
- <Trash2 className="size-3" />
328
- </button>
329
- </>
330
- ) : null}
331
- </>
332
- ) : null}
333
- </div>
334
- </div>
335
- {isEditing ? (
336
- <div className="mt-1 flex flex-col gap-1.5">
337
- <RichTextEditor
338
- value={editContent}
339
- onChange={setEditContent}
340
- mentions={mentionItems}
341
- />
342
- <div className="flex gap-1.5">
343
- <Button
344
- size="sm"
345
- className="h-7 px-2 text-xs"
346
- disabled={
347
- isSaving ||
348
- !editContent.replace(/<[^>]*>/g, '').trim()
349
- }
350
- onClick={() => void handleSaveEdit(comment)}
351
- >
352
- {ct('saveButton')}
353
- </Button>
354
- <Button
355
- size="sm"
356
- variant="ghost"
357
- className="h-7 px-2 text-xs"
358
- disabled={isSaving}
359
- onClick={handleCancelEdit}
360
- >
361
- <X className="size-3" />
362
- {ct('cancelButton')}
363
- </Button>
364
- </div>
365
- </div>
366
- ) : (
367
- <CommentContent content={comment.content} />
368
- )}
369
- </div>
370
- </div>
371
- );
372
- })}
373
- </div>
374
- ) : (
375
- <p className="text-xs text-muted-foreground">{ct('noComments')}</p>
376
- )}
377
-
378
- <div className="flex flex-col gap-1.5 pt-1">
379
- <RichTextEditor
380
- value={newComment}
381
- onChange={setNewComment}
382
- mentions={mentionItems}
383
- />
384
- <div className="flex justify-end">
385
- <Button
386
- size="sm"
387
- className="gap-1.5"
388
- disabled={submitting || !newComment.replace(/<[^>]*>/g, '').trim()}
389
- onClick={() => void handleSubmit()}
390
- >
391
- {ct('submitButton')}
392
- </Button>
393
- </div>
394
- </div>
395
- </div>
396
- );
397
- }
398
-
399
- export function TaskDetailSheet({
400
- task,
401
- open,
402
- onOpenChange,
403
- statusLabel,
404
- footer,
405
- defaultTab = 'comments',
406
- }: Props) {
407
- const commonT = useTranslations('operations.Common');
408
- const detailT = useTranslations('operations.ProjectDetailsPage');
409
- const { getSettingValue, currentLocaleCode } = useApp();
410
-
411
- const [tab, setTab] = useState<'info' | 'comments'>(defaultTab);
412
-
413
- useEffect(() => {
414
- if (open) setTab(defaultTab);
415
- }, [open, defaultTab]);
416
-
417
- const avatarSrc = getAvatarSrc(task);
418
-
419
- return (
420
- <Sheet open={open} onOpenChange={onOpenChange}>
421
- <SheetContent className="flex w-full flex-col gap-0 overflow-hidden sm:max-w-md">
422
- {task ? (
423
- <>
424
- <SheetHeader className="shrink-0 px-4 pb-2 pt-4">
425
- <SheetTitle className="pr-6 text-base font-semibold leading-snug">
426
- {task.name}
427
- </SheetTitle>
428
- <div className="flex flex-wrap gap-2 pt-1">
429
- <StatusBadge
430
- label={statusLabel?.(task.status) ?? task.status}
431
- className={getStatusBadgeClass(task.status)}
432
- />
433
- {task.priority ? (
434
- <span
435
- className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold ${getPriorityClassName(task.priority)}`}
436
- >
437
- {getPriorityLabel(task.priority)}
438
- </span>
439
- ) : null}
440
- </div>
441
- </SheetHeader>
442
-
443
- <Tabs
444
- value={tab}
445
- onValueChange={(v) => setTab(v as 'info' | 'comments')}
446
- className="flex min-h-0 flex-1 flex-col"
447
- >
448
- <TabsList className="mx-4 mb-1 mt-2 grid shrink-0 grid-cols-2">
449
- <TabsTrigger value="info">
450
- {detailT('taskForm.tabInfo')}
451
- </TabsTrigger>
452
- <TabsTrigger value="comments">
453
- <MessageSquare className="mr-1.5 size-3.5" />
454
- {detailT('taskForm.tabComments')}
455
- </TabsTrigger>
456
- </TabsList>
457
-
458
- <TabsContent
459
- value="info"
460
- className="flex-1 overflow-y-auto px-4 pb-4 pt-2 data-[state=inactive]:hidden"
461
- >
462
- <div className="flex flex-col gap-5">
463
- {task.description ? (
464
- <div>
465
- <p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
466
- {detailT('taskForm.descriptionLabel')}
467
- </p>
468
- <p className="text-sm leading-relaxed">
469
- {task.description}
470
- </p>
471
- </div>
472
- ) : null}
473
-
474
- {task.dueDate || task.estimateHours != null ? (
475
- <div className="grid grid-cols-2 gap-3">
476
- {task.dueDate ? (
477
- <div className="flex items-start gap-2 text-sm">
478
- <Calendar className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
479
- <div>
480
- <p className="text-[11px] text-muted-foreground">
481
- {detailT('taskForm.deadlineLabel')}
482
- </p>
483
- <p className="font-medium">
484
- {formatDate(
485
- task.dueDate,
486
- getSettingValue,
487
- currentLocaleCode
488
- )}
489
- </p>
490
- </div>
491
- </div>
492
- ) : null}
493
- {task.estimateHours != null ? (
494
- <div className="flex items-start gap-2 text-sm">
495
- <AlarmClock className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
496
- <div>
497
- <p className="text-[11px] text-muted-foreground">
498
- {detailT('taskForm.estimateLabel')}
499
- </p>
500
- <p className="font-medium">{task.estimateHours}h</p>
501
- </div>
502
- </div>
503
- ) : null}
504
- </div>
505
- ) : null}
506
-
507
- {task.tags ? (
508
- <div>
509
- <div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
510
- <Tag className="size-3" />
511
- {detailT('taskForm.tagsLabel')}
512
- </div>
513
- <div className="flex flex-wrap gap-1.5">
514
- {task.tags.split(',').map((tag) => (
515
- <span
516
- key={tag.trim()}
517
- className="rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground"
518
- >
519
- {tag.trim()}
520
- </span>
521
- ))}
522
- </div>
523
- </div>
524
- ) : null}
525
-
526
- {task.assigneeName ? (
527
- <div>
528
- <div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
529
- <User className="size-3" />
530
- {commonT('labels.collaborator')}
531
- </div>
532
- <div className="flex items-center gap-2.5">
533
- <div className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted text-xs font-semibold uppercase text-muted-foreground ring-1 ring-border">
534
- {avatarSrc ? (
535
- // eslint-disable-next-line @next/next/no-img-element
536
- <img
537
- src={avatarSrc}
538
- alt={task.assigneeName}
539
- className="size-full object-cover"
540
- />
541
- ) : (
542
- getInitials(task.assigneeName)
543
- )}
544
- </div>
545
- <span className="text-sm">{task.assigneeName}</span>
546
- </div>
547
- </div>
548
- ) : null}
549
-
550
- {task.projectName || task.projectCode ? (
551
- <div className="rounded-lg border bg-muted/20 px-3 py-2.5">
552
- <p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
553
- {commonT('labels.project')}
554
- </p>
555
- <p className="mt-0.5 text-sm font-medium">
556
- {[task.projectName, task.projectCode]
557
- .filter(Boolean)
558
- .join(' • ')}
559
- </p>
560
- </div>
561
- ) : null}
562
-
563
- {footer ? (
564
- <div className="border-t pt-5">{footer}</div>
565
- ) : null}
566
- </div>
567
- </TabsContent>
568
-
569
- <TabsContent
570
- value="comments"
571
- className="flex-1 overflow-y-auto px-4 pb-4 pt-2 data-[state=inactive]:hidden"
572
- >
573
- <TaskCommentsSection taskId={task.id} />
574
- </TabsContent>
575
- </Tabs>
576
- </>
577
- ) : null}
578
- </SheetContent>
579
- </Sheet>
580
- );
581
- }
1
+ 'use client';
2
+
3
+ import { RichTextEditor } from '@/components/rich-text-editor';
4
+ import { Button } from '@/components/ui/button';
5
+ import { CommentContent } from '@/components/ui/comment-rich-editor';
6
+ import { KpiCardsGrid, type KpiCardItem } from '@/components/ui/kpi-cards-grid';
7
+ import {
8
+ Sheet,
9
+ SheetContent,
10
+ SheetDescription,
11
+ SheetHeader,
12
+ SheetTitle,
13
+ } from '@/components/ui/sheet';
14
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
15
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
16
+ import {
17
+ Activity,
18
+ AlarmClock,
19
+ Calendar,
20
+ Clock3,
21
+ History,
22
+ MessageSquare,
23
+ Pencil,
24
+ Timer,
25
+ Trash2,
26
+ Users,
27
+ X,
28
+ } from 'lucide-react';
29
+ import { useTranslations } from 'next-intl';
30
+ import type { ReactNode } from 'react';
31
+ import { useEffect, useMemo, useRef, useState } from 'react';
32
+ import {
33
+ createTaskComment,
34
+ deleteTaskComment,
35
+ fetchTaskActivities,
36
+ fetchTaskComments,
37
+ updateTaskComment,
38
+ } from '../_lib/api';
39
+ import { useMentionItems } from '../_lib/hooks/use-mention-items';
40
+ import type {
41
+ OperationsTaskActivity,
42
+ OperationsTaskComment,
43
+ } from '../_lib/types';
44
+ import { formatDate, formatDateTime, getStatusBadgeClass } from '../_lib/utils/format';
45
+ import {
46
+ formatDurationMinutes,
47
+ getElapsedDoingMinutes,
48
+ getInitials,
49
+ getTaskPriorityLabel,
50
+ } from '../_lib/utils/task-ui';
51
+
52
+ import { StatusBadge } from './status-badge';
53
+ import { TaskFileAttachments } from './task-file-attachments';
54
+ import { TimesheetEntryCreateSheet } from './timesheet-entry-create-sheet';
55
+
56
+ export type TaskDetailSheetData = {
57
+ id: number;
58
+ name: string;
59
+ projectId?: number | null;
60
+ projectAssignmentId?: number | null;
61
+ description?: string | null;
62
+ status: string;
63
+ priority?: string | null;
64
+ dueDate?: string | null;
65
+ estimateHours?: number | null;
66
+ tags?: string | null;
67
+ projectName?: string | null;
68
+ projectCode?: string | null;
69
+ assigneeName?: string | null;
70
+ assigneeUserPhotoId?: number | null;
71
+ assigneePersonAvatarId?: number | null;
72
+ doingStartedAt?: string | null;
73
+ totalDoingMinutes?: number | null;
74
+ };
75
+
76
+ type Props = {
77
+ task: TaskDetailSheetData | null;
78
+ open: boolean;
79
+ onOpenChange: (open: boolean) => void;
80
+ statusLabel?: (status: string) => string;
81
+ footer?: ReactNode;
82
+ defaultTab?: 'comments' | 'activities';
83
+ };
84
+
85
+ function getPriorityClassName(value?: string | null) {
86
+ if (value === 'high') return 'bg-rose-100 text-rose-700 border-rose-200';
87
+ if (value === 'medium') return 'bg-amber-100 text-amber-700 border-amber-200';
88
+ return 'bg-emerald-100 text-emerald-700 border-emerald-200';
89
+ }
90
+
91
+ function getAvatarSrc(task: TaskDetailSheetData | null) {
92
+ if (!task) return null;
93
+ if (
94
+ typeof task.assigneeUserPhotoId === 'number' &&
95
+ task.assigneeUserPhotoId > 0
96
+ )
97
+ return `${process.env.NEXT_PUBLIC_API_BASE_URL}/file/open/${task.assigneeUserPhotoId}`;
98
+ if (
99
+ typeof task.assigneePersonAvatarId === 'number' &&
100
+ task.assigneePersonAvatarId > 0
101
+ )
102
+ return `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${task.assigneePersonAvatarId}`;
103
+ return null;
104
+ }
105
+
106
+ function getCommentAvatarSrc(comment: OperationsTaskComment) {
107
+ if (
108
+ typeof comment.actorUserPhotoId === 'number' &&
109
+ comment.actorUserPhotoId > 0
110
+ )
111
+ return `${process.env.NEXT_PUBLIC_API_BASE_URL}/file/open/${comment.actorUserPhotoId}`;
112
+ if (
113
+ typeof comment.actorPersonAvatarId === 'number' &&
114
+ comment.actorPersonAvatarId > 0
115
+ )
116
+ return `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${comment.actorPersonAvatarId}`;
117
+ return null;
118
+ }
119
+
120
+ function formatCommentDate(value?: string | null) {
121
+ if (!value) return '';
122
+ const date = new Date(value);
123
+ if (Number.isNaN(date.getTime())) return '';
124
+ const now = new Date();
125
+ const diffMs = now.getTime() - date.getTime();
126
+ const diffMins = Math.floor(diffMs / 60000);
127
+ if (diffMins < 1) return 'agora';
128
+ if (diffMins < 60) return `${diffMins}min atrás`;
129
+ const diffHours = Math.floor(diffMins / 60);
130
+ if (diffHours < 24) return `${diffHours}h atrás`;
131
+ const diffDays = Math.floor(diffHours / 24);
132
+ if (diffDays < 7) return `${diffDays}d atrás`;
133
+ return date.toLocaleDateString('pt-BR', { day: '2-digit', month: 'short' });
134
+ }
135
+
136
+ export type TaskCommentsSectionProps = {
137
+ taskId: number;
138
+ onChanged?: () => void;
139
+ };
140
+
141
+ export function TaskCommentsSection({ taskId, onChanged }: TaskCommentsSectionProps) {
142
+ const { request, showToastHandler, getSettingValue } = useApp();
143
+ const ct = useTranslations('operations.ProjectDetailsPage.commentsSection');
144
+ const editWindowMinutes = Number(
145
+ getSettingValue('operations.comment-edit-window') ?? 5
146
+ );
147
+ const [newComment, setNewComment] = useState('');
148
+ const [submitting, setSubmitting] = useState(false);
149
+ const [editingId, setEditingId] = useState<number | null>(null);
150
+ const [editContent, setEditContent] = useState('');
151
+ const [savingEditId, setSavingEditId] = useState<number | null>(null);
152
+ const [deletingId, setDeletingId] = useState<number | null>(null);
153
+ const [localComments, setLocalComments] = useState<
154
+ OperationsTaskComment[] | null
155
+ >(null);
156
+
157
+ // Force re-render precisely when each comment's edit window expires
158
+ const [, setTick] = useState(0);
159
+ const comments_ref = useRef<OperationsTaskComment[]>([]);
160
+
161
+ const mentionItems = useMentionItems(request);
162
+
163
+ const { data: fetchedComments = [] } = useQuery<OperationsTaskComment[]>({
164
+ queryKey: ['task-comments', taskId],
165
+ queryFn: () =>
166
+ fetchTaskComments(request, taskId) as Promise<OperationsTaskComment[]>,
167
+ });
168
+
169
+ const comments = localComments ?? fetchedComments;
170
+
171
+ // Keep ref in sync so the timeout effect always sees current comments
172
+ comments_ref.current = comments;
173
+
174
+ // Schedule one precise re-render for each comment that is still within the
175
+ // edit window, so the buttons disappear at the exact moment they expire.
176
+ useEffect(() => {
177
+ if (editWindowMinutes <= 0) return;
178
+ const windowMs = editWindowMinutes * 60_000;
179
+ const timers: ReturnType<typeof setTimeout>[] = [];
180
+ for (const c of comments_ref.current) {
181
+ const ageMs = Date.now() - new Date(c.createdAt).getTime();
182
+ const msUntilExpiry = windowMs - ageMs;
183
+ if (msUntilExpiry > 0) {
184
+ timers.push(setTimeout(() => setTick((t) => t + 1), msUntilExpiry));
185
+ }
186
+ }
187
+ return () => timers.forEach(clearTimeout);
188
+ }, [comments, editWindowMinutes]);
189
+
190
+ const handleSubmit = async () => {
191
+ const stripped = newComment.replace(/<[^>]*>/g, '').trim();
192
+ if (!stripped) return;
193
+ setSubmitting(true);
194
+ try {
195
+ const created = (await createTaskComment(
196
+ request,
197
+ taskId,
198
+ newComment
199
+ )) as OperationsTaskComment;
200
+ setLocalComments([...(localComments ?? fetchedComments), created]);
201
+ setNewComment('');
202
+ onChanged?.();
203
+ } catch {
204
+ showToastHandler?.('error', ct('errors.addComment'));
205
+ } finally {
206
+ setSubmitting(false);
207
+ }
208
+ };
209
+
210
+ const handleStartEdit = (comment: OperationsTaskComment) => {
211
+ setEditingId(comment.id);
212
+ setEditContent(comment.content);
213
+ };
214
+
215
+ const handleCancelEdit = () => {
216
+ setEditingId(null);
217
+ setEditContent('');
218
+ };
219
+
220
+ const handleSaveEdit = async (comment: OperationsTaskComment) => {
221
+ const trimmed = editContent.replace(/<[^>]*>/g, '').trim();
222
+ if (!trimmed) return;
223
+ setSavingEditId(comment.id);
224
+ try {
225
+ const updated = (await updateTaskComment(
226
+ request,
227
+ taskId,
228
+ comment.id,
229
+ editContent
230
+ )) as OperationsTaskComment;
231
+ if (updated) {
232
+ setLocalComments(
233
+ (localComments ?? fetchedComments).map((c) =>
234
+ c.id === comment.id ? updated : c
235
+ )
236
+ );
237
+ }
238
+ setEditingId(null);
239
+ setEditContent('');
240
+ } catch {
241
+ showToastHandler?.('error', ct('errors.editComment'));
242
+ } finally {
243
+ setSavingEditId(null);
244
+ }
245
+ };
246
+
247
+ const handleDelete = async (comment: OperationsTaskComment) => {
248
+ setDeletingId(comment.id);
249
+ try {
250
+ await deleteTaskComment(request, taskId, comment.id);
251
+ setLocalComments(
252
+ (localComments ?? fetchedComments).filter((c) => c.id !== comment.id)
253
+ );
254
+ onChanged?.();
255
+ } catch {
256
+ showToastHandler?.('error', ct('errors.deleteComment'));
257
+ } finally {
258
+ setDeletingId(null);
259
+ }
260
+ };
261
+
262
+ return (
263
+ <div className="flex flex-col gap-3">
264
+ <div className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
265
+ <MessageSquare className="size-3" />
266
+ {ct('title')}
267
+ {comments.length > 0 ? (
268
+ <span className="ml-0.5 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground">
269
+ {comments.length}
270
+ </span>
271
+ ) : null}
272
+ </div>
273
+
274
+ {comments.length > 0 ? (
275
+ <div className="flex flex-col gap-3">
276
+ {comments.map((comment) => {
277
+ const avatarSrc = getCommentAvatarSrc(comment);
278
+ const isEditing = editingId === comment.id;
279
+ const isDeleting = deletingId === comment.id;
280
+ const isSaving = savingEditId === comment.id;
281
+ const ageMinutes =
282
+ (Date.now() - new Date(comment.createdAt).getTime()) / 60000;
283
+ const canModify =
284
+ editWindowMinutes === 0 || ageMinutes < editWindowMinutes;
285
+
286
+ return (
287
+ <div key={comment.id} className="flex gap-2.5">
288
+ <div className="mt-0.5 flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted text-[10px] font-semibold uppercase text-muted-foreground ring-1 ring-border">
289
+ {avatarSrc ? (
290
+ // eslint-disable-next-line @next/next/no-img-element
291
+ <img
292
+ src={avatarSrc}
293
+ alt={comment.actorName ?? ''}
294
+ className="size-full object-cover"
295
+ />
296
+ ) : (
297
+ getInitials(comment.actorName)
298
+ )}
299
+ </div>
300
+ <div className="min-w-0 flex-1">
301
+ <div className="flex items-center justify-between gap-2">
302
+ <span className="truncate text-xs font-semibold">
303
+ {comment.actorName ?? ct('defaultUser')}
304
+ </span>
305
+ <div className="flex shrink-0 items-center gap-1">
306
+ <span className="text-[10px] text-muted-foreground">
307
+ {formatCommentDate(comment.createdAt)}
308
+ </span>
309
+ {!isEditing ? (
310
+ <>
311
+ {canModify ? (
312
+ <>
313
+ <button
314
+ type="button"
315
+ aria-label={ct('ariaEditComment')}
316
+ disabled={isDeleting}
317
+ onClick={() => handleStartEdit(comment)}
318
+ className="rounded p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"
319
+ >
320
+ <Pencil className="size-3" />
321
+ </button>
322
+ <button
323
+ type="button"
324
+ aria-label={ct('ariaDeleteComment')}
325
+ disabled={isDeleting}
326
+ onClick={() => void handleDelete(comment)}
327
+ className="rounded p-0.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
328
+ >
329
+ <Trash2 className="size-3" />
330
+ </button>
331
+ </>
332
+ ) : null}
333
+ </>
334
+ ) : null}
335
+ </div>
336
+ </div>
337
+ {isEditing ? (
338
+ <div className="mt-1 flex flex-col gap-1.5">
339
+ <RichTextEditor
340
+ value={editContent}
341
+ onChange={setEditContent}
342
+ mentions={mentionItems}
343
+ />
344
+ <div className="flex gap-1.5">
345
+ <Button
346
+ size="sm"
347
+ className="h-7 px-2 text-xs"
348
+ disabled={
349
+ isSaving ||
350
+ !editContent.replace(/<[^>]*>/g, '').trim()
351
+ }
352
+ onClick={() => void handleSaveEdit(comment)}
353
+ >
354
+ {ct('saveButton')}
355
+ </Button>
356
+ <Button
357
+ size="sm"
358
+ variant="ghost"
359
+ className="h-7 px-2 text-xs"
360
+ disabled={isSaving}
361
+ onClick={handleCancelEdit}
362
+ >
363
+ <X className="size-3" />
364
+ {ct('cancelButton')}
365
+ </Button>
366
+ </div>
367
+ </div>
368
+ ) : (
369
+ <CommentContent content={comment.content} />
370
+ )}
371
+ </div>
372
+ </div>
373
+ );
374
+ })}
375
+ </div>
376
+ ) : (
377
+ <p className="text-xs text-muted-foreground">{ct('noComments')}</p>
378
+ )}
379
+
380
+ <div className="flex flex-col gap-1.5 pt-1">
381
+ <RichTextEditor
382
+ value={newComment}
383
+ onChange={setNewComment}
384
+ mentions={mentionItems}
385
+ onCtrlEnter={() => void handleSubmit()}
386
+ />
387
+ <div className="flex items-center justify-end gap-2">
388
+ <span className="text-[10px] text-muted-foreground select-none">
389
+ <kbd className="rounded border bg-muted px-1 py-0.5 font-mono text-[10px]">Ctrl</kbd>
390
+ {' + '}
391
+ <kbd className="rounded border bg-muted px-1 py-0.5 font-mono text-[10px]">↵</kbd>
392
+ </span>
393
+ <Button
394
+ size="sm"
395
+ className="gap-1.5"
396
+ disabled={submitting || !newComment.replace(/<[^>]*>/g, '').trim()}
397
+ onMouseDown={(e) => e.preventDefault()}
398
+ onClick={() => void handleSubmit()}
399
+ >
400
+ {ct('submitButton')}
401
+ </Button>
402
+ </div>
403
+ </div>
404
+ </div>
405
+ );
406
+ }
407
+
408
+ export type TaskActivitiesSectionProps = {
409
+ taskId: number;
410
+ task?: {
411
+ status?: string | null;
412
+ doingStartedAt?: string | null;
413
+ totalDoingMinutes?: number | null;
414
+ } | null;
415
+ statusLabel?: (status: string) => string;
416
+ };
417
+
418
+ function getActivityAvatarSrc(activity: OperationsTaskActivity) {
419
+ if (
420
+ typeof activity.actorUserPhotoId === 'number' &&
421
+ activity.actorUserPhotoId > 0
422
+ )
423
+ return `${process.env.NEXT_PUBLIC_API_BASE_URL}/file/open/${activity.actorUserPhotoId}`;
424
+ if (
425
+ typeof activity.actorPersonAvatarId === 'number' &&
426
+ activity.actorPersonAvatarId > 0
427
+ )
428
+ return `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${activity.actorPersonAvatarId}`;
429
+ return null;
430
+ }
431
+
432
+ export function TaskActivitiesSection({
433
+ taskId,
434
+ task,
435
+ statusLabel,
436
+ }: TaskActivitiesSectionProps) {
437
+ const { request, getSettingValue, currentLocaleCode } = useApp();
438
+ const at = useTranslations('operations.ProjectDetailsPage.activitiesSection');
439
+ const [doingTick, setDoingTick] = useState(0);
440
+
441
+ const { data: activities = [] } = useQuery<OperationsTaskActivity[]>({
442
+ queryKey: ['task-activities', taskId],
443
+ queryFn: () =>
444
+ fetchTaskActivities(request, taskId) as Promise<OperationsTaskActivity[]>,
445
+ });
446
+
447
+ useEffect(() => {
448
+ if (task?.status !== 'doing' || !task.doingStartedAt) return;
449
+ const timer = setInterval(() => setDoingTick((value) => value + 1), 30000);
450
+ return () => clearInterval(timer);
451
+ }, [task?.doingStartedAt, task?.status]);
452
+
453
+ const uniqueCollaborators = useMemo(() => {
454
+ const collaborators = new Set<string>();
455
+ for (const activity of activities) {
456
+ if (activity.actorCollaboratorId != null) {
457
+ collaborators.add(`id:${activity.actorCollaboratorId}`);
458
+ } else if (activity.actorName) {
459
+ collaborators.add(`name:${activity.actorName}`);
460
+ }
461
+ }
462
+ return collaborators.size;
463
+ }, [activities]);
464
+
465
+ const totalDoingMinutes = getElapsedDoingMinutes(task, doingTick);
466
+
467
+ const kpiItems: KpiCardItem[] = [
468
+ {
469
+ key: 'activities',
470
+ title: at('kpis.activities'),
471
+ value: activities.length,
472
+ description: at('kpis.activitiesDescription'),
473
+ icon: Activity,
474
+ layout: 'compact',
475
+ accentClassName: 'from-sky-500/30 via-cyan-500/10 to-transparent',
476
+ iconContainerClassName: 'bg-sky-500/10 text-sky-700',
477
+ },
478
+ {
479
+ key: 'collaborators',
480
+ title: at('kpis.collaborators'),
481
+ value: uniqueCollaborators,
482
+ description: at('kpis.collaboratorsDescription'),
483
+ icon: Users,
484
+ layout: 'compact',
485
+ accentClassName: 'from-violet-500/30 via-fuchsia-500/10 to-transparent',
486
+ iconContainerClassName: 'bg-violet-500/10 text-violet-700',
487
+ },
488
+ {
489
+ key: 'doing-time',
490
+ title: at('kpis.executionTime'),
491
+ value: formatDurationMinutes(totalDoingMinutes),
492
+ description: at('kpis.executionTimeDescription'),
493
+ icon: Clock3,
494
+ layout: 'compact',
495
+ accentClassName: 'from-emerald-500/30 via-teal-500/10 to-transparent',
496
+ iconContainerClassName: 'bg-emerald-500/10 text-emerald-700',
497
+ },
498
+ ];
499
+
500
+ const getActivityDescription = (activity: OperationsTaskActivity) => {
501
+ if (activity.action === 'status_changed') {
502
+ return at('statusChanged', {
503
+ from: statusLabel?.(activity.fromStatus) ?? activity.fromStatus,
504
+ to: statusLabel?.(activity.toStatus) ?? activity.toStatus,
505
+ });
506
+ }
507
+ return at('fallbackAction');
508
+ };
509
+
510
+ return (
511
+ <div className="flex flex-col gap-4">
512
+ <div className="flex items-center gap-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
513
+ <History className="size-3" />
514
+ {at('title')}
515
+ {activities.length > 0 ? (
516
+ <span className="ml-0.5 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground">
517
+ {activities.length}
518
+ </span>
519
+ ) : null}
520
+ </div>
521
+
522
+ <KpiCardsGrid items={kpiItems} columns={3} className="gap-2" />
523
+
524
+ {activities.length > 0 ? (
525
+ <div className="relative flex flex-col">
526
+ {activities.map((activity, index) => {
527
+ const avatarSrc = getActivityAvatarSrc(activity);
528
+ const isLast = index === activities.length - 1;
529
+ return (
530
+ <div
531
+ key={activity.id}
532
+ className="relative flex gap-3 pb-4 last:pb-0"
533
+ >
534
+ <div className="relative flex shrink-0 justify-center">
535
+ {!isLast ? (
536
+ <span className="absolute bottom-0 top-9 w-px bg-border" />
537
+ ) : null}
538
+ <div className="relative z-10 flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-background bg-muted text-[11px] font-semibold uppercase text-muted-foreground shadow-sm ring-1 ring-border">
539
+ {avatarSrc ? (
540
+ // eslint-disable-next-line @next/next/no-img-element
541
+ <img
542
+ src={avatarSrc}
543
+ alt={activity.actorName ?? ''}
544
+ className="size-full object-cover"
545
+ />
546
+ ) : (
547
+ getInitials(activity.actorName)
548
+ )}
549
+ </div>
550
+ </div>
551
+ <div className="min-w-0 flex-1 rounded-md border bg-background px-3 py-2 shadow-sm">
552
+ <div className="flex flex-wrap items-start justify-between gap-x-2 gap-y-1">
553
+ <span className="min-w-0 truncate text-xs font-semibold">
554
+ {activity.actorName ?? at('defaultUser')}
555
+ </span>
556
+ <span className="shrink-0 text-[10px] font-medium text-muted-foreground">
557
+ {formatDateTime(
558
+ activity.createdAt,
559
+ getSettingValue,
560
+ currentLocaleCode
561
+ )}
562
+ </span>
563
+ </div>
564
+ <p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">
565
+ {getActivityDescription(activity)}
566
+ </p>
567
+ </div>
568
+ </div>
569
+ );
570
+ })}
571
+ </div>
572
+ ) : (
573
+ <p className="text-xs text-muted-foreground">{at('noActivities')}</p>
574
+ )}
575
+ </div>
576
+ );
577
+ }
578
+
579
+ export function TaskDetailSheet({
580
+ task,
581
+ open,
582
+ onOpenChange,
583
+ statusLabel,
584
+ defaultTab = 'comments',
585
+ }: Props) {
586
+ const detailT = useTranslations('operations.ProjectDetailsPage');
587
+ const commonT = useTranslations('operations.Common');
588
+ const { getSettingValue, currentLocaleCode } = useApp();
589
+
590
+ const [tab, setTab] = useState<'comments' | 'activities'>(defaultTab);
591
+ const [isTimesheetOpen, setIsTimesheetOpen] = useState(false);
592
+
593
+ useEffect(() => {
594
+ if (!open) {
595
+ setIsTimesheetOpen(false);
596
+ return;
597
+ }
598
+ const timer = setTimeout(() => setTab(defaultTab), 0);
599
+ return () => clearTimeout(timer);
600
+ }, [open, defaultTab]);
601
+
602
+ const avatarSrc = getAvatarSrc(task);
603
+
604
+ const timesheetProject =
605
+ task && task.projectId
606
+ ? {
607
+ id: task.projectId,
608
+ label:
609
+ [task.projectName, task.projectCode].filter(Boolean).join(' • ') ||
610
+ commonT('labels.notAssigned'),
611
+ projectAssignmentId: task.projectAssignmentId,
612
+ }
613
+ : null;
614
+
615
+ return (
616
+ <>
617
+ <Sheet open={open} onOpenChange={onOpenChange}>
618
+ <SheetContent className="flex w-full flex-col gap-0 overflow-hidden sm:max-w-5xl">
619
+ {task ? (
620
+ <>
621
+ <SheetHeader className="shrink-0 border-b px-5 pb-3 pt-4">
622
+ <div className="flex items-start justify-between gap-3 pr-8">
623
+ <SheetTitle className="text-base font-semibold leading-snug">
624
+ {task.name}
625
+ </SheetTitle>
626
+ {task.projectId ? (
627
+ <Button
628
+ variant="outline"
629
+ size="sm"
630
+ className="h-7 shrink-0 gap-1.5 text-xs"
631
+ onClick={() => setIsTimesheetOpen(true)}
632
+ >
633
+ <Timer className="size-3.5" />
634
+ {commonT('actions.logHours')}
635
+ </Button>
636
+ ) : null}
637
+ </div>
638
+ <SheetDescription className="sr-only">
639
+ {task.description || task.name}
640
+ </SheetDescription>
641
+ </SheetHeader>
642
+
643
+ <div className="flex flex-1 min-h-0 overflow-hidden">
644
+ {/* ── Coluna esquerda ── */}
645
+ <div className="flex flex-1 flex-col min-h-0 overflow-hidden">
646
+ {task.description ? (
647
+ <div className="shrink-0 border-b px-5 py-4">
648
+ <p className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
649
+ {detailT('taskForm.descriptionLabel')}
650
+ </p>
651
+ <p className="text-sm leading-relaxed">{task.description}</p>
652
+ </div>
653
+ ) : null}
654
+
655
+ <Tabs
656
+ value={tab}
657
+ onValueChange={(v) => setTab(v as 'comments' | 'activities')}
658
+ className="flex flex-1 min-h-0 flex-col"
659
+ >
660
+ <div className="shrink-0 border-b px-5 py-2">
661
+ <TabsList className="grid grid-cols-2">
662
+ <TabsTrigger value="comments">
663
+ <MessageSquare className="mr-1.5 size-3.5" />
664
+ {detailT('taskForm.tabComments')}
665
+ </TabsTrigger>
666
+ <TabsTrigger value="activities">
667
+ <History className="mr-1.5 size-3.5" />
668
+ {detailT('taskForm.tabActivities')}
669
+ </TabsTrigger>
670
+ </TabsList>
671
+ </div>
672
+ <TabsContent
673
+ value="comments"
674
+ className="mt-0 flex-1 overflow-y-auto px-5 py-4 data-[state=inactive]:hidden"
675
+ >
676
+ <TaskCommentsSection taskId={task.id} />
677
+ </TabsContent>
678
+ <TabsContent
679
+ value="activities"
680
+ className="mt-0 flex-1 overflow-y-auto px-5 py-4 data-[state=inactive]:hidden"
681
+ >
682
+ <TaskActivitiesSection
683
+ taskId={task.id}
684
+ task={task}
685
+ statusLabel={statusLabel}
686
+ />
687
+ </TabsContent>
688
+ </Tabs>
689
+ </div>
690
+
691
+ {/* ── Aside direito ── */}
692
+ <aside className="flex w-72 shrink-0 flex-col gap-4 overflow-y-auto border-l px-4 py-4">
693
+ <div>
694
+ <p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
695
+ {detailT('taskForm.columnLabel')}
696
+ </p>
697
+ <StatusBadge
698
+ label={statusLabel?.(task.status) ?? task.status}
699
+ className={getStatusBadgeClass(task.status)}
700
+ />
701
+ </div>
702
+
703
+ {task.priority ? (
704
+ <div>
705
+ <p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
706
+ {detailT('taskForm.priorityLabel')}
707
+ </p>
708
+ <span
709
+ className={`inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold ${getPriorityClassName(task.priority)}`}
710
+ >
711
+ {getTaskPriorityLabel(task.priority)}
712
+ </span>
713
+ </div>
714
+ ) : null}
715
+
716
+ {task.assigneeName ? (
717
+ <div>
718
+ <p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
719
+ {detailT('taskForm.assigneeLabel')}
720
+ </p>
721
+ <div className="flex items-center gap-2">
722
+ <div className="flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted text-[10px] font-semibold uppercase text-muted-foreground ring-1 ring-border">
723
+ {avatarSrc ? (
724
+ // eslint-disable-next-line @next/next/no-img-element
725
+ <img
726
+ src={avatarSrc}
727
+ alt={task.assigneeName}
728
+ className="size-full object-cover"
729
+ />
730
+ ) : (
731
+ getInitials(task.assigneeName)
732
+ )}
733
+ </div>
734
+ <span className="text-sm">{task.assigneeName}</span>
735
+ </div>
736
+ </div>
737
+ ) : null}
738
+
739
+ {task.dueDate ? (
740
+ <div>
741
+ <p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
742
+ {detailT('taskForm.deadlineLabel')}
743
+ </p>
744
+ <div className="flex items-center gap-1.5 text-sm">
745
+ <Calendar className="size-3.5 shrink-0 text-muted-foreground" />
746
+ <span>
747
+ {formatDate(task.dueDate, getSettingValue, currentLocaleCode)}
748
+ </span>
749
+ </div>
750
+ </div>
751
+ ) : null}
752
+
753
+ {task.estimateHours != null ? (
754
+ <div>
755
+ <p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
756
+ {detailT('taskForm.estimateLabel')}
757
+ </p>
758
+ <div className="flex items-center gap-1.5 text-sm">
759
+ <AlarmClock className="size-3.5 shrink-0 text-muted-foreground" />
760
+ <span>{task.estimateHours}h</span>
761
+ </div>
762
+ </div>
763
+ ) : null}
764
+
765
+ {task.tags ? (
766
+ <div>
767
+ <p className="mb-1.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
768
+ {detailT('taskForm.tagsLabel')}
769
+ </p>
770
+ <div className="flex flex-wrap gap-1.5">
771
+ {task.tags.split(',').map((tag) => (
772
+ <span
773
+ key={tag.trim()}
774
+ className="rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground"
775
+ >
776
+ {tag.trim()}
777
+ </span>
778
+ ))}
779
+ </div>
780
+ </div>
781
+ ) : null}
782
+
783
+ <div className="border-t pt-2">
784
+ <p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
785
+ {detailT('taskForm.attachmentsLabel')}
786
+ </p>
787
+ <TaskFileAttachments taskId={task.id} />
788
+ </div>
789
+ </aside>
790
+ </div>
791
+ </>
792
+ ) : null}
793
+ </SheetContent>
794
+ </Sheet>
795
+ <TimesheetEntryCreateSheet
796
+ open={isTimesheetOpen}
797
+ onOpenChange={setIsTimesheetOpen}
798
+ project={timesheetProject}
799
+ task={task ? { id: task.id, label: task.name } : null}
800
+ />
801
+ </>
802
+ );
803
+ }