@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/staff/lib/timesheets-ui/TimerBar.js +21 -34
- package/dist/modules/staff/lib/timesheets-ui/TimerBar.js.map +2 -2
- package/dist/modules/staff/lib/timesheets-ui/useActiveTimesheetTimer.js +117 -0
- package/dist/modules/staff/lib/timesheets-ui/useActiveTimesheetTimer.js.map +7 -0
- package/dist/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.js +58 -68
- package/dist/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.js.map +2 -2
- package/dist/modules/staff/widgets/injection/timer-sidebar-indicator/widget.js +5 -73
- package/dist/modules/staff/widgets/injection/timer-sidebar-indicator/widget.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/staff/lib/timesheets-ui/TimerBar.tsx +20 -39
- package/src/modules/staff/lib/timesheets-ui/useActiveTimesheetTimer.ts +160 -0
- package/src/modules/staff/widgets/dashboard/timesheets-time-reporting/widget.client.tsx +65 -89
- package/src/modules/staff/widgets/injection/timer-sidebar-indicator/widget.tsx +5 -87
|
@@ -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
|
-
//
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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 (!
|
|
154
|
-
|
|
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
|
-
}, [
|
|
132
|
+
}, [timerRunning, timerStartedAt])
|
|
159
133
|
|
|
160
134
|
const handleStart = React.useCallback(async () => {
|
|
161
135
|
if (!selectedProjectId || !staffMemberId) return
|
|
162
|
-
if (
|
|
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
|
|
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,
|
|
172
|
+
}, [selectedProjectId, staffMemberId, timerRunning, notes, runMutation, retryLastMutation, hydrated, onSettingsChange, refreshActiveTimer, t])
|
|
199
173
|
|
|
200
174
|
const handleStop = React.useCallback(async () => {
|
|
201
|
-
if (!
|
|
175
|
+
if (!activeEntryId || !staffMemberId) return
|
|
202
176
|
setActionLoading(true)
|
|
203
177
|
try {
|
|
204
178
|
const stopPayload = {
|
|
205
|
-
id:
|
|
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/${
|
|
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:
|
|
189
|
+
resourceId: activeEntryId,
|
|
216
190
|
staffMemberId,
|
|
217
191
|
action: 'timer-stop',
|
|
218
192
|
retryLastMutation,
|
|
219
193
|
},
|
|
220
194
|
mutationPayload: stopPayload,
|
|
221
195
|
})
|
|
222
|
-
await
|
|
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
|
-
}, [
|
|
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">
|
|
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 (
|
|
269
|
-
const runningProject = projects.find((p) => p.id ===
|
|
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
|
-
|
|
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
|