@open-mercato/core 0.7.1-develop.7178.1.ab93fbaf4e → 0.7.1-develop.7180.1.9717fbbb43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +2 -2
- package/dist/generated/entities/staff_time_entry/index.js +14 -0
- package/dist/generated/entities/staff_time_entry/index.js.map +2 -2
- package/dist/generated/entities/staff_time_entry_tag/index.js +15 -0
- package/dist/generated/entities/staff_time_entry_tag/index.js.map +7 -0
- package/dist/generated/entities/staff_time_project/index.js +16 -0
- package/dist/generated/entities/staff_time_project/index.js.map +2 -2
- package/dist/generated/entities/staff_time_report/index.js +57 -0
- package/dist/generated/entities/staff_time_report/index.js.map +7 -0
- package/dist/generated/entities/staff_time_report_entry/index.js +27 -0
- package/dist/generated/entities/staff_time_report_entry/index.js.map +7 -0
- package/dist/generated/entities/staff_time_report_event/index.js +21 -0
- package/dist/generated/entities/staff_time_report_event/index.js.map +7 -0
- package/dist/generated/entities/staff_time_report_project/index.js +15 -0
- package/dist/generated/entities/staff_time_report_project/index.js.map +7 -0
- package/dist/generated/entities/staff_time_tag/index.js +21 -0
- package/dist/generated/entities/staff_time_tag/index.js.map +7 -0
- package/dist/generated/entities/staff_time_task/index.js +37 -0
- package/dist/generated/entities/staff_time_task/index.js.map +7 -0
- package/dist/generated/entities/staff_time_task_comment/index.js +21 -0
- package/dist/generated/entities/staff_time_task_comment/index.js.map +7 -0
- package/dist/generated/entities/staff_time_task_status/index.js +29 -0
- package/dist/generated/entities/staff_time_task_status/index.js.map +7 -0
- package/dist/generated/entities/staff_time_task_tag/index.js +15 -0
- package/dist/generated/entities/staff_time_task_tag/index.js.map +7 -0
- package/dist/generated/entities.ids.generated.js +11 -1
- package/dist/generated/entities.ids.generated.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +149 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/helpers/integration/timesheetFixtures.js +7 -0
- package/dist/helpers/integration/timesheetFixtures.js.map +2 -2
- package/dist/modules/auth/cli.js +16 -0
- package/dist/modules/auth/cli.js.map +2 -2
- package/dist/modules/auth/lib/backendChrome.js +1 -0
- package/dist/modules/auth/lib/backendChrome.js.map +2 -2
- package/dist/modules/customer_accounts/lib/customerRoleAcls.js +44 -0
- package/dist/modules/customer_accounts/lib/customerRoleAcls.js.map +7 -0
- package/dist/modules/customer_accounts/setup.js +1 -28
- package/dist/modules/customer_accounts/setup.js.map +2 -2
- package/dist/modules/data_sync/api/options.js +2 -0
- package/dist/modules/data_sync/api/options.js.map +2 -2
- package/dist/modules/data_sync/backend/data-sync/page.js +39 -17
- package/dist/modules/data_sync/backend/data-sync/page.js.map +2 -2
- package/dist/modules/data_sync/lib/start-controls.js +42 -0
- package/dist/modules/data_sync/lib/start-controls.js.map +7 -0
- package/dist/modules/staff/acl.js +9 -1
- package/dist/modules/staff/acl.js.map +2 -2
- package/dist/modules/staff/ai-agents.js +142 -0
- package/dist/modules/staff/ai-agents.js.map +7 -0
- package/dist/modules/staff/ai-tools/time-tracking-pack.js +348 -0
- package/dist/modules/staff/ai-tools/time-tracking-pack.js.map +7 -0
- package/dist/modules/staff/ai-tools/types.js +54 -0
- package/dist/modules/staff/ai-tools/types.js.map +7 -0
- package/dist/modules/staff/ai-tools.js +8 -0
- package/dist/modules/staff/ai-tools.js.map +7 -0
- package/dist/modules/staff/analytics.js +84 -1
- package/dist/modules/staff/analytics.js.map +2 -2
- package/dist/modules/staff/api/guards.js +37 -3
- package/dist/modules/staff/api/guards.js.map +2 -2
- package/dist/modules/staff/api/leave-requests/accept/route.js +1 -3
- package/dist/modules/staff/api/leave-requests/accept/route.js.map +2 -2
- package/dist/modules/staff/api/leave-requests/reject/route.js +1 -3
- package/dist/modules/staff/api/leave-requests/reject/route.js.map +2 -2
- package/dist/modules/staff/api/portal/time-reports/[id]/route.js +131 -0
- package/dist/modules/staff/api/portal/time-reports/[id]/route.js.map +7 -0
- package/dist/modules/staff/api/portal/time-reports/route.js +179 -0
- package/dist/modules/staff/api/portal/time-reports/route.js.map +7 -0
- package/dist/modules/staff/api/team-members/self/route.js +1 -3
- package/dist/modules/staff/api/team-members/self/route.js.map +2 -2
- package/dist/modules/staff/api/team-members/tags/assign/route.js +1 -3
- package/dist/modules/staff/api/team-members/tags/assign/route.js.map +2 -2
- package/dist/modules/staff/api/team-members/tags/unassign/route.js +1 -3
- package/dist/modules/staff/api/team-members/tags/unassign/route.js.map +2 -2
- package/dist/modules/staff/api/timesheets/_shared/withTimesheetInterceptors.js +174 -0
- package/dist/modules/staff/api/timesheets/_shared/withTimesheetInterceptors.js.map +7 -0
- package/dist/modules/staff/api/timesheets/access-requests/route.js +226 -0
- package/dist/modules/staff/api/timesheets/access-requests/route.js.map +7 -0
- package/dist/modules/staff/api/timesheets/my-projects/[projectId]/route.js +15 -8
- package/dist/modules/staff/api/timesheets/my-projects/[projectId]/route.js.map +2 -2
- package/dist/modules/staff/api/timesheets/my-projects/route.js +13 -1
- package/dist/modules/staff/api/timesheets/my-projects/route.js.map +2 -2
- package/dist/modules/staff/api/timesheets/my-work/myWorkAggregate.js +42 -0
- package/dist/modules/staff/api/timesheets/my-work/myWorkAggregate.js.map +7 -0
- package/dist/modules/staff/api/timesheets/my-work/route.js +383 -0
- package/dist/modules/staff/api/timesheets/my-work/route.js.map +7 -0
- package/dist/modules/staff/api/timesheets/projects/kpis/route.js +14 -2
- package/dist/modules/staff/api/timesheets/projects/kpis/route.js.map +2 -2
- package/dist/modules/staff/api/timesheets/reports/[id]/close/route.js +168 -0
- package/dist/modules/staff/api/timesheets/reports/[id]/close/route.js.map +7 -0
- package/dist/modules/staff/api/timesheets/reports/[id]/export/route.js +246 -0
- package/dist/modules/staff/api/timesheets/reports/[id]/export/route.js.map +7 -0
- package/dist/modules/staff/api/timesheets/reports/[id]/sheet/route.js +183 -0
- package/dist/modules/staff/api/timesheets/reports/[id]/sheet/route.js.map +7 -0
- package/dist/modules/staff/api/timesheets/reports/[id]/unlock/route.js +164 -0
- package/dist/modules/staff/api/timesheets/reports/[id]/unlock/route.js.map +7 -0
- package/dist/modules/staff/api/timesheets/reports/preview/route.js +360 -0
- package/dist/modules/staff/api/timesheets/reports/preview/route.js.map +7 -0
- package/dist/modules/staff/api/timesheets/reports/route.js +316 -0
- package/dist/modules/staff/api/timesheets/reports/route.js.map +7 -0
- package/dist/modules/staff/api/timesheets/reports/shared.js +63 -0
- package/dist/modules/staff/api/timesheets/reports/shared.js.map +7 -0
- package/dist/modules/staff/api/timesheets/settings/reapply-rounding/route.js +164 -0
- package/dist/modules/staff/api/timesheets/settings/reapply-rounding/route.js.map +7 -0
- package/dist/modules/staff/api/timesheets/settings/rounding-impact/route.js +168 -0
- package/dist/modules/staff/api/timesheets/settings/rounding-impact/route.js.map +7 -0
- package/dist/modules/staff/api/timesheets/settings/route.js +199 -0
- package/dist/modules/staff/api/timesheets/settings/route.js.map +7 -0
- package/dist/modules/staff/api/timesheets/tags/access.js +76 -0
- package/dist/modules/staff/api/timesheets/tags/access.js.map +7 -0
- package/dist/modules/staff/api/timesheets/tags/entry-assignments/route.js +235 -0
- package/dist/modules/staff/api/timesheets/tags/entry-assignments/route.js.map +7 -0
- package/dist/modules/staff/api/timesheets/tags/route.js +160 -0
- package/dist/modules/staff/api/timesheets/tags/route.js.map +7 -0
- package/dist/modules/staff/api/timesheets/tags/task-assignments/route.js +233 -0
- package/dist/modules/staff/api/timesheets/tags/task-assignments/route.js.map +7 -0
- package/dist/modules/staff/api/timesheets/task-statuses/route.js +188 -0
- package/dist/modules/staff/api/timesheets/task-statuses/route.js.map +7 -0
- package/dist/modules/staff/api/timesheets/tasks/[id]/comments/route.js +399 -0
- package/dist/modules/staff/api/timesheets/tasks/[id]/comments/route.js.map +7 -0
- package/dist/modules/staff/api/timesheets/tasks/[id]/status/route.js +210 -0
- package/dist/modules/staff/api/timesheets/tasks/[id]/status/route.js.map +7 -0
- package/dist/modules/staff/api/timesheets/tasks/route.js +333 -0
- package/dist/modules/staff/api/timesheets/tasks/route.js.map +7 -0
- package/dist/modules/staff/api/timesheets/time-entries/[id]/duplicate/route.js +228 -0
- package/dist/modules/staff/api/timesheets/time-entries/[id]/duplicate/route.js.map +7 -0
- package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/[segmentId]/route.js +29 -10
- package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/[segmentId]/route.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/route.js +31 -18
- package/dist/modules/staff/api/timesheets/time-entries/[id]/segments/route.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js +21 -7
- package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.js +27 -7
- package/dist/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-entries/bulk/route.js +215 -32
- package/dist/modules/staff/api/timesheets/time-entries/bulk/route.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-entries/copy-day/route.js +378 -0
- package/dist/modules/staff/api/timesheets/time-entries/copy-day/route.js.map +7 -0
- package/dist/modules/staff/api/timesheets/time-entries/overlaps/route.js +340 -0
- package/dist/modules/staff/api/timesheets/time-entries/overlaps/route.js.map +7 -0
- package/dist/modules/staff/api/timesheets/time-entries/route.js +238 -26
- package/dist/modules/staff/api/timesheets/time-entries/route.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-entries/start-timer/route.js +23 -8
- package/dist/modules/staff/api/timesheets/time-entries/start-timer/route.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-projects/[id]/change-currency/route.js +209 -0
- package/dist/modules/staff/api/timesheets/time-projects/[id]/change-currency/route.js.map +7 -0
- package/dist/modules/staff/api/timesheets/time-projects/[id]/employees/route.js +59 -2
- package/dist/modules/staff/api/timesheets/time-projects/[id]/employees/route.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-projects/route.js +264 -22
- package/dist/modules/staff/api/timesheets/time-projects/route.js.map +2 -2
- package/dist/modules/staff/backend/staff/time-tracking/board/page.js +110 -0
- package/dist/modules/staff/backend/staff/time-tracking/board/page.js.map +7 -0
- package/dist/modules/staff/backend/staff/time-tracking/board/page.meta.js +18 -0
- package/dist/modules/staff/backend/staff/time-tracking/board/page.meta.js.map +7 -0
- package/dist/modules/staff/backend/staff/time-tracking/entries/page.js +981 -0
- package/dist/modules/staff/backend/staff/time-tracking/entries/page.js.map +7 -0
- package/dist/modules/staff/backend/staff/time-tracking/entries/page.meta.js +18 -0
- package/dist/modules/staff/backend/staff/time-tracking/entries/page.meta.js.map +7 -0
- package/dist/modules/staff/backend/staff/time-tracking/page.js +583 -0
- package/dist/modules/staff/backend/staff/time-tracking/page.js.map +7 -0
- package/dist/modules/staff/backend/staff/time-tracking/page.meta.js +15 -0
- package/dist/modules/staff/backend/staff/time-tracking/page.meta.js.map +7 -0
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/board/page.js +65 -0
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/board/page.js.map +7 -0
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/board/page.meta.js +16 -0
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/board/page.meta.js.map +7 -0
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.js +229 -0
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.js.map +7 -0
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.meta.js +16 -0
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.meta.js.map +7 -0
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js +1014 -0
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.js.map +7 -0
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.meta.js +16 -0
- package/dist/modules/staff/backend/staff/time-tracking/projects/[id]/page.meta.js.map +7 -0
- package/dist/modules/staff/backend/staff/time-tracking/projects/create/page.js +97 -0
- package/dist/modules/staff/backend/staff/time-tracking/projects/create/page.js.map +7 -0
- package/dist/modules/staff/backend/staff/time-tracking/projects/create/page.meta.js +16 -0
- package/dist/modules/staff/backend/staff/time-tracking/projects/create/page.meta.js.map +7 -0
- package/dist/modules/staff/backend/staff/time-tracking/projects/page.js +1040 -0
- package/dist/modules/staff/backend/staff/time-tracking/projects/page.js.map +7 -0
- package/dist/modules/staff/backend/staff/time-tracking/projects/page.meta.js +19 -0
- package/dist/modules/staff/backend/staff/time-tracking/projects/page.meta.js.map +7 -0
- package/dist/modules/staff/backend/staff/time-tracking/projects/projectFormConfig.js +353 -0
- package/dist/modules/staff/backend/staff/time-tracking/projects/projectFormConfig.js.map +7 -0
- package/dist/modules/staff/backend/staff/time-tracking/reports/[id]/page.js +457 -0
- package/dist/modules/staff/backend/staff/time-tracking/reports/[id]/page.js.map +7 -0
- package/dist/modules/staff/backend/staff/time-tracking/reports/[id]/page.meta.js +16 -0
- package/dist/modules/staff/backend/staff/time-tracking/reports/[id]/page.meta.js.map +7 -0
- package/dist/modules/staff/backend/staff/time-tracking/reports/create/page.js +697 -0
- package/dist/modules/staff/backend/staff/time-tracking/reports/create/page.js.map +7 -0
- package/dist/modules/staff/backend/staff/time-tracking/reports/create/page.meta.js +16 -0
- package/dist/modules/staff/backend/staff/time-tracking/reports/create/page.meta.js.map +7 -0
- package/dist/modules/staff/backend/staff/time-tracking/reports/page.js +174 -0
- package/dist/modules/staff/backend/staff/time-tracking/reports/page.js.map +7 -0
- package/dist/modules/staff/backend/staff/time-tracking/reports/page.meta.js +18 -0
- package/dist/modules/staff/backend/staff/time-tracking/reports/page.meta.js.map +7 -0
- package/dist/modules/staff/backend/staff/time-tracking/settings/page.js +546 -0
- package/dist/modules/staff/backend/staff/time-tracking/settings/page.js.map +7 -0
- package/dist/modules/staff/backend/staff/time-tracking/settings/page.meta.js +18 -0
- package/dist/modules/staff/backend/staff/time-tracking/settings/page.meta.js.map +7 -0
- package/dist/modules/staff/backend/staff/time-tracking/timesheet/GridView.js +710 -0
- package/dist/modules/staff/backend/staff/time-tracking/timesheet/GridView.js.map +7 -0
- package/dist/modules/staff/backend/staff/time-tracking/timesheet/page.js +646 -0
- package/dist/modules/staff/backend/staff/time-tracking/timesheet/page.js.map +7 -0
- package/dist/modules/staff/backend/staff/time-tracking/timesheet/page.meta.js +18 -0
- package/dist/modules/staff/backend/staff/time-tracking/timesheet/page.meta.js.map +7 -0
- package/dist/modules/staff/backend/staff/timesheets/page.js +4 -793
- package/dist/modules/staff/backend/staff/timesheets/page.js.map +2 -2
- package/dist/modules/staff/backend/staff/timesheets/page.meta.js +2 -7
- package/dist/modules/staff/backend/staff/timesheets/page.meta.js.map +2 -2
- package/dist/modules/staff/backend/staff/timesheets/projects/[id]/edit/page.js +5 -136
- package/dist/modules/staff/backend/staff/timesheets/projects/[id]/edit/page.js.map +2 -2
- package/dist/modules/staff/backend/staff/timesheets/projects/[id]/edit/page.meta.js +1 -7
- package/dist/modules/staff/backend/staff/timesheets/projects/[id]/edit/page.meta.js.map +2 -2
- package/dist/modules/staff/backend/staff/timesheets/projects/[id]/page.js +5 -445
- package/dist/modules/staff/backend/staff/timesheets/projects/[id]/page.js.map +3 -3
- package/dist/modules/staff/backend/staff/timesheets/projects/[id]/page.meta.js +1 -7
- package/dist/modules/staff/backend/staff/timesheets/projects/[id]/page.meta.js.map +2 -2
- package/dist/modules/staff/backend/staff/timesheets/projects/create/page.js +4 -76
- package/dist/modules/staff/backend/staff/timesheets/projects/create/page.js.map +2 -2
- package/dist/modules/staff/backend/staff/timesheets/projects/create/page.meta.js +1 -7
- package/dist/modules/staff/backend/staff/timesheets/projects/create/page.meta.js.map +2 -2
- package/dist/modules/staff/backend/staff/timesheets/projects/page.js +4 -613
- package/dist/modules/staff/backend/staff/timesheets/projects/page.js.map +2 -2
- package/dist/modules/staff/backend/staff/timesheets/projects/page.meta.js +2 -17
- package/dist/modules/staff/backend/staff/timesheets/projects/page.meta.js.map +2 -2
- package/dist/modules/staff/ce.js +40 -0
- package/dist/modules/staff/ce.js.map +2 -2
- package/dist/modules/staff/cli.js +166 -1
- package/dist/modules/staff/cli.js.map +2 -2
- package/dist/modules/staff/commands/index.js +5 -0
- package/dist/modules/staff/commands/index.js.map +2 -2
- package/dist/modules/staff/commands/timesheets-entries.js +569 -21
- package/dist/modules/staff/commands/timesheets-entries.js.map +3 -3
- package/dist/modules/staff/commands/timesheets-projects.js +450 -7
- package/dist/modules/staff/commands/timesheets-projects.js.map +2 -2
- package/dist/modules/staff/commands/timesheets-reports.js +1036 -0
- package/dist/modules/staff/commands/timesheets-reports.js.map +7 -0
- package/dist/modules/staff/commands/timesheets-tags.js +1015 -0
- package/dist/modules/staff/commands/timesheets-tags.js.map +7 -0
- package/dist/modules/staff/commands/timesheets-task-comments.js +438 -0
- package/dist/modules/staff/commands/timesheets-task-comments.js.map +7 -0
- package/dist/modules/staff/commands/timesheets-task-statuses.js +723 -0
- package/dist/modules/staff/commands/timesheets-task-statuses.js.map +7 -0
- package/dist/modules/staff/commands/timesheets-tasks.js +834 -0
- package/dist/modules/staff/commands/timesheets-tasks.js.map +7 -0
- package/dist/modules/staff/data/enrichers.js +426 -12
- package/dist/modules/staff/data/enrichers.js.map +2 -2
- package/dist/modules/staff/data/entities.js +552 -3
- package/dist/modules/staff/data/entities.js.map +2 -2
- package/dist/modules/staff/data/extensions.js +41 -0
- package/dist/modules/staff/data/extensions.js.map +7 -0
- package/dist/modules/staff/data/validators.js +254 -9
- package/dist/modules/staff/data/validators.js.map +2 -2
- package/dist/modules/staff/di.js +42 -1
- package/dist/modules/staff/di.js.map +2 -2
- package/dist/modules/staff/events.js +84 -6
- package/dist/modules/staff/events.js.map +2 -2
- package/dist/modules/staff/events.payloads.js +158 -0
- package/dist/modules/staff/events.payloads.js.map +7 -0
- package/dist/modules/staff/extension-points.js +261 -0
- package/dist/modules/staff/extension-points.js.map +7 -0
- package/dist/modules/staff/frontend/[orgSlug]/portal/time-reports/[id]/page.js +138 -0
- package/dist/modules/staff/frontend/[orgSlug]/portal/time-reports/[id]/page.js.map +7 -0
- package/dist/modules/staff/frontend/[orgSlug]/portal/time-reports/[id]/page.meta.js +12 -0
- package/dist/modules/staff/frontend/[orgSlug]/portal/time-reports/[id]/page.meta.js.map +7 -0
- package/dist/modules/staff/frontend/[orgSlug]/portal/time-reports/page.js +133 -0
- package/dist/modules/staff/frontend/[orgSlug]/portal/time-reports/page.js.map +7 -0
- package/dist/modules/staff/frontend/[orgSlug]/portal/time-reports/page.meta.js +19 -0
- package/dist/modules/staff/frontend/[orgSlug]/portal/time-reports/page.meta.js.map +7 -0
- package/dist/modules/staff/lib/crud.js +11 -1
- package/dist/modules/staff/lib/crud.js.map +2 -2
- package/dist/modules/staff/lib/time-tracking/access.js +92 -0
- package/dist/modules/staff/lib/time-tracking/access.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking/billability.js +39 -0
- package/dist/modules/staff/lib/time-tracking/billability.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking/capacity.js +68 -0
- package/dist/modules/staff/lib/time-tracking/capacity.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking/componentContracts.js +15 -0
- package/dist/modules/staff/lib/time-tracking/componentContracts.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking/cost.js +82 -0
- package/dist/modules/staff/lib/time-tracking/cost.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking/duration.js +55 -0
- package/dist/modules/staff/lib/time-tracking/duration.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking/featureAccess.js +41 -0
- package/dist/modules/staff/lib/time-tracking/featureAccess.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking/interval.js +72 -0
- package/dist/modules/staff/lib/time-tracking/interval.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking/migrateProjectCodes.js +88 -0
- package/dist/modules/staff/lib/time-tracking/migrateProjectCodes.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking/moneyVisibility.js +10 -0
- package/dist/modules/staff/lib/time-tracking/moneyVisibility.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking/overlap.js +87 -0
- package/dist/modules/staff/lib/time-tracking/overlap.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking/portalRecipients.js +27 -0
- package/dist/modules/staff/lib/time-tracking/portalRecipients.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking/portalReports.js +16 -0
- package/dist/modules/staff/lib/time-tracking/portalReports.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking/projectCode.js +135 -0
- package/dist/modules/staff/lib/time-tracking/projectCode.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking/reapplyRounding.js +187 -0
- package/dist/modules/staff/lib/time-tracking/reapplyRounding.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking/recalculationRunner.js +80 -0
- package/dist/modules/staff/lib/time-tracking/recalculationRunner.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking/recalculations.js +56 -0
- package/dist/modules/staff/lib/time-tracking/recalculations.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking/registries/invoke.js +63 -0
- package/dist/modules/staff/lib/time-tracking/registries/invoke.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking/registries/registry.js +62 -0
- package/dist/modules/staff/lib/time-tracking/registries/registry.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking/registries/scope.js +21 -0
- package/dist/modules/staff/lib/time-tracking/registries/scope.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking/rollup.js +34 -0
- package/dist/modules/staff/lib/time-tracking/rollup.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking/rounding.js +66 -0
- package/dist/modules/staff/lib/time-tracking/rounding.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking/roundingImpact.js +39 -0
- package/dist/modules/staff/lib/time-tracking/roundingImpact.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking/settingKeys.js +214 -0
- package/dist/modules/staff/lib/time-tracking/settingKeys.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking/settings.js +87 -0
- package/dist/modules/staff/lib/time-tracking/settings.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking/sqlInClause.js +9 -0
- package/dist/modules/staff/lib/time-tracking/sqlInClause.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking/timeEntrySources.js +48 -0
- package/dist/modules/staff/lib/time-tracking/timeEntrySources.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/BoardFilterChips.js +283 -0
- package/dist/modules/staff/lib/time-tracking-ui/BoardFilterChips.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/CustomerPicker.js +324 -0
- package/dist/modules/staff/lib/time-tracking-ui/CustomerPicker.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/DurationInput.js +121 -0
- package/dist/modules/staff/lib/time-tracking-ui/DurationInput.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/EntryDetailsFields.js +188 -0
- package/dist/modules/staff/lib/time-tracking-ui/EntryDetailsFields.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/EntryFilterBar.js +89 -0
- package/dist/modules/staff/lib/time-tracking-ui/EntryFilterBar.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/KanbanBoard.js +855 -0
- package/dist/modules/staff/lib/time-tracking-ui/KanbanBoard.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/KanbanCard.js +360 -0
- package/dist/modules/staff/lib/time-tracking-ui/KanbanCard.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/KanbanColumn.js +259 -0
- package/dist/modules/staff/lib/time-tracking-ui/KanbanColumn.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/NewTaskDialog.js +238 -0
- package/dist/modules/staff/lib/time-tracking-ui/NewTaskDialog.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/NoProjectAccess.js +130 -0
- package/dist/modules/staff/lib/time-tracking-ui/NoProjectAccess.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/PeriodSelector.js +98 -0
- package/dist/modules/staff/lib/time-tracking-ui/PeriodSelector.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/PhasePlaceholder.js +27 -0
- package/dist/modules/staff/lib/time-tracking-ui/PhasePlaceholder.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/ProjectCodeField.js +166 -0
- package/dist/modules/staff/lib/time-tracking-ui/ProjectCodeField.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/ProjectCurrencyField.js +295 -0
- package/dist/modules/staff/lib/time-tracking-ui/ProjectCurrencyField.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/ProjectFormSections.js +212 -0
- package/dist/modules/staff/lib/time-tracking-ui/ProjectFormSections.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/ProjectTeamDrawer.js +729 -0
- package/dist/modules/staff/lib/time-tracking-ui/ProjectTeamDrawer.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/ReportSheet.js +217 -0
- package/dist/modules/staff/lib/time-tracking-ui/ReportSheet.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/TagPicker.js +158 -0
- package/dist/modules/staff/lib/time-tracking-ui/TagPicker.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/TaskBoardScreen.js +224 -0
- package/dist/modules/staff/lib/time-tracking-ui/TaskBoardScreen.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/TaskCommentThread.js +93 -0
- package/dist/modules/staff/lib/time-tracking-ui/TaskCommentThread.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/TaskDrawer.js +1196 -0
- package/dist/modules/staff/lib/time-tracking-ui/TaskDrawer.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/TaskListView.js +176 -0
- package/dist/modules/staff/lib/time-tracking-ui/TaskListView.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/TaskPicker.js +307 -0
- package/dist/modules/staff/lib/time-tracking-ui/TaskPicker.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/TaskQuickLog.js +284 -0
- package/dist/modules/staff/lib/time-tracking-ui/TaskQuickLog.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/TimeEntriesSummaryFooter.js +73 -0
- package/dist/modules/staff/lib/time-tracking-ui/TimeEntriesSummaryFooter.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/TimeEntryDialog.js +1499 -0
- package/dist/modules/staff/lib/time-tracking-ui/TimeEntryDialog.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/TimeEntryDurationCell.js +95 -0
- package/dist/modules/staff/lib/time-tracking-ui/TimeEntryDurationCell.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/TimesheetCalendar.js +188 -0
- package/dist/modules/staff/lib/time-tracking-ui/TimesheetCalendar.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/TimesheetPeriodFooter.js +61 -0
- package/dist/modules/staff/lib/time-tracking-ui/TimesheetPeriodFooter.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/TimesheetViewSwitch.js +45 -0
- package/dist/modules/staff/lib/time-tracking-ui/TimesheetViewSwitch.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/boardDirectory.js +176 -0
- package/dist/modules/staff/lib/time-tracking-ui/boardDirectory.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/boardFilters.js +82 -0
- package/dist/modules/staff/lib/time-tracking-ui/boardFilters.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/boardSummary.js +5 -0
- package/dist/modules/staff/lib/time-tracking-ui/boardSummary.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/kanbanBoardData.js +152 -0
- package/dist/modules/staff/lib/time-tracking-ui/kanbanBoardData.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/projectTeamAssignment.js +92 -0
- package/dist/modules/staff/lib/time-tracking-ui/projectTeamAssignment.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/taskDrawerData.js +111 -0
- package/dist/modules/staff/lib/time-tracking-ui/taskDrawerData.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/timeEntryDialogState.js +243 -0
- package/dist/modules/staff/lib/time-tracking-ui/timeEntryDialogState.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/timeEntryListData.js +151 -0
- package/dist/modules/staff/lib/time-tracking-ui/timeEntryListData.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/timeTrackingSettingsForm.js +105 -0
- package/dist/modules/staff/lib/time-tracking-ui/timeTrackingSettingsForm.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/timesheetData.js +132 -0
- package/dist/modules/staff/lib/time-tracking-ui/timesheetData.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/timesheetPeriod.js +205 -0
- package/dist/modules/staff/lib/time-tracking-ui/timesheetPeriod.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/timesheetTargets.js +53 -0
- package/dist/modules/staff/lib/time-tracking-ui/timesheetTargets.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/useBoardFilters.js +77 -0
- package/dist/modules/staff/lib/time-tracking-ui/useBoardFilters.js.map +7 -0
- package/dist/modules/staff/lib/time-tracking-ui/useTimesheetPreferences.js +151 -0
- package/dist/modules/staff/lib/time-tracking-ui/useTimesheetPreferences.js.map +7 -0
- package/dist/modules/staff/lib/timeTrackingSeeds.js +809 -0
- package/dist/modules/staff/lib/timeTrackingSeeds.js.map +7 -0
- package/dist/modules/staff/lib/timesheets/timeEntryCacheInvalidation.js +2 -3
- package/dist/modules/staff/lib/timesheets/timeEntryCacheInvalidation.js.map +2 -2
- package/dist/modules/staff/lib/timesheets/timeEntryDecoration.js +152 -0
- package/dist/modules/staff/lib/timesheets/timeEntryDecoration.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-projects/budgetBurn.js +36 -0
- package/dist/modules/staff/lib/timesheets-projects/budgetBurn.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-projects/budgetThreshold.js +66 -0
- package/dist/modules/staff/lib/timesheets-projects/budgetThreshold.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-projects/budgetThresholdState.js +78 -0
- package/dist/modules/staff/lib/timesheets-projects/budgetThresholdState.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-projects/computeProjectFinancials.js +81 -0
- package/dist/modules/staff/lib/timesheets-projects/computeProjectFinancials.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-projects/reportSelection.js +44 -0
- package/dist/modules/staff/lib/timesheets-projects/reportSelection.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-projects-ui/AssignMembersDialog.js +169 -0
- package/dist/modules/staff/lib/timesheets-projects-ui/AssignMembersDialog.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-projects-ui/ProjectBudgetCell.js +22 -0
- package/dist/modules/staff/lib/timesheets-projects-ui/ProjectBudgetCell.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-projects-ui/ProjectCard.js +51 -1
- package/dist/modules/staff/lib/timesheets-projects-ui/ProjectCard.js.map +2 -2
- package/dist/modules/staff/lib/timesheets-projects-ui/ProjectsBulkActionsBar.js +70 -0
- package/dist/modules/staff/lib/timesheets-projects-ui/ProjectsBulkActionsBar.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-reports/buildReportSheet.js +63 -0
- package/dist/modules/staff/lib/timesheets-reports/buildReportSheet.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-reports/loadReportData.js +170 -0
- package/dist/modules/staff/lib/timesheets-reports/loadReportData.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-reports/pdf.js +205 -0
- package/dist/modules/staff/lib/timesheets-reports/pdf.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-reports/reportApprovalPolicies.js +68 -0
- package/dist/modules/staff/lib/timesheets-reports/reportApprovalPolicies.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-reports/reportConfigData.js +135 -0
- package/dist/modules/staff/lib/timesheets-reports/reportConfigData.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-reports/reportConfigState.js +48 -0
- package/dist/modules/staff/lib/timesheets-reports/reportConfigState.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-reports/reportExport.js +262 -0
- package/dist/modules/staff/lib/timesheets-reports/reportExport.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-reports/reportExportFormats.js +34 -0
- package/dist/modules/staff/lib/timesheets-reports/reportExportFormats.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-reports/reportGroupings.js +79 -0
- package/dist/modules/staff/lib/timesheets-reports/reportGroupings.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-reports/reportLabels.js +11 -0
- package/dist/modules/staff/lib/timesheets-reports/reportLabels.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-reports/reportReference.js +83 -0
- package/dist/modules/staff/lib/timesheets-reports/reportReference.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-reports/reportRows.js +42 -0
- package/dist/modules/staff/lib/timesheets-reports/reportRows.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-reports/reportSheetData.js +107 -0
- package/dist/modules/staff/lib/timesheets-reports/reportSheetData.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-reports/reportTotals.js +289 -0
- package/dist/modules/staff/lib/timesheets-reports/reportTotals.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-reports/xlsx.js +145 -0
- package/dist/modules/staff/lib/timesheets-reports/xlsx.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-tasks/computeTaskRollups.js +116 -0
- package/dist/modules/staff/lib/timesheets-tasks/computeTaskRollups.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-tasks/defaultStatusTemplate.js +50 -0
- package/dist/modules/staff/lib/timesheets-tasks/defaultStatusTemplate.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-tasks/seedProjectStatuses.js +27 -0
- package/dist/modules/staff/lib/timesheets-tasks/seedProjectStatuses.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-tasks/statusPositions.js +65 -0
- package/dist/modules/staff/lib/timesheets-tasks/statusPositions.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-tasks/statusSlug.js +31 -0
- package/dist/modules/staff/lib/timesheets-tasks/statusSlug.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-tasks/taskHierarchy.js +31 -0
- package/dist/modules/staff/lib/timesheets-tasks/taskHierarchy.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-tasks/taskHoursTotals.js +17 -0
- package/dist/modules/staff/lib/timesheets-tasks/taskHoursTotals.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-tasks/taskReference.js +51 -0
- package/dist/modules/staff/lib/timesheets-tasks/taskReference.js.map +7 -0
- package/dist/modules/staff/lib/timesheets-ui/CreateProjectDialog.js +11 -4
- package/dist/modules/staff/lib/timesheets-ui/CreateProjectDialog.js.map +2 -2
- package/dist/modules/staff/lib/timesheets-ui/ListView.js +369 -111
- package/dist/modules/staff/lib/timesheets-ui/ListView.js.map +2 -2
- package/dist/modules/staff/lib/timesheets-ui/TimerBar.js +47 -2
- package/dist/modules/staff/lib/timesheets-ui/TimerBar.js.map +2 -2
- package/dist/modules/staff/migrations/Migration20260813073131_staff.js +79 -0
- package/dist/modules/staff/migrations/Migration20260813073131_staff.js.map +7 -0
- package/dist/modules/staff/migrations/Migration20260824143357_staff.js +15 -0
- package/dist/modules/staff/migrations/Migration20260824143357_staff.js.map +7 -0
- package/dist/modules/staff/notifications.client.js +16 -0
- package/dist/modules/staff/notifications.client.js.map +7 -0
- package/dist/modules/staff/notifications.handlers.js +80 -0
- package/dist/modules/staff/notifications.handlers.js.map +7 -0
- package/dist/modules/staff/notifications.js +168 -0
- package/dist/modules/staff/notifications.js.map +2 -2
- package/dist/modules/staff/search.js +159 -1
- package/dist/modules/staff/search.js.map +2 -2
- package/dist/modules/staff/setup.js +20 -1
- package/dist/modules/staff/setup.js.map +2 -2
- package/dist/modules/staff/subscribers/time-project-access-requested-notification.js +68 -0
- package/dist/modules/staff/subscribers/time-project-access-requested-notification.js.map +7 -0
- package/dist/modules/staff/subscribers/time-project-budget-threshold-notification.js +149 -0
- package/dist/modules/staff/subscribers/time-project-budget-threshold-notification.js.map +7 -0
- package/dist/modules/staff/subscribers/time-report-approved-notification.js +78 -0
- package/dist/modules/staff/subscribers/time-report-approved-notification.js.map +7 -0
- package/dist/modules/staff/subscribers/time-report-portal-broadcast.js +78 -0
- package/dist/modules/staff/subscribers/time-report-portal-broadcast.js.map +7 -0
- package/dist/modules/staff/translations.js +4 -1
- package/dist/modules/staff/translations.js.map +2 -2
- package/dist/modules/staff/widgets/components.js +7 -0
- package/dist/modules/staff/widgets/components.js.map +7 -0
- package/dist/modules/staff/widgets/injection/timer-sidebar-indicator/widget.js +5 -4
- package/dist/modules/staff/widgets/injection/timer-sidebar-indicator/widget.js.map +2 -2
- package/dist/modules/staff/widgets/notifications/TimeProjectAccessRequestedRenderer.js +135 -0
- package/dist/modules/staff/widgets/notifications/TimeProjectAccessRequestedRenderer.js.map +7 -0
- package/dist/modules/staff/widgets/notifications/index.js +5 -0
- package/dist/modules/staff/widgets/notifications/index.js.map +7 -0
- package/dist/modules/staff/workers/timesheets-reapply-rounding.js +43 -0
- package/dist/modules/staff/workers/timesheets-reapply-rounding.js.map +7 -0
- package/generated/entities/staff_time_entry/index.ts +7 -0
- package/generated/entities/staff_time_entry_tag/index.ts +6 -0
- package/generated/entities/staff_time_project/index.ts +8 -0
- package/generated/entities/staff_time_report/index.ts +27 -0
- package/generated/entities/staff_time_report_entry/index.ts +12 -0
- package/generated/entities/staff_time_report_event/index.ts +9 -0
- package/generated/entities/staff_time_report_project/index.ts +6 -0
- package/generated/entities/staff_time_tag/index.ts +9 -0
- package/generated/entities/staff_time_task/index.ts +17 -0
- package/generated/entities/staff_time_task_comment/index.ts +9 -0
- package/generated/entities/staff_time_task_status/index.ts +13 -0
- package/generated/entities/staff_time_task_tag/index.ts +6 -0
- package/generated/entities.ids.generated.ts +11 -1
- package/generated/entity-fields-registry.ts +149 -0
- package/package.json +7 -7
- package/src/helpers/integration/timesheetFixtures.ts +31 -1
- package/src/modules/auth/README.md +1 -1
- package/src/modules/auth/cli.ts +35 -1
- package/src/modules/auth/i18n/de.json +7 -0
- package/src/modules/auth/i18n/en.json +7 -0
- package/src/modules/auth/i18n/es.json +7 -0
- package/src/modules/auth/i18n/ko.json +7 -0
- package/src/modules/auth/i18n/pl.json +7 -0
- package/src/modules/auth/lib/backendChrome.tsx +1 -0
- package/src/modules/customer_accounts/lib/customerRoleAcls.ts +79 -0
- package/src/modules/customer_accounts/setup.ts +1 -45
- package/src/modules/data_sync/AGENTS.md +34 -0
- package/src/modules/data_sync/api/options.ts +2 -0
- package/src/modules/data_sync/backend/data-sync/page.tsx +82 -33
- package/src/modules/data_sync/i18n/de.json +1 -0
- package/src/modules/data_sync/i18n/en.json +1 -0
- package/src/modules/data_sync/i18n/es.json +1 -0
- package/src/modules/data_sync/i18n/ko.json +1 -0
- package/src/modules/data_sync/i18n/pl.json +1 -0
- package/src/modules/data_sync/lib/adapter.ts +45 -0
- package/src/modules/data_sync/lib/start-controls.ts +89 -0
- package/src/modules/staff/AGENTS.md +769 -1
- package/src/modules/staff/acl.ts +9 -0
- package/src/modules/staff/ai-agents.ts +189 -0
- package/src/modules/staff/ai-tools/time-tracking-pack.ts +494 -0
- package/src/modules/staff/ai-tools/types.ts +82 -0
- package/src/modules/staff/ai-tools.ts +13 -0
- package/src/modules/staff/analytics.ts +113 -0
- package/src/modules/staff/api/guards.ts +85 -3
- package/src/modules/staff/api/leave-requests/accept/route.ts +0 -2
- package/src/modules/staff/api/leave-requests/reject/route.ts +0 -2
- package/src/modules/staff/api/portal/time-reports/[id]/route.ts +182 -0
- package/src/modules/staff/api/portal/time-reports/route.ts +270 -0
- package/src/modules/staff/api/team-members/self/route.ts +0 -2
- package/src/modules/staff/api/team-members/tags/assign/route.ts +0 -2
- package/src/modules/staff/api/team-members/tags/unassign/route.ts +0 -2
- package/src/modules/staff/api/timesheets/_shared/withTimesheetInterceptors.ts +292 -0
- package/src/modules/staff/api/timesheets/access-requests/route.ts +299 -0
- package/src/modules/staff/api/timesheets/my-projects/[projectId]/route.ts +15 -7
- package/src/modules/staff/api/timesheets/my-projects/route.ts +14 -1
- package/src/modules/staff/api/timesheets/my-work/myWorkAggregate.ts +90 -0
- package/src/modules/staff/api/timesheets/my-work/route.ts +475 -0
- package/src/modules/staff/api/timesheets/projects/kpis/route.ts +15 -2
- package/src/modules/staff/api/timesheets/reports/[id]/close/route.ts +203 -0
- package/src/modules/staff/api/timesheets/reports/[id]/export/route.ts +315 -0
- package/src/modules/staff/api/timesheets/reports/[id]/sheet/route.ts +220 -0
- package/src/modules/staff/api/timesheets/reports/[id]/unlock/route.ts +208 -0
- package/src/modules/staff/api/timesheets/reports/preview/route.ts +449 -0
- package/src/modules/staff/api/timesheets/reports/route.ts +386 -0
- package/src/modules/staff/api/timesheets/reports/shared.ts +121 -0
- package/src/modules/staff/api/timesheets/settings/reapply-rounding/route.ts +205 -0
- package/src/modules/staff/api/timesheets/settings/rounding-impact/route.ts +226 -0
- package/src/modules/staff/api/timesheets/settings/route.ts +242 -0
- package/src/modules/staff/api/timesheets/tags/access.ts +134 -0
- package/src/modules/staff/api/timesheets/tags/entry-assignments/route.ts +290 -0
- package/src/modules/staff/api/timesheets/tags/route.ts +177 -0
- package/src/modules/staff/api/timesheets/tags/task-assignments/route.ts +294 -0
- package/src/modules/staff/api/timesheets/task-statuses/route.ts +232 -0
- package/src/modules/staff/api/timesheets/tasks/[id]/comments/route.ts +532 -0
- package/src/modules/staff/api/timesheets/tasks/[id]/status/route.ts +271 -0
- package/src/modules/staff/api/timesheets/tasks/route.ts +443 -0
- package/src/modules/staff/api/timesheets/time-entries/[id]/duplicate/route.ts +306 -0
- package/src/modules/staff/api/timesheets/time-entries/[id]/segments/[segmentId]/route.ts +32 -10
- package/src/modules/staff/api/timesheets/time-entries/[id]/segments/route.ts +32 -17
- package/src/modules/staff/api/timesheets/time-entries/[id]/timer-start/route.ts +27 -6
- package/src/modules/staff/api/timesheets/time-entries/[id]/timer-stop/route.ts +36 -6
- package/src/modules/staff/api/timesheets/time-entries/bulk/route.ts +331 -34
- package/src/modules/staff/api/timesheets/time-entries/copy-day/route.ts +533 -0
- package/src/modules/staff/api/timesheets/time-entries/overlaps/route.ts +457 -0
- package/src/modules/staff/api/timesheets/time-entries/route.ts +378 -24
- package/src/modules/staff/api/timesheets/time-entries/start-timer/route.ts +31 -9
- package/src/modules/staff/api/timesheets/time-projects/[id]/change-currency/route.ts +246 -0
- package/src/modules/staff/api/timesheets/time-projects/[id]/employees/route.ts +62 -2
- package/src/modules/staff/api/timesheets/time-projects/route.ts +397 -22
- package/src/modules/staff/backend/staff/time-tracking/board/page.meta.ts +14 -0
- package/src/modules/staff/backend/staff/time-tracking/board/page.tsx +151 -0
- package/src/modules/staff/backend/staff/time-tracking/entries/page.meta.ts +14 -0
- package/src/modules/staff/backend/staff/time-tracking/entries/page.tsx +1229 -0
- package/src/modules/staff/backend/staff/time-tracking/page.meta.ts +11 -0
- package/src/modules/staff/backend/staff/time-tracking/page.tsx +823 -0
- package/src/modules/staff/backend/staff/time-tracking/projects/[id]/board/page.meta.ts +12 -0
- package/src/modules/staff/backend/staff/time-tracking/projects/[id]/board/page.tsx +81 -0
- package/src/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.meta.ts +12 -0
- package/src/modules/staff/backend/staff/time-tracking/projects/[id]/edit/page.tsx +275 -0
- package/src/modules/staff/backend/staff/time-tracking/projects/[id]/page.meta.ts +12 -0
- package/src/modules/staff/backend/staff/time-tracking/projects/[id]/page.tsx +1420 -0
- package/src/modules/staff/backend/staff/time-tracking/projects/create/page.meta.ts +12 -0
- package/src/modules/staff/backend/staff/time-tracking/projects/create/page.tsx +109 -0
- package/src/modules/staff/backend/staff/time-tracking/projects/page.meta.ts +15 -0
- package/src/modules/staff/backend/staff/time-tracking/projects/page.tsx +1254 -0
- package/src/modules/staff/backend/staff/time-tracking/projects/projectFormConfig.ts +466 -0
- package/src/modules/staff/backend/staff/time-tracking/reports/[id]/page.meta.ts +12 -0
- package/src/modules/staff/backend/staff/time-tracking/reports/[id]/page.tsx +632 -0
- package/src/modules/staff/backend/staff/time-tracking/reports/create/page.meta.ts +12 -0
- package/src/modules/staff/backend/staff/time-tracking/reports/create/page.tsx +867 -0
- package/src/modules/staff/backend/staff/time-tracking/reports/page.meta.ts +14 -0
- package/src/modules/staff/backend/staff/time-tracking/reports/page.tsx +246 -0
- package/src/modules/staff/backend/staff/time-tracking/settings/page.meta.ts +14 -0
- package/src/modules/staff/backend/staff/time-tracking/settings/page.tsx +701 -0
- package/src/modules/staff/backend/staff/time-tracking/timesheet/GridView.tsx +953 -0
- package/src/modules/staff/backend/staff/time-tracking/timesheet/page.meta.ts +14 -0
- package/src/modules/staff/backend/staff/time-tracking/timesheet/page.tsx +800 -0
- package/src/modules/staff/backend/staff/timesheets/page.meta.ts +6 -6
- package/src/modules/staff/backend/staff/timesheets/page.tsx +12 -1010
- package/src/modules/staff/backend/staff/timesheets/projects/[id]/edit/page.meta.ts +0 -6
- package/src/modules/staff/backend/staff/timesheets/projects/[id]/edit/page.tsx +4 -158
- package/src/modules/staff/backend/staff/timesheets/projects/[id]/page.meta.ts +0 -6
- package/src/modules/staff/backend/staff/timesheets/projects/[id]/page.tsx +4 -609
- package/src/modules/staff/backend/staff/timesheets/projects/create/page.meta.ts +0 -6
- package/src/modules/staff/backend/staff/timesheets/projects/create/page.tsx +3 -89
- package/src/modules/staff/backend/staff/timesheets/projects/page.meta.ts +2 -19
- package/src/modules/staff/backend/staff/timesheets/projects/page.tsx +3 -778
- package/src/modules/staff/ce.ts +63 -0
- package/src/modules/staff/cli.ts +207 -1
- package/src/modules/staff/commands/index.ts +5 -0
- package/src/modules/staff/commands/timesheets-entries.ts +1006 -28
- package/src/modules/staff/commands/timesheets-projects.ts +593 -7
- package/src/modules/staff/commands/timesheets-reports.ts +1329 -0
- package/src/modules/staff/commands/timesheets-tags.ts +1315 -0
- package/src/modules/staff/commands/timesheets-task-comments.ts +583 -0
- package/src/modules/staff/commands/timesheets-task-statuses.ts +889 -0
- package/src/modules/staff/commands/timesheets-tasks.ts +1121 -0
- package/src/modules/staff/data/enrichers.ts +675 -10
- package/src/modules/staff/data/entities.ts +494 -2
- package/src/modules/staff/data/extensions.ts +68 -0
- package/src/modules/staff/data/validators.ts +348 -3
- package/src/modules/staff/di.ts +96 -0
- package/src/modules/staff/events.payloads.ts +268 -0
- package/src/modules/staff/events.ts +87 -5
- package/src/modules/staff/extension-points.ts +308 -0
- package/src/modules/staff/frontend/[orgSlug]/portal/time-reports/[id]/page.meta.ts +10 -0
- package/src/modules/staff/frontend/[orgSlug]/portal/time-reports/[id]/page.tsx +225 -0
- package/src/modules/staff/frontend/[orgSlug]/portal/time-reports/page.meta.ts +17 -0
- package/src/modules/staff/frontend/[orgSlug]/portal/time-reports/page.tsx +182 -0
- package/src/modules/staff/i18n/de.json +899 -0
- package/src/modules/staff/i18n/en.json +899 -0
- package/src/modules/staff/i18n/es.json +899 -0
- package/src/modules/staff/i18n/ko.json +899 -0
- package/src/modules/staff/i18n/pl.json +899 -0
- package/src/modules/staff/lib/crud.ts +14 -3
- package/src/modules/staff/lib/time-tracking/access.ts +155 -0
- package/src/modules/staff/lib/time-tracking/billability.ts +68 -0
- package/src/modules/staff/lib/time-tracking/capacity.ts +124 -0
- package/src/modules/staff/lib/time-tracking/componentContracts.ts +26 -0
- package/src/modules/staff/lib/time-tracking/cost.ts +144 -0
- package/src/modules/staff/lib/time-tracking/duration.ts +68 -0
- package/src/modules/staff/lib/time-tracking/featureAccess.ts +115 -0
- package/src/modules/staff/lib/time-tracking/interval.ts +90 -0
- package/src/modules/staff/lib/time-tracking/migrateProjectCodes.ts +161 -0
- package/src/modules/staff/lib/time-tracking/moneyVisibility.ts +37 -0
- package/src/modules/staff/lib/time-tracking/overlap.ts +159 -0
- package/src/modules/staff/lib/time-tracking/portalRecipients.ts +76 -0
- package/src/modules/staff/lib/time-tracking/portalReports.ts +31 -0
- package/src/modules/staff/lib/time-tracking/projectCode.ts +221 -0
- package/src/modules/staff/lib/time-tracking/reapplyRounding.ts +313 -0
- package/src/modules/staff/lib/time-tracking/recalculationRunner.ts +118 -0
- package/src/modules/staff/lib/time-tracking/recalculations.ts +135 -0
- package/src/modules/staff/lib/time-tracking/registries/invoke.ts +122 -0
- package/src/modules/staff/lib/time-tracking/registries/registry.ts +124 -0
- package/src/modules/staff/lib/time-tracking/registries/scope.ts +56 -0
- package/src/modules/staff/lib/time-tracking/rollup.ts +59 -0
- package/src/modules/staff/lib/time-tracking/rounding.ts +126 -0
- package/src/modules/staff/lib/time-tracking/roundingImpact.ts +75 -0
- package/src/modules/staff/lib/time-tracking/settingKeys.ts +274 -0
- package/src/modules/staff/lib/time-tracking/settings.ts +168 -0
- package/src/modules/staff/lib/time-tracking/sqlInClause.ts +22 -0
- package/src/modules/staff/lib/time-tracking/timeEntrySources.ts +85 -0
- package/src/modules/staff/lib/time-tracking-ui/BoardFilterChips.tsx +360 -0
- package/src/modules/staff/lib/time-tracking-ui/CustomerPicker.tsx +427 -0
- package/src/modules/staff/lib/time-tracking-ui/DurationInput.tsx +192 -0
- package/src/modules/staff/lib/time-tracking-ui/EntryDetailsFields.tsx +224 -0
- package/src/modules/staff/lib/time-tracking-ui/EntryFilterBar.tsx +144 -0
- package/src/modules/staff/lib/time-tracking-ui/KanbanBoard.tsx +1065 -0
- package/src/modules/staff/lib/time-tracking-ui/KanbanCard.tsx +435 -0
- package/src/modules/staff/lib/time-tracking-ui/KanbanColumn.tsx +316 -0
- package/src/modules/staff/lib/time-tracking-ui/NewTaskDialog.tsx +291 -0
- package/src/modules/staff/lib/time-tracking-ui/NoProjectAccess.tsx +162 -0
- package/src/modules/staff/lib/time-tracking-ui/PeriodSelector.tsx +149 -0
- package/src/modules/staff/lib/time-tracking-ui/PhasePlaceholder.tsx +31 -0
- package/src/modules/staff/lib/time-tracking-ui/ProjectCodeField.tsx +230 -0
- package/src/modules/staff/lib/time-tracking-ui/ProjectCurrencyField.tsx +432 -0
- package/src/modules/staff/lib/time-tracking-ui/ProjectFormSections.tsx +323 -0
- package/src/modules/staff/lib/time-tracking-ui/ProjectTeamDrawer.tsx +930 -0
- package/src/modules/staff/lib/time-tracking-ui/ReportSheet.tsx +357 -0
- package/src/modules/staff/lib/time-tracking-ui/TagPicker.tsx +195 -0
- package/src/modules/staff/lib/time-tracking-ui/TaskBoardScreen.tsx +243 -0
- package/src/modules/staff/lib/time-tracking-ui/TaskCommentThread.tsx +137 -0
- package/src/modules/staff/lib/time-tracking-ui/TaskDrawer.tsx +1405 -0
- package/src/modules/staff/lib/time-tracking-ui/TaskListView.tsx +260 -0
- package/src/modules/staff/lib/time-tracking-ui/TaskPicker.tsx +435 -0
- package/src/modules/staff/lib/time-tracking-ui/TaskQuickLog.tsx +296 -0
- package/src/modules/staff/lib/time-tracking-ui/TimeEntriesSummaryFooter.tsx +97 -0
- package/src/modules/staff/lib/time-tracking-ui/TimeEntryDialog.tsx +1929 -0
- package/src/modules/staff/lib/time-tracking-ui/TimeEntryDurationCell.tsx +126 -0
- package/src/modules/staff/lib/time-tracking-ui/TimesheetCalendar.tsx +265 -0
- package/src/modules/staff/lib/time-tracking-ui/TimesheetPeriodFooter.tsx +78 -0
- package/src/modules/staff/lib/time-tracking-ui/TimesheetViewSwitch.tsx +70 -0
- package/src/modules/staff/lib/time-tracking-ui/boardDirectory.ts +231 -0
- package/src/modules/staff/lib/time-tracking-ui/boardFilters.ts +128 -0
- package/src/modules/staff/lib/time-tracking-ui/boardSummary.ts +20 -0
- package/src/modules/staff/lib/time-tracking-ui/kanbanBoardData.ts +228 -0
- package/src/modules/staff/lib/time-tracking-ui/projectTeamAssignment.ts +143 -0
- package/src/modules/staff/lib/time-tracking-ui/taskDrawerData.ts +194 -0
- package/src/modules/staff/lib/time-tracking-ui/timeEntryDialogState.ts +400 -0
- package/src/modules/staff/lib/time-tracking-ui/timeEntryListData.ts +271 -0
- package/src/modules/staff/lib/time-tracking-ui/timeTrackingSettingsForm.ts +168 -0
- package/src/modules/staff/lib/time-tracking-ui/timesheetData.ts +225 -0
- package/src/modules/staff/lib/time-tracking-ui/timesheetPeriod.ts +278 -0
- package/src/modules/staff/lib/time-tracking-ui/timesheetTargets.ts +93 -0
- package/src/modules/staff/lib/time-tracking-ui/useBoardFilters.ts +107 -0
- package/src/modules/staff/lib/time-tracking-ui/useTimesheetPreferences.ts +245 -0
- package/src/modules/staff/lib/timeTrackingSeeds.ts +1037 -0
- package/src/modules/staff/lib/timesheets/timeEntryCacheInvalidation.ts +11 -10
- package/src/modules/staff/lib/timesheets/timeEntryDecoration.ts +256 -0
- package/src/modules/staff/lib/timesheets-projects/budgetBurn.ts +60 -0
- package/src/modules/staff/lib/timesheets-projects/budgetThreshold.ts +135 -0
- package/src/modules/staff/lib/timesheets-projects/budgetThresholdState.ts +137 -0
- package/src/modules/staff/lib/timesheets-projects/computeProjectFinancials.ts +142 -0
- package/src/modules/staff/lib/timesheets-projects/reportSelection.ts +82 -0
- package/src/modules/staff/lib/timesheets-projects-ui/AssignMembersDialog.tsx +237 -0
- package/src/modules/staff/lib/timesheets-projects-ui/ProjectBudgetCell.tsx +33 -0
- package/src/modules/staff/lib/timesheets-projects-ui/ProjectCard.tsx +56 -1
- package/src/modules/staff/lib/timesheets-projects-ui/ProjectsBulkActionsBar.tsx +90 -0
- package/src/modules/staff/lib/timesheets-reports/buildReportSheet.ts +125 -0
- package/src/modules/staff/lib/timesheets-reports/loadReportData.ts +251 -0
- package/src/modules/staff/lib/timesheets-reports/pdf.ts +263 -0
- package/src/modules/staff/lib/timesheets-reports/reportApprovalPolicies.ts +137 -0
- package/src/modules/staff/lib/timesheets-reports/reportConfigData.ts +247 -0
- package/src/modules/staff/lib/timesheets-reports/reportConfigState.ts +89 -0
- package/src/modules/staff/lib/timesheets-reports/reportExport.ts +373 -0
- package/src/modules/staff/lib/timesheets-reports/reportExportFormats.ts +71 -0
- package/src/modules/staff/lib/timesheets-reports/reportGroupings.ts +153 -0
- package/src/modules/staff/lib/timesheets-reports/reportLabels.ts +23 -0
- package/src/modules/staff/lib/timesheets-reports/reportReference.ts +146 -0
- package/src/modules/staff/lib/timesheets-reports/reportRows.ts +89 -0
- package/src/modules/staff/lib/timesheets-reports/reportSheetData.ts +196 -0
- package/src/modules/staff/lib/timesheets-reports/reportTotals.ts +544 -0
- package/src/modules/staff/lib/timesheets-reports/xlsx.ts +207 -0
- package/src/modules/staff/lib/timesheets-tasks/computeTaskRollups.ts +238 -0
- package/src/modules/staff/lib/timesheets-tasks/defaultStatusTemplate.ts +90 -0
- package/src/modules/staff/lib/timesheets-tasks/seedProjectStatuses.ts +43 -0
- package/src/modules/staff/lib/timesheets-tasks/statusPositions.ts +132 -0
- package/src/modules/staff/lib/timesheets-tasks/statusSlug.ts +43 -0
- package/src/modules/staff/lib/timesheets-tasks/taskHierarchy.ts +99 -0
- package/src/modules/staff/lib/timesheets-tasks/taskHoursTotals.ts +42 -0
- package/src/modules/staff/lib/timesheets-tasks/taskReference.ts +108 -0
- package/src/modules/staff/lib/timesheets-ui/CreateProjectDialog.tsx +18 -4
- package/src/modules/staff/lib/timesheets-ui/ListView.tsx +504 -149
- package/src/modules/staff/lib/timesheets-ui/TimerBar.tsx +50 -2
- package/src/modules/staff/migrations/.snapshot-open-mercato.json +3244 -228
- package/src/modules/staff/migrations/Migration20260813073131_staff.ts +94 -0
- package/src/modules/staff/migrations/Migration20260824143357_staff.ts +17 -0
- package/src/modules/staff/notifications.client.ts +16 -0
- package/src/modules/staff/notifications.handlers.ts +88 -0
- package/src/modules/staff/notifications.ts +163 -0
- package/src/modules/staff/search.ts +191 -1
- package/src/modules/staff/setup.ts +22 -0
- package/src/modules/staff/subscribers/time-project-access-requested-notification.ts +95 -0
- package/src/modules/staff/subscribers/time-project-budget-threshold-notification.ts +226 -0
- package/src/modules/staff/subscribers/time-report-approved-notification.ts +116 -0
- package/src/modules/staff/subscribers/time-report-portal-broadcast.ts +120 -0
- package/src/modules/staff/translations.ts +17 -0
- package/src/modules/staff/widgets/components.ts +34 -0
- package/src/modules/staff/widgets/injection/timer-sidebar-indicator/widget.tsx +6 -5
- package/src/modules/staff/widgets/notifications/TimeProjectAccessRequestedRenderer.tsx +138 -0
- package/src/modules/staff/widgets/notifications/index.ts +1 -0
- package/src/modules/staff/workers/timesheets-reapply-rounding.ts +59 -0
- package/dist/modules/staff/backend/staff/timesheets/projects/projectFormConfig.js +0 -123
- package/dist/modules/staff/backend/staff/timesheets/projects/projectFormConfig.js.map +0 -7
- package/src/modules/staff/backend/staff/timesheets/projects/projectFormConfig.ts +0 -138
|
@@ -0,0 +1,1929 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { z } from 'zod'
|
|
5
|
+
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
6
|
+
import { Lock, X } from 'lucide-react'
|
|
7
|
+
import {
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogContent,
|
|
10
|
+
DialogFooter,
|
|
11
|
+
DialogHeader,
|
|
12
|
+
DialogTitle,
|
|
13
|
+
} from '@open-mercato/ui/primitives/dialog'
|
|
14
|
+
import { Alert, AlertDescription, AlertTitle } from '@open-mercato/ui/primitives/alert'
|
|
15
|
+
import { Badge } from '@open-mercato/ui/primitives/badge'
|
|
16
|
+
import { Button } from '@open-mercato/ui/primitives/button'
|
|
17
|
+
import { Input } from '@open-mercato/ui/primitives/input'
|
|
18
|
+
import { Kbd } from '@open-mercato/ui/primitives/kbd'
|
|
19
|
+
import { Label } from '@open-mercato/ui/primitives/label'
|
|
20
|
+
import {
|
|
21
|
+
Select,
|
|
22
|
+
SelectContent,
|
|
23
|
+
SelectItem,
|
|
24
|
+
SelectTrigger,
|
|
25
|
+
SelectValue,
|
|
26
|
+
} from '@open-mercato/ui/primitives/select'
|
|
27
|
+
import { Separator } from '@open-mercato/ui/primitives/separator'
|
|
28
|
+
import { Spinner } from '@open-mercato/ui/primitives/spinner'
|
|
29
|
+
import { Switch } from '@open-mercato/ui/primitives/switch'
|
|
30
|
+
import { ErrorMessage, LoadingMessage } from '@open-mercato/ui/backend/detail'
|
|
31
|
+
import { apiCall, apiCallOrThrow, withScopedApiRequestHeaders } from '@open-mercato/ui/backend/utils/apiCall'
|
|
32
|
+
import { TagPicker, tagChipStyle } from './TagPicker'
|
|
33
|
+
import { TaskPicker, type TaskPickerItem, type TaskPickerStatus } from './TaskPicker'
|
|
34
|
+
import { slugifyProjectName } from '../time-tracking/projectCode'
|
|
35
|
+
import { autoColorFromName } from '../timesheets-ui/colors'
|
|
36
|
+
import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
|
|
37
|
+
import { surfaceRecordConflict } from '@open-mercato/ui/backend/conflicts'
|
|
38
|
+
import { normalizeCrudServerError } from '@open-mercato/ui/backend/utils/serverErrors'
|
|
39
|
+
import { useBackendChrome } from '@open-mercato/ui/backend/BackendChromeProvider'
|
|
40
|
+
import { useConfirmDialog } from '@open-mercato/ui/backend/confirm-dialog'
|
|
41
|
+
import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
|
|
42
|
+
import { InjectionSpot, useInjectionSpotEvents } from '@open-mercato/ui/backend/injection/InjectionSpot'
|
|
43
|
+
import { extensionPoints } from '@open-mercato/core/modules/staff/extension-points'
|
|
44
|
+
import { registerComponent } from '@open-mercato/shared/modules/widgets/component-registry'
|
|
45
|
+
import { useRegisteredComponent } from '@open-mercato/ui/backend/injection/useRegisteredComponent'
|
|
46
|
+
import {
|
|
47
|
+
callbackProp,
|
|
48
|
+
opaqueProp,
|
|
49
|
+
optionalCallbackProp,
|
|
50
|
+
} from '../time-tracking/componentContracts'
|
|
51
|
+
import { extensionSpotChildId } from '@open-mercato/shared/modules/widgets/extension-points'
|
|
52
|
+
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
53
|
+
import { formatCurrency } from '@open-mercato/ui/utils/format'
|
|
54
|
+
import { hasFeature } from '@open-mercato/shared/security/features'
|
|
55
|
+
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
56
|
+
import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
|
|
57
|
+
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
58
|
+
import { DurationInput } from './DurationInput'
|
|
59
|
+
import { formatDuration } from '../time-tracking/duration'
|
|
60
|
+
import { parseClock } from '../time-tracking/interval'
|
|
61
|
+
import { applicableRate, entryAmount } from '../time-tracking/cost'
|
|
62
|
+
import { roundMinutes, DEFAULT_ROUNDING_SETTINGS, type RoundingSettings } from '../time-tracking/rounding'
|
|
63
|
+
import {
|
|
64
|
+
combineDateAndClock,
|
|
65
|
+
createIntervalState,
|
|
66
|
+
reduceIntervalState,
|
|
67
|
+
resetIntervalState,
|
|
68
|
+
readRowItems,
|
|
69
|
+
readRowString,
|
|
70
|
+
shiftIsoDate,
|
|
71
|
+
todayIsoDate,
|
|
72
|
+
toOverlapEntry,
|
|
73
|
+
toProjectOption,
|
|
74
|
+
toTaskOption,
|
|
75
|
+
toTimeEntryRecord,
|
|
76
|
+
type OverlapEntry,
|
|
77
|
+
type ProjectOption,
|
|
78
|
+
type TagOption,
|
|
79
|
+
type TaskOption,
|
|
80
|
+
type TimeEntryRecord,
|
|
81
|
+
type TimeIntervalState,
|
|
82
|
+
} from './timeEntryDialogState'
|
|
83
|
+
|
|
84
|
+
const logger = createLogger('staff').child({ component: 'TimeEntryDialog' })
|
|
85
|
+
|
|
86
|
+
export const TIME_ENTRY_DIALOG_MUTATION_CONTEXT_ID = 'staff.time_tracking.entryDialog'
|
|
87
|
+
const TIME_ENTRY_ENTITY_ID = 'staff:staff_time_entry'
|
|
88
|
+
|
|
89
|
+
type TimeEntryFormInjectionContext = {
|
|
90
|
+
formId: string
|
|
91
|
+
entityId: string
|
|
92
|
+
recordId: string | null
|
|
93
|
+
resourceKind: string
|
|
94
|
+
resourceId: string
|
|
95
|
+
operation: 'create' | 'update'
|
|
96
|
+
locked: boolean
|
|
97
|
+
retryLastMutation: () => Promise<boolean>
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function fieldValueSnapshot(value: unknown): string {
|
|
101
|
+
return JSON.stringify(value ?? null)
|
|
102
|
+
}
|
|
103
|
+
export const RATES_FEATURE = 'staff.timesheets.rates.view'
|
|
104
|
+
export const ENTRIES_LIST_PATH = '/backend/staff/time-tracking/entries'
|
|
105
|
+
export const REPORTS_PATH = '/backend/staff/time-tracking/reports'
|
|
106
|
+
|
|
107
|
+
const DIALOG_QUERY_ROOT = ['staff', 'time-tracking', 'entry-dialog'] as const
|
|
108
|
+
const DIRECTORY_PAGE_SIZE = 100
|
|
109
|
+
const TASK_SEARCH_PAGE_SIZE = 50
|
|
110
|
+
const TASK_SEARCH_DEBOUNCE_MS = 250
|
|
111
|
+
|
|
112
|
+
export type TimeEntryDialogDefaults = {
|
|
113
|
+
date?: string
|
|
114
|
+
taskId?: string | null
|
|
115
|
+
timeProjectId?: string | null
|
|
116
|
+
durationMinutes?: number | null
|
|
117
|
+
startClock?: string | null
|
|
118
|
+
endClock?: string | null
|
|
119
|
+
description?: string | null
|
|
120
|
+
isBillable?: boolean | null
|
|
121
|
+
tagIds?: string[]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export type TimeEntryDialogProps = {
|
|
125
|
+
open: boolean
|
|
126
|
+
onOpenChange: (open: boolean) => void
|
|
127
|
+
/** Present → edit that entry; absent → a brand new one. */
|
|
128
|
+
entryId?: string | null
|
|
129
|
+
/** Seed values for a new entry: a stopped timer, a grid cell, a board card. */
|
|
130
|
+
defaults?: TimeEntryDialogDefaults
|
|
131
|
+
/** Sub-title line, e.g. "Created from a stopped timer · 01:14:02". */
|
|
132
|
+
headerHint?: string | null
|
|
133
|
+
/** Fired after every successful write, including "save and add another". */
|
|
134
|
+
onSaved?: (result: { id: string | null; keptOpen: boolean }) => void
|
|
135
|
+
/** "Show that entry" on the overlap warning; falls back to the entries list. */
|
|
136
|
+
onShowEntry?: (entryId: string) => void
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
type SettingsPayload = {
|
|
140
|
+
rounding: RoundingSettings
|
|
141
|
+
defaults: { billable: boolean; chainStartFromPreviousEnd: boolean }
|
|
142
|
+
warnings: { overlap: boolean }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const FALLBACK_SETTINGS: SettingsPayload = {
|
|
146
|
+
rounding: { ...DEFAULT_ROUNDING_SETTINGS },
|
|
147
|
+
defaults: { billable: true, chainStartFromPreviousEnd: true },
|
|
148
|
+
warnings: { overlap: true },
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function readSettings(payload: unknown): SettingsPayload {
|
|
152
|
+
const source = (payload ?? {}) as Record<string, unknown>
|
|
153
|
+
const rounding = (source.rounding ?? {}) as Record<string, unknown>
|
|
154
|
+
const defaults = (source.defaults ?? {}) as Record<string, unknown>
|
|
155
|
+
const warnings = (source.warnings ?? {}) as Record<string, unknown>
|
|
156
|
+
return {
|
|
157
|
+
rounding: {
|
|
158
|
+
unitMinutes:
|
|
159
|
+
rounding.unitMinutes === 5 || rounding.unitMinutes === 10 || rounding.unitMinutes === 15
|
|
160
|
+
? rounding.unitMinutes
|
|
161
|
+
: 0,
|
|
162
|
+
direction: rounding.direction === 'nearest' ? 'nearest' : 'up',
|
|
163
|
+
},
|
|
164
|
+
defaults: {
|
|
165
|
+
billable: typeof defaults.billable === 'boolean' ? defaults.billable : true,
|
|
166
|
+
chainStartFromPreviousEnd:
|
|
167
|
+
typeof defaults.chainStartFromPreviousEnd === 'boolean' ? defaults.chainStartFromPreviousEnd : true,
|
|
168
|
+
},
|
|
169
|
+
warnings: { overlap: typeof warnings.overlap === 'boolean' ? warnings.overlap : true },
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function parseRateText(value: string): number | null {
|
|
174
|
+
const trimmed = value.trim().replace(',', '.')
|
|
175
|
+
if (!trimmed) return null
|
|
176
|
+
const parsed = Number(trimmed)
|
|
177
|
+
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Everything a person can type into the form, in the one shape both the seed and
|
|
182
|
+
* the live state can be expressed in. Comparing two of these is how the dialog
|
|
183
|
+
* knows whether closing it would throw work away.
|
|
184
|
+
*/
|
|
185
|
+
type FormValues = {
|
|
186
|
+
taskId: string | null
|
|
187
|
+
description: string
|
|
188
|
+
date: string
|
|
189
|
+
startText: string
|
|
190
|
+
endText: string
|
|
191
|
+
durationMinutes: number | null
|
|
192
|
+
isBillable: boolean
|
|
193
|
+
rateOverrideEnabled: boolean
|
|
194
|
+
rateOverrideAmount: number | null
|
|
195
|
+
tagIds: string[]
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
type SaveMode = 'close' | 'again'
|
|
199
|
+
|
|
200
|
+
/** The two controls a failed save can point at, empty when nothing is wrong. */
|
|
201
|
+
type FieldIssues = {
|
|
202
|
+
task: string | null
|
|
203
|
+
duration: string | null
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const NO_FIELD_ISSUES: FieldIssues = { task: null, duration: null }
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* A server complaint that names a field belongs under that field, not in a
|
|
210
|
+
* toast. Only the two the form can point at are claimed; anything else stays
|
|
211
|
+
* global and is flashed.
|
|
212
|
+
*/
|
|
213
|
+
function readFieldIssues(error: unknown): FieldIssues | null {
|
|
214
|
+
const { fieldErrors } = normalizeCrudServerError(error)
|
|
215
|
+
if (!fieldErrors) return null
|
|
216
|
+
const task = fieldErrors.taskId ?? fieldErrors.task ?? null
|
|
217
|
+
const duration = fieldErrors.durationMinutes ?? fieldErrors.duration ?? null
|
|
218
|
+
if (!task && !duration) return null
|
|
219
|
+
return { task, duration }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function formSnapshot(values: FormValues): string {
|
|
223
|
+
return JSON.stringify([
|
|
224
|
+
values.taskId,
|
|
225
|
+
values.description.trim(),
|
|
226
|
+
values.date,
|
|
227
|
+
values.startText,
|
|
228
|
+
values.endText,
|
|
229
|
+
values.durationMinutes,
|
|
230
|
+
values.isBillable,
|
|
231
|
+
values.rateOverrideEnabled,
|
|
232
|
+
values.rateOverrideAmount,
|
|
233
|
+
[...values.tagIds].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
234
|
+
])
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Two requests, merged.
|
|
239
|
+
*
|
|
240
|
+
* A single term cannot be routed to one field by shape — "Booking" is a title
|
|
241
|
+
* word and "AWR" is a code, and both are one alphanumeric token — and the query
|
|
242
|
+
* engine has no OR across fields, so the picker asks both questions and unions
|
|
243
|
+
* the answers. This is what makes task 400 of 900 findable: the directory the
|
|
244
|
+
* picker opens on is only ever its first page.
|
|
245
|
+
*/
|
|
246
|
+
async function fetchTaskOptions(term: string): Promise<TaskOption[]> {
|
|
247
|
+
const params = new URLSearchParams({
|
|
248
|
+
page: '1',
|
|
249
|
+
pageSize: String(TASK_SEARCH_PAGE_SIZE),
|
|
250
|
+
sortField: 'title',
|
|
251
|
+
sortDir: 'asc',
|
|
252
|
+
})
|
|
253
|
+
const responses = await Promise.all(
|
|
254
|
+
[
|
|
255
|
+
`/api/staff/timesheets/tasks?${params.toString()}&q=${encodeURIComponent(term)}`,
|
|
256
|
+
`/api/staff/timesheets/tasks?${params.toString()}&reference=${encodeURIComponent(term)}`,
|
|
257
|
+
].map((url) => apiCall<Record<string, unknown>>(url).catch(() => null)),
|
|
258
|
+
)
|
|
259
|
+
const merged = new Map<string, TaskOption>()
|
|
260
|
+
for (const response of responses) {
|
|
261
|
+
if (!response?.ok) continue
|
|
262
|
+
for (const row of readRowItems(response.result)) {
|
|
263
|
+
const option = toTaskOption(row)
|
|
264
|
+
if (option && !merged.has(option.id)) merged.set(option.id, option)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return [...merged.values()]
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Resolves the entry's own task when it sorts outside the directory's first page. */
|
|
271
|
+
async function fetchTaskById(id: string): Promise<TaskOption | null> {
|
|
272
|
+
const call = await apiCall<Record<string, unknown>>(
|
|
273
|
+
`/api/staff/timesheets/tasks?ids=${encodeURIComponent(id)}&pageSize=1`,
|
|
274
|
+
)
|
|
275
|
+
if (!call.ok) return null
|
|
276
|
+
const row = readRowItems(call.result)[0]
|
|
277
|
+
return row ? toTaskOption(row) : null
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function readErrorCode(error: unknown): string | null {
|
|
281
|
+
const body = (error as { body?: { code?: unknown; error?: unknown } } | null)?.body
|
|
282
|
+
if (body && typeof body.code === 'string') return body.code
|
|
283
|
+
if (body && typeof body.error === 'string') return body.error
|
|
284
|
+
return null
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The full time entry form of screen 8, plus the two edge states of screen 9.
|
|
289
|
+
* Every surface of the module funnels through it — the entries list, the
|
|
290
|
+
* timesheet, the board and the task drawer — so it takes an entry id (edit) or a
|
|
291
|
+
* set of defaults (create) and owns nothing else about its host.
|
|
292
|
+
*
|
|
293
|
+
* Four decisions carry the design intent:
|
|
294
|
+
*
|
|
295
|
+
* 1. **The 2-of-3 arithmetic lives in one reducer, not in the render path.**
|
|
296
|
+
* `reduceIntervalState` calls `deriveInterval` with exactly the two anchor
|
|
297
|
+
* fields and stores which one came back computed, so the `wyliczone` badge is
|
|
298
|
+
* read off the state rather than recomputed. `start` is the preferred anchor,
|
|
299
|
+
* which is what makes editing the end restate the duration (note 1).
|
|
300
|
+
* 2. **The duration field is never rewritten while it is being typed.**
|
|
301
|
+
* `DurationInput` keeps unparseable text on its own; the dialog only remounts
|
|
302
|
+
* it — via `durationEpoch` — when the *derived* duration changed, i.e. when
|
|
303
|
+
* the user was editing some other field.
|
|
304
|
+
* 3. **Overlap is advisory.** The probe is a plain `apiCall`; a non-200, a
|
|
305
|
+
* throw or an empty list all mean "no warning", and Save never observes it.
|
|
306
|
+
* Only an unparseable duration disables Save (screen 9 notes 2 and 4).
|
|
307
|
+
* 4. **A locked entry opens read-only.** Every write path answers
|
|
308
|
+
* `409 entry_locked`, so the form shows the lock and a way to the report that
|
|
309
|
+
* closed it instead of letting someone compose a request that cannot succeed.
|
|
310
|
+
*/
|
|
311
|
+
function DefaultTimeEntryDialog({
|
|
312
|
+
open,
|
|
313
|
+
onOpenChange,
|
|
314
|
+
entryId,
|
|
315
|
+
defaults,
|
|
316
|
+
headerHint,
|
|
317
|
+
onSaved,
|
|
318
|
+
onShowEntry,
|
|
319
|
+
}: TimeEntryDialogProps): React.ReactElement {
|
|
320
|
+
const t = useT()
|
|
321
|
+
const scopeVersion = useOrganizationScopeVersion()
|
|
322
|
+
const { payload } = useBackendChrome()
|
|
323
|
+
const { confirm, ConfirmDialogElement } = useConfirmDialog()
|
|
324
|
+
const canSeeMoney = hasFeature(payload?.grantedFeatures, RATES_FEATURE)
|
|
325
|
+
const isEdit = typeof entryId === 'string' && entryId.length > 0
|
|
326
|
+
|
|
327
|
+
const [taskId, setTaskId] = React.useState<string | null>(null)
|
|
328
|
+
const [description, setDescription] = React.useState('')
|
|
329
|
+
const [date, setDate] = React.useState(() => todayIsoDate())
|
|
330
|
+
const [interval, setIntervalState] = React.useState<TimeIntervalState>(() => createIntervalState({}))
|
|
331
|
+
const [isBillable, setIsBillable] = React.useState(true)
|
|
332
|
+
const [rateOverrideEnabled, setRateOverrideEnabled] = React.useState(false)
|
|
333
|
+
const [rateText, setRateText] = React.useState('')
|
|
334
|
+
const [tagIds, setTagIds] = React.useState<string[]>([])
|
|
335
|
+
const [overlaps, setOverlaps] = React.useState<OverlapEntry[]>([])
|
|
336
|
+
/** Which button is in flight, so the spinner lands on the one that was pressed. */
|
|
337
|
+
const [savingMode, setSavingMode] = React.useState<SaveMode | null>(null)
|
|
338
|
+
/**
|
|
339
|
+
* What is wrong with a single control, said under that control. A disabled
|
|
340
|
+
* Save cannot explain which of the two required fields it is waiting for, and
|
|
341
|
+
* a server complaint about one field is not a global failure either.
|
|
342
|
+
*/
|
|
343
|
+
const [fieldIssues, setFieldIssues] = React.useState<FieldIssues>(NO_FIELD_ISSUES)
|
|
344
|
+
const saving = savingMode !== null
|
|
345
|
+
const [taskSearch, setTaskSearch] = React.useState('')
|
|
346
|
+
const [debouncedTaskSearch, setDebouncedTaskSearch] = React.useState('')
|
|
347
|
+
/**
|
|
348
|
+
* The task the form is on, kept out of the two lists that come and go.
|
|
349
|
+
* The directory holds the first page only and the search results are replaced
|
|
350
|
+
* on every term, so without this the chosen task would vanish from under the
|
|
351
|
+
* selection the moment the search box was cleared.
|
|
352
|
+
*/
|
|
353
|
+
const [pinnedTask, setPinnedTask] = React.useState<TaskOption | null>(null)
|
|
354
|
+
/** What the form was seeded with; `null` until it has been seeded at all. */
|
|
355
|
+
const [baseline, setBaseline] = React.useState<FormValues | null>(null)
|
|
356
|
+
/**
|
|
357
|
+
* T7.4 — the entry loop must be completable without a mouse, so the first field
|
|
358
|
+
* of the loop is also the focus target twice over: when the dialog opens (Radix
|
|
359
|
+
* would otherwise land on the × close button, which renders before the body) and
|
|
360
|
+
* after "save and add another", where focus would otherwise be stranded on the
|
|
361
|
+
* button and the next entry would start with a handful of Shift+Tabs.
|
|
362
|
+
*/
|
|
363
|
+
const taskTriggerRef = React.useRef<HTMLDivElement | null>(null)
|
|
364
|
+
/**
|
|
365
|
+
* The picker is a search box inside a container, not a single focusable
|
|
366
|
+
* control, so the keyboard loop focuses the input within it. Without this,
|
|
367
|
+
* "save and add another" left focus stranded and the loop stopped being a loop.
|
|
368
|
+
*/
|
|
369
|
+
const focusTaskPicker = React.useCallback(() => {
|
|
370
|
+
const host = taskTriggerRef.current
|
|
371
|
+
if (!host) return false
|
|
372
|
+
const focusable = host.querySelector('input, select, button') as HTMLElement | null
|
|
373
|
+
if (!focusable) return false
|
|
374
|
+
focusable.focus()
|
|
375
|
+
return true
|
|
376
|
+
}, [])
|
|
377
|
+
|
|
378
|
+
const seedKeyRef = React.useRef<string | null>(null)
|
|
379
|
+
const billableDefaultRef = React.useRef<string | null>(null)
|
|
380
|
+
const versionRef = React.useRef<string | null>(null)
|
|
381
|
+
|
|
382
|
+
const { runMutation, retryLastMutation } = useGuardedMutation<{
|
|
383
|
+
formId: string
|
|
384
|
+
resourceKind: string
|
|
385
|
+
resourceId: string
|
|
386
|
+
retryLastMutation: () => Promise<boolean>
|
|
387
|
+
}>({
|
|
388
|
+
contextId: TIME_ENTRY_DIALOG_MUTATION_CONTEXT_ID,
|
|
389
|
+
blockedMessage: t('staff.time_tracking.entryDialog.errors.save', 'Could not save the time entry.'),
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
const settingsQuery = useQuery<SettingsPayload>({
|
|
393
|
+
queryKey: [...DIALOG_QUERY_ROOT, 'settings', `scope:${scopeVersion}`],
|
|
394
|
+
enabled: open,
|
|
395
|
+
staleTime: 300_000,
|
|
396
|
+
queryFn: async () => {
|
|
397
|
+
const call = await apiCall<Record<string, unknown>>('/api/staff/timesheets/settings')
|
|
398
|
+
return call.ok ? readSettings(call.result) : FALLBACK_SETTINGS
|
|
399
|
+
},
|
|
400
|
+
})
|
|
401
|
+
const settings = settingsQuery.data ?? FALLBACK_SETTINGS
|
|
402
|
+
|
|
403
|
+
const selfQuery = useQuery<{ id: string; displayName: string } | null>({
|
|
404
|
+
queryKey: [...DIALOG_QUERY_ROOT, 'self', `scope:${scopeVersion}`],
|
|
405
|
+
enabled: open,
|
|
406
|
+
staleTime: 300_000,
|
|
407
|
+
queryFn: async () => {
|
|
408
|
+
const call = await apiCall<{ member?: { id?: string; displayName?: string } | null }>(
|
|
409
|
+
'/api/staff/team-members/self',
|
|
410
|
+
)
|
|
411
|
+
const member = call.ok ? call.result?.member ?? null : null
|
|
412
|
+
if (!member?.id) return null
|
|
413
|
+
return { id: member.id, displayName: member.displayName ?? '' }
|
|
414
|
+
},
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* The first page of tasks, which is what the picker opens on. It is a head
|
|
419
|
+
* start, not the corpus — anything past it is reached through the search below.
|
|
420
|
+
*/
|
|
421
|
+
const tasksQuery = useQuery<TaskOption[]>({
|
|
422
|
+
queryKey: [...DIALOG_QUERY_ROOT, 'tasks', `scope:${scopeVersion}`],
|
|
423
|
+
enabled: open,
|
|
424
|
+
staleTime: 60_000,
|
|
425
|
+
queryFn: async () => {
|
|
426
|
+
const call = await apiCall<Record<string, unknown>>(
|
|
427
|
+
`/api/staff/timesheets/tasks?page=1&pageSize=${DIRECTORY_PAGE_SIZE}&sortField=title&sortDir=asc`,
|
|
428
|
+
)
|
|
429
|
+
if (!call.ok) return []
|
|
430
|
+
return readRowItems(call.result)
|
|
431
|
+
.map(toTaskOption)
|
|
432
|
+
.filter((task): task is TaskOption => task !== null)
|
|
433
|
+
},
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
// Debounced so a typed word is one pair of requests rather than one per letter.
|
|
437
|
+
React.useEffect(() => {
|
|
438
|
+
if (!open) {
|
|
439
|
+
setDebouncedTaskSearch('')
|
|
440
|
+
return
|
|
441
|
+
}
|
|
442
|
+
const handle = setTimeout(() => setDebouncedTaskSearch(taskSearch.trim()), TASK_SEARCH_DEBOUNCE_MS)
|
|
443
|
+
return () => clearTimeout(handle)
|
|
444
|
+
}, [open, taskSearch])
|
|
445
|
+
|
|
446
|
+
const taskSearchQuery = useQuery<TaskOption[]>({
|
|
447
|
+
queryKey: [...DIALOG_QUERY_ROOT, 'task-search', `scope:${scopeVersion}`, debouncedTaskSearch],
|
|
448
|
+
enabled: open && debouncedTaskSearch.length > 0,
|
|
449
|
+
staleTime: 60_000,
|
|
450
|
+
queryFn: () => fetchTaskOptions(debouncedTaskSearch),
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
const projectsQuery = useQuery<ProjectOption[]>({
|
|
454
|
+
queryKey: [...DIALOG_QUERY_ROOT, 'projects', `scope:${scopeVersion}`],
|
|
455
|
+
enabled: open,
|
|
456
|
+
staleTime: 60_000,
|
|
457
|
+
queryFn: async () => {
|
|
458
|
+
// Best effort: a consultant without `projects.view` still gets the task
|
|
459
|
+
// picker, only without the customer and project suffix on each option.
|
|
460
|
+
const call = await apiCall<Record<string, unknown>>(
|
|
461
|
+
`/api/staff/timesheets/time-projects?page=1&pageSize=${DIRECTORY_PAGE_SIZE}&sortField=name&sortDir=asc`,
|
|
462
|
+
)
|
|
463
|
+
if (!call.ok) return []
|
|
464
|
+
return readRowItems(call.result)
|
|
465
|
+
.map(toProjectOption)
|
|
466
|
+
.filter((project): project is ProjectOption => project !== null)
|
|
467
|
+
},
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
const staffMemberId = selfQuery.data?.id ?? null
|
|
471
|
+
|
|
472
|
+
const statusesQuery = useQuery<Record<string, TaskPickerStatus>>({
|
|
473
|
+
queryKey: [...DIALOG_QUERY_ROOT, 'task-statuses', `scope:${scopeVersion}`],
|
|
474
|
+
enabled: open,
|
|
475
|
+
staleTime: 300_000,
|
|
476
|
+
queryFn: async () => {
|
|
477
|
+
const call = await apiCall<Record<string, unknown>>(
|
|
478
|
+
`/api/staff/timesheets/task-statuses?page=1&pageSize=${DIRECTORY_PAGE_SIZE}`,
|
|
479
|
+
)
|
|
480
|
+
if (!call.ok) return {}
|
|
481
|
+
const map: Record<string, TaskPickerStatus> = {}
|
|
482
|
+
for (const row of readRowItems(call.result)) {
|
|
483
|
+
const id = readRowString(row, 'id')
|
|
484
|
+
if (!id) continue
|
|
485
|
+
map[id] = {
|
|
486
|
+
id,
|
|
487
|
+
name: readRowString(row, 'name') ?? '',
|
|
488
|
+
color: readRowString(row, 'color'),
|
|
489
|
+
isDone: row.is_done === true || row.isDone === true,
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return map
|
|
493
|
+
},
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* The tasks this person logged to recently. Repetition is the norm in time
|
|
498
|
+
* logging, so opening the picker on these turns the common case into one
|
|
499
|
+
* keystroke. No new endpoint — it is the entries list asked a narrower question.
|
|
500
|
+
*/
|
|
501
|
+
const recentQuery = useQuery<string[]>({
|
|
502
|
+
queryKey: [...DIALOG_QUERY_ROOT, 'recent-tasks', `scope:${scopeVersion}`, staffMemberId ?? 'none'],
|
|
503
|
+
enabled: open && !!staffMemberId,
|
|
504
|
+
staleTime: 60_000,
|
|
505
|
+
queryFn: async () => {
|
|
506
|
+
const since = shiftIsoDate(todayIsoDate(), -21)
|
|
507
|
+
const call = await apiCall<Record<string, unknown>>(
|
|
508
|
+
`/api/staff/timesheets/time-entries?page=1&pageSize=100&sortField=date&sortDir=desc`
|
|
509
|
+
+ `&staffMemberId=${encodeURIComponent(staffMemberId as string)}&from=${since}`,
|
|
510
|
+
)
|
|
511
|
+
if (!call.ok) return []
|
|
512
|
+
const seen: string[] = []
|
|
513
|
+
for (const row of readRowItems(call.result)) {
|
|
514
|
+
const taskId = readRowString(row, 'task_id', 'taskId')
|
|
515
|
+
if (taskId && !seen.includes(taskId)) seen.push(taskId)
|
|
516
|
+
if (seen.length >= 6) break
|
|
517
|
+
}
|
|
518
|
+
return seen
|
|
519
|
+
},
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
const tagsQuery = useQuery<TagOption[]>({
|
|
523
|
+
queryKey: [...DIALOG_QUERY_ROOT, 'tags', `scope:${scopeVersion}`],
|
|
524
|
+
enabled: open,
|
|
525
|
+
staleTime: 300_000,
|
|
526
|
+
queryFn: async () => {
|
|
527
|
+
const call = await apiCall<Record<string, unknown>>(
|
|
528
|
+
`/api/staff/timesheets/tags?page=1&pageSize=${DIRECTORY_PAGE_SIZE}&sortField=label&sortDir=asc`,
|
|
529
|
+
)
|
|
530
|
+
if (!call.ok) return []
|
|
531
|
+
return readRowItems(call.result)
|
|
532
|
+
.map((row) => {
|
|
533
|
+
const id = readRowString(row, 'id')
|
|
534
|
+
// The colour has always been stored and never rendered — the chips were
|
|
535
|
+
// monochrome while `staff_time_tags.color` held a DS palette key.
|
|
536
|
+
return id
|
|
537
|
+
? { id, label: readRowString(row, 'label') ?? id, color: readRowString(row, 'color') }
|
|
538
|
+
: null
|
|
539
|
+
})
|
|
540
|
+
.filter((tag): tag is TagOption => tag !== null)
|
|
541
|
+
},
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
const entryQuery = useQuery<TimeEntryRecord | null>({
|
|
545
|
+
queryKey: [...DIALOG_QUERY_ROOT, 'entry', `scope:${scopeVersion}`, entryId ?? 'new'],
|
|
546
|
+
enabled: open && isEdit,
|
|
547
|
+
staleTime: 0,
|
|
548
|
+
queryFn: async () => {
|
|
549
|
+
// `ids`, not `id`: the list schema declares the plural and is `.passthrough()`,
|
|
550
|
+
// so `?id=` was accepted, never read, and the filter silently disappeared —
|
|
551
|
+
// leaving `pageSize=1` to return the first entry of the whole table.
|
|
552
|
+
const call = await apiCall<Record<string, unknown>>(
|
|
553
|
+
`/api/staff/timesheets/time-entries?ids=${encodeURIComponent(entryId as string)}&pageSize=1`,
|
|
554
|
+
)
|
|
555
|
+
if (!call.ok) throw new Error('[internal] time entry request failed')
|
|
556
|
+
const row = readRowItems(call.result)[0]
|
|
557
|
+
if (!row) return null
|
|
558
|
+
const record = toTimeEntryRecord(row)
|
|
559
|
+
if (!record) return null
|
|
560
|
+
// A dialog that edits a record other than the one it was asked for writes
|
|
561
|
+
// one entry's values onto another, so a mismatch fails loudly rather than
|
|
562
|
+
// populating the form. Nothing but a dropped filter can produce it.
|
|
563
|
+
if (record.id !== entryId) {
|
|
564
|
+
throw new Error('[internal] time entry response did not match the requested id')
|
|
565
|
+
}
|
|
566
|
+
return record
|
|
567
|
+
},
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
const entry = entryQuery.data ?? null
|
|
571
|
+
const locked = !!entry?.isLocked
|
|
572
|
+
const lockedReportId = entry?.lockedReportId ?? null
|
|
573
|
+
const directoryTasks = React.useMemo(() => tasksQuery.data ?? [], [tasksQuery.data])
|
|
574
|
+
const searchedTasks = React.useMemo(() => taskSearchQuery.data ?? [], [taskSearchQuery.data])
|
|
575
|
+
|
|
576
|
+
React.useEffect(() => {
|
|
577
|
+
if (!taskId) {
|
|
578
|
+
setPinnedTask(null)
|
|
579
|
+
return
|
|
580
|
+
}
|
|
581
|
+
const found =
|
|
582
|
+
directoryTasks.find((task) => task.id === taskId) ??
|
|
583
|
+
searchedTasks.find((task) => task.id === taskId) ??
|
|
584
|
+
null
|
|
585
|
+
if (found) setPinnedTask(found)
|
|
586
|
+
}, [directoryTasks, searchedTasks, taskId])
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* An entry can point at a task that neither the first directory page nor the
|
|
590
|
+
* current search contains — editing one written months ago, for instance — and
|
|
591
|
+
* a form that cannot name the task it is on cannot resolve its project or rate
|
|
592
|
+
* either, so that one row is fetched by id.
|
|
593
|
+
*/
|
|
594
|
+
const unresolvedTaskId =
|
|
595
|
+
taskId && !pinnedTask && !tasksQuery.isPending && !taskSearchQuery.isFetching ? taskId : null
|
|
596
|
+
|
|
597
|
+
const unresolvedTaskQuery = useQuery<TaskOption | null>({
|
|
598
|
+
queryKey: [...DIALOG_QUERY_ROOT, 'task', `scope:${scopeVersion}`, unresolvedTaskId ?? 'none'],
|
|
599
|
+
enabled: open && !!unresolvedTaskId,
|
|
600
|
+
staleTime: 60_000,
|
|
601
|
+
queryFn: () => fetchTaskById(unresolvedTaskId as string),
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
const tasks = React.useMemo(() => {
|
|
605
|
+
const merged = new Map<string, TaskOption>()
|
|
606
|
+
for (const task of directoryTasks) merged.set(task.id, task)
|
|
607
|
+
for (const task of searchedTasks) if (!merged.has(task.id)) merged.set(task.id, task)
|
|
608
|
+
for (const task of [pinnedTask, unresolvedTaskQuery.data ?? null]) {
|
|
609
|
+
if (task && !merged.has(task.id)) merged.set(task.id, task)
|
|
610
|
+
}
|
|
611
|
+
return [...merged.values()]
|
|
612
|
+
}, [directoryTasks, pinnedTask, searchedTasks, unresolvedTaskQuery.data])
|
|
613
|
+
|
|
614
|
+
const projects = React.useMemo(() => projectsQuery.data ?? [], [projectsQuery.data])
|
|
615
|
+
const tagOptions = React.useMemo(() => tagsQuery.data ?? [], [tagsQuery.data])
|
|
616
|
+
|
|
617
|
+
const projectById = React.useMemo(() => {
|
|
618
|
+
const map = new Map<string, ProjectOption>()
|
|
619
|
+
for (const project of projects) map.set(project.id, project)
|
|
620
|
+
return map
|
|
621
|
+
}, [projects])
|
|
622
|
+
|
|
623
|
+
const selectedTask = React.useMemo(
|
|
624
|
+
() => tasks.find((task) => task.id === taskId) ?? null,
|
|
625
|
+
[taskId, tasks],
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
const projectId = selectedTask?.timeProjectId ?? entry?.timeProjectId ?? defaults?.timeProjectId ?? null
|
|
629
|
+
const project = projectId ? projectById.get(projectId) ?? null : null
|
|
630
|
+
|
|
631
|
+
const resetForm = React.useCallback(
|
|
632
|
+
(seed: {
|
|
633
|
+
taskId: string | null
|
|
634
|
+
description: string
|
|
635
|
+
date: string
|
|
636
|
+
start: string | null
|
|
637
|
+
end: string | null
|
|
638
|
+
durationMinutes: number | null
|
|
639
|
+
isBillable: boolean
|
|
640
|
+
rateOverrideAmount: number | null
|
|
641
|
+
tagIds: string[]
|
|
642
|
+
}) => {
|
|
643
|
+
setTaskId(seed.taskId)
|
|
644
|
+
setDescription(seed.description)
|
|
645
|
+
setDate(seed.date || todayIsoDate())
|
|
646
|
+
setIntervalState((current) =>
|
|
647
|
+
resetIntervalState(current, {
|
|
648
|
+
start: seed.start,
|
|
649
|
+
end: seed.end,
|
|
650
|
+
durationMinutes: seed.durationMinutes,
|
|
651
|
+
}),
|
|
652
|
+
)
|
|
653
|
+
setIsBillable(seed.isBillable)
|
|
654
|
+
setRateOverrideEnabled(seed.rateOverrideAmount !== null)
|
|
655
|
+
setRateText(seed.rateOverrideAmount !== null ? String(seed.rateOverrideAmount) : '')
|
|
656
|
+
setTagIds(seed.tagIds)
|
|
657
|
+
setOverlaps([])
|
|
658
|
+
setFieldIssues(NO_FIELD_ISSUES)
|
|
659
|
+
// The seed is run through the same derivation the state uses, so the
|
|
660
|
+
// baseline is what the form will actually show rather than what was asked
|
|
661
|
+
// for — otherwise a seeded start plus duration would read as an edit.
|
|
662
|
+
const derived = createIntervalState({
|
|
663
|
+
start: seed.start,
|
|
664
|
+
end: seed.end,
|
|
665
|
+
durationMinutes: seed.durationMinutes,
|
|
666
|
+
})
|
|
667
|
+
setBaseline({
|
|
668
|
+
taskId: seed.taskId,
|
|
669
|
+
description: seed.description,
|
|
670
|
+
date: seed.date || todayIsoDate(),
|
|
671
|
+
startText: derived.startText,
|
|
672
|
+
endText: derived.endText,
|
|
673
|
+
durationMinutes: derived.durationMinutes,
|
|
674
|
+
isBillable: seed.isBillable,
|
|
675
|
+
rateOverrideEnabled: seed.rateOverrideAmount !== null,
|
|
676
|
+
rateOverrideAmount: seed.rateOverrideAmount,
|
|
677
|
+
tagIds: seed.tagIds,
|
|
678
|
+
})
|
|
679
|
+
},
|
|
680
|
+
[],
|
|
681
|
+
)
|
|
682
|
+
|
|
683
|
+
React.useEffect(() => {
|
|
684
|
+
if (!open) {
|
|
685
|
+
seedKeyRef.current = null
|
|
686
|
+
billableDefaultRef.current = null
|
|
687
|
+
setBaseline(null)
|
|
688
|
+
setTaskSearch('')
|
|
689
|
+
setPinnedTask(null)
|
|
690
|
+
return
|
|
691
|
+
}
|
|
692
|
+
if (isEdit && !entry) return
|
|
693
|
+
const key = isEdit ? `edit:${entry?.id ?? entryId}:${entry?.updatedAt ?? ''}` : 'create'
|
|
694
|
+
if (seedKeyRef.current === key) return
|
|
695
|
+
seedKeyRef.current = key
|
|
696
|
+
versionRef.current = entry?.updatedAt ?? null
|
|
697
|
+
if (entry) {
|
|
698
|
+
resetForm({
|
|
699
|
+
taskId: entry.taskId,
|
|
700
|
+
description: entry.description,
|
|
701
|
+
date: entry.date,
|
|
702
|
+
start: entry.startText,
|
|
703
|
+
end: entry.endText,
|
|
704
|
+
durationMinutes: entry.durationMinutes,
|
|
705
|
+
isBillable: entry.isBillable,
|
|
706
|
+
rateOverrideAmount: entry.rateOverrideAmount,
|
|
707
|
+
tagIds: entry.tagIds,
|
|
708
|
+
})
|
|
709
|
+
return
|
|
710
|
+
}
|
|
711
|
+
resetForm({
|
|
712
|
+
taskId: defaults?.taskId ?? null,
|
|
713
|
+
description: defaults?.description ?? '',
|
|
714
|
+
date: defaults?.date ?? todayIsoDate(),
|
|
715
|
+
start: defaults?.startClock ?? null,
|
|
716
|
+
end: defaults?.endClock ?? null,
|
|
717
|
+
durationMinutes: defaults?.durationMinutes ?? null,
|
|
718
|
+
isBillable: defaults?.isBillable ?? true,
|
|
719
|
+
rateOverrideAmount: null,
|
|
720
|
+
tagIds: defaults?.tagIds ?? [],
|
|
721
|
+
})
|
|
722
|
+
}, [defaults, entry, entryId, isEdit, open, resetForm])
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* The tenant's billable default lands separately, and only on the one field it
|
|
726
|
+
* owns. Seeding the whole form once the settings request answers would race the
|
|
727
|
+
* first keystrokes and wipe them, which is exactly what this split avoids.
|
|
728
|
+
*/
|
|
729
|
+
React.useEffect(() => {
|
|
730
|
+
if (!open || isEdit || settingsQuery.isPending) return
|
|
731
|
+
if (defaults?.isBillable !== undefined && defaults?.isBillable !== null) return
|
|
732
|
+
if (billableDefaultRef.current === seedKeyRef.current) return
|
|
733
|
+
billableDefaultRef.current = seedKeyRef.current
|
|
734
|
+
setIsBillable(settings.defaults.billable)
|
|
735
|
+
// The tenant default is part of the seed, just a late-arriving part, so the
|
|
736
|
+
// baseline moves with it — otherwise every new entry would open "dirty".
|
|
737
|
+
setBaseline((current) =>
|
|
738
|
+
current ? { ...current, isBillable: settings.defaults.billable } : current,
|
|
739
|
+
)
|
|
740
|
+
}, [defaults?.isBillable, isEdit, open, settings.defaults.billable, settingsQuery.isPending])
|
|
741
|
+
|
|
742
|
+
const durationMinutes = interval.durationMinutes
|
|
743
|
+
const startClock = interval.startText
|
|
744
|
+
const endClock = interval.endText
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Advisory overlap probe. Every failure mode — a non-200, a throw, a partial
|
|
748
|
+
* span — lands on "no warning", because the endpoint may not answer and the
|
|
749
|
+
* form must still save.
|
|
750
|
+
*/
|
|
751
|
+
React.useEffect(() => {
|
|
752
|
+
if (!open || locked || !settings.warnings.overlap) {
|
|
753
|
+
setOverlaps([])
|
|
754
|
+
return
|
|
755
|
+
}
|
|
756
|
+
if (!date || parseClock(startClock) === null || !durationMinutes) {
|
|
757
|
+
setOverlaps([])
|
|
758
|
+
return
|
|
759
|
+
}
|
|
760
|
+
let cancelled = false
|
|
761
|
+
const params = new URLSearchParams({
|
|
762
|
+
date,
|
|
763
|
+
startedAt: startClock,
|
|
764
|
+
durationMinutes: String(durationMinutes),
|
|
765
|
+
})
|
|
766
|
+
if (parseClock(endClock) !== null) params.set('endedAt', endClock)
|
|
767
|
+
if (isEdit && entryId) params.set('excludeId', entryId)
|
|
768
|
+
void apiCall<Record<string, unknown>>(`/api/staff/timesheets/time-entries/overlaps?${params.toString()}`)
|
|
769
|
+
.then((call) => {
|
|
770
|
+
if (cancelled) return
|
|
771
|
+
if (!call.ok) {
|
|
772
|
+
setOverlaps([])
|
|
773
|
+
return
|
|
774
|
+
}
|
|
775
|
+
setOverlaps(
|
|
776
|
+
readRowItems(call.result)
|
|
777
|
+
.map(toOverlapEntry)
|
|
778
|
+
.filter((item): item is OverlapEntry => item !== null),
|
|
779
|
+
)
|
|
780
|
+
})
|
|
781
|
+
.catch(() => {
|
|
782
|
+
if (!cancelled) setOverlaps([])
|
|
783
|
+
})
|
|
784
|
+
return () => {
|
|
785
|
+
cancelled = true
|
|
786
|
+
}
|
|
787
|
+
}, [date, durationMinutes, endClock, entryId, isEdit, locked, open, settings.warnings.overlap, startClock])
|
|
788
|
+
|
|
789
|
+
const rateOverrideAmount = rateOverrideEnabled ? parseRateText(rateText) : null
|
|
790
|
+
const currencyCode = project?.currencyCode ?? entry?.currencyCode ?? null
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* The dialog is hand-rolled, so it plays the `CrudForm` host contract by hand:
|
|
794
|
+
* the four render spots below, plus the `onFieldChange` / `onBeforeSave` /
|
|
795
|
+
* `onAfterSave` lifecycle, dispatched with the same semantics `CrudForm` uses —
|
|
796
|
+
* a widget returning `ok: false` from `onBeforeSave` blocks the write and its
|
|
797
|
+
* message is surfaced, and a value returned from `onFieldChange` is written back
|
|
798
|
+
* into the form.
|
|
799
|
+
*/
|
|
800
|
+
const entryFormValues = React.useMemo<FormValues>(
|
|
801
|
+
() => ({
|
|
802
|
+
taskId,
|
|
803
|
+
description,
|
|
804
|
+
date,
|
|
805
|
+
startText: startClock,
|
|
806
|
+
endText: endClock,
|
|
807
|
+
durationMinutes,
|
|
808
|
+
isBillable,
|
|
809
|
+
rateOverrideEnabled,
|
|
810
|
+
rateOverrideAmount,
|
|
811
|
+
tagIds,
|
|
812
|
+
}),
|
|
813
|
+
[
|
|
814
|
+
date,
|
|
815
|
+
description,
|
|
816
|
+
durationMinutes,
|
|
817
|
+
endClock,
|
|
818
|
+
isBillable,
|
|
819
|
+
rateOverrideAmount,
|
|
820
|
+
rateOverrideEnabled,
|
|
821
|
+
startClock,
|
|
822
|
+
tagIds,
|
|
823
|
+
taskId,
|
|
824
|
+
],
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
const entryInjectionContext = React.useMemo<TimeEntryFormInjectionContext>(
|
|
828
|
+
() => ({
|
|
829
|
+
formId: TIME_ENTRY_DIALOG_MUTATION_CONTEXT_ID,
|
|
830
|
+
entityId: TIME_ENTRY_ENTITY_ID,
|
|
831
|
+
recordId: entryId ?? null,
|
|
832
|
+
resourceKind: 'staff.timesheets.time_entry',
|
|
833
|
+
resourceId: entryId ?? 'new',
|
|
834
|
+
operation: isEdit ? 'update' : 'create',
|
|
835
|
+
locked,
|
|
836
|
+
retryLastMutation,
|
|
837
|
+
}),
|
|
838
|
+
[entryId, isEdit, locked, retryLastMutation],
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
const { triggerEvent: triggerEntryFormEvent } = useInjectionSpotEvents<
|
|
842
|
+
TimeEntryFormInjectionContext,
|
|
843
|
+
FormValues
|
|
844
|
+
>(extensionPoints.hosts.timeEntryForm.spotId)
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* Fields the last write to came from a widget, not from the person. Without it
|
|
848
|
+
* an `onFieldChange` handler that rewrites its own field would see the rewrite
|
|
849
|
+
* as a fresh edit and be dispatched again on every apply.
|
|
850
|
+
*/
|
|
851
|
+
const injectedFieldWritesRef = React.useRef(new Set<string>())
|
|
852
|
+
|
|
853
|
+
const applyInjectedFieldValue = React.useCallback((fieldId: string, value: unknown) => {
|
|
854
|
+
switch (fieldId) {
|
|
855
|
+
case 'taskId':
|
|
856
|
+
injectedFieldWritesRef.current.add(fieldId)
|
|
857
|
+
setTaskId(typeof value === 'string' && value.length > 0 ? value : null)
|
|
858
|
+
return
|
|
859
|
+
case 'description':
|
|
860
|
+
injectedFieldWritesRef.current.add(fieldId)
|
|
861
|
+
setDescription(typeof value === 'string' ? value : '')
|
|
862
|
+
return
|
|
863
|
+
case 'date':
|
|
864
|
+
if (typeof value !== 'string') return
|
|
865
|
+
injectedFieldWritesRef.current.add(fieldId)
|
|
866
|
+
setDate(value)
|
|
867
|
+
return
|
|
868
|
+
case 'isBillable':
|
|
869
|
+
injectedFieldWritesRef.current.add(fieldId)
|
|
870
|
+
setIsBillable(value === true)
|
|
871
|
+
return
|
|
872
|
+
case 'rateOverrideEnabled':
|
|
873
|
+
injectedFieldWritesRef.current.add(fieldId)
|
|
874
|
+
setRateOverrideEnabled(value === true)
|
|
875
|
+
return
|
|
876
|
+
case 'rateOverrideAmount':
|
|
877
|
+
injectedFieldWritesRef.current.add(fieldId)
|
|
878
|
+
setRateText(typeof value === 'number' || typeof value === 'string' ? String(value) : '')
|
|
879
|
+
return
|
|
880
|
+
case 'tagIds':
|
|
881
|
+
if (!Array.isArray(value)) return
|
|
882
|
+
injectedFieldWritesRef.current.add(fieldId)
|
|
883
|
+
setTagIds(value.filter((id): id is string => typeof id === 'string'))
|
|
884
|
+
return
|
|
885
|
+
case 'startText':
|
|
886
|
+
if (typeof value !== 'string') return
|
|
887
|
+
injectedFieldWritesRef.current.add(fieldId)
|
|
888
|
+
setIntervalState((current) => reduceIntervalState(current, { field: 'start', value }))
|
|
889
|
+
return
|
|
890
|
+
case 'endText':
|
|
891
|
+
if (typeof value !== 'string') return
|
|
892
|
+
injectedFieldWritesRef.current.add(fieldId)
|
|
893
|
+
setIntervalState((current) => reduceIntervalState(current, { field: 'end', value }))
|
|
894
|
+
return
|
|
895
|
+
case 'durationMinutes':
|
|
896
|
+
injectedFieldWritesRef.current.add(fieldId)
|
|
897
|
+
setIntervalState((current) =>
|
|
898
|
+
reduceIntervalState(current, {
|
|
899
|
+
field: 'duration',
|
|
900
|
+
minutes: typeof value === 'number' ? value : null,
|
|
901
|
+
invalid: false,
|
|
902
|
+
}),
|
|
903
|
+
)
|
|
904
|
+
return
|
|
905
|
+
default:
|
|
906
|
+
}
|
|
907
|
+
}, [])
|
|
908
|
+
|
|
909
|
+
const previousEntryFormValuesRef = React.useRef<FormValues | null>(null)
|
|
910
|
+
|
|
911
|
+
React.useEffect(() => {
|
|
912
|
+
if (!open || locked) {
|
|
913
|
+
previousEntryFormValuesRef.current = null
|
|
914
|
+
injectedFieldWritesRef.current.clear()
|
|
915
|
+
return
|
|
916
|
+
}
|
|
917
|
+
const previous = previousEntryFormValuesRef.current
|
|
918
|
+
previousEntryFormValuesRef.current = entryFormValues
|
|
919
|
+
if (!previous) return
|
|
920
|
+
const injectedWrites = injectedFieldWritesRef.current
|
|
921
|
+
const changedFieldIds = (Object.keys(entryFormValues) as Array<keyof FormValues>).filter(
|
|
922
|
+
(fieldId) => fieldValueSnapshot(previous[fieldId]) !== fieldValueSnapshot(entryFormValues[fieldId]),
|
|
923
|
+
)
|
|
924
|
+
for (const fieldId of changedFieldIds) {
|
|
925
|
+
if (injectedWrites.delete(fieldId)) continue
|
|
926
|
+
void triggerEntryFormEvent('onFieldChange', entryFormValues, entryInjectionContext, {
|
|
927
|
+
fieldId,
|
|
928
|
+
fieldValue: entryFormValues[fieldId],
|
|
929
|
+
})
|
|
930
|
+
.then((result) => {
|
|
931
|
+
if (!result.ok || !result.fieldChange) return
|
|
932
|
+
const change = result.fieldChange
|
|
933
|
+
if (change.value !== undefined) applyInjectedFieldValue(fieldId, change.value)
|
|
934
|
+
for (const [sideFieldId, sideValue] of Object.entries(change.sideEffects ?? {})) {
|
|
935
|
+
applyInjectedFieldValue(sideFieldId, sideValue)
|
|
936
|
+
}
|
|
937
|
+
for (const message of change.messages ?? []) flash(message.text, message.severity)
|
|
938
|
+
})
|
|
939
|
+
.catch((err) => {
|
|
940
|
+
logger.error('staff.time_tracking entry dialog onFieldChange failed', { err })
|
|
941
|
+
})
|
|
942
|
+
}
|
|
943
|
+
}, [
|
|
944
|
+
applyInjectedFieldValue,
|
|
945
|
+
entryFormValues,
|
|
946
|
+
entryInjectionContext,
|
|
947
|
+
locked,
|
|
948
|
+
open,
|
|
949
|
+
triggerEntryFormEvent,
|
|
950
|
+
])
|
|
951
|
+
const roundedMinutes = roundMinutes(durationMinutes ?? 0, settings.rounding)
|
|
952
|
+
const rate = applicableRate({ rateOverrideAmount }, { hourlyRate: project?.hourlyRate ?? null })
|
|
953
|
+
const cost = entryAmount(
|
|
954
|
+
{ isBillable, roundedMinutes, rateOverrideAmount },
|
|
955
|
+
{ hourlyRate: project?.hourlyRate ?? null },
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
const computedBadge = (field: 'start' | 'end' | 'duration' | 'cost') => (
|
|
959
|
+
<Badge variant="neutral" data-testid={`entry-dialog-computed-${field}`}>
|
|
960
|
+
{t('staff.time_tracking.entryDialog.computed', 'computed')}
|
|
961
|
+
</Badge>
|
|
962
|
+
)
|
|
963
|
+
|
|
964
|
+
const midnightEndDate = interval.crossesMidnight ? shiftIsoDate(date, 1) : null
|
|
965
|
+
/**
|
|
966
|
+
* Unparseable duration text keeps Save disabled — the field already explains
|
|
967
|
+
* itself and there is nothing to send. A *missing* required value does not:
|
|
968
|
+
* pressing Save is how the form is asked what it is waiting for, and it
|
|
969
|
+
* answers under the control rather than by staying greyed out and silent.
|
|
970
|
+
*/
|
|
971
|
+
const canSubmit = !locked && !interval.durationInvalid && !saving
|
|
972
|
+
|
|
973
|
+
// A field stops complaining the moment it is filled in.
|
|
974
|
+
React.useEffect(() => {
|
|
975
|
+
if (!taskId) return
|
|
976
|
+
setFieldIssues((current) => (current.task ? { ...current, task: null } : current))
|
|
977
|
+
}, [taskId])
|
|
978
|
+
|
|
979
|
+
React.useEffect(() => {
|
|
980
|
+
if (!durationMinutes) return
|
|
981
|
+
setFieldIssues((current) => (current.duration ? { ...current, duration: null } : current))
|
|
982
|
+
}, [durationMinutes])
|
|
983
|
+
|
|
984
|
+
const validateRequired = React.useCallback(() => {
|
|
985
|
+
const next: FieldIssues = {
|
|
986
|
+
task: taskId
|
|
987
|
+
? null
|
|
988
|
+
: t('staff.time_tracking.entryDialog.errors.taskRequired', 'Pick the task this time belongs to.'),
|
|
989
|
+
duration: durationMinutes
|
|
990
|
+
? null
|
|
991
|
+
: t('staff.time_tracking.entryDialog.errors.durationRequired', 'Enter how long the work took.'),
|
|
992
|
+
}
|
|
993
|
+
setFieldIssues(next)
|
|
994
|
+
return !next.task && !next.duration
|
|
995
|
+
}, [durationMinutes, t, taskId])
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* A locked entry has nothing editable in it, and a form that has not been
|
|
999
|
+
* seeded yet has nothing to compare against; everywhere else the seed is the
|
|
1000
|
+
* baseline and any difference is work that closing would throw away.
|
|
1001
|
+
*/
|
|
1002
|
+
const isDirty = React.useMemo(() => {
|
|
1003
|
+
if (locked || !baseline) return false
|
|
1004
|
+
return (
|
|
1005
|
+
formSnapshot(baseline) !==
|
|
1006
|
+
formSnapshot({
|
|
1007
|
+
taskId,
|
|
1008
|
+
description,
|
|
1009
|
+
date,
|
|
1010
|
+
startText: startClock,
|
|
1011
|
+
endText: endClock,
|
|
1012
|
+
durationMinutes,
|
|
1013
|
+
isBillable,
|
|
1014
|
+
rateOverrideEnabled,
|
|
1015
|
+
rateOverrideAmount,
|
|
1016
|
+
tagIds,
|
|
1017
|
+
})
|
|
1018
|
+
)
|
|
1019
|
+
}, [
|
|
1020
|
+
baseline,
|
|
1021
|
+
date,
|
|
1022
|
+
description,
|
|
1023
|
+
durationMinutes,
|
|
1024
|
+
endClock,
|
|
1025
|
+
isBillable,
|
|
1026
|
+
locked,
|
|
1027
|
+
rateOverrideAmount,
|
|
1028
|
+
rateOverrideEnabled,
|
|
1029
|
+
startClock,
|
|
1030
|
+
tagIds,
|
|
1031
|
+
taskId,
|
|
1032
|
+
])
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* Every way out of the dialog — Escape, the ×, the overlay, Cancel — lands
|
|
1036
|
+
* here, because a half-entered entry is lost the same way whichever one is
|
|
1037
|
+
* used. A clean form closes silently; only real work is worth an interruption.
|
|
1038
|
+
*/
|
|
1039
|
+
const requestClose = React.useCallback(async () => {
|
|
1040
|
+
if (saving) return
|
|
1041
|
+
if (!isDirty) {
|
|
1042
|
+
onOpenChange(false)
|
|
1043
|
+
return
|
|
1044
|
+
}
|
|
1045
|
+
const confirmed = await confirm({
|
|
1046
|
+
title: t('staff.time_tracking.entryDialog.discard.title', 'Discard this time entry?'),
|
|
1047
|
+
text: t(
|
|
1048
|
+
'staff.time_tracking.entryDialog.discard.text',
|
|
1049
|
+
'It has not been saved yet, so closing now loses what you entered.',
|
|
1050
|
+
),
|
|
1051
|
+
confirmText: t('staff.time_tracking.entryDialog.discard.confirm', 'Discard'),
|
|
1052
|
+
cancelText: t('staff.time_tracking.entryDialog.discard.cancel', 'Keep editing'),
|
|
1053
|
+
variant: 'destructive',
|
|
1054
|
+
})
|
|
1055
|
+
if (confirmed) onOpenChange(false)
|
|
1056
|
+
}, [confirm, isDirty, onOpenChange, saving, t])
|
|
1057
|
+
|
|
1058
|
+
const buildPayload = React.useCallback(() => {
|
|
1059
|
+
const startedAt = parseClock(startClock) !== null ? combineDateAndClock(date, startClock) : null
|
|
1060
|
+
const endedAt =
|
|
1061
|
+
parseClock(endClock) !== null ? combineDateAndClock(date, endClock, interval.crossesMidnight ? 1 : 0) : null
|
|
1062
|
+
return {
|
|
1063
|
+
date,
|
|
1064
|
+
durationMinutes: durationMinutes ?? 0,
|
|
1065
|
+
startedAt,
|
|
1066
|
+
endedAt,
|
|
1067
|
+
taskId,
|
|
1068
|
+
timeProjectId: projectId,
|
|
1069
|
+
description: description.trim() ? description.trim() : null,
|
|
1070
|
+
isBillable,
|
|
1071
|
+
tagIds,
|
|
1072
|
+
rateOverrideAmount: canSeeMoney ? rateOverrideAmount : undefined,
|
|
1073
|
+
}
|
|
1074
|
+
}, [
|
|
1075
|
+
canSeeMoney,
|
|
1076
|
+
date,
|
|
1077
|
+
description,
|
|
1078
|
+
durationMinutes,
|
|
1079
|
+
endClock,
|
|
1080
|
+
interval.crossesMidnight,
|
|
1081
|
+
isBillable,
|
|
1082
|
+
projectId,
|
|
1083
|
+
rateOverrideAmount,
|
|
1084
|
+
startClock,
|
|
1085
|
+
tagIds,
|
|
1086
|
+
taskId,
|
|
1087
|
+
])
|
|
1088
|
+
|
|
1089
|
+
const reportFailure = React.useCallback(
|
|
1090
|
+
(error: unknown) => {
|
|
1091
|
+
if (surfaceRecordConflict(error, t)) return
|
|
1092
|
+
if (readErrorCode(error) === 'entry_locked') {
|
|
1093
|
+
flash(
|
|
1094
|
+
t(
|
|
1095
|
+
'staff.time_tracking.entryDialog.locked.notice',
|
|
1096
|
+
'This entry is closed in a report, so it can no longer be edited.',
|
|
1097
|
+
),
|
|
1098
|
+
'error',
|
|
1099
|
+
)
|
|
1100
|
+
return
|
|
1101
|
+
}
|
|
1102
|
+
const issues = readFieldIssues(error)
|
|
1103
|
+
if (issues) {
|
|
1104
|
+
setFieldIssues(issues)
|
|
1105
|
+
return
|
|
1106
|
+
}
|
|
1107
|
+
logger.error('staff.time_tracking entry dialog save failed', { err: error })
|
|
1108
|
+
flash(
|
|
1109
|
+
error instanceof Error && error.message
|
|
1110
|
+
? error.message
|
|
1111
|
+
: t('staff.time_tracking.entryDialog.errors.save', 'Could not save the time entry.'),
|
|
1112
|
+
'error',
|
|
1113
|
+
)
|
|
1114
|
+
},
|
|
1115
|
+
[t],
|
|
1116
|
+
)
|
|
1117
|
+
|
|
1118
|
+
const submit = React.useCallback(
|
|
1119
|
+
async (mode: SaveMode) => {
|
|
1120
|
+
if (!canSubmit) return
|
|
1121
|
+
if (!validateRequired()) return
|
|
1122
|
+
if (!isEdit && !staffMemberId) {
|
|
1123
|
+
flash(
|
|
1124
|
+
t(
|
|
1125
|
+
'staff.time_tracking.entryDialog.errors.noStaffMember',
|
|
1126
|
+
'Your account is not linked to a staff member, so time cannot be logged.',
|
|
1127
|
+
),
|
|
1128
|
+
'error',
|
|
1129
|
+
)
|
|
1130
|
+
return
|
|
1131
|
+
}
|
|
1132
|
+
const body = buildPayload()
|
|
1133
|
+
|
|
1134
|
+
const beforeSave = await triggerEntryFormEvent('onBeforeSave', entryFormValues, entryInjectionContext)
|
|
1135
|
+
if (!beforeSave.ok) {
|
|
1136
|
+
if (beforeSave.fieldErrors) {
|
|
1137
|
+
const taskIssue = beforeSave.fieldErrors.taskId ?? beforeSave.fieldErrors.task ?? null
|
|
1138
|
+
const durationIssue =
|
|
1139
|
+
beforeSave.fieldErrors.durationMinutes ?? beforeSave.fieldErrors.duration ?? null
|
|
1140
|
+
if (taskIssue || durationIssue) setFieldIssues({ task: taskIssue, duration: durationIssue })
|
|
1141
|
+
}
|
|
1142
|
+
flash(
|
|
1143
|
+
beforeSave.message || t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
|
|
1144
|
+
'error',
|
|
1145
|
+
)
|
|
1146
|
+
return
|
|
1147
|
+
}
|
|
1148
|
+
const injectedRequestHeaders = beforeSave.requestHeaders ?? {}
|
|
1149
|
+
|
|
1150
|
+
setSavingMode(mode)
|
|
1151
|
+
try {
|
|
1152
|
+
const saved = await runMutation({
|
|
1153
|
+
operation: () =>
|
|
1154
|
+
withScopedApiRequestHeaders(
|
|
1155
|
+
{
|
|
1156
|
+
...injectedRequestHeaders,
|
|
1157
|
+
...(isEdit ? buildOptimisticLockHeader(versionRef.current) : {}),
|
|
1158
|
+
},
|
|
1159
|
+
() =>
|
|
1160
|
+
apiCallOrThrow<Record<string, unknown>>(
|
|
1161
|
+
'/api/staff/timesheets/time-entries',
|
|
1162
|
+
{
|
|
1163
|
+
method: isEdit ? 'PUT' : 'POST',
|
|
1164
|
+
headers: { 'content-type': 'application/json' },
|
|
1165
|
+
body: JSON.stringify(
|
|
1166
|
+
isEdit ? { id: entryId, ...body } : { staffMemberId, source: 'manual', ...body },
|
|
1167
|
+
),
|
|
1168
|
+
},
|
|
1169
|
+
{
|
|
1170
|
+
errorMessage: t(
|
|
1171
|
+
'staff.time_tracking.entryDialog.errors.save',
|
|
1172
|
+
'Could not save the time entry.',
|
|
1173
|
+
),
|
|
1174
|
+
},
|
|
1175
|
+
),
|
|
1176
|
+
),
|
|
1177
|
+
context: {
|
|
1178
|
+
formId: TIME_ENTRY_DIALOG_MUTATION_CONTEXT_ID,
|
|
1179
|
+
resourceKind: 'staff.timesheets.time_entry',
|
|
1180
|
+
resourceId: entryId ?? 'new',
|
|
1181
|
+
retryLastMutation,
|
|
1182
|
+
},
|
|
1183
|
+
})
|
|
1184
|
+
const savedId = typeof saved?.result?.id === 'string' ? saved.result.id : entryId ?? null
|
|
1185
|
+
try {
|
|
1186
|
+
await triggerEntryFormEvent('onAfterSave', entryFormValues, entryInjectionContext)
|
|
1187
|
+
} catch (err) {
|
|
1188
|
+
logger.error('staff.time_tracking entry dialog onAfterSave failed', { err })
|
|
1189
|
+
}
|
|
1190
|
+
flash(t('staff.time_tracking.entryDialog.saved', 'Time entry saved.'), 'success')
|
|
1191
|
+
if (mode === 'again') {
|
|
1192
|
+
// Note 3: the dialog stays open, the description and the times clear,
|
|
1193
|
+
// and the next entry starts where this one ended.
|
|
1194
|
+
const chain = settings.defaults.chainStartFromPreviousEnd
|
|
1195
|
+
const nextDate = interval.crossesMidnight && midnightEndDate ? midnightEndDate : date
|
|
1196
|
+
const nextStart = chain ? endClock || null : null
|
|
1197
|
+
seedKeyRef.current = 'create'
|
|
1198
|
+
setDescription('')
|
|
1199
|
+
setDate(nextDate)
|
|
1200
|
+
setIntervalState((current) => resetIntervalState(current, { start: nextStart }))
|
|
1201
|
+
setOverlaps([])
|
|
1202
|
+
// The next entry starts clean, so this is its baseline — without it the
|
|
1203
|
+
// dialog would still be holding the one that was just written.
|
|
1204
|
+
setBaseline({
|
|
1205
|
+
taskId,
|
|
1206
|
+
description: '',
|
|
1207
|
+
date: nextDate,
|
|
1208
|
+
startText: nextStart ?? '',
|
|
1209
|
+
endText: '',
|
|
1210
|
+
durationMinutes: null,
|
|
1211
|
+
isBillable,
|
|
1212
|
+
rateOverrideEnabled,
|
|
1213
|
+
rateOverrideAmount,
|
|
1214
|
+
tagIds,
|
|
1215
|
+
})
|
|
1216
|
+
onSaved?.({ id: savedId, keptOpen: true })
|
|
1217
|
+
// Back to the top of the loop: pick task → duration → save → next.
|
|
1218
|
+
focusTaskPicker()
|
|
1219
|
+
return
|
|
1220
|
+
}
|
|
1221
|
+
onSaved?.({ id: savedId, keptOpen: false })
|
|
1222
|
+
onOpenChange(false)
|
|
1223
|
+
} catch (error) {
|
|
1224
|
+
reportFailure(error)
|
|
1225
|
+
} finally {
|
|
1226
|
+
setSavingMode(null)
|
|
1227
|
+
}
|
|
1228
|
+
},
|
|
1229
|
+
[
|
|
1230
|
+
buildPayload,
|
|
1231
|
+
canSubmit,
|
|
1232
|
+
date,
|
|
1233
|
+
endClock,
|
|
1234
|
+
entryFormValues,
|
|
1235
|
+
entryId,
|
|
1236
|
+
entryInjectionContext,
|
|
1237
|
+
interval.crossesMidnight,
|
|
1238
|
+
isBillable,
|
|
1239
|
+
isEdit,
|
|
1240
|
+
midnightEndDate,
|
|
1241
|
+
onOpenChange,
|
|
1242
|
+
onSaved,
|
|
1243
|
+
rateOverrideAmount,
|
|
1244
|
+
rateOverrideEnabled,
|
|
1245
|
+
reportFailure,
|
|
1246
|
+
retryLastMutation,
|
|
1247
|
+
runMutation,
|
|
1248
|
+
settings.defaults.chainStartFromPreviousEnd,
|
|
1249
|
+
staffMemberId,
|
|
1250
|
+
t,
|
|
1251
|
+
tagIds,
|
|
1252
|
+
taskId,
|
|
1253
|
+
triggerEntryFormEvent,
|
|
1254
|
+
validateRequired,
|
|
1255
|
+
],
|
|
1256
|
+
)
|
|
1257
|
+
|
|
1258
|
+
const handleShowOverlap = React.useCallback(
|
|
1259
|
+
(overlap: OverlapEntry) => {
|
|
1260
|
+
if (onShowEntry) {
|
|
1261
|
+
onShowEntry(overlap.id)
|
|
1262
|
+
return
|
|
1263
|
+
}
|
|
1264
|
+
window.open(`${ENTRIES_LIST_PATH}?ids=${encodeURIComponent(overlap.id)}`, '_blank', 'noopener')
|
|
1265
|
+
},
|
|
1266
|
+
[onShowEntry],
|
|
1267
|
+
)
|
|
1268
|
+
|
|
1269
|
+
/**
|
|
1270
|
+
* The picker needs the project and its customer per row so it can group by
|
|
1271
|
+
* them; both already arrive with the projects query the dialog runs for the
|
|
1272
|
+
* rate, so this is a join in memory rather than another request.
|
|
1273
|
+
*/
|
|
1274
|
+
const pickerItems = React.useMemo<TaskPickerItem[]>(
|
|
1275
|
+
() =>
|
|
1276
|
+
tasks.map((task) => {
|
|
1277
|
+
const project = task.timeProjectId ? projectById.get(task.timeProjectId) : undefined
|
|
1278
|
+
return {
|
|
1279
|
+
id: task.id,
|
|
1280
|
+
reference: task.reference,
|
|
1281
|
+
title: task.title,
|
|
1282
|
+
projectId: task.timeProjectId,
|
|
1283
|
+
projectName: project?.name ?? null,
|
|
1284
|
+
customerName: project?.customerName ?? null,
|
|
1285
|
+
statusId: task.taskStatusId ?? null,
|
|
1286
|
+
assigneeInitials: null,
|
|
1287
|
+
assigneeName: null,
|
|
1288
|
+
loggedMinutes: task.loggedMinutes ?? null,
|
|
1289
|
+
}
|
|
1290
|
+
}),
|
|
1291
|
+
[projectById, tasks],
|
|
1292
|
+
)
|
|
1293
|
+
|
|
1294
|
+
const queryClient = useQueryClient()
|
|
1295
|
+
/**
|
|
1296
|
+
* Creates the tag, then hands it back so the caller can assign it.
|
|
1297
|
+
*
|
|
1298
|
+
* The colour is decided here rather than left to the server, which stores
|
|
1299
|
+
* whatever it is sent and defaults to null. The picker already tints its dot
|
|
1300
|
+
* with the name-derived hue, so sending that same hue is what makes the chip
|
|
1301
|
+
* the entry ends up wearing the colour the row promised — instead of the grey
|
|
1302
|
+
* one an unset column renders as.
|
|
1303
|
+
*/
|
|
1304
|
+
const createTag = React.useCallback(
|
|
1305
|
+
async (label: string): Promise<TagOption | null> => {
|
|
1306
|
+
const color = autoColorFromName(label).key
|
|
1307
|
+
try {
|
|
1308
|
+
const created = await apiCallOrThrow<Record<string, unknown>>(
|
|
1309
|
+
'/api/staff/timesheets/tags',
|
|
1310
|
+
{
|
|
1311
|
+
method: 'POST',
|
|
1312
|
+
headers: { 'content-type': 'application/json' },
|
|
1313
|
+
body: JSON.stringify({ label, slug: slugifyProjectName(label).toLowerCase(), color }),
|
|
1314
|
+
},
|
|
1315
|
+
{ errorMessage: t('staff.time_tracking.entryDialog.errors.createTag', 'Could not create the tag.') },
|
|
1316
|
+
)
|
|
1317
|
+
const id = typeof created?.result?.id === 'string' ? created.result.id : null
|
|
1318
|
+
if (!id) return null
|
|
1319
|
+
const option: TagOption = { id, label, color }
|
|
1320
|
+
// Seeded before the refetch so the chip is never briefly the grey one the
|
|
1321
|
+
// fix is about; the invalidation right after replaces it with the row the
|
|
1322
|
+
// server actually stored.
|
|
1323
|
+
queryClient.setQueryData<TagOption[]>(
|
|
1324
|
+
[...DIALOG_QUERY_ROOT, 'tags', `scope:${scopeVersion}`],
|
|
1325
|
+
(current) => [...(current ?? []), option],
|
|
1326
|
+
)
|
|
1327
|
+
await queryClient.invalidateQueries({ queryKey: [...DIALOG_QUERY_ROOT, 'tags'] })
|
|
1328
|
+
return option
|
|
1329
|
+
} catch (error) {
|
|
1330
|
+
logger.warn('staff.time_tracking.entryDialog tag create failed', { err: error })
|
|
1331
|
+
flash(t('staff.time_tracking.entryDialog.errors.createTag', 'Could not create the tag.'), 'error')
|
|
1332
|
+
return null
|
|
1333
|
+
}
|
|
1334
|
+
},
|
|
1335
|
+
[queryClient, scopeVersion, t],
|
|
1336
|
+
)
|
|
1337
|
+
|
|
1338
|
+
const overlap = overlaps[0] ?? null
|
|
1339
|
+
const availableTags = React.useMemo(
|
|
1340
|
+
() => tagOptions.filter((tag) => !tagIds.includes(tag.id)),
|
|
1341
|
+
[tagIds, tagOptions],
|
|
1342
|
+
)
|
|
1343
|
+
const assignedTags = React.useMemo(() => {
|
|
1344
|
+
const byId = new Map(tagOptions.map((tag) => [tag.id, tag]))
|
|
1345
|
+
return tagIds.map((id) => byId.get(id) ?? { id, label: id, color: null })
|
|
1346
|
+
}, [tagIds, tagOptions])
|
|
1347
|
+
|
|
1348
|
+
const rateLabel = rate !== null ? formatCurrency(rate, currencyCode) ?? String(rate) : null
|
|
1349
|
+
const projectRateLabel =
|
|
1350
|
+
project?.hourlyRate !== null && project?.hourlyRate !== undefined
|
|
1351
|
+
? formatCurrency(project.hourlyRate, currencyCode) ?? String(project.hourlyRate)
|
|
1352
|
+
: null
|
|
1353
|
+
|
|
1354
|
+
const body = (() => {
|
|
1355
|
+
if (isEdit && entryQuery.isLoading) {
|
|
1356
|
+
return <LoadingMessage label={t('staff.time_tracking.entryDialog.loading', 'Loading the time entry…')} />
|
|
1357
|
+
}
|
|
1358
|
+
if (isEdit && entryQuery.isError) {
|
|
1359
|
+
return <ErrorMessage label={t('staff.time_tracking.entryDialog.errors.load', 'Could not load the time entry.')} />
|
|
1360
|
+
}
|
|
1361
|
+
if (isEdit && !entry) {
|
|
1362
|
+
return <ErrorMessage label={t('staff.time_tracking.entryDialog.errors.notFound', 'Time entry not found or not accessible.')} />
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
return (
|
|
1366
|
+
<div className="flex flex-col gap-4">
|
|
1367
|
+
<InjectionSpot
|
|
1368
|
+
spotId={extensionSpotChildId(extensionPoints.hosts.timeEntryForm.spotId, 'before-fields')}
|
|
1369
|
+
context={entryInjectionContext}
|
|
1370
|
+
data={entryFormValues}
|
|
1371
|
+
/>
|
|
1372
|
+
|
|
1373
|
+
{locked ? (
|
|
1374
|
+
<Alert status="warning" data-testid="entry-dialog-locked">
|
|
1375
|
+
<AlertTitle className="flex items-center gap-2">
|
|
1376
|
+
<Lock className="size-3.5" aria-hidden="true" />
|
|
1377
|
+
{t('staff.time_tracking.entryDialog.locked.badge', 'Locked')}
|
|
1378
|
+
</AlertTitle>
|
|
1379
|
+
<AlertDescription>
|
|
1380
|
+
{t(
|
|
1381
|
+
'staff.time_tracking.entryDialog.locked.notice',
|
|
1382
|
+
'This entry is closed in a report, so it can no longer be edited.',
|
|
1383
|
+
)}
|
|
1384
|
+
</AlertDescription>
|
|
1385
|
+
{lockedReportId ? (
|
|
1386
|
+
<Button
|
|
1387
|
+
type="button"
|
|
1388
|
+
variant="outline"
|
|
1389
|
+
size="sm"
|
|
1390
|
+
className="mt-2"
|
|
1391
|
+
data-testid="entry-dialog-locked-report"
|
|
1392
|
+
onClick={() =>
|
|
1393
|
+
window.open(`${REPORTS_PATH}?id=${encodeURIComponent(lockedReportId)}`, '_blank', 'noopener')
|
|
1394
|
+
}
|
|
1395
|
+
>
|
|
1396
|
+
{t('staff.time_tracking.entryDialog.locked.showReport', 'Show the report')}
|
|
1397
|
+
</Button>
|
|
1398
|
+
) : null}
|
|
1399
|
+
</Alert>
|
|
1400
|
+
) : null}
|
|
1401
|
+
|
|
1402
|
+
<div className="flex flex-col gap-1.5">
|
|
1403
|
+
<Label htmlFor="entry-dialog-task">
|
|
1404
|
+
{t('staff.time_tracking.entryDialog.task', 'Task')}
|
|
1405
|
+
<span aria-hidden="true"> *</span>
|
|
1406
|
+
</Label>
|
|
1407
|
+
<div
|
|
1408
|
+
data-testid="entry-dialog-task"
|
|
1409
|
+
ref={taskTriggerRef}
|
|
1410
|
+
aria-invalid={fieldIssues.task ? true : undefined}
|
|
1411
|
+
aria-describedby={fieldIssues.task ? 'entry-dialog-task-message' : undefined}
|
|
1412
|
+
>
|
|
1413
|
+
<TaskPicker
|
|
1414
|
+
value={taskId}
|
|
1415
|
+
onChange={(next) => setTaskId(next || null)}
|
|
1416
|
+
items={pickerItems}
|
|
1417
|
+
recentTaskIds={recentQuery.data ?? []}
|
|
1418
|
+
statuses={statusesQuery.data ?? {}}
|
|
1419
|
+
onQueryChange={setTaskSearch}
|
|
1420
|
+
searching={taskSearchQuery.isFetching}
|
|
1421
|
+
loading={tasksQuery.isLoading}
|
|
1422
|
+
disabled={locked}
|
|
1423
|
+
/>
|
|
1424
|
+
</div>
|
|
1425
|
+
{fieldIssues.task ? (
|
|
1426
|
+
<p
|
|
1427
|
+
id="entry-dialog-task-message"
|
|
1428
|
+
className="text-xs text-status-error-text"
|
|
1429
|
+
role="alert"
|
|
1430
|
+
data-testid="entry-dialog-task-error"
|
|
1431
|
+
>
|
|
1432
|
+
{fieldIssues.task}
|
|
1433
|
+
</p>
|
|
1434
|
+
) : (
|
|
1435
|
+
<p className="text-xs text-muted-foreground">
|
|
1436
|
+
{t(
|
|
1437
|
+
'staff.time_tracking.entryDialog.taskHint',
|
|
1438
|
+
'The project and the customer follow from the task — you do not pick them separately.',
|
|
1439
|
+
)}
|
|
1440
|
+
</p>
|
|
1441
|
+
)}
|
|
1442
|
+
</div>
|
|
1443
|
+
|
|
1444
|
+
<div className="flex flex-col gap-1.5">
|
|
1445
|
+
<Label htmlFor="entry-dialog-description">
|
|
1446
|
+
{t('staff.time_tracking.entryDialog.description', 'Description')}
|
|
1447
|
+
</Label>
|
|
1448
|
+
<Input
|
|
1449
|
+
id="entry-dialog-description"
|
|
1450
|
+
value={description}
|
|
1451
|
+
disabled={locked}
|
|
1452
|
+
onChange={(event) => setDescription(event.target.value)}
|
|
1453
|
+
data-testid="entry-dialog-description"
|
|
1454
|
+
/>
|
|
1455
|
+
</div>
|
|
1456
|
+
|
|
1457
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
1458
|
+
<div className="flex flex-col gap-1.5">
|
|
1459
|
+
<Label htmlFor="entry-dialog-date">{t('staff.time_tracking.entryDialog.date', 'Date')}</Label>
|
|
1460
|
+
<Input
|
|
1461
|
+
id="entry-dialog-date"
|
|
1462
|
+
type="date"
|
|
1463
|
+
value={date}
|
|
1464
|
+
disabled={locked}
|
|
1465
|
+
onChange={(event) => setDate(event.target.value)}
|
|
1466
|
+
data-testid="entry-dialog-date"
|
|
1467
|
+
/>
|
|
1468
|
+
</div>
|
|
1469
|
+
<div className="flex flex-col gap-1.5">
|
|
1470
|
+
<Label htmlFor="entry-dialog-duration" className="flex items-center gap-2">
|
|
1471
|
+
{t('staff.time_tracking.entryDialog.duration', 'Duration')}
|
|
1472
|
+
{interval.computed === 'duration' ? computedBadge('duration') : null}
|
|
1473
|
+
</Label>
|
|
1474
|
+
<DurationInput
|
|
1475
|
+
key={`entry-dialog-duration-${interval.durationEpoch}`}
|
|
1476
|
+
id="entry-dialog-duration"
|
|
1477
|
+
value={durationMinutes}
|
|
1478
|
+
disabled={locked}
|
|
1479
|
+
error={fieldIssues.duration ?? undefined}
|
|
1480
|
+
ariaLabel={t('staff.time_tracking.entryDialog.duration', 'Duration')}
|
|
1481
|
+
onChange={(minutes, state) =>
|
|
1482
|
+
setIntervalState((current) =>
|
|
1483
|
+
reduceIntervalState(current, {
|
|
1484
|
+
field: 'duration',
|
|
1485
|
+
minutes,
|
|
1486
|
+
invalid: state.status === 'invalid',
|
|
1487
|
+
}),
|
|
1488
|
+
)
|
|
1489
|
+
}
|
|
1490
|
+
/>
|
|
1491
|
+
</div>
|
|
1492
|
+
</div>
|
|
1493
|
+
|
|
1494
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
1495
|
+
<div className="flex flex-col gap-1.5">
|
|
1496
|
+
<Label htmlFor="entry-dialog-start" className="flex items-center gap-2">
|
|
1497
|
+
{t('staff.time_tracking.entryDialog.start', 'Start')}
|
|
1498
|
+
{interval.computed === 'start' ? computedBadge('start') : null}
|
|
1499
|
+
</Label>
|
|
1500
|
+
<Input
|
|
1501
|
+
id="entry-dialog-start"
|
|
1502
|
+
value={startClock}
|
|
1503
|
+
disabled={locked}
|
|
1504
|
+
inputClassName="font-mono tabular-nums"
|
|
1505
|
+
placeholder="15:10"
|
|
1506
|
+
onChange={(event) =>
|
|
1507
|
+
setIntervalState((current) =>
|
|
1508
|
+
reduceIntervalState(current, { field: 'start', value: event.target.value }),
|
|
1509
|
+
)
|
|
1510
|
+
}
|
|
1511
|
+
data-testid="entry-dialog-start"
|
|
1512
|
+
/>
|
|
1513
|
+
</div>
|
|
1514
|
+
<div className="flex flex-col gap-1.5">
|
|
1515
|
+
<Label htmlFor="entry-dialog-end" className="flex items-center gap-2">
|
|
1516
|
+
{t('staff.time_tracking.entryDialog.end', 'End')}
|
|
1517
|
+
{interval.computed === 'end' ? computedBadge('end') : null}
|
|
1518
|
+
</Label>
|
|
1519
|
+
<Input
|
|
1520
|
+
id="entry-dialog-end"
|
|
1521
|
+
value={endClock}
|
|
1522
|
+
disabled={locked}
|
|
1523
|
+
inputClassName="font-mono tabular-nums"
|
|
1524
|
+
placeholder="16:25"
|
|
1525
|
+
onChange={(event) =>
|
|
1526
|
+
setIntervalState((current) =>
|
|
1527
|
+
reduceIntervalState(current, { field: 'end', value: event.target.value }),
|
|
1528
|
+
)
|
|
1529
|
+
}
|
|
1530
|
+
data-testid="entry-dialog-end"
|
|
1531
|
+
/>
|
|
1532
|
+
<p className="text-xs text-muted-foreground">
|
|
1533
|
+
{t(
|
|
1534
|
+
'staff.time_tracking.entryDialog.endHint',
|
|
1535
|
+
'Change this field and the duration recalculates itself.',
|
|
1536
|
+
)}
|
|
1537
|
+
</p>
|
|
1538
|
+
</div>
|
|
1539
|
+
</div>
|
|
1540
|
+
|
|
1541
|
+
{midnightEndDate ? (
|
|
1542
|
+
<p className="text-xs text-muted-foreground" data-testid="entry-dialog-midnight">
|
|
1543
|
+
{t('staff.time_tracking.entryDialog.midnight', 'This entry ends the next day, {date}.', {
|
|
1544
|
+
date: midnightEndDate,
|
|
1545
|
+
})}
|
|
1546
|
+
</p>
|
|
1547
|
+
) : null}
|
|
1548
|
+
|
|
1549
|
+
{overlap ? (
|
|
1550
|
+
<Alert status="warning" data-testid="entry-dialog-overlap">
|
|
1551
|
+
<AlertTitle>
|
|
1552
|
+
{t('staff.time_tracking.entryDialog.overlap.title', 'This time overlaps another entry')}
|
|
1553
|
+
</AlertTitle>
|
|
1554
|
+
<AlertDescription>
|
|
1555
|
+
{t(
|
|
1556
|
+
'staff.time_tracking.entryDialog.overlap.body',
|
|
1557
|
+
'{start} – {end} · „{task}” · {project} ({duration}). Overlapping is allowed — if it is intentional, just save.',
|
|
1558
|
+
{
|
|
1559
|
+
start: overlap.startText,
|
|
1560
|
+
end: overlap.endText,
|
|
1561
|
+
task:
|
|
1562
|
+
overlap.taskTitle ??
|
|
1563
|
+
overlap.description ??
|
|
1564
|
+
t('staff.time_tracking.entryDialog.overlap.noTask', 'no task'),
|
|
1565
|
+
project:
|
|
1566
|
+
overlap.projectName ??
|
|
1567
|
+
t('staff.time_tracking.entryDialog.overlap.noProject', 'no project'),
|
|
1568
|
+
duration: formatDuration(overlap.durationMinutes, 'clock'),
|
|
1569
|
+
},
|
|
1570
|
+
)}
|
|
1571
|
+
</AlertDescription>
|
|
1572
|
+
<div className="mt-2 flex flex-wrap gap-2">
|
|
1573
|
+
<Button
|
|
1574
|
+
type="button"
|
|
1575
|
+
variant="outline"
|
|
1576
|
+
size="sm"
|
|
1577
|
+
onClick={() => handleShowOverlap(overlap)}
|
|
1578
|
+
data-testid="entry-dialog-overlap-show"
|
|
1579
|
+
>
|
|
1580
|
+
{t('staff.time_tracking.entryDialog.overlap.show', 'Show that entry')}
|
|
1581
|
+
</Button>
|
|
1582
|
+
{overlap.endText ? (
|
|
1583
|
+
<Button
|
|
1584
|
+
type="button"
|
|
1585
|
+
variant="outline"
|
|
1586
|
+
size="sm"
|
|
1587
|
+
disabled={locked}
|
|
1588
|
+
onClick={() =>
|
|
1589
|
+
setIntervalState((current) =>
|
|
1590
|
+
reduceIntervalState(current, { field: 'snapStart', value: overlap.endText }),
|
|
1591
|
+
)
|
|
1592
|
+
}
|
|
1593
|
+
data-testid="entry-dialog-overlap-snap"
|
|
1594
|
+
>
|
|
1595
|
+
{t('staff.time_tracking.entryDialog.overlap.snap', 'Snap start to {time}', {
|
|
1596
|
+
time: overlap.endText,
|
|
1597
|
+
})}
|
|
1598
|
+
</Button>
|
|
1599
|
+
) : null}
|
|
1600
|
+
</div>
|
|
1601
|
+
</Alert>
|
|
1602
|
+
) : null}
|
|
1603
|
+
|
|
1604
|
+
<Separator />
|
|
1605
|
+
|
|
1606
|
+
<div className="flex items-center gap-3">
|
|
1607
|
+
<Switch
|
|
1608
|
+
checked={isBillable}
|
|
1609
|
+
disabled={locked}
|
|
1610
|
+
onCheckedChange={(next) => setIsBillable(next)}
|
|
1611
|
+
aria-label={t('staff.time_tracking.entryDialog.billable', 'Billable')}
|
|
1612
|
+
data-testid="entry-dialog-billable"
|
|
1613
|
+
/>
|
|
1614
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
1615
|
+
<span className="text-sm font-medium text-foreground">
|
|
1616
|
+
{t('staff.time_tracking.entryDialog.billable', 'Billable')}
|
|
1617
|
+
</span>
|
|
1618
|
+
<span className="text-xs text-muted-foreground">
|
|
1619
|
+
{t('staff.time_tracking.entryDialog.billableHint', 'On by default for this project.')}
|
|
1620
|
+
</span>
|
|
1621
|
+
</div>
|
|
1622
|
+
{canSeeMoney && rateLabel ? (
|
|
1623
|
+
<Badge variant="success" data-testid="entry-dialog-rate-badge">
|
|
1624
|
+
{t('staff.time_tracking.entryDialog.ratePerHour', '{amount}/h', { amount: rateLabel })}
|
|
1625
|
+
</Badge>
|
|
1626
|
+
) : null}
|
|
1627
|
+
</div>
|
|
1628
|
+
|
|
1629
|
+
{canSeeMoney ? (
|
|
1630
|
+
<>
|
|
1631
|
+
<div className="flex items-center gap-3">
|
|
1632
|
+
<Switch
|
|
1633
|
+
checked={rateOverrideEnabled}
|
|
1634
|
+
disabled={locked}
|
|
1635
|
+
onCheckedChange={(next) => setRateOverrideEnabled(next)}
|
|
1636
|
+
aria-label={t('staff.time_tracking.entryDialog.rateOverride', 'Custom rate for this entry')}
|
|
1637
|
+
data-testid="entry-dialog-rate-override"
|
|
1638
|
+
/>
|
|
1639
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
1640
|
+
<span className="text-sm font-medium text-foreground">
|
|
1641
|
+
{t('staff.time_tracking.entryDialog.rateOverride', 'Custom rate for this entry')}
|
|
1642
|
+
</span>
|
|
1643
|
+
<span className="text-xs text-muted-foreground">
|
|
1644
|
+
{t(
|
|
1645
|
+
'staff.time_tracking.entryDialog.rateOverrideHint',
|
|
1646
|
+
'Overrides the project rate here only.',
|
|
1647
|
+
)}
|
|
1648
|
+
</span>
|
|
1649
|
+
</div>
|
|
1650
|
+
</div>
|
|
1651
|
+
|
|
1652
|
+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
|
1653
|
+
{rateOverrideEnabled ? (
|
|
1654
|
+
<div className="flex flex-col gap-1.5">
|
|
1655
|
+
<Label htmlFor="entry-dialog-rate">
|
|
1656
|
+
{t('staff.time_tracking.entryDialog.rate', 'Rate')}
|
|
1657
|
+
</Label>
|
|
1658
|
+
<Input
|
|
1659
|
+
id="entry-dialog-rate"
|
|
1660
|
+
value={rateText}
|
|
1661
|
+
disabled={locked}
|
|
1662
|
+
inputMode="decimal"
|
|
1663
|
+
onChange={(event) => setRateText(event.target.value)}
|
|
1664
|
+
data-testid="entry-dialog-rate"
|
|
1665
|
+
/>
|
|
1666
|
+
{projectRateLabel ? (
|
|
1667
|
+
<p className="text-xs text-muted-foreground">
|
|
1668
|
+
{t(
|
|
1669
|
+
'staff.time_tracking.entryDialog.rateHint',
|
|
1670
|
+
'Defaults to {amount} (the project rate).',
|
|
1671
|
+
{ amount: projectRateLabel },
|
|
1672
|
+
)}
|
|
1673
|
+
</p>
|
|
1674
|
+
) : null}
|
|
1675
|
+
</div>
|
|
1676
|
+
) : null}
|
|
1677
|
+
<div className="flex flex-col gap-1.5">
|
|
1678
|
+
<Label htmlFor="entry-dialog-cost" className="flex items-center gap-2">
|
|
1679
|
+
{t('staff.time_tracking.entryDialog.cost', 'Cost')}
|
|
1680
|
+
{computedBadge('cost')}
|
|
1681
|
+
</Label>
|
|
1682
|
+
<Input
|
|
1683
|
+
id="entry-dialog-cost"
|
|
1684
|
+
readOnly
|
|
1685
|
+
disabled
|
|
1686
|
+
value={cost === null ? '' : formatCurrency(cost, currencyCode) ?? String(cost)}
|
|
1687
|
+
inputClassName="font-mono tabular-nums"
|
|
1688
|
+
data-testid="entry-dialog-cost"
|
|
1689
|
+
/>
|
|
1690
|
+
{rateLabel && durationMinutes ? (
|
|
1691
|
+
<p className="text-xs text-muted-foreground" data-testid="entry-dialog-cost-hint">
|
|
1692
|
+
{t(
|
|
1693
|
+
'staff.time_tracking.entryDialog.costHint',
|
|
1694
|
+
'{raw} rounded to {rounded} × {rate}',
|
|
1695
|
+
{
|
|
1696
|
+
raw: formatDuration(durationMinutes, 'clock'),
|
|
1697
|
+
rounded: formatDuration(roundedMinutes, 'clock'),
|
|
1698
|
+
rate: rateLabel,
|
|
1699
|
+
},
|
|
1700
|
+
)}
|
|
1701
|
+
</p>
|
|
1702
|
+
) : null}
|
|
1703
|
+
</div>
|
|
1704
|
+
</div>
|
|
1705
|
+
</>
|
|
1706
|
+
) : (
|
|
1707
|
+
<p className="text-xs text-muted-foreground" data-testid="entry-dialog-money-hidden">
|
|
1708
|
+
{t(
|
|
1709
|
+
'staff.time_tracking.entryDialog.moneyHidden',
|
|
1710
|
+
'Rates and cost are hidden — your permissions do not cover money on time entries.',
|
|
1711
|
+
)}
|
|
1712
|
+
</p>
|
|
1713
|
+
)}
|
|
1714
|
+
|
|
1715
|
+
<div className="flex flex-col gap-1.5">
|
|
1716
|
+
<span className="text-xs font-medium text-muted-foreground">
|
|
1717
|
+
{t('staff.time_tracking.entryDialog.tags', 'Tags')}
|
|
1718
|
+
</span>
|
|
1719
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
1720
|
+
{assignedTags.map((tag) => (
|
|
1721
|
+
<Badge
|
|
1722
|
+
key={tag.id}
|
|
1723
|
+
variant="neutral"
|
|
1724
|
+
className="gap-1"
|
|
1725
|
+
style={tagChipStyle(tag)}
|
|
1726
|
+
>
|
|
1727
|
+
{tag.label}
|
|
1728
|
+
{locked ? null : (
|
|
1729
|
+
<button
|
|
1730
|
+
type="button"
|
|
1731
|
+
aria-label={t('staff.time_tracking.entryDialog.removeTag', 'Remove tag {label}', {
|
|
1732
|
+
label: tag.label,
|
|
1733
|
+
})}
|
|
1734
|
+
onClick={() => setTagIds((current) => current.filter((id) => id !== tag.id))}
|
|
1735
|
+
>
|
|
1736
|
+
<X className="size-3" aria-hidden="true" />
|
|
1737
|
+
</button>
|
|
1738
|
+
)}
|
|
1739
|
+
</Badge>
|
|
1740
|
+
))}
|
|
1741
|
+
{!locked ? (
|
|
1742
|
+
<TagPicker
|
|
1743
|
+
available={availableTags}
|
|
1744
|
+
onAssign={(id) => setTagIds((current) => (current.includes(id) ? current : [...current, id]))}
|
|
1745
|
+
onCreate={createTag}
|
|
1746
|
+
labels={{
|
|
1747
|
+
add: t('staff.time_tracking.entryDialog.addTag', 'Add tag'),
|
|
1748
|
+
create: (name) =>
|
|
1749
|
+
t('staff.time_tracking.entryDialog.createTag', 'Create "{name}"', { name }),
|
|
1750
|
+
empty: t('staff.time_tracking.entryDialog.noTags', 'No matching tag'),
|
|
1751
|
+
}}
|
|
1752
|
+
/>
|
|
1753
|
+
) : null}
|
|
1754
|
+
</div>
|
|
1755
|
+
</div>
|
|
1756
|
+
|
|
1757
|
+
<InjectionSpot
|
|
1758
|
+
spotId={extensionSpotChildId(extensionPoints.hosts.timeEntryForm.spotId, 'fields')}
|
|
1759
|
+
context={entryInjectionContext}
|
|
1760
|
+
data={entryFormValues}
|
|
1761
|
+
/>
|
|
1762
|
+
|
|
1763
|
+
<Alert status="information" data-testid="entry-dialog-creator">
|
|
1764
|
+
<AlertDescription>
|
|
1765
|
+
{selfQuery.data?.displayName
|
|
1766
|
+
? t(
|
|
1767
|
+
'staff.time_tracking.entryDialog.creator',
|
|
1768
|
+
'Entry author: {name} — assigned automatically and not editable.',
|
|
1769
|
+
{ name: selfQuery.data.displayName },
|
|
1770
|
+
)
|
|
1771
|
+
: t(
|
|
1772
|
+
'staff.time_tracking.entryDialog.creatorUnknown',
|
|
1773
|
+
'The entry author is assigned automatically and is not editable.',
|
|
1774
|
+
)}
|
|
1775
|
+
</AlertDescription>
|
|
1776
|
+
</Alert>
|
|
1777
|
+
|
|
1778
|
+
<InjectionSpot
|
|
1779
|
+
spotId={extensionSpotChildId(extensionPoints.hosts.timeEntryForm.spotId, 'after-fields')}
|
|
1780
|
+
context={entryInjectionContext}
|
|
1781
|
+
data={entryFormValues}
|
|
1782
|
+
/>
|
|
1783
|
+
</div>
|
|
1784
|
+
)
|
|
1785
|
+
})()
|
|
1786
|
+
|
|
1787
|
+
return (
|
|
1788
|
+
<Dialog
|
|
1789
|
+
open={open}
|
|
1790
|
+
// Escape, the ×, and a click on the overlay all arrive here, which is why
|
|
1791
|
+
// the unsaved-work guard sits on this one callback rather than on each of
|
|
1792
|
+
// them. Radix keeps the dialog mounted until `open` actually flips, so
|
|
1793
|
+
// declining the prompt simply leaves the form where it was.
|
|
1794
|
+
onOpenChange={(next) => {
|
|
1795
|
+
if (next) {
|
|
1796
|
+
onOpenChange(true)
|
|
1797
|
+
return
|
|
1798
|
+
}
|
|
1799
|
+
void requestClose()
|
|
1800
|
+
}}
|
|
1801
|
+
>
|
|
1802
|
+
<DialogContent
|
|
1803
|
+
size="lg"
|
|
1804
|
+
data-testid="entry-dialog"
|
|
1805
|
+
onOpenAutoFocus={(event) => {
|
|
1806
|
+
// A locked entry has nothing to type into, so the default focus (the
|
|
1807
|
+
// close button) is the right one there.
|
|
1808
|
+
if (locked || !taskTriggerRef.current) return
|
|
1809
|
+
event.preventDefault()
|
|
1810
|
+
focusTaskPicker()
|
|
1811
|
+
}}
|
|
1812
|
+
onKeyDown={(event) => {
|
|
1813
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
|
1814
|
+
event.preventDefault()
|
|
1815
|
+
// Shift keeps the dialog open for the next entry, so a run of entries
|
|
1816
|
+
// never needs the pointer.
|
|
1817
|
+
void submit(event.shiftKey ? 'again' : 'close')
|
|
1818
|
+
}
|
|
1819
|
+
// Escape is deliberately not handled here. Radix's `DismissableLayer`
|
|
1820
|
+
// already dismisses only the topmost layer, so a picker popover open
|
|
1821
|
+
// inside the form consumes the key on its own; a handler here closed
|
|
1822
|
+
// the whole dialog regardless of what was open on top of it.
|
|
1823
|
+
}}
|
|
1824
|
+
>
|
|
1825
|
+
<DialogHeader>
|
|
1826
|
+
<DialogTitle>
|
|
1827
|
+
{isEdit
|
|
1828
|
+
? t('staff.time_tracking.entryDialog.titleEdit', 'Time entry')
|
|
1829
|
+
: t('staff.time_tracking.entryDialog.titleCreate', 'New time entry')}
|
|
1830
|
+
</DialogTitle>
|
|
1831
|
+
{headerHint ? <span className="text-xs text-muted-foreground">{headerHint}</span> : null}
|
|
1832
|
+
</DialogHeader>
|
|
1833
|
+
|
|
1834
|
+
{body}
|
|
1835
|
+
|
|
1836
|
+
<DialogFooter>
|
|
1837
|
+
{/*
|
|
1838
|
+
* The labels are flex items, so without `whitespace-nowrap` "save and
|
|
1839
|
+
* add another" shrinks into a four-line column rather than staying on
|
|
1840
|
+
* the line. Hidden below `sm` as well: on a narrow dialog the hint
|
|
1841
|
+
* competes with the buttons for room it does not deserve, and a
|
|
1842
|
+
* keyboard hint is useless on a touch device anyway.
|
|
1843
|
+
*/}
|
|
1844
|
+
<span className="mr-auto hidden items-center gap-1 whitespace-nowrap text-xs text-muted-foreground sm:flex">
|
|
1845
|
+
<Kbd>⌘</Kbd>
|
|
1846
|
+
<Kbd>↵</Kbd>
|
|
1847
|
+
{t('staff.time_tracking.entryDialog.shortcutSave', 'save')}
|
|
1848
|
+
{locked ? null : (
|
|
1849
|
+
<>
|
|
1850
|
+
<span aria-hidden="true">·</span>
|
|
1851
|
+
<Kbd>⌘</Kbd>
|
|
1852
|
+
<Kbd>⇧</Kbd>
|
|
1853
|
+
<Kbd>↵</Kbd>
|
|
1854
|
+
{t('staff.time_tracking.entryDialog.shortcutSaveAgain', 'save and add another')}
|
|
1855
|
+
</>
|
|
1856
|
+
)}
|
|
1857
|
+
<span aria-hidden="true">·</span>
|
|
1858
|
+
<Kbd>esc</Kbd>
|
|
1859
|
+
{t('staff.time_tracking.entryDialog.shortcutCancel', 'cancel')}
|
|
1860
|
+
</span>
|
|
1861
|
+
<InjectionSpot
|
|
1862
|
+
spotId={extensionSpotChildId(extensionPoints.hosts.timeEntryForm.spotId, 'footer')}
|
|
1863
|
+
context={entryInjectionContext}
|
|
1864
|
+
data={entryFormValues}
|
|
1865
|
+
/>
|
|
1866
|
+
<Button type="button" variant="ghost" onClick={() => { void requestClose() }}>
|
|
1867
|
+
{locked
|
|
1868
|
+
? t('staff.time_tracking.entryDialog.close', 'Close')
|
|
1869
|
+
: t('staff.time_tracking.entryDialog.cancel', 'Cancel')}
|
|
1870
|
+
</Button>
|
|
1871
|
+
{locked ? null : (
|
|
1872
|
+
<>
|
|
1873
|
+
<Button
|
|
1874
|
+
type="button"
|
|
1875
|
+
variant="outline"
|
|
1876
|
+
disabled={!canSubmit}
|
|
1877
|
+
onClick={() => void submit('again')}
|
|
1878
|
+
data-testid="entry-dialog-save-again"
|
|
1879
|
+
>
|
|
1880
|
+
{savingMode === 'again' ? <Spinner className="size-4" /> : null}
|
|
1881
|
+
{t('staff.time_tracking.entryDialog.saveAndNew', 'Save and add another')}
|
|
1882
|
+
</Button>
|
|
1883
|
+
<Button
|
|
1884
|
+
type="button"
|
|
1885
|
+
disabled={!canSubmit}
|
|
1886
|
+
onClick={() => void submit('close')}
|
|
1887
|
+
data-testid="entry-dialog-save"
|
|
1888
|
+
>
|
|
1889
|
+
{savingMode === 'close' ? <Spinner className="size-4" /> : null}
|
|
1890
|
+
{t('staff.time_tracking.entryDialog.save', 'Save')}
|
|
1891
|
+
</Button>
|
|
1892
|
+
</>
|
|
1893
|
+
)}
|
|
1894
|
+
</DialogFooter>
|
|
1895
|
+
{ConfirmDialogElement}
|
|
1896
|
+
</DialogContent>
|
|
1897
|
+
</Dialog>
|
|
1898
|
+
)
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
const timeEntryDialogPropsSchema: z.ZodType<TimeEntryDialogProps> = z.object({
|
|
1902
|
+
open: z.boolean(),
|
|
1903
|
+
onOpenChange: callbackProp<(open: boolean) => void>(),
|
|
1904
|
+
entryId: z.string().nullable().optional(),
|
|
1905
|
+
defaults: opaqueProp<TimeEntryDialogDefaults>().optional(),
|
|
1906
|
+
headerHint: z.string().nullable().optional(),
|
|
1907
|
+
onSaved: optionalCallbackProp<(result: { id: string | null; keptOpen: boolean }) => void>(),
|
|
1908
|
+
onShowEntry: optionalCallbackProp<(entryId: string) => void>(),
|
|
1909
|
+
})
|
|
1910
|
+
|
|
1911
|
+
registerComponent<TimeEntryDialogProps>({
|
|
1912
|
+
id: extensionPoints.hosts.timeEntryDialogComponent.componentId,
|
|
1913
|
+
component: DefaultTimeEntryDialog,
|
|
1914
|
+
metadata: {
|
|
1915
|
+
module: 'staff',
|
|
1916
|
+
description: 'Create/edit dialog for a single time entry.',
|
|
1917
|
+
propsSchema: timeEntryDialogPropsSchema,
|
|
1918
|
+
},
|
|
1919
|
+
})
|
|
1920
|
+
|
|
1921
|
+
export function TimeEntryDialog(props: TimeEntryDialogProps) {
|
|
1922
|
+
const Resolved = useRegisteredComponent<TimeEntryDialogProps>(
|
|
1923
|
+
extensionPoints.hosts.timeEntryDialogComponent.componentId,
|
|
1924
|
+
DefaultTimeEntryDialog,
|
|
1925
|
+
)
|
|
1926
|
+
return <Resolved {...props} />
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
export default TimeEntryDialog
|