@open-mercato/core 0.6.6-develop.6451.1.5b071f47fc → 0.6.6-develop.6453.1.6cbc2d5d47

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.
@@ -6,6 +6,7 @@ import { apiCall, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/ap
6
6
  import { Button } from '@open-mercato/ui/primitives/button'
7
7
  import { Input } from '@open-mercato/ui/primitives/input'
8
8
  import { useT } from '@open-mercato/shared/lib/i18n/context'
9
+ import { useActiveTimesheetTimer } from '../../../lib/timesheets-ui/useActiveTimesheetTimer'
9
10
  import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
10
11
  import { startTimerEntry } from '../../../lib/timesheets-ui/startTimer'
11
12
  import { resolveTimerActionError } from '../../../lib/timesheets-ui/timerErrors'
@@ -13,13 +14,6 @@ import { DEFAULT_SETTINGS, hydrateSettings, type TimeReportingSettings } from '.
13
14
 
14
15
  type ProjectOption = { id: string; name: string; code: string | null }
15
16
 
16
- type TimerState = {
17
- entryId: string | null
18
- running: boolean
19
- startedAt: string | null
20
- projectId: string | null
21
- }
22
-
23
17
  const TIMER_WIDGET_MUTATION_CONTEXT_ID = 'staff-timesheets-time-reporting-widget'
24
18
 
25
19
  type TimerWidgetMutationContext = {
@@ -57,84 +51,56 @@ const TimeReportingWidget: React.FC<DashboardWidgetComponentProps<TimeReportingS
57
51
  const [projects, setProjects] = React.useState<ProjectOption[]>([])
58
52
  const [selectedProjectId, setSelectedProjectId] = React.useState<string | null>(hydrated.lastProjectId)
59
53
  const [notes, setNotes] = React.useState('')
60
- const [timer, setTimer] = React.useState<TimerState>({ entryId: null, running: false, startedAt: null, projectId: null })
61
54
  const [elapsed, setElapsed] = React.useState('00:00:00')
62
- const [staffMemberId, setStaffMemberId] = React.useState<string | null>(null)
63
55
  const [loading, setLoading] = React.useState(true)
64
56
  const [actionLoading, setActionLoading] = React.useState(false)
65
57
  const [error, setError] = React.useState<string | null>(null)
58
+ const didLoadRef = React.useRef(false)
59
+ const activeTimer = useActiveTimesheetTimer()
60
+ const {
61
+ staffMemberId,
62
+ entryId: activeEntryId,
63
+ running: timerRunning,
64
+ startedAt: timerStartedAt,
65
+ projectId: activeProjectId,
66
+ projectName: activeProjectName,
67
+ isLoading: activeTimerLoading,
68
+ error: activeTimerError,
69
+ refresh: refreshActiveTimer,
70
+ } = activeTimer
66
71
 
67
72
  const loadState = React.useCallback(async () => {
68
73
  onRefreshStateChange?.(true)
69
74
  setLoading(true)
70
75
  setError(null)
71
76
  try {
72
- // Assignments and the current user's staff member are independent — fetch them together.
73
- const [assignmentsRes, selfRes] = await Promise.all([
74
- readApiResultOrThrow<{ items?: Array<Record<string, unknown>> }>(
75
- '/api/staff/timesheets/my-projects?pageSize=100',
76
- undefined,
77
- { errorMessage: '', fallback: { items: [] } },
78
- ),
79
- readApiResultOrThrow<{ member?: { id: string } | null }>(
80
- '/api/staff/team-members/self',
81
- undefined,
82
- { errorMessage: '', fallback: { member: null } },
83
- ),
84
- ])
85
-
77
+ // Load assigned projects
78
+ const assignmentsRes = await readApiResultOrThrow<{ items?: Array<Record<string, unknown>> }>(
79
+ '/api/staff/timesheets/my-projects?pageSize=100',
80
+ undefined,
81
+ { errorMessage: '', fallback: { items: [] } },
82
+ )
86
83
  const assignmentItems = Array.isArray(assignmentsRes.items) ? assignmentsRes.items : []
87
84
  const projectIds = assignmentItems
88
85
  .map((item) => String(item.time_project_id ?? item.timeProjectId ?? ''))
89
86
  .filter((id) => id.length > 0)
90
87
 
91
- const memberId = selfRes.member?.id ?? null
92
- setStaffMemberId(memberId)
93
-
94
- // Project details depend on the assignment ids and the active timer depends on the staff
95
- // member id, but the two requests are independent of each other — fetch them together.
96
- const [projectsRes, entriesRes] = await Promise.all([
97
- projectIds.length > 0
98
- ? readApiResultOrThrow<{ items?: Array<Record<string, unknown>> }>(
99
- `/api/staff/timesheets/time-projects?ids=${projectIds.join(',')}&pageSize=100`,
100
- undefined,
101
- { errorMessage: '', fallback: { items: [] } },
102
- )
103
- : Promise.resolve<{ items?: Array<Record<string, unknown>> }>({ items: [] }),
104
- memberId
105
- ? readApiResultOrThrow<{ items?: Array<Record<string, unknown>> }>(
106
- `/api/staff/timesheets/time-entries?staffMemberId=${memberId}&running=true&pageSize=100`,
107
- undefined,
108
- { errorMessage: '', fallback: { items: [] } },
109
- )
110
- : Promise.resolve<{ items?: Array<Record<string, unknown>> }>({ items: [] }),
111
- ])
112
-
113
- const projectItems = Array.isArray(projectsRes.items) ? projectsRes.items : []
114
- setProjects(projectItems.map((item) => ({
115
- id: String(item.id ?? ''),
116
- name: String(item.name ?? ''),
117
- code: typeof item.code === 'string' ? item.code : null,
118
- })))
119
-
120
- if (memberId) {
121
- const entries = Array.isArray(entriesRes.items) ? entriesRes.items : []
122
- const running = entries.find((entry) => {
123
- const startedAt = entry.started_at ?? entry.startedAt
124
- const endedAt = entry.ended_at ?? entry.endedAt
125
- return startedAt != null && endedAt == null
126
- })
127
- if (running) {
128
- setTimer({
129
- entryId: String(running.id ?? ''),
130
- running: true,
131
- startedAt: String(running.started_at ?? running.startedAt ?? ''),
132
- projectId: String(running.time_project_id ?? running.timeProjectId ?? ''),
133
- })
134
- } else {
135
- setTimer({ entryId: null, running: false, startedAt: null, projectId: null })
136
- }
88
+ if (projectIds.length > 0) {
89
+ const projectsRes = await readApiResultOrThrow<{ items?: Array<Record<string, unknown>> }>(
90
+ `/api/staff/timesheets/time-projects?ids=${projectIds.join(',')}&pageSize=100`,
91
+ undefined,
92
+ { errorMessage: '', fallback: { items: [] } },
93
+ )
94
+ const items = Array.isArray(projectsRes.items) ? projectsRes.items : []
95
+ setProjects(items.map((item) => ({
96
+ id: String(item.id ?? ''),
97
+ name: String(item.name ?? ''),
98
+ code: typeof item.code === 'string' ? item.code : null,
99
+ })))
100
+ } else {
101
+ setProjects([])
137
102
  }
103
+
138
104
  } catch (err) {
139
105
  console.error('staff.timesheets.timeReporting.load', err)
140
106
  setError(t('staff.timesheets.widgets.timeReporting.error', 'Failed to load timer state'))
@@ -145,21 +111,29 @@ const TimeReportingWidget: React.FC<DashboardWidgetComponentProps<TimeReportingS
145
111
  }, [onRefreshStateChange, t])
146
112
 
147
113
  React.useEffect(() => {
148
- void loadState()
149
- }, [loadState, refreshToken])
114
+ if (!didLoadRef.current) {
115
+ didLoadRef.current = true
116
+ void loadState()
117
+ return
118
+ }
119
+ void Promise.allSettled([loadState(), refreshActiveTimer()])
120
+ }, [loadState, refreshActiveTimer, refreshToken])
150
121
 
151
122
  // Tick elapsed time
152
123
  React.useEffect(() => {
153
- if (!timer.running || !timer.startedAt) return
154
- const tick = () => setElapsed(formatElapsed(timer.startedAt!))
124
+ if (!timerRunning || !timerStartedAt) {
125
+ setElapsed('00:00:00')
126
+ return
127
+ }
128
+ const tick = () => setElapsed(formatElapsed(timerStartedAt))
155
129
  tick()
156
130
  const interval = setInterval(tick, 1000)
157
131
  return () => clearInterval(interval)
158
- }, [timer.running, timer.startedAt])
132
+ }, [timerRunning, timerStartedAt])
159
133
 
160
134
  const handleStart = React.useCallback(async () => {
161
135
  if (!selectedProjectId || !staffMemberId) return
162
- if (timer.running) return
136
+ if (timerRunning) return
163
137
  setActionLoading(true)
164
138
  try {
165
139
  const today = new Date().toISOString().slice(0, 10)
@@ -188,45 +162,45 @@ const TimeReportingWidget: React.FC<DashboardWidgetComponentProps<TimeReportingS
188
162
  })
189
163
 
190
164
  onSettingsChange({ ...hydrated, lastProjectId: selectedProjectId })
191
- await loadState()
165
+ await refreshActiveTimer()
192
166
  } catch (err) {
193
167
  console.error('staff.timesheets.timeReporting.start', err)
194
168
  setError(resolveTimerActionError(err, t('staff.timesheets.widgets.timeReporting.startError', 'Failed to start timer')))
195
169
  } finally {
196
170
  setActionLoading(false)
197
171
  }
198
- }, [selectedProjectId, staffMemberId, timer.running, notes, hydrated, onSettingsChange, loadState, runMutation, retryLastMutation, t])
172
+ }, [selectedProjectId, staffMemberId, timerRunning, notes, runMutation, retryLastMutation, hydrated, onSettingsChange, refreshActiveTimer, t])
199
173
 
200
174
  const handleStop = React.useCallback(async () => {
201
- if (!timer.entryId || !staffMemberId) return
175
+ if (!activeEntryId || !staffMemberId) return
202
176
  setActionLoading(true)
203
177
  try {
204
178
  const stopPayload = {
205
- id: timer.entryId,
179
+ id: activeEntryId,
206
180
  action: 'timer-stop',
207
181
  staffMemberId,
208
182
  }
209
183
  await runMutation({
210
184
  operation: () =>
211
- apiCall(`/api/staff/timesheets/time-entries/${timer.entryId}/timer-stop`, { method: 'POST' }),
185
+ apiCall(`/api/staff/timesheets/time-entries/${activeEntryId}/timer-stop`, { method: 'POST' }),
212
186
  context: {
213
187
  formId: TIMER_WIDGET_MUTATION_CONTEXT_ID,
214
188
  resourceKind: 'staff.timesheets.time_entry',
215
- resourceId: timer.entryId,
189
+ resourceId: activeEntryId,
216
190
  staffMemberId,
217
191
  action: 'timer-stop',
218
192
  retryLastMutation,
219
193
  },
220
194
  mutationPayload: stopPayload,
221
195
  })
222
- await loadState()
196
+ await refreshActiveTimer()
223
197
  } catch (err) {
224
198
  console.error('staff.timesheets.timeReporting.stop', err)
225
199
  setError(resolveTimerActionError(err, t('staff.timesheets.widgets.timeReporting.stopError', 'Failed to stop timer')))
226
200
  } finally {
227
201
  setActionLoading(false)
228
202
  }
229
- }, [timer.entryId, staffMemberId, loadState, runMutation, retryLastMutation, t])
203
+ }, [activeEntryId, staffMemberId, runMutation, retryLastMutation, refreshActiveTimer, t])
230
204
 
231
205
  if (mode === 'settings') {
232
206
  return (
@@ -238,7 +212,7 @@ const TimeReportingWidget: React.FC<DashboardWidgetComponentProps<TimeReportingS
238
212
  )
239
213
  }
240
214
 
241
- if (loading) {
215
+ if (loading || activeTimerLoading) {
242
216
  return (
243
217
  <div className="flex h-full items-center justify-center py-8">
244
218
  <p className="text-sm text-muted-foreground">{t('staff.timesheets.widgets.timeReporting.loading', 'Loading...')}</p>
@@ -246,10 +220,12 @@ const TimeReportingWidget: React.FC<DashboardWidgetComponentProps<TimeReportingS
246
220
  )
247
221
  }
248
222
 
249
- if (error) {
223
+ if (error || activeTimerError) {
250
224
  return (
251
225
  <div className="flex h-full items-center justify-center py-8">
252
- <p role="alert" className="text-sm text-destructive">{error}</p>
226
+ <p role="alert" className="text-sm text-destructive">
227
+ {error ?? t('staff.timesheets.widgets.timeReporting.error', 'Failed to load timer state')}
228
+ </p>
253
229
  </div>
254
230
  )
255
231
  }
@@ -265,12 +241,12 @@ const TimeReportingWidget: React.FC<DashboardWidgetComponentProps<TimeReportingS
265
241
  }
266
242
 
267
243
  // Timer is running
268
- if (timer.running) {
269
- const runningProject = projects.find((p) => p.id === timer.projectId)
244
+ if (timerRunning) {
245
+ const runningProject = projects.find((p) => p.id === activeProjectId)
270
246
  return (
271
247
  <div className="space-y-3">
272
248
  <div className="text-sm text-muted-foreground">
273
- {runningProject?.name ?? t('staff.timesheets.widgets.timeReporting.unknownProject', 'Unknown project')}
249
+ {runningProject?.name ?? activeProjectName ?? t('staff.timesheets.widgets.timeReporting.unknownProject', 'Unknown project')}
274
250
  </div>
275
251
  <div className="text-center">
276
252
  <p className="font-mono text-3xl font-bold tabular-nums">{elapsed}</p>
@@ -1,36 +1,10 @@
1
1
  "use client"
2
2
 
3
3
  import * as React from 'react'
4
- import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
5
4
  import { useT } from '@open-mercato/shared/lib/i18n/context'
6
5
  import type { InjectionWidgetModule } from '@open-mercato/shared/modules/widgets/injection'
7
6
  import { ProjectColorDot } from '../../../lib/timesheets-ui/ProjectColorDot'
8
-
9
- type TimerState = {
10
- entryId: string
11
- projectName: string
12
- projectColor: string | null
13
- startedAt: string
14
- }
15
-
16
- const STORAGE_KEY = 'om:timesheets:activeTimer'
17
-
18
- function saveToSession(state: TimerState | null) {
19
- try {
20
- if (state) sessionStorage.setItem(STORAGE_KEY, JSON.stringify(state))
21
- else sessionStorage.removeItem(STORAGE_KEY)
22
- } catch { /* private browsing */ }
23
- }
24
-
25
- function loadFromSession(): TimerState | null {
26
- try {
27
- const raw = sessionStorage.getItem(STORAGE_KEY)
28
- if (!raw) return null
29
- const parsed = JSON.parse(raw)
30
- if (parsed?.startedAt && parsed?.entryId) return parsed as TimerState
31
- } catch { /* corrupt or private browsing */ }
32
- return null
33
- }
7
+ import { useActiveTimesheetTimer } from '../../../lib/timesheets-ui/useActiveTimesheetTimer'
34
8
 
35
9
  function formatElapsed(seconds: number): string {
36
10
  const h = Math.floor(seconds / 3600)
@@ -41,69 +15,13 @@ function formatElapsed(seconds: number): string {
41
15
 
42
16
  function TimerSidebarIndicator() {
43
17
  const t = useT()
44
- // Initialise from sessionStorage so the indicator doesn't flash away on navigation
45
- const [timer, setTimer] = React.useState<TimerState | null>(loadFromSession)
18
+ const timer = useActiveTimesheetTimer()
46
19
  const [elapsed, setElapsed] = React.useState(0)
47
20
  const intervalRef = React.useRef<ReturnType<typeof setInterval> | null>(null)
48
21
 
49
- // Wrap setTimer to also persist to sessionStorage
50
- const updateTimer = React.useCallback((next: TimerState | null) => {
51
- setTimer(next)
52
- saveToSession(next)
53
- }, [])
54
-
55
- const pollTimer = React.useCallback(async () => {
56
- try {
57
- const selfRes = await apiCall<{ member?: { id: string } | null }>('/api/staff/team-members/self')
58
- const memberId = selfRes.result?.member?.id
59
- if (!memberId) { updateTimer(null); return }
60
-
61
- const res = await apiCall<{ items?: Array<Record<string, unknown>> }>(
62
- `/api/staff/timesheets/time-entries?staffMemberId=${memberId}&running=true&pageSize=50`,
63
- )
64
- const items = (res.result?.items ?? []) as Array<Record<string, unknown>>
65
- const active = items.find((e) => e.started_at && !e.ended_at)
66
-
67
- if (!active) {
68
- updateTimer(null)
69
- return
70
- }
71
-
72
- const projectId = String(active.time_project_id ?? '')
73
- let projectName = ''
74
- let projectColor: string | null = null
75
- if (projectId) {
76
- const projRes = await apiCall<{ items?: Array<Record<string, unknown>> }>(
77
- `/api/staff/timesheets/time-projects?ids=${projectId}&pageSize=1`,
78
- )
79
- const proj = (projRes.result?.items ?? [])[0]
80
- if (proj) {
81
- projectName = String(proj.name ?? '')
82
- projectColor = typeof proj.color === 'string' ? proj.color : null
83
- }
84
- }
85
-
86
- updateTimer({
87
- entryId: String(active.id ?? ''),
88
- projectName,
89
- projectColor,
90
- startedAt: String(active.started_at ?? ''),
91
- })
92
- } catch {
93
- // Silent — don't crash the sidebar
94
- }
95
- }, [updateTimer])
96
-
97
- // Poll for active timer on mount + every 30s
98
- React.useEffect(() => {
99
- void pollTimer()
100
- const poll = setInterval(() => { void pollTimer() }, 30000)
101
- return () => clearInterval(poll)
102
- }, [pollTimer])
103
-
104
22
  // Tick the elapsed counter locally every second
105
23
  React.useEffect(() => {
106
- if (!timer) {
24
+ if (!timer.running || !timer.startedAt) {
107
25
  if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null }
108
26
  setElapsed(0)
109
27
  return
@@ -117,9 +35,9 @@ function TimerSidebarIndicator() {
117
35
  return () => {
118
36
  if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null }
119
37
  }
120
- }, [timer])
38
+ }, [timer.running, timer.startedAt])
121
39
 
122
- if (!timer) return null
40
+ if (!timer.running || !timer.entryId) return null
123
41
 
124
42
  return (
125
43
  <a